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, AgentHarnessStreamOptions, CompactionSettings,
48 DrivingMode, HarnessTool, HarnessToolExecution, RetryPolicy, ToolReplay,
49};
50use rpi_tools::{
51 create_bash_tool, create_edit_tool, create_powershell_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 ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
297 let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
298 runtime.clone(),
299 host_arc,
300 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
301 ui_dialog_mailbox.clone(),
302 );
303
304 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
306 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
307 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
308 let _registry = Arc::new(MutationQueueRegistry::new());
309 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
312
313 let tools = build_tools(&ctx, args);
314 let mut tools = tools;
315
316 let extension_session = if args.no_extensions {
324 ExtensionSession::none()
325 } else {
326 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
327 };
328 let js_extension_session = if !should_load_js_packages(args) {
329 None
330 } else {
331 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
332 let js_context = serde_json::json!({
333 "cwd": cwd_str,
334 "theme": resolved.theme.clone(),
335 "currentModel": resolved.model.clone(),
336 "models": catalog.clone(),
337 "thinkingLevel": resolved.thinking_level,
338 });
339 match crate::js_extensions::JsExtensionSession::load_with_context(
340 &paths,
341 args.verbose,
342 js_context,
343 ) {
344 Ok(session) => session,
345 Err(error) => {
346 eprintln!("warning: JS/TS extensions were not loaded: {error}");
347 None
348 }
349 }
350 };
351 if js_extension_session.is_some() {
352 eprintln!(
353 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
354 );
355 }
356 if let Some(session) = &js_extension_session {
357 if let Err(error) =
358 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
359 {
360 if args.verbose {
361 eprintln!("warning: JS provider runtime was not enabled: {error}");
362 }
363 }
364 }
365 if args.verbose {
366 if let Some(session) = &js_extension_session {
367 let info = session.backend_info();
368 eprintln!(
369 "JS extension backend: {} v{} ({})",
370 info.name,
371 info.api_version,
372 info.capability_names().join(", ")
373 );
374 }
375 if let Some(s) = extension_session.summary() {
376 eprintln!("extensions: {s}");
377 }
378 report_deferred_renderers(&extension_session);
379 }
380 merge_extension_tools(&mut tools, &extension_session, args);
381 if let Some(session) = &js_extension_session {
382 merge_js_extension_tools(&mut tools, session, args);
383 if args.verbose && !session.commands.is_empty() {
384 eprintln!("JS extension commands: {}", session.commands.join(", "));
385 }
386 }
387 let mut active = active_tool_names(&tools, args);
388 if let Some(session) = &js_extension_session {
393 let js_names = session.tool_names();
394 if let Some(js_active) = session.active_tools() {
395 active.retain(|name| !js_names.iter().any(|js| js == name));
396 active.extend(js_active.into_iter().filter(|name| {
397 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
398 }));
399 }
400 }
401 active = filter_active_tool_names(active, args);
402
403 let selection = select_session(args, cwd);
405 let session = build_session(&selection, &cwd_str).await?;
406 if let Some(js) = &js_extension_session {
407 let session_id = session
408 .get_metadata()
409 .await
410 .ok()
411 .map(|metadata| metadata.id);
412 let leaf_id = session.get_leaf_id().await.ok().flatten();
413 if let Some(session_id) = session_id {
414 let branch = session
415 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
416 .await
417 .ok()
418 .unwrap_or_default();
419 let branch_json =
420 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
421 let runtime_context = serde_json::json!({
422 "session": {
423 "id": session_id,
424 "leafId": leaf_id,
425 "branch": branch_json,
426 "entries": branch_json.clone(),
427 },
428 });
429 if let Err(error) = js.set_runtime_context(runtime_context) {
430 if args.verbose {
431 eprintln!("warning: could not sync JS session context: {error}");
432 }
433 }
434 }
435 }
436
437 let base_prompt = match args.system_prompt.as_deref() {
445 Some(explicit) => explicit.to_string(),
446 None if project_trusted => {
447 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
448 Some(path) => std::fs::read_to_string(&path)
449 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
450 None => default_system_prompt(&cwd_str),
451 }
452 }
453 None => default_system_prompt(&cwd_str),
454 };
455
456 let mut append_texts: Vec<String> = Vec::new();
463 for extra in &args.append_system_prompt {
464 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
465 append_texts.push(text);
466 }
467 if args.append_system_prompt.is_empty() {
468 if let Some(path) = project_trusted
469 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
470 .flatten()
471 {
472 if let Ok(text) = std::fs::read_to_string(&path) {
473 append_texts.push(text);
474 }
475 }
476 }
477 let append_join = if append_texts.is_empty() {
478 None
479 } else {
480 Some(append_texts.join("\n\n"))
481 };
482
483 let agent_dir = crate::config::agent_dir().ok();
495
496 let discovered = extension_session
512 .snapshot_arc()
513 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
514 .unwrap_or_default();
515
516 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
517 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
518 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
519 if !args.no_skills {
520 let mut dirs = if args.dev_local_only {
521 project_skill_dirs(cwd)
522 } else if project_trusted {
523 skill_dirs(cwd)
524 } else {
525 global_skill_dirs()
526 };
527 dirs.extend(args.skill.iter().cloned());
528 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
529 if let Some(session) = &js_extension_session {
530 dirs.extend(session.resources.skill_paths.iter().cloned());
531 }
532 dirs.extend(package_resources.skill_dirs());
533 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
534 skills = result.skills;
535 skill_diags = result.diagnostics;
536 resource_diags.extend(result.resource_diagnostics);
537 }
538
539 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
540 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
541 if !args.no_prompt_templates {
542 let mut dirs = if args.dev_local_only {
543 project_prompt_template_dirs(cwd)
544 } else if project_trusted {
545 prompt_template_dirs(cwd)
546 } else {
547 global_prompt_template_dirs()
548 };
549 dirs.extend(args.prompt_template.iter().cloned());
550 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
551 if let Some(session) = &js_extension_session {
552 dirs.extend(session.resources.prompt_paths.iter().cloned());
553 }
554 dirs.extend(package_resources.prompt_dirs());
555 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
556 prompt_templates = result.prompt_templates;
557 prompt_diags = result.diagnostics;
558 resource_diags.extend(result.resource_diagnostics);
559 }
560
561 let context_block = if args.no_context_files || !project_trusted {
562 String::new()
563 } else {
564 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
569 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
570 format_project_context(&files)
571 };
572
573 if args.verbose {
575 for d in &package_resources.diagnostics {
576 eprintln!("warning: package {}: {}", d.spec, d.message);
577 }
578 for d in &skill_diags {
579 eprintln!(
580 "warning: skill {} ({}): {}",
581 d.path,
582 d.code.as_str(),
583 d.message
584 );
585 }
586 for d in &prompt_diags {
587 eprintln!(
588 "warning: prompt template {} ({}): {}",
589 d.path,
590 d.code.as_str(),
591 d.message
592 );
593 }
594 for d in &resource_diags {
596 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
597 eprintln!(
598 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
599 c.resource_type.as_str(),
600 c.name,
601 c.winner_path,
602 c.loser_path
603 );
604 }
605 }
606 }
607
608 let system_prompt = compose_system_prompt(
617 Some(&base_prompt),
618 &[], if context_block.is_empty() {
620 None
621 } else {
622 Some(&context_block)
623 },
624 append_join.as_deref(),
625 );
626
627 if args.debug_system_prompt {
636 eprintln!("=== --debug-system-prompt ===");
637 let base_src = if args.system_prompt.is_some() {
638 "--system-prompt"
639 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
640 "SYSTEM.md"
641 } else {
642 "default"
643 };
644 eprintln!("[base source: {base_src}]");
645 eprintln!("--- base ---\n{base_prompt}");
646 if let Some(append) = append_join.as_deref() {
647 eprintln!("--- append ---\n{append}");
648 } else {
649 eprintln!("--- append: (none) ---");
650 }
651 if context_block.is_empty() {
652 eprintln!("--- context: (none) ---");
653 } else {
654 eprintln!("--- context ---{context_block}");
655 }
656 let visible_skills = skills
657 .iter()
658 .filter(|s| s.disable_model_invocation != Some(true))
659 .count();
660 eprintln!(
661 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
662 skills.len(),
663 visible_skills,
664 skills.len() - visible_skills
665 );
666 for s in &skills {
667 let hidden = if s.disable_model_invocation == Some(true) {
668 " [hidden]"
669 } else {
670 ""
671 };
672 eprintln!(" {}{hidden} — {}", s.name, s.description);
673 }
674 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
675 for t in &prompt_templates {
676 eprintln!(" /{}", t.name);
677 }
678 eprintln!(
682 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
683 discovered.skill_paths.len(),
684 discovered.prompt_paths.len(),
685 discovered.theme_paths.len(),
686 );
687 for p in &discovered.skill_paths {
688 eprintln!(" skill: {p}");
689 }
690 for p in &discovered.prompt_paths {
691 eprintln!(" prompt: {p}");
692 }
693 eprintln!(
694 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
695 );
696 eprintln!("=== end --debug-system-prompt ===");
697 }
698
699 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
704 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
705 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
709
710 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
719 Some(snapshot) => {
720 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
721 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
722 }
723 None => broadcast_emitter,
724 };
725
726 let options = AgentHarnessOptions {
727 model: resolved.model.clone(),
728 thinking_level: resolved.thinking_level,
729 active_tool_names: active,
730 tools,
731 system_prompt: Some(system_prompt),
732 resources: AgentHarnessResources {
733 skills: if skills.is_empty() {
734 None
735 } else {
736 Some(skills)
737 },
738 prompt_templates: if prompt_templates.is_empty() {
739 None
740 } else {
741 Some(prompt_templates)
742 },
743 },
744 allow_existing_session: matches!(
747 selection,
748 SessionSelection::Latest
749 | SessionSelection::ById { .. }
750 | SessionSelection::ByExactId { .. }
751 | SessionSelection::Fork { .. }
752 ),
753 stream_options: AgentHarnessStreamOptions {
754 timeout: args.timeout,
755 ..Default::default()
756 },
757 retry: RetryPolicy::default(),
758 compaction: CompactionSettings::default(),
759 steering_mode: Default::default(),
760 follow_up_mode: Default::default(),
761 tool_execution: HarnessToolExecution::default(),
762 drive: DrivingMode::default(),
763 session,
764 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
772 to_provider_messages: None,
773 entry_projectors: Default::default(),
774 agent_emitter: Some(emitter),
775 before_tool_call: None,
781 after_tool_call: None,
782 transform_context: None,
783 entry_transforms: Vec::new(),
784 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
790 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
791 };
792
793 let harness = match AgentHarness::create(options).await {
794 Ok(h) => {
795 crate::extensions_actions::HarnessActionHost::set_harness(
798 &harness_cell,
799 Arc::new(h.clone()),
800 );
801 h
802 }
803 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
804 };
805
806 {
814 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
815 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
816 Ok(reports) => {
817 for (lane, report) in reports {
818 if report.is_clean() {
819 continue;
820 }
821 if report.is_corrupt() {
822 eprintln!(
823 "warning: session lane '{lane}' is corrupt ({} open operations); \
824 refusing to auto-repair — inspect the session file",
825 report.open_operations.len()
826 );
827 continue;
828 }
829 for decision in runtime.reconcile(&report) {
830 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
831 if args.verbose {
832 eprintln!(
833 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
834 );
835 }
836 }
837 }
838 runtime.emit_recovery_events(harness.events(), &report);
839 }
840 }
841 Err(e) => {
842 if args.verbose {
843 eprintln!("warning: session recovery sweep failed: {e}");
844 }
845 }
846 }
847 }
848
849 let reload_context = ReloadContext {
856 extension_session: Arc::new(Mutex::new(extension_session)),
857 js_extension_session: js_extension_session.clone(),
858 package_resources: Arc::new(package_resources.clone()),
859 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
860 catalog,
861 gateway: resolved.provider.clone(),
862 runtime: runtime.clone(),
863 cwd: cwd.to_path_buf(),
864 project_trusted,
865 args: args.clone(),
866 resolved_model: resolved.model.clone(),
867 broadcast: broadcast_for_context,
868 mailbox: reload_mailbox,
869 ui_dialog: ui_dialog_mailbox,
870 dev_extension: None,
871 };
872
873 Ok((harness, event_rx, reload_context))
874}
875
876pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
921
922pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
927
928#[derive(Clone)]
936pub struct ReloadContext {
937 pub extension_session: ExtensionSessionCell,
939 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
941 pub package_resources: Arc<crate::packages::PackageResources>,
945 pub action_bridge: ActionBridgeCell,
947 pub catalog: Vec<rpi_ai::Model>,
952 pub gateway: Arc<dyn Provider>,
955 pub runtime: tokio::runtime::Handle,
959 pub cwd: PathBuf,
961 pub project_trusted: bool,
965 pub args: Args,
971 pub resolved_model: rpi_ai::Model,
974 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
980 pub mailbox: rpi_extensions::ReloadMailbox,
987 pub ui_dialog: rpi_extensions::UiDialogMailbox,
993 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
997}
998
999pub async fn dispatch_session_event_async(
1014 reload_context: &ReloadContext,
1015 tag: rpi_plugin_sdk::EventTag,
1016) -> Option<String> {
1017 let (snapshot, keepalive) = {
1023 let session = reload_context.extension_session.lock().unwrap();
1024 (session.snapshot_arc(), session.keepalive())
1025 };
1026 match snapshot {
1027 Some(snapshot) => {
1028 let _keepalive = keepalive;
1029 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1030 }
1031 None => None,
1032 }
1033}
1034
1035pub struct ReloadOutcome {
1038 pub summary: String,
1041 pub had_warnings: bool,
1043 pub details: Vec<String>,
1048 pub had_errors: bool,
1052}
1053
1054struct PreparedReloadInputs {
1055 package_resources: crate::packages::PackageResources,
1056 extension_dirs: Vec<PathBuf>,
1057 skill_base_dirs: Vec<PathBuf>,
1058 prompt_base_dirs: Vec<PathBuf>,
1059}
1060
1061fn append_reload_resource_paths(
1066 mut paths: Vec<PathBuf>,
1067 explicit: &[PathBuf],
1068 discovered: &[String],
1069 js_paths: &[PathBuf],
1070 package_paths: &[PathBuf],
1071) -> Vec<PathBuf> {
1072 paths.extend(explicit.iter().cloned());
1073 paths.extend(discovered.iter().map(PathBuf::from));
1074 paths.extend(js_paths.iter().cloned());
1075 paths.extend(package_paths.iter().cloned());
1076 paths
1077}
1078
1079pub async fn reload_extension_resources(
1091 harness: &AgentHarness,
1092 ctx: &ReloadContext,
1093) -> ReloadOutcome {
1094 reload_extension_resources_inner(harness, ctx, || {}).await
1095}
1096
1097async fn reload_extension_resources_inner<F>(
1098 harness: &AgentHarness,
1099 ctx: &ReloadContext,
1100 after_prepare: F,
1101) -> ReloadOutcome
1102where
1103 F: FnOnce() + Send,
1104{
1105 let mut effective_args = ctx.args.clone();
1110 if let Some(dev) = &ctx.dev_extension {
1111 if let Err(error) = dev
1112 .rebuild()
1113 .and_then(|_| dev.apply_to_args(&mut effective_args))
1114 {
1115 return ReloadOutcome {
1116 summary: format!(
1117 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1118 dev.package_name()
1119 ),
1120 had_warnings: true,
1121 details: Vec::new(),
1122 had_errors: true,
1123 };
1124 }
1125 }
1126 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1127 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1128 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1129 Ok(prepared) => prepared,
1130 Err(error) => {
1131 return ReloadOutcome {
1132 summary: format!(
1133 "Settings reload failed: {error}. Keeping the currently loaded resources."
1134 ),
1135 had_warnings: true,
1136 details: Vec::new(),
1137 had_errors: true,
1138 };
1139 }
1140 };
1141 after_prepare();
1142 let PreparedReloadInputs {
1143 package_resources,
1144 extension_dirs,
1145 skill_base_dirs,
1146 prompt_base_dirs,
1147 } = prepared;
1148 let mut warnings = false;
1149
1150 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1157 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1158 Some(b) => b.clone_host(),
1159 None => {
1160 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1163 ctx.catalog.clone(),
1164 ctx.cwd.clone(),
1165 ctx.runtime.clone(),
1166 ctx.args.unknown_flags.clone(),
1167 );
1168 crate::extensions_actions::HarnessActionHost::set_harness(
1169 &_cell,
1170 Arc::new(harness.clone()),
1171 );
1172 Arc::new(action_host)
1173 }
1174 };
1175
1176 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1177 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1178 ctx.runtime.clone(),
1179 host,
1180 reload_cb,
1181 ctx.ui_dialog.clone(),
1182 );
1183
1184 let extension_session = if effective_args.no_extensions {
1185 rpi_extensions::ExtensionSession::none()
1186 } else {
1187 load_extensions_from_dirs(
1188 &effective_args,
1189 &extension_dirs,
1190 Some(Arc::clone(&fresh_bridge)),
1191 )
1192 };
1193 if extension_session.is_empty() && !effective_args.no_extensions {
1194 }
1197 if effective_args.verbose {
1198 if let Some(s) = extension_session.summary() {
1199 eprintln!("reload: {s}");
1200 }
1201 report_deferred_renderers(&extension_session);
1202 }
1203
1204 {
1218 let mut session_guard = ctx.extension_session.lock().unwrap();
1219 let old_session = std::mem::replace(
1220 &mut *session_guard,
1221 rpi_extensions::ExtensionSession::none(),
1222 );
1223 if let Some(old_snap) = old_session.snapshot_arc() {
1224 old_snap.active_flag().store(false, Ordering::SeqCst);
1228 }
1229 }
1233 if let Some(old_b) = old_bridge {
1234 old_b.invalidate();
1235 }
1236
1237 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1240 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1241
1242 let discovered = extension_session
1244 .snapshot_arc()
1245 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1246 .unwrap_or_default();
1247
1248 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1250 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1251
1252 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1253 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1254 let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1255 if !effective_args.no_skills {
1256 let js_paths: &[PathBuf] = ctx
1260 .js_extension_session
1261 .as_ref()
1262 .map(|js| js.resources.skill_paths.as_slice())
1263 .unwrap_or(&[]);
1264 let package_paths = package_resources.skill_dirs();
1265 let dirs = append_reload_resource_paths(
1266 skill_base_dirs,
1267 &effective_args.skill,
1268 &discovered.skill_paths,
1269 js_paths,
1270 &package_paths,
1271 );
1272 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1273 skills = result.skills;
1274 skill_diags = result.diagnostics;
1275 resource_diags.extend(result.resource_diagnostics);
1276 }
1277
1278 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1279 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1280 if !effective_args.no_prompt_templates {
1281 let js_paths: &[PathBuf] = ctx
1282 .js_extension_session
1283 .as_ref()
1284 .map(|js| js.resources.prompt_paths.as_slice())
1285 .unwrap_or(&[]);
1286 let package_paths = package_resources.prompt_dirs();
1287 let dirs = append_reload_resource_paths(
1288 prompt_base_dirs,
1289 &effective_args.prompt_template,
1290 &discovered.prompt_paths,
1291 js_paths,
1292 &package_paths,
1293 );
1294 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1295 prompt_templates = result.prompt_templates;
1296 prompt_diags = result.diagnostics;
1297 resource_diags.extend(result.resource_diagnostics);
1298 }
1299
1300 let context_block = if effective_args.no_context_files {
1301 String::new()
1302 } else {
1303 let agent_dir = crate::config::agent_dir().ok();
1304 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1305 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1306 format_project_context(&files)
1307 };
1308
1309 let mut details: Vec<String> = Vec::new();
1310 if !skill_diags.is_empty()
1311 || !prompt_diags.is_empty()
1312 || !package_resources.diagnostics.is_empty()
1313 || !resource_diags.is_empty()
1314 {
1315 warnings = true;
1316 for d in &package_resources.diagnostics {
1321 details.push(format!("warning: package {}: {}", d.spec, d.message));
1322 }
1323 for d in &skill_diags {
1324 details.push(format!(
1325 "warning: skill {} ({}): {}",
1326 d.path,
1327 d.code.as_str(),
1328 d.message
1329 ));
1330 }
1331 for d in &prompt_diags {
1332 details.push(format!(
1333 "warning: prompt template {} ({}): {}",
1334 d.path,
1335 d.code.as_str(),
1336 d.message
1337 ));
1338 }
1339 for d in &resource_diags {
1341 if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1342 details.push(format!(
1343 "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1344 c.resource_type.as_str(),
1345 c.name,
1346 c.winner_path,
1347 c.loser_path
1348 ));
1349 }
1350 }
1351 }
1352
1353 let base_prompt = match effective_args.system_prompt.as_deref() {
1355 Some(explicit) => explicit.to_string(),
1356 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1357 Some(path) => {
1358 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1359 }
1360 None => default_system_prompt(&cwd_str),
1361 },
1362 };
1363 let mut append_texts: Vec<String> = Vec::new();
1364 for extra in &effective_args.append_system_prompt {
1365 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1366 append_texts.push(text);
1367 }
1368 if effective_args.append_system_prompt.is_empty() {
1369 if let Some(path) =
1370 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1371 {
1372 if let Ok(text) = std::fs::read_to_string(&path) {
1373 append_texts.push(text);
1374 }
1375 }
1376 }
1377 let append_join = if append_texts.is_empty() {
1378 None
1379 } else {
1380 Some(append_texts.join("\n\n"))
1381 };
1382 let system_prompt = compose_system_prompt(
1383 Some(&base_prompt),
1384 &[],
1385 if context_block.is_empty() {
1386 None
1387 } else {
1388 Some(&context_block)
1389 },
1390 append_join.as_deref(),
1391 );
1392
1393 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1395 Some(snapshot) => {
1396 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1397 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1398 }
1399 None => ctx.broadcast.clone(),
1400 };
1401
1402 let resources = AgentHarnessResources {
1404 skills: if skills.is_empty() {
1405 None
1406 } else {
1407 Some(skills.clone())
1408 },
1409 prompt_templates: if prompt_templates.is_empty() {
1410 None
1411 } else {
1412 Some(prompt_templates.clone())
1413 },
1414 };
1415 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1416 let _ = harness.set_resources(resources).await;
1417 let _ = harness.set_agent_emitter(Some(emitter)).await;
1418 let _ = harness
1419 .set_models(build_models_with_extensions_for_reload(
1420 &ctx.gateway,
1421 &extension_session,
1422 ctx.runtime.clone(),
1423 ))
1424 .await;
1425 let _ = harness
1426 .set_provider_hooks(
1427 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1428 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1429 )
1430 .await;
1431
1432 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1436 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1437 let mut tools = build_tools(&tool_ctx, &effective_args);
1438 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1439 if let Some(js) = &ctx.js_extension_session {
1440 merge_js_extension_tools(&mut tools, js, &effective_args);
1441 }
1442 let mut active = active_tool_names(&tools, &effective_args);
1443 if let Some(js) = &ctx.js_extension_session {
1444 let js_names = js.tool_names();
1445 if let Some(js_active) = js.active_tools() {
1446 active.retain(|name| {
1447 tool_name_allowed(name, &effective_args)
1448 && !js_names.iter().any(|js_name| js_name == name)
1449 });
1450 active.extend(js_active.into_iter().filter(|name| {
1451 js_names.iter().any(|js_name| js_name == name)
1452 && tool_name_allowed(name, &effective_args)
1453 }));
1454 }
1455 }
1456 active = filter_active_tool_names(active, &effective_args);
1457 let _ = harness.set_tools(tools, Some(active)).await;
1458
1459 let summary = format!(
1460 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1461 extension_session.loaded_paths().len(),
1462 skills.len(),
1463 prompt_templates.len(),
1464 );
1465 ReloadOutcome {
1466 summary,
1467 had_warnings: warnings,
1468 details,
1469 had_errors: false,
1472 }
1473}
1474
1475#[derive(Clone, Debug, PartialEq, Eq)]
1476struct ReloadSettingsFields {
1477 packages: Option<Vec<crate::settings::PackageSetting>>,
1478 npm_command: Option<Vec<String>>,
1479 skill_dirs: Option<Vec<String>>,
1480 prompt_dirs: Option<Vec<String>>,
1481 extension_dirs: Option<Vec<String>>,
1482}
1483
1484impl From<crate::settings::Settings> for ReloadSettingsFields {
1485 fn from(settings: crate::settings::Settings) -> Self {
1486 Self {
1487 packages: settings.packages,
1488 npm_command: settings.npm_command,
1489 skill_dirs: settings.skill_dirs,
1490 prompt_dirs: settings.prompt_dirs,
1491 extension_dirs: settings.extension_dirs,
1492 }
1493 }
1494}
1495
1496#[derive(Clone, Debug, PartialEq, Eq)]
1497struct ReloadSettingsSnapshot {
1498 global: ReloadSettingsFields,
1499 project: Option<ReloadSettingsFields>,
1500}
1501
1502fn reload_reads_settings(args: &Args) -> bool {
1503 !args.dev_local_only
1504 && (should_load_js_packages(args)
1505 || !args.no_extensions
1506 || !args.no_skills
1507 || !args.no_prompt_templates)
1508}
1509
1510fn load_reload_settings_snapshot(
1514 args: &Args,
1515 cwd: &Path,
1516 project_trusted: bool,
1517) -> Result<Option<ReloadSettingsSnapshot>, String> {
1518 if !reload_reads_settings(args) {
1519 return Ok(None);
1520 }
1521 let global = crate::settings::load_settings()
1522 .map_err(|error| format!("could not load global settings: {error}"))?
1523 .into();
1524 let project = if project_trusted {
1525 crate::settings::load_active_project_settings(cwd)
1526 .map_err(|error| format!("could not load project settings: {error}"))?
1527 .map(|(_, settings)| settings.into())
1528 } else {
1529 None
1530 };
1531 Ok(Some(ReloadSettingsSnapshot { global, project }))
1532}
1533
1534fn validate_settings_for_reload(
1537 args: &Args,
1538 cwd: &Path,
1539 project_trusted: bool,
1540) -> Result<(), String> {
1541 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1542}
1543
1544fn prepare_reload_inputs(
1545 args: &Args,
1546 cwd: &Path,
1547 project_trusted: bool,
1548) -> Result<PreparedReloadInputs, String> {
1549 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1550}
1551
1552fn prepare_reload_inputs_inner<F>(
1553 args: &Args,
1554 cwd: &Path,
1555 project_trusted: bool,
1556 before_verify: F,
1557) -> Result<PreparedReloadInputs, String>
1558where
1559 F: FnOnce(),
1560{
1561 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1562
1563 let package_resources = if args.dev_local_only {
1564 crate::packages::PackageResources::default()
1565 } else {
1566 package_resources_for(args, cwd, project_trusted)
1567 };
1568
1569 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1570 Vec::new()
1571 } else if project_trusted {
1572 extension_dirs(cwd)
1573 } else {
1574 global_extension_dirs()
1575 };
1576 if !args.no_extensions {
1577 extension_dirs.extend(args.extensions_dir.iter().cloned());
1578 }
1579
1580 let skill_base_dirs = if args.no_skills {
1581 Vec::new()
1582 } else if args.dev_local_only {
1583 project_skill_dirs(cwd)
1584 } else if project_trusted {
1585 skill_dirs(cwd)
1586 } else {
1587 global_skill_dirs()
1588 };
1589
1590 let prompt_base_dirs = if args.no_prompt_templates {
1591 Vec::new()
1592 } else if args.dev_local_only {
1593 project_prompt_template_dirs(cwd)
1594 } else if project_trusted {
1595 prompt_template_dirs(cwd)
1596 } else {
1597 global_prompt_template_dirs()
1598 };
1599
1600 before_verify();
1601 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1602 if settings_before != settings_after {
1603 return Err(
1604 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1605 );
1606 }
1607
1608 Ok(PreparedReloadInputs {
1609 package_resources,
1610 extension_dirs,
1611 skill_base_dirs,
1612 prompt_base_dirs,
1613 })
1614}
1615
1616fn build_models_with_extensions_for_reload(
1621 gateway: &Arc<dyn Provider>,
1622 extension_session: &ExtensionSession,
1623 runtime: tokio::runtime::Handle,
1624) -> Vec<Arc<dyn Provider>> {
1625 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1626 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1627 models.extend(pluggable);
1628 models
1629}
1630
1631fn report_deferred_renderers(session: &ExtensionSession) {
1635 let Some(snap) = session.snapshot_arc() else {
1636 return;
1637 };
1638 let all = snap.renderers();
1639 let markdown = all
1640 .iter()
1641 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1642 .count();
1643 let message = all
1644 .iter()
1645 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1646 .count();
1647 let entry = all
1648 .iter()
1649 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1650 .count();
1651 if markdown + message + entry == 0 {
1652 return;
1653 }
1654 eprintln!(
1655 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1656 markdown, message, entry
1657 );
1658}
1659
1660#[derive(Debug, thiserror::Error)]
1662pub enum BuildError {
1663 #[error("Could not create the session directory: {0}")]
1664 SessionDir(String),
1665 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1666 SessionNotFound { requested: String, dir: String },
1667 #[error("Could not build the harness: {0}")]
1668 HarnessCreate(String),
1669}
1670
1671fn build_models_with_extensions(
1681 resolved: &ResolvedModel,
1682 extension_session: &ExtensionSession,
1683 runtime: tokio::runtime::Handle,
1684) -> Vec<Arc<dyn Provider>> {
1685 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1686 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1687 models.extend(pluggable);
1688 models
1689}
1690
1691pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1694 if let Some(override_value) = args.trust_override {
1695 return override_value;
1696 }
1697 crate::config::project_trust_decision(cwd)
1698 .ok()
1699 .flatten()
1700 .unwrap_or(true)
1701}
1702
1703fn load_extensions(
1711 args: &Args,
1712 cwd: &Path,
1713 project_trusted: bool,
1714 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1715) -> ExtensionSession {
1716 let mut dirs = if args.dev_local_only {
1717 Vec::new()
1718 } else if project_trusted {
1719 extension_dirs(cwd)
1720 } else {
1721 global_extension_dirs()
1722 };
1723 dirs.extend(args.extensions_dir.iter().cloned());
1724 load_extensions_from_dirs(args, &dirs, action_bridge)
1725}
1726
1727fn load_extensions_from_dirs(
1728 args: &Args,
1729 dirs: &[PathBuf],
1730 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1731) -> ExtensionSession {
1732 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1733 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1740}
1741
1742fn js_extension_paths(
1743 args: &Args,
1744 cwd: &Path,
1745 project_trusted: bool,
1746 packages: &crate::packages::PackageResources,
1747) -> Vec<PathBuf> {
1748 if args.dev_local_only {
1749 return Vec::new();
1750 }
1751 let mut paths = packages.extension_paths();
1752 let discovered_dirs = if project_trusted {
1753 extension_dirs(cwd)
1754 } else {
1755 global_extension_dirs()
1756 };
1757 for dir in discovered_dirs {
1758 if let Ok(entries) = std::fs::read_dir(dir) {
1759 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1760 matches!(
1761 path.extension()
1762 .and_then(|ext| ext.to_str())
1763 .map(|ext| ext.to_ascii_lowercase())
1764 .as_deref(),
1765 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1766 )
1767 }));
1768 }
1769 }
1770 paths.extend(
1771 args.extension
1772 .iter()
1773 .filter(|path| {
1774 matches!(
1775 path.extension()
1776 .and_then(|ext| ext.to_str())
1777 .map(|ext| ext.to_ascii_lowercase())
1778 .as_deref(),
1779 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1780 )
1781 })
1782 .cloned(),
1783 );
1784 paths
1785}
1786
1787fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1793 let Some(snapshot) = session.snapshot() else {
1794 return;
1795 };
1796 for et in snapshot.tools() {
1797 let name = &et.tool.name;
1798 if !tool_name_allowed(name, args) {
1799 continue;
1800 }
1801 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1802 let harness_tool = HarnessTool::new(Arc::new(adapter));
1803 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1804 Some(slot) => *slot = harness_tool,
1805 None => tools.push(harness_tool),
1806 }
1807 }
1808}
1809
1810fn merge_js_extension_tools(
1811 tools: &mut Vec<HarnessTool>,
1812 session: &crate::js_extensions::JsExtensionSession,
1813 args: &Args,
1814) {
1815 for adapter in session.tools() {
1816 let name = adapter.schema().name.clone();
1817 if !tool_name_allowed(&name, args) {
1818 continue;
1819 }
1820 let harness_tool = HarnessTool::new(Arc::new(adapter));
1821 match tools
1822 .iter_mut()
1823 .find(|tool| tool.tool.schema().name == name)
1824 {
1825 Some(slot) => *slot = harness_tool,
1826 None => tools.push(harness_tool),
1827 }
1828 }
1829}
1830
1831pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1838 use rpi_tools::tools::bash::BashToolOptions;
1839 let default = std::env::var("RPI_BASH_TIMEOUT")
1840 .ok()
1841 .and_then(|v| v.parse::<f64>().ok())
1842 .unwrap_or(120.0);
1843 BashToolOptions {
1844 command_prefix: None,
1845 default_timeout: Some(default),
1846 }
1847}
1848
1849fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1850 if args.no_tools {
1851 return Vec::new();
1852 }
1853 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1856 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1857 (
1858 "bash",
1859 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1860 ),
1861 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1862 ("write", HarnessTool::new(create_write_tool(ctx))),
1863 ("docs", HarnessTool::new(create_docs_tool())),
1864 ];
1865
1866 #[cfg(target_os = "windows")]
1868 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1869
1870 if args.tools.is_none() {
1872 if let Ok(settings) = crate::settings::load_settings() {
1873 if let Some(default_tools) = &settings.default_tools {
1874 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1875 }
1876 }
1877 }
1878
1879 if args.no_builtin_tools {
1883 all.clear();
1884 }
1885
1886 if let Some(allow) = &args.tools {
1888 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1889 }
1890 if let Some(deny) = &args.exclude_tools {
1892 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1893 }
1894
1895 all.into_iter()
1896 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1897 .collect()
1898}
1899
1900fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1904 filter_active_tool_names(
1905 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1906 args,
1907 )
1908}
1909
1910pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1914 if args.no_tools {
1915 return false;
1916 }
1917 if args
1918 .tools
1919 .as_ref()
1920 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1921 {
1922 return false;
1923 }
1924 if args
1925 .exclude_tools
1926 .as_ref()
1927 .is_some_and(|deny| deny.iter().any(|value| value == name))
1928 {
1929 return false;
1930 }
1931 true
1932}
1933
1934pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1935where
1936 I: IntoIterator<Item = String>,
1937{
1938 names
1939 .into_iter()
1940 .filter(|name| tool_name_allowed(name, args))
1941 .collect()
1942}
1943
1944async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1946 match selection {
1947 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1948 SessionSelection::New { dir, .. } => {
1949 std::fs::create_dir_all(dir)
1952 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1953 let session = create_jsonl_session(dir, cwd)
1954 .await
1955 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1956 Ok(session)
1957 }
1958 SessionSelection::Latest
1959 | SessionSelection::ById { .. }
1960 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1961 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1962 }
1963}
1964
1965async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1971 match selection {
1975 SessionSelection::Latest => {
1976 let metas = list_session_metadata(cwd).await?;
1977 let Some(meta) = metas.first() else {
1978 return Err(BuildError::SessionNotFound {
1979 requested: "the most recent session".to_string(),
1980 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1981 });
1982 };
1983 open_session(meta, cwd).await
1984 }
1985 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1986 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1987 requested,
1988 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1989 },
1990 OpenError::Other(msg) => BuildError::SessionDir(msg),
1991 }),
1992 SessionSelection::ByExactId { id } => {
1993 let metas = list_session_metadata(cwd).await?;
1996 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1997 return open_session(meta, cwd).await;
1998 }
1999 let dir = default_session_dir(Path::new(cwd));
2000 std::fs::create_dir_all(&dir)
2001 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2002 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
2003 .await
2004 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2005 }
2006 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2007 }
2008}
2009
2010async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
2013 use rpi_harness::session::jsonl::{
2014 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2015 };
2016 use rpi_harness::session::types::{ForkOptions, SessionStorage};
2017 use rpi_tools::FileSystem;
2018
2019 let dir = default_session_dir(Path::new(cwd));
2020 std::fs::create_dir_all(&dir)
2021 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2022 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2023 let fs: Arc<dyn FileSystem> = env.clone();
2024 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2025 fs: fs.clone(),
2026 sessions_root: dir.to_string_lossy().into_owned(),
2027 clock: Arc::new(SystemClock),
2028 ids: Arc::new(DefaultIdGenerator::new()),
2029 });
2030 let metas = repo
2031 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2032 .await
2033 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2034 let source_meta = metas
2035 .iter()
2036 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2037 .ok_or_else(|| BuildError::SessionNotFound {
2038 requested: format!("--fork {source}"),
2039 dir: dir.display().to_string(),
2040 })?;
2041 let fork_storage = repo
2042 .fork_typed(
2043 source_meta,
2044 &JsonlSessionCreateOptions {
2045 id: None,
2046 parent_session_id: Some(source_meta.id.clone()),
2047 cwd: cwd.to_string(),
2048 metadata: None,
2049 },
2050 &ForkOptions::default(),
2051 )
2052 .await
2053 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2054 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2055 Ok(Session::new(storage_arc, None))
2056}
2057
2058pub enum OpenError {
2061 NotFound { requested: String },
2063 Other(String),
2065}
2066
2067impl std::fmt::Display for OpenError {
2068 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2069 match self {
2070 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2071 OpenError::Other(msg) => write!(f, "{msg}"),
2072 }
2073 }
2074}
2075
2076pub async fn list_session_metadata(
2079 cwd: &str,
2080) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2081 use rpi_harness::session::jsonl::{
2082 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2083 };
2084 use rpi_tools::FileSystem;
2085
2086 let dir = default_session_dir(Path::new(cwd));
2087 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2088 let fs: Arc<dyn FileSystem> = env.clone();
2089 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2090 fs: fs.clone(),
2091 sessions_root: dir.to_string_lossy().into_owned(),
2092 clock: Arc::new(SystemClock),
2093 ids: Arc::new(DefaultIdGenerator::new()),
2094 });
2095 repo.list_typed(&JsonlSessionListOptions::default())
2096 .await
2097 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2098}
2099
2100pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2104 let metas = list_session_metadata(cwd)
2105 .await
2106 .map_err(|e| OpenError::Other(e.to_string()))?;
2107 let Some(meta) = metas
2108 .iter()
2109 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2110 else {
2111 return Err(OpenError::NotFound {
2112 requested: format!("session {id}"),
2113 });
2114 };
2115 open_session(meta, cwd)
2116 .await
2117 .map_err(|e| OpenError::Other(e.to_string()))
2118}
2119
2120pub(crate) async fn fork_session_storage(
2127 harness: &AgentHarness,
2128 cwd: &str,
2129) -> Result<Session, String> {
2130 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2131 use rpi_tools::FileSystem;
2132
2133 let dir = default_session_dir(Path::new(cwd));
2134 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2135 let fs: Arc<dyn FileSystem> = env.clone();
2136 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2137 fs,
2138 sessions_root: dir.to_string_lossy().into_owned(),
2139 clock: Arc::new(SystemClock),
2140 ids: Arc::new(DefaultIdGenerator::new()),
2141 });
2142 let id = harness.session().storage().metadata().id.clone();
2145 let metas = list_session_metadata(cwd)
2146 .await
2147 .map_err(|e| e.to_string())?;
2148 let Some(source) = metas.iter().find(|m| m.id == id) else {
2149 return Err(format!("current session {id} not found on disk"));
2150 };
2151 let fork_storage = repo
2152 .fork_typed(
2153 source,
2154 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2155 id: None,
2156 parent_session_id: Some(source.id.clone()),
2157 cwd: cwd.to_string(),
2158 metadata: None,
2159 },
2160 &rpi_harness::session::types::ForkOptions::default(),
2161 )
2162 .await
2163 .map_err(|e| e.to_string())?;
2164 Ok(Session::new(Arc::new(fork_storage), None))
2165}
2166
2167async fn open_session(
2170 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2171 cwd: &str,
2172) -> Result<Session, BuildError> {
2173 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2174 use rpi_harness::session::types::SessionStorage;
2175 use rpi_tools::FileSystem;
2176
2177 let dir = default_session_dir(Path::new(cwd));
2178 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2179 let fs: Arc<dyn FileSystem> = env.clone();
2180 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2181 fs: fs.clone(),
2182 sessions_root: dir.to_string_lossy().into_owned(),
2183 clock: Arc::new(SystemClock),
2184 ids: Arc::new(DefaultIdGenerator::new()),
2185 });
2186 let storage = repo
2187 .open_by_jsonl_metadata(meta)
2188 .await
2189 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2190 if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2194 Some(Path::new(&meta.path)),
2195 Path::new(&meta.cwd),
2196 Path::new(cwd),
2197 ) {
2198 eprintln!(
2199 "warning: {}",
2200 crate::session_cwd::format_missing_session_cwd_error(&issue)
2201 );
2202 }
2203 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2204 Ok(Session::new(storage_arc, None))
2205}
2206
2207fn ephemeral_session() -> Session {
2209 let storage = Arc::new(InMemorySessionStorage::new(
2210 SessionMetadata {
2211 id: "ephemeral".into(),
2212 created_at: 0,
2213 parent_session_id: None,
2214 },
2215 Arc::new(SystemClock),
2216 Arc::new(DefaultIdGenerator::new()),
2217 ));
2218 Session::new(storage, None)
2219}
2220
2221pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2232 create_jsonl_session_with_id(dir, cwd, None).await
2233}
2234
2235pub(crate) async fn create_jsonl_session_with_id(
2239 dir: &Path,
2240 cwd: &str,
2241 id: Option<String>,
2242) -> Result<Session, String> {
2243 use rpi_harness::session::jsonl::{
2244 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2245 };
2246 use rpi_tools::FileSystem;
2247
2248 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2251 let fs: Arc<dyn FileSystem> = env.clone();
2252
2253 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2254 fs: fs.clone(),
2255 sessions_root: dir.to_string_lossy().into_owned(),
2256 clock: Arc::new(SystemClock),
2257 ids: Arc::new(DefaultIdGenerator::new()),
2258 });
2259
2260 let opts = JsonlSessionCreateOptions {
2261 id, parent_session_id: None,
2263 cwd: cwd.to_string(),
2264 metadata: None,
2265 };
2266 let storage = repo
2267 .create_typed(&opts)
2268 .await
2269 .map_err(|e| format!("create session: {e}"))?;
2270 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2272 Ok(Session::new(storage_arc, None))
2273}
2274
2275fn read_append_target(target: &str) -> Option<String> {
2278 let path = Path::new(target);
2279 if path.is_file() {
2280 std::fs::read_to_string(path).ok()
2281 } else {
2282 None
2283 }
2284}
2285
2286#[cfg(test)]
2287mod tests {
2288 use super::*;
2289 use crate::args::Args;
2290
2291 #[test]
2292 fn default_prompt_mentions_cwd_and_tools() {
2293 let p = default_system_prompt("/tmp/proj");
2294 assert!(p.contains("/tmp/proj"));
2295 assert!(p.contains("read"));
2296 assert!(p.contains("bash"));
2297 assert!(p.contains("edit"));
2298 assert!(p.contains("write"));
2299 assert!(p.contains("docs"));
2300 assert!(!p.contains("- grep"));
2301 assert!(!p.contains("- find"));
2302 assert!(!p.contains("- ls"));
2303 assert!(!p.contains("powershell"));
2304 }
2305
2306 #[test]
2307 fn default_tools_include_docs_lookup() {
2308 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2309 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2310 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2311 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2312 let names: Vec<String> = build_tools(&context, &Args::default())
2313 .iter()
2314 .map(|tool| tool.tool.schema().name.clone())
2315 .collect();
2316
2317 assert!(names.contains(&"docs".to_string()));
2322 for legacy in ["grep", "find", "ls"] {
2323 assert!(
2324 !names.contains(&legacy.to_string()),
2325 "legacy tool {legacy} should not be registered by default"
2326 );
2327 }
2328 }
2329
2330 #[test]
2331 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2332 let args = Args::default();
2333 assert!(!should_load_js_packages(&args));
2334 let resources = package_resources_for(&args, Path::new("."), false);
2335 assert!(resources.packages.is_empty());
2336
2337 let args = Args {
2338 enable_pi_packages: true,
2339 ..Args::default()
2340 };
2341 assert!(should_load_js_packages(&args));
2342
2343 let args = Args {
2344 enable_pi_packages: true,
2345 no_extensions: true,
2346 ..Args::default()
2347 };
2348 assert!(!should_load_js_packages(&args));
2349 assert!(package_resources_for(&args, Path::new("."), false)
2350 .packages
2351 .is_empty());
2352 }
2353
2354 #[test]
2355 fn pi_offline_env_disables_startup_package_remediation() {
2356 struct RestoreEnv {
2357 name: &'static str,
2358 value: Option<std::ffi::OsString>,
2359 }
2360
2361 impl Drop for RestoreEnv {
2362 fn drop(&mut self) {
2363 match self.value.take() {
2364 Some(value) => std::env::set_var(self.name, value),
2365 None => std::env::remove_var(self.name),
2366 }
2367 }
2368 }
2369
2370 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2371 let _restore_config = RestoreEnv {
2372 name: crate::config::CONFIG_DIR_ENV,
2373 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2374 };
2375 let _restore_offline = RestoreEnv {
2376 name: crate::args::PI_OFFLINE_ENV,
2377 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2378 };
2379 let tmp = tempfile::tempdir().unwrap();
2380 let agent = tmp.path().join("agent");
2381 let cwd = tmp.path().join("project");
2382 let package = agent.join("npm/node_modules/demo");
2383 std::fs::create_dir_all(package.join("extensions")).unwrap();
2384 std::fs::create_dir_all(&cwd).unwrap();
2385 std::fs::write(
2386 package.join("package.json"),
2387 r#"{"name":"demo","version":"1.0.0"}"#,
2388 )
2389 .unwrap();
2390 std::fs::write(
2391 package.join("extensions/index.js"),
2392 "export default () => {};",
2393 )
2394 .unwrap();
2395 std::fs::write(
2396 agent.join("settings.json"),
2397 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2398 )
2399 .unwrap();
2400 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2401 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2402 let args = Args {
2403 enable_pi_packages: true,
2404 offline: false,
2405 ..Args::default()
2406 };
2407
2408 let resources = package_resources_for(&args, &cwd, false);
2409
2410 assert!(resources.packages.is_empty());
2411 assert_eq!(resources.diagnostics.len(), 1);
2412 assert!(resources.diagnostics[0].message.contains("offline"));
2413 assert_eq!(
2414 std::fs::read(package.join("package.json")).unwrap(),
2415 br#"{"name":"demo","version":"1.0.0"}"#
2416 );
2417 }
2418
2419 #[test]
2420 fn package_discovery_uses_the_callers_trust_snapshot() {
2421 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2422 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2423 let tmp = tempfile::tempdir().unwrap();
2424 let agent = tmp.path().join("agent");
2425 let cwd = tmp.path().join("project");
2426 let package = cwd.join("package");
2427 std::fs::create_dir_all(&agent).unwrap();
2428 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2429 std::fs::create_dir_all(&package).unwrap();
2430 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2431 std::fs::write(
2432 package.join("package.json"),
2433 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2434 )
2435 .unwrap();
2436 std::fs::write(
2437 cwd.join(".rpi/settings.json"),
2438 serde_json::json!({"packages": [package]}).to_string(),
2439 )
2440 .unwrap();
2441 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2442
2443 let args = Args {
2444 enable_pi_packages: true,
2445 ..Args::default()
2446 };
2447 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2448 assert!(package_resources_for(&args, &cwd, false)
2449 .packages
2450 .is_empty());
2451 assert_eq!(
2452 package_resources_for_update_check(&args, &cwd, true)
2453 .packages
2454 .len(),
2455 1
2456 );
2457 assert!(package_resources_for_update_check(&args, &cwd, false)
2458 .packages
2459 .is_empty());
2460
2461 match previous {
2462 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2463 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2464 }
2465 }
2466
2467 #[test]
2468 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2469 struct RestoreConfigDir(Option<std::ffi::OsString>);
2470
2471 impl Drop for RestoreConfigDir {
2472 fn drop(&mut self) {
2473 match self.0.take() {
2474 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2475 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2476 }
2477 }
2478 }
2479
2480 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2481 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2482 let tmp = tempfile::tempdir().unwrap();
2483 let agent = tmp.path().join("agent");
2484 let cwd = tmp.path().join("project");
2485 std::fs::create_dir_all(&agent).unwrap();
2486 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2487 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2488 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2489 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2490 let packages_disabled = Args::default();
2491 let packages_enabled = Args {
2492 enable_pi_packages: true,
2493 ..Args::default()
2494 };
2495
2496 for args in [&packages_disabled, &packages_enabled] {
2497 let trusted = validate_settings_for_reload(args, &cwd, true);
2498 let untrusted = validate_settings_for_reload(args, &cwd, false);
2499 assert!(trusted
2500 .unwrap_err()
2501 .contains("could not load project settings"));
2502 assert!(untrusted.is_ok());
2503 }
2504
2505 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2506 for args in [&packages_disabled, &packages_enabled] {
2507 assert!(validate_settings_for_reload(args, &cwd, false)
2508 .unwrap_err()
2509 .contains("could not load global settings"));
2510 }
2511
2512 let local_only = Args {
2513 dev_local_only: true,
2514 ..Args::default()
2515 };
2516 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2517 }
2518
2519 #[tokio::test(flavor = "current_thread")]
2520 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2521 struct RestoreConfigDir(Option<std::ffi::OsString>);
2522
2523 impl Drop for RestoreConfigDir {
2524 fn drop(&mut self) {
2525 match self.0.take() {
2526 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2527 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2528 }
2529 }
2530 }
2531
2532 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2533 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2534 let tmp = tempfile::tempdir().unwrap();
2535 let agent = tmp.path().join("agent");
2536 let cwd = tmp.path().join("project");
2537 let skill_dir = tmp.path().join("configured-skills");
2538 std::fs::create_dir_all(&agent).unwrap();
2539 std::fs::create_dir_all(&cwd).unwrap();
2540 std::fs::create_dir_all(&skill_dir).unwrap();
2541 std::fs::write(
2542 skill_dir.join("SKILL.md"),
2543 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2544 )
2545 .unwrap();
2546 std::fs::write(
2547 agent.join("settings.json"),
2548 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2549 )
2550 .unwrap();
2551 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2552
2553 let resolved = crate::provider::resolve(
2554 Some("anthropic"),
2555 Some(crate::provider::DEFAULT_MODEL_ID),
2556 None,
2557 Some("test-key"),
2558 None,
2559 )
2560 .unwrap();
2561 let args = Args {
2562 trust_override: Some(false),
2563 no_session: true,
2564 no_extensions: true,
2565 no_prompt_templates: true,
2566 no_context_files: true,
2567 system_prompt: Some("stable system prompt".into()),
2568 ..Args::default()
2569 };
2570 assert!(!should_load_js_packages(&args));
2571
2572 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2573 let before_resources = harness.get_resources().await.unwrap();
2574 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2575 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2576 let before_prompt = harness.get_system_prompt().await.unwrap();
2577 let before_tools: Vec<String> = harness
2578 .get_tools()
2579 .await
2580 .unwrap()
2581 .iter()
2582 .map(|tool| tool.tool.schema().name.clone())
2583 .collect();
2584 let before_bridge = context
2585 .action_bridge
2586 .lock()
2587 .unwrap()
2588 .as_ref()
2589 .unwrap()
2590 .clone();
2591
2592 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2593 let outcome = reload_extension_resources(&harness, &context).await;
2594
2595 assert!(outcome.had_warnings);
2596 assert!(outcome.summary.contains("Settings reload failed"));
2597 assert!(outcome.summary.contains("could not load global settings"));
2598 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2599 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2600 let after_tools: Vec<String> = harness
2601 .get_tools()
2602 .await
2603 .unwrap()
2604 .iter()
2605 .map(|tool| tool.tool.schema().name.clone())
2606 .collect();
2607 assert_eq!(after_tools, before_tools);
2608 let after_bridge = context
2609 .action_bridge
2610 .lock()
2611 .unwrap()
2612 .as_ref()
2613 .unwrap()
2614 .clone();
2615 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2616 }
2617
2618 #[test]
2619 fn reload_preparation_rejects_settings_changed_during_derivation() {
2620 struct RestoreConfigDir(Option<std::ffi::OsString>);
2621
2622 impl Drop for RestoreConfigDir {
2623 fn drop(&mut self) {
2624 match self.0.take() {
2625 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2626 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2627 }
2628 }
2629 }
2630
2631 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2632 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2633 let tmp = tempfile::tempdir().unwrap();
2634 let agent = tmp.path().join("agent");
2635 let cwd = tmp.path().join("project");
2636 let first_skill_dir = tmp.path().join("first-skills");
2637 let second_skill_dir = tmp.path().join("second-skills");
2638 std::fs::create_dir_all(&agent).unwrap();
2639 std::fs::create_dir_all(&cwd).unwrap();
2640 std::fs::write(
2641 agent.join("settings.json"),
2642 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2643 )
2644 .unwrap();
2645 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2646 let args = Args {
2647 trust_override: Some(false),
2648 no_extensions: true,
2649 no_prompt_templates: true,
2650 ..Args::default()
2651 };
2652
2653 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2654 std::fs::write(
2655 agent.join("settings.json"),
2656 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2657 )
2658 .unwrap();
2659 });
2660
2661 let error = result
2662 .err()
2663 .expect("settings mutation must fail preparation");
2664 assert!(error.contains("settings changed while reload inputs were being prepared"));
2665 }
2666
2667 #[tokio::test(flavor = "current_thread")]
2668 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2669 struct RestoreConfigDir(Option<std::ffi::OsString>);
2670
2671 impl Drop for RestoreConfigDir {
2672 fn drop(&mut self) {
2673 match self.0.take() {
2674 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2675 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2676 }
2677 }
2678 }
2679
2680 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2681 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2682 let tmp = tempfile::tempdir().unwrap();
2683 let agent = tmp.path().join("agent");
2684 let cwd = tmp.path().join("project");
2685 let skill_dir = tmp.path().join("configured-skills");
2686 std::fs::create_dir_all(&agent).unwrap();
2687 std::fs::create_dir_all(&cwd).unwrap();
2688 std::fs::create_dir_all(&skill_dir).unwrap();
2689 std::fs::write(
2690 skill_dir.join("SKILL.md"),
2691 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2692 )
2693 .unwrap();
2694 std::fs::write(
2695 agent.join("settings.json"),
2696 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2697 )
2698 .unwrap();
2699 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2700
2701 let resolved = crate::provider::resolve(
2702 Some("anthropic"),
2703 Some(crate::provider::DEFAULT_MODEL_ID),
2704 None,
2705 Some("test-key"),
2706 None,
2707 )
2708 .unwrap();
2709 let args = Args {
2710 trust_override: Some(false),
2711 no_session: true,
2712 no_extensions: true,
2713 no_prompt_templates: true,
2714 no_context_files: true,
2715 system_prompt: Some("stable system prompt".into()),
2716 ..Args::default()
2717 };
2718 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2719
2720 let outcome = reload_extension_resources_inner(&harness, &context, || {
2721 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2722 })
2723 .await;
2724
2725 assert!(!outcome.summary.contains("Settings reload failed"));
2726 assert_eq!(
2727 outcome.summary,
2728 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2729 );
2730 let resources = harness.get_resources().await.unwrap();
2731 let skills = resources.skills.as_ref().unwrap();
2732 assert_eq!(skills.len(), 1);
2733 assert_eq!(skills[0].name, "frozen-skill");
2734 }
2735
2736 #[tokio::test]
2737 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2738 let tmp = tempfile::tempdir().unwrap();
2739 let skill_path = tmp.path().join("explicit-skill.md");
2740 std::fs::write(
2741 &skill_path,
2742 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2743 )
2744 .unwrap();
2745 let prompt_path = tmp.path().join("explicit-prompt.md");
2746 std::fs::write(
2747 &prompt_path,
2748 "---\ndescription: Explicit prompt\n---\nPrompt body",
2749 )
2750 .unwrap();
2751
2752 let args = Args {
2753 skill: vec![skill_path.clone()],
2754 prompt_template: vec![prompt_path.clone()],
2755 ..Args::default()
2756 };
2757 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2758 let prompt_paths =
2759 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2760 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2761 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2762
2763 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2764 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2765 assert_eq!(skills.skills[0].name, "explicit-skill");
2766
2767 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2768 assert_eq!(
2769 prompts.prompt_templates.len(),
2770 1,
2771 "{:?}",
2772 prompts.diagnostics
2773 );
2774 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2775 }
2776
2777 #[test]
2778 fn tool_policy_applies_to_rust_and_js_active_names() {
2779 let names = vec![
2780 "read".to_string(),
2781 "ask_user_question".to_string(),
2782 "write".to_string(),
2783 ];
2784
2785 let args = Args {
2786 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2787 ..Args::default()
2788 };
2789 assert_eq!(
2790 filter_active_tool_names(names.clone(), &args),
2791 vec!["read", "ask_user_question"]
2792 );
2793
2794 let args = Args {
2795 exclude_tools: Some(vec!["ask_user_question".into()]),
2796 ..Args::default()
2797 };
2798 assert_eq!(
2799 filter_active_tool_names(names.clone(), &args),
2800 vec!["read", "write"]
2801 );
2802
2803 let args = Args {
2804 no_tools: true,
2805 ..Args::default()
2806 };
2807 assert!(filter_active_tool_names(names, &args).is_empty());
2808 }
2809
2810 #[test]
2811 fn select_ephemeral_when_no_session() {
2812 let args = Args {
2813 no_session: true,
2814 ..Args::default()
2815 };
2816 let cwd = Path::new("/tmp");
2817 assert!(matches!(
2818 select_session(&args, cwd),
2819 SessionSelection::Ephemeral
2820 ));
2821 }
2822
2823 #[test]
2824 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2825 let defaults = Args::default();
2826 assert!(resolve_project_trust(
2827 &defaults,
2828 Path::new("C:/definitely-not-a-project")
2829 ));
2830
2831 let denied = Args {
2832 trust_override: Some(false),
2833 ..Args::default()
2834 };
2835 assert!(!resolve_project_trust(
2836 &denied,
2837 Path::new("C:/definitely-not-a-project")
2838 ));
2839 }
2840
2841 #[tokio::test(flavor = "current_thread")]
2842 async fn build_preserves_the_supplied_startup_project_snapshot() {
2843 struct RestoreConfigDir(Option<std::ffi::OsString>);
2844
2845 impl Drop for RestoreConfigDir {
2846 fn drop(&mut self) {
2847 match self.0.take() {
2848 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2849 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2850 }
2851 }
2852 }
2853
2854 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2855 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2856 let tmp = tempfile::tempdir().unwrap();
2857 let agent = tmp.path().join("agent");
2858 let cwd = tmp.path().join("project");
2859 std::fs::create_dir_all(&agent).unwrap();
2860 std::fs::create_dir_all(&cwd).unwrap();
2861 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2862
2863 let resolved = crate::provider::resolve(
2864 Some("anthropic"),
2865 Some(crate::provider::DEFAULT_MODEL_ID),
2866 None,
2867 Some("test-key"),
2868 None,
2869 )
2870 .unwrap();
2871 let args = Args {
2872 trust_override: Some(false),
2873 no_session: true,
2874 no_tools: true,
2875 no_extensions: true,
2876 no_skills: true,
2877 no_prompt_templates: true,
2878 no_context_files: true,
2879 system_prompt: Some("test prompt".into()),
2880 ..Args::default()
2881 };
2882
2883 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2884
2885 assert_eq!(context.cwd, cwd);
2886 assert!(context.project_trusted);
2887 }
2888
2889 #[test]
2890 fn select_latest_for_continue_and_resume() {
2891 let args = Args {
2892 continue_session: true,
2893 ..Args::default()
2894 };
2895 let cwd = Path::new("/tmp");
2896 assert!(matches!(
2897 select_session(&args, cwd),
2898 SessionSelection::Latest
2899 ));
2900
2901 let args = Args {
2902 resume: true,
2903 ..Args::default()
2904 };
2905 assert!(matches!(
2906 select_session(&args, cwd),
2907 SessionSelection::Latest
2908 ));
2909 }
2910
2911 #[test]
2912 fn select_by_id_for_session_flag() {
2913 let args = Args {
2914 session: Some("01a02ece".into()),
2915 ..Args::default()
2916 };
2917 let cwd = Path::new("/tmp");
2918 assert!(matches!(
2919 select_session(&args, cwd),
2920 SessionSelection::ById { id } if id == "01a02ece"
2921 ));
2922 }
2923
2924 #[test]
2925 fn select_new_with_custom_dir() {
2926 let args = Args {
2927 session_dir: Some(PathBuf::from("/tmp/sess")),
2928 ..Args::default()
2929 };
2930 let cwd = Path::new("/tmp");
2931 match select_session(&args, cwd) {
2932 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2933 other => panic!("expected New, got {other:?}"),
2934 }
2935 }
2936
2937 #[test]
2938 fn select_new_default_dir() {
2939 let args = Args::default();
2940 let cwd = Path::new("/proj");
2941 match select_session(&args, cwd) {
2942 SessionSelection::New { dir, .. } => {
2943 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2944 }
2945 other => panic!("expected New, got {other:?}"),
2946 }
2947 }
2948
2949 #[test]
2950 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2951 let tmp = tempfile::tempdir().unwrap();
2952 let cwd = tmp.path();
2953 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2954 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2955 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2956 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2957 }
2958
2959 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2960 async fn ephemeral_session_builds_roundtrips() {
2961 let s = ephemeral_session();
2965 let leaf = s.get_leaf_id().await;
2966 assert!(leaf.is_ok());
2967 }
2968
2969 }