claude-spotter 0.4.4 → 0.5.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,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ **Session-scoped Haiku 復活 + role-collapse 回復機構 (UX 改善)**。
6
+
7
+ ### 事の発端
8
+
9
+ v0.4.4 の実運用観測で、**Bell 応答後に Claude の動きが落ち着くまで 30 秒前後かかる**ケースが常態化していることを確認。`claude -p` が毎ターン cold-start を踏んでいるのが支配的要因で、v0.4.x stateless 化 (毎ターン fresh `--session-id`) が原因。ユーザーと「速度と独立性のトレードオフ」を再議論した結果、**v0.4.0 の判断を反転**し session-scoped に戻すことで決定。
10
+
11
+ ### 設計 — trade-off をどう捌いたか
12
+
13
+ v0.4.0 で session-scoped を捨てた理由は **Haiku role collapse** (Bell 会話履歴を聞き続けた Haiku が persona drift して JSON 契約を破棄する既発バグ)。今回はこれを:
14
+
15
+ - **構造的予防 (stateless)** ではなく
16
+ - **事後回復 (JSON パース失敗検知 → session renew + silent pass)** で処理する
17
+
18
+ という方針に切り替えた。role collapse は稀事象であり、構造的予防のために毎ターン cold-start を払うコストに見合わない。JSON スキーマ違反を検知した時点で、その Haiku 出力は既にゴミ (block 判定に使えば誤検出で block される) なので、**silent-pass + 次ターンから新 session** が最も UX 影響の小さい正しい手当て。これは CLAUDE.md §0 の「想定済み異常 = 記録 + 正常リターン」に合致する分類変更であり、silent fallback の新規導入ではない。
19
+
20
+ 差し戻しループ vs 沈黙 vs 遅延の三択で検討したオプション:
21
+ - A (原則通り throw): UserPromptSubmit exit 2 で Bell 沈黙 → UX 最悪 (v0.4.0 で問題視された症状そのもの)
22
+ - B (reset 後 1 回リトライ): 異常時のみ cold-start 2 回分 = 30-60 秒待ち
23
+ - **C (silent pass + reset)**: 今ターンだけ監査スキップ、次ターンから正常復帰 ← 採用
24
+
25
+ ### 変更点
26
+
27
+ - **[src/daemon/haiku-caller.mjs](src/daemon/haiku-caller.mjs)**:
28
+ - `createHaikuCaller` を closure で `currentSessionId` と `isFirstCall` を保持する形に再構成。第 1 回は `--session-id <uuid>` のみで spawn、以降は `--session-id <uuid> --resume <uuid>` で同一セッションに reattach。
29
+ - 返り値の callable に `.reset()` と `.sessionId` を付与 (既存テストの `typeof caller === 'function'` 互換のため function に property を足す形)。
30
+ - `buildSpawnArgs` を export (session-id/resume のテスト用)。
31
+ - `buildWarmupPrompt` を削除 (warmup は stateless 対策だった)。
32
+ - **[src/daemon/daemon.mjs](src/daemon/daemon.mjs)**:
33
+ - `runHaikuJudgment` ヘルパー新設。`parseHaikuResponse` が `E_HAIKU_SCHEMA` を throw したら `callHaiku.reset()` を呼び、`{pass: true, missing_tools: [], reason: 'role_collapse_reset'}` を返す。
34
+ - `warmup` オプション削除。`haikuCallWindowMs` オプション追加 (テスト時は 0 で 10 秒ウィンドウを無効化)。
35
+ - `DEFAULT_HAIKU_TIMEOUT_MS` 60s → 30s (session-scoped なら 2 回目以降は cold-start を払わないため短縮可能)。
36
+ - **[src/cli/daemon-cmd.mjs](src/cli/daemon-cmd.mjs)**: `startDaemon` 呼び出しから `warmup: true` 削除。
37
+ - テスト:
38
+ - `buildWarmupPrompt` テスト削除 (3 件)。
39
+ - `createHaikuCaller` の戻り値構造テストを session-scoped + reset 期待に書き換え。
40
+ - `buildSpawnArgs` テスト 2 件追加 (初回 `--session-id` のみ、2 回目以降 `--resume` 付与)。
41
+ - daemon の role-collapse recovery テスト 2 件追加 (user_input / turn_end 両方)。
42
+
43
+ ### 効果見込み
44
+
45
+ - **通常時 UX**: 2 回目以降の Haiku 呼び出しで cold-start が消える → 30s 前後の待ちが推論時間 (数秒) だけに短縮される見込み。
46
+ - **異常時 UX**: role collapse を検知しても、当該ターンのみ監査スキップで Bell の応答はそのまま届く。次ターンから fresh session で監査再開。沈黙ゼロ。
47
+ - **品質**: silent pass が発動したら daemon ログに `role collapse detected, session reset` が残るので、頻度を観測して将来の設計判断材料にする。
48
+
49
+ ### 既知のトレードオフ
50
+
51
+ - Haiku 側の会話履歴に過去の監査が累積する (これが v0.4.0 で問題視された persona drift の源)。構造的には予防しないが、JSON パース失敗が監視ポイントになっているので、drift が顕在化した瞬間に session が切られる。
52
+ - `claude -p --resume` が実際どの程度 spawn コストを削減するかは実測未検証 (プロセス起動・認証自体は毎回発生する可能性)。効果が薄ければ追加検討。
53
+
54
+ ### v0.4.0 の判断反転について
55
+
56
+ v0.4.0 以降「再度 session-scoped を提案しないこと」を絶対制約としていたが、**「速度問題が実運用で深刻になった」「role collapse は事後回復で足りる」の 2 点から反転**。この判断は感情的でも場当たり的でもなく、以下の条件変化を踏まえたもの:
57
+
58
+ 1. v0.4.2 で入れた warmup + timeout 60s でも 30 秒前後の待ちが残る実測
59
+ 2. v0.4.4 で Stop hook が実際に Bell 応答を読むようになり、監査の正確度が上がった → silent pass のコストが下がった
60
+ 3. 役割逸脱検知を JSON パース失敗という客観的シグナルで判定できる設計が見えた
61
+
3
62
  ## 0.4.4
