@tekmidian/pai 0.42.0 → 0.42.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +1 -1
  2. package/dist/chain-B-DbR82m.mjs +164 -0
  3. package/dist/chain-B-DbR82m.mjs.map +1 -0
  4. package/dist/cli/index.mjs +8 -8
  5. package/dist/cli/program.mjs +8 -8
  6. package/dist/{config-DgzR5sfb.mjs → config-_lQI_W08.mjs} +116 -3
  7. package/dist/config-_lQI_W08.mjs.map +1 -0
  8. package/dist/{context-handover-cache-dUnSZwaA.mjs → context-handover-cache-pNFdBNrU.mjs} +27 -8
  9. package/dist/context-handover-cache-pNFdBNrU.mjs.map +1 -0
  10. package/dist/daemon/index.mjs +7 -7
  11. package/dist/{daemon-CKBkhOsZ.mjs → daemon-Awt_3UJp.mjs} +7 -7
  12. package/dist/{daemon-DsvP8KMy.mjs → daemon-Dq8A1uDF.mjs} +591 -370
  13. package/dist/daemon-Dq8A1uDF.mjs.map +1 -0
  14. package/dist/daemon-mcp/index.mjs +5 -5
  15. package/dist/{fallback-5UJO-RyX.mjs → fallback-vQPI1qxt.mjs} +15 -13
  16. package/dist/fallback-vQPI1qxt.mjs.map +1 -0
  17. package/dist/hooks/mcp-deferred-gate.mjs +286 -0
  18. package/dist/hooks/mcp-deferred-gate.mjs.map +7 -0
  19. package/dist/hooks/mcp-deferred-reminder.mjs +54 -0
  20. package/dist/hooks/mcp-deferred-reminder.mjs.map +7 -0
  21. package/dist/hooks/route-agents-to-worker.mjs +65 -2
  22. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  23. package/dist/hooks/worker-proxy.mjs +64 -1
  24. package/dist/hooks/worker-proxy.mjs.map +2 -2
  25. package/dist/hooks/worker-status-line.mjs +68 -2
  26. package/dist/hooks/worker-status-line.mjs.map +2 -2
  27. package/dist/hooks/worker-supervision.mjs +64 -1
  28. package/dist/hooks/worker-supervision.mjs.map +2 -2
  29. package/dist/{main-resolver-DXGuygFG.mjs → main-resolver-BAfOMppW.mjs} +2 -2
  30. package/dist/{main-resolver-BgIrC-D9.mjs → main-resolver-DnkxPYkr.mjs} +111 -16
  31. package/dist/main-resolver-DnkxPYkr.mjs.map +1 -0
  32. package/dist/{planner-BLDJt3ZS.mjs → planner-BfEGcEs1.mjs} +5 -5
  33. package/dist/{planner-BLDJt3ZS.mjs.map → planner-BfEGcEs1.mjs.map} +1 -1
  34. package/dist/{program-CruRnLtc.mjs → program-BDmeZP-v.mjs} +17 -11
  35. package/dist/{program-CruRnLtc.mjs.map → program-BDmeZP-v.mjs.map} +1 -1
  36. package/dist/{chain-DGXNoTwc.mjs → run-CGXbhf2k.mjs} +422 -195
  37. package/dist/run-CGXbhf2k.mjs.map +1 -0
  38. package/dist/{server-Cp7tNzNC.mjs → server-D4vAieF4.mjs} +49 -8
  39. package/dist/server-D4vAieF4.mjs.map +1 -0
  40. package/dist/{state-BTa10SOM.mjs → state-BRdB-Bf1.mjs} +7 -2
  41. package/dist/state-BRdB-Bf1.mjs.map +1 -0
  42. package/dist/state-CY6I-Hnc.mjs +3 -0
  43. package/dist/{work-queue-worker-D1p2V-ae.mjs → work-queue-worker-AEmfVE0F.mjs} +5 -5
  44. package/dist/{work-queue-worker-7Vkli4Yh.mjs → work-queue-worker-DIT-VLSh.mjs} +3 -3
  45. package/dist/{work-queue-worker-7Vkli4Yh.mjs.map → work-queue-worker-DIT-VLSh.mjs.map} +1 -1
  46. package/docs/cache-keepalive.md +82 -0
  47. package/docs/commands/worker.md +1 -0
  48. package/docs/merge-demo.md +1 -0
  49. package/docs/provider-abstraction.md +52 -2
  50. package/package.json +1 -1
  51. package/plugins/core/hooks/hooks.json +12 -0
  52. package/src/hooks/ts/lib/mcp-deferred-gate.test.ts +258 -0
  53. package/src/hooks/ts/lib/mcp-deferred-gate.ts +261 -0
  54. package/src/hooks/ts/lib/mcp-deferred-reminder.test.ts +90 -0
  55. package/src/hooks/ts/lib/mcp-deferred-reminder.ts +66 -0
  56. package/src/hooks/ts/pre-tool-use/mcp-deferred-gate.ts +126 -0
  57. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +6 -2
  58. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +52 -0
  59. package/statusline-command.sh +149 -72
  60. package/dist/chain-DGXNoTwc.mjs.map +0 -1
  61. package/dist/config-DgzR5sfb.mjs.map +0 -1
  62. package/dist/context-handover-cache-dUnSZwaA.mjs.map +0 -1
  63. package/dist/daemon-DsvP8KMy.mjs.map +0 -1
  64. package/dist/fallback-5UJO-RyX.mjs.map +0 -1
  65. package/dist/main-resolver-BgIrC-D9.mjs.map +0 -1
  66. package/dist/scope-NIwCGxRI.mjs +0 -351
  67. package/dist/scope-NIwCGxRI.mjs.map +0 -1
  68. package/dist/server-Cp7tNzNC.mjs.map +0 -1
  69. package/dist/state-BTa10SOM.mjs.map +0 -1
  70. package/dist/state-RXepvbnO.mjs +0 -3
