@tekmidian/pai 0.64.0 → 0.65.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 (180) hide show
  1. package/README.md +19 -5
  2. package/dist/{aibroker-client-BpfuGPe5.mjs → aibroker-client-B8c42Lh8.mjs} +1 -1
  3. package/dist/{aibroker-client-DRld7g6z.mjs → aibroker-client-C5Fw7DNz.mjs} +10 -4
  4. package/dist/{aibroker-client-DRld7g6z.mjs.map → aibroker-client-C5Fw7DNz.mjs.map} +1 -1
  5. package/dist/{async-CY_cd_8j.mjs → async-CNn36gm4.mjs} +2 -2
  6. package/dist/{async-CY_cd_8j.mjs.map → async-CNn36gm4.mjs.map} +1 -1
  7. package/dist/{auto-route-CPlam4iv.mjs → auto-route-sLMU-NnM.mjs} +2 -2
  8. package/dist/{auto-route-CPlam4iv.mjs.map → auto-route-sLMU-NnM.mjs.map} +1 -1
  9. package/dist/{chain-o-g8AgE3.mjs → chain-C8QO8gwj.mjs} +4 -4
  10. package/dist/{chain-o-g8AgE3.mjs.map → chain-C8QO8gwj.mjs.map} +1 -1
  11. package/dist/cli/index.mjs +19 -19
  12. package/dist/cli/program.d.mts.map +1 -1
  13. package/dist/cli/program.mjs +19 -19
  14. package/dist/{clusters-DsMf20PP.mjs → clusters-PgIUYT_v.mjs} +1 -1
  15. package/dist/{clusters-DsMf20PP.mjs.map → clusters-PgIUYT_v.mjs.map} +1 -1
  16. package/dist/{config-2wkz744W.mjs → config-BbLFD7Uf.mjs} +3 -3
  17. package/dist/{config-2wkz744W.mjs.map → config-BbLFD7Uf.mjs.map} +1 -1
  18. package/dist/{config-CUTg6VDq.mjs → config-YinjgXEJ.mjs} +1 -1
  19. package/dist/{context-handover-cache-BOpjLsKe.mjs → context-handover-cache-mGxq2-8f.mjs} +11 -29
  20. package/dist/context-handover-cache-mGxq2-8f.mjs.map +1 -0
  21. package/dist/daemon/index.mjs +16 -16
  22. package/dist/daemon-CX9JomIJ.mjs +19 -0
  23. package/dist/{daemon-BYieXduQ.mjs → daemon-D2r1AQqE.mjs} +30 -30
  24. package/dist/{daemon-BYieXduQ.mjs.map → daemon-D2r1AQqE.mjs.map} +1 -1
  25. package/dist/daemon-mcp/index.mjs +17 -17
  26. package/dist/daemon-mcp/index.mjs.map +1 -1
  27. package/dist/detector-C3Q7mxQU.mjs +3 -0
  28. package/dist/{detector-BAlmrLQ0.mjs → detector-CIHEXKsV.mjs} +1 -1
  29. package/dist/{detector-BAlmrLQ0.mjs.map → detector-CIHEXKsV.mjs.map} +1 -1
  30. package/dist/{embeddings-DK9XfQic.mjs → embeddings-BbNVXa_0.mjs} +1 -1
  31. package/dist/{embeddings-DK9XfQic.mjs.map → embeddings-BbNVXa_0.mjs.map} +1 -1
  32. package/dist/{embeddings-Xg8XLf2K.mjs → embeddings-CDfM63uC.mjs} +1 -1
  33. package/dist/{factory-BMK0tC1b.mjs → factory-CaswxuJ0.mjs} +1 -1
  34. package/dist/{factory-C4We3xR_.mjs → factory-y36qGegI.mjs} +13 -13
  35. package/dist/{factory-C4We3xR_.mjs.map → factory-y36qGegI.mjs.map} +1 -1
  36. package/dist/{fallback-ECiqCuh6.mjs → fallback-CWDQYmJi.mjs} +1205 -1204
  37. package/dist/fallback-CWDQYmJi.mjs.map +1 -0
  38. package/dist/{federation-db-CUgy0WYs.mjs → federation-db-BTyoufBh.mjs} +2 -2
  39. package/dist/{federation-db-CUgy0WYs.mjs.map → federation-db-BTyoufBh.mjs.map} +1 -1
  40. package/dist/federation-db-HfIFc7FG.mjs +3 -0
  41. package/dist/hooks/block-sleep-poll.mjs +36 -11
  42. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  43. package/dist/hooks/capture-all-events.mjs +3 -17
  44. package/dist/hooks/capture-all-events.mjs.map +3 -3
  45. package/dist/hooks/capture-session-summary.mjs +3 -17
  46. package/dist/hooks/capture-session-summary.mjs.map +3 -3
  47. package/dist/hooks/capture-tool-output.mjs +3 -17
  48. package/dist/hooks/capture-tool-output.mjs.map +3 -3
  49. package/dist/hooks/cleanup-session-files.mjs +3 -17
  50. package/dist/hooks/cleanup-session-files.mjs.map +2 -2
  51. package/dist/hooks/context-compression-hook.mjs +3 -17
  52. package/dist/hooks/context-compression-hook.mjs.map +3 -3
  53. package/dist/hooks/initialize-session.mjs +3 -17
  54. package/dist/hooks/initialize-session.mjs.map +2 -2
  55. package/dist/hooks/inject-observations.mjs +3 -17
  56. package/dist/hooks/inject-observations.mjs.map +2 -2
  57. package/dist/hooks/load-core-context.mjs +4 -22
  58. package/dist/hooks/load-core-context.mjs.map +2 -2
  59. package/dist/hooks/load-project-context.mjs +3 -17
  60. package/dist/hooks/load-project-context.mjs.map +3 -3
  61. package/dist/hooks/mcp-deferred-gate.mjs +3 -17
  62. package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
  63. package/dist/hooks/observe.mjs +3 -17
  64. package/dist/hooks/observe.mjs.map +2 -2
  65. package/dist/hooks/security-validator.mjs +3 -17
  66. package/dist/hooks/security-validator.mjs.map +3 -3
  67. package/dist/hooks/stop-hook.mjs +3 -17
  68. package/dist/hooks/stop-hook.mjs.map +3 -3
  69. package/dist/hooks/sync-todo-to-md.mjs +3 -17
  70. package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
  71. package/dist/hooks/worker-guard.mjs.map +2 -2
  72. package/dist/hooks/worker-status-line.mjs +4 -4
  73. package/dist/hooks/worker-status-line.mjs.map +2 -2
  74. package/dist/{indexer-backend-CJ0RGgMz.mjs → indexer-backend-Cnc7Tf5C.mjs} +2 -2
  75. package/dist/{ipc-client-EhWY8XL0.mjs → ipc-client-D16Xw6Uo.mjs} +2 -2
  76. package/dist/{ipc-client-EhWY8XL0.mjs.map → ipc-client-D16Xw6Uo.mjs.map} +1 -1
  77. package/dist/{kg-entity-1uqCnk4u.mjs → kg-entity-COTUj1ZC.mjs} +1 -1
  78. package/dist/{kg-entity-1uqCnk4u.mjs.map → kg-entity-COTUj1ZC.mjs.map} +1 -1
  79. package/dist/{latent-ideas-CSuKfiq3.mjs → latent-ideas-ByvMjVcb.mjs} +3 -3
  80. package/dist/{latent-ideas-CSuKfiq3.mjs.map → latent-ideas-ByvMjVcb.mjs.map} +1 -1
  81. package/dist/{link-boost-BtjzfE3c.mjs → link-boost-UiE-uooT.mjs} +1 -1
  82. package/dist/{link-boost-BtjzfE3c.mjs.map → link-boost-UiE-uooT.mjs.map} +1 -1
  83. package/dist/{main-resolver-DyOrwNYw.mjs → main-resolver-CDe7DCso.mjs} +6 -6
  84. package/dist/{main-resolver-DyOrwNYw.mjs.map → main-resolver-CDe7DCso.mjs.map} +1 -1
  85. package/dist/main-resolver-DI-A7lSO.mjs +7 -0
  86. package/dist/merge-2gqRPFu2.mjs +3 -0
  87. package/dist/{merge-D58n_-LZ.mjs → merge-DgU9OgZy.mjs} +1 -1
  88. package/dist/{merge-D58n_-LZ.mjs.map → merge-DgU9OgZy.mjs.map} +1 -1
  89. package/dist/module-paths-DdRzbkUI.mjs +44 -0
  90. package/dist/module-paths-DdRzbkUI.mjs.map +1 -0
  91. package/dist/{neighborhood-DNuRelRB.mjs → neighborhood-CvRHlqdR.mjs} +2 -2
  92. package/dist/{neighborhood-DNuRelRB.mjs.map → neighborhood-CvRHlqdR.mjs.map} +1 -1
  93. package/dist/{note-context-D9JZ4-7o.mjs → note-context-BrbfUIoP.mjs} +1 -1
  94. package/dist/{note-context-D9JZ4-7o.mjs.map → note-context-BrbfUIoP.mjs.map} +1 -1
  95. package/dist/{pai-home-UncxWxlX.mjs → pai-home-Cm9rcJgX.mjs} +1 -1
  96. package/dist/{pai-home-UncxWxlX.mjs.map → pai-home-Cm9rcJgX.mjs.map} +1 -1
  97. package/dist/{planner-DgC3oSvb.mjs → planner-8-shIa8t.mjs} +7 -7
  98. package/dist/{planner-DgC3oSvb.mjs.map → planner-8-shIa8t.mjs.map} +1 -1
  99. package/dist/{postgres-CcsRKir-.mjs → postgres-DYtZg7J7.mjs} +5 -13
  100. package/dist/{postgres-CcsRKir-.mjs.map → postgres-DYtZg7J7.mjs.map} +1 -1
  101. package/dist/{program-JhF2GgJY.mjs → program-DiktbiWa.mjs} +225 -245
  102. package/dist/program-DiktbiWa.mjs.map +1 -0
  103. package/dist/{query-feedback-BPa0dISE.mjs → query-feedback-BIaZTTFO.mjs} +2 -2
  104. package/dist/{query-feedback-BPa0dISE.mjs.map → query-feedback-BIaZTTFO.mjs.map} +1 -1
  105. package/dist/query-feedback-B_iigYj-.mjs +3 -0
  106. package/dist/{registry-db-DCzdI4sC.mjs → registry-db-C7voqML9.mjs} +2 -2
  107. package/dist/{registry-db-DCzdI4sC.mjs.map → registry-db-C7voqML9.mjs.map} +1 -1
  108. package/dist/registry-db-JHPhA8vF.mjs +3 -0
  109. package/dist/{registry-postgres-BUnkHKYs.mjs → registry-postgres-DSrkxqfF.mjs} +2 -2
  110. package/dist/{registry-postgres-BUnkHKYs.mjs.map → registry-postgres-DSrkxqfF.mjs.map} +1 -1
  111. package/dist/{registry-sqlite-CKYNwkUv.mjs → registry-sqlite-B2436JgX.mjs} +2 -2
  112. package/dist/{registry-sqlite-CKYNwkUv.mjs.map → registry-sqlite-B2436JgX.mjs.map} +1 -1
  113. package/dist/router-S6C5BxzZ.mjs +3 -0
  114. package/dist/{router-CGZATlvm.mjs → router-aqLMvNMg.mjs} +1 -1
  115. package/dist/{router-CGZATlvm.mjs.map → router-aqLMvNMg.mjs.map} +1 -1
  116. package/dist/{run-W2rV_9j0.mjs → run-DUZRSnIT.mjs} +120 -42
  117. package/dist/run-DUZRSnIT.mjs.map +1 -0
  118. package/dist/{run-env-Br4Bc7Vz.mjs → run-env-BHWnXqle.mjs} +3 -3
  119. package/dist/{run-env-Br4Bc7Vz.mjs.map → run-env-BHWnXqle.mjs.map} +1 -1
  120. package/dist/{runtime-paths-QGQJAekd.mjs → runtime-paths-CHTg3ywb.mjs} +1 -1
  121. package/dist/{runtime-paths-QGQJAekd.mjs.map → runtime-paths-CHTg3ywb.mjs.map} +1 -1
  122. package/dist/{server-B71rem4q.mjs → server-DlL1QI2k.mjs} +4 -5
  123. package/dist/server-DlL1QI2k.mjs.map +1 -0
  124. package/dist/{session-keepalive-Dgil9hjw.mjs → session-keepalive-CZUwp5IQ.mjs} +7 -7
  125. package/dist/{session-keepalive-Dgil9hjw.mjs.map → session-keepalive-CZUwp5IQ.mjs.map} +1 -1
  126. package/dist/skills/Art/SKILL.md +1 -1
  127. package/dist/skills/Observability/SKILL.md +4 -4
  128. package/dist/skills/Research/SKILL.md +1 -1
  129. package/dist/{sources-LcGptLA-.mjs → sources-DX4ElmrE.mjs} +1 -1
  130. package/dist/{sources-LcGptLA-.mjs.map → sources-DX4ElmrE.mjs.map} +1 -1
  131. package/dist/{sqlite-CAJcw2zL.mjs → sqlite-BrEu3avy.mjs} +3 -3
  132. package/dist/{sqlite-CAJcw2zL.mjs.map → sqlite-BrEu3avy.mjs.map} +1 -1
  133. package/dist/{state-S9wlKarB.mjs → state-DW8zdweW.mjs} +1 -1
  134. package/dist/{state-CHltNjXI.mjs → state-HyjqTihC.mjs} +1 -1
  135. package/dist/{state-CHltNjXI.mjs.map → state-HyjqTihC.mjs.map} +1 -1
  136. package/dist/{themes-D1FQFthd.mjs → themes-BgqahYVM.mjs} +2 -2
  137. package/dist/{themes-D1FQFthd.mjs.map → themes-BgqahYVM.mjs.map} +1 -1
  138. package/dist/{tools-BW7OXf-N.mjs → tools-BbqQIHPe.mjs} +4 -4
  139. package/dist/{tools-BFC-113F.mjs → tools-gMGDIpJ9.mjs} +18 -18
  140. package/dist/{tools-BFC-113F.mjs.map → tools-gMGDIpJ9.mjs.map} +1 -1
  141. package/dist/{trace-IDKK1VFs.mjs → trace-CmAB7iJZ.mjs} +1 -1
  142. package/dist/{trace-IDKK1VFs.mjs.map → trace-CmAB7iJZ.mjs.map} +1 -1
  143. package/dist/{vault-indexer-y6YC2-mh.mjs → vault-indexer-Ddq51X20.mjs} +1 -1
  144. package/dist/{vault-indexer-y6YC2-mh.mjs.map → vault-indexer-Ddq51X20.mjs.map} +1 -1
  145. package/dist/{wakeup-CNZQZzsY.mjs → wakeup-CZw88uXf.mjs} +3 -3
  146. package/dist/{wakeup-CNZQZzsY.mjs.map → wakeup-CZw88uXf.mjs.map} +1 -1
  147. package/dist/{work-queue-worker-Dy5FT2ak.mjs → work-queue-worker-DCzfH0d1.mjs} +4 -4
  148. package/dist/{work-queue-worker-Dy5FT2ak.mjs.map → work-queue-worker-DCzfH0d1.mjs.map} +1 -1
  149. package/dist/work-queue-worker-Taba_vcA.mjs +11 -0
  150. package/dist/{zettelkasten-Dx-63BEk.mjs → zettelkasten-Dwj67e4p.mjs} +4 -4
  151. package/dist/{zettelkasten-Dx-63BEk.mjs.map → zettelkasten-Dwj67e4p.mjs.map} +1 -1
  152. package/docker/docker-compose.yml +39 -0
  153. package/docs/commands/README.md +3 -3
  154. package/docs/commands/config.md +3 -3
  155. package/docs/commands/setup.md +9 -2
  156. package/docs/commands/worker.md +2 -2
  157. package/package.json +3 -2
  158. package/scripts/build-hooks.mjs +6 -6
  159. package/src/hooks/ts/lib/pai-paths-case.test.ts +12 -0
  160. package/src/hooks/ts/lib/pai-paths-import.test.ts +25 -0
  161. package/src/hooks/ts/lib/pai-paths.ts +5 -28
  162. package/src/hooks/ts/lib/sleep-poll-gate.test.ts +17 -1
  163. package/src/hooks/ts/lib/sleep-poll-gate.ts +21 -9
  164. package/src/hooks/ts/session-start/load-core-context.ts +2 -6
  165. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +24 -1
  166. package/dist/context-handover-cache-BOpjLsKe.mjs.map +0 -1
  167. package/dist/daemon-q3xjHwa4.mjs +0 -19
  168. package/dist/detector-C-YmZsOi.mjs +0 -3
  169. package/dist/fallback-ECiqCuh6.mjs.map +0 -1
  170. package/dist/federation-db-BZ8PyxFe.mjs +0 -3
  171. package/dist/main-resolver-vN09bkXv.mjs +0 -7
  172. package/dist/merge-CXGz5wXz.mjs +0 -3
  173. package/dist/program-JhF2GgJY.mjs.map +0 -1
  174. package/dist/query-feedback-BPHFFSu1.mjs +0 -3
  175. package/dist/registry-db-Hphlj3H4.mjs +0 -3
  176. package/dist/router-CoA8Uy1m.mjs +0 -3
  177. package/dist/run-W2rV_9j0.mjs.map +0 -1
  178. package/dist/server-B71rem4q.mjs.map +0 -1
  179. package/dist/work-queue-worker-CkShUa0z.mjs +0 -11
  180. /package/dist/{indexer-backend-DotpxHJd.mjs → indexer-backend-isSLg6yE.mjs} +0 -0
