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