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