@tekmidian/pai 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +41 -0
  2. package/dist/{auto-route-BlWOWT4z.mjs → auto-route-BwzmlW3W.mjs} +2 -2
  3. package/dist/{auto-route-BlWOWT4z.mjs.map → auto-route-BwzmlW3W.mjs.map} +1 -1
  4. package/dist/{chain-CqlgL7uH.mjs → chain-DhVHVnmT.mjs} +263 -821
  5. package/dist/chain-DhVHVnmT.mjs.map +1 -0
  6. package/dist/cli/index.mjs +10 -8
  7. package/dist/cli/index.mjs.map +1 -1
  8. package/dist/cli/program.mjs +10 -8
  9. package/dist/{clusters-wlK0w41E.mjs → clusters-DRHhfli9.mjs} +1 -1
  10. package/dist/{clusters-wlK0w41E.mjs.map → clusters-DRHhfli9.mjs.map} +1 -1
  11. package/dist/{config-B64vFg14.mjs → config-D1G9IFpn.mjs} +50 -3
  12. package/dist/config-D1G9IFpn.mjs.map +1 -0
  13. package/dist/{config-C_ErGddD.mjs → config-DD52BPB-.mjs} +1 -1
  14. package/dist/config-Dl8lT4Lu.mjs +527 -0
  15. package/dist/config-Dl8lT4Lu.mjs.map +1 -0
  16. package/dist/{context-handover-cache-BpUojjsi.mjs → context-handover-cache-C6_rs7JT.mjs} +112 -24
  17. package/dist/context-handover-cache-C6_rs7JT.mjs.map +1 -0
  18. package/dist/daemon/index.mjs +12 -10
  19. package/dist/daemon/index.mjs.map +1 -1
  20. package/dist/daemon-BSL9sXxm.mjs +22 -0
  21. package/dist/{daemon-CTkN_Vn8.mjs → daemon-CpdVUnl_.mjs} +26 -26
  22. package/dist/{daemon-CTkN_Vn8.mjs.map → daemon-CpdVUnl_.mjs.map} +1 -1
  23. package/dist/daemon-mcp/index.mjs +76 -6
  24. package/dist/daemon-mcp/index.mjs.map +1 -1
  25. package/dist/detector-BLVOCiJ_.mjs +5 -0
  26. package/dist/{detector-Bwk_4Pk4.mjs → detector-smpCm7ku.mjs} +1 -1
  27. package/dist/{detector-Bwk_4Pk4.mjs.map → detector-smpCm7ku.mjs.map} +1 -1
  28. package/dist/{factory-A9x-T2Kg.mjs → factory-Bc5EyN54.mjs} +7 -7
  29. package/dist/{factory-A9x-T2Kg.mjs.map → factory-Bc5EyN54.mjs.map} +1 -1
  30. package/dist/factory-C7ua_3ik.mjs +3 -0
  31. package/dist/{fallback-DqYhiACU.mjs → fallback-CdXv-Np0.mjs} +139 -49
  32. package/dist/fallback-CdXv-Np0.mjs.map +1 -0
  33. package/dist/hooks/context-compression-hook.mjs +136 -22
  34. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  35. package/dist/hooks/post-compact-inject.mjs +98 -7
  36. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  37. package/dist/hooks/route-agents-to-worker.mjs +36 -2
  38. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  39. package/dist/hooks/worker-proxy.mjs +36 -2
  40. package/dist/hooks/worker-proxy.mjs.map +2 -2
  41. package/dist/hooks/worker-status-line.mjs +110 -86
  42. package/dist/hooks/worker-status-line.mjs.map +4 -4
  43. package/dist/{indexer-backend-TG64CCQC.mjs → indexer-backend-rohVVKrA.mjs} +1 -1
  44. package/dist/{indexer-backend-TG64CCQC.mjs.map → indexer-backend-rohVVKrA.mjs.map} +1 -1
  45. package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C7AVpsyv.mjs} +2 -2
  46. package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C7AVpsyv.mjs.map} +1 -1
  47. package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
  48. package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
  49. package/dist/{latent-ideas-B7wq75Pt.mjs → latent-ideas-iNp5s55R.mjs} +2 -2
  50. package/dist/{latent-ideas-B7wq75Pt.mjs.map → latent-ideas-iNp5s55R.mjs.map} +1 -1
  51. package/dist/{link-boost-HkG7JWZR.mjs → link-boost-NNzADHX_.mjs} +1 -1
  52. package/dist/{link-boost-HkG7JWZR.mjs.map → link-boost-NNzADHX_.mjs.map} +1 -1
  53. package/dist/{main-resolver-DlaLOFBA.mjs → main-resolver-CM1IHbuu.mjs} +78 -75
  54. package/dist/main-resolver-CM1IHbuu.mjs.map +1 -0
  55. package/dist/main-resolver-E5WlilhM.mjs +6 -0
  56. package/dist/{neighborhood-lThN-MaQ.mjs → neighborhood-AGL7oXQ2.mjs} +1 -1
  57. package/dist/{neighborhood-lThN-MaQ.mjs.map → neighborhood-AGL7oXQ2.mjs.map} +1 -1
  58. package/dist/{note-context-b6k0mAKi.mjs → note-context-BHwYpSFf.mjs} +1 -1
  59. package/dist/{note-context-b6k0mAKi.mjs.map → note-context-BHwYpSFf.mjs.map} +1 -1
  60. package/dist/{planner-DGV8VFib.mjs → planner-Cm3g6fWH.mjs} +5 -3
  61. package/dist/{planner-DGV8VFib.mjs.map → planner-Cm3g6fWH.mjs.map} +1 -1
  62. package/dist/{postgres-mW1n7Vi1.mjs → postgres-Dwxe290H.mjs} +1 -1
  63. package/dist/{postgres-mW1n7Vi1.mjs.map → postgres-Dwxe290H.mjs.map} +1 -1
  64. package/dist/{program-B-QJXCRb.mjs → program-AR0qlYRg.mjs} +309 -78
  65. package/dist/program-AR0qlYRg.mjs.map +1 -0
  66. package/dist/query-feedback-CGcEiGtY.mjs +3 -0
  67. package/dist/{query-feedback-BV4CcxqS.mjs → query-feedback-DZfjI34w.mjs} +1 -1
  68. package/dist/{query-feedback-BV4CcxqS.mjs.map → query-feedback-DZfjI34w.mjs.map} +1 -1
  69. package/dist/router-CAtyVQud.mjs +3 -0
  70. package/dist/{router-DcHKnEPa.mjs → router-DHracTUC.mjs} +1 -1
  71. package/dist/{router-DcHKnEPa.mjs.map → router-DHracTUC.mjs.map} +1 -1
  72. package/dist/server-BOuAOj9b.mjs +386 -0
  73. package/dist/server-BOuAOj9b.mjs.map +1 -0
  74. package/dist/skills/Worker/SKILL.md +6 -1
  75. package/dist/{sources-kLnQsNrW.mjs → sources-ju6AA7lL.mjs} +1 -1
  76. package/dist/{sources-kLnQsNrW.mjs.map → sources-ju6AA7lL.mjs.map} +1 -1
  77. package/dist/{sqlite-BenGr3UP.mjs → sqlite-kFZeWCJ2.mjs} +1 -1
  78. package/dist/{sqlite-BenGr3UP.mjs.map → sqlite-kFZeWCJ2.mjs.map} +1 -1
  79. package/dist/{state-Ca9F_sZc.mjs → state-DH_mp5Xe.mjs} +1 -1
  80. package/dist/{state-CAeyOdfq.mjs → state-ahFAbD8b.mjs} +1 -1
  81. package/dist/{state-CAeyOdfq.mjs.map → state-ahFAbD8b.mjs.map} +1 -1
  82. package/dist/{themes-BI4GMRP9.mjs → themes-DqhNUUQk.mjs} +1 -1
  83. package/dist/{themes-BI4GMRP9.mjs.map → themes-DqhNUUQk.mjs.map} +1 -1
  84. package/dist/{tools-DGcB3o_S.mjs → tools-CMlHa9lZ.mjs} +14 -14
  85. package/dist/{tools-DGcB3o_S.mjs.map → tools-CMlHa9lZ.mjs.map} +1 -1
  86. package/dist/{tools-Bp7hj6OW.mjs → tools-COna9vZR.mjs} +1 -1
  87. package/dist/{trace-bobARFEX.mjs → trace-DTP6k4B_.mjs} +1 -1
  88. package/dist/{trace-bobARFEX.mjs.map → trace-DTP6k4B_.mjs.map} +1 -1
  89. package/dist/{vault-indexer-C3OfXTNF.mjs → vault-indexer-YMH22mLU.mjs} +1 -1
  90. package/dist/{vault-indexer-C3OfXTNF.mjs.map → vault-indexer-YMH22mLU.mjs.map} +1 -1
  91. package/dist/{work-queue-worker-R7UGIag_.mjs → work-queue-worker-B3nl8yyn.mjs} +23 -26
  92. package/dist/work-queue-worker-B3nl8yyn.mjs.map +1 -0
  93. package/dist/work-queue-worker-BM1GcAAq.mjs +14 -0
  94. package/dist/{zettelkasten-m5QPtb-L.mjs → zettelkasten-DDvKRm2V.mjs} +3 -3
  95. package/dist/{zettelkasten-m5QPtb-L.mjs.map → zettelkasten-DDvKRm2V.mjs.map} +1 -1
  96. package/docs/commands/README.md +4 -2
  97. package/docs/commands/worker.md +48 -5
  98. package/docs/images/provider-abstraction-live.png +0 -0
  99. package/docs/images/workers.png +0 -0
  100. package/docs/launcher-independence.md +70 -0
  101. package/docs/provider-abstraction.md +87 -0
  102. package/docs/provider-independence-details.md +330 -0
  103. package/docs/provider-independence.md +75 -0
  104. package/docs/worker.md +29 -11
  105. package/package.json +1 -1
  106. package/scripts/build-hooks.mjs +6 -1
  107. package/scripts/build-skill-stubs.mjs +17 -4
  108. package/src/hooks/ts/lib/context-fill.test.ts +158 -4
  109. package/src/hooks/ts/lib/context-fill.ts +102 -35
  110. package/statusline-command.sh +120 -21
  111. package/dist/chain-CqlgL7uH.mjs.map +0 -1
  112. package/dist/config-B64vFg14.mjs.map +0 -1
  113. package/dist/context-handover-cache-BpUojjsi.mjs.map +0 -1
  114. package/dist/daemon-CmHHmKpg.mjs +0 -20
  115. package/dist/detector-DO730Zq0.mjs +0 -5
  116. package/dist/factory-BD-np0Vd.mjs +0 -3
  117. package/dist/fallback-DqYhiACU.mjs.map +0 -1
  118. package/dist/main-resolver-D6IImXvF.mjs +0 -4
  119. package/dist/main-resolver-DlaLOFBA.mjs.map +0 -1
  120. package/dist/program-B-QJXCRb.mjs.map +0 -1
  121. package/dist/query-feedback-DSVyHtrG.mjs +0 -3
  122. package/dist/router-Bk77E7hj.mjs +0 -3
  123. package/dist/runtime-paths-rni52zHX.mjs +0 -50
  124. package/dist/runtime-paths-rni52zHX.mjs.map +0 -1
  125. package/dist/work-queue-worker-DW8lz-Oo.mjs +0 -11
  126. package/dist/work-queue-worker-R7UGIag_.mjs.map +0 -1
