@tekmidian/pai 0.39.0 → 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.
- package/README.md +41 -0
- package/dist/{auto-route-BlWOWT4z.mjs → auto-route-BwzmlW3W.mjs} +2 -2
- package/dist/{auto-route-BlWOWT4z.mjs.map → auto-route-BwzmlW3W.mjs.map} +1 -1
- package/dist/{chain-CTtHligI.mjs → chain-DhVHVnmT.mjs} +263 -791
- package/dist/chain-DhVHVnmT.mjs.map +1 -0
- package/dist/cli/index.mjs +10 -8
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +10 -8
- package/dist/{clusters-wlK0w41E.mjs → clusters-DRHhfli9.mjs} +1 -1
- package/dist/{clusters-wlK0w41E.mjs.map → clusters-DRHhfli9.mjs.map} +1 -1
- package/dist/{config-B64vFg14.mjs → config-D1G9IFpn.mjs} +50 -3
- package/dist/config-D1G9IFpn.mjs.map +1 -0
- package/dist/{config-C_ErGddD.mjs → config-DD52BPB-.mjs} +1 -1
- package/dist/config-Dl8lT4Lu.mjs +527 -0
- package/dist/config-Dl8lT4Lu.mjs.map +1 -0
- package/dist/{context-handover-cache-BpUojjsi.mjs → context-handover-cache-C6_rs7JT.mjs} +112 -24
- package/dist/context-handover-cache-C6_rs7JT.mjs.map +1 -0
- package/dist/daemon/index.mjs +12 -10
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/daemon-BSL9sXxm.mjs +22 -0
- package/dist/{daemon-CTkN_Vn8.mjs → daemon-CpdVUnl_.mjs} +26 -26
- package/dist/{daemon-CTkN_Vn8.mjs.map → daemon-CpdVUnl_.mjs.map} +1 -1
- package/dist/daemon-mcp/index.mjs +127 -6
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/detector-BLVOCiJ_.mjs +5 -0
- package/dist/{detector-Bwk_4Pk4.mjs → detector-smpCm7ku.mjs} +1 -1
- package/dist/{detector-Bwk_4Pk4.mjs.map → detector-smpCm7ku.mjs.map} +1 -1
- package/dist/{factory-A9x-T2Kg.mjs → factory-Bc5EyN54.mjs} +7 -7
- package/dist/{factory-A9x-T2Kg.mjs.map → factory-Bc5EyN54.mjs.map} +1 -1
- package/dist/factory-C7ua_3ik.mjs +3 -0
- package/dist/{providers-FYkZjn_C.mjs → fallback-CdXv-Np0.mjs} +411 -51
- package/dist/fallback-CdXv-Np0.mjs.map +1 -0
- package/dist/hooks/context-compression-hook.mjs +136 -22
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/post-compact-inject.mjs +98 -7
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +75 -6
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs +75 -6
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +149 -90
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/{indexer-backend-TG64CCQC.mjs → indexer-backend-rohVVKrA.mjs} +1 -1
- package/dist/{indexer-backend-TG64CCQC.mjs.map → indexer-backend-rohVVKrA.mjs.map} +1 -1
- package/dist/{ipc-client-BmypMNYk.mjs → ipc-client-C7AVpsyv.mjs} +2 -2
- package/dist/{ipc-client-BmypMNYk.mjs.map → ipc-client-C7AVpsyv.mjs.map} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
- package/dist/{latent-ideas-B7wq75Pt.mjs → latent-ideas-iNp5s55R.mjs} +2 -2
- package/dist/{latent-ideas-B7wq75Pt.mjs.map → latent-ideas-iNp5s55R.mjs.map} +1 -1
- package/dist/{link-boost-HkG7JWZR.mjs → link-boost-NNzADHX_.mjs} +1 -1
- package/dist/{link-boost-HkG7JWZR.mjs.map → link-boost-NNzADHX_.mjs.map} +1 -1
- package/dist/{main-resolver-DlaLOFBA.mjs → main-resolver-CM1IHbuu.mjs} +78 -75
- package/dist/main-resolver-CM1IHbuu.mjs.map +1 -0
- package/dist/main-resolver-E5WlilhM.mjs +6 -0
- package/dist/{neighborhood-lThN-MaQ.mjs → neighborhood-AGL7oXQ2.mjs} +1 -1
- package/dist/{neighborhood-lThN-MaQ.mjs.map → neighborhood-AGL7oXQ2.mjs.map} +1 -1
- package/dist/{note-context-b6k0mAKi.mjs → note-context-BHwYpSFf.mjs} +1 -1
- package/dist/{note-context-b6k0mAKi.mjs.map → note-context-BHwYpSFf.mjs.map} +1 -1
- package/dist/{planner-BDI7bE9B.mjs → planner-Cm3g6fWH.mjs} +5 -3
- package/dist/{planner-BDI7bE9B.mjs.map → planner-Cm3g6fWH.mjs.map} +1 -1
- package/dist/{postgres-mW1n7Vi1.mjs → postgres-Dwxe290H.mjs} +1 -1
- package/dist/{postgres-mW1n7Vi1.mjs.map → postgres-Dwxe290H.mjs.map} +1 -1
- package/dist/{program-DXBwJV7h.mjs → program-AR0qlYRg.mjs} +334 -78
- package/dist/program-AR0qlYRg.mjs.map +1 -0
- package/dist/query-feedback-CGcEiGtY.mjs +3 -0
- package/dist/{query-feedback-BV4CcxqS.mjs → query-feedback-DZfjI34w.mjs} +1 -1
- package/dist/{query-feedback-BV4CcxqS.mjs.map → query-feedback-DZfjI34w.mjs.map} +1 -1
- package/dist/router-CAtyVQud.mjs +3 -0
- package/dist/{router-DcHKnEPa.mjs → router-DHracTUC.mjs} +1 -1
- package/dist/{router-DcHKnEPa.mjs.map → router-DHracTUC.mjs.map} +1 -1
- package/dist/server-BOuAOj9b.mjs +386 -0
- package/dist/server-BOuAOj9b.mjs.map +1 -0
- package/dist/skills/Worker/SKILL.md +19 -1
- package/dist/{sources-kLnQsNrW.mjs → sources-ju6AA7lL.mjs} +1 -1
- package/dist/{sources-kLnQsNrW.mjs.map → sources-ju6AA7lL.mjs.map} +1 -1
- package/dist/{sqlite-BenGr3UP.mjs → sqlite-kFZeWCJ2.mjs} +1 -1
- package/dist/{sqlite-BenGr3UP.mjs.map → sqlite-kFZeWCJ2.mjs.map} +1 -1
- package/dist/{state-Ca9F_sZc.mjs → state-DH_mp5Xe.mjs} +1 -1
- package/dist/{state-CAeyOdfq.mjs → state-ahFAbD8b.mjs} +1 -1
- package/dist/{state-CAeyOdfq.mjs.map → state-ahFAbD8b.mjs.map} +1 -1
- package/dist/{themes-BI4GMRP9.mjs → themes-DqhNUUQk.mjs} +1 -1
- package/dist/{themes-BI4GMRP9.mjs.map → themes-DqhNUUQk.mjs.map} +1 -1
- package/dist/{tools-DGcB3o_S.mjs → tools-CMlHa9lZ.mjs} +14 -14
- package/dist/{tools-DGcB3o_S.mjs.map → tools-CMlHa9lZ.mjs.map} +1 -1
- package/dist/{tools-Bp7hj6OW.mjs → tools-COna9vZR.mjs} +1 -1
- package/dist/{trace-bobARFEX.mjs → trace-DTP6k4B_.mjs} +1 -1
- package/dist/{trace-bobARFEX.mjs.map → trace-DTP6k4B_.mjs.map} +1 -1
- package/dist/{vault-indexer-C3OfXTNF.mjs → vault-indexer-YMH22mLU.mjs} +1 -1
- package/dist/{vault-indexer-C3OfXTNF.mjs.map → vault-indexer-YMH22mLU.mjs.map} +1 -1
- package/dist/{work-queue-worker-R7UGIag_.mjs → work-queue-worker-B3nl8yyn.mjs} +23 -26
- package/dist/work-queue-worker-B3nl8yyn.mjs.map +1 -0
- package/dist/work-queue-worker-BM1GcAAq.mjs +14 -0
- package/dist/{zettelkasten-m5QPtb-L.mjs → zettelkasten-DDvKRm2V.mjs} +3 -3
- package/dist/{zettelkasten-m5QPtb-L.mjs.map → zettelkasten-DDvKRm2V.mjs.map} +1 -1
- package/docs/commands/README.md +5 -2
- package/docs/commands/worker.md +66 -5
- package/docs/images/provider-abstraction-live.png +0 -0
- package/docs/images/workers.png +0 -0
- package/docs/launcher-independence.md +70 -0
- package/docs/provider-abstraction.md +87 -0
- package/docs/provider-independence-details.md +330 -0
- package/docs/provider-independence.md +75 -0
- package/docs/worker.md +72 -11
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +6 -1
- package/scripts/build-skill-stubs.mjs +17 -4
- package/src/hooks/ts/lib/context-fill.test.ts +158 -4
- package/src/hooks/ts/lib/context-fill.ts +102 -35
- package/statusline-command.sh +120 -21
- package/dist/chain-CTtHligI.mjs.map +0 -1
- package/dist/config-B64vFg14.mjs.map +0 -1
- package/dist/context-handover-cache-BpUojjsi.mjs.map +0 -1
- package/dist/daemon-CmHHmKpg.mjs +0 -20
- package/dist/detector-DO730Zq0.mjs +0 -5
- package/dist/factory-BD-np0Vd.mjs +0 -3
- package/dist/main-resolver-D6IImXvF.mjs +0 -4
- package/dist/main-resolver-DlaLOFBA.mjs.map +0 -1
- package/dist/program-DXBwJV7h.mjs.map +0 -1
- package/dist/providers-FYkZjn_C.mjs.map +0 -1
- package/dist/query-feedback-DSVyHtrG.mjs +0 -3
- package/dist/router-Bk77E7hj.mjs +0 -3
- package/dist/runtime-paths-rni52zHX.mjs +0 -50
- package/dist/runtime-paths-rni52zHX.mjs.map +0 -1
- package/dist/work-queue-worker-DW8lz-Oo.mjs +0 -11
- package/dist/work-queue-worker-R7UGIag_.mjs.map +0 -1
|
@@ -1,448 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
};
|
|
167
|
-
}
|
|
168
|
-
var WorkersConfigError = class extends Error {};
|
|
169
|
-
function bad(path, why) {
|
|
170
|
-
throw new WorkersConfigError(`workers${path}: ${why}`);
|
|
171
|
-
}
|
|
172
|
-
function str(v) {
|
|
173
|
-
return typeof v === "string" ? v : "";
|
|
174
|
-
}
|
|
175
|
-
function parseProvider(name, raw) {
|
|
176
|
-
if (typeof raw !== "object" || raw === null) bad(`.providers.${name}`, "must be an object");
|
|
177
|
-
const p = raw;
|
|
178
|
-
const protocol = p.protocol === void 0 ? "anthropic" : str(p.protocol);
|
|
179
|
-
if (protocol !== "anthropic" && protocol !== "openai") bad(`.providers.${name}.protocol`, `"${str(p.protocol)}" is neither "anthropic" nor "openai"`);
|
|
180
|
-
const engine = p.engine === void 0 ? "claude" : str(p.engine);
|
|
181
|
-
if (engine !== "claude" && engine !== "codex") bad(`.providers.${name}.engine`, `"${str(p.engine)}" is neither "claude" nor "codex"`);
|
|
182
|
-
const baseUrl = str(p.baseUrl);
|
|
183
|
-
if (!baseUrl && protocol !== "openai") bad(`.providers.${name}.baseUrl`, "is required");
|
|
184
|
-
const keyFile = p.keyFile === void 0 || p.keyFile === null || str(p.keyFile) === "" ? null : str(p.keyFile);
|
|
185
|
-
const modelsRaw = p.models === void 0 ? {} : p.models;
|
|
186
|
-
if (typeof modelsRaw !== "object" || modelsRaw === null) bad(`.providers.${name}.models`, "must be an object");
|
|
187
|
-
const m = modelsRaw;
|
|
188
|
-
const defaultModel = str(m.default);
|
|
189
|
-
if (!defaultModel) bad(`.providers.${name}.models.default`, "is required");
|
|
190
|
-
const fast = m.fast === void 0 ? void 0 : str(m.fast);
|
|
191
|
-
const env = {};
|
|
192
|
-
if (p.env !== void 0) {
|
|
193
|
-
if (typeof p.env !== "object" || p.env === null || Array.isArray(p.env)) bad(`.providers.${name}.env`, "must be an object of string values");
|
|
194
|
-
for (const [k, v] of Object.entries(p.env)) {
|
|
195
|
-
if (typeof v !== "string") bad(`.providers.${name}.env.${k}`, "must be a string");
|
|
196
|
-
env[k] = v;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
const quotaSkipAt = p.quotaSkipAt === void 0 ? void 0 : p.quotaSkipAt;
|
|
200
|
-
if (quotaSkipAt !== void 0) {
|
|
201
|
-
if (typeof quotaSkipAt !== "number" || quotaSkipAt < 0 || quotaSkipAt > 100) bad(`.providers.${name}.quotaSkipAt`, "must be a number between 0 and 100");
|
|
202
|
-
}
|
|
203
|
-
const contextWindow = p.contextWindow === void 0 ? void 0 : p.contextWindow;
|
|
204
|
-
if (contextWindow !== void 0) {
|
|
205
|
-
if (typeof contextWindow !== "number" || contextWindow <= 0) bad(`.providers.${name}.contextWindow`, "must be a positive number of tokens");
|
|
206
|
-
}
|
|
207
|
-
const upstreamUrl = str(p.upstreamUrl);
|
|
208
|
-
if (protocol === "openai" && !upstreamUrl) bad(`.providers.${name}.upstreamUrl`, `is required for protocol "openai" (the Chat Completions base, e.g. "https://api.openai.com/v1")`);
|
|
209
|
-
const costTier = p.costTier === void 0 ? void 0 : p.costTier;
|
|
210
|
-
if (costTier !== void 0) {
|
|
211
|
-
if (typeof costTier !== "number" || !Number.isInteger(costTier) || costTier < 1 || costTier > 5) bad(`.providers.${name}.costTier`, "must be an integer 1 (cheapest) … 5 (most expensive)");
|
|
212
|
-
}
|
|
213
|
-
let tags;
|
|
214
|
-
if (p.tags !== void 0) {
|
|
215
|
-
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(", ")}`);
|
|
216
|
-
for (const t of p.tags) if (!PROVIDER_TAGS.includes(t)) bad(`.providers.${name}.tags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
|
|
217
|
-
tags = p.tags;
|
|
218
|
-
}
|
|
219
|
-
return {
|
|
220
|
-
enabled: p.enabled === void 0 ? true : p.enabled === true,
|
|
221
|
-
protocol,
|
|
222
|
-
baseUrl,
|
|
223
|
-
keyFile,
|
|
224
|
-
models: fast ? {
|
|
225
|
-
default: defaultModel,
|
|
226
|
-
fast
|
|
227
|
-
} : { default: defaultModel },
|
|
228
|
-
env,
|
|
229
|
-
...str(p.note) ? { note: str(p.note) } : {},
|
|
230
|
-
...upstreamUrl ? { upstreamUrl } : {},
|
|
231
|
-
...engine !== "claude" ? { engine } : {},
|
|
232
|
-
...str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {},
|
|
233
|
-
...quotaSkipAt !== void 0 ? { quotaSkipAt } : {},
|
|
234
|
-
...contextWindow !== void 0 ? { contextWindow } : {},
|
|
235
|
-
...costTier !== void 0 ? { costTier } : {},
|
|
236
|
-
...tags ? { tags } : {}
|
|
237
|
-
};
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* Parse and validate a raw `workers` value. Missing section → defaults.
|
|
241
|
-
* Unknown-but-typed garbage → WorkersConfigError naming the offending field.
|
|
242
|
-
*/
|
|
243
|
-
function parseWorkersConfig(raw) {
|
|
244
|
-
const d = defaultWorkersConfig();
|
|
245
|
-
if (raw === void 0 || raw === null) return d;
|
|
246
|
-
if (typeof raw !== "object" || Array.isArray(raw)) bad("", "section must be an object");
|
|
247
|
-
const w = raw;
|
|
248
|
-
const providers = {};
|
|
249
|
-
if (w.providers !== void 0) {
|
|
250
|
-
if (typeof w.providers !== "object" || w.providers === null || Array.isArray(w.providers)) bad(".providers", "must be an object keyed by provider name");
|
|
251
|
-
for (const [name, p] of Object.entries(w.providers)) providers[name] = parseProvider(name, p);
|
|
252
|
-
}
|
|
253
|
-
const classes = {};
|
|
254
|
-
const classesRaw = w.classes !== void 0 ? w.classes : w.roles;
|
|
255
|
-
if (classesRaw !== void 0) {
|
|
256
|
-
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}");
|
|
257
|
-
for (const [cls, target] of Object.entries(classesRaw)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
|
|
258
|
-
const o = target;
|
|
259
|
-
const provider = str(o.provider);
|
|
260
|
-
if (o.provider !== void 0 && (!provider || provider.includes(" "))) bad(`.classes.${cls}.provider`, `invalid provider "${provider}"`);
|
|
261
|
-
let mcp;
|
|
262
|
-
if (o.mcp !== void 0) {
|
|
263
|
-
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");
|
|
264
|
-
mcp = o.mcp;
|
|
265
|
-
}
|
|
266
|
-
let maxCostTier;
|
|
267
|
-
if (o.maxCostTier !== void 0) {
|
|
268
|
-
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");
|
|
269
|
-
maxCostTier = o.maxCostTier;
|
|
270
|
-
}
|
|
271
|
-
let requireTags;
|
|
272
|
-
if (o.requireTags !== void 0) {
|
|
273
|
-
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(", ")}`);
|
|
274
|
-
for (const t of o.requireTags) if (!PROVIDER_TAGS.includes(t)) bad(`.classes.${cls}.requireTags`, `"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
|
|
275
|
-
requireTags = o.requireTags;
|
|
276
|
-
}
|
|
277
|
-
let order;
|
|
278
|
-
if (o.order !== void 0) {
|
|
279
|
-
if (!Array.isArray(o.order) || o.order.some((x) => typeof x !== "string")) bad(`.classes.${cls}.order`, "must be an array of provider names");
|
|
280
|
-
order = o.order;
|
|
281
|
-
}
|
|
282
|
-
const obj = {
|
|
283
|
-
...provider ? { provider } : {},
|
|
284
|
-
...mcp ? { mcp } : {},
|
|
285
|
-
...maxCostTier !== void 0 ? { maxCostTier } : {},
|
|
286
|
-
...requireTags ? { requireTags } : {},
|
|
287
|
-
...order ? { order } : {}
|
|
288
|
-
};
|
|
289
|
-
classes[cls] = Object.keys(obj).length ? obj : {};
|
|
290
|
-
} else {
|
|
291
|
-
const t = str(target);
|
|
292
|
-
if (!t || t.includes(" ")) bad(`.classes.${cls}`, `invalid target "${t}"`);
|
|
293
|
-
classes[cls] = t;
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
const mcpSets = { ...DEFAULT_MCP_SETS };
|
|
297
|
-
if (w.mcpSets !== void 0) {
|
|
298
|
-
if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
|
|
299
|
-
for (const [setName, servers] of Object.entries(w.mcpSets)) {
|
|
300
|
-
if (!Array.isArray(servers) || servers.some((x) => typeof x !== "string")) bad(`.mcpSets.${setName}`, "must be an array of MCP server names");
|
|
301
|
-
mcpSets[setName] = servers;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
let pane = { ...DEFAULT_PANE };
|
|
305
|
-
if (w.pane !== void 0) {
|
|
306
|
-
if (typeof w.pane !== "object" || w.pane === null) bad(".pane", "must be an object");
|
|
307
|
-
const pc = w.pane;
|
|
308
|
-
if (pc.enabled !== void 0 && typeof pc.enabled !== "boolean") bad(".pane.enabled", "must be boolean");
|
|
309
|
-
if (pc.fontSize !== void 0 && (typeof pc.fontSize !== "number" || pc.fontSize <= 0)) bad(".pane.fontSize", "must be a positive number of points");
|
|
310
|
-
if (pc.autoExitSecs !== void 0 && typeof pc.autoExitSecs !== "number") bad(".pane.autoExitSecs", "must be a number");
|
|
311
|
-
pane = {
|
|
312
|
-
enabled: pc.enabled === void 0 ? DEFAULT_PANE.enabled : pc.enabled === true,
|
|
313
|
-
fontSize: pc.fontSize === void 0 ? DEFAULT_PANE.fontSize : pc.fontSize,
|
|
314
|
-
autoExitSecs: pc.autoExitSecs === void 0 ? DEFAULT_PANE.autoExitSecs : pc.autoExitSecs
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
let routing = {
|
|
318
|
-
...DEFAULT_ROUTING,
|
|
319
|
-
order: []
|
|
320
|
-
};
|
|
321
|
-
if (w.routing !== void 0) {
|
|
322
|
-
if (typeof w.routing !== "object" || w.routing === null) bad(".routing", "must be an object");
|
|
323
|
-
const r = w.routing;
|
|
324
|
-
if (r.order !== void 0) {
|
|
325
|
-
if (!Array.isArray(r.order) || r.order.some((x) => typeof x !== "string")) bad(".routing.order", "must be an array of provider names");
|
|
326
|
-
routing.order = r.order;
|
|
327
|
-
}
|
|
328
|
-
if (r.cooldownMinutes !== void 0 && typeof r.cooldownMinutes !== "number") bad(".routing.cooldownMinutes", "must be a number");
|
|
329
|
-
if (r.retryOnQuota !== void 0 && typeof r.retryOnQuota !== "boolean") bad(".routing.retryOnQuota", "must be boolean");
|
|
330
|
-
routing = {
|
|
331
|
-
order: routing.order,
|
|
332
|
-
cooldownMinutes: r.cooldownMinutes === void 0 ? DEFAULT_ROUTING.cooldownMinutes : r.cooldownMinutes,
|
|
333
|
-
retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
let tree = { ...DEFAULT_TREE };
|
|
337
|
-
if (w.tree !== void 0) {
|
|
338
|
-
if (typeof w.tree !== "object" || w.tree === null || Array.isArray(w.tree)) bad(".tree", "must be an object");
|
|
339
|
-
const t = w.tree;
|
|
340
|
-
if (t.maxDepth !== void 0) {
|
|
341
|
-
if (typeof t.maxDepth !== "number" || !Number.isInteger(t.maxDepth) || t.maxDepth < 0) bad(".tree.maxDepth", "must be a non-negative integer");
|
|
342
|
-
}
|
|
343
|
-
if (t.maxChildren !== void 0) {
|
|
344
|
-
if (typeof t.maxChildren !== "number" || !Number.isInteger(t.maxChildren) || t.maxChildren < 1) bad(".tree.maxChildren", "must be a positive integer");
|
|
345
|
-
}
|
|
346
|
-
tree = {
|
|
347
|
-
maxDepth: t.maxDepth === void 0 ? DEFAULT_TREE.maxDepth : t.maxDepth,
|
|
348
|
-
maxChildren: t.maxChildren === void 0 ? DEFAULT_TREE.maxChildren : t.maxChildren
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
const active = w.active === void 0 || w.active === null ? null : str(w.active);
|
|
352
|
-
if (active !== null && active !== "auto" && !(active in providers)) {}
|
|
353
|
-
return {
|
|
354
|
-
enabled: w.enabled === void 0 ? d.enabled : w.enabled === true,
|
|
355
|
-
active,
|
|
356
|
-
providers,
|
|
357
|
-
classes,
|
|
358
|
-
mcpSets,
|
|
359
|
-
pane,
|
|
360
|
-
logDir: str(w.logDir) || d.logDir,
|
|
361
|
-
routing,
|
|
362
|
-
tree
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
/**
|
|
366
|
-
* Read the whole config file and return (raw, workers) — the raw record so
|
|
367
|
-
* callers can rewrite it preserving every other section, the parsed+validated
|
|
368
|
-
* workers section. Unreadable config throws (readJsonStrict), missing is fine.
|
|
369
|
-
*/
|
|
370
|
-
function readWorkersSection() {
|
|
371
|
-
const raw = readJsonStrict(CONFIG_FILE, "~/.config/pai/config.json");
|
|
372
|
-
return {
|
|
373
|
-
raw,
|
|
374
|
-
workers: parseWorkersConfig(raw.workers)
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
/** Write the workers section back into the config file, atomically. */
|
|
378
|
-
function writeWorkersSection(raw, workers) {
|
|
379
|
-
raw.workers = workers;
|
|
380
|
-
writeJsonAtomic(CONFIG_FILE, raw, { label: "~/.config/pai/config.json" });
|
|
381
|
-
}
|
|
382
|
-
/** Expand a leading ~ (config values are written with `~` to stay portable). */
|
|
383
|
-
function expandHome(p) {
|
|
384
|
-
if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1));
|
|
385
|
-
return p;
|
|
386
|
-
}
|
|
387
|
-
function assertProviderRunnable(name, p) {
|
|
388
|
-
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.`);
|
|
389
|
-
}
|
|
390
|
-
/** Where a provider's key file lives, or null. Shared by run + test + add. */
|
|
391
|
-
function providerKeyPath(p) {
|
|
392
|
-
return p.keyFile ? expandHome(p.keyFile) : null;
|
|
393
|
-
}
|
|
394
|
-
const DEFAULT_CONTEXT_WINDOW = 2e5;
|
|
395
|
-
/** Cost tier of a provider for class filtering (unset = 3, the middle). */
|
|
396
|
-
function providerCostTier(p) {
|
|
397
|
-
return p.costTier ?? DEFAULT_COST_TIER;
|
|
398
|
-
}
|
|
399
|
-
/** Context window used by the meter when the init event carries none. */
|
|
400
|
-
function providerContextWindow(p) {
|
|
401
|
-
return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
402
|
-
}
|
|
403
|
-
/** Directory under which inline keys (MCP `key` field) are stored, 0600. */
|
|
404
|
-
function keysDir() {
|
|
405
|
-
return join(homedir(), ".config", "pai", "keys");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
//#endregion
|
|
409
|
-
//#region src/workers/paths.ts
|
|
410
|
-
/**
|
|
411
|
-
* paths.ts — where worker run artefacts live.
|
|
412
|
-
*
|
|
413
|
-
* Everything a run writes (event mirror, status file, pane registry, routing
|
|
414
|
-
* state, the empty MCP config) sits under one logDir so the whole tree is
|
|
415
|
-
* disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.
|
|
416
|
-
*/
|
|
417
|
-
/** Absolute logDir for the given config. */
|
|
418
|
-
function workersLogDir(config) {
|
|
419
|
-
return expandHome(config.logDir);
|
|
420
|
-
}
|
|
421
|
-
function statusPath(logDir, id) {
|
|
422
|
-
return join(logDir, `${id}.status`);
|
|
423
|
-
}
|
|
424
|
-
function eventsPath(logDir, id) {
|
|
425
|
-
return join(logDir, `${id}.jsonl`);
|
|
426
|
-
}
|
|
427
|
-
function ledgerPath(logDir) {
|
|
428
|
-
return join(logDir, "ledger.log");
|
|
429
|
-
}
|
|
430
|
-
function routingStatePath(logDir) {
|
|
431
|
-
return join(logDir, "routing-state.json");
|
|
432
|
-
}
|
|
433
|
-
function panesDir(logDir) {
|
|
434
|
-
return join(logDir, "panes");
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* The strict empty MCP config handed to headless workers. Written into the
|
|
438
|
-
* logDir on demand (never into the user's vendor config directory — this is
|
|
439
|
-
* PAI state, not vendor state).
|
|
440
|
-
*/
|
|
441
|
-
function noMcpConfigPath(logDir) {
|
|
442
|
-
return join(logDir, "no-mcp.json");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
//#endregion
|
|
446
10
|
//#region src/workers/args.ts
|
|
447
11
|
function parseRunnerArgs(argv) {
|
|
448
12
|
let prompt = null;
|
|
@@ -603,11 +167,27 @@ function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()
|
|
|
603
167
|
* runner wrote, plus `provider` (routing is multi-provider now) and `session`
|
|
604
168
|
* (the AIBroker identity of the launching session, see scope.ts).
|
|
605
169
|
*/
|
|
170
|
+
/** Label a worker gets when launched with neither --label nor a prompt. */
|
|
171
|
+
const UNLABELED = "unlabeled";
|
|
606
172
|
/** Context-meter percentage 0–100, null when the numbers are missing. */
|
|
607
173
|
function contextPercent(s) {
|
|
608
174
|
if (!s.contextTokens || !s.contextWindow) return null;
|
|
609
175
|
return Math.round(s.contextTokens / s.contextWindow * 100);
|
|
610
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
|
+
}
|
|
611
191
|
/** Timestamp format shared by status files and the ledger. */
|
|
612
192
|
function nowStamp(d = /* @__PURE__ */ new Date()) {
|
|
613
193
|
const p = (n) => String(n).padStart(2, "0");
|
|
@@ -700,6 +280,12 @@ function describeTool(name, inp) {
|
|
|
700
280
|
* matches (AIBroker absent, unnamed session, non-iTerm terminal).
|
|
701
281
|
*
|
|
702
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.
|
|
703
289
|
*/
|
|
704
290
|
const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
|
|
705
291
|
/**
|
|
@@ -769,213 +355,32 @@ function scopeKey(term) {
|
|
|
769
355
|
function sessionTag(worker) {
|
|
770
356
|
return worker.session?.name ? `[${worker.session.name}]` : "";
|
|
771
357
|
}
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
* Auto-routing walks `workers.routing.order` — or the class's own `order` —
|
|
779
|
-
* and takes the first provider that is enabled, out of cooldown, (when it
|
|
780
|
-
* defines a quotaProbe) under its quotaSkipAt threshold, and — when the class
|
|
781
|
-
* constrains it — within `maxCostTier` and carrying all `requireTags`. A run
|
|
782
|
-
* that dies of a quota/rate error puts its provider in cooldown for
|
|
783
|
-
* cooldownMinutes; when that happens before the first tool call and
|
|
784
|
-
* retryOnQuota is set, the runner restarts the same task on the next provider
|
|
785
|
-
* (ledger: WORKER-REROUTE).
|
|
786
|
-
*
|
|
787
|
-
* An explicit --provider or a class mapping that pins a provider always
|
|
788
|
-
* bypasses all of this.
|
|
789
|
-
*/
|
|
790
|
-
const QUOTA_SKIP_DEFAULT = 95;
|
|
791
|
-
const PROBE_TIMEOUT_MS = 1e4;
|
|
792
|
-
function readRoutingState(logDir) {
|
|
793
|
-
const path = routingStatePath(logDir);
|
|
794
|
-
if (!existsSync(path)) return { cooldowns: {} };
|
|
795
|
-
try {
|
|
796
|
-
return { cooldowns: JSON.parse(readFileSync(path, "utf8")).cooldowns ?? {} };
|
|
797
|
-
} catch {
|
|
798
|
-
return { cooldowns: {} };
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
function writeRoutingState(logDir, state) {
|
|
802
|
-
const path = routingStatePath(logDir);
|
|
803
|
-
const dir = dirname(path);
|
|
804
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
805
|
-
const tmp = `${path}.tmp`;
|
|
806
|
-
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
807
|
-
renameSync(tmp, path);
|
|
808
|
-
}
|
|
809
|
-
function cooldownRemaining(state, provider, now = /* @__PURE__ */ new Date()) {
|
|
810
|
-
const end = state.cooldowns[provider];
|
|
811
|
-
if (!end) return 0;
|
|
812
|
-
const ms = Date.parse(end) - now.getTime();
|
|
813
|
-
return ms > 0 ? ms : 0;
|
|
814
|
-
}
|
|
815
|
-
function setCooldown(logDir, provider, minutes, now = /* @__PURE__ */ new Date()) {
|
|
816
|
-
const state = readRoutingState(logDir);
|
|
817
|
-
state.cooldowns[provider] = new Date(now.getTime() + minutes * 6e4).toISOString();
|
|
818
|
-
writeRoutingState(logDir, state);
|
|
819
|
-
}
|
|
820
|
-
function clearCooldown(logDir, provider) {
|
|
821
|
-
const state = readRoutingState(logDir);
|
|
822
|
-
if (!(provider in state.cooldowns)) return;
|
|
823
|
-
delete state.cooldowns[provider];
|
|
824
|
-
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");
|
|
825
364
|
}
|
|
826
365
|
/**
|
|
827
|
-
*
|
|
828
|
-
*
|
|
829
|
-
*
|
|
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`.
|
|
830
369
|
*/
|
|
831
|
-
function
|
|
832
|
-
|
|
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;
|
|
833
378
|
try {
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
stdio: [
|
|
838
|
-
"ignore",
|
|
839
|
-
"pipe",
|
|
840
|
-
"ignore"
|
|
841
|
-
]
|
|
842
|
-
}).match(/(\d+(?:\.\d+)?)/);
|
|
843
|
-
if (!m) return null;
|
|
844
|
-
return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));
|
|
845
|
-
} catch {
|
|
846
|
-
return null;
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
function quotaSkipThreshold(provider) {
|
|
850
|
-
return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;
|
|
851
|
-
}
|
|
852
|
-
/** Does this provider exceed its quota threshold right now? */
|
|
853
|
-
function quotaExceeded(provider) {
|
|
854
|
-
const used = probeQuota(provider);
|
|
855
|
-
return used !== null && used >= quotaSkipThreshold(provider);
|
|
856
|
-
}
|
|
857
|
-
var NoProviderError = class extends WorkersConfigError {};
|
|
858
|
-
function mustExist(config, name) {
|
|
859
|
-
const p = config.providers[name];
|
|
860
|
-
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>`);
|
|
861
|
-
return p;
|
|
862
|
-
}
|
|
863
|
-
function mustBeRunnable(name, p) {
|
|
864
|
-
if (!p.enabled) throw new NoProviderError(`provider "${name}" is disabled. Enable it with: pai worker providers enable ${name}`);
|
|
865
|
-
return p;
|
|
866
|
-
}
|
|
867
|
-
/** Why one provider of a routing order did not qualify (message fragment). */
|
|
868
|
-
function exclusionReason(config, state, name, cls) {
|
|
869
|
-
const p = config.providers[name];
|
|
870
|
-
if (!p) return "not configured";
|
|
871
|
-
if (!p.enabled) return "disabled";
|
|
872
|
-
if (cooldownRemaining(state, name) > 0) return "cooldown";
|
|
873
|
-
if (quotaExceeded(p)) return "quota";
|
|
874
|
-
const tier = providerCostTier(p);
|
|
875
|
-
if (cls?.maxCostTier !== void 0 && tier > cls.maxCostTier) return `cost tier ${tier} > max ${cls.maxCostTier}`;
|
|
876
|
-
if (cls?.requireTags?.length) {
|
|
877
|
-
const have = p.tags ?? [];
|
|
878
|
-
const missing = cls.requireTags.filter((t) => !have.includes(t));
|
|
879
|
-
if (missing.length) return `missing tags: ${missing.join(", ")}`;
|
|
880
|
-
}
|
|
881
|
-
return null;
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* Resolve which provider (and model alias) a run uses.
|
|
885
|
-
*
|
|
886
|
-
* @param flagProvider --provider value, highest precedence
|
|
887
|
-
* @param className --class value (the old --role), looked up in workers.classes
|
|
888
|
-
*/
|
|
889
|
-
function resolveTarget(config, logDir, opts = {}) {
|
|
890
|
-
if (opts.flagProvider) return {
|
|
891
|
-
providerName: opts.flagProvider,
|
|
892
|
-
provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),
|
|
893
|
-
modelAlias: null,
|
|
894
|
-
classMcp: null,
|
|
895
|
-
via: "flag"
|
|
896
|
-
};
|
|
897
|
-
const clsTarget = opts.className ? config.classes[opts.className] : void 0;
|
|
898
|
-
const cls = typeof clsTarget === "object" && clsTarget !== null ? clsTarget : void 0;
|
|
899
|
-
if (opts.className && clsTarget !== void 0 && typeof clsTarget !== "object") {
|
|
900
|
-
const [name, alias] = clsTarget.split("/");
|
|
901
|
-
return {
|
|
902
|
-
providerName: name,
|
|
903
|
-
provider: mustBeRunnable(name, mustExist(config, name)),
|
|
904
|
-
modelAlias: alias ?? null,
|
|
905
|
-
classMcp: null,
|
|
906
|
-
via: "class"
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
if (opts.className && cls?.provider) {
|
|
910
|
-
const provider = mustBeRunnable(cls.provider, mustExist(config, cls.provider));
|
|
911
|
-
return {
|
|
912
|
-
providerName: cls.provider,
|
|
913
|
-
provider,
|
|
914
|
-
modelAlias: null,
|
|
915
|
-
classMcp: cls.mcp ?? null,
|
|
916
|
-
via: "class"
|
|
917
|
-
};
|
|
918
|
-
}
|
|
919
|
-
if (opts.className && clsTarget === void 0) {
|
|
920
|
-
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]>`);
|
|
921
|
-
}
|
|
922
|
-
if (config.active !== "auto") {
|
|
923
|
-
const name = config.active;
|
|
924
|
-
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>)");
|
|
925
|
-
return {
|
|
926
|
-
providerName: name,
|
|
927
|
-
provider: mustBeRunnable(name, mustExist(config, name)),
|
|
928
|
-
modelAlias: null,
|
|
929
|
-
classMcp: null,
|
|
930
|
-
via: "active"
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
const state = readRoutingState(logDir);
|
|
934
|
-
const order = cls?.order ?? config.routing.order;
|
|
935
|
-
const excluded = [];
|
|
936
|
-
for (const name of order) {
|
|
937
|
-
const p = config.providers[name];
|
|
938
|
-
const why = exclusionReason(config, state, name, cls);
|
|
939
|
-
if (why) {
|
|
940
|
-
excluded.push(`${name}: ${why}`);
|
|
941
|
-
continue;
|
|
942
|
-
}
|
|
943
|
-
return {
|
|
944
|
-
providerName: name,
|
|
945
|
-
provider: p,
|
|
946
|
-
modelAlias: null,
|
|
947
|
-
classMcp: cls?.mcp ?? null,
|
|
948
|
-
via: "auto"
|
|
949
|
-
};
|
|
950
|
-
}
|
|
951
|
-
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>`);
|
|
952
|
-
}
|
|
953
|
-
/**
|
|
954
|
-
* Next provider after `from` in auto order, applying the same filters.
|
|
955
|
-
* Used by rerouting; null when the order is exhausted.
|
|
956
|
-
*/
|
|
957
|
-
function nextAutoProvider(config, logDir, from, now = /* @__PURE__ */ new Date()) {
|
|
958
|
-
const state = readRoutingState(logDir);
|
|
959
|
-
const order = config.routing.order;
|
|
960
|
-
const start = order.indexOf(from);
|
|
961
|
-
for (let i = start + 1; i < order.length; i++) {
|
|
962
|
-
const name = order[i];
|
|
963
|
-
const p = config.providers[name];
|
|
964
|
-
if (!p || !p.enabled) continue;
|
|
965
|
-
const end = state.cooldowns[name];
|
|
966
|
-
if (end && Date.parse(end) > now.getTime()) continue;
|
|
967
|
-
if (quotaExceeded(p)) continue;
|
|
968
|
-
return name;
|
|
969
|
-
}
|
|
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 {}
|
|
970
382
|
return null;
|
|
971
383
|
}
|
|
972
|
-
/**
|
|
973
|
-
* Was this failure a quota/rate failure? Detected from the result text the
|
|
974
|
-
* endpoint produced (HTTP status is not visible in the stream events).
|
|
975
|
-
*/
|
|
976
|
-
function isQuotaFailure(resultText) {
|
|
977
|
-
return /usage limit reached|rate limit|quota/i.test(resultText);
|
|
978
|
-
}
|
|
979
384
|
|
|
980
385
|
//#endregion
|
|
981
386
|
//#region src/workers/pane.ts
|
|
@@ -987,7 +392,10 @@ function isQuotaFailure(resultText) {
|
|
|
987
392
|
* one splits the lowest live worker pane horizontally, so panes stack top to
|
|
988
393
|
* bottom. The split never sizes the new session — that would grow the whole
|
|
989
394
|
* window; instead the window's bounds are read before the split and restored
|
|
990
|
-
* right after, so the panes share the space the window already had.
|
|
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
|
|
991
399
|
* pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
|
|
992
400
|
* dynamic profile (Close Sessions On End), so panes disappear by themselves.
|
|
993
401
|
*
|
|
@@ -1066,6 +474,13 @@ const WORKER_SPLIT_SCRIPT = `on run(argv)
|
|
|
1066
474
|
tell newS
|
|
1067
475
|
write text followCmd
|
|
1068
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
|
|
1069
484
|
try
|
|
1070
485
|
set bounds of w to winBounds
|
|
1071
486
|
end try
|
|
@@ -1149,6 +564,11 @@ const SPLIT_SCRIPT = `on run(argv)
|
|
|
1149
564
|
tell newS
|
|
1150
565
|
write text followCmd
|
|
1151
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
|
|
1152
572
|
return "opened"
|
|
1153
573
|
end if
|
|
1154
574
|
end repeat
|
|
@@ -1476,9 +896,11 @@ function parentFromEnv(env = process.env) {
|
|
|
1476
896
|
* Depth of `id` in the worker forest: 0 for a top-level worker, 1 + the
|
|
1477
897
|
* parent's depth for a sub-worker. Parents without a status file (chain ids,
|
|
1478
898
|
* unknown ids) count as roots; a cycle reads as its own depth and is cut off
|
|
1479
|
-
* 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.
|
|
1480
902
|
*/
|
|
1481
|
-
function workerDepth(statuses, id) {
|
|
903
|
+
function workerDepth(statuses, id, opts) {
|
|
1482
904
|
const byId = new Map(statuses.map((s) => [s.id, s]));
|
|
1483
905
|
let depth = 0;
|
|
1484
906
|
let cur = byId.get(id);
|
|
@@ -1487,7 +909,7 @@ function workerDepth(statuses, id) {
|
|
|
1487
909
|
seen.add(cur.parent);
|
|
1488
910
|
const next = byId.get(cur.parent);
|
|
1489
911
|
if (!next) break;
|
|
1490
|
-
depth += 1;
|
|
912
|
+
if (!(opts?.chatIsRoot && next.origin === "chat")) depth += 1;
|
|
1491
913
|
cur = next;
|
|
1492
914
|
}
|
|
1493
915
|
return depth;
|
|
@@ -1722,7 +1144,7 @@ async function handoffFromInside(logDir, env, payload, deps = {}) {
|
|
|
1722
1144
|
* commits its own work on that branch — the no-commit rule applies to the
|
|
1723
1145
|
* main branch only — and the parent (or the operator) merges the result back:
|
|
1724
1146
|
*
|
|
1725
|
-
* 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
|
|
1726
1148
|
* pai worker discard <id> remove worktree and branch, keep nothing
|
|
1727
1149
|
*
|
|
1728
1150
|
* `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
|
|
@@ -1873,26 +1295,160 @@ function removeWorktree(cwd, dir, force) {
|
|
|
1873
1295
|
} catch {}
|
|
1874
1296
|
}
|
|
1875
1297
|
/**
|
|
1876
|
-
*
|
|
1877
|
-
*
|
|
1878
|
-
*
|
|
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.
|
|
1879
1401
|
*/
|
|
1880
1402
|
function mergeWorker(logDir, id) {
|
|
1881
1403
|
const st = mustHaveBranch(logDir, id);
|
|
1882
1404
|
if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
"
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
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
|
|
1889
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);
|
|
1890
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
|
+
}
|
|
1891
1446
|
saveStatus(logDir, {
|
|
1892
1447
|
...st,
|
|
1893
1448
|
merged: true
|
|
1894
1449
|
});
|
|
1895
|
-
|
|
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;
|
|
1896
1452
|
}
|
|
1897
1453
|
/** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
|
|
1898
1454
|
function discardWorker(logDir, id) {
|
|
@@ -1936,7 +1492,8 @@ function worktreeSystemPrompt(id, branch, dir) {
|
|
|
1936
1492
|
` ${dir} on branch ${branch} (worker id ${id}).`,
|
|
1937
1493
|
"Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
|
|
1938
1494
|
"the no-commit rule applies to the main branch only, and this is not it.",
|
|
1939
|
-
"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."
|
|
1940
1497
|
].join("\n");
|
|
1941
1498
|
}
|
|
1942
1499
|
|
|
@@ -1961,6 +1518,8 @@ const WORKER_CONTRACT_PROMPT = [
|
|
|
1961
1518
|
"changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
|
|
1962
1519
|
"checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
|
|
1963
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).",
|
|
1964
1523
|
"",
|
|
1965
1524
|
`Operator messages: a user turn starting with ${OPERATOR_MARK} was typed by the operator while you`,
|
|
1966
1525
|
"run (the prompt of a resumed run is the operator's too). Answer it FIRST, in one or two plain lines —",
|
|
@@ -2123,101 +1682,6 @@ function describeMcp(config, claudeJson = CLAUDE_JSON) {
|
|
|
2123
1682
|
return lines;
|
|
2124
1683
|
}
|
|
2125
1684
|
|
|
2126
|
-
//#endregion
|
|
2127
|
-
//#region src/workers/proxy/server.ts
|
|
2128
|
-
/**
|
|
2129
|
-
* server.ts — the PAI worker proxy: Anthropic Messages API on the front,
|
|
2130
|
-
* OpenAI Chat Completions on the back, loopback only.
|
|
2131
|
-
*
|
|
2132
|
-
* One proxy serves every OpenAI-protocol provider: Claude Code points
|
|
2133
|
-
* ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
|
|
2134
|
-
* name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
|
|
2135
|
-
* workers config, re-read per request so config edits apply without a
|
|
2136
|
-
* restart). `run` starts the proxy on demand (detached, pid file under the
|
|
2137
|
-
* logDir) and `pai worker proxy stop` stops it again.
|
|
2138
|
-
*
|
|
2139
|
-
* All translation lives in translate.ts; this file is only HTTP plumbing.
|
|
2140
|
-
*/
|
|
2141
|
-
const DEFAULT_PROXY_PORT = 8797;
|
|
2142
|
-
const HOST = "127.0.0.1";
|
|
2143
|
-
function proxyPidPath(logDir) {
|
|
2144
|
-
return join(logDir, "proxy.pid");
|
|
2145
|
-
}
|
|
2146
|
-
/** True when something already accepts connections on the port. */
|
|
2147
|
-
function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
|
|
2148
|
-
return new Promise((resolve) => {
|
|
2149
|
-
const sock = connect({
|
|
2150
|
-
port,
|
|
2151
|
-
host: HOST
|
|
2152
|
-
});
|
|
2153
|
-
const done = (ok) => {
|
|
2154
|
-
sock.removeAllListeners();
|
|
2155
|
-
sock.destroy();
|
|
2156
|
-
resolve(ok);
|
|
2157
|
-
};
|
|
2158
|
-
sock.setTimeout(timeoutMs, () => done(false));
|
|
2159
|
-
sock.once("connect", () => done(true));
|
|
2160
|
-
sock.once("error", () => done(false));
|
|
2161
|
-
});
|
|
2162
|
-
}
|
|
2163
|
-
/**
|
|
2164
|
-
* Where the standalone proxy artefact lives, found from this module's own
|
|
2165
|
-
* location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
|
|
2166
|
-
*/
|
|
2167
|
-
function standaloneProxyPath() {
|
|
2168
|
-
let dir = dirname(fileURLToPath(import.meta.url));
|
|
2169
|
-
for (let i = 0; i < 6; i++) {
|
|
2170
|
-
for (const cand of [
|
|
2171
|
-
join(dir, "worker-proxy.mjs"),
|
|
2172
|
-
join(dir, "hooks", "worker-proxy.mjs"),
|
|
2173
|
-
join(dir, "dist", "hooks", "worker-proxy.mjs")
|
|
2174
|
-
]) if (existsSync(cand)) return cand;
|
|
2175
|
-
const parent = dirname(dir);
|
|
2176
|
-
if (parent === dir) break;
|
|
2177
|
-
dir = parent;
|
|
2178
|
-
}
|
|
2179
|
-
return null;
|
|
2180
|
-
}
|
|
2181
|
-
/**
|
|
2182
|
-
* Ensure a proxy is listening on the port; start the detached standalone when
|
|
2183
|
-
* nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).
|
|
2184
|
-
*/
|
|
2185
|
-
async function ensureProxyRunning(port, logDir) {
|
|
2186
|
-
if (await proxyListening(port)) return `http://${HOST}:${port}`;
|
|
2187
|
-
const script = standaloneProxyPath();
|
|
2188
|
-
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})`);
|
|
2189
|
-
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
2190
|
-
const child = spawn(process.execPath, [
|
|
2191
|
-
script,
|
|
2192
|
-
"--port",
|
|
2193
|
-
String(port)
|
|
2194
|
-
], {
|
|
2195
|
-
detached: true,
|
|
2196
|
-
stdio: "ignore"
|
|
2197
|
-
});
|
|
2198
|
-
child.unref();
|
|
2199
|
-
writeFileSync(proxyPidPath(logDir), `${child.pid}\n`, "utf8");
|
|
2200
|
-
for (let i = 0; i < 40; i++) {
|
|
2201
|
-
if (await proxyListening(port)) return `http://${HOST}:${port}`;
|
|
2202
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
2203
|
-
}
|
|
2204
|
-
throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);
|
|
2205
|
-
}
|
|
2206
|
-
/** `pai worker proxy stop`: SIGTERM the pid from the pid file. */
|
|
2207
|
-
function stopProxy(logDir) {
|
|
2208
|
-
const path = proxyPidPath(logDir);
|
|
2209
|
-
if (!existsSync(path)) return "no proxy pid file — nothing to stop";
|
|
2210
|
-
const pid = parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
2211
|
-
unlinkSync(path);
|
|
2212
|
-
if (!Number.isFinite(pid)) return "stale proxy pid file removed";
|
|
2213
|
-
try {
|
|
2214
|
-
process.kill(pid, "SIGTERM");
|
|
2215
|
-
} catch {
|
|
2216
|
-
return `proxy pid ${pid} was not running (pid file removed)`;
|
|
2217
|
-
}
|
|
2218
|
-
return `proxy pid ${pid} stopped`;
|
|
2219
|
-
}
|
|
2220
|
-
|
|
2221
1685
|
//#endregion
|
|
2222
1686
|
//#region src/workers/codex.ts
|
|
2223
1687
|
/**
|
|
@@ -2413,34 +1877,6 @@ function codexInstalled() {
|
|
|
2413
1877
|
* Auto-routed runs that die of a quota error before the first tool call are
|
|
2414
1878
|
* restarted on the next provider in routing order (WORKER-REROUTE ledger line).
|
|
2415
1879
|
*/
|
|
2416
|
-
/** Environment for a run through `provider`. Caller's env minus the Anthropic key. */
|
|
2417
|
-
function buildRunEnv(provider, headless, proxyUrl) {
|
|
2418
|
-
const env = { ...process.env };
|
|
2419
|
-
delete env.ANTHROPIC_API_KEY;
|
|
2420
|
-
let token = "local";
|
|
2421
|
-
if (proxyUrl) env.ANTHROPIC_BASE_URL = proxyUrl;
|
|
2422
|
-
else {
|
|
2423
|
-
const keyPath = providerKeyPath(provider);
|
|
2424
|
-
if (keyPath) {
|
|
2425
|
-
try {
|
|
2426
|
-
token = readFileSync(keyPath, "utf8").trim();
|
|
2427
|
-
} catch {
|
|
2428
|
-
throw new Error(`key file not readable: ${keyPath}`);
|
|
2429
|
-
}
|
|
2430
|
-
if (!token) throw new Error(`key file is empty: ${keyPath}`);
|
|
2431
|
-
}
|
|
2432
|
-
env.ANTHROPIC_BASE_URL = provider.baseUrl;
|
|
2433
|
-
}
|
|
2434
|
-
env.ANTHROPIC_AUTH_TOKEN = token;
|
|
2435
|
-
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.models.fast ?? provider.models.default;
|
|
2436
|
-
env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.models.default;
|
|
2437
|
-
env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.models.default;
|
|
2438
|
-
for (const [k, v] of Object.entries(provider.env)) env[k] = v;
|
|
2439
|
-
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
2440
|
-
if (headless) env.PAI_WORKER = "1";
|
|
2441
|
-
else env.ENABLE_TOOL_SEARCH = "true";
|
|
2442
|
-
return env;
|
|
2443
|
-
}
|
|
2444
1880
|
/** The strict empty MCP config for headless workers, written on demand. */
|
|
2445
1881
|
function ensureNoMcpConfig(logDir) {
|
|
2446
1882
|
const path = noMcpConfigPath(logDir);
|
|
@@ -2484,10 +1920,35 @@ function usageContextTokens(u) {
|
|
|
2484
1920
|
const t = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0);
|
|
2485
1921
|
return t > 0 ? t : null;
|
|
2486
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
|
+
}
|
|
2487
1947
|
/** Context window announced by the init event, when the endpoint sends one. */
|
|
2488
1948
|
function initContextWindow(e) {
|
|
2489
1949
|
if (typeof e.context_window === "number" && e.context_window > 0) return e.context_window;
|
|
2490
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;
|
|
2491
1952
|
return null;
|
|
2492
1953
|
}
|
|
2493
1954
|
/**
|
|
@@ -2500,7 +1961,7 @@ async function runWorker(opts) {
|
|
|
2500
1961
|
const logDir = workersLogDir(config);
|
|
2501
1962
|
mkdirSync(logDir, { recursive: true });
|
|
2502
1963
|
if (opts.className === "plan" && !opts._planner) {
|
|
2503
|
-
const { runPlanner } = await import("./planner-
|
|
1964
|
+
const { runPlanner } = await import("./planner-Cm3g6fWH.mjs");
|
|
2504
1965
|
return runPlanner(opts);
|
|
2505
1966
|
}
|
|
2506
1967
|
const parent = launchParent(opts.parent);
|
|
@@ -2511,8 +1972,10 @@ async function runWorker(opts) {
|
|
|
2511
1972
|
});
|
|
2512
1973
|
assertProviderRunnable(target.providerName, target.provider);
|
|
2513
1974
|
const parsed = parseRunnerArgs(opts.claudeArgs);
|
|
2514
|
-
const label = opts.label ?? shortText(parsed.prompt ??
|
|
2515
|
-
const
|
|
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);
|
|
2516
1979
|
try {
|
|
2517
1980
|
if (target.provider.engine === "codex") return await executeCodexRun({
|
|
2518
1981
|
config,
|
|
@@ -2567,6 +2030,7 @@ async function executeRun(a) {
|
|
|
2567
2030
|
const cwd = a.cwd ?? process.cwd();
|
|
2568
2031
|
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
2569
2032
|
const session = resolveSession(term);
|
|
2033
|
+
const spawnerSession = resolveSpawnerSession(logDir, cwd);
|
|
2570
2034
|
let worktree = null;
|
|
2571
2035
|
if (headless && worktreeWanted(a.worktreeFlag, {
|
|
2572
2036
|
cwd,
|
|
@@ -2599,8 +2063,9 @@ async function executeRun(a) {
|
|
|
2599
2063
|
last: headless ? "starting" : "interactive",
|
|
2600
2064
|
rc: null,
|
|
2601
2065
|
secs: null,
|
|
2066
|
+
origin: headless ? "spawn" : "chat",
|
|
2602
2067
|
...session ? { session } : {},
|
|
2603
|
-
|
|
2068
|
+
...spawnerSession ? { spawnerSession } : {},
|
|
2604
2069
|
...a.parent ? {
|
|
2605
2070
|
parent: a.parent,
|
|
2606
2071
|
stage: a.stage
|
|
@@ -2749,10 +2214,12 @@ async function executeRun(a) {
|
|
|
2749
2214
|
const cw = initContextWindow(e);
|
|
2750
2215
|
if (cw) status.contextWindow = cw;
|
|
2751
2216
|
saveStatus(logDir, status);
|
|
2217
|
+
} else if (isCompactBoundary(e)) {
|
|
2218
|
+
resetContextTokensOnCompact(status, e);
|
|
2219
|
+
saveStatus(logDir, status);
|
|
2752
2220
|
} else if (e.type === "assistant") {
|
|
2753
2221
|
status.turns += 1;
|
|
2754
|
-
|
|
2755
|
-
if (tokens) status.contextTokens = tokens;
|
|
2222
|
+
bumpContextTokens(status, usageContextTokens(e.message?.usage));
|
|
2756
2223
|
for (const block of e.message?.content ?? []) if (block.type === "tool_use") {
|
|
2757
2224
|
status.tools += 1;
|
|
2758
2225
|
status.last = describeTool(block.name ?? "?", block.input);
|
|
@@ -2760,7 +2227,8 @@ async function executeRun(a) {
|
|
|
2760
2227
|
saveStatus(logDir, status);
|
|
2761
2228
|
} else if (e.type === "result") {
|
|
2762
2229
|
const tokens = usageContextTokens(e.usage);
|
|
2763
|
-
if (
|
|
2230
|
+
if (e.is_compact) status.contextTokens = tokens ?? status.contextTokens;
|
|
2231
|
+
else bumpContextTokens(status, tokens);
|
|
2764
2232
|
const report = parseWorkerReport(e.result ?? "");
|
|
2765
2233
|
if (report?.notes) status.last = shortText(report.notes, 90);
|
|
2766
2234
|
else if (e.result) status.last = shortText(e.result, 90);
|
|
@@ -2861,6 +2329,7 @@ async function executeCodexRun(a) {
|
|
|
2861
2329
|
const cwd = a.cwd ?? process.cwd();
|
|
2862
2330
|
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
2863
2331
|
const session = resolveSession(term);
|
|
2332
|
+
const spawnerSession = resolveSpawnerSession(logDir, cwd);
|
|
2864
2333
|
let worktree = null;
|
|
2865
2334
|
if (worktreeWanted(a.worktreeFlag, {
|
|
2866
2335
|
cwd,
|
|
@@ -2894,8 +2363,10 @@ async function executeCodexRun(a) {
|
|
|
2894
2363
|
last: "starting",
|
|
2895
2364
|
rc: null,
|
|
2896
2365
|
secs: null,
|
|
2366
|
+
origin: "spawn",
|
|
2897
2367
|
...session ? { session } : {},
|
|
2898
|
-
|
|
2368
|
+
...spawnerSession ? { spawnerSession } : {},
|
|
2369
|
+
...target.provider.contextWindow ? { contextWindow: target.provider.contextWindow } : {},
|
|
2899
2370
|
...a.parent ? {
|
|
2900
2371
|
parent: a.parent,
|
|
2901
2372
|
stage: a.stage
|
|
@@ -2948,7 +2419,8 @@ async function executeCodexRun(a) {
|
|
|
2948
2419
|
type: "system",
|
|
2949
2420
|
subtype: "init",
|
|
2950
2421
|
model,
|
|
2951
|
-
cwd
|
|
2422
|
+
cwd,
|
|
2423
|
+
...target.provider.contextWindow ? { context_window: target.provider.contextWindow } : {}
|
|
2952
2424
|
});
|
|
2953
2425
|
let killed = false;
|
|
2954
2426
|
process.once("SIGTERM", onCodexSignal("SIGTERM"));
|
|
@@ -2976,7 +2448,7 @@ async function executeCodexRun(a) {
|
|
|
2976
2448
|
status.turns = fold.turns;
|
|
2977
2449
|
status.tools = fold.tools;
|
|
2978
2450
|
if (fold.last) status.last = shortText(fold.last, 90);
|
|
2979
|
-
|
|
2451
|
+
bumpContextTokens(status, fold.contextTokens);
|
|
2980
2452
|
saveStatus(logDir, status);
|
|
2981
2453
|
for (const ev of fold.events.splice(0)) writeEvent(ev);
|
|
2982
2454
|
});
|
|
@@ -3321,5 +2793,5 @@ async function runChain(opts, deps = {}) {
|
|
|
3321
2793
|
}
|
|
3322
2794
|
|
|
3323
2795
|
//#endregion
|
|
3324
|
-
export {
|
|
3325
|
-
//# sourceMappingURL=chain-
|
|
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
|