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