@@ -1,478 +1,12 @@
1
- import { n as CONFIG_FILE } from "./config-B64vFg14.mjs";
2
- import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
1
+ import { a as WorkersConfigError, h as resolveModelCapability, l as isModelCapability, m as readWorkersSection, o as assertProviderRunnable, p as providerKeyPath, s as classModelCapability } from "./config-Dl8lT4Lu.mjs";
2
+ import { _ as workersLogDir, d as setCooldown, f as eventsPath, g as statusPath, h as panesDir, i as buildRunEnv, m as noMcpConfigPath, n as ensureProxyRunning, o as isQuotaFailure, p as ledgerPath, s as nextAutoProvider, t as DEFAULT_PROXY_PORT, u as resolveTarget } from "./server-BOuAOj9b.mjs";
3
+ import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
3
4
  import { homedir, tmpdir } from "node:os";
4
5
  import { dirname, join, relative } from "node:path";
5
- import { fileURLToPath } from "node:url";
6
6
  import { connect, createServer } from "node:net";
7
7
  import { execFileSync, spawn } from "node:child_process";
8
8
  import { createInterface } from "node:readline";
9
9
 
10
- //#region src/config/json-store.ts
11
- /**
12
- * json-store.ts — read/write JSON config files without destroying them
13
- *
14
- * The failure this exists to prevent:
15
- *
16
- * try { return JSON.parse(read(path)); } catch { return {}; }
17
- * ... later ...
18
- * write(path, JSON.stringify(ourData));
19
- *
20
- * An unreadable file becomes an empty object, and the next write makes that
21
- * permanent. Silently, exit code 0. This shape appeared three times in this
22
- * repo against three different files, and twice in AIBroker.
23
- *
24
- * The distinction that matters is between *missing* and *unreadable*:
25
- *
26
- * missing — legitimate first run. Start fresh; writing is safe.
27
- * unreadable — the file exists and we could not parse it. Those bytes are
28
- * the only copy of something. Never overwrite them.
29
- *
30
- * Collapsing the second into the first is the bug.
31
- *
32
- * NOT everything deserves this guard. For a transient buffer — an undelivered
33
- * message queue, a cache — starting fresh IS the correct recovery, and
34
- * refusing to write would disable the feature permanently. Use `writeJsonAtomic`
35
- * alone there: it still prevents a crash from truncating a good file, without
36
- * blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.
37
- */
38
- /**
39
- * Read a JSON file, distinguishing "absent" from "damaged".
40
- *
41
- * @param path file to read
42
- * @param label how to name it to the user, e.g. "~/.claude.json"
43
- * @throws if the file exists but cannot be read or parsed
44
- */
45
- function readJsonStrict(path, label = path) {
46
- if (!existsSync(path)) return {};
47
- let raw;
48
- try {
49
- raw = readFileSync(path, "utf8");
50
- } catch (e) {
51
- throw new Error(`Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — writing now would replace its contents with ours alone.`);
52
- }
53
- try {
54
- return JSON.parse(raw);
55
- } catch (e) {
56
- throw new Error(`${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — overwriting it would destroy whatever it holds.\nRepair the file, or move it aside and re-run this command.`);
57
- }
58
- }
59
- /**
60
- * Write JSON without risking the existing file.
61
- *
62
- * Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file
63
- * and renames. Rename is atomic within a filesystem, so a crash mid-write
64
- * leaves the original intact rather than truncated — which is how these files
65
- * become corrupt in the first place.
66
- */
67
- function writeJsonAtomic(path, data, opts = {}) {
68
- const { backup = true, label = path } = opts;
69
- const serialized = JSON.stringify(data, null, 2) + "\n";
70
- const dir = dirname(path);
71
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
72
- if (backup && existsSync(path)) try {
73
- copyFileSync(path, `${path}.bak-pai`);
74
- } catch (e) {
75
- throw new Error(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
76
- }
77
- const tmp = `${path}.tmp-pai-${process.pid}`;
78
- try {
79
- writeFileSync(tmp, serialized, "utf8");
80
- renameSync(tmp, path);
81
- } catch (e) {
82
- try {
83
- if (existsSync(tmp)) unlinkSync(tmp);
84
- } catch {}
85
- throw new Error(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\nThe original is unchanged.`);
86
- }
87
- }
88
-
89
- //#endregion
90
- //#region src/workers/config.ts
91
- /**
92
- * config.ts — the `workers` section of ~/.config/pai/config.json
93
- *
94
- * Everything that knows about worker providers reads this module: the CLI
95
- * (`pai worker …`), the MCP tools (worker_*), and the Agent-routing hook.
96
- * Keep it free of commander/MCP imports so all three layers stay thin over it.
97
- *
98
- * Keys are never stored here — only paths to 0600 files. A provider without a
99
- * keyFile is a local server and gets the placeholder token "local".
100
- *
101
- * `protocol: "openai"` providers run through the PAI proxy (src/workers/proxy):
102
- * `upstreamUrl` is their Chat Completions base, and `run` points
103
- * ANTHROPIC_BASE_URL at the local proxy with the provider name in the path.
104
- * `engine: "codex"` providers run through the Codex CLI instead of Claude
105
- * Code.
106
- */
107
- const DEFAULT_COST_TIER = 3;
108
- /** Tags a provider may carry; classes filter auto-routing on them. */
109
- const PROVIDER_TAGS = [
110
- "code",
111
- "vision",
112
- "image-gen",
113
- "long-context",
114
- "fast",
115
- "reasoning"
116
- ];
117
- /** The standard task classes; `workers.classes` maps each to a target. */
118
- const WORKER_CLASSES = [
119
- "draft",
120
- "plan",
121
- "implement",
122
- "review",
123
- "research",
124
- "spotcheck",
125
- "simple",
126
- "complex",
127
- "image"
128
- ];
129
- const DEFAULT_LOG_DIR = "~/.claude/logs/workers";
130
- const DEFAULT_PANE = {
131
- enabled: true,
132
- fontSize: 13,
133
- autoExitSecs: 60
134
- };
135
- const DEFAULT_ROUTING = {
136
- order: [],
137
- cooldownMinutes: 30,
138
- retryOnQuota: true
139
- };
140
- const DEFAULT_TREE = {
141
- maxDepth: 2,
142
- maxChildren: 4
143
- };
144
- /**
145
- * The default MCP sets every config starts from. `desktop` names the clickr
146
- * server so `--mcp desktop` hands a worker the machine controls (read-only
147
- * tools always; actuating ones after the operator hands the controls over,
148
- * see `pai worker controls`). A user's mcpSets section is merged over this,
149
- * so `desktop: []` removes the set deliberately.
150
- */
151
- const DEFAULT_MCP_SETS = { desktop: ["clickr"] };
152
- function defaultWorkersConfig() {
153
- return {
154
- enabled: false,
155
- active: null,
156
- providers: {},
157
- classes: {},
158
- mcpSets: { ...DEFAULT_MCP_SETS },
159
- pane: { ...DEFAULT_PANE },
160
- logDir: DEFAULT_LOG_DIR,
161
- routing: {
162
- ...DEFAULT_ROUTING,
163
- order: []
164
- },
165
- tree: { ...DEFAULT_TREE },
166
- fallback: null
167
- };
168
- }
169
- var WorkersConfigError = class extends Error {};
170
- function bad(path, why) {
171
- throw new WorkersConfigError(`workers${path}: ${why}`);
172
- }
173
- function str(v) {
174
- return typeof v === "string" ? v : "";
175
- }
176
- function parseProvider(name, raw) {
177
- if (typeof raw !== "object" || raw === null) bad(`.providers.${name}`, "must be an object");
178
- const p = raw;
179
- const protocol = p.protocol === void 0 ? "anthropic" : str(p.protocol);
180
- if (protocol !== "anthropic" && protocol !== "openai") bad(`.providers.${name}.protocol`, `"${str(p.protocol)}" is neither "anthropic" nor "openai"`);
181
- const engine = p.engine === void 0 ? "claude" : str(p.engine);
182
- if (engine !== "claude" && engine !== "codex") bad(`.providers.${name}.engine`, `"${str(p.engine)}" is neither "claude" nor "codex"`);
183
- const baseUrl = str(p.baseUrl);
184
- if (!baseUrl && protocol !== "openai") bad(`.providers.${name}.baseUrl`, "is required");
185
- const keyFile = p.keyFile === void 0 || p.keyFile === null || str(p.keyFile) === "" ? null : str(p.keyFile);
186
- const modelsRaw = p.models === void 0 ? {} : p.models;
187
- if (typeof modelsRaw !== "object" || modelsRaw === null) bad(`.providers.${name}.models`, "must be an object");
188
- const m = modelsRaw;
189
- const defaultModel = str(m.default);
190
- if (!defaultModel) bad(`.providers.${name}.models.default`, "is required");
191
- const fast = m.fast === void 0 ? void 0 : str(m.fast);
192
- const env = {};
193
- if (p.env !== void 0) {
194
- if (typeof p.env !== "object" || p.env === null || Array.isArray(p.env)) bad(`.providers.${name}.env`, "must be an object of string values");
195
- for (const [k, v] of Object.entries(p.env)) {
196
- if (typeof v !== "string") bad(`.providers.${name}.env.${k}`, "must be a string");
197
- env[k] = v;
198
- }
199
- }
200
- const quotaSkipAt = p.quotaSkipAt === void 0 ? void 0 : p.quotaSkipAt;
201
- if (quotaSkipAt !== void 0) {
202
- if (typeof quotaSkipAt !== "number" || quotaSkipAt < 0 || quotaSkipAt > 100) bad(`.providers.${name}.quotaSkipAt`, "must be a number between 0 and 100");
203
- }
204
- const contextWindow = p.contextWindow === void 0 ? void 0 : p.contextWindow;
205
- if (contextWindow !== void 0) {
206
- if (typeof contextWindow !== "number" || contextWindow <= 0) bad(`.providers.${name}.contextWindow`, "must be a positive number of tokens");
207
- }
208
- const upstreamUrl = str(p.upstreamUrl);
209
- if (protocol === "openai" && !upstreamUrl) bad(`.providers.${name}.upstreamUrl`, `is required for protocol "openai" (the Chat Completions base, e.g. "https://api.openai.com/v1")`);
210
- const costTier = p.costTier === void 0 ? void 0 : p.costTier;
211
- if (costTier !== void 0) {
212
- if (typeof costTier !== "number" || !Number.isInteger(costTier) || costTier < 1 || costTier > 5) bad(`.providers.${name}.costTier`, "must be an integer 1 (cheapest) … 5 (most expensive)");
213
- }
214
- let tags;
215
- if (p.tags !== void 0) {
216
- if (!Array.isArray(p.tags) || p.tags.some((x) => typeof x !== "string")) bad(`.providers.${name}.tags`, `must be an array of tags from: ${PROVIDER_TAGS.join(", ")}`);
217
- for (const t of p.tags) if (!PROVIDER_TAGS.includes(t)) bad(`.providers.${name}.tags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
218
- tags = p.tags;
219
- }
220
- return {
221
- enabled: p.enabled === void 0 ? true : p.enabled === true,
222
- protocol,
223
- baseUrl,
224
- keyFile,
225
- models: fast ? {
226
- default: defaultModel,
227
- fast
228
- } : { default: defaultModel },
229
- env,
230
- ...str(p.note) ? { note: str(p.note) } : {},
231
- ...upstreamUrl ? { upstreamUrl } : {},
232
- ...engine !== "claude" ? { engine } : {},
233
- ...str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {},
234
- ...quotaSkipAt !== void 0 ? { quotaSkipAt } : {},
235
- ...contextWindow !== void 0 ? { contextWindow } : {},
236
- ...costTier !== void 0 ? { costTier } : {},
237
- ...tags ? { tags } : {}
238
- };
239
- }
240
- /**
241
- * Parse and validate a raw `workers` value. Missing section → defaults.
242
- * Unknown-but-typed garbage → WorkersConfigError naming the offending field.
243
- */
244
- function parseWorkersConfig(raw) {
245
- const d = defaultWorkersConfig();
246
- if (raw === void 0 || raw === null) return d;
247
- if (typeof raw !== "object" || Array.isArray(raw)) bad("", "section must be an object");
248
- const w = raw;
249
- const providers = {};
250
- if (w.providers !== void 0) {
251
- if (typeof w.providers !== "object" || w.providers === null || Array.isArray(w.providers)) bad(".providers", "must be an object keyed by provider name");
252
- for (const [name, p] of Object.entries(w.providers)) providers[name] = parseProvider(name, p);
253
- }
254
- const classes = {};
255
- const classesRaw = w.classes !== void 0 ? w.classes : w.roles;
256
- if (classesRaw !== void 0) {
257
- if (typeof classesRaw !== "object" || classesRaw === null || Array.isArray(classesRaw)) bad(w.classes !== void 0 ? ".classes" : ".roles", "must be an object of class → provider[/alias] or {provider, mcp, maxCostTier, requireTags, order}");
258
- for (const [cls, target] of Object.entries(classesRaw)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
259
- const o = target;
260
- const provider = str(o.provider);
261
- if (o.provider !== void 0 && (!provider || provider.includes(" "))) bad(`.classes.${cls}.provider`, `invalid provider "${provider}"`);
262
- let mcp;
263
- if (o.mcp !== void 0) {
264
- if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== "string")) bad(`.classes.${cls}.mcp`, "must be an array of MCP server or set names");
265
- mcp = o.mcp;
266
- }
267
- let maxCostTier;
268
- if (o.maxCostTier !== void 0) {
269
- if (typeof o.maxCostTier !== "number" || !Number.isInteger(o.maxCostTier) || o.maxCostTier < 1 || o.maxCostTier > 5) bad(`.classes.${cls}.maxCostTier`, "must be an integer 1 … 5");
270
- maxCostTier = o.maxCostTier;
271
- }
272
- let requireTags;
273
- if (o.requireTags !== void 0) {
274
- if (!Array.isArray(o.requireTags) || o.requireTags.some((x) => typeof x !== "string")) bad(`.classes.${cls}.requireTags`, `must be an array of tags from: ${PROVIDER_TAGS.join(", ")}`);
275
- for (const t of o.requireTags) if (!PROVIDER_TAGS.includes(t)) bad(`.classes.${cls}.requireTags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
276
- requireTags = o.requireTags;
277
- }
278
- let order;
279
- if (o.order !== void 0) {
280
- if (!Array.isArray(o.order) || o.order.some((x) => typeof x !== "string")) bad(`.classes.${cls}.order`, "must be an array of provider names");
281
- order = o.order;
282
- }
283
- const obj = {
284
- ...provider ? { provider } : {},
285
- ...mcp ? { mcp } : {},
286
- ...maxCostTier !== void 0 ? { maxCostTier } : {},
287
- ...requireTags ? { requireTags } : {},
288
- ...order ? { order } : {}
289
- };
290
- classes[cls] = Object.keys(obj).length ? obj : {};
291
- } else {
292
- const t = str(target);
293
- if (!t || t.includes(" ")) bad(`.classes.${cls}`, `invalid target "${t}"`);
294
- classes[cls] = t;
295
- }
296
- }
297
- const mcpSets = { ...DEFAULT_MCP_SETS };
298
- if (w.mcpSets !== void 0) {
299
- if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
300
- for (const [setName, servers] of Object.entries(w.mcpSets)) {
301
- if (!Array.isArray(servers) || servers.some((x) => typeof x !== "string")) bad(`.mcpSets.${setName}`, "must be an array of MCP server names");
302
- mcpSets[setName] = servers;
303
- }
304
- }
305
- let pane = { ...DEFAULT_PANE };
306
- if (w.pane !== void 0) {
307
- if (typeof w.pane !== "object" || w.pane === null) bad(".pane", "must be an object");
308
- const pc = w.pane;
309
- if (pc.enabled !== void 0 && typeof pc.enabled !== "boolean") bad(".pane.enabled", "must be boolean");
310
- if (pc.fontSize !== void 0 && (typeof pc.fontSize !== "number" || pc.fontSize <= 0)) bad(".pane.fontSize", "must be a positive number of points");
311
- if (pc.autoExitSecs !== void 0 && typeof pc.autoExitSecs !== "number") bad(".pane.autoExitSecs", "must be a number");
312
- pane = {
313
- enabled: pc.enabled === void 0 ? DEFAULT_PANE.enabled : pc.enabled === true,
314
- fontSize: pc.fontSize === void 0 ? DEFAULT_PANE.fontSize : pc.fontSize,
315
- autoExitSecs: pc.autoExitSecs === void 0 ? DEFAULT_PANE.autoExitSecs : pc.autoExitSecs
316
- };
317
- }
318
- let routing = {
319
- ...DEFAULT_ROUTING,
320
- order: []
321
- };
322
- if (w.routing !== void 0) {
323
- if (typeof w.routing !== "object" || w.routing === null) bad(".routing", "must be an object");
324
- const r = w.routing;
325
- if (r.order !== void 0) {
326
- if (!Array.isArray(r.order) || r.order.some((x) => typeof x !== "string")) bad(".routing.order", "must be an array of provider names");
327
- routing.order = r.order;
328
- }
329
- if (r.cooldownMinutes !== void 0 && typeof r.cooldownMinutes !== "number") bad(".routing.cooldownMinutes", "must be a number");
330
- if (r.retryOnQuota !== void 0 && typeof r.retryOnQuota !== "boolean") bad(".routing.retryOnQuota", "must be boolean");
331
- routing = {
332
- order: routing.order,
333
- cooldownMinutes: r.cooldownMinutes === void 0 ? DEFAULT_ROUTING.cooldownMinutes : r.cooldownMinutes,
334
- retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
335
- };
336
- }
337
- let tree = { ...DEFAULT_TREE };
338
- if (w.tree !== void 0) {
339
- if (typeof w.tree !== "object" || w.tree === null || Array.isArray(w.tree)) bad(".tree", "must be an object");
340
- const t = w.tree;
341
- if (t.maxDepth !== void 0) {
342
- if (typeof t.maxDepth !== "number" || !Number.isInteger(t.maxDepth) || t.maxDepth < 0) bad(".tree.maxDepth", "must be a non-negative integer");
343
- }
344
- if (t.maxChildren !== void 0) {
345
- if (typeof t.maxChildren !== "number" || !Number.isInteger(t.maxChildren) || t.maxChildren < 1) bad(".tree.maxChildren", "must be a positive integer");
346
- }
347
- tree = {
348
- maxDepth: t.maxDepth === void 0 ? DEFAULT_TREE.maxDepth : t.maxDepth,
349
- maxChildren: t.maxChildren === void 0 ? DEFAULT_TREE.maxChildren : t.maxChildren
350
- };
351
- }
352
- const active = w.active === void 0 || w.active === null ? null : str(w.active);
353
- if (active !== null && active !== "auto" && !(active in providers)) {}
354
- let fallback = null;
355
- if (w.fallback !== void 0 && w.fallback !== null) {
356
- if (typeof w.fallback !== "object" || Array.isArray(w.fallback)) bad(".fallback", "must be an object (written by `pai worker fallback on`)");
357
- const f = w.fallback;
358
- const provider = str(f.provider);
359
- if (!provider) bad(".fallback.provider", "is required");
360
- const savedRaw = f.saved;
361
- if (typeof savedRaw !== "object" || savedRaw === null || Array.isArray(savedRaw)) bad(".fallback.saved", "must be an object");
362
- const s = savedRaw;
363
- if (typeof s.env !== "object" || s.env === null || Array.isArray(s.env)) bad(".fallback.saved.env", "must be an object of env key → previous value or null");
364
- const env = {};
365
- for (const [k, v] of Object.entries(s.env)) env[k] = v === null || typeof v === "string" ? v : null;
366
- if (s.model !== null && s.model !== void 0 && typeof s.model !== "string") bad(".fallback.saved.model", "must be a string or null");
367
- fallback = {
368
- provider,
369
- saved: {
370
- env,
371
- model: typeof s.model === "string" ? s.model : null,
372
- envExisted: s.envExisted === true
373
- },
374
- on: str(f.on) || (/* @__PURE__ */ new Date(0)).toISOString()
375
- };
376
- }
377
- return {
378
- enabled: w.enabled === void 0 ? d.enabled : w.enabled === true,
379
- active,
380
- providers,
381
- classes,
382
- mcpSets,
383
- pane,
384
- logDir: str(w.logDir) || d.logDir,
385
- routing,
386
- tree,
387
- fallback
388
- };
389
- }
390
- /**
391
- * Read the whole config file and return (raw, workers) — the raw record so
392
- * callers can rewrite it preserving every other section, the parsed+validated
393
- * workers section. Unreadable config throws (readJsonStrict), missing is fine.
394
- * `path` overrides the config location (tests, CLAUDE_SETTINGS_PATH-style
395
- * dry runs); default ~/.config/pai/config.json.
396
- */
397
- function readWorkersSection(path = CONFIG_FILE) {
398
- const raw = readJsonStrict(path, "~/.config/pai/config.json");
399
- return {
400
- raw,
401
- workers: parseWorkersConfig(raw.workers)
402
- };
403
- }
404
- /** Write the workers section back into the config file, atomically. */
405
- function writeWorkersSection(raw, workers, path = CONFIG_FILE) {
406
- raw.workers = workers.fallback ? workers : {
407
- ...workers,
408
- fallback: void 0
409
- };
410
- writeJsonAtomic(path, raw, { label: "~/.config/pai/config.json" });
411
- }
412
- /** Expand a leading ~ (config values are written with `~` to stay portable). */
413
- function expandHome(p) {
414
- if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1));
415
- return p;
416
- }
417
- function assertProviderRunnable(name, p) {
418
- if (p.protocol === "openai" && !p.upstreamUrl) throw new WorkersConfigError(`provider "${name}" uses protocol "openai" but has no upstreamUrl — set its Chat Completions base (e.g. "https://api.openai.com/v1") so the PAI proxy knows where to translate to.`);
419
- }
420
- /** Where a provider's key file lives, or null. Shared by run + test + add. */
421
- function providerKeyPath(p) {
422
- return p.keyFile ? expandHome(p.keyFile) : null;
423
- }
424
- const DEFAULT_CONTEXT_WINDOW = 2e5;
425
- /** Cost tier of a provider for class filtering (unset = 3, the middle). */
426
- function providerCostTier(p) {
427
- return p.costTier ?? DEFAULT_COST_TIER;
428
- }
429
- /** Context window used by the meter when the init event carries none. */
430
- function providerContextWindow(p) {
431
- return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
432
- }
433
- /** Directory under which inline keys (MCP `key` field) are stored, 0600. */
434
- function keysDir() {
435
- return join(homedir(), ".config", "pai", "keys");
436
- }
437
-
438
- //#endregion
439
- //#region src/workers/paths.ts
440
- /**
441
- * paths.ts — where worker run artefacts live.
442
- *
443
- * Everything a run writes (event mirror, status file, pane registry, routing
444
- * state, the empty MCP config) sits under one logDir so the whole tree is
445
- * disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.
446
- */
447
- /** Absolute logDir for the given config. */
448
- function workersLogDir(config) {
449
- return expandHome(config.logDir);
450
- }
451
- function statusPath(logDir, id) {
452
- return join(logDir, `${id}.status`);
453
- }
454
- function eventsPath(logDir, id) {
455
- return join(logDir, `${id}.jsonl`);
456
- }
457
- function ledgerPath(logDir) {
458
- return join(logDir, "ledger.log");
459
- }
460
- function routingStatePath(logDir) {
461
- return join(logDir, "routing-state.json");
462
- }
463
- function panesDir(logDir) {
464
- return join(logDir, "panes");
465
- }
466
- /**
467
- * The strict empty MCP config handed to headless workers. Written into the
468
- * logDir on demand (never into the user's vendor config directory — this is
469
- * PAI state, not vendor state).
470
- */
471
- function noMcpConfigPath(logDir) {
472
- return join(logDir, "no-mcp.json");
473
- }
474
-
475
- //#endregion
476
10
  //#region src/workers/args.ts
477
11
  function parseRunnerArgs(argv) {
478
12
  let prompt = null;
@@ -633,11 +167,27 @@ function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()
633
167
  * runner wrote, plus `provider` (routing is multi-provider now) and `session`
634
168
  * (the AIBroker identity of the launching session, see scope.ts).
635
169
  */
170
+ /** Label a worker gets when launched with neither --label nor a prompt. */
171
+ const UNLABELED = "unlabeled";
636
172
  /** Context-meter percentage 0–100, null when the numbers are missing. */
637
173
  function contextPercent(s) {
638
174
  if (!s.contextTokens || !s.contextWindow) return null;
639
175
  return Math.round(s.contextTokens / s.contextWindow * 100);
640
176
  }
177
+ /** Compact token count: 84k, 200k, 900. */
178
+ function fmtContextK(n) {
179
+ return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
180
+ }
181
+ /**
182
+ * The one context-meter label, shared by every renderer so identical
183
+ * numbers always render identically: `ctx 84k/200k (42%)` — used-style
184
+ * percent. Empty when the numbers are missing (callers drop the part).
185
+ */
186
+ function contextLabel(s) {
187
+ const pct = contextPercent(s);
188
+ if (pct === null) return "";
189
+ return `ctx ${fmtContextK(s.contextTokens ?? 0)}/${fmtContextK(s.contextWindow ?? 0)} (${pct}%)`;
190
+ }
641
191
  /** Timestamp format shared by status files and the ledger. */
642
192
  function nowStamp(d = /* @__PURE__ */ new Date()) {
643
193
  const p = (n) => String(n).padStart(2, "0");
@@ -730,6 +280,12 @@ function describeTool(name, inp) {
730
280
  * matches (AIBroker absent, unnamed session, non-iTerm terminal).
731
281
  *
732
282
  * If neither is available, viewers fall back to "all workers".
283
+ *
284
+ * A third case: workers spawned from a Claude Code Bash tool (the
285
+ * orchestrator pattern) have neither — Claude Code exports no terminal or
286
+ * session identity to its Bash children. Those record `spawnerSession`, the
287
+ * orchestrator's claude session id bridged through the status line's
288
+ * session map (see below), so their tab still claims them.
733
289
  */
734
290
  const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
735
291
  /**
@@ -799,213 +355,32 @@ function scopeKey(term) {
799
355
  function sessionTag(worker) {
800
356
  return worker.session?.name ? `[${worker.session.name}]` : "";
801
357
  }
802
-
803
- //#endregion
804
- //#region src/workers/routing.ts
805
- /**
806
- * routing.ts — provider selection: flag > class > active (possibly "auto").
807
- *
808
- * Auto-routing walks `workers.routing.order` — or the class's own `order` —
809
- * and takes the first provider that is enabled, out of cooldown, (when it
810
- * defines a quotaProbe) under its quotaSkipAt threshold, and — when the class
811
- * constrains it — within `maxCostTier` and carrying all `requireTags`. A run
812
- * that dies of a quota/rate error puts its provider in cooldown for
813
- * cooldownMinutes; when that happens before the first tool call and
814
- * retryOnQuota is set, the runner restarts the same task on the next provider
815
- * (ledger: WORKER-REROUTE).
816
- *
817
- * An explicit --provider or a class mapping that pins a provider always
818
- * bypasses all of this.
819
- */
820
- const QUOTA_SKIP_DEFAULT = 95;
821
- const PROBE_TIMEOUT_MS = 1e4;
822
- function readRoutingState(logDir) {
823
- const path = routingStatePath(logDir);
824
- if (!existsSync(path)) return { cooldowns: {} };
825
- try {
826
- return { cooldowns: JSON.parse(readFileSync(path, "utf8")).cooldowns ?? {} };
827
- } catch {
828
- return { cooldowns: {} };
829
- }
830
- }
831
- function writeRoutingState(logDir, state) {
832
- const path = routingStatePath(logDir);
833
- const dir = dirname(path);
834
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
835
- const tmp = `${path}.tmp`;
836
- writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
837
- renameSync(tmp, path);
838
- }
839
- function cooldownRemaining(state, provider, now = /* @__PURE__ */ new Date()) {
840
- const end = state.cooldowns[provider];
841
- if (!end) return 0;
842
- const ms = Date.parse(end) - now.getTime();
843
- return ms > 0 ? ms : 0;
844
- }
845
- function setCooldown(logDir, provider, minutes, now = /* @__PURE__ */ new Date()) {
846
- const state = readRoutingState(logDir);
847
- state.cooldowns[provider] = new Date(now.getTime() + minutes * 6e4).toISOString();
848
- writeRoutingState(logDir, state);
849
- }
850
- function clearCooldown(logDir, provider) {
851
- const state = readRoutingState(logDir);
852
- if (!(provider in state.cooldowns)) return;
853
- delete state.cooldowns[provider];
854
- writeRoutingState(logDir, state);
358
+ /** How old a map entry may be for a spawn to adopt it (status lines refresh constantly while a session lives). */
359
+ const SPAWNER_SESSION_TTL_MS = 10 * 6e4;
360
+ /** Map entries not refreshed within this window are pruned on write. */
361
+ const SESSION_MAP_PRUNE_MS = 60 * 6e4;
362
+ function sessionMapPath(logDir) {
363
+ return join(logDir, "claude-session-map.json");
855
364
  }
856
365
  /**
857
- * Run a provider's quotaProbe and return its percentage (0–100), or null when
858
- * there is no probe or it printed nothing usable. A probe must never break
859
- * routing: failures read as "unknown", not as "full".
366
+ * The claude session a new run was spawned by, when it can be known: a run
367
+ * inside another worker inherits its spawner (chain stages keep the
368
+ * orchestrator's tab that way); otherwise a fresh map entry for `cwd`.
860
369
  */
861
- function probeQuota(provider) {
862
- if (!provider.quotaProbe) return null;
370
+ function resolveSpawnerSession(logDir, cwd, env = process.env, now = Date.now()) {
371
+ const parentId = env.PAI_WORKER_ID;
372
+ if (parentId) {
373
+ const inherited = loadStatus(logDir, parentId)?.spawnerSession;
374
+ if (inherited) return inherited;
375
+ }
376
+ const path = sessionMapPath(logDir);
377
+ if (!existsSync(path) || !cwd) return null;
863
378
  try {
864
- const m = execFileSync("/bin/sh", ["-c", provider.quotaProbe], {
865
- timeout: PROBE_TIMEOUT_MS,
866
- encoding: "utf8",
867
- stdio: [
868
- "ignore",
869
- "pipe",
870
- "ignore"
871
- ]
872
- }).match(/(\d+(?:\.\d+)?)/);
873
- if (!m) return null;
874
- return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));
875
- } catch {
876
- return null;
877
- }
878
- }
879
- function quotaSkipThreshold(provider) {
880
- return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;
881
- }
882
- /** Does this provider exceed its quota threshold right now? */
883
- function quotaExceeded(provider) {
884
- const used = probeQuota(provider);
885
- return used !== null && used >= quotaSkipThreshold(provider);
886
- }
887
- var NoProviderError = class extends WorkersConfigError {};
888
- function mustExist(config, name) {
889
- const p = config.providers[name];
890
- if (!p) throw new NoProviderError(`no worker provider named "${name}". Configured: ${Object.keys(config.providers).join(", ") || "(none)"}.\nAdd one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
891
- return p;
892
- }
893
- function mustBeRunnable(name, p) {
894
- if (!p.enabled) throw new NoProviderError(`provider "${name}" is disabled. Enable it with: pai worker providers enable ${name}`);
895
- return p;
896
- }
897
- /** Why one provider of a routing order did not qualify (message fragment). */
898
- function exclusionReason(config, state, name, cls) {
899
- const p = config.providers[name];
900
- if (!p) return "not configured";
901
- if (!p.enabled) return "disabled";
902
- if (cooldownRemaining(state, name) > 0) return "cooldown";
903
- if (quotaExceeded(p)) return "quota";
904
- const tier = providerCostTier(p);
905
- if (cls?.maxCostTier !== void 0 && tier > cls.maxCostTier) return `cost tier ${tier} > max ${cls.maxCostTier}`;
906
- if (cls?.requireTags?.length) {
907
- const have = p.tags ?? [];
908
- const missing = cls.requireTags.filter((t) => !have.includes(t));
909
- if (missing.length) return `missing tags: ${missing.join(", ")}`;
910
- }
911
- return null;
912
- }
913
- /**
914
- * Resolve which provider (and model alias) a run uses.
915
- *
916
- * @param flagProvider --provider value, highest precedence
917
- * @param className --class value (the old --role), looked up in workers.classes
918
- */
919
- function resolveTarget(config, logDir, opts = {}) {
920
- if (opts.flagProvider) return {
921
- providerName: opts.flagProvider,
922
- provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),
923
- modelAlias: null,
924
- classMcp: null,
925
- via: "flag"
926
- };
927
- const clsTarget = opts.className ? config.classes[opts.className] : void 0;
928
- const cls = typeof clsTarget === "object" && clsTarget !== null ? clsTarget : void 0;
929
- if (opts.className && clsTarget !== void 0 && typeof clsTarget !== "object") {
930
- const [name, alias] = clsTarget.split("/");
931
- return {
932
- providerName: name,
933
- provider: mustBeRunnable(name, mustExist(config, name)),
934
- modelAlias: alias ?? null,
935
- classMcp: null,
936
- via: "class"
937
- };
938
- }
939
- if (opts.className && cls?.provider) {
940
- const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));
941
- return {
942
- providerName: cls.provider,
943
- provider,
944
- modelAlias: null,
945
- classMcp: cls.mcp ?? null,
946
- via: "class"
947
- };
948
- }
949
- if (opts.className && clsTarget === void 0) {
950
- if (!WORKER_CLASSES.includes(opts.className)) throw new NoProviderError(`no class named "${opts.className}". Standard classes: ${WORKER_CLASSES.join(", ")}.Defined: ${Object.keys(config.classes).join(", ") || "(none)"}.\nSet one with: pai worker classes set ${opts.className}=<provider[/alias]>`);
951
- }
952
- if (config.active !== "auto") {
953
- const name = config.active;
954
- if (!name) throw new NoProviderError("no active worker provider. Add one with: pai worker providers add <name> --base-url <url> --key-file <path> --model <model>\n(or point one that exists at it: pai worker providers use <name>)");
955
- return {
956
- providerName: name,
957
- provider: mustBeRunnable(name, mustExist(config, name)),
958
- modelAlias: null,
959
- classMcp: null,
960
- via: "active"
961
- };
962
- }
963
- const state = readRoutingState(logDir);
964
- const order = cls?.order ?? config.routing.order;
965
- const excluded = [];
966
- for (const name of order) {
967
- const p = config.providers[name];
968
- const why = exclusionReason(config, state, name, cls);
969
- if (why) {
970
- excluded.push(`${name}: ${why}`);
971
- continue;
972
- }
973
- return {
974
- providerName: name,
975
- provider: p,
976
- modelAlias: null,
977
- classMcp: cls?.mcp ?? null,
978
- via: "auto"
979
- };
980
- }
981
- throw new NoProviderError(`auto-routing found no usable provider${opts.className ? ` for class "${opts.className}"` : ""} (order: [${order.join(", ")}]).${excluded.length ? `\nExcluded: ${excluded.join("; ")}.` : ""}\nClear a cooldown with: pai worker providers enable <name>; widen the class with: pai worker classes set ${opts.className ?? "<class>"}=<target>`);
982
- }
983
- /**
984
- * Next provider after `from` in auto order, applying the same filters.
985
- * Used by rerouting; null when the order is exhausted.
986
- */
987
- function nextAutoProvider(config, logDir, from, now = /* @__PURE__ */ new Date()) {
988
- const state = readRoutingState(logDir);
989
- const order = config.routing.order;
990
- const start = order.indexOf(from);
991
- for (let i = start + 1; i < order.length; i++) {
992
- const name = order[i];
993
- const p = config.providers[name];
994
- if (!p || !p.enabled) continue;
995
- const end = state.cooldowns[name];
996
- if (end && Date.parse(end) > now.getTime()) continue;
997
- if (quotaExceeded(p)) continue;
998
- return name;
999
- }
379
+ const entry = JSON.parse(readFileSync(path, "utf8"))[cwd];
380
+ if (entry && entry.session && now - entry.ts < SPAWNER_SESSION_TTL_MS) return entry.session;
381
+ } catch {}
1000
382
  return null;
1001
383
  }
