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 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
531fn 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
547fn 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
559fn 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
597pub 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 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
625pub 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
642fn 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 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
661fn 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
712fn 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 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 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
795fn 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
842fn 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
849fn 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
898fn 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
923fn 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 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 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
961fn 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
1005async 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
1040async 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 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
1103pub 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}