@tekmidian/pai 0.37.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 (85) 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-sXcK5bDZ.mjs → chain-CTtHligI.mjs} +1040 -782
  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 +207 -34
  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 +112 -20
  23. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  24. package/dist/hooks/worker-proxy.mjs +110 -18
  25. package/dist/hooks/worker-proxy.mjs.map +2 -2
  26. package/dist/hooks/worker-status-line.mjs +176 -23
  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-CEIHn_Ma.mjs → program-DXBwJV7h.mjs} +274 -65
  43. package/dist/program-DXBwJV7h.mjs.map +1 -0
  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/skills/Worker/SKILL.md +34 -12
  53. package/dist/{sources-Bi7--33T.mjs → sources-kLnQsNrW.mjs} +1 -1
  54. package/dist/{sources-Bi7--33T.mjs.map → sources-kLnQsNrW.mjs.map} +1 -1
  55. package/dist/{sqlite-DtaL1glm.mjs → sqlite-BenGr3UP.mjs} +1 -1
  56. package/dist/{sqlite-DtaL1glm.mjs.map → sqlite-BenGr3UP.mjs.map} +1 -1
  57. package/dist/{state-BY2L6-vX.mjs → state-CAeyOdfq.mjs} +1 -1
  58. package/dist/{state-BY2L6-vX.mjs.map → state-CAeyOdfq.mjs.map} +1 -1
  59. package/dist/{state-8Hm9E4tW.mjs → state-Ca9F_sZc.mjs} +1 -1
  60. package/dist/{themes-BN0a2duq.mjs → themes-BI4GMRP9.mjs} +1 -1
  61. package/dist/{themes-BN0a2duq.mjs.map → themes-BI4GMRP9.mjs.map} +1 -1
  62. package/dist/{tools-CGPqpU3A.mjs → tools-Bp7hj6OW.mjs} +1 -1
  63. package/dist/{tools-y2bJpKom.mjs → tools-DGcB3o_S.mjs} +13 -13
  64. package/dist/{tools-y2bJpKom.mjs.map → tools-DGcB3o_S.mjs.map} +1 -1
  65. package/dist/{trace-h23JCcFD.mjs → trace-bobARFEX.mjs} +1 -1
  66. package/dist/{trace-h23JCcFD.mjs.map → trace-bobARFEX.mjs.map} +1 -1
  67. package/dist/{vault-indexer-DgsPjMgs.mjs → vault-indexer-C3OfXTNF.mjs} +1 -1
  68. package/dist/{vault-indexer-DgsPjMgs.mjs.map → vault-indexer-C3OfXTNF.mjs.map} +1 -1
  69. package/dist/{work-queue-worker-gsKd2LJa.mjs → work-queue-worker-DW8lz-Oo.mjs} +3 -3
  70. package/dist/{work-queue-worker-Dva_v_pI.mjs → work-queue-worker-R7UGIag_.mjs} +3 -3
  71. package/dist/{work-queue-worker-Dva_v_pI.mjs.map → work-queue-worker-R7UGIag_.mjs.map} +1 -1
  72. package/dist/{zettelkasten-vo7psPdT.mjs → zettelkasten-m5QPtb-L.mjs} +3 -3
  73. package/dist/{zettelkasten-vo7psPdT.mjs.map → zettelkasten-m5QPtb-L.mjs.map} +1 -1
  74. package/docs/commands/README.md +12 -7
  75. package/docs/commands/worker.md +110 -17
  76. package/docs/worker.md +244 -25
  77. package/package.json +1 -1
  78. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +2 -2
  79. package/dist/detector-DtLExmHN.mjs +0 -5
  80. package/dist/factory-BXzqRYVZ.mjs +0 -3
  81. package/dist/program-CEIHn_Ma.mjs.map +0 -1
  82. package/dist/providers-sXcK5bDZ.mjs.map +0 -1
  83. package/dist/query-feedback-DhyLOe5S.mjs +0 -3
  84. package/dist/router-1zi8jiNF.mjs +0 -3
  85. /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";
@@ -104,6 +104,28 @@ function writeJsonAtomic(path, data, opts = {}) {
104
104
  * `engine: "codex"` providers run through the Codex CLI instead of Claude
105
105
  * Code.
106
106
  */
107
+ const DEFAULT_COST_TIER = 3;
108
+ /** Tags a provider may carry; classes filter auto-routing on them. */
109
+ const PROVIDER_TAGS = [
110
+ "code",
111
+ "vision",
112
+ "image-gen",
113
+ "long-context",
114
+ "fast",
115
+ "reasoning"
116
+ ];
117
+ /** The standard task classes; `workers.classes` maps each to a target. */
118
+ const WORKER_CLASSES = [
119
+ "draft",
120
+ "plan",
121
+ "implement",
122
+ "review",
123
+ "research",
124
+ "spotcheck",
125
+ "simple",
126
+ "complex",
127
+ "image"
128
+ ];
107
129
  const DEFAULT_LOG_DIR = "~/.claude/logs/workers";
108
130
  const DEFAULT_PANE = {
109
131
  enabled: true,
@@ -115,19 +137,32 @@ const DEFAULT_ROUTING = {
115
137
  cooldownMinutes: 30,
116
138
  retryOnQuota: true
117
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"] };
118
152
  function defaultWorkersConfig() {
119
153
  return {
120
154
  enabled: false,
121
155
  active: null,
122
156
  providers: {},
123
- roles: {},
124
- mcpSets: {},
157
+ classes: {},
158
+ mcpSets: { ...DEFAULT_MCP_SETS },
125
159
  pane: { ...DEFAULT_PANE },
126
160
  logDir: DEFAULT_LOG_DIR,
127
161
  routing: {
128
162
  ...DEFAULT_ROUTING,
129
163
  order: []
130
- }
164
+ },
165
+ tree: { ...DEFAULT_TREE }
131
166
  };
132
167
  }
133
168
  var WorkersConfigError = class extends Error {};
@@ -171,6 +206,16 @@ function parseProvider(name, raw) {
171
206
  }
172
207
  const upstreamUrl = str(p.upstreamUrl);
173
208
  if (protocol === "openai" && !upstreamUrl) bad(`.providers.${name}.upstreamUrl`, `is required for protocol "openai" (the Chat Completions base, e.g. "https://api.openai.com/v1")`);
209
+ const costTier = p.costTier === void 0 ? void 0 : p.costTier;
210
+ if (costTier !== void 0) {
211
+ if (typeof costTier !== "number" || !Number.isInteger(costTier) || costTier < 1 || costTier > 5) bad(`.providers.${name}.costTier`, "must be an integer 1 (cheapest) … 5 (most expensive)");
212
+ }
213
+ let tags;
214
+ if (p.tags !== void 0) {
215
+ if (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== "string")) bad(`.providers.${name}.tags`, `must be an array of tags from: ${PROVIDER_TAGS.join(", ")}`);
216
+ for (const t of p.tags) if (!PROVIDER_TAGS.includes(t)) bad(`.providers.${name}.tags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
217
+ tags = p.tags;
218
+ }
174
219
  return {
175
220
  enabled: p.enabled === void 0 ? true : p.enabled === true,
176
221
  protocol,
@@ -186,7 +231,9 @@ function parseProvider(name, raw) {
186
231
  ...engine !== "claude" ? { engine } : {},
187
232
  ...str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {},
188
233
  ...quotaSkipAt !== void 0 ? { quotaSkipAt } : {},
189
- ...contextWindow !== void 0 ? { contextWindow } : {}
234
+ ...contextWindow !== void 0 ? { contextWindow } : {},
235
+ ...costTier !== void 0 ? { costTier } : {},
236
+ ...tags ? { tags } : {}
190
237
  };
191
238
  }
192
239
  /**
@@ -203,29 +250,50 @@ function parseWorkersConfig(raw) {
203
250
  if (typeof w.providers !== "object" || w.providers === null || Array.isArray(w.providers)) bad(".providers", "must be an object keyed by provider name");
204
251
  for (const [name, p] of Object.entries(w.providers)) providers[name] = parseProvider(name, p);
205
252
  }
206
- const roles = {};
207
- if (w.roles !== void 0) {
208
- if (typeof w.roles !== "object" || w.roles === null || Array.isArray(w.roles)) bad(".roles", "must be an object of role → provider[/alias] or {provider, mcp}");
209
- for (const [role, target] of Object.entries(w.roles)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
253
+ const classes = {};
254
+ const classesRaw = w.classes !== void 0 ? w.classes : w.roles;
255
+ if (classesRaw !== void 0) {
256
+ if (typeof classesRaw !== "object" || classesRaw === null || Array.isArray(classesRaw)) bad(w.classes !== void 0 ? ".classes" : ".roles", "must be an object of class → provider[/alias] or {provider, mcp, maxCostTier, requireTags, order}");
257
+ for (const [cls, target] of Object.entries(classesRaw)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
210
258
  const o = target;
211
259
  const provider = str(o.provider);
212
- if (!provider || provider.includes(" ")) bad(`.roles.${role}.provider`, `invalid provider "${provider}"`);
260
+ if (o.provider !== void 0 && (!provider || provider.includes(" "))) bad(`.classes.${cls}.provider`, `invalid provider "${provider}"`);
213
261
  let mcp;
214
262
  if (o.mcp !== void 0) {
215
- if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== "string")) bad(`.roles.${role}.mcp`, "must be an array of MCP server or set names");
263
+ if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== "string")) bad(`.classes.${cls}.mcp`, "must be an array of MCP server or set names");
216
264
  mcp = o.mcp;
217
265
  }
218
- roles[role] = mcp ? {
219
- provider,
220
- mcp
221
- } : { provider };
266
+ let maxCostTier;
267
+ if (o.maxCostTier !== void 0) {
268
+ if (typeof o.maxCostTier !== "number" || !Number.isInteger(o.maxCostTier) || o.maxCostTier < 1 || o.maxCostTier > 5) bad(`.classes.${cls}.maxCostTier`, "must be an integer 1 … 5");
269
+ maxCostTier = o.maxCostTier;
270
+ }
271
+ let requireTags;
272
+ if (o.requireTags !== void 0) {
273
+ if (!Array.isArray(o.requireTags) || o.requireTags.some((x) => typeof x !== "string")) bad(`.classes.${cls}.requireTags`, `must be an array of tags from: ${PROVIDER_TAGS.join(", ")}`);
274
+ for (const t of o.requireTags) if (!PROVIDER_TAGS.includes(t)) bad(`.classes.${cls}.requireTags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
275
+ requireTags = o.requireTags;
276
+ }
277
+ let order;
278
+ if (o.order !== void 0) {
279
+ if (!Array.isArray(o.order) || o.order.some((x) => typeof x !== "string")) bad(`.classes.${cls}.order`, "must be an array of provider names");
280
+ order = o.order;
281
+ }
282
+ const obj = {
283
+ ...provider ? { provider } : {},
284
+ ...mcp ? { mcp } : {},
285
+ ...maxCostTier !== void 0 ? { maxCostTier } : {},
286
+ ...requireTags ? { requireTags } : {},
287
+ ...order ? { order } : {}
288
+ };
289
+ classes[cls] = Object.keys(obj).length ? obj : {};
222
290
  } else {
223
291
  const t = str(target);
224
- if (!t || t.includes(" ")) bad(`.roles.${role}`, `invalid target "${t}"`);
225
- roles[role] = t;
292
+ if (!t || t.includes(" ")) bad(`.classes.${cls}`, `invalid target "${t}"`);
293
+ classes[cls] = t;
226
294
  }
227
295
  }