1002
- /**
1003
- * Was this failure a quota/rate failure? Detected from the result text the
1004
- * endpoint produced (HTTP status is not visible in the stream events).
1005
- */
1006
- function isQuotaFailure(resultText) {
1007
- return /usage limit reached|rate limit|quota/i.test(resultText);
1008
- }
1009
384
 
1010
385
  //#endregion
1011
386
  //#region src/workers/pane.ts
@@ -1017,7 +392,10 @@ function isQuotaFailure(resultText) {
1017
392
  * one splits the lowest live worker pane horizontally, so panes stack top to
1018
393
  * bottom. The split never sizes the new session — that would grow the whole
1019
394
  * window; instead the window's bounds are read before the split and restored
1020
- * right after, so the panes share the space the window already had. Each
395
+ * right after, so the panes share the space the window already had. A split
396
+ * would also make the new session the tab's active one — the scripts re-select
397
+ * the launching session right after, so a pane opening never steals the
398
+ * keystrokes the operator is typing. Each
1021
399
  * pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
1022
400
  * dynamic profile (Close Sessions On End), so panes disappear by themselves.
1023
401
  *
@@ -1096,6 +474,13 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
1096
474
  tell newS
1097
475
  write text followCmd
1098
476
  end tell
477
+ -- a split makes the new session the tab's active one:
478
+ -- re-select the launching session so keystrokes keep
479
+ -- going where the operator was typing, not into the
480
+ -- pane's chat prompt
481
+ try
482
+ select s
483
+ end try
1099
484
  try
1100
485
  set bounds of w to winBounds
1101
486
  end try
@@ -1179,6 +564,11 @@ const SPLIT_SCRIPT = `on run(argv)
1179
564
  tell newS
1180
565
  write text followCmd
1181
566
  end tell
567
+ -- keep the tab's active session where it was (see
568
+ -- WORKER_SPLIT_SCRIPT)
569
+ try
570
+ select s
571
+ end try
1182
572
  return "opened"
1183
573
  end if
1184
574
  end repeat
@@ -1506,9 +896,11 @@ function parentFromEnv(env = process.env) {
1506
896
  * Depth of `id` in the worker forest: 0 for a top-level worker, 1 + the
1507
897
  * parent's depth for a sub-worker. Parents without a status file (chain ids,
1508
898
  * unknown ids) count as roots; a cycle reads as its own depth and is cut off
1509
- * after the statuses it walked.
899
+ * after the statuses it walked. `chatIsRoot` stops one level short of the
900
+ * chat-pane tracker (origin "chat"): the status line treats the pane as the
901
+ * bar itself, so the workers it spawned are its top level.
1510
902
  */
1511
- function workerDepth(statuses, id) {
903
+ function workerDepth(statuses, id, opts) {
1512
904
  const byId = new Map(statuses.map((s) => [s.id, s]));
1513
905
  let depth = 0;
1514
906
  let cur = byId.get(id);
@@ -1517,7 +909,7 @@ function workerDepth(statuses, id) {
1517
909
  seen.add(cur.parent);
1518
910
  const next = byId.get(cur.parent);
1519
911
  if (!next) break;
1520
- depth += 1;
912
+ if (!(opts?.chatIsRoot && next.origin === "chat")) depth += 1;
1521
913
  cur = next;
1522
914
  }
1523
915
  return depth;
@@ -1752,7 +1144,7 @@ async function handoffFromInside(logDir, env, payload, deps = {}) {
1752
1144
  * commits its own work on that branch — the no-commit rule applies to the
1753
1145
  * main branch only — and the parent (or the operator) merges the result back:
1754
1146
  *
1755
- * pai worker merge <id> git merge --no-ff worker/<id> + remove worktree
1147
+ * pai worker merge <id> git merge --no-ff worker/<id> + remove worktree + delete branch
1756
1148
  * pai worker discard <id> remove worktree and branch, keep nothing
1757
1149
  *
1758
1150
  * `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
@@ -1903,26 +1295,160 @@ function removeWorktree(cwd, dir, force) {
1903
1295
  } catch {}
1904
1296
  }
