@tekmidian/pai 0.36.2 → 0.38.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 (157) hide show
  1. package/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
  2. package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -4
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/program.d.mts.map +1 -1
  6. package/dist/cli/program.mjs +5 -4
  7. package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
  8. package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
  9. package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
  10. package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
  11. package/dist/daemon/index.mjs +8 -8
  12. package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
  13. package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
  14. package/dist/daemon-DsGGiIJM.mjs +20 -0
  15. package/dist/daemon-mcp/index.mjs +549 -1
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
  18. package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
  19. package/dist/detector-DtLExmHN.mjs +5 -0
  20. package/dist/factory-BXzqRYVZ.mjs +3 -0
  21. package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
  22. package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
  23. package/dist/hooks/context-compression-hook.mjs +164 -14
  24. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  25. package/dist/hooks/initialize-session.mjs +6 -0
  26. package/dist/hooks/initialize-session.mjs.map +3 -3
  27. package/dist/hooks/load-core-context.mjs +6 -0
  28. package/dist/hooks/load-core-context.mjs.map +3 -3
  29. package/dist/hooks/load-project-context.mjs +6 -0
  30. package/dist/hooks/load-project-context.mjs.map +3 -3
  31. package/dist/hooks/post-compact-inject.mjs +6 -0
  32. package/dist/hooks/post-compact-inject.mjs.map +3 -3
  33. package/dist/hooks/route-agents-to-worker.mjs +518 -0
  34. package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
  35. package/dist/hooks/status-line.mjs +400 -0
  36. package/dist/hooks/status-line.mjs.map +7 -0
  37. package/dist/hooks/stop-hook.mjs +6 -0
  38. package/dist/hooks/stop-hook.mjs.map +3 -3
  39. package/dist/hooks/subagent-stop-hook.mjs +6 -0
  40. package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
  41. package/dist/hooks/sync-todo-to-md.mjs +6 -0
  42. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  43. package/dist/hooks/update-tab-on-action.mjs +6 -0
  44. package/dist/hooks/update-tab-on-action.mjs.map +3 -3
  45. package/dist/hooks/update-tab-titles.mjs +6 -0
  46. package/dist/hooks/update-tab-titles.mjs.map +3 -3
  47. package/dist/hooks/worker-proxy.mjs +858 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +545 -0
  50. package/dist/hooks/worker-status-line.mjs.map +7 -0
  51. package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
  52. package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
  53. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  54. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  55. package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
  56. package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
  57. package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
  58. package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
  59. package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
  60. package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
  61. package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
  62. package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
  63. package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
  64. package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
  65. package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
  66. package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
  67. package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
  68. package/dist/{program-C-fUghPv.mjs → program-Y0hAiVy8.mjs} +851 -101
  69. package/dist/program-Y0hAiVy8.mjs.map +1 -0
  70. package/dist/providers-DUshcB-d.mjs +4031 -0
  71. package/dist/providers-DUshcB-d.mjs.map +1 -0
  72. package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
  73. package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
  74. package/dist/query-feedback-DhyLOe5S.mjs +3 -0
  75. package/dist/router-1zi8jiNF.mjs +3 -0
  76. package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
  77. package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
  78. package/dist/skills/Worker/SKILL.md +63 -0
  79. package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
  80. package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
  81. package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
  82. package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
  83. package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
  84. package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
  85. package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
  86. package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
  87. package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
  88. package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
  89. package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
  90. package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
  91. package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
  92. package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
  94. package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
  96. package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
  97. package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
  98. package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
  99. package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
  100. package/docs/commands/README.md +29 -0
  101. package/docs/commands/backup.md +1 -1
  102. package/docs/commands/clear-names.md +1 -1
  103. package/docs/commands/daemon.md +1 -1
  104. package/docs/commands/db.md +1 -1
  105. package/docs/commands/end.md +1 -1
  106. package/docs/commands/help.md +1 -1
  107. package/docs/commands/identity.md +1 -1
  108. package/docs/commands/kg.md +1 -1
  109. package/docs/commands/mcp.md +1 -1
  110. package/docs/commands/memory.md +1 -1
  111. package/docs/commands/notify.md +1 -1
  112. package/docs/commands/observation.md +1 -1
  113. package/docs/commands/obsidian.md +1 -1
  114. package/docs/commands/pause.md +1 -1
  115. package/docs/commands/project.md +1 -1
  116. package/docs/commands/projects.md +1 -1
  117. package/docs/commands/registry.md +1 -1
  118. package/docs/commands/restore.md +1 -1
  119. package/docs/commands/session.md +1 -1
  120. package/docs/commands/sessions.md +1 -1
  121. package/docs/commands/setup.md +1 -1
  122. package/docs/commands/shell-init.md +1 -1
  123. package/docs/commands/skill.md +1 -1
  124. package/docs/commands/task.md +1 -1
  125. package/docs/commands/topic.md +1 -1
  126. package/docs/commands/update.md +1 -1
  127. package/docs/commands/worker.md +398 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +434 -0
  130. package/package.json +1 -1
  131. package/scripts/build-hooks.mjs +46 -1
  132. package/src/hooks/pre-compact.sh +1 -0
  133. package/src/hooks/session-autosave.sh +1 -0
  134. package/src/hooks/session-stop.sh +1 -0
  135. package/src/hooks/ts/lib/context-fill.test.ts +104 -0
  136. package/src/hooks/ts/lib/context-fill.ts +73 -0
  137. package/src/hooks/ts/lib/handover-evidence.ts +227 -0
  138. package/src/hooks/ts/lib/worker-session.test.ts +24 -0
  139. package/src/hooks/ts/lib/worker-session.ts +23 -0
  140. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
  141. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
  142. package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
  143. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
  144. package/src/hooks/ts/session-start/initialize-session.ts +2 -0
  145. package/src/hooks/ts/session-start/load-core-context.ts +2 -0
  146. package/src/hooks/ts/session-start/load-project-context.ts +2 -0
  147. package/src/hooks/ts/session-start/post-compact-inject.ts +2 -0
  148. package/src/hooks/ts/stop/stop-hook.ts +2 -0
  149. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
  150. package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
  151. package/statusline-command.sh +17 -0
  152. package/dist/daemon-DRdoA489.mjs +0 -20
  153. package/dist/detector-DGAk1iBR.mjs +0 -5
  154. package/dist/factory-CrokPMk2.mjs +0 -3
  155. package/dist/program-C-fUghPv.mjs.map +0 -1
  156. package/dist/query-feedback-BBMBp96K.mjs +0 -3
  157. package/dist/router-BMkOb62X.mjs +0 -3
