claude-spotter 0.6.0 → 0.6.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2
4
+
5
+ **親プロセス watch による孤児 daemon 自動回収**。SessionEnd が発火しない経路 (Claude Code crash, kill -9, IDE reload) で daemon が永久に残る問題への対処。
6
+
7
+ ### 事の発端
8
+
9
+ 実運用で `spotter status` を見ると、現セッション以外に複数の daemon が `process=alive` で残存している状態が頻発していた。今回の観測では 9 個中 8 個が孤児で、手動 `taskkill` + `.pid` ファイル削除で掃除する必要があった。原因は SessionEnd hook が**正常終了経路でしか発火しない**こと。Claude Code の crash、強制終了、VSCode リロード等のいずれかで daemon は親を失っても生き続ける。v0.2 スコープに「孤児 cleanup」と書いてあったが未実装のままだった。
10
+
11
+ ### 変更点
12
+
13
+ - **[src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: `startDaemon({ parentPid, parentWatchIntervalMs })` を追加。`parentPid` が指定されると 5 秒間隔 (default) で `process.kill(parentPid, 0)` を ping し、ESRCH を検知したら自身を shutdown。`parentWatchIntervalMs` はテスト用に短縮可能。`parentPid !== null && (!Number.isInteger || <= 0)` は TypeError で reject。
14
+ - **[src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: `--parent-pid <N>` 引数をパースして `startDaemon` に渡す。
15
+ - **[src/hooks/session-start.mjs](src/hooks/session-start.mjs)**: daemon spawn 時に `--parent-pid <process.ppid>` を付与。`process.ppid` は SessionStart hook から見た親 = Claude Code 本体。
16
+ - **[test/daemon.test.mjs](test/daemon.test.mjs)**: 子プロセスを fake parent として spawn → daemon 起動 → 子を SIGKILL → daemon の `server.on('close')` が発火することを検証する E2E テストを追加。`parentPid: 0` / `1.5` が TypeError になることのバリデーションテストも追加。
17
+
18
+ ### 効果
19
+
20
+ - 通常運用 (SessionEnd 発火経路) では従来どおり graceful shutdown
21
+ - 異常終了経路 (crash, kill, reload) では親消滅を最大 5 秒で検知して自殺
22
+ - 観測コスト: `process.kill(pid, 0)` の syscall が 5 秒に 1 回。idle CPU 影響は無視できる程度
23
+
24
+ ### 既知の制約
25
+
26
+ - 親 PID を持たない経路 (手動 `spotter daemon start --session-id ...`) では watch が動かない (parentPid が null)。これは debug 用なので許容。
27
+ - Claude Code が中間プロセス (cmd.exe / sh wrapper) 越しに hook を起動している場合、`process.ppid` が wrapper を指す可能性あり。今回の Windows 環境では実測で Claude Code 本体を指していたが、将来的に環境差で問題が出れば PID 取得方法を再検討。
28
+ - 5 秒間隔のため、kill 直後の最大 5 秒は孤児状態が残る。これ以上短縮するなら polling コストとのトレードオフ再評価。
29
+
30
+ ## 0.6.1
31
+
32
+ **v0.6.0 で `src/version.mjs` を更新し忘れた trivia fix**。`spotter --version` が古い `0.5.2` を返していた。挙動差はない。
33
+
3
34
  ## 0.6.0
4
35
 
5
36
  **Preamble-once: 初回のみ role+schema+catalog を送り、以降は per-turn delta のみ**。v0.5.x で実測した「resumed 呼び出しが first より遅い」問題の原因に手を入れた構造変更。
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Spotter
2
2
 
3
- > **v0.4.3 released 2026-04-19**. v0.2.0 で追加した session-scoped Haiku Spotter 本体プロジェクトでの長時間運用中に role collapse (Haiku Bell 人格に drift) を起こしたため、v0.4.0 で stateless に回帰。v0.4.2 で stateless 化の副作用として発生した cold-start timeout 問題に対処 (timeout 28s→60s + stateless-safe warmup)、v0.4.3 で過剰になっていたプロンプトを 30-40% 削減。詳細は [CHANGELOG](CHANGELOG.md)。
3
+ > **v0.6.2 released 2026-04-19**. v0.4 で stateless に回帰したが cold-start レイテンシ (毎ターン 30 秒前後の待ち) が運用で問題化したため、v0.5.0 **session-scoped Haiku を「JSON パース失敗検知 session renew + silent pass」の事後回復機構付き**で復活。v0.6.0 で **preamble (role + schema + catalog + few-shot) を初回のみ送信** する形 (preamble-once) に変更して `--resume` 経由の resumed 呼び出しが first より遅くなる v0.5.x の逆転現象を解消。v0.6.2 で **親プロセス (Claude Code) の死を 5 秒間隔で検知して daemon を自動 shutdown** する watch を追加し、SessionEnd が発火しない経路 (crash / kill / IDE reload) での孤児 daemon 残存を解消。詳細は [CHANGELOG](CHANGELOG.md)。
4
4
 
5
5
  **気づく役と実行する役を分離する。** Spotter は Claude Code の横で静かに並走し、Bell (主役の Claude) が**ツールを呼び忘れたとき**に指摘する監査役です。
6
6
 
7
7
  > Claude には「使えるツールがあるのに、使うべきタイミングで使わない」という構造的な弱点があります。現在時刻を推測で答える、web_search を呼ばずに古い情報で応答する、read_file を使わずにファイルの中身を推測する — 「分からないと自覚できない」から、ツールを取りに行けない。
8
8
 
9
- Spotter は、ツールカタログを完全に把握した別エージェント (Claude Haiku 4.5) をセッション毎にプロセスとして常駐させ、Bell の発話予定と応答を並走監査します。見落としを検出すると、透明化された指摘として Bell に届け、補正応答を促します。Haiku 呼び出しは毎ターン stateless (fresh `--session-id`) で実行されるため、長時間運用しても会話履歴による persona drift を構造的に防ぎます。
9
+ Spotter は、ツールカタログを完全に把握した別エージェント (Claude Haiku 4.5) をセッション毎にプロセスとして常駐させ、Bell の発話予定と応答を並走監査します。見落としを検出すると、透明化された指摘として Bell に届け、補正応答を促します。Haiku 呼び出しは session-scoped (`--resume`) で同一セッションに再接続して cold-start を削減し、初回のみ preamble を送って以降は per-turn delta だけ送ることで session 肥大化を防ぎます。role collapse (persona drift で JSON 契約破棄) は構造的に予防せず、検知した瞬間に session を切り直して fresh state から再開する事後回復機構で長時間運用に耐えます。
10
10
 
11
11
  ## インストール
12
12
 
@@ -75,7 +75,7 @@ spotter uninstall # hook 登録を解除 (~/.spotter は残す)
75
75
  ## 既知の制約
76
76
 
77
77
  - Stop hook は Bell の最初の応答が**出力された後**に発火するため、Spotter が Stop で差し戻した場合、ユーザーは「最初の応答 + 補正応答」の 2 連続を見ます (Claude Code の hook 仕様による制約)。UserPromptSubmit 段階での先回り検出を精度の軸にしています
78
- - JSON スキーマ違反・Haiku timeout はリトライせず即 throw します (§14.1 silent fallback 禁止の帰結)UserPromptSubmit がブロックされユーザー入力が Bell に届かない症状として顕在化します。cold-start 対策として v0.4.2 で timeout 60s + warmup を導入しましたが、fail-open 化 (timeout を pass 扱い) は §0 改訂とセットで今後検討
78
+ - **JSON スキーマ違反は v0.5.0 以降「想定済み異常」として silent pass + session renew で回復**します (role collapse 検知パス、daemon ログに `role_collapse_reset` を残す)。一方 **Haiku timeout は引き続き throw** され、UserPromptSubmit がブロックされてユーザー入力が Bell に届かない症状として顕在化します (timeout v0.5.030s に短縮)。timeout fail-open 化 (pass 扱い) は §0 改訂とセットで今後検討
79
79
 
80
80
  ## ライセンス
81
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,18 +6,22 @@ import { join } from 'node:path';
6
6
  import { open } from 'node:fs/promises';
7
7
 
8
8
  function parseArgs(argv) {
9
- const out = { sessionId: null };
9
+ const out = { sessionId: null, parentPid: null };
10
10
  for (let i = 0; i < argv.length; i += 1) {
11
11
  if (argv[i] === '--session-id') {
12
12
  out.sessionId = argv[i + 1];
13
13
  i += 1;
14
+ } else if (argv[i] === '--parent-pid') {
15
+ const n = parseInt(argv[i + 1], 10);
16
+ if (Number.isInteger(n) && n > 0) out.parentPid = n;
17
+ i += 1;
14
18
  }
15
19
  }
16
20
  return out;
17
21
  }
18
22
 
19
23
  export async function runDaemonStart({ argv }) {
20
- const { sessionId } = parseArgs(argv);
24
+ const { sessionId, parentPid } = parseArgs(argv);
21
25
  if (!sessionId) {
22
26
  process.stderr.write('spotter daemon start: --session-id is required\n');
23
27
  process.exit(2);
@@ -37,7 +41,10 @@ export async function runDaemonStart({ argv }) {
37
41
  // v0.5.0: no warmup. Session-scoped Haiku (--resume on follow-ups) pays cold-start
38
42
  // only on the first real call; warmup added complexity for marginal benefit and is
39
43
  // removed along with the stateless regime that required it.
40
- running = await startDaemon({ sessionId, logFn: log });
44
+ // v0.6.2: parentPid (Claude Code PID, captured by SessionStart hook as process.ppid)
45
+ // is threaded in so the daemon self-terminates when the parent dies without
46
+ // SessionEnd (crash / kill / IDE reload).
47
+ running = await startDaemon({ sessionId, parentPid, logFn: log });
41
48
  } catch (err) {
42
49
  if (err instanceof DaemonAlreadyRunningError) {
43
50
  // v0.2 PID-preexist layer: a sibling daemon already serves this session.
@@ -47,6 +47,11 @@ const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
47
47
  // being excessive, and a role-collapse recovery cycle (reset → next call is effectively
48
48
  // a cold start again) stays within budget.
49
49
  const DEFAULT_HAIKU_TIMEOUT_MS = 30_000;
50
+ // v0.6.2: parent-process watch interval. SessionStart hook passes the Claude Code PID;
51
+ // the daemon polls it so it can self-terminate when Claude Code dies without firing
52
+ // SessionEnd (crash, kill -9, IDE reload). 5s is a balance between responsiveness and
53
+ // idle CPU cost — well below the cost of an orphan daemon staying up indefinitely.
54
+ const DEFAULT_PARENT_WATCH_INTERVAL_MS = 5_000;
50
55
 
51
56
  export class DaemonAlreadyRunningError extends Error {
52
57
  constructor(sessionId, pid) {
@@ -63,10 +68,15 @@ export async function startDaemon({
63
68
  haikuCaller,
64
69
  logFn = () => {},
65
70
  haikuCallWindowMs = DEFAULT_HAIKU_CALL_WINDOW_MS,
71
+ parentPid = null,
72
+ parentWatchIntervalMs = DEFAULT_PARENT_WATCH_INTERVAL_MS,
66
73
  } = {}) {
67
74
  if (!sessionId) {
68
75
  throw new TypeError('sessionId is required');
69
76
  }
77
+ if (parentPid !== null && (!Number.isInteger(parentPid) || parentPid <= 0)) {
78
+ throw new TypeError('parentPid must be a positive integer or null');
79
+ }
70
80
 
71
81
  await ensureRuntimeDir();
72
82
 
@@ -158,7 +168,7 @@ export async function startDaemon({
158
168
  case 'turn_end':
159
169
  return handleTurnEnd(envelope.payload ?? {});
160
170
  case 'shutdown':
161
- setImmediate(() => shutdown(server, sessionId, logFn));
171
+ setImmediate(() => stop());
162
172
  return { stopping: true };
163
173
  default: {
164
174
  const err = new Error(`unknown event: ${envelope.event}`);
@@ -257,14 +267,53 @@ export async function startDaemon({
257
267
  const pidPath = pidFilePath(sessionId);
258
268
  await writeFile(pidPath, String(process.pid), 'utf8');
259
269
 
270
+ // v0.6.2: parent-process watch. SessionEnd is a graceful path; if Claude Code dies
271
+ // without it (crash, kill, IDE reload), nothing else cleans us up. Poll the parent
272
+ // PID and self-terminate on its disappearance.
273
+ let parentWatchHandle = null;
274
+ if (parentPid !== null) {
275
+ logFn(`watching parent pid=${parentPid} (interval=${parentWatchIntervalMs}ms)`);
276
+ parentWatchHandle = setInterval(() => {
277
+ if (!isProcessAlive(parentPid)) {
278
+ logFn(`parent pid=${parentPid} gone, shutting down`);
279
+ clearInterval(parentWatchHandle);
280
+ parentWatchHandle = null;
281
+ shutdown(server, sessionId, logFn).catch((err) => {
282
+ logFn(`parent-watch shutdown error: ${err.message}`);
283
+ });
284
+ }
285
+ }, parentWatchIntervalMs);
286
+ parentWatchHandle.unref();
287
+ }
288
+
289
+ const stop = async () => {
290
+ if (parentWatchHandle !== null) {
291
+ clearInterval(parentWatchHandle);
292
+ parentWatchHandle = null;
293
+ }
294
+ return shutdown(server, sessionId, logFn);
295
+ };
296
+
260
297
  return {
261
298
  server,
262
299
  path,
263
300
  pidPath,
264
- stop: () => shutdown(server, sessionId, logFn),
301
+ stop,
265
302
  };
266
303
  }
267
304
 
305
+ function isProcessAlive(pid) {
306
+ try {
307
+ process.kill(pid, 0);
308
+ return true;
309
+ } catch (err) {
310
+ if (err.code === 'ESRCH') return false;
311
+ if (err.code === 'EPERM') return true; // exists but owned by another user
312
+ // Unknown errno — be conservative and assume alive (don't auto-kill on transient).
313
+ return true;
314
+ }
315
+ }
316
+
268
317
  async function assertNoLiveDaemon(sessionId) {
269
318
  const pidPath = pidFilePath(sessionId);
270
319
  let raw;
@@ -67,12 +67,18 @@ export async function runSessionStart({ argv = process.argv, now = Date.now } =
67
67
 
68
68
  function spawnDaemon(sessionId, argv) {
69
69
  // Invoke `node <spotter-bin> daemon start --session-id ...` detached.
70
+ // v0.6.2: pass --parent-pid so the daemon can self-terminate when Claude Code dies
71
+ // without firing SessionEnd. process.ppid here is Claude Code (this hook's parent).
70
72
  const spotterBin = resolveSpotterBin(argv);
71
- const child = spawn(process.execPath, [spotterBin, 'daemon', 'start', '--session-id', sessionId], {
72
- detached: true,
73
- stdio: 'ignore',
74
- windowsHide: true,
75
- });
73
+ const child = spawn(
74
+ process.execPath,
75
+ [spotterBin, 'daemon', 'start', '--session-id', sessionId, '--parent-pid', String(process.ppid)],
76
+ {
77
+ detached: true,
78
+ stdio: 'ignore',
79
+ windowsHide: true,
80
+ }
81
+ );
76
82
  child.on('error', (err) => {
77
83
  // best effort: the polling below will fail if the spawn actually didn't work
78
84
  process.stderr.write(`spotter-hook: daemon spawn error: ${err.message}\n`);
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const version = '0.5.2';
1
+ export const version = '0.6.2';