1use std::path::{Path, PathBuf};
34use std::sync::atomic::Ordering;
35use std::sync::{Arc, Mutex};
36
37use rpi_agent::AgentTool;
38use rpi_ai::Provider;
39use rpi_harness::agent_harness::AgentHarness;
40use rpi_harness::context_files::{format_project_context, load_project_context_files};
41use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
42use rpi_harness::session::session::DefaultIdGenerator;
43use rpi_harness::session::types::{BranchBounds, EntryQuery, SessionMetadata};
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47 AgentHarnessOptions, AgentHarnessResources, CompactionSettings, DrivingMode, HarnessTool,
48 HarnessToolExecution, RetryPolicy, ToolReplay,
49};
50use rpi_tools::{
51 create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
52 MutationQueueRegistry, OsExecutionEnv,
53};
54
55use crate::args::Args;
56use crate::extension_api::ExtensionBackend;
57use crate::provider::ResolvedModel;
58use crate::resource_dirs::{
59 discover_append_system_prompt_file_with_packages, discover_system_prompt_file_with_packages,
60 extension_dirs, global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
61 load_prompt_templates_with_precedence, load_skills_with_precedence,
62 project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
63};
64use rpi_extensions::{
65 emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
66 PluginDiagnostics, PluginToolAdapter, TeeEmitter,
67};
68
69pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write"];
71
72pub(crate) fn should_load_js_packages(args: &Args) -> bool {
75 args.enable_pi_packages && !args.no_extensions
76}
77
78pub(crate) fn package_resources_for(
82 args: &Args,
83 cwd: &Path,
84 project_trusted: bool,
85) -> crate::packages::PackageResources {
86 if should_load_js_packages(args) {
87 if crate::args::offline_mode_enabled(args.offline) {
88 if project_trusted {
89 crate::packages::resolve_offline_from_settings(cwd)
90 } else {
91 crate::packages::resolve_offline_from_global_settings(cwd)
92 }
93 } else if project_trusted {
94 crate::packages::resolve_from_settings(cwd)
95 } else {
96 crate::packages::resolve_from_global_settings(cwd)
97 }
98 } else {
99 crate::packages::PackageResources::default()
100 }
101}
102
103pub(crate) fn package_resources_for_update_check(
108 args: &Args,
109 cwd: &Path,
110 project_trusted: bool,
111) -> crate::packages::PackageResources {
112 if args.dev_local_only {
113 crate::packages::PackageResources::default()
114 } else if project_trusted {
115 crate::packages::discover_from_settings(cwd)
116 } else {
117 crate::packages::discover_from_global_settings(cwd)
118 }
119}
120
121pub fn default_system_prompt(cwd: &str) -> String {
124 format!(
125 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
126You help users by reading files, executing commands, editing code, and writing new files.
127
128Available tools:
129- read — Read file contents
130- bash — Execute shell commands
131- edit — Find/replace edits to existing files
132- write — Create or overwrite files
133
134Guidelines:
135- Be concise in your responses
136- Show file paths clearly when working with files
137- Prefer the smallest change that solves the problem
138- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
139
140Current working directory: {cwd}"
141 )
142}
143
144#[derive(Debug, Clone)]
151pub enum SessionSelection {
152 Ephemeral,
154 New { dir: PathBuf, name: Option<String> },
156 Latest,
158 ById { id: String },
161 ByExactId { id: String },
163 Fork { source: String },
166}
167
168pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
170 if args.no_session {
171 return SessionSelection::Ephemeral;
172 }
173 if args.continue_session || args.resume {
174 return SessionSelection::Latest;
176 }
177 if let Some(s) = &args.fork {
178 return SessionSelection::Fork { source: s.clone() };
179 }
180 if let Some(s) = &args.session_id {
181 return SessionSelection::ByExactId { id: s.clone() };
182 }
183 if let Some(s) = &args.session {
184 return SessionSelection::ById { id: s.clone() };
185 }
186 let dir = args
187 .session_dir
188 .clone()
189 .unwrap_or_else(|| default_session_dir(cwd));
190 SessionSelection::New {
191 dir,
192 name: args.name.clone(),
193 }
194}
195
196pub fn default_session_dir(cwd: &Path) -> PathBuf {
200 let preferred = cwd.join(".rpi").join("sessions");
201 let legacy = cwd.join(".pi").join("sessions");
202 if preferred.exists() || !legacy.exists() {
203 preferred
204 } else {
205 legacy
206 }
207}
208
209pub async fn build(
227 resolved: &ResolvedModel,
228 args: &Args,
229 cwd: &Path,
230 project_trusted: bool,
231) -> Result<
232 (
233 AgentHarness,
234 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
235 ReloadContext,
236 ),
237 BuildError,
238> {
239 let cwd_str = cwd.to_string_lossy().to_string();
240 if !project_trusted && args.verbose {
241 eprintln!(
242 "warning: project is not trusted; local settings, resources, and discovered extensions are disabled (use --approve or /trust)"
243 );
244 }
245 let package_resources = if args.dev_local_only {
249 crate::packages::PackageResources::default()
250 } else {
251 package_resources_for(args, cwd, project_trusted)
252 };
253
254 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
269 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
270 })?;
271 let catalog = crate::provider::available_catalog(resolved);
272 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
273 catalog.clone(),
274 cwd.to_path_buf(),
275 runtime.clone(),
276 args.unknown_flags.clone(),
277 );
278 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
279 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
289 let action_bridge = rpi_extensions::ActionBridge::with_reload(
290 runtime.clone(),
291 host_arc,
292 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
293 );
294
295 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
297 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
298 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
299 let _registry = Arc::new(MutationQueueRegistry::new());
300 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
303
304 let tools = build_tools(&ctx, args);
305 let mut tools = tools;
306
307 let extension_session = if args.no_extensions {
315 ExtensionSession::none()
316 } else {
317 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
318 };
319 let js_extension_session = if !should_load_js_packages(args) {
320 None
321 } else {
322 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
323 let js_context = serde_json::json!({
324 "cwd": cwd_str,
325 "theme": resolved.theme.clone(),
326 "currentModel": resolved.model.clone(),
327 "models": catalog.clone(),
328 "thinkingLevel": resolved.thinking_level,
329 });
330 match crate::js_extensions::JsExtensionSession::load_with_context(
331 &paths,
332 args.verbose,
333 js_context,
334 ) {
335 Ok(session) => session,
336 Err(error) => {
337 eprintln!("warning: JS/TS extensions were not loaded: {error}");
338 None
339 }
340 }
341 };
342 if js_extension_session.is_some() {
343 eprintln!(
344 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
345 );
346 }
347 if let Some(session) = &js_extension_session {
348 if let Err(error) =
349 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
350 {
351 if args.verbose {
352 eprintln!("warning: JS provider runtime was not enabled: {error}");
353 }
354 }
355 }
356 if args.verbose {
357 if let Some(session) = &js_extension_session {
358 let info = session.backend_info();
359 eprintln!(
360 "JS extension backend: {} v{} ({})",
361 info.name,
362 info.api_version,
363 info.capability_names().join(", ")
364 );
365 }
366 if let Some(s) = extension_session.summary() {
367 eprintln!("extensions: {s}");
368 }
369 report_deferred_renderers(&extension_session);
370 }
371 merge_extension_tools(&mut tools, &extension_session, args);
372 if let Some(session) = &js_extension_session {
373 merge_js_extension_tools(&mut tools, session, args);
374 if args.verbose && !session.commands.is_empty() {
375 eprintln!("JS extension commands: {}", session.commands.join(", "));
376 }
377 }
378 let mut active = active_tool_names(&tools, args);
379 if let Some(session) = &js_extension_session {
384 let js_names = session.tool_names();
385 if let Some(js_active) = session.active_tools() {
386 active.retain(|name| !js_names.iter().any(|js| js == name));
387 active.extend(js_active.into_iter().filter(|name| {
388 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
389 }));
390 }
391 }
392 active = filter_active_tool_names(active, args);
393
394 let selection = select_session(args, cwd);
396 let session = build_session(&selection, &cwd_str).await?;
397 if let Some(js) = &js_extension_session {
398 let session_id = session
399 .get_metadata()
400 .await
401 .ok()
402 .map(|metadata| metadata.id);
403 let leaf_id = session.get_leaf_id().await.ok().flatten();
404 if let Some(session_id) = session_id {
405 let branch = session
406 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
407 .await
408 .ok()
409 .unwrap_or_default();
410 let branch_json =
411 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
412 let runtime_context = serde_json::json!({
413 "session": {
414 "id": session_id,
415 "leafId": leaf_id,
416 "branch": branch_json,
417 "entries": branch_json.clone(),
418 },
419 });
420 if let Err(error) = js.set_runtime_context(runtime_context) {
421 if args.verbose {
422 eprintln!("warning: could not sync JS session context: {error}");
423 }
424 }
425 }
426 }
427
428 let base_prompt = match args.system_prompt.as_deref() {
436 Some(explicit) => explicit.to_string(),
437 None if project_trusted => {
438 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
439 Some(path) => std::fs::read_to_string(&path)
440 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
441 None => default_system_prompt(&cwd_str),
442 }
443 }
444 None => default_system_prompt(&cwd_str),
445 };
446
447 let mut append_texts: Vec<String> = Vec::new();
454 for extra in &args.append_system_prompt {
455 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
456 append_texts.push(text);
457 }
458 if args.append_system_prompt.is_empty() {
459 if let Some(path) = project_trusted
460 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
461 .flatten()
462 {
463 if let Ok(text) = std::fs::read_to_string(&path) {
464 append_texts.push(text);
465 }
466 }
467 }
468 let append_join = if append_texts.is_empty() {
469 None
470 } else {
471 Some(append_texts.join("\n\n"))
472 };
473
474 let agent_dir = crate::config::agent_dir().ok();
486
487 let discovered = extension_session
503 .snapshot_arc()
504 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
505 .unwrap_or_default();
506
507 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
508 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
509 if !args.no_skills {
510 let mut dirs = if args.dev_local_only {
511 project_skill_dirs(cwd)
512 } else if project_trusted {
513 skill_dirs(cwd)
514 } else {
515 global_skill_dirs()
516 };
517 dirs.extend(args.skill.iter().cloned());
518 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
519 if let Some(session) = &js_extension_session {
520 dirs.extend(session.resources.skill_paths.iter().cloned());
521 }
522 dirs.extend(package_resources.skill_dirs());
523 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
524 skills = result.skills;
525 skill_diags = result.diagnostics;
526 }
527
528 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
529 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
530 if !args.no_prompt_templates {
531 let mut dirs = if args.dev_local_only {
532 project_prompt_template_dirs(cwd)
533 } else if project_trusted {
534 prompt_template_dirs(cwd)
535 } else {
536 global_prompt_template_dirs()
537 };
538 dirs.extend(args.prompt_template.iter().cloned());
539 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
540 if let Some(session) = &js_extension_session {
541 dirs.extend(session.resources.prompt_paths.iter().cloned());
542 }
543 dirs.extend(package_resources.prompt_dirs());
544 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
545 prompt_templates = result.prompt_templates;
546 prompt_diags = result.diagnostics;
547 }
548
549 let context_block = if args.no_context_files || !project_trusted {
550 String::new()
551 } else {
552 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
557 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
558 format_project_context(&files)
559 };
560
561 if args.verbose {
563 for d in &package_resources.diagnostics {
564 eprintln!("warning: package {}: {}", d.spec, d.message);
565 }
566 for d in &skill_diags {
567 eprintln!(
568 "warning: skill {} ({}): {}",
569 d.path,
570 d.code.as_str(),
571 d.message
572 );
573 }
574 for d in &prompt_diags {
575 eprintln!(
576 "warning: prompt template {} ({}): {}",
577 d.path,
578 d.code.as_str(),
579 d.message
580 );
581 }
582 }
583
584 let system_prompt = compose_system_prompt(
593 Some(&base_prompt),
594 &[], if context_block.is_empty() {
596 None
597 } else {
598 Some(&context_block)
599 },
600 append_join.as_deref(),
601 );
602
603 if args.debug_system_prompt {
612 eprintln!("=== --debug-system-prompt ===");
613 let base_src = if args.system_prompt.is_some() {
614 "--system-prompt"
615 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
616 "SYSTEM.md"
617 } else {
618 "default"
619 };
620 eprintln!("[base source: {base_src}]");
621 eprintln!("--- base ---\n{base_prompt}");
622 if let Some(append) = append_join.as_deref() {
623 eprintln!("--- append ---\n{append}");
624 } else {
625 eprintln!("--- append: (none) ---");
626 }
627 if context_block.is_empty() {
628 eprintln!("--- context: (none) ---");
629 } else {
630 eprintln!("--- context ---{context_block}");
631 }
632 let visible_skills = skills
633 .iter()
634 .filter(|s| s.disable_model_invocation != Some(true))
635 .count();
636 eprintln!(
637 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
638 skills.len(),
639 visible_skills,
640 skills.len() - visible_skills
641 );
642 for s in &skills {
643 let hidden = if s.disable_model_invocation == Some(true) {
644 " [hidden]"
645 } else {
646 ""
647 };
648 eprintln!(" {}{hidden} — {}", s.name, s.description);
649 }
650 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
651 for t in &prompt_templates {
652 eprintln!(" /{}", t.name);
653 }
654 eprintln!(
658 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
659 discovered.skill_paths.len(),
660 discovered.prompt_paths.len(),
661 discovered.theme_paths.len(),
662 );
663 for p in &discovered.skill_paths {
664 eprintln!(" skill: {p}");
665 }
666 for p in &discovered.prompt_paths {
667 eprintln!(" prompt: {p}");
668 }
669 eprintln!(
670 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
671 );
672 eprintln!("=== end --debug-system-prompt ===");
673 }
674
675 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
680 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
681 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
685
686 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
695 Some(snapshot) => {
696 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
697 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
698 }
699 None => broadcast_emitter,
700 };
701
702 let options = AgentHarnessOptions {
703 model: resolved.model.clone(),
704 thinking_level: resolved.thinking_level,
705 active_tool_names: active,
706 tools,
707 system_prompt: Some(system_prompt),
708 resources: AgentHarnessResources {
709 skills: if skills.is_empty() {
710 None
711 } else {
712 Some(skills)
713 },
714 prompt_templates: if prompt_templates.is_empty() {
715 None
716 } else {
717 Some(prompt_templates)
718 },
719 },
720 allow_existing_session: matches!(
723 selection,
724 SessionSelection::Latest
725 | SessionSelection::ById { .. }
726 | SessionSelection::ByExactId { .. }
727 | SessionSelection::Fork { .. }
728 ),
729 stream_options: Default::default(),
730 retry: RetryPolicy::default(),
731 compaction: CompactionSettings::default(),
732 steering_mode: Default::default(),
733 follow_up_mode: Default::default(),
734 tool_execution: HarnessToolExecution::default(),
735 drive: DrivingMode::default(),
736 session,
737 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
745 to_provider_messages: None,
746 entry_projectors: Default::default(),
747 agent_emitter: Some(emitter),
748 before_tool_call: None,
754 after_tool_call: None,
755 transform_context: None,
756 entry_transforms: Vec::new(),
757 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
763 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
764 };
765
766 let harness = match AgentHarness::create(options).await {
767 Ok(h) => {
768 crate::extensions_actions::HarnessActionHost::set_harness(
771 &harness_cell,
772 Arc::new(h.clone()),
773 );
774 h
775 }
776 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
777 };
778
779 let reload_context = ReloadContext {
786 extension_session: Arc::new(Mutex::new(extension_session)),
787 js_extension_session: js_extension_session.clone(),
788 package_resources: Arc::new(package_resources.clone()),
789 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
790 catalog,
791 gateway: resolved.provider.clone(),
792 runtime: runtime.clone(),
793 cwd: cwd.to_path_buf(),
794 project_trusted,
795 args: args.clone(),
796 resolved_model: resolved.model.clone(),
797 broadcast: broadcast_for_context,
798 mailbox: reload_mailbox,
799 dev_extension: None,
800 };
801
802 Ok((harness, event_rx, reload_context))
803}
804
805pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
850
851pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
856
857#[derive(Clone)]
865pub struct ReloadContext {
866 pub extension_session: ExtensionSessionCell,
868 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
870 pub package_resources: Arc<crate::packages::PackageResources>,
874 pub action_bridge: ActionBridgeCell,
876 pub catalog: Vec<rpi_ai::Model>,
881 pub gateway: Arc<dyn Provider>,
884 pub runtime: tokio::runtime::Handle,
888 pub cwd: PathBuf,
890 pub project_trusted: bool,
894 pub args: Args,
900 pub resolved_model: rpi_ai::Model,
903 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
909 pub mailbox: rpi_extensions::ReloadMailbox,
916 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
920}
921
922pub struct ReloadOutcome {
925 pub summary: String,
928 pub had_warnings: bool,
930}
931
932struct PreparedReloadInputs {
933 package_resources: crate::packages::PackageResources,
934 extension_dirs: Vec<PathBuf>,
935 skill_base_dirs: Vec<PathBuf>,
936 prompt_base_dirs: Vec<PathBuf>,
937}
938
939fn append_reload_resource_paths(
944 mut paths: Vec<PathBuf>,
945 explicit: &[PathBuf],
946 discovered: &[String],
947 js_paths: &[PathBuf],
948 package_paths: &[PathBuf],
949) -> Vec<PathBuf> {
950 paths.extend(explicit.iter().cloned());
951 paths.extend(discovered.iter().map(PathBuf::from));
952 paths.extend(js_paths.iter().cloned());
953 paths.extend(package_paths.iter().cloned());
954 paths
955}
956
957pub async fn reload_extension_resources(
969 harness: &AgentHarness,
970 ctx: &ReloadContext,
971) -> ReloadOutcome {
972 reload_extension_resources_inner(harness, ctx, || {}).await
973}
974
975async fn reload_extension_resources_inner<F>(
976 harness: &AgentHarness,
977 ctx: &ReloadContext,
978 after_prepare: F,
979) -> ReloadOutcome
980where
981 F: FnOnce() + Send,
982{
983 let mut effective_args = ctx.args.clone();
988 if let Some(dev) = &ctx.dev_extension {
989 if let Err(error) = dev
990 .rebuild()
991 .and_then(|_| dev.apply_to_args(&mut effective_args))
992 {
993 return ReloadOutcome {
994 summary: format!(
995 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
996 dev.package_name()
997 ),
998 had_warnings: true,
999 };
1000 }
1001 }
1002 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1003 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1004 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1005 Ok(prepared) => prepared,
1006 Err(error) => {
1007 return ReloadOutcome {
1008 summary: format!(
1009 "Settings reload failed: {error}. Keeping the currently loaded resources."
1010 ),
1011 had_warnings: true,
1012 };
1013 }
1014 };
1015 after_prepare();
1016 let PreparedReloadInputs {
1017 package_resources,
1018 extension_dirs,
1019 skill_base_dirs,
1020 prompt_base_dirs,
1021 } = prepared;
1022 let mut warnings = false;
1023
1024 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1031 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1032 Some(b) => b.clone_host(),
1033 None => {
1034 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1037 ctx.catalog.clone(),
1038 ctx.cwd.clone(),
1039 ctx.runtime.clone(),
1040 ctx.args.unknown_flags.clone(),
1041 );
1042 crate::extensions_actions::HarnessActionHost::set_harness(
1043 &_cell,
1044 Arc::new(harness.clone()),
1045 );
1046 Arc::new(action_host)
1047 }
1048 };
1049
1050 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1051 let fresh_bridge =
1052 rpi_extensions::ActionBridge::with_reload(ctx.runtime.clone(), host, reload_cb);
1053
1054 let extension_session = if effective_args.no_extensions {
1055 rpi_extensions::ExtensionSession::none()
1056 } else {
1057 load_extensions_from_dirs(
1058 &effective_args,
1059 &extension_dirs,
1060 Some(Arc::clone(&fresh_bridge)),
1061 )
1062 };
1063 if extension_session.is_empty() && !effective_args.no_extensions {
1064 }
1067 if effective_args.verbose {
1068 if let Some(s) = extension_session.summary() {
1069 eprintln!("reload: {s}");
1070 }
1071 report_deferred_renderers(&extension_session);
1072 }
1073
1074 {
1088 let mut session_guard = ctx.extension_session.lock().unwrap();
1089 let old_session = std::mem::replace(
1090 &mut *session_guard,
1091 rpi_extensions::ExtensionSession::none(),
1092 );
1093 if let Some(old_snap) = old_session.snapshot_arc() {
1094 old_snap.active_flag().store(false, Ordering::SeqCst);
1098 }
1099 }
1103 if let Some(old_b) = old_bridge {
1104 old_b.invalidate();
1105 }
1106
1107 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1110 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1111
1112 let discovered = extension_session
1114 .snapshot_arc()
1115 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1116 .unwrap_or_default();
1117
1118 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1120 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1121
1122 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1123 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1124 if !effective_args.no_skills {
1125 let js_paths: &[PathBuf] = ctx
1129 .js_extension_session
1130 .as_ref()
1131 .map(|js| js.resources.skill_paths.as_slice())
1132 .unwrap_or(&[]);
1133 let package_paths = package_resources.skill_dirs();
1134 let dirs = append_reload_resource_paths(
1135 skill_base_dirs,
1136 &effective_args.skill,
1137 &discovered.skill_paths,
1138 js_paths,
1139 &package_paths,
1140 );
1141 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1142 skills = result.skills;
1143 skill_diags = result.diagnostics;
1144 }
1145
1146 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1147 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1148 if !effective_args.no_prompt_templates {
1149 let js_paths: &[PathBuf] = ctx
1150 .js_extension_session
1151 .as_ref()
1152 .map(|js| js.resources.prompt_paths.as_slice())
1153 .unwrap_or(&[]);
1154 let package_paths = package_resources.prompt_dirs();
1155 let dirs = append_reload_resource_paths(
1156 prompt_base_dirs,
1157 &effective_args.prompt_template,
1158 &discovered.prompt_paths,
1159 js_paths,
1160 &package_paths,
1161 );
1162 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1163 prompt_templates = result.prompt_templates;
1164 prompt_diags = result.diagnostics;
1165 }
1166
1167 let context_block = if effective_args.no_context_files {
1168 String::new()
1169 } else {
1170 let agent_dir = crate::config::agent_dir().ok();
1171 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1172 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1173 format_project_context(&files)
1174 };
1175
1176 if !skill_diags.is_empty()
1177 || !prompt_diags.is_empty()
1178 || !package_resources.diagnostics.is_empty()
1179 {
1180 warnings = true;
1181 if effective_args.verbose {
1182 for d in &package_resources.diagnostics {
1183 eprintln!("warning: package {}: {}", d.spec, d.message);
1184 }
1185 for d in &skill_diags {
1186 eprintln!(
1187 "warning: skill {} ({}): {}",
1188 d.path,
1189 d.code.as_str(),
1190 d.message
1191 );
1192 }
1193 for d in &prompt_diags {
1194 eprintln!(
1195 "warning: prompt template {} ({}): {}",
1196 d.path,
1197 d.code.as_str(),
1198 d.message
1199 );
1200 }
1201 }
1202 }
1203
1204 let base_prompt = match effective_args.system_prompt.as_deref() {
1206 Some(explicit) => explicit.to_string(),
1207 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1208 Some(path) => {
1209 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1210 }
1211 None => default_system_prompt(&cwd_str),
1212 },
1213 };
1214 let mut append_texts: Vec<String> = Vec::new();
1215 for extra in &effective_args.append_system_prompt {
1216 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1217 append_texts.push(text);
1218 }
1219 if effective_args.append_system_prompt.is_empty() {
1220 if let Some(path) =
1221 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1222 {
1223 if let Ok(text) = std::fs::read_to_string(&path) {
1224 append_texts.push(text);
1225 }
1226 }
1227 }
1228 let append_join = if append_texts.is_empty() {
1229 None
1230 } else {
1231 Some(append_texts.join("\n\n"))
1232 };
1233 let system_prompt = compose_system_prompt(
1234 Some(&base_prompt),
1235 &[],
1236 if context_block.is_empty() {
1237 None
1238 } else {
1239 Some(&context_block)
1240 },
1241 append_join.as_deref(),
1242 );
1243
1244 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1246 Some(snapshot) => {
1247 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1248 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1249 }
1250 None => ctx.broadcast.clone(),
1251 };
1252
1253 let resources = AgentHarnessResources {
1255 skills: if skills.is_empty() {
1256 None
1257 } else {
1258 Some(skills.clone())
1259 },
1260 prompt_templates: if prompt_templates.is_empty() {
1261 None
1262 } else {
1263 Some(prompt_templates.clone())
1264 },
1265 };
1266 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1267 let _ = harness.set_resources(resources).await;
1268 let _ = harness.set_agent_emitter(Some(emitter)).await;
1269 let _ = harness
1270 .set_models(build_models_with_extensions_for_reload(
1271 &ctx.gateway,
1272 &extension_session,
1273 ctx.runtime.clone(),
1274 ))
1275 .await;
1276 let _ = harness
1277 .set_provider_hooks(
1278 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1279 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1280 )
1281 .await;
1282
1283 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1287 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1288 let mut tools = build_tools(&tool_ctx, &effective_args);
1289 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1290 if let Some(js) = &ctx.js_extension_session {
1291 merge_js_extension_tools(&mut tools, js, &effective_args);
1292 }
1293 let mut active = active_tool_names(&tools, &effective_args);
1294 if let Some(js) = &ctx.js_extension_session {
1295 let js_names = js.tool_names();
1296 if let Some(js_active) = js.active_tools() {
1297 active.retain(|name| {
1298 tool_name_allowed(name, &effective_args)
1299 && !js_names.iter().any(|js_name| js_name == name)
1300 });
1301 active.extend(js_active.into_iter().filter(|name| {
1302 js_names.iter().any(|js_name| js_name == name)
1303 && tool_name_allowed(name, &effective_args)
1304 }));
1305 }
1306 }
1307 active = filter_active_tool_names(active, &effective_args);
1308 let _ = harness.set_tools(tools, Some(active)).await;
1309
1310 let summary = format!(
1311 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1312 extension_session.loaded_paths().len(),
1313 skills.len(),
1314 prompt_templates.len(),
1315 );
1316 ReloadOutcome {
1317 summary,
1318 had_warnings: warnings,
1319 }
1320}
1321
1322#[derive(Clone, Debug, PartialEq, Eq)]
1323struct ReloadSettingsFields {
1324 packages: Option<Vec<crate::settings::PackageSetting>>,
1325 npm_command: Option<Vec<String>>,
1326 skill_dirs: Option<Vec<String>>,
1327 prompt_dirs: Option<Vec<String>>,
1328 extension_dirs: Option<Vec<String>>,
1329}
1330
1331impl From<crate::settings::Settings> for ReloadSettingsFields {
1332 fn from(settings: crate::settings::Settings) -> Self {
1333 Self {
1334 packages: settings.packages,
1335 npm_command: settings.npm_command,
1336 skill_dirs: settings.skill_dirs,
1337 prompt_dirs: settings.prompt_dirs,
1338 extension_dirs: settings.extension_dirs,
1339 }
1340 }
1341}
1342
1343#[derive(Clone, Debug, PartialEq, Eq)]
1344struct ReloadSettingsSnapshot {
1345 global: ReloadSettingsFields,
1346 project: Option<ReloadSettingsFields>,
1347}
1348
1349fn reload_reads_settings(args: &Args) -> bool {
1350 !args.dev_local_only
1351 && (should_load_js_packages(args)
1352 || !args.no_extensions
1353 || !args.no_skills
1354 || !args.no_prompt_templates)
1355}
1356
1357fn load_reload_settings_snapshot(
1361 args: &Args,
1362 cwd: &Path,
1363 project_trusted: bool,
1364) -> Result<Option<ReloadSettingsSnapshot>, String> {
1365 if !reload_reads_settings(args) {
1366 return Ok(None);
1367 }
1368 let global = crate::settings::load_settings()
1369 .map_err(|error| format!("could not load global settings: {error}"))?
1370 .into();
1371 let project = if project_trusted {
1372 crate::settings::load_active_project_settings(cwd)
1373 .map_err(|error| format!("could not load project settings: {error}"))?
1374 .map(|(_, settings)| settings.into())
1375 } else {
1376 None
1377 };
1378 Ok(Some(ReloadSettingsSnapshot { global, project }))
1379}
1380
1381fn validate_settings_for_reload(
1384 args: &Args,
1385 cwd: &Path,
1386 project_trusted: bool,
1387) -> Result<(), String> {
1388 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1389}
1390
1391fn prepare_reload_inputs(
1392 args: &Args,
1393 cwd: &Path,
1394 project_trusted: bool,
1395) -> Result<PreparedReloadInputs, String> {
1396 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1397}
1398
1399fn prepare_reload_inputs_inner<F>(
1400 args: &Args,
1401 cwd: &Path,
1402 project_trusted: bool,
1403 before_verify: F,
1404) -> Result<PreparedReloadInputs, String>
1405where
1406 F: FnOnce(),
1407{
1408 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1409
1410 let package_resources = if args.dev_local_only {
1411 crate::packages::PackageResources::default()
1412 } else {
1413 package_resources_for(args, cwd, project_trusted)
1414 };
1415
1416 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1417 Vec::new()
1418 } else if project_trusted {
1419 extension_dirs(cwd)
1420 } else {
1421 global_extension_dirs()
1422 };
1423 if !args.no_extensions {
1424 extension_dirs.extend(args.extensions_dir.iter().cloned());
1425 }
1426
1427 let skill_base_dirs = if args.no_skills {
1428 Vec::new()
1429 } else if args.dev_local_only {
1430 project_skill_dirs(cwd)
1431 } else if project_trusted {
1432 skill_dirs(cwd)
1433 } else {
1434 global_skill_dirs()
1435 };
1436
1437 let prompt_base_dirs = if args.no_prompt_templates {
1438 Vec::new()
1439 } else if args.dev_local_only {
1440 project_prompt_template_dirs(cwd)
1441 } else if project_trusted {
1442 prompt_template_dirs(cwd)
1443 } else {
1444 global_prompt_template_dirs()
1445 };
1446
1447 before_verify();
1448 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1449 if settings_before != settings_after {
1450 return Err(
1451 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1452 );
1453 }
1454
1455 Ok(PreparedReloadInputs {
1456 package_resources,
1457 extension_dirs,
1458 skill_base_dirs,
1459 prompt_base_dirs,
1460 })
1461}
1462
1463fn build_models_with_extensions_for_reload(
1468 gateway: &Arc<dyn Provider>,
1469 extension_session: &ExtensionSession,
1470 runtime: tokio::runtime::Handle,
1471) -> Vec<Arc<dyn Provider>> {
1472 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1473 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1474 models.extend(pluggable);
1475 models
1476}
1477
1478fn report_deferred_renderers(session: &ExtensionSession) {
1482 let Some(snap) = session.snapshot_arc() else {
1483 return;
1484 };
1485 let all = snap.renderers();
1486 let markdown = all
1487 .iter()
1488 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1489 .count();
1490 let message = all
1491 .iter()
1492 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1493 .count();
1494 let entry = all
1495 .iter()
1496 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1497 .count();
1498 if markdown + message + entry == 0 {
1499 return;
1500 }
1501 eprintln!(
1502 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1503 markdown, message, entry
1504 );
1505}
1506
1507#[derive(Debug, thiserror::Error)]
1509pub enum BuildError {
1510 #[error("Could not create the session directory: {0}")]
1511 SessionDir(String),
1512 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1513 SessionNotFound { requested: String, dir: String },
1514 #[error("Could not build the harness: {0}")]
1515 HarnessCreate(String),
1516}
1517
1518fn build_models_with_extensions(
1528 resolved: &ResolvedModel,
1529 extension_session: &ExtensionSession,
1530 runtime: tokio::runtime::Handle,
1531) -> Vec<Arc<dyn Provider>> {
1532 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1533 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1534 models.extend(pluggable);
1535 models
1536}
1537
1538pub fn project_has_local_resources(cwd: &Path) -> bool {
1542 const FILES: &[&str] = &[
1543 "settings.json",
1544 "SYSTEM.md",
1545 "APPEND_SYSTEM.md",
1546 "packages.json",
1547 ];
1548 const DIRS: &[&str] = &["skills", "prompts", "themes", "extensions", "packages"];
1549 [".rpi", ".pi"].iter().any(|layout| {
1550 let root = cwd.join(layout);
1551 FILES.iter().any(|name| root.join(name).is_file())
1552 || DIRS.iter().any(|name| root.join(name).is_dir())
1553 })
1554}
1555
1556pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1561 if let Some(override_value) = args.trust_override {
1562 return override_value;
1563 }
1564 crate::config::project_trust_decision(cwd)
1565 .ok()
1566 .flatten()
1567 .unwrap_or(false)
1568}
1569
1570fn load_extensions(
1578 args: &Args,
1579 cwd: &Path,
1580 project_trusted: bool,
1581 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1582) -> ExtensionSession {
1583 let mut dirs = if args.dev_local_only {
1584 Vec::new()
1585 } else if project_trusted {
1586 extension_dirs(cwd)
1587 } else {
1588 global_extension_dirs()
1589 };
1590 dirs.extend(args.extensions_dir.iter().cloned());
1591 load_extensions_from_dirs(args, &dirs, action_bridge)
1592}
1593
1594fn load_extensions_from_dirs(
1595 args: &Args,
1596 dirs: &[PathBuf],
1597 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1598) -> ExtensionSession {
1599 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1600 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1607}
1608
1609fn js_extension_paths(
1610 args: &Args,
1611 cwd: &Path,
1612 project_trusted: bool,
1613 packages: &crate::packages::PackageResources,
1614) -> Vec<PathBuf> {
1615 if args.dev_local_only {
1616 return Vec::new();
1617 }
1618 let mut paths = packages.extension_paths();
1619 let discovered_dirs = if project_trusted {
1620 extension_dirs(cwd)
1621 } else {
1622 global_extension_dirs()
1623 };
1624 for dir in discovered_dirs {
1625 if let Ok(entries) = std::fs::read_dir(dir) {
1626 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1627 matches!(
1628 path.extension()
1629 .and_then(|ext| ext.to_str())
1630 .map(|ext| ext.to_ascii_lowercase())
1631 .as_deref(),
1632 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1633 )
1634 }));
1635 }
1636 }
1637 paths.extend(
1638 args.extension
1639 .iter()
1640 .filter(|path| {
1641 matches!(
1642 path.extension()
1643 .and_then(|ext| ext.to_str())
1644 .map(|ext| ext.to_ascii_lowercase())
1645 .as_deref(),
1646 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1647 )
1648 })
1649 .cloned(),
1650 );
1651 paths
1652}
1653
1654fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1660 let Some(snapshot) = session.snapshot() else {
1661 return;
1662 };
1663 for et in snapshot.tools() {
1664 let name = &et.tool.name;
1665 if !tool_name_allowed(name, args) {
1666 continue;
1667 }
1668 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1669 let harness_tool = HarnessTool::new(Arc::new(adapter));
1670 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1671 Some(slot) => *slot = harness_tool,
1672 None => tools.push(harness_tool),
1673 }
1674 }
1675}
1676
1677fn merge_js_extension_tools(
1678 tools: &mut Vec<HarnessTool>,
1679 session: &crate::js_extensions::JsExtensionSession,
1680 args: &Args,
1681) {
1682 for adapter in session.tools() {
1683 let name = adapter.schema().name.clone();
1684 if !tool_name_allowed(&name, args) {
1685 continue;
1686 }
1687 let harness_tool = HarnessTool::new(Arc::new(adapter));
1688 match tools
1689 .iter_mut()
1690 .find(|tool| tool.tool.schema().name == name)
1691 {
1692 Some(slot) => *slot = harness_tool,
1693 None => tools.push(harness_tool),
1694 }
1695 }
1696}
1697
1698pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1705 use rpi_tools::tools::bash::BashToolOptions;
1706 let default = std::env::var("RPI_BASH_TIMEOUT")
1707 .ok()
1708 .and_then(|v| v.parse::<f64>().ok())
1709 .unwrap_or(120.0);
1710 BashToolOptions {
1711 command_prefix: None,
1712 default_timeout: Some(default),
1713 }
1714}
1715
1716fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1717 if args.no_tools {
1718 return Vec::new();
1719 }
1720 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1722 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1723 (
1724 "bash",
1725 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1726 ),
1727 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1728 ("write", HarnessTool::new(create_write_tool(ctx))),
1729 ];
1730
1731 if args.no_builtin_tools {
1735 all.clear();
1736 }
1737
1738 if let Some(allow) = &args.tools {
1740 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1741 }
1742 if let Some(deny) = &args.exclude_tools {
1744 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1745 }
1746
1747 all.into_iter()
1748 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1749 .collect()
1750}
1751
1752fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1756 filter_active_tool_names(
1757 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1758 args,
1759 )
1760}
1761
1762pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1766 if args.no_tools {
1767 return false;
1768 }
1769 if args
1770 .tools
1771 .as_ref()
1772 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1773 {
1774 return false;
1775 }
1776 if args
1777 .exclude_tools
1778 .as_ref()
1779 .is_some_and(|deny| deny.iter().any(|value| value == name))
1780 {
1781 return false;
1782 }
1783 true
1784}
1785
1786pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1787where
1788 I: IntoIterator<Item = String>,
1789{
1790 names
1791 .into_iter()
1792 .filter(|name| tool_name_allowed(name, args))
1793 .collect()
1794}
1795
1796async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1798 match selection {
1799 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1800 SessionSelection::New { dir, .. } => {
1801 std::fs::create_dir_all(dir)
1804 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1805 let session = create_jsonl_session(dir, cwd)
1806 .await
1807 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1808 Ok(session)
1809 }
1810 SessionSelection::Latest
1811 | SessionSelection::ById { .. }
1812 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1813 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1814 }
1815}
1816
1817async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1823 match selection {
1827 SessionSelection::Latest => {
1828 let metas = list_session_metadata(cwd).await?;
1829 let Some(meta) = metas.first() else {
1830 return Err(BuildError::SessionNotFound {
1831 requested: "the most recent session".to_string(),
1832 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1833 });
1834 };
1835 open_session(meta, cwd).await
1836 }
1837 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1838 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1839 requested,
1840 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1841 },
1842 OpenError::Other(msg) => BuildError::SessionDir(msg),
1843 }),
1844 SessionSelection::ByExactId { id } => {
1845 let metas = list_session_metadata(cwd).await?;
1848 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1849 return open_session(meta, cwd).await;
1850 }
1851 let dir = default_session_dir(Path::new(cwd));
1852 std::fs::create_dir_all(&dir)
1853 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1854 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1855 .await
1856 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1857 }
1858 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1859 }
1860}
1861
1862async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1865 use rpi_harness::session::jsonl::{
1866 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1867 };
1868 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1869 use rpi_tools::FileSystem;
1870
1871 let dir = default_session_dir(Path::new(cwd));
1872 std::fs::create_dir_all(&dir)
1873 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1874 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1875 let fs: Arc<dyn FileSystem> = env.clone();
1876 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1877 fs: fs.clone(),
1878 sessions_root: dir.to_string_lossy().into_owned(),
1879 clock: Arc::new(SystemClock),
1880 ids: Arc::new(DefaultIdGenerator::new()),
1881 });
1882 let metas = repo
1883 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1884 .await
1885 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1886 let source_meta = metas
1887 .iter()
1888 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1889 .ok_or_else(|| BuildError::SessionNotFound {
1890 requested: format!("--fork {source}"),
1891 dir: dir.display().to_string(),
1892 })?;
1893 let fork_storage = repo
1894 .fork_typed(
1895 source_meta,
1896 &JsonlSessionCreateOptions {
1897 id: None,
1898 parent_session_id: Some(source_meta.id.clone()),
1899 cwd: cwd.to_string(),
1900 metadata: None,
1901 },
1902 &ForkOptions::default(),
1903 )
1904 .await
1905 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1906 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1907 Ok(Session::new(storage_arc, None))
1908}
1909
1910pub enum OpenError {
1913 NotFound { requested: String },
1915 Other(String),
1917}
1918
1919impl std::fmt::Display for OpenError {
1920 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1921 match self {
1922 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1923 OpenError::Other(msg) => write!(f, "{msg}"),
1924 }
1925 }
1926}
1927
1928pub async fn list_session_metadata(
1931 cwd: &str,
1932) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1933 use rpi_harness::session::jsonl::{
1934 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1935 };
1936 use rpi_tools::FileSystem;
1937
1938 let dir = default_session_dir(Path::new(cwd));
1939 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1940 let fs: Arc<dyn FileSystem> = env.clone();
1941 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1942 fs: fs.clone(),
1943 sessions_root: dir.to_string_lossy().into_owned(),
1944 clock: Arc::new(SystemClock),
1945 ids: Arc::new(DefaultIdGenerator::new()),
1946 });
1947 repo.list_typed(&JsonlSessionListOptions::default())
1948 .await
1949 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1950}
1951
1952pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1956 let metas = list_session_metadata(cwd)
1957 .await
1958 .map_err(|e| OpenError::Other(e.to_string()))?;
1959 let Some(meta) = metas
1960 .iter()
1961 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1962 else {
1963 return Err(OpenError::NotFound {
1964 requested: format!("session {id}"),
1965 });
1966 };
1967 open_session(meta, cwd)
1968 .await
1969 .map_err(|e| OpenError::Other(e.to_string()))
1970}
1971
1972pub(crate) async fn fork_session_storage(
1979 harness: &AgentHarness,
1980 cwd: &str,
1981) -> Result<Session, String> {
1982 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1983 use rpi_tools::FileSystem;
1984
1985 let dir = default_session_dir(Path::new(cwd));
1986 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1987 let fs: Arc<dyn FileSystem> = env.clone();
1988 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1989 fs,
1990 sessions_root: dir.to_string_lossy().into_owned(),
1991 clock: Arc::new(SystemClock),
1992 ids: Arc::new(DefaultIdGenerator::new()),
1993 });
1994 let id = harness.session().storage().metadata().id.clone();
1997 let metas = list_session_metadata(cwd)
1998 .await
1999 .map_err(|e| e.to_string())?;
2000 let Some(source) = metas.iter().find(|m| m.id == id) else {
2001 return Err(format!("current session {id} not found on disk"));
2002 };
2003 let fork_storage = repo
2004 .fork_typed(
2005 source,
2006 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2007 id: None,
2008 parent_session_id: Some(source.id.clone()),
2009 cwd: cwd.to_string(),
2010 metadata: None,
2011 },
2012 &rpi_harness::session::types::ForkOptions::default(),
2013 )
2014 .await
2015 .map_err(|e| e.to_string())?;
2016 Ok(Session::new(Arc::new(fork_storage), None))
2017}
2018
2019async fn open_session(
2022 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2023 cwd: &str,
2024) -> Result<Session, BuildError> {
2025 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2026 use rpi_harness::session::types::SessionStorage;
2027 use rpi_tools::FileSystem;
2028
2029 let dir = default_session_dir(Path::new(cwd));
2030 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2031 let fs: Arc<dyn FileSystem> = env.clone();
2032 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2033 fs: fs.clone(),
2034 sessions_root: dir.to_string_lossy().into_owned(),
2035 clock: Arc::new(SystemClock),
2036 ids: Arc::new(DefaultIdGenerator::new()),
2037 });
2038 let storage = repo
2039 .open_by_jsonl_metadata(meta)
2040 .await
2041 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2042 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2043 Ok(Session::new(storage_arc, None))
2044}
2045
2046fn ephemeral_session() -> Session {
2048 let storage = Arc::new(InMemorySessionStorage::new(
2049 SessionMetadata {
2050 id: "ephemeral".into(),
2051 created_at: 0,
2052 parent_session_id: None,
2053 },
2054 Arc::new(SystemClock),
2055 Arc::new(DefaultIdGenerator::new()),
2056 ));
2057 Session::new(storage, None)
2058}
2059
2060pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2071 create_jsonl_session_with_id(dir, cwd, None).await
2072}
2073
2074pub(crate) async fn create_jsonl_session_with_id(
2078 dir: &Path,
2079 cwd: &str,
2080 id: Option<String>,
2081) -> Result<Session, String> {
2082 use rpi_harness::session::jsonl::{
2083 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2084 };
2085 use rpi_tools::FileSystem;
2086
2087 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2090 let fs: Arc<dyn FileSystem> = env.clone();
2091
2092 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2093 fs: fs.clone(),
2094 sessions_root: dir.to_string_lossy().into_owned(),
2095 clock: Arc::new(SystemClock),
2096 ids: Arc::new(DefaultIdGenerator::new()),
2097 });
2098
2099 let opts = JsonlSessionCreateOptions {
2100 id, parent_session_id: None,
2102 cwd: cwd.to_string(),
2103 metadata: None,
2104 };
2105 let storage = repo
2106 .create_typed(&opts)
2107 .await
2108 .map_err(|e| format!("create session: {e}"))?;
2109 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2111 Ok(Session::new(storage_arc, None))
2112}
2113
2114fn read_append_target(target: &str) -> Option<String> {
2117 let path = Path::new(target);
2118 if path.is_file() {
2119 std::fs::read_to_string(path).ok()
2120 } else {
2121 None
2122 }
2123}
2124
2125#[cfg(test)]
2126mod tests {
2127 use super::*;
2128 use crate::args::Args;
2129
2130 #[test]
2131 fn default_prompt_mentions_cwd_and_tools() {
2132 let p = default_system_prompt("/tmp/proj");
2133 assert!(p.contains("/tmp/proj"));
2134 assert!(p.contains("read"));
2135 assert!(p.contains("bash"));
2136 assert!(p.contains("edit"));
2137 assert!(p.contains("write"));
2138 assert!(!p.contains("- grep"));
2139 assert!(!p.contains("- find"));
2140 assert!(!p.contains("- ls"));
2141 assert!(!p.contains("powershell"));
2142 }
2143
2144 #[test]
2145 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2146 let args = Args::default();
2147 assert!(!should_load_js_packages(&args));
2148 let resources = package_resources_for(&args, Path::new("."), false);
2149 assert!(resources.packages.is_empty());
2150
2151 let args = Args {
2152 enable_pi_packages: true,
2153 ..Args::default()
2154 };
2155 assert!(should_load_js_packages(&args));
2156
2157 let args = Args {
2158 enable_pi_packages: true,
2159 no_extensions: true,
2160 ..Args::default()
2161 };
2162 assert!(!should_load_js_packages(&args));
2163 assert!(package_resources_for(&args, Path::new("."), false)
2164 .packages
2165 .is_empty());
2166 }
2167
2168 #[test]
2169 fn pi_offline_env_disables_startup_package_remediation() {
2170 struct RestoreEnv {
2171 name: &'static str,
2172 value: Option<std::ffi::OsString>,
2173 }
2174
2175 impl Drop for RestoreEnv {
2176 fn drop(&mut self) {
2177 match self.value.take() {
2178 Some(value) => std::env::set_var(self.name, value),
2179 None => std::env::remove_var(self.name),
2180 }
2181 }
2182 }
2183
2184 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2185 let _restore_config = RestoreEnv {
2186 name: crate::config::CONFIG_DIR_ENV,
2187 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2188 };
2189 let _restore_offline = RestoreEnv {
2190 name: crate::args::PI_OFFLINE_ENV,
2191 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2192 };
2193 let tmp = tempfile::tempdir().unwrap();
2194 let agent = tmp.path().join("agent");
2195 let cwd = tmp.path().join("project");
2196 let package = agent.join("npm/node_modules/demo");
2197 std::fs::create_dir_all(package.join("extensions")).unwrap();
2198 std::fs::create_dir_all(&cwd).unwrap();
2199 std::fs::write(
2200 package.join("package.json"),
2201 r#"{"name":"demo","version":"1.0.0"}"#,
2202 )
2203 .unwrap();
2204 std::fs::write(
2205 package.join("extensions/index.js"),
2206 "export default () => {};",
2207 )
2208 .unwrap();
2209 std::fs::write(
2210 agent.join("settings.json"),
2211 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2212 )
2213 .unwrap();
2214 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2215 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2216 let args = Args {
2217 enable_pi_packages: true,
2218 offline: false,
2219 ..Args::default()
2220 };
2221
2222 let resources = package_resources_for(&args, &cwd, false);
2223
2224 assert!(resources.packages.is_empty());
2225 assert_eq!(resources.diagnostics.len(), 1);
2226 assert!(resources.diagnostics[0].message.contains("offline"));
2227 assert_eq!(
2228 std::fs::read(package.join("package.json")).unwrap(),
2229 br#"{"name":"demo","version":"1.0.0"}"#
2230 );
2231 }
2232
2233 #[test]
2234 fn package_discovery_uses_the_callers_trust_snapshot() {
2235 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2236 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2237 let tmp = tempfile::tempdir().unwrap();
2238 let agent = tmp.path().join("agent");
2239 let cwd = tmp.path().join("project");
2240 let package = cwd.join("package");
2241 std::fs::create_dir_all(&agent).unwrap();
2242 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2243 std::fs::create_dir_all(&package).unwrap();
2244 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2245 std::fs::write(
2246 package.join("package.json"),
2247 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2248 )
2249 .unwrap();
2250 std::fs::write(
2251 cwd.join(".rpi/settings.json"),
2252 serde_json::json!({"packages": [package]}).to_string(),
2253 )
2254 .unwrap();
2255 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2256
2257 let args = Args {
2258 enable_pi_packages: true,
2259 ..Args::default()
2260 };
2261 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2262 assert!(package_resources_for(&args, &cwd, false)
2263 .packages
2264 .is_empty());
2265 assert_eq!(
2266 package_resources_for_update_check(&args, &cwd, true)
2267 .packages
2268 .len(),
2269 1
2270 );
2271 assert!(package_resources_for_update_check(&args, &cwd, false)
2272 .packages
2273 .is_empty());
2274
2275 match previous {
2276 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2277 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2278 }
2279 }
2280
2281 #[test]
2282 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2283 struct RestoreConfigDir(Option<std::ffi::OsString>);
2284
2285 impl Drop for RestoreConfigDir {
2286 fn drop(&mut self) {
2287 match self.0.take() {
2288 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2289 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2290 }
2291 }
2292 }
2293
2294 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2295 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2296 let tmp = tempfile::tempdir().unwrap();
2297 let agent = tmp.path().join("agent");
2298 let cwd = tmp.path().join("project");
2299 std::fs::create_dir_all(&agent).unwrap();
2300 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2301 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2302 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2303 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2304 let packages_disabled = Args::default();
2305 let packages_enabled = Args {
2306 enable_pi_packages: true,
2307 ..Args::default()
2308 };
2309
2310 for args in [&packages_disabled, &packages_enabled] {
2311 let trusted = validate_settings_for_reload(args, &cwd, true);
2312 let untrusted = validate_settings_for_reload(args, &cwd, false);
2313 assert!(trusted
2314 .unwrap_err()
2315 .contains("could not load project settings"));
2316 assert!(untrusted.is_ok());
2317 }
2318
2319 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2320 for args in [&packages_disabled, &packages_enabled] {
2321 assert!(validate_settings_for_reload(args, &cwd, false)
2322 .unwrap_err()
2323 .contains("could not load global settings"));
2324 }
2325
2326 let local_only = Args {
2327 dev_local_only: true,
2328 ..Args::default()
2329 };
2330 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2331 }
2332
2333 #[tokio::test(flavor = "current_thread")]
2334 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2335 struct RestoreConfigDir(Option<std::ffi::OsString>);
2336
2337 impl Drop for RestoreConfigDir {
2338 fn drop(&mut self) {
2339 match self.0.take() {
2340 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2341 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2342 }
2343 }
2344 }
2345
2346 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2347 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2348 let tmp = tempfile::tempdir().unwrap();
2349 let agent = tmp.path().join("agent");
2350 let cwd = tmp.path().join("project");
2351 let skill_dir = tmp.path().join("configured-skills");
2352 std::fs::create_dir_all(&agent).unwrap();
2353 std::fs::create_dir_all(&cwd).unwrap();
2354 std::fs::create_dir_all(&skill_dir).unwrap();
2355 std::fs::write(
2356 skill_dir.join("SKILL.md"),
2357 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2358 )
2359 .unwrap();
2360 std::fs::write(
2361 agent.join("settings.json"),
2362 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2363 )
2364 .unwrap();
2365 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2366
2367 let resolved = crate::provider::resolve(
2368 Some("anthropic"),
2369 Some(crate::provider::DEFAULT_MODEL_ID),
2370 None,
2371 Some("test-key"),
2372 None,
2373 )
2374 .unwrap();
2375 let args = Args {
2376 trust_override: Some(false),
2377 no_session: true,
2378 no_extensions: true,
2379 no_prompt_templates: true,
2380 no_context_files: true,
2381 system_prompt: Some("stable system prompt".into()),
2382 ..Args::default()
2383 };
2384 assert!(!should_load_js_packages(&args));
2385
2386 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2387 let before_resources = harness.get_resources().await.unwrap();
2388 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2389 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2390 let before_prompt = harness.get_system_prompt().await.unwrap();
2391 let before_tools: Vec<String> = harness
2392 .get_tools()
2393 .await
2394 .unwrap()
2395 .iter()
2396 .map(|tool| tool.tool.schema().name.clone())
2397 .collect();
2398 let before_bridge = context
2399 .action_bridge
2400 .lock()
2401 .unwrap()
2402 .as_ref()
2403 .unwrap()
2404 .clone();
2405
2406 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2407 let outcome = reload_extension_resources(&harness, &context).await;
2408
2409 assert!(outcome.had_warnings);
2410 assert!(outcome.summary.contains("Settings reload failed"));
2411 assert!(outcome.summary.contains("could not load global settings"));
2412 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2413 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2414 let after_tools: Vec<String> = harness
2415 .get_tools()
2416 .await
2417 .unwrap()
2418 .iter()
2419 .map(|tool| tool.tool.schema().name.clone())
2420 .collect();
2421 assert_eq!(after_tools, before_tools);
2422 let after_bridge = context
2423 .action_bridge
2424 .lock()
2425 .unwrap()
2426 .as_ref()
2427 .unwrap()
2428 .clone();
2429 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2430 }
2431
2432 #[test]
2433 fn reload_preparation_rejects_settings_changed_during_derivation() {
2434 struct RestoreConfigDir(Option<std::ffi::OsString>);
2435
2436 impl Drop for RestoreConfigDir {
2437 fn drop(&mut self) {
2438 match self.0.take() {
2439 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2440 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2441 }
2442 }
2443 }
2444
2445 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2446 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2447 let tmp = tempfile::tempdir().unwrap();
2448 let agent = tmp.path().join("agent");
2449 let cwd = tmp.path().join("project");
2450 let first_skill_dir = tmp.path().join("first-skills");
2451 let second_skill_dir = tmp.path().join("second-skills");
2452 std::fs::create_dir_all(&agent).unwrap();
2453 std::fs::create_dir_all(&cwd).unwrap();
2454 std::fs::write(
2455 agent.join("settings.json"),
2456 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2457 )
2458 .unwrap();
2459 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2460 let args = Args {
2461 trust_override: Some(false),
2462 no_extensions: true,
2463 no_prompt_templates: true,
2464 ..Args::default()
2465 };
2466
2467 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2468 std::fs::write(
2469 agent.join("settings.json"),
2470 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2471 )
2472 .unwrap();
2473 });
2474
2475 let error = result
2476 .err()
2477 .expect("settings mutation must fail preparation");
2478 assert!(error.contains("settings changed while reload inputs were being prepared"));
2479 }
2480
2481 #[tokio::test(flavor = "current_thread")]
2482 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2483 struct RestoreConfigDir(Option<std::ffi::OsString>);
2484
2485 impl Drop for RestoreConfigDir {
2486 fn drop(&mut self) {
2487 match self.0.take() {
2488 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2489 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2490 }
2491 }
2492 }
2493
2494 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2495 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2496 let tmp = tempfile::tempdir().unwrap();
2497 let agent = tmp.path().join("agent");
2498 let cwd = tmp.path().join("project");
2499 let skill_dir = tmp.path().join("configured-skills");
2500 std::fs::create_dir_all(&agent).unwrap();
2501 std::fs::create_dir_all(&cwd).unwrap();
2502 std::fs::create_dir_all(&skill_dir).unwrap();
2503 std::fs::write(
2504 skill_dir.join("SKILL.md"),
2505 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2506 )
2507 .unwrap();
2508 std::fs::write(
2509 agent.join("settings.json"),
2510 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2511 )
2512 .unwrap();
2513 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2514
2515 let resolved = crate::provider::resolve(
2516 Some("anthropic"),
2517 Some(crate::provider::DEFAULT_MODEL_ID),
2518 None,
2519 Some("test-key"),
2520 None,
2521 )
2522 .unwrap();
2523 let args = Args {
2524 trust_override: Some(false),
2525 no_session: true,
2526 no_extensions: true,
2527 no_prompt_templates: true,
2528 no_context_files: true,
2529 system_prompt: Some("stable system prompt".into()),
2530 ..Args::default()
2531 };
2532 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2533
2534 let outcome = reload_extension_resources_inner(&harness, &context, || {
2535 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2536 })
2537 .await;
2538
2539 assert!(!outcome.summary.contains("Settings reload failed"));
2540 assert_eq!(
2541 outcome.summary,
2542 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2543 );
2544 let resources = harness.get_resources().await.unwrap();
2545 let skills = resources.skills.as_ref().unwrap();
2546 assert_eq!(skills.len(), 1);
2547 assert_eq!(skills[0].name, "frozen-skill");
2548 }
2549
2550 #[tokio::test]
2551 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2552 let tmp = tempfile::tempdir().unwrap();
2553 let skill_path = tmp.path().join("explicit-skill.md");
2554 std::fs::write(
2555 &skill_path,
2556 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2557 )
2558 .unwrap();
2559 let prompt_path = tmp.path().join("explicit-prompt.md");
2560 std::fs::write(
2561 &prompt_path,
2562 "---\ndescription: Explicit prompt\n---\nPrompt body",
2563 )
2564 .unwrap();
2565
2566 let args = Args {
2567 skill: vec![skill_path.clone()],
2568 prompt_template: vec![prompt_path.clone()],
2569 ..Args::default()
2570 };
2571 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2572 let prompt_paths =
2573 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2574 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2575 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2576
2577 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2578 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2579 assert_eq!(skills.skills[0].name, "explicit-skill");
2580
2581 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2582 assert_eq!(
2583 prompts.prompt_templates.len(),
2584 1,
2585 "{:?}",
2586 prompts.diagnostics
2587 );
2588 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2589 }
2590
2591 #[test]
2592 fn tool_policy_applies_to_rust_and_js_active_names() {
2593 let names = vec![
2594 "read".to_string(),
2595 "ask_user_question".to_string(),
2596 "write".to_string(),
2597 ];
2598
2599 let args = Args {
2600 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2601 ..Args::default()
2602 };
2603 assert_eq!(
2604 filter_active_tool_names(names.clone(), &args),
2605 vec!["read", "ask_user_question"]
2606 );
2607
2608 let args = Args {
2609 exclude_tools: Some(vec!["ask_user_question".into()]),
2610 ..Args::default()
2611 };
2612 assert_eq!(
2613 filter_active_tool_names(names.clone(), &args),
2614 vec!["read", "write"]
2615 );
2616
2617 let args = Args {
2618 no_tools: true,
2619 ..Args::default()
2620 };
2621 assert!(filter_active_tool_names(names, &args).is_empty());
2622 }
2623
2624 #[test]
2625 fn select_ephemeral_when_no_session() {
2626 let args = Args {
2627 no_session: true,
2628 ..Args::default()
2629 };
2630 let cwd = Path::new("/tmp");
2631 assert!(matches!(
2632 select_session(&args, cwd),
2633 SessionSelection::Ephemeral
2634 ));
2635 }
2636
2637 #[test]
2638 fn project_trust_override_fails_closed_by_default() {
2639 let denied = Args::default();
2640 assert!(!resolve_project_trust(
2641 &denied,
2642 Path::new("C:/definitely-not-a-project")
2643 ));
2644
2645 let approved = Args {
2646 trust_override: Some(true),
2647 ..Args::default()
2648 };
2649 assert!(resolve_project_trust(
2650 &approved,
2651 Path::new("C:/definitely-not-a-project")
2652 ));
2653 }
2654
2655 #[tokio::test(flavor = "current_thread")]
2656 async fn build_preserves_the_supplied_startup_project_snapshot() {
2657 struct RestoreConfigDir(Option<std::ffi::OsString>);
2658
2659 impl Drop for RestoreConfigDir {
2660 fn drop(&mut self) {
2661 match self.0.take() {
2662 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2663 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2664 }
2665 }
2666 }
2667
2668 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2669 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2670 let tmp = tempfile::tempdir().unwrap();
2671 let agent = tmp.path().join("agent");
2672 let cwd = tmp.path().join("project");
2673 std::fs::create_dir_all(&agent).unwrap();
2674 std::fs::create_dir_all(&cwd).unwrap();
2675 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2676
2677 let resolved = crate::provider::resolve(
2678 Some("anthropic"),
2679 Some(crate::provider::DEFAULT_MODEL_ID),
2680 None,
2681 Some("test-key"),
2682 None,
2683 )
2684 .unwrap();
2685 let args = Args {
2686 trust_override: Some(false),
2687 no_session: true,
2688 no_tools: true,
2689 no_extensions: true,
2690 no_skills: true,
2691 no_prompt_templates: true,
2692 no_context_files: true,
2693 system_prompt: Some("test prompt".into()),
2694 ..Args::default()
2695 };
2696
2697 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2698
2699 assert_eq!(context.cwd, cwd);
2700 assert!(context.project_trusted);
2701 }
2702
2703 #[test]
2704 fn project_resource_probe_ignores_session_directory_but_detects_config() {
2705 let root = tempfile::tempdir().unwrap();
2706 let cwd = root.path();
2707 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2708 assert!(!project_has_local_resources(cwd));
2709 std::fs::write(cwd.join(".rpi/settings.json"), "{}").unwrap();
2710 assert!(project_has_local_resources(cwd));
2711 }
2712
2713 #[test]
2714 fn select_latest_for_continue_and_resume() {
2715 let args = Args {
2716 continue_session: true,
2717 ..Args::default()
2718 };
2719 let cwd = Path::new("/tmp");
2720 assert!(matches!(
2721 select_session(&args, cwd),
2722 SessionSelection::Latest
2723 ));
2724
2725 let args = Args {
2726 resume: true,
2727 ..Args::default()
2728 };
2729 assert!(matches!(
2730 select_session(&args, cwd),
2731 SessionSelection::Latest
2732 ));
2733 }
2734
2735 #[test]
2736 fn select_by_id_for_session_flag() {
2737 let args = Args {
2738 session: Some("01a02ece".into()),
2739 ..Args::default()
2740 };
2741 let cwd = Path::new("/tmp");
2742 assert!(matches!(
2743 select_session(&args, cwd),
2744 SessionSelection::ById { id } if id == "01a02ece"
2745 ));
2746 }
2747
2748 #[test]
2749 fn select_new_with_custom_dir() {
2750 let args = Args {
2751 session_dir: Some(PathBuf::from("/tmp/sess")),
2752 ..Args::default()
2753 };
2754 let cwd = Path::new("/tmp");
2755 match select_session(&args, cwd) {
2756 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2757 other => panic!("expected New, got {other:?}"),
2758 }
2759 }
2760
2761 #[test]
2762 fn select_new_default_dir() {
2763 let args = Args::default();
2764 let cwd = Path::new("/proj");
2765 match select_session(&args, cwd) {
2766 SessionSelection::New { dir, .. } => {
2767 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2768 }
2769 other => panic!("expected New, got {other:?}"),
2770 }
2771 }
2772
2773 #[test]
2774 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2775 let tmp = tempfile::tempdir().unwrap();
2776 let cwd = tmp.path();
2777 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2778 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2779 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2780 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2781 }
2782
2783 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2784 async fn ephemeral_session_builds_roundtrips() {
2785 let s = ephemeral_session();
2789 let leaf = s.get_leaf_id().await;
2790 assert!(leaf.is_ok());
2791 }
2792
2793 }