@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.
- package/.clauth-skill/SKILL.md +75 -17
- package/README.md +70 -10
- package/cli/api.classify.test.js +75 -0
- package/cli/api.js +110 -11
- package/cli/commands/agent-cron.js +396 -0
- package/cli/commands/agent-pool.js +1962 -0
- package/cli/commands/scrub.js +205 -109
- package/cli/commands/scrub.test.js +115 -0
- package/cli/commands/serve.js +3488 -1068
- package/cli/enrollment-script.js +82 -0
- package/cli/index.js +23 -57
- package/cli/studio-debug.js +679 -8
- package/cli/webdav-service.js +339 -0
- package/package.json +11 -3
- package/scripts/postinstall.js +25 -0
|
@@ -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`).
|