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