@@ -1,6 +1,5 @@
1
- import { a as WorkersConfigError, h as resolveModelCapability, l as isModelCapability, m as readWorkersSection, o as assertProviderRunnable, p as providerKeyPath, s as classModelCapability } from "./config-DgzR5sfb.mjs";
2
- import { _ as workersLogDir, d as setCooldown, f as eventsPath, g as statusPath, h as panesDir, i as buildRunEnv, m as noMcpConfigPath, n as ensureProxyRunning, o as isQuotaFailure, p as ledgerPath, s as nextAutoProvider, t as DEFAULT_PROXY_PORT, u as resolveTarget } from "./server-Cp7tNzNC.mjs";
3
- import { S as stripPromptValues, _ as newWorkerId, a as resolveSpawnerSession, b as parseRunnerArgs, d as alive, g as loadStatuses, h as loadStatus, i as resolveSession, l as UNLABELED, m as describeTool, o as scopeKey, r as itermUuid, v as nowStamp, x as shortText, y as saveStatus } from "./scope-NIwCGxRI.mjs";
1
+ import { _ as readWorkersSection, c as classModelCapability, f as isModelCapability, g as providerKeyPath, o as WorkersConfigError, s as assertProviderRunnable, v as resolveModelCapability } from "./config-_lQI_W08.mjs";
2
+ import { _ as workersLogDir, d as setCooldown, f as ensureNoMcpConfig, g as statusPath, h as panesDir, i as buildRunEnv, m as ledgerPath, n as ensureProxyRunning, o as isQuotaFailure, p as eventsPath, s as nextAutoProvider, t as DEFAULT_PROXY_PORT, u as resolveTarget } from "./server-D4vAieF4.mjs";
4
3
  import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
4
  import { homedir, tmpdir } from "node:os";
6
5
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -8,6 +7,100 @@ import { connect, createServer } from "node:net";
8
7
  import { execFileSync, spawn } from "node:child_process";
9
8
  import { createInterface } from "node:readline";
10
9
 
