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(args: &Args, cwd: &Path) -> crate::packages::PackageResources {
82 if should_load_js_packages(args) {
83 if resolve_project_trust(args, cwd) {
84 crate::packages::discover_from_settings(cwd)
85 } else {
86 crate::packages::discover_from_global_settings(cwd)
87 }
88 } else {
89 crate::packages::PackageResources::default()
90 }
91}
92
93pub fn default_system_prompt(cwd: &str) -> String {
96 format!(
97 "You are an expert coding assistant operating inside rpi, a coding agent harness. \
98You help users by reading files, executing commands, editing code, and writing new files.
99
100Available tools:
101- read — Read file contents
102- bash — Execute shell commands
103- edit — Find/replace edits to existing files
104- write — Create or overwrite files
105
106Guidelines:
107- Be concise in your responses
108- Show file paths clearly when working with files
109- Prefer the smallest change that solves the problem
110- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
111
112Current working directory: {cwd}"
113 )
114}
115
116#[derive(Debug, Clone)]
123pub enum SessionSelection {
124 Ephemeral,
126 New { dir: PathBuf, name: Option<String> },
128 Latest,
130 ById { id: String },
133 ByExactId { id: String },
135 Fork { source: String },
138}
139
140pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
142 if args.no_session {
143 return SessionSelection::Ephemeral;
144 }
145 if args.continue_session || args.resume {
146 return SessionSelection::Latest;
148 }
149 if let Some(s) = &args.fork {
150 return SessionSelection::Fork { source: s.clone() };
151 }
152 if let Some(s) = &args.session_id {
153 return SessionSelection::ByExactId { id: s.clone() };
154 }
155 if let Some(s) = &args.session {
156 return SessionSelection::ById { id: s.clone() };
157 }
158 let dir = args
159 .session_dir
160 .clone()
161 .unwrap_or_else(|| default_session_dir(cwd));
162 SessionSelection::New {
163 dir,
164 name: args.name.clone(),
165 }
166}
167
168pub fn default_session_dir(cwd: &Path) -> PathBuf {
172 let preferred = cwd.join(".rpi").join("sessions");
173 let legacy = cwd.join(".pi").join("sessions");
174 if preferred.exists() || !legacy.exists() {
175 preferred
176 } else {
177 legacy
178 }
179}
180
181pub async fn build(
199 resolved: &ResolvedModel,
200 args: &Args,
201 cwd: &Path,
202) -> Result<
203 (
204 AgentHarness,
205 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
206 ReloadContext,
207 ),
208 BuildError,
209> {
210 let cwd_str = cwd.to_string_lossy().to_string();
211 let project_trusted = resolve_project_trust(args, cwd);
212 if !project_trusted && args.verbose {
213 eprintln!(
214 "warning: project is not trusted; local settings, resources, and discovered extensions are disabled (use --approve or /trust)"
215 );
216 }
217 let package_resources = package_resources_for(args, cwd);
221
222 let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
237 BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
238 })?;
239 let catalog = crate::provider::available_catalog(resolved);
240 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
241 catalog.clone(),
242 cwd.to_path_buf(),
243 runtime.clone(),
244 );
245 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
246 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
256 let action_bridge = rpi_extensions::ActionBridge::with_reload(
257 runtime.clone(),
258 host_arc,
259 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
260 );
261
262 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
264 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
265 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
266 let _registry = Arc::new(MutationQueueRegistry::new());
267 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
270
271 let tools = build_tools(&ctx, args);
272 let mut tools = tools;
273
274 let extension_session = if args.no_extensions {
282 ExtensionSession::none()
283 } else {
284 load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
285 };
286 let js_extension_session = if !should_load_js_packages(args) {
287 None
288 } else {
289 let paths = js_extension_paths(args, cwd, project_trusted, &package_resources);
290 let js_context = serde_json::json!({
291 "cwd": cwd_str,
292 "theme": resolved.theme.clone(),
293 "currentModel": resolved.model.clone(),
294 "models": catalog.clone(),
295 "thinkingLevel": resolved.thinking_level,
296 });
297 match crate::js_extensions::JsExtensionSession::load_with_context(
298 &paths,
299 args.verbose,
300 js_context,
301 ) {
302 Ok(session) => session,
303 Err(error) => {
304 eprintln!("warning: JS/TS extensions were not loaded: {error}");
305 None
306 }
307 }
308 };
309 if js_extension_session.is_some() {
310 eprintln!(
311 "warning: enabled Pi JS/TS extensions execute with the current user's permissions"
312 );
313 }
314 if let Some(session) = &js_extension_session {
315 if let Err(error) =
316 session.enable_provider_runtime(resolved.provider.clone(), runtime.clone())
317 {
318 if args.verbose {
319 eprintln!("warning: JS provider runtime was not enabled: {error}");
320 }
321 }
322 }
323 if args.verbose {
324 if let Some(session) = &js_extension_session {
325 let info = session.backend_info();
326 eprintln!(
327 "JS extension backend: {} v{} ({})",
328 info.name,
329 info.api_version,
330 info.capability_names().join(", ")
331 );
332 }
333 if let Some(s) = extension_session.summary() {
334 eprintln!("extensions: {s}");
335 }
336 report_deferred_renderers(&extension_session);
337 }
338 merge_extension_tools(&mut tools, &extension_session, args);
339 if let Some(session) = &js_extension_session {
340 merge_js_extension_tools(&mut tools, session, args);
341 if args.verbose && !session.commands.is_empty() {
342 eprintln!("JS extension commands: {}", session.commands.join(", "));
343 }
344 }
345 let mut active = active_tool_names(&tools, args);
346 if let Some(session) = &js_extension_session {
351 let js_names = session.tool_names();
352 if let Some(js_active) = session.active_tools() {
353 active.retain(|name| !js_names.iter().any(|js| js == name));
354 active.extend(js_active.into_iter().filter(|name| {
355 js_names.iter().any(|js| js == name) && tool_name_allowed(name, args)
356 }));
357 }
358 }
359 active = filter_active_tool_names(active, args);
360
361 let selection = select_session(args, cwd);
363 let session = build_session(&selection, &cwd_str).await?;
364 if let Some(js) = &js_extension_session {
365 let session_id = session
366 .get_metadata()
367 .await
368 .ok()
369 .map(|metadata| metadata.id);
370 let leaf_id = session.get_leaf_id().await.ok().flatten();
371 if let Some(session_id) = session_id {
372 let branch = session
373 .find_entries_on_branch(&EntryQuery::default(), &BranchBounds::default())
374 .await
375 .ok()
376 .unwrap_or_default();
377 let branch_json =
378 serde_json::to_value(&branch).unwrap_or_else(|_| serde_json::json!([]));
379 let runtime_context = serde_json::json!({
380 "session": {
381 "id": session_id,
382 "leafId": leaf_id,
383 "branch": branch_json,
384 "entries": branch_json.clone(),
385 },
386 });
387 if let Err(error) = js.set_runtime_context(runtime_context) {
388 if args.verbose {
389 eprintln!("warning: could not sync JS session context: {error}");
390 }
391 }
392 }
393 }
394
395 let base_prompt = match args.system_prompt.as_deref() {
403 Some(explicit) => explicit.to_string(),
404 None if project_trusted => {
405 match discover_system_prompt_file_with_packages(cwd, &package_resources) {
406 Some(path) => std::fs::read_to_string(&path)
407 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
408 None => default_system_prompt(&cwd_str),
409 }
410 }
411 None => default_system_prompt(&cwd_str),
412 };
413
414 let mut append_texts: Vec<String> = Vec::new();
421 for extra in &args.append_system_prompt {
422 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
423 append_texts.push(text);
424 }
425 if args.append_system_prompt.is_empty() {
426 if let Some(path) = project_trusted
427 .then(|| discover_append_system_prompt_file_with_packages(cwd, &package_resources))
428 .flatten()
429 {
430 if let Ok(text) = std::fs::read_to_string(&path) {
431 append_texts.push(text);
432 }
433 }
434 }
435 let append_join = if append_texts.is_empty() {
436 None
437 } else {
438 Some(append_texts.join("\n\n"))
439 };
440
441 let agent_dir = crate::config::agent_dir().ok();
453
454 let discovered = extension_session
470 .snapshot_arc()
471 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
472 .unwrap_or_default();
473
474 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
475 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
476 if !args.no_skills {
477 let mut dirs = if project_trusted {
478 skill_dirs(cwd)
479 } else {
480 global_skill_dirs()
481 };
482 dirs.extend(args.skill.iter().cloned());
483 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
484 if let Some(session) = &js_extension_session {
485 dirs.extend(session.resources.skill_paths.iter().cloned());
486 }
487 dirs.extend(package_resources.skill_dirs());
488 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
489 skills = result.skills;
490 skill_diags = result.diagnostics;
491 }
492
493 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
494 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
495 if !args.no_prompt_templates {
496 let mut dirs = if project_trusted {
497 prompt_template_dirs(cwd)
498 } else {
499 global_prompt_template_dirs()
500 };
501 dirs.extend(args.prompt_template.iter().cloned());
502 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
503 if let Some(session) = &js_extension_session {
504 dirs.extend(session.resources.prompt_paths.iter().cloned());
505 }
506 dirs.extend(package_resources.prompt_dirs());
507 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
508 prompt_templates = result.prompt_templates;
509 prompt_diags = result.diagnostics;
510 }
511
512 let context_block = if args.no_context_files || !project_trusted {
513 String::new()
514 } else {
515 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
520 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
521 format_project_context(&files)
522 };
523
524 if args.verbose {
526 for d in &package_resources.diagnostics {
527 eprintln!("warning: package {}: {}", d.spec, d.message);
528 }
529 for d in &skill_diags {
530 eprintln!(
531 "warning: skill {} ({}): {}",
532 d.path,
533 d.code.as_str(),
534 d.message
535 );
536 }
537 for d in &prompt_diags {
538 eprintln!(
539 "warning: prompt template {} ({}): {}",
540 d.path,
541 d.code.as_str(),
542 d.message
543 );
544 }
545 }
546
547 let system_prompt = compose_system_prompt(
556 Some(&base_prompt),
557 &[], if context_block.is_empty() {
559 None
560 } else {
561 Some(&context_block)
562 },
563 append_join.as_deref(),
564 );
565
566 if args.debug_system_prompt {
575 eprintln!("=== --debug-system-prompt ===");
576 let base_src = if args.system_prompt.is_some() {
577 "--system-prompt"
578 } else if discover_system_prompt_file_with_packages(cwd, &package_resources).is_some() {
579 "SYSTEM.md"
580 } else {
581 "default"
582 };
583 eprintln!("[base source: {base_src}]");
584 eprintln!("--- base ---\n{base_prompt}");
585 if let Some(append) = append_join.as_deref() {
586 eprintln!("--- append ---\n{append}");
587 } else {
588 eprintln!("--- append: (none) ---");
589 }
590 if context_block.is_empty() {
591 eprintln!("--- context: (none) ---");
592 } else {
593 eprintln!("--- context ---{context_block}");
594 }
595 let visible_skills = skills
596 .iter()
597 .filter(|s| s.disable_model_invocation != Some(true))
598 .count();
599 eprintln!(
600 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
601 skills.len(),
602 visible_skills,
603 skills.len() - visible_skills
604 );
605 for s in &skills {
606 let hidden = if s.disable_model_invocation == Some(true) {
607 " [hidden]"
608 } else {
609 ""
610 };
611 eprintln!(" {}{hidden} — {}", s.name, s.description);
612 }
613 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
614 for t in &prompt_templates {
615 eprintln!(" /{}", t.name);
616 }
617 eprintln!(
621 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
622 discovered.skill_paths.len(),
623 discovered.prompt_paths.len(),
624 discovered.theme_paths.len(),
625 );
626 for p in &discovered.skill_paths {
627 eprintln!(" skill: {p}");
628 }
629 for p in &discovered.prompt_paths {
630 eprintln!(" prompt: {p}");
631 }
632 eprintln!(
633 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
634 );
635 eprintln!("=== end --debug-system-prompt ===");
636 }
637
638 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
643 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
644 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
648
649 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
658 Some(snapshot) => {
659 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
660 Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
661 }
662 None => broadcast_emitter,
663 };
664
665 let options = AgentHarnessOptions {
666 model: resolved.model.clone(),
667 thinking_level: resolved.thinking_level,
668 active_tool_names: active,
669 tools,
670 system_prompt: Some(system_prompt),
671 resources: AgentHarnessResources {
672 skills: if skills.is_empty() {
673 None
674 } else {
675 Some(skills)
676 },
677 prompt_templates: if prompt_templates.is_empty() {
678 None
679 } else {
680 Some(prompt_templates)
681 },
682 },
683 allow_existing_session: matches!(
686 selection,
687 SessionSelection::Latest
688 | SessionSelection::ById { .. }
689 | SessionSelection::ByExactId { .. }
690 | SessionSelection::Fork { .. }
691 ),
692 stream_options: Default::default(),
693 retry: RetryPolicy::default(),
694 compaction: CompactionSettings::default(),
695 steering_mode: Default::default(),
696 follow_up_mode: Default::default(),
697 tool_execution: HarnessToolExecution::default(),
698 drive: DrivingMode::default(),
699 session,
700 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
708 to_provider_messages: None,
709 entry_projectors: Default::default(),
710 agent_emitter: Some(emitter),
711 before_tool_call: None,
717 after_tool_call: None,
718 transform_context: None,
719 entry_transforms: Vec::new(),
720 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
726 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
727 };
728
729 let harness = match AgentHarness::create(options).await {
730 Ok(h) => {
731 crate::extensions_actions::HarnessActionHost::set_harness(
734 &harness_cell,
735 Arc::new(h.clone()),
736 );
737 h
738 }
739 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
740 };
741
742 let reload_context = ReloadContext {
749 extension_session: Arc::new(Mutex::new(extension_session)),
750 js_extension_session: js_extension_session.clone(),
751 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
752 catalog,
753 gateway: resolved.provider.clone(),
754 runtime: runtime.clone(),
755 cwd: cwd.to_path_buf(),
756 args: args.clone(),
757 resolved_model: resolved.model.clone(),
758 broadcast: broadcast_for_context,
759 mailbox: reload_mailbox,
760 dev_extension: None,
761 };
762
763 Ok((harness, event_rx, reload_context))
764}
765
766pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
811
812pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
817
818#[derive(Clone)]
826pub struct ReloadContext {
827 pub extension_session: ExtensionSessionCell,
829 pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
831 pub action_bridge: ActionBridgeCell,
833 pub catalog: Vec<rpi_ai::Model>,
838 pub gateway: Arc<dyn Provider>,
841 pub runtime: tokio::runtime::Handle,
845 pub cwd: PathBuf,
847 pub args: Args,
853 pub resolved_model: rpi_ai::Model,
856 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
862 pub mailbox: rpi_extensions::ReloadMailbox,
869 pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
873}
874
875pub struct ReloadOutcome {
878 pub summary: String,
881 pub had_warnings: bool,
883}
884
885fn append_reload_resource_paths(
890 mut paths: Vec<PathBuf>,
891 explicit: &[PathBuf],
892 discovered: &[String],
893 js_paths: &[PathBuf],
894 package_paths: &[PathBuf],
895) -> Vec<PathBuf> {
896 paths.extend(explicit.iter().cloned());
897 paths.extend(discovered.iter().map(PathBuf::from));
898 paths.extend(js_paths.iter().cloned());
899 paths.extend(package_paths.iter().cloned());
900 paths
901}
902
903pub async fn reload_extension_resources(
915 harness: &AgentHarness,
916 ctx: &ReloadContext,
917) -> ReloadOutcome {
918 let mut effective_args = ctx.args.clone();
923 if let Some(dev) = &ctx.dev_extension {
924 if let Err(error) = dev
925 .rebuild()
926 .and_then(|_| dev.apply_to_args(&mut effective_args))
927 {
928 return ReloadOutcome {
929 summary: format!(
930 "Extension build failed for {}: {error}. Keeping the currently loaded version.",
931 dev.package_name()
932 ),
933 had_warnings: true,
934 };
935 }
936 }
937 let cwd_str = ctx.cwd.to_string_lossy().to_string();
938 let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
939 let package_resources = package_resources_for(&effective_args, &ctx.cwd);
940 let mut warnings = false;
941
942 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
949 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
950 Some(b) => b.clone_host(),
951 None => {
952 let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
955 ctx.catalog.clone(),
956 ctx.cwd.clone(),
957 ctx.runtime.clone(),
958 );
959 crate::extensions_actions::HarnessActionHost::set_harness(
960 &_cell,
961 Arc::new(harness.clone()),
962 );
963 Arc::new(action_host)
964 }
965 };
966
967 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
968 let fresh_bridge =
969 rpi_extensions::ActionBridge::with_reload(ctx.runtime.clone(), host, reload_cb);
970
971 let extension_session = if effective_args.no_extensions {
972 rpi_extensions::ExtensionSession::none()
973 } else {
974 load_extensions(
975 &effective_args,
976 &ctx.cwd,
977 project_trusted,
978 Some(Arc::clone(&fresh_bridge)),
979 )
980 };
981 if extension_session.is_empty() && !effective_args.no_extensions {
982 }
985 if effective_args.verbose {
986 if let Some(s) = extension_session.summary() {
987 eprintln!("reload: {s}");
988 }
989 report_deferred_renderers(&extension_session);
990 }
991
992 {
1006 let mut session_guard = ctx.extension_session.lock().unwrap();
1007 let old_session = std::mem::replace(
1008 &mut *session_guard,
1009 rpi_extensions::ExtensionSession::none(),
1010 );
1011 if let Some(old_snap) = old_session.snapshot_arc() {
1012 old_snap.active_flag().store(false, Ordering::SeqCst);
1016 }
1017 }
1021 if let Some(old_b) = old_bridge {
1022 old_b.invalidate();
1023 }
1024
1025 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1028 *ctx.extension_session.lock().unwrap() = extension_session.clone();
1029
1030 let discovered = extension_session
1032 .snapshot_arc()
1033 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1034 .unwrap_or_default();
1035
1036 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1038 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1039
1040 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1041 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1042 if !effective_args.no_skills {
1043 let js_paths: &[PathBuf] = ctx
1047 .js_extension_session
1048 .as_ref()
1049 .map(|js| js.resources.skill_paths.as_slice())
1050 .unwrap_or(&[]);
1051 let package_paths = package_resources.skill_dirs();
1052 let dirs = append_reload_resource_paths(
1053 skill_dirs(&ctx.cwd),
1054 &effective_args.skill,
1055 &discovered.skill_paths,
1056 js_paths,
1057 &package_paths,
1058 );
1059 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1060 skills = result.skills;
1061 skill_diags = result.diagnostics;
1062 }
1063
1064 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1065 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1066 if !effective_args.no_prompt_templates {
1067 let js_paths: &[PathBuf] = ctx
1068 .js_extension_session
1069 .as_ref()
1070 .map(|js| js.resources.prompt_paths.as_slice())
1071 .unwrap_or(&[]);
1072 let package_paths = package_resources.prompt_dirs();
1073 let dirs = append_reload_resource_paths(
1074 prompt_template_dirs(&ctx.cwd),
1075 &effective_args.prompt_template,
1076 &discovered.prompt_paths,
1077 js_paths,
1078 &package_paths,
1079 );
1080 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1081 prompt_templates = result.prompt_templates;
1082 prompt_diags = result.diagnostics;
1083 }
1084
1085 let context_block = if effective_args.no_context_files {
1086 String::new()
1087 } else {
1088 let agent_dir = crate::config::agent_dir().ok();
1089 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1090 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1091 format_project_context(&files)
1092 };
1093
1094 if !skill_diags.is_empty()
1095 || !prompt_diags.is_empty()
1096 || !package_resources.diagnostics.is_empty()
1097 {
1098 warnings = true;
1099 if effective_args.verbose {
1100 for d in &package_resources.diagnostics {
1101 eprintln!("warning: package {}: {}", d.spec, d.message);
1102 }
1103 for d in &skill_diags {
1104 eprintln!(
1105 "warning: skill {} ({}): {}",
1106 d.path,
1107 d.code.as_str(),
1108 d.message
1109 );
1110 }
1111 for d in &prompt_diags {
1112 eprintln!(
1113 "warning: prompt template {} ({}): {}",
1114 d.path,
1115 d.code.as_str(),
1116 d.message
1117 );
1118 }
1119 }
1120 }
1121
1122 let base_prompt = match effective_args.system_prompt.as_deref() {
1124 Some(explicit) => explicit.to_string(),
1125 None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1126 Some(path) => {
1127 std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1128 }
1129 None => default_system_prompt(&cwd_str),
1130 },
1131 };
1132 let mut append_texts: Vec<String> = Vec::new();
1133 for extra in &effective_args.append_system_prompt {
1134 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1135 append_texts.push(text);
1136 }
1137 if effective_args.append_system_prompt.is_empty() {
1138 if let Some(path) =
1139 discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1140 {
1141 if let Ok(text) = std::fs::read_to_string(&path) {
1142 append_texts.push(text);
1143 }
1144 }
1145 }
1146 let append_join = if append_texts.is_empty() {
1147 None
1148 } else {
1149 Some(append_texts.join("\n\n"))
1150 };
1151 let system_prompt = compose_system_prompt(
1152 Some(&base_prompt),
1153 &[],
1154 if context_block.is_empty() {
1155 None
1156 } else {
1157 Some(&context_block)
1158 },
1159 append_join.as_deref(),
1160 );
1161
1162 let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1164 Some(snapshot) => {
1165 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1166 Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1167 }
1168 None => ctx.broadcast.clone(),
1169 };
1170
1171 let resources = AgentHarnessResources {
1173 skills: if skills.is_empty() {
1174 None
1175 } else {
1176 Some(skills.clone())
1177 },
1178 prompt_templates: if prompt_templates.is_empty() {
1179 None
1180 } else {
1181 Some(prompt_templates.clone())
1182 },
1183 };
1184 let _ = harness.set_system_prompt(Some(system_prompt)).await;
1185 let _ = harness.set_resources(resources).await;
1186 let _ = harness.set_agent_emitter(Some(emitter)).await;
1187 let _ = harness
1188 .set_models(build_models_with_extensions_for_reload(
1189 &ctx.gateway,
1190 &extension_session,
1191 ctx.runtime.clone(),
1192 ))
1193 .await;
1194 let _ = harness
1195 .set_provider_hooks(
1196 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1197 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1198 )
1199 .await;
1200
1201 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1205 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1206 let mut tools = build_tools(&tool_ctx, &effective_args);
1207 merge_extension_tools(&mut tools, &extension_session, &effective_args);
1208 if let Some(js) = &ctx.js_extension_session {
1209 merge_js_extension_tools(&mut tools, js, &effective_args);
1210 }
1211 let mut active = active_tool_names(&tools, &effective_args);
1212 if let Some(js) = &ctx.js_extension_session {
1213 let js_names = js.tool_names();
1214 if let Some(js_active) = js.active_tools() {
1215 active.retain(|name| {
1216 tool_name_allowed(name, &effective_args)
1217 && !js_names.iter().any(|js_name| js_name == name)
1218 });
1219 active.extend(js_active.into_iter().filter(|name| {
1220 js_names.iter().any(|js_name| js_name == name)
1221 && tool_name_allowed(name, &effective_args)
1222 }));
1223 }
1224 }
1225 active = filter_active_tool_names(active, &effective_args);
1226 let _ = harness.set_tools(tools, Some(active)).await;
1227
1228 let summary = format!(
1229 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1230 extension_session.loaded_paths().len(),
1231 skills.len(),
1232 prompt_templates.len(),
1233 );
1234 ReloadOutcome {
1235 summary,
1236 had_warnings: warnings,
1237 }
1238}
1239
1240fn build_models_with_extensions_for_reload(
1245 gateway: &Arc<dyn Provider>,
1246 extension_session: &ExtensionSession,
1247 runtime: tokio::runtime::Handle,
1248) -> Vec<Arc<dyn Provider>> {
1249 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1250 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1251 models.extend(pluggable);
1252 models
1253}
1254
1255fn report_deferred_renderers(session: &ExtensionSession) {
1259 let Some(snap) = session.snapshot_arc() else {
1260 return;
1261 };
1262 let all = snap.renderers();
1263 let markdown = all
1264 .iter()
1265 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1266 .count();
1267 let message = all
1268 .iter()
1269 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1270 .count();
1271 let entry = all
1272 .iter()
1273 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1274 .count();
1275 if markdown + message + entry == 0 {
1276 return;
1277 }
1278 eprintln!(
1279 "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1280 markdown, message, entry
1281 );
1282}
1283
1284#[derive(Debug, thiserror::Error)]
1286pub enum BuildError {
1287 #[error("Could not create the session directory: {0}")]
1288 SessionDir(String),
1289 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1290 SessionNotFound { requested: String, dir: String },
1291 #[error("Could not build the harness: {0}")]
1292 HarnessCreate(String),
1293}
1294
1295fn build_models_with_extensions(
1305 resolved: &ResolvedModel,
1306 extension_session: &ExtensionSession,
1307 runtime: tokio::runtime::Handle,
1308) -> Vec<Arc<dyn Provider>> {
1309 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1310 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1311 models.extend(pluggable);
1312 models
1313}
1314
1315pub fn project_has_local_resources(cwd: &Path) -> bool {
1319 const FILES: &[&str] = &[
1320 "settings.json",
1321 "SYSTEM.md",
1322 "APPEND_SYSTEM.md",
1323 "packages.json",
1324 ];
1325 const DIRS: &[&str] = &["skills", "prompts", "themes", "extensions", "packages"];
1326 [".rpi", ".pi"].iter().any(|layout| {
1327 let root = cwd.join(layout);
1328 FILES.iter().any(|name| root.join(name).is_file())
1329 || DIRS.iter().any(|name| root.join(name).is_dir())
1330 })
1331}
1332
1333fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1338 if let Some(override_value) = args.trust_override {
1339 return override_value;
1340 }
1341 crate::config::project_trust_decision(cwd)
1342 .ok()
1343 .flatten()
1344 .unwrap_or(false)
1345}
1346
1347fn load_extensions(
1355 args: &Args,
1356 cwd: &Path,
1357 project_trusted: bool,
1358 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1359) -> ExtensionSession {
1360 let mut dirs = if project_trusted {
1361 extension_dirs(cwd)
1362 } else {
1363 global_extension_dirs()
1364 };
1365 dirs.extend(args.extensions_dir.iter().cloned());
1366 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1367 rpi_extensions::load_session_mixed(&dirs, &args.extension, diagnostics, action_bridge)
1374}
1375
1376fn js_extension_paths(
1377 args: &Args,
1378 cwd: &Path,
1379 project_trusted: bool,
1380 packages: &crate::packages::PackageResources,
1381) -> Vec<PathBuf> {
1382 let mut paths = packages.extension_paths();
1383 let discovered_dirs = if project_trusted {
1384 extension_dirs(cwd)
1385 } else {
1386 global_extension_dirs()
1387 };
1388 for dir in discovered_dirs {
1389 if let Ok(entries) = std::fs::read_dir(dir) {
1390 paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1391 matches!(
1392 path.extension()
1393 .and_then(|ext| ext.to_str())
1394 .map(|ext| ext.to_ascii_lowercase())
1395 .as_deref(),
1396 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1397 )
1398 }));
1399 }
1400 }
1401 paths.extend(
1402 args.extension
1403 .iter()
1404 .filter(|path| {
1405 matches!(
1406 path.extension()
1407 .and_then(|ext| ext.to_str())
1408 .map(|ext| ext.to_ascii_lowercase())
1409 .as_deref(),
1410 Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1411 )
1412 })
1413 .cloned(),
1414 );
1415 paths
1416}
1417
1418fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1424 let Some(snapshot) = session.snapshot() else {
1425 return;
1426 };
1427 for et in snapshot.tools() {
1428 let name = &et.tool.name;
1429 if !tool_name_allowed(name, args) {
1430 continue;
1431 }
1432 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1433 let harness_tool = HarnessTool::new(Arc::new(adapter));
1434 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1435 Some(slot) => *slot = harness_tool,
1436 None => tools.push(harness_tool),
1437 }
1438 }
1439}
1440
1441fn merge_js_extension_tools(
1442 tools: &mut Vec<HarnessTool>,
1443 session: &crate::js_extensions::JsExtensionSession,
1444 args: &Args,
1445) {
1446 for adapter in session.tools() {
1447 let name = adapter.schema().name.clone();
1448 if !tool_name_allowed(&name, args) {
1449 continue;
1450 }
1451 let harness_tool = HarnessTool::new(Arc::new(adapter));
1452 match tools
1453 .iter_mut()
1454 .find(|tool| tool.tool.schema().name == name)
1455 {
1456 Some(slot) => *slot = harness_tool,
1457 None => tools.push(harness_tool),
1458 }
1459 }
1460}
1461
1462pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1469 use rpi_tools::tools::bash::BashToolOptions;
1470 let default = std::env::var("RPI_BASH_TIMEOUT")
1471 .ok()
1472 .and_then(|v| v.parse::<f64>().ok())
1473 .unwrap_or(120.0);
1474 BashToolOptions {
1475 command_prefix: None,
1476 default_timeout: Some(default),
1477 }
1478}
1479
1480fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1481 if args.no_tools {
1482 return Vec::new();
1483 }
1484 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1486 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1487 (
1488 "bash",
1489 HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1490 ),
1491 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1492 ("write", HarnessTool::new(create_write_tool(ctx))),
1493 ];
1494
1495 if args.no_builtin_tools {
1499 all.clear();
1500 }
1501
1502 if let Some(allow) = &args.tools {
1504 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1505 }
1506 if let Some(deny) = &args.exclude_tools {
1508 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1509 }
1510
1511 all.into_iter()
1512 .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1513 .collect()
1514}
1515
1516fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1520 filter_active_tool_names(
1521 tools.iter().map(|tool| tool.tool.schema().name.clone()),
1522 args,
1523 )
1524}
1525
1526pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1530 if args.no_tools {
1531 return false;
1532 }
1533 if args
1534 .tools
1535 .as_ref()
1536 .is_some_and(|allow| !allow.iter().any(|value| value == name))
1537 {
1538 return false;
1539 }
1540 if args
1541 .exclude_tools
1542 .as_ref()
1543 .is_some_and(|deny| deny.iter().any(|value| value == name))
1544 {
1545 return false;
1546 }
1547 true
1548}
1549
1550pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1551where
1552 I: IntoIterator<Item = String>,
1553{
1554 names
1555 .into_iter()
1556 .filter(|name| tool_name_allowed(name, args))
1557 .collect()
1558}
1559
1560async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1562 match selection {
1563 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1564 SessionSelection::New { dir, .. } => {
1565 std::fs::create_dir_all(dir)
1568 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1569 let session = create_jsonl_session(dir, cwd)
1570 .await
1571 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1572 Ok(session)
1573 }
1574 SessionSelection::Latest
1575 | SessionSelection::ById { .. }
1576 | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1577 SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1578 }
1579}
1580
1581async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1587 match selection {
1591 SessionSelection::Latest => {
1592 let metas = list_session_metadata(cwd).await?;
1593 let Some(meta) = metas.first() else {
1594 return Err(BuildError::SessionNotFound {
1595 requested: "the most recent session".to_string(),
1596 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1597 });
1598 };
1599 open_session(meta, cwd).await
1600 }
1601 SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1602 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1603 requested,
1604 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1605 },
1606 OpenError::Other(msg) => BuildError::SessionDir(msg),
1607 }),
1608 SessionSelection::ByExactId { id } => {
1609 let metas = list_session_metadata(cwd).await?;
1612 if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1613 return open_session(meta, cwd).await;
1614 }
1615 let dir = default_session_dir(Path::new(cwd));
1616 std::fs::create_dir_all(&dir)
1617 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1618 create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1619 .await
1620 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1621 }
1622 _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1623 }
1624}
1625
1626async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1629 use rpi_harness::session::jsonl::{
1630 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1631 };
1632 use rpi_harness::session::types::{ForkOptions, SessionStorage};
1633 use rpi_tools::FileSystem;
1634
1635 let dir = default_session_dir(Path::new(cwd));
1636 std::fs::create_dir_all(&dir)
1637 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1638 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1639 let fs: Arc<dyn FileSystem> = env.clone();
1640 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1641 fs: fs.clone(),
1642 sessions_root: dir.to_string_lossy().into_owned(),
1643 clock: Arc::new(SystemClock),
1644 ids: Arc::new(DefaultIdGenerator::new()),
1645 });
1646 let metas = repo
1647 .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1648 .await
1649 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1650 let source_meta = metas
1651 .iter()
1652 .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1653 .ok_or_else(|| BuildError::SessionNotFound {
1654 requested: format!("--fork {source}"),
1655 dir: dir.display().to_string(),
1656 })?;
1657 let fork_storage = repo
1658 .fork_typed(
1659 source_meta,
1660 &JsonlSessionCreateOptions {
1661 id: None,
1662 parent_session_id: Some(source_meta.id.clone()),
1663 cwd: cwd.to_string(),
1664 metadata: None,
1665 },
1666 &ForkOptions::default(),
1667 )
1668 .await
1669 .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1670 let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1671 Ok(Session::new(storage_arc, None))
1672}
1673
1674pub enum OpenError {
1677 NotFound { requested: String },
1679 Other(String),
1681}
1682
1683impl std::fmt::Display for OpenError {
1684 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1685 match self {
1686 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1687 OpenError::Other(msg) => write!(f, "{msg}"),
1688 }
1689 }
1690}
1691
1692pub async fn list_session_metadata(
1695 cwd: &str,
1696) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1697 use rpi_harness::session::jsonl::{
1698 JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1699 };
1700 use rpi_tools::FileSystem;
1701
1702 let dir = default_session_dir(Path::new(cwd));
1703 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1704 let fs: Arc<dyn FileSystem> = env.clone();
1705 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1706 fs: fs.clone(),
1707 sessions_root: dir.to_string_lossy().into_owned(),
1708 clock: Arc::new(SystemClock),
1709 ids: Arc::new(DefaultIdGenerator::new()),
1710 });
1711 repo.list_typed(&JsonlSessionListOptions::default())
1712 .await
1713 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1714}
1715
1716pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1720 let metas = list_session_metadata(cwd)
1721 .await
1722 .map_err(|e| OpenError::Other(e.to_string()))?;
1723 let Some(meta) = metas
1724 .iter()
1725 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1726 else {
1727 return Err(OpenError::NotFound {
1728 requested: format!("session {id}"),
1729 });
1730 };
1731 open_session(meta, cwd)
1732 .await
1733 .map_err(|e| OpenError::Other(e.to_string()))
1734}
1735
1736pub(crate) async fn fork_session_storage(
1743 harness: &AgentHarness,
1744 cwd: &str,
1745) -> Result<Session, String> {
1746 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1747 use rpi_tools::FileSystem;
1748
1749 let dir = default_session_dir(Path::new(cwd));
1750 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1751 let fs: Arc<dyn FileSystem> = env.clone();
1752 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1753 fs,
1754 sessions_root: dir.to_string_lossy().into_owned(),
1755 clock: Arc::new(SystemClock),
1756 ids: Arc::new(DefaultIdGenerator::new()),
1757 });
1758 let id = harness.session().storage().metadata().id.clone();
1761 let metas = list_session_metadata(cwd)
1762 .await
1763 .map_err(|e| e.to_string())?;
1764 let Some(source) = metas.iter().find(|m| m.id == id) else {
1765 return Err(format!("current session {id} not found on disk"));
1766 };
1767 let fork_storage = repo
1768 .fork_typed(
1769 source,
1770 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1771 id: None,
1772 parent_session_id: Some(source.id.clone()),
1773 cwd: cwd.to_string(),
1774 metadata: None,
1775 },
1776 &rpi_harness::session::types::ForkOptions::default(),
1777 )
1778 .await
1779 .map_err(|e| e.to_string())?;
1780 Ok(Session::new(Arc::new(fork_storage), None))
1781}
1782
1783async fn open_session(
1786 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
1787 cwd: &str,
1788) -> Result<Session, BuildError> {
1789 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1790 use rpi_harness::session::types::SessionStorage;
1791 use rpi_tools::FileSystem;
1792
1793 let dir = default_session_dir(Path::new(cwd));
1794 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1795 let fs: Arc<dyn FileSystem> = env.clone();
1796 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1797 fs: fs.clone(),
1798 sessions_root: dir.to_string_lossy().into_owned(),
1799 clock: Arc::new(SystemClock),
1800 ids: Arc::new(DefaultIdGenerator::new()),
1801 });
1802 let storage = repo
1803 .open_by_jsonl_metadata(meta)
1804 .await
1805 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
1806 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
1807 Ok(Session::new(storage_arc, None))
1808}
1809
1810fn ephemeral_session() -> Session {
1812 let storage = Arc::new(InMemorySessionStorage::new(
1813 SessionMetadata {
1814 id: "ephemeral".into(),
1815 created_at: 0,
1816 parent_session_id: None,
1817 },
1818 Arc::new(SystemClock),
1819 Arc::new(DefaultIdGenerator::new()),
1820 ));
1821 Session::new(storage, None)
1822}
1823
1824pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
1835 create_jsonl_session_with_id(dir, cwd, None).await
1836}
1837
1838pub(crate) async fn create_jsonl_session_with_id(
1842 dir: &Path,
1843 cwd: &str,
1844 id: Option<String>,
1845) -> Result<Session, String> {
1846 use rpi_harness::session::jsonl::{
1847 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1848 };
1849 use rpi_tools::FileSystem;
1850
1851 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1854 let fs: Arc<dyn FileSystem> = env.clone();
1855
1856 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1857 fs: fs.clone(),
1858 sessions_root: dir.to_string_lossy().into_owned(),
1859 clock: Arc::new(SystemClock),
1860 ids: Arc::new(DefaultIdGenerator::new()),
1861 });
1862
1863 let opts = JsonlSessionCreateOptions {
1864 id, parent_session_id: None,
1866 cwd: cwd.to_string(),
1867 metadata: None,
1868 };
1869 let storage = repo
1870 .create_typed(&opts)
1871 .await
1872 .map_err(|e| format!("create session: {e}"))?;
1873 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
1875 Ok(Session::new(storage_arc, None))
1876}
1877
1878fn read_append_target(target: &str) -> Option<String> {
1881 let path = Path::new(target);
1882 if path.is_file() {
1883 std::fs::read_to_string(path).ok()
1884 } else {
1885 None
1886 }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891 use super::*;
1892 use crate::args::Args;
1893
1894 #[test]
1895 fn default_prompt_mentions_cwd_and_tools() {
1896 let p = default_system_prompt("/tmp/proj");
1897 assert!(p.contains("/tmp/proj"));
1898 assert!(p.contains("read"));
1899 assert!(p.contains("bash"));
1900 assert!(p.contains("edit"));
1901 assert!(p.contains("write"));
1902 assert!(!p.contains("- grep"));
1903 assert!(!p.contains("- find"));
1904 assert!(!p.contains("- ls"));
1905 assert!(!p.contains("powershell"));
1906 }
1907
1908 #[test]
1909 fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
1910 let args = Args::default();
1911 assert!(!should_load_js_packages(&args));
1912 let resources = package_resources_for(&args, Path::new("."));
1913 assert!(resources.packages.is_empty());
1914
1915 let args = Args {
1916 enable_pi_packages: true,
1917 ..Args::default()
1918 };
1919 assert!(should_load_js_packages(&args));
1920
1921 let args = Args {
1922 enable_pi_packages: true,
1923 no_extensions: true,
1924 ..Args::default()
1925 };
1926 assert!(!should_load_js_packages(&args));
1927 assert!(package_resources_for(&args, Path::new("."))
1928 .packages
1929 .is_empty());
1930 }
1931
1932 #[tokio::test]
1933 async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
1934 let tmp = tempfile::tempdir().unwrap();
1935 let skill_path = tmp.path().join("explicit-skill.md");
1936 std::fs::write(
1937 &skill_path,
1938 "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
1939 )
1940 .unwrap();
1941 let prompt_path = tmp.path().join("explicit-prompt.md");
1942 std::fs::write(
1943 &prompt_path,
1944 "---\ndescription: Explicit prompt\n---\nPrompt body",
1945 )
1946 .unwrap();
1947
1948 let args = Args {
1949 skill: vec![skill_path.clone()],
1950 prompt_template: vec![prompt_path.clone()],
1951 ..Args::default()
1952 };
1953 let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
1954 let prompt_paths =
1955 append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
1956 let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
1957 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
1958
1959 let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
1960 assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
1961 assert_eq!(skills.skills[0].name, "explicit-skill");
1962
1963 let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
1964 assert_eq!(
1965 prompts.prompt_templates.len(),
1966 1,
1967 "{:?}",
1968 prompts.diagnostics
1969 );
1970 assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
1971 }
1972
1973 #[test]
1974 fn tool_policy_applies_to_rust_and_js_active_names() {
1975 let names = vec![
1976 "read".to_string(),
1977 "ask_user_question".to_string(),
1978 "write".to_string(),
1979 ];
1980
1981 let args = Args {
1982 tools: Some(vec!["read".into(), "ask_user_question".into()]),
1983 ..Args::default()
1984 };
1985 assert_eq!(
1986 filter_active_tool_names(names.clone(), &args),
1987 vec!["read", "ask_user_question"]
1988 );
1989
1990 let args = Args {
1991 exclude_tools: Some(vec!["ask_user_question".into()]),
1992 ..Args::default()
1993 };
1994 assert_eq!(
1995 filter_active_tool_names(names.clone(), &args),
1996 vec!["read", "write"]
1997 );
1998
1999 let args = Args {
2000 no_tools: true,
2001 ..Args::default()
2002 };
2003 assert!(filter_active_tool_names(names, &args).is_empty());
2004 }
2005
2006 #[test]
2007 fn select_ephemeral_when_no_session() {
2008 let args = Args {
2009 no_session: true,
2010 ..Args::default()
2011 };
2012 let cwd = Path::new("/tmp");
2013 assert!(matches!(
2014 select_session(&args, cwd),
2015 SessionSelection::Ephemeral
2016 ));
2017 }
2018
2019 #[test]
2020 fn project_trust_override_fails_closed_by_default() {
2021 let denied = Args::default();
2022 assert!(!resolve_project_trust(
2023 &denied,
2024 Path::new("C:/definitely-not-a-project")
2025 ));
2026
2027 let approved = Args {
2028 trust_override: Some(true),
2029 ..Args::default()
2030 };
2031 assert!(resolve_project_trust(
2032 &approved,
2033 Path::new("C:/definitely-not-a-project")
2034 ));
2035 }
2036
2037 #[test]
2038 fn project_resource_probe_ignores_session_directory_but_detects_config() {
2039 let root = tempfile::tempdir().unwrap();
2040 let cwd = root.path();
2041 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2042 assert!(!project_has_local_resources(cwd));
2043 std::fs::write(cwd.join(".rpi/settings.json"), "{}").unwrap();
2044 assert!(project_has_local_resources(cwd));
2045 }
2046
2047 #[test]
2048 fn select_latest_for_continue_and_resume() {
2049 let args = Args {
2050 continue_session: true,
2051 ..Args::default()
2052 };
2053 let cwd = Path::new("/tmp");
2054 assert!(matches!(
2055 select_session(&args, cwd),
2056 SessionSelection::Latest
2057 ));
2058
2059 let args = Args {
2060 resume: true,
2061 ..Args::default()
2062 };
2063 assert!(matches!(
2064 select_session(&args, cwd),
2065 SessionSelection::Latest
2066 ));
2067 }
2068
2069 #[test]
2070 fn select_by_id_for_session_flag() {
2071 let args = Args {
2072 session: Some("01a02ece".into()),
2073 ..Args::default()
2074 };
2075 let cwd = Path::new("/tmp");
2076 assert!(matches!(
2077 select_session(&args, cwd),
2078 SessionSelection::ById { id } if id == "01a02ece"
2079 ));
2080 }
2081
2082 #[test]
2083 fn select_new_with_custom_dir() {
2084 let args = Args {
2085 session_dir: Some(PathBuf::from("/tmp/sess")),
2086 ..Args::default()
2087 };
2088 let cwd = Path::new("/tmp");
2089 match select_session(&args, cwd) {
2090 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2091 other => panic!("expected New, got {other:?}"),
2092 }
2093 }
2094
2095 #[test]
2096 fn select_new_default_dir() {
2097 let args = Args::default();
2098 let cwd = Path::new("/proj");
2099 match select_session(&args, cwd) {
2100 SessionSelection::New { dir, .. } => {
2101 assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2102 }
2103 other => panic!("expected New, got {other:?}"),
2104 }
2105 }
2106
2107 #[test]
2108 fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2109 let tmp = tempfile::tempdir().unwrap();
2110 let cwd = tmp.path();
2111 std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2112 assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2113 std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2114 assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2115 }
2116
2117 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2118 async fn ephemeral_session_builds_roundtrips() {
2119 let s = ephemeral_session();
2123 let leaf = s.get_leaf_id().await;
2124 assert!(leaf.is_ok());
2125 }
2126
2127 }