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 `.rpi/<sub>` + legacy
10//!   `.rpi/<sub>` + global
11//!   `agent_dir()<sub>` discovery with project-wins dedupe via
12//!   [`crate::resource_dirs`]; SYSTEM.md/APPEND_SYSTEM.md project-wins
13//!   precedence). **Extension `resources_discover` (B5b) feeds the SAME loaders:
14//!   a plugin's discovered skill/prompt paths merge with the static dirs and
15//!   re-run through `load_skills`/`load_prompt_templates` (individual `.md` files
16//!   load too — `load_skills` accepts both dirs and files). Package themes are
17//!   parsed by the TUI when selected via settings or `--theme`.**
18//!   Project resources load by default without a prompt. `--no-approve` or a
19//!   stored negative `trust.json` decision disables them explicitly.
20//! - **No `ModelRuntime`/multi-provider registry.** The resolver supports the
21//!   built-in Anthropic/OpenAI-compatible providers and `models.json`, but
22//!   runtime catalog mutation remains outside this layer.
23//! - **Built-in tools**: `read`, `bash`, `edit`, `write`, and the read-only
24//!   `docs` lookup tool. The former rpi-only `grep`, `find`, `ls`, and
25//!   `powershell` tools remain library modules but are not registered by the
26//!   CLI.
27//! - **Session restore (`-c`/`-r`/`--session`)** is *partially* supported: a
28//!   fresh session is always created. The harness's `create` rejects sessions
29//!   that already have records unless `allow_existing_session` is enabled.
30//!   The interactive `-c`/`-r`/`--session` paths enable that mode and replay
31//!   the existing branch before appending new messages. See [`SessionSelection`].
32
33use std::collections::HashSet;
34use std::path::{Path, PathBuf};
35use std::sync::atomic::Ordering;
36use std::sync::{Arc, Mutex, OnceLock};
37
38use rpi_ai::Provider;
39use rpi_harness::agent_harness::AgentHarness;
40use rpi_harness::context_files::{format_project_context, load_project_context_files};
41use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
42use rpi_harness::session::session::DefaultIdGenerator;
43use rpi_harness::session::types::SessionMetadata;
44use rpi_harness::session::Session;
45use rpi_harness::system_prompt::compose_system_prompt;
46use rpi_harness::types::{
47    AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
48    HarnessTool, HarnessToolExecution, ToolReplay,
49};
50use rpi_tools::{
51    create_bash_tool, create_edit_tool, create_powershell_tool, create_read_tool,
52    create_write_tool, ExecutionToolContext, MutationQueueRegistry, OsExecutionEnv,
53};
54
55use crate::args::Args;
56use crate::docs_tool::create_docs_tool;
57use crate::provider::ResolvedModel;
58use crate::resource_dirs::{
59    discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
60    global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
61    load_prompt_templates_with_precedence, load_skills_with_precedence,
62    project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
63};
64use rpi_extensions::{
65    emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
66    PluginDiagnostics, PluginToolAdapter, TeeEmitter,
67};
68
69/// The coding tools registered by the CLI by default.
70pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
71
72/// The default coding system prompt. A condensed port of the TS
73/// `packages/coding-agent/src/core/system-prompt.ts` base prompt.
74pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
75    // The working-state rule must match what actually comes back on the next
76    // request. With `compat.requiresThinkingAsText` (or the native thinking
77    // channels of anthropic/responses) the model *does* see its own reasoning
78    // again, and claiming otherwise makes it re-derive and restate the plan
79    // every turn — the re-plan symptom in `docs/llm-repetition-forensics.md`.
80    let working_state_rule = if prior_reasoning_replayed {
81        "- Keep a SHORT working plan visible (a few bullets, or the todo tool) and update it as you go.  
82  Your own reasoning is carried back to you on the next turn, so do not re-derive it or restate  
83  the whole plan: continue from the last unfinished step, and when a step lands, say which one.  
84  Re-listing the same plan without acting on it is a bug, not progress."
85    } else {
86        "- Keep your working state in your VISIBLE replies, not only in reasoning. Reasoning is not  
87  carried into your next turn: only the text you write and the tool output you produce come  
88  back. Before each batch of tool calls, write one short line naming the task you are on and  
89  what remains. When you finish a step, say which one is done. If you keep the plan only in  
90  your head you will re-derive it from scratch every turn."
91    };
92
93    format!(
94        "You are an expert coding assistant operating inside rpi, a coding agent harness. \
95You help users by reading files, executing commands, editing code, and writing new files.
96
97Available tools:
98- read  — Read file contents
99- bash  — Execute shell commands
100- edit  — Find/replace edits to existing files
101- write — Create or overwrite files
102- docs  — Look up rpi usage, extension, package, and compatibility documentation
103
104Guidelines:
105- Reply in the SAME language the user is writing in. Detect the language of the user's
106  latest message (and, when it is too short to be sure, the working language of the
107  conversation so far) and use it for your prose replies. Mirror it unless the user
108  explicitly asks for a different language; only fall back to English when the
109  user's language cannot be determined.
110- Be concise in your responses
111- Show file paths clearly when working with files
112- Prefer the smallest change that solves the problem
113{working_state_rule}
114- Track multi-step work with the todo tool instead of re-listing the plan in prose: add the
115  steps once, then mark them done as you go. Re-stating the same plan without acting on it is
116  a bug, not progress.
117- Read only what you need. If a file or search result was already shown earlier in this
118  conversation, use it instead of fetching it again.
119- When unsure about rpi commands, extensions, Pi package compatibility, or .rpi configuration, consult the project documentation before guessing
120
121Current working directory: {cwd}"
122    )
123}
124
125/// How the user asked to select a session. v1 honors `NoSession` (ephemeral
126/// `InMemorySessionStorage`), `New` (a fresh JSONL file), and — new this pass —
127/// `Latest` / `ById`, which **restore** an existing JSONL session on launch
128/// (`--continue`/`-c`, `--resume`/`-r`, `--session <id|path>`). The restored
129/// transcript renders into the TUI on startup and the run continues appending
130/// to the same file.
131#[derive(Debug, Clone)]
132pub enum SessionSelection {
133    /// `--no-session`: ephemeral, in-memory, nothing persisted.
134    Ephemeral,
135    /// Fresh durable JSONL session under `--session-dir` (or the default dir).
136    New { dir: PathBuf, name: Option<String> },
137    /// `-c` / `-r`: restore the most recent session in the default dir.
138    Latest,
139    /// `--session <id|path>`: restore the session whose id matches, or whose
140    /// file name contains the id.
141    ById { id: String },
142    /// `--session-id <id>`: use the EXACT session id, creating it if missing.
143    ByExactId { id: String },
144    /// `--fork <path|id>`: fork the given session into a new one and start in
145    /// the fork.
146    Fork { source: String },
147}
148
149/// Decide the session selection from parsed args + the resolved cwd.
150pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
151    if args.no_session {
152        return SessionSelection::Ephemeral;
153    }
154    if args.continue_session || args.resume {
155        // `--continue` and `--resume` both restore the most recent session.
156        return SessionSelection::Latest;
157    }
158    if let Some(s) = &args.fork {
159        return SessionSelection::Fork { source: s.clone() };
160    }
161    if let Some(s) = &args.session_id {
162        return SessionSelection::ByExactId { id: s.clone() };
163    }
164    if let Some(s) = &args.session {
165        return SessionSelection::ById { id: s.clone() };
166    }
167    // `--session-dir` wins; then the saved `sessionDir` (upstream's key, same
168    // format as the flag); then the built-in default.
169    let dir = args
170        .session_dir
171        .clone()
172        .or_else(|| {
173            crate::settings::load_settings()
174                .ok()
175                .and_then(|settings| settings.session_dir)
176        })
177        .unwrap_or_else(|| default_session_dir(cwd));
178    SessionSelection::New {
179        dir,
180        name: args.name.clone(),
181    }
182}
183
184/// The default session directory is `<cwd>/.rpi/sessions`.
185pub fn default_session_dir(cwd: &Path) -> PathBuf {
186    cwd.join(".rpi").join("sessions")
187}
188
189/// Build the `AgentHarness` from the resolved model + parsed args + cwd.
190///
191/// This is the v1 equivalent of TS `createAgentSession`. It:
192/// 1. Builds the `OsExecutionEnv` rooted at `cwd`.
193/// 2. Constructs the built-in tools (optionally filtered by `--tools`/
194///    `--exclude-tools`/`--no-tools`/`--no-builtin-tools`).
195/// 3. Resolves the session storage (ephemeral vs fresh JSONL vs restore-error).
196/// 4. Assembles `AgentHarnessOptions` and calls `AgentHarness::create`.
197///
198/// Returns the harness plus a `broadcast::Receiver<AgentEvent>` carrying the
199/// live `AgentEvent` stream from every run (backed by a `BroadcastEmitter`
200/// installed on the harness). Interactive mode drains this to render streaming
201/// responses; the non-interactive modes simply drop it.
202/// Returns the harness, the live `AgentEvent` broadcast receiver, and a
203/// [`ReloadContext`] the interactive TUI holds to drive `/reload` (and a
204/// plugin's `runtime_action(Reload)` via the mailbox). Non-interactive modes
205/// drop the context (no `/reload` surface in print/json mode).
206pub async fn build(
207    resolved: &ResolvedModel,
208    args: &Args,
209    cwd: &Path,
210    project_trusted: bool,
211) -> Result<
212    (
213        AgentHarness,
214        tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
215        ReloadContext,
216    ),
217    BuildError,
218> {
219    let cwd_str = cwd.to_string_lossy().to_string();
220    // Plugin tools get the session's identity injected into every call's
221    // arguments (see `rpi_extensions::ToolCallContext`). It is created here,
222    // before the tools are built, and completed with the session id below once
223    // the session exists — the tool set has to exist before the harness does.
224    let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
225    if !project_trusted && args.verbose {
226        eprintln!(
227            "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
228        );
229    }
230    // Pi packages are explicitly opt-in because discovery can start Node and
231    // execute package code. An explicit project opt-out limits discovery to
232    // global settings.
233
234    // ---- B5a: build the action bridge BEFORE extension load ----
235    // Extensions load before `AgentHarness::create` (extensions provide tools the
236    // harness is built with), but a plugin stores the `ActionBridge`'s raw
237    // `user_data` pointer during `register` and it must remain valid + the host
238    // must be ready for the whole session. So:
239    //  1. Capture the current tokio `Handle` (the async main-thread runtime) —
240    //     the bridge spawns dispatch from any thread via `Handle::spawn`.
241    //  2. Build an *empty* `HarnessActionHost` (its harness cell is unset; no
242    //     plugin can call a runtime action before the harness runs).
243    //  3. Wrap it as `Arc<dyn RuntimeActionHost>` + `ActionBridge`, thread
244    //     `Some(bridge)` into `load_extensions` so every plugin's `user_data`
245    //     points at this bridge.
246    //  4. After `AgentHarness::create` succeeds, call `set_harness(&cell, …)` to
247    //     fill the host cell the bridge recovers on the first action call.
248    let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
249        BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
250    })?;
251    let catalog = crate::provider::available_catalog(resolved);
252    let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
253        catalog.clone(),
254        cwd.to_path_buf(),
255        runtime.clone(),
256        args.unknown_flags.clone(),
257        tool_context.clone(),
258    );
259    let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = Arc::new(action_host);
260    // `runtime` is reused below (B5c: `PluggableProvider` needs a captured
261    // `Handle` to `spawn_blocking` the sync `ProviderRequestFn`), so clone here.
262    //
263    // B5d: build the initial bridge WITH a reload callback backed by a session-
264    // long `ReloadMailbox` (cloned into `ReloadContext` + handed to the TUI). A
265    // plugin's `runtime_action(Reload)` then signals the TUI's main loop instead
266    // of hitting the "not configured" fallback. The same mailbox is reused on
267    // `/reload` (the fresh bridge carries `ctx.mailbox`), so the bridge always
268    // points at the one TUI-installed sender across reloads.
269    let reload_mailbox = rpi_extensions::ReloadMailbox::new();
270    // Session-scoped UI-dialog mailbox (runtime action 17 / `ask_user`). The
271    // initial bridge carries it and the TUI attaches itself once it starts
272    // polling; a headless run leaves it detached so `ask_user` fails loudly
273    // instead of fabricating a user answer. The SAME mailbox is reused across
274    // `/reload` so in-flight prompts + TUI attachment survive a plugin swap.
275    let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
276    // Session-scoped extension status registry (runtime action 18 / `SetStatus`).
277    // The bridge lets plugins write; the TUI polls the revision and repaints its
278    // footer. Reused across `/reload` so a plugin's status survives the swap.
279    let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
280    // Session-scoped editor-text queue (runtime action 19 / `SetEditorText`).
281    // The bridge lets a plugin drop a draft into the prompt editor; the TUI
282    // drains it each tick. Reused across `/reload` so a pending transcription
283    // survives a plugin swap.
284    let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
285    let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
286        runtime.clone(),
287        host_arc,
288        rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
289        ui_dialog_mailbox.clone(),
290        ext_status_mailbox.clone(),
291        editor_text_mailbox.clone(),
292    );
293
294    // ---- Execution env + tools ----
295    let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
296    let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
297    let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
298    let _registry = Arc::new(MutationQueueRegistry::new());
299    // `env_dyn` is shared between the tool context (moved in) and the resource
300    // loaders below (borrowed); clone one branch so both hold a reference.
301    let ctx = ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
302
303    let tools = build_tools(&ctx, args);
304    let mut tools = tools;
305
306    // ---- Extensions (Part B2) ----
307    // Load cdylib plugins from the resolved extension dirs, merge their tools
308    // into the built-in set (extension overrides same-named built-in; first-
309    // extension-wins across plugins; explicit `--tools`/`--exclude-tools` still
310    // apply to the merged set), and keep the loaded `Library` handles alive for
311    // the harness lifetime via the returned session guard. `--no-extensions`
312    // skips discovery entirely (no dirs scanned, no plugins loaded).
313    let extension_session = if args.no_extensions {
314        ExtensionSession::none()
315    } else {
316        load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
317    };
318    if args.verbose {
319        if let Some(s) = extension_session.summary() {
320            eprintln!("extensions: {s}");
321        }
322        report_deferred_renderers(&extension_session);
323    }
324    merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
325    let active = filter_active_tool_names(active_tool_names(&tools, args), args);
326
327    // ---- Session storage ----
328    let selection = select_session(args, cwd);
329    let session = build_session(&selection, &cwd_str).await?;
330    // Plugin tools now know which session they serve: `todo` and friends key
331    // their per-session state off it. A session without an id (ephemeral) leaves
332    // the field unset and a plugin is expected to fall back to project scope.
333    if let Ok(metadata) = session.get_metadata().await {
334        publish_session_identity(&tool_context, &metadata.id);
335    }
336    // `--name`/`-n` sets the session display name durably (a `name` fact in the
337    // log). It applies to whichever session this launch works in — a fresh one
338    // or a restored one — and an empty value clears a previous name. This is the
339    // only place the flag is applied: `SessionSelection::New` carries it, but
340    // builds that do not create a session (restore/fork) must still honor it.
341    if let Some(requested) = args.name.as_deref() {
342        let name = requested.trim();
343        let name = if name.is_empty() { None } else { Some(name) };
344        if let Err(error) = session.set_name(name).await {
345            if args.verbose {
346                eprintln!("warning: could not set session name: {error}");
347            }
348        }
349    }
350    // ---- System prompt base (precedence: --system-prompt > SYSTEM.md > default) ----
351    // Mirrors pi `discoverSystemPromptFile` (`resource-loader.ts:1022-1034`):
352    // an explicit `--system-prompt` flag wins; otherwise a discovered
353    // `<cwd>/.rpi/SYSTEM.md` wins, then
354    // `<agent_dir>/SYSTEM.md`.
355    // (global); otherwise the built-in default. **Project-wins** — the same
356    // direction as skills/prompts precedence.
357    // Whether this model sees its own prior reasoning again decides how the
358    // working-state rule is worded (see `default_system_prompt`).
359    let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
360    let base_prompt = match args.system_prompt.as_deref() {
361        Some(explicit) => explicit.to_string(),
362        None if project_trusted => match discover_system_prompt_file(cwd) {
363            Some(path) => std::fs::read_to_string(&path)
364                .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
365            None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
366        },
367        None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
368    };
369
370    // ---- Append-text sources (precedence: --append-system-prompt > APPEND_SYSTEM.md) ----
371    // Mirrors pi `appendSystemPrompt` (`resource-loader.ts:525-542`). Explicit
372    // `--append-system-prompt` flags are joined together; when none are given, a
373    // discovered `APPEND_SYSTEM.md` (project-wins over global) provides the
374    // append text. `--append-system-prompt` takes a value that may be a literal
375    // string OR a readable file path (mirrors TS `resolvePromptInput`).
376    let mut append_texts: Vec<String> = Vec::new();
377    for extra in &args.append_system_prompt {
378        let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
379        append_texts.push(text);
380    }
381    if args.append_system_prompt.is_empty() {
382        if let Some(path) = project_trusted
383            .then(|| discover_append_system_prompt_file(cwd))
384            .flatten()
385        {
386            if let Ok(text) = std::fs::read_to_string(&path) {
387                append_texts.push(text);
388            }
389        }
390    }
391    let append_join = if append_texts.is_empty() {
392        None
393    } else {
394        Some(append_texts.join("\n\n"))
395    };
396
397    // ---- Resource discovery (skills + prompt-templates + context-files) ----
398    // The env is OS-backed, rooted at cwd. Each `--no-*` flag suppresses its
399    // channel independently (pi parity). Skills/prompts load project→global,
400    // explicit/plugin paths, then static packages; dedupe first-wins-by-name
401    // keeps project and user resources ahead of packages. Context files walk
402    // global→ancestor(cwd→root), deepest-last (pi parity).
403    //
404    // **Trust gate (v1 divergence):** pi gates project config discovery on
405    // `isProjectTrusted()` (global resources are unconditional). rpi v1 has no
406    // trust prompt — project resources are discovered unconditionally (a copied
407    // `.rpi/` dir drops in and works). Full trust gating is deferred.
408    let agent_dir = crate::config::agent_dir().ok();
409
410    // ---- B5b: extension resources_discover ----
411    // If any plugin registered a `resources_discover` handler, fan the event out
412    // (reason "startup") and collect skill/prompt/theme paths. These plugin-
413    // contributed paths merge WITH the static Part-A dirs (project
414    // `.rpi/skills` +
415    // `agent_dir/skills`, etc.) and the loaders re-run over the union — the
416    // coherence point: a plugin's discovered skills land through the SAME loaders
417    // as static skills. Static dirs load FIRST so project skills keep winning name
418    // collisions (a plugin must not shadow a project skill of the same name —
419    // mirrors pi `extendResources` running AFTER the default load's first-wins
420    // map). `load_skills` now accepts both dirs and individual `.md` files, so a
421    // plugin returning bare `SKILL.md` paths loads them (the gap this closes).
422    // Theme paths are available to the TUI through the package resource list;
423    // skill/prompt loaders are the only resources needed by the harness here.
424    // A `--no-*` flag suppresses its channel for BOTH static and discovered paths.
425    let discovered = extension_session
426        .snapshot_arc()
427        .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
428        .unwrap_or_default();
429
430    let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
431    let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
432    let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
433    if !args.no_skills {
434        let mut dirs = if args.dev_local_only {
435            project_skill_dirs(cwd)
436        } else if project_trusted {
437            skill_dirs(cwd)
438        } else {
439            global_skill_dirs()
440        };
441        dirs.extend(args.skill.iter().cloned());
442        dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
443        let result = load_skills_with_precedence(&env_dyn, &dirs).await;
444        skills = result.skills;
445        skill_diags = result.diagnostics;
446        resource_diags.extend(result.resource_diagnostics);
447    }
448
449    let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
450    let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
451    if !args.no_prompt_templates {
452        let mut dirs = if args.dev_local_only {
453            project_prompt_template_dirs(cwd)
454        } else if project_trusted {
455            prompt_template_dirs(cwd)
456        } else {
457            global_prompt_template_dirs()
458        };
459        dirs.extend(args.prompt_template.iter().cloned());
460        dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
461        let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
462        prompt_templates = result.prompt_templates;
463        prompt_diags = result.diagnostics;
464        resource_diags.extend(result.resource_diagnostics);
465    }
466
467    let context_block = if args.no_context_files || !project_trusted {
468        String::new()
469    } else {
470        // `load_project_context_files` walks the global agentDir first then
471        // ancestor-walks cwd→root (deepest last). It needs a real agent_dir; if
472        // none is resolvable, pass the cwd dir so only the ancestor-walk runs
473        // (the global step returns None anyway).
474        let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
475        let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
476        format_project_context(&files)
477    };
478
479    // Surface resource-discovery diagnostics as startup warnings (verbose-only).
480    if args.verbose {
481        for d in &skill_diags {
482            eprintln!(
483                "warning: skill {} ({}): {}",
484                d.path,
485                d.code.as_str(),
486                d.message
487            );
488        }
489        for d in &prompt_diags {
490            eprintln!(
491                "warning: prompt template {} ({}): {}",
492                d.path,
493                d.code.as_str(),
494                d.message
495            );
496        }
497        // Structured collisions: make the winner/loser explicit.
498        for d in &resource_diags {
499            if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
500                eprintln!(
501                    "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
502                    c.resource_type.as_str(),
503                    c.name,
504                    c.winner_path,
505                    c.loser_path
506                );
507            }
508        }
509    }
510
511    // ---- Compose the full system prompt ----
512    // Order mirrors pi `buildSystemPrompt` (`system-prompt.ts:28-72`):
513    // base → append → context → skills. The skills listing is the harness's own
514    // section: `AgentHarness::compose_prompt` appends `<available_skills>` (gated
515    // on the `read` tool + `disable_model_invocation`, applied inside
516    // `format_skills_for_system_prompt`). So we pass None for skills here (the
517    // harness adds the listing itself) and fold only base+append+context into
518    // the prompt we hand the harness.
519    let system_prompt = compose_system_prompt(
520        Some(&base_prompt),
521        &[], // skills: harness appends the listing itself
522        if context_block.is_empty() {
523            None
524        } else {
525            Some(&context_block)
526        },
527        append_join.as_deref(),
528    );
529
530    // ---- Debug: dump the resolved system-prompt sections (verification) ----
531    // A verification affordance for Part-A resource discovery: prints the
532    // composed sections + resource counts to stderr so a smoke can confirm
533    // `<available_skills>` + `<project_context>` + appended text reached the
534    // prompt without parsing a provider round-trip. The harness composes the
535    // final prompt (base → append → context → skills); here we print the
536    // pre-harness sections (the harness adds the skills listing itself, gated
537    // on `read` + `disable_model_invocation`).
538    if args.debug_system_prompt {
539        eprintln!("=== --debug-system-prompt ===");
540        let base_src = if args.system_prompt.is_some() {
541            "--system-prompt"
542        } else if discover_system_prompt_file(cwd).is_some() {
543            "SYSTEM.md"
544        } else {
545            "default"
546        };
547        eprintln!("[base source: {base_src}]");
548        eprintln!("--- base ---\n{base_prompt}");
549        if let Some(append) = append_join.as_deref() {
550            eprintln!("--- append ---\n{append}");
551        } else {
552            eprintln!("--- append: (none) ---");
553        }
554        if context_block.is_empty() {
555            eprintln!("--- context: (none) ---");
556        } else {
557            eprintln!("--- context ---{context_block}");
558        }
559        let visible_skills = skills
560            .iter()
561            .filter(|s| s.disable_model_invocation != Some(true))
562            .count();
563        eprintln!(
564            "--- skills: {} loaded ({} model-visible, {} hidden) ---",
565            skills.len(),
566            visible_skills,
567            skills.len() - visible_skills
568        );
569        for s in &skills {
570            let hidden = if s.disable_model_invocation == Some(true) {
571                " [hidden]"
572            } else {
573                ""
574            };
575            eprintln!("    {}{hidden} — {}", s.name, s.description);
576        }
577        eprintln!("--- prompt templates: {} ---", prompt_templates.len());
578        for t in &prompt_templates {
579            eprintln!("    /{}", t.name);
580        }
581        // B5b: surface plugin-contributed discovery paths so a smoke can confirm
582        // the resources_discover round-trip fed the loaders; package themes are
583        // selected by the TUI rather than injected into the harness prompt.
584        eprintln!(
585            "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
586            discovered.skill_paths.len(),
587            discovered.prompt_paths.len(),
588            discovered.theme_paths.len(),
589        );
590        for p in &discovered.skill_paths {
591            eprintln!("    skill: {p}");
592        }
593        for p in &discovered.prompt_paths {
594            eprintln!("    prompt: {p}");
595        }
596        eprintln!(
597            "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
598        );
599        eprintln!("=== end --debug-system-prompt ===");
600    }
601
602    // ---- Options ----
603    // Install a BroadcastEmitter so the caller (the interactive TUI) can drain
604    // AgentEvents live as a run unfolds. The corresponding broadcast::Receiver
605    // is returned alongside the harness; non-interactive modes simply drop it.
606    let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
607    let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
608    // The broadcast half stays live for the whole session (the TUI's drain task
609    // holds the receiver); reload re-wraps it in a fresh `TeeEmitter`, so keep
610    // a clone for the `ReloadContext` before the tee match consumes the original.
611    let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
612
613    // ---- Extensions emitter (Part B3a) ----
614    // If extensions loaded + registered any `on()` handlers, wrap the
615    // broadcast emitter in a `TeeEmitter` so every `AgentEvent` flows to BOTH
616    // the TUI (via the broadcast receiver above) AND the plugin handlers (via
617    // the `ExtensionEmitter`, which translates each `AgentEvent` →
618    // `StablePluginEvent` and fans out to the handlers registered for its tag).
619    // With no extensions the tee degrades to the bare broadcast emitter (a
620    // one-child passthrough), so the TUI path is unchanged.
621    let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
622        Some(snapshot) => {
623            let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
624            Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
625        }
626        None => broadcast_emitter,
627    };
628
629    // Saved user defaults. Best-effort: a missing or malformed file must not stop
630    // a session from starting, and every accessor falls back to the harness
631    // default when its key is absent.
632    let saved_settings = crate::settings::load_settings().unwrap_or_default();
633
634    // `httpProxy` applies to rpi's own HTTP clients. Setting the environment is how
635    // they are configured (`pi-ai/src/http.rs`), and upstream does the same thing
636    // with this key — an explicit environment variable still wins, so a shell-level
637    // proxy override is not silently replaced by the setting.
638    if let Some(proxy) = &saved_settings.http_proxy {
639        for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
640            if std::env::var_os(key).is_none() {
641                std::env::set_var(key, proxy);
642            }
643        }
644    }
645
646    let options = AgentHarnessOptions {
647        model: resolved.model.clone(),
648        thinking_level: resolved.thinking_level,
649        active_tool_names: active,
650        tools,
651        system_prompt: Some(system_prompt),
652        resources: AgentHarnessResources {
653            skills: if skills.is_empty() {
654                None
655            } else {
656                Some(skills)
657            },
658            prompt_templates: if prompt_templates.is_empty() {
659                None
660            } else {
661                Some(prompt_templates)
662            },
663        },
664        // A restored session (--continue/--resume/--session) already has
665        // records — let the harness load it and keep appending.
666        allow_existing_session: matches!(
667            selection,
668            SessionSelection::Latest
669                | SessionSelection::ById { .. }
670                | SessionSelection::ByExactId { .. }
671                | SessionSelection::Fork { .. }
672        ),
673        stream_options: AgentHarnessStreamOptions {
674            timeout: args.timeout,
675            ..Default::default()
676        },
677        // Settings-backed harness options: upstream lets users tune retry,
678        // compaction and queue drain modes from settings.json. Global only,
679        // matching how `defaultTools` and the other settings-backed options in
680        // this file are read.
681        retry: saved_settings.retry_policy(),
682        compaction: saved_settings.compaction_settings(),
683        steering_mode: saved_settings.steering_mode(),
684        follow_up_mode: saved_settings.follow_up_mode(),
685        tool_execution: HarnessToolExecution::default(),
686        drive: DrivingMode::default(),
687        session,
688        // B5c: inject the resolved gateway provider PLUS one `Arc<dyn Provider>`
689        // per registered extension provider (`PluggableProvider` wraps a plugin's
690        // sync `ProviderRequestFn`). The harness's `build_stream_fn` resolves a
691        // provider lazily per call by `models.iter().find(|p| p.id() == model.provider)`,
692        // so a catalog model whose `provider` matches an extension provider's id
693        // routes to it. Extension providers land AFTER the gateway so the gateway
694        // stays first-match for its own ids (first-wins on a `.find`).
695        models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
696        to_provider_messages: None,
697        entry_projectors: Default::default(),
698        agent_emitter: Some(emitter),
699        // B3b: the three exists-but-`None` loop hooks — populated when an
700        // extension session registers handlers for the matching pi `on()`
701        // tags (before_tool_call/after_tool_call/context). v1 leaves them `None`
702        // here; the rpi-extensions adapter that owns plugin handler dispatch is
703        // wired in the same build path once B3b's host-side adapter lands.
704        before_tool_call: None,
705        after_tool_call: None,
706        transform_context: None,
707        entry_transforms: Vec::new(),
708        // Extension provider hooks (B4): plugins subscribing to the
709        // BeforeProviderRequest / BeforeProviderHeaders / AfterProviderResponse
710        // events observe every provider call (observer semantics — the handler
711        // ABI has no patch channel in v1). A session without provider-hook
712        // subscribers runs hook-free.
713        provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
714            .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
715    };
716
717    let harness = match AgentHarness::create(options).await {
718        Ok(h) => {
719            // Fill the extension action host now that the harness exists
720            // (plugin runtime_action calls can then reach it).
721            crate::extensions_actions::HarnessActionHost::set_harness(
722                &harness_cell,
723                Arc::new(h.clone()),
724            );
725            h
726        }
727        Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
728    };
729
730    // ---- Durable operation recovery (runtime layer) ----
731    // A previous process can die between `operation_started` and
732    // `operation_finished`, leaving the lane with an orphaned operation. Sweep
733    // every lane, surface what was found, and emit `run_start`/`run_end`
734    // (aborted) pairs so a TUI that already attached sees the settle. This is
735    // read-only: nothing is mutated here, so a failed sweep cannot corrupt the
736    // log. A clean session (the common case) reports no findings.
737    {
738        let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
739        match rpi_harness::runtime::recover_all_lanes(&runtime).await {
740            Ok(reports) => {
741                for (lane, report) in reports {
742                    if report.is_clean() {
743                        continue;
744                    }
745                    if report.is_corrupt() {
746                        eprintln!(
747                            "warning: session lane '{lane}' is corrupt ({} open operations); \
748                             refusing to auto-repair — inspect the session file",
749                            report.open_operations.len()
750                        );
751                        continue;
752                    }
753                    for decision in runtime.reconcile(&report) {
754                        if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
755                            if args.verbose {
756                                eprintln!(
757                                    "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
758                                );
759                            }
760                        }
761                    }
762                    runtime.emit_recovery_events(harness.events(), &report);
763                }
764            }
765            Err(e) => {
766                if args.verbose {
767                    eprintln!("warning: session recovery sweep failed: {e}");
768                }
769            }
770        }
771    }
772
773    // ---- B5d: assemble the ReloadContext the TUI holds ----
774    // Every field is cheap to clone (Arc / Vec / args Clone). The cells own the
775    // live session + bridge so `/reload` can swap them; the harness itself is
776    // NOT held here (the TUI already owns a `&AgentHarness` / clone at the call
777    // site — passing it into `reload_extension_resources` keeps this structfree
778    // of a harness back-reference so it can be `Clone` into the reload callback).
779    let reload_context = ReloadContext {
780        extension_session: Arc::new(Mutex::new(extension_session)),
781        action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
782        tool_context,
783        catalog,
784        gateway: resolved.provider.clone(),
785        runtime: runtime.clone(),
786        cwd: cwd.to_path_buf(),
787        project_trusted,
788        args: args.clone(),
789        resolved_model: resolved.model.clone(),
790        broadcast: broadcast_for_context,
791        mailbox: reload_mailbox,
792        ui_dialog: ui_dialog_mailbox,
793        ext_status: ext_status_mailbox,
794        editor_text: editor_text_mailbox,
795        dev_extension: None,
796    };
797
798    Ok((harness, event_rx, reload_context))
799}
800
801// ===========================================================================
802// B5d — `/reload`: re-run extension + resource discovery into a LIVE harness
803// ===========================================================================
804//
805// `/reload` (interactive TUI command, or a plugin's `runtime_action(Reload)`)
806// re-runs everything `build` did around resources/extensions WITHOUT rebuilding
807// the `AgentHarness` itself (rebuilding would tear down the session/lane/event
808// wiring + the broadcast drain task the TUI owns). Instead it:
809//
810//  1. Builds a fresh `ExtensionSession` (re-load the cdylibs) over the same
811//     dir set, with a FRESH `ActionBridge` (the old one is `invalidate`d so
812//     in-flight plugin→host calls on the old bridge fail fast).
813//  2. Fans `resources_discover(_, "reload")` over the fresh snapshot.
814//  3. Re-runs the Part-A loaders (skills/prompts/context/SYSTEM.md/
815//     APPEND_SYSTEM.md) with the discovered paths merged in — same precedence
816//     + `--no-*` gates as startup.
817//  4. Rebuilds the harness's live state via the B5d setters
818//     (`set_system_prompt`/`set_resources`/`set_agent_emitter`/`set_models`/
819//     `set_provider_hooks`/`set_tools`) so the NEXT run observes the reloaded
820//     config (in-flight runs finish on the old `ConfigSnapshot`).
821//  5. Swaps the cells (`ExtensionSession`, `ActionBridge`, harness action
822//     host's harness cell stays — the harness is the same object) and drops
823//     the old session + bridge (their keepalives unmap the old cdylibs; the
824//     new session's keepalive holds the fresh mappings).
825//
826// The reload is a `rpi-cli` concern (NOT a harness op): `rpi-extensions`
827// carries only the `ActionBridge` staleness flag + a `ReloadMailbox` `()` signal
828// (no pi-cli `TuiMessage` type — leaf DAG preserved). The TUI owns the mailbox
829// receiver + the actual reload routine; a plugin's
830// `runtime_action(Reload)` signals the mailbox and returns `Ok(null)`
831// immediately so the calling plugin's cdylib is NOT unmapped while its
832// `runtime_action` frame is still on the stack (the self-unmapping race a
833// synchronous plugin-initiated reload would have).
834//
835// `reload_extension_resources` is the shared routine both `/reload` (TUI) and
836// a plugin's `runtime_action(Reload)` (via the mailbox) drive. It is `pub` so
837// the TUI's main-loop handler + the mailbox-driven path call the same code.
838
839/// The cell that holds the live `ExtensionSession` across a `/reload`. Cloned
840/// into every site that needs the current session (the TUI, the reload
841/// callback). On reload the old session is `replace`d out (its `active` flag
842/// flipped + its keepalive dropped, unmapping the old cdylibs) and the fresh one
843/// `store`d. Carried as a plain `ExtensionSession` (not `Option`) — a `none()`
844/// placeholder fills the slot while the fresh one is being built.
845pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
846
847/// The cell that holds the live `ActionBridge` across a `/reload`. A plugin
848/// stores the bridge's raw `user_data` pointer during `register`; on reload the
849/// old bridge is `invalidate`d (in-flight calls fail fast) and the fresh one
850/// `store`d. The fresh session's plugins are handed the fresh bridge pointer.
851pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
852
853/// Everything `/reload` needs to rebuild extension + resource state into a live
854/// harness. Built once in [`build`] (alongside the harness) and held by the TUI
855/// (cloned into the reload callback the bridge carries + the `/reload` command
856/// handler). The harness itself is NOT held here — the TUI already owns a
857/// `&AgentHarness` / a clone; passing it at the call site keeps this struct
858/// free of a harness back-reference (so it can be `Clone` and moved into the
859/// reload callback without borrowing the harness).
860#[derive(Clone)]
861pub struct ReloadContext {
862    /// The live extension-session cell (swapped on reload).
863    pub extension_session: ExtensionSessionCell,
864    /// The session's plugin-tool context (`cwd` + session id), reused — not
865    /// rebuilt — on reload so the rebuilt adapters keep serving the SAME
866    /// session. A reload that made a fresh context would leave every plugin
867    /// tool without a session id until the next launch.
868    pub tool_context: rpi_extensions::ToolCallContext,
869    /// The live action-bridge cell (swapped + old invalidated on reload).
870    pub action_bridge: ActionBridgeCell,
871    /// The extension status registry the TUI renders in its footer (`SetStatus`).
872    /// Lives in the context (not the cell) so it survives a bridge swap.
873    pub ext_status: rpi_extensions::ExtensionStatusMailbox,
874    /// The extension editor-text queue the TUI drains into the prompt editor
875    /// (`SetEditorText`, runtime action 19). Lives in the context (not the cell)
876    /// so it survives a bridge swap — exactly like `ext_status`. A voice plugin
877    /// writes a transcription here as a *draft*; the TUI applies it, starts the
878    /// optional auto-send countdown, and submits through the normal path so the
879    /// user bubble renders as usual.
880    pub editor_text: rpi_extensions::EditorTextMailbox,
881    /// The model catalog (read-only) the host uses to resolve `set_model(id)`.
882    /// `available_catalog(resolved)` is captured once — reload does not re-resolve
883    /// the provider (auth/provider resolution is a startup concern; reloading
884    /// extensions does not re-open auth).
885    pub catalog: Vec<rpi_ai::Model>,
886    /// The resolved gateway provider clone (for rebuilding `models` =
887    /// `vec![gateway] + PluggableProvider::from_session`). Cheap to clone (`Arc`).
888    pub gateway: Arc<dyn Provider>,
889    /// The ambient runtime handle (captured in `build`) — `PluggableProvider`
890    /// + the fresh `ActionBridge` need a captured `Handle` to spawn from any
891    /// thread.
892    pub runtime: tokio::runtime::Handle,
893    /// The cwd (for static resource-dir resolution + context-file walk).
894    pub cwd: PathBuf,
895    /// The project-trust decision captured before provider and session setup.
896    /// Startup consumers reuse this value so extension code cannot change the
897    /// effective policy by mutating the process cwd while it is loading.
898    pub project_trusted: bool,
899    /// The parsed args (cloned) — `--no-*`/`--tools`/`--exclude-tools`/
900    /// `--extensions-dir`/`--no-extensions`/`--system-prompt`/etc all apply on
901    /// reload exactly as at startup (a reload re-reads the same flags; it does
902    /// not pick up argv changes mid-session, which is the right contract — pi's
903    /// `/reload` re-runs discovery with the same config).
904    pub args: Args,
905    /// The resolved model + thinking level (the harness's active model stays
906    /// unless `set_model` changed it; reload does not touch the model).
907    pub resolved_model: rpi_ai::Model,
908    /// The broadcast emitter the harness was built with. Reload rebuilds the
909    /// `TeeEmitter` over the fresh `ExtensionEmitter` (the old tee's extension
910    /// child is dropped, unsubscribing from the old registry). The broadcast
911    /// half stays live the whole session (the TUI's drain task holds the
912    /// receiver), so we keep a handle to re-wrap.
913    pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
914    /// The session-long reload mailbox (B5d). Build creates one, installs it on
915    /// the initial `ActionBridge` via [`reload_callback_from_mailbox`], and hands
916    /// a clone to the TUI. The TUI installs its `TuiMessage` sender so a plugin's
917    /// `runtime_action(Reload)` signals the main loop — the reload routine reuses
918    /// THIS mailbox (not a fresh default) when building the fresh bridge, so the
919    /// bridge always carries the mailbox the TUI installed across reloads.
920    pub mailbox: rpi_extensions::ReloadMailbox,
921    /// Session-long UI-dialog mailbox (runtime action 17). The initial bridge
922    /// carries it; the TUI attaches itself and polls `take_pending` from its key
923    /// loop, and `/reload` reuses THIS mailbox when it builds the fresh bridge.
924    /// A detached mailbox makes `ask_user` fail with "requires an interactive
925    /// UI" (headless contract).
926    pub ui_dialog: rpi_extensions::UiDialogMailbox,
927    /// Active `rpi dev` extension builder. `/reload` rebuilds it before
928    /// swapping plugin sessions; its watcher signals `mailbox` after a
929    /// successful background build.
930    pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
931}
932
933/// Dispatch a no-payload lifecycle event (e.g. `SessionStart`, `SessionShutdown`,
934/// the rpi-specific `BeforeTuiStart`) to every extension handler subscribed to
935/// that tag, with **veto** + **timeout** semantics (P1).
936///
937/// Resolves the **current** extension session from the reload cell so a
938/// `/reload`-swapped session is honored. Each handler runs on a blocking thread
939/// with a per-event timeout ([`rpi_extensions::lifecycle_timeout_for`]); a
940/// handler returning [`rpi_plugin_sdk::EVENT_HANDLER_ABORT`] vetoes the phase
941/// and this returns `Some(reason)`. No-op (`None`) when no plugins loaded, no
942/// handler subscribes, or the registry is stale.
943///
944/// The caller decides what a veto means: a `BeforeTuiStart` veto aborts startup
945/// (never enter the TUI); `SessionStart`/`SessionShutdown` vetoes are advisory
946/// (the session is already up or already closing).
947pub async fn dispatch_session_event_async(
948    reload_context: &ReloadContext,
949    tag: rpi_plugin_sdk::EventTag,
950) -> Option<String> {
951    // Take the snapshot Arc + keepalive, drop the session lock, then dispatch —
952    // we must not hold the session cell's mutex across the await (a handler
953    // could re-enter a reload path that locks it). Holding the keepalive across
954    // the await keeps the cdylibs (owning the handler fn pointers) mapped even
955    // if a concurrent `/reload` swaps the session cell out.
956    let (snapshot, keepalive) = {
957        let session = reload_context.extension_session.lock().unwrap();
958        (session.snapshot_arc(), session.keepalive())
959    };
960    match snapshot {
961        Some(snapshot) => {
962            let _keepalive = keepalive;
963            rpi_extensions::dispatch_lifecycle_event(&snapshot, tag).await
964        }
965        None => None,
966    }
967}
968
969/// The outcome of a reload: a human-readable status line for the transcript
970/// (counts of what reloaded), and whether any load diagnostics appeared.
971pub struct ReloadOutcome {
972    /// One-line summary for the transcript note (e.g. "Reloaded 2 plugin(s),
973    /// 5 skill(s), 1 prompt(s).").
974    pub summary: String,
975    /// True iff at least one extension load warning fired (ABI mismatch / skip).
976    pub had_warnings: bool,
977    /// Formatted diagnostic lines (package / skill / prompt-template warnings)
978    /// that the caller should surface in its UI. Kept out of `eprintln!`
979    /// because the TUI owns the terminal: direct stderr writes during a run
980    /// would corrupt the alternate-screen input row.
981    pub details: Vec<String>,
982    /// True iff the reload itself failed hard (e.g. the extension build or
983    /// settings reload errored) and the previous resources were kept. Soft
984    /// diagnostics (skill shadowing, name validation) do not set this.
985    pub had_errors: bool,
986}
987
988struct PreparedReloadInputs {
989    extension_dirs: Vec<PathBuf>,
990    skill_base_dirs: Vec<PathBuf>,
991    prompt_base_dirs: Vec<PathBuf>,
992}
993
994/// Append the resource sources that are specific to a reload after the
995/// conventional project/global directories. Keep this order aligned with the
996/// initial build: explicit CLI paths must remain available after `/reload`,
997/// while discovered and package resources retain their lower precedence.
998fn append_reload_resource_paths(
999    mut paths: Vec<PathBuf>,
1000    explicit: &[PathBuf],
1001    discovered: &[String],
1002    js_paths: &[PathBuf],
1003    package_paths: &[PathBuf],
1004) -> Vec<PathBuf> {
1005    paths.extend(explicit.iter().cloned());
1006    paths.extend(discovered.iter().map(PathBuf::from));
1007    paths.extend(js_paths.iter().cloned());
1008    paths.extend(package_paths.iter().cloned());
1009    paths
1010}
1011
1012/// Re-run extension + resource discovery and push the rebuilt state into the
1013/// live `harness` via the B5d setters. The old `ExtensionSession` +
1014/// `ActionBridge` are invalidated + swapped in [`ReloadContext`]'s cells. This
1015/// is the single routine both `/reload` (TUI) and a plugin's
1016/// `runtime_action(Reload)` drive (the latter via the mailbox signal).
1017///
1018/// Returns a [`ReloadOutcome`] for the transcript. Best-effort: a failure in
1019/// one channel (e.g. a plugin that fails to reload) does not abort the others —
1020/// the reload completes with whatever loaded, mirroring pi's per-plugin
1021/// skip-on-error. A hard failure (e.g. the harness is closed) surfaces as an
1022/// error summary.
1023pub async fn reload_extension_resources(
1024    harness: &AgentHarness,
1025    ctx: &ReloadContext,
1026) -> ReloadOutcome {
1027    reload_extension_resources_inner(harness, ctx, || {}).await
1028}
1029
1030async fn reload_extension_resources_inner<F>(
1031    harness: &AgentHarness,
1032    ctx: &ReloadContext,
1033    after_prepare: F,
1034) -> ReloadOutcome
1035where
1036    F: FnOnce() + Send,
1037{
1038    // Resolve the arguments once for this reload. `rpi dev` may append its
1039    // freshly staged extension directory; every subsequent loader and policy
1040    // decision must observe that same effective set rather than falling back
1041    // to the pre-dev snapshot held in `ctx.args`.
1042    let mut effective_args = ctx.args.clone();
1043    if let Some(dev) = &ctx.dev_extension {
1044        if let Err(error) = dev
1045            .rebuild()
1046            .and_then(|_| dev.apply_to_args(&mut effective_args))
1047        {
1048            return ReloadOutcome {
1049                summary: format!(
1050                    "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1051                    dev.package_name()
1052                ),
1053                had_warnings: true,
1054                details: Vec::new(),
1055                had_errors: true,
1056            };
1057        }
1058    }
1059    let cwd_str = ctx.cwd.to_string_lossy().to_string();
1060    let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1061    let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1062        Ok(prepared) => prepared,
1063        Err(error) => {
1064            return ReloadOutcome {
1065                summary: format!(
1066                    "Settings reload failed: {error}. Keeping the currently loaded resources."
1067                ),
1068                had_warnings: true,
1069                details: Vec::new(),
1070                had_errors: true,
1071            };
1072        }
1073    };
1074    after_prepare();
1075    let PreparedReloadInputs {
1076        extension_dirs,
1077        skill_base_dirs,
1078        prompt_base_dirs,
1079    } = prepared;
1080    let mut warnings = false;
1081
1082    // ---- 1. Build a fresh ActionBridge + ExtensionSession ----
1083    // The fresh bridge carries the SAME `HarnessActionHost` (the host's harness
1084    // cell already points at this harness; the host impl is reusable across
1085    // reloads — only the bridge's staleness flag + reload callback differ). We
1086    // re-use the host by reading it off the OLD bridge (it's the same
1087    // `Arc<dyn RuntimeActionHost>`).
1088    let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1089    let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1090        Some(b) => b.clone_host(),
1091        None => {
1092            // No prior bridge (no extensions ever loaded). Build a fresh host so
1093            // a reload that newly discovers plugins can still drive actions.
1094            let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1095                ctx.catalog.clone(),
1096                ctx.cwd.clone(),
1097                ctx.runtime.clone(),
1098                ctx.args.unknown_flags.clone(),
1099                ctx.tool_context.clone(),
1100            );
1101            crate::extensions_actions::HarnessActionHost::set_harness(
1102                &_cell,
1103                Arc::new(harness.clone()),
1104            );
1105            Arc::new(action_host)
1106        }
1107    };
1108
1109    let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1110    let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1111        ctx.runtime.clone(),
1112        host,
1113        reload_cb,
1114        ctx.ui_dialog.clone(),
1115        ctx.ext_status.clone(),
1116        ctx.editor_text.clone(),
1117    );
1118
1119    let extension_session = if effective_args.no_extensions {
1120        rpi_extensions::ExtensionSession::none()
1121    } else {
1122        load_extensions_from_dirs(
1123            &effective_args,
1124            &extension_dirs,
1125            Some(Arc::clone(&fresh_bridge)),
1126        )
1127    };
1128    if extension_session.is_empty() && !effective_args.no_extensions {
1129        // The fresh session may be empty if no cdylibs are present — not a
1130        // warning per se, but note it.
1131    }
1132    if effective_args.verbose {
1133        if let Some(s) = extension_session.summary() {
1134            eprintln!("reload: {s}");
1135        }
1136        report_deferred_renderers(&extension_session);
1137    }
1138
1139    // ---- 2. Invalidate the old session + bridge BEFORE the swap ----
1140    // The old registry's `active` flag flips false so any in-flight
1141    // `emit_resources_discover`/event dispatch on the old snapshot no-ops; the
1142    // old bridge's flag flips false so in-flight `runtime_action` calls parked
1143    // on the old `user_data` hit the staleness guard. We do this BEFORE storing
1144    // the fresh session so there is no window where both are "active".
1145    //
1146    // The session cell carries a plain `ExtensionSession` (not `Option`), so we
1147    // `mem::replace` the live one out with a `none()` placeholder to extract it
1148    // for invalidation (the snapshot's `active` flag is on a shared `Arc`, so a
1149    // borrow of the extracted value is enough to flip it; the extraction itself
1150    // also drops the old keepalive once we drop `old_session`, unmapping the old
1151    // cdylibs). `mem::replace` (not `.take()`) because the cell is not `Option`.
1152    {
1153        let mut session_guard = ctx.extension_session.lock().unwrap();
1154        let old_session = std::mem::replace(
1155            &mut *session_guard,
1156            rpi_extensions::ExtensionSession::none(),
1157        );
1158        if let Some(old_snap) = old_session.snapshot_arc() {
1159            // `invalidate` is on the registry, but the snapshot shares the flag —
1160            // flipping the snapshot's flag invalidates the registry too (same Arc).
1161            // `RegistrySnapshot` exposes `active_flag()` for this.
1162            old_snap.active_flag().store(false, Ordering::SeqCst);
1163        }
1164        // `old_session` drops here — its keepalive releases the old `Library`
1165        // handles (unmapping the old cdylibs). The fresh session's keepalive
1166        // (built below) holds the fresh mappings.
1167    }
1168    if let Some(old_b) = old_bridge {
1169        old_b.invalidate();
1170    }
1171
1172    // The fresh bridge is now the live one. Store it + the fresh session so
1173    // subsequent reloads (or plugin calls still resolving the cells) see them.
1174    *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1175    *ctx.extension_session.lock().unwrap() = extension_session.clone();
1176
1177    // ---- 3. resources_discover ("reload") over the fresh snapshot ----
1178    let discovered = extension_session
1179        .snapshot_arc()
1180        .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1181        .unwrap_or_default();
1182
1183    // ---- 4. Re-run the Part-A loaders (same precedence + --no-* gates) ----
1184    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1185    let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1186
1187    let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1188    let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1189    let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1190    if !effective_args.no_skills {
1191        // JS discovery is backed by the session-long lazy Node host. Until
1192        // that host is swapped as part of a future full JS reload, preserve
1193        // the paths it contributed at startup across `/reload`.
1194        let js_paths: &[PathBuf] = &[];
1195        let package_paths: &[PathBuf] = &[];
1196        let dirs = append_reload_resource_paths(
1197            skill_base_dirs,
1198            &effective_args.skill,
1199            &discovered.skill_paths,
1200            js_paths,
1201            &package_paths,
1202        );
1203        let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1204        skills = result.skills;
1205        skill_diags = result.diagnostics;
1206        resource_diags.extend(result.resource_diagnostics);
1207    }
1208
1209    let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1210    let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1211    if !effective_args.no_prompt_templates {
1212        let js_paths: &[PathBuf] = &[];
1213        let package_paths: &[PathBuf] = &[];
1214        let dirs = append_reload_resource_paths(
1215            prompt_base_dirs,
1216            &effective_args.prompt_template,
1217            &discovered.prompt_paths,
1218            js_paths,
1219            &package_paths,
1220        );
1221        let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1222        prompt_templates = result.prompt_templates;
1223        prompt_diags = result.diagnostics;
1224        resource_diags.extend(result.resource_diagnostics);
1225    }
1226
1227    let context_block = if effective_args.no_context_files {
1228        String::new()
1229    } else {
1230        let agent_dir = crate::config::agent_dir().ok();
1231        let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1232        let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1233        format_project_context(&files)
1234    };
1235
1236    let mut details: Vec<String> = Vec::new();
1237    if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1238        warnings = true;
1239        // Collect formatted diagnostics for the caller to surface in its UI
1240        // instead of writing to stderr directly: the TUI owns the terminal,
1241        // and a raw `eprintln!` during a run would scribble over the
1242        // alternate-screen input row.
1243        for d in &skill_diags {
1244            details.push(format!(
1245                "warning: skill {} ({}): {}",
1246                d.path,
1247                d.code.as_str(),
1248                d.message
1249            ));
1250        }
1251        for d in &prompt_diags {
1252            details.push(format!(
1253                "warning: prompt template {} ({}): {}",
1254                d.path,
1255                d.code.as_str(),
1256                d.message
1257            ));
1258        }
1259        // Structured collisions: winner (kept) · loser (shadowed).
1260        for d in &resource_diags {
1261            if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1262                details.push(format!(
1263                    "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1264                    c.resource_type.as_str(),
1265                    c.name,
1266                    c.winner_path,
1267                    c.loser_path
1268                ));
1269            }
1270        }
1271    }
1272
1273    // ---- Re-compose the system prompt (same precedence as build) ----
1274    // Same working-state wording rule as `build`; a mid-session `/model` switch
1275    // re-composes the prompt on the next reload, which is the same staleness the
1276    // rest of this path already has (provider resolution is a startup concern).
1277    let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1278    let base_prompt = match effective_args.system_prompt.as_deref() {
1279        Some(explicit) => explicit.to_string(),
1280        None => match discover_system_prompt_file(&ctx.cwd) {
1281            Some(path) => std::fs::read_to_string(&path)
1282                .unwrap_or_else(|_| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1283            None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1284        },
1285    };
1286    let mut append_texts: Vec<String> = Vec::new();
1287    for extra in &effective_args.append_system_prompt {
1288        let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1289        append_texts.push(text);
1290    }
1291    if effective_args.append_system_prompt.is_empty() {
1292        if let Some(path) = discover_append_system_prompt_file(&ctx.cwd) {
1293            if let Ok(text) = std::fs::read_to_string(&path) {
1294                append_texts.push(text);
1295            }
1296        }
1297    }
1298    let append_join = if append_texts.is_empty() {
1299        None
1300    } else {
1301        Some(append_texts.join("\n\n"))
1302    };
1303    let system_prompt = compose_system_prompt(
1304        Some(&base_prompt),
1305        &[],
1306        if context_block.is_empty() {
1307            None
1308        } else {
1309            Some(&context_block)
1310        },
1311        append_join.as_deref(),
1312    );
1313
1314    // ---- Rebuild the emitter (TeeEmitter over fresh ExtensionEmitter) ----
1315    let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1316        Some(snapshot) => {
1317            let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1318            Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1319        }
1320        None => ctx.broadcast.clone(),
1321    };
1322
1323    // ---- 5. Push the rebuilt state into the live harness via the B5d setters ----
1324    let resources = AgentHarnessResources {
1325        skills: if skills.is_empty() {
1326            None
1327        } else {
1328            Some(skills.clone())
1329        },
1330        prompt_templates: if prompt_templates.is_empty() {
1331            None
1332        } else {
1333            Some(prompt_templates.clone())
1334        },
1335    };
1336    let _ = harness.set_system_prompt(Some(system_prompt)).await;
1337    let _ = harness.set_resources(resources).await;
1338    let _ = harness.set_agent_emitter(Some(emitter)).await;
1339    let _ = harness
1340        .set_models(build_models_with_extensions_for_reload(
1341            &ctx.gateway,
1342            &extension_session,
1343            ctx.runtime.clone(),
1344        ))
1345        .await;
1346    let _ = harness
1347        .set_provider_hooks(
1348            rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1349                .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1350        )
1351        .await;
1352
1353    // Re-merge extension tools (a reloaded plugin may have added/removed a
1354    // tool). The built-in set is rebuilt from scratch + extension tools merged
1355    // on top, mirroring `build`.
1356    let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1357    let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1358    let mut tools = build_tools(&tool_ctx, &effective_args);
1359    merge_extension_tools(
1360        &mut tools,
1361        &extension_session,
1362        &effective_args,
1363        &ctx.tool_context,
1364    );
1365    let mut active = active_tool_names(&tools, &effective_args);
1366    active = filter_active_tool_names(active, &effective_args);
1367    let _ = harness.set_tools(tools, Some(active)).await;
1368
1369    let summary = format!(
1370        "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1371        extension_session.loaded_paths().len(),
1372        skills.len(),
1373        prompt_templates.len(),
1374    );
1375    ReloadOutcome {
1376        summary,
1377        had_warnings: warnings,
1378        details,
1379        // Reload completed; any diagnostics here are soft (shadowing / name
1380        // validation / skipped optional resources), not hard failures.
1381        had_errors: false,
1382    }
1383}
1384
1385#[derive(Clone, Debug, PartialEq, Eq)]
1386struct ReloadSettingsFields {
1387    skill_dirs: Option<Vec<String>>,
1388    prompt_dirs: Option<Vec<String>>,
1389    extension_dirs: Option<Vec<String>>,
1390}
1391
1392impl From<crate::settings::Settings> for ReloadSettingsFields {
1393    fn from(settings: crate::settings::Settings) -> Self {
1394        Self {
1395            skill_dirs: settings.skill_dirs,
1396            prompt_dirs: settings.prompt_dirs,
1397            extension_dirs: settings.extension_dirs,
1398        }
1399    }
1400}
1401
1402#[derive(Clone, Debug, PartialEq, Eq)]
1403struct ReloadSettingsSnapshot {
1404    global: ReloadSettingsFields,
1405    project: Option<ReloadSettingsFields>,
1406}
1407
1408fn reload_reads_settings(args: &Args) -> bool {
1409    !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1410}
1411
1412/// Strictly read the settings fields consumed while preparing a reload. The
1413/// snapshot intentionally excludes UI/model fields that `/reload` does not
1414/// use, so an unrelated settings save does not invalidate the operation.
1415fn load_reload_settings_snapshot(
1416    args: &Args,
1417    cwd: &Path,
1418    project_trusted: bool,
1419) -> Result<Option<ReloadSettingsSnapshot>, String> {
1420    if !reload_reads_settings(args) {
1421        return Ok(None);
1422    }
1423    let global = crate::settings::load_settings()
1424        .map_err(|error| format!("could not load global settings: {error}"))?
1425        .into();
1426    let project = if project_trusted {
1427        crate::settings::load_active_project_settings(cwd)
1428            .map_err(|error| format!("could not load project settings: {error}"))?
1429            .map(|(_, settings)| settings.into())
1430    } else {
1431        None
1432    };
1433    Ok(Some(ReloadSettingsSnapshot { global, project }))
1434}
1435
1436/// Validate every settings document that this reload will consume before
1437/// replacing any live extension, bridge, or harness resource.
1438#[cfg(test)]
1439fn validate_settings_for_reload(
1440    args: &Args,
1441    cwd: &Path,
1442    project_trusted: bool,
1443) -> Result<(), String> {
1444    load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1445}
1446
1447fn prepare_reload_inputs(
1448    args: &Args,
1449    cwd: &Path,
1450    project_trusted: bool,
1451) -> Result<PreparedReloadInputs, String> {
1452    prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1453}
1454
1455fn prepare_reload_inputs_inner<F>(
1456    args: &Args,
1457    cwd: &Path,
1458    project_trusted: bool,
1459    before_verify: F,
1460) -> Result<PreparedReloadInputs, String>
1461where
1462    F: FnOnce(),
1463{
1464    let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1465
1466    let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1467        Vec::new()
1468    } else if project_trusted {
1469        extension_dirs(cwd)
1470    } else {
1471        global_extension_dirs()
1472    };
1473    if !args.no_extensions {
1474        extension_dirs.extend(args.extensions_dir.iter().cloned());
1475    }
1476
1477    let skill_base_dirs = if args.no_skills {
1478        Vec::new()
1479    } else if args.dev_local_only {
1480        project_skill_dirs(cwd)
1481    } else if project_trusted {
1482        skill_dirs(cwd)
1483    } else {
1484        global_skill_dirs()
1485    };
1486
1487    let prompt_base_dirs = if args.no_prompt_templates {
1488        Vec::new()
1489    } else if args.dev_local_only {
1490        project_prompt_template_dirs(cwd)
1491    } else if project_trusted {
1492        prompt_template_dirs(cwd)
1493    } else {
1494        global_prompt_template_dirs()
1495    };
1496
1497    before_verify();
1498    let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1499    if settings_before != settings_after {
1500        return Err(
1501            "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1502        );
1503    }
1504
1505    Ok(PreparedReloadInputs {
1506        extension_dirs,
1507        skill_base_dirs,
1508        prompt_base_dirs,
1509    })
1510}
1511
1512/// `build_models_with_extensions` for the reload path: the resolved gateway
1513/// (NOT `resolved` — the reload context carries the gateway `Arc<dyn Provider>`
1514/// directly, since the provider/auth did not change) first, then one
1515/// `PluggableProvider` per registered extension provider in the fresh session.
1516fn build_models_with_extensions_for_reload(
1517    gateway: &Arc<dyn Provider>,
1518    extension_session: &ExtensionSession,
1519    runtime: tokio::runtime::Handle,
1520) -> Vec<Arc<dyn Provider>> {
1521    let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1522    let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1523    models.extend(pluggable);
1524    models
1525}
1526
1527/// Diagnostic for registered TUI renderers. All three renderer kinds are
1528/// consumed by the interactive TUI's JSON component adapter; this line remains
1529/// useful under `--verbose` for extension authors.
1530fn report_deferred_renderers(session: &ExtensionSession) {
1531    let Some(snap) = session.snapshot_arc() else {
1532        return;
1533    };
1534    let all = snap.renderers();
1535    let markdown = all
1536        .iter()
1537        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1538        .count();
1539    let message = all
1540        .iter()
1541        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1542        .count();
1543    let entry = all
1544        .iter()
1545        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1546        .count();
1547    if markdown + message + entry == 0 {
1548        return;
1549    }
1550    eprintln!(
1551        "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1552        markdown, message, entry
1553    );
1554}
1555
1556/// A harness-build error.
1557#[derive(Debug, thiserror::Error)]
1558pub enum BuildError {
1559    #[error("Could not create the session directory: {0}")]
1560    SessionDir(String),
1561    #[error("Could not open the saved session: {0}")]
1562    SessionOpen(String),
1563    #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1564    SessionNotFound { requested: String, dir: String },
1565    #[error("Could not build the harness: {0}")]
1566    HarnessCreate(String),
1567}
1568
1569/// B5c: build the `AgentHarnessOptions.models` vec — the resolved gateway
1570/// provider first, then one `Arc<dyn Provider>` per registered extension
1571/// provider (each a [`rpi_extensions::PluggableProvider`] wrapping a plugin's
1572/// sync `ProviderRequestFn`). The harness resolves a provider lazily per call by
1573/// `models.iter().find(|p| p.id() == model.provider)`, so the gateway stays
1574/// first-match for its own ids and an extension provider serves a catalog model
1575/// whose `provider` matches its id. `runtime` is the same `Handle` captured for
1576/// the action bridge — `PluggableProvider` needs a captured `Handle` to
1577/// `spawn_blocking` the sync ffi call from the async `stream_simple`.
1578fn build_models_with_extensions(
1579    resolved: &ResolvedModel,
1580    extension_session: &ExtensionSession,
1581    runtime: tokio::runtime::Handle,
1582) -> Vec<Arc<dyn Provider>> {
1583    let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1584    let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1585    models.extend(pluggable);
1586    models
1587}
1588
1589/// Resolve project resource loading without prompting. Explicit CLI overrides
1590/// win, then a stored decision; projects with no decision load by default.
1591pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1592    if let Some(override_value) = args.trust_override {
1593        return override_value;
1594    }
1595    crate::config::project_trust_decision(cwd)
1596        .ok()
1597        .flatten()
1598        .unwrap_or(true)
1599}
1600
1601/// Resolve the extension dirs to scan and load the cdylib plugins, returning
1602/// the loaded session guard (keeps the `Library` handles alive for the harness
1603/// lifetime). Scan order: configured project paths, project `.rpi/extensions`,
1604/// configured/global conventional paths, then any
1605/// `--extensions-dir` flags (scanned after the defaults — `args.rs`).
1606/// Diagnostics are a no-op sink for now; load skips/ABI mismatches surface via
1607/// the `--verbose` summary.
1608fn load_extensions(
1609    args: &Args,
1610    cwd: &Path,
1611    project_trusted: bool,
1612    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1613) -> ExtensionSession {
1614    let mut dirs = if args.dev_local_only {
1615        Vec::new()
1616    } else if project_trusted {
1617        extension_dirs(cwd)
1618    } else {
1619        global_extension_dirs()
1620    };
1621    dirs.extend(args.extensions_dir.iter().cloned());
1622    load_extensions_from_dirs(args, &dirs, action_bridge)
1623}
1624
1625fn load_extensions_from_dirs(
1626    args: &Args,
1627    dirs: &[PathBuf],
1628    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1629) -> ExtensionSession {
1630    let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1631    // B5a: the action bridge is cloned into every loaded plugin's vtable
1632    // `user_data` so post-register `runtime_action` calls recover the harness
1633    // host from any thread. The call site already gates `load_extensions` behind
1634    // `!no_extensions` and threads `Some(bridge)`; `None` is only passed by the
1635    // `--no-extensions` branch (which calls `ExtensionSession::none()` directly)
1636    // and tests. Explicit `--extension`/`-e` files load after the dirs.
1637    rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1638}
1639
1640/// Merge the loaded extension tools into the built-in set. An extension tool
1641/// overrides a same-named built-in; first-extension-wins across plugins is
1642/// already guaranteed by the registry (`register_tool` keeps the prior). The
1643/// explicit `--tools` allowlist / `--exclude-tools` denylist apply to the
1644/// merged set (the built-ins were already filtered in [`build_tools`]).
1645fn merge_extension_tools(
1646    tools: &mut Vec<HarnessTool>,
1647    session: &ExtensionSession,
1648    args: &Args,
1649    tool_context: &rpi_extensions::ToolCallContext,
1650) {
1651    let Some(snapshot) = session.snapshot() else {
1652        return;
1653    };
1654    for et in snapshot.tools() {
1655        let name = &et.tool.name;
1656        if !tool_name_allowed(name, args) {
1657            continue;
1658        }
1659        let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1660            .with_context(tool_context.clone());
1661        let harness_tool = HarnessTool::new(Arc::new(adapter));
1662        match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1663            Some(slot) => *slot = harness_tool,
1664            None => tools.push(harness_tool),
1665        }
1666    }
1667}
1668
1669/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
1670/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
1671/// resolution in `createAgentSession`.
1672/// Default bash timeout: 120s when the model doesn't pass one (prevents a
1673/// forgotten `timeout` from hanging the run forever — the "卡住" report).
1674/// `RPI_BASH_TIMEOUT` overrides; a model-supplied timeout always wins.
1675pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1676    use rpi_tools::tools::bash::BashToolOptions;
1677    let default = std::env::var("RPI_BASH_TIMEOUT")
1678        .ok()
1679        .and_then(|v| v.parse::<f64>().ok())
1680        .unwrap_or(120.0);
1681    // Saved user defaults, matching how `defaultTools` is read in `build_tools`.
1682    // `shellCommandPrefix` is prepended to every command (upstream's key for
1683    // alias/`shopt` setup). Native's `shellPath` is *not* wired: neither
1684    // `BashToolOptions` nor `ShellCaptureOptions` carries a shell binary today, so
1685    // honouring it means plumbing one through pi-tools first.
1686    let settings = crate::settings::load_settings().unwrap_or_default();
1687    BashToolOptions {
1688        command_prefix: settings.shell_command_prefix.clone(),
1689        default_timeout: Some(default),
1690        prepare: None,
1691    }
1692}
1693
1694fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1695    if args.no_tools {
1696        return Vec::new();
1697    }
1698    // Keep the coding tools aligned with Pi and expose rpi's read-only docs
1699    // lookup as a default assistant capability.
1700    //
1701    // Replay policy (native `ToolDeclaration.replay`): only tools that are
1702    // genuinely free of side effects may be re-run when a crash leaves their
1703    // outcome unknown — everything else must stay `Never`, because replaying
1704    // `bash`/`write`/`edit` would duplicate a side effect. `HarnessTool::new`
1705    // already defaults to `Never`, so this is opt-in per read-only tool.
1706    let mut all: Vec<(&'static str, HarnessTool)> = vec![
1707        (
1708            "read",
1709            HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1710        ),
1711        (
1712            "bash",
1713            HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1714        ),
1715        ("edit", HarnessTool::new(create_edit_tool(ctx))),
1716        ("write", HarnessTool::new(create_write_tool(ctx))),
1717        (
1718            "docs",
1719            HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1720        ),
1721    ];
1722
1723    // Add PowerShell tool on Windows
1724    #[cfg(target_os = "windows")]
1725    all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1726
1727    // Apply default_tools from settings (if no CLI override)
1728    if args.tools.is_none() {
1729        if let Ok(settings) = crate::settings::load_settings() {
1730            if let Some(default_tools) = &settings.default_tools {
1731                all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1732            }
1733        }
1734    }
1735
1736    // `--no-builtin-tools` disables the built-in set but would keep
1737    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
1738    // here. We honor it by clearing the built-ins.
1739    if args.no_builtin_tools {
1740        all.clear();
1741    }
1742
1743    // Allowlist (`--tools`): keep only named built-ins.
1744    if let Some(allow) = &args.tools {
1745        all.retain(|(name, _)| allow.iter().any(|a| a == name));
1746    }
1747    // Denylist (`--exclude-tools`): drop named tools.
1748    if let Some(deny) = &args.exclude_tools {
1749        all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1750    }
1751
1752    all.into_iter().map(|(_, t)| t).collect()
1753}
1754
1755/// Resolve the active tool names from the constructed tools when no explicit
1756/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
1757/// active.
1758fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1759    filter_active_tool_names(
1760        tools.iter().map(|tool| tool.tool.schema().name.clone()),
1761        args,
1762    )
1763}
1764
1765/// Whether a tool name survives the command-line tool policy. Keep this check
1766/// centralized so extension-provided tools honor the same policy as built-ins.
1767pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1768    if args.no_tools {
1769        return false;
1770    }
1771    if args
1772        .tools
1773        .as_ref()
1774        .is_some_and(|allow| !allow.iter().any(|value| value == name))
1775    {
1776        return false;
1777    }
1778    if args
1779        .exclude_tools
1780        .as_ref()
1781        .is_some_and(|deny| deny.iter().any(|value| value == name))
1782    {
1783        return false;
1784    }
1785    true
1786}
1787
1788pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1789where
1790    I: IntoIterator<Item = String>,
1791{
1792    names
1793        .into_iter()
1794        .filter(|name| tool_name_allowed(name, args))
1795        .collect()
1796}
1797
1798/// Build the `Session` facade for the chosen selection.
1799async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1800    match selection {
1801        SessionSelection::Ephemeral => Ok(ephemeral_session()),
1802        SessionSelection::New { dir, .. } => {
1803            // Ensure the sessions directory exists, then create a fresh JSONL
1804            // session file inside it.
1805            std::fs::create_dir_all(dir)
1806                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1807            let session = create_jsonl_session(dir, cwd)
1808                .await
1809                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1810            Ok(session)
1811        }
1812        SessionSelection::Latest
1813        | SessionSelection::ById { .. }
1814        | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1815        SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1816    }
1817}
1818
1819/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
1820/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
1821/// match the request, then open the matched file and wrap it in a `Session`
1822/// facade. The restored transcript renders into the TUI at startup and the
1823/// harness continues appending to the same file.
1824async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1825    // `list_typed` is newest-first; `Latest` takes the head, `ById` matches
1826    // the id exactly or by file-name containment (so `--session 01a02…` or a
1827    // partial id works, mirroring the TS id/path matching).
1828    match selection {
1829        SessionSelection::Latest => {
1830            let metas = list_session_metadata(cwd).await?;
1831            let Some(meta) = first_resumable_session(&metas) else {
1832                return Err(BuildError::SessionNotFound {
1833                    requested: "the most recent session".to_string(),
1834                    dir: default_session_dir(Path::new(cwd)).display().to_string(),
1835                });
1836            };
1837            open_session(meta, cwd).await
1838        }
1839        SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1840            OpenError::NotFound { requested } => BuildError::SessionNotFound {
1841                requested,
1842                dir: default_session_dir(Path::new(cwd)).display().to_string(),
1843            },
1844            OpenError::Other(msg) => BuildError::SessionOpen(msg),
1845        }),
1846        SessionSelection::ByExactId { id } => {
1847            // Exact id match only (pi `--session-id`): restore when the
1848            // session exists, else create a fresh one under the default dir.
1849            let metas = list_session_metadata(cwd).await?;
1850            if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1851                return open_session(meta, cwd).await;
1852            }
1853            let dir = default_session_dir(Path::new(cwd));
1854            std::fs::create_dir_all(&dir)
1855                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1856            create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1857                .await
1858                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1859        }
1860        _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1861    }
1862}
1863
1864/// `--fork <path|id>`: open the source session, fork it into a new JSONL
1865/// session (records the parent id), and start in the fork.
1866async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1867    use rpi_harness::session::jsonl::{
1868        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1869    };
1870    use rpi_harness::session::types::{ForkOptions, SessionStorage};
1871    use rpi_tools::FileSystem;
1872
1873    let dir = default_session_dir(Path::new(cwd));
1874    std::fs::create_dir_all(&dir)
1875        .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1876    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1877    let fs: Arc<dyn FileSystem> = env.clone();
1878    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1879        fs: fs.clone(),
1880        sessions_root: dir.to_string_lossy().into_owned(),
1881        clock: Arc::new(SystemClock),
1882        ids: Arc::new(DefaultIdGenerator::new()),
1883    });
1884    let metas = repo
1885        .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1886        .await
1887        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1888    let source_meta = metas
1889        .iter()
1890        .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1891        .ok_or_else(|| BuildError::SessionNotFound {
1892            requested: format!("--fork {source}"),
1893            dir: dir.display().to_string(),
1894        })?;
1895    let fork_storage = repo
1896        .fork_typed(
1897            source_meta,
1898            &JsonlSessionCreateOptions {
1899                id: None,
1900                parent_session_id: Some(source_meta.id.clone()),
1901                cwd: cwd.to_string(),
1902                metadata: None,
1903            },
1904            &ForkOptions::default(),
1905        )
1906        .await
1907        .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1908    claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1909    let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1910    Ok(Session::new(storage_arc, None))
1911}
1912
1913/// Errors from [`open_session_by_id`], split so the CLI can map them to
1914/// [`BuildError`] while the TUI can surface a friendlier note.
1915pub enum OpenError {
1916    /// No session matched the request.
1917    NotFound { requested: String },
1918    /// The match existed but could not be opened/parsed.
1919    Other(String),
1920}
1921
1922impl std::fmt::Display for OpenError {
1923    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1924        match self {
1925            OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1926            // `Other` carries the bare reason (see `open_session_by_id`); the
1927            // prefix lives here so the TUI and the CLI's
1928            // `BuildError::SessionOpen` wrapper each render it exactly once
1929            // (wrapping the already-prefixed `BuildError` string doubled it).
1930            OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1931        }
1932    }
1933}
1934
1935/// List the JSONL session metadata under the default session dir, newest
1936/// first. Shared by startup restore and the TUI `/session` hot-switch.
1937pub async fn list_session_metadata(
1938    cwd: &str,
1939) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1940    use rpi_harness::session::jsonl::{
1941        JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1942    };
1943    use rpi_tools::FileSystem;
1944
1945    let dir = default_session_dir(Path::new(cwd));
1946    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1947    let fs: Arc<dyn FileSystem> = env.clone();
1948    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1949        fs: fs.clone(),
1950        sessions_root: dir.to_string_lossy().into_owned(),
1951        clock: Arc::new(SystemClock),
1952        ids: Arc::new(DefaultIdGenerator::new()),
1953    });
1954    repo.list_typed(&JsonlSessionListOptions::default())
1955        .await
1956        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1957}
1958
1959/// A cheap, display-oriented summary of one JSONL session file.
1960///
1961/// The session pickers (`-r` and `/session`) need enough context to tell saved
1962/// sessions apart. The header-only [`JsonlSessionMetadata`] carries no name and
1963/// no transcript, so a list built from it renders as a wall of identical
1964/// `2026-09-25T15-05-15-847Z_…` file names — including the many header-only
1965/// sessions an abandoned launch leaves behind. This reads just the head (and a
1966/// small tail, for a late `--name`/`/name` fact) of the file to recover:
1967///
1968/// - the session display name, when one was recorded;
1969/// - the first user prompt, single-lined and truncated, for a readable label;
1970/// - whether the file is header-only (nothing to resume).
1971///
1972/// It intentionally does **not** parse the whole log: sessions can be tens of
1973/// megabytes, and the picker runs over every session at startup.
1974#[derive(Debug, Clone, Default, PartialEq, Eq)]
1975pub struct SessionSummary {
1976    /// The session display name (`--name` / `/name`), if one was recorded.
1977    pub name: Option<String>,
1978    /// The first user prompt, single-lined and trimmed to a label-sized length.
1979    pub preview: Option<String>,
1980    /// True when the file holds only its header (a session with no content).
1981    pub empty: bool,
1982    /// On-disk size in bytes (a rough content-volume hint for the picker).
1983    pub bytes: u64,
1984}
1985
1986/// Bytes read from the head of a session file to recover a name + first prompt.
1987const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
1988/// Bytes read from the tail of a session file to recover a late name fact.
1989const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
1990/// Longest preview retained (characters, not bytes).
1991const SUMMARY_PREVIEW_CHARS: usize = 80;
1992
1993/// Summarize one session file for the pickers. Best-effort: an unreadable or
1994/// malformed file yields a summary with `empty: false` and no label, so it is
1995/// still selectable rather than silently dropped.
1996pub fn summarize_session_file(path: &Path) -> SessionSummary {
1997    use std::io::{Read, Seek, SeekFrom};
1998
1999    let Ok(mut file) = std::fs::File::open(path) else {
2000        return SessionSummary::default();
2001    };
2002    let Ok(size) = file.metadata().map(|m| m.len()) else {
2003        return SessionSummary::default();
2004    };
2005    if size == 0 {
2006        return SessionSummary {
2007            name: None,
2008            preview: None,
2009            empty: true,
2010            bytes: 0,
2011        };
2012    }
2013
2014    // Head: the header line (for the empty check) + a name fact + the first user
2015    // prompt. The whole window is scanned; it is already in memory.
2016    let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2017    let head_len = file.read(&mut head).unwrap_or(0);
2018    head.truncate(head_len);
2019    let head = String::from_utf8_lossy(&head);
2020    let mut lines: Vec<&str> = head.lines().collect();
2021    let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2022    // A header-only file is exactly the header plus an optional trailing `\n`.
2023    let empty = (size as usize) <= header_len + 1;
2024    // When the window stopped short of the file end, its final line is partial:
2025    // drop it so a half-read message can never be mistaken for a complete one.
2026    if (head_len as u64) < size && !lines.is_empty() {
2027        lines.pop();
2028    }
2029
2030    let mut summary = SessionSummary {
2031        name: None,
2032        preview: None,
2033        empty,
2034        bytes: size,
2035    };
2036    for line in lines {
2037        apply_summary_line(line, &mut summary);
2038    }
2039
2040    // Tail: a name set late in the session lives after the head window. Read the
2041    // last window and scan only complete lines so a torn/partial first line is
2042    // ignored. Only do this when the file is larger than the head window.
2043    if size > SUMMARY_HEAD_BYTES
2044        && file
2045            .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2046            .is_ok()
2047    {
2048        let mut tail = Vec::new();
2049        if file.read_to_end(&mut tail).is_ok() {
2050            let tail = String::from_utf8_lossy(&tail);
2051            let mut tail_lines = tail.lines();
2052            // Drop the possibly-partial first line of the window.
2053            tail_lines.next();
2054            for line in tail_lines {
2055                if let Some(name) = name_from_line(line) {
2056                    summary.name = name;
2057                }
2058            }
2059        }
2060    }
2061    summary
2062}
2063
2064/// Extract the name from a `{"kind":"fact","fact":"name","name":…}` line.
2065/// Returns `Some(None)` for an explicit `name: null` (the fact was cleared).
2066fn name_from_line(line: &str) -> Option<Option<String>> {
2067    let value: serde_json::Value = serde_json::from_str(line).ok()?;
2068    if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2069        return None;
2070    }
2071    if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2072        return None;
2073    }
2074    Some(match value.get("name") {
2075        Some(serde_json::Value::String(name)) => Some(name.clone()),
2076        _ => None,
2077    })
2078}
2079
2080/// Fold one JSONL line into `summary`: record a name fact, or capture the first
2081/// user prompt as the preview.
2082fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2083    if let Some(name) = name_from_line(line) {
2084        summary.name = name;
2085        return;
2086    }
2087    if summary.preview.is_some() {
2088        return;
2089    }
2090    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2091        return;
2092    };
2093    if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2094        return;
2095    }
2096    if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2097        return;
2098    }
2099    let Some(message) = value.get("message") else {
2100        return;
2101    };
2102    if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2103        return;
2104    }
2105    let text = match message.get("content") {
2106        Some(serde_json::Value::String(text)) => Some(text.as_str()),
2107        Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2108            (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2109                .then(|| block.get("text").and_then(|v| v.as_str()))
2110                .flatten()
2111        }),
2112        _ => None,
2113    };
2114    let Some(text) = text else { return };
2115    let collapsed = text
2116        .lines()
2117        .map(str::trim)
2118        .filter(|line| !line.is_empty())
2119        .collect::<Vec<_>>()
2120        .join(" ");
2121    if collapsed.is_empty() {
2122        return;
2123    }
2124    summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2125}
2126
2127/// The human label for a session row: its recorded name, else the first user
2128/// prompt, else a marker that says the file has no transcript to show. Shared
2129/// by the startup picker and the in-TUI `/session` selector.
2130pub fn session_display_label(summary: &SessionSummary) -> String {
2131    if let Some(name) = summary.name.as_deref().map(str::trim) {
2132        if !name.is_empty() {
2133            return name.to_string();
2134        }
2135    }
2136    match summary.preview.as_deref().map(str::trim) {
2137        Some(preview) if !preview.is_empty() => preview.to_string(),
2138        _ => "(no messages)".to_string(),
2139    }
2140}
2141
2142/// Compact byte count (1.2MB / 34KB / 512B), shared by the startup picker and
2143/// the in-TUI `/session` selector.
2144pub fn format_session_bytes(bytes: u64) -> String {
2145    if bytes >= 1_048_576 {
2146        format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2147    } else if bytes >= 1024 {
2148        format!("{}KB", bytes / 1024)
2149    } else {
2150        format!("{bytes}B")
2151    }
2152}
2153
2154/// The first 8 characters of a session id, enough to disambiguate two rows that
2155/// share a timestamp without shouting the whole UUID.
2156pub fn short_session_id(id: &str) -> &str {
2157    &id[..id.len().min(8)]
2158}
2159
2160/// Synchronously list every JSONL session file under `dir`, newest-first by
2161/// modification time.
2162///
2163/// The repo's [`list_session_metadata`] is async and scans one cwd-encoded
2164/// subdirectory level; the in-TUI `/session` selector runs on the blocking key
2165/// thread and cannot await it, so this walks the same layout directly. Both
2166/// `.jsonl` files directly under `dir` and one level of subdirectories are
2167/// accepted.
2168pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2169    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2170    let mut visit = |path: PathBuf| {
2171        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2172            return;
2173        }
2174        let modified = std::fs::metadata(&path)
2175            .and_then(|m| m.modified())
2176            .unwrap_or(std::time::UNIX_EPOCH);
2177        files.push((path, modified));
2178    };
2179    if let Ok(entries) = std::fs::read_dir(dir) {
2180        for entry in entries.flatten() {
2181            let path = entry.path();
2182            if path.is_dir() {
2183                if let Ok(inner) = std::fs::read_dir(&path) {
2184                    for child in inner.flatten() {
2185                        visit(child.path());
2186                    }
2187                }
2188            } else {
2189                visit(path);
2190            }
2191        }
2192    }
2193    files.sort_by(|a, b| b.1.cmp(&a.1));
2194    files.into_iter().map(|(path, _)| path).collect()
2195}
2196
2197/// Pick the session `--continue`/`-c` should open: the newest session that has
2198/// content (a header-only session resumes to an empty transcript). Falls back to
2199/// the newest overall when every session is empty, and `None` only when the
2200/// list is empty.
2201fn first_resumable_session<'a>(
2202    metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2203) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2204    metas
2205        .iter()
2206        .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2207        .or_else(|| metas.first())
2208}
2209
2210/// Open a session whose id matches exactly or by file-name containment
2211/// (so `--session 01a02…` / a partial id / a full file name all work). The
2212/// TUI `/session` hot-switch calls this with the selector's item value.
2213pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2214    let metas = list_session_metadata(cwd)
2215        .await
2216        .map_err(|e| OpenError::Other(e.to_string()))?;
2217    let Some(meta) = metas
2218        .iter()
2219        .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2220    else {
2221        return Err(OpenError::NotFound {
2222            requested: format!("session {id}"),
2223        });
2224    };
2225    open_session(meta, cwd).await.map_err(|e| {
2226        OpenError::Other(match e {
2227            // `open_session` renders as "Could not open the saved session:
2228            // <reason>" via `BuildError`. Carry the bare reason so it is not
2229            // prefixed twice (once here, once by the CLI/TUI).
2230            BuildError::SessionOpen(reason) => reason,
2231            other => other.to_string(),
2232        })
2233    })
2234}
2235
2236/// Publish the active session's id to plugins, on both channels that carry it.
2237///
2238/// Called on **every** activation of a session — the startup build AND every
2239/// in-process swap (`switch_to_session`, `fork_session`, and the plugin
2240/// `NewSession`/`Fork`/`SwitchSession` runtime actions all route through
2241/// [`set_active_session`]). Two things must stay in step:
2242///
2243/// - `ToolCallContext` — read per plugin **tool** call, which is why a stale
2244///   value there made a plugin's `todo`-style state follow the wrong session.
2245/// - `RPI_SESSION_ID` — read per **turn** by the provider hooks, which is the
2246///   only channel an observer gets for a pure-chat run. Process-global, which is
2247///   exactly the scope of "the session this process is serving".
2248///
2249/// The write overwrites unconditionally, deliberately. An inherited value is not
2250/// an override: `rpi` spawning `rpi` would otherwise hand the child the parent's
2251/// session id, and the child — which is *serving its own* session — would report
2252/// it forever. (Children no longer inherit the variable at all; see
2253/// `rpi_tools::HOST_PRIVATE_ENV`. This overwrite is the second line of defence,
2254/// for any other way a value can arrive pre-set.)
2255///
2256/// An embedder that genuinely wants to pin the id can revert to the old
2257/// behaviour by setting the variable *after* this runs; there is no supported
2258/// way to pin it before, because a session that does not match its own id is
2259/// never what the caller wants.
2260fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2261    std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2262    tool_context.set_session_id(id);
2263}
2264
2265/// Swap the session the harness serves, republishing the identity to plugins.
2266///
2267/// The single funnel for an in-process session change. Prefer this over calling
2268/// `AgentHarness::set_session` directly: the harness holds no reference to the
2269/// plugin channels, so a bare `set_session` would leave every plugin reporting
2270/// the session it had before the swap.
2271///
2272/// `tool_context` is optional because not every caller has one (the harness's own
2273/// tests, a headless embedder); the env channel is always published, since the
2274/// provider hooks depend on it.
2275pub(crate) async fn set_active_session(
2276    harness: &AgentHarness,
2277    session: Session,
2278    tool_context: Option<&rpi_extensions::ToolCallContext>,
2279) -> Result<(), String> {
2280    if let Ok(metadata) = session.get_metadata().await {
2281        std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2282        if let Some(context) = tool_context {
2283            context.set_session_id(metadata.id);
2284        }
2285    }
2286    harness
2287        .set_session(session)
2288        .await
2289        .map_err(|e| e.to_string())
2290}
2291
2292/// Fork the harness's current session into a new JSONL session (new id, parent
2293/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
2294/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
2295/// and the plugin `runtime_action(Fork)` host share one implementation.
2296/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
2297/// does `harness.set_session(...)`).
2298pub(crate) async fn fork_session_storage(
2299    harness: &AgentHarness,
2300    cwd: &str,
2301) -> Result<Session, String> {
2302    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2303    use rpi_tools::FileSystem;
2304
2305    let dir = default_session_dir(Path::new(cwd));
2306    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2307    let fs: Arc<dyn FileSystem> = env.clone();
2308    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2309        fs,
2310        sessions_root: dir.to_string_lossy().into_owned(),
2311        clock: Arc::new(SystemClock),
2312        ids: Arc::new(DefaultIdGenerator::new()),
2313    });
2314    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2315    // it from the session list by the current session's id.
2316    let id = harness.session().storage().metadata().id.clone();
2317    let metas = list_session_metadata(cwd)
2318        .await
2319        .map_err(|e| e.to_string())?;
2320    let Some(source) = metas.iter().find(|m| m.id == id) else {
2321        return Err(format!("current session {id} not found on disk"));
2322    };
2323    let fork_storage = repo
2324        .fork_typed(
2325            source,
2326            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2327                id: None,
2328                parent_session_id: Some(source.id.clone()),
2329                cwd: cwd.to_string(),
2330                metadata: None,
2331            },
2332            &rpi_harness::session::types::ForkOptions::default(),
2333        )
2334        .await
2335        .map_err(|e| e.to_string())?;
2336    claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2337    Ok(Session::new(Arc::new(fork_storage), None))
2338}
2339
2340/// Session logs this process has claimed, so re-claiming is a no-op.
2341static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2342/// The held lock handles. Kept alive for the process lifetime — dropping the
2343/// `File` would release the OS lock, so the guard is parked here.
2344static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2345
2346/// Claim the exclusive lock for a session log.
2347///
2348/// Two rpi processes appending to one JSONL diverge their `sequence` counters
2349/// (duplicate / gapped seqs) and corrupt it. The lock is an OS advisory lock on
2350/// `<path>.lock`: the second process gets a clear "already open" error instead
2351/// of corrupting the log, and the OS releases it automatically when the holder
2352/// exits (no stale-lock cleanup). Re-claiming a path this process already holds
2353/// is a no-op, so the TUI's session hot-switch can re-open freely.
2354fn claim_session_lock(session_path: &str) -> Result<(), String> {
2355    let lock_path = PathBuf::from(format!("{session_path}.lock"));
2356    if SESSION_LOCK_PATHS
2357        .get_or_init(|| Mutex::new(HashSet::new()))
2358        .lock()
2359        .unwrap()
2360        .contains(&lock_path)
2361    {
2362        return Ok(());
2363    }
2364    let file = std::fs::OpenOptions::new()
2365        .create(true)
2366        .write(true)
2367        .open(&lock_path)
2368        .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2369    if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2370        return Err(format!(
2371            "session {session_path} is already open in another rpi process"
2372        ));
2373    }
2374    SESSION_LOCKS
2375        .get_or_init(|| Mutex::new(Vec::new()))
2376        .lock()
2377        .unwrap()
2378        .push(file);
2379    SESSION_LOCK_PATHS
2380        .get_or_init(|| Mutex::new(HashSet::new()))
2381        .lock()
2382        .unwrap()
2383        .insert(lock_path);
2384    Ok(())
2385}
2386
2387/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
2388/// startup restore + TUI hot-switch).
2389async fn open_session(
2390    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2391    cwd: &str,
2392) -> Result<Session, BuildError> {
2393    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2394    use rpi_harness::session::types::SessionStorage;
2395    use rpi_tools::FileSystem;
2396
2397    let dir = default_session_dir(Path::new(cwd));
2398    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2399    let fs: Arc<dyn FileSystem> = env.clone();
2400    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2401        fs: fs.clone(),
2402        sessions_root: dir.to_string_lossy().into_owned(),
2403        clock: Arc::new(SystemClock),
2404        ids: Arc::new(DefaultIdGenerator::new()),
2405    });
2406    claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2407    let storage = repo
2408        .open_by_jsonl_metadata(meta)
2409        .await
2410        .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2411    // A session records the cwd it was created in. When that directory has
2412    // since been removed, continuing silently in it is confusing; warn the
2413    // user that we fell back to the process cwd (pi `getMissingSessionCwdIssue`).
2414    if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2415        Some(Path::new(&meta.path)),
2416        Path::new(&meta.cwd),
2417        Path::new(cwd),
2418    ) {
2419        eprintln!(
2420            "warning: {}",
2421            crate::session_cwd::format_missing_session_cwd_error(&issue)
2422        );
2423    }
2424    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2425    Ok(Session::new(storage_arc, None))
2426}
2427
2428/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
2429fn ephemeral_session() -> Session {
2430    let storage = Arc::new(InMemorySessionStorage::new(
2431        SessionMetadata {
2432            id: "ephemeral".into(),
2433            created_at: 0,
2434            parent_session_id: None,
2435        },
2436        Arc::new(SystemClock),
2437        Arc::new(DefaultIdGenerator::new()),
2438    ));
2439    Session::new(storage, None)
2440}
2441
2442/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2443///
2444/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2445/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2446/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2447/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2448///
2449/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2450/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2451/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2452pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2453    create_jsonl_session_with_id(dir, cwd, None).await
2454}
2455
2456/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
2457/// contract: the file is named with the given id so later `--session-id`
2458/// launches restore the same session).
2459pub(crate) async fn create_jsonl_session_with_id(
2460    dir: &Path,
2461    cwd: &str,
2462    id: Option<String>,
2463) -> Result<Session, String> {
2464    use rpi_harness::session::jsonl::{
2465        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2466    };
2467    use rpi_tools::FileSystem;
2468
2469    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
2470    // relative-path resolution matches the tool env.
2471    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2472    let fs: Arc<dyn FileSystem> = env.clone();
2473
2474    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2475        fs: fs.clone(),
2476        sessions_root: dir.to_string_lossy().into_owned(),
2477        clock: Arc::new(SystemClock),
2478        ids: Arc::new(DefaultIdGenerator::new()),
2479    });
2480
2481    let opts = JsonlSessionCreateOptions {
2482        id, // fresh uuidv7 when None (--session-id passes the fixed id)
2483        parent_session_id: None,
2484        cwd: cwd.to_string(),
2485        metadata: None,
2486    };
2487    let storage = repo
2488        .create_typed(&opts)
2489        .await
2490        .map_err(|e| format!("create session: {e}"))?;
2491    claim_session_lock(&storage.jsonl_metadata().path)
2492        .map_err(|e| format!("create session: {e}"))?;
2493    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
2494    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2495    Ok(Session::new(storage_arc, None))
2496}
2497
2498/// Read an `--append-system-prompt` target: if it's a readable file path, return
2499/// its contents; otherwise return `None` and let the caller use the literal.
2500fn read_append_target(target: &str) -> Option<String> {
2501    let path = Path::new(target);
2502    if path.is_file() {
2503        std::fs::read_to_string(path).ok()
2504    } else {
2505        None
2506    }
2507}
2508
2509#[cfg(test)]
2510mod tests {
2511    use super::*;
2512    use crate::args::Args;
2513
2514    /// A unique scratch file for one summary test.
2515    fn scratch(name: &str) -> PathBuf {
2516        let nanos = std::time::SystemTime::now()
2517            .duration_since(std::time::UNIX_EPOCH)
2518            .unwrap()
2519            .as_nanos();
2520        let path = std::env::temp_dir().join(format!(
2521            "rpi-summary-{}-{nanos}-{name}.jsonl",
2522            std::process::id()
2523        ));
2524        path
2525    }
2526
2527    #[test]
2528    fn summary_marks_a_header_only_session_empty() {
2529        let path = scratch("empty");
2530        std::fs::write(
2531            &path,
2532            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2533        )
2534        .unwrap();
2535        let summary = summarize_session_file(&path);
2536        assert!(summary.empty, "a header-only file has nothing to resume");
2537        assert!(summary.preview.is_none());
2538        assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2539        std::fs::remove_file(&path).ok();
2540    }
2541
2542    #[test]
2543    fn summary_reads_the_first_user_prompt_and_name() {
2544        let path = scratch("content");
2545        let body = concat!(
2546            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2547            "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2548            "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2549            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2550            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2551        );
2552        std::fs::write(&path, body).unwrap();
2553        let summary = summarize_session_file(&path);
2554        assert!(!summary.empty);
2555        assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2556        assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2557        std::fs::remove_file(&path).ok();
2558    }
2559
2560    #[test]
2561    fn summary_extracts_a_block_style_user_prompt() {
2562        let path = scratch("blocks");
2563        let body = concat!(
2564            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2565            "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2566        );
2567        std::fs::write(&path, body).unwrap();
2568        let summary = summarize_session_file(&path);
2569        assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2570        assert_eq!(session_display_label(&summary), "line one line two");
2571        std::fs::remove_file(&path).ok();
2572    }
2573
2574    fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2575        rpi_harness::session::jsonl::JsonlSessionMetadata {
2576            id: id.to_string(),
2577            created_at: 0,
2578            cwd: ".".to_string(),
2579            path: path.to_string_lossy().into_owned(),
2580            modified_at: 0,
2581            source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2582            parent_session_id: None,
2583            legacy_parent_session_path: None,
2584            metadata: None,
2585        }
2586    }
2587
2588    #[test]
2589    fn continue_skips_newer_empty_sessions() {
2590        let empty = scratch("newest-empty");
2591        std::fs::write(
2592            &empty,
2593            "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2594        )
2595        .unwrap();
2596        let content = scratch("older-content");
2597        std::fs::write(
2598            &content,
2599            concat!(
2600                "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2601                "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2602            ),
2603        )
2604        .unwrap();
2605
2606        let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2607        let picked = first_resumable_session(&metas).expect("a resumable session");
2608        assert_eq!(picked.id, "old");
2609
2610        // When every session is empty, fall back to the newest rather than
2611        // erroring.
2612        let all_empty = vec![meta_for(&empty, "empty")];
2613        assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2614
2615        std::fs::remove_file(&empty).ok();
2616        std::fs::remove_file(&content).ok();
2617    }
2618
2619    #[test]
2620    fn list_session_files_sync_walks_the_cwd_subdirectory() {
2621        let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2622        let nested = root.join("--proj--");
2623        std::fs::create_dir_all(&nested).unwrap();
2624        let file = nested.join("2026-01-01_a.jsonl");
2625        std::fs::write(&file, "{}\n").unwrap();
2626        let found = list_session_files_sync(&root);
2627        assert!(found.iter().any(|p| p == &file));
2628        std::fs::remove_dir_all(&root).ok();
2629    }
2630
2631    #[test]
2632    fn default_prompt_mentions_cwd_and_tools() {
2633        let p = default_system_prompt("/tmp/proj", false);
2634        assert!(p.contains("/tmp/proj"));
2635        assert!(p.contains("read"));
2636        assert!(p.contains("bash"));
2637        assert!(p.contains("edit"));
2638        assert!(p.contains("write"));
2639        assert!(p.contains("docs"));
2640        assert!(!p.contains("- grep"));
2641        assert!(!p.contains("- find"));
2642        assert!(!p.contains("- ls"));
2643        assert!(!p.contains("powershell"));
2644    }
2645
2646    #[test]
2647    fn default_prompt_instructs_language_matching() {
2648        // The model must detect the user's language and answer in it, so a
2649        // Chinese (or any non-English) user gets a same-language reply without
2650        // having to ask every turn.
2651        let p = default_system_prompt("/tmp/proj", false);
2652        let lower = p.to_lowercase();
2653        assert!(
2654            lower.contains("same language") && lower.contains("detect"),
2655            "the prompt must tell the model to detect and mirror the user's language: {p}"
2656        );
2657    }
2658
2659    #[test]
2660    fn default_prompt_keeps_working_state_in_the_visible_channel() {
2661        // Regression guard for the re-plan loop: on openai-compatible providers
2662        // the assistant's `thinking` blocks are dropped when the next request is
2663        // built (`providers/openai_completions.rs`: `Content::text_only`), so a
2664        // plan that lives only in reasoning is re-derived every turn. One
2665        // observed session paid for that 112 times in a single run, and twice
2666        // hallucinated the user's input ("The user is greeting me with hello",
2667        // with no such message). See `docs/llm-repetition-forensics.md` §八.
2668        let p = default_system_prompt("/tmp/proj", false);
2669        let lower = p.to_lowercase();
2670        // The rule must state the mechanism, not just "be clear".
2671        assert!(
2672            lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2673            "the prompt must explain that reasoning does not survive the turn: {p}"
2674        );
2675        // …and the remedy: keep state visible, use the todo tool.
2676        assert!(
2677            lower.contains("visible") && lower.contains("todo tool"),
2678            "the prompt must point at visible text and the todo tool: {p}"
2679        );
2680        // …plus the anti-repetition rule itself.
2681        assert!(
2682            lower.contains("re-stating the same plan"),
2683            "the prompt must call out plan re-statement as a bug: {p}"
2684        );
2685        // And the guidance must not instruct re-reading what is already shown.
2686        assert!(
2687            lower.contains("already shown earlier"),
2688            "the prompt must discourage re-fetching known content: {p}"
2689        );
2690    }
2691
2692    #[test]
2693    fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2694        // `compat.requiresThinkingAsText` (and the native thinking channels of
2695        // anthropic-messages / openai-responses) DO carry the model's reasoning
2696        // back on the next request. Telling such a model "reasoning is not
2697        // carried into your next turn" is false, and the instruction to restate
2698        // the plan every turn is exactly the busy-work the re-plan analysis
2699        // measured. The wording must flip with the mechanism.
2700        let p = default_system_prompt("/tmp/proj", true);
2701        let lower = p.to_lowercase();
2702        assert!(
2703            lower.contains("carried back"),
2704            "a replaying model must be told its reasoning survives: {p}"
2705        );
2706        assert!(
2707            !lower.contains("reasoning is not"),
2708            "the replayed variant must not claim reasoning is dropped: {p}"
2709        );
2710        assert!(
2711            lower.contains("do not re-derive"),
2712            "the replayed variant must forbid re-deriving the plan: {p}"
2713        );
2714        assert!(
2715            lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2716            "the anti-repetition rule must survive the flip: {p}"
2717        );
2718        assert!(
2719            lower.contains("already shown earlier"),
2720            "the re-read rule must survive the flip: {p}"
2721        );
2722    }
2723
2724    /// Only side-effect-free tools may be replayed after a crash.
2725    ///
2726    /// Regression guard for an inverted default: `ToolReplay` used to default to
2727    /// `Safe` and the CLI then marked *every* tool `Safe`, so the first time
2728    /// recovery acted on the flag it would have re-run `bash`/`write`/`edit` and
2729    /// duplicated their side effects. upstream defaults to `"never"`
2730    /// (`drive/tools.ts: tool.replay ?? "never"`).
2731    #[test]
2732    fn only_read_only_tools_are_replayable() {
2733        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2734        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2735        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2736        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2737        let tools = build_tools(&context, &Args::default());
2738
2739        let replay_of = |name: &str| {
2740            tools
2741                .iter()
2742                .find(|tool| tool.tool.schema().name == name)
2743                .map(|tool| tool.replay)
2744        };
2745
2746        for read_only in ["read", "docs"] {
2747            assert_eq!(
2748                replay_of(read_only),
2749                Some(ToolReplay::Safe),
2750                "{read_only} is side-effect free, so recovery may re-run it"
2751            );
2752        }
2753        for mutating in ["bash", "edit", "write", "powershell"] {
2754            if let Some(replay) = replay_of(mutating) {
2755                assert_eq!(
2756                    replay,
2757                    ToolReplay::Never,
2758                    "{mutating} must never be replayed: re-running it would duplicate its effect"
2759                );
2760            }
2761        }
2762    }
2763
2764    #[test]
2765    fn default_tools_include_docs_lookup() {
2766        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2767        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2768        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2769        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2770        let names: Vec<String> = build_tools(&context, &Args::default())
2771            .iter()
2772            .map(|tool| tool.tool.schema().name.clone())
2773            .collect();
2774
2775        // `docs` is a default capability; the legacy rpi-only lookup tools stay
2776        // unregistered. Asserted by membership (not exact equality) because
2777        // `powershell` is Windows-only and `settings.json#defaultTools` may
2778        // narrow the set in a developer's environment.
2779        assert!(names.contains(&"docs".to_string()));
2780        for legacy in ["grep", "find", "ls"] {
2781            assert!(
2782                !names.contains(&legacy.to_string()),
2783                "legacy tool {legacy} should not be registered by default"
2784            );
2785        }
2786    }
2787
2788    #[test]
2789    fn reload_settings_preflight_respects_trust() {
2790        struct RestoreConfigDir(Option<std::ffi::OsString>);
2791
2792        impl Drop for RestoreConfigDir {
2793            fn drop(&mut self) {
2794                match self.0.take() {
2795                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2796                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2797                }
2798            }
2799        }
2800
2801        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2802        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2803        let tmp = tempfile::tempdir().unwrap();
2804        let agent = tmp.path().join("agent");
2805        let cwd = tmp.path().join("project");
2806        std::fs::create_dir_all(&agent).unwrap();
2807        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2808        std::fs::write(agent.join("settings.json"), "{}").unwrap();
2809        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2810        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2811        let args = Args::default();
2812
2813        let trusted = validate_settings_for_reload(&args, &cwd, true);
2814        let untrusted = validate_settings_for_reload(&args, &cwd, false);
2815        assert!(trusted
2816            .unwrap_err()
2817            .contains("could not load project settings"));
2818        assert!(untrusted.is_ok());
2819
2820        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2821        assert!(validate_settings_for_reload(&args, &cwd, false)
2822            .unwrap_err()
2823            .contains("could not load global settings"));
2824
2825        let local_only = Args {
2826            dev_local_only: true,
2827            ..Args::default()
2828        };
2829        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2830    }
2831
2832    #[tokio::test(flavor = "current_thread")]
2833    async fn reload_preserves_live_resources_when_settings_break() {
2834        struct RestoreConfigDir(Option<std::ffi::OsString>);
2835
2836        impl Drop for RestoreConfigDir {
2837            fn drop(&mut self) {
2838                match self.0.take() {
2839                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2840                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2841                }
2842            }
2843        }
2844
2845        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2846        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2847        let tmp = tempfile::tempdir().unwrap();
2848        let agent = tmp.path().join("agent");
2849        let cwd = tmp.path().join("project");
2850        let skill_dir = tmp.path().join("configured-skills");
2851        std::fs::create_dir_all(&agent).unwrap();
2852        std::fs::create_dir_all(&cwd).unwrap();
2853        std::fs::create_dir_all(&skill_dir).unwrap();
2854        std::fs::write(
2855            skill_dir.join("SKILL.md"),
2856            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2857        )
2858        .unwrap();
2859        std::fs::write(
2860            agent.join("settings.json"),
2861            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2862        )
2863        .unwrap();
2864        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2865
2866        let resolved = crate::provider::resolve(
2867            Some("anthropic"),
2868            Some(crate::provider::DEFAULT_MODEL_ID),
2869            None,
2870            Some("test-key"),
2871            None,
2872        )
2873        .unwrap();
2874        let args = Args {
2875            trust_override: Some(false),
2876            no_session: true,
2877            no_extensions: true,
2878            no_prompt_templates: true,
2879            no_context_files: true,
2880            system_prompt: Some("stable system prompt".into()),
2881            ..Args::default()
2882        };
2883        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2884        let before_resources = harness.get_resources().await.unwrap();
2885        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2886        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2887        let before_prompt = harness.get_system_prompt().await.unwrap();
2888        let before_tools: Vec<String> = harness
2889            .get_tools()
2890            .await
2891            .unwrap()
2892            .iter()
2893            .map(|tool| tool.tool.schema().name.clone())
2894            .collect();
2895        let before_bridge = context
2896            .action_bridge
2897            .lock()
2898            .unwrap()
2899            .as_ref()
2900            .unwrap()
2901            .clone();
2902
2903        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2904        let outcome = reload_extension_resources(&harness, &context).await;
2905
2906        assert!(outcome.had_warnings);
2907        assert!(outcome.summary.contains("Settings reload failed"));
2908        assert!(outcome.summary.contains("could not load global settings"));
2909        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2910        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2911        let after_tools: Vec<String> = harness
2912            .get_tools()
2913            .await
2914            .unwrap()
2915            .iter()
2916            .map(|tool| tool.tool.schema().name.clone())
2917            .collect();
2918        assert_eq!(after_tools, before_tools);
2919        let after_bridge = context
2920            .action_bridge
2921            .lock()
2922            .unwrap()
2923            .as_ref()
2924            .unwrap()
2925            .clone();
2926        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2927    }
2928
2929    #[test]
2930    fn reload_preparation_rejects_settings_changed_during_derivation() {
2931        struct RestoreConfigDir(Option<std::ffi::OsString>);
2932
2933        impl Drop for RestoreConfigDir {
2934            fn drop(&mut self) {
2935                match self.0.take() {
2936                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2937                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2938                }
2939            }
2940        }
2941
2942        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2943        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2944        let tmp = tempfile::tempdir().unwrap();
2945        let agent = tmp.path().join("agent");
2946        let cwd = tmp.path().join("project");
2947        let first_skill_dir = tmp.path().join("first-skills");
2948        let second_skill_dir = tmp.path().join("second-skills");
2949        std::fs::create_dir_all(&agent).unwrap();
2950        std::fs::create_dir_all(&cwd).unwrap();
2951        std::fs::write(
2952            agent.join("settings.json"),
2953            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2954        )
2955        .unwrap();
2956        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2957        let args = Args {
2958            trust_override: Some(false),
2959            no_extensions: true,
2960            no_prompt_templates: true,
2961            ..Args::default()
2962        };
2963
2964        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2965            std::fs::write(
2966                agent.join("settings.json"),
2967                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2968            )
2969            .unwrap();
2970        });
2971
2972        let error = result
2973            .err()
2974            .expect("settings mutation must fail preparation");
2975        assert!(error.contains("settings changed while reload inputs were being prepared"));
2976    }
2977
2978    #[tokio::test(flavor = "current_thread")]
2979    async fn reload_uses_frozen_settings_inputs_after_preparation() {
2980        struct RestoreConfigDir(Option<std::ffi::OsString>);
2981
2982        impl Drop for RestoreConfigDir {
2983            fn drop(&mut self) {
2984                match self.0.take() {
2985                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2986                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2987                }
2988            }
2989        }
2990
2991        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2992        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2993        let tmp = tempfile::tempdir().unwrap();
2994        let agent = tmp.path().join("agent");
2995        let cwd = tmp.path().join("project");
2996        let skill_dir = tmp.path().join("configured-skills");
2997        std::fs::create_dir_all(&agent).unwrap();
2998        std::fs::create_dir_all(&cwd).unwrap();
2999        std::fs::create_dir_all(&skill_dir).unwrap();
3000        std::fs::write(
3001            skill_dir.join("SKILL.md"),
3002            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3003        )
3004        .unwrap();
3005        std::fs::write(
3006            agent.join("settings.json"),
3007            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3008        )
3009        .unwrap();
3010        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3011
3012        let resolved = crate::provider::resolve(
3013            Some("anthropic"),
3014            Some(crate::provider::DEFAULT_MODEL_ID),
3015            None,
3016            Some("test-key"),
3017            None,
3018        )
3019        .unwrap();
3020        let args = Args {
3021            trust_override: Some(false),
3022            no_session: true,
3023            no_extensions: true,
3024            no_prompt_templates: true,
3025            no_context_files: true,
3026            system_prompt: Some("stable system prompt".into()),
3027            ..Args::default()
3028        };
3029        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3030
3031        let outcome = reload_extension_resources_inner(&harness, &context, || {
3032            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3033        })
3034        .await;
3035
3036        assert!(!outcome.summary.contains("Settings reload failed"));
3037        assert_eq!(
3038            outcome.summary,
3039            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3040        );
3041        let resources = harness.get_resources().await.unwrap();
3042        let skills = resources.skills.as_ref().unwrap();
3043        assert_eq!(skills.len(), 1);
3044        assert_eq!(skills[0].name, "frozen-skill");
3045    }
3046
3047    #[tokio::test]
3048    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3049        let tmp = tempfile::tempdir().unwrap();
3050        let skill_path = tmp.path().join("explicit-skill.md");
3051        std::fs::write(
3052            &skill_path,
3053            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3054        )
3055        .unwrap();
3056        let prompt_path = tmp.path().join("explicit-prompt.md");
3057        std::fs::write(
3058            &prompt_path,
3059            "---\ndescription: Explicit prompt\n---\nPrompt body",
3060        )
3061        .unwrap();
3062
3063        let args = Args {
3064            skill: vec![skill_path.clone()],
3065            prompt_template: vec![prompt_path.clone()],
3066            ..Args::default()
3067        };
3068        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3069        let prompt_paths =
3070            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3071        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3072        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3073
3074        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3075        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3076        assert_eq!(skills.skills[0].name, "explicit-skill");
3077
3078        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3079        assert_eq!(
3080            prompts.prompt_templates.len(),
3081            1,
3082            "{:?}",
3083            prompts.diagnostics
3084        );
3085        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3086    }
3087
3088    #[test]
3089    fn tool_policy_applies_to_rust_and_js_active_names() {
3090        let names = vec![
3091            "read".to_string(),
3092            "ask_user_question".to_string(),
3093            "write".to_string(),
3094        ];
3095
3096        let args = Args {
3097            tools: Some(vec!["read".into(), "ask_user_question".into()]),
3098            ..Args::default()
3099        };
3100        assert_eq!(
3101            filter_active_tool_names(names.clone(), &args),
3102            vec!["read", "ask_user_question"]
3103        );
3104
3105        let args = Args {
3106            exclude_tools: Some(vec!["ask_user_question".into()]),
3107            ..Args::default()
3108        };
3109        assert_eq!(
3110            filter_active_tool_names(names.clone(), &args),
3111            vec!["read", "write"]
3112        );
3113
3114        let args = Args {
3115            no_tools: true,
3116            ..Args::default()
3117        };
3118        assert!(filter_active_tool_names(names, &args).is_empty());
3119    }
3120
3121    #[test]
3122    fn select_ephemeral_when_no_session() {
3123        let args = Args {
3124            no_session: true,
3125            ..Args::default()
3126        };
3127        let cwd = Path::new("/tmp");
3128        assert!(matches!(
3129            select_session(&args, cwd),
3130            SessionSelection::Ephemeral
3131        ));
3132    }
3133
3134    #[test]
3135    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3136        let defaults = Args::default();
3137        assert!(resolve_project_trust(
3138            &defaults,
3139            Path::new("C:/definitely-not-a-project")
3140        ));
3141
3142        let denied = Args {
3143            trust_override: Some(false),
3144            ..Args::default()
3145        };
3146        assert!(!resolve_project_trust(
3147            &denied,
3148            Path::new("C:/definitely-not-a-project")
3149        ));
3150    }
3151
3152    #[tokio::test(flavor = "current_thread")]
3153    async fn build_preserves_the_supplied_startup_project_snapshot() {
3154        struct RestoreConfigDir(Option<std::ffi::OsString>);
3155
3156        impl Drop for RestoreConfigDir {
3157            fn drop(&mut self) {
3158                match self.0.take() {
3159                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3160                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3161                }
3162            }
3163        }
3164
3165        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3166        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3167        let tmp = tempfile::tempdir().unwrap();
3168        let agent = tmp.path().join("agent");
3169        let cwd = tmp.path().join("project");
3170        std::fs::create_dir_all(&agent).unwrap();
3171        std::fs::create_dir_all(&cwd).unwrap();
3172        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3173
3174        let resolved = crate::provider::resolve(
3175            Some("anthropic"),
3176            Some(crate::provider::DEFAULT_MODEL_ID),
3177            None,
3178            Some("test-key"),
3179            None,
3180        )
3181        .unwrap();
3182        let args = Args {
3183            trust_override: Some(false),
3184            no_session: true,
3185            no_tools: true,
3186            no_extensions: true,
3187            no_skills: true,
3188            no_prompt_templates: true,
3189            no_context_files: true,
3190            system_prompt: Some("test prompt".into()),
3191            ..Args::default()
3192        };
3193
3194        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3195
3196        assert_eq!(context.cwd, cwd);
3197        assert!(context.project_trusted);
3198    }
3199
3200    #[tokio::test(flavor = "current_thread")]
3201    async fn build_applies_the_name_flag_to_the_session() {
3202        struct RestoreConfigDir(Option<std::ffi::OsString>);
3203
3204        impl Drop for RestoreConfigDir {
3205            fn drop(&mut self) {
3206                match self.0.take() {
3207                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3208                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3209                }
3210            }
3211        }
3212
3213        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3214        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3215        let tmp = tempfile::tempdir().unwrap();
3216        let agent = tmp.path().join("agent");
3217        let cwd = tmp.path().join("project");
3218        std::fs::create_dir_all(&agent).unwrap();
3219        std::fs::create_dir_all(&cwd).unwrap();
3220        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3221
3222        let resolved = crate::provider::resolve(
3223            Some("anthropic"),
3224            Some(crate::provider::DEFAULT_MODEL_ID),
3225            None,
3226            Some("test-key"),
3227            None,
3228        )
3229        .unwrap();
3230        let args = Args {
3231            trust_override: Some(false),
3232            no_tools: true,
3233            no_extensions: true,
3234            no_skills: true,
3235            no_prompt_templates: true,
3236            no_context_files: true,
3237            system_prompt: Some("test prompt".into()),
3238            name: Some("smoke name".into()),
3239            session_dir: Some(cwd.join("sessions")),
3240            ..Args::default()
3241        };
3242
3243        let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3244        assert_eq!(
3245            harness.get_name().await.unwrap().as_deref(),
3246            Some("smoke name")
3247        );
3248    }
3249
3250    #[test]
3251    fn select_latest_for_continue_and_resume() {
3252        let args = Args {
3253            continue_session: true,
3254            ..Args::default()
3255        };
3256        let cwd = Path::new("/tmp");
3257        assert!(matches!(
3258            select_session(&args, cwd),
3259            SessionSelection::Latest
3260        ));
3261
3262        let args = Args {
3263            resume: true,
3264            ..Args::default()
3265        };
3266        assert!(matches!(
3267            select_session(&args, cwd),
3268            SessionSelection::Latest
3269        ));
3270    }
3271
3272    #[test]
3273    fn select_by_id_for_session_flag() {
3274        let args = Args {
3275            session: Some("01a02ece".into()),
3276            ..Args::default()
3277        };
3278        let cwd = Path::new("/tmp");
3279        assert!(matches!(
3280            select_session(&args, cwd),
3281            SessionSelection::ById { id } if id == "01a02ece"
3282        ));
3283    }
3284
3285    #[test]
3286    fn select_new_with_custom_dir() {
3287        let args = Args {
3288            session_dir: Some(PathBuf::from("/tmp/sess")),
3289            ..Args::default()
3290        };
3291        let cwd = Path::new("/tmp");
3292        match select_session(&args, cwd) {
3293            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3294            other => panic!("expected New, got {other:?}"),
3295        }
3296    }
3297
3298    #[test]
3299    fn select_new_default_dir() {
3300        let args = Args::default();
3301        let cwd = Path::new("/proj");
3302        match select_session(&args, cwd) {
3303            SessionSelection::New { dir, .. } => {
3304                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3305            }
3306            other => panic!("expected New, got {other:?}"),
3307        }
3308    }
3309
3310    #[test]
3311    fn default_session_dir_only_uses_rpi() {
3312        let tmp = tempfile::tempdir().unwrap();
3313        let cwd = tmp.path();
3314        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3315        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3316        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3317        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3318    }
3319
3320    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3321    async fn ephemeral_session_builds_roundtrips() {
3322        // Sanity: the ephemeral path produces a usable Session facade (the
3323        // harness build itself needs a provider; tested via the integration
3324        // path in tests/build.rs instead).
3325        let s = ephemeral_session();
3326        let leaf = s.get_leaf_id().await;
3327        assert!(leaf.is_ok());
3328    }
3329
3330    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
3331    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
3332
3333    /// The exclusion policy, checked against the constants rather than copies.
3334    ///
3335    /// `rpi-tools` builds its exclusion from `rpi-plugin-sdk`'s declarations, so
3336    /// within this repo a rename is a compile error — this test is not the link
3337    /// between those two crates any more. What it still guards is the part the
3338    /// compiler cannot see:
3339    ///
3340    /// - **The wire values.** `rpi-langfuse` lives in a different repository and
3341    ///   agrees on the *strings*, not the constant paths. Rewriting a constant's
3342    ///   value would compile here and silently stop matching there, so the exact
3343    ///   values are pinned below. Changing one is a deliberate, breaking change to
3344    ///   a cross-repo contract, and it should fail here first.
3345    /// - **Coverage.** Every name the SDK declares must actually be excluded.
3346    /// - **Narrowness.** The user's own configuration must still reach the shell.
3347    #[test]
3348    fn host_private_env_matches_the_declared_contract() {
3349        // 1. The wire values. Pinned literally on purpose: see the doc comment.
3350        assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3351        assert_eq!(
3352            rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3353            [
3354                "LANGFUSE_PI_PARENT_TRACE_ID",
3355                "LANGFUSE_PI_PARENT_SPAN_ID",
3356                "LANGFUSE_PI_PARENT_SESSION_ID",
3357                "LANGFUSE_PI_PARENT_DEPTH",
3358            ],
3359            "these strings are what rpi-langfuse (a different repo) matches; a \
3360             change here is a breaking cross-repo change, not a refactor"
3361        );
3362
3363        // 2. Coverage — derived from the declarations, so a newly added family
3364        //    member is checked without anyone remembering to extend this test.
3365        for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3366            .iter()
3367            .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3368        {
3369            assert!(
3370                rpi_tools::is_host_private_env(name),
3371                "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3372                rpi_tools::HOST_PRIVATE_ENV
3373            );
3374        }
3375
3376        // 3. Narrowness — these name how the *user* configured the run and are
3377        //    exactly what a command inside the shell should still see.
3378        for visible in [
3379            crate::args::RPI_OFFLINE_ENV,
3380            crate::config::CONFIG_DIR_ENV,
3381            "PATH",
3382        ] {
3383            assert!(
3384                !rpi_tools::is_host_private_env(visible),
3385                "{visible} describes the user's configuration and must still reach the shell"
3386            );
3387        }
3388    }
3389}