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