From 2e7fc1ac11d3a87d71a866a266112a9df9f57955 Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 8 Oct 2026 18:28:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(session):=20Stop=20=E3=81=AE=E7=9B=B4?= =?UTF-8?q?=E5=BE=8C=E3=81=AB=E9=80=81=E4=BF=A1=E3=81=AA=E3=81=97=E3=81=A7?= =?UTF-8?q?=E5=B1=8A=E3=81=84=E3=81=9F=20pre=5Ftool=5Fuse=20=E3=81=A7?= =?UTF-8?q?=E6=96=B0=E3=81=97=E3=81=84=E3=82=BF=E3=83=BC=E3=83=B3=E3=82=92?= =?UTF-8?q?=E9=96=8B=E3=81=8B=E3=81=AA=E3=81=84=20(#3437)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 原因(実測) - 本番 logs/server.log.1(worktree commandmate-issue-3420, instance claude): 23:40:24.052Z user_prompt_submit → 23:41:32.759Z agent-event-stop-applied → 23:41:35.219Z ask-user-question-recorded + agent-event-received pre_tool_use (detail AskUserQuestion)。user_prompt_submit は無い。23:41:35.954Z の detection-divergence は scraper=ready(input_prompt) / structured=running (hook_pre_tool_use)。23:42:32.785Z notification(idle_prompt)(Stop の 60 秒後 =その間 composer は空いたまま)。以後 Stop は来ない。 - transcript 07a5ddd9-....jsonl: 23:41:32.216Z の最終 assistant text → 23:41:32.760Z Stop hook_success → stop_hook_summary(preventedContinuation false) → turn_duration で終わり、その後の記録は last-prompt / cost-state のみ。 AskUserQuestion の tool_use はセッション全体に 1 件も無く、subagents も無い。 同時刻の他プロジェクトの transcript(CommandAgent-develop / issue-3429 / MyCodeBranchDesk)にも AskUserQuestion は無い。 - よって、この pre_tool_use は Stop が閉じた会話の続きではない(Stop hook は継続を 止めておらず、画面にピッカーも出ていない)。Claude プロセス内の本会話以外の 経路(会話記録に残らない内部の後処理エージェント等と推定。hook の payload は ログに残っておらず、送り主までは特定できない)から来た。サーバーはこれを 「開いていないので開く」と読んで Stop の来ないターンを開いた。 - 全 4 ログで「Stop の後、送信なしで最初に来た pre/post_tool_use」を数えると 31 件。 claude は本件の 1 件だけ(2.5 秒後)。残り 30 件はすべて command-code (user_prompt_submit を持たず pre_tool_use でターンが始まるソース)。 ## 扱い(候補 a を絞って採用) - 送信を報告するソース(supportedEvents に user_prompt_submit を宣言)からの pre_tool_use は、直近のターンがエージェント自身の stop で閉じていれば新しい ターンを開かない(ターン・表示とも不変)。宣言は intake が source の capabilities から読み、AgentEventRecord.promptOpensTurns で渡す(ツール id は 見ない)。該当するのは実質 claude のみ(codex/gemini/copilot/opencode-v2 は pre_tool_use を出さない)。 - (b)(c) を採らない理由: (b) 開いたうえで閉じる条件を足すと、wait が採用する openedAt が先に動いてしまう。(c) 直前のターンに属させると閉じたターンが running に戻り、Stop が来ないので同じ問題になる。 - 壊さないもの: 通常のターン(送信→活動→Stop)は開いたターンを続けるので不変。 post_tool_use は従来どおり開く(Stop hook が block して続いた場合も、claude は AskUserQuestion の post_tool_use でターンを開く)。stale / scraper_evidence / resync_idle / session_end / generation の後は開く(サーバー側の推定で閉じた ターンの続きを潰さない)。Command Code の pre_tool_use 始まり・antigravity の 自己再開 post_tool_use(#2614)は user_prompt_submit を宣言しないので不変。 時間窓は使わない(送信を報告するソースのターンは必ず送信で始まるため)。 ## 対になる場所 - TURN_ACTIVITY_EVENTS(src/lib/session/provisional-turn.ts)と CLI の写し TURN_OPENING_EVENT_TYPES(src/cli/commands/wait.ts): 集合は変えないので両方 不変。status-contract-1926 の一致はそのまま。 - src/lib/agent-health/hook-observation.ts の TURN_ACTIVITY_EVENTS の写し: 集合 不変のため変更不要(範囲外)。 - CLI wait(adoptTurnStart): turn record の openedAt を読むので、ターンが開かな ければ閉じたターンのまま turnSettled になる。変更不要。 - 古い動き(Stop の後の pre_tool_use で開く)を claude で固定したテスト: 探した (tests/unit/session/turn-model.test.ts, agent-event-turn-join-3330, lib/session/agent-event-turn-3375, api/hooks-agent-event)が無かった。 command-code の pre_tool_use で開くテストはそのまま通る。 - docs の ja/en: 該当記述なし。 ## 本文に無い指摘 - src/lib/hooks/agent-event-intake.ts:390 recordQuestionIfAsked は同じ pre_tool_use の質問を記録する(ask-user-question-recorded)。本件のような 会話外の AskUserQuestion でも質問レコードが残る。ターンとは別の状態なので 今回は変えていない。 - hook の payload(session_id / agent_id 等)を intake がログに残さないため、 本件の送り主を後から特定できない。 ## 検証 - npx vitest run tests/unit/lib/session/agent-event-turn-after-stop-3437.test.ts: 11 passed。判定行を外すと陽性 2 件が落ちることを確認(変異注入)。 - 関連(tests/unit/lib/session, tests/unit/session, tests/unit/hooks, tests/unit/lib/hooks, tests/unit/cli/commands, api/hooks-agent-event 等): 421 files / 6491 tests passed。 - npm run lint / npx tsc --noEmit: exit 0。 - ~/.commandmate / ~/.local/state の ls -la を前後で比較: エントリの増減なし ($HOME と ~/.local/state/commandagent の mtime のみ変化。別プロセス由来)。 ~/.commandmate/service.json は前後とも無い。tmux・サーバーは起動していない。 Co-Authored-By: Claude Opus 5.5 --- changelog.d/3437.md | 2 + src/lib/hooks/agent-event-intake.ts | 4 + src/lib/session/agent-event-record.ts | 12 + src/lib/session/agent-event-turn.ts | 36 +++ .../agent-event-turn-after-stop-3437.test.ts | 242 ++++++++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 changelog.d/3437.md create mode 100644 tests/unit/lib/session/agent-event-turn-after-stop-3437.test.ts diff --git a/changelog.d/3437.md b/changelog.d/3437.md new file mode 100644 index 000000000..bab08c084 --- /dev/null +++ b/changelog.d/3437.md @@ -0,0 +1,2 @@ + +- **fix(agent-health): Stop の直後に送信なしで届いた pre_tool_use で新しいターンを開かない** (#3437): 送信(`user_prompt_submit`)を報告するソース(Claude)では、エージェント自身の `Stop` でターンが閉じた後、送信の無いまま届いた `pre_tool_use` を新しいターンの始まりとして扱わない。2026-10-07 の本番で `Stop` の 2.5 秒後に届いた `PreToolUse(AskUserQuestion)` が Stop の来ないターンを開き、`wait` がその終了を待ち続けていた。通常のターン、`post_tool_use` で始まるターン、Command Code・antigravity の自己再開(#2614)は従来どおり diff --git a/src/lib/hooks/agent-event-intake.ts b/src/lib/hooks/agent-event-intake.ts index d8576d7d9..d060705b0 100644 --- a/src/lib/hooks/agent-event-intake.ts +++ b/src/lib/hooks/agent-event-intake.ts @@ -327,6 +327,10 @@ export function applyAgentEventToState( // turn fires `UserPromptSubmit` as well. The source says which prompts // those are; the state decides whether there is a turn to join. joinsOpenTurn, + // Issue #3437: read off the declaration, never the tool id. A source that + // reports its prompts opens its turns on them, so a `pre_tool_use` after + // its own `Stop` with no prompt since does not begin one. + promptOpensTurns: source.capabilities.supportedEvents.includes('user_prompt_submit'), }, { // Issue #1903: the declared value, read off the source this route already diff --git a/src/lib/session/agent-event-record.ts b/src/lib/session/agent-event-record.ts index 5d007521e..30a19f09e 100644 --- a/src/lib/session/agent-event-record.ts +++ b/src/lib/session/agent-event-record.ts @@ -117,4 +117,16 @@ export interface AgentEventRecord { * says nothing opens a new turn, as every prompt did before this Issue. */ joinsOpenTurn?: boolean; + /** + * Whether the delivering source reports the prompt that begins each of its + * turns — it declares `user_prompt_submit` in `supportedEvents` (Issue #3437). + * + * The caller computes it from the source's capabilities. Read only for + * `pre_tool_use`: from such a source, a `pre_tool_use` arriving after the + * agent's own `Stop` with no prompt in between is not the start of a turn, + * so it does not open one — see {@link applyTurnTransition}. Absent is + * `false`: a source whose turns begin with a tool event (Command Code) opens + * them on `pre_tool_use`, as every source did before this Issue. + */ + promptOpensTurns?: boolean; } diff --git a/src/lib/session/agent-event-turn.ts b/src/lib/session/agent-event-turn.ts index 779b9c2ed..e518f66b9 100644 --- a/src/lib/session/agent-event-turn.ts +++ b/src/lib/session/agent-event-turn.ts @@ -376,6 +376,7 @@ export function fenceTurnForNewGeneration(key: string, at: number): void { * | `user_prompt_submit` | **opens a new one** | release | yes | * | `user_prompt_submit` (joins, #3330)| continues, else opens | release | yes | * | `pre_tool_use` | continues, else opens | unchanged| yes | + * | `pre_tool_use` (after its stop) | unchanged (#3437) | unchanged| no | * | `post_tool_use` | continues, else opens | release | yes | * | `stop` (this session) | **closes** `stop` | release | yes | * | `stop` (another session) | unchanged | unchanged| no | @@ -440,6 +441,22 @@ export function fenceTurnForNewGeneration(key: string, at: number): void { * still dropped by the window in front of it ({@link isDuplicateAgentEvent}), * and dropped or applied, none of them moves the turn. * + * The `after its stop` row is the other exception (Issue #3437), and it is + * narrower than it reads: it applies only to a source that reports its + * prompts ({@link AgentEventRecord.promptOpensTurns}), and only when the + * instance's last turn was closed by the agent's own `stop`. In production on + * 2026-10-07 a Claude `PreToolUse(AskUserQuestion)` landed 2.5 s after the + * `Stop`, with no `UserPromptSubmit`, no picker on the pane (the scraper read + * `ready` throughout), and nothing in the session's transcript after the + * `Stop` — something other than the conversation the `Stop` had just ended. + * It opened a turn whose `stop` never came, and a `wait` adopted that turn. + * Every turn of such a source begins with its prompt, and a tool call that + * continues past a `Stop` (a Stop hook that blocks) still reports its + * `post_tool_use`, which opens the turn as before. Sources whose turns begin + * with a tool event are untouched: Command Code's `pre_tool_use` and + * antigravity's self-resume `post_tool_use` (#2614) open turns because + * neither sends `user_prompt_submit`. + * * `idle_prompt` is the one row that publishes `ready` **without** closing the * turn, and that is a measurement rather than an oversight: #1839 caught Claude * emitting it 62 s into a turn that ran nothing, so it cannot be a turn @@ -472,6 +489,11 @@ export function applyTurnTransition(key: string, record: AgentEventRecord): void // Deliberately leaves the dialog alone (Issue #1726). It is the // `AskUserQuestion` invocation, and a picker being *about to be drawn* is // not a fact this state can carry. + // + // Issue #3437: and from a source that reports its prompts, it is not the + // start of a turn when the agent's own `Stop` is the last thing that + // ended one. See the `after its stop` row above. + if (record.promptOpensTurns === true && endedByOwnStop(key)) return; openTurn(key, record, generation, { continueOpen: true }); return; case 'stop': @@ -496,6 +518,20 @@ export function applyTurnTransition(key: string, record: AgentEventRecord): void } } +/** + * Whether this instance's turn was last ended by the agent's own `stop`, with + * nothing opening a turn since (Issue #3437). + * + * Only `stop`: every other close is this server's own inference (`stale`, + * `scraper_evidence`, `resync_idle`) or a session that went away + * (`session_end`, `generation`), and an agent that keeps working after one of + * those must be allowed to open the turn it is in. + */ +function endedByOwnStop(key: string): boolean { + const turn = fencedTurn(key); + return turn !== null && turn.closedAt !== null && turn.closedBy === 'stop'; +} + /** The three fields the turn keeps from a record. */ function displayOf(record: AgentEventRecord): TurnRecord['displayEvent'] { return { event: record.event, at: record.at, detail: record.detail }; diff --git a/tests/unit/lib/session/agent-event-turn-after-stop-3437.test.ts b/tests/unit/lib/session/agent-event-turn-after-stop-3437.test.ts new file mode 100644 index 000000000..be0998ed9 --- /dev/null +++ b/tests/unit/lib/session/agent-event-turn-after-stop-3437.test.ts @@ -0,0 +1,242 @@ +/** + * A `pre_tool_use` after the agent's own `Stop`, with no prompt in between, + * does not open a turn — for a source that reports its prompts (Issue #3437). + * + * The measured sequence (production `server.log.1`, worktree + * `commandmate-issue-3420`, 2026-10-07): `UserPromptSubmit` 23:40:24, `Stop` + * 23:41:32.759, then `PreToolUse(AskUserQuestion)` 23:41:35.219 with no + * `UserPromptSubmit`, and no `Stop` after it. The turn it opened was closed by + * `scraper_evidence`, and a `wait` adopted it and waited on its `stop`. + * + * Every case is a positive or a negative control, and the mutation is the + * declaration (`promptOpensTurns`), never the tool id: a source whose turns + * begin with a tool event (Command Code's `pre_tool_use`, antigravity's + * self-resume `post_tool_use`, #2614) keeps opening them. + * + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it } from 'vitest'; +import { + clearAgentStopEvents, + closeAgentTurn, + getAgentTurn, + getStructuredSessionState, + recordAgentEvent, + type AgentEventRecord, +} from '@/lib/session/agent-event-state'; +import { applyAgentEventToState, type ResolvedAgentEvent } from '@/lib/hooks/agent-event-intake'; +import { getAgentEventSource } from '@/lib/hooks/sources'; +import { SELF_RESUME_PENDING_DETAIL } from '@/lib/hooks/agent-event-types'; +import type { CLIToolType } from '@/lib/cli-tools/types'; +import type { Worktree } from '@/types/models'; + +const WT = 'wt-3437'; +const SESSION = '07a5ddd9-46df-42c1-be1c-a6e35209c4a3'; +/** The prompt of the measured turn, 2026-10-07T23:40:24.052Z. */ +const PROMPT_AT = 1_791_416_424_052; +/** Its `Stop`, 23:41:32.759Z. */ +const STOP_AT = 1_791_416_492_759; +/** The `PreToolUse(AskUserQuestion)` that opened the turn, 23:41:35.219Z. */ +const PRE_AT = 1_791_416_495_219; + +beforeEach(() => { + clearAgentStopEvents(); +}); + +function post( + tool: CLIToolType, + record: Partial & Pick, +): void { + recordAgentEvent(WT, tool, tool, { detail: null, sessionId: SESSION, ...record }); +} + +const turn = (tool: CLIToolType, now: number) => getAgentTurn(WT, tool, tool, now); +const status = (tool: CLIToolType, now: number) => getStructuredSessionState(WT, tool, tool, now); + +describe('[#3437] a pre_tool_use after the agent\'s own stop', () => { + it('does not open a turn when the source reports its prompts (the measured sequence)', () => { + post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true }); + post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true }); + const stopped = turn('claude', STOP_AT + 1); + expect(stopped).toMatchObject({ openedAt: PROMPT_AT, closedAt: STOP_AT, closedBy: 'stop' }); + + post('claude', { + event: 'pre_tool_use', + at: PRE_AT, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + + const after = turn('claude', PRE_AT + 1); + expect(after?.turnId).toBe(stopped?.turnId); + expect(after).toMatchObject({ openedAt: PROMPT_AT, closedAt: STOP_AT, closedBy: 'stop' }); + expect(after?.displayEvent.event).toBe('stop'); + expect(status('claude', PRE_AT + 1)?.status).toBe('ready'); + }); + + it('opens a turn when the source does not say so (negative control: the pre-#3437 reading)', () => { + post('claude', { event: 'user_prompt_submit', at: PROMPT_AT }); + post('claude', { event: 'stop', at: STOP_AT }); + const stopped = turn('claude', STOP_AT + 1); + + post('claude', { event: 'pre_tool_use', at: PRE_AT, detail: 'AskUserQuestion' }); + + const after = turn('claude', PRE_AT + 1); + expect(after?.turnId).not.toBe(stopped?.turnId); + expect(after).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + expect(status('claude', PRE_AT + 1)?.status).toBe('running'); + }); + + it('still opens a turn for Command Code, whose turns begin with pre_tool_use', () => { + post('command-code', { event: 'pre_tool_use', at: PROMPT_AT, detail: 'shell_command' }); + post('command-code', { event: 'stop', at: STOP_AT }); + + post('command-code', { event: 'pre_tool_use', at: PRE_AT, detail: 'read_file' }); + + expect(turn('command-code', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + }); + + it('still opens a turn after a close that was not the agent\'s own stop', () => { + post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true }); + closeAgentTurn(WT, 'claude', 'claude', 'scraper_evidence', STOP_AT); + expect(turn('claude', STOP_AT + 1)).toMatchObject({ closedBy: 'scraper_evidence' }); + + post('claude', { + event: 'pre_tool_use', + at: PRE_AT, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + + expect(turn('claude', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + }); + + it('still opens a turn when nothing has ended one (server started mid-turn)', () => { + post('claude', { + event: 'pre_tool_use', + at: PRE_AT, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + + expect(turn('claude', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + }); +}); + +describe('[#3437] turns that must not change', () => { + it('the ordinary turn: prompt, tool events, stop', () => { + post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true }); + const opened = turn('claude', PROMPT_AT + 1); + post('claude', { + event: 'pre_tool_use', + at: PROMPT_AT + 1_000, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + post('claude', { + event: 'post_tool_use', + at: PROMPT_AT + 2_000, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + expect(turn('claude', PROMPT_AT + 2_001)?.turnId).toBe(opened?.turnId); + + post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true }); + expect(turn('claude', STOP_AT + 1)).toMatchObject({ + turnId: opened?.turnId, + openedAt: PROMPT_AT, + closedAt: STOP_AT, + closedBy: 'stop', + }); + }); + + it('the next prompt after a stop opens a new turn, and its pre_tool_use continues it', () => { + post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true }); + post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true }); + const first = turn('claude', STOP_AT + 1); + + post('claude', { event: 'user_prompt_submit', at: PRE_AT, promptOpensTurns: true }); + const second = turn('claude', PRE_AT + 1); + expect(second?.turnId).not.toBe(first?.turnId); + expect(second).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + + post('claude', { + event: 'pre_tool_use', + at: PRE_AT + 1_000, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + expect(turn('claude', PRE_AT + 1_001)).toMatchObject({ + turnId: second?.turnId, + openedAt: PRE_AT, + closedAt: null, + }); + }); + + it('a post_tool_use after a stop still opens a turn (a Stop hook that blocks continues the agent)', () => { + post('claude', { event: 'user_prompt_submit', at: PROMPT_AT, promptOpensTurns: true }); + post('claude', { event: 'stop', at: STOP_AT, promptOpensTurns: true }); + + post('claude', { + event: 'post_tool_use', + at: PRE_AT, + detail: 'AskUserQuestion', + promptOpensTurns: true, + }); + + expect(turn('claude', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + }); + + it('the self-resume wake (#2614): antigravity\'s post_tool_use after its stop opens a turn', () => { + post('antigravity', { event: 'post_tool_use', at: PROMPT_AT, detail: 'run_command' }); + post('antigravity', { event: 'stop', at: STOP_AT, detail: SELF_RESUME_PENDING_DETAIL }); + const stopped = turn('antigravity', STOP_AT + 1); + + post('antigravity', { event: 'post_tool_use', at: PRE_AT, detail: 'schedule' }); + + const woke = turn('antigravity', PRE_AT + 1); + expect(woke?.turnId).not.toBe(stopped?.turnId); + expect(woke).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + }); +}); + +describe('[#3437] the intake reads the declaration off the source', () => { + function deliver(tool: CLIToolType, event: AgentEventRecord['event'], at: number, detail: string | null): void { + const ctx = { + worktree: { id: WT } as Worktree, + tool, + source: getAgentEventSource(tool), + instanceParam: tool, + event, + sessionId: SESSION, + receivedAt: at, + detail, + normalized: { model: null }, + } as unknown as ResolvedAgentEvent; + applyAgentEventToState(ctx, {}, false, { info: () => {}, warn: () => {} } as never); + } + + it('claude: the measured sequence does not open a turn', () => { + deliver('claude', 'user_prompt_submit', PROMPT_AT, null); + deliver('claude', 'stop', STOP_AT, null); + deliver('claude', 'pre_tool_use', PRE_AT, 'AskUserQuestion'); + + expect(turn('claude', PRE_AT + 1)).toMatchObject({ + openedAt: PROMPT_AT, + closedAt: STOP_AT, + closedBy: 'stop', + }); + }); + + it('command-code: declares no user_prompt_submit, so its pre_tool_use opens a turn', () => { + expect(getAgentEventSource('command-code').capabilities.supportedEvents).not.toContain( + 'user_prompt_submit', + ); + deliver('command-code', 'pre_tool_use', PROMPT_AT, 'shell_command'); + deliver('command-code', 'stop', STOP_AT, null); + deliver('command-code', 'pre_tool_use', PRE_AT, 'read_file'); + + expect(turn('command-code', PRE_AT + 1)).toMatchObject({ openedAt: PRE_AT, closedAt: null }); + }); +}); From 8d16fd528a7b34cb42c24774ecb78399bd0e1ca3 Mon Sep 17 00:00:00 2001 From: kewton Date: Thu, 8 Oct 2026 19:33:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(module-reference):=20Issue=20#3437=20?= =?UTF-8?q?=E3=81=AE=E6=B3=A8=E8=A8=98=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/module-reference.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/module-reference.md b/docs/module-reference.md index e88452dba..2932167ab 100644 --- a/docs/module-reference.md +++ b/docs/module-reference.md @@ -294,10 +294,10 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `src/app/api/hooks/permission-request/` + `src/lib/hooks/permission-decision-service.ts` + `permission-request-payload.ts` | Auto-Yes v2 の構造化裁定(Issue #1724)。Claude が承認ダイアログを**描く前に**同期 POST してくる `PermissionRequest` を裁き、`{}`(no-decision)か `{hookSpecificOutput:{hookEventName:'PermissionRequest',decision:{behavior:'allow'}}}` を返す。**`deny` は返さない** — 現行 Auto-Yes の抑止は「自動応答しない」であって「拒否する」ではなく、deny 化は既存契約の意味を変える。裁定順は 未 parse→no-decision / `AskUserQuestion`→常に no-decision(#1726。allow しても選択画面は出る=#1721 §5.6)/ Auto-Yes 無効・期限切れ→no-decision / 契約ポリシー抑止→no-decision+`recordPolicySuppression`(#1684)/ それ以外→allow。**判定不能は必ず no-decision**(`{}` は TUI ダイアログにフォールバックする=#1721 D5。誤 allow はコマンド実行を意味する)。ポリシー評価は `evaluatePolicyAgainstTexts()` を poller と**共有**し、promptType は `multiple_choice`(画面上の承認ダイアログの分類。hook が poller より緩くならないための固定)。**denyPatterns の照合対象は当該リクエストの `tool_input` のみ**(`collectToolInputMatchTexts`: Bash=command+description、他ツールは主要引数、未知ツール/想定外 shape は input 全体へ fail-safe。#1699 の scrollback 汚染は入力に画面が無いので構造的に起きない)。**Issue #1902**: `tool_input` が object で来なかった場合(copilot の `Edit`)は `collectToolInputMatchTexts()` の第 3 引数 `toolInputNormalization` が照合面を決め、patch は action ヘッダ行・その他の文字列は全文になる(`lib/hooks/tool-input-normalization` 参照)。正規化した事実は `recordToolInputNormalization()` で記録し `structuredEvents.toolInputNormalization` に露出する(無言の rewrite を作らない=方針書 §7)。**allow のときだけ** prompt 履歴に answered 行を作る(ダイアログが出ないので他に記録者が居ない。no-decision 側は画面経路が従来どおり記録し、`pending` 行を作ると `recordAnsweredPrompt` が人間の応答をそれに刻んでしまう)。相関は **`prompt_id` + `tool_name` + `tool_input`** — 実 payload に `tool_use_id` は無い(#1721 D2)。応答時間を毎回ログし `PERMISSION_DECISION_SLOW_MS`(500) 超で warn。想定外例外も 200 `{}`(500 を返すと fail-open で結局ダイアログだが、理由が残らない)。認証は middleware 任せ。**Issue #1725**: allow 以外(=ダイアログが描かれる)のとき `reportPermissionRequestPending()` を呼び、検出層に「ダイアログが出る」を伝える(Notification より約 6 秒早い)。**Issue #1901**: この予告は `getAgentEventSource(cliToolId).capabilities.permissionHookPredictsDialog` が true のソース(claude / codex)に**限定**する — copilot / antigravity は `PreToolUse` を全ツール呼び出しで発火し大半をダイアログ無しに即実行するため、`Read` ごとに偽の `waiting` が立ち `wait --on-prompt agent` が偽 exit 10 を返していた。ツール ID では分岐せず宣言値を読む(#1898 の `permissionReplyReleasesPrompt` と同型)。予告を落としてもダイアログは見落とさない — copilot は承認を箱で描きステータスバーと composer が消えるので scraper が `waiting`/`prompt_detected` を返す(#1885/#1886)、opencode は `notification(permission_prompt)` が観測として記録する。失うのは capture キャッシュ 5 秒ぶんの遅延のみ。**allow 監査行は残す**(allow はダイアログを描かないのでこの行が無人承認の唯一の記録。capability はダイアログ予告についての宣言であって監査についての宣言ではない)。`permission_mode==='bypassPermissions'` は除外(そのモードで hook が発火するかは #1721 未計測。誤検出のコストは健全セッションでの `wait` exit 10)。`AskUserQuestion` も対象にするが**画面を検出できると主張はしない** — 裏取りが来なければ 20 秒で失効する(あの画面はイベントを一切出さない=#1708/#1726 の担当)。**画面ベース Auto-Yes 経路は削除も変更もしていない**(hooks 非対応環境と Claude 以外のフォールバック)。**Issue #1726**: `toolName === 'AskUserQuestion'` のとき `recordAskUserQuestion()` も呼ぶ(`PermissionRequest` の `tool_input` は `PreToolUse` と同一なので第 2 の供給源になり、`PreToolUse` 注入より前に起動したセッションでは唯一の供給源になる)。**裁定は変えていない** — AskUserQuestion は従来どおり常に no-decision。**Issue #1759: 応答ボディの組み立ては route ではなくソースが行う**(`toVerdict()` で `Verdict` にしてから `answerPendingDecision()` へ渡す=push は body に書き pull は別 REST を叩く、C2)。Claude の出力バイトは不変。**`abstain` かつ `source.noDecision.kind !== 'proceeds'` のとき `permission-request-abstain-blocks-agent` を warn する**(C3) — 「黙れば安全」は opencode(無限待ち)と antigravity(空応答=拒否)で成立せず、どちらも**セッションが無言で止まる**(=考え込んでいる agent と見分けがつかない)ため **Issue #2214**: `recordAllowedPermission()` が書いた Auto-Yes 承認の監査行を `broadcastMessage('message', …)` で publish する(`'message'` 固定 — この経路は常に新規 INSERT)。push は `@/lib/ws-server` の動的 import による detached promise で、DB の成功から分離する **Issue #2220**: hook route も `.next/server/chunks` 側の ws-server コピーを引くため、この監査行の push は production でも届いていなかった。`realtime/publisher-registry` の bridge で解消(docstring も訂正済み) **Issue #2849**: antigravity のとき、`source.parsePermissionRequest(raw)` の直後に `recordAntigravityPermissionReceipt(worktree.id, tool, instanceParam ?? tool, payload?.toolName ?? null, startedAt)` を呼ぶ(`permissionHookPredictsDialog`・応答の内容・Auto-Yes の有無によらず記録。`payload` が null なら記録しない)。 | | `src/app/api/hooks/claude-done/route.ts` | Claude 完了 webhook(旧受け口)。pane capture→message 作成→session state 更新は従来どおり。Issue #1549 で末尾に applyAgentStopEvent(tool='claude') を委譲(**message 記録の後**に実行 — タスクログ都合でこの route 本来の出力を失わせない) | | `src/lib/session/agent-event-state.ts` | hook 由来イベントのインメモリ記録(Issue #1549、#1722 で全イベントへ拡張)。recordAgentStopEvent / getLastStopEventAt + **recordAgentEvent / getLastAgentEvent**(event・at・detail・sessionId)+ **isDuplicateAgentEvent**(sessionId 付きのみ 3 秒窓で重複判定。呼ぶと鍵を確保するので 1 リクエスト 1 回。間にターンの境目が適用されていれば窓の中でも通す — `stop` は、間にターンの開始(`TURN_ACTIVITY_EVENTS`)があれば別のターンの終了(#3289)。ターンの開始は、間に同じセッションの `stop` があれば別のターンの開始(#3301))。キーは auto-yes-state と同じ composite key(primary は 2 part 互換)。**recordAgentEvent は lastStopEventAt を書かない** — その timestamp は applyAgentStopEvent が task 遷移と一緒に書く(writer が 2 つあると食い違う)。**DB に置かないのは生きた tmux セッションの話だから**(再起動を跨いだ値は説明する対象が無い)。**Issue #1723 で第一級ソースへ昇格**: `getStructuredSessionState()` が最新イベントを `SessionStatus` に写像して返し(写像表は `status-mapping.ts` の `agentEventToSessionStatus`)、`buildCurrentOutput` がそれを scraper より優先する。信頼範囲を 3 つで縛る — **世代**(`beginAgentEventGeneration()` を `startClaudeSession()` の新規作成パスのみで呼ぶ/`session_start` イベント自身も世代を切る。key が再作成セッションで再利用されるため、無いと前プロセスの `user_prompt_submit` が新セッションの `running` になる)、**齢**(`STRUCTURED_STATE_MAX_AGE_MS`=30 分。hooks は全経路 fail-open なので `Stop` が届かない事故がありうる。無制限だと `wait` が `--timeout` まで回る)、**生存**(呼び出し側が `isRunning()` で判定済み。`stopClaudeSession()` は `discardAgentEventState()` で破棄)。`lastStopEventAt` はこれらの縛りを持たない #1549 の観測値なので破棄対象に含めない。**Issue #1725: prompt_waiting 状態機械を併設** — `reportPermissionRequestPending` / `getStructuredPromptWaiting` / `corroborateStructuredPromptWaiting` / `markStructuredPromptRecorded` / `clearStructuredPromptWaiting`。開始は `notification(permission_prompt)`(確定)と PermissionRequest no-decision(**暫定**)、解除は `stop` / `user_prompt_submit` / `session_start` / `session_end` / `notification(idle_prompt)` / 世代交代/scraper 観測(builder 側)。**`PostToolUse` は #1726 で採用した** — #1721 は「登録済み・0 回」としていたが、matcher を `AskUserQuestion` に絞って実測すると回答確定の直後(`Stop` の 1.3 秒前)に届く(2026-08-06: PreToolUse 15:36:04.112 → PostToolUse 15:36:28.643 → Stop 15:36:29.992)。`Stop`=「ターンが終わった」に対し `PostToolUse`=「そのツール呼び出しが終わった」=**「人間が答えた」に最も近い唯一のイベント**で、回答後もエージェントが働き続けるケースでは `Stop` は数分後になる。`Stop` は取りこぼし用の backstop として併用(hooks は全経路 fail-open)。`lastAgentEvent` と別に持つのは、(a) 最新イベント 1 件では「ダイアログがまだ開いている」を表現できない(次のイベントが来た時点で消える)、(b) 解除条件が違う(「人間が答えた」を示すイベントが存在しないので scraper が解除の一部を担う)ため。**Issue #1726: in-flight な `AskUserQuestion` を第 3 の状態として併設** — `recordAskUserQuestion` / `getAskUserQuestion` / `clearAskUserQuestion`。prompt_waiting と分けるのは答える問いが違うから(あちらは「人間が塞がれているか」で `sessionStatus` を決める、こちらは「何を訊かれたか」で**何も決めない**)。解除は `post_tool_use` / `stop` / `user_prompt_submit` / `session_start` / `session_end` / `notification(idle_prompt)` / 他ツールの `pre_tool_use` / 世代交代。**`notification(permission_prompt)` では解除しない** — 実測でこれは選択画面が出たまま約 6 秒後に届く(初版は §5.6 の「表示中は無音」を「イベントが来た=画面が閉じた」と読んで解除していたため、実機では機能が 6 秒で自分を消していた)。`isDuplicateAgentEvent` の鍵に detail を追加(`pre_tool_use` の detail はツール名で、1 秒に複数のツール呼び出しは普通なので、鍵に無いと `Bash` の直後の `AskUserQuestion` が重複扱いで捨てられる)。暫定記録は `STRUCTURED_PROMPT_PROVISIONAL_MAX_AGE_MS`(20 秒) で失効する — Notification は約 6 秒後、scraper は 5 秒キャッシュ越しなので裏取りには十分で、貼り付いた場合のコストは健全なセッションでの `wait` exit 10(ワーカーの誤停止)だから。**Issue #1736: 6 つの Map を `globalThis` 経由に変更**(`__agentEventLastStopAt` / `__agentEventLast` / `__agentEventGenerationStartedAt` / `__agentEventPromptWaiting` / `__agentEventAskUserQuestion` / `__agentEventRecentKeys`)— 素のモジュールスコープだと dev モードで hook route と current-output route が別インスタンスの Map を見るため `structuredEvents` が常時 null に縮退していた(#1720 の構造化状態すべてが無言で無効化される)。冒頭の「プロセス全体で共有する in-memory 状態は globalThis 経由で持つ」規約を参照。**Issue #1786: 第 3 の状態 awaiting_instruction を併設** — `getAwaitingInstruction` / `isAwaitingInstruction`(`globalThis.__agentEventAwaitingInstruction`)。set は `notification(idle_prompt)` のみ、解除は `user_prompt_submit` / `session_start` / `session_end` / 世代交代。`stop` では**立てない**(中間ターンの終了は指示待ちではない)/`pre_tool_use`・`post_tool_use`・`notification(permission_prompt)` では**解除しない**(ターン内でしか起きず、そのターンの `user_prompt_submit` が既に解除している)。**齢の上限を掛けていない状態**(#1783 の model も同じ理由で掛けていない) — 他は「届かないかもしれないイベント」の被害を縛るためだが、これは composer への入力(アプリ経由でも pane 直打ちでも `UserPromptSubmit` が上がる)とセッション終了という**取りこぼしようのないイベント**で解除される。`SessionStatus` を 5 値に広げず boolean にしたのは `idle_prompt`=`ready`=「送信できる」の意味を全消費者に再判断させないため。**Issue #1783: `AgentEventRecord.model?` と `getLastKnownAgentModel()`(8 つ目の Map `__agentEventLastModel`)** — `lastAgentEvent` はレコードごと置換されるうえ claude は `SessionStart` にしか model を載せないので、最新イベントから読むと初回以降ずっと null になる。**非 null だけを latch し、null は既知の値を消さない**。破棄は世代交代(`beginAgentEventGeneration`=別プロセス)と `discardAgentEventState` のみで、**`/clear`(`session_end`→`session_start`)では保持する**。齢による失効は付けない — 8 時間走るターンも model は変わらないので、失効させると一番役に立つ場面で表示が消える。永続化はしない(Phase 2 #1784 の capture フォールバックが再起動後の claude の穴を埋める)。**Issue #1898: prompt_waiting を「裁定してから記録」に対応させる** — `AgentEventRecord` に additive な `promptSettled?`(このイベントはダイアログを残さない、の意)と `decisionId?`(ソースが per-decision id を宣言している場合のみ)を追加し、`applyPromptWaitingTransition` に 2 行足した: `notification(permission_prompt)` は `promptSettled` なら**開かず解除**、`notification(permission_replied)`(detail は `agent-event-types.ts` の共有定数)は `promptSettled` なら解除。**`promptSettled` を決めるのは呼び出し側**で、判断材料は「verdict が送達されたか」と `AgentSourceCapabilities.permissionReplyReleasesPrompt`(#1924 の宣言値)— hook 系は全ソース false(応答 body は誰も最後まで聞いていない要求へ書くので画面の話にならない)。解除は decisionId が両側にあるときだけ照合し、片方でも匿名なら無条件(照合を必須にすると id を publish しないソースで #1898 の固着が残る)。**Issue #1784: 9 つ目の Map `__agentCapturedModelInfo`(capture 由来)と `recordCapturedModelInfo` / `getLastCapturedModelInfo` / `getResolvedAgentModelInfo` / `getLastKnownAgentEffort`** — hooks 由来の `lastAgentModel` と**別 Map に持つ**(同一 Map に混ぜると scrape した表示名 `Gemini 3.7 Flash` が エージェント自身が名乗った id を後書きで潰し、復元手段が無くなる)。model・effort は**別々に latch** する(codex の legacy フッタのように effort だけ映らない frame があるため)。破棄は hooks 側と同じく世代交代と `discardAgentEventState` のみ。claude はバナーがスクロールアウトするので「映らなくなった=変わった」ではない= latch が本質。**Issue #1899: フレーム固有 id による重複判定 `classifyAgentEventDelivery` と 10 個目の Map `__agentEventRecentIdentities`**(composite key → identity key の**入れ子**。上限を instance ごとに掛けるため。平坦な 1 本だと騒がしい pane が静かな pane の id を追い出して再送を素通しさせる、DR4-009)。規則は 3 つで、**ツール名ではなく `AgentSourceCapabilities.eventIdentity` の宣言値で分岐する** — ①id がある→**時間窓なし**でその id を鍵にする(再同期が live stream の数分後に同じフレームを replay しても 1 通と数える。3 秒窓が狙っていて実際には届いていなかったケース)②id が無く語が `stop`/`session_end`(`LIFECYCLE_AGENT_EVENT_TYPES`)→**抑止しない**(opencode の `session.idle` は `{sessionID}` だけで 2 ターンを区別できず、turn-gate が代わりに数える)。この `stop` は窓を通らないので、同じセッションのターンの開始を窓から外す処理をここで行う(#3301。OpenCode V2 の `session.execution.started` は id が無く③の窓に掛かる)③それ以外→従来どおり `isDuplicateAgentEvent` の 3 秒窓。**push 系 hook は `eventIdentity:null` なので③のみ=挙動不変**(#1722 の二重 `Stop` を分けているのは窓だけ)。identity 鍵には `(event, detail)` を必ず含める — opencode は同じ `per_…` を ask では `properties.id`、reply では `properties.requestID` に載せるので、id 単独の鍵は #1898 の解除フレームを重複として捨てる。identity は時間で失効しないので破棄は世代交代と `discardAgentEventState`。**Issue #1903: 遅れて届く `session_start` を保持する `recordAgentEvent(…, options)`** — 第 5 引数 `RecordAgentEventOptions.sessionStartMayArriveLate`(`AgentSourceCapabilities` の宣言値。省略=false=#1903 以前の挙動)が true のソースでは、**開いているターンの途中に届いた `session_start` を記録しない**(`lastAgentEvent` を置換せず世代も切らない)。判定は ①ソースが宣言している ②`getStructuredSessionState(…, record.at)` が `running`/`waiting`(=世代フェンスと 30 分の齢上限をそのまま継承。別実装にしない)③`sessionId` が開いているターンと食い違わない(片方でも null なら同一とみなす=手書き #1549 hook を壊さない)の 3 条件。copilot 1.0.80 は初回ターンで `UserPromptSubmit` → **12〜15 秒後** → `SessionStart` を出し、`agentEventToSessionStatus('session_start')` が null なので `running` が消えて scraper の `ready`(#1885)に落ち、その窓で始めた `wait` が `basis=scraper_ready` で exit 0 していた。**ツール ID では分岐しない**(#1901 の `permissionHookPredictsDialog` と同型)。保持しても model の latch だけは行う(`SessionStart` は claude が model を載せる唯一のイベント)。戻り値を `void` から `AgentEventRecordOutcome` に変更(additive。receiver が `agent-event-held` を 1 行ログに出す)。**本物の再起動を握り潰さない担保**は sessionId 照合・30 分の齢上限・`beginAgentEventGeneration` の 3 本で、`tests/unit/session/late-session-start-1903.test.ts` が全部 pin する。**Issue #1930: 状態導出を `TurnRecord` へ置換**。`agentTurns`(compositeKey → `TurnRecord`)が verdict の source of truth になり、 `lastAgentEvent` は「何が最後に届いたか」の診断値として残る(両者は verdict を持たないイベントで**意図的に食い違う**)。 導出順は ①未裁定 decision あり→`waiting` ②`closedBy==='stop'`→`ready` ③その他の closedBy→null(画面由来の閉じ方は scraper へ返す) ④表示イベントの verdict ⑤開いた turn→`running`。`session_start` は世代を開き、**開いていた turn だけ**を `closedBy:'generation'` で 閉じて新世代へ持ち越す(既に閉じていた turn は持ち越さない=前プロセスの `Stop` が新セッションで `ready` を publish しない)。 新 API: `getAgentTurn` / `getPublishedAgentTurn` / `getPendingDecisions` / `getAgentEventDropCounts` / `closeAgentTurn(target, 'scraper_evidence'|'resync_idle')` / `observeScraperCompletionEvidence`。 `promptWaiting` マップは廃止し `TurnRecord.pendingDecisions[]` へ統合(`getStructuredPromptWaiting` は最古の live な 1 件を返す既存契約のまま)。 `AgentEventDropCounts`(`dedupDropped{identity,timeWindow}` / `decisionEvicted` / `idsDiscarded` / `dialogTimedOut` / `decisionOverflow`)を instance ごとに数え、`structuredEvents.dedupDropped` で露出する。 **Issue #2031: `AgentEventRecord.toolName` / `decisionPatterns` を追加し、notification 経路の `openDecision` が `toolName: null` 固定をやめた**。`permission.asked` は tool 名を持たない(#1758 §5.4)ので、`message.part.updated` の相関で得た名前を record に載せてもらう以外に経路が無い。`decisionPatterns` は `unknown[]` 型で受け、丸めは `openDecision` 側(`boundDecisionPatterns`)で行う — 保持される footprint を呼び出し側の善意に依存させないため **`recordAgentReportedEffort` / `getLastReportedAgentEffort`**(Issue #2048)—— 「エージェント自身が申告した reasoning effort」の第 3 のラッチ。opencode の `variant` だけが実在し、 `getResolvedAgentModelInfo` が `mergeModelInfo` の第 4 引数として渡す。`lastAgentModel` と同じく 世代切替・`discardAgentEventState` で破棄され、null のフレームでは消さない(latch) **Issue #2100: `reportQuestionPending()` を追加** — question ダイアログ用の書き込み口。`reportPermissionRequestPending`(forecast: 匿名・`source:'permission-request'`・20 秒で失効)と違い、`source:'notification'`(=実在の証拠)で decision id を載せて開く。tool 名と event detail は呼び手(opencode ingest)が渡すのでこの層はツール名を知らないまま。あわせて `applyAskUserQuestionTransition` の `pre_tool_use` 除外を `ASK_USER_QUESTION_TOOL` 単独から `QUESTION_DECISION_TOOL_NAMES` 全体へ拡大 — opencode は同じ question 呼び出しの tool part を `pre_tool_use`/detail `question` として**同一ミリ秒**で送るので、旧規則では記録した選択肢を 1 ms 後に自分で消していた(実測: 設計書 §27.3)。**Issue #2357: モデル変更の検出**。`lastAgentModel`(hook)/`capturedModelInfo`(frame)への書き込みのたびに `observeAgentModel()` が `getResolvedAgentModelInfo().model`(hook 優先の merged 値)を compositeKey ごとの前回値 `__agentModelBaseline`(`{model, source:'hook'\|'frame'}`)と比較し、変わった瞬間に `onAgentModelChange(listener)` の購読者へ `AgentModelChange {worktreeId, cliToolId, instanceId(resolved), from, to, source, at}` を 1 回だけ配る。**変更と見なさない**もの: `null → 値`(初回報告・再起動直後は「開始時モデル」として記録のみ)、`値 → null`(latch は null を書かないので到達しない)、`session_start`(同一 key でも baseline を捨てて新プロセスの開始時モデルにする。`/clear` の session_end+session_start も同じ)、世代交代/`discardAgentEventState`/`clearAgentStopEvents`(baseline も消す)、表記ゆれ。表記ゆれの判定は **`isSameAgentModelName(a, b, {crossSource})`**(extractor の `sameModel` とは別物・ローカル実装。英数字のみ小文字化+末尾 effort トークン除去(`AGENT_MODEL_EFFORT_SUFFIX_PATTERN`: agy の `-high`/`-low` は effort 変更であってモデル変更ではない)+ラベル専用語 `context` 除去。**同一チャネル内は完全一致**(`gpt-5` と `gpt-5-mini` は別 — これがまさに通知したい降格)、**チャネルをまたぐ時だけ包含一致**(`Gemini 3.7 Flash` ⊂ `gemini-3.7-flash-high`、`Opus 5 (1M context)` ⊂ `claude-opus-5[1m]`、`Claude Sonnet 4.6` ⊂ `anthropic/claude-sonnet-4.6`)。`recordCapturedModelInfo` は第 5 引数 `at`(既定 `Date.now()`)を取る。listener 集合は `globalThis.__agentModelChangeListeners`(route bundle と server bundle で 1 つ)。テスト: `tests/unit/lib/session/agent-model-change-2357.test.ts`。**未解決**: claude の `/model` 切替は hook が `SessionStart` でしかモデルを載せず extractor も起動バナーしか読まないため、`Set model to \`X\`` 行の reader が extractor に入るまで観測できない(#2358 / 後続)。**Issue #2361: claude に限り、hook より新しいフレームのモデルを優先**。変更が必要になった理由: `mergeModelInfo` は「model は hooks が勝つ」で、claude の hook は `SessionStart` でしかモデルを載せない(2.1.263 実測: `/model` `/fast` `/effort` は登録済みのどのイベントも発火せず、`/clear` の `SessionStart(source: clear)` にも `model` キーが無い)ため、extractor が確認行から新モデルを読んでも `getResolvedAgentModelInfo().model` は古い hook id のまま動かず、`observeAgentModel` は変化を検知せず 3 経路(chat 行 / `model_changed` frame / push)が動かなかった。実装: `lastAgentModelAt`(`globalThis.__agentEventLastModelAt`。hook がモデルを載せた報告の `at`。同一モデルの再報告でも進む)と、`capturedModelInfo` のレコードに `modelChangedAt`(フレームの model 半分が**別の値になった**時刻。同じバナーを 2 秒ごとに読み直しても進まない)を追加し、`resolveAgentModel(key, cliToolId)` が `FRAME_OVERTAKES_HOOK_MODEL_TOOLS`(= `{'claude'}`)のツールで「hook と frame の両方にモデルがあり、`modelChangedAt > lastAgentModelAt` で、`isSameAgentModelName(hook, frame, {crossSource:true})` が偽」のときだけ hook を null 扱いにして `mergeModelInfo` へ渡す(frame が勝つ)。`getResolvedAgentModelInfo` と `observeAgentModel` の両方がこの 1 か所で「どちらのチャネルの値か」を決めるので baseline の `source` も一致する。**変わらないもの**: 同一モデルの表記ゆれ(起動バナー `Fable 5.1` vs hook `claude-fable-5-1[1m]`、`/model fable` で戻ったとき)は hook の綴り/古い確認行が画面に残ったまま再起動した新プロセスの `SessionStart` は frame より新しいので hook が勝つ(同じ行を読み直しても `modelChangedAt` は進まない)/hook 未接続なら従来どおり frame のみ/codex・antigravity・opencode は hook(構造化チャネル)が毎イベントでモデルを再報告するので従来どおり hooks 優先(フッタの誤読で表示が往復しない)。世代交代・discard・test seam で `lastAgentModelAt` も消す。**測定で判明した別件(未配線)**: 2.1.263 は `PreModelSwitch` / `PostModelSwitch` hook を発火し payload は `from_model` / `to_model` / `requested_model` / `source`(`command` を実測。schema 上は `picker` / `sdk` / `auto` / `resume` も)。`/fast` も `to_model: claude-opus-5[1m]` で届く。CommandMate は両イベント未登録で claude source の `modelFields` は `['model']` のみ。配線すれば hook が切替を再報告する形になり、本ルールはそのまま hook に譲る。**Issue #2363: hook が `PostModelSwitch` で切替を再報告するようになったが、#2361 の `FRAME_OVERTAKES_HOOK_MODEL_TOOLS`(= `{'claude'}`)はロジック不変のまま fallback として残す**。理由: (1) hook が届く場合 — `latchAgentModel` が `to_model` を latch し `lastAgentModelAt` を進め、直後の poll が読む書き換わったバナー / 確認行は同じモデルのラベル綴り(`Sonnet 5`、`Opus 5 (1M context)`)なので `isSameAgentModelName(hook, frame, {crossSource:true})` が真でフレームは追い越さず、公開値は hook の id、#2357 の edge は hook 由来で 1 回(poll が hook より先に読めた場合は frame 由来 1 回で、その後の hook は同一モデルなので edge を足さない)。このルールはもともと「hook が再確認したら譲る」形なので変更不要。(2) hook が届かない場合 — hook 無しの旧版 claude、`CM_AGENT_HOOKS_INJECT=0` や手書き `--settings`、配送ロス — では latch が古いままで、フレームだけが切替を聞いているので従来どおり追い越す。ルールを外すと #2361 の症状(古い id が表示され edge 無し)がそのまま戻る。残る代償は #2361 と同じ 1 ケース(`/fast` や `source: auto` の後にバナーが窓外へ出て、extractor の fallback が古い `Set model to …` 行を新しく読み始めると、正しい hook を追い越す)で、#2363 で広がってはいない。`lastAgentModel` / `latchAgentModel` / `getLastKnownAgentModel` の「claude は `SessionStart` でしか載せない」注記は「`SessionStart` と切替時」に更新。`notification(model_switch:)` は turn / dialog / awaiting_instruction / `agentEventToSessionStatus` のどれも動かさない(`tests/unit/lib/session/agent-model-switch-hook-2363.test.ts`) **Issue #2835**: `recordCapturedModelInfo` は `effortUnreadable` のとき前の effort を残さず null にする。 **Issue #2964**: `recordAgentReportedModel(worktreeId, cliToolId, instanceId, model, at?)` — イベント語に乗らないモデル申告を hook 側 latch(`lastAgentModel`/`lastAgentModelAt`)に書き、`observeAgentModel(..., 'hook')` で #2357 の変更判定にかける(OpenCode V2 が使う)。 **Issue #3375**: 責務ごとに分割。本体は recordAgentEvent・世代/破棄/テスト用リセット・プロンプト待ち API(`getStructuredPromptWaiting` ほか)を持ち、他は `agent-event-{record,dedup,model,turn,structured-state,awaiting-instruction,ask-user-question}.ts` から同じ名前で再 export(import 元は不変)。 **Issue #3397**: 受け付けた `session_start` の世代更新で `beginEnterFallbackSession` を呼ぶ(Auto-Yes の Enter の記録とポーラーの鍵を失効)。 | -| `src/lib/session/agent-event-record.ts` | `AgentEventRecord` 型のみ(Issue #3375 で agent-event-state から分離)。兄弟モジュールが本体を import せずに型を共有するため。`joinsOpenTurn`(#3330: 立っている `user_prompt_submit` は同じセッションの開いているターンを続ける) | +| `src/lib/session/agent-event-record.ts` | `AgentEventRecord` 型のみ(Issue #3375 で agent-event-state から分離)。兄弟モジュールが本体を import せずに型を共有するため。`joinsOpenTurn`(#3330: 立っている `user_prompt_submit` は同じセッションの開いているターンを続ける) **Issue #3437**: `promptOpensTurns`(送信を報告するソース=`supportedEvents` に `user_prompt_submit` を持つソースの印。`pre_tool_use` だけが読む)。 | | `src/lib/session/agent-event-dedup.ts` | 配送の重複判定(3 秒窓・identity)と drop 件数。`MAX_RECENT_EVENT_KEYS` / `trimOldestEntries` を兄弟へ提供(#3375 分離)。`agentEventKeyClaimedAt()`(dedup キーを適用した配送の受信時刻)・`shortSessionTag()`(セッション id の SHA-256 先頭 8 文字)(#3311) | | `src/lib/session/agent-event-model.ts` | model / effort の latch(hook・frame・reported effort)、優先順位、model 変化の edge(#3375 分離) | -| `src/lib/session/agent-event-turn.ts` | ターンモデル(#1930): 世代フェンス、TurnRecord、遷移、ターンが持つ decision(#3375 分離)。`joinsOpenTurn` の記録は開いているターンを続ける(`turnId` / `openedAt` 不変)、`joinOpenTurnFromDuplicate`(印つきの配送が重複で捨てられたとき、先の配送が開き直したターンを戻す)(#3330)。`observeScraperCompletionEvidence(..., at, mayClose = true)`: false の間は数えるが閉じない(hooks を出している codex で Stop が来ないと分かる画面でないとき、#3337) | +| `src/lib/session/agent-event-turn.ts` | ターンモデル(#1930): 世代フェンス、TurnRecord、遷移、ターンが持つ decision(#3375 分離)。`joinsOpenTurn` の記録は開いているターンを続ける(`turnId` / `openedAt` 不変)、`joinOpenTurnFromDuplicate`(印つきの配送が重複で捨てられたとき、先の配送が開き直したターンを戻す)(#3330)。`observeScraperCompletionEvidence(..., at, mayClose = true)`: false の間は数えるが閉じない(hooks を出している codex で Stop が来ないと分かる画面でないとき、#3337) **Issue #3437**: `promptOpensTurns` の `pre_tool_use` は、直近のターンがエージェント自身の `stop` で閉じていれば新しいターンを開かない(`endedByOwnStop`。`stale` / `scraper_evidence` 等の後と、`post_tool_use` は従来どおり開く)。 | | `src/lib/session/agent-event-structured-state.ts` | `getStructuredSessionState`(ターンから導く構造化 verdict)と `STRUCTURED_STATE_MAX_AGE_MS`(#3375 分離) | | `src/lib/session/agent-event-awaiting-instruction.ts` | エージェント自身の「指示待ち」フラグ(#1786)と遷移(#3375 分離) | | `src/lib/session/agent-event-ask-user-question.ts` | 進行中の `AskUserQuestion`(#1726)の記録と解放規則(#3375 分離) |