Skip to main content

supercode_harness/
mail_route.rs

1//! One route for every message: which door reaches a receiver, and delivery
2//! through it.
3//!
4//! Every caller that delivers mail — `supercode message send`,
5//! `harness.v1.sessions.message`, and idle notices — goes through
6//! [`door_for`] and [`deliver`], so the choice of door is made in one place.
7//! The doors, best first:
8//!
9//! 1. **runtime** — a session supercode controls (a hosted runtime of any
10//!    harness): its own `steer`/`send_input` ([`crate::runtime_mail`]). This
11//!    is the default tier.
12//! 2. **native** — a Claude Code session supercode does not control: a Claude
13//!    relay sends with Claude's own `SendMessage` ([`crate::claude_relay`]).
14//! 3. **hook** — a Codex session supercode does not control, with
15//!    supercode's hooks in its hooks file: filed in its mailbox, and the hook
16//!    points the session at it.
17//! 4. **stored** — no door: filed in its mailbox, seen only when the session
18//!    reads it.
19//!
20//! Tiers 2–4 are the degradation tier, for sessions supercode does not
21//! control. An operator (a board, a script) is not a session: its mail is
22//! filed in its mailbox and read with `sessions.inbox`.
23
24use 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
35/// Arguments a Codex hook entry carries, which is how its presence is found.
36pub const CODEX_HOOK_ARGUMENTS: &str = "message hook codex";
37
38/// The door that reaches one receiver.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum Door {
41    /// A session supercode controls.
42    Runtime(Box<LiveRuntimeRecord>),
43    /// A Claude Code session supercode does not control.
44    Native(Box<ClaudePeerSession>),
45    /// A Codex session supercode does not control, with supercode's hooks:
46    /// they show it its mailbox at its next tool call or when its turn ends.
47    /// An idle one in a daemon pane is woken through its pane.
48    Hook {
49        /// The daemon pane it runs in.
50        pane: Option<String>,
51        /// Whether its last turn has ended.
52        idle: bool,
53    },
54    /// A running session with no door.
55    Stored,
56    /// An operator's mailbox.
57    Operator,
58}
59
60impl Door {
61    /// Stable name of the door, as `message list` and receipts show it.
62    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/// Why no door reaches an address on this machine.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum NoDoor {
76    /// The address names another machine.
77    OtherMachine(String),
78    /// No running session has that address.
79    NotRunning,
80}
81
82/// The door that reaches `to` on this machine.
83pub 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/// One running session on this machine, as the router reaches it.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct LiveSession {
101    /// Its address.
102    pub address: MailAddress,
103    /// The name an agent knows it by (`volter-2c@machine`).
104    pub name: String,
105    /// Its status as its harness reports it (`busy`, `idle`, `hosted`, …).
106    pub status: String,
107    /// The door that reaches it.
108    pub door: Door,
109    /// The process running it, when the harness names one (a hosted
110    /// runtime's host, a Claude session's own process).
111    pub pid: Option<u32>,
112    /// Its working directory.
113    pub cwd: Option<std::path::PathBuf>,
114    /// The tmux session and pane it runs in, when Claude records one.
115    pub tmux: Option<String>,
116    /// Its transcript, when the harness names the file (a Codex rollout).
117    pub transcript: Option<std::path::PathBuf>,
118}
119
120impl LiveSession {
121    /// When its transcript last recorded a message (a prompt, a reply, a tool
122    /// call or result), in epoch milliseconds: what the session last did,
123    /// read from the harness's own records. A queued or injected notice is
124    /// not a message, so this is not the transcript's mtime.
125    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    /// What a session waiting on its user is asking, read from its transcript: the newest tool call with no result
145    /// yet. An `AskUserQuestion` gives its questions, headers and option labels; any other tool its name and a
146    /// shortened input. `None` when the session is not waiting or its transcript shows nothing pending.
147    pub fn pending_request(&self, homes: &HarnessHomes) -> Option<serde_json::Value> {
148        if self.status != "waiting" {
149            return None;
150        }
151        // A Codex prompt writes nothing to its rollout: what it asks is read off its pane.
152        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
169/// The newest tool call in a Claude transcript that has no result yet, as [`LiveSession::pending_request`] shows it.
170pub 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
238/// The newest message record's timestamp in a Claude or Codex transcript.
239fn last_message_at_ms(path: &Path, harness: &str) -> Option<u64> {
240    use std::io::{Read, Seek, SeekFrom};
241    // A tool result can be large; widen the window once before giving up.
242    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(); // the first line may begin mid-record
253        }
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/// Every running session on this machine with its door, read once: what
284/// `message list` shows, what names resolve against, and what discovery
285/// projects onto each discovered session as `delivery`.
286#[derive(Debug, Default)]
287pub struct LiveSessions {
288    sessions: Vec<LiveSession>,
289}
290
291/// Why a receiver named by an agent was not found.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub enum Unresolved {
294    /// The address names a session that is not running.
295    Stale(String),
296    /// No running session has that name (the text suggests near names).
297    Unknown(String),
298}
299
300impl LiveSessions {
301    /// Read every running session now. Sessions supercode controls come
302    /// first, and a degraded-tier row for the same session is dropped: the
303    /// runtime is the default tier.
304    pub fn read(homes: &HarnessHomes) -> Self {
305        let machine = local_machine_name();
306        let registry = read_registry(&registry_dir(homes));
307        // A hosted Claude session is also in Claude's registry, which knows
308        // its name; a relay is not a session and is not listed.
309        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            // A Codex turn waiting on an approval or a choice writes nothing to its rollout, which
391            // reads that turn as working; in a daemon pane its screen shows the prompt.
392            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                // The daemon pane it runs in, as a Claude session's tmux names its own.
416                tmux: pane,
417                transcript: Some(path),
418                door,
419            });
420        }
421        Self { sessions }
422    }
423
424    /// Every running session.
425    pub fn all(&self) -> &[LiveSession] {
426        &self.sessions
427    }
428
429    /// The door that reaches `harness`'s session `session_id`, when it is
430    /// running.
431    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    /// The running session an agent means by `to`: an address, `name@machine`
441    /// on this machine, or a name unique here.
442    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
522/// Whether the session process `pid` has supercode's messaging tools: a
523/// harness starts each MCP server as a child of the session, so the tools are
524/// loaded exactly when a live `supercode message mcp` is one of its children.
525pub 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
541/// Display name of a Codex conversation: `codex-` and the start of its id.
542pub fn codex_name(session_id: &str) -> String {
543    format!("codex-{}", session_id.chars().take(8).collect::<String>())
544}
545
546/// The session a process belongs to: the sender a message is from.
547#[derive(Debug, Clone, PartialEq, Eq)]
548pub struct Caller {
549    /// Its address, where replies go.
550    pub address: MailAddress,
551    /// The name it is known by.
552    pub name: String,
553}
554
555/// Why no session could be found behind a process.
556pub 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
560/// The session behind a process, from its ancestry `pids` (nearest first):
561/// the first that owns a hosted runtime, a Claude session or a Codex
562/// conversation. Never declared by the caller; an environment id only
563/// corroborates, and a mismatch refuses.
564pub fn resolve_caller(homes: &HarnessHomes, pids: &[u32]) -> Result<Caller, String> {
565    let machine = local_machine_name();
566    let registry = read_registry(&registry_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        // Codex names the thread a command runs for (`CODEX_THREAD_ID`); the
598        // claim stands when this process holds that thread's rollout, as
599        // Codex's shared app-server daemon does for every thread it runs.
600        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(&registry, 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/// Git Bash (MSYS) runs a command in a forked process that hands over to it. When the command is
637/// itself an MSYS program (a `sh` script, as npm's command shims are), the forked process exits once
638/// it has handed over, so the Windows parent chain is cut just above that shell and never reaches
639/// the Claude session running it. When the ancestry ends at exactly such a cut (its last pid is gone,
640/// the one before it is an MSYS shell), the session Claude names in `CLAUDE_CODE_SESSION_ID` stands
641/// if it is a live Claude session on this machine.
642#[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
664/// This process's ancestry, nearest first (itself included).
665pub fn process_ancestry() -> Vec<u32> {
666    ancestry_of(std::process::id())
667}
668
669/// A process's ancestry, nearest first (itself included).
670pub 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(&current) {
675        if parent <= 1 || chain.contains(&parent) {
676            break;
677        }
678        chain.push(parent);
679        current = parent;
680    }
681    chain
682}
683
684/// Every process's parent: pid → parent pid, from `ps`.
685#[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/// Every process's parent: pid → parent pid, from a process snapshot.
703#[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/// Every process: pid → (parent pid, executable file name), from a process snapshot.
712#[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
744/// Codex's user-level hooks file.
745pub 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
757/// Whether supercode's mail hook is in Codex's user hooks file. (Codex runs
758/// it only once its user has trusted it.)
759pub 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
764/// Whether supercode's mail hook is in a project's Codex hooks file, in the
765/// session's directory or one above it.
766pub 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/// How a delivery ended.
774#[derive(Debug, Clone, PartialEq, Eq)]
775pub enum Delivered {
776    /// The receiver has it now: steered into a running turn.
777    Steered,
778    /// The receiver has it now: it started a turn in an idle session.
779    Started,
780    /// Claude reported it in the receiver's inbox; `busy` says whether the
781    /// receiver reads it at its next tool call (true) or it starts a turn.
782    Native {
783        /// True when the receiver was in a turn.
784        busy: bool,
785    },
786    /// Filed in the receiver's mailbox, which a hook points it at.
787    Hooked,
788    /// Filed in an idle receiver's mailbox, and its pane told to read it: a
789    /// turn has started, in which its hook shows it the message.
790    HookWoken,
791    /// Filed in the receiver's mailbox, waiting to be read.
792    Queued,
793    /// Filed with no door to show it.
794    Stored,
795    /// Filed in an operator's mailbox.
796    Operator,
797}
798
799/// A delivery refused before anything was sent.
800#[derive(Debug, Clone, PartialEq, Eq)]
801pub enum Refused {
802    /// `--queue` cannot hold a message for an idle Claude session: Claude
803    /// starts a turn for every message it delivers.
804    CannotQueueNative,
805    /// The relayed text would not fit one relay turn.
806    TooLong(usize),
807}
808
809/// Largest message relayed into a Claude session. The relay copies it into a
810/// model turn byte for byte, so it must fit comfortably in one.
811pub const MAX_RELAYED_BYTES: usize = 100_000;
812
813/// Deliver `envelope` to `to` through `door`. With `wake` false an idle
814/// receiver is not started. With `notify_when_idle` the sender gets one idle
815/// notice after the receiver's next turn ends.
816pub 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    // A runtime answers with its turn's final message, which the watcher
825    // sends back: not every runtime can run a command (a hosted agent may
826    // have no shell), and each can end a turn.
827    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            // The same envelope every door delivers; Claude wraps it in its
866            // own, which names the relay. Its reply line names the door this
867            // session really has: its tools when it runs supercode's MCP server.
868            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            // its hooks run only inside a turn: an idle session is told, in its pane, to read its mailbox; the
900            // message itself reaches it as mail, never as its user's words
901            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        // The watcher that settles it runs beside the machine daemon.
934        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/// How the user's own turn reached its session.
944#[derive(Debug, Clone, Copy, PartialEq, Eq)]
945pub enum UserTurn {
946    /// A hosted runtime was in a turn; the words were steered into it.
947    Steered,
948    /// A hosted runtime was idle; the words started a turn.
949    Started,
950    /// Typed into the session's pane as its own submitted turn.
951    Typed,
952    /// The pane's composer holds a draft, or its program is not at its
953    /// composer: the turn waits in the session's mailbox and is typed once
954    /// the composer is empty.
955    Waiting,
956}
957
958impl UserTurn {
959    /// Stable wire spelling.
960    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
970/// The daemon pane a session runs in, when it runs in one.
971/// The choice or permission prompt a daemon pane's screen shows, as its lines (the question above
972/// it and its options), or `None`. The terminal substrate's own check (`classifyAttentionSignal`):
973/// a selector (❯ or ›) on a numbered option is a prompt blocked on an answer; the idle input line
974/// has no number after its selector.
975pub 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        // The prompt: the options' block of non-empty lines and the block above it (the question).
1002        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
1044/// Deliver the user's own turn to `to` through the one door that carries the
1045/// user's authority: a hosted runtime's own input, or the session's pane.
1046/// A session with neither (running outside supercode) is refused, never
1047/// reached as a peer instead. Callers hold the owner's authority already:
1048/// this is reached only through owner doors (the machine's own harness.v1).
1049pub 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
1088/// Type the user's waiting turns into `pane`, oldest first, each only when
1089/// the pane's composer is empty. Stops at the first that has to wait.
1090/// Returns the ids typed.
1091pub 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        // Claimed before it is typed: the machine's watcher and a direct delivery both type waiting turns, and
1095        // two typers filling one composer sent a turn's text twice in one prompt. A turn another typer holds is
1096        // that typer's, and so is every turn after it.
1097        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
1123/// What an idle hooked session's pane is given, to start the turn in which
1124/// its hook shows it the waiting mail.
1125pub const CODEX_WAKE: &str =
1126    "You have unread supercode messages. Read them with: supercode message inbox";
1127
1128/// The daemon's pane door: type `text` as a submitted turn if the composer
1129/// is empty now. `Ok(false)` when it holds a draft or is not on screen.
1130async 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        // A message is the session's own door, not a session acting on a pane: the pane doors read that from this
1140        // process's place under the daemon's `message watch`, not from anything named here.
1141        .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}