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 = target_session(verb, &door, mutation)?;
609    if let SessionDoor::Http = door {
610        let ran = opencode_call(verb, mutation, &session).await?;
611        // The HTTP door re-reads through OPENCODE'S OWN API, not through
612        // the file/SQLite loader: the running server owns the store, and
613        // its answer is the only one that can be current.
614        let row = opencode_read_back(mutation, &session).await?;
615        return finish(verb, mutation, session, ran, row);
616    }
617    perform(verb, mutation, door, session)
618}
619
620/// The same mutation as [`mutate`] for every door but the HTTP one — and
621/// every one of those doors works with calls that block the calling THREAD:
622/// the harness's own CLI run to completion, supercode's store, the
623/// orchestrator daemon's socket, and the read-back each of them ends with.
624///
625/// A caller under a deadline runs this on a blocking task. Awaiting the
626/// `async` [`mutate`] instead parks the calling task inside a future that
627/// never yields, so no timeout wrapped around it can ever fire.
628pub fn mutate_blocking(
629    verb: SessionVerb,
630    mutation: &SessionMutation,
631) -> Result<SessionMutationOutcome> {
632    let door = door(&mutation.harness, verb)?;
633    let session = target_session(verb, &door, mutation)?;
634    perform(verb, mutation, door, session)
635}
636
637/// The conversation a mutation names, refused when the verb needs one and the
638/// request named none.
639fn target_session(
640    verb: SessionVerb,
641    door: &SessionDoor,
642    mutation: &SessionMutation,
643) -> Result<String> {
644    let session = mutation.session.as_deref().unwrap_or("").trim().to_string();
645    // The orchestrator's conversation is named by its SURFACE, not by an id:
646    // `needs_session` is about a store row, and a binding is not one.
647    if verb.needs_session() && session.is_empty() && !matches!(door, SessionDoor::Daemon) {
648        return Err(SessionControlError::Invalid(format!(
649            "`sessions.{}` needs the conversation to act on",
650            verb.as_str()
651        )));
652    }
653    Ok(session)
654}
655
656/// Every door that answers without awaiting anything.
657fn perform(
658    verb: SessionVerb,
659    mutation: &SessionMutation,
660    door: SessionDoor,
661    session: String,
662) -> Result<SessionMutationOutcome> {
663    match door {
664        SessionDoor::Http => Err(SessionControlError::Invalid(format!(
665            "`{}` performs `sessions.{}` through its own HTTP API, which is not a blocking \
666             door; call [`mutate`]",
667            mutation.harness,
668            verb.as_str()
669        ))),
670        SessionDoor::Live(command) => Err(SessionControlError::Invalid(format!(
671            "`{}` performs `sessions.{}` by typing `{command}` into a LIVE driven session; call \
672             it with an open runtime `connection`",
673            mutation.harness,
674            verb.as_str()
675        ))),
676        SessionDoor::Cli => {
677            let command = cli_command(verb, mutation, &session)?;
678            let ran = command.narrate();
679            command.run()?;
680            let row = read_back(mutation, &session)?;
681            finish(verb, mutation, session, ran, row)
682        }
683        SessionDoor::Store => {
684            let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
685                SessionControlError::Failed(format!(
686                    "supercode's session store is unreadable: {error}"
687                ))
688            })?;
689            let ran = format!(
690                "supercode store {} {}",
691                verb.as_str(),
692                shell_quote(&session)
693            );
694            match verb {
695                SessionVerb::Archive => store.archive(&session),
696                SessionVerb::Delete => store.delete(&session),
697                _ => unreachable!("the door table only routes archive/delete to the store"),
698            }
699            .map_err(|error| SessionControlError::Failed(format!("`{ran}` failed: {error}")))?;
700            let row = read_back(mutation, &session)?;
701            finish(verb, mutation, session, ran, row)
702        }
703        SessionDoor::Daemon => orchestrator_mutate(verb, mutation),
704    }
705}
706
707// ---------------------------------------------------------------------------
708// The orchestrator — its own daemon's operator door (ORC-13)
709// ---------------------------------------------------------------------------
710
711/// `sessions.new|reset --harness orchestrator --surface <key>`.
712///
713/// The verb ends the LIVE binding on that surface, which is exactly what the
714/// `/new` and `/reset` chat commands do when a human types them into the
715/// conversation (`docs/ORCHESTRATOR-IR.md` §4.5) — the same reducer, reached
716/// through the daemon's operator door instead of through a chat message.
717/// Afterwards the binding is re-read through the ORCH-6 discovery loader, the
718/// same reader `sessions list --harness orchestrator` uses.
719fn orchestrator_mutate(
720    verb: SessionVerb,
721    mutation: &SessionMutation,
722) -> Result<SessionMutationOutcome> {
723    let surface = mutation
724        .surface
725        .as_deref()
726        .map(str::trim)
727        .filter(|surface| !surface.is_empty())
728        .ok_or_else(|| {
729            SessionControlError::Invalid(format!(
730                "an orchestrator conversation is a BINDING on a surface, not a store row: \
731                 `sessions.{}` needs `--surface \
732                 <platform|chat_type|chat_id|thread_id|participant_id>` \
733                 (`supercode sessions list --harness orchestrator` prints the surface of every \
734                 binding)",
735                verb.as_str()
736            ))
737        })?;
738    let root = mutation.homes.orchestrator.clone();
739    let profile = mutation
740        .profile
741        .as_deref()
742        .map(str::trim)
743        .filter(|profile| !profile.is_empty())
744        .unwrap_or("default");
745    let op = match verb {
746        SessionVerb::New => "sessions.new",
747        SessionVerb::Reset => "sessions.reset",
748        other => {
749            return Err(SessionControlError::Unsupported(format!(
750                "the orchestrator has no door for `sessions.{}`",
751                other.as_str()
752            )))
753        }
754    };
755    let args = serde_json::json!({ "surface": surface });
756    let answer = crate::orchestrator_door::call(&root, op, &args, profile).map_err(|error| {
757        match error {
758            // The package refused: its sentence is the answer, exactly as a
759            // harness's own stderr is for the CLI doors.
760            crate::orchestrator_door::DoorError::Refused(message) => {
761                SessionControlError::Failed(message)
762            }
763            crate::orchestrator_door::DoorError::Failed(message) => {
764                SessionControlError::Failed(message)
765            }
766        }
767    })?;
768    let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
769    let session = answer
770        .result
771        .pointer("/binding/session_id")
772        .and_then(Value::as_str)
773        .filter(|id| !id.is_empty())
774        .unwrap_or(surface)
775        .to_string();
776    // The FOLDER is the answer: the ended binding, read back through the
777    // discovery loader on its own surface.
778    let row = orchestrator_read_back(mutation, surface, &ran)?;
779    Ok(SessionMutationOutcome {
780        harness: mutation.harness.clone(),
781        verb: verb.as_str().to_string(),
782        ran,
783        session,
784        row,
785        archived: None,
786        deleted: None,
787    })
788}
789
790/// The newest binding on `surface`, through the ORCH-6 discovery loader.
791///
792/// A binding is addressed by its surface, and the loader renders that surface
793/// as Hermes's `agent:<profile>:<platform>:<chat_type>[:…]` key, so the match
794/// is on the surface COLUMNS the descriptor carries, never on a string the
795/// caller typed.
796fn orchestrator_read_back(
797    mutation: &SessionMutation,
798    surface: &str,
799    ran: &str,
800) -> Result<Option<Value>> {
801    let page = crate::discover_session_page(&DiscoveryQuery {
802        harnesses: vec![HarnessId::new(mutation.harness.clone())],
803        homes: mutation.homes.clone(),
804        include_child_sessions: true,
805        ..DiscoveryQuery::default()
806    })
807    .map_err(|error| {
808        SessionControlError::Failed(format!(
809            "`{ran}` succeeded but the orchestrator's binding store could not be re-read: {error}"
810        ))
811    })?;
812    let wanted = surface_columns(surface);
813    let mut best: Option<Value> = None;
814    let mut best_at = 0;
815    for descriptor in page.sessions {
816        let key = descriptor.nouns.surface.as_ref();
817        let found = [
818            key.and_then(|k| k.platform.clone()).unwrap_or_default(),
819            key.and_then(|k| k.kind.clone()).unwrap_or_default(),
820            key.and_then(|k| k.chat_id.clone()).unwrap_or_default(),
821            key.and_then(|k| k.thread_id.clone()).unwrap_or_default(),
822            key.and_then(|k| k.participant_id.clone())
823                .unwrap_or_default(),
824        ];
825        if found != wanted {
826            continue;
827        }
828        let at = descriptor.updated_at_ms.unwrap_or_default();
829        if best.is_none() || at >= best_at {
830            best_at = at;
831            best = Some(serde_json::to_value(&descriptor).unwrap_or(Value::Null));
832        }
833    }
834    Ok(best)
835}
836
837/// A surface key string split into its five columns, empty for the absent
838/// ones — the inverse of the IR's `surfaceKeyString`.
839fn surface_columns(surface: &str) -> [String; 5] {
840    let mut parts = surface.split('|');
841    std::array::from_fn(|_| parts.next().unwrap_or("").to_string())
842}
843
844/// Turn a completed door into the outcome, enforcing that the harness's own
845/// store agrees with what the door claimed.
846fn finish(
847    verb: SessionVerb,
848    mutation: &SessionMutation,
849    session: String,
850    ran: String,
851    row: Option<Value>,
852) -> Result<SessionMutationOutcome> {
853    let outcome = SessionMutationOutcome {
854        harness: mutation.harness.clone(),
855        verb: verb.as_str().to_string(),
856        ran: ran.clone(),
857        session: session.clone(),
858        row: row.clone(),
859        archived: None,
860        deleted: None,
861    };
862    match verb {
863        SessionVerb::Delete => {
864            if row.is_some() {
865                return Err(SessionControlError::Failed(format!(
866                    "`{ran}` reported success but `{session}` is still in {}'s conversation store",
867                    mutation.harness
868                )));
869            }
870            Ok(SessionMutationOutcome {
871                row: None,
872                deleted: Some(true),
873                ..outcome
874            })
875        }
876        SessionVerb::Archive => {
877            if !archive_took_effect(mutation, row.as_ref()) {
878                return Err(SessionControlError::Failed(format!(
879                    "`{ran}` reported success but {}'s store still lists `{session}` as an \
880                     active conversation",
881                    mutation.harness
882                )));
883            }
884            Ok(SessionMutationOutcome {
885                archived: Some(true),
886                ..outcome
887            })
888        }
889        SessionVerb::New | SessionVerb::Reset => Ok(outcome),
890    }
891}
892
893/// Did the harness's own store record the archive?
894///
895/// Each harness answers in its own terms and none of them is guessed at:
896///
897/// * **supercode** publishes an `archived` flag on the row.
898/// * **codex** MOVES the rollout out of `$CODEX_HOME/sessions` into
899///   `archived_sessions/` (executed 2026-09-03 on an isolated `CODEX_HOME`;
900///   receipt `docs/interop/research/orch19-codex-sessions-receipt-*.json`), so
901///   its disappearance from the active listing IS the store's answer.
902/// * **opencode** stamps `time.archived` on the session record its own API
903///   returns; a `404` (the server dropped it) also counts as archived.
904fn archive_took_effect(mutation: &SessionMutation, row: Option<&Value>) -> bool {
905    let Some(row) = row else {
906        return true;
907    };
908    if mutation.harness == HarnessId::SUPERCODE {
909        return row
910            .get("archived")
911            .and_then(Value::as_bool)
912            .unwrap_or(false);
913    }
914    row.pointer("/time/archived")
915        .is_some_and(|value| !value.is_null())
916}
917
918/// Translate one verb onto the harness's own CLI invocation.
919fn cli_command(
920    verb: SessionVerb,
921    mutation: &SessionMutation,
922    session: &str,
923) -> Result<HarnessCommand> {
924    match (mutation.harness.as_str(), verb) {
925        (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => {
926            let mut command = HarnessCommand::new(harness_program(HarnessId::CODEX)?);
927            command.env("CODEX_HOME", codex_home(mutation).to_string_lossy());
928            command.args([verb.as_str(), session]);
929            if matches!(verb, SessionVerb::Delete) {
930                // Measured 2026-09-03 on codex 0.152: without `--force` the
931                // delete refuses outright off a TTY ("cannot confirm session
932                // deletion without an interactive terminal"). supercode never
933                // drives a prompt it cannot see, so it passes the harness's
934                // own non-interactive flag; the caller already asked for a
935                // delete, and the verification read is what proves it landed.
936                command.args(["--force"]);
937            }
938            Ok(command)
939        }
940        (HarnessId::HERMES, SessionVerb::Delete) => {
941            let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
942            command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
943            // `--yes` skips hermes's own interactive confirmation; supercode
944            // never drives a prompt it cannot see.
945            command.args(["sessions", "delete", session, "--yes"]);
946            Ok(command)
947        }
948        (harness, verb) => Err(SessionControlError::Unsupported(format!(
949            "`{harness}` has no CLI verb for `sessions.{}`",
950            verb.as_str()
951        ))),
952    }
953}
954
955// ---------------------------------------------------------------------------
956// OpenCode — its own HTTP session API
957// ---------------------------------------------------------------------------
958
959/// OpenCode's running server, resolved the same way for the mutation and for
960/// the re-read that verifies it.
961///
962/// `base_url` is required — an endpoint is a fact about the caller's
963/// environment, and guessing one would mutate whichever OpenCode happened to
964/// be listening. The bearer, when present, is sent as a sensitive header and
965/// never appears in the narration.
966fn opencode_endpoint(mutation: &SessionMutation) -> Result<(String, reqwest::Client)> {
967    let base = mutation
968        .base_url
969        .as_deref()
970        .map(|url| url.trim_end_matches('/').to_string())
971        .ok_or_else(|| {
972            SessionControlError::Invalid(
973                "opencode conversations are mutated through its own running server: pass \
974                 `base_url` (the address `runtimes.start` reports, or an `opencode serve` you \
975                 already run)"
976                    .into(),
977            )
978        })?;
979    let mut headers = reqwest::header::HeaderMap::new();
980    if let Some(bearer) = mutation.bearer.as_deref().filter(|t| !t.trim().is_empty()) {
981        let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {bearer}"))
982            .map_err(|_| {
983                SessionControlError::Invalid(
984                    "the opencode bearer token is not a valid header value".into(),
985                )
986            })?;
987        value.set_sensitive(true);
988        headers.insert(reqwest::header::AUTHORIZATION, value);
989    }
990    let client = reqwest::Client::builder()
991        .default_headers(headers)
992        .build()
993        .map_err(|error| {
994            SessionControlError::Failed(format!("could not build the HTTP client: {error}"))
995        })?;
996    Ok((base, client))
997}
998
999/// Re-read one conversation through OPENCODE'S OWN session API.
1000///
1001/// `None` means the server no longer holds it (`404`), which is exactly what a
1002/// successful delete must produce. Anything else the server says — including
1003/// the `time.archived` stamp an archive leaves — comes back verbatim.
1004async fn opencode_read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
1005    let (base, client) = opencode_endpoint(mutation)?;
1006    let url = format!("{base}/session/{session}");
1007    let mut request = client.get(&url);
1008    if let Some(cwd) = mutation.cwd.as_ref() {
1009        request = request.query(&[("directory", cwd.to_string_lossy().into_owned())]);
1010    }
1011    let response = request.send().await.map_err(|error| {
1012        SessionControlError::Failed(format!("`GET {url}` could not be sent: {error}"))
1013    })?;
1014    if response.status() == reqwest::StatusCode::NOT_FOUND {
1015        return Ok(None);
1016    }
1017    let status = response.status();
1018    if !status.is_success() {
1019        let body = response.text().await.unwrap_or_default();
1020        return Err(SessionControlError::Failed(format!(
1021            "`GET {url}` failed ({status}): {}",
1022            body.trim()
1023        )));
1024    }
1025    response
1026        .json::<Value>()
1027        .await
1028        .map(|value| if value.is_null() { None } else { Some(value) })
1029        .map_err(|error| {
1030            SessionControlError::Failed(format!("`GET {url}` returned unreadable JSON: {error}"))
1031        })
1032}
1033
1034/// Call OpenCode's own session API and return the narration.
1035///
1036/// The endpoint is the RUNNING server's: supercode never opens OpenCode's
1037/// SQLite store to archive or delete a row.
1038async fn opencode_call(
1039    verb: SessionVerb,
1040    mutation: &SessionMutation,
1041    session: &str,
1042) -> Result<String> {
1043    let (base, client) = opencode_endpoint(mutation)?;
1044    let url = format!("{base}/session/{session}");
1045    let directory = mutation
1046        .cwd
1047        .as_ref()
1048        .map(|cwd| cwd.to_string_lossy().into_owned());
1049    let (ran, request) = match verb {
1050        SessionVerb::Delete => (format!("DELETE {url}"), client.delete(&url)),
1051        SessionVerb::Archive => {
1052            // OpenCode records the archive as `time.archived` on the session
1053            // record (`packages/schema/src/v1/session.ts`), patched through
1054            // the same route that renames a session. The re-read in `finish`
1055            // is what PROVES it landed: a payload the server ignores leaves
1056            // `time.archived` unset and the mutation fails.
1057            let now = std::time::SystemTime::now()
1058                .duration_since(std::time::UNIX_EPOCH)
1059                .map(|since| since.as_millis() as u64)
1060                .unwrap_or_default();
1061            (
1062                format!("PATCH {url} {{\"time\":{{\"archived\":{now}}}}}"),
1063                client
1064                    .patch(&url)
1065                    .json(&serde_json::json!({"time": {"archived": now}})),
1066            )
1067        }
1068        other => {
1069            return Err(SessionControlError::Unsupported(format!(
1070                "opencode has no HTTP door for `sessions.{}`",
1071                other.as_str()
1072            )));
1073        }
1074    };
1075    let request = match &directory {
1076        Some(directory) => request.query(&[("directory", directory)]),
1077        None => request,
1078    };
1079    let response = request.send().await.map_err(|error| {
1080        SessionControlError::Failed(format!("`{ran}` could not be sent: {error}"))
1081    })?;
1082    let status = response.status();
1083    if !status.is_success() {
1084        let body = response.text().await.unwrap_or_default();
1085        return Err(SessionControlError::Failed(format!(
1086            "`{ran}` failed ({status}): {}",
1087            if body.trim().is_empty() {
1088                "the server returned no body".to_string()
1089            } else {
1090                body.trim().to_string()
1091            }
1092        )));
1093    }
1094    Ok(ran)
1095}
1096
1097/// Build the outcome for a slash-command door the SERVICE performed, so the
1098/// live path and the subprocess path publish exactly the same shape.
1099pub fn live_outcome(
1100    verb: SessionVerb,
1101    mutation: &SessionMutation,
1102    command: &str,
1103    session: String,
1104) -> Result<SessionMutationOutcome> {
1105    let row = read_back(mutation, &session).unwrap_or(None);
1106    Ok(SessionMutationOutcome {
1107        harness: mutation.harness.clone(),
1108        verb: verb.as_str().to_string(),
1109        ran: format!("{} live session: {command}", mutation.harness),
1110        session,
1111        row,
1112        archived: None,
1113        deleted: None,
1114    })
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119    use super::*;
1120
1121    #[test]
1122    fn the_door_table_names_one_door_per_supported_pair() {
1123        assert_eq!(
1124            door(HarnessId::CODEX, SessionVerb::Archive).unwrap(),
1125            SessionDoor::Cli
1126        );
1127        assert_eq!(
1128            door(HarnessId::OPENCODE, SessionVerb::Delete).unwrap(),
1129            SessionDoor::Http
1130        );
1131        assert_eq!(
1132            door(HarnessId::OPENCLAW, SessionVerb::New).unwrap(),
1133            SessionDoor::Live("/new")
1134        );
1135        assert_eq!(
1136            door(HarnessId::OPENCLAW, SessionVerb::Reset).unwrap(),
1137            SessionDoor::Live("/reset")
1138        );
1139        assert_eq!(
1140            door(HarnessId::HERMES, SessionVerb::Reset).unwrap(),
1141            SessionDoor::Live("/reset")
1142        );
1143        assert_eq!(
1144            door(HarnessId::HERMES, SessionVerb::Delete).unwrap(),
1145            SessionDoor::Cli
1146        );
1147        assert_eq!(
1148            door(HarnessId::SUPERCODE, SessionVerb::Archive).unwrap(),
1149            SessionDoor::Store
1150        );
1151    }
1152
1153    #[test]
1154    fn every_refusal_names_the_reason_and_never_a_silent_no_op() {
1155        for (harness, verb, needle) in [
1156            (HarnessId::HERMES, SessionVerb::Archive, "BULK filter verb"),
1157            // The one refusal that exists because the DOOR does not carry the
1158            // command, not because the harness lacks the concept.
1159            (
1160                HarnessId::HERMES,
1161                SessionVerb::New,
1162                "sends any UNRECOGNIZED `/word` to the model as prose",
1163            ),
1164            (HarnessId::OPENCLAW, SessionVerb::Delete, "v2026.7.1-2"),
1165            (
1166                HarnessId::CLAUDE_CODE,
1167                SessionVerb::Delete,
1168                "RETENTION WINDOW",
1169            ),
1170            (
1171                HarnessId::CLAUDE_CODE,
1172                SessionVerb::New,
1173                "harness.v1.runtimes.start",
1174            ),
1175            (
1176                HarnessId::CODEX,
1177                SessionVerb::New,
1178                "harness.v1.runtimes.start",
1179            ),
1180            (
1181                HarnessId::SUPERCODE,
1182                SessionVerb::Reset,
1183                "no conversation reset verb",
1184            ),
1185            // ORC-13: the orchestrator gained `new`/`reset` but still has no
1186            // archive and no delete — a binding ENDS, and its transcript is
1187            // the worker harness's.
1188            (
1189                HarnessId::ORCHESTRATOR,
1190                SessionVerb::Archive,
1191                "a binding is never archived or deleted",
1192            ),
1193        ] {
1194            let error = door(harness, verb).unwrap_err();
1195            assert!(
1196                matches!(error, SessionControlError::Unsupported(_)),
1197                "{harness}.{}: {error}",
1198                verb.as_str()
1199            );
1200            assert!(
1201                error.to_string().contains(needle),
1202                "{harness}.{} must explain itself, got: {error}",
1203                verb.as_str()
1204            );
1205        }
1206    }
1207
1208    #[test]
1209    fn controlled_verbs_track_the_door_table() {
1210        assert_eq!(
1211            controlled_verbs(HarnessId::CODEX),
1212            vec!["archive", "delete"]
1213        );
1214        // Hermes's ACP door carries `/reset` but not `/new`, so the uniform
1215        // verb list is narrower than the harness's chat vocabulary.
1216        assert_eq!(controlled_verbs(HarnessId::HERMES), vec!["reset", "delete"]);
1217        assert_eq!(controlled_verbs(HarnessId::OPENCLAW), vec!["new", "reset"]);
1218        assert_eq!(
1219            controlled_verbs(HarnessId::OPENCODE),
1220            vec!["archive", "delete"]
1221        );
1222        assert_eq!(
1223            controlled_verbs(HarnessId::SUPERCODE),
1224            vec!["archive", "delete"]
1225        );
1226        assert!(controlled_verbs(HarnessId::CLAUDE_CODE).is_empty());
1227        assert!(controlled_verbs(HarnessId::PI).is_empty());
1228        // ORC-13: the orchestrator's `/new` and `/reset` are its reducer's,
1229        // reached through the daemon's operator door. It has no archive and no
1230        // delete at all: a binding ends, it is never filed away.
1231        assert_eq!(
1232            controlled_verbs(HarnessId::ORCHESTRATOR),
1233            vec!["new", "reset"]
1234        );
1235        assert_eq!(
1236            door(HarnessId::ORCHESTRATOR, SessionVerb::Reset).unwrap(),
1237            SessionDoor::Daemon
1238        );
1239        assert!(controlled_verbs("not-a-harness").is_empty());
1240        for harness in crate::harness_support_registry().harnesses {
1241            assert_eq!(
1242                !controlled_verbs(harness.id.as_str()).is_empty(),
1243                supports_session_control(harness.id.as_str()),
1244                "{}: CONTROLLED_SESSION_HARNESSES must track the door table",
1245                harness.id.as_str()
1246            );
1247        }
1248    }
1249
1250    /// The door table cannot read the registry back (it is one of the
1251    /// registry's inputs), so this test is what keeps the hand-written list
1252    /// honest.
1253    #[test]
1254    fn the_registered_harness_list_matches_the_compiled_registry() {
1255        let mut from_registry: Vec<String> = crate::harness_support_registry()
1256            .harnesses
1257            .into_iter()
1258            .map(|descriptor| descriptor.id.as_str().to_string())
1259            .collect();
1260        from_registry.sort();
1261        let mut declared: Vec<String> = REGISTERED_HARNESSES
1262            .iter()
1263            .map(|id| id.to_string())
1264            .collect();
1265        declared.sort();
1266        assert_eq!(declared, from_registry);
1267    }
1268
1269    #[test]
1270    fn codex_home_is_the_parent_of_the_sessions_root() {
1271        let mutation = SessionMutation {
1272            harness: HarnessId::CODEX.into(),
1273            homes: HarnessHomes {
1274                codex: PathBuf::from("/tmp/iso/.codex/sessions"),
1275                ..HarnessHomes::default()
1276            },
1277            ..SessionMutation::default()
1278        };
1279        assert_eq!(codex_home(&mutation), PathBuf::from("/tmp/iso/.codex"));
1280    }
1281
1282    #[test]
1283    fn a_hermes_profile_is_a_full_home() {
1284        let mutation = SessionMutation {
1285            harness: HarnessId::HERMES.into(),
1286            profile: Some("work".into()),
1287            homes: HarnessHomes {
1288                hermes: PathBuf::from("/tmp/iso/.hermes/state.db"),
1289                ..HarnessHomes::default()
1290            },
1291            ..SessionMutation::default()
1292        };
1293        assert_eq!(
1294            hermes_home(&mutation),
1295            PathBuf::from("/tmp/iso/.hermes/profiles/work")
1296        );
1297    }
1298
1299    #[tokio::test]
1300    async fn a_live_door_asked_for_out_of_band_says_so() {
1301        let error = mutate(
1302            SessionVerb::Reset,
1303            &SessionMutation {
1304                harness: HarnessId::HERMES.into(),
1305                session: Some("s1".into()),
1306                ..SessionMutation::default()
1307            },
1308        )
1309        .await
1310        .unwrap_err();
1311        assert!(matches!(error, SessionControlError::Invalid(_)));
1312        assert!(error.to_string().contains("/reset"));
1313        assert!(error.to_string().contains("connection"));
1314    }
1315
1316    #[tokio::test]
1317    async fn opencode_refuses_to_guess_an_endpoint() {
1318        let error = mutate(
1319            SessionVerb::Delete,
1320            &SessionMutation {
1321                harness: HarnessId::OPENCODE.into(),
1322                session: Some("ses_1".into()),
1323                ..SessionMutation::default()
1324            },
1325        )
1326        .await
1327        .unwrap_err();
1328        assert!(matches!(error, SessionControlError::Invalid(_)));
1329        assert!(error.to_string().contains("base_url"));
1330    }
1331
1332    #[tokio::test]
1333    async fn a_verb_without_its_conversation_is_invalid() {
1334        let error = mutate(
1335            SessionVerb::Delete,
1336            &SessionMutation {
1337                harness: HarnessId::CODEX.into(),
1338                ..SessionMutation::default()
1339            },
1340        )
1341        .await
1342        .unwrap_err();
1343        assert!(matches!(error, SessionControlError::Invalid(_)));
1344        assert!(error.to_string().contains("sessions.delete"));
1345    }
1346}