Skip to main content

rpi_cli/
session.rs

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