@@ -1,7 +1,7 @@
1
- import { _ as writeYamlFileAtomic, d as readMainConfigRaw, f as writeMainConfigRaw, l as paiConfigFilePath, m as migrateMainConfigToYaml, n as CONFIG_FILE, r as DEFAULTS, s as loadConfig, u as paiConfigYamlFilePath, v as readJsonStrict, y as writeJsonAtomic } from "./config-2wkz744W.mjs";
2
- import { A as writeWorkersSection, C as nativeAnthropicProvider, D as resolveCapability, E as readWorkersSection, O as resolveModelCapability, R as workersYamlPath, S as maskKey, a as DEFAULT_CACHE_KEEPALIVE_SECS, b as isModelCapability, c as DEFAULT_ROUTING, f as PROVIDER_TAGS, k as resolveProviderKey, l as DEFAULT_TREE, m as WorkersConfigError, o as DEFAULT_LOG_DIR, r as ANTHROPIC_NATIVE, s as DEFAULT_PANE, u as MODEL_CAPABILITIES, v as expandHome, w as providerCostTier, x as keysDir, y as getProviderOrNative } from "./run-env-Br4Bc7Vz.mjs";
3
- import { B as loadStatuses, F as contextPercent, I as elapsedOf, L as isChatPane, M as ageOf, N as alive, P as contextLabel, R as isLive, S as workerInScope, Y as shortText, a as parseWorkerReport, c as readInbox, g as currentTabKey, h as markControlsHeld, j as UNLABELED, k as appendLedger, l as sayToWorker, m as clickrControlsArgv, o as renderReport, u as workerDepth, x as sessionTag, y as resolveSession } from "./run-W2rV_9j0.mjs";
4
- import { a as clearCooldown, c as probeQuota, d as resolveTarget, h as ledgerPath, l as quotaSkipThreshold, m as eventsPath, v as workersLogDir } from "./server-B71rem4q.mjs";
1
+ import { _ as writeYamlFileAtomic, d as readMainConfigRaw, f as writeMainConfigRaw, l as paiConfigFilePath, m as migrateMainConfigToYaml, n as CONFIG_FILE, r as DEFAULTS, s as loadConfig, u as paiConfigYamlFilePath, v as readJsonStrict, y as writeJsonAtomic } from "./config-BbLFD7Uf.mjs";
2
+ import { A as writeWorkersSection, C as nativeAnthropicProvider, D as resolveCapability, E as readWorkersSection, O as resolveModelCapability, R as workersYamlPath, S as maskKey, a as DEFAULT_CACHE_KEEPALIVE_SECS, b as isModelCapability, c as DEFAULT_ROUTING, f as PROVIDER_TAGS, k as resolveProviderKey, l as DEFAULT_TREE, m as WorkersConfigError, o as DEFAULT_LOG_DIR, r as ANTHROPIC_NATIVE, s as DEFAULT_PANE, u as MODEL_CAPABILITIES, v as expandHome, w as providerCostTier, x as keysDir, y as getProviderOrNative } from "./run-env-BHWnXqle.mjs";
3
+ import { B as loadStatuses, F as contextPercent, I as elapsedOf, L as isChatPane, M as ageOf, N as alive, P as contextLabel, R as isLive, S as workerInScope, Y as shortText, a as parseWorkerReport, c as readInbox, g as currentTabKey, h as markControlsHeld, j as UNLABELED, k as appendLedger, l as sayToWorker, m as clickrControlsArgv, o as renderReport, u as workerDepth, x as sessionTag, y as resolveSession } from "./run-DUZRSnIT.mjs";
4
+ import { a as clearCooldown, c as probeQuota, d as resolveTarget, h as ledgerPath, l as quotaSkipThreshold, m as eventsPath, v as workersLogDir } from "./server-DlL1QI2k.mjs";
5
5
  import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { basename, join, relative, resolve } from "node:path";
@@ -213,909 +213,1248 @@ function unsetConfigValueOp(dottedPath) {
213
213
  }
214
214
 
215
215
  //#endregion
216
- //#region src/workers/specfile.ts
216
+ //#region src/workers/providers.ts
217
217
  /**
218
- * specfile.ts — `--spec <path>` reads a worker's prompt from a file (or stdin
219
- * via `--spec -`) instead of an inline `-p '<prompt>'`, which has repeatedly
220
- * died on shell quoting for anything long or multi-line.
218
+ * providers.ts — provider, class and switch management over the workers config.
219
+ *
220
+ * One layer under both `pai worker providers …` and the MCP worker_providers
221
+ * tool. Every mutation re-reads the config file, changes only the workers
222
+ * section, and writes it back atomically — the file is shared with everything
223
+ * else PAI runs, so a torn write is not an option.
224
+ *
225
+ * The CLI's `--key` writes the token inline as `key:` in workers.yaml
226
+ * (quoted, and the file is kept 0600 — see workers-config.ts); `--key-file`
227
+ * still writes only a path. The MCP `add` tool instead accepts a raw `key`
228
+ * and parks it in ~/.claude/pai/keys/<name>, mode 0600, storing only the
229
+ * path in the config — a chat is not a place to leave a credential lying
230
+ * around.
221
231
  */
