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