1905
1297
  /**
1906
- * `pai worker merge <id>`: merge the worker's branch into the original
1907
- * checkout with --no-ff (the merge commit names the worker), then remove the
1908
- * worktree. The branch stays for the record; the status gains `merged: true`.
1298
+ * Tracked changes (staged or not) plus untracked files everything a
1299
+ * `git add -A` in `wtDir` would commit. The same two calls the old patch
1300
+ * carry used; name-only, not porcelain: a worktree-only change renders as
1301
+ * " M path" and the shared git() helper trims that leading space away.
1302
+ */
1303
+ function uncommittedPaths(wtDir) {
1304
+ const tracked = git(wtDir, [
1305
+ "diff",
1306
+ "--name-only",
1307
+ "HEAD"
1308
+ ]).split("\n").filter(Boolean);
1309
+ const untracked = git(wtDir, [
1310
+ "ls-files",
1311
+ "--others",
1312
+ "--exclude-standard"
1313
+ ]).split("\n").filter(Boolean);
1314
+ return [...tracked, ...untracked];
1315
+ }
1316
+ /**
1317
+ * Commit a worktree's uncommitted changes to its branch so the merge carries
1318
+ * them. `git merge` only moves committed work, so a worker that stopped
1319
+ * without committing would lose its edits to `worktree remove` — exactly
1320
+ * what happened live on 2026-09-17. Returns the salvaged paths, [] when the
1321
+ * worktree is clean. A failed commit throws with the worktree untouched:
1322
+ * its edits are still on disk, so nothing is lost.
1323
+ */
1324
+ function salvageUncommitted(wtDir, label) {
1325
+ const paths = uncommittedPaths(wtDir);
1326
+ if (!paths.length) return [];
1327
+ try {
1328
+ git(wtDir, ["add", "-A"]);
1329
+ git(wtDir, [
1330
+ "commit",
1331
+ "-m",
1332
+ `salvaged: ${label}`
1333
+ ]);
1334
+ } catch (e) {
1335
+ throw new Error(`cannot salvage the uncommitted changes in ${wtDir} — ${e.message}; nothing was lost: commit them there by hand, then re-run merge`);
1336
+ }
1337
+ return paths;
1338
+ }
1339
+ /**
1340
+ * The dirty paths of a checkout, parsed from `git status --porcelain -z`:
1341
+ * NUL-separated (a path with a newline in it cannot corrupt the parse),
1342
+ * rename entries contribute both sides, and any quoting is stripped.
1343
+ */
1344
+ function dirtyPaths(cwd) {
1345
+ const fields = execFileSync("git", [
1346
+ "-C",
1347
+ cwd,
1348
+ "status",
1349
+ "--porcelain",
1350
+ "-z"
1351
+ ], {
1352
+ encoding: "utf8",
1353
+ timeout: 3e4,
1354
+ stdio: [
1355
+ "ignore",
1356
+ "pipe",
1357
+ "pipe"
1358
+ ]
1359
+ }).split("\0");
1360
+ const strip = (p) => p.startsWith("\"") && p.endsWith("\"") ? p.slice(1, -1) : p;
1361
+ const out = [];
1362
+ for (let i = 0; i < fields.length; i++) {
1363
+ const f = fields[i];
1364
+ if (!f || f.length < 4 || f.charAt(2) !== " ") continue;
1365
+ out.push(strip(f.slice(3)));
1366
+ const xy = f.slice(0, 2);
1367
+ if (xy.includes("R") || xy.includes("C")) {
1368
+ const orig = fields[i + 1];
1369
+ if (orig && orig.charAt(2) !== " ") {
1370
+ out.push(strip(orig));
1371
+ i += 1;
1372
+ }
1373
+ }
1374
+ }
1375
+ return out;
1376
+ }
1377
+ /**
1378
+ * Refuse the merge when the original checkout is dirty in paths the branch
1379
+ * touches: the merge would overwrite those edits or fail on them, either way
1380
+ * leaving a half-state. No merge is made, the worktree stays.
1381
+ */
1382
+ function assertNoDirtyOverlap(cwd, incoming, id, branch) {
1383
+ const dirty = new Set(dirtyPaths(cwd));
1384
+ const overlap = [...new Set(incoming)].filter((p) => dirty.has(p)).sort();
1385
+ if (!overlap.length) return;
1386
+ throw new Error(`worker ${id}: the checkout ${cwd} has uncommitted changes in paths ${branch} touches: ${overlap.join(", ")}. Commit or stash them in the checkout, then re-run: pai worker merge ${id}. No merge was made; the worktree was kept.`);
1387
+ }
1388
+ /** Never remove a worktree that still holds uncommitted changes. */
1389
+ function assertWorktreeClean(wtDir, id) {
1390
+ const leftover = uncommittedPaths(wtDir);
1391
+ if (leftover.length) throw new Error(`worker ${id}: the worktree ${wtDir} still holds uncommitted changes (${leftover.join(", ")}) — it was NOT removed; commit or copy them by hand, then re-run merge`);
1392
+ }
1393
+ /**
1394
+ * `pai worker merge <id>`: salvage whatever the worker left uncommitted onto
1395
+ * its branch, refuse when the original checkout is dirty in paths the branch
1396
+ * touches, merge with --no-ff (the merge commit names the worker), then
1397
+ * remove the worktree and delete the branch; the status gains `merged: true`.
1398
+ * A branch with nothing to merge is refused loudly — after salvage that
1399
+ * genuinely means it holds nothing, and reporting success there would
1400
+ * destroy the worktree for no gain.
1909
1401
  */
