claude-spotter 0.11.0 → 0.12.0
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 +35 -0
- package/package.json +1 -1
- package/src/cli/daemon-cmd.mjs +4 -10
- package/src/daemon/daemon.mjs +38 -42
- package/src/hooks/session-start.mjs +7 -74
- package/src/hooks/spawn-daemon.mjs +72 -0
- package/src/hooks/user-prompt.mjs +39 -10
- package/src/version.mjs +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
**親 PID watch を heartbeat 方式に置換 + UserPromptSubmit auto-resurrect**。v0.6.2 で導入した `--parent-pid` watch (Claude Code 本体 PID を `process.kill(pid, 0)` で 5 秒間隔 ping) が VSCode native extension 環境で誤爆する問題を解消。`process.ppid` は extension host から spawn される短命ラッパーを指していて、5 秒で ESRCH → daemon 自死していた (`~/.spotter/logs/ppid-probe.log` の env dump で実測: hook の ppid が毎回 (55692, 46020 等) 変わるのに対し `VSCODE_PID=39964` は固定、CLAUDE_* 系には PID 系 env なし)。
|
|
6
|
+
|
|
7
|
+
### 設計
|
|
8
|
+
|
|
9
|
+
- **heartbeat 方式 (b 案)**: daemon が envelope を受信するたびに `setTimeout(selfShutdown, 30min)` を `clearTimeout` + 再 set。ポーリングではなく event-driven で CPU 負荷ゼロ、検出は精密。30 分を超える Claude Code 沈黙は通常の使用では発生しない閾値
|
|
10
|
+
- **OS / 環境依存ゼロ**: VSCODE_PID / CLAUDE_*_PID 等の探索が不要、CLI / native extension / 将来の他クライアント全てで同一挙動
|
|
11
|
+
- **auto-resurrect**: UserPromptSubmit hook が `E_UNREACHABLE` (socket 不在) を検出したら spawn + readiness 待ち + retry。daemon が 30 分 timeout で死んでも crash していても、次のユーザー入力で自動復活する。「daemon が死んでたら pass」(§0 silent fallback 違反) ではなく「daemon が死んでたら起こす」で対処
|
|
12
|
+
- **PreToolUse / Stop は復活させない**: turn の途中で daemon が居なかった場合、used_tools 欠落・preamble 未送信の歪んだ状態で監査再開すると誤検出が増える。次の UserPromptSubmit (新 turn の起点) で復活する設計
|
|
13
|
+
|
|
14
|
+
### 変更点
|
|
15
|
+
|
|
16
|
+
- **編集 [src/daemon/daemon.mjs](src/daemon/daemon.mjs)**: parent-pid watch (`setInterval` + `process.kill(pid, 0)`) を削除、`heartbeatTimeoutMs` パラメータ + `resetHeartbeat()` (clearTimeout + setTimeout の per-envelope re-arm) に置換。`isProcessAlive` も削除 (status.mjs に独立コピー有り)
|
|
17
|
+
- **編集 [src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: `--parent-pid` パース削除、`startDaemon` 呼び出しから `parentPid` 削除
|
|
18
|
+
- **新規 [src/hooks/spawn-daemon.mjs](src/hooks/spawn-daemon.mjs)**: spawn detached + readiness poll を session-start / user-prompt の両方から使えるように共通化
|
|
19
|
+
- **編集 [src/hooks/session-start.mjs](src/hooks/session-start.mjs)**: spawn ロジックを spawn-daemon.mjs に委譲、`--parent-pid` 渡し削除
|
|
20
|
+
- **編集 [src/hooks/user-prompt.mjs](src/hooks/user-prompt.mjs)**: `sendRequest` が `E_UNREACHABLE` で失敗したら `spawnDaemonAndWaitReady` を呼んで retry (1 回のみ)
|
|
21
|
+
- **削除 [src/hooks/ppid-probe.mjs]**: env dump 用の調査 hook、役目終了
|
|
22
|
+
- **編集 [.claude/settings.json](.claude/settings.json)**: probe hook 登録撤去
|
|
23
|
+
- **編集 [test/daemon.test.mjs](test/daemon.test.mjs)**: parent-watch test 2 件を削除、heartbeat timeout / heartbeat reset / heartbeatTimeoutMs validation の 3 件を追加
|
|
24
|
+
|
|
25
|
+
### 非互換
|
|
26
|
+
|
|
27
|
+
- `startDaemon({parentPid, parentWatchIntervalMs})` → `startDaemon({heartbeatTimeoutMs})`: API 変更。CLI の `--parent-pid` 引数も廃止 (受け取らなくなる)。Spotter は hook + daemon を同一 npm package で配布するため `npm install -g claude-spotter@latest` で一括更新すれば混在は起きない
|
|
28
|
+
|
|
29
|
+
## 0.11.1
|
|
30
|
+
|
|
31
|
+
**hotfix: `src/version.mjs` を `package.json` から読み取る**。0.11.0 は package.json を 0.11.0 に bump したが `src/version.mjs` のハードコード文字列 (`'0.10.0'`) を上げ忘れていたため、`spotter --version` が `0.10.0` のまま表示される不整合があった。同じミスを防ぐため ESM JSON import (`import pkg from '../package.json' with { type: 'json' }`) で package.json から動的に引くよう変更。以降は package.json の version を bump するだけで CLI 出力も追従する。
|
|
32
|
+
|
|
33
|
+
### 変更点
|
|
34
|
+
|
|
35
|
+
- **編集 [src/version.mjs](src/version.mjs)**: ハードコード廃止、`package.json` の version フィールドを ESM JSON import で読み取る
|
|
36
|
+
- **編集 [package.json](package.json)**: 0.11.0 → 0.11.1
|
|
37
|
+
|
|
3
38
|
## 0.11.0
|
|
4
39
|
|
|
5
40
|
**短プロンプトの Haiku スキップ**。ユーザーの入力が trim 後 10 文字 (コードポイント) 以下なら、挨拶・相槌・短い確認質問などツール不要な会話が支配的なため、UserPromptSubmit hook で早期 return して Haiku 呼び出しを完全にスキップする。daemon には user_input を送らず、`state.lastUserInput=null` のまま次の turn_end が `reason=no_user_input` で自動 pass する。preamble 57 件の判定コストを、最も的外れになりやすい短文ターンで丸ごと節約する。
|
package/package.json
CHANGED
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -6,15 +6,11 @@ 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, projectRoot: 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;
|
|
18
14
|
} else if (argv[i] === '--project-root') {
|
|
19
15
|
out.projectRoot = argv[i + 1];
|
|
20
16
|
i += 1;
|
|
@@ -24,7 +20,7 @@ function parseArgs(argv) {
|
|
|
24
20
|
}
|
|
25
21
|
|
|
26
22
|
export async function runDaemonStart({ argv }) {
|
|
27
|
-
const { sessionId,
|
|
23
|
+
const { sessionId, projectRoot } = parseArgs(argv);
|
|
28
24
|
if (!sessionId) {
|
|
29
25
|
process.stderr.write('spotter daemon start: --session-id is required\n');
|
|
30
26
|
process.exit(2);
|
|
@@ -48,11 +44,9 @@ export async function runDaemonStart({ argv }) {
|
|
|
48
44
|
// v0.5.0: no warmup. Session-scoped Haiku (--resume on follow-ups) pays cold-start
|
|
49
45
|
// only on the first real call; warmup added complexity for marginal benefit and is
|
|
50
46
|
// removed along with the stateless regime that required it.
|
|
51
|
-
// v0.6.2: parentPid (Claude Code PID, captured by SessionStart hook as process.ppid)
|
|
52
|
-
// is threaded in so the daemon self-terminates when the parent dies without
|
|
53
|
-
// SessionEnd (crash / kill / IDE reload).
|
|
54
47
|
// v0.7.0: projectRoot drives tool-db loading (replaces the old tools.yaml catalog).
|
|
55
|
-
|
|
48
|
+
// v0.12.0: orphan-cleanup is heartbeat-based inside startDaemon (no parent-PID arg).
|
|
49
|
+
running = await startDaemon({ sessionId, projectRoot, logFn: log });
|
|
56
50
|
} catch (err) {
|
|
57
51
|
if (err instanceof DaemonAlreadyRunningError) {
|
|
58
52
|
// v0.2 PID-preexist layer: a sibling daemon already serves this session.
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
// §5.7: event dispatch follows the envelope contract.
|
|
17
17
|
// §14: unexpected errors are thrown; hooks convert them to exit codes.
|
|
18
18
|
//
|
|
19
|
+
// v0.12.0: orphan-cleanup is now heartbeat-based instead of parent-PID watch. Every
|
|
20
|
+
// envelope (including readiness) resets a setTimeout; if no hook event arrives within
|
|
21
|
+
// HEARTBEAT_TIMEOUT_MS (30 min), the daemon self-shuts. Replaces v0.6.2's --parent-pid
|
|
22
|
+
// scheme, which mis-fired in VSCode native-extension environments where process.ppid
|
|
23
|
+
// pointed at a short-lived wrapper. UserPromptSubmit hook auto-resurrects a dead daemon.
|
|
24
|
+
//
|
|
19
25
|
// v0.2 defence layers against daemon proliferation (see plan §18) — still active:
|
|
20
26
|
// - SPOTTER_PARENT_PID env var (set by haiku-caller when spawning claude -p; hooks skip on presence)
|
|
21
27
|
// - agent_id gate (subagent hooks exit 0 before reaching the daemon)
|
|
@@ -45,11 +51,11 @@ const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
|
|
|
45
51
|
// being excessive, and a role-collapse recovery cycle (reset → next call is effectively
|
|
46
52
|
// a cold start again) stays within budget.
|
|
47
53
|
const DEFAULT_HAIKU_TIMEOUT_MS = 30_000;
|
|
48
|
-
// v0.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
const
|
|
54
|
+
// v0.12.0: heartbeat-based orphan cleanup. Every envelope resets a setTimeout; if no
|
|
55
|
+
// hook event arrives within this window, the daemon self-shuts. 30 min is the longest
|
|
56
|
+
// silence we expect from a live Claude Code session. UserPromptSubmit auto-resurrects
|
|
57
|
+
// a dead daemon, so over-aggressive timeout would just cause a visible re-spawn.
|
|
58
|
+
const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30 * 60 * 1000;
|
|
53
59
|
|
|
54
60
|
export class DaemonAlreadyRunningError extends Error {
|
|
55
61
|
constructor(sessionId, pid) {
|
|
@@ -67,14 +73,13 @@ export async function startDaemon({
|
|
|
67
73
|
haikuCaller,
|
|
68
74
|
logFn = () => {},
|
|
69
75
|
haikuCallWindowMs = DEFAULT_HAIKU_CALL_WINDOW_MS,
|
|
70
|
-
|
|
71
|
-
parentWatchIntervalMs = DEFAULT_PARENT_WATCH_INTERVAL_MS,
|
|
76
|
+
heartbeatTimeoutMs = DEFAULT_HEARTBEAT_TIMEOUT_MS,
|
|
72
77
|
} = {}) {
|
|
73
78
|
if (!sessionId) {
|
|
74
79
|
throw new TypeError('sessionId is required');
|
|
75
80
|
}
|
|
76
|
-
if (
|
|
77
|
-
throw new TypeError('
|
|
81
|
+
if (!Number.isFinite(heartbeatTimeoutMs) || heartbeatTimeoutMs <= 0) {
|
|
82
|
+
throw new TypeError('heartbeatTimeoutMs must be a positive number');
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
await ensureRuntimeDir();
|
|
@@ -141,7 +146,24 @@ export async function startDaemon({
|
|
|
141
146
|
}
|
|
142
147
|
};
|
|
143
148
|
|
|
149
|
+
// v0.12.0: heartbeat. Reset on every envelope; if no event arrives within
|
|
150
|
+
// heartbeatTimeoutMs the daemon self-shuts. Replaces v0.6.2 parent-PID watch.
|
|
151
|
+
// The timer itself is created after server.listen() succeeds (see below).
|
|
152
|
+
let heartbeatHandle = null;
|
|
153
|
+
const resetHeartbeat = () => {
|
|
154
|
+
if (heartbeatHandle !== null) clearTimeout(heartbeatHandle);
|
|
155
|
+
heartbeatHandle = setTimeout(() => {
|
|
156
|
+
heartbeatHandle = null;
|
|
157
|
+
logFn(`heartbeat timeout (${heartbeatTimeoutMs}ms), shutting down`);
|
|
158
|
+
shutdown(server, sessionId, logFn).catch((err) => {
|
|
159
|
+
logFn(`heartbeat shutdown error: ${err.message}`);
|
|
160
|
+
});
|
|
161
|
+
}, heartbeatTimeoutMs);
|
|
162
|
+
heartbeatHandle.unref();
|
|
163
|
+
};
|
|
164
|
+
|
|
144
165
|
const handler = async (envelope) => {
|
|
166
|
+
resetHeartbeat();
|
|
145
167
|
if (!envelope || typeof envelope !== 'object') {
|
|
146
168
|
const err = new Error('invalid envelope');
|
|
147
169
|
err.code = 'E_INTERNAL';
|
|
@@ -276,29 +298,15 @@ export async function startDaemon({
|
|
|
276
298
|
const pidPath = pidFilePath(sessionId);
|
|
277
299
|
await writeFile(pidPath, String(process.pid), 'utf8');
|
|
278
300
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (parentPid !== null) {
|
|
284
|
-
logFn(`watching parent pid=${parentPid} (interval=${parentWatchIntervalMs}ms)`);
|
|
285
|
-
parentWatchHandle = setInterval(() => {
|
|
286
|
-
if (!isProcessAlive(parentPid)) {
|
|
287
|
-
logFn(`parent pid=${parentPid} gone, shutting down`);
|
|
288
|
-
clearInterval(parentWatchHandle);
|
|
289
|
-
parentWatchHandle = null;
|
|
290
|
-
shutdown(server, sessionId, logFn).catch((err) => {
|
|
291
|
-
logFn(`parent-watch shutdown error: ${err.message}`);
|
|
292
|
-
});
|
|
293
|
-
}
|
|
294
|
-
}, parentWatchIntervalMs);
|
|
295
|
-
parentWatchHandle.unref();
|
|
296
|
-
}
|
|
301
|
+
// Start the heartbeat. The first hook event (typically SessionStart's readiness
|
|
302
|
+
// ping moments later) will reset it; if nothing arrives in heartbeatTimeoutMs we self-shut.
|
|
303
|
+
logFn(`heartbeat armed (timeout=${heartbeatTimeoutMs}ms)`);
|
|
304
|
+
resetHeartbeat();
|
|
297
305
|
|
|
298
306
|
const stop = async () => {
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
307
|
+
if (heartbeatHandle !== null) {
|
|
308
|
+
clearTimeout(heartbeatHandle);
|
|
309
|
+
heartbeatHandle = null;
|
|
302
310
|
}
|
|
303
311
|
return shutdown(server, sessionId, logFn);
|
|
304
312
|
};
|
|
@@ -311,18 +319,6 @@ export async function startDaemon({
|
|
|
311
319
|
};
|
|
312
320
|
}
|
|
313
321
|
|
|
314
|
-
function isProcessAlive(pid) {
|
|
315
|
-
try {
|
|
316
|
-
process.kill(pid, 0);
|
|
317
|
-
return true;
|
|
318
|
-
} catch (err) {
|
|
319
|
-
if (err.code === 'ESRCH') return false;
|
|
320
|
-
if (err.code === 'EPERM') return true; // exists but owned by another user
|
|
321
|
-
// Unknown errno — be conservative and assume alive (don't auto-kill on transient).
|
|
322
|
-
return true;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
322
|
async function assertNoLiveDaemon(sessionId) {
|
|
327
323
|
const pidPath = pidFilePath(sessionId);
|
|
328
324
|
let raw;
|
|
@@ -11,31 +11,21 @@
|
|
|
11
11
|
// v0.3 gate:
|
|
12
12
|
// - isOutsideSpotterProject: cwd has no .spotter/marker.json above it → exit 0
|
|
13
13
|
// (Throughline workdir etc. — `claude -p` from tools outside any installed project)
|
|
14
|
+
//
|
|
15
|
+
// v0.12.0: spawn + readiness-poll moved to spawn-daemon.mjs (shared with auto-resurrect
|
|
16
|
+
// in user-prompt.mjs). The --parent-pid scheme from v0.6.2 is gone; orphan cleanup is
|
|
17
|
+
// now heartbeat-based inside the daemon.
|
|
14
18
|
|
|
15
|
-
import { spawn } from 'node:child_process';
|
|
16
|
-
import { setTimeout as delay } from 'node:timers/promises';
|
|
17
|
-
import { fileURLToPath } from 'node:url';
|
|
18
|
-
import { dirname, resolve } from 'node:path';
|
|
19
19
|
import { readStdinJson, requireString, die, isChildCall, isSubagentCall, isOutsideSpotterProject, findSpotterMarker } from './lib.mjs';
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
const READINESS_TIMEOUT_MS = 3_000;
|
|
23
|
-
const POLL_INTERVAL_MS = 100;
|
|
20
|
+
import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
|
|
24
21
|
|
|
25
|
-
export async function runSessionStart({
|
|
26
|
-
// Gate 1 (pre-stdin): Spotter's own claude -p subprocess — exit without reading stdin.
|
|
22
|
+
export async function runSessionStart({ now = Date.now } = {}) {
|
|
27
23
|
if (isChildCall()) return;
|
|
28
24
|
|
|
29
25
|
const input = await readStdinJson();
|
|
30
26
|
|
|
31
|
-
// Gate 2: Task subagent — skip audit.
|
|
32
27
|
if (isSubagentCall(input)) return;
|
|
33
|
-
|
|
34
|
-
// Gate 3: non-startup sources (resume/compact/clear) don't spawn a new daemon.
|
|
35
28
|
if (input.source !== 'startup') return;
|
|
36
|
-
|
|
37
|
-
// Gate 4 (v0.3): cwd is not inside any project that has been `spotter install`-ed.
|
|
38
|
-
// Other tools (Throughline etc.) launching `claude -p` in unrelated workdirs land here.
|
|
39
29
|
if (isOutsideSpotterProject(input)) return;
|
|
40
30
|
|
|
41
31
|
const sessionId = requireString(input, 'session_id');
|
|
@@ -44,66 +34,9 @@ export async function runSessionStart({ argv = process.argv, now = Date.now } =
|
|
|
44
34
|
die(`SessionStart: failed to locate project root from cwd=${input.cwd}`, 2);
|
|
45
35
|
}
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const deadline = now() + READINESS_TIMEOUT_MS;
|
|
50
|
-
while (now() < deadline) {
|
|
51
|
-
try {
|
|
52
|
-
const resp = await sendRequest({
|
|
53
|
-
sessionId,
|
|
54
|
-
event: 'readiness',
|
|
55
|
-
timeoutMs: 500,
|
|
56
|
-
});
|
|
57
|
-
if (resp.ok === true && resp.result && resp.result.ready === true) {
|
|
58
|
-
return; // success — exit 0 implicitly
|
|
59
|
-
}
|
|
60
|
-
} catch (err) {
|
|
61
|
-
if (!(err instanceof TransportError) || err.code !== 'E_UNREACHABLE') {
|
|
62
|
-
// E_TIMEOUT or internal errors while daemon is booting — keep polling.
|
|
63
|
-
// E_UNREACHABLE means the socket file/pipe doesn't exist yet — also retryable.
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
await delay(POLL_INTERVAL_MS);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
die(`daemon did not reach readiness within ${READINESS_TIMEOUT_MS}ms for session ${sessionId}`, 2);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function spawnDaemon(sessionId, projectRoot, argv) {
|
|
73
|
-
// Invoke `node <spotter-bin> daemon start --session-id ...` detached.
|
|
74
|
-
// v0.6.2: pass --parent-pid so the daemon can self-terminate when Claude Code dies
|
|
75
|
-
// without firing SessionEnd. process.ppid here is Claude Code (this hook's parent).
|
|
76
|
-
// v0.7.0: pass --project-root for tool-db loading.
|
|
77
|
-
const spotterBin = resolveSpotterBin(argv);
|
|
78
|
-
const child = spawn(
|
|
79
|
-
process.execPath,
|
|
80
|
-
[
|
|
81
|
-
spotterBin, 'daemon', 'start',
|
|
82
|
-
'--session-id', sessionId,
|
|
83
|
-
'--parent-pid', String(process.ppid),
|
|
84
|
-
'--project-root', projectRoot,
|
|
85
|
-
],
|
|
86
|
-
{
|
|
87
|
-
detached: true,
|
|
88
|
-
stdio: 'ignore',
|
|
89
|
-
windowsHide: true,
|
|
90
|
-
}
|
|
91
|
-
);
|
|
92
|
-
child.on('error', (err) => {
|
|
93
|
-
// best effort: the polling below will fail if the spawn actually didn't work
|
|
94
|
-
process.stderr.write(`spotter-hook: daemon spawn error: ${err.message}\n`);
|
|
95
|
-
});
|
|
96
|
-
child.unref();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function resolveSpotterBin(argv) {
|
|
100
|
-
// argv[1] is the path to the currently running script (bin/spotter.mjs when invoked via CLI,
|
|
101
|
-
// or src/hooks/session-start.mjs in tests). Walk up to the package root.
|
|
102
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
103
|
-
return resolve(here, '..', '..', 'bin', 'spotter.mjs');
|
|
37
|
+
await spawnDaemonAndWaitReady({ sessionId, projectRoot, now });
|
|
104
38
|
}
|
|
105
39
|
|
|
106
|
-
// Direct-execution entry — used when called as `node session-start.mjs`.
|
|
107
40
|
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
108
41
|
runSessionStart().catch((err) => die(err.message, err.exitCode ?? 2));
|
|
109
42
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shared daemon-spawn + readiness-poll plumbing.
|
|
2
|
+
//
|
|
3
|
+
// Used by:
|
|
4
|
+
// - session-start.mjs (initial daemon for a fresh session)
|
|
5
|
+
// - user-prompt.mjs (auto-resurrect when a heartbeat-killed or crashed daemon
|
|
6
|
+
// is detected at the start of a new turn — v0.12.0)
|
|
7
|
+
//
|
|
8
|
+
// The hook caller is responsible for the v0.2 gates (isChildCall / isSubagentCall /
|
|
9
|
+
// isOutsideSpotterProject) and for resolving sessionId + projectRoot before invoking
|
|
10
|
+
// this. We keep this module narrowly focused on "spawn detached + poll until ready".
|
|
11
|
+
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, resolve } from 'node:path';
|
|
16
|
+
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
17
|
+
|
|
18
|
+
const READINESS_TIMEOUT_MS = 3_000;
|
|
19
|
+
const POLL_INTERVAL_MS = 100;
|
|
20
|
+
|
|
21
|
+
export async function spawnDaemonAndWaitReady({ sessionId, projectRoot, now = Date.now }) {
|
|
22
|
+
spawnDaemon(sessionId, projectRoot);
|
|
23
|
+
|
|
24
|
+
const deadline = now() + READINESS_TIMEOUT_MS;
|
|
25
|
+
while (now() < deadline) {
|
|
26
|
+
try {
|
|
27
|
+
const resp = await sendRequest({
|
|
28
|
+
sessionId,
|
|
29
|
+
event: 'readiness',
|
|
30
|
+
timeoutMs: 500,
|
|
31
|
+
});
|
|
32
|
+
if (resp.ok === true && resp.result && resp.result.ready === true) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (!(err instanceof TransportError) || err.code !== 'E_UNREACHABLE') {
|
|
37
|
+
// E_TIMEOUT or internal errors while daemon is booting — keep polling.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
await delay(POLL_INTERVAL_MS);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const err = new Error(`daemon did not reach readiness within ${READINESS_TIMEOUT_MS}ms for session ${sessionId}`);
|
|
44
|
+
err.exitCode = 2;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function spawnDaemon(sessionId, projectRoot) {
|
|
49
|
+
const spotterBin = resolveSpotterBin();
|
|
50
|
+
const child = spawn(
|
|
51
|
+
process.execPath,
|
|
52
|
+
[
|
|
53
|
+
spotterBin, 'daemon', 'start',
|
|
54
|
+
'--session-id', sessionId,
|
|
55
|
+
'--project-root', projectRoot,
|
|
56
|
+
],
|
|
57
|
+
{
|
|
58
|
+
detached: true,
|
|
59
|
+
stdio: 'ignore',
|
|
60
|
+
windowsHide: true,
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
child.on('error', (err) => {
|
|
64
|
+
process.stderr.write(`spotter-hook: daemon spawn error: ${err.message}\n`);
|
|
65
|
+
});
|
|
66
|
+
child.unref();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolveSpotterBin() {
|
|
70
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
71
|
+
return resolve(here, '..', '..', 'bin', 'spotter.mjs');
|
|
72
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// UserPromptSubmit hook — send user_input to daemon, inject additionalContext (§12.2 transparent).
|
|
2
2
|
// v0.2 gates: see src/hooks/session-start.mjs comment.
|
|
3
|
+
//
|
|
4
|
+
// v0.12.0: auto-resurrect. If sendRequest fails with E_UNREACHABLE (daemon was
|
|
5
|
+
// heartbeat-killed for 30 min idle, crashed, or was never spawned because SessionStart
|
|
6
|
+
// fired in a context we skipped), we spawn a fresh daemon and retry once. This is the
|
|
7
|
+
// natural recovery point — the start of a new turn — so the user's prompt is still
|
|
8
|
+
// audited even after long pauses or daemon failures.
|
|
3
9
|
|
|
4
10
|
import {
|
|
5
11
|
readStdinJson,
|
|
@@ -10,8 +16,10 @@ import {
|
|
|
10
16
|
isChildCall,
|
|
11
17
|
isSubagentCall,
|
|
12
18
|
isOutsideSpotterProject,
|
|
19
|
+
findSpotterMarker,
|
|
13
20
|
} from './lib.mjs';
|
|
14
|
-
import { sendRequest } from '../daemon/transport.mjs';
|
|
21
|
+
import { sendRequest, TransportError } from '../daemon/transport.mjs';
|
|
22
|
+
import { spawnDaemonAndWaitReady } from './spawn-daemon.mjs';
|
|
15
23
|
|
|
16
24
|
const TIMEOUT_MS = 30_000;
|
|
17
25
|
const SHORT_PROMPT_MAX_CHARS = 10;
|
|
@@ -25,22 +33,43 @@ export async function runUserPrompt() {
|
|
|
25
33
|
const sessionId = requireString(input, 'session_id');
|
|
26
34
|
const prompt = requireString(input, 'prompt');
|
|
27
35
|
|
|
28
|
-
// Short prompts (<=10 codepoints after trim) are almost never tool-required
|
|
29
|
-
// (greetings, acknowledgements, short questions). Skip Haiku entirely —
|
|
30
|
-
// daemon keeps lastUserInput=null, so the next turn_end passes with reason=no_user_input.
|
|
31
36
|
if ([...prompt.trim()].length <= SHORT_PROMPT_MAX_CHARS) return;
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
response = await sendRequest({
|
|
38
|
+
const sendUserInput = () =>
|
|
39
|
+
sendRequest({
|
|
36
40
|
sessionId,
|
|
37
41
|
event: 'user_input',
|
|
38
42
|
payload: { user_input: prompt },
|
|
39
43
|
timeoutMs: TIMEOUT_MS,
|
|
40
44
|
});
|
|
45
|
+
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await sendUserInput();
|
|
41
49
|
} catch (err) {
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
if (err instanceof TransportError && err.code === 'E_UNREACHABLE') {
|
|
51
|
+
// v0.12.0: daemon is gone (heartbeat shutdown, crash, missing). Resurrect and retry.
|
|
52
|
+
const projectRoot = findSpotterMarker(input.cwd);
|
|
53
|
+
if (!projectRoot) {
|
|
54
|
+
die(`user-prompt: cannot resurrect daemon — no .spotter/marker.json above cwd=${input.cwd}`, 2);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
await spawnDaemonAndWaitReady({ sessionId, projectRoot });
|
|
59
|
+
} catch (spawnErr) {
|
|
60
|
+
die(`user-prompt: daemon resurrect failed: ${spawnErr.message}`, spawnErr.exitCode ?? 2);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
response = await sendUserInput();
|
|
65
|
+
} catch (retryErr) {
|
|
66
|
+
die(`user-prompt transport failure after resurrect: ${retryErr.code ?? '?'}: ${retryErr.message}`, exitCodeFor(retryErr));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
die(`user-prompt transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
44
73
|
}
|
|
45
74
|
|
|
46
75
|
if (response.ok !== true) {
|
|
@@ -50,7 +79,7 @@ export async function runUserPrompt() {
|
|
|
50
79
|
|
|
51
80
|
const result = response.result;
|
|
52
81
|
if (result.pass === true) {
|
|
53
|
-
return;
|
|
82
|
+
return;
|
|
54
83
|
}
|
|
55
84
|
|
|
56
85
|
const additionalContext = formatTransparentContext(result.missing_tools);
|
package/src/version.mjs
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
|
|
1
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
2
|
+
export const version = pkg.version;
|