228
- const mcpSets = {};
296
+ const mcpSets = { ...DEFAULT_MCP_SETS };
229
297
  if (w.mcpSets !== void 0) {
230
298
  if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
231
299
  for (const [setName, servers] of Object.entries(w.mcpSets)) {
@@ -265,17 +333,33 @@ function parseWorkersConfig(raw) {
265
333
  retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
266
334
  };
267
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
+ }
268
351
  const active = w.active === void 0 || w.active === null ? null : str(w.active);
269
352
  if (active !== null && active !== "auto" && !(active in providers)) {}
270
353
  return {
271
354
  enabled: w.enabled === void 0 ? d.enabled : w.enabled === true,
272
355
  active,
273
356
  providers,
274
- roles,
357
+ classes,
275
358
  mcpSets,
276
359
  pane,
277
360
  logDir: str(w.logDir) || d.logDir,
278
- routing
361
+ routing,
362
+ tree
279
363
  };
280
364
  }
281
365
  /**
@@ -308,6 +392,10 @@ function providerKeyPath(p) {
308
392
  return p.keyFile ? expandHome(p.keyFile) : null;
309
393
  }
310
394
  const DEFAULT_CONTEXT_WINDOW = 2e5;
395
+ /** Cost tier of a provider for class filtering (unset = 3, the middle). */
396
+ function providerCostTier(p) {
397
+ return p.costTier ?? DEFAULT_COST_TIER;
398
+ }
311
399
  /** Context window used by the meter when the init event carries none. */
