claude-spotter 1.4.14 → 1.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.15
4
+
5
+ **codex ログイン失効でサイレントに死に、host の Claude が無反応になる実害バグを根治**。codex auditor の
6
+ ログインが失効 (`token_revoked` / `refresh_token_reused` / `401 Unauthorized`) すると、`spotter install`
7
+ 済みプロジェクトで毎ターン入力が消えて「Claude が一切反応しない」状態になっていた。実セッションの daemon
8
+ ログ (`handler error on user_input/turn_end: E_CODEX_CLI_EXIT`) + コード監査 + 公式 hook 仕様の裏取りで
9
+ 根本原因を 2 点に特定: (A) codex 異常終了時、stderr/stdout に入っているログイン失効の痕跡を捨てて全部
10
+ `E_CODEX_CLI_EXIT` に潰しており、auth 失敗を区別できていなかった。(B) `UserPromptSubmit` hook が daemon
11
+ エラーを一律 `die(exit 2)` で処理しており、Claude Code は入力時 hook の exit 2 を **ブロッキング扱い =
12
+ プロンプト消去** とするため、失効が永続する限り毎ターン入力が消えていた。
13
+
14
+ ### 変更点
15
+
16
+ - **編集 [src/core/codex-cli-backend.mjs](src/core/codex-cli-backend.mjs)**: codex の非ゼロ終了時に
17
+ stdout+stderr をスキャンし、ログイン失効の痕跡があれば新コード `E_CODEX_CLI_AUTH` (対処法
18
+ 「`codex login`」を含むメッセージ) を投げる。痕跡が無ければ従来通り `E_CODEX_CLI_EXIT`。新規 export
19
+ `isCodexAuthFailure`。分類は非ゼロ終了経路のみ (auth 失敗は <1s で即終了するため timeout 経路は対象外)。
20
+ - **編集 [src/hooks/lib.mjs](src/hooks/lib.mjs)**: `formatSpotterWarning({code,message})` を新設
21
+ (`[Spotter からの警告]` ブロック、`E_CODEX_CLI_AUTH` は `codex login` を案内、他は理由コード入りの汎用
22
+ 文面)。exit-code 契約コメントを「audit 失敗は loud degradation = exit 0 + additionalContext、exit 2 は
23
+ malformed envelope 専用」に更新。
24
+ - **編集 [src/hooks/user-prompt.mjs](src/hooks/user-prompt.mjs)**: daemon/transport/resurrect 失敗で
25
+ `die(exit 2)` する代わりに `degrade()` — 警告を `additionalContext` (drain 済み pending と merge) で出して
26
+ **exit 0 でプロンプトを通す**。失効に限らずあらゆる監査失敗で host が固まらない。throw 値の `.message`
27
+ アクセスを optional chaining 化し、非 Error throw が top-level catch (exit 2) に抜ける穴も塞いだ。
28
+ - **編集 [src/hooks/stop.mjs](src/hooks/stop.mjs)**: backend/transport エラーと marker 消失 (TOCTOU) で
29
+ `die(exit 2 = 継続強制)` をやめ、`status:"degraded"` 記録 + exit 0。pending は積まない (verdict 未生成)。
30
+ loud な警告は次の `UserPromptSubmit` が配信。
31
+ - **編集 [src/hooks/pre-tool-use.mjs](src/hooks/pre-tool-use.mjs)**: daemon/transport エラーで
32
+ `die(exit 2 = ツール拒否)` をやめ、`status:"degraded"` 記録 + exit 0 (ツール許可)。記録は best-effort
33
+ telemetry でありツールを止める理由にならない。
34
+ - **更新 [docs/SPOTTER_CLAUDE_CONTRACT.md](docs/SPOTTER_CLAUDE_CONTRACT.md)**: `UserPromptSubmit` /
35
+ `PreToolUse` / `Stop` の失敗時 exit-code 契約を新挙動に追従。
36
+ - **追記 [docs/open-issues.md](docs/open-issues.md)**: auth-freeze バグの解決を記録。`Stop` 失敗が
37
+ セッション最終ターンだと deferred-delivery の性質上サイレントになる残課題を P2 に追記。
38
+ - **テスト 11 件追加** ([test/hooks.test.mjs](test/hooks.test.mjs) /
39
+ [test/codex-cli-backend.test.mjs](test/codex-cli-backend.test.mjs)): auth 分類 / `formatSpotterWarning` /
40
+ UserPromptSubmit の loud degrade (auth/汎用/pending merge/resurrect 失敗/非 Error throw) / Stop degrade /
41
+ PreToolUse degrade。`node --test` 344 pass / 1 skip 緑。
42
+
43
+ ### 検証
44
+
45
+ 実プロジェクトの監査経路 (`createCodexCliAuditorBackend.judge` を実コードで起動) で、再ログイン後に
46
+ `pass` verdict が返ること、および fake spawn でログイン失効 stderr → `E_CODEX_CLI_AUTH` 分類 → hook が
47
+ `[Spotter からの警告]` を additionalContext に出して exit 0 することを確認。多エージェントの敵対的レビュー
48
+ (host-freeze 完全性 / §0 silent-fallback / 分類器精度+docs) で HIGH 2 / MEDIUM 3 を検出し全て反映。
49
+
3
50
  ## 1.4.14
