@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
@@ -0,0 +1,1405 @@
1
+ import { A as contextPercent, B as workersLogDir, C as quotaSkipThreshold, D as workerInScope, E as sessionTag, G as keysDir, H as PROVIDER_TAGS, J as readWorkersSection, K as parseWorkersConfig, L as shortText, M as loadStatuses, O as ageOf, R as eventsPath, S as probeQuota, T as resolveSession, U as WorkersConfigError, V as DEFAULT_LOG_DIR, W as expandHome, Y as writeWorkersSection, _ as workerDepth, d as renderReport, g as sayToWorker, h as readInbox, k as alive, q as providerCostTier, u as parseWorkerReport, w as currentTabKey, x as clearCooldown } from "./chain-CTtHligI.mjs";
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
3
+ import { basename, relative } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { createInterface } from "node:readline";
6
+
7
+ //#region src/workers/chatui.ts
8
+ /** Dim hint shown once behind the cursor until the first line is typed. */
9
+ const CHAT_HINT = "type here and press Enter · /help for commands";
10
+ /** What `/help` prints (one command per line, dim). */
11
+ const CHAT_HELP = [
12
+ "/quit close this pane",
13
+ "/resume <text> continue the finished worker with <text>",
14
+ "/status one-line worker status",
15
+ "anything else is sent to the worker — said while it runs, resumed after"
16
+ ];
17
+ /** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
18
+ function endOfEscape(s, i) {
19
+ const n = s[i + 1];
20
+ if (n === "[") {
21
+ let j = i + 2;
22
+ while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
23
+ return Math.min(s.length, j + 1);
24
+ }
25
+ if (n === "]") {
26
+ let j = i + 2;
27
+ while (j < s.length && s[j] !== "\x07") j++;
28
+ return Math.min(s.length, j + 1);
29
+ }
30
+ return i + 2;
31
+ }
32
+ /** Printable columns of `s` — ANSI escape sequences measure zero. */
33
+ function visibleWidth(s) {
34
+ let w = 0;
35
+ let i = 0;
36
+ while (i < s.length) {
37
+ if (s[i] === "\x1B") {
38
+ i = endOfEscape(s, i);
39
+ continue;
40
+ }
41
+ w += 1;
42
+ i += 1;
43
+ }
44
+ return w;
45
+ }
46
+ /**
47
+ * The SGR sequences in effect at `upto`: everything opened since the last
48
+ * reset, in order. Anything that is not an SGR escape is ignored (it does
49
+ * not change colour state).
50
+ */
51
+ function sgrStateAt(text, upto) {
52
+ const open = [];
53
+ let i = 0;
54
+ while (i < Math.min(upto, text.length)) {
55
+ if (text[i] === "\x1B") {
56
+ const end = endOfEscape(text, i);
57
+ const esc = text.slice(i, end);
58
+ if (/^\x1b\[[0-9;]*m$/.test(esc)) {
59
+ const params = esc.slice(2, -1);
60
+ if (params === "" || params.split(";").includes("0")) open.length = 0;
61
+ if (!(params === "" || params === "0")) open.push(esc);
62
+ }
63
+ i = end;
64
+ continue;
65
+ }
66
+ i += 1;
67
+ }
68
+ return open;
69
+ }
70
+ /**
71
+ * Wrap one rendered row to `width` printable columns. Breaks on whitespace
72
+ * where possible, hard-wraps words longer than the width, never splits an
73
+ * ANSI escape, and re-opens the colours it wraps inside of, so a diff row
74
+ * keeps its `-`/`+` colour on every continuation row.
75
+ */
76
+ function wrapText(text, width) {
77
+ if (width < 1 || visibleWidth(text) <= width) return [text];
78
+ const chars = [];
79
+ for (let i = 0; i < text.length; i++) {
80
+ if (text[i] === "\x1B") {
81
+ i = endOfEscape(text, i) - 1;
82
+ continue;
83
+ }
84
+ chars.push(i);
85
+ }
86
+ const words = [];
87
+ {
88
+ let s = -1;
89
+ let w = 0;
90
+ for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
91
+ if (s >= 0) {
92
+ words.push({
93
+ s,
94
+ e: k - 1,
95
+ w
96
+ });
97
+ s = -1;
98
+ w = 0;
99
+ }
100
+ } else {
101
+ if (s < 0) s = k;
102
+ w++;
103
+ }
104
+ }
105
+ if (words.length && words[0].s > 0) words[0] = {
106
+ s: 0,
107
+ e: words[0].e,
108
+ w: words[0].w + words[0].s
109
+ };
110
+ const spans = [];
111
+ let cs = -1;
112
+ let ce = -1;
113
+ for (const word of words) {
114
+ if (word.w > width) {
115
+ const room = cs < 0 ? 0 : width - (ce - cs + 1);
116
+ const take = room > 0 ? ce + room - word.s + 1 : 0;
117
+ if (take > 0) spans.push([cs, ce + room]);
118
+ else if (cs >= 0) spans.push([cs, ce]);
119
+ let pos = word.s + Math.max(0, take);
120
+ let remaining = word.w - Math.max(0, take);
121
+ while (remaining > width) {
122
+ spans.push([pos, pos + width - 1]);
123
+ pos += width;
124
+ remaining -= width;
125
+ }
126
+ cs = pos;
127
+ ce = pos + remaining - 1;
128
+ continue;
129
+ }
130
+ if (cs < 0) {
131
+ cs = word.s;
132
+ ce = word.e;
133
+ continue;
134
+ }
135
+ if (word.e - cs + 1 <= width) {
136
+ ce = word.e;
137
+ continue;
138
+ }
139
+ spans.push([cs, ce]);
140
+ cs = word.s;
141
+ ce = word.e;
142
+ }
143
+ if (cs >= 0) spans.push([cs, ce]);
144
+ const out = [];
145
+ for (const [a, b] of spans) {
146
+ const from = chars[a];
147
+ let end = chars[b] + 1;
148
+ while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
149
+ let piece = text.slice(from, end);
150
+ const reopen = sgrStateAt(text, from);
151
+ if (reopen.length) piece = reopen.join("") + piece;
152
+ if (sgrStateAt(text, end).length) piece += "\x1B[0m";
153
+ out.push(piece);
154
+ }
155
+ return out.length ? out : [""];
156
+ }
157
+ /** Restrict scrolling to the transcript region (rows 1 … rows-3). */
158
+ function chatScrollRegion(rows) {
159
+ return `\x1b[1;${Math.max(1, rows - 3)}r`;
160
+ }
161
+ /** Clear the blank separator row between the transcript and the prompt. */
162
+ function chatBlankRow(rows) {
163
+ return `\x1b[${Math.max(1, rows - 2)};1H\x1b[K`;
164
+ }
165
+ /**
166
+ * Enter the chat layout: clear the pane, set the scroll region, blank the
167
+ * separator row and park the cursor at column 3 of the prompt row (rows-1,
168
+ * right after `› `). The ticker owns row `rows`.
169
+ */
170
+ function chatEnter(rows) {
171
+ return "\x1B[2J" + chatScrollRegion(rows) + chatBlankRow(rows) + `\x1b[${Math.max(1, rows - 1)};3H`;
172
+ }
173
+ /** Leave it: reset the scroll region, show the cursor, drop to the last row. */
174
+ function chatLeave(rows) {
175
+ return `[?25h\x1b[${Math.max(1, rows)};1H`;
176
+ }
177
+ /**
178
+ * Redraw the ticker on its own row and park the cursor back on the prompt
179
+ * row at `parkCol` (default right after `› `): a ticker refresh never leaves
180
+ * the cursor on the bottom row.
181
+ */
182
+ function chatTickerRow(text, rows, parkCol = 3) {
183
+ return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + `\x1b[${Math.max(1, rows - 1)};${Math.max(1, parkCol)}H`;
184
+ }
185
+ /**
186
+ * The prompt row: the fixed `› ` prefix, then the input buffer — or, while
187
+ * the buffer is empty, the dim placeholder behind the same prefix. The
188
+ * cursor parks right after the buffer (`cursorAt` chars in, for a cursor
189
+ * mid-draft), so typed text always starts at the column the placeholder
190
+ * occupied, never glued to a hint.
191
+ */
192
+ function chatPromptRow(rows, buffer = "", dim = (s) => s, cursorAt) {
193
+ const rowN = Math.max(1, rows - 1);
194
+ const body = buffer !== "" ? buffer : dim(CHAT_HINT);
195
+ const col = 3 + Math.max(0, Math.min(cursorAt ?? buffer.length, buffer.length));
196
+ return `\x1b[${rowN};1H\x1b[K› ` + body + `\x1b[${rowN};${col}H`;
197
+ }
198
+ /**
199
+ * Insert one transcript row above the fixed prompt/ticker rows. While the
200
+ * region is still filling (`fill < regionRows`) the row is placed top-down;
201
+ * once full, the cursor moves to the region's bottom row and a newline
202
+ * scrolls the region up by one — the two fixed rows are never touched. The
203
+ * user's cursor is saved before and restored after, so readline keeps its
204
+ * position on the prompt row.
205
+ */
206
+ function chatInsertLine(line, fill, regionRows) {
207
+ return {
208
+ seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
209
+ fill: Math.min(fill + 1, regionRows)
210
+ };
211
+ }
212
+ /**
213
+ * One submitted prompt line → what to do with it. `/help`, `/quit`,
214
+ * `/status` and `/resume <text>` are commands (a bare `/resume` comes back
215
+ * with empty text so the caller can print its usage); anything else,
216
+ * including any other `/word`, is a message for the worker.
217
+ */
218
+ function parseChatLine(raw) {
219
+ const text = raw.trim();
220
+ if (text === "/help") return { kind: "help" };
221
+ if (text === "/quit") return { kind: "quit" };
222
+ if (text === "/status") return { kind: "status" };
223
+ if (text.startsWith("/resume")) return {
224
+ kind: "resume",
225
+ text: text.slice(7).trim()
226
+ };
227
+ return {
228
+ kind: "message",
229
+ text
230
+ };
231
+ }
232
+ /**
233
+ * The auto-exit countdown must not fire while the prompt holds unsent text:
234
+ * true while it does. null/undefined (no prompt wired) never holds.
235
+ */
236
+ function holdAutoExit(promptText) {
237
+ return typeof promptText === "string" && promptText.trim() !== "";
238
+ }
239
+
240
+ //#endregion
241
+ //#region src/workers/render.ts
242
+ /**
243
+ * render.ts — turn worker events into the lines a human reads.
244
+ *
245
+ * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
246
+ * and searches, `$` for shell, magenta `~` + red/green diff for edits, green
247
+ * `+` for writes), Read-result trimming (first 3 lines then a count), and the
248
+ * result footer. Colors are applied only when the output is a TTY, so MCP
249
+ * tool output stays plain text.
250
+ */
251
+ const CODES = {
252
+ dim: "2",
253
+ bold: "1",
254
+ red: "31",
255
+ green: "32",
256
+ yellow: "33",
257
+ blue: "34",
258
+ mag: "35",
259
+ cyan: "36"
260
+ };
261
+ function makeColor(enabled) {
262
+ return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
263
+ }
264
+ /** Path relative to cwd when it lies inside it, otherwise unchanged. */
265
+ function relPath(path, cwd) {
266
+ if (!path || !cwd) return path;
267
+ let r;
268
+ try {
269
+ r = relative(cwd, path);
270
+ } catch {
271
+ return path;
272
+ }
273
+ return r.startsWith("..") ? path : r;
274
+ }
275
+ /** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
276
+ function unifiedDiffLines(oldStr, newStr) {
277
+ const oldL = oldStr.split("\n");
278
+ const newL = newStr.split("\n");
279
+ let start = 0;
280
+ while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
281
+ let endOld = oldL.length;
282
+ let endNew = newL.length;
283
+ while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
284
+ endOld--;
285
+ endNew--;
286
+ }
287
+ const out = [];
288
+ for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
289
+ for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
290
+ return out;
291
+ }
292
+ /**
293
+ * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
294
+ * negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
295
+ * null when the stamp cannot be parsed. Offsets make this testable without
296
+ * depending on the machine's zone; the default is this machine's.
297
+ */
298
+ function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
299
+ const t = Date.parse(ts);
300
+ if (Number.isNaN(t)) return null;
301
+ return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
302
+ }
303
+ /** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
304
+ function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
305
+ const t = Date.parse(ts);
306
+ if (Number.isNaN(t)) return null;
307
+ return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
308
+ }
309
+ /**
310
+ * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
311
+ * the worker tag in front when several run at once. Continuation lines get
312
+ * blanks of the same width so wrapped text stays aligned; when the viewer
313
+ * wraps lines itself, continuation rows carry the `│` bar instead (barCont)
314
+ * so the bar runs unbroken down the pane. null when the event carries no
315
+ * stamp (logs from before 2g render with the plain prefix). The time is the
316
+ * stamp's wall clock at `offMin` — the default renders local time, whatever
317
+ * zone stamped the log (old logs were stamped in UTC).
318
+ */
319
+ function gutterFor(c, e, tag, offMin) {
320
+ if (!e._ts) return null;
321
+ const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
322
+ const head = tag ? `${tag} ${time}` : time;
323
+ const width = head.length + 3;
324
+ return {
325
+ first: c("dim", `${head} │ `),
326
+ cont: " ".repeat(width),
327
+ barCont: c("dim", `${" ".repeat(head.length)} │ `),
328
+ width
329
+ };
330
+ }
331
+ /**
332
+ * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
333
+ * basename for the file tools, the bare name for everything else.
334
+ */
335
+ function tickerTool(name, inp) {
336
+ const i = typeof inp === "object" && inp !== null ? inp : {};
337
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
338
+ if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
339
+ if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
340
+ return name;
341
+ }
342
+ /**
343
+ * The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
344
+ * seconds since the last *rendered* event, the worker's last stated intent
345
+ * (its last assistant text, ≤60 chars) and the tool it is currently running.
346
+ * Parts that are empty drop out.
347
+ */
348
+ function tickerText(secs, intent, tool, meter) {
349
+ const head = `⋯ ${secs}s`;
350
+ const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
351
+ const line = tail ? `${head} · ${tail}` : head;
352
+ return meter ? `${line} · ${meter}` : line;
353
+ }
354
+ /** `4m12s` — the runtime shape the status row shows. */
355
+ function fmtElapsed(secs) {
356
+ const s = Math.max(0, Math.floor(secs));
357
+ return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
358
+ }
359
+ /**
360
+ * The chat pane's status row (always visible while following):
361
+ * `[glm/glm-5.3] ctx 84k/200k 42% · turns 12 · tools 7 · 4m12s · ⋯ 7s · <intent> · <tool>`
362
+ * — context yellow past 70 %, red past 85 %, dropped when unknown; once the
363
+ * worker finished, the ticker part is replaced by `✓ done` / `✗ failed`.
364
+ */
365
+ function chatStatusRow(c, s) {
366
+ const parts = [];
367
+ const pct = contextPercent(s);
368
+ if (pct !== null) {
369
+ const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} ${pct}%`;
370
+ parts.push(pct > 85 ? c("red", label) : pct > 70 ? c("yellow", label) : label);
371
+ }
372
+ parts.push(`turns ${s.turns}`, `tools ${s.tools}`, fmtElapsed(s.elapsed));
373
+ if (s.state && s.state !== "running") parts.push(s.state === "done" ? c("green", "✓ done") : c("red", "✗ failed"));
374
+ else {
375
+ parts.push(`⋯ ${Math.max(0, Math.floor(s.idle ?? 0))}s`);
376
+ for (const p of [s.intent, s.tool]) if (p && p.trim()) parts.push(p.trim());
377
+ }
378
+ return `[${s.provider}/${s.model}] ${parts.join(" · ")}`;
379
+ }
380
+ /**
381
+ * One blank line between turns, none inside one: a blank goes before an
382
+ * assistant message that follows a tool result or an operator message (the
383
+ * worker starting to speak again after its tools were answered / it was told
384
+ * something), not between the text, tool calls and results of one turn.
385
+ */
386
+ function blankBetween(prev, e) {
387
+ if (!prev) return false;
388
+ if (e.type !== "assistant") return false;
389
+ return prev.type === "user" || prev.type === "operator" || prev.type === "handoff";
390
+ }
391
+ /** The worker's last stated intent: the first line of its last text, ≤60. */
392
+ function intentOf(text) {
393
+ return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
394
+ }
395
+ /** Compact token count: 84k, 200k, 900. */
396
+ function fmtK(n) {
397
+ return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
398
+ }
399
+ /**
400
+ * The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.
401
+ * null when the numbers are missing or below `minPct` (the table only shows
402
+ * it past 60; the pane liveness line always shows it).
403
+ */
404
+ function contextMeter(c, s, minPct = 0) {
405
+ const pct = contextPercent(s);
406
+ if (pct === null || pct <= minPct) return null;
407
+ const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;
408
+ if (pct > 85) return c("red", label);
409
+ if (pct > 70) return c("yellow", label);
410
+ return label;
411
+ }
412
+ function renderToolUse(c, prefix, name, inp, cwd) {
413
+ const i = typeof inp === "object" && inp !== null ? inp : {};
414
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
415
+ if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
416
+ if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
417
+ if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
418
+ if (name === "Edit") {
419
+ const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
420
+ for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
421
+ else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
422
+ else out.push(`${prefix} ${c("dim", line)}`);
423
+ return out;
424
+ }
425
+ if (name === "Write") {
426
+ const n = get("content").split("\n").length;
427
+ return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
428
+ }
429
+ if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
430
+ return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
431
+ }
432
+ /** Render one stream-json event; `tools` maps tool_use_id → tool name. */
433
+ function renderEvent(c, prefix, e, cwd, tools, gutter) {
434
+ const out = [];
435
+ if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
436
+ else if (e.type === "operator") {
437
+ if (e.handoff) return out;
438
+ for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
439
+ } else if (e.type === "handoff") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("mag", `◆ from ${e.from ?? "?"} · ${e.kind ?? "?"}: ${ln}`)}`);
440
+ else if (e.type === "assistant") {
441
+ 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}`);
442
+ else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
443
+ } else if (e.type === "user") for (const b of e.message?.content ?? []) {
444
+ if (b.type !== "tool_result") continue;
445
+ let content = b.content ?? "";
446
+ if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
447
+ const text = String(content);
448
+ const isError = b.is_error === true;
449
+ if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
450
+ const lines = text.split("\n");
451
+ for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
452
+ if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
453
+ } else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
454
+ else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
455
+ }
456
+ else if (e.type === "result") {
457
+ const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
458
+ out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
459
+ const report = parseWorkerReport(String(e.result ?? ""));
460
+ if (report) out.push(...renderReport(c, prefix, report, cwd));
461
+ else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
462
+ }
463
+ if (!gutter) return out;
464
+ return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
465
+ }
466
+ /** Transcript header: id, provider, label, session name, project dir. */
467
+ function headerLine(c, s) {
468
+ const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
469
+ const sep = bits ? ` ${bits}` : "";
470
+ return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
471
+ }
472
+ /** The chain label behind a stage label: strip the trailing " · <stage>". */
473
+ function chainLabelOf(stages) {
474
+ const first = stages[0];
475
+ if (!first) return "";
476
+ const suffix = first.stage ? ` · ${first.stage}` : "";
477
+ return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
478
+ }
479
+ /**
480
+ * The ps table (RUNNING + FINISHED last 8) as a forest: a worker whose parent
481
+ * is another worker renders indented under it (`├`/`└` connectors); a parent
482
+ * that is not a worker is a chain id and gets its old `chain <id>` header.
483
+ * `⎇` marks an unmerged worktree branch, `◆N` an inbox with N handoffs.
484
+ */
485
+ function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date(), inbox = {}) {
486
+ const running = [];
487
+ const done = [];
488
+ for (const s of statuses) if (s.state === "running" && alive(s.pid)) running.push(s);
489
+ else {
490
+ if (s.state === "running") s.state = "lost";
491
+ done.push(s);
492
+ }
493
+ const isWorker = (id) => statuses.some((s) => s.id === id);
494
+ const chainOf = (s) => s.parent && !isWorker(s.parent) ? s.parent : null;
495
+ const treeLine = (line, chain, last) => {
496
+ if (chain === null) return line;
497
+ const mark = last ? "└" : "├";
498
+ const bar = last ? " " : "│";
499
+ return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
500
+ };
501
+ const rowPrefix = (depth, last) => depth <= 0 ? " " : " " + "│ ".repeat(depth - 1) + (last ? "└ " : "├ ");
502
+ const rowCont = (depth, last) => depth <= 0 ? " " : " " + "│ ".repeat(depth - 1) + (last ? " " : "│ ");
503
+ const branchMark = (s) => s.branch && !s.merged ? " " + c("yellow", "⎇" + (s.commits ? String(s.commits) : "")) : "";
504
+ const inboxMark = (s) => inbox[s.id] ? " " + c("mag", `◆${inbox[s.id]}`) : "";
505
+ const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
506
+ lines.push(c("bold", `RUNNING (${running.length})`));
507
+ if (!running.length) lines.push(" none");
508
+ for (let i = 0; i < running.length; i++) {
509
+ const s = running[i];
510
+ const chain = chainOf(s);
511
+ if (chain) {
512
+ const prev = running[i - 1];
513
+ if (!prev || chainOf(prev) !== chain) {
514
+ const stages = running.filter((x) => chainOf(x) === chain);
515
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
516
+ }
517
+ }
518
+ const last = !!chain && (i + 1 >= running.length || chainOf(running[i + 1]) !== chain);
519
+ const depth = workerDepth(statuses, s.id);
520
+ const subLast = !chain && (i + 1 >= running.length || running[i + 1].parent !== s.parent);
521
+ const meter = contextMeter(c, s, 60);
522
+ const p = chain ? " " : rowPrefix(depth, subLast);
523
+ const q = chain ? " " : rowCont(depth, subLast);
524
+ lines.push(treeLine(`${p}${c("cyan", s.id)}${inboxMark(s)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${branchMark(s)}${meter ? " " + meter : ""}`, chain, last));
525
+ lines.push(treeLine(`${q}task: ${s.label}`, chain, last));
526
+ lines.push(treeLine(`${q}now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
527
+ }
528
+ lines.push("");
529
+ lines.push(c("bold", "FINISHED (last 8)"));
530
+ const doneSlice = done.slice(-8);
531
+ for (let i = 0; i < doneSlice.length; i++) {
532
+ const s = doneSlice[i];
533
+ const chain = chainOf(s);
534
+ if (chain) {
535
+ const prev = doneSlice[i - 1];
536
+ if (!prev || chainOf(prev) !== chain) {
537
+ const stages = doneSlice.filter((x) => chainOf(x) === chain);
538
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
539
+ }
540
+ }
541
+ const last = !!chain && (i + 1 >= doneSlice.length || chainOf(doneSlice[i + 1]) !== chain);
542
+ const depth = workerDepth(statuses, s.id);
543
+ const subLast = !chain && (i + 1 >= doneSlice.length || doneSlice[i + 1].parent !== s.parent);
544
+ const col = s.state === "done" ? "green" : "red";
545
+ const tag = sessionTag(s);
546
+ lines.push(treeLine(`${chain ? " " : rowPrefix(depth, subLast)}${s.id}${inboxMark(s)} [${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)}${branchMark(s)} ${s.label}`, chain, last));
547
+ }
548
+ lines.push("");
549
+ lines.push(c("dim", "worker follow live transcript of running workers"));
550
+ lines.push(c("dim", "worker <id> replay one worker"));
551
+ lines.push(c("dim", scopeLabel));
552
+ return lines.join("\n");
553
+ }
554
+ /**
555
+ * One-line status-bar summary (same shape glm-ps --status printed). Sub-workers
556
+ * render after their parent with a `↳` prefix (`all` carries the whole forest
557
+ * so depth is right even when only some workers are this session's);
558
+ * `◆N` marks an inbox with N handoffs.
559
+ */
560
+ function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(true), all = mine, inbox = {}) {
561
+ if (!mine.length) return "";
562
+ const running = mine.filter((s) => s.state === "running" && alive(s.pid)).sort((a, b) => workerDepth(all, a.id) - workerDepth(all, b.id));
563
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
564
+ const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
565
+ const ok = doneToday.filter((s) => s.state === "done").length;
566
+ const bad = doneToday.length - ok;
567
+ const providers = new Set(running.map((s) => s.provider));
568
+ let head = `${providers.size === 1 ? [...providers][0] : "workers"} ▶${running.length}`;
569
+ const parts = running.slice(0, 3).map((s) => {
570
+ const meter = contextMeter(c, s, 60);
571
+ const depth = workerDepth(all, s.id);
572
+ const lead = depth > 0 ? " ".repeat(depth - 1) + "↳ " : "";
573
+ const box = inbox[s.id] ? ` ◆${inbox[s.id]}` : "";
574
+ return `${lead}${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}${box}` + (meter ? ` ${meter}` : "");
575
+ });
576
+ if (parts.length) head += " " + parts.join(" | ");
577
+ if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
578
+ return head;
579
+ }
580
+
581
+ //#endregion
582
+ //#region src/workers/viewer.ts
583
+ /**
584
+ * viewer.ts — ps / follow / replay / status line over the worker logDir.
585
+ *
586
+ * Scoping: workers launched from this terminal's AIBroker session (or, as the
587
+ * fallback, its iTerm tab) unless --all or an explicit worker id is given.
588
+ * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
589
+ *
590
+ * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
591
+ * `_ts` stamp on every mirrored event (local wall clock — stamps carry a local
592
+ * offset and old UTC stamps are converted), the worker tag in front when
593
+ * several run at once, a date separator when the day changes, and — on a TTY —
594
+ * a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
595
+ * is rewritten in place between events. Attaching to a worker that is already
596
+ * running first replays its last events (backfill), then continues live.
597
+ *
598
+ * Following one worker turns the pane into a small chat (see chatui.ts): the
599
+ * transcript scrolls in a region that ends two rows above the bottom, the
600
+ * prompt row (`› `, readline editing) and the ticker row stay fixed, and every
601
+ * submitted line is said to the worker while it runs and resumes it (same
602
+ * Claude session) once it has finished. Lines the pane wraps itself keep the
603
+ * `│` bar on continuation rows, so no content ever lands left of the bar.
604
+ * Non-TTY output keeps the plain scrolling behaviour.
605
+ */
606
+ /** How many existing events a fresh follow pane replays before going live. */
607
+ const BACKFILL_EVENTS = 200;
608
+ function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
609
+ const c = makeColor(color);
610
+ const term = env.ITERM_SESSION_ID ?? "";
611
+ const statuses = loadStatuses(logDir);
612
+ const scoped = showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term));
613
+ const scopeLabel = showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`;
614
+ const inbox = inboxCounts(logDir, scoped);
615
+ return renderTable(c, scoped, scopeLabel, /* @__PURE__ */ new Date(), inbox);
616
+ }
617
+ /** Handoffs waiting in each listed worker's inbox: id → count. */
618
+ function inboxCounts(logDir, statuses) {
619
+ const out = {};
620
+ for (const s of statuses) {
621
+ const n = readInbox(logDir, s.id).length;
622
+ if (n) out[s.id] = n;
623
+ }
624
+ return out;
625
+ }
626
+ function initialFollowState(intent = "waiting for first event") {
627
+ return {
628
+ lastDay: "",
629
+ tools: {},
630
+ intent,
631
+ tool: "",
632
+ prev: null
633
+ };
634
+ }
635
+ /**
636
+ * Gutter the rendered body of one event, wrapping when the pane width is
637
+ * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
638
+ * the stamped gutter, later rows of the event blanks — exactly the pre-chat
639
+ * rendering. Wrapped: every row is folded at `wrapWidth` columns and each
640
+ * continuation row carries the blank gutter with the `│` bar, so the bar runs
641
+ * unbroken down the pane and no content ever lands left of it.
642
+ */
643
+ function gutterBody(body, gutter, wrapWidth) {
644
+ if (!gutter) return body;
645
+ if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
646
+ const out = [];
647
+ for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
648
+ return out;
649
+ }
650
+ /**
651
+ * Render one event and advance the follow state. Everything the viewer shows
652
+ * between events of one worker comes from here: replay, the backfill on
653
+ * attach and the live tail all use it, so they space identically — events
654
+ * back to back, one blank line between turns. `activity` is false for events
655
+ * that render nothing (stream noise, empty tool results): they leave the
656
+ * ticker's "since last event" clock running. `wrapWidth` (the pane's column
657
+ * count, re-read on resize) makes the viewer wrap rows itself; null keeps
658
+ * the terminal's own wrapping.
659
+ */
660
+ function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
661
+ const tools = { ...s.tools };
662
+ if (e.type === "assistant") {
663
+ for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
664
+ }
665
+ const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
666
+ const state = {
667
+ lastDay: day && day !== s.lastDay ? day : s.lastDay,
668
+ tools,
669
+ intent: s.intent,
670
+ tool: s.tool,
671
+ prev: e
672
+ };
673
+ if (e.type === "assistant") {
674
+ for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
675
+ else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
676
+ }
677
+ const gutter = gutterFor(c, e, tag, offMin);
678
+ const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
679
+ const lines = blankBetween(s.prev, e) ? ["", ...body] : body;
680
+ return {
681
+ lines,
682
+ day: day && day !== s.lastDay ? day : null,
683
+ activity: lines.some((ln) => ln !== ""),
684
+ state
685
+ };
686
+ }
687
+ /** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
688
+ function rawDay(ts) {
689
+ return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
690
+ }
691
+ /**
692
+ * The events a fresh follow replays before going live: the last `cap`
693
+ * non-empty log lines, oldest first.
694
+ */
695
+ function backfillLines(raw, cap = BACKFILL_EVENTS) {
696
+ return raw.split("\n").filter((l) => l.trim()).slice(-cap);
697
+ }
698
+ /** The rendered transcript of one worker, from the start, as one string. */
699
+ function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
700
+ const path = eventsPath(logDir, wid);
701
+ if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
702
+ const c = makeColor(color);
703
+ const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
704
+ id: wid,
705
+ label: "",
706
+ cwd: "",
707
+ provider: ""
708
+ };
709
+ const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
710
+ const out = [headerLine(c, st)];
711
+ const raw = readFileSync(path, "utf8");
712
+ const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
713
+ const handoffs = readInbox(logDir, wid).map((h) => ({
714
+ type: "handoff",
715
+ from: h.from,
716
+ kind: h.kind,
717
+ text: h.text,
718
+ _ts: h._ts
719
+ }));
720
+ let state = initialFollowState("");
721
+ const events = [];
722
+ for (const line of lines) {
723
+ if (!line.trim()) continue;
724
+ let e;
725
+ try {
726
+ e = JSON.parse(line);
727
+ } catch {
728
+ continue;
729
+ }
730
+ events.push(e);
731
+ }
732
+ const stamp = (e) => {
733
+ const t = e._ts ? Date.parse(e._ts) : NaN;
734
+ return Number.isNaN(t) ? 0 : t;
735
+ };
736
+ const merged = [...events, ...handoffs].sort((a, b) => stamp(a) - stamp(b));
737
+ for (const e of merged) {
738
+ const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
739
+ if (step.day) out.push(c("dim", `── ${step.day} ──`));
740
+ out.push(...step.lines);
741
+ state = step.state;
742
+ }
743
+ return out.join("\n");
744
+ }
745
+ /**
746
+ * The follow exit decision for one worker: it is over once its result event
747
+ * was rendered, or once its status left "running" while its pid is gone — a
748
+ * worker killed without writing a result still ends. A status never read
749
+ * (undefined) means "not over": follow keeps waiting for the first event.
750
+ */
751
+ function workerEnded(resultRendered, state, pidAlive) {
752
+ return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
753
+ }
754
+ /**
755
+ * One typed stdin line → say (worker running) or resume (worker finished):
756
+ * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
757
+ * missing target are ignored.
758
+ */
759
+ function makeOperatorInput(d) {
760
+ return (raw) => {
761
+ const text = raw.trim();
762
+ if (!text) return;
763
+ const id = d.target();
764
+ if (id === null) return;
765
+ d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
766
+ if (d.workerKnown(id)) d.resume(text, id);
767
+ else d.note(d.paint("red", `» ${e.message}`));
768
+ });
769
+ };
770
+ }
771
+ /**
772
+ * Tail one worker (target) or the running workers of this scope, live.
773
+ * Workers whose event log already exists are first replayed (last
774
+ * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
775
+ * `autoExit` seconds after its end; without one, exit once no worker in
776
+ * scope has run for that many seconds in a row, never within the first 30 s.
777
+ *
778
+ * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
779
+ * a scroll region, fixed prompt and ticker rows, submitted lines said to the
780
+ * worker (or resuming it after it finished) and echoed as `»` rows. A draft
781
+ * in the prompt holds the auto-exit countdown. Non-TTY output keeps the
782
+ * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
783
+ */
784
+ async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
785
+ const c = makeColor(color);
786
+ const out_ = io?.stdout ?? process.stdout;
787
+ const in_ = io?.stdin ?? process.stdin;
788
+ const tty = out_.isTTY === true || env.FORCE_TTY === "1";
789
+ const term = env.ITERM_SESSION_ID ?? "";
790
+ const scopeTab = target || showAll ? "" : currentTabKey(env);
791
+ const handles = /* @__PURE__ */ new Map();
792
+ const seenHeader = /* @__PURE__ */ new Set();
793
+ const finished = /* @__PURE__ */ new Set();
794
+ const inboxSeen = /* @__PURE__ */ new Map();
795
+ const states = /* @__PURE__ */ new Map();
796
+ const started = Date.now();
797
+ let idleSince = null;
798
+ let aborted = false;
799
+ let lastEventAt = Date.now();
800
+ let meterStatus = null;
801
+ const onInt = () => {
802
+ aborted = true;
803
+ };
804
+ process.once("SIGINT", onInt);
805
+ const chat = tty && target !== null;
806
+ let rows = out_.rows ?? 24;
807
+ const columns = () => typeof out_.columns === "number" ? out_.columns : null;
808
+ let fill = 0;
809
+ const regionRows = () => Math.max(1, rows - 3);
810
+ /** Every line the pane shows goes through here: plain newline, or a row
811
+ * inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */
812
+ const out = (line) => {
813
+ if (!chat) {
814
+ out_.write(line + "\n");
815
+ return;
816
+ }
817
+ const r = chatInsertLine(line, fill, regionRows());
818
+ out_.write(r.seq);
819
+ fill = r.fill;
820
+ };
821
+ const eraseLiveness = () => {
822
+ if (chat) return;
823
+ if (tty) out_.write("\r\x1B[K");
824
+ };
825
+ let ticker = initialFollowState();
826
+ const statusRowOf = (st, secs) => {
827
+ const fin = finished.has(st.id);
828
+ const startedAt = Date.parse((st.started ?? "").replace(" ", "T"));
829
+ const elapsed = fin && st.secs !== null ? st.secs : Number.isNaN(startedAt) ? 0 : Math.floor((Date.now() - startedAt) / 1e3);
830
+ return {
831
+ provider: st.provider,
832
+ model: st.model,
833
+ contextTokens: st.contextTokens,
834
+ contextWindow: st.contextWindow,
835
+ turns: st.turns,
836
+ tools: st.tools,
837
+ elapsed,
838
+ idle: secs,
839
+ intent: ticker.intent,
840
+ tool: ticker.tool,
841
+ state: fin ? st.state === "running" ? "done" : st.state : null
842
+ };
843
+ };
844
+ const writeLiveness = () => {
845
+ if (!tty) return;
846
+ const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
847
+ if (chat) {
848
+ const text = meterStatus ? chatStatusRow(c, statusRowOf(meterStatus, secs)) : tickerText(secs, ticker.intent, ticker.tool);
849
+ out_.write(chatTickerRow(text, rows, promptCursorCol()));
850
+ } else {
851
+ const meter = meterStatus ? contextMeter(c, meterStatus) : null;
852
+ out_.write("\r\x1B[K");
853
+ out_.write(tickerText(secs, ticker.intent, ticker.tool, meter));
854
+ }
855
+ };
856
+ const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
857
+ const emitEvent = (e, wid, st, multi) => {
858
+ if (e.type === "operator" && chat && suppressMirror(String(e.text ?? ""))) return;
859
+ if (!seenHeader.has(wid)) {
860
+ eraseLiveness();
861
+ out(headerLine(c, st));
862
+ seenHeader.add(wid);
863
+ }
864
+ const step = applyEvent(c, states.get(wid) ?? initialFollowState(), e, st.cwd ?? "", multi ? c("cyan", wid.slice(-4)) : void 0, void 0, columns());
865
+ if (step.day) out(c("dim", `── ${step.day} ──`));
866
+ states.set(wid, step.state);
867
+ if (wid === target) ticker = step.state;
868
+ eraseLiveness();
869
+ for (const ln of step.lines) out(ln);
870
+ if (step.activity) lastEventAt = Date.now();
871
+ if (e.type === "result") {
872
+ meterStatus = st;
873
+ finished.add(wid);
874
+ } else if (wid === target || target === null) meterStatus = st;
875
+ };
876
+ const attachHandle = (wid, path, st, multi) => {
877
+ const handle = {
878
+ fd: openSync(path, "r"),
879
+ buf: ""
880
+ };
881
+ try {
882
+ const existing = readFileSync(path, "utf8");
883
+ if (existing.trim()) for (const line of backfillLines(existing)) {
884
+ let e;
885
+ try {
886
+ e = JSON.parse(line);
887
+ } catch {
888
+ continue;
889
+ }
890
+ emitEvent(e, wid, st, multi);
891
+ }
892
+ else if (!seenHeader.has(wid)) {
893
+ out(headerLine(c, st));
894
+ seenHeader.add(wid);
895
+ }
896
+ const sink = Buffer.alloc(65536);
897
+ for (;;) {
898
+ let n;
899
+ try {
900
+ n = readSync(handle.fd, sink, 0, sink.length, null);
901
+ } catch {
902
+ break;
903
+ }
904
+ if (n <= 0) break;
905
+ }
906
+ } catch {}
907
+ return handle;
908
+ };
909
+ const noteLine = (s) => {
910
+ eraseLiveness();
911
+ out(s);
912
+ };
913
+ const spawnResume = io?.spawnResume ?? ((id, text) => spawn("pai", [
914
+ "worker",
915
+ "resume",
916
+ id,
917
+ text,
918
+ "--print-id",
919
+ "--no-pane"
920
+ ], { stdio: [
921
+ "ignore",
922
+ "pipe",
923
+ "inherit"
924
+ ] }));
925
+ const resumeTarget = (text, id) => {
926
+ noteLine(c("dim", `» resuming ${id} …`));
927
+ const child = spawnResume(id, text);
928
+ let idOut = "";
929
+ child.stdout?.on("data", (chunk) => {
930
+ idOut += chunk.toString("utf8");
931
+ });
932
+ child.on("close", (rc) => {
933
+ const newId = idOut.trim().split("\n").pop() ?? "";
934
+ if (rc === 0 && /^\d{8}-\d{6}-\d+$/.test(newId)) {
935
+ noteLine(c("dim", `» resumed as ${newId}`));
936
+ target = newId;
937
+ finished.delete(newId);
938
+ seenHeader.delete(newId);
939
+ states.delete(newId);
940
+ ticker = initialFollowState("resumed");
941
+ lastEventAt = Date.now();
942
+ } else noteLine(c("red", `» resume failed (rc=${rc})`));
943
+ });
944
+ };
945
+ const handleOperatorLine = makeOperatorInput({
946
+ target: () => target,
947
+ say: (id, text) => sayToWorker(logDir, id, text),
948
+ workerKnown: (id) => loadStatuses(logDir).some((s) => s.id === id),
949
+ resume: resumeTarget,
950
+ note: noteLine,
951
+ paint: c,
952
+ ...chat ? { sent: () => void 0 } : {}
953
+ });
954
+ const terminalIn = in_.isTTY === true;
955
+ let rlIn = null;
956
+ let onResize = null;
957
+ const echoed = /* @__PURE__ */ new Map();
958
+ const suppressMirror = (text) => {
959
+ const g = echoed.get(text);
960
+ if (!g || Date.now() > g.until) return false;
961
+ g.n -= 1;
962
+ if (g.n <= 0) echoed.delete(text);
963
+ return true;
964
+ };
965
+ /** Column the prompt row's cursor parks at: right after the draft's cursor. */
966
+ const promptCursorCol = () => {
967
+ const line = rlIn?.line ?? "";
968
+ const cur = rlIn?.cursor;
969
+ return 3 + Math.max(0, Math.min(typeof cur === "number" ? cur : line.length, line.length));
970
+ };
971
+ /**
972
+ * The pane owns the prompt row's rendering: `› `, the buffer — or the dim
973
+ * placeholder while the buffer is empty — and the cursor parked after it.
974
+ */
975
+ const drawPrompt = () => {
976
+ if (!chat) return;
977
+ const line = rlIn?.line ?? "";
978
+ const cur = rlIn?.cursor;
979
+ out_.write(chatPromptRow(rows, line, (s) => c("dim", s), cur));
980
+ };
981
+ if (chat) {
982
+ const echoOperator = (text) => {
983
+ const id = target;
984
+ if (id === null) return;
985
+ echoed.set(text, {
986
+ n: 1,
987
+ until: Date.now() + 1e4
988
+ });
989
+ const step = applyEvent(c, states.get(id) ?? initialFollowState(), {
990
+ type: "operator",
991
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
992
+ text
993
+ }, "", void 0, void 0, columns());
994
+ if (step.day) out(c("dim", `── ${step.day} ──`));
995
+ for (const ln of step.lines) out(ln);
996
+ states.set(id, step.state);
997
+ };
998
+ const handleChatLine = (raw) => {
999
+ const act = parseChatLine(raw);
1000
+ let redrew = false;
1001
+ switch (act.kind) {
1002
+ case "message":
1003
+ if (!act.text) break;
1004
+ handleOperatorLine(act.text);
1005
+ drawPrompt();
1006
+ redrew = true;
1007
+ echoOperator(act.text);
1008
+ break;
1009
+ case "resume":
1010
+ if (!act.text) {
1011
+ out(c("dim", "usage: /resume <text>"));
1012
+ break;
1013
+ }
1014
+ if (target !== null) resumeTarget(act.text, target);
1015
+ drawPrompt();
1016
+ redrew = true;
1017
+ echoOperator(act.text);
1018
+ break;
1019
+ case "help":
1020
+ for (const ln of CHAT_HELP) out(c("dim", ln));
1021
+ break;
1022
+ case "status": {
1023
+ const s = target !== null ? loadStatuses(logDir).find((x) => x.id === target) : void 0;
1024
+ out(c("dim", s ? `${s.id} · ${s.state} · ${s.last}` : `${target ?? "?"} · no status`));
1025
+ break;
1026
+ }
1027
+ case "quit":
1028
+ aborted = true;
1029
+ break;
1030
+ }
1031
+ if (!redrew) drawPrompt();
1032
+ };
1033
+ rlIn = createInterface({
1034
+ input: in_,
1035
+ output: { write: () => true },
1036
+ terminal: terminalIn
1037
+ });
1038
+ rlIn.on("line", handleChatLine);
1039
+ rlIn.on("SIGINT", () => {
1040
+ if ((rlIn?.line ?? "").trim() === "") aborted = true;
1041
+ else {
1042
+ rlIn?.write(null, {
1043
+ ctrl: true,
1044
+ name: "u"
1045
+ });
1046
+ drawPrompt();
1047
+ }
1048
+ });
1049
+ rlIn.on("close", () => {
1050
+ aborted = true;
1051
+ });
1052
+ out_.write(chatEnter(rows));
1053
+ drawPrompt();
1054
+ if (terminalIn) in_.on("keypress", () => drawPrompt());
1055
+ onResize = () => {
1056
+ if (typeof out_.rows === "number") rows = out_.rows;
1057
+ fill = 0;
1058
+ out_.write(chatScrollRegion(rows) + chatBlankRow(rows));
1059
+ drawPrompt();
1060
+ };
1061
+ out_.on?.("resize", onResize);
1062
+ } else if (target !== null && in_.isTTY) {
1063
+ rlIn = createInterface({ input: in_ });
1064
+ rlIn.on("line", handleOperatorLine);
1065
+ }
1066
+ /** The prompt's unsent text — a draft holds the auto-exit countdown. */
1067
+ const promptText = () => io?.promptLine ? io.promptLine() : rlIn?.line ?? "";
1068
+ try {
1069
+ for (;;) {
1070
+ if (aborted) return;
1071
+ const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
1072
+ const wanted = target ? [target] : runningIds();
1073
+ for (const wid of wanted) {
1074
+ if (handles.has(wid) || finished.has(wid)) continue;
1075
+ const path = eventsPath(logDir, wid);
1076
+ const st = statuses.get(wid) ?? {
1077
+ id: wid,
1078
+ label: "",
1079
+ cwd: "",
1080
+ provider: ""
1081
+ };
1082
+ if (existsSync(path)) {
1083
+ const multi = target === null || handles.size > 0;
1084
+ handles.set(wid, attachHandle(wid, path, st, multi));
1085
+ states.set(wid, states.get(wid) ?? initialFollowState());
1086
+ } else if (!seenHeader.has(wid)) {
1087
+ out(headerLine(c, st));
1088
+ seenHeader.add(wid);
1089
+ }
1090
+ }
1091
+ const multi = handles.size > 1 || target === null;
1092
+ let progressed = false;
1093
+ for (const [wid, h] of [...handles.entries()]) {
1094
+ const st = statuses.get(wid) ?? {
1095
+ id: wid,
1096
+ label: "",
1097
+ cwd: "",
1098
+ provider: ""
1099
+ };
1100
+ const buffer = Buffer.alloc(65536);
1101
+ for (;;) {
1102
+ let n;
1103
+ try {
1104
+ n = readSync(h.fd, buffer, 0, buffer.length, null);
1105
+ } catch {
1106
+ n = 0;
1107
+ }
1108
+ if (n <= 0) break;
1109
+ h.buf += buffer.toString("utf8", 0, n);
1110
+ }
1111
+ const lines = h.buf.split("\n");
1112
+ h.buf = lines.pop() ?? "";
1113
+ for (const line of lines) {
1114
+ if (!line.trim()) continue;
1115
+ progressed = true;
1116
+ let e;
1117
+ try {
1118
+ e = JSON.parse(line);
1119
+ } catch {
1120
+ continue;
1121
+ }
1122
+ emitEvent(e, wid, st, multi);
1123
+ }
1124
+ if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
1125
+ if (!finished.has(wid)) {
1126
+ eraseLiveness();
1127
+ out(`${multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " "}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}`);
1128
+ finished.add(wid);
1129
+ }
1130
+ closeSync(h.fd);
1131
+ handles.delete(wid);
1132
+ }
1133
+ }
1134
+ for (const wid of [...handles.keys()]) {
1135
+ const st = statuses.get(wid) ?? {
1136
+ id: wid,
1137
+ label: "",
1138
+ cwd: "",
1139
+ provider: ""
1140
+ };
1141
+ const msgs = readInbox(logDir, wid);
1142
+ const seenN = inboxSeen.get(wid) ?? 0;
1143
+ if (msgs.length > seenN) {
1144
+ for (const m of msgs.slice(seenN)) emitEvent({
1145
+ type: "handoff",
1146
+ from: m.from,
1147
+ kind: m.kind,
1148
+ text: m.text,
1149
+ _ts: m._ts
1150
+ }, wid, st, multi);
1151
+ inboxSeen.set(wid, msgs.length);
1152
+ progressed = true;
1153
+ }
1154
+ }
1155
+ const lingerOn = target;
1156
+ if (lingerOn !== null && finished.has(lingerOn)) {
1157
+ if (autoExit) {
1158
+ if (holdAutoExit(promptText())) {
1159
+ writeLiveness();
1160
+ await sleep(250);
1161
+ continue;
1162
+ }
1163
+ const until = Date.now() + autoExit * 1e3;
1164
+ while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
1165
+ writeLiveness();
1166
+ await sleep(250);
1167
+ }
1168
+ if (aborted || target !== lingerOn || holdAutoExit(promptText())) continue;
1169
+ eraseLiveness();
1170
+ out(c("dim", "closing"));
1171
+ return;
1172
+ }
1173
+ if (rlIn === null) return;
1174
+ }
1175
+ if (autoExit && !target) {
1176
+ if (runningIds().length) idleSince = null;
1177
+ else if (idleSince === null) idleSince = Date.now();
1178
+ else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
1179
+ eraseLiveness();
1180
+ out(c("dim", "closing"));
1181
+ return;
1182
+ }
1183
+ }
1184
+ if (!progressed) {
1185
+ writeLiveness();
1186
+ await sleep(500);
1187
+ }
1188
+ }
1189
+ } finally {
1190
+ rlIn?.close();
1191
+ process.removeListener("SIGINT", onInt);
1192
+ if (onResize) out_.removeListener?.("resize", onResize);
1193
+ if (chat) out_.write(chatLeave(rows));
1194
+ for (const h of handles.values()) try {
1195
+ closeSync(h.fd);
1196
+ } catch {}
1197
+ }
1198
+ }
1199
+ function sleep(ms) {
1200
+ return new Promise((r) => setTimeout(r, ms));
1201
+ }
1202
+ /** Workers of this terminal for the status bar; "" when there are none. */
1203
+ function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
1204
+ const statuses = loadStatuses(logDir);
1205
+ const mine = statuses.filter((s) => {
1206
+ const sameScope = term && workerInScope(s, term);
1207
+ const sameDir = cwd && s.cwd.startsWith(cwd);
1208
+ return sameScope || !term && sameDir;
1209
+ });
1210
+ return renderStatusLine(mine, now, makeColor(false), statuses, inboxCounts(logDir, mine));
1211
+ }
1212
+
1213
+ //#endregion
1214
+ //#region src/workers/providers.ts
1215
+ /**
1216
+ * providers.ts — provider, class and switch management over the workers config.
1217
+ *
1218
+ * One layer under both `pai worker providers …` and the MCP worker_providers
1219
+ * tool. Every mutation re-reads the config file, changes only the workers
1220
+ * section, and writes it back atomically — the file is shared with everything
1221
+ * else PAI runs, so a torn write is not an option.
1222
+ *
1223
+ * Keys are never stored in the config and never accepted inline from the CLI:
1224
+ * only file paths. (The MCP `add` tool additionally accepts a raw `key`, which
1225
+ * it immediately parks in ~/.config/pai/keys/<name>, mode 0600, storing only
1226
+ * the path — a chat is not a place to leave a credential lying around.)
1227
+ */
1228
+ function addProvider(input) {
1229
+ 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 _`);
1230
+ 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.");
1231
+ if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
1232
+ let keyFile = input.keyFile ?? null;
1233
+ if (input.key !== void 0) {
1234
+ const dir = keysDir();
1235
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1236
+ const path = `${dir}/${input.name}`;
1237
+ writeFileSync(path, input.key.trim() + "\n", {
1238
+ encoding: "utf8",
1239
+ mode: 384
1240
+ });
1241
+ try {
1242
+ chmodSync(path, 384);
1243
+ } catch {}
1244
+ keyFile = path;
1245
+ }
1246
+ const { raw, workers } = readWorkersSection();
1247
+ const provider = {
1248
+ enabled: true,
1249
+ protocol: input.protocol ?? "anthropic",
1250
+ baseUrl: input.baseUrl,
1251
+ keyFile,
1252
+ models: input.fastModel ? {
1253
+ default: input.model,
1254
+ fast: input.fastModel
1255
+ } : { default: input.model },
1256
+ env: input.env ?? {},
1257
+ ...input.note ? { note: input.note } : {},
1258
+ ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
1259
+ ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
1260
+ ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
1261
+ ...input.contextWindow ? { contextWindow: input.contextWindow } : {},
1262
+ ...input.costTier ? { costTier: input.costTier } : {},
1263
+ ...input.tags?.length ? { tags: input.tags } : {}
1264
+ };
1265
+ workers.providers[input.name] = provider;
1266
+ if (Object.keys(workers.providers).length === 1) {
1267
+ workers.enabled = true;
1268
+ workers.active = input.name;
1269
+ workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
1270
+ const fast = input.fastModel ? `${input.name}/fast` : input.name;
1271
+ workers.classes = {
1272
+ draft: fast,
1273
+ plan: input.name,
1274
+ implement: input.name,
1275
+ review: input.name,
1276
+ research: input.name,
1277
+ spotcheck: fast,
1278
+ simple: fast,
1279
+ complex: input.name,
1280
+ image: input.name
1281
+ };
1282
+ }
1283
+ writeWorkersSection(raw, workers);
1284
+ return workers;
1285
+ }
1286
+ /** Change costTier / tags on an existing provider (MCP action "update"). */
1287
+ function updateProvider(name, changes) {
1288
+ const { raw, workers } = readWorkersSection();
1289
+ const p = workers.providers[name];
1290
+ if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1291
+ if (changes.costTier !== void 0) {
1292
+ if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
1293
+ p.costTier = changes.costTier;
1294
+ }
1295
+ if (changes.tags !== void 0) {
1296
+ for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
1297
+ p.tags = changes.tags;
1298
+ }
1299
+ writeWorkersSection(raw, workers);
1300
+ return workers;
1301
+ }
1302
+ function removeProvider(name) {
1303
+ const { raw, workers } = readWorkersSection();
1304
+ if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
1305
+ delete workers.providers[name];
1306
+ for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
1307
+ if (workers.active === name) workers.active = null;
1308
+ writeWorkersSection(raw, workers);
1309
+ return workers;
1310
+ }
1311
+ function useProvider(name) {
1312
+ const { raw, workers } = readWorkersSection();
1313
+ if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1314
+ workers.active = name;
1315
+ writeWorkersSection(raw, workers);
1316
+ return workers;
1317
+ }
1318
+ function setProviderEnabled(name, enabled) {
1319
+ const { raw, workers } = readWorkersSection();
1320
+ const p = workers.providers[name];
1321
+ if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
1322
+ p.enabled = enabled;
1323
+ writeWorkersSection(raw, workers);
1324
+ if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
1325
+ return workers;
1326
+ }
1327
+ /**
1328
+ * Point a class at a target ("provider", "provider/fast", or an object with
1329
+ * provider + optional mcp/maxCostTier/requireTags/order). An object without
1330
+ * a provider only constrains auto-routing for that class.
1331
+ */
1332
+ function setClass(name, target) {
1333
+ const { raw, workers } = readWorkersSection();
1334
+ if (typeof target === "string") {
1335
+ const [provider, alias] = target.split("/");
1336
+ const p = workers.providers[provider];
1337
+ if (!p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1338
+ if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose "default" and "fast"`);
1339
+ if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${provider}" has no fast model configured`);
1340
+ } else if (target.provider) {
1341
+ if (!workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1342
+ }
1343
+ workers.classes[name] = target;
1344
+ writeWorkersSection(raw, workers);
1345
+ return workers;
1346
+ }
1347
+ function unsetClass(name) {
1348
+ const { raw, workers } = readWorkersSection();
1349
+ if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
1350
+ delete workers.classes[name];
1351
+ writeWorkersSection(raw, workers);
1352
+ return workers;
1353
+ }
1354
+ function setWorkersEnabled(enabled) {
1355
+ const { raw, workers } = readWorkersSection();
1356
+ workers.enabled = enabled;
1357
+ writeWorkersSection(raw, workers);
1358
+ return workers;
1359
+ }
1360
+ /** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
1361
+ function classTargetText(target) {
1362
+ if (typeof target === "string") return target;
1363
+ return [
1364
+ target.provider ?? "(routing)",
1365
+ ...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
1366
+ ...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
1367
+ ...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
1368
+ ...target.order?.length ? [`order [${target.order.join(",")}]`] : []
1369
+ ].join(" ");
1370
+ }
1371
+ /** Human-readable provider listing (quota probe included when configured). */
1372
+ function describeProviders(workers) {
1373
+ const lines = [];
1374
+ const names = Object.keys(workers.providers);
1375
+ if (!names.length) {
1376
+ lines.push(`no providers configured. Add one with:`);
1377
+ lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
1378
+ return lines;
1379
+ }
1380
+ for (const name of names) {
1381
+ const p = workers.providers[name];
1382
+ const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
1383
+ const quota = p.quotaProbe ? probeQuota(p) : null;
1384
+ const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
1385
+ const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
1386
+ lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
1387
+ lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
1388
+ lines.push(` ${tierTags}`);
1389
+ if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
1390
+ else lines.push(` no key file (token "local")`);
1391
+ if (p.note) lines.push(` ${p.note}`);
1392
+ if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
1393
+ if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
1394
+ }
1395
+ const setNames = Object.keys(workers.mcpSets);
1396
+ if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
1397
+ const classNames = Object.keys(workers.classes);
1398
+ if (classNames.length) lines.push(`classes: ${classNames.map((cl) => `${cl}=${classTargetText(workers.classes[cl])}`).join(" ")}`);
1399
+ if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
1400
+ return lines;
1401
+ }
1402
+
1403
+ //#endregion
1404
+ export { setClass as a, unsetClass as c, followWorkers as d, psOutput as f, removeProvider as i, updateProvider as l, statusLineOutput as m, classTargetText as n, setProviderEnabled as o, replayOutput as p, describeProviders as r, setWorkersEnabled as s, addProvider as t, useProvider as u };
1405
+ //# sourceMappingURL=providers-FYkZjn_C.mjs.map