1910
1402
  function mergeWorker(logDir, id) {
1911
1403
  const st = mustHaveBranch(logDir, id);
1912
1404
  if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
1913
- git(st.cwd, [
1914
- "merge",
1915
- "--no-ff",
1916
- st.branch,
1917
- "-m",
1918
- `merge worker ${id} (${st.label})`
1405
+ const salvaged = existsSync(st.worktreeDir) ? salvageUncommitted(st.worktreeDir, st.label || UNLABELED) : [];
1406
+ if ((parseInt(git(st.cwd, [
1407
+ "rev-list",
1408
+ "--count",
1409
+ `HEAD..${st.branch}`
1410
+ ]), 10) || 0) <= 0) throw new Error(`worker ${id}: branch ${st.branch} has no commits to merge. The worktree ${st.worktreeDir} was NOT removed — uncommitted work there would be destroyed. Commit it yourself, or drop everything with: pai worker discard ${id}`);
1411
+ const mergeBase = git(st.cwd, [
1412
+ "merge-base",
1413
+ "HEAD",
1414
+ st.branch
1919
1415
  ]);
1416
+ const incomingPaths = git(st.cwd, [
1417
+ "diff",
1418
+ "--name-only",
1419
+ mergeBase,
1420
+ st.branch
1421
+ ]).split("\n").filter(Boolean);
1422
+ assertNoDirtyOverlap(st.cwd, incomingPaths, id, st.branch);
1423
+ try {
1424
+ git(st.cwd, [
1425
+ "merge",
1426
+ "--no-ff",
1427
+ st.branch,
1428
+ "-m",
1429
+ `merge worker ${id} (${st.label})`
1430
+ ]);
1431
+ } catch (e) {
1432
+ throw new Error(`worker ${id}: git refused the merge of ${st.branch} — ${e.message}. The worktree ${st.worktreeDir} was kept: resolve the conflict, then re-run merge`);
1433
+ }
1434
+ if (existsSync(st.worktreeDir)) assertWorktreeClean(st.worktreeDir, id);
1920
1435
  removeWorktree(st.cwd, st.worktreeDir, false);
1436
+ let branchGone = true;
1437
+ try {
1438
+ git(st.cwd, [
1439
+ "branch",
1440
+ "-d",
1441
+ st.branch
1442
+ ]);
1443
+ } catch {
1444
+ branchGone = false;
1445
+ }
1921
1446
  saveStatus(logDir, {
1922
1447
  ...st,
1923
1448
  merged: true
1924
1449
  });
1925
- return `merged ${st.branch} into ${st.cwd} (worktree removed)`;
1450
+ const base = `merged ${st.branch} into ${st.cwd} (worktree removed${branchGone ? ", branch deleted" : "; branch kept: git refused -d"})`;
1451
+ return salvaged.length ? `${base}; salvaged ${salvaged.length} uncommitted change(s): ${salvaged.join(", ")}` : base;
1926
1452
  }
1927
1453
  /** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
1928
1454
  function discardWorker(logDir, id) {
@@ -1966,7 +1492,8 @@ function worktreeSystemPrompt(id, branch, dir) {
1966
1492
  ` ${dir} on branch ${branch} (worker id ${id}).`,
1967
1493
  "Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
1968
1494
  "the no-commit rule applies to the main branch only, and this is not it.",
1969
- "Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`."
1495
+ "Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`.",
1496
+ "Use ONLY relative paths inside the worktree, never absolute worktree paths — absolute paths break after merge and leak machine layout."
1970
1497
  ].join("\n");
1971
1498
  }
1972
1499
 
@@ -1991,6 +1518,8 @@ const WORKER_CONTRACT_PROMPT = [
1991
1518
  "changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
1992
1519
  "checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
1993
1520
  "notes: one line, the headline a reviewer reads first.",
1521
+ "Never write shell heredocs — write the script to a file with the Write tool, then run it",
1522
+ "(heredocs fail silently or get swallowed).",
1994
1523
  "",
1995
1524
  `Operator messages: a user turn starting with ${OPERATOR_MARK} was typed by the operator while you`,
1996
1525
  "run (the prompt of a resumed run is the operator's too). Answer it FIRST, in one or two plain lines —",
@@ -2153,101 +1682,6 @@ function describeMcp(config, claudeJson = CLAUDE_JSON) {
2153
1682
  return lines;
2154
1683
  }
2155
1684
 
2156
- //#endregion
2157
- //#region src/workers/proxy/server.ts
2158
- /**
2159
- * server.ts — the PAI worker proxy: Anthropic Messages API on the front,
2160
- * OpenAI Chat Completions on the back, loopback only.
2161
- *
2162
- * One proxy serves every OpenAI-protocol provider: Claude Code points
2163
- * ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
2164
- * name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
2165
- * workers config, re-read per request so config edits apply without a
2166
- * restart). `run` starts the proxy on demand (detached, pid file under the
2167
- * logDir) and `pai worker proxy stop` stops it again.
2168
- *
2169
- * All translation lives in translate.ts; this file is only HTTP plumbing.
2170
- */
2171
- const DEFAULT_PROXY_PORT = 8797;
2172
- const HOST = "127.0.0.1";
2173
- function proxyPidPath(logDir) {
2174
- return join(logDir, "proxy.pid");
2175
- }
2176
- /** True when something already accepts connections on the port. */
2177
- function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
2178
- return new Promise((resolve) => {
2179
- const sock = connect({
2180
- port,
2181
- host: HOST
2182
- });
2183
- const done = (ok) => {
2184
- sock.removeAllListeners();
2185
- sock.destroy();
2186
- resolve(ok);
2187
- };
2188
- sock.setTimeout(timeoutMs, () => done(false));
2189
- sock.once("connect", () => done(true));
2190
- sock.once("error", () => done(false));
2191
- });
2192
- }
2193
- /**
2194
- * Where the standalone proxy artefact lives, found from this module's own
2195
- * location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
2196
- */
2197
- function standaloneProxyPath() {
2198
- let dir = dirname(fileURLToPath(import.meta.url));
2199
- for (let i = 0; i < 6; i++) {
2200
- for (const cand of [
2201
- join(dir, "worker-proxy.mjs"),
2202
- join(dir, "hooks", "worker-proxy.mjs"),
2203
- join(dir, "dist", "hooks", "worker-proxy.mjs")
2204
- ]) if (existsSync(cand)) return cand;
2205
- const parent = dirname(dir);
2206
- if (parent === dir) break;
2207
- dir = parent;
2208
- }
2209
- return null;
2210
- }
2211
- /**
2212
- * Ensure a proxy is listening on the port; start the detached standalone when
2213
- * nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).
2214
- */
2215
- async function ensureProxyRunning(port, logDir) {
2216
- if (await proxyListening(port)) return `http://${HOST}:${port}`;
2217
- const script = standaloneProxyPath();
2218
- if (!script) throw new Error(`the PAI worker proxy is not running and its build was not found — run \`bun run build\` (or start one with: pai worker proxy --port ${port})`);
2219
- if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
2220
- const child = spawn(process.execPath, [
2221
- script,
2222
- "--port",
2223
- String(port)
2224
- ], {
2225
- detached: true,
2226
- stdio: "ignore"
2227
- });
2228
- child.unref();
2229
- writeFileSync(proxyPidPath(logDir), `${child.pid}\n`, "utf8");
2230
- for (let i = 0; i < 40; i++) {
2231
- if (await proxyListening(port)) return `http://${HOST}:${port}`;
2232
- await new Promise((r) => setTimeout(r, 250));
2233
- }
2234
- throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);
2235
- }
2236
- /** `pai worker proxy stop`: SIGTERM the pid from the pid file. */
2237
- function stopProxy(logDir) {
2238
- const path = proxyPidPath(logDir);
2239
- if (!existsSync(path)) return "no proxy pid file — nothing to stop";
2240
- const pid = parseInt(readFileSync(path, "utf8").trim(), 10);
2241
- unlinkSync(path);
2242
- if (!Number.isFinite(pid)) return "stale proxy pid file removed";
2243
- try {
2244
- process.kill(pid, "SIGTERM");
2245
- } catch {
2246
- return `proxy pid ${pid} was not running (pid file removed)`;
2247
- }
2248
- return `proxy pid ${pid} stopped`;
2249
- }
2250
-
2251
1685
  //#endregion
