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