fix: track codex turns from session transcripts

refs #4507
This commit is contained in:
Jonathan Liebig
2026-09-23 22:34:30 +02:00
parent 21fd121a63
commit 4d93f6ebc5
40 changed files with 2116 additions and 86 deletions
+34 -14
View File
@@ -1,7 +1,7 @@
id = "codex"
version = "2026.09.22.1"
version = "2026.09.23.1"
min_engine_version = 3
updated_at = "2026-09-22T00:00:00Z"
updated_at = "2026-09-23T00:00:00Z"
[[rules]]
id = "osc_title_blocked"
@@ -42,6 +42,38 @@ all = [
{ regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] },
]
[[rules]]
id = "folder_access"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Folder access", "Trust this folder?", "Trust and continue"]
[[rules]]
id = "sign_in"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Welcome to Codex", "Sign in with ChatGPT", "Sign in with Device Code"]
[[rules]]
id = "hooks_review"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Hooks need review", "Review hooks", "Trust all and continue"]
[[rules]]
id = "windows_sandbox_setup"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Set up the Codex agent sandbox", "Use non-admin sandbox"]
[[rules]]
id = "startup_update"
state = "blocked"
@@ -92,15 +124,3 @@ any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# A failed reconnect keeps its final elapsed timer but is no longer working.
not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
[[rules]]
id = "osc_title_idle"
state = "idle"
priority = 100
region = "osc_title"
visible_idle = true
regex = ['\S']
not = [
{ regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] },
{ contains = ["Action Required"] },
]
+8
View File
@@ -3053,6 +3053,14 @@
"null"
]
},
"origin_pid": {
"format": "uint32",
"minimum": 0,
"type": [
"integer",
"null"
]
},
"pane_id": {
"type": "string"
},
@@ -62,7 +62,8 @@ Herdr uses integrations in two ways:
| Integration type | Agents | Effect |
| --- | --- | --- |
| Lifecycle authority | Pi, OMP, Kimi Code CLI, OpenCode, Kilo Code CLI, MastraCode | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use screen manifest fallback for that same lifecycle authority. |
| Session identity | Claude Code, Codex, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
| Codex turns | Codex | The integration reports session identity and enables turn tracking from the session transcript. Visible permission prompts still show as blocked. |
| Session identity | Claude Code, GitHub Copilot CLI, Devin CLI, Droid, Qoder CLI, Qwen Code, Letta Code, Cursor Agent CLI, Hermes Agent, Antigravity CLI, Grok CLI | The integration reports native session references for restore. State still comes from Herdr's screen manifest detection. |
Custom integrations can also report state that is not visible in the native terminal UI. They do not need to be built into Herdr or use a recognized agent executable.
@@ -153,7 +154,7 @@ Install the Codex hook:
herdr integration install codex
```
The Codex hook reports session identity through the same local socket API used by other integrations. Codex state comes from Herdr's screen manifest detection.
The Codex hook reports session identity and lets Herdr track turn starts, completion, and interruption from the session transcript. Visible permission prompts still show as blocked. This requires Codex integration version `9`; rerun the install command above to update an older integration, then restart Codex. Without turn tracking, ambiguous Codex screens show as unknown rather than idle.
Herdr uses `~/.codex` by default, or `CODEX_HOME` when set. The Codex config directory must already exist. Install writes `herdr-agent-state.sh`, updates `hooks.json`, and ensures `[features] hooks = true` in `config.toml`. It also removes the deprecated top-level `codex_hooks` flag when present. Uninstall removes Herdr entries from `hooks.json` and deletes the hook script, but leaves `config.toml` unchanged.
@@ -64,7 +64,8 @@ Herdr はインテグレーションを 2 つの異なる方法で使います:
| インテグレーションの種類 | エージェント | 効果 |
| --- | --- | --- |
| ライフサイクル権威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | インストールされ、そのペインについて能動的に報告している間は、フックまたはプラグインのイベントが `idle`、`working`、`blocked` を決定します。同じライフサイクル権威に対して、Herdr はスクリーンマニフェストのフォールバックを併用しません。 |
| セッション識別 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 |
| Codex のターン | Codex | インテグレーションはセッション識別を報告し、セッション記録からターンを追跡します。表示中の権限確認は引き続き blocked になります。 |
| セッション識別 | Claude Code、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | インテグレーションは復元用のネイティブセッション参照を報告します。状態は引き続き Herdr のスクリーンマニフェスト検出から得られます。 |
カスタムインテグレーションも、ネイティブのターミナル UI では見えない状態を定義する場合に状態を報告できます。Herdr への組み込みや、認識済みのエージェント実行ファイルは必要ありません。
@@ -155,7 +156,7 @@ Codex フックをインストールします:
herdr integration install codex
```
Codex フックは、他のインテグレーションと同じローカルソケット API を通じてセッション識別を報告します。Codex の状態は Herdr のスクリーンマニフェスト検出から得られます。
Codex フックはセッション識別を報告し、Herdr はセッション記録からターンの開始、完了、中断を追跡します。表示中の権限確認は引き続き blocked になります。Codex インテグレーションのバージョン `9` が必要です。古い場合は上記のインストールコマンドを再実行し、Codex を再起動してください。ターンを追跡できない場合、判定できない画面は idle ではなく unknown と表示されます。
Herdr はデフォルトで `~/.codex` を使い、`CODEX_HOME` が設定されていればそちらを使います。Codex の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh` を書き込み、`hooks.json` を更新し、`config.toml` に `[features] hooks = true` があることを保証します。非推奨のトップレベル `codex_hooks` フラグが存在すれば削除もします。アンインストールは `hooks.json` から Herdr のエントリを削除してフックスクリプトを削除しますが、`config.toml` は変更しません。
@@ -64,7 +64,8 @@ Herdr 以两种不同方式使用集成:
| 集成类型 | 智能体 | 效果 |
| --- | --- | --- |
| 生命周期权威 | Pi、OMP、Kimi Code CLI、OpenCode、Kilo Code CLI、MastraCode | 已安装且在为该窗格主动上报时,由钩子或插件事件决定 `idle`、`working` 和 `blocked`。对同一个生命周期权威,Herdr 不再使用屏幕清单兜底。 |
| 会话身份 | Claude Code、Codex、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 |
| Codex 轮次 | Codex | 集成上报会话身份,并通过会话记录跟踪轮次。可见的权限提示仍显示为 blocked。 |
| 会话身份 | Claude Code、GitHub Copilot CLI、Devin CLI、Droid、Qoder CLI、Qwen Code、Letta Code、Cursor Agent CLI、Hermes Agent、Antigravity CLI、Grok CLI | 集成上报用于恢复的原生会话引用。状态仍来自 Herdr 的屏幕清单检测。 |
自定义集成在定义了原生终端 UI 中不可见的状态时,也可以上报状态。它们不需要内置到 Herdr 中,也不要求 Herdr 识别智能体的可执行文件。
@@ -155,7 +156,7 @@ Herdr 默认使用 `~/.claude`,设置了 `CLAUDE_CONFIG_DIR` 时使用后者。C
herdr integration install codex
```
Codex 钩子通过与其他集成相同的本地 socket API 上报会话身份。Codex 的状态来自 Herdr 的屏幕清单检测。
Codex 钩子上报会话身份,Herdr 通过会话记录跟踪轮次的开始、完成和中断。可见的权限提示仍显示为 blocked。这需要 Codex 集成版本 `9`;请重新运行上面的安装命令更新旧版集成,然后重启 Codex。无法跟踪轮次时,不能明确判断的 Codex 画面显示为 unknown,而不是 idle。
Herdr 默认使用 `~/.codex`,设置了 `CODEX_HOME` 时使用后者。Codex 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`、更新 `hooks.json`,并确保 `config.toml` 中有 `[features] hooks = true`。如果存在已弃用的顶层 `codex_hooks` 标志,它也会一并移除。卸载会从 `hooks.json` 中移除 Herdr 条目并删除钩子脚本,但不改动 `config.toml`。
+2
View File
@@ -463,6 +463,8 @@ pub struct PaneReportAgentParams {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct PaneReportAgentSessionParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_pid: Option<u32>,
pub source: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
+191 -3
View File
@@ -1501,15 +1501,52 @@ impl AppState {
seq,
session_ref,
session_start_source,
transcript_path,
} => self
.update_terminal_state(pane_id, |terminal| {
terminal.set_agent_session_ref_for_session_start(
let codex = source == "herdr:codex" && agent_label == "codex";
let mutation = terminal.set_agent_session_ref_for_session_start(
source,
agent_label,
session_ref,
seq,
session_start_source,
)
)?;
if codex {
if let Some(path) = transcript_path {
terminal.register_codex_transcript(path);
}
}
Some(mutation)
})
.into_iter()
.collect(),
AppEvent::CodexTurnObserved(observation) => {
let pane_id = self
.workspaces
.iter()
.flat_map(|ws| &ws.tabs)
.flat_map(|tab| &tab.panes)
.find_map(|(id, pane)| {
(pane.attached_terminal_id == observation.registration.terminal_id)
.then_some(*id)
});
pane_id
.and_then(|pane_id| {
self.update_terminal_state(pane_id, |terminal| {
terminal.observe_codex_turn(observation)
})
})
.into_iter()
.collect()
}
AppEvent::CodexPromptReady {
pane_id,
ready,
observed_at,
} => self
.update_terminal_state(pane_id, |terminal| {
terminal.observe_codex_prompt(ready, observed_at)
})
.into_iter()
.collect(),
@@ -1682,7 +1719,11 @@ impl AppState {
let agent_released = mutation.agent_released;
let change = mutation.effective_state_change.or(unchanged_change)?;
let suppress_completion = force_suppress_completion
|| (change.state == AgentState::Idle && suppress_acquisition_completion);
|| (change.state == AgentState::Idle
&& (suppress_acquisition_completion
|| self.terminals.get(&terminal_id).is_some_and(
crate::terminal::TerminalState::codex_turn_aborted_effective,
)));
if change.previous_state != change.state {
self.next_agent_state_change_seq += 1;
if let Some(terminal) = self.terminals.get_mut(&terminal_id) {
@@ -3169,6 +3210,7 @@ mod tests {
seq: Some(seq),
session_ref: crate::agent_resume::AgentSessionRef::id(session),
session_start_source: Some(reason.into()),
transcript_path: None,
});
if seq == 1 {
for state in [AgentState::Working, AgentState::Idle] {
@@ -3206,6 +3248,152 @@ mod tests {
assert!(!app.pending_agent_notifications.contains_key(&pane_id));
}
#[test]
fn codex_replay_and_abort_do_not_notify_even_when_a_blocker_delays_idle() {
for blocked in [false, true] {
let mut app = app_with_workspaces(&["active", "background"]);
app.active = Some(0);
app.toast_config.delay_seconds = 5;
let pane_id = app.workspaces[1].tabs[0].root_pane;
let terminal_id = app.workspaces[1].panes[&pane_id]
.attached_terminal_id
.clone();
let at = Instant::now();
app.handle_app_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Codex),
state: AgentState::Working,
visible_blocker: false,
visible_working: true,
process_exited: false,
observed_at: at,
});
app.handle_app_event(AppEvent::AgentSessionReported {
pane_id,
source: "herdr:codex".into(),
agent_label: "codex".into(),
seq: None,
session_ref: crate::agent_resume::AgentSessionRef::id("session"),
session_start_source: None,
transcript_path: Some(std::env::temp_dir().join("session.jsonl")),
});
if blocked {
app.handle_app_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Codex),
state: AgentState::Blocked,
visible_blocker: true,
visible_working: false,
process_exited: false,
observed_at: at,
});
}
let registration = app.terminals[&terminal_id]
.codex_session
.as_ref()
.unwrap()
.registration
.clone();
let turn_event = |id: &str, phase, replay| {
AppEvent::CodexTurnObserved(crate::terminal::codex::Observation {
registration: registration.clone(),
turn: Some(crate::terminal::codex::Turn {
id: id.into(),
phase,
}),
observed_at: Instant::now(),
replay,
unavailable: false,
})
};
app.handle_app_event(turn_event(
"historical",
crate::terminal::codex::TurnPhase::Completed,
true,
));
assert_eq!(
app.terminals[&terminal_id].state,
if blocked {
AgentState::Blocked
} else {
AgentState::Idle
}
);
app.handle_app_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Codex),
state: AgentState::Unknown,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: Instant::now(),
});
assert_eq!(app.terminals[&terminal_id].state, AgentState::Idle);
assert!(app.terminals[&terminal_id]
.last_agent_completion_seq
.is_none());
assert!(app.workspaces[1].panes[&pane_id].seen);
assert!(!app
.pending_agent_notifications
.get(&pane_id)
.is_some_and(|notification| notification.kind == ToastKind::Finished));
app.handle_app_event(turn_event(
"aborted",
crate::terminal::codex::TurnPhase::Active,
false,
));
app.handle_app_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Codex),
state: AgentState::Blocked,
visible_blocker: true,
visible_working: false,
process_exited: false,
observed_at: Instant::now(),
});
app.handle_app_event(turn_event(
"aborted",
crate::terminal::codex::TurnPhase::Aborted,
false,
));
assert_eq!(app.terminals[&terminal_id].state, AgentState::Blocked);
app.handle_app_event(AppEvent::StateChanged {
pane_id,
agent: Some(Agent::Codex),
state: AgentState::Unknown,
visible_blocker: false,
visible_working: false,
process_exited: false,
observed_at: Instant::now(),
});
assert_eq!(app.terminals[&terminal_id].state, AgentState::Idle);
assert!(app.terminals[&terminal_id]
.last_agent_completion_seq
.is_none());
assert!(app.workspaces[1].panes[&pane_id].seen);
assert!(!app
.pending_agent_notifications
.get(&pane_id)
.is_some_and(|notification| notification.kind == ToastKind::Finished));
app.handle_app_event(turn_event(
"successful",
crate::terminal::codex::TurnPhase::Active,
false,
));
app.handle_app_event(turn_event(
"successful",
crate::terminal::codex::TurnPhase::Completed,
false,
));
assert!(app.terminals[&terminal_id]
.last_agent_completion_seq
.is_some());
assert!(!app.workspaces[1].panes[&pane_id].seen);
assert!(app.pending_agent_notifications.contains_key(&pane_id));
app.assert_invariants_for_test();
}
}
#[test]
fn completion_guard_normal_turn_still_finishes() {
assert_completion_guard_sequence(
+24 -4
View File
@@ -36,9 +36,9 @@ impl App {
.collect()
}
pub(super) fn reconcile_managed_agent_target(&mut self, target: &str) {
pub(super) fn reconcile_managed_agent_target(&mut self, target: &str, now: Instant) -> bool {
let Ok(resolved) = self.resolve_agent_target(target) else {
return;
return false;
};
let Some(terminal_id) = self
.state
@@ -47,18 +47,38 @@ impl App {
.and_then(|workspace| workspace.terminal_id(resolved.pane_id))
.cloned()
else {
return;
return false;
};
let changed = self
.state
.terminals
.get_mut(&terminal_id)
.is_some_and(|terminal| terminal.reconcile_managed_agent_at(Instant::now(), false));
.is_some_and(|terminal| terminal.reconcile_managed_agent_at(now, false));
if changed {
self.state.mark_session_dirty();
self.schedule_session_save();
self.emit_pane_updated(resolved.ws_idx, resolved.pane_id);
}
changed
}
pub(crate) fn reconcile_due_managed_agents_at(&mut self, now: Instant) -> bool {
let due = self
.state
.terminals
.values()
.filter(|terminal| {
terminal
.next_managed_agent_deadline()
.is_some_and(|deadline| now >= deadline)
})
.filter_map(|terminal| terminal.agent_name.clone())
.collect::<Vec<_>>();
let mut changed = false;
for name in due {
changed |= self.reconcile_managed_agent_target(&name, now);
}
changed
}
pub(super) fn agent_info_for_target(
+7
View File
@@ -463,6 +463,13 @@ impl App {
runtime.set_full_lifecycle_authority_active(
terminal.full_lifecycle_hook_authority_active(),
);
runtime.sync_codex_observer(
terminal
.codex_session
.as_ref()
.map(|session| &session.registration),
&self.event_tx,
);
}
}
}
+12 -2
View File
@@ -43,7 +43,7 @@ impl App {
}
pub(super) fn handle_agent_get(&mut self, id: String, target: AgentTarget) -> String {
self.reconcile_managed_agent_target(&target.target);
self.reconcile_managed_agent_target(&target.target, std::time::Instant::now());
let agent = match self.agent_info_for_target(&target.target) {
Ok(agent) => agent,
Err(err) => return encode_error_body(id, self.agent_target_error_body(err)),
@@ -322,7 +322,17 @@ impl App {
osc_progress: &osc_progress,
},
);
let value = crate::detect::manifest::explain_to_json_value(&explain);
let mut value = crate::detect::manifest::explain_to_json_value(&explain);
if agent == crate::detect::Agent::Codex
&& terminal
.codex_session
.as_ref()
.is_some_and(|session| session.turn.is_some())
{
value["screen_state"] = value["state"].clone();
value["state"] = crate::detect::manifest::agent_state_label(terminal.state).into();
value["turn_state_source"] = "session_transcript".into();
}
encode_success(id, ResponseResult::AgentExplain { explain: value })
}
+64 -1
View File
@@ -1578,14 +1578,77 @@ impl App {
id: String,
params: PaneReportAgentSessionParams,
) -> String {
let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
let Some((ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
let terminal_id = self.state.workspaces[ws_idx].terminal_id(pane_id);
let Some(agent_label) = normalize_reported_agent_label(&params.agent) else {
return invalid_agent(id);
};
let codex_report = params.source == "herdr:codex" && agent_label == "codex";
let transcript_admitted = codex_report && params.origin_pid.is_some();
if transcript_admitted {
// Hook admission needs current process evidence, even if detection cached a shell-only snapshot.
let foreground_job = terminal_id
.and_then(|id| self.terminal_runtimes.get(id))
.and_then(|runtime| runtime.child_pid())
.and_then(crate::platform::foreground_job_fresh);
let origin_pid = params.origin_pid.unwrap_or_default();
let origin_matches = foreground_job.as_ref().is_some_and(|job| {
job.processes.iter().any(|process| {
let candidate = crate::platform::ForegroundJob {
process_group_id: process.pid,
processes: vec![process.clone()],
};
crate::detect::identify_agent_in_job(&candidate)
.is_some_and(|(agent, _)| agent == crate::detect::Agent::Codex)
&& crate::platform::process_is_descendant_of(origin_pid, process.pid)
})
});
if !origin_matches {
return encode_error(
id,
"agent_session_stale",
"the Codex hook did not come from the current process",
);
}
if terminal_id
.and_then(|id| self.state.terminals.get(id))
.is_some_and(|terminal| terminal.needs_codex_process_acquisition())
{
// SessionStart may beat the detector after a same-shell relaunch.
self.handle_internal_event(crate::events::AppEvent::AgentProcessDetected {
pane_id,
agent: crate::detect::Agent::Codex,
observed_at: std::time::Instant::now(),
});
}
} else if codex_report
&& terminal_id
.and_then(|id| self.state.terminals.get(id))
.and_then(|terminal| terminal.codex_session.as_ref())
.is_some_and(|session| {
params.agent_session_id.as_deref()
!= Some(session.registration.session_id.as_str())
})
{
return encode_error(
id,
"agent_session_stale",
"a current Codex session is already bound",
);
}
self.handle_internal_event(crate::events::AppEvent::AgentSessionReported {
pane_id,
// Older identity-only hooks remain valid without transcript authority.
transcript_path: transcript_admitted
.then(|| {
params
.agent_session_path
.as_deref()
.map(std::path::PathBuf::from)
})
.flatten(),
session_ref: crate::agent_resume::session_ref_from_report(
&params.source,
&agent_label,
+6
View File
@@ -610,6 +610,12 @@ fn wait_for_named_agent(
"agent_not_ready",
format!("agent {name} is blocked during startup and is not ready for prompts"),
))),
Some("unknown")
if expected_kind == "codex"
&& agent["interactive_ready"].as_bool() == Some(true) =>
{
Some(Ok(agent.clone()))
}
Some("working" | "unknown") => None,
Some("idle" | "done") if agent["interactive_ready"].as_bool() == Some(true) => {
Some(Ok(agent.clone()))
+16 -1
View File
@@ -1286,7 +1286,7 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
}
fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE]";
const USAGE: &str = "usage: herdr pane report-agent-session <pane_id> --source ID --agent LABEL [--seq N] [--agent-session-id ID] [--agent-session-path PATH] [--session-start-source SOURCE] [--origin-pid PID]";
let args = super::expand_equals_args(
args,
@@ -1297,6 +1297,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
"--agent-session-id",
"--agent-session-path",
"--session-start-source",
"--origin-pid",
],
);
let mut pane_id = None;
@@ -1306,10 +1307,23 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
let mut agent_session_id = None;
let mut agent_session_path = None;
let mut session_start_source = None;
let mut origin_pid = None;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--origin-pid" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --origin-pid");
return Ok(2);
};
origin_pid = Some(
super::parse_u64_flag("--origin-pid", value)?
.try_into()
.map_err(|_| std::io::Error::other("invalid origin pid"))?,
);
index += 2;
}
"--source" => {
let Some(value) = args.get(index + 1) else {
eprintln!("missing value for --source");
@@ -1392,6 +1406,7 @@ fn pane_report_agent_session(args: &[String]) -> std::io::Result<i32> {
super::send_ok_request(Method::PaneReportAgentSession(
PaneReportAgentSessionParams {
pane_id,
origin_pid,
source,
agent,
seq,
+1
View File
@@ -658,6 +658,7 @@ fn report_agent_session_command() -> Command {
.arg(option("agent-session-id", "ID"))
.arg(path_option("agent-session-path", "PATH"))
.arg(option("session-start-source", "SOURCE"))
.arg(option("origin-pid", "PID"))
}
fn release_agent_command() -> Command {
+8 -2
View File
@@ -564,7 +564,7 @@ fn fallback_explain(
DetectionExplain {
agent: agent.map(|agent| agent_label(agent).to_string()),
state: if known_agent {
state: if known_agent && agent != Some(Agent::Codex) {
AgentState::Idle
} else {
AgentState::Unknown
@@ -577,7 +577,13 @@ fn fallback_explain(
visible_working: false,
skip_state_update: false,
skipped_update_reason: None,
fallback_reason: known_agent.then(|| DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()),
fallback_reason: known_agent.then(|| {
if agent == Some(Agent::Codex) {
"codex_turn_state_unavailable".to_string()
} else {
DEFAULT_KNOWN_AGENT_IDLE_FALLBACK.to_string()
}
}),
evaluated_rules,
warning,
manifest_version,
+7 -7
View File
@@ -89,16 +89,16 @@ fn write_local_codex(content: &str) {
}
#[test]
fn known_agent_no_match_defaults_to_idle_fallback() {
fn codex_no_match_requires_turn_evidence() {
with_manifest_dirs("no-match", || {
write_local_codex(&local_manifest("working", "active-marker"));
let explain = explain(Agent::Codex, "unmatched-marker");
assert_eq!(explain.state, AgentState::Idle);
assert_eq!(explain.state, AgentState::Unknown);
assert!(!explain.visible_idle);
assert_eq!(
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
Some("codex_turn_state_unavailable")
);
});
}
@@ -184,10 +184,10 @@ fn fallback_explain_preserves_active_manifest_version() {
let explain = explain(Agent::Codex, "ordinary prompt text");
assert_eq!(explain.state, AgentState::Idle);
assert_eq!(explain.state, AgentState::Unknown);
assert_eq!(
explain.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
Some("codex_turn_state_unavailable")
);
assert_eq!(explain.manifest_version.as_deref(), Some("9999.01.01.1"));
assert!(matches!(
@@ -267,10 +267,10 @@ fn detection_uses_cached_manifest_until_explicit_reload() {
write_remote_codex_without_reload(&remote_manifest("9999.01.01.2", "working", "new-ready"));
let unchanged = explain(Agent::Codex, "new-ready");
assert_eq!(unchanged.state, AgentState::Idle);
assert_eq!(unchanged.state, AgentState::Unknown);
assert_eq!(
unchanged.fallback_reason.as_deref(),
Some(DEFAULT_KNOWN_AGENT_IDLE_FALLBACK)
Some("codex_turn_state_unavailable")
);
assert_eq!(
unchanged.cached_remote_version.as_deref(),
+34 -14
View File
@@ -1,7 +1,7 @@
id = "codex"
version = "2026.09.22.1"
version = "2026.09.23.1"
min_engine_version = 3
updated_at = "2026-09-22T00:00:00Z"
updated_at = "2026-09-23T00:00:00Z"
[[rules]]
id = "osc_title_blocked"
@@ -42,6 +42,38 @@ all = [
{ regex = ['(?s)Do\s+you\s+trust\s+the\s+contents\s+of\s+this\s+directory\?'] },
]
[[rules]]
id = "folder_access"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Folder access", "Trust this folder?", "Trust and continue"]
[[rules]]
id = "sign_in"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Welcome to Codex", "Sign in with ChatGPT", "Sign in with Device Code"]
[[rules]]
id = "hooks_review"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Hooks need review", "Review hooks", "Trust all and continue"]
[[rules]]
id = "windows_sandbox_setup"
state = "blocked"
priority = 950
region = "bottom_non_empty_lines(20)"
visible_blocker = true
contains = ["Set up the Codex agent sandbox", "Use non-admin sandbox"]
[[rules]]
id = "startup_update"
state = "blocked"
@@ -92,15 +124,3 @@ any = [{ contains = [" to interrupt)"] }, { contains = ["s)"] }]
regex = ['(?m)^(?:[•◦][ \t]+)?[^\s›•◦■✗✓─][^\r\n]* \((?:[0-9]+[hm] )*[0-9]+s(?: • [^\r\n]+? to interrupt)?\)(?: · [^\r\n]*)?(?:\r?\n(?:[^•◦›■✗✓─\r\n][^\r\n]*|•[ \t]+(?:Queued\s+follow-up\s+inputs|Messages\s+to\s+be\s+submitted\s+after\s+next\s+tool\s+call(?:\s+\(press\s+[^\r\n]+?\s+to\s+interrupt\s+and\s+send\s+immediately\))?|Messages\s+to\s+be\s+submitted\s+at\s+end\s+of\s+turn)|›[⠁⠂⠄⠈⠐⠠⡀⢀][^\r\n]*)?)*\s*\z']
# A failed reconnect keeps its final elapsed timer but is no longer working.
not = [{ line_regex = ['^(?:[•◦][ \t]+)?Reconnect failed — check the endpoint, then relaunch \([0-9hms ]+\)$'] }]
[[rules]]
id = "osc_title_idle"
state = "idle"
priority = 100
region = "osc_title"
visible_idle = true
regex = ['\S']
not = [
{ regex = ['(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)'] },
{ contains = ["Action Required"] },
]
+9
View File
@@ -117,6 +117,15 @@ pub enum AppEvent {
seq: Option<u64>,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
session_start_source: Option<String>,
transcript_path: Option<std::path::PathBuf>,
},
/// A validated terminal-owned Codex transcript advanced or became unavailable.
CodexTurnObserved(crate::terminal::codex::Observation),
/// Codex displayed its input composer during managed startup.
CodexPromptReady {
pane_id: PaneId,
ready: bool,
observed_at: Instant,
},
/// Display-only agent metadata was reported for a pane.
HookMetadataReported {
+2
View File
@@ -29,6 +29,8 @@ pub(crate) struct HandoffRuntimeState {
pub initial_history_ansi: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_state: Option<crate::terminal::state::HandoffAgentState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_session: Option<crate::terminal::codex::HandoffSession>,
}
#[cfg(unix)]
@@ -2,7 +2,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=codex
# HERDR_INTEGRATION_VERSION=8
# HERDR_INTEGRATION_VERSION=9
param([string]$Action = "")
@@ -38,7 +38,11 @@ try {
"--seq",
"$seq",
"--agent-session-id",
"$sessionId"
"$sessionId",
"--agent-session-path",
"$($payload.transcript_path)",
"--origin-pid",
"$PID"
)
if ($payload.hook_event_name -eq "SessionStart" -and $payload.source -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.source)) {
$args += @("--session-start-source", "$($payload.source)")
@@ -3,7 +3,7 @@
# managed by herdr; reinstalling or updating the integration overwrites this file.
# add custom hooks beside this file instead of editing it.
# HERDR_INTEGRATION_ID=codex
# HERDR_INTEGRATION_VERSION=8
# HERDR_INTEGRATION_VERSION=9
set -eu
@@ -72,6 +72,8 @@ if agent_session_id:
"agent": "codex",
"seq": report_seq,
"agent_session_id": agent_session_id,
"agent_session_path": transcript_path,
"origin_pid": os.getpid(),
}
if session_start_source:
params["session_start_source"] = session_start_source
@@ -83,16 +85,18 @@ if agent_session_id:
else:
raise SystemExit(0)
try:
def send(request):
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout(0.5)
client.connect(socket_path)
client.sendall((json.dumps(request) + "\n").encode())
try:
client.recv(4096)
except Exception:
pass
client.close()
return json.loads(client.makefile("r").readline())
finally:
client.close()
try:
send(request)
except Exception:
pass
PY
+1 -1
View File
@@ -60,7 +60,7 @@ const CODEX_HOOK_ASSET: &str = if cfg!(windows) {
} else {
include_str!("assets/codex/herdr-agent-state.sh")
};
const CODEX_INTEGRATION_VERSION: u32 = 8;
const CODEX_INTEGRATION_VERSION: u32 = 9;
const KIMI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
"herdr-agent-state.ps1"
} else {
+1 -1
View File
@@ -1304,7 +1304,7 @@ fn codex_v2_integration_status_is_outdated() {
assert_eq!(codex.path, hook_path);
assert_eq!(codex.installed_version, Some(2));
assert_eq!(codex.expected_version, 8);
assert!(codex.expected_version > codex.installed_version.unwrap());
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
std::env::remove_var("HOME");
+79 -2
View File
@@ -1,4 +1,4 @@
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::io;
use std::path::Path;
use std::sync::{
@@ -34,7 +34,7 @@ mod terminal;
mod xtgettcap;
use self::agent_detection::{
decide_detection_screen_read, decide_screen_detection_publish,
codex_prompt_ready, decide_detection_screen_read, decide_screen_detection_publish,
detection_update_for_publish_with_osc, mark_detection_content_changed,
observe_detection_content_change, DetectionPublishDecision, DetectionScreenReadDecision,
DetectionScreenReadInput, PendingIdleConfirmation, ScreenDetectionPublishInput,
@@ -771,6 +771,7 @@ fn spawn_basic_detection_task(
let mut last_screen_scan_detection_content_seq = None;
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
let mut codex_prompt_reported = false;
loop {
let sleep_duration = if pending_idle.active() {
@@ -788,6 +789,7 @@ fn spawn_basic_detection_task(
last_visible_working = false;
last_visible_signal_refresh = None;
last_process_check = std::time::Instant::now();
codex_prompt_reported = false;
last_foreground_pgid = None;
has_process_probe = false;
acquisition_started_at = None;
@@ -887,6 +889,7 @@ fn spawn_basic_detection_task(
|| foreground_action
== ForegroundShellAgentAction::ReportReplacementProcess;
if agent_changed {
codex_prompt_reported = false;
pending_idle.clear();
last_screen_scan_detection_content_seq = None;
// A replacement agent must not inherit OSC evidence
@@ -962,6 +965,16 @@ fn spawn_basic_detection_task(
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if !process_exited && crate::detect::should_skip_state_update(agent, &content) {
if codex_prompt_reported {
codex_prompt_reported = false;
let _ = state_events
.send(AppEvent::CodexPromptReady {
pane_id,
ready: false,
observed_at: now,
})
.await;
}
pending_idle.clear();
continue;
}
@@ -987,6 +1000,20 @@ fn spawn_basic_detection_task(
pending_idle.clear();
continue;
};
let codex_ready = agent == Some(Agent::Codex)
&& !process_exited
&& screen_detection.state != AgentState::Blocked
&& codex_prompt_ready(&content);
if codex_ready != codex_prompt_reported {
codex_prompt_reported = codex_ready;
let _ = state_events
.send(AppEvent::CodexPromptReady {
pane_id,
ready: codex_ready,
observed_at: now,
})
.await;
}
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
screen_detection,
@@ -1297,6 +1324,7 @@ pub struct PaneRuntime {
// Task handles for deterministic shutdown
compression: TerminalCompressionTask,
detect_handle: Option<tokio::task::AbortHandle>,
codex_observer: RefCell<Option<crate::terminal::codex::Observer>>,
}
enum PaneRuntimeIo {
@@ -1479,6 +1507,7 @@ pub enum WheelRouting {
impl Drop for PaneRuntime {
fn drop(&mut self) {
self.codex_observer.get_mut().take();
// Abort detection task immediately and terminate the owned session.
// The PTY actor shuts down before the process/session policy runs.
if let Some(handle) = &self.detect_handle {
@@ -2089,6 +2118,7 @@ impl PaneRuntime {
terminal_title: self.terminal_title(),
initial_history_ansi: None,
agent_state: None,
codex_session: None,
}
}
@@ -2300,6 +2330,7 @@ impl PaneRuntime {
terminal_title,
initial_history_ansi,
agent_state: _,
codex_session: _,
} = state;
let pane_id = PaneId::from_raw(pane_id);
use std::os::fd::FromRawFd;
@@ -2453,6 +2484,7 @@ impl PaneRuntime {
preserve_processes_on_drop: true,
compression,
detect_handle: Some(detect_handle),
codex_observer: RefCell::new(None),
})
}
@@ -2660,6 +2692,7 @@ impl PaneRuntime {
let mut last_screen_scan_detection_content_seq = None;
let mut agent_startup_grace_until = None;
let mut pending_idle = PendingIdleConfirmation::default();
let mut codex_prompt_reported = false;
tokio::time::sleep(Duration::from_millis(50)).await;
@@ -2691,6 +2724,7 @@ impl PaneRuntime {
foreground_shell_exit_reported = false;
release_was_active = false;
pending_restore_probe = false;
codex_prompt_reported = false;
last_visible_blocker = false;
last_visible_working = false;
last_visible_signal_refresh = None;
@@ -2836,6 +2870,7 @@ impl PaneRuntime {
agent_startup_grace_until =
Some(now + AGENT_STARTUP_GRACE_WINDOW);
state = AgentState::Unknown;
codex_prompt_reported = false;
last_visible_idle = false;
last_visible_blocker = false;
last_visible_working = false;
@@ -2931,6 +2966,16 @@ impl PaneRuntime {
let content_changed = content != last_detection_text;
last_detection_text.clone_from(&content);
if detect::should_skip_state_update(agent, &content) {
if codex_prompt_reported {
codex_prompt_reported = false;
let _ = state_events
.send(AppEvent::CodexPromptReady {
pane_id,
ready: false,
observed_at: now,
})
.await;
}
pending_idle.clear();
continue;
}
@@ -2956,6 +3001,20 @@ impl PaneRuntime {
pending_idle.clear();
continue;
};
let codex_ready = agent == Some(Agent::Codex)
&& !process_exited
&& screen_detection.state != AgentState::Blocked
&& codex_prompt_ready(&content);
if codex_ready != codex_prompt_reported {
codex_prompt_reported = codex_ready;
let _ = state_events
.send(AppEvent::CodexPromptReady {
pane_id,
ready: codex_ready,
observed_at: now,
})
.await;
}
match decide_screen_detection_publish(
ScreenDetectionPublishInput {
screen_detection,
@@ -3031,6 +3090,7 @@ impl PaneRuntime {
preserve_processes_on_drop: false,
compression,
detect_handle,
codex_observer: RefCell::new(None),
})
}
@@ -3044,6 +3104,20 @@ impl PaneRuntime {
self.detect_reset_notify.notify_one();
}
pub(crate) fn sync_codex_observer(
&self,
registration: Option<&crate::terminal::codex::Registration>,
events: &mpsc::Sender<AppEvent>,
) {
let mut observer = self.codex_observer.borrow_mut();
if observer.as_ref().map(|observer| &observer.registration) == registration {
return;
}
*observer = registration.map(|registration| {
crate::terminal::codex::Observer::start(registration.clone(), events.clone())
});
}
pub fn reset_agent_detection(&self) {
self.detect_reset_notify.notify_one();
}
@@ -3738,6 +3812,7 @@ impl PaneRuntime {
preserve_processes_on_drop: true,
compression,
detect_handle: Some(tokio::spawn(async {}).abort_handle()),
codex_observer: RefCell::new(None),
},
rx,
)
@@ -4906,6 +4981,7 @@ mod tests {
preserve_processes_on_drop: true,
compression,
detect_handle: Some(tokio::spawn(async {}).abort_handle()),
codex_observer: RefCell::new(None),
};
assert!(runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained));
@@ -4945,6 +5021,7 @@ mod tests {
preserve_processes_on_drop: true,
compression,
detect_handle: Some(tokio::spawn(async {}).abort_handle()),
codex_observer: RefCell::new(None),
};
assert!(!runtime.try_send_focus_event(crate::ghostty::FocusEvent::Gained));
+33 -4
View File
@@ -78,7 +78,7 @@ impl PendingIdleConfirmation {
}
#[derive(Debug, Clone, Copy)]
pub(super) struct IdleScreenScanSkipInput {
pub(super) struct UnchangedScreenScanSkipInput {
pub(super) state: AgentState,
pub(super) agent: Option<Agent>,
pub(super) pending_idle_active: bool,
@@ -88,8 +88,9 @@ pub(super) struct IdleScreenScanSkipInput {
pub(super) last_screen_scan_detection_content_seq: Option<u64>,
}
pub(super) fn should_skip_idle_screen_scan(input: IdleScreenScanSkipInput) -> bool {
if input.state != AgentState::Idle
pub(super) fn should_skip_unchanged_screen_scan(input: UnchangedScreenScanSkipInput) -> bool {
if !(input.state == AgentState::Idle
|| (input.state == AgentState::Unknown && input.agent == Some(Agent::Codex)))
|| input.agent.is_none()
|| input.pending_idle_active
|| input.agent_changed
@@ -122,7 +123,7 @@ pub(super) struct DetectionScreenReadInput {
pub(super) fn decide_detection_screen_read(
input: DetectionScreenReadInput,
) -> DetectionScreenReadDecision {
if should_skip_idle_screen_scan(IdleScreenScanSkipInput {
if should_skip_unchanged_screen_scan(UnchangedScreenScanSkipInput {
state: input.state,
agent: input.agent,
pending_idle_active: input.pending_idle_active,
@@ -316,6 +317,22 @@ pub(super) fn detection_update_for_publish_with_osc(
(!detection.skip_state_update).then_some(detection)
}
pub(super) fn codex_prompt_ready(content: &str) -> bool {
// The composer is also visible during a turn. This is only startup
// readiness evidence; it must never classify a turn as idle.
!content.contains("model: loading")
&& !content
.lines()
.rev()
.take(12)
.any(|line| line.contains("Resuming session"))
&& content
.lines()
.rev()
.take(8)
.any(|line| line.trim() == "› Ask Codex to do anything")
}
pub(super) fn observe_detection_content_change(bytes: &[u8], detection_content_seq: &AtomicU64) {
if !bytes.is_empty() {
detection_content_seq.fetch_add(1, Ordering::Relaxed);
@@ -387,6 +404,18 @@ mod tests {
);
}
#[test]
fn screen_read_skips_unchanged_codex_unknown_but_reads_changed_content() {
assert_eq!(
decide_detection_screen_read(screen_read_input(AgentState::Unknown, 10)),
DetectionScreenReadDecision::Skip
);
assert_eq!(
decide_detection_screen_read(screen_read_input(AgentState::Unknown, 11)),
DetectionScreenReadDecision::Read
);
}
#[test]
fn screen_read_reads_when_idle_bottom_buffer_changes() {
assert_eq!(
+20 -1
View File
@@ -565,6 +565,10 @@ fn restore_tab(
let handoff_agent_state = imported_runtime
.as_ref()
.and_then(|imported| imported.state.agent_state.clone());
#[cfg(unix)]
let handoff_codex_session = imported_runtime
.as_ref()
.and_then(|imported| imported.state.codex_session.clone());
let pending_native_agent_restore = if was_imported {
None
} else {
@@ -689,7 +693,11 @@ fn restore_tab(
if let Some(agent) = initial_restore_agent {
let _ = terminal.set_detected_state_with_screen_signals_at(
Some(agent),
AgentState::Idle,
if agent == crate::detect::Agent::Codex {
AgentState::Unknown
} else {
AgentState::Idle
},
false,
false,
false,
@@ -701,6 +709,17 @@ fn restore_tab(
if let Some(agent_state) = handoff_agent_state {
terminal.restore_handoff_agent_state(agent_state);
}
#[cfg(unix)]
if let Some(session) = handoff_codex_session {
terminal.restore_codex_session(session);
runtime.sync_codex_observer(
terminal
.codex_session
.as_ref()
.map(|session| &session.registration),
&runtime_context.events,
);
}
panes.insert(*id, PaneState::new(terminal_id.clone()));
terminal_runtimes.insert(terminal_id, runtime);
terminals.push(terminal);
+4
View File
@@ -197,6 +197,10 @@ pub fn foreground_job(_child_pid: u32) -> Option<ForegroundJob> {
None
}
pub fn process_is_descendant_of(_pid: u32, _ancestor_pid: u32) -> bool {
false
}
/// Unsupported platform stub.
pub fn foreground_group_leader_job(_process_group_id: u32) -> Option<ForegroundJob> {
None
+30
View File
@@ -659,6 +659,36 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
(tpgid > 0).then_some(tpgid as u32)
}
pub fn process_is_descendant_of(pid: u32, ancestor_pid: u32) -> bool {
fn parent_and_start(pid: u32) -> Option<(u32, u64)> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let rest = stat.get(stat.rfind(')')? + 2..)?;
let fields: Vec<&str> = rest.split_whitespace().collect();
if matches!(*fields.first()?, "Z" | "X") {
return None;
}
Some((fields.get(1)?.parse().ok()?, fields.get(19)?.parse().ok()?))
}
let Some((_, ancestor_start)) = parent_and_start(ancestor_pid) else {
return false;
};
let mut current = pid;
let mut visited = HashSet::new();
let mut child_start = None;
while visited.insert(current) {
let Some((parent, start)) = parent_and_start(current) else {
return false;
};
child_start.get_or_insert(start);
if parent == ancestor_pid {
return ancestor_start <= child_start.unwrap_or(start);
}
current = parent;
}
false
}
pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option<u32> {
let pgid = unsafe { libc::tcgetpgrp(fd) };
(pgid > 0).then_some(pgid as u32)
+22
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::ffi::OsStr;
use std::io::Write;
use std::os::fd::RawFd;
@@ -925,6 +926,27 @@ fn process_bsdinfo(pid: u32) -> Option<libc::proc_bsdinfo> {
(ret == size).then_some(info)
}
pub fn process_is_descendant_of(pid: u32, ancestor_pid: u32) -> bool {
let Some(ancestor) = process_bsdinfo(ancestor_pid) else {
return false;
};
let mut current = pid;
let mut visited = HashSet::new();
let mut child_start = None;
while visited.insert(current) {
let Some(info) = process_bsdinfo(current) else {
return false;
};
child_start.get_or_insert((info.pbi_start_tvsec, info.pbi_start_tvusec));
if info.pbi_ppid == ancestor_pid {
return (ancestor.pbi_start_tvsec, ancestor.pbi_start_tvusec)
<= child_start.unwrap_or((info.pbi_start_tvsec, info.pbi_start_tvusec));
}
current = info.pbi_ppid;
}
false
}
fn comm_from_bsdinfo(info: &libc::proc_bsdinfo) -> Option<String> {
let end = info
.pbi_comm
+5
View File
@@ -51,6 +51,11 @@ pub struct ForegroundJob {
pub processes: Vec<ForegroundProcess>,
}
#[cfg(not(windows))]
pub fn foreground_job_fresh(child_pid: u32) -> Option<ForegroundJob> {
foreground_job(child_pid)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Signal {
Hangup,
+63 -2
View File
@@ -1387,6 +1387,11 @@ pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
select_pane_foreground_job_cached(child_pid)
}
pub fn foreground_job_fresh(child_pid: u32) -> Option<ForegroundJob> {
let snapshot = fresh_foreground_processes();
select_pane_foreground_job_from_snapshot_uncached(child_pid, &snapshot)
}
pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
let snapshot = ProcessSnapshot::new(snapshot_processes());
available_pane_shell_from_snapshot(child_pid, &snapshot)
@@ -1650,6 +1655,34 @@ fn fresh_foreground_processes() -> Arc<ProcessSnapshot> {
cache.snapshot(Duration::ZERO, snapshot_processes)
}
pub fn process_is_descendant_of(pid: u32, ancestor_pid: u32) -> bool {
let snapshot = fresh_foreground_processes();
let Some(process) = snapshot.entry(pid) else {
return false;
};
let Some(ancestor) = snapshot.entry(ancestor_pid) else {
return false;
};
let Some(process_identity) = ProcessIdentity::open(pid) else {
return false;
};
let Some(ancestor_identity) = ProcessIdentity::open(ancestor_pid) else {
return false;
};
let (Some(process_start), Some(ancestor_start)) = (
process_identity.creation_time(),
ancestor_identity.creation_time(),
) else {
return false;
};
process_identity.running()
&& ancestor_identity.running()
&& process.command().creation_time == Some(process_start)
&& ancestor.command().creation_time == Some(ancestor_start)
&& ancestor_start <= process_start
&& process_is_ancestor(ancestor_pid, pid, &snapshot)
}
fn prepare_cached_foreground_selection(
shell_pid: u32,
snapshot: &ProcessSnapshot,
@@ -3633,6 +3666,20 @@ mod tests {
assert_eq!(job.processes[0].name, "codex.exe");
}
#[test]
fn hook_origin_must_descend_from_current_codex_not_shared_pane_shell() {
let snapshot = super::ProcessSnapshot::new(vec![
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
test_entry(20, 10, "codex.exe", &["codex.exe"]),
test_entry(21, 20, "powershell.exe", &["powershell.exe"]),
test_entry(30, 10, "codex.exe", &["codex.exe"]),
test_entry(31, 30, "powershell.exe", &["powershell.exe"]),
]);
assert!(super::process_is_ancestor(10, 21, &snapshot));
assert!(!super::process_is_ancestor(30, 21, &snapshot));
assert!(super::process_is_ancestor(30, 31, &snapshot));
}
#[test]
fn windows_process_tree_still_inspects_unusual_escaped_argv0() {
let snapshot = super::ProcessSnapshot::new(vec![
@@ -3858,13 +3905,27 @@ mod tests {
});
let refreshed = cache.snapshot(Duration::ZERO, || {
builds += 1;
vec![test_entry(20, 1, "pwsh.exe", &["pwsh.exe"])]
vec![
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
test_entry(20, 10, "codex.exe", &["codex.exe"]),
]
});
assert!(Arc::ptr_eq(&first, &second));
assert!(!Arc::ptr_eq(&second, &refreshed));
assert_eq!(builds, 2);
assert_eq!(refreshed.entries[0].pid, 20);
assert_eq!(
super::select_pane_foreground_job_from_snapshot_uncached(10, &second)
.unwrap()
.process_group_id,
10
);
assert_eq!(
super::select_pane_foreground_job_from_snapshot_uncached(10, &refreshed)
.unwrap()
.process_group_id,
20
);
}
#[test]
+9
View File
@@ -3368,6 +3368,15 @@ impl HeadlessServer {
// No resize polling needed — server has no terminal.
// Client resize messages drive size changes instead.
if self
.app
.state
.next_managed_agent_deadline()
.is_some_and(|deadline| now >= deadline)
{
changed |= self.app.reconcile_due_managed_agents_at(now);
}
if self
.app
.config_diagnostic_deadline
+6
View File
@@ -83,6 +83,12 @@ impl HeadlessServer {
continue;
};
let mut handoff_runtime = runtime.handoff_runtime_state(pane_id);
handoff_runtime.codex_session = self
.app
.state
.terminals
.get(terminal_id)
.and_then(|terminal| terminal.handoff_codex_session());
handoff_runtime.agent_state = self
.app
.state
+154
View File
@@ -5745,6 +5745,46 @@ fn headless_scheduled_tasks_clears_disabled_agent_manifest_update_deadline() {
assert_eq!(server.app.next_agent_manifest_update_check, None);
}
#[test]
fn headless_scheduled_tasks_finish_codex_readiness_after_blocker() {
let mut server = test_headless_server();
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("codex-ready")];
server.app.state.ensure_test_terminals();
let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = server.app.state.workspaces[0].panes[&pane_id]
.attached_terminal_id
.clone();
let at = Instant::now();
let terminal = server.app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.begin_managed_agent(
"codex-ready".into(),
crate::detect::Agent::Codex,
at,
Duration::ZERO,
Duration::from_secs(60),
);
terminal.set_detected_state(
Some(crate::detect::Agent::Codex),
crate::detect::AgentState::Blocked,
);
assert!(terminal.reconcile_managed_agent_at(at, false));
terminal.set_detected_state(
Some(crate::detect::Agent::Codex),
crate::detect::AgentState::Unknown,
);
terminal.observe_codex_prompt(true, at).unwrap();
assert!(terminal.managed_agent_launch_pending());
assert_eq!(
terminal.next_managed_agent_deadline(),
Some(at + Duration::from_secs(2))
);
assert!(server.handle_scheduled_tasks_headless(at + Duration::from_secs(2), false));
let terminal = &server.app.state.terminals[&terminal_id];
assert!(terminal.managed_agent_interactive_ready());
assert_eq!(terminal.next_managed_agent_deadline(), None);
}
#[cfg(unix)]
#[tokio::test]
async fn headless_scheduled_tasks_start_pending_agent_resume_without_foreground_client() {
@@ -7374,6 +7414,7 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() {
&mut server,
Method::PaneReportAgentSession(PaneReportAgentSessionParams {
pane_id: public_pane_id.clone(),
origin_pid: None,
source: "herdr:pi".into(),
agent: "pi".into(),
seq: Some(11),
@@ -7438,6 +7479,119 @@ fn completion_guard_api_session_replacement_does_not_notify_finished() {
}
}
#[test]
fn codex_session_report_requires_current_process_and_keeps_legacy_identity_only() {
let mut server = test_headless_server();
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("codex-report")];
server.app.state.ensure_test_terminals();
let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = server.app.state.workspaces[0].panes[&pane_id]
.attached_terminal_id
.clone();
let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap();
for (origin_pid, stale) in [(Some(std::process::id()), true), (None, false)] {
let (respond_to, response_rx) = std::sync::mpsc::channel();
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
request: api::schema::Request {
id: "codex-registration".into(),
method: api::schema::Method::PaneReportAgentSession(
api::schema::PaneReportAgentSessionParams {
pane_id: public_pane_id.clone(),
origin_pid,
source: "herdr:codex".into(),
agent: "codex".into(),
seq: None,
agent_session_id: Some("session".into()),
agent_session_path: Some(
std::env::temp_dir()
.join("session.jsonl")
.display()
.to_string(),
),
session_start_source: None,
},
),
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response: serde_json::Value =
serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
if stale {
assert_eq!(response["error"]["code"], "agent_session_stale");
assert!(server.app.state.terminals[&terminal_id]
.persisted_agent_session
.is_none());
} else {
assert!(response.get("result").is_some());
assert!(server.app.state.terminals[&terminal_id]
.persisted_agent_session
.is_some());
}
assert!(server.app.state.terminals[&terminal_id]
.codex_session
.is_none());
}
let terminal = server.app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.set_agent_session_ref_for_session_start(
"herdr:codex".into(),
"codex".into(),
crate::agent_resume::AgentSessionRef::id("current"),
None,
Some("startup".into()),
);
terminal.register_codex_transcript(std::env::temp_dir().join("current.jsonl"));
for origin_pid in [None, Some(std::process::id())] {
let (respond_to, response_rx) = std::sync::mpsc::channel();
server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
request: api::schema::Request {
id: "stale-codex-hook".into(),
method: api::schema::Method::PaneReportAgentSession(
api::schema::PaneReportAgentSessionParams {
pane_id: public_pane_id.clone(),
origin_pid,
source: "herdr:codex".into(),
agent: "codex".into(),
seq: None,
agent_session_id: Some("old".into()),
agent_session_path: Some(
std::env::temp_dir().join("old.jsonl").display().to_string(),
),
session_start_source: Some("startup".into()),
},
),
},
respond_to,
response_write_complete: None,
stream_active: None,
});
let response: serde_json::Value =
serde_json::from_str(&response_rx.recv().unwrap()).unwrap();
assert_eq!(response["error"]["code"], "agent_session_stale");
let terminal = &server.app.state.terminals[&terminal_id];
assert_eq!(
terminal
.codex_session
.as_ref()
.unwrap()
.registration
.session_id,
"current"
);
assert_eq!(
terminal
.persisted_agent_session
.as_ref()
.unwrap()
.session_ref
.value,
"current"
);
}
}
#[test]
fn startup_idle_does_not_forward_completion() {
let mut server = test_headless_server();
+546
View File
@@ -0,0 +1,546 @@
//! Codex turn boundaries from the transcript supplied by its SessionStart hook.
use std::io::{self, BufRead};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use super::TerminalId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Registration {
pub terminal_id: TerminalId,
pub generation: u64,
pub session_id: String,
pub path: PathBuf,
pub current_turn_hook: bool,
pub replay_after: Option<Turn>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum TurnPhase {
Active,
Completed,
Aborted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Turn {
pub id: String,
pub phase: TurnPhase,
}
#[derive(Debug, Clone)]
pub(crate) struct Session {
pub registration: Registration,
pub turn: Option<Turn>,
pub registered_at: Instant,
pub observed_at: Instant,
pub replayed_idle: bool,
}
#[cfg(unix)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct HandoffSession {
pub session_id: String,
pub path: PathBuf,
pub turn: Option<Turn>,
}
#[derive(Debug)]
pub(crate) struct Observation {
pub registration: Registration,
pub turn: Option<Turn>,
pub observed_at: Instant,
pub replay: bool,
pub unavailable: bool,
}
#[derive(Deserialize)]
#[serde(tag = "type", content = "payload")]
enum Record {
#[serde(rename = "session_meta")]
Session { id: String },
#[serde(rename = "event_msg")]
Event(Event),
#[serde(other)]
Other,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum Event {
#[serde(rename = "task_started")]
Started { turn_id: String },
#[serde(rename = "task_complete")]
Complete { turn_id: String },
#[serde(rename = "turn_aborted")]
Aborted { turn_id: String },
#[serde(other)]
Other,
}
struct Reader {
registration: Registration,
file: io::BufReader<std::fs::File>,
partial: Vec<u8>,
consumed: u64,
verified_session: bool,
turn: Option<Turn>,
}
impl Reader {
fn open(registration: Registration) -> io::Result<Self> {
let file = std::fs::File::open(&registration.path)?;
Ok(Self {
registration,
file: io::BufReader::new(file),
partial: Vec::new(),
consumed: 0,
verified_session: false,
turn: None,
})
}
fn read_available(&mut self, replay: bool) -> io::Result<Vec<(Turn, bool)>> {
if std::fs::metadata(&self.registration.path)?.len() < self.consumed {
return Err(io::Error::other("Codex transcript was truncated"));
}
let mut changes = Vec::new();
let mut last_start = None;
let mut reached_handoff_turn = self.registration.replay_after.is_none();
loop {
let read = self.file.read_until(b'\n', &mut self.partial)?;
self.consumed += read as u64;
if read == 0 || !self.partial.ends_with(b"\n") {
break;
}
let record = serde_json::from_slice::<Record>(&self.partial);
self.partial.clear();
let next = match record {
Ok(Record::Session { id }) => {
if id != self.registration.session_id {
return Err(io::Error::other("Codex transcript session does not match"));
}
self.verified_session = true;
None
}
Ok(Record::Event(Event::Started { turn_id })) => Some(Turn {
id: turn_id,
phase: TurnPhase::Active,
}),
Ok(Record::Event(Event::Complete { turn_id }))
if self.turn.as_ref().is_none_or(|turn| turn.id == turn_id) =>
{
Some(Turn {
id: turn_id,
phase: TurnPhase::Completed,
})
}
Ok(Record::Event(Event::Aborted { turn_id }))
if self.turn.as_ref().is_none_or(|turn| turn.id == turn_id) =>
{
Some(Turn {
id: turn_id,
phase: TurnPhase::Aborted,
})
}
_ => None,
};
if let Some(next) = next.filter(|next| Some(next) != self.turn.as_ref()) {
if next.phase == TurnPhase::Active {
last_start = Some(next.clone());
}
self.turn = Some(next.clone());
if replay && self.registration.replay_after.is_some() {
if reached_handoff_turn && self.verified_session {
changes.push((next, false));
} else if Some(&next) == self.registration.replay_after.as_ref() {
reached_handoff_turn = true;
}
} else if !replay && self.verified_session {
changes.push((next, false));
}
}
}
if replay && self.registration.replay_after.is_some() && !reached_handoff_turn {
return Err(io::Error::other(
"Saved Codex turn was not found in transcript",
));
}
if replay && self.verified_session && self.registration.replay_after.is_none() {
if self.registration.current_turn_hook {
// Codex records task_started before its synchronous SessionStart
// hook, including on resume; completion may race this first read.
if let Some(start) = last_start {
let terminal = self
.turn
.as_ref()
.filter(|turn| turn.id == start.id && turn.phase != TurnPhase::Active)
.cloned();
changes.push((start, false));
changes.extend(terminal.map(|turn| (turn, false)));
}
} else {
changes.extend(self.turn.clone().map(|turn| (turn, true)));
}
}
Ok(changes)
}
}
pub(crate) struct Observer {
pub registration: Registration,
task: tokio::task::AbortHandle,
}
impl Drop for Observer {
fn drop(&mut self) {
self.task.abort();
}
}
impl Observer {
pub fn start(
registration: Registration,
events: tokio::sync::mpsc::Sender<crate::events::AppEvent>,
) -> Self {
let task_registration = registration.clone();
let task = tokio::spawn(async move {
if let Err(error) = observe(task_registration.clone(), &events).await {
tracing::warn!(path = %task_registration.path.display(), %error, "Codex turn tracking stopped");
let _ = events
.send(crate::events::AppEvent::CodexTurnObserved(Observation {
registration: task_registration,
turn: None,
observed_at: Instant::now(),
replay: false,
unavailable: true,
}))
.await;
}
});
Self {
registration,
task: task.abort_handle(),
}
}
}
async fn observe(
registration: Registration,
events: &tokio::sync::mpsc::Sender<crate::events::AppEvent>,
) -> io::Result<()> {
let open_registration = registration.clone();
let mut reader = tokio::task::spawn_blocking(move || Reader::open(open_registration)).await??;
let mut replay = true;
loop {
// File work is independent of PTY parsing, screen detection, and rendering.
let (next_reader, changes) = tokio::task::spawn_blocking(move || {
let changes = reader.read_available(replay);
(reader, changes)
})
.await?;
reader = next_reader;
let observed_at = Instant::now();
for (turn, historical) in changes? {
if events
.send(crate::events::AppEvent::CodexTurnObserved(Observation {
registration: registration.clone(),
turn: Some(turn),
observed_at,
replay: historical,
unavailable: false,
}))
.await
.is_err()
{
return Ok(());
}
}
replay = false;
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn fixture() -> Registration {
let terminal_id = TerminalId::alloc();
Registration {
path: std::env::temp_dir().join(format!("herdr-codex-{terminal_id}.jsonl")),
terminal_id,
generation: 1,
session_id: "session".into(),
current_turn_hook: false,
replay_after: None,
}
}
fn event(kind: &str, id: &str) -> String {
format!(
r#"{{"type":"event_msg","payload":{{"type":"{kind}","turn_id":"{id}","extra":true}}}}"#
) + "\n"
}
const META: &str = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"session\"}}\n";
#[test]
fn handoff_replays_only_unapplied_turn_changes_in_order() {
let turn = |id: &str, phase| Turn {
id: id.into(),
phase,
};
for (saved, suffix, expected) in [
(turn("a", TurnPhase::Completed), "".into(), vec![]),
(
turn("a", TurnPhase::Completed),
format!(
"{}{}",
event("task_started", "b"),
event("task_complete", "b")
),
vec![
turn("b", TurnPhase::Active),
turn("b", TurnPhase::Completed),
],
),
(
turn("a", TurnPhase::Active),
format!(
"{}{}",
event("task_complete", "a"),
event("task_started", "b")
),
vec![
turn("a", TurnPhase::Completed),
turn("b", TurnPhase::Active),
],
),
(
turn("a", TurnPhase::Active),
format!(
"{}{}",
event("turn_aborted", "a"),
event("task_started", "b")
),
vec![turn("a", TurnPhase::Aborted), turn("b", TurnPhase::Active)],
),
] {
let mut registration = fixture();
registration.replay_after = Some(saved.clone());
let prefix = format!("{META}{}", event("task_started", "a"));
let prefix = if saved.phase == TurnPhase::Completed {
format!("{prefix}{}", event("task_complete", "a"))
} else {
prefix
};
std::fs::write(&registration.path, format!("{prefix}{suffix}")).unwrap();
let mut reader = Reader::open(registration.clone()).unwrap();
assert_eq!(
reader.read_available(true).unwrap(),
expected
.into_iter()
.map(|turn| (turn, false))
.collect::<Vec<_>>()
);
drop(reader);
std::fs::remove_file(registration.path).unwrap();
}
}
#[test]
fn replay_reduces_history_and_append_matches_turn_ids_and_partial_lines() {
let registration = fixture();
let history = [
META.into(),
event("task_started", "old"),
event("task_started", "current"),
]
.concat();
std::fs::write(&registration.path, history).unwrap();
let mut reader = Reader::open(registration.clone()).unwrap();
assert_eq!(
reader.read_available(true).unwrap(),
vec![(
Turn {
id: "current".into(),
phase: TurnPhase::Active,
},
true,
)]
);
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&registration.path)
.unwrap();
file.write_all(event("task_complete", "old").as_bytes())
.unwrap();
let abort = event("turn_aborted", "current");
file.write_all(&abort.as_bytes()[..abort.len() - 1])
.unwrap();
assert!(reader.read_available(false).unwrap().is_empty());
file.write_all(b"\n").unwrap();
assert_eq!(
reader.read_available(false).unwrap(),
vec![(
Turn {
id: "current".into(),
phase: TurnPhase::Aborted,
},
false,
)]
);
assert!(reader.read_available(false).unwrap().is_empty());
drop(file);
drop(reader);
std::fs::remove_file(registration.path).unwrap();
}
#[test]
fn live_hook_replay_keeps_only_its_current_start_and_outcome() {
for (event_name, phase) in [
("task_complete", TurnPhase::Completed),
("turn_aborted", TurnPhase::Aborted),
] {
let mut registration = fixture();
registration.current_turn_hook = true;
std::fs::write(
&registration.path,
format!(
"{META}{}{}{}{}",
event("task_started", "old"),
event("task_complete", "old"),
event("task_started", "current"),
event(event_name, "current"),
),
)
.unwrap();
let mut reader = Reader::open(registration.clone()).unwrap();
assert_eq!(
reader.read_available(true).unwrap(),
vec![
(
Turn {
id: "current".into(),
phase: TurnPhase::Active
},
false
),
(
Turn {
id: "current".into(),
phase
},
false
),
]
);
drop(reader);
std::fs::remove_file(registration.path).unwrap();
}
}
#[test]
fn empty_and_unrecognized_replay_have_no_state_and_wrong_session_is_rejected() {
let registration = fixture();
for contents in ["", "{\"type\":\"future_record\",\"payload\":{}}\n", META] {
std::fs::write(&registration.path, contents).unwrap();
let mut reader = Reader::open(registration.clone()).unwrap();
assert!(reader.read_available(true).unwrap().is_empty());
}
std::fs::write(&registration.path, META.replace("session\"", "another\"")).unwrap();
let mut reader = Reader::open(registration.clone()).unwrap();
assert!(reader.read_available(true).is_err());
drop(reader);
std::fs::remove_file(registration.path).unwrap();
}
#[tokio::test]
async fn observer_wakes_without_pty_output_and_releases_authority_on_read_failure() {
let registration = fixture();
std::fs::write(
&registration.path,
format!(
"{META}{}{}",
event("task_started", "old"),
event("task_complete", "old")
),
)
.unwrap();
let (sender, mut receiver) = tokio::sync::mpsc::channel(8);
let observer = Observer::start(registration.clone(), sender);
async fn receive(
receiver: &mut tokio::sync::mpsc::Receiver<crate::events::AppEvent>,
) -> Observation {
match tokio::time::timeout(Duration::from_secs(5), receiver.recv())
.await
.unwrap()
.unwrap()
{
crate::events::AppEvent::CodexTurnObserved(observation) => observation,
_ => panic!("unexpected event"),
}
}
let initial = receive(&mut receiver).await;
assert!(initial.replay);
assert_eq!(initial.turn.unwrap().phase, TurnPhase::Completed);
assert!(receiver.try_recv().is_err());
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(&registration.path)
.unwrap();
file.write_all(event("task_started", "new").as_bytes())
.unwrap();
let live = receive(&mut receiver).await;
assert!(!live.replay);
assert_eq!(live.turn.unwrap().phase, TurnPhase::Active);
drop(file);
std::fs::write(&registration.path, "").unwrap();
assert!(receive(&mut receiver).await.unavailable);
drop(observer);
std::fs::remove_file(registration.path).unwrap();
}
#[test]
#[ignore = "non-gating observer filesystem scaling profile"]
fn render_scale_profile_codex_observers() {
for count in [1, 15] {
let mut readers: Vec<_> = (0..count)
.map(|_| {
let registration = fixture();
std::fs::write(
&registration.path,
format!("{META}{}", event("task_started", "turn")),
)
.unwrap();
let mut reader = Reader::open(registration).unwrap();
reader.read_available(true).unwrap();
reader
})
.collect();
let mut samples = Vec::new();
for _ in 0..100 {
let start = Instant::now();
for reader in &mut readers {
std::hint::black_box(reader.read_available(false).unwrap());
}
samples.push(start.elapsed());
}
samples.sort_unstable();
println!(
"Codex observers={count}, idle poll median={:?}, polls per second=4",
samples[50]
);
for reader in readers {
let path = reader.registration.path.clone();
drop(reader);
std::fs::remove_file(path).unwrap();
}
}
}
}
+604
View File
@@ -0,0 +1,604 @@
use super::*;
use crate::terminal::codex::{Observation, Registration, Session, TurnPhase};
impl TerminalState {
pub(crate) fn needs_codex_process_acquisition(&self) -> bool {
self.detected_agent != Some(Agent::Codex) || self.recent_agent_process_exit.is_some()
}
#[cfg(unix)]
pub(crate) fn handoff_codex_session(&self) -> Option<crate::terminal::codex::HandoffSession> {
let session = self.codex_session.as_ref()?;
self.codex_session_matches_current()
.then(|| crate::terminal::codex::HandoffSession {
session_id: session.registration.session_id.clone(),
path: session.registration.path.clone(),
turn: session.turn.clone(),
})
}
#[cfg(unix)]
pub(crate) fn restore_codex_session(
&mut self,
snapshot: crate::terminal::codex::HandoffSession,
) {
if !self
.persisted_agent_session
.as_ref()
.is_some_and(|session| {
session.source == "herdr:codex"
&& session.agent == "codex"
&& session.session_ref.value == snapshot.session_id
})
{
return;
}
self.register_codex_transcript(snapshot.path);
if let Some(session) = self.codex_session.as_mut() {
session.registration.current_turn_hook = snapshot.turn.is_none();
session.registration.replay_after = snapshot.turn.clone();
session.replayed_idle = snapshot
.turn
.as_ref()
.is_some_and(|turn| turn.phase != TurnPhase::Active);
session.turn = snapshot.turn;
self.detected_agent = Some(Agent::Codex);
self.recompute_effective_state(
self.effective_agent_label().map(str::to_string),
self.effective_known_agent(),
self.state,
self.effective_presentation_for_state_at(self.state, Instant::now()),
Instant::now(),
);
}
}
pub(crate) fn register_codex_transcript(&mut self, path: PathBuf) {
let Some(session) = self
.persisted_agent_session
.as_ref()
.filter(|session| session.source == "herdr:codex" && session.agent == "codex")
else {
return;
};
if self.recent_agent_process_exit.is_some()
|| !path.is_absolute()
|| self.codex_session.as_ref().is_some_and(|current| {
current.registration.session_id == session.session_ref.value
&& current.registration.path == path
})
{
return;
}
self.codex_generation += 1;
self.codex_session = Some(Session {
registration: Registration {
terminal_id: self.id.clone(),
generation: self.codex_generation,
session_id: session.session_ref.value.clone(),
path,
current_turn_hook: true,
replay_after: None,
},
turn: None,
registered_at: Instant::now(),
observed_at: Instant::now(),
replayed_idle: false,
});
}
pub(crate) fn observe_codex_turn(
&mut self,
observation: Observation,
) -> Option<TerminalStateMutation> {
let current = self.codex_session.as_ref()?;
if current.registration != observation.registration
|| self.recent_agent_process_exit.is_some()
|| self
.detected_agent
.is_some_and(|agent| agent != Agent::Codex)
|| !self.codex_session_matches_current()
{
return None;
}
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
let now = observation.observed_at;
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let historical = observation.replay
&& !self.codex_session.as_ref().is_some_and(|session| {
session.turn.as_ref().is_some_and(|previous| {
previous.phase == TurnPhase::Active
&& observation.turn.as_ref().is_some_and(|next| {
next.id == previous.id && next.phase != TurnPhase::Active
})
})
});
if observation.unavailable {
self.codex_session = None;
} else if let Some(session) = self.codex_session.as_mut() {
session.replayed_idle = historical
&& observation
.turn
.as_ref()
.is_some_and(|turn| turn.phase != TurnPhase::Active);
session.turn = observation.turn;
session.observed_at = now;
// A live turn is new activity, not acquisition of historical state.
if !historical {
self.agent_process_acquisition_pending = false;
}
}
Some(TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
previous_agent_label,
previous_known_agent,
previous_state,
previous_presentation,
now,
),
..Default::default()
})
}
pub(super) fn codex_session_matches_current(&self) -> bool {
self.codex_session.as_ref().is_some_and(|codex| {
self.persisted_agent_session
.as_ref()
.is_some_and(|session| {
session.source == "herdr:codex"
&& session.agent == "codex"
&& session.session_ref.value == codex.registration.session_id
})
})
}
pub(super) fn codex_state(&self) -> Option<AgentState> {
if self.detected_agent != Some(Agent::Codex) || !self.codex_session_matches_current() {
return None;
}
let session = self.codex_session.as_ref()?;
let turn = session.turn.as_ref()?;
Some(if self.fallback_visible_blocker {
AgentState::Blocked
} else if turn.phase == TurnPhase::Active {
AgentState::Working
} else {
AgentState::Idle
})
}
pub(crate) fn observe_codex_prompt(
&mut self,
ready: bool,
observed_at: Instant,
) -> Option<TerminalStateMutation> {
if self.detected_agent != Some(Agent::Codex) || self.recent_agent_process_exit.is_some() {
return None;
}
if self
.codex_prompt_started_at
.is_some_and(|started| observed_at < started)
{
return None;
}
if ready && !self.codex_prompt_ready {
self.codex_prompt_started_at = Some(observed_at);
}
self.codex_prompt_ready = ready;
Some(TerminalStateMutation::default())
}
pub(super) fn managed_agent_prompt_ready(&self, agent: Agent, now: Instant) -> bool {
if agent == Agent::Codex {
self.codex_prompt_ready
&& self
.codex_prompt_started_at
.is_some_and(|at| now >= at + CODEX_PROMPT_SETTLE_DELAY)
&& self.state != AgentState::Blocked
} else {
self.state == AgentState::Idle
}
}
pub(crate) fn codex_turn_aborted_effective(&self) -> bool {
self.codex_state().is_some()
&& !self
.hook_authority
.as_ref()
.is_some_and(|authority| self.hook_authority_is_effective(authority))
&& self.codex_session.as_ref().is_some_and(|session| {
session
.turn
.as_ref()
.is_some_and(|turn| turn.phase == TurnPhase::Aborted)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::codex::Turn;
fn register(terminal: &mut TerminalState, id: &str) -> Registration {
terminal
.set_agent_session_ref_for_session_start(
"herdr:codex".into(),
"codex".into(),
crate::agent_resume::AgentSessionRef::id(id),
None,
Some("startup".into()),
)
.unwrap();
terminal.register_codex_transcript(std::env::temp_dir().join(format!("{id}.jsonl")));
terminal
.codex_session
.as_ref()
.unwrap()
.registration
.clone()
}
fn observe(
terminal: &mut TerminalState,
registration: &Registration,
active: bool,
replay: bool,
at: Instant,
) {
terminal
.observe_codex_turn(Observation {
registration: registration.clone(),
turn: Some(Turn {
id: "turn".into(),
phase: if active {
TurnPhase::Active
} else {
TurnPhase::Completed
},
}),
observed_at: at,
replay,
unavailable: false,
})
.unwrap();
}
fn screen(terminal: &mut TerminalState, state: AgentState, at: Instant) {
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Codex),
state,
state == AgentState::Blocked,
false,
state == AgentState::Working,
false,
at,
);
}
#[test]
fn codex_turn_owns_ambiguous_screen_and_preserves_blockers_and_event_order() {
let mut terminal = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
let at = Instant::now();
// SessionStart can precede process detection and must survive an unknown scan.
let registration = register(&mut terminal, "session");
terminal.set_detected_state(None, AgentState::Unknown);
observe(&mut terminal, &registration, true, true, at);
screen(&mut terminal, AgentState::Unknown, at);
assert_eq!(terminal.state, AgentState::Working);
screen(&mut terminal, AgentState::Blocked, at);
observe(
&mut terminal,
&registration,
false,
true,
at + Duration::from_millis(2),
);
assert_eq!(terminal.state, AgentState::Blocked);
screen(
&mut terminal,
AgentState::Unknown,
at + Duration::from_millis(3),
);
assert_eq!(terminal.state, AgentState::Idle);
assert!(!terminal.finish_agent_process_acquisition());
// An older queued screen event cannot undo completion.
screen(
&mut terminal,
AgentState::Working,
at + Duration::from_millis(1),
);
assert_eq!(terminal.state, AgentState::Idle);
screen(
&mut terminal,
AgentState::Working,
at + Duration::from_millis(4),
);
assert_eq!(terminal.state, AgentState::Idle);
observe(
&mut terminal,
&registration,
false,
false,
at + Duration::from_millis(5),
);
assert_eq!(terminal.state, AgentState::Idle);
assert!(!terminal.finish_agent_process_acquisition());
let mut terminal = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
screen(&mut terminal, AgentState::Unknown, at);
terminal.set_hook_authority_at(
"custom:codex".into(),
"codex".into(),
AgentState::Working,
None,
None,
None,
at + Duration::from_millis(6),
);
assert_eq!(terminal.state, AgentState::Working);
let registration = register(&mut terminal, "session");
observe(
&mut terminal,
&registration,
false,
true,
at + Duration::from_millis(7),
);
assert_eq!(terminal.state, AgentState::Working);
screen(
&mut terminal,
AgentState::Blocked,
at + Duration::from_millis(7),
);
assert_eq!(terminal.state, AgentState::Blocked);
terminal
.codex_session
.as_mut()
.unwrap()
.turn
.as_mut()
.unwrap()
.phase = TurnPhase::Aborted;
assert!(!terminal.codex_turn_aborted_effective());
}
#[test]
fn codex_registration_replacement_exit_and_failure_discard_stale_authority() {
let mut terminal = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
let at = Instant::now();
screen(&mut terminal, AgentState::Unknown, at);
let old = register(&mut terminal, "old");
observe(&mut terminal, &old, true, true, at);
assert_eq!(
register(&mut terminal, "old"),
old,
"compaction does not restart replay"
);
let current = register(&mut terminal, "new");
assert_ne!(old.generation, current.generation);
assert!(terminal
.observe_codex_turn(Observation {
registration: old,
turn: None,
observed_at: at,
replay: false,
unavailable: true
})
.is_none());
observe(&mut terminal, &current, false, true, at);
screen(
&mut terminal,
AgentState::Working,
at - Duration::from_millis(1),
);
assert_eq!(terminal.state, AgentState::Idle);
terminal
.observe_codex_turn(Observation {
registration: current.clone(),
turn: None,
observed_at: at + Duration::from_millis(2),
replay: false,
unavailable: true,
})
.unwrap();
assert_eq!(terminal.state, AgentState::Working);
assert!(terminal.codex_session.is_none());
let current = register(&mut terminal, "new");
assert!(current.generation > 1);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Codex),
AgentState::Unknown,
false,
false,
false,
true,
at + Duration::from_millis(3),
);
assert!(terminal.codex_session.is_none());
// A legacy identity report still cannot restore transcript authority after exit.
terminal.set_agent_session_ref_for_session_start(
"herdr:codex".into(),
"codex".into(),
crate::agent_resume::AgentSessionRef::id("new"),
None,
None,
);
terminal.register_codex_transcript(std::env::temp_dir().join("new.jsonl"));
assert!(terminal.codex_session.is_none());
// Guarded admission can acquire a verified replacement before the detector ticks.
assert!(terminal.needs_codex_process_acquisition());
terminal.set_detected_agent_process_at(Agent::Codex, at + Duration::from_millis(4));
let replacement = register(&mut terminal, "replacement");
assert!(!terminal.needs_codex_process_acquisition());
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Codex),
AgentState::Unknown,
false,
false,
false,
true,
at - Duration::from_millis(1),
);
assert_eq!(
terminal.codex_session.as_ref().unwrap().registration,
replacement
);
terminal.clear_agent_runtime_identity_after_respawn();
let replacement = register(&mut terminal, "new");
assert!(replacement.generation > current.generation);
}
#[test]
fn codex_managed_startup_requires_fresh_prompt_evidence_not_unknown_or_blocked() {
let mut terminal = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
let at = Instant::now();
terminal.begin_managed_agent(
"worker".into(),
Agent::Codex,
at,
Duration::ZERO,
Duration::from_secs(60),
);
screen(&mut terminal, AgentState::Unknown, at);
terminal.reconcile_managed_agent_at(at, false);
assert!(!terminal.managed_agent_interactive_ready());
assert!(terminal
.observe_codex_prompt(true, at - Duration::from_millis(1))
.is_none());
terminal.observe_codex_prompt(true, at).unwrap();
screen(&mut terminal, AgentState::Blocked, at);
terminal.observe_codex_prompt(false, at).unwrap();
terminal.reconcile_managed_agent_at(at, false);
assert!(!terminal.managed_agent_interactive_ready());
screen(&mut terminal, AgentState::Unknown, at);
terminal.reconcile_managed_agent_at(at, false);
assert!(!terminal.managed_agent_interactive_ready());
terminal.observe_codex_prompt(true, at).unwrap();
terminal.reconcile_managed_agent_at(at + Duration::from_secs(1), false);
assert!(!terminal.managed_agent_interactive_ready());
terminal.reconcile_managed_agent_at(at + Duration::from_secs(2), false);
assert!(terminal.managed_agent_interactive_ready());
assert_eq!(terminal.state, AgentState::Unknown);
let mut fast = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
fast.begin_managed_agent(
"fast".into(),
Agent::Codex,
at,
Duration::from_secs(3),
Duration::from_secs(60),
);
screen(&mut fast, AgentState::Unknown, at);
fast.observe_codex_prompt(true, at).unwrap();
assert_eq!(
fast.next_managed_agent_deadline(),
Some(at + Duration::from_secs(3))
);
fast.reconcile_managed_agent_at(at + Duration::from_secs(2), false);
assert_eq!(
fast.next_managed_agent_deadline(),
Some(at + Duration::from_secs(3))
);
fast.reconcile_managed_agent_at(at + Duration::from_secs(3), false);
assert!(fast.managed_agent_interactive_ready());
}
#[cfg(unix)]
#[test]
fn codex_handoff_rebinds_observer_to_new_terminal_identity() {
let mut original = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
screen(&mut original, AgentState::Unknown, Instant::now());
let old = register(&mut original, "session");
observe(&mut original, &old, true, false, Instant::now());
let mut restored = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
restored.persisted_agent_session = original.persisted_agent_session.clone();
restored.restore_codex_session(original.handoff_codex_session().unwrap());
let registration = restored
.codex_session
.as_ref()
.unwrap()
.registration
.clone();
assert_eq!(registration.terminal_id, restored.id);
assert_ne!(registration.terminal_id, old.terminal_id);
assert_eq!(restored.state, AgentState::Working);
assert!(restored
.observe_codex_turn(Observation {
registration: old,
turn: None,
observed_at: Instant::now(),
replay: false,
unavailable: true
})
.is_none());
restored
.observe_codex_turn(Observation {
registration: registration.clone(),
turn: Some(Turn {
id: "turn".into(),
phase: TurnPhase::Completed,
}),
observed_at: Instant::now(),
replay: true,
unavailable: false,
})
.unwrap();
assert_eq!(restored.state, AgentState::Idle);
assert!(!restored.codex_session.as_ref().unwrap().replayed_idle);
}
#[cfg(unix)]
#[test]
fn handoff_completion_after_saved_idle_survives_a_delayed_blocker() {
let at = Instant::now();
let mut original = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
screen(&mut original, AgentState::Unknown, at);
let old = register(&mut original, "session");
observe(&mut original, &old, false, false, at);
let mut restored = TerminalState::new(TerminalId::alloc(), std::env::temp_dir());
restored.persisted_agent_session = original.persisted_agent_session.clone();
restored.restore_codex_session(original.handoff_codex_session().unwrap());
let registration = restored
.codex_session
.as_ref()
.unwrap()
.registration
.clone();
assert_eq!(
registration.replay_after,
original.codex_session.unwrap().turn
);
screen(
&mut restored,
AgentState::Blocked,
at + Duration::from_millis(1),
);
for (phase, delay) in [(TurnPhase::Active, 2), (TurnPhase::Completed, 3)] {
restored
.observe_codex_turn(Observation {
registration: registration.clone(),
turn: Some(Turn {
id: "next".into(),
phase,
}),
observed_at: at + Duration::from_millis(delay),
replay: false,
unavailable: false,
})
.unwrap();
}
assert_eq!(restored.state, AgentState::Blocked);
screen(
&mut restored,
AgentState::Unknown,
at + Duration::from_millis(4),
);
assert_eq!(restored.state, AgentState::Idle);
assert!(!restored.codex_session.as_ref().unwrap().replayed_idle);
assert!(!restored.finish_agent_process_acquisition());
}
}
+1
View File
@@ -1,3 +1,4 @@
pub(crate) mod codex;
mod history_read;
mod id;
mod runtime;
+7
View File
@@ -17,6 +17,13 @@ use crate::layout::PaneId;
pub struct TerminalRuntime(crate::pane::PaneRuntime);
impl TerminalRuntime {
pub(crate) fn sync_codex_observer(
&self,
registration: Option<&super::codex::Registration>,
events: &mpsc::Sender<AppEvent>,
) {
self.0.sync_codex_observer(registration, events);
}
pub fn shutdown(self) {
self.0.shutdown();
}
+74 -13
View File
@@ -13,6 +13,9 @@ use crate::terminal::TerminalId;
#[path = "metadata.rs"]
mod metadata;
pub use metadata::{AgentMetadata, AgentMetadataReport, EffectivePresentation};
#[path = "codex_state.rs"]
mod codex_state;
const CODEX_PROMPT_SETTLE_DELAY: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HookAuthority {
@@ -133,6 +136,10 @@ pub struct TerminalState {
pub fallback_state: AgentState,
fallback_visible_blocker: bool,
fallback_observed_at: Option<Instant>,
pub(crate) codex_session: Option<super::codex::Session>,
codex_generation: u64,
codex_prompt_ready: bool,
codex_prompt_started_at: Option<Instant>,
pub hook_authority: Option<HookAuthority>,
pub agent_metadata: HashMap<String, AgentMetadata>,
pub metadata_tokens: crate::metadata_tokens::MetadataTokens,
@@ -170,6 +177,10 @@ impl TerminalState {
fallback_state: AgentState::Unknown,
fallback_visible_blocker: false,
fallback_observed_at: None,
codex_session: None,
codex_generation: 0,
codex_prompt_ready: false,
codex_prompt_started_at: None,
hook_authority: None,
agent_metadata: HashMap::new(),
metadata_tokens: crate::metadata_tokens::MetadataTokens::default(),
@@ -249,7 +260,17 @@ impl TerminalState {
pub(crate) fn finish_agent_process_acquisition(&mut self) -> bool {
let reached_idle = self.agent_process_acquisition_pending && self.state == AgentState::Idle;
let suppress_completion = reached_idle && self.recent_agent_process_exit.is_none();
let suppress_completion = (reached_idle
|| (self.state == AgentState::Idle
&& !self
.hook_authority
.as_ref()
.is_some_and(|authority| self.hook_authority_is_effective(authority))
&& self
.codex_session
.as_ref()
.is_some_and(|session| session.replayed_idle)))
&& self.recent_agent_process_exit.is_none();
if reached_idle {
self.agent_process_acquisition_pending = false;
}
@@ -359,6 +380,16 @@ impl TerminalState {
process_exited: bool,
now: Instant,
) -> TerminalStateMutation {
if (process_exited || agent.is_some_and(|agent| agent != Agent::Codex))
&& self
.codex_session
.as_ref()
.is_some_and(|session| session.registered_at > now)
{
// A guarded SessionStart can acquire a replacement before an old
// detector event leaves the queue.
return TerminalStateMutation::default();
}
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
@@ -419,6 +450,10 @@ impl TerminalState {
};
}
self.detected_agent = agent;
if process_exited || agent.is_some_and(|agent| agent != Agent::Codex) {
self.codex_session = None;
self.codex_prompt_ready = false;
}
if let Some(agent) = agent {
let agent_label = crate::detect::agent_label(agent);
self.reconcile_agent_name_owner(agent_label, None);
@@ -1972,6 +2007,8 @@ impl TerminalState {
settle_delay: Duration,
timeout: Duration,
) {
self.codex_prompt_ready = false;
self.codex_prompt_started_at = Some(now);
self.set_agent_name(name);
self.agent_process_acquisition_pending = true;
self.agent_name_owner = Some(AgentNameOwner {
@@ -2007,15 +2044,30 @@ impl TerminalState {
}
pub fn next_managed_agent_deadline(&self) -> Option<Instant> {
let ManagedAgentPhase::Pending {
ready_after,
deadline,
..
} = self.managed_agent?.phase
else {
return None;
};
Some(ready_after.unwrap_or(deadline).min(deadline))
let managed = self.managed_agent?;
let prompt_deadline = (managed.kind == Agent::Codex
&& self.codex_prompt_ready
&& self.state != AgentState::Blocked
&& self.effective_known_agent() == Some(Agent::Codex))
.then(|| {
self.codex_prompt_started_at
.and_then(|at| at.checked_add(CODEX_PROMPT_SETTLE_DELAY))
})
.flatten();
match managed.phase {
ManagedAgentPhase::Blocked => prompt_deadline,
ManagedAgentPhase::Pending {
ready_after,
deadline,
..
} => Some(
ready_after
.or(prompt_deadline)
.unwrap_or(deadline)
.min(deadline),
),
ManagedAgentPhase::Active => None,
}
}
pub fn reconcile_managed_agent_at(&mut self, now: Instant, process_exited: bool) -> bool {
@@ -2039,7 +2091,9 @@ impl TerminalState {
return true;
}
if managed.phase == ManagedAgentPhase::Blocked {
if known_agent == Some(managed.kind) && self.state == AgentState::Idle {
if known_agent == Some(managed.kind)
&& self.managed_agent_prompt_ready(managed.kind, now)
{
self.managed_agent = Some(ManagedAgent {
kind: managed.kind,
phase: ManagedAgentPhase::Active,
@@ -2067,7 +2121,9 @@ impl TerminalState {
return true;
}
if ready_after.is_none_or(|ready_after| now >= ready_after) {
if known_agent == Some(managed.kind) && self.state == AgentState::Idle {
if known_agent == Some(managed.kind)
&& self.managed_agent_prompt_ready(managed.kind, now)
{
self.managed_agent = Some(ManagedAgent {
kind: managed.kind,
phase: ManagedAgentPhase::Active,
@@ -2133,6 +2189,8 @@ impl TerminalState {
self.fallback_state = AgentState::Unknown;
self.fallback_visible_blocker = false;
self.fallback_observed_at = None;
self.codex_session = None;
self.codex_prompt_ready = false;
self.hook_authority = None;
self.persisted_agent_session = None;
self.agent_metadata.clear();
@@ -2213,6 +2271,9 @@ impl TerminalState {
previous_presentation: EffectivePresentation,
now: Instant,
) -> Option<EffectiveStateChange> {
if !self.codex_session_matches_current() {
self.codex_session = None;
}
let state = if self.visible_blocker_overrides_hook() {
AgentState::Blocked
} else {
@@ -2220,7 +2281,7 @@ impl TerminalState {
.as_ref()
.filter(|authority| self.hook_authority_is_effective(authority))
.map(|authority| authority.state)
.unwrap_or(self.fallback_state)
.unwrap_or_else(|| self.codex_state().unwrap_or(self.fallback_state))
};
let agent_label = self.effective_agent_label().map(str::to_string);
let known_agent = self.effective_known_agent();
+7
View File
@@ -195,6 +195,13 @@ fn codex_hook_reports_persisted_root_session_and_ignores_ephemeral_or_nested_ses
assert_eq!(request["method"], "pane.report_agent_session");
assert_eq!(request["params"]["agent_session_id"], "codex-session");
assert_eq!(
request["params"]["agent_session_path"],
"/tmp/codex-session.jsonl"
);
assert!(request["params"].get("terminal_id").is_none());
assert!(request["params"].get("process_group_id").is_none());
assert!(request["params"]["origin_pid"].as_u64().is_some());
assert!(request["params"].get("state").is_none());
let matching_request = run_shell_hook_with_env(