Skip to main content

supercode_harness/
sessions_control.rs

1//! Controlled-tier conversations (Domain 11, concept 5) — `new`, `reset`,
2//! `archive`, `delete` over one uniform door.
3//!
4//! Charter (`docs/plans/orchestration-domain-11-2026-09-02.md` §0.4):
5//! **every mutation here is the harness's OWN verb.** supercode never writes
6//! another harness's session store by hand; it runs the harness's CLI, calls
7//! the harness's HTTP API, or types the harness's slash command into a LIVE
8//! driven session, then re-reads the row the harness's own store now holds.
9//!
10//! The doors, per harness, at the pinned versions:
11//!
12//! | harness | new | reset | archive | delete |
13//! |---|---|---|---|---|
14//! | claude-code | refused → `runtimes.start` | refused | refused | refused |
15//! | codex | refused → `runtimes.start` | refused | `codex archive <id>` | `codex delete <id>` |
16//! | opencode | refused → `runtimes.start` | refused | `PATCH /session/<id>` | `DELETE /session/<id>` |
17//! | hermes | refused (gateway-only) | `/reset` in a live session | refused | `hermes sessions delete <id>` |
18//! | openclaw | `/new` in a live session | `/reset` in a live session | refused | refused |
19//! | orchestrator | its daemon's operator door | its daemon's operator door | refused | refused |
20//! | supercode | refused → `runtimes.start` | refused | own store | own store |
21//!
22//! Three rules the whole tier inherits from [`crate::jobs_control`]:
23//!
24//! 1. **The harness's answer is the answer.** After the door reports success
25//!    the conversation is re-read through the ORCH-6 discovery loader and
26//!    returned. A delete that leaves the row behind, or an archive the store
27//!    did not record, is a FAILURE — never a silent success.
28//! 2. **The door is narrated.** Every outcome carries `ran`: the exact argv,
29//!    HTTP request line, slash command, or store call that was performed,
30//!    with any credential rendered as `<redacted>`.
31//! 3. **A verb the harness has no door for is refused**
32//!    ([`SessionControlError::Unsupported`] → `UnsupportedAction`), with the
33//!    reason and the door that DOES exist, never a silent no-op.
34//!
35//! ## Why some cells are refused at the pin
36//!
37//! * **`hermes sessions archive`** exists but is a BULK filter verb
38//!   (`--older-than`, `--title`, `--cwd`, …) with no per-session selector, so
39//!   a uniform "archive THIS conversation" cannot be expressed through it.
40//!   `hermes sessions delete <id>` is per-session and IS used.
41//!   (Pinned help fixture: `crates/harness/src/parity/fixtures/hermes-help.txt`,
42//!   section `$ hermes sessions --help`.)
43//! * **OpenClaw** registers only `sessions list | cleanup | tail |
44//!   export-trajectory | compact` at v2026.7.1-2 — no `archive`, no `delete`.
45//! * **Claude Code** publishes no conversation lifecycle verb at all: its
46//!   sessions expire on a retention window it owns.
47//! * **The orchestrator** has no archive and no delete BY MODEL: a binding
48//!   (`docs/ORCHESTRATOR-IR.md` §2.5) ends, and the transcript belongs to the
49//!   worker harness the binding addresses. `new` and `reset` DO exist — they
50//!   are the two chat commands its reducer applies to a binding (§4.5) — and
51//!   ORC-13 opened the door that reaches them from outside a chat: the
52//!   daemon's operator socket, or the package's own CLI when it is down
53//!   ([`crate::orchestrator_door`]). The orchestrator's conversation is a
54//!   BINDING, so it is named by its SURFACE key
55//!   (`platform|chat_type|chat_id|thread_id|participant_id`), never by a
56//!   worker session id — `--surface`, not `--session`.
57//! * **`new` / `reset` on the file-store harnesses** is not a missing verb —
58//!   it is a DIFFERENT door that already exists: `harness.v1.runtimes.start`.
59//!   Refusing while naming it keeps one way to do one thing.
60//!
61//! ## A slash command is only sent when the harness's door advertises it
62//!
63//! Both gateway harnesses expose `/new` and `/reset` IN CHAT, but the door
64//! supercode drives is each one's ACP adapter, and the two adapters do not
65//! carry the same set. Read from the harnesses themselves, 2026-09-03:
66//!
67//! * **OpenClaw** (`openclaw@2026.7.1-2`, `dist/commands-*.js`
68//!   `BASE_AVAILABLE_COMMANDS`) advertises BOTH `new` ("Reset the session
69//!   (/reset)") and `reset` on its ACP door. Both are supported.
70//! * **Hermes** (`acp_adapter/server.py` `_SLASH_COMMANDS` /
71//!   `_handle_slash_command`) advertises `reset` and NOT `new` — `/new` lives
72//!   only in `gateway/slash_commands.py`, and the adapter comments that an
73//!   unrecognized command "falls through to the LLM (the user may have typed
74//!   `/something` as prose)". `sessions.new` on hermes is therefore refused:
75//!   sending it would put the literal text `/new` in front of the model, which
76//!   is the silent no-op this tier exists to prevent.
77//!
78//! The two doors also differ in EFFECT, and neither is re-interpreted here:
79//! hermes's ACP `/reset` clears the conversation and keeps the session row,
80//! while the gateway's `/reset` rotates the session id. supercode drives the
81//! door it can reach and reports what that door did.
82
83use std::path::{Path, PathBuf};
84use std::process::Command;
85
86use serde::{Deserialize, Serialize};
87use serde_json::Value;
88
89use crate::{DiscoveryQuery, HarnessHomes, HarnessId};
90
91/// Environment variable overriding the `codex` executable (tests).
92pub const CODEX_BIN_ENV: &str = "SUPERCODE_CODEX_BIN";
93/// Environment variable overriding the `hermes` executable (tests).
94pub const HERMES_BIN_ENV: &str = "SUPERCODE_HERMES_BIN";
95
96/// One uniform conversation-lifecycle verb.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum SessionVerb {
100    /// Open a fresh conversation on the same surface.
101    New,
102    /// Clear the conversation while keeping the surface.
103    Reset,
104    /// Soft-hide the conversation, keeping its transcript.
105    Archive,
106    /// Permanently remove the conversation.
107    Delete,
108}
109
110impl SessionVerb {
111    /// Uniform spelling used in the RPC method and in outcomes.
112    pub const fn as_str(self) -> &'static str {
113        match self {
114            Self::New => "new",
115            Self::Reset => "reset",
116            Self::Archive => "archive",
117            Self::Delete => "delete",
118        }
119    }
120
121    /// The RPC method this verb is spelled as.
122    pub const fn method(self) -> &'static str {
123        match self {
124            Self::New => "harness.v1.sessions.new",
125            Self::Reset => "harness.v1.sessions.reset",
126            Self::Archive => "harness.v1.sessions.archive",
127            Self::Delete => "harness.v1.sessions.delete",
128        }
129    }
130
131    /// Whether the verb names an existing conversation.
132    const fn needs_session(self) -> bool {
133        !matches!(self, Self::New)
134    }
135}
136
137/// The door one `(harness, verb)` pair goes through.
138///
139/// The service needs this BEFORE it acts: a [`SessionDoor::Live`] verb is
140/// typed into an already-open runtime connection the service owns, while
141/// every other door is self-contained in this module.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum SessionDoor {
144    /// The harness's own CLI verb, run as a subprocess.
145    Cli,
146    /// The harness's own HTTP API.
147    Http,
148    /// The harness's own slash command, typed into a LIVE driven session.
149    /// Carries the exact command text (`/new`, `/reset`).
150    Live(&'static str),
151    /// supercode's own session store (the Domain 5 verb).
152    Store,
153    /// The orchestrator daemon's own operator door (ORC-13): its local socket
154    /// while the daemon is up, its package's CLI when it is down. Both land in
155    /// the reducer that owns bindings, and in the `save()` that owns the
156    /// folder.
157    Daemon,
158}
159
160/// One mutating request, in the uniform Domain 11 vocabulary.
161#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
162pub struct SessionMutation {
163    /// Harness that owns the conversation.
164    pub harness: String,
165    /// Harness-native conversation id, or supercode session name. Required
166    /// for every verb but `new`.
167    #[serde(default)]
168    pub session: Option<String>,
169    /// Working directory the new conversation belongs to (`new`).
170    #[serde(default)]
171    pub cwd: Option<PathBuf>,
172    /// Live runtime connection id, for the slash-command doors.
173    #[serde(default)]
174    pub connection: Option<String>,
175    /// Already-running OpenCode server this conversation lives on. Without
176    /// it the HTTP door is refused rather than guessing an endpoint.
177    #[serde(default)]
178    pub base_url: Option<String>,
179    /// Bearer credential for the OpenCode server, when it requires one. Never
180    /// narrated.
181    #[serde(default)]
182    pub bearer: Option<String>,
183    /// Hermes profile name — a profile IS a full `HERMES_HOME`. For the
184    /// orchestrator it is the profile FOLDER the binding belongs to.
185    #[serde(default)]
186    pub profile: Option<String>,
187    /// ORC-13: the surface key a conversation is bound to, in the IR's own
188    /// rendering (`platform|chat_type|chat_id|thread_id|participant_id`).
189    /// This is how the orchestrator's conversations are named — its binding
190    /// has no id of its own, only the surface it holds.
191    #[serde(default)]
192    pub surface: Option<String>,
193    /// Storage roots, so an isolated home is addressed the same way the read
194    /// side addresses it.
195    #[serde(default)]
196    pub homes: HarnessHomes,
197}
198
199/// What one mutation did, with the conversation re-read afterwards.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct SessionMutationOutcome {
202    /// Harness whose door was used.
203    pub harness: String,
204    /// Uniform verb that was asked for.
205    pub verb: String,
206    /// The exact door that was used, credentials redacted.
207    pub ran: String,
208    /// Conversation the verb acted on.
209    pub session: String,
210    /// The conversation as the harness's own store reports it AFTER the verb.
211    /// Absent for `delete`, and for a `new`/`reset` whose fresh conversation
212    /// the harness has not committed to its store yet.
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub row: Option<Value>,
215    /// `true` on a successful `archive`.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub archived: Option<bool>,
218    /// `true` on a successful `delete`.
219    #[serde(skip_serializing_if = "Option::is_none")]
220    pub deleted: Option<bool>,
221}
222
223/// Why a mutation could not be performed.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum SessionControlError {
226    /// The harness has no door for what was asked (refused, never faked).
227    Unsupported(String),
228    /// The request itself is incoherent.
229    Invalid(String),
230    /// The harness's door ran and failed; the message carries its own error.
231    Failed(String),
232}
233
234impl std::fmt::Display for SessionControlError {
235    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        match self {
237            Self::Unsupported(message) | Self::Invalid(message) | Self::Failed(message) => {
238                formatter.write_str(message)
239            }
240        }
241    }
242}
243
244impl std::error::Error for SessionControlError {}
245
246type Result<T> = std::result::Result<T, SessionControlError>;
247
248/// Harnesses whose conversations supercode can mutate through at least one of
249/// their own doors. Strictly narrower than the set it can READ.
250pub const CONTROLLED_SESSION_HARNESSES: &[&str] = &[
251    HarnessId::CODEX,
252    HarnessId::OPENCODE,
253    HarnessId::HERMES,
254    HarnessId::OPENCLAW,
255    HarnessId::ORCHESTRATOR,
256    HarnessId::SUPERCODE,
257];
258
259/// Every harness the compiled registry carries.
260///
261/// Spelled out rather than read back from [`crate::harness_support_registry`]:
262/// this door table is one of the INPUTS that registry is built from (the
263/// `conversation` concept block reads [`controlled_methods`]), so looking the
264/// registry up from here would recurse forever. A unit test below pins the two
265/// lists together.
266const REGISTERED_HARNESSES: &[&str] = &[
267    HarnessId::CLAUDE_CODE,
268    HarnessId::CODEX,
269    HarnessId::PI,
270    HarnessId::OPENCODE,
271    HarnessId::GROK,
272    HarnessId::GEMINI,
273    HarnessId::GOOSE,
274    HarnessId::HERMES,
275    HarnessId::OPENCLAW,
276    HarnessId::ORCHESTRATOR,
277    HarnessId::SUPERCODE,
278];
279
280/// Whether `harness` publishes a door for at least one conversation verb.
281pub fn supports_session_control(harness: &str) -> bool {
282    CONTROLLED_SESSION_HARNESSES.contains(&harness)
283}
284
285/// Every uniform verb, in declaration order.
286pub const ALL_SESSION_VERBS: [SessionVerb; 4] = [
287    SessionVerb::New,
288    SessionVerb::Reset,
289    SessionVerb::Archive,
290    SessionVerb::Delete,
291];
292
293/// Every uniform verb `harness` can actually perform, in declaration order.
294/// Empty for a harness with no door at all.
295pub fn controlled_verbs(harness: &str) -> Vec<&'static str> {
296    ALL_SESSION_VERBS
297        .into_iter()
298        .filter(|verb| door(harness, *verb).is_ok())
299        .map(SessionVerb::as_str)
300        .collect()
301}
302
303/// The RPC methods `harness` actually answers for the controlled tier. This is
304/// what the registry block advertises, so a method can never appear in the
305/// descriptor without a door behind it.
306pub fn controlled_methods(harness: &str) -> Vec<&'static str> {
307    ALL_SESSION_VERBS
308        .into_iter()
309        .filter(|verb| door(harness, *verb).is_ok())
310        .map(SessionVerb::method)
311        .collect()
312}
313
314/// Which door this `(harness, verb)` pair goes through, or WHY the harness
315/// refuses it.
316///
317/// This is the single table the whole tier is derived from: the service, the
318/// registry block, and the refusal messages all read it, so a door can never
319/// be advertised in one place and missing in another.
320pub fn door(harness: &str, verb: SessionVerb) -> Result<SessionDoor> {
321    match (harness, verb) {
322        // --- codex: two native CLI verbs, no `new`/`reset` concept ---------
323        (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => Ok(SessionDoor::Cli),
324        // --- opencode: its own HTTP session API ---------------------------
325        (HarnessId::OPENCODE, SessionVerb::Archive | SessionVerb::Delete) => Ok(SessionDoor::Http),
326        // --- the live slash-command doors, each checked against what that
327        //     harness's ACP door ACTUALLY advertises (module header) --------
328        (HarnessId::OPENCLAW, SessionVerb::New) => Ok(SessionDoor::Live("/new")),
329        (HarnessId::HERMES | HarnessId::OPENCLAW, SessionVerb::Reset) => {
330            Ok(SessionDoor::Live("/reset"))
331        }
332        (HarnessId::HERMES, SessionVerb::New) => Err(SessionControlError::Unsupported(
333            "hermes's ACP door advertises help, model, tools, context, reset, compress, steer, \
334             queue and version; `/new` is a GATEWAY command \
335             (`gateway/slash_commands.py::_handle_reset_command`) and hermes's ACP adapter sends \
336             any UNRECOGNIZED `/word` to the model as prose. Typing `/new` there would be a \
337             silent no-op dressed as a chat turn, so supercode refuses. `sessions.reset` IS \
338             advertised on that door and is supported"
339                .into(),
340        )),
341        (HarnessId::HERMES, SessionVerb::Delete) => Ok(SessionDoor::Cli),
342        (HarnessId::HERMES, SessionVerb::Archive) => Err(SessionControlError::Unsupported(
343            "hermes 0.21.0 registers `hermes sessions archive`, but it is a BULK filter verb \
344             (--older-than / --title / --cwd / ...) with no per-session selector, so archiving \
345             ONE conversation cannot be expressed through it. `sessions.delete` is per-session \
346             and is supported"
347                .into(),
348        )),
349        // --- openclaw: no lifecycle verb at the pin -----------------------
350        (HarnessId::OPENCLAW, SessionVerb::Archive | SessionVerb::Delete) => {
351            Err(SessionControlError::Unsupported(format!(
352                "openclaw v2026.7.1-2 registers `sessions list | cleanup | tail | \
353                 export-trajectory | compact` and no `archive` or `delete`, so supercode refuses \
354                 `sessions.{}` rather than inventing store-maintenance semantics for it",
355                verb.as_str()
356            )))
357        }
358        // --- supercode's own store ----------------------------------------
359        (HarnessId::SUPERCODE, SessionVerb::Archive | SessionVerb::Delete) => {
360            Ok(SessionDoor::Store)
361        }
362        // --- claude-code: no lifecycle verb at all -------------------------
363        (HarnessId::CLAUDE_CODE, SessionVerb::Archive | SessionVerb::Delete) => {
364            Err(SessionControlError::Unsupported(format!(
365                "claude-code publishes no conversation lifecycle verb: its sessions are removed \
366                 by a RETENTION WINDOW the harness itself owns (`cleanupPeriodDays`), so \
367                 supercode refuses `sessions.{}` rather than deleting files behind the \
368                 harness's back",
369                verb.as_str()
370            )))
371        }
372        // --- the orchestrator: its lifecycle verbs are the DAEMON's -------
373        (HarnessId::ORCHESTRATOR, SessionVerb::New | SessionVerb::Reset) => Ok(SessionDoor::Daemon),
374        (HarnessId::ORCHESTRATOR, verb) => Err(SessionControlError::Unsupported(format!(
375            "the orchestrator's conversations are BINDINGS its daemon holds \
376             (`docs/ORCHESTRATOR-IR.md` §2.5): a binding is never archived or deleted — it \
377             ENDS, and the transcript belongs to the WORKER harness it addresses, which is \
378             where `sessions.{}` is performed. `sessions.new` and `sessions.reset` end a \
379             binding through the daemon's own operator door and are supported",
380            verb.as_str()
381        ))),
382        (other, verb) if !REGISTERED_HARNESSES.contains(&other) => {
383            Err(SessionControlError::Unsupported(format!(
384                "`{other}` is not a registered harness, so `sessions.{}` has no door to go \
385                 through",
386                verb.as_str()
387            )))
388        }
389        // --- `new` / `reset` where the door is `runtimes.start` -----------
390        (_, SessionVerb::New) => Err(SessionControlError::Unsupported(format!(
391            "`{harness}` opens a conversation through `harness.v1.runtimes.start` (CLI: \
392             `supercode run --harness {harness}`), not through a slash command; `sessions.new` \
393             is only for the gateway harnesses whose surface outlives the conversation"
394        ))),
395        (_, SessionVerb::Reset) => Err(SessionControlError::Unsupported(format!(
396            "`{harness}` has no conversation reset verb: a fresh conversation is a new runtime \
397             (`harness.v1.runtimes.start`). `sessions.reset` is only for the gateway harnesses \
398             whose surface outlives the conversation"
399        ))),
400        (other, verb) => Err(SessionControlError::Unsupported(format!(
401            "`{other}` publishes no door for `sessions.{}`; conversation mutation is supported \
402             for: {}",
403            verb.as_str(),
404            CONTROLLED_SESSION_HARNESSES.join(", ")
405        ))),
406    }
407}
408
409// ---------------------------------------------------------------------------
410// Command narration (the same contract `jobs_control` established)
411// ---------------------------------------------------------------------------
412
413fn shell_quote(value: &str) -> String {
414    if !value.is_empty()
415        && value
416            .chars()
417            .all(|c| c.is_ascii_alphanumeric() || "-_./:@=+,".contains(c))
418    {
419        return value.to_string();
420    }
421    format!("'{}'", value.replace('\'', "'\\''"))
422}
423
424/// A harness CLI invocation, ready to run and ready to narrate.
425#[derive(Debug, Clone)]
426struct HarnessCommand {
427    program: String,
428    args: Vec<String>,
429    env: Vec<(String, String)>,
430}
431
432impl HarnessCommand {
433    fn new(program: impl Into<String>) -> Self {
434        Self {
435            program: program.into(),
436            args: Vec::new(),
437            env: Vec::new(),
438        }
439    }
440
441    fn args<I: IntoIterator<Item = S>, S: Into<String>>(&mut self, values: I) -> &mut Self {
442        for value in values {
443            self.args.push(value.into());
444        }
445        self
446    }
447
448    fn env(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
449        self.env.push((key.into(), value.into()));
450        self
451    }
452
453    /// The narration: exactly what ran.
454    fn narrate(&self) -> String {
455        let mut line = shell_quote(&self.program);
456        for arg in &self.args {
457            line.push(' ');
458            line.push_str(&shell_quote(arg));
459        }
460        line
461    }
462
463    /// Run it, returning stdout on success and the harness's own stderr on
464    /// failure.
465    fn run(&self) -> Result<String> {
466        let mut command = Command::new(&self.program);
467        command.args(&self.args);
468        for (key, value) in &self.env {
469            command.env(key, value);
470        }
471        command.stdin(std::process::Stdio::null());
472        let output = command.output().map_err(|error| {
473            SessionControlError::Failed(format!(
474                "`{}` could not be executed: {error}",
475                self.narrate()
476            ))
477        })?;
478        if output.status.success() {
479            return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
480        }
481        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
482        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
483        let detail = if stderr.is_empty() { stdout } else { stderr };
484        Err(SessionControlError::Failed(format!(
485            "`{}` failed ({}): {}",
486            self.narrate(),
487            output.status,
488            if detail.is_empty() {
489                "the harness printed nothing".to_string()
490            } else {
491                detail
492            }
493        )))
494    }
495}
496
497/// The harness's own executable, with a `SUPERCODE_<HARNESS>_BIN` override so
498/// a fake CLI can stand in under test without touching PATH. The registry
499/// names each harness's binary family in its runtime launch; the lifecycle
500/// verbs live on the base CLI, so an `-acp` bridge suffix is stripped.
501pub fn harness_program(harness: &str) -> Result<String> {
502    let variable = match harness {
503        HarnessId::CODEX => CODEX_BIN_ENV,
504        HarnessId::HERMES => HERMES_BIN_ENV,
505        other => {
506            return Err(SessionControlError::Unsupported(format!(
507                "`{other}` has no conversation CLI supercode calls"
508            )));
509        }
510    };
511    if let Some(over) = std::env::var_os(variable) {
512        let over = over.to_string_lossy().trim().to_string();
513        if !over.is_empty() {
514            return Ok(over);
515        }
516    }
517    let program = crate::harness_support(harness)
518        .and_then(|descriptor| descriptor.runtime.default_launch)
519        .map(|launch| launch.program)
520        .ok_or_else(|| {
521            SessionControlError::Unsupported(format!(
522                "the registry has no launch for `{harness}`, so its CLI cannot be located"
523            ))
524        })?;
525    Ok(program.strip_suffix("-acp").unwrap_or(&program).to_string())
526}
527
528/// `HERMES_HOME` for this request: the profile's own home when one is named
529/// (upstream treats a profile as a full `HERMES_HOME`), else the install root.
530/// `HarnessHomes::hermes` addresses `state.db`; `HERMES_HOME` is its parent —
531/// the same derivation the read side uses.
532fn hermes_home(mutation: &SessionMutation) -> PathBuf {
533    let root = mutation
534        .homes
535        .hermes
536        .parent()
537        .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
538    match mutation.profile.as_deref() {
539        Some(profile) => root.join("profiles").join(profile),
540        None => root,
541    }
542}
543
544/// `CODEX_HOME` for this request. `HarnessHomes::codex` addresses the
545/// `sessions/` directory inside it; codex itself wants the parent.
546fn codex_home(mutation: &SessionMutation) -> PathBuf {
547    let root = &mutation.homes.codex;
548    if root.file_name().is_some_and(|name| name == "sessions") {
549        return root
550            .parent()
551            .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
552    }
553    root.clone()
554}
555
556// ---------------------------------------------------------------------------
557// Re-reading the harness's own store
558// ---------------------------------------------------------------------------
559
560/// The conversation as the harness's own store reports it right now, through
561/// the ORCH-6 discovery loader. `None` means the store no longer holds it.
562fn read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
563    if mutation.harness == HarnessId::SUPERCODE {
564        let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
565            SessionControlError::Failed(format!("supercode's session store is unreadable: {error}"))
566        })?;
567        return Ok(store
568            .list()
569            .into_iter()
570            .find(|info| info.name == session)
571            .map(|info| serde_json::to_value(info).unwrap_or(Value::Null)));
572    }
573    let page = crate::discover_session_page(&DiscoveryQuery {
574        harnesses: vec![HarnessId::new(mutation.harness.clone())],
575        homes: mutation.homes.clone(),
576        include_child_sessions: true,
577        ..DiscoveryQuery::default()
578    })
579    .map_err(|error| {
580        SessionControlError::Failed(format!(
581            "the {} conversation store could not be re-read: {error}",
582            mutation.harness
583        ))
584    })?;
585    Ok(page
586        .sessions
587        .into_iter()
588        .find(|descriptor| descriptor.locator.session_id == session)
589        .map(|descriptor| serde_json::to_value(descriptor).unwrap_or(Value::Null)))
590}
591
592// ---------------------------------------------------------------------------
593// The mutation itself
594// ---------------------------------------------------------------------------
595
596/// Perform one conversation mutation through the harness's own door.
597///
598/// [`SessionDoor::Live`] verbs are NOT handled here: they need an open runtime
599/// connection, which only the service owns. Callers check [`door`] first and
600/// route those to `harness.v1.runtimes.send_input`; asking for one here is an
601/// [`SessionControlError::Invalid`], because it names a door this function
602/// cannot open rather than a door the harness lacks.
603pub async fn mutate(
604    verb: SessionVerb,
605    mutation: &SessionMutation,
606) -> Result<SessionMutationOutcome> {
607    let door = door(&mutation.harness, verb)?;
608    let session = mutation.session.as_deref().unwrap_or("").trim().to_string();
609    // The orchestrator's conversation is named by its SURFACE, not by an id:
610    // `needs_session` is about a store row, and a binding is not one.
611    if verb.needs_session() && session.is_empty() && !matches!(door, SessionDoor::Daemon) {
612        return Err(SessionControlError::Invalid(format!(
613            "`sessions.{}` needs the conversation to act on",
614            verb.as_str()
615        )));
616    }
617    match door {
618        SessionDoor::Live(command) => Err(SessionControlError::Invalid(format!(
619            "`{}` performs `sessions.{}` by typing `{command}` into a LIVE driven session; call \
620             it with an open runtime `connection`",
621            mutation.harness,
622            verb.as_str()
623        ))),
624        SessionDoor::Cli => {
625            let command = cli_command(verb, mutation, &session)?;
626            let ran = command.narrate();
627            command.run()?;
628            let row = read_back(mutation, &session)?;
629            finish(verb, mutation, session, ran, row)
630        }
631        SessionDoor::Store => {
632            let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
633                SessionControlError::Failed(format!(
634                    "supercode's session store is unreadable: {error}"
635                ))
636            })?;
637            let ran = format!(
638                "supercode store {} {}",
639                verb.as_str(),
640                shell_quote(&session)
641            );
642            match verb {
643                SessionVerb::Archive => store.archive(&session),
644                SessionVerb::Delete => store.delete(&session),
645                _ => unreachable!("the door table only routes archive/delete to the store"),
646            }
647            .map_err(|error| SessionControlError::Failed(format!("`{ran}` failed: {error}")))?;
648            let row = read_back(mutation, &session)?;
649            finish(verb, mutation, session, ran, row)
650        }
651        SessionDoor::Daemon => orchestrator_mutate(verb, mutation),
652        SessionDoor::Http => {
653            let ran = opencode_call(verb, mutation, &session).await?;
654            // The HTTP door re-reads through OPENCODE'S OWN API, not through
655            // the file/SQLite loader: the running server owns the store, and
656            // its answer is the only one that can be current.
657            let row = opencode_read_back(mutation, &session).await?;
658            finish(verb, mutation, session, ran, row)
659        }
660    }
661}
662
663// ---------------------------------------------------------------------------
664// The orchestrator — its own daemon's operator door (ORC-13)
665// ---------------------------------------------------------------------------
666
667/// `sessions.new|reset --harness orchestrator --surface <key>`.
668///
669/// The verb ends the LIVE binding on that surface, which is exactly what the
670/// `/new` and `/reset` chat commands do when a human types them into the
671/// conversation (`docs/ORCHESTRATOR-IR.md` §4.5) — the same reducer, reached
672/// through the daemon's operator door instead of through a chat message.
673/// Afterwards the binding is re-read through the ORCH-6 discovery loader, the
674/// same reader `sessions list --harness orchestrator` uses.
675fn orchestrator_mutate(
676    verb: SessionVerb,
677    mutation: &SessionMutation,
678) -> Result<SessionMutationOutcome> {
679    let surface = mutation
680        .surface
681        .as_deref()
682        .map(str::trim)
683        .filter(|surface| !surface.is_empty())
684        .ok_or_else(|| {
685            SessionControlError::Invalid(format!(
686                "an orchestrator conversation is a BINDING on a surface, not a store row: \
687                 `sessions.{}` needs `--surface \
688                 <platform|chat_type|chat_id|thread_id|participant_id>` \
689                 (`supercode sessions list --harness orchestrator` prints the surface of every \
690                 binding)",
691                verb.as_str()
692            ))
693        })?;
694    let root = mutation.homes.orchestrator.clone();
695    let profile = mutation
696        .profile
697        .as_deref()
698        .map(str::trim)
699        .filter(|profile| !profile.is_empty())
700        .unwrap_or("default");
701    let op = match verb {
702        SessionVerb::New => "sessions.new",
703        SessionVerb::Reset => "sessions.reset",
704        other => {
705            return Err(SessionControlError::Unsupported(format!(
706                "the orchestrator has no door for `sessions.{}`",
707                other.as_str()
708            )))
709        }
710    };
711    let args = serde_json::json!({ "surface": surface });
712    let answer = crate::orchestrator_door::call(&root, op, &args, profile).map_err(|error| {
713        match error {
714            // The package refused: its sentence is the answer, exactly as a
715            // harness's own stderr is for the CLI doors.
716            crate::orchestrator_door::DoorError::Refused(message) => {
717                SessionControlError::Failed(message)
718            }
719            crate::orchestrator_door::DoorError::Failed(message) => {
720                SessionControlError::Failed(message)
721            }
722        }
723    })?;
724    let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
725    let session = answer
726        .result
727        .pointer("/binding/session_id")
728        .and_then(Value::as_str)
729        .filter(|id| !id.is_empty())
730        .unwrap_or(surface)
731        .to_string();
732    // The FOLDER is the answer: the ended binding, read back through the
733    // discovery loader on its own surface.
734    let row = orchestrator_read_back(mutation, surface, &ran)?;
735    Ok(SessionMutationOutcome {
736        harness: mutation.harness.clone(),
737        verb: verb.as_str().to_string(),
738        ran,
739        session,
740        row,
741        archived: None,
742        deleted: None,
743    })
744}
745
746/// The newest binding on `surface`, through the ORCH-6 discovery loader.
747///
748/// A binding is addressed by its surface, and the loader renders that surface
749/// as Hermes's `agent:<profile>:<platform>:<chat_type>[:…]` key, so the match
750/// is on the surface COLUMNS the descriptor carries, never on a string the
751/// caller typed.
752fn orchestrator_read_back(
753    mutation: &SessionMutation,
754    surface: &str,
755    ran: &str,
756) -> Result<Option<Value>> {
757    let page = crate::discover_session_page(&DiscoveryQuery {
758        harnesses: vec![HarnessId::new(mutation.harness.clone())],
759        homes: mutation.homes.clone(),
760        include_child_sessions: true,
761        ..DiscoveryQuery::default()
762    })
763    .map_err(|error| {
764        SessionControlError::Failed(format!(
765            "`{ran}` succeeded but the orchestrator's binding store could not be re-read: {error}"
766        ))
767    })?;
768    let wanted = surface_columns(surface);
769    let mut best: Option<Value> = None;
770    let mut best_at = 0;
771    for descriptor in page.sessions {
772        let key = descriptor.nouns.surface.as_ref();
773        let found = [
774            key.and_then(|k| k.platform.clone()).unwrap_or_default(),
775            key.and_then(|k| k.kind.clone()).unwrap_or_default(),
776            key.and_then(|k| k.chat_id.clone()).unwrap_or_default(),
777            key.and_then(|k| k.thread_id.clone()).unwrap_or_default(),
778            key.and_then(|k| k.participant_id.clone())
779                .unwrap_or_default(),
780        ];
781        if found != wanted {
782            continue;
783        }
784        let at = descriptor.updated_at_ms.unwrap_or_default();
785        if best.is_none() || at >= best_at {
786            best_at = at;
787            best = Some(serde_json::to_value(&descriptor).unwrap_or(Value::Null));
788        }
789    }
790    Ok(best)
791}
792
793/// A surface key string split into its five columns, empty for the absent
794/// ones — the inverse of the IR's `surfaceKeyString`.
795fn surface_columns(surface: &str) -> [String; 5] {
796    let mut parts = surface.split('|');
797    std::array::from_fn(|_| parts.next().unwrap_or("").to_string())
798}
799
800/// Turn a completed door into the outcome, enforcing that the harness's own
801/// store agrees with what the door claimed.
802fn finish(
803    verb: SessionVerb,
804    mutation: &SessionMutation,
805    session: String,
806    ran: String,
807    row: Option<Value>,
808) -> Result<SessionMutationOutcome> {
809    let outcome = SessionMutationOutcome {
810        harness: mutation.harness.clone(),
811        verb: verb.as_str().to_string(),
812        ran: ran.clone(),
813        session: session.clone(),
814        row: row.clone(),
815        archived: None,
816        deleted: None,
817    };
818    match verb {
819        SessionVerb::Delete => {
820            if row.is_some() {
821                return Err(SessionControlError::Failed(format!(
822                    "`{ran}` reported success but `{session}` is still in {}'s conversation store",
823                    mutation.harness
824                )));
825            }
826            Ok(SessionMutationOutcome {
827                row: None,
828                deleted: Some(true),
829                ..outcome
830            })
831        }
832        SessionVerb::Archive => {
833            if !archive_took_effect(mutation, row.as_ref()) {
834                return Err(SessionControlError::Failed(format!(
835                    "`{ran}` reported success but {}'s store still lists `{session}` as an \
836                     active conversation",
837                    mutation.harness
838                )));
839            }
840            Ok(SessionMutationOutcome {
841                archived: Some(true),
842                ..outcome
843            })
844        }
845        SessionVerb::New | SessionVerb::Reset => Ok(outcome),
846    }
847}
848
849/// Did the harness's own store record the archive?
850///
851/// Each harness answers in its own terms and none of them is guessed at:
852///
853/// * **supercode** publishes an `archived` flag on the row.
854/// * **codex** MOVES the rollout out of `$CODEX_HOME/sessions` into
855///   `archived_sessions/` (executed 2026-09-03 on an isolated `CODEX_HOME`;
856///   receipt `docs/interop/research/orch19-codex-sessions-receipt-*.json`), so
857///   its disappearance from the active listing IS the store's answer.
858/// * **opencode** stamps `time.archived` on the session record its own API
859///   returns; a `404` (the server dropped it) also counts as archived.
860fn archive_took_effect(mutation: &SessionMutation, row: Option<&Value>) -> bool {
861    let Some(row) = row else {
862        return true;
863    };
864    if mutation.harness == HarnessId::SUPERCODE {
865        return row
866            .get("archived")
867            .and_then(Value::as_bool)
868            .unwrap_or(false);
869    }
870    row.pointer("/time/archived")
871        .is_some_and(|value| !value.is_null())
872}
873
874/// Translate one verb onto the harness's own CLI invocation.
875fn cli_command(
876    verb: SessionVerb,
877    mutation: &SessionMutation,
878    session: &str,
879) -> Result<HarnessCommand> {
880    match (mutation.harness.as_str(), verb) {
881        (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => {
882            let mut command = HarnessCommand::new(harness_program(HarnessId::CODEX)?);
883            command.env("CODEX_HOME", codex_home(mutation).to_string_lossy());
884            command.args([verb.as_str(), session]);
885            if matches!(verb, SessionVerb::Delete) {
886                // Measured 2026-09-03 on codex 0.152: without `--force` the
887                // delete refuses outright off a TTY ("cannot confirm session
888                // deletion without an interactive terminal"). supercode never
889                // drives a prompt it cannot see, so it passes the harness's
890                // own non-interactive flag; the caller already asked for a
891                // delete, and the verification read is what proves it landed.
892                command.args(["--force"]);
893            }
894            Ok(command)
895        }
896        (HarnessId::HERMES, SessionVerb::Delete) => {
897            let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
898            command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
899            // `--yes` skips hermes's own interactive confirmation; supercode
900            // never drives a prompt it cannot see.
901            command.args(["sessions", "delete", session, "--yes"]);
902            Ok(command)
903        }
904        (harness, verb) => Err(SessionControlError::Unsupported(format!(
905            "`{harness}` has no CLI verb for `sessions.{}`",
906            verb.as_str()
907        ))),
908    }
909}
910
911// ---------------------------------------------------------------------------
912// OpenCode — its own HTTP session API
913// ---------------------------------------------------------------------------
914
915/// OpenCode's running server, resolved the same way for the mutation and for
916/// the re-read that verifies it.
917///
918/// `base_url` is required — an endpoint is a fact about the caller's
919/// environment, and guessing one would mutate whichever OpenCode happened to
920/// be listening. The bearer, when present, is sent as a sensitive header and
921/// never appears in the narration.
922fn opencode_endpoint(mutation: &SessionMutation) -> Result<(String, reqwest::Client)> {
923    let base = mutation
924        .base_url
925        .as_deref()
926        .map(|url| url.trim_end_matches('/').to_string())
927        .ok_or_else(|| {
928            SessionControlError::Invalid(
929                "opencode conversations are mutated through its own running server: pass \
930                 `base_url` (the address `runtimes.start` reports, or an `opencode serve` you \
931                 already run)"
932                    .into(),
933            )
934        })?;
935    let mut headers = reqwest::header::HeaderMap::new();
936    if let Some(bearer) = mutation.bearer.as_deref().filter(|t| !t.trim().is_empty()) {
937        let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {bearer}"))
938            .map_err(|_| {
939                SessionControlError::Invalid(
940                    "the opencode bearer token is not a valid header value".into(),
941                )
942            })?;
943        value.set_sensitive(true);
944        headers.insert(reqwest::header::AUTHORIZATION, value);
945    }
946    let client = reqwest::Client::builder()
947        .default_headers(headers)
948        .build()
949        .map_err(|error| {
950            SessionControlError::Failed(format!("could not build the HTTP client: {error}"))
951        })?;
952    Ok((base, client))
953}
954
955/// Re-read one conversation through OPENCODE'S OWN session API.
956///
957/// `None` means the server no longer holds it (`404`), which is exactly what a
958/// successful delete must produce. Anything else the server says — including
959/// the `time.archived` stamp an archive leaves — comes back verbatim.
960async fn opencode_read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
961    let (base, client) = opencode_endpoint(mutation)?;
962    let url = format!("{base}/session/{session}");
963    let mut request = client.get(&url);
964    if let Some(cwd) = mutation.cwd.as_ref() {
965        request = request.query(&[("directory", cwd.to_string_lossy().into_owned())]);
966    }
967    let response = request.send().await.map_err(|error| {
968        SessionControlError::Failed(format!("`GET {url}` could not be sent: {error}"))
969    })?;
970    if response.status() == reqwest::StatusCode::NOT_FOUND {
971        return Ok(None);
972    }
973    let status = response.status();
974    if !status.is_success() {
975        let body = response.text().await.unwrap_or_default();
976        return Err(SessionControlError::Failed(format!(
977            "`GET {url}` failed ({status}): {}",
978            body.trim()
979        )));
980    }
981    response
982        .json::<Value>()
983        .await
984        .map(|value| if value.is_null() { None } else { Some(value) })
985        .map_err(|error| {
986            SessionControlError::Failed(format!("`GET {url}` returned unreadable JSON: {error}"))
987        })
988}
989
990/// Call OpenCode's own session API and return the narration.
991///
992/// The endpoint is the RUNNING server's: supercode never opens OpenCode's
993/// SQLite store to archive or delete a row.
994async fn opencode_call(
995    verb: SessionVerb,
996    mutation: &SessionMutation,
997    session: &str,
998) -> Result<String> {
999    let (base, client) = opencode_endpoint(mutation)?;
1000    let url = format!("{base}/session/{session}");
1001    let directory = mutation
1002        .cwd
1003        .as_ref()
1004        .map(|cwd| cwd.to_string_lossy().into_owned());
1005    let (ran, request) = match verb {
1006        SessionVerb::Delete => (format!("DELETE {url}"), client.delete(&url)),
1007        SessionVerb::Archive => {
1008            // OpenCode records the archive as `time.archived` on the session
1009            // record (`packages/schema/src/v1/session.ts`), patched through
1010            // the same route that renames a session. The re-read in `finish`
1011            // is what PROVES it landed: a payload the server ignores leaves
1012            // `time.archived` unset and the mutation fails.
1013            let now = std::time::SystemTime::now()
1014                .duration_since(std::time::UNIX_EPOCH)
1015                .map(|since| since.as_millis() as u64)
1016                .unwrap_or_default();
1017            (
1018                format!("PATCH {url} {{\"time\":{{\"archived\":{now}}}}}"),
1019                client
1020                    .patch(&url)
1021                    .json(&serde_json::json!({"time": {"archived": now}})),
1022            )
1023        }
1024        other => {
1025            return Err(SessionControlError::Unsupported(format!(
1026                "opencode has no HTTP door for `sessions.{}`",
1027                other.as_str()
1028            )));
1029        }
1030    };
1031    let request = match &directory {
1032        Some(directory) => request.query(&[("directory", directory)]),
1033        None => request,
1034    };
1035    let response = request.send().await.map_err(|error| {
1036        SessionControlError::Failed(format!("`{ran}` could not be sent: {error}"))
1037    })?;
1038    let status = response.status();
1039    if !status.is_success() {
1040        let body = response.text().await.unwrap_or_default();
1041        return Err(SessionControlError::Failed(format!(
1042            "`{ran}` failed ({status}): {}",
1043            if body.trim().is_empty() {
1044                "the server returned no body".to_string()
1045            } else {
1046                body.trim().to_string()
1047            }
1048        )));
1049    }
1050    Ok(ran)
1051}
1052
1053/// Build the outcome for a slash-command door the SERVICE performed, so the
1054/// live path and the subprocess path publish exactly the same shape.
1055pub fn live_outcome(
1056    verb: SessionVerb,
1057    mutation: &SessionMutation,
1058    command: &str,
1059    session: String,
1060) -> Result<SessionMutationOutcome> {
1061    let row = read_back(mutation, &session).unwrap_or(None);
1062    Ok(SessionMutationOutcome {
1063        harness: mutation.harness.clone(),
1064        verb: verb.as_str().to_string(),
1065        ran: format!("{} live session: {command}", mutation.harness),
1066        session,
1067        row,
1068        archived: None,
1069        deleted: None,
1070    })
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075    use super::*;
1076
1077    #[test]
1078    fn the_door_table_names_one_door_per_supported_pair() {
1079        assert_eq!(
1080            door(HarnessId::CODEX, SessionVerb::Archive).unwrap(),
1081            SessionDoor::Cli
1082        );
1083        assert_eq!(
1084            door(HarnessId::OPENCODE, SessionVerb::Delete).unwrap(),
1085            SessionDoor::Http
1086        );
1087        assert_eq!(
1088            door(HarnessId::OPENCLAW, SessionVerb::New).unwrap(),
1089            SessionDoor::Live("/new")
1090        );
1091        assert_eq!(
1092            door(HarnessId::OPENCLAW, SessionVerb::Reset).unwrap(),
1093            SessionDoor::Live("/reset")
1094        );
1095        assert_eq!(
1096            door(HarnessId::HERMES, SessionVerb::Reset).unwrap(),
1097            SessionDoor::Live("/reset")
1098        );
1099        assert_eq!(
1100            door(HarnessId::HERMES, SessionVerb::Delete).unwrap(),
1101            SessionDoor::Cli
1102        );
1103        assert_eq!(
1104            door(HarnessId::SUPERCODE, SessionVerb::Archive).unwrap(),
1105            SessionDoor::Store
1106        );
1107    }
1108
1109    #[test]
1110    fn every_refusal_names_the_reason_and_never_a_silent_no_op() {
1111        for (harness, verb, needle) in [
1112            (HarnessId::HERMES, SessionVerb::Archive, "BULK filter verb"),
1113            // The one refusal that exists because the DOOR does not carry the
1114            // command, not because the harness lacks the concept.
1115            (
1116                HarnessId::HERMES,
1117                SessionVerb::New,
1118                "sends any UNRECOGNIZED `/word` to the model as prose",
1119            ),
1120            (HarnessId::OPENCLAW, SessionVerb::Delete, "v2026.7.1-2"),
1121            (
1122                HarnessId::CLAUDE_CODE,
1123                SessionVerb::Delete,
1124                "RETENTION WINDOW",
1125            ),
1126            (
1127                HarnessId::CLAUDE_CODE,
1128                SessionVerb::New,
1129                "harness.v1.runtimes.start",
1130            ),
1131            (
1132                HarnessId::CODEX,
1133                SessionVerb::New,
1134                "harness.v1.runtimes.start",
1135            ),
1136            (
1137                HarnessId::SUPERCODE,
1138                SessionVerb::Reset,
1139                "no conversation reset verb",
1140            ),
1141            // ORC-13: the orchestrator gained `new`/`reset` but still has no
1142            // archive and no delete — a binding ENDS, and its transcript is
1143            // the worker harness's.
1144            (
1145                HarnessId::ORCHESTRATOR,
1146                SessionVerb::Archive,
1147                "a binding is never archived or deleted",
1148            ),
1149        ] {
1150            let error = door(harness, verb).unwrap_err();
1151            assert!(
1152                matches!(error, SessionControlError::Unsupported(_)),
1153                "{harness}.{}: {error}",
1154                verb.as_str()
1155            );
1156            assert!(
1157                error.to_string().contains(needle),
1158                "{harness}.{} must explain itself, got: {error}",
1159                verb.as_str()
1160            );
1161        }
1162    }
1163
1164    #[test]
1165    fn controlled_verbs_track_the_door_table() {
1166        assert_eq!(
1167            controlled_verbs(HarnessId::CODEX),
1168            vec!["archive", "delete"]
1169        );
1170        // Hermes's ACP door carries `/reset` but not `/new`, so the uniform
1171        // verb list is narrower than the harness's chat vocabulary.
1172        assert_eq!(controlled_verbs(HarnessId::HERMES), vec!["reset", "delete"]);
1173        assert_eq!(controlled_verbs(HarnessId::OPENCLAW), vec!["new", "reset"]);
1174        assert_eq!(
1175            controlled_verbs(HarnessId::OPENCODE),
1176            vec!["archive", "delete"]
1177        );
1178        assert_eq!(
1179            controlled_verbs(HarnessId::SUPERCODE),
1180            vec!["archive", "delete"]
1181        );
1182        assert!(controlled_verbs(HarnessId::CLAUDE_CODE).is_empty());
1183        assert!(controlled_verbs(HarnessId::PI).is_empty());
1184        // ORC-13: the orchestrator's `/new` and `/reset` are its reducer's,
1185        // reached through the daemon's operator door. It has no archive and no
1186        // delete at all: a binding ends, it is never filed away.
1187        assert_eq!(
1188            controlled_verbs(HarnessId::ORCHESTRATOR),
1189            vec!["new", "reset"]
1190        );
1191        assert_eq!(
1192            door(HarnessId::ORCHESTRATOR, SessionVerb::Reset).unwrap(),
1193            SessionDoor::Daemon
1194        );
1195        assert!(controlled_verbs("not-a-harness").is_empty());
1196        for harness in crate::harness_support_registry().harnesses {
1197            assert_eq!(
1198                !controlled_verbs(harness.id.as_str()).is_empty(),
1199                supports_session_control(harness.id.as_str()),
1200                "{}: CONTROLLED_SESSION_HARNESSES must track the door table",
1201                harness.id.as_str()
1202            );
1203        }
1204    }
1205
1206    /// The door table cannot read the registry back (it is one of the
1207    /// registry's inputs), so this test is what keeps the hand-written list
1208    /// honest.
1209    #[test]
1210    fn the_registered_harness_list_matches_the_compiled_registry() {
1211        let mut from_registry: Vec<String> = crate::harness_support_registry()
1212            .harnesses
1213            .into_iter()
1214            .map(|descriptor| descriptor.id.as_str().to_string())
1215            .collect();
1216        from_registry.sort();
1217        let mut declared: Vec<String> = REGISTERED_HARNESSES
1218            .iter()
1219            .map(|id| id.to_string())
1220            .collect();
1221        declared.sort();
1222        assert_eq!(declared, from_registry);
1223    }
1224
1225    #[test]
1226    fn codex_home_is_the_parent_of_the_sessions_root() {
1227        let mutation = SessionMutation {
1228            harness: HarnessId::CODEX.into(),
1229            homes: HarnessHomes {
1230                codex: PathBuf::from("/tmp/iso/.codex/sessions"),
1231                ..HarnessHomes::default()
1232            },
1233            ..SessionMutation::default()
1234        };
1235        assert_eq!(codex_home(&mutation), PathBuf::from("/tmp/iso/.codex"));
1236    }
1237
1238    #[test]
1239    fn a_hermes_profile_is_a_full_home() {
1240        let mutation = SessionMutation {
1241            harness: HarnessId::HERMES.into(),
1242            profile: Some("work".into()),
1243            homes: HarnessHomes {
1244                hermes: PathBuf::from("/tmp/iso/.hermes/state.db"),
1245                ..HarnessHomes::default()
1246            },
1247            ..SessionMutation::default()
1248        };
1249        assert_eq!(
1250            hermes_home(&mutation),
1251            PathBuf::from("/tmp/iso/.hermes/profiles/work")
1252        );
1253    }
1254
1255    #[tokio::test]
1256    async fn a_live_door_asked_for_out_of_band_says_so() {
1257        let error = mutate(
1258            SessionVerb::Reset,
1259            &SessionMutation {
1260                harness: HarnessId::HERMES.into(),
1261                session: Some("s1".into()),
1262                ..SessionMutation::default()
1263            },
1264        )
1265        .await
1266        .unwrap_err();
1267        assert!(matches!(error, SessionControlError::Invalid(_)));
1268        assert!(error.to_string().contains("/reset"));
1269        assert!(error.to_string().contains("connection"));
1270    }
1271
1272    #[tokio::test]
1273    async fn opencode_refuses_to_guess_an_endpoint() {
1274        let error = mutate(
1275            SessionVerb::Delete,
1276            &SessionMutation {
1277                harness: HarnessId::OPENCODE.into(),
1278                session: Some("ses_1".into()),
1279                ..SessionMutation::default()
1280            },
1281        )
1282        .await
1283        .unwrap_err();
1284        assert!(matches!(error, SessionControlError::Invalid(_)));
1285        assert!(error.to_string().contains("base_url"));
1286    }
1287
1288    #[tokio::test]
1289    async fn a_verb_without_its_conversation_is_invalid() {
1290        let error = mutate(
1291            SessionVerb::Delete,
1292            &SessionMutation {
1293                harness: HarnessId::CODEX.into(),
1294                ..SessionMutation::default()
1295            },
1296        )
1297        .await
1298        .unwrap_err();
1299        assert!(matches!(error, SessionControlError::Invalid(_)));
1300        assert!(error.to_string().contains("sessions.delete"));
1301    }
1302}