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