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