@lifeaitools/clauth 1.30.13 → 1.30.14

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.
@@ -0,0 +1,1962 @@
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({ model, appendSystemPrompt }) {
898
+ return [
899
+ // Prompt is fed via the child's stdin (claude -p reads stdin), NOT as an
900
+ // argv positional — a large prompt as argv overflows the OS command-line
901
+ // length limit (spawn ENAMETOOLONG on content-heavy call_agent jobs).
902
+ "-p",
903
+ "--model",
904
+ model || this.defaultModel,
905
+ "--dangerously-skip-permissions",
906
+ // Lean isolation: only the user's auth/settings, no project CLAUDE.md/hooks.
907
+ "--setting-sources",
908
+ "user",
909
+ "--strict-mcp-config",
910
+ "--append-system-prompt",
911
+ appendSystemPrompt || LEAN_SYSTEM_PROMPT,
912
+ ];
913
+ }
914
+
915
+ // ── WP-P2 warm worker management ───────────────────────────────────────────
916
+
917
+ /** Count of alive (ready or busy) warm workers. */
918
+ _liveWarmCount() {
919
+ return this._warmWorkers.filter((w) => !w.isDead()).length;
920
+ }
921
+
922
+ /**
923
+ * First idle warm worker matching `model`, or null. Warm workers are pinned
924
+ * to one model; a call requesting a different model must NOT be silently
925
+ * served by a mismatched warm worker — it falls back to a cold spawn that
926
+ * honors the requested model. Workers pinned to a session are excluded so
927
+ * general dispatch cannot steal a session's dedicated worker.
928
+ */
929
+ _acquireWarmWorker(model) {
930
+ const pinnedSet = new Set(this._sessionPins.values());
931
+ return this._warmWorkers.find((w) =>
932
+ w.isIdle() && (!model || w.model === model) && !pinnedSet.has(w)
933
+ ) || null;
934
+ }
935
+
936
+ // ── WP-5: session-level warm worker pinning ─────────────────────────────
937
+
938
+ /**
939
+ * Acquire (or re-acquire) a warm worker pinned to `sessionId`. Idempotent:
940
+ * calling with the same sessionId returns the already-pinned worker. A stale
941
+ * pin (dead worker) is transparently replaced. Returns null when no warm
942
+ * worker is available (caller should fall back or queue).
943
+ *
944
+ * IMPORTANT — cwd: a WarmWorker's working directory is fixed at process spawn
945
+ * and CANNOT be changed later. The shared general warm pool (ensureWarmWorkers)
946
+ * always boots workers in this.neutralCwd (a throwaway tmp dir) so a generic
947
+ * one-shot call_agent turn never accidentally sees a real repo. That means a
948
+ * worker pulled from the shared pool cannot reliably reach files by relative
949
+ * path for a session that needs a specific repo. When `opts.cwd` is given
950
+ * (e.g. the editor session's repoRoot), this boots a DEDICATED worker rooted
951
+ * there instead of reusing the shared neutral-cwd pool — it is never returned
952
+ * to the general pool on release (see releaseSession below), so it can never
953
+ * leak the wrong cwd into an unrelated one-shot dispatch.
954
+ *
955
+ * @param {string} sessionId
956
+ * @param {object} [opts]
957
+ * @param {string} [opts.model]
958
+ * @param {string} [opts.cwd]
959
+ * @returns {WarmWorker|null}
960
+ */
961
+ acquireForSession(sessionId, opts = {}) {
962
+ if (this._sessionPins.has(sessionId)) {
963
+ const worker = this._sessionPins.get(sessionId);
964
+ if (!worker.isDead()) return worker;
965
+ this._sessionPins.delete(sessionId); // stale pin, re-acquire below
966
+ }
967
+ const model = opts.model || this.defaultModel;
968
+ const cwd = typeof opts.cwd === "string" && opts.cwd.trim() ? opts.cwd.trim() : null;
969
+
970
+ let worker;
971
+ if (cwd) {
972
+ // This worker genuinely runs IN the repo (unlike the shared neutral-cwd pool), so
973
+ // it's told it's running at `cwd` — but with the LEAN studio-editor prompt, not
974
+ // buildFullSystemPrompt()'s general clauth-credential/Supabase-RPC block. A studio
975
+ // session is scoped to plain source-file edits; it never needs to fetch a
976
+ // credential or touch Supabase, so that boilerplate is unnecessary noise here.
977
+ worker = new WarmWorker({
978
+ binary: this.binary,
979
+ model,
980
+ cwd,
981
+ appendSystemPrompt: buildStudioEditorSystemPrompt(cwd),
982
+ spawnImpl: this._spawn,
983
+ onExit: (w, reason) => this._onWarmWorkerExit(w, reason),
984
+ allowVisibleWindow: true,
985
+ });
986
+ worker._dedicatedSession = true;
987
+ this._warmWorkers.push(worker);
988
+ // Fire-and-forget: dispatchToSession's tryRun() loop already polls isIdle()/isDead(),
989
+ // so callers don't need to await boot here.
990
+ worker.start().catch(() => {});
991
+ } else {
992
+ worker = this._acquireWarmWorker(model);
993
+ if (!worker) return null;
994
+ }
995
+ this._sessionPins.set(sessionId, worker);
996
+ return worker;
997
+ }
998
+
999
+ /**
1000
+ * Release a session's pin. A general-pool worker (no dedicated cwd) returns
1001
+ * to the shared pool — it is just no longer reserved. A dedicated worker
1002
+ * (booted with a session-specific cwd) is killed and removed instead of
1003
+ * being returned to the pool, since its cwd would be wrong for any other
1004
+ * session or general one-shot dispatch that later acquired it.
1005
+ * @param {string} sessionId
1006
+ * @returns {WarmWorker|null}
1007
+ */
1008
+ releaseSession(sessionId) {
1009
+ const worker = this._sessionPins.get(sessionId);
1010
+ this._sessionPins.delete(sessionId);
1011
+ if (worker && worker._dedicatedSession) {
1012
+ worker.kill("session_released");
1013
+ this._warmWorkers = this._warmWorkers.filter((w) => w !== worker);
1014
+ }
1015
+ return worker || null;
1016
+ }
1017
+
1018
+ /**
1019
+ * Dispatch a prompt to the worker pinned for `sessionId`. Unlike general
1020
+ * dispatch, this always targets the pinned worker (preserving conversation
1021
+ * context). Returns a rejected-shape result if no pin exists or the worker
1022
+ * is dead. If the worker is busy (prior turn still in flight), waits for it.
1023
+ *
1024
+ * @param {string} sessionId
1025
+ * @param {object} [args] { prompt, timeout_ms, ... }
1026
+ * @returns {Promise<object>}
1027
+ */
1028
+ dispatchToSession(sessionId, args = {}) {
1029
+ const worker = this._sessionPins.get(sessionId);
1030
+ if (!worker || worker.isDead()) {
1031
+ return Promise.resolve({ ok: false, error: "no_pinned_worker", sessionId });
1032
+ }
1033
+ const prompt = args.prompt;
1034
+ if (!prompt || typeof prompt !== "string") {
1035
+ return Promise.resolve({ ok: false, error: "prompt_required" });
1036
+ }
1037
+ return new Promise((resolve, reject) => {
1038
+ const item = { args, resolve, reject };
1039
+ const tryRun = () => {
1040
+ if (worker.isDead()) {
1041
+ return resolve({ ok: false, error: "pinned_worker_died", sessionId });
1042
+ }
1043
+ if (worker.isIdle()) {
1044
+ this._runWarm(item, worker);
1045
+ } else {
1046
+ setTimeout(tryRun, 100);
1047
+ }
1048
+ };
1049
+ tryRun();
1050
+ });
1051
+ }
1052
+
1053
+ /**
1054
+ * Query the pin state for a session. Returns null if no pin exists.
1055
+ * @param {string} sessionId
1056
+ * @returns {object|null}
1057
+ */
1058
+ getSessionInfo(sessionId) {
1059
+ const worker = this._sessionPins.get(sessionId);
1060
+ if (!worker) return null;
1061
+ return {
1062
+ sessionId,
1063
+ pinned: true,
1064
+ alive: !worker.isDead(),
1065
+ idle: worker.isIdle(),
1066
+ model: worker.model,
1067
+ turns: worker.turns,
1068
+ };
1069
+ }
1070
+
1071
+ /** Drop dead workers from the roster. */
1072
+ _pruneDeadWarm() {
1073
+ this._warmWorkers = this._warmWorkers.filter((w) => !w.isDead());
1074
+ }
1075
+
1076
+ /**
1077
+ * Boot `warmSize` persistent workers (idempotent — only tops up to target).
1078
+ * Auto-restart is wired via each worker's onExit, which respawns a fresh
1079
+ * worker if the pool still wants warm capacity and isn't shutting down.
1080
+ */
1081
+ async ensureWarmWorkers() {
1082
+ if (!this.warmEnabled || !this.available() || this._shuttingDown) return 0;
1083
+ if (this._warmCircuitTripped) return 0; // C1: breaker open — warm is permanently off
1084
+ // H2 — reentrancy guard. Two overlapping ensureWarmWorkers() calls (e.g. a
1085
+ // boot prime racing a manual top-up) would each independently compute the
1086
+ // deficit BEFORE the other's workers register, and both spawn — overshooting
1087
+ // warmSize. Honor `_warmStarting`: a second concurrent entrant returns 0 and
1088
+ // lets the in-flight call do the provisioning.
1089
+ if (this._warmStarting) return 0;
1090
+ this._warmStarting = true;
1091
+ try {
1092
+ this._pruneDeadWarm();
1093
+ const deficit = this.warmSize - this._liveWarmCount();
1094
+ if (deficit <= 0) return 0;
1095
+ const starts = [];
1096
+ for (let i = 0; i < deficit; i++) {
1097
+ const w = new WarmWorker({
1098
+ binary: this.binary,
1099
+ model: this.defaultModel,
1100
+ cwd: this.neutralCwd,
1101
+ appendSystemPrompt: this.appendSystemPrompt,
1102
+ spawnImpl: this._spawn,
1103
+ onExit: (worker, reason) => this._onWarmWorkerExit(worker, reason),
1104
+ });
1105
+ this._warmWorkers.push(w);
1106
+ starts.push(w.start().catch(() => false));
1107
+ }
1108
+ const results = await Promise.all(starts);
1109
+ this._pruneDeadWarm();
1110
+ // Fire ONE priming turn per freshly-started worker so the CLI boot/init cost
1111
+ // is paid during warm-up, not on the user's first real call. After this, the
1112
+ // worker's session is hot and every dispatch is a pure-inference re-target.
1113
+ // C2: send() now has its own internal timeout, so a hung prime can no longer
1114
+ // wedge this Promise.all forever.
1115
+ if (this.warmPrimeTurn !== false) {
1116
+ const primes = [];
1117
+ for (const w of this._warmWorkers) {
1118
+ if (w.isIdle() && w.turns === 0) {
1119
+ primes.push(w.send("Reply with exactly: OK", { timeout_ms: 60000 }).catch(() => null));
1120
+ }
1121
+ }
1122
+ await Promise.all(primes);
1123
+ this._pruneDeadWarm();
1124
+ }
1125
+ this.warm = this._warmWorkers.some((w) => !w.isDead());
1126
+ return results.filter(Boolean).length;
1127
+ } finally {
1128
+ this._warmStarting = false;
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * Auto-restart on death: replace a dead worker if still under target. A short
1134
+ * backoff prevents a tight respawn loop when a worker dies the instant it
1135
+ * starts (e.g. a session that crashes immediately) — without it, each death
1136
+ * would synchronously schedule another start that dies and reschedules,
1137
+ * spinning CPU and spawning processes in a hot loop.
1138
+ */
1139
+ _onWarmWorkerExit(worker, reason) {
1140
+ this._pruneDeadWarm();
1141
+ this.warm = this._warmWorkers.some((w) => !w.isDead());
1142
+
1143
+ // Once shutting down, a dying worker is expected teardown — do not count it
1144
+ // toward the boot-failure breaker and do not respawn. (Also prevents a
1145
+ // late-firing backoff respawn's death from tripping the breaker post-shutdown.)
1146
+ if (this._shuttingDown) return;
1147
+
1148
+ // C1/H1 — death classification. Measure how long THIS worker lived. An
1149
+ // immediate death (lifeMs < immediateDeathMs) is the signature of a hard boot
1150
+ // failure (bad model, login/MCP error) — the worker died during boot/prime.
1151
+ // A worker that lived past the window died for a transient reason (a normal
1152
+ // turn, an OOM, a kill) and must NOT clear protection a different flapping
1153
+ // worker raised: we only reset the consecutive-immediate-death counter when a
1154
+ // worker proves the boot path actually works by surviving the window.
1155
+ const lifeMs = worker && worker.spawnedAt ? Date.now() - worker.spawnedAt : Infinity;
1156
+ const immediate = lifeMs < this.immediateDeathMs;
1157
+
1158
+ if (immediate) {
1159
+ this._consecutiveImmediateDeaths++;
1160
+ } else {
1161
+ // A healthy lifetime proves boot works → safe to forgive accumulated
1162
+ // immediate-death backoff for the lineage.
1163
+ this._consecutiveImmediateDeaths = 0;
1164
+ }
1165
+
1166
+ // C1 — trip the breaker after N consecutive immediate deaths. Disable warm
1167
+ // for the daemon's life, log ONCE, and stop respawning. dispatch() already
1168
+ // falls back to the cold path whenever no warm worker is free, so flipping
1169
+ // warmEnabled=false makes the cold fallback permanent. This protects the live
1170
+ // daemon from endless `claude` churn on a misconfigured CLAUTH_AGENT_MODEL.
1171
+ if (!this._warmCircuitTripped && this._consecutiveImmediateDeaths >= this.warmCircuitThreshold) {
1172
+ this._warmCircuitTripped = true;
1173
+ this.warmEnabled = false;
1174
+ if (!this._warmCircuitLogged) {
1175
+ this._warmCircuitLogged = true;
1176
+ console.error(
1177
+ `[agent-pool] WARM CIRCUIT BREAKER TRIPPED: ${this._consecutiveImmediateDeaths} ` +
1178
+ `consecutive warm-worker boot failures (last: ${reason || worker?.error || "unknown"}). ` +
1179
+ `Disabling warm workers for the rest of this process; call_agent now uses the cold ` +
1180
+ `path exclusively. Check CLAUTH_AGENT_MODEL / CLI login / MCP config.`
1181
+ );
1182
+ }
1183
+ return; // do not respawn — breaker is open
1184
+ }
1185
+
1186
+ if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
1187
+ if (this._liveWarmCount() >= this.warmSize) return;
1188
+
1189
+ // H1 — backoff keyed to the CONSECUTIVE immediate-death count (lineage
1190
+ // protection), not a shared mutable counter that any healthy exit zeroes.
1191
+ // Each successive immediate death backs off longer (capped at 30s); a
1192
+ // worker that lived past the window respawns immediately.
1193
+ const delay = immediate
1194
+ ? Math.min(30000, this.warmBackoffBaseMs * Math.pow(2, Math.max(0, this._consecutiveImmediateDeaths - 1)))
1195
+ : 0;
1196
+
1197
+ const spawnReplacement = () => {
1198
+ if (this._warmCircuitTripped) return;
1199
+ if (!this.warmEnabled || this._shuttingDown || !this.available()) return;
1200
+ if (this._liveWarmCount() >= this.warmSize) return;
1201
+ const replacement = new WarmWorker({
1202
+ binary: this.binary,
1203
+ model: this.defaultModel,
1204
+ cwd: this.neutralCwd,
1205
+ appendSystemPrompt: this.appendSystemPrompt,
1206
+ spawnImpl: this._spawn,
1207
+ onExit: (w, r) => this._onWarmWorkerExit(w, r),
1208
+ });
1209
+ this._warmWorkers.push(replacement);
1210
+ replacement
1211
+ .start()
1212
+ .then(() => {
1213
+ this.warm = this._warmWorkers.some((w) => !w.isDead());
1214
+ })
1215
+ .catch(() => {});
1216
+ };
1217
+ if (delay > 0) {
1218
+ const t = setTimeout(spawnReplacement, delay);
1219
+ if (t.unref) t.unref();
1220
+ } else {
1221
+ spawnReplacement();
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Pre-warm. With warm workers enabled (default), boots `warmSize` persistent
1227
+ * re-targetable stream-json workers. Otherwise (legacy/cold), fires poolSize
1228
+ * trivial one-shot pings so the OS file cache / model session is warm.
1229
+ * Returns when warming settles (or immediately if unavailable).
1230
+ */
1231
+ async prime() {
1232
+ if (!this.available()) return { warmed: 0, available: false, mode: "unavailable" };
1233
+ if (this.warmEnabled) {
1234
+ const warmed = await this.ensureWarmWorkers();
1235
+ return { warmed, available: true, mode: "warm-workers", workers: this._liveWarmCount() };
1236
+ }
1237
+ const n = this.poolSize;
1238
+ const pings = [];
1239
+ for (let i = 0; i < n; i++) {
1240
+ pings.push(
1241
+ this.dispatch({
1242
+ prompt: "Reply with exactly: OK",
1243
+ timeout_ms: 30000,
1244
+ _priming: true,
1245
+ }).catch((e) => ({ ok: false, error: e.message }))
1246
+ );
1247
+ }
1248
+ const results = await Promise.all(pings);
1249
+ this.warm = results.some((r) => r && r.ok);
1250
+ return { warmed: results.filter((r) => r && r.ok).length, available: true, mode: "cold-prime" };
1251
+ }
1252
+
1253
+ /**
1254
+ * Dispatch a one-shot. Resolves with a job record once the worker exits
1255
+ * (sync semantics). Concurrency beyond maxConcurrent is queued.
1256
+ *
1257
+ * @returns {Promise<object>} job record { ok, jobId, package, model, ms, status, exit_code, error }
1258
+ */
1259
+ dispatch(args = {}) {
1260
+ return new Promise((resolve, reject) => {
1261
+ const item = { args, resolve, reject };
1262
+ this.queue.push(item);
1263
+ this._drain();
1264
+ });
1265
+ }
1266
+
1267
+ _drain() {
1268
+ while (this.active < this.maxConcurrent && this.queue.length > 0) {
1269
+ const item = this.queue.shift();
1270
+ // Priming pings stay on the cold one-shot path (they exist only to warm
1271
+ // the cold cache; with warm workers they are a no-op caller). Real calls
1272
+ // prefer a free warm worker and fall back to cold when none is free.
1273
+ //
1274
+ // Bootstrap pinning (tiered startup): warm workers are pre-spawned with the
1275
+ // DEFAULT ("identity") startup prompt and the neutral cwd. A call asking for
1276
+ // a DIFFERENT bootstrap level — "none" (lean fast path) or "full" (repo cwd
1277
+ // + bootstrap guide) — has a different system prompt and/or cwd, so it must
1278
+ // NOT be served by a warm worker. Route it to the cold path, which honors
1279
+ // args.appendSystemPrompt and args.cwd. Default/absent bootstrap → warm.
1280
+ const bootstrapLevel = item.args.bootstrap || DEFAULT_BOOTSTRAP;
1281
+ const warmEligible = bootstrapLevel === DEFAULT_BOOTSTRAP;
1282
+ if (this.warmEnabled && !item.args._priming && warmEligible) {
1283
+ // Only a warm worker pinned to the REQUESTED model may serve the call;
1284
+ // a model mismatch must fall through to the cold path (which honors
1285
+ // args.model) rather than be silently downgraded to the warm model.
1286
+ const reqModel = item.args.model || this.defaultModel;
1287
+ const worker = this._acquireWarmWorker(reqModel);
1288
+ if (worker) {
1289
+ this._runWarm(item, worker);
1290
+ continue;
1291
+ }
1292
+ }
1293
+ this._run(item);
1294
+ }
1295
+ }
1296
+
1297
+ /**
1298
+ * Run a call on a persistent warm worker (re-targeting it with a new prompt).
1299
+ * Produces the SAME public job record shape as the cold path so callers
1300
+ * (runCallAgent, durable delivery, GET /call-agent/:jobId) are unchanged.
1301
+ */
1302
+ _runWarm(item, worker) {
1303
+ const { args, resolve } = item;
1304
+ const prompt = args.prompt;
1305
+ if (!prompt || typeof prompt !== "string") {
1306
+ return resolve({ ok: false, error: "prompt_required" });
1307
+ }
1308
+ const model = worker.model;
1309
+ const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1310
+ const jobId = args.jobId || makeJobId("call");
1311
+
1312
+ const job = {
1313
+ jobId,
1314
+ status: "running",
1315
+ model,
1316
+ priming: false,
1317
+ warm: true,
1318
+ pid: worker.pid || null,
1319
+ started_at: Date.now(),
1320
+ deadline: Date.now() + timeout,
1321
+ completed_at: null,
1322
+ ms: null,
1323
+ exit_code: null,
1324
+ stdout: "",
1325
+ stderr: "",
1326
+ package: null,
1327
+ error: null,
1328
+ _proc: null,
1329
+ _worker: worker,
1330
+ _settle: null,
1331
+ };
1332
+ this.jobs.set(jobId, job);
1333
+ this.active++;
1334
+
1335
+ let settled = false;
1336
+ const settle = () => {
1337
+ if (settled) return;
1338
+ settled = true;
1339
+ this.active = Math.max(0, this.active - 1);
1340
+ const rec = {
1341
+ ok: job.status === "completed",
1342
+ jobId: job.jobId,
1343
+ status: job.status,
1344
+ model: job.model,
1345
+ ms: job.ms,
1346
+ exit_code: job.exit_code,
1347
+ package: job.package,
1348
+ error: job.error,
1349
+ warm: true,
1350
+ };
1351
+ if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
1352
+ this._drain();
1353
+ resolve(rec);
1354
+ };
1355
+ job._settle = () => {
1356
+ // Reaper path: the turn blew its deadline. Kill the worker (its session is
1357
+ // wedged); auto-restart re-tops the pool. Mark the job per its set status.
1358
+ if (job.status === "running") {
1359
+ job.status = "timeout";
1360
+ job.completed_at = Date.now();
1361
+ job.ms = job.completed_at - job.started_at;
1362
+ job.error = "timeout";
1363
+ try {
1364
+ worker.kill("turn_timeout");
1365
+ } catch {
1366
+ /* ignore */
1367
+ }
1368
+ }
1369
+ settle();
1370
+ };
1371
+
1372
+ worker
1373
+ .send(prompt, { timeout_ms: timeout })
1374
+ .then((res) => {
1375
+ if (settled) return; // reaper already settled this
1376
+ job.completed_at = Date.now();
1377
+ job.ms = job.completed_at - job.started_at;
1378
+ if (res.ok) {
1379
+ job.status = "completed";
1380
+ job.exit_code = 0;
1381
+ job.package = res.package;
1382
+ } else {
1383
+ job.status = "failed";
1384
+ job.error = res.error || (res.is_error ? "agent_error" : "warm_failed");
1385
+ if (res.package) job.package = res.package;
1386
+ }
1387
+ settle();
1388
+ })
1389
+ .catch((e) => {
1390
+ if (settled) return;
1391
+ job.status = "failed";
1392
+ job.error = `warm_dispatch: ${e.message}`;
1393
+ job.completed_at = Date.now();
1394
+ job.ms = job.completed_at - job.started_at;
1395
+ settle();
1396
+ });
1397
+ }
1398
+
1399
+ _run({ args, resolve, reject }) {
1400
+ if (!this.available()) {
1401
+ return resolve({
1402
+ ok: false,
1403
+ error: "binary_not_found",
1404
+ message: "claude CLI not found in PATH or AppData/npm",
1405
+ });
1406
+ }
1407
+ const prompt = args.prompt;
1408
+ if (!prompt || typeof prompt !== "string") {
1409
+ return resolve({ ok: false, error: "prompt_required" });
1410
+ }
1411
+ const model = args.model || this.defaultModel;
1412
+ const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1413
+ const jobId = args.jobId || makeJobId(args._priming ? "warm" : "call");
1414
+ const cwd = args.cwd || this.neutralCwd;
1415
+
1416
+ const job = {
1417
+ jobId,
1418
+ status: "running",
1419
+ model,
1420
+ priming: !!args._priming,
1421
+ started_at: Date.now(),
1422
+ deadline: Date.now() + timeout,
1423
+ completed_at: null,
1424
+ ms: null,
1425
+ exit_code: null,
1426
+ stdout: "",
1427
+ stderr: "",
1428
+ package: null,
1429
+ error: null,
1430
+ _proc: null,
1431
+ _settle: null,
1432
+ };
1433
+ this.jobs.set(jobId, job);
1434
+ this.active++;
1435
+
1436
+ let settled = false;
1437
+ const settle = () => {
1438
+ if (settled) return;
1439
+ settled = true;
1440
+ this.active = Math.max(0, this.active - 1);
1441
+ // Build the public record (no internal handles).
1442
+ const rec = {
1443
+ ok: job.status === "completed",
1444
+ jobId: job.jobId,
1445
+ status: job.status,
1446
+ model: job.model,
1447
+ ms: job.ms,
1448
+ exit_code: job.exit_code,
1449
+ package: job.package,
1450
+ error: job.error,
1451
+ };
1452
+ if (job.stderr && job.status !== "completed") rec.stderr = job.stderr.slice(-2000);
1453
+ this._drain();
1454
+ resolve(rec);
1455
+ };
1456
+ job._settle = () => settle();
1457
+
1458
+ const cmdArgs = this.buildArgs({
1459
+ prompt,
1460
+ model,
1461
+ appendSystemPrompt: args.appendSystemPrompt,
1462
+ });
1463
+
1464
+ let proc;
1465
+ try {
1466
+ const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
1467
+ const command = isCmdShim ? "cmd" : this.binary;
1468
+ const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
1469
+ proc = this._spawn(command, spawnArgs, {
1470
+ cwd,
1471
+ env: process.env, // inherits CLI login session; no ANTHROPIC_API_KEY injected
1472
+ stdio: ["pipe", "pipe", "pipe"],
1473
+ shell: false,
1474
+ windowsHide: true,
1475
+ });
1476
+ } catch (e) {
1477
+ job.status = "failed";
1478
+ job.error = `spawn_failed: ${e.message}`;
1479
+ job.completed_at = Date.now();
1480
+ job.ms = job.completed_at - job.started_at;
1481
+ return settle();
1482
+ }
1483
+
1484
+ job._proc = proc;
1485
+ job.pid = proc.pid;
1486
+ // Feed the prompt over stdin (buildArgs no longer puts it in argv) so a large
1487
+ // prompt cannot overflow the OS command-line limit. Tolerate a stdin race:
1488
+ // the proc error/close handlers below settle the job if the pipe breaks.
1489
+ if (proc.stdin) {
1490
+ proc.stdin.on("error", () => {});
1491
+ try { proc.stdin.write(prompt); proc.stdin.end(); } catch { /* settled by close/error */ }
1492
+ }
1493
+ // REVERTED (2026-07-02): a visible window here is a dead end — this job's stdin/stdout
1494
+ // are piped to this Node process for programmatic stream-json parsing, not connected to
1495
+ // the console, so "showing" it just displays an empty, non-interactive window. Real
1496
+ // interactive access needs a genuinely separate terminal (see codevelop.js's wt.exe
1497
+ // pattern) attached to a session-pinned warm worker, not this ephemeral one-shot job.
1498
+
1499
+ proc.stdout?.on("data", (d) => {
1500
+ job.stdout = `${job.stdout}${d.toString()}`.slice(-200000);
1501
+ });
1502
+ proc.stderr?.on("data", (d) => {
1503
+ job.stderr = `${job.stderr}${d.toString()}`.slice(-20000);
1504
+ });
1505
+ proc.on("error", (e) => {
1506
+ if (job.status !== "running") return;
1507
+ job.status = "failed";
1508
+ job.error = `proc_error: ${e.message}`;
1509
+ job.completed_at = Date.now();
1510
+ job.ms = job.completed_at - job.started_at;
1511
+ settle();
1512
+ });
1513
+ proc.on("close", (code) => {
1514
+ if (job.status !== "running") return; // reaper/kill already settled
1515
+ job.exit_code = code;
1516
+ job.completed_at = Date.now();
1517
+ job.ms = job.completed_at - job.started_at;
1518
+ if (code === 0) {
1519
+ job.status = "completed";
1520
+ job.package = job.stdout.trim();
1521
+ } else {
1522
+ job.status = "failed";
1523
+ job.error = `exit_${code}`;
1524
+ }
1525
+ settle();
1526
+ });
1527
+ }
1528
+
1529
+ getJob(jobId) {
1530
+ const job = this.jobs.get(jobId);
1531
+ if (!job) return null;
1532
+ return {
1533
+ jobId: job.jobId,
1534
+ status: job.status,
1535
+ model: job.model,
1536
+ ms: job.ms,
1537
+ exit_code: job.exit_code,
1538
+ package: job.package,
1539
+ error: job.error,
1540
+ started_at: job.started_at,
1541
+ completed_at: job.completed_at,
1542
+ pid: job.pid || null,
1543
+ };
1544
+ }
1545
+
1546
+ /**
1547
+ * Bring a running job's console window to the foreground (un-hides it — the window
1548
+ * exists but was hidden immediately after spawn, see _run()'s post-spawn hide call).
1549
+ * No-op with a clear reason if the job is unknown, has no pid, or already finished.
1550
+ */
1551
+ async showJob(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
+ if (job.status !== "running") return { shown: false, reason: "job_not_running" };
1556
+ return toggleWindowForPid(job.pid, true);
1557
+ }
1558
+
1559
+ /** Re-hide a job's console window (e.g. operator is done looking at it). */
1560
+ async hideJob(jobId) {
1561
+ const job = this.jobs.get(jobId);
1562
+ if (!job) return { shown: false, reason: "job_not_found" };
1563
+ if (!job.pid) return { shown: false, reason: "no_pid" };
1564
+ return toggleWindowForPid(job.pid, false);
1565
+ }
1566
+
1567
+ stats() {
1568
+ let running = 0,
1569
+ completed = 0,
1570
+ failed = 0;
1571
+ for (const j of this.jobs.values()) {
1572
+ if (j.status === "running") running++;
1573
+ else if (j.status === "completed") completed++;
1574
+ else failed++;
1575
+ }
1576
+ const warmReady = this._warmWorkers.filter((w) => w.isIdle()).length;
1577
+ const warmBusy = this._warmWorkers.filter((w) => w.isBusy()).length;
1578
+ return {
1579
+ active: this.active,
1580
+ queued: this.queue.length,
1581
+ maxConcurrent: this.maxConcurrent,
1582
+ poolSize: this.poolSize,
1583
+ // `warm` reflects REAL persistent workers when warm mode is on (WP-P2):
1584
+ // true iff at least one live worker exists. Cold mode keeps legacy meaning.
1585
+ warm: this.warmEnabled ? this._liveWarmCount() > 0 : this.warm,
1586
+ warmEnabled: this.warmEnabled,
1587
+ warmCircuitTripped: this._warmCircuitTripped,
1588
+ warmWorkers: {
1589
+ target: this.warmSize,
1590
+ live: this._liveWarmCount(),
1591
+ ready: warmReady,
1592
+ busy: warmBusy,
1593
+ consecutiveImmediateDeaths: this._consecutiveImmediateDeaths,
1594
+ },
1595
+ jobs: { running, completed, failed, total: this.jobs.size },
1596
+ };
1597
+ }
1598
+
1599
+ shutdown() {
1600
+ this._shuttingDown = true;
1601
+ this.stopReaper();
1602
+ for (const job of this.jobs.values()) {
1603
+ if (job.status === "running") this._killJob(job, "shutdown");
1604
+ }
1605
+ // Clear session pins before tearing down workers.
1606
+ this._sessionPins.clear();
1607
+ // Tear down persistent warm workers so no orphan `claude` processes linger.
1608
+ for (const w of this._warmWorkers) {
1609
+ try {
1610
+ w.kill("shutdown");
1611
+ } catch {
1612
+ /* already gone */
1613
+ }
1614
+ }
1615
+ this._warmWorkers = [];
1616
+ }
1617
+ }
1618
+
1619
+ // ── WP-P7: hop-limited sub-agent delegation ──────────────────────────────────
1620
+ //
1621
+ // THE DEADLOCK we must make impossible:
1622
+ // The warm/interactive pool has only ~2 workers. A PARENT call_agent worker is
1623
+ // itself a running `claude` process. If that parent calls call_agent and the
1624
+ // sub-call needs a *pool* worker while all pool workers are held by parents
1625
+ // blocked on their own sub-calls → classic resource deadlock. So delegated
1626
+ // (sub-agent) calls MUST NOT touch the AgentPool. They run on this separate
1627
+ // lane, which:
1628
+ // 1. NEVER references an AgentPool, warm worker, or its queue. It only
1629
+ // cold-spawns its own `claude` one-shots, capped by `maxInFlight`. The
1630
+ // interactive pool's latency/availability is therefore unaffected by
1631
+ // delegation depth or fan-out.
1632
+ // 2. Enforces a strict HOP BUDGET (carried in agent_context.delegation):
1633
+ // a call at hop >= max is REJECTED with a clean error and NO spawn, so a
1634
+ // runaway delegation loop is impossible and tree depth is bounded.
1635
+ // 3. Enforces a daemon-wide in-flight cap so a wide fan-out can't exhaust
1636
+ // resources.
1637
+ //
1638
+ // Hop accounting: the delegation context on an INCOMING call describes the call
1639
+ // itself — `{ hop: N, max: M }` means "this is a hop-N delegated call, budget M".
1640
+ // A top-level (interactive) call has no delegation context (hop is treated as 0).
1641
+ // When a worker at hop N spawns a sub-agent, the daemon computes the child's
1642
+ // context as `{ hop: N+1, max: M }` and rejects if `N+1 > M`. Equivalently, an
1643
+ // incoming delegated call is admitted iff `hop <= max`; hop is the depth of the
1644
+ // call being served, and a call at `hop > max` (or `hop == max` asking to go
1645
+ // deeper) is refused.
1646
+
1647
+ /**
1648
+ * Normalize / validate a delegation marker from agent_context.delegation.
1649
+ * Returns { hop, max } with safe integer coercion, or null if absent/invalid.
1650
+ * A present-but-malformed marker normalizes to hop 0 with the default max so a
1651
+ * caller cannot smuggle a negative hop or a giant max past the budget.
1652
+ */
1653
+ export function normalizeDelegation(input, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
1654
+ if (input == null) return null;
1655
+ if (typeof input !== "object") return null;
1656
+ let hop = Number(input.hop);
1657
+ let max = Number(input.max);
1658
+ if (!Number.isFinite(hop) || hop < 0) hop = 0;
1659
+ hop = Math.floor(hop);
1660
+ if (!Number.isFinite(max) || max < 0) max = defaultMax;
1661
+ max = Math.floor(max);
1662
+ return { hop, max };
1663
+ }
1664
+
1665
+ /**
1666
+ * Decide whether an incoming DELEGATED call may proceed, and compute the child
1667
+ * delegation context the spawned sub-agent should carry if IT delegates further.
1668
+ *
1669
+ * @param {object|null} delegation normalized { hop, max } of the incoming call
1670
+ * @param {object} [opts]
1671
+ * @param {number} [opts.defaultMax]
1672
+ * @returns {{ allow: boolean, hop: number, max: number, childContext?: object, error?: string }}
1673
+ */
1674
+ export function evaluateHopBudget(delegation, { defaultMax = DEFAULT_DELEGATION_MAX_HOPS } = {}) {
1675
+ const d = normalizeDelegation(delegation, { defaultMax }) || { hop: 0, max: defaultMax };
1676
+ // The call being served is at depth `hop`. It is admissible iff it does not
1677
+ // exceed the budget. `hop > max` means a parent tried to spawn one level too
1678
+ // deep — refuse with a clean error, no spawn.
1679
+ if (d.hop > d.max) {
1680
+ return {
1681
+ allow: false,
1682
+ hop: d.hop,
1683
+ max: d.max,
1684
+ error: "delegation_hop_exceeded",
1685
+ };
1686
+ }
1687
+ // Child context for any sub-agent THIS call spawns: one hop deeper, same max.
1688
+ return {
1689
+ allow: true,
1690
+ hop: d.hop,
1691
+ max: d.max,
1692
+ childContext: { hop: d.hop + 1, max: d.max },
1693
+ };
1694
+ }
1695
+
1696
+ /**
1697
+ * Single source of truth for the routing decision: should THIS call_agent
1698
+ * invocation go through the delegation lane instead of the interactive pool?
1699
+ *
1700
+ * The gate (`enabled`, from CLAUTH_AGENT_DELEGATION) is checked FIRST: when the
1701
+ * gate is OFF this returns false unconditionally and `agent_context.delegation`
1702
+ * is never even read — that is the byte-stable-off guarantee. A call is delegated
1703
+ * iff the gate is on AND the caller supplied an `agent_context.delegation`
1704
+ * marker (only a sub-agent invocation carries one).
1705
+ *
1706
+ * @param {object} args
1707
+ * @param {boolean} args.enabled CLAUTH_AGENT_DELEGATION on?
1708
+ * @param {object} [args.agent_context]
1709
+ * @returns {boolean}
1710
+ */
1711
+ export function shouldDelegate({ enabled, agent_context } = {}) {
1712
+ if (!enabled) return false; // gate OFF → delegation marker ignored entirely
1713
+ if (!agent_context || typeof agent_context !== "object") return false;
1714
+ return agent_context.delegation != null;
1715
+ }
1716
+
1717
+ /**
1718
+ * DelegationLane — a cold-spawn-only, concurrency-capped execution lane for
1719
+ * DELEGATED (sub-agent) call_agent invocations. Structurally distinct from
1720
+ * AgentPool: it holds no warm workers, owns no AgentPool reference, and shares
1721
+ * no queue/slot with the interactive pool. That separation is the anti-deadlock
1722
+ * guarantee — sub-agent spawns can never starve or hold an interactive slot.
1723
+ *
1724
+ * It deliberately reuses the SAME lean one-shot recipe (model, skip-permissions,
1725
+ * user-only settings, strict-mcp, lean system prompt) as AgentPool's cold path,
1726
+ * but on an independent set of processes with its own `maxInFlight` cap.
1727
+ */
1728
+ export class DelegationLane {
1729
+ /**
1730
+ * @param {object} opts
1731
+ * @param {string} [opts.binary] resolved claude binary
1732
+ * @param {number} [opts.maxInFlight] daemon-wide concurrent sub-agent cap
1733
+ * @param {number} [opts.maxHops] hop-budget default ceiling
1734
+ * @param {string} [opts.defaultModel]
1735
+ * @param {number} [opts.defaultTimeoutMs]
1736
+ * @param {string} [opts.neutralCwd]
1737
+ * @param {string} [opts.appendSystemPrompt]
1738
+ * @param {function} [opts.spawnImpl] inject a fake spawn for tests
1739
+ */
1740
+ constructor(opts = {}) {
1741
+ this.binary = opts.binary || resolveClaudeBinary(opts.binary);
1742
+ this.maxInFlight = Number.isFinite(opts.maxInFlight)
1743
+ ? Math.max(1, opts.maxInFlight)
1744
+ : DEFAULT_DELEGATION_MAX_INFLIGHT;
1745
+ this.maxHops = Number.isFinite(opts.maxHops) ? Math.max(0, opts.maxHops) : DEFAULT_DELEGATION_MAX_HOPS;
1746
+ this.defaultModel = opts.defaultModel || DEFAULT_MODEL;
1747
+ this.defaultTimeoutMs = Number.isFinite(opts.defaultTimeoutMs) ? opts.defaultTimeoutMs : DEFAULT_TIMEOUT_MS;
1748
+ this.neutralCwd = opts.neutralCwd || path.join(os.tmpdir(), "clauth-call-agent-delegation");
1749
+ this.appendSystemPrompt = opts.appendSystemPrompt || LEAN_SYSTEM_PROMPT;
1750
+ this._spawn = opts.spawnImpl || spawn;
1751
+
1752
+ this.active = 0; // live sub-agent cold spawns
1753
+ this.totalSpawned = 0;
1754
+ this.totalRejected = 0;
1755
+ this._shuttingDown = false;
1756
+ this._procs = new Set(); // live child procs for shutdown teardown
1757
+
1758
+ try {
1759
+ fs.mkdirSync(this.neutralCwd, { recursive: true });
1760
+ } catch {
1761
+ /* best effort */
1762
+ }
1763
+ }
1764
+
1765
+ available() {
1766
+ return this.binary != null;
1767
+ }
1768
+
1769
+ buildArgs({ model, appendSystemPrompt }) {
1770
+ return [
1771
+ // Prompt is fed via the child's stdin (claude -p reads stdin), NOT as an
1772
+ // argv positional — a large prompt as argv overflows the OS command-line
1773
+ // length limit (spawn ENAMETOOLONG on content-heavy call_agent jobs).
1774
+ "-p",
1775
+ "--model",
1776
+ model || this.defaultModel,
1777
+ "--dangerously-skip-permissions",
1778
+ "--setting-sources",
1779
+ "user",
1780
+ "--strict-mcp-config",
1781
+ "--append-system-prompt",
1782
+ appendSystemPrompt || this.appendSystemPrompt,
1783
+ ];
1784
+ }
1785
+
1786
+ /**
1787
+ * Dispatch a DELEGATED sub-agent call. Enforces the hop budget and the
1788
+ * in-flight cap, then cold-spawns a one-shot. Returns a job record shaped like
1789
+ * AgentPool.dispatch's so callers are uniform. On rejection it resolves with
1790
+ * `{ ok:false, error, delegated:true, ... }` and NEVER spawns.
1791
+ *
1792
+ * @param {object} args { prompt, model, timeout_ms, delegation:{hop,max}, jobId, appendSystemPrompt }
1793
+ * @returns {Promise<object>}
1794
+ */
1795
+ dispatch(args = {}) {
1796
+ return new Promise((resolve) => {
1797
+ if (!this.available()) {
1798
+ return resolve({ ok: false, error: "binary_not_found", delegated: true });
1799
+ }
1800
+ const prompt = args.prompt;
1801
+ if (!prompt || typeof prompt !== "string") {
1802
+ return resolve({ ok: false, error: "prompt_required", delegated: true });
1803
+ }
1804
+ // Hop budget — the single most important guard. A call past budget is
1805
+ // refused cleanly with NO spawn, so a delegation loop cannot run away.
1806
+ const budget = evaluateHopBudget(args.delegation, { defaultMax: this.maxHops });
1807
+ if (!budget.allow) {
1808
+ this.totalRejected++;
1809
+ return resolve({
1810
+ ok: false,
1811
+ error: budget.error,
1812
+ delegated: true,
1813
+ hop: budget.hop,
1814
+ max: budget.max,
1815
+ message: `delegation hop ${budget.hop} exceeds max ${budget.max}`,
1816
+ });
1817
+ }
1818
+ // Daemon-wide in-flight cap — a wide fan-out cannot exhaust resources.
1819
+ if (this.active >= this.maxInFlight) {
1820
+ this.totalRejected++;
1821
+ return resolve({
1822
+ ok: false,
1823
+ error: "delegation_capacity",
1824
+ delegated: true,
1825
+ active: this.active,
1826
+ maxInFlight: this.maxInFlight,
1827
+ message: `delegation lane full (${this.active}/${this.maxInFlight})`,
1828
+ });
1829
+ }
1830
+ if (this._shuttingDown) {
1831
+ return resolve({ ok: false, error: "delegation_shutting_down", delegated: true });
1832
+ }
1833
+
1834
+ const model = args.model || this.defaultModel;
1835
+ const timeout = Number.isFinite(args.timeout_ms) ? args.timeout_ms : this.defaultTimeoutMs;
1836
+ const jobId = args.jobId || makeJobId("deleg");
1837
+ const cwd = args.cwd || this.neutralCwd;
1838
+ const startedAt = Date.now();
1839
+
1840
+ const cmdArgs = this.buildArgs({ prompt, model, appendSystemPrompt: args.appendSystemPrompt });
1841
+
1842
+ let proc;
1843
+ try {
1844
+ const isCmdShim = process.platform === "win32" && /\.cmd$/i.test(this.binary);
1845
+ const command = isCmdShim ? "cmd" : this.binary;
1846
+ const spawnArgs = isCmdShim ? ["/d", "/s", "/c", `"${this.binary}"`, ...cmdArgs] : cmdArgs;
1847
+ proc = this._spawn(command, spawnArgs, {
1848
+ cwd,
1849
+ env: process.env, // inherits CLI login; never sets ANTHROPIC_API_KEY
1850
+ stdio: ["pipe", "pipe", "pipe"],
1851
+ shell: false,
1852
+ windowsHide: true,
1853
+ });
1854
+ } catch (e) {
1855
+ return resolve({
1856
+ ok: false,
1857
+ error: `spawn_failed: ${e.message}`,
1858
+ delegated: true,
1859
+ jobId,
1860
+ });
1861
+ }
1862
+
1863
+ this.active++;
1864
+ this.totalSpawned++;
1865
+ this._procs.add(proc);
1866
+ // Prompt over stdin — avoids argv-length overflow on large delegated prompts.
1867
+ if (proc.stdin) {
1868
+ proc.stdin.on("error", () => {});
1869
+ try { proc.stdin.write(prompt); proc.stdin.end(); } catch { /* settled by close/error */ }
1870
+ }
1871
+
1872
+ let stdout = "";
1873
+ let stderr = "";
1874
+ let settled = false;
1875
+ // NOTE: intentionally NOT unref'd. While a sub-agent is in flight this
1876
+ // timer is the only thing that can settle a hung one-shot, so it must keep
1877
+ // the loop alive until it fires (it clears on normal close). The daemon
1878
+ // owns process lifecycle; shutdown() kills all live procs explicitly.
1879
+ const timer = setTimeout(() => {
1880
+ if (settled) return;
1881
+ try {
1882
+ proc.kill("SIGKILL");
1883
+ } catch {
1884
+ /* already gone */
1885
+ }
1886
+ finish({ status: "timeout", error: "timeout" });
1887
+ }, timeout);
1888
+
1889
+ const finish = (extra) => {
1890
+ if (settled) return;
1891
+ settled = true;
1892
+ clearTimeout(timer);
1893
+ this.active = Math.max(0, this.active - 1);
1894
+ this._procs.delete(proc);
1895
+ const ms = Date.now() - startedAt;
1896
+ const status = extra.status;
1897
+ resolve({
1898
+ ok: status === "completed",
1899
+ jobId,
1900
+ status,
1901
+ model,
1902
+ ms,
1903
+ exit_code: extra.exit_code ?? null,
1904
+ package: status === "completed" ? stdout.trim() : null,
1905
+ error: extra.error || null,
1906
+ delegated: true,
1907
+ hop: budget.childContext.hop, // depth of this spawned sub-agent
1908
+ max: budget.max,
1909
+ ...(stderr && status !== "completed" ? { stderr: stderr.slice(-2000) } : {}),
1910
+ });
1911
+ };
1912
+
1913
+ proc.stdout?.on("data", (d) => {
1914
+ stdout = `${stdout}${d.toString()}`.slice(-200000);
1915
+ });
1916
+ proc.stderr?.on("data", (d) => {
1917
+ stderr = `${stderr}${d.toString()}`.slice(-20000);
1918
+ });
1919
+ proc.on("error", (e) => finish({ status: "failed", error: `proc_error: ${e.message}` }));
1920
+ proc.on("close", (code) => {
1921
+ if (code === 0) finish({ status: "completed", exit_code: 0 });
1922
+ else finish({ status: "failed", exit_code: code, error: `exit_${code}` });
1923
+ });
1924
+ });
1925
+ }
1926
+
1927
+ stats() {
1928
+ return {
1929
+ active: this.active,
1930
+ maxInFlight: this.maxInFlight,
1931
+ maxHops: this.maxHops,
1932
+ totalSpawned: this.totalSpawned,
1933
+ totalRejected: this.totalRejected,
1934
+ };
1935
+ }
1936
+
1937
+ shutdown() {
1938
+ this._shuttingDown = true;
1939
+ for (const proc of this._procs) {
1940
+ try {
1941
+ proc.kill("SIGKILL");
1942
+ } catch {
1943
+ /* already gone */
1944
+ }
1945
+ }
1946
+ this._procs.clear();
1947
+ }
1948
+ }
1949
+
1950
+ export {
1951
+ LEAN_SYSTEM_PROMPT,
1952
+ IDENTITY_SYSTEM_PROMPT,
1953
+ resolveClaudeBinary,
1954
+ buildWarmArgs,
1955
+ buildFullSystemPrompt,
1956
+ buildStudioEditorSystemPrompt,
1957
+ loadEditorSettings,
1958
+ makeJobId,
1959
+ };
1960
+
1961
+ // composeCorpus / buildCorpusSection / loadBootstrapDoc are exported inline
1962
+ // above (function declarations with `export`).