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//!   `.rpi/<sub>` + global
11//!   `agent_dir()<sub>` discovery with project-wins dedupe via
12//!   [`crate::resource_dirs`]; SYSTEM.md/APPEND_SYSTEM.md project-wins
13//!   precedence). **Extension `resources_discover` (B5b) feeds the SAME loaders:
14//!   a plugin's discovered skill/prompt paths merge with the static dirs and
15//!   re-run through `load_skills`/`load_prompt_templates` (individual `.md` files
16//!   load too — `load_skills` accepts both dirs and files). Package themes are
17//!   parsed by the TUI when selected via settings or `--theme`.**
18//!   Project resources load by default without a prompt. `--no-approve` or a
19//!   stored negative `trust.json` decision disables them explicitly.
20//! - **No `ModelRuntime`/multi-provider registry.** The resolver supports the
21//!   built-in Anthropic/OpenAI-compatible providers and `models.json`, but
22//!   runtime catalog mutation remains outside this layer.
23//! - **Built-in tools**: `read`, `bash`, `edit`, `write`, and the read-only
24//!   `docs` lookup tool. The former rpi-only `grep`, `find`, `ls`, and
25//!   `powershell` tools remain library modules but are not registered by the
26//!   CLI.
27//! - **Session restore (`-c`/`-r`/`--session`)** is *partially* supported: a
28//!   fresh session is always created. The harness's `create` rejects sessions
29//!   that already have records unless `allow_existing_session` is enabled.
30//!   The interactive `-c`/`-r`/`--session` paths enable that mode and replay
31//!   the existing branch before appending new messages. See [`SessionSelection`].
32
33mod backend;
34
35use std::collections::{BTreeMap, HashSet};
36use std::path::{Path, PathBuf};
37use std::sync::atomic::Ordering;
38use std::sync::{Arc, Mutex, OnceLock};
39
40use rpi_ai::Provider;
41use rpi_harness::agent_harness::AgentHarness;
42use rpi_harness::context_files::{format_project_context, load_project_context_files};
43use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
44use rpi_harness::session::session::DefaultIdGenerator;
45use rpi_harness::session::types::SessionMetadata;
46use rpi_harness::session::Session;
47use rpi_harness::system_prompt::compose_system_prompt;
48use rpi_harness::types::{
49    AgentHarnessOptions, AgentHarnessResources, AgentHarnessStreamOptions, DrivingMode,
50    HarnessTool, HarnessToolExecution, ToolReplay,
51};
52#[cfg(windows)]
53use rpi_tools::create_powershell_tool;
54use rpi_tools::{
55    create_bash_tool, create_edit_tool, create_read_tool, create_write_tool, ExecutionToolContext,
56    MutationQueueRegistry, OsExecutionEnv,
57};
58
59use crate::args::Args;
60use crate::docs_tool::create_docs_tool;
61use crate::provider::ResolvedModel;
62use crate::resource_dirs::{
63    discover_append_system_prompt_file, discover_system_prompt_file, extension_dirs,
64    global_extension_dirs, global_prompt_template_dirs, global_skill_dirs,
65    load_prompt_templates_with_precedence, load_skills_with_precedence, project_extension_dirs,
66    project_prompt_template_dirs, project_skill_dirs, prompt_template_dirs, skill_dirs,
67};
68use crate::startup_diagnostics::{Phase, StartupSpan};
69use rpi_extensions::{
70    emit_resources_discover, ExtensionEmitter, ExtensionSession, NullDiagnostics,
71    PluginDiagnostics, PluginToolAdapter, TeeEmitter, ToolPromptMetadata,
72};
73
74// Preserve the existing public modes::rpc signature. This CLI-only attachment
75// table holds no session/router alive and keys by storage allocation identity,
76// NOT by a durable ID (two independent restored hosts must never share routing).
77// The session/tools/ReloadContext own the actual router. RPC owns its client.
78// Reload MUST reuse ReloadContext::file_io, including a disconnected selected
79// client backend; rebuilding a default router would silently restore host file
80// authority. Missing attachments reject configuration (never infer a router or
81// install host-backed replacements). This compatibility bridge can be replaced
82// by an explicit neutral harness attachment when its public API is revised.
83type FileIOAttachment = (
84    std::sync::Weak<dyn rpi_harness::session::types::SessionStorage>,
85    std::sync::Weak<rpi_tools::file_io::FileIORouter>,
86);
87fn file_io_attachments() -> &'static Mutex<Vec<FileIOAttachment>> {
88    static ATTACHMENTS: std::sync::OnceLock<Mutex<Vec<FileIOAttachment>>> =
89        std::sync::OnceLock::new();
90    ATTACHMENTS.get_or_init(|| Mutex::new(Vec::new()))
91}
92pub(crate) fn register_file_io_router(
93    harness: &AgentHarness,
94    router: &Arc<rpi_tools::file_io::FileIORouter>,
95) {
96    let storage = harness.session().storage();
97    let mut attachments = file_io_attachments()
98        .lock()
99        .expect("file I/O attachments poisoned");
100    attachments.retain(|(s, r)| {
101        s.strong_count() > 0
102            && r.strong_count() > 0
103            && !s
104                .upgrade()
105                .is_some_and(|known| Arc::ptr_eq(&known, &storage))
106    });
107    attachments.push((Arc::downgrade(&storage), Arc::downgrade(router)));
108}
109pub(crate) fn file_io_router(
110    harness: &AgentHarness,
111) -> Option<Arc<rpi_tools::file_io::FileIORouter>> {
112    let storage = harness.session().storage();
113    let mut attachments = file_io_attachments()
114        .lock()
115        .expect("file I/O attachments poisoned");
116    attachments.retain(|(s, r)| s.strong_count() > 0 && r.strong_count() > 0);
117    attachments.iter().find_map(|(s, r)| {
118        s.upgrade()
119            .filter(|known| Arc::ptr_eq(known, &storage))
120            .and_then(|_| r.upgrade())
121    })
122}
123
124/// The coding tools registered by the CLI by default.
125pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "docs"];
126
127/// The default coding system prompt for the CLI's built-in tools.
128///
129/// This retains the two-argument API for library callers. Session startup and
130/// reload use [`default_system_prompt_with_tool_prompts`] with the actual active
131/// tools and their winning extension metadata.
132pub fn default_system_prompt(cwd: &str, prior_reasoning_replayed: bool) -> String {
133    let tools = BUILTIN_TOOL_NAMES
134        .iter()
135        .map(|name| (*name).to_owned())
136        .collect::<Vec<_>>();
137    default_system_prompt_with_tools(cwd, prior_reasoning_replayed, &tools)
138}
139
140/// A coding system prompt for the selected tools. A condensed port of the TS
141/// `packages/coding-agent/src/core/system-prompt.ts` base prompt.
142///
143/// `tools` is the active tool set after `--tools`/`--exclude-tools` and
144/// extension merging. Like pi, the text list includes only tools with an
145/// explicit prompt snippet; other active tools remain available through their
146/// provider-facing schemas. Tool-specific guidelines still follow the full
147/// active set, so restricted runs get no guidance for unavailable tools.
148pub fn default_system_prompt_with_tools(
149    cwd: &str,
150    prior_reasoning_replayed: bool,
151    tools: &[String],
152) -> String {
153    default_system_prompt_with_tool_prompts(cwd, prior_reasoning_replayed, tools, &BTreeMap::new())
154}
155
156/// Render pi-style explicit prompt contributions for the active tool set.
157/// Entries override built-in metadata, including an empty entry for an
158/// extension that replaces a built-in without contributing a prompt snippet.
159/// Inactive metadata is ignored. Schemas and callable tools are not modified.
160pub fn default_system_prompt_with_tool_prompts(
161    cwd: &str,
162    prior_reasoning_replayed: bool,
163    tools: &[String],
164    overrides: &BTreeMap<String, ToolPromptMetadata>,
165) -> String {
166    let metadata: BTreeMap<_, _> = tools
167        .iter()
168        .map(|name| {
169            let prompt = overrides
170                .get(name)
171                .cloned()
172                .unwrap_or_else(|| ToolPromptMetadata {
173                    prompt_snippet: builtin_tool_snippet(name).map(str::to_owned),
174                    prompt_guidelines: Vec::new(),
175                })
176                .normalized();
177            (name.as_str(), prompt)
178        })
179        .collect();
180    // The working-state rule must match what actually comes back on the next
181    // request. With `compat.requiresThinkingAsText` (or the native thinking
182    // channels of anthropic/responses) the model *does* see its own reasoning
183    // again, and claiming otherwise makes it re-derive and restate the plan
184    // every turn — the re-plan symptom in `docs/llm-repetition-forensics.md`.
185    let working_state_rule = if prior_reasoning_replayed {
186        "- Your reasoning is carried back to you on the next turn: do not re-derive it or restate
187  the plan. Continue from the last unfinished step, and when a step lands, say which one.
188  Re-stating the same plan without acting on it is a bug, not progress."
189    } else {
190        "- Your reasoning is not carried into your next turn; only your visible replies and tool
191  output come back. Before each batch of tool calls, write one short line naming the step you
192  are on and what remains, and say which step is done when it lands.
193  Re-stating the same plan without acting on it is a bug, not progress."
194    };
195
196    // The text list is a selective summary, not the tool registry. In
197    // particular, do not repeat extension tool names without a prompt snippet.
198    let tool_lines = tools
199        .iter()
200        .filter_map(|name| {
201            metadata
202                .get(name.as_str())?
203                .prompt_snippet
204                .as_ref()
205                .map(|snippet| format!("- {name:<5} — {snippet}"))
206        })
207        .collect::<Vec<_>>()
208        .join("\n");
209    let tool_lines = if tool_lines.is_empty() {
210        "(none)".to_string()
211    } else {
212        tool_lines
213    };
214
215    // Tool-specific rules (Bash on Windows, docs before guessing) live in the
216    // tool's own line or description, not here a second time.
217    let guidelines = [
218        "- Reply in the same language as the user's latest message (the conversation's language when
219  that message is too short to tell), unless the user asks for another.",
220        "- Be concise in your responses",
221        "- Show file paths clearly when working with files",
222        "- Prefer the smallest change that solves the problem",
223        working_state_rule,
224        "- Read only what you need. If a file or search result was already shown earlier in this
225  conversation, use it instead of fetching it again.",
226    ];
227    // Tools contribute rules even without snippets. Like pi, trim and dedupe
228    // across the full active set and the core rules, preserving first occurrence.
229    let mut seen = HashSet::new();
230    let tool_rules = tools
231        .iter()
232        .flat_map(|name| &metadata[name.as_str()].prompt_guidelines)
233        .map(String::as_str);
234    let core_rules = guidelines
235        .iter()
236        .map(|rule| rule.strip_prefix("- ").unwrap_or(rule));
237    let guidelines = tool_rules
238        .chain(core_rules)
239        .map(str::trim)
240        .filter(|rule| !rule.is_empty() && seen.insert(*rule))
241        .map(|rule| format!("- {rule}"))
242        .collect::<Vec<_>>()
243        .join("\n");
244
245    format!(
246        "You are an expert coding assistant operating inside rpi, a coding agent harness. \
247You help users by reading files, executing commands, editing code, and writing new files.
248
249Available tools:
250{tool_lines}
251
252In addition to the tools above, you may have access to other custom tools depending on the project.
253
254Guidelines:
255{guidelines}
256
257Current working directory: {cwd}"
258    )
259}
260
261/// Take metadata from the same winning extension definitions used for tool
262/// merging, including empty metadata for overrides. Called on startup/reload.
263fn extension_tool_prompt_metadata(
264    session: &ExtensionSession,
265) -> BTreeMap<String, ToolPromptMetadata> {
266    session
267        .snapshot()
268        .map(|snapshot| {
269            snapshot
270                .tools()
271                .iter()
272                .map(|tool| (tool.tool.name.clone(), tool.prompt.clone()))
273                .collect()
274        })
275        .unwrap_or_default()
276}
277
278/// One-line prompt descriptions of the tools rpi itself registers.
279fn builtin_tool_snippet(name: &str) -> Option<&'static str> {
280    Some(match name {
281        "read" => "Read file contents",
282        "bash" => "Execute Bash commands (Bash/POSIX syntax, also on Windows)",
283        "edit" => "Find/replace edits to existing files",
284        "write" => "Create or overwrite files",
285        "docs" => "Look up rpi commands, extensions, Pi package compatibility and .rpi configuration instead of guessing",
286        "grep" => "Search file contents for a pattern",
287        "find" => "Find files by glob pattern",
288        "ls" => "List directory contents",
289        "powershell" => "Execute PowerShell commands on Windows",
290        _ => return None,
291    })
292}
293
294/// How the user asked to select a session. v1 honors `NoSession` (ephemeral
295/// `InMemorySessionStorage`), `New` (a fresh JSONL file), and — new this pass —
296/// `Latest` / `ById`, which **restore** an existing JSONL session on launch
297/// (`--continue`/`-c`, `--resume`/`-r`, `--session <id|path>`). The restored
298/// transcript renders into the TUI on startup and the run continues appending
299/// to the same file.
300#[derive(Debug, Clone)]
301pub enum SessionSelection {
302    /// `--no-session`: ephemeral, in-memory, nothing persisted.
303    Ephemeral,
304    /// Fresh durable JSONL session under `--session-dir` (or the default dir).
305    New { dir: PathBuf, name: Option<String> },
306    /// Explicit fresh print-only experimental backend; never a restore selection.
307    ExperimentalNative { dir: PathBuf },
308    /// `-c` / `-r`: restore the most recent session in the default dir.
309    Latest { dir: PathBuf },
310    /// `--session <id|path>`: restore the session whose id matches, or whose
311    /// file name contains the id.
312    ById { id: String, dir: PathBuf },
313    /// `--session-id <id>`: use the EXACT session id, creating it if missing.
314    ByExactId { id: String, dir: PathBuf },
315    /// `--fork <path|id>`: fork the given session into a new one and start in
316    /// the fork.
317    Fork { source: String, dir: PathBuf },
318}
319
320/// Decide the session selection from parsed args + the resolved cwd.
321pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
322    select_session_in_dir(args, configured_session_dir(args, cwd))
323}
324
325/// Resolve the root once at startup; runtime actions retain this selection.
326pub(crate) fn configured_session_dir(args: &Args, cwd: &Path) -> PathBuf {
327    if let Some(root) = &args.experimental_native_store {
328        return if root.is_absolute() {
329            root.clone()
330        } else {
331            cwd.join(root)
332        };
333    }
334    let dir = args
335        .session_dir
336        .clone()
337        .or_else(|| {
338            crate::settings::load_settings()
339                .ok()
340                .and_then(|settings| settings.session_dir)
341        })
342        .unwrap_or_else(|| default_session_dir(cwd));
343    // Relative roots belong to the launch project, even if an extension later
344    // changes the process working directory.
345    if dir.is_relative() && !dir.has_root() {
346        cwd.join(dir)
347    } else {
348        dir
349    }
350}
351
352pub(crate) fn select_session_in_dir(args: &Args, dir: PathBuf) -> SessionSelection {
353    if args.experimental_native_store.is_some() {
354        return SessionSelection::ExperimentalNative { dir };
355    }
356    if args.no_session {
357        return SessionSelection::Ephemeral;
358    }
359    // `--session-dir` wins; then the saved `sessionDir` (upstream's key, same
360    // format as the flag); then the built-in default. Keep this directory on
361    // restore selections too, not only on fresh sessions.
362    if args.continue_session || args.resume {
363        return SessionSelection::Latest { dir };
364    }
365    if let Some(s) = &args.fork {
366        return SessionSelection::Fork {
367            source: s.clone(),
368            dir,
369        };
370    }
371    if let Some(s) = &args.session_id {
372        return SessionSelection::ByExactId { id: s.clone(), dir };
373    }
374    if let Some(s) = &args.session {
375        return SessionSelection::ById { id: s.clone(), dir };
376    }
377    SessionSelection::New {
378        dir,
379        name: args.name.clone(),
380    }
381}
382
383/// The default session directory is `<cwd>/.rpi/sessions`, unless
384/// `RPI_CODING_AGENT_SESSION_DIR` is set (in which case its value is used).
385pub fn default_session_dir(cwd: &Path) -> PathBuf {
386    std::env::var("RPI_CODING_AGENT_SESSION_DIR")
387        .ok()
388        .map(PathBuf::from)
389        .unwrap_or_else(|| cwd.join(".rpi").join("sessions"))
390}
391
392/// Extensions are loaded exactly once before model resolution. Their action
393/// host remains unbound until the harness and authenticated catalog exist.
394pub struct PreparedExtensions {
395    resource_leases: Arc<Vec<crate::resource_management::Lease>>,
396    resource_selection: Option<crate::resource_selection::ResolvedSelection>,
397    session_selection: SessionSelection,
398    transition_session: Option<Session>,
399    pub extension_session: ExtensionSession,
400    native_store: Option<PathBuf>,
401    session_dir: PathBuf,
402    runtime: tokio::runtime::Handle,
403    tool_context: rpi_extensions::ToolCallContext,
404    action_host: Arc<crate::extensions_actions::HarnessActionHost>,
405    harness_cell: Arc<std::sync::OnceLock<rpi_harness::agent_harness::WeakAgentHarness>>,
406    reload_mailbox: rpi_extensions::ReloadMailbox,
407    ui_dialog_mailbox: rpi_extensions::UiDialogMailbox,
408    ext_status_mailbox: rpi_extensions::ExtensionStatusMailbox,
409    editor_text_mailbox: rpi_extensions::EditorTextMailbox,
410    action_bridge: Arc<rpi_extensions::ActionBridge>,
411}
412
413/// Validate native durable resource ownership BEFORE loading any executable code.
414/// The synchronous public prepare_extensions API is retained: a scoped reader
415/// thread uses its own runtime so this also works inside current-thread Tokio.
416/// It does no registration, provider callbacks, session creation, or lock claiming.
417pub(crate) fn preflight_extension_startup(
418    args: &Args,
419    cwd: &Path,
420    trusted: bool,
421) -> Result<
422    (
423        Option<crate::resource_selection::ResolvedSelection>,
424        SessionSelection,
425    ),
426    BuildError,
427> {
428    let mut resources = crate::resource_selection::ResolvedSelection::load(args, cwd, trusted)
429        .map_err(BuildError::HarnessCreate)?;
430    let selection = select_session(args, cwd);
431    let pinned = std::thread::scope(|scope| {
432        scope
433            .spawn(|| {
434                tokio::runtime::Builder::new_current_thread()
435                    .enable_all()
436                    .build()
437                    .map_err(|e| BuildError::SessionOpen(e.to_string()))?
438                    .block_on(async {
439                        if let Some(selected) = &mut resources {
440                            let env: Arc<dyn rpi_tools::ExecutionEnv> =
441                                Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
442                            selected
443                                .validate_skills(&env)
444                                .await
445                                .map_err(BuildError::HarnessCreate)?;
446                        }
447                        let cwd_str = cwd.to_string_lossy();
448                        let target = match &selection {
449                            SessionSelection::Fork { source, dir } => {
450                                match session_metadata_at(Path::new(source)) {
451                                    Some(meta) => Some(meta),
452                                    None => find_session_metadata(source, &cwd_str, dir).await?,
453                                }
454                            }
455                            _ => resolve_restore_target(&selection, &cwd_str).await?,
456                        };
457                        let Some(meta) = target else {
458                            if resources.as_ref().is_some_and(|r| r.transition.is_some()) {
459                                return Err(BuildError::SessionOpen("resource transition requires an existing native session".into()));
460                            }
461                            return Ok(selection.clone());
462                        };
463                        if resources.as_ref().is_some_and(|r| r.transition.is_some()) && matches!(selection, SessionSelection::Fork { .. }) {
464                            return Err(BuildError::SessionOpen("resource transition cannot fork".into()));
465                        }
466                        use rpi_harness::session::jsonl::{
467                            JsonlSessionRepo, JsonlSessionRepoOptions,
468                        };
469                        let dir = selection_dir(&selection);
470                        let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
471                            fs: Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
472                            sessions_root: dir.to_string_lossy().into_owned(),
473                            clock: Arc::new(SystemClock),
474                            ids: Arc::new(DefaultIdGenerator::new()),
475                        });
476                        let binding_facts = StartupSpan::start(Phase::BindingFacts);
477                        let facts = if resources.as_ref().is_some_and(|r| r.transition.is_some()) {
478                            rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
479                                Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())), &meta.path,
480                                Arc::new(SystemClock), Arc::new(DefaultIdGenerator::new()),
481                            ).await
482                        } else { repo.read_facts_by_jsonl_metadata(&meta).await }
483                            .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
484                        crate::resource_selection::validate_session_binding(
485                            &facts.values,
486                            resources.as_ref(),
487                        )
488                        .map_err(BuildError::SessionOpen)?;
489                        binding_facts.finish();
490                        Ok(match selection {
491                            SessionSelection::Fork { .. } => SessionSelection::Fork {
492                                source: meta.path,
493                                dir,
494                            },
495                            _ => SessionSelection::ByExactId { id: meta.id, dir },
496                        })
497                    })
498            })
499            .join()
500            .map_err(|_| BuildError::SessionOpen("resource preflight reader panicked".into()))?
501    })?;
502    Ok((resources, pinned))
503}
504
505pub fn prepare_extensions(
506    args: &Args,
507    cwd: &Path,
508    project_trusted: bool,
509) -> Result<PreparedExtensions, BuildError> {
510    let native_store =
511        crate::native_store::root(args, cwd).map_err(BuildError::NativeStorePolicy)?;
512    let resource_leases =
513        crate::resource_management::host_leases(cwd).map_err(BuildError::HarnessCreate)?;
514    let (resource_selection, session_selection) =
515        preflight_extension_startup(args, cwd, project_trusted)?;
516    // Transition hosts own the writer before any DLL registration. The strict
517    // loader cannot repair history/progress or migrate metadata on rejection.
518    let transition_session = if resource_selection
519        .as_ref()
520        .is_some_and(|r| r.transition.is_some())
521    {
522        Some(std::thread::scope(|scope| {
523            scope.spawn(|| {
524            tokio::runtime::Builder::new_current_thread().enable_all().build()
525                .map_err(|e| BuildError::SessionOpen(e.to_string()))?.block_on(async {
526                    let meta = resolve_restore_target(&session_selection, &cwd.to_string_lossy()).await?
527                        .ok_or_else(|| BuildError::SessionOpen("transition session disappeared".into()))?;
528                    let path = std::fs::canonicalize(&meta.path).map_err(|e| BuildError::SessionOpen(e.to_string()))?;
529                    claim_session_lock_inner(&path.to_string_lossy(), false).map_err(BuildError::SessionOpen)?;
530                    let storage = rpi_harness::session::jsonl::JsonlSessionStorage::load_for_resource_transition(
531                        Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())), &path.to_string_lossy(),
532                        Arc::new(SystemClock), Arc::new(DefaultIdGenerator::new()),
533                    ).await.map_err(|e| BuildError::SessionOpen(e.to_string()))?;
534                    let session = Session::new(Arc::new(storage), None);
535                    crate::resource_selection::bind_session(&session, resource_selection.as_ref()).await.map_err(BuildError::SessionOpen)?;
536                    Ok::<_, BuildError>(session)
537                })
538        }).join().map_err(|_| BuildError::SessionOpen("transition writer preparation panicked".into()))?
539        })?)
540    } else {
541        None
542    };
543    let session_dir = if matches!(session_selection, SessionSelection::Ephemeral) {
544        configured_session_dir(args, cwd)
545    } else {
546        selection_dir(&session_selection)
547    };
548    let cwd_str = cwd.to_string_lossy().to_string();
549    let tool_context = rpi_extensions::ToolCallContext::new(cwd_str.clone());
550    if !project_trusted && args.verbose {
551        eprintln!(
552            "warning: current-project settings, resources, and discovered extensions are explicitly disabled (use --approve or /trust yes to re-enable)"
553        );
554    }
555    // Pi packages are explicitly opt-in because discovery can start Node and
556    // execute package code. An explicit project opt-out limits discovery to
557    // global settings.
558
559    // ---- B5a: build the action bridge BEFORE extension load ----
560    // Extensions load before `AgentHarness::create` (extensions provide tools the
561    // harness is built with), but a plugin stores the `ActionBridge`'s raw
562    // `user_data` pointer during `register` and it must remain valid + the host
563    // must be ready for the whole session. So:
564    //  1. Capture the current tokio `Handle` (the async main-thread runtime) —
565    //     the bridge spawns dispatch from any thread via `Handle::spawn`.
566    //  2. Build an *empty* `HarnessActionHost` (its harness cell is unset; no
567    //     plugin can call a runtime action before the harness runs).
568    //  3. Wrap it as `Arc<dyn RuntimeActionHost>` + `ActionBridge`, thread
569    //     `Some(bridge)` into `load_extensions` so every plugin's `user_data`
570    //     points at this bridge.
571    //  4. After `AgentHarness::create` succeeds, call `set_harness(&cell, …)` to
572    //     fill the host cell the bridge recovers on the first action call.
573    let runtime = tokio::runtime::Handle::try_current().map_err(|e| {
574        BuildError::HarnessCreate(format!("no tokio runtime for action bridge: {e}"))
575    })?;
576    let catalog = Vec::new();
577    let (action_host, harness_cell) = crate::extensions_actions::HarnessActionHost::new_empty(
578        catalog.clone(),
579        cwd.to_path_buf(),
580        runtime.clone(),
581        args.unknown_flags.clone(),
582        tool_context.clone(),
583    );
584    let action_host = Arc::new(
585        action_host
586            .with_session_dir(session_dir.clone())
587            .with_fresh_session_only(native_store.is_some()),
588    );
589    let host_arc: Arc<dyn rpi_extensions::RuntimeActionHost> = action_host.clone();
590    // `runtime` is reused below (B5c: `PluggableProvider` needs a captured
591    // `Handle` to `spawn_blocking` the sync `ProviderRequestFn`), so clone here.
592    //
593    // B5d: build the initial bridge WITH a reload callback backed by a session-
594    // long `ReloadMailbox` (cloned into `ReloadContext` + handed to the TUI). A
595    // plugin's `runtime_action(Reload)` then signals the TUI's main loop instead
596    // of hitting the "not configured" fallback. The same mailbox is reused on
597    // `/reload` (the fresh bridge carries `ctx.mailbox`), so the bridge always
598    // points at the one TUI-installed sender across reloads.
599    let reload_mailbox = rpi_extensions::ReloadMailbox::new();
600    // Session-scoped UI-dialog mailbox (runtime action 17 / `ask_user`). The
601    // initial bridge carries it and the TUI attaches itself once it starts
602    // polling; a headless run leaves it detached so `ask_user` fails loudly
603    // instead of fabricating a user answer. The SAME mailbox is reused across
604    // `/reload` so in-flight prompts + TUI attachment survive a plugin swap.
605    let ui_dialog_mailbox = rpi_extensions::UiDialogMailbox::new();
606    // Session-scoped extension status registry (runtime action 18 / `SetStatus`).
607    // The bridge lets plugins write; the TUI polls the revision and repaints its
608    // footer. Reused across `/reload` so a plugin's status survives the swap.
609    let ext_status_mailbox = rpi_extensions::ExtensionStatusMailbox::new();
610    // Session-scoped editor-text queue (runtime action 19 / `SetEditorText`).
611    // The bridge lets a plugin drop a draft into the prompt editor; the TUI
612    // drains it each tick. Reused across `/reload` so a pending transcription
613    // survives a plugin swap.
614    let editor_text_mailbox = rpi_extensions::EditorTextMailbox::new();
615    let action_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
616        runtime.clone(),
617        host_arc,
618        rpi_extensions::reload_callback_from_mailbox(reload_mailbox.clone()),
619        ui_dialog_mailbox.clone(),
620        ext_status_mailbox.clone(),
621        editor_text_mailbox.clone(),
622    );
623
624    let extension_register = StartupSpan::start(Phase::ExtensionRegister);
625    let extension_session = if let Some(selected) = &resource_selection {
626        let session = rpi_extensions::load_session_mixed_guarded(
627            &[],
628            &selected.extensions,
629            Arc::new(NullDiagnostics),
630            Some(Arc::clone(&action_bridge)),
631            Some(resource_leases.clone()),
632        );
633        if session.loaded_paths() != selected.extensions.as_slice() {
634            return Err(BuildError::HarnessCreate(
635                "selected native extension failed to load/register".into(),
636            ));
637        }
638        if session.owns_stdio() {
639            return Err(BuildError::HarnessCreate(
640                "stdio-owning extensions are unsupported with --resource-config".into(),
641            ));
642        }
643        session
644    } else if args.no_extensions {
645        ExtensionSession::none()
646    } else {
647        load_extensions(args, cwd, project_trusted, Some(Arc::clone(&action_bridge)))
648    };
649    extension_register.finish();
650    if args.verbose {
651        if let Some(s) = extension_session.summary() {
652            eprintln!("extensions: {s}");
653        }
654        report_deferred_renderers(&extension_session);
655    }
656    crate::native_store::validate_stdio(native_store.is_some(), extension_session.owns_stdio())
657        .map_err(BuildError::NativeStorePolicy)?;
658    // Publish the extension registry so `ListCommands` can enumerate slash
659    // commands. Done here rather than at host construction because the registry
660    // only exists after extensions load; `/reload` republishes the new snapshot
661    // via `RuntimeActionHost::publish_registry`.
662    action_host.publish_registry_impl(extension_session.snapshot_arc());
663    Ok(PreparedExtensions {
664        resource_leases,
665        resource_selection,
666        session_selection,
667        transition_session,
668        native_store,
669        session_dir,
670        runtime,
671        tool_context,
672        action_host,
673        harness_cell,
674        reload_mailbox,
675        ui_dialog_mailbox,
676        ext_status_mailbox,
677        editor_text_mailbox,
678        action_bridge,
679        extension_session,
680    })
681}
682
683/// Build the `AgentHarness` from the resolved model + parsed args + cwd.
684///
685/// This is the v1 equivalent of TS `createAgentSession`. It:
686/// 1. Builds the `OsExecutionEnv` rooted at `cwd`.
687/// 2. Constructs the built-in tools (optionally filtered by `--tools`/
688///    `--exclude-tools`/`--no-tools`/`--no-builtin-tools`).
689/// 3. Resolves the session storage (ephemeral vs fresh JSONL vs restore-error).
690/// 4. Assembles `AgentHarnessOptions` and calls `AgentHarness::create`.
691///
692/// Returns the harness plus a `broadcast::Receiver<AgentEvent>` carrying the
693/// live `AgentEvent` stream from every run (backed by a `BroadcastEmitter`
694/// installed on the harness). Interactive mode drains this to render streaming
695/// responses; the non-interactive modes simply drop it.
696/// Returns the harness, the live `AgentEvent` broadcast receiver, and a
697/// [`ReloadContext`] the interactive TUI holds to drive `/reload` (and a
698/// plugin's `runtime_action(Reload)` via the mailbox). Non-interactive modes
699/// drop the context (no `/reload` surface in print/json mode).
700pub async fn build(
701    resolved: &ResolvedModel,
702    args: &Args,
703    cwd: &Path,
704    project_trusted: bool,
705) -> Result<
706    (
707        AgentHarness,
708        tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
709        ReloadContext,
710    ),
711    BuildError,
712> {
713    let prepared = prepare_extensions(args, cwd, project_trusted)?;
714    build_prepared(resolved, args, cwd, project_trusted, prepared).await
715}
716
717pub async fn build_prepared(
718    resolved: &ResolvedModel,
719    args: &Args,
720    cwd: &Path,
721    project_trusted: bool,
722    prepared: PreparedExtensions,
723) -> Result<
724    (
725        AgentHarness,
726        tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
727        ReloadContext,
728    ),
729    BuildError,
730> {
731    let native_store =
732        crate::native_store::root(args, cwd).map_err(BuildError::NativeStorePolicy)?;
733    if native_store != prepared.native_store {
734        return Err(BuildError::NativeStorePolicy(
735            "experimental session selection changed after extension preparation".into(),
736        ));
737    }
738    crate::native_store::validate_stdio(
739        native_store.is_some(),
740        prepared.extension_session.owns_stdio(),
741    )
742    .map_err(BuildError::NativeStorePolicy)?;
743    let fresh_session_only = native_store.is_some();
744    let cwd_str = cwd.to_string_lossy().to_string();
745    // Plugin tools get the session's identity injected into every call's
746    // arguments (see `rpi_extensions::ToolCallContext`). It is created here,
747    // before the tools are built, and completed with the session id below once
748    // the session exists — the tool set has to exist before the harness does.
749    let PreparedExtensions {
750        resource_leases,
751        resource_selection,
752        session_selection: selection,
753        transition_session,
754        native_store: _,
755        session_dir,
756        runtime,
757        tool_context,
758        action_host,
759        harness_cell,
760        reload_mailbox,
761        ui_dialog_mailbox,
762        ext_status_mailbox,
763        editor_text_mailbox,
764        action_bridge,
765        extension_session,
766    } = prepared;
767    let catalog = crate::provider::authenticated_catalog(resolved);
768    action_host.set_catalog(catalog.clone());
769
770    // ---- Execution env + tools ----
771    let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
772    let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
773    let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
774    let _registry = Arc::new(MutationQueueRegistry::new());
775    // `env_dyn` is shared between the tool context (moved in) and the resource
776    // loaders below (borrowed); clone one branch so both hold a reference.
777    let file_io = Arc::new(rpi_tools::file_io::FileIORouter::new());
778    let ctx =
779        ExecutionToolContext::new(env_dyn.clone(), Some(mut_env)).with_file_io(file_io.clone());
780
781    let mut tools = build_tools(&ctx, args);
782
783    // ---- Extensions (Part B2) ----
784    // Load cdylib plugins from the resolved extension dirs, merge their tools
785    // into the built-in set (extension overrides same-named built-in; first-
786    // extension-wins across plugins; explicit `--tools`/`--exclude-tools` still
787    // apply to the merged set), and keep the loaded `Library` handles alive for
788    // the harness lifetime via the returned session guard. `--no-extensions`
789    // skips discovery entirely (no dirs scanned, no plugins loaded).
790    merge_extension_tools(&mut tools, &extension_session, args, &tool_context);
791    let active = if let Some(selected) = &resource_selection {
792        selected
793            .active_tools(
794                &tools
795                    .iter()
796                    .map(|t| t.tool.schema().name.clone())
797                    .collect::<Vec<_>>(),
798            )
799            .map_err(BuildError::HarnessCreate)?
800    } else {
801        filter_active_tool_names(active_tool_names(&tools, args), args)
802    };
803
804    // ---- Session storage ----
805    // The root is the one `prepare_extensions` froze at startup, never a fresh
806    // `configured_session_dir(args, cwd)`: settings and `RPI_CODING_AGENT_SESSION_DIR`
807    // can change while extensions load, and an adopted session cwd would point
808    // at another project's tree. Both the cwd peek and this build must read the
809    // same root, so the caller pins it into `args` before either runs.
810    let session = match transition_session {
811        Some(session) => session,
812        None => build_session(&selection, &cwd_str, resource_selection.as_ref()).await?,
813    };
814    crate::resource_selection::bind_session(&session, resource_selection.as_ref())
815        .await
816        .map_err(BuildError::SessionOpen)?;
817    // Plugin tools now know which session they serve: `todo` and friends key
818    // their per-session state off it. A session without an id (ephemeral) leaves
819    // the field unset and a plugin is expected to fall back to project scope.
820    if let Ok(metadata) = session.get_metadata().await {
821        publish_session_identity(&tool_context, &metadata.id);
822    }
823    // `--name`/`-n` sets the session display name durably (a `name` fact in the
824    // log). It applies to whichever session this launch works in — a fresh one
825    // or a restored one — and an empty value clears a previous name. This is the
826    // only place the flag is applied: `SessionSelection::New` carries it, but
827    // builds that do not create a session (restore/fork) must still honor it.
828    if let Some(requested) = args.name.as_deref() {
829        let name = requested.trim();
830        let name = if name.is_empty() { None } else { Some(name) };
831        if let Err(error) = session.set_name(name).await {
832            if fresh_session_only {
833                return Err(BuildError::SessionOpen(format!(
834                    "native session name persistence failed: {error}"
835                )));
836            }
837            if args.verbose {
838                eprintln!("warning: could not set session name: {error}");
839            }
840        }
841    }
842    // ---- System prompt base (precedence: --system-prompt > SYSTEM.md > default) ----
843    // Mirrors pi `discoverSystemPromptFile` (`resource-loader.ts:1022-1034`):
844    // an explicit `--system-prompt` flag wins; otherwise a discovered
845    // `<cwd>/.rpi/SYSTEM.md` wins, then
846    // `<agent_dir>/SYSTEM.md`.
847    // (global); otherwise the built-in default. **Project-wins** — the same
848    // direction as skills/prompts precedence.
849    // Whether this model sees its own prior reasoning again decides how the
850    // working-state rule is worded (see `default_system_prompt`).
851    let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&resolved.model);
852    let prompt_tools = active.clone();
853    let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
854    let default_prompt = || {
855        default_system_prompt_with_tool_prompts(
856            &cwd_str,
857            prior_reasoning_replayed,
858            &prompt_tools,
859            &prompt_metadata,
860        )
861    };
862    let mut context_paths = Vec::new();
863    let base_prompt = match args.system_prompt.as_deref() {
864        Some(explicit) => explicit.to_string(),
865        None if project_trusted && resource_selection.is_none() => {
866            match discover_system_prompt_file(cwd) {
867                Some(path) => {
868                    read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
869                }
870                None => default_prompt(),
871            }
872        }
873        None => default_prompt(),
874    };
875
876    // ---- Append-text sources (precedence: --append-system-prompt > APPEND_SYSTEM.md) ----
877    // Mirrors pi `appendSystemPrompt` (`resource-loader.ts:525-542`). Explicit
878    // `--append-system-prompt` flags are joined together; when none are given, a
879    // discovered `APPEND_SYSTEM.md` (project-wins over global) provides the
880    // append text. `--append-system-prompt` takes a value that may be a literal
881    // string OR a readable file path (mirrors TS `resolvePromptInput`).
882    let mut append_texts: Vec<String> = Vec::new();
883    for extra in &args.append_system_prompt {
884        let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
885        append_texts.push(text);
886    }
887    if let Some(selected) = &resource_selection {
888        append_texts.extend(
889            selected
890                .instructions
891                .iter()
892                .map(|(_, _, text)| text.clone()),
893        );
894    }
895    if args.append_system_prompt.is_empty() && resource_selection.is_none() {
896        if let Some(path) = project_trusted
897            .then(|| discover_append_system_prompt_file(cwd))
898            .flatten()
899        {
900            if let Some(text) = read_prompt_source(&path, &mut context_paths) {
901                append_texts.push(text);
902            }
903        }
904    }
905    let append_join = if append_texts.is_empty() {
906        None
907    } else {
908        Some(append_texts.join("\n\n"))
909    };
910
911    // ---- Resource discovery (skills + prompt-templates + context-files) ----
912    // The env is OS-backed, rooted at cwd. Each `--no-*` flag suppresses its
913    // channel independently (pi parity). Skills/prompts load project→global,
914    // explicit/plugin paths, then static packages; dedupe first-wins-by-name
915    // keeps project and user resources ahead of packages. Context files walk
916    // global→ancestor(cwd→root), deepest-last (pi parity).
917    //
918    // **Trust gate (v1 divergence):** pi gates project config discovery on
919    // `isProjectTrusted()` (global resources are unconditional). rpi v1 has no
920    // trust prompt — project resources are discovered unconditionally (a copied
921    // `.rpi/` dir drops in and works). Full trust gating is deferred.
922    let agent_dir = crate::config::agent_dir().ok();
923
924    // ---- B5b: extension resources_discover ----
925    // If any plugin registered a `resources_discover` handler, fan the event out
926    // (reason "startup") and collect skill/prompt/theme paths. These plugin-
927    // contributed paths merge WITH the static Part-A dirs (project
928    // `.rpi/skills` +
929    // `agent_dir/skills`, etc.) and the loaders re-run over the union — the
930    // coherence point: a plugin's discovered skills land through the SAME loaders
931    // as static skills. Static dirs load FIRST so project skills keep winning name
932    // collisions (a plugin must not shadow a project skill of the same name —
933    // mirrors pi `extendResources` running AFTER the default load's first-wins
934    // map). `load_skills` now accepts both dirs and individual `.md` files, so a
935    // plugin returning bare `SKILL.md` paths loads them (the gap this closes).
936    // Theme paths are available to the TUI through the package resource list;
937    // skill/prompt loaders are the only resources needed by the harness here.
938    // A `--no-*` flag suppresses its channel for BOTH static and discovered paths.
939    let discovered = extension_session
940        .snapshot_arc()
941        .filter(|_| resource_selection.is_none())
942        .map(|snap| emit_resources_discover(&cwd_str, "startup", &snap))
943        .unwrap_or_default();
944
945    let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
946    let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
947    let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
948    if let Some(selected) = &resource_selection {
949        // Consume the preflight's parsed resources, never rediscover or reread
950        // selected Skills after executable registration / writer opening.
951        skills = selected
952            .validated_skills()
953            .map_err(BuildError::HarnessCreate)?
954            .skills
955            .clone();
956    } else if !args.no_skills {
957        let mut dirs = if args.dev_local_only || args.project_only {
958            project_skill_dirs(cwd)
959        } else if project_trusted {
960            skill_dirs(cwd)
961        } else {
962            global_skill_dirs()
963        };
964        dirs.extend(args.skill.iter().cloned());
965        dirs.extend(discovered.skill_paths.iter().map(PathBuf::from));
966        let result = load_skills_with_precedence(&env_dyn, &dirs).await;
967        skills = result.skills;
968        skill_diags = result.diagnostics;
969        resource_diags.extend(result.resource_diagnostics);
970    }
971
972    let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
973    let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
974    if !args.no_prompt_templates && resource_selection.is_none() {
975        let mut dirs = if args.dev_local_only || args.project_only {
976            project_prompt_template_dirs(cwd)
977        } else if project_trusted {
978            prompt_template_dirs(cwd)
979        } else {
980            global_prompt_template_dirs()
981        };
982        dirs.extend(args.prompt_template.iter().cloned());
983        dirs.extend(discovered.prompt_paths.iter().map(PathBuf::from));
984        let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
985        prompt_templates = result.prompt_templates;
986        prompt_diags = result.diagnostics;
987        resource_diags.extend(result.resource_diagnostics);
988    }
989
990    let context_block = if args.no_context_files || !project_trusted || resource_selection.is_some()
991    {
992        String::new()
993    } else {
994        // `load_project_context_files` walks the global agentDir first then
995        // ancestor-walks cwd→root (deepest last). It needs a real agent_dir; if
996        // none is resolvable, pass the cwd dir so only the ancestor-walk runs
997        // (the global step returns None anyway).
998        let agent_dir_path = agent_dir.clone().unwrap_or_else(|| cwd.to_path_buf());
999        let files = load_project_context_files(&env_dyn, cwd, &agent_dir_path).await;
1000        context_paths.extend(
1001            files
1002                .iter()
1003                .map(|file| file.path.to_string_lossy().into_owned()),
1004        );
1005        format_project_context(&files)
1006    };
1007
1008    // Surface resource-discovery diagnostics as startup warnings (verbose-only).
1009    if args.verbose {
1010        for d in &skill_diags {
1011            eprintln!(
1012                "warning: skill {} ({}): {}",
1013                d.path,
1014                d.code.as_str(),
1015                d.message
1016            );
1017        }
1018        for d in &prompt_diags {
1019            eprintln!(
1020                "warning: prompt template {} ({}): {}",
1021                d.path,
1022                d.code.as_str(),
1023                d.message
1024            );
1025        }
1026        // Structured collisions: make the winner/loser explicit.
1027        for d in &resource_diags {
1028            if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
1029                eprintln!(
1030                    "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
1031                    c.resource_type.as_str(),
1032                    c.name,
1033                    c.winner_path,
1034                    c.loser_path
1035                );
1036            }
1037        }
1038    }
1039
1040    // ---- Compose the full system prompt ----
1041    // Order mirrors pi `buildSystemPrompt` (`system-prompt.ts:28-72`):
1042    // base → append → context → skills. The skills listing is the harness's own
1043    // section: `AgentHarness::compose_prompt` appends `<available_skills>` (gated
1044    // on the `read` tool + `disable_model_invocation`, applied inside
1045    // `format_skills_for_system_prompt`). So we pass None for skills here (the
1046    // harness adds the listing itself) and fold only base+append+context into
1047    // the prompt we hand the harness.
1048    let system_prompt = compose_system_prompt(
1049        Some(&base_prompt),
1050        &[], // skills: harness appends the listing itself
1051        if context_block.is_empty() {
1052            None
1053        } else {
1054            Some(&context_block)
1055        },
1056        append_join.as_deref(),
1057    );
1058
1059    // ---- Debug: dump the resolved system-prompt sections (verification) ----
1060    // A verification affordance for Part-A resource discovery: prints the
1061    // composed sections + resource counts to stderr so a smoke can confirm
1062    // `<available_skills>` + `<project_context>` + appended text reached the
1063    // prompt without parsing a provider round-trip. The harness composes the
1064    // final prompt (base → append → context → skills); here we print the
1065    // pre-harness sections (the harness adds the skills listing itself, gated
1066    // on `read` + `disable_model_invocation`).
1067    if args.debug_system_prompt {
1068        eprintln!("=== --debug-system-prompt ===");
1069        let base_src = if args.system_prompt.is_some() {
1070            "--system-prompt"
1071        } else if discover_system_prompt_file(cwd).is_some() {
1072            "SYSTEM.md"
1073        } else {
1074            "default"
1075        };
1076        eprintln!("[base source: {base_src}]");
1077        eprintln!("--- base ---\n{base_prompt}");
1078        if let Some(append) = append_join.as_deref() {
1079            eprintln!("--- append ---\n{append}");
1080        } else {
1081            eprintln!("--- append: (none) ---");
1082        }
1083        if context_block.is_empty() {
1084            eprintln!("--- context: (none) ---");
1085        } else {
1086            eprintln!("--- context ---{context_block}");
1087        }
1088        let visible_skills = skills
1089            .iter()
1090            .filter(|s| s.disable_model_invocation != Some(true))
1091            .count();
1092        eprintln!(
1093            "--- skills: {} loaded ({} model-visible, {} hidden) ---",
1094            skills.len(),
1095            visible_skills,
1096            skills.len() - visible_skills
1097        );
1098        for s in &skills {
1099            let hidden = if s.disable_model_invocation == Some(true) {
1100                " [hidden]"
1101            } else {
1102                ""
1103            };
1104            eprintln!("    {}{hidden} — {}", s.name, s.description);
1105        }
1106        eprintln!("--- prompt templates: {} ---", prompt_templates.len());
1107        for t in &prompt_templates {
1108            eprintln!("    /{}", t.name);
1109        }
1110        // B5b: surface plugin-contributed discovery paths so a smoke can confirm
1111        // the resources_discover round-trip fed the loaders; package themes are
1112        // selected by the TUI rather than injected into the harness prompt.
1113        eprintln!(
1114            "--- discovered via resources_discover: {} skill(s), {} prompt(s), {} theme(s) ---",
1115            discovered.skill_paths.len(),
1116            discovered.prompt_paths.len(),
1117            discovered.theme_paths.len(),
1118        );
1119        for p in &discovered.skill_paths {
1120            eprintln!("    skill: {p}");
1121        }
1122        for p in &discovered.prompt_paths {
1123            eprintln!("    prompt: {p}");
1124        }
1125        eprintln!(
1126            "--- final composed base+append+context (skills listing added by harness) ---\n{system_prompt}"
1127        );
1128        eprintln!("=== end --debug-system-prompt ===");
1129    }
1130
1131    // ---- Options ----
1132    // Install a BroadcastEmitter so the caller (the interactive TUI) can drain
1133    // AgentEvents live as a run unfolds. The corresponding broadcast::Receiver
1134    // is returned alongside the harness; non-interactive modes simply drop it.
1135    let (broadcast, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
1136    let broadcast_emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(broadcast);
1137    // The broadcast half stays live for the whole session (the TUI's drain task
1138    // holds the receiver); reload re-wraps it in a fresh `TeeEmitter`, so keep
1139    // a clone for the `ReloadContext` before the tee match consumes the original.
1140    let broadcast_for_context: Arc<dyn rpi_agent::AgentEmitter> = Arc::clone(&broadcast_emitter);
1141
1142    // ---- Extensions emitter (Part B3a) ----
1143    // If extensions loaded + registered any `on()` handlers, wrap the
1144    // broadcast emitter in a `TeeEmitter` so every `AgentEvent` flows to BOTH
1145    // the TUI (via the broadcast receiver above) AND the plugin handlers (via
1146    // the `ExtensionEmitter`, which translates each `AgentEvent` →
1147    // `StablePluginEvent` and fans out to the handlers registered for its tag).
1148    // With no extensions the tee degrades to the bare broadcast emitter (a
1149    // one-child passthrough), so the TUI path is unchanged.
1150    let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
1151        Some(snapshot) => {
1152            let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
1153            Arc::new(TeeEmitter::new(vec![broadcast_emitter, Arc::new(ext)]))
1154        }
1155        None => broadcast_emitter,
1156    };
1157
1158    // Saved user defaults. Best-effort: a missing or malformed file must not stop
1159    // a session from starting, and every accessor falls back to the harness
1160    // default when its key is absent.
1161    let saved_settings = crate::settings::load_settings().unwrap_or_default();
1162
1163    // `httpProxy` applies to rpi's own HTTP clients. Setting the environment is how
1164    // they are configured (`pi-ai/src/http.rs`), and upstream does the same thing
1165    // with this key — an explicit environment variable still wins, so a shell-level
1166    // proxy override is not silently replaced by the setting.
1167    if let Some(proxy) = &saved_settings.http_proxy {
1168        for key in ["HTTP_PROXY", "HTTPS_PROXY"] {
1169            if std::env::var_os(key).is_none() {
1170                std::env::set_var(key, proxy);
1171            }
1172        }
1173    }
1174
1175    // Share the live session cell with both /reload and the approval hook.
1176    // The hook is installed only when the current snapshot has a tool-call
1177    // handler; a later reload rebuilds the hook state together with the
1178    // extension session.
1179    let extension_session_cell = Arc::new(Mutex::new(extension_session.clone()));
1180    // Only install the approval hook when a plugin actually registered a
1181    // tool-call handler. An extension session may contain flags/resources but
1182    // no handler; dispatching through that empty session needlessly crosses
1183    // the plugin bridge and can leave a headless RPC tool call waiting.
1184    let before_tool_call = extension_session
1185        .snapshot()
1186        .filter(|snapshot| {
1187            !snapshot
1188                .handlers_for(rpi_plugin_sdk::EventTag::ToolCall)
1189                .is_empty()
1190        })
1191        .map(|_| {
1192            extension_tool_call_hook(
1193                Arc::clone(&extension_session_cell),
1194                ui_dialog_mailbox.clone(),
1195            )
1196        });
1197
1198    let options = AgentHarnessOptions {
1199        model: resolved.model.clone(),
1200        thinking_level: resolved.thinking_level,
1201        active_tool_names: active.clone(),
1202        tools,
1203        system_prompt: Some(system_prompt),
1204        resources: AgentHarnessResources {
1205            context_files: context_paths,
1206            skills: if skills.is_empty() {
1207                None
1208            } else {
1209                Some(skills)
1210            },
1211            prompt_templates: if prompt_templates.is_empty() {
1212                None
1213            } else {
1214                Some(prompt_templates)
1215            },
1216        },
1217        // A restored session (--continue/--resume/--session) already has
1218        // records — let the harness load it and keep appending.
1219        allow_existing_session: matches!(
1220            selection,
1221            SessionSelection::Latest { .. }
1222                | SessionSelection::ById { .. }
1223                | SessionSelection::ByExactId { .. }
1224                | SessionSelection::Fork { .. }
1225        ),
1226        stream_options: AgentHarnessStreamOptions {
1227            timeout: args.timeout,
1228            stream_idle_timeout: args.stream_idle_timeout.or_else(|| {
1229                saved_settings
1230                    .stream_idle_timeout
1231                    .map(std::time::Duration::from_secs)
1232            }),
1233            thinking_budgets: saved_settings.thinking_budgets,
1234            ..Default::default()
1235        },
1236        // Settings-backed harness options: upstream lets users tune retry,
1237        // compaction and queue drain modes from settings.json. Global only,
1238        // matching how `defaultTools` and the other settings-backed options in
1239        // this file are read.
1240        retry: saved_settings.retry_policy(),
1241        compaction: saved_settings.compaction_settings(),
1242        steering_mode: saved_settings.steering_mode(),
1243        follow_up_mode: saved_settings.follow_up_mode(),
1244        tool_execution: HarnessToolExecution::default(),
1245        drive: DrivingMode::default(),
1246        session,
1247        // B5c: inject the resolved gateway provider PLUS one `Arc<dyn Provider>`
1248        // per registered extension provider (`PluggableProvider` wraps a plugin's
1249        // sync `ProviderRequestFn`). The harness's `build_stream_fn` resolves a
1250        // provider lazily per call by `models.iter().find(|p| p.id() == model.provider)`,
1251        // so a catalog model whose `provider` matches an extension provider's id
1252        // routes to it. Extension providers land AFTER the gateway so the gateway
1253        // stays first-match for its own ids (first-wins on a `.find`).
1254        models: build_models_with_extensions(resolved, &extension_session, runtime.clone()),
1255        to_provider_messages: None,
1256        entry_projectors: Default::default(),
1257        agent_emitter: Some(emitter),
1258        // B3b loop hooks. `before_tool_call` dispatches the pi `on("tool_call")`
1259        // extension event and honors a veto (a permission gate can block a tool
1260        // before it runs). `after_tool_call`/`transform_context` remain `None`
1261        // (their host-side adapters are not wired yet).
1262        before_tool_call,
1263        after_tool_call: None,
1264        transform_context: None,
1265        entry_transforms: Vec::new(),
1266        // Extension provider hooks (B4): plugins subscribing to the
1267        // BeforeProviderRequest / BeforeProviderHeaders / AfterProviderResponse
1268        // events observe every provider call (observer semantics — the handler
1269        // ABI has no patch channel in v1). A session without provider-hook
1270        // subscribers runs hook-free.
1271        provider_hooks: rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
1272            .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
1273    };
1274
1275    let harness = AgentHarness::create(options)
1276        .await
1277        .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1278
1279    // Retain read leases even for resource-only/--no-extensions hosts.
1280    let harness_leases = resource_leases.clone();
1281    harness
1282        .events()
1283        .on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |_| {
1284            let _ = &harness_leases;
1285        });
1286    install_extension_settlement_bridge(harness.events(), extension_session_cell.clone());
1287    if let Some(selected) = &resource_selection {
1288        harness
1289            .seal_resources(active)
1290            .map_err(|e| BuildError::HarnessCreate(e.to_string()))?;
1291        crate::resource_selection::attach(
1292            &harness,
1293            serde_json::json!({
1294                "version": 1,
1295                "hostId": uuid::Uuid::new_v4().to_string(),
1296                "host": {"name": crate::APP_NAME, "version": crate::VERSION, "pid": std::process::id()},
1297                "selection": selected.selection,
1298                "package": selected.package,
1299                "selectedSkills": selected.skills,
1300                "skillDiagnostics": [],
1301                "loadedExtensions": extension_session.loaded_paths().iter().map(|p| serde_json::json!({"identity": p.file_stem().and_then(|s| s.to_str()), "path": p})).collect::<Vec<_>>(),
1302                "instructions": selected.provenance(),
1303                "baseSystemPrompt": "native-default",
1304                "projectContext": "disabled",
1305                "resourceDiscovery": "disabled",
1306                "changes": "restart-required"
1307            }),
1308        );
1309        let resource_observation = StartupSpan::start(Phase::ResourceObservation);
1310        crate::resource_selection::get_resource_state(&harness)
1311            .await
1312            .map_err(BuildError::HarnessCreate)?;
1313        resource_observation.finish();
1314        if selected.transition.is_some() {
1315            // Re-read persisted facts at commit, not merely the startup mirror.
1316            // The canonical OS writer claim acquired before registration is
1317            // retained by the host for the entire session lifetime.
1318            let meta = resolve_restore_target(&selection, &cwd_str)
1319                .await?
1320                .ok_or_else(|| {
1321                    BuildError::SessionOpen("transition session disappeared before commit".into())
1322                })?;
1323            let path = std::fs::canonicalize(&meta.path)
1324                .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1325            let lock_path = PathBuf::from(format!("{}.lock", path.display()));
1326            if !SESSION_LOCK_PATHS
1327                .get_or_init(|| Mutex::new(HashSet::new()))
1328                .lock()
1329                .unwrap()
1330                .contains(&lock_path)
1331            {
1332                return Err(BuildError::SessionOpen(
1333                    "resource transition lost canonical writer ownership".into(),
1334                ));
1335            }
1336            let facts =
1337                rpi_harness::session::jsonl::JsonlSessionStorage::read_resource_transition_facts(
1338                    Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf())),
1339                    &path.to_string_lossy(),
1340                    Arc::new(SystemClock),
1341                    Arc::new(DefaultIdGenerator::new()),
1342                )
1343                .await
1344                .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1345            crate::resource_selection::validate_session_binding(&facts.values, Some(selected))
1346                .map_err(BuildError::SessionOpen)?;
1347            let mirror = rpi_harness::session::SessionValues::load(harness.session())
1348                .await
1349                .map_err(|e| BuildError::SessionOpen(e.to_string()))?;
1350            if crate::resource_selection::binding_state(&mirror)
1351                != crate::resource_selection::binding_state(&facts.values)
1352            {
1353                return Err(BuildError::SessionOpen(
1354                    "resource binding changed before commit".into(),
1355                ));
1356            }
1357        }
1358        let inventory_bind = StartupSpan::start(Phase::InventoryBind);
1359        crate::resource_selection::bind_inventory(&harness, selected)
1360            .await
1361            .map_err(BuildError::SessionOpen)?;
1362        inventory_bind.finish();
1363    }
1364    // Publish only after exact resources have been sealed and validated.
1365    crate::extensions_actions::HarnessActionHost::set_harness(
1366        &harness_cell,
1367        Arc::new(harness.clone()),
1368    );
1369    register_file_io_router(&harness, &file_io);
1370
1371    // ---- Durable operation recovery (runtime layer) ----
1372    // A previous process can die between `operation_started` and
1373    // `operation_finished`, leaving the lane with an orphaned operation. Sweep
1374    // every lane, surface what was found, and emit `run_start`/`run_end`
1375    // (aborted) pairs so a TUI that already attached sees the settle. This is
1376    // read-only: nothing is mutated here, so a failed sweep cannot corrupt the
1377    // log. A clean session (the common case) reports no findings.
1378    {
1379        let runtime = rpi_harness::runtime::SessionRuntime::new(harness.session().clone());
1380        match rpi_harness::runtime::recover_all_lanes(&runtime).await {
1381            Ok(reports) => {
1382                for (lane, report) in reports {
1383                    if report.is_clean() {
1384                        continue;
1385                    }
1386                    if report.is_corrupt() {
1387                        eprintln!(
1388                            "warning: session lane '{lane}' is corrupt ({} open operations); \
1389                             refusing to auto-repair — inspect the session file",
1390                            report.open_operations.len()
1391                        );
1392                        continue;
1393                    }
1394                    for decision in runtime.reconcile(&report) {
1395                        if let rpi_harness::runtime::RecoveryDecision::Abort { run_id } = decision {
1396                            if args.verbose {
1397                                eprintln!(
1398                                    "recovered interrupted run {run_id} on lane '{lane}' (aborted)"
1399                                );
1400                            }
1401                        }
1402                    }
1403                    runtime.emit_recovery_events(harness.events(), &report);
1404                }
1405            }
1406            Err(e) => {
1407                if args.verbose {
1408                    eprintln!("warning: session recovery sweep failed: {e}");
1409                }
1410            }
1411        }
1412    }
1413
1414    // ---- B5d: assemble the ReloadContext the TUI holds ----
1415    // Every field is cheap to clone (Arc / Vec / args Clone). The cells own the
1416    // live session + bridge so `/reload` can swap them; the harness itself is
1417    // NOT held here (the TUI already owns a `&AgentHarness` / clone at the call
1418    // site — passing it into `reload_extension_resources` keeps this structfree
1419    // of a harness back-reference so it can be `Clone` into the reload callback).
1420    // Retain the resolved root in the existing args snapshot so reload never
1421    // reselects it from mutable settings or environment variables.
1422    let mut reload_args = args.clone();
1423    reload_args.session_dir = Some(session_dir);
1424    let reload_context = ReloadContext {
1425        resource_leases,
1426        fresh_session_only,
1427        extension_session: extension_session_cell,
1428        action_bridge: Arc::new(Mutex::new(Some(Arc::clone(&action_bridge)))),
1429        tool_context,
1430        file_io,
1431        catalog: Arc::new(std::sync::RwLock::new(catalog)),
1432        providers: Arc::new(std::sync::RwLock::new(resolved.providers.clone())),
1433        runtime: runtime.clone(),
1434        cwd: cwd.to_path_buf(),
1435        project_trusted,
1436        discovered_theme_paths: Arc::new(std::sync::Mutex::new(discovered.theme_paths.clone())),
1437        args: reload_args,
1438        resolved_model: resolved.model.clone(),
1439        broadcast: broadcast_for_context,
1440        mailbox: reload_mailbox,
1441        ui_dialog: ui_dialog_mailbox,
1442        ext_status: ext_status_mailbox,
1443        editor_text: editor_text_mailbox,
1444        dev_extension: None,
1445        startup_notices: Vec::new(),
1446    };
1447
1448    Ok((harness, event_rx, reload_context))
1449}
1450
1451/// Project authoritative harness termination, not per-attempt AgentEnd, to
1452/// plugins. The bus owns this listener; its session cell follows reloads and
1453/// retains callback libraries, while the action host binds the harness weakly.
1454fn install_extension_settlement_bridge(
1455    events: &rpi_harness::events::HarnessEventBus,
1456    sessions: ExtensionSessionCell,
1457) {
1458    events.on_lifetime::<rpi_harness::events::RunEndEvent, _>(move |event| {
1459        if event.lane != "main" {
1460            return;
1461        }
1462        let session = sessions.lock().unwrap().clone();
1463        if let Some(snapshot) = session.snapshot_arc() {
1464            rpi_extensions::dispatch_empty_event(&snapshot, rpi_plugin_sdk::EventTag::AgentSettled);
1465        }
1466    });
1467}
1468
1469/// Resolve the current plugin session for each call rather than keeping a
1470/// startup snapshot. A clone retains its libraries while approval is pending.
1471fn extension_tool_call_hook(
1472    sessions: ExtensionSessionCell,
1473    ui_dialog: rpi_extensions::UiDialogMailbox,
1474) -> rpi_agent::BeforeToolCall {
1475    Arc::new(move |ctx: rpi_agent::BeforeToolCallContext<'_>, signal| {
1476        let session = sessions
1477            .lock()
1478            .map(|session| session.clone())
1479            .map_err(|_| ());
1480        let ui_dialog = ui_dialog.clone();
1481        let tool_call_id = ctx.tool_call.id.clone();
1482        let tool_name = ctx.tool_call.name.clone();
1483        let params = ctx.args.clone();
1484        Box::pin(async move {
1485            let reason = match session {
1486                Ok(session) => {
1487                    rpi_extensions::dispatch_tool_call_event(
1488                        &session,
1489                        &tool_call_id,
1490                        &tool_name,
1491                        &params,
1492                        &signal,
1493                        &ui_dialog,
1494                    )
1495                    .await
1496                }
1497                Err(_) => Some("extension approval session is unavailable".to_string()),
1498            };
1499            reason.map(|reason| rpi_agent::BeforeToolCallResult {
1500                block: true,
1501                reason: Some(reason),
1502                terminate: false,
1503                args: None,
1504            })
1505        })
1506    })
1507}
1508
1509// ===========================================================================
1510// B5d — `/reload`: re-run extension + resource discovery into a LIVE harness
1511// ===========================================================================
1512//
1513// `/reload` (interactive TUI command, or a plugin's `runtime_action(Reload)`)
1514// re-runs everything `build` did around resources/extensions WITHOUT rebuilding
1515// the `AgentHarness` itself (rebuilding would tear down the session/lane/event
1516// wiring + the broadcast drain task the TUI owns). Instead it:
1517//
1518//  1. Builds a fresh `ExtensionSession` (re-load the cdylibs) over the same
1519//     dir set, with a FRESH `ActionBridge` (the old one is `invalidate`d so
1520//     in-flight plugin→host calls on the old bridge fail fast).
1521//  2. Fans `resources_discover(_, "reload")` over the fresh snapshot.
1522//  3. Re-runs the Part-A loaders (skills/prompts/context/SYSTEM.md/
1523//     APPEND_SYSTEM.md) with the discovered paths merged in — same precedence
1524//     + `--no-*` gates as startup.
1525//  4. Rebuilds the harness's live state via the B5d setters
1526//     (`set_system_prompt`/`set_resources`/`set_agent_emitter`/`set_models`/
1527//     `set_provider_hooks`/`set_tools`) so the NEXT run observes the reloaded
1528//     config (in-flight runs finish on the old `ConfigSnapshot`).
1529//  5. Swaps the cells (`ExtensionSession`, `ActionBridge`, harness action
1530//     host's harness cell stays — the harness is the same object) and drops
1531//     the old session + bridge (their keepalives unmap the old cdylibs; the
1532//     new session's keepalive holds the fresh mappings).
1533//
1534// The reload is a `rpi-cli` concern (NOT a harness op): `rpi-extensions`
1535// carries only the `ActionBridge` staleness flag + a `ReloadMailbox` `()` signal
1536// (no pi-cli `TuiMessage` type — leaf DAG preserved). The TUI owns the mailbox
1537// receiver + the actual reload routine; a plugin's
1538// `runtime_action(Reload)` signals the mailbox and returns `Ok(null)`
1539// immediately so the calling plugin's cdylib is NOT unmapped while its
1540// `runtime_action` frame is still on the stack (the self-unmapping race a
1541// synchronous plugin-initiated reload would have).
1542//
1543// `reload_extension_resources` is the shared routine both `/reload` (TUI) and
1544// a plugin's `runtime_action(Reload)` (via the mailbox) drive. It is `pub` so
1545// the TUI's main-loop handler + the mailbox-driven path call the same code.
1546
1547/// The cell that holds the live `ExtensionSession` across a `/reload`. Cloned
1548/// into every site that needs the current session (the TUI, the reload
1549/// callback). On reload the old session is `replace`d out (its `active` flag
1550/// flipped) and the fresh one stored atomically. In-flight callbacks retain
1551/// the old keepalive until they return; callers never observe an empty
1552/// placeholder during the swap.
1553pub type ExtensionSessionCell = Arc<Mutex<ExtensionSession>>;
1554
1555/// The cell that holds the live `ActionBridge` across a `/reload`. A plugin
1556/// stores the bridge's raw `user_data` pointer during `register`; on reload the
1557/// old bridge is `invalidate`d (in-flight calls fail fast) and the fresh one
1558/// `store`d. The fresh session's plugins are handed the fresh bridge pointer.
1559pub type ActionBridgeCell = Arc<Mutex<Option<Arc<rpi_extensions::ActionBridge>>>>;
1560
1561/// Everything `/reload` needs to rebuild extension + resource state into a live
1562/// harness. Built once in [`build`] (alongside the harness) and held by the TUI
1563/// (cloned into the reload callback the bridge carries + the `/reload` command
1564/// handler). The harness itself is NOT held here — the TUI already owns a
1565/// `&AgentHarness` / a clone; passing it at the call site keeps this struct
1566/// free of a harness back-reference (so it can be `Clone` and moved into the
1567/// reload callback without borrowing the harness).
1568#[derive(Clone)]
1569pub struct ReloadContext {
1570    resource_leases: Arc<Vec<crate::resource_management::Lease>>,
1571    /// Startup routing capability, retained independently of mutable args/settings.
1572    fresh_session_only: bool,
1573    /// One neutral router for all built-ins, including those reconstructed on reload.
1574    pub file_io: Arc<rpi_tools::file_io::FileIORouter>,
1575    /// The live extension-session cell (swapped on reload).
1576    pub extension_session: ExtensionSessionCell,
1577    /// The session's plugin-tool context (`cwd` + session id), reused — not
1578    /// rebuilt — on reload so the rebuilt adapters keep serving the SAME
1579    /// session. A reload that made a fresh context would leave every plugin
1580    /// tool without a session id until the next launch.
1581    pub tool_context: rpi_extensions::ToolCallContext,
1582    /// The live action-bridge cell (swapped + old invalidated on reload).
1583    pub action_bridge: ActionBridgeCell,
1584    /// The extension status registry the TUI renders in its footer (`SetStatus`).
1585    /// Lives in the context (not the cell) so it survives a bridge swap.
1586    pub ext_status: rpi_extensions::ExtensionStatusMailbox,
1587    /// The extension editor-text queue the TUI drains into the prompt editor
1588    /// (`SetEditorText`, runtime action 19). Lives in the context (not the cell)
1589    /// so it survives a bridge swap — exactly like `ext_status`. A voice plugin
1590    /// writes a transcription here as a *draft*; the TUI applies it, starts the
1591    /// optional auto-send countdown, and submits through the normal path so the
1592    /// user bubble renders as usual.
1593    pub editor_text: rpi_extensions::EditorTextMailbox,
1594    /// Shared authenticated model catalog for runtime actions and the TUI.
1595    /// Provider-package reloads replace it together with the runtime bindings.
1596    pub catalog: Arc<std::sync::RwLock<Vec<rpi_ai::Model>>>,
1597    /// Current configured provider runtimes. Replaced after package reload so
1598    /// subsequent reloads cannot recover stale plugin snapshots.
1599    pub providers: Arc<std::sync::RwLock<Vec<Arc<dyn Provider>>>>,
1600    /// The ambient runtime handle (captured in `build`) — `PluggableProvider`
1601    /// + the fresh `ActionBridge` need a captured `Handle` to spawn from any
1602    /// thread.
1603    pub runtime: tokio::runtime::Handle,
1604    /// The cwd (for static resource-dir resolution + context-file walk).
1605    pub cwd: PathBuf,
1606    /// The project-trust decision captured before provider and session setup.
1607    /// Startup consumers reuse this value so extension code cannot change the
1608    /// effective policy by mutating the process cwd while it is loading.
1609    pub project_trusted: bool,
1610    /// Theme file paths contributed by extensions via `resources_discover`,
1611    /// shared so the TUI theme registry can merge them. Filled at startup,
1612    /// replaced on every `/reload` (paths are unattributed — the v1 divergence
1613    /// documented in `rpi-extensions::resources`).
1614    pub discovered_theme_paths: Arc<std::sync::Mutex<Vec<String>>>,
1615    /// The parsed args (cloned) — `--no-*`/`--tools`/`--exclude-tools`/
1616    /// `--extensions-dir`/`--no-extensions`/`--system-prompt`/etc all apply on
1617    /// reload exactly as at startup (a reload re-reads the same flags; it does
1618    /// not pick up argv changes mid-session, which is the right contract — pi's
1619    /// `/reload` re-runs discovery with the same config). `session_dir` holds
1620    /// the resolved startup root, including saved settings/environment defaults.
1621    pub args: Args,
1622    /// The resolved model + thinking level (the harness's active model stays
1623    /// unless `set_model` changed it; reload does not touch the model).
1624    pub resolved_model: rpi_ai::Model,
1625    /// The broadcast emitter the harness was built with. Reload rebuilds the
1626    /// `TeeEmitter` over the fresh `ExtensionEmitter` (the old tee's extension
1627    /// child is dropped, unsubscribing from the old registry). The broadcast
1628    /// half stays live the whole session (the TUI's drain task holds the
1629    /// receiver), so we keep a handle to re-wrap.
1630    pub broadcast: Arc<dyn rpi_agent::AgentEmitter>,
1631    /// The session-long reload mailbox (B5d). Build creates one, installs it on
1632    /// the initial `ActionBridge` via [`reload_callback_from_mailbox`], and hands
1633    /// a clone to the TUI. The TUI installs its `TuiMessage` sender so a plugin's
1634    /// `runtime_action(Reload)` signals the main loop — the reload routine reuses
1635    /// THIS mailbox (not a fresh default) when building the fresh bridge, so the
1636    /// bridge always carries the mailbox the TUI installed across reloads.
1637    pub mailbox: rpi_extensions::ReloadMailbox,
1638    /// Session-long UI-dialog mailbox (runtime action 17). The initial bridge
1639    /// carries it; the TUI attaches itself and polls `take_pending` from its key
1640    /// loop, and `/reload` reuses THIS mailbox when it builds the fresh bridge.
1641    /// A detached mailbox makes `ask_user` fail with "requires an interactive
1642    /// UI" (headless contract).
1643    pub ui_dialog: rpi_extensions::UiDialogMailbox,
1644    /// Active `rpi dev` extension builder. `/reload` rebuilds it before
1645    /// swapping plugin sessions; its watcher signals `mailbox` after a
1646    /// successful background build.
1647    pub dev_extension: Option<Arc<crate::dev_extension::DevExtension>>,
1648    /// One-line notices the host should show at startup (a session directory
1649    /// that fell back, an untrusted adopted directory). Filled by the CLI after
1650    /// the build and rendered as transcript notes in the TUI; headless modes
1651    /// print them to stderr. Kept as data — never `eprintln!`d from here, which
1652    /// would corrupt the alternate screen (issue #115).
1653    pub startup_notices: Vec<String>,
1654}
1655
1656impl ReloadContext {
1657    pub(crate) fn session_dir(&self) -> PathBuf {
1658        // The fallback also supports contexts constructed by embedding hosts.
1659        self.args
1660            .session_dir
1661            .clone()
1662            .unwrap_or_else(|| configured_session_dir(&self.args, &self.cwd))
1663    }
1664}
1665
1666/// Dispatch a no-payload lifecycle event (e.g. `SessionStart`, `SessionShutdown`,
1667/// the rpi-specific `BeforeTuiStart`) to every extension handler subscribed to
1668/// that tag, with **veto** + **timeout** semantics (P1).
1669///
1670/// Resolves the **current** extension session from the reload cell so a
1671/// `/reload`-swapped session is honored. Each handler runs on a blocking thread
1672/// with a per-event timeout ([`rpi_extensions::lifecycle_timeout_for`]); a
1673/// handler returning [`rpi_plugin_sdk::EVENT_HANDLER_ABORT`] vetoes the phase
1674/// and this returns `Some(reason)`. No-op (`None`) when no plugins loaded, no
1675/// handler subscribes, or the registry is stale.
1676///
1677/// The caller decides what a veto means: a `BeforeTuiStart` veto aborts startup
1678/// (never enter the TUI); `SessionStart`/`SessionShutdown` vetoes are advisory
1679/// (the session is already up or already closing).
1680pub async fn dispatch_session_event_async(
1681    reload_context: &ReloadContext,
1682    tag: rpi_plugin_sdk::EventTag,
1683) -> Option<String> {
1684    // Take the snapshot Arc + keepalive, drop the session lock, then dispatch —
1685    // we must not hold the session cell's mutex across the await (a handler
1686    // could re-enter a reload path that locks it). Holding the keepalive across
1687    // the await keeps the cdylibs (owning the handler fn pointers) mapped even
1688    // if a concurrent `/reload` swaps the session cell out.
1689    let (snapshot, keepalive) = {
1690        let session = reload_context.extension_session.lock().unwrap();
1691        (session.snapshot_arc(), session.keepalive())
1692    };
1693    match snapshot {
1694        Some(snapshot) => rpi_extensions::dispatch_lifecycle_event(&snapshot, tag, keepalive).await,
1695        None => None,
1696    }
1697}
1698
1699/// Join plugin teardown before unloading modules. A detached timed-out handler
1700/// could still execute code in the old DLL, so it cannot safely permit a swap.
1701async fn shutdown_extensions_for_reload(
1702    snapshot: Option<Arc<rpi_extensions::RegistrySnapshot>>,
1703    keepalive: Arc<rpi_extensions::PluginKeepalive>,
1704) -> Result<(), String> {
1705    if let Some(snapshot) = snapshot {
1706        tokio::task::spawn_blocking(move || {
1707            let _keepalive = keepalive;
1708            rpi_extensions::dispatch_empty_event(
1709                &snapshot,
1710                rpi_plugin_sdk::EventTag::SessionShutdown,
1711            );
1712        })
1713        .await
1714        .map_err(|error| error.to_string())?;
1715    }
1716    Ok(())
1717}
1718
1719/// The outcome of a reload: a human-readable status line for the transcript
1720/// (counts of what reloaded), and whether any load diagnostics appeared.
1721pub struct ReloadOutcome {
1722    /// One-line summary for the transcript note (e.g. "Reloaded 2 plugin(s),
1723    /// 5 skill(s), 1 prompt(s).").
1724    pub summary: String,
1725    /// True iff at least one extension load warning fired (ABI mismatch / skip).
1726    pub had_warnings: bool,
1727    /// Formatted diagnostic lines (package / skill / prompt-template warnings)
1728    /// that the caller should surface in its UI. Kept out of `eprintln!`
1729    /// because the TUI owns the terminal: direct stderr writes during a run
1730    /// would corrupt the alternate-screen input row.
1731    pub details: Vec<String>,
1732    /// True iff the reload itself failed hard (e.g. the extension build or
1733    /// settings reload errored) and the previous resources were kept. Soft
1734    /// diagnostics (skill shadowing, name validation) do not set this.
1735    pub had_errors: bool,
1736    /// The project-trust policy this reload actually resolved and applied.
1737    /// `None` when the reload failed hard and kept the previous resources, so
1738    /// consumers (e.g. theme discovery) should stay on the snapshot they
1739    /// already match instead of re-reading a stale startup bool.
1740    pub project_trusted: Option<bool>,
1741}
1742
1743struct PreparedReloadInputs {
1744    extension_dirs: Vec<PathBuf>,
1745    skill_base_dirs: Vec<PathBuf>,
1746    prompt_base_dirs: Vec<PathBuf>,
1747}
1748
1749/// Append the resource sources that are specific to a reload after the
1750/// conventional project/global directories. Keep this order aligned with the
1751/// initial build: explicit CLI paths must remain available after `/reload`,
1752/// while discovered and package resources retain their lower precedence.
1753fn append_reload_resource_paths(
1754    mut paths: Vec<PathBuf>,
1755    explicit: &[PathBuf],
1756    discovered: &[String],
1757    js_paths: &[PathBuf],
1758    package_paths: &[PathBuf],
1759) -> Vec<PathBuf> {
1760    paths.extend(explicit.iter().cloned());
1761    paths.extend(discovered.iter().map(PathBuf::from));
1762    paths.extend(js_paths.iter().cloned());
1763    paths.extend(package_paths.iter().cloned());
1764    paths
1765}
1766
1767/// Re-run extension + resource discovery and push the rebuilt state into the
1768/// live `harness` via the B5d setters. The old `ExtensionSession` +
1769/// `ActionBridge` are invalidated + swapped in [`ReloadContext`]'s cells. This
1770/// is the single routine both `/reload` (TUI) and a plugin's
1771/// `runtime_action(Reload)` drive (the latter via the mailbox signal).
1772///
1773/// Returns a [`ReloadOutcome`] for the transcript. Best-effort: a failure in
1774/// one channel (e.g. a plugin that fails to reload) does not abort the others —
1775/// the reload completes with whatever loaded, mirroring pi's per-plugin
1776/// skip-on-error. A hard failure (e.g. the harness is closed) surfaces as an
1777/// error summary.
1778pub async fn reload_extension_resources(
1779    harness: &AgentHarness,
1780    ctx: &ReloadContext,
1781) -> ReloadOutcome {
1782    reload_extension_resources_inner(harness, ctx, || {}).await
1783}
1784
1785async fn reload_extension_resources_inner<F>(
1786    harness: &AgentHarness,
1787    ctx: &ReloadContext,
1788    after_prepare: F,
1789) -> ReloadOutcome
1790where
1791    F: FnOnce() + Send,
1792{
1793    if harness.has_exact_resources()
1794        || ctx.args.resource_config.is_some()
1795        || ctx.args.resource_transition.is_some()
1796    {
1797        return ReloadOutcome {
1798            summary:
1799                "Exact resource selection is restart-required; reload/dev hot apply unsupported"
1800                    .into(),
1801            had_warnings: true,
1802            details: Vec::new(),
1803            had_errors: true,
1804            project_trusted: None,
1805        };
1806    }
1807    // Resolve the arguments once for this reload. `rpi dev` may append its
1808    // freshly staged extension directory; every subsequent loader and policy
1809    // decision must observe that same effective set rather than falling back
1810    // to the pre-dev snapshot held in `ctx.args`.
1811    let mut effective_args = ctx.args.clone();
1812    if let Some(dev) = &ctx.dev_extension {
1813        if let Err(error) = dev
1814            .rebuild()
1815            .and_then(|_| dev.apply_to_args(&mut effective_args))
1816        {
1817            return ReloadOutcome {
1818                summary: format!(
1819                    "Extension build failed for {}: {error}. Keeping the currently loaded version.",
1820                    dev.package_name()
1821                ),
1822                had_warnings: true,
1823                details: Vec::new(),
1824                had_errors: true,
1825                project_trusted: None,
1826            };
1827        }
1828    }
1829    let cwd_str = ctx.cwd.to_string_lossy().to_string();
1830    let project_trusted = resolve_project_trust(&effective_args, &ctx.cwd);
1831    let prepared = match prepare_reload_inputs(&effective_args, &ctx.cwd, project_trusted) {
1832        Ok(prepared) => prepared,
1833        Err(error) => {
1834            return ReloadOutcome {
1835                summary: format!(
1836                    "Settings reload failed: {error}. Keeping the currently loaded resources."
1837                ),
1838                had_warnings: true,
1839                details: Vec::new(),
1840                had_errors: true,
1841                project_trusted: None,
1842            };
1843        }
1844    };
1845    after_prepare();
1846    let PreparedReloadInputs {
1847        extension_dirs,
1848        skill_base_dirs,
1849        prompt_base_dirs,
1850    } = prepared;
1851    let mut warnings = false;
1852
1853    // Shutdown callbacks must finish before a replacement can unload old DLLs.
1854    // Ordinary lifecycle dispatch can time out or stop on a veto; unload cleanup
1855    // instead joins every handler while keeping its module mapped.
1856    let (shutdown_snapshot, shutdown_keepalive) = {
1857        let session = ctx.extension_session.lock().unwrap();
1858        (session.snapshot_arc(), session.keepalive())
1859    };
1860    if let Err(reason) = shutdown_extensions_for_reload(shutdown_snapshot, shutdown_keepalive).await
1861    {
1862        return ReloadOutcome {
1863            summary: "Reload failed during extension shutdown".into(),
1864            had_warnings: true,
1865            details: vec![reason],
1866            had_errors: true,
1867            project_trusted: None,
1868        };
1869    }
1870
1871    // ---- 1. Build a fresh ActionBridge + ExtensionSession ----
1872    // The fresh bridge carries the SAME `HarnessActionHost` (the host's harness
1873    // cell already points at this harness; the host impl is reusable across
1874    // reloads — only the bridge's staleness flag + reload callback differ). We
1875    // re-use the host by reading it off the OLD bridge (it's the same
1876    // `Arc<dyn RuntimeActionHost>`).
1877    let old_bridge = ctx.action_bridge.lock().unwrap().clone();
1878    let host: Arc<dyn rpi_extensions::RuntimeActionHost> = match &old_bridge {
1879        Some(b) => b.clone_host(),
1880        None => {
1881            // No prior bridge (no extensions ever loaded). Build a fresh host so
1882            // a reload that newly discovers plugins can still drive actions.
1883            let (action_host, _cell) = crate::extensions_actions::HarnessActionHost::new_empty(
1884                ctx.catalog
1885                    .read()
1886                    .unwrap_or_else(|error| error.into_inner())
1887                    .clone(),
1888                ctx.cwd.clone(),
1889                ctx.runtime.clone(),
1890                ctx.args.unknown_flags.clone(),
1891                ctx.tool_context.clone(),
1892            );
1893            crate::extensions_actions::HarnessActionHost::set_harness(
1894                &_cell,
1895                Arc::new(harness.clone()),
1896            );
1897            Arc::new(
1898                action_host
1899                    .with_session_dir(ctx.session_dir())
1900                    .with_fresh_session_only(ctx.fresh_session_only),
1901            )
1902        }
1903    };
1904
1905    let reload_cb = rpi_extensions::reload_callback_from_mailbox(ctx.mailbox.clone());
1906    let publish_host = Arc::clone(&host);
1907    let fresh_bridge = rpi_extensions::ActionBridge::with_reload_and_ui(
1908        ctx.runtime.clone(),
1909        host,
1910        reload_cb,
1911        ctx.ui_dialog.clone(),
1912        ctx.ext_status.clone(),
1913        ctx.editor_text.clone(),
1914    );
1915
1916    let extension_session = if effective_args.no_extensions {
1917        rpi_extensions::ExtensionSession::none()
1918    } else {
1919        load_extensions_from_dirs(
1920            &effective_args,
1921            &extension_dirs,
1922            Some(Arc::clone(&fresh_bridge)),
1923            Some(ctx.resource_leases.clone()),
1924        )
1925    };
1926    if let Err(error) =
1927        crate::native_store::validate_stdio(ctx.fresh_session_only, extension_session.owns_stdio())
1928    {
1929        return ReloadOutcome {
1930            summary: "Reload refused by experimental native session policy".into(),
1931            had_warnings: true,
1932            details: vec![error],
1933            had_errors: true,
1934            project_trusted: None,
1935        };
1936    }
1937    if extension_session.is_empty() && !effective_args.no_extensions {
1938        // The fresh session may be empty if no cdylibs are present — not a
1939        // warning per se, but note it.
1940    }
1941    if effective_args.verbose {
1942        if let Some(s) = extension_session.summary() {
1943            eprintln!("reload: {s}");
1944        }
1945        report_deferred_renderers(&extension_session);
1946    }
1947
1948    let has_package_providers = |session: &ExtensionSession| {
1949        session.snapshot().is_some_and(|snapshot| {
1950            snapshot
1951                .providers()
1952                .iter()
1953                .any(|provider| snapshot.provider_manifest(&provider.provider_id).is_some())
1954        })
1955    };
1956    let packaged = has_package_providers(&extension_session)
1957        || has_package_providers(&ctx.extension_session.lock().unwrap());
1958    let refreshed = if packaged {
1959        let mut args = effective_args.clone();
1960        if let Ok(current) = harness.lane("main").get_model().await {
1961            let was_package = ctx
1962                .extension_session
1963                .lock()
1964                .unwrap()
1965                .snapshot()
1966                .is_some_and(|snapshot| snapshot.provider_manifest(&current.provider).is_some());
1967            let has_replacement = extension_session.snapshot().is_some_and(|snapshot| {
1968                snapshot
1969                    .providers()
1970                    .iter()
1971                    .any(|provider| provider.provider_id == current.provider)
1972            });
1973            if was_package && !has_replacement {
1974                return ReloadOutcome {
1975                    summary: "Reload failed while resolving provider packages".into(),
1976                    had_warnings: true,
1977                    details: vec![format!(
1978                        "active provider package '{}' is no longer loaded",
1979                        current.provider
1980                    )],
1981                    had_errors: true,
1982                    project_trusted: None,
1983                };
1984            }
1985            args.provider = Some(current.provider.clone());
1986            args.model = Some(current.id);
1987            if current.provider != ctx.resolved_model.provider {
1988                args.api_key = None;
1989                args.base_url = None;
1990            }
1991        }
1992        match crate::provider::resolve_with_extensions(
1993            &args,
1994            &ctx.cwd,
1995            ctx.project_trusted,
1996            &extension_session,
1997        ) {
1998            Ok(resolved) => Some(resolved),
1999            Err(error) => {
2000                return ReloadOutcome {
2001                    summary: "Reload failed while resolving provider packages".into(),
2002                    had_warnings: true,
2003                    details: vec![error.to_string()],
2004                    had_errors: true,
2005                    project_trusted: None,
2006                }
2007            }
2008        }
2009    } else {
2010        None
2011    };
2012
2013    // ---- 2. Invalidate the old session + bridge BEFORE the swap ----
2014    // The old registry's `active` flag flips false so any in-flight
2015    // `emit_resources_discover`/event dispatch on the old snapshot no-ops; the
2016    // old bridge's flag flips false so in-flight `runtime_action` calls parked
2017    // on the old `user_data` hit the staleness guard. Invalidate under the
2018    // session lock before exposing the replacement to approval callbacks.
2019    //
2020    // Swap directly to the fresh session under the same lock that approvals
2021    // use. A temporary empty session would let a concurrent call bypass its gate.
2022    // In-flight approvals retain their old session until their callback exits;
2023    // its staleness flag prevents them from authorizing after this swap.
2024    let old_session = {
2025        let mut session_guard = ctx.extension_session.lock().unwrap();
2026        let old_session = std::mem::replace(&mut *session_guard, extension_session.clone());
2027        if let Some(old_snap) = old_session.snapshot_arc() {
2028            // `invalidate` is on the registry, but the snapshot shares the flag —
2029            // flipping the snapshot's flag invalidates the registry too (same Arc).
2030            // `RegistrySnapshot` exposes `active_flag()` for this.
2031            old_snap.active_flag().store(false, Ordering::SeqCst);
2032        }
2033        old_session
2034    };
2035    if let Some(old_b) = old_bridge {
2036        old_b.invalidate();
2037    }
2038    // Shutdown callbacks see stale host actions and run outside session locks.
2039    old_session.begin_shutdown();
2040
2041    // The session was swapped atomically above; expose its matching bridge.
2042    *ctx.action_bridge.lock().unwrap() = Some(Arc::clone(&fresh_bridge));
2043    // Republish the registry so `ListCommands` reflects the commands the reload
2044    // just discovered — without this a remote UI keeps serving the startup set
2045    // until the host process restarts. Via the trait method because all we hold
2046    // here is `Arc<dyn RuntimeActionHost>`.
2047    // Publish even when the reload found nothing: a nonempty -> empty reload
2048    // returns `ExtensionSession::none()`, and skipping it would leave the old
2049    // snapshot advertised.
2050    publish_host.publish_registry_opt(extension_session.snapshot_arc());
2051
2052    // ---- 3. resources_discover ("reload") over the fresh snapshot ----
2053    let discovered = extension_session
2054        .snapshot_arc()
2055        .map(|snap| rpi_extensions::emit_resources_discover(&cwd_str, "reload", &snap))
2056        .unwrap_or_default();
2057    // Publish the fresh theme paths so the TUI registry rebuild sees the same
2058    // set the skill/prompt loaders just consumed.
2059    if let Ok(mut guard) = ctx.discovered_theme_paths.lock() {
2060        *guard = discovered.theme_paths.clone();
2061    }
2062
2063    // ---- 4. Re-run the Part-A loaders (same precedence + --no-* gates) ----
2064    let env = Arc::new(rpi_tools::OsExecutionEnv::with_cwd(ctx.cwd.clone()));
2065    let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
2066
2067    let mut skills: Vec<rpi_harness::types::Skill> = Vec::new();
2068    let mut skill_diags: Vec<rpi_harness::skills::SkillDiagnostic> = Vec::new();
2069    let mut resource_diags: Vec<rpi_harness::diagnostics::ResourceDiagnostic> = Vec::new();
2070    if !effective_args.no_skills {
2071        // JS discovery is backed by the session-long lazy Node host. Until
2072        // that host is swapped as part of a future full JS reload, preserve
2073        // the paths it contributed at startup across `/reload`.
2074        let js_paths: &[PathBuf] = &[];
2075        let package_paths: &[PathBuf] = &[];
2076        let dirs = append_reload_resource_paths(
2077            skill_base_dirs,
2078            &effective_args.skill,
2079            &discovered.skill_paths,
2080            js_paths,
2081            package_paths,
2082        );
2083        let result = load_skills_with_precedence(&env_dyn, &dirs).await;
2084        skills = result.skills;
2085        skill_diags = result.diagnostics;
2086        resource_diags.extend(result.resource_diagnostics);
2087    }
2088
2089    let mut prompt_templates: Vec<rpi_harness::types::PromptTemplate> = Vec::new();
2090    let mut prompt_diags: Vec<rpi_harness::prompt_templates::PromptTemplateDiagnostic> = Vec::new();
2091    if !effective_args.no_prompt_templates {
2092        let js_paths: &[PathBuf] = &[];
2093        let package_paths: &[PathBuf] = &[];
2094        let dirs = append_reload_resource_paths(
2095            prompt_base_dirs,
2096            &effective_args.prompt_template,
2097            &discovered.prompt_paths,
2098            js_paths,
2099            package_paths,
2100        );
2101        let result = load_prompt_templates_with_precedence(&env_dyn, &dirs).await;
2102        prompt_templates = result.prompt_templates;
2103        prompt_diags = result.diagnostics;
2104        resource_diags.extend(result.resource_diagnostics);
2105    }
2106
2107    let context_files = if effective_args.no_context_files || !project_trusted {
2108        Vec::new()
2109    } else {
2110        let agent_dir = crate::config::agent_dir().ok();
2111        let agent_dir_path = agent_dir.unwrap_or_else(|| ctx.cwd.clone());
2112        load_project_context_files(&env_dyn, &ctx.cwd, &agent_dir_path).await
2113    };
2114    let context_block = format_project_context(&context_files);
2115
2116    let mut details: Vec<String> = Vec::new();
2117    if let Some(resolved) = &refreshed {
2118        warnings |= !resolved.compat_warnings.is_empty();
2119        details.extend(
2120            resolved
2121                .compat_warnings
2122                .iter()
2123                .map(|warning| format!("warning: models.json: {warning}")),
2124        );
2125    }
2126    if !skill_diags.is_empty() || !prompt_diags.is_empty() || !resource_diags.is_empty() {
2127        warnings = true;
2128        // Collect formatted diagnostics for the caller to surface in its UI
2129        // instead of writing to stderr directly: the TUI owns the terminal,
2130        // and a raw `eprintln!` during a run would scribble over the
2131        // alternate-screen input row.
2132        for d in &skill_diags {
2133            details.push(format!(
2134                "warning: skill {} ({}): {}",
2135                d.path,
2136                d.code.as_str(),
2137                d.message
2138            ));
2139        }
2140        for d in &prompt_diags {
2141            details.push(format!(
2142                "warning: prompt template {} ({}): {}",
2143                d.path,
2144                d.code.as_str(),
2145                d.message
2146            ));
2147        }
2148        // Structured collisions: winner (kept) · loser (shadowed).
2149        for d in &resource_diags {
2150            if let rpi_harness::diagnostics::ResourceDiagnostic::Collision(c) = d {
2151                details.push(format!(
2152                    "warning: {} name \"{}\" collision: keeping {} (shadowed {})",
2153                    c.resource_type.as_str(),
2154                    c.name,
2155                    c.winner_path,
2156                    c.loser_path
2157                ));
2158            }
2159        }
2160    }
2161
2162    // ---- Re-compose the system prompt (same precedence as build) ----
2163    // Same working-state wording rule as `build`; a mid-session `/model` switch
2164    // re-composes the prompt on the next reload, which is the same staleness the
2165    // rest of this path already has (provider resolution is a startup concern).
2166    // Re-merge extension tools (a reloaded plugin may have added/removed a
2167    // tool). The built-in set is rebuilt from scratch + extension tools merged
2168    // on top, mirroring `build`. Built before the
2169    // prompt so the prompt lists exactly the active tools.
2170    let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
2171    let tool_ctx = rpi_tools::ExecutionToolContext::new(env_dyn.clone(), Some(mut_env))
2172        .with_file_io(ctx.file_io.clone());
2173    let mut tools = build_tools(&tool_ctx, &effective_args);
2174    merge_extension_tools(
2175        &mut tools,
2176        &extension_session,
2177        &effective_args,
2178        &ctx.tool_context,
2179    );
2180    let mut active = active_tool_names(&tools, &effective_args);
2181    active = filter_active_tool_names(active, &effective_args);
2182    let prompt_tools = active.clone();
2183    let prompt_metadata = extension_tool_prompt_metadata(&extension_session);
2184    let prior_reasoning_replayed = rpi_ai::model::prior_reasoning_is_replayed(&ctx.resolved_model);
2185    let default_prompt = || {
2186        default_system_prompt_with_tool_prompts(
2187            &cwd_str,
2188            prior_reasoning_replayed,
2189            &prompt_tools,
2190            &prompt_metadata,
2191        )
2192    };
2193    let mut context_paths = Vec::new();
2194    let base_prompt = match effective_args.system_prompt.as_deref() {
2195        Some(explicit) => explicit.to_string(),
2196        None if project_trusted => match discover_system_prompt_file(&ctx.cwd) {
2197            Some(path) => {
2198                read_prompt_source(&path, &mut context_paths).unwrap_or_else(default_prompt)
2199            }
2200            None => default_prompt(),
2201        },
2202        None => default_prompt(),
2203    };
2204    let mut append_texts: Vec<String> = Vec::new();
2205    for extra in &effective_args.append_system_prompt {
2206        let text = read_append_target(extra, &mut context_paths).unwrap_or_else(|| extra.clone());
2207        append_texts.push(text);
2208    }
2209    if effective_args.append_system_prompt.is_empty() {
2210        if let Some(path) = project_trusted
2211            .then(|| discover_append_system_prompt_file(&ctx.cwd))
2212            .flatten()
2213        {
2214            if let Some(text) = read_prompt_source(&path, &mut context_paths) {
2215                append_texts.push(text);
2216            }
2217        }
2218    }
2219    context_paths.extend(
2220        context_files
2221            .iter()
2222            .map(|file| file.path.to_string_lossy().into_owned()),
2223    );
2224    let append_join = if append_texts.is_empty() {
2225        None
2226    } else {
2227        Some(append_texts.join("\n\n"))
2228    };
2229    let system_prompt = compose_system_prompt(
2230        Some(&base_prompt),
2231        &[],
2232        if context_block.is_empty() {
2233            None
2234        } else {
2235            Some(&context_block)
2236        },
2237        append_join.as_deref(),
2238    );
2239
2240    // ---- Rebuild the emitter (TeeEmitter over fresh ExtensionEmitter) ----
2241    let emitter: Arc<dyn rpi_agent::AgentEmitter> = match extension_session.snapshot_arc() {
2242        Some(snapshot) => {
2243            let ext = ExtensionEmitter::new(snapshot, extension_session.keepalive());
2244            Arc::new(TeeEmitter::new(vec![ctx.broadcast.clone(), Arc::new(ext)]))
2245        }
2246        None => ctx.broadcast.clone(),
2247    };
2248
2249    // ---- 5. Push the rebuilt state into the live harness via the B5d setters ----
2250    let resources = AgentHarnessResources {
2251        context_files: context_paths,
2252        skills: if skills.is_empty() {
2253            None
2254        } else {
2255            Some(skills.clone())
2256        },
2257        prompt_templates: if prompt_templates.is_empty() {
2258            None
2259        } else {
2260            Some(prompt_templates.clone())
2261        },
2262    };
2263    let _ = harness.set_system_prompt(Some(system_prompt)).await;
2264    let _ = harness.set_resources(resources).await;
2265    let _ = harness.set_agent_emitter(Some(emitter)).await;
2266    let _ = harness
2267        .set_models(build_models_with_extensions_for_reload(
2268            &refreshed
2269                .as_ref()
2270                .map(|resolved| resolved.providers.clone())
2271                .unwrap_or_else(|| {
2272                    ctx.providers
2273                        .read()
2274                        .unwrap_or_else(|error| error.into_inner())
2275                        .clone()
2276                }),
2277            &extension_session,
2278            ctx.runtime.clone(),
2279        ))
2280        .await;
2281    let _ = harness
2282        .set_provider_hooks(
2283            rpi_extensions::ExtensionProviderHooks::from_session(&extension_session)
2284                .map(|h| Arc::new(h) as Arc<dyn rpi_ai::ProviderHooks>),
2285        )
2286        .await;
2287
2288    if let Some(resolved) = &refreshed {
2289        *ctx.providers
2290            .write()
2291            .unwrap_or_else(|error| error.into_inner()) = resolved.providers.clone();
2292        let catalog = crate::provider::authenticated_catalog(resolved);
2293        fresh_bridge
2294            .clone_host()
2295            .replace_model_catalog(catalog.clone());
2296        *ctx.catalog
2297            .write()
2298            .unwrap_or_else(|error| error.into_inner()) = catalog;
2299        // Refresh the public descriptor. Credentials remain in the newly bound
2300        // provider instances installed above and never enter the lane model.
2301        let _ = harness.lane("main").set_model(resolved.model.clone()).await;
2302    }
2303
2304    let _ = harness.set_tools(tools, Some(active)).await;
2305
2306    let summary = format!(
2307        "Reloaded {} plugin(s), {} skill(s), {} prompt(s).",
2308        extension_session.loaded_paths().len(),
2309        skills.len(),
2310        prompt_templates.len(),
2311    );
2312    ReloadOutcome {
2313        summary,
2314        had_warnings: warnings,
2315        details,
2316        // Reload completed; any diagnostics here are soft (shadowing / name
2317        // validation / skipped optional resources), not hard failures.
2318        had_errors: false,
2319        project_trusted: Some(project_trusted),
2320    }
2321}
2322
2323#[derive(Clone, Debug, PartialEq, Eq)]
2324struct ReloadSettingsFields {
2325    skill_dirs: Option<Vec<String>>,
2326    prompt_dirs: Option<Vec<String>>,
2327    extension_dirs: Option<Vec<String>>,
2328}
2329
2330impl From<crate::settings::Settings> for ReloadSettingsFields {
2331    fn from(settings: crate::settings::Settings) -> Self {
2332        Self {
2333            skill_dirs: settings.skill_dirs,
2334            prompt_dirs: settings.prompt_dirs,
2335            extension_dirs: settings.extension_dirs,
2336        }
2337    }
2338}
2339
2340#[derive(Clone, Debug, PartialEq, Eq)]
2341struct ReloadSettingsSnapshot {
2342    global: ReloadSettingsFields,
2343    project: Option<ReloadSettingsFields>,
2344}
2345
2346fn reload_reads_settings(args: &Args) -> bool {
2347    !args.dev_local_only && (!args.no_extensions || !args.no_skills || !args.no_prompt_templates)
2348}
2349
2350/// Strictly read the settings fields consumed while preparing a reload. The
2351/// snapshot intentionally excludes UI/model fields that `/reload` does not
2352/// use, so an unrelated settings save does not invalidate the operation.
2353fn load_reload_settings_snapshot(
2354    args: &Args,
2355    cwd: &Path,
2356    project_trusted: bool,
2357) -> Result<Option<ReloadSettingsSnapshot>, String> {
2358    if !reload_reads_settings(args) {
2359        return Ok(None);
2360    }
2361    let global = crate::settings::load_settings()
2362        .map_err(|error| format!("could not load global settings: {error}"))?
2363        .into();
2364    let project = if project_trusted {
2365        crate::settings::load_active_project_settings(cwd)
2366            .map_err(|error| format!("could not load project settings: {error}"))?
2367            .map(|(_, settings)| settings.into())
2368    } else {
2369        None
2370    };
2371    Ok(Some(ReloadSettingsSnapshot { global, project }))
2372}
2373
2374/// Validate every settings document that this reload will consume before
2375/// replacing any live extension, bridge, or harness resource.
2376#[cfg(test)]
2377fn validate_settings_for_reload(
2378    args: &Args,
2379    cwd: &Path,
2380    project_trusted: bool,
2381) -> Result<(), String> {
2382    load_reload_settings_snapshot(args, cwd, project_trusted).map(|_| ())
2383}
2384
2385fn prepare_reload_inputs(
2386    args: &Args,
2387    cwd: &Path,
2388    project_trusted: bool,
2389) -> Result<PreparedReloadInputs, String> {
2390    prepare_reload_inputs_inner(args, cwd, project_trusted, || {})
2391}
2392
2393fn prepare_reload_inputs_inner<F>(
2394    args: &Args,
2395    cwd: &Path,
2396    project_trusted: bool,
2397    before_verify: F,
2398) -> Result<PreparedReloadInputs, String>
2399where
2400    F: FnOnce(),
2401{
2402    let settings_before = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2403
2404    let mut extension_dirs = if args.no_extensions || args.dev_local_only {
2405        Vec::new()
2406    } else if args.project_only {
2407        project_extension_dirs(cwd)
2408    } else if project_trusted {
2409        extension_dirs(cwd)
2410    } else {
2411        global_extension_dirs()
2412    };
2413    if !args.no_extensions {
2414        extension_dirs.extend(args.extensions_dir.iter().cloned());
2415    }
2416
2417    let skill_base_dirs = if args.no_skills {
2418        Vec::new()
2419    } else if args.dev_local_only || args.project_only {
2420        project_skill_dirs(cwd)
2421    } else if project_trusted {
2422        skill_dirs(cwd)
2423    } else {
2424        global_skill_dirs()
2425    };
2426
2427    let prompt_base_dirs = if args.no_prompt_templates {
2428        Vec::new()
2429    } else if args.dev_local_only || args.project_only {
2430        project_prompt_template_dirs(cwd)
2431    } else if project_trusted {
2432        prompt_template_dirs(cwd)
2433    } else {
2434        global_prompt_template_dirs()
2435    };
2436
2437    before_verify();
2438    let settings_after = load_reload_settings_snapshot(args, cwd, project_trusted)?;
2439    if settings_before != settings_after {
2440        return Err(
2441            "settings changed while reload inputs were being prepared; retry /reload".to_string(),
2442        );
2443    }
2444
2445    Ok(PreparedReloadInputs {
2446        extension_dirs,
2447        skill_base_dirs,
2448        prompt_base_dirs,
2449    })
2450}
2451
2452/// `build_models_with_extensions` for the reload path: the resolved provider
2453/// runtimes (NOT `resolved` — the reload context carries the `Arc<dyn Provider>`
2454/// list directly, since provider/auth did not change) first, then one
2455/// `PluggableProvider` per registered extension provider in the fresh session.
2456fn build_models_with_extensions_for_reload(
2457    providers: &[Arc<dyn Provider>],
2458    extension_session: &ExtensionSession,
2459    runtime: tokio::runtime::Handle,
2460) -> Vec<Arc<dyn Provider>> {
2461    let mut models: Vec<Arc<dyn Provider>> = providers.to_vec();
2462    let pluggable = rpi_extensions::PluggableProvider::from_session(extension_session, runtime);
2463    for provider in pluggable {
2464        // Declared packages must enter through credential-bound factories.
2465        // Falling back to a raw adapter would reintroduce packages rejected by
2466        // auth filtering and would discard their configured model snapshot.
2467        if extension_session
2468            .snapshot()
2469            .is_some_and(|snapshot| snapshot.provider_manifest(provider.id()).is_some())
2470        {
2471            continue;
2472        }
2473        if !models.iter().any(|known| known.id() == provider.id()) {
2474            models.push(provider);
2475        }
2476    }
2477    models
2478}
2479
2480/// Diagnostic for registered TUI renderers. All three renderer kinds are
2481/// consumed by the interactive TUI's JSON component adapter; this line remains
2482/// useful under `--verbose` for extension authors.
2483fn report_deferred_renderers(session: &ExtensionSession) {
2484    let Some(snap) = session.snapshot_arc() else {
2485        return;
2486    };
2487    let all = snap.renderers();
2488    let markdown = all
2489        .iter()
2490        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Markdown)
2491        .count();
2492    let message = all
2493        .iter()
2494        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Message)
2495        .count();
2496    let entry = all
2497        .iter()
2498        .filter(|r| r.kind == rpi_extensions::RegisteredRendererKind::Entry)
2499        .count();
2500    if markdown + message + entry == 0 {
2501        return;
2502    }
2503    eprintln!(
2504        "renderers: {} markdown-transform, {} message-render, {} entry-render (active)",
2505        markdown, message, entry
2506    );
2507}
2508
2509/// A harness-build error.
2510#[derive(Debug, thiserror::Error)]
2511pub enum BuildError {
2512    #[error("Experimental native session policy: {0}")]
2513    NativeStorePolicy(String),
2514    #[error("Could not create the session directory: {0}")]
2515    SessionDir(String),
2516    #[error("Could not open the saved session: {0}")]
2517    SessionOpen(String),
2518    #[error("No session found for {requested} in {dir}. Start a fresh session instead (drop --continue/--resume/--session).")]
2519    SessionNotFound { requested: String, dir: String },
2520    #[error("Could not build the harness: {0}")]
2521    HarnessCreate(String),
2522}
2523
2524/// Keep configured factories (including package-specific auth and catalogs)
2525/// first, then append legacy plugin registrations that have no runtime yet.
2526fn build_models_with_extensions(
2527    resolved: &ResolvedModel,
2528    extension_session: &ExtensionSession,
2529    runtime: tokio::runtime::Handle,
2530) -> Vec<Arc<dyn Provider>> {
2531    build_models_with_extensions_for_reload(&resolved.providers, extension_session, runtime)
2532}
2533
2534/// Resolve project resource loading without prompting. Explicit CLI overrides
2535/// win, then a stored decision; projects with no decision load by default.
2536pub(crate) fn resolve_project_trust(args: &Args, cwd: &Path) -> bool {
2537    if let Some(override_value) = args.trust_override {
2538        return override_value;
2539    }
2540    crate::config::project_trust_decision(cwd)
2541        .ok()
2542        .flatten()
2543        .unwrap_or(true)
2544}
2545
2546/// Resolve the extension dirs to scan and load the cdylib plugins, returning
2547/// the loaded session guard (keeps the `Library` handles alive for the harness
2548/// lifetime). Scan order: configured project paths, project `.rpi/extensions`,
2549/// configured/global conventional paths, then any
2550/// `--extensions-dir` flags (scanned after the defaults — `args.rs`).
2551/// Diagnostics are a no-op sink for now; load skips/ABI mismatches surface via
2552/// the `--verbose` summary.
2553pub(crate) fn load_extensions(
2554    args: &Args,
2555    cwd: &Path,
2556    project_trusted: bool,
2557    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2558) -> ExtensionSession {
2559    if args.no_extensions {
2560        return ExtensionSession::none();
2561    }
2562    let mut dirs = if args.dev_local_only {
2563        Vec::new()
2564    } else if args.project_only {
2565        project_extension_dirs(cwd)
2566    } else if project_trusted {
2567        extension_dirs(cwd)
2568    } else {
2569        global_extension_dirs()
2570    };
2571    dirs.extend(args.extensions_dir.iter().cloned());
2572    let leases = match crate::resource_management::host_leases(cwd) {
2573        Ok(leases) => leases,
2574        Err(error) => {
2575            eprintln!("warning: refusing extension loading: {error}");
2576            return ExtensionSession::none();
2577        }
2578    };
2579    load_extensions_from_dirs(args, &dirs, action_bridge, Some(leases))
2580}
2581
2582fn load_extensions_from_dirs(
2583    args: &Args,
2584    dirs: &[PathBuf],
2585    action_bridge: Option<Arc<rpi_extensions::ActionBridge>>,
2586    host_guard: Option<Arc<dyn Send + Sync>>,
2587) -> ExtensionSession {
2588    let diagnostics: Arc<dyn PluginDiagnostics> = Arc::new(NullDiagnostics);
2589    // B5a: the action bridge is cloned into every loaded plugin's vtable
2590    // `user_data` so post-register `runtime_action` calls recover the harness
2591    // host from any thread. The call site already gates `load_extensions` behind
2592    // `!no_extensions` and threads `Some(bridge)`; `None` is only passed by the
2593    // `--no-extensions` branch (which calls `ExtensionSession::none()` directly)
2594    // and tests. Explicit `--extension`/`-e` files load after the dirs.
2595    rpi_extensions::load_session_mixed_guarded(
2596        dirs,
2597        &args.extension,
2598        diagnostics,
2599        action_bridge,
2600        host_guard,
2601    )
2602}
2603
2604/// Merge the loaded extension tools into the built-in set. An extension tool
2605/// overrides a same-named built-in; first-extension-wins across plugins is
2606/// already guaranteed by the registry (`register_tool` keeps the prior). The
2607/// explicit `--tools` allowlist / `--exclude-tools` denylist apply to the
2608/// merged set (the built-ins were already filtered in [`build_tools`]).
2609fn merge_extension_tools(
2610    tools: &mut Vec<HarnessTool>,
2611    session: &ExtensionSession,
2612    args: &Args,
2613    tool_context: &rpi_extensions::ToolCallContext,
2614) {
2615    let Some(snapshot) = session.snapshot() else {
2616        return;
2617    };
2618    for et in snapshot.tools() {
2619        let name = &et.tool.name;
2620        if !tool_name_allowed(name, args) {
2621            continue;
2622        }
2623        let adapter = PluginToolAdapter::new(et.tool.clone(), et.handle(), session.keepalive())
2624            .with_context(tool_context.clone());
2625        let harness_tool = HarnessTool::new(Arc::new(adapter));
2626        match tools.iter_mut().find(|t| t.tool.schema().name == *name) {
2627            Some(slot) => *slot = harness_tool,
2628            None => tools.push(harness_tool),
2629        }
2630    }
2631}
2632
2633/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
2634/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
2635/// resolution in `createAgentSession`.
2636/// Default bash timeout: 120s when the model doesn't pass one (prevents a
2637/// forgotten `timeout` from hanging the run forever — the "卡住" report).
2638/// `RPI_BASH_TIMEOUT` overrides; a model-supplied timeout always wins.
2639pub fn bash_options() -> rpi_tools::tools::bash::BashToolOptions {
2640    use rpi_tools::tools::bash::BashToolOptions;
2641    let default = std::env::var("RPI_BASH_TIMEOUT")
2642        .ok()
2643        .and_then(|v| v.parse::<f64>().ok())
2644        .unwrap_or(rpi_tools::tools::bash::DEFAULT_BASH_TIMEOUT_SECONDS);
2645    // Saved user defaults, matching how `defaultTools` is read in `build_tools`.
2646    // `shellCommandPrefix` is prepended to every command (upstream's key for
2647    // alias/`shopt` setup). Native's `shellPath` is *not* wired: neither
2648    // `BashToolOptions` nor `ShellCaptureOptions` carries a shell binary today, so
2649    // honouring it means plumbing one through pi-tools first.
2650    let settings = crate::settings::load_settings().unwrap_or_default();
2651    BashToolOptions {
2652        command_prefix: settings.shell_command_prefix.clone(),
2653        default_timeout: Some(default),
2654        prepare: None,
2655    }
2656}
2657
2658fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
2659    if args.no_tools {
2660        return Vec::new();
2661    }
2662    // Keep the coding tools aligned with Pi and expose rpi's read-only docs
2663    // lookup as a default assistant capability.
2664    //
2665    // Replay policy (native `ToolDeclaration.replay`): only tools that are
2666    // genuinely free of side effects may be re-run when a crash leaves their
2667    // outcome unknown — everything else must stay `Never`, because replaying
2668    // `bash`/`write`/`edit` would duplicate a side effect. `HarnessTool::new`
2669    // already defaults to `Never`, so this is opt-in per read-only tool.
2670    let mut all: Vec<(&'static str, HarnessTool)> = vec![
2671        (
2672            "read",
2673            HarnessTool::new(create_read_tool(
2674                ctx,
2675                Some(rpi_tools::tools::read::ReadToolOptions {
2676                    image_processor: Some(Arc::new(
2677                        rpi_tools::image_processing::InlineImageProcessor,
2678                    )),
2679                    ..Default::default()
2680                }),
2681            ))
2682            .with_replay(ToolReplay::Safe),
2683        ),
2684        (
2685            "bash",
2686            HarnessTool::new(create_bash_tool(ctx, Some(bash_options()))),
2687        ),
2688        ("edit", HarnessTool::new(create_edit_tool(ctx))),
2689        ("write", HarnessTool::new(create_write_tool(ctx))),
2690        (
2691            "docs",
2692            HarnessTool::new(create_docs_tool()).with_replay(ToolReplay::Safe),
2693        ),
2694    ];
2695
2696    // Add PowerShell tool on Windows
2697    #[cfg(target_os = "windows")]
2698    all.push(("powershell", HarnessTool::new(create_powershell_tool(ctx))));
2699
2700    // Apply default_tools from settings (if no CLI override)
2701    if args.tools.is_none() && args.resource_config.is_none() && args.resource_transition.is_none()
2702    {
2703        if let Ok(settings) = crate::settings::load_settings() {
2704            if let Some(default_tools) = &settings.default_tools {
2705                all.retain(|(name, _)| default_tools.iter().any(|t| t == name));
2706            }
2707        }
2708    }
2709
2710    // `--no-builtin-tools` disables the built-in set but would keep
2711    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
2712    // here. We honor it by clearing the built-ins.
2713    if args.no_builtin_tools {
2714        all.clear();
2715    }
2716
2717    // Allowlist (`--tools`): keep only named built-ins.
2718    if let Some(allow) = &args.tools {
2719        all.retain(|(name, _)| allow.iter().any(|a| a == name));
2720    }
2721    // Denylist (`--exclude-tools`): drop named tools.
2722    if let Some(deny) = &args.exclude_tools {
2723        all.retain(|(name, _)| !deny.iter().any(|d| d == name));
2724    }
2725
2726    all.into_iter().map(|(_, t)| t).collect()
2727}
2728
2729/// Resolve the active tool names from the constructed tools when no explicit
2730/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
2731/// active.
2732fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
2733    filter_active_tool_names(
2734        tools.iter().map(|tool| tool.tool.schema().name.clone()),
2735        args,
2736    )
2737}
2738
2739/// Whether a tool name survives the command-line tool policy. Keep this check
2740/// centralized so extension-provided tools honor the same policy as built-ins.
2741pub(crate) fn tool_name_allowed(name: &str, args: &Args) -> bool {
2742    if args.no_tools {
2743        return false;
2744    }
2745    if args
2746        .tools
2747        .as_ref()
2748        .is_some_and(|allow| !allow.iter().any(|value| value == name))
2749    {
2750        return false;
2751    }
2752    if args
2753        .exclude_tools
2754        .as_ref()
2755        .is_some_and(|deny| deny.iter().any(|value| value == name))
2756    {
2757        return false;
2758    }
2759    true
2760}
2761
2762pub(crate) fn filter_active_tool_names<I>(names: I, args: &Args) -> Vec<String>
2763where
2764    I: IntoIterator<Item = String>,
2765{
2766    names
2767        .into_iter()
2768        .filter(|name| tool_name_allowed(name, args))
2769        .collect()
2770}
2771
2772/// Build the `Session` facade for the chosen selection.
2773async fn build_session(
2774    selection: &SessionSelection,
2775    cwd: &str,
2776    selected: Option<&crate::resource_selection::ResolvedSelection>,
2777) -> Result<Session, BuildError> {
2778    match selection {
2779        SessionSelection::Ephemeral => Ok(ephemeral_session()),
2780        SessionSelection::ExperimentalNative { dir } => Ok(Session::new(
2781            backend::fresh_experimental_native(dir).await?,
2782            None,
2783        )),
2784        SessionSelection::New { dir, .. } => {
2785            // Ensure the sessions directory exists, then create a fresh JSONL
2786            // session file inside it.
2787            std::fs::create_dir_all(dir)
2788                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2789            let session = create_jsonl_session(dir, cwd)
2790                .await
2791                .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2792            Ok(session)
2793        }
2794        SessionSelection::Latest { .. }
2795        | SessionSelection::ById { .. }
2796        | SessionSelection::ByExactId { .. } => restore_session(selection, cwd).await,
2797        SessionSelection::Fork { source, dir } => {
2798            fork_session_at_launch(source, dir, cwd, selected).await
2799        }
2800    }
2801}
2802
2803/// Open an existing JSONL session for `Latest` / `ById`. Mirrors the TS
2804/// `SessionManager.resume`/`open` flow: list the session dir (newest-first),
2805/// match the request, then open the matched file and wrap it in a `Session`
2806/// facade. The restored transcript renders into the TUI at startup and the
2807/// harness continues appending to the same file.
2808async fn restore_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
2809    // `resolve_restore_target` is newest-first; `Latest` takes the head, `ById`
2810    // matches the id exactly or by file-name containment (so `--session 01a02…`
2811    // or a partial id works, mirroring the TS id/path matching).
2812    let Some(meta) = resolve_restore_target(selection, cwd).await? else {
2813        return match selection {
2814            // Exact id match only (pi `--session-id`): a missing session is
2815            // created fresh under the selected dir instead of an error.
2816            SessionSelection::ByExactId { id, dir } => {
2817                std::fs::create_dir_all(dir)
2818                    .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2819                create_jsonl_session_with_id(dir, cwd, Some(id.clone()))
2820                    .await
2821                    .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))
2822            }
2823            SessionSelection::Latest { dir } => Err(BuildError::SessionNotFound {
2824                requested: "the most recent session".to_string(),
2825                dir: dir.display().to_string(),
2826            }),
2827            SessionSelection::ById { id, dir } => Err(BuildError::SessionNotFound {
2828                requested: format!("session {id}"),
2829                dir: dir.display().to_string(),
2830            }),
2831            _ => unreachable!("resolve_restore_target only returns None for restore selections"),
2832        };
2833    };
2834    let sessions_root = selection_dir(selection);
2835    let writer_restore = StartupSpan::start(Phase::WriterRestore);
2836    let result = open_session(&meta, cwd, &sessions_root)
2837        .await
2838        .map(|restored| restored.session);
2839    if result.is_ok() {
2840        writer_restore.finish();
2841    }
2842    result
2843}
2844
2845/// The sessions root a selection was resolved against.
2846fn selection_dir(selection: &SessionSelection) -> PathBuf {
2847    match selection {
2848        SessionSelection::Ephemeral => PathBuf::new(),
2849        SessionSelection::New { dir, .. }
2850        | SessionSelection::ExperimentalNative { dir }
2851        | SessionSelection::Latest { dir }
2852        | SessionSelection::ById { dir, .. }
2853        | SessionSelection::ByExactId { dir, .. }
2854        | SessionSelection::Fork { dir, .. } => dir.clone(),
2855    }
2856}
2857
2858/// Resolve the metadata a restore selection names, without opening the file.
2859///
2860/// Shared by the startup build ([`restore_session`]) and the cwd peek
2861/// ([`peek_restored_session_cwd`]) so the two can never disagree about which
2862/// session a launch restores. Pure listing + matching: no session lock is
2863/// claimed and nothing is created.
2864async fn resolve_restore_target(
2865    selection: &SessionSelection,
2866    cwd: &str,
2867) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2868    match selection {
2869        SessionSelection::Latest { dir } => {
2870            let metas = list_session_metadata_in_dir(cwd, dir).await?;
2871            Ok(first_resumable_session(&metas).cloned())
2872        }
2873        SessionSelection::ById { id, dir } => find_session_metadata(id, cwd, dir).await,
2874        SessionSelection::ByExactId { id, dir } => {
2875            let metas = list_session_metadata_in_dir(cwd, dir).await?;
2876            Ok(metas.into_iter().find(|m| m.id == *id))
2877        }
2878        SessionSelection::Fork { .. }
2879        | SessionSelection::ExperimentalNative { .. }
2880        | SessionSelection::New { .. }
2881        | SessionSelection::Ephemeral => Ok(None),
2882    }
2883}
2884
2885/// The metadata of the session whose id matches exactly or by file-name
2886/// containment (so `--session 01a02…` / a partial id / a full file name all
2887/// work), or `None` when nothing matches.
2888async fn find_session_metadata(
2889    id: &str,
2890    cwd: &str,
2891    dir: &Path,
2892) -> Result<Option<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
2893    let metas = list_session_metadata_in_dir(cwd, dir).await?;
2894    Ok(metas
2895        .into_iter()
2896        .find(|m| m.id == id || m.path.contains(id) || id.contains(&m.id)))
2897}
2898
2899/// What a launch that would restore a session says about the working directory.
2900pub(crate) struct RestoredSessionCwd {
2901    /// The id of the session this launch will restore. The host pins it into
2902    /// `args` so the later build opens exactly this session — a directory
2903    /// listing taken twice can disagree (another `rpi` may write a newer
2904    /// session in between), and adopting one session's directory while opening
2905    /// another is the very bug this restores.
2906    pub id: String,
2907    /// The directory recorded in the session header.
2908    pub recorded: PathBuf,
2909    /// `Some` when the recorded directory still exists and the run adopts it;
2910    /// `None` when the caller must fall back to the launch cwd and say so.
2911    pub adopted: Option<PathBuf>,
2912}
2913
2914/// The working directory the launch's restored session was created in, resolved
2915/// WITHOUT opening, locking, or creating anything.
2916///
2917/// Read-only by design: this runs before trust resolution and extension
2918/// preparation, where a failure must not take the run down — the real open
2919/// happens later and surfaces its own errors. Returns `None` when the selection
2920/// is not a restore (fresh/ephemeral/fork) or cannot be resolved.
2921pub(crate) async fn peek_restored_session_cwd(
2922    selection: &SessionSelection,
2923    launch_cwd: &Path,
2924) -> Option<RestoredSessionCwd> {
2925    let cwd_str = launch_cwd.to_string_lossy().into_owned();
2926    let meta = resolve_restore_target(selection, &cwd_str).await.ok()??;
2927    let recorded = PathBuf::from(&meta.cwd);
2928    if recorded.as_os_str().is_empty() {
2929        return None;
2930    }
2931    Some(RestoredSessionCwd {
2932        id: meta.id,
2933        adopted: crate::session_cwd::resolve_session_cwd(&recorded),
2934        recorded,
2935    })
2936}
2937
2938/// Pin a peeked restore target into `args` so the later build opens exactly the
2939/// session the peek inspected.
2940///
2941/// `Latest` (`-c`) re-lists the session directory when the build runs, and
2942/// another `rpi` may have written a newer session in between. Adopting one
2943/// session's working directory while opening a different conversation would put
2944/// the tools in a directory that conversation never used — the exact failure
2945/// this adoption exists to prevent. Pinning the resolved id turns the second
2946/// listing into an exact match instead of a fresh "newest" choice.
2947pub(crate) fn pin_restored_selection(args: &mut Args, restored: &RestoredSessionCwd) {
2948    args.continue_session = false;
2949    args.resume = false;
2950    args.session = Some(restored.id.clone());
2951}
2952
2953/// `--fork <path|id>`: open the source session, fork it into a new JSONL
2954/// session (records the parent id), and start in the fork.
2955async fn fork_session_at_launch(
2956    source: &str,
2957    dir: &Path,
2958    cwd: &str,
2959    selected: Option<&crate::resource_selection::ResolvedSelection>,
2960) -> Result<Session, BuildError> {
2961    use rpi_harness::session::jsonl::{
2962        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
2963    };
2964    use rpi_harness::session::types::{ForkOptions, SessionStorage};
2965    use rpi_tools::FileSystem;
2966
2967    let dir = dir.to_path_buf();
2968    std::fs::create_dir_all(&dir)
2969        .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
2970    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
2971    let fs: Arc<dyn FileSystem> = env.clone();
2972    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
2973        fs: fs.clone(),
2974        sessions_root: dir.to_string_lossy().into_owned(),
2975        clock: Arc::new(SystemClock),
2976        ids: Arc::new(DefaultIdGenerator::new()),
2977    });
2978    // A session file given by path forks from wherever it lives (pi parity:
2979    // `pi --fork <file>` takes any session file); an id or a name fragment is
2980    // looked up in the session directory.
2981    let source_meta = match session_metadata_at(Path::new(source)) {
2982        Some(meta) => meta,
2983        None => {
2984            let metas = repo
2985                .list_typed(&rpi_harness::session::jsonl::JsonlSessionListOptions::default())
2986                .await
2987                .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")))?;
2988            metas
2989                .into_iter()
2990                .find(|m| m.id == *source || m.path.contains(source) || source.contains(&m.id))
2991                .ok_or_else(|| BuildError::SessionNotFound {
2992                    requested: format!("--fork {source}"),
2993                    dir: dir.display().to_string(),
2994                })?
2995        }
2996    };
2997    let fork_storage = repo
2998        .fork_typed_with_value_initializer(
2999            &source_meta,
3000            &JsonlSessionCreateOptions {
3001                id: None,
3002                parent_session_id: Some(source_meta.id.clone()),
3003                cwd: cwd.to_string(),
3004                metadata: None,
3005            },
3006            &ForkOptions::default(),
3007            |values| crate::resource_selection::fork_initial_values_for_selection(values, selected),
3008        )
3009        .await
3010        .map_err(|e| BuildError::SessionDir(format!("fork {}: {e}", source_meta.path)))?;
3011    claim_session_lock(&fork_storage.jsonl_metadata().path).map_err(BuildError::SessionOpen)?;
3012    let storage_arc: Arc<dyn SessionStorage> = Arc::new(fork_storage);
3013    Ok(Session::new(storage_arc, None))
3014}
3015
3016/// The metadata of the JSONL session file at `path`, or `None` when `path` is
3017/// not a readable session file.
3018fn session_metadata_at(path: &Path) -> Option<rpi_harness::session::jsonl::JsonlSessionMetadata> {
3019    use rpi_harness::session::jsonl::codec::{metadata_from_header, parse_header};
3020    use std::io::BufRead;
3021
3022    let file = std::fs::File::open(path).ok()?;
3023    let modified_at = file
3024        .metadata()
3025        .ok()?
3026        .modified()
3027        .ok()?
3028        .duration_since(std::time::UNIX_EPOCH)
3029        .ok()?
3030        .as_millis() as i64;
3031    let mut first_line = String::new();
3032    std::io::BufReader::new(file)
3033        .read_line(&mut first_line)
3034        .ok()?;
3035    let header = parse_header(first_line.trim_end()).ok()?;
3036    let path = std::fs::canonicalize(path).ok()?;
3037    Some(metadata_from_header(
3038        &header,
3039        &path.to_string_lossy(),
3040        modified_at,
3041    ))
3042}
3043
3044/// Errors from [`open_session_by_id`], split so the CLI can map them to
3045/// [`BuildError`] while the TUI can surface a friendlier note.
3046pub enum OpenError {
3047    /// No session matched the request.
3048    NotFound { requested: String },
3049    /// The match existed but could not be opened/parsed.
3050    Other(String),
3051}
3052
3053impl std::fmt::Display for OpenError {
3054    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3055        match self {
3056            OpenError::NotFound { requested } => write!(f, "no session matches {requested}"),
3057            // `Other` carries the bare reason (see `open_session_by_id`); the
3058            // prefix lives here so the TUI and the CLI's
3059            // `BuildError::SessionOpen` wrapper each render it exactly once
3060            // (wrapping the already-prefixed `BuildError` string doubled it).
3061            OpenError::Other(msg) => write!(f, "Could not open the saved session: {msg}"),
3062        }
3063    }
3064}
3065
3066/// List the JSONL session metadata under the default session dir, newest
3067/// first. Shared by startup restore and the TUI `/session` hot-switch.
3068pub async fn list_session_metadata(
3069    cwd: &str,
3070) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3071    let dir = default_session_dir(Path::new(cwd));
3072    list_session_metadata_in_dir(cwd, &dir).await
3073}
3074
3075pub(crate) async fn list_session_metadata_in_dir(
3076    cwd: &str,
3077    dir: &Path,
3078) -> Result<Vec<rpi_harness::session::jsonl::JsonlSessionMetadata>, BuildError> {
3079    use rpi_harness::session::jsonl::{
3080        JsonlSessionListOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3081    };
3082    use rpi_tools::FileSystem;
3083
3084    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3085    let fs: Arc<dyn FileSystem> = env.clone();
3086    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3087        fs: fs.clone(),
3088        sessions_root: dir.to_string_lossy().into_owned(),
3089        clock: Arc::new(SystemClock),
3090        ids: Arc::new(DefaultIdGenerator::new()),
3091    });
3092    let metadata_lookup = StartupSpan::start(Phase::MetadataLookup);
3093    let result = repo
3094        .list_typed(&JsonlSessionListOptions::default())
3095        .await
3096        .map_err(|e| BuildError::SessionDir(format!("list sessions: {e}")));
3097    if result.is_ok() {
3098        metadata_lookup.finish();
3099    }
3100    result
3101}
3102
3103/// A cheap, display-oriented summary of one JSONL session file.
3104///
3105/// The session pickers (`-r` and `/session`) need enough context to tell saved
3106/// sessions apart. The header-only [`JsonlSessionMetadata`] carries no name and
3107/// no transcript, so a list built from it renders as a wall of identical
3108/// `2026-09-25T15-05-15-847Z_…` file names — including the many header-only
3109/// sessions an abandoned launch leaves behind. This reads just the head (and a
3110/// small tail, for a late `--name`/`/name` fact) of the file to recover:
3111///
3112/// - the session display name, when one was recorded;
3113/// - the first user prompt, single-lined and truncated, for a readable label;
3114/// - whether the file is header-only (nothing to resume).
3115///
3116/// It intentionally does **not** parse the whole log: sessions can be tens of
3117/// megabytes, and the picker runs over every session at startup.
3118#[derive(Debug, Clone, Default, PartialEq, Eq)]
3119pub struct SessionSummary {
3120    /// The session display name (`--name` / `/name`), if one was recorded.
3121    pub name: Option<String>,
3122    /// The first user prompt, single-lined and trimmed to a label-sized length.
3123    pub preview: Option<String>,
3124    /// True when the file holds only its header (a session with no content).
3125    pub empty: bool,
3126    /// On-disk size in bytes (a rough content-volume hint for the picker).
3127    pub bytes: u64,
3128}
3129
3130/// Bytes read from the head of a session file to recover a name + first prompt.
3131const SUMMARY_HEAD_BYTES: u64 = 256 * 1024;
3132/// Bytes read from the tail of a session file to recover a late name fact.
3133const SUMMARY_TAIL_BYTES: u64 = 8 * 1024;
3134/// Longest preview retained (characters, not bytes).
3135const SUMMARY_PREVIEW_CHARS: usize = 80;
3136
3137/// Summarize one session file for the pickers. Best-effort: an unreadable or
3138/// malformed file yields a summary with `empty: false` and no label, so it is
3139/// still selectable rather than silently dropped.
3140pub fn summarize_session_file(path: &Path) -> SessionSummary {
3141    use std::io::{Read, Seek, SeekFrom};
3142
3143    let Ok(mut file) = std::fs::File::open(path) else {
3144        return SessionSummary::default();
3145    };
3146    let Ok(size) = file.metadata().map(|m| m.len()) else {
3147        return SessionSummary::default();
3148    };
3149    if size == 0 {
3150        return SessionSummary {
3151            name: None,
3152            preview: None,
3153            empty: true,
3154            bytes: 0,
3155        };
3156    }
3157
3158    // Head: the header line (for the empty check) + a name fact + the first user
3159    // prompt. The whole window is scanned; it is already in memory.
3160    let mut head = vec![0u8; SUMMARY_HEAD_BYTES.min(size) as usize];
3161    let head_len = file.read(&mut head).unwrap_or(0);
3162    head.truncate(head_len);
3163    let head = String::from_utf8_lossy(&head);
3164    let mut lines: Vec<&str> = head.lines().collect();
3165    let header_len = lines.first().map(|line| line.len()).unwrap_or(0);
3166    // A header-only file is exactly the header plus an optional trailing `\n`.
3167    let empty = (size as usize) <= header_len + 1;
3168    // When the window stopped short of the file end, its final line is partial:
3169    // drop it so a half-read message can never be mistaken for a complete one.
3170    if (head_len as u64) < size && !lines.is_empty() {
3171        lines.pop();
3172    }
3173
3174    let mut summary = SessionSummary {
3175        name: None,
3176        preview: None,
3177        empty,
3178        bytes: size,
3179    };
3180    for line in lines {
3181        apply_summary_line(line, &mut summary);
3182    }
3183
3184    // Tail: a name set late in the session lives after the head window. Read the
3185    // last window and scan only complete lines so a torn/partial first line is
3186    // ignored. Only do this when the file is larger than the head window.
3187    if size > SUMMARY_HEAD_BYTES
3188        && file
3189            .seek(SeekFrom::End(-(SUMMARY_TAIL_BYTES as i64)))
3190            .is_ok()
3191    {
3192        let mut tail = Vec::new();
3193        if file.read_to_end(&mut tail).is_ok() {
3194            let tail = String::from_utf8_lossy(&tail);
3195            let mut tail_lines = tail.lines();
3196            // Drop the possibly-partial first line of the window.
3197            tail_lines.next();
3198            for line in tail_lines {
3199                if let Some(name) = name_from_line(line) {
3200                    summary.name = name;
3201                }
3202            }
3203        }
3204    }
3205    summary
3206}
3207
3208/// Extract the name from a `{"kind":"fact","fact":"name","name":…}` line.
3209/// Returns `Some(None)` for an explicit `name: null` (the fact was cleared).
3210fn name_from_line(line: &str) -> Option<Option<String>> {
3211    let value: serde_json::Value = serde_json::from_str(line).ok()?;
3212    if value.get("kind").and_then(|v| v.as_str()) != Some("fact") {
3213        return None;
3214    }
3215    if value.get("fact").and_then(|v| v.as_str()) != Some("name") {
3216        return None;
3217    }
3218    Some(match value.get("name") {
3219        Some(serde_json::Value::String(name)) => Some(name.clone()),
3220        _ => None,
3221    })
3222}
3223
3224/// Fold one JSONL line into `summary`: record a name fact, or capture the first
3225/// user prompt as the preview.
3226fn apply_summary_line(line: &str, summary: &mut SessionSummary) {
3227    if let Some(name) = name_from_line(line) {
3228        summary.name = name;
3229        return;
3230    }
3231    if summary.preview.is_some() {
3232        return;
3233    }
3234    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
3235        return;
3236    };
3237    if value.get("kind").and_then(|v| v.as_str()) != Some("entry") {
3238        return;
3239    }
3240    if value.get("type").and_then(|v| v.as_str()) != Some("message") {
3241        return;
3242    }
3243    let Some(message) = value.get("message") else {
3244        return;
3245    };
3246    if message.get("role").and_then(|v| v.as_str()) != Some("user") {
3247        return;
3248    }
3249    let text = match message.get("content") {
3250        Some(serde_json::Value::String(text)) => Some(text.as_str()),
3251        Some(serde_json::Value::Array(blocks)) => blocks.iter().find_map(|block| {
3252            (block.get("type").and_then(|v| v.as_str()) == Some("text"))
3253                .then(|| block.get("text").and_then(|v| v.as_str()))
3254                .flatten()
3255        }),
3256        _ => None,
3257    };
3258    let Some(text) = text else { return };
3259    let collapsed = text
3260        .lines()
3261        .map(str::trim)
3262        .filter(|line| !line.is_empty())
3263        .collect::<Vec<_>>()
3264        .join(" ");
3265    if collapsed.is_empty() {
3266        return;
3267    }
3268    summary.preview = Some(collapsed.chars().take(SUMMARY_PREVIEW_CHARS).collect());
3269}
3270
3271/// The human label for a session row: its recorded name, else the first user
3272/// prompt, else a marker that says the file has no transcript to show. Shared
3273/// by the startup picker and the in-TUI `/session` selector.
3274pub fn session_display_label(summary: &SessionSummary) -> String {
3275    if let Some(name) = summary.name.as_deref().map(str::trim) {
3276        if !name.is_empty() {
3277            return name.to_string();
3278        }
3279    }
3280    match summary.preview.as_deref().map(str::trim) {
3281        Some(preview) if !preview.is_empty() => preview.to_string(),
3282        _ => "(no messages)".to_string(),
3283    }
3284}
3285
3286/// Compact byte count (1.2MB / 34KB / 512B), shared by the startup picker and
3287/// the in-TUI `/session` selector.
3288pub fn format_session_bytes(bytes: u64) -> String {
3289    if bytes >= 1_048_576 {
3290        format!("{:.1}MB", bytes as f64 / 1_048_576.0)
3291    } else if bytes >= 1024 {
3292        format!("{}KB", bytes / 1024)
3293    } else {
3294        format!("{bytes}B")
3295    }
3296}
3297
3298/// The first 8 characters of a session id, enough to disambiguate two rows that
3299/// share a timestamp without shouting the whole UUID.
3300pub fn short_session_id(id: &str) -> &str {
3301    &id[..id.len().min(8)]
3302}
3303
3304/// Synchronously list every JSONL session file under `dir`, newest-first by
3305/// modification time.
3306///
3307/// The repo's [`list_session_metadata`] is async and scans one cwd-encoded
3308/// subdirectory level; the in-TUI `/session` selector runs on the blocking key
3309/// thread and cannot await it, so this walks the same layout directly. Both
3310/// `.jsonl` files directly under `dir` and one level of subdirectories are
3311/// accepted.
3312pub fn list_session_files_sync(dir: &Path) -> Vec<PathBuf> {
3313    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
3314    let mut visit = |path: PathBuf| {
3315        if path.extension().and_then(|e| e.to_str()) != Some("jsonl")
3316            || path.to_string_lossy().ends_with(".progress.jsonl")
3317        {
3318            return;
3319        }
3320        let modified = std::fs::metadata(&path)
3321            .and_then(|m| m.modified())
3322            .unwrap_or(std::time::UNIX_EPOCH);
3323        files.push((path, modified));
3324    };
3325    if let Ok(entries) = std::fs::read_dir(dir) {
3326        for entry in entries.flatten() {
3327            let path = entry.path();
3328            if path.is_dir() {
3329                if let Ok(inner) = std::fs::read_dir(&path) {
3330                    for child in inner.flatten() {
3331                        visit(child.path());
3332                    }
3333                }
3334            } else {
3335                visit(path);
3336            }
3337        }
3338    }
3339    files.sort_by(|a, b| b.1.cmp(&a.1));
3340    files.into_iter().map(|(path, _)| path).collect()
3341}
3342
3343/// Pick the session `--continue`/`-c` should open: the newest session that has
3344/// content (a header-only session resumes to an empty transcript). Falls back to
3345/// the newest overall when every session is empty, and `None` only when the
3346/// list is empty.
3347fn first_resumable_session(
3348    metas: &[rpi_harness::session::jsonl::JsonlSessionMetadata],
3349) -> Option<&rpi_harness::session::jsonl::JsonlSessionMetadata> {
3350    metas
3351        .iter()
3352        .find(|meta| !summarize_session_file(Path::new(&meta.path)).empty)
3353        .or_else(|| metas.first())
3354}
3355
3356/// Open a session whose id matches exactly or by file-name containment
3357/// (so `--session 01a02…` / a partial id / a full file name all work). The
3358/// TUI `/session` hot-switch calls this with the selector's item value.
3359pub async fn open_session_by_id(id: &str, cwd: &str) -> Result<Session, OpenError> {
3360    let dir = default_session_dir(Path::new(cwd));
3361    open_session_by_id_in_dir(id, cwd, &dir)
3362        .await
3363        .map(|restored| restored.session)
3364}
3365
3366pub(crate) async fn open_session_by_id_in_dir(
3367    id: &str,
3368    cwd: &str,
3369    dir: &Path,
3370) -> Result<RestoredSession, OpenError> {
3371    let meta = find_session_metadata(id, cwd, dir)
3372        .await
3373        .map_err(|e| OpenError::Other(e.to_string()))?;
3374    let Some(meta) = meta else {
3375        return Err(OpenError::NotFound {
3376            requested: format!("session {id}"),
3377        });
3378    };
3379    open_session(&meta, cwd, dir).await.map_err(|e| {
3380        OpenError::Other(match e {
3381            // `open_session` renders as "Could not open the saved session:
3382            // <reason>" via `BuildError`. Carry the bare reason so it is not
3383            // prefixed twice (once here, once by the CLI/TUI).
3384            BuildError::SessionOpen(reason) => reason,
3385            other => other.to_string(),
3386        })
3387    })
3388}
3389
3390/// Publish the active session's id to plugins, on both channels that carry it.
3391///
3392/// Called on **every** activation of a session — the startup build AND every
3393/// in-process swap (`switch_to_session`, `fork_session`, and the plugin
3394/// `NewSession`/`Fork`/`SwitchSession` runtime actions all route through
3395/// [`set_active_session`]). Two things must stay in step:
3396///
3397/// - `ToolCallContext` — read per plugin **tool** call, which is why a stale
3398///   value there made a plugin's `todo`-style state follow the wrong session.
3399/// - `RPI_SESSION_ID` — read per **turn** by the provider hooks, which is the
3400///   only channel an observer gets for a pure-chat run. Process-global, which is
3401///   exactly the scope of "the session this process is serving".
3402///
3403/// The write overwrites unconditionally, deliberately. An inherited value is not
3404/// an override: `rpi` spawning `rpi` would otherwise hand the child the parent's
3405/// session id, and the child — which is *serving its own* session — would report
3406/// it forever. (Children no longer inherit the variable at all; see
3407/// `rpi_tools::HOST_PRIVATE_ENV`. This overwrite is the second line of defence,
3408/// for any other way a value can arrive pre-set.)
3409///
3410/// An embedder that genuinely wants to pin the id can revert to the old
3411/// behaviour by setting the variable *after* this runs; there is no supported
3412/// way to pin it before, because a session that does not match its own id is
3413/// never what the caller wants.
3414fn publish_session_identity(tool_context: &rpi_extensions::ToolCallContext, id: &str) {
3415    std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, id);
3416    tool_context.set_session_id(id);
3417}
3418
3419/// Swap the session the harness serves, republishing the identity to plugins.
3420///
3421/// The single funnel for an in-process session change. Prefer this over calling
3422/// `AgentHarness::set_session` directly: the harness holds no reference to the
3423/// plugin channels, so a bare `set_session` would leave every plugin reporting
3424/// the session it had before the swap.
3425///
3426/// `tool_context` is optional because not every caller has one (the harness's own
3427/// tests, a headless embedder); the env channel is always published, since the
3428/// provider hooks depend on it.
3429pub(crate) async fn set_active_session(
3430    harness: &AgentHarness,
3431    session: Session,
3432    tool_context: Option<&rpi_extensions::ToolCallContext>,
3433) -> Result<(), String> {
3434    let metadata = session.get_metadata().await.ok();
3435    harness
3436        .set_session(session)
3437        .await
3438        .map_err(|e| e.to_string())?;
3439    if let Some(metadata) = metadata {
3440        std::env::set_var(rpi_plugin_sdk::SESSION_ID_ENV, &metadata.id);
3441        if let Some(context) = tool_context {
3442            context.set_session_id(metadata.id);
3443        }
3444    }
3445    Ok(())
3446}
3447
3448/// Fork the harness's current session into a new JSONL session (new id, parent
3449/// set to the source) and wrap it in a `Session`. Mirrors the TUI's
3450/// `fork_session` flow (`interactive_tui.rs`) — hoisted here so both the TUI
3451/// and the plugin `runtime_action(Fork)` host share one implementation.
3452/// Returns the new `Session` (NOT yet swapped onto the harness — the caller
3453/// does `harness.set_session(...)`).
3454pub(crate) async fn fork_session_storage(
3455    harness: &AgentHarness,
3456    cwd: &str,
3457    dir: &Path,
3458) -> Result<Session, String> {
3459    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3460    use rpi_tools::FileSystem;
3461
3462    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3463    let fs: Arc<dyn FileSystem> = env.clone();
3464    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3465        fs,
3466        sessions_root: dir.to_string_lossy().into_owned(),
3467        clock: Arc::new(SystemClock),
3468        ids: Arc::new(DefaultIdGenerator::new()),
3469    });
3470    // The fork needs the rich JSONL metadata (with the on-disk path); resolve
3471    // it from the session list by the current session's id.
3472    let id = harness.session().storage().metadata().id.clone();
3473    let metas = list_session_metadata_in_dir(cwd, dir)
3474        .await
3475        .map_err(|e| e.to_string())?;
3476    let Some(source) = metas.iter().find(|m| m.id == id) else {
3477        return Err(format!("current session {id} not found on disk"));
3478    };
3479    let fork_storage = repo
3480        .fork_typed_with_value_initializer(
3481            source,
3482            &rpi_harness::session::jsonl::JsonlSessionCreateOptions {
3483                id: None,
3484                parent_session_id: Some(source.id.clone()),
3485                cwd: cwd.to_string(),
3486                metadata: None,
3487            },
3488            &rpi_harness::session::types::ForkOptions::default(),
3489            crate::resource_selection::fork_initial_values,
3490        )
3491        .await
3492        .map_err(|e| e.to_string())?;
3493    claim_session_lock(&fork_storage.jsonl_metadata().path)?;
3494    Ok(Session::new(Arc::new(fork_storage), None))
3495}
3496
3497/// Validate a private copy before publishing it in the repo's normal cwd
3498/// directory. Keep the imported identity/history and never overwrite a saved
3499/// session (including when the source is already inside this repository).
3500pub(crate) async fn import_session_file(
3501    source: &Path,
3502    cwd: &str,
3503    dir: &Path,
3504) -> Result<Session, String> {
3505    use rpi_harness::session::jsonl::{
3506        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions, JsonlSessionStorage,
3507    };
3508    use rpi_harness::session::types::SessionStorage;
3509    use rpi_tools::FileSystem;
3510
3511    let dir = if dir.is_absolute() {
3512        dir.to_path_buf()
3513    } else {
3514        Path::new(cwd).join(dir)
3515    };
3516    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
3517    // External writers must be closed. This process deliberately retains locks
3518    // for previously opened sessions, so its own copies additionally verify a
3519    // stable history around the sidecar snapshot (bind/entry before retirement).
3520    let source_progress = PathBuf::from(format!("{}.progress.jsonl", source.display()));
3521    let source_lock_path = PathBuf::from(format!("{}.lock", source.display()));
3522    let canonical_lock = std::fs::canonicalize(&source_lock_path).ok();
3523    let owned_here = SESSION_LOCK_PATHS.get().is_some_and(|paths| {
3524        paths.lock().unwrap().iter().any(|path| {
3525            path == &source_lock_path
3526                || canonical_lock.as_ref().is_some_and(|canonical| {
3527                    std::fs::canonicalize(path).ok().as_ref() == Some(canonical)
3528                })
3529        })
3530    });
3531    let _source_lock = if !owned_here && source_progress.try_exists().map_err(|e| e.to_string())? {
3532        let lock = std::fs::OpenOptions::new()
3533            .create(true)
3534            .write(true)
3535            .open(format!("{}.lock", source.display()))
3536            .map_err(|e| format!("lock import source: {e}"))?;
3537        fs2::FileExt::try_lock_exclusive(&lock).map_err(|_| {
3538            "close the source session before importing its recovery pair".to_owned()
3539        })?;
3540        Some(lock)
3541    } else {
3542        None
3543    };
3544    let staging = tempfile::tempdir_in(&dir).map_err(|e| e.to_string())?;
3545    let staged = staging.path().join("history.stage");
3546    let staged_progress = PathBuf::from(format!("{}.progress.jsonl", staged.display()));
3547    let mut attempts = 0;
3548    let has_progress = loop {
3549        std::fs::copy(source, &staged).map_err(|e| format!("copy import: {e}"))?;
3550        let copied_history = std::fs::read(&staged).map_err(|e| e.to_string())?;
3551        let has_progress = match std::fs::copy(&source_progress, &staged_progress) {
3552            Ok(_) => true,
3553            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3554                let _ = std::fs::remove_file(&staged_progress);
3555                false
3556            }
3557            Err(error) => return Err(format!("copy progress import: {error}")),
3558        };
3559        if copied_history == std::fs::read(source).map_err(|e| e.to_string())? {
3560            break has_progress;
3561        }
3562        attempts += 1;
3563        if attempts >= 4 {
3564            return Err(
3565                "source history changed during import; retry when the session is idle".into(),
3566            );
3567        }
3568        tokio::task::yield_now().await;
3569    };
3570    let fs: Arc<dyn FileSystem> = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3571    let validated = JsonlSessionStorage::load(
3572        fs.clone(),
3573        &staged.to_string_lossy(),
3574        Arc::new(SystemClock),
3575        Arc::new(DefaultIdGenerator::new()),
3576    )
3577    .await
3578    .map_err(|e| format!("invalid session import: {e}"))?;
3579    validated
3580        .read_progress("")
3581        .await
3582        .map_err(|e| format!("invalid recovery import: {e}"))?;
3583    let mut metadata = validated.jsonl_metadata().clone();
3584    drop(validated);
3585    let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?;
3586    let (first, rest) = text
3587        .split_once('\n')
3588        .ok_or_else(|| "import has no complete header".to_owned())?;
3589    let mut header =
3590        rpi_harness::session::jsonl::codec::parse_header(first).map_err(|e| e.to_string())?;
3591    if has_progress && header.progress_format.is_none() {
3592        return Err("history-only artifact has an undeclared recovery companion".into());
3593    }
3594    // An imported history-only artifact receives the destination's empty store.
3595    // Imports of live-format sessions retain their validated companion instead.
3596    header.progress_format = Some("assistant-progress-v1".into());
3597    std::fs::write(
3598        &staged,
3599        format!(
3600            "{}{}",
3601            rpi_harness::session::jsonl::codec::encode_header(&header),
3602            rest
3603        ),
3604    )
3605    .map_err(|e| e.to_string())?;
3606    if list_session_metadata_in_dir(cwd, &dir)
3607        .await
3608        .map_err(|e| e.to_string())?
3609        .iter()
3610        .any(|saved| saved.id == metadata.id)
3611    {
3612        return Err(format!(
3613            "session {} already exists in {}",
3614            metadata.id,
3615            dir.display()
3616        ));
3617    }
3618    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3619        fs,
3620        sessions_root: dir.to_string_lossy().into_owned(),
3621        clock: Arc::new(SystemClock),
3622        ids: Arc::new(DefaultIdGenerator::new()),
3623    });
3624    // Let the repository validate the id and choose its canonical destination.
3625    let destination = repo
3626        .create_typed(&JsonlSessionCreateOptions {
3627            id: Some(metadata.id.clone()),
3628            parent_session_id: metadata.parent_session_id.clone(),
3629            cwd: cwd.to_string(),
3630            metadata: metadata.metadata.clone(),
3631        })
3632        .await
3633        .map_err(|e| e.to_string())?;
3634    let path = destination.jsonl_metadata().path.clone();
3635    drop(destination);
3636    if let Err(error) = claim_session_lock(&path) {
3637        // Another process owns this destination; leave its file intact.
3638        return Err(error);
3639    }
3640    let destination_progress = format!("{path}.progress.jsonl");
3641    let publish = (|| -> std::io::Result<()> {
3642        if has_progress {
3643            std::fs::rename(&staged_progress, &destination_progress)?;
3644        }
3645        std::fs::rename(&staged, &path)
3646    })();
3647    if let Err(error) = publish {
3648        let _ = std::fs::remove_file(&path);
3649        let _ = std::fs::remove_file(&destination_progress);
3650        return Err(format!("publish import: {error}"));
3651    }
3652    metadata.path = path;
3653    let storage = repo
3654        .open_by_jsonl_metadata(&metadata)
3655        .await
3656        .map_err(|e| e.to_string())?;
3657    Ok(Session::new(Arc::new(storage), None))
3658}
3659
3660/// Session logs this process has claimed, so re-claiming is a no-op.
3661static SESSION_LOCK_PATHS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
3662/// The held lock handles. Kept alive for the process lifetime — dropping the
3663/// `File` would release the OS lock, so the guard is parked here.
3664static SESSION_LOCKS: OnceLock<Mutex<Vec<std::fs::File>>> = OnceLock::new();
3665
3666/// Claim the exclusive lock for a session log.
3667///
3668/// Two rpi processes appending to one JSONL diverge their `sequence` counters
3669/// (duplicate / gapped seqs) and corrupt it. The lock is an OS advisory lock on
3670/// `<path>.lock`: the second process gets a clear "already open" error instead
3671/// of corrupting the log, and the OS releases it automatically when the holder
3672/// exits (no stale-lock cleanup). Re-claiming a path this process already holds
3673/// is a no-op, so the TUI's session hot-switch can re-open freely.
3674fn claim_session_lock(session_path: &str) -> Result<(), String> {
3675    claim_session_lock_inner(session_path, true)
3676}
3677fn claim_session_lock_inner(session_path: &str, allow_reentry: bool) -> Result<(), String> {
3678    let canonical =
3679        std::fs::canonicalize(session_path).unwrap_or_else(|_| PathBuf::from(session_path));
3680    let lock_path = PathBuf::from(format!("{}.lock", canonical.display()));
3681    let mut paths = SESSION_LOCK_PATHS
3682        .get_or_init(|| Mutex::new(HashSet::new()))
3683        .lock()
3684        .unwrap();
3685    if paths.contains(&lock_path) {
3686        return if allow_reentry {
3687            Ok(())
3688        } else {
3689            Err("transition session already has a writer in this process".into())
3690        };
3691    }
3692    let file = std::fs::OpenOptions::new()
3693        .create(true)
3694        .write(true)
3695        .open(&lock_path)
3696        .map_err(|e| format!("open session lock {}: {e}", lock_path.display()))?;
3697    if fs2::FileExt::try_lock_exclusive(&file).is_err() {
3698        return Err(format!(
3699            "session {session_path} is already open in another rpi process"
3700        ));
3701    }
3702    SESSION_LOCKS
3703        .get_or_init(|| Mutex::new(Vec::new()))
3704        .lock()
3705        .unwrap()
3706        .push(file);
3707    paths.insert(lock_path);
3708    Ok(())
3709}
3710
3711/// A restored session together with the working directory it was created in.
3712///
3713/// The recorded cwd is data for the host's notices, never a silent adoption:
3714/// the launch path adopts the directory in [`app`](crate::app) before any
3715/// resource loads (tools, extensions, system prompt, and trust all key off it),
3716/// and the `/session` hot-switch does not move the running tools at all. Either
3717/// way the user hears about the directory instead of the process quietly
3718/// working somewhere unexpected.
3719pub(crate) struct RestoredSession {
3720    pub session: Session,
3721    /// The directory recorded in the session header, when it still exists.
3722    /// `None` when the record is empty or the path is gone (or is a file).
3723    pub recorded_cwd: Option<PathBuf>,
3724}
3725
3726/// Wrap an opened [`JsonlSessionStorage`] in the `Session` facade (shared by
3727/// startup restore + TUI hot-switch).
3728///
3729/// `sessions_root` is passed in rather than re-derived from `cwd`: after a
3730/// restore adopts the session's directory, `default_session_dir(cwd)` would
3731/// name the ADOPTED project's tree, which is not where this session was found.
3732async fn open_session(
3733    meta: &rpi_harness::session::jsonl::JsonlSessionMetadata,
3734    cwd: &str,
3735    sessions_root: &Path,
3736) -> Result<RestoredSession, BuildError> {
3737    use rpi_harness::session::jsonl::{JsonlSessionRepo, JsonlSessionRepoOptions};
3738    use rpi_harness::session::types::SessionStorage;
3739    use rpi_tools::FileSystem;
3740
3741    let dir = sessions_root;
3742    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3743    let fs: Arc<dyn FileSystem> = env.clone();
3744    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3745        fs: fs.clone(),
3746        sessions_root: dir.to_string_lossy().into_owned(),
3747        clock: Arc::new(SystemClock),
3748        ids: Arc::new(DefaultIdGenerator::new()),
3749    });
3750    claim_session_lock(&meta.path).map_err(BuildError::SessionOpen)?;
3751    let storage = repo
3752        .open_by_jsonl_metadata(meta)
3753        .await
3754        .map_err(|e| BuildError::SessionOpen(format!("open {}: {e}", meta.path)))?;
3755    // Native saved sessions predating progressFormat remain read-only to plain
3756    // storage loaders. Only this locked, not-yet-shared restore opts into writes.
3757    let storage = storage
3758        .enable_progress_under_exclusive_lock()
3759        .await
3760        .map_err(|e| BuildError::SessionOpen(format!("upgrade {}: {e}", meta.path)))?;
3761    let storage_arc: Arc<dyn SessionStorage> = Arc::new(storage);
3762    Ok(RestoredSession {
3763        session: Session::new(storage_arc, None),
3764        recorded_cwd: crate::session_cwd::resolve_session_cwd(Path::new(&meta.cwd)),
3765    })
3766}
3767
3768/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`
3769/// and for `/new` inside such a run — an ephemeral launch keeps swapping in
3770/// in-memory sessions, never a JSONL file.
3771pub(crate) fn ephemeral_session() -> Session {
3772    let storage = Arc::new(InMemorySessionStorage::new(
3773        SessionMetadata {
3774            id: "ephemeral".into(),
3775            created_at: 0,
3776            parent_session_id: None,
3777        },
3778        Arc::new(SystemClock),
3779        Arc::new(DefaultIdGenerator::new()),
3780    ));
3781    Session::new(storage, None).with_volatile_progress()
3782}
3783
3784/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
3785///
3786/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
3787/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
3788/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
3789/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
3790///
3791/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
3792/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
3793/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
3794pub(crate) async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
3795    create_jsonl_session_with_id(dir, cwd, None).await
3796}
3797
3798/// `create_jsonl_session` with an explicit id (the `--session-id` fixed-id
3799/// contract: the file is named with the given id so later `--session-id`
3800/// launches restore the same session).
3801pub(crate) async fn create_jsonl_session_with_id(
3802    dir: &Path,
3803    cwd: &str,
3804    id: Option<String>,
3805) -> Result<Session, String> {
3806    use rpi_harness::session::jsonl::{
3807        JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
3808    };
3809    use rpi_tools::FileSystem;
3810
3811    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
3812    // relative-path resolution matches the tool env.
3813    let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
3814    let fs: Arc<dyn FileSystem> = env.clone();
3815
3816    let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
3817        fs: fs.clone(),
3818        sessions_root: dir.to_string_lossy().into_owned(),
3819        clock: Arc::new(SystemClock),
3820        ids: Arc::new(DefaultIdGenerator::new()),
3821    });
3822
3823    let opts = JsonlSessionCreateOptions {
3824        id, // fresh uuidv7 when None (--session-id passes the fixed id)
3825        parent_session_id: None,
3826        cwd: cwd.to_string(),
3827        metadata: None,
3828    };
3829    let storage = repo
3830        .create_typed(&opts)
3831        .await
3832        .map_err(|e| format!("create session: {e}"))?;
3833    claim_session_lock(&storage.jsonl_metadata().path)
3834        .map_err(|e| format!("create session: {e}"))?;
3835    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
3836    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
3837    Ok(Session::new(storage_arc, None))
3838}
3839
3840/// Read an `--append-system-prompt` target: if it's a readable file path, return
3841/// its contents; otherwise return `None` and let the caller use the literal.
3842fn read_append_target(target: &str, sources: &mut Vec<String>) -> Option<String> {
3843    let path = Path::new(target);
3844    if path.is_file() {
3845        read_prompt_source(path, sources)
3846    } else {
3847        None
3848    }
3849}
3850
3851/// Record provenance at the successful read, rather than rediscovering files
3852/// during rendering (which could report a shadowed or unreadable file).
3853fn read_prompt_source(path: &Path, sources: &mut Vec<String>) -> Option<String> {
3854    let text = std::fs::read_to_string(path).ok()?;
3855    let absolute = if path.is_absolute() {
3856        path.to_path_buf()
3857    } else {
3858        std::env::current_dir()
3859            .map(|cwd| cwd.join(path))
3860            .unwrap_or_else(|_| path.to_path_buf())
3861    };
3862    let source = absolute.to_string_lossy().into_owned();
3863    if !sources.contains(&source) {
3864        sources.push(source);
3865    }
3866    Some(text)
3867}
3868
3869#[cfg(test)]
3870mod goal_outbox_smoke_tests;
3871#[cfg(all(test, feature = "experimental-native-store"))]
3872mod native_roundtrip_tests;
3873#[cfg(all(test, feature = "experimental-native-store"))]
3874mod native_store_tests;
3875#[cfg(test)]
3876mod runtime_session_tests;
3877
3878#[cfg(test)]
3879mod tests {
3880    use super::*;
3881
3882    #[test]
3883    fn settlement_bridge_follows_reload_and_releases_its_session_with_the_bus() {
3884        use rpi_harness::events::{HarnessEvent, HarnessEventBus, RunEndEvent, RunEndOutcome};
3885        use std::sync::atomic::{AtomicUsize, Ordering};
3886        static OLD: AtomicUsize = AtomicUsize::new(0);
3887        static NEW: AtomicUsize = AtomicUsize::new(0);
3888        extern "C" fn old(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
3889            OLD.fetch_add(1, Ordering::SeqCst);
3890            0
3891        }
3892        extern "C" fn new(_: rpi_plugin_sdk::StablePluginEvent, _: *mut std::ffi::c_void) -> i32 {
3893            NEW.fetch_add(1, Ordering::SeqCst);
3894            0
3895        }
3896        let make = |handler: rpi_plugin_sdk::EventHandlerFn| {
3897            let mut registry = rpi_extensions::ExtensionRegistry::new();
3898            registry.register_event_handler(
3899                "settlement".into(),
3900                rpi_plugin_sdk::EventTag::AgentSettled,
3901                handler,
3902                std::ptr::null_mut(),
3903            );
3904            ExtensionSession::from_parts(
3905                Arc::new(registry.snapshot()),
3906                rpi_extensions::PluginKeepalive::empty(),
3907                vec![],
3908                None,
3909            )
3910        };
3911        let sessions = Arc::new(Mutex::new(make(old)));
3912        let weak = Arc::downgrade(&sessions);
3913        let bus = HarnessEventBus::new();
3914        install_extension_settlement_bridge(&bus, sessions.clone());
3915        let ended = |outcome| {
3916            HarnessEvent::RunEnd(RunEndEvent {
3917                lane: "main".into(),
3918                run_id: "run".into(),
3919                outcome,
3920                leaf_id: "leaf".into(),
3921            })
3922        };
3923        bus.emit(&ended(RunEndOutcome::Completed));
3924        assert_eq!(OLD.load(Ordering::SeqCst), 1);
3925        *sessions.lock().unwrap() = make(new);
3926        for outcome in [RunEndOutcome::Aborted, RunEndOutcome::Failed] {
3927            bus.emit(&ended(outcome));
3928        }
3929        assert_eq!(OLD.load(Ordering::SeqCst), 1);
3930        assert_eq!(NEW.load(Ordering::SeqCst), 2);
3931        drop(sessions);
3932        assert!(weak.upgrade().is_some());
3933        drop(bus);
3934        assert!(
3935            weak.upgrade().is_none(),
3936            "settlement observer must not create an ownership cycle"
3937        );
3938    }
3939
3940    #[tokio::test]
3941    async fn unauthenticated_packages_are_not_reintroduced_as_legacy_adapters() {
3942        extern "C" fn request(
3943            _: rpi_plugin_sdk::StbStringRef,
3944            _: *mut rpi_plugin_sdk::StbString,
3945            _: *mut std::ffi::c_void,
3946        ) -> i32 {
3947            1
3948        }
3949        let mut registry = rpi_extensions::ExtensionRegistry::new();
3950        for id in ["package", "legacy"] {
3951            registry.register_provider(rpi_extensions::RegisteredProvider {
3952                provider_id: id.into(),
3953                base_url: "http://localhost".into(),
3954                api_style: "openai-completions".into(),
3955                request_fn: request,
3956                stream_fn: None,
3957                plugin_free_string: rpi_plugin_sdk::callbacks::free_string,
3958                user_data: std::ptr::null_mut(),
3959            });
3960        }
3961        assert!(registry.apply_declaration_json(&serde_json::json!({"providers":[{
3962            "id":"package", "requiresAuth":true,
3963            "models":[rpi_ai::Model::new("model", "Model", rpi_ai::Api::OpenaiCompletions, "package", "http://localhost")]
3964        }]}).to_string()));
3965        let session = ExtensionSession::from_parts(
3966            Arc::new(registry.snapshot()),
3967            rpi_extensions::PluginKeepalive::empty(),
3968            vec![],
3969            None,
3970        );
3971        let providers = build_models_with_extensions_for_reload(
3972            &[],
3973            &session,
3974            tokio::runtime::Handle::current(),
3975        );
3976        assert_eq!(
3977            providers
3978                .iter()
3979                .map(|provider| provider.id())
3980                .collect::<Vec<_>>(),
3981            ["legacy"]
3982        );
3983    }
3984    use crate::args::Args;
3985
3986    #[test]
3987    fn loaded_context_paths_track_precedence_and_reload() {
3988        const CHILD: &str = "RPI_CONTEXT_PATHS_REGRESSION_CHILD";
3989        if std::env::var_os(CHILD).is_none() {
3990            let dir = tempfile::tempdir().unwrap();
3991            let output = std::process::Command::new(std::env::current_exe().unwrap())
3992                .args([
3993                    "--exact",
3994                    "session::tests::loaded_context_paths_track_precedence_and_reload",
3995                    "--nocapture",
3996                ])
3997                .env(CHILD, "1")
3998                .env(crate::config::CONFIG_DIR_ENV, dir.path())
3999                .output()
4000                .unwrap();
4001            assert!(
4002                output.status.success(),
4003                "{}\n{}",
4004                String::from_utf8_lossy(&output.stdout),
4005                String::from_utf8_lossy(&output.stderr)
4006            );
4007            return;
4008        }
4009        tokio::runtime::Builder::new_current_thread()
4010            .enable_all()
4011            .build()
4012            .unwrap()
4013            .block_on(async {
4014                let agent = crate::config::agent_dir().unwrap();
4015                let cwd = agent.join("project");
4016                let project_config = cwd.join(".rpi");
4017                std::fs::create_dir_all(&project_config).unwrap();
4018                for (path, content) in [
4019                    (agent.join("SYSTEM.md"), "GLOBAL_BASE"),
4020                    (agent.join("APPEND_SYSTEM.md"), "GLOBAL_APPEND"),
4021                    (agent.join("AGENTS.md"), "GLOBAL_INSTRUCTIONS"),
4022                    (project_config.join("SYSTEM.md"), "PROJECT_BASE"),
4023                    (project_config.join("APPEND_SYSTEM.md"), "PROJECT_APPEND"),
4024                    (cwd.join("AGENTS.md"), "PROJECT_INSTRUCTIONS"),
4025                    (cwd.join("CLAUDE.md"), "SHADOWED_CLAUDE"),
4026                ] {
4027                    std::fs::write(path, content).unwrap();
4028                }
4029                let resolved = crate::provider::resolve(
4030                    Some("anthropic"),
4031                    Some(crate::provider::DEFAULT_MODEL_ID),
4032                    None,
4033                    Some("test-key"),
4034                    None,
4035                )
4036                .unwrap();
4037                let args = Args {
4038                    no_session: true,
4039                    no_extensions: true,
4040                    no_skills: true,
4041                    no_prompt_templates: true,
4042                    trust_override: Some(true),
4043                    ..Default::default()
4044                };
4045                let (harness, _events, mut context) =
4046                    build(&resolved, &args, &cwd, true).await.unwrap();
4047                let resources = harness.get_resources().await.unwrap();
4048                let path = |p: PathBuf| p.to_string_lossy().into_owned();
4049                assert_eq!(
4050                    &resources.context_files[..2],
4051                    &[
4052                        path(project_config.join("SYSTEM.md")),
4053                        path(project_config.join("APPEND_SYSTEM.md")),
4054                    ]
4055                );
4056                assert!(resources
4057                    .context_files
4058                    .contains(&path(agent.join("AGENTS.md"))));
4059                assert!(resources
4060                    .context_files
4061                    .contains(&path(cwd.join("AGENTS.md"))));
4062                assert!(!resources
4063                    .context_files
4064                    .contains(&path(cwd.join("CLAUDE.md"))));
4065                assert!(!resources
4066                    .context_files
4067                    .contains(&path(agent.join("SYSTEM.md"))));
4068                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4069                assert!(prompt.contains("PROJECT_BASE") && prompt.contains("PROJECT_APPEND"));
4070                assert!(!prompt.contains("GLOBAL_BASE") && !prompt.contains("SHADOWED_CLAUDE"));
4071
4072                // Removing the selected project files makes reload fall back to
4073                // globals; removed instruction paths must disappear as well.
4074                std::fs::remove_file(project_config.join("SYSTEM.md")).unwrap();
4075                std::fs::remove_file(project_config.join("APPEND_SYSTEM.md")).unwrap();
4076                std::fs::remove_file(cwd.join("AGENTS.md")).unwrap();
4077                assert!(
4078                    !reload_extension_resources(&harness, &context)
4079                        .await
4080                        .had_errors
4081                );
4082                let resources = harness.get_resources().await.unwrap();
4083                assert_eq!(
4084                    &resources.context_files[..2],
4085                    &[
4086                        path(agent.join("SYSTEM.md")),
4087                        path(agent.join("APPEND_SYSTEM.md")),
4088                    ]
4089                );
4090                assert!(resources
4091                    .context_files
4092                    .contains(&path(cwd.join("CLAUDE.md"))));
4093                assert!(!resources
4094                    .context_files
4095                    .contains(&path(project_config.join("SYSTEM.md"))));
4096
4097                // Explicit append files are listed; literal text and overridden
4098                // discovered base/append files are not invented as file sources.
4099                let explicit = agent.join("custom-append.md");
4100                std::fs::write(&explicit, "EXPLICIT_APPEND").unwrap();
4101                context.args.system_prompt = Some("EXPLICIT_BASE".into());
4102                context.args.append_system_prompt =
4103                    vec![path(explicit.clone()), "literal append".into()];
4104                context.args.no_context_files = true;
4105                assert!(
4106                    !reload_extension_resources(&harness, &context)
4107                        .await
4108                        .had_errors
4109                );
4110                assert_eq!(
4111                    harness.get_resources().await.unwrap().context_files,
4112                    vec![path(explicit)]
4113                );
4114
4115                // The reload must honor the startup opt-out from project context.
4116                context.args.system_prompt = None;
4117                context.args.append_system_prompt.clear();
4118                context.args.no_context_files = false;
4119                context.args.trust_override = Some(false);
4120                assert!(
4121                    !reload_extension_resources(&harness, &context)
4122                        .await
4123                        .had_errors
4124                );
4125                assert!(harness
4126                    .get_resources()
4127                    .await
4128                    .unwrap()
4129                    .context_files
4130                    .is_empty());
4131                let prompt = harness.get_system_prompt().await.unwrap().unwrap();
4132                assert!(
4133                    !prompt.contains("SHADOWED_CLAUDE") && !prompt.contains("GLOBAL_INSTRUCTIONS")
4134                );
4135            });
4136    }
4137
4138    #[test]
4139    fn prompt_source_read_failures_and_literals_do_not_create_file_provenance() {
4140        let dir = tempfile::tempdir().unwrap();
4141        let file = dir.path().join("SYSTEM.md");
4142        let mut sources = Vec::new();
4143        assert!(read_prompt_source(&file, &mut sources).is_none());
4144        std::fs::write(&file, [0xff, 0xfe]).unwrap();
4145        assert!(read_prompt_source(&file, &mut sources).is_none());
4146        assert!(read_append_target("literal instructions", &mut sources).is_none());
4147        assert!(sources.is_empty());
4148        std::fs::write(&file, "loaded instructions").unwrap();
4149        assert_eq!(
4150            read_prompt_source(&file, &mut sources).as_deref(),
4151            Some("loaded instructions")
4152        );
4153        assert!(read_prompt_source(&file, &mut sources).is_some());
4154        assert_eq!(sources, vec![file.to_string_lossy().into_owned()]);
4155    }
4156
4157    extern "C" fn deny_test_tool_call(
4158        _event: rpi_plugin_sdk::StablePluginEvent,
4159        _data: *mut std::ffi::c_void,
4160    ) -> i32 {
4161        rpi_plugin_sdk::EVENT_HANDLER_ABORT
4162    }
4163
4164    async fn invoke_test_tool_hook(
4165        hook: &rpi_agent::BeforeToolCall,
4166    ) -> Option<rpi_agent::BeforeToolCallResult> {
4167        let message = rpi_ai::providers::faux::faux_assistant_message(
4168            "approval",
4169            rpi_ai::types::StopReason::ToolUse,
4170        );
4171        let rpi_ai::types::Content::ToolCall(call) =
4172            rpi_ai::types::Content::tool_call("call", "read", serde_json::json!({"path":"x"}))
4173        else {
4174            unreachable!()
4175        };
4176        hook(
4177            rpi_agent::BeforeToolCallContext {
4178                assistant_message: &message,
4179                tool_call: &call,
4180                args: &call.arguments,
4181                context: &rpi_agent::AgentContext::default(),
4182            },
4183            tokio_util::sync::CancellationToken::new(),
4184        )
4185        .await
4186    }
4187
4188    #[tokio::test]
4189    async fn tool_call_hook_uses_reloaded_session_and_newly_discovered_gate() {
4190        let sessions = Arc::new(Mutex::new(ExtensionSession::none()));
4191        let hook =
4192            extension_tool_call_hook(sessions.clone(), rpi_extensions::UiDialogMailbox::new());
4193        assert!(invoke_test_tool_hook(&hook).await.is_none());
4194        for name in ["first-gate", "reloaded-gate"] {
4195            let mut registry = rpi_extensions::ExtensionRegistry::new();
4196            registry.register_event_handler(
4197                name.into(),
4198                rpi_plugin_sdk::EventTag::ToolCall,
4199                deny_test_tool_call,
4200                std::ptr::null_mut(),
4201            );
4202            let fresh = ExtensionSession::from_parts(
4203                Arc::new(registry.snapshot()),
4204                rpi_extensions::PluginKeepalive::empty(),
4205                Vec::new(),
4206                None,
4207            );
4208            {
4209                let mut current = sessions.lock().unwrap();
4210                if let Some(snapshot) = current.snapshot() {
4211                    snapshot.active_flag().store(false, Ordering::SeqCst);
4212                }
4213                *current = fresh;
4214            }
4215            let result = invoke_test_tool_hook(&hook).await.unwrap();
4216            assert!(result.block);
4217            assert!(result.reason.unwrap().contains(name));
4218        }
4219        *sessions.lock().unwrap() = ExtensionSession::none();
4220        assert!(invoke_test_tool_hook(&hook).await.is_none());
4221    }
4222
4223    /// A unique scratch file for one summary test.
4224    fn scratch(name: &str) -> PathBuf {
4225        let nanos = std::time::SystemTime::now()
4226            .duration_since(std::time::UNIX_EPOCH)
4227            .unwrap()
4228            .as_nanos();
4229        let path = std::env::temp_dir().join(format!(
4230            "rpi-summary-{}-{nanos}-{name}.jsonl",
4231            std::process::id()
4232        ));
4233        path
4234    }
4235
4236    #[test]
4237    fn summary_marks_a_header_only_session_empty() {
4238        let path = scratch("empty");
4239        std::fs::write(
4240            &path,
4241            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4242        )
4243        .unwrap();
4244        let summary = summarize_session_file(&path);
4245        assert!(summary.empty, "a header-only file has nothing to resume");
4246        assert!(summary.preview.is_none());
4247        assert_eq!(summary.bytes, std::fs::metadata(&path).unwrap().len());
4248        std::fs::remove_file(&path).ok();
4249    }
4250
4251    #[test]
4252    fn summary_reads_the_first_user_prompt_and_name() {
4253        let path = scratch("content");
4254        let body = concat!(
4255            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4256            "{\"kind\":\"fact\",\"seq\":1,\"fact\":\"name\",\"name\":\"fix resume picker\"}\n",
4257            "{\"kind\":\"record\",\"type\":\"operation_started\",\"id\":\"r1\"}\n",
4258            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"kind\":\"user\",\"role\":\"user\",\"content\":\"read the rpi -r picker\"}}\n",
4259            "{\"kind\":\"entry\",\"lane\":\"main\",\"type\":\"message\",\"id\":\"e2\",\"message\":{\"kind\":\"assistant\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second\"}]}}\n",
4260        );
4261        std::fs::write(&path, body).unwrap();
4262        let summary = summarize_session_file(&path);
4263        assert!(!summary.empty);
4264        assert_eq!(summary.name.as_deref(), Some("fix resume picker"));
4265        assert_eq!(summary.preview.as_deref(), Some("read the rpi -r picker"));
4266        std::fs::remove_file(&path).ok();
4267    }
4268
4269    #[test]
4270    fn summary_extracts_a_block_style_user_prompt() {
4271        let path = scratch("blocks");
4272        let body = concat!(
4273            "{\"kind\":\"header\",\"version\":4,\"id\":\"abc\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4274            "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"line one\\nline two\"}]}}\n",
4275        );
4276        std::fs::write(&path, body).unwrap();
4277        let summary = summarize_session_file(&path);
4278        assert_eq!(summary.preview.as_deref(), Some("line one line two"));
4279        assert_eq!(session_display_label(&summary), "line one line two");
4280        std::fs::remove_file(&path).ok();
4281    }
4282
4283    fn meta_for(path: &Path, id: &str) -> rpi_harness::session::jsonl::JsonlSessionMetadata {
4284        rpi_harness::session::jsonl::JsonlSessionMetadata {
4285            id: id.to_string(),
4286            created_at: 0,
4287            cwd: ".".to_string(),
4288            path: path.to_string_lossy().into_owned(),
4289            modified_at: 0,
4290            source_format: rpi_harness::session::jsonl::JsonlSourceFormat::V4,
4291            parent_session_id: None,
4292            legacy_parent_session_path: None,
4293            metadata: None,
4294        }
4295    }
4296
4297    #[test]
4298    fn continue_skips_newer_empty_sessions() {
4299        let empty = scratch("newest-empty");
4300        std::fs::write(
4301            &empty,
4302            "{\"kind\":\"header\",\"version\":4,\"id\":\"empty\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4303        )
4304        .unwrap();
4305        let content = scratch("older-content");
4306        std::fs::write(
4307            &content,
4308            concat!(
4309                "{\"kind\":\"header\",\"version\":4,\"id\":\"old\",\"createdAt\":1,\"cwd\":\"x\"}\n",
4310                "{\"kind\":\"entry\",\"type\":\"message\",\"id\":\"e1\",\"message\":{\"role\":\"user\",\"content\":\"hello\"}}\n",
4311            ),
4312        )
4313        .unwrap();
4314
4315        let metas = vec![meta_for(&empty, "empty"), meta_for(&content, "old")];
4316        let picked = first_resumable_session(&metas).expect("a resumable session");
4317        assert_eq!(picked.id, "old");
4318
4319        // When every session is empty, fall back to the newest rather than
4320        // erroring.
4321        let all_empty = vec![meta_for(&empty, "empty")];
4322        assert_eq!(first_resumable_session(&all_empty).unwrap().id, "empty");
4323
4324        std::fs::remove_file(&empty).ok();
4325        std::fs::remove_file(&content).ok();
4326    }
4327
4328    #[tokio::test]
4329    async fn import_preserves_validates_and_locks_the_recovery_pair() {
4330        use rpi_harness::session::jsonl::{JsonlSessionStorage, JsonlV4Header};
4331        use rpi_harness::session::types::{
4332            AssistantFrameOp, AssistantFrameRecord, RecordBase, SessionStorage,
4333        };
4334        let tmp = tempfile::tempdir().unwrap();
4335        let source = tmp.path().join("source.jsonl");
4336        let progress = tmp.path().join("source.jsonl.progress.jsonl");
4337        let fs: Arc<dyn rpi_tools::FileSystem> =
4338            Arc::new(OsExecutionEnv::with_cwd(tmp.path().into()));
4339        let storage = JsonlSessionStorage::create(
4340            fs,
4341            &source.to_string_lossy(),
4342            JsonlV4Header::new("import-pair".into(), 1, "/".into(), None, None, None),
4343            Arc::new(SystemClock),
4344            Arc::new(DefaultIdGenerator::new()),
4345        )
4346        .await
4347        .unwrap();
4348        storage
4349            .append_progress(vec![AssistantFrameRecord {
4350                base: RecordBase {
4351                    id: "accepted-prefix".into(),
4352                    seq: 0,
4353                    lane: "main".into(),
4354                    timestamp: 0,
4355                },
4356                run_id: "run".into(),
4357                stream_index: 0,
4358                op: AssistantFrameOp::Append,
4359                frame: Some(serde_json::json!({"payload":"keep"})),
4360            }])
4361            .await
4362            .unwrap();
4363        drop(storage);
4364        let original = std::fs::read(&progress).unwrap();
4365        let destination = tmp.path().join("imported");
4366        let imported = import_session_file(&source, &tmp.path().to_string_lossy(), &destination)
4367            .await
4368            .unwrap();
4369        assert_eq!(
4370            imported.read_progress("run").await.unwrap()[0].base.id,
4371            "accepted-prefix"
4372        );
4373        assert_eq!(
4374            std::fs::read(&progress).unwrap(),
4375            original,
4376            "import must not repair or mutate its source"
4377        );
4378        let lock = std::fs::OpenOptions::new()
4379            .create(true)
4380            .write(true)
4381            .open(tmp.path().join("source.jsonl.lock"))
4382            .unwrap();
4383        fs2::FileExt::try_lock_exclusive(&lock).unwrap();
4384        assert!(import_session_file(
4385            &source,
4386            &tmp.path().to_string_lossy(),
4387            &tmp.path().join("busy")
4388        )
4389        .await
4390        .is_err());
4391        drop(lock);
4392        let mut corrupt = String::from_utf8(original.clone()).unwrap();
4393        corrupt = corrupt.replace(
4394            "\"session_id\":\"import-pair\"",
4395            "\"session_id\":\"foreign\"",
4396        );
4397        assert_ne!(corrupt.as_bytes(), original.as_slice());
4398        std::fs::write(&progress, corrupt).unwrap();
4399        assert!(import_session_file(
4400            &source,
4401            &tmp.path().to_string_lossy(),
4402            &tmp.path().join("foreign")
4403        )
4404        .await
4405        .is_err());
4406        std::fs::remove_file(&progress).unwrap();
4407        assert!(import_session_file(
4408            &source,
4409            &tmp.path().to_string_lossy(),
4410            &tmp.path().join("missing")
4411        )
4412        .await
4413        .is_err());
4414    }
4415
4416    #[test]
4417    fn list_session_files_sync_walks_the_cwd_subdirectory() {
4418        let root = std::env::temp_dir().join(format!("rpi-summary-{}-dir", std::process::id()));
4419        let nested = root.join("--proj--");
4420        std::fs::create_dir_all(&nested).unwrap();
4421        let file = nested.join("2026-01-01_a.jsonl");
4422        std::fs::write(&file, "{}\n").unwrap();
4423        let sidecar = nested.join("2026-01-01_a.jsonl.progress.jsonl");
4424        std::fs::write(&sidecar, "{}\n").unwrap();
4425        let found = list_session_files_sync(&root);
4426        assert!(found.iter().any(|p| p == &file));
4427        assert!(!found.contains(&sidecar));
4428        std::fs::remove_dir_all(&root).ok();
4429    }
4430
4431    /// The default CLI tool set plus the `todo` tool its bundled plugins add.
4432    fn default_prompt_tools() -> Vec<String> {
4433        BUILTIN_TOOL_NAMES
4434            .iter()
4435            .copied()
4436            .chain(["todo"])
4437            .map(str::to_owned)
4438            .collect()
4439    }
4440
4441    #[test]
4442    fn default_prompt_lists_only_the_active_tools() {
4443        let tools = ["read", "grep", "find", "ls"].map(str::to_owned);
4444        let p = default_system_prompt_with_tools("/tmp/proj", true, &tools);
4445        assert!(p.contains("- read  — Read file contents"));
4446        assert!(p.contains("- grep  — Search file contents"));
4447        for absent in ["- edit", "- write", "- bash", "- docs", "todo", "Windows"] {
4448            assert!(
4449                !p.contains(absent),
4450                "{absent} must not appear for a read-only run: {p}"
4451            );
4452        }
4453        assert!(p.contains("Re-stating the same plan without acting on it is a bug"));
4454        let none = default_system_prompt_with_tools("/tmp/proj", false, &[]);
4455        assert!(none.contains("Available tools:\n(none)"));
4456    }
4457
4458    #[test]
4459    fn default_prompt_mentions_cwd_and_tools() {
4460        let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4461        assert!(p.contains("/tmp/proj"));
4462        assert!(p.contains("read"));
4463        assert!(p.contains("bash"));
4464        assert!(p.contains("edit"));
4465        assert!(p.contains("write"));
4466        assert!(p.contains("docs"));
4467        assert!(!p.contains("- grep"));
4468        assert!(!p.contains("- find"));
4469        assert!(!p.contains("- ls"));
4470        assert!(!p.contains("- powershell"));
4471        assert!(p.contains("Bash/POSIX syntax, also on Windows"));
4472    }
4473
4474    #[test]
4475    fn default_prompt_instructs_language_matching() {
4476        // A Chinese (or any non-English) user gets a same-language reply
4477        // without having to ask every turn.
4478        let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4479        assert!(
4480            p.contains("same language as the user's latest message"),
4481            "the prompt must tell the model to mirror the user's language: {p}"
4482        );
4483    }
4484
4485    #[test]
4486    fn default_prompt_keeps_working_state_in_the_visible_channel() {
4487        // Regression guard for the re-plan loop: on openai-compatible providers
4488        // the assistant's `thinking` blocks are dropped when the next request is
4489        // built (`providers/openai_completions.rs`: `Content::text_only`), so a
4490        // plan that lives only in reasoning is re-derived every turn. One
4491        // observed session paid for that 112 times in a single run, and twice
4492        // hallucinated the user's input ("The user is greeting me with hello",
4493        // with no such message). See `docs/llm-repetition-forensics.md` §八.
4494        let p = default_system_prompt_with_tools("/tmp/proj", false, &default_prompt_tools());
4495        let lower = p.to_lowercase();
4496        // The rule must state the mechanism, not just "be clear".
4497        assert!(
4498            lower.contains("reasoning is not") && lower.contains("carried into your next turn"),
4499            "the prompt must explain that reasoning does not survive the turn: {p}"
4500        );
4501        // Working state stays visible; extension-specific rules require opt-in.
4502        assert!(
4503            lower.contains("visible") && !lower.contains("todo tool"),
4504            "the prompt must keep state visible without inventing extension rules: {p}"
4505        );
4506        // …plus the anti-repetition rule itself.
4507        assert!(
4508            lower.contains("re-stating the same plan"),
4509            "the prompt must call out plan re-statement as a bug: {p}"
4510        );
4511        // And the guidance must not instruct re-reading what is already shown.
4512        assert!(
4513            lower.contains("already shown earlier"),
4514            "the prompt must discourage re-fetching known content: {p}"
4515        );
4516    }
4517
4518    #[test]
4519    fn default_prompt_does_not_claim_reasoning_is_lost_when_it_is_replayed() {
4520        // `compat.requiresThinkingAsText` (and the native thinking channels of
4521        // anthropic-messages / openai-responses) DO carry the model's reasoning
4522        // back on the next request. Telling such a model "reasoning is not
4523        // carried into your next turn" is false, and the instruction to restate
4524        // the plan every turn is exactly the busy-work the re-plan analysis
4525        // measured. The wording must flip with the mechanism.
4526        let p = default_system_prompt_with_tools("/tmp/proj", true, &default_prompt_tools());
4527        let lower = p.to_lowercase();
4528        assert!(
4529            lower.contains("carried back"),
4530            "a replaying model must be told its reasoning survives: {p}"
4531        );
4532        assert!(
4533            !lower.contains("reasoning is not"),
4534            "the replayed variant must not claim reasoning is dropped: {p}"
4535        );
4536        assert!(
4537            lower.contains("do not re-derive"),
4538            "the replayed variant must forbid re-deriving the plan: {p}"
4539        );
4540        assert!(
4541            lower.contains("do not re-derive") && lower.contains("re-stating the same plan"),
4542            "the anti-repetition rule must survive the flip: {p}"
4543        );
4544        assert!(
4545            lower.contains("already shown earlier"),
4546            "the re-read rule must survive the flip: {p}"
4547        );
4548    }
4549
4550    #[test]
4551    fn default_prompt_states_each_rule_once() {
4552        // The plan rule, the todo tool and the anti-repetition line used to be
4553        // spread over two guidelines that repeated each other.
4554        for replayed in [false, true] {
4555            let p =
4556                default_system_prompt_with_tools("/tmp/proj", replayed, &default_prompt_tools());
4557            for phrase in ["the same plan", "Windows", "guessing"] {
4558                assert_eq!(p.matches(phrase).count(), 1, "{phrase:?} repeated: {p}");
4559            }
4560            assert!(
4561                !p.contains("todo tool"),
4562                "extension rules require explicit metadata"
4563            );
4564        }
4565    }
4566
4567    /// Only side-effect-free tools may be replayed after a crash.
4568    ///
4569    /// Regression guard for an inverted default: `ToolReplay` used to default to
4570    /// `Safe` and the CLI then marked *every* tool `Safe`, so the first time
4571    /// recovery acted on the flag it would have re-run `bash`/`write`/`edit` and
4572    /// duplicated their side effects. upstream defaults to `"never"`
4573    /// (`drive/tools.ts: tool.replay ?? "never"`).
4574    #[test]
4575    fn only_read_only_tools_are_replayable() {
4576        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4577        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4578        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4579        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4580        let tools = build_tools(&context, &Args::default());
4581
4582        let replay_of = |name: &str| {
4583            tools
4584                .iter()
4585                .find(|tool| tool.tool.schema().name == name)
4586                .map(|tool| tool.replay)
4587        };
4588
4589        for read_only in ["read", "docs"] {
4590            assert_eq!(
4591                replay_of(read_only),
4592                Some(ToolReplay::Safe),
4593                "{read_only} is side-effect free, so recovery may re-run it"
4594            );
4595        }
4596        for mutating in ["bash", "edit", "write", "powershell"] {
4597            if let Some(replay) = replay_of(mutating) {
4598                assert_eq!(
4599                    replay,
4600                    ToolReplay::Never,
4601                    "{mutating} must never be replayed: re-running it would duplicate its effect"
4602                );
4603            }
4604        }
4605    }
4606
4607    #[test]
4608    fn default_tools_include_docs_lookup() {
4609        let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(".")));
4610        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
4611        let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
4612        let context = ExecutionToolContext::new(env_dyn, Some(mut_env));
4613        let names: Vec<String> = build_tools(&context, &Args::default())
4614            .iter()
4615            .map(|tool| tool.tool.schema().name.clone())
4616            .collect();
4617
4618        // `docs` is a default capability; the legacy rpi-only lookup tools stay
4619        // unregistered. Asserted by membership (not exact equality) because
4620        // `powershell` is Windows-only and `settings.json#defaultTools` may
4621        // narrow the set in a developer's environment.
4622        assert!(names.contains(&"docs".to_string()));
4623        for legacy in ["grep", "find", "ls"] {
4624            assert!(
4625                !names.contains(&legacy.to_string()),
4626                "legacy tool {legacy} should not be registered by default"
4627            );
4628        }
4629    }
4630
4631    #[test]
4632    fn reload_settings_preflight_respects_trust() {
4633        struct RestoreConfigDir(Option<std::ffi::OsString>);
4634
4635        impl Drop for RestoreConfigDir {
4636            fn drop(&mut self) {
4637                match self.0.take() {
4638                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4639                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4640                }
4641            }
4642        }
4643
4644        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4645        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4646        let tmp = tempfile::tempdir().unwrap();
4647        let agent = tmp.path().join("agent");
4648        let cwd = tmp.path().join("project");
4649        std::fs::create_dir_all(&agent).unwrap();
4650        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4651        std::fs::write(agent.join("settings.json"), "{}").unwrap();
4652        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
4653        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4654        let args = Args::default();
4655
4656        let trusted = validate_settings_for_reload(&args, &cwd, true);
4657        let untrusted = validate_settings_for_reload(&args, &cwd, false);
4658        assert!(trusted
4659            .unwrap_err()
4660            .contains("could not load project settings"));
4661        assert!(untrusted.is_ok());
4662
4663        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4664        assert!(validate_settings_for_reload(&args, &cwd, false)
4665            .unwrap_err()
4666            .contains("could not load global settings"));
4667
4668        let local_only = Args {
4669            dev_local_only: true,
4670            ..Args::default()
4671        };
4672        assert!(validate_settings_for_reload(&local_only, &cwd, true).is_ok());
4673    }
4674
4675    #[tokio::test(flavor = "current_thread")]
4676    async fn reload_preserves_live_resources_when_settings_break() {
4677        struct RestoreConfigDir(Option<std::ffi::OsString>);
4678
4679        impl Drop for RestoreConfigDir {
4680            fn drop(&mut self) {
4681                match self.0.take() {
4682                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4683                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4684                }
4685            }
4686        }
4687
4688        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4689        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4690        let tmp = tempfile::tempdir().unwrap();
4691        let agent = tmp.path().join("agent");
4692        let cwd = tmp.path().join("project");
4693        let skill_dir = tmp.path().join("configured-skills");
4694        std::fs::create_dir_all(&agent).unwrap();
4695        std::fs::create_dir_all(&cwd).unwrap();
4696        std::fs::create_dir_all(&skill_dir).unwrap();
4697        std::fs::write(
4698            skill_dir.join("SKILL.md"),
4699            "---\nname: keep-me\ndescription: Reload sentinel\n---\nKeep this skill loaded.",
4700        )
4701        .unwrap();
4702        std::fs::write(
4703            agent.join("settings.json"),
4704            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4705        )
4706        .unwrap();
4707        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4708
4709        let resolved = crate::provider::resolve(
4710            Some("anthropic"),
4711            Some(crate::provider::DEFAULT_MODEL_ID),
4712            None,
4713            Some("test-key"),
4714            None,
4715        )
4716        .unwrap();
4717        let args = Args {
4718            trust_override: Some(false),
4719            no_session: true,
4720            no_extensions: true,
4721            no_prompt_templates: true,
4722            no_context_files: true,
4723            system_prompt: Some("stable system prompt".into()),
4724            ..Args::default()
4725        };
4726        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4727        let before_resources = harness.get_resources().await.unwrap();
4728        assert_eq!(before_resources.skills.as_ref().unwrap().len(), 1);
4729        assert_eq!(before_resources.skills.as_ref().unwrap()[0].name, "keep-me");
4730        let before_prompt = harness.get_system_prompt().await.unwrap();
4731        let before_tools: Vec<String> = harness
4732            .get_tools()
4733            .await
4734            .unwrap()
4735            .iter()
4736            .map(|tool| tool.tool.schema().name.clone())
4737            .collect();
4738        let before_bridge = context
4739            .action_bridge
4740            .lock()
4741            .unwrap()
4742            .as_ref()
4743            .unwrap()
4744            .clone();
4745
4746        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4747        let outcome = reload_extension_resources(&harness, &context).await;
4748
4749        assert!(outcome.had_warnings);
4750        assert!(outcome.summary.contains("Settings reload failed"));
4751        assert!(outcome.summary.contains("could not load global settings"));
4752        // Hard failure keeps the previous resources; the outcome must not
4753        // advertise a fresh trust policy either (theme discovery keeps the
4754        // registry it already matches).
4755        assert_eq!(outcome.project_trusted, None);
4756        assert_eq!(harness.get_resources().await.unwrap(), before_resources);
4757        assert_eq!(harness.get_system_prompt().await.unwrap(), before_prompt);
4758        let after_tools: Vec<String> = harness
4759            .get_tools()
4760            .await
4761            .unwrap()
4762            .iter()
4763            .map(|tool| tool.tool.schema().name.clone())
4764            .collect();
4765        assert_eq!(after_tools, before_tools);
4766        let after_bridge = context
4767            .action_bridge
4768            .lock()
4769            .unwrap()
4770            .as_ref()
4771            .unwrap()
4772            .clone();
4773        assert!(Arc::ptr_eq(&after_bridge, &before_bridge));
4774    }
4775
4776    #[tokio::test(flavor = "current_thread")]
4777    async fn reload_outcome_carries_the_current_project_trust() {
4778        struct RestoreConfigDir(Option<std::ffi::OsString>);
4779
4780        impl Drop for RestoreConfigDir {
4781            fn drop(&mut self) {
4782                match self.0.take() {
4783                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4784                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4785                }
4786            }
4787        }
4788
4789        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4790        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4791        let tmp = tempfile::tempdir().unwrap();
4792        let agent = tmp.path().join("agent");
4793        let cwd = tmp.path().join("project");
4794        std::fs::create_dir_all(&agent).unwrap();
4795        std::fs::create_dir_all(&cwd).unwrap();
4796        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4797
4798        // Startup snapshot: trusted.
4799        crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4800        let resolved = crate::provider::resolve(
4801            Some("anthropic"),
4802            Some(crate::provider::DEFAULT_MODEL_ID),
4803            None,
4804            Some("test-key"),
4805            None,
4806        )
4807        .unwrap();
4808        let args = Args {
4809            no_session: true,
4810            no_extensions: true,
4811            no_prompt_templates: true,
4812            no_context_files: true,
4813            system_prompt: Some("stable system prompt".into()),
4814            ..Args::default()
4815        };
4816        let (harness, _events, context) = build(&resolved, &args, &cwd, true).await.unwrap();
4817        assert!(context.project_trusted, "startup snapshot is trusted");
4818
4819        // /trust no, then /reload: the outcome must report the CURRENT
4820        // decision, not the immutable startup snapshot.
4821        crate::config::set_project_trust(&cwd, Some(false)).unwrap();
4822        let outcome = reload_extension_resources(&harness, &context).await;
4823        assert!(!outcome.had_errors, "{}", outcome.summary);
4824        assert_eq!(outcome.project_trusted, Some(false));
4825
4826        // And the opposite transition back to trusted.
4827        crate::config::set_project_trust(&cwd, Some(true)).unwrap();
4828        let outcome = reload_extension_resources(&harness, &context).await;
4829        assert!(!outcome.had_errors, "{}", outcome.summary);
4830        assert_eq!(outcome.project_trusted, Some(true));
4831    }
4832
4833    #[test]
4834    fn reload_preparation_rejects_settings_changed_during_derivation() {
4835        struct RestoreConfigDir(Option<std::ffi::OsString>);
4836
4837        impl Drop for RestoreConfigDir {
4838            fn drop(&mut self) {
4839                match self.0.take() {
4840                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4841                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4842                }
4843            }
4844        }
4845
4846        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4847        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4848        let tmp = tempfile::tempdir().unwrap();
4849        let agent = tmp.path().join("agent");
4850        let cwd = tmp.path().join("project");
4851        let first_skill_dir = tmp.path().join("first-skills");
4852        let second_skill_dir = tmp.path().join("second-skills");
4853        std::fs::create_dir_all(&agent).unwrap();
4854        std::fs::create_dir_all(&cwd).unwrap();
4855        std::fs::write(
4856            agent.join("settings.json"),
4857            serde_json::json!({"skillDirs": [first_skill_dir]}).to_string(),
4858        )
4859        .unwrap();
4860        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4861        let args = Args {
4862            trust_override: Some(false),
4863            no_extensions: true,
4864            no_prompt_templates: true,
4865            ..Args::default()
4866        };
4867
4868        let result = prepare_reload_inputs_inner(&args, &cwd, false, || {
4869            std::fs::write(
4870                agent.join("settings.json"),
4871                serde_json::json!({"skillDirs": [second_skill_dir]}).to_string(),
4872            )
4873            .unwrap();
4874        });
4875
4876        let error = result
4877            .err()
4878            .expect("settings mutation must fail preparation");
4879        assert!(error.contains("settings changed while reload inputs were being prepared"));
4880    }
4881
4882    #[tokio::test(flavor = "current_thread")]
4883    async fn reload_uses_frozen_settings_inputs_after_preparation() {
4884        struct RestoreConfigDir(Option<std::ffi::OsString>);
4885
4886        impl Drop for RestoreConfigDir {
4887            fn drop(&mut self) {
4888                match self.0.take() {
4889                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
4890                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
4891                }
4892            }
4893        }
4894
4895        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4896        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
4897        let tmp = tempfile::tempdir().unwrap();
4898        let agent = tmp.path().join("agent");
4899        let cwd = tmp.path().join("project");
4900        let skill_dir = tmp.path().join("configured-skills");
4901        std::fs::create_dir_all(&agent).unwrap();
4902        std::fs::create_dir_all(&cwd).unwrap();
4903        std::fs::create_dir_all(&skill_dir).unwrap();
4904        std::fs::write(
4905            skill_dir.join("SKILL.md"),
4906            "---\nname: frozen-skill\ndescription: Reload sentinel\n---\nFrozen input.",
4907        )
4908        .unwrap();
4909        std::fs::write(
4910            agent.join("settings.json"),
4911            serde_json::json!({"skillDirs": [skill_dir]}).to_string(),
4912        )
4913        .unwrap();
4914        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
4915
4916        let resolved = crate::provider::resolve(
4917            Some("anthropic"),
4918            Some(crate::provider::DEFAULT_MODEL_ID),
4919            None,
4920            Some("test-key"),
4921            None,
4922        )
4923        .unwrap();
4924        let args = Args {
4925            trust_override: Some(false),
4926            no_session: true,
4927            no_extensions: true,
4928            no_prompt_templates: true,
4929            no_context_files: true,
4930            system_prompt: Some("stable system prompt".into()),
4931            ..Args::default()
4932        };
4933        let (harness, _events, context) = build(&resolved, &args, &cwd, false).await.unwrap();
4934
4935        let outcome = reload_extension_resources_inner(&harness, &context, || {
4936            std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
4937        })
4938        .await;
4939
4940        assert!(!outcome.summary.contains("Settings reload failed"));
4941        assert_eq!(
4942            outcome.summary,
4943            "Reloaded 0 plugin(s), 1 skill(s), 0 prompt(s)."
4944        );
4945        let resources = harness.get_resources().await.unwrap();
4946        let skills = resources.skills.as_ref().unwrap();
4947        assert_eq!(skills.len(), 1);
4948        assert_eq!(skills[0].name, "frozen-skill");
4949    }
4950
4951    #[tokio::test]
4952    async fn reload_resource_paths_include_explicit_skill_and_prompt_files() {
4953        let tmp = tempfile::tempdir().unwrap();
4954        let skill_path = tmp.path().join("explicit-skill.md");
4955        std::fs::write(
4956            &skill_path,
4957            "---\nname: explicit-skill\ndescription: Explicit skill\n---\nSkill body",
4958        )
4959        .unwrap();
4960        let prompt_path = tmp.path().join("explicit-prompt.md");
4961        std::fs::write(
4962            &prompt_path,
4963            "---\ndescription: Explicit prompt\n---\nPrompt body",
4964        )
4965        .unwrap();
4966
4967        let args = Args {
4968            skill: vec![skill_path.clone()],
4969            prompt_template: vec![prompt_path.clone()],
4970            ..Args::default()
4971        };
4972        let skill_paths = append_reload_resource_paths(Vec::new(), &args.skill, &[], &[], &[]);
4973        let prompt_paths =
4974            append_reload_resource_paths(Vec::new(), &args.prompt_template, &[], &[], &[]);
4975        let env = Arc::new(OsExecutionEnv::with_cwd(tmp.path().to_path_buf()));
4976        let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env;
4977
4978        let skills = load_skills_with_precedence(&env_dyn, &skill_paths).await;
4979        assert_eq!(skills.skills.len(), 1, "{:?}", skills.diagnostics);
4980        assert_eq!(skills.skills[0].name, "explicit-skill");
4981
4982        let prompts = load_prompt_templates_with_precedence(&env_dyn, &prompt_paths).await;
4983        assert_eq!(
4984            prompts.prompt_templates.len(),
4985            1,
4986            "{:?}",
4987            prompts.diagnostics
4988        );
4989        assert_eq!(prompts.prompt_templates[0].name, "explicit-prompt");
4990    }
4991
4992    #[test]
4993    fn tool_policy_applies_to_rust_and_js_active_names() {
4994        let names = vec![
4995            "read".to_string(),
4996            "ask_user_question".to_string(),
4997            "write".to_string(),
4998        ];
4999
5000        let args = Args {
5001            tools: Some(vec!["read".into(), "ask_user_question".into()]),
5002            ..Args::default()
5003        };
5004        assert_eq!(
5005            filter_active_tool_names(names.clone(), &args),
5006            vec!["read", "ask_user_question"]
5007        );
5008
5009        let args = Args {
5010            exclude_tools: Some(vec!["ask_user_question".into()]),
5011            ..Args::default()
5012        };
5013        assert_eq!(
5014            filter_active_tool_names(names.clone(), &args),
5015            vec!["read", "write"]
5016        );
5017
5018        let args = Args {
5019            no_tools: true,
5020            ..Args::default()
5021        };
5022        assert!(filter_active_tool_names(names, &args).is_empty());
5023    }
5024
5025    #[test]
5026    fn select_ephemeral_when_no_session() {
5027        let args = Args {
5028            no_session: true,
5029            ..Args::default()
5030        };
5031        let cwd = Path::new("/tmp");
5032        assert!(matches!(
5033            select_session(&args, cwd),
5034            SessionSelection::Ephemeral
5035        ));
5036    }
5037
5038    #[test]
5039    fn project_resources_load_by_default_and_allow_explicit_opt_out() {
5040        let defaults = Args::default();
5041        assert!(resolve_project_trust(
5042            &defaults,
5043            Path::new("C:/definitely-not-a-project")
5044        ));
5045
5046        let denied = Args {
5047            trust_override: Some(false),
5048            ..Args::default()
5049        };
5050        assert!(!resolve_project_trust(
5051            &denied,
5052            Path::new("C:/definitely-not-a-project")
5053        ));
5054    }
5055
5056    #[tokio::test(flavor = "current_thread")]
5057    async fn build_preserves_the_supplied_startup_project_snapshot() {
5058        struct RestoreConfigDir(Option<std::ffi::OsString>);
5059
5060        impl Drop for RestoreConfigDir {
5061            fn drop(&mut self) {
5062                match self.0.take() {
5063                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5064                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5065                }
5066            }
5067        }
5068
5069        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5070        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5071        let tmp = tempfile::tempdir().unwrap();
5072        let agent = tmp.path().join("agent");
5073        let cwd = tmp.path().join("project");
5074        std::fs::create_dir_all(&agent).unwrap();
5075        std::fs::create_dir_all(&cwd).unwrap();
5076        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5077
5078        let resolved = crate::provider::resolve(
5079            Some("anthropic"),
5080            Some(crate::provider::DEFAULT_MODEL_ID),
5081            None,
5082            Some("test-key"),
5083            None,
5084        )
5085        .unwrap();
5086        let args = Args {
5087            trust_override: Some(false),
5088            no_session: true,
5089            no_tools: true,
5090            no_extensions: true,
5091            no_skills: true,
5092            no_prompt_templates: true,
5093            no_context_files: true,
5094            system_prompt: Some("test prompt".into()),
5095            ..Args::default()
5096        };
5097
5098        let (_, _, context) = build(&resolved, &args, &cwd, true).await.unwrap();
5099
5100        assert_eq!(context.cwd, cwd);
5101        assert!(context.project_trusted);
5102    }
5103
5104    #[tokio::test(flavor = "current_thread")]
5105    async fn build_applies_the_name_flag_to_the_session() {
5106        struct RestoreConfigDir(Option<std::ffi::OsString>);
5107
5108        impl Drop for RestoreConfigDir {
5109            fn drop(&mut self) {
5110                match self.0.take() {
5111                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5112                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5113                }
5114            }
5115        }
5116
5117        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5118        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5119        let tmp = tempfile::tempdir().unwrap();
5120        let agent = tmp.path().join("agent");
5121        let cwd = tmp.path().join("project");
5122        std::fs::create_dir_all(&agent).unwrap();
5123        std::fs::create_dir_all(&cwd).unwrap();
5124        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5125
5126        let resolved = crate::provider::resolve(
5127            Some("anthropic"),
5128            Some(crate::provider::DEFAULT_MODEL_ID),
5129            None,
5130            Some("test-key"),
5131            None,
5132        )
5133        .unwrap();
5134        let args = Args {
5135            trust_override: Some(false),
5136            no_tools: true,
5137            no_extensions: true,
5138            no_skills: true,
5139            no_prompt_templates: true,
5140            no_context_files: true,
5141            system_prompt: Some("test prompt".into()),
5142            name: Some("smoke name".into()),
5143            session_dir: Some(cwd.join("sessions")),
5144            ..Args::default()
5145        };
5146
5147        let (harness, _, _) = build(&resolved, &args, &cwd, true).await.unwrap();
5148        assert_eq!(
5149            harness.get_name().await.unwrap().as_deref(),
5150            Some("smoke name")
5151        );
5152    }
5153
5154    #[test]
5155    fn select_latest_for_continue_and_resume() {
5156        let _env = crate::config::test_support::SessionDirEnv::new(None);
5157        let args = Args {
5158            continue_session: true,
5159            ..Args::default()
5160        };
5161        let cwd = Path::new("/tmp");
5162        assert!(matches!(
5163            select_session(&args, cwd),
5164            SessionSelection::Latest { .. }
5165        ));
5166
5167        let args = Args {
5168            resume: true,
5169            ..Args::default()
5170        };
5171        assert!(matches!(
5172            select_session(&args, cwd),
5173            SessionSelection::Latest { .. }
5174        ));
5175    }
5176
5177    #[test]
5178    fn select_by_id_for_session_flag() {
5179        let _env = crate::config::test_support::SessionDirEnv::new(None);
5180        let args = Args {
5181            session: Some("01a02ece".into()),
5182            ..Args::default()
5183        };
5184        let cwd = Path::new("/tmp");
5185        assert!(matches!(
5186            select_session(&args, cwd),
5187            SessionSelection::ById { id, .. } if id == "01a02ece"
5188        ));
5189    }
5190
5191    #[test]
5192    fn select_new_with_custom_dir() {
5193        let _env = crate::config::test_support::SessionDirEnv::new(None);
5194        let args = Args {
5195            session_dir: Some(PathBuf::from("/tmp/sess")),
5196            ..Args::default()
5197        };
5198        let cwd = Path::new("/tmp");
5199        match select_session(&args, cwd) {
5200            SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
5201            other => panic!("expected New, got {other:?}"),
5202        }
5203    }
5204
5205    #[test]
5206    fn select_new_default_dir() {
5207        let _env = crate::config::test_support::SessionDirEnv::new(None);
5208        let args = Args::default();
5209        let cwd = Path::new("/proj");
5210        match select_session(&args, cwd) {
5211            SessionSelection::New { dir, .. } => {
5212                assert_eq!(dir, Path::new("/proj/.rpi/sessions"));
5213            }
5214            other => panic!("expected New, got {other:?}"),
5215        }
5216    }
5217
5218    #[test]
5219    fn default_session_dir_only_uses_rpi() {
5220        let _env = crate::config::test_support::SessionDirEnv::new(None);
5221        let tmp = tempfile::tempdir().unwrap();
5222        let cwd = tmp.path();
5223        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5224        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5225        std::fs::create_dir_all(cwd.join(".rpi/sessions")).unwrap();
5226        assert_eq!(default_session_dir(cwd), cwd.join(".rpi/sessions"));
5227    }
5228
5229    #[test]
5230    fn default_session_dir_uses_env_var() {
5231        let tmp = tempfile::tempdir().unwrap();
5232        let cwd = tmp.path();
5233        let custom = tmp.path().join("custom_sessions");
5234        let _env = crate::config::test_support::SessionDirEnv::new(Some(&custom));
5235        assert_eq!(default_session_dir(cwd), custom);
5236    }
5237
5238    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5239    async fn ephemeral_session_builds_roundtrips() {
5240        // Sanity: the ephemeral path produces a usable Session facade (the
5241        // harness build itself needs a provider; tested via the integration
5242        // path in tests/build.rs instead).
5243        let s = ephemeral_session();
5244        let leaf = s.get_leaf_id().await;
5245        assert!(leaf.is_ok());
5246    }
5247
5248    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
5249    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
5250
5251    /// The exclusion policy, checked against the constants rather than copies.
5252    ///
5253    /// `rpi-tools` builds its exclusion from `rpi-plugin-sdk`'s declarations, so
5254    /// within this repo a rename is a compile error — this test is not the link
5255    /// between those two crates any more. What it still guards is the part the
5256    /// compiler cannot see:
5257    ///
5258    /// - **The wire values.** `rpi-langfuse` lives in a different repository and
5259    ///   agrees on the *strings*, not the constant paths. Rewriting a constant's
5260    ///   value would compile here and silently stop matching there, so the exact
5261    ///   values are pinned below. Changing one is a deliberate, breaking change to
5262    ///   a cross-repo contract, and it should fail here first.
5263    /// - **Coverage.** Every name the SDK declares must actually be excluded.
5264    /// - **Narrowness.** The user's own configuration must still reach the shell.
5265    #[test]
5266    fn host_private_env_matches_the_declared_contract() {
5267        // 1. The wire values. Pinned literally on purpose: see the doc comment.
5268        assert_eq!(rpi_plugin_sdk::SESSION_ID_ENV, "RPI_SESSION_ID");
5269        assert_eq!(
5270            rpi_plugin_sdk::SUBAGENT_PARENT_ENV,
5271            [
5272                "LANGFUSE_PI_PARENT_TRACE_ID",
5273                "LANGFUSE_PI_PARENT_SPAN_ID",
5274                "LANGFUSE_PI_PARENT_SESSION_ID",
5275                "LANGFUSE_PI_PARENT_DEPTH",
5276            ],
5277            "these strings are what rpi-langfuse (a different repo) matches; a \
5278             change here is a breaking cross-repo change, not a refactor"
5279        );
5280
5281        // 2. Coverage — derived from the declarations, so a newly added family
5282        //    member is checked without anyone remembering to extend this test.
5283        for name in rpi_plugin_sdk::SUBAGENT_PARENT_ENV
5284            .iter()
5285            .chain(std::iter::once(&rpi_plugin_sdk::SESSION_ID_ENV))
5286        {
5287            assert!(
5288                rpi_tools::is_host_private_env(name),
5289                "`{name}` must not reach a spawned shell, but rpi-tools excludes only {:?}",
5290                rpi_tools::HOST_PRIVATE_ENV
5291            );
5292        }
5293
5294        // 3. Narrowness — these name how the *user* configured the run and are
5295        //    exactly what a command inside the shell should still see.
5296        for visible in [
5297            crate::args::RPI_OFFLINE_ENV,
5298            crate::config::CONFIG_DIR_ENV,
5299            "PATH",
5300        ] {
5301            assert!(
5302                !rpi_tools::is_host_private_env(visible),
5303                "{visible} describes the user's configuration and must still reach the shell"
5304            );
5305        }
5306    }
5307    #[tokio::test]
5308    async fn reload_shutdown_joins_all_handlers_even_after_veto() {
5309        use rpi_plugin_sdk::{EventTag, StablePluginEvent};
5310        use std::sync::atomic::AtomicUsize;
5311        static CONTEXTS: rpi_plugin_sdk::handles::ToolHandles<Arc<AtomicUsize>> =
5312            rpi_plugin_sdk::handles::ToolHandles::new();
5313        struct ContextOwner(usize);
5314        impl Drop for ContextOwner {
5315            fn drop(&mut self) {
5316                drop(CONTEXTS.remove(self.0 as rpi_plugin_sdk::StepHandle));
5317            }
5318        }
5319        extern "C" fn stop(_: StablePluginEvent, data: *mut std::ffi::c_void) -> i32 {
5320            let context = CONTEXTS.get(data).expect("live test context");
5321            let calls = context.lock().clone();
5322            std::thread::sleep(std::time::Duration::from_millis(20));
5323            if calls.fetch_add(1, Ordering::SeqCst) == 0 {
5324                rpi_plugin_sdk::EVENT_HANDLER_ABORT
5325            } else {
5326                0
5327            }
5328        }
5329        let calls = Arc::new(AtomicUsize::new(0));
5330        let context = CONTEXTS.insert(calls.clone());
5331        let _owner = ContextOwner(context as usize);
5332        let mut registry = rpi_extensions::ExtensionRegistry::new();
5333        for name in ["first", "second"] {
5334            registry.register_event_handler(name.into(), EventTag::SessionShutdown, stop, context);
5335        }
5336        super::shutdown_extensions_for_reload(
5337            Some(Arc::new(registry.snapshot())),
5338            rpi_extensions::PluginKeepalive::empty(),
5339        )
5340        .await
5341        .unwrap();
5342        assert_eq!(calls.load(Ordering::SeqCst), 2);
5343    }
5344
5345    // ---- restored-session working directory ----
5346
5347    /// A restore peek must name the directory the session was created in —
5348    /// that is the whole point of recording it. Everything downstream (tool
5349    /// env, system prompt, context files, extensions) keys off the run's cwd,
5350    /// so a peek that falls back to the launch directory would leave the
5351    /// resumed agent editing files in whatever directory `rpi` happened to be
5352    /// started from.
5353    #[tokio::test]
5354    async fn a_restored_session_adopts_its_recorded_directory() {
5355        let tmp = tempfile::tempdir().unwrap();
5356        let project = tmp.path().join("project-a");
5357        let launch = tmp.path().join("launch-dir");
5358        let sessions = tmp.path().join("sessions");
5359        std::fs::create_dir_all(&project).unwrap();
5360        std::fs::create_dir_all(&launch).unwrap();
5361        std::fs::create_dir_all(&sessions).unwrap();
5362        create_jsonl_session_with_id(
5363            &sessions,
5364            &project.to_string_lossy(),
5365            Some("cwdadopt01".into()),
5366        )
5367        .await
5368        .unwrap();
5369
5370        let selection = SessionSelection::ById {
5371            id: "cwdadopt01".into(),
5372            dir: sessions.clone(),
5373        };
5374        let peeked = peek_restored_session_cwd(&selection, &launch)
5375            .await
5376            .expect("a resumable session with a recorded cwd");
5377        assert_eq!(peeked.recorded, project);
5378        assert_eq!(peeked.adopted, Some(project));
5379    }
5380
5381    /// A recorded directory that no longer exists must NOT be adopted: the run
5382    /// falls back to the launch cwd, and the peek hands back the record so the
5383    /// host can say so (instead of the old behaviour of eprintln-ing mid-TUI).
5384    #[tokio::test]
5385    async fn a_missing_recorded_directory_is_reported_not_adopted() {
5386        let tmp = tempfile::tempdir().unwrap();
5387        let gone = tmp.path().join("project-gone");
5388        let launch = tmp.path().join("launch-dir");
5389        let sessions = tmp.path().join("sessions");
5390        std::fs::create_dir_all(&launch).unwrap();
5391        std::fs::create_dir_all(&sessions).unwrap();
5392        create_jsonl_session_with_id(
5393            &sessions,
5394            &gone.to_string_lossy(),
5395            Some("cwdgone001".into()),
5396        )
5397        .await
5398        .unwrap();
5399
5400        let selection = SessionSelection::ById {
5401            id: "cwdgone001".into(),
5402            dir: sessions.clone(),
5403        };
5404        let peeked = peek_restored_session_cwd(&selection, &launch)
5405            .await
5406            .expect("the session itself is resumable");
5407        assert_eq!(peeked.recorded, gone);
5408        assert_eq!(peeked.adopted, None);
5409        let note = crate::session_cwd::missing_session_cwd_note(&peeked.recorded, &launch);
5410        assert!(note.contains("no longer exists"), "{note}");
5411        assert!(note.contains("launch-dir"), "{note}");
5412    }
5413
5414    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5415    async fn legacy_progress_upgrade_normal_open_runs_and_reopens() {
5416        use rpi_ai::providers::faux::{FauxProvider, FauxScript};
5417        use rpi_harness::agent_harness::{AgentHarness, AgentLane, HarnessRunOutcome};
5418        use rpi_harness::session::jsonl::{
5419            encode_header, JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
5420            JsonlV4Header,
5421        };
5422        let tmp = tempfile::tempdir().unwrap();
5423        let sessions = tmp.path().join("sessions");
5424        std::fs::create_dir_all(&sessions).unwrap();
5425        let cwd = tmp.path().to_string_lossy().into_owned();
5426        let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
5427            fs: Arc::new(OsExecutionEnv::with_cwd(tmp.path().into())),
5428            sessions_root: sessions.to_string_lossy().into_owned(),
5429            clock: Arc::new(SystemClock),
5430            ids: Arc::new(DefaultIdGenerator::new()),
5431        });
5432        let fixture = repo
5433            .create_typed(&JsonlSessionCreateOptions {
5434                id: Some("legacycli001".into()),
5435                parent_session_id: None,
5436                cwd: cwd.clone(),
5437                metadata: None,
5438            })
5439            .await
5440            .unwrap();
5441        let path = PathBuf::from(fixture.jsonl_metadata().path);
5442        drop(fixture);
5443        let sidecar = PathBuf::from(format!("{}.progress.jsonl", path.display()));
5444        std::fs::remove_file(&sidecar).unwrap();
5445        let header = JsonlV4Header::new("legacycli001".into(), 1, cwd.clone(), None, None, None);
5446        std::fs::write(&path, encode_header(&header)).unwrap();
5447        assert!(!sidecar.exists());
5448        for _ in 0..2 {
5449            let restored = open_session_by_id_in_dir("legacycli001", &cwd, &sessions)
5450                .await
5451                .map_err(|e| e.to_string())
5452                .unwrap();
5453            assert_eq!(restored.session.storage().metadata().id, "legacycli001");
5454            let provider =
5455                FauxProvider::new(FauxScript::new().with_text("continued legacy session"));
5456            let harness = AgentHarness::create(AgentHarnessOptions {
5457                model: provider.default_model().clone(),
5458                models: vec![provider],
5459                session: restored.session,
5460                allow_existing_session: true,
5461                ..Default::default()
5462            })
5463            .await
5464            .unwrap();
5465            let run = harness.prompt_text("continue", vec![]).await.unwrap();
5466            assert!(matches!(run.outcome, HarnessRunOutcome::Completed { .. }));
5467        }
5468        let content = std::fs::read_to_string(&path).unwrap();
5469        assert!(content.lines().next().unwrap().contains("progressFormat"));
5470        assert!(sidecar.exists());
5471    }
5472
5473    /// `open_session` hands back the recorded directory as data (for the
5474    /// `/session` hot-switch notes) while still opening the session normally.
5475    #[tokio::test]
5476    async fn open_session_reports_the_recorded_directory() {
5477        let tmp = tempfile::tempdir().unwrap();
5478        let project = tmp.path().join("project-open");
5479        let launch = tmp.path().join("launch-dir");
5480        let sessions = tmp.path().join("sessions");
5481        std::fs::create_dir_all(&project).unwrap();
5482        std::fs::create_dir_all(&launch).unwrap();
5483        std::fs::create_dir_all(&sessions).unwrap();
5484        create_jsonl_session_with_id(
5485            &sessions,
5486            &project.to_string_lossy(),
5487            Some("cwdopen001".into()),
5488        )
5489        .await
5490        .unwrap();
5491
5492        let restored =
5493            open_session_by_id_in_dir("cwdopen001", &launch.to_string_lossy(), &sessions)
5494                .await
5495                .map_err(|error| error.to_string())
5496                .unwrap();
5497        assert_eq!(restored.recorded_cwd, Some(project.clone()));
5498        assert_eq!(restored.session.storage().metadata().id, "cwdopen001");
5499    }
5500
5501    /// The session the peek inspected must be the session the build opens.
5502    ///
5503    /// `-c` means "the most recent session", and the build lists the directory
5504    /// again. Another `rpi` (or a `/session` new-session) can write a newer one
5505    /// between the peek and the build; adopting the peeked session's directory
5506    /// while opening the newer conversation would run the tools in a directory
5507    /// that conversation never used. Pinning the resolved id makes the second
5508    /// listing an exact match instead of a fresh newest-first choice.
5509    #[tokio::test]
5510    async fn the_peeked_session_is_the_one_the_build_opens() {
5511        let tmp = tempfile::tempdir().unwrap();
5512        let first_project = tmp.path().join("project-first");
5513        let second_project = tmp.path().join("project-second");
5514        let launch = tmp.path().join("launch-dir");
5515        let sessions = tmp.path().join("sessions");
5516        for dir in [&first_project, &second_project, &launch, &sessions] {
5517            std::fs::create_dir_all(dir).unwrap();
5518        }
5519        create_jsonl_session_with_id(
5520            &sessions,
5521            &first_project.to_string_lossy(),
5522            Some("cwdrace001".into()),
5523        )
5524        .await
5525        .unwrap();
5526
5527        let mut args = Args {
5528            continue_session: true,
5529            session_dir: Some(sessions.clone()),
5530            ..Default::default()
5531        };
5532        let selection = select_session_in_dir(&args, sessions.clone());
5533        let peeked = peek_restored_session_cwd(&selection, &launch)
5534            .await
5535            .expect("a resumable session");
5536        assert_eq!(peeked.adopted, Some(first_project.clone()));
5537
5538        // A newer session appears in another project before the build runs.
5539        create_jsonl_session_with_id(
5540            &sessions,
5541            &second_project.to_string_lossy(),
5542            Some("cwdrace002".into()),
5543        )
5544        .await
5545        .unwrap();
5546
5547        pin_restored_selection(&mut args, &peeked);
5548        let pinned = select_session_in_dir(&args, sessions.clone());
5549        let reopened = resolve_restore_target(&pinned, &launch.to_string_lossy())
5550            .await
5551            .unwrap()
5552            .expect("the pinned session still exists");
5553        assert_eq!(
5554            reopened.id, peeked.id,
5555            "the build must open the session the peek adopted the directory of"
5556        );
5557        assert_eq!(reopened.cwd, first_project.to_string_lossy());
5558    }
5559
5560    /// Trust must be resolved against the directory it is asked about. A
5561    /// session recorded in another project must not inherit the launch
5562    /// directory's decision — that would run an untrusted project's settings,
5563    /// skills, and context files with trusted privileges.
5564    #[test]
5565    fn trust_is_resolved_per_directory() {
5566        struct RestoreConfigDir(Option<std::ffi::OsString>);
5567
5568        impl Drop for RestoreConfigDir {
5569            fn drop(&mut self) {
5570                match self.0.take() {
5571                    Some(value) => std::env::set_var(crate::config::CONFIG_DIR_ENV, value),
5572                    None => std::env::remove_var(crate::config::CONFIG_DIR_ENV),
5573                }
5574            }
5575        }
5576
5577        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5578        let _restore = RestoreConfigDir(std::env::var_os(crate::config::CONFIG_DIR_ENV));
5579        let tmp = tempfile::tempdir().unwrap();
5580        let agent = tmp.path().join("agent");
5581        std::fs::create_dir_all(&agent).unwrap();
5582        std::env::set_var(crate::config::CONFIG_DIR_ENV, &agent);
5583
5584        let untrusted = tmp.path().join("project-untrusted");
5585        let launch = tmp.path().join("project-launch");
5586        std::fs::create_dir_all(&untrusted).unwrap();
5587        std::fs::create_dir_all(&launch).unwrap();
5588        crate::config::set_project_trust(&untrusted, Some(false)).unwrap();
5589
5590        let args = Args::default();
5591        assert!(!resolve_project_trust(&args, &untrusted));
5592        // A directory with no stored decision loads by default.
5593        assert!(resolve_project_trust(&args, &launch));
5594    }
5595}