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