claude-spotter 0.6.1 → 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 +27 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/cli/daemon-cmd.mjs +10 -3
- package/src/daemon/daemon.mjs +51 -2
- package/src/hooks/session-start.mjs +11 -5
- package/src/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
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
|
+
|
|
3
30
|
## 0.6.1
|
|
4
31
|
|
|
5
32
|
**v0.6.0 で `src/version.mjs` を更新し忘れた trivia fix**。`spotter --version` が古い `0.5.2` を返していた。挙動差はない。
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Spotter
|
|
2
2
|
|
|
3
|
-
> **v0.
|
|
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
|
|
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
|
|
78
|
+
- **JSON スキーマ違反は v0.5.0 以降「想定済み異常」として silent pass + session renew で回復**します (role collapse 検知パス、daemon ログに `role_collapse_reset` を残す)。一方 **Haiku timeout は引き続き throw** され、UserPromptSubmit がブロックされてユーザー入力が Bell に届かない症状として顕在化します (timeout は v0.5.0 で 30s に短縮)。timeout の fail-open 化 (pass 扱い) は §0 改訂とセットで今後検討
|
|
79
79
|
|
|
80
80
|
## ライセンス
|
|
81
81
|
|
package/package.json
CHANGED
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -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(() =>
|
|
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
|
|
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(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.6.
|
|
1
|
+
export const version = '0.6.2';
|