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