2252
1686
  //#region src/workers/codex.ts
2253
1687
  /**
@@ -2443,34 +1877,6 @@ function codexInstalled() {
2443
1877
  * Auto-routed runs that die of a quota error before the first tool call are
2444
1878
  * restarted on the next provider in routing order (WORKER-REROUTE ledger line).
2445
1879
  */
2446
- /** Environment for a run through `provider`. Caller's env minus the Anthropic key. */
2447
- function buildRunEnv(provider, headless, proxyUrl) {
2448
- const env = { ...process.env };
2449
- delete env.ANTHROPIC_API_KEY;
2450
- let token = "local";
2451
- if (proxyUrl) env.ANTHROPIC_BASE_URL = proxyUrl;
2452
- else {
2453
- const keyPath = providerKeyPath(provider);
2454
- if (keyPath) {
2455
- try {
2456
- token = readFileSync(keyPath, "utf8").trim();
2457
- } catch {
2458
- throw new Error(`key file not readable: ${keyPath}`);
2459
- }
2460
- if (!token) throw new Error(`key file is empty: ${keyPath}`);
2461
- }
2462
- env.ANTHROPIC_BASE_URL = provider.baseUrl;
2463
- }
2464
- env.ANTHROPIC_AUTH_TOKEN = token;
2465
- env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.models.fast ?? provider.models.default;
2466
- env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.models.default;
2467
- env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.models.default;
2468
- for (const [k, v] of Object.entries(provider.env)) env[k] = v;
2469
- env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
2470
- if (headless) env.PAI_WORKER = "1";
2471
- else env.ENABLE_TOOL_SEARCH = "true";
2472
- return env;
2473
- }
2474
1880
  /** The strict empty MCP config for headless workers, written on demand. */