4
51
 
5
52
  **README 等の公開資産から内部コードネーム "Bell" を撤去**。Spotter の内部設計議論で使われている
@@ -1066,7 +1113,7 @@ Haiku 突然死 (shutdown ログなしで daemon 再起動する事象、v0.12.0
1066
1113
 
1067
1114
  ### 事の発端
1068
1115
 
1069
- v0.8.0 の `spotter db refresh` 実測で x-api (HTTP MCP) が 401 Unauthorized で落ちていた。`claude mcp list` では `x-api: https://kitepon.dynv6.net/mcp (HTTP)` と表示され URL は拾えるが、Spotter の refresh プロセスから叩くと認証情報がないため拒否。ユーザーの指摘で `.mcp.json` を直接 cat したところ、実態は **stdio** で `env: {X_BEARER_TOKEN: "..."}` を持つ設定だった。CLI 表示と actual config が食い違っていた (CLI の cache の古さと思われる)。
1116
+ v0.8.0 の `spotter db refresh` 実測で x-api (HTTP MCP) が 401 Unauthorized で落ちていた。`claude mcp list` では `x-api: https://kitepon.dev/mcp (HTTP)` と表示され URL は拾えるが、Spotter の refresh プロセスから叩くと認証情報がないため拒否。ユーザーの指摘で `.mcp.json` を直接 cat したところ、実態は **stdio** で `env: {X_BEARER_TOKEN: "..."}` を持つ設定だった。CLI 表示と actual config が食い違っていた (CLI の cache の古さと思われる)。
1070
1117
 
1071
1118
  判明した設計上の転換点:
1072
1119
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "1.4.14",
3
+ "version": "1.4.15",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,37 @@ const DEFAULT_CODEX_CLI_REASONING_EFFORT = 'low';
12
12
  const STDERR_LIMIT = 32 * 1024;
13
13
  const STDOUT_LIMIT = 64 * 1024;
14
14
 
15
+ // codex prints auth/login failures to BOTH stdout (the JSON error stream, e.g.
16
+ // {"type":"error","message":"...sign in again..."}) and stderr (codex_login::auth::manager,
17
+ // e.g. "401 Unauthorized ... token_revoked"). We scan the combined text for these markers so a
18
+ // revoked/expired login is classified as the distinct, actionable E_CODEX_CLI_AUTH instead of
19
+ // being collapsed into the generic E_CODEX_CLI_EXIT. Conservative case-insensitive substring
20
+ // match keyed on the wording codex actually emits; if codex changes its wording the generic
21
+ // path still degrades loudly (the hook no longer freezes the host on either code). Classification
22
+ // runs only on the nonzero-exit path below — auth failures are observed to exit immediately
23
+ // (<1s), so the timeout path is not expected to see them; if codex ever starts hanging on auth,
24
+ // the timeout would surface as the generic E_CODEX_CLI_TIMEOUT rather than E_CODEX_CLI_AUTH.
25
+ const CODEX_AUTH_FAILURE_MARKERS = [
26
+ 'token_revoked',
27
+ 'refresh_token_reused',
28
+ 'refresh token was already used',
29
+ 'refresh token has already been used',
30
+ 'invalidated oauth token',
31
+ '401 unauthorized',
32
+ 'sign in again',
33
+ 'sign back in',
34
+ 'log out and sign in',
35
+ 'not logged in',
36
+ 'please log in',
37
+ 'please login',
38
+ ];
39
+
40
+ export function isCodexAuthFailure(text) {
41
+ if (typeof text !== 'string' || text.length === 0) return false;
42
+ const haystack = text.toLowerCase();
43
+ return CODEX_AUTH_FAILURE_MARKERS.some((marker) => haystack.includes(marker));
44
+ }
45
+
15
46
  export const CODEX_AUDITOR_SCHEMA = {
16
47
  type: 'object',
17
48
  additionalProperties: false,
@@ -295,9 +326,17 @@ async function runCodexExec({
295
326
  child.on('close', (code) => {
296
327
  settle(() => {
297
328
  if (code !== 0) {
329
+ const diag = { ...diagnostics(), exitCode: code };
330
+ if (isCodexAuthFailure(`${diag.stdout}\n${diag.stderr}`)) {
331
+ reject(new AuditorBackendError('E_CODEX_CLI_AUTH', 'codex-cli auth failed — codex login required (run `codex login`)', {
332
+ backend: 'codex-cli',
333
+ diagnostics: diag,
334
+ }));
335
+ return;
336
+ }
298
337
  reject(new AuditorBackendError('E_CODEX_CLI_EXIT', `codex-cli exited with code ${code}`, {
299
338
  backend: 'codex-cli',
300
- diagnostics: { ...diagnostics(), exitCode: code },
339
+ diagnostics: diag,
301
340
  }));
302
341
  return;
303
342
  }
package/src/hooks/lib.mjs CHANGED
@@ -1,11 +1,18 @@
1
1
  // Shared hook plumbing — stdin read, error → exit-code mapping per §14.3 / §14.4.
2
2
  //
3
3
  // Exit code contract (§14.3 / §14.4):
4
- // 0 = success (normal flow)
5
- // 1 = expected abnormal (daemon unreachable; user should restart daemon)
6
- // 2 = unexpected (propagate to Claude Code transcript)
4
+ // 0 = success (normal flow), OR a LOUD degradation: the audit could not run but the failure
5
+ // is surfaced in-band via a `[Spotter からの警告]` additionalContext block, so the host
6
+ // stays responsive and the user's prompt is never erased. UserPromptSubmit uses this for
7
+ // any daemon / auditor-backend failure (e.g. codex login expired) — see user-prompt.mjs.
8
+ // 1 = expected abnormal, non-blocking (Claude Code proceeds; first stderr line shown to user).
9
+ // 2 = unexpected protocol/contract violation (malformed Claude Code envelope, missing required
10
+ // field). Reserved for cases where there is no real user prompt worth preserving — a
11
+ // UserPromptSubmit exit 2 BLOCKS and erases the prompt, so audit failures must NOT use it.
7
12
  //
8
- // Silent fallback (exit 0 with missing behaviour) is forbidden. See §14.1.
13
+ // A loud degradation (visible warning naming the cause + remedy) is NOT a silent fallback: the
14
+ // user is explicitly told the audit did not run. The forbidden pattern (§14.1) is the SILENT
15
+ // exit-0 that leaves the user believing they are protected when they are not.
9
16
  //
10
17
  // v0.2 gate helpers (plan §18 / C:\Users\kite_\.claude\plans\10-cuddly-codd.md):
11
18
  // - isChildCall(): env-var gate for Spotter's own child backend invocations
@@ -114,15 +121,6 @@ export function optionalString(input, key) {
114
121
  return value;
115
122
  }
116
123
 
117
- // Map a TransportError / HaikuError into an exit code.
118
- export function exitCodeFor(err) {
119
- if (err && typeof err.code === 'string') {
120
- if (err.code === 'E_UNREACHABLE') return 1;
121
- return 2;
122
- }
123
- return 2;
124
- }
125
-
126
124
  export function die(message, exitCode = 2) {
127
125
  process.stderr.write(`spotter-hook: ${message}\n`);
128
126
  process.exit(exitCode);
@@ -162,3 +160,25 @@ export function formatTransparentBlockReason(missingTools) {
162
160
  ...lines,
163
161
  ].join('\n');
164
162
  }
163
+
164
+ // Loud degradation notice (§0 / §14.1): the auditor could not run this turn. Surfaced via the
165
+ // same additionalContext channel as findings so the host stays responsive AND the user is told
166
+ // they are temporarily unprotected (and, for an expired codex login, exactly how to recover).
167
+ // This is the opposite of a silent fallback.
168
+ export function formatSpotterWarning({ code, message } = {}) {
169
+ const header = '[Spotter からの警告]';
170
+ if (code === 'E_CODEX_CLI_AUTH') {
171
+ return [
172
+ header,
173
+ 'Spotter の監査エンジン (codex) のログインが失効しているため、今回の入力は監査できませんでした。',
174
+ 'この応答を続ける前に、ユーザーに「端末で `codex login` を実行して再ログインすれば Spotter の監査が復旧する」ことを伝えてください。',
175
+ ].join('\n');
176
+ }
177
+ const lines = [
178
+ header,
179
+ `Spotter は今回の入力を監査できませんでした (理由コード: ${code ?? 'unknown'})。この応答は監査されていません。`,
180
+ 'この応答を続ける前に、ユーザーに Spotter の監査が一時的に無効になっていることを伝えてください。',
181
+ ];
182
+ if (typeof message === 'string' && message.length > 0) lines.push(`詳細: ${message}`);
183
+ return lines.join('\n');
184
+ }
@@ -7,7 +7,6 @@
7
7
  import {
8
8
  readStdinJson,
9
9
  requireString,
10
- exitCodeFor,
11
10
  die,
12
11
  isChildCall,
13
12
  isSubagentCall,
@@ -23,7 +22,6 @@ export async function runPreToolUse({
23
22
  readInput = readStdinJson,
24
23
  sendRequestFn = sendRequest,
25
24
  recordHookEventFn = recordClaudeHookEvent,
26
- dieFn = die,
27
25
  } = {}) {
28
26
  if (isChildCall()) return;
29
27
  const input = await readInput();
@@ -43,17 +41,21 @@ export async function runPreToolUse({
43
41
  timeoutMs: TIMEOUT_MS,
44
42
  });
45
43
  if (response.ok !== true) {
44
+ // Recording is best-effort telemetry, not an audit verdict. On a daemon-side error, allow
45
+ // the tool (exit 0) — a PreToolUse exit 2 would DENY the tool, which must never happen
46
+ // just because Spotter could not record it. The audit at user_input/turn_end still warns
47
+ // loudly if the backend is down.
46
48
  await recordHookEventFn({
47
49
  projectRoot,
48
50
  event: {
49
51
  hook: 'PreToolUse',
50
- status: 'error',
52
+ status: 'degraded',
51
53
  toolName,
52
54
  code: response.error?.code ?? 'E_INTERNAL',
55
+ reason: 'daemon_error',
53
56
  durationMs: Date.now() - startedAt,
54
57
  },
55
58
  });
56
- dieFn(`daemon error on tool_used: ${response.error?.code ?? '?'}: ${response.error?.message ?? ''}`, 2);
57
59
  return;
58
60
  }
59
61
  await recordHookEventFn({
@@ -66,17 +68,18 @@ export async function runPreToolUse({
66
68
  },
67
69
  });
68
70
  } catch (err) {
71
+ // Transport failure during best-effort recording: allow the tool (no exit 2 deny).
69
72
  await recordHookEventFn({
70
73
  projectRoot,
71
74
  event: {
72
75
  hook: 'PreToolUse',
73
- status: 'error',
76
+ status: 'degraded',
74
77
  toolName,
75
78
  code: err?.code ?? 'E_INTERNAL',
79
+ reason: 'transport',
76
80
  durationMs: Date.now() - startedAt,
77
81
  },
78
82
  });
79
- dieFn(`pre-tool-use transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
80
83
  }
81
84
  }
82
85
 
@@ -10,8 +10,11 @@
10
10
  // queue and folds the entries into `additionalContext`, so Bell receives the audit finding
11
11
  // alongside the next user input. The original A reply stays as the turn's final message.
12
12
  //
13
- // `decision:"block"` is no longer emitted from this hook. Backend / transport errors still
14
- // exit with code 1 + stderr (silent fallback is forbidden — see lib.mjs).
13
+ // `decision:"block"` is no longer emitted from this hook. Backend / transport errors do NOT
14
+ // force a continuation (a Stop exit 2 would block stopping = harmful noise on a Spotter-side
15
+ // failure); they are recorded as `degraded` and exit 0. The next UserPromptSubmit surfaces the
16
+ // loud `[Spotter からの警告]`. No verdict is produced on failure, so nothing is queued — this is
17
+ // still not a silent "all clear" (the surfacing just moves to the next turn — see lib.mjs).
15
18
  // `stop_hook_active:true` is still observed: the daemon early-passes on it, so we just
16
19
  // receive `pass:true` and return without writing pending context.
17
20
  //
@@ -20,7 +23,6 @@
20
23
  import {
21
24
  readStdinJson,
22
25
  requireString,
23
- exitCodeFor,
24
26
  die,
25
27
  findSpotterMarker,
26
28
  formatTransparentBlockReason,
@@ -41,7 +43,6 @@ export async function runStop({
41
43
  appendPendingContextFn = appendPendingContext,
42
44
  getLastAssistantTextFn = getLastAssistantText,
43
45
  recordHookEventFn = recordClaudeHookEvent,
44
- dieFn = die,
45
46
  } = {}) {
46
47
  if (isChildCall()) return;
47
48
  const input = await readInput();
@@ -71,30 +72,36 @@ export async function runStop({
71
72
  timeoutMs: TIMEOUT_MS,
72
73
  });
73
74
  } catch (err) {
75
+ // Spotter-side failure (daemon unreachable, etc.): record + exit 0. Do NOT force the model to
76
+ // continue (a Stop exit 2 blocks stopping). The next UserPromptSubmit surfaces the loud
77
+ // warning; no pending is written, so this is not a silent "all clear" — there was no verdict.
74
78
  await recordHookEventFn({
75
79
  projectRoot,
76
80
  event: {
77
81
  hook: 'Stop',
78
- status: 'error',
82
+ status: 'degraded',
79
83
  code: err?.code ?? 'E_INTERNAL',
84
+ reason: 'transport',
80
85
  durationMs: Date.now() - startedAt,
81
86
  },
82
87
  });
83
- dieFn(`stop transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
84
88
  return;
85
89
  }
86
90
 
87
91
  if (response.ok !== true) {
92
+ // Auditor backend failed (e.g. codex login expired): record + exit 0. Forcing a continuation
93
+ // (exit 2) on a Spotter-side failure is harmful; the loud warning is delivered by the next
94
+ // UserPromptSubmit. No verdict was produced, so nothing is queued.
88
95
  await recordHookEventFn({
89
96
  projectRoot,
90
97
  event: {
91
98
  hook: 'Stop',
92
- status: 'error',
99
+ status: 'degraded',
93
100
  code: response.error?.code ?? 'E_INTERNAL',
101
+ reason: 'daemon_error',
94
102
  durationMs: Date.now() - startedAt,
95
103
  },
96
104
  });
97
- dieFn(`daemon error on turn_end: ${response.error?.code ?? '?'}: ${response.error?.message ?? ''}`, 2);
98
105
  return;
99
106
  }
100
107
 
@@ -117,9 +124,10 @@ export async function runStop({
117
124
  // decision:"block". Using the same transparent block-reason wording keeps the user-facing
118
125
  // text identical to the prior block flow.
119
126
  if (!projectRoot) {
120
- // Marker walk-up returns null only when isOutsideSpotterProject would have early-returned
121
- // above. Reaching here implies the marker disappeared mid-turn; treat as unexpected.
122
- dieFn(`stop: cannot queue pending context — no .spotter/marker.json above cwd=${input.cwd}`, 2);
127
+ // TOCTOU: the marker disappeared between the isOutsideSpotterProject guard and now. We cannot
128
+ // persist the finding (no project dir to write into), but a Stop exit 2 would force the model
129
+ // to continue — the harmful pattern this hook avoids. Drop the unpersistable finding and exit
130
+ // 0; the next UserPromptSubmit re-audits from a fresh marker walk.
123
131
  return;
124
132
  }
125
133
  const text = formatTransparentBlockReason(result.missing_tools);
@@ -16,9 +16,9 @@
16
16
  import {
17
17
  readStdinJson,
18
18
  requireString,
19
- exitCodeFor,
20
19
  die,
21
20
  formatTransparentContext,
21
+ formatSpotterWarning,
22
22
  isChildCall,
23
23
  isSubagentCall,
24
24
  isOutsideSpotterProject,
@@ -39,7 +39,6 @@ export async function runUserPrompt({
39
39
  drainPendingContextsFn = drainPendingContexts,
40
40
  recordHookEventFn = recordClaudeHookEvent,
41
41
  writeOutput = (text) => process.stdout.write(text),
42
- dieFn = die,
43
42
  } = {}) {
44
43
  if (isChildCall()) return;
45
44
  const input = await readInput();
@@ -58,6 +57,26 @@ export async function runUserPrompt({
58
57
  ? await drainPendingContextsFn({ projectRoot, sessionId })
59
58
  : [];
60
59
 
60
+ // Loud degradation (§0 / §14.1): the audit could not run, but the user's prompt is valid.
61
+ // Surface a visible [Spotter からの警告] (merged with any drained pending context) and exit 0
62
+ // so the prompt reaches the host — never erase it with a blocking exit 2.
63
+ const degrade = async ({ code, message, reason }) => {
64
+ const contexts = pendingContexts.slice();
65
+ contexts.push(formatSpotterWarning({ code, message }));
66
+ emitAdditionalContext(writeOutput, contexts);
67
+ await recordHookEventFn({
68
+ projectRoot,
69
+ event: {
70
+ hook: 'UserPromptSubmit',
71
+ status: 'degraded',
72
+ code: code ?? 'E_INTERNAL',
73
+ reason,
74
+ pendingContextCount: pendingContexts.length,
75
+ durationMs: Date.now() - startedAt,
76
+ },
77
+ });
78
+ };
79
+
61
80
  if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) {
62
81
  if (pendingContexts.length > 0) {
63
82
  emitAdditionalContext(writeOutput, pendingContexts);
@@ -88,67 +107,31 @@ export async function runUserPrompt({
88
107
  response = await sendUserInput();
89
108
  } catch (err) {
90
109
  if (err instanceof TransportError && err.code === 'E_UNREACHABLE') {
91
- // v0.12.0: daemon is gone (heartbeat shutdown, crash, missing). Resurrect and retry.
92
- if (!projectRoot) {
93
- dieFn(`user-prompt: cannot resurrect daemon — no .spotter/marker.json above cwd=${input.cwd}`, 2);
94
- return;
95
- }
110
+ // v0.12.0: daemon is gone (heartbeat shutdown, crash, missing). Resurrect and retry once.
111
+ // projectRoot is guaranteed non-null here (isOutsideSpotterProject early-returned otherwise).
96
112
  try {
97
113
  await spawnDaemonAndWaitReadyFn({ sessionId, projectRoot });
98
- } catch (spawnErr) {
99
- await recordHookEventFn({
100
- projectRoot,
101
- event: {
102
- hook: 'UserPromptSubmit',
103
- status: 'error',
104
- code: spawnErr?.code ?? 'E_RESURRECT_FAILED',
105
- durationMs: Date.now() - startedAt,
106
- },
107
- });
108
- dieFn(`user-prompt: daemon resurrect failed: ${spawnErr.message}`, spawnErr.exitCode ?? 2);
109
- return;
110
- }
111
- try {
112
114
  response = await sendUserInput();
113
- } catch (retryErr) {
114
- await recordHookEventFn({
115
- projectRoot,
116
- event: {
117
- hook: 'UserPromptSubmit',
118
- status: 'error',
119
- code: retryErr?.code ?? 'E_INTERNAL',
120
- durationMs: Date.now() - startedAt,
121
- },
115
+ } catch (recoverErr) {
116
+ await degrade({
117
+ code: recoverErr?.code ?? 'E_RESURRECT_FAILED',
118
+ message: recoverErr?.message ?? '',
119
+ reason: 'resurrect_failed',
122
120
  });
123
- dieFn(`user-prompt transport failure after resurrect: ${retryErr.code ?? '?'}: ${retryErr.message}`, exitCodeFor(retryErr));
124
121
  return;
125
122
  }
126
123
  } else {
127
- await recordHookEventFn({
128
- projectRoot,
129
- event: {
130
- hook: 'UserPromptSubmit',
131
- status: 'error',
132
- code: err?.code ?? 'E_INTERNAL',
133
- durationMs: Date.now() - startedAt,
134
- },
135
- });
136
- dieFn(`user-prompt transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
124
+ await degrade({ code: err?.code ?? 'E_INTERNAL', message: err?.message ?? '', reason: 'transport' });
137
125
  return;
138
126
  }
139
127
  }
140
128
 
141
129
  if (response.ok !== true) {
142
- await recordHookEventFn({
143
- projectRoot,
144
- event: {
145
- hook: 'UserPromptSubmit',
146
- status: 'error',
147
- code: response.error?.code ?? 'E_INTERNAL',
148
- durationMs: Date.now() - startedAt,
149
- },
130
+ await degrade({
131
+ code: response.error?.code ?? 'E_INTERNAL',
132
+ message: response.error?.message ?? '',
133
+ reason: 'daemon_error',
150
134
  });
151
- dieFn(`daemon error on user_input: ${response.error?.code ?? '?'}: ${response.error?.message ?? ''}`, 2);
152
135
  return;
153
136
  }
154
137