@tekmidian/pai 0.38.0 → 0.39.0

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 (83) hide show
  1. package/dist/{auto-route-DM7GhJ8y.mjs → auto-route-BlWOWT4z.mjs} +2 -2
  2. package/dist/{auto-route-DM7GhJ8y.mjs.map → auto-route-BlWOWT4z.mjs.map} +1 -1
  3. package/dist/{providers-DUshcB-d.mjs → chain-CTtHligI.mjs} +699 -1405
  4. package/dist/chain-CTtHligI.mjs.map +1 -0
  5. package/dist/cli/index.mjs +4 -3
  6. package/dist/cli/index.mjs.map +1 -1
  7. package/dist/cli/program.mjs +4 -3
  8. package/dist/{clusters-Do4tEGyc.mjs → clusters-wlK0w41E.mjs} +1 -1
  9. package/dist/{clusters-Do4tEGyc.mjs.map → clusters-wlK0w41E.mjs.map} +1 -1
  10. package/dist/daemon/index.mjs +7 -7
  11. package/dist/{daemon-CGg1VCbA.mjs → daemon-CTkN_Vn8.mjs} +25 -25
  12. package/dist/{daemon-CGg1VCbA.mjs.map → daemon-CTkN_Vn8.mjs.map} +1 -1
  13. package/dist/{daemon-DsGGiIJM.mjs → daemon-CmHHmKpg.mjs} +7 -7
  14. package/dist/daemon-mcp/index.mjs +55 -3
  15. package/dist/daemon-mcp/index.mjs.map +1 -1
  16. package/dist/{detector-CMap-9vw.mjs → detector-Bwk_4Pk4.mjs} +1 -1
  17. package/dist/{detector-CMap-9vw.mjs.map → detector-Bwk_4Pk4.mjs.map} +1 -1
  18. package/dist/detector-DO730Zq0.mjs +5 -0
  19. package/dist/{factory-DD2T33C9.mjs → factory-A9x-T2Kg.mjs} +5 -5
  20. package/dist/{factory-DD2T33C9.mjs.map → factory-A9x-T2Kg.mjs.map} +1 -1
  21. package/dist/factory-BD-np0Vd.mjs +3 -0
  22. package/dist/hooks/route-agents-to-worker.mjs +36 -6
  23. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  24. package/dist/hooks/worker-proxy.mjs +34 -4
  25. package/dist/hooks/worker-proxy.mjs.map +2 -2
  26. package/dist/hooks/worker-status-line.mjs +100 -9
  27. package/dist/hooks/worker-status-line.mjs.map +4 -4
  28. package/dist/{indexer-backend-Cox9BCo-.mjs → indexer-backend-TG64CCQC.mjs} +1 -1
  29. package/dist/{indexer-backend-Cox9BCo-.mjs.map → indexer-backend-TG64CCQC.mjs.map} +1 -1
  30. package/dist/{latent-ideas-BC1oINZ-.mjs → latent-ideas-B7wq75Pt.mjs} +2 -2
  31. package/dist/{latent-ideas-BC1oINZ-.mjs.map → latent-ideas-B7wq75Pt.mjs.map} +1 -1
  32. package/dist/{link-boost-QFLrJwD6.mjs → link-boost-HkG7JWZR.mjs} +1 -1
  33. package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-HkG7JWZR.mjs.map} +1 -1
  34. package/dist/{neighborhood-D9MJ1c8f.mjs → neighborhood-lThN-MaQ.mjs} +1 -1
  35. package/dist/{neighborhood-D9MJ1c8f.mjs.map → neighborhood-lThN-MaQ.mjs.map} +1 -1
  36. package/dist/{note-context-d1wT_-GA.mjs → note-context-b6k0mAKi.mjs} +1 -1
  37. package/dist/{note-context-d1wT_-GA.mjs.map → note-context-b6k0mAKi.mjs.map} +1 -1
  38. package/dist/planner-BDI7bE9B.mjs +243 -0
  39. package/dist/planner-BDI7bE9B.mjs.map +1 -0
  40. package/dist/{postgres--BjPtLa0.mjs → postgres-mW1n7Vi1.mjs} +1 -1
  41. package/dist/{postgres--BjPtLa0.mjs.map → postgres-mW1n7Vi1.mjs.map} +1 -1
  42. package/dist/{program-Y0hAiVy8.mjs → program-DXBwJV7h.mjs} +70 -19
  43. package/dist/{program-Y0hAiVy8.mjs.map → program-DXBwJV7h.mjs.map} +1 -1
  44. package/dist/providers-FYkZjn_C.mjs +1405 -0
  45. package/dist/providers-FYkZjn_C.mjs.map +1 -0
  46. package/dist/{query-feedback-BUJxgw5B.mjs → query-feedback-BV4CcxqS.mjs} +1 -1
  47. package/dist/{query-feedback-BUJxgw5B.mjs.map → query-feedback-BV4CcxqS.mjs.map} +1 -1
  48. package/dist/query-feedback-DSVyHtrG.mjs +3 -0
  49. package/dist/router-Bk77E7hj.mjs +3 -0
  50. package/dist/{router-DK_sLsUL.mjs → router-DcHKnEPa.mjs} +1 -1
  51. package/dist/{router-DK_sLsUL.mjs.map → router-DcHKnEPa.mjs.map} +1 -1
  52. package/dist/{sources-Bi7--33T.mjs → sources-kLnQsNrW.mjs} +1 -1
  53. package/dist/{sources-Bi7--33T.mjs.map → sources-kLnQsNrW.mjs.map} +1 -1
  54. package/dist/{sqlite-DtaL1glm.mjs → sqlite-BenGr3UP.mjs} +1 -1
  55. package/dist/{sqlite-DtaL1glm.mjs.map → sqlite-BenGr3UP.mjs.map} +1 -1
  56. package/dist/{state-BY2L6-vX.mjs → state-CAeyOdfq.mjs} +1 -1
  57. package/dist/{state-BY2L6-vX.mjs.map → state-CAeyOdfq.mjs.map} +1 -1
  58. package/dist/{state-8Hm9E4tW.mjs → state-Ca9F_sZc.mjs} +1 -1
  59. package/dist/{themes-BN0a2duq.mjs → themes-BI4GMRP9.mjs} +1 -1
  60. package/dist/{themes-BN0a2duq.mjs.map → themes-BI4GMRP9.mjs.map} +1 -1
  61. package/dist/{tools-CGPqpU3A.mjs → tools-Bp7hj6OW.mjs} +1 -1
  62. package/dist/{tools-y2bJpKom.mjs → tools-DGcB3o_S.mjs} +13 -13
  63. package/dist/{tools-y2bJpKom.mjs.map → tools-DGcB3o_S.mjs.map} +1 -1
  64. package/dist/{trace-h23JCcFD.mjs → trace-bobARFEX.mjs} +1 -1
  65. package/dist/{trace-h23JCcFD.mjs.map → trace-bobARFEX.mjs.map} +1 -1
  66. package/dist/{vault-indexer-DgsPjMgs.mjs → vault-indexer-C3OfXTNF.mjs} +1 -1
  67. package/dist/{vault-indexer-DgsPjMgs.mjs.map → vault-indexer-C3OfXTNF.mjs.map} +1 -1
  68. package/dist/{work-queue-worker-gsKd2LJa.mjs → work-queue-worker-DW8lz-Oo.mjs} +3 -3
  69. package/dist/{work-queue-worker-Dva_v_pI.mjs → work-queue-worker-R7UGIag_.mjs} +3 -3
  70. package/dist/{work-queue-worker-Dva_v_pI.mjs.map → work-queue-worker-R7UGIag_.mjs.map} +1 -1
  71. package/dist/{zettelkasten-vo7psPdT.mjs → zettelkasten-m5QPtb-L.mjs} +3 -3
  72. package/dist/{zettelkasten-vo7psPdT.mjs.map → zettelkasten-m5QPtb-L.mjs.map} +1 -1
  73. package/docs/commands/README.md +4 -0
  74. package/docs/commands/worker.md +56 -0
  75. package/docs/worker.md +89 -1
  76. package/package.json +1 -1
  77. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +2 -2
  78. package/dist/detector-DtLExmHN.mjs +0 -5
  79. package/dist/factory-BXzqRYVZ.mjs +0 -3
  80. package/dist/providers-DUshcB-d.mjs.map +0 -1
  81. package/dist/query-feedback-DhyLOe5S.mjs +0 -3
  82. package/dist/router-1zi8jiNF.mjs +0 -3
  83. /package/dist/{main-resolver-IhZo4pI0.mjs → main-resolver-D6IImXvF.mjs} +0 -0
@@ -1,7 +1,7 @@
1
1
  import { n as CONFIG_FILE } from "./config-B64vFg14.mjs";
2
- import { appendFileSync, chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
2
+ import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { basename, dirname, join, relative } from "node:path";
4
+ import { dirname, join, relative } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { connect, createServer } from "node:net";
7
7
  import { execFileSync, spawn } from "node:child_process";
@@ -137,19 +137,32 @@ const DEFAULT_ROUTING = {
137
137
  cooldownMinutes: 30,
138
138
  retryOnQuota: true
139
139
  };
140
+ const DEFAULT_TREE = {
141
+ maxDepth: 2,
142
+ maxChildren: 4
143
+ };
144
+ /**
145
+ * The default MCP sets every config starts from. `desktop` names the clickr
146
+ * server so `--mcp desktop` hands a worker the machine controls (read-only
147
+ * tools always; actuating ones after the operator hands the controls over,
148
+ * see `pai worker controls`). A user's mcpSets section is merged over this,
149
+ * so `desktop: []` removes the set deliberately.
150
+ */
151
+ const DEFAULT_MCP_SETS = { desktop: ["clickr"] };
140
152
  function defaultWorkersConfig() {
141
153
  return {
142
154
  enabled: false,
143
155
  active: null,
144
156
  providers: {},
145
157
  classes: {},
146
- mcpSets: {},
158
+ mcpSets: { ...DEFAULT_MCP_SETS },
147
159
  pane: { ...DEFAULT_PANE },
148
160
  logDir: DEFAULT_LOG_DIR,
149
161
  routing: {
150
162
  ...DEFAULT_ROUTING,
151
163
  order: []
152
- }
164
+ },
165
+ tree: { ...DEFAULT_TREE }
153
166
  };
154
167
  }
155
168
  var WorkersConfigError = class extends Error {};
@@ -280,7 +293,7 @@ function parseWorkersConfig(raw) {
280
293
  classes[cls] = t;
281
294
  }
282
295
  }
