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        prepare: None,
1689    }
1690}
1691
1692fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1693    if args.no_tools {
1694        return Vec::new();
1695    }
1696    // Keep the coding tools aligned with Pi and expose rpi's read-only docs
1697    // lookup as a default assistant capability.
1698    //
1699    // Replay policy (native `ToolDeclaration.replay`): only tools that are
1700    // genuinely free of side effects may be re-run when a crash leaves their
1701    // outcome unknown — everything else must stay `Never`, because replaying
1702    // `bash`/`write`/`edit` would duplicate a side effect. `HarnessTool::new`
1703    // already defaults to `Never`, so this is opt-in per read-only tool.
1704    let mut all: Vec<(&'static str, HarnessTool)> = vec![
1705        (
1706            "read",
1707            HarnessTool::new(create_read_tool(ctx, None)).with_replay(ToolReplay::Safe),
1708        ),
1709        (
1710            "bash",
1711            HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1712        ),
1713        ("edit", HarnessTool::new(create_edit_tool(ctx))),
1714        ("write", HarnessTool::new(create_write_tool(ctx))),
1715        (
1716            "docs",
1717            HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
1718        ),
1719    ];
1720
1721    // Add PowerShell tool on Windows
1722    #[cfg(target_os = "windows")]
1723    all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
1724
1725    // Apply default_tools from settings (if no CLI override)
1726    if args.tools.is_none() {
1727        if let Ok(settings) = crate::settings::load_settings() {
1728            if let Some(default_tools) = &settings.default_tools {
1729                all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
1730            }
1731        }
1732    }
1733
1734    // `--no-builtin-tools` disables the built-in set but would keep
1735    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
1736    // here. We honor it by clearing the built-ins.
1737    if args.no_builtin_tools {
1738        all.clear();
1739    }
1740
1741    // Allowlist (`--tools`): keep only named built-ins.
1742    if let Some(allow) = &args.tools {
1743        all.retain(|(name, _)| allow.iter().any(|a| a == name));
1744    }
1745    // Denylist (`--exclude-tools`): drop named tools.
1746    if let Some(deny) = &args.exclude_tools {
1747        all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1748    }
1749
1750    all.into_iter().map(|(_, t)| t).collect()
1751}
1752
1753/// Resolve the active tool names from the constructed tools when no explicit
1754/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
1755/// active.
1756fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1757    filter_active_tool_names(
1758        tools.iter().map(|tool| tool.tool.schema().name.clone()),
1759        args,
1760    )
1761}
1762
1763/// Whether a tool name survives the command-line tool policy. Keep this check
1764/// centralized so extension-provided tools honor the same policy as built-ins.
1765pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1766    if args.no_tools {
1767        return false;
1768    }
1769    if args
1770        .tools
1771        .as_ref()
1772        .is_some_and(|allow| !allow.iter().any(|value| value == name))
1773    {
1774        return false;
1775    }
1776    if args
1777        .exclude_tools
1778        .as_ref()
1779        .is_some_and(|deny| deny.iter().any(|value| value == name))
1780    {
1781        return false;
1782    }
1783    true
1784}
1785
1786pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1787where
1788    I: IntoIterator<Item = String>,
1789{
1790    names
1791        .into_iter()
1792        .filter(|name| tool_name_allowed(name, args))
1793        .collect()
1794}
1795
1796/// Build the `Session` facade for the chosen selection.
1797async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1798    match selection {
1799        SessionSelection::Ephemeral => Ok(ephemeral_session()),
1800        SessionSelection::New { dir, .. } => {
1801            // Ensure the sessions directory exists, then create a fresh JSONL
1802            // session file inside it.
1803            std::fs::create_dir_all(dir)
1804                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1805            let session = create_jsonl_session(dir, cwd)
1806                .await
1807                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1808            Ok(session)
1809        }
1810        SessionSelection::Latest
1811        | SessionSelection::ById { .. }
1812        | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1813        SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1814    }
1815}
1816
1817/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
1818/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
1819/// match the request, then open the matched file and wrap it in a `Session`
1820/// facade. The restored transcript renders into the TUI at startup and the
1821/// harness continues appending to the same file.
1822async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1823    // `list_typed` is newest-first; `Latest` takes the head, `ById` matches
1824    // the id exactly or by file-name containment (so `--session 01a02…` or a
1825    // partial id works, mirroring the TS id/path matching).
1826    match selection {
1827        SessionSelection::Latest => {
1828            let metas = list_session_metadata(cwd).await?;
1829            let Some(meta) = first_resumable_session(&metas) else {
1830                return Err(BuildError::SessionNotFound {
1831                    requested: "the most recent session".to_string(),
1832                    dir: default_session_dir(Path::new(cwd)).display().to_string(),
1833                });
1834            };
1835            open_session(meta, cwd).await
1836        }
1837        SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1838            OpenError::NotFound { requested } => BuildError::SessionNotFound {
1839                requested,
1840                dir: default_session_dir(Path::new(cwd)).display().to_string(),
1841            },
1842            OpenError::Other(msg) => BuildError::SessionOpen(msg),
1843        }),
1844        SessionSelection::ByExactId { id } => {
1845            // Exact id match only (pi `--session-id`): restore when the
1846            // session exists, else create a fresh one under the default dir.
1847            let metas = list_session_metadata(cwd).await?;
1848            if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1849                return open_session(meta, cwd).await;
1850            }
1851            let dir = default_session_dir(Path::new(cwd));
1852            std::fs::create_dir_all(&dir)
1853                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1854            create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1855                .await
1856                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1857        }
1858        _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1859    }
1860}
1861
1862/// `--fork <path|id>`: open the source session, fork it into a new JSONL
1863/// session (records the parent id), and start in the fork.
1864async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1865    use rpi_harness::session::jsonl::{
1866        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1867    };
1868    use rpi_harness::session::types::{ForkOptions, SessionStorage};
1869    use rpi_tools::FileSystem;
1870
1871    let dir = default_session_dir(Path::new(cwd));
1872    std::fs::create_dir_all(&dir)
1873        .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1874    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1875    let fs: Arc<dyn FileSystem> = env.clone();
1876    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1877        fs: fs.clone(),
1878        sessions_root: dir.to_string_lossy().into_owned(),
1879        clock: Arc::new(SystemClock),
1880        ids: Arc::new(DefaultIdGenerator::new()),
1881    });
1882    let metas = repo
1883        .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1884        .await
1885        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1886    let source_meta = metas
1887        .iter()
1888        .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1889        .ok_or_else(|| BuildError::SessionNotFound {
1890            requested: format!("--fork {source}"),
1891            dir: dir.display().to_string(),
1892        })?;
1893    let fork_storage = repo
1894        .fork_typed(
1895            source_meta,
1896            &JsonlSessionCreateOptions {
1897                id: None,
1898                parent_session_id: Some(source_meta.id.clone()),
1899                cwd: cwd.to_string(),
1900                metadata: None,
1901            },
1902            &ForkOptions::default(),
1903        )
1904        .await
1905        .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1906    claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
1907    let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1908    Ok(Session::new(storage_arc, None))
1909}
1910
1911/// Errors from [`open_session_by_id`], split so the CLI can map them to
1912/// [`BuildError`] while the TUI can surface a friendlier note.
1913pub enum OpenError {
1914    /// No session matched the request.
1915    NotFound { requested: String },
1916    /// The match existed but could not be opened/parsed.
1917    Other(String),
1918}
1919
1920impl std::fmt::Display for OpenError {
1921    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1922        match self {
1923            OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1924            // `Other` carries the bare reason (see `open_session_by_id`); the
1925            // prefix lives here so the TUI and the CLI's
1926            // `BuildError::SessionOpen` wrapper each render it exactly once
1927            // (wrapping the already-prefixed `BuildError` string doubled it).
1928            OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
1929        }
1930    }
1931}
1932
1933/// List the JSONL session metadata under the default session dir, newest
1934/// first. Shared by startup restore and the TUI `/session` hot-switch.
1935pub async fn list_session_metadata(
1936    cwd: &str,
1937) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1938    use rpi_harness::session::jsonl::{
1939        JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1940    };
1941    use rpi_tools::FileSystem;
1942
1943    let dir = default_session_dir(Path::new(cwd));
1944    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1945    let fs: Arc<dyn FileSystem> = env.clone();
1946    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1947        fs: fs.clone(),
1948        sessions_root: dir.to_string_lossy().into_owned(),
1949        clock: Arc::new(SystemClock),
1950        ids: Arc::new(DefaultIdGenerator::new()),
1951    });
1952    repo.list_typed(&JsonlSessionListOptions::default())
1953        .await
1954        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1955}
1956
1957/// A cheap, display-oriented summary of one JSONL session file.
1958///
1959/// The session pickers (`-r` and `/session`) need enough context to tell saved
1960/// sessions apart. The header-only [`JsonlSessionMetadata`] carries no name and
1961/// no transcript, so a list built from it renders as a wall of identical
1962/// `2026-09-25T15-05-15-847Z_…` file names — including the many header-only
1963/// sessions an abandoned launch leaves behind. This reads just the head (and a
1964/// small tail, for a late `--name`/`/name` fact) of the file to recover:
1965///
1966/// - the session display name, when one was recorded;
1967/// - the first user prompt, single-lined and truncated, for a readable label;
1968/// - whether the file is header-only (nothing to resume).
1969///
1970/// It intentionally does **not** parse the whole log: sessions can be tens of
1971/// megabytes, and the picker runs over every session at startup.
1972#[derive(Debug, Clone, Default, PartialEq, Eq)]
1973pub struct SessionSummary {
1974    /// The session display name (`--name` / `/name`), if one was recorded.
1975    pub name: Option<String>,
1976    /// The first user prompt, single-lined and trimmed to a label-sized length.
1977    pub preview: Option<String>,
1978    /// True when the file holds only its header (a session with no content).
1979    pub empty: bool,
1980    /// On-disk size in bytes (a rough content-volume hint for the picker).
1981    pub bytes: u64,
1982}
1983
1984/// Bytes read from the head of a session file to recover a name + first prompt.
1985const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
1986/// Bytes read from the tail of a session file to recover a late name fact.
1987const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
1988/// Longest preview retained (characters, not bytes).
1989const SUMMARY_PREVIEW_CHARS: usize = 80;
1990
1991/// Summarize one session file for the pickers. Best-effort: an unreadable or
1992/// malformed file yields a summary with `empty: false` and no label, so it is
1993/// still selectable rather than silently dropped.
1994pub fn summarize_session_file(path: &Path) -> SessionSummary {
1995    use std::io::{Read, Seek, SeekFrom};
1996
1997    let Ok(mut file) = std::fs::File::open(path) else {
1998        return SessionSummary::default();
1999    };
2000    let Ok(size) = file.metadata().map(|m| m.len()) else {
2001        return SessionSummary::default();
2002    };
2003    if size == 0 {
2004        return SessionSummary {
2005            name: None,
2006            preview: None,
2007            empty: true,
2008            bytes: 0,
2009        };
2010    }
2011
2012    // Head: the header line (for the empty check) + a name fact + the first user
2013    // prompt. The whole window is scanned; it is already in memory.
2014    let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2015    let head_len = file.read(&mut head).unwrap_or(0);
2016    head.truncate(head_len);
2017    let head = String::from_utf8_lossy(&head);
2018    let mut lines: Vec<&str> = head.lines().collect();
2019    let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2020    // A header-only file is exactly the header plus an optional trailing `\n`.
2021    let empty = (size as usize) <= header_len + 1;
2022    // When the window stopped short of the file end, its final line is partial:
2023    // drop it so a half-read message can never be mistaken for a complete one.
2024    if (head_len as u64) < size && !lines.is_empty() {
2025        lines.pop();
2026    }
2027
2028    let mut summary = SessionSummary {
2029        name: None,
2030        preview: None,
2031        empty,
2032        bytes: size,
2033    };
2034    for line in lines {
2035        apply_summary_line(line, &mut summary);
2036    }
2037
2038    // Tail: a name set late in the session lives after the head window. Read the
2039    // last window and scan only complete lines so a torn/partial first line is
2040    // ignored. Only do this when the file is larger than the head window.
2041    if size > SUMMARY_HEAD_BYTES
2042        && file
2043            .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2044            .is_ok()
2045    {
2046        let mut tail = Vec::new();
2047        if file.read_to_end(&mut tail).is_ok() {
2048            let tail = String::from_utf8_lossy(&tail);
2049            let mut tail_lines = tail.lines();
2050            // Drop the possibly-partial first line of the window.
2051            tail_lines.next();
2052            for line in tail_lines {
2053                if let Some(name) = name_from_line(line) {
2054                    summary.name = name;
2055                }
2056            }
2057        }
2058    }
2059    summary
2060}
2061
2062/// Extract the name from a `{"kind":"fact","fact":"name","name":…}` line.
2063/// Returns `Some(None)` for an explicit `name: null` (the fact was cleared).
2064fn name_from_line(line: &str) -> Option<Option<String>> {
2065    let value: serde_json::Value = serde_json::from_str(line).ok()?;
2066    if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2067        return None;
2068    }
2069    if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2070        return None;
2071    }
2072    Some(match value.get("name") {
2073        Some(serde_json::Value::String(name)) => Some(name.clone()),
2074        _ => None,
2075    })
2076}
2077
2078/// Fold one JSONL line into `summary`: record a name fact, or capture the first
2079/// user prompt as the preview.
2080fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2081    if let Some(name) = name_from_line(line) {
2082        summary.name = name;
2083        return;
2084    }
2085    if summary.preview.is_some() {
2086        return;
2087    }
2088    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2089        return;
2090    };
2091    if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2092        return;
2093    }
2094    if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2095        return;
2096    }
2097    let Some(message) = value.get("message") else {
2098        return;
2099    };
2100    if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2101        return;
2102    }
2103    let text = match message.get("content") {
2104        Some(serde_json::Value::String(text)) => Some(text.as_str()),
2105        Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2106            (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2107                .then(|| block.get("text").and_then(|v| v.as_str()))
2108                .flatten()
2109        }),
2110        _ => None,
2111    };
2112    let Some(text) = text else { return };
2113    let collapsed = text
2114        .lines()
2115        .map(str::trim)
2116        .filter(|line| !line.is_empty())
2117        .collect::<Vec<_>>()
2118        .join(" ");
2119    if collapsed.is_empty() {
2120        return;
2121    }
2122    summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2123}
2124
2125/// The human label for a session row: its recorded name, else the first user
2126/// prompt, else a marker that says the file has no transcript to show. Shared
2127/// by the startup picker and the in-TUI `/session` selector.
2128pub fn session_display_label(summary: &SessionSummary) -> String {
2129    if let Some(name) = summary.name.as_deref().map(str::trim) {
2130        if !name.is_empty() {
2131            return name.to_string();
2132        }
2133    }
2134    match summary.preview.as_deref().map(str::trim) {
2135        Some(preview) if !preview.is_empty() => preview.to_string(),
2136        _ => "(no messages)".to_string(),
2137    }
2138}
2139
2140/// Compact byte count (1.2MB / 34KB / 512B), shared by the startup picker and
2141/// the in-TUI `/session` selector.
2142pub fn format_session_bytes(bytes: u64) -> String {
2143    if bytes >= 1_048_576 {
2144        format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2145    } else if bytes >= 1024 {
2146        format!("{}KB", bytes / 1024)
2147    } else {
2148        format!("{bytes}B")
2149    }
2150}
2151
2152/// The first 8 characters of a session id, enough to disambiguate two rows that
2153/// share a timestamp without shouting the whole UUID.
2154pub fn short_session_id(id: &str) -> &str {
2155    &id[..id.len().min(8)]
2156}
2157
2158/// Synchronously list every JSONL session file under `dir`, newest-first by
2159/// modification time.
2160///
2161/// The repo's [`list_session_metadata`] is async and scans one cwd-encoded
2162/// subdirectory level; the in-TUI `/session` selector runs on the blocking key
2163/// thread and cannot await it, so this walks the same layout directly. Both
2164/// `.jsonl` files directly under `dir` and one level of subdirectories are
2165/// accepted.
2166pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2167    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2168    let mut visit = |path: PathBuf| {
2169        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2170            return;
2171        }
2172        let modified = std::fs::metadata(&path)
2173            .and_then(|m| m.modified())
2174            .unwrap_or(std::time::UNIX_EPOCH);
2175        files.push((path, modified));
2176    };
2177    if let Ok(entries) = std::fs::read_dir(dir) {
2178        for entry in entries.flatten() {
2179            let path = entry.path();
2180            if path.is_dir() {
2181                if let Ok(inner) = std::fs::read_dir(&path) {
2182                    for child in inner.flatten() {
2183                        visit(child.path());
2184                    }
2185                }
2186            } else {
2187                visit(path);
2188            }
2189        }
2190    }
2191    files.sort_by(|a, b| b.1.cmp(&a.1));
2192    files.into_iter().map(|(path, _)| path).collect()
2193}
2194
2195/// Pick the session `--continue`/`-c` should open: the newest session that has
2196/// content (a header-only session resumes to an empty transcript). Falls back to
2197/// the newest overall when every session is empty, and `None` only when the
2198/// list is empty.
2199fn first_resumable_session<'a>(
2200    metas: &'a [rpi_harness::session::jsonl::JsonlSessionMetadata],
2201) -> Option<&'a rpi_harness::session::jsonl::JsonlSessionMetadata> {
2202    metas
2203        .iter()
2204        .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2205        .or_else(|| metas.first())
2206}
2207
2208/// Open a session whose id matches exactly or by file-name containment
2209/// (so `--session 01a02…` / a partial id / a full file name all work). The
2210/// TUI `/session` hot-switch calls this with the selector's item value.
2211pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2212    let metas = list_session_metadata(cwd)
2213        .await
2214        .map_err(|e| OpenError::Other(e.to_string()))?;
2215    let Some(meta) = metas
2216        .iter()
2217        .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2218    else {
2219        return Err(OpenError::NotFound {
2220            requested: format!("session {id}"),
2221        });
2222    };
2223    open_session(meta, cwd).await.map_err(|e| {
2224        OpenError::Other(match e {
2225            // `open_session` renders as "Could not open the saved session:
2226            // <reason>" via `BuildError`. Carry the bare reason so it is not
2227            // prefixed twice (once here, once by the CLI/TUI).
2228            BuildError::SessionOpen(reason) => reason,
2229            other => other.to_string(),
2230        })
2231    })
2232}
2233
2234/// Fork the harness's current session into a new JSONL session (new id, parent
2235/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
2236/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
2237/// and the plugin `runtime_action(Fork)` host share one implementation.
2238/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
2239/// does `harness.set_session(...)`).
2240pub(crate) async fn fork_session_storage(
2241    harness: &AgentHarness,
2242    cwd: &str,
2243) -> Result<Session, String> {
2244    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2245    use rpi_tools::FileSystem;
2246
2247    let dir = default_session_dir(Path::new(cwd));
2248    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2249    let fs: Arc<dyn FileSystem> = env.clone();
2250    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2251        fs,
2252        sessions_root: dir.to_string_lossy().into_owned(),
2253        clock: Arc::new(SystemClock),
2254        ids: Arc::new(DefaultIdGenerator::new()),
2255    });
2256    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2257    // it from the session list by the current session's id.
2258    let id = harness.session().storage().metadata().id.clone();
2259    let metas = list_session_metadata(cwd)
2260        .await
2261        .map_err(|e| e.to_string())?;
2262    let Some(source) = metas.iter().find(|m| m.id == id) else {
2263        return Err(format!("current session {id} not found on disk"));
2264    };
2265    let fork_storage = repo
2266        .fork_typed(
2267            source,
2268            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2269                id: None,
2270                parent_session_id: Some(source.id.clone()),
2271                cwd: cwd.to_string(),
2272                metadata: None,
2273            },
2274            &rpi_harness::session::types::ForkOptions::default(),
2275        )
2276        .await
2277        .map_err(|e| e.to_string())?;
2278    claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2279    Ok(Session::new(Arc::new(fork_storage), None))
2280}
2281
2282/// Session logs this process has claimed, so re-claiming is a no-op.
2283static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2284/// The held lock handles. Kept alive for the process lifetime — dropping the
2285/// `File` would release the OS lock, so the guard is parked here.
2286static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2287
2288/// Claim the exclusive lock for a session log.
2289///
2290/// Two rpi processes appending to one JSONL diverge their `sequence` counters
2291/// (duplicate / gapped seqs) and corrupt it. The lock is an OS advisory lock on
2292/// `<path>.lock`: the second process gets a clear "already open" error instead
2293/// of corrupting the log, and the OS releases it automatically when the holder
2294/// exits (no stale-lock cleanup). Re-claiming a path this process already holds
2295/// is a no-op, so the TUI's session hot-switch can re-open freely.
2296fn claim_session_lock(session_path: &str) -> Result<(), String> {
2297    let lock_path = PathBuf::from(format!("{session_path}.lock"));
2298    if SESSION_LOCK_PATHS
2299        .get_or_init(|| Mutex::new(HashSet::new()))
2300        .lock()
2301        .unwrap()
2302        .contains(&lock_path)
2303    {
2304        return Ok(());
2305    }
2306    let file = std::fs::OpenOptions::new()
2307        .create(true)
2308        .write(true)
2309        .open(&lock_path)
2310        .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2311    if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2312        return Err(format!(
2313            "session {session_path} is already open in another rpi process"
2314        ));
2315    }
2316    SESSION_LOCKS
2317        .get_or_init(|| Mutex::new(Vec::new()))
2318        .lock()
2319        .unwrap()
2320        .push(file);
2321    SESSION_LOCK_PATHS
2322        .get_or_init(|| Mutex::new(HashSet::new()))
2323        .lock()
2324        .unwrap()
2325        .insert(lock_path);
2326    Ok(())
2327}
2328
2329/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
2330/// startup restore + TUI hot-switch).
2331async fn open_session(
2332    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2333    cwd: &str,
2334) -> Result<Session, BuildError> {
2335    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2336    use rpi_harness::session::types::SessionStorage;
2337    use rpi_tools::FileSystem;
2338
2339    let dir = default_session_dir(Path::new(cwd));
2340    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2341    let fs: Arc<dyn FileSystem> = env.clone();
2342    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2343        fs: fs.clone(),
2344        sessions_root: dir.to_string_lossy().into_owned(),
2345        clock: Arc::new(SystemClock),
2346        ids: Arc::new(DefaultIdGenerator::new()),
2347    });
2348    claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2349    let storage = repo
2350        .open_by_jsonl_metadata(meta)
2351        .await
2352        .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2353    // A session records the cwd it was created in. When that directory has
2354    // since been removed, continuing silently in it is confusing; warn the
2355    // user that we fell back to the process cwd (pi `getMissingSessionCwdIssue`).
2356    if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2357        Some(Path::new(&meta.path)),
2358        Path::new(&meta.cwd),
2359        Path::new(cwd),
2360    ) {
2361        eprintln!(
2362            "warning: {}",
2363            crate::session_cwd::format_missing_session_cwd_error(&issue)
2364        );
2365    }
2366    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2367    Ok(Session::new(storage_arc, None))
2368}
2369
2370/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
2371fn ephemeral_session() -> Session {
2372    let storage = Arc::new(InMemorySessionStorage::new(
2373        SessionMetadata {
2374            id: "ephemeral".into(),
2375            created_at: 0,
2376            parent_session_id: None,
2377        },
2378        Arc::new(SystemClock),
2379        Arc::new(DefaultIdGenerator::new()),
2380    ));
2381    Session::new(storage, None)
2382}
2383
2384/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2385///
2386/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2387/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2388/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2389/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2390///
2391/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2392/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2393/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2394pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2395    create_jsonl_session_with_id(dir, cwd, None).await
2396}
2397
2398/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
2399/// contract: the file is named with the given id so later `--session-id`
2400/// launches restore the same session).
2401pub(crate) async fn create_jsonl_session_with_id(
2402    dir: &Path,
2403    cwd: &str,
2404    id: Option<String>,
2405) -> Result<Session, String> {
2406    use rpi_harness::session::jsonl::{
2407        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2408    };
2409    use rpi_tools::FileSystem;
2410
2411    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
2412    // relative-path resolution matches the tool env.
2413    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2414    let fs: Arc<dyn FileSystem> = env.clone();
2415
2416    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2417        fs: fs.clone(),
2418        sessions_root: dir.to_string_lossy().into_owned(),
2419        clock: Arc::new(SystemClock),
2420        ids: Arc::new(DefaultIdGenerator::new()),
2421    });
2422
2423    let opts = JsonlSessionCreateOptions {
2424        id, // fresh uuidv7 when None (--session-id passes the fixed id)
2425        parent_session_id: None,
2426        cwd: cwd.to_string(),
2427        metadata: None,
2428    };
2429    let storage = repo
2430        .create_typed(&opts)
2431        .await
2432        .map_err(|e| format!("create session: {e}"))?;
2433    claim_session_lock(&storage.jsonl_metadata().path)
2434        .map_err(|e| format!("create session: {e}"))?;
2435    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
2436    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2437    Ok(Session::new(storage_arc, None))
2438}
2439
2440/// Read an `--append-system-prompt` target: if it's a readable file path, return
2441/// its contents; otherwise return `None` and let the caller use the literal.
2442fn read_append_target(target: &str) -> Option<String> {
2443    let path = Path::new(target);
2444    if path.is_file() {
2445        std::fs::read_to_string(path).ok()
2446    } else {
2447        None
2448    }
2449}
2450
2451#[cfg(test)]
2452mod tests {
2453    use super::*;
2454    use crate::args::Args;
2455
2456    /// A unique scratch file for one summary test.
2457    fn scratch(name: &str) -> PathBuf {
2458        let nanos = std::time::SystemTime::now()
2459            .duration_since(std::time::UNIX_EPOCH)
2460            .unwrap()
2461            .as_nanos();
2462        let path = std::env::temp_dir().join(format!(
2463            "rpi-summary-{}-{nanos}-{name}.jsonl",
2464            std::process::id()
2465        ));
2466        path
2467    }
2468
2469    #[test]
2470    fn summary_marks_a_header_only_session_empty() {
2471        let path = scratch("empty");
2472        std::fs::write(
2473            &path,
2474            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2475        )
2476        .unwrap();
2477        let summary = summarize_session_file(&path);
2478        assert!(summary.empty, "a header-only file has nothing to resume");
2479        assert!(summary.preview.is_none());
2480        assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
2481        std::fs::remove_file(&path).ok();
2482    }
2483
2484    #[test]
2485    fn summary_reads_the_first_user_prompt_and_name() {
2486        let path = scratch("content");
2487        let body = concat!(
2488            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2489            "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
2490            "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
2491            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
2492            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
2493        );
2494        std::fs::write(&path, body).unwrap();
2495        let summary = summarize_session_file(&path);
2496        assert!(!summary.empty);
2497        assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
2498        assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
2499        std::fs::remove_file(&path).ok();
2500    }
2501
2502    #[test]
2503    fn summary_extracts_a_block_style_user_prompt() {
2504        let path = scratch("blocks");
2505        let body = concat!(
2506            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2507            "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
2508        );
2509        std::fs::write(&path, body).unwrap();
2510        let summary = summarize_session_file(&path);
2511        assert_eq!(summary.preview.as_deref(), Some("line one line two"));
2512        assert_eq!(session_display_label(&summary), "line one line two");
2513        std::fs::remove_file(&path).ok();
2514    }
2515
2516    fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
2517        rpi_harness::session::jsonl::JsonlSessionMetadata {
2518            id: id.to_string(),
2519            created_at: 0,
2520            cwd: ".".to_string(),
2521            path: path.to_string_lossy().into_owned(),
2522            modified_at: 0,
2523            source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
2524            parent_session_id: None,
2525            legacy_parent_session_path: None,
2526            metadata: None,
2527        }
2528    }
2529
2530    #[test]
2531    fn continue_skips_newer_empty_sessions() {
2532        let empty = scratch("newest-empty");
2533        std::fs::write(
2534            &empty,
2535            "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2536        )
2537        .unwrap();
2538        let content = scratch("older-content");
2539        std::fs::write(
2540            &content,
2541            concat!(
2542                "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
2543                "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
2544            ),
2545        )
2546        .unwrap();
2547
2548        let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
2549        let picked = first_resumable_session(&metas).expect("a resumable session");
2550        assert_eq!(picked.id, "old");
2551
2552        // When every session is empty, fall back to the newest rather than
2553        // erroring.
2554        let all_empty = vec![meta_for(&empty, "empty")];
2555        assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
2556
2557        std::fs::remove_file(&empty).ok();
2558        std::fs::remove_file(&content).ok();
2559    }
2560
2561    #[test]
2562    fn list_session_files_sync_walks_the_cwd_subdirectory() {
2563        let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
2564        let nested = root.join("--proj--");
2565        std::fs::create_dir_all(&nested).unwrap();
2566        let file = nested.join("2026-01-01_a.jsonl");
2567        std::fs::write(&file, "{}\n").unwrap();
2568        let found = list_session_files_sync(&root);
2569        assert!(found.iter().any(|p| p == &file));
2570        std::fs::remove_dir_all(&root).ok();
2571    }
2572
2573    #[test]
2574    fn default_prompt_mentions_cwd_and_tools() {
2575        let p = default_system_prompt("/tmp/proj", false);
2576        assert!(p.contains("/tmp/proj"));
2577        assert!(p.contains("read"));
2578        assert!(p.contains("bash"));
2579        assert!(p.contains("edit"));
2580        assert!(p.contains("write"));
2581        assert!(p.contains("docs"));
2582        assert!(!p.contains("- grep"));
2583        assert!(!p.contains("- find"));
2584        assert!(!p.contains("- ls"));
2585        assert!(!p.contains("powershell"));
2586    }
2587
2588    #[test]
2589    fn default_prompt_instructs_language_matching() {
2590        // The model must detect the user's language and answer in it, so a
2591        // Chinese (or any non-English) user gets a same-language reply without
2592        // having to ask every turn.
2593        let p = default_system_prompt("/tmp/proj", false);
2594        let lower = p.to_lowercase();
2595        assert!(
2596            lower.contains("same language") && lower.contains("detect"),
2597            "the prompt must tell the model to detect and mirror the user's language: {p}"
2598        );
2599    }
2600
2601    #[test]
2602    fn default_prompt_keeps_working_state_in_the_visible_channel() {
2603        // Regression guard for the re-plan loop: on openai-compatible providers
2604        // the assistant's `thinking` blocks are dropped when the next request is
2605        // built (`providers/openai_completions.rs`: `Content::text_only`), so a
2606        // plan that lives only in reasoning is re-derived every turn. One
2607        // observed session paid for that 112 times in a single run, and twice
2608        // hallucinated the user's input ("The user is greeting me with hello",
2609        // with no such message). See `docs/llm-repetition-forensics.md` §八.
2610        let p = default_system_prompt("/tmp/proj", false);
2611        let lower = p.to_lowercase();
2612        // The rule must state the mechanism, not just "be clear".
2613        assert!(
2614            lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
2615            "the prompt must explain that reasoning does not survive the turn: {p}"
2616        );
2617        // …and the remedy: keep state visible, use the todo tool.
2618        assert!(
2619            lower.contains("visible") && lower.contains("todo tool"),
2620            "the prompt must point at visible text and the todo tool: {p}"
2621        );
2622        // …plus the anti-repetition rule itself.
2623        assert!(
2624            lower.contains("re-stating the same plan"),
2625            "the prompt must call out plan re-statement as a bug: {p}"
2626        );
2627        // And the guidance must not instruct re-reading what is already shown.
2628        assert!(
2629            lower.contains("already shown earlier"),
2630            "the prompt must discourage re-fetching known content: {p}"
2631        );
2632    }
2633
2634    #[test]
2635    fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
2636        // `compat.requiresThinkingAsText` (and the native thinking channels of
2637        // anthropic-messages / openai-responses) DO carry the model's reasoning
2638        // back on the next request. Telling such a model "reasoning is not
2639        // carried into your next turn" is false, and the instruction to restate
2640        // the plan every turn is exactly the busy-work the re-plan analysis
2641        // measured. The wording must flip with the mechanism.
2642        let p = default_system_prompt("/tmp/proj", true);
2643        let lower = p.to_lowercase();
2644        assert!(
2645            lower.contains("carried back"),
2646            "a replaying model must be told its reasoning survives: {p}"
2647        );
2648        assert!(
2649            !lower.contains("reasoning is not"),
2650            "the replayed variant must not claim reasoning is dropped: {p}"
2651        );
2652        assert!(
2653            lower.contains("do not re-derive"),
2654            "the replayed variant must forbid re-deriving the plan: {p}"
2655        );
2656        assert!(
2657            lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
2658            "the anti-repetition rule must survive the flip: {p}"
2659        );
2660        assert!(
2661            lower.contains("already shown earlier"),
2662            "the re-read rule must survive the flip: {p}"
2663        );
2664    }
2665
2666    /// Only side-effect-free tools may be replayed after a crash.
2667    ///
2668    /// Regression guard for an inverted default: `ToolReplay` used to default to
2669    /// `Safe` and the CLI then marked *every* tool `Safe`, so the first time
2670    /// recovery acted on the flag it would have re-run `bash`/`write`/`edit` and
2671    /// duplicated their side effects. upstream defaults to `"never"`
2672    /// (`drive/tools.ts: tool.replay ?? "never"`).
2673    #[test]
2674    fn only_read_only_tools_are_replayable() {
2675        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2676        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2677        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2678        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2679        let tools = build_tools(&context, &Args::default());
2680
2681        let replay_of = |name: &str| {
2682            tools
2683                .iter()
2684                .find(|tool| tool.tool.schema().name == name)
2685                .map(|tool| tool.replay)
2686        };
2687
2688        for read_only in ["read", "docs"] {
2689            assert_eq!(
2690                replay_of(read_only),
2691                Some(ToolReplay::Safe),
2692                "{read_only} is side-effect free, so recovery may re-run it"
2693            );
2694        }
2695        for mutating in ["bash", "edit", "write", "powershell"] {
2696            if let Some(replay) = replay_of(mutating) {
2697                assert_eq!(
2698                    replay,
2699                    ToolReplay::Never,
2700                    "{mutating} must never be replayed: re-running it would duplicate its effect"
2701                );
2702            }
2703        }
2704    }
2705
2706    #[test]
2707    fn default_tools_include_docs_lookup() {
2708        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2709        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2710        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2711        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2712        let names: Vec<String> = build_tools(&context, &Args::default())
2713            .iter()
2714            .map(|tool| tool.tool.schema().name.clone())
2715            .collect();
2716
2717        // `docs` is a default capability; the legacy rpi-only lookup tools stay
2718        // unregistered. Asserted by membership (not exact equality) because
2719        // `powershell` is Windows-only and `settings.json#defaultTools` may
2720        // narrow the set in a developer's environment.
2721        assert!(names.contains(&"docs".to_string()));
2722        for legacy in ["grep", "find", "ls"] {
2723            assert!(
2724                !names.contains(&legacy.to_string()),
2725                "legacy tool {legacy} should not be registered by default"
2726            );
2727        }
2728    }
2729
2730    #[test]
2731    fn reload_settings_preflight_respects_trust() {
2732        struct RestoreConfigDir(Option<std::ffi::OsString>);
2733
2734        impl Drop for RestoreConfigDir {
2735            fn drop(&mut self) {
2736                match self.0.take() {
2737                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2738                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2739                }
2740            }
2741        }
2742
2743        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2744        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2745        let tmp = tempfile::tempdir().unwrap();
2746        let agent = tmp.path().join("agent");
2747        let cwd = tmp.path().join("project");
2748        std::fs::create_dir_all(&agent).unwrap();
2749        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2750        std::fs::write(agent.join("settings.json"), "{}").unwrap();
2751        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2752        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2753        let args = Args::default();
2754
2755        let trusted = validate_settings_for_reload(&args, &cwd, true);
2756        let untrusted = validate_settings_for_reload(&args, &cwd, false);
2757        assert!(trusted
2758            .unwrap_err()
2759            .contains("could not load project settings"));
2760        assert!(untrusted.is_ok());
2761
2762        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2763        assert!(validate_settings_for_reload(&args, &cwd, false)
2764            .unwrap_err()
2765            .contains("could not load global settings"));
2766
2767        let local_only = Args {
2768            dev_local_only: true,
2769            ..Args::default()
2770        };
2771        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2772    }
2773
2774    #[tokio::test(flavor = "current_thread")]
2775    async fn reload_preserves_live_resources_when_settings_break() {
2776        struct RestoreConfigDir(Option<std::ffi::OsString>);
2777
2778        impl Drop for RestoreConfigDir {
2779            fn drop(&mut self) {
2780                match self.0.take() {
2781                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2782                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2783                }
2784            }
2785        }
2786
2787        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2788        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2789        let tmp = tempfile::tempdir().unwrap();
2790        let agent = tmp.path().join("agent");
2791        let cwd = tmp.path().join("project");
2792        let skill_dir = tmp.path().join("configured-skills");
2793        std::fs::create_dir_all(&agent).unwrap();
2794        std::fs::create_dir_all(&cwd).unwrap();
2795        std::fs::create_dir_all(&skill_dir).unwrap();
2796        std::fs::write(
2797            skill_dir.join("SKILL.md"),
2798            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2799        )
2800        .unwrap();
2801        std::fs::write(
2802            agent.join("settings.json"),
2803            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2804        )
2805        .unwrap();
2806        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2807
2808        let resolved = crate::provider::resolve(
2809            Some("anthropic"),
2810            Some(crate::provider::DEFAULT_MODEL_ID),
2811            None,
2812            Some("test-key"),
2813            None,
2814        )
2815        .unwrap();
2816        let args = Args {
2817            trust_override: Some(false),
2818            no_session: true,
2819            no_extensions: true,
2820            no_prompt_templates: true,
2821            no_context_files: true,
2822            system_prompt: Some("stable system prompt".into()),
2823            ..Args::default()
2824        };
2825        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2826        let before_resources = harness.get_resources().await.unwrap();
2827        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2828        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2829        let before_prompt = harness.get_system_prompt().await.unwrap();
2830        let before_tools: Vec<String> = harness
2831            .get_tools()
2832            .await
2833            .unwrap()
2834            .iter()
2835            .map(|tool| tool.tool.schema().name.clone())
2836            .collect();
2837        let before_bridge = context
2838            .action_bridge
2839            .lock()
2840            .unwrap()
2841            .as_ref()
2842            .unwrap()
2843            .clone();
2844
2845        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2846        let outcome = reload_extension_resources(&harness, &context).await;
2847
2848        assert!(outcome.had_warnings);
2849        assert!(outcome.summary.contains("Settings reload failed"));
2850        assert!(outcome.summary.contains("could not load global settings"));
2851        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2852        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2853        let after_tools: Vec<String> = harness
2854            .get_tools()
2855            .await
2856            .unwrap()
2857            .iter()
2858            .map(|tool| tool.tool.schema().name.clone())
2859            .collect();
2860        assert_eq!(after_tools, before_tools);
2861        let after_bridge = context
2862            .action_bridge
2863            .lock()
2864            .unwrap()
2865            .as_ref()
2866            .unwrap()
2867            .clone();
2868        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2869    }
2870
2871    #[test]
2872    fn reload_preparation_rejects_settings_changed_during_derivation() {
2873        struct RestoreConfigDir(Option<std::ffi::OsString>);
2874
2875        impl Drop for RestoreConfigDir {
2876            fn drop(&mut self) {
2877                match self.0.take() {
2878                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2879                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2880                }
2881            }
2882        }
2883
2884        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2885        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2886        let tmp = tempfile::tempdir().unwrap();
2887        let agent = tmp.path().join("agent");
2888        let cwd = tmp.path().join("project");
2889        let first_skill_dir = tmp.path().join("first-skills");
2890        let second_skill_dir = tmp.path().join("second-skills");
2891        std::fs::create_dir_all(&agent).unwrap();
2892        std::fs::create_dir_all(&cwd).unwrap();
2893        std::fs::write(
2894            agent.join("settings.json"),
2895            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2896        )
2897        .unwrap();
2898        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2899        let args = Args {
2900            trust_override: Some(false),
2901            no_extensions: true,
2902            no_prompt_templates: true,
2903            ..Args::default()
2904        };
2905
2906        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2907            std::fs::write(
2908                agent.join("settings.json"),
2909                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2910            )
2911            .unwrap();
2912        });
2913
2914        let error = result
2915            .err()
2916            .expect("settings mutation must fail preparation");
2917        assert!(error.contains("settings changed while reload inputs were being prepared"));
2918    }
2919
2920    #[tokio::test(flavor = "current_thread")]
2921    async fn reload_uses_frozen_settings_inputs_after_preparation() {
2922        struct RestoreConfigDir(Option<std::ffi::OsString>);
2923
2924        impl Drop for RestoreConfigDir {
2925            fn drop(&mut self) {
2926                match self.0.take() {
2927                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2928                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2929                }
2930            }
2931        }
2932
2933        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2934        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2935        let tmp = tempfile::tempdir().unwrap();
2936        let agent = tmp.path().join("agent");
2937        let cwd = tmp.path().join("project");
2938        let skill_dir = tmp.path().join("configured-skills");
2939        std::fs::create_dir_all(&agent).unwrap();
2940        std::fs::create_dir_all(&cwd).unwrap();
2941        std::fs::create_dir_all(&skill_dir).unwrap();
2942        std::fs::write(
2943            skill_dir.join("SKILL.md"),
2944            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2945        )
2946        .unwrap();
2947        std::fs::write(
2948            agent.join("settings.json"),
2949            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2950        )
2951        .unwrap();
2952        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2953
2954        let resolved = crate::provider::resolve(
2955            Some("anthropic"),
2956            Some(crate::provider::DEFAULT_MODEL_ID),
2957            None,
2958            Some("test-key"),
2959            None,
2960        )
2961        .unwrap();
2962        let args = Args {
2963            trust_override: Some(false),
2964            no_session: true,
2965            no_extensions: true,
2966            no_prompt_templates: true,
2967            no_context_files: true,
2968            system_prompt: Some("stable system prompt".into()),
2969            ..Args::default()
2970        };
2971        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2972
2973        let outcome = reload_extension_resources_inner(&harness, &context, || {
2974            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2975        })
2976        .await;
2977
2978        assert!(!outcome.summary.contains("Settings reload failed"));
2979        assert_eq!(
2980            outcome.summary,
2981            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2982        );
2983        let resources = harness.get_resources().await.unwrap();
2984        let skills = resources.skills.as_ref().unwrap();
2985        assert_eq!(skills.len(), 1);
2986        assert_eq!(skills[0].name, "frozen-skill");
2987    }
2988
2989    #[tokio::test]
2990    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2991        let tmp = tempfile::tempdir().unwrap();
2992        let skill_path = tmp.path().join("explicit-skill.md");
2993        std::fs::write(
2994            &skill_path,
2995            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2996        )
2997        .unwrap();
2998        let prompt_path = tmp.path().join("explicit-prompt.md");
2999        std::fs::write(
3000            &prompt_path,
3001            "---\ndescription: Explicit prompt\n---\nPrompt body",
3002        )
3003        .unwrap();
3004
3005        let args = Args {
3006            skill: vec![skill_path.clone()],
3007            prompt_template: vec![prompt_path.clone()],
3008            ..Args::default()
3009        };
3010        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3011        let prompt_paths =
3012            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3013        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3014        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3015
3016        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3017        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3018        assert_eq!(skills.skills[0].name, "explicit-skill");
3019
3020        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3021        assert_eq!(
3022            prompts.prompt_templates.len(),
3023            1,
3024            "{:?}",
3025            prompts.diagnostics
3026        );
3027        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3028    }
3029
3030    #[test]
3031    fn tool_policy_applies_to_rust_and_js_active_names() {
3032        let names = vec![
3033            "read".to_string(),
3034            "ask_user_question".to_string(),
3035            "write".to_string(),
3036        ];
3037
3038        let args = Args {
3039            tools: Some(vec!["read".into(), "ask_user_question".into()]),
3040            ..Args::default()
3041        };
3042        assert_eq!(
3043            filter_active_tool_names(names.clone(), &args),
3044            vec!["read", "ask_user_question"]
3045        );
3046
3047        let args = Args {
3048            exclude_tools: Some(vec!["ask_user_question".into()]),
3049            ..Args::default()
3050        };
3051        assert_eq!(
3052            filter_active_tool_names(names.clone(), &args),
3053            vec!["read", "write"]
3054        );
3055
3056        let args = Args {
3057            no_tools: true,
3058            ..Args::default()
3059        };
3060        assert!(filter_active_tool_names(names, &args).is_empty());
3061    }
3062
3063    #[test]
3064    fn select_ephemeral_when_no_session() {
3065        let args = Args {
3066            no_session: true,
3067            ..Args::default()
3068        };
3069        let cwd = Path::new("/tmp");
3070        assert!(matches!(
3071            select_session(&args, cwd),
3072            SessionSelection::Ephemeral
3073        ));
3074    }
3075
3076    #[test]
3077    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3078        let defaults = Args::default();
3079        assert!(resolve_project_trust(
3080            &defaults,
3081            Path::new("C:/definitely-not-a-project")
3082        ));
3083
3084        let denied = Args {
3085            trust_override: Some(false),
3086            ..Args::default()
3087        };
3088        assert!(!resolve_project_trust(
3089            &denied,
3090            Path::new("C:/definitely-not-a-project")
3091        ));
3092    }
3093
3094    #[tokio::test(flavor = "current_thread")]
3095    async fn build_preserves_the_supplied_startup_project_snapshot() {
3096        struct RestoreConfigDir(Option<std::ffi::OsString>);
3097
3098        impl Drop for RestoreConfigDir {
3099            fn drop(&mut self) {
3100                match self.0.take() {
3101                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3102                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3103                }
3104            }
3105        }
3106
3107        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3108        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3109        let tmp = tempfile::tempdir().unwrap();
3110        let agent = tmp.path().join("agent");
3111        let cwd = tmp.path().join("project");
3112        std::fs::create_dir_all(&agent).unwrap();
3113        std::fs::create_dir_all(&cwd).unwrap();
3114        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3115
3116        let resolved = crate::provider::resolve(
3117            Some("anthropic"),
3118            Some(crate::provider::DEFAULT_MODEL_ID),
3119            None,
3120            Some("test-key"),
3121            None,
3122        )
3123        .unwrap();
3124        let args = Args {
3125            trust_override: Some(false),
3126            no_session: true,
3127            no_tools: true,
3128            no_extensions: true,
3129            no_skills: true,
3130            no_prompt_templates: true,
3131            no_context_files: true,
3132            system_prompt: Some("test prompt".into()),
3133            ..Args::default()
3134        };
3135
3136        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3137
3138        assert_eq!(context.cwd, cwd);
3139        assert!(context.project_trusted);
3140    }
3141
3142    #[tokio::test(flavor = "current_thread")]
3143    async fn build_applies_the_name_flag_to_the_session() {
3144        struct RestoreConfigDir(Option<std::ffi::OsString>);
3145
3146        impl Drop for RestoreConfigDir {
3147            fn drop(&mut self) {
3148                match self.0.take() {
3149                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3150                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3151                }
3152            }
3153        }
3154
3155        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3156        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3157        let tmp = tempfile::tempdir().unwrap();
3158        let agent = tmp.path().join("agent");
3159        let cwd = tmp.path().join("project");
3160        std::fs::create_dir_all(&agent).unwrap();
3161        std::fs::create_dir_all(&cwd).unwrap();
3162        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3163
3164        let resolved = crate::provider::resolve(
3165            Some("anthropic"),
3166            Some(crate::provider::DEFAULT_MODEL_ID),
3167            None,
3168            Some("test-key"),
3169            None,
3170        )
3171        .unwrap();
3172        let args = Args {
3173            trust_override: Some(false),
3174            no_tools: true,
3175            no_extensions: true,
3176            no_skills: true,
3177            no_prompt_templates: true,
3178            no_context_files: true,
3179            system_prompt: Some("test prompt".into()),
3180            name: Some("smoke name".into()),
3181            session_dir: Some(cwd.join("sessions")),
3182            ..Args::default()
3183        };
3184
3185        let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3186        assert_eq!(
3187            harness.get_name().await.unwrap().as_deref(),
3188            Some("smoke name")
3189        );
3190    }
3191
3192    #[test]
3193    fn select_latest_for_continue_and_resume() {
3194        let args = Args {
3195            continue_session: true,
3196            ..Args::default()
3197        };
3198        let cwd = Path::new("/tmp");
3199        assert!(matches!(
3200            select_session(&args, cwd),
3201            SessionSelection::Latest
3202        ));
3203
3204        let args = Args {
3205            resume: true,
3206            ..Args::default()
3207        };
3208        assert!(matches!(
3209            select_session(&args, cwd),
3210            SessionSelection::Latest
3211        ));
3212    }
3213
3214    #[test]
3215    fn select_by_id_for_session_flag() {
3216        let args = Args {
3217            session: Some("01a02ece".into()),
3218            ..Args::default()
3219        };
3220        let cwd = Path::new("/tmp");
3221        assert!(matches!(
3222            select_session(&args, cwd),
3223            SessionSelection::ById { id } if id == "01a02ece"
3224        ));
3225    }
3226
3227    #[test]
3228    fn select_new_with_custom_dir() {
3229        let args = Args {
3230            session_dir: Some(PathBuf::from("/tmp/sess")),
3231            ..Args::default()
3232        };
3233        let cwd = Path::new("/tmp");
3234        match select_session(&args, cwd) {
3235            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
3236            other => panic!("expected New, got {other:?}"),
3237        }
3238    }
3239
3240    #[test]
3241    fn select_new_default_dir() {
3242        let args = Args::default();
3243        let cwd = Path::new("/proj");
3244        match select_session(&args, cwd) {
3245            SessionSelection::New { dir, .. } => {
3246                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
3247            }
3248            other => panic!("expected New, got {other:?}"),
3249        }
3250    }
3251
3252    #[test]
3253    fn default_session_dir_only_uses_rpi() {
3254        let tmp = tempfile::tempdir().unwrap();
3255        let cwd = tmp.path();
3256        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3257        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3258        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
3259        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
3260    }
3261
3262    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3263    async fn ephemeral_session_builds_roundtrips() {
3264        // Sanity: the ephemeral path produces a usable Session facade (the
3265        // harness build itself needs a provider; tested via the integration
3266        // path in tests/build.rs instead).
3267        let s = ephemeral_session();
3268        let leaf = s.get_leaf_id().await;
3269        assert!(leaf.is_ok());
3270    }
3271
3272    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
3273    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
3274}