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