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 for (path, status) in rollouts {
396 let Some((session_id, None)) = crate::codex_peer::rollout_session(&path) else {
397 continue;
398 };
399 let Ok(address) = MailAddress::new(&machine, "codex", &session_id) else {
400 continue;
401 };
402 if controlled(&address, &sessions) {
403 continue;
404 }
405 let cwd = crate::codex_peer::rollout_cwd(&path);
406 let hooked = user_hook || cwd.as_deref().is_some_and(codex_project_hook_installed);
407 let pane = panes.get(&session_id).cloned();
408 let status = match status {
411 crate::codex_peer::CodexPeerStatus::Busy
412 | crate::codex_peer::CodexPeerStatus::Running
413 if pane.as_deref().and_then(pane_prompt).is_some() =>
414 {
415 "waiting".to_string()
416 }
417 status => status.as_str().to_string(),
418 };
419 let door = if hooked {
420 Door::Hook {
421 pane: pane.clone(),
422 idle: status == "idle",
423 }
424 } else {
425 Door::Stored
426 };
427 sessions.push(LiveSession {
428 name: format!("{}@{machine}", codex_name(&session_id)),
429 address,
430 status,
431 pid: None,
432 cwd,
433 tmux: pane,
435 transcript: Some(path),
436 door,
437 });
438 }
439 Self { sessions }
440 }
441
442 pub fn all(&self) -> &[LiveSession] {
444 &self.sessions
445 }
446
447 pub fn door(&self, harness: &str, session_id: &str) -> Option<&'static str> {
450 self.sessions
451 .iter()
452 .find(|session| {
453 session.address.harness == harness && session.address.session_id == session_id
454 })
455 .map(|session| session.door.name())
456 }
457
458 pub fn resolve(&self, to: &str) -> Result<&LiveSession, Unresolved> {
461 let machine = local_machine_name();
462 if let Ok(address) = MailAddress::parse(to) {
463 return self
464 .sessions
465 .iter()
466 .find(|session| session.address == address)
467 .ok_or_else(|| {
468 Unresolved::Stale(format!(
469 "{to} is no longer running. Nothing was sent. Run supercode message list \
470 for the live sessions."
471 ))
472 });
473 }
474 let wanted = match to.split_once('@') {
475 Some((name, at)) if at == machine => name.to_string(),
476 Some((_, at)) => {
477 return Err(Unresolved::Unknown(format!(
478 "Not sent: {to} is on machine {at}, not this one. Nothing was sent."
479 )))
480 }
481 None => to.to_string(),
482 };
483 let short = |session: &LiveSession| {
484 session
485 .name
486 .split('@')
487 .next()
488 .unwrap_or_default()
489 .to_string()
490 };
491 let matching: Vec<&LiveSession> = self
492 .sessions
493 .iter()
494 .filter(|session| short(session) == wanted)
495 .collect();
496 if let [only] = matching.as_slice() {
497 return Ok(only);
498 }
499 let hint = if matching.len() > 1 {
500 format!(
501 " {} sessions are named {wanted}; use its address.",
502 matching.len()
503 )
504 } else {
505 let near: Vec<String> = self
506 .sessions
507 .iter()
508 .filter(|session| {
509 let name = short(session);
510 name.contains(&wanted)
511 || wanted.contains(&name)
512 || name
513 .chars()
514 .zip(wanted.chars())
515 .take_while(|(a, b)| a == b)
516 .count()
517 >= 4
518 })
519 .take(3)
520 .map(|session| {
521 format!(
522 "{} ({}, {})",
523 session.name, session.address.harness, session.status
524 )
525 })
526 .collect();
527 if near.is_empty() {
528 String::new()
529 } else {
530 format!(" Did you mean: {}?", near.join(", "))
531 }
532 };
533 Err(Unresolved::Unknown(format!(
534 "No session named \"{wanted}\" is reachable.{hint} Run supercode message list. Nothing \
535 was sent."
536 )))
537 }
538}
539
540pub fn has_message_tools(pid: u32) -> bool {
544 let Ok(output) = std::process::Command::new("ps")
545 .args(["-A", "-o", "ppid=,command="])
546 .output()
547 else {
548 return false;
549 };
550 String::from_utf8_lossy(&output.stdout).lines().any(|line| {
551 let line = line.trim_start();
552 let Some((ppid, command)) = line.split_once(' ') else {
553 return false;
554 };
555 ppid.parse::<u32>() == Ok(pid) && command.trim_end().ends_with(" message mcp")
556 })
557}
558
559pub fn codex_name(session_id: &str) -> String {
561 format!("codex-{}", session_id.chars().take(8).collect::<String>())
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
566pub struct Caller {
567 pub address: MailAddress,
569 pub name: String,
571}
572
573pub const CALLER_UNRESOLVED: &str = "Can't tell which session is running this command, so \
575 replies would have nowhere to go. Nothing was sent. Run it from your agent session's own \
576 shell tool.";
577
578pub fn resolve_caller(homes: &HarnessHomes, pids: &[u32]) -> Result<Caller, String> {
583 let machine = local_machine_name();
584 let registry = read_registry(®istry_dir(homes));
585 let hosted = crate::runtime_mail::controlled_runtimes();
586 for &pid in pids {
587 if let Some(record) = hosted.iter().find(|record| record.pid == pid) {
588 let short: String = record.source.session_id.chars().take(8).collect();
589 return Ok(Caller {
590 address: MailAddress::new(
591 &machine,
592 &record.source.harness,
593 &record.source.session_id,
594 )
595 .map_err(|error| error.to_string())?,
596 name: format!("{}-{short}@{machine}", record.source.harness),
597 });
598 }
599 if let Some(session) = registry.iter().find(|session| session.pid == pid) {
600 if let Ok(claimed) = std::env::var("CLAUDE_CODE_SESSION_ID") {
601 if !claimed.is_empty() && claimed != session.session_id {
602 return Err(format!(
603 "{CALLER_UNRESOLVED} (CLAUDE_CODE_SESSION_ID names {claimed}, but the Claude \
604 process {pid} above this command is session {})",
605 session.session_id
606 ));
607 }
608 }
609 return Ok(Caller {
610 address: MailAddress::new(&machine, "claude-code", &session.session_id)
611 .map_err(|error| error.to_string())?,
612 name: format!("{}@{machine}", session.name),
613 });
614 }
615 if let Some(thread) = std::env::var("CODEX_THREAD_ID")
619 .ok()
620 .filter(|thread| !thread.is_empty())
621 .filter(|thread| crate::codex_peer::holds_session(pid, thread))
622 {
623 return Ok(Caller {
624 address: MailAddress::new(&machine, "codex", &thread)
625 .map_err(|error| error.to_string())?,
626 name: format!("{}@{machine}", codex_name(&thread)),
627 });
628 }
629 if let Some((session_id, _)) = crate::codex_peer::session_of_process(pid) {
630 return Ok(Caller {
631 address: MailAddress::new(&machine, "codex", &session_id)
632 .map_err(|error| error.to_string())?,
633 name: format!("{}@{machine}", codex_name(&session_id)),
634 });
635 }
636 }
637 #[cfg(windows)]
638 if let Some(session) = msys_cut_claim(®istry, pids) {
639 return Ok(Caller {
640 address: MailAddress::new(&machine, "claude-code", &session.session_id)
641 .map_err(|error| error.to_string())?,
642 name: format!("{}@{machine}", session.name),
643 });
644 }
645 Err(format!(
646 "{CALLER_UNRESOLVED} (looked for this command's processes {pids:?} among {} Claude \
647 sessions in {} and {} hosted runtimes)",
648 registry.len(),
649 registry_dir(homes).display(),
650 hosted.len()
651 ))
652}
653
654#[cfg(windows)]
661fn msys_cut_claim<'a>(
662 registry: &'a [ClaudePeerSession],
663 pids: &[u32],
664) -> Option<&'a ClaudePeerSession> {
665 let table = process_table();
666 let [.., shell, cut] = pids else {
667 return None;
668 };
669 if table.contains_key(cut) {
670 return None;
671 }
672 let name = table.get(shell)?.1.to_ascii_lowercase();
673 if !matches!(name.as_str(), "sh.exe" | "bash.exe" | "dash.exe") {
674 return None;
675 }
676 let claimed = std::env::var("CLAUDE_CODE_SESSION_ID").ok()?;
677 registry
678 .iter()
679 .find(|session| !claimed.is_empty() && session.session_id == claimed)
680}
681
682pub fn process_ancestry() -> Vec<u32> {
684 ancestry_of(std::process::id())
685}
686
687pub fn ancestry_of(pid: u32) -> Vec<u32> {
689 let parents = parent_pids();
690 let mut chain = vec![pid];
691 let mut current = pid;
692 while let Some(&parent) = parents.get(¤t) {
693 if parent <= 1 || chain.contains(&parent) {
694 break;
695 }
696 chain.push(parent);
697 current = parent;
698 }
699 chain
700}
701
702#[cfg(not(windows))]
704fn parent_pids() -> std::collections::HashMap<u32, u32> {
705 let Ok(output) = std::process::Command::new("ps")
706 .args(["-axo", "pid=,ppid="])
707 .output()
708 else {
709 return Default::default();
710 };
711 String::from_utf8_lossy(&output.stdout)
712 .lines()
713 .filter_map(|line| {
714 let mut fields = line.split_whitespace();
715 Some((fields.next()?.parse().ok()?, fields.next()?.parse().ok()?))
716 })
717 .collect()
718}
719
720#[cfg(windows)]
722fn parent_pids() -> std::collections::HashMap<u32, u32> {
723 process_table()
724 .into_iter()
725 .map(|(pid, (parent, _))| (pid, parent))
726 .collect()
727}
728
729#[cfg(windows)]
731fn process_table() -> std::collections::HashMap<u32, (u32, String)> {
732 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
733 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
734 CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
735 TH32CS_SNAPPROCESS,
736 };
737
738 let mut parents = std::collections::HashMap::new();
739 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
740 if snapshot == INVALID_HANDLE_VALUE {
741 return parents;
742 }
743 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
744 entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
745 let mut has_entry = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
746 while has_entry {
747 let length = entry
748 .szExeFile
749 .iter()
750 .position(|&unit| unit == 0)
751 .unwrap_or(entry.szExeFile.len());
752 let name = String::from_utf16_lossy(&entry.szExeFile[..length]);
753 parents.insert(entry.th32ProcessID, (entry.th32ParentProcessID, name));
754 has_entry = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
755 }
756 unsafe {
757 CloseHandle(snapshot);
758 }
759 parents
760}
761
762pub fn codex_hooks_path() -> std::path::PathBuf {
764 std::env::var_os("CODEX_HOME")
765 .map(std::path::PathBuf::from)
766 .or_else(|| {
767 supercode_interchange::user_home()
768 .map(std::path::PathBuf::into_os_string)
769 .map(|home| std::path::PathBuf::from(home).join(".codex"))
770 })
771 .unwrap_or_else(|| std::path::PathBuf::from(".codex"))
772 .join("hooks.json")
773}
774
775pub fn codex_user_hook_installed() -> bool {
778 std::fs::read_to_string(codex_hooks_path())
779 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
780}
781
782pub fn codex_project_hook_installed(cwd: &Path) -> bool {
785 cwd.ancestors().any(|directory| {
786 std::fs::read_to_string(directory.join(".codex").join("hooks.json"))
787 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
788 })
789}
790
791#[derive(Debug, Clone, PartialEq, Eq)]
793pub enum Delivered {
794 Steered,
796 Started,
798 Native {
801 busy: bool,
803 },
804 Hooked,
806 HookWoken,
809 Queued,
811 Stored,
813 Operator,
815}
816
817#[derive(Debug, Clone, PartialEq, Eq)]
819pub enum Refused {
820 CannotQueueNative,
823 TooLong(usize),
825}
826
827pub const MAX_RELAYED_BYTES: usize = 100_000;
830
831pub async fn deliver(
835 envelope: &Envelope,
836 to: &MailAddress,
837 door: &Door,
838 wake: bool,
839 notify_when_idle: bool,
840) -> Result<Result<Delivered, Refused>, String> {
841 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
842 let mut final_reply = false;
846 let delivered = match door {
847 Door::Runtime(record) => {
848 let mut sent = envelope.clone();
849 let answers = matches!(envelope.reply_via, ReplyVia::Command);
850 if answers {
851 sent.reply_via = ReplyVia::FinalMessage {
852 destination: envelope.from_name.clone(),
853 };
854 }
855 match deliver_to_runtime(record, sent.render(), wake).await? {
856 RuntimeDelivery::Steered => {
857 mailbox
858 .deliver_read(&sent)
859 .map_err(|error| error.to_string())?;
860 final_reply = answers;
861 Delivered::Steered
862 }
863 RuntimeDelivery::Started => {
864 mailbox
865 .deliver_read(&sent)
866 .map_err(|error| error.to_string())?;
867 final_reply = answers;
868 Delivered::Started
869 }
870 RuntimeDelivery::NotWoken => {
871 mailbox
872 .deliver(envelope)
873 .map_err(|error| error.to_string())?;
874 Delivered::Queued
875 }
876 }
877 }
878 Door::Native(session) => {
879 let busy = session.status != Some(ClaudePeerStatus::Idle);
880 if !wake && !busy {
881 return Ok(Err(Refused::CannotQueueNative));
882 }
883 let mut envelope = envelope.clone();
887 if envelope.reply_via == ReplyVia::Command && has_message_tools(session.pid) {
888 envelope.reply_via = ReplyVia::Tool;
889 }
890 let envelope = &envelope;
891 let text = envelope.render();
892 if text.len() > MAX_RELAYED_BYTES {
893 return Ok(Err(Refused::TooLong(text.len())));
894 }
895 match send_through_relay(
896 &envelope.from,
897 &envelope.from_name,
898 &session.name,
899 text,
900 &envelope.id,
901 )
902 .await
903 {
904 RelayReceipt::Delivered { .. } => {
905 mailbox
906 .deliver_read(envelope)
907 .map_err(|error| error.to_string())?;
908 Delivered::Native { busy }
909 }
910 RelayReceipt::Failed { detail } => return Err(detail),
911 }
912 }
913 Door::Hook { pane, idle } => {
914 mailbox
915 .deliver(envelope)
916 .map_err(|error| error.to_string())?;
917 match pane {
920 Some(pane)
921 if wake
922 && *idle
923 && submit_when_composer_empty(pane, CODEX_WAKE).await == Ok(true) =>
924 {
925 Delivered::HookWoken
926 }
927 _ => Delivered::Hooked,
928 }
929 }
930 Door::Stored => {
931 mailbox
932 .deliver(envelope)
933 .map_err(|error| error.to_string())?;
934 Delivered::Stored
935 }
936 Door::Operator => {
937 mailbox
938 .deliver(envelope)
939 .map_err(|error| error.to_string())?;
940 Delivered::Operator
941 }
942 };
943 let notice = notify_when_idle && !matches!(door, Door::Operator);
944 if notice || final_reply {
945 let mut subscription = IdleSubscription::new(envelope.id.clone(), envelope.from.clone());
946 subscription.notice = notice;
947 subscription.final_reply = final_reply;
948 mailbox
949 .subscribe_idle(&subscription)
950 .map_err(|error| error.to_string())?;
951 if let Ok(program) = supercode_program() {
953 crate::claude_relay::ensure_machine_daemon(&program)
954 .await
955 .ok();
956 }
957 }
958 Ok(Ok(delivered))
959}
960
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
963pub enum UserTurn {
964 Steered,
966 Started,
968 Typed,
970 Waiting,
974}
975
976impl UserTurn {
977 pub const fn as_str(self) -> &'static str {
979 match self {
980 Self::Steered => "steered",
981 Self::Started => "started",
982 Self::Typed => "typed",
983 Self::Waiting => "waiting",
984 }
985 }
986}
987
988pub fn pane_prompt(pane: &str) -> Option<Vec<String>> {
993 let screen = pane_screen(pane)?;
994 let lines: Vec<&str> = screen.lines().map(str::trim_end).collect();
995 let is_option = |line: &str| {
996 let line = line.trim_start();
997 let rest = line
999 .strip_prefix('❯')
1000 .or_else(|| line.strip_prefix('›'))
1001 .or_else(|| line.strip_prefix('>'))
1002 .unwrap_or(line)
1003 .trim_start();
1004 let digits = rest.chars().take_while(char::is_ascii_digit).count();
1005 digits > 0 && matches!(rest[digits..].chars().next(), Some('.' | ')'))
1006 };
1007 let selected = lines.iter().position(|line| {
1008 let line = line.trim_start();
1009 (line.starts_with('❯') || line.starts_with('›') || line.starts_with('>')) && is_option(line)
1010 })?;
1011 let block_start = |end: usize| {
1013 lines[..end]
1014 .iter()
1015 .rposition(|line| line.trim().is_empty())
1016 .map_or(0, |blank| blank + 1)
1017 };
1018 let options = block_start(selected);
1019 let above = lines[..options]
1020 .iter()
1021 .rposition(|line| !line.trim().is_empty())
1022 .map(|last| block_start(last));
1023 let start = above.unwrap_or(options);
1024 let end = lines[selected..]
1025 .iter()
1026 .position(|line| line.trim().is_empty())
1027 .map_or(lines.len(), |blank| selected + blank);
1028 Some(
1029 lines[start..end]
1030 .iter()
1031 .map(|line| line.trim().to_string())
1032 .filter(|line| !line.is_empty())
1033 .take(20)
1034 .collect(),
1035 )
1036}
1037
1038fn pane_screen(pane: &str) -> Option<String> {
1041 #[cfg(unix)]
1042 {
1043 let output = std::process::Command::new("tmux")
1044 .args(["capture-pane", "-p", "-t", &format!("{pane}:")])
1045 .output()
1046 .ok()?;
1047 output
1048 .status
1049 .success()
1050 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1051 }
1052 #[cfg(not(unix))]
1053 {
1054 let entry = crate::teams_entry().ok()?;
1055 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1056 .ok()
1057 .filter(|value| !value.trim().is_empty())
1058 .unwrap_or_else(|| "node".into());
1059 let output = std::process::Command::new(node)
1060 .arg(entry)
1061 .args(["panes", "capture", pane, "--lines", "60"])
1062 .stdin(std::process::Stdio::null())
1063 .output()
1064 .ok()?;
1065 output
1066 .status
1067 .success()
1068 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1069 }
1070}
1071
1072pub fn daemon_pane(session: &LiveSession) -> Option<String> {
1074 let name = session.tmux.as_deref()?.split(':').next()?;
1075 let rest = name.strip_prefix("p_")?;
1076 (!rest.is_empty()
1077 && rest
1078 .chars()
1079 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1080 .then(|| name.to_string())
1081}
1082
1083pub async fn deliver_user_turn(
1089 homes: &HarnessHomes,
1090 envelope: &Envelope,
1091 to: &MailAddress,
1092) -> Result<UserTurn, String> {
1093 let session = LiveSessions::read(homes)
1094 .sessions
1095 .into_iter()
1096 .find(|session| &session.address == to)
1097 .ok_or_else(|| format!("{to} is not running; nothing was sent"))?;
1098 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
1099 if let Door::Runtime(record) = &session.door {
1100 let delivered = deliver_to_runtime(record, envelope.body.clone(), true).await?;
1101 mailbox
1102 .deliver_read(envelope)
1103 .map_err(|error| error.to_string())?;
1104 return Ok(match delivered {
1105 RuntimeDelivery::Steered => UserTurn::Steered,
1106 _ => UserTurn::Started,
1107 });
1108 }
1109 let Some(pane) = daemon_pane(&session) else {
1110 let name = session.name.split('@').next().unwrap_or(&session.name);
1111 return Err(format!(
1112 "{name} runs outside supercode, where nothing can speak as its user. Open it in a \
1113 pane with `supercode open {name}`; nothing was sent."
1114 ));
1115 };
1116 mailbox
1117 .deliver(envelope)
1118 .map_err(|error| error.to_string())?;
1119 let typed = type_user_turns(&mailbox, &pane).await;
1120 Ok(if typed.contains(&envelope.id) {
1121 UserTurn::Typed
1122 } else {
1123 UserTurn::Waiting
1124 })
1125}
1126
1127pub async fn type_user_turns(mailbox: &Mailbox, pane: &str) -> Vec<String> {
1131 let mut typed = Vec::new();
1132 for waiting in mailbox.user_turns().unwrap_or_default() {
1133 let Ok(Some(stored)) = mailbox.claim_user_turn(&waiting) else {
1137 break;
1138 };
1139 match submit_when_composer_empty(pane, &stored.envelope.body).await {
1140 Ok(true) => {
1141 mailbox.acknowledge(&stored).ok();
1142 typed.push(stored.envelope.id.clone());
1143 }
1144 Ok(false) => {
1145 mailbox.release(&stored).ok();
1146 break;
1147 }
1148 Err(error) => {
1149 mailbox.release(&stored).ok();
1150 eprintln!(
1151 "supercode: the user's turn {} for {} waits: {error}",
1152 stored.envelope.id,
1153 mailbox.address()
1154 );
1155 break;
1156 }
1157 }
1158 }
1159 typed
1160}
1161
1162pub const CODEX_WAKE: &str =
1165 "You have unread supercode messages. Read them with: supercode message inbox";
1166
1167async fn submit_when_composer_empty(pane: &str, text: &str) -> Result<bool, String> {
1170 let entry = crate::teams_entry().map_err(|error| error.to_string())?;
1171 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1172 .ok()
1173 .filter(|value| !value.trim().is_empty())
1174 .unwrap_or_else(|| "node".into());
1175 let output = tokio::process::Command::new(node)
1176 .arg(entry)
1177 .args(["input", pane, text, "--when-composer-empty"])
1178 .env_remove("SUPERCODE_CALLER")
1181 .stdin(std::process::Stdio::null())
1182 .output()
1183 .await
1184 .map_err(|error| error.to_string())?;
1185 if !output.status.success() {
1186 return Err(crate::mailbox::error_line(&String::from_utf8_lossy(
1187 &output.stderr,
1188 )));
1189 }
1190 let answer: serde_json::Value =
1191 serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
1192 Ok(answer["delivered"] == true)
1193}