@tekmidian/pai 0.36.1 → 0.37.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/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
- package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
- package/dist/cli/index.mjs +5 -4
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +5 -4
- package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
- package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
- package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
- package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
- package/dist/daemon/index.mjs +8 -8
- package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
- package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
- package/dist/daemon-DsGGiIJM.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +428 -1
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
- package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
- package/dist/detector-DtLExmHN.mjs +5 -0
- package/dist/factory-BXzqRYVZ.mjs +3 -0
- package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
- package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +164 -14
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/initialize-session.mjs +6 -0
- package/dist/hooks/initialize-session.mjs.map +3 -3
- package/dist/hooks/load-core-context.mjs +6 -0
- package/dist/hooks/load-core-context.mjs.map +3 -3
- package/dist/hooks/load-project-context.mjs +6 -0
- package/dist/hooks/load-project-context.mjs.map +3 -3
- package/dist/hooks/post-compact-inject.mjs +48 -7
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +456 -0
- package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
- package/dist/hooks/status-line.mjs +400 -0
- package/dist/hooks/status-line.mjs.map +7 -0
- package/dist/hooks/stop-hook.mjs +6 -0
- package/dist/hooks/stop-hook.mjs.map +3 -3
- package/dist/hooks/subagent-stop-hook.mjs +6 -0
- package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
- package/dist/hooks/sync-todo-to-md.mjs +6 -0
- package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
- package/dist/hooks/update-tab-on-action.mjs +6 -0
- package/dist/hooks/update-tab-on-action.mjs.map +3 -3
- package/dist/hooks/update-tab-titles.mjs +6 -0
- package/dist/hooks/update-tab-titles.mjs.map +3 -3
- package/dist/hooks/worker-proxy.mjs +796 -0
- package/dist/hooks/worker-proxy.mjs.map +7 -0
- package/dist/hooks/worker-status-line.mjs +483 -0
- package/dist/hooks/worker-status-line.mjs.map +7 -0
- package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
- package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
- package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
- package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
- package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
- package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
- package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
- package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
- package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
- package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
- package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
- package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
- package/dist/{program-C-fUghPv.mjs → program-CEIHn_Ma.mjs} +693 -101
- package/dist/program-CEIHn_Ma.mjs.map +1 -0
- package/dist/providers-sXcK5bDZ.mjs +3067 -0
- package/dist/providers-sXcK5bDZ.mjs.map +1 -0
- package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
- package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
- package/dist/query-feedback-DhyLOe5S.mjs +3 -0
- package/dist/router-1zi8jiNF.mjs +3 -0
- package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
- package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
- package/dist/skills/Worker/SKILL.md +41 -0
- package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
- package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
- package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
- package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
- package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
- package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
- package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
- package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
- package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
- package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
- package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
- package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
- package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
- package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
- package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
- package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
- package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
- package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
- package/docs/commands/README.md +28 -0
- package/docs/commands/backup.md +1 -1
- package/docs/commands/clear-names.md +1 -1
- package/docs/commands/daemon.md +1 -1
- package/docs/commands/db.md +1 -1
- package/docs/commands/end.md +1 -1
- package/docs/commands/help.md +1 -1
- package/docs/commands/identity.md +1 -1
- package/docs/commands/kg.md +1 -1
- package/docs/commands/mcp.md +1 -1
- package/docs/commands/memory.md +1 -1
- package/docs/commands/notify.md +1 -1
- package/docs/commands/observation.md +1 -1
- package/docs/commands/obsidian.md +1 -1
- package/docs/commands/pause.md +1 -1
- package/docs/commands/project.md +1 -1
- package/docs/commands/projects.md +1 -1
- package/docs/commands/registry.md +1 -1
- package/docs/commands/restore.md +1 -1
- package/docs/commands/session.md +1 -1
- package/docs/commands/sessions.md +1 -1
- package/docs/commands/setup.md +1 -1
- package/docs/commands/shell-init.md +1 -1
- package/docs/commands/skill.md +1 -1
- package/docs/commands/task.md +1 -1
- package/docs/commands/topic.md +1 -1
- package/docs/commands/update.md +1 -1
- package/docs/commands/worker.md +361 -0
- package/docs/commands/zettel.md +1 -1
- package/docs/worker.md +303 -0
- package/package.json +1 -1
- package/scripts/build-hooks.mjs +46 -1
- package/src/hooks/pre-compact.sh +1 -0
- package/src/hooks/session-autosave.sh +1 -0
- package/src/hooks/session-stop.sh +1 -0
- package/src/hooks/ts/lib/context-fill.test.ts +104 -0
- package/src/hooks/ts/lib/context-fill.ts +73 -0
- package/src/hooks/ts/lib/handover-evidence.ts +227 -0
- package/src/hooks/ts/lib/worker-session.test.ts +24 -0
- package/src/hooks/ts/lib/worker-session.ts +23 -0
- package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
- package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
- package/src/hooks/ts/session-start/initialize-session.ts +2 -0
- package/src/hooks/ts/session-start/load-core-context.ts +2 -0
- package/src/hooks/ts/session-start/load-project-context.ts +2 -0
- package/src/hooks/ts/session-start/post-compact-inject.ts +13 -0
- package/src/hooks/ts/stop/stop-hook.ts +2 -0
- package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
- package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
- package/statusline-command.sh +17 -0
- package/dist/daemon-DRdoA489.mjs +0 -20
- package/dist/detector-DGAk1iBR.mjs +0 -5
- package/dist/factory-CrokPMk2.mjs +0 -3
- package/dist/program-C-fUghPv.mjs.map +0 -1
- package/dist/query-feedback-BBMBp96K.mjs +0 -3
- package/dist/router-BMkOb62X.mjs +0 -3
|
@@ -0,0 +1,3067 @@
|
|
|
1
|
+
import { n as CONFIG_FILE } from "./config-B64vFg14.mjs";
|
|
2
|
+
import { appendFileSync, chmodSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { connect, createServer } from "node:net";
|
|
7
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
8
|
+
import { createInterface } from "node:readline";
|
|
9
|
+
|
|
10
|
+
//#region src/config/json-store.ts
|
|
11
|
+
/**
|
|
12
|
+
* json-store.ts — read/write JSON config files without destroying them
|
|
13
|
+
*
|
|
14
|
+
* The failure this exists to prevent:
|
|
15
|
+
*
|
|
16
|
+
* try { return JSON.parse(read(path)); } catch { return {}; }
|
|
17
|
+
* ... later ...
|
|
18
|
+
* write(path, JSON.stringify(ourData));
|
|
19
|
+
*
|
|
20
|
+
* An unreadable file becomes an empty object, and the next write makes that
|
|
21
|
+
* permanent. Silently, exit code 0. This shape appeared three times in this
|
|
22
|
+
* repo against three different files, and twice in AIBroker.
|
|
23
|
+
*
|
|
24
|
+
* The distinction that matters is between *missing* and *unreadable*:
|
|
25
|
+
*
|
|
26
|
+
* missing — legitimate first run. Start fresh; writing is safe.
|
|
27
|
+
* unreadable — the file exists and we could not parse it. Those bytes are
|
|
28
|
+
* the only copy of something. Never overwrite them.
|
|
29
|
+
*
|
|
30
|
+
* Collapsing the second into the first is the bug.
|
|
31
|
+
*
|
|
32
|
+
* NOT everything deserves this guard. For a transient buffer — an undelivered
|
|
33
|
+
* message queue, a cache — starting fresh IS the correct recovery, and
|
|
34
|
+
* refusing to write would disable the feature permanently. Use `writeJsonAtomic`
|
|
35
|
+
* alone there: it still prevents a crash from truncating a good file, without
|
|
36
|
+
* blocking recovery. Reserve `readJsonStrict` for data a user cannot rebuild.
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Read a JSON file, distinguishing "absent" from "damaged".
|
|
40
|
+
*
|
|
41
|
+
* @param path file to read
|
|
42
|
+
* @param label how to name it to the user, e.g. "~/.claude.json"
|
|
43
|
+
* @throws if the file exists but cannot be read or parsed
|
|
44
|
+
*/
|
|
45
|
+
function readJsonStrict(path, label = path) {
|
|
46
|
+
if (!existsSync(path)) return {};
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = readFileSync(path, "utf8");
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`Could not read ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — writing now would replace its contents with ours alone.`);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(raw);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
throw new Error(`${label} exists but is not valid JSON: ${e instanceof Error ? e.message : String(e)}\nRefusing to continue — overwriting it would destroy whatever it holds.\nRepair the file, or move it aside and re-run this command.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Write JSON without risking the existing file.
|
|
61
|
+
*
|
|
62
|
+
* Keeps a `.bak-pai` copy of the previous contents, then writes to a temp file
|
|
63
|
+
* and renames. Rename is atomic within a filesystem, so a crash mid-write
|
|
64
|
+
* leaves the original intact rather than truncated — which is how these files
|
|
65
|
+
* become corrupt in the first place.
|
|
66
|
+
*/
|
|
67
|
+
function writeJsonAtomic(path, data, opts = {}) {
|
|
68
|
+
const { backup = true, label = path } = opts;
|
|
69
|
+
const serialized = JSON.stringify(data, null, 2) + "\n";
|
|
70
|
+
const dir = dirname(path);
|
|
71
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
72
|
+
if (backup && existsSync(path)) try {
|
|
73
|
+
copyFileSync(path, `${path}.bak-pai`);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new Error(`Could not back up ${label}: ${e instanceof Error ? e.message : String(e)}\nRefusing to write without a backup.`);
|
|
76
|
+
}
|
|
77
|
+
const tmp = `${path}.tmp-pai-${process.pid}`;
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(tmp, serialized, "utf8");
|
|
80
|
+
renameSync(tmp, path);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
try {
|
|
83
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
84
|
+
} catch {}
|
|
85
|
+
throw new Error(`Failed to write ${label}: ${e instanceof Error ? e.message : String(e)}\nThe original is unchanged.`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/workers/config.ts
|
|
91
|
+
/**
|
|
92
|
+
* config.ts — the `workers` section of ~/.config/pai/config.json
|
|
93
|
+
*
|
|
94
|
+
* Everything that knows about worker providers reads this module: the CLI
|
|
95
|
+
* (`pai worker …`), the MCP tools (worker_*), and the Agent-routing hook.
|
|
96
|
+
* Keep it free of commander/MCP imports so all three layers stay thin over it.
|
|
97
|
+
*
|
|
98
|
+
* Keys are never stored here — only paths to 0600 files. A provider without a
|
|
99
|
+
* keyFile is a local server and gets the placeholder token "local".
|
|
100
|
+
*
|
|
101
|
+
* `protocol: "openai"` providers run through the PAI proxy (src/workers/proxy):
|
|
102
|
+
* `upstreamUrl` is their Chat Completions base, and `run` points
|
|
103
|
+
* ANTHROPIC_BASE_URL at the local proxy with the provider name in the path.
|
|
104
|
+
* `engine: "codex"` providers run through the Codex CLI instead of Claude
|
|
105
|
+
* Code.
|
|
106
|
+
*/
|
|
107
|
+
const DEFAULT_LOG_DIR = "~/.claude/logs/workers";
|
|
108
|
+
const DEFAULT_PANE = {
|
|
109
|
+
enabled: true,
|
|
110
|
+
fontSize: 13,
|
|
111
|
+
autoExitSecs: 60
|
|
112
|
+
};
|
|
113
|
+
const DEFAULT_ROUTING = {
|
|
114
|
+
order: [],
|
|
115
|
+
cooldownMinutes: 30,
|
|
116
|
+
retryOnQuota: true
|
|
117
|
+
};
|
|
118
|
+
function defaultWorkersConfig() {
|
|
119
|
+
return {
|
|
120
|
+
enabled: false,
|
|
121
|
+
active: null,
|
|
122
|
+
providers: {},
|
|
123
|
+
roles: {},
|
|
124
|
+
mcpSets: {},
|
|
125
|
+
pane: { ...DEFAULT_PANE },
|
|
126
|
+
logDir: DEFAULT_LOG_DIR,
|
|
127
|
+
routing: {
|
|
128
|
+
...DEFAULT_ROUTING,
|
|
129
|
+
order: []
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
var WorkersConfigError = class extends Error {};
|
|
134
|
+
function bad(path, why) {
|
|
135
|
+
throw new WorkersConfigError(`workers${path}: ${why}`);
|
|
136
|
+
}
|
|
137
|
+
function str(v) {
|
|
138
|
+
return typeof v === "string" ? v : "";
|
|
139
|
+
}
|
|
140
|
+
function parseProvider(name, raw) {
|
|
141
|
+
if (typeof raw !== "object" || raw === null) bad(`.providers.${name}`, "must be an object");
|
|
142
|
+
const p = raw;
|
|
143
|
+
const protocol = p.protocol === void 0 ? "anthropic" : str(p.protocol);
|
|
144
|
+
if (protocol !== "anthropic" && protocol !== "openai") bad(`.providers.${name}.protocol`, `"${str(p.protocol)}" is neither "anthropic" nor "openai"`);
|
|
145
|
+
const engine = p.engine === void 0 ? "claude" : str(p.engine);
|
|
146
|
+
if (engine !== "claude" && engine !== "codex") bad(`.providers.${name}.engine`, `"${str(p.engine)}" is neither "claude" nor "codex"`);
|
|
147
|
+
const baseUrl = str(p.baseUrl);
|
|
148
|
+
if (!baseUrl && protocol !== "openai") bad(`.providers.${name}.baseUrl`, "is required");
|
|
149
|
+
const keyFile = p.keyFile === void 0 || p.keyFile === null || str(p.keyFile) === "" ? null : str(p.keyFile);
|
|
150
|
+
const modelsRaw = p.models === void 0 ? {} : p.models;
|
|
151
|
+
if (typeof modelsRaw !== "object" || modelsRaw === null) bad(`.providers.${name}.models`, "must be an object");
|
|
152
|
+
const m = modelsRaw;
|
|
153
|
+
const defaultModel = str(m.default);
|
|
154
|
+
if (!defaultModel) bad(`.providers.${name}.models.default`, "is required");
|
|
155
|
+
const fast = m.fast === void 0 ? void 0 : str(m.fast);
|
|
156
|
+
const env = {};
|
|
157
|
+
if (p.env !== void 0) {
|
|
158
|
+
if (typeof p.env !== "object" || p.env === null || Array.isArray(p.env)) bad(`.providers.${name}.env`, "must be an object of string values");
|
|
159
|
+
for (const [k, v] of Object.entries(p.env)) {
|
|
160
|
+
if (typeof v !== "string") bad(`.providers.${name}.env.${k}`, "must be a string");
|
|
161
|
+
env[k] = v;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const quotaSkipAt = p.quotaSkipAt === void 0 ? void 0 : p.quotaSkipAt;
|
|
165
|
+
if (quotaSkipAt !== void 0) {
|
|
166
|
+
if (typeof quotaSkipAt !== "number" || quotaSkipAt < 0 || quotaSkipAt > 100) bad(`.providers.${name}.quotaSkipAt`, "must be a number between 0 and 100");
|
|
167
|
+
}
|
|
168
|
+
const contextWindow = p.contextWindow === void 0 ? void 0 : p.contextWindow;
|
|
169
|
+
if (contextWindow !== void 0) {
|
|
170
|
+
if (typeof contextWindow !== "number" || contextWindow <= 0) bad(`.providers.${name}.contextWindow`, "must be a positive number of tokens");
|
|
171
|
+
}
|
|
172
|
+
const upstreamUrl = str(p.upstreamUrl);
|
|
173
|
+
if (protocol === "openai" && !upstreamUrl) bad(`.providers.${name}.upstreamUrl`, `is required for protocol "openai" (the Chat Completions base, e.g. "https://api.openai.com/v1")`);
|
|
174
|
+
return {
|
|
175
|
+
enabled: p.enabled === void 0 ? true : p.enabled === true,
|
|
176
|
+
protocol,
|
|
177
|
+
baseUrl,
|
|
178
|
+
keyFile,
|
|
179
|
+
models: fast ? {
|
|
180
|
+
default: defaultModel,
|
|
181
|
+
fast
|
|
182
|
+
} : { default: defaultModel },
|
|
183
|
+
env,
|
|
184
|
+
...str(p.note) ? { note: str(p.note) } : {},
|
|
185
|
+
...upstreamUrl ? { upstreamUrl } : {},
|
|
186
|
+
...engine !== "claude" ? { engine } : {},
|
|
187
|
+
...str(p.quotaProbe) ? { quotaProbe: str(p.quotaProbe) } : {},
|
|
188
|
+
...quotaSkipAt !== void 0 ? { quotaSkipAt } : {},
|
|
189
|
+
...contextWindow !== void 0 ? { contextWindow } : {}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Parse and validate a raw `workers` value. Missing section → defaults.
|
|
194
|
+
* Unknown-but-typed garbage → WorkersConfigError naming the offending field.
|
|
195
|
+
*/
|
|
196
|
+
function parseWorkersConfig(raw) {
|
|
197
|
+
const d = defaultWorkersConfig();
|
|
198
|
+
if (raw === void 0 || raw === null) return d;
|
|
199
|
+
if (typeof raw !== "object" || Array.isArray(raw)) bad("", "section must be an object");
|
|
200
|
+
const w = raw;
|
|
201
|
+
const providers = {};
|
|
202
|
+
if (w.providers !== void 0) {
|
|
203
|
+
if (typeof w.providers !== "object" || w.providers === null || Array.isArray(w.providers)) bad(".providers", "must be an object keyed by provider name");
|
|
204
|
+
for (const [name, p] of Object.entries(w.providers)) providers[name] = parseProvider(name, p);
|
|
205
|
+
}
|
|
206
|
+
const roles = {};
|
|
207
|
+
if (w.roles !== void 0) {
|
|
208
|
+
if (typeof w.roles !== "object" || w.roles === null || Array.isArray(w.roles)) bad(".roles", "must be an object of role → provider[/alias] or {provider, mcp}");
|
|
209
|
+
for (const [role, target] of Object.entries(w.roles)) if (typeof target === "object" && target !== null && !Array.isArray(target)) {
|
|
210
|
+
const o = target;
|
|
211
|
+
const provider = str(o.provider);
|
|
212
|
+
if (!provider || provider.includes(" ")) bad(`.roles.${role}.provider`, `invalid provider "${provider}"`);
|
|
213
|
+
let mcp;
|
|
214
|
+
if (o.mcp !== void 0) {
|
|
215
|
+
if (!Array.isArray(o.mcp) || o.mcp.some((x) => typeof x !== "string")) bad(`.roles.${role}.mcp`, "must be an array of MCP server or set names");
|
|
216
|
+
mcp = o.mcp;
|
|
217
|
+
}
|
|
218
|
+
roles[role] = mcp ? {
|
|
219
|
+
provider,
|
|
220
|
+
mcp
|
|
221
|
+
} : { provider };
|
|
222
|
+
} else {
|
|
223
|
+
const t = str(target);
|
|
224
|
+
if (!t || t.includes(" ")) bad(`.roles.${role}`, `invalid target "${t}"`);
|
|
225
|
+
roles[role] = t;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const mcpSets = {};
|
|
229
|
+
if (w.mcpSets !== void 0) {
|
|
230
|
+
if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
|
|
231
|
+
for (const [setName, servers] of Object.entries(w.mcpSets)) {
|
|
232
|
+
if (!Array.isArray(servers) || servers.some((x) => typeof x !== "string")) bad(`.mcpSets.${setName}`, "must be an array of MCP server names");
|
|
233
|
+
mcpSets[setName] = servers;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
let pane = { ...DEFAULT_PANE };
|
|
237
|
+
if (w.pane !== void 0) {
|
|
238
|
+
if (typeof w.pane !== "object" || w.pane === null) bad(".pane", "must be an object");
|
|
239
|
+
const pc = w.pane;
|
|
240
|
+
if (pc.enabled !== void 0 && typeof pc.enabled !== "boolean") bad(".pane.enabled", "must be boolean");
|
|
241
|
+
if (pc.fontSize !== void 0 && (typeof pc.fontSize !== "number" || pc.fontSize <= 0)) bad(".pane.fontSize", "must be a positive number of points");
|
|
242
|
+
if (pc.autoExitSecs !== void 0 && typeof pc.autoExitSecs !== "number") bad(".pane.autoExitSecs", "must be a number");
|
|
243
|
+
pane = {
|
|
244
|
+
enabled: pc.enabled === void 0 ? DEFAULT_PANE.enabled : pc.enabled === true,
|
|
245
|
+
fontSize: pc.fontSize === void 0 ? DEFAULT_PANE.fontSize : pc.fontSize,
|
|
246
|
+
autoExitSecs: pc.autoExitSecs === void 0 ? DEFAULT_PANE.autoExitSecs : pc.autoExitSecs
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
let routing = {
|
|
250
|
+
...DEFAULT_ROUTING,
|
|
251
|
+
order: []
|
|
252
|
+
};
|
|
253
|
+
if (w.routing !== void 0) {
|
|
254
|
+
if (typeof w.routing !== "object" || w.routing === null) bad(".routing", "must be an object");
|
|
255
|
+
const r = w.routing;
|
|
256
|
+
if (r.order !== void 0) {
|
|
257
|
+
if (!Array.isArray(r.order) || r.order.some((x) => typeof x !== "string")) bad(".routing.order", "must be an array of provider names");
|
|
258
|
+
routing.order = r.order;
|
|
259
|
+
}
|
|
260
|
+
if (r.cooldownMinutes !== void 0 && typeof r.cooldownMinutes !== "number") bad(".routing.cooldownMinutes", "must be a number");
|
|
261
|
+
if (r.retryOnQuota !== void 0 && typeof r.retryOnQuota !== "boolean") bad(".routing.retryOnQuota", "must be boolean");
|
|
262
|
+
routing = {
|
|
263
|
+
order: routing.order,
|
|
264
|
+
cooldownMinutes: r.cooldownMinutes === void 0 ? DEFAULT_ROUTING.cooldownMinutes : r.cooldownMinutes,
|
|
265
|
+
retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const active = w.active === void 0 || w.active === null ? null : str(w.active);
|
|
269
|
+
if (active !== null && active !== "auto" && !(active in providers)) {}
|
|
270
|
+
return {
|
|
271
|
+
enabled: w.enabled === void 0 ? d.enabled : w.enabled === true,
|
|
272
|
+
active,
|
|
273
|
+
providers,
|
|
274
|
+
roles,
|
|
275
|
+
mcpSets,
|
|
276
|
+
pane,
|
|
277
|
+
logDir: str(w.logDir) || d.logDir,
|
|
278
|
+
routing
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Read the whole config file and return (raw, workers) — the raw record so
|
|
283
|
+
* callers can rewrite it preserving every other section, the parsed+validated
|
|
284
|
+
* workers section. Unreadable config throws (readJsonStrict), missing is fine.
|
|
285
|
+
*/
|
|
286
|
+
function readWorkersSection() {
|
|
287
|
+
const raw = readJsonStrict(CONFIG_FILE, "~/.config/pai/config.json");
|
|
288
|
+
return {
|
|
289
|
+
raw,
|
|
290
|
+
workers: parseWorkersConfig(raw.workers)
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
/** Write the workers section back into the config file, atomically. */
|
|
294
|
+
function writeWorkersSection(raw, workers) {
|
|
295
|
+
raw.workers = workers;
|
|
296
|
+
writeJsonAtomic(CONFIG_FILE, raw, { label: "~/.config/pai/config.json" });
|
|
297
|
+
}
|
|
298
|
+
/** Expand a leading ~ (config values are written with `~` to stay portable). */
|
|
299
|
+
function expandHome(p) {
|
|
300
|
+
if (p === "~" || p.startsWith("~/")) return join(homedir(), p.slice(1));
|
|
301
|
+
return p;
|
|
302
|
+
}
|
|
303
|
+
function assertProviderRunnable(name, p) {
|
|
304
|
+
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.`);
|
|
305
|
+
}
|
|
306
|
+
/** Where a provider's key file lives, or null. Shared by run + test + add. */
|
|
307
|
+
function providerKeyPath(p) {
|
|
308
|
+
return p.keyFile ? expandHome(p.keyFile) : null;
|
|
309
|
+
}
|
|
310
|
+
const DEFAULT_CONTEXT_WINDOW = 2e5;
|
|
311
|
+
/** Context window used by the meter when the init event carries none. */
|
|
312
|
+
function providerContextWindow(p) {
|
|
313
|
+
return p.contextWindow ?? DEFAULT_CONTEXT_WINDOW;
|
|
314
|
+
}
|
|
315
|
+
/** Directory under which inline keys (MCP `key` field) are stored, 0600. */
|
|
316
|
+
function keysDir() {
|
|
317
|
+
return join(homedir(), ".config", "pai", "keys");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/workers/paths.ts
|
|
322
|
+
/**
|
|
323
|
+
* paths.ts — where worker run artefacts live.
|
|
324
|
+
*
|
|
325
|
+
* Everything a run writes (event mirror, status file, pane registry, routing
|
|
326
|
+
* state, the empty MCP config) sits under one logDir so the whole tree is
|
|
327
|
+
* disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.
|
|
328
|
+
*/
|
|
329
|
+
/** Absolute logDir for the given config. */
|
|
330
|
+
function workersLogDir(config) {
|
|
331
|
+
return expandHome(config.logDir);
|
|
332
|
+
}
|
|
333
|
+
function statusPath(logDir, id) {
|
|
334
|
+
return join(logDir, `${id}.status`);
|
|
335
|
+
}
|
|
336
|
+
function eventsPath(logDir, id) {
|
|
337
|
+
return join(logDir, `${id}.jsonl`);
|
|
338
|
+
}
|
|
339
|
+
function ledgerPath(logDir) {
|
|
340
|
+
return join(logDir, "ledger.log");
|
|
341
|
+
}
|
|
342
|
+
function routingStatePath(logDir) {
|
|
343
|
+
return join(logDir, "routing-state.json");
|
|
344
|
+
}
|
|
345
|
+
function panesDir(logDir) {
|
|
346
|
+
return join(logDir, "panes");
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* The strict empty MCP config handed to headless workers. Written into the
|
|
350
|
+
* logDir on demand (never into the user's vendor config directory — this is
|
|
351
|
+
* PAI state, not vendor state).
|
|
352
|
+
*/
|
|
353
|
+
function noMcpConfigPath(logDir) {
|
|
354
|
+
return join(logDir, "no-mcp.json");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/workers/args.ts
|
|
359
|
+
function parseRunnerArgs(argv) {
|
|
360
|
+
let prompt = null;
|
|
361
|
+
let outputFormat = "text";
|
|
362
|
+
const rest = [];
|
|
363
|
+
let headless = false;
|
|
364
|
+
let callerModel = false;
|
|
365
|
+
let callerMcpConfig = false;
|
|
366
|
+
let callerSystemPrompt = false;
|
|
367
|
+
const mcp = [];
|
|
368
|
+
let i = 0;
|
|
369
|
+
while (i < argv.length) {
|
|
370
|
+
const a = argv[i];
|
|
371
|
+
if (a === "-p" || a === "--print") {
|
|
372
|
+
headless = true;
|
|
373
|
+
rest.push(a);
|
|
374
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
375
|
+
prompt = argv[i + 1];
|
|
376
|
+
rest.push(prompt);
|
|
377
|
+
i += 1;
|
|
378
|
+
}
|
|
379
|
+
} else if (a === "--output-format") {
|
|
380
|
+
const v = argv[i + 1];
|
|
381
|
+
if (v === "json" || v === "stream-json") outputFormat = v;
|
|
382
|
+
i += 1;
|
|
383
|
+
} else if (a.startsWith("--output-format=")) {
|
|
384
|
+
const v = a.slice(16);
|
|
385
|
+
if (v === "json" || v === "stream-json") outputFormat = v;
|
|
386
|
+
} else if (a === "--verbose") {} else if (a === "--mcp") {
|
|
387
|
+
const v = argv[i + 1];
|
|
388
|
+
if (v !== void 0 && !v.startsWith("-")) {
|
|
389
|
+
mcp.push(v);
|
|
390
|
+
i += 1;
|
|
391
|
+
}
|
|
392
|
+
} else if (a.startsWith("--mcp=")) mcp.push(a.slice(6));
|
|
393
|
+
else {
|
|
394
|
+
if (a === "--model") callerModel = true;
|
|
395
|
+
if (a.startsWith("--model=")) callerModel = true;
|
|
396
|
+
if (a === "--mcp-config") callerMcpConfig = true;
|
|
397
|
+
if (a.startsWith("--mcp-config=")) callerMcpConfig = true;
|
|
398
|
+
if (a === "--append-system-prompt") callerSystemPrompt = true;
|
|
399
|
+
if (a.startsWith("--append-system-prompt=")) callerSystemPrompt = true;
|
|
400
|
+
if (prompt === null && !a.startsWith("-") && rest.length > 0 && (rest[rest.length - 1] === "-p" || rest[rest.length - 1] === "--print")) prompt = a;
|
|
401
|
+
rest.push(a);
|
|
402
|
+
}
|
|
403
|
+
i += 1;
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
prompt,
|
|
407
|
+
outputFormat,
|
|
408
|
+
rest,
|
|
409
|
+
headless,
|
|
410
|
+
callerModel,
|
|
411
|
+
callerMcpConfig,
|
|
412
|
+
callerSystemPrompt,
|
|
413
|
+
mcp
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Turn `-p "<prompt>"` into bare `-p` (same for --print): for stream-json
|
|
418
|
+
* stdin runs the prompt moves to the first user message on stdin, so the
|
|
419
|
+
* value must not stay on the command line. Only prompt values directly
|
|
420
|
+
* following the flag are touched; everything else passes through.
|
|
421
|
+
*/
|
|
422
|
+
function stripPromptValues(argv) {
|
|
423
|
+
const out = [];
|
|
424
|
+
for (let i = 0; i < argv.length; i++) {
|
|
425
|
+
const a = argv[i];
|
|
426
|
+
const isPrint = a === "-p" || a === "--print";
|
|
427
|
+
out.push(a);
|
|
428
|
+
if (isPrint && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i += 1;
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
/** Collapse whitespace and cut to n chars with an ellipsis (label rendering). */
|
|
433
|
+
function shortText(s, n) {
|
|
434
|
+
const t = String(s ?? "").split(/\s+/).filter(Boolean).join(" ");
|
|
435
|
+
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
//#endregion
|
|
439
|
+
//#region src/workers/ledger.ts
|
|
440
|
+
/**
|
|
441
|
+
* ledger.ts — the routing ledger, one append-only log for every worker event.
|
|
442
|
+
*
|
|
443
|
+
* Line shapes (whitespace-aligned so `tail` reads as a table):
|
|
444
|
+
*
|
|
445
|
+
* 2026-09-17 12:00:00 WORKER-START id=<id> provider=<p> mode=headless model=<m> cwd=<cwd> label=<label>
|
|
446
|
+
* 2026-09-17 12:01:00 WORKER-END id=<id> provider=<p> mode=headless model=<m> rc=0 secs=60 turns=3 tools=8 label=<label>
|
|
447
|
+
* 2026-09-17 12:00:20 WORKER-REROUTE from=<a> to=<b> reason=quota
|
|
448
|
+
* 2026-09-17 12:00:00 DENIED-ANTHROPIC-AGENT cwd=<cwd> desc=<desc>
|
|
449
|
+
* 2026-09-17 12:00:00 ALLOWED-ANTHROPIC-AGENT cwd=<cwd> desc=<desc>
|
|
450
|
+
*
|
|
451
|
+
* `pai worker log` (and glm-log before it) counts these; keep the tags stable.
|
|
452
|
+
*/
|
|
453
|
+
function ledgerStamp(d = /* @__PURE__ */ new Date()) {
|
|
454
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
455
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
456
|
+
}
|
|
457
|
+
/** Parse `2026-09-17 12:00:00 TAG key=value …` into its parts. */
|
|
458
|
+
function parseLedgerLine(line) {
|
|
459
|
+
const m = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\S+)(?: (.*))?$/);
|
|
460
|
+
if (!m) return null;
|
|
461
|
+
const fields = {};
|
|
462
|
+
for (const part of (m[3] ?? "").split(" ")) {
|
|
463
|
+
if (!part) continue;
|
|
464
|
+
const eq = part.indexOf("=");
|
|
465
|
+
if (eq <= 0) continue;
|
|
466
|
+
fields[part.slice(0, eq)] = part.slice(eq + 1);
|
|
467
|
+
}
|
|
468
|
+
return {
|
|
469
|
+
stamp: m[1],
|
|
470
|
+
event: m[2],
|
|
471
|
+
fields
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/** Append one ledger line. `kv` order is the caller's; values are flattened. */
|
|
475
|
+
function appendLedger(path, event, kv, now = /* @__PURE__ */ new Date()) {
|
|
476
|
+
const parts = [];
|
|
477
|
+
for (const [k, v] of Object.entries(kv)) {
|
|
478
|
+
if (v === void 0 || v === null) continue;
|
|
479
|
+
parts.push(`${k}=${String(v).replace(/\s+/g, " ")}`);
|
|
480
|
+
}
|
|
481
|
+
const dir = dirname(path);
|
|
482
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
483
|
+
const tail = parts.length ? " " + parts.join(" ") : "";
|
|
484
|
+
appendFileSync(path, `${ledgerStamp(now)} ${event}${tail}\n`, "utf8");
|
|
485
|
+
}
|
|
486
|
+
/** The counts `pai worker log` prints, over today's lines or the whole file. */
|
|
487
|
+
function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()) {
|
|
488
|
+
if (!existsSync(path)) return null;
|
|
489
|
+
const text = readFileSync(path, "utf8");
|
|
490
|
+
const today = ledgerStamp(now).slice(0, 10);
|
|
491
|
+
const lines = text.split("\n").filter((l) => l.trim());
|
|
492
|
+
const parsed = (scope === "all" ? lines : lines.filter((l) => l.startsWith(today))).map(parseLedgerLine).filter((x) => x !== null);
|
|
493
|
+
const count = (ev) => parsed.filter((p) => p.event === ev).length;
|
|
494
|
+
const ends = parsed.filter((p) => p.event === "WORKER-END");
|
|
495
|
+
const ok = ends.filter((p) => p.fields.rc === "0").length;
|
|
496
|
+
return {
|
|
497
|
+
scope: scope === "all" ? "all time" : `today ${today}`,
|
|
498
|
+
started: count("WORKER-START"),
|
|
499
|
+
endedOk: ok,
|
|
500
|
+
endedFailed: ends.length - ok,
|
|
501
|
+
denied: count("DENIED-ANTHROPIC-AGENT"),
|
|
502
|
+
allowed: count("ALLOWED-ANTHROPIC-AGENT"),
|
|
503
|
+
reroutes: count("WORKER-REROUTE"),
|
|
504
|
+
lastLines: lines.slice(-lastN)
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
//#endregion
|
|
509
|
+
//#region src/workers/status.ts
|
|
510
|
+
/**
|
|
511
|
+
* status.ts — the live per-worker status file.
|
|
512
|
+
*
|
|
513
|
+
* One JSON file per run, rewritten atomically on every turn, read by `ps`,
|
|
514
|
+
* `follow`, the status line and the pane logic. Same field set the Python
|
|
515
|
+
* runner wrote, plus `provider` (routing is multi-provider now) and `session`
|
|
516
|
+
* (the AIBroker identity of the launching session, see scope.ts).
|
|
517
|
+
*/
|
|
518
|
+
/** Context-meter percentage 0–100, null when the numbers are missing. */
|
|
519
|
+
function contextPercent(s) {
|
|
520
|
+
if (!s.contextTokens || !s.contextWindow) return null;
|
|
521
|
+
return Math.round(s.contextTokens / s.contextWindow * 100);
|
|
522
|
+
}
|
|
523
|
+
/** Timestamp format shared by status files and the ledger. */
|
|
524
|
+
function nowStamp(d = /* @__PURE__ */ new Date()) {
|
|
525
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
526
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
527
|
+
}
|
|
528
|
+
function newWorkerId(d = /* @__PURE__ */ new Date(), pid = process.pid) {
|
|
529
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
530
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
|
|
531
|
+
}
|
|
532
|
+
/** Write status atomically (temp + rename) and stamp `updated`. */
|
|
533
|
+
function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
|
|
534
|
+
status.updated = nowStamp(d);
|
|
535
|
+
const path = statusPath(logDir, status.id);
|
|
536
|
+
const tmp = `${path}.tmp`;
|
|
537
|
+
writeFileSync(tmp, JSON.stringify(status), "utf8");
|
|
538
|
+
renameSync(tmp, path);
|
|
539
|
+
}
|
|
540
|
+
/** Load every status file in the logDir, oldest id first, skipping damage. */
|
|
541
|
+
function loadStatuses(logDir) {
|
|
542
|
+
if (!existsSync(logDir)) return [];
|
|
543
|
+
const out = [];
|
|
544
|
+
for (const name of readdirSync(logDir).sort()) {
|
|
545
|
+
if (!name.endsWith(".status")) continue;
|
|
546
|
+
try {
|
|
547
|
+
out.push(JSON.parse(readFileSync(join(logDir, name), "utf8")));
|
|
548
|
+
} catch {}
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
function loadStatus(logDir, id) {
|
|
553
|
+
const path = statusPath(logDir, id);
|
|
554
|
+
if (!existsSync(path)) return null;
|
|
555
|
+
try {
|
|
556
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
557
|
+
} catch {
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function alive(pid) {
|
|
562
|
+
if (!pid || pid <= 0) return false;
|
|
563
|
+
try {
|
|
564
|
+
process.kill(pid, 0);
|
|
565
|
+
return true;
|
|
566
|
+
} catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** "42s" under 90s, "7m" above — the coarse age the table and bar show. */
|
|
571
|
+
function ageOf(ts, now = /* @__PURE__ */ new Date()) {
|
|
572
|
+
const t = Date.parse(ts.replace(" ", "T"));
|
|
573
|
+
if (Number.isNaN(t)) return "?";
|
|
574
|
+
const s = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
|
|
575
|
+
return s < 90 ? `${s}s` : `${Math.floor(s / 60)}m`;
|
|
576
|
+
}
|
|
577
|
+
/** One-line description of a tool call, e.g. "Bash: npm test". */
|
|
578
|
+
function describeTool(name, inp) {
|
|
579
|
+
if (typeof inp !== "object" || inp === null) return name;
|
|
580
|
+
const i = inp;
|
|
581
|
+
const get = (k) => typeof i[k] === "string" ? i[k] : "";
|
|
582
|
+
if (name === "Bash") return `Bash: ${shortText(get("command"), 70)}`;
|
|
583
|
+
if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return `${name}: ${get("file_path").split("/").pop() ?? ""}`;
|
|
584
|
+
if (name === "Grep" || name === "Glob") return `${name}: ${shortText(get("pattern"), 50)}`;
|
|
585
|
+
if (name === "WebSearch") return `${name}: ${shortText(get("query"), 50)}`;
|
|
586
|
+
if (name === "WebFetch") return `${name}: ${shortText(get("url"), 60)}`;
|
|
587
|
+
return name;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
//#endregion
|
|
591
|
+
//#region src/workers/scope.ts
|
|
592
|
+
/**
|
|
593
|
+
* scope.ts — which workers "belong" to the terminal asking about them.
|
|
594
|
+
*
|
|
595
|
+
* Two identities, in priority order:
|
|
596
|
+
*
|
|
597
|
+
* 1. AIBroker's session registry (~/.aibroker/session-names.json): iTerm
|
|
598
|
+
* session UUID → session name. The launching session's UUID is stored in
|
|
599
|
+
* each worker's status file as `session.id`, so every pane of a named
|
|
600
|
+
* session sees its workers — regardless of tab layout.
|
|
601
|
+
* 2. The iTerm tab key (`w<window>t<tab>` prefix of ITERM_SESSION_ID): the
|
|
602
|
+
* original heuristic, kept as the fallback when no registry entry
|
|
603
|
+
* matches (AIBroker absent, unnamed session, non-iTerm terminal).
|
|
604
|
+
*
|
|
605
|
+
* If neither is available, viewers fall back to "all workers".
|
|
606
|
+
*/
|
|
607
|
+
const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
|
|
608
|
+
/**
|
|
609
|
+
* `w<window>t<tab>` prefix of an iTerm session id, "" if empty or malformed.
|
|
610
|
+
* Panes split from the same tab share this prefix and differ only in `p<n>`.
|
|
611
|
+
*/
|
|
612
|
+
function tabKey(term) {
|
|
613
|
+
if (!term) return "";
|
|
614
|
+
const head = term.split("p", 1)[0];
|
|
615
|
+
const parts = head.slice(1).split("t");
|
|
616
|
+
if (head.startsWith("w") && parts.length === 2 && parts.every((p) => /^\d+$/.test(p))) return head;
|
|
617
|
+
return "";
|
|
618
|
+
}
|
|
619
|
+
/** Tab key of the iTerm tab this process runs in, "" if not inside iTerm2. */
|
|
620
|
+
function currentTabKey(env = process.env) {
|
|
621
|
+
return tabKey(env.ITERM_SESSION_ID ?? "");
|
|
622
|
+
}
|
|
623
|
+
/** The iTerm UUID part of an ITERM_SESSION_ID (after the last colon). */
|
|
624
|
+
function itermUuid(term) {
|
|
625
|
+
if (!term) return "";
|
|
626
|
+
return term.split(":").pop() ?? "";
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Resolve the AIBroker session for an ITERM_SESSION_ID. Reads the persistent
|
|
630
|
+
* name registry (the same store `aibroker_rename` writes); returns null when
|
|
631
|
+
* AIBroker is absent or the session is not in it — callers then use the tab
|
|
632
|
+
* key, which is the pre-AIBroker behaviour.
|
|
633
|
+
*/
|
|
634
|
+
function resolveSession(term, registryPath = AIBROKER_REGISTRY) {
|
|
635
|
+
const uuid = itermUuid(term);
|
|
636
|
+
if (!uuid) return null;
|
|
637
|
+
let names;
|
|
638
|
+
try {
|
|
639
|
+
if (!existsSync(registryPath)) return null;
|
|
640
|
+
names = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
641
|
+
} catch {
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
const name = names[uuid];
|
|
645
|
+
if (typeof name !== "string" || !name) return null;
|
|
646
|
+
return {
|
|
647
|
+
id: uuid,
|
|
648
|
+
name
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* True when `worker` was launched from `term` (the terminal asking about it).
|
|
653
|
+
* Session-id match first; the tab key only when no registry entry matched —
|
|
654
|
+
* a session without a name keeps the tab-scoped behaviour it always had.
|
|
655
|
+
*/
|
|
656
|
+
function workerInScope(worker, term) {
|
|
657
|
+
if (!term) return false;
|
|
658
|
+
const uuid = itermUuid(term);
|
|
659
|
+
if (worker.session?.id && uuid) return worker.session.id === uuid;
|
|
660
|
+
return tabKey(worker.term) === tabKey(term) && tabKey(term) !== "";
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Registry key for pane files: the session id when AIBroker knows this
|
|
664
|
+
* terminal, else the tab key, else the raw iTerm UUID.
|
|
665
|
+
*/
|
|
666
|
+
function scopeKey(term) {
|
|
667
|
+
const session = resolveSession(term);
|
|
668
|
+
if (session) return session.id;
|
|
669
|
+
return currentTabKey() || itermUuid(term);
|
|
670
|
+
}
|
|
671
|
+
/** `[Name]` when the worker has an AIBroker session name, else "". */
|
|
672
|
+
function sessionTag(worker) {
|
|
673
|
+
return worker.session?.name ? `[${worker.session.name}]` : "";
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
//#endregion
|
|
677
|
+
//#region src/workers/routing.ts
|
|
678
|
+
/**
|
|
679
|
+
* routing.ts — provider selection: flag > role > active (possibly "auto").
|
|
680
|
+
*
|
|
681
|
+
* Auto-routing walks `workers.routing.order` and takes the first provider
|
|
682
|
+
* that is enabled, out of cooldown, and (when it defines a quotaProbe) under
|
|
683
|
+
* its quotaSkipAt threshold. A run that dies of a quota/rate error puts its
|
|
684
|
+
* provider in cooldown for cooldownMinutes; when that happens before the
|
|
685
|
+
* first tool call and retryOnQuota is set, the runner restarts the same task
|
|
686
|
+
* on the next provider (ledger: WORKER-REROUTE).
|
|
687
|
+
*
|
|
688
|
+
* An explicit --provider or --role always bypasses all of this.
|
|
689
|
+
*/
|
|
690
|
+
const QUOTA_SKIP_DEFAULT = 95;
|
|
691
|
+
const PROBE_TIMEOUT_MS = 1e4;
|
|
692
|
+
function readRoutingState(logDir) {
|
|
693
|
+
const path = routingStatePath(logDir);
|
|
694
|
+
if (!existsSync(path)) return { cooldowns: {} };
|
|
695
|
+
try {
|
|
696
|
+
return { cooldowns: JSON.parse(readFileSync(path, "utf8")).cooldowns ?? {} };
|
|
697
|
+
} catch {
|
|
698
|
+
return { cooldowns: {} };
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function writeRoutingState(logDir, state) {
|
|
702
|
+
const path = routingStatePath(logDir);
|
|
703
|
+
const dir = dirname(path);
|
|
704
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
705
|
+
const tmp = `${path}.tmp`;
|
|
706
|
+
writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
707
|
+
renameSync(tmp, path);
|
|
708
|
+
}
|
|
709
|
+
function cooldownRemaining(state, provider, now = /* @__PURE__ */ new Date()) {
|
|
710
|
+
const end = state.cooldowns[provider];
|
|
711
|
+
if (!end) return 0;
|
|
712
|
+
const ms = Date.parse(end) - now.getTime();
|
|
713
|
+
return ms > 0 ? ms : 0;
|
|
714
|
+
}
|
|
715
|
+
function setCooldown(logDir, provider, minutes, now = /* @__PURE__ */ new Date()) {
|
|
716
|
+
const state = readRoutingState(logDir);
|
|
717
|
+
state.cooldowns[provider] = new Date(now.getTime() + minutes * 6e4).toISOString();
|
|
718
|
+
writeRoutingState(logDir, state);
|
|
719
|
+
}
|
|
720
|
+
function clearCooldown(logDir, provider) {
|
|
721
|
+
const state = readRoutingState(logDir);
|
|
722
|
+
if (!(provider in state.cooldowns)) return;
|
|
723
|
+
delete state.cooldowns[provider];
|
|
724
|
+
writeRoutingState(logDir, state);
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Run a provider's quotaProbe and return its percentage (0–100), or null when
|
|
728
|
+
* there is no probe or it printed nothing usable. A probe must never break
|
|
729
|
+
* routing: failures read as "unknown", not as "full".
|
|
730
|
+
*/
|
|
731
|
+
function probeQuota(provider) {
|
|
732
|
+
if (!provider.quotaProbe) return null;
|
|
733
|
+
try {
|
|
734
|
+
const m = execFileSync("/bin/sh", ["-c", provider.quotaProbe], {
|
|
735
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
736
|
+
encoding: "utf8",
|
|
737
|
+
stdio: [
|
|
738
|
+
"ignore",
|
|
739
|
+
"pipe",
|
|
740
|
+
"ignore"
|
|
741
|
+
]
|
|
742
|
+
}).match(/(\d+(?:\.\d+)?)/);
|
|
743
|
+
if (!m) return null;
|
|
744
|
+
return Math.min(100, Math.max(0, Math.round(parseFloat(m[1]))));
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
function quotaSkipThreshold(provider) {
|
|
750
|
+
return provider.quotaSkipAt ?? QUOTA_SKIP_DEFAULT;
|
|
751
|
+
}
|
|
752
|
+
/** Does this provider exceed its quota threshold right now? */
|
|
753
|
+
function quotaExceeded(provider) {
|
|
754
|
+
const used = probeQuota(provider);
|
|
755
|
+
return used !== null && used >= quotaSkipThreshold(provider);
|
|
756
|
+
}
|
|
757
|
+
var NoProviderError = class extends WorkersConfigError {};
|
|
758
|
+
function mustExist(config, name) {
|
|
759
|
+
const p = config.providers[name];
|
|
760
|
+
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>`);
|
|
761
|
+
return p;
|
|
762
|
+
}
|
|
763
|
+
function mustBeRunnable(name, p) {
|
|
764
|
+
if (!p.enabled) throw new NoProviderError(`provider "${name}" is disabled. Enable it with: pai worker providers enable ${name}`);
|
|
765
|
+
return p;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Resolve which provider (and model alias) a run uses.
|
|
769
|
+
*
|
|
770
|
+
* @param flagProvider --provider value, highest precedence
|
|
771
|
+
* @param role --role value, looked up in workers.roles
|
|
772
|
+
*/
|
|
773
|
+
function resolveTarget(config, logDir, opts = {}) {
|
|
774
|
+
if (opts.flagProvider) return {
|
|
775
|
+
providerName: opts.flagProvider,
|
|
776
|
+
provider: mustBeRunnable(opts.flagProvider, mustExist(config, opts.flagProvider)),
|
|
777
|
+
modelAlias: null,
|
|
778
|
+
roleMcp: null,
|
|
779
|
+
via: "flag"
|
|
780
|
+
};
|
|
781
|
+
if (opts.role) {
|
|
782
|
+
const target = config.roles[opts.role];
|
|
783
|
+
if (!target) throw new NoProviderError(`no role named "${opts.role}". Defined: ${Object.keys(config.roles).join(", ") || "(none)"}.\nSet one with: pai worker roles set ${opts.role}=<provider[/alias]>`);
|
|
784
|
+
const name = typeof target === "string" ? target.split("/")[0] : target.provider;
|
|
785
|
+
const alias = typeof target === "string" ? target.split("/")[1] : void 0;
|
|
786
|
+
const roleMcp = typeof target === "string" ? null : target.mcp ?? null;
|
|
787
|
+
return {
|
|
788
|
+
providerName: name,
|
|
789
|
+
provider: mustBeRunnable(name, mustExist(config, name)),
|
|
790
|
+
modelAlias: alias ?? null,
|
|
791
|
+
roleMcp,
|
|
792
|
+
via: "role"
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (config.active !== "auto") {
|
|
796
|
+
const name = config.active;
|
|
797
|
+
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>)");
|
|
798
|
+
return {
|
|
799
|
+
providerName: name,
|
|
800
|
+
provider: mustBeRunnable(name, mustExist(config, name)),
|
|
801
|
+
modelAlias: null,
|
|
802
|
+
roleMcp: null,
|
|
803
|
+
via: "active"
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const state = readRoutingState(logDir);
|
|
807
|
+
const skipped = [];
|
|
808
|
+
for (const name of config.routing.order) {
|
|
809
|
+
const p = config.providers[name];
|
|
810
|
+
if (!p || !p.enabled) continue;
|
|
811
|
+
if (cooldownRemaining(state, name) > 0) {
|
|
812
|
+
skipped.push(`${name}(cooldown)`);
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (quotaExceeded(p)) {
|
|
816
|
+
skipped.push(`${name}(quota)`);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
return {
|
|
820
|
+
providerName: name,
|
|
821
|
+
provider: p,
|
|
822
|
+
modelAlias: null,
|
|
823
|
+
roleMcp: null,
|
|
824
|
+
via: "auto"
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
throw new NoProviderError(`auto-routing found no usable provider (order: [${config.routing.order.join(", ")}]${skipped.length ? `; skipped: ${skipped.join(", ")}` : ""}).\nClear a cooldown with: pai worker providers enable <name>`);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Next provider after `from` in auto order, applying the same filters.
|
|
831
|
+
* Used by rerouting; null when the order is exhausted.
|
|
832
|
+
*/
|
|
833
|
+
function nextAutoProvider(config, logDir, from, now = /* @__PURE__ */ new Date()) {
|
|
834
|
+
const state = readRoutingState(logDir);
|
|
835
|
+
const order = config.routing.order;
|
|
836
|
+
const start = order.indexOf(from);
|
|
837
|
+
for (let i = start + 1; i < order.length; i++) {
|
|
838
|
+
const name = order[i];
|
|
839
|
+
const p = config.providers[name];
|
|
840
|
+
if (!p || !p.enabled) continue;
|
|
841
|
+
const end = state.cooldowns[name];
|
|
842
|
+
if (end && Date.parse(end) > now.getTime()) continue;
|
|
843
|
+
if (quotaExceeded(p)) continue;
|
|
844
|
+
return name;
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Was this failure a quota/rate failure? Detected from the result text the
|
|
850
|
+
* endpoint produced (HTTP status is not visible in the stream events).
|
|
851
|
+
*/
|
|
852
|
+
function isQuotaFailure(resultText) {
|
|
853
|
+
return /usage limit reached|rate limit|quota/i.test(resultText);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
//#endregion
|
|
857
|
+
//#region src/workers/pane.ts
|
|
858
|
+
/**
|
|
859
|
+
* pane.ts — the per-worker follow pane in iTerm2.
|
|
860
|
+
*
|
|
861
|
+
* One small-font pane per worker, stacked in a right-hand column: the first
|
|
862
|
+
* worker of a scope splits the launching session vertically (right, ~40% of
|
|
863
|
+
* the columns), every further one splits the lowest live worker pane
|
|
864
|
+
* horizontally, so panes stack top to bottom. Each pane runs
|
|
865
|
+
* `pai worker follow <id> --auto-exit <n>` under the `pai-worker` dynamic
|
|
866
|
+
* profile (Close Sessions On End), so panes disappear by themselves.
|
|
867
|
+
*
|
|
868
|
+
* Panes are tracked per scope (AIBroker session id, else tab key) in
|
|
869
|
+
* <logDir>/panes/<key>.json, keyed by iTerm session unique id, newest first —
|
|
870
|
+
* the split lands below the lowest live worker pane.
|
|
871
|
+
*
|
|
872
|
+
* The dynamic profile's font is the family of iTerm's DEFAULT profile (the
|
|
873
|
+
* `Default Bookmark Guid` entry in New Bookmarks) at workers.pane.fontSize —
|
|
874
|
+
* never the launching session's profile, which the Python version did and
|
|
875
|
+
* which produced iTerm's "unknown parent name" dialog whenever the two
|
|
876
|
+
* differed. `Dynamic Profile Parent Name` is only written when that name
|
|
877
|
+
* actually exists in New Bookmarks; when iTerm's preferences cannot be read
|
|
878
|
+
* the profile is still written, with font Menlo-Regular and no parent.
|
|
879
|
+
*/
|
|
880
|
+
const PROFILE_NAME = "pai-worker";
|
|
881
|
+
/** Where the dynamic profile lives (env override keeps tests off the real one). */
|
|
882
|
+
function dynamicProfilePath() {
|
|
883
|
+
return process.env.PAI_WORKER_PROFILE ?? join(homedir(), "Library", "Application Support", "iTerm2", "DynamicProfiles", "pai-worker.json");
|
|
884
|
+
}
|
|
885
|
+
const WORKER_SPLIT_SCRIPT = `on run(argv)
|
|
886
|
+
set targetID to item 1 of argv
|
|
887
|
+
set candList to item 2 of argv
|
|
888
|
+
set followCmd to item 3 of argv
|
|
889
|
+
set profileName to item 4 of argv
|
|
890
|
+
tell application id "com.googlecode.iterm2"
|
|
891
|
+
if not running then return "notrunning"
|
|
892
|
+
repeat with w in windows
|
|
893
|
+
repeat with t in tabs of w
|
|
894
|
+
repeat with s in sessions of t
|
|
895
|
+
if id of s is targetID then
|
|
896
|
+
set parentCols to missing value
|
|
897
|
+
try
|
|
898
|
+
set parentCols to columns of s
|
|
899
|
+
end try
|
|
900
|
+
set sessIDs to {}
|
|
901
|
+
repeat with other in sessions of t
|
|
902
|
+
set end of sessIDs to (id of other as text)
|
|
903
|
+
end repeat
|
|
904
|
+
set lived to {}
|
|
905
|
+
set splitS to missing value
|
|
906
|
+
repeat with cid in my splitIds(candList)
|
|
907
|
+
set cidText to (cid as text)
|
|
908
|
+
if sessIDs contains cidText then
|
|
909
|
+
set end of lived to cidText
|
|
910
|
+
if splitS is missing value then
|
|
911
|
+
set splitS to first session of t whose id is cidText
|
|
912
|
+
end if
|
|
913
|
+
end if
|
|
914
|
+
end repeat
|
|
915
|
+
if profileName is "" then
|
|
916
|
+
if splitS is missing value then
|
|
917
|
+
tell s
|
|
918
|
+
set newS to split vertically with default profile
|
|
919
|
+
end tell
|
|
920
|
+
else
|
|
921
|
+
tell splitS
|
|
922
|
+
set newS to split horizontally with default profile
|
|
923
|
+
end tell
|
|
924
|
+
end if
|
|
925
|
+
else
|
|
926
|
+
if splitS is missing value then
|
|
927
|
+
tell s
|
|
928
|
+
set newS to split vertically with profile profileName
|
|
929
|
+
end tell
|
|
930
|
+
else
|
|
931
|
+
tell splitS
|
|
932
|
+
set newS to split horizontally with profile profileName
|
|
933
|
+
end tell
|
|
934
|
+
end if
|
|
935
|
+
end if
|
|
936
|
+
tell newS
|
|
937
|
+
write text followCmd
|
|
938
|
+
end tell
|
|
939
|
+
if splitS is missing value and parentCols is not missing value then
|
|
940
|
+
try
|
|
941
|
+
tell newS
|
|
942
|
+
set columns to (round (parentCols * 0.4))
|
|
943
|
+
end tell
|
|
944
|
+
end try
|
|
945
|
+
end if
|
|
946
|
+
set out to ""
|
|
947
|
+
repeat with lid in lived
|
|
948
|
+
set out to out & lid & ","
|
|
949
|
+
end repeat
|
|
950
|
+
return out & "|" & (id of newS as text)
|
|
951
|
+
end if
|
|
952
|
+
end repeat
|
|
953
|
+
end repeat
|
|
954
|
+
end repeat
|
|
955
|
+
end tell
|
|
956
|
+
return "notfound"
|
|
957
|
+
end run
|
|
958
|
+
|
|
959
|
+
on splitIds(s)
|
|
960
|
+
set out to {}
|
|
961
|
+
if s is "" then return out
|
|
962
|
+
set prevDels to AppleScript's text item delimiters
|
|
963
|
+
set AppleScript's text item delimiters to ","
|
|
964
|
+
repeat with part in text items of s
|
|
965
|
+
set end of out to (part as text)
|
|
966
|
+
end repeat
|
|
967
|
+
set AppleScript's text item delimiters to prevDels
|
|
968
|
+
return out
|
|
969
|
+
end splitIds`;
|
|
970
|
+
const TAB_TTYS_SCRIPT = `on run(argv)
|
|
971
|
+
set targetID to item 1 of argv
|
|
972
|
+
tell application id "com.googlecode.iterm2"
|
|
973
|
+
if not running then return "notrunning"
|
|
974
|
+
repeat with w in windows
|
|
975
|
+
repeat with t in tabs of w
|
|
976
|
+
repeat with s in sessions of t
|
|
977
|
+
if id of s is targetID then
|
|
978
|
+
set ttys to {}
|
|
979
|
+
repeat with other in sessions of t
|
|
980
|
+
copy (tty of other) to end of ttys
|
|
981
|
+
end repeat
|
|
982
|
+
return ttys
|
|
983
|
+
end if
|
|
984
|
+
end repeat
|
|
985
|
+
end repeat
|
|
986
|
+
end repeat
|
|
987
|
+
end tell
|
|
988
|
+
return "notfound"
|
|
989
|
+
end run`;
|
|
990
|
+
const SPLIT_SCRIPT = `on run(argv)
|
|
991
|
+
set targetID to item 1 of argv
|
|
992
|
+
set followCmd to item 2 of argv
|
|
993
|
+
tell application id "com.googlecode.iterm2"
|
|
994
|
+
if not running then return "notrunning"
|
|
995
|
+
repeat with w in windows
|
|
996
|
+
repeat with t in tabs of w
|
|
997
|
+
repeat with s in sessions of t
|
|
998
|
+
if id of s is targetID then
|
|
999
|
+
tell s
|
|
1000
|
+
set newS to split vertically with default profile
|
|
1001
|
+
end tell
|
|
1002
|
+
tell newS
|
|
1003
|
+
write text followCmd
|
|
1004
|
+
end tell
|
|
1005
|
+
return "opened"
|
|
1006
|
+
end if
|
|
1007
|
+
end repeat
|
|
1008
|
+
end repeat
|
|
1009
|
+
end repeat
|
|
1010
|
+
end tell
|
|
1011
|
+
return "notfound"
|
|
1012
|
+
end run`;
|
|
1013
|
+
/** Run an AppleScript with argv. Rejects when osascript itself cannot run. */
|
|
1014
|
+
function osascript(script, args) {
|
|
1015
|
+
return new Promise((resolve, reject) => {
|
|
1016
|
+
const proc = spawn("osascript", ["-", ...args], { stdio: [
|
|
1017
|
+
"pipe",
|
|
1018
|
+
"pipe",
|
|
1019
|
+
"pipe"
|
|
1020
|
+
] });
|
|
1021
|
+
let out = "";
|
|
1022
|
+
let err = "";
|
|
1023
|
+
proc.stdout.on("data", (c) => out += c.toString("utf8"));
|
|
1024
|
+
proc.stderr.on("data", (c) => err += c.toString("utf8"));
|
|
1025
|
+
proc.on("error", reject);
|
|
1026
|
+
proc.on("close", () => resolve({
|
|
1027
|
+
stdout: out,
|
|
1028
|
+
stderr: err
|
|
1029
|
+
}));
|
|
1030
|
+
proc.stdin.write(script);
|
|
1031
|
+
proc.stdin.end();
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
function psOutput$1(format) {
|
|
1035
|
+
try {
|
|
1036
|
+
return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
|
|
1037
|
+
} catch {
|
|
1038
|
+
return "";
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
/** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
|
|
1042
|
+
function followTtys() {
|
|
1043
|
+
const ttys = /* @__PURE__ */ new Set();
|
|
1044
|
+
for (const line of psOutput$1("tty=,command=").split("\n")) {
|
|
1045
|
+
const parts = line.trim().split(/\s+/, 2);
|
|
1046
|
+
if (parts.length < 2 || parts[0] === "ps") continue;
|
|
1047
|
+
if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
|
|
1048
|
+
if (parts[0].startsWith("ttys")) ttys.add(`/dev/${parts[0]}`);
|
|
1049
|
+
}
|
|
1050
|
+
return ttys;
|
|
1051
|
+
}
|
|
1052
|
+
/** True while a `follow <wid>` process runs (its pane shows that worker). */
|
|
1053
|
+
function workerPaneOpen(wid) {
|
|
1054
|
+
const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
1055
|
+
return psOutput$1("command=").split("\n").some((ln) => pat.test(ln));
|
|
1056
|
+
}
|
|
1057
|
+
/** plutil's message for an ExecFileSync failure, else the exception text. */
|
|
1058
|
+
function toolErr(e) {
|
|
1059
|
+
const err = e;
|
|
1060
|
+
return ((typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e)).trim();
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Read New Bookmarks and Default Bookmark Guid from an iTerm preferences
|
|
1064
|
+
* plist. Key-scoped `plutil -extract`, never a whole-file JSON conversion:
|
|
1065
|
+
* real iTerm preferences carry `<date>` objects (SULastCheckTime & friends)
|
|
1066
|
+
* which `plutil -convert json` rejects outright — "Invalid object in plist
|
|
1067
|
+
* for JSON format" — so the whole-file read always failed and the dynamic
|
|
1068
|
+
* profile was never written.
|
|
1069
|
+
*/
|
|
1070
|
+
function readItermPlist(plistPath) {
|
|
1071
|
+
let bookmarks = [];
|
|
1072
|
+
try {
|
|
1073
|
+
const out = execFileSync("plutil", [
|
|
1074
|
+
"-extract",
|
|
1075
|
+
"New Bookmarks",
|
|
1076
|
+
"json",
|
|
1077
|
+
"-o",
|
|
1078
|
+
"-",
|
|
1079
|
+
plistPath
|
|
1080
|
+
], {
|
|
1081
|
+
encoding: "utf8",
|
|
1082
|
+
timeout: 1e4
|
|
1083
|
+
});
|
|
1084
|
+
const parsed = JSON.parse(out);
|
|
1085
|
+
if (Array.isArray(parsed)) bookmarks = parsed;
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
return {
|
|
1088
|
+
bookmarks: [],
|
|
1089
|
+
defaultGuid: null,
|
|
1090
|
+
error: `extracting New Bookmarks: ${toolErr(e)}`
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
let defaultGuid = null;
|
|
1094
|
+
try {
|
|
1095
|
+
defaultGuid = execFileSync("plutil", [
|
|
1096
|
+
"-extract",
|
|
1097
|
+
"Default Bookmark Guid",
|
|
1098
|
+
"raw",
|
|
1099
|
+
"-o",
|
|
1100
|
+
"-",
|
|
1101
|
+
plistPath
|
|
1102
|
+
], {
|
|
1103
|
+
encoding: "utf8",
|
|
1104
|
+
timeout: 1e4
|
|
1105
|
+
}).trim() || null;
|
|
1106
|
+
} catch {}
|
|
1107
|
+
return {
|
|
1108
|
+
bookmarks,
|
|
1109
|
+
defaultGuid,
|
|
1110
|
+
error: null
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
/** iTerm's exported preferences, via a temp plist (no shell pipe involved). */
|
|
1114
|
+
function itermPrefs() {
|
|
1115
|
+
let dir = null;
|
|
1116
|
+
try {
|
|
1117
|
+
dir = mkdtempSync(join(tmpdir(), "pai-iterm-"));
|
|
1118
|
+
const plist = join(dir, "iterm2.plist");
|
|
1119
|
+
execFileSync("defaults", [
|
|
1120
|
+
"export",
|
|
1121
|
+
"com.googlecode.iterm2",
|
|
1122
|
+
plist
|
|
1123
|
+
], {
|
|
1124
|
+
encoding: "utf8",
|
|
1125
|
+
timeout: 1e4
|
|
1126
|
+
});
|
|
1127
|
+
return readItermPlist(plist);
|
|
1128
|
+
} catch (e) {
|
|
1129
|
+
return {
|
|
1130
|
+
bookmarks: [],
|
|
1131
|
+
defaultGuid: null,
|
|
1132
|
+
error: `defaults export: ${toolErr(e)}`
|
|
1133
|
+
};
|
|
1134
|
+
} finally {
|
|
1135
|
+
if (dir) try {
|
|
1136
|
+
rmSync(dir, {
|
|
1137
|
+
recursive: true,
|
|
1138
|
+
force: true
|
|
1139
|
+
});
|
|
1140
|
+
} catch {}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/** The New Bookmarks entry of that profile name, null if absent. */
|
|
1144
|
+
function bookmarkFrom(read, named) {
|
|
1145
|
+
return read.bookmarks.find((b) => b.Name === named) ?? null;
|
|
1146
|
+
}
|
|
1147
|
+
/** The bookmark iTerm marks default, null when it is missing or unreadable. */
|
|
1148
|
+
function defaultBookmarkFrom(read) {
|
|
1149
|
+
return read.defaultGuid ? read.bookmarks.find((b) => b.Guid === read.defaultGuid) ?? null : null;
|
|
1150
|
+
}
|
|
1151
|
+
/** The default profile, read fresh from iTerm's preferences. */
|
|
1152
|
+
function defaultBookmark() {
|
|
1153
|
+
return defaultBookmarkFrom(itermPrefs());
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* The pane profile's font: the family of iTerm's default profile at
|
|
1157
|
+
* `fontSize` points ("MesloLGLNFM-Regular 18" + 13 → "MesloLGLNFM-Regular 13"),
|
|
1158
|
+
* or "Menlo-Regular <fontSize>" when the default font cannot be read.
|
|
1159
|
+
*/
|
|
1160
|
+
function paneFont(font, fontSize) {
|
|
1161
|
+
const idx = (font ?? "").lastIndexOf(" ");
|
|
1162
|
+
if (idx > 0) {
|
|
1163
|
+
const family = font.slice(0, idx);
|
|
1164
|
+
if (!Number.isNaN(parseFloat(font.slice(idx + 1)))) return `${family} ${fontSize}`;
|
|
1165
|
+
}
|
|
1166
|
+
return `Menlo-Regular ${fontSize}`;
|
|
1167
|
+
}
|
|
1168
|
+
/** Write the pai-worker dynamic profile; iTerm2 loads that directory itself. */
|
|
1169
|
+
function writeDynamicProfile(parent, fontSize, read = itermPrefs) {
|
|
1170
|
+
const profile = {
|
|
1171
|
+
Name: PROFILE_NAME,
|
|
1172
|
+
Guid: "pai-worker-dynamic-profile",
|
|
1173
|
+
"Normal Font": paneFont(parent?.["Normal Font"], fontSize),
|
|
1174
|
+
"Close Sessions On End": true
|
|
1175
|
+
};
|
|
1176
|
+
if (parent?.Name && bookmarkFrom(read(), parent.Name)) profile["Dynamic Profile Parent Name"] = parent.Name;
|
|
1177
|
+
const path = dynamicProfilePath();
|
|
1178
|
+
const dir = dirname(path);
|
|
1179
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1180
|
+
const tmp = `${path}.tmp`;
|
|
1181
|
+
writeFileSync(tmp, JSON.stringify({ Profiles: [profile] }, null, 2) + "\n", "utf8");
|
|
1182
|
+
renameSync(tmp, path);
|
|
1183
|
+
}
|
|
1184
|
+
let warnedPrefs = false;
|
|
1185
|
+
/**
|
|
1186
|
+
* Profile for new worker panes, creating the small-font one on demand.
|
|
1187
|
+
* "" means "split with the default profile" (also the fallback when the
|
|
1188
|
+
* dynamic profile does not show up in time).
|
|
1189
|
+
*
|
|
1190
|
+
* The profile file is written whenever it is missing — even when iTerm's
|
|
1191
|
+
* preferences cannot be read; then it gets font "Menlo-Regular <fontSize>"
|
|
1192
|
+
* and no parent, and the reason is logged to stderr once.
|
|
1193
|
+
*/
|
|
1194
|
+
async function followProfile(fontSize, read = itermPrefs) {
|
|
1195
|
+
const first = read();
|
|
1196
|
+
const parent = defaultBookmarkFrom(first);
|
|
1197
|
+
const path = dynamicProfilePath();
|
|
1198
|
+
const existed = existsSync(path);
|
|
1199
|
+
if (!existed) writeDynamicProfile(parent, fontSize, () => first);
|
|
1200
|
+
if (!parent) {
|
|
1201
|
+
if (!warnedPrefs) {
|
|
1202
|
+
warnedPrefs = true;
|
|
1203
|
+
const reason = first.error ?? "no profile is marked default";
|
|
1204
|
+
const what = existed ? `keeping ${path} as-is` : `wrote ${path} with Menlo-Regular ${fontSize} and no parent`;
|
|
1205
|
+
process.stderr.write(`pai worker pane: cannot read iTerm's default profile (${reason}) — ${what}\n`);
|
|
1206
|
+
}
|
|
1207
|
+
if (first.error) return "";
|
|
1208
|
+
}
|
|
1209
|
+
for (let i = 0; i < 10; i++) {
|
|
1210
|
+
if (bookmarkFrom(read(), PROFILE_NAME)) return PROFILE_NAME;
|
|
1211
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1212
|
+
}
|
|
1213
|
+
process.stderr.write(`pai worker pane: profile ${PROFILE_NAME} not visible, splitting with default\n`);
|
|
1214
|
+
return "";
|
|
1215
|
+
}
|
|
1216
|
+
function loadRegistry(path) {
|
|
1217
|
+
try {
|
|
1218
|
+
const reg = JSON.parse(readFileSync(path, "utf8"));
|
|
1219
|
+
return Array.isArray(reg) ? reg : [];
|
|
1220
|
+
} catch {
|
|
1221
|
+
return [];
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function saveRegistry(path, reg) {
|
|
1225
|
+
const dir = dirname(path);
|
|
1226
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1227
|
+
const tmp = `${path}.tmp`;
|
|
1228
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 1), "utf8");
|
|
1229
|
+
renameSync(tmp, path);
|
|
1230
|
+
}
|
|
1231
|
+
/** Open (or only report on) the stacked follow pane of one worker. */
|
|
1232
|
+
async function openPaneForWorker(logDir, config, wid, term) {
|
|
1233
|
+
if (workerPaneOpen(wid)) return `pane for ${wid} already open`;
|
|
1234
|
+
const uid = itermUuid(term);
|
|
1235
|
+
const regPath = join(panesDir(logDir), `${scopeKey(term)}.json`);
|
|
1236
|
+
const reg = loadRegistry(regPath);
|
|
1237
|
+
const profile = await followProfile(config.pane.fontSize);
|
|
1238
|
+
const cmd = ` exec pai worker follow ${JSON.stringify(wid)} --auto-exit ${config.pane.autoExitSecs}`;
|
|
1239
|
+
const p = await osascript(WORKER_SPLIT_SCRIPT, [
|
|
1240
|
+
uid,
|
|
1241
|
+
[...reg].reverse().map((e) => e.session).join(","),
|
|
1242
|
+
cmd,
|
|
1243
|
+
profile
|
|
1244
|
+
]);
|
|
1245
|
+
const out = p.stdout.trim();
|
|
1246
|
+
if (out === "notfound" || out === "notrunning") throw new Error(out === "notfound" ? "pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID" : "pai worker pane: iTerm2 is not running");
|
|
1247
|
+
const bar = out.indexOf("|");
|
|
1248
|
+
if (bar < 0) throw new Error(`pai worker pane: osascript failed: ${(p.stderr || out).slice(0, 200)}`);
|
|
1249
|
+
const live = new Set(out.slice(0, bar).split(",").filter(Boolean));
|
|
1250
|
+
const pruned = reg.filter((e) => live.has(e.session));
|
|
1251
|
+
pruned.push({
|
|
1252
|
+
session: out.slice(bar + 1),
|
|
1253
|
+
worker: wid,
|
|
1254
|
+
opened: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
1255
|
+
});
|
|
1256
|
+
saveRegistry(regPath, pruned);
|
|
1257
|
+
return `pane opened for ${wid}`;
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Report-only variant used by `pai worker pane <id> --check`: whether a pane
|
|
1261
|
+
* runs for the worker, plus the dynamic profile's path, its existence, and
|
|
1262
|
+
* the font it contains (or, when missing, would write).
|
|
1263
|
+
*/
|
|
1264
|
+
async function checkPaneForWorker(wid, fontSize) {
|
|
1265
|
+
const lines = [workerPaneOpen(wid) ? `pane for ${wid} open` : `no pane for ${wid}`];
|
|
1266
|
+
const path = dynamicProfilePath();
|
|
1267
|
+
if (existsSync(path)) {
|
|
1268
|
+
let font = "(unreadable)";
|
|
1269
|
+
try {
|
|
1270
|
+
font = JSON.parse(readFileSync(path, "utf8")).Profiles?.[0]?.["Normal Font"] ?? "(none)";
|
|
1271
|
+
} catch {}
|
|
1272
|
+
lines.push(`profile file: ${path} (exists)`);
|
|
1273
|
+
lines.push(`profile font: ${font}`);
|
|
1274
|
+
} else {
|
|
1275
|
+
const parent = defaultBookmark();
|
|
1276
|
+
lines.push(`profile file: ${path} (missing)`);
|
|
1277
|
+
lines.push(`profile font (would write): ${paneFont(parent?.["Normal Font"], fontSize)}`);
|
|
1278
|
+
}
|
|
1279
|
+
return lines.join("\n");
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Split this iTerm tab and run `pai worker follow` in the new pane, unless
|
|
1283
|
+
* one already runs here (detected by TTY — iTerm overwrites session names
|
|
1284
|
+
* with the running command, so names cannot serve as idempotence).
|
|
1285
|
+
*/
|
|
1286
|
+
async function openFollowPane(logDir, _config, term, checkOnly) {
|
|
1287
|
+
const uid = itermUuid(term);
|
|
1288
|
+
const out = (await osascript(TAB_TTYS_SCRIPT, [uid])).stdout.trim();
|
|
1289
|
+
if (out === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
|
|
1290
|
+
if (out === "notrunning") throw new Error("pai worker pane: iTerm2 is not running");
|
|
1291
|
+
if ([...new Set(out.split(",").map((t) => t.trim()).filter(Boolean))].filter((t) => followTtys().has(t)).length > 0) return "follow pane already open";
|
|
1292
|
+
if (checkOnly) return "no follow pane";
|
|
1293
|
+
const s = await osascript(SPLIT_SCRIPT, [uid, " exec pai worker follow"]);
|
|
1294
|
+
const sout = s.stdout.trim();
|
|
1295
|
+
if (sout === "opened") return "follow pane opened";
|
|
1296
|
+
if (sout === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
|
|
1297
|
+
throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
//#endregion
|
|
1301
|
+
//#region src/workers/report.ts
|
|
1302
|
+
/**
|
|
1303
|
+
* report.ts — the worker contract: terse, structured final reports.
|
|
1304
|
+
*
|
|
1305
|
+
* Headless workers run with an appended system prompt that fixes their output
|
|
1306
|
+
* shape: act, verify, then finish with ONE final message that is a JSON object
|
|
1307
|
+
* and nothing else. The runner parses it out of the result; the viewer renders
|
|
1308
|
+
* it as a compact block instead of a wall of text.
|
|
1309
|
+
*/
|
|
1310
|
+
/** Appended to the caller's system prompt on every headless run (2h). */
|
|
1311
|
+
const WORKER_CONTRACT_PROMPT = [
|
|
1312
|
+
"You are a headless implementation worker, run non-interactively by an orchestrating session.",
|
|
1313
|
+
"No narration, no timestamps, no greetings, no summaries of what you read; act, verify, then stop.",
|
|
1314
|
+
"Your ONE final message is a JSON object and nothing else — no prose before or after, no code fence:",
|
|
1315
|
+
"{\"changed\":[{\"path\":\"…\",\"summary\":\"…\"}],\"commands\":[\"…\"],\"checks\":[{\"name\":\"…\",\"ok\":true,\"detail\":\"…\"}],\"open\":[\"…\"],\"notes\":\"one line\"}",
|
|
1316
|
+
"changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
|
|
1317
|
+
"checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
|
|
1318
|
+
"notes: one line, the headline a reviewer reads first."
|
|
1319
|
+
].join("\n");
|
|
1320
|
+
/**
|
|
1321
|
+
* Extract the JSON report from a worker's final message. Accepts a bare
|
|
1322
|
+
* object, a ```json fenced block, or an object embedded in surrounding text.
|
|
1323
|
+
* Only objects that look like the contract count (at least one of changed /
|
|
1324
|
+
* checks / notes) — any other JSON falls through to null so the raw text is
|
|
1325
|
+
* kept as-is.
|
|
1326
|
+
*/
|
|
1327
|
+
function parseWorkerReport(text) {
|
|
1328
|
+
const trimmed = (text ?? "").trim();
|
|
1329
|
+
if (!trimmed) return null;
|
|
1330
|
+
const candidates = [];
|
|
1331
|
+
const fence = trimmed.match(/```(?:json)?\s*(\{[\s\S]*?\})\s*```/);
|
|
1332
|
+
if (fence) candidates.push(fence[1]);
|
|
1333
|
+
if (trimmed.startsWith("{")) candidates.push(trimmed);
|
|
1334
|
+
const first = trimmed.indexOf("{");
|
|
1335
|
+
const last = trimmed.lastIndexOf("}");
|
|
1336
|
+
if (first >= 0 && last > first) candidates.push(trimmed.slice(first, last + 1));
|
|
1337
|
+
for (const cand of candidates) try {
|
|
1338
|
+
const v = JSON.parse(cand);
|
|
1339
|
+
if (typeof v === "object" && v !== null && !Array.isArray(v) && looksLikeReport(v)) return v;
|
|
1340
|
+
} catch {}
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
/** The contract's fingerprint — guards against unrelated JSON in a reply. */
|
|
1344
|
+
function looksLikeReport(v) {
|
|
1345
|
+
const o = v;
|
|
1346
|
+
return Array.isArray(o.changed) || Array.isArray(o.checks) || typeof o.notes === "string";
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Render a parsed report as the compact block the viewer shows: changed paths
|
|
1350
|
+
* with summaries, checks with ✓/✗, open items, notes. Empty report → [].
|
|
1351
|
+
*/
|
|
1352
|
+
function renderReport(c, prefix, r, cwd = "") {
|
|
1353
|
+
const out = [];
|
|
1354
|
+
const has = (xs) => (xs ?? []).length > 0;
|
|
1355
|
+
if (has(r.changed)) {
|
|
1356
|
+
out.push(`${prefix}${c("bold", "changed")}`);
|
|
1357
|
+
for (const ch of r.changed) out.push(`${prefix} ${relShort(ch.path ?? "?", cwd)} — ${shortText(ch.summary ?? "", 90)}`);
|
|
1358
|
+
}
|
|
1359
|
+
if (has(r.checks)) {
|
|
1360
|
+
out.push(`${prefix}${c("bold", "checks")}`);
|
|
1361
|
+
for (const ck of r.checks) {
|
|
1362
|
+
const mark = ck.ok === false ? c("red", "✗") : c("green", "✓");
|
|
1363
|
+
const detail = ck.detail ? c("dim", ` · ${shortText(ck.detail, 80)}`) : "";
|
|
1364
|
+
out.push(`${prefix} ${mark} ${ck.name ?? "?"}${detail}`);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
if (has(r.commands)) {
|
|
1368
|
+
out.push(`${prefix}${c("bold", "commands")}`);
|
|
1369
|
+
for (const cmd of r.commands) out.push(`${prefix} ${c("dim", shortText(cmd, 100))}`);
|
|
1370
|
+
}
|
|
1371
|
+
if (has(r.open)) {
|
|
1372
|
+
out.push(`${prefix}${c("bold", "open")}`);
|
|
1373
|
+
for (const o of r.open) out.push(`${prefix} ${c("yellow", shortText(o, 100))}`);
|
|
1374
|
+
}
|
|
1375
|
+
if (r.notes) out.push(`${prefix}${c("bold", "notes")} ${shortText(r.notes, 120)}`);
|
|
1376
|
+
return out;
|
|
1377
|
+
}
|
|
1378
|
+
/** repo-relative display of a changed path when it lies under cwd. */
|
|
1379
|
+
function relShort(p, cwd) {
|
|
1380
|
+
if (!cwd) return p;
|
|
1381
|
+
const r = relative(cwd, p);
|
|
1382
|
+
return r && !r.startsWith("..") ? r : p;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
//#endregion
|
|
1386
|
+
//#region src/workers/mcp.ts
|
|
1387
|
+
/**
|
|
1388
|
+
* mcp.ts — the MCP allowlist for headless workers.
|
|
1389
|
+
*
|
|
1390
|
+
* Headless workers start with NO MCP servers: every server definition is a
|
|
1391
|
+
* prompt-time tool inventory the model pays to know, and a worker that only
|
|
1392
|
+
* reads and edits files needs none of it. A run may opt in with `--mcp
|
|
1393
|
+
* name[,name…]` (or a role carrying `"mcp": [...]`); names may be single
|
|
1394
|
+
* servers from ~/.claude.json's `mcpServers` or `workers.mcpSets` set names,
|
|
1395
|
+
* which expand to their member list. The filtered config lands in
|
|
1396
|
+
* `<logDir>/<id>.mcp.json` and is passed with `--strict-mcp-config
|
|
1397
|
+
* --mcp-config` so exactly those servers load. MCP servers are chosen at
|
|
1398
|
+
* launch only — a mid-run `say` cannot add any.
|
|
1399
|
+
*/
|
|
1400
|
+
/** ~/.claude.json — the user's MCP server definitions (top-level mcpServers). */
|
|
1401
|
+
const CLAUDE_JSON = join(homedir(), ".claude.json");
|
|
1402
|
+
function readMcpServers(claudeJson = CLAUDE_JSON) {
|
|
1403
|
+
try {
|
|
1404
|
+
if (!existsSync(claudeJson)) return {};
|
|
1405
|
+
const servers = JSON.parse(readFileSync(claudeJson, "utf8")).mcpServers;
|
|
1406
|
+
if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return {};
|
|
1407
|
+
return servers;
|
|
1408
|
+
} catch {
|
|
1409
|
+
return {};
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* Split a `--mcp a,b,c` flag value (also accepts repeated flags already split
|
|
1414
|
+
* by the caller) and expand set names from workers.mcpSets.
|
|
1415
|
+
*/
|
|
1416
|
+
function expandMcpNames(names, config, claudeJson = CLAUDE_JSON) {
|
|
1417
|
+
const available = readMcpServers(claudeJson);
|
|
1418
|
+
const out = [];
|
|
1419
|
+
for (const raw of names) for (const name of raw.split(",").map((s) => s.trim()).filter(Boolean)) {
|
|
1420
|
+
if (name in config.mcpSets) {
|
|
1421
|
+
for (const member of config.mcpSets[name]) if (!out.includes(member)) out.push(member);
|
|
1422
|
+
continue;
|
|
1423
|
+
}
|
|
1424
|
+
if (!(name in available)) {
|
|
1425
|
+
const sets = Object.keys(config.mcpSets);
|
|
1426
|
+
throw new WorkersConfigError(`unknown MCP server "${name}". Available servers: ${Object.keys(available).join(", ") || "(none in ~/.claude.json)"}${sets.length ? `; sets: ${sets.join(", ")}` : ""}`);
|
|
1427
|
+
}
|
|
1428
|
+
if (!out.includes(name)) out.push(name);
|
|
1429
|
+
}
|
|
1430
|
+
return out;
|
|
1431
|
+
}
|
|
1432
|
+
/** Path of a run's filtered MCP config. */
|
|
1433
|
+
function runMcpConfigPath(logDir, id) {
|
|
1434
|
+
return join(logDir, `${id}.mcp.json`);
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Write a config containing only `names` (already expanded) and return its
|
|
1438
|
+
* path. Unknown names fail fast with the available list.
|
|
1439
|
+
*/
|
|
1440
|
+
function writeMcpConfig(logDir, id, names, claudeJson = CLAUDE_JSON) {
|
|
1441
|
+
const available = readMcpServers(claudeJson);
|
|
1442
|
+
const unknown = names.filter((n) => !(n in available));
|
|
1443
|
+
if (unknown.length) throw new WorkersConfigError(`unknown MCP server(s): ${unknown.join(", ")}. Available: ${Object.keys(available).join(", ") || "(none in ~/.claude.json)"}`);
|
|
1444
|
+
const servers = {};
|
|
1445
|
+
for (const n of names) servers[n] = available[n];
|
|
1446
|
+
const path = runMcpConfigPath(logDir, id);
|
|
1447
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
1448
|
+
writeFileSync(path, JSON.stringify({ mcpServers: servers }, null, 2) + "\n", "utf8");
|
|
1449
|
+
return path;
|
|
1450
|
+
}
|
|
1451
|
+
/** `pai worker mcp list`: the servers and sets a run could name. */
|
|
1452
|
+
function describeMcp(config, claudeJson = CLAUDE_JSON) {
|
|
1453
|
+
const servers = Object.keys(readMcpServers(claudeJson));
|
|
1454
|
+
const lines = [];
|
|
1455
|
+
if (servers.length) {
|
|
1456
|
+
lines.push(`servers (~/.claude.json):`);
|
|
1457
|
+
for (const s of servers) lines.push(` ${s}`);
|
|
1458
|
+
} else lines.push(`no MCP servers defined in ~/.claude.json`);
|
|
1459
|
+
const sets = Object.entries(config.mcpSets);
|
|
1460
|
+
if (sets.length) {
|
|
1461
|
+
lines.push(`sets (workers.mcpSets):`);
|
|
1462
|
+
for (const [name, members] of sets) lines.push(` ${name} = ${members.join(", ")}`);
|
|
1463
|
+
}
|
|
1464
|
+
lines.push(`usage: pai worker run --mcp <name>[,<name>…] -p '<task>'`);
|
|
1465
|
+
return lines;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
//#endregion
|
|
1469
|
+
//#region src/workers/operator.ts
|
|
1470
|
+
/**
|
|
1471
|
+
* operator.ts — talking to a running worker.
|
|
1472
|
+
*
|
|
1473
|
+
* Headless workers run with `--input-format stream-json` and their stdin held
|
|
1474
|
+
* open, so a run is a conversation: every line sent to the per-worker Unix
|
|
1475
|
+
* socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
|
|
1476
|
+
* mirrored into the transcript as an `operator` event (rendered with a »
|
|
1477
|
+
* marker). Once the worker has finished its turn, stdin closes 2 s later
|
|
1478
|
+
* unless a new message arrives — after that, `say` refuses and `resume`
|
|
1479
|
+
* continues the same Claude session with the worker's context intact.
|
|
1480
|
+
*/
|
|
1481
|
+
function operatorSocketPath(logDir, id) {
|
|
1482
|
+
return join(logDir, `${id}.sock`);
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* The runner's side: listen on the worker socket, hand every received line to
|
|
1486
|
+
* `onLine`. Returns the server (close it when the run ends; the socket file is
|
|
1487
|
+
* unlinked on close, best effort).
|
|
1488
|
+
*/
|
|
1489
|
+
function createOperatorServer(logDir, id, onLine) {
|
|
1490
|
+
const path = operatorSocketPath(logDir, id);
|
|
1491
|
+
try {
|
|
1492
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1493
|
+
} catch {}
|
|
1494
|
+
const server = createServer((socket) => {
|
|
1495
|
+
let buf = "";
|
|
1496
|
+
socket.on("data", (chunk) => {
|
|
1497
|
+
buf += chunk.toString("utf8");
|
|
1498
|
+
let nl;
|
|
1499
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1500
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
1501
|
+
buf = buf.slice(nl + 1);
|
|
1502
|
+
if (line.trim()) onLine(line);
|
|
1503
|
+
socket.write("ok\n");
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
server.listen(path);
|
|
1508
|
+
server.on("close", () => {
|
|
1509
|
+
try {
|
|
1510
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1511
|
+
} catch {}
|
|
1512
|
+
});
|
|
1513
|
+
return server;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* `pai worker say <id> "<text>"`: forward one line to a running worker.
|
|
1517
|
+
* Resolves "ok", rejects with a clear message when the worker is not running.
|
|
1518
|
+
*/
|
|
1519
|
+
function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
|
|
1520
|
+
const status = loadStatus(logDir, id);
|
|
1521
|
+
if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
|
|
1522
|
+
if (status.state !== "running" || !alive(status.pid)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} is not running (state: ${status.state}) — continue it instead with: pai worker resume ${id} "<text>"`));
|
|
1523
|
+
const path = operatorSocketPath(logDir, id);
|
|
1524
|
+
if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
|
|
1525
|
+
return new Promise((resolve, reject) => {
|
|
1526
|
+
const sock = connect(path);
|
|
1527
|
+
const fail = (e) => {
|
|
1528
|
+
sock.destroy();
|
|
1529
|
+
reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
|
|
1530
|
+
};
|
|
1531
|
+
sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
|
|
1532
|
+
sock.once("error", (e) => fail(e));
|
|
1533
|
+
sock.once("connect", () => {
|
|
1534
|
+
sock.write(text.replace(/\n/g, " ") + "\n");
|
|
1535
|
+
});
|
|
1536
|
+
sock.once("data", () => {
|
|
1537
|
+
sock.end();
|
|
1538
|
+
resolve("ok");
|
|
1539
|
+
});
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
//#endregion
|
|
1544
|
+
//#region src/workers/proxy/server.ts
|
|
1545
|
+
/**
|
|
1546
|
+
* server.ts — the PAI worker proxy: Anthropic Messages API on the front,
|
|
1547
|
+
* OpenAI Chat Completions on the back, loopback only.
|
|
1548
|
+
*
|
|
1549
|
+
* One proxy serves every OpenAI-protocol provider: Claude Code points
|
|
1550
|
+
* ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
|
|
1551
|
+
* name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
|
|
1552
|
+
* workers config, re-read per request so config edits apply without a
|
|
1553
|
+
* restart). `run` starts the proxy on demand (detached, pid file under the
|
|
1554
|
+
* logDir) and `pai worker proxy stop` stops it again.
|
|
1555
|
+
*
|
|
1556
|
+
* All translation lives in translate.ts; this file is only HTTP plumbing.
|
|
1557
|
+
*/
|
|
1558
|
+
const DEFAULT_PROXY_PORT = 8797;
|
|
1559
|
+
const HOST = "127.0.0.1";
|
|
1560
|
+
function proxyPidPath(logDir) {
|
|
1561
|
+
return join(logDir, "proxy.pid");
|
|
1562
|
+
}
|
|
1563
|
+
/** True when something already accepts connections on the port. */
|
|
1564
|
+
function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
|
|
1565
|
+
return new Promise((resolve) => {
|
|
1566
|
+
const sock = connect({
|
|
1567
|
+
port,
|
|
1568
|
+
host: HOST
|
|
1569
|
+
});
|
|
1570
|
+
const done = (ok) => {
|
|
1571
|
+
sock.removeAllListeners();
|
|
1572
|
+
sock.destroy();
|
|
1573
|
+
resolve(ok);
|
|
1574
|
+
};
|
|
1575
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
1576
|
+
sock.once("connect", () => done(true));
|
|
1577
|
+
sock.once("error", () => done(false));
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Where the standalone proxy artefact lives, found from this module's own
|
|
1582
|
+
* location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
|
|
1583
|
+
*/
|
|
1584
|
+
function standaloneProxyPath() {
|
|
1585
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
1586
|
+
for (let i = 0; i < 6; i++) {
|
|
1587
|
+
for (const cand of [
|
|
1588
|
+
join(dir, "worker-proxy.mjs"),
|
|
1589
|
+
join(dir, "hooks", "worker-proxy.mjs"),
|
|
1590
|
+
join(dir, "dist", "hooks", "worker-proxy.mjs")
|
|
1591
|
+
]) if (existsSync(cand)) return cand;
|
|
1592
|
+
const parent = dirname(dir);
|
|
1593
|
+
if (parent === dir) break;
|
|
1594
|
+
dir = parent;
|
|
1595
|
+
}
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Ensure a proxy is listening on the port; start the detached standalone when
|
|
1600
|
+
* nothing answers. Returns the base URL (`http://127.0.0.1:<port>`).
|
|
1601
|
+
*/
|
|
1602
|
+
async function ensureProxyRunning(port, logDir) {
|
|
1603
|
+
if (await proxyListening(port)) return `http://${HOST}:${port}`;
|
|
1604
|
+
const script = standaloneProxyPath();
|
|
1605
|
+
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})`);
|
|
1606
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
1607
|
+
const child = spawn(process.execPath, [
|
|
1608
|
+
script,
|
|
1609
|
+
"--port",
|
|
1610
|
+
String(port)
|
|
1611
|
+
], {
|
|
1612
|
+
detached: true,
|
|
1613
|
+
stdio: "ignore"
|
|
1614
|
+
});
|
|
1615
|
+
child.unref();
|
|
1616
|
+
writeFileSync(proxyPidPath(logDir), `${child.pid}\n`, "utf8");
|
|
1617
|
+
for (let i = 0; i < 40; i++) {
|
|
1618
|
+
if (await proxyListening(port)) return `http://${HOST}:${port}`;
|
|
1619
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1620
|
+
}
|
|
1621
|
+
throw new Error(`the PAI worker proxy did not come up on port ${port} (see ${proxyPidPath(logDir)})`);
|
|
1622
|
+
}
|
|
1623
|
+
/** `pai worker proxy stop`: SIGTERM the pid from the pid file. */
|
|
1624
|
+
function stopProxy(logDir) {
|
|
1625
|
+
const path = proxyPidPath(logDir);
|
|
1626
|
+
if (!existsSync(path)) return "no proxy pid file — nothing to stop";
|
|
1627
|
+
const pid = parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
1628
|
+
unlinkSync(path);
|
|
1629
|
+
if (!Number.isFinite(pid)) return "stale proxy pid file removed";
|
|
1630
|
+
try {
|
|
1631
|
+
process.kill(pid, "SIGTERM");
|
|
1632
|
+
} catch {
|
|
1633
|
+
return `proxy pid ${pid} was not running (pid file removed)`;
|
|
1634
|
+
}
|
|
1635
|
+
return `proxy pid ${pid} stopped`;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
//#endregion
|
|
1639
|
+
//#region src/workers/codex.ts
|
|
1640
|
+
/**
|
|
1641
|
+
* codex.ts — the "codex" runner engine.
|
|
1642
|
+
*
|
|
1643
|
+
* A ChatGPT plan gives no API key, only Codex CLI access, so a provider may
|
|
1644
|
+
* set `engine: "codex"`: `run` then shells out to `codex exec --json <prompt>`
|
|
1645
|
+
* (non-interactive) instead of Claude Code. The JSONL event stream is parsed
|
|
1646
|
+
* into the same status-file fields (turns, tools, last) and ledger lines as a
|
|
1647
|
+
* Claude run, and is normalised into claude-code-shaped events in the
|
|
1648
|
+
* worker's .jsonl so follow/replay/the pane render it unchanged.
|
|
1649
|
+
*
|
|
1650
|
+
* Implemented against the documented `codex exec --json` interface
|
|
1651
|
+
* (thread.started / item.completed / turn.completed / turn.failed lines);
|
|
1652
|
+
* verify against the installed CLI when one is present.
|
|
1653
|
+
* `--allowedTools` has no Codex equivalent and is dropped (ledgered).
|
|
1654
|
+
*/
|
|
1655
|
+
/** Build the codex exec argument vector (without the binary itself). */
|
|
1656
|
+
function buildCodexArgs(prompt, model) {
|
|
1657
|
+
return [
|
|
1658
|
+
"exec",
|
|
1659
|
+
"--json",
|
|
1660
|
+
"--skip-git-repo-check",
|
|
1661
|
+
...model ? ["-m", model] : [],
|
|
1662
|
+
"--",
|
|
1663
|
+
prompt
|
|
1664
|
+
];
|
|
1665
|
+
}
|
|
1666
|
+
/** Env for a codex run: caller's env, Anthropic vars stripped, key applied. */
|
|
1667
|
+
function buildCodexEnv(provider) {
|
|
1668
|
+
const env = { ...process.env };
|
|
1669
|
+
delete env.ANTHROPIC_API_KEY;
|
|
1670
|
+
delete env.ANTHROPIC_BASE_URL;
|
|
1671
|
+
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
1672
|
+
delete env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
|
1673
|
+
delete env.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
|
1674
|
+
delete env.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
|
1675
|
+
const keyPath = providerKeyPath(provider);
|
|
1676
|
+
if (keyPath && existsSync(keyPath)) env.OPENAI_API_KEY = readFileSync(keyPath, "utf8").trim();
|
|
1677
|
+
if (provider.upstreamUrl) env.OPENAI_BASE_URL = provider.upstreamUrl;
|
|
1678
|
+
for (const [k, v] of Object.entries(provider.env)) env[k] = v;
|
|
1679
|
+
env.PAI_WORKER = "1";
|
|
1680
|
+
return env;
|
|
1681
|
+
}
|
|
1682
|
+
/** Codex "not applicable" flags the runner drops with a ledger note. */
|
|
1683
|
+
function codexDroppedFlags(argv) {
|
|
1684
|
+
const dropped = [];
|
|
1685
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1686
|
+
const a = argv[i];
|
|
1687
|
+
if (a === "--allowedTools" || a === "--disallowedTools" || a === "--mcp-config" || a === "--mcp") {
|
|
1688
|
+
dropped.push(a);
|
|
1689
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) i++;
|
|
1690
|
+
} else if (a.startsWith("--allowedTools=") || a.startsWith("--mcp-config=")) dropped.push(a.split("=")[0]);
|
|
1691
|
+
}
|
|
1692
|
+
return dropped;
|
|
1693
|
+
}
|
|
1694
|
+
const emptyCodexResult = () => ({
|
|
1695
|
+
events: [],
|
|
1696
|
+
turns: 0,
|
|
1697
|
+
tools: 0,
|
|
1698
|
+
last: null,
|
|
1699
|
+
isError: false,
|
|
1700
|
+
contextTokens: null,
|
|
1701
|
+
finalText: null,
|
|
1702
|
+
threadId: null
|
|
1703
|
+
});
|
|
1704
|
+
/**
|
|
1705
|
+
* Fold one parsed `codex exec --json` line into the running result: appends
|
|
1706
|
+
* normalised transcript events and updates the counters in place.
|
|
1707
|
+
*/
|
|
1708
|
+
function foldCodexLine(line, r) {
|
|
1709
|
+
if (typeof line !== "object" || line === null) return;
|
|
1710
|
+
const e = line;
|
|
1711
|
+
const type = typeof e.type === "string" ? e.type : "";
|
|
1712
|
+
if (type === "thread.started" && typeof e.thread_id === "string") {
|
|
1713
|
+
r.threadId = e.thread_id;
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (type === "item.completed" && typeof e.item === "object" && e.item !== null) {
|
|
1717
|
+
const item = e.item;
|
|
1718
|
+
const kind = typeof item.type === "string" ? item.type : "";
|
|
1719
|
+
if (kind === "agent_message" && typeof item.text === "string") {
|
|
1720
|
+
r.turns += 1;
|
|
1721
|
+
r.last = `says: ${item.text.slice(0, 70)}`;
|
|
1722
|
+
r.finalText = item.text;
|
|
1723
|
+
r.events.push({
|
|
1724
|
+
type: "assistant",
|
|
1725
|
+
message: { content: [{
|
|
1726
|
+
type: "text",
|
|
1727
|
+
text: item.text
|
|
1728
|
+
}] }
|
|
1729
|
+
});
|
|
1730
|
+
} else if (kind === "command_execution") {
|
|
1731
|
+
r.tools += 1;
|
|
1732
|
+
const cmd = typeof item.command === "string" ? item.command : "?";
|
|
1733
|
+
const rc = typeof item.exit_code === "number" ? item.exit_code : 0;
|
|
1734
|
+
r.last = `Bash: ${cmd.slice(0, 70)}`;
|
|
1735
|
+
r.events.push({
|
|
1736
|
+
type: "assistant",
|
|
1737
|
+
message: { content: [{
|
|
1738
|
+
type: "tool_use",
|
|
1739
|
+
name: "Bash",
|
|
1740
|
+
input: { command: cmd }
|
|
1741
|
+
}] }
|
|
1742
|
+
});
|
|
1743
|
+
r.events.push({
|
|
1744
|
+
type: "user",
|
|
1745
|
+
message: { content: [{
|
|
1746
|
+
type: "tool_result",
|
|
1747
|
+
tool_use_id: "",
|
|
1748
|
+
is_error: rc !== 0,
|
|
1749
|
+
content: item.aggregated_output ?? ""
|
|
1750
|
+
}] }
|
|
1751
|
+
});
|
|
1752
|
+
} else if (kind === "file_change") {
|
|
1753
|
+
r.tools += 1;
|
|
1754
|
+
const files = (Array.isArray(item.changes) ? item.changes : []).map((c) => typeof c === "object" && c !== null && typeof c.path === "string" ? c.path : "?").join(", ");
|
|
1755
|
+
r.last = `files: ${files.slice(0, 70)}`;
|
|
1756
|
+
r.events.push({
|
|
1757
|
+
type: "assistant",
|
|
1758
|
+
message: { content: [{
|
|
1759
|
+
type: "tool_use",
|
|
1760
|
+
name: "Write",
|
|
1761
|
+
input: { file_path: files }
|
|
1762
|
+
}] }
|
|
1763
|
+
});
|
|
1764
|
+
} else if (kind === "mcp_tool_call") {
|
|
1765
|
+
r.tools += 1;
|
|
1766
|
+
r.last = `mcp: ${String(item.tool ?? "?")}`;
|
|
1767
|
+
}
|
|
1768
|
+
return;
|
|
1769
|
+
}
|
|
1770
|
+
if (type === "turn.completed") {
|
|
1771
|
+
const usage = typeof e.usage === "object" && e.usage !== null ? e.usage : {};
|
|
1772
|
+
const tokens = (usage.input_tokens ?? 0) + (usage.cached_input_tokens ?? 0) + (usage.output_tokens ?? 0);
|
|
1773
|
+
if (tokens > 0) r.contextTokens = tokens;
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (type === "turn.failed" || type === "error") {
|
|
1777
|
+
r.isError = true;
|
|
1778
|
+
const err = typeof e.error === "object" && e.error !== null ? e.error : {};
|
|
1779
|
+
r.last = String(err.message ?? e.message ?? "codex turn failed");
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
/** Parse one JSONL line; null for blanks and non-JSON noise. */
|
|
1783
|
+
function parseCodexLine(line) {
|
|
1784
|
+
const t = line.trim();
|
|
1785
|
+
if (!t.startsWith("{")) return null;
|
|
1786
|
+
try {
|
|
1787
|
+
return JSON.parse(t);
|
|
1788
|
+
} catch {
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
/** True when the Codex CLI is on PATH (test reports "not installed" else). */
|
|
1793
|
+
function codexInstalled() {
|
|
1794
|
+
try {
|
|
1795
|
+
execFileSync("codex", ["--version"], {
|
|
1796
|
+
timeout: 5e3,
|
|
1797
|
+
stdio: "ignore"
|
|
1798
|
+
});
|
|
1799
|
+
return true;
|
|
1800
|
+
} catch {
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
//#endregion
|
|
1806
|
+
//#region src/workers/run.ts
|
|
1807
|
+
/**
|
|
1808
|
+
* run.ts — the worker runner (port of the glm / glm-run pair, provider-neutral).
|
|
1809
|
+
*
|
|
1810
|
+
* One claude-code process per call, pointed at the chosen provider:
|
|
1811
|
+
*
|
|
1812
|
+
* - env: ANTHROPIC_BASE_URL/AUTH_TOKEN from the provider (token from its
|
|
1813
|
+
* key file, never from the environment), the three DEFAULT_*_MODEL vars,
|
|
1814
|
+
* the provider's extra env, nonessential traffic off, and ANTHROPIC_API_KEY
|
|
1815
|
+
* stripped so nothing can fall back to Anthropic billing. OpenAI-protocol
|
|
1816
|
+
* providers point at the PAI proxy instead (started on demand, the
|
|
1817
|
+
* provider name in the URL path); codex-engine providers run the Codex CLI.
|
|
1818
|
+
* - headless (-p): strict empty MCP config unless the caller brings one or
|
|
1819
|
+
* names servers via --mcp / a role (then a filtered <id>.mcp.json),
|
|
1820
|
+
* PAI_WORKER=1 so PAI's per-session hooks leave it alone, the worker
|
|
1821
|
+
* contract appended to the system prompt, `--input-format stream-json`
|
|
1822
|
+
* with the prompt as the first stdin user message (the operator socket
|
|
1823
|
+
* can add more mid-run), stream-json mirroring (every line stamped `_ts`)
|
|
1824
|
+
* into <logDir>/<id>.jsonl, a live <id>.status for ps/follow/status line
|
|
1825
|
+
* (context meter included), ledger lines, result printed in the caller's
|
|
1826
|
+
* --output-format (json adds the parsed `report`), and the follow pane
|
|
1827
|
+
* (unless --no-pane).
|
|
1828
|
+
* - interactive: no MCP restriction, no pane, ENABLE_TOOL_SEARCH=true.
|
|
1829
|
+
*
|
|
1830
|
+
* Auto-routed runs that die of a quota error before the first tool call are
|
|
1831
|
+
* restarted on the next provider in routing order (WORKER-REROUTE ledger line).
|
|
1832
|
+
*/
|
|
1833
|
+
/** Environment for a run through `provider`. Caller's env minus the Anthropic key. */
|
|
1834
|
+
function buildRunEnv(provider, headless, proxyUrl) {
|
|
1835
|
+
const env = { ...process.env };
|
|
1836
|
+
delete env.ANTHROPIC_API_KEY;
|
|
1837
|
+
let token = "local";
|
|
1838
|
+
if (proxyUrl) env.ANTHROPIC_BASE_URL = proxyUrl;
|
|
1839
|
+
else {
|
|
1840
|
+
const keyPath = providerKeyPath(provider);
|
|
1841
|
+
if (keyPath) {
|
|
1842
|
+
try {
|
|
1843
|
+
token = readFileSync(keyPath, "utf8").trim();
|
|
1844
|
+
} catch {
|
|
1845
|
+
throw new Error(`key file not readable: ${keyPath}`);
|
|
1846
|
+
}
|
|
1847
|
+
if (!token) throw new Error(`key file is empty: ${keyPath}`);
|
|
1848
|
+
}
|
|
1849
|
+
env.ANTHROPIC_BASE_URL = provider.baseUrl;
|
|
1850
|
+
}
|
|
1851
|
+
env.ANTHROPIC_AUTH_TOKEN = token;
|
|
1852
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = provider.models.fast ?? provider.models.default;
|
|
1853
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = provider.models.default;
|
|
1854
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = provider.models.default;
|
|
1855
|
+
for (const [k, v] of Object.entries(provider.env)) env[k] = v;
|
|
1856
|
+
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
|
|
1857
|
+
if (headless) env.PAI_WORKER = "1";
|
|
1858
|
+
else env.ENABLE_TOOL_SEARCH = "true";
|
|
1859
|
+
return env;
|
|
1860
|
+
}
|
|
1861
|
+
/** The strict empty MCP config for headless workers, written on demand. */
|
|
1862
|
+
function ensureNoMcpConfig(logDir) {
|
|
1863
|
+
const path = noMcpConfigPath(logDir);
|
|
1864
|
+
if (!existsSync(path)) {
|
|
1865
|
+
mkdirSync(logDir, { recursive: true });
|
|
1866
|
+
writeFileSync(path, "{ \"mcpServers\": {} }\n", "utf8");
|
|
1867
|
+
}
|
|
1868
|
+
return path;
|
|
1869
|
+
}
|
|
1870
|
+
/** A stream-json user message for the child's stdin. */
|
|
1871
|
+
function stdinUserMessage(text) {
|
|
1872
|
+
return JSON.stringify({
|
|
1873
|
+
type: "user",
|
|
1874
|
+
message: {
|
|
1875
|
+
role: "user",
|
|
1876
|
+
content: text
|
|
1877
|
+
}
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1880
|
+
/** ISO stamp with seconds, attached to every mirrored event (2g). */
|
|
1881
|
+
function isoStamp(d = /* @__PURE__ */ new Date()) {
|
|
1882
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
1883
|
+
}
|
|
1884
|
+
/** Context tokens of an assistant/result usage block (input+cache+output). */
|
|
1885
|
+
function usageContextTokens(u) {
|
|
1886
|
+
if (!u) return null;
|
|
1887
|
+
const t = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.output_tokens ?? 0);
|
|
1888
|
+
return t > 0 ? t : null;
|
|
1889
|
+
}
|
|
1890
|
+
/** Context window announced by the init event, when the endpoint sends one. */
|
|
1891
|
+
function initContextWindow(e) {
|
|
1892
|
+
if (typeof e.context_window === "number" && e.context_window > 0) return e.context_window;
|
|
1893
|
+
if (e.model_info && typeof e.model_info.context_window === "number" && e.model_info.context_window > 0) return e.model_info.context_window;
|
|
1894
|
+
return null;
|
|
1895
|
+
}
|
|
1896
|
+
/**
|
|
1897
|
+
* Run one worker. Returns the process exit code to pass through.
|
|
1898
|
+
* Throws WorkersConfigError-shaped Errors for configuration problems.
|
|
1899
|
+
*/
|
|
1900
|
+
async function runWorker(opts) {
|
|
1901
|
+
const { raw: _raw, workers: config } = readWorkersSection();
|
|
1902
|
+
if (!config.enabled) throw new Error("workers are off. Turn them on with: pai worker on\n(then the Agent-tool hook stops denying Anthropic subagents only when you do)");
|
|
1903
|
+
const logDir = workersLogDir(config);
|
|
1904
|
+
mkdirSync(logDir, { recursive: true });
|
|
1905
|
+
const target = resolveTarget(config, logDir, {
|
|
1906
|
+
flagProvider: opts.providerFlag,
|
|
1907
|
+
role: opts.role
|
|
1908
|
+
});
|
|
1909
|
+
assertProviderRunnable(target.providerName, target.provider);
|
|
1910
|
+
const parsed = parseRunnerArgs(opts.claudeArgs);
|
|
1911
|
+
const label = opts.label ?? shortText(parsed.prompt ?? "(no prompt)", 70);
|
|
1912
|
+
const model = opts.modelFlag ?? (target.modelAlias === "fast" ? target.provider.models.fast ?? target.provider.models.default : target.provider.models.default);
|
|
1913
|
+
try {
|
|
1914
|
+
if (target.provider.engine === "codex") return await executeCodexRun({
|
|
1915
|
+
config,
|
|
1916
|
+
logDir,
|
|
1917
|
+
target,
|
|
1918
|
+
model,
|
|
1919
|
+
label,
|
|
1920
|
+
parsed,
|
|
1921
|
+
claudeArgs: opts.claudeArgs,
|
|
1922
|
+
noPane: opts.noPane ?? false,
|
|
1923
|
+
onWorkerStart: opts.onWorkerStart
|
|
1924
|
+
});
|
|
1925
|
+
return await executeRun({
|
|
1926
|
+
config,
|
|
1927
|
+
logDir,
|
|
1928
|
+
target,
|
|
1929
|
+
model,
|
|
1930
|
+
label,
|
|
1931
|
+
parsed,
|
|
1932
|
+
claudeArgs: opts.claudeArgs,
|
|
1933
|
+
noPane: opts.noPane ?? false,
|
|
1934
|
+
mcpFlag: opts.mcpFlag,
|
|
1935
|
+
onWorkerStart: opts.onWorkerStart,
|
|
1936
|
+
reroutes: opts._reroutes ?? 0
|
|
1937
|
+
});
|
|
1938
|
+
} catch (e) {
|
|
1939
|
+
if (e instanceof Error && e.message.startsWith("key file")) throw new Error(`provider "${target.providerName}": ${e.message}\nPut the token in that file (chmod 600) or point keyFile elsewhere.`);
|
|
1940
|
+
throw e;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
async function executeRun(a) {
|
|
1944
|
+
const { config, logDir, target, model, label, parsed, noPane } = a;
|
|
1945
|
+
const headless = parsed.headless;
|
|
1946
|
+
let proxyUrl;
|
|
1947
|
+
if (target.provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${target.providerName}`;
|
|
1948
|
+
const env = buildRunEnv(target.provider, headless, proxyUrl);
|
|
1949
|
+
const wid = newWorkerId();
|
|
1950
|
+
const cwd = process.cwd();
|
|
1951
|
+
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
1952
|
+
const session = resolveSession(term);
|
|
1953
|
+
const status = {
|
|
1954
|
+
id: wid,
|
|
1955
|
+
pid: process.pid,
|
|
1956
|
+
label,
|
|
1957
|
+
cwd,
|
|
1958
|
+
term,
|
|
1959
|
+
provider: target.providerName,
|
|
1960
|
+
model,
|
|
1961
|
+
state: "running",
|
|
1962
|
+
started: nowStamp(),
|
|
1963
|
+
updated: nowStamp(),
|
|
1964
|
+
turns: 0,
|
|
1965
|
+
tools: 0,
|
|
1966
|
+
last: headless ? "starting" : "interactive",
|
|
1967
|
+
rc: null,
|
|
1968
|
+
secs: null,
|
|
1969
|
+
...session ? { session } : {},
|
|
1970
|
+
contextWindow: providerContextWindow(target.provider)
|
|
1971
|
+
};
|
|
1972
|
+
saveStatus(logDir, status);
|
|
1973
|
+
a.onWorkerStart?.(wid);
|
|
1974
|
+
const ledger = ledgerPath(logDir);
|
|
1975
|
+
appendLedger(ledger, "WORKER-START", {
|
|
1976
|
+
id: wid,
|
|
1977
|
+
provider: target.providerName,
|
|
1978
|
+
mode: headless ? "headless" : "interactive",
|
|
1979
|
+
model,
|
|
1980
|
+
cwd,
|
|
1981
|
+
label
|
|
1982
|
+
});
|
|
1983
|
+
if (headless && !noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
|
|
1984
|
+
let mcpArgs = [];
|
|
1985
|
+
if (headless && !parsed.callerMcpConfig) {
|
|
1986
|
+
const wanted = [
|
|
1987
|
+
...a.mcpFlag ? [a.mcpFlag] : [],
|
|
1988
|
+
...parsed.mcp,
|
|
1989
|
+
...target.roleMcp ?? []
|
|
1990
|
+
];
|
|
1991
|
+
if (wanted.length) mcpArgs = [
|
|
1992
|
+
"--strict-mcp-config",
|
|
1993
|
+
"--mcp-config",
|
|
1994
|
+
writeMcpConfig(logDir, wid, expandMcpNames(wanted, config))
|
|
1995
|
+
];
|
|
1996
|
+
else mcpArgs = [
|
|
1997
|
+
"--strict-mcp-config",
|
|
1998
|
+
"--mcp-config",
|
|
1999
|
+
ensureNoMcpConfig(logDir)
|
|
2000
|
+
];
|
|
2001
|
+
}
|
|
2002
|
+
const restArgs = headless ? stripPromptValues(parsed.rest) : parsed.rest;
|
|
2003
|
+
const cmd = ["claude"];
|
|
2004
|
+
if (!parsed.callerModel) cmd.push("--model", model);
|
|
2005
|
+
cmd.push(...mcpArgs, ...restArgs);
|
|
2006
|
+
if (headless) {
|
|
2007
|
+
cmd.push("--output-format", "stream-json", "--verbose", "--input-format", "stream-json");
|
|
2008
|
+
if (!parsed.callerSystemPrompt) cmd.push("--append-system-prompt", WORKER_CONTRACT_PROMPT);
|
|
2009
|
+
}
|
|
2010
|
+
const t0 = Date.now();
|
|
2011
|
+
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
2012
|
+
env,
|
|
2013
|
+
cwd,
|
|
2014
|
+
stdio: headless ? [
|
|
2015
|
+
"pipe",
|
|
2016
|
+
"pipe",
|
|
2017
|
+
"inherit"
|
|
2018
|
+
] : "inherit"
|
|
2019
|
+
});
|
|
2020
|
+
let operatorInFlight = 0;
|
|
2021
|
+
let closeTimer = null;
|
|
2022
|
+
const armStdinClose = () => {
|
|
2023
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
2024
|
+
closeTimer = setTimeout(() => {
|
|
2025
|
+
if (operatorInFlight === 0) try {
|
|
2026
|
+
proc.stdin?.end();
|
|
2027
|
+
} catch {}
|
|
2028
|
+
}, 2e3);
|
|
2029
|
+
};
|
|
2030
|
+
let eventsFd = null;
|
|
2031
|
+
const writeEvent = (obj) => {
|
|
2032
|
+
if (eventsFd === null) return;
|
|
2033
|
+
try {
|
|
2034
|
+
writeSync(eventsFd, JSON.stringify({
|
|
2035
|
+
...obj,
|
|
2036
|
+
_ts: isoStamp()
|
|
2037
|
+
}) + "\n");
|
|
2038
|
+
} catch {}
|
|
2039
|
+
};
|
|
2040
|
+
const operatorServer = headless ? createOperatorServer(logDir, wid, (text) => {
|
|
2041
|
+
operatorInFlight += 1;
|
|
2042
|
+
if (closeTimer) {
|
|
2043
|
+
clearTimeout(closeTimer);
|
|
2044
|
+
closeTimer = null;
|
|
2045
|
+
}
|
|
2046
|
+
writeEvent({
|
|
2047
|
+
type: "operator",
|
|
2048
|
+
text
|
|
2049
|
+
});
|
|
2050
|
+
try {
|
|
2051
|
+
proc.stdin?.write(stdinUserMessage(text) + "\n");
|
|
2052
|
+
} catch {}
|
|
2053
|
+
}) : null;
|
|
2054
|
+
let killed = false;
|
|
2055
|
+
const cleanup = () => {
|
|
2056
|
+
if (closeTimer) clearTimeout(closeTimer);
|
|
2057
|
+
operatorServer?.close();
|
|
2058
|
+
};
|
|
2059
|
+
const onSignal = (sig) => {
|
|
2060
|
+
killed = true;
|
|
2061
|
+
status.state = "killed";
|
|
2062
|
+
status.rc = 143;
|
|
2063
|
+
status.secs = Math.floor((Date.now() - t0) / 1e3);
|
|
2064
|
+
status.last = `killed by signal ${sig}`;
|
|
2065
|
+
saveStatus(logDir, status);
|
|
2066
|
+
appendLedger(ledger, "WORKER-END", {
|
|
2067
|
+
id: wid,
|
|
2068
|
+
provider: target.providerName,
|
|
2069
|
+
mode: "headless",
|
|
2070
|
+
model,
|
|
2071
|
+
rc: 143,
|
|
2072
|
+
secs: status.secs,
|
|
2073
|
+
killed: 1,
|
|
2074
|
+
label
|
|
2075
|
+
});
|
|
2076
|
+
cleanup();
|
|
2077
|
+
try {
|
|
2078
|
+
proc.kill();
|
|
2079
|
+
} catch {}
|
|
2080
|
+
process.exit(143);
|
|
2081
|
+
};
|
|
2082
|
+
process.once("SIGTERM", () => onSignal("SIGTERM"));
|
|
2083
|
+
process.once("SIGINT", () => onSignal("SIGINT"));
|
|
2084
|
+
const ctx = {
|
|
2085
|
+
resultEvent: null,
|
|
2086
|
+
resultReport: null
|
|
2087
|
+
};
|
|
2088
|
+
if (headless) {
|
|
2089
|
+
if (parsed.prompt !== null) try {
|
|
2090
|
+
proc.stdin.write(stdinUserMessage(parsed.prompt) + "\n");
|
|
2091
|
+
} catch {}
|
|
2092
|
+
eventsFd = openSync(eventsPath(logDir, wid), "a");
|
|
2093
|
+
createInterface({ input: proc.stdout }).on("line", (line) => {
|
|
2094
|
+
if (parsed.outputFormat === "stream-json") process.stdout.write(line + "\n");
|
|
2095
|
+
if (!line.startsWith("{")) return;
|
|
2096
|
+
let e;
|
|
2097
|
+
try {
|
|
2098
|
+
e = JSON.parse(line);
|
|
2099
|
+
} catch {
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
writeEvent(e);
|
|
2103
|
+
if (e.type === "system" && e.subtype === "init") {
|
|
2104
|
+
if (e.session_id) status.claudeSession = e.session_id;
|
|
2105
|
+
const cw = initContextWindow(e);
|
|
2106
|
+
if (cw) status.contextWindow = cw;
|
|
2107
|
+
saveStatus(logDir, status);
|
|
2108
|
+
} else if (e.type === "assistant") {
|
|
2109
|
+
status.turns += 1;
|
|
2110
|
+
const tokens = usageContextTokens(e.message?.usage);
|
|
2111
|
+
if (tokens) status.contextTokens = tokens;
|
|
2112
|
+
for (const block of e.message?.content ?? []) if (block.type === "tool_use") {
|
|
2113
|
+
status.tools += 1;
|
|
2114
|
+
status.last = describeTool(block.name ?? "?", block.input);
|
|
2115
|
+
} else if (block.type === "text" && (block.text ?? "").trim()) status.last = "says: " + shortText(block.text, 70);
|
|
2116
|
+
saveStatus(logDir, status);
|
|
2117
|
+
} else if (e.type === "result") {
|
|
2118
|
+
const tokens = usageContextTokens(e.usage);
|
|
2119
|
+
if (tokens) status.contextTokens = tokens;
|
|
2120
|
+
const report = parseWorkerReport(e.result ?? "");
|
|
2121
|
+
if (report?.notes) status.last = shortText(report.notes, 90);
|
|
2122
|
+
else if (e.result) status.last = shortText(e.result, 90);
|
|
2123
|
+
saveStatus(logDir, status);
|
|
2124
|
+
operatorInFlight = 0;
|
|
2125
|
+
armStdinClose();
|
|
2126
|
+
ctx.resultEvent = e;
|
|
2127
|
+
ctx.resultReport = report;
|
|
2128
|
+
}
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
const rc = await new Promise((resolve, reject) => {
|
|
2132
|
+
proc.on("error", reject);
|
|
2133
|
+
proc.on("close", (code) => resolve(code ?? (killed ? 143 : 1)));
|
|
2134
|
+
});
|
|
2135
|
+
if (eventsFd !== null) closeSync(eventsFd);
|
|
2136
|
+
cleanup();
|
|
2137
|
+
const secs = Math.floor((Date.now() - t0) / 1e3);
|
|
2138
|
+
const resultEvent = ctx.resultEvent;
|
|
2139
|
+
const ok = rc === 0 && resultEvent !== null && !resultEvent.is_error;
|
|
2140
|
+
status.state = ok ? "done" : "failed";
|
|
2141
|
+
status.rc = rc;
|
|
2142
|
+
status.secs = secs;
|
|
2143
|
+
if (resultEvent) status.last = shortText(resultEvent.result ?? "", 90);
|
|
2144
|
+
if (ctx.resultReport?.notes) status.last = shortText(ctx.resultReport.notes, 90);
|
|
2145
|
+
saveStatus(logDir, status);
|
|
2146
|
+
appendLedger(ledger, "WORKER-END", {
|
|
2147
|
+
id: wid,
|
|
2148
|
+
provider: target.providerName,
|
|
2149
|
+
mode: headless ? "headless" : "interactive",
|
|
2150
|
+
model,
|
|
2151
|
+
rc,
|
|
2152
|
+
secs,
|
|
2153
|
+
turns: status.turns,
|
|
2154
|
+
tools: status.tools,
|
|
2155
|
+
label
|
|
2156
|
+
});
|
|
2157
|
+
if (headless) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport);
|
|
2158
|
+
const resultText = resultEvent?.result ?? "";
|
|
2159
|
+
if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
|
|
2160
|
+
setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
|
|
2161
|
+
const next = nextAutoProvider(config, logDir, target.providerName);
|
|
2162
|
+
if (next && a.reroutes < config.routing.order.length) {
|
|
2163
|
+
appendLedger(ledger, "WORKER-REROUTE", {
|
|
2164
|
+
from: target.providerName,
|
|
2165
|
+
to: next,
|
|
2166
|
+
reason: "quota"
|
|
2167
|
+
});
|
|
2168
|
+
return runWorker({
|
|
2169
|
+
providerFlag: next,
|
|
2170
|
+
label,
|
|
2171
|
+
noPane: a.noPane,
|
|
2172
|
+
mcpFlag: a.mcpFlag,
|
|
2173
|
+
claudeArgs: a.claudeArgs,
|
|
2174
|
+
onWorkerStart: a.onWorkerStart,
|
|
2175
|
+
_reroutes: a.reroutes + 1
|
|
2176
|
+
});
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return rc !== 0 ? rc : ok ? 0 : 1;
|
|
2180
|
+
}
|
|
2181
|
+
async function executeCodexRun(a) {
|
|
2182
|
+
const { config, logDir, target, model, label, parsed, noPane } = a;
|
|
2183
|
+
if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine codex) supports headless runs only: pass the task with -p '<prompt>'`);
|
|
2184
|
+
const env = buildCodexEnv(target.provider);
|
|
2185
|
+
const wid = newWorkerId();
|
|
2186
|
+
const cwd = process.cwd();
|
|
2187
|
+
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
2188
|
+
const session = resolveSession(term);
|
|
2189
|
+
const status = {
|
|
2190
|
+
id: wid,
|
|
2191
|
+
pid: process.pid,
|
|
2192
|
+
label,
|
|
2193
|
+
cwd,
|
|
2194
|
+
term,
|
|
2195
|
+
provider: target.providerName,
|
|
2196
|
+
model,
|
|
2197
|
+
state: "running",
|
|
2198
|
+
started: nowStamp(),
|
|
2199
|
+
updated: nowStamp(),
|
|
2200
|
+
turns: 0,
|
|
2201
|
+
tools: 0,
|
|
2202
|
+
last: "starting",
|
|
2203
|
+
rc: null,
|
|
2204
|
+
secs: null,
|
|
2205
|
+
...session ? { session } : {},
|
|
2206
|
+
contextWindow: providerContextWindow(target.provider)
|
|
2207
|
+
};
|
|
2208
|
+
saveStatus(logDir, status);
|
|
2209
|
+
a.onWorkerStart?.(wid);
|
|
2210
|
+
const ledger = ledgerPath(logDir);
|
|
2211
|
+
appendLedger(ledger, "WORKER-START", {
|
|
2212
|
+
id: wid,
|
|
2213
|
+
provider: target.providerName,
|
|
2214
|
+
mode: "headless",
|
|
2215
|
+
engine: "codex",
|
|
2216
|
+
model,
|
|
2217
|
+
cwd,
|
|
2218
|
+
label
|
|
2219
|
+
});
|
|
2220
|
+
const dropped = codexDroppedFlags(a.claudeArgs);
|
|
2221
|
+
if (dropped.length) appendLedger(ledger, "WORKER-NOTE", {
|
|
2222
|
+
id: wid,
|
|
2223
|
+
note: `dropped for codex: ${dropped.join(", ")}`
|
|
2224
|
+
});
|
|
2225
|
+
if (!noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
|
|
2226
|
+
const t0 = Date.now();
|
|
2227
|
+
const proc = spawn("codex", buildCodexArgs(parsed.prompt, parsed.callerModel ? void 0 : model), {
|
|
2228
|
+
env,
|
|
2229
|
+
cwd,
|
|
2230
|
+
stdio: [
|
|
2231
|
+
"ignore",
|
|
2232
|
+
"pipe",
|
|
2233
|
+
"inherit"
|
|
2234
|
+
]
|
|
2235
|
+
});
|
|
2236
|
+
const fold = emptyCodexResult();
|
|
2237
|
+
const eventsFd = openSync(eventsPath(logDir, wid), "a");
|
|
2238
|
+
const writeEvent = (obj) => {
|
|
2239
|
+
try {
|
|
2240
|
+
writeSync(eventsFd, JSON.stringify({
|
|
2241
|
+
...obj,
|
|
2242
|
+
_ts: isoStamp()
|
|
2243
|
+
}) + "\n");
|
|
2244
|
+
} catch {}
|
|
2245
|
+
};
|
|
2246
|
+
writeEvent({
|
|
2247
|
+
type: "system",
|
|
2248
|
+
subtype: "init",
|
|
2249
|
+
model,
|
|
2250
|
+
cwd
|
|
2251
|
+
});
|
|
2252
|
+
let killed = false;
|
|
2253
|
+
process.once("SIGTERM", onCodexSignal("SIGTERM"));
|
|
2254
|
+
process.once("SIGINT", onCodexSignal("SIGINT"));
|
|
2255
|
+
function onCodexSignal(sig) {
|
|
2256
|
+
return () => {
|
|
2257
|
+
killed = true;
|
|
2258
|
+
status.state = "killed";
|
|
2259
|
+
status.rc = 143;
|
|
2260
|
+
status.secs = Math.floor((Date.now() - t0) / 1e3);
|
|
2261
|
+
status.last = `killed by signal ${sig}`;
|
|
2262
|
+
saveStatus(logDir, status);
|
|
2263
|
+
try {
|
|
2264
|
+
proc.kill();
|
|
2265
|
+
} catch {}
|
|
2266
|
+
process.exit(143);
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
createInterface({ input: proc.stdout }).on("line", (line) => {
|
|
2270
|
+
if (parsed.outputFormat === "stream-json") process.stdout.write(line + "\n");
|
|
2271
|
+
const parsedLine = parseCodexLine(line);
|
|
2272
|
+
if (parsedLine === null) return;
|
|
2273
|
+
foldCodexLine(parsedLine, fold);
|
|
2274
|
+
if (fold.threadId && !status.claudeSession) status.claudeSession = fold.threadId;
|
|
2275
|
+
status.turns = fold.turns;
|
|
2276
|
+
status.tools = fold.tools;
|
|
2277
|
+
if (fold.last) status.last = shortText(fold.last, 90);
|
|
2278
|
+
if (fold.contextTokens) status.contextTokens = fold.contextTokens;
|
|
2279
|
+
saveStatus(logDir, status);
|
|
2280
|
+
for (const ev of fold.events.splice(0)) writeEvent(ev);
|
|
2281
|
+
});
|
|
2282
|
+
const rc = await new Promise((resolve, reject) => {
|
|
2283
|
+
proc.on("error", reject);
|
|
2284
|
+
proc.on("close", (code) => resolve(code ?? (killed ? 143 : 1)));
|
|
2285
|
+
});
|
|
2286
|
+
closeSync(eventsFd);
|
|
2287
|
+
const secs = Math.floor((Date.now() - t0) / 1e3);
|
|
2288
|
+
const finalText = fold.finalText ?? "";
|
|
2289
|
+
const report = parseWorkerReport(finalText);
|
|
2290
|
+
const resultEvent = {
|
|
2291
|
+
type: "result",
|
|
2292
|
+
result: finalText,
|
|
2293
|
+
is_error: fold.isError || rc !== 0,
|
|
2294
|
+
num_turns: fold.turns,
|
|
2295
|
+
duration_ms: secs * 1e3
|
|
2296
|
+
};
|
|
2297
|
+
writeEvent(resultEvent);
|
|
2298
|
+
const ok = rc === 0 && !fold.isError;
|
|
2299
|
+
status.state = ok ? "done" : "failed";
|
|
2300
|
+
status.rc = rc;
|
|
2301
|
+
status.secs = secs;
|
|
2302
|
+
status.last = shortText(report?.notes ?? finalText, 90) || (ok ? "done" : "failed");
|
|
2303
|
+
saveStatus(logDir, status);
|
|
2304
|
+
appendLedger(ledger, "WORKER-END", {
|
|
2305
|
+
id: wid,
|
|
2306
|
+
provider: target.providerName,
|
|
2307
|
+
mode: "headless",
|
|
2308
|
+
engine: "codex",
|
|
2309
|
+
model,
|
|
2310
|
+
rc,
|
|
2311
|
+
secs,
|
|
2312
|
+
turns: status.turns,
|
|
2313
|
+
tools: status.tools,
|
|
2314
|
+
label
|
|
2315
|
+
});
|
|
2316
|
+
printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report);
|
|
2317
|
+
return rc !== 0 ? rc : ok ? 0 : 1;
|
|
2318
|
+
}
|
|
2319
|
+
function printResult(fmt, resultEvent, rc, logDir, wid, report) {
|
|
2320
|
+
if (fmt === "stream-json") return;
|
|
2321
|
+
if (fmt === "json") {
|
|
2322
|
+
const payload = report ? {
|
|
2323
|
+
...resultEvent ?? {
|
|
2324
|
+
is_error: true,
|
|
2325
|
+
result: "no result event",
|
|
2326
|
+
rc
|
|
2327
|
+
},
|
|
2328
|
+
report
|
|
2329
|
+
} : resultEvent ?? {
|
|
2330
|
+
is_error: true,
|
|
2331
|
+
result: "no result event",
|
|
2332
|
+
rc
|
|
2333
|
+
};
|
|
2334
|
+
console.log(JSON.stringify(payload));
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (resultEvent) console.log(resultEvent.result ?? "");
|
|
2338
|
+
else process.stderr.write(`pai worker: run produced no result (rc=${rc}); see ${eventsPath(logDir, wid)}\n`);
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Run a one-word pong probe through the provider (headless, no pane) and
|
|
2342
|
+
* report provider, model, latency and the reply. ok is false unless the reply
|
|
2343
|
+
* was exactly "pong" (case-insensitive, whitespace-trimmed).
|
|
2344
|
+
*/
|
|
2345
|
+
async function testProvider(providerName, provider, logDir, timeoutMs = 9e4) {
|
|
2346
|
+
assertProviderRunnable(providerName, provider);
|
|
2347
|
+
const model = provider.models.default;
|
|
2348
|
+
if (provider.engine === "codex") {
|
|
2349
|
+
const skipped = codexInstalled() ? void 0 : "codex not installed";
|
|
2350
|
+
return {
|
|
2351
|
+
provider: providerName,
|
|
2352
|
+
model,
|
|
2353
|
+
latencyMs: 0,
|
|
2354
|
+
result: skipped ?? "codex engine: pong probe through codex exec not implemented",
|
|
2355
|
+
ok: false,
|
|
2356
|
+
...skipped ? { skipped } : {}
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
let proxyUrl;
|
|
2360
|
+
if (provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${providerName}`;
|
|
2361
|
+
const env = buildRunEnv(provider, true, proxyUrl);
|
|
2362
|
+
const t0 = Date.now();
|
|
2363
|
+
const proc = spawn("claude", [
|
|
2364
|
+
"--model",
|
|
2365
|
+
model,
|
|
2366
|
+
"--strict-mcp-config",
|
|
2367
|
+
"--mcp-config",
|
|
2368
|
+
ensureNoMcpConfig(logDir),
|
|
2369
|
+
"-p",
|
|
2370
|
+
"Reply with exactly one word: pong",
|
|
2371
|
+
"--output-format",
|
|
2372
|
+
"json"
|
|
2373
|
+
], {
|
|
2374
|
+
env,
|
|
2375
|
+
stdio: [
|
|
2376
|
+
"ignore",
|
|
2377
|
+
"pipe",
|
|
2378
|
+
"inherit"
|
|
2379
|
+
]
|
|
2380
|
+
});
|
|
2381
|
+
const timer = setTimeout(() => {
|
|
2382
|
+
try {
|
|
2383
|
+
proc.kill("SIGKILL");
|
|
2384
|
+
} catch {}
|
|
2385
|
+
}, timeoutMs);
|
|
2386
|
+
let out = "";
|
|
2387
|
+
proc.stdout.on("data", (chunk) => {
|
|
2388
|
+
out += chunk.toString("utf8");
|
|
2389
|
+
});
|
|
2390
|
+
const rc = await new Promise((resolve) => {
|
|
2391
|
+
proc.on("error", () => resolve(1));
|
|
2392
|
+
proc.on("close", (code) => resolve(code ?? 1));
|
|
2393
|
+
});
|
|
2394
|
+
clearTimeout(timer);
|
|
2395
|
+
return {
|
|
2396
|
+
provider: providerName,
|
|
2397
|
+
model,
|
|
2398
|
+
latencyMs: Date.now() - t0,
|
|
2399
|
+
result: resultFromOutput(out),
|
|
2400
|
+
ok: rc === 0 && resultFromOutput(out).trim().toLowerCase() === "pong"
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
/**
|
|
2404
|
+
* Pull the reply out of claude's stdout. With --verbose, `--output-format
|
|
2405
|
+
* json` dumps a JSON array of stream events and the reply sits in the last
|
|
2406
|
+
* "result" event; without it, stdout is the single result object. Accept
|
|
2407
|
+
* either shape, plus a bare-text fallback for error output.
|
|
2408
|
+
*/
|
|
2409
|
+
function resultFromOutput(out) {
|
|
2410
|
+
const trimmed = out.trim();
|
|
2411
|
+
if (!trimmed) return "";
|
|
2412
|
+
const parse = (s) => {
|
|
2413
|
+
try {
|
|
2414
|
+
return JSON.parse(s);
|
|
2415
|
+
} catch {
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
};
|
|
2419
|
+
const fromValue = (v) => {
|
|
2420
|
+
if (Array.isArray(v)) {
|
|
2421
|
+
for (let i = v.length - 1; i >= 0; i--) {
|
|
2422
|
+
const r = fromValue(v[i]);
|
|
2423
|
+
if (r !== null) return r;
|
|
2424
|
+
}
|
|
2425
|
+
return null;
|
|
2426
|
+
}
|
|
2427
|
+
if (typeof v === "object" && v !== null) {
|
|
2428
|
+
const o = v;
|
|
2429
|
+
if (o.type === "result" && typeof o.result === "string") return o.result;
|
|
2430
|
+
}
|
|
2431
|
+
return null;
|
|
2432
|
+
};
|
|
2433
|
+
const direct = fromValue(parse(trimmed));
|
|
2434
|
+
if (direct !== null) return direct;
|
|
2435
|
+
const lines = trimmed.split("\n");
|
|
2436
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2437
|
+
const r = fromValue(parse(lines[i]));
|
|
2438
|
+
if (r !== null) return r;
|
|
2439
|
+
}
|
|
2440
|
+
return trimmed.slice(0, 200);
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
//#endregion
|
|
2444
|
+
//#region src/workers/render.ts
|
|
2445
|
+
/**
|
|
2446
|
+
* render.ts — turn worker events into the lines a human reads.
|
|
2447
|
+
*
|
|
2448
|
+
* Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
|
|
2449
|
+
* and searches, `$` for shell, magenta `~` + red/green diff for edits, green
|
|
2450
|
+
* `+` for writes), Read-result trimming (first 3 lines then a count), and the
|
|
2451
|
+
* result footer. Colors are applied only when the output is a TTY, so MCP
|
|
2452
|
+
* tool output stays plain text.
|
|
2453
|
+
*/
|
|
2454
|
+
const CODES = {
|
|
2455
|
+
dim: "2",
|
|
2456
|
+
bold: "1",
|
|
2457
|
+
red: "31",
|
|
2458
|
+
green: "32",
|
|
2459
|
+
yellow: "33",
|
|
2460
|
+
blue: "34",
|
|
2461
|
+
mag: "35",
|
|
2462
|
+
cyan: "36"
|
|
2463
|
+
};
|
|
2464
|
+
function makeColor(enabled) {
|
|
2465
|
+
return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
|
|
2466
|
+
}
|
|
2467
|
+
/** Path relative to cwd when it lies inside it, otherwise unchanged. */
|
|
2468
|
+
function relPath(path, cwd) {
|
|
2469
|
+
if (!path || !cwd) return path;
|
|
2470
|
+
let r;
|
|
2471
|
+
try {
|
|
2472
|
+
r = relative(cwd, path);
|
|
2473
|
+
} catch {
|
|
2474
|
+
return path;
|
|
2475
|
+
}
|
|
2476
|
+
return r.startsWith("..") ? path : r;
|
|
2477
|
+
}
|
|
2478
|
+
/** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
|
|
2479
|
+
function unifiedDiffLines(oldStr, newStr) {
|
|
2480
|
+
const oldL = oldStr.split("\n");
|
|
2481
|
+
const newL = newStr.split("\n");
|
|
2482
|
+
let start = 0;
|
|
2483
|
+
while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
|
|
2484
|
+
let endOld = oldL.length;
|
|
2485
|
+
let endNew = newL.length;
|
|
2486
|
+
while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
|
|
2487
|
+
endOld--;
|
|
2488
|
+
endNew--;
|
|
2489
|
+
}
|
|
2490
|
+
const out = [];
|
|
2491
|
+
for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
|
|
2492
|
+
for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
|
|
2493
|
+
return out;
|
|
2494
|
+
}
|
|
2495
|
+
/**
|
|
2496
|
+
* The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
|
|
2497
|
+
* the worker tag in front when several run at once. Continuation lines get
|
|
2498
|
+
* blanks of the same width so wrapped text stays aligned. null when the event
|
|
2499
|
+
* carries no stamp (logs from before 2g render with the plain prefix).
|
|
2500
|
+
*/
|
|
2501
|
+
function gutterFor(c, e, tag) {
|
|
2502
|
+
if (!e._ts) return null;
|
|
2503
|
+
const time = e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts;
|
|
2504
|
+
const head = tag ? `${tag} ${time}` : time;
|
|
2505
|
+
const width = head.length + 3;
|
|
2506
|
+
return {
|
|
2507
|
+
first: c("dim", `${head} │ `),
|
|
2508
|
+
cont: " ".repeat(width)
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
/** Compact token count: 84k, 200k, 900. */
|
|
2512
|
+
function fmtK(n) {
|
|
2513
|
+
return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
2514
|
+
}
|
|
2515
|
+
/**
|
|
2516
|
+
* The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.
|
|
2517
|
+
* null when the numbers are missing or below `minPct` (the table only shows
|
|
2518
|
+
* it past 60; the pane liveness line always shows it).
|
|
2519
|
+
*/
|
|
2520
|
+
function contextMeter(c, s, minPct = 0) {
|
|
2521
|
+
const pct = contextPercent(s);
|
|
2522
|
+
if (pct === null || pct <= minPct) return null;
|
|
2523
|
+
const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;
|
|
2524
|
+
if (pct > 85) return c("red", label);
|
|
2525
|
+
if (pct > 70) return c("yellow", label);
|
|
2526
|
+
return label;
|
|
2527
|
+
}
|
|
2528
|
+
function renderToolUse(c, prefix, name, inp, cwd) {
|
|
2529
|
+
const i = typeof inp === "object" && inp !== null ? inp : {};
|
|
2530
|
+
const get = (k) => typeof i[k] === "string" ? i[k] : "";
|
|
2531
|
+
if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
|
|
2532
|
+
if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
|
|
2533
|
+
if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
|
|
2534
|
+
if (name === "Edit") {
|
|
2535
|
+
const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
|
|
2536
|
+
for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
|
|
2537
|
+
else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
|
|
2538
|
+
else out.push(`${prefix} ${c("dim", line)}`);
|
|
2539
|
+
return out;
|
|
2540
|
+
}
|
|
2541
|
+
if (name === "Write") {
|
|
2542
|
+
const n = get("content").split("\n").length;
|
|
2543
|
+
return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
|
|
2544
|
+
}
|
|
2545
|
+
if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
|
|
2546
|
+
return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
|
|
2547
|
+
}
|
|
2548
|
+
/** Render one stream-json event; `tools` maps tool_use_id → tool name. */
|
|
2549
|
+
function renderEvent(c, prefix, e, cwd, tools, gutter) {
|
|
2550
|
+
const out = [];
|
|
2551
|
+
if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
|
|
2552
|
+
else if (e.type === "operator") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
|
|
2553
|
+
else if (e.type === "assistant") {
|
|
2554
|
+
for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) for (const ln of (b.text ?? "").trim().split("\n")) out.push(`${prefix}${ln}`);
|
|
2555
|
+
else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
|
|
2556
|
+
} else if (e.type === "user") for (const b of e.message?.content ?? []) {
|
|
2557
|
+
if (b.type !== "tool_result") continue;
|
|
2558
|
+
let content = b.content ?? "";
|
|
2559
|
+
if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
|
|
2560
|
+
const text = String(content);
|
|
2561
|
+
const isError = b.is_error === true;
|
|
2562
|
+
if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
|
|
2563
|
+
const lines = text.split("\n");
|
|
2564
|
+
for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
|
|
2565
|
+
if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
|
|
2566
|
+
} else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
|
|
2567
|
+
else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
|
|
2568
|
+
}
|
|
2569
|
+
else if (e.type === "result") {
|
|
2570
|
+
const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
|
|
2571
|
+
out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
|
|
2572
|
+
const report = parseWorkerReport(String(e.result ?? ""));
|
|
2573
|
+
if (report) out.push(...renderReport(c, prefix, report, cwd));
|
|
2574
|
+
else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
|
|
2575
|
+
}
|
|
2576
|
+
if (!gutter) return out;
|
|
2577
|
+
return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
|
|
2578
|
+
}
|
|
2579
|
+
/** Transcript header: id, provider, label, session name, project dir. */
|
|
2580
|
+
function headerLine(c, s) {
|
|
2581
|
+
const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
|
|
2582
|
+
const sep = bits ? ` ${bits}` : "";
|
|
2583
|
+
return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
|
|
2584
|
+
}
|
|
2585
|
+
/** The ps table (RUNNING + FINISHED last 8). */
|
|
2586
|
+
function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date()) {
|
|
2587
|
+
const running = [];
|
|
2588
|
+
const done = [];
|
|
2589
|
+
for (const s of statuses) if (s.state === "running" && alive(s.pid)) running.push(s);
|
|
2590
|
+
else {
|
|
2591
|
+
if (s.state === "running") s.state = "lost";
|
|
2592
|
+
done.push(s);
|
|
2593
|
+
}
|
|
2594
|
+
const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
|
|
2595
|
+
lines.push(c("bold", `RUNNING (${running.length})`));
|
|
2596
|
+
if (!running.length) lines.push(" none");
|
|
2597
|
+
for (const s of running) {
|
|
2598
|
+
const meter = contextMeter(c, s, 60);
|
|
2599
|
+
lines.push(` ${c("cyan", s.id)} [${s.provider}] ${ageOf(s.started, now).padStart(4)} old turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)}${meter ? " " + meter : ""}`);
|
|
2600
|
+
lines.push(` task: ${s.label}`);
|
|
2601
|
+
lines.push(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`);
|
|
2602
|
+
}
|
|
2603
|
+
lines.push("");
|
|
2604
|
+
lines.push(c("bold", "FINISHED (last 8)"));
|
|
2605
|
+
for (const s of done.slice(-8)) {
|
|
2606
|
+
const col = s.state === "done" ? "green" : "red";
|
|
2607
|
+
const tag = sessionTag(s);
|
|
2608
|
+
lines.push(` ${s.id} [${s.provider}]${tag ? " " + tag : ""} ${c(col, s.state.padEnd(6))} rc=${s.rc} ${String(s.secs ?? "?").padStart(4)}s turns ${String(s.turns).padStart(2)} tools ${String(s.tools).padStart(2)} ${basename(s.cwd)} ${s.label}`);
|
|
2609
|
+
}
|
|
2610
|
+
lines.push("");
|
|
2611
|
+
lines.push(c("dim", "worker follow live transcript of running workers"));
|
|
2612
|
+
lines.push(c("dim", "worker <id> replay one worker"));
|
|
2613
|
+
lines.push(c("dim", scopeLabel));
|
|
2614
|
+
return lines.join("\n");
|
|
2615
|
+
}
|
|
2616
|
+
/** One-line status-bar summary (same shape glm-ps --status printed). */
|
|
2617
|
+
function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(true)) {
|
|
2618
|
+
if (!mine.length) return "";
|
|
2619
|
+
const running = mine.filter((s) => s.state === "running" && alive(s.pid));
|
|
2620
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
2621
|
+
const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
|
|
2622
|
+
const ok = doneToday.filter((s) => s.state === "done").length;
|
|
2623
|
+
const bad = doneToday.length - ok;
|
|
2624
|
+
const providers = new Set(running.map((s) => s.provider));
|
|
2625
|
+
let head = `${providers.size === 1 ? [...providers][0] : "workers"} ▶${running.length}`;
|
|
2626
|
+
const parts = running.slice(0, 3).map((s) => {
|
|
2627
|
+
const meter = contextMeter(c, s, 60);
|
|
2628
|
+
return `${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}` + (meter ? ` ${meter}` : "");
|
|
2629
|
+
});
|
|
2630
|
+
if (parts.length) head += " " + parts.join(" | ");
|
|
2631
|
+
if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
|
|
2632
|
+
return head;
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
//#endregion
|
|
2636
|
+
//#region src/workers/viewer.ts
|
|
2637
|
+
/**
|
|
2638
|
+
* viewer.ts — ps / follow / replay / status line over the worker logDir.
|
|
2639
|
+
*
|
|
2640
|
+
* Scoping: workers launched from this terminal's AIBroker session (or, as the
|
|
2641
|
+
* fallback, its iTerm tab) unless --all or an explicit worker id is given.
|
|
2642
|
+
* Outside iTerm, everything degrades to "all workers" — the Python behaviour.
|
|
2643
|
+
*
|
|
2644
|
+
* Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
|
|
2645
|
+
* `_ts` stamp on every mirrored event, the worker tag in front when several
|
|
2646
|
+
* run at once, a date separator when the day changes, and — on a TTY — a
|
|
2647
|
+
* liveness line (`⋯ 12s since last event · Bash: npm test · ctx 84k/200k (42%)`)
|
|
2648
|
+
* that is rewritten in place between events. Following one worker also wires
|
|
2649
|
+
* this pane's stdin: every typed line is said to the worker while it runs and
|
|
2650
|
+
* resumes it (same Claude session) once it has finished.
|
|
2651
|
+
*/
|
|
2652
|
+
function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
|
|
2653
|
+
const c = makeColor(color);
|
|
2654
|
+
const term = env.ITERM_SESSION_ID ?? "";
|
|
2655
|
+
const statuses = loadStatuses(logDir);
|
|
2656
|
+
return renderTable(c, showAll || !term ? statuses : statuses.filter((s) => workerInScope(s, term)), showAll || !term ? "scope: all workers" : resolveSession(term) ? `scope: session ${resolveSession(term).name}` : `scope: tab ${currentTabKey(env)} (this iTerm tab)`);
|
|
2657
|
+
}
|
|
2658
|
+
/** The rendered transcript of one worker, from the start, as one string. */
|
|
2659
|
+
function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
|
|
2660
|
+
const path = eventsPath(logDir, wid);
|
|
2661
|
+
if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
|
|
2662
|
+
const c = makeColor(color);
|
|
2663
|
+
const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
|
|
2664
|
+
id: wid,
|
|
2665
|
+
label: "",
|
|
2666
|
+
cwd: "",
|
|
2667
|
+
provider: ""
|
|
2668
|
+
};
|
|
2669
|
+
const tools = {};
|
|
2670
|
+
const out = [headerLine(c, st)];
|
|
2671
|
+
const lines = readFileSync(path, "utf8").split("\n");
|
|
2672
|
+
let lastDay = "";
|
|
2673
|
+
for (const line of tailLines !== void 0 ? lines.slice(-tailLines) : lines) {
|
|
2674
|
+
if (!line.trim()) continue;
|
|
2675
|
+
let e;
|
|
2676
|
+
try {
|
|
2677
|
+
e = JSON.parse(line);
|
|
2678
|
+
} catch {
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
if (typeof e._ts === "string" && e._ts.slice(0, 10) !== lastDay) {
|
|
2682
|
+
lastDay = e._ts.slice(0, 10);
|
|
2683
|
+
out.push(c("dim", `── ${lastDay} ──`));
|
|
2684
|
+
}
|
|
2685
|
+
const g = gutterFor(c, e);
|
|
2686
|
+
collectTools(e, tools);
|
|
2687
|
+
out.push(...renderEvent(c, g ? "" : " ", e, st.cwd ?? "", tools, g));
|
|
2688
|
+
}
|
|
2689
|
+
return out.join("\n");
|
|
2690
|
+
}
|
|
2691
|
+
function collectTools(e, tools) {
|
|
2692
|
+
if (e.type !== "assistant") return;
|
|
2693
|
+
for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
|
|
2694
|
+
}
|
|
2695
|
+
/**
|
|
2696
|
+
* The follow exit decision for one worker: it is over once its result event
|
|
2697
|
+
* was rendered, or once its status left "running" while its pid is gone — a
|
|
2698
|
+
* worker killed without writing a result still ends. A status never read
|
|
2699
|
+
* (undefined) means "not over": follow keeps waiting for the first event.
|
|
2700
|
+
*/
|
|
2701
|
+
function workerEnded(resultRendered, state, pidAlive) {
|
|
2702
|
+
return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
|
|
2703
|
+
}
|
|
2704
|
+
/**
|
|
2705
|
+
* Tail one worker (target) or the running workers of this scope, live.
|
|
2706
|
+
* Auto-exit: with a target, wait `autoExit` seconds after its end; without
|
|
2707
|
+
* one, exit once no worker in scope has run for that many seconds in a row,
|
|
2708
|
+
* never within the first 30 s. With a target on a TTY, typed stdin lines are
|
|
2709
|
+
* said to the worker (or resume it after it finished).
|
|
2710
|
+
*/
|
|
2711
|
+
async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true) {
|
|
2712
|
+
const c = makeColor(color);
|
|
2713
|
+
const tty = process.stdout.isTTY === true;
|
|
2714
|
+
const term = env.ITERM_SESSION_ID ?? "";
|
|
2715
|
+
const scopeTab = target || showAll ? "" : currentTabKey(env);
|
|
2716
|
+
const tools = {};
|
|
2717
|
+
const handles = /* @__PURE__ */ new Map();
|
|
2718
|
+
const seenHeader = /* @__PURE__ */ new Set();
|
|
2719
|
+
const finished = /* @__PURE__ */ new Set();
|
|
2720
|
+
const lastDayBy = /* @__PURE__ */ new Map();
|
|
2721
|
+
const started = Date.now();
|
|
2722
|
+
let idleSince = null;
|
|
2723
|
+
let aborted = false;
|
|
2724
|
+
let lastEventAt = Date.now();
|
|
2725
|
+
let lastAction = "waiting for first event";
|
|
2726
|
+
let meterStatus = null;
|
|
2727
|
+
let livenessLen = 0;
|
|
2728
|
+
const onInt = () => {
|
|
2729
|
+
aborted = true;
|
|
2730
|
+
};
|
|
2731
|
+
process.once("SIGINT", onInt);
|
|
2732
|
+
const plainOf = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
2733
|
+
const eraseLiveness = () => {
|
|
2734
|
+
if (livenessLen > 0) {
|
|
2735
|
+
process.stdout.write("\r" + " ".repeat(livenessLen) + "\r");
|
|
2736
|
+
livenessLen = 0;
|
|
2737
|
+
}
|
|
2738
|
+
};
|
|
2739
|
+
const writeLiveness = () => {
|
|
2740
|
+
if (!tty) return;
|
|
2741
|
+
eraseLiveness();
|
|
2742
|
+
const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
|
|
2743
|
+
const meter = meterStatus ? contextMeter(c, meterStatus) : null;
|
|
2744
|
+
const plain = `⋯ ${secs}s since last event · ${lastAction}${meter ? ` · ${plainOf(meter)}` : ""}`;
|
|
2745
|
+
process.stdout.write(`
|
|
2746
|
+
livenessLen = plain.length;
|
|
2747
|
+
};
|
|
2748
|
+
const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
|
|
2749
|
+
const noteLine = (s) => {
|
|
2750
|
+
eraseLiveness();
|
|
2751
|
+
process.stdout.write(s + "\n");
|
|
2752
|
+
};
|
|
2753
|
+
const resumeTarget = (text, id) => {
|
|
2754
|
+
noteLine(c("dim", `» resuming ${id} …`));
|
|
2755
|
+
const child = spawn("pai", [
|
|
2756
|
+
"worker",
|
|
2757
|
+
"resume",
|
|
2758
|
+
id,
|
|
2759
|
+
text,
|
|
2760
|
+
"--print-id",
|
|
2761
|
+
"--no-pane"
|
|
2762
|
+
], { stdio: [
|
|
2763
|
+
"ignore",
|
|
2764
|
+
"pipe",
|
|
2765
|
+
"inherit"
|
|
2766
|
+
] });
|
|
2767
|
+
let idOut = "";
|
|
2768
|
+
child.stdout?.on("data", (chunk) => {
|
|
2769
|
+
idOut += chunk.toString("utf8");
|
|
2770
|
+
});
|
|
2771
|
+
child.on("close", (rc) => {
|
|
2772
|
+
const newId = idOut.trim().split("\n").pop() ?? "";
|
|
2773
|
+
if (rc === 0 && /^[0-9]{8}-[0-9]{6}-[0-9]+$/.test(newId)) {
|
|
2774
|
+
noteLine(c("dim", `» resumed as ${newId}`));
|
|
2775
|
+
target = newId;
|
|
2776
|
+
finished.delete(newId);
|
|
2777
|
+
seenHeader.delete(newId);
|
|
2778
|
+
lastEventAt = Date.now();
|
|
2779
|
+
lastAction = "resumed";
|
|
2780
|
+
} else noteLine(c("red", `» resume failed (rc=${rc})`));
|
|
2781
|
+
});
|
|
2782
|
+
};
|
|
2783
|
+
const handleOperatorLine = (raw) => {
|
|
2784
|
+
const text = raw.trim();
|
|
2785
|
+
if (!text || target === null) return;
|
|
2786
|
+
const id = target;
|
|
2787
|
+
sayToWorker(logDir, id, text).then(() => noteLine(c("dim", `» sent to ${id}`)), (e) => {
|
|
2788
|
+
if (loadStatuses(logDir).some((s) => s.id === id)) resumeTarget(text, id);
|
|
2789
|
+
else noteLine(c("red", `» ${e.message}`));
|
|
2790
|
+
});
|
|
2791
|
+
};
|
|
2792
|
+
let rlIn = null;
|
|
2793
|
+
if (target !== null && process.stdin.isTTY) {
|
|
2794
|
+
rlIn = createInterface({ input: process.stdin });
|
|
2795
|
+
rlIn.on("line", handleOperatorLine);
|
|
2796
|
+
}
|
|
2797
|
+
try {
|
|
2798
|
+
for (;;) {
|
|
2799
|
+
if (aborted) return;
|
|
2800
|
+
for (const wid of target ? [target] : runningIds()) {
|
|
2801
|
+
if (handles.has(wid) || finished.has(wid)) continue;
|
|
2802
|
+
const path = eventsPath(logDir, wid);
|
|
2803
|
+
if (existsSync(path)) handles.set(wid, {
|
|
2804
|
+
fd: openSync(path, "r"),
|
|
2805
|
+
buf: ""
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
|
|
2809
|
+
const multi = handles.size > 1 || target === null;
|
|
2810
|
+
let progressed = false;
|
|
2811
|
+
for (const [wid, h] of [...handles.entries()]) {
|
|
2812
|
+
const st = statuses.get(wid) ?? {
|
|
2813
|
+
id: wid,
|
|
2814
|
+
label: "",
|
|
2815
|
+
cwd: "",
|
|
2816
|
+
provider: ""
|
|
2817
|
+
};
|
|
2818
|
+
const prefix = multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " ";
|
|
2819
|
+
const buffer = Buffer.alloc(65536);
|
|
2820
|
+
for (;;) {
|
|
2821
|
+
let n;
|
|
2822
|
+
try {
|
|
2823
|
+
n = readSync(h.fd, buffer, 0, buffer.length, null);
|
|
2824
|
+
} catch {
|
|
2825
|
+
n = 0;
|
|
2826
|
+
}
|
|
2827
|
+
if (n <= 0) break;
|
|
2828
|
+
h.buf += buffer.toString("utf8", 0, n);
|
|
2829
|
+
}
|
|
2830
|
+
const lines = h.buf.split("\n");
|
|
2831
|
+
h.buf = lines.pop() ?? "";
|
|
2832
|
+
for (const line of lines) {
|
|
2833
|
+
progressed = true;
|
|
2834
|
+
if (!seenHeader.has(wid)) {
|
|
2835
|
+
eraseLiveness();
|
|
2836
|
+
process.stdout.write(headerLine(c, st) + "\n");
|
|
2837
|
+
seenHeader.add(wid);
|
|
2838
|
+
}
|
|
2839
|
+
if (!line.trim()) continue;
|
|
2840
|
+
let e;
|
|
2841
|
+
try {
|
|
2842
|
+
e = JSON.parse(line);
|
|
2843
|
+
} catch {
|
|
2844
|
+
continue;
|
|
2845
|
+
}
|
|
2846
|
+
const day = typeof e._ts === "string" ? e._ts.slice(0, 10) : "";
|
|
2847
|
+
if (day && day !== lastDayBy.get(wid)) {
|
|
2848
|
+
eraseLiveness();
|
|
2849
|
+
process.stdout.write(`${multi ? prefix : ""}${c("dim", `── ${day} ──`)}\n`);
|
|
2850
|
+
lastDayBy.set(wid, day);
|
|
2851
|
+
}
|
|
2852
|
+
collectTools(e, tools);
|
|
2853
|
+
const g = gutterFor(c, e, multi ? c("cyan", wid.slice(-4)) : void 0);
|
|
2854
|
+
eraseLiveness();
|
|
2855
|
+
for (const ln of renderEvent(c, g ? "" : prefix, e, st.cwd ?? "", tools, g)) process.stdout.write(ln + "\n");
|
|
2856
|
+
lastEventAt = Date.now();
|
|
2857
|
+
if (e.type === "operator") lastAction = "operator message";
|
|
2858
|
+
else if (e.type === "assistant") {
|
|
2859
|
+
const tool = (e.message?.content ?? []).find((b) => b.type === "tool_use");
|
|
2860
|
+
lastAction = tool ? `${tool.name ?? "?"} running` : "thinking";
|
|
2861
|
+
} else if (e.type === "result") lastAction = "finished";
|
|
2862
|
+
meterStatus = st;
|
|
2863
|
+
if (e.type === "result") finished.add(wid);
|
|
2864
|
+
}
|
|
2865
|
+
if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
|
|
2866
|
+
if (!finished.has(wid)) {
|
|
2867
|
+
eraseLiveness();
|
|
2868
|
+
process.stdout.write(`${prefix}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}\n`);
|
|
2869
|
+
finished.add(wid);
|
|
2870
|
+
}
|
|
2871
|
+
closeSync(h.fd);
|
|
2872
|
+
handles.delete(wid);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
const lingerOn = target;
|
|
2876
|
+
if (lingerOn !== null && finished.has(lingerOn)) {
|
|
2877
|
+
if (autoExit) {
|
|
2878
|
+
const until = Date.now() + autoExit * 1e3;
|
|
2879
|
+
while (Date.now() < until && !aborted && target === lingerOn) {
|
|
2880
|
+
writeLiveness();
|
|
2881
|
+
await sleep(250);
|
|
2882
|
+
}
|
|
2883
|
+
if (aborted || target !== lingerOn) continue;
|
|
2884
|
+
eraseLiveness();
|
|
2885
|
+
process.stdout.write(c("dim", "closing") + "\n");
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
if (rlIn === null) return;
|
|
2889
|
+
}
|
|
2890
|
+
if (autoExit && !target) {
|
|
2891
|
+
if (runningIds().length) idleSince = null;
|
|
2892
|
+
else if (idleSince === null) idleSince = Date.now();
|
|
2893
|
+
else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
|
|
2894
|
+
eraseLiveness();
|
|
2895
|
+
process.stdout.write(c("dim", "closing") + "\n");
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
if (!progressed) {
|
|
2900
|
+
writeLiveness();
|
|
2901
|
+
await sleep(500);
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
} finally {
|
|
2905
|
+
rlIn?.close();
|
|
2906
|
+
process.removeListener("SIGINT", onInt);
|
|
2907
|
+
for (const h of handles.values()) try {
|
|
2908
|
+
closeSync(h.fd);
|
|
2909
|
+
} catch {}
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
function sleep(ms) {
|
|
2913
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2914
|
+
}
|
|
2915
|
+
/** Workers of this terminal for the status bar; "" when there are none. */
|
|
2916
|
+
function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
|
|
2917
|
+
return renderStatusLine(loadStatuses(logDir).filter((s) => {
|
|
2918
|
+
const sameScope = term && workerInScope(s, term);
|
|
2919
|
+
const sameDir = cwd && s.cwd.startsWith(cwd);
|
|
2920
|
+
return sameScope || !term && sameDir;
|
|
2921
|
+
}), now);
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
//#endregion
|
|
2925
|
+
//#region src/workers/providers.ts
|
|
2926
|
+
/**
|
|
2927
|
+
* providers.ts — provider, role and switch management over the workers config.
|
|
2928
|
+
*
|
|
2929
|
+
* One layer under both `pai worker providers …` and the MCP worker_providers
|
|
2930
|
+
* tool. Every mutation re-reads the config file, changes only the workers
|
|
2931
|
+
* section, and writes it back atomically — the file is shared with everything
|
|
2932
|
+
* else PAI runs, so a torn write is not an option.
|
|
2933
|
+
*
|
|
2934
|
+
* Keys are never stored in the config and never accepted inline from the CLI:
|
|
2935
|
+
* only file paths. (The MCP `add` tool additionally accepts a raw `key`, which
|
|
2936
|
+
* it immediately parks in ~/.config/pai/keys/<name>, mode 0600, storing only
|
|
2937
|
+
* the path — a chat is not a place to leave a credential lying around.)
|
|
2938
|
+
*/
|
|
2939
|
+
function addProvider(input) {
|
|
2940
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(input.name)) throw new WorkersConfigError(`provider name "${input.name}" may only contain letters, digits, - and _`);
|
|
2941
|
+
if (input.protocol === "openai" && !input.upstreamUrl) throw new WorkersConfigError("protocol \"openai\" needs the upstreamUrl (the Chat Completions base, e.g. \"https://api.openai.com/v1\") — the PAI proxy translates to it.");
|
|
2942
|
+
if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
|
|
2943
|
+
let keyFile = input.keyFile ?? null;
|
|
2944
|
+
if (input.key !== void 0) {
|
|
2945
|
+
const dir = keysDir();
|
|
2946
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
2947
|
+
const path = `${dir}/${input.name}`;
|
|
2948
|
+
writeFileSync(path, input.key.trim() + "\n", {
|
|
2949
|
+
encoding: "utf8",
|
|
2950
|
+
mode: 384
|
|
2951
|
+
});
|
|
2952
|
+
try {
|
|
2953
|
+
chmodSync(path, 384);
|
|
2954
|
+
} catch {}
|
|
2955
|
+
keyFile = path;
|
|
2956
|
+
}
|
|
2957
|
+
const { raw, workers } = readWorkersSection();
|
|
2958
|
+
const provider = {
|
|
2959
|
+
enabled: true,
|
|
2960
|
+
protocol: input.protocol ?? "anthropic",
|
|
2961
|
+
baseUrl: input.baseUrl,
|
|
2962
|
+
keyFile,
|
|
2963
|
+
models: input.fastModel ? {
|
|
2964
|
+
default: input.model,
|
|
2965
|
+
fast: input.fastModel
|
|
2966
|
+
} : { default: input.model },
|
|
2967
|
+
env: input.env ?? {},
|
|
2968
|
+
...input.note ? { note: input.note } : {},
|
|
2969
|
+
...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
|
|
2970
|
+
...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
|
|
2971
|
+
...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
|
|
2972
|
+
...input.contextWindow ? { contextWindow: input.contextWindow } : {}
|
|
2973
|
+
};
|
|
2974
|
+
workers.providers[input.name] = provider;
|
|
2975
|
+
if (Object.keys(workers.providers).length === 1) {
|
|
2976
|
+
workers.enabled = true;
|
|
2977
|
+
workers.active = input.name;
|
|
2978
|
+
workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
|
|
2979
|
+
workers.roles = {
|
|
2980
|
+
implement: input.name,
|
|
2981
|
+
research: input.name,
|
|
2982
|
+
spotcheck: input.fastModel ? `${input.name}/fast` : input.name
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
writeWorkersSection(raw, workers);
|
|
2986
|
+
return workers;
|
|
2987
|
+
}
|
|
2988
|
+
function removeProvider(name) {
|
|
2989
|
+
const { raw, workers } = readWorkersSection();
|
|
2990
|
+
if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
|
|
2991
|
+
delete workers.providers[name];
|
|
2992
|
+
for (const [role, target] of Object.entries(workers.roles)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.roles[role];
|
|
2993
|
+
if (workers.active === name) workers.active = null;
|
|
2994
|
+
writeWorkersSection(raw, workers);
|
|
2995
|
+
return workers;
|
|
2996
|
+
}
|
|
2997
|
+
function useProvider(name) {
|
|
2998
|
+
const { raw, workers } = readWorkersSection();
|
|
2999
|
+
if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
3000
|
+
workers.active = name;
|
|
3001
|
+
writeWorkersSection(raw, workers);
|
|
3002
|
+
return workers;
|
|
3003
|
+
}
|
|
3004
|
+
function setProviderEnabled(name, enabled) {
|
|
3005
|
+
const { raw, workers } = readWorkersSection();
|
|
3006
|
+
const p = workers.providers[name];
|
|
3007
|
+
if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
|
|
3008
|
+
p.enabled = enabled;
|
|
3009
|
+
writeWorkersSection(raw, workers);
|
|
3010
|
+
if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
|
|
3011
|
+
return workers;
|
|
3012
|
+
}
|
|
3013
|
+
function setRole(role, target) {
|
|
3014
|
+
const { raw, workers } = readWorkersSection();
|
|
3015
|
+
const [name, alias] = target.split("/");
|
|
3016
|
+
const p = workers.providers[name];
|
|
3017
|
+
if (!p) throw new WorkersConfigError(`no provider named "${name}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
3018
|
+
if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose "default" and "fast"`);
|
|
3019
|
+
if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${name}" has no fast model configured`);
|
|
3020
|
+
workers.roles[role] = target;
|
|
3021
|
+
writeWorkersSection(raw, workers);
|
|
3022
|
+
return workers;
|
|
3023
|
+
}
|
|
3024
|
+
function unsetRole(role) {
|
|
3025
|
+
const { raw, workers } = readWorkersSection();
|
|
3026
|
+
if (!(role in workers.roles)) throw new WorkersConfigError(`no role named "${role}"`);
|
|
3027
|
+
delete workers.roles[role];
|
|
3028
|
+
writeWorkersSection(raw, workers);
|
|
3029
|
+
return workers;
|
|
3030
|
+
}
|
|
3031
|
+
function setWorkersEnabled(enabled) {
|
|
3032
|
+
const { raw, workers } = readWorkersSection();
|
|
3033
|
+
workers.enabled = enabled;
|
|
3034
|
+
writeWorkersSection(raw, workers);
|
|
3035
|
+
return workers;
|
|
3036
|
+
}
|
|
3037
|
+
/** Human-readable provider listing (quota probe included when configured). */
|
|
3038
|
+
function describeProviders(workers) {
|
|
3039
|
+
const lines = [];
|
|
3040
|
+
const names = Object.keys(workers.providers);
|
|
3041
|
+
if (!names.length) {
|
|
3042
|
+
lines.push(`no providers configured. Add one with:`);
|
|
3043
|
+
lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
|
|
3044
|
+
return lines;
|
|
3045
|
+
}
|
|
3046
|
+
for (const name of names) {
|
|
3047
|
+
const p = workers.providers[name];
|
|
3048
|
+
const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
|
|
3049
|
+
const quota = p.quotaProbe ? probeQuota(p) : null;
|
|
3050
|
+
const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
|
|
3051
|
+
lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
|
|
3052
|
+
lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
|
|
3053
|
+
if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
|
|
3054
|
+
else lines.push(` no key file (token "local")`);
|
|
3055
|
+
if (p.note) lines.push(` ${p.note}`);
|
|
3056
|
+
if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
|
|
3057
|
+
if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
|
|
3058
|
+
}
|
|
3059
|
+
const setNames = Object.keys(workers.mcpSets);
|
|
3060
|
+
if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
|
|
3061
|
+
if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
|
|
3062
|
+
return lines;
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
//#endregion
|
|
3066
|
+
export { readJsonStrict as A, loadStatus as C, workersLogDir as D, ledgerPath as E, WorkersConfigError as O, openPaneForWorker as S, eventsPath as T, stopProxy as _, setRole as a, checkPaneForWorker as b, useProvider as c, replayOutput as d, statusLineOutput as f, ensureProxyRunning as g, DEFAULT_PROXY_PORT as h, setProviderEnabled as i, writeJsonAtomic as j, readWorkersSection as k, followWorkers as l, testProvider as m, describeProviders as n, setWorkersEnabled as o, runWorker as p, removeProvider as r, unsetRole as s, addProvider as t, psOutput as u, sayToWorker as v, ledgerSummary as w, openFollowPane as x, describeMcp as y };
|
|
3067
|
+
//# sourceMappingURL=providers-sXcK5bDZ.mjs.map
|