Skip to main content

rpi_cli/
session.rs

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