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