diff --git a/changelog.d/3450.md b/changelog.d/3450.md new file mode 100644 index 000000000..e8fe7286c --- /dev/null +++ b/changelog.d/3450.md @@ -0,0 +1,2 @@ + +- **fix(agent-health): 手動設定の中継スクリプト経由の Claude の Stop でもバックグラウンドの作業の残りを読む** (#3450): `scripts/hooks/cmate-agent-event.sh` が Claude の Stop の payload から `transcript_path` だけを転送する(`.claude/projects` 配下の `.jsonl` の絶対パスのときのみ)。#1549 の手動設定でも #3430 の自己再開の保留が効く diff --git a/docs/en/user-guide/agent-event-hooks.md b/docs/en/user-guide/agent-event-hooks.md index 64a45dce2..a85b273eb 100644 --- a/docs/en/user-guide/agent-event-hooks.md +++ b/docs/en/user-guide/agent-event-hooks.md @@ -572,6 +572,12 @@ harm than the lost event. Use `--strict` where a failure must be detected, such Claude Code hands the hook `{"session_id":"...","hook_event_name":"Stop","cwd":"..."}` as **JSON on stdin**, so pass `--stdin-json`. The mapping is the one in §2. +Of the Stop payload the relay forwards `transcript_path`, under the same name, and nothing else +(only when it is an absolute path to a `.jsonl` under `.claude/projects`; no other field and no +text). The receiver reads that transcript and, when background work with no completion notice is +still pending, treats the Stop as one the agent will resume from by itself (Issue #3430, #3450). +Without `--stdin-json` it is not sent. + On a worktree running a different port, prefix `command` with `CM_PORT=3135 `. > Without `--stdin-json` the script does not read stdin, so it does not block on a setup that passes diff --git a/docs/module-reference.md b/docs/module-reference.md index 7bc67dccc..4b079a410 100644 --- a/docs/module-reference.md +++ b/docs/module-reference.md @@ -319,7 +319,7 @@ const myState = getOrInitGlobal('__myState', () => new Map()); | `scripts/design-doc-identifiers.ts` | 設計書が名指す識別子の実在監査(Issue #1995、`npx tsx scripts/design-doc-identifiers.ts [--json]`)。markdown のバックティック span を機械抽出し、型名 / 関数名 / 定数 / camelCase シンボル / snake_case 理由コード / ファイルパスに分類して `src`・`tests`・`scripts` の実在と突き合わせる。**コメントを除去したうえで照合する**のが要点で、削除された名前は「コメントにだけ残る」という固有の痕跡を残す(#1900 の `openOpencodeEventStream` 改名も #1933 の `getStatusCaptureLines` 削除もこの形だった)。分類は `ok` / `test-only` / `comment-only` / `missing` の 4 値。パス解決は brace 展開・`*`/`**`・`` プレースホルダ・`/` 境界の suffix 一致(`auto-yes/route.ts` → `src/app/api/worktrees/[id]/auto-yes/route.ts`)に対応し、`~`・`$VAR`・URL・先頭 `...`(省略記法)・`dev-reports/` は対象外。**自分自身とガードテストは corpus から除外する**(自分の散文を証拠に読むと、捕まえるべき名前がちょうど「実在する」に化ける)。**未実装と陳腐化は区別しない**(設計書は「これから作るもの」を名指す文書なので外からは同じに見える)。判定は呼び出し側の宣言表に置き、報告のみで exit code は常に 0。消費者は `tests/unit/docs/design-doc-identifier-audit.test.ts` **Issue #2212: `DECLARED` の `history` 区分は「設計書が削除・改名・撤回を*書いている*」ことが条件**である。#2200 は `docs/design/**` が scope 外だったため、設計書が何も書いていない状態のまま `MessageList` / `generatingContent` / `realtimeOutput` の 3 行を `history` に置いており、ガードは緑のまま読者だけが「まだ在る」と誤読する状態だった。#2212 が `multi-agent-state-architecture.md` の §9 / §10.13 / §15.3 DR3-022 / §15.4 に「#2200 で削除済み」を注記し、§15.4 の「後日の更新」段落を唯一の説明置き場にした(**§9 と DR3-022 の判定内容=2026-08-21 時点の否定的実測は書き換えない**) | | `scripts/measure-real-shell-budget.mjs` | 実シェル予算の再測定ツール(Issue #1985、`node scripts/measure-real-shell-budget.mjs [--guard=MS] [--top=N] report.json...`)。vitest の JSON レポート(複数可・**別 checkout のレポートも `tests/` 以降のパスで解決**するので N 本同時実行の全ランをまとめて食える)から、実シェルを起動するテストの所要分布(p50/p90/p99/p99.9/max、ガード超過件数、最遅テスト一覧)を出す。**ファミリ判定の正規表現は `tests/helpers/real-shell-budget.ts` の `REAL_SHELL_MARKERS` 宣言を実行時に読み出す**(コピーを持つと 2 つ目の権威になり黙って乖離するため。宣言が見つからなければ推測せず throw)。パーセンタイルは nearest-rank = 報告値は必ず実在した実測値。**指標はテスト単位でガードは呼び出し単位**なので、この数字は 1 呼び出しの上界であり、#1950 と同じ指標であること(=条件だけが違うこと)が比較可能性の根拠。消費者は `tests/unit/scripts/measure-real-shell-budget.test.ts` | | `scripts/run-related-unit-tests.mjs` | 変更に関係するテストとリポジトリのファイルを読むテストだけを実行するユニットテストランナー。**Issue #2639**: `/orchestrate` の `unit-related` ゲート用スクリプト。`--base ` を受け取り、差分から変更種別を判定(none / related / full)。related では vitest `--changed` と `findTextScanTests` のテストを実行 **Issue #2889**: `classifyChanges` の除外対象に `changelog.d/` を追加(`CHANGELOG.md` と同じ扱い)。`/orchestrate` の契約で全ワーカーが断片をコミットするため、以前は `unit-related` が毎回 `mode=full` に切り替わっていた | -| `scripts/hooks/cmate-agent-event.sh` | 同梱 hook スクリプト(Issue #1549、**bash 3.2 互換**=連想配列・mapfile 不可)。`--tool/--event/--cwd/--session-id/--worktree-id/--instance-id/--json/--stdin-json/--url/--strict`。Claude Code は stdin JSON(`--stdin-json`)、Codex notify は位置引数 JSON。**Issue #1759 で 7 語 + 5 ツールの native 名に拡張**(`--detail` も追加): `--event` は stop / notification / session_start / user_prompt_submit / session_end / **pre_tool_use / post_tool_use**、`map_event_name` は CamelCase 系(Stop・SubagentStop・agent-turn-complete→stop、Notification、SessionStart、SessionEnd、UserPromptSubmit、**PreToolUse / PostToolUse**)に加え **gemini の BeforeAgent→user_prompt_submit / AfterAgent→stop / BeforeTool→pre_tool_use / AfterTool→post_tool_use**(CLI 自身の `gemini hooks migrate --from-claude` 変換表)。**これは Phase 4 全体のハードブロッカーだった** — `type:"http"` は 4 ツールとも不可(#1757)なのでこの中継が唯一の配送路であり、#1726 が 7 語にしたのに `--event` が 5 語のままで `pre_tool_use` が exit 2 になっていた。**PermissionRequest は引き続き対応づけず exit 2**(応答ボディが裁定になるイベントは #1724 の受け口)。対応語の無い native 名(PreInvocation/PostInvocation/BeforeModel/PreCompact/SubagentStart 等)も**推測せず exit 2**。detail はイベントごとに別フィールドから取る(notification→`notification_type`、session_end→`reason`、session_start→`source`、**pre/post_tool_use→`tool_name`(無ければ antigravity 用に nested `toolCall.name`)**。**`message` は人間向け文言なので使わない**)。session_id は `session_id`→**`conversationId`(antigravity)**→**`turn_id`(codex)**→`turn-id` の順。`--worktree-id`/`--instance-id` は**指定時のみ**ボディに載る(未指定=#1549 と同挙動)。cwd 決定順 `--cwd`→CM_AGENT_CWD→CLAUDE_PROJECT_DIR→JSON cwd→$PWD。**POST 失敗でも exit 0**(サーバ停止でエージェントのセッションを壊さない。`--strict` で伝播)。#1722 の自動注入では **`SessionStart` 専用の中継**として使われる(http が使えない唯一のイベント)。設定手順は docs/user-guide/agent-event-hooks.md **Issue #2955**: `session_start` のときだけ payload の `model` を POST 本文に `model` として載せる(無ければ付けない)。これが無いと Claude の `SessionStart` のモデルがサーバに届かなかった。 **Issue #3330**: claude の user_prompt_submit で prompt が `` で始まるとき `"queuedNotice":true` を送る(prompt 本文は送らない)。 | +| `scripts/hooks/cmate-agent-event.sh` | 同梱 hook スクリプト(Issue #1549、**bash 3.2 互換**=連想配列・mapfile 不可)。`--tool/--event/--cwd/--session-id/--worktree-id/--instance-id/--json/--stdin-json/--url/--strict`。Claude Code は stdin JSON(`--stdin-json`)、Codex notify は位置引数 JSON。**Issue #1759 で 7 語 + 5 ツールの native 名に拡張**(`--detail` も追加): `--event` は stop / notification / session_start / user_prompt_submit / session_end / **pre_tool_use / post_tool_use**、`map_event_name` は CamelCase 系(Stop・SubagentStop・agent-turn-complete→stop、Notification、SessionStart、SessionEnd、UserPromptSubmit、**PreToolUse / PostToolUse**)に加え **gemini の BeforeAgent→user_prompt_submit / AfterAgent→stop / BeforeTool→pre_tool_use / AfterTool→post_tool_use**(CLI 自身の `gemini hooks migrate --from-claude` 変換表)。**これは Phase 4 全体のハードブロッカーだった** — `type:"http"` は 4 ツールとも不可(#1757)なのでこの中継が唯一の配送路であり、#1726 が 7 語にしたのに `--event` が 5 語のままで `pre_tool_use` が exit 2 になっていた。**PermissionRequest は引き続き対応づけず exit 2**(応答ボディが裁定になるイベントは #1724 の受け口)。対応語の無い native 名(PreInvocation/PostInvocation/BeforeModel/PreCompact/SubagentStart 等)も**推測せず exit 2**。detail はイベントごとに別フィールドから取る(notification→`notification_type`、session_end→`reason`、session_start→`source`、**pre/post_tool_use→`tool_name`(無ければ antigravity 用に nested `toolCall.name`)**。**`message` は人間向け文言なので使わない**)。session_id は `session_id`→**`conversationId`(antigravity)**→**`turn_id`(codex)**→`turn-id` の順。`--worktree-id`/`--instance-id` は**指定時のみ**ボディに載る(未指定=#1549 と同挙動)。cwd 決定順 `--cwd`→CM_AGENT_CWD→CLAUDE_PROJECT_DIR→JSON cwd→$PWD。**POST 失敗でも exit 0**(サーバ停止でエージェントのセッションを壊さない。`--strict` で伝播)。#1722 の自動注入では **`SessionStart` 専用の中継**として使われる(http が使えない唯一のイベント)。設定手順は docs/user-guide/agent-event-hooks.md **Issue #2955**: `session_start` のときだけ payload の `model` を POST 本文に `model` として載せる(無ければ付けない)。これが無いと Claude の `SessionStart` のモデルがサーバに届かなかった。 **Issue #3330**: claude の user_prompt_submit で prompt が `` で始まるとき `"queuedNotice":true` を送る(prompt 本文は送らない)。 **Issue #3450**: claude の stop で payload の `transcript_path` だけを同名で転送する(`/*/.claude/projects/*.jsonl` の絶対パスのときのみ。他の項目・本文は送らない)。#1549 の手動 Stop hook でも #3430 の self_resume_pending が付く。 | | `scripts/load-env.sh` | `.env` を export する bash 専用ローダー。**Issue #2132**: 先頭に `${BASH_SOURCE+x}` ガードを置き、zsh / dash から `source` されたら理由と代替コマンドを stderr に出して `return 1` する。以前は `${BASH_SOURCE[0]}` が空文字→`dirname ""`=`.` となって親ディレクトリの `.env` を探し、**1 変数も export せず rc=0 で返っていた**(`#!/bin/bash` は `source` には効かない)。ガードは bash 専用構文より前に置くこと | | `scripts/start.sh` | **ビルドしない**起動スクリプト。**Issue #2132**: `--daemon` / `-d` / `--help` を追加(引数解析は `load-env.sh` の source より前なので `--help` は .env もビルドも要らない)。daemon 経路は `build-and-start.sh` の daemon ブロックからビルド段を抜いた移植で、ログローテーション(10MB×3 世代)/PID `chmod 600` [S4-003]/ログ `chmod 640` [S4-005]/symlink 拒否 [S4-006]/PID・ポート二重起動チェック [D1-004] を保持。`dist/server/server.js` が無ければ**起動せずエラー**。停止は `stop-server.sh`。**Issue #2473**: ポートでの二重起動チェック [D1-004] は `lib/port-pids.sh` の `find_listen_pids_by_port` で待ち受け中(LISTEN)のプロセスだけを見る(`build-and-start.sh` も同じ) **Issue #2488**: PID ファイルの二重起動チェックは、PID が生きていても即 exit 1 せず `wait_for_exit` で `STOP_GRACE_SECONDS`(`CM_STOP_GRACE_SECONDS`、既定 10 秒)だけ待ってから判定する。`server.ts` の graceful shutdown は待ち受けソケットを先に閉じてから最大 3 秒生きるので、直前の停止から戻った時点の生存は「終了しかけ」でありうる。待っても生きていれば `Server is already running (PID: …)` で従来どおり exit 1。`still_alive` / `wait_for_exit` は `stop.sh` / `stop-server.sh` / `build-and-start.sh` と**同一文字列**(各スクリプトは単独で走る必要があり、`lib/port-pids.sh` は「このポートのサーバは誰か」を答える別の関心事)。同一性は `tests/unit/scripts/stop-waits-for-exit-2488.test.ts` が突き合わせる | | `scripts/stop.sh` | アプリ停止スクリプト(pm2 があれば `pm2 stop`、無ければポート経由)。**Issue #2473**: 停止対象は `lib/port-pids.sh` の `find_listen_pids_by_port` が返す**待ち受け中のプロセスだけ**で、`print_port_targets` で署名(コマンドライン)を出してから signal する。**Issue #2488**: 完了判定を LISTEN の有無から**プロセスの生死**へ変更。SIGTERM を送った PID そのものを `kill -0` で 100ms 間隔にポーリングし、`CM_STOP_GRACE_SECONDS`(既定 10 秒 > `server.ts` の 3 秒強制終了)を超えた分だけ SIGKILL へ上げる。加えて `stop-server.sh` にしか無かった PID ファイル段(`logs/server.pid` = `nohup npm start`。LISTEN しないのでポート検索には映らない)を追加し、停止後に PID ファイルを必ず削除する。挙動: `tests/unit/scripts/stop-waits-for-exit-2488.test.ts` | diff --git a/docs/user-guide/agent-event-hooks.md b/docs/user-guide/agent-event-hooks.md index 680484ae3..ed1fb7412 100644 --- a/docs/user-guide/agent-event-hooks.md +++ b/docs/user-guide/agent-event-hooks.md @@ -598,6 +598,11 @@ cmate-agent-event.sh [--tool ID] [--event EVENT] [--cwd PATH] [--session-id ID] Claude Code は hook に `{"session_id":"...","hook_event_name":"Stop","cwd":"..."}` を **stdin の JSON** で渡すので `--stdin-json` を付ける。対応づけは §2 のとおり。 +中継スクリプトは Stop の payload のうち `transcript_path` だけを同じ名前で転送する +(`.claude/projects` 配下の `.jsonl` を指す絶対パスのときだけ。他の項目や本文は送らない)。 +受け口はこれで transcript を読み、完了通知の届いていないバックグラウンドの作業が残っていれば、 +その Stop を自己再開の保留として扱う(Issue #3430・#3450)。`--stdin-json` が無いと送られない。 + ポートを変えている worktree では `command` の前に `CM_PORT=3135 ` を付ける。 > `--stdin-json` を付けないと stdin を読まないので、hook が stdin を渡さない構成でも diff --git a/scripts/hooks/cmate-agent-event.sh b/scripts/hooks/cmate-agent-event.sh index eec758197..51593153c 100755 --- a/scripts/hooks/cmate-agent-event.sh +++ b/scripts/hooks/cmate-agent-event.sh @@ -297,6 +297,26 @@ if [ "$EVENT" = "user_prompt_submit" ] && [ "$TOOL" = "claude" ] && [ -n "$HOOK_ BODY="$BODY,\"queuedNotice\":true" fi fi +# Issue #3450. On a Claude `Stop` the receiver reads the session's transcript +# for background work that has not been notified yet (#3430), and it finds the +# transcript by the payload's `transcript_path`. An injected `type: "http"` Stop +# hook posts the payload verbatim, but a hand-configured Stop hook (§3 of +# docs/user-guide/agent-event-hooks.md, Issue #1549) comes through here, so the +# field is forwarded under its own name — the one field, never the rest of the +# payload. Claude's `stop` only, so codex (whose shared relay is a copy of this +# script) and every other tool keep the body they had. +# +# Only a path the receiver could accept is sent: absolute, under a +# `.claude/projects` directory and ending in `.jsonl`. The receiver checks it +# again against its own home; anything else would be ignored there anyway. +if [ "$EVENT" = "stop" ] && [ "$TOOL" = "claude" ] && [ -n "$HOOK_JSON" ]; then + TRANSCRIPT_PATH="$(json_string_field "$HOOK_JSON" 'transcript_path')" + case "$TRANSCRIPT_PATH" in + /*/.claude/projects/*.jsonl) + BODY="$BODY,\"transcript_path\":\"$(json_escape "$TRANSCRIPT_PATH")\"" + ;; + esac +fi BODY="$BODY}" # Token via --header rather than the command line of a subprocess: argv is world diff --git a/tests/unit/scripts/cmate-agent-event-transcript-path-3450.test.ts b/tests/unit/scripts/cmate-agent-event-transcript-path-3450.test.ts new file mode 100644 index 000000000..15e25a02a --- /dev/null +++ b/tests/unit/scripts/cmate-agent-event-transcript-path-3450.test.ts @@ -0,0 +1,140 @@ +/** + * The relay forwards a Claude `Stop`'s `transcript_path` (Issue #3450). + * + * #3430 reads the session's transcript on a Claude `Stop` to tell whether the + * turn left background work behind, and finds it by the payload's + * `transcript_path`. The injected `type: "http"` hook posts the payload as it + * is; a hand-configured Stop hook (docs/user-guide/agent-event-hooks.md §3, + * Issue #1549) posts through `scripts/hooks/cmate-agent-event.sh`, which built + * its body from a fixed set of fields and dropped this one. + * + * Same harness as `cmate-agent-event.test.ts`: a fake `curl` first on PATH + * records what would have been sent. No server. + * + * @vitest-environment node + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { removeTempDir } from '@tests/helpers/temp-dir'; +import { claudeStopLeavesBackgroundWork } from '@/lib/hooks/sources/claude/self-resume'; + +const SCRIPT = join(process.cwd(), 'scripts/hooks/cmate-agent-event.sh'); + +let root: string; +let fakeBin: string; +let argsFile: string; +let home: string; +let transcript: string; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'cmate-relay-3450-')); + fakeBin = join(root, 'bin'); + mkdirSync(fakeBin); + argsFile = join(fakeBin, 'curl-args.txt'); + const fakeCurl = join(fakeBin, 'curl'); + writeFileSync( + fakeCurl, + ['#!/usr/bin/env bash', 'printf "%s\\n" "$@" > "$CURL_ARGS_FILE"', 'exit 0', ''].join('\n') + ); + chmodSync(fakeCurl, 0o755); + + home = join(root, 'home'); + const projectDir = join(home, '.claude', 'projects', '-repos-wt-claude'); + mkdirSync(projectDir, { recursive: true }); + transcript = join(projectDir, 'sess-3450.jsonl'); + // One background Bash with no notification yet: the turn will resume. + writeFileSync( + transcript, + JSON.stringify({ type: 'user', toolUseResult: { backgroundTaskId: 'bg3450' } }) + '\n' + ); +}); + +afterAll(() => removeTempDir(root)); + +beforeEach(() => rmSync(argsFile, { force: true })); + +function relay(args: string[], stdin: string): Record { + const result = spawnSync('bash', [SCRIPT, ...args], { + encoding: 'utf8', + input: stdin, + env: { + ...process.env, + PATH: `${fakeBin}:${process.env.PATH ?? ''}`, + CURL_ARGS_FILE: argsFile, + CM_HOOK_URL: '', + CM_AUTH_TOKEN: '', + CM_AGENT_TOOL: '', + CM_AGENT_CWD: '', + CLAUDE_PROJECT_DIR: '', + CM_HOST: '', + CM_PORT: '', + }, + }); + expect(result.status, result.stderr).toBe(0); + expect(existsSync(argsFile), 'curl was never invoked').toBe(true); + const curlArgs = readFileSync(argsFile, 'utf8').split('\n').slice(0, -1); + return JSON.parse(curlArgs[curlArgs.indexOf('--data-binary') + 1]) as Record; +} + +const stopPayload = (extra: Record = {}): string => + JSON.stringify({ + session_id: 'sess-3450', + transcript_path: transcript, + cwd: '/repos/wt-claude', + permission_mode: 'default', + hook_event_name: 'Stop', + stop_hook_active: false, + last_assistant_message: 'Waiting for the tests to finish.', + ...extra, + }); + +describe('a Claude Stop through the relay (Issue #3450)', () => { + it('sends transcript_path and the session id, and nothing else from the payload', () => { + expect(relay(['--stdin-json'], stopPayload())).toEqual({ + tool: 'claude', + event: 'stop', + cwd: '/repos/wt-claude', + sessionId: 'sess-3450', + transcript_path: transcript, + }); + }); + + it('sends a body the receiver reads as leaving background work behind', () => { + const body = relay(['--stdin-json'], stopPayload()); + expect(claudeStopLeavesBackgroundWork(body, { homeDir: home })).toBe(true); + }); + + it('also forwards it when the hook passes the payload with --json', () => { + expect(relay(['--json', stopPayload()], '').transcript_path).toBe(transcript); + }); + + it('omits a path the receiver would refuse anyway', () => { + for (const path of ['/tmp/t.jsonl', 'relative/.claude/projects/x/s.jsonl', `${transcript}.txt`, '']) { + const body = relay(['--stdin-json'], stopPayload({ transcript_path: path })); + expect(body, path).not.toHaveProperty('transcript_path'); + } + }); + + it('omits it when the payload has none', () => { + const payload = JSON.stringify({ session_id: 's', hook_event_name: 'Stop', cwd: '/r' }); + expect(relay(['--stdin-json'], payload)).not.toHaveProperty('transcript_path'); + }); + + it('leaves every other event and every other tool as it was', () => { + // codex's shared relay is a byte copy of this script; its bodies must not change. + const codex = relay(['--tool', 'codex', '--stdin-json'], stopPayload()); + expect(codex).not.toHaveProperty('transcript_path'); + + const submit = relay(['--stdin-json'], stopPayload({ hook_event_name: 'UserPromptSubmit', prompt: 'go' })); + expect(submit).toEqual({ + tool: 'claude', + event: 'user_prompt_submit', + cwd: '/repos/wt-claude', + sessionId: 'sess-3450', + }); + }); +});