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 if !args.no_skills {
519 let mut dirs = if args.dev_local_only {
520 project_skill_dirs(cwd)
521 } else if project_trusted {
522 skill_dirs(cwd)
523 } else {
524 global_skill_dirs()
525 };
526 dirs.extend(args.skill.iter().cloned());
527 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
528 if let Some(session) = &js_extension_session {
529 dirs.extend(session.resources.skill_paths.iter().cloned());
530 }
531 dirs.extend(package_resources.skill_dirs());
532 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
533 skills = result.skills;
534 skill_diags = result.diagnostics;
535 }
536
537 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
538 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
539 if !args.no_prompt_templates {
540 let mut dirs = if args.dev_local_only {
541 project_prompt_template_dirs(cwd)
542 } else if project_trusted {
543 prompt_template_dirs(cwd)
544 } else {
545 global_prompt_template_dirs()
546 };
547 dirs.extend(args.prompt_template.iter().cloned());
548 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
549 if let Some(session) = &js_extension_session {
550 dirs.extend(session.resources.prompt_paths.iter().cloned());
551 }
552 dirs.extend(package_resources.prompt_dirs());
553 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
554 prompt_templates = result.prompt_templates;
555 prompt_diags = result.diagnostics;
556 }
557
558 let context_block = if args.no_context_files || !project_trusted {
559 String::new()
560 } else {
561 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
566 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
567 format_project_context(&files)
568 };
569
570 if args.verbose {
572 for d in &package_resources.diagnostics {
573 eprintln!("warning: package {}: {}", d.spec, d.message);
574 }
575 for d in &skill_diags {
576 eprintln!(
577 "warning: skill {} ({}): {}",
578 d.path,
579 d.code.as_str(),
580 d.message
581 );
582 }
583 for d in &prompt_diags {
584 eprintln!(
585 "warning: prompt template {} ({}): {}",
586 d.path,
587 d.code.as_str(),
588 d.message
589 );
590 }
591 }
592
593 let system_prompt = compose_system_prompt(
602 Some(&base_prompt),
603 &[], if context_block.is_empty() {
605 None
606 } else {
607 Some(&context_block)
608 },
609 append_join.as_deref(),
610 );
611
612 if args.debug_system_prompt {
621 eprintln!("=== --debug-system-prompt ===");
622 let base_src = if args.system_prompt.is_some() {
623 "--system-prompt"
624 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
625 "SYSTEM.md"
626 } else {
627 "default"
628 };
629 eprintln!("[base source: {base_src}]");
630 eprintln!("--- base ---\n{base_prompt}");
631 if let Some(append) = append_join.as_deref() {
632 eprintln!("--- append ---\n{append}");
633 } else {
634 eprintln!("--- append: (none) ---");
635 }
636 if context_block.is_empty() {
637 eprintln!("--- context: (none) ---");
638 } else {
639 eprintln!("--- context ---{context_block}");
640 }
641 let visible_skills = skills
642 .iter()
643 .filter(|s| s.disable_model_invocation != Some(true))
644 .count();
645 eprintln!(
646 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
647 skills.len(),
648 visible_skills,
649 skills.len() - visible_skills
650 );
651 for s in &skills {
652 let hidden = if s.disable_model_invocation == Some(true) {
653 " [hidden]"
654 } else {
655 ""
656 };
657 eprintln!(" {}{hidden} — {}", s.name, s.description);
658 }
659 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
660 for t in &prompt_templates {
661 eprintln!(" /{}", t.name);
662 }
663 eprintln!(
667 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
668 discovered.skill_paths.len(),
669 discovered.prompt_paths.len(),
670 discovered.theme_paths.len(),
671 );
672 for p in &discovered.skill_paths {
673 eprintln!(" skill: {p}");
674 }
675 for p in &discovered.prompt_paths {
676 eprintln!(" prompt: {p}");
677 }
678 eprintln!(
679 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
680 );
681 eprintln!("=== end --debug-system-prompt ===");
682 }
683
684 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
689 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
690 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
694
695 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
704 Some(snapshot) => {
705 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
706 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
707 }
708 None => broadcast_emitter,
709 };
710
711 let options = AgentHarnessOptions {
712 model: resolved.model.clone(),
713 thinking_level: resolved.thinking_level,
714 active_tool_names: active,
715 tools,
716 system_prompt: Some(system_prompt),
717 resources: AgentHarnessResources {
718 skills: if skills.is_empty() {
719 None
720 } else {
721 Some(skills)
722 },
723 prompt_templates: if prompt_templates.is_empty() {
724 None
725 } else {
726 Some(prompt_templates)
727 },
728 },
729 allow_existing_session: matches!(
732 selection,
733 SessionSelection::Latest
734 | SessionSelection::ById { .. }
735 | SessionSelection::ByExactId { .. }
736 | SessionSelection::Fork { .. }
737 ),
738 stream_options: AgentHarnessStreamOptions {
739 timeout: args.timeout,
740 ..Default::default()
741 },
742 retry: RetryPolicy::default(),
743 compaction: CompactionSettings::default(),
744 steering_mode: Default::default(),
745 follow_up_mode: Default::default(),
746 tool_execution: HarnessToolExecution::default(),
747 drive: DrivingMode::default(),
748 session,
749 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
757 to_provider_messages: None,
758 entry_projectors: Default::default(),
759 agent_emitter: Some(emitter),
760 before_tool_call: None,
766 after_tool_call: None,
767 transform_context: None,
768 entry_transforms: Vec::new(),
769 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
775 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
776 };
777
778 let harness = match AgentHarness::create(options).await {
779 Ok(h) => {
780 crate::extensions_actions::HarnessActionHost::set_harness(
783 &harness_cell,
784 Arc::new(h.clone()),
785 );
786 h
787 }
788 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
789 };
790
791 {
799 let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
800 match rpi_harness::runtime::recover_all_lanes(&runtime).await {
801 Ok(reports) => {
802 for (lane, report) in reports {
803 if report.is_clean() {
804 continue;
805 }
806 if report.is_corrupt() {
807 eprintln!(
808 "warning: session lane '{lane}' is corrupt ({} open operations); \
809 refusing to auto-repair — inspect the session file",
810 report.open_operations.len()
811 );
812 continue;
813 }
814 for decision in runtime.reconcile(&report) {
815 if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
816 if args.verbose {
817 eprintln!(
818 "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
819 );
820 }
821 }
822 }
823 runtime.emit_recovery_events(harness.events(), &report);
824 }
825 }
826 Err(e) => {
827 if args.verbose {
828 eprintln!("warning: session recovery sweep failed: {e}");
829 }
830 }
831 }
832 }
833
834 let reload_context = ReloadContext {
841 extension_session: Arc::new(Mutex::new(extension_session)),
842 js_extension_session: js_extension_session.clone(),
843 package_resources: Arc::new(package_resources.clone()),
844 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
845 catalog,
846 gateway: resolved.provider.clone(),
847 runtime: runtime.clone(),
848 cwd: cwd.to_path_buf(),
849 project_trusted,
850 args: args.clone(),
851 resolved_model: resolved.model.clone(),
852 broadcast: broadcast_for_context,
853 mailbox: reload_mailbox,
854 ui_dialog: ui_dialog_mailbox,
855 dev_extension: None,
856 };
857
858 Ok((harness, event_rx, reload_context))
859}
860
861pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
906
907pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
912
913#[derive(Clone)]
921pub struct ReloadContext {
922 pub extension_session: ExtensionSessionCell,
924 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
926 pub package_resources: Arc<crate::packages::PackageResources>,
930 pub action_bridge: ActionBridgeCell,
932 pub catalog: Vec<rpi_ai::Model>,
937 pub gateway: Arc<dyn Provider>,
940 pub runtime: tokio::runtime::Handle,
944 pub cwd: PathBuf,
946 pub project_trusted: bool,
950 pub args: Args,
956 pub resolved_model: rpi_ai::Model,
959 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
965 pub mailbox: rpi_extensions::ReloadMailbox,
972 pub ui_dialog: rpi_extensions::UiDialogMailbox,
978 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
982}
983
984pub async fn dispatch_session_event_async(
999 reload_context: &ReloadContext,
1000 tag: rpi_plugin_sdk::EventTag,
1001) -> Option<String> {
1002 let (snapshot, keepalive) = {
1008 let session = reload_context.extension_session.lock().unwrap();
1009 (session.snapshot_arc(), session.keepalive())
1010 };
1011 match snapshot {
1012 Some(snapshot) => {
1013 let _keepalive = keepalive;
1014 rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
1015 }
1016 None => None,
1017 }
1018}
1019
1020pub struct ReloadOutcome {
1023 pub summary: String,
1026 pub had_warnings: bool,
1028 pub details: Vec<String>,
1033 pub had_errors: bool,
1037}
1038
1039struct PreparedReloadInputs {
1040 package_resources: crate::packages::PackageResources,
1041 extension_dirs: Vec<PathBuf>,
1042 skill_base_dirs: Vec<PathBuf>,
1043 prompt_base_dirs: Vec<PathBuf>,
1044}
1045
1046fn append_reload_resource_paths(
1051 mut paths: Vec<PathBuf>,
1052 explicit: &[PathBuf],
1053 discovered: &[String],
1054 js_paths: &[PathBuf],
1055 package_paths: &[PathBuf],
1056) -> Vec<PathBuf> {
1057 paths.extend(explicit.iter().cloned());
1058 paths.extend(discovered.iter().map(PathBuf::from));
1059 paths.extend(js_paths.iter().cloned());
1060 paths.extend(package_paths.iter().cloned());
1061 paths
1062}
1063
1064pub async fn reload_extension_resources(
1076 harness: &AgentHarness,
1077 ctx: &ReloadContext,
1078) -> ReloadOutcome {
1079 reload_extension_resources_inner(harness, ctx, || {}).await
1080}
1081
1082async fn reload_extension_resources_inner<F>(
1083 harness: &AgentHarness,
1084 ctx: &ReloadContext,
1085 after_prepare: F,
1086) -> ReloadOutcome
1087where
1088 F: FnOnce() + Send,
1089{
1090 let mut effective_args = ctx.args.clone();
1095 if let Some(dev) = &ctx.dev_extension {
1096 if let Err(error) = dev
1097 .rebuild()
1098 .and_then(|_| dev.apply_to_args(&mut effective_args))
1099 {
1100 return ReloadOutcome {
1101 summary: format!(
1102 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1103 dev.package_name()
1104 ),
1105 had_warnings: true,
1106 details: Vec::new(),
1107 had_errors: true,
1108 };
1109 }
1110 }
1111 let cwd_str = ctx.cwd.to_string_lossy().to_string();
1112 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1113 let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1114 Ok(prepared) => prepared,
1115 Err(error) => {
1116 return ReloadOutcome {
1117 summary: format!(
1118 "Settings reload failed: {error}. Keeping the currently loaded resources."
1119 ),
1120 had_warnings: true,
1121 details: Vec::new(),
1122 had_errors: true,
1123 };
1124 }
1125 };
1126 after_prepare();
1127 let PreparedReloadInputs {
1128 package_resources,
1129 extension_dirs,
1130 skill_base_dirs,
1131 prompt_base_dirs,
1132 } = prepared;
1133 let mut warnings = false;
1134
1135 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1142 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1143 Some(b) => b.clone_host(),
1144 None => {
1145 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1148 ctx.catalog.clone(),
1149 ctx.cwd.clone(),
1150 ctx.runtime.clone(),
1151 ctx.args.unknown_flags.clone(),
1152 );
1153 crate::extensions_actions::HarnessActionHost::set_harness(
1154 &_cell,
1155 Arc::new(harness.clone()),
1156 );
1157 Arc::new(action_host)
1158 }
1159 };
1160
1161 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1162 let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1163 ctx.runtime.clone(),
1164 host,
1165 reload_cb,
1166 ctx.ui_dialog.clone(),
1167 );
1168
1169 let extension_session = if effective_args.no_extensions {
1170 rpi_extensions::ExtensionSession::none()
1171 } else {
1172 load_extensions_from_dirs(
1173 &effective_args,
1174 &extension_dirs,
1175 Some(Arc::clone(&fresh_bridge)),
1176 )
1177 };
1178 if extension_session.is_empty() && !effective_args.no_extensions {
1179 }
1182 if effective_args.verbose {
1183 if let Some(s) = extension_session.summary() {
1184 eprintln!("reload: {s}");
1185 }
1186 report_deferred_renderers(&extension_session);
1187 }
1188
1189 {
1203 let mut session_guard = ctx.extension_session.lock().unwrap();
1204 let old_session = std::mem::replace(
1205 &mut *session_guard,
1206 rpi_extensions::ExtensionSession::none(),
1207 );
1208 if let Some(old_snap) = old_session.snapshot_arc() {
1209 old_snap.active_flag().store(false, Ordering::SeqCst);
1213 }
1214 }
1218 if let Some(old_b) = old_bridge {
1219 old_b.invalidate();
1220 }
1221
1222 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1225 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1226
1227 let discovered = extension_session
1229 .snapshot_arc()
1230 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1231 .unwrap_or_default();
1232
1233 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1235 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1236
1237 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1238 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1239 if !effective_args.no_skills {
1240 let js_paths: &[PathBuf] = ctx
1244 .js_extension_session
1245 .as_ref()
1246 .map(|js| js.resources.skill_paths.as_slice())
1247 .unwrap_or(&[]);
1248 let package_paths = package_resources.skill_dirs();
1249 let dirs = append_reload_resource_paths(
1250 skill_base_dirs,
1251 &effective_args.skill,
1252 &discovered.skill_paths,
1253 js_paths,
1254 &package_paths,
1255 );
1256 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1257 skills = result.skills;
1258 skill_diags = result.diagnostics;
1259 }
1260
1261 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1262 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1263 if !effective_args.no_prompt_templates {
1264 let js_paths: &[PathBuf] = ctx
1265 .js_extension_session
1266 .as_ref()
1267 .map(|js| js.resources.prompt_paths.as_slice())
1268 .unwrap_or(&[]);
1269 let package_paths = package_resources.prompt_dirs();
1270 let dirs = append_reload_resource_paths(
1271 prompt_base_dirs,
1272 &effective_args.prompt_template,
1273 &discovered.prompt_paths,
1274 js_paths,
1275 &package_paths,
1276 );
1277 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1278 prompt_templates = result.prompt_templates;
1279 prompt_diags = result.diagnostics;
1280 }
1281
1282 let context_block = if effective_args.no_context_files {
1283 String::new()
1284 } else {
1285 let agent_dir = crate::config::agent_dir().ok();
1286 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1287 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1288 format_project_context(&files)
1289 };
1290
1291 let mut details: Vec<String> = Vec::new();
1292 if !skill_diags.is_empty()
1293 || !prompt_diags.is_empty()
1294 || !package_resources.diagnostics.is_empty()
1295 {
1296 warnings = true;
1297 for d in &package_resources.diagnostics {
1302 details.push(format!("warning: package {}: {}", d.spec, d.message));
1303 }
1304 for d in &skill_diags {
1305 details.push(format!(
1306 "warning: skill {} ({}): {}",
1307 d.path,
1308 d.code.as_str(),
1309 d.message
1310 ));
1311 }
1312 for d in &prompt_diags {
1313 details.push(format!(
1314 "warning: prompt template {} ({}): {}",
1315 d.path,
1316 d.code.as_str(),
1317 d.message
1318 ));
1319 }
1320 }
1321
1322 let base_prompt = match effective_args.system_prompt.as_deref() {
1324 Some(explicit) => explicit.to_string(),
1325 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1326 Some(path) => {
1327 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1328 }
1329 None => default_system_prompt(&cwd_str),
1330 },
1331 };
1332 let mut append_texts: Vec<String> = Vec::new();
1333 for extra in &effective_args.append_system_prompt {
1334 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1335 append_texts.push(text);
1336 }
1337 if effective_args.append_system_prompt.is_empty() {
1338 if let Some(path) =
1339 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1340 {
1341 if let Ok(text) = std::fs::read_to_string(&path) {
1342 append_texts.push(text);
1343 }
1344 }
1345 }
1346 let append_join = if append_texts.is_empty() {
1347 None
1348 } else {
1349 Some(append_texts.join("\n\n"))
1350 };
1351 let system_prompt = compose_system_prompt(
1352 Some(&base_prompt),
1353 &[],
1354 if context_block.is_empty() {
1355 None
1356 } else {
1357 Some(&context_block)
1358 },
1359 append_join.as_deref(),
1360 );
1361
1362 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1364 Some(snapshot) => {
1365 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1366 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1367 }
1368 None => ctx.broadcast.clone(),
1369 };
1370
1371 let resources = AgentHarnessResources {
1373 skills: if skills.is_empty() {
1374 None
1375 } else {
1376 Some(skills.clone())
1377 },
1378 prompt_templates: if prompt_templates.is_empty() {
1379 None
1380 } else {
1381 Some(prompt_templates.clone())
1382 },
1383 };
1384 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1385 let _ = harness.set_resources(resources).await;
1386 let _ = harness.set_agent_emitter(Some(emitter)).await;
1387 let _ = harness
1388 .set_models(build_models_with_extensions_for_reload(
1389 &ctx.gateway,
1390 &extension_session,
1391 ctx.runtime.clone(),
1392 ))
1393 .await;
1394 let _ = harness
1395 .set_provider_hooks(
1396 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1397 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1398 )
1399 .await;
1400
1401 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1405 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1406 let mut tools = build_tools(&tool_ctx, &effective_args);
1407 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1408 if let Some(js) = &ctx.js_extension_session {
1409 merge_js_extension_tools(&mut tools, js, &effective_args);
1410 }
1411 let mut active = active_tool_names(&tools, &effective_args);
1412 if let Some(js) = &ctx.js_extension_session {
1413 let js_names = js.tool_names();
1414 if let Some(js_active) = js.active_tools() {
1415 active.retain(|name| {
1416 tool_name_allowed(name, &effective_args)
1417 && !js_names.iter().any(|js_name| js_name == name)
1418 });
1419 active.extend(js_active.into_iter().filter(|name| {
1420 js_names.iter().any(|js_name| js_name == name)
1421 && tool_name_allowed(name, &effective_args)
1422 }));
1423 }
1424 }
1425 active = filter_active_tool_names(active, &effective_args);
1426 let _ = harness.set_tools(tools, Some(active)).await;
1427
1428 let summary = format!(
1429 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1430 extension_session.loaded_paths().len(),
1431 skills.len(),
1432 prompt_templates.len(),
1433 );
1434 ReloadOutcome {
1435 summary,
1436 had_warnings: warnings,
1437 details,
1438 had_errors: false,
1441 }
1442}
1443
1444#[derive(Clone, Debug, PartialEq, Eq)]
1445struct ReloadSettingsFields {
1446 packages: Option<Vec<crate::settings::PackageSetting>>,
1447 npm_command: Option<Vec<String>>,
1448 skill_dirs: Option<Vec<String>>,
1449 prompt_dirs: Option<Vec<String>>,
1450 extension_dirs: Option<Vec<String>>,
1451}
1452
1453impl From<crate::settings::Settings> for ReloadSettingsFields {
1454 fn from(settings: crate::settings::Settings) -> Self {
1455 Self {
1456 packages: settings.packages,
1457 npm_command: settings.npm_command,
1458 skill_dirs: settings.skill_dirs,
1459 prompt_dirs: settings.prompt_dirs,
1460 extension_dirs: settings.extension_dirs,
1461 }
1462 }
1463}
1464
1465#[derive(Clone, Debug, PartialEq, Eq)]
1466struct ReloadSettingsSnapshot {
1467 global: ReloadSettingsFields,
1468 project: Option<ReloadSettingsFields>,
1469}
1470
1471fn reload_reads_settings(args: &Args) -> bool {
1472 !args.dev_local_only
1473 && (should_load_js_packages(args)
1474 || !args.no_extensions
1475 || !args.no_skills
1476 || !args.no_prompt_templates)
1477}
1478
1479fn load_reload_settings_snapshot(
1483 args: &Args,
1484 cwd: &Path,
1485 project_trusted: bool,
1486) -> Result<Option<ReloadSettingsSnapshot>, String> {
1487 if !reload_reads_settings(args) {
1488 return Ok(None);
1489 }
1490 let global = crate::settings::load_settings()
1491 .map_err(|error| format!("could not load global settings: {error}"))?
1492 .into();
1493 let project = if project_trusted {
1494 crate::settings::load_active_project_settings(cwd)
1495 .map_err(|error| format!("could not load project settings: {error}"))?
1496 .map(|(_, settings)| settings.into())
1497 } else {
1498 None
1499 };
1500 Ok(Some(ReloadSettingsSnapshot { global, project }))
1501}
1502
1503fn validate_settings_for_reload(
1506 args: &Args,
1507 cwd: &Path,
1508 project_trusted: bool,
1509) -> Result<(), String> {
1510 load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1511}
1512
1513fn prepare_reload_inputs(
1514 args: &Args,
1515 cwd: &Path,
1516 project_trusted: bool,
1517) -> Result<PreparedReloadInputs, String> {
1518 prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1519}
1520
1521fn prepare_reload_inputs_inner<F>(
1522 args: &Args,
1523 cwd: &Path,
1524 project_trusted: bool,
1525 before_verify: F,
1526) -> Result<PreparedReloadInputs, String>
1527where
1528 F: FnOnce(),
1529{
1530 let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1531
1532 let package_resources = if args.dev_local_only {
1533 crate::packages::PackageResources::default()
1534 } else {
1535 package_resources_for(args, cwd, project_trusted)
1536 };
1537
1538 let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1539 Vec::new()
1540 } else if project_trusted {
1541 extension_dirs(cwd)
1542 } else {
1543 global_extension_dirs()
1544 };
1545 if !args.no_extensions {
1546 extension_dirs.extend(args.extensions_dir.iter().cloned());
1547 }
1548
1549 let skill_base_dirs = if args.no_skills {
1550 Vec::new()
1551 } else if args.dev_local_only {
1552 project_skill_dirs(cwd)
1553 } else if project_trusted {
1554 skill_dirs(cwd)
1555 } else {
1556 global_skill_dirs()
1557 };
1558
1559 let prompt_base_dirs = if args.no_prompt_templates {
1560 Vec::new()
1561 } else if args.dev_local_only {
1562 project_prompt_template_dirs(cwd)
1563 } else if project_trusted {
1564 prompt_template_dirs(cwd)
1565 } else {
1566 global_prompt_template_dirs()
1567 };
1568
1569 before_verify();
1570 let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1571 if settings_before != settings_after {
1572 return Err(
1573 "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1574 );
1575 }
1576
1577 Ok(PreparedReloadInputs {
1578 package_resources,
1579 extension_dirs,
1580 skill_base_dirs,
1581 prompt_base_dirs,
1582 })
1583}
1584
1585fn build_models_with_extensions_for_reload(
1590 gateway: &Arc<dyn Provider>,
1591 extension_session: &ExtensionSession,
1592 runtime: tokio::runtime::Handle,
1593) -> Vec<Arc<dyn Provider>> {
1594 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1595 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1596 models.extend(pluggable);
1597 models
1598}
1599
1600fn report_deferred_renderers(session: &ExtensionSession) {
1604 let Some(snap) = session.snapshot_arc() else {
1605 return;
1606 };
1607 let all = snap.renderers();
1608 let markdown = all
1609 .iter()
1610 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1611 .count();
1612 let message = all
1613 .iter()
1614 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1615 .count();
1616 let entry = all
1617 .iter()
1618 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1619 .count();
1620 if markdown + message + entry == 0 {
1621 return;
1622 }
1623 eprintln!(
1624 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1625 markdown, message, entry
1626 );
1627}
1628
1629#[derive(Debug, thiserror::Error)]
1631pub enum BuildError {
1632 #[error("Could not create the session directory: {0}")]
1633 SessionDir(String),
1634 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1635 SessionNotFound { requested: String, dir: String },
1636 #[error("Could not build the harness: {0}")]
1637 HarnessCreate(String),
1638}
1639
1640fn build_models_with_extensions(
1650 resolved: &ResolvedModel,
1651 extension_session: &ExtensionSession,
1652 runtime: tokio::runtime::Handle,
1653) -> Vec<Arc<dyn Provider>> {
1654 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1655 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1656 models.extend(pluggable);
1657 models
1658}
1659
1660pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1663 if let Some(override_value) = args.trust_override {
1664 return override_value;
1665 }
1666 crate::config::project_trust_decision(cwd)
1667 .ok()
1668 .flatten()
1669 .unwrap_or(true)
1670}
1671
1672fn load_extensions(
1680 args: &Args,
1681 cwd: &Path,
1682 project_trusted: bool,
1683 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1684) -> ExtensionSession {
1685 let mut dirs = if args.dev_local_only {
1686 Vec::new()
1687 } else if project_trusted {
1688 extension_dirs(cwd)
1689 } else {
1690 global_extension_dirs()
1691 };
1692 dirs.extend(args.extensions_dir.iter().cloned());
1693 load_extensions_from_dirs(args, &dirs, action_bridge)
1694}
1695
1696fn load_extensions_from_dirs(
1697 args: &Args,
1698 dirs: &[PathBuf],
1699 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1700) -> ExtensionSession {
1701 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1702 rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1709}
1710
1711fn js_extension_paths(
1712 args: &Args,
1713 cwd: &Path,
1714 project_trusted: bool,
1715 packages: &crate::packages::PackageResources,
1716) -> Vec<PathBuf> {
1717 if args.dev_local_only {
1718 return Vec::new();
1719 }
1720 let mut paths = packages.extension_paths();
1721 let discovered_dirs = if project_trusted {
1722 extension_dirs(cwd)
1723 } else {
1724 global_extension_dirs()
1725 };
1726 for dir in discovered_dirs {
1727 if let Ok(entries) = std::fs::read_dir(dir) {
1728 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1729 matches!(
1730 path.extension()
1731 .and_then(|ext| ext.to_str())
1732 .map(|ext| ext.to_ascii_lowercase())
1733 .as_deref(),
1734 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1735 )
1736 }));
1737 }
1738 }
1739 paths.extend(
1740 args.extension
1741 .iter()
1742 .filter(|path| {
1743 matches!(
1744 path.extension()
1745 .and_then(|ext| ext.to_str())
1746 .map(|ext| ext.to_ascii_lowercase())
1747 .as_deref(),
1748 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1749 )
1750 })
1751 .cloned(),
1752 );
1753 paths
1754}
1755
1756fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1762 let Some(snapshot) = session.snapshot() else {
1763 return;
1764 };
1765 for et in snapshot.tools() {
1766 let name = &et.tool.name;
1767 if !tool_name_allowed(name, args) {
1768 continue;
1769 }
1770 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1771 let harness_tool = HarnessTool::new(Arc::new(adapter));
1772 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1773 Some(slot) => *slot = harness_tool,
1774 None => tools.push(harness_tool),
1775 }
1776 }
1777}
1778
1779fn merge_js_extension_tools(
1780 tools: &mut Vec<HarnessTool>,
1781 session: &crate::js_extensions::JsExtensionSession,
1782 args: &Args,
1783) {
1784 for adapter in session.tools() {
1785 let name = adapter.schema().name.clone();
1786 if !tool_name_allowed(&name, args) {
1787 continue;
1788 }
1789 let harness_tool = HarnessTool::new(Arc::new(adapter));
1790 match tools
1791 .iter_mut()
1792 .find(|tool| tool.tool.schema().name == name)
1793 {
1794 Some(slot) => *slot = harness_tool,
1795 None => tools.push(harness_tool),
1796 }
1797 }
1798}
1799
1800pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1807 use rpi_tools::tools::bash::BashToolOptions;
1808 let default = std::env::var("RPI_BASH_TIMEOUT")
1809 .ok()
1810 .and_then(|v| v.parse::<f64>().ok())
1811 .unwrap_or(120.0);
1812 BashToolOptions {
1813 command_prefix: None,
1814 default_timeout: Some(default),
1815 }
1816}
1817
1818fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1819 if args.no_tools {
1820 return Vec::new();
1821 }
1822 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1825 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1826 (
1827 "bash",
1828 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1829 ),
1830 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1831 ("write", HarnessTool::new(create_write_tool(ctx))),
1832 ("docs", HarnessTool::new(create_docs_tool())),
1833 ];
1834
1835 #[cfg(target_os = "windows")]
1837 all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1838
1839 if args.tools.is_none() {
1841 if let Ok(settings) = crate::settings::load_settings() {
1842 if let Some(default_tools) = &settings.default_tools {
1843 all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1844 }
1845 }
1846 }
1847
1848 if args.no_builtin_tools {
1852 all.clear();
1853 }
1854
1855 if let Some(allow) = &args.tools {
1857 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1858 }
1859 if let Some(deny) = &args.exclude_tools {
1861 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1862 }
1863
1864 all.into_iter()
1865 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1866 .collect()
1867}
1868
1869fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1873 filter_active_tool_names(
1874 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1875 args,
1876 )
1877}
1878
1879pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1883 if args.no_tools {
1884 return false;
1885 }
1886 if args
1887 .tools
1888 .as_ref()
1889 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1890 {
1891 return false;
1892 }
1893 if args
1894 .exclude_tools
1895 .as_ref()
1896 .is_some_and(|deny| deny.iter().any(|value| value == name))
1897 {
1898 return false;
1899 }
1900 true
1901}
1902
1903pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1904where
1905 I: IntoIterator<Item = String>,
1906{
1907 names
1908 .into_iter()
1909 .filter(|name| tool_name_allowed(name, args))
1910 .collect()
1911}
1912
1913async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1915 match selection {
1916 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1917 SessionSelection::New { dir, .. } => {
1918 std::fs::create_dir_all(dir)
1921 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1922 let session = create_jsonl_session(dir, cwd)
1923 .await
1924 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1925 Ok(session)
1926 }
1927 SessionSelection::Latest
1928 | SessionSelection::ById { .. }
1929 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1930 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1931 }
1932}
1933
1934async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1940 match selection {
1944 SessionSelection::Latest => {
1945 let metas = list_session_metadata(cwd).await?;
1946 let Some(meta) = metas.first() else {
1947 return Err(BuildError::SessionNotFound {
1948 requested: "the most recent session".to_string(),
1949 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1950 });
1951 };
1952 open_session(meta, cwd).await
1953 }
1954 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1955 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1956 requested,
1957 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1958 },
1959 OpenError::Other(msg) => BuildError::SessionDir(msg),
1960 }),
1961 SessionSelection::ByExactId { id } => {
1962 let metas = list_session_metadata(cwd).await?;
1965 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1966 return open_session(meta, cwd).await;
1967 }
1968 let dir = default_session_dir(Path::new(cwd));
1969 std::fs::create_dir_all(&dir)
1970 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1971 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1972 .await
1973 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1974 }
1975 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1976 }
1977}
1978
1979async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1982 use rpi_harness::session::jsonl::{
1983 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1984 };
1985 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1986 use rpi_tools::FileSystem;
1987
1988 let dir = default_session_dir(Path::new(cwd));
1989 std::fs::create_dir_all(&dir)
1990 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1991 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1992 let fs: Arc<dyn FileSystem> = env.clone();
1993 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1994 fs: fs.clone(),
1995 sessions_root: dir.to_string_lossy().into_owned(),
1996 clock: Arc::new(SystemClock),
1997 ids: Arc::new(DefaultIdGenerator::new()),
1998 });
1999 let metas = repo
2000 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2001 .await
2002 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2003 let source_meta = metas
2004 .iter()
2005 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2006 .ok_or_else(|| BuildError::SessionNotFound {
2007 requested: format!("--fork {source}"),
2008 dir: dir.display().to_string(),
2009 })?;
2010 let fork_storage = repo
2011 .fork_typed(
2012 source_meta,
2013 &JsonlSessionCreateOptions {
2014 id: None,
2015 parent_session_id: Some(source_meta.id.clone()),
2016 cwd: cwd.to_string(),
2017 metadata: None,
2018 },
2019 &ForkOptions::default(),
2020 )
2021 .await
2022 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2023 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2024 Ok(Session::new(storage_arc, None))
2025}
2026
2027pub enum OpenError {
2030 NotFound { requested: String },
2032 Other(String),
2034}
2035
2036impl std::fmt::Display for OpenError {
2037 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2038 match self {
2039 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2040 OpenError::Other(msg) => write!(f, "{msg}"),
2041 }
2042 }
2043}
2044
2045pub async fn list_session_metadata(
2048 cwd: &str,
2049) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2050 use rpi_harness::session::jsonl::{
2051 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2052 };
2053 use rpi_tools::FileSystem;
2054
2055 let dir = default_session_dir(Path::new(cwd));
2056 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2057 let fs: Arc<dyn FileSystem> = env.clone();
2058 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2059 fs: fs.clone(),
2060 sessions_root: dir.to_string_lossy().into_owned(),
2061 clock: Arc::new(SystemClock),
2062 ids: Arc::new(DefaultIdGenerator::new()),
2063 });
2064 repo.list_typed(&JsonlSessionListOptions::default())
2065 .await
2066 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2067}
2068
2069pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2073 let metas = list_session_metadata(cwd)
2074 .await
2075 .map_err(|e| OpenError::Other(e.to_string()))?;
2076 let Some(meta) = metas
2077 .iter()
2078 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2079 else {
2080 return Err(OpenError::NotFound {
2081 requested: format!("session {id}"),
2082 });
2083 };
2084 open_session(meta, cwd)
2085 .await
2086 .map_err(|e| OpenError::Other(e.to_string()))
2087}
2088
2089pub(crate) async fn fork_session_storage(
2096 harness: &AgentHarness,
2097 cwd: &str,
2098) -> Result<Session, String> {
2099 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2100 use rpi_tools::FileSystem;
2101
2102 let dir = default_session_dir(Path::new(cwd));
2103 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2104 let fs: Arc<dyn FileSystem> = env.clone();
2105 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2106 fs,
2107 sessions_root: dir.to_string_lossy().into_owned(),
2108 clock: Arc::new(SystemClock),
2109 ids: Arc::new(DefaultIdGenerator::new()),
2110 });
2111 let id = harness.session().storage().metadata().id.clone();
2114 let metas = list_session_metadata(cwd)
2115 .await
2116 .map_err(|e| e.to_string())?;
2117 let Some(source) = metas.iter().find(|m| m.id == id) else {
2118 return Err(format!("current session {id} not found on disk"));
2119 };
2120 let fork_storage = repo
2121 .fork_typed(
2122 source,
2123 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2124 id: None,
2125 parent_session_id: Some(source.id.clone()),
2126 cwd: cwd.to_string(),
2127 metadata: None,
2128 },
2129 &rpi_harness::session::types::ForkOptions::default(),
2130 )
2131 .await
2132 .map_err(|e| e.to_string())?;
2133 Ok(Session::new(Arc::new(fork_storage), None))
2134}
2135
2136async fn open_session(
2139 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2140 cwd: &str,
2141) -> Result<Session, BuildError> {
2142 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2143 use rpi_harness::session::types::SessionStorage;
2144 use rpi_tools::FileSystem;
2145
2146 let dir = default_session_dir(Path::new(cwd));
2147 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2148 let fs: Arc<dyn FileSystem> = env.clone();
2149 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2150 fs: fs.clone(),
2151 sessions_root: dir.to_string_lossy().into_owned(),
2152 clock: Arc::new(SystemClock),
2153 ids: Arc::new(DefaultIdGenerator::new()),
2154 });
2155 let storage = repo
2156 .open_by_jsonl_metadata(meta)
2157 .await
2158 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2159 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2160 Ok(Session::new(storage_arc, None))
2161}
2162
2163fn ephemeral_session() -> Session {
2165 let storage = Arc::new(InMemorySessionStorage::new(
2166 SessionMetadata {
2167 id: "ephemeral".into(),
2168 created_at: 0,
2169 parent_session_id: None,
2170 },
2171 Arc::new(SystemClock),
2172 Arc::new(DefaultIdGenerator::new()),
2173 ));
2174 Session::new(storage, None)
2175}
2176
2177pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2188 create_jsonl_session_with_id(dir, cwd, None).await
2189}
2190
2191pub(crate) async fn create_jsonl_session_with_id(
2195 dir: &Path,
2196 cwd: &str,
2197 id: Option<String>,
2198) -> Result<Session, String> {
2199 use rpi_harness::session::jsonl::{
2200 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2201 };
2202 use rpi_tools::FileSystem;
2203
2204 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2207 let fs: Arc<dyn FileSystem> = env.clone();
2208
2209 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2210 fs: fs.clone(),
2211 sessions_root: dir.to_string_lossy().into_owned(),
2212 clock: Arc::new(SystemClock),
2213 ids: Arc::new(DefaultIdGenerator::new()),
2214 });
2215
2216 let opts = JsonlSessionCreateOptions {
2217 id, parent_session_id: None,
2219 cwd: cwd.to_string(),
2220 metadata: None,
2221 };
2222 let storage = repo
2223 .create_typed(&opts)
2224 .await
2225 .map_err(|e| format!("create session: {e}"))?;
2226 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2228 Ok(Session::new(storage_arc, None))
2229}
2230
2231fn read_append_target(target: &str) -> Option<String> {
2234 let path = Path::new(target);
2235 if path.is_file() {
2236 std::fs::read_to_string(path).ok()
2237 } else {
2238 None
2239 }
2240}
2241
2242#[cfg(test)]
2243mod tests {
2244 use super::*;
2245 use crate::args::Args;
2246
2247 #[test]
2248 fn default_prompt_mentions_cwd_and_tools() {
2249 let p = default_system_prompt("/tmp/proj");
2250 assert!(p.contains("/tmp/proj"));
2251 assert!(p.contains("read"));
2252 assert!(p.contains("bash"));
2253 assert!(p.contains("edit"));
2254 assert!(p.contains("write"));
2255 assert!(p.contains("docs"));
2256 assert!(!p.contains("- grep"));
2257 assert!(!p.contains("- find"));
2258 assert!(!p.contains("- ls"));
2259 assert!(!p.contains("powershell"));
2260 }
2261
2262 #[test]
2263 fn default_tools_include_docs_lookup() {
2264 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2265 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2266 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2267 let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2268 let names: Vec<String> = build_tools(&context, &Args::default())
2269 .iter()
2270 .map(|tool| tool.tool.schema().name.clone())
2271 .collect();
2272
2273 assert!(names.contains(&"docs".to_string()));
2278 for legacy in ["grep", "find", "ls"] {
2279 assert!(
2280 !names.contains(&legacy.to_string()),
2281 "legacy tool {legacy} should not be registered by default"
2282 );
2283 }
2284 }
2285
2286 #[test]
2287 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2288 let args = Args::default();
2289 assert!(!should_load_js_packages(&args));
2290 let resources = package_resources_for(&args, Path::new("."), false);
2291 assert!(resources.packages.is_empty());
2292
2293 let args = Args {
2294 enable_pi_packages: true,
2295 ..Args::default()
2296 };
2297 assert!(should_load_js_packages(&args));
2298
2299 let args = Args {
2300 enable_pi_packages: true,
2301 no_extensions: true,
2302 ..Args::default()
2303 };
2304 assert!(!should_load_js_packages(&args));
2305 assert!(package_resources_for(&args, Path::new("."), false)
2306 .packages
2307 .is_empty());
2308 }
2309
2310 #[test]
2311 fn pi_offline_env_disables_startup_package_remediation() {
2312 struct RestoreEnv {
2313 name: &'static str,
2314 value: Option<std::ffi::OsString>,
2315 }
2316
2317 impl Drop for RestoreEnv {
2318 fn drop(&mut self) {
2319 match self.value.take() {
2320 Some(value) => std::env::set_var(self.name, value),
2321 None => std::env::remove_var(self.name),
2322 }
2323 }
2324 }
2325
2326 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2327 let _restore_config = RestoreEnv {
2328 name: crate::config::CONFIG_DIR_ENV,
2329 value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2330 };
2331 let _restore_offline = RestoreEnv {
2332 name: crate::args::PI_OFFLINE_ENV,
2333 value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2334 };
2335 let tmp = tempfile::tempdir().unwrap();
2336 let agent = tmp.path().join("agent");
2337 let cwd = tmp.path().join("project");
2338 let package = agent.join("npm/node_modules/demo");
2339 std::fs::create_dir_all(package.join("extensions")).unwrap();
2340 std::fs::create_dir_all(&cwd).unwrap();
2341 std::fs::write(
2342 package.join("package.json"),
2343 r#"{"name":"demo","version":"1.0.0"}"#,
2344 )
2345 .unwrap();
2346 std::fs::write(
2347 package.join("extensions/index.js"),
2348 "export default () => {};",
2349 )
2350 .unwrap();
2351 std::fs::write(
2352 agent.join("settings.json"),
2353 r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2354 )
2355 .unwrap();
2356 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2357 std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2358 let args = Args {
2359 enable_pi_packages: true,
2360 offline: false,
2361 ..Args::default()
2362 };
2363
2364 let resources = package_resources_for(&args, &cwd, false);
2365
2366 assert!(resources.packages.is_empty());
2367 assert_eq!(resources.diagnostics.len(), 1);
2368 assert!(resources.diagnostics[0].message.contains("offline"));
2369 assert_eq!(
2370 std::fs::read(package.join("package.json")).unwrap(),
2371 br#"{"name":"demo","version":"1.0.0"}"#
2372 );
2373 }
2374
2375 #[test]
2376 fn package_discovery_uses_the_callers_trust_snapshot() {
2377 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2378 let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2379 let tmp = tempfile::tempdir().unwrap();
2380 let agent = tmp.path().join("agent");
2381 let cwd = tmp.path().join("project");
2382 let package = cwd.join("package");
2383 std::fs::create_dir_all(&agent).unwrap();
2384 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2385 std::fs::create_dir_all(&package).unwrap();
2386 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2387 std::fs::write(
2388 package.join("package.json"),
2389 r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2390 )
2391 .unwrap();
2392 std::fs::write(
2393 cwd.join(".rpi/settings.json"),
2394 serde_json::json!({"packages": [package]}).to_string(),
2395 )
2396 .unwrap();
2397 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2398
2399 let args = Args {
2400 enable_pi_packages: true,
2401 ..Args::default()
2402 };
2403 assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2404 assert!(package_resources_for(&args, &cwd, false)
2405 .packages
2406 .is_empty());
2407 assert_eq!(
2408 package_resources_for_update_check(&args, &cwd, true)
2409 .packages
2410 .len(),
2411 1
2412 );
2413 assert!(package_resources_for_update_check(&args, &cwd, false)
2414 .packages
2415 .is_empty());
2416
2417 match previous {
2418 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2419 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2420 }
2421 }
2422
2423 #[test]
2424 fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2425 struct RestoreConfigDir(Option<std::ffi::OsString>);
2426
2427 impl Drop for RestoreConfigDir {
2428 fn drop(&mut self) {
2429 match self.0.take() {
2430 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2431 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2432 }
2433 }
2434 }
2435
2436 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2437 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2438 let tmp = tempfile::tempdir().unwrap();
2439 let agent = tmp.path().join("agent");
2440 let cwd = tmp.path().join("project");
2441 std::fs::create_dir_all(&agent).unwrap();
2442 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2443 std::fs::write(agent.join("settings.json"), "{}").unwrap();
2444 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2445 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2446 let packages_disabled = Args::default();
2447 let packages_enabled = Args {
2448 enable_pi_packages: true,
2449 ..Args::default()
2450 };
2451
2452 for args in [&packages_disabled, &packages_enabled] {
2453 let trusted = validate_settings_for_reload(args, &cwd, true);
2454 let untrusted = validate_settings_for_reload(args, &cwd, false);
2455 assert!(trusted
2456 .unwrap_err()
2457 .contains("could not load project settings"));
2458 assert!(untrusted.is_ok());
2459 }
2460
2461 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2462 for args in [&packages_disabled, &packages_enabled] {
2463 assert!(validate_settings_for_reload(args, &cwd, false)
2464 .unwrap_err()
2465 .contains("could not load global settings"));
2466 }
2467
2468 let local_only = Args {
2469 dev_local_only: true,
2470 ..Args::default()
2471 };
2472 assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2473 }
2474
2475 #[tokio::test(flavor = "current_thread")]
2476 async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2477 struct RestoreConfigDir(Option<std::ffi::OsString>);
2478
2479 impl Drop for RestoreConfigDir {
2480 fn drop(&mut self) {
2481 match self.0.take() {
2482 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2483 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2484 }
2485 }
2486 }
2487
2488 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2489 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2490 let tmp = tempfile::tempdir().unwrap();
2491 let agent = tmp.path().join("agent");
2492 let cwd = tmp.path().join("project");
2493 let skill_dir = tmp.path().join("configured-skills");
2494 std::fs::create_dir_all(&agent).unwrap();
2495 std::fs::create_dir_all(&cwd).unwrap();
2496 std::fs::create_dir_all(&skill_dir).unwrap();
2497 std::fs::write(
2498 skill_dir.join("SKILL.md"),
2499 "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2500 )
2501 .unwrap();
2502 std::fs::write(
2503 agent.join("settings.json"),
2504 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2505 )
2506 .unwrap();
2507 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2508
2509 let resolved = crate::provider::resolve(
2510 Some("anthropic"),
2511 Some(crate::provider::DEFAULT_MODEL_ID),
2512 None,
2513 Some("test-key"),
2514 None,
2515 )
2516 .unwrap();
2517 let args = Args {
2518 trust_override: Some(false),
2519 no_session: true,
2520 no_extensions: true,
2521 no_prompt_templates: true,
2522 no_context_files: true,
2523 system_prompt: Some("stable system prompt".into()),
2524 ..Args::default()
2525 };
2526 assert!(!should_load_js_packages(&args));
2527
2528 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2529 let before_resources = harness.get_resources().await.unwrap();
2530 assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2531 assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2532 let before_prompt = harness.get_system_prompt().await.unwrap();
2533 let before_tools: Vec<String> = harness
2534 .get_tools()
2535 .await
2536 .unwrap()
2537 .iter()
2538 .map(|tool| tool.tool.schema().name.clone())
2539 .collect();
2540 let before_bridge = context
2541 .action_bridge
2542 .lock()
2543 .unwrap()
2544 .as_ref()
2545 .unwrap()
2546 .clone();
2547
2548 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2549 let outcome = reload_extension_resources(&harness, &context).await;
2550
2551 assert!(outcome.had_warnings);
2552 assert!(outcome.summary.contains("Settings reload failed"));
2553 assert!(outcome.summary.contains("could not load global settings"));
2554 assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2555 assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2556 let after_tools: Vec<String> = harness
2557 .get_tools()
2558 .await
2559 .unwrap()
2560 .iter()
2561 .map(|tool| tool.tool.schema().name.clone())
2562 .collect();
2563 assert_eq!(after_tools, before_tools);
2564 let after_bridge = context
2565 .action_bridge
2566 .lock()
2567 .unwrap()
2568 .as_ref()
2569 .unwrap()
2570 .clone();
2571 assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2572 }
2573
2574 #[test]
2575 fn reload_preparation_rejects_settings_changed_during_derivation() {
2576 struct RestoreConfigDir(Option<std::ffi::OsString>);
2577
2578 impl Drop for RestoreConfigDir {
2579 fn drop(&mut self) {
2580 match self.0.take() {
2581 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2582 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2583 }
2584 }
2585 }
2586
2587 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2588 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2589 let tmp = tempfile::tempdir().unwrap();
2590 let agent = tmp.path().join("agent");
2591 let cwd = tmp.path().join("project");
2592 let first_skill_dir = tmp.path().join("first-skills");
2593 let second_skill_dir = tmp.path().join("second-skills");
2594 std::fs::create_dir_all(&agent).unwrap();
2595 std::fs::create_dir_all(&cwd).unwrap();
2596 std::fs::write(
2597 agent.join("settings.json"),
2598 serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2599 )
2600 .unwrap();
2601 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2602 let args = Args {
2603 trust_override: Some(false),
2604 no_extensions: true,
2605 no_prompt_templates: true,
2606 ..Args::default()
2607 };
2608
2609 let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2610 std::fs::write(
2611 agent.join("settings.json"),
2612 serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2613 )
2614 .unwrap();
2615 });
2616
2617 let error = result
2618 .err()
2619 .expect("settings mutation must fail preparation");
2620 assert!(error.contains("settings changed while reload inputs were being prepared"));
2621 }
2622
2623 #[tokio::test(flavor = "current_thread")]
2624 async fn reload_uses_frozen_settings_inputs_after_preparation() {
2625 struct RestoreConfigDir(Option<std::ffi::OsString>);
2626
2627 impl Drop for RestoreConfigDir {
2628 fn drop(&mut self) {
2629 match self.0.take() {
2630 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2631 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2632 }
2633 }
2634 }
2635
2636 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2637 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2638 let tmp = tempfile::tempdir().unwrap();
2639 let agent = tmp.path().join("agent");
2640 let cwd = tmp.path().join("project");
2641 let skill_dir = tmp.path().join("configured-skills");
2642 std::fs::create_dir_all(&agent).unwrap();
2643 std::fs::create_dir_all(&cwd).unwrap();
2644 std::fs::create_dir_all(&skill_dir).unwrap();
2645 std::fs::write(
2646 skill_dir.join("SKILL.md"),
2647 "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2648 )
2649 .unwrap();
2650 std::fs::write(
2651 agent.join("settings.json"),
2652 serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2653 )
2654 .unwrap();
2655 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2656
2657 let resolved = crate::provider::resolve(
2658 Some("anthropic"),
2659 Some(crate::provider::DEFAULT_MODEL_ID),
2660 None,
2661 Some("test-key"),
2662 None,
2663 )
2664 .unwrap();
2665 let args = Args {
2666 trust_override: Some(false),
2667 no_session: true,
2668 no_extensions: true,
2669 no_prompt_templates: true,
2670 no_context_files: true,
2671 system_prompt: Some("stable system prompt".into()),
2672 ..Args::default()
2673 };
2674 let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2675
2676 let outcome = reload_extension_resources_inner(&harness, &context, || {
2677 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2678 })
2679 .await;
2680
2681 assert!(!outcome.summary.contains("Settings reload failed"));
2682 assert_eq!(
2683 outcome.summary,
2684 "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2685 );
2686 let resources = harness.get_resources().await.unwrap();
2687 let skills = resources.skills.as_ref().unwrap();
2688 assert_eq!(skills.len(), 1);
2689 assert_eq!(skills[0].name, "frozen-skill");
2690 }
2691
2692 #[tokio::test]
2693 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2694 let tmp = tempfile::tempdir().unwrap();
2695 let skill_path = tmp.path().join("explicit-skill.md");
2696 std::fs::write(
2697 &skill_path,
2698 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2699 )
2700 .unwrap();
2701 let prompt_path = tmp.path().join("explicit-prompt.md");
2702 std::fs::write(
2703 &prompt_path,
2704 "---\ndescription: Explicit prompt\n---\nPrompt body",
2705 )
2706 .unwrap();
2707
2708 let args = Args {
2709 skill: vec![skill_path.clone()],
2710 prompt_template: vec![prompt_path.clone()],
2711 ..Args::default()
2712 };
2713 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2714 let prompt_paths =
2715 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2716 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2717 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2718
2719 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2720 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2721 assert_eq!(skills.skills[0].name, "explicit-skill");
2722
2723 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2724 assert_eq!(
2725 prompts.prompt_templates.len(),
2726 1,
2727 "{:?}",
2728 prompts.diagnostics
2729 );
2730 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2731 }
2732
2733 #[test]
2734 fn tool_policy_applies_to_rust_and_js_active_names() {
2735 let names = vec![
2736 "read".to_string(),
2737 "ask_user_question".to_string(),
2738 "write".to_string(),
2739 ];
2740
2741 let args = Args {
2742 tools: Some(vec!["read".into(), "ask_user_question".into()]),
2743 ..Args::default()
2744 };
2745 assert_eq!(
2746 filter_active_tool_names(names.clone(), &args),
2747 vec!["read", "ask_user_question"]
2748 );
2749
2750 let args = Args {
2751 exclude_tools: Some(vec!["ask_user_question".into()]),
2752 ..Args::default()
2753 };
2754 assert_eq!(
2755 filter_active_tool_names(names.clone(), &args),
2756 vec!["read", "write"]
2757 );
2758
2759 let args = Args {
2760 no_tools: true,
2761 ..Args::default()
2762 };
2763 assert!(filter_active_tool_names(names, &args).is_empty());
2764 }
2765
2766 #[test]
2767 fn select_ephemeral_when_no_session() {
2768 let args = Args {
2769 no_session: true,
2770 ..Args::default()
2771 };
2772 let cwd = Path::new("/tmp");
2773 assert!(matches!(
2774 select_session(&args, cwd),
2775 SessionSelection::Ephemeral
2776 ));
2777 }
2778
2779 #[test]
2780 fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2781 let defaults = Args::default();
2782 assert!(resolve_project_trust(
2783 &defaults,
2784 Path::new("C:/definitely-not-a-project")
2785 ));
2786
2787 let denied = Args {
2788 trust_override: Some(false),
2789 ..Args::default()
2790 };
2791 assert!(!resolve_project_trust(
2792 &denied,
2793 Path::new("C:/definitely-not-a-project")
2794 ));
2795 }
2796
2797 #[tokio::test(flavor = "current_thread")]
2798 async fn build_preserves_the_supplied_startup_project_snapshot() {
2799 struct RestoreConfigDir(Option<std::ffi::OsString>);
2800
2801 impl Drop for RestoreConfigDir {
2802 fn drop(&mut self) {
2803 match self.0.take() {
2804 Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2805 None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2806 }
2807 }
2808 }
2809
2810 let _guard = crate::config::test_support::env_lock().lock().unwrap();
2811 let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2812 let tmp = tempfile::tempdir().unwrap();
2813 let agent = tmp.path().join("agent");
2814 let cwd = tmp.path().join("project");
2815 std::fs::create_dir_all(&agent).unwrap();
2816 std::fs::create_dir_all(&cwd).unwrap();
2817 std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2818
2819 let resolved = crate::provider::resolve(
2820 Some("anthropic"),
2821 Some(crate::provider::DEFAULT_MODEL_ID),
2822 None,
2823 Some("test-key"),
2824 None,
2825 )
2826 .unwrap();
2827 let args = Args {
2828 trust_override: Some(false),
2829 no_session: true,
2830 no_tools: true,
2831 no_extensions: true,
2832 no_skills: true,
2833 no_prompt_templates: true,
2834 no_context_files: true,
2835 system_prompt: Some("test prompt".into()),
2836 ..Args::default()
2837 };
2838
2839 let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2840
2841 assert_eq!(context.cwd, cwd);
2842 assert!(context.project_trusted);
2843 }
2844
2845 #[test]
2846 fn select_latest_for_continue_and_resume() {
2847 let args = Args {
2848 continue_session: true,
2849 ..Args::default()
2850 };
2851 let cwd = Path::new("/tmp");
2852 assert!(matches!(
2853 select_session(&args, cwd),
2854 SessionSelection::Latest
2855 ));
2856
2857 let args = Args {
2858 resume: true,
2859 ..Args::default()
2860 };
2861 assert!(matches!(
2862 select_session(&args, cwd),
2863 SessionSelection::Latest
2864 ));
2865 }
2866
2867 #[test]
2868 fn select_by_id_for_session_flag() {
2869 let args = Args {
2870 session: Some("01a02ece".into()),
2871 ..Args::default()
2872 };
2873 let cwd = Path::new("/tmp");
2874 assert!(matches!(
2875 select_session(&args, cwd),
2876 SessionSelection::ById { id } if id == "01a02ece"
2877 ));
2878 }
2879
2880 #[test]
2881 fn select_new_with_custom_dir() {
2882 let args = Args {
2883 session_dir: Some(PathBuf::from("/tmp/sess")),
2884 ..Args::default()
2885 };
2886 let cwd = Path::new("/tmp");
2887 match select_session(&args, cwd) {
2888 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2889 other => panic!("expected New, got {other:?}"),
2890 }
2891 }
2892
2893 #[test]
2894 fn select_new_default_dir() {
2895 let args = Args::default();
2896 let cwd = Path::new("/proj");
2897 match select_session(&args, cwd) {
2898 SessionSelection::New { dir, .. } => {
2899 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2900 }
2901 other => panic!("expected New, got {other:?}"),
2902 }
2903 }
2904
2905 #[test]
2906 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2907 let tmp = tempfile::tempdir().unwrap();
2908 let cwd = tmp.path();
2909 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2910 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2911 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2912 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2913 }
2914
2915 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2916 async fn ephemeral_session_builds_roundtrips() {
2917 let s = ephemeral_session();
2921 let leaf = s.get_leaf_id().await;
2922 assert!(leaf.is_ok());
2923 }
2924
2925 }