diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index e388488b..8a569e78 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -153,6 +153,9 @@ fn build_hook_body(agent: &str, event: &str, stdin_json: &str) -> String { ("cwd", "cwd"), // Goose spells the working directory its own way. ("cwd", "working_dir"), + // What started a session (startup, resume, clear…): routes a Codex + // report — see `daemon::pane::codex_report_target`. + ("source", "source"), ] { if let Some(v) = payload .get(key) @@ -336,14 +339,20 @@ fn catalog_says_no_effort(catalogs: &[serde_json::Value], model: &str) -> bool { /// The model that wrote `reply` to the prompt `prompt_id`, once the /// transcript has it as its latest words. Claude Code runs `Stop` before -/// that write is always on disk, so this waits a little for it. +/// that write is always on disk, so this waits a little for it — reading +/// the tail again only once the transcript has grown. fn turn_reply_model(path: &Path, reply: &str, prompt_id: Option<&str>) -> Option { let deadline = std::time::Instant::now() + TRANSCRIPT_CATCH_UP; + let mut read = None; loop { - if let Some(model) = - transcript_tail(path).and_then(|t| reply_model_in(&t, reply, prompt_id)) - { - return Some(model); + let len = std::fs::metadata(path).ok().map(|m| m.len()); + if len != read { + read = len; + if let Some(model) = + transcript_tail(path).and_then(|t| reply_model_in(&t, reply, prompt_id)) + { + return Some(model); + } } if std::time::Instant::now() >= deadline { return None; @@ -2217,9 +2226,10 @@ export default function (pi: ExtensionAPI) {{ // Whether this build says when a run is over for good. agent_end also ends // each attempt an automatic retry, a compaction or a queued message picks // up again, and reading that as "done" calls the pane finished while it - // still works. Pi's agent_settled comes once the run is really over; a Pi - // whose on() hands back an unsubscribe is new enough to always send it, - // and an older one that sends it shows so the first time it does. + // still works. Pi's agent_settled comes once the run is really over, and a + // build that sends it shows so the first time it does. Nothing earlier + // tells: on() returns nothing in Pi or Oh My Pi, and a build that takes the + // name without ever sending it must still end its turns on agent_end. let settles = false; pi.on("agent_start", (_event, ctx) => emit((turn = "prompt-submit"), ctx)); pi.on("agent_end", (_event, ctx) => {{ @@ -2250,11 +2260,10 @@ export default function (pi: ExtensionAPI) {{ pi.on("ui_prompt_end", (_event, ctx) => emit(turn, ctx)); }} catch {{}} try {{ - const off: unknown = pi.on("agent_settled" as any, (_event: unknown, ctx: SessionCtx) => {{ + pi.on("agent_settled" as any, (_event: unknown, ctx: SessionCtx) => {{ settles = true; emit((turn = "stop"), ctx); }}); - if (typeof off === "function") settles = true; }} catch {{}} // Oh My Pi asks before running a tool through events of its own rather // than Pi's UI-prompt pair, and announces its retries — which follow an diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 271e1038..bc811040 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -3609,14 +3609,17 @@ pub struct CodexCandidate { /// 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. +/// Codex in that directory, or among every Codex pane when none runs there +/// (`codex -C` leaves the shell elsewhere). Of several, a session `/new` +/// started (`cleared`) goes to one that had a session before and any other to +/// one yet to report, and the named pane breaks a tie that still stands. +/// `None` is no pane, or no telling which. pub fn codex_report_target( panes: &[CodexCandidate], named: u64, session: Option<&str>, cwd: Option<&Path>, + cleared: bool, ) -> Option { let codex = || panes.iter().filter(|p| p.runs_codex); if let Some(session) = session @@ -3624,16 +3627,23 @@ pub fn codex_report_target( { 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))) + .filter(|p| { + cwd.zip(p.cwd.as_deref()) + .is_some_and(|(cwd, c)| same_dir(c, cwd)) + }) .collect(); - let fresh: Vec<&CodexCandidate> = here + let pool = if here.is_empty() { + codex().collect() + } else { + here + }; + let likely: Vec<&CodexCandidate> = pool .iter() .copied() - .filter(|p| p.session.is_none()) + .filter(|p| p.session.is_some() == cleared) .collect(); - let pool = if fresh.is_empty() { here } else { fresh }; + let pool = if likely.is_empty() { pool } else { likely }; match pool.as_slice() { [only] => Some(only.pane), [] => None, @@ -4238,7 +4248,7 @@ mod tests { 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))) + codex_report_target(&panes, named, session, Some(Path::new(cwd)), false) }; // The app-server was started in pane 1, so every hook names it. @@ -4263,14 +4273,39 @@ mod tests { 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); + assert_eq!( + codex_report_target(&panes, 1, Some("s-c2"), Some(Path::new("/work/c")), true), + Some(4), + "but a session /new started goes to the one that had a session" + ); + assert_eq!( + target(1, Some("s-new"), "/work/none"), + None, + "a directory no pane runs Codex in leaves every fresh pane in the running" + ); + let elsewhere = [ + pane(1, true, "/work/a", Some("s-a")), + pane(2, true, "/home", None), + ]; + assert_eq!( + codex_report_target( + &elsewhere, + 1, + Some("s-b"), + Some(Path::new("/work/b")), + false + ), + Some(2), + "codex -C: the one Codex pane yet to report, not the app-server's" + ); 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"))); + let twin = |named| { + codex_report_target(&twins, named, Some("s"), Some(Path::new("/work/d")), false) + }; 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"); } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index aa260318..2c260be9 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -146,6 +146,10 @@ fn codex_report_pane( named, event.session_id.as_deref(), event.cwd.as_deref(), + // Codex's SessionStart says what started the session; `clear` is /new. + serde_json::from_str::(body) + .ok() + .is_some_and(|body| body["source"] == "clear"), )?; let pane = panes.into_iter().find(|p| p.id == target)?; Some((pane, event))