2475
1881
  function ensureNoMcpConfig(logDir) {
2476
1882
  const path = noMcpConfigPath(logDir);
@@ -2514,10 +1920,35 @@ function usageContextTokens(u) {
2514
1920
  const t = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0);
2515
1921
  return t > 0 ? t : null;
2516
1922
  }
1923
+ /**
1924
+ * Context never shrinks mid-segment, so the status keeps the high-water
1925
+ * mark of the usage it has seen: a smaller later reading (short reply,
1926
+ * sidechain answer) must not drag the meter down. Compaction is the one
1927
+ * legitimate drop — see `resetContextTokensOnCompact`.
1928
+ */
1929
+ function bumpContextTokens(status, tokens) {
1930
+ if (tokens === null || tokens <= 0) return;
1931
+ status.contextTokens = Math.max(status.contextTokens ?? 0, tokens);
1932
+ }
1933
+ /**
1934
+ * A compact boundary (`system`/`compact_boundary`, the shape Claude Code
1935
+ * writes with `compactMetadata.preTokens`; `compact` kept as the older
1936
+ * spelling) legitimately restarts the context at a lower size: the floor
1937
+ * drops to the event's own usage — usually none — so the next usage
1938
+ * reading re-seeds the meter at the fresh, smaller context.
1939
+ */
1940
+ function resetContextTokensOnCompact(status, e) {
1941
+ status.contextTokens = usageContextTokens(e.usage) ?? null;
1942
+ }
1943
+ /** A compact boundary in a worker's stream, in either event spelling. */
1944
+ function isCompactBoundary(e) {
1945
+ return e.type === "system" && (e.subtype === "compact_boundary" || e.subtype === "compact");
1946
+ }
2517
1947
  /** Context window announced by the init event, when the endpoint sends one. */
