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    // A session file given by path forks from wherever it lives (pi parity:
2324    // `pi --fork <file>` takes any session file); an id or a name fragment is
2325    // looked up in the session directory.
2326    let source_meta = match session_metadata_at(Path::new(source)) {
2327        Some(meta) => meta,
2328        None => {
2329            let metas = repo
2330                .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2331                .await
2332                .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2333            metas
2334                .into_iter()
2335                .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2336                .ok_or_else(|| BuildError::SessionNotFound {
2337                    requested: format!("--fork {source}"),
2338                    dir: dir.display().to_string(),
2339                })?
2340        }
2341    };
2342    let fork_storage = repo
2343        .fork_typed(
2344            &source_meta,
2345            &JsonlSessionCreateOptions {
2346                id: None,
2347                parent_session_id: Some(source_meta.id.clone()),
2348                cwd: cwd.to_string(),
2349                metadata: None,
2350            },
2351            &ForkOptions::default(),
2352        )
2353        .await
2354        .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
2355    claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
2356    let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
2357    Ok(Session::new(storage_arc, None))
2358}
2359
2360/// The metadata of the JSONL session file at `path`, or `None` when `path` is
2361/// not a readable session file.
2362fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
2363    use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
2364    use std::io::BufRead;
2365
2366    let file = std::fs::File::open(path).ok()?;
2367    let modified_at = file
2368        .metadata()
2369        .ok()?
2370        .modified()
2371        .ok()?
2372        .duration_since(std::time::UNIX_EPOCH)
2373        .ok()?
2374        .as_millis() as i64;
2375    let mut first_line = String::new();
2376    std::io::BufReader::new(file)
2377        .read_line(&mut first_line)
2378        .ok()?;
2379    let header = parse_header(first_line.trim_end()).ok()?;
2380    let path = std::fs::canonicalize(path).ok()?;
2381    Some(metadata_from_header(
2382        &header,
2383        &path.to_string_lossy(),
2384        modified_at,
2385    ))
2386}
2387
2388/// Errors from [`open_session_by_id`], split so the CLI can map them to
2389/// [`BuildError`] while the TUI can surface a friendlier note.
2390pub enum OpenError {
2391    /// No session matched the request.
2392    NotFound { requested: String },
2393    /// The match existed but could not be opened/parsed.
2394    Other(String),
2395}
2396
2397impl std::fmt::Display for OpenError {
2398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2399        match self {
2400            OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
2401            // `Other` carries the bare reason (see `open_session_by_id`); the
2402            // prefix lives here so the TUI and the CLI's
2403            // `BuildError::SessionOpen` wrapper each render it exactly once
2404            // (wrapping the already-prefixed `BuildError` string doubled it).
2405            OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
2406        }
2407    }
2408}
2409
2410/// List the JSONL session metadata under the default session dir, newest
2411/// first. Shared by startup restore and the TUI `/session` hot-switch.
2412pub async fn list_session_metadata(
2413    cwd: &str,
2414) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2415    let dir = default_session_dir(Path::new(cwd));
2416    list_session_metadata_in_dir(cwd, &dir).await
2417}
2418
2419pub(crate) async fn list_session_metadata_in_dir(
2420    cwd: &str,
2421    dir: &Path,
2422) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2423    use rpi_harness::session::jsonl::{
2424        JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2425    };
2426    use rpi_tools::FileSystem;
2427
2428    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2429    let fs: Arc<dyn FileSystem> = env.clone();
2430    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2431        fs: fs.clone(),
2432        sessions_root: dir.to_string_lossy().into_owned(),
2433        clock: Arc::new(SystemClock),
2434        ids: Arc::new(DefaultIdGenerator::new()),
2435    });
2436    repo.list_typed(&JsonlSessionListOptions::default())
2437        .await
2438        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
2439}
2440
2441/// A cheap, display-oriented summary of one JSONL session file.
2442///
2443/// The session pickers (`-r` and `/session`) need enough context to tell saved
2444/// sessions apart. The header-only [`JsonlSessionMetadata`] carries no name and
2445/// no transcript, so a list built from it renders as a wall of identical
2446/// `2026-09-25T15-05-15-847Z_…` file names — including the many header-only
2447/// sessions an abandoned launch leaves behind. This reads just the head (and a
2448/// small tail, for a late `--name`/`/name` fact) of the file to recover:
2449///
2450/// - the session display name, when one was recorded;
2451/// - the first user prompt, single-lined and truncated, for a readable label;
2452/// - whether the file is header-only (nothing to resume).
2453///
2454/// It intentionally does **not** parse the whole log: sessions can be tens of
2455/// megabytes, and the picker runs over every session at startup.
2456#[derive(Debug, Clone, Default, PartialEq, Eq)]
2457pub struct SessionSummary {
2458    /// The session display name (`--name` / `/name`), if one was recorded.
2459    pub name: Option<String>,
2460    /// The first user prompt, single-lined and trimmed to a label-sized length.
2461    pub preview: Option<String>,
2462    /// True when the file holds only its header (a session with no content).
2463    pub empty: bool,
2464    /// On-disk size in bytes (a rough content-volume hint for the picker).
2465    pub bytes: u64,
2466}
2467
2468/// Bytes read from the head of a session file to recover a name + first prompt.
2469const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
2470/// Bytes read from the tail of a session file to recover a late name fact.
2471const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
2472/// Longest preview retained (characters, not bytes).
2473const SUMMARY_PREVIEW_CHARS: usize = 80;
2474
2475/// Summarize one session file for the pickers. Best-effort: an unreadable or
2476/// malformed file yields a summary with `empty: false` and no label, so it is
2477/// still selectable rather than silently dropped.
2478pub fn summarize_session_file(path: &Path) -> SessionSummary {
2479    use std::io::{Read, Seek, SeekFrom};
2480
2481    let Ok(mut file) = std::fs::File::open(path) else {
2482        return SessionSummary::default();
2483    };
2484    let Ok(size) = file.metadata().map(|m| m.len()) else {
2485        return SessionSummary::default();
2486    };
2487    if size == 0 {
2488        return SessionSummary {
2489            name: None,
2490            preview: None,
2491            empty: true,
2492            bytes: 0,
2493        };
2494    }
2495
2496    // Head: the header line (for the empty check) + a name fact + the first user
2497    // prompt. The whole window is scanned; it is already in memory.
2498    let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
2499    let head_len = file.read(&mut head).unwrap_or(0);
2500    head.truncate(head_len);
2501    let head = String::from_utf8_lossy(&head);
2502    let mut lines: Vec<&str> = head.lines().collect();
2503    let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
2504    // A header-only file is exactly the header plus an optional trailing `\n`.
2505    let empty = (size as usize) <= header_len + 1;
2506    // When the window stopped short of the file end, its final line is partial:
2507    // drop it so a half-read message can never be mistaken for a complete one.
2508    if (head_len as u64) < size && !lines.is_empty() {
2509        lines.pop();
2510    }
2511
2512    let mut summary = SessionSummary {
2513        name: None,
2514        preview: None,
2515        empty,
2516        bytes: size,
2517    };
2518    for line in lines {
2519        apply_summary_line(line, &mut summary);
2520    }
2521
2522    // Tail: a name set late in the session lives after the head window. Read the
2523    // last window and scan only complete lines so a torn/partial first line is
2524    // ignored. Only do this when the file is larger than the head window.
2525    if size > SUMMARY_HEAD_BYTES
2526        && file
2527            .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
2528            .is_ok()
2529    {
2530        let mut tail = Vec::new();
2531        if file.read_to_end(&mut tail).is_ok() {
2532            let tail = String::from_utf8_lossy(&tail);
2533            let mut tail_lines = tail.lines();
2534            // Drop the possibly-partial first line of the window.
2535            tail_lines.next();
2536            for line in tail_lines {
2537                if let Some(name) = name_from_line(line) {
2538                    summary.name = name;
2539                }
2540            }
2541        }
2542    }
2543    summary
2544}
2545
2546/// Extract the name from a `{"kind":"fact","fact":"name","name":…}` line.
2547/// Returns `Some(None)` for an explicit `name: null` (the fact was cleared).
2548fn name_from_line(line: &str) -> Option<Option<String>> {
2549    let value: serde_json::Value = serde_json::from_str(line).ok()?;
2550    if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
2551        return None;
2552    }
2553    if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
2554        return None;
2555    }
2556    Some(match value.get("name") {
2557        Some(serde_json::Value::String(name)) => Some(name.clone()),
2558        _ => None,
2559    })
2560}
2561
2562/// Fold one JSONL line into `summary`: record a name fact, or capture the first
2563/// user prompt as the preview.
2564fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
2565    if let Some(name) = name_from_line(line) {
2566        summary.name = name;
2567        return;
2568    }
2569    if summary.preview.is_some() {
2570        return;
2571    }
2572    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
2573        return;
2574    };
2575    if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
2576        return;
2577    }
2578    if value.get("type").and_then(|v| v.as_str()) != Some("message") {
2579        return;
2580    }
2581    let Some(message) = value.get("message") else {
2582        return;
2583    };
2584    if message.get("role").and_then(|v| v.as_str()) != Some("user") {
2585        return;
2586    }
2587    let text = match message.get("content") {
2588        Some(serde_json::Value::String(text)) => Some(text.as_str()),
2589        Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
2590            (block.get("type").and_then(|v| v.as_str()) == Some("text"))
2591                .then(|| block.get("text").and_then(|v| v.as_str()))
2592                .flatten()
2593        }),
2594        _ => None,
2595    };
2596    let Some(text) = text else { return };
2597    let collapsed = text
2598        .lines()
2599        .map(str::trim)
2600        .filter(|line| !line.is_empty())
2601        .collect::<Vec<_>>()
2602        .join(" ");
2603    if collapsed.is_empty() {
2604        return;
2605    }
2606    summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
2607}
2608
2609/// The human label for a session row: its recorded name, else the first user
2610/// prompt, else a marker that says the file has no transcript to show. Shared
2611/// by the startup picker and the in-TUI `/session` selector.
2612pub fn session_display_label(summary: &SessionSummary) -> String {
2613    if let Some(name) = summary.name.as_deref().map(str::trim) {
2614        if !name.is_empty() {
2615            return name.to_string();
2616        }
2617    }
2618    match summary.preview.as_deref().map(str::trim) {
2619        Some(preview) if !preview.is_empty() => preview.to_string(),
2620        _ => "(no messages)".to_string(),
2621    }
2622}
2623
2624/// Compact byte count (1.2MB / 34KB / 512B), shared by the startup picker and
2625/// the in-TUI `/session` selector.
2626pub fn format_session_bytes(bytes: u64) -> String {
2627    if bytes >= 1_048_576 {
2628        format!("{:.1}MB", bytes as f64 / 1_048_576.0)
2629    } else if bytes >= 1024 {
2630        format!("{}KB", bytes / 1024)
2631    } else {
2632        format!("{bytes}B")
2633    }
2634}
2635
2636/// The first 8 characters of a session id, enough to disambiguate two rows that
2637/// share a timestamp without shouting the whole UUID.
2638pub fn short_session_id(id: &str) -> &str {
2639    &id[..id.len().min(8)]
2640}
2641
2642/// Synchronously list every JSONL session file under `dir`, newest-first by
2643/// modification time.
2644///
2645/// The repo's [`list_session_metadata`] is async and scans one cwd-encoded
2646/// subdirectory level; the in-TUI `/session` selector runs on the blocking key
2647/// thread and cannot await it, so this walks the same layout directly. Both
2648/// `.jsonl` files directly under `dir` and one level of subdirectories are
2649/// accepted.
2650pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
2651    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
2652    let mut visit = |path: PathBuf| {
2653        if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
2654            return;
2655        }
2656        let modified = std::fs::metadata(&path)
2657            .and_then(|m| m.modified())
2658            .unwrap_or(std::time::UNIX_EPOCH);
2659        files.push((path, modified));
2660    };
2661    if let Ok(entries) = std::fs::read_dir(dir) {
2662        for entry in entries.flatten() {
2663            let path = entry.path();
2664            if path.is_dir() {
2665                if let Ok(inner) = std::fs::read_dir(&path) {
2666                    for child in inner.flatten() {
2667                        visit(child.path());
2668                    }
2669                }
2670            } else {
2671                visit(path);
2672            }
2673        }
2674    }
2675    files.sort_by(|a, b| b.1.cmp(&a.1));
2676    files.into_iter().map(|(path, _)| path).collect()
2677}
2678
2679/// Pick the session `--continue`/`-c` should open: the newest session that has
2680/// content (a header-only session resumes to an empty transcript). Falls back to
2681/// the newest overall when every session is empty, and `None` only when the
2682/// list is empty.
2683fn first_resumable_session(
2684    metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
2685) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
2686    metas
2687        .iter()
2688        .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
2689        .or_else(|| metas.first())
2690}
2691
2692/// Open a session whose id matches exactly or by file-name containment
2693/// (so `--session 01a02…` / a partial id / a full file name all work). The
2694/// TUI `/session` hot-switch calls this with the selector's item value.
2695pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
2696    let dir = default_session_dir(Path::new(cwd));
2697    open_session_by_id_in_dir(id, cwd, &dir).await
2698}
2699
2700pub(crate) async fn open_session_by_id_in_dir(
2701    id: &str,
2702    cwd: &str,
2703    dir: &Path,
2704) -> Result<Session, OpenError> {
2705    let metas = list_session_metadata_in_dir(cwd, dir)
2706        .await
2707        .map_err(|e| OpenError::Other(e.to_string()))?;
2708    let Some(meta) = metas
2709        .iter()
2710        .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
2711    else {
2712        return Err(OpenError::NotFound {
2713            requested: format!("session {id}"),
2714        });
2715    };
2716    open_session(meta, cwd).await.map_err(|e| {
2717        OpenError::Other(match e {
2718            // `open_session` renders as "Could not open the saved session:
2719            // <reason>" via `BuildError`. Carry the bare reason so it is not
2720            // prefixed twice (once here, once by the CLI/TUI).
2721            BuildError::SessionOpen(reason) => reason,
2722            other => other.to_string(),
2723        })
2724    })
2725}
2726
2727/// Publish the active session's id to plugins, on both channels that carry it.
2728///
2729/// Called on **every** activation of a session — the startup build AND every
2730/// in-process swap (`switch_to_session`, `fork_session`, and the plugin
2731/// `NewSession`/`Fork`/`SwitchSession` runtime actions all route through
2732/// [`set_active_session`]). Two things must stay in step:
2733///
2734/// - `ToolCallContext` — read per plugin **tool** call, which is why a stale
2735///   value there made a plugin's `todo`-style state follow the wrong session.
2736/// - `RPI_SESSION_ID` — read per **turn** by the provider hooks, which is the
2737///   only channel an observer gets for a pure-chat run. Process-global, which is
2738///   exactly the scope of "the session this process is serving".
2739///
2740/// The write overwrites unconditionally, deliberately. An inherited value is not
2741/// an override: `rpi` spawning `rpi` would otherwise hand the child the parent's
2742/// session id, and the child — which is *serving its own* session — would report
2743/// it forever. (Children no longer inherit the variable at all; see
2744/// `rpi_tools::HOST_PRIVATE_ENV`. This overwrite is the second line of defence,
2745/// for any other way a value can arrive pre-set.)
2746///
2747/// An embedder that genuinely wants to pin the id can revert to the old
2748/// behaviour by setting the variable *after* this runs; there is no supported
2749/// way to pin it before, because a session that does not match its own id is
2750/// never what the caller wants.
2751fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
2752    std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
2753    tool_context.set_session_id(id);
2754}
2755
2756/// Swap the session the harness serves, republishing the identity to plugins.
2757///
2758/// The single funnel for an in-process session change. Prefer this over calling
2759/// `AgentHarness::set_session` directly: the harness holds no reference to the
2760/// plugin channels, so a bare `set_session` would leave every plugin reporting
2761/// the session it had before the swap.
2762///
2763/// `tool_context` is optional because not every caller has one (the harness's own
2764/// tests, a headless embedder); the env channel is always published, since the
2765/// provider hooks depend on it.
2766pub(crate) async fn set_active_session(
2767    harness: &AgentHarness,
2768    session: Session,
2769    tool_context: Option<&rpi_extensions::ToolCallContext>,
2770) -> Result<(), String> {
2771    let metadata = session.get_metadata().await.ok();
2772    harness
2773        .set_session(session)
2774        .await
2775        .map_err(|e| e.to_string())?;
2776    if let Some(metadata) = metadata {
2777        std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
2778        if let Some(context) = tool_context {
2779            context.set_session_id(metadata.id);
2780        }
2781    }
2782    Ok(())
2783}
2784
2785/// Fork the harness's current session into a new JSONL session (new id, parent
2786/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
2787/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
2788/// and the plugin `runtime_action(Fork)` host share one implementation.
2789/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
2790/// does `harness.set_session(...)`).
2791pub(crate) async fn fork_session_storage(
2792    harness: &AgentHarness,
2793    cwd: &str,
2794    dir: &Path,
2795) -> Result<Session, String> {
2796    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2797    use rpi_tools::FileSystem;
2798
2799    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2800    let fs: Arc<dyn FileSystem> = env.clone();
2801    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2802        fs,
2803        sessions_root: dir.to_string_lossy().into_owned(),
2804        clock: Arc::new(SystemClock),
2805        ids: Arc::new(DefaultIdGenerator::new()),
2806    });
2807    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
2808    // it from the session list by the current session's id.
2809    let id = harness.session().storage().metadata().id.clone();
2810    let metas = list_session_metadata_in_dir(cwd, dir)
2811        .await
2812        .map_err(|e| e.to_string())?;
2813    let Some(source) = metas.iter().find(|m| m.id == id) else {
2814        return Err(format!("current session {id} not found on disk"));
2815    };
2816    let fork_storage = repo
2817        .fork_typed(
2818            source,
2819            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
2820                id: None,
2821                parent_session_id: Some(source.id.clone()),
2822                cwd: cwd.to_string(),
2823                metadata: None,
2824            },
2825            &rpi_harness::session::types::ForkOptions::default(),
2826        )
2827        .await
2828        .map_err(|e| e.to_string())?;
2829    claim_session_lock(&fork_storage.jsonl_metadata().path)?;
2830    Ok(Session::new(Arc::new(fork_storage), None))
2831}
2832
2833/// Validate a private copy before publishing it in the repo's normal cwd
2834/// directory. Keep the imported identity/history and never overwrite a saved
2835/// session (including when the source is already inside this repository).
2836pub(crate) async fn import_session_file(
2837    source: &Path,
2838    cwd: &str,
2839    dir: &Path,
2840) -> Result<Session, String> {
2841    use rpi_harness::session::jsonl::{
2842        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
2843    };
2844    use rpi_tools::FileSystem;
2845
2846    let dir = if dir.is_absolute() {
2847        dir.to_path_buf()
2848    } else {
2849        Path::new(cwd).join(dir)
2850    };
2851    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
2852    let staged = tempfile::NamedTempFile::new_in(&dir)
2853        .map_err(|e| e.to_string())?
2854        .into_temp_path();
2855    std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
2856    let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2857    let validated = JsonlSessionStorage::load(
2858        fs.clone(),
2859        &staged.to_string_lossy(),
2860        Arc::new(SystemClock),
2861        Arc::new(DefaultIdGenerator::new()),
2862    )
2863    .await
2864    .map_err(|e| format!("invalid session import: {e}"))?;
2865    let mut metadata = validated.jsonl_metadata().clone();
2866    drop(validated);
2867    if list_session_metadata_in_dir(cwd, &dir)
2868        .await
2869        .map_err(|e| e.to_string())?
2870        .iter()
2871        .any(|saved| saved.id == metadata.id)
2872    {
2873        return Err(format!(
2874            "session {} already exists in {}",
2875            metadata.id,
2876            dir.display()
2877        ));
2878    }
2879    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2880        fs,
2881        sessions_root: dir.to_string_lossy().into_owned(),
2882        clock: Arc::new(SystemClock),
2883        ids: Arc::new(DefaultIdGenerator::new()),
2884    });
2885    // Let the repository validate the id and choose its canonical destination.
2886    let destination = repo
2887        .create_typed(&JsonlSessionCreateOptions {
2888            id: Some(metadata.id.clone()),
2889            parent_session_id: metadata.parent_session_id.clone(),
2890            cwd: cwd.to_string(),
2891            metadata: metadata.metadata.clone(),
2892        })
2893        .await
2894        .map_err(|e| e.to_string())?;
2895    let path = destination.jsonl_metadata().path.clone();
2896    drop(destination);
2897    if let Err(error) = claim_session_lock(&path) {
2898        // Another process owns this destination; leave its file intact.
2899        return Err(error);
2900    }
2901    if let Err(error) = staged.persist(&path) {
2902        let _ = std::fs::remove_file(&path);
2903        return Err(format!("publish import: {error}"));
2904    }
2905    metadata.path = path;
2906    let storage = repo
2907        .open_by_jsonl_metadata(&metadata)
2908        .await
2909        .map_err(|e| e.to_string())?;
2910    Ok(Session::new(Arc::new(storage), None))
2911}
2912
2913/// Session logs this process has claimed, so re-claiming is a no-op.
2914static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
2915/// The held lock handles. Kept alive for the process lifetime — dropping the
2916/// `File` would release the OS lock, so the guard is parked here.
2917static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
2918
2919/// Claim the exclusive lock for a session log.
2920///
2921/// Two rpi processes appending to one JSONL diverge their `sequence` counters
2922/// (duplicate / gapped seqs) and corrupt it. The lock is an OS advisory lock on
2923/// `<path>.lock`: the second process gets a clear "already open" error instead
2924/// of corrupting the log, and the OS releases it automatically when the holder
2925/// exits (no stale-lock cleanup). Re-claiming a path this process already holds
2926/// is a no-op, so the TUI's session hot-switch can re-open freely.
2927fn claim_session_lock(session_path: &str) -> Result<(), String> {
2928    let lock_path = PathBuf::from(format!("{session_path}.lock"));
2929    if SESSION_LOCK_PATHS
2930        .get_or_init(|| Mutex::new(HashSet::new()))
2931        .lock()
2932        .unwrap()
2933        .contains(&lock_path)
2934    {
2935        return Ok(());
2936    }
2937    let file = std::fs::OpenOptions::new()
2938        .create(true)
2939        .write(true)
2940        .open(&lock_path)
2941        .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
2942    if fs2::FileExt::try_lock_exclusive(&file).is_err() {
2943        return Err(format!(
2944            "session {session_path} is already open in another rpi process"
2945        ));
2946    }
2947    SESSION_LOCKS
2948        .get_or_init(|| Mutex::new(Vec::new()))
2949        .lock()
2950        .unwrap()
2951        .push(file);
2952    SESSION_LOCK_PATHS
2953        .get_or_init(|| Mutex::new(HashSet::new()))
2954        .lock()
2955        .unwrap()
2956        .insert(lock_path);
2957    Ok(())
2958}
2959
2960/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
2961/// startup restore + TUI hot-switch).
2962async fn open_session(
2963    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2964    cwd: &str,
2965) -> Result<Session, BuildError> {
2966    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2967    use rpi_harness::session::types::SessionStorage;
2968    use rpi_tools::FileSystem;
2969
2970    let dir = default_session_dir(Path::new(cwd));
2971    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2972    let fs: Arc<dyn FileSystem> = env.clone();
2973    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2974        fs: fs.clone(),
2975        sessions_root: dir.to_string_lossy().into_owned(),
2976        clock: Arc::new(SystemClock),
2977        ids: Arc::new(DefaultIdGenerator::new()),
2978    });
2979    claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
2980    let storage = repo
2981        .open_by_jsonl_metadata(meta)
2982        .await
2983        .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
2984    // A session records the cwd it was created in. When that directory has
2985    // since been removed, continuing silently in it is confusing; warn the
2986    // user that we fell back to the process cwd (pi `getMissingSessionCwdIssue`).
2987    if let Some(issue) = crate::session_cwd::get_missing_session_cwd_issue(
2988        Some(Path::new(&meta.path)),
2989        Path::new(&meta.cwd),
2990        Path::new(cwd),
2991    ) {
2992        eprintln!(
2993            "warning: {}",
2994            crate::session_cwd::format_missing_session_cwd_error(&issue)
2995        );
2996    }
2997    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2998    Ok(Session::new(storage_arc, None))
2999}
3000
3001/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`
3002/// and for `/new` inside such a run — an ephemeral launch keeps swapping in
3003/// in-memory sessions, never a JSONL file.
3004pub(crate) fn ephemeral_session() -> Session {
3005    let storage = Arc::new(InMemorySessionStorage::new(
3006        SessionMetadata {
3007            id: "ephemeral".into(),
3008            created_at: 0,
3009            parent_session_id: None,
3010        },
3011        Arc::new(SystemClock),
3012        Arc::new(DefaultIdGenerator::new()),
3013    ));
3014    Session::new(storage, None).with_volatile_progress()
3015}
3016
3017/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
3018///
3019/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
3020/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
3021/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
3022/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
3023///
3024/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
3025/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
3026/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
3027pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3028    create_jsonl_session_with_id(dir, cwd, None).await
3029}
3030
3031/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
3032/// contract: the file is named with the given id so later `--session-id`
3033/// launches restore the same session).
3034pub(crate) async fn create_jsonl_session_with_id(
3035    dir: &Path,
3036    cwd: &str,
3037    id: Option<String>,
3038) -> Result<Session, String> {
3039    use rpi_harness::session::jsonl::{
3040        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3041    };
3042    use rpi_tools::FileSystem;
3043
3044    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
3045    // relative-path resolution matches the tool env.
3046    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3047    let fs: Arc<dyn FileSystem> = env.clone();
3048
3049    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3050        fs: fs.clone(),
3051        sessions_root: dir.to_string_lossy().into_owned(),
3052        clock: Arc::new(SystemClock),
3053        ids: Arc::new(DefaultIdGenerator::new()),
3054    });
3055
3056    let opts = JsonlSessionCreateOptions {
3057        id, // fresh uuidv7 when None (--session-id passes the fixed id)
3058        parent_session_id: None,
3059        cwd: cwd.to_string(),
3060        metadata: None,
3061    };
3062    let storage = repo
3063        .create_typed(&opts)
3064        .await
3065        .map_err(|e| format!("create session: {e}"))?;
3066    claim_session_lock(&storage.jsonl_metadata().path)
3067        .map_err(|e| format!("create session: {e}"))?;
3068    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
3069    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3070    Ok(Session::new(storage_arc, None))
3071}
3072
3073/// Read an `--append-system-prompt` target: if it's a readable file path, return
3074/// its contents; otherwise return `None` and let the caller use the literal.
3075fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3076    let path = Path::new(target);
3077    if path.is_file() {
3078        read_prompt_source(path, sources)
3079    } else {
3080        None
3081    }
3082}
3083
3084/// Record provenance at the successful read, rather than rediscovering files
3085/// during rendering (which could report a shadowed or unreadable file).
3086fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3087    let text = std::fs::read_to_string(path).ok()?;
3088    let absolute = if path.is_absolute() {
3089        path.to_path_buf()
3090    } else {
3091        std::env::current_dir()
3092            .map(|cwd| cwd.join(path))
3093            .unwrap_or_else(|_| path.to_path_buf())
3094    };
3095    let source = absolute.to_string_lossy().into_owned();
3096    if !sources.contains(&source) {
3097        sources.push(source);
3098    }
3099    Some(text)
3100}
3101
3102#[cfg(test)]
3103mod runtime_session_tests;
3104
3105#[cfg(test)]
3106mod tests {
3107    use super::*;
3108
3109    #[tokio::test]
3110    async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3111        extern "C" fn request(
3112            _: rpi_plugin_sdk::StbStringRef,
3113            _: *mut rpi_plugin_sdk::StbString,
3114            _: *mut std::ffi::c_void,
3115        ) -> i32 {
3116            1
3117        }
3118        let mut registry = rpi_extensions::ExtensionRegistry::new();
3119        for id in ["package", "legacy"] {
3120            registry.register_provider(rpi_extensions::RegisteredProvider {
3121                provider_id: id.into(),
3122                base_url: "http://localhost".into(),
3123                api_style: "openai-completions".into(),
3124                request_fn: request,
3125                stream_fn: None,
3126                plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3127                user_data: std::ptr::null_mut(),
3128            });
3129        }
3130        assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3131            "id":"package", "requiresAuth":true,
3132            "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3133        }]}).to_string()));
3134        let session = ExtensionSession::from_parts(
3135            Arc::new(registry.snapshot()),
3136            rpi_extensions::PluginKeepalive::empty(),
3137            vec![],
3138            None,
3139        );
3140        let providers = build_models_with_extensions_for_reload(
3141            &[],
3142            &session,
3143            tokio::runtime::Handle::current(),
3144        );
3145        assert_eq!(
3146            providers
3147                .iter()
3148                .map(|provider| provider.id())
3149                .collect::<Vec<_>>(),
3150            ["legacy"]
3151        );
3152    }
3153    use crate::args::Args;
3154
3155    #[test]
3156    fn loaded_context_paths_track_precedence_and_reload() {
3157        const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3158        if std::env::var_os(CHILD).is_none() {
3159            let dir = tempfile::tempdir().unwrap();
3160            let output = std::process::Command::new(std::env::current_exe().unwrap())
3161                .args([
3162                    "--exact",
3163                    "session::tests::loaded_context_paths_track_precedence_and_reload",
3164                    "--nocapture",
3165                ])
3166                .env(CHILD, "1")
3167                .env(crate::config::CONFIG_DIR_ENV, dir.path())
3168                .output()
3169                .unwrap();
3170            assert!(
3171                output.status.success(),
3172                "{}\n{}",
3173                String::from_utf8_lossy(&output.stdout),
3174                String::from_utf8_lossy(&output.stderr)
3175            );
3176            return;
3177        }
3178        tokio::runtime::Builder::new_current_thread()
3179            .enable_all()
3180            .build()
3181            .unwrap()
3182            .block_on(async {
3183                let agent = crate::config::agent_dir().unwrap();
3184                let cwd = agent.join("project");
3185                let project_config = cwd.join(".rpi");
3186                std::fs::create_dir_all(&project_config).unwrap();
3187                for (path, content) in [
3188                    (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
3189                    (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
3190                    (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
3191                    (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
3192                    (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
3193                    (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
3194                    (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
3195                ] {
3196                    std::fs::write(path, content).unwrap();
3197                }
3198                let resolved = crate::provider::resolve(
3199                    Some("anthropic"),
3200                    Some(crate::provider::DEFAULT_MODEL_ID),
3201                    None,
3202                    Some("test-key"),
3203                    None,
3204                )
3205                .unwrap();
3206                let args = Args {
3207                    no_session: true,
3208                    no_extensions: true,
3209                    no_skills: true,
3210                    no_prompt_templates: true,
3211                    trust_override: Some(true),
3212                    ..Default::default()
3213                };
3214                let (harness, _events, mut context) =
3215                    build(&resolved, &args, &cwd, true).await.unwrap();
3216                let resources = harness.get_resources().await.unwrap();
3217                let path = |p: PathBuf| p.to_string_lossy().into_owned();
3218                assert_eq!(
3219                    &resources.context_files[..2],
3220                    &[
3221                        path(project_config.join("SYSTEM.md")),
3222                        path(project_config.join("APPEND_SYSTEM.md")),
3223                    ]
3224                );
3225                assert!(resources
3226                    .context_files
3227                    .contains(&path(agent.join("AGENTS.md"))));
3228                assert!(resources
3229                    .context_files
3230                    .contains(&path(cwd.join("AGENTS.md"))));
3231                assert!(!resources
3232                    .context_files
3233                    .contains(&path(cwd.join("CLAUDE.md"))));
3234                assert!(!resources
3235                    .context_files
3236                    .contains(&path(agent.join("SYSTEM.md"))));
3237                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3238                assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
3239                assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
3240
3241                // Removing the selected project files makes reload fall back to
3242                // globals; removed instruction paths must disappear as well.
3243                std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
3244                std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
3245                std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
3246                assert!(
3247                    !reload_extension_resources(&harness, &context)
3248                        .await
3249                        .had_errors
3250                );
3251                let resources = harness.get_resources().await.unwrap();
3252                assert_eq!(
3253                    &resources.context_files[..2],
3254                    &[
3255                        path(agent.join("SYSTEM.md")),
3256                        path(agent.join("APPEND_SYSTEM.md")),
3257                    ]
3258                );
3259                assert!(resources
3260                    .context_files
3261                    .contains(&path(cwd.join("CLAUDE.md"))));
3262                assert!(!resources
3263                    .context_files
3264                    .contains(&path(project_config.join("SYSTEM.md"))));
3265
3266                // Explicit append files are listed; literal text and overridden
3267                // discovered base/append files are not invented as file sources.
3268                let explicit = agent.join("custom-append.md");
3269                std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
3270                context.args.system_prompt = Some("EXPLICIT_BASE".into());
3271                context.args.append_system_prompt =
3272                    vec![path(explicit.clone()), "literal append".into()];
3273                context.args.no_context_files = true;
3274                assert!(
3275                    !reload_extension_resources(&harness, &context)
3276                        .await
3277                        .had_errors
3278                );
3279                assert_eq!(
3280                    harness.get_resources().await.unwrap().context_files,
3281                    vec![path(explicit)]
3282                );
3283
3284                // The reload must honor the startup opt-out from project context.
3285                context.args.system_prompt = None;
3286                context.args.append_system_prompt.clear();
3287                context.args.no_context_files = false;
3288                context.args.trust_override = Some(false);
3289                assert!(
3290                    !reload_extension_resources(&harness, &context)
3291                        .await
3292                        .had_errors
3293                );
3294                assert!(harness
3295                    .get_resources()
3296                    .await
3297                    .unwrap()
3298                    .context_files
3299                    .is_empty());
3300                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
3301                assert!(
3302                    !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
3303                );
3304            });
3305    }
3306
3307    #[test]
3308    fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
3309        let dir = tempfile::tempdir().unwrap();
3310        let file = dir.path().join("SYSTEM.md");
3311        let mut sources = Vec::new();
3312        assert!(read_prompt_source(&file, &mut sources).is_none());
3313        std::fs::write(&file, [0xff, 0xfe]).unwrap();
3314        assert!(read_prompt_source(&file, &mut sources).is_none());
3315        assert!(read_append_target("literal instructions", &mut sources).is_none());
3316        assert!(sources.is_empty());
3317        std::fs::write(&file, "loaded instructions").unwrap();
3318        assert_eq!(
3319            read_prompt_source(&file, &mut sources).as_deref(),
3320            Some("loaded instructions")
3321        );
3322        assert!(read_prompt_source(&file, &mut sources).is_some());
3323        assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
3324    }
3325
3326    extern "C" fn deny_test_tool_call(
3327        _event: rpi_plugin_sdk::StablePluginEvent,
3328        _data: *mut std::ffi::c_void,
3329    ) -> i32 {
3330        rpi_plugin_sdk::EVENT_HANDLER_ABORT
3331    }
3332
3333    async fn invoke_test_tool_hook(
3334        hook: &rpi_agent::BeforeToolCall,
3335    ) -> Option<rpi_agent::BeforeToolCallResult> {
3336        let message = rpi_ai::providers::faux::faux_assistant_message(
3337            "approval",
3338            rpi_ai::types::StopReason::ToolUse,
3339        );
3340        let rpi_ai::types::Content::ToolCall(call) =
3341            rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
3342        else {
3343            unreachable!()
3344        };
3345        hook(
3346            rpi_agent::BeforeToolCallContext {
3347                assistant_message: &message,
3348                tool_call: &call,
3349                args: &call.arguments,
3350                context: &rpi_agent::AgentContext::default(),
3351            },
3352            tokio_util::sync::CancellationToken::new(),
3353        )
3354        .await
3355    }
3356
3357    #[tokio::test]
3358    async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
3359        let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
3360        let hook =
3361            extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
3362        assert!(invoke_test_tool_hook(&hook).await.is_none());
3363        for name in ["first-gate", "reloaded-gate"] {
3364            let mut registry = rpi_extensions::ExtensionRegistry::new();
3365            registry.register_event_handler(
3366                name.into(),
3367                rpi_plugin_sdk::EventTag::ToolCall,
3368                deny_test_tool_call,
3369                std::ptr::null_mut(),
3370            );
3371            let fresh = ExtensionSession::from_parts(
3372                Arc::new(registry.snapshot()),
3373                rpi_extensions::PluginKeepalive::empty(),
3374                Vec::new(),
3375                None,
3376            );
3377            {
3378                let mut current = sessions.lock().unwrap();
3379                if let Some(snapshot) = current.snapshot() {
3380                    snapshot.active_flag().store(false, Ordering::SeqCst);
3381                }
3382                *current = fresh;
3383            }
3384            let result = invoke_test_tool_hook(&hook).await.unwrap();
3385            assert!(result.block);
3386            assert!(result.reason.unwrap().contains(name));
3387        }
3388        *sessions.lock().unwrap() = ExtensionSession::none();
3389        assert!(invoke_test_tool_hook(&hook).await.is_none());
3390    }
3391
3392    /// A unique scratch file for one summary test.
3393    fn scratch(name: &str) -> PathBuf {
3394        let nanos = std::time::SystemTime::now()
3395            .duration_since(std::time::UNIX_EPOCH)
3396            .unwrap()
3397            .as_nanos();
3398        let path = std::env::temp_dir().join(format!(
3399            "rpi-summary-{}-{nanos}-{name}.jsonl",
3400            std::process::id()
3401        ));
3402        path
3403    }
3404
3405    #[test]
3406    fn summary_marks_a_header_only_session_empty() {
3407        let path = scratch("empty");
3408        std::fs::write(
3409            &path,
3410            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3411        )
3412        .unwrap();
3413        let summary = summarize_session_file(&path);
3414        assert!(summary.empty, "a header-only file has nothing to resume");
3415        assert!(summary.preview.is_none());
3416        assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
3417        std::fs::remove_file(&path).ok();
3418    }
3419
3420    #[test]
3421    fn summary_reads_the_first_user_prompt_and_name() {
3422        let path = scratch("content");
3423        let body = concat!(
3424            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3425            "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
3426            "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
3427            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
3428            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
3429        );
3430        std::fs::write(&path, body).unwrap();
3431        let summary = summarize_session_file(&path);
3432        assert!(!summary.empty);
3433        assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
3434        assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
3435        std::fs::remove_file(&path).ok();
3436    }
3437
3438    #[test]
3439    fn summary_extracts_a_block_style_user_prompt() {
3440        let path = scratch("blocks");
3441        let body = concat!(
3442            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3443            "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
3444        );
3445        std::fs::write(&path, body).unwrap();
3446        let summary = summarize_session_file(&path);
3447        assert_eq!(summary.preview.as_deref(), Some("line one line two"));
3448        assert_eq!(session_display_label(&summary), "line one line two");
3449        std::fs::remove_file(&path).ok();
3450    }
3451
3452    fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
3453        rpi_harness::session::jsonl::JsonlSessionMetadata {
3454            id: id.to_string(),
3455            created_at: 0,
3456            cwd: ".".to_string(),
3457            path: path.to_string_lossy().into_owned(),
3458            modified_at: 0,
3459            source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
3460            parent_session_id: None,
3461            legacy_parent_session_path: None,
3462            metadata: None,
3463        }
3464    }
3465
3466    #[test]
3467    fn continue_skips_newer_empty_sessions() {
3468        let empty = scratch("newest-empty");
3469        std::fs::write(
3470            &empty,
3471            "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3472        )
3473        .unwrap();
3474        let content = scratch("older-content");
3475        std::fs::write(
3476            &content,
3477            concat!(
3478                "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
3479                "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
3480            ),
3481        )
3482        .unwrap();
3483
3484        let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
3485        let picked = first_resumable_session(&metas).expect("a resumable session");
3486        assert_eq!(picked.id, "old");
3487
3488        // When every session is empty, fall back to the newest rather than
3489        // erroring.
3490        let all_empty = vec![meta_for(&empty, "empty")];
3491        assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
3492
3493        std::fs::remove_file(&empty).ok();
3494        std::fs::remove_file(&content).ok();
3495    }
3496
3497    #[test]
3498    fn list_session_files_sync_walks_the_cwd_subdirectory() {
3499        let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
3500        let nested = root.join("--proj--");
3501        std::fs::create_dir_all(&nested).unwrap();
3502        let file = nested.join("2026-01-01_a.jsonl");
3503        std::fs::write(&file, "{}\n").unwrap();
3504        let found = list_session_files_sync(&root);
3505        assert!(found.iter().any(|p| p == &file));
3506        std::fs::remove_dir_all(&root).ok();
3507    }
3508
3509    /// The default CLI tool set plus the `todo` tool its bundled plugins add.
3510    fn default_prompt_tools() -> Vec<String> {
3511        BUILTIN_TOOL_NAMES
3512            .iter()
3513            .copied()
3514            .chain(["todo"])
3515            .map(str::to_owned)
3516            .collect()
3517    }
3518
3519    #[test]
3520    fn default_prompt_lists_only_the_active_tools() {
3521        let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
3522        let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
3523        assert!(p.contains("- read  — Read file contents"));
3524        assert!(p.contains("- grep  — Search file contents"));
3525        for absent in [
3526            "- edit",
3527            "- write",
3528            "- bash",
3529            "- docs",
3530            "todo",
3531            "PowerShell cmdlets",
3532            "project documentation",
3533        ] {
3534            assert!(
3535                !p.contains(absent),
3536                "{absent} must not appear for a read-only run: {p}"
3537            );
3538        }
3539        assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
3540        let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
3541        assert!(none.contains("Available tools:\n(none)"));
3542    }
3543
3544    #[test]
3545    fn default_prompt_mentions_cwd_and_tools() {
3546        let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3547        assert!(p.contains("/tmp/proj"));
3548        assert!(p.contains("read"));
3549        assert!(p.contains("bash"));
3550        assert!(p.contains("edit"));
3551        assert!(p.contains("write"));
3552        assert!(p.contains("docs"));
3553        assert!(!p.contains("- grep"));
3554        assert!(!p.contains("- find"));
3555        assert!(!p.contains("- ls"));
3556        assert!(!p.contains("- powershell"));
3557        assert!(p.contains("Bash/POSIX syntax, including on Windows"));
3558        assert!(p.contains("do not pass PowerShell cmdlets"));
3559    }
3560
3561    #[test]
3562    fn default_prompt_instructs_language_matching() {
3563        // The model must detect the user's language and answer in it, so a
3564        // Chinese (or any non-English) user gets a same-language reply without
3565        // having to ask every turn.
3566        let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3567        let lower = p.to_lowercase();
3568        assert!(
3569            lower.contains("same language") && lower.contains("detect"),
3570            "the prompt must tell the model to detect and mirror the user's language: {p}"
3571        );
3572    }
3573
3574    #[test]
3575    fn default_prompt_keeps_working_state_in_the_visible_channel() {
3576        // Regression guard for the re-plan loop: on openai-compatible providers
3577        // the assistant's `thinking` blocks are dropped when the next request is
3578        // built (`providers/openai_completions.rs`: `Content::text_only`), so a
3579        // plan that lives only in reasoning is re-derived every turn. One
3580        // observed session paid for that 112 times in a single run, and twice
3581        // hallucinated the user's input ("The user is greeting me with hello",
3582        // with no such message). See `docs/llm-repetition-forensics.md` §八.
3583        let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
3584        let lower = p.to_lowercase();
3585        // The rule must state the mechanism, not just "be clear".
3586        assert!(
3587            lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
3588            "the prompt must explain that reasoning does not survive the turn: {p}"
3589        );
3590        // …and the remedy: keep state visible, use the todo tool.
3591        assert!(
3592            lower.contains("visible") && lower.contains("todo tool"),
3593            "the prompt must point at visible text and the todo tool: {p}"
3594        );
3595        // …plus the anti-repetition rule itself.
3596        assert!(
3597            lower.contains("re-stating the same plan"),
3598            "the prompt must call out plan re-statement as a bug: {p}"
3599        );
3600        // And the guidance must not instruct re-reading what is already shown.
3601        assert!(
3602            lower.contains("already shown earlier"),
3603            "the prompt must discourage re-fetching known content: {p}"
3604        );
3605    }
3606
3607    #[test]
3608    fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
3609        // `compat.requiresThinkingAsText` (and the native thinking channels of
3610        // anthropic-messages / openai-responses) DO carry the model's reasoning
3611        // back on the next request. Telling such a model "reasoning is not
3612        // carried into your next turn" is false, and the instruction to restate
3613        // the plan every turn is exactly the busy-work the re-plan analysis
3614        // measured. The wording must flip with the mechanism.
3615        let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
3616        let lower = p.to_lowercase();
3617        assert!(
3618            lower.contains("carried back"),
3619            "a replaying model must be told its reasoning survives: {p}"
3620        );
3621        assert!(
3622            !lower.contains("reasoning is not"),
3623            "the replayed variant must not claim reasoning is dropped: {p}"
3624        );
3625        assert!(
3626            lower.contains("do not re-derive"),
3627            "the replayed variant must forbid re-deriving the plan: {p}"
3628        );
3629        assert!(
3630            lower.contains("do not re-derive") && lower.contains("re-listing the same plan"),
3631            "the anti-repetition rule must survive the flip: {p}"
3632        );
3633        assert!(
3634            lower.contains("already shown earlier"),
3635            "the re-read rule must survive the flip: {p}"
3636        );
3637    }
3638
3639    /// Only side-effect-free tools may be replayed after a crash.
3640    ///
3641    /// Regression guard for an inverted default: `ToolReplay` used to default to
3642    /// `Safe` and the CLI then marked *every* tool `Safe`, so the first time
3643    /// recovery acted on the flag it would have re-run `bash`/`write`/`edit` and
3644    /// duplicated their side effects. upstream defaults to `"never"`
3645    /// (`drive/tools.ts: tool.replay ?? "never"`).
3646    #[test]
3647    fn only_read_only_tools_are_replayable() {
3648        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3649        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3650        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3651        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3652        let tools = build_tools(&context, &Args::default());
3653
3654        let replay_of = |name: &str| {
3655            tools
3656                .iter()
3657                .find(|tool| tool.tool.schema().name == name)
3658                .map(|tool| tool.replay)
3659        };
3660
3661        for read_only in ["read", "docs"] {
3662            assert_eq!(
3663                replay_of(read_only),
3664                Some(ToolReplay::Safe),
3665                "{read_only} is side-effect free, so recovery may re-run it"
3666            );
3667        }
3668        for mutating in ["bash", "edit", "write", "powershell"] {
3669            if let Some(replay) = replay_of(mutating) {
3670                assert_eq!(
3671                    replay,
3672                    ToolReplay::Never,
3673                    "{mutating} must never be replayed: re-running it would duplicate its effect"
3674                );
3675            }
3676        }
3677    }
3678
3679    #[test]
3680    fn default_tools_include_docs_lookup() {
3681        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
3682        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
3683        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
3684        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
3685        let names: Vec<String> = build_tools(&context, &Args::default())
3686            .iter()
3687            .map(|tool| tool.tool.schema().name.clone())
3688            .collect();
3689
3690        // `docs` is a default capability; the legacy rpi-only lookup tools stay
3691        // unregistered. Asserted by membership (not exact equality) because
3692        // `powershell` is Windows-only and `settings.json#defaultTools` may
3693        // narrow the set in a developer's environment.
3694        assert!(names.contains(&"docs".to_string()));
3695        for legacy in ["grep", "find", "ls"] {
3696            assert!(
3697                !names.contains(&legacy.to_string()),
3698                "legacy tool {legacy} should not be registered by default"
3699            );
3700        }
3701    }
3702
3703    #[test]
3704    fn reload_settings_preflight_respects_trust() {
3705        struct RestoreConfigDir(Option<std::ffi::OsString>);
3706
3707        impl Drop for RestoreConfigDir {
3708            fn drop(&mut self) {
3709                match self.0.take() {
3710                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3711                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3712                }
3713            }
3714        }
3715
3716        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3717        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3718        let tmp = tempfile::tempdir().unwrap();
3719        let agent = tmp.path().join("agent");
3720        let cwd = tmp.path().join("project");
3721        std::fs::create_dir_all(&agent).unwrap();
3722        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
3723        std::fs::write(agent.join("settings.json"), "{}").unwrap();
3724        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
3725        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3726        let args = Args::default();
3727
3728        let trusted = validate_settings_for_reload(&args, &cwd, true);
3729        let untrusted = validate_settings_for_reload(&args, &cwd, false);
3730        assert!(trusted
3731            .unwrap_err()
3732            .contains("could not load project settings"));
3733        assert!(untrusted.is_ok());
3734
3735        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3736        assert!(validate_settings_for_reload(&args, &cwd, false)
3737            .unwrap_err()
3738            .contains("could not load global settings"));
3739
3740        let local_only = Args {
3741            dev_local_only: true,
3742            ..Args::default()
3743        };
3744        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
3745    }
3746
3747    #[tokio::test(flavor = "current_thread")]
3748    async fn reload_preserves_live_resources_when_settings_break() {
3749        struct RestoreConfigDir(Option<std::ffi::OsString>);
3750
3751        impl Drop for RestoreConfigDir {
3752            fn drop(&mut self) {
3753                match self.0.take() {
3754                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3755                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3756                }
3757            }
3758        }
3759
3760        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3761        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3762        let tmp = tempfile::tempdir().unwrap();
3763        let agent = tmp.path().join("agent");
3764        let cwd = tmp.path().join("project");
3765        let skill_dir = tmp.path().join("configured-skills");
3766        std::fs::create_dir_all(&agent).unwrap();
3767        std::fs::create_dir_all(&cwd).unwrap();
3768        std::fs::create_dir_all(&skill_dir).unwrap();
3769        std::fs::write(
3770            skill_dir.join("SKILL.md"),
3771            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
3772        )
3773        .unwrap();
3774        std::fs::write(
3775            agent.join("settings.json"),
3776            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3777        )
3778        .unwrap();
3779        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3780
3781        let resolved = crate::provider::resolve(
3782            Some("anthropic"),
3783            Some(crate::provider::DEFAULT_MODEL_ID),
3784            None,
3785            Some("test-key"),
3786            None,
3787        )
3788        .unwrap();
3789        let args = Args {
3790            trust_override: Some(false),
3791            no_session: true,
3792            no_extensions: true,
3793            no_prompt_templates: true,
3794            no_context_files: true,
3795            system_prompt: Some("stable system prompt".into()),
3796            ..Args::default()
3797        };
3798        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3799        let before_resources = harness.get_resources().await.unwrap();
3800        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
3801        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
3802        let before_prompt = harness.get_system_prompt().await.unwrap();
3803        let before_tools: Vec<String> = harness
3804            .get_tools()
3805            .await
3806            .unwrap()
3807            .iter()
3808            .map(|tool| tool.tool.schema().name.clone())
3809            .collect();
3810        let before_bridge = context
3811            .action_bridge
3812            .lock()
3813            .unwrap()
3814            .as_ref()
3815            .unwrap()
3816            .clone();
3817
3818        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3819        let outcome = reload_extension_resources(&harness, &context).await;
3820
3821        assert!(outcome.had_warnings);
3822        assert!(outcome.summary.contains("Settings reload failed"));
3823        assert!(outcome.summary.contains("could not load global settings"));
3824        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
3825        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
3826        let after_tools: Vec<String> = harness
3827            .get_tools()
3828            .await
3829            .unwrap()
3830            .iter()
3831            .map(|tool| tool.tool.schema().name.clone())
3832            .collect();
3833        assert_eq!(after_tools, before_tools);
3834        let after_bridge = context
3835            .action_bridge
3836            .lock()
3837            .unwrap()
3838            .as_ref()
3839            .unwrap()
3840            .clone();
3841        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
3842    }
3843
3844    #[test]
3845    fn reload_preparation_rejects_settings_changed_during_derivation() {
3846        struct RestoreConfigDir(Option<std::ffi::OsString>);
3847
3848        impl Drop for RestoreConfigDir {
3849            fn drop(&mut self) {
3850                match self.0.take() {
3851                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3852                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3853                }
3854            }
3855        }
3856
3857        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3858        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3859        let tmp = tempfile::tempdir().unwrap();
3860        let agent = tmp.path().join("agent");
3861        let cwd = tmp.path().join("project");
3862        let first_skill_dir = tmp.path().join("first-skills");
3863        let second_skill_dir = tmp.path().join("second-skills");
3864        std::fs::create_dir_all(&agent).unwrap();
3865        std::fs::create_dir_all(&cwd).unwrap();
3866        std::fs::write(
3867            agent.join("settings.json"),
3868            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
3869        )
3870        .unwrap();
3871        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3872        let args = Args {
3873            trust_override: Some(false),
3874            no_extensions: true,
3875            no_prompt_templates: true,
3876            ..Args::default()
3877        };
3878
3879        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
3880            std::fs::write(
3881                agent.join("settings.json"),
3882                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
3883            )
3884            .unwrap();
3885        });
3886
3887        let error = result
3888            .err()
3889            .expect("settings mutation must fail preparation");
3890        assert!(error.contains("settings changed while reload inputs were being prepared"));
3891    }
3892
3893    #[tokio::test(flavor = "current_thread")]
3894    async fn reload_uses_frozen_settings_inputs_after_preparation() {
3895        struct RestoreConfigDir(Option<std::ffi::OsString>);
3896
3897        impl Drop for RestoreConfigDir {
3898            fn drop(&mut self) {
3899                match self.0.take() {
3900                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
3901                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
3902                }
3903            }
3904        }
3905
3906        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3907        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
3908        let tmp = tempfile::tempdir().unwrap();
3909        let agent = tmp.path().join("agent");
3910        let cwd = tmp.path().join("project");
3911        let skill_dir = tmp.path().join("configured-skills");
3912        std::fs::create_dir_all(&agent).unwrap();
3913        std::fs::create_dir_all(&cwd).unwrap();
3914        std::fs::create_dir_all(&skill_dir).unwrap();
3915        std::fs::write(
3916            skill_dir.join("SKILL.md"),
3917            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
3918        )
3919        .unwrap();
3920        std::fs::write(
3921            agent.join("settings.json"),
3922            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
3923        )
3924        .unwrap();
3925        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
3926
3927        let resolved = crate::provider::resolve(
3928            Some("anthropic"),
3929            Some(crate::provider::DEFAULT_MODEL_ID),
3930            None,
3931            Some("test-key"),
3932            None,
3933        )
3934        .unwrap();
3935        let args = Args {
3936            trust_override: Some(false),
3937            no_session: true,
3938            no_extensions: true,
3939            no_prompt_templates: true,
3940            no_context_files: true,
3941            system_prompt: Some("stable system prompt".into()),
3942            ..Args::default()
3943        };
3944        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
3945
3946        let outcome = reload_extension_resources_inner(&harness, &context, || {
3947            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3948        })
3949        .await;
3950
3951        assert!(!outcome.summary.contains("Settings reload failed"));
3952        assert_eq!(
3953            outcome.summary,
3954            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
3955        );
3956        let resources = harness.get_resources().await.unwrap();
3957        let skills = resources.skills.as_ref().unwrap();
3958        assert_eq!(skills.len(), 1);
3959        assert_eq!(skills[0].name, "frozen-skill");
3960    }
3961
3962    #[tokio::test]
3963    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
3964        let tmp = tempfile::tempdir().unwrap();
3965        let skill_path = tmp.path().join("explicit-skill.md");
3966        std::fs::write(
3967            &skill_path,
3968            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
3969        )
3970        .unwrap();
3971        let prompt_path = tmp.path().join("explicit-prompt.md");
3972        std::fs::write(
3973            &prompt_path,
3974            "---\ndescription: Explicit prompt\n---\nPrompt body",
3975        )
3976        .unwrap();
3977
3978        let args = Args {
3979            skill: vec![skill_path.clone()],
3980            prompt_template: vec![prompt_path.clone()],
3981            ..Args::default()
3982        };
3983        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
3984        let prompt_paths =
3985            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
3986        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
3987        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
3988
3989        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
3990        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
3991        assert_eq!(skills.skills[0].name, "explicit-skill");
3992
3993        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
3994        assert_eq!(
3995            prompts.prompt_templates.len(),
3996            1,
3997            "{:?}",
3998            prompts.diagnostics
3999        );
4000        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4001    }
4002
4003    #[test]
4004    fn tool_policy_applies_to_rust_and_js_active_names() {
4005        let names = vec![
4006            "read".to_string(),
4007            "ask_user_question".to_string(),
4008            "write".to_string(),
4009        ];
4010
4011        let args = Args {
4012            tools: Some(vec!["read".into(), "ask_user_question".into()]),
4013            ..Args::default()
4014        };
4015        assert_eq!(
4016            filter_active_tool_names(names.clone(), &args),
4017            vec!["read", "ask_user_question"]
4018        );
4019
4020        let args = Args {
4021            exclude_tools: Some(vec!["ask_user_question".into()]),
4022            ..Args::default()
4023        };
4024        assert_eq!(
4025            filter_active_tool_names(names.clone(), &args),
4026            vec!["read", "write"]
4027        );
4028
4029        let args = Args {
4030            no_tools: true,
4031            ..Args::default()
4032        };
4033        assert!(filter_active_tool_names(names, &args).is_empty());
4034    }
4035
4036    #[test]
4037    fn select_ephemeral_when_no_session() {
4038        let args = Args {
4039            no_session: true,
4040            ..Args::default()
4041        };
4042        let cwd = Path::new("/tmp");
4043        assert!(matches!(
4044            select_session(&args, cwd),
4045            SessionSelection::Ephemeral
4046        ));
4047    }
4048
4049    #[test]
4050    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
4051        let defaults = Args::default();
4052        assert!(resolve_project_trust(
4053            &defaults,
4054            Path::new("C:/definitely-not-a-project")
4055        ));
4056
4057        let denied = Args {
4058            trust_override: Some(false),
4059            ..Args::default()
4060        };
4061        assert!(!resolve_project_trust(
4062            &denied,
4063            Path::new("C:/definitely-not-a-project")
4064        ));
4065    }
4066
4067    #[tokio::test(flavor = "current_thread")]
4068    async fn build_preserves_the_supplied_startup_project_snapshot() {
4069        struct RestoreConfigDir(Option<std::ffi::OsString>);
4070
4071        impl Drop for RestoreConfigDir {
4072            fn drop(&mut self) {
4073                match self.0.take() {
4074                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4075                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4076                }
4077            }
4078        }
4079
4080        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4081        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4082        let tmp = tempfile::tempdir().unwrap();
4083        let agent = tmp.path().join("agent");
4084        let cwd = tmp.path().join("project");
4085        std::fs::create_dir_all(&agent).unwrap();
4086        std::fs::create_dir_all(&cwd).unwrap();
4087        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4088
4089        let resolved = crate::provider::resolve(
4090            Some("anthropic"),
4091            Some(crate::provider::DEFAULT_MODEL_ID),
4092            None,
4093            Some("test-key"),
4094            None,
4095        )
4096        .unwrap();
4097        let args = Args {
4098            trust_override: Some(false),
4099            no_session: true,
4100            no_tools: true,
4101            no_extensions: true,
4102            no_skills: true,
4103            no_prompt_templates: true,
4104            no_context_files: true,
4105            system_prompt: Some("test prompt".into()),
4106            ..Args::default()
4107        };
4108
4109        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4110
4111        assert_eq!(context.cwd, cwd);
4112        assert!(context.project_trusted);
4113    }
4114
4115    #[tokio::test(flavor = "current_thread")]
4116    async fn build_applies_the_name_flag_to_the_session() {
4117        struct RestoreConfigDir(Option<std::ffi::OsString>);
4118
4119        impl Drop for RestoreConfigDir {
4120            fn drop(&mut self) {
4121                match self.0.take() {
4122                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4123                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4124                }
4125            }
4126        }
4127
4128        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4129        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4130        let tmp = tempfile::tempdir().unwrap();
4131        let agent = tmp.path().join("agent");
4132        let cwd = tmp.path().join("project");
4133        std::fs::create_dir_all(&agent).unwrap();
4134        std::fs::create_dir_all(&cwd).unwrap();
4135        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4136
4137        let resolved = crate::provider::resolve(
4138            Some("anthropic"),
4139            Some(crate::provider::DEFAULT_MODEL_ID),
4140            None,
4141            Some("test-key"),
4142            None,
4143        )
4144        .unwrap();
4145        let args = Args {
4146            trust_override: Some(false),
4147            no_tools: true,
4148            no_extensions: true,
4149            no_skills: true,
4150            no_prompt_templates: true,
4151            no_context_files: true,
4152            system_prompt: Some("test prompt".into()),
4153            name: Some("smoke name".into()),
4154            session_dir: Some(cwd.join("sessions")),
4155            ..Args::default()
4156        };
4157
4158        let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
4159        assert_eq!(
4160            harness.get_name().await.unwrap().as_deref(),
4161            Some("smoke name")
4162        );
4163    }
4164
4165    #[test]
4166    fn select_latest_for_continue_and_resume() {
4167        let _env = crate::config::test_support::SessionDirEnv::new(None);
4168        let args = Args {
4169            continue_session: true,
4170            ..Args::default()
4171        };
4172        let cwd = Path::new("/tmp");
4173        assert!(matches!(
4174            select_session(&args, cwd),
4175            SessionSelection::Latest { .. }
4176        ));
4177
4178        let args = Args {
4179            resume: true,
4180            ..Args::default()
4181        };
4182        assert!(matches!(
4183            select_session(&args, cwd),
4184            SessionSelection::Latest { .. }
4185        ));
4186    }
4187
4188    #[test]
4189    fn select_by_id_for_session_flag() {
4190        let _env = crate::config::test_support::SessionDirEnv::new(None);
4191        let args = Args {
4192            session: Some("01a02ece".into()),
4193            ..Args::default()
4194        };
4195        let cwd = Path::new("/tmp");
4196        assert!(matches!(
4197            select_session(&args, cwd),
4198            SessionSelection::ById { id, .. } if id == "01a02ece"
4199        ));
4200    }
4201
4202    #[test]
4203    fn select_new_with_custom_dir() {
4204        let _env = crate::config::test_support::SessionDirEnv::new(None);
4205        let args = Args {
4206            session_dir: Some(PathBuf::from("/tmp/sess")),
4207            ..Args::default()
4208        };
4209        let cwd = Path::new("/tmp");
4210        match select_session(&args, cwd) {
4211            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
4212            other => panic!("expected New, got {other:?}"),
4213        }
4214    }
4215
4216    #[test]
4217    fn select_new_default_dir() {
4218        let _env = crate::config::test_support::SessionDirEnv::new(None);
4219        let args = Args::default();
4220        let cwd = Path::new("/proj");
4221        match select_session(&args, cwd) {
4222            SessionSelection::New { dir, .. } => {
4223                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
4224            }
4225            other => panic!("expected New, got {other:?}"),
4226        }
4227    }
4228
4229    #[test]
4230    fn default_session_dir_only_uses_rpi() {
4231        let _env = crate::config::test_support::SessionDirEnv::new(None);
4232        let tmp = tempfile::tempdir().unwrap();
4233        let cwd = tmp.path();
4234        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4235        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4236        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
4237        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
4238    }
4239
4240    #[test]
4241    fn default_session_dir_uses_env_var() {
4242        let tmp = tempfile::tempdir().unwrap();
4243        let cwd = tmp.path();
4244        let custom = tmp.path().join("custom_sessions");
4245        let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
4246        assert_eq!(default_session_dir(cwd), custom);
4247    }
4248
4249    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4250    async fn ephemeral_session_builds_roundtrips() {
4251        // Sanity: the ephemeral path produces a usable Session facade (the
4252        // harness build itself needs a provider; tested via the integration
4253        // path in tests/build.rs instead).
4254        let s = ephemeral_session();
4255        let leaf = s.get_leaf_id().await;
4256        assert!(leaf.is_ok());
4257    }
4258
4259    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
4260    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
4261
4262    /// The exclusion policy, checked against the constants rather than copies.
4263    ///
4264    /// `rpi-tools` builds its exclusion from `rpi-plugin-sdk`'s declarations, so
4265    /// within this repo a rename is a compile error — this test is not the link
4266    /// between those two crates any more. What it still guards is the part the
4267    /// compiler cannot see:
4268    ///
4269    /// - **The wire values.** `rpi-langfuse` lives in a different repository and
4270    ///   agrees on the *strings*, not the constant paths. Rewriting a constant's
4271    ///   value would compile here and silently stop matching there, so the exact
4272    ///   values are pinned below. Changing one is a deliberate, breaking change to
4273    ///   a cross-repo contract, and it should fail here first.
4274    /// - **Coverage.** Every name the SDK declares must actually be excluded.
4275    /// - **Narrowness.** The user's own configuration must still reach the shell.
4276    #[test]
4277    fn host_private_env_matches_the_declared_contract() {
4278        // 1. The wire values. Pinned literally on purpose: see the doc comment.
4279        assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
4280        assert_eq!(
4281            rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
4282            [
4283                "LANGFUSE_PI_PARENT_TRACE_ID",
4284                "LANGFUSE_PI_PARENT_SPAN_ID",
4285                "LANGFUSE_PI_PARENT_SESSION_ID",
4286                "LANGFUSE_PI_PARENT_DEPTH",
4287            ],
4288            "these strings are what rpi-langfuse (a different repo) matches; a \
4289             change here is a breaking cross-repo change, not a refactor"
4290        );
4291
4292        // 2. Coverage — derived from the declarations, so a newly added family
4293        //    member is checked without anyone remembering to extend this test.
4294        for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
4295            .iter()
4296            .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
4297        {
4298            assert!(
4299                rpi_tools::is_host_private_env(name),
4300                "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
4301                rpi_tools::HOST_PRIVATE_ENV
4302            );
4303        }
4304
4305        // 3. Narrowness — these name how the *user* configured the run and are
4306        //    exactly what a command inside the shell should still see.
4307        for visible in [
4308            crate::args::RPI_OFFLINE_ENV,
4309            crate::config::CONFIG_DIR_ENV,
4310            "PATH",
4311        ] {
4312            assert!(
4313                !rpi_tools::is_host_private_env(visible),
4314                "{visible} describes the user's configuration and must still reach the shell"
4315            );
4316        }
4317    }
4318    #[tokio::test]
4319    async fn reload_shutdown_joins_all_handlers_even_after_veto() {
4320        use rpi_plugin_sdk::{EventTag, StablePluginEvent};
4321        use std::sync::atomic::AtomicUsize;
4322        static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
4323            rpi_plugin_sdk::handles::ToolHandles::new();
4324        struct ContextOwner(usize);
4325        impl Drop for ContextOwner {
4326            fn drop(&mut self) {
4327                drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
4328            }
4329        }
4330        extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
4331            let context = CONTEXTS.get(data).expect("live test context");
4332            let calls = context.lock().clone();
4333            std::thread::sleep(std::time::Duration::from_millis(20));
4334            if calls.fetch_add(1, Ordering::SeqCst) == 0 {
4335                rpi_plugin_sdk::EVENT_HANDLER_ABORT
4336            } else {
4337                0
4338            }
4339        }
4340        let calls = Arc::new(AtomicUsize::new(0));
4341        let context = CONTEXTS.insert(calls.clone());
4342        let _owner = ContextOwner(context as usize);
4343        let mut registry = rpi_extensions::ExtensionRegistry::new();
4344        for name in ["first", "second"] {
4345            registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
4346        }
4347        super::shutdown_extensions_for_reload(
4348            Some(Arc::new(registry.snapshot())),
4349            rpi_extensions::PluginKeepalive::empty(),
4350        )
4351        .await
4352        .unwrap();
4353        assert_eq!(calls.load(Ordering::SeqCst), 2);
4354    }
4355}