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