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