@lifeaitools/clauth 1.19.4 → 1.30.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.
@@ -1,1745 +1,1946 @@
1
- // agent-pool.js — warm Haiku worker pool for call_agent one-shot dispatch.
2
- //
3
- // Design (Gate B, WP-B1/B3/B4) — cold path:
4
- // * Each `claude -p "<prompt>"` is inherently a fresh detached process — the
5
- // prompt is passed as argv, so an idle pre-spawned process cannot be
6
- // re-targeted. The cold "warm pool" therefore does two things:
7
- // 1. Resolves + caches the claude binary once (no per-call `--version`
8
- // probe) and keeps a neutral, project-free cwd hot on disk.
9
- // 2. Optionally fires `poolSize` priming pings at startup so the OS file
10
- // cache / model session is warm — the FIRST real call then returns in
11
- // ~4-5s instead of the ~50-90s cold path.
12
- //
13
- // WP-P2 — TRUE persistent re-targetable warm workers:
14
- // * The `claude` CLI (v2.1.170) supports `-p --input-format stream-json
15
- // --output-format stream-json`: a single long-lived process that reads
16
- // newline-delimited user-message envelopes from stdin and emits
17
- // newline-delimited events (including a `result` per turn) on stdout. The
18
- // SAME process (same session_id) answers MULTIPLE sequential prompts —
19
- // proven: 3 prompts on one process, sid stable, 4.4s → 2.0s → 2.8s with NO
20
- // per-call CLI boot. This is true re-targeting: a pre-spawned idle worker IS
21
- // re-aimable at a new prompt over stdin.
22
- // * `WarmWorker` wraps one such process. `AgentPool.dispatch()` prefers a free
23
- // warm worker (sub-boot dispatch — latency is pure model inference, not the
24
- // ~10-15s CLI cold-boot) and transparently FALLS BACK to the cold spawn path
25
- // when warm workers are disabled, all busy, or unavailable. Fully backward
26
- // compatible: the public dispatch()/job-record contract is unchanged.
27
- // * Warm workers are health-checked and AUTO-RESTARTED on death; the reaper
28
- // enforces per-turn deadlines on warm turns too. Model-tiered: each pool is
29
- // pinned to one model (default Haiku); a Sonnet/Opus pool is a second
30
- // AgentPool instance.
31
- //
32
- // * Lean one-shot: neutral cwd + `--setting-sources user` + `--strict-mcp-config`
33
- // strips the regen-root CLAUDE.md / SessionEnd hooks / lessons gate that
34
- // otherwise contaminates replies ("Waived." instead of the real answer).
35
- // * Auth: piggybacks the CLI's own login session. NEVER sets ANTHROPIC_API_KEY.
36
- // * Reaping: a periodic sweep kills workers that exceed their timeout and
37
- // frees concurrency slots so the queue self-heals.
38
- //
39
- // This module is intentionally self-contained and daemon-free so its core can
40
- // be unit/integration tested WITHOUT the clauth vault or boot.key.
41
-
42
- import { spawn } from "child_process";
43
- import { execSync } from "child_process";
44
- import os from "os";
45
- import path from "path";
46
- import fs from "fs";
47
- import crypto from "crypto";
48
-
49
- export const DEFAULT_MODEL = "claude-haiku-4-5";
50
- export const DEFAULT_TIMEOUT_MS = 120000;
51
- export const DEFAULT_POOL_SIZE = 2;
52
- // Warm workers are ON by default. Set CLAUTH_AGENT_WARM=0 to force the cold path.
53
- export const WARM_ENABLED_DEFAULT =
54
- String(process.env.CLAUTH_AGENT_WARM ?? "1") !== "0";
55
-
56
- // ── WP-P7: hop-limited sub-agent delegation defaults ─────────────────────────
57
- // A parent call_agent worker may itself invoke call_agent SUB-agents, bounded by
58
- // a hop budget and routed through a SEPARATE execution lane (DelegationLane) so
59
- // it can NEVER consume the shared warm/interactive pool slots (anti-deadlock).
60
- export const DEFAULT_DELEGATION_MAX_HOPS = 2; // tree depth cap (hop 0 = top call)
61
- export const DEFAULT_DELEGATION_MAX_INFLIGHT = 4; // daemon-wide concurrent sub-agents
62
-
63
- const LEAN_SYSTEM_PROMPT =
64
- "You are a stateless one-shot agent worker invoked via clauth call_agent. " +
65
- "Answer the user's prompt directly, concisely, and completely. " +
66
- "Do not editorialize, do not read project files unless the prompt asks, " +
67
- "and do not emit meta-commentary about hooks, sessions, or governance. " +
68
- "If a JSON answer is requested, return ONLY valid JSON.";
69
-
70
- // ── Tiered call_agent startup (bootstrap) ────────────────────────────────────
71
- // A spawned worker otherwise has NO identity and NO idea what it can reach (no
72
- // clauth creds, no Supabase, no repo). These three tiers fix that, additively:
73
- //
74
- // * "none" — today's lean fast path, byte-identical to LEAN_SYSTEM_PROMPT.
75
- // For trivial one-shots; warm ~2s. Neutral, project-free cwd.
76
- // * "identity" — DEFAULT. Prepends a SHORT startup that tells the agent who it
77
- // is and HOW to reach access (clauth daemon, Supabase ref,
78
- // work_items RPCs, Bash/file tools). Neutral cwd. Low latency.
79
- // * "full" — for real repo/DB work: runs in the REPO cwd and points the
80
- // agent at .rdc/guides/agent-bootstrap.md (creds, git rules,
81
- // completion report) plus the identity/access block.
82
- //
83
- // IMPORTANT: the startup TELLS the agent how to fetch credentials; it NEVER
84
- // embeds a credential and NEVER sets ANTHROPIC_API_KEY.
85
- export const BOOTSTRAP_LEVELS = ["none", "identity", "full"];
86
- export const DEFAULT_BOOTSTRAP = "identity";
87
- export const DEFAULT_AGENT_REPO_ROOT =
88
- process.env.CLAUTH_AGENT_REPO_ROOT || "C:/Dev/regen-root";
89
-
90
- // The clauth credential daemon every Regen agent uses.
91
- const CLAUTH_DAEMON_URL = "http://127.0.0.1:52437";
92
- const SUPABASE_PROJECT_REF = "uvojezuorjgqzmhhgluu";
93
-
94
- // Short identity/access block — a dozen lines so latency stays low. This is the
95
- // minimum every default agent must know: who it is + how to get access.
96
- const IDENTITY_SYSTEM_PROMPT =
97
- "You are a Regen agent running on the operator's machine. " +
98
- "ACCESS — discover service names through the clauth HTTP daemon at " +
99
- `${CLAUTH_DAEMON_URL}: run \`curl -s ${CLAUTH_DAEMON_URL}/knowledge\` for ` +
100
- "sanitized metadata, then fetch exactly one needed credential with " +
101
- `\`curl -s ${CLAUTH_DAEMON_URL}/v/<service>\`. ` +
102
- "Do not bulk-fetch secrets. NEVER print a credential to stdout — it is logged. " +
103
- `The Supabase project ref is ${SUPABASE_PROJECT_REF}. ` +
104
- "Work items live in Supabase and are accessed ONLY through RPCs " +
105
- "(get_open_epics, insert_work_item, update_work_item_status) — never raw " +
106
- "SQL CRUD against work_items. You have Bash plus file read/write tools. " +
107
- "Answer the user's prompt directly and completely; if JSON is requested, " +
108
- "return ONLY valid JSON.";
109
-
110
- /**
111
- * Build the "full" startup: identity/access block + a pointer to the project
112
- * bootstrap guide that the agent must read first. Runs in the repo cwd.
113
- * @param {string} [repoRoot]
114
- */
115
- function buildFullSystemPrompt(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
116
- const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
117
- return (
118
- IDENTITY_SYSTEM_PROMPT +
119
- " You are running in the repo at " +
120
- root +
121
- ". Before doing repo or database work, READ " +
122
- root +
123
- "/.rdc/guides/agent-bootstrap.md FIRST it has the full credential, " +
124
- "Supabase, git (commit to develop, never main, never force-push), and " +
125
- "completion-report contract. Then read your role guide under " +
126
- root +
127
- "/.rdc/guides/ if one applies."
128
- );
129
- }
130
-
131
- // ── Corpus handoff (Regen Agent corpus, matching @regen/claude-dispatch) ─────
132
- // Every Regen route agent dispatched through @regen/claude-dispatch gets a
133
- // `## Corpus` section assembled into its prompt body from the dispatch
134
- // envelope's `corpus` field. call_agent previously gave only a thin identity
135
- // system prompt and NO corpus. composeCorpus() reproduces the EXACT dispatch
136
- // format (packages/claude-dispatch/src/channels/cli.ts buildPrompt) so a
137
- // call_agent worker starts up with the same handoff a route agent gets.
138
- //
139
- // Shape (DispatchCorpus): { product?: string, design?: string, docs?: CorpusDocument[] }
140
- // CorpusDocument: { path: string, title?: string, content: string }
141
- //
142
- // CRITICAL warm-reuse correctness: the corpus is assembled into the per-turn
143
- // PROMPT (the stdin/`-p` user message), NOT the worker's persistent
144
- // append-system-prompt. A warm worker's system prompt is fixed at spawn and
145
- // shared across all turns; corpus content is variable per call. Because the
146
- // corpus rides the turn's user message (composeCallAgentPrompt output) and not
147
- // the worker identity, a generic warm worker can serve a corpus call correctly
148
- // the corpus is scoped to that single turn and never leaks into the next.
149
-
150
- // Per-document and total corpus byte caps so an oversized handoff cannot blow
151
- // the agent's context. Each doc/field is truncated to MAX_CORPUS_DOC_CHARS and
152
- // the assembled section to MAX_CORPUS_TOTAL_CHARS; truncations are logged (not
153
- // silently dropped). Overridable via env for ops tuning.
154
- export const MAX_CORPUS_DOC_CHARS = Number.isFinite(Number(process.env.CLAUTH_AGENT_CORPUS_DOC_CHARS))
155
- ? Number(process.env.CLAUTH_AGENT_CORPUS_DOC_CHARS)
156
- : 24000;
157
- export const MAX_CORPUS_TOTAL_CHARS = Number.isFinite(Number(process.env.CLAUTH_AGENT_CORPUS_TOTAL_CHARS))
158
- ? Number(process.env.CLAUTH_AGENT_CORPUS_TOTAL_CHARS)
159
- : 60000;
160
-
161
- const BOOTSTRAP_DOC_REL_PATH = ".rdc/guides/agent-bootstrap.md";
162
-
163
- /** Truncate a string to `cap` chars, appending a visible marker + logging. */
164
- function capText(text, cap, label) {
165
- if (typeof text !== "string") return "";
166
- if (text.length <= cap) return text;
167
- const truncated = text.slice(0, cap);
168
- console.error(
169
- `[agent-pool] corpus ${label} truncated: ${text.length} ${cap} chars (cap MAX_CORPUS_DOC_CHARS)`
170
- );
171
- return `${truncated}\n…[truncated ${text.length - cap} chars]`;
172
- }
173
-
174
- /**
175
- * Load the project bootstrap guide CONTENT from disk so a "full"-tier agent
176
- * gets the REAL bootstrap inline as a Supporting Doc — not just a "go read this
177
- * file" pointer. Returns a CorpusDocument or null if unreadable (never throws).
178
- * @param {string} [repoRoot]
179
- * @returns {{ path: string, title: string, content: string } | null}
180
- */
181
- export function loadBootstrapDoc(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
182
- const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
183
- const abs = path.join(root, BOOTSTRAP_DOC_REL_PATH);
184
- try {
185
- const content = fs.readFileSync(abs, "utf8");
186
- if (!content || !content.trim()) return null;
187
- return { path: BOOTSTRAP_DOC_REL_PATH, title: "Agent Bootstrap", content };
188
- } catch (e) {
189
- console.error(`[agent-pool] bootstrap doc unreadable (${abs}): ${e.message} — skipping`);
190
- return null;
191
- }
192
- }
193
-
194
- /**
195
- * Assemble the `## Corpus` section EXACTLY as @regen/claude-dispatch does
196
- * (buildPrompt). Returns the full section string beginning with "## Corpus\n",
197
- * or "" when nothing to emit. Order: Product MD, Design MD, Supporting Docs.
198
- *
199
- * Format (byte-for-byte with dispatch):
200
- * ## Corpus
201
- * ### Product MD\n<product>
202
- *
203
- * ### Design MD\n<design>
204
- *
205
- * ### Supporting Docs
206
- * - <path> (<title>)\n```\n<content>\n``` ← per doc; title parenthetical
207
- * omitted if no title; docs
208
- * joined by "\n"
209
- * (corpusSections joined by "\n\n"; section is "## Corpus" + "\n" + body.)
210
- *
211
- * @param {{product?:string, design?:string, docs?:Array<{path:string,title?:string,content:string}>}|null|undefined} corpus
212
- * @returns {string}
213
- */
214
- export function composeCorpus(corpus) {
215
- if (!corpus || typeof corpus !== "object") return "";
216
- const corpusSections = [];
217
- if (typeof corpus.product === "string" && corpus.product) {
218
- corpusSections.push(`### Product MD\n${capText(corpus.product, MAX_CORPUS_DOC_CHARS, "product")}`);
219
- }
220
- if (typeof corpus.design === "string" && corpus.design) {
221
- corpusSections.push(`### Design MD\n${capText(corpus.design, MAX_CORPUS_DOC_CHARS, "design")}`);
222
- }
223
- if (Array.isArray(corpus.docs) && corpus.docs.length > 0) {
224
- const docs = corpus.docs
225
- .filter((d) => d && typeof d.path === "string" && typeof d.content === "string")
226
- .map((doc) => {
227
- const titleLine = doc.title ? ` (${doc.title})` : "";
228
- const content = capText(doc.content, MAX_CORPUS_DOC_CHARS, `doc ${doc.path}`);
229
- return `- ${doc.path}${titleLine}\n\`\`\`\n${content}\n\`\`\``;
230
- })
231
- .join("\n");
232
- if (docs) corpusSections.push(`### Supporting Docs\n${docs}`);
233
- }
234
- if (corpusSections.length === 0) return "";
235
- let section = `## Corpus\n${corpusSections.join("\n\n")}`;
236
- if (section.length > MAX_CORPUS_TOTAL_CHARS) {
237
- console.error(
238
- `[agent-pool] corpus total truncated: ${section.length} → ${MAX_CORPUS_TOTAL_CHARS} chars (cap MAX_CORPUS_TOTAL_CHARS)`
239
- );
240
- section = `${section.slice(0, MAX_CORPUS_TOTAL_CHARS)}\n…[corpus truncated]`;
241
- }
242
- return section;
243
- }
244
-
245
- /**
246
- * Build the effective corpus for a call: the caller's corpus (if any), with the
247
- * bootstrap doc auto-appended as a Supporting Doc when bootstrap === "full" and
248
- * the file is readable. Caller docs come first, then the bootstrap doc, matching
249
- * the merge contract (caller docs + bootstrap doc). Returns the assembled
250
- * `## Corpus` section string ("" when nothing to emit).
251
- *
252
- * @param {object} opts
253
- * @param {string} opts.bootstrap normalized level
254
- * @param {object} [opts.corpus] caller-supplied DispatchCorpus
255
- * @param {string} [opts.repoRoot] repo root for the bootstrap doc
256
- * @param {function} [opts.loadDoc] injectable loader (tests)
257
- * @returns {string}
258
- */
259
- export function buildCorpusSection({ bootstrap, corpus, repoRoot, loadDoc } = {}) {
260
- const caller = corpus && typeof corpus === "object" ? corpus : null;
261
- let effective = caller;
262
- if (bootstrap === "full") {
263
- const load = typeof loadDoc === "function" ? loadDoc : loadBootstrapDoc;
264
- const bootDoc = load(repoRoot);
265
- if (bootDoc) {
266
- const callerDocs = Array.isArray(caller?.docs) ? caller.docs : [];
267
- effective = {
268
- ...(caller || {}),
269
- docs: [...callerDocs, bootDoc],
270
- };
271
- }
272
- }
273
- return composeCorpus(effective);
274
- }
275
-
276
- /**
277
- * Normalize a requested bootstrap level to one of BOOTSTRAP_LEVELS.
278
- * Unknown/absent → DEFAULT_BOOTSTRAP ("identity").
279
- */
280
- export function normalizeBootstrap(level) {
281
- if (typeof level !== "string") return DEFAULT_BOOTSTRAP;
282
- const l = level.trim().toLowerCase();
283
- return BOOTSTRAP_LEVELS.includes(l) ? l : DEFAULT_BOOTSTRAP;
284
- }
285
-
286
- /**
287
- * Compose the call_agent startup for a bootstrap level. Returns the
288
- * append-system-prompt text plus the cwd policy for that tier:
289
- * - none/identity → neutral cwd (caller passes its neutral dir)
290
- * - full → the repo cwd (so the agent can read the bootstrap guide)
291
- *
292
- * @param {string} level "none" | "identity" | "full"
293
- * @param {object} [opts]
294
- * @param {string} [opts.repoRoot] repo root for the "full" tier
295
- * @param {string} [opts.extra] optional caller append_system_prompt,
296
- * concatenated after the tier text
297
- * @returns {{ level:string, appendSystemPrompt:string, useRepoCwd:boolean, repoRoot:string|null }}
298
- */
299
- export function composeBootstrap(level, { repoRoot, extra } = {}) {
300
- const lvl = normalizeBootstrap(level);
301
- let base;
302
- let useRepoCwd = false;
303
- let root = null;
304
- if (lvl === "none") {
305
- base = LEAN_SYSTEM_PROMPT;
306
- } else if (lvl === "full") {
307
- root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
308
- base = buildFullSystemPrompt(root);
309
- useRepoCwd = true;
310
- } else {
311
- base = IDENTITY_SYSTEM_PROMPT; // identity (default)
312
- }
313
- const appendSystemPrompt =
314
- extra && typeof extra === "string" && extra.trim()
315
- ? `${base}\n\n${extra.trim()}`
316
- : base;
317
- return { level: lvl, appendSystemPrompt, useRepoCwd, repoRoot: root };
318
- }
319
-
320
- function resolveClaudeBinary(explicit) {
321
- const candidates = [
322
- explicit,
323
- process.env.CLAUDE_BIN,
324
- path.join(os.homedir(), ".local", "bin", process.platform === "win32" ? "claude.exe" : "claude"),
325
- path.join(process.env.APPDATA || "", "npm", "claude.cmd"),
326
- path.join(process.env.APPDATA || "", "npm", "claude"),
327
- "claude",
328
- ].filter(Boolean);
329
- for (const c of candidates) {
330
- try {
331
- execSync(`"${c}" --version`, { stdio: "ignore", timeout: 5000 });
332
- return c;
333
- } catch {
334
- /* try next */
335
- }
336
- }
337
- return null;
338
- }
339
-
340
- function makeJobId(prefix = "call") {
341
- return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
342
- }
343
-
344
- /**
345
- * Build the persistent stream-json argv for a warm worker. Same lean isolation
346
- * as the cold buildArgs() (model, skip-permissions, user-only settings, strict
347
- * mcp, lean system prompt) but with the persistent stdin/stdout streaming
348
- * transport instead of a one-shot `-p "<prompt>"`.
349
- */
350
- function buildWarmArgs({ model, appendSystemPrompt }) {
351
- return [
352
- "-p",
353
- "--input-format",
354
- "stream-json",
355
- "--output-format",
356
- "stream-json",
357
- "--verbose", // required by the CLI for stream-json output
358
- "--model",
359
- model,
360
- "--dangerously-skip-permissions",
361
- "--setting-sources",
362
- "user",
363
- "--strict-mcp-config",
364
- "--append-system-prompt",
365
- appendSystemPrompt || LEAN_SYSTEM_PROMPT,
366
- ];
367
- }
368
-
369
- /**
370
- * WarmWorker one long-lived `claude` process in stream-json mode that can be
371
- * RE-TARGETED with a new prompt per turn over stdin. This is the true persistent
372
- * re-targetable worker: no per-call CLI boot.
373
- *
374
- * Lifecycle: spawned → ready (process is alive and stdin is connected) → busy
375
- * (a turn is in flight) → idle again (after that turn's `result`). On process
376
- * death it is marked dead; the pool auto-restarts it. One turn at a time per
377
- * worker (the CLI session is single-threaded); the pool routes each call to a
378
- * free idle worker.
379
- *
380
- * READINESS NOTE: the `claude` CLI in `--input-format stream-json` mode emits
381
- * NOTHING (not even `system/init`) until the FIRST stdin user-message arrives —
382
- * `init` is part of the first turn's event stream, not a standalone boot signal.
383
- * So a warm worker is "ready" the moment its process is spawned with a live
384
- * stdin pipe. The first `send()` pays the CLI boot/init cost inside its turn;
385
- * every subsequent `send()` re-targets the already-booted process (pure
386
- * inference latency, no boot). That is the warm win: boot is paid ONCE, then the
387
- * hot process is re-aimed per call.
388
- */
389
- export class WarmWorker {
390
- /**
391
- * @param {object} opts
392
- * @param {string} opts.binary resolved claude binary
393
- * @param {string} opts.model model pinned for this worker
394
- * @param {string} opts.cwd neutral project-free cwd
395
- * @param {string} [opts.appendSystemPrompt]
396
- * @param {function} [opts.spawnImpl] inject a fake spawn for tests
397
- * @param {function} [opts.onExit] called when the process dies
398
- */
399
- constructor(opts = {}) {
400
- this.binary = opts.binary;
401
- this.model = opts.model || DEFAULT_MODEL;
402
- this.cwd = opts.cwd;
403
- this.appendSystemPrompt = opts.appendSystemPrompt;
404
- this._spawn = opts.spawnImpl || spawn;
405
- this.onExit = typeof opts.onExit === "function" ? opts.onExit : () => {};
406
-
407
- this.id = `warm-${crypto.randomBytes(4).toString("hex")}`;
408
- this.proc = null;
409
- this.sessionId = null;
410
- this.state = "spawning"; // spawning | ready | busy | dead
411
- this.spawnedAt = Date.now();
412
- this.turns = 0;
413
-
414
- this._buf = "";
415
- this._current = null; // { resolve, stdout, deadline, jobId, settled }
416
- this._readyResolve = null;
417
- this._readyPromise = new Promise((r) => (this._readyResolve = r));
418
- }
419
-
420
- /** True iff the worker is alive and free to accept a new turn. */
421
- isIdle() {
422
- return this.state === "ready";
423
- }
424
- isBusy() {
425
- return this.state === "busy";
426
- }
427
- isDead() {
428
- return this.state === "dead";
429
- }
430
-
431
- /** Spawn the persistent process. Resolves once the worker is ready (or dead). */
432
- async start() {
433
- let proc;
434
- try {
435
- const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
436
- const args = buildWarmArgs({ model: this.model, appendSystemPrompt: this.appendSystemPrompt });
437
- const command = isCmdShim ? "cmd" : this.binary;
438
- const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...args] : args;
439
- proc = this._spawn(command, spawnArgs, {
440
- cwd: this.cwd,
441
- env: process.env, // inherits CLI login session; never sets ANTHROPIC_API_KEY
442
- stdio: ["pipe", "pipe", "pipe"],
443
- shell: false,
444
- windowsHide: true,
445
- });
446
- } catch (e) {
447
- this.state = "dead";
448
- this.error = `spawn_failed: ${e.message}`;
449
- this._readyResolve?.(false);
450
- return false;
451
- }
452
-
453
- this.proc = proc;
454
- this.pid = proc.pid;
455
-
456
- proc.stdout?.on("data", (d) => this._onStdout(d));
457
- proc.stderr?.on("data", (d) => {
458
- this._stderr = `${this._stderr || ""}${d.toString()}`.slice(-20000);
459
- });
460
- proc.on("error", (e) => this._onDeath(`proc_error: ${e.message}`));
461
- proc.on("close", (code) => this._onDeath(`exit_${code}`));
462
-
463
- // In stream-json mode the CLI is silent until the first stdin message, so a
464
- // freshly-spawned process with a live stdin pipe IS ready. Mark ready on the
465
- // next tick (after spawn errors would have fired synchronously). The first
466
- // send() pays the boot cost inside its turn; later sends are hot re-targets.
467
- await new Promise((r) => setImmediate(r));
468
- if (this.state === "spawning" && this.proc && this.proc.stdin && !this.isDead()) {
469
- this.state = "ready";
470
- this._readyResolve?.(true);
471
- }
472
- return await this._readyPromise;
473
- }
474
-
475
- _onStdout(d) {
476
- this._buf += d.toString();
477
- let idx;
478
- while ((idx = this._buf.indexOf("\n")) >= 0) {
479
- const line = this._buf.slice(0, idx).trim();
480
- this._buf = this._buf.slice(idx + 1);
481
- if (!line) continue;
482
- let msg;
483
- try {
484
- msg = JSON.parse(line);
485
- } catch {
486
- continue; // ignore non-JSON noise
487
- }
488
- this._onEvent(msg);
489
- }
490
- }
491
-
492
- _onEvent(msg) {
493
- if (msg.session_id && !this.sessionId) this.sessionId = msg.session_id;
494
- if (msg.type === "system" && msg.subtype === "init") {
495
- if (this.state === "spawning") {
496
- this.state = "ready";
497
- this._readyResolve?.(true);
498
- }
499
- return;
500
- }
501
- if (msg.type === "result" && this._current) {
502
- const cur = this._current;
503
- if (cur.settled) return;
504
- cur.settled = true;
505
- if (cur.timer) clearTimeout(cur.timer); // C2: cancel internal timeout on normal settle
506
- const text = typeof msg.result === "string" ? msg.result : cur.stdout;
507
- this._current = null;
508
- if (this.state === "busy") this.state = "ready";
509
- cur.resolve({
510
- ok: !msg.is_error,
511
- package: (text || "").trim(),
512
- is_error: !!msg.is_error,
513
- session_id: this.sessionId,
514
- });
515
- }
516
- }
517
-
518
- _onDeath(reason) {
519
- if (this.state === "dead") return;
520
- this.state = "dead";
521
- this.error = reason;
522
- // Fail any in-flight turn so the caller (and concurrency slot) is released.
523
- if (this._current && !this._current.settled) {
524
- const cur = this._current;
525
- cur.settled = true;
526
- if (cur.timer) clearTimeout(cur.timer); // C2: cancel internal timeout on death
527
- this._current = null;
528
- cur.resolve({ ok: false, package: "", error: `worker_died: ${reason}`, dead: true });
529
- }
530
- // Unblock a start() still waiting on readiness.
531
- if (this.state === "dead") this._readyResolve?.(false);
532
- this.onExit(this, reason);
533
- }
534
-
535
- /**
536
- * Re-target this warm worker with a new prompt. One turn at a time.
537
- * Resolves with { ok, package, is_error } when the turn's `result` arrives,
538
- * or { ok:false, ... } if the deadline elapses (caller/pool kills the worker).
539
- * @returns {Promise<object>}
540
- */
541
- send(prompt, { timeout_ms } = {}) {
542
- if (this.state !== "ready") {
543
- return Promise.resolve({ ok: false, error: `worker_not_ready:${this.state}` });
544
- }
545
- return new Promise((resolve) => {
546
- this.state = "busy";
547
- this.turns++;
548
- const ms = Number.isFinite(timeout_ms) ? timeout_ms : DEFAULT_TIMEOUT_MS;
549
- const cur = { resolve, stdout: "", settled: false, startedAt: Date.now() };
550
- cur.deadline = Date.now() + ms;
551
- // C2 — own internal timeout. The pool reaper only covers turns that have a
552
- // job record; the PRIMING turn (ensureWarmWorkers) has none, so a hung
553
- // prime would otherwise leave `send()`'s promise unsettled forever and
554
- // `await Promise.all(primes)` would never resolve. This timer settles the
555
- // turn with a failure AND kills the wedged process independently of the
556
- // reaper, hardening every send() (priming and normal) against a hung CLI.
557
- cur.timer = setTimeout(() => {
558
- if (cur.settled) return;
559
- cur.settled = true;
560
- // Detach so a late `result` event can't double-settle.
561
- if (this._current === cur) this._current = null;
562
- cur.resolve({ ok: false, package: "", error: "warm_send_timeout", timeout: true });
563
- // The session is wedged mid-turn — kill the proc so the worker is
564
- // recycled (onExit → circuit-breaker accounting / auto-restart).
565
- this.kill("send_timeout");
566
- }, ms);
567
- if (cur.timer && cur.timer.unref) cur.timer.unref();
568
- this._current = cur;
569
- const envelope = {
570
- type: "user",
571
- message: { role: "user", content: [{ type: "text", text: prompt }] },
572
- };
573
- try {
574
- this.proc.stdin.write(JSON.stringify(envelope) + "\n");
575
- } catch (e) {
576
- if (cur.timer) clearTimeout(cur.timer);
577
- cur.settled = true;
578
- this._current = null;
579
- this.state = "ready";
580
- resolve({ ok: false, error: `stdin_write_failed: ${e.message}` });
581
- }
582
- });
583
- }
584
-
585
- /** Deadline of the in-flight turn, or null if idle. Used by the pool reaper. */
586
- currentDeadline() {
587
- return this._current && !this._current.settled ? this._current.deadline : null;
588
- }
589
-
590
- kill(reason = "killed") {
591
- try {
592
- this.proc?.stdin?.end();
593
- } catch {
594
- /* ignore */
595
- }
596
- try {
597
- this.proc?.kill("SIGKILL");
598
- } catch {
599
- /* already gone */
600
- }
601
- this._onDeath(reason);
602
- }
603
- }
604
-
605
- /**
606
- * AgentPool — concurrency-limited, warm, model-targeted one-shot dispatcher.
607
- */
608
- export class AgentPool {
609
- /**
610
- * @param {object} opts
611
- * @param {string} [opts.binary] explicit claude binary path
612
- * @param {number} [opts.poolSize] max concurrent + prime count (default 2)
613
- * @param {number} [opts.maxConcurrent] hard concurrency cap (default = poolSize)
614
- * @param {string} [opts.defaultModel] default model alias
615
- * @param {number} [opts.defaultTimeoutMs]
616
- * @param {string} [opts.neutralCwd] project-free working dir for lean runs
617
- * @param {number} [opts.reaperIntervalMs] sweep cadence (default 5000)
618
- * @param {number} [opts.maxJobs] retained job history (default 200)
619
- * @param {boolean} [opts.spawnImpl] inject a fake spawn for tests
620
- */
621
- constructor(opts = {}) {
622
- this.binary = opts.binary || resolveClaudeBinary(opts.binary);
623
- this.poolSize = Number.isFinite(opts.poolSize) ? Math.max(1, opts.poolSize) : DEFAULT_POOL_SIZE;
624
- this.maxConcurrent = Number.isFinite(opts.maxConcurrent)
625
- ? Math.max(1, opts.maxConcurrent)
626
- : this.poolSize;
627
- this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
628
- this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs)
629
- ? opts.defaultTimeoutMs
630
- : DEFAULT_TIMEOUT_MS;
631
- this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-neutral");
632
- this.reaperIntervalMs = Number.isFinite(opts.reaperIntervalMs) ? opts.reaperIntervalMs : 5000;
633
- this.maxJobs = Number.isFinite(opts.maxJobs) ? opts.maxJobs : 200;
634
- this._spawn = opts.spawnImpl || spawn;
635
-
636
- // WP-P2: true persistent re-targetable warm workers via stream-json.
637
- // `warmEnabled` gates the whole warm path; when false the pool is exactly
638
- // the legacy cold dispatcher. `warmSize` long-lived workers are kept hot.
639
- this.warmEnabled = opts.warmEnabled != null ? !!opts.warmEnabled : WARM_ENABLED_DEFAULT;
640
- this.warmSize = Number.isFinite(opts.warmSize) ? Math.max(0, opts.warmSize) : this.poolSize;
641
- // When false, skip the per-worker priming turn (used by tests to isolate
642
- // lifecycle behavior from prime-turn timing). Default: prime on (undefined).
643
- this.warmPrimeTurn = opts.warmPrimeTurn;
644
- // Warm workers serve the DEFAULT bootstrap tier ("identity"): they are
645
- // pre-spawned with the identity startup prompt (who-am-I + how-to-get-access)
646
- // on the neutral cwd. "none" and "full" calls carry a different prompt/cwd and
647
- // are routed to the cold path in _drain(), so the warm prompt is the identity
648
- // tier — not the bare LEAN prompt — to match what a default call expects.
649
- this.appendSystemPrompt = opts.appendSystemPrompt || IDENTITY_SYSTEM_PROMPT;
650
- this._warmWorkers = []; // WarmWorker[]
651
- this._warmStarting = false; // H2: reentrancy guard for ensureWarmWorkers()
652
-
653
- // C1 boot-failure circuit breaker. A deterministic boot failure (bad
654
- // CLAUTH_AGENT_MODEL, broken login/MCP) makes every warm worker die during
655
- // its prime turn and respawn forever. We count CONSECUTIVE immediate deaths
656
- // (lifeMs < IMMEDIATE_DEATH_MS); after `warmCircuitThreshold` of them we trip
657
- // the breaker: disable warm for the daemon's life and fall back to the cold
658
- // path permanently. A worker that lives past the immediate window resets the
659
- // counter (genuine flapping isn't the same as a hard boot failure).
660
- this.warmCircuitThreshold = Number.isFinite(opts.warmCircuitThreshold)
661
- ? Math.max(1, opts.warmCircuitThreshold)
662
- : 5;
663
- this.immediateDeathMs = Number.isFinite(opts.immediateDeathMs)
664
- ? opts.immediateDeathMs
665
- : 2000;
666
- this._consecutiveImmediateDeaths = 0;
667
- this._warmCircuitTripped = false;
668
- this._warmCircuitLogged = false;
669
- // Base unit for the immediate-death respawn backoff (exponential, capped at
670
- // 30s). Overridable so tests can exercise the breaker quickly.
671
- this.warmBackoffBaseMs = Number.isFinite(opts.warmBackoffBaseMs) ? opts.warmBackoffBaseMs : 1000;
672
-
673
- // WP-5: session-level warm worker pinning. A session (e.g. Studio editor)
674
- // can claim a warm worker and reuse it across turns, preserving conversation
675
- // context. Pinned workers are excluded from the general _acquireWarmWorker()
676
- // pool so other callers cannot steal them mid-session.
677
- this._sessionPins = new Map(); // Map<sessionId, WarmWorker>
678
-
679
- this.jobs = new Map(); // jobId -> job record
680
- this.active = 0;
681
- this.queue = []; // pending { resolve, reject, args }
682
- this.warm = false;
683
- this._reaper = null;
684
-
685
- try {
686
- fs.mkdirSync(this.neutralCwd, { recursive: true });
687
- } catch {
688
- /* best effort */
689
- }
690
- }
691
-
692
- available() {
693
- return this.binary != null;
694
- }
695
-
696
- startReaper() {
697
- if (this._reaper) return;
698
- this._reaper = setInterval(() => this.reap(), this.reaperIntervalMs);
699
- // Note: intentionally NOT unref'd. While a job is in flight the reaper is
700
- // the only thing that can settle a hung worker, so it must keep the loop
701
- // alive. The daemon owns lifecycle and calls shutdown()/stopReaper().
702
- }
703
-
704
- stopReaper() {
705
- if (this._reaper) {
706
- clearInterval(this._reaper);
707
- this._reaper = null;
708
- }
709
- }
710
-
711
- /**
712
- * Kill any running job past its deadline, free its slot, drain the queue.
713
- * Also evicts old terminal jobs beyond maxJobs.
714
- */
715
- reap() {
716
- const now = Date.now();
717
- let reaped = 0;
718
- for (const job of this.jobs.values()) {
719
- if (job.status === "running" && job.deadline && now > job.deadline) {
720
- this._killJob(job, "timeout");
721
- reaped++;
722
- }
723
- }
724
- // evict oldest terminal jobs
725
- if (this.jobs.size > this.maxJobs) {
726
- const terminal = [...this.jobs.values()]
727
- .filter((j) => j.status !== "running")
728
- .sort((a, b) => (a.completed_at || 0) - (b.completed_at || 0));
729
- let toRemove = this.jobs.size - this.maxJobs;
730
- for (const j of terminal) {
731
- if (toRemove-- <= 0) break;
732
- this.jobs.delete(j.jobId);
733
- }
734
- }
735
- this._drain();
736
- return reaped;
737
- }
738
-
739
- _killJob(job, reason) {
740
- if (job._proc && job.status === "running") {
741
- try {
742
- job._proc.kill("SIGKILL");
743
- } catch {
744
- /* already gone */
745
- }
746
- }
747
- // Warm job past its deadline: the worker's session is wedged mid-turn. Kill
748
- // the persistent worker (auto-restart re-tops the pool) so the slot frees.
749
- if (job._worker && job.status === "running") {
750
- try {
751
- job._worker.kill(reason === "timeout" ? "turn_timeout" : reason);
752
- } catch {
753
- /* already gone */
754
- }
755
- }
756
- if (job.status === "running") {
757
- job.status = reason === "timeout" ? "timeout" : "killed";
758
- job.completed_at = Date.now();
759
- job.ms = job.completed_at - job.started_at;
760
- job.error = reason;
761
- // NOTE: do NOT decrement this.active here. settle() is the single source
762
- // of truth for the concurrency-slot decrement (it guards with the
763
- // `settled` flag). Decrementing here too double-counted every reaped /
764
- // timed-out / shutdown-killed job, dropping active by 2 and letting
765
- // _drain() launch more than maxConcurrent real workers.
766
- if (job._settle) job._settle();
767
- }
768
- }
769
-
770
- buildArgs({ prompt, model, appendSystemPrompt }) {
771
- return [
772
- "-p",
773
- prompt,
774
- "--model",
775
- model || this.defaultModel,
776
- "--dangerously-skip-permissions",
777
- // Lean isolation: only the user's auth/settings, no project CLAUDE.md/hooks.
778
- "--setting-sources",
779
- "user",
780
- "--strict-mcp-config",
781
- "--append-system-prompt",
782
- appendSystemPrompt || LEAN_SYSTEM_PROMPT,
783
- ];
784
- }
785
-
786
- // ── WP-P2 warm worker management ───────────────────────────────────────────
787
-
788
- /** Count of alive (ready or busy) warm workers. */
789
- _liveWarmCount() {
790
- return this._warmWorkers.filter((w) => !w.isDead()).length;
791
- }
792
-
793
- /**
794
- * First idle warm worker matching `model`, or null. Warm workers are pinned
795
- * to one model; a call requesting a different model must NOT be silently
796
- * served by a mismatched warm worker it falls back to a cold spawn that
797
- * honors the requested model. Workers pinned to a session are excluded so
798
- * general dispatch cannot steal a session's dedicated worker.
799
- */
800
- _acquireWarmWorker(model) {
801
- const pinnedSet = new Set(this._sessionPins.values());
802
- return this._warmWorkers.find((w) =>
803
- w.isIdle() && (!model || w.model === model) && !pinnedSet.has(w)
804
- ) || null;
805
- }
806
-
807
- // ── WP-5: session-level warm worker pinning ─────────────────────────────
808
-
809
- /**
810
- * Acquire (or re-acquire) a warm worker pinned to `sessionId`. Idempotent:
811
- * calling with the same sessionId returns the already-pinned worker. A stale
812
- * pin (dead worker) is transparently replaced. Returns null when no warm
813
- * worker is available (caller should fall back or queue).
814
- *
815
- * @param {string} sessionId
816
- * @param {object} [opts]
817
- * @param {string} [opts.model]
818
- * @returns {WarmWorker|null}
819
- */
820
- acquireForSession(sessionId, opts = {}) {
821
- if (this._sessionPins.has(sessionId)) {
822
- const worker = this._sessionPins.get(sessionId);
823
- if (!worker.isDead()) return worker;
824
- this._sessionPins.delete(sessionId); // stale pin, re-acquire below
825
- }
826
- const model = opts.model || this.defaultModel;
827
- const worker = this._acquireWarmWorker(model);
828
- if (!worker) return null;
829
- this._sessionPins.set(sessionId, worker);
830
- return worker;
831
- }
832
-
833
- /**
834
- * Release a session's pin. The worker returns to the general pool (it is
835
- * just no longer reserved). Returns the formerly-pinned worker, or null.
836
- * @param {string} sessionId
837
- * @returns {WarmWorker|null}
838
- */
839
- releaseSession(sessionId) {
840
- const worker = this._sessionPins.get(sessionId);
841
- this._sessionPins.delete(sessionId);
842
- return worker || null;
843
- }
844
-
845
- /**
846
- * Dispatch a prompt to the worker pinned for `sessionId`. Unlike general
847
- * dispatch, this always targets the pinned worker (preserving conversation
848
- * context). Returns a rejected-shape result if no pin exists or the worker
849
- * is dead. If the worker is busy (prior turn still in flight), waits for it.
850
- *
851
- * @param {string} sessionId
852
- * @param {object} [args] { prompt, timeout_ms, ... }
853
- * @returns {Promise<object>}
854
- */
855
- dispatchToSession(sessionId, args = {}) {
856
- const worker = this._sessionPins.get(sessionId);
857
- if (!worker || worker.isDead()) {
858
- return Promise.resolve({ ok: false, error: "no_pinned_worker", sessionId });
859
- }
860
- const prompt = args.prompt;
861
- if (!prompt || typeof prompt !== "string") {
862
- return Promise.resolve({ ok: false, error: "prompt_required" });
863
- }
864
- return new Promise((resolve, reject) => {
865
- const item = { args, resolve, reject };
866
- const tryRun = () => {
867
- if (worker.isDead()) {
868
- return resolve({ ok: false, error: "pinned_worker_died", sessionId });
869
- }
870
- if (worker.isIdle()) {
871
- this._runWarm(item, worker);
872
- } else {
873
- setTimeout(tryRun, 100);
874
- }
875
- };
876
- tryRun();
877
- });
878
- }
879
-
880
- /**
881
- * Query the pin state for a session. Returns null if no pin exists.
882
- * @param {string} sessionId
883
- * @returns {object|null}
884
- */
885
- getSessionInfo(sessionId) {
886
- const worker = this._sessionPins.get(sessionId);
887
- if (!worker) return null;
888
- return {
889
- sessionId,
890
- pinned: true,
891
- alive: !worker.isDead(),
892
- idle: worker.isIdle(),
893
- model: worker.model,
894
- turns: worker.turns,
895
- };
896
- }
897
-
898
- /** Drop dead workers from the roster. */
899
- _pruneDeadWarm() {
900
- this._warmWorkers = this._warmWorkers.filter((w) => !w.isDead());
901
- }
902
-
903
- /**
904
- * Boot `warmSize` persistent workers (idempotent only tops up to target).
905
- * Auto-restart is wired via each worker's onExit, which respawns a fresh
906
- * worker if the pool still wants warm capacity and isn't shutting down.
907
- */
908
- async ensureWarmWorkers() {
909
- if (!this.warmEnabled || !this.available() || this._shuttingDown) return 0;
910
- if (this._warmCircuitTripped) return 0; // C1: breaker open — warm is permanently off
911
- // H2 — reentrancy guard. Two overlapping ensureWarmWorkers() calls (e.g. a
912
- // boot prime racing a manual top-up) would each independently compute the
913
- // deficit BEFORE the other's workers register, and both spawn — overshooting
914
- // warmSize. Honor `_warmStarting`: a second concurrent entrant returns 0 and
915
- // lets the in-flight call do the provisioning.
916
- if (this._warmStarting) return 0;
917
- this._warmStarting = true;
918
- try {
919
- this._pruneDeadWarm();
920
- const deficit = this.warmSize - this._liveWarmCount();
921
- if (deficit <= 0) return 0;
922
- const starts = [];
923
- for (let i = 0; i < deficit; i++) {
924
- const w = new WarmWorker({
925
- binary: this.binary,
926
- model: this.defaultModel,
927
- cwd: this.neutralCwd,
928
- appendSystemPrompt: this.appendSystemPrompt,
929
- spawnImpl: this._spawn,
930
- onExit: (worker, reason) => this._onWarmWorkerExit(worker, reason),
931
- });
932
- this._warmWorkers.push(w);
933
- starts.push(w.start().catch(() => false));
934
- }
935
- const results = await Promise.all(starts);
936
- this._pruneDeadWarm();
937
- // Fire ONE priming turn per freshly-started worker so the CLI boot/init cost
938
- // is paid during warm-up, not on the user's first real call. After this, the
939
- // worker's session is hot and every dispatch is a pure-inference re-target.
940
- // C2: send() now has its own internal timeout, so a hung prime can no longer
941
- // wedge this Promise.all forever.
942
- if (this.warmPrimeTurn !== false) {
943
- const primes = [];
944
- for (const w of this._warmWorkers) {
945
- if (w.isIdle() && w.turns === 0) {
946
- primes.push(w.send("Reply with exactly: OK", { timeout_ms: 60000 }).catch(() => null));
947
- }
948
- }
949
- await Promise.all(primes);
950
- this._pruneDeadWarm();
951
- }
952
- this.warm = this._warmWorkers.some((w) => !w.isDead());
953
- return results.filter(Boolean).length;
954
- } finally {
955
- this._warmStarting = false;
956
- }
957
- }
958
-
959
- /**
960
- * Auto-restart on death: replace a dead worker if still under target. A short
961
- * backoff prevents a tight respawn loop when a worker dies the instant it
962
- * starts (e.g. a session that crashes immediately) without it, each death
963
- * would synchronously schedule another start that dies and reschedules,
964
- * spinning CPU and spawning processes in a hot loop.
965
- */
966
- _onWarmWorkerExit(worker, reason) {
967
- this._pruneDeadWarm();
968
- this.warm = this._warmWorkers.some((w) => !w.isDead());
969
-
970
- // Once shutting down, a dying worker is expected teardown do not count it
971
- // toward the boot-failure breaker and do not respawn. (Also prevents a
972
- // late-firing backoff respawn's death from tripping the breaker post-shutdown.)
973
- if (this._shuttingDown) return;
974
-
975
- // C1/H1 death classification. Measure how long THIS worker lived. An
976
- // immediate death (lifeMs < immediateDeathMs) is the signature of a hard boot
977
- // failure (bad model, login/MCP error) — the worker died during boot/prime.
978
- // A worker that lived past the window died for a transient reason (a normal
979
- // turn, an OOM, a kill) and must NOT clear protection a different flapping
980
- // worker raised: we only reset the consecutive-immediate-death counter when a
981
- // worker proves the boot path actually works by surviving the window.
982
- const lifeMs = worker && worker.spawnedAt ? Date.now() - worker.spawnedAt : Infinity;
983
- const immediate = lifeMs < this.immediateDeathMs;
984
-
985
- if (immediate) {
986
- this._consecutiveImmediateDeaths++;
987
- } else {
988
- // A healthy lifetime proves boot works → safe to forgive accumulated
989
- // immediate-death backoff for the lineage.
990
- this._consecutiveImmediateDeaths = 0;
991
- }
992
-
993
- // C1 — trip the breaker after N consecutive immediate deaths. Disable warm
994
- // for the daemon's life, log ONCE, and stop respawning. dispatch() already
995
- // falls back to the cold path whenever no warm worker is free, so flipping
996
- // warmEnabled=false makes the cold fallback permanent. This protects the live
997
- // daemon from endless `claude` churn on a misconfigured CLAUTH_AGENT_MODEL.
998
- if (!this._warmCircuitTripped && this._consecutiveImmediateDeaths >= this.warmCircuitThreshold) {
999
- this._warmCircuitTripped = true;
1000
- this.warmEnabled = false;
1001
- if (!this._warmCircuitLogged) {
1002
- this._warmCircuitLogged = true;
1003
- console.error(
1004
- `[agent-pool] WARM CIRCUIT BREAKER TRIPPED: ${this._consecutiveImmediateDeaths} ` +
1005
- `consecutive warm-worker boot failures (last: ${reason || worker?.error || "unknown"}). ` +
1006
- `Disabling warm workers for the rest of this process; call_agent now uses the cold ` +
1007
- `path exclusively. Check CLAUTH_AGENT_MODEL / CLI login / MCP config.`
1008
- );
1009
- }
1010
- return; // do not respawn — breaker is open
1011
- }
1012
-
1013
- if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
1014
- if (this._liveWarmCount() >= this.warmSize) return;
1015
-
1016
- // H1 — backoff keyed to the CONSECUTIVE immediate-death count (lineage
1017
- // protection), not a shared mutable counter that any healthy exit zeroes.
1018
- // Each successive immediate death backs off longer (capped at 30s); a
1019
- // worker that lived past the window respawns immediately.
1020
- const delay = immediate
1021
- ? Math.min(30000, this.warmBackoffBaseMs * Math.pow(2, Math.max(0, this._consecutiveImmediateDeaths - 1)))
1022
- : 0;
1023
-
1024
- const spawnReplacement = () => {
1025
- if (this._warmCircuitTripped) return;
1026
- if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
1027
- if (this._liveWarmCount() >= this.warmSize) return;
1028
- const replacement = new WarmWorker({
1029
- binary: this.binary,
1030
- model: this.defaultModel,
1031
- cwd: this.neutralCwd,
1032
- appendSystemPrompt: this.appendSystemPrompt,
1033
- spawnImpl: this._spawn,
1034
- onExit: (w, r) => this._onWarmWorkerExit(w, r),
1035
- });
1036
- this._warmWorkers.push(replacement);
1037
- replacement
1038
- .start()
1039
- .then(() => {
1040
- this.warm = this._warmWorkers.some((w) => !w.isDead());
1041
- })
1042
- .catch(() => {});
1043
- };
1044
- if (delay > 0) {
1045
- const t = setTimeout(spawnReplacement, delay);
1046
- if (t.unref) t.unref();
1047
- } else {
1048
- spawnReplacement();
1049
- }
1050
- }
1051
-
1052
- /**
1053
- * Pre-warm. With warm workers enabled (default), boots `warmSize` persistent
1054
- * re-targetable stream-json workers. Otherwise (legacy/cold), fires poolSize
1055
- * trivial one-shot pings so the OS file cache / model session is warm.
1056
- * Returns when warming settles (or immediately if unavailable).
1057
- */
1058
- async prime() {
1059
- if (!this.available()) return { warmed: 0, available: false, mode: "unavailable" };
1060
- if (this.warmEnabled) {
1061
- const warmed = await this.ensureWarmWorkers();
1062
- return { warmed, available: true, mode: "warm-workers", workers: this._liveWarmCount() };
1063
- }
1064
- const n = this.poolSize;
1065
- const pings = [];
1066
- for (let i = 0; i < n; i++) {
1067
- pings.push(
1068
- this.dispatch({
1069
- prompt: "Reply with exactly: OK",
1070
- timeout_ms: 30000,
1071
- _priming: true,
1072
- }).catch((e) => ({ ok: false, error: e.message }))
1073
- );
1074
- }
1075
- const results = await Promise.all(pings);
1076
- this.warm = results.some((r) => r && r.ok);
1077
- return { warmed: results.filter((r) => r && r.ok).length, available: true, mode: "cold-prime" };
1078
- }
1079
-
1080
- /**
1081
- * Dispatch a one-shot. Resolves with a job record once the worker exits
1082
- * (sync semantics). Concurrency beyond maxConcurrent is queued.
1083
- *
1084
- * @returns {Promise<object>} job record { ok, jobId, package, model, ms, status, exit_code, error }
1085
- */
1086
- dispatch(args = {}) {
1087
- return new Promise((resolve, reject) => {
1088
- const item = { args, resolve, reject };
1089
- this.queue.push(item);
1090
- this._drain();
1091
- });
1092
- }
1093
-
1094
- _drain() {
1095
- while (this.active < this.maxConcurrent && this.queue.length > 0) {
1096
- const item = this.queue.shift();
1097
- // Priming pings stay on the cold one-shot path (they exist only to warm
1098
- // the cold cache; with warm workers they are a no-op caller). Real calls
1099
- // prefer a free warm worker and fall back to cold when none is free.
1100
- //
1101
- // Bootstrap pinning (tiered startup): warm workers are pre-spawned with the
1102
- // DEFAULT ("identity") startup prompt and the neutral cwd. A call asking for
1103
- // a DIFFERENT bootstrap level — "none" (lean fast path) or "full" (repo cwd
1104
- // + bootstrap guide) — has a different system prompt and/or cwd, so it must
1105
- // NOT be served by a warm worker. Route it to the cold path, which honors
1106
- // args.appendSystemPrompt and args.cwd. Default/absent bootstrap → warm.
1107
- const bootstrapLevel = item.args.bootstrap || DEFAULT_BOOTSTRAP;
1108
- const warmEligible = bootstrapLevel === DEFAULT_BOOTSTRAP;
1109
- if (this.warmEnabled && !item.args._priming && warmEligible) {
1110
- // Only a warm worker pinned to the REQUESTED model may serve the call;
1111
- // a model mismatch must fall through to the cold path (which honors
1112
- // args.model) rather than be silently downgraded to the warm model.
1113
- const reqModel = item.args.model || this.defaultModel;
1114
- const worker = this._acquireWarmWorker(reqModel);
1115
- if (worker) {
1116
- this._runWarm(item, worker);
1117
- continue;
1118
- }
1119
- }
1120
- this._run(item);
1121
- }
1122
- }
1123
-
1124
- /**
1125
- * Run a call on a persistent warm worker (re-targeting it with a new prompt).
1126
- * Produces the SAME public job record shape as the cold path so callers
1127
- * (runCallAgent, durable delivery, GET /call-agent/:jobId) are unchanged.
1128
- */
1129
- _runWarm(item, worker) {
1130
- const { args, resolve } = item;
1131
- const prompt = args.prompt;
1132
- if (!prompt || typeof prompt !== "string") {
1133
- return resolve({ ok: false, error: "prompt_required" });
1134
- }
1135
- const model = worker.model;
1136
- const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1137
- const jobId = args.jobId || makeJobId("call");
1138
-
1139
- const job = {
1140
- jobId,
1141
- status: "running",
1142
- model,
1143
- priming: false,
1144
- warm: true,
1145
- started_at: Date.now(),
1146
- deadline: Date.now() + timeout,
1147
- completed_at: null,
1148
- ms: null,
1149
- exit_code: null,
1150
- stdout: "",
1151
- stderr: "",
1152
- package: null,
1153
- error: null,
1154
- _proc: null,
1155
- _worker: worker,
1156
- _settle: null,
1157
- };
1158
- this.jobs.set(jobId, job);
1159
- this.active++;
1160
-
1161
- let settled = false;
1162
- const settle = () => {
1163
- if (settled) return;
1164
- settled = true;
1165
- this.active = Math.max(0, this.active - 1);
1166
- const rec = {
1167
- ok: job.status === "completed",
1168
- jobId: job.jobId,
1169
- status: job.status,
1170
- model: job.model,
1171
- ms: job.ms,
1172
- exit_code: job.exit_code,
1173
- package: job.package,
1174
- error: job.error,
1175
- warm: true,
1176
- };
1177
- if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
1178
- this._drain();
1179
- resolve(rec);
1180
- };
1181
- job._settle = () => {
1182
- // Reaper path: the turn blew its deadline. Kill the worker (its session is
1183
- // wedged); auto-restart re-tops the pool. Mark the job per its set status.
1184
- if (job.status === "running") {
1185
- job.status = "timeout";
1186
- job.completed_at = Date.now();
1187
- job.ms = job.completed_at - job.started_at;
1188
- job.error = "timeout";
1189
- try {
1190
- worker.kill("turn_timeout");
1191
- } catch {
1192
- /* ignore */
1193
- }
1194
- }
1195
- settle();
1196
- };
1197
-
1198
- worker
1199
- .send(prompt, { timeout_ms: timeout })
1200
- .then((res) => {
1201
- if (settled) return; // reaper already settled this
1202
- job.completed_at = Date.now();
1203
- job.ms = job.completed_at - job.started_at;
1204
- if (res.ok) {
1205
- job.status = "completed";
1206
- job.exit_code = 0;
1207
- job.package = res.package;
1208
- } else {
1209
- job.status = "failed";
1210
- job.error = res.error || (res.is_error ? "agent_error" : "warm_failed");
1211
- if (res.package) job.package = res.package;
1212
- }
1213
- settle();
1214
- })
1215
- .catch((e) => {
1216
- if (settled) return;
1217
- job.status = "failed";
1218
- job.error = `warm_dispatch: ${e.message}`;
1219
- job.completed_at = Date.now();
1220
- job.ms = job.completed_at - job.started_at;
1221
- settle();
1222
- });
1223
- }
1224
-
1225
- _run({ args, resolve, reject }) {
1226
- if (!this.available()) {
1227
- return resolve({
1228
- ok: false,
1229
- error: "binary_not_found",
1230
- message: "claude CLI not found in PATH or AppData/npm",
1231
- });
1232
- }
1233
- const prompt = args.prompt;
1234
- if (!prompt || typeof prompt !== "string") {
1235
- return resolve({ ok: false, error: "prompt_required" });
1236
- }
1237
- const model = args.model || this.defaultModel;
1238
- const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1239
- const jobId = args.jobId || makeJobId(args._priming ? "warm" : "call");
1240
- const cwd = args.cwd || this.neutralCwd;
1241
-
1242
- const job = {
1243
- jobId,
1244
- status: "running",
1245
- model,
1246
- priming: !!args._priming,
1247
- started_at: Date.now(),
1248
- deadline: Date.now() + timeout,
1249
- completed_at: null,
1250
- ms: null,
1251
- exit_code: null,
1252
- stdout: "",
1253
- stderr: "",
1254
- package: null,
1255
- error: null,
1256
- _proc: null,
1257
- _settle: null,
1258
- };
1259
- this.jobs.set(jobId, job);
1260
- this.active++;
1261
-
1262
- let settled = false;
1263
- const settle = () => {
1264
- if (settled) return;
1265
- settled = true;
1266
- this.active = Math.max(0, this.active - 1);
1267
- // Build the public record (no internal handles).
1268
- const rec = {
1269
- ok: job.status === "completed",
1270
- jobId: job.jobId,
1271
- status: job.status,
1272
- model: job.model,
1273
- ms: job.ms,
1274
- exit_code: job.exit_code,
1275
- package: job.package,
1276
- error: job.error,
1277
- };
1278
- if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
1279
- this._drain();
1280
- resolve(rec);
1281
- };
1282
- job._settle = () => settle();
1283
-
1284
- const cmdArgs = this.buildArgs({
1285
- prompt,
1286
- model,
1287
- appendSystemPrompt: args.appendSystemPrompt,
1288
- });
1289
-
1290
- let proc;
1291
- try {
1292
- const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
1293
- const command = isCmdShim ? "cmd" : this.binary;
1294
- const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
1295
- proc = this._spawn(command, spawnArgs, {
1296
- cwd,
1297
- env: process.env, // inherits CLI login session; no ANTHROPIC_API_KEY injected
1298
- stdio: ["ignore", "pipe", "pipe"],
1299
- shell: false,
1300
- windowsHide: true,
1301
- });
1302
- } catch (e) {
1303
- job.status = "failed";
1304
- job.error = `spawn_failed: ${e.message}`;
1305
- job.completed_at = Date.now();
1306
- job.ms = job.completed_at - job.started_at;
1307
- return settle();
1308
- }
1309
-
1310
- job._proc = proc;
1311
- job.pid = proc.pid;
1312
-
1313
- proc.stdout?.on("data", (d) => {
1314
- job.stdout = `${job.stdout}${d.toString()}`.slice(-200000);
1315
- });
1316
- proc.stderr?.on("data", (d) => {
1317
- job.stderr = `${job.stderr}${d.toString()}`.slice(-20000);
1318
- });
1319
- proc.on("error", (e) => {
1320
- if (job.status !== "running") return;
1321
- job.status = "failed";
1322
- job.error = `proc_error: ${e.message}`;
1323
- job.completed_at = Date.now();
1324
- job.ms = job.completed_at - job.started_at;
1325
- settle();
1326
- });
1327
- proc.on("close", (code) => {
1328
- if (job.status !== "running") return; // reaper/kill already settled
1329
- job.exit_code = code;
1330
- job.completed_at = Date.now();
1331
- job.ms = job.completed_at - job.started_at;
1332
- if (code === 0) {
1333
- job.status = "completed";
1334
- job.package = job.stdout.trim();
1335
- } else {
1336
- job.status = "failed";
1337
- job.error = `exit_${code}`;
1338
- }
1339
- settle();
1340
- });
1341
- }
1342
-
1343
- getJob(jobId) {
1344
- const job = this.jobs.get(jobId);
1345
- if (!job) return null;
1346
- return {
1347
- jobId: job.jobId,
1348
- status: job.status,
1349
- model: job.model,
1350
- ms: job.ms,
1351
- exit_code: job.exit_code,
1352
- package: job.package,
1353
- error: job.error,
1354
- started_at: job.started_at,
1355
- completed_at: job.completed_at,
1356
- };
1357
- }
1358
-
1359
- stats() {
1360
- let running = 0,
1361
- completed = 0,
1362
- failed = 0;
1363
- for (const j of this.jobs.values()) {
1364
- if (j.status === "running") running++;
1365
- else if (j.status === "completed") completed++;
1366
- else failed++;
1367
- }
1368
- const warmReady = this._warmWorkers.filter((w) => w.isIdle()).length;
1369
- const warmBusy = this._warmWorkers.filter((w) => w.isBusy()).length;
1370
- return {
1371
- active: this.active,
1372
- queued: this.queue.length,
1373
- maxConcurrent: this.maxConcurrent,
1374
- poolSize: this.poolSize,
1375
- // `warm` reflects REAL persistent workers when warm mode is on (WP-P2):
1376
- // true iff at least one live worker exists. Cold mode keeps legacy meaning.
1377
- warm: this.warmEnabled ? this._liveWarmCount() > 0 : this.warm,
1378
- warmEnabled: this.warmEnabled,
1379
- warmCircuitTripped: this._warmCircuitTripped,
1380
- warmWorkers: {
1381
- target: this.warmSize,
1382
- live: this._liveWarmCount(),
1383
- ready: warmReady,
1384
- busy: warmBusy,
1385
- consecutiveImmediateDeaths: this._consecutiveImmediateDeaths,
1386
- },
1387
- jobs: { running, completed, failed, total: this.jobs.size },
1388
- };
1389
- }
1390
-
1391
- shutdown() {
1392
- this._shuttingDown = true;
1393
- this.stopReaper();
1394
- for (const job of this.jobs.values()) {
1395
- if (job.status === "running") this._killJob(job, "shutdown");
1396
- }
1397
- // Clear session pins before tearing down workers.
1398
- this._sessionPins.clear();
1399
- // Tear down persistent warm workers so no orphan `claude` processes linger.
1400
- for (const w of this._warmWorkers) {
1401
- try {
1402
- w.kill("shutdown");
1403
- } catch {
1404
- /* already gone */
1405
- }
1406
- }
1407
- this._warmWorkers = [];
1408
- }
1409
- }
1410
-
1411
- // ── WP-P7: hop-limited sub-agent delegation ──────────────────────────────────
1412
- //
1413
- // THE DEADLOCK we must make impossible:
1414
- // The warm/interactive pool has only ~2 workers. A PARENT call_agent worker is
1415
- // itself a running `claude` process. If that parent calls call_agent and the
1416
- // sub-call needs a *pool* worker while all pool workers are held by parents
1417
- // blocked on their own sub-calls → classic resource deadlock. So delegated
1418
- // (sub-agent) calls MUST NOT touch the AgentPool. They run on this separate
1419
- // lane, which:
1420
- // 1. NEVER references an AgentPool, warm worker, or its queue. It only
1421
- // cold-spawns its own `claude` one-shots, capped by `maxInFlight`. The
1422
- // interactive pool's latency/availability is therefore unaffected by
1423
- // delegation depth or fan-out.
1424
- // 2. Enforces a strict HOP BUDGET (carried in agent_context.delegation):
1425
- // a call at hop >= max is REJECTED with a clean error and NO spawn, so a
1426
- // runaway delegation loop is impossible and tree depth is bounded.
1427
- // 3. Enforces a daemon-wide in-flight cap so a wide fan-out can't exhaust
1428
- // resources.
1429
- //
1430
- // Hop accounting: the delegation context on an INCOMING call describes the call
1431
- // itself — `{ hop: N, max: M }` means "this is a hop-N delegated call, budget M".
1432
- // A top-level (interactive) call has no delegation context (hop is treated as 0).
1433
- // When a worker at hop N spawns a sub-agent, the daemon computes the child's
1434
- // context as `{ hop: N+1, max: M }` and rejects if `N+1 > M`. Equivalently, an
1435
- // incoming delegated call is admitted iff `hop <= max`; hop is the depth of the
1436
- // call being served, and a call at `hop > max` (or `hop == max` asking to go
1437
- // deeper) is refused.
1438
-
1439
- /**
1440
- * Normalize / validate a delegation marker from agent_context.delegation.
1441
- * Returns { hop, max } with safe integer coercion, or null if absent/invalid.
1442
- * A present-but-malformed marker normalizes to hop 0 with the default max so a
1443
- * caller cannot smuggle a negative hop or a giant max past the budget.
1444
- */
1445
- export function normalizeDelegation(input, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
1446
- if (input == null) return null;
1447
- if (typeof input !== "object") return null;
1448
- let hop = Number(input.hop);
1449
- let max = Number(input.max);
1450
- if (!Number.isFinite(hop) || hop < 0) hop = 0;
1451
- hop = Math.floor(hop);
1452
- if (!Number.isFinite(max) || max < 0) max = defaultMax;
1453
- max = Math.floor(max);
1454
- return { hop, max };
1455
- }
1456
-
1457
- /**
1458
- * Decide whether an incoming DELEGATED call may proceed, and compute the child
1459
- * delegation context the spawned sub-agent should carry if IT delegates further.
1460
- *
1461
- * @param {object|null} delegation normalized { hop, max } of the incoming call
1462
- * @param {object} [opts]
1463
- * @param {number} [opts.defaultMax]
1464
- * @returns {{ allow: boolean, hop: number, max: number, childContext?: object, error?: string }}
1465
- */
1466
- export function evaluateHopBudget(delegation, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
1467
- const d = normalizeDelegation(delegation, { defaultMax }) || { hop: 0, max: defaultMax };
1468
- // The call being served is at depth `hop`. It is admissible iff it does not
1469
- // exceed the budget. `hop > max` means a parent tried to spawn one level too
1470
- // deep refuse with a clean error, no spawn.
1471
- if (d.hop > d.max) {
1472
- return {
1473
- allow: false,
1474
- hop: d.hop,
1475
- max: d.max,
1476
- error: "delegation_hop_exceeded",
1477
- };
1478
- }
1479
- // Child context for any sub-agent THIS call spawns: one hop deeper, same max.
1480
- return {
1481
- allow: true,
1482
- hop: d.hop,
1483
- max: d.max,
1484
- childContext: { hop: d.hop + 1, max: d.max },
1485
- };
1486
- }
1487
-
1488
- /**
1489
- * Single source of truth for the routing decision: should THIS call_agent
1490
- * invocation go through the delegation lane instead of the interactive pool?
1491
- *
1492
- * The gate (`enabled`, from CLAUTH_AGENT_DELEGATION) is checked FIRST: when the
1493
- * gate is OFF this returns false unconditionally and `agent_context.delegation`
1494
- * is never even read — that is the byte-stable-off guarantee. A call is delegated
1495
- * iff the gate is on AND the caller supplied an `agent_context.delegation`
1496
- * marker (only a sub-agent invocation carries one).
1497
- *
1498
- * @param {object} args
1499
- * @param {boolean} args.enabled CLAUTH_AGENT_DELEGATION on?
1500
- * @param {object} [args.agent_context]
1501
- * @returns {boolean}
1502
- */
1503
- export function shouldDelegate({ enabled, agent_context } = {}) {
1504
- if (!enabled) return false; // gate OFF → delegation marker ignored entirely
1505
- if (!agent_context || typeof agent_context !== "object") return false;
1506
- return agent_context.delegation != null;
1507
- }
1508
-
1509
- /**
1510
- * DelegationLane — a cold-spawn-only, concurrency-capped execution lane for
1511
- * DELEGATED (sub-agent) call_agent invocations. Structurally distinct from
1512
- * AgentPool: it holds no warm workers, owns no AgentPool reference, and shares
1513
- * no queue/slot with the interactive pool. That separation is the anti-deadlock
1514
- * guarantee — sub-agent spawns can never starve or hold an interactive slot.
1515
- *
1516
- * It deliberately reuses the SAME lean one-shot recipe (model, skip-permissions,
1517
- * user-only settings, strict-mcp, lean system prompt) as AgentPool's cold path,
1518
- * but on an independent set of processes with its own `maxInFlight` cap.
1519
- */
1520
- export class DelegationLane {
1521
- /**
1522
- * @param {object} opts
1523
- * @param {string} [opts.binary] resolved claude binary
1524
- * @param {number} [opts.maxInFlight] daemon-wide concurrent sub-agent cap
1525
- * @param {number} [opts.maxHops] hop-budget default ceiling
1526
- * @param {string} [opts.defaultModel]
1527
- * @param {number} [opts.defaultTimeoutMs]
1528
- * @param {string} [opts.neutralCwd]
1529
- * @param {string} [opts.appendSystemPrompt]
1530
- * @param {function} [opts.spawnImpl] inject a fake spawn for tests
1531
- */
1532
- constructor(opts = {}) {
1533
- this.binary = opts.binary || resolveClaudeBinary(opts.binary);
1534
- this.maxInFlight = Number.isFinite(opts.maxInFlight)
1535
- ? Math.max(1, opts.maxInFlight)
1536
- : DEFAULT_DELEGATION_MAX_INFLIGHT;
1537
- this.maxHops = Number.isFinite(opts.maxHops) ? Math.max(0, opts.maxHops) : DEFAULT_DELEGATION_MAX_HOPS;
1538
- this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
1539
- this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs) ? opts.defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
1540
- this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-delegation");
1541
- this.appendSystemPrompt = opts.appendSystemPrompt || LEAN_SYSTEM_PROMPT;
1542
- this._spawn = opts.spawnImpl || spawn;
1543
-
1544
- this.active = 0; // live sub-agent cold spawns
1545
- this.totalSpawned = 0;
1546
- this.totalRejected = 0;
1547
- this._shuttingDown = false;
1548
- this._procs = new Set(); // live child procs for shutdown teardown
1549
-
1550
- try {
1551
- fs.mkdirSync(this.neutralCwd, { recursive: true });
1552
- } catch {
1553
- /* best effort */
1554
- }
1555
- }
1556
-
1557
- available() {
1558
- return this.binary != null;
1559
- }
1560
-
1561
- buildArgs({ prompt, model, appendSystemPrompt }) {
1562
- return [
1563
- "-p",
1564
- prompt,
1565
- "--model",
1566
- model || this.defaultModel,
1567
- "--dangerously-skip-permissions",
1568
- "--setting-sources",
1569
- "user",
1570
- "--strict-mcp-config",
1571
- "--append-system-prompt",
1572
- appendSystemPrompt || this.appendSystemPrompt,
1573
- ];
1574
- }
1575
-
1576
- /**
1577
- * Dispatch a DELEGATED sub-agent call. Enforces the hop budget and the
1578
- * in-flight cap, then cold-spawns a one-shot. Returns a job record shaped like
1579
- * AgentPool.dispatch's so callers are uniform. On rejection it resolves with
1580
- * `{ ok:false, error, delegated:true, ... }` and NEVER spawns.
1581
- *
1582
- * @param {object} args { prompt, model, timeout_ms, delegation:{hop,max}, jobId, appendSystemPrompt }
1583
- * @returns {Promise<object>}
1584
- */
1585
- dispatch(args = {}) {
1586
- return new Promise((resolve) => {
1587
- if (!this.available()) {
1588
- return resolve({ ok: false, error: "binary_not_found", delegated: true });
1589
- }
1590
- const prompt = args.prompt;
1591
- if (!prompt || typeof prompt !== "string") {
1592
- return resolve({ ok: false, error: "prompt_required", delegated: true });
1593
- }
1594
- // Hop budget the single most important guard. A call past budget is
1595
- // refused cleanly with NO spawn, so a delegation loop cannot run away.
1596
- const budget = evaluateHopBudget(args.delegation, { defaultMax: this.maxHops });
1597
- if (!budget.allow) {
1598
- this.totalRejected++;
1599
- return resolve({
1600
- ok: false,
1601
- error: budget.error,
1602
- delegated: true,
1603
- hop: budget.hop,
1604
- max: budget.max,
1605
- message: `delegation hop ${budget.hop} exceeds max ${budget.max}`,
1606
- });
1607
- }
1608
- // Daemon-wide in-flight cap — a wide fan-out cannot exhaust resources.
1609
- if (this.active >= this.maxInFlight) {
1610
- this.totalRejected++;
1611
- return resolve({
1612
- ok: false,
1613
- error: "delegation_capacity",
1614
- delegated: true,
1615
- active: this.active,
1616
- maxInFlight: this.maxInFlight,
1617
- message: `delegation lane full (${this.active}/${this.maxInFlight})`,
1618
- });
1619
- }
1620
- if (this._shuttingDown) {
1621
- return resolve({ ok: false, error: "delegation_shutting_down", delegated: true });
1622
- }
1623
-
1624
- const model = args.model || this.defaultModel;
1625
- const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1626
- const jobId = args.jobId || makeJobId("deleg");
1627
- const cwd = args.cwd || this.neutralCwd;
1628
- const startedAt = Date.now();
1629
-
1630
- const cmdArgs = this.buildArgs({ prompt, model, appendSystemPrompt: args.appendSystemPrompt });
1631
-
1632
- let proc;
1633
- try {
1634
- const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
1635
- const command = isCmdShim ? "cmd" : this.binary;
1636
- const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
1637
- proc = this._spawn(command, spawnArgs, {
1638
- cwd,
1639
- env: process.env, // inherits CLI login; never sets ANTHROPIC_API_KEY
1640
- stdio: ["ignore", "pipe", "pipe"],
1641
- shell: false,
1642
- windowsHide: true,
1643
- });
1644
- } catch (e) {
1645
- return resolve({
1646
- ok: false,
1647
- error: `spawn_failed: ${e.message}`,
1648
- delegated: true,
1649
- jobId,
1650
- });
1651
- }
1652
-
1653
- this.active++;
1654
- this.totalSpawned++;
1655
- this._procs.add(proc);
1656
-
1657
- let stdout = "";
1658
- let stderr = "";
1659
- let settled = false;
1660
- // NOTE: intentionally NOT unref'd. While a sub-agent is in flight this
1661
- // timer is the only thing that can settle a hung one-shot, so it must keep
1662
- // the loop alive until it fires (it clears on normal close). The daemon
1663
- // owns process lifecycle; shutdown() kills all live procs explicitly.
1664
- const timer = setTimeout(() => {
1665
- if (settled) return;
1666
- try {
1667
- proc.kill("SIGKILL");
1668
- } catch {
1669
- /* already gone */
1670
- }
1671
- finish({ status: "timeout", error: "timeout" });
1672
- }, timeout);
1673
-
1674
- const finish = (extra) => {
1675
- if (settled) return;
1676
- settled = true;
1677
- clearTimeout(timer);
1678
- this.active = Math.max(0, this.active - 1);
1679
- this._procs.delete(proc);
1680
- const ms = Date.now() - startedAt;
1681
- const status = extra.status;
1682
- resolve({
1683
- ok: status === "completed",
1684
- jobId,
1685
- status,
1686
- model,
1687
- ms,
1688
- exit_code: extra.exit_code ?? null,
1689
- package: status === "completed" ? stdout.trim() : null,
1690
- error: extra.error || null,
1691
- delegated: true,
1692
- hop: budget.childContext.hop, // depth of this spawned sub-agent
1693
- max: budget.max,
1694
- ...(stderr && status !== "completed" ? { stderr: stderr.slice(-2000) } : {}),
1695
- });
1696
- };
1697
-
1698
- proc.stdout?.on("data", (d) => {
1699
- stdout = `${stdout}${d.toString()}`.slice(-200000);
1700
- });
1701
- proc.stderr?.on("data", (d) => {
1702
- stderr = `${stderr}${d.toString()}`.slice(-20000);
1703
- });
1704
- proc.on("error", (e) => finish({ status: "failed", error: `proc_error: ${e.message}` }));
1705
- proc.on("close", (code) => {
1706
- if (code === 0) finish({ status: "completed", exit_code: 0 });
1707
- else finish({ status: "failed", exit_code: code, error: `exit_${code}` });
1708
- });
1709
- });
1710
- }
1711
-
1712
- stats() {
1713
- return {
1714
- active: this.active,
1715
- maxInFlight: this.maxInFlight,
1716
- maxHops: this.maxHops,
1717
- totalSpawned: this.totalSpawned,
1718
- totalRejected: this.totalRejected,
1719
- };
1720
- }
1721
-
1722
- shutdown() {
1723
- this._shuttingDown = true;
1724
- for (const proc of this._procs) {
1725
- try {
1726
- proc.kill("SIGKILL");
1727
- } catch {
1728
- /* already gone */
1729
- }
1730
- }
1731
- this._procs.clear();
1732
- }
1733
- }
1734
-
1735
- export {
1736
- LEAN_SYSTEM_PROMPT,
1737
- IDENTITY_SYSTEM_PROMPT,
1738
- resolveClaudeBinary,
1739
- buildWarmArgs,
1740
- buildFullSystemPrompt,
1741
- makeJobId,
1742
- };
1743
-
1744
- // composeCorpus / buildCorpusSection / loadBootstrapDoc are exported inline
1745
- // above (function declarations with `export`).
1
+ // agent-pool.js — warm Haiku worker pool for call_agent one-shot dispatch.
2
+ //
3
+ // Design (Gate B, WP-B1/B3/B4) — cold path:
4
+ // * Each `claude -p "<prompt>"` is inherently a fresh detached process — the
5
+ // prompt is passed as argv, so an idle pre-spawned process cannot be
6
+ // re-targeted. The cold "warm pool" therefore does two things:
7
+ // 1. Resolves + caches the claude binary once (no per-call `--version`
8
+ // probe) and keeps a neutral, project-free cwd hot on disk.
9
+ // 2. Optionally fires `poolSize` priming pings at startup so the OS file
10
+ // cache / model session is warm — the FIRST real call then returns in
11
+ // ~4-5s instead of the ~50-90s cold path.
12
+ //
13
+ // WP-P2 — TRUE persistent re-targetable warm workers:
14
+ // * The `claude` CLI (v2.1.170) supports `-p --input-format stream-json
15
+ // --output-format stream-json`: a single long-lived process that reads
16
+ // newline-delimited user-message envelopes from stdin and emits
17
+ // newline-delimited events (including a `result` per turn) on stdout. The
18
+ // SAME process (same session_id) answers MULTIPLE sequential prompts —
19
+ // proven: 3 prompts on one process, sid stable, 4.4s → 2.0s → 2.8s with NO
20
+ // per-call CLI boot. This is true re-targeting: a pre-spawned idle worker IS
21
+ // re-aimable at a new prompt over stdin.
22
+ // * `WarmWorker` wraps one such process. `AgentPool.dispatch()` prefers a free
23
+ // warm worker (sub-boot dispatch — latency is pure model inference, not the
24
+ // ~10-15s CLI cold-boot) and transparently FALLS BACK to the cold spawn path
25
+ // when warm workers are disabled, all busy, or unavailable. Fully backward
26
+ // compatible: the public dispatch()/job-record contract is unchanged.
27
+ // * Warm workers are health-checked and AUTO-RESTARTED on death; the reaper
28
+ // enforces per-turn deadlines on warm turns too. Model-tiered: each pool is
29
+ // pinned to one model (default Haiku); a Sonnet/Opus pool is a second
30
+ // AgentPool instance.
31
+ //
32
+ // * Lean one-shot: neutral cwd + `--setting-sources user` + `--strict-mcp-config`
33
+ // strips the regen-root CLAUDE.md / SessionEnd hooks / lessons gate that
34
+ // otherwise contaminates replies ("Waived." instead of the real answer).
35
+ // * Auth: piggybacks the CLI's own login session. NEVER sets ANTHROPIC_API_KEY.
36
+ // * Reaping: a periodic sweep kills workers that exceed their timeout and
37
+ // frees concurrency slots so the queue self-heals.
38
+ //
39
+ // This module is intentionally self-contained and daemon-free so its core can
40
+ // be unit/integration tested WITHOUT the clauth vault or boot.key.
41
+
42
+ import { spawn } from "child_process";
43
+ import { execSync } from "child_process";
44
+ import os from "os";
45
+ import path from "path";
46
+ import fs from "fs";
47
+ import crypto from "crypto";
48
+
49
+ export const DEFAULT_MODEL = "claude-haiku-4-5";
50
+ export const DEFAULT_TIMEOUT_MS = 120000;
51
+ export const DEFAULT_POOL_SIZE = 2;
52
+ // Warm workers are ON by default. Set CLAUTH_AGENT_WARM=0 to force the cold path.
53
+ export const WARM_ENABLED_DEFAULT =
54
+ String(process.env.CLAUTH_AGENT_WARM ?? "1") !== "0";
55
+
56
+ // ── WP-P7: hop-limited sub-agent delegation defaults ─────────────────────────
57
+ // A parent call_agent worker may itself invoke call_agent SUB-agents, bounded by
58
+ // a hop budget and routed through a SEPARATE execution lane (DelegationLane) so
59
+ // it can NEVER consume the shared warm/interactive pool slots (anti-deadlock).
60
+ export const DEFAULT_DELEGATION_MAX_HOPS = 2; // tree depth cap (hop 0 = top call)
61
+ export const DEFAULT_DELEGATION_MAX_INFLIGHT = 4; // daemon-wide concurrent sub-agents
62
+
63
+ const LEAN_SYSTEM_PROMPT =
64
+ "You are a stateless one-shot agent worker invoked via clauth call_agent. " +
65
+ "Answer the user's prompt directly, concisely, and completely. " +
66
+ "Do not editorialize, do not read project files unless the prompt asks, " +
67
+ "and do not emit meta-commentary about hooks, sessions, or governance. " +
68
+ "If a JSON answer is requested, return ONLY valid JSON.";
69
+
70
+ // ── Tiered call_agent startup (bootstrap) ────────────────────────────────────
71
+ // A spawned worker otherwise has NO identity and NO idea what it can reach (no
72
+ // clauth creds, no Supabase, no repo). These three tiers fix that, additively:
73
+ //
74
+ // * "none" — today's lean fast path, byte-identical to LEAN_SYSTEM_PROMPT.
75
+ // For trivial one-shots; warm ~2s. Neutral, project-free cwd.
76
+ // * "identity" — DEFAULT. Prepends a SHORT startup that tells the agent who it
77
+ // is and HOW to reach access (clauth daemon, Supabase ref,
78
+ // work_items RPCs, Bash/file tools). Neutral cwd. Low latency.
79
+ // * "full" — for real repo/DB work: runs in the REPO cwd and points the
80
+ // agent at .rdc/guides/agent-bootstrap.md (creds, git rules,
81
+ // completion report) plus the identity/access block.
82
+ //
83
+ // IMPORTANT: the startup TELLS the agent how to fetch credentials; it NEVER
84
+ // embeds a credential and NEVER sets ANTHROPIC_API_KEY.
85
+ export const BOOTSTRAP_LEVELS = ["none", "identity", "full"];
86
+ export const DEFAULT_BOOTSTRAP = "identity";
87
+ export const DEFAULT_AGENT_REPO_ROOT =
88
+ process.env.CLAUTH_AGENT_REPO_ROOT || "C:/Dev/regen-root";
89
+
90
+ // The clauth credential daemon every Regen agent uses.
91
+ const CLAUTH_DAEMON_URL = "http://127.0.0.1:52437";
92
+ const SUPABASE_PROJECT_REF = "uvojezuorjgqzmhhgluu";
93
+
94
+ // Short identity/access block — a dozen lines so latency stays low. This is the
95
+ // minimum every default agent must know: who it is + how to get access.
96
+ const IDENTITY_SYSTEM_PROMPT =
97
+ "You are a Regen agent running on the operator's machine. " +
98
+ "ACCESS — discover service names through the clauth HTTP daemon at " +
99
+ `${CLAUTH_DAEMON_URL}: run \`curl -s ${CLAUTH_DAEMON_URL}/knowledge\` for ` +
100
+ "sanitized metadata, then fetch exactly one needed credential with " +
101
+ `\`curl -s ${CLAUTH_DAEMON_URL}/v/<service>\`. ` +
102
+ "Do not bulk-fetch secrets. NEVER print a credential to stdout — it is logged. " +
103
+ `The Supabase project ref is ${SUPABASE_PROJECT_REF}. ` +
104
+ "Work items live in Supabase and are accessed ONLY through RPCs " +
105
+ "(get_open_epics, insert_work_item, update_work_item_status) — never raw " +
106
+ "SQL CRUD against work_items. You have Bash plus file read/write tools. " +
107
+ "Answer the user's prompt directly and completely; if JSON is requested, " +
108
+ "return ONLY valid JSON.";
109
+
110
+ const EDITOR_SETTINGS_FILENAME = ".studio-editor-settings.json";
111
+
112
+ /**
113
+ * Editable-prompts settings file — every Claude prompt this pool builds has a
114
+ * default baked into code; this loads the SAME-SHAPED override from
115
+ * `<repoRoot>/.studio-editor-settings.json` if present. Read fresh on every
116
+ * call (no caching) so an edit to the file takes effect on the next dispatch
117
+ * without a daemon restart. Never throws — a missing or malformed settings
118
+ * file must fall back to defaults silently, not break prompt construction.
119
+ * @param {string} repoRoot
120
+ * @returns {object}
121
+ */
122
+ function loadEditorSettings(repoRoot) {
123
+ if (!repoRoot) return {};
124
+ try {
125
+ const settingsPath = path.join(repoRoot, EDITOR_SETTINGS_FILENAME);
126
+ if (!fs.existsSync(settingsPath)) return {};
127
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
128
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
129
+ } catch (e) {
130
+ console.error(`[agent-pool] ${EDITOR_SETTINGS_FILENAME} malformed, using code defaults: ${e.message}`);
131
+ return {};
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Build the "full" startup: identity/access block + a pointer to the project
137
+ * bootstrap guide that the agent must read first. Runs in the repo cwd.
138
+ *
139
+ * Editable: set `systemPrompt` in `<repoRoot>/.studio-editor-settings.json` to
140
+ * override this entire string. `{repoRoot}` and `{bootstrapPath}` tokens are
141
+ * substituted if present.
142
+ * @param {string} [repoRoot]
143
+ */
144
+ function buildFullSystemPrompt(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
145
+ const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
146
+ const bootstrapPath = `${root}/${BOOTSTRAP_DOC_REL_PATH}`;
147
+ const settings = loadEditorSettings(root);
148
+ if (typeof settings.systemPrompt === "string" && settings.systemPrompt.trim()) {
149
+ return settings.systemPrompt
150
+ .replace(/\{repoRoot\}/g, root)
151
+ .replace(/\{bootstrapPath\}/g, bootstrapPath);
152
+ }
153
+ return (
154
+ IDENTITY_SYSTEM_PROMPT +
155
+ " You are running in the repo at " +
156
+ root +
157
+ ". Before doing repo or database work, READ " +
158
+ bootstrapPath +
159
+ " FIRST — it has the full credential, " +
160
+ "Supabase, git (commit to develop, never main, never force-push), and " +
161
+ "completion-report contract. Then read your role guide under " +
162
+ root +
163
+ "/.rdc/guides/ if one applies."
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Lean system prompt for a Studio editor session's dedicated worker. Deliberately
169
+ * does NOT include buildFullSystemPrompt()'s clauth-credential / Supabase-RPC
170
+ * block — a studio-editor session is scoped to plain source-file edits, not
171
+ * general repo-or-database agent work, so that access-instruction boilerplate
172
+ * is pure noise for this task (it doesn't need to fetch a credential or touch
173
+ * Supabase to change a JSX string). Still editable via the SAME
174
+ * `.studio-editor-settings.json` `systemPrompt` override buildFullSystemPrompt()
175
+ * reads set one there if a given repo's edits genuinely need DB/credential
176
+ * access.
177
+ * @param {string} [repoRoot]
178
+ */
179
+ function buildStudioEditorSystemPrompt(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
180
+ const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
181
+ const settings = loadEditorSettings(root);
182
+ if (typeof settings.systemPrompt === "string" && settings.systemPrompt.trim()) {
183
+ return settings.systemPrompt
184
+ .replace(/\{repoRoot\}/g, root)
185
+ .replace(/\{bootstrapPath\}/g, `${root}/${BOOTSTRAP_DOC_REL_PATH}`);
186
+ }
187
+ return (
188
+ "You are a Regen Studio editor agent running on the operator's machine, in the repo at " +
189
+ root +
190
+ ". Your job is to make precise, minimal source-code edits per the instructions in " +
191
+ "each turn's prompt. You have Bash plus file read/write tools. Do not fetch " +
192
+ "credentials or touch Supabase/work_items — plain source edits never need either. " +
193
+ "After editing, output the requested JSON result exactly as specified."
194
+ );
195
+ }
196
+
197
+ // ── Corpus handoff (Regen Agent corpus, matching @regen/claude-dispatch) ─────
198
+ // Every Regen route agent dispatched through @regen/claude-dispatch gets a
199
+ // `## Corpus` section assembled into its prompt body from the dispatch
200
+ // envelope's `corpus` field. call_agent previously gave only a thin identity
201
+ // system prompt and NO corpus. composeCorpus() reproduces the EXACT dispatch
202
+ // format (packages/claude-dispatch/src/channels/cli.ts buildPrompt) so a
203
+ // call_agent worker starts up with the same handoff a route agent gets.
204
+ //
205
+ // Shape (DispatchCorpus): { product?: string, design?: string, docs?: CorpusDocument[] }
206
+ // CorpusDocument: { path: string, title?: string, content: string }
207
+ //
208
+ // CRITICAL warm-reuse correctness: the corpus is assembled into the per-turn
209
+ // PROMPT (the stdin/`-p` user message), NOT the worker's persistent
210
+ // append-system-prompt. A warm worker's system prompt is fixed at spawn and
211
+ // shared across all turns; corpus content is variable per call. Because the
212
+ // corpus rides the turn's user message (composeCallAgentPrompt output) and not
213
+ // the worker identity, a generic warm worker can serve a corpus call correctly
214
+ // the corpus is scoped to that single turn and never leaks into the next.
215
+
216
+ // Per-document and total corpus byte caps so an oversized handoff cannot blow
217
+ // the agent's context. Each doc/field is truncated to MAX_CORPUS_DOC_CHARS and
218
+ // the assembled section to MAX_CORPUS_TOTAL_CHARS; truncations are logged (not
219
+ // silently dropped). Overridable via env for ops tuning.
220
+ export const MAX_CORPUS_DOC_CHARS = Number.isFinite(Number(process.env.CLAUTH_AGENT_CORPUS_DOC_CHARS))
221
+ ? Number(process.env.CLAUTH_AGENT_CORPUS_DOC_CHARS)
222
+ : 24000;
223
+ export const MAX_CORPUS_TOTAL_CHARS = Number.isFinite(Number(process.env.CLAUTH_AGENT_CORPUS_TOTAL_CHARS))
224
+ ? Number(process.env.CLAUTH_AGENT_CORPUS_TOTAL_CHARS)
225
+ : 60000;
226
+
227
+ const BOOTSTRAP_DOC_REL_PATH = ".rdc/guides/agent-bootstrap.md";
228
+
229
+ /** Truncate a string to `cap` chars, appending a visible marker + logging. */
230
+ function capText(text, cap, label) {
231
+ if (typeof text !== "string") return "";
232
+ if (text.length <= cap) return text;
233
+ const truncated = text.slice(0, cap);
234
+ console.error(
235
+ `[agent-pool] corpus ${label} truncated: ${text.length} → ${cap} chars (cap MAX_CORPUS_DOC_CHARS)`
236
+ );
237
+ return `${truncated}\n…[truncated ${text.length - cap} chars]`;
238
+ }
239
+
240
+ /**
241
+ * Load the project bootstrap guide CONTENT from disk so a "full"-tier agent
242
+ * gets the REAL bootstrap inline as a Supporting Doc — not just a "go read this
243
+ * file" pointer. Returns a CorpusDocument or null if unreadable (never throws).
244
+ * @param {string} [repoRoot]
245
+ * @returns {{ path: string, title: string, content: string } | null}
246
+ */
247
+ export function loadBootstrapDoc(repoRoot = DEFAULT_AGENT_REPO_ROOT) {
248
+ const root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
249
+ const abs = path.join(root, BOOTSTRAP_DOC_REL_PATH);
250
+ try {
251
+ const content = fs.readFileSync(abs, "utf8");
252
+ if (!content || !content.trim()) return null;
253
+ return { path: BOOTSTRAP_DOC_REL_PATH, title: "Agent Bootstrap", content };
254
+ } catch (e) {
255
+ console.error(`[agent-pool] bootstrap doc unreadable (${abs}): ${e.message} skipping`);
256
+ return null;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Assemble the `## Corpus` section EXACTLY as @regen/claude-dispatch does
262
+ * (buildPrompt). Returns the full section string beginning with "## Corpus\n",
263
+ * or "" when nothing to emit. Order: Product MD, Design MD, Supporting Docs.
264
+ *
265
+ * Format (byte-for-byte with dispatch):
266
+ * ## Corpus
267
+ * ### Product MD\n<product>
268
+ *
269
+ * ### Design MD\n<design>
270
+ *
271
+ * ### Supporting Docs
272
+ * - <path> (<title>)\n```\n<content>\n``` ← per doc; title parenthetical
273
+ * omitted if no title; docs
274
+ * joined by "\n"
275
+ * (corpusSections joined by "\n\n"; section is "## Corpus" + "\n" + body.)
276
+ *
277
+ * @param {{product?:string, design?:string, docs?:Array<{path:string,title?:string,content:string}>}|null|undefined} corpus
278
+ * @returns {string}
279
+ */
280
+ export function composeCorpus(corpus) {
281
+ if (!corpus || typeof corpus !== "object") return "";
282
+ const corpusSections = [];
283
+ if (typeof corpus.product === "string" && corpus.product) {
284
+ corpusSections.push(`### Product MD\n${capText(corpus.product, MAX_CORPUS_DOC_CHARS, "product")}`);
285
+ }
286
+ if (typeof corpus.design === "string" && corpus.design) {
287
+ corpusSections.push(`### Design MD\n${capText(corpus.design, MAX_CORPUS_DOC_CHARS, "design")}`);
288
+ }
289
+ if (Array.isArray(corpus.docs) && corpus.docs.length > 0) {
290
+ const docs = corpus.docs
291
+ .filter((d) => d && typeof d.path === "string" && typeof d.content === "string")
292
+ .map((doc) => {
293
+ const titleLine = doc.title ? ` (${doc.title})` : "";
294
+ const content = capText(doc.content, MAX_CORPUS_DOC_CHARS, `doc ${doc.path}`);
295
+ return `- ${doc.path}${titleLine}\n\`\`\`\n${content}\n\`\`\``;
296
+ })
297
+ .join("\n");
298
+ if (docs) corpusSections.push(`### Supporting Docs\n${docs}`);
299
+ }
300
+ if (corpusSections.length === 0) return "";
301
+ let section = `## Corpus\n${corpusSections.join("\n\n")}`;
302
+ if (section.length > MAX_CORPUS_TOTAL_CHARS) {
303
+ console.error(
304
+ `[agent-pool] corpus total truncated: ${section.length} → ${MAX_CORPUS_TOTAL_CHARS} chars (cap MAX_CORPUS_TOTAL_CHARS)`
305
+ );
306
+ section = `${section.slice(0, MAX_CORPUS_TOTAL_CHARS)}\n…[corpus truncated]`;
307
+ }
308
+ return section;
309
+ }
310
+
311
+ /**
312
+ * Build the effective corpus for a call: the caller's corpus (if any), with the
313
+ * bootstrap doc auto-appended as a Supporting Doc when bootstrap === "full" and
314
+ * the file is readable. Caller docs come first, then the bootstrap doc, matching
315
+ * the merge contract (caller docs + bootstrap doc). Returns the assembled
316
+ * `## Corpus` section string ("" when nothing to emit).
317
+ *
318
+ * @param {object} opts
319
+ * @param {string} opts.bootstrap normalized level
320
+ * @param {object} [opts.corpus] caller-supplied DispatchCorpus
321
+ * @param {string} [opts.repoRoot] repo root for the bootstrap doc
322
+ * @param {function} [opts.loadDoc] injectable loader (tests)
323
+ * @returns {string}
324
+ */
325
+ export function buildCorpusSection({ bootstrap, corpus, repoRoot, loadDoc } = {}) {
326
+ const caller = corpus && typeof corpus === "object" ? corpus : null;
327
+ let effective = caller;
328
+ if (bootstrap === "full") {
329
+ const load = typeof loadDoc === "function" ? loadDoc : loadBootstrapDoc;
330
+ const bootDoc = load(repoRoot);
331
+ if (bootDoc) {
332
+ const callerDocs = Array.isArray(caller?.docs) ? caller.docs : [];
333
+ effective = {
334
+ ...(caller || {}),
335
+ docs: [...callerDocs, bootDoc],
336
+ };
337
+ }
338
+ }
339
+ return composeCorpus(effective);
340
+ }
341
+
342
+ /**
343
+ * Normalize a requested bootstrap level to one of BOOTSTRAP_LEVELS.
344
+ * Unknown/absent → DEFAULT_BOOTSTRAP ("identity").
345
+ */
346
+ export function normalizeBootstrap(level) {
347
+ if (typeof level !== "string") return DEFAULT_BOOTSTRAP;
348
+ const l = level.trim().toLowerCase();
349
+ return BOOTSTRAP_LEVELS.includes(l) ? l : DEFAULT_BOOTSTRAP;
350
+ }
351
+
352
+ /**
353
+ * Compose the call_agent startup for a bootstrap level. Returns the
354
+ * append-system-prompt text plus the cwd policy for that tier:
355
+ * - none/identity → neutral cwd (caller passes its neutral dir)
356
+ * - full → the repo cwd (so the agent can read the bootstrap guide)
357
+ *
358
+ * @param {string} level "none" | "identity" | "full"
359
+ * @param {object} [opts]
360
+ * @param {string} [opts.repoRoot] repo root for the "full" tier
361
+ * @param {string} [opts.extra] optional caller append_system_prompt,
362
+ * concatenated after the tier text
363
+ * @returns {{ level:string, appendSystemPrompt:string, useRepoCwd:boolean, repoRoot:string|null }}
364
+ */
365
+ export function composeBootstrap(level, { repoRoot, extra } = {}) {
366
+ const lvl = normalizeBootstrap(level);
367
+ let base;
368
+ let useRepoCwd = false;
369
+ let root = null;
370
+ if (lvl === "none") {
371
+ base = LEAN_SYSTEM_PROMPT;
372
+ } else if (lvl === "full") {
373
+ root = (repoRoot || DEFAULT_AGENT_REPO_ROOT).replace(/\\/g, "/");
374
+ base = buildFullSystemPrompt(root);
375
+ useRepoCwd = true;
376
+ } else {
377
+ base = IDENTITY_SYSTEM_PROMPT; // identity (default)
378
+ }
379
+ const appendSystemPrompt =
380
+ extra && typeof extra === "string" && extra.trim()
381
+ ? `${base}\n\n${extra.trim()}`
382
+ : base;
383
+ return { level: lvl, appendSystemPrompt, useRepoCwd, repoRoot: root };
384
+ }
385
+
386
+ function resolveClaudeBinary(explicit) {
387
+ const candidates = [
388
+ explicit,
389
+ process.env.CLAUDE_BIN,
390
+ path.join(os.homedir(), ".local", "bin", process.platform === "win32" ? "claude.exe" : "claude"),
391
+ path.join(process.env.APPDATA || "", "npm", "claude.cmd"),
392
+ path.join(process.env.APPDATA || "", "npm", "claude"),
393
+ "claude",
394
+ ].filter(Boolean);
395
+ for (const c of candidates) {
396
+ try {
397
+ execSync(`"${c}" --version`, { stdio: "ignore", timeout: 5000 });
398
+ return c;
399
+ } catch {
400
+ /* try next */
401
+ }
402
+ }
403
+ return null;
404
+ }
405
+
406
+ function makeJobId(prefix = "call") {
407
+ return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
408
+ }
409
+
410
+ /**
411
+ * Show or hide a process's console window by PID (Windows only). Uses the same
412
+ * user32.dll ShowWindowAsync/SetForegroundWindow P/Invoke pattern already used by
413
+ * clauth's terminal_show tool (serve.js showTerminalSession) — this is that same
414
+ * capability, applied to AgentPool-dispatched job windows, which previously had no
415
+ * window at all (spawned with windowsHide: true / CREATE_NO_WINDOW). Async and
416
+ * non-blocking (spawn, not spawnSync) so it never stalls the daemon's event loop.
417
+ */
418
+ function toggleWindowForPid(pid, show) {
419
+ return new Promise((resolve) => {
420
+ if (process.platform !== "win32") {
421
+ resolve({ shown: false, reason: "unsupported_platform" });
422
+ return;
423
+ }
424
+ const nCmdShow = show ? 5 /* SW_SHOW */ : 0 /* SW_HIDE */;
425
+ const script = [
426
+ "Add-Type @'",
427
+ "using System;",
428
+ "using System.Runtime.InteropServices;",
429
+ "public class ClauthAgentWindow {",
430
+ " [DllImport(\"user32.dll\")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);",
431
+ " [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);",
432
+ "}",
433
+ "'@",
434
+ `$p = Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue`,
435
+ "if ($p -and $p.MainWindowHandle -ne 0) {",
436
+ ` [ClauthAgentWindow]::ShowWindowAsync($p.MainWindowHandle, ${nCmdShow}) | Out-Null`,
437
+ show ? " [ClauthAgentWindow]::SetForegroundWindow($p.MainWindowHandle) | Out-Null" : "",
438
+ " 'ok'",
439
+ "} else {",
440
+ " 'no-window'",
441
+ "}",
442
+ ].filter(Boolean).join("\n");
443
+ let proc;
444
+ try {
445
+ proc = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], {
446
+ stdio: ["ignore", "pipe", "pipe"],
447
+ windowsHide: true,
448
+ });
449
+ } catch (e) {
450
+ resolve({ shown: false, reason: `spawn_failed: ${e.message}` });
451
+ return;
452
+ }
453
+ let stdout = "";
454
+ proc.stdout?.on("data", (d) => { stdout += d.toString(); });
455
+ proc.on("error", () => resolve({ shown: false, reason: "toggle_process_error" }));
456
+ proc.on("close", () => {
457
+ resolve(stdout.includes("ok") ? { shown: show, reason: "ok" } : { shown: false, reason: "no_window" });
458
+ });
459
+ });
460
+ }
461
+
462
+ /**
463
+ * Build the persistent stream-json argv for a warm worker. Same lean isolation
464
+ * as the cold buildArgs() (model, skip-permissions, user-only settings, strict
465
+ * mcp, lean system prompt) but with the persistent stdin/stdout streaming
466
+ * transport instead of a one-shot `-p "<prompt>"`.
467
+ */
468
+ function buildWarmArgs({ model, appendSystemPrompt }) {
469
+ return [
470
+ "-p",
471
+ "--input-format",
472
+ "stream-json",
473
+ "--output-format",
474
+ "stream-json",
475
+ "--verbose", // required by the CLI for stream-json output
476
+ "--model",
477
+ model,
478
+ "--dangerously-skip-permissions",
479
+ "--setting-sources",
480
+ "user",
481
+ "--strict-mcp-config",
482
+ "--append-system-prompt",
483
+ appendSystemPrompt || LEAN_SYSTEM_PROMPT,
484
+ ];
485
+ }
486
+
487
+ /**
488
+ * WarmWorker — one long-lived `claude` process in stream-json mode that can be
489
+ * RE-TARGETED with a new prompt per turn over stdin. This is the true persistent
490
+ * re-targetable worker: no per-call CLI boot.
491
+ *
492
+ * Lifecycle: spawned → ready (process is alive and stdin is connected) → busy
493
+ * (a turn is in flight) → idle again (after that turn's `result`). On process
494
+ * death it is marked dead; the pool auto-restarts it. One turn at a time per
495
+ * worker (the CLI session is single-threaded); the pool routes each call to a
496
+ * free idle worker.
497
+ *
498
+ * READINESS NOTE: the `claude` CLI in `--input-format stream-json` mode emits
499
+ * NOTHING (not even `system/init`) until the FIRST stdin user-message arrives —
500
+ * `init` is part of the first turn's event stream, not a standalone boot signal.
501
+ * So a warm worker is "ready" the moment its process is spawned with a live
502
+ * stdin pipe. The first `send()` pays the CLI boot/init cost inside its turn;
503
+ * every subsequent `send()` re-targets the already-booted process (pure
504
+ * inference latency, no boot). That is the warm win: boot is paid ONCE, then the
505
+ * hot process is re-aimed per call.
506
+ */
507
+ export class WarmWorker {
508
+ /**
509
+ * @param {object} opts
510
+ * @param {string} opts.binary resolved claude binary
511
+ * @param {string} opts.model model pinned for this worker
512
+ * @param {string} opts.cwd neutral project-free cwd
513
+ * @param {string} [opts.appendSystemPrompt]
514
+ * @param {function} [opts.spawnImpl] inject a fake spawn for tests
515
+ * @param {function} [opts.onExit] called when the process dies
516
+ * @param {boolean} [opts.allowVisibleWindow] spawn with a real (immediately-hidden)
517
+ * console window instead of CREATE_NO_WINDOW, so showJob() can later bring it to
518
+ * the foreground. Only set for session-pinned dedicated workers (see
519
+ * AgentPool.acquireForSession's cwd branch) -- the general shared warm pool never
520
+ * needs this and shouldn't pay the extra spawn overhead for every boot.
521
+ */
522
+ constructor(opts = {}) {
523
+ this.binary = opts.binary;
524
+ this.model = opts.model || DEFAULT_MODEL;
525
+ this.cwd = opts.cwd;
526
+ this.appendSystemPrompt = opts.appendSystemPrompt;
527
+ this._spawn = opts.spawnImpl || spawn;
528
+ this.onExit = typeof opts.onExit === "function" ? opts.onExit : () => {};
529
+ this.allowVisibleWindow = !!opts.allowVisibleWindow;
530
+
531
+ this.id = `warm-${crypto.randomBytes(4).toString("hex")}`;
532
+ this.proc = null;
533
+ this.sessionId = null;
534
+ this.state = "spawning"; // spawning | ready | busy | dead
535
+ this.spawnedAt = Date.now();
536
+ this.turns = 0;
537
+
538
+ this._buf = "";
539
+ this._current = null; // { resolve, stdout, deadline, jobId, settled }
540
+ this._readyResolve = null;
541
+ this._readyPromise = new Promise((r) => (this._readyResolve = r));
542
+ }
543
+
544
+ /** True iff the worker is alive and free to accept a new turn. */
545
+ isIdle() {
546
+ return this.state === "ready";
547
+ }
548
+ isBusy() {
549
+ return this.state === "busy";
550
+ }
551
+ isDead() {
552
+ return this.state === "dead";
553
+ }
554
+
555
+ /** Spawn the persistent process. Resolves once the worker is ready (or dead). */
556
+ async start() {
557
+ let proc;
558
+ try {
559
+ const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
560
+ const args = buildWarmArgs({ model: this.model, appendSystemPrompt: this.appendSystemPrompt });
561
+ const command = isCmdShim ? "cmd" : this.binary;
562
+ const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...args] : args;
563
+ proc = this._spawn(command, spawnArgs, {
564
+ cwd: this.cwd,
565
+ env: process.env, // inherits CLI login session; never sets ANTHROPIC_API_KEY
566
+ stdio: ["pipe", "pipe", "pipe"],
567
+ shell: false,
568
+ windowsHide: !this.allowVisibleWindow,
569
+ });
570
+ } catch (e) {
571
+ this.state = "dead";
572
+ this.error = `spawn_failed: ${e.message}`;
573
+ this._readyResolve?.(false);
574
+ return false;
575
+ }
576
+
577
+ this.proc = proc;
578
+ this.pid = proc.pid;
579
+ if (this.allowVisibleWindow && process.platform === "win32" && proc.pid) {
580
+ toggleWindowForPid(proc.pid, false).catch(() => {});
581
+ }
582
+
583
+ proc.stdout?.on("data", (d) => this._onStdout(d));
584
+ proc.stderr?.on("data", (d) => {
585
+ this._stderr = `${this._stderr || ""}${d.toString()}`.slice(-20000);
586
+ });
587
+ proc.on("error", (e) => this._onDeath(`proc_error: ${e.message}`));
588
+ proc.on("close", (code) => this._onDeath(`exit_${code}`));
589
+
590
+ // In stream-json mode the CLI is silent until the first stdin message, so a
591
+ // freshly-spawned process with a live stdin pipe IS ready. Mark ready on the
592
+ // next tick (after spawn errors would have fired synchronously). The first
593
+ // send() pays the boot cost inside its turn; later sends are hot re-targets.
594
+ await new Promise((r) => setImmediate(r));
595
+ if (this.state === "spawning" && this.proc && this.proc.stdin && !this.isDead()) {
596
+ this.state = "ready";
597
+ this._readyResolve?.(true);
598
+ }
599
+ return await this._readyPromise;
600
+ }
601
+
602
+ _onStdout(d) {
603
+ this._buf += d.toString();
604
+ let idx;
605
+ while ((idx = this._buf.indexOf("\n")) >= 0) {
606
+ const line = this._buf.slice(0, idx).trim();
607
+ this._buf = this._buf.slice(idx + 1);
608
+ if (!line) continue;
609
+ let msg;
610
+ try {
611
+ msg = JSON.parse(line);
612
+ } catch {
613
+ continue; // ignore non-JSON noise
614
+ }
615
+ this._onEvent(msg);
616
+ }
617
+ }
618
+
619
+ _onEvent(msg) {
620
+ if (msg.session_id && !this.sessionId) this.sessionId = msg.session_id;
621
+ if (msg.type === "system" && msg.subtype === "init") {
622
+ if (this.state === "spawning") {
623
+ this.state = "ready";
624
+ this._readyResolve?.(true);
625
+ }
626
+ return;
627
+ }
628
+ if (msg.type === "result" && this._current) {
629
+ const cur = this._current;
630
+ if (cur.settled) return;
631
+ cur.settled = true;
632
+ if (cur.timer) clearTimeout(cur.timer); // C2: cancel internal timeout on normal settle
633
+ const text = typeof msg.result === "string" ? msg.result : cur.stdout;
634
+ this._current = null;
635
+ if (this.state === "busy") this.state = "ready";
636
+ cur.resolve({
637
+ ok: !msg.is_error,
638
+ package: (text || "").trim(),
639
+ is_error: !!msg.is_error,
640
+ session_id: this.sessionId,
641
+ });
642
+ }
643
+ }
644
+
645
+ _onDeath(reason) {
646
+ if (this.state === "dead") return;
647
+ this.state = "dead";
648
+ this.error = reason;
649
+ // Fail any in-flight turn so the caller (and concurrency slot) is released.
650
+ if (this._current && !this._current.settled) {
651
+ const cur = this._current;
652
+ cur.settled = true;
653
+ if (cur.timer) clearTimeout(cur.timer); // C2: cancel internal timeout on death
654
+ this._current = null;
655
+ cur.resolve({ ok: false, package: "", error: `worker_died: ${reason}`, dead: true });
656
+ }
657
+ // Unblock a start() still waiting on readiness.
658
+ if (this.state === "dead") this._readyResolve?.(false);
659
+ this.onExit(this, reason);
660
+ }
661
+
662
+ /**
663
+ * Re-target this warm worker with a new prompt. One turn at a time.
664
+ * Resolves with { ok, package, is_error } when the turn's `result` arrives,
665
+ * or { ok:false, ... } if the deadline elapses (caller/pool kills the worker).
666
+ * @returns {Promise<object>}
667
+ */
668
+ send(prompt, { timeout_ms } = {}) {
669
+ if (this.state !== "ready") {
670
+ return Promise.resolve({ ok: false, error: `worker_not_ready:${this.state}` });
671
+ }
672
+ return new Promise((resolve) => {
673
+ this.state = "busy";
674
+ this.turns++;
675
+ const ms = Number.isFinite(timeout_ms) ? timeout_ms : DEFAULT_TIMEOUT_MS;
676
+ const cur = { resolve, stdout: "", settled: false, startedAt: Date.now() };
677
+ cur.deadline = Date.now() + ms;
678
+ // C2 — own internal timeout. The pool reaper only covers turns that have a
679
+ // job record; the PRIMING turn (ensureWarmWorkers) has none, so a hung
680
+ // prime would otherwise leave `send()`'s promise unsettled forever and
681
+ // `await Promise.all(primes)` would never resolve. This timer settles the
682
+ // turn with a failure AND kills the wedged process independently of the
683
+ // reaper, hardening every send() (priming and normal) against a hung CLI.
684
+ cur.timer = setTimeout(() => {
685
+ if (cur.settled) return;
686
+ cur.settled = true;
687
+ // Detach so a late `result` event can't double-settle.
688
+ if (this._current === cur) this._current = null;
689
+ cur.resolve({ ok: false, package: "", error: "warm_send_timeout", timeout: true });
690
+ // The session is wedged mid-turn — kill the proc so the worker is
691
+ // recycled (onExit → circuit-breaker accounting / auto-restart).
692
+ this.kill("send_timeout");
693
+ }, ms);
694
+ if (cur.timer && cur.timer.unref) cur.timer.unref();
695
+ this._current = cur;
696
+ const envelope = {
697
+ type: "user",
698
+ message: { role: "user", content: [{ type: "text", text: prompt }] },
699
+ };
700
+ try {
701
+ this.proc.stdin.write(JSON.stringify(envelope) + "\n");
702
+ } catch (e) {
703
+ if (cur.timer) clearTimeout(cur.timer);
704
+ cur.settled = true;
705
+ this._current = null;
706
+ this.state = "ready";
707
+ resolve({ ok: false, error: `stdin_write_failed: ${e.message}` });
708
+ }
709
+ });
710
+ }
711
+
712
+ /** Deadline of the in-flight turn, or null if idle. Used by the pool reaper. */
713
+ currentDeadline() {
714
+ return this._current && !this._current.settled ? this._current.deadline : null;
715
+ }
716
+
717
+ kill(reason = "killed") {
718
+ try {
719
+ this.proc?.stdin?.end();
720
+ } catch {
721
+ /* ignore */
722
+ }
723
+ try {
724
+ this.proc?.kill("SIGKILL");
725
+ } catch {
726
+ /* already gone */
727
+ }
728
+ this._onDeath(reason);
729
+ }
730
+ }
731
+
732
+ /**
733
+ * AgentPool — concurrency-limited, warm, model-targeted one-shot dispatcher.
734
+ */
735
+ export class AgentPool {
736
+ /**
737
+ * @param {object} opts
738
+ * @param {string} [opts.binary] explicit claude binary path
739
+ * @param {number} [opts.poolSize] max concurrent + prime count (default 2)
740
+ * @param {number} [opts.maxConcurrent] hard concurrency cap (default = poolSize)
741
+ * @param {string} [opts.defaultModel] default model alias
742
+ * @param {number} [opts.defaultTimeoutMs]
743
+ * @param {string} [opts.neutralCwd] project-free working dir for lean runs
744
+ * @param {number} [opts.reaperIntervalMs] sweep cadence (default 5000)
745
+ * @param {number} [opts.maxJobs] retained job history (default 200)
746
+ * @param {boolean} [opts.spawnImpl] inject a fake spawn for tests
747
+ */
748
+ constructor(opts = {}) {
749
+ this.binary = opts.binary || resolveClaudeBinary(opts.binary);
750
+ this.poolSize = Number.isFinite(opts.poolSize) ? Math.max(1, opts.poolSize) : DEFAULT_POOL_SIZE;
751
+ this.maxConcurrent = Number.isFinite(opts.maxConcurrent)
752
+ ? Math.max(1, opts.maxConcurrent)
753
+ : this.poolSize;
754
+ this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
755
+ this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs)
756
+ ? opts.defaultTimeoutMs
757
+ : DEFAULT_TIMEOUT_MS;
758
+ this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-neutral");
759
+ this.reaperIntervalMs = Number.isFinite(opts.reaperIntervalMs) ? opts.reaperIntervalMs : 5000;
760
+ this.maxJobs = Number.isFinite(opts.maxJobs) ? opts.maxJobs : 200;
761
+ this._spawn = opts.spawnImpl || spawn;
762
+
763
+ // WP-P2: true persistent re-targetable warm workers via stream-json.
764
+ // `warmEnabled` gates the whole warm path; when false the pool is exactly
765
+ // the legacy cold dispatcher. `warmSize` long-lived workers are kept hot.
766
+ this.warmEnabled = opts.warmEnabled != null ? !!opts.warmEnabled : WARM_ENABLED_DEFAULT;
767
+ this.warmSize = Number.isFinite(opts.warmSize) ? Math.max(0, opts.warmSize) : this.poolSize;
768
+ // When false, skip the per-worker priming turn (used by tests to isolate
769
+ // lifecycle behavior from prime-turn timing). Default: prime on (undefined).
770
+ this.warmPrimeTurn = opts.warmPrimeTurn;
771
+ // Warm workers serve the DEFAULT bootstrap tier ("identity"): they are
772
+ // pre-spawned with the identity startup prompt (who-am-I + how-to-get-access)
773
+ // on the neutral cwd. "none" and "full" calls carry a different prompt/cwd and
774
+ // are routed to the cold path in _drain(), so the warm prompt is the identity
775
+ // tier — not the bare LEAN prompt — to match what a default call expects.
776
+ this.appendSystemPrompt = opts.appendSystemPrompt || IDENTITY_SYSTEM_PROMPT;
777
+ this._warmWorkers = []; // WarmWorker[]
778
+ this._warmStarting = false; // H2: reentrancy guard for ensureWarmWorkers()
779
+
780
+ // C1 — boot-failure circuit breaker. A deterministic boot failure (bad
781
+ // CLAUTH_AGENT_MODEL, broken login/MCP) makes every warm worker die during
782
+ // its prime turn and respawn forever. We count CONSECUTIVE immediate deaths
783
+ // (lifeMs < IMMEDIATE_DEATH_MS); after `warmCircuitThreshold` of them we trip
784
+ // the breaker: disable warm for the daemon's life and fall back to the cold
785
+ // path permanently. A worker that lives past the immediate window resets the
786
+ // counter (genuine flapping isn't the same as a hard boot failure).
787
+ this.warmCircuitThreshold = Number.isFinite(opts.warmCircuitThreshold)
788
+ ? Math.max(1, opts.warmCircuitThreshold)
789
+ : 5;
790
+ this.immediateDeathMs = Number.isFinite(opts.immediateDeathMs)
791
+ ? opts.immediateDeathMs
792
+ : 2000;
793
+ this._consecutiveImmediateDeaths = 0;
794
+ this._warmCircuitTripped = false;
795
+ this._warmCircuitLogged = false;
796
+ // Base unit for the immediate-death respawn backoff (exponential, capped at
797
+ // 30s). Overridable so tests can exercise the breaker quickly.
798
+ this.warmBackoffBaseMs = Number.isFinite(opts.warmBackoffBaseMs) ? opts.warmBackoffBaseMs : 1000;
799
+
800
+ // WP-5: session-level warm worker pinning. A session (e.g. Studio editor)
801
+ // can claim a warm worker and reuse it across turns, preserving conversation
802
+ // context. Pinned workers are excluded from the general _acquireWarmWorker()
803
+ // pool so other callers cannot steal them mid-session.
804
+ this._sessionPins = new Map(); // Map<sessionId, WarmWorker>
805
+
806
+ this.jobs = new Map(); // jobId -> job record
807
+ this.active = 0;
808
+ this.queue = []; // pending { resolve, reject, args }
809
+ this.warm = false;
810
+ this._reaper = null;
811
+
812
+ try {
813
+ fs.mkdirSync(this.neutralCwd, { recursive: true });
814
+ } catch {
815
+ /* best effort */
816
+ }
817
+ }
818
+
819
+ available() {
820
+ return this.binary != null;
821
+ }
822
+
823
+ startReaper() {
824
+ if (this._reaper) return;
825
+ this._reaper = setInterval(() => this.reap(), this.reaperIntervalMs);
826
+ // Note: intentionally NOT unref'd. While a job is in flight the reaper is
827
+ // the only thing that can settle a hung worker, so it must keep the loop
828
+ // alive. The daemon owns lifecycle and calls shutdown()/stopReaper().
829
+ }
830
+
831
+ stopReaper() {
832
+ if (this._reaper) {
833
+ clearInterval(this._reaper);
834
+ this._reaper = null;
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Kill any running job past its deadline, free its slot, drain the queue.
840
+ * Also evicts old terminal jobs beyond maxJobs.
841
+ */
842
+ reap() {
843
+ const now = Date.now();
844
+ let reaped = 0;
845
+ for (const job of this.jobs.values()) {
846
+ if (job.status === "running" && job.deadline && now > job.deadline) {
847
+ this._killJob(job, "timeout");
848
+ reaped++;
849
+ }
850
+ }
851
+ // evict oldest terminal jobs
852
+ if (this.jobs.size > this.maxJobs) {
853
+ const terminal = [...this.jobs.values()]
854
+ .filter((j) => j.status !== "running")
855
+ .sort((a, b) => (a.completed_at || 0) - (b.completed_at || 0));
856
+ let toRemove = this.jobs.size - this.maxJobs;
857
+ for (const j of terminal) {
858
+ if (toRemove-- <= 0) break;
859
+ this.jobs.delete(j.jobId);
860
+ }
861
+ }
862
+ this._drain();
863
+ return reaped;
864
+ }
865
+
866
+ _killJob(job, reason) {
867
+ if (job._proc && job.status === "running") {
868
+ try {
869
+ job._proc.kill("SIGKILL");
870
+ } catch {
871
+ /* already gone */
872
+ }
873
+ }
874
+ // Warm job past its deadline: the worker's session is wedged mid-turn. Kill
875
+ // the persistent worker (auto-restart re-tops the pool) so the slot frees.
876
+ if (job._worker && job.status === "running") {
877
+ try {
878
+ job._worker.kill(reason === "timeout" ? "turn_timeout" : reason);
879
+ } catch {
880
+ /* already gone */
881
+ }
882
+ }
883
+ if (job.status === "running") {
884
+ job.status = reason === "timeout" ? "timeout" : "killed";
885
+ job.completed_at = Date.now();
886
+ job.ms = job.completed_at - job.started_at;
887
+ job.error = reason;
888
+ // NOTE: do NOT decrement this.active here. settle() is the single source
889
+ // of truth for the concurrency-slot decrement (it guards with the
890
+ // `settled` flag). Decrementing here too double-counted every reaped /
891
+ // timed-out / shutdown-killed job, dropping active by 2 and letting
892
+ // _drain() launch more than maxConcurrent real workers.
893
+ if (job._settle) job._settle();
894
+ }
895
+ }
896
+
897
+ buildArgs({ prompt, model, appendSystemPrompt }) {
898
+ return [
899
+ "-p",
900
+ prompt,
901
+ "--model",
902
+ model || this.defaultModel,
903
+ "--dangerously-skip-permissions",
904
+ // Lean isolation: only the user's auth/settings, no project CLAUDE.md/hooks.
905
+ "--setting-sources",
906
+ "user",
907
+ "--strict-mcp-config",
908
+ "--append-system-prompt",
909
+ appendSystemPrompt || LEAN_SYSTEM_PROMPT,
910
+ ];
911
+ }
912
+
913
+ // ── WP-P2 warm worker management ───────────────────────────────────────────
914
+
915
+ /** Count of alive (ready or busy) warm workers. */
916
+ _liveWarmCount() {
917
+ return this._warmWorkers.filter((w) => !w.isDead()).length;
918
+ }
919
+
920
+ /**
921
+ * First idle warm worker matching `model`, or null. Warm workers are pinned
922
+ * to one model; a call requesting a different model must NOT be silently
923
+ * served by a mismatched warm worker it falls back to a cold spawn that
924
+ * honors the requested model. Workers pinned to a session are excluded so
925
+ * general dispatch cannot steal a session's dedicated worker.
926
+ */
927
+ _acquireWarmWorker(model) {
928
+ const pinnedSet = new Set(this._sessionPins.values());
929
+ return this._warmWorkers.find((w) =>
930
+ w.isIdle() && (!model || w.model === model) && !pinnedSet.has(w)
931
+ ) || null;
932
+ }
933
+
934
+ // ── WP-5: session-level warm worker pinning ─────────────────────────────
935
+
936
+ /**
937
+ * Acquire (or re-acquire) a warm worker pinned to `sessionId`. Idempotent:
938
+ * calling with the same sessionId returns the already-pinned worker. A stale
939
+ * pin (dead worker) is transparently replaced. Returns null when no warm
940
+ * worker is available (caller should fall back or queue).
941
+ *
942
+ * IMPORTANT cwd: a WarmWorker's working directory is fixed at process spawn
943
+ * and CANNOT be changed later. The shared general warm pool (ensureWarmWorkers)
944
+ * always boots workers in this.neutralCwd (a throwaway tmp dir) so a generic
945
+ * one-shot call_agent turn never accidentally sees a real repo. That means a
946
+ * worker pulled from the shared pool cannot reliably reach files by relative
947
+ * path for a session that needs a specific repo. When `opts.cwd` is given
948
+ * (e.g. the editor session's repoRoot), this boots a DEDICATED worker rooted
949
+ * there instead of reusing the shared neutral-cwd pool — it is never returned
950
+ * to the general pool on release (see releaseSession below), so it can never
951
+ * leak the wrong cwd into an unrelated one-shot dispatch.
952
+ *
953
+ * @param {string} sessionId
954
+ * @param {object} [opts]
955
+ * @param {string} [opts.model]
956
+ * @param {string} [opts.cwd]
957
+ * @returns {WarmWorker|null}
958
+ */
959
+ acquireForSession(sessionId, opts = {}) {
960
+ if (this._sessionPins.has(sessionId)) {
961
+ const worker = this._sessionPins.get(sessionId);
962
+ if (!worker.isDead()) return worker;
963
+ this._sessionPins.delete(sessionId); // stale pin, re-acquire below
964
+ }
965
+ const model = opts.model || this.defaultModel;
966
+ const cwd = typeof opts.cwd === "string" && opts.cwd.trim() ? opts.cwd.trim() : null;
967
+
968
+ let worker;
969
+ if (cwd) {
970
+ // This worker genuinely runs IN the repo (unlike the shared neutral-cwd pool), so
971
+ // it's told it's running at `cwd` but with the LEAN studio-editor prompt, not
972
+ // buildFullSystemPrompt()'s general clauth-credential/Supabase-RPC block. A studio
973
+ // session is scoped to plain source-file edits; it never needs to fetch a
974
+ // credential or touch Supabase, so that boilerplate is unnecessary noise here.
975
+ worker = new WarmWorker({
976
+ binary: this.binary,
977
+ model,
978
+ cwd,
979
+ appendSystemPrompt: buildStudioEditorSystemPrompt(cwd),
980
+ spawnImpl: this._spawn,
981
+ onExit: (w, reason) => this._onWarmWorkerExit(w, reason),
982
+ allowVisibleWindow: true,
983
+ });
984
+ worker._dedicatedSession = true;
985
+ this._warmWorkers.push(worker);
986
+ // Fire-and-forget: dispatchToSession's tryRun() loop already polls isIdle()/isDead(),
987
+ // so callers don't need to await boot here.
988
+ worker.start().catch(() => {});
989
+ } else {
990
+ worker = this._acquireWarmWorker(model);
991
+ if (!worker) return null;
992
+ }
993
+ this._sessionPins.set(sessionId, worker);
994
+ return worker;
995
+ }
996
+
997
+ /**
998
+ * Release a session's pin. A general-pool worker (no dedicated cwd) returns
999
+ * to the shared pool — it is just no longer reserved. A dedicated worker
1000
+ * (booted with a session-specific cwd) is killed and removed instead of
1001
+ * being returned to the pool, since its cwd would be wrong for any other
1002
+ * session or general one-shot dispatch that later acquired it.
1003
+ * @param {string} sessionId
1004
+ * @returns {WarmWorker|null}
1005
+ */
1006
+ releaseSession(sessionId) {
1007
+ const worker = this._sessionPins.get(sessionId);
1008
+ this._sessionPins.delete(sessionId);
1009
+ if (worker && worker._dedicatedSession) {
1010
+ worker.kill("session_released");
1011
+ this._warmWorkers = this._warmWorkers.filter((w) => w !== worker);
1012
+ }
1013
+ return worker || null;
1014
+ }
1015
+
1016
+ /**
1017
+ * Dispatch a prompt to the worker pinned for `sessionId`. Unlike general
1018
+ * dispatch, this always targets the pinned worker (preserving conversation
1019
+ * context). Returns a rejected-shape result if no pin exists or the worker
1020
+ * is dead. If the worker is busy (prior turn still in flight), waits for it.
1021
+ *
1022
+ * @param {string} sessionId
1023
+ * @param {object} [args] { prompt, timeout_ms, ... }
1024
+ * @returns {Promise<object>}
1025
+ */
1026
+ dispatchToSession(sessionId, args = {}) {
1027
+ const worker = this._sessionPins.get(sessionId);
1028
+ if (!worker || worker.isDead()) {
1029
+ return Promise.resolve({ ok: false, error: "no_pinned_worker", sessionId });
1030
+ }
1031
+ const prompt = args.prompt;
1032
+ if (!prompt || typeof prompt !== "string") {
1033
+ return Promise.resolve({ ok: false, error: "prompt_required" });
1034
+ }
1035
+ return new Promise((resolve, reject) => {
1036
+ const item = { args, resolve, reject };
1037
+ const tryRun = () => {
1038
+ if (worker.isDead()) {
1039
+ return resolve({ ok: false, error: "pinned_worker_died", sessionId });
1040
+ }
1041
+ if (worker.isIdle()) {
1042
+ this._runWarm(item, worker);
1043
+ } else {
1044
+ setTimeout(tryRun, 100);
1045
+ }
1046
+ };
1047
+ tryRun();
1048
+ });
1049
+ }
1050
+
1051
+ /**
1052
+ * Query the pin state for a session. Returns null if no pin exists.
1053
+ * @param {string} sessionId
1054
+ * @returns {object|null}
1055
+ */
1056
+ getSessionInfo(sessionId) {
1057
+ const worker = this._sessionPins.get(sessionId);
1058
+ if (!worker) return null;
1059
+ return {
1060
+ sessionId,
1061
+ pinned: true,
1062
+ alive: !worker.isDead(),
1063
+ idle: worker.isIdle(),
1064
+ model: worker.model,
1065
+ turns: worker.turns,
1066
+ };
1067
+ }
1068
+
1069
+ /** Drop dead workers from the roster. */
1070
+ _pruneDeadWarm() {
1071
+ this._warmWorkers = this._warmWorkers.filter((w) => !w.isDead());
1072
+ }
1073
+
1074
+ /**
1075
+ * Boot `warmSize` persistent workers (idempotent — only tops up to target).
1076
+ * Auto-restart is wired via each worker's onExit, which respawns a fresh
1077
+ * worker if the pool still wants warm capacity and isn't shutting down.
1078
+ */
1079
+ async ensureWarmWorkers() {
1080
+ if (!this.warmEnabled || !this.available() || this._shuttingDown) return 0;
1081
+ if (this._warmCircuitTripped) return 0; // C1: breaker open warm is permanently off
1082
+ // H2 — reentrancy guard. Two overlapping ensureWarmWorkers() calls (e.g. a
1083
+ // boot prime racing a manual top-up) would each independently compute the
1084
+ // deficit BEFORE the other's workers register, and both spawn overshooting
1085
+ // warmSize. Honor `_warmStarting`: a second concurrent entrant returns 0 and
1086
+ // lets the in-flight call do the provisioning.
1087
+ if (this._warmStarting) return 0;
1088
+ this._warmStarting = true;
1089
+ try {
1090
+ this._pruneDeadWarm();
1091
+ const deficit = this.warmSize - this._liveWarmCount();
1092
+ if (deficit <= 0) return 0;
1093
+ const starts = [];
1094
+ for (let i = 0; i < deficit; i++) {
1095
+ const w = new WarmWorker({
1096
+ binary: this.binary,
1097
+ model: this.defaultModel,
1098
+ cwd: this.neutralCwd,
1099
+ appendSystemPrompt: this.appendSystemPrompt,
1100
+ spawnImpl: this._spawn,
1101
+ onExit: (worker, reason) => this._onWarmWorkerExit(worker, reason),
1102
+ });
1103
+ this._warmWorkers.push(w);
1104
+ starts.push(w.start().catch(() => false));
1105
+ }
1106
+ const results = await Promise.all(starts);
1107
+ this._pruneDeadWarm();
1108
+ // Fire ONE priming turn per freshly-started worker so the CLI boot/init cost
1109
+ // is paid during warm-up, not on the user's first real call. After this, the
1110
+ // worker's session is hot and every dispatch is a pure-inference re-target.
1111
+ // C2: send() now has its own internal timeout, so a hung prime can no longer
1112
+ // wedge this Promise.all forever.
1113
+ if (this.warmPrimeTurn !== false) {
1114
+ const primes = [];
1115
+ for (const w of this._warmWorkers) {
1116
+ if (w.isIdle() && w.turns === 0) {
1117
+ primes.push(w.send("Reply with exactly: OK", { timeout_ms: 60000 }).catch(() => null));
1118
+ }
1119
+ }
1120
+ await Promise.all(primes);
1121
+ this._pruneDeadWarm();
1122
+ }
1123
+ this.warm = this._warmWorkers.some((w) => !w.isDead());
1124
+ return results.filter(Boolean).length;
1125
+ } finally {
1126
+ this._warmStarting = false;
1127
+ }
1128
+ }
1129
+
1130
+ /**
1131
+ * Auto-restart on death: replace a dead worker if still under target. A short
1132
+ * backoff prevents a tight respawn loop when a worker dies the instant it
1133
+ * starts (e.g. a session that crashes immediately) — without it, each death
1134
+ * would synchronously schedule another start that dies and reschedules,
1135
+ * spinning CPU and spawning processes in a hot loop.
1136
+ */
1137
+ _onWarmWorkerExit(worker, reason) {
1138
+ this._pruneDeadWarm();
1139
+ this.warm = this._warmWorkers.some((w) => !w.isDead());
1140
+
1141
+ // Once shutting down, a dying worker is expected teardown — do not count it
1142
+ // toward the boot-failure breaker and do not respawn. (Also prevents a
1143
+ // late-firing backoff respawn's death from tripping the breaker post-shutdown.)
1144
+ if (this._shuttingDown) return;
1145
+
1146
+ // C1/H1 — death classification. Measure how long THIS worker lived. An
1147
+ // immediate death (lifeMs < immediateDeathMs) is the signature of a hard boot
1148
+ // failure (bad model, login/MCP error) — the worker died during boot/prime.
1149
+ // A worker that lived past the window died for a transient reason (a normal
1150
+ // turn, an OOM, a kill) and must NOT clear protection a different flapping
1151
+ // worker raised: we only reset the consecutive-immediate-death counter when a
1152
+ // worker proves the boot path actually works by surviving the window.
1153
+ const lifeMs = worker && worker.spawnedAt ? Date.now() - worker.spawnedAt : Infinity;
1154
+ const immediate = lifeMs < this.immediateDeathMs;
1155
+
1156
+ if (immediate) {
1157
+ this._consecutiveImmediateDeaths++;
1158
+ } else {
1159
+ // A healthy lifetime proves boot works → safe to forgive accumulated
1160
+ // immediate-death backoff for the lineage.
1161
+ this._consecutiveImmediateDeaths = 0;
1162
+ }
1163
+
1164
+ // C1 — trip the breaker after N consecutive immediate deaths. Disable warm
1165
+ // for the daemon's life, log ONCE, and stop respawning. dispatch() already
1166
+ // falls back to the cold path whenever no warm worker is free, so flipping
1167
+ // warmEnabled=false makes the cold fallback permanent. This protects the live
1168
+ // daemon from endless `claude` churn on a misconfigured CLAUTH_AGENT_MODEL.
1169
+ if (!this._warmCircuitTripped && this._consecutiveImmediateDeaths >= this.warmCircuitThreshold) {
1170
+ this._warmCircuitTripped = true;
1171
+ this.warmEnabled = false;
1172
+ if (!this._warmCircuitLogged) {
1173
+ this._warmCircuitLogged = true;
1174
+ console.error(
1175
+ `[agent-pool] WARM CIRCUIT BREAKER TRIPPED: ${this._consecutiveImmediateDeaths} ` +
1176
+ `consecutive warm-worker boot failures (last: ${reason || worker?.error || "unknown"}). ` +
1177
+ `Disabling warm workers for the rest of this process; call_agent now uses the cold ` +
1178
+ `path exclusively. Check CLAUTH_AGENT_MODEL / CLI login / MCP config.`
1179
+ );
1180
+ }
1181
+ return; // do not respawn — breaker is open
1182
+ }
1183
+
1184
+ if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
1185
+ if (this._liveWarmCount() >= this.warmSize) return;
1186
+
1187
+ // H1 backoff keyed to the CONSECUTIVE immediate-death count (lineage
1188
+ // protection), not a shared mutable counter that any healthy exit zeroes.
1189
+ // Each successive immediate death backs off longer (capped at 30s); a
1190
+ // worker that lived past the window respawns immediately.
1191
+ const delay = immediate
1192
+ ? Math.min(30000, this.warmBackoffBaseMs * Math.pow(2, Math.max(0, this._consecutiveImmediateDeaths - 1)))
1193
+ : 0;
1194
+
1195
+ const spawnReplacement = () => {
1196
+ if (this._warmCircuitTripped) return;
1197
+ if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
1198
+ if (this._liveWarmCount() >= this.warmSize) return;
1199
+ const replacement = new WarmWorker({
1200
+ binary: this.binary,
1201
+ model: this.defaultModel,
1202
+ cwd: this.neutralCwd,
1203
+ appendSystemPrompt: this.appendSystemPrompt,
1204
+ spawnImpl: this._spawn,
1205
+ onExit: (w, r) => this._onWarmWorkerExit(w, r),
1206
+ });
1207
+ this._warmWorkers.push(replacement);
1208
+ replacement
1209
+ .start()
1210
+ .then(() => {
1211
+ this.warm = this._warmWorkers.some((w) => !w.isDead());
1212
+ })
1213
+ .catch(() => {});
1214
+ };
1215
+ if (delay > 0) {
1216
+ const t = setTimeout(spawnReplacement, delay);
1217
+ if (t.unref) t.unref();
1218
+ } else {
1219
+ spawnReplacement();
1220
+ }
1221
+ }
1222
+
1223
+ /**
1224
+ * Pre-warm. With warm workers enabled (default), boots `warmSize` persistent
1225
+ * re-targetable stream-json workers. Otherwise (legacy/cold), fires poolSize
1226
+ * trivial one-shot pings so the OS file cache / model session is warm.
1227
+ * Returns when warming settles (or immediately if unavailable).
1228
+ */
1229
+ async prime() {
1230
+ if (!this.available()) return { warmed: 0, available: false, mode: "unavailable" };
1231
+ if (this.warmEnabled) {
1232
+ const warmed = await this.ensureWarmWorkers();
1233
+ return { warmed, available: true, mode: "warm-workers", workers: this._liveWarmCount() };
1234
+ }
1235
+ const n = this.poolSize;
1236
+ const pings = [];
1237
+ for (let i = 0; i < n; i++) {
1238
+ pings.push(
1239
+ this.dispatch({
1240
+ prompt: "Reply with exactly: OK",
1241
+ timeout_ms: 30000,
1242
+ _priming: true,
1243
+ }).catch((e) => ({ ok: false, error: e.message }))
1244
+ );
1245
+ }
1246
+ const results = await Promise.all(pings);
1247
+ this.warm = results.some((r) => r && r.ok);
1248
+ return { warmed: results.filter((r) => r && r.ok).length, available: true, mode: "cold-prime" };
1249
+ }
1250
+
1251
+ /**
1252
+ * Dispatch a one-shot. Resolves with a job record once the worker exits
1253
+ * (sync semantics). Concurrency beyond maxConcurrent is queued.
1254
+ *
1255
+ * @returns {Promise<object>} job record { ok, jobId, package, model, ms, status, exit_code, error }
1256
+ */
1257
+ dispatch(args = {}) {
1258
+ return new Promise((resolve, reject) => {
1259
+ const item = { args, resolve, reject };
1260
+ this.queue.push(item);
1261
+ this._drain();
1262
+ });
1263
+ }
1264
+
1265
+ _drain() {
1266
+ while (this.active < this.maxConcurrent && this.queue.length > 0) {
1267
+ const item = this.queue.shift();
1268
+ // Priming pings stay on the cold one-shot path (they exist only to warm
1269
+ // the cold cache; with warm workers they are a no-op caller). Real calls
1270
+ // prefer a free warm worker and fall back to cold when none is free.
1271
+ //
1272
+ // Bootstrap pinning (tiered startup): warm workers are pre-spawned with the
1273
+ // DEFAULT ("identity") startup prompt and the neutral cwd. A call asking for
1274
+ // a DIFFERENT bootstrap level — "none" (lean fast path) or "full" (repo cwd
1275
+ // + bootstrap guide) — has a different system prompt and/or cwd, so it must
1276
+ // NOT be served by a warm worker. Route it to the cold path, which honors
1277
+ // args.appendSystemPrompt and args.cwd. Default/absent bootstrap → warm.
1278
+ const bootstrapLevel = item.args.bootstrap || DEFAULT_BOOTSTRAP;
1279
+ const warmEligible = bootstrapLevel === DEFAULT_BOOTSTRAP;
1280
+ if (this.warmEnabled && !item.args._priming && warmEligible) {
1281
+ // Only a warm worker pinned to the REQUESTED model may serve the call;
1282
+ // a model mismatch must fall through to the cold path (which honors
1283
+ // args.model) rather than be silently downgraded to the warm model.
1284
+ const reqModel = item.args.model || this.defaultModel;
1285
+ const worker = this._acquireWarmWorker(reqModel);
1286
+ if (worker) {
1287
+ this._runWarm(item, worker);
1288
+ continue;
1289
+ }
1290
+ }
1291
+ this._run(item);
1292
+ }
1293
+ }
1294
+
1295
+ /**
1296
+ * Run a call on a persistent warm worker (re-targeting it with a new prompt).
1297
+ * Produces the SAME public job record shape as the cold path so callers
1298
+ * (runCallAgent, durable delivery, GET /call-agent/:jobId) are unchanged.
1299
+ */
1300
+ _runWarm(item, worker) {
1301
+ const { args, resolve } = item;
1302
+ const prompt = args.prompt;
1303
+ if (!prompt || typeof prompt !== "string") {
1304
+ return resolve({ ok: false, error: "prompt_required" });
1305
+ }
1306
+ const model = worker.model;
1307
+ const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1308
+ const jobId = args.jobId || makeJobId("call");
1309
+
1310
+ const job = {
1311
+ jobId,
1312
+ status: "running",
1313
+ model,
1314
+ priming: false,
1315
+ warm: true,
1316
+ pid: worker.pid || null,
1317
+ started_at: Date.now(),
1318
+ deadline: Date.now() + timeout,
1319
+ completed_at: null,
1320
+ ms: null,
1321
+ exit_code: null,
1322
+ stdout: "",
1323
+ stderr: "",
1324
+ package: null,
1325
+ error: null,
1326
+ _proc: null,
1327
+ _worker: worker,
1328
+ _settle: null,
1329
+ };
1330
+ this.jobs.set(jobId, job);
1331
+ this.active++;
1332
+
1333
+ let settled = false;
1334
+ const settle = () => {
1335
+ if (settled) return;
1336
+ settled = true;
1337
+ this.active = Math.max(0, this.active - 1);
1338
+ const rec = {
1339
+ ok: job.status === "completed",
1340
+ jobId: job.jobId,
1341
+ status: job.status,
1342
+ model: job.model,
1343
+ ms: job.ms,
1344
+ exit_code: job.exit_code,
1345
+ package: job.package,
1346
+ error: job.error,
1347
+ warm: true,
1348
+ };
1349
+ if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
1350
+ this._drain();
1351
+ resolve(rec);
1352
+ };
1353
+ job._settle = () => {
1354
+ // Reaper path: the turn blew its deadline. Kill the worker (its session is
1355
+ // wedged); auto-restart re-tops the pool. Mark the job per its set status.
1356
+ if (job.status === "running") {
1357
+ job.status = "timeout";
1358
+ job.completed_at = Date.now();
1359
+ job.ms = job.completed_at - job.started_at;
1360
+ job.error = "timeout";
1361
+ try {
1362
+ worker.kill("turn_timeout");
1363
+ } catch {
1364
+ /* ignore */
1365
+ }
1366
+ }
1367
+ settle();
1368
+ };
1369
+
1370
+ worker
1371
+ .send(prompt, { timeout_ms: timeout })
1372
+ .then((res) => {
1373
+ if (settled) return; // reaper already settled this
1374
+ job.completed_at = Date.now();
1375
+ job.ms = job.completed_at - job.started_at;
1376
+ if (res.ok) {
1377
+ job.status = "completed";
1378
+ job.exit_code = 0;
1379
+ job.package = res.package;
1380
+ } else {
1381
+ job.status = "failed";
1382
+ job.error = res.error || (res.is_error ? "agent_error" : "warm_failed");
1383
+ if (res.package) job.package = res.package;
1384
+ }
1385
+ settle();
1386
+ })
1387
+ .catch((e) => {
1388
+ if (settled) return;
1389
+ job.status = "failed";
1390
+ job.error = `warm_dispatch: ${e.message}`;
1391
+ job.completed_at = Date.now();
1392
+ job.ms = job.completed_at - job.started_at;
1393
+ settle();
1394
+ });
1395
+ }
1396
+
1397
+ _run({ args, resolve, reject }) {
1398
+ if (!this.available()) {
1399
+ return resolve({
1400
+ ok: false,
1401
+ error: "binary_not_found",
1402
+ message: "claude CLI not found in PATH or AppData/npm",
1403
+ });
1404
+ }
1405
+ const prompt = args.prompt;
1406
+ if (!prompt || typeof prompt !== "string") {
1407
+ return resolve({ ok: false, error: "prompt_required" });
1408
+ }
1409
+ const model = args.model || this.defaultModel;
1410
+ const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1411
+ const jobId = args.jobId || makeJobId(args._priming ? "warm" : "call");
1412
+ const cwd = args.cwd || this.neutralCwd;
1413
+
1414
+ const job = {
1415
+ jobId,
1416
+ status: "running",
1417
+ model,
1418
+ priming: !!args._priming,
1419
+ started_at: Date.now(),
1420
+ deadline: Date.now() + timeout,
1421
+ completed_at: null,
1422
+ ms: null,
1423
+ exit_code: null,
1424
+ stdout: "",
1425
+ stderr: "",
1426
+ package: null,
1427
+ error: null,
1428
+ _proc: null,
1429
+ _settle: null,
1430
+ };
1431
+ this.jobs.set(jobId, job);
1432
+ this.active++;
1433
+
1434
+ let settled = false;
1435
+ const settle = () => {
1436
+ if (settled) return;
1437
+ settled = true;
1438
+ this.active = Math.max(0, this.active - 1);
1439
+ // Build the public record (no internal handles).
1440
+ const rec = {
1441
+ ok: job.status === "completed",
1442
+ jobId: job.jobId,
1443
+ status: job.status,
1444
+ model: job.model,
1445
+ ms: job.ms,
1446
+ exit_code: job.exit_code,
1447
+ package: job.package,
1448
+ error: job.error,
1449
+ };
1450
+ if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
1451
+ this._drain();
1452
+ resolve(rec);
1453
+ };
1454
+ job._settle = () => settle();
1455
+
1456
+ const cmdArgs = this.buildArgs({
1457
+ prompt,
1458
+ model,
1459
+ appendSystemPrompt: args.appendSystemPrompt,
1460
+ });
1461
+
1462
+ let proc;
1463
+ try {
1464
+ const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
1465
+ const command = isCmdShim ? "cmd" : this.binary;
1466
+ const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
1467
+ proc = this._spawn(command, spawnArgs, {
1468
+ cwd,
1469
+ env: process.env, // inherits CLI login session; no ANTHROPIC_API_KEY injected
1470
+ stdio: ["ignore", "pipe", "pipe"],
1471
+ shell: false,
1472
+ windowsHide: true,
1473
+ });
1474
+ } catch (e) {
1475
+ job.status = "failed";
1476
+ job.error = `spawn_failed: ${e.message}`;
1477
+ job.completed_at = Date.now();
1478
+ job.ms = job.completed_at - job.started_at;
1479
+ return settle();
1480
+ }
1481
+
1482
+ job._proc = proc;
1483
+ job.pid = proc.pid;
1484
+ // REVERTED (2026-07-02): a visible window here is a dead end — this job's stdin/stdout
1485
+ // are piped to this Node process for programmatic stream-json parsing, not connected to
1486
+ // the console, so "showing" it just displays an empty, non-interactive window. Real
1487
+ // interactive access needs a genuinely separate terminal (see codevelop.js's wt.exe
1488
+ // pattern) attached to a session-pinned warm worker, not this ephemeral one-shot job.
1489
+
1490
+ proc.stdout?.on("data", (d) => {
1491
+ job.stdout = `${job.stdout}${d.toString()}`.slice(-200000);
1492
+ });
1493
+ proc.stderr?.on("data", (d) => {
1494
+ job.stderr = `${job.stderr}${d.toString()}`.slice(-20000);
1495
+ });
1496
+ proc.on("error", (e) => {
1497
+ if (job.status !== "running") return;
1498
+ job.status = "failed";
1499
+ job.error = `proc_error: ${e.message}`;
1500
+ job.completed_at = Date.now();
1501
+ job.ms = job.completed_at - job.started_at;
1502
+ settle();
1503
+ });
1504
+ proc.on("close", (code) => {
1505
+ if (job.status !== "running") return; // reaper/kill already settled
1506
+ job.exit_code = code;
1507
+ job.completed_at = Date.now();
1508
+ job.ms = job.completed_at - job.started_at;
1509
+ if (code === 0) {
1510
+ job.status = "completed";
1511
+ job.package = job.stdout.trim();
1512
+ } else {
1513
+ job.status = "failed";
1514
+ job.error = `exit_${code}`;
1515
+ }
1516
+ settle();
1517
+ });
1518
+ }
1519
+
1520
+ getJob(jobId) {
1521
+ const job = this.jobs.get(jobId);
1522
+ if (!job) return null;
1523
+ return {
1524
+ jobId: job.jobId,
1525
+ status: job.status,
1526
+ model: job.model,
1527
+ ms: job.ms,
1528
+ exit_code: job.exit_code,
1529
+ package: job.package,
1530
+ error: job.error,
1531
+ started_at: job.started_at,
1532
+ completed_at: job.completed_at,
1533
+ pid: job.pid || null,
1534
+ };
1535
+ }
1536
+
1537
+ /**
1538
+ * Bring a running job's console window to the foreground (un-hides it — the window
1539
+ * exists but was hidden immediately after spawn, see _run()'s post-spawn hide call).
1540
+ * No-op with a clear reason if the job is unknown, has no pid, or already finished.
1541
+ */
1542
+ async showJob(jobId) {
1543
+ const job = this.jobs.get(jobId);
1544
+ if (!job) return { shown: false, reason: "job_not_found" };
1545
+ if (!job.pid) return { shown: false, reason: "no_pid" };
1546
+ if (job.status !== "running") return { shown: false, reason: "job_not_running" };
1547
+ return toggleWindowForPid(job.pid, true);
1548
+ }
1549
+
1550
+ /** Re-hide a job's console window (e.g. operator is done looking at it). */
1551
+ async hideJob(jobId) {
1552
+ const job = this.jobs.get(jobId);
1553
+ if (!job) return { shown: false, reason: "job_not_found" };
1554
+ if (!job.pid) return { shown: false, reason: "no_pid" };
1555
+ return toggleWindowForPid(job.pid, false);
1556
+ }
1557
+
1558
+ stats() {
1559
+ let running = 0,
1560
+ completed = 0,
1561
+ failed = 0;
1562
+ for (const j of this.jobs.values()) {
1563
+ if (j.status === "running") running++;
1564
+ else if (j.status === "completed") completed++;
1565
+ else failed++;
1566
+ }
1567
+ const warmReady = this._warmWorkers.filter((w) => w.isIdle()).length;
1568
+ const warmBusy = this._warmWorkers.filter((w) => w.isBusy()).length;
1569
+ return {
1570
+ active: this.active,
1571
+ queued: this.queue.length,
1572
+ maxConcurrent: this.maxConcurrent,
1573
+ poolSize: this.poolSize,
1574
+ // `warm` reflects REAL persistent workers when warm mode is on (WP-P2):
1575
+ // true iff at least one live worker exists. Cold mode keeps legacy meaning.
1576
+ warm: this.warmEnabled ? this._liveWarmCount() > 0 : this.warm,
1577
+ warmEnabled: this.warmEnabled,
1578
+ warmCircuitTripped: this._warmCircuitTripped,
1579
+ warmWorkers: {
1580
+ target: this.warmSize,
1581
+ live: this._liveWarmCount(),
1582
+ ready: warmReady,
1583
+ busy: warmBusy,
1584
+ consecutiveImmediateDeaths: this._consecutiveImmediateDeaths,
1585
+ },
1586
+ jobs: { running, completed, failed, total: this.jobs.size },
1587
+ };
1588
+ }
1589
+
1590
+ shutdown() {
1591
+ this._shuttingDown = true;
1592
+ this.stopReaper();
1593
+ for (const job of this.jobs.values()) {
1594
+ if (job.status === "running") this._killJob(job, "shutdown");
1595
+ }
1596
+ // Clear session pins before tearing down workers.
1597
+ this._sessionPins.clear();
1598
+ // Tear down persistent warm workers so no orphan `claude` processes linger.
1599
+ for (const w of this._warmWorkers) {
1600
+ try {
1601
+ w.kill("shutdown");
1602
+ } catch {
1603
+ /* already gone */
1604
+ }
1605
+ }
1606
+ this._warmWorkers = [];
1607
+ }
1608
+ }
1609
+
1610
+ // ── WP-P7: hop-limited sub-agent delegation ──────────────────────────────────
1611
+ //
1612
+ // THE DEADLOCK we must make impossible:
1613
+ // The warm/interactive pool has only ~2 workers. A PARENT call_agent worker is
1614
+ // itself a running `claude` process. If that parent calls call_agent and the
1615
+ // sub-call needs a *pool* worker while all pool workers are held by parents
1616
+ // blocked on their own sub-calls → classic resource deadlock. So delegated
1617
+ // (sub-agent) calls MUST NOT touch the AgentPool. They run on this separate
1618
+ // lane, which:
1619
+ // 1. NEVER references an AgentPool, warm worker, or its queue. It only
1620
+ // cold-spawns its own `claude` one-shots, capped by `maxInFlight`. The
1621
+ // interactive pool's latency/availability is therefore unaffected by
1622
+ // delegation depth or fan-out.
1623
+ // 2. Enforces a strict HOP BUDGET (carried in agent_context.delegation):
1624
+ // a call at hop >= max is REJECTED with a clean error and NO spawn, so a
1625
+ // runaway delegation loop is impossible and tree depth is bounded.
1626
+ // 3. Enforces a daemon-wide in-flight cap so a wide fan-out can't exhaust
1627
+ // resources.
1628
+ //
1629
+ // Hop accounting: the delegation context on an INCOMING call describes the call
1630
+ // itself `{ hop: N, max: M }` means "this is a hop-N delegated call, budget M".
1631
+ // A top-level (interactive) call has no delegation context (hop is treated as 0).
1632
+ // When a worker at hop N spawns a sub-agent, the daemon computes the child's
1633
+ // context as `{ hop: N+1, max: M }` and rejects if `N+1 > M`. Equivalently, an
1634
+ // incoming delegated call is admitted iff `hop <= max`; hop is the depth of the
1635
+ // call being served, and a call at `hop > max` (or `hop == max` asking to go
1636
+ // deeper) is refused.
1637
+
1638
+ /**
1639
+ * Normalize / validate a delegation marker from agent_context.delegation.
1640
+ * Returns { hop, max } with safe integer coercion, or null if absent/invalid.
1641
+ * A present-but-malformed marker normalizes to hop 0 with the default max so a
1642
+ * caller cannot smuggle a negative hop or a giant max past the budget.
1643
+ */
1644
+ export function normalizeDelegation(input, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
1645
+ if (input == null) return null;
1646
+ if (typeof input !== "object") return null;
1647
+ let hop = Number(input.hop);
1648
+ let max = Number(input.max);
1649
+ if (!Number.isFinite(hop) || hop < 0) hop = 0;
1650
+ hop = Math.floor(hop);
1651
+ if (!Number.isFinite(max) || max < 0) max = defaultMax;
1652
+ max = Math.floor(max);
1653
+ return { hop, max };
1654
+ }
1655
+
1656
+ /**
1657
+ * Decide whether an incoming DELEGATED call may proceed, and compute the child
1658
+ * delegation context the spawned sub-agent should carry if IT delegates further.
1659
+ *
1660
+ * @param {object|null} delegation normalized { hop, max } of the incoming call
1661
+ * @param {object} [opts]
1662
+ * @param {number} [opts.defaultMax]
1663
+ * @returns {{ allow: boolean, hop: number, max: number, childContext?: object, error?: string }}
1664
+ */
1665
+ export function evaluateHopBudget(delegation, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
1666
+ const d = normalizeDelegation(delegation, { defaultMax }) || { hop: 0, max: defaultMax };
1667
+ // The call being served is at depth `hop`. It is admissible iff it does not
1668
+ // exceed the budget. `hop > max` means a parent tried to spawn one level too
1669
+ // deep refuse with a clean error, no spawn.
1670
+ if (d.hop > d.max) {
1671
+ return {
1672
+ allow: false,
1673
+ hop: d.hop,
1674
+ max: d.max,
1675
+ error: "delegation_hop_exceeded",
1676
+ };
1677
+ }
1678
+ // Child context for any sub-agent THIS call spawns: one hop deeper, same max.
1679
+ return {
1680
+ allow: true,
1681
+ hop: d.hop,
1682
+ max: d.max,
1683
+ childContext: { hop: d.hop + 1, max: d.max },
1684
+ };
1685
+ }
1686
+
1687
+ /**
1688
+ * Single source of truth for the routing decision: should THIS call_agent
1689
+ * invocation go through the delegation lane instead of the interactive pool?
1690
+ *
1691
+ * The gate (`enabled`, from CLAUTH_AGENT_DELEGATION) is checked FIRST: when the
1692
+ * gate is OFF this returns false unconditionally and `agent_context.delegation`
1693
+ * is never even read — that is the byte-stable-off guarantee. A call is delegated
1694
+ * iff the gate is on AND the caller supplied an `agent_context.delegation`
1695
+ * marker (only a sub-agent invocation carries one).
1696
+ *
1697
+ * @param {object} args
1698
+ * @param {boolean} args.enabled CLAUTH_AGENT_DELEGATION on?
1699
+ * @param {object} [args.agent_context]
1700
+ * @returns {boolean}
1701
+ */
1702
+ export function shouldDelegate({ enabled, agent_context } = {}) {
1703
+ if (!enabled) return false; // gate OFF → delegation marker ignored entirely
1704
+ if (!agent_context || typeof agent_context !== "object") return false;
1705
+ return agent_context.delegation != null;
1706
+ }
1707
+
1708
+ /**
1709
+ * DelegationLane — a cold-spawn-only, concurrency-capped execution lane for
1710
+ * DELEGATED (sub-agent) call_agent invocations. Structurally distinct from
1711
+ * AgentPool: it holds no warm workers, owns no AgentPool reference, and shares
1712
+ * no queue/slot with the interactive pool. That separation is the anti-deadlock
1713
+ * guarantee — sub-agent spawns can never starve or hold an interactive slot.
1714
+ *
1715
+ * It deliberately reuses the SAME lean one-shot recipe (model, skip-permissions,
1716
+ * user-only settings, strict-mcp, lean system prompt) as AgentPool's cold path,
1717
+ * but on an independent set of processes with its own `maxInFlight` cap.
1718
+ */
1719
+ export class DelegationLane {
1720
+ /**
1721
+ * @param {object} opts
1722
+ * @param {string} [opts.binary] resolved claude binary
1723
+ * @param {number} [opts.maxInFlight] daemon-wide concurrent sub-agent cap
1724
+ * @param {number} [opts.maxHops] hop-budget default ceiling
1725
+ * @param {string} [opts.defaultModel]
1726
+ * @param {number} [opts.defaultTimeoutMs]
1727
+ * @param {string} [opts.neutralCwd]
1728
+ * @param {string} [opts.appendSystemPrompt]
1729
+ * @param {function} [opts.spawnImpl] inject a fake spawn for tests
1730
+ */
1731
+ constructor(opts = {}) {
1732
+ this.binary = opts.binary || resolveClaudeBinary(opts.binary);
1733
+ this.maxInFlight = Number.isFinite(opts.maxInFlight)
1734
+ ? Math.max(1, opts.maxInFlight)
1735
+ : DEFAULT_DELEGATION_MAX_INFLIGHT;
1736
+ this.maxHops = Number.isFinite(opts.maxHops) ? Math.max(0, opts.maxHops) : DEFAULT_DELEGATION_MAX_HOPS;
1737
+ this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
1738
+ this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs) ? opts.defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
1739
+ this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-delegation");
1740
+ this.appendSystemPrompt = opts.appendSystemPrompt || LEAN_SYSTEM_PROMPT;
1741
+ this._spawn = opts.spawnImpl || spawn;
1742
+
1743
+ this.active = 0; // live sub-agent cold spawns
1744
+ this.totalSpawned = 0;
1745
+ this.totalRejected = 0;
1746
+ this._shuttingDown = false;
1747
+ this._procs = new Set(); // live child procs for shutdown teardown
1748
+
1749
+ try {
1750
+ fs.mkdirSync(this.neutralCwd, { recursive: true });
1751
+ } catch {
1752
+ /* best effort */
1753
+ }
1754
+ }
1755
+
1756
+ available() {
1757
+ return this.binary != null;
1758
+ }
1759
+
1760
+ buildArgs({ prompt, model, appendSystemPrompt }) {
1761
+ return [
1762
+ "-p",
1763
+ prompt,
1764
+ "--model",
1765
+ model || this.defaultModel,
1766
+ "--dangerously-skip-permissions",
1767
+ "--setting-sources",
1768
+ "user",
1769
+ "--strict-mcp-config",
1770
+ "--append-system-prompt",
1771
+ appendSystemPrompt || this.appendSystemPrompt,
1772
+ ];
1773
+ }
1774
+
1775
+ /**
1776
+ * Dispatch a DELEGATED sub-agent call. Enforces the hop budget and the
1777
+ * in-flight cap, then cold-spawns a one-shot. Returns a job record shaped like
1778
+ * AgentPool.dispatch's so callers are uniform. On rejection it resolves with
1779
+ * `{ ok:false, error, delegated:true, ... }` and NEVER spawns.
1780
+ *
1781
+ * @param {object} args { prompt, model, timeout_ms, delegation:{hop,max}, jobId, appendSystemPrompt }
1782
+ * @returns {Promise<object>}
1783
+ */
1784
+ dispatch(args = {}) {
1785
+ return new Promise((resolve) => {
1786
+ if (!this.available()) {
1787
+ return resolve({ ok: false, error: "binary_not_found", delegated: true });
1788
+ }
1789
+ const prompt = args.prompt;
1790
+ if (!prompt || typeof prompt !== "string") {
1791
+ return resolve({ ok: false, error: "prompt_required", delegated: true });
1792
+ }
1793
+ // Hop budget — the single most important guard. A call past budget is
1794
+ // refused cleanly with NO spawn, so a delegation loop cannot run away.
1795
+ const budget = evaluateHopBudget(args.delegation, { defaultMax: this.maxHops });
1796
+ if (!budget.allow) {
1797
+ this.totalRejected++;
1798
+ return resolve({
1799
+ ok: false,
1800
+ error: budget.error,
1801
+ delegated: true,
1802
+ hop: budget.hop,
1803
+ max: budget.max,
1804
+ message: `delegation hop ${budget.hop} exceeds max ${budget.max}`,
1805
+ });
1806
+ }
1807
+ // Daemon-wide in-flight cap — a wide fan-out cannot exhaust resources.
1808
+ if (this.active >= this.maxInFlight) {
1809
+ this.totalRejected++;
1810
+ return resolve({
1811
+ ok: false,
1812
+ error: "delegation_capacity",
1813
+ delegated: true,
1814
+ active: this.active,
1815
+ maxInFlight: this.maxInFlight,
1816
+ message: `delegation lane full (${this.active}/${this.maxInFlight})`,
1817
+ });
1818
+ }
1819
+ if (this._shuttingDown) {
1820
+ return resolve({ ok: false, error: "delegation_shutting_down", delegated: true });
1821
+ }
1822
+
1823
+ const model = args.model || this.defaultModel;
1824
+ const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1825
+ const jobId = args.jobId || makeJobId("deleg");
1826
+ const cwd = args.cwd || this.neutralCwd;
1827
+ const startedAt = Date.now();
1828
+
1829
+ const cmdArgs = this.buildArgs({ prompt, model, appendSystemPrompt: args.appendSystemPrompt });
1830
+
1831
+ let proc;
1832
+ try {
1833
+ const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
1834
+ const command = isCmdShim ? "cmd" : this.binary;
1835
+ const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
1836
+ proc = this._spawn(command, spawnArgs, {
1837
+ cwd,
1838
+ env: process.env, // inherits CLI login; never sets ANTHROPIC_API_KEY
1839
+ stdio: ["ignore", "pipe", "pipe"],
1840
+ shell: false,
1841
+ windowsHide: true,
1842
+ });
1843
+ } catch (e) {
1844
+ return resolve({
1845
+ ok: false,
1846
+ error: `spawn_failed: ${e.message}`,
1847
+ delegated: true,
1848
+ jobId,
1849
+ });
1850
+ }
1851
+
1852
+ this.active++;
1853
+ this.totalSpawned++;
1854
+ this._procs.add(proc);
1855
+
1856
+ let stdout = "";
1857
+ let stderr = "";
1858
+ let settled = false;
1859
+ // NOTE: intentionally NOT unref'd. While a sub-agent is in flight this
1860
+ // timer is the only thing that can settle a hung one-shot, so it must keep
1861
+ // the loop alive until it fires (it clears on normal close). The daemon
1862
+ // owns process lifecycle; shutdown() kills all live procs explicitly.
1863
+ const timer = setTimeout(() => {
1864
+ if (settled) return;
1865
+ try {
1866
+ proc.kill("SIGKILL");
1867
+ } catch {
1868
+ /* already gone */
1869
+ }
1870
+ finish({ status: "timeout", error: "timeout" });
1871
+ }, timeout);
1872
+
1873
+ const finish = (extra) => {
1874
+ if (settled) return;
1875
+ settled = true;
1876
+ clearTimeout(timer);
1877
+ this.active = Math.max(0, this.active - 1);
1878
+ this._procs.delete(proc);
1879
+ const ms = Date.now() - startedAt;
1880
+ const status = extra.status;
1881
+ resolve({
1882
+ ok: status === "completed",
1883
+ jobId,
1884
+ status,
1885
+ model,
1886
+ ms,
1887
+ exit_code: extra.exit_code ?? null,
1888
+ package: status === "completed" ? stdout.trim() : null,
1889
+ error: extra.error || null,
1890
+ delegated: true,
1891
+ hop: budget.childContext.hop, // depth of this spawned sub-agent
1892
+ max: budget.max,
1893
+ ...(stderr && status !== "completed" ? { stderr: stderr.slice(-2000) } : {}),
1894
+ });
1895
+ };
1896
+
1897
+ proc.stdout?.on("data", (d) => {
1898
+ stdout = `${stdout}${d.toString()}`.slice(-200000);
1899
+ });
1900
+ proc.stderr?.on("data", (d) => {
1901
+ stderr = `${stderr}${d.toString()}`.slice(-20000);
1902
+ });
1903
+ proc.on("error", (e) => finish({ status: "failed", error: `proc_error: ${e.message}` }));
1904
+ proc.on("close", (code) => {
1905
+ if (code === 0) finish({ status: "completed", exit_code: 0 });
1906
+ else finish({ status: "failed", exit_code: code, error: `exit_${code}` });
1907
+ });
1908
+ });
1909
+ }
1910
+
1911
+ stats() {
1912
+ return {
1913
+ active: this.active,
1914
+ maxInFlight: this.maxInFlight,
1915
+ maxHops: this.maxHops,
1916
+ totalSpawned: this.totalSpawned,
1917
+ totalRejected: this.totalRejected,
1918
+ };
1919
+ }
1920
+
1921
+ shutdown() {
1922
+ this._shuttingDown = true;
1923
+ for (const proc of this._procs) {
1924
+ try {
1925
+ proc.kill("SIGKILL");
1926
+ } catch {
1927
+ /* already gone */
1928
+ }
1929
+ }
1930
+ this._procs.clear();
1931
+ }
1932
+ }
1933
+
1934
+ export {
1935
+ LEAN_SYSTEM_PROMPT,
1936
+ IDENTITY_SYSTEM_PROMPT,
1937
+ resolveClaudeBinary,
1938
+ buildWarmArgs,
1939
+ buildFullSystemPrompt,
1940
+ buildStudioEditorSystemPrompt,
1941
+ loadEditorSettings,
1942
+ makeJobId,
1943
+ };
1944
+
1945
+ // composeCorpus / buildCorpusSection / loadBootstrapDoc are exported inline
1946
+ // above (function declarations with `export`).