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 +59 -0
- package/package.json +1 -1
- package/src/cli/daemon-cmd.mjs +4 -4
- package/src/daemon/daemon.mjs +55 -51
- package/src/daemon/haiku-caller.mjs +55 -40
- package/src/version.mjs +1 -1
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
package/src/cli/daemon-cmd.mjs
CHANGED
|
@@ -34,10 +34,10 @@ export async function runDaemonStart({ argv }) {
|
|
|
34
34
|
|
|
35
35
|
let running;
|
|
36
36
|
try {
|
|
37
|
-
// v0.
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
running = await startDaemon({ sessionId, logFn: log
|
|
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.
|
package/src/daemon/daemon.mjs
CHANGED
|
@@ -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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
39
|
-
// v0.
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
const DEFAULT_HAIKU_TIMEOUT_MS =
|
|
39
|
+
const DEFAULT_HAIKU_CALL_WINDOW_MS = 10_000;
|
|
40
|
+
// v0.5.0: lowered 60s → 30s. 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
|
-
|
|
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 (
|
|
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
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
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
|
-
//
|
|
181
|
-
//
|
|
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.
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
function buildSpawnArgs(claudeBin, model) {
|
|
187
|
-
const args = ['-p', '--session-id',
|
|
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.
|
|
198
|
-
//
|
|
199
|
-
//
|
|
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
|
-
|
|
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(
|
|
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.
|
|
1
|
+
export const version = '0.5.0';
|