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