312
400
  function providerContextWindow(p) {
313
401
  return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
@@ -525,9 +613,18 @@ function nowStamp(d = /* @__PURE__ */ new Date()) {
525
613
  const p = (n) => String(n).padStart(2, "0");
526
614
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
527
615
  }
616
+ let lastId = "";
617
+ let idSeq = 0;
528
618
  function newWorkerId(d = /* @__PURE__ */ new Date(), pid = process.pid) {
529
619
  const p = (n) => String(n).padStart(2, "0");
530
- return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
620
+ const base = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
621
+ if (base === lastId) {
622
+ idSeq += 1;
623
+ return `${base}-${String(idSeq).padStart(2, "0")}`;
624
+ }
625
+ lastId = base;
626
+ idSeq = 0;
627
+ return base;
531
628
  }
532
629
  /** Write status atomically (temp + rename) and stamp `updated`. */
533
630
  function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
@@ -676,16 +773,19 @@ function sessionTag(worker) {
676
773
  //#endregion
677
774
  //#region src/workers/routing.ts
678
775
  /**
679
- * routing.ts — provider selection: flag > role > active (possibly "auto").
776
+ * routing.ts — provider selection: flag > class > active (possibly "auto").
680
777
  *
681
- * Auto-routing walks `workers.routing.order` and takes the first provider
682
- * that is enabled, out of cooldown, and (when it defines a quotaProbe) under
683
- * its quotaSkipAt threshold. A run that dies of a quota/rate error puts its
684
- * provider in cooldown for cooldownMinutes; when that happens before the
685
- * first tool call and retryOnQuota is set, the runner restarts the same task
686
- * on the next provider (ledger: WORKER-REROUTE).
778
+ * Auto-routing walks `workers.routing.order` or the class's own `order` —
779
+ * and takes the first provider that is enabled, out of cooldown, (when it
780
+ * defines a quotaProbe) under its quotaSkipAt threshold, and when the class
781
+ * constrains it within `maxCostTier` and carrying all `requireTags`. A run
782
+ * that dies of a quota/rate error puts its provider in cooldown for
783
+ * cooldownMinutes; when that happens before the first tool call and
784
+ * retryOnQuota is set, the runner restarts the same task on the next provider
785
+ * (ledger: WORKER-REROUTE).
687
786
  *
688
- * An explicit --provider or --role always bypasses all of this.
787
+ * An explicit --provider or a class mapping that pins a provider always
788
+ * bypasses all of this.
689
789
  */
690
790
  const QUOTA_SKIP_DEFAULT = 95;
691
791
  const PROBE_TIMEOUT_MS = 1e4;
@@ -764,34 +864,61 @@ function mustBeRunnable(name, p) {
764
864
  if (!p.enabled) throw new NoProviderError(`provider "${name}" is disabled. Enable it with: pai worker providers enable ${name}`);
765
865
  return p;
766
866
  }
867
+ /** Why one provider of a routing order did not qualify (message fragment). */
868
+ function exclusionReason(config, state, name, cls) {
869
+ const p = config.providers[name];
870
+ if (!p) return "not configured";
871
+ if (!p.enabled) return "disabled";
872
+ if (cooldownRemaining(state, name) > 0) return "cooldown";
873
+ if (quotaExceeded(p)) return "quota";
874
+ const tier = providerCostTier(p);
875
+ if (cls?.maxCostTier !== void 0 && tier > cls.maxCostTier) return `cost tier ${tier} > max ${cls.maxCostTier}`;
876
+ if (cls?.requireTags?.length) {
877
+ const have = p.tags ?? [];
878
+ const missing = cls.requireTags.filter((t) => !have.includes(t));
879
+ if (missing.length) return `missing tags: ${missing.join(", ")}`;
880
+ }
881
+ return null;
882
+ }
767
883
  /**
768
884
  * Resolve which provider (and model alias) a run uses.
769
885
  *
770
886
  * @param flagProvider --provider value, highest precedence
771
- * @param role --role value, looked up in workers.roles
887
+ * @param className --class value (the old --role), looked up in workers.classes
772
888
  */
773
889
  function resolveTarget(config, logDir, opts = {}) {
774
890
  if (opts.flagProvider) return {
775
891
  providerName: opts.flagProvider,
776
892
  provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),
777
893
  modelAlias: null,
778
- roleMcp: null,
894
+ classMcp: null,
779
895
  via: "flag"
780
896
  };
781
- if (opts.role) {
782
- const target = config.roles[opts.role];
783
- if (!target) throw new NoProviderError(`no role named "${opts.role}". Defined: ${Object.keys(config.roles).join(", ") || "(none)"}.\nSet one with: pai worker roles set ${opts.role}=<provider[/alias]>`);
784
- const name = typeof target === "string" ? target.split("/")[0] : target.provider;
785
- const alias = typeof target === "string" ? target.split("/")[1] : void 0;
786
- const roleMcp = typeof target === "string" ? null : target.mcp ?? null;
897
+ const clsTarget = opts.className ? config.classes[opts.className] : void 0;
898
+ const cls = typeof clsTarget === "object" && clsTarget !== null ? clsTarget : void 0;
899
+ if (opts.className && clsTarget !== void 0 && typeof clsTarget !== "object") {
900
+ const [name, alias] = clsTarget.split("/");
787
901
  return {
788
902
  providerName: name,
789
903
  provider: mustBeRunnable(name, mustExist(config, name)),
790
904
  modelAlias: alias ?? null,
791
- roleMcp,
792
- via: "role"
905
+ classMcp: null,
906
+ via: "class"
907
+ };
908
+ }
909
+ if (opts.className && cls?.provider) {
910
+ const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));
911
+ return {
912
+ providerName: cls.provider,
913
+ provider,
914
+ modelAlias: null,
915
+ classMcp: cls.mcp ?? null,
916
+ via: "class"
793
917
  };
794
918
  }
919
+ if (opts.className && clsTarget === void 0) {
920
+ if (!WORKER_CLASSES.includes(opts.className)) throw new NoProviderError(`no class named "${opts.className}". Standard classes: ${WORKER_CLASSES.join(", ")}.Defined: ${Object.keys(config.classes).join(", ") || "(none)"}.\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`);
921
+ }
795
922
  if (config.active !== "auto") {
796
923
  const name = config.active;
797
924
  if (!name) throw new NoProviderError("no active worker provider. Add one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>\n(or point one that exists at it: pai worker providers use <name>)");
@@ -799,32 +926,29 @@ function resolveTarget(config, logDir, opts = {}) {
799
926
  providerName: name,
800
927
  provider: mustBeRunnable(name, mustExist(config, name)),
801
928
  modelAlias: null,
802
- roleMcp: null,
929
+ classMcp: null,
803
930
  via: "active"
804
931
  };
805
932
  }
806
933
  const state = readRoutingState(logDir);
807
- const skipped = [];
808
- for (const name of config.routing.order) {
934
+ const order = cls?.order ?? config.routing.order;
935
+ const excluded = [];
936
+ for (const name of order) {
809
937
  const p = config.providers[name];
810
- if (!p || !p.enabled) continue;
811
- if (cooldownRemaining(state, name) > 0) {
812
- skipped.push(`${name}(cooldown)`);
813
- continue;
814
- }
815
- if (quotaExceeded(p)) {
816
- skipped.push(`${name}(quota)`);
938
+ const why = exclusionReason(config, state, name, cls);
939
+ if (why) {
940
+ excluded.push(`${name}: ${why}`);
817
941
  continue;
818
942
  }
819
943
  return {
820
944
  providerName: name,
821
945
  provider: p,
822
946
  modelAlias: null,
823
- roleMcp: null,
947
+ classMcp: cls?.mcp ?? null,
824
948
  via: "auto"
825
949
  };
826
950
  }
827
- throw new NoProviderError(`auto-routing found no usable provider (order: [${config.routing.order.join(", ")}]${skipped.length ? `; skipped: ${skipped.join(", ")}` : ""}).\nClear a cooldown with: pai worker providers enable <name>`);
951
+ throw new NoProviderError(`auto-routing found no usable provider${opts.className ? ` for class "${opts.className}"` : ""} (order: [${order.join(", ")}]).${excluded.length ? `\nExcluded: ${excluded.join("; ")}.` : ""}\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? "<class>"}=<target>`);
828
952
  }
829
953
  /**
830
954
  * Next provider after `from` in auto order, applying the same filters.
@@ -859,11 +983,13 @@ function isQuotaFailure(resultText) {
859
983
  * pane.ts — the per-worker follow pane in iTerm2.
860
984
  *
861
985
  * One small-font pane per worker, stacked in a right-hand column: the first
862
- * worker of a scope splits the launching session vertically (right, ~40% of
863
- * the columns), every further one splits the lowest live worker pane
864
- * horizontally, so panes stack top to bottom. Each pane runs
865
- * `pai worker follow <id> --auto-exit <n>` under the `pai-worker` dynamic
866
- * profile (Close Sessions On End), so panes disappear by themselves.
986
+ * worker of a scope splits the launching session vertically, every further
987
+ * one splits the lowest live worker pane horizontally, so panes stack top to
988
+ * bottom. The split never sizes the new session that would grow the whole
989
+ * window; instead the window's bounds are read before the split and restored
990
+ * right after, so the panes share the space the window already had. Each
991
+ * pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
992
+ * dynamic profile (Close Sessions On End), so panes disappear by themselves.
867
993
  *
868
994
  * Panes are tracked per scope (AIBroker session id, else tab key) in
869
995
  * <logDir>/panes/<key>.json, keyed by iTerm session unique id, newest first —
@@ -893,10 +1019,14 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
893
1019
  repeat with t in tabs of w
894
1020
  repeat with s in sessions of t
895
1021
  if id of s is targetID then
896
- set parentCols to missing value
897
- try
898
- set parentCols to columns of s
899
- end try
1022
+ -- sizing the new session would grow the whole window:
1023
+ -- pin the window's bounds now and restore them after
1024
+ -- the split, so the panes share the existing space.
1025
+ -- copy, never set: set stores the property
1026
+ -- reference lazily, so restoring it would re-read the
1027
+ -- post-split bounds instead of these — observed as the
1028
+ -- window jumping to the main display
1029
+ copy bounds of w to winBounds
900
1030
  set sessIDs to {}
901
1031
  repeat with other in sessions of t
902
1032
  set end of sessIDs to (id of other as text)
@@ -936,13 +1066,9 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
936
1066
  tell newS
937
1067
  write text followCmd
938
1068
  end tell
939
- if splitS is missing value and parentCols is not missing value then
940
- try
941
- tell newS
942
- set columns to (round (parentCols * 0.4))
943
- end tell
944
- end try
945
- end if
1069
+ try
1070
+ set bounds of w to winBounds
1071
+ end try
946
1072
  set out to ""
947
1073
  repeat with lid in lived
948
1074
  set out to out & lid & ","
@@ -987,6 +1113,27 @@ const TAB_TTYS_SCRIPT = `on run(argv)
987
1113
  end tell
988
1114
  return "notfound"
989
1115
  end run`;
1116
+ const WINDOW_BOUNDS_SCRIPT = `on run(argv)
1117
+ set targetID to item 1 of argv
1118
+ tell application id "com.googlecode.iterm2"
1119
+ if not running then return "notrunning"
1120
+ repeat with w in windows
1121
+ repeat with t in tabs of w
1122
+ repeat with s in sessions of t
1123
+ if id of s is targetID then
1124
+ copy bounds of w to winBounds
1125
+ set prevDels to AppleScript's text item delimiters
1126
+ set AppleScript's text item delimiters to ", "
1127
+ set out to winBounds as text
1128
+ set AppleScript's text item delimiters to prevDels
1129
+ return out
1130
+ end if
1131
+ end repeat
1132
+ end repeat
1133
+ end repeat
1134
+ end tell
1135
+ return "notfound"
1136
+ end run`;
990
1137
  const SPLIT_SCRIPT = `on run(argv)
991
1138
  set targetID to item 1 of argv
992
1139
  set followCmd to item 2 of argv
@@ -1031,7 +1178,7 @@ function osascript(script, args) {
1031
1178
  proc.stdin.end();
1032
1179
  });
1033
1180
  }
1034
- function psOutput$1(format) {
1181
+ function psOutput(format) {
1035
1182
  try {
1036
1183
  return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
1037
1184
  } catch {
@@ -1041,7 +1188,7 @@ function psOutput$1(format) {
1041
1188
  /** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
1042
1189
  function followTtys() {
1043
1190
  const ttys = /* @__PURE__ */ new Set();
1044
- for (const line of psOutput$1("tty=,command=").split("\n")) {
1191
+ for (const line of psOutput("tty=,command=").split("\n")) {
1045
1192
  const parts = line.trim().split(/\s+/, 2);
1046
1193
  if (parts.length < 2 || parts[0] === "ps") continue;
1047
1194
  if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
@@ -1052,7 +1199,7 @@ function followTtys() {
1052
1199
  /** True while a `follow <wid>` process runs (its pane shows that worker). */
1053
1200
  function workerPaneOpen(wid) {
1054
1201
  const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
1055
- return psOutput$1("command=").split("\n").some((ln) => pat.test(ln));
1202
+ return psOutput("command=").split("\n").some((ln) => pat.test(ln));
1056
1203
  }
1057
1204
  /** plutil's message for an ExecFileSync failure, else the exception text. */
1058
1205
  function toolErr(e) {
@@ -1257,12 +1404,31 @@ async function openPaneForWorker(logDir, config, wid, term) {
1257
1404
  return `pane opened for ${wid}`;
1258
1405
  }
1259
1406
  /**
1407
+ * One `--check` line with the bounds of the window hosting `term`'s iTerm
1408
+ * session — the before/after pair that shows whether a split moved it.
1409
+ * Read-only; never touches the window.
1410
+ */
1411
+ async function windowBoundsLine(term) {
1412
+ const uid = itermUuid(term);
1413
+ if (!uid) return "window bounds: (not in iTerm2)";
1414
+ try {
1415
+ const p = await osascript(WINDOW_BOUNDS_SCRIPT, [uid]);
1416
+ const out = p.stdout.trim();
1417
+ if (out && out !== "notfound" && out !== "notrunning") return `window bounds: ${out}`;
1418
+ return `window bounds: (${out === "notfound" ? "iTerm2 session not found" : out === "notrunning" ? "iTerm2 not running" : (p.stderr.trim() || "no output").slice(0, 120)})`;
1419
+ } catch (e) {
1420
+ return `window bounds: (osascript: ${String(e.message ?? e).slice(0, 120)})`;
1421
+ }
1422
+ }
1423
+ /**
1260
1424
  * Report-only variant used by `pai worker pane <id> --check`: whether a pane
1261
- * runs for the worker, plus the dynamic profile's path, its existence, and
1262
- * the font it contains (or, when missing, would write).
1425
+ * runs for the worker, the bounds of the window hosting the asking session,
1426
+ * plus the dynamic profile's path, its existence, and the font it contains
1427
+ * (or, when missing, would write).
1263
1428
  */
1264
- async function checkPaneForWorker(wid, fontSize) {
1429
+ async function checkPaneForWorker(wid, fontSize, term) {
1265
1430
  const lines = [workerPaneOpen(wid) ? `pane for ${wid} open` : `no pane for ${wid}`];
1431
+ lines.push(await windowBoundsLine(term));
1266
1432
  const path = dynamicProfilePath();
1267
1433
  if (existsSync(path)) {
1268
1434
  let font = "(unreadable)";
@@ -1297,6 +1463,483 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
1297
1463
  throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
1298
1464
  }
1299
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
+
1300
1943
  //#endregion
1301
1944
  //#region src/workers/report.ts
1302
1945
  /**
@@ -1307,6 +1950,8 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
1307
1950
  * and nothing else. The runner parses it out of the result; the viewer renders
1308
1951
  * it as a compact block instead of a wall of text.
1309
1952
  */
1953
+ /** The marker the runner puts on operator messages the worker must answer. */
1954
+ const OPERATOR_MARK = "[operator]";
1310
1955
  /** Appended to the caller's system prompt on every headless run (2h). */
1311
1956
  const WORKER_CONTRACT_PROMPT = [
1312
1957
  "You are a headless implementation worker, run non-interactively by an orchestrating session.",
@@ -1315,7 +1960,20 @@ const WORKER_CONTRACT_PROMPT = [
1315
1960
  "{\"changed\":[{\"path\":\"…\",\"summary\":\"…\"}],\"commands\":[\"…\"],\"checks\":[{\"name\":\"…\",\"ok\":true,\"detail\":\"…\"}],\"open\":[\"…\"],\"notes\":\"one line\"}",
1316
1961
  "changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
1317
1962
  "checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
1318
- "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."
1319
1977
  ].join("\n");
1320
1978
  /**
1321
1979
  * Extract the JSON report from a worker's final message. Accepts a bare
@@ -1465,81 +2123,6 @@ function describeMcp(config, claudeJson = CLAUDE_JSON) {
1465
2123
  return lines;
1466
2124
  }
1467
2125
 
1468
- //#endregion
1469
- //#region src/workers/operator.ts
1470
- /**
1471
- * operator.ts — talking to a running worker.
1472
- *
1473
- * Headless workers run with `--input-format stream-json` and their stdin held
1474
- * open, so a run is a conversation: every line sent to the per-worker Unix
1475
- * socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
1476
- * mirrored into the transcript as an `operator` event (rendered with a »
1477
- * marker). Once the worker has finished its turn, stdin closes 2 s later
1478
- * unless a new message arrives — after that, `say` refuses and `resume`
1479
- * continues the same Claude session with the worker's context intact.
1480
- */
1481
- function operatorSocketPath(logDir, id) {
1482
- return join(logDir, `${id}.sock`);
1483
- }
1484
- /**
1485
- * The runner's side: listen on the worker socket, hand every received line to
1486
- * `onLine`. Returns the server (close it when the run ends; the socket file is
1487
- * unlinked on close, best effort).
1488
- */
1489
- function createOperatorServer(logDir, id, onLine) {
1490
- const path = operatorSocketPath(logDir, id);
1491
- try {
1492
- if (existsSync(path)) unlinkSync(path);
1493
- } catch {}
1494
- const server = createServer((socket) => {
1495
- let buf = "";
1496
- socket.on("data", (chunk) => {
1497
- buf += chunk.toString("utf8");
1498
- let nl;
1499
- while ((nl = buf.indexOf("\n")) >= 0) {
1500
- const line = buf.slice(0, nl).replace(/\r$/, "");
1501
- buf = buf.slice(nl + 1);
1502
- if (line.trim()) onLine(line);
1503
- socket.write("ok\n");
1504
- }
1505
- });
1506
- });
1507
- server.listen(path);
1508
- server.on("close", () => {
1509
- try {
1510
- if (existsSync(path)) unlinkSync(path);
1511
- } catch {}
1512
- });
1513
- return server;
1514
- }
1515
- /**
1516
- * `pai worker say <id> "<text>"`: forward one line to a running worker.
1517
- * Resolves "ok", rejects with a clear message when the worker is not running.
1518
- */
1519
- function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
1520
- const status = loadStatus(logDir, id);
1521
- if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
1522
- 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>"`));
1523
- const path = operatorSocketPath(logDir, id);
1524
- if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
1525
- return new Promise((resolve, reject) => {
1526
- const sock = connect(path);
1527
- const fail = (e) => {
1528
- sock.destroy();
1529
- reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
1530
- };
1531
- sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
1532
- sock.once("error", (e) => fail(e));
1533
- sock.once("connect", () => {
1534
- sock.write(text.replace(/\n/g, " ") + "\n");
1535
- });
1536
- sock.once("data", () => {
1537
- sock.end();
1538
- resolve("ok");
1539
- });
1540
- });
1541
- }
1542
-
1543
2126
  //#endregion
1544
2127
  //#region src/workers/proxy/server.ts
1545
2128
  /**
@@ -1877,9 +2460,23 @@ function stdinUserMessage(text) {
1877
2460
  }
1878
2461
  });
1879
2462
  }
1880
- /** ISO stamp with seconds, attached to every mirrored event (2g). */
1881
- function isoStamp(d = /* @__PURE__ */ new Date()) {
1882
- return d.toISOString().replace(/\.\d{3}Z$/, "Z");
2463
+ /** An operator line as the worker sees it: carrying the contract's marker. */
2464
+ function operatorUserText(text) {
2465
+ return `${OPERATOR_MARK} ${text}`;
2466
+ }
2467
+ /**
2468
+ * ISO stamp with seconds, attached to every mirrored event (2g): local time
2469
+ * with its offset (`2026-09-17T14:19:23+02:00`), so the viewer can render the
2470
+ * wall clock the operator lives in. `offMin` is east-positive minutes —
2471
+ * injectable so tests do not depend on the machine's zone.
2472
+ */
2473
+ function isoStamp(d = /* @__PURE__ */ new Date(), offMin = -d.getTimezoneOffset()) {
2474
+ const t = new Date(d.getTime() + offMin * 6e4);
2475
+ const sign = offMin < 0 ? "-" : "+";
2476
+ const abs = Math.abs(offMin);
2477
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
2478
+ const mm = String(abs % 60).padStart(2, "0");
2479
+ return `${t.toISOString().slice(0, 19)}${sign}${hh}:${mm}`;
1883
2480
  }
1884
2481
  /** Context tokens of an assistant/result usage block (input+cache+output). */
1885
2482
  function usageContextTokens(u) {
@@ -1902,9 +2499,15 @@ async function runWorker(opts) {
1902
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)");
1903
2500
  const logDir = workersLogDir(config);
1904
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);
1905
2508
  const target = resolveTarget(config, logDir, {
1906
2509
  flagProvider: opts.providerFlag,
1907
- role: opts.role
2510
+ className: opts.className
1908
2511
  });
1909
2512
  assertProviderRunnable(target.providerName, target.provider);
1910
2513
  const parsed = parseRunnerArgs(opts.claudeArgs);
@@ -1920,7 +2523,14 @@ async function runWorker(opts) {
1920
2523
  parsed,
1921
2524
  claudeArgs: opts.claudeArgs,
1922
2525
  noPane: opts.noPane ?? false,
1923
- onWorkerStart: opts.onWorkerStart
2526
+ cwd: opts.cwd,
2527
+ parent: parent ?? void 0,
2528
+ stage: opts.stage,
2529
+ quiet: opts.quiet,
2530
+ onWorkerStart: opts.onWorkerStart,
2531
+ id: opts.id,
2532
+ worktreeFlag: opts.worktreeFlag,
2533
+ className: opts.className
1924
2534
  });
1925
2535
  return await executeRun({
1926
2536
  config,
@@ -1932,7 +2542,14 @@ async function runWorker(opts) {
1932
2542
  claudeArgs: opts.claudeArgs,
1933
2543
  noPane: opts.noPane ?? false,
1934
2544
  mcpFlag: opts.mcpFlag,
2545
+ cwd: opts.cwd,
2546
+ parent: parent ?? void 0,
2547
+ stage: opts.stage,
2548
+ quiet: opts.quiet,
1935
2549
  onWorkerStart: opts.onWorkerStart,
2550
+ id: opts.id,
2551
+ worktreeFlag: opts.worktreeFlag,
2552
+ className: opts.className,
1936
2553
  reroutes: opts._reroutes ?? 0
1937
2554
  });
1938
2555
  } catch (e) {
@@ -1946,10 +2563,26 @@ async function executeRun(a) {
1946
2563
  let proxyUrl;
1947
2564
  if (target.provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${target.providerName}`;
1948
2565
  const env = buildRunEnv(target.provider, headless, proxyUrl);
1949
- const wid = newWorkerId();
1950
- const cwd = process.cwd();
2566
+ const wid = a.id ?? newWorkerId();
2567
+ const cwd = a.cwd ?? process.cwd();
1951
2568
  const term = process.env.ITERM_SESSION_ID ?? "";
1952
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;
1953
2586
  const status = {
1954
2587
  id: wid,
1955
2588
  pid: process.pid,
@@ -1967,7 +2600,16 @@ async function executeRun(a) {
1967
2600
  rc: null,
1968
2601
  secs: null,
1969
2602
  ...session ? { session } : {},
1970
- contextWindow: providerContextWindow(target.provider)
2603
+ contextWindow: providerContextWindow(target.provider),
2604
+ ...a.parent ? {
2605
+ parent: a.parent,
2606
+ stage: a.stage
2607
+ } : {},
2608
+ ...worktree ? {
2609
+ worktreeDir: worktree.dir,
2610
+ branch: worktree.branch,
2611
+ worktreeBase: worktree.base
2612
+ } : {}
1971
2613
  };
1972
2614
  saveStatus(logDir, status);
1973
2615
  a.onWorkerStart?.(wid);
@@ -1986,7 +2628,7 @@ async function executeRun(a) {
1986
2628
  const wanted = [
1987
2629
  ...a.mcpFlag ? [a.mcpFlag] : [],
1988
2630
  ...parsed.mcp,
1989
- ...target.roleMcp ?? []
2631
+ ...target.classMcp ?? []
1990
2632
  ];
1991
2633
  if (wanted.length) mcpArgs = [
1992
2634
  "--strict-mcp-config",
@@ -2006,11 +2648,12 @@ async function executeRun(a) {
2006
2648
  if (headless) {
2007
2649
  cmd.push("--output-format", "stream-json", "--verbose", "--input-format", "stream-json");
2008
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));
2009
2652
  }
2010
2653
  const t0 = Date.now();
2011
2654
  const proc = spawn(cmd[0], cmd.slice(1), {
2012
2655
  env,
2013
- cwd,
2656
+ cwd: worktree?.dir ?? cwd,
2014
2657
  stdio: headless ? [
2015
2658
  "pipe",
2016
2659
  "pipe",
@@ -2045,10 +2688,11 @@ async function executeRun(a) {
2045
2688
  }
2046
2689
  writeEvent({
2047
2690
  type: "operator",
2048
- text
2691
+ text,
2692
+ handoff: isHandoffMessage(text)
2049
2693
  });
2050
2694
  try {
2051
- proc.stdin?.write(stdinUserMessage(text) + "\n");
2695
+ proc.stdin?.write(stdinUserMessage(operatorUserText(text)) + "\n");
2052
2696
  } catch {}
2053
2697
  }) : null;
2054
2698
  let killed = false;
@@ -2154,7 +2798,8 @@ async function executeRun(a) {
2154
2798
  tools: status.tools,
2155
2799
  label
2156
2800
  });
2157
- if (headless) 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));
2158
2803
  const resultText = resultEvent?.result ?? "";
2159
2804
  if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
2160
2805
  setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
@@ -2170,22 +2815,69 @@ async function executeRun(a) {
2170
2815
  label,
2171
2816
  noPane: a.noPane,
2172
2817
  mcpFlag: a.mcpFlag,
2818
+ cwd: a.cwd,
2819
+ parent: a.parent,
2820
+ stage: a.stage,
2173
2821
  claudeArgs: a.claudeArgs,
2174
2822
  onWorkerStart: a.onWorkerStart,
2823
+ worktreeFlag: a.worktreeFlag,
2824
+ className: a.className,
2175
2825
  _reroutes: a.reroutes + 1
2176
2826
  });
2177
2827
  }
2178
2828
  }
2179
- 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;
2180
2855
  }
2181
2856
  async function executeCodexRun(a) {
2182
2857
  const { config, logDir, target, model, label, parsed, noPane } = a;
2183
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>'`);
2184
2859
  const env = buildCodexEnv(target.provider);
2185
- const wid = newWorkerId();
2186
- const cwd = process.cwd();
2860
+ const wid = a.id ?? newWorkerId();
2861
+ const cwd = a.cwd ?? process.cwd();
2187
2862
  const term = process.env.ITERM_SESSION_ID ?? "";
2188
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;
2189
2881
  const status = {
2190
2882
  id: wid,
2191
2883
  pid: process.pid,
@@ -2203,7 +2895,16 @@ async function executeCodexRun(a) {
2203
2895
  rc: null,
2204
2896
  secs: null,
2205
2897
  ...session ? { session } : {},
2206
- contextWindow: providerContextWindow(target.provider)
2898
+ contextWindow: providerContextWindow(target.provider),
2899
+ ...a.parent ? {
2900
+ parent: a.parent,
2901
+ stage: a.stage
2902
+ } : {},
2903
+ ...worktree ? {
2904
+ worktreeDir: worktree.dir,
2905
+ branch: worktree.branch,
2906
+ worktreeBase: worktree.base
2907
+ } : {}
2207
2908
  };
2208
2909
  saveStatus(logDir, status);
2209
2910
  a.onWorkerStart?.(wid);
@@ -2224,9 +2925,9 @@ async function executeCodexRun(a) {
2224
2925
  });
2225
2926
  if (!noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
2226
2927
  const t0 = Date.now();
2227
- const proc = spawn("codex", buildCodexArgs(parsed.prompt, parsed.callerModel ? void 0 : model), {
2928
+ const proc = spawn("codex", buildCodexArgs(prompt, parsed.callerModel ? void 0 : model), {
2228
2929
  env,
2229
- cwd,
2930
+ cwd: worktree?.dir ?? cwd,
2230
2931
  stdio: [
2231
2932
  "ignore",
2232
2933
  "pipe",
@@ -2313,10 +3014,29 @@ async function executeCodexRun(a) {
2313
3014
  tools: status.tools,
2314
3015
  label
2315
3016
  });
2316
- printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report);
2317
- 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;
2318
3038
  }
2319
- function printResult(fmt, resultEvent, rc, logDir, wid, report) {
3039
+ function printResult(fmt, resultEvent, rc, logDir, wid, report, extras) {
2320
3040
  if (fmt === "stream-json") return;
2321
3041
  if (fmt === "json") {
2322
3042
  const payload = report ? {
@@ -2325,11 +3045,15 @@ function printResult(fmt, resultEvent, rc, logDir, wid, report) {
2325
3045
  result: "no result event",
2326
3046
  rc
2327
3047
  },
2328
- report
2329
- } : resultEvent ?? {
2330
- is_error: true,
2331
- result: "no result event",
2332
- rc
3048
+ report,
3049
+ ...extras
3050
+ } : {
3051
+ ...resultEvent ?? {
3052
+ is_error: true,
3053
+ result: "no result event",
3054
+ rc
3055
+ },
3056
+ ...extras
2333
3057
  };
2334
3058
  console.log(JSON.stringify(payload));
2335
3059
  return;
@@ -2441,627 +3165,161 @@ function resultFromOutput(out) {
2441
3165
  }
2442
3166
 
2443
3167
  //#endregion
2444
- //#region src/workers/render.ts
3168
+ //#region src/workers/chain.ts
2445
3169
  /**
2446
- * render.ts — turn worker events into the lines a human reads.
3170
+ * chain.ts — draft-then-implement chains: `--chain draft,implement[,review]`.
2447
3171
  *
2448
- * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
2449
- * and searches, `$` for shell, magenta `~` + red/green diff for edits, green
2450
- * `+` for writes), Read-result trimming (first 3 lines then a count), and the
2451
- * result footer. Colors are applied only when the output is a TTY, so MCP
2452
- * tool output stays plain text.
2453
- */
2454
- const CODES = {
2455
- dim: "2",
2456
- bold: "1",
2457
- red: "31",
2458
- green: "32",
2459
- yellow: "33",
2460
- blue: "34",
2461
- mag: "35",
2462
- cyan: "36"
2463
- };
2464
- function makeColor(enabled) {
2465
- return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
2466
- }
2467
- /** Path relative to cwd when it lies inside it, otherwise unchanged. */
2468
- function relPath(path, cwd) {
2469
- if (!path || !cwd) return path;
2470
- let r;
2471
- try {
2472
- r = relative(cwd, path);
2473
- } catch {
2474
- return path;
2475
- }
2476
- return r.startsWith("..") ? path : r;
2477
- }
2478
- /** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
2479
- function unifiedDiffLines(oldStr, newStr) {
2480
- const oldL = oldStr.split("\n");
2481
- const newL = newStr.split("\n");
2482
- let start = 0;
2483
- while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
2484
- let endOld = oldL.length;
2485
- let endNew = newL.length;
2486
- while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
2487
- endOld--;
2488
- endNew--;
2489
- }
2490
- const out = [];
2491
- for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
2492
- for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
2493
- return out;
2494
- }
2495
- /**
2496
- * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
2497
- * the worker tag in front when several run at once. Continuation lines get
2498
- * blanks of the same width so wrapped text stays aligned. null when the event
2499
- * carries no stamp (logs from before 2g render with the plain prefix).
3172
+ * The chain runs each stage as its own worker (own id, own pane, `parent` set
3173
+ * to the chain id), so `ps` shows the chain as a tree and every stage can be
3174
+ * followed, replayed and said to like any other worker:
3175
+ *
3176
+ * - draft turns the operator's brief into a full spec file under
3177
+ * <logDir>/specs/<chain id>.md (goal, constraints, files likely
3178
+ * touched, acceptance checks, verification commands);
3179
+ * - any other stage (implement, plan, …) runs with that spec as its prompt
3180
+ * and the original brief attached;
3181
+ * - review reads the spec and the working-tree diff and produces the
3182
+ * structured report.
3183
+ *
3184
+ * A stage that fails (or a draft that produces no spec file) stops the chain;
3185
+ * the caller then writes the spec itself and re-runs without the draft stage.
2500
3186
  */
2501
- function gutterFor(c, e, tag) {
2502
- if (!e._ts) return null;
2503
- const time = e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts;
2504
- const head = tag ? `${tag} ${time}` : time;
2505
- const width = head.length + 3;
2506
- return {
2507
- first: c("dim", `${head} │ `),
2508
- cont: " ".repeat(width)
2509
- };
2510
- }
2511
- /** Compact token count: 84k, 200k, 900. */
2512
- function fmtK(n) {
2513
- return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
3187
+ /** Where a chain's spec file lives: <logDir>/specs/<chain id>.md. */
3188
+ function specPathFor(logDir, chainId) {
3189
+ return join(logDir, "specs", `${chainId}.md`);
2514
3190
  }
2515
3191
  /**
2516
- * The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.
2517
- * null when the numbers are missing or below `minPct` (the table only shows
2518
- * it past 60; the pane liveness line always shows it).
3192
+ * Replace the caller's -p value with `prompt`, dropping every existing
3193
+ * -p/--print pair first (two -p flags on one claude command line are an error,
3194
+ * so the chain must never leave the brief in place when swapping prompts).
2519
3195
  */
2520
- function contextMeter(c, s, minPct = 0) {
2521
- const pct = contextPercent(s);
2522
- if (pct === null || pct <= minPct) return null;
2523
- const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;
2524
- if (pct > 85) return c("red", label);
2525
- if (pct > 70) return c("yellow", label);
2526
- return label;
2527
- }
2528
- function renderToolUse(c, prefix, name, inp, cwd) {
2529
- const i = typeof inp === "object" && inp !== null ? inp : {};
2530
- const get = (k) => typeof i[k] === "string" ? i[k] : "";
2531
- if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
2532
- if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
2533
- if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
2534
- if (name === "Edit") {
2535
- const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
2536
- for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
2537
- else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
2538
- else out.push(`${prefix} ${c("dim", line)}`);
2539
- return out;
2540
- }
2541
- if (name === "Write") {
2542
- const n = get("content").split("\n").length;
2543
- return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
2544
- }
2545
- if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
2546
- return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
2547
- }
2548
- /** Render one stream-json event; `tools` maps tool_use_id → tool name. */
2549
- function renderEvent(c, prefix, e, cwd, tools, gutter) {
3196
+ function swapPromptArg(claudeArgs, prompt) {
2550
3197
  const out = [];
2551
- if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
2552
- else if (e.type === "operator") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
2553
- else if (e.type === "assistant") {
2554
- 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}`);
2555
- else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
2556
- } else if (e.type === "user") for (const b of e.message?.content ?? []) {
2557
- if (b.type !== "tool_result") continue;
2558
- let content = b.content ?? "";
2559
- if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
2560
- const text = String(content);
2561
- const isError = b.is_error === true;
2562
- if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
2563
- const lines = text.split("\n");
2564
- for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
2565
- if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
2566
- } else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
2567
- else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
2568
- }
2569
- else if (e.type === "result") {
2570
- const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
2571
- out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
2572
- const report = parseWorkerReport(String(e.result ?? ""));
2573
- if (report) out.push(...renderReport(c, prefix, report, cwd));
2574
- else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
2575
- }
2576
- if (!gutter) return out;
2577
- return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
2578
- }
2579
- /** Transcript header: id, provider, label, session name, project dir. */
2580
- function headerLine(c, s) {
2581
- const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
2582
- const sep = bits ? ` ${bits}` : "";
2583
- return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
2584
- }
2585
- /** The ps table (RUNNING + FINISHED last 8). */
2586
- function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date()) {
2587
- const running = [];
2588
- const done = [];
2589
- for (const s of statuses) if (s.state === "running" && alive(s.pid)) running.push(s);
2590
- else {
2591
- if (s.state === "running") s.state = "lost";
2592
- done.push(s);
2593
- }
2594
- const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
2595
- lines.push(c("bold", `RUNNING (${running.length})`));
2596
- if (!running.length) lines.push(" none");
2597
- for (const s of running) {
2598
- const meter = contextMeter(c, s, 60);
2599
- lines.push(` ${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 : ""}`);
2600
- lines.push(` task: ${s.label}`);
2601
- lines.push(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`);
2602
- }
2603
- lines.push("");
2604
- lines.push(c("bold", "FINISHED (last 8)"));
2605
- for (const s of done.slice(-8)) {
2606
- const col = s.state === "done" ? "green" : "red";
2607
- const tag = sessionTag(s);
2608
- lines.push(` ${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}`);
2609
- }
2610
- lines.push("");
2611
- lines.push(c("dim", "worker follow live transcript of running workers"));
2612
- lines.push(c("dim", "worker <id> replay one worker"));
2613
- lines.push(c("dim", scopeLabel));
2614
- return lines.join("\n");
2615
- }
2616
- /** One-line status-bar summary (same shape glm-ps --status printed). */
2617
- function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(true)) {
2618
- if (!mine.length) return "";
2619
- const running = mine.filter((s) => s.state === "running" && alive(s.pid));
2620
- const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
2621
- const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
2622
- const ok = doneToday.filter((s) => s.state === "done").length;
2623
- const bad = doneToday.length - ok;
2624
- const providers = new Set(running.map((s) => s.provider));
2625
- let head = `${providers.size === 1 ? [...providers][0] : "workers"} ▶${running.length}`;
2626
- const parts = running.slice(0, 3).map((s) => {
2627
- const meter = contextMeter(c, s, 60);
2628
- return `${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}` + (meter ? ` ${meter}` : "");
2629
- });
2630
- if (parts.length) head += " " + parts.join(" | ");
2631
- if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
2632
- return head;
2633
- }
2634
-
2635
- //#endregion
2636
- //#region src/workers/viewer.ts
2637
- /**
2638
- * viewer.ts — ps / follow / replay / status line over the worker logDir.
2639
- *
2640
- * Scoping: workers launched from this terminal's AIBroker session (or, as the
2641
- * fallback, its iTerm tab) unless --all or an explicit worker id is given.
2642
- * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
2643
- *
2644
- * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
2645
- * `_ts` stamp on every mirrored event, the worker tag in front when several
2646
- * run at once, a date separator when the day changes, and — on a TTY — a
2647
- * liveness line (`⋯ 12s since last event · Bash: npm test · ctx 84k/200k (42%)`)
2648
- * that is rewritten in place between events. Following one worker also wires
2649
- * this pane's stdin: every typed line is said to the worker while it runs and
2650
- * resumes it (same Claude session) once it has finished.
2651
- */
2652
- function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
2653
- const c = makeColor(color);
2654
- const term = env.ITERM_SESSION_ID ?? "";
2655
- const statuses = loadStatuses(logDir);
2656
- 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)`);
2657
- }
2658
- /** The rendered transcript of one worker, from the start, as one string. */
2659
- function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
2660
- const path = eventsPath(logDir, wid);
2661
- if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
2662
- const c = makeColor(color);
2663
- const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
2664
- id: wid,
2665
- label: "",
2666
- cwd: "",
2667
- provider: ""
2668
- };
2669
- const tools = {};
2670
- const out = [headerLine(c, st)];
2671
- const lines = readFileSync(path, "utf8").split("\n");
2672
- let lastDay = "";
2673
- for (const line of tailLines !== void 0 ? lines.slice(-tailLines) : lines) {
2674
- if (!line.trim()) continue;
2675
- let e;
2676
- try {
2677
- e = JSON.parse(line);
2678
- } catch {
3198
+ for (let i = 0; i < claudeArgs.length; i++) {
3199
+ const a = claudeArgs[i];
3200
+ if (a === "-p" || a === "--print") {
3201
+ if (i + 1 < claudeArgs.length && !claudeArgs[i + 1].startsWith("-")) i += 1;
2679
3202
  continue;
2680
3203
  }
2681
- if (typeof e._ts === "string" && e._ts.slice(0, 10) !== lastDay) {
2682
- lastDay = e._ts.slice(0, 10);
2683
- out.push(c("dim", `── ${lastDay} ──`));
2684
- }
2685
- const g = gutterFor(c, e);
2686
- collectTools(e, tools);
2687
- out.push(...renderEvent(c, g ? "" : " ", e, st.cwd ?? "", tools, g));
3204
+ out.push(a);
2688
3205
  }
2689
- return out.join("\n");
2690
- }
2691
- function collectTools(e, tools) {
2692
- if (e.type !== "assistant") return;
2693
- for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
2694
- }
2695
- /**
2696
- * The follow exit decision for one worker: it is over once its result event
2697
- * was rendered, or once its status left "running" while its pid is gone — a
2698
- * worker killed without writing a result still ends. A status never read
2699
- * (undefined) means "not over": follow keeps waiting for the first event.
2700
- */
2701
- function workerEnded(resultRendered, state, pidAlive) {
2702
- return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
3206
+ out.push("-p", prompt);
3207
+ return out;
2703
3208
  }
2704
- /**
2705
- * Tail one worker (target) or the running workers of this scope, live.
2706
- * Auto-exit: with a target, wait `autoExit` seconds after its end; without
2707
- * one, exit once no worker in scope has run for that many seconds in a row,
2708
- * never within the first 30 s. With a target on a TTY, typed stdin lines are
2709
- * said to the worker (or resume it after it finished).
2710
- */
2711
- async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true) {
2712
- const c = makeColor(color);
2713
- const tty = process.stdout.isTTY === true;
2714
- const term = env.ITERM_SESSION_ID ?? "";
2715
- const scopeTab = target || showAll ? "" : currentTabKey(env);
2716
- const tools = {};
2717
- const handles = /* @__PURE__ */ new Map();
2718
- const seenHeader = /* @__PURE__ */ new Set();
2719
- const finished = /* @__PURE__ */ new Set();
2720
- const lastDayBy = /* @__PURE__ */ new Map();
2721
- const started = Date.now();
2722
- let idleSince = null;
2723
- let aborted = false;
2724
- let lastEventAt = Date.now();
2725
- let lastAction = "waiting for first event";
2726
- let meterStatus = null;
2727
- let livenessLen = 0;
2728
- const onInt = () => {
2729
- aborted = true;
2730
- };
2731
- process.once("SIGINT", onInt);
2732
- const plainOf = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
2733
- const eraseLiveness = () => {
2734
- if (livenessLen > 0) {
2735
- process.stdout.write("\r" + " ".repeat(livenessLen) + "\r");
2736
- livenessLen = 0;
2737
- }
2738
- };
2739
- const writeLiveness = () => {
2740
- if (!tty) return;
2741
- eraseLiveness();
2742
- const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
2743
- const meter = meterStatus ? contextMeter(c, meterStatus) : null;
2744
- const plain = `⋯ ${secs}s since last event · ${lastAction}${meter ? ` · ${plainOf(meter)}` : ""}`;
2745
- process.stdout.write(`
2746
- livenessLen = plain.length;
2747
- };
2748
- const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
2749
- const noteLine = (s) => {
2750
- eraseLiveness();
2751
- process.stdout.write(s + "\n");
2752
- };
2753
- const resumeTarget = (text, id) => {
2754
- noteLine(c("dim", resuming ${id} …`));
2755
- const child = spawn("pai", [
2756
- "worker",
2757
- "resume",
2758
- id,
2759
- text,
2760
- "--print-id",
2761
- "--no-pane"
2762
- ], { stdio: [
2763
- "ignore",
2764
- "pipe",
2765
- "inherit"
2766
- ] });
2767
- let idOut = "";
2768
- child.stdout?.on("data", (chunk) => {
2769
- idOut += chunk.toString("utf8");
2770
- });
2771
- child.on("close", (rc) => {
2772
- const newId = idOut.trim().split("\n").pop() ?? "";
2773
- if (rc === 0 && /^[0-9]{8}-[0-9]{6}-[0-9]+$/.test(newId)) {
2774
- noteLine(c("dim", resumed as ${newId}`));
2775
- target = newId;
2776
- finished.delete(newId);
2777
- seenHeader.delete(newId);
2778
- lastEventAt = Date.now();
2779
- lastAction = "resumed";
2780
- } else noteLine(c("red", `» resume failed (rc=${rc})`));
2781
- });
2782
- };
2783
- const handleOperatorLine = (raw) => {
2784
- const text = raw.trim();
2785
- if (!text || target === null) return;
2786
- const id = target;
2787
- sayToWorker(logDir, id, text).then(() => noteLine(c("dim", `» sent to ${id}`)), (e) => {
2788
- if (loadStatuses(logDir).some((s) => s.id === id)) resumeTarget(text, id);
2789
- else noteLine(c("red", `» ${e.message}`));
3209
+ function draftPrompt(brief, specPath) {
3210
+ return [
3211
+ "You are the DRAFT stage of a worker chain. Turn the operator's brief below into a full implementation spec and write it to",
3212
+ specPath,
3213
+ "with the Write tool.",
3214
+ "",
3215
+ "Use exactly these five sections as Markdown headings:",
3216
+ "# Goal",
3217
+ "# Constraints",
3218
+ "# Files likely touched",
3219
+ "# Acceptance checks",
3220
+ "# Verification commands",
3221
+ "",
3222
+ "Read the repository first (Glob/Grep/Read) so the spec names real files and real commands. Do not implement anything.",
3223
+ "",
3224
+ "## Operator brief",
3225
+ "",
3226
+ brief
3227
+ ].join("\n");
3228
+ }
3229
+ function implementPrompt(brief, specPath, spec) {
3230
+ return [
3231
+ ...spec ? [
3232
+ "You are the IMPLEMENT stage of a worker chain. The draft stage wrote the spec below (also at " + specPath + "). Implement it exactly, then run the spec's verification commands before finishing.",
3233
+ "",
3234
+ "## Spec",
3235
+ "",
3236
+ spec
3237
+ ] : ["Implement the operator's brief below."],
3238
+ "",
3239
+ "## Operator brief",
3240
+ "",
3241
+ brief
3242
+ ].join("\n");
3243
+ }
3244
+ function reviewPrompt(brief, specPath, spec) {
3245
+ return [
3246
+ "You are the REVIEW stage of a worker chain. The implement stage just ran. Read the repository's diff (run `git diff` and `git status`; use `git diff --stat` for the overview) and check it against the spec's acceptance checks and verification commands — run the checks when they are cheap. Do not fix anything you find; report it.",
3247
+ ...spec ? [
3248
+ "",
3249
+ "## Spec (also at " + specPath + ")",
3250
+ "",
3251
+ spec
3252
+ ] : [],
3253
+ "",
3254
+ "## Operator brief",
3255
+ "",
3256
+ brief
3257
+ ].join("\n");
3258
+ }
3259
+ /** Run a chain of stages; returns the exit code of the first failed stage, 0 when all pass. */
3260
+ async function runChain(opts, deps = {}) {
3261
+ const stages = opts.stages.map((s) => s.trim()).filter(Boolean);
3262
+ if (!stages.length) throw new Error("--chain needs at least one class, e.g. --chain draft,implement");
3263
+ const runStage = deps.runStage ?? runWorker;
3264
+ const logDir = deps.logDir ?? workersLogDir(readWorkersSection().workers);
3265
+ const chainId = newWorkerId();
3266
+ const specPath = specPathFor(logDir, chainId);
3267
+ mkdirSync(join(logDir, "specs"), { recursive: true });
3268
+ const baseLabel = opts.label ?? shortText(opts.brief, 40);
3269
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN", {
3270
+ chain: chainId,
3271
+ stages: stages.join(","),
3272
+ label: baseLabel
3273
+ });
3274
+ opts.onChainStart?.(chainId);
3275
+ let spec = null;
3276
+ for (let i = 0; i < stages.length; i++) {
3277
+ const stage = stages[i];
3278
+ if (stage === "draft") spec = null;
3279
+ else if (spec === null && existsSync(specPath)) spec = readFileSync(specPath, "utf8");
3280
+ const prompt = stage === "draft" ? draftPrompt(opts.brief, specPath) : stage === "review" ? reviewPrompt(opts.brief, spec ? specPath : null, spec) : implementPrompt(opts.brief, spec ? specPath : null, spec);
3281
+ process.stderr.write(`chain ${chainId}: stage ${i + 1}/${stages.length} ${stage} (spec: ${specPath})\n`);
3282
+ const rc = await runStage({
3283
+ className: opts.className ?? stage,
3284
+ providerFlag: opts.providerFlag,
3285
+ modelFlag: opts.modelFlag,
3286
+ label: `${baseLabel} · ${stage}`,
3287
+ noPane: opts.noPane,
3288
+ mcpFlag: opts.mcpFlag,
3289
+ claudeArgs: swapPromptArg(opts.claudeArgs, prompt),
3290
+ cwd: opts.cwd,
3291
+ parent: chainId,
3292
+ stage,
3293
+ quiet: opts.quiet
2790
3294
  });
2791
- };
2792
- let rlIn = null;
2793
- if (target !== null && process.stdin.isTTY) {
2794
- rlIn = createInterface({ input: process.stdin });
2795
- rlIn.on("line", handleOperatorLine);
2796
- }
2797
- try {
2798
- for (;;) {
2799
- if (aborted) return;
2800
- for (const wid of target ? [target] : runningIds()) {
2801
- if (handles.has(wid) || finished.has(wid)) continue;
2802
- const path = eventsPath(logDir, wid);
2803
- if (existsSync(path)) handles.set(wid, {
2804
- fd: openSync(path, "r"),
2805
- buf: ""
3295
+ if (stage === "draft") {
3296
+ if (!existsSync(specPath)) {
3297
+ process.stderr.write(`chain ${chainId}: draft stage produced no spec at ${specPath} — stopping. Write the spec yourself and re-run without the draft stage.\n`);
3298
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
3299
+ chain: chainId,
3300
+ rc: rc !== 0 ? rc : 1,
3301
+ failed: "draft"
2806
3302
  });
3303
+ return rc !== 0 ? rc : 1;
2807
3304
  }
2808
- const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
2809
- const multi = handles.size > 1 || target === null;
2810
- let progressed = false;
2811
- for (const [wid, h] of [...handles.entries()]) {
2812
- const st = statuses.get(wid) ?? {
2813
- id: wid,
2814
- label: "",
2815
- cwd: "",
2816
- provider: ""
2817
- };
2818
- const prefix = multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " ";
2819
- const buffer = Buffer.alloc(65536);
2820
- for (;;) {
2821
- let n;
2822
- try {
2823
- n = readSync(h.fd, buffer, 0, buffer.length, null);
2824
- } catch {
2825
- n = 0;
2826
- }
2827
- if (n <= 0) break;
2828
- h.buf += buffer.toString("utf8", 0, n);
2829
- }
2830
- const lines = h.buf.split("\n");
2831
- h.buf = lines.pop() ?? "";
2832
- for (const line of lines) {
2833
- progressed = true;
2834
- if (!seenHeader.has(wid)) {
2835
- eraseLiveness();
2836
- process.stdout.write(headerLine(c, st) + "\n");
2837
- seenHeader.add(wid);
2838
- }
2839
- if (!line.trim()) continue;
2840
- let e;
2841
- try {
2842
- e = JSON.parse(line);
2843
- } catch {
2844
- continue;
2845
- }
2846
- const day = typeof e._ts === "string" ? e._ts.slice(0, 10) : "";
2847
- if (day && day !== lastDayBy.get(wid)) {
2848
- eraseLiveness();
2849
- process.stdout.write(`${multi ? prefix : ""}${c("dim", `── ${day} ──`)}\n`);
2850
- lastDayBy.set(wid, day);
2851
- }
2852
- collectTools(e, tools);
2853
- const g = gutterFor(c, e, multi ? c("cyan", wid.slice(-4)) : void 0);
2854
- eraseLiveness();
2855
- for (const ln of renderEvent(c, g ? "" : prefix, e, st.cwd ?? "", tools, g)) process.stdout.write(ln + "\n");
2856
- lastEventAt = Date.now();
2857
- if (e.type === "operator") lastAction = "operator message";
2858
- else if (e.type === "assistant") {
2859
- const tool = (e.message?.content ?? []).find((b) => b.type === "tool_use");
2860
- lastAction = tool ? `${tool.name ?? "?"} running` : "thinking";
2861
- } else if (e.type === "result") lastAction = "finished";
2862
- meterStatus = st;
2863
- if (e.type === "result") finished.add(wid);
2864
- }
2865
- if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
2866
- if (!finished.has(wid)) {
2867
- eraseLiveness();
2868
- process.stdout.write(`${prefix}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}\n`);
2869
- finished.add(wid);
2870
- }
2871
- closeSync(h.fd);
2872
- handles.delete(wid);
2873
- }
2874
- }
2875
- const lingerOn = target;
2876
- if (lingerOn !== null && finished.has(lingerOn)) {
2877
- if (autoExit) {
2878
- const until = Date.now() + autoExit * 1e3;
2879
- while (Date.now() < until && !aborted && target === lingerOn) {
2880
- writeLiveness();
2881
- await sleep(250);
2882
- }
2883
- if (aborted || target !== lingerOn) continue;
2884
- eraseLiveness();
2885
- process.stdout.write(c("dim", "closing") + "\n");
2886
- return;
2887
- }
2888
- if (rlIn === null) return;
2889
- }
2890
- if (autoExit && !target) {
2891
- if (runningIds().length) idleSince = null;
2892
- else if (idleSince === null) idleSince = Date.now();
2893
- else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
2894
- eraseLiveness();
2895
- process.stdout.write(c("dim", "closing") + "\n");
2896
- return;
2897
- }
2898
- }
2899
- if (!progressed) {
2900
- writeLiveness();
2901
- await sleep(500);
2902
- }
3305
+ spec = readFileSync(specPath, "utf8");
3306
+ }
3307
+ if (rc !== 0) {
3308
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
3309
+ chain: chainId,
3310
+ rc,
3311
+ failed: stage
3312
+ });
3313
+ return rc;
2903
3314
  }
2904
- } finally {
2905
- rlIn?.close();
2906
- process.removeListener("SIGINT", onInt);
2907
- for (const h of handles.values()) try {
2908
- closeSync(h.fd);
2909
- } catch {}
2910
- }
2911
- }
2912
- function sleep(ms) {
2913
- return new Promise((r) => setTimeout(r, ms));
2914
- }
2915
- /** Workers of this terminal for the status bar; "" when there are none. */
2916
- function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
2917
- return renderStatusLine(loadStatuses(logDir).filter((s) => {
2918
- const sameScope = term && workerInScope(s, term);
2919
- const sameDir = cwd && s.cwd.startsWith(cwd);
2920
- return sameScope || !term && sameDir;
2921
- }), now);
2922
- }
2923
-
2924
- //#endregion
2925
- //#region src/workers/providers.ts
2926
- /**
2927
- * providers.ts — provider, role and switch management over the workers config.
2928
- *
2929
- * One layer under both `pai worker providers …` and the MCP worker_providers
2930
- * tool. Every mutation re-reads the config file, changes only the workers
2931
- * section, and writes it back atomically — the file is shared with everything
2932
- * else PAI runs, so a torn write is not an option.
2933
- *
2934
- * Keys are never stored in the config and never accepted inline from the CLI:
2935
- * only file paths. (The MCP `add` tool additionally accepts a raw `key`, which
2936
- * it immediately parks in ~/.config/pai/keys/<name>, mode 0600, storing only
2937
- * the path — a chat is not a place to leave a credential lying around.)
2938
- */
2939
- function addProvider(input) {
2940
- 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 _`);
2941
- 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.");
2942
- if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
2943
- let keyFile = input.keyFile ?? null;
2944
- if (input.key !== void 0) {
2945
- const dir = keysDir();
2946
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2947
- const path = `${dir}/${input.name}`;
2948
- writeFileSync(path, input.key.trim() + "\n", {
2949
- encoding: "utf8",
2950
- mode: 384
2951
- });
2952
- try {
2953
- chmodSync(path, 384);
2954
- } catch {}
2955
- keyFile = path;
2956
- }
2957
- const { raw, workers } = readWorkersSection();
2958
- const provider = {
2959
- enabled: true,
2960
- protocol: input.protocol ?? "anthropic",
2961
- baseUrl: input.baseUrl,
2962
- keyFile,
2963
- models: input.fastModel ? {
2964
- default: input.model,
2965
- fast: input.fastModel
2966
- } : { default: input.model },
2967
- env: input.env ?? {},
2968
- ...input.note ? { note: input.note } : {},
2969
- ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
2970
- ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
2971
- ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
2972
- ...input.contextWindow ? { contextWindow: input.contextWindow } : {}
2973
- };
2974
- workers.providers[input.name] = provider;
2975
- if (Object.keys(workers.providers).length === 1) {
2976
- workers.enabled = true;
2977
- workers.active = input.name;
2978
- workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
2979
- workers.roles = {
2980
- implement: input.name,
2981
- research: input.name,
2982
- spotcheck: input.fastModel ? `${input.name}/fast` : input.name
2983
- };
2984
3315
  }
2985
- writeWorkersSection(raw, workers);
2986
- return workers;
2987
- }
2988
- function removeProvider(name) {
2989
- const { raw, workers } = readWorkersSection();
2990
- if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
2991
- delete workers.providers[name];
2992
- for (const [role, target] of Object.entries(workers.roles)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.roles[role];
2993
- if (workers.active === name) workers.active = null;
2994
- writeWorkersSection(raw, workers);
2995
- return workers;
2996
- }
2997
- function useProvider(name) {
2998
- const { raw, workers } = readWorkersSection();
2999
- if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3000
- workers.active = name;
3001
- writeWorkersSection(raw, workers);
3002
- return workers;
3003
- }
3004
- function setProviderEnabled(name, enabled) {
3005
- const { raw, workers } = readWorkersSection();
3006
- const p = workers.providers[name];
3007
- if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
3008
- p.enabled = enabled;
3009
- writeWorkersSection(raw, workers);
3010
- if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
3011
- return workers;
3012
- }
3013
- function setRole(role, target) {
3014
- const { raw, workers } = readWorkersSection();
3015
- const [name, alias] = target.split("/");
3016
- const p = workers.providers[name];
3017
- if (!p) throw new WorkersConfigError(`no provider named "${name}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3018
- if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose "default" and "fast"`);
3019
- if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${name}" has no fast model configured`);
3020
- workers.roles[role] = target;
3021
- writeWorkersSection(raw, workers);
3022
- return workers;
3023
- }
3024
- function unsetRole(role) {
3025
- const { raw, workers } = readWorkersSection();
3026
- if (!(role in workers.roles)) throw new WorkersConfigError(`no role named "${role}"`);
3027
- delete workers.roles[role];
3028
- writeWorkersSection(raw, workers);
3029
- return workers;
3030
- }
3031
- function setWorkersEnabled(enabled) {
3032
- const { raw, workers } = readWorkersSection();
3033
- workers.enabled = enabled;
3034
- writeWorkersSection(raw, workers);
3035
- return workers;
3036
- }
3037
- /** Human-readable provider listing (quota probe included when configured). */
3038
- function describeProviders(workers) {
3039
- const lines = [];
3040
- const names = Object.keys(workers.providers);
3041
- if (!names.length) {
3042
- lines.push(`no providers configured. Add one with:`);
3043
- lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
3044
- return lines;
3045
- }
3046
- for (const name of names) {
3047
- const p = workers.providers[name];
3048
- const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
3049
- const quota = p.quotaProbe ? probeQuota(p) : null;
3050
- const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
3051
- lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
3052
- lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
3053
- if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
3054
- else lines.push(` no key file (token "local")`);
3055
- if (p.note) lines.push(` ${p.note}`);
3056
- if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
3057
- if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
3058
- }
3059
- const setNames = Object.keys(workers.mcpSets);
3060
- if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
3061
- if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
3062
- return lines;
3316
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
3317
+ chain: chainId,
3318
+ rc: 0
3319
+ });
3320
+ return 0;
3063
3321
  }
3064
3322
 
3065
3323
  //#endregion
3066
- export { readJsonStrict as A, loadStatus as C, workersLogDir as D, ledgerPath as E, WorkersConfigError as O, openPaneForWorker as S, eventsPath as T, stopProxy as _, setRole as a, checkPaneForWorker as b, useProvider as c, replayOutput as d, statusLineOutput as f, ensureProxyRunning as g, DEFAULT_PROXY_PORT as h, setProviderEnabled as i, writeJsonAtomic as j, readWorkersSection as k, followWorkers as l, testProvider as m, describeProviders as n, setWorkersEnabled as o, runWorker as p, removeProvider as r, unsetRole as s, addProvider as t, psOutput as u, sayToWorker as v, ledgerSummary as w, openFollowPane as x, describeMcp as y };
3067
- //# sourceMappingURL=providers-sXcK5bDZ.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