1use std::path::Path;
25
26use crate::claude_peer::{read_registry, registry_dir, ClaudePeerSession, ClaudePeerStatus};
27use crate::claude_relay::{send_through_relay, supercode_program, RelayReceipt, RELAY_NAME_PREFIX};
28use crate::live_runtime::LiveRuntimeRecord;
29use crate::mailbox::{
30 local_machine_name, mail_root, Envelope, IdleSubscription, MailAddress, Mailbox, ReplyVia,
31};
32use crate::runtime_mail::{deliver_to_runtime, RuntimeDelivery};
33use crate::HarnessHomes;
34
35pub const CODEX_HOOK_ARGUMENTS: &str = "message hook codex";
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Door {
41 Runtime(Box<LiveRuntimeRecord>),
43 Native(Box<ClaudePeerSession>),
45 Hook {
49 pane: Option<String>,
51 idle: bool,
53 },
54 Stored,
56 Operator,
58}
59
60impl Door {
61 pub fn name(&self) -> &'static str {
63 match self {
64 Self::Runtime(_) => "runtime",
65 Self::Native(_) => "native",
66 Self::Hook { .. } => "hook",
67 Self::Stored => "stored",
68 Self::Operator => "operator",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum NoDoor {
76 OtherMachine(String),
78 NotRunning,
80}
81
82pub fn door_for(homes: &HarnessHomes, to: &MailAddress) -> Result<Door, NoDoor> {
84 if to.machine != local_machine_name() {
85 return Err(NoDoor::OtherMachine(to.machine.clone()));
86 }
87 if to.harness == "operator" || to.harness == "board" {
88 return Ok(Door::Operator);
89 }
90 LiveSessions::read(homes)
91 .sessions
92 .into_iter()
93 .find(|session| &session.address == to)
94 .map(|session| session.door)
95 .ok_or(NoDoor::NotRunning)
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct LiveSession {
101 pub address: MailAddress,
103 pub name: String,
105 pub status: String,
107 pub door: Door,
109 pub pid: Option<u32>,
112 pub cwd: Option<std::path::PathBuf>,
114 pub tmux: Option<String>,
116 pub transcript: Option<std::path::PathBuf>,
118}
119
120impl LiveSession {
121 pub fn last_message_at_ms(&self, homes: &HarnessHomes) -> Option<u64> {
126 let harness = self.address.harness.as_str();
127 let path = match &self.transcript {
128 Some(path) => path.clone(),
129 None if harness == "claude-code" => {
130 let file = format!("{}.jsonl", self.address.session_id);
131 std::fs::read_dir(&homes.claude_code)
132 .ok()?
133 .flatten()
134 .map(|project| project.path().join(&file))
135 .find(|path| path.is_file())?
136 }
137 None => return None,
138 };
139 last_message_at_ms(&path, harness)
140 }
141}
142
143impl LiveSession {
144 pub fn pending_request(&self, homes: &HarnessHomes) -> Option<serde_json::Value> {
148 if self.status != "waiting" {
149 return None;
150 }
151 if self.address.harness == "codex" {
153 let prompt = daemon_pane(self).and_then(|pane| pane_prompt(&pane))?;
154 return Some(serde_json::json!({"prompt": prompt, "source": "screen"}));
155 }
156 if self.address.harness != "claude-code" {
157 return None;
158 }
159 let file = format!("{}.jsonl", self.address.session_id);
160 let from_transcript = std::fs::read_dir(&homes.claude_code)
161 .ok()
162 .and_then(|projects| {
163 projects
164 .flatten()
165 .map(|project| project.path().join(&file))
166 .find(|path| path.is_file())
167 })
168 .and_then(|path| pending_request(&path));
169 from_transcript.or_else(|| {
172 let prompt = daemon_pane(self).and_then(|pane| pane_prompt(&pane))?;
173 Some(serde_json::json!({"prompt": prompt, "source": "screen"}))
174 })
175 }
176}
177
178pub fn pending_request(path: &Path) -> Option<serde_json::Value> {
180 use std::io::{Read, Seek, SeekFrom};
181 let mut file = std::fs::File::open(path).ok()?;
182 let length = file.metadata().ok()?.len();
183 let start = length.saturating_sub(512 * 1024);
184 file.seek(SeekFrom::Start(start)).ok()?;
185 let mut bytes = Vec::new();
186 file.read_to_end(&mut bytes).ok()?;
187 let text = String::from_utf8_lossy(&bytes);
188 let mut answered = std::collections::HashSet::new();
189 for line in text.lines().rev() {
190 let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
191 continue;
192 };
193 if record["isSidechain"] == true {
194 continue;
195 }
196 let Some(content) = record
197 .pointer("/message/content")
198 .and_then(serde_json::Value::as_array)
199 else {
200 continue;
201 };
202 match record["type"].as_str() {
203 Some("user") => {
204 for item in content.iter().filter(|item| item["type"] == "tool_result") {
205 if let Some(id) = item["tool_use_id"].as_str() {
206 answered.insert(id.to_string());
207 }
208 }
209 }
210 Some("assistant") => {
211 let Some(call) = content.iter().rev().find(|item| item["type"] == "tool_use")
212 else {
213 continue;
214 };
215 if call["id"].as_str().is_some_and(|id| answered.contains(id)) {
216 return None;
217 }
218 let tool = call["name"].as_str().unwrap_or_default();
219 if tool == "AskUserQuestion" {
220 let questions = call["input"]["questions"]
221 .as_array()
222 .map(|questions| {
223 questions
224 .iter()
225 .map(|question| {
226 serde_json::json!({
227 "question": question["question"],
228 "header": question["header"],
229 "options": question["options"].as_array().map(|options| options.iter().map(|option| option["label"].clone()).collect::<Vec<_>>()).unwrap_or_default(),
230 })
231 })
232 .collect::<Vec<_>>()
233 })
234 .unwrap_or_default();
235 return Some(serde_json::json!({"tool": tool, "questions": questions}));
236 }
237 let input = call["input"].to_string();
238 let input: String = input.chars().take(500).collect();
239 return Some(serde_json::json!({"tool": tool, "input": input}));
240 }
241 _ => {}
242 }
243 }
244 None
245}
246
247fn last_message_at_ms(path: &Path, harness: &str) -> Option<u64> {
249 use std::io::{Read, Seek, SeekFrom};
250 for window in [256 * 1024_u64, 8 * 1024 * 1024] {
252 let mut file = std::fs::File::open(path).ok()?;
253 let length = file.metadata().ok()?.len();
254 let start = length.saturating_sub(window);
255 file.seek(SeekFrom::Start(start)).ok()?;
256 let mut bytes = Vec::new();
257 file.read_to_end(&mut bytes).ok()?;
258 let text = String::from_utf8_lossy(&bytes);
259 let mut lines = text.lines().rev().collect::<Vec<_>>();
260 if start > 0 {
261 lines.pop(); }
263 for line in lines {
264 let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
265 continue;
266 };
267 let message = match harness {
268 "codex" => record["type"] == "response_item",
269 _ => {
270 matches!(record["type"].as_str(), Some("user" | "assistant"))
271 && record["isSidechain"] != true
272 }
273 };
274 if !message {
275 continue;
276 }
277 if let Some(at) = record["timestamp"]
278 .as_str()
279 .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
280 .and_then(|at| u64::try_from(at).ok())
281 {
282 return Some(at);
283 }
284 }
285 if start == 0 {
286 return None;
287 }
288 }
289 None
290}
291
292#[derive(Debug, Default)]
296pub struct LiveSessions {
297 sessions: Vec<LiveSession>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum Unresolved {
303 Stale(String),
305 Unknown(String),
307}
308
309impl LiveSessions {
310 pub fn read(homes: &HarnessHomes) -> Self {
314 let machine = local_machine_name();
315 let registry = read_registry(®istry_dir(homes));
316 let registered = |record: &crate::live_runtime::LiveRuntimeRecord| {
319 registry.iter().find(|session| {
320 session.session_id == record.source.session_id
321 || session.session_id == record.runtime_session_id
322 })
323 };
324 let records = crate::runtime_mail::controlled_runtimes();
325 let turns = crate::runtime_mail::runtime_turn_states(&records);
327 let mut sessions: Vec<LiveSession> = records
328 .into_iter()
329 .zip(turns)
330 .filter_map(|(record, turn)| {
331 let registered = registered(&record);
332 if registered.is_some_and(|session| session.name.starts_with(RELAY_NAME_PREFIX)) {
333 return None;
334 }
335 let address =
336 MailAddress::new(&machine, &record.source.harness, &record.source.session_id)
337 .ok()?;
338 let short: String = record.source.session_id.chars().take(8).collect();
339 let name = match registered {
340 Some(session) if !session.name.is_empty() => session.name.clone(),
341 _ => format!("{}-{short}", record.source.harness),
342 };
343 Some(LiveSession {
344 name: format!("{name}@{machine}"),
345 address,
346 status: match turn {
348 Some(crate::frontend::FrontendTurnState::Busy) => "busy".into(),
349 Some(crate::frontend::FrontendTurnState::Idle) => "idle".into(),
350 None => "hosted".into(),
351 },
352 pid: Some(record.pid),
353 cwd: Some(record.source.workspace.clone()),
354 tmux: None,
355 transcript: None,
356 door: Door::Runtime(Box::new(record)),
357 })
358 })
359 .collect();
360 let controlled = |address: &MailAddress, sessions: &[LiveSession]| {
361 sessions.iter().any(|session| &session.address == address)
362 };
363 for session in registry {
364 if session.name.starts_with(RELAY_NAME_PREFIX) {
365 continue;
366 }
367 let Ok(address) = MailAddress::new(&machine, "claude-code", &session.session_id) else {
368 continue;
369 };
370 if controlled(&address, &sessions) {
371 continue;
372 }
373 sessions.push(LiveSession {
374 address,
375 name: format!("{}@{machine}", session.name),
376 status: session
377 .status
378 .as_ref()
379 .map(|status| status.as_str().to_string())
380 .unwrap_or_else(|| "unknown".into()),
381 pid: Some(session.pid),
382 cwd: session.cwd.clone(),
383 tmux: session.tmux.clone(),
384 transcript: None,
385 door: Door::Native(Box::new(session)),
386 });
387 }
388 let user_hook = codex_user_hook_installed();
389 let rollouts = crate::codex_peer::live_rollouts(&homes.codex);
390 let panes = if rollouts.is_empty() {
391 std::collections::HashMap::new()
392 } else {
393 crate::codex_peer::session_panes()
394 };
395 let live_panes = (!panes.is_empty()).then(live_daemon_panes).flatten();
398 for (path, status) in rollouts {
399 let Some((session_id, None)) = crate::codex_peer::rollout_session(&path) else {
400 continue;
401 };
402 let Ok(address) = MailAddress::new(&machine, "codex", &session_id) else {
403 continue;
404 };
405 if controlled(&address, &sessions) {
406 continue;
407 }
408 let cwd = crate::codex_peer::rollout_cwd(&path);
409 let hooked = user_hook || cwd.as_deref().is_some_and(codex_project_hook_installed);
410 let pane = panes.get(&session_id).cloned();
411 if pane.as_ref().is_some_and(|pane| {
412 live_panes
413 .as_ref()
414 .is_some_and(|live| !live.contains_key(pane))
415 }) {
416 continue;
417 }
418 let status = match status {
421 crate::codex_peer::CodexPeerStatus::Busy
422 | crate::codex_peer::CodexPeerStatus::Running
423 if pane.as_ref().is_some_and(|p| {
424 live_panes.as_ref().and_then(|live| live.get(p)) == Some(&true)
425 }) =>
426 {
427 "idle".to_string()
428 }
429
430 crate::codex_peer::CodexPeerStatus::Busy
431 | crate::codex_peer::CodexPeerStatus::Running
432 if pane.as_deref().and_then(pane_prompt).is_some() =>
433 {
434 "waiting".to_string()
435 }
436 status => status.as_str().to_string(),
437 };
438 let door = if hooked {
439 Door::Hook {
440 pane: pane.clone(),
441 idle: status == "idle",
442 }
443 } else {
444 Door::Stored
445 };
446 sessions.push(LiveSession {
447 name: format!("{}@{machine}", codex_name(&session_id)),
448 address,
449 status,
450 pid: None,
451 cwd,
452 tmux: pane,
454 transcript: Some(path),
455 door,
456 });
457 }
458 Self { sessions }
459 }
460
461 pub fn all(&self) -> &[LiveSession] {
463 &self.sessions
464 }
465
466 pub fn door(&self, harness: &str, session_id: &str) -> Option<&'static str> {
469 self.sessions
470 .iter()
471 .find(|session| {
472 session.address.harness == harness && session.address.session_id == session_id
473 })
474 .map(|session| session.door.name())
475 }
476
477 pub fn resolve(&self, to: &str) -> Result<&LiveSession, Unresolved> {
480 let machine = local_machine_name();
481 if let Ok(address) = MailAddress::parse(to) {
482 return self
483 .sessions
484 .iter()
485 .find(|session| session.address == address)
486 .ok_or_else(|| {
487 Unresolved::Stale(format!(
488 "{to} is no longer running. Nothing was sent. Run supercode message list \
489 for the live sessions."
490 ))
491 });
492 }
493 let wanted = match to.split_once('@') {
494 Some((name, at)) if at == machine => name.to_string(),
495 Some((_, at)) => {
496 return Err(Unresolved::Unknown(format!(
497 "Not sent: {to} is on machine {at}, not this one. Nothing was sent."
498 )))
499 }
500 None => to.to_string(),
501 };
502 let short = |session: &LiveSession| {
503 session
504 .name
505 .split('@')
506 .next()
507 .unwrap_or_default()
508 .to_string()
509 };
510 let matching: Vec<&LiveSession> = self
511 .sessions
512 .iter()
513 .filter(|session| short(session) == wanted)
514 .collect();
515 if let [only] = matching.as_slice() {
516 return Ok(only);
517 }
518 let hint = if matching.len() > 1 {
519 format!(
520 " {} sessions are named {wanted}; use its address.",
521 matching.len()
522 )
523 } else {
524 let near: Vec<String> = self
525 .sessions
526 .iter()
527 .filter(|session| {
528 let name = short(session);
529 name.contains(&wanted)
530 || wanted.contains(&name)
531 || name
532 .chars()
533 .zip(wanted.chars())
534 .take_while(|(a, b)| a == b)
535 .count()
536 >= 4
537 })
538 .take(3)
539 .map(|session| {
540 format!(
541 "{} ({}, {})",
542 session.name, session.address.harness, session.status
543 )
544 })
545 .collect();
546 if near.is_empty() {
547 String::new()
548 } else {
549 format!(" Did you mean: {}?", near.join(", "))
550 }
551 };
552 Err(Unresolved::Unknown(format!(
553 "No session named \"{wanted}\" is reachable.{hint} Run supercode message list. Nothing \
554 was sent."
555 )))
556 }
557}
558
559pub fn has_message_tools(pid: u32) -> bool {
563 let Ok(output) = std::process::Command::new("ps")
564 .args(["-A", "-o", "ppid=,command="])
565 .output()
566 else {
567 return false;
568 };
569 String::from_utf8_lossy(&output.stdout).lines().any(|line| {
570 let line = line.trim_start();
571 let Some((ppid, command)) = line.split_once(' ') else {
572 return false;
573 };
574 ppid.parse::<u32>() == Ok(pid) && command.trim_end().ends_with(" message mcp")
575 })
576}
577
578pub fn codex_name(session_id: &str) -> String {
580 format!("codex-{}", session_id.chars().take(8).collect::<String>())
581}
582
583#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct Caller {
586 pub address: MailAddress,
588 pub name: String,
590}
591
592pub const CALLER_UNRESOLVED: &str = "Can't tell which session is running this command, so \
594 replies would have nowhere to go. Nothing was sent. Run it from your agent session's own \
595 shell tool.";
596
597pub fn resolve_caller(homes: &HarnessHomes, pids: &[u32]) -> Result<Caller, String> {
602 let machine = local_machine_name();
603 let registry = read_registry(®istry_dir(homes));
604 let hosted = crate::runtime_mail::controlled_runtimes();
605 for &pid in pids {
606 if let Some(record) = hosted.iter().find(|record| record.pid == pid) {
607 let short: String = record.source.session_id.chars().take(8).collect();
608 return Ok(Caller {
609 address: MailAddress::new(
610 &machine,
611 &record.source.harness,
612 &record.source.session_id,
613 )
614 .map_err(|error| error.to_string())?,
615 name: format!("{}-{short}@{machine}", record.source.harness),
616 });
617 }
618 if let Some(session) = registry.iter().find(|session| session.pid == pid) {
619 if let Ok(claimed) = std::env::var("CLAUDE_CODE_SESSION_ID") {
620 if !claimed.is_empty() && claimed != session.session_id {
621 return Err(format!(
622 "{CALLER_UNRESOLVED} (CLAUDE_CODE_SESSION_ID names {claimed}, but the Claude \
623 process {pid} above this command is session {})",
624 session.session_id
625 ));
626 }
627 }
628 return Ok(Caller {
629 address: MailAddress::new(&machine, "claude-code", &session.session_id)
630 .map_err(|error| error.to_string())?,
631 name: format!("{}@{machine}", session.name),
632 });
633 }
634 if let Some(thread) = std::env::var("CODEX_THREAD_ID")
638 .ok()
639 .filter(|thread| !thread.is_empty())
640 .filter(|thread| crate::codex_peer::holds_session(pid, thread))
641 {
642 return Ok(Caller {
643 address: MailAddress::new(&machine, "codex", &thread)
644 .map_err(|error| error.to_string())?,
645 name: format!("{}@{machine}", codex_name(&thread)),
646 });
647 }
648 if let Some((session_id, _)) = crate::codex_peer::session_of_process(pid) {
649 return Ok(Caller {
650 address: MailAddress::new(&machine, "codex", &session_id)
651 .map_err(|error| error.to_string())?,
652 name: format!("{}@{machine}", codex_name(&session_id)),
653 });
654 }
655 }
656 #[cfg(windows)]
657 if let Some(session) = msys_cut_claim(®istry, pids) {
658 return Ok(Caller {
659 address: MailAddress::new(&machine, "claude-code", &session.session_id)
660 .map_err(|error| error.to_string())?,
661 name: format!("{}@{machine}", session.name),
662 });
663 }
664 Err(format!(
665 "{CALLER_UNRESOLVED} (looked for this command's processes {pids:?} among {} Claude \
666 sessions in {} and {} hosted runtimes)",
667 registry.len(),
668 registry_dir(homes).display(),
669 hosted.len()
670 ))
671}
672
673#[cfg(windows)]
680fn msys_cut_claim<'a>(
681 registry: &'a [ClaudePeerSession],
682 pids: &[u32],
683) -> Option<&'a ClaudePeerSession> {
684 let table = process_table();
685 let [.., shell, cut] = pids else {
686 return None;
687 };
688 if table.contains_key(cut) {
689 return None;
690 }
691 let name = table.get(shell)?.1.to_ascii_lowercase();
692 if !matches!(name.as_str(), "sh.exe" | "bash.exe" | "dash.exe") {
693 return None;
694 }
695 let claimed = std::env::var("CLAUDE_CODE_SESSION_ID").ok()?;
696 registry
697 .iter()
698 .find(|session| !claimed.is_empty() && session.session_id == claimed)
699}
700
701pub fn process_ancestry() -> Vec<u32> {
703 ancestry_of(std::process::id())
704}
705
706pub fn ancestry_of(pid: u32) -> Vec<u32> {
708 let parents = parent_pids();
709 let mut chain = vec![pid];
710 let mut current = pid;
711 while let Some(&parent) = parents.get(¤t) {
712 if parent <= 1 || chain.contains(&parent) {
713 break;
714 }
715 chain.push(parent);
716 current = parent;
717 }
718 chain
719}
720
721#[cfg(not(windows))]
723fn parent_pids() -> std::collections::HashMap<u32, u32> {
724 let Ok(output) = std::process::Command::new("ps")
725 .args(["-axo", "pid=,ppid="])
726 .output()
727 else {
728 return Default::default();
729 };
730 String::from_utf8_lossy(&output.stdout)
731 .lines()
732 .filter_map(|line| {
733 let mut fields = line.split_whitespace();
734 Some((fields.next()?.parse().ok()?, fields.next()?.parse().ok()?))
735 })
736 .collect()
737}
738
739#[cfg(windows)]
741fn parent_pids() -> std::collections::HashMap<u32, u32> {
742 process_table()
743 .into_iter()
744 .map(|(pid, (parent, _))| (pid, parent))
745 .collect()
746}
747
748#[cfg(windows)]
750fn process_table() -> std::collections::HashMap<u32, (u32, String)> {
751 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
752 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
753 CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
754 TH32CS_SNAPPROCESS,
755 };
756
757 let mut parents = std::collections::HashMap::new();
758 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
759 if snapshot == INVALID_HANDLE_VALUE {
760 return parents;
761 }
762 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
763 entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
764 let mut has_entry = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
765 while has_entry {
766 let length = entry
767 .szExeFile
768 .iter()
769 .position(|&unit| unit == 0)
770 .unwrap_or(entry.szExeFile.len());
771 let name = String::from_utf16_lossy(&entry.szExeFile[..length]);
772 parents.insert(entry.th32ProcessID, (entry.th32ParentProcessID, name));
773 has_entry = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
774 }
775 unsafe {
776 CloseHandle(snapshot);
777 }
778 parents
779}
780
781pub fn codex_hooks_path() -> std::path::PathBuf {
783 std::env::var_os("CODEX_HOME")
784 .map(std::path::PathBuf::from)
785 .or_else(|| {
786 supercode_interchange::user_home()
787 .map(std::path::PathBuf::into_os_string)
788 .map(|home| std::path::PathBuf::from(home).join(".codex"))
789 })
790 .unwrap_or_else(|| std::path::PathBuf::from(".codex"))
791 .join("hooks.json")
792}
793
794pub fn codex_user_hook_installed() -> bool {
797 std::fs::read_to_string(codex_hooks_path())
798 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
799}
800
801pub fn codex_project_hook_installed(cwd: &Path) -> bool {
804 cwd.ancestors().any(|directory| {
805 std::fs::read_to_string(directory.join(".codex").join("hooks.json"))
806 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
807 })
808}
809
810#[derive(Debug, Clone, PartialEq, Eq)]
812pub enum Delivered {
813 Steered,
815 Started,
817 Native {
820 busy: bool,
822 },
823 Hooked,
825 HookWoken,
828 Queued,
830 Stored,
832 Operator,
834}
835
836#[derive(Debug, Clone, PartialEq, Eq)]
838pub enum Refused {
839 CannotQueueNative,
842 TooLong(usize),
844}
845
846pub const MAX_RELAYED_BYTES: usize = 100_000;
849
850pub async fn deliver(
854 envelope: &Envelope,
855 to: &MailAddress,
856 door: &Door,
857 wake: bool,
858 notify_when_idle: bool,
859) -> Result<Result<Delivered, Refused>, String> {
860 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
861 let mut final_reply = false;
865 let delivered = match door {
866 Door::Runtime(record) => {
867 let mut sent = envelope.clone();
868 let answers = matches!(envelope.reply_via, ReplyVia::Command);
869 if answers {
870 sent.reply_via = ReplyVia::FinalMessage {
871 destination: envelope.from_name.clone(),
872 };
873 }
874 match deliver_to_runtime(record, sent.render(), wake).await? {
875 RuntimeDelivery::Steered => {
876 mailbox
877 .deliver_read(&sent)
878 .map_err(|error| error.to_string())?;
879 final_reply = answers;
880 Delivered::Steered
881 }
882 RuntimeDelivery::Started => {
883 mailbox
884 .deliver_read(&sent)
885 .map_err(|error| error.to_string())?;
886 final_reply = answers;
887 Delivered::Started
888 }
889 RuntimeDelivery::NotWoken => {
890 mailbox
891 .deliver(envelope)
892 .map_err(|error| error.to_string())?;
893 Delivered::Queued
894 }
895 }
896 }
897 Door::Native(session) => {
898 let busy = session.status != Some(ClaudePeerStatus::Idle);
899 if !wake && !busy {
900 return Ok(Err(Refused::CannotQueueNative));
901 }
902 let mut envelope = envelope.clone();
906 if envelope.reply_via == ReplyVia::Command && has_message_tools(session.pid) {
907 envelope.reply_via = ReplyVia::Tool;
908 }
909 let envelope = &envelope;
910 let text = envelope.render();
911 if text.len() > MAX_RELAYED_BYTES {
912 return Ok(Err(Refused::TooLong(text.len())));
913 }
914 match send_through_relay(
915 &envelope.from,
916 &envelope.from_name,
917 &session.name,
918 text,
919 &envelope.id,
920 )
921 .await
922 {
923 RelayReceipt::Delivered { .. } => {
924 mailbox
925 .deliver_read(envelope)
926 .map_err(|error| error.to_string())?;
927 Delivered::Native { busy }
928 }
929 RelayReceipt::Failed { detail } => return Err(detail),
930 }
931 }
932 Door::Hook { pane, idle } => {
933 mailbox
934 .deliver(envelope)
935 .map_err(|error| error.to_string())?;
936 match pane {
939 Some(pane)
940 if wake
941 && *idle
942 && submit_when_composer_empty(pane, CODEX_WAKE).await == Ok(true) =>
943 {
944 Delivered::HookWoken
945 }
946 _ => Delivered::Hooked,
947 }
948 }
949 Door::Stored => {
950 mailbox
951 .deliver(envelope)
952 .map_err(|error| error.to_string())?;
953 Delivered::Stored
954 }
955 Door::Operator => {
956 mailbox
957 .deliver(envelope)
958 .map_err(|error| error.to_string())?;
959 Delivered::Operator
960 }
961 };
962 let notice = notify_when_idle && !matches!(door, Door::Operator);
963 if notice || final_reply {
964 let mut subscription = IdleSubscription::new(envelope.id.clone(), envelope.from.clone());
965 subscription.notice = notice;
966 subscription.final_reply = final_reply;
967 mailbox
968 .subscribe_idle(&subscription)
969 .map_err(|error| error.to_string())?;
970 if let Ok(program) = supercode_program() {
972 crate::claude_relay::ensure_machine_daemon(&program)
973 .await
974 .ok();
975 }
976 }
977 Ok(Ok(delivered))
978}
979
980#[derive(Debug, Clone, Copy, PartialEq, Eq)]
982pub enum UserTurn {
983 Steered,
985 Started,
987 Typed,
989 Waiting,
993}
994
995impl UserTurn {
996 pub const fn as_str(self) -> &'static str {
998 match self {
999 Self::Steered => "steered",
1000 Self::Started => "started",
1001 Self::Typed => "typed",
1002 Self::Waiting => "waiting",
1003 }
1004 }
1005}
1006
1007pub fn pane_prompt(pane: &str) -> Option<Vec<String>> {
1012 let screen = pane_screen(pane)?;
1013 let lines: Vec<&str> = screen.lines().map(str::trim_end).collect();
1014 let is_option = |line: &str| {
1015 let line = line.trim_start();
1016 let rest = line
1018 .strip_prefix('❯')
1019 .or_else(|| line.strip_prefix('›'))
1020 .or_else(|| line.strip_prefix('>'))
1021 .unwrap_or(line)
1022 .trim_start();
1023 let digits = rest.chars().take_while(char::is_ascii_digit).count();
1024 digits > 0 && matches!(rest[digits..].chars().next(), Some('.' | ')'))
1025 };
1026 let selected = lines.iter().position(|line| {
1027 let line = line.trim_start();
1028 (line.starts_with('❯') || line.starts_with('›') || line.starts_with('>')) && is_option(line)
1029 })?;
1030 let block_start = |end: usize| {
1032 lines[..end]
1033 .iter()
1034 .rposition(|line| line.trim().is_empty())
1035 .map_or(0, |blank| blank + 1)
1036 };
1037 let options = block_start(selected);
1038 let above = lines[..options]
1039 .iter()
1040 .rposition(|line| !line.trim().is_empty())
1041 .map(|last| block_start(last));
1042 let start = above.unwrap_or(options);
1043 let end = lines[selected..]
1044 .iter()
1045 .position(|line| line.trim().is_empty())
1046 .map_or(lines.len(), |blank| selected + blank);
1047 Some(
1048 lines[start..end]
1049 .iter()
1050 .map(|line| line.trim().to_string())
1051 .filter(|line| !line.is_empty())
1052 .take(20)
1053 .collect(),
1054 )
1055}
1056
1057fn live_daemon_panes() -> Option<std::collections::HashMap<String, bool>> {
1059 let entry = crate::teams_entry().ok()?;
1060 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1061 .ok()
1062 .filter(|value| !value.trim().is_empty())
1063 .unwrap_or_else(|| "node".into());
1064 let output = std::process::Command::new(node)
1065 .arg(entry)
1066 .args(["panes", "ls", "--presence-only"])
1067 .stdin(std::process::Stdio::null())
1068 .output()
1069 .ok()?;
1070 if !output.status.success() {
1071 return None;
1072 }
1073 let rows: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
1074 rows.iter()
1075 .map(|row| {
1076 Some((
1077 row["pane"].as_str()?.to_owned(),
1078 row["idleComposer"].as_bool()?,
1079 ))
1080 })
1081 .collect()
1082}
1083
1084fn pane_screen(pane: &str) -> Option<String> {
1087 #[cfg(unix)]
1088 {
1089 let output = std::process::Command::new("tmux")
1090 .args(["capture-pane", "-p", "-t", &format!("{pane}:")])
1091 .output()
1092 .ok()?;
1093 output
1094 .status
1095 .success()
1096 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1097 }
1098 #[cfg(not(unix))]
1099 {
1100 let entry = crate::teams_entry().ok()?;
1101 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1102 .ok()
1103 .filter(|value| !value.trim().is_empty())
1104 .unwrap_or_else(|| "node".into());
1105 let output = std::process::Command::new(node)
1106 .arg(entry)
1107 .args(["panes", "capture", pane, "--lines", "60"])
1108 .stdin(std::process::Stdio::null())
1109 .output()
1110 .ok()?;
1111 output
1112 .status
1113 .success()
1114 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1115 }
1116}
1117
1118pub fn daemon_pane(session: &LiveSession) -> Option<String> {
1120 let name = session.tmux.as_deref()?.split(':').next()?;
1121 let rest = name.strip_prefix("p_")?;
1122 (!rest.is_empty()
1123 && rest
1124 .chars()
1125 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1126 .then(|| name.to_string())
1127}
1128
1129pub async fn deliver_user_turn(
1135 homes: &HarnessHomes,
1136 envelope: &Envelope,
1137 to: &MailAddress,
1138) -> Result<UserTurn, String> {
1139 let session = LiveSessions::read(homes)
1140 .sessions
1141 .into_iter()
1142 .find(|session| &session.address == to)
1143 .ok_or_else(|| format!("{to} is not running; nothing was sent"))?;
1144 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
1145 if let Door::Runtime(record) = &session.door {
1146 let delivered = deliver_to_runtime(record, envelope.body.clone(), true).await?;
1147 mailbox
1148 .deliver_read(envelope)
1149 .map_err(|error| error.to_string())?;
1150 return Ok(match delivered {
1151 RuntimeDelivery::Steered => UserTurn::Steered,
1152 _ => UserTurn::Started,
1153 });
1154 }
1155 let Some(pane) = daemon_pane(&session) else {
1156 let name = session.name.split('@').next().unwrap_or(&session.name);
1157 return Err(format!(
1158 "{name} runs outside supercode, where nothing can speak as its user. Open it in a \
1159 pane with `supercode open {name}`; nothing was sent."
1160 ));
1161 };
1162 mailbox
1163 .deliver(envelope)
1164 .map_err(|error| error.to_string())?;
1165 let typed = type_user_turns(&mailbox, &pane).await;
1166 Ok(if typed.contains(&envelope.id) {
1167 UserTurn::Typed
1168 } else {
1169 UserTurn::Waiting
1170 })
1171}
1172
1173pub async fn type_user_turns(mailbox: &Mailbox, pane: &str) -> Vec<String> {
1177 let mut typed = Vec::new();
1178 for waiting in mailbox.user_turns().unwrap_or_default() {
1179 let Ok(Some(stored)) = mailbox.claim_user_turn(&waiting) else {
1183 break;
1184 };
1185 match submit_when_composer_empty(pane, &stored.envelope.body).await {
1186 Ok(true) => {
1187 mailbox.acknowledge(&stored).ok();
1188 typed.push(stored.envelope.id.clone());
1189 }
1190 Ok(false) => {
1191 mailbox.release(&stored).ok();
1192 break;
1193 }
1194 Err(error) => {
1195 mailbox.release(&stored).ok();
1196 eprintln!(
1197 "supercode: the user's turn {} for {} waits: {error}",
1198 stored.envelope.id,
1199 mailbox.address()
1200 );
1201 break;
1202 }
1203 }
1204 }
1205 typed
1206}
1207
1208pub const CODEX_WAKE: &str =
1211 "<supercode-notice source=\"mailbox\">You have unread supercode messages. Read them with: supercode message inbox</supercode-notice>";
1212
1213async fn submit_when_composer_empty(pane: &str, text: &str) -> Result<bool, String> {
1216 let entry = crate::teams_entry().map_err(|error| error.to_string())?;
1217 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1218 .ok()
1219 .filter(|value| !value.trim().is_empty())
1220 .unwrap_or_else(|| "node".into());
1221 let output = tokio::process::Command::new(node)
1222 .arg(entry)
1223 .args(["input", pane, text, "--when-composer-empty"])
1224 .env_remove("SUPERCODE_CALLER")
1227 .stdin(std::process::Stdio::null())
1228 .output()
1229 .await
1230 .map_err(|error| error.to_string())?;
1231 if !output.status.success() {
1232 return Err(crate::mailbox::error_line(&String::from_utf8_lossy(
1233 &output.stderr,
1234 )));
1235 }
1236 let answer: serde_json::Value =
1237 serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
1238 Ok(answer["delivered"] == true)
1239}