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    let old_session = {
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
1446    };
1447    if let Some(old_b) = old_bridge {
1448        old_b.invalidate();
1449    }
1450    // Shutdown callbacks see stale host actions and run outside session locks.
1451    old_session.begin_shutdown();
1452
1453    // The session was swapped atomically above; expose its matching bridge.
1454    *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1455
1456    // ---- 3. resources_discover ("reload") over the fresh snapshot ----
1457    let discovered = extension_session
1458        .snapshot_arc()
1459        .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1460        .unwrap_or_default();
1461
1462    // ---- 4. Re-run the Part-A loaders (same precedence + --no-* gates) ----
1463    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1464    let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1465
1466    let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1467    let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1468    let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
1469    if !effective_args.no_skills {
1470        // JS discovery is backed by the session-long lazy Node host. Until
1471        // that host is swapped as part of a future full JS reload, preserve
1472        // the paths it contributed at startup across `/reload`.
1473        let js_paths: &[PathBuf] = &[];
1474        let package_paths: &[PathBuf] = &[];
1475        let dirs = append_reload_resource_paths(
1476            skill_base_dirs,
1477            &effective_args.skill,
1478            &discovered.skill_paths,
1479            js_paths,
1480            package_paths,
1481        );
1482        let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1483        skills = result.skills;
1484        skill_diags = result.diagnostics;
1485        resource_diags.extend(result.resource_diagnostics);
1486    }
1487
1488    let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1489    let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1490    if !effective_args.no_prompt_templates {
1491        let js_paths: &[PathBuf] = &[];
1492        let package_paths: &[PathBuf] = &[];
1493        let dirs = append_reload_resource_paths(
1494            prompt_base_dirs,
1495            &effective_args.prompt_template,
1496            &discovered.prompt_paths,
1497            js_paths,
1498            package_paths,
1499        );
1500        let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1501        prompt_templates = result.prompt_templates;
1502        prompt_diags = result.diagnostics;
1503        resource_diags.extend(result.resource_diagnostics);
1504    }
1505
1506    let context_files = if effective_args.no_context_files || !project_trusted {
1507        Vec::new()
1508    } else {
1509        let agent_dir = crate::config::agent_dir().ok();
1510        let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1511        load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
1512    };
1513    let context_block = format_project_context(&context_files);
1514
1515    let mut details: Vec<String> = Vec::new();
1516    if let Some(resolved) = &refreshed {
1517        warnings |= !resolved.compat_warnings.is_empty();
1518        details.extend(
1519            resolved
1520                .compat_warnings
1521                .iter()
1522                .map(|warning| format!("warning: models.json: {warning}")),
1523        );
1524    }
1525    if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
1526        warnings = true;
1527        // Collect formatted diagnostics for the caller to surface in its UI
1528        // instead of writing to stderr directly: the TUI owns the terminal,
1529        // and a raw `eprintln!` during a run would scribble over the
1530        // alternate-screen input row.
1531        for d in &skill_diags {
1532            details.push(format!(
1533                "warning: skill {} ({}): {}",
1534                d.path,
1535                d.code.as_str(),
1536                d.message
1537            ));
1538        }
1539        for d in &prompt_diags {
1540            details.push(format!(
1541                "warning: prompt template {} ({}): {}",
1542                d.path,
1543                d.code.as_str(),
1544                d.message
1545            ));
1546        }
1547        // Structured collisions: winner (kept) · loser (shadowed).
1548        for d in &resource_diags {
1549            if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1550                details.push(format!(
1551                    "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1552                    c.resource_type.as_str(),
1553                    c.name,
1554                    c.winner_path,
1555                    c.loser_path
1556                ));
1557            }
1558        }
1559    }
1560
1561    // ---- Re-compose the system prompt (same precedence as build) ----
1562    // Same working-state wording rule as `build`; a mid-session `/model` switch
1563    // re-composes the prompt on the next reload, which is the same staleness the
1564    // rest of this path already has (provider resolution is a startup concern).
1565    let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
1566    let mut context_paths = Vec::new();
1567    let base_prompt = match effective_args.system_prompt.as_deref() {
1568        Some(explicit) => explicit.to_string(),
1569        None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
1570            Some(path) => read_prompt_source(&path, &mut context_paths)
1571                .unwrap_or_else(|| default_system_prompt(&cwd_str, prior_reasoning_replayed)),
1572            None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1573        },
1574        None => default_system_prompt(&cwd_str, prior_reasoning_replayed),
1575    };
1576    let mut append_texts: Vec<String> = Vec::new();
1577    for extra in &effective_args.append_system_prompt {
1578        let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
1579        append_texts.push(text);
1580    }
1581    if effective_args.append_system_prompt.is_empty() {
1582        if let Some(path) = project_trusted
1583            .then(|| discover_append_system_prompt_file(&ctx.cwd))
1584            .flatten()
1585        {
1586            if let Some(text) = read_prompt_source(&path, &mut context_paths) {
1587                append_texts.push(text);
1588            }
1589        }
1590    }
1591    context_paths.extend(
1592        context_files
1593            .iter()
1594            .map(|file| file.path.to_string_lossy().into_owned()),
1595    );
1596    let append_join = if append_texts.is_empty() {
1597        None
1598    } else {
1599        Some(append_texts.join("\n\n"))
1600    };
1601    let system_prompt = compose_system_prompt(
1602        Some(&base_prompt),
1603        &[],
1604        if context_block.is_empty() {
1605            None
1606        } else {
1607            Some(&context_block)
1608        },
1609        append_join.as_deref(),
1610    );
1611
1612    // ---- Rebuild the emitter (TeeEmitter over fresh ExtensionEmitter) ----
1613    let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1614        Some(snapshot) => {
1615            let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1616            Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1617        }
1618        None => ctx.broadcast.clone(),
1619    };
1620
1621    // ---- 5. Push the rebuilt state into the live harness via the B5d setters ----
1622    let resources = AgentHarnessResources {
1623        context_files: context_paths,
1624        skills: if skills.is_empty() {
1625            None
1626        } else {
1627            Some(skills.clone())
1628        },
1629        prompt_templates: if prompt_templates.is_empty() {
1630            None
1631        } else {
1632            Some(prompt_templates.clone())
1633        },
1634    };
1635    let _ = harness.set_system_prompt(Some(system_prompt)).await;
1636    let _ = harness.set_resources(resources).await;
1637    let _ = harness.set_agent_emitter(Some(emitter)).await;
1638    let _ = harness
1639        .set_models(build_models_with_extensions_for_reload(
1640            &refreshed
1641                .as_ref()
1642                .map(|resolved| resolved.providers.clone())
1643                .unwrap_or_else(|| {
1644                    ctx.providers
1645                        .read()
1646                        .unwrap_or_else(|error| error.into_inner())
1647                        .clone()
1648                }),
1649            &extension_session,
1650            ctx.runtime.clone(),
1651        ))
1652        .await;
1653    let _ = harness
1654        .set_provider_hooks(
1655            rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1656                .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1657        )
1658        .await;
1659
1660    if let Some(resolved) = &refreshed {
1661        *ctx.providers
1662            .write()
1663            .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
1664        let catalog = crate::provider::authenticated_catalog(resolved);
1665        fresh_bridge
1666            .clone_host()
1667            .replace_model_catalog(catalog.clone());
1668        *ctx.catalog
1669            .write()
1670            .unwrap_or_else(|error| error.into_inner()) = catalog;
1671        // Refresh the public descriptor. Credentials remain in the newly bound
1672        // provider instances installed above and never enter the lane model.
1673        let _ = harness.lane("main").set_model(resolved.model.clone()).await;
1674    }
1675
1676    // Re-merge extension tools (a reloaded plugin may have added/removed a
1677    // tool). The built-in set is rebuilt from scratch + extension tools merged
1678    // on top, mirroring `build`.
1679    let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1680    let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1681    let mut tools = build_tools(&tool_ctx, &effective_args);
1682    merge_extension_tools(
1683        &mut tools,
1684        &extension_session,
1685        &effective_args,
1686        &ctx.tool_context,
1687    );
1688    let mut active = active_tool_names(&tools, &effective_args);
1689    active = filter_active_tool_names(active, &effective_args);
1690    let _ = harness.set_tools(tools, Some(active)).await;
1691
1692    let summary = format!(
1693        "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1694        extension_session.loaded_paths().len(),
1695        skills.len(),
1696        prompt_templates.len(),
1697    );
1698    ReloadOutcome {
1699        summary,
1700        had_warnings: warnings,
1701        details,
1702        // Reload completed; any diagnostics here are soft (shadowing / name
1703        // validation / skipped optional resources), not hard failures.
1704        had_errors: false,
1705    }
1706}
1707
1708#[derive(Clone, Debug, PartialEq, Eq)]
1709struct ReloadSettingsFields {
1710    skill_dirs: Option<Vec<String>>,
1711    prompt_dirs: Option<Vec<String>>,
1712    extension_dirs: Option<Vec<String>>,
1713}
1714
1715impl From<crate::settings::Settings> for ReloadSettingsFields {
1716    fn from(settings: crate::settings::Settings) -> Self {
1717        Self {
1718            skill_dirs: settings.skill_dirs,
1719            prompt_dirs: settings.prompt_dirs,
1720            extension_dirs: settings.extension_dirs,
1721        }
1722    }
1723}
1724
1725#[derive(Clone, Debug, PartialEq, Eq)]
1726struct ReloadSettingsSnapshot {
1727    global: ReloadSettingsFields,
1728    project: Option<ReloadSettingsFields>,
1729}
1730
1731fn reload_reads_settings(args: &Args) -> bool {
1732    !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
1733}
1734
1735/// Strictly read the settings fields consumed while preparing a reload. The
1736/// snapshot intentionally excludes UI/model fields that `/reload` does not
1737/// use, so an unrelated settings save does not invalidate the operation.
1738fn load_reload_settings_snapshot(
1739    args: &Args,
1740    cwd: &Path,
1741    project_trusted: bool,
1742) -> Result<Option<ReloadSettingsSnapshot>, String> {
1743    if !reload_reads_settings(args) {
1744        return Ok(None);
1745    }
1746    let global = crate::settings::load_settings()
1747        .map_err(|error| format!("could not load global settings: {error}"))?
1748        .into();
1749    let project = if project_trusted {
1750        crate::settings::load_active_project_settings(cwd)
1751            .map_err(|error| format!("could not load project settings: {error}"))?
1752            .map(|(_, settings)| settings.into())
1753    } else {
1754        None
1755    };
1756    Ok(Some(ReloadSettingsSnapshot { global, project }))
1757}
1758
1759/// Validate every settings document that this reload will consume before
1760/// replacing any live extension, bridge, or harness resource.
1761#[cfg(test)]
1762fn validate_settings_for_reload(
1763    args: &Args,
1764    cwd: &Path,
1765    project_trusted: bool,
1766) -> Result<(), String> {
1767    load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1768}
1769
1770fn prepare_reload_inputs(
1771    args: &Args,
1772    cwd: &Path,
1773    project_trusted: bool,
1774) -> Result<PreparedReloadInputs, String> {
1775    prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1776}
1777
1778fn prepare_reload_inputs_inner<F>(
1779    args: &Args,
1780    cwd: &Path,
1781    project_trusted: bool,
1782    before_verify: F,
1783) -> Result<PreparedReloadInputs, String>
1784where
1785    F: FnOnce(),
1786{
1787    let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1788
1789    let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1790        Vec::new()
1791    } else if args.project_only {
1792        project_extension_dirs(cwd)
1793    } else if project_trusted {
1794        extension_dirs(cwd)
1795    } else {
1796        global_extension_dirs()
1797    };
1798    if !args.no_extensions {
1799        extension_dirs.extend(args.extensions_dir.iter().cloned());
1800    }
1801
1802    let skill_base_dirs = if args.no_skills {
1803        Vec::new()
1804    } else if args.dev_local_only || args.project_only {
1805        project_skill_dirs(cwd)
1806    } else if project_trusted {
1807        skill_dirs(cwd)
1808    } else {
1809        global_skill_dirs()
1810    };
1811
1812    let prompt_base_dirs = if args.no_prompt_templates {
1813        Vec::new()
1814    } else if args.dev_local_only || args.project_only {
1815        project_prompt_template_dirs(cwd)
1816    } else if project_trusted {
1817        prompt_template_dirs(cwd)
1818    } else {
1819        global_prompt_template_dirs()
1820    };
1821
1822    before_verify();
1823    let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1824    if settings_before != settings_after {
1825        return Err(
1826            "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1827        );
1828    }
1829
1830    Ok(PreparedReloadInputs {
1831        extension_dirs,
1832        skill_base_dirs,
1833        prompt_base_dirs,
1834    })
1835}
1836
1837/// `build_models_with_extensions` for the reload path: the resolved provider
1838/// runtimes (NOT `resolved` — the reload context carries the `Arc<dyn Provider>`
1839/// list directly, since provider/auth did not change) first, then one
1840/// `PluggableProvider` per registered extension provider in the fresh session.
1841fn build_models_with_extensions_for_reload(
1842    providers: &[Arc<dyn Provider>],
1843    extension_session: &ExtensionSession,
1844    runtime: tokio::runtime::Handle,
1845) -> Vec<Arc<dyn Provider>> {
1846    let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
1847    let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1848    for provider in pluggable {
1849        // Declared packages must enter through credential-bound factories.
1850        // Falling back to a raw adapter would reintroduce packages rejected by
1851        // auth filtering and would discard their configured model snapshot.
1852        if extension_session
1853            .snapshot()
1854            .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
1855        {
1856            continue;
1857        }
1858        if !models.iter().any(|known| known.id() == provider.id()) {
1859            models.push(provider);
1860        }
1861    }
1862    models
1863}
1864
1865/// Diagnostic for registered TUI renderers. All three renderer kinds are
1866/// consumed by the interactive TUI's JSON component adapter; this line remains
1867/// useful under `--verbose` for extension authors.
1868fn report_deferred_renderers(session: &ExtensionSession) {
1869    let Some(snap) = session.snapshot_arc() else {
1870        return;
1871    };
1872    let all = snap.renderers();
1873    let markdown = all
1874        .iter()
1875        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1876        .count();
1877    let message = all
1878        .iter()
1879        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1880        .count();
1881    let entry = all
1882        .iter()
1883        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1884        .count();
1885    if markdown + message + entry == 0 {
1886        return;
1887    }
1888    eprintln!(
1889        "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1890        markdown, message, entry
1891    );
1892}
1893
1894/// A harness-build error.
1895#[derive(Debug, thiserror::Error)]
1896pub enum BuildError {
1897    #[error("Could not create the session directory: {0}")]
1898    SessionDir(String),
1899    #[error("Could not open the saved session: {0}")]
1900    SessionOpen(String),
1901    #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1902    SessionNotFound { requested: String, dir: String },
1903    #[error("Could not build the harness: {0}")]
1904    HarnessCreate(String),
1905}
1906
1907/// Keep configured factories (including package-specific auth and catalogs)
1908/// first, then append legacy plugin registrations that have no runtime yet.
1909fn build_models_with_extensions(
1910    resolved: &ResolvedModel,
1911    extension_session: &ExtensionSession,
1912    runtime: tokio::runtime::Handle,
1913) -> Vec<Arc<dyn Provider>> {
1914    build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
1915}
1916
1917/// Resolve project resource loading without prompting. Explicit CLI overrides
1918/// win, then a stored decision; projects with no decision load by default.
1919pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1920    if let Some(override_value) = args.trust_override {
1921        return override_value;
1922    }
1923    crate::config::project_trust_decision(cwd)
1924        .ok()
1925        .flatten()
1926        .unwrap_or(true)
1927}
1928
1929/// Resolve the extension dirs to scan and load the cdylib plugins, returning
1930/// the loaded session guard (keeps the `Library` handles alive for the harness
1931/// lifetime). Scan order: configured project paths, project `.rpi/extensions`,
1932/// configured/global conventional paths, then any
1933/// `--extensions-dir` flags (scanned after the defaults — `args.rs`).
1934/// Diagnostics are a no-op sink for now; load skips/ABI mismatches surface via
1935/// the `--verbose` summary.
1936pub(crate) fn load_extensions(
1937    args: &Args,
1938    cwd: &Path,
1939    project_trusted: bool,
1940    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1941) -> ExtensionSession {
1942    if args.no_extensions {
1943        return ExtensionSession::none();
1944    }
1945    let mut dirs = if args.dev_local_only {
1946        Vec::new()
1947    } else if args.project_only {
1948        project_extension_dirs(cwd)
1949    } else if project_trusted {
1950        extension_dirs(cwd)
1951    } else {
1952        global_extension_dirs()
1953    };
1954    dirs.extend(args.extensions_dir.iter().cloned());
1955    load_extensions_from_dirs(args, &dirs, action_bridge)
1956}
1957
1958fn load_extensions_from_dirs(
1959    args: &Args,
1960    dirs: &[PathBuf],
1961    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1962) -> ExtensionSession {
1963    let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1964    // B5a: the action bridge is cloned into every loaded plugin's vtable
1965    // `user_data` so post-register `runtime_action` calls recover the harness
1966    // host from any thread. The call site already gates `load_extensions` behind
1967    // `!no_extensions` and threads `Some(bridge)`; `None` is only passed by the
1968    // `--no-extensions` branch (which calls `ExtensionSession::none()` directly)
1969    // and tests. Explicit `--extension`/`-e` files load after the dirs.
1970    rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1971}
1972
1973/// Merge the loaded extension tools into the built-in set. An extension tool
1974/// overrides a same-named built-in; first-extension-wins across plugins is
1975/// already guaranteed by the registry (`register_tool` keeps the prior). The
1976/// explicit `--tools` allowlist / `--exclude-tools` denylist apply to the
1977/// merged set (the built-ins were already filtered in [`build_tools`]).
1978fn merge_extension_tools(
1979    tools: &mut Vec<HarnessTool>,
1980    session: &ExtensionSession,
1981    args: &Args,
1982    tool_context: &rpi_extensions::ToolCallContext,
1983) {
1984    let Some(snapshot) = session.snapshot() else {
1985        return;
1986    };
1987    for et in snapshot.tools() {
1988        let name = &et.tool.name;
1989        if !tool_name_allowed(name, args) {
1990            continue;
1991        }
1992        let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
1993            .with_context(tool_context.clone());
1994        let harness_tool = HarnessTool::new(Arc::new(adapter));
1995        match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1996            Some(slot) => *slot = harness_tool,
1997            None => tools.push(harness_tool),
1998        }
1999    }
2000}
2001
2002/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
2003/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
2004/// resolution in `createAgentSession`.
2005/// Default bash timeout: 120s when the model doesn't pass one (prevents a
2006/// forgotten `timeout` from hanging the run forever — the "卡住" report).
2007/// `RPI_BASH_TIMEOUT` overrides; a model-supplied timeout always wins.
2008pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2009    use rpi_tools::tools::bash::BashToolOptions;
2010    let default = std::env::var("RPI_BASH_TIMEOUT")
2011        .ok()
2012        .and_then(|v| v.parse::<f64>().ok())
2013        .unwrap_or(120.0);
2014    // Saved user defaults, matching how `defaultTools` is read in `build_tools`.
2015    // `shellCommandPrefix` is prepended to every command (upstream's key for
2016    // alias/`shopt` setup). Native's `shellPath` is *not* wired: neither
2017    // `BashToolOptions` nor `ShellCaptureOptions` carries a shell binary today, so
2018    // honouring it means plumbing one through pi-tools first.
2019    let settings = crate::settings::load_settings().unwrap_or_default();
2020    BashToolOptions {
2021        command_prefix: settings.shell_command_prefix.clone(),
2022        default_timeout: Some(default),
2023        prepare: None,
2024    }
2025}
2026
2027fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2028    if args.no_tools {
2029        return Vec::new();
2030    }
2031    // Keep the coding tools aligned with Pi and expose rpi's read-only docs
2032    // lookup as a default assistant capability.
2033    //
2034    // Replay policy (native `ToolDeclaration.replay`): only tools that are
2035    // genuinely free of side effects may be re-run when a crash leaves their
2036    // outcome unknown — everything else must stay `Never`, because replaying
2037    // `bash`/`write`/`edit` would duplicate a side effect. `HarnessTool::new`
2038    // already defaults to `Never`, so this is opt-in per read-only tool.
2039    let mut all: Vec<(&'static str, HarnessTool)> = vec![
2040        (
2041            "read",
2042            HarnessTool::new(create_read_tool(
2043                ctx,
2044                Some(rpi_tools::tools::read::ReadToolOptions {
2045                    image_processor: Some(Arc::new(
2046                        rpi_tools::image_processing::InlineImageProcessor,
2047                    )),
2048                    ..Default::default()
2049                }),
2050            ))
2051            .with_replay(ToolReplay::Safe),
2052        ),
2053        (
2054            "bash",
2055            HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2056        ),
2057        ("edit", HarnessTool::new(create_edit_tool(ctx))),
2058        ("write", HarnessTool::new(create_write_tool(ctx))),
2059        (
2060            "docs",
2061            HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2062        ),
2063    ];
2064
2065    // Add PowerShell tool on Windows
2066    #[cfg(target_os = "windows")]
2067    all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2068
2069    // Apply default_tools from settings (if no CLI override)
2070    if args.tools.is_none() {
2071        if let Ok(settings) = crate::settings::load_settings() {
2072            if let Some(default_tools) = &settings.default_tools {
2073                all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2074            }
2075        }
2076    }
2077
2078    // `--no-builtin-tools` disables the built-in set but would keep
2079    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
2080    // here. We honor it by clearing the built-ins.
2081    if args.no_builtin_tools {
2082        all.clear();
2083    }
2084
2085    // Allowlist (`--tools`): keep only named built-ins.
2086    if let Some(allow) = &args.tools {
2087        all.retain(|(name, _)| allow.iter().any(|a| a == name));
2088    }
2089    // Denylist (`--exclude-tools`): drop named tools.
2090    if let Some(deny) = &args.exclude_tools {
2091        all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2092    }
2093
2094    all.into_iter().map(|(_, t)| t).collect()
2095}
2096
2097/// Resolve the active tool names from the constructed tools when no explicit
2098/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
2099/// active.
2100fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2101    filter_active_tool_names(
2102        tools.iter().map(|tool| tool.tool.schema().name.clone()),
2103        args,
2104    )
2105}
2106
2107/// Whether a tool name survives the command-line tool policy. Keep this check
2108/// centralized so extension-provided tools honor the same policy as built-ins.
2109pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2110    if args.no_tools {
2111        return false;
2112    }
2113    if args
2114        .tools
2115        .as_ref()
2116        .is_some_and(|allow| !allow.iter().any(|value| value == name))
2117    {
2118        return false;
2119    }
2120    if args
2121        .exclude_tools
2122        .as_ref()
2123        .is_some_and(|deny| deny.iter().any(|value| value == name))
2124    {
2125        return false;
2126    }
2127    true
2128}
2129
2130pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2131where
2132    I: IntoIterator<Item = String>,
2133{
2134    names
2135        .into_iter()
2136        .filter(|name| tool_name_allowed(name, args))
2137        .collect()
2138}
2139
2140/// Build the `Session` facade for the chosen selection.
2141async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2142    match selection {
2143        SessionSelection::Ephemeral => Ok(ephemeral_session()),
2144        SessionSelection::New { dir, .. } => {
2145            // Ensure the sessions directory exists, then create a fresh JSONL
2146            // session file inside it.
2147            std::fs::create_dir_all(dir)
2148                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2149            let session = create_jsonl_session(dir, cwd)
2150                .await
2151                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2152            Ok(session)
2153        }
2154        SessionSelection::Latest { .. }
2155        | SessionSelection::ById { .. }
2156        | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2157        SessionSelection::Fork { source, dir } => fork_session_at_launch(source, dir, cwd).await,
2158    }
2159}
2160
2161/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
2162/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
2163/// match the request, then open the matched file and wrap it in a `Session`
2164/// facade. The restored transcript renders into the TUI at startup and the
2165/// harness continues appending to the same file.
2166async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2167    // `list_typed` is newest-first; `Latest` takes the head, `ById` matches
2168    // the id exactly or by file-name containment (so `--session 01a02…` or a
2169    // partial id works, mirroring the TS id/path matching).
2170    match selection {
2171        SessionSelection::Latest { dir } => {
2172            let metas = list_session_metadata_in_dir(cwd, dir).await?;
2173            let Some(meta) = first_resumable_session(&metas) else {
2174                return Err(BuildError::SessionNotFound {
2175                    requested: "the most recent session".to_string(),
2176                    dir: dir.display().to_string(),
2177                });
2178            };
2179            open_session(meta, cwd).await
2180        }
2181        SessionSelection::ById { id, dir } => open_session_by_id_in_dir(id, cwd, dir)
2182            .await
2183            .map_err(|e| match e {
2184                OpenError::NotFound { requested } => BuildError::SessionNotFound {
2185                    requested,
2186                    dir: dir.display().to_string(),
2187                },
2188                OpenError::Other(msg) => BuildError::SessionOpen(msg),
2189            }),
2190        SessionSelection::ByExactId { id, dir } => {
2191            // Exact id match only (pi `--session-id`): restore when the
2192            // session exists, else create a fresh one under the selected dir.
2193            let metas = list_session_metadata_in_dir(cwd, dir).await?;
2194            if let Some(meta) = metas.iter().find(|m| m.id == *id) {
2195                return open_session(meta, cwd).await;
2196            }
2197            std::fs::create_dir_all(dir)
2198                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2199            create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2200                .await
2201                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2202        }
2203        _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
2204    }
2205}
2206
2207/// `--fork <path|id>`: open the source session, fork it into a new JSONL
2208/// session (records the parent id), and start in the fork.
2209async fn fork_session_at_launch(
2210    source: &str,
2211    dir: &Path,
2212    cwd: &str,
2213) -> Result<Session, BuildError> {
2214    use rpi_harness::session::jsonl::{
2215        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2216    };
2217    use rpi_harness::session::types::{ForkOptions, SessionStorage};
2218    use rpi_tools::FileSystem;
2219
2220    let dir = dir.to_path_buf();
2221    std::fs::create_dir_all(&dir)
2222        .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2223    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2224    let fs: Arc<dyn FileSystem> = env.clone();
2225    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2226        fs: fs.clone(),
2227        sessions_root: dir.to_string_lossy().into_owned(),
2228        clock: Arc::new(SystemClock),
2229        ids: Arc::new(DefaultIdGenerator::new()),
2230    });
2231    let metas = repo
2232        .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2233        .await
2234        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2235    let source_meta = metas
2236        .iter()
2237        .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2238        .ok_or_else(|| BuildError::SessionNotFound {
2239            requested: format!("--fork {source}"),
2240            dir: dir.display().to_string(),
2241        })?;
2242    let fork_storage = repo
2243        .fork_typed(
2244            source_meta,
2245            &JsonlSessionCreateOptions {
2246                id: None,
2247                parent_session_id: Some(source_meta.id.clone()),
2248                cwd: cwd.to_string(),
2249                metadata: None,
2250            },
2251            &ForkOptions::default(),
2252        )
2253        .await
2254        .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2255    claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2256    let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2257    Ok(Session::new(storage_arc, None))
2258}
2259
2260/// Errors from [`open_session_by_id`], split so the CLI can map them to
2261/// [`BuildError`] while the TUI can surface a friendlier note.
2262pub enum OpenError {
2263    /// No session matched the request.
2264    NotFound { requested: String },
2265    /// The match existed but could not be opened/parsed.
2266    Other(String),
2267}
2268
2269impl std::fmt::Display for OpenError {
2270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2271        match self {
2272            OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2273            // `Other` carries the bare reason (see `open_session_by_id`); the
2274            // prefix lives here so the TUI and the CLI's
2275            // `BuildError::SessionOpen` wrapper each render it exactly once
2276            // (wrapping the already-prefixed `BuildError` string doubled it).
2277            OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2278        }
2279    }
2280}
2281
2282/// List the JSONL session metadata under the default session dir, newest
2283/// first. Shared by startup restore and the TUI `/session` hot-switch.
2284pub async fn list_session_metadata(
2285    cwd: &str,
2286) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2287    let dir = default_session_dir(Path::new(cwd));
2288    list_session_metadata_in_dir(cwd, &dir).await
2289}
2290
2291pub(crate) async fn list_session_metadata_in_dir(
2292    cwd: &str,
2293    dir: &Path,
2294) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2295    use rpi_harness::session::jsonl::{
2296        JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2297    };
2298    use rpi_tools::FileSystem;
2299
2300    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2301    let fs: Arc<dyn FileSystem> = env.clone();
2302    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2303        fs: fs.clone(),
2304        sessions_root: dir.to_string_lossy().into_owned(),
2305        clock: Arc::new(SystemClock),
2306        ids: Arc::new(DefaultIdGenerator::new()),
2307    });
2308    repo.list_typed(&JsonlSessionListOptions::default())
2309        .await
2310        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2311}
2312
2313/// A cheap, display-oriented summary of one JSONL session file.
2314///
2315/// The session pickers (`-r` and `/session`) need enough context to tell saved
2316/// sessions apart. The header-only [`JsonlSessionMetadata`] carries no name and
2317/// no transcript, so a list built from it renders as a wall of identical
2318/// `2026-09-25T15-05-15-847Z_…` file names — including the many header-only
2319/// sessions an abandoned launch leaves behind. This reads just the head (and a
2320/// small tail, for a late `--name`/`/name` fact) of the file to recover:
2321///
2322/// - the session display name, when one was recorded;
2323/// - the first user prompt, single-lined and truncated, for a readable label;
2324/// - whether the file is header-only (nothing to resume).
2325///
2326/// It intentionally does **not** parse the whole log: sessions can be tens of
2327/// megabytes, and the picker runs over every session at startup.
2328#[derive(Debug, Clone, Default, PartialEq, Eq)]
2329pub struct SessionSummary {
2330    /// The session display name (`--name` / `/name`), if one was recorded.
2331    pub name: Option<String>,
2332    /// The first user prompt, single-lined and trimmed to a label-sized length.
2333    pub preview: Option<String>,
2334    /// True when the file holds only its header (a session with no content).
2335    pub empty: bool,
2336    /// On-disk size in bytes (a rough content-volume hint for the picker).
2337    pub bytes: u64,
2338}
2339
2340/// Bytes read from the head of a session file to recover a name + first prompt.
2341const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2342/// Bytes read from the tail of a session file to recover a late name fact.
2343const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2344/// Longest preview retained (characters, not bytes).
2345const SUMMARY_PREVIEW_CHARS: usize = 80;
2346
2347/// Summarize one session file for the pickers. Best-effort: an unreadable or
2348/// malformed file yields a summary with `empty: false` and no label, so it is
2349/// still selectable rather than silently dropped.
2350pub fn summarize_session_file(path: &Path) -> SessionSummary {
2351    use std::io::{Read, Seek, SeekFrom};
2352
2353    let Ok(mut file) = std::fs::File::open(path) else {
2354        return SessionSummary::default();
2355    };
2356    let Ok(size) = file.metadata().map(|m| m.len()) else {
2357        return SessionSummary::default();
2358    };
2359    if size == 0 {
2360        return SessionSummary {
2361            name: None,
2362            preview: None,
2363            empty: true,
2364            bytes: 0,
2365        };
2366    }
2367
2368    // Head: the header line (for the empty check) + a name fact + the first user
2369    // prompt. The whole window is scanned; it is already in memory.
2370    let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2371    let head_len = file.read(&mut head).unwrap_or(0);
2372    head.truncate(head_len);
2373    let head = String::from_utf8_lossy(&head);
2374    let mut lines: Vec<&str> = head.lines().collect();
2375    let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2376    // A header-only file is exactly the header plus an optional trailing `\n`.
2377    let empty = (size as usize) <= header_len + 1;
2378    // When the window stopped short of the file end, its final line is partial:
2379    // drop it so a half-read message can never be mistaken for a complete one.
2380    if (head_len as u64) < size && !lines.is_empty() {
2381        lines.pop();
2382    }
2383
2384    let mut summary = SessionSummary {
2385        name: None,
2386        preview: None,
2387        empty,
2388        bytes: size,
2389    };
2390    for line in lines {
2391        apply_summary_line(line, &mut summary);
2392    }
2393
2394    // Tail: a name set late in the session lives after the head window. Read the
2395    // last window and scan only complete lines so a torn/partial first line is
2396    // ignored. Only do this when the file is larger than the head window.
2397    if size > SUMMARY_HEAD_BYTES
2398        && file
2399            .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2400            .is_ok()
2401    {
2402        let mut tail = Vec::new();
2403        if file.read_to_end(&mut tail).is_ok() {
2404            let tail = String::from_utf8_lossy(&tail);
2405            let mut tail_lines = tail.lines();
2406            // Drop the possibly-partial first line of the window.
2407            tail_lines.next();
2408            for line in tail_lines {
2409                if let Some(name) = name_from_line(line) {
2410                    summary.name = name;
2411                }
2412            }
2413        }
2414    }
2415    summary
2416}
2417
2418/// Extract the name from a `{"kind":"fact","fact":"name","name":…}` line.
2419/// Returns `Some(None)` for an explicit `name: null` (the fact was cleared).
2420fn name_from_line(line: &str) -> Option<Option<String>> {
2421    let value: serde_json::Value = serde_json::from_str(line).ok()?;
2422    if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2423        return None;
2424    }
2425    if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2426        return None;
2427    }
2428    Some(match value.get("name") {
2429        Some(serde_json::Value::String(name)) => Some(name.clone()),
2430        _ => None,
2431    })
2432}
2433
2434/// Fold one JSONL line into `summary`: record a name fact, or capture the first
2435/// user prompt as the preview.
2436fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2437    if let Some(name) = name_from_line(line) {
2438        summary.name = name;
2439        return;
2440    }
2441    if summary.preview.is_some() {
2442        return;
2443    }
2444    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2445        return;
2446    };
2447    if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2448        return;
2449    }
2450    if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2451        return;
2452    }
2453    let Some(message) = value.get("message") else {
2454        return;
2455    };
2456    if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2457        return;
2458    }
2459    let text = match message.get("content") {
2460        Some(serde_json::Value::String(text)) => Some(text.as_str()),
2461        Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2462            (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2463                .then(|| block.get("text").and_then(|v| v.as_str()))
2464                .flatten()
2465        }),
2466        _ => None,
2467    };
2468    let Some(text) = text else { return };
2469    let collapsed = text
2470        .lines()
2471        .map(str::trim)
2472        .filter(|line| !line.is_empty())
2473        .collect::<Vec<_>>()
2474        .join(" ");
2475    if collapsed.is_empty() {
2476        return;
2477    }
2478    summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2479}
2480
2481/// The human label for a session row: its recorded name, else the first user
2482/// prompt, else a marker that says the file has no transcript to show. Shared
2483/// by the startup picker and the in-TUI `/session` selector.
2484pub fn session_display_label(summary: &SessionSummary) -> String {
2485    if let Some(name) = summary.name.as_deref().map(str::trim) {
2486        if !name.is_empty() {
2487            return name.to_string();
2488        }
2489    }
2490    match summary.preview.as_deref().map(str::trim) {
2491        Some(preview) if !preview.is_empty() => preview.to_string(),
2492        _ => "(no messages)".to_string(),
2493    }
2494}
2495
2496/// Compact byte count (1.2MB / 34KB / 512B), shared by the startup picker and
2497/// the in-TUI `/session` selector.
2498pub fn format_session_bytes(bytes: u64) -> String {
2499    if bytes >= 1_048_576 {
2500        format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2501    } else if bytes >= 1024 {
2502        format!("{}KB", bytes / 1024)
2503    } else {
2504        format!("{bytes}B")
2505    }
2506}
2507
2508/// The first 8 characters of a session id, enough to disambiguate two rows that
2509/// share a timestamp without shouting the whole UUID.
2510pub fn short_session_id(id: &str) -> &str {
2511    &id[..id.len().min(8)]
2512}
2513
2514/// Synchronously list every JSONL session file under `dir`, newest-first by
2515/// modification time.
2516///
2517/// The repo's [`list_session_metadata`] is async and scans one cwd-encoded
2518/// subdirectory level; the in-TUI `/session` selector runs on the blocking key
2519/// thread and cannot await it, so this walks the same layout directly. Both
2520/// `.jsonl` files directly under `dir` and one level of subdirectories are
2521/// accepted.
2522pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2523    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2524    let mut visit = |path: PathBuf| {
2525        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2526            return;
2527        }
2528        let modified = std::fs::metadata(&path)
2529            .and_then(|m| m.modified())
2530            .unwrap_or(std::time::UNIX_EPOCH);
2531        files.push((path, modified));
2532    };
2533    if let Ok(entries) = std::fs::read_dir(dir) {
2534        for entry in entries.flatten() {
2535            let path = entry.path();
2536            if path.is_dir() {
2537                if let Ok(inner) = std::fs::read_dir(&path) {
2538                    for child in inner.flatten() {
2539                        visit(child.path());
2540                    }
2541                }
2542            } else {
2543                visit(path);
2544            }
2545        }
2546    }
2547    files.sort_by(|a, b| b.1.cmp(&a.1));
2548    files.into_iter().map(|(path, _)| path).collect()
2549}
2550
2551/// Pick the session `--continue`/`-c` should open: the newest session that has
2552/// content (a header-only session resumes to an empty transcript). Falls back to
2553/// the newest overall when every session is empty, and `None` only when the
2554/// list is empty.
2555fn first_resumable_session(
2556    metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2557) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2558    metas
2559        .iter()
2560        .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2561        .or_else(|| metas.first())
2562}
2563
2564/// Open a session whose id matches exactly or by file-name containment
2565/// (so `--session 01a02…` / a partial id / a full file name all work). The
2566/// TUI `/session` hot-switch calls this with the selector's item value.
2567pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2568    let dir = default_session_dir(Path::new(cwd));
2569    open_session_by_id_in_dir(id, cwd, &dir).await
2570}
2571
2572pub(crate) async fn open_session_by_id_in_dir(
2573    id: &str,
2574    cwd: &str,
2575    dir: &Path,
2576) -> Result<Session, OpenError> {
2577    let metas = list_session_metadata_in_dir(cwd, dir)
2578        .await
2579        .map_err(|e| OpenError::Other(e.to_string()))?;
2580    let Some(meta) = metas
2581        .iter()
2582        .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2583    else {
2584        return Err(OpenError::NotFound {
2585            requested: format!("session {id}"),
2586        });
2587    };
2588    open_session(meta, cwd).await.map_err(|e| {
2589        OpenError::Other(match e {
2590            // `open_session` renders as "Could not open the saved session:
2591            // <reason>" via `BuildError`. Carry the bare reason so it is not
2592            // prefixed twice (once here, once by the CLI/TUI).
2593            BuildError::SessionOpen(reason) => reason,
2594            other => other.to_string(),
2595        })
2596    })
2597}
2598
2599/// Publish the active session's id to plugins, on both channels that carry it.
2600///
2601/// Called on **every** activation of a session — the startup build AND every
2602/// in-process swap (`switch_to_session`, `fork_session`, and the plugin
2603/// `NewSession`/`Fork`/`SwitchSession` runtime actions all route through
2604/// [`set_active_session`]). Two things must stay in step:
2605///
2606/// - `ToolCallContext` — read per plugin **tool** call, which is why a stale
2607///   value there made a plugin's `todo`-style state follow the wrong session.
2608/// - `RPI_SESSION_ID` — read per **turn** by the provider hooks, which is the
2609///   only channel an observer gets for a pure-chat run. Process-global, which is
2610///   exactly the scope of "the session this process is serving".
2611///
2612/// The write overwrites unconditionally, deliberately. An inherited value is not
2613/// an override: `rpi` spawning `rpi` would otherwise hand the child the parent's
2614/// session id, and the child — which is *serving its own* session — would report
2615/// it forever. (Children no longer inherit the variable at all; see
2616/// `rpi_tools::HOST_PRIVATE_ENV`. This overwrite is the second line of defence,
2617/// for any other way a value can arrive pre-set.)
2618///
2619/// An embedder that genuinely wants to pin the id can revert to the old
2620/// behaviour by setting the variable *after* this runs; there is no supported
2621/// way to pin it before, because a session that does not match its own id is
2622/// never what the caller wants.
2623fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2624    std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2625    tool_context.set_session_id(id);
2626}
2627
2628/// Swap the session the harness serves, republishing the identity to plugins.
2629///
2630/// The single funnel for an in-process session change. Prefer this over calling
2631/// `AgentHarness::set_session` directly: the harness holds no reference to the
2632/// plugin channels, so a bare `set_session` would leave every plugin reporting
2633/// the session it had before the swap.
2634///
2635/// `tool_context` is optional because not every caller has one (the harness's own
2636/// tests, a headless embedder); the env channel is always published, since the
2637/// provider hooks depend on it.
2638pub(crate) async fn set_active_session(
2639    harness: &AgentHarness,
2640    session: Session,
2641    tool_context: Option<&rpi_extensions::ToolCallContext>,
2642) -> Result<(), String> {
2643    let metadata = session.get_metadata().await.ok();
2644    harness
2645        .set_session(session)
2646        .await
2647        .map_err(|e| e.to_string())?;
2648    if let Some(metadata) = metadata {
2649        std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2650        if let Some(context) = tool_context {
2651            context.set_session_id(metadata.id);
2652        }
2653    }
2654    Ok(())
2655}
2656
2657/// Fork the harness's current session into a new JSONL session (new id, parent
2658/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
2659/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
2660/// and the plugin `runtime_action(Fork)` host share one implementation.
2661/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
2662/// does `harness.set_session(...)`).
2663pub(crate) async fn fork_session_storage(
2664    harness: &AgentHarness,
2665    cwd: &str,
2666    dir: &Path,
2667) -> Result<Session, String> {
2668    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2669    use rpi_tools::FileSystem;
2670
2671    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2672    let fs: Arc<dyn FileSystem> = env.clone();
2673    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2674        fs,
2675        sessions_root: dir.to_string_lossy().into_owned(),
2676        clock: Arc::new(SystemClock),
2677        ids: Arc::new(DefaultIdGenerator::new()),
2678    });
2679    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2680    // it from the session list by the current session's id.
2681    let id = harness.session().storage().metadata().id.clone();
2682    let metas = list_session_metadata_in_dir(cwd, dir)
2683        .await
2684        .map_err(|e| e.to_string())?;
2685    let Some(source) = metas.iter().find(|m| m.id == id) else {
2686        return Err(format!("current session {id} not found on disk"));
2687    };
2688    let fork_storage = repo
2689        .fork_typed(
2690            source,
2691            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2692                id: None,
2693                parent_session_id: Some(source.id.clone()),
2694                cwd: cwd.to_string(),
2695                metadata: None,
2696            },
2697            &rpi_harness::session::types::ForkOptions::default(),
2698        )
2699        .await
2700        .map_err(|e| e.to_string())?;
2701    claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2702    Ok(Session::new(Arc::new(fork_storage), None))
2703}
2704
2705/// Validate a private copy before publishing it in the repo's normal cwd
2706/// directory. Keep the imported identity/history and never overwrite a saved
2707/// session (including when the source is already inside this repository).
2708pub(crate) async fn import_session_file(
2709    source: &Path,
2710    cwd: &str,
2711    dir: &Path,
2712) -> Result<Session, String> {
2713    use rpi_harness::session::jsonl::{
2714        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2715    };
2716    use rpi_tools::FileSystem;
2717
2718    let dir = if dir.is_absolute() {
2719        dir.to_path_buf()
2720    } else {
2721        Path::new(cwd).join(dir)
2722    };
2723    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2724    let staged = tempfile::NamedTempFile::new_in(&dir)
2725        .map_err(|e| e.to_string())?
2726        .into_temp_path();
2727    std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2728    let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2729    let validated = JsonlSessionStorage::load(
2730        fs.clone(),
2731        &staged.to_string_lossy(),
2732        Arc::new(SystemClock),
2733        Arc::new(DefaultIdGenerator::new()),
2734    )
2735    .await
2736    .map_err(|e| format!("invalid session import: {e}"))?;
2737    let mut metadata = validated.jsonl_metadata().clone();
2738    drop(validated);
2739    if list_session_metadata_in_dir(cwd, &dir)
2740        .await
2741        .map_err(|e| e.to_string())?
2742        .iter()
2743        .any(|saved| saved.id == metadata.id)
2744    {
2745        return Err(format!(
2746            "session {} already exists in {}",
2747            metadata.id,
2748            dir.display()
2749        ));
2750    }
2751    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2752        fs,
2753        sessions_root: dir.to_string_lossy().into_owned(),
2754        clock: Arc::new(SystemClock),
2755        ids: Arc::new(DefaultIdGenerator::new()),
2756    });
2757    // Let the repository validate the id and choose its canonical destination.
2758    let destination = repo
2759        .create_typed(&JsonlSessionCreateOptions {
2760            id: Some(metadata.id.clone()),
2761            parent_session_id: metadata.parent_session_id.clone(),
2762            cwd: cwd.to_string(),
2763            metadata: metadata.metadata.clone(),
2764        })
2765        .await
2766        .map_err(|e| e.to_string())?;
2767    let path = destination.jsonl_metadata().path.clone();
2768    drop(destination);
2769    if let Err(error) = claim_session_lock(&path) {
2770        // Another process owns this destination; leave its file intact.
2771        return Err(error);
2772    }
2773    if let Err(error) = staged.persist(&path) {
2774        let _ = std::fs::remove_file(&path);
2775        return Err(format!("publish import: {error}"));
2776    }
2777    metadata.path = path;
2778    let storage = repo
2779        .open_by_jsonl_metadata(&metadata)
2780        .await
2781        .map_err(|e| e.to_string())?;
2782    Ok(Session::new(Arc::new(storage), None))
2783}
2784
2785/// Session logs this process has claimed, so re-claiming is a no-op.
2786static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2787/// The held lock handles. Kept alive for the process lifetime — dropping the
2788/// `File` would release the OS lock, so the guard is parked here.
2789static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2790
2791/// Claim the exclusive lock for a session log.
2792///
2793/// Two rpi processes appending to one JSONL diverge their `sequence` counters
2794/// (duplicate / gapped seqs) and corrupt it. The lock is an OS advisory lock on
2795/// `<path>.lock`: the second process gets a clear "already open" error instead
2796/// of corrupting the log, and the OS releases it automatically when the holder
2797/// exits (no stale-lock cleanup). Re-claiming a path this process already holds
2798/// is a no-op, so the TUI's session hot-switch can re-open freely.
2799fn claim_session_lock(session_path: &str) -> Result<(), String> {
2800    let lock_path = PathBuf::from(format!("{session_path}.lock"));
2801    if SESSION_LOCK_PATHS
2802        .get_or_init(|| Mutex::new(HashSet::new()))
2803        .lock()
2804        .unwrap()
2805        .contains(&lock_path)
2806    {
2807        return Ok(());
2808    }
2809    let file = std::fs::OpenOptions::new()
2810        .create(true)
2811        .write(true)
2812        .open(&lock_path)
2813        .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2814    if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2815        return Err(format!(
2816            "session {session_path} is already open in another rpi process"
2817        ));
2818    }
2819    SESSION_LOCKS
2820        .get_or_init(|| Mutex::new(Vec::new()))
2821        .lock()
2822        .unwrap()
2823        .push(file);
2824    SESSION_LOCK_PATHS
2825        .get_or_init(|| Mutex::new(HashSet::new()))
2826        .lock()
2827        .unwrap()
2828        .insert(lock_path);
2829    Ok(())
2830}
2831
2832/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
2833/// startup restore + TUI hot-switch).
2834async fn open_session(
2835    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2836    cwd: &str,
2837) -> Result<Session, BuildError> {
2838    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2839    use rpi_harness::session::types::SessionStorage;
2840    use rpi_tools::FileSystem;
2841
2842    let dir = default_session_dir(Path::new(cwd));
2843    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2844    let fs: Arc<dyn FileSystem> = env.clone();
2845    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2846        fs: fs.clone(),
2847        sessions_root: dir.to_string_lossy().into_owned(),
2848        clock: Arc::new(SystemClock),
2849        ids: Arc::new(DefaultIdGenerator::new()),
2850    });
2851    claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2852    let storage = repo
2853        .open_by_jsonl_metadata(meta)
2854        .await
2855        .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2856    // A session records the cwd it was created in. When that directory has
2857    // since been removed, continuing silently in it is confusing; warn the
2858    // user that we fell back to the process cwd (pi `getMissingSessionCwdIssue`).
2859    if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2860        Some(Path::new(&meta.path)),
2861        Path::new(&meta.cwd),
2862        Path::new(cwd),
2863    ) {
2864        eprintln!(
2865            "warning: {}",
2866            crate::session_cwd::format_missing_session_cwd_error(&issue)
2867        );
2868    }
2869    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2870    Ok(Session::new(storage_arc, None))
2871}
2872
2873/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`
2874/// and for `/new` inside such a run — an ephemeral launch keeps swapping in
2875/// in-memory sessions, never a JSONL file.
2876pub(crate) fn ephemeral_session() -> Session {
2877    let storage = Arc::new(InMemorySessionStorage::new(
2878        SessionMetadata {
2879            id: "ephemeral".into(),
2880            created_at: 0,
2881            parent_session_id: None,
2882        },
2883        Arc::new(SystemClock),
2884        Arc::new(DefaultIdGenerator::new()),
2885    ));
2886    Session::new(storage, None).with_volatile_progress()
2887}
2888
2889/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2890///
2891/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2892/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2893/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2894/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2895///
2896/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2897/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2898/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2899pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2900    create_jsonl_session_with_id(dir, cwd, None).await
2901}
2902
2903/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
2904/// contract: the file is named with the given id so later `--session-id`
2905/// launches restore the same session).
2906pub(crate) async fn create_jsonl_session_with_id(
2907    dir: &Path,
2908    cwd: &str,
2909    id: Option<String>,
2910) -> Result<Session, String> {
2911    use rpi_harness::session::jsonl::{
2912        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2913    };
2914    use rpi_tools::FileSystem;
2915
2916    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
2917    // relative-path resolution matches the tool env.
2918    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2919    let fs: Arc<dyn FileSystem> = env.clone();
2920
2921    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2922        fs: fs.clone(),
2923        sessions_root: dir.to_string_lossy().into_owned(),
2924        clock: Arc::new(SystemClock),
2925        ids: Arc::new(DefaultIdGenerator::new()),
2926    });
2927
2928    let opts = JsonlSessionCreateOptions {
2929        id, // fresh uuidv7 when None (--session-id passes the fixed id)
2930        parent_session_id: None,
2931        cwd: cwd.to_string(),
2932        metadata: None,
2933    };
2934    let storage = repo
2935        .create_typed(&opts)
2936        .await
2937        .map_err(|e| format!("create session: {e}"))?;
2938    claim_session_lock(&storage.jsonl_metadata().path)
2939        .map_err(|e| format!("create session: {e}"))?;
2940    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
2941    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2942    Ok(Session::new(storage_arc, None))
2943}
2944
2945/// Read an `--append-system-prompt` target: if it's a readable file path, return
2946/// its contents; otherwise return `None` and let the caller use the literal.
2947fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
2948    let path = Path::new(target);
2949    if path.is_file() {
2950        read_prompt_source(path, sources)
2951    } else {
2952        None
2953    }
2954}
2955
2956/// Record provenance at the successful read, rather than rediscovering files
2957/// during rendering (which could report a shadowed or unreadable file).
2958fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
2959    let text = std::fs::read_to_string(path).ok()?;
2960    let absolute = if path.is_absolute() {
2961        path.to_path_buf()
2962    } else {
2963        std::env::current_dir()
2964            .map(|cwd| cwd.join(path))
2965            .unwrap_or_else(|_| path.to_path_buf())
2966    };
2967    let source = absolute.to_string_lossy().into_owned();
2968    if !sources.contains(&source) {
2969        sources.push(source);
2970    }
2971    Some(text)
2972}
2973
2974#[cfg(test)]
2975mod runtime_session_tests;
2976
2977#[cfg(test)]
2978mod tests {
2979    use super::*;
2980
2981    #[tokio::test]
2982    async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
2983        extern "C" fn request(
2984            _: rpi_plugin_sdk::StbStringRef,
2985            _: *mut rpi_plugin_sdk::StbString,
2986            _: *mut std::ffi::c_void,
2987        ) -> i32 {
2988            1
2989        }
2990        let mut registry = rpi_extensions::ExtensionRegistry::new();
2991        for id in ["package", "legacy"] {
2992            registry.register_provider(rpi_extensions::RegisteredProvider {
2993                provider_id: id.into(),
2994                base_url: "http://localhost".into(),
2995                api_style: "openai-completions".into(),
2996                request_fn: request,
2997                stream_fn: None,
2998                plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
2999                user_data: std::ptr::null_mut(),
3000            });
3001        }
3002        assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3003            "id":"package", "requiresAuth":true,
3004            "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3005        }]}).to_string()));
3006        let session = ExtensionSession::from_parts(
3007            Arc::new(registry.snapshot()),
3008            rpi_extensions::PluginKeepalive::empty(),
3009            vec![],
3010            None,
3011        );
3012        let providers = build_models_with_extensions_for_reload(
3013            &[],
3014            &session,
3015            tokio::runtime::Handle::current(),
3016        );
3017        assert_eq!(
3018            providers
3019                .iter()
3020                .map(|provider| provider.id())
3021                .collect::<Vec<_>>(),
3022            ["legacy"]
3023        );
3024    }
3025    use crate::args::Args;
3026
3027    #[test]
3028    fn loaded_context_paths_track_precedence_and_reload() {
3029        const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3030        if std::env::var_os(CHILD).is_none() {
3031            let dir = tempfile::tempdir().unwrap();
3032            let output = std::process::Command::new(std::env::current_exe().unwrap())
3033                .args([
3034                    "--exact",
3035                    "session::tests::loaded_context_paths_track_precedence_and_reload",
3036                    "--nocapture",
3037                ])
3038                .env(CHILD, "1")
3039                .env(crate::config::CONFIG_DIR_ENV, dir.path())
3040                .output()
3041                .unwrap();
3042            assert!(
3043                output.status.success(),
3044                "{}\n{}",
3045                String::from_utf8_lossy(&output.stdout),
3046                String::from_utf8_lossy(&output.stderr)
3047            );
3048            return;
3049        }
3050        tokio::runtime::Builder::new_current_thread()
3051            .enable_all()
3052            .build()
3053            .unwrap()
3054            .block_on(async {
3055                let agent = crate::config::agent_dir().unwrap();
3056                let cwd = agent.join("project");
3057                let project_config = cwd.join(".rpi");
3058                std::fs::create_dir_all(&project_config).unwrap();
3059                for (path, content) in [
3060                    (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3061                    (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3062                    (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3063                    (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3064                    (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3065                    (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3066                    (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3067                ] {
3068                    std::fs::write(path, content).unwrap();
3069                }
3070                let resolved = crate::provider::resolve(
3071                    Some("anthropic"),
3072                    Some(crate::provider::DEFAULT_MODEL_ID),
3073                    None,
3074                    Some("test-key"),
3075                    None,
3076                )
3077                .unwrap();
3078                let args = Args {
3079                    no_session: true,
3080                    no_extensions: true,
3081                    no_skills: true,
3082                    no_prompt_templates: true,
3083                    trust_override: Some(true),
3084                    ..Default::default()
3085                };
3086                let (harness, _events, mut context) =
3087                    build(&resolved, &args, &cwd, true).await.unwrap();
3088                let resources = harness.get_resources().await.unwrap();
3089                let path = |p: PathBuf| p.to_string_lossy().into_owned();
3090                assert_eq!(
3091                    &resources.context_files[..2],
3092                    &[
3093                        path(project_config.join("SYSTEM.md")),
3094                        path(project_config.join("APPEND_SYSTEM.md")),
3095                    ]
3096                );
3097                assert!(resources
3098                    .context_files
3099                    .contains(&path(agent.join("AGENTS.md"))));
3100                assert!(resources
3101                    .context_files
3102                    .contains(&path(cwd.join("AGENTS.md"))));
3103                assert!(!resources
3104                    .context_files
3105                    .contains(&path(cwd.join("CLAUDE.md"))));
3106                assert!(!resources
3107                    .context_files
3108                    .contains(&path(agent.join("SYSTEM.md"))));
3109                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3110                assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3111                assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3112
3113                // Removing the selected project files makes reload fall back to
3114                // globals; removed instruction paths must disappear as well.
3115                std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3116                std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3117                std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3118                assert!(
3119                    !reload_extension_resources(&harness, &context)
3120                        .await
3121                        .had_errors
3122                );
3123                let resources = harness.get_resources().await.unwrap();
3124                assert_eq!(
3125                    &resources.context_files[..2],
3126                    &[
3127                        path(agent.join("SYSTEM.md")),
3128                        path(agent.join("APPEND_SYSTEM.md")),
3129                    ]
3130                );
3131                assert!(resources
3132                    .context_files
3133                    .contains(&path(cwd.join("CLAUDE.md"))));
3134                assert!(!resources
3135                    .context_files
3136                    .contains(&path(project_config.join("SYSTEM.md"))));
3137
3138                // Explicit append files are listed; literal text and overridden
3139                // discovered base/append files are not invented as file sources.
3140                let explicit = agent.join("custom-append.md");
3141                std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3142                context.args.system_prompt = Some("EXPLICIT_BASE".into());
3143                context.args.append_system_prompt =
3144                    vec![path(explicit.clone()), "literal append".into()];
3145                context.args.no_context_files = true;
3146                assert!(
3147                    !reload_extension_resources(&harness, &context)
3148                        .await
3149                        .had_errors
3150                );
3151                assert_eq!(
3152                    harness.get_resources().await.unwrap().context_files,
3153                    vec![path(explicit)]
3154                );
3155
3156                // The reload must honor the startup opt-out from project context.
3157                context.args.system_prompt = None;
3158                context.args.append_system_prompt.clear();
3159                context.args.no_context_files = false;
3160                context.args.trust_override = Some(false);
3161                assert!(
3162                    !reload_extension_resources(&harness, &context)
3163                        .await
3164                        .had_errors
3165                );
3166                assert!(harness
3167                    .get_resources()
3168                    .await
3169                    .unwrap()
3170                    .context_files
3171                    .is_empty());
3172                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3173                assert!(
3174                    !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3175                );
3176            });
3177    }
3178
3179    #[test]
3180    fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3181        let dir = tempfile::tempdir().unwrap();
3182        let file = dir.path().join("SYSTEM.md");
3183        let mut sources = Vec::new();
3184        assert!(read_prompt_source(&file, &mut sources).is_none());
3185        std::fs::write(&file, [0xff, 0xfe]).unwrap();
3186        assert!(read_prompt_source(&file, &mut sources).is_none());
3187        assert!(read_append_target("literal instructions", &mut sources).is_none());
3188        assert!(sources.is_empty());
3189        std::fs::write(&file, "loaded instructions").unwrap();
3190        assert_eq!(
3191            read_prompt_source(&file, &mut sources).as_deref(),
3192            Some("loaded instructions")
3193        );
3194        assert!(read_prompt_source(&file, &mut sources).is_some());
3195        assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3196    }
3197
3198    extern "C" fn deny_test_tool_call(
3199        _event: rpi_plugin_sdk::StablePluginEvent,
3200        _data: *mut std::ffi::c_void,
3201    ) -> i32 {
3202        rpi_plugin_sdk::EVENT_HANDLER_ABORT
3203    }
3204
3205    async fn invoke_test_tool_hook(
3206        hook: &rpi_agent::BeforeToolCall,
3207    ) -> Option<rpi_agent::BeforeToolCallResult> {
3208        let message = rpi_ai::providers::faux::faux_assistant_message(
3209            "approval",
3210            rpi_ai::types::StopReason::ToolUse,
3211        );
3212        let rpi_ai::types::Content::ToolCall(call) =
3213            rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3214        else {
3215            unreachable!()
3216        };
3217        hook(
3218            rpi_agent::BeforeToolCallContext {
3219                assistant_message: &message,
3220                tool_call: &call,
3221                args: &call.arguments,
3222                context: &rpi_agent::AgentContext::default(),
3223            },
3224            tokio_util::sync::CancellationToken::new(),
3225        )
3226        .await
3227    }
3228
3229    #[tokio::test]
3230    async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3231        let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3232        let hook =
3233            extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3234        assert!(invoke_test_tool_hook(&hook).await.is_none());
3235        for name in ["first-gate", "reloaded-gate"] {
3236            let mut registry = rpi_extensions::ExtensionRegistry::new();
3237            registry.register_event_handler(
3238                name.into(),
3239                rpi_plugin_sdk::EventTag::ToolCall,
3240                deny_test_tool_call,
3241                std::ptr::null_mut(),
3242            );
3243            let fresh = ExtensionSession::from_parts(
3244                Arc::new(registry.snapshot()),
3245                rpi_extensions::PluginKeepalive::empty(),
3246                Vec::new(),
3247                None,
3248            );
3249            {
3250                let mut current = sessions.lock().unwrap();
3251                if let Some(snapshot) = current.snapshot() {
3252                    snapshot.active_flag().store(false, Ordering::SeqCst);
3253                }
3254                *current = fresh;
3255            }
3256            let result = invoke_test_tool_hook(&hook).await.unwrap();
3257            assert!(result.block);
3258            assert!(result.reason.unwrap().contains(name));
3259        }
3260        *sessions.lock().unwrap() = ExtensionSession::none();
3261        assert!(invoke_test_tool_hook(&hook).await.is_none());
3262    }
3263
3264    /// A unique scratch file for one summary test.
3265    fn scratch(name: &str) -> PathBuf {
3266        let nanos = std::time::SystemTime::now()
3267            .duration_since(std::time::UNIX_EPOCH)
3268            .unwrap()
3269            .as_nanos();
3270        let path = std::env::temp_dir().join(format!(
3271            "rpi-summary-{}-{nanos}-{name}.jsonl",
3272            std::process::id()
3273        ));
3274        path
3275    }
3276
3277    #[test]
3278    fn summary_marks_a_header_only_session_empty() {
3279        let path = scratch("empty");
3280        std::fs::write(
3281            &path,
3282            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3283        )
3284        .unwrap();
3285        let summary = summarize_session_file(&path);
3286        assert!(summary.empty, "a header-only file has nothing to resume");
3287        assert!(summary.preview.is_none());
3288        assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3289        std::fs::remove_file(&path).ok();
3290    }
3291
3292    #[test]
3293    fn summary_reads_the_first_user_prompt_and_name() {
3294        let path = scratch("content");
3295        let body = concat!(
3296            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3297            "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3298            "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3299            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3300            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3301        );
3302        std::fs::write(&path, body).unwrap();
3303        let summary = summarize_session_file(&path);
3304        assert!(!summary.empty);
3305        assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3306        assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3307        std::fs::remove_file(&path).ok();
3308    }
3309
3310    #[test]
3311    fn summary_extracts_a_block_style_user_prompt() {
3312        let path = scratch("blocks");
3313        let body = concat!(
3314            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3315            "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3316        );
3317        std::fs::write(&path, body).unwrap();
3318        let summary = summarize_session_file(&path);
3319        assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3320        assert_eq!(session_display_label(&summary), "line one line two");
3321        std::fs::remove_file(&path).ok();
3322    }
3323
3324    fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3325        rpi_harness::session::jsonl::JsonlSessionMetadata {
3326            id: id.to_string(),
3327            created_at: 0,
3328            cwd: ".".to_string(),
3329            path: path.to_string_lossy().into_owned(),
3330            modified_at: 0,
3331            source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3332            parent_session_id: None,
3333            legacy_parent_session_path: None,
3334            metadata: None,
3335        }
3336    }
3337
3338    #[test]
3339    fn continue_skips_newer_empty_sessions() {
3340        let empty = scratch("newest-empty");
3341        std::fs::write(
3342            &empty,
3343            "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3344        )
3345        .unwrap();
3346        let content = scratch("older-content");
3347        std::fs::write(
3348            &content,
3349            concat!(
3350                "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3351                "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3352            ),
3353        )
3354        .unwrap();
3355
3356        let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3357        let picked = first_resumable_session(&metas).expect("a resumable session");
3358        assert_eq!(picked.id, "old");
3359
3360        // When every session is empty, fall back to the newest rather than
3361        // erroring.
3362        let all_empty = vec![meta_for(&empty, "empty")];
3363        assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3364
3365        std::fs::remove_file(&empty).ok();
3366        std::fs::remove_file(&content).ok();
3367    }
3368
3369    #[test]
3370    fn list_session_files_sync_walks_the_cwd_subdirectory() {
3371        let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3372        let nested = root.join("--proj--");
3373        std::fs::create_dir_all(&nested).unwrap();
3374        let file = nested.join("2026-01-01_a.jsonl");
3375        std::fs::write(&file, "{}\n").unwrap();
3376        let found = list_session_files_sync(&root);
3377        assert!(found.iter().any(|p| p == &file));
3378        std::fs::remove_dir_all(&root).ok();
3379    }
3380
3381    #[test]
3382    fn default_prompt_mentions_cwd_and_tools() {
3383        let p = default_system_prompt("/tmp/proj", false);
3384        assert!(p.contains("/tmp/proj"));
3385        assert!(p.contains("read"));
3386        assert!(p.contains("bash"));
3387        assert!(p.contains("edit"));
3388        assert!(p.contains("write"));
3389        assert!(p.contains("docs"));
3390        assert!(!p.contains("- grep"));
3391        assert!(!p.contains("- find"));
3392        assert!(!p.contains("- ls"));
3393        assert!(!p.contains("- powershell"));
3394        assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3395        assert!(p.contains("do not pass PowerShell cmdlets"));
3396    }
3397
3398    #[test]
3399    fn default_prompt_instructs_language_matching() {
3400        // The model must detect the user's language and answer in it, so a
3401        // Chinese (or any non-English) user gets a same-language reply without
3402        // having to ask every turn.
3403        let p = default_system_prompt("/tmp/proj", false);
3404        let lower = p.to_lowercase();
3405        assert!(
3406            lower.contains("same language") && lower.contains("detect"),
3407            "the prompt must tell the model to detect and mirror the user's language: {p}"
3408        );
3409    }
3410
3411    #[test]
3412    fn default_prompt_keeps_working_state_in_the_visible_channel() {
3413        // Regression guard for the re-plan loop: on openai-compatible providers
3414        // the assistant's `thinking` blocks are dropped when the next request is
3415        // built (`providers/openai_completions.rs`: `Content::text_only`), so a
3416        // plan that lives only in reasoning is re-derived every turn. One
3417        // observed session paid for that 112 times in a single run, and twice
3418        // hallucinated the user's input ("The user is greeting me with hello",
3419        // with no such message). See `docs/llm-repetition-forensics.md` §八.
3420        let p = default_system_prompt("/tmp/proj", false);
3421        let lower = p.to_lowercase();
3422        // The rule must state the mechanism, not just "be clear".
3423        assert!(
3424            lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3425            "the prompt must explain that reasoning does not survive the turn: {p}"
3426        );
3427        // …and the remedy: keep state visible, use the todo tool.
3428        assert!(
3429            lower.contains("visible") && lower.contains("todo tool"),
3430            "the prompt must point at visible text and the todo tool: {p}"
3431        );
3432        // …plus the anti-repetition rule itself.
3433        assert!(
3434            lower.contains("re-stating the same plan"),
3435            "the prompt must call out plan re-statement as a bug: {p}"
3436        );
3437        // And the guidance must not instruct re-reading what is already shown.
3438        assert!(
3439            lower.contains("already shown earlier"),
3440            "the prompt must discourage re-fetching known content: {p}"
3441        );
3442    }
3443
3444    #[test]
3445    fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3446        // `compat.requiresThinkingAsText` (and the native thinking channels of
3447        // anthropic-messages / openai-responses) DO carry the model's reasoning
3448        // back on the next request. Telling such a model "reasoning is not
3449        // carried into your next turn" is false, and the instruction to restate
3450        // the plan every turn is exactly the busy-work the re-plan analysis
3451        // measured. The wording must flip with the mechanism.
3452        let p = default_system_prompt("/tmp/proj", true);
3453        let lower = p.to_lowercase();
3454        assert!(
3455            lower.contains("carried back"),
3456            "a replaying model must be told its reasoning survives: {p}"
3457        );
3458        assert!(
3459            !lower.contains("reasoning is not"),
3460            "the replayed variant must not claim reasoning is dropped: {p}"
3461        );
3462        assert!(
3463            lower.contains("do not re-derive"),
3464            "the replayed variant must forbid re-deriving the plan: {p}"
3465        );
3466        assert!(
3467            lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3468            "the anti-repetition rule must survive the flip: {p}"
3469        );
3470        assert!(
3471            lower.contains("already shown earlier"),
3472            "the re-read rule must survive the flip: {p}"
3473        );
3474    }
3475
3476    /// Only side-effect-free tools may be replayed after a crash.
3477    ///
3478    /// Regression guard for an inverted default: `ToolReplay` used to default to
3479    /// `Safe` and the CLI then marked *every* tool `Safe`, so the first time
3480    /// recovery acted on the flag it would have re-run `bash`/`write`/`edit` and
3481    /// duplicated their side effects. upstream defaults to `"never"`
3482    /// (`drive/tools.ts: tool.replay ?? "never"`).
3483    #[test]
3484    fn only_read_only_tools_are_replayable() {
3485        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3486        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3487        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3488        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3489        let tools = build_tools(&context, &Args::default());
3490
3491        let replay_of = |name: &str| {
3492            tools
3493                .iter()
3494                .find(|tool| tool.tool.schema().name == name)
3495                .map(|tool| tool.replay)
3496        };
3497
3498        for read_only in ["read", "docs"] {
3499            assert_eq!(
3500                replay_of(read_only),
3501                Some(ToolReplay::Safe),
3502                "{read_only} is side-effect free, so recovery may re-run it"
3503            );
3504        }
3505        for mutating in ["bash", "edit", "write", "powershell"] {
3506            if let Some(replay) = replay_of(mutating) {
3507                assert_eq!(
3508                    replay,
3509                    ToolReplay::Never,
3510                    "{mutating} must never be replayed: re-running it would duplicate its effect"
3511                );
3512            }
3513        }
3514    }
3515
3516    #[test]
3517    fn default_tools_include_docs_lookup() {
3518        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3519        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3520        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3521        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3522        let names: Vec<String> = build_tools(&context, &Args::default())
3523            .iter()
3524            .map(|tool| tool.tool.schema().name.clone())
3525            .collect();
3526
3527        // `docs` is a default capability; the legacy rpi-only lookup tools stay
3528        // unregistered. Asserted by membership (not exact equality) because
3529        // `powershell` is Windows-only and `settings.json#defaultTools` may
3530        // narrow the set in a developer's environment.
3531        assert!(names.contains(&"docs".to_string()));
3532        for legacy in ["grep", "find", "ls"] {
3533            assert!(
3534                !names.contains(&legacy.to_string()),
3535                "legacy tool {legacy} should not be registered by default"
3536            );
3537        }
3538    }
3539
3540    #[test]
3541    fn reload_settings_preflight_respects_trust() {
3542        struct RestoreConfigDir(Option<std::ffi::OsString>);
3543
3544        impl Drop for RestoreConfigDir {
3545            fn drop(&mut self) {
3546                match self.0.take() {
3547                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3548                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3549                }
3550            }
3551        }
3552
3553        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3554        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3555        let tmp = tempfile::tempdir().unwrap();
3556        let agent = tmp.path().join("agent");
3557        let cwd = tmp.path().join("project");
3558        std::fs::create_dir_all(&agent).unwrap();
3559        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3560        std::fs::write(agent.join("settings.json"), "{}").unwrap();
3561        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3562        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3563        let args = Args::default();
3564
3565        let trusted = validate_settings_for_reload(&args, &cwd, true);
3566        let untrusted = validate_settings_for_reload(&args, &cwd, false);
3567        assert!(trusted
3568            .unwrap_err()
3569            .contains("could not load project settings"));
3570        assert!(untrusted.is_ok());
3571
3572        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3573        assert!(validate_settings_for_reload(&args, &cwd, false)
3574            .unwrap_err()
3575            .contains("could not load global settings"));
3576
3577        let local_only = Args {
3578            dev_local_only: true,
3579            ..Args::default()
3580        };
3581        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3582    }
3583
3584    #[tokio::test(flavor = "current_thread")]
3585    async fn reload_preserves_live_resources_when_settings_break() {
3586        struct RestoreConfigDir(Option<std::ffi::OsString>);
3587
3588        impl Drop for RestoreConfigDir {
3589            fn drop(&mut self) {
3590                match self.0.take() {
3591                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3592                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3593                }
3594            }
3595        }
3596
3597        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3598        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3599        let tmp = tempfile::tempdir().unwrap();
3600        let agent = tmp.path().join("agent");
3601        let cwd = tmp.path().join("project");
3602        let skill_dir = tmp.path().join("configured-skills");
3603        std::fs::create_dir_all(&agent).unwrap();
3604        std::fs::create_dir_all(&cwd).unwrap();
3605        std::fs::create_dir_all(&skill_dir).unwrap();
3606        std::fs::write(
3607            skill_dir.join("SKILL.md"),
3608            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3609        )
3610        .unwrap();
3611        std::fs::write(
3612            agent.join("settings.json"),
3613            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3614        )
3615        .unwrap();
3616        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3617
3618        let resolved = crate::provider::resolve(
3619            Some("anthropic"),
3620            Some(crate::provider::DEFAULT_MODEL_ID),
3621            None,
3622            Some("test-key"),
3623            None,
3624        )
3625        .unwrap();
3626        let args = Args {
3627            trust_override: Some(false),
3628            no_session: true,
3629            no_extensions: true,
3630            no_prompt_templates: true,
3631            no_context_files: true,
3632            system_prompt: Some("stable system prompt".into()),
3633            ..Args::default()
3634        };
3635        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3636        let before_resources = harness.get_resources().await.unwrap();
3637        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3638        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3639        let before_prompt = harness.get_system_prompt().await.unwrap();
3640        let before_tools: Vec<String> = harness
3641            .get_tools()
3642            .await
3643            .unwrap()
3644            .iter()
3645            .map(|tool| tool.tool.schema().name.clone())
3646            .collect();
3647        let before_bridge = context
3648            .action_bridge
3649            .lock()
3650            .unwrap()
3651            .as_ref()
3652            .unwrap()
3653            .clone();
3654
3655        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3656        let outcome = reload_extension_resources(&harness, &context).await;
3657
3658        assert!(outcome.had_warnings);
3659        assert!(outcome.summary.contains("Settings reload failed"));
3660        assert!(outcome.summary.contains("could not load global settings"));
3661        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3662        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3663        let after_tools: Vec<String> = harness
3664            .get_tools()
3665            .await
3666            .unwrap()
3667            .iter()
3668            .map(|tool| tool.tool.schema().name.clone())
3669            .collect();
3670        assert_eq!(after_tools, before_tools);
3671        let after_bridge = context
3672            .action_bridge
3673            .lock()
3674            .unwrap()
3675            .as_ref()
3676            .unwrap()
3677            .clone();
3678        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3679    }
3680
3681    #[test]
3682    fn reload_preparation_rejects_settings_changed_during_derivation() {
3683        struct RestoreConfigDir(Option<std::ffi::OsString>);
3684
3685        impl Drop for RestoreConfigDir {
3686            fn drop(&mut self) {
3687                match self.0.take() {
3688                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3689                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3690                }
3691            }
3692        }
3693
3694        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3695        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3696        let tmp = tempfile::tempdir().unwrap();
3697        let agent = tmp.path().join("agent");
3698        let cwd = tmp.path().join("project");
3699        let first_skill_dir = tmp.path().join("first-skills");
3700        let second_skill_dir = tmp.path().join("second-skills");
3701        std::fs::create_dir_all(&agent).unwrap();
3702        std::fs::create_dir_all(&cwd).unwrap();
3703        std::fs::write(
3704            agent.join("settings.json"),
3705            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3706        )
3707        .unwrap();
3708        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3709        let args = Args {
3710            trust_override: Some(false),
3711            no_extensions: true,
3712            no_prompt_templates: true,
3713            ..Args::default()
3714        };
3715
3716        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3717            std::fs::write(
3718                agent.join("settings.json"),
3719                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3720            )
3721            .unwrap();
3722        });
3723
3724        let error = result
3725            .err()
3726            .expect("settings mutation must fail preparation");
3727        assert!(error.contains("settings changed while reload inputs were being prepared"));
3728    }
3729
3730    #[tokio::test(flavor = "current_thread")]
3731    async fn reload_uses_frozen_settings_inputs_after_preparation() {
3732        struct RestoreConfigDir(Option<std::ffi::OsString>);
3733
3734        impl Drop for RestoreConfigDir {
3735            fn drop(&mut self) {
3736                match self.0.take() {
3737                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3738                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3739                }
3740            }
3741        }
3742
3743        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3744        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3745        let tmp = tempfile::tempdir().unwrap();
3746        let agent = tmp.path().join("agent");
3747        let cwd = tmp.path().join("project");
3748        let skill_dir = tmp.path().join("configured-skills");
3749        std::fs::create_dir_all(&agent).unwrap();
3750        std::fs::create_dir_all(&cwd).unwrap();
3751        std::fs::create_dir_all(&skill_dir).unwrap();
3752        std::fs::write(
3753            skill_dir.join("SKILL.md"),
3754            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3755        )
3756        .unwrap();
3757        std::fs::write(
3758            agent.join("settings.json"),
3759            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3760        )
3761        .unwrap();
3762        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3763
3764        let resolved = crate::provider::resolve(
3765            Some("anthropic"),
3766            Some(crate::provider::DEFAULT_MODEL_ID),
3767            None,
3768            Some("test-key"),
3769            None,
3770        )
3771        .unwrap();
3772        let args = Args {
3773            trust_override: Some(false),
3774            no_session: true,
3775            no_extensions: true,
3776            no_prompt_templates: true,
3777            no_context_files: true,
3778            system_prompt: Some("stable system prompt".into()),
3779            ..Args::default()
3780        };
3781        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3782
3783        let outcome = reload_extension_resources_inner(&harness, &context, || {
3784            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3785        })
3786        .await;
3787
3788        assert!(!outcome.summary.contains("Settings reload failed"));
3789        assert_eq!(
3790            outcome.summary,
3791            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3792        );
3793        let resources = harness.get_resources().await.unwrap();
3794        let skills = resources.skills.as_ref().unwrap();
3795        assert_eq!(skills.len(), 1);
3796        assert_eq!(skills[0].name, "frozen-skill");
3797    }
3798
3799    #[tokio::test]
3800    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3801        let tmp = tempfile::tempdir().unwrap();
3802        let skill_path = tmp.path().join("explicit-skill.md");
3803        std::fs::write(
3804            &skill_path,
3805            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3806        )
3807        .unwrap();
3808        let prompt_path = tmp.path().join("explicit-prompt.md");
3809        std::fs::write(
3810            &prompt_path,
3811            "---\ndescription: Explicit prompt\n---\nPrompt body",
3812        )
3813        .unwrap();
3814
3815        let args = Args {
3816            skill: vec![skill_path.clone()],
3817            prompt_template: vec![prompt_path.clone()],
3818            ..Args::default()
3819        };
3820        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3821        let prompt_paths =
3822            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3823        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3824        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3825
3826        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3827        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3828        assert_eq!(skills.skills[0].name, "explicit-skill");
3829
3830        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3831        assert_eq!(
3832            prompts.prompt_templates.len(),
3833            1,
3834            "{:?}",
3835            prompts.diagnostics
3836        );
3837        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
3838    }
3839
3840    #[test]
3841    fn tool_policy_applies_to_rust_and_js_active_names() {
3842        let names = vec![
3843            "read".to_string(),
3844            "ask_user_question".to_string(),
3845            "write".to_string(),
3846        ];
3847
3848        let args = Args {
3849            tools: Some(vec!["read".into(), "ask_user_question".into()]),
3850            ..Args::default()
3851        };
3852        assert_eq!(
3853            filter_active_tool_names(names.clone(), &args),
3854            vec!["read", "ask_user_question"]
3855        );
3856
3857        let args = Args {
3858            exclude_tools: Some(vec!["ask_user_question".into()]),
3859            ..Args::default()
3860        };
3861        assert_eq!(
3862            filter_active_tool_names(names.clone(), &args),
3863            vec!["read", "write"]
3864        );
3865
3866        let args = Args {
3867            no_tools: true,
3868            ..Args::default()
3869        };
3870        assert!(filter_active_tool_names(names, &args).is_empty());
3871    }
3872
3873    #[test]
3874    fn select_ephemeral_when_no_session() {
3875        let args = Args {
3876            no_session: true,
3877            ..Args::default()
3878        };
3879        let cwd = Path::new("/tmp");
3880        assert!(matches!(
3881            select_session(&args, cwd),
3882            SessionSelection::Ephemeral
3883        ));
3884    }
3885
3886    #[test]
3887    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
3888        let defaults = Args::default();
3889        assert!(resolve_project_trust(
3890            &defaults,
3891            Path::new("C:/definitely-not-a-project")
3892        ));
3893
3894        let denied = Args {
3895            trust_override: Some(false),
3896            ..Args::default()
3897        };
3898        assert!(!resolve_project_trust(
3899            &denied,
3900            Path::new("C:/definitely-not-a-project")
3901        ));
3902    }
3903
3904    #[tokio::test(flavor = "current_thread")]
3905    async fn build_preserves_the_supplied_startup_project_snapshot() {
3906        struct RestoreConfigDir(Option<std::ffi::OsString>);
3907
3908        impl Drop for RestoreConfigDir {
3909            fn drop(&mut self) {
3910                match self.0.take() {
3911                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3912                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3913                }
3914            }
3915        }
3916
3917        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3918        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3919        let tmp = tempfile::tempdir().unwrap();
3920        let agent = tmp.path().join("agent");
3921        let cwd = tmp.path().join("project");
3922        std::fs::create_dir_all(&agent).unwrap();
3923        std::fs::create_dir_all(&cwd).unwrap();
3924        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3925
3926        let resolved = crate::provider::resolve(
3927            Some("anthropic"),
3928            Some(crate::provider::DEFAULT_MODEL_ID),
3929            None,
3930            Some("test-key"),
3931            None,
3932        )
3933        .unwrap();
3934        let args = Args {
3935            trust_override: Some(false),
3936            no_session: true,
3937            no_tools: true,
3938            no_extensions: true,
3939            no_skills: true,
3940            no_prompt_templates: true,
3941            no_context_files: true,
3942            system_prompt: Some("test prompt".into()),
3943            ..Args::default()
3944        };
3945
3946        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
3947
3948        assert_eq!(context.cwd, cwd);
3949        assert!(context.project_trusted);
3950    }
3951
3952    #[tokio::test(flavor = "current_thread")]
3953    async fn build_applies_the_name_flag_to_the_session() {
3954        struct RestoreConfigDir(Option<std::ffi::OsString>);
3955
3956        impl Drop for RestoreConfigDir {
3957            fn drop(&mut self) {
3958                match self.0.take() {
3959                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3960                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3961                }
3962            }
3963        }
3964
3965        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3966        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3967        let tmp = tempfile::tempdir().unwrap();
3968        let agent = tmp.path().join("agent");
3969        let cwd = tmp.path().join("project");
3970        std::fs::create_dir_all(&agent).unwrap();
3971        std::fs::create_dir_all(&cwd).unwrap();
3972        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3973
3974        let resolved = crate::provider::resolve(
3975            Some("anthropic"),
3976            Some(crate::provider::DEFAULT_MODEL_ID),
3977            None,
3978            Some("test-key"),
3979            None,
3980        )
3981        .unwrap();
3982        let args = Args {
3983            trust_override: Some(false),
3984            no_tools: true,
3985            no_extensions: true,
3986            no_skills: true,
3987            no_prompt_templates: true,
3988            no_context_files: true,
3989            system_prompt: Some("test prompt".into()),
3990            name: Some("smoke name".into()),
3991            session_dir: Some(cwd.join("sessions")),
3992            ..Args::default()
3993        };
3994
3995        let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
3996        assert_eq!(
3997            harness.get_name().await.unwrap().as_deref(),
3998            Some("smoke name")
3999        );
4000    }
4001
4002    #[test]
4003    fn select_latest_for_continue_and_resume() {
4004        let _env = crate::config::test_support::SessionDirEnv::new(None);
4005        let args = Args {
4006            continue_session: true,
4007            ..Args::default()
4008        };
4009        let cwd = Path::new("/tmp");
4010        assert!(matches!(
4011            select_session(&args, cwd),
4012            SessionSelection::Latest { .. }
4013        ));
4014
4015        let args = Args {
4016            resume: true,
4017            ..Args::default()
4018        };
4019        assert!(matches!(
4020            select_session(&args, cwd),
4021            SessionSelection::Latest { .. }
4022        ));
4023    }
4024
4025    #[test]
4026    fn select_by_id_for_session_flag() {
4027        let _env = crate::config::test_support::SessionDirEnv::new(None);
4028        let args = Args {
4029            session: Some("01a02ece".into()),
4030            ..Args::default()
4031        };
4032        let cwd = Path::new("/tmp");
4033        assert!(matches!(
4034            select_session(&args, cwd),
4035            SessionSelection::ById { id, .. } if id == "01a02ece"
4036        ));
4037    }
4038
4039    #[test]
4040    fn select_new_with_custom_dir() {
4041        let _env = crate::config::test_support::SessionDirEnv::new(None);
4042        let args = Args {
4043            session_dir: Some(PathBuf::from("/tmp/sess")),
4044            ..Args::default()
4045        };
4046        let cwd = Path::new("/tmp");
4047        match select_session(&args, cwd) {
4048            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4049            other => panic!("expected New, got {other:?}"),
4050        }
4051    }
4052
4053    #[test]
4054    fn select_new_default_dir() {
4055        let _env = crate::config::test_support::SessionDirEnv::new(None);
4056        let args = Args::default();
4057        let cwd = Path::new("/proj");
4058        match select_session(&args, cwd) {
4059            SessionSelection::New { dir, .. } => {
4060                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4061            }
4062            other => panic!("expected New, got {other:?}"),
4063        }
4064    }
4065
4066    #[test]
4067    fn default_session_dir_only_uses_rpi() {
4068        let _env = crate::config::test_support::SessionDirEnv::new(None);
4069        let tmp = tempfile::tempdir().unwrap();
4070        let cwd = tmp.path();
4071        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4072        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4073        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4074        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4075    }
4076
4077    #[test]
4078    fn default_session_dir_uses_env_var() {
4079        let tmp = tempfile::tempdir().unwrap();
4080        let cwd = tmp.path();
4081        let custom = tmp.path().join("custom_sessions");
4082        let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4083        assert_eq!(default_session_dir(cwd), custom);
4084    }
4085
4086    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4087    async fn ephemeral_session_builds_roundtrips() {
4088        // Sanity: the ephemeral path produces a usable Session facade (the
4089        // harness build itself needs a provider; tested via the integration
4090        // path in tests/build.rs instead).
4091        let s = ephemeral_session();
4092        let leaf = s.get_leaf_id().await;
4093        assert!(leaf.is_ok());
4094    }
4095
4096    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
4097    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
4098
4099    /// The exclusion policy, checked against the constants rather than copies.
4100    ///
4101    /// `rpi-tools` builds its exclusion from `rpi-plugin-sdk`'s declarations, so
4102    /// within this repo a rename is a compile error — this test is not the link
4103    /// between those two crates any more. What it still guards is the part the
4104    /// compiler cannot see:
4105    ///
4106    /// - **The wire values.** `rpi-langfuse` lives in a different repository and
4107    ///   agrees on the *strings*, not the constant paths. Rewriting a constant's
4108    ///   value would compile here and silently stop matching there, so the exact
4109    ///   values are pinned below. Changing one is a deliberate, breaking change to
4110    ///   a cross-repo contract, and it should fail here first.
4111    /// - **Coverage.** Every name the SDK declares must actually be excluded.
4112    /// - **Narrowness.** The user's own configuration must still reach the shell.
4113    #[test]
4114    fn host_private_env_matches_the_declared_contract() {
4115        // 1. The wire values. Pinned literally on purpose: see the doc comment.
4116        assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4117        assert_eq!(
4118            rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4119            [
4120                "LANGFUSE_PI_PARENT_TRACE_ID",
4121                "LANGFUSE_PI_PARENT_SPAN_ID",
4122                "LANGFUSE_PI_PARENT_SESSION_ID",
4123                "LANGFUSE_PI_PARENT_DEPTH",
4124            ],
4125            "these strings are what rpi-langfuse (a different repo) matches; a \
4126             change here is a breaking cross-repo change, not a refactor"
4127        );
4128
4129        // 2. Coverage — derived from the declarations, so a newly added family
4130        //    member is checked without anyone remembering to extend this test.
4131        for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4132            .iter()
4133            .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4134        {
4135            assert!(
4136                rpi_tools::is_host_private_env(name),
4137                "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4138                rpi_tools::HOST_PRIVATE_ENV
4139            );
4140        }
4141
4142        // 3. Narrowness — these name how the *user* configured the run and are
4143        //    exactly what a command inside the shell should still see.
4144        for visible in [
4145            crate::args::RPI_OFFLINE_ENV,
4146            crate::config::CONFIG_DIR_ENV,
4147            "PATH",
4148        ] {
4149            assert!(
4150                !rpi_tools::is_host_private_env(visible),
4151                "{visible} describes the user's configuration and must still reach the shell"
4152            );
4153        }
4154    }
4155    #[tokio::test]
4156    async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4157        use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4158        use std::sync::atomic::AtomicUsize;
4159        static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4160            rpi_plugin_sdk::handles::ToolHandles::new();
4161        struct ContextOwner(usize);
4162        impl Drop for ContextOwner {
4163            fn drop(&mut self) {
4164                drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4165            }
4166        }
4167        extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4168            let context = CONTEXTS.get(data).expect("live test context");
4169            let calls = context.lock().clone();
4170            std::thread::sleep(std::time::Duration::from_millis(20));
4171            if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4172                rpi_plugin_sdk::EVENT_HANDLER_ABORT
4173            } else {
4174                0
4175            }
4176        }
4177        let calls = Arc::new(AtomicUsize::new(0));
4178        let context = CONTEXTS.insert(calls.clone());
4179        let _owner = ContextOwner(context as usize);
4180        let mut registry = rpi_extensions::ExtensionRegistry::new();
4181        for name in ["first", "second"] {
4182            registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4183        }
4184        super::shutdown_extensions_for_reload(
4185            Some(Arc::new(registry.snapshot())),
4186            rpi_extensions::PluginKeepalive::empty(),
4187        )
4188        .await
4189        .unwrap();
4190        assert_eq!(calls.load(Ordering::SeqCst), 2);
4191    }
4192}