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, CompactionSettings, DrivingMode, HarnessTool,
48    HarnessToolExecution, RetryPolicy, ToolReplay,
49};
50use rpi_tools::{
51    create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
52    MutationQueueRegistry, OsExecutionEnv,
53};
54
55use crate::args::Args;
56use crate::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: Default::default(),
732        retry: RetryPolicy::default(),
733        compaction: CompactionSettings::default(),
734        steering_mode: Default::default(),
735        follow_up_mode: Default::default(),
736        tool_execution: HarnessToolExecution::default(),
737        drive: DrivingMode::default(),
738        session,
739        // B5c: inject the resolved gateway provider PLUS one `Arc<dyn Provider>`
740        // per registered extension provider (`PluggableProvider` wraps a plugin's
741        // sync `ProviderRequestFn`). The harness's `build_stream_fn` resolves a
742        // provider lazily per call by `models.iter().find(|p| p.id() == model.provider)`,
743        // so a catalog model whose `provider` matches an extension provider's id
744        // routes to it. Extension providers land AFTER the gateway so the gateway
745        // stays first-match for its own ids (first-wins on a `.find`).
746        models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
747        to_provider_messages: None,
748        entry_projectors: Default::default(),
749        agent_emitter: Some(emitter),
750        // B3b: the three exists-but-`None` loop hooks — populated when an
751        // extension session registers handlers for the matching pi `on()`
752        // tags (before_tool_call/after_tool_call/context). v1 leaves them `None`
753        // here; the rpi-extensions adapter that owns plugin handler dispatch is
754        // wired in the same build path once B3b's host-side adapter lands.
755        before_tool_call: None,
756        after_tool_call: None,
757        transform_context: None,
758        entry_transforms: Vec::new(),
759        // Extension provider hooks (B4): plugins subscribing to the
760        // BeforeProviderRequest / BeforeProviderHeaders / AfterProviderResponse
761        // events observe every provider call (observer semantics — the handler
762        // ABI has no patch channel in v1). A session without provider-hook
763        // subscribers runs hook-free.
764        provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
765            .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
766    };
767
768    let harness = match AgentHarness::create(options).await {
769        Ok(h) => {
770            // Fill the extension action host now that the harness exists
771            // (plugin runtime_action calls can then reach it).
772            crate::extensions_actions::HarnessActionHost::set_harness(
773                &harness_cell,
774                Arc::new(h.clone()),
775            );
776            h
777        }
778        Err(e) => return Err(BuildError::HarnessCreate(e.to_string())),
779    };
780
781    // ---- B5d: assemble the ReloadContext the TUI holds ----
782    // Every field is cheap to clone (Arc / Vec / args Clone). The cells own the
783    // live session + bridge so `/reload` can swap them; the harness itself is
784    // NOT held here (the TUI already owns a `&AgentHarness` / clone at the call
785    // site — passing it into `reload_extension_resources` keeps this structfree
786    // of a harness back-reference so it can be `Clone` into the reload callback).
787    let reload_context = ReloadContext {
788        extension_session: Arc::new(Mutex::new(extension_session)),
789        js_extension_session: js_extension_session.clone(),
790        package_resources: Arc::new(package_resources.clone()),
791        action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
792        catalog,
793        gateway: resolved.provider.clone(),
794        runtime: runtime.clone(),
795        cwd: cwd.to_path_buf(),
796        project_trusted,
797        args: args.clone(),
798        resolved_model: resolved.model.clone(),
799        broadcast: broadcast_for_context,
800        mailbox: reload_mailbox,
801        dev_extension: None,
802    };
803
804    Ok((harness, event_rx, reload_context))
805}
806
807// ===========================================================================
808// B5d — `/reload`: re-run extension + resource discovery into a LIVE harness
809// ===========================================================================
810//
811// `/reload` (interactive TUI command, or a plugin's `runtime_action(Reload)`)
812// re-runs everything `build` did around resources/extensions WITHOUT rebuilding
813// the `AgentHarness` itself (rebuilding would tear down the session/lane/event
814// wiring + the broadcast drain task the TUI owns). Instead it:
815//
816//  1. Builds a fresh `ExtensionSession` (re-load the cdylibs) over the same
817//     dir set, with a FRESH `ActionBridge` (the old one is `invalidate`d so
818//     in-flight plugin→host calls on the old bridge fail fast).
819//  2. Fans `resources_discover(_, "reload")` over the fresh snapshot.
820//  3. Re-runs the Part-A loaders (skills/prompts/context/SYSTEM.md/
821//     APPEND_SYSTEM.md) with the discovered paths merged in — same precedence
822//     + `--no-*` gates as startup.
823//  4. Rebuilds the harness's live state via the B5d setters
824//     (`set_system_prompt`/`set_resources`/`set_agent_emitter`/`set_models`/
825//     `set_provider_hooks`/`set_tools`) so the NEXT run observes the reloaded
826//     config (in-flight runs finish on the old `ConfigSnapshot`).
827//  5. Swaps the cells (`ExtensionSession`, `ActionBridge`, harness action
828//     host's harness cell stays — the harness is the same object) and drops
829//     the old session + bridge (their keepalives unmap the old cdylibs; the
830//     new session's keepalive holds the fresh mappings).
831//
832// The reload is a `rpi-cli` concern (NOT a harness op): `rpi-extensions`
833// carries only the `ActionBridge` staleness flag + a `ReloadMailbox` `()` signal
834// (no pi-cli `TuiMessage` type — leaf DAG preserved). The TUI owns the mailbox
835// receiver + the actual reload routine; a plugin's
836// `runtime_action(Reload)` signals the mailbox and returns `Ok(null)`
837// immediately so the calling plugin's cdylib is NOT unmapped while its
838// `runtime_action` frame is still on the stack (the self-unmapping race a
839// synchronous plugin-initiated reload would have).
840//
841// `reload_extension_resources` is the shared routine both `/reload` (TUI) and
842// a plugin's `runtime_action(Reload)` (via the mailbox) drive. It is `pub` so
843// the TUI's main-loop handler + the mailbox-driven path call the same code.
844
845/// The cell that holds the live `ExtensionSession` across a `/reload`. Cloned
846/// into every site that needs the current session (the TUI, the reload
847/// callback). On reload the old session is `replace`d out (its `active` flag
848/// flipped + its keepalive dropped, unmapping the old cdylibs) and the fresh one
849/// `store`d. Carried as a plain `ExtensionSession` (not `Option`) — a `none()`
850/// placeholder fills the slot while the fresh one is being built.
851pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
852
853/// The cell that holds the live `ActionBridge` across a `/reload`. A plugin
854/// stores the bridge's raw `user_data` pointer during `register`; on reload the
855/// old bridge is `invalidate`d (in-flight calls fail fast) and the fresh one
856/// `store`d. The fresh session's plugins are handed the fresh bridge pointer.
857pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
858
859/// Everything `/reload` needs to rebuild extension + resource state into a live
860/// harness. Built once in [`build`] (alongside the harness) and held by the TUI
861/// (cloned into the reload callback the bridge carries + the `/reload` command
862/// handler). The harness itself is NOT held here — the TUI already owns a
863/// `&AgentHarness` / a clone; passing it at the call site keeps this struct
864/// free of a harness back-reference (so it can be `Clone` and moved into the
865/// reload callback without borrowing the harness).
866#[derive(Clone)]
867pub struct ReloadContext {
868    /// The live extension-session cell (swapped on reload).
869    pub extension_session: ExtensionSessionCell,
870    /// JS/TS Pi extension host kept alive for the interactive session.
871    pub js_extension_session: Option<crate::js_extensions::JsExtensionSession>,
872    /// The exact trust-gated package set resolved during initial build. The TUI
873    /// reuses this snapshot so failed startup remediation is not retried or
874    /// accidentally exposed by a second best-effort discovery pass.
875    pub package_resources: Arc<crate::packages::PackageResources>,
876    /// The live action-bridge cell (swapped + old invalidated on reload).
877    pub action_bridge: ActionBridgeCell,
878    /// The model catalog (read-only) the host uses to resolve `set_model(id)`.
879    /// `available_catalog(resolved)` is captured once — reload does not re-resolve
880    /// the provider (auth/provider resolution is a startup concern; reloading
881    /// extensions does not re-open auth).
882    pub catalog: Vec<rpi_ai::Model>,
883    /// The resolved gateway provider clone (for rebuilding `models` =
884    /// `vec![gateway] + PluggableProvider::from_session`). Cheap to clone (`Arc`).
885    pub gateway: Arc<dyn Provider>,
886    /// The ambient runtime handle (captured in `build`) — `PluggableProvider`
887    /// + the fresh `ActionBridge` need a captured `Handle` to spawn from any
888    /// thread.
889    pub runtime: tokio::runtime::Handle,
890    /// The cwd (for static resource-dir resolution + context-file walk).
891    pub cwd: PathBuf,
892    /// The project-trust decision captured before provider and session setup.
893    /// Startup consumers reuse this value so extension code cannot change the
894    /// effective policy by mutating the process cwd while it is loading.
895    pub project_trusted: bool,
896    /// The parsed args (cloned) — `--no-*`/`--tools`/`--exclude-tools`/
897    /// `--extensions-dir`/`--no-extensions`/`--system-prompt`/etc all apply on
898    /// reload exactly as at startup (a reload re-reads the same flags; it does
899    /// not pick up argv changes mid-session, which is the right contract — pi's
900    /// `/reload` re-runs discovery with the same config).
901    pub args: Args,
902    /// The resolved model + thinking level (the harness's active model stays
903    /// unless `set_model` changed it; reload does not touch the model).
904    pub resolved_model: rpi_ai::Model,
905    /// The broadcast emitter the harness was built with. Reload rebuilds the
906    /// `TeeEmitter` over the fresh `ExtensionEmitter` (the old tee's extension
907    /// child is dropped, unsubscribing from the old registry). The broadcast
908    /// half stays live the whole session (the TUI's drain task holds the
909    /// receiver), so we keep a handle to re-wrap.
910    pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
911    /// The session-long reload mailbox (B5d). Build creates one, installs it on
912    /// the initial `ActionBridge` via [`reload_callback_from_mailbox`], and hands
913    /// a clone to the TUI. The TUI installs its `TuiMessage` sender so a plugin's
914    /// `runtime_action(Reload)` signals the main loop — the reload routine reuses
915    /// THIS mailbox (not a fresh default) when building the fresh bridge, so the
916    /// bridge always carries the mailbox the TUI installed across reloads.
917    pub mailbox: rpi_extensions::ReloadMailbox,
918    /// Active `rpi dev` extension builder. `/reload` rebuilds it before
919    /// swapping plugin sessions; its watcher signals `mailbox` after a
920    /// successful background build.
921    pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
922}
923
924/// The outcome of a reload: a human-readable status line for the transcript
925/// (counts of what reloaded), and whether any load diagnostics appeared.
926pub struct ReloadOutcome {
927    /// One-line summary for the transcript note (e.g. "Reloaded 2 plugin(s),
928    /// 5 skill(s), 1 prompt(s).").
929    pub summary: String,
930    /// True iff at least one extension load warning fired (ABI mismatch / skip).
931    pub had_warnings: bool,
932}
933
934struct PreparedReloadInputs {
935    package_resources: crate::packages::PackageResources,
936    extension_dirs: Vec<PathBuf>,
937    skill_base_dirs: Vec<PathBuf>,
938    prompt_base_dirs: Vec<PathBuf>,
939}
940
941/// Append the resource sources that are specific to a reload after the
942/// conventional project/global directories. Keep this order aligned with the
943/// initial build: explicit CLI paths must remain available after `/reload`,
944/// while discovered and package resources retain their lower precedence.
945fn append_reload_resource_paths(
946    mut paths: Vec<PathBuf>,
947    explicit: &[PathBuf],
948    discovered: &[String],
949    js_paths: &[PathBuf],
950    package_paths: &[PathBuf],
951) -> Vec<PathBuf> {
952    paths.extend(explicit.iter().cloned());
953    paths.extend(discovered.iter().map(PathBuf::from));
954    paths.extend(js_paths.iter().cloned());
955    paths.extend(package_paths.iter().cloned());
956    paths
957}
958
959/// Re-run extension + resource discovery and push the rebuilt state into the
960/// live `harness` via the B5d setters. The old `ExtensionSession` +
961/// `ActionBridge` are invalidated + swapped in [`ReloadContext`]'s cells. This
962/// is the single routine both `/reload` (TUI) and a plugin's
963/// `runtime_action(Reload)` drive (the latter via the mailbox signal).
964///
965/// Returns a [`ReloadOutcome`] for the transcript. Best-effort: a failure in
966/// one channel (e.g. a plugin that fails to reload) does not abort the others —
967/// the reload completes with whatever loaded, mirroring pi's per-plugin
968/// skip-on-error. A hard failure (e.g. the harness is closed) surfaces as an
969/// error summary.
970pub async fn reload_extension_resources(
971    harness: &AgentHarness,
972    ctx: &ReloadContext,
973) -> ReloadOutcome {
974    reload_extension_resources_inner(harness, ctx, || {}).await
975}
976
977async fn reload_extension_resources_inner<F>(
978    harness: &AgentHarness,
979    ctx: &ReloadContext,
980    after_prepare: F,
981) -> ReloadOutcome
982where
983    F: FnOnce() + Send,
984{
985    // Resolve the arguments once for this reload. `rpi dev` may append its
986    // freshly staged extension directory; every subsequent loader and policy
987    // decision must observe that same effective set rather than falling back
988    // to the pre-dev snapshot held in `ctx.args`.
989    let mut effective_args = ctx.args.clone();
990    if let Some(dev) = &ctx.dev_extension {
991        if let Err(error) = dev
992            .rebuild()
993            .and_then(|_| dev.apply_to_args(&mut effective_args))
994        {
995            return ReloadOutcome {
996                summary: format!(
997                    "Extension build failed for {}: {error}. Keeping the currently loaded version.",
998                    dev.package_name()
999                ),
1000                had_warnings: true,
1001            };
1002        }
1003    }
1004    let cwd_str = ctx.cwd.to_string_lossy().to_string();
1005    let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1006    let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1007        Ok(prepared) => prepared,
1008        Err(error) => {
1009            return ReloadOutcome {
1010                summary: format!(
1011                    "Settings reload failed: {error}. Keeping the currently loaded resources."
1012                ),
1013                had_warnings: true,
1014            };
1015        }
1016    };
1017    after_prepare();
1018    let PreparedReloadInputs {
1019        package_resources,
1020        extension_dirs,
1021        skill_base_dirs,
1022        prompt_base_dirs,
1023    } = prepared;
1024    let mut warnings = false;
1025
1026    // ---- 1. Build a fresh ActionBridge + ExtensionSession ----
1027    // The fresh bridge carries the SAME `HarnessActionHost` (the host's harness
1028    // cell already points at this harness; the host impl is reusable across
1029    // reloads — only the bridge's staleness flag + reload callback differ). We
1030    // re-use the host by reading it off the OLD bridge (it's the same
1031    // `Arc<dyn RuntimeActionHost>`).
1032    let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1033    let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1034        Some(b) => b.clone_host(),
1035        None => {
1036            // No prior bridge (no extensions ever loaded). Build a fresh host so
1037            // a reload that newly discovers plugins can still drive actions.
1038            let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1039                ctx.catalog.clone(),
1040                ctx.cwd.clone(),
1041                ctx.runtime.clone(),
1042                ctx.args.unknown_flags.clone(),
1043            );
1044            crate::extensions_actions::HarnessActionHost::set_harness(
1045                &_cell,
1046                Arc::new(harness.clone()),
1047            );
1048            Arc::new(action_host)
1049        }
1050    };
1051
1052    let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1053    let fresh_bridge =
1054        rpi_extensions::ActionBridge::with_reload(ctx.runtime.clone(), host, reload_cb);
1055
1056    let extension_session = if effective_args.no_extensions {
1057        rpi_extensions::ExtensionSession::none()
1058    } else {
1059        load_extensions_from_dirs(
1060            &effective_args,
1061            &extension_dirs,
1062            Some(Arc::clone(&fresh_bridge)),
1063        )
1064    };
1065    if extension_session.is_empty() && !effective_args.no_extensions {
1066        // The fresh session may be empty if no cdylibs are present — not a
1067        // warning per se, but note it.
1068    }
1069    if effective_args.verbose {
1070        if let Some(s) = extension_session.summary() {
1071            eprintln!("reload: {s}");
1072        }
1073        report_deferred_renderers(&extension_session);
1074    }
1075
1076    // ---- 2. Invalidate the old session + bridge BEFORE the swap ----
1077    // The old registry's `active` flag flips false so any in-flight
1078    // `emit_resources_discover`/event dispatch on the old snapshot no-ops; the
1079    // old bridge's flag flips false so in-flight `runtime_action` calls parked
1080    // on the old `user_data` hit the staleness guard. We do this BEFORE storing
1081    // the fresh session so there is no window where both are "active".
1082    //
1083    // The session cell carries a plain `ExtensionSession` (not `Option`), so we
1084    // `mem::replace` the live one out with a `none()` placeholder to extract it
1085    // for invalidation (the snapshot's `active` flag is on a shared `Arc`, so a
1086    // borrow of the extracted value is enough to flip it; the extraction itself
1087    // also drops the old keepalive once we drop `old_session`, unmapping the old
1088    // cdylibs). `mem::replace` (not `.take()`) because the cell is not `Option`.
1089    {
1090        let mut session_guard = ctx.extension_session.lock().unwrap();
1091        let old_session = std::mem::replace(
1092            &mut *session_guard,
1093            rpi_extensions::ExtensionSession::none(),
1094        );
1095        if let Some(old_snap) = old_session.snapshot_arc() {
1096            // `invalidate` is on the registry, but the snapshot shares the flag —
1097            // flipping the snapshot's flag invalidates the registry too (same Arc).
1098            // `RegistrySnapshot` exposes `active_flag()` for this.
1099            old_snap.active_flag().store(false, Ordering::SeqCst);
1100        }
1101        // `old_session` drops here — its keepalive releases the old `Library`
1102        // handles (unmapping the old cdylibs). The fresh session's keepalive
1103        // (built below) holds the fresh mappings.
1104    }
1105    if let Some(old_b) = old_bridge {
1106        old_b.invalidate();
1107    }
1108
1109    // The fresh bridge is now the live one. Store it + the fresh session so
1110    // subsequent reloads (or plugin calls still resolving the cells) see them.
1111    *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
1112    *ctx.extension_session.lock().unwrap() = extension_session.clone();
1113
1114    // ---- 3. resources_discover ("reload") over the fresh snapshot ----
1115    let discovered = extension_session
1116        .snapshot_arc()
1117        .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
1118        .unwrap_or_default();
1119
1120    // ---- 4. Re-run the Part-A loaders (same precedence + --no-* gates) ----
1121    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
1122    let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
1123
1124    let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
1125    let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
1126    if !effective_args.no_skills {
1127        // JS discovery is backed by the session-long lazy Node host. Until
1128        // that host is swapped as part of a future full JS reload, preserve
1129        // the paths it contributed at startup across `/reload`.
1130        let js_paths: &[PathBuf] = ctx
1131            .js_extension_session
1132            .as_ref()
1133            .map(|js| js.resources.skill_paths.as_slice())
1134            .unwrap_or(&[]);
1135        let package_paths = package_resources.skill_dirs();
1136        let dirs = append_reload_resource_paths(
1137            skill_base_dirs,
1138            &effective_args.skill,
1139            &discovered.skill_paths,
1140            js_paths,
1141            &package_paths,
1142        );
1143        let result = load_skills_with_precedence(&env_dyn, &dirs).await;
1144        skills = result.skills;
1145        skill_diags = result.diagnostics;
1146    }
1147
1148    let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
1149    let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
1150    if !effective_args.no_prompt_templates {
1151        let js_paths: &[PathBuf] = ctx
1152            .js_extension_session
1153            .as_ref()
1154            .map(|js| js.resources.prompt_paths.as_slice())
1155            .unwrap_or(&[]);
1156        let package_paths = package_resources.prompt_dirs();
1157        let dirs = append_reload_resource_paths(
1158            prompt_base_dirs,
1159            &effective_args.prompt_template,
1160            &discovered.prompt_paths,
1161            js_paths,
1162            &package_paths,
1163        );
1164        let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
1165        prompt_templates = result.prompt_templates;
1166        prompt_diags = result.diagnostics;
1167    }
1168
1169    let context_block = if effective_args.no_context_files {
1170        String::new()
1171    } else {
1172        let agent_dir = crate::config::agent_dir().ok();
1173        let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
1174        let files = load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await;
1175        format_project_context(&files)
1176    };
1177
1178    if !skill_diags.is_empty()
1179        || !prompt_diags.is_empty()
1180        || !package_resources.diagnostics.is_empty()
1181    {
1182        warnings = true;
1183        if effective_args.verbose {
1184            for d in &package_resources.diagnostics {
1185                eprintln!("warning: package {}: {}", d.spec, d.message);
1186            }
1187            for d in &skill_diags {
1188                eprintln!(
1189                    "warning: skill {} ({}): {}",
1190                    d.path,
1191                    d.code.as_str(),
1192                    d.message
1193                );
1194            }
1195            for d in &prompt_diags {
1196                eprintln!(
1197                    "warning: prompt template {} ({}): {}",
1198                    d.path,
1199                    d.code.as_str(),
1200                    d.message
1201                );
1202            }
1203        }
1204    }
1205
1206    // ---- Re-compose the system prompt (same precedence as build) ----
1207    let base_prompt = match effective_args.system_prompt.as_deref() {
1208        Some(explicit) => explicit.to_string(),
1209        None => match discover_system_prompt_file_with_packages(&ctx.cwd, &package_resources) {
1210            Some(path) => {
1211                std::fs::read_to_string(&path).unwrap_or_else(|_| default_system_prompt(&cwd_str))
1212            }
1213            None => default_system_prompt(&cwd_str),
1214        },
1215    };
1216    let mut append_texts: Vec<String> = Vec::new();
1217    for extra in &effective_args.append_system_prompt {
1218        let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
1219        append_texts.push(text);
1220    }
1221    if effective_args.append_system_prompt.is_empty() {
1222        if let Some(path) =
1223            discover_append_system_prompt_file_with_packages(&ctx.cwd, &package_resources)
1224        {
1225            if let Ok(text) = std::fs::read_to_string(&path) {
1226                append_texts.push(text);
1227            }
1228        }
1229    }
1230    let append_join = if append_texts.is_empty() {
1231        None
1232    } else {
1233        Some(append_texts.join("\n\n"))
1234    };
1235    let system_prompt = compose_system_prompt(
1236        Some(&base_prompt),
1237        &[],
1238        if context_block.is_empty() {
1239            None
1240        } else {
1241            Some(&context_block)
1242        },
1243        append_join.as_deref(),
1244    );
1245
1246    // ---- Rebuild the emitter (TeeEmitter over fresh ExtensionEmitter) ----
1247    let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1248        Some(snapshot) => {
1249            let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1250            Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
1251        }
1252        None => ctx.broadcast.clone(),
1253    };
1254
1255    // ---- 5. Push the rebuilt state into the live harness via the B5d setters ----
1256    let resources = AgentHarnessResources {
1257        skills: if skills.is_empty() {
1258            None
1259        } else {
1260            Some(skills.clone())
1261        },
1262        prompt_templates: if prompt_templates.is_empty() {
1263            None
1264        } else {
1265            Some(prompt_templates.clone())
1266        },
1267    };
1268    let _ = harness.set_system_prompt(Some(system_prompt)).await;
1269    let _ = harness.set_resources(resources).await;
1270    let _ = harness.set_agent_emitter(Some(emitter)).await;
1271    let _ = harness
1272        .set_models(build_models_with_extensions_for_reload(
1273            &ctx.gateway,
1274            &extension_session,
1275            ctx.runtime.clone(),
1276        ))
1277        .await;
1278    let _ = harness
1279        .set_provider_hooks(
1280            rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1281                .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1282        )
1283        .await;
1284
1285    // Re-merge extension tools (a reloaded plugin may have added/removed a
1286    // tool). The built-in set is rebuilt from scratch + extension tools merged
1287    // on top, mirroring `build`.
1288    let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
1289    let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env));
1290    let mut tools = build_tools(&tool_ctx, &effective_args);
1291    merge_extension_tools(&mut tools, &extension_session, &effective_args);
1292    if let Some(js) = &ctx.js_extension_session {
1293        merge_js_extension_tools(&mut tools, js, &effective_args);
1294    }
1295    let mut active = active_tool_names(&tools, &effective_args);
1296    if let Some(js) = &ctx.js_extension_session {
1297        let js_names = js.tool_names();
1298        if let Some(js_active) = js.active_tools() {
1299            active.retain(|name| {
1300                tool_name_allowed(name, &effective_args)
1301                    && !js_names.iter().any(|js_name| js_name == name)
1302            });
1303            active.extend(js_active.into_iter().filter(|name| {
1304                js_names.iter().any(|js_name| js_name == name)
1305                    && tool_name_allowed(name, &effective_args)
1306            }));
1307        }
1308    }
1309    active = filter_active_tool_names(active, &effective_args);
1310    let _ = harness.set_tools(tools, Some(active)).await;
1311
1312    let summary = format!(
1313        "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
1314        extension_session.loaded_paths().len(),
1315        skills.len(),
1316        prompt_templates.len(),
1317    );
1318    ReloadOutcome {
1319        summary,
1320        had_warnings: warnings,
1321    }
1322}
1323
1324#[derive(Clone, Debug, PartialEq, Eq)]
1325struct ReloadSettingsFields {
1326    packages: Option<Vec<crate::settings::PackageSetting>>,
1327    npm_command: Option<Vec<String>>,
1328    skill_dirs: Option<Vec<String>>,
1329    prompt_dirs: Option<Vec<String>>,
1330    extension_dirs: Option<Vec<String>>,
1331}
1332
1333impl From<crate::settings::Settings> for ReloadSettingsFields {
1334    fn from(settings: crate::settings::Settings) -> Self {
1335        Self {
1336            packages: settings.packages,
1337            npm_command: settings.npm_command,
1338            skill_dirs: settings.skill_dirs,
1339            prompt_dirs: settings.prompt_dirs,
1340            extension_dirs: settings.extension_dirs,
1341        }
1342    }
1343}
1344
1345#[derive(Clone, Debug, PartialEq, Eq)]
1346struct ReloadSettingsSnapshot {
1347    global: ReloadSettingsFields,
1348    project: Option<ReloadSettingsFields>,
1349}
1350
1351fn reload_reads_settings(args: &Args) -> bool {
1352    !args.dev_local_only
1353        && (should_load_js_packages(args)
1354            || !args.no_extensions
1355            || !args.no_skills
1356            || !args.no_prompt_templates)
1357}
1358
1359/// Strictly read the settings fields consumed while preparing a reload. The
1360/// snapshot intentionally excludes UI/model fields that `/reload` does not
1361/// use, so an unrelated settings save does not invalidate the operation.
1362fn load_reload_settings_snapshot(
1363    args: &Args,
1364    cwd: &Path,
1365    project_trusted: bool,
1366) -> Result<Option<ReloadSettingsSnapshot>, String> {
1367    if !reload_reads_settings(args) {
1368        return Ok(None);
1369    }
1370    let global = crate::settings::load_settings()
1371        .map_err(|error| format!("could not load global settings: {error}"))?
1372        .into();
1373    let project = if project_trusted {
1374        crate::settings::load_active_project_settings(cwd)
1375            .map_err(|error| format!("could not load project settings: {error}"))?
1376            .map(|(_, settings)| settings.into())
1377    } else {
1378        None
1379    };
1380    Ok(Some(ReloadSettingsSnapshot { global, project }))
1381}
1382
1383/// Validate every settings document that this reload will consume before
1384/// replacing any live extension, bridge, or harness resource.
1385fn validate_settings_for_reload(
1386    args: &Args,
1387    cwd: &Path,
1388    project_trusted: bool,
1389) -> Result<(), String> {
1390    load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
1391}
1392
1393fn prepare_reload_inputs(
1394    args: &Args,
1395    cwd: &Path,
1396    project_trusted: bool,
1397) -> Result<PreparedReloadInputs, String> {
1398    prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
1399}
1400
1401fn prepare_reload_inputs_inner<F>(
1402    args: &Args,
1403    cwd: &Path,
1404    project_trusted: bool,
1405    before_verify: F,
1406) -> Result<PreparedReloadInputs, String>
1407where
1408    F: FnOnce(),
1409{
1410    let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1411
1412    let package_resources = if args.dev_local_only {
1413        crate::packages::PackageResources::default()
1414    } else {
1415        package_resources_for(args, cwd, project_trusted)
1416    };
1417
1418    let mut extension_dirs = if args.no_extensions || args.dev_local_only {
1419        Vec::new()
1420    } else if project_trusted {
1421        extension_dirs(cwd)
1422    } else {
1423        global_extension_dirs()
1424    };
1425    if !args.no_extensions {
1426        extension_dirs.extend(args.extensions_dir.iter().cloned());
1427    }
1428
1429    let skill_base_dirs = if args.no_skills {
1430        Vec::new()
1431    } else if args.dev_local_only {
1432        project_skill_dirs(cwd)
1433    } else if project_trusted {
1434        skill_dirs(cwd)
1435    } else {
1436        global_skill_dirs()
1437    };
1438
1439    let prompt_base_dirs = if args.no_prompt_templates {
1440        Vec::new()
1441    } else if args.dev_local_only {
1442        project_prompt_template_dirs(cwd)
1443    } else if project_trusted {
1444        prompt_template_dirs(cwd)
1445    } else {
1446        global_prompt_template_dirs()
1447    };
1448
1449    before_verify();
1450    let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
1451    if settings_before != settings_after {
1452        return Err(
1453            "settings changed while reload inputs were being prepared; retry /reload".to_string(),
1454        );
1455    }
1456
1457    Ok(PreparedReloadInputs {
1458        package_resources,
1459        extension_dirs,
1460        skill_base_dirs,
1461        prompt_base_dirs,
1462    })
1463}
1464
1465/// `build_models_with_extensions` for the reload path: the resolved gateway
1466/// (NOT `resolved` — the reload context carries the gateway `Arc<dyn Provider>`
1467/// directly, since the provider/auth did not change) first, then one
1468/// `PluggableProvider` per registered extension provider in the fresh session.
1469fn build_models_with_extensions_for_reload(
1470    gateway: &Arc<dyn Provider>,
1471    extension_session: &ExtensionSession,
1472    runtime: tokio::runtime::Handle,
1473) -> Vec<Arc<dyn Provider>> {
1474    let mut models: Vec<Arc<dyn Provider>> = vec![gateway.clone()];
1475    let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1476    models.extend(pluggable);
1477    models
1478}
1479
1480/// Diagnostic for registered TUI renderers. All three renderer kinds are
1481/// consumed by the interactive TUI's JSON component adapter; this line remains
1482/// useful under `--verbose` for extension authors.
1483fn report_deferred_renderers(session: &ExtensionSession) {
1484    let Some(snap) = session.snapshot_arc() else {
1485        return;
1486    };
1487    let all = snap.renderers();
1488    let markdown = all
1489        .iter()
1490        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
1491        .count();
1492    let message = all
1493        .iter()
1494        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
1495        .count();
1496    let entry = all
1497        .iter()
1498        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
1499        .count();
1500    if markdown + message + entry == 0 {
1501        return;
1502    }
1503    eprintln!(
1504        "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
1505        markdown, message, entry
1506    );
1507}
1508
1509/// A harness-build error.
1510#[derive(Debug, thiserror::Error)]
1511pub enum BuildError {
1512    #[error("Could not create the session directory: {0}")]
1513    SessionDir(String),
1514    #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
1515    SessionNotFound { requested: String, dir: String },
1516    #[error("Could not build the harness: {0}")]
1517    HarnessCreate(String),
1518}
1519
1520/// B5c: build the `AgentHarnessOptions.models` vec — the resolved gateway
1521/// provider first, then one `Arc<dyn Provider>` per registered extension
1522/// provider (each a [`rpi_extensions::PluggableProvider`] wrapping a plugin's
1523/// sync `ProviderRequestFn`). The harness resolves a provider lazily per call by
1524/// `models.iter().find(|p| p.id() == model.provider)`, so the gateway stays
1525/// first-match for its own ids and an extension provider serves a catalog model
1526/// whose `provider` matches its id. `runtime` is the same `Handle` captured for
1527/// the action bridge — `PluggableProvider` needs a captured `Handle` to
1528/// `spawn_blocking` the sync ffi call from the async `stream_simple`.
1529fn build_models_with_extensions(
1530    resolved: &ResolvedModel,
1531    extension_session: &ExtensionSession,
1532    runtime: tokio::runtime::Handle,
1533) -> Vec<Arc<dyn Provider>> {
1534    let mut models: Vec<Arc<dyn Provider>> = vec![resolved.provider.clone() as Arc<dyn Provider>];
1535    let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
1536    models.extend(pluggable);
1537    models
1538}
1539
1540/// Resolve project resource loading without prompting. Explicit CLI overrides
1541/// win, then a stored decision; projects with no decision load by default.
1542pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
1543    if let Some(override_value) = args.trust_override {
1544        return override_value;
1545    }
1546    crate::config::project_trust_decision(cwd)
1547        .ok()
1548        .flatten()
1549        .unwrap_or(true)
1550}
1551
1552/// Resolve the extension dirs to scan and load the cdylib plugins, returning
1553/// the loaded session guard (keeps the `Library` handles alive for the harness
1554/// lifetime). Scan order: configured project paths, project `.rpi/extensions`,
1555/// legacy `.pi/extensions`, configured/global conventional paths, then any
1556/// `--extensions-dir` flags (scanned after the defaults — `args.rs`).
1557/// Diagnostics are a no-op sink for now; load skips/ABI mismatches surface via
1558/// the `--verbose` summary.
1559fn load_extensions(
1560    args: &Args,
1561    cwd: &Path,
1562    project_trusted: bool,
1563    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1564) -> ExtensionSession {
1565    let mut dirs = if args.dev_local_only {
1566        Vec::new()
1567    } else if project_trusted {
1568        extension_dirs(cwd)
1569    } else {
1570        global_extension_dirs()
1571    };
1572    dirs.extend(args.extensions_dir.iter().cloned());
1573    load_extensions_from_dirs(args, &dirs, action_bridge)
1574}
1575
1576fn load_extensions_from_dirs(
1577    args: &Args,
1578    dirs: &[PathBuf],
1579    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
1580) -> ExtensionSession {
1581    let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
1582    // B5a: the action bridge is cloned into every loaded plugin's vtable
1583    // `user_data` so post-register `runtime_action` calls recover the harness
1584    // host from any thread. The call site already gates `load_extensions` behind
1585    // `!no_extensions` and threads `Some(bridge)`; `None` is only passed by the
1586    // `--no-extensions` branch (which calls `ExtensionSession::none()` directly)
1587    // and tests. Explicit `--extension`/`-e` files load after the dirs.
1588    rpi_extensions::load_session_mixed(dirs, &args.extension, diagnostics, action_bridge)
1589}
1590
1591fn js_extension_paths(
1592    args: &Args,
1593    cwd: &Path,
1594    project_trusted: bool,
1595    packages: &crate::packages::PackageResources,
1596) -> Vec<PathBuf> {
1597    if args.dev_local_only {
1598        return Vec::new();
1599    }
1600    let mut paths = packages.extension_paths();
1601    let discovered_dirs = if project_trusted {
1602        extension_dirs(cwd)
1603    } else {
1604        global_extension_dirs()
1605    };
1606    for dir in discovered_dirs {
1607        if let Ok(entries) = std::fs::read_dir(dir) {
1608            paths.extend(entries.flatten().map(|entry| entry.path()).filter(|path| {
1609                matches!(
1610                    path.extension()
1611                        .and_then(|ext| ext.to_str())
1612                        .map(|ext| ext.to_ascii_lowercase())
1613                        .as_deref(),
1614                    Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1615                )
1616            }));
1617        }
1618    }
1619    paths.extend(
1620        args.extension
1621            .iter()
1622            .filter(|path| {
1623                matches!(
1624                    path.extension()
1625                        .and_then(|ext| ext.to_str())
1626                        .map(|ext| ext.to_ascii_lowercase())
1627                        .as_deref(),
1628                    Some("js" | "mjs" | "cjs" | "ts" | "tsx")
1629                )
1630            })
1631            .cloned(),
1632    );
1633    paths
1634}
1635
1636/// Merge the loaded extension tools into the built-in set. An extension tool
1637/// overrides a same-named built-in; first-extension-wins across plugins is
1638/// already guaranteed by the registry (`register_tool` keeps the prior). The
1639/// explicit `--tools` allowlist / `--exclude-tools` denylist apply to the
1640/// merged set (the built-ins were already filtered in [`build_tools`]).
1641fn merge_extension_tools(tools: &mut Vec<HarnessTool>, session: &ExtensionSession, args: &Args) {
1642    let Some(snapshot) = session.snapshot() else {
1643        return;
1644    };
1645    for et in snapshot.tools() {
1646        let name = &et.tool.name;
1647        if !tool_name_allowed(name, args) {
1648            continue;
1649        }
1650        let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive());
1651        let harness_tool = HarnessTool::new(Arc::new(adapter));
1652        match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
1653            Some(slot) => *slot = harness_tool,
1654            None => tools.push(harness_tool),
1655        }
1656    }
1657}
1658
1659fn merge_js_extension_tools(
1660    tools: &mut Vec<HarnessTool>,
1661    session: &crate::js_extensions::JsExtensionSession,
1662    args: &Args,
1663) {
1664    for adapter in session.tools() {
1665        let name = adapter.schema().name.clone();
1666        if !tool_name_allowed(&name, args) {
1667            continue;
1668        }
1669        let harness_tool = HarnessTool::new(Arc::new(adapter));
1670        match tools
1671            .iter_mut()
1672            .find(|tool| tool.tool.schema().name == name)
1673        {
1674            Some(slot) => *slot = harness_tool,
1675            None => tools.push(harness_tool),
1676        }
1677    }
1678}
1679
1680/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
1681/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
1682/// resolution in `createAgentSession`.
1683/// Default bash timeout: 120s when the model doesn't pass one (prevents a
1684/// forgotten `timeout` from hanging the run forever — the "卡住" report).
1685/// `RPI_BASH_TIMEOUT` overrides; a model-supplied timeout always wins.
1686pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
1687    use rpi_tools::tools::bash::BashToolOptions;
1688    let default = std::env::var("RPI_BASH_TIMEOUT")
1689        .ok()
1690        .and_then(|v| v.parse::<f64>().ok())
1691        .unwrap_or(120.0);
1692    BashToolOptions {
1693        command_prefix: None,
1694        default_timeout: Some(default),
1695    }
1696}
1697
1698fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
1699    if args.no_tools {
1700        return Vec::new();
1701    }
1702    // Keep the coding tools aligned with Pi and expose rpi's read-only docs
1703    // lookup as a default assistant capability.
1704    let mut all: Vec<(&'static str, HarnessTool)> = vec![
1705        ("read", HarnessTool::new(create_read_tool(ctx, None))),
1706        (
1707            "bash",
1708            HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
1709        ),
1710        ("edit", HarnessTool::new(create_edit_tool(ctx))),
1711        ("write", HarnessTool::new(create_write_tool(ctx))),
1712        ("docs", HarnessTool::new(create_docs_tool())),
1713    ];
1714
1715    // `--no-builtin-tools` disables the built-in set but would keep
1716    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
1717    // here. We honor it by clearing the built-ins.
1718    if args.no_builtin_tools {
1719        all.clear();
1720    }
1721
1722    // Allowlist (`--tools`): keep only named built-ins.
1723    if let Some(allow) = &args.tools {
1724        all.retain(|(name, _)| allow.iter().any(|a| a == name));
1725    }
1726    // Denylist (`--exclude-tools`): drop named tools.
1727    if let Some(deny) = &args.exclude_tools {
1728        all.retain(|(name, _)| !deny.iter().any(|d| d == name));
1729    }
1730
1731    all.into_iter()
1732        .map(|(_, t)| t.with_replay(ToolReplay::Safe))
1733        .collect()
1734}
1735
1736/// Resolve the active tool names from the constructed tools when no explicit
1737/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
1738/// active.
1739fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
1740    filter_active_tool_names(
1741        tools.iter().map(|tool| tool.tool.schema().name.clone()),
1742        args,
1743    )
1744}
1745
1746/// Whether a tool name survives the command-line tool policy. Keep this check
1747/// centralized because JS extensions can mutate the active set after the
1748/// initial Rust tool list has been built.
1749pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
1750    if args.no_tools {
1751        return false;
1752    }
1753    if args
1754        .tools
1755        .as_ref()
1756        .is_some_and(|allow| !allow.iter().any(|value| value == name))
1757    {
1758        return false;
1759    }
1760    if args
1761        .exclude_tools
1762        .as_ref()
1763        .is_some_and(|deny| deny.iter().any(|value| value == name))
1764    {
1765        return false;
1766    }
1767    true
1768}
1769
1770pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
1771where
1772    I: IntoIterator<Item = String>,
1773{
1774    names
1775        .into_iter()
1776        .filter(|name| tool_name_allowed(name, args))
1777        .collect()
1778}
1779
1780/// Build the `Session` facade for the chosen selection.
1781async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1782    match selection {
1783        SessionSelection::Ephemeral => Ok(ephemeral_session()),
1784        SessionSelection::New { dir, .. } => {
1785            // Ensure the sessions directory exists, then create a fresh JSONL
1786            // session file inside it.
1787            std::fs::create_dir_all(dir)
1788                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1789            let session = create_jsonl_session(dir, cwd)
1790                .await
1791                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1792            Ok(session)
1793        }
1794        SessionSelection::Latest
1795        | SessionSelection::ById { .. }
1796        | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
1797        SessionSelection::Fork { source } => fork_session_at_launch(source, cwd).await,
1798    }
1799}
1800
1801/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
1802/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
1803/// match the request, then open the matched file and wrap it in a `Session`
1804/// facade. The restored transcript renders into the TUI at startup and the
1805/// harness continues appending to the same file.
1806async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
1807    // `list_typed` is newest-first; `Latest` takes the head, `ById` matches
1808    // the id exactly or by file-name containment (so `--session 01a02…` or a
1809    // partial id works, mirroring the TS id/path matching).
1810    match selection {
1811        SessionSelection::Latest => {
1812            let metas = list_session_metadata(cwd).await?;
1813            let Some(meta) = metas.first() else {
1814                return Err(BuildError::SessionNotFound {
1815                    requested: "the most recent session".to_string(),
1816                    dir: default_session_dir(Path::new(cwd)).display().to_string(),
1817                });
1818            };
1819            open_session(meta, cwd).await
1820        }
1821        SessionSelection::ById { id } => open_session_by_id(id, cwd).await.map_err(|e| match e {
1822            OpenError::NotFound { requested } => BuildError::SessionNotFound {
1823                requested,
1824                dir: default_session_dir(Path::new(cwd)).display().to_string(),
1825            },
1826            OpenError::Other(msg) => BuildError::SessionDir(msg),
1827        }),
1828        SessionSelection::ByExactId { id } => {
1829            // Exact id match only (pi `--session-id`): restore when the
1830            // session exists, else create a fresh one under the default dir.
1831            let metas = list_session_metadata(cwd).await?;
1832            if let Some(meta) = metas.iter().find(|m| m.id == *id) {
1833                return open_session(meta, cwd).await;
1834            }
1835            let dir = default_session_dir(Path::new(cwd));
1836            std::fs::create_dir_all(&dir)
1837                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1838            create_jsonl_session_with_id(&dir, cwd, Some(id.clone()))
1839                .await
1840                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
1841        }
1842        _ => unreachable!("restore_session only called for Latest/ById/ByExactId"),
1843    }
1844}
1845
1846/// `--fork <path|id>`: open the source session, fork it into a new JSONL
1847/// session (records the parent id), and start in the fork.
1848async fn fork_session_at_launch(source: &str, cwd: &str) -> Result<Session, BuildError> {
1849    use rpi_harness::session::jsonl::{
1850        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1851    };
1852    use rpi_harness::session::types::{ForkOptions, SessionStorage};
1853    use rpi_tools::FileSystem;
1854
1855    let dir = default_session_dir(Path::new(cwd));
1856    std::fs::create_dir_all(&dir)
1857        .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
1858    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1859    let fs: Arc<dyn FileSystem> = env.clone();
1860    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1861        fs: fs.clone(),
1862        sessions_root: dir.to_string_lossy().into_owned(),
1863        clock: Arc::new(SystemClock),
1864        ids: Arc::new(DefaultIdGenerator::new()),
1865    });
1866    let metas = repo
1867        .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
1868        .await
1869        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
1870    let source_meta = metas
1871        .iter()
1872        .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
1873        .ok_or_else(|| BuildError::SessionNotFound {
1874            requested: format!("--fork {source}"),
1875            dir: dir.display().to_string(),
1876        })?;
1877    let fork_storage = repo
1878        .fork_typed(
1879            source_meta,
1880            &JsonlSessionCreateOptions {
1881                id: None,
1882                parent_session_id: Some(source_meta.id.clone()),
1883                cwd: cwd.to_string(),
1884                metadata: None,
1885            },
1886            &ForkOptions::default(),
1887        )
1888        .await
1889        .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
1890    let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
1891    Ok(Session::new(storage_arc, None))
1892}
1893
1894/// Errors from [`open_session_by_id`], split so the CLI can map them to
1895/// [`BuildError`] while the TUI can surface a friendlier note.
1896pub enum OpenError {
1897    /// No session matched the request.
1898    NotFound { requested: String },
1899    /// The match existed but could not be opened/parsed.
1900    Other(String),
1901}
1902
1903impl std::fmt::Display for OpenError {
1904    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1905        match self {
1906            OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
1907            OpenError::Other(msg) => write!(f, "{msg}"),
1908        }
1909    }
1910}
1911
1912/// List the JSONL session metadata under the default session dir, newest
1913/// first. Shared by startup restore and the TUI `/session` hot-switch.
1914pub async fn list_session_metadata(
1915    cwd: &str,
1916) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
1917    use rpi_harness::session::jsonl::{
1918        JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
1919    };
1920    use rpi_tools::FileSystem;
1921
1922    let dir = default_session_dir(Path::new(cwd));
1923    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1924    let fs: Arc<dyn FileSystem> = env.clone();
1925    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1926        fs: fs.clone(),
1927        sessions_root: dir.to_string_lossy().into_owned(),
1928        clock: Arc::new(SystemClock),
1929        ids: Arc::new(DefaultIdGenerator::new()),
1930    });
1931    repo.list_typed(&JsonlSessionListOptions::default())
1932        .await
1933        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))
1934}
1935
1936/// Open a session whose id matches exactly or by file-name containment
1937/// (so `--session 01a02…` / a partial id / a full file name all work). The
1938/// TUI `/session` hot-switch calls this with the selector's item value.
1939pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
1940    let metas = list_session_metadata(cwd)
1941        .await
1942        .map_err(|e| OpenError::Other(e.to_string()))?;
1943    let Some(meta) = metas
1944        .iter()
1945        .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id))
1946    else {
1947        return Err(OpenError::NotFound {
1948            requested: format!("session {id}"),
1949        });
1950    };
1951    open_session(meta, cwd)
1952        .await
1953        .map_err(|e| OpenError::Other(e.to_string()))
1954}
1955
1956/// Fork the harness's current session into a new JSONL session (new id, parent
1957/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
1958/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
1959/// and the plugin `runtime_action(Fork)` host share one implementation.
1960/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
1961/// does `harness.set_session(...)`).
1962pub(crate) async fn fork_session_storage(
1963    harness: &AgentHarness,
1964    cwd: &str,
1965) -> Result<Session, String> {
1966    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
1967    use rpi_tools::FileSystem;
1968
1969    let dir = default_session_dir(Path::new(cwd));
1970    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
1971    let fs: Arc<dyn FileSystem> = env.clone();
1972    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
1973        fs,
1974        sessions_root: dir.to_string_lossy().into_owned(),
1975        clock: Arc::new(SystemClock),
1976        ids: Arc::new(DefaultIdGenerator::new()),
1977    });
1978    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
1979    // it from the session list by the current session's id.
1980    let id = harness.session().storage().metadata().id.clone();
1981    let metas = list_session_metadata(cwd)
1982        .await
1983        .map_err(|e| e.to_string())?;
1984    let Some(source) = metas.iter().find(|m| m.id == id) else {
1985        return Err(format!("current session {id} not found on disk"));
1986    };
1987    let fork_storage = repo
1988        .fork_typed(
1989            source,
1990            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
1991                id: None,
1992                parent_session_id: Some(source.id.clone()),
1993                cwd: cwd.to_string(),
1994                metadata: None,
1995            },
1996            &rpi_harness::session::types::ForkOptions::default(),
1997        )
1998        .await
1999        .map_err(|e| e.to_string())?;
2000    Ok(Session::new(Arc::new(fork_storage), None))
2001}
2002
2003/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
2004/// startup restore + TUI hot-switch).
2005async fn open_session(
2006    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
2007    cwd: &str,
2008) -> Result<Session, BuildError> {
2009    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
2010    use rpi_harness::session::types::SessionStorage;
2011    use rpi_tools::FileSystem;
2012
2013    let dir = default_session_dir(Path::new(cwd));
2014    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2015    let fs: Arc<dyn FileSystem> = env.clone();
2016    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2017        fs: fs.clone(),
2018        sessions_root: dir.to_string_lossy().into_owned(),
2019        clock: Arc::new(SystemClock),
2020        ids: Arc::new(DefaultIdGenerator::new()),
2021    });
2022    let storage = repo
2023        .open_by_jsonl_metadata(meta)
2024        .await
2025        .map_err(|e| BuildError::SessionDir(format!("open {}: {e}", meta.path)))?;
2026    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
2027    Ok(Session::new(storage_arc, None))
2028}
2029
2030/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
2031fn ephemeral_session() -> Session {
2032    let storage = Arc::new(InMemorySessionStorage::new(
2033        SessionMetadata {
2034            id: "ephemeral".into(),
2035            created_at: 0,
2036            parent_session_id: None,
2037        },
2038        Arc::new(SystemClock),
2039        Arc::new(DefaultIdGenerator::new()),
2040    ));
2041    Session::new(storage, None)
2042}
2043
2044/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2045///
2046/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2047/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2048/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2049/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
2050///
2051/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
2052/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
2053/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
2054pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
2055    create_jsonl_session_with_id(dir, cwd, None).await
2056}
2057
2058/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
2059/// contract: the file is named with the given id so later `--session-id`
2060/// launches restore the same session).
2061pub(crate) async fn create_jsonl_session_with_id(
2062    dir: &Path,
2063    cwd: &str,
2064    id: Option<String>,
2065) -> Result<Session, String> {
2066    use rpi_harness::session::jsonl::{
2067        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2068    };
2069    use rpi_tools::FileSystem;
2070
2071    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
2072    // relative-path resolution matches the tool env.
2073    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2074    let fs: Arc<dyn FileSystem> = env.clone();
2075
2076    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2077        fs: fs.clone(),
2078        sessions_root: dir.to_string_lossy().into_owned(),
2079        clock: Arc::new(SystemClock),
2080        ids: Arc::new(DefaultIdGenerator::new()),
2081    });
2082
2083    let opts = JsonlSessionCreateOptions {
2084        id, // fresh uuidv7 when None (--session-id passes the fixed id)
2085        parent_session_id: None,
2086        cwd: cwd.to_string(),
2087        metadata: None,
2088    };
2089    let storage = repo
2090        .create_typed(&opts)
2091        .await
2092        .map_err(|e| format!("create session: {e}"))?;
2093    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
2094    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
2095    Ok(Session::new(storage_arc, None))
2096}
2097
2098/// Read an `--append-system-prompt` target: if it's a readable file path, return
2099/// its contents; otherwise return `None` and let the caller use the literal.
2100fn read_append_target(target: &str) -> Option<String> {
2101    let path = Path::new(target);
2102    if path.is_file() {
2103        std::fs::read_to_string(path).ok()
2104    } else {
2105        None
2106    }
2107}
2108
2109#[cfg(test)]
2110mod tests {
2111    use super::*;
2112    use crate::args::Args;
2113
2114    #[test]
2115    fn default_prompt_mentions_cwd_and_tools() {
2116        let p = default_system_prompt("/tmp/proj");
2117        assert!(p.contains("/tmp/proj"));
2118        assert!(p.contains("read"));
2119        assert!(p.contains("bash"));
2120        assert!(p.contains("edit"));
2121        assert!(p.contains("write"));
2122        assert!(p.contains("docs"));
2123        assert!(!p.contains("- grep"));
2124        assert!(!p.contains("- find"));
2125        assert!(!p.contains("- ls"));
2126        assert!(!p.contains("powershell"));
2127    }
2128
2129    #[test]
2130    fn default_tools_include_docs_lookup() {
2131        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
2132        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2133        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2134        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
2135        let names: Vec<String> = build_tools(&context, &Args::default())
2136            .iter()
2137            .map(|tool| tool.tool.schema().name.clone())
2138            .collect();
2139
2140        assert_eq!(names, vec!["read", "bash", "edit", "write", "docs"]);
2141    }
2142
2143    #[test]
2144    fn pi_package_loading_is_opt_in_and_respects_no_extensions() {
2145        let args = Args::default();
2146        assert!(!should_load_js_packages(&args));
2147        let resources = package_resources_for(&args, Path::new("."), false);
2148        assert!(resources.packages.is_empty());
2149
2150        let args = Args {
2151            enable_pi_packages: true,
2152            ..Args::default()
2153        };
2154        assert!(should_load_js_packages(&args));
2155
2156        let args = Args {
2157            enable_pi_packages: true,
2158            no_extensions: true,
2159            ..Args::default()
2160        };
2161        assert!(!should_load_js_packages(&args));
2162        assert!(package_resources_for(&args, Path::new("."), false)
2163            .packages
2164            .is_empty());
2165    }
2166
2167    #[test]
2168    fn pi_offline_env_disables_startup_package_remediation() {
2169        struct RestoreEnv {
2170            name: &'static str,
2171            value: Option<std::ffi::OsString>,
2172        }
2173
2174        impl Drop for RestoreEnv {
2175            fn drop(&mut self) {
2176                match self.value.take() {
2177                    Some(value) => std::env::set_var(self.name, value),
2178                    None => std::env::remove_var(self.name),
2179                }
2180            }
2181        }
2182
2183        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2184        let _restore_config = RestoreEnv {
2185            name: crate::config::CONFIG_DIR_ENV,
2186            value: std::env::var_os(crate::config::CONFIG_DIR_ENV),
2187        };
2188        let _restore_offline = RestoreEnv {
2189            name: crate::args::PI_OFFLINE_ENV,
2190            value: std::env::var_os(crate::args::PI_OFFLINE_ENV),
2191        };
2192        let tmp = tempfile::tempdir().unwrap();
2193        let agent = tmp.path().join("agent");
2194        let cwd = tmp.path().join("project");
2195        let package = agent.join("npm/node_modules/demo");
2196        std::fs::create_dir_all(package.join("extensions")).unwrap();
2197        std::fs::create_dir_all(&cwd).unwrap();
2198        std::fs::write(
2199            package.join("package.json"),
2200            r#"{"name":"demo","version":"1.0.0"}"#,
2201        )
2202        .unwrap();
2203        std::fs::write(
2204            package.join("extensions/index.js"),
2205            "export default () => {};",
2206        )
2207        .unwrap();
2208        std::fs::write(
2209            agent.join("settings.json"),
2210            r#"{"npmCommand":[""],"packages":["npm:demo@2.0.0"]}"#,
2211        )
2212        .unwrap();
2213        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2214        std::env::set_var(crate::args::PI_OFFLINE_ENV, "TrUe");
2215        let args = Args {
2216            enable_pi_packages: true,
2217            offline: false,
2218            ..Args::default()
2219        };
2220
2221        let resources = package_resources_for(&args, &cwd, false);
2222
2223        assert!(resources.packages.is_empty());
2224        assert_eq!(resources.diagnostics.len(), 1);
2225        assert!(resources.diagnostics[0].message.contains("offline"));
2226        assert_eq!(
2227            std::fs::read(package.join("package.json")).unwrap(),
2228            br#"{"name":"demo","version":"1.0.0"}"#
2229        );
2230    }
2231
2232    #[test]
2233    fn package_discovery_uses_the_callers_trust_snapshot() {
2234        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2235        let previous = std::env::var_os(crate::config::CONFIG_DIR_ENV);
2236        let tmp = tempfile::tempdir().unwrap();
2237        let agent = tmp.path().join("agent");
2238        let cwd = tmp.path().join("project");
2239        let package = cwd.join("package");
2240        std::fs::create_dir_all(&agent).unwrap();
2241        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2242        std::fs::create_dir_all(&package).unwrap();
2243        std::fs::write(agent.join("settings.json"), "{}").unwrap();
2244        std::fs::write(
2245            package.join("package.json"),
2246            r#"{"name":"snapshot-package","version":"1.0.0"}"#,
2247        )
2248        .unwrap();
2249        std::fs::write(
2250            cwd.join(".rpi/settings.json"),
2251            serde_json::json!({"packages": [package]}).to_string(),
2252        )
2253        .unwrap();
2254        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2255
2256        let args = Args {
2257            enable_pi_packages: true,
2258            ..Args::default()
2259        };
2260        assert_eq!(package_resources_for(&args, &cwd, true).packages.len(), 1);
2261        assert!(package_resources_for(&args, &cwd, false)
2262            .packages
2263            .is_empty());
2264        assert_eq!(
2265            package_resources_for_update_check(&args, &cwd, true)
2266                .packages
2267                .len(),
2268            1
2269        );
2270        assert!(package_resources_for_update_check(&args, &cwd, false)
2271            .packages
2272            .is_empty());
2273
2274        match previous {
2275            Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2276            None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2277        }
2278    }
2279
2280    #[test]
2281    fn reload_settings_preflight_is_independent_of_packages_and_respects_trust() {
2282        struct RestoreConfigDir(Option<std::ffi::OsString>);
2283
2284        impl Drop for RestoreConfigDir {
2285            fn drop(&mut self) {
2286                match self.0.take() {
2287                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2288                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2289                }
2290            }
2291        }
2292
2293        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2294        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2295        let tmp = tempfile::tempdir().unwrap();
2296        let agent = tmp.path().join("agent");
2297        let cwd = tmp.path().join("project");
2298        std::fs::create_dir_all(&agent).unwrap();
2299        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
2300        std::fs::write(agent.join("settings.json"), "{}").unwrap();
2301        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
2302        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2303        let packages_disabled = Args::default();
2304        let packages_enabled = Args {
2305            enable_pi_packages: true,
2306            ..Args::default()
2307        };
2308
2309        for args in [&packages_disabled, &packages_enabled] {
2310            let trusted = validate_settings_for_reload(args, &cwd, true);
2311            let untrusted = validate_settings_for_reload(args, &cwd, false);
2312            assert!(trusted
2313                .unwrap_err()
2314                .contains("could not load project settings"));
2315            assert!(untrusted.is_ok());
2316        }
2317
2318        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2319        for args in [&packages_disabled, &packages_enabled] {
2320            assert!(validate_settings_for_reload(args, &cwd, false)
2321                .unwrap_err()
2322                .contains("could not load global settings"));
2323        }
2324
2325        let local_only = Args {
2326            dev_local_only: true,
2327            ..Args::default()
2328        };
2329        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
2330    }
2331
2332    #[tokio::test(flavor = "current_thread")]
2333    async fn reload_with_packages_disabled_preserves_live_resources_when_settings_break() {
2334        struct RestoreConfigDir(Option<std::ffi::OsString>);
2335
2336        impl Drop for RestoreConfigDir {
2337            fn drop(&mut self) {
2338                match self.0.take() {
2339                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2340                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2341                }
2342            }
2343        }
2344
2345        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2346        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2347        let tmp = tempfile::tempdir().unwrap();
2348        let agent = tmp.path().join("agent");
2349        let cwd = tmp.path().join("project");
2350        let skill_dir = tmp.path().join("configured-skills");
2351        std::fs::create_dir_all(&agent).unwrap();
2352        std::fs::create_dir_all(&cwd).unwrap();
2353        std::fs::create_dir_all(&skill_dir).unwrap();
2354        std::fs::write(
2355            skill_dir.join("SKILL.md"),
2356            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
2357        )
2358        .unwrap();
2359        std::fs::write(
2360            agent.join("settings.json"),
2361            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2362        )
2363        .unwrap();
2364        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2365
2366        let resolved = crate::provider::resolve(
2367            Some("anthropic"),
2368            Some(crate::provider::DEFAULT_MODEL_ID),
2369            None,
2370            Some("test-key"),
2371            None,
2372        )
2373        .unwrap();
2374        let args = Args {
2375            trust_override: Some(false),
2376            no_session: true,
2377            no_extensions: true,
2378            no_prompt_templates: true,
2379            no_context_files: true,
2380            system_prompt: Some("stable system prompt".into()),
2381            ..Args::default()
2382        };
2383        assert!(!should_load_js_packages(&args));
2384
2385        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2386        let before_resources = harness.get_resources().await.unwrap();
2387        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
2388        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
2389        let before_prompt = harness.get_system_prompt().await.unwrap();
2390        let before_tools: Vec<String> = harness
2391            .get_tools()
2392            .await
2393            .unwrap()
2394            .iter()
2395            .map(|tool| tool.tool.schema().name.clone())
2396            .collect();
2397        let before_bridge = context
2398            .action_bridge
2399            .lock()
2400            .unwrap()
2401            .as_ref()
2402            .unwrap()
2403            .clone();
2404
2405        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2406        let outcome = reload_extension_resources(&harness, &context).await;
2407
2408        assert!(outcome.had_warnings);
2409        assert!(outcome.summary.contains("Settings reload failed"));
2410        assert!(outcome.summary.contains("could not load global settings"));
2411        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
2412        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
2413        let after_tools: Vec<String> = harness
2414            .get_tools()
2415            .await
2416            .unwrap()
2417            .iter()
2418            .map(|tool| tool.tool.schema().name.clone())
2419            .collect();
2420        assert_eq!(after_tools, before_tools);
2421        let after_bridge = context
2422            .action_bridge
2423            .lock()
2424            .unwrap()
2425            .as_ref()
2426            .unwrap()
2427            .clone();
2428        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
2429    }
2430
2431    #[test]
2432    fn reload_preparation_rejects_settings_changed_during_derivation() {
2433        struct RestoreConfigDir(Option<std::ffi::OsString>);
2434
2435        impl Drop for RestoreConfigDir {
2436            fn drop(&mut self) {
2437                match self.0.take() {
2438                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2439                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2440                }
2441            }
2442        }
2443
2444        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2445        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2446        let tmp = tempfile::tempdir().unwrap();
2447        let agent = tmp.path().join("agent");
2448        let cwd = tmp.path().join("project");
2449        let first_skill_dir = tmp.path().join("first-skills");
2450        let second_skill_dir = tmp.path().join("second-skills");
2451        std::fs::create_dir_all(&agent).unwrap();
2452        std::fs::create_dir_all(&cwd).unwrap();
2453        std::fs::write(
2454            agent.join("settings.json"),
2455            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
2456        )
2457        .unwrap();
2458        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2459        let args = Args {
2460            trust_override: Some(false),
2461            no_extensions: true,
2462            no_prompt_templates: true,
2463            ..Args::default()
2464        };
2465
2466        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
2467            std::fs::write(
2468                agent.join("settings.json"),
2469                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
2470            )
2471            .unwrap();
2472        });
2473
2474        let error = result
2475            .err()
2476            .expect("settings mutation must fail preparation");
2477        assert!(error.contains("settings changed while reload inputs were being prepared"));
2478    }
2479
2480    #[tokio::test(flavor = "current_thread")]
2481    async fn reload_uses_frozen_settings_inputs_after_preparation() {
2482        struct RestoreConfigDir(Option<std::ffi::OsString>);
2483
2484        impl Drop for RestoreConfigDir {
2485            fn drop(&mut self) {
2486                match self.0.take() {
2487                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2488                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2489                }
2490            }
2491        }
2492
2493        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2494        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2495        let tmp = tempfile::tempdir().unwrap();
2496        let agent = tmp.path().join("agent");
2497        let cwd = tmp.path().join("project");
2498        let skill_dir = tmp.path().join("configured-skills");
2499        std::fs::create_dir_all(&agent).unwrap();
2500        std::fs::create_dir_all(&cwd).unwrap();
2501        std::fs::create_dir_all(&skill_dir).unwrap();
2502        std::fs::write(
2503            skill_dir.join("SKILL.md"),
2504            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
2505        )
2506        .unwrap();
2507        std::fs::write(
2508            agent.join("settings.json"),
2509            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
2510        )
2511        .unwrap();
2512        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2513
2514        let resolved = crate::provider::resolve(
2515            Some("anthropic"),
2516            Some(crate::provider::DEFAULT_MODEL_ID),
2517            None,
2518            Some("test-key"),
2519            None,
2520        )
2521        .unwrap();
2522        let args = Args {
2523            trust_override: Some(false),
2524            no_session: true,
2525            no_extensions: true,
2526            no_prompt_templates: true,
2527            no_context_files: true,
2528            system_prompt: Some("stable system prompt".into()),
2529            ..Args::default()
2530        };
2531        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
2532
2533        let outcome = reload_extension_resources_inner(&harness, &context, || {
2534            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
2535        })
2536        .await;
2537
2538        assert!(!outcome.summary.contains("Settings reload failed"));
2539        assert_eq!(
2540            outcome.summary,
2541            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
2542        );
2543        let resources = harness.get_resources().await.unwrap();
2544        let skills = resources.skills.as_ref().unwrap();
2545        assert_eq!(skills.len(), 1);
2546        assert_eq!(skills[0].name, "frozen-skill");
2547    }
2548
2549    #[tokio::test]
2550    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
2551        let tmp = tempfile::tempdir().unwrap();
2552        let skill_path = tmp.path().join("explicit-skill.md");
2553        std::fs::write(
2554            &skill_path,
2555            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
2556        )
2557        .unwrap();
2558        let prompt_path = tmp.path().join("explicit-prompt.md");
2559        std::fs::write(
2560            &prompt_path,
2561            "---\ndescription: Explicit prompt\n---\nPrompt body",
2562        )
2563        .unwrap();
2564
2565        let args = Args {
2566            skill: vec![skill_path.clone()],
2567            prompt_template: vec![prompt_path.clone()],
2568            ..Args::default()
2569        };
2570        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
2571        let prompt_paths =
2572            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
2573        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
2574        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
2575
2576        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
2577        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
2578        assert_eq!(skills.skills[0].name, "explicit-skill");
2579
2580        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
2581        assert_eq!(
2582            prompts.prompt_templates.len(),
2583            1,
2584            "{:?}",
2585            prompts.diagnostics
2586        );
2587        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
2588    }
2589
2590    #[test]
2591    fn tool_policy_applies_to_rust_and_js_active_names() {
2592        let names = vec![
2593            "read".to_string(),
2594            "ask_user_question".to_string(),
2595            "write".to_string(),
2596        ];
2597
2598        let args = Args {
2599            tools: Some(vec!["read".into(), "ask_user_question".into()]),
2600            ..Args::default()
2601        };
2602        assert_eq!(
2603            filter_active_tool_names(names.clone(), &args),
2604            vec!["read", "ask_user_question"]
2605        );
2606
2607        let args = Args {
2608            exclude_tools: Some(vec!["ask_user_question".into()]),
2609            ..Args::default()
2610        };
2611        assert_eq!(
2612            filter_active_tool_names(names.clone(), &args),
2613            vec!["read", "write"]
2614        );
2615
2616        let args = Args {
2617            no_tools: true,
2618            ..Args::default()
2619        };
2620        assert!(filter_active_tool_names(names, &args).is_empty());
2621    }
2622
2623    #[test]
2624    fn select_ephemeral_when_no_session() {
2625        let args = Args {
2626            no_session: true,
2627            ..Args::default()
2628        };
2629        let cwd = Path::new("/tmp");
2630        assert!(matches!(
2631            select_session(&args, cwd),
2632            SessionSelection::Ephemeral
2633        ));
2634    }
2635
2636    #[test]
2637    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
2638        let defaults = Args::default();
2639        assert!(resolve_project_trust(
2640            &defaults,
2641            Path::new("C:/definitely-not-a-project")
2642        ));
2643
2644        let denied = Args {
2645            trust_override: Some(false),
2646            ..Args::default()
2647        };
2648        assert!(!resolve_project_trust(
2649            &denied,
2650            Path::new("C:/definitely-not-a-project")
2651        ));
2652    }
2653
2654    #[tokio::test(flavor = "current_thread")]
2655    async fn build_preserves_the_supplied_startup_project_snapshot() {
2656        struct RestoreConfigDir(Option<std::ffi::OsString>);
2657
2658        impl Drop for RestoreConfigDir {
2659            fn drop(&mut self) {
2660                match self.0.take() {
2661                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
2662                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
2663                }
2664            }
2665        }
2666
2667        let _guard = crate::config::test_support::env_lock().lock().unwrap();
2668        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
2669        let tmp = tempfile::tempdir().unwrap();
2670        let agent = tmp.path().join("agent");
2671        let cwd = tmp.path().join("project");
2672        std::fs::create_dir_all(&agent).unwrap();
2673        std::fs::create_dir_all(&cwd).unwrap();
2674        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
2675
2676        let resolved = crate::provider::resolve(
2677            Some("anthropic"),
2678            Some(crate::provider::DEFAULT_MODEL_ID),
2679            None,
2680            Some("test-key"),
2681            None,
2682        )
2683        .unwrap();
2684        let args = Args {
2685            trust_override: Some(false),
2686            no_session: true,
2687            no_tools: true,
2688            no_extensions: true,
2689            no_skills: true,
2690            no_prompt_templates: true,
2691            no_context_files: true,
2692            system_prompt: Some("test prompt".into()),
2693            ..Args::default()
2694        };
2695
2696        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
2697
2698        assert_eq!(context.cwd, cwd);
2699        assert!(context.project_trusted);
2700    }
2701
2702    #[test]
2703    fn select_latest_for_continue_and_resume() {
2704        let args = Args {
2705            continue_session: true,
2706            ..Args::default()
2707        };
2708        let cwd = Path::new("/tmp");
2709        assert!(matches!(
2710            select_session(&args, cwd),
2711            SessionSelection::Latest
2712        ));
2713
2714        let args = Args {
2715            resume: true,
2716            ..Args::default()
2717        };
2718        assert!(matches!(
2719            select_session(&args, cwd),
2720            SessionSelection::Latest
2721        ));
2722    }
2723
2724    #[test]
2725    fn select_by_id_for_session_flag() {
2726        let args = Args {
2727            session: Some("01a02ece".into()),
2728            ..Args::default()
2729        };
2730        let cwd = Path::new("/tmp");
2731        assert!(matches!(
2732            select_session(&args, cwd),
2733            SessionSelection::ById { id } if id == "01a02ece"
2734        ));
2735    }
2736
2737    #[test]
2738    fn select_new_with_custom_dir() {
2739        let args = Args {
2740            session_dir: Some(PathBuf::from("/tmp/sess")),
2741            ..Args::default()
2742        };
2743        let cwd = Path::new("/tmp");
2744        match select_session(&args, cwd) {
2745            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
2746            other => panic!("expected New, got {other:?}"),
2747        }
2748    }
2749
2750    #[test]
2751    fn select_new_default_dir() {
2752        let args = Args::default();
2753        let cwd = Path::new("/proj");
2754        match select_session(&args, cwd) {
2755            SessionSelection::New { dir, .. } => {
2756                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
2757            }
2758            other => panic!("expected New, got {other:?}"),
2759        }
2760    }
2761
2762    #[test]
2763    fn default_session_dir_prefers_rpi_but_reads_legacy_pi() {
2764        let tmp = tempfile::tempdir().unwrap();
2765        let cwd = tmp.path();
2766        std::fs::create_dir_all(cwd.join(".pi/sessions")).unwrap();
2767        assert_eq!(default_session_dir(cwd), cwd.join(".pi/sessions"));
2768        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
2769        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
2770    }
2771
2772    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2773    async fn ephemeral_session_builds_roundtrips() {
2774        // Sanity: the ephemeral path produces a usable Session facade (the
2775        // harness build itself needs a provider; tested via the integration
2776        // path in tests/build.rs instead).
2777        let s = ephemeral_session();
2778        let leaf = s.get_leaf_id().await;
2779        assert!(leaf.is_ok());
2780    }
2781
2782    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
2783    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
2784}