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 Volter Harness 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!(
566                "Volter Harness's session store is unreadable: {error}"
567            ))
568        })?;
569        return Ok(store
570            .list()
571            .into_iter()
572            .find(|info| info.name == session)
573            .map(|info| serde_json::to_value(info).unwrap_or(Value::Null)));
574    }
575    let page = crate::discover_session_page(&DiscoveryQuery {
576        harnesses: vec![HarnessId::new(mutation.harness.clone())],
577        homes: mutation.homes.clone(),
578        include_child_sessions: true,
579        ..DiscoveryQuery::default()
580    })
581    .map_err(|error| {
582        SessionControlError::Failed(format!(
583            "the {} conversation store could not be re-read: {error}",
584            mutation.harness
585        ))
586    })?;
587    Ok(page
588        .sessions
589        .into_iter()
590        .find(|descriptor| descriptor.locator.session_id == session)
591        .map(|descriptor| serde_json::to_value(descriptor).unwrap_or(Value::Null)))
592}
593
594// ---------------------------------------------------------------------------
595// The mutation itself
596// ---------------------------------------------------------------------------
597
598/// Perform one conversation mutation through the harness's own door.
599///
600/// [`SessionDoor::Live`] verbs are NOT handled here: they need an open runtime
601/// connection, which only the service owns. Callers check [`door`] first and
602/// route those to `harness.v1.runtimes.send_input`; asking for one here is an
603/// [`SessionControlError::Invalid`], because it names a door this function
604/// cannot open rather than a door the harness lacks.
605pub async fn mutate(
606    verb: SessionVerb,
607    mutation: &SessionMutation,
608) -> Result<SessionMutationOutcome> {
609    let door = door(&mutation.harness, verb)?;
610    let session = target_session(verb, &door, mutation)?;
611    if let SessionDoor::Http = door {
612        let ran = opencode_call(verb, mutation, &session).await?;
613        // The HTTP door re-reads through OPENCODE'S OWN API, not through
614        // the file/SQLite loader: the running server owns the store, and
615        // its answer is the only one that can be current.
616        let row = opencode_read_back(mutation, &session).await?;
617        return finish(verb, mutation, session, ran, row);
618    }
619    perform(verb, mutation, door, session)
620}
621
622/// The same mutation as [`mutate`] for every door but the HTTP one — and
623/// every one of those doors works with calls that block the calling THREAD:
624/// the harness's own CLI run to completion, supercode's store, the
625/// orchestrator daemon's socket, and the read-back each of them ends with.
626///
627/// A caller under a deadline runs this on a blocking task. Awaiting the
628/// `async` [`mutate`] instead parks the calling task inside a future that
629/// never yields, so no timeout wrapped around it can ever fire.
630pub fn mutate_blocking(
631    verb: SessionVerb,
632    mutation: &SessionMutation,
633) -> Result<SessionMutationOutcome> {
634    let door = door(&mutation.harness, verb)?;
635    let session = target_session(verb, &door, mutation)?;
636    perform(verb, mutation, door, session)
637}
638
639/// The conversation a mutation names, refused when the verb needs one and the
640/// request named none.
641fn target_session(
642    verb: SessionVerb,
643    door: &SessionDoor,
644    mutation: &SessionMutation,
645) -> Result<String> {
646    let session = mutation.session.as_deref().unwrap_or("").trim().to_string();
647    // The orchestrator's conversation is named by its SURFACE, not by an id:
648    // `needs_session` is about a store row, and a binding is not one.
649    if verb.needs_session() && session.is_empty() && !matches!(door, SessionDoor::Daemon) {
650        return Err(SessionControlError::Invalid(format!(
651            "`sessions.{}` needs the conversation to act on",
652            verb.as_str()
653        )));
654    }
655    Ok(session)
656}
657
658/// Every door that answers without awaiting anything.
659fn perform(
660    verb: SessionVerb,
661    mutation: &SessionMutation,
662    door: SessionDoor,
663    session: String,
664) -> Result<SessionMutationOutcome> {
665    match door {
666        SessionDoor::Http => Err(SessionControlError::Invalid(format!(
667            "`{}` performs `sessions.{}` through its own HTTP API, which is not a blocking \
668             door; call [`mutate`]",
669            mutation.harness,
670            verb.as_str()
671        ))),
672        SessionDoor::Live(command) => Err(SessionControlError::Invalid(format!(
673            "`{}` performs `sessions.{}` by typing `{command}` into a LIVE driven session; call \
674             it with an open runtime `connection`",
675            mutation.harness,
676            verb.as_str()
677        ))),
678        SessionDoor::Cli => {
679            let command = cli_command(verb, mutation, &session)?;
680            let ran = command.narrate();
681            command.run()?;
682            let row = read_back(mutation, &session)?;
683            finish(verb, mutation, session, ran, row)
684        }
685        SessionDoor::Store => {
686            let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
687                SessionControlError::Failed(format!(
688                    "Volter Harness's session store is unreadable: {error}"
689                ))
690            })?;
691            let ran = format!(
692                "supercode store {} {}",
693                verb.as_str(),
694                shell_quote(&session)
695            );
696            match verb {
697                SessionVerb::Archive => store.archive(&session),
698                SessionVerb::Delete => store.delete(&session),
699                _ => unreachable!("the door table only routes archive/delete to the store"),
700            }
701            .map_err(|error| SessionControlError::Failed(format!("`{ran}` failed: {error}")))?;
702            let row = read_back(mutation, &session)?;
703            finish(verb, mutation, session, ran, row)
704        }
705        SessionDoor::Daemon => orchestrator_mutate(verb, mutation),
706    }
707}
708
709// ---------------------------------------------------------------------------
710// The orchestrator — its own daemon's operator door (ORC-13)
711// ---------------------------------------------------------------------------
712
713/// `sessions.new|reset --harness orchestrator --surface <key>`.
714///
715/// The verb ends the LIVE binding on that surface, which is exactly what the
716/// `/new` and `/reset` chat commands do when a human types them into the
717/// conversation (`docs/ORCHESTRATOR-IR.md` §4.5) — the same reducer, reached
718/// through the daemon's operator door instead of through a chat message.
719/// Afterwards the binding is re-read through the ORCH-6 discovery loader, the
720/// same reader `sessions list --harness orchestrator` uses.
721fn orchestrator_mutate(
722    verb: SessionVerb,
723    mutation: &SessionMutation,
724) -> Result<SessionMutationOutcome> {
725    let surface = mutation
726        .surface
727        .as_deref()
728        .map(str::trim)
729        .filter(|surface| !surface.is_empty())
730        .ok_or_else(|| {
731            SessionControlError::Invalid(format!(
732                "an orchestrator conversation is a BINDING on a surface, not a store row: \
733                 `sessions.{}` needs `--surface \
734                 <platform|chat_type|chat_id|thread_id|participant_id>` \
735                 (`supercode sessions list --harness orchestrator` prints the surface of every \
736                 binding)",
737                verb.as_str()
738            ))
739        })?;
740    let root = mutation.homes.orchestrator.clone();
741    let profile = mutation
742        .profile
743        .as_deref()
744        .map(str::trim)
745        .filter(|profile| !profile.is_empty())
746        .unwrap_or("default");
747    let op = match verb {
748        SessionVerb::New => "sessions.new",
749        SessionVerb::Reset => "sessions.reset",
750        other => {
751            return Err(SessionControlError::Unsupported(format!(
752                "the orchestrator has no door for `sessions.{}`",
753                other.as_str()
754            )))
755        }
756    };
757    let args = serde_json::json!({ "surface": surface });
758    let answer = crate::orchestrator_door::call(&root, op, &args, profile).map_err(|error| {
759        match error {
760            // The package refused: its sentence is the answer, exactly as a
761            // harness's own stderr is for the CLI doors.
762            crate::orchestrator_door::DoorError::Refused(message) => {
763                SessionControlError::Failed(message)
764            }
765            crate::orchestrator_door::DoorError::Failed(message) => {
766                SessionControlError::Failed(message)
767            }
768        }
769    })?;
770    let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
771    let session = answer
772        .result
773        .pointer("/binding/session_id")
774        .and_then(Value::as_str)
775        .filter(|id| !id.is_empty())
776        .unwrap_or(surface)
777        .to_string();
778    // The FOLDER is the answer: the ended binding, read back through the
779    // discovery loader on its own surface.
780    let row = orchestrator_read_back(mutation, surface, &ran)?;
781    Ok(SessionMutationOutcome {
782        harness: mutation.harness.clone(),
783        verb: verb.as_str().to_string(),
784        ran,
785        session,
786        row,
787        archived: None,
788        deleted: None,
789    })
790}
791
792/// The newest binding on `surface`, through the ORCH-6 discovery loader.
793///
794/// A binding is addressed by its surface, and the loader renders that surface
795/// as Hermes's `agent:<profile>:<platform>:<chat_type>[:…]` key, so the match
796/// is on the surface COLUMNS the descriptor carries, never on a string the
797/// caller typed.
798fn orchestrator_read_back(
799    mutation: &SessionMutation,
800    surface: &str,
801    ran: &str,
802) -> Result<Option<Value>> {
803    let page = crate::discover_session_page(&DiscoveryQuery {
804        harnesses: vec![HarnessId::new(mutation.harness.clone())],
805        homes: mutation.homes.clone(),
806        include_child_sessions: true,
807        ..DiscoveryQuery::default()
808    })
809    .map_err(|error| {
810        SessionControlError::Failed(format!(
811            "`{ran}` succeeded but the orchestrator's binding store could not be re-read: {error}"
812        ))
813    })?;
814    let wanted = surface_columns(surface);
815    let mut best: Option<Value> = None;
816    let mut best_at = 0;
817    for descriptor in page.sessions {
818        let key = descriptor.nouns.surface.as_ref();
819        let found = [
820            key.and_then(|k| k.platform.clone()).unwrap_or_default(),
821            key.and_then(|k| k.kind.clone()).unwrap_or_default(),
822            key.and_then(|k| k.chat_id.clone()).unwrap_or_default(),
823            key.and_then(|k| k.thread_id.clone()).unwrap_or_default(),
824            key.and_then(|k| k.participant_id.clone())
825                .unwrap_or_default(),
826        ];
827        if found != wanted {
828            continue;
829        }
830        let at = descriptor.updated_at_ms.unwrap_or_default();
831        if best.is_none() || at >= best_at {
832            best_at = at;
833            best = Some(serde_json::to_value(&descriptor).unwrap_or(Value::Null));
834        }
835    }
836    Ok(best)
837}
838
839/// A surface key string split into its five columns, empty for the absent
840/// ones — the inverse of the IR's `surfaceKeyString`.
841fn surface_columns(surface: &str) -> [String; 5] {
842    let mut parts = surface.split('|');
843    std::array::from_fn(|_| parts.next().unwrap_or("").to_string())
844}
845
846/// Turn a completed door into the outcome, enforcing that the harness's own
847/// store agrees with what the door claimed.
848fn finish(
849    verb: SessionVerb,
850    mutation: &SessionMutation,
851    session: String,
852    ran: String,
853    row: Option<Value>,
854) -> Result<SessionMutationOutcome> {
855    let outcome = SessionMutationOutcome {
856        harness: mutation.harness.clone(),
857        verb: verb.as_str().to_string(),
858        ran: ran.clone(),
859        session: session.clone(),
860        row: row.clone(),
861        archived: None,
862        deleted: None,
863    };
864    match verb {
865        SessionVerb::Delete => {
866            if row.is_some() {
867                return Err(SessionControlError::Failed(format!(
868                    "`{ran}` reported success but `{session}` is still in {}'s conversation store",
869                    mutation.harness
870                )));
871            }
872            Ok(SessionMutationOutcome {
873                row: None,
874                deleted: Some(true),
875                ..outcome
876            })
877        }
878        SessionVerb::Archive => {
879            if !archive_took_effect(mutation, row.as_ref()) {
880                return Err(SessionControlError::Failed(format!(
881                    "`{ran}` reported success but {}'s store still lists `{session}` as an \
882                     active conversation",
883                    mutation.harness
884                )));
885            }
886            Ok(SessionMutationOutcome {
887                archived: Some(true),
888                ..outcome
889            })
890        }
891        SessionVerb::New | SessionVerb::Reset => Ok(outcome),
892    }
893}
894
895/// Did the harness's own store record the archive?
896///
897/// Each harness answers in its own terms and none of them is guessed at:
898///
899/// * **supercode** publishes an `archived` flag on the row.
900/// * **codex** MOVES the rollout out of `$CODEX_HOME/sessions` into
901///   `archived_sessions/` (executed 2026-09-03 on an isolated `CODEX_HOME`;
902///   receipt `docs/interop/research/orch19-codex-sessions-receipt-*.json`), so
903///   its disappearance from the active listing IS the store's answer.
904/// * **opencode** stamps `time.archived` on the session record its own API
905///   returns; a `404` (the server dropped it) also counts as archived.
906fn archive_took_effect(mutation: &SessionMutation, row: Option<&Value>) -> bool {
907    let Some(row) = row else {
908        return true;
909    };
910    if mutation.harness == HarnessId::SUPERCODE {
911        return row
912            .get("archived")
913            .and_then(Value::as_bool)
914            .unwrap_or(false);
915    }
916    row.pointer("/time/archived")
917        .is_some_and(|value| !value.is_null())
918}
919
920/// Translate one verb onto the harness's own CLI invocation.
921fn cli_command(
922    verb: SessionVerb,
923    mutation: &SessionMutation,
924    session: &str,
925) -> Result<HarnessCommand> {
926    match (mutation.harness.as_str(), verb) {
927        (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => {
928            let mut command = HarnessCommand::new(harness_program(HarnessId::CODEX)?);
929            command.env("CODEX_HOME", codex_home(mutation).to_string_lossy());
930            command.args([verb.as_str(), session]);
931            if matches!(verb, SessionVerb::Delete) {
932                // Measured 2026-09-03 on codex 0.152: without `--force` the
933                // delete refuses outright off a TTY ("cannot confirm session
934                // deletion without an interactive terminal"). supercode never
935                // drives a prompt it cannot see, so it passes the harness's
936                // own non-interactive flag; the caller already asked for a
937                // delete, and the verification read is what proves it landed.
938                command.args(["--force"]);
939            }
940            Ok(command)
941        }
942        (HarnessId::HERMES, SessionVerb::Delete) => {
943            let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
944            command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
945            // `--yes` skips hermes's own interactive confirmation; supercode
946            // never drives a prompt it cannot see.
947            command.args(["sessions", "delete", session, "--yes"]);
948            Ok(command)
949        }
950        (harness, verb) => Err(SessionControlError::Unsupported(format!(
951            "`{harness}` has no CLI verb for `sessions.{}`",
952            verb.as_str()
953        ))),
954    }
955}
956
957// ---------------------------------------------------------------------------
958// OpenCode — its own HTTP session API
959// ---------------------------------------------------------------------------
960
961/// OpenCode's running server, resolved the same way for the mutation and for
962/// the re-read that verifies it.
963///
964/// `base_url` is required — an endpoint is a fact about the caller's
965/// environment, and guessing one would mutate whichever OpenCode happened to
966/// be listening. The bearer, when present, is sent as a sensitive header and
967/// never appears in the narration.
968fn opencode_endpoint(mutation: &SessionMutation) -> Result<(String, reqwest::Client)> {
969    let base = mutation
970        .base_url
971        .as_deref()
972        .map(|url| url.trim_end_matches('/').to_string())
973        .ok_or_else(|| {
974            SessionControlError::Invalid(
975                "opencode conversations are mutated through its own running server: pass \
976                 `base_url` (the address `runtimes.start` reports, or an `opencode serve` you \
977                 already run)"
978                    .into(),
979            )
980        })?;
981    let mut headers = reqwest::header::HeaderMap::new();
982    if let Some(bearer) = mutation.bearer.as_deref().filter(|t| !t.trim().is_empty()) {
983        let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {bearer}"))
984            .map_err(|_| {
985                SessionControlError::Invalid(
986                    "the opencode bearer token is not a valid header value".into(),
987                )
988            })?;
989        value.set_sensitive(true);
990        headers.insert(reqwest::header::AUTHORIZATION, value);
991    }
992    let client = reqwest::Client::builder()
993        .default_headers(headers)
994        .build()
995        .map_err(|error| {
996            SessionControlError::Failed(format!("could not build the HTTP client: {error}"))
997        })?;
998    Ok((base, client))
999}
1000
1001/// Re-read one conversation through OPENCODE'S OWN session API.
1002///
1003/// `None` means the server no longer holds it (`404`), which is exactly what a
1004/// successful delete must produce. Anything else the server says — including
1005/// the `time.archived` stamp an archive leaves — comes back verbatim.
1006async fn opencode_read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
1007    let (base, client) = opencode_endpoint(mutation)?;
1008    let url = format!("{base}/session/{session}");
1009    let mut request = client.get(&url);
1010    if let Some(cwd) = mutation.cwd.as_ref() {
1011        request = request.query(&[("directory", cwd.to_string_lossy().into_owned())]);
1012    }
1013    let response = request.send().await.map_err(|error| {
1014        SessionControlError::Failed(format!("`GET {url}` could not be sent: {error}"))
1015    })?;
1016    if response.status() == reqwest::StatusCode::NOT_FOUND {
1017        return Ok(None);
1018    }
1019    let status = response.status();
1020    if !status.is_success() {
1021        let body = response.text().await.unwrap_or_default();
1022        return Err(SessionControlError::Failed(format!(
1023            "`GET {url}` failed ({status}): {}",
1024            body.trim()
1025        )));
1026    }
1027    response
1028        .json::<Value>()
1029        .await
1030        .map(|value| if value.is_null() { None } else { Some(value) })
1031        .map_err(|error| {
1032            SessionControlError::Failed(format!("`GET {url}` returned unreadable JSON: {error}"))
1033        })
1034}
1035
1036/// Call OpenCode's own session API and return the narration.
1037///
1038/// The endpoint is the RUNNING server's: supercode never opens OpenCode's
1039/// SQLite store to archive or delete a row.
1040async fn opencode_call(
1041    verb: SessionVerb,
1042    mutation: &SessionMutation,
1043    session: &str,
1044) -> Result<String> {
1045    let (base, client) = opencode_endpoint(mutation)?;
1046    let url = format!("{base}/session/{session}");
1047    let directory = mutation
1048        .cwd
1049        .as_ref()
1050        .map(|cwd| cwd.to_string_lossy().into_owned());
1051    let (ran, request) = match verb {
1052        SessionVerb::Delete => (format!("DELETE {url}"), client.delete(&url)),
1053        SessionVerb::Archive => {
1054            // OpenCode records the archive as `time.archived` on the session
1055            // record (`packages/schema/src/v1/session.ts`), patched through
1056            // the same route that renames a session. The re-read in `finish`
1057            // is what PROVES it landed: a payload the server ignores leaves
1058            // `time.archived` unset and the mutation fails.
1059            let now = std::time::SystemTime::now()
1060                .duration_since(std::time::UNIX_EPOCH)
1061                .map(|since| since.as_millis() as u64)
1062                .unwrap_or_default();
1063            (
1064                format!("PATCH {url} {{\"time\":{{\"archived\":{now}}}}}"),
1065                client
1066                    .patch(&url)
1067                    .json(&serde_json::json!({"time": {"archived": now}})),
1068            )
1069        }
1070        other => {
1071            return Err(SessionControlError::Unsupported(format!(
1072                "opencode has no HTTP door for `sessions.{}`",
1073                other.as_str()
1074            )));
1075        }
1076    };
1077    let request = match &directory {
1078        Some(directory) => request.query(&[("directory", directory)]),
1079        None => request,
1080    };
1081    let response = request.send().await.map_err(|error| {
1082        SessionControlError::Failed(format!("`{ran}` could not be sent: {error}"))
1083    })?;
1084    let status = response.status();
1085    if !status.is_success() {
1086        let body = response.text().await.unwrap_or_default();
1087        return Err(SessionControlError::Failed(format!(
1088            "`{ran}` failed ({status}): {}",
1089            if body.trim().is_empty() {
1090                "the server returned no body".to_string()
1091            } else {
1092                body.trim().to_string()
1093            }
1094        )));
1095    }
1096    Ok(ran)
1097}
1098
1099/// Build the outcome for a slash-command door the SERVICE performed, so the
1100/// live path and the subprocess path publish exactly the same shape.
1101pub fn live_outcome(
1102    verb: SessionVerb,
1103    mutation: &SessionMutation,
1104    command: &str,
1105    session: String,
1106) -> Result<SessionMutationOutcome> {
1107    let row = read_back(mutation, &session).unwrap_or(None);
1108    Ok(SessionMutationOutcome {
1109        harness: mutation.harness.clone(),
1110        verb: verb.as_str().to_string(),
1111        ran: format!("{} live session: {command}", mutation.harness),
1112        session,
1113        row,
1114        archived: None,
1115        deleted: None,
1116    })
1117}