4
63
 
5
64
  **Stop hook が Bell の最終応答を Haiku に正しく渡すよう修正**。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-spotter",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Audit agent running alongside Claude Code that catches missed tool calls — 気づく役と実行する役の分離",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,10 +34,10 @@ export async function runDaemonStart({ argv }) {
34
34
 
35
35
  let running;
36
36
  try {
37
- // v0.4.2: warmup=true — fire-and-forget throwaway Haiku call to pre-warm the
38
- // Claude CLI / connection pool / prompt cache. Unit tests keep warmup=false
39
- // (the default) to avoid spurious haikuCaller calls.
40
- running = await startDaemon({ sessionId, logFn: log, warmup: true });
37
+ // v0.5.0: no warmup. Session-scoped Haiku (--resume on follow-ups) pays cold-start
38
+ // only on the first real call; warmup added complexity for marginal benefit and is
39
+ // removed along with the stateless regime that required it.
40
+ running = await startDaemon({ sessionId, logFn: log });
41
41
  } catch (err) {
42
42
  if (err instanceof DaemonAlreadyRunningError) {
43
43
  // v0.2 PID-preexist layer: a sibling daemon already serves this session.
@@ -1,13 +1,14 @@
1
1
  // Session-scoped daemon — receives hook events, dispatches to handlers,
2
2
  // calls Haiku on user_input / turn_end, keeps used_tools in process memory.
3
3
  //
4
- // v0.4: Haiku calls are STATELESS. Each call is an independent claude -p invocation with
5
- // full system prompt + catalog. There is no session-scoped Haiku conversation
6
- // (§18.5 is reverted). The daemon still keeps per-turn state (used_tools, lastUserInput)
7
- // in its own memory; what was removed is the *Haiku-side* conversation persistence
8
- // introduced in v0.2.0. This matches the CLAUDE.md core design
9
- // ("Claude 呼び出しは毎回 stateless") and prevents role collapse caused by Haiku
10
- // accumulating Bell conversation history across turns.
4
+ // v0.5.0: Haiku calls are session-scoped at the claude -p layer (--session-id + --resume).
5
+ // The daemon holds one Haiku caller for the session's lifetime; the first call establishes
6
+ // the claude -p session and every subsequent call reattaches, avoiding cold-start spawn.
7
+ // Role collapse (Haiku drifting into Bell's persona, previously the reason v0.4.0 reverted
8
+ // to stateless) is handled by a recovery mechanism rather than structural prevention:
9
+ // E_HAIKU_SCHEMA → haikuCaller.reset() + silent-pass the offending turn. This is the §0
10
+ // "想定済み異常 = 記録 + 正常リターン" classification.
11
+ //
11
12
  // §5.7: event dispatch follows the envelope contract.
12
13
  // §14: unexpected errors are thrown; hooks convert them to exit codes.
13
14
  //
@@ -25,9 +26,9 @@ import { createServer, ensureRuntimeDir, socketPath } from './transport.mjs';
25
26
  import {
26
27
  buildFirstStagePrompt,
27
28
  buildFinalStagePrompt,
28
- buildWarmupPrompt,
29
29
  parseHaikuResponse,
30
30
  createHaikuCaller,
31
+ HaikuError,
31
32
  } from './haiku-caller.mjs';
32
33
  import { loadCatalog } from '../catalog/loader.mjs';
33
34
  import { homedir } from 'node:os';
@@ -35,12 +36,12 @@ import { join } from 'node:path';
35
36
  import { writeFile, unlink } from 'node:fs/promises';
36
37
 
37
38
  const DEFAULT_CATALOG_PATH = join(homedir(), '.spotter', 'tool-catalog', 'tools.yaml');
38
- const HAIKU_CALL_WINDOW_MS = 10_000;
39
- // v0.4.2: bumped 28s60s. Stateless calls mean every turn pays cold-start cost
40
- // (Claude CLI boot, Anthropic round-trip, prompt-cache warm-up). 28s was not enough
41
- // under observed load E_HAIKU_TIMEOUT then blocked UserPromptSubmit (exit 2)
42
- // and silenced Bell. Warmup below also helps but cannot eliminate every cold start.
43
- const DEFAULT_HAIKU_TIMEOUT_MS = 60_000;
39
+ const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
40
+ // v0.5.0: lowered 60s30s. Session-scoped (--resume) means the first call still pays
41
+ // cold-start but subsequent calls skip it. 30s covers the first-call cold path without
42
+ // being excessive, and a role-collapse recovery cycle (reset → next call is effectively
43
+ // a cold start again) stays within budget.
44
+ const DEFAULT_HAIKU_TIMEOUT_MS = 30_000;
44
45
 
45
46
  export class DaemonAlreadyRunningError extends Error {
46
47
  constructor(sessionId, pid) {
@@ -56,7 +57,7 @@ export async function startDaemon({
56
57
  catalogPath = DEFAULT_CATALOG_PATH,
57
58
  haikuCaller,
58
59
  logFn = () => {},
59
- warmup = false,
60
+ haikuCallWindowMs = DEFAULT_HAIKU_CALL_WINDOW_MS,
60
61
  } = {}) {
61
62
  if (!sessionId) {
62
63
  throw new TypeError('sessionId is required');
@@ -82,6 +83,7 @@ export async function startDaemon({
82
83
 
83
84
  // 10-second recursion-guard bookkeeping. Every Haiku spawn updates this; incoming
84
85
  // Haiku-invoking events within the window are treated as recursive noise and passed.
86
+ // Tests may pass haikuCallWindowMs: 0 to disable this guard.
85
87
  let lastHaikuCallAt = 0;
86
88
 
87
89
  const callHaikuTracked = async (prompt) => {
@@ -89,6 +91,25 @@ export async function startDaemon({
89
91
  return callHaiku(prompt);
90
92
  };
91
93
 
94
+ // v0.5.0: shared Haiku-invocation + parse helper. On E_HAIKU_SCHEMA (role collapse),
95
+ // rotates the Haiku session-id and silent-passes the turn (reason: role_collapse_reset).
96
+ // Other Haiku errors (timeout, spawn failure) still propagate — §14 unexpected → throw.
97
+ const runHaikuJudgment = async (stage, prompt) => {
98
+ const raw = await callHaikuTracked(prompt);
99
+ try {
100
+ return parseHaikuResponse(raw);
101
+ } catch (err) {
102
+ if (err instanceof HaikuError && err.code === 'E_HAIKU_SCHEMA') {
103
+ logFn(`${stage}: role collapse detected, session reset: ${err.message}`);
104
+ if (typeof callHaiku.reset === 'function') {
105
+ callHaiku.reset();
106
+ }
107
+ return { pass: true, missing_tools: [], reason: 'role_collapse_reset' };
108
+ }
109
+ throw err;
110
+ }
111
+ };
112
+
92
113
  const handler = async (envelope) => {
93
114
  if (!envelope || typeof envelope !== 'object') {
94
115
  const err = new Error('invalid envelope');
@@ -105,7 +126,12 @@ export async function startDaemon({
105
126
  // claude -p spawn are likely recursive noise; pass them quietly.
106
127
  const needsHaiku = envelope.event === 'user_input' || envelope.event === 'turn_end';
107
128
  const sinceLast = Date.now() - lastHaikuCallAt;
108
- if (needsHaiku && lastHaikuCallAt > 0 && sinceLast < HAIKU_CALL_WINDOW_MS) {
129
+ if (
130
+ needsHaiku &&
131
+ haikuCallWindowMs > 0 &&
132
+ lastHaikuCallAt > 0 &&
133
+ sinceLast < haikuCallWindowMs
134
+ ) {
109
135
  logFn(`${envelope.event} skipped: within ${sinceLast}ms of own haiku call`);
110
136
  return { pass: true, missing_tools: [], reason: 'within_haiku_call_window' };
111
137
  }
@@ -140,9 +166,12 @@ export async function startDaemon({
140
166
  state.lastUserInput = userInput;
141
167
  state.usedTools = []; // reset tools for this turn
142
168
 
143
- const raw = await callHaikuTracked(buildFirstStagePrompt({ catalog, userInput }));
144
- const parsed = parseHaikuResponse(raw);
145
- logFn(`user_input: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
169
+ const parsed = await runHaikuJudgment('user_input', buildFirstStagePrompt({ catalog, userInput }));
170
+ logFn(
171
+ `user_input: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}${
172
+ parsed.reason ? `, reason=${parsed.reason}` : ''
173
+ }`
174
+ );
146
175
  return parsed;
147
176
  }
148
177
 
@@ -178,7 +207,8 @@ export async function startDaemon({
178
207
 
179
208
  const savedUserInput = state.lastUserInput;
180
209
  const savedUsedTools = state.usedTools.slice();
181
- const raw = await callHaikuTracked(
210
+ const parsed = await runHaikuJudgment(
211
+ 'turn_end',
182
212
  buildFinalStagePrompt({
183
213
  catalog,
184
214
  userInput: savedUserInput,
@@ -186,8 +216,11 @@ export async function startDaemon({
186
216
  finalResponse,
187
217
  })
188
218
  );
189
- const parsed = parseHaikuResponse(raw);
190
- logFn(`turn_end: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}`);
219
+ logFn(
220
+ `turn_end: pass=${parsed.pass}, missing=${parsed.missing_tools.map((m) => m.name).join(',')}${
221
+ parsed.reason ? `, reason=${parsed.reason}` : ''
222
+ }`
223
+ );
191
224
 
192
225
  state.usedTools = [];
193
226
  state.lastUserInput = null;
@@ -213,35 +246,6 @@ export async function startDaemon({
213
246
  const pidPath = pidFilePath(sessionId);
214
247
  await writeFile(pidPath, String(process.pid), 'utf8');
215
248
 
216
- // v0.4.2: stateless-safe warmup.
217
- // Spawns a single throwaway Haiku call with the full system-prompt + catalog prefix
218
- // (same as real calls). Goal: pre-load the Claude CLI binary, warm the Anthropic
219
- // connection pool, and populate the prompt cache for the shared prefix, so the
220
- // first real user_input does not pay full cold-start cost.
221
- //
222
- // Important: we bypass callHaikuTracked to avoid setting lastHaikuCallAt. If the
223
- // 10-second recursion window activated, a legitimate user_input arriving within
224
- // 10s of warmup would be silent-passed (the v0.2.1 bug this mirrors).
225
- //
226
- // Fire-and-forget: warmup MUST NOT block readiness or real traffic. Failure is
227
- // logged but not thrown — at worst, the first real call pays the cold-start cost
228
- // we were trying to avoid, same as the pre-warmup baseline.
229
- if (warmup) {
230
- const warmupPrompt = buildWarmupPrompt({ catalog });
231
- callHaiku(warmupPrompt)
232
- .then((raw) => {
233
- try {
234
- parseHaikuResponse(raw);
235
- logFn('warmup: ok');
236
- } catch (err) {
237
- logFn(`warmup: schema failed: ${err.code ?? 'E_INTERNAL'}: ${err.message}`);
238
- }
239
- })
240
- .catch((err) => {
241
- logFn(`warmup: call failed: ${err.code ?? 'E_INTERNAL'}: ${err.message}`);
242
- });
243
- }
244
-
245
249
  return {
246
250
  server,
247
251
  path,
@@ -1,13 +1,17 @@
1
1
  // claude -p --model claude-haiku-4-5-* wrapper.
2
2
  // §5.5: structured JSON I/O, no retries, schema violations throw.
3
3
  //
4
- // v0.4: each Haiku invocation is STATELESS no --resume, no session-scoped conversation.
5
- // Every call is an isolated --session-id <fresh UUID> with the full system prompt + catalog.
6
- // This reverts the v0.2.0 session-scoped optimisation, which caused role-collapse on long
7
- // sessions: Haiku, having listened to the accumulating Bell conversation, eventually drifted
8
- // into Bell's persona and abandoned the JSON contract ("Spotter のロールは正式に終了します"),
9
- // producing E_HAIKU_SCHEMA and silencing the user via hook exit 1.
10
- // Stateless calls prevent that drift structurally each call starts from zero context.
4
+ // v0.5.0: session-scoped Haiku is back. Each daemon holds a single Haiku session-id for
5
+ // the life of the Bell session; the first call uses --session-id only, subsequent calls
6
+ // use --session-id + --resume to reattach to the same claude -p conversation, avoiding
7
+ // per-turn cold-start spawn (observed 20–50s in v0.4.x stateless mode).
8
+ //
9
+ // Role-collapse (Haiku drifting into Bell's persona over long sessions — the failure mode
10
+ // v0.4.0 reverted to stateless to avoid) is now handled by a recovery mechanism instead of
11
+ // structural prevention: if the daemon detects a schema violation (E_HAIKU_SCHEMA) from
12
+ // parseHaikuResponse, it calls reset() on the caller to renew the session-id, then silent-
13
+ // passes the offending turn. See daemon.mjs for the catch site. This makes role collapse a
14
+ // "想定済み異常" (CLAUDE.md §0) — recorded, recovered from, never bubbled up as exit 2.
11
15
 
12
16
  import { spawn } from 'node:child_process';
13
17
  import { homedir } from 'node:os';
@@ -32,12 +36,9 @@ export async function ensureWorkdir() {
32
36
  return WORKDIR;
33
37
  }
34
38
 
35
- // v0.4.3 minimization:
36
- // The prompt was getting bigger each iteration (role-guard enumeration, tag injection defence,
37
- // triple restatement of "JSON only"). For a solo project there is no adversarial prompt-
38
- // injection threat, and persona drift is already structurally prevented by stateless calls.
39
- // So we trim aggressively. Shorter prompts → the judgment-anchoring instruction at the tail
40
- // is relatively more prominent → JSON compliance tends to improve, not worsen.
39
+ // v0.4.3 minimization (retained in v0.5.0):
40
+ // Aggressive trim no adversarial prompt-injection list, no 【最重要】 tags, no triple
41
+ // restatement. The judgment-anchoring instruction at the tail stays prominent.
41
42
  //
42
43
  // Shared header (role + schema + few-shot) is identical between first/final stages, which
43
44
  // keeps the Anthropic prompt-cache prefix stable across calls of the same stage.
@@ -106,19 +107,6 @@ function projectCatalog(catalog) {
106
107
  }));
107
108
  }
108
109
 
109
- // Build a lightweight warmup prompt — pre-loads the Claude CLI, network pool,
110
- // and Anthropic prompt cache (system rules + catalog prefix is identical to real calls).
111
- // The user_input is a sentinel that should cleanly return pass:true.
112
- // v0.4.2: stateless-safe. Uses the same buildFirstStagePrompt path, with its own
113
- // fresh --session-id at spawn time (like every other stateless call).
114
- // No conversation state survives the warmup.
115
- export function buildWarmupPrompt({ catalog }) {
116
- return buildFirstStagePrompt({
117
- catalog,
118
- userInput: '__spotter_warmup_ping__',
119
- });
120
- }
121
-
122
110
  // Parse Haiku's response. Throws HaikuError on schema violation.
123
111
  export function parseHaikuResponse(raw) {
124
112
  const trimmed = raw.trim();
@@ -176,15 +164,16 @@ function truncate(s, n = 300) {
176
164
  return s.slice(0, n) + '...';
177
165
  }
178
166
 
179
- // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn
180
- // cannot locate without going through the shell. We use cmd.exe /c explicitly
181
- // rather than spawn({ shell: true }) because the latter triggers DEP0190 on Node 24+.
167
+ // On Windows, the `claude` entry is typically a .cmd shim which Node's spawn cannot locate
168
+ // without going through the shell. We use cmd.exe /c explicitly rather than spawn({ shell:
169
+ // true }) because the latter triggers DEP0190 on Node 24+.
182
170
  //
183
- // v0.4: stateless each call spawns with a fresh --session-id so no conversation history
184
- // carries over. We keep the flag (rather than omitting) so each call has an explicit,
185
- // loggable session id, which aids debugging when something goes wrong.
186
- function buildSpawnArgs(claudeBin, model) {
187
- const args = ['-p', '--session-id', randomUUID(), '--model', model];
171
+ // v0.5.0: session-scoped. First call of a given session id uses --session-id only; every
172
+ // subsequent call adds --resume so claude -p re-attaches to that session, skipping cold
173
+ // start. buildSpawnArgs is exported so tests can assert the flag wiring without spawning.
174
+ export function buildSpawnArgs({ claudeBin, model, sessionId, resume }) {
175
+ const args = ['-p', '--session-id', sessionId, '--model', model];
176
+ if (resume) args.push('--resume', sessionId);
188
177
  if (process.platform === 'win32') {
189
178
  return { cmd: 'cmd.exe', cmdArgs: ['/c', claudeBin, ...args] };
190
179
  }
@@ -192,20 +181,30 @@ function buildSpawnArgs(claudeBin, model) {
192
181
  }
193
182
 
194
183
  // Invoke `claude -p` in the isolated workdir. Returns raw stdout.
195
- // §5.5: no retry on failure. §14.1: silent fallback forbidden.
184
+ // §5.5: no retry on failure. §14.1: silent fallback forbidden (role-collapse recovery in
185
+ // daemon.mjs is an explicit §0 exception, not silent fallback).
196
186
  //
197
- // v0.4: STATELESS. Each call is a fresh --session-id; no --resume, no warmup.
198
- // SPOTTER_PARENT_PID is injected so hooks firing inside the spawned claude exit early
199
- // via isChildCall() (prevents daemon-spawn recursion).
187
+ // v0.5.0: session-scoped. The caller keeps one session-id for the session's lifetime, and
188
+ // exposes a reset() method that rotates the session-id (used by daemon on E_HAIKU_SCHEMA
189
+ // to recover from role collapse). The returned value is a function (so existing tests that
190
+ // check `typeof caller === 'function'` still pass) with `reset` and `sessionId` attached.
200
191
  export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAIKU_MODEL, env = process.env }) {
201
192
  if (typeof timeoutMs !== 'number' || timeoutMs <= 0) {
202
193
  throw new TypeError('timeoutMs must be a positive number');
203
194
  }
204
195
 
205
- return async function callHaiku(prompt) {
196
+ let currentSessionId = randomUUID();
197
+ let isFirstCall = true;
198
+
199
+ const callHaiku = async function (prompt) {
206
200
  await ensureWorkdir();
207
201
  return new Promise((resolve, reject) => {
208
- const { cmd, cmdArgs } = buildSpawnArgs(claudeBin, model);
202
+ const { cmd, cmdArgs } = buildSpawnArgs({
203
+ claudeBin,
204
+ model,
205
+ sessionId: currentSessionId,
206
+ resume: !isFirstCall,
207
+ });
209
208
  const child = spawn(cmd, cmdArgs, {
210
209
  cwd: WORKDIR,
211
210
  env: { ...env, SPOTTER_PARENT_PID: String(process.pid) },
@@ -241,10 +240,26 @@ export function createHaikuCaller({ timeoutMs, claudeBin = 'claude', model = HAI
241
240
  reject(new HaikuError('E_INTERNAL', `haiku exited with code ${code}: ${truncate(stderr)}`));
242
241
  return;
243
242
  }
243
+ // Flip isFirstCall only after a successful spawn — a failed first call should
244
+ // still be treated as "session not yet established" so the next attempt uses
245
+ // --session-id only (not --resume against a non-existent session).
246
+ isFirstCall = false;
244
247
  resolve(stdout);
245
248
  });
246
249
 
247
250
  child.stdin.end(prompt, 'utf8');
248
251
  });
249
252
  };
253
+
254
+ callHaiku.reset = () => {
255
+ currentSessionId = randomUUID();
256
+ isFirstCall = true;
257
+ };
258
+
259
+ Object.defineProperty(callHaiku, 'sessionId', {
260
+ get: () => currentSessionId,
261
+ enumerable: true,
262
+ });
263
+
264
+ return callHaiku;
250
265
  }
package/src/version.mjs CHANGED
@@ -1 +1 @@
1
- export const version = '0.4.4';
1
+ export const version = '0.5.0';