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" || 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/// 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 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        // Claude Code writes a question's tool call to its transcript only once it is answered
170        // (2.1.280): until then, what it asks is on its pane.
171        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
178/// The newest tool call in a Claude transcript that has no result yet, as [`LiveSession::pending_request`] shows it.
179pub 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
247/// The newest message record's timestamp in a Claude or Codex transcript.
248fn last_message_at_ms(path: &Path, harness: &str) -> Option<u64> {
249    use std::io::{Read, Seek, SeekFrom};
250    // A tool result can be large; widen the window once before giving up.
251    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(); // the first line may begin mid-record
262        }
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/// Every running session on this machine with its door, read once: what
293/// `message list` shows, what names resolve against, and what discovery
294/// projects onto each discovered session as `delivery`.
295#[derive(Debug, Default)]
296pub struct LiveSessions {
297    sessions: Vec<LiveSession>,
298}
299
300/// Why a receiver named by an agent was not found.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub enum Unresolved {
303    /// The address names a session that is not running.
304    Stale(String),
305    /// No running session has that name (the text suggests near names).
306    Unknown(String),
307}
308
309impl LiveSessions {
310    /// Read every running session now. Sessions supercode controls come
311    /// first, and a degraded-tier row for the same session is dropped: the
312    /// runtime is the default tier.
313    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(&registry_dir(homes));
320        // A hosted Claude session is also in Claude's registry, which knows
321        // its name; a relay is not a session and is not listed.
322        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        // A runtime supercode hosts says whether a turn runs: its label is that, not only its door.
330        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                    // `hosted` only when its runtime did not answer: no label rather than a false one.
351                    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        // A shared native app-server can retain a rollout after its terminal has gone.
398        // Pane absence rules out the pane, not the native writer holding the thread.
399        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            // A Codex turn waiting on an approval or a choice writes nothing to its rollout, which
423            // reads that turn as working; in a daemon pane its screen shows the prompt.
424            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                // The daemon pane it runs in, as a Claude session's tmux names its own.
457                tmux: pane,
458                transcript: Some(path),
459                door,
460            });
461        }
462        // A conversation resumed in a daemon pane and idle since holds no rollout open; its pane and the
463        // process's own `resume <id>` still name it, so it is reachable as an idle session there.
464        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    /// Every running session.
502    pub fn all(&self) -> &[LiveSession] {
503        &self.sessions
504    }
505
506    /// The door that reaches `harness`'s session `session_id`, when it is
507    /// running.
508    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    /// The running session an agent means by `to`: an address, `name@machine`
518    /// on this machine, or a name unique here.
519    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
599/// Whether the session process `pid` has supercode's messaging tools: a
600/// harness starts each MCP server as a child of the session, so the tools are
601/// loaded exactly when a live `supercode message mcp` is one of its children.
602pub 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
618/// Display name of a Codex conversation: `codex-` and the start of its id.
619pub fn codex_name(session_id: &str) -> String {
620    format!("codex-{}", session_id.chars().take(8).collect::<String>())
621}
622
623/// The session a process belongs to: the sender a message is from.
624#[derive(Debug, Clone, PartialEq, Eq)]
625pub struct Caller {
626    /// Its address, where replies go.
627    pub address: MailAddress,
628    /// The name it is known by.
629    pub name: String,
630}
631
632/// Why no session could be found behind a process.
633pub 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
637/// The session behind a process, from its ancestry `pids` (nearest first):
638/// the first that owns a hosted runtime, a Claude session or a Codex
639/// conversation. Never declared by the caller; an environment id only
640/// corroborates, and a mismatch refuses.
641pub 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(&registry_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        // Codex names the thread a command runs for (`CODEX_THREAD_ID`); the
679        // claim stands when this process holds that thread's rollout, as
680        // Codex's shared app-server daemon does for every thread it runs.
681        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(&registry, 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/// Git Bash (MSYS) runs a command in a forked process that hands over to it. When the command is
718/// itself an MSYS program (a `sh` script, as npm's command shims are), the forked process exits once
719/// it has handed over, so the Windows parent chain is cut just above that shell and never reaches
720/// the Claude session running it. When the ancestry ends at exactly such a cut (its last pid is gone,
721/// the one before it is an MSYS shell), the session Claude names in `CLAUDE_CODE_SESSION_ID` stands
722/// if it is a live Claude session on this machine.
723#[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
745/// This process's ancestry, nearest first (itself included).
746pub fn process_ancestry() -> Vec<u32> {
747    crate::slow_log::timed("read the process ancestry", || {
748        ancestry_of(std::process::id())
749    })
750}
751
752/// A process's ancestry, nearest first (itself included).
753pub fn ancestry_of(pid: u32) -> Vec<u32> {
754    ancestry_in(pid, &parent_pids())
755}
756
757/// Every process's parent now (pid → parent pid): one read for a caller that walks several
758/// processes' ancestries ([`ancestry_in`]) instead of reading the table once per process.
759pub fn parent_table() -> std::collections::HashMap<u32, u32> {
760    parent_pids()
761}
762
763/// `pid`'s ancestry, nearest first (itself included), in a table [`parent_table`] read.
764pub 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(&current) {
768        if parent <= 1 || chain.contains(&parent) {
769            break;
770        }
771        chain.push(parent);
772        current = parent;
773    }
774    chain
775}
776
777/// Every process's parent: pid → parent pid, from `ps`.
778#[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/// Every process's parent: pid → parent pid, from a process snapshot.
801#[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/// Every process: pid → (parent pid, executable file name), from a process snapshot.
810#[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
842/// Codex's user-level hooks file.
843pub 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
855/// Whether supercode's mail hook is in Codex's user hooks file. (Codex runs
856/// it only once its user has trusted it.)
857pub 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
862/// Whether supercode's mail hook is in a project's Codex hooks file, in the
863/// session's directory or one above it.
864pub 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/// How a delivery ended.
872#[derive(Debug, Clone, PartialEq, Eq)]
873pub enum Delivered {
874    /// The receiver has it now: steered into a running turn.
875    Steered,
876    /// The receiver has it now: it started a turn in an idle session.
877    Started,
878    /// Claude reported it in the receiver's inbox; `busy` says whether the
879    /// receiver reads it at its next tool call (true) or it starts a turn.
880    Native {
881        /// True when the receiver was in a turn.
882        busy: bool,
883    },
884    /// Filed in the receiver's mailbox, which a hook points it at.
885    Hooked,
886    /// Filed in an idle receiver's mailbox, and its pane told to read it: a
887    /// turn has started, in which its hook shows it the message.
888    HookWoken,
889    /// Filed in the receiver's mailbox, waiting to be read.
890    Queued,
891    /// Filed with no door to show it.
892    Stored,
893    /// Filed in an operator's mailbox.
894    Operator,
895}
896
897/// A delivery refused before anything was sent.
898#[derive(Debug, Clone, PartialEq, Eq)]
899pub enum Refused {
900    /// `--queue` cannot hold a message for an idle Claude session: Claude
901    /// starts a turn for every message it delivers.
902    CannotQueueNative,
903    /// The relayed text would not fit one relay turn.
904    TooLong(usize),
905}
906
907/// Largest message relayed into a Claude session. The relay copies it into a
908/// model turn byte for byte, so it must fit comfortably in one.
909pub const MAX_RELAYED_BYTES: usize = 100_000;
910
911/// Deliver `envelope` to `to` through `door`. With `wake` false an idle
912/// receiver is not started. With `notify_when_idle` the sender gets one idle
913/// notice after the receiver's next turn ends.
914pub async fn deliver(
915    envelope: &Envelope,
916    to: &MailAddress,
917    door: &Door,
918    wake: bool,
919    notify_when_idle: bool,
920) -> Result<Result<Delivered, Refused>, String> {
921    let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
922    // A runtime answers with its turn's final message, which the watcher
923    // sends back: not every runtime can run a command (a hosted agent may
924    // have no shell), and each can end a turn.
925    let mut final_reply = false;
926    let delivered = match door {
927        Door::Runtime(record) => {
928            let mut sent = envelope.clone();
929            let answers = matches!(envelope.reply_via, ReplyVia::Command);
930            if answers {
931                sent.reply_via = ReplyVia::FinalMessage {
932                    destination: envelope.from_name.clone(),
933                };
934            }
935            match deliver_to_runtime(record, sent.render(), wake).await? {
936                RuntimeDelivery::Steered => {
937                    mailbox
938                        .deliver_read(&sent)
939                        .map_err(|error| error.to_string())?;
940                    final_reply = answers;
941                    Delivered::Steered
942                }
943                RuntimeDelivery::Started => {
944                    mailbox
945                        .deliver_read(&sent)
946                        .map_err(|error| error.to_string())?;
947                    final_reply = answers;
948                    Delivered::Started
949                }
950                RuntimeDelivery::NotWoken => {
951                    mailbox
952                        .deliver(envelope)
953                        .map_err(|error| error.to_string())?;
954                    Delivered::Queued
955                }
956            }
957        }
958        Door::Native(session) => {
959            let busy = session.status != Some(ClaudePeerStatus::Idle);
960            if !wake && !busy {
961                return Ok(Err(Refused::CannotQueueNative));
962            }
963            // The same envelope every door delivers; Claude wraps it in its
964            // own, which names the relay. Its reply line names the door this
965            // session really has: its tools when it runs supercode's MCP server.
966            let mut envelope = envelope.clone();
967            if envelope.reply_via == ReplyVia::Command && has_message_tools(session.pid) {
968                envelope.reply_via = ReplyVia::Tool;
969            }
970            let envelope = &envelope;
971            let text = envelope.render();
972            if text.len() > MAX_RELAYED_BYTES {
973                return Ok(Err(Refused::TooLong(text.len())));
974            }
975            // Filed before it is handed over: a session that reads its mailbox on its first tool call (to reply by
976            // the message's id) finds it there. A relay that fails takes back the copy this delivery filed.
977            let filed_here = mailbox
978                .find(&envelope.id)
979                .map_err(|error| error.to_string())?
980                .is_none();
981            let filed = mailbox
982                .deliver_read(envelope)
983                .map_err(|error| error.to_string())?;
984            match send_through_relay(
985                &envelope.from,
986                &envelope.from_name,
987                &session.name,
988                text,
989                &envelope.id,
990            )
991            .await
992            {
993                RelayReceipt::Delivered { .. } => Delivered::Native { busy },
994                RelayReceipt::Failed { detail } => {
995                    if filed_here {
996                        std::fs::remove_file(&filed).ok();
997                    }
998                    return Err(detail);
999                }
1000            }
1001        }
1002        Door::Hook { .. } => {
1003            mailbox
1004                .deliver(envelope)
1005                .map_err(|error| error.to_string())?;
1006            if wake {
1007                mailbox
1008                    .request_wake(&envelope.id)
1009                    .map_err(|error| error.to_string())?;
1010            }
1011            Delivered::Hooked
1012        }
1013        Door::Stored => {
1014            mailbox
1015                .deliver(envelope)
1016                .map_err(|error| error.to_string())?;
1017            Delivered::Stored
1018        }
1019        Door::Operator => {
1020            mailbox
1021                .deliver(envelope)
1022                .map_err(|error| error.to_string())?;
1023            Delivered::Operator
1024        }
1025    };
1026    let notice = notify_when_idle && !matches!(door, Door::Operator);
1027    if notice || final_reply {
1028        let mut subscription = IdleSubscription::new(envelope.id.clone(), envelope.from.clone());
1029        subscription.notice = notice;
1030        subscription.final_reply = final_reply;
1031        mailbox
1032            .subscribe_idle(&subscription)
1033            .map_err(|error| error.to_string())?;
1034        // The watcher that settles it runs beside the machine daemon.
1035        if let Ok(program) = supercode_program() {
1036            crate::claude_relay::ensure_machine_daemon(&program)
1037                .await
1038                .ok();
1039        }
1040    }
1041    Ok(Ok(delivered))
1042}
1043
1044/// How the user's own turn reached its session.
1045#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1046pub enum UserTurn {
1047    /// A hosted runtime was in a turn; the words were steered into it.
1048    Steered,
1049    /// A hosted runtime was idle; the words started a turn.
1050    Started,
1051    /// Typed into the session's pane as its own submitted turn.
1052    Typed,
1053    /// The pane's composer holds a draft, or its program is not at its
1054    /// composer: the turn waits in the session's mailbox and is typed once
1055    /// the composer is empty.
1056    Waiting,
1057}
1058
1059impl UserTurn {
1060    /// Stable wire spelling.
1061    pub const fn as_str(self) -> &'static str {
1062        match self {
1063            Self::Steered => "steered",
1064            Self::Started => "started",
1065            Self::Typed => "typed",
1066            Self::Waiting => "waiting",
1067        }
1068    }
1069}
1070
1071/// The choice or permission prompt a daemon pane's screen shows, as its lines (the question above
1072/// it and its options), or `None`. The terminal substrate's own check (`classifyAttentionSignal`):
1073/// a selector (❯ or ›) on a numbered option is a prompt blocked on an answer; the idle input line
1074/// has no number after its selector.
1075pub fn pane_prompt(pane: &str) -> Option<Vec<String>> {
1076    let screen = pane_screen(pane)?;
1077    let lines: Vec<&str> = screen.lines().map(str::trim_end).collect();
1078    let is_option = |line: &str| {
1079        let line = line.trim_start();
1080        // Windows consoles draw Claude's selector as `>`.
1081        let rest = line
1082            .strip_prefix('❯')
1083            .or_else(|| line.strip_prefix('›'))
1084            .or_else(|| line.strip_prefix('>'))
1085            .unwrap_or(line)
1086            .trim_start();
1087        let digits = rest.chars().take_while(char::is_ascii_digit).count();
1088        digits > 0 && matches!(rest[digits..].chars().next(), Some('.' | ')'))
1089    };
1090    let selected = lines.iter().position(|line| {
1091        let line = line.trim_start();
1092        (line.starts_with('❯') || line.starts_with('›') || line.starts_with('>')) && is_option(line)
1093    })?;
1094    // The prompt: the options' block of non-empty lines and the block above it (the question).
1095    let block_start = |end: usize| {
1096        lines[..end]
1097            .iter()
1098            .rposition(|line| line.trim().is_empty())
1099            .map_or(0, |blank| blank + 1)
1100    };
1101    let options = block_start(selected);
1102    let above = lines[..options]
1103        .iter()
1104        .rposition(|line| !line.trim().is_empty())
1105        .map(|last| block_start(last));
1106    let start = above.unwrap_or(options);
1107    let end = lines[selected..]
1108        .iter()
1109        .position(|line| line.trim().is_empty())
1110        .map_or(lines.len(), |blank| selected + blank);
1111    Some(
1112        lines[start..end]
1113            .iter()
1114            .map(|line| line.trim().to_string())
1115            .filter(|line| !line.is_empty())
1116            .take(20)
1117            .collect(),
1118    )
1119}
1120
1121/// Read this machine's actual panes once for the session list. Unavailability is unknown.
1122/// Only `wanted` are read: the daemon captures each pane it is asked about, so asking about every
1123/// pane cost a screen capture of all of them on every live-session read (t_23e47b73).
1124fn live_daemon_panes<'a>(
1125    wanted: impl Iterator<Item = &'a String>,
1126) -> Option<std::collections::HashMap<String, bool>> {
1127    let wanted: Vec<&str> = wanted.map(String::as_str).collect();
1128    crate::slow_log::timed("teams panes ls --presence-only", || {
1129        live_daemon_panes_now(&wanted.join(","))
1130    })
1131}
1132
1133fn live_daemon_panes_now(wanted: &str) -> Option<std::collections::HashMap<String, bool>> {
1134    let entry = crate::teams_entry().ok()?;
1135    let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1136        .ok()
1137        .filter(|value| !value.trim().is_empty())
1138        .unwrap_or_else(|| "node".into());
1139    let output = std::process::Command::new(node)
1140        .arg(entry)
1141        .args(["panes", "ls", "--presence-only", "--panes", wanted])
1142        .stdin(std::process::Stdio::null())
1143        .output()
1144        .ok()?;
1145    if !output.status.success() {
1146        return None;
1147    }
1148    let rows: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout).ok()?;
1149    rows.iter()
1150        .map(|row| {
1151            Some((
1152                row["pane"].as_str()?.to_owned(),
1153                row["idleComposer"].as_bool()?,
1154            ))
1155        })
1156        .collect()
1157}
1158
1159/// A daemon pane's screen: tmux's capture where the daemon's host is tmux, else the machine daemon's
1160/// own capture (`teams panes capture`), as on Windows, where panes are ConPTY.
1161fn pane_screen(pane: &str) -> Option<String> {
1162    #[cfg(unix)]
1163    {
1164        let output = std::process::Command::new("tmux")
1165            .args(["capture-pane", "-p", "-t", &format!("{pane}:")])
1166            .output()
1167            .ok()?;
1168        output
1169            .status
1170            .success()
1171            .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1172    }
1173    #[cfg(not(unix))]
1174    {
1175        let entry = crate::teams_entry().ok()?;
1176        let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1177            .ok()
1178            .filter(|value| !value.trim().is_empty())
1179            .unwrap_or_else(|| "node".into());
1180        let output = std::process::Command::new(node)
1181            .arg(entry)
1182            .args(["panes", "capture", pane, "--lines", "60"])
1183            .stdin(std::process::Stdio::null())
1184            .output()
1185            .ok()?;
1186        output
1187            .status
1188            .success()
1189            .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
1190    }
1191}
1192
1193/// The daemon pane a session runs in, when it runs in one.
1194pub fn daemon_pane(session: &LiveSession) -> Option<String> {
1195    let name = session.tmux.as_deref()?.split(':').next()?;
1196    let rest = name.strip_prefix("p_")?;
1197    (!rest.is_empty()
1198        && rest
1199            .chars()
1200            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
1201    .then(|| name.to_string())
1202}
1203
1204/// Deliver the user's own turn to `to` through the one door that carries the
1205/// user's authority: a hosted runtime's own input, or the session's pane.
1206/// A session with neither (running outside supercode) is refused, never
1207/// reached as a peer instead. Callers hold the owner's authority already:
1208/// this is reached only through owner doors (the machine's own harness.v1).
1209pub async fn deliver_user_turn(
1210    homes: &HarnessHomes,
1211    envelope: &Envelope,
1212    to: &MailAddress,
1213) -> Result<UserTurn, String> {
1214    let session = LiveSessions::read(homes)
1215        .sessions
1216        .into_iter()
1217        .find(|session| &session.address == to)
1218        .ok_or_else(|| format!("{to} is not running; nothing was sent"))?;
1219    let mailbox = Mailbox::open(&mail_root(), to).map_err(|error| error.to_string())?;
1220    if let Door::Runtime(record) = &session.door {
1221        let delivered = deliver_to_runtime(record, envelope.body.clone(), true).await?;
1222        mailbox
1223            .deliver_read(envelope)
1224            .map_err(|error| error.to_string())?;
1225        return Ok(match delivered {
1226            RuntimeDelivery::Steered => UserTurn::Steered,
1227            _ => UserTurn::Started,
1228        });
1229    }
1230    let Some(pane) = daemon_pane(&session) else {
1231        let name = session.name.split('@').next().unwrap_or(&session.name);
1232        return Err(format!(
1233            "{name} runs outside supercode, where nothing can speak as its user. Open it in a \
1234             pane with `supercode open {name}`; nothing was sent."
1235        ));
1236    };
1237    mailbox
1238        .deliver(envelope)
1239        .map_err(|error| error.to_string())?;
1240    let typed = type_user_turns(&mailbox, &pane).await;
1241    Ok(if typed.contains(&envelope.id) {
1242        UserTurn::Typed
1243    } else {
1244        UserTurn::Waiting
1245    })
1246}
1247
1248/// Type the user's waiting turns into `pane`, oldest first, each only when
1249/// the pane's composer is empty. Stops at the first that has to wait.
1250/// Returns the ids typed.
1251pub async fn type_user_turns(mailbox: &Mailbox, pane: &str) -> Vec<String> {
1252    let mut typed = Vec::new();
1253    for waiting in mailbox.user_turns().unwrap_or_default() {
1254        // Claimed before it is typed: the machine's watcher and a direct delivery both type waiting turns, and
1255        // two typers filling one composer sent a turn's text twice in one prompt. A turn another typer holds is
1256        // that typer's, and so is every turn after it.
1257        let Ok(Some(stored)) = mailbox.claim_user_turn(&waiting) else {
1258            break;
1259        };
1260        match submit_mail_batch(pane, &stored.envelope.body, &[stored.envelope.id.clone()]).await {
1261            Ok(true) => {
1262                mailbox.acknowledge(&stored).ok();
1263                typed.push(stored.envelope.id.clone());
1264            }
1265            Ok(false) => {
1266                mailbox.release(&stored).ok();
1267                break;
1268            }
1269            Err(error) => {
1270                mailbox.release(&stored).ok();
1271                eprintln!(
1272                    "supercode: the user's turn {} for {} waits: {error}",
1273                    stored.envelope.id,
1274                    mailbox.address()
1275                );
1276                break;
1277            }
1278        }
1279    }
1280    typed
1281}
1282
1283/// The daemon drains a recipient's waiting envelopes as one paste. The machine
1284/// retains the batch and its ids until the native transcript confirms receipt.
1285pub(crate) async fn wake_hook_mailbox(
1286    mailbox: &Mailbox,
1287    pane: &str,
1288    ids: &[String],
1289) -> Result<bool, String> {
1290    let messages = ids
1291        .iter()
1292        .map(|id| mailbox.find(id))
1293        .collect::<Result<Vec<_>, _>>()
1294        .map_err(|error| error.to_string())?;
1295    let messages: Vec<_> = messages.into_iter().flatten().collect();
1296    if messages.is_empty() {
1297        return Ok(false);
1298    }
1299    let mut delivered = true;
1300    for message in messages {
1301        delivered &= submit_mail_batch(
1302            pane,
1303            &message.envelope.render(),
1304            &[message.envelope.id.clone()],
1305        )
1306        .await?;
1307    }
1308    Ok(delivered)
1309}
1310
1311async fn submit_mail_batch(pane: &str, text: &str, ids: &[String]) -> Result<bool, String> {
1312    let entry = crate::teams_entry().map_err(|error| error.to_string())?;
1313    let node = std::env::var(crate::orchestrator_door::NODE_BIN_ENV)
1314        .ok()
1315        .filter(|value| !value.trim().is_empty())
1316        .unwrap_or_else(|| "node".into());
1317    let output = tokio::process::Command::new(node)
1318        .arg(entry)
1319        .args([
1320            "input",
1321            pane,
1322            text,
1323            "--when-composer-empty",
1324            "--message-ids",
1325            &ids.join(","),
1326        ])
1327        // A message is the session's own door, not a session acting on a pane: the pane doors read that from this
1328        // process's place under the daemon's `message watch`, not from anything named here.
1329        .env_remove("SUPERCODE_CALLER")
1330        .stdin(std::process::Stdio::null())
1331        .output()
1332        .await
1333        .map_err(|error| error.to_string())?;
1334    if !output.status.success() {
1335        return Err(crate::mailbox::error_line(&String::from_utf8_lossy(
1336            &output.stderr,
1337        )));
1338    }
1339    let answer: serde_json::Value =
1340        serde_json::from_slice(&output.stdout).map_err(|error| error.to_string())?;
1341    Ok(answer["delivered"] == true)
1342}