283
- const mcpSets = {};
296
+ const mcpSets = { ...DEFAULT_MCP_SETS };
284
297
  if (w.mcpSets !== void 0) {
285
298
  if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
286
299
  for (const [setName, servers] of Object.entries(w.mcpSets)) {
@@ -320,6 +333,21 @@ function parseWorkersConfig(raw) {
320
333
  retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
321
334
  };
322
335
  }
336
+ let tree = { ...DEFAULT_TREE };
337
+ if (w.tree !== void 0) {
338
+ if (typeof w.tree !== "object" || w.tree === null || Array.isArray(w.tree)) bad(".tree", "must be an object");
339
+ const t = w.tree;
340
+ if (t.maxDepth !== void 0) {
341
+ if (typeof t.maxDepth !== "number" || !Number.isInteger(t.maxDepth) || t.maxDepth < 0) bad(".tree.maxDepth", "must be a non-negative integer");
342
+ }
343
+ if (t.maxChildren !== void 0) {
344
+ if (typeof t.maxChildren !== "number" || !Number.isInteger(t.maxChildren) || t.maxChildren < 1) bad(".tree.maxChildren", "must be a positive integer");
345
+ }
346
+ tree = {
347
+ maxDepth: t.maxDepth === void 0 ? DEFAULT_TREE.maxDepth : t.maxDepth,
348
+ maxChildren: t.maxChildren === void 0 ? DEFAULT_TREE.maxChildren : t.maxChildren
349
+ };
350
+ }
323
351
  const active = w.active === void 0 || w.active === null ? null : str(w.active);
324
352
  if (active !== null && active !== "auto" && !(active in providers)) {}
325
353
  return {
@@ -330,7 +358,8 @@ function parseWorkersConfig(raw) {
330
358
  mcpSets,
331
359
  pane,
332
360
  logDir: str(w.logDir) || d.logDir,
333
- routing
361
+ routing,
362
+ tree
334
363
  };
335
364
  }
336
365
  /**
@@ -1149,7 +1178,7 @@ function osascript(script, args) {
1149
1178
  proc.stdin.end();
1150
1179
  });
1151
1180
  }
1152
- function psOutput$1(format) {
1181
+ function psOutput(format) {
1153
1182
  try {
1154
1183
  return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
1155
1184
  } catch {
@@ -1159,7 +1188,7 @@ function psOutput$1(format) {
1159
1188
  /** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
1160
1189
  function followTtys() {
1161
1190
  const ttys = /* @__PURE__ */ new Set();
1162
- for (const line of psOutput$1("tty=,command=").split("\n")) {
1191
+ for (const line of psOutput("tty=,command=").split("\n")) {
1163
1192
  const parts = line.trim().split(/\s+/, 2);
1164
1193
  if (parts.length < 2 || parts[0] === "ps") continue;
1165
1194
  if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
@@ -1170,7 +1199,7 @@ function followTtys() {
1170
1199
  /** True while a `follow <wid>` process runs (its pane shows that worker). */
1171
1200
  function workerPaneOpen(wid) {
1172
1201
  const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
1173
- return psOutput$1("command=").split("\n").some((ln) => pat.test(ln));
1202
+ return psOutput("command=").split("\n").some((ln) => pat.test(ln));
1174
1203
  }
1175
1204
  /** plutil's message for an ExecFileSync failure, else the exception text. */
1176
1205
  function toolErr(e) {
@@ -1434,6 +1463,483 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
1434
1463
  throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
1435
1464
  }
1436
1465
 
1466
+ //#endregion
1467
+ //#region src/workers/tree.ts
1468
+ /** The env var the runner sets in every worker's environment. */
1469
+ const WORKER_ID_ENV = "PAI_WORKER_ID";
1470
+ /** The worker this process runs inside, when it runs inside one. */
1471
+ function parentFromEnv(env = process.env) {
1472
+ const id = env[WORKER_ID_ENV];
1473
+ return typeof id === "string" && id.trim() ? id.trim() : null;
1474
+ }
1475
+ /**
1476
+ * Depth of `id` in the worker forest: 0 for a top-level worker, 1 + the
1477
+ * parent's depth for a sub-worker. Parents without a status file (chain ids,
1478
+ * unknown ids) count as roots; a cycle reads as its own depth and is cut off
1479
+ * after the statuses it walked.
1480
+ */
1481
+ function workerDepth(statuses, id) {
1482
+ const byId = new Map(statuses.map((s) => [s.id, s]));
1483
+ let depth = 0;
1484
+ let cur = byId.get(id);
1485
+ const seen = new Set([id]);
1486
+ while (cur?.parent && !seen.has(cur.parent)) {
1487
+ seen.add(cur.parent);
1488
+ const next = byId.get(cur.parent);
1489
+ if (!next) break;
1490
+ depth += 1;
1491
+ cur = next;
1492
+ }
1493
+ return depth;
1494
+ }
1495
+ /** Children of `parent` that are still running, oldest first. */
1496
+ function runningChildren(statuses, parent) {
1497
+ return statuses.filter((s) => s.parent === parent && s.state === "running" && alive(s.pid));
1498
+ }
1499
+ /**
1500
+ * Would starting a child of `parent` stay within the caps? Throws with a
1501
+ * clear message when it would not; returns silently when `parent` has no
1502
+ * status file (a chain id or an unknown id — not a worker, not capped).
1503
+ */
1504
+ function assertChildAllowed(logDir, parent, caps, statuses = loadStatuses(logDir)) {
1505
+ if (!statuses.some((s) => s.id === parent)) return;
1506
+ const depth = workerDepth(statuses, parent);
1507
+ if (depth + 1 > caps.maxDepth) throw new Error(`worker tree: ${parent} sits at depth ${depth} and workers.tree.maxDepth is ${caps.maxDepth} — it cannot start another level of sub-workers. Hand the task up instead: pai worker handoff '{"kind":"proposal","text":"…"}'`);
1508
+ const kids = runningChildren(statuses, parent);
1509
+ if (kids.length >= caps.maxChildren) throw new Error(`worker tree: ${parent} already has ${kids.length} running sub-workers (${kids.map((k) => k.id).join(", ")}) and workers.tree.maxChildren is ${caps.maxChildren} — wait for one to finish, or raise the cap in the workers config`);
1510
+ }
1511
+ /**
1512
+ * The parent a launch should record: an explicit one (chain stage) wins, else
1513
+ * the worker this process runs inside. Returns null for top-level runs.
1514
+ */
1515
+ function launchParent(explicit, env = process.env) {
1516
+ return explicit ?? parentFromEnv(env);
1517
+ }
1518
+ /** Does a status file exist for `id` (i.e. is it a worker rather than a chain)? */
1519
+ function isWorkerId(logDir, id) {
1520
+ return loadStatus(logDir, id) !== null;
1521
+ }
1522
+
1523
+ //#endregion
1524
+ //#region src/workers/operator.ts
1525
+ /**
1526
+ * operator.ts — talking to a running worker.
1527
+ *
1528
+ * Headless workers run with `--input-format stream-json` and their stdin held
1529
+ * open, so a run is a conversation: every line sent to the per-worker Unix
1530
+ * socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
1531
+ * mirrored into the transcript as an `operator` event (rendered with a »
1532
+ * marker). Once the worker has finished its turn, stdin closes 2 s later
1533
+ * unless a new message arrives — after that, `say` refuses and `resume`
1534
+ * continues the same Claude session with the worker's context intact.
1535
+ */
1536
+ function operatorSocketPath(logDir, id) {
1537
+ return join(logDir, `${id}.sock`);
1538
+ }
1539
+ /**
1540
+ * The runner's side: listen on the worker socket, hand every received line to
1541
+ * `onLine`. Returns the server (close it when the run ends; the socket file is
1542
+ * unlinked on close, best effort).
1543
+ */
1544
+ function createOperatorServer(logDir, id, onLine) {
1545
+ const path = operatorSocketPath(logDir, id);
1546
+ try {
1547
+ if (existsSync(path)) unlinkSync(path);
1548
+ } catch {}
1549
+ const server = createServer((socket) => {
1550
+ let buf = "";
1551
+ socket.on("data", (chunk) => {
1552
+ buf += chunk.toString("utf8");
1553
+ let nl;
1554
+ while ((nl = buf.indexOf("\n")) >= 0) {
1555
+ const line = buf.slice(0, nl).replace(/\r$/, "");
1556
+ buf = buf.slice(nl + 1);
1557
+ if (line.trim()) onLine(line);
1558
+ socket.write("ok\n");
1559
+ }
1560
+ });
1561
+ });
1562
+ server.listen(path);
1563
+ server.on("close", () => {
1564
+ try {
1565
+ if (existsSync(path)) unlinkSync(path);
1566
+ } catch {}
1567
+ });
1568
+ return server;
1569
+ }
1570
+ /**
1571
+ * `pai worker say <id> "<text>"`: forward one line to a running worker.
1572
+ * Resolves "ok", rejects with a clear message when the worker is not running.
1573
+ */
1574
+ function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
1575
+ const status = loadStatus(logDir, id);
1576
+ if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
1577
+ if (status.state !== "running" || !alive(status.pid)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} is not running (state: ${status.state}) — continue it instead with: pai worker resume ${id} "<text>"`));
1578
+ const path = operatorSocketPath(logDir, id);
1579
+ if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
1580
+ return new Promise((resolve, reject) => {
1581
+ const sock = connect(path);
1582
+ const fail = (e) => {
1583
+ sock.destroy();
1584
+ reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
1585
+ };
1586
+ sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
1587
+ sock.once("error", (e) => fail(e));
1588
+ sock.once("connect", () => {
1589
+ sock.write(text.replace(/\n/g, " ") + "\n");
1590
+ });
1591
+ sock.once("data", () => {
1592
+ sock.end();
1593
+ resolve("ok");
1594
+ });
1595
+ });
1596
+ }
1597
+
1598
+ //#endregion
1599
+ //#region src/workers/handoff.ts
1600
+ /**
1601
+ * handoff.ts — upward messages between workers.
1602
+ *
1603
+ * Coordination in the worker tree is upward only: a child appends a handoff
1604
+ * to `<logDir>/<parent>.inbox.jsonl` and (when the parent is a running
1605
+ * worker) the same text is said to it, so it lands in the parent's
1606
+ * conversation as `[handoff from <child>] …`. The parent's pane renders the
1607
+ * inbox as `◆` lines, `ps`/`worker_status` show an inbox count, and a child
1608
+ * that finishes delivers its structured report automatically as a
1609
+ * `kind: "result"` handoff. There is no downward or sideways path — telling
1610
+ * a worker something is the operator's job (`pai worker say`).
1611
+ */
1612
+ const HANDOFF_KINDS = [
1613
+ "proposal",
1614
+ "result",
1615
+ "question",
1616
+ "blocker"
1617
+ ];
1618
+ /** Where a worker's handoffs land: <logDir>/<id>.inbox.jsonl. */
1619
+ function inboxPath(logDir, id) {
1620
+ return join(logDir, `${id}.inbox.jsonl`);
1621
+ }
1622
+ function isHandoffKind(v) {
1623
+ return typeof v === "string" && HANDOFF_KINDS.includes(v);
1624
+ }
1625
+ /**
1626
+ * Normalize a raw parsed value into a Handoff, or explain what is missing.
1627
+ * `from`/`to` may be preset by the caller (the CLI fills them from the
1628
+ * environment); everything else must be present and well-formed.
1629
+ */
1630
+ function parseHandoff(v, preset = {}) {
1631
+ if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error("handoff payload must be a JSON object");
1632
+ const o = v;
1633
+ const from = typeof o.from === "string" && o.from ? o.from : preset.from;
1634
+ const to = typeof o.to === "string" && o.to ? o.to : preset.to;
1635
+ if (!from) throw new Error(`handoff needs "from" (the sending worker id)`);
1636
+ if (!to) throw new Error(`handoff needs "to" (the parent worker id)`);
1637
+ if (!isHandoffKind(o.kind)) throw new Error(`handoff "kind" must be one of: ${HANDOFF_KINDS.join(", ")}`);
1638
+ const text = typeof o.text === "string" ? o.text : "";
1639
+ if (!text.trim()) throw new Error(`handoff needs a non-empty "text"`);
1640
+ const data = typeof o.data === "object" && o.data !== null && !Array.isArray(o.data) ? o.data : void 0;
1641
+ return {
1642
+ from,
1643
+ to,
1644
+ kind: o.kind,
1645
+ text,
1646
+ ...data ? { data } : {}
1647
+ };
1648
+ }
1649
+ /** Append one handoff to its recipient's inbox, stamped now. */
1650
+ function appendHandoff(logDir, h, now = /* @__PURE__ */ new Date()) {
1651
+ const path = inboxPath(logDir, h.to);
1652
+ const dir = dirname(path);
1653
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1654
+ appendFileSync(path, JSON.stringify({
1655
+ ...h,
1656
+ _ts: now.toISOString()
1657
+ }) + "\n", "utf8");
1658
+ }
1659
+ /** Every handoff in a worker's inbox, oldest first; damaged lines skip. */
1660
+ function readInbox(logDir, id) {
1661
+ const path = inboxPath(logDir, id);
1662
+ if (!existsSync(path)) return [];
1663
+ const out = [];
1664
+ for (const line of readFileSync(path, "utf8").split("\n")) {
1665
+ if (!line.trim()) continue;
1666
+ try {
1667
+ out.push(JSON.parse(line));
1668
+ } catch {}
1669
+ }
1670
+ return out;
1671
+ }
1672
+ /** How the say path prefixes a handoff so the parent model knows its source. */
1673
+ function handoffMessage(h) {
1674
+ return `[handoff from ${h.from}] (${h.kind}) ${h.text.replace(/\s+/g, " ").trim()}`;
1675
+ }
1676
+ /** True for the exact shape of handoffMessage() — the runner marks the mirror. */
1677
+ function isHandoffMessage(text) {
1678
+ return new RegExp(`^\\[handoff from \\S+\\] \\((${HANDOFF_KINDS.join("|")})\\) `).test(text);
1679
+ }
1680
+ /**
1681
+ * Deliver one handoff: append it to the parent's inbox, then — when the
1682
+ * parent is a running worker with a live operator socket — say it so it
1683
+ * enters the parent's conversation. The say is best effort: a parent that is
1684
+ * busy, finished or gone still has the inbox line, and nothing here may make
1685
+ * a finishing child's exit path fail.
1686
+ */
1687
+ async function deliverHandoff(logDir, h, deps = {}, now = /* @__PURE__ */ new Date()) {
1688
+ appendHandoff(logDir, h, now);
1689
+ const say = deps.say ?? ((id, text) => sayToWorker(logDir, id, text));
1690
+ const parent = loadStatus(logDir, h.to);
1691
+ if (parent && parent.state === "running" && alive(parent.pid)) try {
1692
+ await say(h.to, handoffMessage(h));
1693
+ } catch {}
1694
+ return h;
1695
+ }
1696
+ /**
1697
+ * Send a handoff from inside a worker (the `pai worker handoff` CLI and the
1698
+ * MCP tool both land here): the sender comes from PAI_WORKER_ID, the
1699
+ * recipient from its status file's `parent`. Rejects with a clear message
1700
+ * outside a worker or under a parentless worker.
1701
+ */
1702
+ async function handoffFromInside(logDir, env, payload, deps = {}) {
1703
+ const mine = env.PAI_WORKER_ID;
1704
+ if (!mine) throw new Error("not inside a worker — handoffs are sent by workers (PAI_WORKER_ID unset). Talk to a running worker with: pai worker say <id> \"<text>\"");
1705
+ const st = loadStatus(logDir, mine);
1706
+ if (!st) throw new Error(`no status for this worker (${mine}) — cannot find its parent`);
1707
+ if (!st.parent || !loadStatus(logDir, st.parent)) throw new Error(`worker ${mine} has no worker parent to hand off to (parent: ${st.parent ?? "(none)"}) — handoffs go up the worker tree only`);
1708
+ return deliverHandoff(logDir, parseHandoff(payload, {
1709
+ from: mine,
1710
+ to: st.parent
1711
+ }), deps);
1712
+ }
1713
+
1714
+ //#endregion
1715
+ //#region src/workers/worktree.ts
1716
+ /**
1717
+ * worktree.ts — one git worktree per writing worker.
1718
+ *
1719
+ * A run whose class edits files (implement, complex, plan) and whose cwd is a
1720
+ * git repository gets its own worktree by default: `git worktree add
1721
+ * <logDir>/worktrees/<id> -b worker/<id>` from the current HEAD. The worker
1722
+ * commits its own work on that branch — the no-commit rule applies to the
1723
+ * main branch only — and the parent (or the operator) merges the result back:
1724
+ *
1725
+ * pai worker merge <id> git merge --no-ff worker/<id> + remove worktree
1726
+ * pai worker discard <id> remove worktree and branch, keep nothing
1727
+ *
1728
+ * `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
1729
+ * place; `--no-worktree` opts out, `--worktree` forces one on.
1730
+ */
1731
+ function worktreesDir(logDir) {
1732
+ return join(logDir, "worktrees");
1733
+ }
1734
+ function worktreeBranch(id) {
1735
+ return `worker/${id}`;
1736
+ }
1737
+ function worktreePath(logDir, id) {
1738
+ return join(worktreesDir(logDir), id);
1739
+ }
1740
+ /** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
1741
+ function git(cwd, args) {
1742
+ try {
1743
+ return execFileSync("git", [
1744
+ "-C",
1745
+ cwd,
1746
+ ...args
1747
+ ], {
1748
+ encoding: "utf8",
1749
+ timeout: 3e4,
1750
+ stdio: [
1751
+ "ignore",
1752
+ "pipe",
1753
+ "pipe"
1754
+ ]
1755
+ }).trim();
1756
+ } catch (e) {
1757
+ const err = e;
1758
+ const why = (typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e);
1759
+ throw new Error(`git ${args.join(" ")} in ${cwd}: ${why.trim()}`);
1760
+ }
1761
+ }
1762
+ /** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
1763
+ function isGitRepo(cwd) {
1764
+ try {
1765
+ return git(cwd, ["rev-parse", "--git-dir"]) !== "";
1766
+ } catch {
1767
+ return false;
1768
+ }
1769
+ }
1770
+ /**
1771
+ * Does the prompt read as a read-only task? Writing classes default to a
1772
+ * worktree; a prompt that only asks to look at things should not pay for one.
1773
+ * First-word verbs plus the explicit markers people actually write.
1774
+ */
1775
+ function promptLooksReadonly(prompt) {
1776
+ const p = prompt.trim();
1777
+ if (!p) return true;
1778
+ if (/\b(read[- ]only|do not (modify|change|edit|write)|don'?t (modify|change|edit|write)|no changes)\b/i.test(p)) return true;
1779
+ return /^(review|read|analy[sz]e|research|summar[iy]|inspect|investigate|spotcheck|report|find|list|check|verify|describe|explain|show)\b/i.test(p);
1780
+ }
1781
+ /** The classes whose runs write files and therefore default to a worktree. */
1782
+ const WORKTREE_CLASSES = [
1783
+ "implement",
1784
+ "complex",
1785
+ "plan"
1786
+ ];
1787
+ /** Should this run get a worktree? Explicit flag first, then the default rule. */
1788
+ function worktreeWanted(flag, opts) {
1789
+ if (flag !== void 0) return flag;
1790
+ if (!opts.className || !WORKTREE_CLASSES.includes(opts.className)) return false;
1791
+ if (!isGitRepo(opts.cwd)) return false;
1792
+ return !promptLooksReadonly(opts.prompt ?? "");
1793
+ }
1794
+ /**
1795
+ * Create the worktree and branch for `id` from `cwd`'s HEAD. Throws when git
1796
+ * refuses (no commits yet, branch exists, …) — the caller decides whether to
1797
+ * degrade to an in-place run.
1798
+ */
1799
+ function addWorktree(logDir, id, cwd) {
1800
+ const dir = worktreePath(logDir, id);
1801
+ const branch = worktreeBranch(id);
1802
+ const base = git(cwd, ["rev-parse", "HEAD"]);
1803
+ git(cwd, [
1804
+ "worktree",
1805
+ "add",
1806
+ dir,
1807
+ "-b",
1808
+ branch
1809
+ ]);
1810
+ return {
1811
+ dir,
1812
+ branch,
1813
+ base
1814
+ };
1815
+ }
1816
+ /** Commits the branch collected on top of its base. */
1817
+ function commitsSince(dir, base) {
1818
+ try {
1819
+ return parseInt(git(dir, [
1820
+ "rev-list",
1821
+ "--count",
1822
+ `${base}..HEAD`
1823
+ ]), 10) || 0;
1824
+ } catch {
1825
+ return 0;
1826
+ }
1827
+ }
1828
+ /**
1829
+ * Record the worktree result in the status file: branch and commit count on
1830
+ * success, the worktree cleaned up on failure. Returns the updated status.
1831
+ */
1832
+ function recordWorktree(logDir, status, info, ok) {
1833
+ const s = { ...status };
1834
+ if (ok) {
1835
+ s.branch = info.branch;
1836
+ s.commits = commitsSince(info.dir, info.base);
1837
+ s.worktreeDir = info.dir;
1838
+ s.worktreeBase = info.base;
1839
+ } else {
1840
+ removeWorktree(s.cwd, info.dir, true);
1841
+ try {
1842
+ git(s.cwd, [
1843
+ "branch",
1844
+ "-D",
1845
+ info.branch
1846
+ ]);
1847
+ } catch {}
1848
+ s.branch = null;
1849
+ s.worktreeDir = null;
1850
+ s.worktreeBase = null;
1851
+ s.commits = null;
1852
+ }
1853
+ saveStatus(logDir, s);
1854
+ return s;
1855
+ }
1856
+ /** Remove a worktree directory from git's books and the filesystem. */
1857
+ function removeWorktree(cwd, dir, force) {
1858
+ try {
1859
+ git(cwd, [
1860
+ "worktree",
1861
+ "remove",
1862
+ ...force ? ["--force"] : [],
1863
+ dir
1864
+ ]);
1865
+ return;
1866
+ } catch {}
1867
+ if (existsSync(dir)) try {
1868
+ rmSync(dir, {
1869
+ recursive: true,
1870
+ force: true
1871
+ });
1872
+ git(cwd, ["worktree", "prune"]);
1873
+ } catch {}
1874
+ }
1875
+ /**
1876
+ * `pai worker merge <id>`: merge the worker's branch into the original
1877
+ * checkout with --no-ff (the merge commit names the worker), then remove the
1878
+ * worktree. The branch stays for the record; the status gains `merged: true`.
1879
+ */
1880
+ function mergeWorker(logDir, id) {
1881
+ const st = mustHaveBranch(logDir, id);
1882
+ if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
1883
+ git(st.cwd, [
1884
+ "merge",
1885
+ "--no-ff",
1886
+ st.branch,
1887
+ "-m",
1888
+ `merge worker ${id} (${st.label})`
1889
+ ]);
1890
+ removeWorktree(st.cwd, st.worktreeDir, false);
1891
+ saveStatus(logDir, {
1892
+ ...st,
1893
+ merged: true
1894
+ });
1895
+ return `merged ${st.branch} into ${st.cwd} (worktree removed)`;
1896
+ }
1897
+ /** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
1898
+ function discardWorker(logDir, id) {
1899
+ const st = mustHaveBranch(logDir, id);
1900
+ removeWorktree(st.cwd, st.worktreeDir, true);
1901
+ let branchGone = false;
1902
+ try {
1903
+ git(st.cwd, [
1904
+ "branch",
1905
+ "-D",
1906
+ st.branch
1907
+ ]);
1908
+ branchGone = true;
1909
+ } catch {
1910
+ branchGone = false;
1911
+ }
1912
+ saveStatus(logDir, {
1913
+ ...st,
1914
+ branch: null,
1915
+ worktreeDir: null,
1916
+ worktreeBase: null,
1917
+ commits: null,
1918
+ merged: false
1919
+ });
1920
+ return `discarded worker ${id}: worktree removed${branchGone ? `, branch ${st.branch} deleted` : ""}`;
1921
+ }
1922
+ function mustHaveBranch(logDir, id) {
1923
+ const st = loadStatus(logDir, id);
1924
+ if (!st) throw new Error(`no worker named "${id}"`);
1925
+ if (!st.branch || !st.worktreeDir) throw new Error(`worker ${id} has no worktree branch to ${st.branch ? "clean up" : "merge"} — it ran in place or its branch was already handled`);
1926
+ return st;
1927
+ }
1928
+ /**
1929
+ * The paragraph a worktree run's system prompt gains: it may commit on its
1930
+ * own branch (the no-commit rule holds for the main branch only), it must not
1931
+ * merge or push itself, and the parent or operator merges.
1932
+ */
1933
+ function worktreeSystemPrompt(id, branch, dir) {
1934
+ return [
1935
+ "You are running in your own git worktree:",
1936
+ ` ${dir} on branch ${branch} (worker id ${id}).`,
1937
+ "Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
1938
+ "the no-commit rule applies to the main branch only, and this is not it.",
1939
+ "Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`."
1940
+ ].join("\n");
1941
+ }
1942
+
1437
1943
  //#endregion
1438
1944
  //#region src/workers/report.ts
1439
1945
  /**
@@ -1444,6 +1950,8 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
1444
1950
  * and nothing else. The runner parses it out of the result; the viewer renders
1445
1951
  * it as a compact block instead of a wall of text.
1446
1952
  */
1953
+ /** The marker the runner puts on operator messages the worker must answer. */
1954
+ const OPERATOR_MARK = "[operator]";
1447
1955
  /** Appended to the caller's system prompt on every headless run (2h). */
1448
1956
  const WORKER_CONTRACT_PROMPT = [
1449
1957
  "You are a headless implementation worker, run non-interactively by an orchestrating session.",
@@ -1452,7 +1960,20 @@ const WORKER_CONTRACT_PROMPT = [
1452
1960
  "{\"changed\":[{\"path\":\"…\",\"summary\":\"…\"}],\"commands\":[\"…\"],\"checks\":[{\"name\":\"…\",\"ok\":true,\"detail\":\"…\"}],\"open\":[\"…\"],\"notes\":\"one line\"}",
1453
1961
  "changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
1454
1962
  "checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
1455
- "notes: one line, the headline a reviewer reads first."
1963
+ "notes: one line, the headline a reviewer reads first.",
1964
+ "",
1965
+ `Operator messages: a user turn starting with ${OPERATOR_MARK} was typed by the operator while you`,
1966
+ "run (the prompt of a resumed run is the operator's too). Answer it FIRST, in one or two plain lines —",
1967
+ "a question gets its answer, an instruction gets one line naming what will change — then continue the",
1968
+ "task you were on.",
1969
+ "",
1970
+ "Sub-workers: you may start your own workers with `pai worker run --class <class> -p '<prompt>'`",
1971
+ "(Bash tool). Your children run on their own provider and report back to you automatically when",
1972
+ "they finish. Send a handoff UP instead of doing work yourself when a cheaper provider would",
1973
+ "suffice, the task is out of your scope, or a decision is needed:",
1974
+ "`pai worker handoff '{\"kind\":\"proposal\",\"text\":\"…\",\"data\":{…}}'` (kinds: proposal,",
1975
+ "question, blocker; results are sent for you when you finish). Sibling workers are not a",
1976
+ "coordination path — there is no sideways channel; everything goes up to your parent."
1456
1977
  ].join("\n");
1457
1978
  /**
1458
1979
  * Extract the JSON report from a worker's final message. Accepts a bare
@@ -1603,120 +2124,45 @@ function describeMcp(config, claudeJson = CLAUDE_JSON) {
1603
2124
  }
1604
2125
 
1605
2126
  //#endregion
1606
- //#region src/workers/operator.ts
2127
+ //#region src/workers/proxy/server.ts
1607
2128
  /**
1608
- * operator.ts — talking to a running worker.
2129
+ * server.ts — the PAI worker proxy: Anthropic Messages API on the front,
2130
+ * OpenAI Chat Completions on the back, loopback only.
1609
2131
  *
1610
- * Headless workers run with `--input-format stream-json` and their stdin held
1611
- * open, so a run is a conversation: every line sent to the per-worker Unix
1612
- * socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
1613
- * mirrored into the transcript as an `operator` event (rendered with a »
1614
- * marker). Once the worker has finished its turn, stdin closes 2 s later
1615
- * unless a new message arrives — after that, `say` refuses and `resume`
1616
- * continues the same Claude session with the worker's context intact.
2132
+ * One proxy serves every OpenAI-protocol provider: Claude Code points
2133
+ * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
2134
+ * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
2135
+ * workers config, re-read per request so config edits apply without a
2136
+ * restart). `run` starts the proxy on demand (detached, pid file under the
2137
+ * logDir) and `pai worker proxy stop` stops it again.
2138
+ *
2139
+ * All translation lives in translate.ts; this file is only HTTP plumbing.
1617
2140
  */
1618
- function operatorSocketPath(logDir, id) {
1619
- return join(logDir, `${id}.sock`);
2141
+ const DEFAULT_PROXY_PORT = 8797;
2142
+ const HOST = "127.0.0.1";
2143
+ function proxyPidPath(logDir) {
2144
+ return join(logDir, "proxy.pid");
2145
+ }
2146
+ /** True when something already accepts connections on the port. */
2147
+ function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
2148
+ return new Promise((resolve) => {
2149
+ const sock = connect({
2150
+ port,
2151
+ host: HOST
2152
+ });
2153
+ const done = (ok) => {
2154
+ sock.removeAllListeners();
2155
+ sock.destroy();
2156
+ resolve(ok);
2157
+ };
2158
+ sock.setTimeout(timeoutMs, () => done(false));
2159
+ sock.once("connect", () => done(true));
2160
+ sock.once("error", () => done(false));
2161
+ });
1620
2162
  }
1621
2163
  /**
1622
- * The runner's side: listen on the worker socket, hand every received line to
1623
- * `onLine`. Returns the server (close it when the run ends; the socket file is
1624
- * unlinked on close, best effort).
1625
- */
1626
- function createOperatorServer(logDir, id, onLine) {
1627
- const path = operatorSocketPath(logDir, id);
1628
- try {
1629
- if (existsSync(path)) unlinkSync(path);
1630
- } catch {}
1631
- const server = createServer((socket) => {
1632
- let buf = "";
1633
- socket.on("data", (chunk) => {
1634
- buf += chunk.toString("utf8");
1635
- let nl;
1636
- while ((nl = buf.indexOf("\n")) >= 0) {
1637
- const line = buf.slice(0, nl).replace(/\r$/, "");
1638
- buf = buf.slice(nl + 1);
1639
- if (line.trim()) onLine(line);
1640
- socket.write("ok\n");
1641
- }
1642
- });
1643
- });
1644
- server.listen(path);
1645
- server.on("close", () => {
1646
- try {
1647
- if (existsSync(path)) unlinkSync(path);
1648
- } catch {}
1649
- });
1650
- return server;
1651
- }
1652
- /**
1653
- * `pai worker say <id> "<text>"`: forward one line to a running worker.
1654
- * Resolves "ok", rejects with a clear message when the worker is not running.
1655
- */
1656
- function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
1657
- const status = loadStatus(logDir, id);
1658
- if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
1659
- if (status.state !== "running" || !alive(status.pid)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} is not running (state: ${status.state}) — continue it instead with: pai worker resume ${id} "<text>"`));
1660
- const path = operatorSocketPath(logDir, id);
1661
- if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
1662
- return new Promise((resolve, reject) => {
1663
- const sock = connect(path);
1664
- const fail = (e) => {
1665
- sock.destroy();
1666
- reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
1667
- };
1668
- sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
1669
- sock.once("error", (e) => fail(e));
1670
- sock.once("connect", () => {
1671
- sock.write(text.replace(/\n/g, " ") + "\n");
1672
- });
1673
- sock.once("data", () => {
1674
- sock.end();
1675
- resolve("ok");
1676
- });
1677
- });
1678
- }
1679
-
1680
- //#endregion
1681
- //#region src/workers/proxy/server.ts
1682
- /**
1683
- * server.ts — the PAI worker proxy: Anthropic Messages API on the front,
1684
- * OpenAI Chat Completions on the back, loopback only.
1685
- *
1686
- * One proxy serves every OpenAI-protocol provider: Claude Code points
1687
- * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
1688
- * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
1689
- * workers config, re-read per request so config edits apply without a
1690
- * restart). `run` starts the proxy on demand (detached, pid file under the
1691
- * logDir) and `pai worker proxy stop` stops it again.
1692
- *
1693
- * All translation lives in translate.ts; this file is only HTTP plumbing.
1694
- */
1695
- const DEFAULT_PROXY_PORT = 8797;
1696
- const HOST = "127.0.0.1";
1697
- function proxyPidPath(logDir) {
1698
- return join(logDir, "proxy.pid");
1699
- }
1700
- /** True when something already accepts connections on the port. */
1701
- function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
1702
- return new Promise((resolve) => {
1703
- const sock = connect({
1704
- port,
1705
- host: HOST
1706
- });
1707
- const done = (ok) => {
1708
- sock.removeAllListeners();
1709
- sock.destroy();
1710
- resolve(ok);
1711
- };
1712
- sock.setTimeout(timeoutMs, () => done(false));
1713
- sock.once("connect", () => done(true));
1714
- sock.once("error", () => done(false));
1715
- });
1716
- }
1717
- /**
1718
- * Where the standalone proxy artefact lives, found from this module's own
1719
- * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
2164
+ * Where the standalone proxy artefact lives, found from this module's own
2165
+ * location (dist/cli bundle ../hooks/, dev checkout <repo>/dist/hooks).
1720
2166
  */
1721
2167
  function standaloneProxyPath() {
1722
2168
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -2014,6 +2460,10 @@ function stdinUserMessage(text) {
2014
2460
  }
2015
2461
  });
2016
2462
  }
2463
+ /** An operator line as the worker sees it: carrying the contract's marker. */
2464
+ function operatorUserText(text) {
2465
+ return `${OPERATOR_MARK} ${text}`;
2466
+ }
2017
2467
  /**
2018
2468
  * ISO stamp with seconds, attached to every mirrored event (2g): local time
2019
2469
  * with its offset (`2026-09-17T14:19:23+02:00`), so the viewer can render the
@@ -2049,6 +2499,12 @@ async function runWorker(opts) {
2049
2499
  if (!config.enabled) throw new Error("workers are off. Turn them on with: pai worker on\n(then the Agent-tool hook stops denying Anthropic subagents only when you do)");
2050
2500
  const logDir = workersLogDir(config);
2051
2501
  mkdirSync(logDir, { recursive: true });
2502
+ if (opts.className === "plan" && !opts._planner) {
2503
+ const { runPlanner } = await import("./planner-BDI7bE9B.mjs");
2504
+ return runPlanner(opts);
2505
+ }
2506
+ const parent = launchParent(opts.parent);
2507
+ if (parent) assertChildAllowed(logDir, parent, config.tree);
2052
2508
  const target = resolveTarget(config, logDir, {
2053
2509
  flagProvider: opts.providerFlag,
2054
2510
  className: opts.className
@@ -2068,10 +2524,13 @@ async function runWorker(opts) {
2068
2524
  claudeArgs: opts.claudeArgs,
2069
2525
  noPane: opts.noPane ?? false,
2070
2526
  cwd: opts.cwd,
2071
- parent: opts.parent,
2527
+ parent: parent ?? void 0,
2072
2528
  stage: opts.stage,
2073
2529
  quiet: opts.quiet,
2074
- onWorkerStart: opts.onWorkerStart
2530
+ onWorkerStart: opts.onWorkerStart,
2531
+ id: opts.id,
2532
+ worktreeFlag: opts.worktreeFlag,
2533
+ className: opts.className
2075
2534
  });
2076
2535
  return await executeRun({
2077
2536
  config,
@@ -2084,10 +2543,13 @@ async function runWorker(opts) {
2084
2543
  noPane: opts.noPane ?? false,
2085
2544
  mcpFlag: opts.mcpFlag,
2086
2545
  cwd: opts.cwd,
2087
- parent: opts.parent,
2546
+ parent: parent ?? void 0,
2088
2547
  stage: opts.stage,
2089
2548
  quiet: opts.quiet,
2090
2549
  onWorkerStart: opts.onWorkerStart,
2550
+ id: opts.id,
2551
+ worktreeFlag: opts.worktreeFlag,
2552
+ className: opts.className,
2091
2553
  reroutes: opts._reroutes ?? 0
2092
2554
  });
2093
2555
  } catch (e) {
@@ -2101,10 +2563,26 @@ async function executeRun(a) {
2101
2563
  let proxyUrl;
2102
2564
  if (target.provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${target.providerName}`;
2103
2565
  const env = buildRunEnv(target.provider, headless, proxyUrl);
2104
- const wid = newWorkerId();
2566
+ const wid = a.id ?? newWorkerId();
2105
2567
  const cwd = a.cwd ?? process.cwd();
2106
2568
  const term = process.env.ITERM_SESSION_ID ?? "";
2107
2569
  const session = resolveSession(term);
2570
+ let worktree = null;
2571
+ if (headless && worktreeWanted(a.worktreeFlag, {
2572
+ cwd,
2573
+ className: a.className,
2574
+ prompt: parsed.prompt
2575
+ })) try {
2576
+ worktree = addWorktree(logDir, wid, cwd);
2577
+ } catch (e) {
2578
+ const why = e.message;
2579
+ process.stderr.write(`pai worker: no worktree (${why}) — running in place\n`);
2580
+ appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
2581
+ id: wid,
2582
+ note: `no worktree: ${why}`
2583
+ });
2584
+ }
2585
+ env.PAI_WORKER_ID = wid;
2108
2586
  const status = {
2109
2587
  id: wid,
2110
2588
  pid: process.pid,
@@ -2126,6 +2604,11 @@ async function executeRun(a) {
2126
2604
  ...a.parent ? {
2127
2605
  parent: a.parent,
2128
2606
  stage: a.stage
2607
+ } : {},
2608
+ ...worktree ? {
2609
+ worktreeDir: worktree.dir,
2610
+ branch: worktree.branch,
2611
+ worktreeBase: worktree.base
2129
2612
  } : {}
2130
2613
  };
2131
2614
  saveStatus(logDir, status);
@@ -2165,11 +2648,12 @@ async function executeRun(a) {
2165
2648
  if (headless) {
2166
2649
  cmd.push("--output-format", "stream-json", "--verbose", "--input-format", "stream-json");
2167
2650
  if (!parsed.callerSystemPrompt) cmd.push("--append-system-prompt", WORKER_CONTRACT_PROMPT);
2651
+ if (worktree) cmd.push("--append-system-prompt", worktreeSystemPrompt(wid, worktree.branch, worktree.dir));
2168
2652
  }
2169
2653
  const t0 = Date.now();
2170
2654
  const proc = spawn(cmd[0], cmd.slice(1), {
2171
2655
  env,
2172
- cwd,
2656
+ cwd: worktree?.dir ?? cwd,
2173
2657
  stdio: headless ? [
2174
2658
  "pipe",
2175
2659
  "pipe",
@@ -2204,10 +2688,11 @@ async function executeRun(a) {
2204
2688
  }
2205
2689
  writeEvent({
2206
2690
  type: "operator",
2207
- text
2691
+ text,
2692
+ handoff: isHandoffMessage(text)
2208
2693
  });
2209
2694
  try {
2210
- proc.stdin?.write(stdinUserMessage(text) + "\n");
2695
+ proc.stdin?.write(stdinUserMessage(operatorUserText(text)) + "\n");
2211
2696
  } catch {}
2212
2697
  }) : null;
2213
2698
  let killed = false;
@@ -2313,7 +2798,8 @@ async function executeRun(a) {
2313
2798
  tools: status.tools,
2314
2799
  label
2315
2800
  });
2316
- if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport);
2801
+ if (worktree) recordWorktree(logDir, status, worktree, ok);
2802
+ if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport, worktreeExtras(status));
2317
2803
  const resultText = resultEvent?.result ?? "";
2318
2804
  if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
2319
2805
  setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
@@ -2334,20 +2820,64 @@ async function executeRun(a) {
2334
2820
  stage: a.stage,
2335
2821
  claudeArgs: a.claudeArgs,
2336
2822
  onWorkerStart: a.onWorkerStart,
2823
+ worktreeFlag: a.worktreeFlag,
2824
+ className: a.className,
2337
2825
  _reroutes: a.reroutes + 1
2338
2826
  });
2339
2827
  }
2340
2828
  }
2341
- return rc !== 0 ? rc : ok ? 0 : 1;
2829
+ const finalRc = rc !== 0 ? rc : ok ? 0 : 1;
2830
+ if (status.parent && isWorkerId(logDir, status.parent)) try {
2831
+ await deliverHandoff(logDir, {
2832
+ from: wid,
2833
+ to: status.parent,
2834
+ kind: "result",
2835
+ text: shortText(ctx.resultReport?.notes ?? resultEvent?.result ?? (ok ? "done" : "failed"), 400),
2836
+ data: {
2837
+ rc: finalRc,
2838
+ ok,
2839
+ ...status.branch ? {
2840
+ branch: status.branch,
2841
+ commits: status.commits ?? 0
2842
+ } : {},
2843
+ ...ctx.resultReport ? { report: ctx.resultReport } : {}
2844
+ }
2845
+ });
2846
+ } catch {}
2847
+ return finalRc;
2848
+ }
2849
+ /** The worktree fields printResult adds to a json payload, when there is one. */
2850
+ function worktreeExtras(s) {
2851
+ return s.branch ? {
2852
+ branch: s.branch,
2853
+ commits: s.commits ?? 0
2854
+ } : void 0;
2342
2855
  }
2343
2856
  async function executeCodexRun(a) {
2344
2857
  const { config, logDir, target, model, label, parsed, noPane } = a;
2345
2858
  if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine codex) supports headless runs only: pass the task with -p '<prompt>'`);
2346
2859
  const env = buildCodexEnv(target.provider);
2347
- const wid = newWorkerId();
2860
+ const wid = a.id ?? newWorkerId();
2348
2861
  const cwd = a.cwd ?? process.cwd();
2349
2862
  const term = process.env.ITERM_SESSION_ID ?? "";
2350
2863
  const session = resolveSession(term);
2864
+ let worktree = null;
2865
+ if (worktreeWanted(a.worktreeFlag, {
2866
+ cwd,
2867
+ className: a.className,
2868
+ prompt: parsed.prompt
2869
+ })) try {
2870
+ worktree = addWorktree(logDir, wid, cwd);
2871
+ } catch (e) {
2872
+ const why = e.message;
2873
+ process.stderr.write(`pai worker: no worktree (${why}) — running in place\n`);
2874
+ appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
2875
+ id: wid,
2876
+ note: `no worktree: ${why}`
2877
+ });
2878
+ }
2879
+ env.PAI_WORKER_ID = wid;
2880
+ const prompt = (worktree ? worktreeSystemPrompt(wid, worktree.branch, worktree.dir) + "\n\n" : "") + parsed.prompt;
2351
2881
  const status = {
2352
2882
  id: wid,
2353
2883
  pid: process.pid,
@@ -2369,6 +2899,11 @@ async function executeCodexRun(a) {
2369
2899
  ...a.parent ? {
2370
2900
  parent: a.parent,
2371
2901
  stage: a.stage
2902
+ } : {},
2903
+ ...worktree ? {
2904
+ worktreeDir: worktree.dir,
2905
+ branch: worktree.branch,
2906
+ worktreeBase: worktree.base
2372
2907
  } : {}
2373
2908
  };
2374
2909
  saveStatus(logDir, status);
@@ -2390,9 +2925,9 @@ async function executeCodexRun(a) {
2390
2925
  });
2391
2926
  if (!noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
2392
2927
  const t0 = Date.now();
2393
- const proc = spawn("codex", buildCodexArgs(parsed.prompt, parsed.callerModel ? void 0 : model), {
2928
+ const proc = spawn("codex", buildCodexArgs(prompt, parsed.callerModel ? void 0 : model), {
2394
2929
  env,
2395
- cwd,
2930
+ cwd: worktree?.dir ?? cwd,
2396
2931
  stdio: [
2397
2932
  "ignore",
2398
2933
  "pipe",
@@ -2479,10 +3014,29 @@ async function executeCodexRun(a) {
2479
3014
  tools: status.tools,
2480
3015
  label
2481
3016
  });
2482
- if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report);
2483
- return rc !== 0 ? rc : ok ? 0 : 1;
3017
+ if (worktree) recordWorktree(logDir, status, worktree, ok);
3018
+ if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report, worktreeExtras(status));
3019
+ const finalRc = rc !== 0 ? rc : ok ? 0 : 1;
3020
+ if (status.parent && isWorkerId(logDir, status.parent)) try {
3021
+ await deliverHandoff(logDir, {
3022
+ from: wid,
3023
+ to: status.parent,
3024
+ kind: "result",
3025
+ text: shortText(report?.notes ?? finalText ?? (ok ? "done" : "failed"), 400),
3026
+ data: {
3027
+ rc: finalRc,
3028
+ ok,
3029
+ ...status.branch ? {
3030
+ branch: status.branch,
3031
+ commits: status.commits ?? 0
3032
+ } : {},
3033
+ ...report ? { report } : {}
3034
+ }
3035
+ });
3036
+ } catch {}
3037
+ return finalRc;
2484
3038
  }
2485
- function printResult(fmt, resultEvent, rc, logDir, wid, report) {
3039
+ function printResult(fmt, resultEvent, rc, logDir, wid, report, extras) {
2486
3040
  if (fmt === "stream-json") return;
2487
3041
  if (fmt === "json") {
2488
3042
  const payload = report ? {
@@ -2491,11 +3045,15 @@ function printResult(fmt, resultEvent, rc, logDir, wid, report) {
2491
3045
  result: "no result event",
2492
3046
  rc
2493
3047
  },
2494
- report
2495
- } : resultEvent ?? {
2496
- is_error: true,
2497
- result: "no result event",
2498
- rc
3048
+ report,
3049
+ ...extras
3050
+ } : {
3051
+ ...resultEvent ?? {
3052
+ is_error: true,
3053
+ result: "no result event",
3054
+ rc
3055
+ },
3056
+ ...extras
2499
3057
  };
2500
3058
  console.log(JSON.stringify(payload));
2501
3059
  return;
@@ -2763,1269 +3321,5 @@ async function runChain(opts, deps = {}) {
2763
3321
  }
2764
3322
 
2765
3323
  //#endregion
2766
- //#region src/workers/chatui.ts
2767
- /**
2768
- * chatui.ts — the chat line of a `follow` pane.
2769
- *
2770
- * A follow pane with a target behaves like a small chat, Claude Code style:
2771
- * the transcript lives in a terminal scroll region that ends two rows above
2772
- * the pane's bottom; the last two rows are fixed — the prompt row (`› `,
2773
- * readline line editing) and the ticker row. A transcript line is inserted
2774
- * above the fixed rows with a save-cursor / scroll-region / restore-cursor
2775
- * write that never touches them; the scroll region makes the transcript roll
2776
- * inside itself. Everything here builds strings (or parses one line), so the
2777
- * tests assert exact byte sequences — no terminal needed.
2778
- */
2779
- /** The prompt marker of the chat row. */
2780
- const CHAT_PROMPT = "› ";
2781
- /** Dim hint shown once behind the cursor until the first line is typed. */
2782
- const CHAT_HINT = "type here and press Enter · /help for commands";
2783
- /** What `/help` prints (one command per line, dim). */
2784
- const CHAT_HELP = [
2785
- "/quit close this pane",
2786
- "/resume <text> continue the finished worker with <text>",
2787
- "/status one-line worker status",
2788
- "anything else is sent to the worker — said while it runs, resumed after"
2789
- ];
2790
- /** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
2791
- function endOfEscape(s, i) {
2792
- const n = s[i + 1];
2793
- if (n === "[") {
2794
- let j = i + 2;
2795
- while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
2796
- return Math.min(s.length, j + 1);
2797
- }
2798
- if (n === "]") {
2799
- let j = i + 2;
2800
- while (j < s.length && s[j] !== "\x07") j++;
2801
- return Math.min(s.length, j + 1);
2802
- }
2803
- return i + 2;
2804
- }
2805
- /** Printable columns of `s` — ANSI escape sequences measure zero. */
2806
- function visibleWidth(s) {
2807
- let w = 0;
2808
- let i = 0;
2809
- while (i < s.length) {
2810
- if (s[i] === "\x1B") {
2811
- i = endOfEscape(s, i);
2812
- continue;
2813
- }
2814
- w += 1;
2815
- i += 1;
2816
- }
2817
- return w;
2818
- }
2819
- /**
2820
- * The SGR sequences in effect at `upto`: everything opened since the last
2821
- * reset, in order. Anything that is not an SGR escape is ignored (it does
2822
- * not change colour state).
2823
- */
2824
- function sgrStateAt(text, upto) {
2825
- const open = [];
2826
- let i = 0;
2827
- while (i < Math.min(upto, text.length)) {
2828
- if (text[i] === "\x1B") {
2829
- const end = endOfEscape(text, i);
2830
- const esc = text.slice(i, end);
2831
- if (/^\x1b\[[0-9;]*m$/.test(esc)) {
2832
- const params = esc.slice(2, -1);
2833
- if (params === "" || params.split(";").includes("0")) open.length = 0;
2834
- if (!(params === "" || params === "0")) open.push(esc);
2835
- }
2836
- i = end;
2837
- continue;
2838
- }
2839
- i += 1;
2840
- }
2841
- return open;
2842
- }
2843
- /**
2844
- * Wrap one rendered row to `width` printable columns. Breaks on whitespace
2845
- * where possible, hard-wraps words longer than the width, never splits an
2846
- * ANSI escape, and re-opens the colours it wraps inside of, so a diff row
2847
- * keeps its `-`/`+` colour on every continuation row.
2848
- */
2849
- function wrapText(text, width) {
2850
- if (width < 1 || visibleWidth(text) <= width) return [text];
2851
- const chars = [];
2852
- for (let i = 0; i < text.length; i++) {
2853
- if (text[i] === "\x1B") {
2854
- i = endOfEscape(text, i) - 1;
2855
- continue;
2856
- }
2857
- chars.push(i);
2858
- }
2859
- const words = [];
2860
- {
2861
- let s = -1;
2862
- let w = 0;
2863
- for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
2864
- if (s >= 0) {
2865
- words.push({
2866
- s,
2867
- e: k - 1,
2868
- w
2869
- });
2870
- s = -1;
2871
- w = 0;
2872
- }
2873
- } else {
2874
- if (s < 0) s = k;
2875
- w++;
2876
- }
2877
- }
2878
- if (words.length && words[0].s > 0) words[0] = {
2879
- s: 0,
2880
- e: words[0].e,
2881
- w: words[0].w + words[0].s
2882
- };
2883
- const spans = [];
2884
- let cs = -1;
2885
- let ce = -1;
2886
- for (const word of words) {
2887
- if (word.w > width) {
2888
- const room = cs < 0 ? 0 : width - (ce - cs + 1);
2889
- const take = room > 0 ? ce + room - word.s + 1 : 0;
2890
- if (take > 0) spans.push([cs, ce + room]);
2891
- else if (cs >= 0) spans.push([cs, ce]);
2892
- let pos = word.s + Math.max(0, take);
2893
- let remaining = word.w - Math.max(0, take);
2894
- while (remaining > width) {
2895
- spans.push([pos, pos + width - 1]);
2896
- pos += width;
2897
- remaining -= width;
2898
- }
2899
- cs = pos;
2900
- ce = pos + remaining - 1;
2901
- continue;
2902
- }
2903
- if (cs < 0) {
2904
- cs = word.s;
2905
- ce = word.e;
2906
- continue;
2907
- }
2908
- if (word.e - cs + 1 <= width) {
2909
- ce = word.e;
2910
- continue;
2911
- }
2912
- spans.push([cs, ce]);
2913
- cs = word.s;
2914
- ce = word.e;
2915
- }
2916
- if (cs >= 0) spans.push([cs, ce]);
2917
- const out = [];
2918
- for (const [a, b] of spans) {
2919
- const from = chars[a];
2920
- let end = chars[b] + 1;
2921
- while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
2922
- let piece = text.slice(from, end);
2923
- const reopen = sgrStateAt(text, from);
2924
- if (reopen.length) piece = reopen.join("") + piece;
2925
- if (sgrStateAt(text, end).length) piece += "\x1B[0m";
2926
- out.push(piece);
2927
- }
2928
- return out.length ? out : [""];
2929
- }
2930
- /** Restrict scrolling to the transcript region (rows 1 … rows-2). */
2931
- function chatScrollRegion(rows) {
2932
- return `\x1b[1;${Math.max(1, rows - 2)}r`;
2933
- }
2934
- /**
2935
- * Enter the chat layout: clear the pane, set the scroll region, park the
2936
- * cursor at column 1 of the prompt row (rows-1). The ticker owns row `rows`.
2937
- */
2938
- function chatEnter(rows) {
2939
- return "\x1B[2J" + chatScrollRegion(rows) + `\x1b[${Math.max(1, rows - 1)};1H`;
2940
- }
2941
- /** Leave it: reset the scroll region, show the cursor, drop to the last row. */
2942
- function chatLeave(rows) {
2943
- return `[?25h\x1b[${Math.max(1, rows)};1H`;
2944
- }
2945
- /** Redraw the ticker on its own row without moving the user's cursor. */
2946
- function chatTickerRow(text, rows) {
2947
- return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + "\x1B8";
2948
- }
2949
- /** Move to column 1 of the prompt row and draw prompt (and hint). */
2950
- function chatPromptRow(rows, prompt = CHAT_PROMPT, hint) {
2951
- return `\x1b[${Math.max(1, rows - 1)};1H` + prompt + (hint ?? "");
2952
- }
2953
- /**
2954
- * Insert one transcript row above the fixed prompt/ticker rows. While the
2955
- * region is still filling (`fill < regionRows`) the row is placed top-down;
2956
- * once full, the cursor moves to the region's bottom row and a newline
2957
- * scrolls the region up by one — the two fixed rows are never touched. The
2958
- * user's cursor is saved before and restored after, so readline keeps its
2959
- * position on the prompt row.
2960
- */
2961
- function chatInsertLine(line, fill, regionRows) {
2962
- return {
2963
- seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
2964
- fill: Math.min(fill + 1, regionRows)
2965
- };
2966
- }
2967
- /**
2968
- * One submitted prompt line → what to do with it. `/help`, `/quit`,
2969
- * `/status` and `/resume <text>` are commands (a bare `/resume` comes back
2970
- * with empty text so the caller can print its usage); anything else,
2971
- * including any other `/word`, is a message for the worker.
2972
- */
2973
- function parseChatLine(raw) {
2974
- const text = raw.trim();
2975
- if (text === "/help") return { kind: "help" };
2976
- if (text === "/quit") return { kind: "quit" };
2977
- if (text === "/status") return { kind: "status" };
2978
- if (text.startsWith("/resume")) return {
2979
- kind: "resume",
2980
- text: text.slice(7).trim()
2981
- };
2982
- return {
2983
- kind: "message",
2984
- text
2985
- };
2986
- }
2987
- /**
2988
- * The auto-exit countdown must not fire while the prompt holds unsent text:
2989
- * true while it does. null/undefined (no prompt wired) never holds.
2990
- */
2991
- function holdAutoExit(promptText) {
2992
- return typeof promptText === "string" && promptText.trim() !== "";
2993
- }
2994
-
2995
- //#endregion
2996
- //#region src/workers/render.ts
2997
- /**
2998
- * render.ts — turn worker events into the lines a human reads.
2999
- *
3000
- * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
3001
- * and searches, `$` for shell, magenta `~` + red/green diff for edits, green
3002
- * `+` for writes), Read-result trimming (first 3 lines then a count), and the
3003
- * result footer. Colors are applied only when the output is a TTY, so MCP
3004
- * tool output stays plain text.
3005
- */
3006
- const CODES = {
3007
- dim: "2",
3008
- bold: "1",
3009
- red: "31",
3010
- green: "32",
3011
- yellow: "33",
3012
- blue: "34",
3013
- mag: "35",
3014
- cyan: "36"
3015
- };
3016
- function makeColor(enabled) {
3017
- return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
3018
- }
3019
- /** Path relative to cwd when it lies inside it, otherwise unchanged. */
3020
- function relPath(path, cwd) {
3021
- if (!path || !cwd) return path;
3022
- let r;
3023
- try {
3024
- r = relative(cwd, path);
3025
- } catch {
3026
- return path;
3027
- }
3028
- return r.startsWith("..") ? path : r;
3029
- }
3030
- /** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
3031
- function unifiedDiffLines(oldStr, newStr) {
3032
- const oldL = oldStr.split("\n");
3033
- const newL = newStr.split("\n");
3034
- let start = 0;
3035
- while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
3036
- let endOld = oldL.length;
3037
- let endNew = newL.length;
3038
- while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
3039
- endOld--;
3040
- endNew--;
3041
- }
3042
- const out = [];
3043
- for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
3044
- for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
3045
- return out;
3046
- }
3047
- /**
3048
- * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
3049
- * negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
3050
- * null when the stamp cannot be parsed. Offsets make this testable without
3051
- * depending on the machine's zone; the default is this machine's.
3052
- */
3053
- function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
3054
- const t = Date.parse(ts);
3055
- if (Number.isNaN(t)) return null;
3056
- return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
3057
- }
3058
- /** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
3059
- function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
3060
- const t = Date.parse(ts);
3061
- if (Number.isNaN(t)) return null;
3062
- return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
3063
- }
3064
- /**
3065
- * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
3066
- * the worker tag in front when several run at once. Continuation lines get
3067
- * blanks of the same width so wrapped text stays aligned; when the viewer
3068
- * wraps lines itself, continuation rows carry the `│` bar instead (barCont)
3069
- * so the bar runs unbroken down the pane. null when the event carries no
3070
- * stamp (logs from before 2g render with the plain prefix). The time is the
3071
- * stamp's wall clock at `offMin` — the default renders local time, whatever
3072
- * zone stamped the log (old logs were stamped in UTC).
3073
- */
3074
- function gutterFor(c, e, tag, offMin) {
3075
- if (!e._ts) return null;
3076
- const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
3077
- const head = tag ? `${tag} ${time}` : time;
3078
- const width = head.length + 3;
3079
- return {
3080
- first: c("dim", `${head} │ `),
3081
- cont: " ".repeat(width),
3082
- barCont: c("dim", `${" ".repeat(head.length)} │ `),
3083
- width
3084
- };
3085
- }
3086
- /**
3087
- * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
3088
- * basename for the file tools, the bare name for everything else.
3089
- */
3090
- function tickerTool(name, inp) {
3091
- const i = typeof inp === "object" && inp !== null ? inp : {};
3092
- const get = (k) => typeof i[k] === "string" ? i[k] : "";
3093
- if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
3094
- if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
3095
- return name;
3096
- }
3097
- /**
3098
- * The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
3099
- * seconds since the last *rendered* event, the worker's last stated intent
3100
- * (its last assistant text, ≤60 chars) and the tool it is currently running.
3101
- * Parts that are empty drop out.
3102
- */
3103
- function tickerText(secs, intent, tool, meter) {
3104
- const head = `⋯ ${secs}s`;
3105
- const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
3106
- const line = tail ? `${head} · ${tail}` : head;
3107
- return meter ? `${line} · ${meter}` : line;
3108
- }
3109
- /**
3110
- * One blank line between turns, none inside one: a blank goes before an
3111
- * assistant message that follows a tool result or an operator message (the
3112
- * worker starting to speak again after its tools were answered / it was told
3113
- * something), not between the text, tool calls and results of one turn.
3114
- */
3115
- function blankBetween(prev, e) {
3116
- if (!prev) return false;
3117
- if (e.type !== "assistant") return false;
3118
- return prev.type === "user" || prev.type === "operator";
3119
- }
3120
- /** The worker's last stated intent: the first line of its last text, ≤60. */
3121
- function intentOf(text) {
3122
- return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
3123
- }
3124
- /** Compact token count: 84k, 200k, 900. */
3125
- function fmtK(n) {
3126
- return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
3127
- }
3128
- /**
3129
- * The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.
3130
- * null when the numbers are missing or below `minPct` (the table only shows
3131
- * it past 60; the pane liveness line always shows it).
3132
- */
3133
- function contextMeter(c, s, minPct = 0) {
3134
- const pct = contextPercent(s);
3135
- if (pct === null || pct <= minPct) return null;
3136
- const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;
3137
- if (pct > 85) return c("red", label);
3138
- if (pct > 70) return c("yellow", label);
3139
- return label;
3140
- }
3141
- function renderToolUse(c, prefix, name, inp, cwd) {
3142
- const i = typeof inp === "object" && inp !== null ? inp : {};
3143
- const get = (k) => typeof i[k] === "string" ? i[k] : "";
3144
- if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
3145
- if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
3146
- if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
3147
- if (name === "Edit") {
3148
- const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
3149
- for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
3150
- else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
3151
- else out.push(`${prefix} ${c("dim", line)}`);
3152
- return out;
3153
- }
3154
- if (name === "Write") {
3155
- const n = get("content").split("\n").length;
3156
- return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
3157
- }
3158
- if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
3159
- return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
3160
- }
3161
- /** Render one stream-json event; `tools` maps tool_use_id → tool name. */
3162
- function renderEvent(c, prefix, e, cwd, tools, gutter) {
3163
- const out = [];
3164
- if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
3165
- else if (e.type === "operator") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
3166
- else if (e.type === "assistant") {
3167
- for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) for (const ln of (b.text ?? "").trim().split("\n")) out.push(`${prefix}${ln}`);
3168
- else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
3169
- } else if (e.type === "user") for (const b of e.message?.content ?? []) {
3170
- if (b.type !== "tool_result") continue;
3171
- let content = b.content ?? "";
3172
- if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
3173
- const text = String(content);
3174
- const isError = b.is_error === true;
3175
- if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
3176
- const lines = text.split("\n");
3177
- for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
3178
- if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
3179
- } else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
3180
- else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
3181
- }
3182
- else if (e.type === "result") {
3183
- const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
3184
- out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
3185
- const report = parseWorkerReport(String(e.result ?? ""));
3186
- if (report) out.push(...renderReport(c, prefix, report, cwd));
3187
- else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
3188
- }
3189
- if (!gutter) return out;
3190
- return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
3191
- }
3192
- /** Transcript header: id, provider, label, session name, project dir. */
3193
- function headerLine(c, s) {
3194
- const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
3195
- const sep = bits ? ` ${bits}` : "";
3196
- return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
3197
- }
3198
- /** The chain label behind a stage label: strip the trailing " · <stage>". */
3199
- function chainLabelOf(stages) {
3200
- const first = stages[0];
3201
- if (!first) return "";
3202
- const suffix = first.stage ? ` · ${first.stage}` : "";
3203
- return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
3204
- }
3205
- /**
3206
- * The ps table (RUNNING + FINISHED last 8). Chain stages carry `parent` and
3207
- * render as a tree under one `chain <id>` header; plain workers render as
3208
- * before.
3209
- */
3210
- function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date()) {
3211
- const running = [];
3212
- const done = [];
3213
- for (const s of statuses) if (s.state === "running" && alive(s.pid)) running.push(s);
3214
- else {
3215
- if (s.state === "running") s.state = "lost";
3216
- done.push(s);
3217
- }
3218
- const group = (list) => {
3219
- const out = [];
3220
- for (const s of list) out.push({
3221
- s,
3222
- chain: s.parent ?? null
3223
- });
3224
- return out;
3225
- };
3226
- const treeLine = (line, chain, last) => {
3227
- if (chain === null) return line;
3228
- const mark = last ? "└" : "├";
3229
- const bar = last ? " " : "│";
3230
- return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
3231
- };
3232
- const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
3233
- lines.push(c("bold", `RUNNING (${running.length})`));
3234
- if (!running.length) lines.push(" none");
3235
- const runEntries = group(running);
3236
- for (let i = 0; i < runEntries.length; i++) {
3237
- const { s, chain } = runEntries[i];
3238
- if (chain) {
3239
- const prev = runEntries[i - 1];
3240
- if (!prev || prev.chain !== chain) {
3241
- const stages = runEntries.filter((e) => e.chain === chain).map((e) => e.s);
3242
- lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
3243
- }
3244
- }
3245
- const last = !chain || !runEntries[i + 1] || runEntries[i + 1].chain !== chain;
3246
- const meter = contextMeter(c, s, 60);
3247
- lines.push(treeLine(` ${c("cyan", s.id)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${meter ? " " + meter : ""}`, chain, last));
3248
- lines.push(treeLine(` task: ${s.label}`, chain, last));
3249
- lines.push(treeLine(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
3250
- }
3251
- lines.push("");
3252
- lines.push(c("bold", "FINISHED (last 8)"));
3253
- const doneEntries = group(done.slice(-8));
3254
- for (let i = 0; i < doneEntries.length; i++) {
3255
- const { s, chain } = doneEntries[i];
3256
- if (chain) {
3257
- const prev = doneEntries[i - 1];
3258
- if (!prev || prev.chain !== chain) {
3259
- const stages = doneEntries.filter((e) => e.chain === chain).map((e) => e.s);
3260
- lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
3261
- }
3262
- }
3263
- const last = !chain || !doneEntries[i + 1] || doneEntries[i + 1].chain !== chain;
3264
- const col = s.state === "done" ? "green" : "red";
3265
- const tag = sessionTag(s);
3266
- lines.push(treeLine(` ${s.id} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)} ${s.label}`, chain, last));
3267
- }
3268
- lines.push("");
3269
- lines.push(c("dim", "worker follow live transcript of running workers"));
3270
- lines.push(c("dim", "worker <id> replay one worker"));
3271
- lines.push(c("dim", scopeLabel));
3272
- return lines.join("\n");
3273
- }
3274
- /** One-line status-bar summary (same shape glm-ps --status printed). */
3275
- function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(true)) {
3276
- if (!mine.length) return "";
3277
- const running = mine.filter((s) => s.state === "running" && alive(s.pid));
3278
- const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
3279
- const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
3280
- const ok = doneToday.filter((s) => s.state === "done").length;
3281
- const bad = doneToday.length - ok;
3282
- const providers = new Set(running.map((s) => s.provider));
3283
- let head = `${providers.size === 1 ? [...providers][0] : "workers"} ▶${running.length}`;
3284
- const parts = running.slice(0, 3).map((s) => {
3285
- const meter = contextMeter(c, s, 60);
3286
- return `${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}` + (meter ? ` ${meter}` : "");
3287
- });
3288
- if (parts.length) head += " " + parts.join(" | ");
3289
- if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
3290
- return head;
3291
- }
3292
-
3293
- //#endregion
3294
- //#region src/workers/viewer.ts
3295
- /**
3296
- * viewer.ts — ps / follow / replay / status line over the worker logDir.
3297
- *
3298
- * Scoping: workers launched from this terminal's AIBroker session (or, as the
3299
- * fallback, its iTerm tab) unless --all or an explicit worker id is given.
3300
- * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
3301
- *
3302
- * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
3303
- * `_ts` stamp on every mirrored event (local wall clock — stamps carry a local
3304
- * offset and old UTC stamps are converted), the worker tag in front when
3305
- * several run at once, a date separator when the day changes, and — on a TTY —
3306
- * a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
3307
- * is rewritten in place between events. Attaching to a worker that is already
3308
- * running first replays its last events (backfill), then continues live.
3309
- *
3310
- * Following one worker turns the pane into a small chat (see chatui.ts): the
3311
- * transcript scrolls in a region that ends two rows above the bottom, the
3312
- * prompt row (`› `, readline editing) and the ticker row stay fixed, and every
3313
- * submitted line is said to the worker while it runs and resumes it (same
3314
- * Claude session) once it has finished. Lines the pane wraps itself keep the
3315
- * `│` bar on continuation rows, so no content ever lands left of the bar.
3316
- * Non-TTY output keeps the plain scrolling behaviour.
3317
- */
3318
- /** How many existing events a fresh follow pane replays before going live. */
3319
- const BACKFILL_EVENTS = 200;
3320
- function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
3321
- const c = makeColor(color);
3322
- const term = env.ITERM_SESSION_ID ?? "";
3323
- const statuses = loadStatuses(logDir);
3324
- return renderTable(c, showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term)), showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`);
3325
- }
3326
- function initialFollowState(intent = "waiting for first event") {
3327
- return {
3328
- lastDay: "",
3329
- tools: {},
3330
- intent,
3331
- tool: "",
3332
- prev: null
3333
- };
3334
- }
3335
- /**
3336
- * Gutter the rendered body of one event, wrapping when the pane width is
3337
- * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
3338
- * the stamped gutter, later rows of the event blanks — exactly the pre-chat
3339
- * rendering. Wrapped: every row is folded at `wrapWidth` columns and each
3340
- * continuation row carries the blank gutter with the `│` bar, so the bar runs
3341
- * unbroken down the pane and no content ever lands left of it.
3342
- */
3343
- function gutterBody(body, gutter, wrapWidth) {
3344
- if (!gutter) return body;
3345
- if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
3346
- const out = [];
3347
- for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
3348
- return out;
3349
- }
3350
- /**
3351
- * Render one event and advance the follow state. Everything the viewer shows
3352
- * between events of one worker comes from here: replay, the backfill on
3353
- * attach and the live tail all use it, so they space identically — events
3354
- * back to back, one blank line between turns. `activity` is false for events
3355
- * that render nothing (stream noise, empty tool results): they leave the
3356
- * ticker's "since last event" clock running. `wrapWidth` (the pane's column
3357
- * count, re-read on resize) makes the viewer wrap rows itself; null keeps
3358
- * the terminal's own wrapping.
3359
- */
3360
- function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
3361
- const tools = { ...s.tools };
3362
- if (e.type === "assistant") {
3363
- for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
3364
- }
3365
- const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
3366
- const state = {
3367
- lastDay: day && day !== s.lastDay ? day : s.lastDay,
3368
- tools,
3369
- intent: s.intent,
3370
- tool: s.tool,
3371
- prev: e
3372
- };
3373
- if (e.type === "assistant") {
3374
- for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
3375
- else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
3376
- }
3377
- const gutter = gutterFor(c, e, tag, offMin);
3378
- const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
3379
- const lines = blankBetween(s.prev, e) ? ["", ...body] : body;
3380
- return {
3381
- lines,
3382
- day: day && day !== s.lastDay ? day : null,
3383
- activity: lines.some((ln) => ln !== ""),
3384
- state
3385
- };
3386
- }
3387
- /** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
3388
- function rawDay(ts) {
3389
- return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
3390
- }
3391
- /**
3392
- * The events a fresh follow replays before going live: the last `cap`
3393
- * non-empty log lines, oldest first.
3394
- */
3395
- function backfillLines(raw, cap = BACKFILL_EVENTS) {
3396
- return raw.split("\n").filter((l) => l.trim()).slice(-cap);
3397
- }
3398
- /** The rendered transcript of one worker, from the start, as one string. */
3399
- function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
3400
- const path = eventsPath(logDir, wid);
3401
- if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
3402
- const c = makeColor(color);
3403
- const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
3404
- id: wid,
3405
- label: "",
3406
- cwd: "",
3407
- provider: ""
3408
- };
3409
- const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
3410
- const out = [headerLine(c, st)];
3411
- const raw = readFileSync(path, "utf8");
3412
- const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
3413
- let state = initialFollowState("");
3414
- for (const line of lines) {
3415
- if (!line.trim()) continue;
3416
- let e;
3417
- try {
3418
- e = JSON.parse(line);
3419
- } catch {
3420
- continue;
3421
- }
3422
- const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
3423
- if (step.day) out.push(c("dim", `── ${step.day} ──`));
3424
- out.push(...step.lines);
3425
- state = step.state;
3426
- }
3427
- return out.join("\n");
3428
- }
3429
- /**
3430
- * The follow exit decision for one worker: it is over once its result event
3431
- * was rendered, or once its status left "running" while its pid is gone — a
3432
- * worker killed without writing a result still ends. A status never read
3433
- * (undefined) means "not over": follow keeps waiting for the first event.
3434
- */
3435
- function workerEnded(resultRendered, state, pidAlive) {
3436
- return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
3437
- }
3438
- /**
3439
- * One typed stdin line → say (worker running) or resume (worker finished):
3440
- * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
3441
- * missing target are ignored.
3442
- */
3443
- function makeOperatorInput(d) {
3444
- return (raw) => {
3445
- const text = raw.trim();
3446
- if (!text) return;
3447
- const id = d.target();
3448
- if (id === null) return;
3449
- d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
3450
- if (d.workerKnown(id)) d.resume(text, id);
3451
- else d.note(d.paint("red", `» ${e.message}`));
3452
- });
3453
- };
3454
- }
3455
- /**
3456
- * Tail one worker (target) or the running workers of this scope, live.
3457
- * Workers whose event log already exists are first replayed (last
3458
- * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
3459
- * `autoExit` seconds after its end; without one, exit once no worker in
3460
- * scope has run for that many seconds in a row, never within the first 30 s.
3461
- *
3462
- * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
3463
- * a scroll region, fixed prompt and ticker rows, submitted lines said to the
3464
- * worker (or resuming it after it finished) and echoed as `»` rows. A draft
3465
- * in the prompt holds the auto-exit countdown. Non-TTY output keeps the
3466
- * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
3467
- */
3468
- async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
3469
- const c = makeColor(color);
3470
- const out_ = io?.stdout ?? process.stdout;
3471
- const in_ = io?.stdin ?? process.stdin;
3472
- const tty = out_.isTTY === true || env.FORCE_TTY === "1";
3473
- const term = env.ITERM_SESSION_ID ?? "";
3474
- const scopeTab = target || showAll ? "" : currentTabKey(env);
3475
- const handles = /* @__PURE__ */ new Map();
3476
- const seenHeader = /* @__PURE__ */ new Set();
3477
- const finished = /* @__PURE__ */ new Set();
3478
- const states = /* @__PURE__ */ new Map();
3479
- const started = Date.now();
3480
- let idleSince = null;
3481
- let aborted = false;
3482
- let lastEventAt = Date.now();
3483
- let meterStatus = null;
3484
- const onInt = () => {
3485
- aborted = true;
3486
- };
3487
- process.once("SIGINT", onInt);
3488
- const chat = tty && target !== null;
3489
- let rows = out_.rows ?? 24;
3490
- const columns = () => typeof out_.columns === "number" ? out_.columns : null;
3491
- let fill = 0;
3492
- const regionRows = () => Math.max(1, rows - 2);
3493
- /** Every line the pane shows goes through here: plain newline, or a row
3494
- * inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */
3495
- const out = (line) => {
3496
- if (!chat) {
3497
- out_.write(line + "\n");
3498
- return;
3499
- }
3500
- const r = chatInsertLine(line, fill, regionRows());
3501
- out_.write(r.seq);
3502
- fill = r.fill;
3503
- };
3504
- const eraseLiveness = () => {
3505
- if (chat) return;
3506
- if (tty) out_.write("\r\x1B[K");
3507
- };
3508
- let ticker = initialFollowState();
3509
- const writeLiveness = () => {
3510
- if (!tty) return;
3511
- const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
3512
- const meter = meterStatus ? contextMeter(c, meterStatus) : null;
3513
- const text = tickerText(secs, ticker.intent, ticker.tool, meter);
3514
- if (chat) out_.write(chatTickerRow(text, rows));
3515
- else {
3516
- out_.write("\r\x1B[K");
3517
- out_.write(text);
3518
- }
3519
- };
3520
- const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
3521
- const emitEvent = (e, wid, st, multi) => {
3522
- if (e.type === "operator" && chat && suppressMirror(String(e.text ?? ""))) return;
3523
- if (!seenHeader.has(wid)) {
3524
- eraseLiveness();
3525
- out(headerLine(c, st));
3526
- seenHeader.add(wid);
3527
- }
3528
- const step = applyEvent(c, states.get(wid) ?? initialFollowState(), e, st.cwd ?? "", multi ? c("cyan", wid.slice(-4)) : void 0, void 0, columns());
3529
- if (step.day) out(c("dim", `── ${step.day} ──`));
3530
- states.set(wid, step.state);
3531
- if (wid === target) ticker = step.state;
3532
- eraseLiveness();
3533
- for (const ln of step.lines) out(ln);
3534
- if (step.activity) lastEventAt = Date.now();
3535
- if (e.type === "result") {
3536
- meterStatus = st;
3537
- finished.add(wid);
3538
- } else if (wid === target || target === null) meterStatus = st;
3539
- };
3540
- const attachHandle = (wid, path, st, multi) => {
3541
- const handle = {
3542
- fd: openSync(path, "r"),
3543
- buf: ""
3544
- };
3545
- try {
3546
- const existing = readFileSync(path, "utf8");
3547
- if (existing.trim()) for (const line of backfillLines(existing)) {
3548
- let e;
3549
- try {
3550
- e = JSON.parse(line);
3551
- } catch {
3552
- continue;
3553
- }
3554
- emitEvent(e, wid, st, multi);
3555
- }
3556
- else if (!seenHeader.has(wid)) {
3557
- out(headerLine(c, st));
3558
- seenHeader.add(wid);
3559
- }
3560
- const sink = Buffer.alloc(65536);
3561
- for (;;) {
3562
- let n;
3563
- try {
3564
- n = readSync(handle.fd, sink, 0, sink.length, null);
3565
- } catch {
3566
- break;
3567
- }
3568
- if (n <= 0) break;
3569
- }
3570
- } catch {}
3571
- return handle;
3572
- };
3573
- const noteLine = (s) => {
3574
- eraseLiveness();
3575
- out(s);
3576
- };
3577
- const spawnResume = io?.spawnResume ?? ((id, text) => spawn("pai", [
3578
- "worker",
3579
- "resume",
3580
- id,
3581
- text,
3582
- "--print-id",
3583
- "--no-pane"
3584
- ], { stdio: [
3585
- "ignore",
3586
- "pipe",
3587
- "inherit"
3588
- ] }));
3589
- const resumeTarget = (text, id) => {
3590
- noteLine(c("dim", `» resuming ${id} …`));
3591
- const child = spawnResume(id, text);
3592
- let idOut = "";
3593
- child.stdout?.on("data", (chunk) => {
3594
- idOut += chunk.toString("utf8");
3595
- });
3596
- child.on("close", (rc) => {
3597
- const newId = idOut.trim().split("\n").pop() ?? "";
3598
- if (rc === 0 && /^\d{8}-\d{6}-\d+$/.test(newId)) {
3599
- noteLine(c("dim", `» resumed as ${newId}`));
3600
- target = newId;
3601
- finished.delete(newId);
3602
- seenHeader.delete(newId);
3603
- states.delete(newId);
3604
- ticker = initialFollowState("resumed");
3605
- lastEventAt = Date.now();
3606
- } else noteLine(c("red", `» resume failed (rc=${rc})`));
3607
- });
3608
- };
3609
- const handleOperatorLine = makeOperatorInput({
3610
- target: () => target,
3611
- say: (id, text) => sayToWorker(logDir, id, text),
3612
- workerKnown: (id) => loadStatuses(logDir).some((s) => s.id === id),
3613
- resume: resumeTarget,
3614
- note: noteLine,
3615
- paint: c,
3616
- ...chat ? { sent: () => void 0 } : {}
3617
- });
3618
- const terminalIn = in_.isTTY === true;
3619
- let hintUp = true;
3620
- let rlIn = null;
3621
- let onResize = null;
3622
- const echoed = /* @__PURE__ */ new Map();
3623
- const suppressMirror = (text) => {
3624
- const g = echoed.get(text);
3625
- if (!g || Date.now() > g.until) return false;
3626
- g.n -= 1;
3627
- if (g.n <= 0) echoed.delete(text);
3628
- return true;
3629
- };
3630
- const drawPrompt = () => {
3631
- if (!chat || terminalIn) return;
3632
- out_.write(chatPromptRow(rows, CHAT_PROMPT, hintUp ? c("dim", CHAT_HINT) : void 0));
3633
- };
3634
- if (chat) {
3635
- const echoOperator = (text) => {
3636
- const id = target;
3637
- if (id === null) return;
3638
- echoed.set(text, {
3639
- n: 1,
3640
- until: Date.now() + 1e4
3641
- });
3642
- const step = applyEvent(c, states.get(id) ?? initialFollowState(), {
3643
- type: "operator",
3644
- _ts: (/* @__PURE__ */ new Date()).toISOString(),
3645
- text
3646
- }, "", void 0, void 0, columns());
3647
- if (step.day) out(c("dim", `── ${step.day} ──`));
3648
- for (const ln of step.lines) out(ln);
3649
- states.set(id, step.state);
3650
- };
3651
- const handleChatLine = (raw) => {
3652
- hintUp = false;
3653
- const act = parseChatLine(raw);
3654
- switch (act.kind) {
3655
- case "message":
3656
- if (!act.text) break;
3657
- echoOperator(act.text);
3658
- handleOperatorLine(act.text);
3659
- break;
3660
- case "resume":
3661
- if (!act.text) {
3662
- out(c("dim", "usage: /resume <text>"));
3663
- break;
3664
- }
3665
- echoOperator(act.text);
3666
- if (target !== null) resumeTarget(act.text, target);
3667
- break;
3668
- case "help":
3669
- for (const ln of CHAT_HELP) out(c("dim", ln));
3670
- break;
3671
- case "status": {
3672
- const s = target !== null ? loadStatuses(logDir).find((x) => x.id === target) : void 0;
3673
- out(c("dim", s ? `${s.id} · ${s.state} · ${s.last}` : `${target ?? "?"} · no status`));
3674
- break;
3675
- }
3676
- case "quit":
3677
- aborted = true;
3678
- break;
3679
- }
3680
- drawPrompt();
3681
- };
3682
- rlIn = createInterface({
3683
- input: in_,
3684
- output: out_,
3685
- terminal: terminalIn
3686
- });
3687
- rlIn.on("line", handleChatLine);
3688
- rlIn.on("SIGINT", () => {
3689
- if ((rlIn?.line ?? "").trim() === "") aborted = true;
3690
- else rlIn?.write(null, {
3691
- ctrl: true,
3692
- name: "u"
3693
- });
3694
- });
3695
- rlIn.on("close", () => {
3696
- aborted = true;
3697
- });
3698
- out_.write(chatEnter(rows));
3699
- if (terminalIn) {
3700
- rlIn.setPrompt(CHAT_PROMPT);
3701
- rlIn.prompt();
3702
- out_.write(c("dim", CHAT_HINT));
3703
- } else drawPrompt();
3704
- onResize = () => {
3705
- if (typeof out_.rows === "number") rows = out_.rows;
3706
- fill = 0;
3707
- out_.write(chatScrollRegion(rows));
3708
- drawPrompt();
3709
- };
3710
- out_.on?.("resize", onResize);
3711
- } else if (target !== null && in_.isTTY) {
3712
- rlIn = createInterface({ input: in_ });
3713
- rlIn.on("line", handleOperatorLine);
3714
- }
3715
- /** The prompt's unsent text — a draft holds the auto-exit countdown. */
3716
- const promptText = () => io?.promptLine ? io.promptLine() : rlIn?.line ?? "";
3717
- try {
3718
- for (;;) {
3719
- if (aborted) return;
3720
- const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
3721
- const wanted = target ? [target] : runningIds();
3722
- for (const wid of wanted) {
3723
- if (handles.has(wid) || finished.has(wid)) continue;
3724
- const path = eventsPath(logDir, wid);
3725
- const st = statuses.get(wid) ?? {
3726
- id: wid,
3727
- label: "",
3728
- cwd: "",
3729
- provider: ""
3730
- };
3731
- if (existsSync(path)) {
3732
- const multi = target === null || handles.size > 0;
3733
- handles.set(wid, attachHandle(wid, path, st, multi));
3734
- states.set(wid, states.get(wid) ?? initialFollowState());
3735
- } else if (!seenHeader.has(wid)) {
3736
- out(headerLine(c, st));
3737
- seenHeader.add(wid);
3738
- }
3739
- }
3740
- const multi = handles.size > 1 || target === null;
3741
- let progressed = false;
3742
- for (const [wid, h] of [...handles.entries()]) {
3743
- const st = statuses.get(wid) ?? {
3744
- id: wid,
3745
- label: "",
3746
- cwd: "",
3747
- provider: ""
3748
- };
3749
- const buffer = Buffer.alloc(65536);
3750
- for (;;) {
3751
- let n;
3752
- try {
3753
- n = readSync(h.fd, buffer, 0, buffer.length, null);
3754
- } catch {
3755
- n = 0;
3756
- }
3757
- if (n <= 0) break;
3758
- h.buf += buffer.toString("utf8", 0, n);
3759
- }
3760
- const lines = h.buf.split("\n");
3761
- h.buf = lines.pop() ?? "";
3762
- for (const line of lines) {
3763
- if (!line.trim()) continue;
3764
- progressed = true;
3765
- let e;
3766
- try {
3767
- e = JSON.parse(line);
3768
- } catch {
3769
- continue;
3770
- }
3771
- emitEvent(e, wid, st, multi);
3772
- }
3773
- if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
3774
- if (!finished.has(wid)) {
3775
- eraseLiveness();
3776
- out(`${multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " "}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}`);
3777
- finished.add(wid);
3778
- }
3779
- closeSync(h.fd);
3780
- handles.delete(wid);
3781
- }
3782
- }
3783
- const lingerOn = target;
3784
- if (lingerOn !== null && finished.has(lingerOn)) {
3785
- if (autoExit) {
3786
- if (holdAutoExit(promptText())) {
3787
- writeLiveness();
3788
- await sleep(250);
3789
- continue;
3790
- }
3791
- const until = Date.now() + autoExit * 1e3;
3792
- while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
3793
- writeLiveness();
3794
- await sleep(250);
3795
- }
3796
- if (aborted || target !== lingerOn || holdAutoExit(promptText())) continue;
3797
- eraseLiveness();
3798
- out(c("dim", "closing"));
3799
- return;
3800
- }
3801
- if (rlIn === null) return;
3802
- }
3803
- if (autoExit && !target) {
3804
- if (runningIds().length) idleSince = null;
3805
- else if (idleSince === null) idleSince = Date.now();
3806
- else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
3807
- eraseLiveness();
3808
- out(c("dim", "closing"));
3809
- return;
3810
- }
3811
- }
3812
- if (!progressed) {
3813
- writeLiveness();
3814
- await sleep(500);
3815
- }
3816
- }
3817
- } finally {
3818
- rlIn?.close();
3819
- process.removeListener("SIGINT", onInt);
3820
- if (onResize) out_.removeListener?.("resize", onResize);
3821
- if (chat) out_.write(chatLeave(rows));
3822
- for (const h of handles.values()) try {
3823
- closeSync(h.fd);
3824
- } catch {}
3825
- }
3826
- }
3827
- function sleep(ms) {
3828
- return new Promise((r) => setTimeout(r, ms));
3829
- }
3830
- /** Workers of this terminal for the status bar; "" when there are none. */
3831
- function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
3832
- return renderStatusLine(loadStatuses(logDir).filter((s) => {
3833
- const sameScope = term && workerInScope(s, term);
3834
- const sameDir = cwd && s.cwd.startsWith(cwd);
3835
- return sameScope || !term && sameDir;
3836
- }), now);
3837
- }
3838
-
3839
- //#endregion
3840
- //#region src/workers/providers.ts
3841
- /**
3842
- * providers.ts — provider, class and switch management over the workers config.
3843
- *
3844
- * One layer under both `pai worker providers …` and the MCP worker_providers
3845
- * tool. Every mutation re-reads the config file, changes only the workers
3846
- * section, and writes it back atomically — the file is shared with everything
3847
- * else PAI runs, so a torn write is not an option.
3848
- *
3849
- * Keys are never stored in the config and never accepted inline from the CLI:
3850
- * only file paths. (The MCP `add` tool additionally accepts a raw `key`, which
3851
- * it immediately parks in ~/.config/pai/keys/<name>, mode 0600, storing only
3852
- * the path — a chat is not a place to leave a credential lying around.)
3853
- */
3854
- function addProvider(input) {
3855
- if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(input.name)) throw new WorkersConfigError(`provider name "${input.name}" may only contain letters, digits, - and _`);
3856
- if (input.protocol === "openai" && !input.upstreamUrl) throw new WorkersConfigError("protocol \"openai\" needs the upstreamUrl (the Chat Completions base, e.g. \"https://api.openai.com/v1\") — the PAI proxy translates to it.");
3857
- if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
3858
- let keyFile = input.keyFile ?? null;
3859
- if (input.key !== void 0) {
3860
- const dir = keysDir();
3861
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3862
- const path = `${dir}/${input.name}`;
3863
- writeFileSync(path, input.key.trim() + "\n", {
3864
- encoding: "utf8",
3865
- mode: 384
3866
- });
3867
- try {
3868
- chmodSync(path, 384);
3869
- } catch {}
3870
- keyFile = path;
3871
- }
3872
- const { raw, workers } = readWorkersSection();
3873
- const provider = {
3874
- enabled: true,
3875
- protocol: input.protocol ?? "anthropic",
3876
- baseUrl: input.baseUrl,
3877
- keyFile,
3878
- models: input.fastModel ? {
3879
- default: input.model,
3880
- fast: input.fastModel
3881
- } : { default: input.model },
3882
- env: input.env ?? {},
3883
- ...input.note ? { note: input.note } : {},
3884
- ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
3885
- ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
3886
- ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
3887
- ...input.contextWindow ? { contextWindow: input.contextWindow } : {},
3888
- ...input.costTier ? { costTier: input.costTier } : {},
3889
- ...input.tags?.length ? { tags: input.tags } : {}
3890
- };
3891
- workers.providers[input.name] = provider;
3892
- if (Object.keys(workers.providers).length === 1) {
3893
- workers.enabled = true;
3894
- workers.active = input.name;
3895
- workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
3896
- const fast = input.fastModel ? `${input.name}/fast` : input.name;
3897
- workers.classes = {
3898
- draft: fast,
3899
- plan: input.name,
3900
- implement: input.name,
3901
- review: input.name,
3902
- research: input.name,
3903
- spotcheck: fast,
3904
- simple: fast,
3905
- complex: input.name,
3906
- image: input.name
3907
- };
3908
- }
3909
- writeWorkersSection(raw, workers);
3910
- return workers;
3911
- }
3912
- /** Change costTier / tags on an existing provider (MCP action "update"). */
3913
- function updateProvider(name, changes) {
3914
- const { raw, workers } = readWorkersSection();
3915
- const p = workers.providers[name];
3916
- if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3917
- if (changes.costTier !== void 0) {
3918
- if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
3919
- p.costTier = changes.costTier;
3920
- }
3921
- if (changes.tags !== void 0) {
3922
- for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
3923
- p.tags = changes.tags;
3924
- }
3925
- writeWorkersSection(raw, workers);
3926
- return workers;
3927
- }
3928
- function removeProvider(name) {
3929
- const { raw, workers } = readWorkersSection();
3930
- if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
3931
- delete workers.providers[name];
3932
- for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
3933
- if (workers.active === name) workers.active = null;
3934
- writeWorkersSection(raw, workers);
3935
- return workers;
3936
- }
3937
- function useProvider(name) {
3938
- const { raw, workers } = readWorkersSection();
3939
- if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3940
- workers.active = name;
3941
- writeWorkersSection(raw, workers);
3942
- return workers;
3943
- }
3944
- function setProviderEnabled(name, enabled) {
3945
- const { raw, workers } = readWorkersSection();
3946
- const p = workers.providers[name];
3947
- if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
3948
- p.enabled = enabled;
3949
- writeWorkersSection(raw, workers);
3950
- if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
3951
- return workers;
3952
- }
3953
- /**
3954
- * Point a class at a target ("provider", "provider/fast", or an object with
3955
- * provider + optional mcp/maxCostTier/requireTags/order). An object without
3956
- * a provider only constrains auto-routing for that class.
3957
- */
3958
- function setClass(name, target) {
3959
- const { raw, workers } = readWorkersSection();
3960
- if (typeof target === "string") {
3961
- const [provider, alias] = target.split("/");
3962
- const p = workers.providers[provider];
3963
- if (!p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3964
- if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose "default" and "fast"`);
3965
- if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${provider}" has no fast model configured`);
3966
- } else if (target.provider) {
3967
- if (!workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3968
- }
3969
- workers.classes[name] = target;
3970
- writeWorkersSection(raw, workers);
3971
- return workers;
3972
- }
3973
- function unsetClass(name) {
3974
- const { raw, workers } = readWorkersSection();
3975
- if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
3976
- delete workers.classes[name];
3977
- writeWorkersSection(raw, workers);
3978
- return workers;
3979
- }
3980
- function setWorkersEnabled(enabled) {
3981
- const { raw, workers } = readWorkersSection();
3982
- workers.enabled = enabled;
3983
- writeWorkersSection(raw, workers);
3984
- return workers;
3985
- }
3986
- /** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
3987
- function classTargetText(target) {
3988
- if (typeof target === "string") return target;
3989
- return [
3990
- target.provider ?? "(routing)",
3991
- ...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
3992
- ...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
3993
- ...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
3994
- ...target.order?.length ? [`order [${target.order.join(",")}]`] : []
3995
- ].join(" ");
3996
- }
3997
- /** Human-readable provider listing (quota probe included when configured). */
3998
- function describeProviders(workers) {
3999
- const lines = [];
4000
- const names = Object.keys(workers.providers);
4001
- if (!names.length) {
4002
- lines.push(`no providers configured. Add one with:`);
4003
- lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
4004
- return lines;
4005
- }
4006
- for (const name of names) {
4007
- const p = workers.providers[name];
4008
- const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
4009
- const quota = p.quotaProbe ? probeQuota(p) : null;
4010
- const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
4011
- const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
4012
- lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
4013
- lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
4014
- lines.push(` ${tierTags}`);
4015
- if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
4016
- else lines.push(` no key file (token "local")`);
4017
- if (p.note) lines.push(` ${p.note}`);
4018
- if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
4019
- if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
4020
- }
4021
- const setNames = Object.keys(workers.mcpSets);
4022
- if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
4023
- const classNames = Object.keys(workers.classes);
4024
- if (classNames.length) lines.push(`classes: ${classNames.map((cl) => `${cl}=${classTargetText(workers.classes[cl])}`).join(" ")}`);
4025
- if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
4026
- return lines;
4027
- }
4028
-
4029
- //#endregion
4030
- export { ledgerPath as A, checkPaneForWorker as C, ledgerSummary as D, loadStatus as E, readJsonStrict as F, writeJsonAtomic as I, PROVIDER_TAGS as M, WorkersConfigError as N, parseRunnerArgs as O, readWorkersSection as P, describeMcp as S, openPaneForWorker as T, testProvider as _, setClass as a, stopProxy as b, unsetClass as c, followWorkers as d, psOutput as f, runWorker as g, runChain as h, removeProvider as i, workersLogDir as j, eventsPath as k, updateProvider as l, statusLineOutput as m, classTargetText as n, setProviderEnabled as o, replayOutput as p, describeProviders as r, setWorkersEnabled as s, addProvider as t, useProvider as u, DEFAULT_PROXY_PORT as v, openFollowPane as w, sayToWorker as x, ensureProxyRunning as y };
4031
- //# sourceMappingURL=providers-DUshcB-d.mjs.map
3324
+ export { contextPercent as A, workersLogDir as B, quotaSkipThreshold as C, workerInScope as D, sessionTag as E, ledgerSummary as F, keysDir as G, PROVIDER_TAGS as H, parseRunnerArgs as I, readWorkersSection as J, parseWorkersConfig as K, shortText as L, loadStatuses as M, newWorkerId as N, ageOf as O, appendLedger as P, eventsPath as R, probeQuota as S, resolveSession as T, WorkersConfigError as U, DEFAULT_LOG_DIR as V, expandHome as W, readJsonStrict as X, writeWorkersSection as Y, writeJsonAtomic as Z, workerDepth as _, testProvider as a, openPaneForWorker as b, stopProxy as c, renderReport as d, discardWorker as f, sayToWorker as g, readInbox as h, runWorker as i, loadStatus as j, alive as k, describeMcp as l, handoffFromInside as m, swapPromptArg as n, DEFAULT_PROXY_PORT as o, mergeWorker as p, providerCostTier as q, printResult as r, ensureProxyRunning as s, runChain as t, parseWorkerReport as u, checkPaneForWorker as v, currentTabKey as w, clearCooldown as x, openFollowPane as y, ledgerPath as z };
3325
+ //# sourceMappingURL=chain-CTtHligI.mjs.map