fix(core): find the pane a Codex hook reports on from the report itself

Codex runs its hooks in its app-server, one background process every
Codex session on the machine talks to. The first session starts it, and
it outlives that session, detached. Its hooks therefore carry the first
session's $TTY7_PANE and run under that pane's shell only while that
session lasts. Once it ended, the daemon refused every Codex report as
coming from outside the pane -- and the tty fallback has no terminal in
a detached process -- so a Codex pane never showed working, waiting or
done. While the first session was still running, a second pane's
reports were taken as the first pane's.

A Codex report is now matched to its pane by what it says: the pane
that already reported its session, else the one pane running Codex in
its directory (the one yet to report a session, and the named pane on a
tie). Other agents' reports are unchanged.
This commit is contained in:
l0ng-ai
2026-10-01 15:28:00 +08:00
parent 32b9b35b64
commit cdc3b47914
2 changed files with 155 additions and 3 deletions
+124
View File
@@ -2515,6 +2515,15 @@ impl DaemonPane {
}
let event = crate::core::cli_agent::parse_agent_event_body(body.as_bytes())
.ok_or_else(|| "not an agent event".to_string())?;
self.apply_reported_agent_event(event)
}
/// Apply a hook's report this pane was found to own some other way than
/// by where the hook runs — see [`codex_report_target`].
pub fn apply_reported_agent_event(
&self,
event: crate::core::cli_agent::AgentEvent,
) -> Result<(), String> {
let mut st = self.state.lock().unwrap();
if !st.alive {
return Err(format!("pane {} is not running", self.id));
@@ -2529,6 +2538,17 @@ impl DaemonPane {
Ok(())
}
/// This pane as a Codex report sees it, for [`codex_report_target`].
pub fn codex_candidate(&self) -> CodexCandidate {
let st = self.state.lock().unwrap();
CodexCandidate {
pane: self.id,
runs_codex: st.alive && st.agent == Some(crate::core::cli_agent::CLIAgent::Codex),
cwd: st.cwd.clone(),
session: st.agent_session.as_ref().and_then(|s| s.session_id.clone()),
}
}
pub fn gate(&self) -> Arc<OutputGate> {
self.gate.clone()
}
@@ -3527,6 +3547,57 @@ fn apply_probed_cwd(st: &mut PaneState, probed: Option<PathBuf>) {
st.cwd = Some(probed);
}
/// A pane as a Codex hook report sees it: whether Codex runs in it, where,
/// and which session it last reported.
#[derive(Debug, Clone)]
pub struct CodexCandidate {
pub pane: u64,
pub runs_codex: bool,
pub cwd: Option<PathBuf>,
pub session: Option<String>,
}
/// The pane a Codex hook's report belongs to.
///
/// Codex runs its hooks in its app-server: one background process that every
/// Codex session on the machine talks to, started by whichever session came
/// first and outliving it. A hook therefore carries that first session's
/// environment — its `$TTY7_PANE` — and runs under that pane's shell only for
/// as long as that session lasts, so neither says which pane the report is
/// about. The report does: its session, then its directory. A pane that has
/// already reported the session owns it; otherwise it is the one pane running
/// Codex in that directory — the one yet to report a session, when there are
/// several, and the named pane when that still leaves a tie. `None` is no
/// pane, or no telling which.
pub fn codex_report_target(
panes: &[CodexCandidate],
named: u64,
session: Option<&str>,
cwd: Option<&Path>,
) -> Option<u64> {
let codex = || panes.iter().filter(|p| p.runs_codex);
if let Some(session) = session
&& let Some(owner) = codex().find(|p| p.session.as_deref() == Some(session))
{
return Some(owner.pane);
}
let cwd = cwd?;
let here: Vec<&CodexCandidate> = codex()
.filter(|p| p.cwd.as_deref().is_some_and(|c| same_dir(c, cwd)))
.collect();
let fresh: Vec<&CodexCandidate> = here
.iter()
.copied()
.filter(|p| p.session.is_none())
.collect();
let pool = if fresh.is_empty() { here } else { fresh };
match pool.as_slice() {
[only] => Some(only.pane),
[] => None,
several => several.iter().find(|p| p.pane == named).map(|p| p.pane),
}
}
fn same_dir(a: &Path, b: &Path) -> bool {
a == b
|| match (a.canonicalize(), b.canonicalize()) {
@@ -4091,6 +4162,59 @@ mod tests {
/// A hook's report that arrives over the socket lands like one read off
/// the output — but only from a process that runs in the pane.
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn a_codex_report_finds_its_pane_by_session_then_directory() {
let pane = |pane: u64, codex: bool, cwd: &str, session: Option<&str>| CodexCandidate {
pane,
runs_codex: codex,
cwd: Some(PathBuf::from(cwd)),
session: session.map(str::to_string),
};
let panes = [
pane(1, true, "/work/a", Some("s-a")),
pane(2, true, "/work/b", None),
pane(3, false, "/work/b", None),
pane(4, true, "/work/c", Some("s-c")),
pane(5, true, "/work/c", None),
];
let target = |named: u64, session: Option<&str>, cwd: &str| {
codex_report_target(&panes, named, session, Some(Path::new(cwd)))
};
// The app-server was started in pane 1, so every hook names it.
assert_eq!(target(1, Some("s-a"), "/work/a"), Some(1));
assert_eq!(
target(1, Some("s-c"), "/work/elsewhere"),
Some(4),
"a reported session stays with its pane"
);
assert_eq!(
target(1, Some("s-new"), "/work/b"),
Some(2),
"a new session goes to the one Codex pane in its directory"
);
assert_eq!(
target(1, Some("s-new"), "/work/c"),
Some(5),
"of two there, the one yet to report a session"
);
assert_eq!(
target(1, Some("s-next"), "/work/a"),
Some(1),
"a new session in a pane that had one before (/new) is still that pane's"
);
assert_eq!(target(1, Some("s-new"), "/work/none"), None);
let twins = [
pane(6, true, "/work/d", None),
pane(7, true, "/work/d", None),
];
let twin =
|named| codex_report_target(&twins, named, Some("s"), Some(Path::new("/work/d")));
assert_eq!(twin(7), Some(7), "a tie goes to the pane the hook named");
assert_eq!(twin(1), None, "and is no one's otherwise");
}
#[test]
fn a_hook_report_counts_only_from_inside_the_pane() {
let (tx, rx) = mpsc::channel();
+31 -3
View File
@@ -126,6 +126,31 @@ impl crate::host::server::PaneDirectory for Registry {
/// so being up to a minute late to notice costs nothing.
const AGENT_STALE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
/// The pane a Codex hook's report is about, found from the report itself —
/// where a hook runs says nothing for Codex (see
/// [`crate::daemon::pane::codex_report_target`]). `None` for any other agent's
/// report, which goes to the pane the hook named.
fn codex_report_pane(
registry: &Registry,
named: u64,
body: &str,
) -> Option<(Arc<DaemonPane>, crate::core::cli_agent::AgentEvent)> {
let event = crate::core::cli_agent::parse_agent_event_body(body.as_bytes())?;
if event.agent != Some(crate::core::cli_agent::CLIAgent::Codex) {
return None;
}
let panes = registry.all();
let candidates: Vec<_> = panes.iter().map(|p| p.codex_candidate()).collect();
let target = crate::daemon::pane::codex_report_target(
&candidates,
named,
event.session_id.as_deref(),
event.cwd.as_deref(),
)?;
let pane = panes.into_iter().find(|p| p.id == target)?;
Some((pane, event))
}
fn spawn_agent_stale_sweep(registry: Arc<Registry>) {
let spawned = std::thread::Builder::new()
.name("tty7-agent-stale".into())
@@ -1158,9 +1183,12 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
event,
} => {
let mut w = write_stream;
let applied = match registry.get(pane_id) {
Some(pane) => pane.report_agent_event(pid, &event),
None => Err(format!("no such pane {pane_id}")),
let applied = match codex_report_pane(&registry, pane_id, &event) {
Some((pane, parsed)) => pane.apply_reported_agent_event(parsed),
None => match registry.get(pane_id) {
Some(pane) => pane.report_agent_event(pid, &event),
None => Err(format!("no such pane {pane_id}")),
},
};
match applied {
Ok(()) => DaemonMsg::InputAck { pane_id }.encode(&mut w)?,