10
+ //#region src/workers/args.ts
11
+ function parseRunnerArgs(argv) {
12
+ let prompt = null;
13
+ let outputFormat = "text";
14
+ const rest = [];
15
+ let headless = false;
16
+ let callerModel = false;
17
+ let callerMcpConfig = false;
18
+ let callerSystemPrompt = false;
19
+ const mcp = [];
20
+ const allowedTools = [];
21
+ let i = 0;
22
+ while (i < argv.length) {
23
+ const a = argv[i];
24
+ if (a === "-p" || a === "--print") {
25
+ headless = true;
26
+ rest.push(a);
27
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
28
+ prompt = argv[i + 1];
29
+ rest.push(prompt);
30
+ i += 1;
31
+ }
32
+ } else if (a === "--output-format") {
33
+ const v = argv[i + 1];
34
+ if (v === "json" || v === "stream-json") outputFormat = v;
35
+ i += 1;
36
+ } else if (a.startsWith("--output-format=")) {
37
+ const v = a.slice(16);
38
+ if (v === "json" || v === "stream-json") outputFormat = v;
39
+ } else if (a === "--verbose") {} else if (a === "--mcp") {
40
+ const v = argv[i + 1];
41
+ if (v !== void 0 && !v.startsWith("-")) {
42
+ mcp.push(v);
43
+ i += 1;
44
+ }
45
+ } else if (a.startsWith("--mcp=")) mcp.push(a.slice(6));
46
+ else if (a === "--allowedTools") {
47
+ rest.push(a);
48
+ const v = argv[i + 1];
49
+ if (v !== void 0 && !v.startsWith("-")) {
50
+ allowedTools.push(v);
51
+ rest.push(v);
52
+ i += 1;
53
+ }
54
+ } else if (a.startsWith("--allowedTools=")) {
55
+ allowedTools.push(a.slice(15));
56
+ rest.push(a);
57
+ } else {
58
+ if (a === "--model") callerModel = true;
59
+ if (a.startsWith("--model=")) callerModel = true;
60
+ if (a === "--mcp-config") callerMcpConfig = true;
61
+ if (a.startsWith("--mcp-config=")) callerMcpConfig = true;
62
+ if (a === "--append-system-prompt") callerSystemPrompt = true;
63
+ if (a.startsWith("--append-system-prompt=")) callerSystemPrompt = true;
64
+ if (prompt === null && !a.startsWith("-") && rest.length > 0 && (rest[rest.length - 1] === "-p" || rest[rest.length - 1] === "--print")) prompt = a;
65
+ rest.push(a);
66
+ }
67
+ i += 1;
68
+ }
69
+ return {
70
+ prompt,
71
+ outputFormat,
72
+ rest,
73
+ headless,
74
+ callerModel,
75
+ callerMcpConfig,
76
+ callerSystemPrompt,
77
+ mcp,
78
+ allowedTools
79
+ };
80
+ }
81
+ /**
82
+ * Turn `-p "<prompt>"` into bare `-p` (same for --print): for stream-json
83
+ * stdin runs the prompt moves to the first user message on stdin, so the
84
+ * value must not stay on the command line. Only prompt values directly
85
+ * following the flag are touched; everything else passes through.
86
+ */
87
+ function stripPromptValues(argv) {
88
+ const out = [];
89
+ for (let i = 0; i < argv.length; i++) {
90
+ const a = argv[i];
91
+ const isPrint = a === "-p" || a === "--print";
92
+ out.push(a);
93
+ if (isPrint && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i += 1;
94
+ }
95
+ return out;
96
+ }
97
+ /** Collapse whitespace and cut to n chars with an ellipsis (label rendering). */
98
+ function shortText(s, n) {
99
+ const t = String(s ?? "").split(/\s+/).filter(Boolean).join(" ");
100
+ return t.length <= n ? t : t.slice(0, n - 1) + "…";
101
+ }
102
+
103
+ //#endregion
11
104
  //#region src/workers/ledger.ts
12
105
  /**
13
106
  * ledger.ts — the routing ledger, one append-only log for every worker event.
@@ -77,6 +170,265 @@ function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()
77
170
  };
78
171
  }
79
172
 
173
+ //#endregion
174
+ //#region src/workers/status.ts
175
+ /**
176
+ * status.ts — the live per-worker status file.
177
+ *
178
+ * One JSON file per run, rewritten atomically on every turn, read by `ps`,
179
+ * `follow`, the status line and the pane logic. Same field set the Python
180
+ * runner wrote, plus `provider` (routing is multi-provider now) and `session`
181
+ * (the AIBroker identity of the launching session, see scope.ts).
182
+ */
183
+ /** Label a worker gets when launched with neither --label nor a prompt. */
184
+ const UNLABELED = "unlabeled";
185
+ /**
186
+ * Whether a status is the terminal's interactive chat pane, not a spawned
187
+ * task worker. `origin` is the real discriminator; the fallback catches
188
+ * running entries from before the flag existed (unlabeled, no turns yet).
189
+ * Removable once every pane runs code that writes `origin`.
190
+ */
191
+ function isChatPane(s) {
192
+ return s.origin === "chat" || !s.origin && (s.label === UNLABELED || s.label === "(no prompt)") && s.turns === 0;
193
+ }
194
+ /** Context-meter percentage 0–100, null when the numbers are missing. */
195
+ function contextPercent(s) {
196
+ if (!s.contextTokens || !s.contextWindow) return null;
197
+ return Math.round(s.contextTokens / s.contextWindow * 100);
198
+ }
199
+ /** Compact token count: 84k, 200k, 900. */
200
+ function fmtContextK(n) {
201
+ return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
202
+ }
203
+ /**
204
+ * The one context-meter label, shared by every renderer so identical
205
+ * numbers always render identically: `ctx 84k/200k (42%)` — used-style
206
+ * percent. Empty when the numbers are missing (callers drop the part).
207
+ */
208
+ function contextLabel(s) {
209
+ const pct = contextPercent(s);
210
+ if (pct === null) return "";
211
+ return `ctx ${fmtContextK(s.contextTokens ?? 0)}/${fmtContextK(s.contextWindow ?? 0)} (${pct}%)`;
212
+ }
213
+ /** Timestamp format shared by status files and the ledger. */
214
+ function nowStamp(d = /* @__PURE__ */ new Date()) {
215
+ const p = (n) => String(n).padStart(2, "0");
216
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
217
+ }
218
+ let lastId = "";
219
+ let idSeq = 0;
220
+ function newWorkerId(d = /* @__PURE__ */ new Date(), pid = process.pid) {
221
+ const p = (n) => String(n).padStart(2, "0");
222
+ const base = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
223
+ if (base === lastId) {
224
+ idSeq += 1;
225
+ return `${base}-${String(idSeq).padStart(2, "0")}`;
226
+ }
227
+ lastId = base;
228
+ idSeq = 0;
229
+ return base;
230
+ }
231
+ /** Write status atomically (temp + rename) and stamp `updated`. */
232
+ function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
233
+ status.updated = nowStamp(d);
234
+ const path = statusPath(logDir, status.id);
235
+ const tmp = `${path}.tmp`;
236
+ writeFileSync(tmp, JSON.stringify(status), "utf8");
237
+ renameSync(tmp, path);
238
+ }
239
+ /** Load every status file in the logDir, oldest id first, skipping damage. */
240
+ function loadStatuses(logDir) {
241
+ if (!existsSync(logDir)) return [];
242
+ const out = [];
243
+ for (const name of readdirSync(logDir).sort()) {
244
+ if (!name.endsWith(".status")) continue;
245
+ try {
246
+ out.push(JSON.parse(readFileSync(join(logDir, name), "utf8")));
247
+ } catch {}
248
+ }
249
+ return out;
250
+ }
251
+ function loadStatus(logDir, id) {
252
+ const path = statusPath(logDir, id);
253
+ if (!existsSync(path)) return null;
254
+ try {
255
+ return JSON.parse(readFileSync(path, "utf8"));
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+ function alive(pid) {
261
+ if (!pid || pid <= 0) return false;
262
+ try {
263
+ process.kill(pid, 0);
264
+ return true;
265
+ } catch {
266
+ return false;
267
+ }
268
+ }
269
+ /** "42s" under 90s, "7m" above — the coarse age the table and bar show. */
270
+ function ageOf(ts, now = /* @__PURE__ */ new Date()) {
271
+ const t = Date.parse(ts.replace(" ", "T"));
272
+ if (Number.isNaN(t)) return "?";
273
+ const s = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
274
+ return s < 90 ? `${s}s` : `${Math.floor(s / 60)}m`;
275
+ }
276
+ /** One-line description of a tool call, e.g. "Bash: npm test". */
277
+ function describeTool(name, inp) {
278
+ if (typeof inp !== "object" || inp === null) return name;
279
+ const i = inp;
280
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
281
+ if (name === "Bash") return `Bash: ${shortText(get("command"), 70)}`;
282
+ if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return `${name}: ${get("file_path").split("/").pop() ?? ""}`;
283
+ if (name === "Grep" || name === "Glob") return `${name}: ${shortText(get("pattern"), 50)}`;
284
+ if (name === "WebSearch") return `${name}: ${shortText(get("query"), 50)}`;
285
+ if (name === "WebFetch") return `${name}: ${shortText(get("url"), 60)}`;
286
+ return name;
287
+ }
288
+
289
+ //#endregion
290
+ //#region src/workers/scope.ts
291
+ /**
292
+ * scope.ts — which workers "belong" to the terminal asking about them.
293
+ *
294
+ * Two identities, in priority order:
295
+ *
296
+ * 1. AIBroker's session registry (~/.aibroker/session-names.json): iTerm
297
+ * session UUID → session name. The launching session's UUID is stored in
298
+ * each worker's status file as `session.id`, so every pane of a named
299
+ * session sees its workers — regardless of tab layout.
300
+ * 2. The iTerm tab key (`w<window>t<tab>` prefix of ITERM_SESSION_ID): the
301
+ * original heuristic, kept as the fallback when no registry entry
302
+ * matches (AIBroker absent, unnamed session, non-iTerm terminal).
303
+ *
304
+ * If neither is available, viewers fall back to "all workers".
305
+ *
306
+ * A third case: workers spawned from a Claude Code Bash tool (the
307
+ * orchestrator pattern) have neither — Claude Code exports no terminal or
308
+ * session identity to its Bash children. Those record `spawnerSession`, the
309
+ * orchestrator's claude session id bridged through the status line's
310
+ * session map (see below), so their tab still claims them.
311
+ */
312
+ const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
313
+ /**
314
+ * `w<window>t<tab>` prefix of an iTerm session id, "" if empty or malformed.
315
+ * Panes split from the same tab share this prefix and differ only in `p<n>`.
316
+ */
317
+ function tabKey(term) {
318
+ if (!term) return "";
319
+ const head = term.split("p", 1)[0];
320
+ const parts = head.slice(1).split("t");
321
+ if (head.startsWith("w") && parts.length === 2 && parts.every((p) => /^\d+$/.test(p))) return head;
322
+ return "";
323
+ }
324
+ /** Tab key of the iTerm tab this process runs in, "" if not inside iTerm2. */
325
+ function currentTabKey(env = process.env) {
326
+ return tabKey(env.ITERM_SESSION_ID ?? "");
327
+ }
328
+ /** The iTerm UUID part of an ITERM_SESSION_ID (after the last colon). */
329
+ function itermUuid(term) {
330
+ if (!term) return "";
331
+ return term.split(":").pop() ?? "";
332
+ }
333
+ /**
334
+ * Resolve the AIBroker session for an ITERM_SESSION_ID. Reads the persistent
335
+ * name registry (the same store `aibroker_rename` writes); returns null when
336
+ * AIBroker is absent or the session is not in it — callers then use the tab
337
+ * key, which is the pre-AIBroker behaviour.
338
+ */
339
+ function resolveSession(term, registryPath = AIBROKER_REGISTRY) {
340
+ const uuid = itermUuid(term);
341
+ if (!uuid) return null;
342
+ let names;
343
+ try {
344
+ if (!existsSync(registryPath)) return null;
345
+ names = JSON.parse(readFileSync(registryPath, "utf8"));
346
+ } catch {
347
+ return null;
348
+ }
349
+ const name = names[uuid];
350
+ if (typeof name !== "string" || !name) return null;
351
+ return {
352
+ id: uuid,
353
+ name
354
+ };
355
+ }
356
+ /**
357
+ * True when `worker` was launched from `term` (the terminal asking about it).
358
+ * Session-id match first; the tab key only when no registry entry matched —
359
+ * a session without a name keeps the tab-scoped behaviour it always had.
360
+ */
361
+ function workerInScope(worker, term) {
362
+ if (!term) return false;
363
+ const uuid = itermUuid(term);
364
+ if (worker.session?.id && uuid) return worker.session.id === uuid;
365
+ return tabKey(worker.term) === tabKey(term) && tabKey(term) !== "";
366
+ }
367
+ /**
368
+ * Registry key for pane files: the session id when AIBroker knows this
369
+ * terminal, else the tab key, else the raw iTerm UUID.
370
+ */
371
+ function scopeKey(term) {
372
+ const session = resolveSession(term);
373
+ if (session) return session.id;
374
+ return currentTabKey() || itermUuid(term);
375
+ }
376
+ /** `[Name]` when the worker has an AIBroker session name, else "". */
377
+ function sessionTag(worker) {
378
+ return worker.session?.name ? `[${worker.session.name}]` : "";
379
+ }
380
+ /** How old a map entry may be for a spawn to adopt it (status lines refresh constantly while a session lives). */
381
+ const SPAWNER_SESSION_TTL_MS = 10 * 6e4;
382
+ /** Map entries not refreshed within this window are pruned on write. */
383
+ const SESSION_MAP_PRUNE_MS = 60 * 6e4;
384
+ function sessionMapPath(logDir) {
385
+ return join(logDir, "claude-session-map.json");
386
+ }
387
+ /**
388
+ * The claude session a new run was spawned by, when it can be known: a run
389
+ * inside another worker inherits its spawner (chain stages keep the
390
+ * orchestrator's tab that way); otherwise a fresh map entry for `cwd`.
391
+ */
392
+ function resolveSpawnerSession(logDir, cwd, env = process.env, now = Date.now()) {
393
+ const parentId = env.PAI_WORKER_ID;
394
+ if (parentId) {
395
+ const inherited = loadStatus(logDir, parentId)?.spawnerSession;
396
+ if (inherited) return inherited;
397
+ }
398
+ const path = sessionMapPath(logDir);
399
+ if (!existsSync(path) || !cwd) return null;
400
+ try {
401
+ const entry = JSON.parse(readFileSync(path, "utf8"))[cwd];
402
+ if (entry && entry.session && now - entry.ts < SPAWNER_SESSION_TTL_MS) return entry.session;
403
+ } catch {}
404
+ return null;
405
+ }
406
+ /**
407
+ * The iTerm UUID a claude session last rendered its status line in, when the
408
+ * map knows one: the freshest entry naming that session. This is the reverse
409
+ * of resolveSpawnerSession — worker → orchestrator there, orchestrator →
410
+ * terminal here — and it exists so daemon-side pushes (supervision) can reach
411
+ * a session that has no iTerm identity of its own in any worker status.
412
+ */
413
+ function itermForClaudeSession(logDir, claudeSession, now = Date.now()) {
414
+ const path = sessionMapPath(logDir);
415
+ if (!existsSync(path) || !claudeSession) return null;
416
+ let map;
417
+ try {
418
+ map = JSON.parse(readFileSync(path, "utf8"));
419
+ } catch {
420
+ return null;
421
+ }
422
+ let best = null;
423
+ for (const e of Object.values(map)) {
424
+ if (e.session !== claudeSession || !e.term) continue;
425
+ if (now - e.ts >= SESSION_MAP_PRUNE_MS) continue;
426
+ if (!best || e.ts > best.ts) best = e;
427
+ }
428
+ const term = best?.term;
429
+ return term ? itermUuid(term) : null;
430
+ }
431
+
80
432
  //#endregion
81
433
  //#region src/workers/pane.ts
82
434
  /**
@@ -87,10 +439,14 @@ function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()
87
439
  * one splits the lowest live worker pane horizontally, so panes stack top to
88
440
  * bottom. The split never sizes the new session — that would grow the whole
89
441
  * window; instead the window's bounds are read before the split and restored
90
- * right after, so the panes share the space the window already had. A split
91
- * would also make the new session the tab's active one — the scripts re-select
92
- * the launching session right after, so a pane opening never steals the
93
- * keystrokes the operator is typing. Each
442
+ * right after, so the panes share the space the window already had. The follow
443
+ * command is part of the split itself — iTerm creates the new session already
444
+ * running it — so no text is ever typed into any session afterwards; a separate
445
+ * typing step raced the operator's keystrokes (focus briefly sits on the new
446
+ * pane after a split, and in one observed failure the command landed in the
447
+ * operator's own shell). A split still makes the new session the tab's active
448
+ * one — the scripts re-select the launching session right after, so a pane
449
+ * opening never steals the keystrokes the operator is typing. Each
94
450
  * pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
95
451
  * dynamic profile (Close Sessions On End), so panes disappear by themselves.
96
452
  *
@@ -145,34 +501,35 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
145
501
  end if
146
502
  end if
147
503
  end repeat
504
+ -- the follow command is part of the split itself: the
505
+ -- pane is born already running it, so no text is ever
506
+ -- typed into any session — a typing step raced the
507
+ -- operator's keystrokes and could even land in the
508
+ -- operator's own session
148
509
  if profileName is "" then
149
510
  if splitS is missing value then
150
511
  tell s
151
- set newS to split vertically with default profile
512
+ set newS to split vertically with default profile command followCmd
152
513
  end tell
153
514
  else
154
515
  tell splitS
155
- set newS to split horizontally with default profile
516
+ set newS to split horizontally with default profile command followCmd
156
517
  end tell
157
518
  end if
158
519
  else
159
520
  if splitS is missing value then
160
521
  tell s
161
- set newS to split vertically with profile profileName
522
+ set newS to split vertically with profile profileName command followCmd
162
523
  end tell
163
524
  else
164
525
  tell splitS
165
- set newS to split horizontally with profile profileName
526
+ set newS to split horizontally with profile profileName command followCmd
166
527
  end tell
167
528
  end if
168
529
  end if
169
- tell newS
170
- write text followCmd
171
- end tell
172
530
  -- a split makes the new session the tab's active one:
173
- -- re-select the launching session so keystrokes keep
174
- -- going where the operator was typing, not into the
175
- -- pane's chat prompt
531
+ -- re-select the launching session so focus returns to
532
+ -- where the operator was typing, never the new pane
176
533
  try
177
534
  select s
178
535
  end try
@@ -253,11 +610,11 @@ const SPLIT_SCRIPT = `on run(argv)
253
610
  repeat with t in tabs of w
254
611
  repeat with s in sessions of t
255
612
  if id of s is targetID then
613
+ -- the follow command is part of the split itself (see
614
+ -- WORKER_SPLIT_SCRIPT): nothing is ever typed into a
615
+ -- session afterwards
256
616
  tell s
257
- set newS to split vertically with default profile
258
- end tell
259
- tell newS
260
- write text followCmd
617
+ set newS to split vertically with default profile command followCmd
261
618
  end tell
262
619
  -- keep the tab's active session where it was (see
263
620
  -- WORKER_SPLIT_SCRIPT)
@@ -490,6 +847,23 @@ function saveRegistry(path, reg) {
490
847
  writeFileSync(tmp, JSON.stringify(reg, null, 1), "utf8");
491
848
  renameSync(tmp, path);
492
849
  }
850
+ /**
851
+ * The command a worker pane runs: one exec-able line with no shell syntax —
852
+ * iTerm's split command parameter execs without a shell (proven live: panes
853
+ * survive `sleep 30` and a single-quoted `/bin/sh -c`, die instantly on
854
+ * `export …; …` or a redirect), so the command must be one exec-able line.
855
+ * Absolute node on the CLI entry file also sidesteps the panes' bare launchd
856
+ * PATH (no pai, no node) and the `#!/usr/bin/env node` shebang.
857
+ */
858
+ function followCommand(wid, autoExitSecs) {
859
+ return `${process.execPath} ${absoluteCliPath()} worker follow ${wid} --auto-exit ${autoExitSecs}`;
860
+ }
861
+ /** Absolute path of the running pai CLI; falls back to PATH lookup at spawn. */
862
+ function absoluteCliPath() {
863
+ const argv1 = process.argv[1] ?? "";
864
+ if (argv1.endsWith("pai")) return argv1;
865
+ return "pai";
866
+ }
493
867
  /** Open (or only report on) the stacked follow pane of one worker. */
494
868
  async function openPaneForWorker(logDir, config, wid, term) {
495
869
  if (workerPaneOpen(wid)) return `pane for ${wid} already open`;
@@ -497,7 +871,7 @@ async function openPaneForWorker(logDir, config, wid, term) {
497
871
  const regPath = join(panesDir(logDir), `${scopeKey(term)}.json`);
498
872
  const reg = loadRegistry(regPath);
499
873
  const profile = await followProfile(config.pane.fontSize);
500
- const cmd = ` exec pai worker follow ${JSON.stringify(wid)} --auto-exit ${config.pane.autoExitSecs}`;
874
+ const cmd = followCommand(wid, config.pane.autoExitSecs);
501
875
  const p = await osascript(WORKER_SPLIT_SCRIPT, [
502
876
  uid,
503
877
  [...reg].reverse().map((e) => e.session).join(","),
@@ -571,7 +945,7 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
571
945
  if (out === "notrunning") throw new Error("pai worker pane: iTerm2 is not running");
572
946
  if ([...new Set(out.split(",").map((t) => t.trim()).filter(Boolean))].filter((t) => followTtys().has(t)).length > 0) return "follow pane already open";
573
947
  if (checkOnly) return "no follow pane";
574
- const s = await osascript(SPLIT_SCRIPT, [uid, " exec pai worker follow"]);
948
+ const s = await osascript(SPLIT_SCRIPT, [uid, `${process.execPath} ${absoluteCliPath()} worker follow`]);
575
949
  const sout = s.stdout.trim();
576
950
  if (sout === "opened") return "follow pane opened";
577
951
  if (sout === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
@@ -1627,8 +2001,12 @@ function codexInstalled() {
1627
2001
  * demand, the provider name in the URL path); codex-engine providers run
1628
2002
  * the Codex CLI.
1629
2003
  * - headless (-p): strict empty MCP config unless the caller brings one or
1630
- * names servers via --mcp / a role (then a filtered <id>.mcp.json),
1631
- * PAI_WORKER=1 so PAI's per-session hooks leave it alone, the worker
2004
+ * names servers via --mcp / a role (then a filtered <id>.mcp.json), the
2005
+ * core tool grants the caller did not bring (a headless run cannot
2006
+ * approve a permission-gated tool mid-flight — with no grant at all,
2007
+ * claude drops the file/shell tools entirely and the worker is
2008
+ * tool-blind), PAI_WORKER=1 so PAI's per-session hooks leave it alone,
2009
+ * the worker
1632
2010
  * contract appended to the system prompt, `--input-format stream-json`
1633
2011
  * with the prompt as the first stdin user message (the operator socket
1634
2012
  * can add more mid-run), stream-json mirroring (every line stamped `_ts`)
@@ -1641,15 +2019,6 @@ function codexInstalled() {
1641
2019
  * Auto-routed runs that die of a quota error before the first tool call are
1642
2020
  * restarted on the next provider in routing order (WORKER-REROUTE ledger line).
1643
2021
  */
1644
- /** The strict empty MCP config for headless workers, written on demand. */
1645
- function ensureNoMcpConfig(logDir) {
1646
- const path = noMcpConfigPath(logDir);
1647
- if (!existsSync(path)) {
1648
- mkdirSync(logDir, { recursive: true });
1649
- writeFileSync(path, "{ \"mcpServers\": {} }\n", "utf8");
1650
- }
1651
- return path;
1652
- }
1653
2022
  /** A stream-json user message for the child's stdin. */
1654
2023
  function stdinUserMessage(text) {
1655
2024
  return JSON.stringify({
@@ -1725,7 +2094,7 @@ async function runWorker(opts) {
1725
2094
  const logDir = workersLogDir(config);
1726
2095
  mkdirSync(logDir, { recursive: true });
1727
2096
  if (opts.className === "plan" && !opts._planner) {
1728
- const { runPlanner } = await import("./planner-BLDJt3ZS.mjs");
2097
+ const { runPlanner } = await import("./planner-BfEGcEs1.mjs");
1729
2098
  return runPlanner(opts);
1730
2099
  }
1731
2100
  const parent = launchParent(opts.parent);
@@ -1784,6 +2153,19 @@ async function runWorker(opts) {
1784
2153
  throw e;
1785
2154
  }
1786
2155
  }
2156
+ /**
2157
+ * The core tools a headless run grants when the caller brings none of its
2158
+ * own. Permission-gated tools (Bash, Read, Write, …) are never offered to a
2159
+ * headless session that has no allow rule for them — the child starts with
2160
+ * only the tools that never ask (tool search, web, cron, tasks) and cannot
2161
+ * touch a file or shell (2026-09-18). A caller's own --allowedTools is a
2162
+ * deliberate restriction and passes through untouched.
2163
+ */
2164
+ const DEFAULT_WORKER_TOOLS = "Read,Edit,Write,Bash,Grep,Glob";
2165
+ /** The --allowedTools args a headless run needs; [] when the caller granted. */
2166
+ function headlessToolGrants(allowedTools) {
2167
+ return allowedTools.length ? [] : ["--allowedTools", DEFAULT_WORKER_TOOLS];
2168
+ }
1787
2169
  async function executeRun(a) {
1788
2170
  const { config, logDir, target, model, label, parsed, noPane } = a;
1789
2171
  const headless = parsed.headless;
@@ -1872,9 +2254,10 @@ async function executeRun(a) {
1872
2254
  ];
1873
2255
  }
1874
2256
  const restArgs = headless ? stripPromptValues(parsed.rest) : parsed.rest;
2257
+ const toolArgs = headless ? headlessToolGrants(parsed.allowedTools) : [];
1875
2258
  const cmd = ["claude"];
1876
- if (!parsed.callerModel) cmd.push("--model", model);
1877
- cmd.push(...mcpArgs, ...restArgs);
2259
+ if (!parsed.callerModel && model) cmd.push("--model", model);
2260
+ cmd.push(...mcpArgs, ...toolArgs, ...restArgs);
1878
2261
  if (headless) {
1879
2262
  cmd.push("--output-format", "stream-json", "--verbose", "--input-format", "stream-json");
1880
2263
  if (!parsed.callerSystemPrompt) cmd.push("--append-system-prompt", WORKER_CONTRACT_PROMPT);
@@ -2410,161 +2793,5 @@ function resultFromOutput(out) {
2410
2793
  }
2411
2794
 
2412
2795
  //#endregion
2413
- //#region src/workers/chain.ts
2414
- /**
2415
- * chain.ts — draft-then-implement chains: `--chain draft,implement[,review]`.
2416
- *
2417
- * The chain runs each stage as its own worker (own id, own pane, `parent` set
2418
- * to the chain id), so `ps` shows the chain as a tree and every stage can be
2419
- * followed, replayed and said to like any other worker:
2420
- *
2421
- * - draft turns the operator's brief into a full spec file under
2422
- * <logDir>/specs/<chain id>.md (goal, constraints, files likely
2423
- * touched, acceptance checks, verification commands);
2424
- * - any other stage (implement, plan, …) runs with that spec as its prompt
2425
- * and the original brief attached;
2426
- * - review reads the spec and the working-tree diff and produces the
2427
- * structured report.
2428
- *
2429
- * A stage that fails (or a draft that produces no spec file) stops the chain;
2430
- * the caller then writes the spec itself and re-runs without the draft stage.
2431
- */
2432
- /** Where a chain's spec file lives: <logDir>/specs/<chain id>.md. */
2433
- function specPathFor(logDir, chainId) {
2434
- return join(logDir, "specs", `${chainId}.md`);
2435
- }
2436
- /**
2437
- * Replace the caller's -p value with `prompt`, dropping every existing
2438
- * -p/--print pair first (two -p flags on one claude command line are an error,
2439
- * so the chain must never leave the brief in place when swapping prompts).
2440
- */
2441
- function swapPromptArg(claudeArgs, prompt) {
2442
- const out = [];
2443
- for (let i = 0; i < claudeArgs.length; i++) {
2444
- const a = claudeArgs[i];
2445
- if (a === "-p" || a === "--print") {
2446
- if (i + 1 < claudeArgs.length && !claudeArgs[i + 1].startsWith("-")) i += 1;
2447
- continue;
2448
- }
2449
- out.push(a);
2450
- }
2451
- out.push("-p", prompt);
2452
- return out;
2453
- }
2454
- function draftPrompt(brief, specPath) {
2455
- return [
2456
- "You are the DRAFT stage of a worker chain. Turn the operator's brief below into a full implementation spec and write it to",
2457
- specPath,
2458
- "with the Write tool.",
2459
- "",
2460
- "Use exactly these five sections as Markdown headings:",
2461
- "# Goal",
2462
- "# Constraints",
2463
- "# Files likely touched",
2464
- "# Acceptance checks",
2465
- "# Verification commands",
2466
- "",
2467
- "Read the repository first (Glob/Grep/Read) so the spec names real files and real commands. Do not implement anything.",
2468
- "",
2469
- "## Operator brief",
2470
- "",
2471
- brief
2472
- ].join("\n");
2473
- }
2474
- function implementPrompt(brief, specPath, spec) {
2475
- return [
2476
- ...spec ? [
2477
- "You are the IMPLEMENT stage of a worker chain. The draft stage wrote the spec below (also at " + specPath + "). Implement it exactly, then run the spec's verification commands before finishing.",
2478
- "",
2479
- "## Spec",
2480
- "",
2481
- spec
2482
- ] : ["Implement the operator's brief below."],
2483
- "",
2484
- "## Operator brief",
2485
- "",
2486
- brief
2487
- ].join("\n");
2488
- }
2489
- function reviewPrompt(brief, specPath, spec) {
2490
- return [
2491
- "You are the REVIEW stage of a worker chain. The implement stage just ran. Read the repository's diff (run `git diff` and `git status`; use `git diff --stat` for the overview) and check it against the spec's acceptance checks and verification commands — run the checks when they are cheap. Do not fix anything you find; report it.",
2492
- ...spec ? [
2493
- "",
2494
- "## Spec (also at " + specPath + ")",
2495
- "",
2496
- spec
2497
- ] : [],
2498
- "",
2499
- "## Operator brief",
2500
- "",
2501
- brief
2502
- ].join("\n");
2503
- }
2504
- /** Run a chain of stages; returns the exit code of the first failed stage, 0 when all pass. */
2505
- async function runChain(opts, deps = {}) {
2506
- const stages = opts.stages.map((s) => s.trim()).filter(Boolean);
2507
- if (!stages.length) throw new Error("--chain needs at least one class, e.g. --chain draft,implement");
2508
- const runStage = deps.runStage ?? runWorker;
2509
- const logDir = deps.logDir ?? workersLogDir(readWorkersSection().workers);
2510
- const chainId = newWorkerId();
2511
- const specPath = specPathFor(logDir, chainId);
2512
- mkdirSync(join(logDir, "specs"), { recursive: true });
2513
- const baseLabel = opts.label ?? shortText(opts.brief, 40);
2514
- appendLedger(ledgerPath(logDir), "WORKER-CHAIN", {
2515
- chain: chainId,
2516
- stages: stages.join(","),
2517
- label: baseLabel
2518
- });
2519
- opts.onChainStart?.(chainId);
2520
- let spec = null;
2521
- for (let i = 0; i < stages.length; i++) {
2522
- const stage = stages[i];
2523
- if (stage === "draft") spec = null;
2524
- else if (spec === null && existsSync(specPath)) spec = readFileSync(specPath, "utf8");
2525
- const prompt = stage === "draft" ? draftPrompt(opts.brief, specPath) : stage === "review" ? reviewPrompt(opts.brief, spec ? specPath : null, spec) : implementPrompt(opts.brief, spec ? specPath : null, spec);
2526
- process.stderr.write(`chain ${chainId}: stage ${i + 1}/${stages.length} ${stage} (spec: ${specPath})\n`);
2527
- const rc = await runStage({
2528
- className: opts.className ?? stage,
2529
- providerFlag: opts.providerFlag,
2530
- modelFlag: opts.modelFlag,
2531
- label: `${baseLabel} · ${stage}`,
2532
- noPane: opts.noPane,
2533
- mcpFlag: opts.mcpFlag,
2534
- claudeArgs: swapPromptArg(opts.claudeArgs, prompt),
2535
- cwd: opts.cwd,
2536
- parent: chainId,
2537
- stage,
2538
- quiet: opts.quiet
2539
- });
2540
- if (stage === "draft") {
2541
- if (!existsSync(specPath)) {
2542
- process.stderr.write(`chain ${chainId}: draft stage produced no spec at ${specPath} — stopping. Write the spec yourself and re-run without the draft stage.\n`);
2543
- appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2544
- chain: chainId,
2545
- rc: rc !== 0 ? rc : 1,
2546
- failed: "draft"
2547
- });
2548
- return rc !== 0 ? rc : 1;
2549
- }
2550
- spec = readFileSync(specPath, "utf8");
2551
- }
2552
- if (rc !== 0) {
2553
- appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2554
- chain: chainId,
2555
- rc,
2556
- failed: stage
2557
- });
2558
- return rc;
2559
- }
2560
- }
2561
- appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2562
- chain: chainId,
2563
- rc: 0
2564
- });
2565
- return 0;
2566
- }
2567
-
2568
- //#endregion
2569
- export { openPaneForWorker as _, testProvider as a, renderReport as c, handoffFromInside as d, readInbox as f, openFollowPane as g, checkPaneForWorker as h, runWorker as i, discardWorker as l, workerDepth as m, swapPromptArg as n, describeMcp as o, sayToWorker as p, printResult as r, parseWorkerReport as s, runChain as t, mergeWorker as u, appendLedger as v, ledgerSummary as y };
2570
- //# sourceMappingURL=chain-DGXNoTwc.mjs.map
2796
+ export { nowStamp as A, alive as C, loadStatus as D, isChatPane as E, ledgerSummary as M, parseRunnerArgs as N, loadStatuses as O, shortText as P, ageOf as S, contextPercent as T, itermForClaudeSession as _, parseWorkerReport as a, workerInScope as b, mergeWorker as c, sayToWorker as d, workerDepth as f, currentTabKey as g, openPaneForWorker as h, describeMcp as i, appendLedger as j, newWorkerId as k, handoffFromInside as l, openFollowPane as m, runWorker as n, renderReport as o, checkPaneForWorker as p, testProvider as r, discardWorker as s, printResult as t, readInbox as u, resolveSession as v, contextLabel as w, UNLABELED as x, sessionTag as y };
2797
+ //# sourceMappingURL=run-CGXbhf2k.mjs.map