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