@tekmidian/pai 0.64.0 → 0.65.1
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.
- package/README.md +60 -1051
- package/dist/{aibroker-client-BpfuGPe5.mjs → aibroker-client-B8c42Lh8.mjs} +1 -1
- package/dist/{aibroker-client-DRld7g6z.mjs → aibroker-client-C5Fw7DNz.mjs} +10 -4
- package/dist/{aibroker-client-DRld7g6z.mjs.map → aibroker-client-C5Fw7DNz.mjs.map} +1 -1
- package/dist/{async-CY_cd_8j.mjs → async-CNn36gm4.mjs} +2 -2
- package/dist/{async-CY_cd_8j.mjs.map → async-CNn36gm4.mjs.map} +1 -1
- package/dist/{auto-route-CPlam4iv.mjs → auto-route-sLMU-NnM.mjs} +2 -2
- package/dist/{auto-route-CPlam4iv.mjs.map → auto-route-sLMU-NnM.mjs.map} +1 -1
- package/dist/{chain-o-g8AgE3.mjs → chain-CLb6hbFS.mjs} +4 -4
- package/dist/{chain-o-g8AgE3.mjs.map → chain-CLb6hbFS.mjs.map} +1 -1
- package/dist/cli/index.mjs +19 -19
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +19 -19
- package/dist/{clusters-DsMf20PP.mjs → clusters-PgIUYT_v.mjs} +1 -1
- package/dist/{clusters-DsMf20PP.mjs.map → clusters-PgIUYT_v.mjs.map} +1 -1
- package/dist/{config-2wkz744W.mjs → config-BbLFD7Uf.mjs} +3 -3
- package/dist/config-BbLFD7Uf.mjs.map +1 -0
- package/dist/{config-CUTg6VDq.mjs → config-YinjgXEJ.mjs} +1 -1
- package/dist/{context-handover-cache-BOpjLsKe.mjs → context-handover-cache-mGxq2-8f.mjs} +11 -29
- package/dist/context-handover-cache-mGxq2-8f.mjs.map +1 -0
- package/dist/daemon/index.mjs +16 -16
- package/dist/daemon-BjaPR39W.mjs +19 -0
- package/dist/{daemon-BYieXduQ.mjs → daemon-DqCB3fO-.mjs} +30 -30
- package/dist/{daemon-BYieXduQ.mjs.map → daemon-DqCB3fO-.mjs.map} +1 -1
- package/dist/daemon-mcp/index.mjs +18 -18
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/detector-C3Q7mxQU.mjs +3 -0
- package/dist/{detector-BAlmrLQ0.mjs → detector-CIHEXKsV.mjs} +1 -1
- package/dist/{detector-BAlmrLQ0.mjs.map → detector-CIHEXKsV.mjs.map} +1 -1
- package/dist/{embeddings-DK9XfQic.mjs → embeddings-BbNVXa_0.mjs} +1 -1
- package/dist/{embeddings-DK9XfQic.mjs.map → embeddings-BbNVXa_0.mjs.map} +1 -1
- package/dist/{embeddings-Xg8XLf2K.mjs → embeddings-CDfM63uC.mjs} +1 -1
- package/dist/{factory-BMK0tC1b.mjs → factory-CaswxuJ0.mjs} +1 -1
- package/dist/{factory-C4We3xR_.mjs → factory-y36qGegI.mjs} +13 -13
- package/dist/{factory-C4We3xR_.mjs.map → factory-y36qGegI.mjs.map} +1 -1
- package/dist/{fallback-ECiqCuh6.mjs → fallback-CupzGkuJ.mjs} +1205 -1204
- package/dist/fallback-CupzGkuJ.mjs.map +1 -0
- package/dist/{federation-db-CUgy0WYs.mjs → federation-db-BTyoufBh.mjs} +2 -2
- package/dist/{federation-db-CUgy0WYs.mjs.map → federation-db-BTyoufBh.mjs.map} +1 -1
- package/dist/federation-db-HfIFc7FG.mjs +3 -0
- package/dist/hooks/block-sleep-poll.mjs +36 -11
- package/dist/hooks/block-sleep-poll.mjs.map +2 -2
- package/dist/hooks/capture-all-events.mjs +3 -17
- package/dist/hooks/capture-all-events.mjs.map +3 -3
- package/dist/hooks/capture-session-summary.mjs +3 -17
- package/dist/hooks/capture-session-summary.mjs.map +3 -3
- package/dist/hooks/capture-tool-output.mjs +3 -17
- package/dist/hooks/capture-tool-output.mjs.map +3 -3
- package/dist/hooks/cleanup-session-files.mjs +3 -17
- package/dist/hooks/cleanup-session-files.mjs.map +2 -2
- package/dist/hooks/context-compression-hook.mjs +3 -17
- package/dist/hooks/context-compression-hook.mjs.map +3 -3
- package/dist/hooks/initialize-session.mjs +3 -17
- package/dist/hooks/initialize-session.mjs.map +2 -2
- package/dist/hooks/inject-observations.mjs +3 -17
- package/dist/hooks/inject-observations.mjs.map +2 -2
- package/dist/hooks/load-core-context.mjs +4 -22
- package/dist/hooks/load-core-context.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +3 -17
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/mcp-deferred-gate.mjs +3 -17
- package/dist/hooks/mcp-deferred-gate.mjs.map +2 -2
- package/dist/hooks/observe.mjs +3 -17
- package/dist/hooks/observe.mjs.map +2 -2
- package/dist/hooks/post-compact-inject.mjs.map +1 -1
- package/dist/hooks/route-agents-to-worker.mjs.map +1 -1
- package/dist/hooks/security-validator.mjs +5 -19
- package/dist/hooks/security-validator.mjs.map +3 -3
- package/dist/hooks/stop-hook.mjs +3 -17
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +3 -17
- package/dist/hooks/sync-todo-to-md.mjs.map +2 -2
- package/dist/hooks/whisper-rules.mjs.map +1 -1
- package/dist/hooks/worker-guard.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs.map +1 -1
- package/dist/hooks/worker-status-line.mjs +4 -4
- package/dist/hooks/worker-status-line.mjs.map +2 -2
- package/dist/hooks/worker-supervision.mjs.map +1 -1
- package/dist/{indexer-backend-CJ0RGgMz.mjs → indexer-backend-Cnc7Tf5C.mjs} +2 -2
- package/dist/{ipc-client-EhWY8XL0.mjs → ipc-client-D16Xw6Uo.mjs} +2 -2
- package/dist/{ipc-client-EhWY8XL0.mjs.map → ipc-client-D16Xw6Uo.mjs.map} +1 -1
- package/dist/{kg-entity-1uqCnk4u.mjs → kg-entity-COTUj1ZC.mjs} +1 -1
- package/dist/{kg-entity-1uqCnk4u.mjs.map → kg-entity-COTUj1ZC.mjs.map} +1 -1
- package/dist/{latent-ideas-CSuKfiq3.mjs → latent-ideas-ByvMjVcb.mjs} +3 -3
- package/dist/{latent-ideas-CSuKfiq3.mjs.map → latent-ideas-ByvMjVcb.mjs.map} +1 -1
- package/dist/{link-boost-BtjzfE3c.mjs → link-boost-UiE-uooT.mjs} +1 -1
- package/dist/{link-boost-BtjzfE3c.mjs.map → link-boost-UiE-uooT.mjs.map} +1 -1
- package/dist/{main-resolver-DyOrwNYw.mjs → main-resolver-BeYWNzrt.mjs} +11 -11
- package/dist/{main-resolver-DyOrwNYw.mjs.map → main-resolver-BeYWNzrt.mjs.map} +1 -1
- package/dist/main-resolver-kHW6FewU.mjs +7 -0
- package/dist/merge-2gqRPFu2.mjs +3 -0
- package/dist/{merge-D58n_-LZ.mjs → merge-DgU9OgZy.mjs} +1 -1
- package/dist/{merge-D58n_-LZ.mjs.map → merge-DgU9OgZy.mjs.map} +1 -1
- package/dist/module-paths-DdRzbkUI.mjs +44 -0
- package/dist/module-paths-DdRzbkUI.mjs.map +1 -0
- package/dist/{neighborhood-DNuRelRB.mjs → neighborhood-CvRHlqdR.mjs} +2 -2
- package/dist/{neighborhood-DNuRelRB.mjs.map → neighborhood-CvRHlqdR.mjs.map} +1 -1
- package/dist/{note-context-D9JZ4-7o.mjs → note-context-BrbfUIoP.mjs} +1 -1
- package/dist/{note-context-D9JZ4-7o.mjs.map → note-context-BrbfUIoP.mjs.map} +1 -1
- package/dist/{pai-home-UncxWxlX.mjs → pai-home-Cm9rcJgX.mjs} +1 -1
- package/dist/{pai-home-UncxWxlX.mjs.map → pai-home-Cm9rcJgX.mjs.map} +1 -1
- package/dist/{planner-DgC3oSvb.mjs → planner-DAq4Yx-H.mjs} +7 -7
- package/dist/{planner-DgC3oSvb.mjs.map → planner-DAq4Yx-H.mjs.map} +1 -1
- package/dist/{postgres-CcsRKir-.mjs → postgres-DYtZg7J7.mjs} +5 -13
- package/dist/{postgres-CcsRKir-.mjs.map → postgres-DYtZg7J7.mjs.map} +1 -1
- package/dist/{program-JhF2GgJY.mjs → program-CWf9mT7Z.mjs} +262 -260
- package/dist/program-CWf9mT7Z.mjs.map +1 -0
- package/dist/{query-feedback-BPa0dISE.mjs → query-feedback-BIaZTTFO.mjs} +2 -2
- package/dist/{query-feedback-BPa0dISE.mjs.map → query-feedback-BIaZTTFO.mjs.map} +1 -1
- package/dist/query-feedback-B_iigYj-.mjs +3 -0
- package/dist/{registry-db-DCzdI4sC.mjs → registry-db-C7voqML9.mjs} +2 -2
- package/dist/{registry-db-DCzdI4sC.mjs.map → registry-db-C7voqML9.mjs.map} +1 -1
- package/dist/registry-db-JHPhA8vF.mjs +3 -0
- package/dist/{registry-postgres-BUnkHKYs.mjs → registry-postgres-DSrkxqfF.mjs} +2 -2
- package/dist/{registry-postgres-BUnkHKYs.mjs.map → registry-postgres-DSrkxqfF.mjs.map} +1 -1
- package/dist/{registry-sqlite-CKYNwkUv.mjs → registry-sqlite-B2436JgX.mjs} +2 -2
- package/dist/{registry-sqlite-CKYNwkUv.mjs.map → registry-sqlite-B2436JgX.mjs.map} +1 -1
- package/dist/router-S6C5BxzZ.mjs +3 -0
- package/dist/{router-CGZATlvm.mjs → router-aqLMvNMg.mjs} +1 -1
- package/dist/{router-CGZATlvm.mjs.map → router-aqLMvNMg.mjs.map} +1 -1
- package/dist/{run-env-Br4Bc7Vz.mjs → run-env-BHWnXqle.mjs} +3 -3
- package/dist/{run-env-Br4Bc7Vz.mjs.map → run-env-BHWnXqle.mjs.map} +1 -1
- package/dist/{run-W2rV_9j0.mjs → run-uAkNItb6.mjs} +140 -44
- package/dist/run-uAkNItb6.mjs.map +1 -0
- package/dist/{runtime-paths-QGQJAekd.mjs → runtime-paths-CHTg3ywb.mjs} +1 -1
- package/dist/{runtime-paths-QGQJAekd.mjs.map → runtime-paths-CHTg3ywb.mjs.map} +1 -1
- package/dist/{server-B71rem4q.mjs → server-DlL1QI2k.mjs} +4 -5
- package/dist/server-DlL1QI2k.mjs.map +1 -0
- package/dist/{session-keepalive-Dgil9hjw.mjs → session-keepalive-BWEjcRrh.mjs} +7 -7
- package/dist/{session-keepalive-Dgil9hjw.mjs.map → session-keepalive-BWEjcRrh.mjs.map} +1 -1
- package/dist/skills/Art/SKILL.md +1 -1
- package/dist/skills/Observability/SKILL.md +4 -4
- package/dist/skills/Research/SKILL.md +1 -1
- package/dist/skills/Tasks/SKILL.md +1 -1
- package/dist/{sources-LcGptLA-.mjs → sources-DX4ElmrE.mjs} +1 -1
- package/dist/{sources-LcGptLA-.mjs.map → sources-DX4ElmrE.mjs.map} +1 -1
- package/dist/{sqlite-CAJcw2zL.mjs → sqlite-BrEu3avy.mjs} +3 -3
- package/dist/{sqlite-CAJcw2zL.mjs.map → sqlite-BrEu3avy.mjs.map} +1 -1
- package/dist/{state-S9wlKarB.mjs → state-DW8zdweW.mjs} +1 -1
- package/dist/{state-CHltNjXI.mjs → state-HyjqTihC.mjs} +1 -1
- package/dist/{state-CHltNjXI.mjs.map → state-HyjqTihC.mjs.map} +1 -1
- package/dist/{themes-D1FQFthd.mjs → themes-BgqahYVM.mjs} +2 -2
- package/dist/{themes-D1FQFthd.mjs.map → themes-BgqahYVM.mjs.map} +1 -1
- package/dist/{tools-BW7OXf-N.mjs → tools-BbqQIHPe.mjs} +4 -4
- package/dist/{tools-BFC-113F.mjs → tools-gMGDIpJ9.mjs} +18 -18
- package/dist/{tools-BFC-113F.mjs.map → tools-gMGDIpJ9.mjs.map} +1 -1
- package/dist/{trace-IDKK1VFs.mjs → trace-CmAB7iJZ.mjs} +1 -1
- package/dist/{trace-IDKK1VFs.mjs.map → trace-CmAB7iJZ.mjs.map} +1 -1
- package/dist/{vault-indexer-y6YC2-mh.mjs → vault-indexer-Ddq51X20.mjs} +1 -1
- package/dist/{vault-indexer-y6YC2-mh.mjs.map → vault-indexer-Ddq51X20.mjs.map} +1 -1
- package/dist/{wakeup-CNZQZzsY.mjs → wakeup-CZw88uXf.mjs} +3 -3
- package/dist/{wakeup-CNZQZzsY.mjs.map → wakeup-CZw88uXf.mjs.map} +1 -1
- package/dist/{work-queue-worker-Dy5FT2ak.mjs → work-queue-worker-DCzfH0d1.mjs} +4 -4
- package/dist/{work-queue-worker-Dy5FT2ak.mjs.map → work-queue-worker-DCzfH0d1.mjs.map} +1 -1
- package/dist/work-queue-worker-Taba_vcA.mjs +11 -0
- package/dist/{zettelkasten-Dx-63BEk.mjs → zettelkasten-Dwj67e4p.mjs} +4 -4
- package/dist/{zettelkasten-Dx-63BEk.mjs.map → zettelkasten-Dwj67e4p.mjs.map} +1 -1
- package/docker/docker-compose.yml +39 -0
- package/docs/auto-compact.md +31 -0
- package/docs/budget-advisor.md +48 -0
- package/docs/command-reference.md +25 -0
- package/docs/commands/README.md +3 -3
- package/docs/commands/config.md +3 -3
- package/docs/commands/setup.md +9 -2
- package/docs/commands/worker.md +2 -2
- package/docs/companion-projects.md +9 -0
- package/docs/context-preservation.md +43 -0
- package/docs/how-it-works.md +25 -0
- package/docs/install-linux.md +32 -0
- package/docs/install.md +56 -0
- package/docs/memory.md +96 -0
- package/docs/observations.md +58 -0
- package/docs/release-history.md +42 -0
- package/docs/rules-and-privacy.md +37 -0
- package/docs/search.md +169 -0
- package/docs/session-management.md +153 -0
- package/docs/session-notes.md +64 -0
- package/docs/skills.md +45 -0
- package/docs/task-bus.md +1 -2
- package/docs/use-cases.md +194 -0
- package/docs/what-you-can-ask.md +78 -0
- package/docs/worker-providers.md +58 -0
- package/docs/zettelkasten.md +37 -0
- package/package.json +3 -2
- package/plugins/productivity/skills/Tasks/SKILL.md +1 -1
- package/scripts/build-hooks.mjs +6 -6
- package/src/hooks/ts/lib/pai-paths-case.test.ts +12 -0
- package/src/hooks/ts/lib/pai-paths-import.test.ts +25 -0
- package/src/hooks/ts/lib/pai-paths.ts +5 -28
- package/src/hooks/ts/lib/sleep-poll-gate.test.ts +17 -1
- package/src/hooks/ts/lib/sleep-poll-gate.ts +21 -9
- package/src/hooks/ts/pre-tool-use/security-validator.test.ts +23 -0
- package/src/hooks/ts/pre-tool-use/security-validator.ts +1 -1
- package/src/hooks/ts/session-start/load-core-context.ts +2 -6
- package/src/hooks/ts/session-start/load-project-context.ts +1 -1
- package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +24 -1
- package/dist/config-2wkz744W.mjs.map +0 -1
- package/dist/context-handover-cache-BOpjLsKe.mjs.map +0 -1
- package/dist/daemon-q3xjHwa4.mjs +0 -19
- package/dist/detector-C-YmZsOi.mjs +0 -3
- package/dist/fallback-ECiqCuh6.mjs.map +0 -1
- package/dist/federation-db-BZ8PyxFe.mjs +0 -3
- package/dist/main-resolver-vN09bkXv.mjs +0 -7
- package/dist/merge-CXGz5wXz.mjs +0 -3
- package/dist/program-JhF2GgJY.mjs.map +0 -1
- package/dist/query-feedback-BPHFFSu1.mjs +0 -3
- package/dist/registry-db-Hphlj3H4.mjs +0 -3
- package/dist/router-CoA8Uy1m.mjs +0 -3
- package/dist/run-W2rV_9j0.mjs.map +0 -1
- package/dist/server-B71rem4q.mjs.map +0 -1
- package/dist/work-queue-worker-CkShUa0z.mjs +0 -11
- /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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-uAkNItb6.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/
|
|
216
|
+
//#region src/workers/providers.ts
|
|
217
217
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
} catch
|
|
233
|
-
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 (
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
304
|
+
writeWorkersSection(raw, workers, configPath);
|
|
305
|
+
return workers;
|
|
271
306
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
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
|
|
331
|
+
if (!target.provider || target.provider === name) return null;
|
|
332
|
+
return {
|
|
333
|
+
...target,
|
|
334
|
+
provider: name
|
|
335
|
+
};
|
|
309
336
|
}
|
|
310
337
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
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
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
403
|
-
*
|
|
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
|
|
408
|
-
const
|
|
409
|
-
|
|
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
|
-
*
|
|
413
|
-
*
|
|
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
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
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
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
*
|
|
488
|
-
*
|
|
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
|
|
491
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
465
|
+
return lines;
|
|
528
466
|
}
|
|
529
|
-
/**
|
|
530
|
-
function
|
|
531
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
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
|
|
574
|
-
if (!
|
|
575
|
-
|
|
576
|
-
const
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
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
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
*
|
|
610
|
-
*
|
|
611
|
-
*
|
|
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
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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 (
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
655
|
-
return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
|
|
609
|
+
return i + 2;
|
|
656
610
|
}
|
|
657
|
-
/**
|
|
658
|
-
function
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
if (
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
689
|
-
return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
|
|
623
|
+
return w;
|
|
690
624
|
}
|
|
691
|
-
/**
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
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
|
|
713
|
-
|
|
714
|
-
const
|
|
715
|
-
for (
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
|
784
|
-
*
|
|
785
|
-
*
|
|
786
|
-
*
|
|
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
|
|
791
|
-
const
|
|
792
|
-
|
|
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
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
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
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
return
|
|
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 `[r[?25h\x1b[${Math.max(1, rows)};1H`;
|
|
817
761
|
}
|
|
818
762
|
/**
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
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
|
|
829
|
-
|
|
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
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
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
|
|
856
|
-
const
|
|
857
|
-
|
|
858
|
-
const
|
|
859
|
-
|
|
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
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
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
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
intent,
|
|
942
|
-
tool: "",
|
|
943
|
-
prev: null
|
|
821
|
+
kind: "message",
|
|
822
|
+
text
|
|
944
823
|
};
|
|
945
824
|
}
|
|
946
825
|
/**
|
|
947
|
-
*
|
|
948
|
-
*
|
|
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
|
|
955
|
-
|
|
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
|
-
*
|
|
963
|
-
*
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
999
|
-
function
|
|
1000
|
-
|
|
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
|
-
*
|
|
1004
|
-
*
|
|
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
|
|
1007
|
-
|
|
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
|
-
/**
|
|
1010
|
-
function
|
|
1011
|
-
const
|
|
1012
|
-
if (
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
|
1058
|
-
*
|
|
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
|
|
1063
|
-
|
|
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
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
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
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
*
|
|
1084
|
-
*
|
|
1085
|
-
*
|
|
1086
|
-
*
|
|
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
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
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
|
|
1105
|
-
const
|
|
1106
|
-
const
|
|
1107
|
-
const
|
|
1108
|
-
const
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
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
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
|
1914
|
-
|
|
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
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
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,
|
|
2207
|
-
//# sourceMappingURL=fallback-
|
|
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-CupzGkuJ.mjs.map
|