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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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, parentPid: null, projectRoot: 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, parentPid, projectRoot } = parseArgs(argv);
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
- running = await startDaemon({ sessionId, projectRoot, parentPid, logFn: log });
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.
@@ -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.6.2: parent-process watch interval. SessionStart hook passes the Claude Code PID;
49
- // the daemon polls it so it can self-terminate when Claude Code dies without firing
50
- // SessionEnd (crash, kill -9, IDE reload). 5s is a balance between responsiveness and
51
- // idle CPU cost — well below the cost of an orphan daemon staying up indefinitely.
52
- const DEFAULT_PARENT_WATCH_INTERVAL_MS = 5_000;
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
- parentPid = null,
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 (parentPid !== null && (!Number.isInteger(parentPid) || parentPid <= 0)) {
77
- throw new TypeError('parentPid must be a positive integer or null');
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
- // v0.6.2: parent-process watch. SessionEnd is a graceful path; if Claude Code dies
280
- // without it (crash, kill, IDE reload), nothing else cleans us up. Poll the parent
281
- // PID and self-terminate on its disappearance.
282
- let parentWatchHandle = null;
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 (parentWatchHandle !== null) {
300
- clearInterval(parentWatchHandle);
301
- parentWatchHandle = null;
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 { sendRequest, TransportError } from '../daemon/transport.mjs';
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({ argv = process.argv, now = Date.now } = {}) {
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
- spawnDaemon(sessionId, projectRoot, argv);
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
- let response;
34
- try {
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
- die(`user-prompt transport failure: ${err.code ?? '?'}: ${err.message}`, exitCodeFor(err));
43
- return;
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; // no additionalContext to inject
82
+ return;
54
83
  }
55
84
 
56
85
  const additionalContext = formatTransparentContext(result.missing_tools);
package/src/version.mjs CHANGED
@@ -1 +1,2 @@
1
- export const version = '0.10.0';
1
+ import pkg from '../package.json' with { type: 'json' };
2
+ export const version = pkg.version;