fix(core): route Codex reports past /new and codex -C, trust agent_settled only once seen

A session /new started goes to the Codex pane that had one; a report from
a directory no pane runs Codex in is matched among every Codex pane instead
of falling back to the app-server's. The Pi bridge no longer reads a
returned function from on() as proof agent_settled will come, and the Stop
hook re-reads the transcript only once it has grown.
This commit is contained in:
l0ng-ai
2026-10-01 15:56:02 +08:00
parent cd4ff91747
commit da0add62e2
3 changed files with 70 additions and 22 deletions
+19 -10
View File
@@ -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<String> {
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
+47 -12
View File
@@ -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<u64> {
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");
}
+4
View File
@@ -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::<serde_json::Value>(body)
.ok()
.is_some_and(|body| body["source"] == "clear"),
)?;
let pane = panes.into_iter().find(|p| p.id == target)?;
Some((pane, event))