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