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" {
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 path = std::fs::read_dir(&homes.claude_code)
161 .ok()?
162 .flatten()
163 .map(|project| project.path().join(&file))
164 .find(|path| path.is_file())?;
165 pending_request(&path)
166 }
167}
168
169pub fn pending_request(path: &Path) -> Option<serde_json::Value> {
171 use std::io::{Read, Seek, SeekFrom};
172 let mut file = std::fs::File::open(path).ok()?;
173 let length = file.metadata().ok()?.len();
174 let start = length.saturating_sub(512 * 1024);
175 file.seek(SeekFrom::Start(start)).ok()?;
176 let mut bytes = Vec::new();
177 file.read_to_end(&mut bytes).ok()?;
178 let text = String::from_utf8_lossy(&bytes);
179 let mut answered = std::collections::HashSet::new();
180 for line in text.lines().rev() {
181 let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
182 continue;
183 };
184 if record["isSidechain"] == true {
185 continue;
186 }
187 let Some(content) = record
188 .pointer("/message/content")
189 .and_then(serde_json::Value::as_array)
190 else {
191 continue;
192 };
193 match record["type"].as_str() {
194 Some("user") => {
195 for item in content.iter().filter(|item| item["type"] == "tool_result") {
196 if let Some(id) = item["tool_use_id"].as_str() {
197 answered.insert(id.to_string());
198 }
199 }
200 }
201 Some("assistant") => {
202 let Some(call) = content.iter().rev().find(|item| item["type"] == "tool_use")
203 else {
204 continue;
205 };
206 if call["id"].as_str().is_some_and(|id| answered.contains(id)) {
207 return None;
208 }
209 let tool = call["name"].as_str().unwrap_or_default();
210 if tool == "AskUserQuestion" {
211 let questions = call["input"]["questions"]
212 .as_array()
213 .map(|questions| {
214 questions
215 .iter()
216 .map(|question| {
217 serde_json::json!({
218 "question": question["question"],
219 "header": question["header"],
220 "options": question["options"].as_array().map(|options| options.iter().map(|option| option["label"].clone()).collect::<Vec<_>>()).unwrap_or_default(),
221 })
222 })
223 .collect::<Vec<_>>()
224 })
225 .unwrap_or_default();
226 return Some(serde_json::json!({"tool": tool, "questions": questions}));
227 }
228 let input = call["input"].to_string();
229 let input: String = input.chars().take(500).collect();
230 return Some(serde_json::json!({"tool": tool, "input": input}));
231 }
232 _ => {}
233 }
234 }
235 None
236}
237
238fn last_message_at_ms(path: &Path, harness: &str) -> Option<u64> {
240 use std::io::{Read, Seek, SeekFrom};
241 for window in [256 * 1024_u64, 8 * 1024 * 1024] {
243 let mut file = std::fs::File::open(path).ok()?;
244 let length = file.metadata().ok()?.len();
245 let start = length.saturating_sub(window);
246 file.seek(SeekFrom::Start(start)).ok()?;
247 let mut bytes = Vec::new();
248 file.read_to_end(&mut bytes).ok()?;
249 let text = String::from_utf8_lossy(&bytes);
250 let mut lines = text.lines().rev().collect::<Vec<_>>();
251 if start > 0 {
252 lines.pop(); }
254 for line in lines {
255 let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
256 continue;
257 };
258 let message = match harness {
259 "codex" => record["type"] == "response_item",
260 _ => {
261 matches!(record["type"].as_str(), Some("user" | "assistant"))
262 && record["isSidechain"] != true
263 }
264 };
265 if !message {
266 continue;
267 }
268 if let Some(at) = record["timestamp"]
269 .as_str()
270 .and_then(supercode_interchange::sidecar::rfc3339_to_ms)
271 .and_then(|at| u64::try_from(at).ok())
272 {
273 return Some(at);
274 }
275 }
276 if start == 0 {
277 return None;
278 }
279 }
280 None
281}
282
283#[derive(Debug, Default)]
287pub struct LiveSessions {
288 sessions: Vec<LiveSession>,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum Unresolved {
294 Stale(String),
296 Unknown(String),
298}
299
300impl LiveSessions {
301 pub fn read(homes: &HarnessHomes) -> Self {
305 let machine = local_machine_name();
306 let registry = read_registry(®istry_dir(homes));
307 let registered = |record: &crate::live_runtime::LiveRuntimeRecord| {
310 registry.iter().find(|session| {
311 session.session_id == record.source.session_id
312 || session.session_id == record.runtime_session_id
313 })
314 };
315 let mut sessions: Vec<LiveSession> = crate::runtime_mail::controlled_runtimes()
316 .into_iter()
317 .filter_map(|record| {
318 let registered = registered(&record);
319 if registered.is_some_and(|session| session.name.starts_with(RELAY_NAME_PREFIX)) {
320 return None;
321 }
322 let address =
323 MailAddress::new(&machine, &record.source.harness, &record.source.session_id)
324 .ok()?;
325 let short: String = record.source.session_id.chars().take(8).collect();
326 let name = match registered {
327 Some(session) if !session.name.is_empty() => session.name.clone(),
328 _ => format!("{}-{short}", record.source.harness),
329 };
330 Some(LiveSession {
331 name: format!("{name}@{machine}"),
332 address,
333 status: "hosted".into(),
334 pid: Some(record.pid),
335 cwd: Some(record.source.workspace.clone()),
336 tmux: None,
337 transcript: None,
338 door: Door::Runtime(Box::new(record)),
339 })
340 })
341 .collect();
342 let controlled = |address: &MailAddress, sessions: &[LiveSession]| {
343 sessions.iter().any(|session| &session.address == address)
344 };
345 for session in registry {
346 if session.name.starts_with(RELAY_NAME_PREFIX) {
347 continue;
348 }
349 let Ok(address) = MailAddress::new(&machine, "claude-code", &session.session_id) else {
350 continue;
351 };
352 if controlled(&address, &sessions) {
353 continue;
354 }
355 sessions.push(LiveSession {
356 address,
357 name: format!("{}@{machine}", session.name),
358 status: session
359 .status
360 .as_ref()
361 .map(|status| status.as_str().to_string())
362 .unwrap_or_else(|| "unknown".into()),
363 pid: Some(session.pid),
364 cwd: session.cwd.clone(),
365 tmux: session.tmux.clone(),
366 transcript: None,
367 door: Door::Native(Box::new(session)),
368 });
369 }
370 let user_hook = codex_user_hook_installed();
371 let rollouts = crate::codex_peer::live_rollouts(&homes.codex);
372 let panes = if rollouts.is_empty() {
373 std::collections::HashMap::new()
374 } else {
375 crate::codex_peer::session_panes()
376 };
377 for (path, status) in rollouts {
378 let Some((session_id, None)) = crate::codex_peer::rollout_session(&path) else {
379 continue;
380 };
381 let Ok(address) = MailAddress::new(&machine, "codex", &session_id) else {
382 continue;
383 };
384 if controlled(&address, &sessions) {
385 continue;
386 }
387 let cwd = crate::codex_peer::rollout_cwd(&path);
388 let hooked = user_hook || cwd.as_deref().is_some_and(codex_project_hook_installed);
389 let pane = panes.get(&session_id).cloned();
390 let status = match status {
393 crate::codex_peer::CodexPeerStatus::Busy
394 | crate::codex_peer::CodexPeerStatus::Running
395 if pane.as_deref().and_then(pane_prompt).is_some() =>
396 {
397 "waiting".to_string()
398 }
399 status => status.as_str().to_string(),
400 };
401 let door = if hooked {
402 Door::Hook {
403 pane: pane.clone(),
404 idle: status == "idle",
405 }
406 } else {
407 Door::Stored
408 };
409 sessions.push(LiveSession {
410 name: format!("{}@{machine}", codex_name(&session_id)),
411 address,
412 status,
413 pid: None,
414 cwd,
415 tmux: pane,
417 transcript: Some(path),
418 door,
419 });
420 }
421 Self { sessions }
422 }
423
424 pub fn all(&self) -> &[LiveSession] {
426 &self.sessions
427 }
428
429 pub fn door(&self, harness: &str, session_id: &str) -> Option<&'static str> {
432 self.sessions
433 .iter()
434 .find(|session| {
435 session.address.harness == harness && session.address.session_id == session_id
436 })
437 .map(|session| session.door.name())
438 }
439
440 pub fn resolve(&self, to: &str) -> Result<&LiveSession, Unresolved> {
443 let machine = local_machine_name();
444 if let Ok(address) = MailAddress::parse(to) {
445 return self
446 .sessions
447 .iter()
448 .find(|session| session.address == address)
449 .ok_or_else(|| {
450 Unresolved::Stale(format!(
451 "{to} is no longer running. Nothing was sent. Run supercode message list \
452 for the live sessions."
453 ))
454 });
455 }
456 let wanted = match to.split_once('@') {
457 Some((name, at)) if at == machine => name.to_string(),
458 Some((_, at)) => {
459 return Err(Unresolved::Unknown(format!(
460 "Not sent: {to} is on machine {at}, not this one. Nothing was sent."
461 )))
462 }
463 None => to.to_string(),
464 };
465 let short = |session: &LiveSession| {
466 session
467 .name
468 .split('@')
469 .next()
470 .unwrap_or_default()
471 .to_string()
472 };
473 let matching: Vec<&LiveSession> = self
474 .sessions
475 .iter()
476 .filter(|session| short(session) == wanted)
477 .collect();
478 if let [only] = matching.as_slice() {
479 return Ok(only);
480 }
481 let hint = if matching.len() > 1 {
482 format!(
483 " {} sessions are named {wanted}; use its address.",
484 matching.len()
485 )
486 } else {
487 let near: Vec<String> = self
488 .sessions
489 .iter()
490 .filter(|session| {
491 let name = short(session);
492 name.contains(&wanted)
493 || wanted.contains(&name)
494 || name
495 .chars()
496 .zip(wanted.chars())
497 .take_while(|(a, b)| a == b)
498 .count()
499 >= 4
500 })
501 .take(3)
502 .map(|session| {
503 format!(
504 "{} ({}, {})",
505 session.name, session.address.harness, session.status
506 )
507 })
508 .collect();
509 if near.is_empty() {
510 String::new()
511 } else {
512 format!(" Did you mean: {}?", near.join(", "))
513 }
514 };
515 Err(Unresolved::Unknown(format!(
516 "No session named \"{wanted}\" is reachable.{hint} Run supercode message list. Nothing \
517 was sent."
518 )))
519 }
520}
521
522pub fn has_message_tools(pid: u32) -> bool {
526 let Ok(output) = std::process::Command::new("ps")
527 .args(["-A", "-o", "ppid=,command="])
528 .output()
529 else {
530 return false;
531 };
532 String::from_utf8_lossy(&output.stdout).lines().any(|line| {
533 let line = line.trim_start();
534 let Some((ppid, command)) = line.split_once(' ') else {
535 return false;
536 };
537 ppid.parse::<u32>() == Ok(pid) && command.trim_end().ends_with(" message mcp")
538 })
539}
540
541pub fn codex_name(session_id: &str) -> String {
543 format!("codex-{}", session_id.chars().take(8).collect::<String>())
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct Caller {
549 pub address: MailAddress,
551 pub name: String,
553}
554
555pub const CALLER_UNRESOLVED: &str = "Can't tell which session is running this command, so \
557 replies would have nowhere to go. Nothing was sent. Run it from your agent session's own \
558 shell tool.";
559
560pub fn resolve_caller(homes: &HarnessHomes, pids: &[u32]) -> Result<Caller, String> {
565 let machine = local_machine_name();
566 let registry = read_registry(®istry_dir(homes));
567 let hosted = crate::runtime_mail::controlled_runtimes();
568 for &pid in pids {
569 if let Some(record) = hosted.iter().find(|record| record.pid == pid) {
570 let short: String = record.source.session_id.chars().take(8).collect();
571 return Ok(Caller {
572 address: MailAddress::new(
573 &machine,
574 &record.source.harness,
575 &record.source.session_id,
576 )
577 .map_err(|error| error.to_string())?,
578 name: format!("{}-{short}@{machine}", record.source.harness),
579 });
580 }
581 if let Some(session) = registry.iter().find(|session| session.pid == pid) {
582 if let Ok(claimed) = std::env::var("CLAUDE_CODE_SESSION_ID") {
583 if !claimed.is_empty() && claimed != session.session_id {
584 return Err(format!(
585 "{CALLER_UNRESOLVED} (CLAUDE_CODE_SESSION_ID names {claimed}, but the Claude \
586 process {pid} above this command is session {})",
587 session.session_id
588 ));
589 }
590 }
591 return Ok(Caller {
592 address: MailAddress::new(&machine, "claude-code", &session.session_id)
593 .map_err(|error| error.to_string())?,
594 name: format!("{}@{machine}", session.name),
595 });
596 }
597 if let Some(thread) = std::env::var("CODEX_THREAD_ID")
601 .ok()
602 .filter(|thread| !thread.is_empty())
603 .filter(|thread| crate::codex_peer::holds_session(pid, thread))
604 {
605 return Ok(Caller {
606 address: MailAddress::new(&machine, "codex", &thread)
607 .map_err(|error| error.to_string())?,
608 name: format!("{}@{machine}", codex_name(&thread)),
609 });
610 }
611 if let Some((session_id, _)) = crate::codex_peer::session_of_process(pid) {
612 return Ok(Caller {
613 address: MailAddress::new(&machine, "codex", &session_id)
614 .map_err(|error| error.to_string())?,
615 name: format!("{}@{machine}", codex_name(&session_id)),
616 });
617 }
618 }
619 #[cfg(windows)]
620 if let Some(session) = msys_cut_claim(®istry, pids) {
621 return Ok(Caller {
622 address: MailAddress::new(&machine, "claude-code", &session.session_id)
623 .map_err(|error| error.to_string())?,
624 name: format!("{}@{machine}", session.name),
625 });
626 }
627 Err(format!(
628 "{CALLER_UNRESOLVED} (looked for this command's processes {pids:?} among {} Claude \
629 sessions in {} and {} hosted runtimes)",
630 registry.len(),
631 registry_dir(homes).display(),
632 hosted.len()
633 ))
634}
635
636#[cfg(windows)]
643fn msys_cut_claim<'a>(
644 registry: &'a [ClaudePeerSession],
645 pids: &[u32],
646) -> Option<&'a ClaudePeerSession> {
647 let table = process_table();
648 let [.., shell, cut] = pids else {
649 return None;
650 };
651 if table.contains_key(cut) {
652 return None;
653 }
654 let name = table.get(shell)?.1.to_ascii_lowercase();
655 if !matches!(name.as_str(), "sh.exe" | "bash.exe" | "dash.exe") {
656 return None;
657 }
658 let claimed = std::env::var("CLAUDE_CODE_SESSION_ID").ok()?;
659 registry
660 .iter()
661 .find(|session| !claimed.is_empty() && session.session_id == claimed)
662}
663
664pub fn process_ancestry() -> Vec<u32> {
666 ancestry_of(std::process::id())
667}
668
669pub fn ancestry_of(pid: u32) -> Vec<u32> {
671 let parents = parent_pids();
672 let mut chain = vec![pid];
673 let mut current = pid;
674 while let Some(&parent) = parents.get(¤t) {
675 if parent <= 1 || chain.contains(&parent) {
676 break;
677 }
678 chain.push(parent);
679 current = parent;
680 }
681 chain
682}
683
684#[cfg(not(windows))]
686fn parent_pids() -> std::collections::HashMap<u32, u32> {
687 let Ok(output) = std::process::Command::new("ps")
688 .args(["-axo", "pid=,ppid="])
689 .output()
690 else {
691 return Default::default();
692 };
693 String::from_utf8_lossy(&output.stdout)
694 .lines()
695 .filter_map(|line| {
696 let mut fields = line.split_whitespace();
697 Some((fields.next()?.parse().ok()?, fields.next()?.parse().ok()?))
698 })
699 .collect()
700}
701
702#[cfg(windows)]
704fn parent_pids() -> std::collections::HashMap<u32, u32> {
705 process_table()
706 .into_iter()
707 .map(|(pid, (parent, _))| (pid, parent))
708 .collect()
709}
710
711#[cfg(windows)]
713fn process_table() -> std::collections::HashMap<u32, (u32, String)> {
714 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
715 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
716 CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
717 TH32CS_SNAPPROCESS,
718 };
719
720 let mut parents = std::collections::HashMap::new();
721 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
722 if snapshot == INVALID_HANDLE_VALUE {
723 return parents;
724 }
725 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
726 entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
727 let mut has_entry = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
728 while has_entry {
729 let length = entry
730 .szExeFile
731 .iter()
732 .position(|&unit| unit == 0)
733 .unwrap_or(entry.szExeFile.len());
734 let name = String::from_utf16_lossy(&entry.szExeFile[..length]);
735 parents.insert(entry.th32ProcessID, (entry.th32ParentProcessID, name));
736 has_entry = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
737 }
738 unsafe {
739 CloseHandle(snapshot);
740 }
741 parents
742}
743
744pub fn codex_hooks_path() -> std::path::PathBuf {
746 std::env::var_os("CODEX_HOME")
747 .map(std::path::PathBuf::from)
748 .or_else(|| {
749 supercode_interchange::user_home()
750 .map(std::path::PathBuf::into_os_string)
751 .map(|home| std::path::PathBuf::from(home).join(".codex"))
752 })
753 .unwrap_or_else(|| std::path::PathBuf::from(".codex"))
754 .join("hooks.json")
755}
756
757pub fn codex_user_hook_installed() -> bool {
760 std::fs::read_to_string(codex_hooks_path())
761 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
762}
763
764pub fn codex_project_hook_installed(cwd: &Path) -> bool {
767 cwd.ancestors().any(|directory| {
768 std::fs::read_to_string(directory.join(".codex").join("hooks.json"))
769 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
770 })
771}
772
773#[derive(Debug, Clone, PartialEq, Eq)]
775pub enum Delivered {
776 Steered,
778 Started,
780 Native {
783 busy: bool,
785 },
786 Hooked,
788 HookWoken,
791 Queued,
793 Stored,
795 Operator,
797}
798
799#[derive(Debug, Clone, PartialEq, Eq)]
801pub enum Refused {
802 CannotQueueNative,
805 TooLong(usize),
807}
808
809pub const MAX_RELAYED_BYTES: usize = 100_000;
812
813pub async fn deliver(
817 envelope: &Envelope,
818 to: &MailAddress,
819 door: &Door,
820 wake: bool,
821 notify_when_idle: bool,
822) -> Result<Result<Delivered, Refused>, String> {
823 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
824 let mut final_reply = false;
828 let delivered = match door {
829 Door::Runtime(record) => {
830 let mut sent = envelope.clone();
831 let answers = matches!(envelope.reply_via, ReplyVia::Command);
832 if answers {
833 sent.reply_via = ReplyVia::FinalMessage {
834 destination: envelope.from_name.clone(),
835 };
836 }
837 match deliver_to_runtime(record, sent.render(), wake).await? {
838 RuntimeDelivery::Steered => {
839 mailbox
840 .deliver_read(&sent)
841 .map_err(|error| error.to_string())?;
842 final_reply = answers;
843 Delivered::Steered
844 }
845 RuntimeDelivery::Started => {
846 mailbox
847 .deliver_read(&sent)
848 .map_err(|error| error.to_string())?;
849 final_reply = answers;
850 Delivered::Started
851 }
852 RuntimeDelivery::NotWoken => {
853 mailbox
854 .deliver(envelope)
855 .map_err(|error| error.to_string())?;
856 Delivered::Queued
857 }
858 }
859 }
860 Door::Native(session) => {
861 let busy = session.status != Some(ClaudePeerStatus::Idle);
862 if !wake && !busy {
863 return Ok(Err(Refused::CannotQueueNative));
864 }
865 let mut envelope = envelope.clone();
869 if envelope.reply_via == ReplyVia::Command && has_message_tools(session.pid) {
870 envelope.reply_via = ReplyVia::Tool;
871 }
872 let envelope = &envelope;
873 let text = envelope.render();
874 if text.len() > MAX_RELAYED_BYTES {
875 return Ok(Err(Refused::TooLong(text.len())));
876 }
877 match send_through_relay(
878 &envelope.from,
879 &envelope.from_name,
880 &session.name,
881 text,
882 &envelope.id,
883 )
884 .await
885 {
886 RelayReceipt::Delivered { .. } => {
887 mailbox
888 .deliver_read(envelope)
889 .map_err(|error| error.to_string())?;
890 Delivered::Native { busy }
891 }
892 RelayReceipt::Failed { detail } => return Err(detail),
893 }
894 }
895 Door::Hook { pane, idle } => {
896 mailbox
897 .deliver(envelope)
898 .map_err(|error| error.to_string())?;
899 match pane {
902 Some(pane)
903 if wake
904 && *idle
905 && submit_when_composer_empty(pane, CODEX_WAKE).await == Ok(true) =>
906 {
907 Delivered::HookWoken
908 }
909 _ => Delivered::Hooked,
910 }
911 }
912 Door::Stored => {
913 mailbox
914 .deliver(envelope)
915 .map_err(|error| error.to_string())?;
916 Delivered::Stored
917 }
918 Door::Operator => {
919 mailbox
920 .deliver(envelope)
921 .map_err(|error| error.to_string())?;
922 Delivered::Operator
923 }
924 };
925 let notice = notify_when_idle && !matches!(door, Door::Operator);
926 if notice || final_reply {
927 let mut subscription = IdleSubscription::new(envelope.id.clone(), envelope.from.clone());
928 subscription.notice = notice;
929 subscription.final_reply = final_reply;
930 mailbox
931 .subscribe_idle(&subscription)
932 .map_err(|error| error.to_string())?;
933 if let Ok(program) = supercode_program() {
935 crate::claude_relay::ensure_machine_daemon(&program)
936 .await
937 .ok();
938 }
939 }
940 Ok(Ok(delivered))
941}
942
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
945pub enum UserTurn {
946 Steered,
948 Started,
950 Typed,
952 Waiting,
956}
957
958impl UserTurn {
959 pub const fn as_str(self) -> &'static str {
961 match self {
962 Self::Steered => "steered",
963 Self::Started => "started",
964 Self::Typed => "typed",
965 Self::Waiting => "waiting",
966 }
967 }
968}
969
970pub fn pane_prompt(pane: &str) -> Option<Vec<String>> {
976 #[cfg(unix)]
977 {
978 let output = std::process::Command::new("tmux")
979 .args(["capture-pane", "-p", "-t", &format!("{pane}:")])
980 .output()
981 .ok()?;
982 if !output.status.success() {
983 return None;
984 }
985 let screen = String::from_utf8_lossy(&output.stdout);
986 let lines: Vec<&str> = screen.lines().map(str::trim_end).collect();
987 let is_option = |line: &str| {
988 let line = line.trim_start();
989 let rest = line
990 .strip_prefix('❯')
991 .or_else(|| line.strip_prefix('›'))
992 .unwrap_or(line)
993 .trim_start();
994 let digits = rest.chars().take_while(char::is_ascii_digit).count();
995 digits > 0 && matches!(rest[digits..].chars().next(), Some('.' | ')'))
996 };
997 let selected = lines.iter().position(|line| {
998 let line = line.trim_start();
999 (line.starts_with('❯') || line.starts_with('›')) && is_option(line)
1000 })?;
1001 let block_start = |end: usize| {
1003 lines[..end]
1004 .iter()
1005 .rposition(|line| line.trim().is_empty())
1006 .map_or(0, |blank| blank + 1)
1007 };
1008 let options = block_start(selected);
1009 let above = lines[..options]
1010 .iter()
1011 .rposition(|line| !line.trim().is_empty())
1012 .map(|last| block_start(last));
1013 let start = above.unwrap_or(options);
1014 let end = lines[selected..]
1015 .iter()
1016 .position(|line| line.trim().is_empty())
1017 .map_or(lines.len(), |blank| selected + blank);
1018 Some(
1019 lines[start..end]
1020 .iter()
1021 .map(|line| line.trim().to_string())
1022 .filter(|line| !line.is_empty())
1023 .take(20)
1024 .collect(),
1025 )
1026 }
1027 #[cfg(not(unix))]
1028 {
1029 let _ = pane;
1030 None
1031 }
1032}
1033
1034pub fn daemon_pane(session: &LiveSession) -> Option<String> {
1035 let name = session.tmux.as_deref()?.split(':').next()?;
1036 let rest = name.strip_prefix("p_")?;
1037 (!rest.is_empty()
1038 && rest
1039 .chars()
1040 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1041 .then(|| name.to_string())
1042}
1043
1044pub async fn deliver_user_turn(
1050 homes: &HarnessHomes,
1051 envelope: &Envelope,
1052 to: &MailAddress,
1053) -> Result<UserTurn, String> {
1054 let session = LiveSessions::read(homes)
1055 .sessions
1056 .into_iter()
1057 .find(|session| &session.address == to)
1058 .ok_or_else(|| format!("{to} is not running; nothing was sent"))?;
1059 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
1060 if let Door::Runtime(record) = &session.door {
1061 let delivered = deliver_to_runtime(record, envelope.body.clone(), true).await?;
1062 mailbox
1063 .deliver_read(envelope)
1064 .map_err(|error| error.to_string())?;
1065 return Ok(match delivered {
1066 RuntimeDelivery::Steered => UserTurn::Steered,
1067 _ => UserTurn::Started,
1068 });
1069 }
1070 let Some(pane) = daemon_pane(&session) else {
1071 let name = session.name.split('@').next().unwrap_or(&session.name);
1072 return Err(format!(
1073 "{name} runs outside supercode, where nothing can speak as its user. Open it in a \
1074 pane with `supercode open {name}`; nothing was sent."
1075 ));
1076 };
1077 mailbox
1078 .deliver(envelope)
1079 .map_err(|error| error.to_string())?;
1080 let typed = type_user_turns(&mailbox, &pane).await;
1081 Ok(if typed.contains(&envelope.id) {
1082 UserTurn::Typed
1083 } else {
1084 UserTurn::Waiting
1085 })
1086}
1087
1088pub async fn type_user_turns(mailbox: &Mailbox, pane: &str) -> Vec<String> {
1092 let mut typed = Vec::new();
1093 for waiting in mailbox.user_turns().unwrap_or_default() {
1094 let Ok(Some(stored)) = mailbox.claim_user_turn(&waiting) else {
1098 break;
1099 };
1100 match submit_when_composer_empty(pane, &stored.envelope.body).await {
1101 Ok(true) => {
1102 mailbox.acknowledge(&stored).ok();
1103 typed.push(stored.envelope.id.clone());
1104 }
1105 Ok(false) => {
1106 mailbox.release(&stored).ok();
1107 break;
1108 }
1109 Err(error) => {
1110 mailbox.release(&stored).ok();
1111 eprintln!(
1112 "supercode: the user's turn {} for {} waits: {error}",
1113 stored.envelope.id,
1114 mailbox.address()
1115 );
1116 break;
1117 }
1118 }
1119 }
1120 typed
1121}
1122
1123pub const CODEX_WAKE: &str =
1126 "You have unread supercode messages. Read them with: supercode message inbox";
1127
1128async fn submit_when_composer_empty(pane: &str, text: &str) -> Result<bool, String> {
1131 let entry = crate::teams_entry().map_err(|error| error.to_string())?;
1132 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1133 .ok()
1134 .filter(|value| !value.trim().is_empty())
1135 .unwrap_or_else(|| "node".into());
1136 let output = tokio::process::Command::new(node)
1137 .arg(entry)
1138 .args(["input", pane, text, "--when-composer-empty"])
1139 .env_remove("SUPERCODE_CALLER")
1142 .stdin(std::process::Stdio::null())
1143 .output()
1144 .await
1145 .map_err(|error| error.to_string())?;
1146 if !output.status.success() {
1147 return Err(crate::mailbox::error_line(&String::from_utf8_lossy(
1148 &output.stderr,
1149 )));
1150 }
1151 let answer: serde_json::Value =
1152 serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
1153 Ok(answer["delivered"] == true)
1154}