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