222
- /** The path a --spec value resolves to for display/recording; "-" (stdin) passes through unchanged. */
223
- function resolveSpecPath(spec, cwd) {
224
- return spec === "-" ? "-" : resolve(cwd, spec);
225
- }
226
- /** The prompt text a --spec value reads: the file's exact bytes, or stdin's. */
227
- function readSpecPrompt(spec, cwd) {
228
- if (spec === "-") {
229
- let content;
232
+ function addProvider(input) {
233
+ 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 _`);
234
+ 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.");
235
+ if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
236
+ let keyFile = input.keyFile ?? null;
237
+ if (input.key !== void 0) {
238
+ const dir = keysDir();
239
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
240
+ const path = `${dir}/${input.name}`;
241
+ writeFileSync(path, input.key.trim() + "\n", {
242
+ encoding: "utf8",
243
+ mode: 384
244
+ });
230
245
  try {
231
- content = readFileSync(0, "utf8");
232
- } catch (e) {
233
- throw new Error(`--spec -: could not read stdin: ${e.message}`);
234
- }
235
- if (!content) throw new Error("--spec -: stdin was empty");
236
- return content;
246
+ chmodSync(path, 384);
247
+ } catch {}
248
+ keyFile = path;
237
249
  }
238
- const path = resolveSpecPath(spec, cwd);
239
- if (!existsSync(path)) throw new Error(`--spec: file not found: ${path}`);
240
- const content = readFileSync(path, "utf8");
241
- if (!content) throw new Error(`--spec: file is empty: ${path}`);
242
- return content;
250
+ const { raw, workers } = readWorkersSection(input.configPath);
251
+ const provider = {
252
+ enabled: true,
253
+ protocol: input.protocol ?? "anthropic",
254
+ baseUrl: input.baseUrl,
255
+ keyFile,
256
+ ...input.inlineKey ? { key: input.inlineKey } : {},
257
+ models: input.fastModel ? {
258
+ default: input.model,
259
+ fast: input.fastModel
260
+ } : { default: input.model },
261
+ env: input.env ?? {},
262
+ ...input.note ? { note: input.note } : {},
263
+ ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
264
+ ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
265
+ ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
266
+ ...input.contextWindow ? { contextWindow: input.contextWindow } : {},
267
+ ...input.costTier ? { costTier: input.costTier } : {},
268
+ ...input.tags?.length ? { tags: input.tags } : {}
269
+ };
270
+ workers.providers[input.name] = provider;
271
+ if (Object.keys(workers.providers).length === 1) {
272
+ workers.enabled = true;
273
+ workers.active = input.name;
274
+ workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
275
+ const fast = input.fastModel ? `${input.name}/fast` : input.name;
276
+ workers.classes = {
277
+ draft: fast,
278
+ plan: input.name,
279
+ implement: input.name,
280
+ review: input.name,
281
+ research: input.name,
282
+ spotcheck: fast,
283
+ simple: fast,
284
+ complex: input.name,
285
+ image: input.name
286
+ };
287
+ }
288
+ writeWorkersSection(raw, workers, input.configPath);
289
+ return workers;
243
290
  }
244
-
245
- //#endregion
246
- //#region src/workers/chatui.ts
247
- /** Dim hint shown once behind the cursor until the first line is typed. */
248
- const CHAT_HINT = "type here and press Enter · /help for commands";
249
- /** What `/help` prints (one command per line, dim). */
250
- const CHAT_HELP = [
251
- "/quit close this pane",
252
- "/resume <text> continue the finished worker with <text>",
253
- "/status one-line worker status",
254
- "your controls exclusive clickr control to this worker (my controls takes it back)",
255
- "anything else is sent to the worker — said while it runs, resumed after"
256
- ];
257
- /** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
258
- function endOfEscape(s, i) {
259
- const n = s[i + 1];
260
- if (n === "[") {
261
- let j = i + 2;
262
- while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
263
- return Math.min(s.length, j + 1);
291
+ /** Change costTier / tags on an existing provider (MCP action "update"). */
292
+ function updateProvider(name, changes, configPath) {
293
+ const { raw, workers } = readWorkersSection(configPath);
294
+ const p = workers.providers[name];
295
+ if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
296
+ if (changes.costTier !== void 0) {
297
+ if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
298
+ p.costTier = changes.costTier;
264
299
  }
265
- if (n === "]") {
266
- let j = i + 2;
267
- while (j < s.length && s[j] !== "\x07") j++;
268
- return Math.min(s.length, j + 1);
300
+ if (changes.tags !== void 0) {
301
+ for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
302
+ p.tags = changes.tags;
269
303
  }
270
- return i + 2;
304
+ writeWorkersSection(raw, workers, configPath);
305
+ return workers;
271
306
  }
272
- /** Printable columns of `s` — ANSI escape sequences measure zero. */
273
- function visibleWidth(s) {
274
- let w = 0;
275
- let i = 0;
276
- while (i < s.length) {
277
- if (s[i] === "\x1B") {
278
- i = endOfEscape(s, i);
279
- continue;
280
- }
281
- w += 1;
282
- i += 1;
283
- }
284
- return w;
307
+ function removeProvider(name, configPath) {
308
+ const { raw, workers } = readWorkersSection(configPath);
309
+ if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
310
+ delete workers.providers[name];
311
+ for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
312
+ if (workers.active === name) workers.active = null;
313
+ writeWorkersSection(raw, workers, configPath);
314
+ return workers;
285
315
  }
286
316
  /**
287
- * The SGR sequences in effect at `upto`: everything opened since the last
288
- * reset, in order. Anything that is not an SGR escape is ignored (it does
289
- * not change colour state).
317
+ * Point a class target at `name`, keeping every other field. Only touches
318
+ * targets that already pin a provider — a constraint-only object (no
319
+ * `provider`) only narrows auto-routing and is left alone. A string target's
320
+ * `/alias` is dropped (falling back to `name`'s default model) when `name`
321
+ * has no such model configured — the class must move to `name` either way,
322
+ * since leaving it on the old provider is exactly the bug this fixes.
290
323
  */
291
- function sgrStateAt(text, upto) {
292
- const open = [];
293
- let i = 0;
294
- while (i < Math.min(upto, text.length)) {
295
- if (text[i] === "\x1B") {
296
- const end = endOfEscape(text, i);
297
- const esc = text.slice(i, end);
298
- if (/^\x1b\[[0-9;]*m$/.test(esc)) {
299
- const params = esc.slice(2, -1);
300
- if (params === "" || params.split(";").includes("0")) open.length = 0;
301
- if (!(params === "" || params === "0")) open.push(esc);
302
- }
303
- i = end;
304
- continue;
305
- }
306
- i += 1;
324
+ function repointClassTarget(target, name, providers) {
325
+ if (typeof target === "string") {
326
+ const [provider, alias] = target.split("/");
327
+ if (provider === name) return null;
328
+ if (alias && name !== ANTHROPIC_NATIVE && alias !== "default" && !providers[name]?.models[alias]) return name;
329
+ return alias ? `${name}/${alias}` : name;
307
330
  }
308
- return open;
331
+ if (!target.provider || target.provider === name) return null;
332
+ return {
333
+ ...target,
334
+ provider: name
335
+ };
309
336
  }
310
337
  /**
311
- * Wrap one rendered row to `width` printable columns. Breaks on whitespace
312
- * where possible, hard-wraps words longer than the width, never splits an
313
- * ANSI escape, and re-opens the colours it wraps inside of, so a diff row
314
- * keeps its `-`/`+` colour on every continuation row.
338
+ * Make `name` the active provider. Unless `name` is "auto", also re-points
339
+ * every class whose target pins a provider (string or `{provider: …}`) at
340
+ * it — a class pinned to a provider otherwise wins over `active` at run time
341
+ * (routing.ts resolveTarget), so leaving classes untouched would make `use`
342
+ * silently do nothing for any class already pinned elsewhere.
315
343
  */
316
- function wrapText(text, width) {
317
- if (width < 1 || visibleWidth(text) <= width) return [text];
318
- const chars = [];
319
- for (let i = 0; i < text.length; i++) {
320
- if (text[i] === "\x1B") {
321
- i = endOfEscape(text, i) - 1;
322
- continue;
323
- }
324
- chars.push(i);
325
- }
326
- const words = [];
327
- {
328
- let s = -1;
329
- let w = 0;
330
- for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
331
- if (s >= 0) {
332
- words.push({
333
- s,
334
- e: k - 1,
335
- w
336
- });
337
- s = -1;
338
- w = 0;
339
- }
340
- } else {
341
- if (s < 0) s = k;
342
- w++;
344
+ function useProvider(name, configPath) {
345
+ const { raw, workers } = readWorkersSection(configPath);
346
+ if (name !== ANTHROPIC_NATIVE && name !== "auto" && !workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
347
+ workers.active = name;
348
+ let repointed = 0;
349
+ if (name !== "auto") for (const [cls, target] of Object.entries(workers.classes)) {
350
+ const next = repointClassTarget(target, name, workers.providers);
351
+ if (next !== null) {
352
+ workers.classes[cls] = next;
353
+ repointed++;
343
354
  }
344
355
  }
345
- if (words.length && words[0].s > 0) words[0] = {
346
- s: 0,
347
- e: words[0].e,
348
- w: words[0].w + words[0].s
356
+ writeWorkersSection(raw, workers, configPath);
357
+ return {
358
+ workers,
359
+ repointed
349
360
  };
350
- const spans = [];
351
- let cs = -1;
352
- let ce = -1;
353
- for (const word of words) {
354
- if (word.w > width) {
355
- const room = cs < 0 ? 0 : width - (ce - cs + 1);
356
- const take = room > 0 ? ce + room - word.s + 1 : 0;
357
- if (take > 0) spans.push([cs, ce + room]);
358
- else if (cs >= 0) spans.push([cs, ce]);
359
- let pos = word.s + Math.max(0, take);
360
- let remaining = word.w - Math.max(0, take);
361
- while (remaining > width) {
362
- spans.push([pos, pos + width - 1]);
363
- pos += width;
364
- remaining -= width;
365
- }
366
- cs = pos;
367
- ce = pos + remaining - 1;
368
- continue;
369
- }
370
- if (cs < 0) {
371
- cs = word.s;
372
- ce = word.e;
373
- continue;
374
- }
375
- if (word.e - cs + 1 <= width) {
376
- ce = word.e;
377
- continue;
378
- }
379
- spans.push([cs, ce]);
380
- cs = word.s;
381
- ce = word.e;
382
- }
383
- if (cs >= 0) spans.push([cs, ce]);
384
- const out = [];
385
- for (const [a, b] of spans) {
386
- const from = chars[a];
387
- let end = chars[b] + 1;
388
- while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
389
- let piece = text.slice(from, end);
390
- const reopen = sgrStateAt(text, from);
391
- if (reopen.length) piece = reopen.join("") + piece;
392
- if (sgrStateAt(text, end).length) piece += "\x1B[0m";
393
- out.push(piece);
394
- }
395
- return out.length ? out : [""];
396
- }
397
- /** Restrict scrolling to the transcript region (rows 1 … rows-3). */
398
- function chatScrollRegion(rows) {
399
- return `\x1b[1;${Math.max(1, rows - 3)}r`;
400
361
  }
401
362
  /**
402
- * The separator row between the transcript and the prompt: cleared, then —
403
- * when the pane's width is known — a dim rule of `cols` box-drawing `─`
404
- * filling the row. `cols` 0 (unknown) keeps the row blank; `dim` is the
405
- * pane's colour helper, identity when colours are off.
363
+ * The provider a model command targets: the named one, else the active one.
364
+ * "auto" and an unset active both ask for an explicit name.
406
365
  */
407
- function chatBlankRow(rows, cols = 0, dim = (s) => s) {
408
- const rule = cols > 0 ? dim("─".repeat(cols)) : "";
409
- return `\x1b[${Math.max(1, rows - 2)};1H\x1b[K` + rule;
366
+ function resolveProviderName(workers, name) {
367
+ const target = name ?? workers.active ?? "";
368
+ if (target === ANTHROPIC_NATIVE || workers.providers[target]) return target;
369
+ if (!target) throw new WorkersConfigError("no active provider — name one explicitly");
370
+ if (target === "auto") throw new WorkersConfigError("active is \"auto\" — name a provider explicitly");
371
+ throw new WorkersConfigError(`no provider named "${target}". Configured: ${[ANTHROPIC_NATIVE, ...Object.keys(workers.providers)].join(", ")}`);
410
372
  }
411
373
  /**
412
- * Enter the chat layout: clear the pane, set the scroll region, draw the
413
- * separator row and park the cursor at column 3 of the prompt row (rows-1,
414
- * right after `› `). The ticker owns row `rows`.
374
+ * Set a provider's model id for a capability — default, fast, image, …
375
+ * (`pai worker model`, MCP worker_model).
415
376
  */
416
- function chatEnter(rows, cols = 0, dim = (s) => s) {
417
- return "\x1B[2J" + chatScrollRegion(rows) + chatBlankRow(rows, cols, dim) + `\x1b[${Math.max(1, rows - 1)};3H`;
377
+ function setProviderModel(name, capability, model, configPath) {
378
+ if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$; well-known: ${MODEL_CAPABILITIES.join(", ")})`);
379
+ const id = model.trim();
380
+ if (!id) throw new WorkersConfigError(`a ${capability} model id must not be empty`);
381
+ const { raw, workers } = readWorkersSection(configPath);
382
+ const p = getProviderOrNative(workers, name);
383
+ if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${[ANTHROPIC_NATIVE, ...Object.keys(workers.providers)].join(", ")}`);
384
+ (name === ANTHROPIC_NATIVE ? workers.nativeModels : p.models)[capability] = id;
385
+ writeWorkersSection(raw, workers, configPath);
386
+ return workers;
418
387
  }
419
- /** Leave it: reset the scroll region, show the cursor, drop to the last row. */
420
- function chatLeave(rows) {
421
- return `[?25h\x1b[${Math.max(1, rows)};1H`;
388
+ function setProviderEnabled(name, enabled, configPath) {
389
+ const { raw, workers } = readWorkersSection(configPath);
390
+ const p = workers.providers[name];
391
+ if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
392
+ p.enabled = enabled;
393
+ writeWorkersSection(raw, workers, configPath);
394
+ if (enabled) clearCooldown(workersLogDir(workers), name);
395
+ return workers;
422
396
  }
423
397
  /**
424
- * Redraw the ticker on its own row and park the cursor back on the prompt
425
- * row at `parkCol` (default right after `› `): a ticker refresh never leaves
426
- * the cursor on the bottom row.
398
+ * Point a class at a target ("provider", "provider/fast", or an object with
399
+ * provider + optional mcp/maxCostTier/requireTags/order). An object without
400
+ * a provider only constrains auto-routing for that class.
427
401
  */
428
- function chatTickerRow(text, rows, parkCol = 3) {
429
- return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + `\x1b[${Math.max(1, rows - 1)};${Math.max(1, parkCol)}H`;
402
+ function setClass(name, target, configPath) {
403
+ const { raw, workers } = readWorkersSection(configPath);
404
+ if (typeof target === "string") {
405
+ const [provider, alias] = target.split("/");
406
+ const native = provider === ANTHROPIC_NATIVE;
407
+ const p = native ? void 0 : workers.providers[provider];
408
+ if (!native && !p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
409
+ if (alias) {
410
+ if (!isModelCapability(alias)) throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose: ${MODEL_CAPABILITIES.join(", ")}`);
411
+ if (!native && alias !== "default" && !p.models[alias]) throw new WorkersConfigError(`provider "${provider}" has no ${alias} model configured`);
412
+ }
413
+ } else if (target.provider) {
414
+ if (target.provider !== ANTHROPIC_NATIVE && !workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
415
+ }
416
+ workers.classes[name] = target;
417
+ writeWorkersSection(raw, workers, configPath);
418
+ return workers;
430
419
  }
431
- /**
432
- * The prompt row: the fixed `› ` prefix, then the input buffer — or, while
433
- * the buffer is empty, the dim placeholder behind the same prefix. The
434
- * cursor parks right after the buffer (`cursorAt` chars in, for a cursor
435
- * mid-draft), so typed text always starts at the column the placeholder
436
- * occupied, never glued to a hint.
437
- */
438
- function chatPromptRow(rows, buffer = "", dim = (s) => s, cursorAt) {
439
- const rowN = Math.max(1, rows - 1);
440
- const body = buffer !== "" ? buffer : dim(CHAT_HINT);
441
- const col = 3 + Math.max(0, Math.min(cursorAt ?? buffer.length, buffer.length));
442
- return `\x1b[${rowN};1H\x1b[K› ` + body + `\x1b[${rowN};${col}H`;
420
+ function unsetClass(name, configPath) {
421
+ const { raw, workers } = readWorkersSection(configPath);
422
+ if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
423
+ delete workers.classes[name];
424
+ writeWorkersSection(raw, workers, configPath);
425
+ return workers;
443
426
  }
444
- /**
445
- * Insert one transcript row above the fixed prompt/ticker rows. While the
446
- * region is still filling (`fill < regionRows`) the row is placed top-down;
447
- * once full, the cursor moves to the region's bottom row and a newline
448
- * scrolls the region up by one — the two fixed rows are never touched. The
449
- * user's cursor is saved before and restored after, so readline keeps its
450
- * position on the prompt row.
451
- */
452
- function chatInsertLine(line, fill, regionRows) {
453
- return {
454
- seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
455
- fill: Math.min(fill + 1, regionRows)
456
- };
427
+ function setWorkersEnabled(enabled, configPath) {
428
+ const { raw, workers } = readWorkersSection(configPath);
429
+ workers.enabled = enabled;
430
+ writeWorkersSection(raw, workers, configPath);
431
+ return workers;
457
432
  }
458
- const CONTROLS_LINE = /^(your|my) controls\.?$/i;
459
- /**
460
- * One submitted prompt line → what to do with it. `/help`, `/quit`,
461
- * `/status` and `/resume <text>` are commands (a bare `/resume` comes back
462
- * with empty text so the caller can print its usage); "your controls" /
463
- * "my controls" is the same `pai worker controls <id> you|me` action, never
464
- * forwarded to the worker as a normal chat line; anything else, including
465
- * any other `/word`, is a message for the worker.
466
- */
467
- function parseChatLine(raw) {
468
- const text = raw.trim();
469
- if (text === "/help") return { kind: "help" };
470
- if (text === "/quit") return { kind: "quit" };
471
- if (text === "/status") return { kind: "status" };
472
- if (text.startsWith("/resume")) return {
473
- kind: "resume",
474
- text: text.slice(7).trim()
475
- };
476
- const controls = text.match(CONTROLS_LINE);
477
- if (controls) return {
478
- kind: "controls",
479
- who: controls[1].toLowerCase() === "your" ? "you" : "me"
480
- };
481
- return {
482
- kind: "message",
483
- text
484
- };
433
+ /** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
434
+ function classTargetText(target) {
435
+ if (typeof target === "string") return target;
436
+ return [
437
+ target.provider ?? "(routing)",
438
+ ...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
439
+ ...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
440
+ ...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
441
+ ...target.order?.length ? [`order [${target.order.join(",")}]`] : []
442
+ ].join(" ");
485
443
  }
486
444
  /**
487
- * The auto-exit countdown must not fire while the prompt holds unsent text:
488
- * true while it does. null/undefined (no prompt wired) never holds.
445
+ * `default X fast Y image Z …` — every well-known capability on one line
446
+ * (unset ones shown as "(none)"), plus any open-set capability this provider
447
+ * actually declares (e.g. `vision`), which is only listed when set.
489
448
  */
490
- function holdAutoExit(promptText) {
491
- return typeof promptText === "string" && promptText.trim() !== "";
449
+ function modelPrefsText(p) {
450
+ const known = new Set(MODEL_CAPABILITIES);
451
+ const extra = Object.keys(p.models).filter((c) => !known.has(c)).sort();
452
+ return [...MODEL_CAPABILITIES, ...extra].map((c) => `${c} ${p.models[c] ?? "(none)"}`).join(" ");
492
453
  }
493
-
494
- //#endregion
495
- //#region src/workers/render.ts
496
454
  /**
497
- * render.ts — turn worker events into the lines a human reads.
498
- *
499
- * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
500
- * and searches, `$` for shell, magenta `~` + red/green diff for edits, green
501
- * `+` for writes), Read-result trimming (first 3 lines then a count), and the
502
- * result footer. Colors are applied only when the output is a TTY, so MCP
503
- * tool output stays plain text.
455
+ * Compact model listing for `pai worker model` / worker_model get: the active
456
+ * provider, then one line per provider with all its capability preferences.
504
457
  */
505
- const CODES = {
506
- dim: "2",
507
- bold: "1",
508
- red: "31",
509
- green: "32",
510
- yellow: "33",
511
- blue: "34",
512
- mag: "35",
513
- cyan: "36"
514
- };
515
- function makeColor(enabled) {
516
- return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
517
- }
518
- /** Path relative to cwd when it lies inside it, otherwise unchanged. */
519
- function relPath(path, cwd) {
520
- if (!path || !cwd) return path;
521
- let r;
522
- try {
523
- r = relative(cwd, path);
524
- } catch {
525
- return path;
458
+ function describeModels(workers) {
459
+ const lines = [`active provider: ${workers.active ?? "(none)"}`];
460
+ for (const name of [...Object.keys(workers.providers), ANTHROPIC_NATIVE]) {
461
+ const p = getProviderOrNative(workers, name);
462
+ const active = workers.active === name ? " [active]" : "";
463
+ lines.push(`${name}${active} ${modelPrefsText(p)}`);
526
464
  }
527
- return r.startsWith("..") ? path : r;
465
+ return lines;
528
466
  }
529
- /** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
530
- function unifiedDiffLines(oldStr, newStr) {
531
- const oldL = oldStr.split("\n");
532
- const newL = newStr.split("\n");
533
- let start = 0;
534
- while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
535
- let endOld = oldL.length;
536
- let endNew = newL.length;
537
- while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
538
- endOld--;
539
- endNew--;
540
- }
541
- const out = [];
542
- for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
543
- for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
544
- return out;
467
+ /** Classes (in declaration order) whose target resolves to this provider. */
468
+ function classesForProvider(workers, name) {
469
+ return Object.entries(workers.classes).filter(([, target]) => (typeof target === "string" ? target.split("/")[0] : target.provider) === name).map(([cls]) => cls);
545
470
  }
546
- /**
547
- * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
548
- * negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
549
- * null when the stamp cannot be parsed. Offsets make this testable without
550
- * depending on the machine's zone; the default is this machine's.
551
- */
552
- function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
553
- const t = Date.parse(ts);
554
- if (Number.isNaN(t)) return null;
555
- return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
556
- }
557
- /** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
558
- function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
559
- const t = Date.parse(ts);
560
- if (Number.isNaN(t)) return null;
561
- return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
471
+ /** Human-readable provider listing (quota probe included when configured). */
472
+ function describeProviders(workers, configPath = CONFIG_FILE) {
473
+ const lines = [];
474
+ const nativeActive = workers.active === ANTHROPIC_NATIVE;
475
+ const nativeDefault = !workers.active;
476
+ lines.push(`${ANTHROPIC_NATIVE} [built-in${nativeActive ? ", active" : ""}]${nativeDefault ? " (active: default)" : ""} Claude Code's own OAuth/Max-plan login — no base URL, no API key`);
477
+ lines.push(` ${modelPrefsText(nativeAnthropicProvider(workers.nativeModels))}`);
478
+ const nativeClasses = classesForProvider(workers, ANTHROPIC_NATIVE);
479
+ lines.push(` classes: ${nativeClasses.length ? nativeClasses.join(", ") : "(none)"}`);
480
+ const names = Object.keys(workers.providers);
481
+ if (!names.length) {
482
+ lines.push(`no other providers configured. Add one with:`);
483
+ lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
484
+ lines.push(`config: ${existsSync(workersYamlPath()) ? workersYamlPath() : configPath}`);
485
+ return lines;
486
+ }
487
+ for (const name of names) {
488
+ const p = workers.providers[name];
489
+ const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
490
+ const quota = p.quotaProbe ? probeQuota(p) : null;
491
+ const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
492
+ const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
493
+ lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
494
+ lines.push(` ${modelPrefsText(p)}${quotaNote}`);
495
+ lines.push(` ${tierTags}`);
496
+ if (p.key) lines.push(` key ${maskKey(p.key)}`);
497
+ if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
498
+ if (!p.key && !p.keyFile) lines.push(` no key file (token "local")`);
499
+ if (p.note) lines.push(` ${p.note}`);
500
+ if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
501
+ if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
502
+ if (p.engine === "image") lines.push(` engine image (no process spawn — POSTs {url}/images/generations directly)`);
503
+ const cls = classesForProvider(workers, name);
504
+ lines.push(` classes: ${cls.length ? cls.join(", ") : "(none)"}`);
505
+ }
506
+ const setNames = Object.keys(workers.mcpSets);
507
+ if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
508
+ if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
509
+ lines.push(`config: ${existsSync(workersYamlPath()) ? workersYamlPath() : configPath}`);
510
+ return lines;
562
511
  }
563
512
  /**
564
- * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
565
- * the worker tag in front when several run at once. Continuation lines get
566
- * blanks of the same width so wrapped text stays aligned; when the viewer
567
- * wraps lines itself, continuation rows carry the `│` bar instead (barCont)
568
- * so the bar runs unbroken down the pane. null when the event carries no
569
- * stamp (logs from before 2g render with the plain prefix). The time is the
570
- * stamp's wall clock at `offMin` — the default renders local time, whatever
571
- * zone stamped the log (old logs were stamped in UTC).
513
+ * Set which provider(s), in order, serve a capability across the whole
514
+ * config (`pai worker capability <name> <provider>[,<provider>…]`, MCP
515
+ * worker_capability). resolveCapability walks this list at run time.
572
516
  */
573
- function gutterFor(c, e, tag, offMin) {
574
- if (!e._ts) return null;
575
- const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
576
- const head = tag ? `${tag} ${time}` : time;
577
- const width = head.length + 3;
578
- return {
579
- first: c("dim", `${head} │ `),
580
- cont: " ".repeat(width),
581
- barCont: c("dim", `${" ".repeat(head.length)} │ `),
582
- width
583
- };
517
+ function setCapabilityPreference(capability, providers, configPath) {
518
+ if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$)`);
519
+ if (!providers.length) throw new WorkersConfigError("at least one provider name is required");
520
+ const { raw, workers } = readWorkersSection(configPath);
521
+ for (const name of providers) if (name !== ANTHROPIC_NATIVE && !workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
522
+ workers.capabilities[capability] = providers;
523
+ writeWorkersSection(raw, workers, configPath);
524
+ return workers;
584
525
  }
585
- /**
586
- * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
587
- * basename for the file tools, the bare name for everything else.
588
- */
589
- function tickerTool(name, inp) {
590
- const i = typeof inp === "object" && inp !== null ? inp : {};
591
- const get = (k) => typeof i[k] === "string" ? i[k] : "";
592
- if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
593
- if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
594
- return name;
526
+ function unsetCapabilityPreference(capability, configPath) {
527
+ const { raw, workers } = readWorkersSection(configPath);
528
+ if (!(capability in workers.capabilities)) throw new WorkersConfigError(`no capability preference set for "${capability}"`);
529
+ delete workers.capabilities[capability];
530
+ writeWorkersSection(raw, workers, configPath);
531
+ return workers;
595
532
  }
596
533
  /**
597
- * The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
598
- * seconds since the last *rendered* event, the worker's last stated intent
599
- * (its last assistant text, ≤60 chars) and the tool it is currently running.
600
- * Parts that are empty drop out.
534
+ * `pai worker capability` with no args: every configured `capabilities:`
535
+ * preference and what it resolves to right now — provider/model/engine, or
536
+ * `fallback: <provider>/<model>` when nothing on the list actually declares
537
+ * the capability (see resolveCapability's fellBack flag).
601
538
  */
602
- function tickerText(secs, intent, tool, meter) {
603
- const head = `⋯ ${secs}s`;
604
- const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
605
- const line = tail ? `${head} · ${tail}` : head;
606
- return meter ? `${line} · ${meter}` : line;
539
+ function describeCapabilities(workers) {
540
+ const names = Object.keys(workers.capabilities);
541
+ if (!names.length) return ["no capability preferences set — set one with `pai worker capability <name> <provider>[,<provider>…]`"];
542
+ return names.map((cap) => {
543
+ const pref = workers.capabilities[cap];
544
+ try {
545
+ const r = resolveCapability(workers, cap);
546
+ const resolved = r.fellBack ? `fallback: ${r.provider}/${r.model}` : `${r.provider}/${r.model} engine ${r.engine}`;
547
+ return `${cap}: [${pref.join(", ")}] -> ${resolved}`;
548
+ } catch (e) {
549
+ return `${cap}: [${pref.join(", ")}] -> unresolved: ${e instanceof Error ? e.message : String(e)}`;
550
+ }
551
+ });
607
552
  }
553
+
554
+ //#endregion
555
+ //#region src/workers/specfile.ts
608
556
  /**
609
- * One blank line between turns, none inside one: a blank goes before an
610
- * assistant message that follows a tool result or an operator message (the
611
- * worker starting to speak again after its tools were answered / it was told
612
- * something), not between the text, tool calls and results of one turn.
557
+ * specfile.ts — `--spec <path>` reads a worker's prompt from a file (or stdin
558
+ * via `--spec -`) instead of an inline `-p '<prompt>'`, which has repeatedly
559
+ * died on shell quoting for anything long or multi-line.
613
560
  */
614
- function blankBetween(prev, e) {
615
- if (!prev) return false;
616
- if (e.type !== "assistant") return false;
617
- return prev.type === "user" || prev.type === "operator" || prev.type === "handoff";
618
- }
619
- /** The worker's last stated intent: the first line of its last text, ≤60. */
620
- function intentOf(text) {
621
- return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
561
+ /** The path a --spec value resolves to for display/recording; "-" (stdin) passes through unchanged. */
562
+ function resolveSpecPath(spec, cwd) {
563
+ return spec === "-" ? "-" : resolve(cwd, spec);
622
564
  }
623
- /**
624
- * The context meter `ctx 84k/200k (42%)` (contextLabel, shared with the
625
- * chat status row), yellow from 70 %, red from 85 %. null when the numbers
626
- * are missing or below `minPct` (the table only shows it past 60; the pane
627
- * liveness line always shows it).
628
- */
629
- function contextMeter(c, s, minPct = 0) {
630
- const pct = contextPercent(s);
631
- if (pct === null || pct <= minPct) return null;
632
- const label = contextLabel(s);
633
- if (pct > 85) return c("red", label);
634
- if (pct > 70) return c("yellow", label);
635
- return label;
565
+ /** The prompt text a --spec value reads: the file's exact bytes, or stdin's. */
566
+ function readSpecPrompt(spec, cwd) {
567
+ if (spec === "-") {
568
+ let content;
569
+ try {
570
+ content = readFileSync(0, "utf8");
571
+ } catch (e) {
572
+ throw new Error(`--spec -: could not read stdin: ${e.message}`);
573
+ }
574
+ if (!content) throw new Error("--spec -: stdin was empty");
575
+ return content;
576
+ }
577
+ const path = resolveSpecPath(spec, cwd);
578
+ if (!existsSync(path)) throw new Error(`--spec: file not found: ${path}`);
579
+ const content = readFileSync(path, "utf8");
580
+ if (!content) throw new Error(`--spec: file is empty: ${path}`);
581
+ return content;
636
582
  }
637
- function renderToolUse(c, prefix, name, inp, cwd) {
638
- const i = typeof inp === "object" && inp !== null ? inp : {};
639
- const get = (k) => typeof i[k] === "string" ? i[k] : "";
640
- if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
641
- if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
642
- if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
643
- if (name === "Edit") {
644
- const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
645
- for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
646
- else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
647
- else out.push(`${prefix} ${c("dim", line)}`);
648
- return out;
583
+
584
+ //#endregion
585
+ //#region src/workers/chatui.ts
586
+ /** Dim hint shown once behind the cursor until the first line is typed. */
587
+ const CHAT_HINT = "type here and press Enter · /help for commands";
588
+ /** What `/help` prints (one command per line, dim). */
589
+ const CHAT_HELP = [
590
+ "/quit close this pane",
591
+ "/resume <text> continue the finished worker with <text>",
592
+ "/status one-line worker status",
593
+ "your controls exclusive clickr control to this worker (my controls takes it back)",
594
+ "anything else is sent to the worker — said while it runs, resumed after"
595
+ ];
596
+ /** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
597
+ function endOfEscape(s, i) {
598
+ const n = s[i + 1];
599
+ if (n === "[") {
600
+ let j = i + 2;
601
+ while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
602
+ return Math.min(s.length, j + 1);
649
603
  }
650
- if (name === "Write") {
651
- const n = get("content").split("\n").length;
652
- return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
604
+ if (n === "]") {
605
+ let j = i + 2;
606
+ while (j < s.length && s[j] !== "\x07") j++;
607
+ return Math.min(s.length, j + 1);
653
608
  }
654
- if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
655
- return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
609
+ return i + 2;
656
610
  }
657
- /** Render one stream-json event; `tools` maps tool_use_id → tool name. */
658
- function renderEvent(c, prefix, e, cwd, tools, gutter) {
659
- const out = [];
660
- if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
661
- else if (e.type === "operator") {
662
- if (e.handoff) return out;
663
- for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
664
- } else if (e.type === "handoff") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("mag", `◆ from ${e.from ?? "?"} · ${e.kind ?? "?"}: ${ln}`)}`);
665
- else if (e.type === "assistant") {
666
- 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}`);
667
- else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
668
- } else if (e.type === "user") for (const b of e.message?.content ?? []) {
669
- if (b.type !== "tool_result") continue;
670
- let content = b.content ?? "";
671
- if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
672
- const text = String(content);
673
- const isError = b.is_error === true;
674
- if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
675
- const lines = text.split("\n");
676
- for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
677
- if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
678
- } else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
679
- else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
680
- }
681
- else if (e.type === "result") {
682
- const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
683
- out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
684
- const report = parseWorkerReport(String(e.result ?? ""));
685
- if (report) out.push(...renderReport(c, prefix, report, cwd));
686
- else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
611
+ /** Printable columns of `s` — ANSI escape sequences measure zero. */
612
+ function visibleWidth(s) {
613
+ let w = 0;
614
+ let i = 0;
615
+ while (i < s.length) {
616
+ if (s[i] === "\x1B") {
617
+ i = endOfEscape(s, i);
618
+ continue;
619
+ }
620
+ w += 1;
621
+ i += 1;
687
622
  }
688
- if (!gutter) return out;
689
- return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
623
+ return w;
690
624
  }
691
- /** Transcript header: id, provider, label, session name, project dir. */
692
- function headerLine(c, s) {
693
- const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
694
- const sep = bits ? ` ${bits}` : "";
695
- const specBit = s.spec ? ` spec: ${s.spec}` : "";
696
- const reportBit = s.reportValid === false ? " " + c("red", s.reportFormat === "ag2" && (s.reportErrors ?? []).length ? "R!" : "R?") : "";
697
- return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})${specBit}${reportBit}`);
698
- }
699
- /** The chain label behind a stage label: strip the trailing " · <stage>". */
700
- function chainLabelOf(stages) {
701
- const first = stages[0];
702
- if (!first) return "";
703
- const suffix = first.stage ? ` · ${first.stage}` : "";
704
- return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
625
+ /**
626
+ * The SGR sequences in effect at `upto`: everything opened since the last
627
+ * reset, in order. Anything that is not an SGR escape is ignored (it does
628
+ * not change colour state).
629
+ */
630
+ function sgrStateAt(text, upto) {
631
+ const open = [];
632
+ let i = 0;
633
+ while (i < Math.min(upto, text.length)) {
634
+ if (text[i] === "\x1B") {
635
+ const end = endOfEscape(text, i);
636
+ const esc = text.slice(i, end);
637
+ if (/^\x1b\[[0-9;]*m$/.test(esc)) {
638
+ const params = esc.slice(2, -1);
639
+ if (params === "" || params.split(";").includes("0")) open.length = 0;
640
+ if (!(params === "" || params === "0")) open.push(esc);
641
+ }
642
+ i = end;
643
+ continue;
644
+ }
645
+ i += 1;
646
+ }
647
+ return open;
705
648
  }
706
649
  /**
707
- * The ps table (RUNNING + FINISHED last 8) as a forest: a worker whose parent
708
- * is another worker renders indented under it (`├`/`└` connectors); a parent
709
- * that is not a worker is a chain id and gets its old `chain <id>` header.
710
- * `⎇` marks an unmerged worktree branch, `◆N` an inbox with N handoffs.
650
+ * Wrap one rendered row to `width` printable columns. Breaks on whitespace
651
+ * where possible, hard-wraps words longer than the width, never splits an
652
+ * ANSI escape, and re-opens the colours it wraps inside of, so a diff row
653
+ * keeps its `-`/`+` colour on every continuation row.
711
654
  */
712
- function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date(), inbox = {}) {
713
- const running = [];
714
- const done = [];
715
- for (const s of statuses) if (isLive(s)) running.push(s);
716
- else {
717
- if (s.state === "running") s.state = "lost";
718
- done.push(s);
655
+ function wrapText(text, width) {
656
+ if (width < 1 || visibleWidth(text) <= width) return [text];
657
+ const chars = [];
658
+ for (let i = 0; i < text.length; i++) {
659
+ if (text[i] === "\x1B") {
660
+ i = endOfEscape(text, i) - 1;
661
+ continue;
662
+ }
663
+ chars.push(i);
719
664
  }
720
- const isWorker = (id) => statuses.some((s) => s.id === id);
721
- const chainOf = (s) => s.parent && !isWorker(s.parent) ? s.parent : null;
722
- const treeLine = (line, chain, last) => {
723
- if (chain === null) return line;
724
- const mark = last ? "└" : "├";
725
- const bar = last ? " " : "│";
726
- return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
727
- };
728
- const rowPrefix = (depth, last) => depth <= 0 ? " " : " " + "│ ".repeat(depth - 1) + (last ? "└ " : "├ ");
729
- const rowCont = (depth, last) => depth <= 0 ? " " : " " + "│ ".repeat(depth - 1) + (last ? " " : "│ ");
730
- const branchMark = (s) => s.branch && !s.merged ? " " + c("yellow", "⎇" + (s.commits ? String(s.commits) : "")) : "";
731
- const inboxMark = (s) => inbox[s.id] ? " " + c("mag", `◆${inbox[s.id]}`) : "";
732
- const reportMark = (s) => s.reportValid === false ? " " + c("red", s.reportFormat === "ag2" && (s.reportErrors ?? []).length ? "R!" : "R?") : "";
733
- const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
734
- lines.push(c("bold", `RUNNING (${running.length})`));
735
- if (!running.length) lines.push(" none");
736
- for (let i = 0; i < running.length; i++) {
737
- const s = running[i];
738
- const chain = chainOf(s);
739
- if (chain) {
740
- const prev = running[i - 1];
741
- if (!prev || chainOf(prev) !== chain) {
742
- const stages = running.filter((x) => chainOf(x) === chain);
743
- lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
665
+ const words = [];
666
+ {
667
+ let s = -1;
668
+ let w = 0;
669
+ for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
670
+ if (s >= 0) {
671
+ words.push({
672
+ s,
673
+ e: k - 1,
674
+ w
675
+ });
676
+ s = -1;
677
+ w = 0;
744
678
  }
679
+ } else {
680
+ if (s < 0) s = k;
681
+ w++;
745
682
  }
746
- const last = !!chain && (i + 1 >= running.length || chainOf(running[i + 1]) !== chain);
747
- const depth = workerDepth(statuses, s.id);
748
- const subLast = !chain && (i + 1 >= running.length || running[i + 1].parent !== s.parent);
749
- const meter = contextMeter(c, s, 60);
750
- const p = chain ? " " : rowPrefix(depth, subLast);
751
- const q = chain ? " " : rowCont(depth, subLast);
752
- lines.push(treeLine(`${p}${c("cyan", s.id)}${inboxMark(s)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${branchMark(s)}${meter ? " " + meter : ""}`, chain, last));
753
- lines.push(treeLine(`${q}task: ${s.label}`, chain, last));
754
- lines.push(treeLine(`${q}now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
755
683
  }
756
- lines.push("");
757
- lines.push(c("bold", "FINISHED (last 8)"));
758
- const doneSlice = done.slice(-8);
759
- for (let i = 0; i < doneSlice.length; i++) {
760
- const s = doneSlice[i];
761
- const chain = chainOf(s);
762
- if (chain) {
763
- const prev = doneSlice[i - 1];
764
- if (!prev || chainOf(prev) !== chain) {
765
- const stages = doneSlice.filter((x) => chainOf(x) === chain);
766
- lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
684
+ if (words.length && words[0].s > 0) words[0] = {
685
+ s: 0,
686
+ e: words[0].e,
687
+ w: words[0].w + words[0].s
688
+ };
689
+ const spans = [];
690
+ let cs = -1;
691
+ let ce = -1;
692
+ for (const word of words) {
693
+ if (word.w > width) {
694
+ const room = cs < 0 ? 0 : width - (ce - cs + 1);
695
+ const take = room > 0 ? ce + room - word.s + 1 : 0;
696
+ if (take > 0) spans.push([cs, ce + room]);
697
+ else if (cs >= 0) spans.push([cs, ce]);
698
+ let pos = word.s + Math.max(0, take);
699
+ let remaining = word.w - Math.max(0, take);
700
+ while (remaining > width) {
701
+ spans.push([pos, pos + width - 1]);
702
+ pos += width;
703
+ remaining -= width;
767
704
  }
705
+ cs = pos;
706
+ ce = pos + remaining - 1;
707
+ continue;
768
708
  }
769
- const last = !!chain && (i + 1 >= doneSlice.length || chainOf(doneSlice[i + 1]) !== chain);
770
- const depth = workerDepth(statuses, s.id);
771
- const subLast = !chain && (i + 1 >= doneSlice.length || doneSlice[i + 1].parent !== s.parent);
772
- const col = s.state === "done" ? "green" : "red";
773
- const tag = sessionTag(s);
774
- lines.push(treeLine(`${chain ? " " : rowPrefix(depth, subLast)}${s.id}${inboxMark(s)} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${branchMark(s)}${reportMark(s)} ${s.label}`, chain, last));
709
+ if (cs < 0) {
710
+ cs = word.s;
711
+ ce = word.e;
712
+ continue;
713
+ }
714
+ if (word.e - cs + 1 <= width) {
715
+ ce = word.e;
716
+ continue;
717
+ }
718
+ spans.push([cs, ce]);
719
+ cs = word.s;
720
+ ce = word.e;
775
721
  }
776
- lines.push("");
777
- lines.push(c("dim", "worker follow live transcript of running workers"));
778
- lines.push(c("dim", "worker <id> replay one worker"));
779
- lines.push(c("dim", scopeLabel));
780
- return lines.join("\n");
722
+ if (cs >= 0) spans.push([cs, ce]);
723
+ const out = [];
724
+ for (const [a, b] of spans) {
725
+ const from = chars[a];
726
+ let end = chars[b] + 1;
727
+ while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
728
+ let piece = text.slice(from, end);
729
+ const reopen = sgrStateAt(text, from);
730
+ if (reopen.length) piece = reopen.join("") + piece;
731
+ if (sgrStateAt(text, end).length) piece += "\x1B[0m";
732
+ out.push(piece);
733
+ }
734
+ return out.length ? out : [""];
735
+ }
736
+ /** Restrict scrolling to the transcript region (rows 1 … rows-3). */
737
+ function chatScrollRegion(rows) {
738
+ return `\x1b[1;${Math.max(1, rows - 3)}r`;
781
739
  }
782
740
  /**
783
- * The one place a model id is compacted for display, so every renderer
784
- * shortens identically: the vendor prefix goes (the provider column already
785
- * says who serves it), a release-date suffix goes, version segments read as
786
- * dots, and a `[1m]` window marker is kept because it changes what the run
787
- * costs. `claude-opus-5[1m]` → `opus-5[1m]`, `claude-haiku-4-5-20251001` →
788
- * `haiku-4.5`, `glm-5.3[1m]` and `k3[1m]` unchanged. Empty in, empty out.
741
+ * The separator row between the transcript and the prompt: cleared, then —
742
+ * when the pane's width is known — a dim rule of `cols` box-drawing `─`
743
+ * filling the row. `cols` 0 (unknown) keeps the row blank; `dim` is the
744
+ * pane's colour helper, identity when colours are off.
789
745
  */
790
- function shortModel(model) {
791
- const raw = String(model ?? "").trim();
792
- if (!raw) return "";
793
- const wide = /\[1m\]$/i.test(raw);
794
- let id = wide ? raw.slice(0, -4) : raw;
795
- id = id.replace(/^(?:us\.|eu\.|apac\.)?(?:anthropic|claude|openai|google|models)[./-]/i, "");
796
- id = id.replace(/-\d{8}$/, "");
797
- const parts = id.split("-");
798
- const nums = [];
799
- while (parts.length > 1 && /^\d+(?:\.\d+)*$/.test(parts[parts.length - 1])) nums.unshift(parts.pop());
800
- if (nums.length) id = `${parts.join("-")}-${nums.join(".")}`;
801
- return wide ? `${id}[1m]` : id;
746
+ function chatBlankRow(rows, cols = 0, dim = (s) => s) {
747
+ const rule = cols > 0 ? dim("─".repeat(cols)) : "";
748
+ return `\x1b[${Math.max(1, rows - 2)};1H\x1b[K` + rule;
802
749
  }
803
750
  /**
804
- * The goal of a statusline row: what the worker is FOR, never what it is doing
805
- * this second. The operator's `--label` is the goal and is preferred over
806
- * anything the model wrote; an unlabelled run's label already holds its prompt
807
- * (see run.ts), so the same path shortens that to its first sentence, cut on a
808
- * word boundary. A run with neither label nor prompt reads "unlabeled". Width
809
- * fitting goes through shortText, the repo's one truncation helper.
751
+ * Enter the chat layout: clear the pane, set the scroll region, draw the
752
+ * separator row and park the cursor at column 3 of the prompt row (rows-1,
753
+ * right after `› `). The ticker owns row `rows`.
810
754
  */
811
- function goalOf(s, max = 40) {
812
- const raw = String(s.label ?? "").trim();
813
- if (!raw || raw === "(no prompt)") return UNLABELED;
814
- const first = raw.split(/(?<=[.!?])\s+/)[0] ?? raw;
815
- if (first.length <= max) return shortText(first, max);
816
- return shortText(`${first.slice(0, max - 1).replace(/\s+\S*$/, "") || first.slice(0, max - 1)}…`, max);
755
+ function chatEnter(rows, cols = 0, dim = (s) => s) {
756
+ return "\x1B[2J" + chatScrollRegion(rows) + chatBlankRow(rows, cols, dim) + `\x1b[${Math.max(1, rows - 1)};3H`;
757
+ }
758
+ /** Leave it: reset the scroll region, show the cursor, drop to the last row. */
759
+ function chatLeave(rows) {
760
+ return `[?25h\x1b[${Math.max(1, rows)};1H`;
817
761
  }
818
762
  /**
819
- * A worker pane's bottom line: `<goal> · <model> · started HH:MM · <age>` —
820
- * what the pane is FOR and how long it has run, never the tool call it
821
- * happens to be executing this second (that is `pai worker ps` / `follow`
822
- * territory). `columns`, when known, shrinks the goal so the fixed
823
- * `model · started · age` tail always survives — trimming that tail instead
824
- * would defeat the one thing an operator scanning several panes needs at a
825
- * glance. A legacy status with no recorded model omits that field rather
826
- * than rendering a bare gap.
763
+ * Redraw the ticker on its own row and park the cursor back on the prompt
764
+ * row at `parkCol` (default right after `› `): a ticker refresh never leaves
765
+ * the cursor on the bottom row.
827
766
  */
828
- function paneStatusRow(s, now = /* @__PURE__ */ new Date(), columns = null) {
829
- const model = shortModel(s.model);
830
- const started = String(s.started ?? "").slice(11, 16);
831
- const elapsed = elapsedOf(s.started, now);
832
- const tail = [
833
- model,
834
- started ? `started ${started}` : "",
835
- elapsed
836
- ].filter(Boolean).join(" · ");
837
- return `${goalOf(s, columns !== null ? Math.max(1, columns - tail.length - 3) : 40)} · ${tail}`;
767
+ function chatTickerRow(text, rows, parkCol = 3) {
768
+ return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + `\x1b[${Math.max(1, rows - 1)};${Math.max(1, parkCol)}H`;
838
769
  }
839
770
  /**
840
- * The statusline bar: `provider ▶N · <models> · oldest <age>` and today's
841
- * ✓/✗ tail. Three workers used to spell out three goals, three ages and three
842
- * models on one line and none of it fit; each worker's own pane bottom line
843
- * now carries `goal · model · started · age` (paneStatusRow) and `pai worker
844
- * ps` has the full table, so the bar only has to answer how many workers are
845
- * running, on what models, and how long the oldest has been going. `<models>`
846
- * lists distinct short model names, most-populous first (ties broken by
847
- * name), each suffixed `×N` past one; an empty model counts under `?` rather
848
- * than vanishing (a legacy status with no recorded model is still a worker).
849
- * `columns`, when known, shrinks only the models segment (shortText) so the
850
- * head, the `▶N` count and the today tally — the three things worth reading
851
- * at a glance — never get cut for the one segment that can grow without
852
- * bound. The chat pane contributes nothing but its provider: its age, state
853
- * and inbox live in `pai worker ps`, not here.
771
+ * The prompt row: the fixed `› ` prefix, then the input buffer — or, while
772
+ * the buffer is empty, the dim placeholder behind the same prefix. The
773
+ * cursor parks right after the buffer (`cursorAt` chars in, for a cursor
774
+ * mid-draft), so typed text always starts at the column the placeholder
775
+ * occupied, never glued to a hint.
854
776
  */
855
- function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), active = null, columns = null) {
856
- const live = active && active !== "auto" ? active : null;
857
- if (!mine.length) return live ?? "";
858
- const isChat = isChatPane;
859
- const chat = mine.find((s) => isChat(s) && isLive(s));
860
- const running = mine.filter((s) => !isChat(s) && isLive(s));
861
- const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
862
- const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
863
- const ok = doneToday.filter((s) => s.state === "done").length;
864
- const bad = doneToday.length - ok;
865
- let head;
866
- if (live) head = live;
867
- else if (chat) head = chat.provider;
868
- else {
869
- const providers = new Set(running.map((s) => s.provider));
870
- head = providers.size === 1 ? [...providers][0] : "workers";
871
- }
872
- if (running.length) head += ` ▶${running.length}`;
873
- const tail = doneToday.length ? ` ✓${ok} ✗${bad} today` : "";
874
- if (!running.length) return head + tail;
875
- const counts = /* @__PURE__ */ new Map();
876
- for (const s of running) {
877
- const m = shortModel(s.model) || "?";
878
- counts.set(m, (counts.get(m) ?? 0) + 1);
879
- }
880
- const modelsFull = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([m, n]) => n > 1 ? `${m} ×${n}` : m).join(" · ");
881
- const oldestPart = `oldest ${ageOf(running.reduce((a, b) => a.started < b.started ? a : b).started, now)}`;
882
- let models = modelsFull;
883
- const fixedLen = head.length + 3 + oldestPart.length + tail.length;
884
- if (columns !== null && fixedLen + 3 + modelsFull.length > columns) {
885
- const budget = columns - fixedLen - 3;
886
- models = budget > 0 ? shortText(modelsFull, budget) : "";
887
- }
888
- const mid = [models, oldestPart].filter(Boolean).join(" · ");
889
- return `${head} · ${mid}${tail}`;
777
+ function chatPromptRow(rows, buffer = "", dim = (s) => s, cursorAt) {
778
+ const rowN = Math.max(1, rows - 1);
779
+ const body = buffer !== "" ? buffer : dim(CHAT_HINT);
780
+ const col = 3 + Math.max(0, Math.min(cursorAt ?? buffer.length, buffer.length));
781
+ return `\x1b[${rowN};1H\x1b[K› ` + body + `\x1b[${rowN};${col}H`;
890
782
  }
891
-
892
- //#endregion
893
- //#region src/workers/viewer.ts
894
783
  /**
895
- * viewer.ts — ps / follow / replay / status line over the worker logDir.
896
- *
897
- * Scoping: workers launched from this terminal's AIBroker session (or, as the
898
- * fallback, its iTerm tab) unless --all or an explicit worker id is given.
899
- * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
900
- *
901
- * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
902
- * `_ts` stamp on every mirrored event (local wall clock — stamps carry a local
903
- * offset and old UTC stamps are converted), the worker tag in front when
904
- * several run at once, a date separator when the day changes, and — on a TTY —
905
- * a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
906
- * is rewritten in place between events. Attaching to a worker that is already
907
- * running first replays its last events (backfill), then continues live.
908
- *
909
- * Following one worker turns the pane into a small chat (see chatui.ts): the
910
- * transcript scrolls in a region that ends two rows above the bottom, the
911
- * prompt row (`› `, readline editing) and the ticker row stay fixed, and every
912
- * submitted line is said to the worker while it runs and resumes it (same
913
- * Claude session) once it has finished. Lines the pane wraps itself keep the
914
- * `│` bar on continuation rows, so no content ever lands left of the bar.
915
- * Non-TTY output keeps the plain scrolling behaviour.
784
+ * Insert one transcript row above the fixed prompt/ticker rows. While the
785
+ * region is still filling (`fill < regionRows`) the row is placed top-down;
786
+ * once full, the cursor moves to the region's bottom row and a newline
787
+ * scrolls the region up by one — the two fixed rows are never touched. The
788
+ * user's cursor is saved before and restored after, so readline keeps its
789
+ * position on the prompt row.
916
790
  */
917
- /** How many existing events a fresh follow pane replays before going live. */
918
- const BACKFILL_EVENTS = 200;
919
- function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
920
- const c = makeColor(color);
921
- const term = env.ITERM_SESSION_ID ?? "";
922
- const statuses = loadStatuses(logDir);
923
- const scoped = showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term));
924
- const scopeLabel = showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`;
925
- const inbox = inboxCounts(logDir, scoped);
926
- return renderTable(c, scoped, scopeLabel, /* @__PURE__ */ new Date(), inbox);
927
- }
928
- /** Handoffs waiting in each listed worker's inbox: id → count. */
929
- function inboxCounts(logDir, statuses) {
930
- const out = {};
931
- for (const s of statuses) {
932
- const n = readInbox(logDir, s.id).length;
933
- if (n) out[s.id] = n;
934
- }
935
- return out;
791
+ function chatInsertLine(line, fill, regionRows) {
792
+ return {
793
+ seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
794
+ fill: Math.min(fill + 1, regionRows)
795
+ };
936
796
  }
937
- function initialFollowState(intent = "waiting for first event") {
797
+ const CONTROLS_LINE = /^(your|my) controls\.?$/i;
798
+ /**
799
+ * One submitted prompt line → what to do with it. `/help`, `/quit`,
800
+ * `/status` and `/resume <text>` are commands (a bare `/resume` comes back
801
+ * with empty text so the caller can print its usage); "your controls" /
802
+ * "my controls" is the same `pai worker controls <id> you|me` action, never
803
+ * forwarded to the worker as a normal chat line; anything else, including
804
+ * any other `/word`, is a message for the worker.
805
+ */
806
+ function parseChatLine(raw) {
807
+ const text = raw.trim();
808
+ if (text === "/help") return { kind: "help" };
809
+ if (text === "/quit") return { kind: "quit" };
810
+ if (text === "/status") return { kind: "status" };
811
+ if (text.startsWith("/resume")) return {
812
+ kind: "resume",
813
+ text: text.slice(7).trim()
814
+ };
815
+ const controls = text.match(CONTROLS_LINE);
816
+ if (controls) return {
817
+ kind: "controls",
818
+ who: controls[1].toLowerCase() === "your" ? "you" : "me"
819
+ };
938
820
  return {
939
- lastDay: "",
940
- tools: {},
941
- intent,
942
- tool: "",
943
- prev: null
821
+ kind: "message",
822
+ text
944
823
  };
945
824
  }
946
825
  /**
947
- * Gutter the rendered body of one event, wrapping when the pane width is
948
- * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
949
- * the stamped gutter, later rows of the event blanks — exactly the pre-chat
950
- * rendering. Wrapped: every row is folded at `wrapWidth` columns and each
951
- * continuation row carries the blank gutter with the `│` bar, so the bar runs
952
- * unbroken down the pane and no content ever lands left of it.
826
+ * The auto-exit countdown must not fire while the prompt holds unsent text:
827
+ * true while it does. null/undefined (no prompt wired) never holds.
953
828
  */
954
- function gutterBody(body, gutter, wrapWidth) {
955
- if (!gutter) return body;
956
- if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
957
- const out = [];
958
- for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
959
- return out;
829
+ function holdAutoExit(promptText) {
830
+ return typeof promptText === "string" && promptText.trim() !== "";
960
831
  }
832
+
833
+ //#endregion
834
+ //#region src/workers/render.ts
961
835
  /**
962
- * Render one event and advance the follow state. Everything the viewer shows
963
- * between events of one worker comes from here: replay, the backfill on
964
- * attach and the live tail all use it, so they space identically — events
965
- * back to back, one blank line between turns. `activity` is false for events
966
- * that render nothing (stream noise, empty tool results): they leave the
967
- * ticker's "since last event" clock running. `wrapWidth` (the pane's column
968
- * count, re-read on resize) makes the viewer wrap rows itself; null keeps
969
- * the terminal's own wrapping.
836
+ * render.ts — turn worker events into the lines a human reads.
837
+ *
838
+ * Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
839
+ * and searches, `$` for shell, magenta `~` + red/green diff for edits, green
840
+ * `+` for writes), Read-result trimming (first 3 lines then a count), and the
841
+ * result footer. Colors are applied only when the output is a TTY, so MCP
842
+ * tool output stays plain text.
970
843
  */
971
- function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
972
- const tools = { ...s.tools };
973
- if (e.type === "assistant") {
974
- for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
975
- }
976
- const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
977
- const state = {
978
- lastDay: day && day !== s.lastDay ? day : s.lastDay,
979
- tools,
980
- intent: s.intent,
981
- tool: s.tool,
982
- prev: e
983
- };
984
- if (e.type === "assistant") {
985
- for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
986
- else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
844
+ const CODES = {
845
+ dim: "2",
846
+ bold: "1",
847
+ red: "31",
848
+ green: "32",
849
+ yellow: "33",
850
+ blue: "34",
851
+ mag: "35",
852
+ cyan: "36"
853
+ };
854
+ function makeColor(enabled) {
855
+ return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
856
+ }
857
+ /** Path relative to cwd when it lies inside it, otherwise unchanged. */
858
+ function relPath(path, cwd) {
859
+ if (!path || !cwd) return path;
860
+ let r;
861
+ try {
862
+ r = relative(cwd, path);
863
+ } catch {
864
+ return path;
987
865
  }
988
- const gutter = gutterFor(c, e, tag, offMin);
989
- const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
990
- const lines = blankBetween(s.prev, e) ? [gutter ? gutter.barCont : "", ...body] : body;
991
- return {
992
- lines,
993
- day: day && day !== s.lastDay ? day : null,
994
- activity: lines.some((ln) => ln !== ""),
995
- state
996
- };
866
+ return r.startsWith("..") ? path : r;
997
867
  }
998
- /** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
999
- function rawDay(ts) {
1000
- return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
868
+ /** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
869
+ function unifiedDiffLines(oldStr, newStr) {
870
+ const oldL = oldStr.split("\n");
871
+ const newL = newStr.split("\n");
872
+ let start = 0;
873
+ while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
874
+ let endOld = oldL.length;
875
+ let endNew = newL.length;
876
+ while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
877
+ endOld--;
878
+ endNew--;
879
+ }
880
+ const out = [];
881
+ for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
882
+ for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
883
+ return out;
1001
884
  }
1002
885
  /**
1003
- * The events a fresh follow replays before going live: the last `cap`
1004
- * non-empty log lines, oldest first.
886
+ * `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
887
+ * negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
888
+ * null when the stamp cannot be parsed. Offsets make this testable without
889
+ * depending on the machine's zone; the default is this machine's.
1005
890
  */
1006
- function backfillLines(raw, cap = BACKFILL_EVENTS) {
1007
- return raw.split("\n").filter((l) => l.trim()).slice(-cap);
891
+ function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
892
+ const t = Date.parse(ts);
893
+ if (Number.isNaN(t)) return null;
894
+ return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
1008
895
  }
1009
- /** The rendered transcript of one worker, from the start, as one string. */
1010
- function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
1011
- const path = eventsPath(logDir, wid);
1012
- if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
1013
- const c = makeColor(color);
1014
- const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
1015
- id: wid,
1016
- label: "",
1017
- cwd: "",
1018
- provider: ""
1019
- };
1020
- const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
1021
- const out = [headerLine(c, st)];
1022
- const raw = readFileSync(path, "utf8");
1023
- const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
1024
- const handoffs = readInbox(logDir, wid).map((h) => ({
1025
- type: "handoff",
1026
- from: h.from,
1027
- kind: h.kind,
1028
- text: h.text,
1029
- _ts: h._ts
1030
- }));
1031
- let state = initialFollowState("");
1032
- const events = [];
1033
- for (const line of lines) {
1034
- if (!line.trim()) continue;
1035
- let e;
1036
- try {
1037
- e = JSON.parse(line);
1038
- } catch {
1039
- continue;
1040
- }
1041
- events.push(e);
1042
- }
1043
- const stamp = (e) => {
1044
- const t = e._ts ? Date.parse(e._ts) : NaN;
1045
- return Number.isNaN(t) ? 0 : t;
896
+ /** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
897
+ function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
898
+ const t = Date.parse(ts);
899
+ if (Number.isNaN(t)) return null;
900
+ return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
901
+ }
902
+ /**
903
+ * The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
904
+ * the worker tag in front when several run at once. Continuation lines get
905
+ * blanks of the same width so wrapped text stays aligned; when the viewer
906
+ * wraps lines itself, continuation rows carry the `│` bar instead (barCont)
907
+ * so the bar runs unbroken down the pane. null when the event carries no
908
+ * stamp (logs from before 2g render with the plain prefix). The time is the
909
+ * stamp's wall clock at `offMin` — the default renders local time, whatever
910
+ * zone stamped the log (old logs were stamped in UTC).
911
+ */
912
+ function gutterFor(c, e, tag, offMin) {
913
+ if (!e._ts) return null;
914
+ const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
915
+ const head = tag ? `${tag} ${time}` : time;
916
+ const width = head.length + 3;
917
+ return {
918
+ first: c("dim", `${head} │ `),
919
+ cont: " ".repeat(width),
920
+ barCont: c("dim", `${" ".repeat(head.length)} │ `),
921
+ width
1046
922
  };
1047
- const merged = [...events, ...handoffs].sort((a, b) => stamp(a) - stamp(b));
1048
- for (const e of merged) {
1049
- const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
1050
- if (step.day) out.push(c("dim", `── ${step.day} ──`));
1051
- out.push(...step.lines);
1052
- state = step.state;
1053
- }
1054
- return out.join("\n");
1055
923
  }
1056
924
  /**
1057
- * The follow exit decision for one worker: it is over once its result event
1058
- * was rendered, or once its status left "running" while its pid is gone — a
1059
- * worker killed without writing a result still ends. A status never read
1060
- * (undefined) means "not over": follow keeps waiting for the first event.
925
+ * The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
926
+ * basename for the file tools, the bare name for everything else.
1061
927
  */
1062
- function workerEnded(resultRendered, state, pidAlive) {
1063
- return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
928
+ function tickerTool(name, inp) {
929
+ const i = typeof inp === "object" && inp !== null ? inp : {};
930
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
931
+ if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
932
+ if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
933
+ return name;
1064
934
  }
1065
935
  /**
1066
- * One typed stdin line → say (worker running) or resume (worker finished):
1067
- * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
1068
- * missing target are ignored.
936
+ * The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
937
+ * seconds since the last *rendered* event, the worker's last stated intent
938
+ * (its last assistant text, ≤60 chars) and the tool it is currently running.
939
+ * Parts that are empty drop out.
1069
940
  */
1070
- function makeOperatorInput(d) {
1071
- return (raw) => {
1072
- const text = raw.trim();
1073
- if (!text) return;
1074
- const id = d.target();
1075
- if (id === null) return;
1076
- d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
1077
- if (d.workerKnown(id)) d.resume(text, id);
1078
- else d.note(d.paint("red", `» ${e.message}`));
1079
- });
1080
- };
941
+ function tickerText(secs, intent, tool, meter) {
942
+ const head = `⋯ ${secs}s`;
943
+ const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
944
+ const line = tail ? `${head} · ${tail}` : head;
945
+ return meter ? `${line} · ${meter}` : line;
1081
946
  }
1082
947
  /**
1083
- * Tail one worker (target) or the running workers of this scope, live.
1084
- * Workers whose event log already exists are first replayed (last
1085
- * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
1086
- * `autoExit` seconds after its end; without one, exit once no worker in
1087
- * scope has run for that many seconds in a row, never within the first 30 s.
1088
- *
1089
- * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
1090
- * a scroll region, fixed prompt and ticker rows, submitted lines said to the
1091
- * worker (or resuming it after it finished) and echoed as `»` rows. A draft
1092
- * in the prompt holds the auto-exit countdown. Non-TTY output keeps the
1093
- * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
948
+ * One blank line between turns, none inside one: a blank goes before an
949
+ * assistant message that follows a tool result or an operator message (the
950
+ * worker starting to speak again after its tools were answered / it was told
951
+ * something), not between the text, tool calls and results of one turn.
1094
952
  */
1095
- async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
1096
- const c = makeColor(color);
1097
- const out_ = io?.stdout ?? process.stdout;
1098
- const in_ = io?.stdin ?? process.stdin;
1099
- const safeWrite = (s) => {
1100
- try {
1101
- out_.write(s);
1102
- } catch {}
953
+ function blankBetween(prev, e) {
954
+ if (!prev) return false;
955
+ if (e.type !== "assistant") return false;
956
+ return prev.type === "user" || prev.type === "operator" || prev.type === "handoff";
957
+ }
958
+ /** The worker's last stated intent: the first line of its last text, ≤60. */
959
+ function intentOf(text) {
960
+ return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
961
+ }
962
+ /**
963
+ * The context meter `ctx 84k/200k (42%)` (contextLabel, shared with the
964
+ * chat status row), yellow from 70 %, red from 85 %. null when the numbers
965
+ * are missing or below `minPct` (the table only shows it past 60; the pane
966
+ * liveness line always shows it).
967
+ */
968
+ function contextMeter(c, s, minPct = 0) {
969
+ const pct = contextPercent(s);
970
+ if (pct === null || pct <= minPct) return null;
971
+ const label = contextLabel(s);
972
+ if (pct > 85) return c("red", label);
973
+ if (pct > 70) return c("yellow", label);
974
+ return label;
975
+ }
976
+ function renderToolUse(c, prefix, name, inp, cwd) {
977
+ const i = typeof inp === "object" && inp !== null ? inp : {};
978
+ const get = (k) => typeof i[k] === "string" ? i[k] : "";
979
+ if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
980
+ if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
981
+ if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
982
+ if (name === "Edit") {
983
+ const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
984
+ for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
985
+ else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
986
+ else out.push(`${prefix} ${c("dim", line)}`);
987
+ return out;
988
+ }
989
+ if (name === "Write") {
990
+ const n = get("content").split("\n").length;
991
+ return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
992
+ }
993
+ if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
994
+ return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
995
+ }
996
+ /** Render one stream-json event; `tools` maps tool_use_id → tool name. */
997
+ function renderEvent(c, prefix, e, cwd, tools, gutter) {
998
+ const out = [];
999
+ if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
1000
+ else if (e.type === "operator") {
1001
+ if (e.handoff) return out;
1002
+ for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
1003
+ } else if (e.type === "handoff") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("mag", `◆ from ${e.from ?? "?"} · ${e.kind ?? "?"}: ${ln}`)}`);
1004
+ else if (e.type === "assistant") {
1005
+ 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}`);
1006
+ else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
1007
+ } else if (e.type === "user") for (const b of e.message?.content ?? []) {
1008
+ if (b.type !== "tool_result") continue;
1009
+ let content = b.content ?? "";
1010
+ if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
1011
+ const text = String(content);
1012
+ const isError = b.is_error === true;
1013
+ if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
1014
+ const lines = text.split("\n");
1015
+ for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
1016
+ if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
1017
+ } else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
1018
+ else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
1019
+ }
1020
+ else if (e.type === "result") {
1021
+ const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
1022
+ out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
1023
+ const report = parseWorkerReport(String(e.result ?? ""));
1024
+ if (report) out.push(...renderReport(c, prefix, report, cwd));
1025
+ else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
1026
+ }
1027
+ if (!gutter) return out;
1028
+ return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
1029
+ }
1030
+ /** Transcript header: id, provider, label, session name, project dir. */
1031
+ function headerLine(c, s) {
1032
+ const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
1033
+ const sep = bits ? ` ${bits}` : "";
1034
+ const specBit = s.spec ? ` spec: ${s.spec}` : "";
1035
+ const reportBit = s.reportValid === false ? " " + c("red", s.reportFormat === "ag2" && (s.reportErrors ?? []).length ? "R!" : "R?") : "";
1036
+ return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})${specBit}${reportBit}`);
1037
+ }
1038
+ /** The chain label behind a stage label: strip the trailing " · <stage>". */
1039
+ function chainLabelOf(stages) {
1040
+ const first = stages[0];
1041
+ if (!first) return "";
1042
+ const suffix = first.stage ? ` · ${first.stage}` : "";
1043
+ return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
1044
+ }
1045
+ /**
1046
+ * The ps table (RUNNING + FINISHED last 8) as a forest: a worker whose parent
1047
+ * is another worker renders indented under it (`├`/`└` connectors); a parent
1048
+ * that is not a worker is a chain id and gets its old `chain <id>` header.
1049
+ * `⎇` marks an unmerged worktree branch, `◆N` an inbox with N handoffs.
1050
+ */
1051
+ function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date(), inbox = {}) {
1052
+ const running = [];
1053
+ const done = [];
1054
+ for (const s of statuses) if (isLive(s)) running.push(s);
1055
+ else {
1056
+ if (s.state === "running") s.state = "lost";
1057
+ done.push(s);
1058
+ }
1059
+ const isWorker = (id) => statuses.some((s) => s.id === id);
1060
+ const chainOf = (s) => s.parent && !isWorker(s.parent) ? s.parent : null;
1061
+ const treeLine = (line, chain, last) => {
1062
+ if (chain === null) return line;
1063
+ const mark = last ? "└" : "├";
1064
+ const bar = last ? " " : "│";
1065
+ return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
1103
1066
  };
1104
- const tty = out_.isTTY === true || env.FORCE_TTY === "1";
1105
- const term = env.ITERM_SESSION_ID ?? "";
1106
- const scopeTab = target || showAll ? "" : currentTabKey(env);
1107
- const handles = /* @__PURE__ */ new Map();
1108
- const seenHeader = /* @__PURE__ */ new Set();
1109
- const finished = /* @__PURE__ */ new Set();
1110
- const inboxSeen = /* @__PURE__ */ new Map();
1111
- const states = /* @__PURE__ */ new Map();
1112
- const started = Date.now();
1113
- let idleSince = null;
1114
- let aborted = false;
1115
- let exitReason = "aborted";
1116
- let consecutiveErrors = 0;
1117
- const MAX_CONSECUTIVE_ERRORS = 10;
1118
- let lastEventAt = Date.now();
1067
+ const rowPrefix = (depth, last) => depth <= 0 ? " " : " " + "│ ".repeat(depth - 1) + (last ? "└ " : "├ ");
1068
+ const rowCont = (depth, last) => depth <= 0 ? " " : " " + "│ ".repeat(depth - 1) + (last ? " " : "│ ");
1069
+ const branchMark = (s) => s.branch && !s.merged ? " " + c("yellow", "⎇" + (s.commits ? String(s.commits) : "")) : "";
1070
+ const inboxMark = (s) => inbox[s.id] ? " " + c("mag", `◆${inbox[s.id]}`) : "";
1071
+ const reportMark = (s) => s.reportValid === false ? " " + c("red", s.reportFormat === "ag2" && (s.reportErrors ?? []).length ? "R!" : "R?") : "";
1072
+ const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
1073
+ lines.push(c("bold", `RUNNING (${running.length})`));
1074
+ if (!running.length) lines.push(" none");
1075
+ for (let i = 0; i < running.length; i++) {
1076
+ const s = running[i];
1077
+ const chain = chainOf(s);
1078
+ if (chain) {
1079
+ const prev = running[i - 1];
1080
+ if (!prev || chainOf(prev) !== chain) {
1081
+ const stages = running.filter((x) => chainOf(x) === chain);
1082
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
1083
+ }
1084
+ }
1085
+ const last = !!chain && (i + 1 >= running.length || chainOf(running[i + 1]) !== chain);
1086
+ const depth = workerDepth(statuses, s.id);
1087
+ const subLast = !chain && (i + 1 >= running.length || running[i + 1].parent !== s.parent);
1088
+ const meter = contextMeter(c, s, 60);
1089
+ const p = chain ? " " : rowPrefix(depth, subLast);
1090
+ const q = chain ? " " : rowCont(depth, subLast);
1091
+ lines.push(treeLine(`${p}${c("cyan", s.id)}${inboxMark(s)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${branchMark(s)}${meter ? " " + meter : ""}`, chain, last));
1092
+ lines.push(treeLine(`${q}task: ${s.label}`, chain, last));
1093
+ lines.push(treeLine(`${q}now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
1094
+ }
1095
+ lines.push("");
1096
+ lines.push(c("bold", "FINISHED (last 8)"));
1097
+ const doneSlice = done.slice(-8);
1098
+ for (let i = 0; i < doneSlice.length; i++) {
1099
+ const s = doneSlice[i];
1100
+ const chain = chainOf(s);
1101
+ if (chain) {
1102
+ const prev = doneSlice[i - 1];
1103
+ if (!prev || chainOf(prev) !== chain) {
1104
+ const stages = doneSlice.filter((x) => chainOf(x) === chain);
1105
+ lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
1106
+ }
1107
+ }
1108
+ const last = !!chain && (i + 1 >= doneSlice.length || chainOf(doneSlice[i + 1]) !== chain);
1109
+ const depth = workerDepth(statuses, s.id);
1110
+ const subLast = !chain && (i + 1 >= doneSlice.length || doneSlice[i + 1].parent !== s.parent);
1111
+ const col = s.state === "done" ? "green" : "red";
1112
+ const tag = sessionTag(s);
1113
+ lines.push(treeLine(`${chain ? " " : rowPrefix(depth, subLast)}${s.id}${inboxMark(s)} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${branchMark(s)}${reportMark(s)} ${s.label}`, chain, last));
1114
+ }
1115
+ lines.push("");
1116
+ lines.push(c("dim", "worker follow live transcript of running workers"));
1117
+ lines.push(c("dim", "worker <id> replay one worker"));
1118
+ lines.push(c("dim", scopeLabel));
1119
+ return lines.join("\n");
1120
+ }
1121
+ /**
1122
+ * The one place a model id is compacted for display, so every renderer
1123
+ * shortens identically: the vendor prefix goes (the provider column already
1124
+ * says who serves it), a release-date suffix goes, version segments read as
1125
+ * dots, and a `[1m]` window marker is kept because it changes what the run
1126
+ * costs. `claude-opus-5[1m]` → `opus-5[1m]`, `claude-haiku-4-5-20251001` →
1127
+ * `haiku-4.5`, `glm-5.3[1m]` and `k3[1m]` unchanged. Empty in, empty out.
1128
+ */
1129
+ function shortModel(model) {
1130
+ const raw = String(model ?? "").trim();
1131
+ if (!raw) return "";
1132
+ const wide = /\[1m\]$/i.test(raw);
1133
+ let id = wide ? raw.slice(0, -4) : raw;
1134
+ id = id.replace(/^(?:us\.|eu\.|apac\.)?(?:anthropic|claude|openai|google|models)[./-]/i, "");
1135
+ id = id.replace(/-\d{8}$/, "");
1136
+ const parts = id.split("-");
1137
+ const nums = [];
1138
+ while (parts.length > 1 && /^\d+(?:\.\d+)*$/.test(parts[parts.length - 1])) nums.unshift(parts.pop());
1139
+ if (nums.length) id = `${parts.join("-")}-${nums.join(".")}`;
1140
+ return wide ? `${id}[1m]` : id;
1141
+ }
1142
+ /**
1143
+ * The goal of a statusline row: what the worker is FOR, never what it is doing
1144
+ * this second. The operator's `--label` is the goal and is preferred over
1145
+ * anything the model wrote; an unlabelled run's label already holds its prompt
1146
+ * (see run.ts), so the same path shortens that to its first sentence, cut on a
1147
+ * word boundary. A run with neither label nor prompt reads "unlabeled". Width
1148
+ * fitting goes through shortText, the repo's one truncation helper.
1149
+ */
1150
+ function goalOf(s, max = 40) {
1151
+ const raw = String(s.label ?? "").trim();
1152
+ if (!raw || raw === "(no prompt)") return UNLABELED;
1153
+ const first = raw.split(/(?<=[.!?])\s+/)[0] ?? raw;
1154
+ if (first.length <= max) return shortText(first, max);
1155
+ return shortText(`${first.slice(0, max - 1).replace(/\s+\S*$/, "") || first.slice(0, max - 1)}…`, max);
1156
+ }
1157
+ /**
1158
+ * A worker pane's bottom line: `<goal> · <model> · started HH:MM · <age>` —
1159
+ * what the pane is FOR and how long it has run, never the tool call it
1160
+ * happens to be executing this second (that is `pai worker ps` / `follow`
1161
+ * territory). `columns`, when known, shrinks the goal so the fixed
1162
+ * `model · started · age` tail always survives — trimming that tail instead
1163
+ * would defeat the one thing an operator scanning several panes needs at a
1164
+ * glance. A legacy status with no recorded model omits that field rather
1165
+ * than rendering a bare gap.
1166
+ */
1167
+ function paneStatusRow(s, now = /* @__PURE__ */ new Date(), columns = null) {
1168
+ const model = shortModel(s.model);
1169
+ const started = String(s.started ?? "").slice(11, 16);
1170
+ const elapsed = elapsedOf(s.started, now);
1171
+ const tail = [
1172
+ model,
1173
+ started ? `started ${started}` : "",
1174
+ elapsed
1175
+ ].filter(Boolean).join(" · ");
1176
+ return `${goalOf(s, columns !== null ? Math.max(1, columns - tail.length - 3) : 40)} · ${tail}`;
1177
+ }
1178
+ /**
1179
+ * The statusline bar: `provider ▶N · <models> · oldest <age>` and today's
1180
+ * ✓/✗ tail. Three workers used to spell out three goals, three ages and three
1181
+ * models on one line and none of it fit; each worker's own pane bottom line
1182
+ * now carries `goal · model · started · age` (paneStatusRow) and `pai worker
1183
+ * ps` has the full table, so the bar only has to answer how many workers are
1184
+ * running, on what models, and how long the oldest has been going. `<models>`
1185
+ * lists distinct short model names, most-populous first (ties broken by
1186
+ * name), each suffixed `×N` past one; an empty model counts under `?` rather
1187
+ * than vanishing (a legacy status with no recorded model is still a worker).
1188
+ * `columns`, when known, shrinks only the models segment (shortText) so the
1189
+ * head, the `▶N` count and the today tally — the three things worth reading
1190
+ * at a glance — never get cut for the one segment that can grow without
1191
+ * bound. The chat pane contributes nothing but its provider: its age, state
1192
+ * and inbox live in `pai worker ps`, not here.
1193
+ */
1194
+ function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), active = null, columns = null) {
1195
+ const live = active && active !== "auto" ? active : null;
1196
+ if (!mine.length) return live ?? "";
1197
+ const isChat = isChatPane;
1198
+ const chat = mine.find((s) => isChat(s) && isLive(s));
1199
+ const running = mine.filter((s) => !isChat(s) && isLive(s));
1200
+ const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
1201
+ const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
1202
+ const ok = doneToday.filter((s) => s.state === "done").length;
1203
+ const bad = doneToday.length - ok;
1204
+ let head;
1205
+ if (live) head = live;
1206
+ else if (chat) head = chat.provider;
1207
+ else {
1208
+ const providers = new Set(running.map((s) => s.provider));
1209
+ head = providers.size === 1 ? [...providers][0] : "workers";
1210
+ }
1211
+ if (running.length) head += ` ▶${running.length}`;
1212
+ const tail = doneToday.length ? ` ✓${ok} ✗${bad} today` : "";
1213
+ if (!running.length) return head + tail;
1214
+ const counts = /* @__PURE__ */ new Map();
1215
+ for (const s of running) {
1216
+ const m = shortModel(s.model) || "?";
1217
+ counts.set(m, (counts.get(m) ?? 0) + 1);
1218
+ }
1219
+ const modelsFull = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([m, n]) => n > 1 ? `${m} ×${n}` : m).join(" · ");
1220
+ const oldestPart = `oldest ${ageOf(running.reduce((a, b) => a.started < b.started ? a : b).started, now)}`;
1221
+ let models = modelsFull;
1222
+ const fixedLen = head.length + 3 + oldestPart.length + tail.length;
1223
+ if (columns !== null && fixedLen + 3 + modelsFull.length > columns) {
1224
+ const budget = columns - fixedLen - 3;
1225
+ models = budget > 0 ? shortText(modelsFull, budget) : "";
1226
+ }
1227
+ const mid = [models, oldestPart].filter(Boolean).join(" · ");
1228
+ return `${head} · ${mid}${tail}`;
1229
+ }
1230
+
1231
+ //#endregion
1232
+ //#region src/workers/viewer.ts
1233
+ /**
1234
+ * viewer.ts — ps / follow / replay / status line over the worker logDir.
1235
+ *
1236
+ * Scoping: workers launched from this terminal's AIBroker session (or, as the
1237
+ * fallback, its iTerm tab) unless --all or an explicit worker id is given.
1238
+ * Outside iTerm, everything degrades to "all workers" — the Python behaviour.
1239
+ *
1240
+ * Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
1241
+ * `_ts` stamp on every mirrored event (local wall clock — stamps carry a local
1242
+ * offset and old UTC stamps are converted), the worker tag in front when
1243
+ * several run at once, a date separator when the day changes, and — on a TTY —
1244
+ * a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
1245
+ * is rewritten in place between events. Attaching to a worker that is already
1246
+ * running first replays its last events (backfill), then continues live.
1247
+ *
1248
+ * Following one worker turns the pane into a small chat (see chatui.ts): the
1249
+ * transcript scrolls in a region that ends two rows above the bottom, the
1250
+ * prompt row (`› `, readline editing) and the ticker row stay fixed, and every
1251
+ * submitted line is said to the worker while it runs and resumes it (same
1252
+ * Claude session) once it has finished. Lines the pane wraps itself keep the
1253
+ * `│` bar on continuation rows, so no content ever lands left of the bar.
1254
+ * Non-TTY output keeps the plain scrolling behaviour.
1255
+ */
1256
+ /** How many existing events a fresh follow pane replays before going live. */
1257
+ const BACKFILL_EVENTS = 200;
1258
+ function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
1259
+ const c = makeColor(color);
1260
+ const term = env.ITERM_SESSION_ID ?? "";
1261
+ const statuses = loadStatuses(logDir);
1262
+ const scoped = showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term));
1263
+ const scopeLabel = showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`;
1264
+ const inbox = inboxCounts(logDir, scoped);
1265
+ return renderTable(c, scoped, scopeLabel, /* @__PURE__ */ new Date(), inbox);
1266
+ }
1267
+ /** Handoffs waiting in each listed worker's inbox: id → count. */
1268
+ function inboxCounts(logDir, statuses) {
1269
+ const out = {};
1270
+ for (const s of statuses) {
1271
+ const n = readInbox(logDir, s.id).length;
1272
+ if (n) out[s.id] = n;
1273
+ }
1274
+ return out;
1275
+ }
1276
+ function initialFollowState(intent = "waiting for first event") {
1277
+ return {
1278
+ lastDay: "",
1279
+ tools: {},
1280
+ intent,
1281
+ tool: "",
1282
+ prev: null
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Gutter the rendered body of one event, wrapping when the pane width is
1287
+ * known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
1288
+ * the stamped gutter, later rows of the event blanks — exactly the pre-chat
1289
+ * rendering. Wrapped: every row is folded at `wrapWidth` columns and each
1290
+ * continuation row carries the blank gutter with the `│` bar, so the bar runs
1291
+ * unbroken down the pane and no content ever lands left of it.
1292
+ */
1293
+ function gutterBody(body, gutter, wrapWidth) {
1294
+ if (!gutter) return body;
1295
+ if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
1296
+ const out = [];
1297
+ for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
1298
+ return out;
1299
+ }
1300
+ /**
1301
+ * Render one event and advance the follow state. Everything the viewer shows
1302
+ * between events of one worker comes from here: replay, the backfill on
1303
+ * attach and the live tail all use it, so they space identically — events
1304
+ * back to back, one blank line between turns. `activity` is false for events
1305
+ * that render nothing (stream noise, empty tool results): they leave the
1306
+ * ticker's "since last event" clock running. `wrapWidth` (the pane's column
1307
+ * count, re-read on resize) makes the viewer wrap rows itself; null keeps
1308
+ * the terminal's own wrapping.
1309
+ */
1310
+ function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
1311
+ const tools = { ...s.tools };
1312
+ if (e.type === "assistant") {
1313
+ for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
1314
+ }
1315
+ const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
1316
+ const state = {
1317
+ lastDay: day && day !== s.lastDay ? day : s.lastDay,
1318
+ tools,
1319
+ intent: s.intent,
1320
+ tool: s.tool,
1321
+ prev: e
1322
+ };
1323
+ if (e.type === "assistant") {
1324
+ for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
1325
+ else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
1326
+ }
1327
+ const gutter = gutterFor(c, e, tag, offMin);
1328
+ const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
1329
+ const lines = blankBetween(s.prev, e) ? [gutter ? gutter.barCont : "", ...body] : body;
1330
+ return {
1331
+ lines,
1332
+ day: day && day !== s.lastDay ? day : null,
1333
+ activity: lines.some((ln) => ln !== ""),
1334
+ state
1335
+ };
1336
+ }
1337
+ /** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
1338
+ function rawDay(ts) {
1339
+ return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
1340
+ }
1341
+ /**
1342
+ * The events a fresh follow replays before going live: the last `cap`
1343
+ * non-empty log lines, oldest first.
1344
+ */
1345
+ function backfillLines(raw, cap = BACKFILL_EVENTS) {
1346
+ return raw.split("\n").filter((l) => l.trim()).slice(-cap);
1347
+ }
1348
+ /** The rendered transcript of one worker, from the start, as one string. */
1349
+ function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
1350
+ const path = eventsPath(logDir, wid);
1351
+ if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
1352
+ const c = makeColor(color);
1353
+ const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
1354
+ id: wid,
1355
+ label: "",
1356
+ cwd: "",
1357
+ provider: ""
1358
+ };
1359
+ const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
1360
+ const out = [headerLine(c, st)];
1361
+ const raw = readFileSync(path, "utf8");
1362
+ const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
1363
+ const handoffs = readInbox(logDir, wid).map((h) => ({
1364
+ type: "handoff",
1365
+ from: h.from,
1366
+ kind: h.kind,
1367
+ text: h.text,
1368
+ _ts: h._ts
1369
+ }));
1370
+ let state = initialFollowState("");
1371
+ const events = [];
1372
+ for (const line of lines) {
1373
+ if (!line.trim()) continue;
1374
+ let e;
1375
+ try {
1376
+ e = JSON.parse(line);
1377
+ } catch {
1378
+ continue;
1379
+ }
1380
+ events.push(e);
1381
+ }
1382
+ const stamp = (e) => {
1383
+ const t = e._ts ? Date.parse(e._ts) : NaN;
1384
+ return Number.isNaN(t) ? 0 : t;
1385
+ };
1386
+ const merged = [...events, ...handoffs].sort((a, b) => stamp(a) - stamp(b));
1387
+ for (const e of merged) {
1388
+ const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
1389
+ if (step.day) out.push(c("dim", `── ${step.day} ──`));
1390
+ out.push(...step.lines);
1391
+ state = step.state;
1392
+ }
1393
+ return out.join("\n");
1394
+ }
1395
+ /**
1396
+ * The follow exit decision for one worker: it is over once its result event
1397
+ * was rendered, or once its status left "running" while its pid is gone — a
1398
+ * worker killed without writing a result still ends. A status never read
1399
+ * (undefined) means "not over": follow keeps waiting for the first event.
1400
+ */
1401
+ function workerEnded(resultRendered, state, pidAlive) {
1402
+ return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
1403
+ }
1404
+ /**
1405
+ * One typed stdin line → say (worker running) or resume (worker finished):
1406
+ * the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
1407
+ * missing target are ignored.
1408
+ */
1409
+ function makeOperatorInput(d) {
1410
+ return (raw) => {
1411
+ const text = raw.trim();
1412
+ if (!text) return;
1413
+ const id = d.target();
1414
+ if (id === null) return;
1415
+ d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
1416
+ if (d.workerKnown(id)) d.resume(text, id);
1417
+ else d.note(d.paint("red", `» ${e.message}`));
1418
+ });
1419
+ };
1420
+ }
1421
+ /**
1422
+ * Tail one worker (target) or the running workers of this scope, live.
1423
+ * Workers whose event log already exists are first replayed (last
1424
+ * BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
1425
+ * `autoExit` seconds after its end; without one, exit once no worker in
1426
+ * scope has run for that many seconds in a row, never within the first 30 s.
1427
+ *
1428
+ * With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
1429
+ * a scroll region, fixed prompt and ticker rows, submitted lines said to the
1430
+ * worker (or resuming it after it finished) and echoed as `»` rows. A draft
1431
+ * in the prompt holds the auto-exit countdown. Non-TTY output keeps the
1432
+ * plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
1433
+ */
1434
+ async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
1435
+ const c = makeColor(color);
1436
+ const out_ = io?.stdout ?? process.stdout;
1437
+ const in_ = io?.stdin ?? process.stdin;
1438
+ const safeWrite = (s) => {
1439
+ try {
1440
+ out_.write(s);
1441
+ } catch {}
1442
+ };
1443
+ const tty = out_.isTTY === true || env.FORCE_TTY === "1";
1444
+ const term = env.ITERM_SESSION_ID ?? "";
1445
+ const scopeTab = target || showAll ? "" : currentTabKey(env);
1446
+ const handles = /* @__PURE__ */ new Map();
1447
+ const seenHeader = /* @__PURE__ */ new Set();
1448
+ const finished = /* @__PURE__ */ new Set();
1449
+ const inboxSeen = /* @__PURE__ */ new Map();
1450
+ const states = /* @__PURE__ */ new Map();
1451
+ const started = Date.now();
1452
+ let idleSince = null;
1453
+ let aborted = false;
1454
+ let exitReason = "aborted";
1455
+ let consecutiveErrors = 0;
1456
+ const MAX_CONSECUTIVE_ERRORS = 10;
1457
+ let lastEventAt = Date.now();
1119
1458
  let meterStatus = null;
1120
1459
  let paneStatus;
1121
1460
  const onInt = () => {
@@ -1528,414 +1867,76 @@ async function followWorkers(logDir, target, showAll, autoExit, env = process.en
1528
1867
  cwd: "",
1529
1868
  provider: ""
1530
1869
  };
1531
- const msgs = readInbox(logDir, wid);
1532
- const seenN = inboxSeen.get(wid) ?? 0;
1533
- if (msgs.length > seenN) {
1534
- for (const m of msgs.slice(seenN)) emitEvent({
1535
- type: "handoff",
1536
- from: m.from,
1537
- kind: m.kind,
1538
- text: m.text,
1539
- _ts: m._ts
1540
- }, wid, st, multi);
1541
- inboxSeen.set(wid, msgs.length);
1542
- progressed = true;
1543
- }
1544
- }
1545
- const lingerOn = target;
1546
- if (lingerOn !== null && finished.has(lingerOn)) {
1547
- if (autoExit) {
1548
- if (holdAutoExit(promptText())) {
1549
- writeLiveness();
1550
- await sleep(250);
1551
- return { done: false };
1552
- }
1553
- const until = Date.now() + autoExit * 1e3;
1554
- while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
1555
- writeLiveness();
1556
- await sleep(250);
1557
- }
1558
- if (aborted || target !== lingerOn || holdAutoExit(promptText())) return { done: false };
1559
- eraseLiveness();
1560
- out(c("dim", "closing"));
1561
- return {
1562
- done: true,
1563
- reason: "worker-ended"
1564
- };
1565
- }
1566
- if (rlIn === null) return {
1567
- done: true,
1568
- reason: "worker-ended"
1569
- };
1570
- }
1571
- if (autoExit && !target) {
1572
- if (runningIds().length) idleSince = null;
1573
- else if (idleSince === null) idleSince = Date.now();
1574
- else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
1575
- eraseLiveness();
1576
- out(c("dim", "closing"));
1577
- return {
1578
- done: true,
1579
- reason: "idle"
1580
- };
1581
- }
1582
- }
1583
- if (!progressed) {
1584
- writeLiveness();
1585
- await sleep(500);
1586
- }
1587
- return { done: false };
1588
- }
1589
- }
1590
- function sleep(ms) {
1591
- return new Promise((r) => setTimeout(r, ms));
1592
- }
1593
- /** Workers of this terminal for the status bar; "" when there are none. */
1594
- function statusLineOutput(logDir, term, cwd, claudeSession = "", now = /* @__PURE__ */ new Date(), active = null) {
1595
- return renderStatusLine(loadStatuses(logDir).filter((s) => {
1596
- const sameScope = term && workerInScope(s, term);
1597
- const spawnedHere = claudeSession && s.spawnerSession === claudeSession;
1598
- const sameDir = cwd && s.cwd.startsWith(cwd);
1599
- return sameScope || spawnedHere || !term && sameDir;
1600
- }), now, active);
1601
- }
1602
-
1603
- //#endregion
1604
- //#region src/workers/providers.ts
1605
- /**
1606
- * providers.ts — provider, class and switch management over the workers config.
1607
- *
1608
- * One layer under both `pai worker providers …` and the MCP worker_providers
1609
- * tool. Every mutation re-reads the config file, changes only the workers
1610
- * section, and writes it back atomically — the file is shared with everything
1611
- * else PAI runs, so a torn write is not an option.
1612
- *
1613
- * The CLI's `--key` writes the token inline as `key:` in workers.yaml
1614
- * (quoted, and the file is kept 0600 — see workers-config.ts); `--key-file`
1615
- * still writes only a path. The MCP `add` tool instead accepts a raw `key`
1616
- * and parks it in ~/.claude/pai/keys/<name>, mode 0600, storing only the
1617
- * path in the config — a chat is not a place to leave a credential lying
1618
- * around.
1619
- */
1620
- function addProvider(input) {
1621
- 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 _`);
1622
- 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.");
1623
- if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
1624
- let keyFile = input.keyFile ?? null;
1625
- if (input.key !== void 0) {
1626
- const dir = keysDir();
1627
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1628
- const path = `${dir}/${input.name}`;
1629
- writeFileSync(path, input.key.trim() + "\n", {
1630
- encoding: "utf8",
1631
- mode: 384
1632
- });
1633
- try {
1634
- chmodSync(path, 384);
1635
- } catch {}
1636
- keyFile = path;
1637
- }
1638
- const { raw, workers } = readWorkersSection(input.configPath);
1639
- const provider = {
1640
- enabled: true,
1641
- protocol: input.protocol ?? "anthropic",
1642
- baseUrl: input.baseUrl,
1643
- keyFile,
1644
- ...input.inlineKey ? { key: input.inlineKey } : {},
1645
- models: input.fastModel ? {
1646
- default: input.model,
1647
- fast: input.fastModel
1648
- } : { default: input.model },
1649
- env: input.env ?? {},
1650
- ...input.note ? { note: input.note } : {},
1651
- ...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
1652
- ...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
1653
- ...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
1654
- ...input.contextWindow ? { contextWindow: input.contextWindow } : {},
1655
- ...input.costTier ? { costTier: input.costTier } : {},
1656
- ...input.tags?.length ? { tags: input.tags } : {}
1657
- };
1658
- workers.providers[input.name] = provider;
1659
- if (Object.keys(workers.providers).length === 1) {
1660
- workers.enabled = true;
1661
- workers.active = input.name;
1662
- workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
1663
- const fast = input.fastModel ? `${input.name}/fast` : input.name;
1664
- workers.classes = {
1665
- draft: fast,
1666
- plan: input.name,
1667
- implement: input.name,
1668
- review: input.name,
1669
- research: input.name,
1670
- spotcheck: fast,
1671
- simple: fast,
1672
- complex: input.name,
1673
- image: input.name
1674
- };
1675
- }
1676
- writeWorkersSection(raw, workers, input.configPath);
1677
- return workers;
1678
- }
1679
- /** Change costTier / tags on an existing provider (MCP action "update"). */
1680
- function updateProvider(name, changes, configPath) {
1681
- const { raw, workers } = readWorkersSection(configPath);
1682
- const p = workers.providers[name];
1683
- if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1684
- if (changes.costTier !== void 0) {
1685
- if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
1686
- p.costTier = changes.costTier;
1687
- }
1688
- if (changes.tags !== void 0) {
1689
- for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
1690
- p.tags = changes.tags;
1691
- }
1692
- writeWorkersSection(raw, workers, configPath);
1693
- return workers;
1694
- }
1695
- function removeProvider(name, configPath) {
1696
- const { raw, workers } = readWorkersSection(configPath);
1697
- if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
1698
- delete workers.providers[name];
1699
- for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
1700
- if (workers.active === name) workers.active = null;
1701
- writeWorkersSection(raw, workers, configPath);
1702
- return workers;
1703
- }
1704
- /**
1705
- * Point a class target at `name`, keeping every other field. Only touches
1706
- * targets that already pin a provider — a constraint-only object (no
1707
- * `provider`) only narrows auto-routing and is left alone. A string target's
1708
- * `/alias` is dropped (falling back to `name`'s default model) when `name`
1709
- * has no such model configured — the class must move to `name` either way,
1710
- * since leaving it on the old provider is exactly the bug this fixes.
1711
- */
1712
- function repointClassTarget(target, name, providers) {
1713
- if (typeof target === "string") {
1714
- const [provider, alias] = target.split("/");
1715
- if (provider === name) return null;
1716
- if (alias && name !== ANTHROPIC_NATIVE && alias !== "default" && !providers[name]?.models[alias]) return name;
1717
- return alias ? `${name}/${alias}` : name;
1718
- }
1719
- if (!target.provider || target.provider === name) return null;
1720
- return {
1721
- ...target,
1722
- provider: name
1723
- };
1724
- }
1725
- /**
1726
- * Make `name` the active provider. Unless `name` is "auto", also re-points
1727
- * every class whose target pins a provider (string or `{provider: …}`) at
1728
- * it — a class pinned to a provider otherwise wins over `active` at run time
1729
- * (routing.ts resolveTarget), so leaving classes untouched would make `use`
1730
- * silently do nothing for any class already pinned elsewhere.
1731
- */
1732
- function useProvider(name, configPath) {
1733
- const { raw, workers } = readWorkersSection(configPath);
1734
- if (name !== ANTHROPIC_NATIVE && name !== "auto" && !workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1735
- workers.active = name;
1736
- let repointed = 0;
1737
- if (name !== "auto") for (const [cls, target] of Object.entries(workers.classes)) {
1738
- const next = repointClassTarget(target, name, workers.providers);
1739
- if (next !== null) {
1740
- workers.classes[cls] = next;
1741
- repointed++;
1870
+ const msgs = readInbox(logDir, wid);
1871
+ const seenN = inboxSeen.get(wid) ?? 0;
1872
+ if (msgs.length > seenN) {
1873
+ for (const m of msgs.slice(seenN)) emitEvent({
1874
+ type: "handoff",
1875
+ from: m.from,
1876
+ kind: m.kind,
1877
+ text: m.text,
1878
+ _ts: m._ts
1879
+ }, wid, st, multi);
1880
+ inboxSeen.set(wid, msgs.length);
1881
+ progressed = true;
1882
+ }
1742
1883
  }
1743
- }
1744
- writeWorkersSection(raw, workers, configPath);
1745
- return {
1746
- workers,
1747
- repointed
1748
- };
1749
- }
1750
- /**
1751
- * The provider a model command targets: the named one, else the active one.
1752
- * "auto" and an unset active both ask for an explicit name.
1753
- */
1754
- function resolveProviderName(workers, name) {
1755
- const target = name ?? workers.active ?? "";
1756
- if (target === ANTHROPIC_NATIVE || workers.providers[target]) return target;
1757
- if (!target) throw new WorkersConfigError("no active provider — name one explicitly");
1758
- if (target === "auto") throw new WorkersConfigError("active is \"auto\" — name a provider explicitly");
1759
- throw new WorkersConfigError(`no provider named "${target}". Configured: ${[ANTHROPIC_NATIVE, ...Object.keys(workers.providers)].join(", ")}`);
1760
- }
1761
- /**
1762
- * Set a provider's model id for a capability — default, fast, image, …
1763
- * (`pai worker model`, MCP worker_model).
1764
- */
1765
- function setProviderModel(name, capability, model, configPath) {
1766
- if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$; well-known: ${MODEL_CAPABILITIES.join(", ")})`);
1767
- const id = model.trim();
1768
- if (!id) throw new WorkersConfigError(`a ${capability} model id must not be empty`);
1769
- const { raw, workers } = readWorkersSection(configPath);
1770
- const p = getProviderOrNative(workers, name);
1771
- if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${[ANTHROPIC_NATIVE, ...Object.keys(workers.providers)].join(", ")}`);
1772
- (name === ANTHROPIC_NATIVE ? workers.nativeModels : p.models)[capability] = id;
1773
- writeWorkersSection(raw, workers, configPath);
1774
- return workers;
1775
- }
1776
- function setProviderEnabled(name, enabled, configPath) {
1777
- const { raw, workers } = readWorkersSection(configPath);
1778
- const p = workers.providers[name];
1779
- if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
1780
- p.enabled = enabled;
1781
- writeWorkersSection(raw, workers, configPath);
1782
- if (enabled) clearCooldown(workersLogDir(workers), name);
1783
- return workers;
1784
- }
1785
- /**
1786
- * Point a class at a target ("provider", "provider/fast", or an object with
1787
- * provider + optional mcp/maxCostTier/requireTags/order). An object without
1788
- * a provider only constrains auto-routing for that class.
1789
- */
1790
- function setClass(name, target, configPath) {
1791
- const { raw, workers } = readWorkersSection(configPath);
1792
- if (typeof target === "string") {
1793
- const [provider, alias] = target.split("/");
1794
- const native = provider === ANTHROPIC_NATIVE;
1795
- const p = native ? void 0 : workers.providers[provider];
1796
- if (!native && !p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1797
- if (alias) {
1798
- if (!isModelCapability(alias)) throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose: ${MODEL_CAPABILITIES.join(", ")}`);
1799
- if (!native && alias !== "default" && !p.models[alias]) throw new WorkersConfigError(`provider "${provider}" has no ${alias} model configured`);
1884
+ const lingerOn = target;
1885
+ if (lingerOn !== null && finished.has(lingerOn)) {
1886
+ if (autoExit) {
1887
+ if (holdAutoExit(promptText())) {
1888
+ writeLiveness();
1889
+ await sleep(250);
1890
+ return { done: false };
1891
+ }
1892
+ const until = Date.now() + autoExit * 1e3;
1893
+ while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
1894
+ writeLiveness();
1895
+ await sleep(250);
1896
+ }
1897
+ if (aborted || target !== lingerOn || holdAutoExit(promptText())) return { done: false };
1898
+ eraseLiveness();
1899
+ out(c("dim", "closing"));
1900
+ return {
1901
+ done: true,
1902
+ reason: "worker-ended"
1903
+ };
1904
+ }
1905
+ if (rlIn === null) return {
1906
+ done: true,
1907
+ reason: "worker-ended"
1908
+ };
1800
1909
  }
1801
- } else if (target.provider) {
1802
- if (target.provider !== ANTHROPIC_NATIVE && !workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1803
- }
1804
- workers.classes[name] = target;
1805
- writeWorkersSection(raw, workers, configPath);
1806
- return workers;
1807
- }
1808
- function unsetClass(name, configPath) {
1809
- const { raw, workers } = readWorkersSection(configPath);
1810
- if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
1811
- delete workers.classes[name];
1812
- writeWorkersSection(raw, workers, configPath);
1813
- return workers;
1814
- }
1815
- function setWorkersEnabled(enabled, configPath) {
1816
- const { raw, workers } = readWorkersSection(configPath);
1817
- workers.enabled = enabled;
1818
- writeWorkersSection(raw, workers, configPath);
1819
- return workers;
1820
- }
1821
- /** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
1822
- function classTargetText(target) {
1823
- if (typeof target === "string") return target;
1824
- return [
1825
- target.provider ?? "(routing)",
1826
- ...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
1827
- ...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
1828
- ...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
1829
- ...target.order?.length ? [`order [${target.order.join(",")}]`] : []
1830
- ].join(" ");
1831
- }
1832
- /**
1833
- * `default X fast Y image Z …` — every well-known capability on one line
1834
- * (unset ones shown as "(none)"), plus any open-set capability this provider
1835
- * actually declares (e.g. `vision`), which is only listed when set.
1836
- */
1837
- function modelPrefsText(p) {
1838
- const known = new Set(MODEL_CAPABILITIES);
1839
- const extra = Object.keys(p.models).filter((c) => !known.has(c)).sort();
1840
- return [...MODEL_CAPABILITIES, ...extra].map((c) => `${c} ${p.models[c] ?? "(none)"}`).join(" ");
1841
- }
1842
- /**
1843
- * Compact model listing for `pai worker model` / worker_model get: the active
1844
- * provider, then one line per provider with all its capability preferences.
1845
- */
1846
- function describeModels(workers) {
1847
- const lines = [`active provider: ${workers.active ?? "(none)"}`];
1848
- for (const name of [...Object.keys(workers.providers), ANTHROPIC_NATIVE]) {
1849
- const p = getProviderOrNative(workers, name);
1850
- const active = workers.active === name ? " [active]" : "";
1851
- lines.push(`${name}${active} ${modelPrefsText(p)}`);
1852
- }
1853
- return lines;
1854
- }
1855
- /** Classes (in declaration order) whose target resolves to this provider. */
1856
- function classesForProvider(workers, name) {
1857
- return Object.entries(workers.classes).filter(([, target]) => (typeof target === "string" ? target.split("/")[0] : target.provider) === name).map(([cls]) => cls);
1858
- }
1859
- /** Human-readable provider listing (quota probe included when configured). */
1860
- function describeProviders(workers, configPath = CONFIG_FILE) {
1861
- const lines = [];
1862
- const nativeActive = workers.active === ANTHROPIC_NATIVE;
1863
- lines.push(`${ANTHROPIC_NATIVE} [built-in${nativeActive ? ", active" : ""}] Claude Code's own OAuth/Max-plan login — no base URL, no API key`);
1864
- lines.push(` ${modelPrefsText(nativeAnthropicProvider(workers.nativeModels))}`);
1865
- const nativeClasses = classesForProvider(workers, ANTHROPIC_NATIVE);
1866
- lines.push(` classes: ${nativeClasses.length ? nativeClasses.join(", ") : "(none)"}`);
1867
- const names = Object.keys(workers.providers);
1868
- if (!names.length) {
1869
- lines.push(`no other providers configured. Add one with:`);
1870
- lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
1871
- lines.push(`config: ${existsSync(workersYamlPath()) ? workersYamlPath() : configPath}`);
1872
- return lines;
1873
- }
1874
- for (const name of names) {
1875
- const p = workers.providers[name];
1876
- const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
1877
- const quota = p.quotaProbe ? probeQuota(p) : null;
1878
- const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
1879
- const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
1880
- lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
1881
- lines.push(` ${modelPrefsText(p)}${quotaNote}`);
1882
- lines.push(` ${tierTags}`);
1883
- if (p.key) lines.push(` key ${maskKey(p.key)}`);
1884
- if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
1885
- if (!p.key && !p.keyFile) lines.push(` no key file (token "local")`);
1886
- if (p.note) lines.push(` ${p.note}`);
1887
- if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
1888
- if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
1889
- if (p.engine === "image") lines.push(` engine image (no process spawn — POSTs {url}/images/generations directly)`);
1890
- const cls = classesForProvider(workers, name);
1891
- lines.push(` classes: ${cls.length ? cls.join(", ") : "(none)"}`);
1910
+ if (autoExit && !target) {
1911
+ if (runningIds().length) idleSince = null;
1912
+ else if (idleSince === null) idleSince = Date.now();
1913
+ else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
1914
+ eraseLiveness();
1915
+ out(c("dim", "closing"));
1916
+ return {
1917
+ done: true,
1918
+ reason: "idle"
1919
+ };
1920
+ }
1921
+ }
1922
+ if (!progressed) {
1923
+ writeLiveness();
1924
+ await sleep(500);
1925
+ }
1926
+ return { done: false };
1892
1927
  }
1893
- const setNames = Object.keys(workers.mcpSets);
1894
- if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
1895
- if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
1896
- lines.push(`config: ${existsSync(workersYamlPath()) ? workersYamlPath() : configPath}`);
1897
- return lines;
1898
- }
1899
- /**
1900
- * Set which provider(s), in order, serve a capability across the whole
1901
- * config (`pai worker capability <name> <provider>[,<provider>…]`, MCP
1902
- * worker_capability). resolveCapability walks this list at run time.
1903
- */
1904
- function setCapabilityPreference(capability, providers, configPath) {
1905
- if (!isModelCapability(capability)) throw new WorkersConfigError(`"${capability}" is not a valid capability name (must match ^[a-z][a-z0-9-]*$)`);
1906
- if (!providers.length) throw new WorkersConfigError("at least one provider name is required");
1907
- const { raw, workers } = readWorkersSection(configPath);
1908
- for (const name of providers) if (name !== ANTHROPIC_NATIVE && !workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
1909
- workers.capabilities[capability] = providers;
1910
- writeWorkersSection(raw, workers, configPath);
1911
- return workers;
1912
1928
  }
1913
- function unsetCapabilityPreference(capability, configPath) {
1914
- const { raw, workers } = readWorkersSection(configPath);
1915
- if (!(capability in workers.capabilities)) throw new WorkersConfigError(`no capability preference set for "${capability}"`);
1916
- delete workers.capabilities[capability];
1917
- writeWorkersSection(raw, workers, configPath);
1918
- return workers;
1929
+ function sleep(ms) {
1930
+ return new Promise((r) => setTimeout(r, ms));
1919
1931
  }
1920
- /**
1921
- * `pai worker capability` with no args: every configured `capabilities:`
1922
- * preference and what it resolves to right now — provider/model/engine, or
1923
- * `fallback: <provider>/<model>` when nothing on the list actually declares
1924
- * the capability (see resolveCapability's fellBack flag).
1925
- */
1926
- function describeCapabilities(workers) {
1927
- const names = Object.keys(workers.capabilities);
1928
- if (!names.length) return ["no capability preferences set — set one with `pai worker capability <name> <provider>[,<provider>…]`"];
1929
- return names.map((cap) => {
1930
- const pref = workers.capabilities[cap];
1931
- try {
1932
- const r = resolveCapability(workers, cap);
1933
- const resolved = r.fellBack ? `fallback: ${r.provider}/${r.model}` : `${r.provider}/${r.model} engine ${r.engine}`;
1934
- return `${cap}: [${pref.join(", ")}] -> ${resolved}`;
1935
- } catch (e) {
1936
- return `${cap}: [${pref.join(", ")}] -> unresolved: ${e instanceof Error ? e.message : String(e)}`;
1937
- }
1938
- });
1932
+ /** Workers of this terminal for the status bar; "" when there are none. */
1933
+ function statusLineOutput(logDir, term, cwd, claudeSession = "", now = /* @__PURE__ */ new Date(), active = null) {
1934
+ return renderStatusLine(loadStatuses(logDir).filter((s) => {
1935
+ const sameScope = term && workerInScope(s, term);
1936
+ const spawnedHere = claudeSession && s.spawnerSession === claudeSession;
1937
+ const sameDir = cwd && s.cwd.startsWith(cwd);
1938
+ return sameScope || spawnedHere || !term && sameDir;
1939
+ }), now, active);
1939
1940
  }
1940
1941
 
1941
1942
  //#endregion
@@ -2203,5 +2204,5 @@ function fallbackStatusText(s) {
2203
2204
  }
2204
2205
 
2205
2206
  //#endregion
2206
- export { getConfigValueOp as A, psOutput as C, resolveSpecPath as D, readSpecPrompt as E, setConfigValueOp as M, unsetConfigValueOp as N, MainConfigOpsError as O, followWorkers as S, statusLineOutput as T, setWorkersEnabled as _, addProvider as a, updateProvider as b, describeModels as c, removeProvider as d, resolveProviderName as f, setProviderModel as g, setProviderEnabled as h, fallbackStatusText as i, listConfigOp as j, formatConfigGetOutput as k, describeProviders as l, setClass as m, fallbackOn as n, classTargetText as o, setCapabilityPreference as p, fallbackStatus as r, describeCapabilities as s, fallbackOff as t, modelPrefsText as u, unsetCapabilityPreference as v, replayOutput as w, useProvider as x, unsetClass as y };
2207
- //# sourceMappingURL=fallback-ECiqCuh6.mjs.map
2207
+ export { getConfigValueOp as A, setWorkersEnabled as C, useProvider as D, updateProvider as E, setConfigValueOp as M, unsetConfigValueOp as N, MainConfigOpsError as O, setProviderModel as S, unsetClass as T, removeProvider as _, followWorkers as a, setClass as b, statusLineOutput as c, addProvider as d, classTargetText as f, modelPrefsText as g, describeProviders as h, fallbackStatusText as i, listConfigOp as j, formatConfigGetOutput as k, readSpecPrompt as l, describeModels as m, fallbackOn as n, psOutput as o, describeCapabilities as p, fallbackStatus as r, replayOutput as s, fallbackOff as t, resolveSpecPath as u, resolveProviderName as v, unsetCapabilityPreference as w, setProviderEnabled as x, setCapabilityPreference as y };
2208
+ //# sourceMappingURL=fallback-CWDQYmJi.mjs.map