Skip to main content

rpi_cli/
session.rs

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