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