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 = target_session(verb, &door, mutation)?;
609 if let SessionDoor::Http = door {
610 let ran = opencode_call(verb, mutation, &session).await?;
611 let row = opencode_read_back(mutation, &session).await?;
615 return finish(verb, mutation, session, ran, row);
616 }
617 perform(verb, mutation, door, session)
618}
619
620pub fn mutate_blocking(
629 verb: SessionVerb,
630 mutation: &SessionMutation,
631) -> Result<SessionMutationOutcome> {
632 let door = door(&mutation.harness, verb)?;
633 let session = target_session(verb, &door, mutation)?;
634 perform(verb, mutation, door, session)
635}
636
637fn target_session(
640 verb: SessionVerb,
641 door: &SessionDoor,
642 mutation: &SessionMutation,
643) -> Result<String> {
644 let session = mutation.session.as_deref().unwrap_or("").trim().to_string();
645 if verb.needs_session() && session.is_empty() && !matches!(door, SessionDoor::Daemon) {
648 return Err(SessionControlError::Invalid(format!(
649 "`sessions.{}` needs the conversation to act on",
650 verb.as_str()
651 )));
652 }
653 Ok(session)
654}
655
656fn perform(
658 verb: SessionVerb,
659 mutation: &SessionMutation,
660 door: SessionDoor,
661 session: String,
662) -> Result<SessionMutationOutcome> {
663 match door {
664 SessionDoor::Http => Err(SessionControlError::Invalid(format!(
665 "`{}` performs `sessions.{}` through its own HTTP API, which is not a blocking \
666 door; call [`mutate`]",
667 mutation.harness,
668 verb.as_str()
669 ))),
670 SessionDoor::Live(command) => Err(SessionControlError::Invalid(format!(
671 "`{}` performs `sessions.{}` by typing `{command}` into a LIVE driven session; call \
672 it with an open runtime `connection`",
673 mutation.harness,
674 verb.as_str()
675 ))),
676 SessionDoor::Cli => {
677 let command = cli_command(verb, mutation, &session)?;
678 let ran = command.narrate();
679 command.run()?;
680 let row = read_back(mutation, &session)?;
681 finish(verb, mutation, session, ran, row)
682 }
683 SessionDoor::Store => {
684 let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
685 SessionControlError::Failed(format!(
686 "supercode's session store is unreadable: {error}"
687 ))
688 })?;
689 let ran = format!(
690 "supercode store {} {}",
691 verb.as_str(),
692 shell_quote(&session)
693 );
694 match verb {
695 SessionVerb::Archive => store.archive(&session),
696 SessionVerb::Delete => store.delete(&session),
697 _ => unreachable!("the door table only routes archive/delete to the store"),
698 }
699 .map_err(|error| SessionControlError::Failed(format!("`{ran}` failed: {error}")))?;
700 let row = read_back(mutation, &session)?;
701 finish(verb, mutation, session, ran, row)
702 }
703 SessionDoor::Daemon => orchestrator_mutate(verb, mutation),
704 }
705}
706
707fn orchestrator_mutate(
720 verb: SessionVerb,
721 mutation: &SessionMutation,
722) -> Result<SessionMutationOutcome> {
723 let surface = mutation
724 .surface
725 .as_deref()
726 .map(str::trim)
727 .filter(|surface| !surface.is_empty())
728 .ok_or_else(|| {
729 SessionControlError::Invalid(format!(
730 "an orchestrator conversation is a BINDING on a surface, not a store row: \
731 `sessions.{}` needs `--surface \
732 <platform|chat_type|chat_id|thread_id|participant_id>` \
733 (`supercode sessions list --harness orchestrator` prints the surface of every \
734 binding)",
735 verb.as_str()
736 ))
737 })?;
738 let root = mutation.homes.orchestrator.clone();
739 let profile = mutation
740 .profile
741 .as_deref()
742 .map(str::trim)
743 .filter(|profile| !profile.is_empty())
744 .unwrap_or("default");
745 let op = match verb {
746 SessionVerb::New => "sessions.new",
747 SessionVerb::Reset => "sessions.reset",
748 other => {
749 return Err(SessionControlError::Unsupported(format!(
750 "the orchestrator has no door for `sessions.{}`",
751 other.as_str()
752 )))
753 }
754 };
755 let args = serde_json::json!({ "surface": surface });
756 let answer = crate::orchestrator_door::call(&root, op, &args, profile).map_err(|error| {
757 match error {
758 crate::orchestrator_door::DoorError::Refused(message) => {
761 SessionControlError::Failed(message)
762 }
763 crate::orchestrator_door::DoorError::Failed(message) => {
764 SessionControlError::Failed(message)
765 }
766 }
767 })?;
768 let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
769 let session = answer
770 .result
771 .pointer("/binding/session_id")
772 .and_then(Value::as_str)
773 .filter(|id| !id.is_empty())
774 .unwrap_or(surface)
775 .to_string();
776 let row = orchestrator_read_back(mutation, surface, &ran)?;
779 Ok(SessionMutationOutcome {
780 harness: mutation.harness.clone(),
781 verb: verb.as_str().to_string(),
782 ran,
783 session,
784 row,
785 archived: None,
786 deleted: None,
787 })
788}
789
790fn orchestrator_read_back(
797 mutation: &SessionMutation,
798 surface: &str,
799 ran: &str,
800) -> Result<Option<Value>> {
801 let page = crate::discover_session_page(&DiscoveryQuery {
802 harnesses: vec![HarnessId::new(mutation.harness.clone())],
803 homes: mutation.homes.clone(),
804 include_child_sessions: true,
805 ..DiscoveryQuery::default()
806 })
807 .map_err(|error| {
808 SessionControlError::Failed(format!(
809 "`{ran}` succeeded but the orchestrator's binding store could not be re-read: {error}"
810 ))
811 })?;
812 let wanted = surface_columns(surface);
813 let mut best: Option<Value> = None;
814 let mut best_at = 0;
815 for descriptor in page.sessions {
816 let key = descriptor.nouns.surface.as_ref();
817 let found = [
818 key.and_then(|k| k.platform.clone()).unwrap_or_default(),
819 key.and_then(|k| k.kind.clone()).unwrap_or_default(),
820 key.and_then(|k| k.chat_id.clone()).unwrap_or_default(),
821 key.and_then(|k| k.thread_id.clone()).unwrap_or_default(),
822 key.and_then(|k| k.participant_id.clone())
823 .unwrap_or_default(),
824 ];
825 if found != wanted {
826 continue;
827 }
828 let at = descriptor.updated_at_ms.unwrap_or_default();
829 if best.is_none() || at >= best_at {
830 best_at = at;
831 best = Some(serde_json::to_value(&descriptor).unwrap_or(Value::Null));
832 }
833 }
834 Ok(best)
835}
836
837fn surface_columns(surface: &str) -> [String; 5] {
840 let mut parts = surface.split('|');
841 std::array::from_fn(|_| parts.next().unwrap_or("").to_string())
842}
843
844fn finish(
847 verb: SessionVerb,
848 mutation: &SessionMutation,
849 session: String,
850 ran: String,
851 row: Option<Value>,
852) -> Result<SessionMutationOutcome> {
853 let outcome = SessionMutationOutcome {
854 harness: mutation.harness.clone(),
855 verb: verb.as_str().to_string(),
856 ran: ran.clone(),
857 session: session.clone(),
858 row: row.clone(),
859 archived: None,
860 deleted: None,
861 };
862 match verb {
863 SessionVerb::Delete => {
864 if row.is_some() {
865 return Err(SessionControlError::Failed(format!(
866 "`{ran}` reported success but `{session}` is still in {}'s conversation store",
867 mutation.harness
868 )));
869 }
870 Ok(SessionMutationOutcome {
871 row: None,
872 deleted: Some(true),
873 ..outcome
874 })
875 }
876 SessionVerb::Archive => {
877 if !archive_took_effect(mutation, row.as_ref()) {
878 return Err(SessionControlError::Failed(format!(
879 "`{ran}` reported success but {}'s store still lists `{session}` as an \
880 active conversation",
881 mutation.harness
882 )));
883 }
884 Ok(SessionMutationOutcome {
885 archived: Some(true),
886 ..outcome
887 })
888 }
889 SessionVerb::New | SessionVerb::Reset => Ok(outcome),
890 }
891}
892
893fn archive_took_effect(mutation: &SessionMutation, row: Option<&Value>) -> bool {
905 let Some(row) = row else {
906 return true;
907 };
908 if mutation.harness == HarnessId::SUPERCODE {
909 return row
910 .get("archived")
911 .and_then(Value::as_bool)
912 .unwrap_or(false);
913 }
914 row.pointer("/time/archived")
915 .is_some_and(|value| !value.is_null())
916}
917
918fn cli_command(
920 verb: SessionVerb,
921 mutation: &SessionMutation,
922 session: &str,
923) -> Result<HarnessCommand> {
924 match (mutation.harness.as_str(), verb) {
925 (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => {
926 let mut command = HarnessCommand::new(harness_program(HarnessId::CODEX)?);
927 command.env("CODEX_HOME", codex_home(mutation).to_string_lossy());
928 command.args([verb.as_str(), session]);
929 if matches!(verb, SessionVerb::Delete) {
930 command.args(["--force"]);
937 }
938 Ok(command)
939 }
940 (HarnessId::HERMES, SessionVerb::Delete) => {
941 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
942 command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
943 command.args(["sessions", "delete", session, "--yes"]);
946 Ok(command)
947 }
948 (harness, verb) => Err(SessionControlError::Unsupported(format!(
949 "`{harness}` has no CLI verb for `sessions.{}`",
950 verb.as_str()
951 ))),
952 }
953}
954
955fn opencode_endpoint(mutation: &SessionMutation) -> Result<(String, reqwest::Client)> {
967 let base = mutation
968 .base_url
969 .as_deref()
970 .map(|url| url.trim_end_matches('/').to_string())
971 .ok_or_else(|| {
972 SessionControlError::Invalid(
973 "opencode conversations are mutated through its own running server: pass \
974 `base_url` (the address `runtimes.start` reports, or an `opencode serve` you \
975 already run)"
976 .into(),
977 )
978 })?;
979 let mut headers = reqwest::header::HeaderMap::new();
980 if let Some(bearer) = mutation.bearer.as_deref().filter(|t| !t.trim().is_empty()) {
981 let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {bearer}"))
982 .map_err(|_| {
983 SessionControlError::Invalid(
984 "the opencode bearer token is not a valid header value".into(),
985 )
986 })?;
987 value.set_sensitive(true);
988 headers.insert(reqwest::header::AUTHORIZATION, value);
989 }
990 let client = reqwest::Client::builder()
991 .default_headers(headers)
992 .build()
993 .map_err(|error| {
994 SessionControlError::Failed(format!("could not build the HTTP client: {error}"))
995 })?;
996 Ok((base, client))
997}
998
999async fn opencode_read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
1005 let (base, client) = opencode_endpoint(mutation)?;
1006 let url = format!("{base}/session/{session}");
1007 let mut request = client.get(&url);
1008 if let Some(cwd) = mutation.cwd.as_ref() {
1009 request = request.query(&[("directory", cwd.to_string_lossy().into_owned())]);
1010 }
1011 let response = request.send().await.map_err(|error| {
1012 SessionControlError::Failed(format!("`GET {url}` could not be sent: {error}"))
1013 })?;
1014 if response.status() == reqwest::StatusCode::NOT_FOUND {
1015 return Ok(None);
1016 }
1017 let status = response.status();
1018 if !status.is_success() {
1019 let body = response.text().await.unwrap_or_default();
1020 return Err(SessionControlError::Failed(format!(
1021 "`GET {url}` failed ({status}): {}",
1022 body.trim()
1023 )));
1024 }
1025 response
1026 .json::<Value>()
1027 .await
1028 .map(|value| if value.is_null() { None } else { Some(value) })
1029 .map_err(|error| {
1030 SessionControlError::Failed(format!("`GET {url}` returned unreadable JSON: {error}"))
1031 })
1032}
1033
1034async fn opencode_call(
1039 verb: SessionVerb,
1040 mutation: &SessionMutation,
1041 session: &str,
1042) -> Result<String> {
1043 let (base, client) = opencode_endpoint(mutation)?;
1044 let url = format!("{base}/session/{session}");
1045 let directory = mutation
1046 .cwd
1047 .as_ref()
1048 .map(|cwd| cwd.to_string_lossy().into_owned());
1049 let (ran, request) = match verb {
1050 SessionVerb::Delete => (format!("DELETE {url}"), client.delete(&url)),
1051 SessionVerb::Archive => {
1052 let now = std::time::SystemTime::now()
1058 .duration_since(std::time::UNIX_EPOCH)
1059 .map(|since| since.as_millis() as u64)
1060 .unwrap_or_default();
1061 (
1062 format!("PATCH {url} {{\"time\":{{\"archived\":{now}}}}}"),
1063 client
1064 .patch(&url)
1065 .json(&serde_json::json!({"time": {"archived": now}})),
1066 )
1067 }
1068 other => {
1069 return Err(SessionControlError::Unsupported(format!(
1070 "opencode has no HTTP door for `sessions.{}`",
1071 other.as_str()
1072 )));
1073 }
1074 };
1075 let request = match &directory {
1076 Some(directory) => request.query(&[("directory", directory)]),
1077 None => request,
1078 };
1079 let response = request.send().await.map_err(|error| {
1080 SessionControlError::Failed(format!("`{ran}` could not be sent: {error}"))
1081 })?;
1082 let status = response.status();
1083 if !status.is_success() {
1084 let body = response.text().await.unwrap_or_default();
1085 return Err(SessionControlError::Failed(format!(
1086 "`{ran}` failed ({status}): {}",
1087 if body.trim().is_empty() {
1088 "the server returned no body".to_string()
1089 } else {
1090 body.trim().to_string()
1091 }
1092 )));
1093 }
1094 Ok(ran)
1095}
1096
1097pub fn live_outcome(
1100 verb: SessionVerb,
1101 mutation: &SessionMutation,
1102 command: &str,
1103 session: String,
1104) -> Result<SessionMutationOutcome> {
1105 let row = read_back(mutation, &session).unwrap_or(None);
1106 Ok(SessionMutationOutcome {
1107 harness: mutation.harness.clone(),
1108 verb: verb.as_str().to_string(),
1109 ran: format!("{} live session: {command}", mutation.harness),
1110 session,
1111 row,
1112 archived: None,
1113 deleted: None,
1114 })
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120
1121 #[test]
1122 fn the_door_table_names_one_door_per_supported_pair() {
1123 assert_eq!(
1124 door(HarnessId::CODEX, SessionVerb::Archive).unwrap(),
1125 SessionDoor::Cli
1126 );
1127 assert_eq!(
1128 door(HarnessId::OPENCODE, SessionVerb::Delete).unwrap(),
1129 SessionDoor::Http
1130 );
1131 assert_eq!(
1132 door(HarnessId::OPENCLAW, SessionVerb::New).unwrap(),
1133 SessionDoor::Live("/new")
1134 );
1135 assert_eq!(
1136 door(HarnessId::OPENCLAW, SessionVerb::Reset).unwrap(),
1137 SessionDoor::Live("/reset")
1138 );
1139 assert_eq!(
1140 door(HarnessId::HERMES, SessionVerb::Reset).unwrap(),
1141 SessionDoor::Live("/reset")
1142 );
1143 assert_eq!(
1144 door(HarnessId::HERMES, SessionVerb::Delete).unwrap(),
1145 SessionDoor::Cli
1146 );
1147 assert_eq!(
1148 door(HarnessId::SUPERCODE, SessionVerb::Archive).unwrap(),
1149 SessionDoor::Store
1150 );
1151 }
1152
1153 #[test]
1154 fn every_refusal_names_the_reason_and_never_a_silent_no_op() {
1155 for (harness, verb, needle) in [
1156 (HarnessId::HERMES, SessionVerb::Archive, "BULK filter verb"),
1157 (
1160 HarnessId::HERMES,
1161 SessionVerb::New,
1162 "sends any UNRECOGNIZED `/word` to the model as prose",
1163 ),
1164 (HarnessId::OPENCLAW, SessionVerb::Delete, "v2026.7.1-2"),
1165 (
1166 HarnessId::CLAUDE_CODE,
1167 SessionVerb::Delete,
1168 "RETENTION WINDOW",
1169 ),
1170 (
1171 HarnessId::CLAUDE_CODE,
1172 SessionVerb::New,
1173 "harness.v1.runtimes.start",
1174 ),
1175 (
1176 HarnessId::CODEX,
1177 SessionVerb::New,
1178 "harness.v1.runtimes.start",
1179 ),
1180 (
1181 HarnessId::SUPERCODE,
1182 SessionVerb::Reset,
1183 "no conversation reset verb",
1184 ),
1185 (
1189 HarnessId::ORCHESTRATOR,
1190 SessionVerb::Archive,
1191 "a binding is never archived or deleted",
1192 ),
1193 ] {
1194 let error = door(harness, verb).unwrap_err();
1195 assert!(
1196 matches!(error, SessionControlError::Unsupported(_)),
1197 "{harness}.{}: {error}",
1198 verb.as_str()
1199 );
1200 assert!(
1201 error.to_string().contains(needle),
1202 "{harness}.{} must explain itself, got: {error}",
1203 verb.as_str()
1204 );
1205 }
1206 }
1207
1208 #[test]
1209 fn controlled_verbs_track_the_door_table() {
1210 assert_eq!(
1211 controlled_verbs(HarnessId::CODEX),
1212 vec!["archive", "delete"]
1213 );
1214 assert_eq!(controlled_verbs(HarnessId::HERMES), vec!["reset", "delete"]);
1217 assert_eq!(controlled_verbs(HarnessId::OPENCLAW), vec!["new", "reset"]);
1218 assert_eq!(
1219 controlled_verbs(HarnessId::OPENCODE),
1220 vec!["archive", "delete"]
1221 );
1222 assert_eq!(
1223 controlled_verbs(HarnessId::SUPERCODE),
1224 vec!["archive", "delete"]
1225 );
1226 assert!(controlled_verbs(HarnessId::CLAUDE_CODE).is_empty());
1227 assert!(controlled_verbs(HarnessId::PI).is_empty());
1228 assert_eq!(
1232 controlled_verbs(HarnessId::ORCHESTRATOR),
1233 vec!["new", "reset"]
1234 );
1235 assert_eq!(
1236 door(HarnessId::ORCHESTRATOR, SessionVerb::Reset).unwrap(),
1237 SessionDoor::Daemon
1238 );
1239 assert!(controlled_verbs("not-a-harness").is_empty());
1240 for harness in crate::harness_support_registry().harnesses {
1241 assert_eq!(
1242 !controlled_verbs(harness.id.as_str()).is_empty(),
1243 supports_session_control(harness.id.as_str()),
1244 "{}: CONTROLLED_SESSION_HARNESSES must track the door table",
1245 harness.id.as_str()
1246 );
1247 }
1248 }
1249
1250 #[test]
1254 fn the_registered_harness_list_matches_the_compiled_registry() {
1255 let mut from_registry: Vec<String> = crate::harness_support_registry()
1256 .harnesses
1257 .into_iter()
1258 .map(|descriptor| descriptor.id.as_str().to_string())
1259 .collect();
1260 from_registry.sort();
1261 let mut declared: Vec<String> = REGISTERED_HARNESSES
1262 .iter()
1263 .map(|id| id.to_string())
1264 .collect();
1265 declared.sort();
1266 assert_eq!(declared, from_registry);
1267 }
1268
1269 #[test]
1270 fn codex_home_is_the_parent_of_the_sessions_root() {
1271 let mutation = SessionMutation {
1272 harness: HarnessId::CODEX.into(),
1273 homes: HarnessHomes {
1274 codex: PathBuf::from("/tmp/iso/.codex/sessions"),
1275 ..HarnessHomes::default()
1276 },
1277 ..SessionMutation::default()
1278 };
1279 assert_eq!(codex_home(&mutation), PathBuf::from("/tmp/iso/.codex"));
1280 }
1281
1282 #[test]
1283 fn a_hermes_profile_is_a_full_home() {
1284 let mutation = SessionMutation {
1285 harness: HarnessId::HERMES.into(),
1286 profile: Some("work".into()),
1287 homes: HarnessHomes {
1288 hermes: PathBuf::from("/tmp/iso/.hermes/state.db"),
1289 ..HarnessHomes::default()
1290 },
1291 ..SessionMutation::default()
1292 };
1293 assert_eq!(
1294 hermes_home(&mutation),
1295 PathBuf::from("/tmp/iso/.hermes/profiles/work")
1296 );
1297 }
1298
1299 #[tokio::test]
1300 async fn a_live_door_asked_for_out_of_band_says_so() {
1301 let error = mutate(
1302 SessionVerb::Reset,
1303 &SessionMutation {
1304 harness: HarnessId::HERMES.into(),
1305 session: Some("s1".into()),
1306 ..SessionMutation::default()
1307 },
1308 )
1309 .await
1310 .unwrap_err();
1311 assert!(matches!(error, SessionControlError::Invalid(_)));
1312 assert!(error.to_string().contains("/reset"));
1313 assert!(error.to_string().contains("connection"));
1314 }
1315
1316 #[tokio::test]
1317 async fn opencode_refuses_to_guess_an_endpoint() {
1318 let error = mutate(
1319 SessionVerb::Delete,
1320 &SessionMutation {
1321 harness: HarnessId::OPENCODE.into(),
1322 session: Some("ses_1".into()),
1323 ..SessionMutation::default()
1324 },
1325 )
1326 .await
1327 .unwrap_err();
1328 assert!(matches!(error, SessionControlError::Invalid(_)));
1329 assert!(error.to_string().contains("base_url"));
1330 }
1331
1332 #[tokio::test]
1333 async fn a_verb_without_its_conversation_is_invalid() {
1334 let error = mutate(
1335 SessionVerb::Delete,
1336 &SessionMutation {
1337 harness: HarnessId::CODEX.into(),
1338 ..SessionMutation::default()
1339 },
1340 )
1341 .await
1342 .unwrap_err();
1343 assert!(matches!(error, SessionControlError::Invalid(_)));
1344 assert!(error.to_string().contains("sessions.delete"));
1345 }
1346}