rpi_cli/session.rs
1//! Harness construction + session-storage wiring. Mirrors the Rust-side
2//! equivalent of the TS `packages/coding-agent/src/core/sdk.ts`
3//! (`createAgentSession`) — build the env, tools, durable session storage, and
4//! `AgentHarnessOptions`, then `AgentHarness::create`.
5//!
6//! v1 scope cuts vs the TS SDK (tracked in `docs/m6-cli-open-questions.md`):
7//! - **Skill / prompt-template / context-file discovery IS wired**
8//! (`--no-skills`/`-ns`, `--no-prompt-templates`/`-np`, `--no-context-files`/
9//! `-nc` each suppress one channel; project `.pi/<sub>` + global
10//! `agent_dir()<sub>` discovery with project-wins dedupe via
11//! [`crate::resource_dirs`]; SYSTEM.md/APPEND_SYSTEM.md project-wins
12//! precedence). **Extension `resources_discover` (B5b) feeds the SAME loaders:
13//! a plugin's discovered skill/prompt paths merge with the static dirs and
14//! re-run through `load_skills`/`load_prompt_templates` (individual `.md` files
15//! load too — `load_skills` accepts both dirs and files). Theme discovery is
16//! accepted but ignored (rpi has no theme system — documented divergence).**
17//! **Trust gating remains deferred** — project resources are discovered
18//! unconditionally (a copied `.pi/` drops in and works).
19//! - **No `--models` cycling, no `ModelRuntime`/multi-provider.** One model,
20//! one provider (Anthropic), resolved up-front by [`crate::provider`].
21//! - **Built-in tools**: `read`, `bash`, `edit`, `write` plus the read-only
22//! `grep`/`find`/`ls` (the TS `createCodingTools` default set). `grep`/`find`
23//! use an in-process `FileSystem`+`regex`/`globset` implementation (documented
24//! divergence from the TS `rg`/`fd` shell-out; see `docs/m4-tools-open-questions.md`).
25//! - **Session restore (`-c`/`-r`/`--session`)** is *partially* supported: a
26//! fresh session is always created. The harness's `create` rejects sessions
27//! that already have records (restore not implemented — M5f divergence #3),
28//! so `-c`/`-r`/`--session` currently surface a clear "not implemented"
29//! message rather than silently starting fresh. See [`SessionSelection`].
30
31use std::path::{Path, PathBuf};
32use std::sync::atomic::Ordering;
33use std::sync::{Arc, Mutex};
34
35use rpi_ai::Provider;
36use rpi_harness::agent_harness::AgentHarness;
37use rpi_harness::context_files::{format_project_context, load_project_context_files};
38use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
39use rpi_harness::session::session::DefaultIdGenerator;
40use rpi_harness::session::types::SessionMetadata;
41use rpi_harness::session::Session;
42use rpi_harness::system_prompt::compose_system_prompt;
43use rpi_harness::types::{
44 AgentHarnessOptions, AgentHarnessResources, CompactionSettings, DrivingMode,
45 HarnessToolExecution, HarnessTool, RetryPolicy, ToolReplay,
46};
47use rpi_tools::{
48 create_bash_tool, create_edit_tool, create_find_tool, create_grep_tool, create_ls_tool,
49 create_read_tool, create_write_tool, ExecutionToolContext, MutationQueueRegistry,
50 OsExecutionEnv,
51};
52
53use crate::args::Args;
54use crate::provider::ResolvedModel;
55use crate::resource_dirs::{
56 discover_append_system_prompt_file, discover_system_prompt_file, global_dir,
57 load_prompt_templates_with_precedence, load_skills_with_precedence, project_dir,
58 prompt_template_dirs, skill_dirs,
59};
60use rpi_extensions::{
61 ExtensionEmitter, ExtensionSession, NullDiagnostics, PluginDiagnostics, PluginToolAdapter,
62 TeeEmitter, emit_resources_discover, load_session,
63};
64
65/// The subdirectory (under both project `.pi/` and global `agent_dir()/`) where
66/// rpi scans for cdylib plugins. Mirrors pi's `.pi/extensions`.
67const EXTENSIONS_SUBDIR: &str = "extensions";
68
69/// The built-in tool names v1 ships, in the order the TS `createCodingTools`
70/// registers them: the mutating set (`read`/`bash`/`edit`/`write`) followed by
71/// the read-only search set (`grep`/`find`/`ls`).
72pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "grep", "find", "ls"];
73
74/// The default coding system prompt. A condensed port of the TS
75/// `packages/coding-agent/src/core/system-prompt.ts` base prompt — the
76/// pi-internal docs/skills/context-file sections are omitted (v1 has none of
77/// that machinery), leaving the role + tools + guidelines core.
78pub fn default_system_prompt(cwd: &str) -> String {
79 format!(
80 "You are an expert coding assistant operating inside pi, a coding agent harness. \
81You help users by reading files, executing commands, editing code, and writing new files.
82
83Available tools:
84- read — Read file contents
85- bash — Execute shell commands
86- edit — Find/replace edits to existing files
87- write — Create or overwrite files
88- grep — Search file contents for a pattern
89- find — Search for files by glob pattern
90- ls — List directory contents
91
92Guidelines:
93- Be concise in your responses
94- Show file paths clearly when working with files
95- Prefer the smallest change that solves the problem
96
97Current working directory: {cwd}"
98 )
99}
100
101/// How the user asked to select a session. v1 honors `NoSession` (ephemeral
102/// `InMemorySessionStorage`), `New` (a fresh JSONL file), and — new this pass —
103/// `Latest` / `ById`, which **restore** an existing JSONL session on launch
104/// (`--continue`/`-c`, `--resume`/`-r`, `--session <id|path>`). The restored
105/// transcript renders into the TUI on startup and the run continues appending
106/// to the same file.
107#[derive(Debug, Clone)]
108pub enum SessionSelection {
109 /// `--no-session`: ephemeral, in-memory, nothing persisted.
110 Ephemeral,
111 /// Fresh durable JSONL session under `--session-dir` (or the default dir).
112 New { dir: PathBuf, name: Option<String> },
113 /// `-c` / `-r`: restore the most recent session in the default dir.
114 Latest,
115 /// `--session <id|path>`: restore the session whose id matches, or whose
116 /// file name contains the id.
117 ById { id: String },
118}
119
120/// Decide the session selection from parsed args + the resolved cwd.
121pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
122 if args.no_session {
123 return SessionSelection::Ephemeral;
124 }
125 if args.continue_session || args.resume {
126 // `--continue` and `--resume` both restore the most recent session.
127 return SessionSelection::Latest;
128 }
129 if let Some(s) = &args.session {
130 return SessionSelection::ById { id: s.clone() };
131 }
132 let dir = args
133 .session_dir
134 .clone()
135 .unwrap_or_else(|| default_session_dir(cwd));
136 SessionSelection::New { dir, name: args.name.clone() }
137}
138
139/// The default session directory: `<cwd>/.pi/sessions`. Mirrors the TS
140/// `getDefaultSessionDir` (`.pi/agent/sessions` in TS; v1 uses `.pi/sessions`
141/// under the project — a documented divergence).
142pub fn default_session_dir(cwd: &Path) -> PathBuf {
143 cwd.join(".pi").join("sessions")
144}
145
146/// Build the `AgentHarness` from the resolved model + parsed args + cwd.
147///
148/// This is the v1 equivalent of TS `createAgentSession`. It:
149/// 1. Builds the `OsExecutionEnv` rooted at `cwd`.
150/// 2. Constructs the built-in tools (optionally filtered by `--tools`/
151/// `--exclude-tools`/`--no-tools`/`--no-builtin-tools`).
152/// 3. Resolves the session storage (ephemeral vs fresh JSONL vs restore-error).
153/// 4. Assembles `AgentHarnessOptions` and calls `AgentHarness::create`.
154///
155/// Returns the harness plus a `broadcast::Receiver<AgentEvent>` carrying the
156/// live `AgentEvent` stream from every run (backed by a `BroadcastEmitter`
157/// installed on the harness). Interactive mode drains this to render streaming
158/// responses; the non-interactive modes simply drop it.
159/// Returns the harness, the live `AgentEvent` broadcast receiver, and a
160/// [`ReloadContext`] the interactive TUI holds to drive `/reload` (and a
161/// plugin's `runtime_action(Reload)` via the mailbox). Non-interactive modes
162/// drop the context (no `/reload` surface in print/json mode).
163pub async fn build(
164 resolved: &ResolvedModel,
165 args: &Args,
166 cwd: &Path,
167) -> Result<
168 (
169 AgentHarness,
170 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
171 ReloadContext,
172 ),
173 BuildError,
174> {
175 let cwd_str = cwd.to_string_lossy().to_string();
176
177 // ---- B5a: build the action bridge BEFORE extension load ----
178 // Extensions load before `AgentHarness::create` (extensions provide tools the
179 // harness is built with), but a plugin stores the `ActionBridge`'s raw
180 // `user_data` pointer during `register` and it must remain valid + the host
181 // must be ready for the whole session. So:
182 // 1. Capture the current tokio `Handle` (the async main-thread runtime) —
183 // the bridge spawns dispatch from any thread via `Handle::spawn`.
184 // 2. Build an *empty* `HarnessActionHost` (its harness cell is unset; no
185 // plugin can call a runtime action before the harness runs).
186 // 3. Wrap it as `Arc<dyn RuntimeActionHost>` + `ActionBridge`, thread
187 // `Some(bridge)` into `load_extensions` so every plugin's `user_data`
188 // points at this bridge.
189 // 4. After `AgentHarness::create` succeeds, call `set_harness(&cell, …)` to
190 // fill the host cell the bridge recovers on the first action call.
191 let runtime = tokio::runtime::Handle::try_current()
192 .map_err(|e| BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}")))?;
193 let catalog = crate::provider::available_catalog(resolved);
194 let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
195 catalog.clone(),
196 cwd.to_path_buf(),
197 runtime.clone(),
198 );
199 let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
200 // `runtime` is reused below (B5c: `PluggableProvider` needs a captured
201 // `Handle` to `spawn_blocking` the sync `ProviderRequestFn`), so clone here.
202 //
203 // B5d: build the initial bridge WITH a reload callback backed by a session-
204 // long `ReloadMailbox` (cloned into `ReloadContext` + handed to the TUI). A
205 // plugin's `runtime_action(Reload)` then signals the TUI's main loop instead
206 // of hitting the "not configured" fallback. The same mailbox is reused on
207 // `/reload` (the fresh bridge carries `ctx.mailbox`), so the bridge always
208 // points at the one TUI-installed sender across reloads.
209 let reload_mailbox = rpi_extensions::ReloadMailbox::new();
210 let action_bridge = rpi_extensions::ActionBridge::with_reload(
211 runtime.clone(),
212 host_arc,
213 rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
214 );
215
216 // ---- Execution env + tools ----
217 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
218 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
219 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
220 let _registry = Arc::new(MutationQueueRegistry::new());
221 // `env_dyn` is shared between the tool context (moved in) and the resource
222 // loaders below (borrowed); clone one branch so both hold a reference.
223 let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
224
225 let tools = build_tools(&ctx, args);
226 let mut tools = tools;
227
228 // ---- Extensions (Part B2) ----
229 // Load cdylib plugins from the resolved extension dirs, merge their tools
230 // into the built-in set (extension overrides same-named built-in; first-
231 // extension-wins across plugins; explicit `--tools`/`--exclude-tools` still
232 // apply to the merged set), and keep the loaded `Library` handles alive for
233 // the harness lifetime via the returned session guard. `--no-extensions`
234 // skips discovery entirely (no dirs scanned, no plugins loaded).
235 let extension_session = if args.no_extensions {
236 ExtensionSession::none()
237 } else {
238 load_extensions(args, cwd, Some(Arc::clone(&action_bridge)))
239 };
240 if args.verbose {
241 if let Some(s) = extension_session.summary() {
242 eprintln!("extensions: {s}");
243 }
244 report_deferred_renderers(&extension_session);
245 }
246 merge_extension_tools(&mut tools, &extension_session, args);
247 let active = active_tool_names(&tools, args);
248
249 // ---- Session storage ----
250 let selection = select_session(args, cwd);
251 let session = build_session(&selection, &cwd_str).await?;
252
253 // ---- System prompt base (precedence: --system-prompt > SYSTEM.md > default) ----
254 // Mirrors pi `discoverSystemPromptFile` (`resource-loader.ts:1022-1034`):
255 // an explicit `--system-prompt` flag wins; otherwise a discovered
256 // `<cwd>/.pi/SYSTEM.md` (project) overrides `<agent_dir>/SYSTEM.md`
257 // (global); otherwise the built-in default. **Project-wins** — the same
258 // direction as skills/prompts precedence.
259 let base_prompt = match args.system_prompt.as_deref() {
260 Some(explicit) => explicit.to_string(),
261 None => match discover_system_prompt_file(cwd) {
262 Some(path) => std::fs::read_to_string(&path).unwrap_or_else(|_| {
263 default_system_prompt(&cwd_str)
264 }),
265 None => default_system_prompt(&cwd_str),
266 },
267 };
268
269 // ---- Append-text sources (precedence: --append-system-prompt > APPEND_SYSTEM.md) ----
270 // Mirrors pi `appendSystemPrompt` (`resource-loader.ts:525-542`). Explicit
271 // `--append-system-prompt` flags are joined together; when none are given, a
272 // discovered `APPEND_SYSTEM.md` (project-wins over global) provides the
273 // append text. `--append-system-prompt` takes a value that may be a literal
274 // string OR a readable file path (mirrors TS `resolvePromptInput`).
275 let mut append_texts: Vec<String> = Vec::new();
276 for extra in &args.append_system_prompt {
277 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
278 append_texts.push(text);
279 }
280 if args.append_system_prompt.is_empty() {
281 if let Some(path) = discover_append_system_prompt_file(cwd) {
282 if let Ok(text) = std::fs::read_to_string(&path) {
283 append_texts.push(text);
284 }
285 }
286 }
287 let append_join = if append_texts.is_empty() {
288 None
289 } else {
290 Some(append_texts.join("\n\n"))
291 };
292
293 // ---- Resource discovery (skills + prompt-templates + context-files) ----
294 // The env is OS-backed, rooted at cwd. Each `--no-*` flag suppresses its
295 // channel independently (pi parity). Skills/prompts load project→global then
296 // dedupe first-wins-by-name (project wins). Context files walk
297 // global→ancestor(cwd→root), deepest-last (pi parity).
298 //
299 // **Trust gate (v1 divergence):** pi gates project `.pi/*` discovery on
300 // `isProjectTrusted()` (global resources are unconditional). rpi v1 has no
301 // trust prompt — project resources are discovered unconditionally (a copied
302 // `.pi/` drops in and works). Full trust gating is deferred.
303 let agent_dir = crate::config::agent_dir().ok();
304
305 // ---- B5b: extension resources_discover ----
306 // If any plugin registered a `resources_discover` handler, fan the event out
307 // (reason "startup") and collect skill/prompt/theme paths. These plugin-
308 // contributed paths merge WITH the static Part-A dirs (project `.pi/skills` +
309 // `agent_dir/skills`, etc.) and the loaders re-run over the union — the
310 // coherence point: a plugin's discovered skills land through the SAME loaders
311 // as static skills. Static dirs load FIRST so project skills keep winning name
312 // collisions (a plugin must not shadow a project skill of the same name —
313 // mirrors pi `extendResources` running AFTER the default load's first-wins
314 // map). `load_skills` now accepts both dirs and individual `.md` files, so a
315 // plugin returning bare `SKILL.md` paths loads them (the gap this closes).
316 // Themes are accepted but ignored (rpi has no theme system — documented).
317 // A `--no-*` flag suppresses its channel for BOTH static and discovered paths.
318 let discovered = extension_session
319 .snapshot_arc()
320 .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
321 .unwrap_or_default();
322
323 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
324 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
325 if !args.no_skills {
326 let mut dirs = skill_dirs(cwd);
327 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
328 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
329 skills = result.skills;
330 skill_diags = result.diagnostics;
331 }
332
333 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
334 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
335 if !args.no_prompt_templates {
336 let mut dirs = prompt_template_dirs(cwd);
337 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
338 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
339 prompt_templates = result.prompt_templates;
340 prompt_diags = result.diagnostics;
341 }
342
343 let context_block = if args.no_context_files {
344 String::new()
345 } else {
346 // `load_project_context_files` walks the global agentDir first then
347 // ancestor-walks cwd→root (deepest last). It needs a real agent_dir; if
348 // none is resolvable, pass the cwd dir so only the ancestor-walk runs
349 // (the global step returns None anyway).
350 let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
351 let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
352 format_project_context(&files)
353 };
354
355 // Surface resource-discovery diagnostics as startup warnings (verbose-only).
356 if args.verbose {
357 for d in &skill_diags {
358 eprintln!("warning: skill {} ({}): {}", d.path, d.code.as_str(), d.message);
359 }
360 for d in &prompt_diags {
361 eprintln!(
362 "warning: prompt template {} ({}): {}",
363 d.path,
364 d.code.as_str(),
365 d.message
366 );
367 }
368 }
369
370 // ---- Compose the full system prompt ----
371 // Order mirrors pi `buildSystemPrompt` (`system-prompt.ts:28-72`):
372 // base → append → context → skills. The skills listing is the harness's own
373 // section: `AgentHarness::compose_prompt` appends `<available_skills>` (gated
374 // on the `read` tool + `disable_model_invocation`, applied inside
375 // `format_skills_for_system_prompt`). So we pass None for skills here (the
376 // harness adds the listing itself) and fold only base+append+context into
377 // the prompt we hand the harness.
378 let system_prompt = compose_system_prompt(
379 Some(&base_prompt),
380 &[], // skills: harness appends the listing itself
381 if context_block.is_empty() { None } else { Some(&context_block) },
382 append_join.as_deref(),
383 );
384
385 // ---- Debug: dump the resolved system-prompt sections (verification) ----
386 // A verification affordance for Part-A resource discovery: prints the
387 // composed sections + resource counts to stderr so a smoke can confirm
388 // `<available_skills>` + `<project_context>` + appended text reached the
389 // prompt without parsing a provider round-trip. The harness composes the
390 // final prompt (base → append → context → skills); here we print the
391 // pre-harness sections (the harness adds the skills listing itself, gated
392 // on `read` + `disable_model_invocation`).
393 if args.debug_system_prompt {
394 eprintln!("=== --debug-system-prompt ===");
395 let base_src = if args.system_prompt.is_some() {
396 "--system-prompt"
397 } else if discover_system_prompt_file(cwd).is_some() {
398 "SYSTEM.md"
399 } else {
400 "default"
401 };
402 eprintln!("[base source: {base_src}]");
403 eprintln!("--- base ---\n{base_prompt}");
404 if let Some(append) = append_join.as_deref() {
405 eprintln!("--- append ---\n{append}");
406 } else {
407 eprintln!("--- append: (none) ---");
408 }
409 if context_block.is_empty() {
410 eprintln!("--- context: (none) ---");
411 } else {
412 eprintln!("--- context ---{context_block}");
413 }
414 let visible_skills = skills
415 .iter()
416 .filter(|s| s.disable_model_invocation != Some(true))
417 .count();
418 eprintln!(
419 "--- skills: {} loaded ({} model-visible, {} hidden) ---",
420 skills.len(),
421 visible_skills,
422 skills.len() - visible_skills
423 );
424 for s in &skills {
425 let hidden = if s.disable_model_invocation == Some(true) { " [hidden]" } else { "" };
426 eprintln!(" {}{hidden} — {}", s.name, s.description);
427 }
428 eprintln!("--- prompt templates: {} ---", prompt_templates.len());
429 for t in &prompt_templates {
430 eprintln!(" /{}", t.name);
431 }
432 // B5b: surface plugin-contributed discovery paths so a smoke can confirm
433 // the resources_discover round-trip fed the loaders (themes ignored).
434 eprintln!(
435 "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) (ignored) ---",
436 discovered.skill_paths.len(),
437 discovered.prompt_paths.len(),
438 discovered.theme_paths.len(),
439 );
440 for p in &discovered.skill_paths {
441 eprintln!(" skill: {p}");
442 }
443 for p in &discovered.prompt_paths {
444 eprintln!(" prompt: {p}");
445 }
446 eprintln!(
447 "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
448 );
449 eprintln!("=== end --debug-system-prompt ===");
450 }
451
452 // ---- Options ----
453 // Install a BroadcastEmitter so the caller (the interactive TUI) can drain
454 // AgentEvents live as a run unfolds. The corresponding broadcast::Receiver
455 // is returned alongside the harness; non-interactive modes simply drop it.
456 let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
457 let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
458 // The broadcast half stays live for the whole session (the TUI's drain task
459 // holds the receiver); reload re-wraps it in a fresh `TeeEmitter`, so keep
460 // a clone for the `ReloadContext` before the tee match consumes the original.
461 let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
462
463 // ---- Extensions emitter (Part B3a) ----
464 // If extensions loaded + registered any `on()` handlers, wrap the
465 // broadcast emitter in a `TeeEmitter` so every `AgentEvent` flows to BOTH
466 // the TUI (via the broadcast receiver above) AND the plugin handlers (via
467 // the `ExtensionEmitter`, which translates each `AgentEvent` →
468 // `StablePluginEvent` and fans out to the handlers registered for its tag).
469 // With no extensions the tee degrades to the bare broadcast emitter (a
470 // one-child passthrough), so the TUI path is unchanged.
471 let emitter: Arc<dyn rpi_agent::AgentEmitter> =
472 match extension_session.snapshot_arc() {
473 Some(snapshot) => {
474 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
475 Arc::new(TeeEmitter::new(vec![
476 broadcast_emitter,
477 Arc::new(ext),
478 ]))
479 }
480 None => broadcast_emitter,
481 };
482
483 let options = AgentHarnessOptions {
484 model: resolved.model.clone(),
485 thinking_level: resolved.thinking_level,
486 active_tool_names: active,
487 tools,
488 system_prompt: Some(system_prompt),
489 resources: AgentHarnessResources {
490 skills: if skills.is_empty() { None } else { Some(skills) },
491 prompt_templates: if prompt_templates.is_empty() {
492 None
493 } else {
494 Some(prompt_templates)
495 },
496 },
497 // A restored session (--continue/--resume/--session) already has
498 // records — let the harness load it and keep appending.
499 allow_existing_session: matches!(
500 selection,
501 SessionSelection::Latest | SessionSelection::ById { .. }
502 ),
503 stream_options: Default::default(),
504 retry: RetryPolicy::default(),
505 compaction: CompactionSettings::default(),
506 steering_mode: Default::default(),
507 follow_up_mode: Default::default(),
508 tool_execution: HarnessToolExecution::default(),
509 drive: DrivingMode::default(),
510 session,
511 // B5c: inject the resolved gateway provider PLUS one `Arc<dyn Provider>`
512 // per registered extension provider (`PluggableProvider` wraps a plugin's
513 // sync `ProviderRequestFn`). The harness's `build_stream_fn` resolves a
514 // provider lazily per call by `models.iter().find(|p| p.id() == model.provider)`,
515 // so a catalog model whose `provider` matches an extension provider's id
516 // routes to it. Extension providers land AFTER the gateway so the gateway
517 // stays first-match for its own ids (first-wins on a `.find`).
518 models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
519 to_provider_messages: None,
520 entry_projectors: Default::default(),
521 agent_emitter: Some(emitter),
522 // B3b: the three exists-but-`None` loop hooks — populated when an
523 // extension session registers handlers for the matching pi `on()`
524 // tags (before_tool_call/after_tool_call/context). v1 leaves them `None`
525 // here; the rpi-extensions adapter that owns plugin handler dispatch is
526 // wired in the same build path once B3b's host-side adapter lands.
527 before_tool_call: None,
528 after_tool_call: None,
529 transform_context: None,
530 entry_transforms: Vec::new(),
531 // Extension provider hooks (B4): plugins subscribing to the
532 // BeforeProviderRequest / BeforeProviderHeaders / AfterProviderResponse
533 // events observe every provider call (observer semantics — the handler
534 // ABI has no patch channel in v1). A session without provider-hook
535 // subscribers runs hook-free.
536 provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(
537 &extension_session,
538 )
539 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
540 };
541
542 let harness = match AgentHarness::create(options).await {
543 Ok(h) => {
544 // Fill the extension action host now that the harness exists
545 // (plugin runtime_action calls can then reach it).
546 crate::extensions_actions::HarnessActionHost::set_harness(
547 &harness_cell,
548 Arc::new(h.clone()),
549 );
550 h
551 }
552 Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
553 };
554
555 // ---- B5d: assemble the ReloadContext the TUI holds ----
556 // Every field is cheap to clone (Arc / Vec / args Clone). The cells own the
557 // live session + bridge so `/reload` can swap them; the harness itself is
558 // NOT held here (the TUI already owns a `&AgentHarness` / clone at the call
559 // site — passing it into `reload_extension_resources` keeps this structfree
560 // of a harness back-reference so it can be `Clone` into the reload callback).
561 let reload_context = ReloadContext {
562 extension_session: Arc::new(Mutex::new(extension_session)),
563 action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
564 catalog,
565 gateway: resolved.provider.clone(),
566 runtime: runtime.clone(),
567 cwd: cwd.to_path_buf(),
568 args: args.clone(),
569 resolved_model: resolved.model.clone(),
570 broadcast: broadcast_for_context,
571 mailbox: reload_mailbox,
572 };
573
574 Ok((harness, event_rx, reload_context))
575}
576
577// ===========================================================================
578// B5d — `/reload`: re-run extension + resource discovery into a LIVE harness
579// ===========================================================================
580//
581// `/reload` (interactive TUI command, or a plugin's `runtime_action(Reload)`)
582// re-runs everything `build` did around resources/extensions WITHOUT rebuilding
583// the `AgentHarness` itself (rebuilding would tear down the session/lane/event
584// wiring + the broadcast drain task the TUI owns). Instead it:
585//
586// 1. Builds a fresh `ExtensionSession` (re-load the cdylibs) over the same
587// dir set, with a FRESH `ActionBridge` (the old one is `invalidate`d so
588// in-flight plugin→host calls on the old bridge fail fast).
589// 2. Fans `resources_discover(_, "reload")` over the fresh snapshot.
590// 3. Re-runs the Part-A loaders (skills/prompts/context/SYSTEM.md/
591// APPEND_SYSTEM.md) with the discovered paths merged in — same precedence
592// + `--no-*` gates as startup.
593// 4. Rebuilds the harness's live state via the B5d setters
594// (`set_system_prompt`/`set_resources`/`set_agent_emitter`/`set_models`/
595// `set_provider_hooks`/`set_tools`) so the NEXT run observes the reloaded
596// config (in-flight runs finish on the old `ConfigSnapshot`).
597// 5. Swaps the cells (`ExtensionSession`, `ActionBridge`, harness action
598// host's harness cell stays — the harness is the same object) and drops
599// the old session + bridge (their keepalives unmap the old cdylibs; the
600// new session's keepalive holds the fresh mappings).
601//
602// The reload is a `rpi-cli` concern (NOT a harness op): `rpi-extensions`
603// carries only the `ActionBridge` staleness flag + a `ReloadMailbox` `()` signal
604// (no pi-cli `TuiMessage` type — leaf DAG preserved). The TUI owns the mailbox
605// receiver + the actual reload routine; a plugin's
606// `runtime_action(Reload)` signals the mailbox and returns `Ok(null)`
607// immediately so the calling plugin's cdylib is NOT unmapped while its
608// `runtime_action` frame is still on the stack (the self-unmapping race a
609// synchronous plugin-initiated reload would have).
610//
611// `reload_extension_resources` is the shared routine both `/reload` (TUI) and
612// a plugin's `runtime_action(Reload)` (via the mailbox) drive. It is `pub` so
613// the TUI's main-loop handler + the mailbox-driven path call the same code.
614
615/// The cell that holds the live `ExtensionSession` across a `/reload`. Cloned
616/// into every site that needs the current session (the TUI, the reload
617/// callback). On reload the old session is `replace`d out (its `active` flag
618/// flipped + its keepalive dropped, unmapping the old cdylibs) and the fresh one
619/// `store`d. Carried as a plain `ExtensionSession` (not `Option`) — a `none()`
620/// placeholder fills the slot while the fresh one is being built.
621pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
622
623/// The cell that holds the live `ActionBridge` across a `/reload`. A plugin
624/// stores the bridge's raw `user_data` pointer during `register`; on reload the
625/// old bridge is `invalidate`d (in-flight calls fail fast) and the fresh one
626/// `store`d. The fresh session's plugins are handed the fresh bridge pointer.
627pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
628
629/// Everything `/reload` needs to rebuild extension + resource state into a live
630/// harness. Built once in [`build`] (alongside the harness) and held by the TUI
631/// (cloned into the reload callback the bridge carries + the `/reload` command
632/// handler). The harness itself is NOT held here — the TUI already owns a
633/// `&AgentHarness` / a clone; passing it at the call site keeps this struct
634/// free of a harness back-reference (so it can be `Clone` and moved into the
635/// reload callback without borrowing the harness).
636#[derive(Clone)]
637pub struct ReloadContext {
638 /// The live extension-session cell (swapped on reload).
639 pub extension_session: ExtensionSessionCell,
640 /// The live action-bridge cell (swapped + old invalidated on reload).
641 pub action_bridge: ActionBridgeCell,
642 /// The model catalog (read-only) the host uses to resolve `set_model(id)`.
643 /// `available_catalog(resolved)` is captured once — reload does not re-resolve
644 /// the provider (auth/provider resolution is a startup concern; reloading
645 /// extensions does not re-open auth).
646 pub catalog: Vec<rpi_ai::Model>,
647 /// The resolved gateway provider clone (for rebuilding `models` =
648 /// `vec![gateway] + PluggableProvider::from_session`). Cheap to clone (`Arc`).
649 pub gateway: Arc<dyn Provider>,
650 /// The ambient runtime handle (captured in `build`) — `PluggableProvider`
651 /// + the fresh `ActionBridge` need a captured `Handle` to spawn from any
652 /// thread.
653 pub runtime: tokio::runtime::Handle,
654 /// The cwd (for static resource-dir resolution + context-file walk).
655 pub cwd: PathBuf,
656 /// The parsed args (cloned) — `--no-*`/`--tools`/`--exclude-tools`/
657 /// `--extensions-dir`/`--no-extensions`/`--system-prompt`/etc all apply on
658 /// reload exactly as at startup (a reload re-reads the same flags; it does
659 /// not pick up argv changes mid-session, which is the right contract — pi's
660 /// `/reload` re-runs discovery with the same config).
661 pub args: Args,
662 /// The resolved model + thinking level (the harness's active model stays
663 /// unless `set_model` changed it; reload does not touch the model).
664 pub resolved_model: rpi_ai::Model,
665 /// The broadcast emitter the harness was built with. Reload rebuilds the
666 /// `TeeEmitter` over the fresh `ExtensionEmitter` (the old tee's extension
667 /// child is dropped, unsubscribing from the old registry). The broadcast
668 /// half stays live the whole session (the TUI's drain task holds the
669 /// receiver), so we keep a handle to re-wrap.
670 pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
671 /// The session-long reload mailbox (B5d). Build creates one, installs it on
672 /// the initial `ActionBridge` via [`reload_callback_from_mailbox`], and hands
673 /// a clone to the TUI. The TUI installs its `TuiMessage` sender so a plugin's
674 /// `runtime_action(Reload)` signals the main loop — the reload routine reuses
675 /// THIS mailbox (not a fresh default) when building the fresh bridge, so the
676 /// bridge always carries the mailbox the TUI installed across reloads.
677 pub mailbox: rpi_extensions::ReloadMailbox,
678}
679
680/// The outcome of a reload: a human-readable status line for the transcript
681/// (counts of what reloaded), and whether any load diagnostics appeared.
682pub struct ReloadOutcome {
683 /// One-line summary for the transcript note (e.g. "Reloaded 2 plugin(s),
684 /// 5 skill(s), 1 prompt(s).").
685 pub summary: String,
686 /// True iff at least one extension load warning fired (ABI mismatch / skip).
687 pub had_warnings: bool,
688}
689
690/// Re-run extension + resource discovery and push the rebuilt state into the
691/// live `harness` via the B5d setters. The old `ExtensionSession` +
692/// `ActionBridge` are invalidated + swapped in [`ReloadContext`]'s cells. This
693/// is the single routine both `/reload` (TUI) and a plugin's
694/// `runtime_action(Reload)` drive (the latter via the mailbox signal).
695///
696/// Returns a [`ReloadOutcome`] for the transcript. Best-effort: a failure in
697/// one channel (e.g. a plugin that fails to reload) does not abort the others —
698/// the reload completes with whatever loaded, mirroring pi's per-plugin
699/// skip-on-error. A hard failure (e.g. the harness is closed) surfaces as an
700/// error summary.
701pub async fn reload_extension_resources(
702 harness: &AgentHarness,
703 ctx: &ReloadContext,
704) -> ReloadOutcome {
705 let cwd_str = ctx.cwd.to_string_lossy().to_string();
706 let mut warnings = false;
707
708 // ---- 1. Build a fresh ActionBridge + ExtensionSession ----
709 // The fresh bridge carries the SAME `HarnessActionHost` (the host's harness
710 // cell already points at this harness; the host impl is reusable across
711 // reloads — only the bridge's staleness flag + reload callback differ). We
712 // re-use the host by reading it off the OLD bridge (it's the same
713 // `Arc<dyn RuntimeActionHost>`).
714 let old_bridge = ctx.action_bridge.lock().unwrap().clone();
715 let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
716 Some(b) => b.clone_host(),
717 None => {
718 // No prior bridge (no extensions ever loaded). Build a fresh host so
719 // a reload that newly discovers plugins can still drive actions.
720 let (action_host, _cell) =
721 crate::extensions_actions::HarnessActionHost::new_empty(
722 ctx.catalog.clone(),
723 ctx.cwd.clone(),
724 ctx.runtime.clone(),
725 );
726 crate::extensions_actions::HarnessActionHost::set_harness(
727 &_cell,
728 Arc::new(harness.clone()),
729 );
730 Arc::new(action_host)
731 }
732 };
733
734 let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
735 let fresh_bridge =
736 rpi_extensions::ActionBridge::with_reload(ctx.runtime.clone(), host, reload_cb);
737
738 let extension_session = if ctx.args.no_extensions {
739 rpi_extensions::ExtensionSession::none()
740 } else {
741 load_extensions(&ctx.args, &ctx.cwd, Some(Arc::clone(&fresh_bridge)))
742 };
743 if extension_session.is_empty() && !ctx.args.no_extensions {
744 // The fresh session may be empty if no cdylibs are present — not a
745 // warning per se, but note it.
746 }
747 if ctx.args.verbose {
748 if let Some(s) = extension_session.summary() {
749 eprintln!("reload: {s}");
750 }
751 report_deferred_renderers(&extension_session);
752 }
753
754 // ---- 2. Invalidate the old session + bridge BEFORE the swap ----
755 // The old registry's `active` flag flips false so any in-flight
756 // `emit_resources_discover`/event dispatch on the old snapshot no-ops; the
757 // old bridge's flag flips false so in-flight `runtime_action` calls parked
758 // on the old `user_data` hit the staleness guard. We do this BEFORE storing
759 // the fresh session so there is no window where both are "active".
760 //
761 // The session cell carries a plain `ExtensionSession` (not `Option`), so we
762 // `mem::replace` the live one out with a `none()` placeholder to extract it
763 // for invalidation (the snapshot's `active` flag is on a shared `Arc`, so a
764 // borrow of the extracted value is enough to flip it; the extraction itself
765 // also drops the old keepalive once we drop `old_session`, unmapping the old
766 // cdylibs). `mem::replace` (not `.take()`) because the cell is not `Option`.
767 {
768 let mut session_guard = ctx.extension_session.lock().unwrap();
769 let old_session =
770 std::mem::replace(&mut *session_guard, rpi_extensions::ExtensionSession::none());
771 if let Some(old_snap) = old_session.snapshot_arc() {
772 // `invalidate` is on the registry, but the snapshot shares the flag —
773 // flipping the snapshot's flag invalidates the registry too (same Arc).
774 // `RegistrySnapshot` exposes `active_flag()` for this.
775 old_snap.active_flag().store(false, Ordering::SeqCst);
776 }
777 // `old_session` drops here — its keepalive releases the old `Library`
778 // handles (unmapping the old cdylibs). The fresh session's keepalive
779 // (built below) holds the fresh mappings.
780 }
781 if let Some(old_b) = old_bridge {
782 old_b.invalidate();
783 }
784
785 // The fresh bridge is now the live one. Store it + the fresh session so
786 // subsequent reloads (or plugin calls still resolving the cells) see them.
787 *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
788 *ctx.extension_session.lock().unwrap() = extension_session.clone();
789
790 // ---- 3. resources_discover ("reload") over the fresh snapshot ----
791 let discovered = extension_session
792 .snapshot_arc()
793 .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
794 .unwrap_or_default();
795
796 // ---- 4. Re-run the Part-A loaders (same precedence + --no-* gates) ----
797 let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
798 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
799
800 let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
801 let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
802 if !ctx.args.no_skills {
803 let mut dirs = skill_dirs(&ctx.cwd);
804 dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
805 let result = load_skills_with_precedence(&env_dyn, &dirs).await;
806 skills = result.skills;
807 skill_diags = result.diagnostics;
808 }
809
810 let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
811 let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
812 if !ctx.args.no_prompt_templates {
813 let mut dirs = prompt_template_dirs(&ctx.cwd);
814 dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
815 let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
816 prompt_templates = result.prompt_templates;
817 prompt_diags = result.diagnostics;
818 }
819
820 let context_block = if ctx.args.no_context_files {
821 String::new()
822 } else {
823 let agent_dir = crate::config::agent_dir().ok();
824 let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
825 let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
826 format_project_context(&files)
827 };
828
829 if !skill_diags.is_empty() || !prompt_diags.is_empty() {
830 warnings = true;
831 if ctx.args.verbose {
832 for d in &skill_diags {
833 eprintln!("warning: skill {} ({}): {}", d.path, d.code.as_str(), d.message);
834 }
835 for d in &prompt_diags {
836 eprintln!(
837 "warning: prompt template {} ({}): {}",
838 d.path,
839 d.code.as_str(),
840 d.message
841 );
842 }
843 }
844 }
845
846 // ---- Re-compose the system prompt (same precedence as build) ----
847 let base_prompt = match ctx.args.system_prompt.as_deref() {
848 Some(explicit) => explicit.to_string(),
849 None => match discover_system_prompt_file(&ctx.cwd) {
850 Some(path) => std::fs::read_to_string(&path)
851 .unwrap_or_else(|_| default_system_prompt(&cwd_str)),
852 None => default_system_prompt(&cwd_str),
853 },
854 };
855 let mut append_texts: Vec<String> = Vec::new();
856 for extra in &ctx.args.append_system_prompt {
857 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
858 append_texts.push(text);
859 }
860 if ctx.args.append_system_prompt.is_empty() {
861 if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
862 if let Ok(text) = std::fs::read_to_string(&path) {
863 append_texts.push(text);
864 }
865 }
866 }
867 let append_join = if append_texts.is_empty() {
868 None
869 } else {
870 Some(append_texts.join("\n\n"))
871 };
872 let system_prompt = compose_system_prompt(
873 Some(&base_prompt),
874 &[],
875 if context_block.is_empty() { None } else { Some(&context_block) },
876 append_join.as_deref(),
877 );
878
879 // ---- Rebuild the emitter (TeeEmitter over fresh ExtensionEmitter) ----
880 let emitter: Arc<dyn rpi_agent::AgentEmitter> =
881 match extension_session.snapshot_arc() {
882 Some(snapshot) => {
883 let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
884 Arc::new(TeeEmitter::new(vec![
885 ctx.broadcast.clone(),
886 Arc::new(ext),
887 ]))
888 }
889 None => ctx.broadcast.clone(),
890 };
891
892 // ---- 5. Push the rebuilt state into the live harness via the B5d setters ----
893 let resources = AgentHarnessResources {
894 skills: if skills.is_empty() { None } else { Some(skills.clone()) },
895 prompt_templates: if prompt_templates.is_empty() {
896 None
897 } else {
898 Some(prompt_templates.clone())
899 },
900 };
901 let _ = harness.set_system_prompt(Some(system_prompt)).await;
902 let _ = harness.set_resources(resources).await;
903 let _ = harness.set_agent_emitter(Some(emitter)).await;
904 let _ = harness
905 .set_models(build_models_with_extensions_for_reload(
906 &ctx.gateway,
907 &extension_session,
908 ctx.runtime.clone(),
909 ))
910 .await;
911 let _ = harness
912 .set_provider_hooks(
913 rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
914 .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
915 )
916 .await;
917
918 // Re-merge extension tools (a reloaded plugin may have added/removed a
919 // tool). The built-in set is rebuilt from scratch + extension tools merged
920 // on top, mirroring `build`.
921 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
922 let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
923 let mut tools = build_tools(&tool_ctx, &ctx.args);
924 merge_extension_tools(&mut tools, &extension_session, &ctx.args);
925 let active = active_tool_names(&tools, &ctx.args);
926 let _ = harness.set_tools(tools, Some(active)).await;
927
928 let summary = format!(
929 "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
930 extension_session.loaded_paths().len(),
931 skills.len(),
932 prompt_templates.len(),
933 );
934 ReloadOutcome { summary, had_warnings: warnings }
935}
936
937/// `build_models_with_extensions` for the reload path: the resolved gateway
938/// (NOT `resolved` — the reload context carries the gateway `Arc<dyn Provider>`
939/// directly, since the provider/auth did not change) first, then one
940/// `PluggableProvider` per registered extension provider in the fresh session.
941fn build_models_with_extensions_for_reload(
942 gateway: &Arc<dyn Provider>,
943 extension_session: &ExtensionSession,
944 runtime: tokio::runtime::Handle,
945) -> Vec<Arc<dyn Provider>> {
946 let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
947 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
948 models.extend(pluggable);
949 models
950}
951
952/// B5e: diagnostic for the deferred TUI renderers. `register_message_renderer`
953/// + `register_entry_renderer` (B5c) are recorded + exposed in the registry but
954/// their TUI consumption is deferred (the plan's B5e v1 wires ONLY
955/// `register_markdown_transformer` into the render path); a plugin that
956/// registers a message/entry renderer gets a one-line stderr note under
957/// `--verbose` so the author knows the registration landed but isn't driving
958/// the UI yet. `register_markdown_transformer` handlers ARE wired (B5e) — they
959/// are counted separately as "active".
960fn report_deferred_renderers(session: &ExtensionSession) {
961 let Some(snap) = session.snapshot_arc() else {
962 return;
963 };
964 let all = snap.renderers();
965 let markdown = all
966 .iter()
967 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
968 .count();
969 let message = all
970 .iter()
971 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
972 .count();
973 let entry = all
974 .iter()
975 .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
976 .count();
977 if markdown + message + entry == 0 {
978 return;
979 }
980 eprintln!(
981 "renderers: {} markdown-transform (active), {} message-render (deferred), {} entry-render (deferred)",
982 markdown, message, entry
983 );
984}
985
986/// A harness-build error.
987#[derive(Debug, thiserror::Error)]
988pub enum BuildError {
989 #[error("Could not create the session directory: {0}")]
990 SessionDir(String),
991 #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
992 SessionNotFound { requested: String, dir: String },
993 #[error("Could not build the harness: {0}")]
994 HarnessCreate(String),
995}
996
997/// B5c: build the `AgentHarnessOptions.models` vec — the resolved gateway
998/// provider first, then one `Arc<dyn Provider>` per registered extension
999/// provider (each a [`rpi_extensions::PluggableProvider`] wrapping a plugin's
1000/// sync `ProviderRequestFn`). The harness resolves a provider lazily per call by
1001/// `models.iter().find(|p| p.id() == model.provider)`, so the gateway stays
1002/// first-match for its own ids and an extension provider serves a catalog model
1003/// whose `provider` matches its id. `runtime` is the same `Handle` captured for
1004/// the action bridge — `PluggableProvider` needs a captured `Handle` to
1005/// `spawn_blocking` the sync ffi call from the async `stream_simple`.
1006fn build_models_with_extensions(
1007 resolved: &ResolvedModel,
1008 extension_session: &ExtensionSession,
1009 runtime: tokio::runtime::Handle,
1010) -> Vec<Arc<dyn Provider>> {
1011 let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1012 let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1013 models.extend(pluggable);
1014 models
1015}
1016
1017
1018/// Resolve the extension dirs to scan and load the cdylib plugins, returning
1019/// the loaded session guard (keeps the `Library` handles alive for the harness
1020/// lifetime). Scan order: project `.pi/extensions`, global `agent_dir()/`
1021/// `extensions`, then any `--extensions-dir` flags (scanned after the defaults
1022/// — `args.rs`). Diagnostics are a no-op sink for now; load skips/ABI mismatches
1023/// surface via the `--verbose` summary.
1024fn load_extensions(
1025 args: &Args,
1026 cwd: &Path,
1027 action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1028) -> ExtensionSession {
1029 let mut dirs = vec![project_dir(cwd, EXTENSIONS_SUBDIR)];
1030 if let Some(g) = global_dir(EXTENSIONS_SUBDIR) {
1031 dirs.push(g);
1032 }
1033 dirs.extend(args.extensions_dir.iter().cloned());
1034 let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1035 // B5a: the action bridge is cloned into every loaded plugin's vtable
1036 // `user_data` so post-register `runtime_action` calls recover the harness
1037 // host from any thread. The call site already gates `load_extensions` behind
1038 // `!no_extensions` and threads `Some(bridge)`; `None` is only passed by the
1039 // `--no-extensions` branch (which calls `ExtensionSession::none()` directly)
1040 // and tests.
1041 load_session(&dirs, diagnostics, action_bridge)
1042}
1043
1044/// Merge the loaded extension tools into the built-in set. An extension tool
1045/// overrides a same-named built-in; first-extension-wins across plugins is
1046/// already guaranteed by the registry (`register_tool` keeps the prior). The
1047/// explicit `--tools` allowlist / `--exclude-tools` denylist apply to the
1048/// merged set (the built-ins were already filtered in [`build_tools`]).
1049fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1050 let Some(snapshot) = session.snapshot() else { return };
1051 for et in snapshot.tools() {
1052 let name = &et.tool.name;
1053 if let Some(allow) = &args.tools {
1054 if !allow.iter().any(|a| a == name) {
1055 continue;
1056 }
1057 }
1058 if let Some(deny) = &args.exclude_tools {
1059 if deny.iter().any(|d| d == name) {
1060 continue;
1061 }
1062 }
1063 let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1064 let harness_tool = HarnessTool::new(Arc::new(adapter));
1065 match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1066 Some(slot) => *slot = harness_tool,
1067 None => tools.push(harness_tool),
1068 }
1069 }
1070}
1071
1072/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
1073/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
1074/// resolution in `createAgentSession`.
1075fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1076 if args.no_tools {
1077 return Vec::new();
1078 }
1079 // Construct every built-in once (cheap; the allowlist filters below).
1080 // Read-only search tools (grep/find/ls) take the same context and need no
1081 // mutation queue — they go through the `FileSystem` trait only.
1082 let mut all: Vec<(&'static str, HarnessTool)> = vec![
1083 ("read", HarnessTool::new(create_read_tool(ctx, None))),
1084 ("bash", HarnessTool::new(create_bash_tool(ctx, None))),
1085 ("edit", HarnessTool::new(create_edit_tool(ctx))),
1086 ("write", HarnessTool::new(create_write_tool(ctx))),
1087 ("grep", HarnessTool::new(create_grep_tool(ctx, None))),
1088 ("find", HarnessTool::new(create_find_tool(ctx, None))),
1089 ("ls", HarnessTool::new(create_ls_tool(ctx, None))),
1090 ];
1091
1092 // `--no-builtin-tools` disables the built-in set but would keep
1093 // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
1094 // here. We honor it by clearing the built-ins.
1095 if args.no_builtin_tools {
1096 all.clear();
1097 }
1098
1099 // Allowlist (`--tools`): keep only named built-ins.
1100 if let Some(allow) = &args.tools {
1101 all.retain(|(name, _)| allow.iter().any(|a| a == name));
1102 }
1103 // Denylist (`--exclude-tools`): drop named tools.
1104 if let Some(deny) = &args.exclude_tools {
1105 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1106 }
1107
1108 all.into_iter().map(|(_, t)| t.with_replay(ToolReplay::Safe)).collect()
1109}
1110
1111/// Resolve the active tool names from the constructed tools when no explicit
1112/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
1113/// active.
1114fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1115 if args.no_tools {
1116 return Vec::new();
1117 }
1118 if let Some(allow) = &args.tools {
1119 // The allowlist IS the active set (TS: `tools` doubles as the active
1120 // set when provided). Keep order + only those that exist.
1121 let names: Vec<String> = tools.iter().map(|t| t.tool.schema().name.clone()).collect();
1122 return allow.iter().filter(|a| names.iter().any(|n| n == *a)).cloned().collect();
1123 }
1124 // Default: every constructed tool is active. If `--exclude-tools` dropped
1125 // some, they're simply absent from `tools`, so this lands right.
1126 tools.iter().map(|t| t.tool.schema().name.clone()).collect()
1127}
1128
1129/// Build the `Session` facade for the chosen selection.
1130async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1131 match selection {
1132 SessionSelection::Ephemeral => Ok(ephemeral_session()),
1133 SessionSelection::New { dir, .. } => {
1134 // Ensure the sessions directory exists, then create a fresh JSONL
1135 // session file inside it.
1136 std::fs::create_dir_all(dir)
1137 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1138 let session = create_jsonl_session(dir, cwd)
1139 .await
1140 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1141 Ok(session)
1142 }
1143 SessionSelection::Latest | SessionSelection::ById { .. } => {
1144 restore_session(selection, cwd).await
1145 }
1146 }
1147}
1148
1149/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
1150/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
1151/// match the request, then open the matched file and wrap it in a `Session`
1152/// facade. The restored transcript renders into the TUI at startup and the
1153/// harness continues appending to the same file.
1154async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1155 // `list_typed` is newest-first; `Latest` takes the head, `ById` matches
1156 // the id exactly or by file-name containment (so `--session 01a02…` or a
1157 // partial id works, mirroring the TS id/path matching).
1158 match selection {
1159 SessionSelection::Latest => {
1160 let metas = list_session_metadata(cwd).await?;
1161 let Some(meta) = metas.first() else {
1162 return Err(BuildError::SessionNotFound {
1163 requested: "the most recent session".to_string(),
1164 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1165 });
1166 };
1167 open_session(meta, cwd).await
1168 }
1169 SessionSelection::ById { id } => {
1170 open_session_by_id(id, cwd).await.map_err(|e| match e {
1171 OpenError::NotFound { requested } => BuildError::SessionNotFound {
1172 requested,
1173 dir: default_session_dir(Path::new(cwd)).display().to_string(),
1174 },
1175 OpenError::Other(msg) => BuildError::SessionDir(msg),
1176 })
1177 }
1178 _ => unreachable!("restore_session only called for Latest/ById"),
1179 }
1180}
1181
1182/// Errors from [`open_session_by_id`], split so the CLI can map them to
1183/// [`BuildError`] while the TUI can surface a friendlier note.
1184pub enum OpenError {
1185 /// No session matched the request.
1186 NotFound { requested: String },
1187 /// The match existed but could not be opened/parsed.
1188 Other(String),
1189}
1190
1191impl std::fmt::Display for OpenError {
1192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1193 match self {
1194 OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1195 OpenError::Other(msg) => write!(f, "{msg}"),
1196 }
1197 }
1198}
1199
1200/// List the JSONL session metadata under the default session dir, newest
1201/// first. Shared by startup restore and the TUI `/session` hot-switch.
1202pub async fn list_session_metadata(cwd: &str) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1203 use rpi_harness::session::jsonl::{JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions};
1204 use rpi_tools::FileSystem;
1205
1206 let dir = default_session_dir(Path::new(cwd));
1207 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1208 let fs: Arc<dyn FileSystem> = env.clone();
1209 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1210 fs: fs.clone(),
1211 sessions_root: dir.to_string_lossy().into_owned(),
1212 clock: Arc::new(SystemClock),
1213 ids: Arc::new(DefaultIdGenerator::new()),
1214 });
1215 repo.list_typed(&JsonlSessionListOptions::default())
1216 .await
1217 .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1218}
1219
1220/// Open a session whose id matches exactly or by file-name containment
1221/// (so `--session 01a02…` / a partial id / a full file name all work). The
1222/// TUI `/session` hot-switch calls this with the selector's item value.
1223pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1224 let metas = list_session_metadata(cwd)
1225 .await
1226 .map_err(|e| OpenError::Other(e.to_string()))?;
1227 let Some(meta) = metas
1228 .iter()
1229 .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1230 else {
1231 return Err(OpenError::NotFound { requested: format!("session {id}") });
1232 };
1233 open_session(meta, cwd)
1234 .await
1235 .map_err(|e| OpenError::Other(e.to_string()))
1236}
1237
1238/// Fork the harness's current session into a new JSONL session (new id, parent
1239/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
1240/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
1241/// and the plugin `runtime_action(Fork)` host share one implementation.
1242/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
1243/// does `harness.set_session(...)`).
1244pub(crate) async fn fork_session_storage(
1245 harness: &AgentHarness,
1246 cwd: &str,
1247) -> Result<Session, String> {
1248 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1249 use rpi_tools::FileSystem;
1250
1251 let dir = default_session_dir(Path::new(cwd));
1252 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1253 let fs: Arc<dyn FileSystem> = env.clone();
1254 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1255 fs,
1256 sessions_root: dir.to_string_lossy().into_owned(),
1257 clock: Arc::new(SystemClock),
1258 ids: Arc::new(DefaultIdGenerator::new()),
1259 });
1260 // The fork needs the rich JSONL metadata (with the on-disk path); resolve
1261 // it from the session list by the current session's id.
1262 let id = harness.session().storage().metadata().id.clone();
1263 let metas = list_session_metadata(cwd).await.map_err(|e| e.to_string())?;
1264 let Some(source) = metas.iter().find(|m| m.id == id) else {
1265 return Err(format!("current session {id} not found on disk"));
1266 };
1267 let fork_storage = repo
1268 .fork_typed(
1269 source,
1270 &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1271 id: None,
1272 parent_session_id: Some(source.id.clone()),
1273 cwd: cwd.to_string(),
1274 metadata: None,
1275 },
1276 &rpi_harness::session::types::ForkOptions::default(),
1277 )
1278 .await
1279 .map_err(|e| e.to_string())?;
1280 Ok(Session::new(Arc::new(fork_storage), None))
1281}
1282
1283/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
1284/// startup restore + TUI hot-switch).
1285async fn open_session(
1286 meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
1287 cwd: &str,
1288) -> Result<Session, BuildError> {
1289 use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1290 use rpi_harness::session::types::SessionStorage;
1291 use rpi_tools::FileSystem;
1292
1293 let dir = default_session_dir(Path::new(cwd));
1294 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1295 let fs: Arc<dyn FileSystem> = env.clone();
1296 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1297 fs: fs.clone(),
1298 sessions_root: dir.to_string_lossy().into_owned(),
1299 clock: Arc::new(SystemClock),
1300 ids: Arc::new(DefaultIdGenerator::new()),
1301 });
1302 let storage = repo
1303 .open_by_jsonl_metadata(meta)
1304 .await
1305 .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
1306 let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
1307 Ok(Session::new(storage_arc, None))
1308}
1309
1310/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
1311fn ephemeral_session() -> Session {
1312 let storage = Arc::new(InMemorySessionStorage::new(
1313 SessionMetadata {
1314 id: "ephemeral".into(),
1315 created_at: 0,
1316 parent_session_id: None,
1317 },
1318 Arc::new(SystemClock),
1319 Arc::new(DefaultIdGenerator::new()),
1320 ));
1321 Session::new(storage, None)
1322}
1323
1324/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
1325///
1326/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
1327/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
1328/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
1329/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
1330///
1331/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
1332/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
1333/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
1334pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
1335 use rpi_harness::session::jsonl::{
1336 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1337 };
1338 use rpi_tools::FileSystem;
1339
1340 // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
1341 // relative-path resolution matches the tool env.
1342 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1343 let fs: Arc<dyn FileSystem> = env.clone();
1344
1345 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1346 fs: fs.clone(),
1347 sessions_root: dir.to_string_lossy().into_owned(),
1348 clock: Arc::new(SystemClock),
1349 ids: Arc::new(DefaultIdGenerator::new()),
1350 });
1351
1352 let opts = JsonlSessionCreateOptions {
1353 id: None, // fresh uuidv7
1354 parent_session_id: None,
1355 cwd: cwd.to_string(),
1356 metadata: None,
1357 };
1358 let storage = repo
1359 .create_typed(&opts)
1360 .await
1361 .map_err(|e| format!("create session: {e}"))?;
1362 // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
1363 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
1364 Ok(Session::new(storage_arc, None))
1365}
1366
1367/// Read an `--append-system-prompt` target: if it's a readable file path, return
1368/// its contents; otherwise return `None` and let the caller use the literal.
1369fn read_append_target(target: &str) -> Option<String> {
1370 let path = Path::new(target);
1371 if path.is_file() {
1372 std::fs::read_to_string(path).ok()
1373 } else {
1374 None
1375 }
1376}
1377
1378#[cfg(test)]
1379mod tests {
1380 use super::*;
1381 use crate::args::Args;
1382
1383 #[test]
1384 fn default_prompt_mentions_cwd_and_tools() {
1385 let p = default_system_prompt("/tmp/proj");
1386 assert!(p.contains("/tmp/proj"));
1387 assert!(p.contains("read"));
1388 assert!(p.contains("bash"));
1389 assert!(p.contains("edit"));
1390 assert!(p.contains("write"));
1391 assert!(p.contains("grep"));
1392 assert!(p.contains("find"));
1393 assert!(p.contains("ls"));
1394 }
1395
1396 #[test]
1397 fn select_ephemeral_when_no_session() {
1398 let args = Args { no_session: true, ..Args::default() };
1399 let cwd = Path::new("/tmp");
1400 assert!(matches!(select_session(&args, cwd), SessionSelection::Ephemeral));
1401 }
1402
1403 #[test]
1404 fn select_latest_for_continue_and_resume() {
1405 let args = Args { continue_session: true, ..Args::default() };
1406 let cwd = Path::new("/tmp");
1407 assert!(matches!(select_session(&args, cwd), SessionSelection::Latest));
1408
1409 let args = Args { resume: true, ..Args::default() };
1410 assert!(matches!(select_session(&args, cwd), SessionSelection::Latest));
1411 }
1412
1413 #[test]
1414 fn select_by_id_for_session_flag() {
1415 let args = Args {
1416 session: Some("01a02ece".into()),
1417 ..Args::default()
1418 };
1419 let cwd = Path::new("/tmp");
1420 assert!(matches!(
1421 select_session(&args, cwd),
1422 SessionSelection::ById { id } if id == "01a02ece"
1423 ));
1424 }
1425
1426 #[test]
1427 fn select_new_with_custom_dir() {
1428 let args = Args {
1429 session_dir: Some(PathBuf::from("/tmp/sess")),
1430 ..Args::default()
1431 };
1432 let cwd = Path::new("/tmp");
1433 match select_session(&args, cwd) {
1434 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
1435 other => panic!("expected New, got {other:?}"),
1436 }
1437 }
1438
1439 #[test]
1440 fn select_new_default_dir() {
1441 let args = Args::default();
1442 let cwd = Path::new("/proj");
1443 match select_session(&args, cwd) {
1444 SessionSelection::New { dir, .. } => {
1445 assert_eq!(dir, Path::new("/proj/.pi/sessions"));
1446 }
1447 other => panic!("expected New, got {other:?}"),
1448 }
1449 }
1450
1451 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1452 async fn ephemeral_session_builds_roundtrips() {
1453 // Sanity: the ephemeral path produces a usable Session facade (the
1454 // harness build itself needs a provider; tested via the integration
1455 // path in tests/build.rs instead).
1456 let s = ephemeral_session();
1457 let leaf = s.get_leaf_id().await;
1458 assert!(leaf.is_ok());
1459 }
1460
1461 // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
1462 // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
1463}