2518
1948
  function initContextWindow(e) {
2519
1949
  if (typeof e.context_window === "number" && e.context_window > 0) return e.context_window;
2520
1950
  if (e.model_info && typeof e.model_info.context_window === "number" && e.model_info.context_window > 0) return e.model_info.context_window;
1951
+ if (/\[1m\]$/.test((e.model ?? "").trim())) return 1e6;
2521
1952
  return null;
2522
1953
  }
2523
1954
  /**
@@ -2530,7 +1961,7 @@ async function runWorker(opts) {
2530
1961
  const logDir = workersLogDir(config);
2531
1962
  mkdirSync(logDir, { recursive: true });
2532
1963
  if (opts.className === "plan" && !opts._planner) {
2533
- const { runPlanner } = await import("./planner-DGV8VFib.mjs");
1964
+ const { runPlanner } = await import("./planner-Cm3g6fWH.mjs");
2534
1965
  return runPlanner(opts);
2535
1966
  }
2536
1967
  const parent = launchParent(opts.parent);
@@ -2541,8 +1972,10 @@ async function runWorker(opts) {
2541
1972
  });
2542
1973
  assertProviderRunnable(target.providerName, target.provider);
2543
1974
  const parsed = parseRunnerArgs(opts.claudeArgs);
2544
- const label = opts.label ?? shortText(parsed.prompt ?? "(no prompt)", 70);
2545
- const model = opts.modelFlag ?? (target.modelAlias === "fast" ? target.provider.models.fast ?? target.provider.models.default : target.provider.models.default);
1975
+ const label = opts.label ?? shortText(parsed.prompt ?? UNLABELED, 70);
1976
+ const alias = target.modelAlias;
1977
+ const capability = alias && isModelCapability(alias) ? alias : classModelCapability(opts.className);
1978
+ const model = opts.modelFlag ?? resolveModelCapability(target.provider, capability);
2546
1979
  try {
2547
1980
  if (target.provider.engine === "codex") return await executeCodexRun({
2548
1981
  config,
@@ -2597,6 +2030,7 @@ async function executeRun(a) {
2597
2030
  const cwd = a.cwd ?? process.cwd();
2598
2031
  const term = process.env.ITERM_SESSION_ID ?? "";
2599
2032
  const session = resolveSession(term);
2033
+ const spawnerSession = resolveSpawnerSession(logDir, cwd);
2600
2034
  let worktree = null;
2601
2035
  if (headless && worktreeWanted(a.worktreeFlag, {
2602
2036
  cwd,
@@ -2629,8 +2063,9 @@ async function executeRun(a) {
2629
2063
  last: headless ? "starting" : "interactive",
2630
2064
  rc: null,
2631
2065
  secs: null,
2066
+ origin: headless ? "spawn" : "chat",
2632
2067
  ...session ? { session } : {},
2633
- contextWindow: providerContextWindow(target.provider),
2068
+ ...spawnerSession ? { spawnerSession } : {},
2634
2069
  ...a.parent ? {
2635
2070
  parent: a.parent,
2636
2071
  stage: a.stage
@@ -2779,10 +2214,12 @@ async function executeRun(a) {
2779
2214
  const cw = initContextWindow(e);
2780
2215
  if (cw) status.contextWindow = cw;
2781
2216
  saveStatus(logDir, status);
2217
+ } else if (isCompactBoundary(e)) {
2218
+ resetContextTokensOnCompact(status, e);
2219
+ saveStatus(logDir, status);
2782
2220
  } else if (e.type === "assistant") {
2783
2221
  status.turns += 1;
2784
- const tokens = usageContextTokens(e.message?.usage);
2785
- if (tokens) status.contextTokens = tokens;
2222
+ bumpContextTokens(status, usageContextTokens(e.message?.usage));
2786
2223
  for (const block of e.message?.content ?? []) if (block.type === "tool_use") {
2787
2224
  status.tools += 1;
2788
2225
  status.last = describeTool(block.name ?? "?", block.input);
@@ -2790,7 +2227,8 @@ async function executeRun(a) {
2790
2227
  saveStatus(logDir, status);
2791
2228
  } else if (e.type === "result") {
2792
2229
  const tokens = usageContextTokens(e.usage);
2793
- if (tokens) status.contextTokens = tokens;
2230
+ if (e.is_compact) status.contextTokens = tokens ?? status.contextTokens;
2231
+ else bumpContextTokens(status, tokens);
2794
2232
  const report = parseWorkerReport(e.result ?? "");
2795
2233
  if (report?.notes) status.last = shortText(report.notes, 90);
2796
2234
  else if (e.result) status.last = shortText(e.result, 90);
@@ -2891,6 +2329,7 @@ async function executeCodexRun(a) {
2891
2329
  const cwd = a.cwd ?? process.cwd();
2892
2330
  const term = process.env.ITERM_SESSION_ID ?? "";
2893
2331
  const session = resolveSession(term);
2332
+ const spawnerSession = resolveSpawnerSession(logDir, cwd);
2894
2333
  let worktree = null;
2895
2334
  if (worktreeWanted(a.worktreeFlag, {
2896
2335
  cwd,
@@ -2924,8 +2363,10 @@ async function executeCodexRun(a) {
2924
2363
  last: "starting",
2925
2364
  rc: null,
2926
2365
  secs: null,
2366
+ origin: "spawn",
2927
2367
  ...session ? { session } : {},
2928
- contextWindow: providerContextWindow(target.provider),
2368
+ ...spawnerSession ? { spawnerSession } : {},
2369
+ ...target.provider.contextWindow ? { contextWindow: target.provider.contextWindow } : {},
2929
2370
  ...a.parent ? {
2930
2371
  parent: a.parent,
2931
2372
  stage: a.stage
@@ -2978,7 +2419,8 @@ async function executeCodexRun(a) {
2978
2419
  type: "system",
2979
2420
  subtype: "init",
2980
2421
  model,
2981
- cwd
2422
+ cwd,
2423
+ ...target.provider.contextWindow ? { context_window: target.provider.contextWindow } : {}
2982
2424
  });
2983
2425
  let killed = false;
2984
2426
  process.once("SIGTERM", onCodexSignal("SIGTERM"));
@@ -3006,7 +2448,7 @@ async function executeCodexRun(a) {
3006
2448
  status.turns = fold.turns;
3007
2449
  status.tools = fold.tools;
3008
2450
  if (fold.last) status.last = shortText(fold.last, 90);
3009
- if (fold.contextTokens) status.contextTokens = fold.contextTokens;
2451
+ bumpContextTokens(status, fold.contextTokens);
3010
2452
  saveStatus(logDir, status);
3011
2453
  for (const ev of fold.events.splice(0)) writeEvent(ev);
3012
2454
  });
@@ -3351,5 +2793,5 @@ async function runChain(opts, deps = {}) {
3351
2793
  }
3352
2794
 
3353
2795
  //#endregion
3354
- export { writeJsonAtomic as $, alive as A, ledgerPath as B, quotaSkipThreshold as C, sessionTag as D, resolveSession as E, appendLedger as F, expandHome as G, DEFAULT_LOG_DIR as H, ledgerSummary as I, providerCostTier as J, keysDir as K, parseRunnerArgs as L, loadStatus as M, loadStatuses as N, workerInScope as O, newWorkerId as P, readJsonStrict as Q, shortText as R, probeQuota as S, currentTabKey as T, PROVIDER_TAGS as U, workersLogDir as V, WorkersConfigError as W, readWorkersSection as X, providerKeyPath as Y, writeWorkersSection as Z, workerDepth as _, testProvider as a, openPaneForWorker as b, stopProxy as c, renderReport as d, discardWorker as f, sayToWorker as g, readInbox as h, runWorker as i, contextPercent as j, ageOf as k, describeMcp as l, handoffFromInside as m, swapPromptArg as n, DEFAULT_PROXY_PORT as o, mergeWorker as p, parseWorkersConfig as q, printResult as r, ensureProxyRunning as s, runChain as t, parseWorkerReport as u, checkPaneForWorker as v, resolveTarget as w, clearCooldown as x, openFollowPane as y, eventsPath as z };
3355
- //# sourceMappingURL=chain-CqlgL7uH.mjs.map
2796
+ export { appendLedger as A, ageOf as C, loadStatus as D, contextPercent as E, parseRunnerArgs as M, shortText as N, loadStatuses as O, UNLABELED as S, contextLabel as T, openPaneForWorker as _, testProvider as a, sessionTag as b, renderReport as c, handoffFromInside as d, readInbox as f, openFollowPane as g, checkPaneForWorker as h, runWorker as i, ledgerSummary as j, newWorkerId as k, discardWorker as l, workerDepth as m, swapPromptArg as n, describeMcp as o, sayToWorker as p, printResult as r, parseWorkerReport as s, runChain as t, mergeWorker as u, currentTabKey as v, alive as w, workerInScope as x, resolveSession as y };
2797
+ //# sourceMappingURL=chain-DhVHVnmT.mjs.map