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