1use std::path::{Path, PathBuf};
84use std::process::Command;
85
86use serde::{Deserialize, Serialize};
87use serde_json::Value;
88
89use crate::{DiscoveryQuery, HarnessHomes, HarnessId};
90
91pub const CODEX_BIN_ENV: &str = "SUPERCODE_CODEX_BIN";
93pub const HERMES_BIN_ENV: &str = "SUPERCODE_HERMES_BIN";
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum SessionVerb {
100 New,
102 Reset,
104 Archive,
106 Delete,
108}
109
110impl SessionVerb {
111 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 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 const fn needs_session(self) -> bool {
133 !matches!(self, Self::New)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum SessionDoor {
144 Cli,
146 Http,
148 Live(&'static str),
151 Store,
153 Daemon,
158}
159
160#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
162pub struct SessionMutation {
163 pub harness: String,
165 #[serde(default)]
168 pub session: Option<String>,
169 #[serde(default)]
171 pub cwd: Option<PathBuf>,
172 #[serde(default)]
174 pub connection: Option<String>,
175 #[serde(default)]
178 pub base_url: Option<String>,
179 #[serde(default)]
182 pub bearer: Option<String>,
183 #[serde(default)]
186 pub profile: Option<String>,
187 #[serde(default)]
192 pub surface: Option<String>,
193 #[serde(default)]
196 pub homes: HarnessHomes,
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct SessionMutationOutcome {
202 pub harness: String,
204 pub verb: String,
206 pub ran: String,
208 pub session: String,
210 #[serde(skip_serializing_if = "Option::is_none")]
214 pub row: Option<Value>,
215 #[serde(skip_serializing_if = "Option::is_none")]
217 pub archived: Option<bool>,
218 #[serde(skip_serializing_if = "Option::is_none")]
220 pub deleted: Option<bool>,
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum SessionControlError {
226 Unsupported(String),
228 Invalid(String),
230 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
248pub 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
259const 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
280pub fn supports_session_control(harness: &str) -> bool {
282 CONTROLLED_SESSION_HARNESSES.contains(&harness)
283}
284
285pub const ALL_SESSION_VERBS: [SessionVerb; 4] = [
287 SessionVerb::New,
288 SessionVerb::Reset,
289 SessionVerb::Archive,
290 SessionVerb::Delete,
291];
292
293pub 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
303pub 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
314pub fn door(harness: &str, verb: SessionVerb) -> Result<SessionDoor> {
321 match (harness, verb) {
322 (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => Ok(SessionDoor::Cli),
324 (HarnessId::OPENCODE, SessionVerb::Archive | SessionVerb::Delete) => Ok(SessionDoor::Http),
326 (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 (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 (HarnessId::SUPERCODE, SessionVerb::Archive | SessionVerb::Delete) => {
360 Ok(SessionDoor::Store)
361 }
362 (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 (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 (_, 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
409fn 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#[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 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 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
497pub fn harness_program(harness: &str) -> Result<String> {
502 let variable = match harness {
503 HarnessId::CODEX => CODEX_BIN_ENV,
504 HarnessId::HERMES => HERMES_BIN_ENV,
505 other => {
506 return Err(SessionControlError::Unsupported(format!(
507 "`{other}` has no conversation CLI supercode calls"
508 )));
509 }
510 };
511 if let Some(over) = std::env::var_os(variable) {
512 let over = over.to_string_lossy().trim().to_string();
513 if !over.is_empty() {
514 return Ok(over);
515 }
516 }
517 let program = crate::harness_support(harness)
518 .and_then(|descriptor| descriptor.runtime.default_launch)
519 .map(|launch| launch.program)
520 .ok_or_else(|| {
521 SessionControlError::Unsupported(format!(
522 "the registry has no launch for `{harness}`, so its CLI cannot be located"
523 ))
524 })?;
525 Ok(program.strip_suffix("-acp").unwrap_or(&program).to_string())
526}
527
528fn 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
544fn 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
556fn read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
563 if mutation.harness == HarnessId::SUPERCODE {
564 let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
565 SessionControlError::Failed(format!("supercode's session store is unreadable: {error}"))
566 })?;
567 return Ok(store
568 .list()
569 .into_iter()
570 .find(|info| info.name == session)
571 .map(|info| serde_json::to_value(info).unwrap_or(Value::Null)));
572 }
573 let page = crate::discover_session_page(&DiscoveryQuery {
574 harnesses: vec![HarnessId::new(mutation.harness.clone())],
575 homes: mutation.homes.clone(),
576 include_child_sessions: true,
577 ..DiscoveryQuery::default()
578 })
579 .map_err(|error| {
580 SessionControlError::Failed(format!(
581 "the {} conversation store could not be re-read: {error}",
582 mutation.harness
583 ))
584 })?;
585 Ok(page
586 .sessions
587 .into_iter()
588 .find(|descriptor| descriptor.locator.session_id == session)
589 .map(|descriptor| serde_json::to_value(descriptor).unwrap_or(Value::Null)))
590}
591
592pub async fn mutate(
604 verb: SessionVerb,
605 mutation: &SessionMutation,
606) -> Result<SessionMutationOutcome> {
607 let door = door(&mutation.harness, verb)?;
608 let session = mutation.session.as_deref().unwrap_or("").trim().to_string();
609 if verb.needs_session() && session.is_empty() && !matches!(door, SessionDoor::Daemon) {
612 return Err(SessionControlError::Invalid(format!(
613 "`sessions.{}` needs the conversation to act on",
614 verb.as_str()
615 )));
616 }
617 match door {
618 SessionDoor::Live(command) => Err(SessionControlError::Invalid(format!(
619 "`{}` performs `sessions.{}` by typing `{command}` into a LIVE driven session; call \
620 it with an open runtime `connection`",
621 mutation.harness,
622 verb.as_str()
623 ))),
624 SessionDoor::Cli => {
625 let command = cli_command(verb, mutation, &session)?;
626 let ran = command.narrate();
627 command.run()?;
628 let row = read_back(mutation, &session)?;
629 finish(verb, mutation, session, ran, row)
630 }
631 SessionDoor::Store => {
632 let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
633 SessionControlError::Failed(format!(
634 "supercode's session store is unreadable: {error}"
635 ))
636 })?;
637 let ran = format!(
638 "supercode store {} {}",
639 verb.as_str(),
640 shell_quote(&session)
641 );
642 match verb {
643 SessionVerb::Archive => store.archive(&session),
644 SessionVerb::Delete => store.delete(&session),
645 _ => unreachable!("the door table only routes archive/delete to the store"),
646 }
647 .map_err(|error| SessionControlError::Failed(format!("`{ran}` failed: {error}")))?;
648 let row = read_back(mutation, &session)?;
649 finish(verb, mutation, session, ran, row)
650 }
651 SessionDoor::Daemon => orchestrator_mutate(verb, mutation),
652 SessionDoor::Http => {
653 let ran = opencode_call(verb, mutation, &session).await?;
654 let row = opencode_read_back(mutation, &session).await?;
658 finish(verb, mutation, session, ran, row)
659 }
660 }
661}
662
663fn orchestrator_mutate(
676 verb: SessionVerb,
677 mutation: &SessionMutation,
678) -> Result<SessionMutationOutcome> {
679 let surface = mutation
680 .surface
681 .as_deref()
682 .map(str::trim)
683 .filter(|surface| !surface.is_empty())
684 .ok_or_else(|| {
685 SessionControlError::Invalid(format!(
686 "an orchestrator conversation is a BINDING on a surface, not a store row: \
687 `sessions.{}` needs `--surface \
688 <platform|chat_type|chat_id|thread_id|participant_id>` \
689 (`supercode sessions list --harness orchestrator` prints the surface of every \
690 binding)",
691 verb.as_str()
692 ))
693 })?;
694 let root = mutation.homes.orchestrator.clone();
695 let profile = mutation
696 .profile
697 .as_deref()
698 .map(str::trim)
699 .filter(|profile| !profile.is_empty())
700 .unwrap_or("default");
701 let op = match verb {
702 SessionVerb::New => "sessions.new",
703 SessionVerb::Reset => "sessions.reset",
704 other => {
705 return Err(SessionControlError::Unsupported(format!(
706 "the orchestrator has no door for `sessions.{}`",
707 other.as_str()
708 )))
709 }
710 };
711 let args = serde_json::json!({ "surface": surface });
712 let answer = crate::orchestrator_door::call(&root, op, &args, profile).map_err(|error| {
713 match error {
714 crate::orchestrator_door::DoorError::Refused(message) => {
717 SessionControlError::Failed(message)
718 }
719 crate::orchestrator_door::DoorError::Failed(message) => {
720 SessionControlError::Failed(message)
721 }
722 }
723 })?;
724 let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
725 let session = answer
726 .result
727 .pointer("/binding/session_id")
728 .and_then(Value::as_str)
729 .filter(|id| !id.is_empty())
730 .unwrap_or(surface)
731 .to_string();
732 let row = orchestrator_read_back(mutation, surface, &ran)?;
735 Ok(SessionMutationOutcome {
736 harness: mutation.harness.clone(),
737 verb: verb.as_str().to_string(),
738 ran,
739 session,
740 row,
741 archived: None,
742 deleted: None,
743 })
744}
745
746fn orchestrator_read_back(
753 mutation: &SessionMutation,
754 surface: &str,
755 ran: &str,
756) -> Result<Option<Value>> {
757 let page = crate::discover_session_page(&DiscoveryQuery {
758 harnesses: vec![HarnessId::new(mutation.harness.clone())],
759 homes: mutation.homes.clone(),
760 include_child_sessions: true,
761 ..DiscoveryQuery::default()
762 })
763 .map_err(|error| {
764 SessionControlError::Failed(format!(
765 "`{ran}` succeeded but the orchestrator's binding store could not be re-read: {error}"
766 ))
767 })?;
768 let wanted = surface_columns(surface);
769 let mut best: Option<Value> = None;
770 let mut best_at = 0;
771 for descriptor in page.sessions {
772 let key = descriptor.nouns.surface.as_ref();
773 let found = [
774 key.and_then(|k| k.platform.clone()).unwrap_or_default(),
775 key.and_then(|k| k.kind.clone()).unwrap_or_default(),
776 key.and_then(|k| k.chat_id.clone()).unwrap_or_default(),
777 key.and_then(|k| k.thread_id.clone()).unwrap_or_default(),
778 key.and_then(|k| k.participant_id.clone())
779 .unwrap_or_default(),
780 ];
781 if found != wanted {
782 continue;
783 }
784 let at = descriptor.updated_at_ms.unwrap_or_default();
785 if best.is_none() || at >= best_at {
786 best_at = at;
787 best = Some(serde_json::to_value(&descriptor).unwrap_or(Value::Null));
788 }
789 }
790 Ok(best)
791}
792
793fn surface_columns(surface: &str) -> [String; 5] {
796 let mut parts = surface.split('|');
797 std::array::from_fn(|_| parts.next().unwrap_or("").to_string())
798}
799
800fn finish(
803 verb: SessionVerb,
804 mutation: &SessionMutation,
805 session: String,
806 ran: String,
807 row: Option<Value>,
808) -> Result<SessionMutationOutcome> {
809 let outcome = SessionMutationOutcome {
810 harness: mutation.harness.clone(),
811 verb: verb.as_str().to_string(),
812 ran: ran.clone(),
813 session: session.clone(),
814 row: row.clone(),
815 archived: None,
816 deleted: None,
817 };
818 match verb {
819 SessionVerb::Delete => {
820 if row.is_some() {
821 return Err(SessionControlError::Failed(format!(
822 "`{ran}` reported success but `{session}` is still in {}'s conversation store",
823 mutation.harness
824 )));
825 }
826 Ok(SessionMutationOutcome {
827 row: None,
828 deleted: Some(true),
829 ..outcome
830 })
831 }
832 SessionVerb::Archive => {
833 if !archive_took_effect(mutation, row.as_ref()) {
834 return Err(SessionControlError::Failed(format!(
835 "`{ran}` reported success but {}'s store still lists `{session}` as an \
836 active conversation",
837 mutation.harness
838 )));
839 }
840 Ok(SessionMutationOutcome {
841 archived: Some(true),
842 ..outcome
843 })
844 }
845 SessionVerb::New | SessionVerb::Reset => Ok(outcome),
846 }
847}
848
849fn archive_took_effect(mutation: &SessionMutation, row: Option<&Value>) -> bool {
861 let Some(row) = row else {
862 return true;
863 };
864 if mutation.harness == HarnessId::SUPERCODE {
865 return row
866 .get("archived")
867 .and_then(Value::as_bool)
868 .unwrap_or(false);
869 }
870 row.pointer("/time/archived")
871 .is_some_and(|value| !value.is_null())
872}
873
874fn cli_command(
876 verb: SessionVerb,
877 mutation: &SessionMutation,
878 session: &str,
879) -> Result<HarnessCommand> {
880 match (mutation.harness.as_str(), verb) {
881 (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => {
882 let mut command = HarnessCommand::new(harness_program(HarnessId::CODEX)?);
883 command.env("CODEX_HOME", codex_home(mutation).to_string_lossy());
884 command.args([verb.as_str(), session]);
885 if matches!(verb, SessionVerb::Delete) {
886 command.args(["--force"]);
893 }
894 Ok(command)
895 }
896 (HarnessId::HERMES, SessionVerb::Delete) => {
897 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
898 command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
899 command.args(["sessions", "delete", session, "--yes"]);
902 Ok(command)
903 }
904 (harness, verb) => Err(SessionControlError::Unsupported(format!(
905 "`{harness}` has no CLI verb for `sessions.{}`",
906 verb.as_str()
907 ))),
908 }
909}
910
911fn opencode_endpoint(mutation: &SessionMutation) -> Result<(String, reqwest::Client)> {
923 let base = mutation
924 .base_url
925 .as_deref()
926 .map(|url| url.trim_end_matches('/').to_string())
927 .ok_or_else(|| {
928 SessionControlError::Invalid(
929 "opencode conversations are mutated through its own running server: pass \
930 `base_url` (the address `runtimes.start` reports, or an `opencode serve` you \
931 already run)"
932 .into(),
933 )
934 })?;
935 let mut headers = reqwest::header::HeaderMap::new();
936 if let Some(bearer) = mutation.bearer.as_deref().filter(|t| !t.trim().is_empty()) {
937 let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {bearer}"))
938 .map_err(|_| {
939 SessionControlError::Invalid(
940 "the opencode bearer token is not a valid header value".into(),
941 )
942 })?;
943 value.set_sensitive(true);
944 headers.insert(reqwest::header::AUTHORIZATION, value);
945 }
946 let client = reqwest::Client::builder()
947 .default_headers(headers)
948 .build()
949 .map_err(|error| {
950 SessionControlError::Failed(format!("could not build the HTTP client: {error}"))
951 })?;
952 Ok((base, client))
953}
954
955async fn opencode_read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
961 let (base, client) = opencode_endpoint(mutation)?;
962 let url = format!("{base}/session/{session}");
963 let mut request = client.get(&url);
964 if let Some(cwd) = mutation.cwd.as_ref() {
965 request = request.query(&[("directory", cwd.to_string_lossy().into_owned())]);
966 }
967 let response = request.send().await.map_err(|error| {
968 SessionControlError::Failed(format!("`GET {url}` could not be sent: {error}"))
969 })?;
970 if response.status() == reqwest::StatusCode::NOT_FOUND {
971 return Ok(None);
972 }
973 let status = response.status();
974 if !status.is_success() {
975 let body = response.text().await.unwrap_or_default();
976 return Err(SessionControlError::Failed(format!(
977 "`GET {url}` failed ({status}): {}",
978 body.trim()
979 )));
980 }
981 response
982 .json::<Value>()
983 .await
984 .map(|value| if value.is_null() { None } else { Some(value) })
985 .map_err(|error| {
986 SessionControlError::Failed(format!("`GET {url}` returned unreadable JSON: {error}"))
987 })
988}
989
990async fn opencode_call(
995 verb: SessionVerb,
996 mutation: &SessionMutation,
997 session: &str,
998) -> Result<String> {
999 let (base, client) = opencode_endpoint(mutation)?;
1000 let url = format!("{base}/session/{session}");
1001 let directory = mutation
1002 .cwd
1003 .as_ref()
1004 .map(|cwd| cwd.to_string_lossy().into_owned());
1005 let (ran, request) = match verb {
1006 SessionVerb::Delete => (format!("DELETE {url}"), client.delete(&url)),
1007 SessionVerb::Archive => {
1008 let now = std::time::SystemTime::now()
1014 .duration_since(std::time::UNIX_EPOCH)
1015 .map(|since| since.as_millis() as u64)
1016 .unwrap_or_default();
1017 (
1018 format!("PATCH {url} {{\"time\":{{\"archived\":{now}}}}}"),
1019 client
1020 .patch(&url)
1021 .json(&serde_json::json!({"time": {"archived": now}})),
1022 )
1023 }
1024 other => {
1025 return Err(SessionControlError::Unsupported(format!(
1026 "opencode has no HTTP door for `sessions.{}`",
1027 other.as_str()
1028 )));
1029 }
1030 };
1031 let request = match &directory {
1032 Some(directory) => request.query(&[("directory", directory)]),
1033 None => request,
1034 };
1035 let response = request.send().await.map_err(|error| {
1036 SessionControlError::Failed(format!("`{ran}` could not be sent: {error}"))
1037 })?;
1038 let status = response.status();
1039 if !status.is_success() {
1040 let body = response.text().await.unwrap_or_default();
1041 return Err(SessionControlError::Failed(format!(
1042 "`{ran}` failed ({status}): {}",
1043 if body.trim().is_empty() {
1044 "the server returned no body".to_string()
1045 } else {
1046 body.trim().to_string()
1047 }
1048 )));
1049 }
1050 Ok(ran)
1051}
1052
1053pub fn live_outcome(
1056 verb: SessionVerb,
1057 mutation: &SessionMutation,
1058 command: &str,
1059 session: String,
1060) -> Result<SessionMutationOutcome> {
1061 let row = read_back(mutation, &session).unwrap_or(None);
1062 Ok(SessionMutationOutcome {
1063 harness: mutation.harness.clone(),
1064 verb: verb.as_str().to_string(),
1065 ran: format!("{} live session: {command}", mutation.harness),
1066 session,
1067 row,
1068 archived: None,
1069 deleted: None,
1070 })
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use super::*;
1076
1077 #[test]
1078 fn the_door_table_names_one_door_per_supported_pair() {
1079 assert_eq!(
1080 door(HarnessId::CODEX, SessionVerb::Archive).unwrap(),
1081 SessionDoor::Cli
1082 );
1083 assert_eq!(
1084 door(HarnessId::OPENCODE, SessionVerb::Delete).unwrap(),
1085 SessionDoor::Http
1086 );
1087 assert_eq!(
1088 door(HarnessId::OPENCLAW, SessionVerb::New).unwrap(),
1089 SessionDoor::Live("/new")
1090 );
1091 assert_eq!(
1092 door(HarnessId::OPENCLAW, SessionVerb::Reset).unwrap(),
1093 SessionDoor::Live("/reset")
1094 );
1095 assert_eq!(
1096 door(HarnessId::HERMES, SessionVerb::Reset).unwrap(),
1097 SessionDoor::Live("/reset")
1098 );
1099 assert_eq!(
1100 door(HarnessId::HERMES, SessionVerb::Delete).unwrap(),
1101 SessionDoor::Cli
1102 );
1103 assert_eq!(
1104 door(HarnessId::SUPERCODE, SessionVerb::Archive).unwrap(),
1105 SessionDoor::Store
1106 );
1107 }
1108
1109 #[test]
1110 fn every_refusal_names_the_reason_and_never_a_silent_no_op() {
1111 for (harness, verb, needle) in [
1112 (HarnessId::HERMES, SessionVerb::Archive, "BULK filter verb"),
1113 (
1116 HarnessId::HERMES,
1117 SessionVerb::New,
1118 "sends any UNRECOGNIZED `/word` to the model as prose",
1119 ),
1120 (HarnessId::OPENCLAW, SessionVerb::Delete, "v2026.7.1-2"),
1121 (
1122 HarnessId::CLAUDE_CODE,
1123 SessionVerb::Delete,
1124 "RETENTION WINDOW",
1125 ),
1126 (
1127 HarnessId::CLAUDE_CODE,
1128 SessionVerb::New,
1129 "harness.v1.runtimes.start",
1130 ),
1131 (
1132 HarnessId::CODEX,
1133 SessionVerb::New,
1134 "harness.v1.runtimes.start",
1135 ),
1136 (
1137 HarnessId::SUPERCODE,
1138 SessionVerb::Reset,
1139 "no conversation reset verb",
1140 ),
1141 (
1145 HarnessId::ORCHESTRATOR,
1146 SessionVerb::Archive,
1147 "a binding is never archived or deleted",
1148 ),
1149 ] {
1150 let error = door(harness, verb).unwrap_err();
1151 assert!(
1152 matches!(error, SessionControlError::Unsupported(_)),
1153 "{harness}.{}: {error}",
1154 verb.as_str()
1155 );
1156 assert!(
1157 error.to_string().contains(needle),
1158 "{harness}.{} must explain itself, got: {error}",
1159 verb.as_str()
1160 );
1161 }
1162 }
1163
1164 #[test]
1165 fn controlled_verbs_track_the_door_table() {
1166 assert_eq!(
1167 controlled_verbs(HarnessId::CODEX),
1168 vec!["archive", "delete"]
1169 );
1170 assert_eq!(controlled_verbs(HarnessId::HERMES), vec!["reset", "delete"]);
1173 assert_eq!(controlled_verbs(HarnessId::OPENCLAW), vec!["new", "reset"]);
1174 assert_eq!(
1175 controlled_verbs(HarnessId::OPENCODE),
1176 vec!["archive", "delete"]
1177 );
1178 assert_eq!(
1179 controlled_verbs(HarnessId::SUPERCODE),
1180 vec!["archive", "delete"]
1181 );
1182 assert!(controlled_verbs(HarnessId::CLAUDE_CODE).is_empty());
1183 assert!(controlled_verbs(HarnessId::PI).is_empty());
1184 assert_eq!(
1188 controlled_verbs(HarnessId::ORCHESTRATOR),
1189 vec!["new", "reset"]
1190 );
1191 assert_eq!(
1192 door(HarnessId::ORCHESTRATOR, SessionVerb::Reset).unwrap(),
1193 SessionDoor::Daemon
1194 );
1195 assert!(controlled_verbs("not-a-harness").is_empty());
1196 for harness in crate::harness_support_registry().harnesses {
1197 assert_eq!(
1198 !controlled_verbs(harness.id.as_str()).is_empty(),
1199 supports_session_control(harness.id.as_str()),
1200 "{}: CONTROLLED_SESSION_HARNESSES must track the door table",
1201 harness.id.as_str()
1202 );
1203 }
1204 }
1205
1206 #[test]
1210 fn the_registered_harness_list_matches_the_compiled_registry() {
1211 let mut from_registry: Vec<String> = crate::harness_support_registry()
1212 .harnesses
1213 .into_iter()
1214 .map(|descriptor| descriptor.id.as_str().to_string())
1215 .collect();
1216 from_registry.sort();
1217 let mut declared: Vec<String> = REGISTERED_HARNESSES
1218 .iter()
1219 .map(|id| id.to_string())
1220 .collect();
1221 declared.sort();
1222 assert_eq!(declared, from_registry);
1223 }
1224
1225 #[test]
1226 fn codex_home_is_the_parent_of_the_sessions_root() {
1227 let mutation = SessionMutation {
1228 harness: HarnessId::CODEX.into(),
1229 homes: HarnessHomes {
1230 codex: PathBuf::from("/tmp/iso/.codex/sessions"),
1231 ..HarnessHomes::default()
1232 },
1233 ..SessionMutation::default()
1234 };
1235 assert_eq!(codex_home(&mutation), PathBuf::from("/tmp/iso/.codex"));
1236 }
1237
1238 #[test]
1239 fn a_hermes_profile_is_a_full_home() {
1240 let mutation = SessionMutation {
1241 harness: HarnessId::HERMES.into(),
1242 profile: Some("work".into()),
1243 homes: HarnessHomes {
1244 hermes: PathBuf::from("/tmp/iso/.hermes/state.db"),
1245 ..HarnessHomes::default()
1246 },
1247 ..SessionMutation::default()
1248 };
1249 assert_eq!(
1250 hermes_home(&mutation),
1251 PathBuf::from("/tmp/iso/.hermes/profiles/work")
1252 );
1253 }
1254
1255 #[tokio::test]
1256 async fn a_live_door_asked_for_out_of_band_says_so() {
1257 let error = mutate(
1258 SessionVerb::Reset,
1259 &SessionMutation {
1260 harness: HarnessId::HERMES.into(),
1261 session: Some("s1".into()),
1262 ..SessionMutation::default()
1263 },
1264 )
1265 .await
1266 .unwrap_err();
1267 assert!(matches!(error, SessionControlError::Invalid(_)));
1268 assert!(error.to_string().contains("/reset"));
1269 assert!(error.to_string().contains("connection"));
1270 }
1271
1272 #[tokio::test]
1273 async fn opencode_refuses_to_guess_an_endpoint() {
1274 let error = mutate(
1275 SessionVerb::Delete,
1276 &SessionMutation {
1277 harness: HarnessId::OPENCODE.into(),
1278 session: Some("ses_1".into()),
1279 ..SessionMutation::default()
1280 },
1281 )
1282 .await
1283 .unwrap_err();
1284 assert!(matches!(error, SessionControlError::Invalid(_)));
1285 assert!(error.to_string().contains("base_url"));
1286 }
1287
1288 #[tokio::test]
1289 async fn a_verb_without_its_conversation_is_invalid() {
1290 let error = mutate(
1291 SessionVerb::Delete,
1292 &SessionMutation {
1293 harness: HarnessId::CODEX.into(),
1294 ..SessionMutation::default()
1295 },
1296 )
1297 .await
1298 .unwrap_err();
1299 assert!(matches!(error, SessionControlError::Invalid(_)));
1300 assert!(error.to_string().contains("sessions.delete"));
1301 }
1302}