@@ -0,0 +1,4031 @@
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";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { basename, dirname, join, relative } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { connect, createServer } from "node:net";
7
+ import { execFileSync, spawn } from "node:child_process";
8
+ import { createInterface } from "node:readline";
9
+
10
+ //#region src/config/json-store.ts
11
+ /**
12
+ * json-store.ts — read/write JSON config files without destroying them
13
+ *
14
+ * The failure this exists to prevent:
15
+ *
16
+ * try { return JSON.parse(read(path)); } catch { return {}; }
17
+ * ... later ...
18
+ * write(path, JSON.stringify(ourData));
19
+ *
20
+ * An unreadable file becomes an empty object, and the next write makes that
21
+ * permanent. Silently, exit code 0. This shape appeared three times in this
22
+ * repo against three different files, and twice in AIBroker.
23
+ *
24
+ * The distinction that matters is between *missing* and *unreadable*:
25
+ *
26
+ * missing — legitimate first run. Start fresh; writing is safe.
27
+ * unreadable — the file exists and we could not parse it. Those bytes are
28
+ * the only copy of something. Never overwrite them.
29
+ *
30
+ * Collapsing the second into the first is the bug.
31
+ *
32
+ * NOT everything deserves this guard. For a transient buffer — an undelivered
33
+ * message queue, a cache — starting fresh IS the correct recovery, and
34
+ * refusing to write would disable the feature permanently. Use `writeJsonAtomic`
35
+ * alone there: it still prevents a crash from truncating a good file, without
36
+ * blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.
37
+ */
38
+ /**
39
+ * Read a JSON file, distinguishing "absent" from "damaged".
40
+ *
41
+ * @param path file to read
42
+ * @param label how to name it to the user, e.g. "~/.claude.json"
43
+ * @throws if the file exists but cannot be read or parsed
44
+ */
45
+ function readJsonStrict(path, label = path) {
46
+ if (!existsSync(path)) return {};
47
+ let raw;
48
+ try {
49
+ raw = readFileSync(path, "utf8");
50
+ } catch (e) {
51
+ throw new Error(`Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — writing now would replace its contents with ours alone.`);
52
+ }
53
+ try {
54
+ return JSON.parse(raw);
55
+ } catch (e) {
56
+ throw new Error(`${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — overwriting it would destroy whatever it holds.\nRepair the file, or move it aside and re-run this command.`);
57
+ }
58
+ }
59
+ /**
60
+ * Write JSON without risking the existing file.
61
+ *
62
+ * Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file
63
+ * and renames. Rename is atomic within a filesystem, so a crash mid-write
64
+ * leaves the original intact rather than truncated — which is how these files
65
+ * become corrupt in the first place.
66
+ */
67
+ function writeJsonAtomic(path, data, opts = {}) {
68
+ const { backup = true, label = path } = opts;
69
+ const serialized = JSON.stringify(data, null, 2) + "\n";
70
+ const dir = dirname(path);
71
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
72
+ if (backup && existsSync(path)) try {
73
+ copyFileSync(path, `${path}.bak-pai`);
74
+ } catch (e) {
75
+ throw new Error(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
76
+ }
77
+ const tmp = `${path}.tmp-pai-${process.pid}`;
78
+ try {
79
+ writeFileSync(tmp, serialized, "utf8");
80
+ renameSync(tmp, path);
81
+ } catch (e) {
82
+ try {
83
+ if (existsSync(tmp)) unlinkSync(tmp);
84
+ } catch {}
85
+ throw new Error(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\nThe original is unchanged.`);
86
+ }
87
+ }
88
+
89
+ //#endregion
90
+ //#region src/workers/config.ts
91
+ /**
92
+ * config.ts — the `workers` section of ~/.config/pai/config.json
93
+ *
94
+ * Everything that knows about worker providers reads this module: the CLI
95
+ * (`pai worker …`), the MCP tools (worker_*), and the Agent-routing hook.
96
+ * Keep it free of commander/MCP imports so all three layers stay thin over it.
97
+ *
98
+ * Keys are never stored here — only paths to 0600 files. A provider without a
99
+ * keyFile is a local server and gets the placeholder token "local".
100
+ *
101
+ * `protocol: "openai"` providers run through the PAI proxy (src/workers/proxy):
102
+ * `upstreamUrl` is their Chat Completions base, and `run` points
103
+ * ANTHROPIC_BASE_URL at the local proxy with the provider name in the path.
104
+ * `engine: "codex"` providers run through the Codex CLI instead of Claude
105
+ * Code.
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
+ ];
129
+ const DEFAULT_LOG_DIR = "~/.claude/logs/workers";
130
+ const DEFAULT_PANE = {
131
+ enabled: true,
132
+ fontSize: 13,
133
+ autoExitSecs: 60
134
+ };
135
+ const DEFAULT_ROUTING = {
136
+ order: [],
137
+ cooldownMinutes: 30,
138
+ retryOnQuota: true
139
+ };
140
+ function defaultWorkersConfig() {
141
+ return {
142
+ enabled: false,
143
+ active: null,
144
+ providers: {},
145
+ classes: {},
146
+ mcpSets: {},
147
+ pane: { ...DEFAULT_PANE },
148
+ logDir: DEFAULT_LOG_DIR,
149
+ routing: {
150
+ ...DEFAULT_ROUTING,
151
+ order: []
152
+ }
153
+ };
154
+ }
155
+ var WorkersConfigError = class extends Error {};
156
+ function bad(path, why) {
157
+ throw new WorkersConfigError(`workers${path}: ${why}`);
158
+ }
159
+ function str(v) {
160
+ return typeof v === "string" ? v : "";
161
+ }
162
+ function parseProvider(name, raw) {
163
+ if (typeof raw !== "object" || raw === null) bad(`.providers.${name}`, "must be an object");
164
+ const p = raw;
165
+ const protocol = p.protocol === void 0 ? "anthropic" : str(p.protocol);
166
+ if (protocol !== "anthropic" && protocol !== "openai") bad(`.providers.${name}.protocol`, `"${str(p.protocol)}" is neither "anthropic" nor "openai"`);
167
+ const engine = p.engine === void 0 ? "claude" : str(p.engine);
168
+ if (engine !== "claude" && engine !== "codex") bad(`.providers.${name}.engine`, `"${str(p.engine)}" is neither "claude" nor "codex"`);
169
+ const baseUrl = str(p.baseUrl);
170
+ if (!baseUrl && protocol !== "openai") bad(`.providers.${name}.baseUrl`, "is required");
171
+ const keyFile = p.keyFile === void 0 || p.keyFile === null || str(p.keyFile) === "" ? null : str(p.keyFile);
172
+ const modelsRaw = p.models === void 0 ? {} : p.models;
173
+ if (typeof modelsRaw !== "object" || modelsRaw === null) bad(`.providers.${name}.models`, "must be an object");
174
+ const m = modelsRaw;
175
+ const defaultModel = str(m.default);
176
+ if (!defaultModel) bad(`.providers.${name}.models.default`, "is required");
177
+ const fast = m.fast === void 0 ? void 0 : str(m.fast);
178
+ const env = {};
179
+ if (p.env !== void 0) {
180
+ if (typeof p.env !== "object" || p.env === null || Array.isArray(p.env)) bad(`.providers.${name}.env`, "must be an object of string values");
181
+ for (const [k, v] of Object.entries(p.env)) {
182
+ if (typeof v !== "string") bad(`.providers.${name}.env.${k}`, "must be a string");
183
+ env[k] = v;
184
+ }
185
+ }
186
+ const quotaSkipAt = p.quotaSkipAt === void 0 ? void 0 : p.quotaSkipAt;
187
+ if (quotaSkipAt !== void 0) {
188
+ if (typeof quotaSkipAt !== "number" || quotaSkipAt < 0 || quotaSkipAt > 100) bad(`.providers.${name}.quotaSkipAt`, "must be a number between 0 and 100");
189
+ }
190
+ const contextWindow = p.contextWindow === void 0 ? void 0 : p.contextWindow;
191
+ if (contextWindow !== void 0) {
192
+ if (typeof contextWindow !== "number" || contextWindow <= 0) bad(`.providers.${name}.contextWindow`, "must be a positive number of tokens");
193
+ }
194
+ const upstreamUrl = str(p.upstreamUrl);
195
+ if (protocol === "openai" && !upstreamUrl) bad(`.providers.${name}.upstreamUrl`, `is required for protocol "openai" (the Chat Completions base, e.g. "https://api.openai.com/v1")`);
196
+ const costTier = p.costTier === void 0 ? void 0 : p.costTier;
197
+ if (costTier !== void 0) {
198
+ if (typeof costTier !== "number" || !Number.isInteger(costTier) || costTier < 1 || costTier > 5) bad(`.providers.${name}.costTier`, "must be an integer 1 (cheapest) … 5 (most expensive)");
199
+ }
200
+ let tags;
201
+ if (p.tags !== void 0) {
202
+ 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(", ")}`);
203
+ for (const t of p.tags) if (!PROVIDER_TAGS.includes(t)) bad(`.providers.${name}.tags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
204
+ tags = p.tags;
205
+ }
206
+ return {
207
+ enabled: p.enabled === void 0 ? true : p.enabled === true,
208
+ protocol,
209
+ baseUrl,
210
+ keyFile,
211
+ models: fast ? {
212
+ default: defaultModel,
213
+ fast
214
+ } : { default: defaultModel },
215
+ env,
216
+ ...str(p.note) ? { note: str(p.note) } : {},
217
+ ...upstreamUrl ? { upstreamUrl } : {},
218
+ ...engine !== "claude" ? { engine } : {},
219
+ ...str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {},
220
+ ...quotaSkipAt !== void 0 ? { quotaSkipAt } : {},
221
+ ...contextWindow !== void 0 ? { contextWindow } : {},
222
+ ...costTier !== void 0 ? { costTier } : {},
223
+ ...tags ? { tags } : {}
224
+ };
225
+ }
226
+ /**
227
+ * Parse and validate a raw `workers` value. Missing section → defaults.
228
+ * Unknown-but-typed garbage → WorkersConfigError naming the offending field.
229
+ */
230
+ function parseWorkersConfig(raw) {
231
+ const d = defaultWorkersConfig();
232
+ if (raw === void 0 || raw === null) return d;
233
+ if (typeof raw !== "object" || Array.isArray(raw)) bad("", "section must be an object");
234
+ const w = raw;
235
+ const providers = {};
236
+ if (w.providers !== void 0) {
237
+ if (typeof w.providers !== "object" || w.providers === null || Array.isArray(w.providers)) bad(".providers", "must be an object keyed by provider name");
238
+ for (const [name, p] of Object.entries(w.providers)) providers[name] = parseProvider(name, p);
239
+ }
240
+ const classes = {};
241
+ const classesRaw = w.classes !== void 0 ? w.classes : w.roles;
242
+ if (classesRaw !== void 0) {
243
+ 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}");
244
+ for (const [cls, target] of Object.entries(classesRaw)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
245
+ const o = target;
246
+ const provider = str(o.provider);
247
+ if (o.provider !== void 0 && (!provider || provider.includes(" "))) bad(`.classes.${cls}.provider`, `invalid provider "${provider}"`);
248
+ let mcp;
249
+ if (o.mcp !== void 0) {
250
+ 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");
251
+ mcp = o.mcp;
252
+ }
253
+ let maxCostTier;
254
+ if (o.maxCostTier !== void 0) {
255
+ 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");
256
+ maxCostTier = o.maxCostTier;
257
+ }
258
+ let requireTags;
259
+ if (o.requireTags !== void 0) {
260
+ 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(", ")}`);
261
+ for (const t of o.requireTags) if (!PROVIDER_TAGS.includes(t)) bad(`.classes.${cls}.requireTags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
262
+ requireTags = o.requireTags;
263
+ }
264
+ let order;
265
+ if (o.order !== void 0) {
266
+ if (!Array.isArray(o.order) || o.order.some((x) => typeof x !== "string")) bad(`.classes.${cls}.order`, "must be an array of provider names");
267
+ order = o.order;
268
+ }
269
+ const obj = {
270
+ ...provider ? { provider } : {},
271
+ ...mcp ? { mcp } : {},
272
+ ...maxCostTier !== void 0 ? { maxCostTier } : {},
273
+ ...requireTags ? { requireTags } : {},
274
+ ...order ? { order } : {}
275
+ };
276
+ classes[cls] = Object.keys(obj).length ? obj : {};
277
+ } else {
278
+ const t = str(target);
279
+ if (!t || t.includes(" ")) bad(`.classes.${cls}`, `invalid target "${t}"`);
280
+ classes[cls] = t;
281
+ }
282
+ }
283
+ const mcpSets = {};
284
+ if (w.mcpSets !== void 0) {
285
+ if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
286
+ for (const [setName, servers] of Object.entries(w.mcpSets)) {
287
+ if (!Array.isArray(servers) || servers.some((x) => typeof x !== "string")) bad(`.mcpSets.${setName}`, "must be an array of MCP server names");
288
+ mcpSets[setName] = servers;
289
+ }
290
+ }
291
+ let pane = { ...DEFAULT_PANE };
292
+ if (w.pane !== void 0) {
293
+ if (typeof w.pane !== "object" || w.pane === null) bad(".pane", "must be an object");
294
+ const pc = w.pane;
295
+ if (pc.enabled !== void 0 && typeof pc.enabled !== "boolean") bad(".pane.enabled", "must be boolean");
296
+ if (pc.fontSize !== void 0 && (typeof pc.fontSize !== "number" || pc.fontSize <= 0)) bad(".pane.fontSize", "must be a positive number of points");
297
+ if (pc.autoExitSecs !== void 0 && typeof pc.autoExitSecs !== "number") bad(".pane.autoExitSecs", "must be a number");
298
+ pane = {
299
+ enabled: pc.enabled === void 0 ? DEFAULT_PANE.enabled : pc.enabled === true,
300
+ fontSize: pc.fontSize === void 0 ? DEFAULT_PANE.fontSize : pc.fontSize,
301
+ autoExitSecs: pc.autoExitSecs === void 0 ? DEFAULT_PANE.autoExitSecs : pc.autoExitSecs
302
+ };
303
+ }
304
+ let routing = {
305
+ ...DEFAULT_ROUTING,
306
+ order: []
307
+ };
308
+ if (w.routing !== void 0) {
309
+ if (typeof w.routing !== "object" || w.routing === null) bad(".routing", "must be an object");
310
+ const r = w.routing;
311
+ if (r.order !== void 0) {
312
+ if (!Array.isArray(r.order) || r.order.some((x) => typeof x !== "string")) bad(".routing.order", "must be an array of provider names");
313
+ routing.order = r.order;
314
+ }
315
+ if (r.cooldownMinutes !== void 0 && typeof r.cooldownMinutes !== "number") bad(".routing.cooldownMinutes", "must be a number");
316
+ if (r.retryOnQuota !== void 0 && typeof r.retryOnQuota !== "boolean") bad(".routing.retryOnQuota", "must be boolean");
317
+ routing = {
318
+ order: routing.order,
319
+ cooldownMinutes: r.cooldownMinutes === void 0 ? DEFAULT_ROUTING.cooldownMinutes : r.cooldownMinutes,
320
+ retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
321
+ };
322
+ }
323
+ const active = w.active === void 0 || w.active === null ? null : str(w.active);
324
+ if (active !== null && active !== "auto" && !(active in providers)) {}
325
+ return {
326
+ enabled: w.enabled === void 0 ? d.enabled : w.enabled === true,
327
+ active,
328
+ providers,
329
+ classes,
330
+ mcpSets,
331
+ pane,
332
+ logDir: str(w.logDir) || d.logDir,
333
+ routing
334
+ };
335
+ }
336
+ /**
337
+ * Read the whole config file and return (raw, workers) — the raw record so
338
+ * callers can rewrite it preserving every other section, the parsed+validated
339
+ * workers section. Unreadable config throws (readJsonStrict), missing is fine.
340
+ */
341
+ function readWorkersSection() {
342
+ const raw = readJsonStrict(CONFIG_FILE, "~/.config/pai/config.json");
343
+ return {
344
+ raw,
345
+ workers: parseWorkersConfig(raw.workers)
346
+ };
347
+ }
348
+ /** Write the workers section back into the config file, atomically. */
349
+ function writeWorkersSection(raw, workers) {
350
+ raw.workers = workers;
351
+ writeJsonAtomic(CONFIG_FILE, raw, { label: "~/.config/pai/config.json" });
352
+ }
353
+ /** Expand a leading ~ (config values are written with `~` to stay portable). */
354
+ function expandHome(p) {
355
+ if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1));
356
+ return p;
357
+ }
358
+ function assertProviderRunnable(name, p) {
359
+ if (p.protocol === "openai" && !p.upstreamUrl) throw new WorkersConfigError(`provider "${name}" uses protocol "openai" but has no upstreamUrl — set its Chat Completions base (e.g. "https://api.openai.com/v1") so the PAI proxy knows where to translate to.`);
360
+ }
361
+ /** Where a provider's key file lives, or null. Shared by run + test + add. */
362
+ function providerKeyPath(p) {
363
+ return p.keyFile ? expandHome(p.keyFile) : null;
364
+ }
365
+ const DEFAULT_CONTEXT_WINDOW = 2e5;
366
+ /** Cost tier of a provider for class filtering (unset = 3, the middle). */
367
+ function providerCostTier(p) {
368
+ return p.costTier ?? DEFAULT_COST_TIER;
369
+ }
370
+ /** Context window used by the meter when the init event carries none. */
371
+ function providerContextWindow(p) {
372
+ return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
373
+ }
374
+ /** Directory under which inline keys (MCP `key` field) are stored, 0600. */
375
+ function keysDir() {
376
+ return join(homedir(), ".config", "pai", "keys");
377
+ }
378
+
379
+ //#endregion
380
+ //#region src/workers/paths.ts
381
+ /**
382
+ * paths.ts — where worker run artefacts live.
383
+ *
384
+ * Everything a run writes (event mirror, status file, pane registry, routing
385
+ * state, the empty MCP config) sits under one logDir so the whole tree is
386
+ * disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.
387
+ */
388
+ /** Absolute logDir for the given config. */
389
+ function workersLogDir(config) {
390
+ return expandHome(config.logDir);
391
+ }
392
+ function statusPath(logDir, id) {
393
+ return join(logDir, `${id}.status`);
394
+ }
395
+ function eventsPath(logDir, id) {
396
+ return join(logDir, `${id}.jsonl`);
397
+ }
398
+ function ledgerPath(logDir) {
399
+ return join(logDir, "ledger.log");
400
+ }
401
+ function routingStatePath(logDir) {
402
+ return join(logDir, "routing-state.json");
403
+ }
404
+ function panesDir(logDir) {
405
+ return join(logDir, "panes");
406
+ }
407
+ /**
408
+ * The strict empty MCP config handed to headless workers. Written into the
409
+ * logDir on demand (never into the user's vendor config directory — this is
410
+ * PAI state, not vendor state).
411
+ */
412
+ function noMcpConfigPath(logDir) {
413
+ return join(logDir, "no-mcp.json");
414
+ }
415
+
416
+ //#endregion
417
+ //#region src/workers/args.ts
418
+ function parseRunnerArgs(argv) {
419
+ let prompt = null;
420
+ let outputFormat = "text";
421
+ const rest = [];
422
+ let headless = false;
423
+ let callerModel = false;
424
+ let callerMcpConfig = false;
425
+ let callerSystemPrompt = false;
426
+ const mcp = [];
427
+ let i = 0;
428
+ while (i < argv.length) {
429
+ const a = argv[i];
430
+ if (a === "-p" || a === "--print") {
431
+ headless = true;
432
+ rest.push(a);
433
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
434
+ prompt = argv[i + 1];
435
+ rest.push(prompt);
436
+ i += 1;
437
+ }
438
+ } else if (a === "--output-format") {
439
+ const v = argv[i + 1];
440
+ if (v === "json" || v === "stream-json") outputFormat = v;
441
+ i += 1;
442
+ } else if (a.startsWith("--output-format=")) {
443
+ const v = a.slice(16);
444
+ if (v === "json" || v === "stream-json") outputFormat = v;
445
+ } else if (a === "--verbose") {} else if (a === "--mcp") {
446
+ const v = argv[i + 1];
447
+ if (v !== void 0 && !v.startsWith("-")) {
448
+ mcp.push(v);
449
+ i += 1;
450
+ }
451
+ } else if (a.startsWith("--mcp=")) mcp.push(a.slice(6));
452
+ else {
453
+ if (a === "--model") callerModel = true;
454
+ if (a.startsWith("--model=")) callerModel = true;
455
+ if (a === "--mcp-config") callerMcpConfig = true;
456
+ if (a.startsWith("--mcp-config=")) callerMcpConfig = true;
457
+ if (a === "--append-system-prompt") callerSystemPrompt = true;
458
+ if (a.startsWith("--append-system-prompt=")) callerSystemPrompt = true;
459
+ if (prompt === null && !a.startsWith("-") && rest.length > 0 && (rest[rest.length - 1] === "-p" || rest[rest.length - 1] === "--print")) prompt = a;
460
+ rest.push(a);
461
+ }
462
+ i += 1;
463
+ }
464
+ return {
465
+ prompt,
466
+ outputFormat,
467
+ rest,
468
+ headless,
469
+ callerModel,
470
+ callerMcpConfig,
471
+ callerSystemPrompt,
472
+ mcp
473
+ };
474
+ }
475
+ /**
476
+ * Turn `-p "<prompt>"` into bare `-p` (same for --print): for stream-json
477
+ * stdin runs the prompt moves to the first user message on stdin, so the
478
+ * value must not stay on the command line. Only prompt values directly
479
+ * following the flag are touched; everything else passes through.
480
+ */
481
+ function stripPromptValues(argv) {
482
+ const out = [];
483
+ for (let i = 0; i < argv.length; i++) {
484
+ const a = argv[i];
485
+ const isPrint = a === "-p" || a === "--print";
486
+ out.push(a);
487
+ if (isPrint && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i += 1;
488
+ }
489
+ return out;
490
+ }
491
+ /** Collapse whitespace and cut to n chars with an ellipsis (label rendering). */
492
+ function shortText(s, n) {
493
+ const t = String(s ?? "").split(/\s+/).filter(Boolean).join(" ");
494
+ return t.length <= n ? t : t.slice(0, n - 1) + "…";
495
+ }
496
+
497
+ //#endregion
498
+ //#region src/workers/ledger.ts
499
+ /**
500
+ * ledger.ts — the routing ledger, one append-only log for every worker event.
501
+ *
502
+ * Line shapes (whitespace-aligned so `tail` reads as a table):
503
+ *
504
+ * 2026-09-17 12:00:00 WORKER-START id=<id> provider=<p> mode=headless model=<m> cwd=<cwd> label=<label>
505
+ * 2026-09-17 12:01:00 WORKER-END id=<id> provider=<p> mode=headless model=<m> rc=0 secs=60 turns=3 tools=8 label=<label>
506
+ * 2026-09-17 12:00:20 WORKER-REROUTE from=<a> to=<b> reason=quota
507
+ * 2026-09-17 12:00:00 DENIED-ANTHROPIC-AGENT cwd=<cwd> desc=<desc>
508
+ * 2026-09-17 12:00:00 ALLOWED-ANTHROPIC-AGENT cwd=<cwd> desc=<desc>
509
+ *
510
+ * `pai worker log` (and glm-log before it) counts these; keep the tags stable.
511
+ */
512
+ function ledgerStamp(d = /* @__PURE__ */ new Date()) {
513
+ const p = (n) => String(n).padStart(2, "0");
514
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
515
+ }
516
+ /** Parse `2026-09-17 12:00:00 TAG key=value …` into its parts. */
517
+ function parseLedgerLine(line) {
518
+ const m = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\S+)(?: (.*))?$/);
519
+ if (!m) return null;
520
+ const fields = {};
521
+ for (const part of (m[3] ?? "").split(" ")) {
522
+ if (!part) continue;
523
+ const eq = part.indexOf("=");
524
+ if (eq <= 0) continue;
525
+ fields[part.slice(0, eq)] = part.slice(eq + 1);
526
+ }
527
+ return {
528
+ stamp: m[1],
529
+ event: m[2],
530
+ fields
531
+ };
532
+ }
533
+ /** Append one ledger line. `kv` order is the caller's; values are flattened. */
534
+ function appendLedger(path, event, kv, now = /* @__PURE__ */ new Date()) {
535
+ const parts = [];
536
+ for (const [k, v] of Object.entries(kv)) {
537
+ if (v === void 0 || v === null) continue;
538
+ parts.push(`${k}=${String(v).replace(/\s+/g, " ")}`);
539
+ }
540
+ const dir = dirname(path);
541
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
542
+ const tail = parts.length ? " " + parts.join(" ") : "";
543
+ appendFileSync(path, `${ledgerStamp(now)} ${event}${tail}\n`, "utf8");
544
+ }
545
+ /** The counts `pai worker log` prints, over today's lines or the whole file. */
546
+ function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()) {
547
+ if (!existsSync(path)) return null;
548
+ const text = readFileSync(path, "utf8");
549
+ const today = ledgerStamp(now).slice(0, 10);
550
+ const lines = text.split("\n").filter((l) => l.trim());
551
+ const parsed = (scope === "all" ? lines : lines.filter((l) => l.startsWith(today))).map(parseLedgerLine).filter((x) => x !== null);
552
+ const count = (ev) => parsed.filter((p) => p.event === ev).length;
553
+ const ends = parsed.filter((p) => p.event === "WORKER-END");
554
+ const ok = ends.filter((p) => p.fields.rc === "0").length;
555
+ return {
556
+ scope: scope === "all" ? "all time" : `today ${today}`,
557
+ started: count("WORKER-START"),
558
+ endedOk: ok,
559
+ endedFailed: ends.length - ok,
560
+ denied: count("DENIED-ANTHROPIC-AGENT"),
561
+ allowed: count("ALLOWED-ANTHROPIC-AGENT"),
562
+ reroutes: count("WORKER-REROUTE"),
563
+ lastLines: lines.slice(-lastN)
564
+ };
565
+ }
566
+
567
+ //#endregion
568
+ //#region src/workers/status.ts
569
+ /**
570
+ * status.ts — the live per-worker status file.
571
+ *
572
+ * One JSON file per run, rewritten atomically on every turn, read by `ps`,
573
+ * `follow`, the status line and the pane logic. Same field set the Python
574
+ * runner wrote, plus `provider` (routing is multi-provider now) and `session`
575
+ * (the AIBroker identity of the launching session, see scope.ts).
576
+ */
577
+ /** Context-meter percentage 0–100, null when the numbers are missing. */
578
+ function contextPercent(s) {
579
+ if (!s.contextTokens || !s.contextWindow) return null;
580
+ return Math.round(s.contextTokens / s.contextWindow * 100);
581
+ }
582
+ /** Timestamp format shared by status files and the ledger. */
583
+ function nowStamp(d = /* @__PURE__ */ new Date()) {
584
+ const p = (n) => String(n).padStart(2, "0");
585
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
586
+ }
587
+ let lastId = "";
588
+ let idSeq = 0;
589
+ function newWorkerId(d = /* @__PURE__ */ new Date(), pid = process.pid) {
590
+ const p = (n) => String(n).padStart(2, "0");
591
+ const base = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
592
+ if (base === lastId) {
593
+ idSeq += 1;
594
+ return `${base}-${String(idSeq).padStart(2, "0")}`;
595
+ }
596
+ lastId = base;
597
+ idSeq = 0;
598
+ return base;
599
+ }
600
+ /** Write status atomically (temp + rename) and stamp `updated`. */
601
+ function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
602
+ status.updated = nowStamp(d);
603
+ const path = statusPath(logDir, status.id);
604
+ const tmp = `${path}.tmp`;
605
+ writeFileSync(tmp, JSON.stringify(status), "utf8");
606
+ renameSync(tmp, path);
607
+ }
608
+ /** Load every status file in the logDir, oldest id first, skipping damage. */
609
+ function loadStatuses(logDir) {
610
+ if (!existsSync(logDir)) return [];
611
+ const out = [];
612
+ for (const name of readdirSync(logDir).sort()) {
613
+ if (!name.endsWith(".status")) continue;
614
+ try {
615
+ out.push(JSON.parse(readFileSync(join(logDir, name), "utf8")));
616
+ } catch {}
617
+ }
618
+ return out;
619
+ }
620
+ function loadStatus(logDir, id) {
621
+ const path = statusPath(logDir, id);
622
+ if (!existsSync(path)) return null;
623
+ try {
624
+ return JSON.parse(readFileSync(path, "utf8"));
625
+ } catch {
626
+ return null;
627
+ }
628
+ }
629
+ function alive(pid) {
630
+ if (!pid || pid <= 0) return false;
631
+ try {
632
+ process.kill(pid, 0);
633
+ return true;
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
638
+ /** "42s" under 90s, "7m" above — the coarse age the table and bar show. */
639
+ function ageOf(ts, now = /* @__PURE__ */ new Date()) {
640
+ const t = Date.parse(ts.replace(" ", "T"));
641
+ if (Number.isNaN(t)) return "?";
642
+ const s = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
643
+ return s < 90 ? `${s}s` : `${Math.floor(s / 60)}m`;
644
+ }
645
+ /** One-line description of a tool call, e.g. "Bash: npm test". */
646
+ function describeTool(name, inp) {
647
+ if (typeof inp !== "object" || inp === null) return name;
648
+ const i = inp;
649
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
650
+ if (name === "Bash") return `Bash: ${shortText(get("command"), 70)}`;
651
+ if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return `${name}: ${get("file_path").split("/").pop() ?? ""}`;
652
+ if (name === "Grep" || name === "Glob") return `${name}: ${shortText(get("pattern"), 50)}`;
653
+ if (name === "WebSearch") return `${name}: ${shortText(get("query"), 50)}`;
654
+ if (name === "WebFetch") return `${name}: ${shortText(get("url"), 60)}`;
655
+ return name;
656
+ }
657
+
658
+ //#endregion
659
+ //#region src/workers/scope.ts
660
+ /**
661
+ * scope.ts — which workers "belong" to the terminal asking about them.
662
+ *
663
+ * Two identities, in priority order:
664
+ *
665
+ * 1. AIBroker's session registry (~/.aibroker/session-names.json): iTerm
666
+ * session UUID → session name. The launching session's UUID is stored in
667
+ * each worker's status file as `session.id`, so every pane of a named
668
+ * session sees its workers — regardless of tab layout.
669
+ * 2. The iTerm tab key (`w<window>t<tab>` prefix of ITERM_SESSION_ID): the
670
+ * original heuristic, kept as the fallback when no registry entry
671
+ * matches (AIBroker absent, unnamed session, non-iTerm terminal).
672
+ *
673
+ * If neither is available, viewers fall back to "all workers".
674
+ */
675
+ const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
676
+ /**
677
+ * `w<window>t<tab>` prefix of an iTerm session id, "" if empty or malformed.
678
+ * Panes split from the same tab share this prefix and differ only in `p<n>`.
679
+ */
680
+ function tabKey(term) {
681
+ if (!term) return "";
682
+ const head = term.split("p", 1)[0];
683
+ const parts = head.slice(1).split("t");
684
+ if (head.startsWith("w") && parts.length === 2 && parts.every((p) => /^\d+$/.test(p))) return head;
685
+ return "";
686
+ }
687
+ /** Tab key of the iTerm tab this process runs in, "" if not inside iTerm2. */
688
+ function currentTabKey(env = process.env) {
689
+ return tabKey(env.ITERM_SESSION_ID ?? "");
690
+ }
691
+ /** The iTerm UUID part of an ITERM_SESSION_ID (after the last colon). */
692
+ function itermUuid(term) {
693
+ if (!term) return "";
694
+ return term.split(":").pop() ?? "";
695
+ }
696
+ /**
697
+ * Resolve the AIBroker session for an ITERM_SESSION_ID. Reads the persistent
698
+ * name registry (the same store `aibroker_rename` writes); returns null when
699
+ * AIBroker is absent or the session is not in it — callers then use the tab
700
+ * key, which is the pre-AIBroker behaviour.
701
+ */
702
+ function resolveSession(term, registryPath = AIBROKER_REGISTRY) {
703
+ const uuid = itermUuid(term);
704
+ if (!uuid) return null;
705
+ let names;
706
+ try {
707
+ if (!existsSync(registryPath)) return null;
708
+ names = JSON.parse(readFileSync(registryPath, "utf8"));
709
+ } catch {
710
+ return null;
711
+ }
712
+ const name = names[uuid];
713
+ if (typeof name !== "string" || !name) return null;
714
+ return {
715
+ id: uuid,
716
+ name
717
+ };
718
+ }
719
+ /**
720
+ * True when `worker` was launched from `term` (the terminal asking about it).
721
+ * Session-id match first; the tab key only when no registry entry matched —
722
+ * a session without a name keeps the tab-scoped behaviour it always had.
723
+ */
724
+ function workerInScope(worker, term) {
725
+ if (!term) return false;
726
+ const uuid = itermUuid(term);
727
+ if (worker.session?.id && uuid) return worker.session.id === uuid;
728
+ return tabKey(worker.term) === tabKey(term) && tabKey(term) !== "";
729
+ }
730
+ /**
731
+ * Registry key for pane files: the session id when AIBroker knows this
732
+ * terminal, else the tab key, else the raw iTerm UUID.
733
+ */
734
+ function scopeKey(term) {
735
+ const session = resolveSession(term);
736
+ if (session) return session.id;
737
+ return currentTabKey() || itermUuid(term);
738
+ }
739
+ /** `[Name]` when the worker has an AIBroker session name, else "". */
740
+ function sessionTag(worker) {
741
+ return worker.session?.name ? `[${worker.session.name}]` : "";
742
+ }
743
+
744
+ //#endregion
745
+ //#region src/workers/routing.ts
746
+ /**
747
+ * routing.ts — provider selection: flag > class > active (possibly "auto").
748
+ *
749
+ * Auto-routing walks `workers.routing.order` — or the class's own `order` —
750
+ * and takes the first provider that is enabled, out of cooldown, (when it
751
+ * defines a quotaProbe) under its quotaSkipAt threshold, and — when the class
752
+ * constrains it — within `maxCostTier` and carrying all `requireTags`. A run
753
+ * that dies of a quota/rate error puts its provider in cooldown for
754
+ * cooldownMinutes; when that happens before the first tool call and
755
+ * retryOnQuota is set, the runner restarts the same task on the next provider
756
+ * (ledger: WORKER-REROUTE).
757
+ *
758
+ * An explicit --provider or a class mapping that pins a provider always
759
+ * bypasses all of this.
760
+ */
761
+ const QUOTA_SKIP_DEFAULT = 95;
762
+ const PROBE_TIMEOUT_MS = 1e4;
763
+ function readRoutingState(logDir) {
764
+ const path = routingStatePath(logDir);
765
+ if (!existsSync(path)) return { cooldowns: {} };
766
+ try {
767
+ return { cooldowns: JSON.parse(readFileSync(path, "utf8")).cooldowns ?? {} };
768
+ } catch {
769
+ return { cooldowns: {} };
770
+ }
771
+ }
772
+ function writeRoutingState(logDir, state) {
773
+ const path = routingStatePath(logDir);
774
+ const dir = dirname(path);
775
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
776
+ const tmp = `${path}.tmp`;
777
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
778
+ renameSync(tmp, path);
779
+ }
780
+ function cooldownRemaining(state, provider, now = /* @__PURE__ */ new Date()) {
781
+ const end = state.cooldowns[provider];
782
+ if (!end) return 0;
783
+ const ms = Date.parse(end) - now.getTime();
784
+ return ms > 0 ? ms : 0;
785
+ }
786
+ function setCooldown(logDir, provider, minutes, now = /* @__PURE__ */ new Date()) {
787
+ const state = readRoutingState(logDir);
788
+ state.cooldowns[provider] = new Date(now.getTime() + minutes * 6e4).toISOString();
789
+ writeRoutingState(logDir, state);
790
+ }
791
+ function clearCooldown(logDir, provider) {
792
+ const state = readRoutingState(logDir);
793
+ if (!(provider in state.cooldowns)) return;
794
+ delete state.cooldowns[provider];
795
+ writeRoutingState(logDir, state);
796
+ }
797
+ /**
798
+ * Run a provider's quotaProbe and return its percentage (0–100), or null when
799
+ * there is no probe or it printed nothing usable. A probe must never break
800
+ * routing: failures read as "unknown", not as "full".
801
+ */
802
+ function probeQuota(provider) {
803
+ if (!provider.quotaProbe) return null;
804
+ try {
805
+ const m = execFileSync("/bin/sh", ["-c", provider.quotaProbe], {
806
+ timeout: PROBE_TIMEOUT_MS,
807
+ encoding: "utf8",
808
+ stdio: [
809
+ "ignore",
810
+ "pipe",
811
+ "ignore"
812
+ ]
813
+ }).match(/(\d+(?:\.\d+)?)/);
814
+ if (!m) return null;
815
+ return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));
816
+ } catch {
817
+ return null;
818
+ }
819
+ }
820
+ function quotaSkipThreshold(provider) {
821
+ return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;
822
+ }
823
+ /** Does this provider exceed its quota threshold right now? */
824
+ function quotaExceeded(provider) {
825
+ const used = probeQuota(provider);
826
+ return used !== null && used >= quotaSkipThreshold(provider);
827
+ }
828
+ var NoProviderError = class extends WorkersConfigError {};
829
+ function mustExist(config, name) {
830
+ const p = config.providers[name];
831
+ if (!p) throw new NoProviderError(`no worker provider named "${name}". Configured: ${Object.keys(config.providers).join(", ") || "(none)"}.\nAdd one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
832
+ return p;
833
+ }
834
+ function mustBeRunnable(name, p) {
835
+ if (!p.enabled) throw new NoProviderError(`provider "${name}" is disabled. Enable it with: pai worker providers enable ${name}`);
836
+ return p;
837
+ }
838
+ /** Why one provider of a routing order did not qualify (message fragment). */
839
+ function exclusionReason(config, state, name, cls) {
840
+ const p = config.providers[name];
841
+ if (!p) return "not configured";
842
+ if (!p.enabled) return "disabled";
843
+ if (cooldownRemaining(state, name) > 0) return "cooldown";
844
+ if (quotaExceeded(p)) return "quota";
845
+ const tier = providerCostTier(p);
846
+ if (cls?.maxCostTier !== void 0 && tier > cls.maxCostTier) return `cost tier ${tier} > max ${cls.maxCostTier}`;
847
+ if (cls?.requireTags?.length) {
848
+ const have = p.tags ?? [];
849
+ const missing = cls.requireTags.filter((t) => !have.includes(t));
850
+ if (missing.length) return `missing tags: ${missing.join(", ")}`;
851
+ }
852
+ return null;
853
+ }
854
+ /**
855
+ * Resolve which provider (and model alias) a run uses.
856
+ *
857
+ * @param flagProvider --provider value, highest precedence
858
+ * @param className --class value (the old --role), looked up in workers.classes
859
+ */
860
+ function resolveTarget(config, logDir, opts = {}) {
861
+ if (opts.flagProvider) return {
862
+ providerName: opts.flagProvider,
863
+ provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),
864
+ modelAlias: null,
865
+ classMcp: null,
866
+ via: "flag"
867
+ };
868
+ const clsTarget = opts.className ? config.classes[opts.className] : void 0;
869
+ const cls = typeof clsTarget === "object" && clsTarget !== null ? clsTarget : void 0;
870
+ if (opts.className && clsTarget !== void 0 && typeof clsTarget !== "object") {
871
+ const [name, alias] = clsTarget.split("/");
872
+ return {
873
+ providerName: name,
874
+ provider: mustBeRunnable(name, mustExist(config, name)),
875
+ modelAlias: alias ?? null,
876
+ classMcp: null,
877
+ via: "class"
878
+ };
879
+ }
880
+ if (opts.className && cls?.provider) {
881
+ const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));
882
+ return {
883
+ providerName: cls.provider,
884
+ provider,
885
+ modelAlias: null,
886
+ classMcp: cls.mcp ?? null,
887
+ via: "class"
888
+ };
889
+ }
890
+ if (opts.className && clsTarget === void 0) {
891
+ 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]>`);
892
+ }
893
+ if (config.active !== "auto") {
894
+ const name = config.active;
895
+ 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>)");
896
+ return {
897
+ providerName: name,
898
+ provider: mustBeRunnable(name, mustExist(config, name)),
899
+ modelAlias: null,
900
+ classMcp: null,
901
+ via: "active"
902
+ };
903
+ }
904
+ const state = readRoutingState(logDir);
905
+ const order = cls?.order ?? config.routing.order;
906
+ const excluded = [];
907
+ for (const name of order) {
908
+ const p = config.providers[name];
909
+ const why = exclusionReason(config, state, name, cls);
910
+ if (why) {
911
+ excluded.push(`${name}: ${why}`);
912
+ continue;
913
+ }
914
+ return {
915
+ providerName: name,
916
+ provider: p,
917
+ modelAlias: null,
918
+ classMcp: cls?.mcp ?? null,
919
+ via: "auto"
920
+ };
921
+ }
922
+ 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>`);
923
+ }
924
+ /**
925
+ * Next provider after `from` in auto order, applying the same filters.
926
+ * Used by rerouting; null when the order is exhausted.
927
+ */
928
+ function nextAutoProvider(config, logDir, from, now = /* @__PURE__ */ new Date()) {
929
+ const state = readRoutingState(logDir);
930
+ const order = config.routing.order;
931
+ const start = order.indexOf(from);
932
+ for (let i = start + 1; i < order.length; i++) {
933
+ const name = order[i];
934
+ const p = config.providers[name];
935
+ if (!p || !p.enabled) continue;
936
+ const end = state.cooldowns[name];
937
+ if (end && Date.parse(end) > now.getTime()) continue;
938
+ if (quotaExceeded(p)) continue;
939
+ return name;
940
+ }
941
+ return null;
942
+ }
943
+ /**
944
+ * Was this failure a quota/rate failure? Detected from the result text the
945
+ * endpoint produced (HTTP status is not visible in the stream events).
946
+ */
947
+ function isQuotaFailure(resultText) {
948
+ return /usage limit reached|rate limit|quota/i.test(resultText);
949
+ }
950
+
951
+ //#endregion
952
+ //#region src/workers/pane.ts
953
+ /**
954
+ * pane.ts — the per-worker follow pane in iTerm2.
955
+ *
956
+ * One small-font pane per worker, stacked in a right-hand column: the first
957
+ * worker of a scope splits the launching session vertically, every further
958
+ * one splits the lowest live worker pane horizontally, so panes stack top to
959
+ * bottom. The split never sizes the new session — that would grow the whole
960
+ * window; instead the window's bounds are read before the split and restored
961
+ * right after, so the panes share the space the window already had. Each
962
+ * pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
963
+ * dynamic profile (Close Sessions On End), so panes disappear by themselves.
964
+ *
965
+ * Panes are tracked per scope (AIBroker session id, else tab key) in
966
+ * <logDir>/panes/<key>.json, keyed by iTerm session unique id, newest first —
967
+ * the split lands below the lowest live worker pane.
968
+ *
969
+ * The dynamic profile's font is the family of iTerm's DEFAULT profile (the
970
+ * `Default Bookmark Guid` entry in New Bookmarks) at workers.pane.fontSize —
971
+ * never the launching session's profile, which the Python version did and
972
+ * which produced iTerm's "unknown parent name" dialog whenever the two
973
+ * differed. `Dynamic Profile Parent Name` is only written when that name
974
+ * actually exists in New Bookmarks; when iTerm's preferences cannot be read
975
+ * the profile is still written, with font Menlo-Regular and no parent.
976
+ */
977
+ const PROFILE_NAME = "pai-worker";
978
+ /** Where the dynamic profile lives (env override keeps tests off the real one). */
979
+ function dynamicProfilePath() {
980
+ return process.env.PAI_WORKER_PROFILE ?? join(homedir(), "Library", "Application Support", "iTerm2", "DynamicProfiles", "pai-worker.json");
981
+ }
982
+ const WORKER_SPLIT_SCRIPT = `on run(argv)
983
+ set targetID to item 1 of argv
984
+ set candList to item 2 of argv
985
+ set followCmd to item 3 of argv
986
+ set profileName to item 4 of argv
987
+ tell application id "com.googlecode.iterm2"
988
+ if not running then return "notrunning"
989
+ repeat with w in windows
990
+ repeat with t in tabs of w
991
+ repeat with s in sessions of t
992
+ if id of s is targetID then
993
+ -- sizing the new session would grow the whole window:
994
+ -- pin the window's bounds now and restore them after
995
+ -- the split, so the panes share the existing space.
996
+ -- copy, never set: set stores the property
997
+ -- reference lazily, so restoring it would re-read the
998
+ -- post-split bounds instead of these — observed as the
999
+ -- window jumping to the main display
1000
+ copy bounds of w to winBounds
1001
+ set sessIDs to {}
1002
+ repeat with other in sessions of t
1003
+ set end of sessIDs to (id of other as text)
1004
+ end repeat
1005
+ set lived to {}
1006
+ set splitS to missing value
1007
+ repeat with cid in my splitIds(candList)
1008
+ set cidText to (cid as text)
1009
+ if sessIDs contains cidText then
1010
+ set end of lived to cidText
1011
+ if splitS is missing value then
1012
+ set splitS to first session of t whose id is cidText
1013
+ end if
1014
+ end if
1015
+ end repeat
1016
+ if profileName is "" then
1017
+ if splitS is missing value then
1018
+ tell s
1019
+ set newS to split vertically with default profile
1020
+ end tell
1021
+ else
1022
+ tell splitS
1023
+ set newS to split horizontally with default profile
1024
+ end tell
1025
+ end if
1026
+ else
1027
+ if splitS is missing value then
1028
+ tell s
1029
+ set newS to split vertically with profile profileName
1030
+ end tell
1031
+ else
1032
+ tell splitS
1033
+ set newS to split horizontally with profile profileName
1034
+ end tell
1035
+ end if
1036
+ end if
1037
+ tell newS
1038
+ write text followCmd
1039
+ end tell
1040
+ try
1041
+ set bounds of w to winBounds
1042
+ end try
1043
+ set out to ""
1044
+ repeat with lid in lived
1045
+ set out to out & lid & ","
1046
+ end repeat
1047
+ return out & "|" & (id of newS as text)
1048
+ end if
1049
+ end repeat
1050
+ end repeat
1051
+ end repeat
1052
+ end tell
1053
+ return "notfound"
1054
+ end run
1055
+
1056
+ on splitIds(s)
1057
+ set out to {}
1058
+ if s is "" then return out
1059
+ set prevDels to AppleScript's text item delimiters
1060
+ set AppleScript's text item delimiters to ","
1061
+ repeat with part in text items of s
1062
+ set end of out to (part as text)
1063
+ end repeat
1064
+ set AppleScript's text item delimiters to prevDels
1065
+ return out
1066
+ end splitIds`;
1067
+ const TAB_TTYS_SCRIPT = `on run(argv)
1068
+ set targetID to item 1 of argv
1069
+ tell application id "com.googlecode.iterm2"
1070
+ if not running then return "notrunning"
1071
+ repeat with w in windows
1072
+ repeat with t in tabs of w
1073
+ repeat with s in sessions of t
1074
+ if id of s is targetID then
1075
+ set ttys to {}
1076
+ repeat with other in sessions of t
1077
+ copy (tty of other) to end of ttys
1078
+ end repeat
1079
+ return ttys
1080
+ end if
1081
+ end repeat
1082
+ end repeat
1083
+ end repeat
1084
+ end tell
1085
+ return "notfound"
1086
+ end run`;
1087
+ const WINDOW_BOUNDS_SCRIPT = `on run(argv)
1088
+ set targetID to item 1 of argv
1089
+ tell application id "com.googlecode.iterm2"
1090
+ if not running then return "notrunning"
1091
+ repeat with w in windows
1092
+ repeat with t in tabs of w
1093
+ repeat with s in sessions of t
1094
+ if id of s is targetID then
1095
+ copy bounds of w to winBounds
1096
+ set prevDels to AppleScript's text item delimiters
1097
+ set AppleScript's text item delimiters to ", "
1098
+ set out to winBounds as text
1099
+ set AppleScript's text item delimiters to prevDels
1100
+ return out
1101
+ end if
1102
+ end repeat
1103
+ end repeat
1104
+ end repeat
1105
+ end tell
1106
+ return "notfound"
1107
+ end run`;
1108
+ const SPLIT_SCRIPT = `on run(argv)
1109
+ set targetID to item 1 of argv
1110
+ set followCmd to item 2 of argv
1111
+ tell application id "com.googlecode.iterm2"
1112
+ if not running then return "notrunning"
1113
+ repeat with w in windows
1114
+ repeat with t in tabs of w
1115
+ repeat with s in sessions of t
1116
+ if id of s is targetID then
1117
+ tell s
1118
+ set newS to split vertically with default profile
1119
+ end tell
1120
+ tell newS
1121
+ write text followCmd
1122
+ end tell
1123
+ return "opened"
1124
+ end if
1125
+ end repeat
1126
+ end repeat
1127
+ end repeat
1128
+ end tell
1129
+ return "notfound"
1130
+ end run`;
1131
+ /** Run an AppleScript with argv. Rejects when osascript itself cannot run. */
1132
+ function osascript(script, args) {
1133
+ return new Promise((resolve, reject) => {
1134
+ const proc = spawn("osascript", ["-", ...args], { stdio: [
1135
+ "pipe",
1136
+ "pipe",
1137
+ "pipe"
1138
+ ] });
1139
+ let out = "";
1140
+ let err = "";
1141
+ proc.stdout.on("data", (c) => out += c.toString("utf8"));
1142
+ proc.stderr.on("data", (c) => err += c.toString("utf8"));
1143
+ proc.on("error", reject);
1144
+ proc.on("close", () => resolve({
1145
+ stdout: out,
1146
+ stderr: err
1147
+ }));
1148
+ proc.stdin.write(script);
1149
+ proc.stdin.end();
1150
+ });
1151
+ }
1152
+ function psOutput$1(format) {
1153
+ try {
1154
+ return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
1155
+ } catch {
1156
+ return "";
1157
+ }
1158
+ }
1159
+ /** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
1160
+ function followTtys() {
1161
+ const ttys = /* @__PURE__ */ new Set();
1162
+ for (const line of psOutput$1("tty=,command=").split("\n")) {
1163
+ const parts = line.trim().split(/\s+/, 2);
1164
+ if (parts.length < 2 || parts[0] === "ps") continue;
1165
+ if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
1166
+ if (parts[0].startsWith("ttys")) ttys.add(`/dev/${parts[0]}`);
1167
+ }
1168
+ return ttys;
1169
+ }
1170
+ /** True while a `follow <wid>` process runs (its pane shows that worker). */
1171
+ function workerPaneOpen(wid) {
1172
+ const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
1173
+ return psOutput$1("command=").split("\n").some((ln) => pat.test(ln));
1174
+ }
1175
+ /** plutil's message for an ExecFileSync failure, else the exception text. */
1176
+ function toolErr(e) {
1177
+ const err = e;
1178
+ return ((typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e)).trim();
1179
+ }
1180
+ /**
1181
+ * Read New Bookmarks and Default Bookmark Guid from an iTerm preferences
1182
+ * plist. Key-scoped `plutil -extract`, never a whole-file JSON conversion:
1183
+ * real iTerm preferences carry `<date>` objects (SULastCheckTime & friends)
1184
+ * which `plutil -convert json` rejects outright — "Invalid object in plist
1185
+ * for JSON format" — so the whole-file read always failed and the dynamic
1186
+ * profile was never written.
1187
+ */
1188
+ function readItermPlist(plistPath) {
1189
+ let bookmarks = [];
1190
+ try {
1191
+ const out = execFileSync("plutil", [
1192
+ "-extract",
1193
+ "New Bookmarks",
1194
+ "json",
1195
+ "-o",
1196
+ "-",
1197
+ plistPath
1198
+ ], {
1199
+ encoding: "utf8",
1200
+ timeout: 1e4
1201
+ });
1202
+ const parsed = JSON.parse(out);
1203
+ if (Array.isArray(parsed)) bookmarks = parsed;
1204
+ } catch (e) {
1205
+ return {
1206
+ bookmarks: [],
1207
+ defaultGuid: null,
1208
+ error: `extracting New Bookmarks: ${toolErr(e)}`
1209
+ };
1210
+ }
1211
+ let defaultGuid = null;
1212
+ try {
1213
+ defaultGuid = execFileSync("plutil", [
1214
+ "-extract",
1215
+ "Default Bookmark Guid",
1216
+ "raw",
1217
+ "-o",
1218
+ "-",
1219
+ plistPath
1220
+ ], {
1221
+ encoding: "utf8",
1222
+ timeout: 1e4
1223
+ }).trim() || null;
1224
+ } catch {}
1225
+ return {
1226
+ bookmarks,
1227
+ defaultGuid,
1228
+ error: null
1229
+ };
1230
+ }
1231
+ /** iTerm's exported preferences, via a temp plist (no shell pipe involved). */
1232
+ function itermPrefs() {
1233
+ let dir = null;
1234
+ try {
1235
+ dir = mkdtempSync(join(tmpdir(), "pai-iterm-"));
1236
+ const plist = join(dir, "iterm2.plist");
1237
+ execFileSync("defaults", [
1238
+ "export",
1239
+ "com.googlecode.iterm2",
1240
+ plist
1241
+ ], {
1242
+ encoding: "utf8",
1243
+ timeout: 1e4
1244
+ });
1245
+ return readItermPlist(plist);
1246
+ } catch (e) {
1247
+ return {
1248
+ bookmarks: [],
1249
+ defaultGuid: null,
1250
+ error: `defaults export: ${toolErr(e)}`
1251
+ };
1252
+ } finally {
1253
+ if (dir) try {
1254
+ rmSync(dir, {
1255
+ recursive: true,
1256
+ force: true
1257
+ });
1258
+ } catch {}
1259
+ }
1260
+ }
1261
+ /** The New Bookmarks entry of that profile name, null if absent. */
1262
+ function bookmarkFrom(read, named) {
1263
+ return read.bookmarks.find((b) => b.Name === named) ?? null;
1264
+ }
1265
+ /** The bookmark iTerm marks default, null when it is missing or unreadable. */
1266
+ function defaultBookmarkFrom(read) {
1267
+ return read.defaultGuid ? read.bookmarks.find((b) => b.Guid === read.defaultGuid) ?? null : null;
1268
+ }
1269
+ /** The default profile, read fresh from iTerm's preferences. */
1270
+ function defaultBookmark() {
1271
+ return defaultBookmarkFrom(itermPrefs());
1272
+ }
1273
+ /**
1274
+ * The pane profile's font: the family of iTerm's default profile at
1275
+ * `fontSize` points ("MesloLGLNFM-Regular 18" + 13 → "MesloLGLNFM-Regular 13"),
1276
+ * or "Menlo-Regular <fontSize>" when the default font cannot be read.
1277
+ */
1278
+ function paneFont(font, fontSize) {
1279
+ const idx = (font ?? "").lastIndexOf(" ");
1280
+ if (idx > 0) {
1281
+ const family = font.slice(0, idx);
1282
+ if (!Number.isNaN(parseFloat(font.slice(idx + 1)))) return `${family} ${fontSize}`;
1283
+ }
1284
+ return `Menlo-Regular ${fontSize}`;
1285
+ }
1286
+ /** Write the pai-worker dynamic profile; iTerm2 loads that directory itself. */
1287
+ function writeDynamicProfile(parent, fontSize, read = itermPrefs) {
1288
+ const profile = {
1289
+ Name: PROFILE_NAME,
1290
+ Guid: "pai-worker-dynamic-profile",
1291
+ "Normal Font": paneFont(parent?.["Normal Font"], fontSize),
1292
+ "Close Sessions On End": true
1293
+ };
1294
+ if (parent?.Name && bookmarkFrom(read(), parent.Name)) profile["Dynamic Profile Parent Name"] = parent.Name;
1295
+ const path = dynamicProfilePath();
1296
+ const dir = dirname(path);
1297
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1298
+ const tmp = `${path}.tmp`;
1299
+ writeFileSync(tmp, JSON.stringify({ Profiles: [profile] }, null, 2) + "\n", "utf8");
1300
+ renameSync(tmp, path);
1301
+ }
1302
+ let warnedPrefs = false;
1303
+ /**
1304
+ * Profile for new worker panes, creating the small-font one on demand.
1305
+ * "" means "split with the default profile" (also the fallback when the
1306
+ * dynamic profile does not show up in time).
1307
+ *
1308
+ * The profile file is written whenever it is missing — even when iTerm's
1309
+ * preferences cannot be read; then it gets font "Menlo-Regular <fontSize>"
1310
+ * and no parent, and the reason is logged to stderr once.
1311
+ */
1312
+ async function followProfile(fontSize, read = itermPrefs) {
1313
+ const first = read();
1314
+ const parent = defaultBookmarkFrom(first);
1315
+ const path = dynamicProfilePath();
1316
+ const existed = existsSync(path);
1317
+ if (!existed) writeDynamicProfile(parent, fontSize, () => first);
1318
+ if (!parent) {
1319
+ if (!warnedPrefs) {
1320
+ warnedPrefs = true;
1321
+ const reason = first.error ?? "no profile is marked default";
1322
+ const what = existed ? `keeping ${path} as-is` : `wrote ${path} with Menlo-Regular ${fontSize} and no parent`;
1323
+ process.stderr.write(`pai worker pane: cannot read iTerm's default profile (${reason}) — ${what}\n`);
1324
+ }
1325
+ if (first.error) return "";
1326
+ }
1327
+ for (let i = 0; i < 10; i++) {
1328
+ if (bookmarkFrom(read(), PROFILE_NAME)) return PROFILE_NAME;
1329
+ await new Promise((r) => setTimeout(r, 200));
1330
+ }
1331
+ process.stderr.write(`pai worker pane: profile ${PROFILE_NAME} not visible, splitting with default\n`);
1332
+ return "";
1333
+ }
1334
+ function loadRegistry(path) {
1335
+ try {
1336
+ const reg = JSON.parse(readFileSync(path, "utf8"));
1337
+ return Array.isArray(reg) ? reg : [];
1338
+ } catch {
1339
+ return [];
1340
+ }
1341
+ }
1342
+ function saveRegistry(path, reg) {
1343
+ const dir = dirname(path);
1344
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1345
+ const tmp = `${path}.tmp`;
1346
+ writeFileSync(tmp, JSON.stringify(reg, null, 1), "utf8");
1347
+ renameSync(tmp, path);
1348
+ }
1349
+ /** Open (or only report on) the stacked follow pane of one worker. */
1350
+ async function openPaneForWorker(logDir, config, wid, term) {
1351
+ if (workerPaneOpen(wid)) return `pane for ${wid} already open`;
1352
+ const uid = itermUuid(term);
1353
+ const regPath = join(panesDir(logDir), `${scopeKey(term)}.json`);
1354
+ const reg = loadRegistry(regPath);
1355
+ const profile = await followProfile(config.pane.fontSize);
1356
+ const cmd = ` exec pai worker follow ${JSON.stringify(wid)} --auto-exit ${config.pane.autoExitSecs}`;
1357
+ const p = await osascript(WORKER_SPLIT_SCRIPT, [
1358
+ uid,
1359
+ [...reg].reverse().map((e) => e.session).join(","),
1360
+ cmd,
1361
+ profile
1362
+ ]);
1363
+ const out = p.stdout.trim();
1364
+ if (out === "notfound" || out === "notrunning") throw new Error(out === "notfound" ? "pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID" : "pai worker pane: iTerm2 is not running");
1365
+ const bar = out.indexOf("|");
1366
+ if (bar < 0) throw new Error(`pai worker pane: osascript failed: ${(p.stderr || out).slice(0, 200)}`);
1367
+ const live = new Set(out.slice(0, bar).split(",").filter(Boolean));
1368
+ const pruned = reg.filter((e) => live.has(e.session));
1369
+ pruned.push({
1370
+ session: out.slice(bar + 1),
1371
+ worker: wid,
1372
+ opened: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
1373
+ });
1374
+ saveRegistry(regPath, pruned);
1375
+ return `pane opened for ${wid}`;
1376
+ }
1377
+ /**
1378
+ * One `--check` line with the bounds of the window hosting `term`'s iTerm
1379
+ * session — the before/after pair that shows whether a split moved it.
1380
+ * Read-only; never touches the window.
1381
+ */
1382
+ async function windowBoundsLine(term) {
1383
+ const uid = itermUuid(term);
1384
+ if (!uid) return "window bounds: (not in iTerm2)";
1385
+ try {
1386
+ const p = await osascript(WINDOW_BOUNDS_SCRIPT, [uid]);
1387
+ const out = p.stdout.trim();
1388
+ if (out && out !== "notfound" && out !== "notrunning") return `window bounds: ${out}`;
1389
+ return `window bounds: (${out === "notfound" ? "iTerm2 session not found" : out === "notrunning" ? "iTerm2 not running" : (p.stderr.trim() || "no output").slice(0, 120)})`;
1390
+ } catch (e) {
1391
+ return `window bounds: (osascript: ${String(e.message ?? e).slice(0, 120)})`;
1392
+ }
1393
+ }
1394
+ /**
1395
+ * Report-only variant used by `pai worker pane <id> --check`: whether a pane
1396
+ * runs for the worker, the bounds of the window hosting the asking session,
1397
+ * plus the dynamic profile's path, its existence, and the font it contains
1398
+ * (or, when missing, would write).
1399
+ */
1400
+ async function checkPaneForWorker(wid, fontSize, term) {
1401
+ const lines = [workerPaneOpen(wid) ? `pane for ${wid} open` : `no pane for ${wid}`];
1402
+ lines.push(await windowBoundsLine(term));
1403
+ const path = dynamicProfilePath();
1404
+ if (existsSync(path)) {
1405
+ let font = "(unreadable)";
1406
+ try {
1407
+ font = JSON.parse(readFileSync(path, "utf8")).Profiles?.[0]?.["Normal Font"] ?? "(none)";
1408
+ } catch {}
1409
+ lines.push(`profile file: ${path} (exists)`);
1410
+ lines.push(`profile font: ${font}`);
1411
+ } else {
1412
+ const parent = defaultBookmark();
1413
+ lines.push(`profile file: ${path} (missing)`);
1414
+ lines.push(`profile font (would write): ${paneFont(parent?.["Normal Font"], fontSize)}`);
1415
+ }
1416
+ return lines.join("\n");
1417
+ }
1418
+ /**
1419
+ * Split this iTerm tab and run `pai worker follow` in the new pane, unless
1420
+ * one already runs here (detected by TTY — iTerm overwrites session names
1421
+ * with the running command, so names cannot serve as idempotence).
1422
+ */
1423
+ async function openFollowPane(logDir, _config, term, checkOnly) {
1424
+ const uid = itermUuid(term);
1425
+ const out = (await osascript(TAB_TTYS_SCRIPT, [uid])).stdout.trim();
1426
+ if (out === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
1427
+ if (out === "notrunning") throw new Error("pai worker pane: iTerm2 is not running");
1428
+ if ([...new Set(out.split(",").map((t) => t.trim()).filter(Boolean))].filter((t) => followTtys().has(t)).length > 0) return "follow pane already open";
1429
+ if (checkOnly) return "no follow pane";
1430
+ const s = await osascript(SPLIT_SCRIPT, [uid, " exec pai worker follow"]);
1431
+ const sout = s.stdout.trim();
1432
+ if (sout === "opened") return "follow pane opened";
1433
+ if (sout === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
1434
+ throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
1435
+ }
1436
+
1437
+ //#endregion
1438
+ //#region src/workers/report.ts
1439
+ /**
1440
+ * report.ts — the worker contract: terse, structured final reports.
1441
+ *
1442
+ * Headless workers run with an appended system prompt that fixes their output
1443
+ * shape: act, verify, then finish with ONE final message that is a JSON object
1444
+ * and nothing else. The runner parses it out of the result; the viewer renders
1445
+ * it as a compact block instead of a wall of text.
1446
+ */
1447
+ /** Appended to the caller's system prompt on every headless run (2h). */
1448
+ const WORKER_CONTRACT_PROMPT = [
1449
+ "You are a headless implementation worker, run non-interactively by an orchestrating session.",
1450
+ "No narration, no timestamps, no greetings, no summaries of what you read; act, verify, then stop.",
1451
+ "Your ONE final message is a JSON object and nothing else — no prose before or after, no code fence:",
1452
+ "{\"changed\":[{\"path\":\"…\",\"summary\":\"…\"}],\"commands\":[\"…\"],\"checks\":[{\"name\":\"…\",\"ok\":true,\"detail\":\"…\"}],\"open\":[\"…\"],\"notes\":\"one line\"}",
1453
+ "changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
1454
+ "checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
1455
+ "notes: one line, the headline a reviewer reads first."
1456
+ ].join("\n");
1457
+ /**
1458
+ * Extract the JSON report from a worker's final message. Accepts a bare
1459
+ * object, a ```json fenced block, or an object embedded in surrounding text.
1460
+ * Only objects that look like the contract count (at least one of changed /
1461
+ * checks / notes) — any other JSON falls through to null so the raw text is
1462
+ * kept as-is.
1463
+ */
1464
+ function parseWorkerReport(text) {
1465
+ const trimmed = (text ?? "").trim();
1466
+ if (!trimmed) return null;
1467
+ const candidates = [];
1468
+ const fence = trimmed.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
1469
+ if (fence) candidates.push(fence[1]);
1470
+ if (trimmed.startsWith("{")) candidates.push(trimmed);
1471
+ const first = trimmed.indexOf("{");
1472
+ const last = trimmed.lastIndexOf("}");
1473
+ if (first >= 0 && last > first) candidates.push(trimmed.slice(first, last + 1));
1474
+ for (const cand of candidates) try {
1475
+ const v = JSON.parse(cand);
1476
+ if (typeof v === "object" && v !== null && !Array.isArray(v) && looksLikeReport(v)) return v;
1477
+ } catch {}
1478
+ return null;
1479
+ }
1480
+ /** The contract's fingerprint — guards against unrelated JSON in a reply. */
1481
+ function looksLikeReport(v) {
1482
+ const o = v;
1483
+ return Array.isArray(o.changed) || Array.isArray(o.checks) || typeof o.notes === "string";
1484
+ }
1485
+ /**
1486
+ * Render a parsed report as the compact block the viewer shows: changed paths
1487
+ * with summaries, checks with ✓/✗, open items, notes. Empty report → [].
1488
+ */
1489
+ function renderReport(c, prefix, r, cwd = "") {
1490
+ const out = [];
1491
+ const has = (xs) => (xs ?? []).length > 0;
1492
+ if (has(r.changed)) {
1493
+ out.push(`${prefix}${c("bold", "changed")}`);
1494
+ for (const ch of r.changed) out.push(`${prefix} ${relShort(ch.path ?? "?", cwd)} — ${shortText(ch.summary ?? "", 90)}`);
1495
+ }
1496
+ if (has(r.checks)) {
1497
+ out.push(`${prefix}${c("bold", "checks")}`);
1498
+ for (const ck of r.checks) {
1499
+ const mark = ck.ok === false ? c("red", "✗") : c("green", "✓");
1500
+ const detail = ck.detail ? c("dim", ` · ${shortText(ck.detail, 80)}`) : "";
1501
+ out.push(`${prefix} ${mark} ${ck.name ?? "?"}${detail}`);
1502
+ }
1503
+ }
1504
+ if (has(r.commands)) {
1505
+ out.push(`${prefix}${c("bold", "commands")}`);
1506
+ for (const cmd of r.commands) out.push(`${prefix} ${c("dim", shortText(cmd, 100))}`);
1507
+ }
1508
+ if (has(r.open)) {
1509
+ out.push(`${prefix}${c("bold", "open")}`);
1510
+ for (const o of r.open) out.push(`${prefix} ${c("yellow", shortText(o, 100))}`);
1511
+ }
1512
+ if (r.notes) out.push(`${prefix}${c("bold", "notes")} ${shortText(r.notes, 120)}`);
1513
+ return out;
1514
+ }
1515
+ /** repo-relative display of a changed path when it lies under cwd. */
1516
+ function relShort(p, cwd) {
1517
+ if (!cwd) return p;
1518
+ const r = relative(cwd, p);
1519
+ return r && !r.startsWith("..") ? r : p;
1520
+ }
1521
+
1522
+ //#endregion
1523
+ //#region src/workers/mcp.ts
1524
+ /**
1525
+ * mcp.ts — the MCP allowlist for headless workers.
1526
+ *
1527
+ * Headless workers start with NO MCP servers: every server definition is a
1528
+ * prompt-time tool inventory the model pays to know, and a worker that only
1529
+ * reads and edits files needs none of it. A run may opt in with `--mcp
1530
+ * name[,name…]` (or a role carrying `"mcp": [...]`); names may be single
1531
+ * servers from ~/.claude.json's `mcpServers` or `workers.mcpSets` set names,
1532
+ * which expand to their member list. The filtered config lands in
1533
+ * `<logDir>/<id>.mcp.json` and is passed with `--strict-mcp-config
1534
+ * --mcp-config` so exactly those servers load. MCP servers are chosen at
1535
+ * launch only — a mid-run `say` cannot add any.
1536
+ */
1537
+ /** ~/.claude.json — the user's MCP server definitions (top-level mcpServers). */
1538
+ const CLAUDE_JSON = join(homedir(), ".claude.json");
1539
+ function readMcpServers(claudeJson = CLAUDE_JSON) {
1540
+ try {
1541
+ if (!existsSync(claudeJson)) return {};
1542
+ const servers = JSON.parse(readFileSync(claudeJson, "utf8")).mcpServers;
1543
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return {};
1544
+ return servers;
1545
+ } catch {
1546
+ return {};
1547
+ }
1548
+ }
1549
+ /**
1550
+ * Split a `--mcp a,b,c` flag value (also accepts repeated flags already split
1551
+ * by the caller) and expand set names from workers.mcpSets.
1552
+ */
1553
+ function expandMcpNames(names, config, claudeJson = CLAUDE_JSON) {
1554
+ const available = readMcpServers(claudeJson);
1555
+ const out = [];
1556
+ for (const raw of names) for (const name of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
1557
+ if (name in config.mcpSets) {
1558
+ for (const member of config.mcpSets[name]) if (!out.includes(member)) out.push(member);
1559
+ continue;
1560
+ }
1561
+ if (!(name in available)) {
1562
+ const sets = Object.keys(config.mcpSets);
1563
+ throw new WorkersConfigError(`unknown MCP server "${name}". Available servers: ${Object.keys(available).join(", ") || "(none in ~/.claude.json)"}${sets.length ? `; sets: ${sets.join(", ")}` : ""}`);
1564
+ }
1565
+ if (!out.includes(name)) out.push(name);
1566
+ }
1567
+ return out;
1568
+ }
1569
+ /** Path of a run's filtered MCP config. */
1570
+ function runMcpConfigPath(logDir, id) {
1571
+ return join(logDir, `${id}.mcp.json`);
1572
+ }
1573
+ /**
1574
+ * Write a config containing only `names` (already expanded) and return its
1575
+ * path. Unknown names fail fast with the available list.
1576
+ */
1577
+ function writeMcpConfig(logDir, id, names, claudeJson = CLAUDE_JSON) {
1578
+ const available = readMcpServers(claudeJson);
1579
+ const unknown = names.filter((n) => !(n in available));
1580
+ if (unknown.length) throw new WorkersConfigError(`unknown MCP server(s): ${unknown.join(", ")}. Available: ${Object.keys(available).join(", ") || "(none in ~/.claude.json)"}`);
1581
+ const servers = {};
1582
+ for (const n of names) servers[n] = available[n];
1583
+ const path = runMcpConfigPath(logDir, id);
1584
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
1585
+ writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "utf8");
1586
+ return path;
1587
+ }
1588
+ /** `pai worker mcp list`: the servers and sets a run could name. */
1589
+ function describeMcp(config, claudeJson = CLAUDE_JSON) {
1590
+ const servers = Object.keys(readMcpServers(claudeJson));
1591
+ const lines = [];
1592
+ if (servers.length) {
1593
+ lines.push(`servers (~/.claude.json):`);
1594
+ for (const s of servers) lines.push(` ${s}`);
1595
+ } else lines.push(`no MCP servers defined in ~/.claude.json`);
1596
+ const sets = Object.entries(config.mcpSets);
1597
+ if (sets.length) {
1598
+ lines.push(`sets (workers.mcpSets):`);
1599
+ for (const [name, members] of sets) lines.push(` ${name} = ${members.join(", ")}`);
1600
+ }
1601
+ lines.push(`usage: pai worker run --mcp <name>[,<name>…] -p '<task>'`);
1602
+ return lines;
1603
+ }
1604
+
1605
+ //#endregion
1606
+ //#region src/workers/operator.ts
1607
+ /**
1608
+ * operator.ts — talking to a running worker.
1609
+ *
1610
+ * Headless workers run with `--input-format stream-json` and their stdin held
1611
+ * open, so a run is a conversation: every line sent to the per-worker Unix
1612
+ * socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
1613
+ * mirrored into the transcript as an `operator` event (rendered with a »
1614
+ * marker). Once the worker has finished its turn, stdin closes 2 s later
1615
+ * unless a new message arrives — after that, `say` refuses and `resume`
1616
+ * continues the same Claude session with the worker's context intact.
1617
+ */
1618
+ function operatorSocketPath(logDir, id) {
1619
+ return join(logDir, `${id}.sock`);
1620
+ }
1621
+ /**
1622
+ * The runner's side: listen on the worker socket, hand every received line to
1623
+ * `onLine`. Returns the server (close it when the run ends; the socket file is
1624
+ * unlinked on close, best effort).
1625
+ */
1626
+ function createOperatorServer(logDir, id, onLine) {
1627
+ const path = operatorSocketPath(logDir, id);
1628
+ try {
1629
+ if (existsSync(path)) unlinkSync(path);
1630
+ } catch {}
1631
+ const server = createServer((socket) => {
1632
+ let buf = "";
1633
+ socket.on("data", (chunk) => {
1634
+ buf += chunk.toString("utf8");
1635
+ let nl;
1636
+ while ((nl = buf.indexOf("\n")) >= 0) {
1637
+ const line = buf.slice(0, nl).replace(/\r$/, "");
1638
+ buf = buf.slice(nl + 1);
1639
+ if (line.trim()) onLine(line);
1640
+ socket.write("ok\n");
1641
+ }
1642
+ });
1643
+ });
1644
+ server.listen(path);
1645
+ server.on("close", () => {
1646
+ try {
1647
+ if (existsSync(path)) unlinkSync(path);
1648
+ } catch {}
1649
+ });
1650
+ return server;
1651
+ }
1652
+ /**
1653
+ * `pai worker say <id> "<text>"`: forward one line to a running worker.
1654
+ * Resolves "ok", rejects with a clear message when the worker is not running.
1655
+ */
1656
+ function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
1657
+ const status = loadStatus(logDir, id);
1658
+ if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
1659
+ if (status.state !== "running" || !alive(status.pid)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} is not running (state: ${status.state}) — continue it instead with: pai worker resume ${id} "<text>"`));
1660
+ const path = operatorSocketPath(logDir, id);
1661
+ if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
1662
+ return new Promise((resolve, reject) => {
1663
+ const sock = connect(path);
1664
+ const fail = (e) => {
1665
+ sock.destroy();
1666
+ reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
1667
+ };
1668
+ sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
1669
+ sock.once("error", (e) => fail(e));
1670
+ sock.once("connect", () => {
1671
+ sock.write(text.replace(/\n/g, " ") + "\n");
1672
+ });
1673
+ sock.once("data", () => {
1674
+ sock.end();
1675
+ resolve("ok");
1676
+ });
1677
+ });
1678
+ }
1679
+
1680
+ //#endregion
1681
+ //#region src/workers/proxy/server.ts
1682
+ /**
1683
+ * server.ts — the PAI worker proxy: Anthropic Messages API on the front,
1684
+ * OpenAI Chat Completions on the back, loopback only.
1685
+ *
1686
+ * One proxy serves every OpenAI-protocol provider: Claude Code points
1687
+ * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
1688
+ * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
1689
+ * workers config, re-read per request so config edits apply without a
1690
+ * restart). `run` starts the proxy on demand (detached, pid file under the
1691
+ * logDir) and `pai worker proxy stop` stops it again.
1692
+ *
1693
+ * All translation lives in translate.ts; this file is only HTTP plumbing.
1694
+ */
1695
+ const DEFAULT_PROXY_PORT = 8797;
1696
+ const HOST = "127.0.0.1";
1697
+ function proxyPidPath(logDir) {
1698
+ return join(logDir, "proxy.pid");
1699
+ }
1700
+ /** True when something already accepts connections on the port. */
1701
+ function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
1702
+ return new Promise((resolve) => {
1703
+ const sock = connect({
1704
+ port,
1705
+ host: HOST
1706
+ });
1707
+ const done = (ok) => {
1708
+ sock.removeAllListeners();
1709
+ sock.destroy();
1710
+ resolve(ok);
1711
+ };
1712
+ sock.setTimeout(timeoutMs, () => done(false));
1713
+ sock.once("connect", () => done(true));
1714
+ sock.once("error", () => done(false));
1715
+ });
1716
+ }
1717
+ /**
1718
+ * Where the standalone proxy artefact lives, found from this module's own
1719
+ * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
1720
+ */
1721
+ function standaloneProxyPath() {
1722
+ let dir = dirname(fileURLToPath(import.meta.url));
1723
+ for (let i = 0; i < 6; i++) {
1724
+ for (const cand of [
1725
+ join(dir, "worker-proxy.mjs"),
1726
+ join(dir, "hooks", "worker-proxy.mjs"),
1727
+ join(dir, "dist", "hooks", "worker-proxy.mjs")
1728
+ ]) if (existsSync(cand)) return cand;
1729
+ const parent = dirname(dir);
1730
+ if (parent === dir) break;
1731
+ dir = parent;
1732
+ }
1733
+ return null;
1734
+ }
1735
+ /**
1736
+ * Ensure a proxy is listening on the port; start the detached standalone when
1737
+ * nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).
1738
+ */
1739
+ async function ensureProxyRunning(port, logDir) {
1740
+ if (await proxyListening(port)) return `http://${HOST}:${port}`;
1741
+ const script = standaloneProxyPath();
1742
+ if (!script) throw new Error(`the PAI worker proxy is not running and its build was not found — run \`bun run build\` (or start one with: pai worker proxy --port ${port})`);
1743
+ if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
1744
+ const child = spawn(process.execPath, [
1745
+ script,
1746
+ "--port",
1747
+ String(port)
1748
+ ], {
1749
+ detached: true,
1750
+ stdio: "ignore"
1751
+ });
1752
+ child.unref();
1753
+ writeFileSync(proxyPidPath(logDir), `${child.pid}\n`, "utf8");
1754
+ for (let i = 0; i < 40; i++) {
1755
+ if (await proxyListening(port)) return `http://${HOST}:${port}`;
1756
+ await new Promise((r) => setTimeout(r, 250));
1757
+ }
1758
+ throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);
1759
+ }
1760
+ /** `pai worker proxy stop`: SIGTERM the pid from the pid file. */
1761
+ function stopProxy(logDir) {
1762
+ const path = proxyPidPath(logDir);
1763
+ if (!existsSync(path)) return "no proxy pid file — nothing to stop";
1764
+ const pid = parseInt(readFileSync(path, "utf8").trim(), 10);
1765
+ unlinkSync(path);
1766
+ if (!Number.isFinite(pid)) return "stale proxy pid file removed";
1767
+ try {
1768
+ process.kill(pid, "SIGTERM");
1769
+ } catch {
1770
+ return `proxy pid ${pid} was not running (pid file removed)`;
1771
+ }
1772
+ return `proxy pid ${pid} stopped`;
1773
+ }
1774
+
1775
+ //#endregion
1776
+ //#region src/workers/codex.ts
1777
+ /**
1778
+ * codex.ts — the "codex" runner engine.
1779
+ *
1780
+ * A ChatGPT plan gives no API key, only Codex CLI access, so a provider may
1781
+ * set `engine: "codex"`: `run` then shells out to `codex exec --json <prompt>`
1782
+ * (non-interactive) instead of Claude Code. The JSONL event stream is parsed
1783
+ * into the same status-file fields (turns, tools, last) and ledger lines as a
1784
+ * Claude run, and is normalised into claude-code-shaped events in the
1785
+ * worker's .jsonl so follow/replay/the pane render it unchanged.
1786
+ *
1787
+ * Implemented against the documented `codex exec --json` interface
1788
+ * (thread.started / item.completed / turn.completed / turn.failed lines);
1789
+ * verify against the installed CLI when one is present.
1790
+ * `--allowedTools` has no Codex equivalent and is dropped (ledgered).
1791
+ */
1792
+ /** Build the codex exec argument vector (without the binary itself). */
1793
+ function buildCodexArgs(prompt, model) {
1794
+ return [
1795
+ "exec",
1796
+ "--json",
1797
+ "--skip-git-repo-check",
1798
+ ...model ? ["-m", model] : [],
1799
+ "--",
1800
+ prompt
1801
+ ];
1802
+ }
1803
+ /** Env for a codex run: caller's env, Anthropic vars stripped, key applied. */
1804
+ function buildCodexEnv(provider) {
1805
+ const env = { ...process.env };
1806
+ delete env.ANTHROPIC_API_KEY;
1807
+ delete env.ANTHROPIC_BASE_URL;
1808
+ delete env.ANTHROPIC_AUTH_TOKEN;
1809
+ delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
1810
+ delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
1811
+ delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
1812
+ const keyPath = providerKeyPath(provider);
1813
+ if (keyPath && existsSync(keyPath)) env.OPENAI_API_KEY = readFileSync(keyPath, "utf8").trim();
1814
+ if (provider.upstreamUrl) env.OPENAI_BASE_URL = provider.upstreamUrl;
1815
+ for (const [k, v] of Object.entries(provider.env)) env[k] = v;
1816
+ env.PAI_WORKER = "1";
1817
+ return env;
1818
+ }
1819
+ /** Codex "not applicable" flags the runner drops with a ledger note. */
1820
+ function codexDroppedFlags(argv) {
1821
+ const dropped = [];
1822
+ for (let i = 0; i < argv.length; i++) {
1823
+ const a = argv[i];
1824
+ if (a === "--allowedTools" || a === "--disallowedTools" || a === "--mcp-config" || a === "--mcp") {
1825
+ dropped.push(a);
1826
+ if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) i++;
1827
+ } else if (a.startsWith("--allowedTools=") || a.startsWith("--mcp-config=")) dropped.push(a.split("=")[0]);
1828
+ }
1829
+ return dropped;
1830
+ }
1831
+ const emptyCodexResult = () => ({
1832
+ events: [],
1833
+ turns: 0,
1834
+ tools: 0,
1835
+ last: null,
1836
+ isError: false,
1837
+ contextTokens: null,
1838
+ finalText: null,
1839
+ threadId: null
1840
+ });
1841
+ /**
1842
+ * Fold one parsed `codex exec --json` line into the running result: appends
1843
+ * normalised transcript events and updates the counters in place.
1844
+ */
1845
+ function foldCodexLine(line, r) {
1846
+ if (typeof line !== "object" || line === null) return;
1847
+ const e = line;
1848
+ const type = typeof e.type === "string" ? e.type : "";
1849
+ if (type === "thread.started" && typeof e.thread_id === "string") {
1850
+ r.threadId = e.thread_id;
1851
+ return;
1852
+ }
1853
+ if (type === "item.completed" && typeof e.item === "object" && e.item !== null) {
1854
+ const item = e.item;
1855
+ const kind = typeof item.type === "string" ? item.type : "";
1856
+ if (kind === "agent_message" && typeof item.text === "string") {
1857
+ r.turns += 1;
1858
+ r.last = `says: ${item.text.slice(0, 70)}`;
1859
+ r.finalText = item.text;
1860
+ r.events.push({
1861
+ type: "assistant",
1862
+ message: { content: [{
1863
+ type: "text",
1864
+ text: item.text
1865
+ }] }
1866
+ });
1867
+ } else if (kind === "command_execution") {
1868
+ r.tools += 1;
1869
+ const cmd = typeof item.command === "string" ? item.command : "?";
1870
+ const rc = typeof item.exit_code === "number" ? item.exit_code : 0;
1871
+ r.last = `Bash: ${cmd.slice(0, 70)}`;
1872
+ r.events.push({
1873
+ type: "assistant",
1874
+ message: { content: [{
1875
+ type: "tool_use",
1876
+ name: "Bash",
1877
+ input: { command: cmd }
1878
+ }] }
1879
+ });
1880
+ r.events.push({
1881
+ type: "user",
1882
+ message: { content: [{
1883
+ type: "tool_result",
1884
+ tool_use_id: "",
1885
+ is_error: rc !== 0,
1886
+ content: item.aggregated_output ?? ""
1887
+ }] }
1888
+ });
1889
+ } else if (kind === "file_change") {
1890
+ r.tools += 1;
1891
+ const files = (Array.isArray(item.changes) ? item.changes : []).map((c) => typeof c === "object" && c !== null && typeof c.path === "string" ? c.path : "?").join(", ");
1892
+ r.last = `files: ${files.slice(0, 70)}`;
1893
+ r.events.push({
1894
+ type: "assistant",
1895
+ message: { content: [{
1896
+ type: "tool_use",
1897
+ name: "Write",
1898
+ input: { file_path: files }
1899
+ }] }
1900
+ });
1901
+ } else if (kind === "mcp_tool_call") {
1902
+ r.tools += 1;
1903
+ r.last = `mcp: ${String(item.tool ?? "?")}`;
1904
+ }
1905
+ return;
1906
+ }
1907
+ if (type === "turn.completed") {
1908
+ const usage = typeof e.usage === "object" && e.usage !== null ? e.usage : {};
1909
+ const tokens = (usage.input_tokens ?? 0) + (usage.cached_input_tokens ?? 0) + (usage.output_tokens ?? 0);
1910
+ if (tokens > 0) r.contextTokens = tokens;
1911
+ return;
1912
+ }
1913
+ if (type === "turn.failed" || type === "error") {
1914
+ r.isError = true;
1915
+ const err = typeof e.error === "object" && e.error !== null ? e.error : {};
1916
+ r.last = String(err.message ?? e.message ?? "codex turn failed");
1917
+ }
1918
+ }
1919
+ /** Parse one JSONL line; null for blanks and non-JSON noise. */
1920
+ function parseCodexLine(line) {
1921
+ const t = line.trim();
1922
+ if (!t.startsWith("{")) return null;
1923
+ try {
1924
+ return JSON.parse(t);
1925
+ } catch {
1926
+ return null;
1927
+ }
1928
+ }
1929
+ /** True when the Codex CLI is on PATH (test reports "not installed" else). */
1930
+ function codexInstalled() {
1931
+ try {
1932
+ execFileSync("codex", ["--version"], {
1933
+ timeout: 5e3,
1934
+ stdio: "ignore"
1935
+ });
1936
+ return true;
1937
+ } catch {
1938
+ return false;
1939
+ }
1940
+ }
1941
+
1942
+ //#endregion
1943
+ //#region src/workers/run.ts
1944
+ /**
1945
+ * run.ts — the worker runner (port of the glm / glm-run pair, provider-neutral).
1946
+ *
1947
+ * One claude-code process per call, pointed at the chosen provider:
1948
+ *
1949
+ * - env: ANTHROPIC_BASE_URL/AUTH_TOKEN from the provider (token from its
1950
+ * key file, never from the environment), the three DEFAULT_*_MODEL vars,
1951
+ * the provider's extra env, nonessential traffic off, and ANTHROPIC_API_KEY
1952
+ * stripped so nothing can fall back to Anthropic billing. OpenAI-protocol
1953
+ * providers point at the PAI proxy instead (started on demand, the
1954
+ * provider name in the URL path); codex-engine providers run the Codex CLI.
1955
+ * - headless (-p): strict empty MCP config unless the caller brings one or
1956
+ * names servers via --mcp / a role (then a filtered <id>.mcp.json),
1957
+ * PAI_WORKER=1 so PAI's per-session hooks leave it alone, the worker
1958
+ * contract appended to the system prompt, `--input-format stream-json`
1959
+ * with the prompt as the first stdin user message (the operator socket
1960
+ * can add more mid-run), stream-json mirroring (every line stamped `_ts`)
1961
+ * into <logDir>/<id>.jsonl, a live <id>.status for ps/follow/status line
1962
+ * (context meter included), ledger lines, result printed in the caller's
1963
+ * --output-format (json adds the parsed `report`), and the follow pane
1964
+ * (unless --no-pane).
1965
+ * - interactive: no MCP restriction, no pane, ENABLE_TOOL_SEARCH=true.
1966
+ *
1967
+ * Auto-routed runs that die of a quota error before the first tool call are
1968
+ * restarted on the next provider in routing order (WORKER-REROUTE ledger line).
1969
+ */
1970
+ /** Environment for a run through `provider`. Caller's env minus the Anthropic key. */
1971
+ function buildRunEnv(provider, headless, proxyUrl) {
1972
+ const env = { ...process.env };
1973
+ delete env.ANTHROPIC_API_KEY;
1974
+ let token = "local";
1975
+ if (proxyUrl) env.ANTHROPIC_BASE_URL = proxyUrl;
1976
+ else {
1977
+ const keyPath = providerKeyPath(provider);
1978
+ if (keyPath) {
1979
+ try {
1980
+ token = readFileSync(keyPath, "utf8").trim();
1981
+ } catch {
1982
+ throw new Error(`key file not readable: ${keyPath}`);
1983
+ }
1984
+ if (!token) throw new Error(`key file is empty: ${keyPath}`);
1985
+ }
1986
+ env.ANTHROPIC_BASE_URL = provider.baseUrl;
1987
+ }
1988
+ env.ANTHROPIC_AUTH_TOKEN = token;
1989
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.models.fast ?? provider.models.default;
1990
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.models.default;
1991
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.models.default;
1992
+ for (const [k, v] of Object.entries(provider.env)) env[k] = v;
1993
+ env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
1994
+ if (headless) env.PAI_WORKER = "1";
1995
+ else env.ENABLE_TOOL_SEARCH = "true";
1996
+ return env;
1997
+ }
1998
+ /** The strict empty MCP config for headless workers, written on demand. */
1999
+ function ensureNoMcpConfig(logDir) {
2000
+ const path = noMcpConfigPath(logDir);
2001
+ if (!existsSync(path)) {
2002
+ mkdirSync(logDir, { recursive: true });
2003
+ writeFileSync(path, "{ \"mcpServers\": {} }\n", "utf8");
2004
+ }
2005
+ return path;
2006
+ }
2007
+ /** A stream-json user message for the child's stdin. */
2008
+ function stdinUserMessage(text) {
2009
+ return JSON.stringify({
2010
+ type: "user",
2011
+ message: {
2012
+ role: "user",
2013
+ content: text
2014
+ }
2015
+ });
2016
+ }
2017
+ /**
2018
+ * ISO stamp with seconds, attached to every mirrored event (2g): local time
2019
+ * with its offset (`2026-09-17T14:19:23+02:00`), so the viewer can render the
2020
+ * wall clock the operator lives in. `offMin` is east-positive minutes —
2021
+ * injectable so tests do not depend on the machine's zone.
2022
+ */
2023
+ function isoStamp(d = /* @__PURE__ */ new Date(), offMin = -d.getTimezoneOffset()) {
2024
+ const t = new Date(d.getTime() + offMin * 6e4);
2025
+ const sign = offMin < 0 ? "-" : "+";
2026
+ const abs = Math.abs(offMin);
2027
+ const hh = String(Math.floor(abs / 60)).padStart(2, "0");
2028
+ const mm = String(abs % 60).padStart(2, "0");
2029
+ return `${t.toISOString().slice(0, 19)}${sign}${hh}:${mm}`;
2030
+ }
2031
+ /** Context tokens of an assistant/result usage block (input+cache+output). */
2032
+ function usageContextTokens(u) {
2033
+ if (!u) return null;
2034
+ const t = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0);
2035
+ return t > 0 ? t : null;
2036
+ }
2037
+ /** Context window announced by the init event, when the endpoint sends one. */
2038
+ function initContextWindow(e) {
2039
+ if (typeof e.context_window === "number" && e.context_window > 0) return e.context_window;
2040
+ if (e.model_info && typeof e.model_info.context_window === "number" && e.model_info.context_window > 0) return e.model_info.context_window;
2041
+ return null;
2042
+ }
2043
+ /**
2044
+ * Run one worker. Returns the process exit code to pass through.
2045
+ * Throws WorkersConfigError-shaped Errors for configuration problems.
2046
+ */
2047
+ async function runWorker(opts) {
2048
+ const { raw: _raw, workers: config } = readWorkersSection();
2049
+ if (!config.enabled) throw new Error("workers are off. Turn them on with: pai worker on\n(then the Agent-tool hook stops denying Anthropic subagents only when you do)");
2050
+ const logDir = workersLogDir(config);
2051
+ mkdirSync(logDir, { recursive: true });
2052
+ const target = resolveTarget(config, logDir, {
2053
+ flagProvider: opts.providerFlag,
2054
+ className: opts.className
2055
+ });
2056
+ assertProviderRunnable(target.providerName, target.provider);
2057
+ const parsed = parseRunnerArgs(opts.claudeArgs);
2058
+ const label = opts.label ?? shortText(parsed.prompt ?? "(no prompt)", 70);
2059
+ const model = opts.modelFlag ?? (target.modelAlias === "fast" ? target.provider.models.fast ?? target.provider.models.default : target.provider.models.default);
2060
+ try {
2061
+ if (target.provider.engine === "codex") return await executeCodexRun({
2062
+ config,
2063
+ logDir,
2064
+ target,
2065
+ model,
2066
+ label,
2067
+ parsed,
2068
+ claudeArgs: opts.claudeArgs,
2069
+ noPane: opts.noPane ?? false,
2070
+ cwd: opts.cwd,
2071
+ parent: opts.parent,
2072
+ stage: opts.stage,
2073
+ quiet: opts.quiet,
2074
+ onWorkerStart: opts.onWorkerStart
2075
+ });
2076
+ return await executeRun({
2077
+ config,
2078
+ logDir,
2079
+ target,
2080
+ model,
2081
+ label,
2082
+ parsed,
2083
+ claudeArgs: opts.claudeArgs,
2084
+ noPane: opts.noPane ?? false,
2085
+ mcpFlag: opts.mcpFlag,
2086
+ cwd: opts.cwd,
2087
+ parent: opts.parent,
2088
+ stage: opts.stage,
2089
+ quiet: opts.quiet,
2090
+ onWorkerStart: opts.onWorkerStart,
2091
+ reroutes: opts._reroutes ?? 0
2092
+ });
2093
+ } catch (e) {
2094
+ if (e instanceof Error && e.message.startsWith("key file")) throw new Error(`provider "${target.providerName}": ${e.message}\nPut the token in that file (chmod 600) or point keyFile elsewhere.`);
2095
+ throw e;
2096
+ }
2097
+ }
2098
+ async function executeRun(a) {
2099
+ const { config, logDir, target, model, label, parsed, noPane } = a;
2100
+ const headless = parsed.headless;
2101
+ let proxyUrl;
2102
+ if (target.provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${target.providerName}`;
2103
+ const env = buildRunEnv(target.provider, headless, proxyUrl);
2104
+ const wid = newWorkerId();
2105
+ const cwd = a.cwd ?? process.cwd();
2106
+ const term = process.env.ITERM_SESSION_ID ?? "";
2107
+ const session = resolveSession(term);
2108
+ const status = {
2109
+ id: wid,
2110
+ pid: process.pid,
2111
+ label,
2112
+ cwd,
2113
+ term,
2114
+ provider: target.providerName,
2115
+ model,
2116
+ state: "running",
2117
+ started: nowStamp(),
2118
+ updated: nowStamp(),
2119
+ turns: 0,
2120
+ tools: 0,
2121
+ last: headless ? "starting" : "interactive",
2122
+ rc: null,
2123
+ secs: null,
2124
+ ...session ? { session } : {},
2125
+ contextWindow: providerContextWindow(target.provider),
2126
+ ...a.parent ? {
2127
+ parent: a.parent,
2128
+ stage: a.stage
2129
+ } : {}
2130
+ };
2131
+ saveStatus(logDir, status);
2132
+ a.onWorkerStart?.(wid);
2133
+ const ledger = ledgerPath(logDir);
2134
+ appendLedger(ledger, "WORKER-START", {
2135
+ id: wid,
2136
+ provider: target.providerName,
2137
+ mode: headless ? "headless" : "interactive",
2138
+ model,
2139
+ cwd,
2140
+ label
2141
+ });
2142
+ if (headless && !noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
2143
+ let mcpArgs = [];
2144
+ if (headless && !parsed.callerMcpConfig) {
2145
+ const wanted = [
2146
+ ...a.mcpFlag ? [a.mcpFlag] : [],
2147
+ ...parsed.mcp,
2148
+ ...target.classMcp ?? []
2149
+ ];
2150
+ if (wanted.length) mcpArgs = [
2151
+ "--strict-mcp-config",
2152
+ "--mcp-config",
2153
+ writeMcpConfig(logDir, wid, expandMcpNames(wanted, config))
2154
+ ];
2155
+ else mcpArgs = [
2156
+ "--strict-mcp-config",
2157
+ "--mcp-config",
2158
+ ensureNoMcpConfig(logDir)
2159
+ ];
2160
+ }
2161
+ const restArgs = headless ? stripPromptValues(parsed.rest) : parsed.rest;
2162
+ const cmd = ["claude"];
2163
+ if (!parsed.callerModel) cmd.push("--model", model);
2164
+ cmd.push(...mcpArgs, ...restArgs);
2165
+ if (headless) {
2166
+ cmd.push("--output-format", "stream-json", "--verbose", "--input-format", "stream-json");
2167
+ if (!parsed.callerSystemPrompt) cmd.push("--append-system-prompt", WORKER_CONTRACT_PROMPT);
2168
+ }
2169
+ const t0 = Date.now();
2170
+ const proc = spawn(cmd[0], cmd.slice(1), {
2171
+ env,
2172
+ cwd,
2173
+ stdio: headless ? [
2174
+ "pipe",
2175
+ "pipe",
2176
+ "inherit"
2177
+ ] : "inherit"
2178
+ });
2179
+ let operatorInFlight = 0;
2180
+ let closeTimer = null;
2181
+ const armStdinClose = () => {
2182
+ if (closeTimer) clearTimeout(closeTimer);
2183
+ closeTimer = setTimeout(() => {
2184
+ if (operatorInFlight === 0) try {
2185
+ proc.stdin?.end();
2186
+ } catch {}
2187
+ }, 2e3);
2188
+ };
2189
+ let eventsFd = null;
2190
+ const writeEvent = (obj) => {
2191
+ if (eventsFd === null) return;
2192
+ try {
2193
+ writeSync(eventsFd, JSON.stringify({
2194
+ ...obj,
2195
+ _ts: isoStamp()
2196
+ }) + "\n");
2197
+ } catch {}
2198
+ };
2199
+ const operatorServer = headless ? createOperatorServer(logDir, wid, (text) => {
2200
+ operatorInFlight += 1;
2201
+ if (closeTimer) {
2202
+ clearTimeout(closeTimer);
2203
+ closeTimer = null;
2204
+ }
2205
+ writeEvent({
2206
+ type: "operator",
2207
+ text
2208
+ });
2209
+ try {
2210
+ proc.stdin?.write(stdinUserMessage(text) + "\n");
2211
+ } catch {}
2212
+ }) : null;
2213
+ let killed = false;
2214
+ const cleanup = () => {
2215
+ if (closeTimer) clearTimeout(closeTimer);
2216
+ operatorServer?.close();
2217
+ };
2218
+ const onSignal = (sig) => {
2219
+ killed = true;
2220
+ status.state = "killed";
2221
+ status.rc = 143;
2222
+ status.secs = Math.floor((Date.now() - t0) / 1e3);
2223
+ status.last = `killed by signal ${sig}`;
2224
+ saveStatus(logDir, status);
2225
+ appendLedger(ledger, "WORKER-END", {
2226
+ id: wid,
2227
+ provider: target.providerName,
2228
+ mode: "headless",
2229
+ model,
2230
+ rc: 143,
2231
+ secs: status.secs,
2232
+ killed: 1,
2233
+ label
2234
+ });
2235
+ cleanup();
2236
+ try {
2237
+ proc.kill();
2238
+ } catch {}
2239
+ process.exit(143);
2240
+ };
2241
+ process.once("SIGTERM", () => onSignal("SIGTERM"));
2242
+ process.once("SIGINT", () => onSignal("SIGINT"));
2243
+ const ctx = {
2244
+ resultEvent: null,
2245
+ resultReport: null
2246
+ };
2247
+ if (headless) {
2248
+ if (parsed.prompt !== null) try {
2249
+ proc.stdin.write(stdinUserMessage(parsed.prompt) + "\n");
2250
+ } catch {}
2251
+ eventsFd = openSync(eventsPath(logDir, wid), "a");
2252
+ createInterface({ input: proc.stdout }).on("line", (line) => {
2253
+ if (parsed.outputFormat === "stream-json") process.stdout.write(line + "\n");
2254
+ if (!line.startsWith("{")) return;
2255
+ let e;
2256
+ try {
2257
+ e = JSON.parse(line);
2258
+ } catch {
2259
+ return;
2260
+ }
2261
+ writeEvent(e);
2262
+ if (e.type === "system" && e.subtype === "init") {
2263
+ if (e.session_id) status.claudeSession = e.session_id;
2264
+ const cw = initContextWindow(e);
2265
+ if (cw) status.contextWindow = cw;
2266
+ saveStatus(logDir, status);
2267
+ } else if (e.type === "assistant") {
2268
+ status.turns += 1;
2269
+ const tokens = usageContextTokens(e.message?.usage);
2270
+ if (tokens) status.contextTokens = tokens;
2271
+ for (const block of e.message?.content ?? []) if (block.type === "tool_use") {
2272
+ status.tools += 1;
2273
+ status.last = describeTool(block.name ?? "?", block.input);
2274
+ } else if (block.type === "text" && (block.text ?? "").trim()) status.last = "says: " + shortText(block.text, 70);
2275
+ saveStatus(logDir, status);
2276
+ } else if (e.type === "result") {
2277
+ const tokens = usageContextTokens(e.usage);
2278
+ if (tokens) status.contextTokens = tokens;
2279
+ const report = parseWorkerReport(e.result ?? "");
2280
+ if (report?.notes) status.last = shortText(report.notes, 90);
2281
+ else if (e.result) status.last = shortText(e.result, 90);
2282
+ saveStatus(logDir, status);
2283
+ operatorInFlight = 0;
2284
+ armStdinClose();
2285
+ ctx.resultEvent = e;
2286
+ ctx.resultReport = report;
2287
+ }
2288
+ });
2289
+ }
2290
+ const rc = await new Promise((resolve, reject) => {
2291
+ proc.on("error", reject);
2292
+ proc.on("close", (code) => resolve(code ?? (killed ? 143 : 1)));
2293
+ });
2294
+ if (eventsFd !== null) closeSync(eventsFd);
2295
+ cleanup();
2296
+ const secs = Math.floor((Date.now() - t0) / 1e3);
2297
+ const resultEvent = ctx.resultEvent;
2298
+ const ok = rc === 0 && resultEvent !== null && !resultEvent.is_error;
2299
+ status.state = ok ? "done" : "failed";
2300
+ status.rc = rc;
2301
+ status.secs = secs;
2302
+ if (resultEvent) status.last = shortText(resultEvent.result ?? "", 90);
2303
+ if (ctx.resultReport?.notes) status.last = shortText(ctx.resultReport.notes, 90);
2304
+ saveStatus(logDir, status);
2305
+ appendLedger(ledger, "WORKER-END", {
2306
+ id: wid,
2307
+ provider: target.providerName,
2308
+ mode: headless ? "headless" : "interactive",
2309
+ model,
2310
+ rc,
2311
+ secs,
2312
+ turns: status.turns,
2313
+ tools: status.tools,
2314
+ label
2315
+ });
2316
+ if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport);
2317
+ const resultText = resultEvent?.result ?? "";
2318
+ if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
2319
+ setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
2320
+ const next = nextAutoProvider(config, logDir, target.providerName);
2321
+ if (next && a.reroutes < config.routing.order.length) {
2322
+ appendLedger(ledger, "WORKER-REROUTE", {
2323
+ from: target.providerName,
2324
+ to: next,
2325
+ reason: "quota"
2326
+ });
2327
+ return runWorker({
2328
+ providerFlag: next,
2329
+ label,
2330
+ noPane: a.noPane,
2331
+ mcpFlag: a.mcpFlag,
2332
+ cwd: a.cwd,
2333
+ parent: a.parent,
2334
+ stage: a.stage,
2335
+ claudeArgs: a.claudeArgs,
2336
+ onWorkerStart: a.onWorkerStart,
2337
+ _reroutes: a.reroutes + 1
2338
+ });
2339
+ }
2340
+ }
2341
+ return rc !== 0 ? rc : ok ? 0 : 1;
2342
+ }
2343
+ async function executeCodexRun(a) {
2344
+ const { config, logDir, target, model, label, parsed, noPane } = a;
2345
+ if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine codex) supports headless runs only: pass the task with -p '<prompt>'`);
2346
+ const env = buildCodexEnv(target.provider);
2347
+ const wid = newWorkerId();
2348
+ const cwd = a.cwd ?? process.cwd();
2349
+ const term = process.env.ITERM_SESSION_ID ?? "";
2350
+ const session = resolveSession(term);
2351
+ const status = {
2352
+ id: wid,
2353
+ pid: process.pid,
2354
+ label,
2355
+ cwd,
2356
+ term,
2357
+ provider: target.providerName,
2358
+ model,
2359
+ state: "running",
2360
+ started: nowStamp(),
2361
+ updated: nowStamp(),
2362
+ turns: 0,
2363
+ tools: 0,
2364
+ last: "starting",
2365
+ rc: null,
2366
+ secs: null,
2367
+ ...session ? { session } : {},
2368
+ contextWindow: providerContextWindow(target.provider),
2369
+ ...a.parent ? {
2370
+ parent: a.parent,
2371
+ stage: a.stage
2372
+ } : {}
2373
+ };
2374
+ saveStatus(logDir, status);
2375
+ a.onWorkerStart?.(wid);
2376
+ const ledger = ledgerPath(logDir);
2377
+ appendLedger(ledger, "WORKER-START", {
2378
+ id: wid,
2379
+ provider: target.providerName,
2380
+ mode: "headless",
2381
+ engine: "codex",
2382
+ model,
2383
+ cwd,
2384
+ label
2385
+ });
2386
+ const dropped = codexDroppedFlags(a.claudeArgs);
2387
+ if (dropped.length) appendLedger(ledger, "WORKER-NOTE", {
2388
+ id: wid,
2389
+ note: `dropped for codex: ${dropped.join(", ")}`
2390
+ });
2391
+ if (!noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
2392
+ const t0 = Date.now();
2393
+ const proc = spawn("codex", buildCodexArgs(parsed.prompt, parsed.callerModel ? void 0 : model), {
2394
+ env,
2395
+ cwd,
2396
+ stdio: [
2397
+ "ignore",
2398
+ "pipe",
2399
+ "inherit"
2400
+ ]
2401
+ });
2402
+ const fold = emptyCodexResult();
2403
+ const eventsFd = openSync(eventsPath(logDir, wid), "a");
2404
+ const writeEvent = (obj) => {
2405
+ try {
2406
+ writeSync(eventsFd, JSON.stringify({
2407
+ ...obj,
2408
+ _ts: isoStamp()
2409
+ }) + "\n");
2410
+ } catch {}
2411
+ };
2412
+ writeEvent({
2413
+ type: "system",
2414
+ subtype: "init",
2415
+ model,
2416
+ cwd
2417
+ });
2418
+ let killed = false;
2419
+ process.once("SIGTERM", onCodexSignal("SIGTERM"));
2420
+ process.once("SIGINT", onCodexSignal("SIGINT"));
2421
+ function onCodexSignal(sig) {
2422
+ return () => {
2423
+ killed = true;
2424
+ status.state = "killed";
2425
+ status.rc = 143;
2426
+ status.secs = Math.floor((Date.now() - t0) / 1e3);
2427
+ status.last = `killed by signal ${sig}`;
2428
+ saveStatus(logDir, status);
2429
+ try {
2430
+ proc.kill();
2431
+ } catch {}
2432
+ process.exit(143);
2433
+ };
2434
+ }
2435
+ createInterface({ input: proc.stdout }).on("line", (line) => {
2436
+ if (parsed.outputFormat === "stream-json") process.stdout.write(line + "\n");
2437
+ const parsedLine = parseCodexLine(line);
2438
+ if (parsedLine === null) return;
2439
+ foldCodexLine(parsedLine, fold);
2440
+ if (fold.threadId && !status.claudeSession) status.claudeSession = fold.threadId;
2441
+ status.turns = fold.turns;
2442
+ status.tools = fold.tools;
2443
+ if (fold.last) status.last = shortText(fold.last, 90);
2444
+ if (fold.contextTokens) status.contextTokens = fold.contextTokens;
2445
+ saveStatus(logDir, status);
2446
+ for (const ev of fold.events.splice(0)) writeEvent(ev);
2447
+ });
2448
+ const rc = await new Promise((resolve, reject) => {
2449
+ proc.on("error", reject);
2450
+ proc.on("close", (code) => resolve(code ?? (killed ? 143 : 1)));
2451
+ });
2452
+ closeSync(eventsFd);
2453
+ const secs = Math.floor((Date.now() - t0) / 1e3);
2454
+ const finalText = fold.finalText ?? "";
2455
+ const report = parseWorkerReport(finalText);
2456
+ const resultEvent = {
2457
+ type: "result",
2458
+ result: finalText,
2459
+ is_error: fold.isError || rc !== 0,
2460
+ num_turns: fold.turns,
2461
+ duration_ms: secs * 1e3
2462
+ };
2463
+ writeEvent(resultEvent);
2464
+ const ok = rc === 0 && !fold.isError;
2465
+ status.state = ok ? "done" : "failed";
2466
+ status.rc = rc;
2467
+ status.secs = secs;
2468
+ status.last = shortText(report?.notes ?? finalText, 90) || (ok ? "done" : "failed");
2469
+ saveStatus(logDir, status);
2470
+ appendLedger(ledger, "WORKER-END", {
2471
+ id: wid,
2472
+ provider: target.providerName,
2473
+ mode: "headless",
2474
+ engine: "codex",
2475
+ model,
2476
+ rc,
2477
+ secs,
2478
+ turns: status.turns,
2479
+ tools: status.tools,
2480
+ label
2481
+ });
2482
+ if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report);
2483
+ return rc !== 0 ? rc : ok ? 0 : 1;
2484
+ }
2485
+ function printResult(fmt, resultEvent, rc, logDir, wid, report) {
2486
+ if (fmt === "stream-json") return;
2487
+ if (fmt === "json") {
2488
+ const payload = report ? {
2489
+ ...resultEvent ?? {
2490
+ is_error: true,
2491
+ result: "no result event",
2492
+ rc
2493
+ },
2494
+ report
2495
+ } : resultEvent ?? {
2496
+ is_error: true,
2497
+ result: "no result event",
2498
+ rc
2499
+ };
2500
+ console.log(JSON.stringify(payload));
2501
+ return;
2502
+ }
2503
+ if (resultEvent) console.log(resultEvent.result ?? "");
2504
+ else process.stderr.write(`pai worker: run produced no result (rc=${rc}); see ${eventsPath(logDir, wid)}\n`);
2505
+ }
2506
+ /**
2507
+ * Run a one-word pong probe through the provider (headless, no pane) and
2508
+ * report provider, model, latency and the reply. ok is false unless the reply
2509
+ * was exactly "pong" (case-insensitive, whitespace-trimmed).
2510
+ */
2511
+ async function testProvider(providerName, provider, logDir, timeoutMs = 9e4) {
2512
+ assertProviderRunnable(providerName, provider);
2513
+ const model = provider.models.default;
2514
+ if (provider.engine === "codex") {
2515
+ const skipped = codexInstalled() ? void 0 : "codex not installed";
2516
+ return {
2517
+ provider: providerName,
2518
+ model,
2519
+ latencyMs: 0,
2520
+ result: skipped ?? "codex engine: pong probe through codex exec not implemented",
2521
+ ok: false,
2522
+ ...skipped ? { skipped } : {}
2523
+ };
2524
+ }
2525
+ let proxyUrl;
2526
+ if (provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${providerName}`;
2527
+ const env = buildRunEnv(provider, true, proxyUrl);
2528
+ const t0 = Date.now();
2529
+ const proc = spawn("claude", [
2530
+ "--model",
2531
+ model,
2532
+ "--strict-mcp-config",
2533
+ "--mcp-config",
2534
+ ensureNoMcpConfig(logDir),
2535
+ "-p",
2536
+ "Reply with exactly one word: pong",
2537
+ "--output-format",
2538
+ "json"
2539
+ ], {
2540
+ env,
2541
+ stdio: [
2542
+ "ignore",
2543
+ "pipe",
2544
+ "inherit"
2545
+ ]
2546
+ });
2547
+ const timer = setTimeout(() => {
2548
+ try {
2549
+ proc.kill("SIGKILL");
2550
+ } catch {}
2551
+ }, timeoutMs);
2552
+ let out = "";
2553
+ proc.stdout.on("data", (chunk) => {
2554
+ out += chunk.toString("utf8");
2555
+ });
2556
+ const rc = await new Promise((resolve) => {
2557
+ proc.on("error", () => resolve(1));
2558
+ proc.on("close", (code) => resolve(code ?? 1));
2559
+ });
2560
+ clearTimeout(timer);
2561
+ return {
2562
+ provider: providerName,
2563
+ model,
2564
+ latencyMs: Date.now() - t0,
2565
+ result: resultFromOutput(out),
2566
+ ok: rc === 0 && resultFromOutput(out).trim().toLowerCase() === "pong"
2567
+ };
2568
+ }
2569
+ /**
2570
+ * Pull the reply out of claude's stdout. With --verbose, `--output-format
2571
+ * json` dumps a JSON array of stream events and the reply sits in the last
2572
+ * "result" event; without it, stdout is the single result object. Accept
2573
+ * either shape, plus a bare-text fallback for error output.
2574
+ */
2575
+ function resultFromOutput(out) {
2576
+ const trimmed = out.trim();
2577
+ if (!trimmed) return "";
2578
+ const parse = (s) => {
2579
+ try {
2580
+ return JSON.parse(s);
2581
+ } catch {
2582
+ return;
2583
+ }
2584
+ };
2585
+ const fromValue = (v) => {
2586
+ if (Array.isArray(v)) {
2587
+ for (let i = v.length - 1; i >= 0; i--) {
2588
+ const r = fromValue(v[i]);
2589
+ if (r !== null) return r;
2590
+ }
2591
+ return null;
2592
+ }
2593
+ if (typeof v === "object" && v !== null) {
2594
+ const o = v;
2595
+ if (o.type === "result" && typeof o.result === "string") return o.result;
2596
+ }
2597
+ return null;
2598
+ };
2599
+ const direct = fromValue(parse(trimmed));
2600
+ if (direct !== null) return direct;
2601
+ const lines = trimmed.split("\n");
2602
+ for (let i = lines.length - 1; i >= 0; i--) {
2603
+ const r = fromValue(parse(lines[i]));
2604
+ if (r !== null) return r;
2605
+ }
2606
+ return trimmed.slice(0, 200);
2607
+ }
2608
+
2609
+ //#endregion
2610
+ //#region src/workers/chain.ts
2611
+ /**
2612
+ * chain.ts — draft-then-implement chains: `--chain draft,implement[,review]`.
2613
+ *
2614
+ * The chain runs each stage as its own worker (own id, own pane, `parent` set
2615
+ * to the chain id), so `ps` shows the chain as a tree and every stage can be
2616
+ * followed, replayed and said to like any other worker:
2617
+ *
2618
+ * - draft turns the operator's brief into a full spec file under
2619
+ * <logDir>/specs/<chain id>.md (goal, constraints, files likely
2620
+ * touched, acceptance checks, verification commands);
2621
+ * - any other stage (implement, plan, …) runs with that spec as its prompt
2622
+ * and the original brief attached;
2623
+ * - review reads the spec and the working-tree diff and produces the
2624
+ * structured report.
2625
+ *
2626
+ * A stage that fails (or a draft that produces no spec file) stops the chain;
2627
+ * the caller then writes the spec itself and re-runs without the draft stage.
2628
+ */
2629
+ /** Where a chain's spec file lives: <logDir>/specs/<chain id>.md. */
2630
+ function specPathFor(logDir, chainId) {
2631
+ return join(logDir, "specs", `${chainId}.md`);
2632
+ }
2633
+ /**
2634
+ * Replace the caller's -p value with `prompt`, dropping every existing
2635
+ * -p/--print pair first (two -p flags on one claude command line are an error,
2636
+ * so the chain must never leave the brief in place when swapping prompts).
2637
+ */
2638
+ function swapPromptArg(claudeArgs, prompt) {
2639
+ const out = [];
2640
+ for (let i = 0; i < claudeArgs.length; i++) {
2641
+ const a = claudeArgs[i];
2642
+ if (a === "-p" || a === "--print") {
2643
+ if (i + 1 < claudeArgs.length && !claudeArgs[i + 1].startsWith("-")) i += 1;
2644
+ continue;
2645
+ }
2646
+ out.push(a);
2647
+ }
2648
+ out.push("-p", prompt);
2649
+ return out;
2650
+ }
2651
+ function draftPrompt(brief, specPath) {
2652
+ return [
2653
+ "You are the DRAFT stage of a worker chain. Turn the operator's brief below into a full implementation spec and write it to",
2654
+ specPath,
2655
+ "with the Write tool.",
2656
+ "",
2657
+ "Use exactly these five sections as Markdown headings:",
2658
+ "# Goal",
2659
+ "# Constraints",
2660
+ "# Files likely touched",
2661
+ "# Acceptance checks",
2662
+ "# Verification commands",
2663
+ "",
2664
+ "Read the repository first (Glob/Grep/Read) so the spec names real files and real commands. Do not implement anything.",
2665
+ "",
2666
+ "## Operator brief",
2667
+ "",
2668
+ brief
2669
+ ].join("\n");
2670
+ }
2671
+ function implementPrompt(brief, specPath, spec) {
2672
+ return [
2673
+ ...spec ? [
2674
+ "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.",
2675
+ "",
2676
+ "## Spec",
2677
+ "",
2678
+ spec
2679
+ ] : ["Implement the operator's brief below."],
2680
+ "",
2681
+ "## Operator brief",
2682
+ "",
2683
+ brief
2684
+ ].join("\n");
2685
+ }
2686
+ function reviewPrompt(brief, specPath, spec) {
2687
+ return [
2688
+ "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.",
2689
+ ...spec ? [
2690
+ "",
2691
+ "## Spec (also at " + specPath + ")",
2692
+ "",
2693
+ spec
2694
+ ] : [],
2695
+ "",
2696
+ "## Operator brief",
2697
+ "",
2698
+ brief
2699
+ ].join("\n");
2700
+ }
2701
+ /** Run a chain of stages; returns the exit code of the first failed stage, 0 when all pass. */
2702
+ async function runChain(opts, deps = {}) {
2703
+ const stages = opts.stages.map((s) => s.trim()).filter(Boolean);
2704
+ if (!stages.length) throw new Error("--chain needs at least one class, e.g. --chain draft,implement");
2705
+ const runStage = deps.runStage ?? runWorker;
2706
+ const logDir = deps.logDir ?? workersLogDir(readWorkersSection().workers);
2707
+ const chainId = newWorkerId();
2708
+ const specPath = specPathFor(logDir, chainId);
2709
+ mkdirSync(join(logDir, "specs"), { recursive: true });
2710
+ const baseLabel = opts.label ?? shortText(opts.brief, 40);
2711
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN", {
2712
+ chain: chainId,
2713
+ stages: stages.join(","),
2714
+ label: baseLabel
2715
+ });
2716
+ opts.onChainStart?.(chainId);
2717
+ let spec = null;
2718
+ for (let i = 0; i < stages.length; i++) {
2719
+ const stage = stages[i];
2720
+ if (stage === "draft") spec = null;
2721
+ else if (spec === null && existsSync(specPath)) spec = readFileSync(specPath, "utf8");
2722
+ const prompt = stage === "draft" ? draftPrompt(opts.brief, specPath) : stage === "review" ? reviewPrompt(opts.brief, spec ? specPath : null, spec) : implementPrompt(opts.brief, spec ? specPath : null, spec);
2723
+ process.stderr.write(`chain ${chainId}: stage ${i + 1}/${stages.length} ${stage} (spec: ${specPath})\n`);
2724
+ const rc = await runStage({
2725
+ className: opts.className ?? stage,
2726
+ providerFlag: opts.providerFlag,
2727
+ modelFlag: opts.modelFlag,
2728
+ label: `${baseLabel} · ${stage}`,
2729
+ noPane: opts.noPane,
2730
+ mcpFlag: opts.mcpFlag,
2731
+ claudeArgs: swapPromptArg(opts.claudeArgs, prompt),
2732
+ cwd: opts.cwd,
2733
+ parent: chainId,
2734
+ stage,
2735
+ quiet: opts.quiet
2736
+ });
2737
+ if (stage === "draft") {
2738
+ if (!existsSync(specPath)) {
2739
+ 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`);
2740
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2741
+ chain: chainId,
2742
+ rc: rc !== 0 ? rc : 1,
2743
+ failed: "draft"
2744
+ });
2745
+ return rc !== 0 ? rc : 1;
2746
+ }
2747
+ spec = readFileSync(specPath, "utf8");
2748
+ }
2749
+ if (rc !== 0) {
2750
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2751
+ chain: chainId,
2752
+ rc,
2753
+ failed: stage
2754
+ });
2755
+ return rc;
2756
+ }
2757
+ }
2758
+ appendLedger(ledgerPath(logDir), "WORKER-CHAIN-END", {
2759
+ chain: chainId,
2760
+ rc: 0
2761
+ });
2762
+ return 0;
2763
+ }
2764
+
2765
+ //#endregion
2766
+ //#region src/workers/chatui.ts
2767
+ /**
2768
+ * chatui.ts — the chat line of a `follow` pane.
2769
+ *
2770
+ * A follow pane with a target behaves like a small chat, Claude Code style:
2771
+ * the transcript lives in a terminal scroll region that ends two rows above
2772
+ * the pane's bottom; the last two rows are fixed — the prompt row (`› `,
2773
+ * readline line editing) and the ticker row. A transcript line is inserted
2774
+ * above the fixed rows with a save-cursor / scroll-region / restore-cursor
2775
+ * write that never touches them; the scroll region makes the transcript roll
2776
+ * inside itself. Everything here builds strings (or parses one line), so the
2777
+ * tests assert exact byte sequences — no terminal needed.
2778
+ */
2779
+ /** The prompt marker of the chat row. */
2780
+ const CHAT_PROMPT = "› ";
2781
+ /** Dim hint shown once behind the cursor until the first line is typed. */
2782
+ const CHAT_HINT = "type here and press Enter · /help for commands";
2783
+ /** What `/help` prints (one command per line, dim). */
2784
+ const CHAT_HELP = [
2785
+ "/quit close this pane",
2786
+ "/resume <text> continue the finished worker with <text>",
2787
+ "/status one-line worker status",
2788
+ "anything else is sent to the worker — said while it runs, resumed after"
2789
+ ];
2790
+ /** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
2791
+ function endOfEscape(s, i) {
2792
+ const n = s[i + 1];
2793
+ if (n === "[") {
2794
+ let j = i + 2;
2795
+ while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
2796
+ return Math.min(s.length, j + 1);
2797
+ }
2798
+ if (n === "]") {
2799
+ let j = i + 2;
2800
+ while (j < s.length && s[j] !== "\x07") j++;
2801
+ return Math.min(s.length, j + 1);
2802
+ }
2803
+ return i + 2;
2804
+ }
2805
+ /** Printable columns of `s` — ANSI escape sequences measure zero. */
2806
+ function visibleWidth(s) {
2807
+ let w = 0;
2808
+ let i = 0;
2809
+ while (i < s.length) {
2810
+ if (s[i] === "\x1B") {
2811
+ i = endOfEscape(s, i);
2812
+ continue;
2813
+ }
2814
+ w += 1;
2815
+ i += 1;
2816
+ }
2817
+ return w;
2818
+ }
2819
+ /**
2820
+ * The SGR sequences in effect at `upto`: everything opened since the last
2821
+ * reset, in order. Anything that is not an SGR escape is ignored (it does
2822
+ * not change colour state).
2823
+ */
2824
+ function sgrStateAt(text, upto) {
2825
+ const open = [];
2826
+ let i = 0;
2827
+ while (i < Math.min(upto, text.length)) {
2828
+ if (text[i] === "\x1B") {
2829
+ const end = endOfEscape(text, i);
2830
+ const esc = text.slice(i, end);
2831
+ if (/^\x1b\[[0-9;]*m$/.test(esc)) {
2832
+ const params = esc.slice(2, -1);
2833
+ if (params === "" || params.split(";").includes("0")) open.length = 0;
2834
+ if (!(params === "" || params === "0")) open.push(esc);
2835
+ }
2836
+ i = end;
2837
+ continue;
2838
+ }
2839
+ i += 1;
2840
+ }
2841
+ return open;
2842
+ }
2843
+ /**
2844
+ * Wrap one rendered row to `width` printable columns. Breaks on whitespace
2845
+ * where possible, hard-wraps words longer than the width, never splits an
2846
+ * ANSI escape, and re-opens the colours it wraps inside of, so a diff row
2847
+ * keeps its `-`/`+` colour on every continuation row.
2848
+ */
2849
+ function wrapText(text, width) {
2850
+ if (width < 1 || visibleWidth(text) <= width) return [text];
2851
+ const chars = [];
2852
+ for (let i = 0; i < text.length; i++) {
2853
+ if (text[i] === "\x1B") {
2854
+ i = endOfEscape(text, i) - 1;
2855
+ continue;
2856
+ }
2857
+ chars.push(i);
2858
+ }
2859
+ const words = [];
2860
+ {
2861
+ let s = -1;
2862
+ let w = 0;
2863
+ for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
2864
+ if (s >= 0) {
2865
+ words.push({
2866
+ s,
2867
+ e: k - 1,
2868
+ w
2869
+ });
2870
+ s = -1;
2871
+ w = 0;
2872
+ }
2873
+ } else {
2874
+ if (s < 0) s = k;
2875
+ w++;
2876
+ }
2877
+ }
2878
+ if (words.length && words[0].s > 0) words[0] = {
2879
+ s: 0,
2880
+ e: words[0].e,
2881
+ w: words[0].w + words[0].s
2882
+ };
2883
+ const spans = [];
2884
+ let cs = -1;
2885
+ let ce = -1;
2886
+ for (const word of words) {
2887
+ if (word.w > width) {
2888
+ const room = cs < 0 ? 0 : width - (ce - cs + 1);
2889
+ const take = room > 0 ? ce + room - word.s + 1 : 0;
2890
+ if (take > 0) spans.push([cs, ce + room]);
2891
+ else if (cs >= 0) spans.push([cs, ce]);
2892
+ let pos = word.s + Math.max(0, take);
2893
+ let remaining = word.w - Math.max(0, take);
2894
+ while (remaining > width) {
2895
+ spans.push([pos, pos + width - 1]);
2896
+ pos += width;
2897
+ remaining -= width;
2898
+ }
2899
+ cs = pos;
2900
+ ce = pos + remaining - 1;
2901
+ continue;
2902
+ }
2903
+ if (cs < 0) {
2904
+ cs = word.s;
2905
+ ce = word.e;
2906
+ continue;
2907
+ }
2908
+ if (word.e - cs + 1 <= width) {
2909
+ ce = word.e;
2910
+ continue;
2911
+ }
2912
+ spans.push([cs, ce]);
2913
+ cs = word.s;
2914
+ ce = word.e;
2915
+ }
2916
+ if (cs >= 0) spans.push([cs, ce]);
2917
+ const out = [];
2918
+ for (const [a, b] of spans) {
2919
+ const from = chars[a];
2920
+ let end = chars[b] + 1;
2921
+ while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
2922
+ let piece = text.slice(from, end);
2923
+ const reopen = sgrStateAt(text, from);
2924
+ if (reopen.length) piece = reopen.join("") + piece;
2925
+ if (sgrStateAt(text, end).length) piece += "\x1B[0m";
2926
+ out.push(piece);
2927
+ }
2928
+ return out.length ? out : [""];
2929
+ }
2930
+ /** Restrict scrolling to the transcript region (rows 1 … rows-2). */
2931
+ function chatScrollRegion(rows) {
2932
+ return `\x1b[1;${Math.max(1, rows - 2)}r`;
2933
+ }
2934
+ /**
2935
+ * Enter the chat layout: clear the pane, set the scroll region, park the
2936
+ * cursor at column 1 of the prompt row (rows-1). The ticker owns row `rows`.
2937
+ */
2938
+ function chatEnter(rows) {
2939
+ return "\x1B[2J" + chatScrollRegion(rows) + `\x1b[${Math.max(1, rows - 1)};1H`;
2940
+ }
2941
+ /** Leave it: reset the scroll region, show the cursor, drop to the last row. */
2942
+ function chatLeave(rows) {
2943
+ return `[?25h\x1b[${Math.max(1, rows)};1H`;
2944
+ }
2945
+ /** Redraw the ticker on its own row without moving the user's cursor. */
2946
+ function chatTickerRow(text, rows) {
2947
+ return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + "\x1B8";
2948
+ }
2949
+ /** Move to column 1 of the prompt row and draw prompt (and hint). */
2950
+ function chatPromptRow(rows, prompt = CHAT_PROMPT, hint) {
2951
+ return `\x1b[${Math.max(1, rows - 1)};1H` + prompt + (hint ?? "");
2952
+ }
2953
+ /**
2954
+ * Insert one transcript row above the fixed prompt/ticker rows. While the
2955
+ * region is still filling (`fill < regionRows`) the row is placed top-down;
2956
+ * once full, the cursor moves to the region's bottom row and a newline
2957
+ * scrolls the region up by one — the two fixed rows are never touched. The
2958
+ * user's cursor is saved before and restored after, so readline keeps its
2959
+ * position on the prompt row.
2960
+ */
2961
+ function chatInsertLine(line, fill, regionRows) {
2962
+ return {
2963
+ seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
2964
+ fill: Math.min(fill + 1, regionRows)
2965
+ };
2966
+ }
2967
+ /**
2968
+ * One submitted prompt line → what to do with it. `/help`, `/quit`,
2969
+ * `/status` and `/resume <text>` are commands (a bare `/resume` comes back
2970
+ * with empty text so the caller can print its usage); anything else,
2971
+ * including any other `/word`, is a message for the worker.
2972
+ */
2973
+ function parseChatLine(raw) {
2974
+ const text = raw.trim();
2975
+ if (text === "/help") return { kind: "help" };
2976
+ if (text === "/quit") return { kind: "quit" };
2977
+ if (text === "/status") return { kind: "status" };
2978
+ if (text.startsWith("/resume")) return {
2979
+ kind: "resume",
2980
+ text: text.slice(7).trim()
2981
+ };
2982
+ return {
2983
+ kind: "message",
2984
+ text
2985
+ };
2986
+ }
2987
+ /**
2988
+ * The auto-exit countdown must not fire while the prompt holds unsent text:
2989
+ * true while it does. null/undefined (no prompt wired) never holds.
2990
+ */
2991
+ function holdAutoExit(promptText) {
2992
+ return typeof promptText === "string" && promptText.trim() !== "";
2993
+ }
2994
+
2995
+ //#endregion
2996
+ //#region src/workers/render.ts
2997
+ /**
2998
+ * render.ts — turn worker events into the lines a human reads.
2999
+ *
3000
+ * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
3001
+ * and searches, `$` for shell, magenta `~` + red/green diff for edits, green
3002
+ * `+` for writes), Read-result trimming (first 3 lines then a count), and the
3003
+ * result footer. Colors are applied only when the output is a TTY, so MCP
3004
+ * tool output stays plain text.
3005
+ */
3006
+ const CODES = {
3007
+ dim: "2",
3008
+ bold: "1",
3009
+ red: "31",
3010
+ green: "32",
3011
+ yellow: "33",
3012
+ blue: "34",
3013
+ mag: "35",
3014
+ cyan: "36"
3015
+ };
3016
+ function makeColor(enabled) {
3017
+ return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
3018
+ }
3019
+ /** Path relative to cwd when it lies inside it, otherwise unchanged. */
3020
+ function relPath(path, cwd) {
3021
+ if (!path || !cwd) return path;
3022
+ let r;
3023
+ try {
3024
+ r = relative(cwd, path);
3025
+ } catch {
3026
+ return path;
3027
+ }
3028
+ return r.startsWith("..") ? path : r;
3029
+ }
3030
+ /** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
3031
+ function unifiedDiffLines(oldStr, newStr) {
3032
+ const oldL = oldStr.split("\n");
3033
+ const newL = newStr.split("\n");
3034
+ let start = 0;
3035
+ while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
3036
+ let endOld = oldL.length;
3037
+ let endNew = newL.length;
3038
+ while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
3039
+ endOld--;
3040
+ endNew--;
3041
+ }
3042
+ const out = [];
3043
+ for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
3044
+ for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
3045
+ return out;
3046
+ }
3047
+ /**
3048
+ * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
3049
+ * negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
3050
+ * null when the stamp cannot be parsed. Offsets make this testable without
3051
+ * depending on the machine's zone; the default is this machine's.
3052
+ */
3053
+ function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
3054
+ const t = Date.parse(ts);
3055
+ if (Number.isNaN(t)) return null;
3056
+ return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
3057
+ }
3058
+ /** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
3059
+ function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
3060
+ const t = Date.parse(ts);
3061
+ if (Number.isNaN(t)) return null;
3062
+ return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
3063
+ }
3064
+ /**
3065
+ * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
3066
+ * the worker tag in front when several run at once. Continuation lines get
3067
+ * blanks of the same width so wrapped text stays aligned; when the viewer
3068
+ * wraps lines itself, continuation rows carry the `│` bar instead (barCont)
3069
+ * so the bar runs unbroken down the pane. null when the event carries no
3070
+ * stamp (logs from before 2g render with the plain prefix). The time is the
3071
+ * stamp's wall clock at `offMin` — the default renders local time, whatever
3072
+ * zone stamped the log (old logs were stamped in UTC).
3073
+ */
3074
+ function gutterFor(c, e, tag, offMin) {
3075
+ if (!e._ts) return null;
3076
+ const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
3077
+ const head = tag ? `${tag} ${time}` : time;
3078
+ const width = head.length + 3;
3079
+ return {
3080
+ first: c("dim", `${head} │ `),
3081
+ cont: " ".repeat(width),
3082
+ barCont: c("dim", `${" ".repeat(head.length)} │ `),
3083
+ width
3084
+ };
3085
+ }
3086
+ /**
3087
+ * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
3088
+ * basename for the file tools, the bare name for everything else.
3089
+ */
3090
+ function tickerTool(name, inp) {
3091
+ const i = typeof inp === "object" && inp !== null ? inp : {};
3092
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
3093
+ if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
3094
+ if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
3095
+ return name;
3096
+ }
3097
+ /**
3098
+ * The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
3099
+ * seconds since the last *rendered* event, the worker's last stated intent
3100
+ * (its last assistant text, ≤60 chars) and the tool it is currently running.
3101
+ * Parts that are empty drop out.
3102
+ */
3103
+ function tickerText(secs, intent, tool, meter) {
3104
+ const head = `⋯ ${secs}s`;
3105
+ const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
3106
+ const line = tail ? `${head} · ${tail}` : head;
3107
+ return meter ? `${line} · ${meter}` : line;
3108
+ }
3109
+ /**
3110
+ * One blank line between turns, none inside one: a blank goes before an
3111
+ * assistant message that follows a tool result or an operator message (the
3112
+ * worker starting to speak again after its tools were answered / it was told
3113
+ * something), not between the text, tool calls and results of one turn.
3114
+ */
3115
+ function blankBetween(prev, e) {
3116
+ if (!prev) return false;
3117
+ if (e.type !== "assistant") return false;
3118
+ return prev.type === "user" || prev.type === "operator";
3119
+ }
3120
+ /** The worker's last stated intent: the first line of its last text, ≤60. */
3121
+ function intentOf(text) {
3122
+ return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
3123
+ }
3124
+ /** Compact token count: 84k, 200k, 900. */
3125
+ function fmtK(n) {
3126
+ return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
3127
+ }
3128
+ /**
3129
+ * The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.
3130
+ * null when the numbers are missing or below `minPct` (the table only shows
3131
+ * it past 60; the pane liveness line always shows it).
3132
+ */
3133
+ function contextMeter(c, s, minPct = 0) {
3134
+ const pct = contextPercent(s);
3135
+ if (pct === null || pct <= minPct) return null;
3136
+ const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;
3137
+ if (pct > 85) return c("red", label);
3138
+ if (pct > 70) return c("yellow", label);
3139
+ return label;
3140
+ }
3141
+ function renderToolUse(c, prefix, name, inp, cwd) {
3142
+ const i = typeof inp === "object" && inp !== null ? inp : {};
3143
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
3144
+ if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
3145
+ if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
3146
+ if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
3147
+ if (name === "Edit") {
3148
+ const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
3149
+ for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
3150
+ else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
3151
+ else out.push(`${prefix} ${c("dim", line)}`);
3152
+ return out;
3153
+ }
3154
+ if (name === "Write") {
3155
+ const n = get("content").split("\n").length;
3156
+ return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
3157
+ }
3158
+ if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
3159
+ return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
3160
+ }
3161
+ /** Render one stream-json event; `tools` maps tool_use_id → tool name. */
3162
+ function renderEvent(c, prefix, e, cwd, tools, gutter) {
3163
+ const out = [];
3164
+ if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
3165
+ else if (e.type === "operator") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
3166
+ else if (e.type === "assistant") {
3167
+ for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) for (const ln of (b.text ?? "").trim().split("\n")) out.push(`${prefix}${ln}`);
3168
+ else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
3169
+ } else if (e.type === "user") for (const b of e.message?.content ?? []) {
3170
+ if (b.type !== "tool_result") continue;
3171
+ let content = b.content ?? "";
3172
+ if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
3173
+ const text = String(content);
3174
+ const isError = b.is_error === true;
3175
+ if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
3176
+ const lines = text.split("\n");
3177
+ for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
3178
+ if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
3179
+ } else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
3180
+ else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
3181
+ }
3182
+ else if (e.type === "result") {
3183
+ const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
3184
+ out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
3185
+ const report = parseWorkerReport(String(e.result ?? ""));
3186
+ if (report) out.push(...renderReport(c, prefix, report, cwd));
3187
+ else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
3188
+ }
3189
+ if (!gutter) return out;
3190
+ return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
3191
+ }
3192
+ /** Transcript header: id, provider, label, session name, project dir. */
3193
+ function headerLine(c, s) {
3194
+ const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
3195
+ const sep = bits ? ` ${bits}` : "";
3196
+ return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
3197
+ }
3198
+ /** The chain label behind a stage label: strip the trailing " · <stage>". */
3199
+ function chainLabelOf(stages) {
3200
+ const first = stages[0];
3201
+ if (!first) return "";
3202
+ const suffix = first.stage ? ` · ${first.stage}` : "";
3203
+ return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
3204
+ }
3205
+ /**
3206
+ * The ps table (RUNNING + FINISHED last 8). Chain stages carry `parent` and
3207
+ * render as a tree under one `chain <id>` header; plain workers render as
3208
+ * before.
3209
+ */
3210
+ function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date()) {
3211
+ const running = [];
3212
+ const done = [];
3213
+ for (const s of statuses) if (s.state === "running" && alive(s.pid)) running.push(s);
3214
+ else {
3215
+ if (s.state === "running") s.state = "lost";
3216
+ done.push(s);
3217
+ }
3218
+ const group = (list) => {
3219
+ const out = [];
3220
+ for (const s of list) out.push({
3221
+ s,
3222
+ chain: s.parent ?? null
3223
+ });
3224
+ return out;
3225
+ };
3226
+ const treeLine = (line, chain, last) => {
3227
+ if (chain === null) return line;
3228
+ const mark = last ? "└" : "├";
3229
+ const bar = last ? " " : "│";
3230
+ return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
3231
+ };
3232
+ const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
3233
+ lines.push(c("bold", `RUNNING (${running.length})`));
3234
+ if (!running.length) lines.push(" none");
3235
+ const runEntries = group(running);
3236
+ for (let i = 0; i < runEntries.length; i++) {
3237
+ const { s, chain } = runEntries[i];
3238
+ if (chain) {
3239
+ const prev = runEntries[i - 1];
3240
+ if (!prev || prev.chain !== chain) {
3241
+ const stages = runEntries.filter((e) => e.chain === chain).map((e) => e.s);
3242
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
3243
+ }
3244
+ }
3245
+ const last = !chain || !runEntries[i + 1] || runEntries[i + 1].chain !== chain;
3246
+ const meter = contextMeter(c, s, 60);
3247
+ lines.push(treeLine(` ${c("cyan", s.id)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${meter ? " " + meter : ""}`, chain, last));
3248
+ lines.push(treeLine(` task: ${s.label}`, chain, last));
3249
+ lines.push(treeLine(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
3250
+ }
3251
+ lines.push("");
3252
+ lines.push(c("bold", "FINISHED (last 8)"));
3253
+ const doneEntries = group(done.slice(-8));
3254
+ for (let i = 0; i < doneEntries.length; i++) {
3255
+ const { s, chain } = doneEntries[i];
3256
+ if (chain) {
3257
+ const prev = doneEntries[i - 1];
3258
+ if (!prev || prev.chain !== chain) {
3259
+ const stages = doneEntries.filter((e) => e.chain === chain).map((e) => e.s);
3260
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
3261
+ }
3262
+ }
3263
+ const last = !chain || !doneEntries[i + 1] || doneEntries[i + 1].chain !== chain;
3264
+ const col = s.state === "done" ? "green" : "red";
3265
+ const tag = sessionTag(s);
3266
+ lines.push(treeLine(` ${s.id} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)} ${s.label}`, chain, last));
3267
+ }
3268
+ lines.push("");
3269
+ lines.push(c("dim", "worker follow live transcript of running workers"));
3270
+ lines.push(c("dim", "worker <id> replay one worker"));
3271
+ lines.push(c("dim", scopeLabel));
3272
+ return lines.join("\n");
3273
+ }
3274
+ /** One-line status-bar summary (same shape glm-ps --status printed). */
3275
+ function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(true)) {
3276
+ if (!mine.length) return "";
3277
+ const running = mine.filter((s) => s.state === "running" && alive(s.pid));
3278
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
3279
+ const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
3280
+ const ok = doneToday.filter((s) => s.state === "done").length;
3281
+ const bad = doneToday.length - ok;
3282
+ const providers = new Set(running.map((s) => s.provider));
3283
+ let head = `${providers.size === 1 ? [...providers][0] : "workers"} ▶${running.length}`;
3284
+ const parts = running.slice(0, 3).map((s) => {
3285
+ const meter = contextMeter(c, s, 60);
3286
+ return `${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}` + (meter ? ` ${meter}` : "");
3287
+ });
3288
+ if (parts.length) head += " " + parts.join(" | ");
3289
+ if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
3290
+ return head;
3291
+ }
3292
+
3293
+ //#endregion
3294
+ //#region src/workers/viewer.ts
3295
+ /**
3296
+ * viewer.ts — ps / follow / replay / status line over the worker logDir.
3297
+ *
3298
+ * Scoping: workers launched from this terminal's AIBroker session (or, as the
3299
+ * fallback, its iTerm tab) unless --all or an explicit worker id is given.
3300
+ * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
3301
+ *
3302
+ * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
3303
+ * `_ts` stamp on every mirrored event (local wall clock — stamps carry a local
3304
+ * offset and old UTC stamps are converted), the worker tag in front when
3305
+ * several run at once, a date separator when the day changes, and — on a TTY —
3306
+ * a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
3307
+ * is rewritten in place between events. Attaching to a worker that is already
3308
+ * running first replays its last events (backfill), then continues live.
3309
+ *
3310
+ * Following one worker turns the pane into a small chat (see chatui.ts): the
3311
+ * transcript scrolls in a region that ends two rows above the bottom, the
3312
+ * prompt row (`› `, readline editing) and the ticker row stay fixed, and every
3313
+ * submitted line is said to the worker while it runs and resumes it (same
3314
+ * Claude session) once it has finished. Lines the pane wraps itself keep the
3315
+ * `│` bar on continuation rows, so no content ever lands left of the bar.
3316
+ * Non-TTY output keeps the plain scrolling behaviour.
3317
+ */
3318
+ /** How many existing events a fresh follow pane replays before going live. */
3319
+ const BACKFILL_EVENTS = 200;
3320
+ function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
3321
+ const c = makeColor(color);
3322
+ const term = env.ITERM_SESSION_ID ?? "";
3323
+ const statuses = loadStatuses(logDir);
3324
+ return renderTable(c, showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term)), showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`);
3325
+ }
3326
+ function initialFollowState(intent = "waiting for first event") {
3327
+ return {
3328
+ lastDay: "",
3329
+ tools: {},
3330
+ intent,
3331
+ tool: "",
3332
+ prev: null
3333
+ };
3334
+ }
3335
+ /**
3336
+ * Gutter the rendered body of one event, wrapping when the pane width is
3337
+ * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
3338
+ * the stamped gutter, later rows of the event blanks — exactly the pre-chat
3339
+ * rendering. Wrapped: every row is folded at `wrapWidth` columns and each
3340
+ * continuation row carries the blank gutter with the `│` bar, so the bar runs
3341
+ * unbroken down the pane and no content ever lands left of it.
3342
+ */
3343
+ function gutterBody(body, gutter, wrapWidth) {
3344
+ if (!gutter) return body;
3345
+ if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
3346
+ const out = [];
3347
+ for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
3348
+ return out;
3349
+ }
3350
+ /**
3351
+ * Render one event and advance the follow state. Everything the viewer shows
3352
+ * between events of one worker comes from here: replay, the backfill on
3353
+ * attach and the live tail all use it, so they space identically — events
3354
+ * back to back, one blank line between turns. `activity` is false for events
3355
+ * that render nothing (stream noise, empty tool results): they leave the
3356
+ * ticker's "since last event" clock running. `wrapWidth` (the pane's column
3357
+ * count, re-read on resize) makes the viewer wrap rows itself; null keeps
3358
+ * the terminal's own wrapping.
3359
+ */
3360
+ function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
3361
+ const tools = { ...s.tools };
3362
+ if (e.type === "assistant") {
3363
+ for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
3364
+ }
3365
+ const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
3366
+ const state = {
3367
+ lastDay: day && day !== s.lastDay ? day : s.lastDay,
3368
+ tools,
3369
+ intent: s.intent,
3370
+ tool: s.tool,
3371
+ prev: e
3372
+ };
3373
+ if (e.type === "assistant") {
3374
+ for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
3375
+ else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
3376
+ }
3377
+ const gutter = gutterFor(c, e, tag, offMin);
3378
+ const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
3379
+ const lines = blankBetween(s.prev, e) ? ["", ...body] : body;
3380
+ return {
3381
+ lines,
3382
+ day: day && day !== s.lastDay ? day : null,
3383
+ activity: lines.some((ln) => ln !== ""),
3384
+ state
3385
+ };
3386
+ }
3387
+ /** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
3388
+ function rawDay(ts) {
3389
+ return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
3390
+ }
3391
+ /**
3392
+ * The events a fresh follow replays before going live: the last `cap`
3393
+ * non-empty log lines, oldest first.
3394
+ */
3395
+ function backfillLines(raw, cap = BACKFILL_EVENTS) {
3396
+ return raw.split("\n").filter((l) => l.trim()).slice(-cap);
3397
+ }
3398
+ /** The rendered transcript of one worker, from the start, as one string. */
3399
+ function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
3400
+ const path = eventsPath(logDir, wid);
3401
+ if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
3402
+ const c = makeColor(color);
3403
+ const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
3404
+ id: wid,
3405
+ label: "",
3406
+ cwd: "",
3407
+ provider: ""
3408
+ };
3409
+ const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
3410
+ const out = [headerLine(c, st)];
3411
+ const raw = readFileSync(path, "utf8");
3412
+ const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
3413
+ let state = initialFollowState("");
3414
+ for (const line of lines) {
3415
+ if (!line.trim()) continue;
3416
+ let e;
3417
+ try {
3418
+ e = JSON.parse(line);
3419
+ } catch {
3420
+ continue;
3421
+ }
3422
+ const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
3423
+ if (step.day) out.push(c("dim", `── ${step.day} ──`));
3424
+ out.push(...step.lines);
3425
+ state = step.state;
3426
+ }
3427
+ return out.join("\n");
3428
+ }
3429
+ /**
3430
+ * The follow exit decision for one worker: it is over once its result event
3431
+ * was rendered, or once its status left "running" while its pid is gone — a
3432
+ * worker killed without writing a result still ends. A status never read
3433
+ * (undefined) means "not over": follow keeps waiting for the first event.
3434
+ */
3435
+ function workerEnded(resultRendered, state, pidAlive) {
3436
+ return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
3437
+ }
3438
+ /**
3439
+ * One typed stdin line → say (worker running) or resume (worker finished):
3440
+ * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
3441
+ * missing target are ignored.
3442
+ */
3443
+ function makeOperatorInput(d) {
3444
+ return (raw) => {
3445
+ const text = raw.trim();
3446
+ if (!text) return;
3447
+ const id = d.target();
3448
+ if (id === null) return;
3449
+ d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
3450
+ if (d.workerKnown(id)) d.resume(text, id);
3451
+ else d.note(d.paint("red", `» ${e.message}`));
3452
+ });
3453
+ };
3454
+ }
3455
+ /**
3456
+ * Tail one worker (target) or the running workers of this scope, live.
3457
+ * Workers whose event log already exists are first replayed (last
3458
+ * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
3459
+ * `autoExit` seconds after its end; without one, exit once no worker in
3460
+ * scope has run for that many seconds in a row, never within the first 30 s.
3461
+ *
3462
+ * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
3463
+ * a scroll region, fixed prompt and ticker rows, submitted lines said to the
3464
+ * worker (or resuming it after it finished) and echoed as `»` rows. A draft
3465
+ * in the prompt holds the auto-exit countdown. Non-TTY output keeps the
3466
+ * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
3467
+ */
3468
+ async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
3469
+ const c = makeColor(color);
3470
+ const out_ = io?.stdout ?? process.stdout;
3471
+ const in_ = io?.stdin ?? process.stdin;
3472
+ const tty = out_.isTTY === true || env.FORCE_TTY === "1";
3473
+ const term = env.ITERM_SESSION_ID ?? "";
3474
+ const scopeTab = target || showAll ? "" : currentTabKey(env);
3475
+ const handles = /* @__PURE__ */ new Map();
3476
+ const seenHeader = /* @__PURE__ */ new Set();
3477
+ const finished = /* @__PURE__ */ new Set();
3478
+ const states = /* @__PURE__ */ new Map();
3479
+ const started = Date.now();
3480
+ let idleSince = null;
3481
+ let aborted = false;
3482
+ let lastEventAt = Date.now();
3483
+ let meterStatus = null;
3484
+ const onInt = () => {
3485
+ aborted = true;
3486
+ };
3487
+ process.once("SIGINT", onInt);
3488
+ const chat = tty && target !== null;
3489
+ let rows = out_.rows ?? 24;
3490
+ const columns = () => typeof out_.columns === "number" ? out_.columns : null;
3491
+ let fill = 0;
3492
+ const regionRows = () => Math.max(1, rows - 2);
3493
+ /** Every line the pane shows goes through here: plain newline, or a row
3494
+ * inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */
3495
+ const out = (line) => {
3496
+ if (!chat) {
3497
+ out_.write(line + "\n");
3498
+ return;
3499
+ }
3500
+ const r = chatInsertLine(line, fill, regionRows());
3501
+ out_.write(r.seq);
3502
+ fill = r.fill;
3503
+ };
3504
+ const eraseLiveness = () => {
3505
+ if (chat) return;
3506
+ if (tty) out_.write("\r\x1B[K");
3507
+ };
3508
+ let ticker = initialFollowState();
3509
+ const writeLiveness = () => {
3510
+ if (!tty) return;
3511
+ const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
3512
+ const meter = meterStatus ? contextMeter(c, meterStatus) : null;
3513
+ const text = tickerText(secs, ticker.intent, ticker.tool, meter);
3514
+ if (chat) out_.write(chatTickerRow(text, rows));
3515
+ else {
3516
+ out_.write("\r\x1B[K");
3517
+ out_.write(text);
3518
+ }
3519
+ };
3520
+ const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
3521
+ const emitEvent = (e, wid, st, multi) => {
3522
+ if (e.type === "operator" && chat && suppressMirror(String(e.text ?? ""))) return;
3523
+ if (!seenHeader.has(wid)) {
3524
+ eraseLiveness();
3525
+ out(headerLine(c, st));
3526
+ seenHeader.add(wid);
3527
+ }
3528
+ const step = applyEvent(c, states.get(wid) ?? initialFollowState(), e, st.cwd ?? "", multi ? c("cyan", wid.slice(-4)) : void 0, void 0, columns());
3529
+ if (step.day) out(c("dim", `── ${step.day} ──`));
3530
+ states.set(wid, step.state);
3531
+ if (wid === target) ticker = step.state;
3532
+ eraseLiveness();
3533
+ for (const ln of step.lines) out(ln);
3534
+ if (step.activity) lastEventAt = Date.now();
3535
+ if (e.type === "result") {
3536
+ meterStatus = st;
3537
+ finished.add(wid);
3538
+ } else if (wid === target || target === null) meterStatus = st;
3539
+ };
3540
+ const attachHandle = (wid, path, st, multi) => {
3541
+ const handle = {
3542
+ fd: openSync(path, "r"),
3543
+ buf: ""
3544
+ };
3545
+ try {
3546
+ const existing = readFileSync(path, "utf8");
3547
+ if (existing.trim()) for (const line of backfillLines(existing)) {
3548
+ let e;
3549
+ try {
3550
+ e = JSON.parse(line);
3551
+ } catch {
3552
+ continue;
3553
+ }
3554
+ emitEvent(e, wid, st, multi);
3555
+ }
3556
+ else if (!seenHeader.has(wid)) {
3557
+ out(headerLine(c, st));
3558
+ seenHeader.add(wid);
3559
+ }
3560
+ const sink = Buffer.alloc(65536);
3561
+ for (;;) {
3562
+ let n;
3563
+ try {
3564
+ n = readSync(handle.fd, sink, 0, sink.length, null);
3565
+ } catch {
3566
+ break;
3567
+ }
3568
+ if (n <= 0) break;
3569
+ }
3570
+ } catch {}
3571
+ return handle;
3572
+ };
3573
+ const noteLine = (s) => {
3574
+ eraseLiveness();
3575
+ out(s);
3576
+ };
3577
+ const spawnResume = io?.spawnResume ?? ((id, text) => spawn("pai", [
3578
+ "worker",
3579
+ "resume",
3580
+ id,
3581
+ text,
3582
+ "--print-id",
3583
+ "--no-pane"
3584
+ ], { stdio: [
3585
+ "ignore",
3586
+ "pipe",
3587
+ "inherit"
3588
+ ] }));
3589
+ const resumeTarget = (text, id) => {
3590
+ noteLine(c("dim", `» resuming ${id} …`));
3591
+ const child = spawnResume(id, text);
3592
+ let idOut = "";
3593
+ child.stdout?.on("data", (chunk) => {
3594
+ idOut += chunk.toString("utf8");
3595
+ });
3596
+ child.on("close", (rc) => {
3597
+ const newId = idOut.trim().split("\n").pop() ?? "";
3598
+ if (rc === 0 && /^\d{8}-\d{6}-\d+$/.test(newId)) {
3599
+ noteLine(c("dim", `» resumed as ${newId}`));
3600
+ target = newId;
3601
+ finished.delete(newId);
3602
+ seenHeader.delete(newId);
3603
+ states.delete(newId);
3604
+ ticker = initialFollowState("resumed");
3605
+ lastEventAt = Date.now();
3606
+ } else noteLine(c("red", `» resume failed (rc=${rc})`));
3607
+ });
3608
+ };
3609
+ const handleOperatorLine = makeOperatorInput({
3610
+ target: () => target,
3611
+ say: (id, text) => sayToWorker(logDir, id, text),
3612
+ workerKnown: (id) => loadStatuses(logDir).some((s) => s.id === id),
3613
+ resume: resumeTarget,
3614
+ note: noteLine,
3615
+ paint: c,
3616
+ ...chat ? { sent: () => void 0 } : {}
3617
+ });
3618
+ const terminalIn = in_.isTTY === true;
3619
+ let hintUp = true;
3620
+ let rlIn = null;
3621
+ let onResize = null;
3622
+ const echoed = /* @__PURE__ */ new Map();
3623
+ const suppressMirror = (text) => {
3624
+ const g = echoed.get(text);
3625
+ if (!g || Date.now() > g.until) return false;
3626
+ g.n -= 1;
3627
+ if (g.n <= 0) echoed.delete(text);
3628
+ return true;
3629
+ };
3630
+ const drawPrompt = () => {
3631
+ if (!chat || terminalIn) return;
3632
+ out_.write(chatPromptRow(rows, CHAT_PROMPT, hintUp ? c("dim", CHAT_HINT) : void 0));
3633
+ };
3634
+ if (chat) {
3635
+ const echoOperator = (text) => {
3636
+ const id = target;
3637
+ if (id === null) return;
3638
+ echoed.set(text, {
3639
+ n: 1,
3640
+ until: Date.now() + 1e4
3641
+ });
3642
+ const step = applyEvent(c, states.get(id) ?? initialFollowState(), {
3643
+ type: "operator",
3644
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
3645
+ text
3646
+ }, "", void 0, void 0, columns());
3647
+ if (step.day) out(c("dim", `── ${step.day} ──`));
3648
+ for (const ln of step.lines) out(ln);
3649
+ states.set(id, step.state);
3650
+ };
3651
+ const handleChatLine = (raw) => {
3652
+ hintUp = false;
3653
+ const act = parseChatLine(raw);
3654
+ switch (act.kind) {
3655
+ case "message":
3656
+ if (!act.text) break;
3657
+ echoOperator(act.text);
3658
+ handleOperatorLine(act.text);
3659
+ break;
3660
+ case "resume":
3661
+ if (!act.text) {
3662
+ out(c("dim", "usage: /resume <text>"));
3663
+ break;
3664
+ }
3665
+ echoOperator(act.text);
3666
+ if (target !== null) resumeTarget(act.text, target);
3667
+ break;
3668
+ case "help":
3669
+ for (const ln of CHAT_HELP) out(c("dim", ln));
3670
+ break;
3671
+ case "status": {
3672
+ const s = target !== null ? loadStatuses(logDir).find((x) => x.id === target) : void 0;
3673
+ out(c("dim", s ? `${s.id} · ${s.state} · ${s.last}` : `${target ?? "?"} · no status`));
3674
+ break;
3675
+ }
3676
+ case "quit":
3677
+ aborted = true;
3678
+ break;
3679
+ }
3680
+ drawPrompt();
3681
+ };
3682
+ rlIn = createInterface({
3683
+ input: in_,
3684
+ output: out_,
3685
+ terminal: terminalIn
3686
+ });
3687
+ rlIn.on("line", handleChatLine);
3688
+ rlIn.on("SIGINT", () => {
3689
+ if ((rlIn?.line ?? "").trim() === "") aborted = true;
3690
+ else rlIn?.write(null, {
3691
+ ctrl: true,
3692
+ name: "u"
3693
+ });
3694
+ });
3695
+ rlIn.on("close", () => {
3696
+ aborted = true;
3697
+ });
3698
+ out_.write(chatEnter(rows));
3699
+ if (terminalIn) {
3700
+ rlIn.setPrompt(CHAT_PROMPT);
3701
+ rlIn.prompt();
3702
+ out_.write(c("dim", CHAT_HINT));
3703
+ } else drawPrompt();
3704
+ onResize = () => {
3705
+ if (typeof out_.rows === "number") rows = out_.rows;
3706
+ fill = 0;
3707
+ out_.write(chatScrollRegion(rows));
3708
+ drawPrompt();
3709
+ };
3710
+ out_.on?.("resize", onResize);
3711
+ } else if (target !== null && in_.isTTY) {
3712
+ rlIn = createInterface({ input: in_ });
3713
+ rlIn.on("line", handleOperatorLine);
3714
+ }
3715
+ /** The prompt's unsent text — a draft holds the auto-exit countdown. */
3716
+ const promptText = () => io?.promptLine ? io.promptLine() : rlIn?.line ?? "";
3717
+ try {
3718
+ for (;;) {
3719
+ if (aborted) return;
3720
+ const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
3721
+ const wanted = target ? [target] : runningIds();
3722
+ for (const wid of wanted) {
3723
+ if (handles.has(wid) || finished.has(wid)) continue;
3724
+ const path = eventsPath(logDir, wid);
3725
+ const st = statuses.get(wid) ?? {
3726
+ id: wid,
3727
+ label: "",
3728
+ cwd: "",
3729
+ provider: ""
3730
+ };
3731
+ if (existsSync(path)) {
3732
+ const multi = target === null || handles.size > 0;
3733
+ handles.set(wid, attachHandle(wid, path, st, multi));
3734
+ states.set(wid, states.get(wid) ?? initialFollowState());
3735
+ } else if (!seenHeader.has(wid)) {
3736
+ out(headerLine(c, st));
3737
+ seenHeader.add(wid);
3738
+ }
3739
+ }
3740
+ const multi = handles.size > 1 || target === null;
3741
+ let progressed = false;
3742
+ for (const [wid, h] of [...handles.entries()]) {
3743
+ const st = statuses.get(wid) ?? {
3744
+ id: wid,
3745
+ label: "",
3746
+ cwd: "",
3747
+ provider: ""
3748
+ };
3749
+ const buffer = Buffer.alloc(65536);
3750
+ for (;;) {
3751
+ let n;
3752
+ try {
3753
+ n = readSync(h.fd, buffer, 0, buffer.length, null);
3754
+ } catch {
3755
+ n = 0;
3756
+ }
3757
+ if (n <= 0) break;
3758
+ h.buf += buffer.toString("utf8", 0, n);
3759
+ }
3760
+ const lines = h.buf.split("\n");
3761
+ h.buf = lines.pop() ?? "";
3762
+ for (const line of lines) {
3763
+ if (!line.trim()) continue;
3764
+ progressed = true;
3765
+ let e;
3766
+ try {
3767
+ e = JSON.parse(line);
3768
+ } catch {
3769
+ continue;
3770
+ }
3771
+ emitEvent(e, wid, st, multi);
3772
+ }
3773
+ if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
3774
+ if (!finished.has(wid)) {
3775
+ eraseLiveness();
3776
+ out(`${multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " "}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}`);
3777
+ finished.add(wid);
3778
+ }
3779
+ closeSync(h.fd);
3780
+ handles.delete(wid);
3781
+ }
3782
+ }
3783
+ const lingerOn = target;
3784
+ if (lingerOn !== null && finished.has(lingerOn)) {
3785
+ if (autoExit) {
3786
+ if (holdAutoExit(promptText())) {
3787
+ writeLiveness();
3788
+ await sleep(250);
3789
+ continue;
3790
+ }
3791
+ const until = Date.now() + autoExit * 1e3;
3792
+ while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
3793
+ writeLiveness();
3794
+ await sleep(250);
3795
+ }
3796
+ if (aborted || target !== lingerOn || holdAutoExit(promptText())) continue;
3797
+ eraseLiveness();
3798
+ out(c("dim", "closing"));
3799
+ return;
3800
+ }
3801
+ if (rlIn === null) return;
3802
+ }
3803
+ if (autoExit && !target) {
3804
+ if (runningIds().length) idleSince = null;
3805
+ else if (idleSince === null) idleSince = Date.now();
3806
+ else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
3807
+ eraseLiveness();
3808
+ out(c("dim", "closing"));
3809
+ return;
3810
+ }
3811
+ }
3812
+ if (!progressed) {
3813
+ writeLiveness();
3814
+ await sleep(500);
3815
+ }
3816
+ }
3817
+ } finally {
3818
+ rlIn?.close();
3819
+ process.removeListener("SIGINT", onInt);
3820
+ if (onResize) out_.removeListener?.("resize", onResize);
3821
+ if (chat) out_.write(chatLeave(rows));
3822
+ for (const h of handles.values()) try {
3823
+ closeSync(h.fd);
3824
+ } catch {}
3825
+ }
3826
+ }
3827
+ function sleep(ms) {
3828
+ return new Promise((r) => setTimeout(r, ms));
3829
+ }
3830
+ /** Workers of this terminal for the status bar; "" when there are none. */
3831
+ function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
3832
+ return renderStatusLine(loadStatuses(logDir).filter((s) => {
3833
+ const sameScope = term && workerInScope(s, term);
3834
+ const sameDir = cwd && s.cwd.startsWith(cwd);
3835
+ return sameScope || !term && sameDir;
3836
+ }), now);
3837
+ }
3838
+
3839
+ //#endregion
3840
+ //#region src/workers/providers.ts
3841
+ /**
3842
+ * providers.ts — provider, class and switch management over the workers config.
3843
+ *
3844
+ * One layer under both `pai worker providers …` and the MCP worker_providers
3845
+ * tool. Every mutation re-reads the config file, changes only the workers
3846
+ * section, and writes it back atomically — the file is shared with everything
3847
+ * else PAI runs, so a torn write is not an option.
3848
+ *
3849
+ * Keys are never stored in the config and never accepted inline from the CLI:
3850
+ * only file paths. (The MCP `add` tool additionally accepts a raw `key`, which
3851
+ * it immediately parks in ~/.config/pai/keys/<name>, mode 0600, storing only
3852
+ * the path — a chat is not a place to leave a credential lying around.)
3853
+ */
3854
+ function addProvider(input) {
3855
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(input.name)) throw new WorkersConfigError(`provider name "${input.name}" may only contain letters, digits, - and _`);
3856
+ if (input.protocol === "openai" && !input.upstreamUrl) throw new WorkersConfigError("protocol \"openai\" needs the upstreamUrl (the Chat Completions base, e.g. \"https://api.openai.com/v1\") — the PAI proxy translates to it.");
3857
+ if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
3858
+ let keyFile = input.keyFile ?? null;
3859
+ if (input.key !== void 0) {
3860
+ const dir = keysDir();
3861
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3862
+ const path = `${dir}/${input.name}`;
3863
+ writeFileSync(path, input.key.trim() + "\n", {
3864
+ encoding: "utf8",
3865
+ mode: 384
3866
+ });
3867
+ try {
3868
+ chmodSync(path, 384);
3869
+ } catch {}
3870
+ keyFile = path;
3871
+ }
3872
+ const { raw, workers } = readWorkersSection();
3873
+ const provider = {
3874
+ enabled: true,
3875
+ protocol: input.protocol ?? "anthropic",
3876
+ baseUrl: input.baseUrl,
3877
+ keyFile,
3878
+ models: input.fastModel ? {
3879
+ default: input.model,
3880
+ fast: input.fastModel
3881
+ } : { default: input.model },
3882
+ env: input.env ?? {},
3883
+ ...input.note ? { note: input.note } : {},
3884
+ ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
3885
+ ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
3886
+ ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
3887
+ ...input.contextWindow ? { contextWindow: input.contextWindow } : {},
3888
+ ...input.costTier ? { costTier: input.costTier } : {},
3889
+ ...input.tags?.length ? { tags: input.tags } : {}
3890
+ };
3891
+ workers.providers[input.name] = provider;
3892
+ if (Object.keys(workers.providers).length === 1) {
3893
+ workers.enabled = true;
3894
+ workers.active = input.name;
3895
+ workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
3896
+ const fast = input.fastModel ? `${input.name}/fast` : input.name;
3897
+ workers.classes = {
3898
+ draft: fast,
3899
+ plan: input.name,
3900
+ implement: input.name,
3901
+ review: input.name,
3902
+ research: input.name,
3903
+ spotcheck: fast,
3904
+ simple: fast,
3905
+ complex: input.name,
3906
+ image: input.name
3907
+ };
3908
+ }
3909
+ writeWorkersSection(raw, workers);
3910
+ return workers;
3911
+ }
3912
+ /** Change costTier / tags on an existing provider (MCP action "update"). */
3913
+ function updateProvider(name, changes) {
3914
+ const { raw, workers } = readWorkersSection();
3915
+ const p = workers.providers[name];
3916
+ if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3917
+ if (changes.costTier !== void 0) {
3918
+ if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
3919
+ p.costTier = changes.costTier;
3920
+ }
3921
+ if (changes.tags !== void 0) {
3922
+ for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
3923
+ p.tags = changes.tags;
3924
+ }
3925
+ writeWorkersSection(raw, workers);
3926
+ return workers;
3927
+ }
3928
+ function removeProvider(name) {
3929
+ const { raw, workers } = readWorkersSection();
3930
+ if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
3931
+ delete workers.providers[name];
3932
+ for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
3933
+ if (workers.active === name) workers.active = null;
3934
+ writeWorkersSection(raw, workers);
3935
+ return workers;
3936
+ }
3937
+ function useProvider(name) {
3938
+ const { raw, workers } = readWorkersSection();
3939
+ if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3940
+ workers.active = name;
3941
+ writeWorkersSection(raw, workers);
3942
+ return workers;
3943
+ }
3944
+ function setProviderEnabled(name, enabled) {
3945
+ const { raw, workers } = readWorkersSection();
3946
+ const p = workers.providers[name];
3947
+ if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
3948
+ p.enabled = enabled;
3949
+ writeWorkersSection(raw, workers);
3950
+ if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
3951
+ return workers;
3952
+ }
3953
+ /**
3954
+ * Point a class at a target ("provider", "provider/fast", or an object with
3955
+ * provider + optional mcp/maxCostTier/requireTags/order). An object without
3956
+ * a provider only constrains auto-routing for that class.
3957
+ */
3958
+ function setClass(name, target) {
3959
+ const { raw, workers } = readWorkersSection();
3960
+ if (typeof target === "string") {
3961
+ const [provider, alias] = target.split("/");
3962
+ const p = workers.providers[provider];
3963
+ if (!p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3964
+ if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose "default" and "fast"`);
3965
+ if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${provider}" has no fast model configured`);
3966
+ } else if (target.provider) {
3967
+ if (!workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
3968
+ }
3969
+ workers.classes[name] = target;
3970
+ writeWorkersSection(raw, workers);
3971
+ return workers;
3972
+ }
3973
+ function unsetClass(name) {
3974
+ const { raw, workers } = readWorkersSection();
3975
+ if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
3976
+ delete workers.classes[name];
3977
+ writeWorkersSection(raw, workers);
3978
+ return workers;
3979
+ }
3980
+ function setWorkersEnabled(enabled) {
3981
+ const { raw, workers } = readWorkersSection();
3982
+ workers.enabled = enabled;
3983
+ writeWorkersSection(raw, workers);
3984
+ return workers;
3985
+ }
3986
+ /** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
3987
+ function classTargetText(target) {
3988
+ if (typeof target === "string") return target;
3989
+ return [
3990
+ target.provider ?? "(routing)",
3991
+ ...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
3992
+ ...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
3993
+ ...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
3994
+ ...target.order?.length ? [`order [${target.order.join(",")}]`] : []
3995
+ ].join(" ");
3996
+ }
3997
+ /** Human-readable provider listing (quota probe included when configured). */
3998
+ function describeProviders(workers) {
3999
+ const lines = [];
4000
+ const names = Object.keys(workers.providers);
4001
+ if (!names.length) {
4002
+ lines.push(`no providers configured. Add one with:`);
4003
+ lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
4004
+ return lines;
4005
+ }
4006
+ for (const name of names) {
4007
+ const p = workers.providers[name];
4008
+ const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
4009
+ const quota = p.quotaProbe ? probeQuota(p) : null;
4010
+ const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
4011
+ const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
4012
+ lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
4013
+ lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
4014
+ lines.push(` ${tierTags}`);
4015
+ if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
4016
+ else lines.push(` no key file (token "local")`);
4017
+ if (p.note) lines.push(` ${p.note}`);
4018
+ if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
4019
+ if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
4020
+ }
4021
+ const setNames = Object.keys(workers.mcpSets);
4022
+ if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
4023
+ const classNames = Object.keys(workers.classes);
4024
+ if (classNames.length) lines.push(`classes: ${classNames.map((cl) => `${cl}=${classTargetText(workers.classes[cl])}`).join(" ")}`);
4025
+ if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
4026
+ return lines;
4027
+ }
4028
+
4029
+ //#endregion
4030
+ export { ledgerPath as A, checkPaneForWorker as C, ledgerSummary as D, loadStatus as E, readJsonStrict as F, writeJsonAtomic as I, PROVIDER_TAGS as M, WorkersConfigError as N, parseRunnerArgs as O, readWorkersSection as P, describeMcp as S, openPaneForWorker as T, testProvider as _, setClass as a, stopProxy as b, unsetClass as c, followWorkers as d, psOutput as f, runWorker as g, runChain as h, removeProvider as i, workersLogDir as j, eventsPath as k, updateProvider as l, statusLineOutput as m, classTargetText as n, setProviderEnabled as o, replayOutput as p, describeProviders as r, setWorkersEnabled as s, addProvider as t, useProvider as u, DEFAULT_PROXY_PORT as v, openFollowPane as w, sayToWorker as x, ensureProxyRunning as y };
4031
+ //# sourceMappingURL=providers-DUshcB-d.mjs.map