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