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