Skip to main content

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}