claude-spotter 0.13.0 → 0.13.2

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,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.2
4
+
5
+ **Daemon の死因を必ずログに残す診断インフラ + Haiku 子プロセス stdio の防御的 error listener**。v0.13.1 までは daemon が `uncaughtException` / `unhandledRejection` で死ぬと痕跡ゼロで消えていた ([daemon-80b5c0af.log](../../.spotter/logs/daemon-80b5c0af-700f-47af-a3ac-796144823a7d.log) line 15 → line 16 で shutdown ログなしに再起動)。次に同じことが起きた時に真因を必ず捕まえられるよう、診断 handler を導入。
6
+
7
+ ### 監査の経過と結論
8
+
9
+ [haiku-caller.mjs](src/daemon/haiku-caller.mjs) の `child.stdin.end(prompt)` → timeout で `child.kill()` という流れで、未 flush の stdin が EPIPE を emit → unhandled stream error → daemon 即死、という仮説を立てた。Windows + Node v24.14.0 で repro script を書いて検証したところ、**`child.stdin/stdout/stderr` の error listener 不在でも uncaughtException は発火せず process は生存** (stdin 8KB end → 500ms 後に kill → 4 秒生存して clean exit、EXIT=0)。
10
+
11
+ つまり 80b5c0af の死因は stdin EPIPE ではなく、別経路。ログには `handler error on turn_end: E_HAIKU_TIMEOUT` (transport の catch + onError まで完了) が残っているので、その後の何かで死んでいる。**真因を確定できる証拠がログにないことが本質的な問題**と判断、診断 handler を先に入れる方針に切替えた。
12
+
13
+ ### 変更点
14
+
15
+ - **編集 [src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: daemon 起動冒頭で `process.on('uncaughtException')` / `process.on('unhandledRejection')` を登録。**同期 `writeFileSync` で log file に append** してから `process.exit(1)`。async write はバッファ flush 前に exit して line を失うが、sync write なら必ず残る。次回死亡時に stack trace + 種別 (`uncaughtException` か `unhandledRejection` か) が確実に記録される
16
+ - **編集 [src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**: `child.stdin/stdout/stderr` に no-op error listener を追加。今の Node v24 では落ちないと実証済みだが、Node 公式 docs はこの edge case を明示保証していない (将来の Node 変更や別 OS で挙動が変わる可能性) ため、defensive coding として投入
17
+
18
+ ### 残課題
19
+
20
+ - daemon 突然死の真因特定: **次回再現を待つ**。診断 handler が入ったので、次に死亡した時はログに必ず痕跡が残る。それを見て対処する
21
+ - v0.13.1 で 30s → 45s 緩和した Haiku timeout の効果観測は継続: 現セッション [daemon-69bd2b93.log](../../.spotter/logs/daemon-69bd2b93-ffbe-43bc-94e7-1d0ba2bd9e74.log) line 5 で `mode=first, duration_ms=32703` を観測 (30s 設定なら timeout していた値が 45s で生存)。サンプル 1 件で結論はまだ早い
22
+
23
+ ## 0.13.1
24
+
25
+ **Haiku timeout 30s → 45s 緩和 + hook 側 IPC timeout を整合**。v0.13.0 以前の実セッション ([daemon-80b5c0af.log](../../.spotter/logs/daemon-80b5c0af-700f-47af-a3ac-796144823a7d.log) line 15) で `E_HAIKU_TIMEOUT: haiku did not respond within 30000ms` を観測。同ログ line 20 でも `mode=first, duration_ms=20948` と 30s の 70% 域まで達しており、timeout が実測レイテンシに対して狭すぎた。合わせて [src/hooks/stop.mjs](src/hooks/stop.mjs) の IPC timeout が元々 15s で Haiku 側 30s と整合していなかった既存バグ (turn_end で Haiku が 16s 超かかると hook 側が先に諦めていた) も同時解消。
26
+
27
+ ### 調査で判明した Haiku 4.5 の高速化ダイヤル不在
28
+
29
+ 公式 docs 確認 (2026-04-20):
30
+
31
+ - `--effort` フラグは Opus 4.7 / Opus 4.6 / Sonnet 4.6 のみ対応、**Haiku 4.5 は effort 非対応** ([model-config docs](https://code.claude.com/docs/en/model-config#adjust-effort-level))
32
+ - Haiku 4.5 は **extended thinking 対応だが adaptive thinking 非対応** ([models/overview](https://platform.claude.com/docs/en/docs/about-claude/models/overview) 比較表)
33
+ - `claude -p` CLI に thinking 直接フラグ無し、API デフォルトで thinking は OFF
34
+ - つまり Haiku 側で「速くする手段」は存在せず、timeout 緩和しか打ち手が無い (Sonnet 4.6 切替は遅くなる + 3 倍コストで逆効果)
35
+
36
+ ### 変更点
37
+
38
+ - **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: `DEFAULT_HAIKU_TIMEOUT_MS` を 30_000 → 45_000。first-call cold-start + 観測されたレイテンシスパイク (20.9s) + margin を包含
39
+ - **編集 [src/hooks/user-prompt.mjs](src/hooks/user-prompt.mjs)**: `TIMEOUT_MS` を 30_000 → 50_000 (Haiku 45s + IPC margin 5s)
40
+ - **編集 [src/hooks/stop.mjs](src/hooks/stop.mjs)**: `TIMEOUT_MS` を 15_000 → 50_000 (既存の整合性バグも解消)
41
+
42
+ ### 残課題
43
+
44
+ Haiku 突然死 (shutdown ログなしで daemon 再起動する事象、v0.12.0 auto-resurrect で救われているが見えない欠落ターンが発生する) は未対処。[docs/open-issues.md](docs/open-issues.md) P0 に残置。
45
+
3
46
  ## 0.13.0
4
47
 
5
48
  **Stop 判定軸を「要請充足チェック」から「ツール適用機会の監査」に転換**。v0.12.x 以前の `stage=turn_end` は `<user_input>` + `<used_tools>` + `<final_response>` を Haiku に渡し、「ユーザー要請されたツールが使われたか」を判定していた。この軸では Bell が Stop 到達後にすべき動作 — 事実断定の裏付け / 新知見の記録 / 既知情報の照会 — を拾えない。実セッションで Haiku が応答「判明: A モジュールは B に依存」に対し `caveat_record` を推奨する、といった本来期待される指摘が構造的に出ない状態だった (ユーザーが [この議論](https://github.com/kitepon-rgb/Spotter) で指摘)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@ import { startDaemon, DaemonAlreadyRunningError } from '../daemon/daemon.mjs';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { open } from 'node:fs/promises';
7
+ import { writeFileSync } from 'node:fs';
7
8
 
8
9
  function parseArgs(argv) {
9
10
  const out = { sessionId: null, projectRoot: null };
@@ -30,10 +31,32 @@ export async function runDaemonStart({ argv }) {
30
31
  process.exit(2);
31
32
  }
32
33
 
33
- const logFile = await open(
34
- join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`),
35
- 'a'
36
- );
34
+ const logFilePath = join(homedir(), '.spotter', 'logs', `daemon-${sessionId}.log`);
35
+
36
+ // v0.13.2: last-resort fatal handlers. Without these, an uncaughtException or
37
+ // unhandledRejection silently kills the daemon and the regular logFile.write()
38
+ // (async) loses the trailing line on sudden death — leaving zero forensic
39
+ // trace. We do a sync append so the cause is always captured before exit.
40
+ // See open-issues.md "daemon プロセスが shutdown ログなしに死ぬ".
41
+ const fatalLog = (kind, err) => {
42
+ const detail = err && err.stack ? err.stack : String(err);
43
+ const line = `[${new Date().toISOString()}] FATAL ${kind}: ${detail}\n`;
44
+ try {
45
+ writeFileSync(logFilePath, line, { flag: 'a' });
46
+ } catch {
47
+ process.stderr.write(line);
48
+ }
49
+ };
50
+ process.on('uncaughtException', (err) => {
51
+ fatalLog('uncaughtException', err);
52
+ process.exit(1);
53
+ });
54
+ process.on('unhandledRejection', (reason) => {
55
+ fatalLog('unhandledRejection', reason);
56
+ process.exit(1);
57
+ });
58
+
59
+ const logFile = await open(logFilePath, 'a');
37
60
  const log = (msg) => {
38
61
  const line = `[${new Date().toISOString()}] ${msg}\n`;
39
62
  logFile.write(line).catch(() => {});
@@ -47,10 +47,11 @@ import { join } from 'node:path';
47
47
  import { writeFile, unlink } from 'node:fs/promises';
48
48
  const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
49
49
  // v0.5.0: lowered 60s → 30s. Session-scoped (--resume) means the first call still pays
50
- // cold-start but subsequent calls skip it. 30s covers the first-call cold path without
51
- // being excessive, and a role-collapse recovery cycle (reset next call is effectively
52
- // a cold start again) stays within budget.
53
- const DEFAULT_HAIKU_TIMEOUT_MS = 30_000;
50
+ // cold-start but subsequent calls skip it. 45s covers first-call cold path plus the
51
+ // observed Haiku CLI latency spikes (2026-04-20 log shows 20.9s resumed calls and 30s
52
+ // timeouts in the wild). Role-collapse recovery (reset → next call is effectively a
53
+ // cold start again) stays within budget.
54
+ const DEFAULT_HAIKU_TIMEOUT_MS = 45_000;
54
55
  // v0.12.0: heartbeat-based orphan cleanup. Every envelope resets a setTimeout; if no
55
56
  // hook event arrives within this window, the daemon self-shuts. 30 min is the longest
56
57
  // silence we expect from a live Claude Code session. UserPromptSubmit auto-resurrects
@@ -242,6 +242,16 @@ export function createHaikuCaller({ preamble, timeoutMs, claudeBin = 'claude', m
242
242
  let stderr = '';
243
243
  let settled = false;
244
244
 
245
+ // v0.13.2: absorb EPIPE/ECONNRESET on stdio streams. We end(prompt) and
246
+ // then kill() on timeout — if the write hadn't drained yet, the unflushed
247
+ // stream can emit 'error' after kill. Today's Node doesn't crash on
248
+ // unhandled stdin errors, but the docs don't guarantee that, and this
249
+ // listener removes a potential silent-death path.
250
+ const noop = () => {};
251
+ child.stdin.on('error', noop);
252
+ child.stdout.on('error', noop);
253
+ child.stderr.on('error', noop);
254
+
245
255
  const timer = setTimeout(() => {
246
256
  if (settled) return;
247
257
  settled = true;
@@ -15,7 +15,7 @@ import {
15
15
  import { getLastAssistantText } from './transcript-reader.mjs';
16
16
  import { sendRequest } from '../daemon/transport.mjs';
17
17
 
18
- const TIMEOUT_MS = 15_000;
18
+ const TIMEOUT_MS = 50_000;
19
19
 
20
20
  export async function runStop() {
21
21
  if (isChildCall()) return;
@@ -21,7 +21,7 @@ import {
21
21
  import { sendRequest, TransportError } from '../daemon/transport.mjs';
22
22
  import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
23
23
 
24
- const TIMEOUT_MS = 30_000;
24
+ const TIMEOUT_MS = 50_000;
25
25
  const SHORT_PROMPT_MAX_CHARS = 10;
26
26
 
27
27
  export async function runUserPrompt() {