@tekmidian/pai 0.71.0 → 0.71.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,413 @@
1
+ import { t as ANTHROPIC_NATIVE, x as WorkersConfigError } from "./config-CH2Xj7BG.mjs";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { request } from "node:http";
6
+
7
+ //#region src/workers/mcp.ts
8
+ /**
9
+ * mcp.ts — the MCP allowlist for headless workers.
10
+ *
11
+ * Headless workers start with NO MCP servers: every server definition is a
12
+ * prompt-time tool inventory the model pays to know, and a worker that only
13
+ * reads and edits files needs none of it. A run may opt in with `--mcp
14
+ * name[,name…]`, a role carrying `"mcp": [...]`, or implicitly by naming
15
+ * `mcp__server__tool` in --allowedTools (a grant without its server loaded is
16
+ * a dead letter). Names may be single servers from ~/.claude.json's
17
+ * `mcpServers` or `workers.mcpSets` set names, which expand to their member
18
+ * list. The filtered config lands in
19
+ * `<logDir>/<id>.mcp.json` and is passed with `--strict-mcp-config
20
+ * --mcp-config` so exactly those servers load. MCP servers are chosen at
21
+ * launch only — a mid-run `say` cannot add any.
22
+ *
23
+ * One name in that space is not a server at all: `claude-in-chrome` is the
24
+ * Chrome native-host bridge, which no config file can load and which the
25
+ * `--chrome` flag switches on instead. It is recognised here so a grant
26
+ * naming it becomes that flag rather than an "unknown MCP server" rejection.
27
+ */
28
+ /** ~/.claude.json — the user's MCP server definitions (top-level mcpServers). */
29
+ const CLAUDE_JSON = join(homedir(), ".claude.json");
30
+ /**
31
+ * The browser bridge is not an MCP server. It rides the Chrome native-host
32
+ * channel, so it never appears in `mcpServers`; a spawned claude has it off
33
+ * and the `--chrome` flag switches it on. Tool grants naming it must
34
+ * therefore neither resolve to a server nor be rejected as unknown — they
35
+ * select a flag. See `grantsChrome`.
36
+ */
37
+ const CHROME_SERVER = "claude-in-chrome";
38
+ /**
39
+ * True for a real server definition, false for anything else that happens to
40
+ * sit under `mcpServers`.
41
+ *
42
+ * The vendor config is not ours and has been observed carrying non-server
43
+ * entries under that key — tool-usage records keyed by tool name
44
+ * (`Read`, `Bash`, `mcp__server__tool`, …). Those are not servers: handing
45
+ * one to `--mcp-config` yields "invalid MCP server config", and listing one
46
+ * as available invites a run that cannot work. A server has a `command` (or a
47
+ * `url` for remote transports); an `mcp__…` key is a tool name whatever its
48
+ * shape.
49
+ */
50
+ function isServerDefinition(name, value) {
51
+ if (name.startsWith("mcp__")) return false;
52
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
53
+ const v = value;
54
+ return typeof v.command === "string" && v.command.length > 0 || typeof v.url === "string" && v.url.length > 0;
55
+ }
56
+ function readMcpServers(claudeJson = CLAUDE_JSON) {
57
+ try {
58
+ if (!existsSync(claudeJson)) return {};
59
+ const servers = JSON.parse(readFileSync(claudeJson, "utf8")).mcpServers;
60
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return {};
61
+ const out = {};
62
+ for (const [name, value] of Object.entries(servers)) if (isServerDefinition(name, value)) out[name] = value;
63
+ return out;
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+ /**
69
+ * True when any `--mcp` name or `--allowedTools` grant asks for the browser
70
+ * bridge: the bare server name, `mcp__claude-in-chrome`, or any
71
+ * `mcp__claude-in-chrome__<tool>`. The runner turns this into `--chrome` on
72
+ * the claude argv.
73
+ */
74
+ function grantsChrome(entries) {
75
+ for (const entry of entries) for (const name of entry.split(",").map((s) => s.trim()).filter(Boolean)) {
76
+ if (name === CHROME_SERVER) return true;
77
+ if (name === `mcp__${CHROME_SERVER}`) return true;
78
+ if (name.startsWith(`mcp__${CHROME_SERVER}__`)) return true;
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * Split a `--mcp a,b,c` flag value (also accepts repeated flags already split
84
+ * by the caller) and expand set names from workers.mcpSets.
85
+ */
86
+ function expandMcpNames(names, config, claudeJson = CLAUDE_JSON) {
87
+ const available = readMcpServers(claudeJson);
88
+ const out = [];
89
+ for (const raw of names) for (const name of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
90
+ if (name === CHROME_SERVER) continue;
91
+ if (name in config.mcpSets) {
92
+ for (const member of config.mcpSets[name]) if (!out.includes(member)) out.push(member);
93
+ continue;
94
+ }
95
+ if (!(name in available)) {
96
+ const sets = Object.keys(config.mcpSets);
97
+ throw new WorkersConfigError(`unknown MCP server "${name}". Available servers: ${Object.keys(available).join(", ") || "(none in ~/.claude.json)"}${sets.length ? `; sets: ${sets.join(", ")}` : ""}`);
98
+ }
99
+ if (!out.includes(name)) out.push(name);
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Derive server names from tool grants: every `mcp__<server>__<tool>` (or bare
105
+ * `mcp__<server>`, or `mcp__<server>__*`) in an --allowedTools list names a
106
+ * server the run expects to be loaded. A grant is a dead letter unless its
107
+ * server is in the filtered config, so the runner treats these as implicit
108
+ * --mcp names — and only these; non-mcp grants load nothing.
109
+ */
110
+ function mcpServersFromToolGrants(tools) {
111
+ const out = [];
112
+ for (const entry of tools) for (const name of entry.split(",").map((s) => s.trim()).filter(Boolean)) {
113
+ if (!name.startsWith("mcp__")) continue;
114
+ const server = name.slice(5).split("__")[0];
115
+ if (server === CHROME_SERVER) continue;
116
+ if (server && !out.includes(server)) out.push(server);
117
+ }
118
+ return out;
119
+ }
120
+ /** Path of a run's filtered MCP config. */
121
+ function runMcpConfigPath(logDir, id) {
122
+ return join(logDir, `${id}.mcp.json`);
123
+ }
124
+ /**
125
+ * Write a config containing only `names` (already expanded) and return its
126
+ * path. Unknown names fail fast with the available list.
127
+ */
128
+ function writeMcpConfig(logDir, id, names, claudeJson = CLAUDE_JSON) {
129
+ const available = readMcpServers(claudeJson);
130
+ const unknown = names.filter((n) => !(n in available));
131
+ if (unknown.length) throw new WorkersConfigError(`unknown MCP server(s): ${unknown.join(", ")}. Available: ${Object.keys(available).join(", ") || "(none in ~/.claude.json)"}`);
132
+ const servers = {};
133
+ for (const n of names) servers[n] = available[n];
134
+ const path = runMcpConfigPath(logDir, id);
135
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
136
+ writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "utf8");
137
+ return path;
138
+ }
139
+ /** `pai worker mcp list`: the servers and sets a run could name. */
140
+ function describeMcp(config, claudeJson = CLAUDE_JSON) {
141
+ const servers = Object.keys(readMcpServers(claudeJson));
142
+ const lines = [];
143
+ if (servers.length) {
144
+ lines.push(`servers (~/.claude.json):`);
145
+ for (const s of servers) lines.push(` ${s}`);
146
+ } else lines.push(`no MCP servers defined in ~/.claude.json`);
147
+ const sets = Object.entries(config.mcpSets);
148
+ if (sets.length) {
149
+ lines.push(`sets (workers.mcpSets):`);
150
+ for (const [name, members] of sets) lines.push(` ${name} = ${members.join(", ")}`);
151
+ }
152
+ lines.push(`usage: pai worker run --mcp <name>[,<name>…] -p '<task>'`);
153
+ return lines;
154
+ }
155
+
156
+ //#endregion
157
+ //#region src/utils/input-side-tokens.ts
158
+ /** Input-side tokens of one usage block: input + cache read + cache creation (no output). */
159
+ function inputSideTokens(u) {
160
+ return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
161
+ }
162
+
163
+ //#endregion
164
+ //#region src/workers/pool.ts
165
+ /**
166
+ * pool.ts — the worker session pool: one long-lived headless claude per
167
+ * (provider, model) for successive small `pai worker run` tasks, so the
168
+ * provider-side prompt cache carries over between them.
169
+ *
170
+ * Evidence (2026-10-16, router-usage.jsonl): 158 of 161 glm sessions carry
171
+ * exactly ONE request — every `pai worker run` spawns a fresh claude, sends
172
+ * one giant prompt, exits. The provider cache can only serve a fresh spawn
173
+ * within its TTL, so the effective hit ratio on first requests is ~13%
174
+ * (continuous sessions: 98%). A warm slot turns the next task into the
175
+ * SECOND request of a continuous session instead.
176
+ *
177
+ * Architecture (mirrors the openai proxy, src/workers/proxy/server.ts):
178
+ * - `pai worker pool on` starts a detached host (standalone/pool-host.ts,
179
+ * bundled to dist/hooks/worker-pool-host.mjs) that owns the slot
180
+ * processes and listens on a loopback port.
181
+ * - runWorker, behind workers.pool.enabled, routes eligible runs to the
182
+ * host (postTaskToPool); when the host answers `pooled`, the task ran as
183
+ * one more user turn of a pooled claude and the result comes back as
184
+ * JSON. Host down or task ineligible → the normal fresh spawn.
185
+ * - Each pooled task keeps its full worker identity: own id, status file,
186
+ * <id>.jsonl event mirror, ledger lines, AG2 contract and report
187
+ * validation — the pool wraps the bookkeeping, never replaces it.
188
+ *
189
+ * Contamination: a pooled claude sees the previous task's turns. Every task
190
+ * turn starts with POOL_TASK_BOUNDARY — a system-reminder line naming the
191
+ * task and forbidding references to prior turns — and the worker contract
192
+ * already forbids restating; the boundary is the measured-in risk dial.
193
+ *
194
+ * Cwd: a slot process has ONE cwd, fixed at spawn, and the slot key is
195
+ * (provider, model, cwd): a run in /repo pools into a slot living in /repo,
196
+ * exactly where a fresh spawn would have run. Runs that need their own
197
+ * worktree (writable tools in a git cwd, the default) pool only on loopback
198
+ * providers, through slot worktrees (slots.ts): each slot owns ONE persistent
199
+ * worktree that is the process cwd for its whole life, every task gets a new
200
+ * branch in it and a fresh conversation, so the system prompt (which names the
201
+ * cwd) is byte-identical across tasks and stays in the server's prefix cache.
202
+ */
203
+ const DEFAULT_POOL_SETTINGS = {
204
+ enabled: false,
205
+ maxSlots: 3,
206
+ idleSecs: 900,
207
+ port: 8798,
208
+ native: true
209
+ };
210
+ function poolSettings(config) {
211
+ const p = config.pool;
212
+ return {
213
+ enabled: p?.enabled === true,
214
+ maxSlots: p?.maxSlots ?? DEFAULT_POOL_SETTINGS.maxSlots,
215
+ idleSecs: p?.idleSecs ?? DEFAULT_POOL_SETTINGS.idleSecs,
216
+ port: p?.port ?? DEFAULT_POOL_SETTINGS.port,
217
+ native: p?.native !== false
218
+ };
219
+ }
220
+ function poolDir(logDir) {
221
+ return join(logDir, "pool");
222
+ }
223
+ function poolStatePath(logDir) {
224
+ return join(poolDir(logDir), "state.json");
225
+ }
226
+ function poolMetricsPath(logDir) {
227
+ return join(poolDir(logDir), "metrics.jsonl");
228
+ }
229
+ function poolPidPath(logDir) {
230
+ return join(poolDir(logDir), "host.pid");
231
+ }
232
+ function readPoolState(logDir) {
233
+ try {
234
+ return JSON.parse(readFileSync(poolStatePath(logDir), "utf8"));
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+ function readPoolMetrics(file) {
240
+ if (!existsSync(file)) return [];
241
+ const rows = [];
242
+ for (const l of readFileSync(file, "utf8").split("\n")) {
243
+ if (!l) continue;
244
+ try {
245
+ const v = JSON.parse(l);
246
+ if (typeof v.ts === "string" && typeof v.id === "string") rows.push(v);
247
+ } catch {}
248
+ }
249
+ return rows;
250
+ }
251
+ function poolCarry(rows) {
252
+ let read = 0;
253
+ let write = 0;
254
+ let fresh = 0;
255
+ for (const r of rows) {
256
+ read += r.cache_read_input_tokens ?? 0;
257
+ write += r.cache_creation_input_tokens ?? 0;
258
+ fresh += r.input_tokens ?? 0;
259
+ }
260
+ const denom = read + write + fresh;
261
+ return {
262
+ tasks: rows.length,
263
+ tokensCarried: read,
264
+ hitRatio: denom ? read / denom : 0
265
+ };
266
+ }
267
+ function poolCarryByProvider(rows) {
268
+ const groups = /* @__PURE__ */ new Map();
269
+ for (const r of rows) groups.set(r.provider, [...groups.get(r.provider) ?? [], r]);
270
+ const out = [];
271
+ for (const [provider, rs] of groups) {
272
+ const reported = rs.filter((r) => (r.cache_read_input_tokens ?? 0) + (r.cache_creation_input_tokens ?? 0) > 0);
273
+ if (reported.length) {
274
+ out.push({
275
+ provider,
276
+ tasks: reported.length,
277
+ hitRatio: poolCarry(reported).hitRatio,
278
+ server: false
279
+ });
280
+ continue;
281
+ }
282
+ const shares = rs.flatMap((r) => typeof r.firstShare === "number" ? [r.firstShare] : []);
283
+ if (shares.length) out.push({
284
+ provider,
285
+ tasks: shares.length,
286
+ hitRatio: shares.reduce((a, b) => a + b, 0) / shares.length,
287
+ server: true
288
+ });
289
+ }
290
+ return out;
291
+ }
292
+ /** "anthropic 42% (5 tasks) · local 69% (63 tasks)", or "n/a" with no reporting rows. */
293
+ function formatCarryByProvider(rows) {
294
+ const parts = poolCarryByProvider(rows).map((c) => `${c.provider} ${Math.round(c.hitRatio * 100)}% (${c.tasks} task${c.tasks === 1 ? "" : "s"}${c.server ? ", server" : ""})`);
295
+ return parts.length ? parts.join(" · ") : "n/a";
296
+ }
297
+ /** The tool grant every slot is born with (== DEFAULT_WORKER_TOOLS in run.ts). */
298
+ const SLOT_TOOLS = [
299
+ "Read",
300
+ "Edit",
301
+ "Write",
302
+ "Bash",
303
+ "Grep",
304
+ "Glob"
305
+ ];
306
+ /**
307
+ * A caller's --allowedTools is a deliberate restriction; a slot has the
308
+ * default grant fixed at spawn. Only "no grant" or exactly the default set
309
+ * gives the run what it asked for (MCP grants are screened by the mcp rule).
310
+ */
311
+ function toolGrantMatchesSlot(allowedTools) {
312
+ const names = allowedTools.flatMap((e) => e.split(",")).map((x) => x.trim()).filter((x) => x && !x.startsWith("mcp__"));
313
+ if (!names.length) return true;
314
+ const set = new Set(names);
315
+ return set.size === SLOT_TOOLS.length && SLOT_TOOLS.every((t) => set.has(t));
316
+ }
317
+ /**
318
+ * Why this run may not use the pool, or null when it may. Every reason is a
319
+ * hard incompatibility with a slot whose system prompt, MCP config, tool
320
+ * grants and cwd were fixed at spawn — the run would silently get a
321
+ * different setup than the fresh spawn it asked for.
322
+ */
323
+ function poolIneligibleReason(input) {
324
+ if (!input.headless || input.prompt === null) return "interactive";
325
+ if ((input.providerName === ANTHROPIC_NATIVE || input.provider.native) && input.nativeAllowed === false) return "native-provider";
326
+ if ((input.provider.engine ?? "claude") !== "claude") return "engine";
327
+ if (input.worktreeWanted && !input.slotWorktree) return "worktree";
328
+ if (input.resumeOf !== void 0) return "resume";
329
+ if (input.printCmd === true) return "print-cmd";
330
+ if (input.parsed.callerSystemPrompt) return "system-prompt";
331
+ if (input.parsed.callerMcpConfig || input.mcpFlag || input.parsed.mcp.length) return "mcp";
332
+ if (mcpServersFromToolGrants(input.parsed.allowedTools).length) return "mcp";
333
+ if (!input.noBrowser && grantsChrome(input.parsed.allowedTools)) return "browser";
334
+ if (input.outputFormat === "stream-json") return "stream-json";
335
+ if (!toolGrantMatchesSlot(input.parsed.allowedTools)) return "tools";
336
+ if (input.cwd === void 0 || !existsSync(input.cwd)) return "cwd";
337
+ return null;
338
+ }
339
+ /** Marker lines a spec uses to set its shared preamble apart from the per-unit part. */
340
+ const SHARED_MARKER = "--- shared ---";
341
+ const UNIT_MARKER = "--- unit ---";
342
+ /** The pool host's HTTP health shape. */
343
+ function poolPort(config) {
344
+ return config.pool?.port ?? 8798;
345
+ }
346
+ /**
347
+ * Post one task to the pool host. Resolves null when the host is not
348
+ * listening (callers fall back to a fresh spawn); resolves a PoolTaskResponse
349
+ * otherwise. No undici here: the host holds the connection for the whole task
350
+ * (minutes), which Node's fetch would cut off with its 300 s headers timeout.
351
+ */
352
+ function postTaskToPool(port, task, timeoutMs) {
353
+ return new Promise((resolve) => {
354
+ const body = JSON.stringify(task);
355
+ const req = request({
356
+ host: "127.0.0.1",
357
+ port,
358
+ path: "/task",
359
+ method: "POST",
360
+ headers: {
361
+ "content-type": "application/json",
362
+ "content-length": Buffer.byteLength(body)
363
+ }
364
+ }, (res) => {
365
+ const chunks = [];
366
+ res.on("data", (c) => chunks.push(c));
367
+ res.on("end", () => {
368
+ try {
369
+ resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
370
+ } catch {
371
+ resolve(null);
372
+ }
373
+ });
374
+ res.on("error", () => resolve(null));
375
+ });
376
+ req.setTimeout(timeoutMs, () => {
377
+ req.destroy();
378
+ resolve(null);
379
+ });
380
+ req.on("error", () => resolve(null));
381
+ req.end(body);
382
+ });
383
+ }
384
+ /** Cheap host probe for `pai worker pool status` and the on/off commands. */
385
+ function poolHostAlive(port, timeoutMs = 500) {
386
+ return new Promise((resolve) => {
387
+ const req = request({
388
+ host: "127.0.0.1",
389
+ port,
390
+ path: "/healthz",
391
+ method: "GET",
392
+ timeout: timeoutMs
393
+ }, (res) => {
394
+ res.resume();
395
+ res.on("end", () => resolve(res.statusCode === 200));
396
+ res.on("error", () => resolve(false));
397
+ });
398
+ req.on("timeout", () => {
399
+ req.destroy();
400
+ resolve(false);
401
+ });
402
+ req.on("error", () => resolve(false));
403
+ req.end();
404
+ });
405
+ }
406
+ /** "warm" when most of the first request came from the prefix cache. */
407
+ function warmOrCold(share) {
408
+ return share == null ? null : share >= .8 ? "warm" : "cold";
409
+ }
410
+
411
+ //#endregion
412
+ export { writeMcpConfig as S, describeMcp as _, poolDir as a, mcpServersFromToolGrants as b, poolMetricsPath as c, poolSettings as d, postTaskToPool as f, inputSideTokens as g, warmOrCold as h, poolCarry as i, poolPidPath as l, readPoolState as m, UNIT_MARKER as n, poolHostAlive as o, readPoolMetrics as p, formatCarryByProvider as r, poolIneligibleReason as s, SHARED_MARKER as t, poolPort as u, expandMcpNames as v, readMcpServers as x, grantsChrome as y };
413
+ //# sourceMappingURL=pool-CRpTNNWq.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pool-CRpTNNWq.mjs","names":["httpRequest"],"sources":["../src/workers/mcp.ts","../src/utils/input-side-tokens.ts","../src/workers/pool.ts"],"sourcesContent":["/**\n * mcp.ts — the MCP allowlist for headless workers.\n *\n * Headless workers start with NO MCP servers: every server definition is a\n * prompt-time tool inventory the model pays to know, and a worker that only\n * reads and edits files needs none of it. A run may opt in with `--mcp\n * name[,name…]`, a role carrying `\"mcp\": [...]`, or implicitly by naming\n * `mcp__server__tool` in --allowedTools (a grant without its server loaded is\n * a dead letter). Names may be single servers from ~/.claude.json's\n * `mcpServers` or `workers.mcpSets` set names, which expand to their member\n * list. The filtered config lands in\n * `<logDir>/<id>.mcp.json` and is passed with `--strict-mcp-config\n * --mcp-config` so exactly those servers load. MCP servers are chosen at\n * launch only — a mid-run `say` cannot add any.\n *\n * One name in that space is not a server at all: `claude-in-chrome` is the\n * Chrome native-host bridge, which no config file can load and which the\n * `--chrome` flag switches on instead. It is recognised here so a grant\n * naming it becomes that flag rather than an \"unknown MCP server\" rejection.\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { WorkersConfigError, type WorkersConfig } from \"./config.js\";\n\n/** ~/.claude.json — the user's MCP server definitions (top-level mcpServers). */\nexport const CLAUDE_JSON = join(homedir(), \".claude.json\");\n\n/**\n * The browser bridge is not an MCP server. It rides the Chrome native-host\n * channel, so it never appears in `mcpServers`; a spawned claude has it off\n * and the `--chrome` flag switches it on. Tool grants naming it must\n * therefore neither resolve to a server nor be rejected as unknown — they\n * select a flag. See `grantsChrome`.\n */\nexport const CHROME_SERVER = \"claude-in-chrome\";\n\n/**\n * True for a real server definition, false for anything else that happens to\n * sit under `mcpServers`.\n *\n * The vendor config is not ours and has been observed carrying non-server\n * entries under that key — tool-usage records keyed by tool name\n * (`Read`, `Bash`, `mcp__server__tool`, …). Those are not servers: handing\n * one to `--mcp-config` yields \"invalid MCP server config\", and listing one\n * as available invites a run that cannot work. A server has a `command` (or a\n * `url` for remote transports); an `mcp__…` key is a tool name whatever its\n * shape.\n */\nfunction isServerDefinition(name: string, value: unknown): boolean {\n if (name.startsWith(\"mcp__\")) return false;\n if (typeof value !== \"object\" || value === null || Array.isArray(value)) return false;\n const v = value as Record<string, unknown>;\n return (\n (typeof v.command === \"string\" && v.command.length > 0) ||\n (typeof v.url === \"string\" && v.url.length > 0)\n );\n}\n\nexport function readMcpServers(claudeJson = CLAUDE_JSON): Record<string, unknown> {\n try {\n if (!existsSync(claudeJson)) return {};\n const parsed = JSON.parse(readFileSync(claudeJson, \"utf8\")) as Record<string, unknown>;\n const servers = parsed.mcpServers;\n if (typeof servers !== \"object\" || servers === null || Array.isArray(servers)) return {};\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {\n if (isServerDefinition(name, value)) out[name] = value;\n }\n return out;\n } catch {\n // a damaged ~/.claude.json must not take workers down with it\n return {};\n }\n}\n\n/**\n * True when any `--mcp` name or `--allowedTools` grant asks for the browser\n * bridge: the bare server name, `mcp__claude-in-chrome`, or any\n * `mcp__claude-in-chrome__<tool>`. The runner turns this into `--chrome` on\n * the claude argv.\n */\nexport function grantsChrome(entries: string[]): boolean {\n for (const entry of entries) {\n for (const name of entry.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (name === CHROME_SERVER) return true;\n if (name === `mcp__${CHROME_SERVER}`) return true;\n if (name.startsWith(`mcp__${CHROME_SERVER}__`)) return true;\n }\n }\n return false;\n}\n\n/**\n * Split a `--mcp a,b,c` flag value (also accepts repeated flags already split\n * by the caller) and expand set names from workers.mcpSets.\n */\nexport function expandMcpNames(\n names: string[],\n config: Pick<WorkersConfig, \"mcpSets\">,\n claudeJson = CLAUDE_JSON\n): string[] {\n const available = readMcpServers(claudeJson);\n const out: string[] = [];\n for (const raw of names) {\n for (const name of raw.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (name === CHROME_SERVER) continue; // a flag, not a server — see grantsChrome\n if (name in config.mcpSets) {\n for (const member of config.mcpSets[name]) {\n if (!out.includes(member)) out.push(member);\n }\n continue;\n }\n if (!(name in available)) {\n const sets = Object.keys(config.mcpSets);\n throw new WorkersConfigError(\n `unknown MCP server \"${name}\". Available servers: ` +\n `${Object.keys(available).join(\", \") || \"(none in ~/.claude.json)\"}` +\n `${sets.length ? `; sets: ${sets.join(\", \")}` : \"\"}`\n );\n }\n if (!out.includes(name)) out.push(name);\n }\n }\n return out;\n}\n\n/**\n * Derive server names from tool grants: every `mcp__<server>__<tool>` (or bare\n * `mcp__<server>`, or `mcp__<server>__*`) in an --allowedTools list names a\n * server the run expects to be loaded. A grant is a dead letter unless its\n * server is in the filtered config, so the runner treats these as implicit\n * --mcp names — and only these; non-mcp grants load nothing.\n */\nexport function mcpServersFromToolGrants(tools: string[]): string[] {\n const out: string[] = [];\n for (const entry of tools) {\n for (const name of entry.split(\",\").map((s) => s.trim()).filter(Boolean)) {\n if (!name.startsWith(\"mcp__\")) continue;\n const server = name.slice(\"mcp__\".length).split(\"__\")[0];\n if (server === CHROME_SERVER) continue; // a flag, not a server — see grantsChrome\n if (server && !out.includes(server)) out.push(server);\n }\n }\n return out;\n}\n\n/** Path of a run's filtered MCP config. */\nexport function runMcpConfigPath(logDir: string, id: string): string {\n return join(logDir, `${id}.mcp.json`);\n}\n\n/**\n * Write a config containing only `names` (already expanded) and return its\n * path. Unknown names fail fast with the available list.\n */\nexport function writeMcpConfig(\n logDir: string,\n id: string,\n names: string[],\n claudeJson = CLAUDE_JSON\n): string {\n const available = readMcpServers(claudeJson);\n const unknown = names.filter((n) => !(n in available));\n if (unknown.length) {\n throw new WorkersConfigError(\n `unknown MCP server(s): ${unknown.join(\", \")}. Available: ` +\n `${Object.keys(available).join(\", \") || \"(none in ~/.claude.json)\"}`\n );\n }\n const servers: Record<string, unknown> = {};\n for (const n of names) servers[n] = available[n];\n const path = runMcpConfigPath(logDir, id);\n if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });\n writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2) + \"\\n\", \"utf8\");\n return path;\n}\n\n/** `pai worker mcp list`: the servers and sets a run could name. */\nexport function describeMcp(config: Pick<WorkersConfig, \"mcpSets\">, claudeJson = CLAUDE_JSON): string[] {\n const servers = Object.keys(readMcpServers(claudeJson));\n const lines: string[] = [];\n if (servers.length) {\n lines.push(`servers (~/.claude.json):`);\n for (const s of servers) lines.push(` ${s}`);\n } else {\n lines.push(`no MCP servers defined in ~/.claude.json`);\n }\n const sets = Object.entries(config.mcpSets);\n if (sets.length) {\n lines.push(`sets (workers.mcpSets):`);\n for (const [name, members] of sets) lines.push(` ${name} = ${members.join(\", \")}`);\n }\n lines.push(`usage: pai worker run --mcp <name>[,<name>…] -p '<task>'`);\n return lines;\n}\n","/**\n * Leaf module (no imports): hook bundles use it, so it must never reach the\n * storage / embedding stack that workers/run.ts pulls in.\n */\n\nexport interface UsageBlock {\n input_tokens?: number;\n output_tokens?: number;\n cache_read_input_tokens?: number;\n cache_creation_input_tokens?: number;\n}\n\n/** Input-side tokens of one usage block: input + cache read + cache creation (no output). */\nexport function inputSideTokens(u: UsageBlock): number {\n return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);\n}\n","/**\n * pool.ts — the worker session pool: one long-lived headless claude per\n * (provider, model) for successive small `pai worker run` tasks, so the\n * provider-side prompt cache carries over between them.\n *\n * Evidence (2026-10-16, router-usage.jsonl): 158 of 161 glm sessions carry\n * exactly ONE request — every `pai worker run` spawns a fresh claude, sends\n * one giant prompt, exits. The provider cache can only serve a fresh spawn\n * within its TTL, so the effective hit ratio on first requests is ~13%\n * (continuous sessions: 98%). A warm slot turns the next task into the\n * SECOND request of a continuous session instead.\n *\n * Architecture (mirrors the openai proxy, src/workers/proxy/server.ts):\n * - `pai worker pool on` starts a detached host (standalone/pool-host.ts,\n * bundled to dist/hooks/worker-pool-host.mjs) that owns the slot\n * processes and listens on a loopback port.\n * - runWorker, behind workers.pool.enabled, routes eligible runs to the\n * host (postTaskToPool); when the host answers `pooled`, the task ran as\n * one more user turn of a pooled claude and the result comes back as\n * JSON. Host down or task ineligible → the normal fresh spawn.\n * - Each pooled task keeps its full worker identity: own id, status file,\n * <id>.jsonl event mirror, ledger lines, AG2 contract and report\n * validation — the pool wraps the bookkeeping, never replaces it.\n *\n * Contamination: a pooled claude sees the previous task's turns. Every task\n * turn starts with POOL_TASK_BOUNDARY — a system-reminder line naming the\n * task and forbidding references to prior turns — and the worker contract\n * already forbids restating; the boundary is the measured-in risk dial.\n *\n * Cwd: a slot process has ONE cwd, fixed at spawn, and the slot key is\n * (provider, model, cwd): a run in /repo pools into a slot living in /repo,\n * exactly where a fresh spawn would have run. Runs that need their own\n * worktree (writable tools in a git cwd, the default) pool only on loopback\n * providers, through slot worktrees (slots.ts): each slot owns ONE persistent\n * worktree that is the process cwd for its whole life, every task gets a new\n * branch in it and a fresh conversation, so the system prompt (which names the\n * cwd) is byte-identical across tasks and stays in the server's prefix cache.\n */\n\nimport { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { request as httpRequest, type IncomingMessage } from \"node:http\";\nimport { dirname, join } from \"node:path\";\nimport { ANTHROPIC_NATIVE, type WorkersConfig, type WorkerProvider } from \"./config.js\";\nimport { grantsChrome, mcpServersFromToolGrants } from \"./mcp.js\";\nimport type { ParsedRunnerArgs } from \"./args.js\";\nimport type { StreamEvent } from \"./run.js\";\nimport { inputSideTokens, type UsageBlock } from \"../utils/input-side-tokens.js\";\nimport type { WorkerReport } from \"./report.js\";\nimport type { WorkerStatus } from \"./status.js\";\n\n// ---------------------------------------------------------------------------\n// Config-facing helpers\n// ---------------------------------------------------------------------------\n\n/** The pool section of the workers config, with its defaults applied. { enabled, maxSlots, idleSecs, port } */\nexport interface PoolSettings {\n enabled: boolean;\n /** Maximum slot processes the host keeps, across all (provider, model) keys. */\n maxSlots: number;\n /** Idle seconds before the host kills a slot (0 = never); provider cache TTL makes longer pointless. */\n idleSecs: number;\n /** Loopback port the host listens on. */\n port: number;\n /** Native anthropic runs may use pooled slots (worktree runs never do). */\n native: boolean;\n}\n\nexport const DEFAULT_POOL_SETTINGS: PoolSettings = {\n enabled: false,\n maxSlots: 3,\n idleSecs: 900,\n port: 8798,\n native: true,\n};\n\nexport function poolSettings(config: WorkersConfig): PoolSettings {\n const p = config.pool;\n return {\n enabled: p?.enabled === true,\n maxSlots: p?.maxSlots ?? DEFAULT_POOL_SETTINGS.maxSlots,\n idleSecs: p?.idleSecs ?? DEFAULT_POOL_SETTINGS.idleSecs,\n port: p?.port ?? DEFAULT_POOL_SETTINGS.port,\n native: p?.native !== false,\n };\n}\n\n// ---------------------------------------------------------------------------\n// On-disk state (under <logDir>/pool/)\n// ---------------------------------------------------------------------------\n\n/** One pooled slot: a long-lived claude process keyed by provider+model+cwd. */\nexport interface PoolSlotRecord {\n provider: string;\n model: string;\n /** The slot's fixed working directory (the host's neutral dir). */\n cwd: string;\n pid: number;\n /** Claude session id from the slot's init event (`pai worker resume` can reach it). */\n claudeSession: string | null;\n /** Context window the slot's init event announced, when it did. */\n contextWindow?: number | null;\n spawnedAt: string;\n lastUsedAt: string;\n /** Tasks the slot has served. */\n tasks: number;\n /** While a task is mid-turn the slot is exclusive to it. */\n busyWith: string | null;\n /** Slot-worktree slots: the branch of the current (or last) task. */\n branch?: string | null;\n /** Cached share of the last task's first request: 1 = whole prefix reused, ~0 = cold. */\n firstShare?: number | null;\n}\n\nexport interface PoolState {\n hostPid: number;\n port: number;\n startedAt: string;\n updatedAt: string;\n slots: PoolSlotRecord[];\n /** Pooled tasks served since the host started. */\n tasksServed: number;\n /** cache_read tokens seen on the first request of each pooled task. */\n tokensCarried: number;\n}\n\nexport function poolDir(logDir: string): string {\n return join(logDir, \"pool\");\n}\n\nexport function poolStatePath(logDir: string): string {\n return join(poolDir(logDir), \"state.json\");\n}\n\nexport function poolMetricsPath(logDir: string): string {\n return join(poolDir(logDir), \"metrics.jsonl\");\n}\n\nexport function poolPidPath(logDir: string): string {\n return join(poolDir(logDir), \"host.pid\");\n}\n\nexport function readPoolState(logDir: string): PoolState | null {\n try {\n return JSON.parse(readFileSync(poolStatePath(logDir), \"utf8\")) as PoolState;\n } catch {\n return null;\n }\n}\n\n/** Persist the host's state (advisory file: a torn write costs a status read, not a task). */\nexport function writePoolState(state: PoolState, path: string): void {\n try {\n mkdirSync(dirname(path), { recursive: true });\n writeFileSync(path, JSON.stringify(state, null, 2), \"utf8\");\n } catch {\n /* status is best effort */\n }\n}\n\n/** One pooled task's cache-carry record (first request of the task's turn). */\nexport interface PoolMetricRow {\n ts: string;\n id: string;\n provider: string;\n model: string;\n /** Task index within its slot (1 = the task that spawned the slot). */\n task: number;\n slotSession: string | null;\n input_tokens: number;\n output_tokens: number;\n cache_creation_input_tokens: number;\n cache_read_input_tokens: number;\n /** Loopback providers: the server-side first-request share (the model itself reports no cache tokens). */\n firstShare?: number;\n}\n\n/** Append one task's carry record; accounting must never break a run. */\nexport function appendPoolMetric(row: PoolMetricRow, file: string): void {\n try {\n mkdirSync(dirname(file), { recursive: true });\n appendFileSync(file, JSON.stringify(row) + \"\\n\", \"utf8\");\n } catch {\n /* best effort */\n }\n}\n\nexport function readPoolMetrics(file: string): PoolMetricRow[] {\n if (!existsSync(file)) return [];\n const rows: PoolMetricRow[] = [];\n for (const l of readFileSync(file, \"utf8\").split(\"\\n\")) {\n if (!l) continue;\n try {\n const v = JSON.parse(l) as PoolMetricRow;\n if (typeof v.ts === \"string\" && typeof v.id === \"string\") rows.push(v);\n } catch {\n /* skip torn line */\n }\n }\n return rows;\n}\n\n/**\n * Cache-carry summary over pooled task records: how many tasks ran pooled\n * and how many tokens their FIRST request read from the provider cache —\n * the carry-over a fresh spawn would have missed.\n */\nexport interface PoolCarry {\n tasks: number;\n tokensCarried: number;\n /** cache_read / (read + write + fresh input) over the pooled first requests. */\n hitRatio: number;\n}\n\nexport function poolCarry(rows: PoolMetricRow[]): PoolCarry {\n let read = 0;\n let write = 0;\n let fresh = 0;\n for (const r of rows) {\n read += r.cache_read_input_tokens ?? 0;\n write += r.cache_creation_input_tokens ?? 0;\n fresh += r.input_tokens ?? 0;\n }\n const denom = read + write + fresh;\n return { tasks: rows.length, tokensCarried: read, hitRatio: denom ? read / denom : 0 };\n}\n\n/** First-request carry of one provider: token-based for cloud providers, server-side share for local ones. */\nexport interface ProviderCarry {\n provider: string;\n /** Rows that carry a number (zero-report rows are dropped). */\n tasks: number;\n hitRatio: number;\n /** True when hitRatio is the mean router/server share rather than cache tokens. */\n server: boolean;\n}\n\nexport function poolCarryByProvider(rows: PoolMetricRow[]): ProviderCarry[] {\n const groups = new Map<string, PoolMetricRow[]>();\n for (const r of rows) groups.set(r.provider, [...(groups.get(r.provider) ?? []), r]);\n const out: ProviderCarry[] = [];\n for (const [provider, rs] of groups) {\n const reported = rs.filter(\n (r) => (r.cache_read_input_tokens ?? 0) + (r.cache_creation_input_tokens ?? 0) > 0\n );\n if (reported.length) {\n out.push({ provider, tasks: reported.length, hitRatio: poolCarry(reported).hitRatio, server: false });\n continue;\n }\n const shares = rs.flatMap((r) => (typeof r.firstShare === \"number\" ? [r.firstShare] : []));\n if (shares.length) {\n out.push({ provider, tasks: shares.length, hitRatio: shares.reduce((a, b) => a + b, 0) / shares.length, server: true });\n }\n }\n return out;\n}\n\n/** \"anthropic 42% (5 tasks) · local 69% (63 tasks)\", or \"n/a\" with no reporting rows. */\nexport function formatCarryByProvider(rows: PoolMetricRow[]): string {\n const parts = poolCarryByProvider(rows).map(\n (c) => `${c.provider} ${Math.round(c.hitRatio * 100)}% (${c.tasks} task${c.tasks === 1 ? \"\" : \"s\"}${c.server ? \", server\" : \"\"})`\n );\n return parts.length ? parts.join(\" · \") : \"n/a\";\n}\n\n// ---------------------------------------------------------------------------\n// Eligibility — which runs may go through the pool at all\n// ---------------------------------------------------------------------------\n\nexport interface PoolEligibilityInput {\n headless: boolean;\n prompt: string | null;\n /** Resolved provider name (\"anthropic\" for the built-in). */\n providerName: string;\n provider: Pick<WorkerProvider, \"native\" | \"engine\" | \"protocol\">;\n /** workers.pool.native; undefined = allowed. */\n nativeAllowed?: boolean;\n /**\n * The run will get its own git worktree (worktreeWanted: flag first, then\n * writable tools in a git cwd). A worktree is a fresh directory per run, so\n * a long-lived slot cannot serve it.\n */\n worktreeWanted: boolean;\n /**\n * The provider can serve worktree runs through slot worktrees (slots.ts):\n * loopback providers only. A worktree run is then pool-eligible.\n */\n slotWorktree?: boolean;\n resumeOf: string | undefined;\n printCmd: boolean | undefined;\n parsed: Pick<ParsedRunnerArgs, \"callerSystemPrompt\" | \"callerMcpConfig\" | \"mcp\" | \"allowedTools\">;\n /** Caller's --mcp flag value, when given. */\n mcpFlag: string | undefined;\n noBrowser: boolean;\n outputFormat: ParsedRunnerArgs[\"outputFormat\"];\n /** Resolved cwd of the run; slots are keyed by it, so any existing directory pools. */\n cwd: string | undefined;\n}\n\nexport type PoolIneligibleReason =\n | \"pool-off\"\n | \"interactive\"\n | \"native-provider\"\n | \"engine\"\n | \"worktree\"\n | \"resume\"\n | \"print-cmd\"\n | \"system-prompt\"\n | \"mcp\"\n | \"browser\"\n | \"stream-json\"\n | \"tools\"\n | \"cwd\";\n\n/** The tool grant every slot is born with (== DEFAULT_WORKER_TOOLS in run.ts). */\nconst SLOT_TOOLS = [\"Read\", \"Edit\", \"Write\", \"Bash\", \"Grep\", \"Glob\"];\n\n/**\n * A caller's --allowedTools is a deliberate restriction; a slot has the\n * default grant fixed at spawn. Only \"no grant\" or exactly the default set\n * gives the run what it asked for (MCP grants are screened by the mcp rule).\n */\nexport function toolGrantMatchesSlot(allowedTools: string[]): boolean {\n const names = allowedTools.flatMap((e) => e.split(\",\")).map((x) => x.trim()).filter((x) => x && !x.startsWith(\"mcp__\"));\n if (!names.length) return true;\n const set = new Set(names);\n return set.size === SLOT_TOOLS.length && SLOT_TOOLS.every((t) => set.has(t));\n}\n\n/**\n * Why this run may not use the pool, or null when it may. Every reason is a\n * hard incompatibility with a slot whose system prompt, MCP config, tool\n * grants and cwd were fixed at spawn — the run would silently get a\n * different setup than the fresh spawn it asked for.\n */\nexport function poolIneligibleReason(input: PoolEligibilityInput): PoolIneligibleReason | null {\n if (!input.headless || input.prompt === null) return \"interactive\";\n // A native run on Claude Code's own login behaves like a fresh spawn inside a\n // slot (same env via buildRunEnv, same slim profile, model fixed in the slot\n // key, identical router pin); only caution kept it out. A worktree run is\n // refused by the \"worktree\" rule below. workers.pool.native=false switches it off.\n if ((input.providerName === ANTHROPIC_NATIVE || input.provider.native) && input.nativeAllowed === false) {\n return \"native-provider\";\n }\n if ((input.provider.engine ?? \"claude\") !== \"claude\") return \"engine\";\n if (input.worktreeWanted && !input.slotWorktree) return \"worktree\";\n if (input.resumeOf !== undefined) return \"resume\";\n if (input.printCmd === true) return \"print-cmd\";\n if (input.parsed.callerSystemPrompt) return \"system-prompt\";\n if (input.parsed.callerMcpConfig || input.mcpFlag || input.parsed.mcp.length) return \"mcp\";\n if (mcpServersFromToolGrants(input.parsed.allowedTools).length) return \"mcp\";\n if (!input.noBrowser && grantsChrome(input.parsed.allowedTools)) return \"browser\";\n if (input.outputFormat === \"stream-json\") return \"stream-json\";\n if (!toolGrantMatchesSlot(input.parsed.allowedTools)) return \"tools\";\n if (input.cwd === undefined || !existsSync(input.cwd)) return \"cwd\";\n return null;\n}\n\n// ---------------------------------------------------------------------------\n// The task boundary — one delimiter per pooled turn\n// ---------------------------------------------------------------------------\n\n/**\n * The delimiter that opens every pooled task turn. A pooled session saw the\n * previous task's context; this line (plus the contract's own no-restating\n * rule) is the boundary: the new task is unrelated, prior turns must not be\n * referenced, and the fixed slot cwd is restated so absolute-path specs stay\n * anchored.\n */\nexport function poolTaskBoundary(taskNo: number, cwd: string): string {\n return (\n `<system-reminder>NEW TASK ${taskNo} — this session is a worker pool slot; the ` +\n `preceding turns were other, unrelated tasks by other workers. Ignore them ` +\n `completely: never reference, reuse or continue anything from them. Your working ` +\n `directory is ${cwd}; relative paths in the spec resolve there. Work only on the task ` +\n `below.</system-reminder>`\n );\n}\n\n/** Marker lines a spec uses to set its shared preamble apart from the per-unit part. */\nexport const SHARED_MARKER = \"--- shared ---\";\nexport const UNIT_MARKER = \"--- unit ---\";\n\n/**\n * Split a spec written as `--- shared ---` … `--- unit ---` … into its shared\n * preamble (goes to the slot's system prompt, inside the cached prefix) and\n * the unit part. Without both markers in that order the spec is unchanged.\n */\nexport function splitSharedSpec(prompt: string): { shared: string | null; unit: string } {\n const lines = prompt.split(\"\\n\");\n const a = lines.findIndex((l) => l.trim() === SHARED_MARKER);\n const b = lines.findIndex((l, i) => i > a && l.trim() === UNIT_MARKER);\n if (a < 0 || b < 0) return { shared: null, unit: prompt };\n const shared = lines.slice(a + 1, b).join(\"\\n\").trim();\n if (!shared) return { shared: null, unit: prompt };\n const head = lines.slice(0, a).join(\"\\n\").trim();\n const unit = [head, lines.slice(b + 1).join(\"\\n\").trim()].filter(Boolean).join(\"\\n\\n\");\n return { shared, unit };\n}\n\n/** The spec followed by the per-unit worktree note (worker id, branch): the stable text leads. */\nexport function pooledUnitPrompt(spec: string, worktreeNote: string): string {\n return worktreeNote ? `${spec}\\n\\n${worktreeNote}\\n\\n` : spec;\n}\n\n/** The full text fed to the slot for one task: boundary + spec + trailer. */\nexport function pooledTaskText(\n taskNo: number,\n cwd: string,\n prompt: string,\n trailer: string\n): string {\n return `${poolTaskBoundary(taskNo, cwd)}\\n\\n${prompt}${trailer}`;\n}\n\n// ---------------------------------------------------------------------------\n// Client: runWorker → pool host\n// ---------------------------------------------------------------------------\n\n/** The task payload a runWorker hands the pool host. */\nexport interface PoolTaskRequest {\n id: string;\n label: string;\n prompt: string;\n provider: string;\n model: string;\n /** The run's working directory; the slot is keyed by and runs in it. */\n cwd: string;\n className?: string;\n capability?: string;\n specPath?: string;\n parent?: string;\n stage?: string;\n /** The `pai worker run` client's pid: killing it cancels the pooled task (the host sees the disconnect). */\n clientPid?: number;\n reportFormat: \"json\" | \"ag2\";\n outputFormat: \"text\" | \"json\";\n /** The run wants its own worktree: served by a slot worktree (loopback providers only). */\n worktree?: boolean;\n /** Task time limit in minutes; null/0 = none. */\n maxMinutes: number | null;\n trailer: string;\n}\n\n/** What the host answers: either the finished task or a refusal to pool it. */\nexport interface PoolTaskResponse {\n pooled: boolean;\n reason?: PoolIneligibleReason | \"busy\" | \"host-error\";\n rc?: number;\n resultEvent?: StreamEvent | null;\n report?: WorkerReport | null;\n status?: WorkerStatus | null;\n}\n\n/** The pool host's HTTP health shape. */\nexport function poolPort(config: WorkersConfig): number {\n return config.pool?.port ?? 8798;\n}\n\n/**\n * Post one task to the pool host. Resolves null when the host is not\n * listening (callers fall back to a fresh spawn); resolves a PoolTaskResponse\n * otherwise. No undici here: the host holds the connection for the whole task\n * (minutes), which Node's fetch would cut off with its 300 s headers timeout.\n */\nexport function postTaskToPool(\n port: number,\n task: PoolTaskRequest,\n timeoutMs: number\n): Promise<PoolTaskResponse | null> {\n return new Promise((resolve) => {\n const body = JSON.stringify(task);\n const req = httpRequest(\n {\n host: \"127.0.0.1\",\n port,\n path: \"/task\",\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"content-length\": Buffer.byteLength(body) },\n },\n (res: IncomingMessage) => {\n const chunks: Buffer[] = [];\n res.on(\"data\", (c: Buffer) => chunks.push(c));\n res.on(\"end\", () => {\n try {\n resolve(JSON.parse(Buffer.concat(chunks).toString(\"utf8\")) as PoolTaskResponse);\n } catch {\n resolve(null);\n }\n });\n res.on(\"error\", () => resolve(null));\n }\n );\n // the timer bounds connect+send only; the response may take the whole task\n req.setTimeout(timeoutMs, () => {\n // a deadline firing here is the task's own limit: the host kills the\n // slot and answers on its own; abandoning the socket is the client's\n // best move and the fresh-spawn fallback is NOT taken mid-task\n req.destroy();\n resolve(null);\n });\n req.on(\"error\", () => resolve(null));\n req.end(body);\n });\n}\n\n/** Cheap host probe for `pai worker pool status` and the on/off commands. */\nexport function poolHostAlive(port: number, timeoutMs = 500): Promise<boolean> {\n return new Promise((resolve) => {\n const req = httpRequest({ host: \"127.0.0.1\", port, path: \"/healthz\", method: \"GET\", timeout: timeoutMs }, (res) => {\n res.resume();\n res.on(\"end\", () => resolve(res.statusCode === 200));\n res.on(\"error\", () => resolve(false));\n });\n req.on(\"timeout\", () => {\n req.destroy();\n resolve(false);\n });\n req.on(\"error\", () => resolve(false));\n req.end();\n });\n}\n\n// ---------------------------------------------------------------------------\n// Host-side task execution (also exercised by the host tests through the\n// createPoolHost deps hook — the same code path the detached process runs)\n// ---------------------------------------------------------------------------\n\n/** Spawning surface of a slot process; injectable so tests run a fake claude. */\nexport interface SlotProcess {\n pid: number;\n write(line: string): boolean;\n kill(signal?: NodeJS.Signals | number): boolean;\n onLine(cb: (line: string) => void): void;\n onExit(cb: (code: number | null) => void): void;\n}\n\n/** How the host obtains a slot process; the real impl spawns `claude`. */\nexport type SlotSpawner = (argv: string[], cwd: string, env: NodeJS.ProcessEnv) => SlotProcess;\n\n/** Hash of the identity a slot is pinned to: provider, model and its cwd. */\nexport function slotKey(provider: string, model: string, cwd: string): string {\n return `${provider}\\u0000${model}\\u0000${cwd}`;\n}\n\n/** Cached share of a first request's prompt tokens (null when it carried no numbers). */\nexport function firstRequestShare(u: { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number }): number | null {\n const read = u.cache_read_input_tokens ?? 0;\n const total = (u.input_tokens ?? 0) + read + (u.cache_creation_input_tokens ?? 0);\n return total > 0 ? read / total : null;\n}\n\n/** Cold prefill speed of the local server (measured), tokens per second. */\nexport const LOCAL_PREFILL_TOK_PER_S = 1100;\n\n/**\n * Cached share of a local first request, inferred from a router usage row: the time to first token\n * against the time a full cold prefill of input_tokens would take. Null for short prompts (under 2000\n * tokens) or rows without numbers.\n */\nexport function routerRowShare(row: { input_tokens?: number; ttftMs?: number }): number | null {\n const n = row.input_tokens ?? 0;\n if (n < 2000 || row.ttftMs == null) return null;\n const coldMs = (n / LOCAL_PREFILL_TOK_PER_S) * 1000;\n const share = Math.min(1, Math.max(0, 1 - row.ttftMs / coldMs));\n return Math.round(share * 1000) / 1000;\n}\n\n/** The share of the FIRST router row (file order) of one claude session; lines are router-usage.jsonl lines. */\nexport function firstRouterShare(lines: string[], sessionId: string): number | null {\n for (const line of lines) {\n let row: { session_id?: string; input_tokens?: number; ttftMs?: number };\n try {\n row = JSON.parse(line);\n } catch {\n continue;\n }\n if (row.session_id === sessionId) return routerRowShare(row);\n }\n return null;\n}\n\n/** \"warm\" when most of the first request came from the prefix cache. */\nexport function warmOrCold(share: number | null | undefined): \"warm\" | \"cold\" | null {\n return share == null ? null : share >= 0.8 ? \"warm\" : \"cold\";\n}\n\n/**\n * Decide which idle slot serves a task, or whether a new slot may spawn.\n * Pure over the slot list so tests can drive it directly.\n */\nexport function pickSlot(\n slots: PoolSlotRecord[],\n key: string,\n maxSlots: number,\n nowMs: number,\n idleSecs: number\n): { slot: PoolSlotRecord | null; evict: PoolSlotRecord | null; canSpawn: boolean } {\n const alive = (s: PoolSlotRecord): boolean => {\n try {\n process.kill(s.pid, 0);\n return true;\n } catch {\n return false;\n }\n };\n const fresh = slots.filter((s) => alive(s));\n const match = fresh.find((s) => slotKey(s.provider, s.model, s.cwd) === key && !s.busyWith);\n if (match) return { slot: match, evict: null, canSpawn: false };\n if (fresh.length < maxSlots) return { slot: null, evict: null, canSpawn: true };\n // at capacity: make room by evicting the longest-idle idle slot — capacity\n // is shared across keys, like connections in a DB pool. A fully busy pool\n // refuses, and the client falls back to a fresh spawn.\n const evict =\n fresh\n .filter((s) => !s.busyWith)\n .sort((a, b) => Date.parse(a.lastUsedAt) - Date.parse(b.lastUsedAt))[0] ?? null;\n return { slot: null, evict, canSpawn: evict !== null };\n}\n\n/** Whether a slot idled long enough to be reaped (idleSecs 0 = never). */\nexport function slotIdleFor(s: PoolSlotRecord, nowMs: number): number {\n return Math.max(0, Math.round((nowMs - Date.parse(s.lastUsedAt)) / 1000));\n}\n\nexport function slotShouldReap(s: PoolSlotRecord, nowMs: number, idleSecs: number): boolean {\n if (idleSecs <= 0 || s.busyWith) return false;\n return slotIdleFor(s, nowMs) >= idleSecs;\n}\n\n/**\n * Pure check: which usage numbers mark a task's first request as cache-carried.\n * The proof bar from the spec: a pooled task's first request should read the\n * bulk of its prompt from cache, where a fresh spawn reads ~13%.\n */\nexport function carriedShare(u: UsageBlock | undefined): number | null {\n if (!u) return null;\n const denom = inputSideTokens(u);\n if (!denom) return null;\n return (u.cache_read_input_tokens ?? 0) / denom;\n}\n"],"mappings":"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"}
@@ -0,0 +1,143 @@
1
+ import { B as readWorkersSection, Ct as fmtTokens, W as writeWorkersSection } from "./config-CH2Xj7BG.mjs";
2
+ import "./config-D7w6FDaM.mjs";
3
+ import { a as poolDir, c as poolMetricsPath, d as poolSettings, i as poolCarry, l as poolPidPath, m as readPoolState, o as poolHostAlive, p as readPoolMetrics, r as formatCarryByProvider, u as poolPort } from "./pool-CRpTNNWq.mjs";
4
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { request } from "node:http";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ //#region src/workers/pool-cli.ts
11
+ /**
12
+ * pool-cli.ts — the `pai worker pool on|off|status` implementations.
13
+ *
14
+ * on flips workers.pool.enabled in the main config and starts the detached
15
+ * host (dist/hooks/worker-pool-host.mjs, same find-and-spawn pattern as the
16
+ * openai proxy); off flips the flag back and shuts the host down; status
17
+ * prints the live state: slots, tasks served, cache-carry total.
18
+ */
19
+ /** Where the standalone host bundle lives (see standaloneProxyPath in proxy/server.ts). */
20
+ function standalonePoolHostPath() {
21
+ let dir = dirname(fileURLToPath(import.meta.url));
22
+ for (let i = 0; i < 6; i++) {
23
+ for (const cand of [
24
+ join(dir, "worker-pool-host.mjs"),
25
+ join(dir, "hooks", "worker-pool-host.mjs"),
26
+ join(dir, "dist", "hooks", "worker-pool-host.mjs")
27
+ ]) if (existsSync(cand)) return cand;
28
+ const parent = dirname(dir);
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ return null;
33
+ }
34
+ function setPoolEnabled(enabled) {
35
+ const { raw, workers } = readWorkersSection();
36
+ workers.pool = {
37
+ ...workers.pool ?? {
38
+ enabled,
39
+ maxSlots: 3,
40
+ idleSecs: 900,
41
+ port: 8798
42
+ },
43
+ enabled
44
+ };
45
+ writeWorkersSection(raw, workers);
46
+ }
47
+ /** POST /shutdown, best effort: a dead host needs no shutdown. */
48
+ function shutdownHost(port) {
49
+ const req = request({
50
+ host: "127.0.0.1",
51
+ port,
52
+ path: "/shutdown",
53
+ method: "POST",
54
+ timeout: 1500
55
+ }, (res) => {
56
+ res.resume();
57
+ });
58
+ req.on("timeout", () => req.destroy());
59
+ req.on("error", () => {});
60
+ req.end();
61
+ }
62
+ async function poolOn(logDir) {
63
+ const { workers } = readWorkersSection();
64
+ const settings = poolSettings(workers);
65
+ const port = settings.port;
66
+ if (await poolHostAlive(port)) {
67
+ setPoolEnabled(true);
68
+ return [`pool host already running on port ${port} — routing enabled`];
69
+ }
70
+ const script = standalonePoolHostPath();
71
+ if (!script) throw new Error(`the pool host build was not found — run \`bun run build\` first, or start one by hand: node dist/hooks/worker-pool-host.mjs --port ${port}`);
72
+ mkdirSync(logDir, { recursive: true });
73
+ mkdirSync(poolDir(logDir), { recursive: true });
74
+ const child = spawn(process.execPath, [
75
+ script,
76
+ "--port",
77
+ String(port)
78
+ ], {
79
+ detached: true,
80
+ stdio: "ignore",
81
+ cwd: logDir
82
+ });
83
+ child.unref();
84
+ writeFileSync(poolPidPath(logDir), `${child.pid}\n`, "utf8");
85
+ for (let i = 0; i < 40; i++) {
86
+ if (await poolHostAlive(port)) {
87
+ setPoolEnabled(true);
88
+ return [
89
+ `pool host started (pid ${child.pid}, port ${port}, cwd ${logDir})`,
90
+ `routing enabled: eligible headless runs (no worktree, no own system prompt/MCP) go through the pool`,
91
+ `slots: max ${settings.maxSlots}, idle kill ${settings.idleSecs}s`
92
+ ];
93
+ }
94
+ await new Promise((r) => setTimeout(r, 250));
95
+ }
96
+ throw new Error(`the pool host did not come up on port ${port} (see ${poolPidPath(logDir)})`);
97
+ }
98
+ async function poolOff(logDir) {
99
+ const { workers } = readWorkersSection();
100
+ const port = poolPort(workers);
101
+ setPoolEnabled(false);
102
+ shutdownHost(port);
103
+ const lines = [`routing disabled — every run spawns fresh again`];
104
+ const pidPath = poolPidPath(logDir);
105
+ if (existsSync(pidPath)) {
106
+ const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
107
+ unlinkSync(pidPath);
108
+ try {
109
+ process.kill(pid, "SIGTERM");
110
+ lines.push(`pool host pid ${pid} stopped`);
111
+ } catch {
112
+ lines.push(`pool host pid ${pid} was not running (pid file removed)`);
113
+ }
114
+ } else lines.push(`no pool host pid file — nothing to stop`);
115
+ return lines;
116
+ }
117
+ /** The `pai worker pool status` text: config, host, slots, carry. */
118
+ async function poolStatusText(logDir) {
119
+ const { workers } = readWorkersSection();
120
+ const settings = poolSettings(workers);
121
+ const port = settings.port;
122
+ const alive = await poolHostAlive(port);
123
+ const lines = [];
124
+ lines.push(`pool: ${settings.enabled ? "enabled" : "disabled"} (config workers.pool; host port ${port}, max ${settings.maxSlots} slots, idle kill ${settings.idleSecs}s)`);
125
+ const state = readPoolState(logDir);
126
+ if (!state) {
127
+ lines.push(alive ? `host: alive on port ${port} (no state file yet)` : `host: not running`);
128
+ return lines.join("\n");
129
+ }
130
+ lines.push(`host: pid ${state.hostPid}, port ${state.port}, up since ${state.startedAt}, ${alive ? "alive" : "NOT answering (state file is stale)"}`);
131
+ if (state.slots.length) {
132
+ lines.push(`slots (${state.slots.length}):`);
133
+ for (const s of state.slots) lines.push(` ${s.provider}/${s.model} pid ${s.pid} session ${(s.claudeSession ?? "?").slice(0, 8)} tasks ${s.tasks} ${s.busyWith ? `busy ${s.busyWith}` : "idle"} last ${s.lastUsedAt}`);
134
+ } else lines.push(`slots: none (a slot spawns with the first pooled task)`);
135
+ const rows = readPoolMetrics(poolMetricsPath(logDir));
136
+ const carry = poolCarry(rows);
137
+ lines.push(`tasks served ${state.tasksServed} · carried ${fmtTokens(carry.tokensCarried)} cache-read tokens · first-request hit ratio ${formatCarryByProvider(rows)}`);
138
+ return lines.join("\n");
139
+ }
140
+
141
+ //#endregion
142
+ export { poolOff, poolOn, poolStatusText };
143
+ //# sourceMappingURL=pool-cli-Dt62g-Sd.mjs.map