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