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