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 crate::slow_log::timed("read live sessions", || Self::read_now(homes))
315 }
316
317 fn read_now(homes: &HarnessHomes) -> Self {
318 let machine = local_machine_name();
319 let registry = read_registry(®istry_dir(homes));
320 let registered = |record: &crate::live_runtime::LiveRuntimeRecord| {
323 registry.iter().find(|session| {
324 session.session_id == record.source.session_id
325 || session.session_id == record.runtime_session_id
326 })
327 };
328 let records = crate::runtime_mail::controlled_runtimes();
329 let turns = crate::runtime_mail::runtime_turn_states(&records);
331 let mut sessions: Vec<LiveSession> = records
332 .into_iter()
333 .zip(turns)
334 .filter_map(|(record, turn)| {
335 let registered = registered(&record);
336 if registered.is_some_and(|session| session.name.starts_with(RELAY_NAME_PREFIX)) {
337 return None;
338 }
339 let address =
340 MailAddress::new(&machine, &record.source.harness, &record.source.session_id)
341 .ok()?;
342 let short: String = record.source.session_id.chars().take(8).collect();
343 let name = match registered {
344 Some(session) if !session.name.is_empty() => session.name.clone(),
345 _ => format!("{}-{short}", record.source.harness),
346 };
347 Some(LiveSession {
348 name: format!("{name}@{machine}"),
349 address,
350 status: match turn {
352 Some(crate::frontend::FrontendTurnState::Busy) => "busy".into(),
353 Some(crate::frontend::FrontendTurnState::Idle) => "idle".into(),
354 None => "hosted".into(),
355 },
356 pid: Some(record.pid),
357 cwd: Some(record.source.workspace.clone()),
358 tmux: None,
359 transcript: None,
360 door: Door::Runtime(Box::new(record)),
361 })
362 })
363 .collect();
364 let controlled = |address: &MailAddress, sessions: &[LiveSession]| {
365 sessions.iter().any(|session| &session.address == address)
366 };
367 for session in registry {
368 if session.name.starts_with(RELAY_NAME_PREFIX) {
369 continue;
370 }
371 let Ok(address) = MailAddress::new(&machine, "claude-code", &session.session_id) else {
372 continue;
373 };
374 if controlled(&address, &sessions) {
375 continue;
376 }
377 sessions.push(LiveSession {
378 address,
379 name: format!("{}@{machine}", session.name),
380 status: session
381 .status
382 .as_ref()
383 .map(|status| status.as_str().to_string())
384 .unwrap_or_else(|| "unknown".into()),
385 pid: Some(session.pid),
386 cwd: session.cwd.clone(),
387 tmux: session.tmux.clone(),
388 transcript: None,
389 door: Door::Native(Box::new(session)),
390 });
391 }
392 let user_hook = codex_user_hook_installed();
393 let rollouts = crate::slow_log::timed("codex live rollouts", || {
394 crate::codex_peer::live_rollouts(&homes.codex)
395 });
396 let panes = crate::slow_log::timed("codex session panes", crate::codex_peer::session_panes);
397 let live_panes = (!panes.is_empty())
400 .then(|| live_daemon_panes(panes.values()))
401 .flatten();
402 for (path, status) in rollouts {
403 let Some((session_id, None)) = crate::codex_peer::rollout_session(&path) else {
404 continue;
405 };
406 let Ok(address) = MailAddress::new(&machine, "codex", &session_id) else {
407 continue;
408 };
409 if controlled(&address, &sessions) {
410 continue;
411 }
412 let cwd = crate::codex_peer::rollout_cwd(&path);
413 let hooked = user_hook || cwd.as_deref().is_some_and(codex_project_hook_installed);
414 let pane = panes
415 .get(&session_id)
416 .filter(|pane| {
417 live_panes
418 .as_ref()
419 .is_none_or(|live| live.contains_key(*pane))
420 })
421 .cloned();
422 let status = match status {
425 crate::codex_peer::CodexPeerStatus::Busy
426 | crate::codex_peer::CodexPeerStatus::Running
427 if pane.as_ref().is_some_and(|p| {
428 live_panes.as_ref().and_then(|live| live.get(p)) == Some(&true)
429 }) =>
430 {
431 "idle".to_string()
432 }
433
434 crate::codex_peer::CodexPeerStatus::Busy
435 | crate::codex_peer::CodexPeerStatus::Running
436 if pane.as_deref().and_then(pane_prompt).is_some() =>
437 {
438 "waiting".to_string()
439 }
440 status => status.as_str().to_string(),
441 };
442 let door = if hooked {
443 Door::Hook {
444 pane: pane.clone(),
445 idle: status == "idle",
446 }
447 } else {
448 Door::Stored
449 };
450 sessions.push(LiveSession {
451 name: format!("{}@{machine}", codex_name(&session_id)),
452 address,
453 status,
454 pid: None,
455 cwd,
456 tmux: pane,
458 transcript: Some(path),
459 door,
460 });
461 }
462 for (session_id, pane) in &panes {
465 let Ok(address) = MailAddress::new(&machine, "codex", session_id) else {
466 continue;
467 };
468 if controlled(&address, &sessions)
469 || !live_panes
470 .as_ref()
471 .is_some_and(|live| live.contains_key(pane))
472 {
473 continue;
474 }
475 let Some(path) = crate::codex_peer::rollout_of_session(&homes.codex, session_id) else {
476 continue;
477 };
478 let cwd = crate::codex_peer::rollout_cwd(&path);
479 let hooked = user_hook || cwd.as_deref().is_some_and(codex_project_hook_installed);
480 sessions.push(LiveSession {
481 name: format!("{}@{machine}", codex_name(session_id)),
482 address,
483 status: "idle".to_string(),
484 pid: None,
485 cwd,
486 tmux: Some(pane.clone()),
487 transcript: Some(path),
488 door: if hooked {
489 Door::Hook {
490 pane: Some(pane.clone()),
491 idle: true,
492 }
493 } else {
494 Door::Stored
495 },
496 });
497 }
498 Self { sessions }
499 }
500
501 pub fn all(&self) -> &[LiveSession] {
503 &self.sessions
504 }
505
506 pub fn door(&self, harness: &str, session_id: &str) -> Option<&'static str> {
509 self.sessions
510 .iter()
511 .find(|session| {
512 session.address.harness == harness && session.address.session_id == session_id
513 })
514 .map(|session| session.door.name())
515 }
516
517 pub fn resolve(&self, to: &str) -> Result<&LiveSession, Unresolved> {
520 let machine = local_machine_name();
521 if let Ok(address) = MailAddress::parse(to) {
522 return self
523 .sessions
524 .iter()
525 .find(|session| session.address == address)
526 .ok_or_else(|| {
527 Unresolved::Stale(format!(
528 "{to} is no longer running. Nothing was sent. Run supercode message list \
529 for the live sessions."
530 ))
531 });
532 }
533 let wanted = match to.split_once('@') {
534 Some((name, at)) if at == machine => name.to_string(),
535 Some((_, at)) => {
536 return Err(Unresolved::Unknown(format!(
537 "Not sent: {to} is on machine {at}, not this one. Nothing was sent."
538 )))
539 }
540 None => to.to_string(),
541 };
542 let short = |session: &LiveSession| {
543 session
544 .name
545 .split('@')
546 .next()
547 .unwrap_or_default()
548 .to_string()
549 };
550 let matching: Vec<&LiveSession> = self
551 .sessions
552 .iter()
553 .filter(|session| short(session) == wanted)
554 .collect();
555 if let [only] = matching.as_slice() {
556 return Ok(only);
557 }
558 let hint = if matching.len() > 1 {
559 format!(
560 " {} sessions are named {wanted}; use its address.",
561 matching.len()
562 )
563 } else {
564 let near: Vec<String> = self
565 .sessions
566 .iter()
567 .filter(|session| {
568 let name = short(session);
569 name.contains(&wanted)
570 || wanted.contains(&name)
571 || name
572 .chars()
573 .zip(wanted.chars())
574 .take_while(|(a, b)| a == b)
575 .count()
576 >= 4
577 })
578 .take(3)
579 .map(|session| {
580 format!(
581 "{} ({}, {})",
582 session.name, session.address.harness, session.status
583 )
584 })
585 .collect();
586 if near.is_empty() {
587 String::new()
588 } else {
589 format!(" Did you mean: {}?", near.join(", "))
590 }
591 };
592 Err(Unresolved::Unknown(format!(
593 "No session named \"{wanted}\" is reachable.{hint} Run supercode message list. Nothing \
594 was sent."
595 )))
596 }
597}
598
599pub fn has_message_tools(pid: u32) -> bool {
603 let Ok(output) = std::process::Command::new("ps")
604 .args(["-A", "-o", "ppid=,command="])
605 .output()
606 else {
607 return false;
608 };
609 String::from_utf8_lossy(&output.stdout).lines().any(|line| {
610 let line = line.trim_start();
611 let Some((ppid, command)) = line.split_once(' ') else {
612 return false;
613 };
614 ppid.parse::<u32>() == Ok(pid) && command.trim_end().ends_with(" message mcp")
615 })
616}
617
618pub fn codex_name(session_id: &str) -> String {
620 format!("codex-{}", session_id.chars().take(8).collect::<String>())
621}
622
623#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct Caller {
626 pub address: MailAddress,
628 pub name: String,
630}
631
632pub const CALLER_UNRESOLVED: &str = "Can't tell which session is running this command, so \
634 replies would have nowhere to go. Nothing was sent. Run it from your agent session's own \
635 shell tool.";
636
637pub fn resolve_caller(homes: &HarnessHomes, pids: &[u32]) -> Result<Caller, String> {
642 crate::slow_log::timed("resolve the caller", || resolve_caller_now(homes, pids))
643}
644
645fn resolve_caller_now(homes: &HarnessHomes, pids: &[u32]) -> Result<Caller, String> {
646 let machine = local_machine_name();
647 let registry = read_registry(®istry_dir(homes));
648 let hosted = crate::runtime_mail::controlled_runtimes();
649 for &pid in pids {
650 if let Some(record) = hosted.iter().find(|record| record.pid == pid) {
651 let short: String = record.source.session_id.chars().take(8).collect();
652 return Ok(Caller {
653 address: MailAddress::new(
654 &machine,
655 &record.source.harness,
656 &record.source.session_id,
657 )
658 .map_err(|error| error.to_string())?,
659 name: format!("{}-{short}@{machine}", record.source.harness),
660 });
661 }
662 if let Some(session) = registry.iter().find(|session| session.pid == pid) {
663 if let Ok(claimed) = std::env::var("CLAUDE_CODE_SESSION_ID") {
664 if !claimed.is_empty() && claimed != session.session_id {
665 return Err(format!(
666 "{CALLER_UNRESOLVED} (CLAUDE_CODE_SESSION_ID names {claimed}, but the Claude \
667 process {pid} above this command is session {})",
668 session.session_id
669 ));
670 }
671 }
672 return Ok(Caller {
673 address: MailAddress::new(&machine, "claude-code", &session.session_id)
674 .map_err(|error| error.to_string())?,
675 name: format!("{}@{machine}", session.name),
676 });
677 }
678 if let Some(thread) = std::env::var("CODEX_THREAD_ID")
682 .ok()
683 .filter(|thread| !thread.is_empty())
684 .filter(|thread| crate::codex_peer::holds_session(pid, thread))
685 {
686 return Ok(Caller {
687 address: MailAddress::new(&machine, "codex", &thread)
688 .map_err(|error| error.to_string())?,
689 name: format!("{}@{machine}", codex_name(&thread)),
690 });
691 }
692 if let Some((session_id, _)) = crate::codex_peer::session_of_process(pid) {
693 return Ok(Caller {
694 address: MailAddress::new(&machine, "codex", &session_id)
695 .map_err(|error| error.to_string())?,
696 name: format!("{}@{machine}", codex_name(&session_id)),
697 });
698 }
699 }
700 #[cfg(windows)]
701 if let Some(session) = msys_cut_claim(®istry, pids) {
702 return Ok(Caller {
703 address: MailAddress::new(&machine, "claude-code", &session.session_id)
704 .map_err(|error| error.to_string())?,
705 name: format!("{}@{machine}", session.name),
706 });
707 }
708 Err(format!(
709 "{CALLER_UNRESOLVED} (looked for this command's processes {pids:?} among {} Claude \
710 sessions in {} and {} hosted runtimes)",
711 registry.len(),
712 registry_dir(homes).display(),
713 hosted.len()
714 ))
715}
716
717#[cfg(windows)]
724fn msys_cut_claim<'a>(
725 registry: &'a [ClaudePeerSession],
726 pids: &[u32],
727) -> Option<&'a ClaudePeerSession> {
728 let table = process_table();
729 let [.., shell, cut] = pids else {
730 return None;
731 };
732 if table.contains_key(cut) {
733 return None;
734 }
735 let name = table.get(shell)?.1.to_ascii_lowercase();
736 if !matches!(name.as_str(), "sh.exe" | "bash.exe" | "dash.exe") {
737 return None;
738 }
739 let claimed = std::env::var("CLAUDE_CODE_SESSION_ID").ok()?;
740 registry
741 .iter()
742 .find(|session| !claimed.is_empty() && session.session_id == claimed)
743}
744
745pub fn process_ancestry() -> Vec<u32> {
747 crate::slow_log::timed("read the process ancestry", || {
748 ancestry_of(std::process::id())
749 })
750}
751
752pub fn ancestry_of(pid: u32) -> Vec<u32> {
754 ancestry_in(pid, &parent_pids())
755}
756
757pub fn parent_table() -> std::collections::HashMap<u32, u32> {
760 parent_pids()
761}
762
763pub fn ancestry_in(pid: u32, parents: &std::collections::HashMap<u32, u32>) -> Vec<u32> {
765 let mut chain = vec![pid];
766 let mut current = pid;
767 while let Some(&parent) = parents.get(¤t) {
768 if parent <= 1 || chain.contains(&parent) {
769 break;
770 }
771 chain.push(parent);
772 current = parent;
773 }
774 chain
775}
776
777#[cfg(not(windows))]
779fn parent_pids() -> std::collections::HashMap<u32, u32> {
780 crate::slow_log::timed("ps parent table", parent_pids_now)
781}
782
783#[cfg(not(windows))]
784fn parent_pids_now() -> std::collections::HashMap<u32, u32> {
785 let Ok(output) = std::process::Command::new("ps")
786 .args(["-axo", "pid=,ppid="])
787 .output()
788 else {
789 return Default::default();
790 };
791 String::from_utf8_lossy(&output.stdout)
792 .lines()
793 .filter_map(|line| {
794 let mut fields = line.split_whitespace();
795 Some((fields.next()?.parse().ok()?, fields.next()?.parse().ok()?))
796 })
797 .collect()
798}
799
800#[cfg(windows)]
802fn parent_pids() -> std::collections::HashMap<u32, u32> {
803 process_table()
804 .into_iter()
805 .map(|(pid, (parent, _))| (pid, parent))
806 .collect()
807}
808
809#[cfg(windows)]
811fn process_table() -> std::collections::HashMap<u32, (u32, String)> {
812 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
813 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
814 CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
815 TH32CS_SNAPPROCESS,
816 };
817
818 let mut parents = std::collections::HashMap::new();
819 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
820 if snapshot == INVALID_HANDLE_VALUE {
821 return parents;
822 }
823 let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
824 entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
825 let mut has_entry = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
826 while has_entry {
827 let length = entry
828 .szExeFile
829 .iter()
830 .position(|&unit| unit == 0)
831 .unwrap_or(entry.szExeFile.len());
832 let name = String::from_utf16_lossy(&entry.szExeFile[..length]);
833 parents.insert(entry.th32ProcessID, (entry.th32ParentProcessID, name));
834 has_entry = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
835 }
836 unsafe {
837 CloseHandle(snapshot);
838 }
839 parents
840}
841
842pub fn codex_hooks_path() -> std::path::PathBuf {
844 std::env::var_os("CODEX_HOME")
845 .map(std::path::PathBuf::from)
846 .or_else(|| {
847 supercode_interchange::user_home()
848 .map(std::path::PathBuf::into_os_string)
849 .map(|home| std::path::PathBuf::from(home).join(".codex"))
850 })
851 .unwrap_or_else(|| std::path::PathBuf::from(".codex"))
852 .join("hooks.json")
853}
854
855pub fn codex_user_hook_installed() -> bool {
858 std::fs::read_to_string(codex_hooks_path())
859 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
860}
861
862pub fn codex_project_hook_installed(cwd: &Path) -> bool {
865 cwd.ancestors().any(|directory| {
866 std::fs::read_to_string(directory.join(".codex").join("hooks.json"))
867 .is_ok_and(|text| text.contains(CODEX_HOOK_ARGUMENTS))
868 })
869}
870
871#[derive(Debug, Clone, PartialEq, Eq)]
873pub enum Delivered {
874 Steered,
876 Started,
878 Native {
881 busy: bool,
883 },
884 Hooked,
886 HookWoken,
889 Queued,
891 Stored,
893 Operator,
895 Already,
897}
898
899#[derive(Debug, Clone, PartialEq, Eq)]
901pub enum Refused {
902 CannotQueueNative,
905 TooLong(usize),
907}
908
909pub const MAX_RELAYED_BYTES: usize = 100_000;
912
913pub async fn deliver(
917 envelope: &Envelope,
918 to: &MailAddress,
919 door: &Door,
920 wake: bool,
921 notify_when_idle: bool,
922) -> Result<Result<Delivered, Refused>, String> {
923 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
924 let mut final_reply = false;
928 if matches!(door, Door::Native(_) | Door::Runtime(_))
931 && mailbox
932 .find(&envelope.id)
933 .map_err(|error| error.to_string())?
934 .is_some_and(|stored| stored.state == crate::mailbox::MailState::Read)
935 && mailbox.delivered_to_recipient(&envelope.id)
936 {
937 return Ok(Ok(Delivered::Already));
938 }
939 let delivered = match door {
940 Door::Runtime(record) => {
941 let mut sent = envelope.clone();
942 let answers = matches!(envelope.reply_via, ReplyVia::Command);
943 if answers {
944 sent.reply_via = ReplyVia::FinalMessage {
945 destination: envelope.from_name.clone(),
946 };
947 }
948 match deliver_to_runtime(record, sent.render(), wake).await? {
949 RuntimeDelivery::Steered => {
950 mailbox
951 .deliver_read(&sent)
952 .map_err(|error| error.to_string())?;
953 final_reply = answers;
954 Delivered::Steered
955 }
956 RuntimeDelivery::Started => {
957 mailbox
958 .deliver_read(&sent)
959 .map_err(|error| error.to_string())?;
960 final_reply = answers;
961 Delivered::Started
962 }
963 RuntimeDelivery::NotWoken => {
964 mailbox
965 .deliver(envelope)
966 .map_err(|error| error.to_string())?;
967 Delivered::Queued
968 }
969 }
970 }
971 Door::Native(session) => {
972 let busy = session.status != Some(ClaudePeerStatus::Idle);
973 if !wake && !busy {
974 return Ok(Err(Refused::CannotQueueNative));
975 }
976 let mut envelope = envelope.clone();
980 if envelope.reply_via == ReplyVia::Command && has_message_tools(session.pid) {
981 envelope.reply_via = ReplyVia::Tool;
982 }
983 let envelope = &envelope;
984 let text = envelope.render();
985 if text.len() > MAX_RELAYED_BYTES {
986 return Ok(Err(Refused::TooLong(text.len())));
987 }
988 let filed_here = mailbox
991 .find(&envelope.id)
992 .map_err(|error| error.to_string())?
993 .is_none();
994 let filed = mailbox
995 .deliver_read(envelope)
996 .map_err(|error| error.to_string())?;
997 match send_through_relay(
998 &envelope.from,
999 &envelope.from_name,
1000 &session.name,
1001 text,
1002 &envelope.id,
1003 )
1004 .await
1005 {
1006 RelayReceipt::Delivered { .. } => Delivered::Native { busy },
1007 RelayReceipt::Failed { detail } => {
1008 if filed_here {
1009 std::fs::remove_file(&filed).ok();
1010 }
1011 return Err(detail);
1012 }
1013 }
1014 }
1015 Door::Hook { .. } => {
1016 mailbox
1017 .deliver(envelope)
1018 .map_err(|error| error.to_string())?;
1019 if wake {
1020 mailbox
1021 .request_wake(&envelope.id)
1022 .map_err(|error| error.to_string())?;
1023 }
1024 Delivered::Hooked
1025 }
1026 Door::Stored => {
1027 mailbox
1028 .deliver(envelope)
1029 .map_err(|error| error.to_string())?;
1030 Delivered::Stored
1031 }
1032 Door::Operator => {
1033 mailbox
1034 .deliver(envelope)
1035 .map_err(|error| error.to_string())?;
1036 Delivered::Operator
1037 }
1038 };
1039 if matches!(
1041 delivered,
1042 Delivered::Steered | Delivered::Started | Delivered::Native { .. }
1043 ) {
1044 mailbox
1045 .record_claim(
1046 &envelope.id,
1047 &crate::mailbox::Claim::new("door", None, true),
1048 )
1049 .ok();
1050 }
1051 let notice = notify_when_idle && !matches!(door, Door::Operator);
1052 if notice || final_reply {
1053 let mut subscription = IdleSubscription::new(envelope.id.clone(), envelope.from.clone());
1054 subscription.notice = notice;
1055 subscription.final_reply = final_reply;
1056 mailbox
1057 .subscribe_idle(&subscription)
1058 .map_err(|error| error.to_string())?;
1059 if let Ok(program) = supercode_program() {
1061 crate::claude_relay::ensure_machine_daemon(&program)
1062 .await
1063 .ok();
1064 }
1065 }
1066 Ok(Ok(delivered))
1067}
1068
1069#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071pub enum UserTurn {
1072 Steered,
1074 Started,
1076 Typed,
1078 Waiting,
1082}
1083
1084impl UserTurn {
1085 pub const fn as_str(self) -> &'static str {
1087 match self {
1088 Self::Steered => "steered",
1089 Self::Started => "started",
1090 Self::Typed => "typed",
1091 Self::Waiting => "waiting",
1092 }
1093 }
1094}
1095
1096pub fn pane_prompt(pane: &str) -> Option<Vec<String>> {
1101 let screen = pane_screen(pane)?;
1102 let lines: Vec<&str> = screen.lines().map(str::trim_end).collect();
1103 let is_option = |line: &str| {
1104 let line = line.trim_start();
1105 let rest = line
1107 .strip_prefix('❯')
1108 .or_else(|| line.strip_prefix('›'))
1109 .or_else(|| line.strip_prefix('>'))
1110 .unwrap_or(line)
1111 .trim_start();
1112 let digits = rest.chars().take_while(char::is_ascii_digit).count();
1113 digits > 0 && matches!(rest[digits..].chars().next(), Some('.' | ')'))
1114 };
1115 let selected = lines.iter().position(|line| {
1116 let line = line.trim_start();
1117 (line.starts_with('❯') || line.starts_with('›') || line.starts_with('>')) && is_option(line)
1118 })?;
1119 let block_start = |end: usize| {
1121 lines[..end]
1122 .iter()
1123 .rposition(|line| line.trim().is_empty())
1124 .map_or(0, |blank| blank + 1)
1125 };
1126 let options = block_start(selected);
1127 let above = lines[..options]
1128 .iter()
1129 .rposition(|line| !line.trim().is_empty())
1130 .map(|last| block_start(last));
1131 let start = above.unwrap_or(options);
1132 let end = lines[selected..]
1133 .iter()
1134 .position(|line| line.trim().is_empty())
1135 .map_or(lines.len(), |blank| selected + blank);
1136 Some(
1137 lines[start..end]
1138 .iter()
1139 .map(|line| line.trim().to_string())
1140 .filter(|line| !line.is_empty())
1141 .take(20)
1142 .collect(),
1143 )
1144}
1145
1146fn live_daemon_panes<'a>(
1150 wanted: impl Iterator<Item = &'a String>,
1151) -> Option<std::collections::HashMap<String, bool>> {
1152 let wanted: Vec<&str> = wanted.map(String::as_str).collect();
1153 crate::slow_log::timed("teams panes ls --presence-only", || {
1154 live_daemon_panes_now(&wanted.join(","))
1155 })
1156}
1157
1158fn live_daemon_panes_now(wanted: &str) -> Option<std::collections::HashMap<String, bool>> {
1159 let entry = crate::teams_entry().ok()?;
1160 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1161 .ok()
1162 .filter(|value| !value.trim().is_empty())
1163 .unwrap_or_else(|| "node".into());
1164 let output = std::process::Command::new(node)
1165 .arg(entry)
1166 .args(["panes", "ls", "--presence-only", "--panes", wanted])
1167 .stdin(std::process::Stdio::null())
1168 .output()
1169 .ok()?;
1170 if !output.status.success() {
1171 return None;
1172 }
1173 let rows: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
1174 rows.iter()
1175 .map(|row| {
1176 Some((
1177 row["pane"].as_str()?.to_owned(),
1178 row["idleComposer"].as_bool()?,
1179 ))
1180 })
1181 .collect()
1182}
1183
1184fn pane_screen(pane: &str) -> Option<String> {
1187 #[cfg(unix)]
1188 {
1189 let output = std::process::Command::new("tmux")
1190 .args(["capture-pane", "-p", "-t", &format!("{pane}:")])
1191 .output()
1192 .ok()?;
1193 output
1194 .status
1195 .success()
1196 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1197 }
1198 #[cfg(not(unix))]
1199 {
1200 let entry = crate::teams_entry().ok()?;
1201 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1202 .ok()
1203 .filter(|value| !value.trim().is_empty())
1204 .unwrap_or_else(|| "node".into());
1205 let output = std::process::Command::new(node)
1206 .arg(entry)
1207 .args(["panes", "capture", pane, "--lines", "60"])
1208 .stdin(std::process::Stdio::null())
1209 .output()
1210 .ok()?;
1211 output
1212 .status
1213 .success()
1214 .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1215 }
1216}
1217
1218pub fn daemon_pane(session: &LiveSession) -> Option<String> {
1220 let name = session.tmux.as_deref()?.split(':').next()?;
1221 let rest = name.strip_prefix("p_")?;
1222 (!rest.is_empty()
1223 && rest
1224 .chars()
1225 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1226 .then(|| name.to_string())
1227}
1228
1229pub fn has_user_door(homes: &HarnessHomes, to: &MailAddress) -> bool {
1232 LiveSessions::read(homes)
1233 .sessions
1234 .into_iter()
1235 .find(|session| &session.address == to)
1236 .is_some_and(|session| {
1237 matches!(session.door, Door::Runtime(_)) || daemon_pane(&session).is_some()
1238 })
1239}
1240
1241pub async fn deliver_user_turn(
1247 homes: &HarnessHomes,
1248 envelope: &Envelope,
1249 to: &MailAddress,
1250) -> Result<UserTurn, String> {
1251 let session = LiveSessions::read(homes)
1252 .sessions
1253 .into_iter()
1254 .find(|session| &session.address == to)
1255 .ok_or_else(|| format!("{to} is not running; nothing was sent"))?;
1256 let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
1257 if let Door::Runtime(record) = &session.door {
1258 let delivered = deliver_to_runtime(record, envelope.body.clone(), true).await?;
1259 mailbox
1260 .deliver_read(envelope)
1261 .map_err(|error| error.to_string())?;
1262 return Ok(match delivered {
1263 RuntimeDelivery::Steered => UserTurn::Steered,
1264 _ => UserTurn::Started,
1265 });
1266 }
1267 let Some(pane) = daemon_pane(&session) else {
1268 let name = session.name.split('@').next().unwrap_or(&session.name);
1269 return Err(format!(
1270 "{name} runs outside supercode, where nothing can speak as its user. Open it in a \
1271 pane with `supercode open {name}`; nothing was sent."
1272 ));
1273 };
1274 mailbox
1275 .deliver(envelope)
1276 .map_err(|error| error.to_string())?;
1277 let typed = type_user_turns(&mailbox, &pane).await;
1278 Ok(if typed.contains(&envelope.id) {
1279 UserTurn::Typed
1280 } else {
1281 UserTurn::Waiting
1282 })
1283}
1284
1285pub async fn type_user_turns(mailbox: &Mailbox, pane: &str) -> Vec<String> {
1289 let mut typed = Vec::new();
1290 for waiting in mailbox.user_turns().unwrap_or_default() {
1291 let Ok(Some(stored)) = mailbox.claim_user_turn(&waiting) else {
1295 break;
1296 };
1297 match submit_mail_batch(pane, &stored.envelope.body, &[stored.envelope.id.clone()]).await {
1298 Ok(true) => {
1299 mailbox.acknowledge(&stored).ok();
1300 typed.push(stored.envelope.id.clone());
1301 }
1302 Ok(false) => {
1303 mailbox.release(&stored).ok();
1304 break;
1305 }
1306 Err(error) => {
1307 mailbox.release(&stored).ok();
1308 eprintln!(
1309 "supercode: the user's turn {} for {} waits: {error}",
1310 stored.envelope.id,
1311 mailbox.address()
1312 );
1313 break;
1314 }
1315 }
1316 }
1317 typed
1318}
1319
1320pub(crate) async fn wake_hook_mailbox(
1323 mailbox: &Mailbox,
1324 pane: &str,
1325 ids: &[String],
1326) -> Result<bool, String> {
1327 let messages = ids
1328 .iter()
1329 .map(|id| mailbox.find(id))
1330 .collect::<Result<Vec<_>, _>>()
1331 .map_err(|error| error.to_string())?;
1332 let messages: Vec<_> = messages.into_iter().flatten().collect();
1333 if messages.is_empty() {
1334 return Ok(false);
1335 }
1336 let mut delivered = true;
1337 for message in messages {
1338 delivered &= submit_mail_batch(
1339 pane,
1340 &message.envelope.render(),
1341 &[message.envelope.id.clone()],
1342 )
1343 .await?;
1344 }
1345 Ok(delivered)
1346}
1347
1348async fn submit_mail_batch(pane: &str, text: &str, ids: &[String]) -> Result<bool, String> {
1349 let entry = crate::teams_entry().map_err(|error| error.to_string())?;
1350 let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1351 .ok()
1352 .filter(|value| !value.trim().is_empty())
1353 .unwrap_or_else(|| "node".into());
1354 let output = tokio::process::Command::new(node)
1355 .arg(entry)
1356 .args([
1357 "input",
1358 pane,
1359 text,
1360 "--when-composer-empty",
1361 "--message-ids",
1362 &ids.join(","),
1363 ])
1364 .env_remove("SUPERCODE_CALLER")
1367 .stdin(std::process::Stdio::null())
1368 .output()
1369 .await
1370 .map_err(|error| error.to_string())?;
1371 if !output.status.success() {
1372 return Err(crate::mailbox::error_line(&String::from_utf8_lossy(
1373 &output.stderr,
1374 )));
1375 }
1376 let answer: serde_json::Value =
1377 serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
1378 Ok(answer["delivered"] == true)
1379}