Skip to main content

rpi_cli/
session.rs

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