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 Volter Harness calls"
508 )));
509 }
510 };
511 if let Some(over) = std::env::var_os(variable) {
512 let over = over.to_string_lossy().trim().to_string();
513 if !over.is_empty() {
514 return Ok(over);
515 }
516 }
517 let program = crate::harness_support(harness)
518 .and_then(|descriptor| descriptor.runtime.default_launch)
519 .map(|launch| launch.program)
520 .ok_or_else(|| {
521 SessionControlError::Unsupported(format!(
522 "the registry has no launch for `{harness}`, so its CLI cannot be located"
523 ))
524 })?;
525 Ok(program.strip_suffix("-acp").unwrap_or(&program).to_string())
526}
527
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!(
566 "Volter Harness's session store is unreadable: {error}"
567 ))
568 })?;
569 return Ok(store
570 .list()
571 .into_iter()
572 .find(|info| info.name == session)
573 .map(|info| serde_json::to_value(info).unwrap_or(Value::Null)));
574 }
575 let page = crate::discover_session_page(&DiscoveryQuery {
576 harnesses: vec![HarnessId::new(mutation.harness.clone())],
577 homes: mutation.homes.clone(),
578 include_child_sessions: true,
579 ..DiscoveryQuery::default()
580 })
581 .map_err(|error| {
582 SessionControlError::Failed(format!(
583 "the {} conversation store could not be re-read: {error}",
584 mutation.harness
585 ))
586 })?;
587 Ok(page
588 .sessions
589 .into_iter()
590 .find(|descriptor| descriptor.locator.session_id == session)
591 .map(|descriptor| serde_json::to_value(descriptor).unwrap_or(Value::Null)))
592}
593
594pub async fn mutate(
606 verb: SessionVerb,
607 mutation: &SessionMutation,
608) -> Result<SessionMutationOutcome> {
609 let door = door(&mutation.harness, verb)?;
610 let session = target_session(verb, &door, mutation)?;
611 if let SessionDoor::Http = door {
612 let ran = opencode_call(verb, mutation, &session).await?;
613 let row = opencode_read_back(mutation, &session).await?;
617 return finish(verb, mutation, session, ran, row);
618 }
619 perform(verb, mutation, door, session)
620}
621
622pub fn mutate_blocking(
631 verb: SessionVerb,
632 mutation: &SessionMutation,
633) -> Result<SessionMutationOutcome> {
634 let door = door(&mutation.harness, verb)?;
635 let session = target_session(verb, &door, mutation)?;
636 perform(verb, mutation, door, session)
637}
638
639fn target_session(
642 verb: SessionVerb,
643 door: &SessionDoor,
644 mutation: &SessionMutation,
645) -> Result<String> {
646 let session = mutation.session.as_deref().unwrap_or("").trim().to_string();
647 if verb.needs_session() && session.is_empty() && !matches!(door, SessionDoor::Daemon) {
650 return Err(SessionControlError::Invalid(format!(
651 "`sessions.{}` needs the conversation to act on",
652 verb.as_str()
653 )));
654 }
655 Ok(session)
656}
657
658fn perform(
660 verb: SessionVerb,
661 mutation: &SessionMutation,
662 door: SessionDoor,
663 session: String,
664) -> Result<SessionMutationOutcome> {
665 match door {
666 SessionDoor::Http => Err(SessionControlError::Invalid(format!(
667 "`{}` performs `sessions.{}` through its own HTTP API, which is not a blocking \
668 door; call [`mutate`]",
669 mutation.harness,
670 verb.as_str()
671 ))),
672 SessionDoor::Live(command) => Err(SessionControlError::Invalid(format!(
673 "`{}` performs `sessions.{}` by typing `{command}` into a LIVE driven session; call \
674 it with an open runtime `connection`",
675 mutation.harness,
676 verb.as_str()
677 ))),
678 SessionDoor::Cli => {
679 let command = cli_command(verb, mutation, &session)?;
680 let ran = command.narrate();
681 command.run()?;
682 let row = read_back(mutation, &session)?;
683 finish(verb, mutation, session, ran, row)
684 }
685 SessionDoor::Store => {
686 let store = crate::SessionStore::open(&mutation.homes.supercode).map_err(|error| {
687 SessionControlError::Failed(format!(
688 "Volter Harness's session store is unreadable: {error}"
689 ))
690 })?;
691 let ran = format!(
692 "supercode store {} {}",
693 verb.as_str(),
694 shell_quote(&session)
695 );
696 match verb {
697 SessionVerb::Archive => store.archive(&session),
698 SessionVerb::Delete => store.delete(&session),
699 _ => unreachable!("the door table only routes archive/delete to the store"),
700 }
701 .map_err(|error| SessionControlError::Failed(format!("`{ran}` failed: {error}")))?;
702 let row = read_back(mutation, &session)?;
703 finish(verb, mutation, session, ran, row)
704 }
705 SessionDoor::Daemon => orchestrator_mutate(verb, mutation),
706 }
707}
708
709fn orchestrator_mutate(
722 verb: SessionVerb,
723 mutation: &SessionMutation,
724) -> Result<SessionMutationOutcome> {
725 let surface = mutation
726 .surface
727 .as_deref()
728 .map(str::trim)
729 .filter(|surface| !surface.is_empty())
730 .ok_or_else(|| {
731 SessionControlError::Invalid(format!(
732 "an orchestrator conversation is a BINDING on a surface, not a store row: \
733 `sessions.{}` needs `--surface \
734 <platform|chat_type|chat_id|thread_id|participant_id>` \
735 (`supercode sessions list --harness orchestrator` prints the surface of every \
736 binding)",
737 verb.as_str()
738 ))
739 })?;
740 let root = mutation.homes.orchestrator.clone();
741 let profile = mutation
742 .profile
743 .as_deref()
744 .map(str::trim)
745 .filter(|profile| !profile.is_empty())
746 .unwrap_or("default");
747 let op = match verb {
748 SessionVerb::New => "sessions.new",
749 SessionVerb::Reset => "sessions.reset",
750 other => {
751 return Err(SessionControlError::Unsupported(format!(
752 "the orchestrator has no door for `sessions.{}`",
753 other.as_str()
754 )))
755 }
756 };
757 let args = serde_json::json!({ "surface": surface });
758 let answer = crate::orchestrator_door::call(&root, op, &args, profile).map_err(|error| {
759 match error {
760 crate::orchestrator_door::DoorError::Refused(message) => {
763 SessionControlError::Failed(message)
764 }
765 crate::orchestrator_door::DoorError::Failed(message) => {
766 SessionControlError::Failed(message)
767 }
768 }
769 })?;
770 let ran = format!("{} [{}]", answer.ran, answer.door.as_str());
771 let session = answer
772 .result
773 .pointer("/binding/session_id")
774 .and_then(Value::as_str)
775 .filter(|id| !id.is_empty())
776 .unwrap_or(surface)
777 .to_string();
778 let row = orchestrator_read_back(mutation, surface, &ran)?;
781 Ok(SessionMutationOutcome {
782 harness: mutation.harness.clone(),
783 verb: verb.as_str().to_string(),
784 ran,
785 session,
786 row,
787 archived: None,
788 deleted: None,
789 })
790}
791
792fn orchestrator_read_back(
799 mutation: &SessionMutation,
800 surface: &str,
801 ran: &str,
802) -> Result<Option<Value>> {
803 let page = crate::discover_session_page(&DiscoveryQuery {
804 harnesses: vec![HarnessId::new(mutation.harness.clone())],
805 homes: mutation.homes.clone(),
806 include_child_sessions: true,
807 ..DiscoveryQuery::default()
808 })
809 .map_err(|error| {
810 SessionControlError::Failed(format!(
811 "`{ran}` succeeded but the orchestrator's binding store could not be re-read: {error}"
812 ))
813 })?;
814 let wanted = surface_columns(surface);
815 let mut best: Option<Value> = None;
816 let mut best_at = 0;
817 for descriptor in page.sessions {
818 let key = descriptor.nouns.surface.as_ref();
819 let found = [
820 key.and_then(|k| k.platform.clone()).unwrap_or_default(),
821 key.and_then(|k| k.kind.clone()).unwrap_or_default(),
822 key.and_then(|k| k.chat_id.clone()).unwrap_or_default(),
823 key.and_then(|k| k.thread_id.clone()).unwrap_or_default(),
824 key.and_then(|k| k.participant_id.clone())
825 .unwrap_or_default(),
826 ];
827 if found != wanted {
828 continue;
829 }
830 let at = descriptor.updated_at_ms.unwrap_or_default();
831 if best.is_none() || at >= best_at {
832 best_at = at;
833 best = Some(serde_json::to_value(&descriptor).unwrap_or(Value::Null));
834 }
835 }
836 Ok(best)
837}
838
839fn surface_columns(surface: &str) -> [String; 5] {
842 let mut parts = surface.split('|');
843 std::array::from_fn(|_| parts.next().unwrap_or("").to_string())
844}
845
846fn finish(
849 verb: SessionVerb,
850 mutation: &SessionMutation,
851 session: String,
852 ran: String,
853 row: Option<Value>,
854) -> Result<SessionMutationOutcome> {
855 let outcome = SessionMutationOutcome {
856 harness: mutation.harness.clone(),
857 verb: verb.as_str().to_string(),
858 ran: ran.clone(),
859 session: session.clone(),
860 row: row.clone(),
861 archived: None,
862 deleted: None,
863 };
864 match verb {
865 SessionVerb::Delete => {
866 if row.is_some() {
867 return Err(SessionControlError::Failed(format!(
868 "`{ran}` reported success but `{session}` is still in {}'s conversation store",
869 mutation.harness
870 )));
871 }
872 Ok(SessionMutationOutcome {
873 row: None,
874 deleted: Some(true),
875 ..outcome
876 })
877 }
878 SessionVerb::Archive => {
879 if !archive_took_effect(mutation, row.as_ref()) {
880 return Err(SessionControlError::Failed(format!(
881 "`{ran}` reported success but {}'s store still lists `{session}` as an \
882 active conversation",
883 mutation.harness
884 )));
885 }
886 Ok(SessionMutationOutcome {
887 archived: Some(true),
888 ..outcome
889 })
890 }
891 SessionVerb::New | SessionVerb::Reset => Ok(outcome),
892 }
893}
894
895fn archive_took_effect(mutation: &SessionMutation, row: Option<&Value>) -> bool {
907 let Some(row) = row else {
908 return true;
909 };
910 if mutation.harness == HarnessId::SUPERCODE {
911 return row
912 .get("archived")
913 .and_then(Value::as_bool)
914 .unwrap_or(false);
915 }
916 row.pointer("/time/archived")
917 .is_some_and(|value| !value.is_null())
918}
919
920fn cli_command(
922 verb: SessionVerb,
923 mutation: &SessionMutation,
924 session: &str,
925) -> Result<HarnessCommand> {
926 match (mutation.harness.as_str(), verb) {
927 (HarnessId::CODEX, SessionVerb::Archive | SessionVerb::Delete) => {
928 let mut command = HarnessCommand::new(harness_program(HarnessId::CODEX)?);
929 command.env("CODEX_HOME", codex_home(mutation).to_string_lossy());
930 command.args([verb.as_str(), session]);
931 if matches!(verb, SessionVerb::Delete) {
932 command.args(["--force"]);
939 }
940 Ok(command)
941 }
942 (HarnessId::HERMES, SessionVerb::Delete) => {
943 let mut command = HarnessCommand::new(harness_program(HarnessId::HERMES)?);
944 command.env("HERMES_HOME", hermes_home(mutation).to_string_lossy());
945 command.args(["sessions", "delete", session, "--yes"]);
948 Ok(command)
949 }
950 (harness, verb) => Err(SessionControlError::Unsupported(format!(
951 "`{harness}` has no CLI verb for `sessions.{}`",
952 verb.as_str()
953 ))),
954 }
955}
956
957fn opencode_endpoint(mutation: &SessionMutation) -> Result<(String, reqwest::Client)> {
969 let base = mutation
970 .base_url
971 .as_deref()
972 .map(|url| url.trim_end_matches('/').to_string())
973 .ok_or_else(|| {
974 SessionControlError::Invalid(
975 "opencode conversations are mutated through its own running server: pass \
976 `base_url` (the address `runtimes.start` reports, or an `opencode serve` you \
977 already run)"
978 .into(),
979 )
980 })?;
981 let mut headers = reqwest::header::HeaderMap::new();
982 if let Some(bearer) = mutation.bearer.as_deref().filter(|t| !t.trim().is_empty()) {
983 let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {bearer}"))
984 .map_err(|_| {
985 SessionControlError::Invalid(
986 "the opencode bearer token is not a valid header value".into(),
987 )
988 })?;
989 value.set_sensitive(true);
990 headers.insert(reqwest::header::AUTHORIZATION, value);
991 }
992 let client = reqwest::Client::builder()
993 .default_headers(headers)
994 .build()
995 .map_err(|error| {
996 SessionControlError::Failed(format!("could not build the HTTP client: {error}"))
997 })?;
998 Ok((base, client))
999}
1000
1001async fn opencode_read_back(mutation: &SessionMutation, session: &str) -> Result<Option<Value>> {
1007 let (base, client) = opencode_endpoint(mutation)?;
1008 let url = format!("{base}/session/{session}");
1009 let mut request = client.get(&url);
1010 if let Some(cwd) = mutation.cwd.as_ref() {
1011 request = request.query(&[("directory", cwd.to_string_lossy().into_owned())]);
1012 }
1013 let response = request.send().await.map_err(|error| {
1014 SessionControlError::Failed(format!("`GET {url}` could not be sent: {error}"))
1015 })?;
1016 if response.status() == reqwest::StatusCode::NOT_FOUND {
1017 return Ok(None);
1018 }
1019 let status = response.status();
1020 if !status.is_success() {
1021 let body = response.text().await.unwrap_or_default();
1022 return Err(SessionControlError::Failed(format!(
1023 "`GET {url}` failed ({status}): {}",
1024 body.trim()
1025 )));
1026 }
1027 response
1028 .json::<Value>()
1029 .await
1030 .map(|value| if value.is_null() { None } else { Some(value) })
1031 .map_err(|error| {
1032 SessionControlError::Failed(format!("`GET {url}` returned unreadable JSON: {error}"))
1033 })
1034}
1035
1036async fn opencode_call(
1041 verb: SessionVerb,
1042 mutation: &SessionMutation,
1043 session: &str,
1044) -> Result<String> {
1045 let (base, client) = opencode_endpoint(mutation)?;
1046 let url = format!("{base}/session/{session}");
1047 let directory = mutation
1048 .cwd
1049 .as_ref()
1050 .map(|cwd| cwd.to_string_lossy().into_owned());
1051 let (ran, request) = match verb {
1052 SessionVerb::Delete => (format!("DELETE {url}"), client.delete(&url)),
1053 SessionVerb::Archive => {
1054 let now = std::time::SystemTime::now()
1060 .duration_since(std::time::UNIX_EPOCH)
1061 .map(|since| since.as_millis() as u64)
1062 .unwrap_or_default();
1063 (
1064 format!("PATCH {url} {{\"time\":{{\"archived\":{now}}}}}"),
1065 client
1066 .patch(&url)
1067 .json(&serde_json::json!({"time": {"archived": now}})),
1068 )
1069 }
1070 other => {
1071 return Err(SessionControlError::Unsupported(format!(
1072 "opencode has no HTTP door for `sessions.{}`",
1073 other.as_str()
1074 )));
1075 }
1076 };
1077 let request = match &directory {
1078 Some(directory) => request.query(&[("directory", directory)]),
1079 None => request,
1080 };
1081 let response = request.send().await.map_err(|error| {
1082 SessionControlError::Failed(format!("`{ran}` could not be sent: {error}"))
1083 })?;
1084 let status = response.status();
1085 if !status.is_success() {
1086 let body = response.text().await.unwrap_or_default();
1087 return Err(SessionControlError::Failed(format!(
1088 "`{ran}` failed ({status}): {}",
1089 if body.trim().is_empty() {
1090 "the server returned no body".to_string()
1091 } else {
1092 body.trim().to_string()
1093 }
1094 )));
1095 }
1096 Ok(ran)
1097}
1098
1099pub fn live_outcome(
1102 verb: SessionVerb,
1103 mutation: &SessionMutation,
1104 command: &str,
1105 session: String,
1106) -> Result<SessionMutationOutcome> {
1107 let row = read_back(mutation, &session).unwrap_or(None);
1108 Ok(SessionMutationOutcome {
1109 harness: mutation.harness.clone(),
1110 verb: verb.as_str().to_string(),
1111 ran: format!("{} live session: {command}", mutation.harness),
1112 session,
1113 row,
1114 archived: None,
1115 deleted: None,
1116 })
1117}