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