Skip to main content

rpi_cli/
session.rs

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