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    if let Ok(metadata) = session.get_metadata().await {
2404        std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2405        if let Some(context) = tool_context {
2406            context.set_session_id(metadata.id);
2407        }
2408    }
2409    harness
2410        .set_session(session)
2411        .await
2412        .map_err(|e| e.to_string())
2413}
2414
2415/// Fork the harness's current session into a new JSONL session (new id, parent
2416/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
2417/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
2418/// and the plugin `runtime_action(Fork)` host share one implementation.
2419/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
2420/// does `harness.set_session(...)`).
2421pub(crate) async fn fork_session_storage(
2422    harness: &AgentHarness,
2423    cwd: &str,
2424) -> Result<Session, String> {
2425    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2426    use rpi_tools::FileSystem;
2427
2428    let dir = default_session_dir(Path::new(cwd));
2429    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2430    let fs: Arc<dyn FileSystem> = env.clone();
2431    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2432        fs,
2433        sessions_root: dir.to_string_lossy().into_owned(),
2434        clock: Arc::new(SystemClock),
2435        ids: Arc::new(DefaultIdGenerator::new()),
2436    });
2437    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2438    // it from the session list by the current session's id.
2439    let id = harness.session().storage().metadata().id.clone();
2440    let metas = list_session_metadata(cwd)
2441        .await
2442        .map_err(|e| e.to_string())?;
2443    let Some(source) = metas.iter().find(|m| m.id == id) else {
2444        return Err(format!("current session {id} not found on disk"));
2445    };
2446    let fork_storage = repo
2447        .fork_typed(
2448            source,
2449            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2450                id: None,
2451                parent_session_id: Some(source.id.clone()),
2452                cwd: cwd.to_string(),
2453                metadata: None,
2454            },
2455            &rpi_harness::session::types::ForkOptions::default(),
2456        )
2457        .await
2458        .map_err(|e| e.to_string())?;
2459    claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2460    Ok(Session::new(Arc::new(fork_storage), None))
2461}
2462
2463/// Session logs this process has claimed, so re-claiming is a no-op.
2464static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2465/// The held lock handles. Kept alive for the process lifetime — dropping the
2466/// `File` would release the OS lock, so the guard is parked here.
2467static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2468
2469/// Claim the exclusive lock for a session log.
2470///
2471/// Two rpi processes appending to one JSONL diverge their `sequence` counters
2472/// (duplicate / gapped seqs) and corrupt it. The lock is an OS advisory lock on
2473/// `<path>.lock`: the second process gets a clear "already open" error instead
2474/// of corrupting the log, and the OS releases it automatically when the holder
2475/// exits (no stale-lock cleanup). Re-claiming a path this process already holds
2476/// is a no-op, so the TUI's session hot-switch can re-open freely.
2477fn claim_session_lock(session_path: &str) -> Result<(), String> {
2478    let lock_path = PathBuf::from(format!("{session_path}.lock"));
2479    if SESSION_LOCK_PATHS
2480        .get_or_init(|| Mutex::new(HashSet::new()))
2481        .lock()
2482        .unwrap()
2483        .contains(&lock_path)
2484    {
2485        return Ok(());
2486    }
2487    let file = std::fs::OpenOptions::new()
2488        .create(true)
2489        .write(true)
2490        .open(&lock_path)
2491        .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2492    if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2493        return Err(format!(
2494            "session {session_path} is already open in another rpi process"
2495        ));
2496    }
2497    SESSION_LOCKS
2498        .get_or_init(|| Mutex::new(Vec::new()))
2499        .lock()
2500        .unwrap()
2501        .push(file);
2502    SESSION_LOCK_PATHS
2503        .get_or_init(|| Mutex::new(HashSet::new()))
2504        .lock()
2505        .unwrap()
2506        .insert(lock_path);
2507    Ok(())
2508}
2509
2510/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
2511/// startup restore + TUI hot-switch).
2512async fn open_session(
2513    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2514    cwd: &str,
2515) -> Result<Session, BuildError> {
2516    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2517    use rpi_harness::session::types::SessionStorage;
2518    use rpi_tools::FileSystem;
2519
2520    let dir = default_session_dir(Path::new(cwd));
2521    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2522    let fs: Arc<dyn FileSystem> = env.clone();
2523    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2524        fs: fs.clone(),
2525        sessions_root: dir.to_string_lossy().into_owned(),
2526        clock: Arc::new(SystemClock),
2527        ids: Arc::new(DefaultIdGenerator::new()),
2528    });
2529    claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2530    let storage = repo
2531        .open_by_jsonl_metadata(meta)
2532        .await
2533        .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2534    // A session records the cwd it was created in. When that directory has
2535    // since been removed, continuing silently in it is confusing; warn the
2536    // user that we fell back to the process cwd (pi `getMissingSessionCwdIssue`).
2537    if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2538        Some(Path::new(&meta.path)),
2539        Path::new(&meta.cwd),
2540        Path::new(cwd),
2541    ) {
2542        eprintln!(
2543            "warning: {}",
2544            crate::session_cwd::format_missing_session_cwd_error(&issue)
2545        );
2546    }
2547    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2548    Ok(Session::new(storage_arc, None))
2549}
2550
2551/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
2552fn ephemeral_session() -> Session {
2553    let storage = Arc::new(InMemorySessionStorage::new(
2554        SessionMetadata {
2555            id: "ephemeral".into(),
2556            created_at: 0,
2557            parent_session_id: None,
2558        },
2559        Arc::new(SystemClock),
2560        Arc::new(DefaultIdGenerator::new()),
2561    ));
2562    Session::new(storage, None)
2563}
2564
2565/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2566///
2567/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2568/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2569/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2570/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2571///
2572/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2573/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2574/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2575pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2576    create_jsonl_session_with_id(dir, cwd, None).await
2577}
2578
2579/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
2580/// contract: the file is named with the given id so later `--session-id`
2581/// launches restore the same session).
2582pub(crate) async fn create_jsonl_session_with_id(
2583    dir: &Path,
2584    cwd: &str,
2585    id: Option<String>,
2586) -> Result<Session, String> {
2587    use rpi_harness::session::jsonl::{
2588        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2589    };
2590    use rpi_tools::FileSystem;
2591
2592    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
2593    // relative-path resolution matches the tool env.
2594    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2595    let fs: Arc<dyn FileSystem> = env.clone();
2596
2597    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2598        fs: fs.clone(),
2599        sessions_root: dir.to_string_lossy().into_owned(),
2600        clock: Arc::new(SystemClock),
2601        ids: Arc::new(DefaultIdGenerator::new()),
2602    });
2603
2604    let opts = JsonlSessionCreateOptions {
2605        id, // fresh uuidv7 when None (--session-id passes the fixed id)
2606        parent_session_id: None,
2607        cwd: cwd.to_string(),
2608        metadata: None,
2609    };
2610    let storage = repo
2611        .create_typed(&opts)
2612        .await
2613        .map_err(|e| format!("create session: {e}"))?;
2614    claim_session_lock(&storage.jsonl_metadata().path)
2615        .map_err(|e| format!("create session: {e}"))?;
2616    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
2617    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2618    Ok(Session::new(storage_arc, None))
2619}
2620
2621/// Read an `--append-system-prompt` target: if it's a readable file path, return
2622/// its contents; otherwise return `None` and let the caller use the literal.
2623fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2624    let path = Path::new(target);
2625    if path.is_file() {
2626        read_prompt_source(path, sources)
2627    } else {
2628        None
2629    }
2630}
2631
2632/// Record provenance at the successful read, rather than rediscovering files
2633/// during rendering (which could report a shadowed or unreadable file).
2634fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2635    let text = std::fs::read_to_string(path).ok()?;
2636    let absolute = if path.is_absolute() {
2637        path.to_path_buf()
2638    } else {
2639        std::env::current_dir()
2640            .map(|cwd| cwd.join(path))
2641            .unwrap_or_else(|_| path.to_path_buf())
2642    };
2643    let source = absolute.to_string_lossy().into_owned();
2644    if !sources.contains(&source) {
2645        sources.push(source);
2646    }
2647    Some(text)
2648}
2649
2650#[cfg(test)]
2651mod tests {
2652    use super::*;
2653    use crate::args::Args;
2654
2655    #[test]
2656    fn loaded_context_paths_track_precedence_and_reload() {
2657        const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
2658        if std::env::var_os(CHILD).is_none() {
2659            let dir = tempfile::tempdir().unwrap();
2660            let output = std::process::Command::new(std::env::current_exe().unwrap())
2661                .args([
2662                    "--exact",
2663                    "session::tests::loaded_context_paths_track_precedence_and_reload",
2664                    "--nocapture",
2665                ])
2666                .env(CHILD, "1")
2667                .env(crate::config::CONFIG_DIR_ENV, dir.path())
2668                .output()
2669                .unwrap();
2670            assert!(
2671                output.status.success(),
2672                "{}\n{}",
2673                String::from_utf8_lossy(&output.stdout),
2674                String::from_utf8_lossy(&output.stderr)
2675            );
2676            return;
2677        }
2678        tokio::runtime::Builder::new_current_thread()
2679            .enable_all()
2680            .build()
2681            .unwrap()
2682            .block_on(async {
2683                let agent = crate::config::agent_dir().unwrap();
2684                let cwd = agent.join("project");
2685                let project_config = cwd.join(".rpi");
2686                std::fs::create_dir_all(&project_config).unwrap();
2687                for (path, content) in [
2688                    (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
2689                    (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
2690                    (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
2691                    (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
2692                    (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
2693                    (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
2694                    (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
2695                ] {
2696                    std::fs::write(path, content).unwrap();
2697                }
2698                let resolved = crate::provider::resolve(
2699                    Some("anthropic"),
2700                    Some(crate::provider::DEFAULT_MODEL_ID),
2701                    None,
2702                    Some("test-key"),
2703                    None,
2704                )
2705                .unwrap();
2706                let args = Args {
2707                    no_session: true,
2708                    no_extensions: true,
2709                    no_skills: true,
2710                    no_prompt_templates: true,
2711                    trust_override: Some(true),
2712                    ..Default::default()
2713                };
2714                let (harness, _events, mut context) =
2715                    build(&resolved, &args, &cwd, true).await.unwrap();
2716                let resources = harness.get_resources().await.unwrap();
2717                let path = |p: PathBuf| p.to_string_lossy().into_owned();
2718                assert_eq!(
2719                    &resources.context_files[..2],
2720                    &[
2721                        path(project_config.join("SYSTEM.md")),
2722                        path(project_config.join("APPEND_SYSTEM.md")),
2723                    ]
2724                );
2725                assert!(resources
2726                    .context_files
2727                    .contains(&path(agent.join("AGENTS.md"))));
2728                assert!(resources
2729                    .context_files
2730                    .contains(&path(cwd.join("AGENTS.md"))));
2731                assert!(!resources
2732                    .context_files
2733                    .contains(&path(cwd.join("CLAUDE.md"))));
2734                assert!(!resources
2735                    .context_files
2736                    .contains(&path(agent.join("SYSTEM.md"))));
2737                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2738                assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
2739                assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
2740
2741                // Removing the selected project files makes reload fall back to
2742                // globals; removed instruction paths must disappear as well.
2743                std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
2744                std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
2745                std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
2746                assert!(
2747                    !reload_extension_resources(&harness, &context)
2748                        .await
2749                        .had_errors
2750                );
2751                let resources = harness.get_resources().await.unwrap();
2752                assert_eq!(
2753                    &resources.context_files[..2],
2754                    &[
2755                        path(agent.join("SYSTEM.md")),
2756                        path(agent.join("APPEND_SYSTEM.md")),
2757                    ]
2758                );
2759                assert!(resources
2760                    .context_files
2761                    .contains(&path(cwd.join("CLAUDE.md"))));
2762                assert!(!resources
2763                    .context_files
2764                    .contains(&path(project_config.join("SYSTEM.md"))));
2765
2766                // Explicit append files are listed; literal text and overridden
2767                // discovered base/append files are not invented as file sources.
2768                let explicit = agent.join("custom-append.md");
2769                std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
2770                context.args.system_prompt = Some("EXPLICIT_BASE".into());
2771                context.args.append_system_prompt =
2772                    vec![path(explicit.clone()), "literal append".into()];
2773                context.args.no_context_files = true;
2774                assert!(
2775                    !reload_extension_resources(&harness, &context)
2776                        .await
2777                        .had_errors
2778                );
2779                assert_eq!(
2780                    harness.get_resources().await.unwrap().context_files,
2781                    vec![path(explicit)]
2782                );
2783
2784                // The reload must honor the startup opt-out from project context.
2785                context.args.system_prompt = None;
2786                context.args.append_system_prompt.clear();
2787                context.args.no_context_files = false;
2788                context.args.trust_override = Some(false);
2789                assert!(
2790                    !reload_extension_resources(&harness, &context)
2791                        .await
2792                        .had_errors
2793                );
2794                assert!(harness
2795                    .get_resources()
2796                    .await
2797                    .unwrap()
2798                    .context_files
2799                    .is_empty());
2800                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
2801                assert!(
2802                    !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
2803                );
2804            });
2805    }
2806
2807    #[test]
2808    fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
2809        let dir = tempfile::tempdir().unwrap();
2810        let file = dir.path().join("SYSTEM.md");
2811        let mut sources = Vec::new();
2812        assert!(read_prompt_source(&file, &mut sources).is_none());
2813        std::fs::write(&file, [0xff, 0xfe]).unwrap();
2814        assert!(read_prompt_source(&file, &mut sources).is_none());
2815        assert!(read_append_target("literal instructions", &mut sources).is_none());
2816        assert!(sources.is_empty());
2817        std::fs::write(&file, "loaded instructions").unwrap();
2818        assert_eq!(
2819            read_prompt_source(&file, &mut sources).as_deref(),
2820            Some("loaded instructions")
2821        );
2822        assert!(read_prompt_source(&file, &mut sources).is_some());
2823        assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
2824    }
2825
2826    extern "C" fn deny_test_tool_call(
2827        _event: rpi_plugin_sdk::StablePluginEvent,
2828        _data: *mut std::ffi::c_void,
2829    ) -> i32 {
2830        rpi_plugin_sdk::EVENT_HANDLER_ABORT
2831    }
2832
2833    async fn invoke_test_tool_hook(
2834        hook: &rpi_agent::BeforeToolCall,
2835    ) -> Option<rpi_agent::BeforeToolCallResult> {
2836        let message = rpi_ai::providers::faux::faux_assistant_message(
2837            "approval",
2838            rpi_ai::types::StopReason::ToolUse,
2839        );
2840        let rpi_ai::types::Content::ToolCall(call) =
2841            rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
2842        else {
2843            unreachable!()
2844        };
2845        hook(
2846            rpi_agent::BeforeToolCallContext {
2847                assistant_message: &message,
2848                tool_call: &call,
2849                args: &call.arguments,
2850                context: &rpi_agent::AgentContext::default(),
2851            },
2852            tokio_util::sync::CancellationToken::new(),
2853        )
2854        .await
2855    }
2856
2857    #[tokio::test]
2858    async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
2859        let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
2860        let hook =
2861            extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
2862        assert!(invoke_test_tool_hook(&hook).await.is_none());
2863        for name in ["first-gate", "reloaded-gate"] {
2864            let mut registry = rpi_extensions::ExtensionRegistry::new();
2865            registry.register_event_handler(
2866                name.into(),
2867                rpi_plugin_sdk::EventTag::ToolCall,
2868                deny_test_tool_call,
2869                std::ptr::null_mut(),
2870            );
2871            let fresh = ExtensionSession::from_parts(
2872                Arc::new(registry.snapshot()),
2873                rpi_extensions::PluginKeepalive::empty(),
2874                Vec::new(),
2875                None,
2876            );
2877            {
2878                let mut current = sessions.lock().unwrap();
2879                if let Some(snapshot) = current.snapshot() {
2880                    snapshot.active_flag().store(false, Ordering::SeqCst);
2881                }
2882                *current = fresh;
2883            }
2884            let result = invoke_test_tool_hook(&hook).await.unwrap();
2885            assert!(result.block);
2886            assert!(result.reason.unwrap().contains(name));
2887        }
2888        *sessions.lock().unwrap() = ExtensionSession::none();
2889        assert!(invoke_test_tool_hook(&hook).await.is_none());
2890    }
2891
2892    /// A unique scratch file for one summary test.
2893    fn scratch(name: &str) -> PathBuf {
2894        let nanos = std::time::SystemTime::now()
2895            .duration_since(std::time::UNIX_EPOCH)
2896            .unwrap()
2897            .as_nanos();
2898        let path = std::env::temp_dir().join(format!(
2899            "rpi-summary-{}-{nanos}-{name}.jsonl",
2900            std::process::id()
2901        ));
2902        path
2903    }
2904
2905    #[test]
2906    fn summary_marks_a_header_only_session_empty() {
2907        let path = scratch("empty");
2908        std::fs::write(
2909            &path,
2910            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2911        )
2912        .unwrap();
2913        let summary = summarize_session_file(&path);
2914        assert!(summary.empty, "a header-only file has nothing to resume");
2915        assert!(summary.preview.is_none());
2916        assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2917        std::fs::remove_file(&path).ok();
2918    }
2919
2920    #[test]
2921    fn summary_reads_the_first_user_prompt_and_name() {
2922        let path = scratch("content");
2923        let body = concat!(
2924            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2925            "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2926            "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2927            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2928            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2929        );
2930        std::fs::write(&path, body).unwrap();
2931        let summary = summarize_session_file(&path);
2932        assert!(!summary.empty);
2933        assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2934        assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2935        std::fs::remove_file(&path).ok();
2936    }
2937
2938    #[test]
2939    fn summary_extracts_a_block_style_user_prompt() {
2940        let path = scratch("blocks");
2941        let body = concat!(
2942            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2943            "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2944        );
2945        std::fs::write(&path, body).unwrap();
2946        let summary = summarize_session_file(&path);
2947        assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2948        assert_eq!(session_display_label(&summary), "line one line two");
2949        std::fs::remove_file(&path).ok();
2950    }
2951
2952    fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2953        rpi_harness::session::jsonl::JsonlSessionMetadata {
2954            id: id.to_string(),
2955            created_at: 0,
2956            cwd: ".".to_string(),
2957            path: path.to_string_lossy().into_owned(),
2958            modified_at: 0,
2959            source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2960            parent_session_id: None,
2961            legacy_parent_session_path: None,
2962            metadata: None,
2963        }
2964    }
2965
2966    #[test]
2967    fn continue_skips_newer_empty_sessions() {
2968        let empty = scratch("newest-empty");
2969        std::fs::write(
2970            &empty,
2971            "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2972        )
2973        .unwrap();
2974        let content = scratch("older-content");
2975        std::fs::write(
2976            &content,
2977            concat!(
2978                "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2979                "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2980            ),
2981        )
2982        .unwrap();
2983
2984        let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2985        let picked = first_resumable_session(&metas).expect("a resumable session");
2986        assert_eq!(picked.id, "old");
2987
2988        // When every session is empty, fall back to the newest rather than
2989        // erroring.
2990        let all_empty = vec![meta_for(&empty, "empty")];
2991        assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2992
2993        std::fs::remove_file(&empty).ok();
2994        std::fs::remove_file(&content).ok();
2995    }
2996
2997    #[test]
2998    fn list_session_files_sync_walks_the_cwd_subdirectory() {
2999        let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3000        let nested = root.join("--proj--");
3001        std::fs::create_dir_all(&nested).unwrap();
3002        let file = nested.join("2026-01-01_a.jsonl");
3003        std::fs::write(&file, "{}\n").unwrap();
3004        let found = list_session_files_sync(&root);
3005        assert!(found.iter().any(|p| p == &file));
3006        std::fs::remove_dir_all(&root).ok();
3007    }
3008
3009    #[test]
3010    fn default_prompt_mentions_cwd_and_tools() {
3011        let p = default_system_prompt("/tmp/proj", false);
3012        assert!(p.contains("/tmp/proj"));
3013        assert!(p.contains("read"));
3014        assert!(p.contains("bash"));
3015        assert!(p.contains("edit"));
3016        assert!(p.contains("write"));
3017        assert!(p.contains("docs"));
3018        assert!(!p.contains("- grep"));
3019        assert!(!p.contains("- find"));
3020        assert!(!p.contains("- ls"));
3021        assert!(!p.contains("- powershell"));
3022        assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3023        assert!(p.contains("do not pass PowerShell cmdlets"));
3024    }
3025
3026    #[test]
3027    fn default_prompt_instructs_language_matching() {
3028        // The model must detect the user's language and answer in it, so a
3029        // Chinese (or any non-English) user gets a same-language reply without
3030        // having to ask every turn.
3031        let p = default_system_prompt("/tmp/proj", false);
3032        let lower = p.to_lowercase();
3033        assert!(
3034            lower.contains("same language") && lower.contains("detect"),
3035            "the prompt must tell the model to detect and mirror the user's language: {p}"
3036        );
3037    }
3038
3039    #[test]
3040    fn default_prompt_keeps_working_state_in_the_visible_channel() {
3041        // Regression guard for the re-plan loop: on openai-compatible providers
3042        // the assistant's `thinking` blocks are dropped when the next request is
3043        // built (`providers/openai_completions.rs`: `Content::text_only`), so a
3044        // plan that lives only in reasoning is re-derived every turn. One
3045        // observed session paid for that 112 times in a single run, and twice
3046        // hallucinated the user's input ("The user is greeting me with hello",
3047        // with no such message). See `docs/llm-repetition-forensics.md` §八.
3048        let p = default_system_prompt("/tmp/proj", false);
3049        let lower = p.to_lowercase();
3050        // The rule must state the mechanism, not just "be clear".
3051        assert!(
3052            lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3053            "the prompt must explain that reasoning does not survive the turn: {p}"
3054        );
3055        // …and the remedy: keep state visible, use the todo tool.
3056        assert!(
3057            lower.contains("visible") && lower.contains("todo tool"),
3058            "the prompt must point at visible text and the todo tool: {p}"
3059        );
3060        // …plus the anti-repetition rule itself.
3061        assert!(
3062            lower.contains("re-stating the same plan"),
3063            "the prompt must call out plan re-statement as a bug: {p}"
3064        );
3065        // And the guidance must not instruct re-reading what is already shown.
3066        assert!(
3067            lower.contains("already shown earlier"),
3068            "the prompt must discourage re-fetching known content: {p}"
3069        );
3070    }
3071
3072    #[test]
3073    fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3074        // `compat.requiresThinkingAsText` (and the native thinking channels of
3075        // anthropic-messages / openai-responses) DO carry the model's reasoning
3076        // back on the next request. Telling such a model "reasoning is not
3077        // carried into your next turn" is false, and the instruction to restate
3078        // the plan every turn is exactly the busy-work the re-plan analysis
3079        // measured. The wording must flip with the mechanism.
3080        let p = default_system_prompt("/tmp/proj", true);
3081        let lower = p.to_lowercase();
3082        assert!(
3083            lower.contains("carried back"),
3084            "a replaying model must be told its reasoning survives: {p}"
3085        );
3086        assert!(
3087            !lower.contains("reasoning is not"),
3088            "the replayed variant must not claim reasoning is dropped: {p}"
3089        );
3090        assert!(
3091            lower.contains("do not re-derive"),
3092            "the replayed variant must forbid re-deriving the plan: {p}"
3093        );
3094        assert!(
3095            lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3096            "the anti-repetition rule must survive the flip: {p}"
3097        );
3098        assert!(
3099            lower.contains("already shown earlier"),
3100            "the re-read rule must survive the flip: {p}"
3101        );
3102    }
3103
3104    /// Only side-effect-free tools may be replayed after a crash.
3105    ///
3106    /// Regression guard for an inverted default: `ToolReplay` used to default to
3107    /// `Safe` and the CLI then marked *every* tool `Safe`, so the first time
3108    /// recovery acted on the flag it would have re-run `bash`/`write`/`edit` and
3109    /// duplicated their side effects. upstream defaults to `"never"`
3110    /// (`drive/tools.ts: tool.replay ?? "never"`).
3111    #[test]
3112    fn only_read_only_tools_are_replayable() {
3113        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3114        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3115        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3116        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3117        let tools = build_tools(&context, &Args::default());
3118
3119        let replay_of = |name: &str| {
3120            tools
3121                .iter()
3122                .find(|tool| tool.tool.schema().name == name)
3123                .map(|tool| tool.replay)
3124        };
3125
3126        for read_only in ["read", "docs"] {
3127            assert_eq!(
3128                replay_of(read_only),
3129                Some(ToolReplay::Safe),
3130                "{read_only} is side-effect free, so recovery may re-run it"
3131            );
3132        }
3133        for mutating in ["bash", "edit", "write", "powershell"] {
3134            if let Some(replay) = replay_of(mutating) {
3135                assert_eq!(
3136                    replay,
3137                    ToolReplay::Never,
3138                    "{mutating} must never be replayed: re-running it would duplicate its effect"
3139                );
3140            }
3141        }
3142    }
3143
3144    #[test]
3145    fn default_tools_include_docs_lookup() {
3146        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3147        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3148        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3149        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3150        let names: Vec<String> = build_tools(&context, &Args::default())
3151            .iter()
3152            .map(|tool| tool.tool.schema().name.clone())
3153            .collect();
3154
3155        // `docs` is a default capability; the legacy rpi-only lookup tools stay
3156        // unregistered. Asserted by membership (not exact equality) because
3157        // `powershell` is Windows-only and `settings.json#defaultTools` may
3158        // narrow the set in a developer's environment.
3159        assert!(names.contains(&"docs".to_string()));
3160        for legacy in ["grep", "find", "ls"] {
3161            assert!(
3162                !names.contains(&legacy.to_string()),
3163                "legacy tool {legacy} should not be registered by default"
3164            );
3165        }
3166    }
3167
3168    #[test]
3169    fn reload_settings_preflight_respects_trust() {
3170        struct RestoreConfigDir(Option<std::ffi::OsString>);
3171
3172        impl Drop for RestoreConfigDir {
3173            fn drop(&mut self) {
3174                match self.0.take() {
3175                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3176                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3177                }
3178            }
3179        }
3180
3181        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3182        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3183        let tmp = tempfile::tempdir().unwrap();
3184        let agent = tmp.path().join("agent");
3185        let cwd = tmp.path().join("project");
3186        std::fs::create_dir_all(&agent).unwrap();
3187        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3188        std::fs::write(agent.join("settings.json"), "{}").unwrap();
3189        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3190        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3191        let args = Args::default();
3192
3193        let trusted = validate_settings_for_reload(&args, &cwd, true);
3194        let untrusted = validate_settings_for_reload(&args, &cwd, false);
3195        assert!(trusted
3196            .unwrap_err()
3197            .contains("could not load project settings"));
3198        assert!(untrusted.is_ok());
3199
3200        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3201        assert!(validate_settings_for_reload(&args, &cwd, false)
3202            .unwrap_err()
3203            .contains("could not load global settings"));
3204
3205        let local_only = Args {
3206            dev_local_only: true,
3207            ..Args::default()
3208        };
3209        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3210    }
3211
3212    #[tokio::test(flavor = "current_thread")]
3213    async fn reload_preserves_live_resources_when_settings_break() {
3214        struct RestoreConfigDir(Option<std::ffi::OsString>);
3215
3216        impl Drop for RestoreConfigDir {
3217            fn drop(&mut self) {
3218                match self.0.take() {
3219                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3220                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3221                }
3222            }
3223        }
3224
3225        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3226        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3227        let tmp = tempfile::tempdir().unwrap();
3228        let agent = tmp.path().join("agent");
3229        let cwd = tmp.path().join("project");
3230        let skill_dir = tmp.path().join("configured-skills");
3231        std::fs::create_dir_all(&agent).unwrap();
3232        std::fs::create_dir_all(&cwd).unwrap();
3233        std::fs::create_dir_all(&skill_dir).unwrap();
3234        std::fs::write(
3235            skill_dir.join("SKILL.md"),
3236            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3237        )
3238        .unwrap();
3239        std::fs::write(
3240            agent.join("settings.json"),
3241            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3242        )
3243        .unwrap();
3244        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3245
3246        let resolved = crate::provider::resolve(
3247            Some("anthropic"),
3248            Some(crate::provider::DEFAULT_MODEL_ID),
3249            None,
3250            Some("test-key"),
3251            None,
3252        )
3253        .unwrap();
3254        let args = Args {
3255            trust_override: Some(false),
3256            no_session: true,
3257            no_extensions: true,
3258            no_prompt_templates: true,
3259            no_context_files: true,
3260            system_prompt: Some("stable system prompt".into()),
3261            ..Args::default()
3262        };
3263        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3264        let before_resources = harness.get_resources().await.unwrap();
3265        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3266        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3267        let before_prompt = harness.get_system_prompt().await.unwrap();
3268        let before_tools: Vec<String> = harness
3269            .get_tools()
3270            .await
3271            .unwrap()
3272            .iter()
3273            .map(|tool| tool.tool.schema().name.clone())
3274            .collect();
3275        let before_bridge = context
3276            .action_bridge
3277            .lock()
3278            .unwrap()
3279            .as_ref()
3280            .unwrap()
3281            .clone();
3282
3283        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3284        let outcome = reload_extension_resources(&harness, &context).await;
3285
3286        assert!(outcome.had_warnings);
3287        assert!(outcome.summary.contains("Settings reload failed"));
3288        assert!(outcome.summary.contains("could not load global settings"));
3289        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3290        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3291        let after_tools: Vec<String> = harness
3292            .get_tools()
3293            .await
3294            .unwrap()
3295            .iter()
3296            .map(|tool| tool.tool.schema().name.clone())
3297            .collect();
3298        assert_eq!(after_tools, before_tools);
3299        let after_bridge = context
3300            .action_bridge
3301            .lock()
3302            .unwrap()
3303            .as_ref()
3304            .unwrap()
3305            .clone();
3306        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3307    }
3308
3309    #[test]
3310    fn reload_preparation_rejects_settings_changed_during_derivation() {
3311        struct RestoreConfigDir(Option<std::ffi::OsString>);
3312
3313        impl Drop for RestoreConfigDir {
3314            fn drop(&mut self) {
3315                match self.0.take() {
3316                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3317                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3318                }
3319            }
3320        }
3321
3322        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3323        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3324        let tmp = tempfile::tempdir().unwrap();
3325        let agent = tmp.path().join("agent");
3326        let cwd = tmp.path().join("project");
3327        let first_skill_dir = tmp.path().join("first-skills");
3328        let second_skill_dir = tmp.path().join("second-skills");
3329        std::fs::create_dir_all(&agent).unwrap();
3330        std::fs::create_dir_all(&cwd).unwrap();
3331        std::fs::write(
3332            agent.join("settings.json"),
3333            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3334        )
3335        .unwrap();
3336        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3337        let args = Args {
3338            trust_override: Some(false),
3339            no_extensions: true,
3340            no_prompt_templates: true,
3341            ..Args::default()
3342        };
3343
3344        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3345            std::fs::write(
3346                agent.join("settings.json"),
3347                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3348            )
3349            .unwrap();
3350        });
3351
3352        let error = result
3353            .err()
3354            .expect("settings mutation must fail preparation");
3355        assert!(error.contains("settings changed while reload inputs were being prepared"));
3356    }
3357
3358    #[tokio::test(flavor = "current_thread")]
3359    async fn reload_uses_frozen_settings_inputs_after_preparation() {
3360        struct RestoreConfigDir(Option<std::ffi::OsString>);
3361
3362        impl Drop for RestoreConfigDir {
3363            fn drop(&mut self) {
3364                match self.0.take() {
3365                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3366                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3367                }
3368            }
3369        }
3370
3371        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3372        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3373        let tmp = tempfile::tempdir().unwrap();
3374        let agent = tmp.path().join("agent");
3375        let cwd = tmp.path().join("project");
3376        let skill_dir = tmp.path().join("configured-skills");
3377        std::fs::create_dir_all(&agent).unwrap();
3378        std::fs::create_dir_all(&cwd).unwrap();
3379        std::fs::create_dir_all(&skill_dir).unwrap();
3380        std::fs::write(
3381            skill_dir.join("SKILL.md"),
3382            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3383        )
3384        .unwrap();
3385        std::fs::write(
3386            agent.join("settings.json"),
3387            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3388        )
3389        .unwrap();
3390        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3391
3392        let resolved = crate::provider::resolve(
3393            Some("anthropic"),
3394            Some(crate::provider::DEFAULT_MODEL_ID),
3395            None,
3396            Some("test-key"),
3397            None,
3398        )
3399        .unwrap();
3400        let args = Args {
3401            trust_override: Some(false),
3402            no_session: true,
3403            no_extensions: true,
3404            no_prompt_templates: true,
3405            no_context_files: true,
3406            system_prompt: Some("stable system prompt".into()),
3407            ..Args::default()
3408        };
3409        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3410
3411        let outcome = reload_extension_resources_inner(&harness, &context, || {
3412            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3413        })
3414        .await;
3415
3416        assert!(!outcome.summary.contains("Settings reload failed"));
3417        assert_eq!(
3418            outcome.summary,
3419            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3420        );
3421        let resources = harness.get_resources().await.unwrap();
3422        let skills = resources.skills.as_ref().unwrap();
3423        assert_eq!(skills.len(), 1);
3424        assert_eq!(skills[0].name, "frozen-skill");
3425    }
3426
3427    #[tokio::test]
3428    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3429        let tmp = tempfile::tempdir().unwrap();
3430        let skill_path = tmp.path().join("explicit-skill.md");
3431        std::fs::write(
3432            &skill_path,
3433            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3434        )
3435        .unwrap();
3436        let prompt_path = tmp.path().join("explicit-prompt.md");
3437        std::fs::write(
3438            &prompt_path,
3439            "---\ndescription: Explicit prompt\n---\nPrompt body",
3440        )
3441        .unwrap();
3442
3443        let args = Args {
3444            skill: vec![skill_path.clone()],
3445            prompt_template: vec![prompt_path.clone()],
3446            ..Args::default()
3447        };
3448        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3449        let prompt_paths =
3450            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3451        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3452        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3453
3454        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3455        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3456        assert_eq!(skills.skills[0].name, "explicit-skill");
3457
3458        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3459        assert_eq!(
3460            prompts.prompt_templates.len(),
3461            1,
3462            "{:?}",
3463            prompts.diagnostics
3464        );
3465        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3466    }
3467
3468    #[test]
3469    fn tool_policy_applies_to_rust_and_js_active_names() {
3470        let names = vec![
3471            "read".to_string(),
3472            "ask_user_question".to_string(),
3473            "write".to_string(),
3474        ];
3475
3476        let args = Args {
3477            tools: Some(vec!["read".into(), "ask_user_question".into()]),
3478            ..Args::default()
3479        };
3480        assert_eq!(
3481            filter_active_tool_names(names.clone(), &args),
3482            vec!["read", "ask_user_question"]
3483        );
3484
3485        let args = Args {
3486            exclude_tools: Some(vec!["ask_user_question".into()]),
3487            ..Args::default()
3488        };
3489        assert_eq!(
3490            filter_active_tool_names(names.clone(), &args),
3491            vec!["read", "write"]
3492        );
3493
3494        let args = Args {
3495            no_tools: true,
3496            ..Args::default()
3497        };
3498        assert!(filter_active_tool_names(names, &args).is_empty());
3499    }
3500
3501    #[test]
3502    fn select_ephemeral_when_no_session() {
3503        let args = Args {
3504            no_session: true,
3505            ..Args::default()
3506        };
3507        let cwd = Path::new("/tmp");
3508        assert!(matches!(
3509            select_session(&args, cwd),
3510            SessionSelection::Ephemeral
3511        ));
3512    }
3513
3514    #[test]
3515    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3516        let defaults = Args::default();
3517        assert!(resolve_project_trust(
3518            &defaults,
3519            Path::new("C:/definitely-not-a-project")
3520        ));
3521
3522        let denied = Args {
3523            trust_override: Some(false),
3524            ..Args::default()
3525        };
3526        assert!(!resolve_project_trust(
3527            &denied,
3528            Path::new("C:/definitely-not-a-project")
3529        ));
3530    }
3531
3532    #[tokio::test(flavor = "current_thread")]
3533    async fn build_preserves_the_supplied_startup_project_snapshot() {
3534        struct RestoreConfigDir(Option<std::ffi::OsString>);
3535
3536        impl Drop for RestoreConfigDir {
3537            fn drop(&mut self) {
3538                match self.0.take() {
3539                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3540                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3541                }
3542            }
3543        }
3544
3545        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3546        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3547        let tmp = tempfile::tempdir().unwrap();
3548        let agent = tmp.path().join("agent");
3549        let cwd = tmp.path().join("project");
3550        std::fs::create_dir_all(&agent).unwrap();
3551        std::fs::create_dir_all(&cwd).unwrap();
3552        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3553
3554        let resolved = crate::provider::resolve(
3555            Some("anthropic"),
3556            Some(crate::provider::DEFAULT_MODEL_ID),
3557            None,
3558            Some("test-key"),
3559            None,
3560        )
3561        .unwrap();
3562        let args = Args {
3563            trust_override: Some(false),
3564            no_session: true,
3565            no_tools: true,
3566            no_extensions: true,
3567            no_skills: true,
3568            no_prompt_templates: true,
3569            no_context_files: true,
3570            system_prompt: Some("test prompt".into()),
3571            ..Args::default()
3572        };
3573
3574        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3575
3576        assert_eq!(context.cwd, cwd);
3577        assert!(context.project_trusted);
3578    }
3579
3580    #[tokio::test(flavor = "current_thread")]
3581    async fn build_applies_the_name_flag_to_the_session() {
3582        struct RestoreConfigDir(Option<std::ffi::OsString>);
3583
3584        impl Drop for RestoreConfigDir {
3585            fn drop(&mut self) {
3586                match self.0.take() {
3587                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3588                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3589                }
3590            }
3591        }
3592
3593        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3594        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3595        let tmp = tempfile::tempdir().unwrap();
3596        let agent = tmp.path().join("agent");
3597        let cwd = tmp.path().join("project");
3598        std::fs::create_dir_all(&agent).unwrap();
3599        std::fs::create_dir_all(&cwd).unwrap();
3600        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3601
3602        let resolved = crate::provider::resolve(
3603            Some("anthropic"),
3604            Some(crate::provider::DEFAULT_MODEL_ID),
3605            None,
3606            Some("test-key"),
3607            None,
3608        )
3609        .unwrap();
3610        let args = Args {
3611            trust_override: Some(false),
3612            no_tools: true,
3613            no_extensions: true,
3614            no_skills: true,
3615            no_prompt_templates: true,
3616            no_context_files: true,
3617            system_prompt: Some("test prompt".into()),
3618            name: Some("smoke name".into()),
3619            session_dir: Some(cwd.join("sessions")),
3620            ..Args::default()
3621        };
3622
3623        let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3624        assert_eq!(
3625            harness.get_name().await.unwrap().as_deref(),
3626            Some("smoke name")
3627        );
3628    }
3629
3630    #[test]
3631    fn select_latest_for_continue_and_resume() {
3632        let args = Args {
3633            continue_session: true,
3634            ..Args::default()
3635        };
3636        let cwd = Path::new("/tmp");
3637        assert!(matches!(
3638            select_session(&args, cwd),
3639            SessionSelection::Latest { .. }
3640        ));
3641
3642        let args = Args {
3643            resume: true,
3644            ..Args::default()
3645        };
3646        assert!(matches!(
3647            select_session(&args, cwd),
3648            SessionSelection::Latest { .. }
3649        ));
3650    }
3651
3652    #[test]
3653    fn select_by_id_for_session_flag() {
3654        let args = Args {
3655            session: Some("01a02ece".into()),
3656            ..Args::default()
3657        };
3658        let cwd = Path::new("/tmp");
3659        assert!(matches!(
3660            select_session(&args, cwd),
3661            SessionSelection::ById { id, .. } if id == "01a02ece"
3662        ));
3663    }
3664
3665    #[test]
3666    fn select_new_with_custom_dir() {
3667        let args = Args {
3668            session_dir: Some(PathBuf::from("/tmp/sess")),
3669            ..Args::default()
3670        };
3671        let cwd = Path::new("/tmp");
3672        match select_session(&args, cwd) {
3673            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3674            other => panic!("expected New, got {other:?}"),
3675        }
3676    }
3677
3678    #[test]
3679    fn select_new_default_dir() {
3680        let args = Args::default();
3681        let cwd = Path::new("/proj");
3682        match select_session(&args, cwd) {
3683            SessionSelection::New { dir, .. } => {
3684                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3685            }
3686            other => panic!("expected New, got {other:?}"),
3687        }
3688    }
3689
3690    #[test]
3691    fn default_session_dir_only_uses_rpi() {
3692        let tmp = tempfile::tempdir().unwrap();
3693        let cwd = tmp.path();
3694        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3695        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3696        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3697        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3698    }
3699
3700    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3701    async fn ephemeral_session_builds_roundtrips() {
3702        // Sanity: the ephemeral path produces a usable Session facade (the
3703        // harness build itself needs a provider; tested via the integration
3704        // path in tests/build.rs instead).
3705        let s = ephemeral_session();
3706        let leaf = s.get_leaf_id().await;
3707        assert!(leaf.is_ok());
3708    }
3709
3710    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
3711    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
3712
3713    /// The exclusion policy, checked against the constants rather than copies.
3714    ///
3715    /// `rpi-tools` builds its exclusion from `rpi-plugin-sdk`'s declarations, so
3716    /// within this repo a rename is a compile error — this test is not the link
3717    /// between those two crates any more. What it still guards is the part the
3718    /// compiler cannot see:
3719    ///
3720    /// - **The wire values.** `rpi-langfuse` lives in a different repository and
3721    ///   agrees on the *strings*, not the constant paths. Rewriting a constant's
3722    ///   value would compile here and silently stop matching there, so the exact
3723    ///   values are pinned below. Changing one is a deliberate, breaking change to
3724    ///   a cross-repo contract, and it should fail here first.
3725    /// - **Coverage.** Every name the SDK declares must actually be excluded.
3726    /// - **Narrowness.** The user's own configuration must still reach the shell.
3727    #[test]
3728    fn host_private_env_matches_the_declared_contract() {
3729        // 1. The wire values. Pinned literally on purpose: see the doc comment.
3730        assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
3731        assert_eq!(
3732            rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
3733            [
3734                "LANGFUSE_PI_PARENT_TRACE_ID",
3735                "LANGFUSE_PI_PARENT_SPAN_ID",
3736                "LANGFUSE_PI_PARENT_SESSION_ID",
3737                "LANGFUSE_PI_PARENT_DEPTH",
3738            ],
3739            "these strings are what rpi-langfuse (a different repo) matches; a \
3740             change here is a breaking cross-repo change, not a refactor"
3741        );
3742
3743        // 2. Coverage — derived from the declarations, so a newly added family
3744        //    member is checked without anyone remembering to extend this test.
3745        for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
3746            .iter()
3747            .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
3748        {
3749            assert!(
3750                rpi_tools::is_host_private_env(name),
3751                "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
3752                rpi_tools::HOST_PRIVATE_ENV
3753            );
3754        }
3755
3756        // 3. Narrowness — these name how the *user* configured the run and are
3757        //    exactly what a command inside the shell should still see.
3758        for visible in [
3759            crate::args::RPI_OFFLINE_ENV,
3760            crate::config::CONFIG_DIR_ENV,
3761            "PATH",
3762        ] {
3763            assert!(
3764                !rpi_tools::is_host_private_env(visible),
3765                "{visible} describes the user's configuration and must still reach the shell"
3766            );
3767        }
3768    }
3769    #[tokio::test]
3770    async fn reload_shutdown_joins_all_handlers_even_after_veto() {
3771        use rpi_plugin_sdk::{EventTag, StablePluginEvent};
3772        use std::sync::atomic::AtomicUsize;
3773        extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
3774            let calls = unsafe { &*(data as *const AtomicUsize) };
3775            std::thread::sleep(std::time::Duration::from_millis(20));
3776            if calls.fetch_add(1, Ordering::SeqCst) == 0 {
3777                rpi_plugin_sdk::EVENT_HANDLER_ABORT
3778            } else {
3779                0
3780            }
3781        }
3782        let calls = AtomicUsize::new(0);
3783        let mut registry = rpi_extensions::ExtensionRegistry::new();
3784        for name in ["first", "second"] {
3785            registry.register_event_handler(
3786                name.into(),
3787                EventTag::SessionShutdown,
3788                stop,
3789                &calls as *const _ as *mut std::ffi::c_void,
3790            );
3791        }
3792        super::shutdown_extensions_for_reload(
3793            Some(Arc::new(registry.snapshot())),
3794            rpi_extensions::PluginKeepalive::empty(),
3795        )
3796        .await
3797        .unwrap();
3798        assert_eq!(calls.load(Ordering::SeqCst), 2);
3799    }
3800}