@tekmidian/pai 0.38.0 → 0.39.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-DM7GhJ8y.mjs → auto-route-BlWOWT4z.mjs} +2 -2
- package/dist/{auto-route-DM7GhJ8y.mjs.map → auto-route-BlWOWT4z.mjs.map} +1 -1
- package/dist/{providers-DUshcB-d.mjs → chain-CTtHligI.mjs} +699 -1405
- package/dist/chain-CTtHligI.mjs.map +1 -0
- package/dist/cli/index.mjs +4 -3
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +4 -3
- package/dist/{clusters-Do4tEGyc.mjs → clusters-wlK0w41E.mjs} +1 -1
- package/dist/{clusters-Do4tEGyc.mjs.map → clusters-wlK0w41E.mjs.map} +1 -1
- package/dist/daemon/index.mjs +7 -7
- package/dist/{daemon-CGg1VCbA.mjs → daemon-CTkN_Vn8.mjs} +25 -25
- package/dist/{daemon-CGg1VCbA.mjs.map → daemon-CTkN_Vn8.mjs.map} +1 -1
- package/dist/{daemon-DsGGiIJM.mjs → daemon-CmHHmKpg.mjs} +7 -7
- package/dist/daemon-mcp/index.mjs +55 -3
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/{detector-CMap-9vw.mjs → detector-Bwk_4Pk4.mjs} +1 -1
- package/dist/{detector-CMap-9vw.mjs.map → detector-Bwk_4Pk4.mjs.map} +1 -1
- package/dist/detector-DO730Zq0.mjs +5 -0
- package/dist/{factory-DD2T33C9.mjs → factory-A9x-T2Kg.mjs} +5 -5
- package/dist/{factory-DD2T33C9.mjs.map → factory-A9x-T2Kg.mjs.map} +1 -1
- package/dist/factory-BD-np0Vd.mjs +3 -0
- package/dist/hooks/route-agents-to-worker.mjs +36 -6
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/worker-proxy.mjs +34 -4
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +100 -9
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/{indexer-backend-Cox9BCo-.mjs → indexer-backend-TG64CCQC.mjs} +1 -1
- package/dist/{indexer-backend-Cox9BCo-.mjs.map → indexer-backend-TG64CCQC.mjs.map} +1 -1
- package/dist/{latent-ideas-BC1oINZ-.mjs → latent-ideas-B7wq75Pt.mjs} +2 -2
- package/dist/{latent-ideas-BC1oINZ-.mjs.map → latent-ideas-B7wq75Pt.mjs.map} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs → link-boost-HkG7JWZR.mjs} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-HkG7JWZR.mjs.map} +1 -1
- package/dist/{neighborhood-D9MJ1c8f.mjs → neighborhood-lThN-MaQ.mjs} +1 -1
- package/dist/{neighborhood-D9MJ1c8f.mjs.map → neighborhood-lThN-MaQ.mjs.map} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs → note-context-b6k0mAKi.mjs} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs.map → note-context-b6k0mAKi.mjs.map} +1 -1
- package/dist/planner-BDI7bE9B.mjs +243 -0
- package/dist/planner-BDI7bE9B.mjs.map +1 -0
- package/dist/{postgres--BjPtLa0.mjs → postgres-mW1n7Vi1.mjs} +1 -1
- package/dist/{postgres--BjPtLa0.mjs.map → postgres-mW1n7Vi1.mjs.map} +1 -1
- package/dist/{program-Y0hAiVy8.mjs → program-DXBwJV7h.mjs} +70 -19
- package/dist/{program-Y0hAiVy8.mjs.map → program-DXBwJV7h.mjs.map} +1 -1
- package/dist/providers-FYkZjn_C.mjs +1405 -0
- package/dist/providers-FYkZjn_C.mjs.map +1 -0
- package/dist/{query-feedback-BUJxgw5B.mjs → query-feedback-BV4CcxqS.mjs} +1 -1
- package/dist/{query-feedback-BUJxgw5B.mjs.map → query-feedback-BV4CcxqS.mjs.map} +1 -1
- package/dist/query-feedback-DSVyHtrG.mjs +3 -0
- package/dist/router-Bk77E7hj.mjs +3 -0
- package/dist/{router-DK_sLsUL.mjs → router-DcHKnEPa.mjs} +1 -1
- package/dist/{router-DK_sLsUL.mjs.map → router-DcHKnEPa.mjs.map} +1 -1
- package/dist/{sources-Bi7--33T.mjs → sources-kLnQsNrW.mjs} +1 -1
- package/dist/{sources-Bi7--33T.mjs.map → sources-kLnQsNrW.mjs.map} +1 -1
- package/dist/{sqlite-DtaL1glm.mjs → sqlite-BenGr3UP.mjs} +1 -1
- package/dist/{sqlite-DtaL1glm.mjs.map → sqlite-BenGr3UP.mjs.map} +1 -1
- package/dist/{state-BY2L6-vX.mjs → state-CAeyOdfq.mjs} +1 -1
- package/dist/{state-BY2L6-vX.mjs.map → state-CAeyOdfq.mjs.map} +1 -1
- package/dist/{state-8Hm9E4tW.mjs → state-Ca9F_sZc.mjs} +1 -1
- package/dist/{themes-BN0a2duq.mjs → themes-BI4GMRP9.mjs} +1 -1
- package/dist/{themes-BN0a2duq.mjs.map → themes-BI4GMRP9.mjs.map} +1 -1
- package/dist/{tools-CGPqpU3A.mjs → tools-Bp7hj6OW.mjs} +1 -1
- package/dist/{tools-y2bJpKom.mjs → tools-DGcB3o_S.mjs} +13 -13
- package/dist/{tools-y2bJpKom.mjs.map → tools-DGcB3o_S.mjs.map} +1 -1
- package/dist/{trace-h23JCcFD.mjs → trace-bobARFEX.mjs} +1 -1
- package/dist/{trace-h23JCcFD.mjs.map → trace-bobARFEX.mjs.map} +1 -1
- package/dist/{vault-indexer-DgsPjMgs.mjs → vault-indexer-C3OfXTNF.mjs} +1 -1
- package/dist/{vault-indexer-DgsPjMgs.mjs.map → vault-indexer-C3OfXTNF.mjs.map} +1 -1
- package/dist/{work-queue-worker-gsKd2LJa.mjs → work-queue-worker-DW8lz-Oo.mjs} +3 -3
- package/dist/{work-queue-worker-Dva_v_pI.mjs → work-queue-worker-R7UGIag_.mjs} +3 -3
- package/dist/{work-queue-worker-Dva_v_pI.mjs.map → work-queue-worker-R7UGIag_.mjs.map} +1 -1
- package/dist/{zettelkasten-vo7psPdT.mjs → zettelkasten-m5QPtb-L.mjs} +3 -3
- package/dist/{zettelkasten-vo7psPdT.mjs.map → zettelkasten-m5QPtb-L.mjs.map} +1 -1
- package/docs/commands/README.md +4 -0
- package/docs/commands/worker.md +56 -0
- package/docs/worker.md +89 -1
- package/package.json +1 -1
- package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +2 -2
- package/dist/detector-DtLExmHN.mjs +0 -5
- package/dist/factory-BXzqRYVZ.mjs +0 -3
- package/dist/providers-DUshcB-d.mjs.map +0 -1
- package/dist/query-feedback-DhyLOe5S.mjs +0 -3
- package/dist/router-1zi8jiNF.mjs +0 -3
- /package/dist/{main-resolver-IhZo4pI0.mjs → main-resolver-D6IImXvF.mjs} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as CONFIG_FILE } from "./config-B64vFg14.mjs";
|
|
2
|
-
import { appendFileSync,
|
|
2
|
+
import { appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
|
-
import {
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { connect, createServer } from "node:net";
|
|
7
7
|
import { execFileSync, spawn } from "node:child_process";
|
|
@@ -137,19 +137,32 @@ const DEFAULT_ROUTING = {
|
|
|
137
137
|
cooldownMinutes: 30,
|
|
138
138
|
retryOnQuota: true
|
|
139
139
|
};
|
|
140
|
+
const DEFAULT_TREE = {
|
|
141
|
+
maxDepth: 2,
|
|
142
|
+
maxChildren: 4
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* The default MCP sets every config starts from. `desktop` names the clickr
|
|
146
|
+
* server so `--mcp desktop` hands a worker the machine controls (read-only
|
|
147
|
+
* tools always; actuating ones after the operator hands the controls over,
|
|
148
|
+
* see `pai worker controls`). A user's mcpSets section is merged over this,
|
|
149
|
+
* so `desktop: []` removes the set deliberately.
|
|
150
|
+
*/
|
|
151
|
+
const DEFAULT_MCP_SETS = { desktop: ["clickr"] };
|
|
140
152
|
function defaultWorkersConfig() {
|
|
141
153
|
return {
|
|
142
154
|
enabled: false,
|
|
143
155
|
active: null,
|
|
144
156
|
providers: {},
|
|
145
157
|
classes: {},
|
|
146
|
-
mcpSets: {},
|
|
158
|
+
mcpSets: { ...DEFAULT_MCP_SETS },
|
|
147
159
|
pane: { ...DEFAULT_PANE },
|
|
148
160
|
logDir: DEFAULT_LOG_DIR,
|
|
149
161
|
routing: {
|
|
150
162
|
...DEFAULT_ROUTING,
|
|
151
163
|
order: []
|
|
152
|
-
}
|
|
164
|
+
},
|
|
165
|
+
tree: { ...DEFAULT_TREE }
|
|
153
166
|
};
|
|
154
167
|
}
|
|
155
168
|
var WorkersConfigError = class extends Error {};
|
|
@@ -280,7 +293,7 @@ function parseWorkersConfig(raw) {
|
|
|
280
293
|
classes[cls] = t;
|
|
281
294
|
}
|
|
282
295
|
}
|
|
283
|
-
const mcpSets = {};
|
|
296
|
+
const mcpSets = { ...DEFAULT_MCP_SETS };
|
|
284
297
|
if (w.mcpSets !== void 0) {
|
|
285
298
|
if (typeof w.mcpSets !== "object" || w.mcpSets === null || Array.isArray(w.mcpSets)) bad(".mcpSets", "must be an object of set name → [server names]");
|
|
286
299
|
for (const [setName, servers] of Object.entries(w.mcpSets)) {
|
|
@@ -320,6 +333,21 @@ function parseWorkersConfig(raw) {
|
|
|
320
333
|
retryOnQuota: r.retryOnQuota === void 0 ? DEFAULT_ROUTING.retryOnQuota : r.retryOnQuota === true
|
|
321
334
|
};
|
|
322
335
|
}
|
|
336
|
+
let tree = { ...DEFAULT_TREE };
|
|
337
|
+
if (w.tree !== void 0) {
|
|
338
|
+
if (typeof w.tree !== "object" || w.tree === null || Array.isArray(w.tree)) bad(".tree", "must be an object");
|
|
339
|
+
const t = w.tree;
|
|
340
|
+
if (t.maxDepth !== void 0) {
|
|
341
|
+
if (typeof t.maxDepth !== "number" || !Number.isInteger(t.maxDepth) || t.maxDepth < 0) bad(".tree.maxDepth", "must be a non-negative integer");
|
|
342
|
+
}
|
|
343
|
+
if (t.maxChildren !== void 0) {
|
|
344
|
+
if (typeof t.maxChildren !== "number" || !Number.isInteger(t.maxChildren) || t.maxChildren < 1) bad(".tree.maxChildren", "must be a positive integer");
|
|
345
|
+
}
|
|
346
|
+
tree = {
|
|
347
|
+
maxDepth: t.maxDepth === void 0 ? DEFAULT_TREE.maxDepth : t.maxDepth,
|
|
348
|
+
maxChildren: t.maxChildren === void 0 ? DEFAULT_TREE.maxChildren : t.maxChildren
|
|
349
|
+
};
|
|
350
|
+
}
|
|
323
351
|
const active = w.active === void 0 || w.active === null ? null : str(w.active);
|
|
324
352
|
if (active !== null && active !== "auto" && !(active in providers)) {}
|
|
325
353
|
return {
|
|
@@ -330,7 +358,8 @@ function parseWorkersConfig(raw) {
|
|
|
330
358
|
mcpSets,
|
|
331
359
|
pane,
|
|
332
360
|
logDir: str(w.logDir) || d.logDir,
|
|
333
|
-
routing
|
|
361
|
+
routing,
|
|
362
|
+
tree
|
|
334
363
|
};
|
|
335
364
|
}
|
|
336
365
|
/**
|
|
@@ -1149,7 +1178,7 @@ function osascript(script, args) {
|
|
|
1149
1178
|
proc.stdin.end();
|
|
1150
1179
|
});
|
|
1151
1180
|
}
|
|
1152
|
-
function psOutput
|
|
1181
|
+
function psOutput(format) {
|
|
1153
1182
|
try {
|
|
1154
1183
|
return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
|
|
1155
1184
|
} catch {
|
|
@@ -1159,7 +1188,7 @@ function psOutput$1(format) {
|
|
|
1159
1188
|
/** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
|
|
1160
1189
|
function followTtys() {
|
|
1161
1190
|
const ttys = /* @__PURE__ */ new Set();
|
|
1162
|
-
for (const line of psOutput
|
|
1191
|
+
for (const line of psOutput("tty=,command=").split("\n")) {
|
|
1163
1192
|
const parts = line.trim().split(/\s+/, 2);
|
|
1164
1193
|
if (parts.length < 2 || parts[0] === "ps") continue;
|
|
1165
1194
|
if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
|
|
@@ -1170,7 +1199,7 @@ function followTtys() {
|
|
|
1170
1199
|
/** True while a `follow <wid>` process runs (its pane shows that worker). */
|
|
1171
1200
|
function workerPaneOpen(wid) {
|
|
1172
1201
|
const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
1173
|
-
return psOutput
|
|
1202
|
+
return psOutput("command=").split("\n").some((ln) => pat.test(ln));
|
|
1174
1203
|
}
|
|
1175
1204
|
/** plutil's message for an ExecFileSync failure, else the exception text. */
|
|
1176
1205
|
function toolErr(e) {
|
|
@@ -1434,6 +1463,483 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
|
|
|
1434
1463
|
throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
|
|
1435
1464
|
}
|
|
1436
1465
|
|
|
1466
|
+
//#endregion
|
|
1467
|
+
//#region src/workers/tree.ts
|
|
1468
|
+
/** The env var the runner sets in every worker's environment. */
|
|
1469
|
+
const WORKER_ID_ENV = "PAI_WORKER_ID";
|
|
1470
|
+
/** The worker this process runs inside, when it runs inside one. */
|
|
1471
|
+
function parentFromEnv(env = process.env) {
|
|
1472
|
+
const id = env[WORKER_ID_ENV];
|
|
1473
|
+
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Depth of `id` in the worker forest: 0 for a top-level worker, 1 + the
|
|
1477
|
+
* parent's depth for a sub-worker. Parents without a status file (chain ids,
|
|
1478
|
+
* unknown ids) count as roots; a cycle reads as its own depth and is cut off
|
|
1479
|
+
* after the statuses it walked.
|
|
1480
|
+
*/
|
|
1481
|
+
function workerDepth(statuses, id) {
|
|
1482
|
+
const byId = new Map(statuses.map((s) => [s.id, s]));
|
|
1483
|
+
let depth = 0;
|
|
1484
|
+
let cur = byId.get(id);
|
|
1485
|
+
const seen = new Set([id]);
|
|
1486
|
+
while (cur?.parent && !seen.has(cur.parent)) {
|
|
1487
|
+
seen.add(cur.parent);
|
|
1488
|
+
const next = byId.get(cur.parent);
|
|
1489
|
+
if (!next) break;
|
|
1490
|
+
depth += 1;
|
|
1491
|
+
cur = next;
|
|
1492
|
+
}
|
|
1493
|
+
return depth;
|
|
1494
|
+
}
|
|
1495
|
+
/** Children of `parent` that are still running, oldest first. */
|
|
1496
|
+
function runningChildren(statuses, parent) {
|
|
1497
|
+
return statuses.filter((s) => s.parent === parent && s.state === "running" && alive(s.pid));
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Would starting a child of `parent` stay within the caps? Throws with a
|
|
1501
|
+
* clear message when it would not; returns silently when `parent` has no
|
|
1502
|
+
* status file (a chain id or an unknown id — not a worker, not capped).
|
|
1503
|
+
*/
|
|
1504
|
+
function assertChildAllowed(logDir, parent, caps, statuses = loadStatuses(logDir)) {
|
|
1505
|
+
if (!statuses.some((s) => s.id === parent)) return;
|
|
1506
|
+
const depth = workerDepth(statuses, parent);
|
|
1507
|
+
if (depth + 1 > caps.maxDepth) throw new Error(`worker tree: ${parent} sits at depth ${depth} and workers.tree.maxDepth is ${caps.maxDepth} — it cannot start another level of sub-workers. Hand the task up instead: pai worker handoff '{"kind":"proposal","text":"…"}'`);
|
|
1508
|
+
const kids = runningChildren(statuses, parent);
|
|
1509
|
+
if (kids.length >= caps.maxChildren) throw new Error(`worker tree: ${parent} already has ${kids.length} running sub-workers (${kids.map((k) => k.id).join(", ")}) and workers.tree.maxChildren is ${caps.maxChildren} — wait for one to finish, or raise the cap in the workers config`);
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* The parent a launch should record: an explicit one (chain stage) wins, else
|
|
1513
|
+
* the worker this process runs inside. Returns null for top-level runs.
|
|
1514
|
+
*/
|
|
1515
|
+
function launchParent(explicit, env = process.env) {
|
|
1516
|
+
return explicit ?? parentFromEnv(env);
|
|
1517
|
+
}
|
|
1518
|
+
/** Does a status file exist for `id` (i.e. is it a worker rather than a chain)? */
|
|
1519
|
+
function isWorkerId(logDir, id) {
|
|
1520
|
+
return loadStatus(logDir, id) !== null;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
//#endregion
|
|
1524
|
+
//#region src/workers/operator.ts
|
|
1525
|
+
/**
|
|
1526
|
+
* operator.ts — talking to a running worker.
|
|
1527
|
+
*
|
|
1528
|
+
* Headless workers run with `--input-format stream-json` and their stdin held
|
|
1529
|
+
* open, so a run is a conversation: every line sent to the per-worker Unix
|
|
1530
|
+
* socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
|
|
1531
|
+
* mirrored into the transcript as an `operator` event (rendered with a »
|
|
1532
|
+
* marker). Once the worker has finished its turn, stdin closes 2 s later
|
|
1533
|
+
* unless a new message arrives — after that, `say` refuses and `resume`
|
|
1534
|
+
* continues the same Claude session with the worker's context intact.
|
|
1535
|
+
*/
|
|
1536
|
+
function operatorSocketPath(logDir, id) {
|
|
1537
|
+
return join(logDir, `${id}.sock`);
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* The runner's side: listen on the worker socket, hand every received line to
|
|
1541
|
+
* `onLine`. Returns the server (close it when the run ends; the socket file is
|
|
1542
|
+
* unlinked on close, best effort).
|
|
1543
|
+
*/
|
|
1544
|
+
function createOperatorServer(logDir, id, onLine) {
|
|
1545
|
+
const path = operatorSocketPath(logDir, id);
|
|
1546
|
+
try {
|
|
1547
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1548
|
+
} catch {}
|
|
1549
|
+
const server = createServer((socket) => {
|
|
1550
|
+
let buf = "";
|
|
1551
|
+
socket.on("data", (chunk) => {
|
|
1552
|
+
buf += chunk.toString("utf8");
|
|
1553
|
+
let nl;
|
|
1554
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1555
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
1556
|
+
buf = buf.slice(nl + 1);
|
|
1557
|
+
if (line.trim()) onLine(line);
|
|
1558
|
+
socket.write("ok\n");
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
server.listen(path);
|
|
1563
|
+
server.on("close", () => {
|
|
1564
|
+
try {
|
|
1565
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1566
|
+
} catch {}
|
|
1567
|
+
});
|
|
1568
|
+
return server;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* `pai worker say <id> "<text>"`: forward one line to a running worker.
|
|
1572
|
+
* Resolves "ok", rejects with a clear message when the worker is not running.
|
|
1573
|
+
*/
|
|
1574
|
+
function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
|
|
1575
|
+
const status = loadStatus(logDir, id);
|
|
1576
|
+
if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
|
|
1577
|
+
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>"`));
|
|
1578
|
+
const path = operatorSocketPath(logDir, id);
|
|
1579
|
+
if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
|
|
1580
|
+
return new Promise((resolve, reject) => {
|
|
1581
|
+
const sock = connect(path);
|
|
1582
|
+
const fail = (e) => {
|
|
1583
|
+
sock.destroy();
|
|
1584
|
+
reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
|
|
1585
|
+
};
|
|
1586
|
+
sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
|
|
1587
|
+
sock.once("error", (e) => fail(e));
|
|
1588
|
+
sock.once("connect", () => {
|
|
1589
|
+
sock.write(text.replace(/\n/g, " ") + "\n");
|
|
1590
|
+
});
|
|
1591
|
+
sock.once("data", () => {
|
|
1592
|
+
sock.end();
|
|
1593
|
+
resolve("ok");
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
//#endregion
|
|
1599
|
+
//#region src/workers/handoff.ts
|
|
1600
|
+
/**
|
|
1601
|
+
* handoff.ts — upward messages between workers.
|
|
1602
|
+
*
|
|
1603
|
+
* Coordination in the worker tree is upward only: a child appends a handoff
|
|
1604
|
+
* to `<logDir>/<parent>.inbox.jsonl` and (when the parent is a running
|
|
1605
|
+
* worker) the same text is said to it, so it lands in the parent's
|
|
1606
|
+
* conversation as `[handoff from <child>] …`. The parent's pane renders the
|
|
1607
|
+
* inbox as `◆` lines, `ps`/`worker_status` show an inbox count, and a child
|
|
1608
|
+
* that finishes delivers its structured report automatically as a
|
|
1609
|
+
* `kind: "result"` handoff. There is no downward or sideways path — telling
|
|
1610
|
+
* a worker something is the operator's job (`pai worker say`).
|
|
1611
|
+
*/
|
|
1612
|
+
const HANDOFF_KINDS = [
|
|
1613
|
+
"proposal",
|
|
1614
|
+
"result",
|
|
1615
|
+
"question",
|
|
1616
|
+
"blocker"
|
|
1617
|
+
];
|
|
1618
|
+
/** Where a worker's handoffs land: <logDir>/<id>.inbox.jsonl. */
|
|
1619
|
+
function inboxPath(logDir, id) {
|
|
1620
|
+
return join(logDir, `${id}.inbox.jsonl`);
|
|
1621
|
+
}
|
|
1622
|
+
function isHandoffKind(v) {
|
|
1623
|
+
return typeof v === "string" && HANDOFF_KINDS.includes(v);
|
|
1624
|
+
}
|
|
1625
|
+
/**
|
|
1626
|
+
* Normalize a raw parsed value into a Handoff, or explain what is missing.
|
|
1627
|
+
* `from`/`to` may be preset by the caller (the CLI fills them from the
|
|
1628
|
+
* environment); everything else must be present and well-formed.
|
|
1629
|
+
*/
|
|
1630
|
+
function parseHandoff(v, preset = {}) {
|
|
1631
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error("handoff payload must be a JSON object");
|
|
1632
|
+
const o = v;
|
|
1633
|
+
const from = typeof o.from === "string" && o.from ? o.from : preset.from;
|
|
1634
|
+
const to = typeof o.to === "string" && o.to ? o.to : preset.to;
|
|
1635
|
+
if (!from) throw new Error(`handoff needs "from" (the sending worker id)`);
|
|
1636
|
+
if (!to) throw new Error(`handoff needs "to" (the parent worker id)`);
|
|
1637
|
+
if (!isHandoffKind(o.kind)) throw new Error(`handoff "kind" must be one of: ${HANDOFF_KINDS.join(", ")}`);
|
|
1638
|
+
const text = typeof o.text === "string" ? o.text : "";
|
|
1639
|
+
if (!text.trim()) throw new Error(`handoff needs a non-empty "text"`);
|
|
1640
|
+
const data = typeof o.data === "object" && o.data !== null && !Array.isArray(o.data) ? o.data : void 0;
|
|
1641
|
+
return {
|
|
1642
|
+
from,
|
|
1643
|
+
to,
|
|
1644
|
+
kind: o.kind,
|
|
1645
|
+
text,
|
|
1646
|
+
...data ? { data } : {}
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
/** Append one handoff to its recipient's inbox, stamped now. */
|
|
1650
|
+
function appendHandoff(logDir, h, now = /* @__PURE__ */ new Date()) {
|
|
1651
|
+
const path = inboxPath(logDir, h.to);
|
|
1652
|
+
const dir = dirname(path);
|
|
1653
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1654
|
+
appendFileSync(path, JSON.stringify({
|
|
1655
|
+
...h,
|
|
1656
|
+
_ts: now.toISOString()
|
|
1657
|
+
}) + "\n", "utf8");
|
|
1658
|
+
}
|
|
1659
|
+
/** Every handoff in a worker's inbox, oldest first; damaged lines skip. */
|
|
1660
|
+
function readInbox(logDir, id) {
|
|
1661
|
+
const path = inboxPath(logDir, id);
|
|
1662
|
+
if (!existsSync(path)) return [];
|
|
1663
|
+
const out = [];
|
|
1664
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1665
|
+
if (!line.trim()) continue;
|
|
1666
|
+
try {
|
|
1667
|
+
out.push(JSON.parse(line));
|
|
1668
|
+
} catch {}
|
|
1669
|
+
}
|
|
1670
|
+
return out;
|
|
1671
|
+
}
|
|
1672
|
+
/** How the say path prefixes a handoff so the parent model knows its source. */
|
|
1673
|
+
function handoffMessage(h) {
|
|
1674
|
+
return `[handoff from ${h.from}] (${h.kind}) ${h.text.replace(/\s+/g, " ").trim()}`;
|
|
1675
|
+
}
|
|
1676
|
+
/** True for the exact shape of handoffMessage() — the runner marks the mirror. */
|
|
1677
|
+
function isHandoffMessage(text) {
|
|
1678
|
+
return new RegExp(`^\\[handoff from \\S+\\] \\((${HANDOFF_KINDS.join("|")})\\) `).test(text);
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Deliver one handoff: append it to the parent's inbox, then — when the
|
|
1682
|
+
* parent is a running worker with a live operator socket — say it so it
|
|
1683
|
+
* enters the parent's conversation. The say is best effort: a parent that is
|
|
1684
|
+
* busy, finished or gone still has the inbox line, and nothing here may make
|
|
1685
|
+
* a finishing child's exit path fail.
|
|
1686
|
+
*/
|
|
1687
|
+
async function deliverHandoff(logDir, h, deps = {}, now = /* @__PURE__ */ new Date()) {
|
|
1688
|
+
appendHandoff(logDir, h, now);
|
|
1689
|
+
const say = deps.say ?? ((id, text) => sayToWorker(logDir, id, text));
|
|
1690
|
+
const parent = loadStatus(logDir, h.to);
|
|
1691
|
+
if (parent && parent.state === "running" && alive(parent.pid)) try {
|
|
1692
|
+
await say(h.to, handoffMessage(h));
|
|
1693
|
+
} catch {}
|
|
1694
|
+
return h;
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Send a handoff from inside a worker (the `pai worker handoff` CLI and the
|
|
1698
|
+
* MCP tool both land here): the sender comes from PAI_WORKER_ID, the
|
|
1699
|
+
* recipient from its status file's `parent`. Rejects with a clear message
|
|
1700
|
+
* outside a worker or under a parentless worker.
|
|
1701
|
+
*/
|
|
1702
|
+
async function handoffFromInside(logDir, env, payload, deps = {}) {
|
|
1703
|
+
const mine = env.PAI_WORKER_ID;
|
|
1704
|
+
if (!mine) throw new Error("not inside a worker — handoffs are sent by workers (PAI_WORKER_ID unset). Talk to a running worker with: pai worker say <id> \"<text>\"");
|
|
1705
|
+
const st = loadStatus(logDir, mine);
|
|
1706
|
+
if (!st) throw new Error(`no status for this worker (${mine}) — cannot find its parent`);
|
|
1707
|
+
if (!st.parent || !loadStatus(logDir, st.parent)) throw new Error(`worker ${mine} has no worker parent to hand off to (parent: ${st.parent ?? "(none)"}) — handoffs go up the worker tree only`);
|
|
1708
|
+
return deliverHandoff(logDir, parseHandoff(payload, {
|
|
1709
|
+
from: mine,
|
|
1710
|
+
to: st.parent
|
|
1711
|
+
}), deps);
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
//#endregion
|
|
1715
|
+
//#region src/workers/worktree.ts
|
|
1716
|
+
/**
|
|
1717
|
+
* worktree.ts — one git worktree per writing worker.
|
|
1718
|
+
*
|
|
1719
|
+
* A run whose class edits files (implement, complex, plan) and whose cwd is a
|
|
1720
|
+
* git repository gets its own worktree by default: `git worktree add
|
|
1721
|
+
* <logDir>/worktrees/<id> -b worker/<id>` from the current HEAD. The worker
|
|
1722
|
+
* commits its own work on that branch — the no-commit rule applies to the
|
|
1723
|
+
* main branch only — and the parent (or the operator) merges the result back:
|
|
1724
|
+
*
|
|
1725
|
+
* pai worker merge <id> git merge --no-ff worker/<id> + remove worktree
|
|
1726
|
+
* pai worker discard <id> remove worktree and branch, keep nothing
|
|
1727
|
+
*
|
|
1728
|
+
* `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
|
|
1729
|
+
* place; `--no-worktree` opts out, `--worktree` forces one on.
|
|
1730
|
+
*/
|
|
1731
|
+
function worktreesDir(logDir) {
|
|
1732
|
+
return join(logDir, "worktrees");
|
|
1733
|
+
}
|
|
1734
|
+
function worktreeBranch(id) {
|
|
1735
|
+
return `worker/${id}`;
|
|
1736
|
+
}
|
|
1737
|
+
function worktreePath(logDir, id) {
|
|
1738
|
+
return join(worktreesDir(logDir), id);
|
|
1739
|
+
}
|
|
1740
|
+
/** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
|
|
1741
|
+
function git(cwd, args) {
|
|
1742
|
+
try {
|
|
1743
|
+
return execFileSync("git", [
|
|
1744
|
+
"-C",
|
|
1745
|
+
cwd,
|
|
1746
|
+
...args
|
|
1747
|
+
], {
|
|
1748
|
+
encoding: "utf8",
|
|
1749
|
+
timeout: 3e4,
|
|
1750
|
+
stdio: [
|
|
1751
|
+
"ignore",
|
|
1752
|
+
"pipe",
|
|
1753
|
+
"pipe"
|
|
1754
|
+
]
|
|
1755
|
+
}).trim();
|
|
1756
|
+
} catch (e) {
|
|
1757
|
+
const err = e;
|
|
1758
|
+
const why = (typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e);
|
|
1759
|
+
throw new Error(`git ${args.join(" ")} in ${cwd}: ${why.trim()}`);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
/** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
|
|
1763
|
+
function isGitRepo(cwd) {
|
|
1764
|
+
try {
|
|
1765
|
+
return git(cwd, ["rev-parse", "--git-dir"]) !== "";
|
|
1766
|
+
} catch {
|
|
1767
|
+
return false;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Does the prompt read as a read-only task? Writing classes default to a
|
|
1772
|
+
* worktree; a prompt that only asks to look at things should not pay for one.
|
|
1773
|
+
* First-word verbs plus the explicit markers people actually write.
|
|
1774
|
+
*/
|
|
1775
|
+
function promptLooksReadonly(prompt) {
|
|
1776
|
+
const p = prompt.trim();
|
|
1777
|
+
if (!p) return true;
|
|
1778
|
+
if (/\b(read[- ]only|do not (modify|change|edit|write)|don'?t (modify|change|edit|write)|no changes)\b/i.test(p)) return true;
|
|
1779
|
+
return /^(review|read|analy[sz]e|research|summar[iy]|inspect|investigate|spotcheck|report|find|list|check|verify|describe|explain|show)\b/i.test(p);
|
|
1780
|
+
}
|
|
1781
|
+
/** The classes whose runs write files and therefore default to a worktree. */
|
|
1782
|
+
const WORKTREE_CLASSES = [
|
|
1783
|
+
"implement",
|
|
1784
|
+
"complex",
|
|
1785
|
+
"plan"
|
|
1786
|
+
];
|
|
1787
|
+
/** Should this run get a worktree? Explicit flag first, then the default rule. */
|
|
1788
|
+
function worktreeWanted(flag, opts) {
|
|
1789
|
+
if (flag !== void 0) return flag;
|
|
1790
|
+
if (!opts.className || !WORKTREE_CLASSES.includes(opts.className)) return false;
|
|
1791
|
+
if (!isGitRepo(opts.cwd)) return false;
|
|
1792
|
+
return !promptLooksReadonly(opts.prompt ?? "");
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Create the worktree and branch for `id` from `cwd`'s HEAD. Throws when git
|
|
1796
|
+
* refuses (no commits yet, branch exists, …) — the caller decides whether to
|
|
1797
|
+
* degrade to an in-place run.
|
|
1798
|
+
*/
|
|
1799
|
+
function addWorktree(logDir, id, cwd) {
|
|
1800
|
+
const dir = worktreePath(logDir, id);
|
|
1801
|
+
const branch = worktreeBranch(id);
|
|
1802
|
+
const base = git(cwd, ["rev-parse", "HEAD"]);
|
|
1803
|
+
git(cwd, [
|
|
1804
|
+
"worktree",
|
|
1805
|
+
"add",
|
|
1806
|
+
dir,
|
|
1807
|
+
"-b",
|
|
1808
|
+
branch
|
|
1809
|
+
]);
|
|
1810
|
+
return {
|
|
1811
|
+
dir,
|
|
1812
|
+
branch,
|
|
1813
|
+
base
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
/** Commits the branch collected on top of its base. */
|
|
1817
|
+
function commitsSince(dir, base) {
|
|
1818
|
+
try {
|
|
1819
|
+
return parseInt(git(dir, [
|
|
1820
|
+
"rev-list",
|
|
1821
|
+
"--count",
|
|
1822
|
+
`${base}..HEAD`
|
|
1823
|
+
]), 10) || 0;
|
|
1824
|
+
} catch {
|
|
1825
|
+
return 0;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
/**
|
|
1829
|
+
* Record the worktree result in the status file: branch and commit count on
|
|
1830
|
+
* success, the worktree cleaned up on failure. Returns the updated status.
|
|
1831
|
+
*/
|
|
1832
|
+
function recordWorktree(logDir, status, info, ok) {
|
|
1833
|
+
const s = { ...status };
|
|
1834
|
+
if (ok) {
|
|
1835
|
+
s.branch = info.branch;
|
|
1836
|
+
s.commits = commitsSince(info.dir, info.base);
|
|
1837
|
+
s.worktreeDir = info.dir;
|
|
1838
|
+
s.worktreeBase = info.base;
|
|
1839
|
+
} else {
|
|
1840
|
+
removeWorktree(s.cwd, info.dir, true);
|
|
1841
|
+
try {
|
|
1842
|
+
git(s.cwd, [
|
|
1843
|
+
"branch",
|
|
1844
|
+
"-D",
|
|
1845
|
+
info.branch
|
|
1846
|
+
]);
|
|
1847
|
+
} catch {}
|
|
1848
|
+
s.branch = null;
|
|
1849
|
+
s.worktreeDir = null;
|
|
1850
|
+
s.worktreeBase = null;
|
|
1851
|
+
s.commits = null;
|
|
1852
|
+
}
|
|
1853
|
+
saveStatus(logDir, s);
|
|
1854
|
+
return s;
|
|
1855
|
+
}
|
|
1856
|
+
/** Remove a worktree directory from git's books and the filesystem. */
|
|
1857
|
+
function removeWorktree(cwd, dir, force) {
|
|
1858
|
+
try {
|
|
1859
|
+
git(cwd, [
|
|
1860
|
+
"worktree",
|
|
1861
|
+
"remove",
|
|
1862
|
+
...force ? ["--force"] : [],
|
|
1863
|
+
dir
|
|
1864
|
+
]);
|
|
1865
|
+
return;
|
|
1866
|
+
} catch {}
|
|
1867
|
+
if (existsSync(dir)) try {
|
|
1868
|
+
rmSync(dir, {
|
|
1869
|
+
recursive: true,
|
|
1870
|
+
force: true
|
|
1871
|
+
});
|
|
1872
|
+
git(cwd, ["worktree", "prune"]);
|
|
1873
|
+
} catch {}
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* `pai worker merge <id>`: merge the worker's branch into the original
|
|
1877
|
+
* checkout with --no-ff (the merge commit names the worker), then remove the
|
|
1878
|
+
* worktree. The branch stays for the record; the status gains `merged: true`.
|
|
1879
|
+
*/
|
|
1880
|
+
function mergeWorker(logDir, id) {
|
|
1881
|
+
const st = mustHaveBranch(logDir, id);
|
|
1882
|
+
if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
|
|
1883
|
+
git(st.cwd, [
|
|
1884
|
+
"merge",
|
|
1885
|
+
"--no-ff",
|
|
1886
|
+
st.branch,
|
|
1887
|
+
"-m",
|
|
1888
|
+
`merge worker ${id} (${st.label})`
|
|
1889
|
+
]);
|
|
1890
|
+
removeWorktree(st.cwd, st.worktreeDir, false);
|
|
1891
|
+
saveStatus(logDir, {
|
|
1892
|
+
...st,
|
|
1893
|
+
merged: true
|
|
1894
|
+
});
|
|
1895
|
+
return `merged ${st.branch} into ${st.cwd} (worktree removed)`;
|
|
1896
|
+
}
|
|
1897
|
+
/** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
|
|
1898
|
+
function discardWorker(logDir, id) {
|
|
1899
|
+
const st = mustHaveBranch(logDir, id);
|
|
1900
|
+
removeWorktree(st.cwd, st.worktreeDir, true);
|
|
1901
|
+
let branchGone = false;
|
|
1902
|
+
try {
|
|
1903
|
+
git(st.cwd, [
|
|
1904
|
+
"branch",
|
|
1905
|
+
"-D",
|
|
1906
|
+
st.branch
|
|
1907
|
+
]);
|
|
1908
|
+
branchGone = true;
|
|
1909
|
+
} catch {
|
|
1910
|
+
branchGone = false;
|
|
1911
|
+
}
|
|
1912
|
+
saveStatus(logDir, {
|
|
1913
|
+
...st,
|
|
1914
|
+
branch: null,
|
|
1915
|
+
worktreeDir: null,
|
|
1916
|
+
worktreeBase: null,
|
|
1917
|
+
commits: null,
|
|
1918
|
+
merged: false
|
|
1919
|
+
});
|
|
1920
|
+
return `discarded worker ${id}: worktree removed${branchGone ? `, branch ${st.branch} deleted` : ""}`;
|
|
1921
|
+
}
|
|
1922
|
+
function mustHaveBranch(logDir, id) {
|
|
1923
|
+
const st = loadStatus(logDir, id);
|
|
1924
|
+
if (!st) throw new Error(`no worker named "${id}"`);
|
|
1925
|
+
if (!st.branch || !st.worktreeDir) throw new Error(`worker ${id} has no worktree branch to ${st.branch ? "clean up" : "merge"} — it ran in place or its branch was already handled`);
|
|
1926
|
+
return st;
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* The paragraph a worktree run's system prompt gains: it may commit on its
|
|
1930
|
+
* own branch (the no-commit rule holds for the main branch only), it must not
|
|
1931
|
+
* merge or push itself, and the parent or operator merges.
|
|
1932
|
+
*/
|
|
1933
|
+
function worktreeSystemPrompt(id, branch, dir) {
|
|
1934
|
+
return [
|
|
1935
|
+
"You are running in your own git worktree:",
|
|
1936
|
+
` ${dir} on branch ${branch} (worker id ${id}).`,
|
|
1937
|
+
"Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
|
|
1938
|
+
"the no-commit rule applies to the main branch only, and this is not it.",
|
|
1939
|
+
"Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`."
|
|
1940
|
+
].join("\n");
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1437
1943
|
//#endregion
|
|
1438
1944
|
//#region src/workers/report.ts
|
|
1439
1945
|
/**
|
|
@@ -1444,6 +1950,8 @@ async function openFollowPane(logDir, _config, term, checkOnly) {
|
|
|
1444
1950
|
* and nothing else. The runner parses it out of the result; the viewer renders
|
|
1445
1951
|
* it as a compact block instead of a wall of text.
|
|
1446
1952
|
*/
|
|
1953
|
+
/** The marker the runner puts on operator messages the worker must answer. */
|
|
1954
|
+
const OPERATOR_MARK = "[operator]";
|
|
1447
1955
|
/** Appended to the caller's system prompt on every headless run (2h). */
|
|
1448
1956
|
const WORKER_CONTRACT_PROMPT = [
|
|
1449
1957
|
"You are a headless implementation worker, run non-interactively by an orchestrating session.",
|
|
@@ -1452,7 +1960,20 @@ const WORKER_CONTRACT_PROMPT = [
|
|
|
1452
1960
|
"{\"changed\":[{\"path\":\"…\",\"summary\":\"…\"}],\"commands\":[\"…\"],\"checks\":[{\"name\":\"…\",\"ok\":true,\"detail\":\"…\"}],\"open\":[\"…\"],\"notes\":\"one line\"}",
|
|
1453
1961
|
"changed: files you touched (path + one-line summary). commands: the commands that verify the work.",
|
|
1454
1962
|
"checks: each with ok true/false and the evidence in detail. open: what you could not finish, if anything.",
|
|
1455
|
-
"notes: one line, the headline a reviewer reads first."
|
|
1963
|
+
"notes: one line, the headline a reviewer reads first.",
|
|
1964
|
+
"",
|
|
1965
|
+
`Operator messages: a user turn starting with ${OPERATOR_MARK} was typed by the operator while you`,
|
|
1966
|
+
"run (the prompt of a resumed run is the operator's too). Answer it FIRST, in one or two plain lines —",
|
|
1967
|
+
"a question gets its answer, an instruction gets one line naming what will change — then continue the",
|
|
1968
|
+
"task you were on.",
|
|
1969
|
+
"",
|
|
1970
|
+
"Sub-workers: you may start your own workers with `pai worker run --class <class> -p '<prompt>'`",
|
|
1971
|
+
"(Bash tool). Your children run on their own provider and report back to you automatically when",
|
|
1972
|
+
"they finish. Send a handoff UP instead of doing work yourself when a cheaper provider would",
|
|
1973
|
+
"suffice, the task is out of your scope, or a decision is needed:",
|
|
1974
|
+
"`pai worker handoff '{\"kind\":\"proposal\",\"text\":\"…\",\"data\":{…}}'` (kinds: proposal,",
|
|
1975
|
+
"question, blocker; results are sent for you when you finish). Sibling workers are not a",
|
|
1976
|
+
"coordination path — there is no sideways channel; everything goes up to your parent."
|
|
1456
1977
|
].join("\n");
|
|
1457
1978
|
/**
|
|
1458
1979
|
* Extract the JSON report from a worker's final message. Accepts a bare
|
|
@@ -1603,120 +2124,45 @@ function describeMcp(config, claudeJson = CLAUDE_JSON) {
|
|
|
1603
2124
|
}
|
|
1604
2125
|
|
|
1605
2126
|
//#endregion
|
|
1606
|
-
//#region src/workers/
|
|
2127
|
+
//#region src/workers/proxy/server.ts
|
|
1607
2128
|
/**
|
|
1608
|
-
*
|
|
2129
|
+
* server.ts — the PAI worker proxy: Anthropic Messages API on the front,
|
|
2130
|
+
* OpenAI Chat Completions on the back, loopback only.
|
|
1609
2131
|
*
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1615
|
-
*
|
|
1616
|
-
*
|
|
2132
|
+
* One proxy serves every OpenAI-protocol provider: Claude Code points
|
|
2133
|
+
* ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
|
|
2134
|
+
* name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
|
|
2135
|
+
* workers config, re-read per request so config edits apply without a
|
|
2136
|
+
* restart). `run` starts the proxy on demand (detached, pid file under the
|
|
2137
|
+
* logDir) and `pai worker proxy stop` stops it again.
|
|
2138
|
+
*
|
|
2139
|
+
* All translation lives in translate.ts; this file is only HTTP plumbing.
|
|
1617
2140
|
*/
|
|
1618
|
-
|
|
1619
|
-
|
|
2141
|
+
const DEFAULT_PROXY_PORT = 8797;
|
|
2142
|
+
const HOST = "127.0.0.1";
|
|
2143
|
+
function proxyPidPath(logDir) {
|
|
2144
|
+
return join(logDir, "proxy.pid");
|
|
2145
|
+
}
|
|
2146
|
+
/** True when something already accepts connections on the port. */
|
|
2147
|
+
function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
|
|
2148
|
+
return new Promise((resolve) => {
|
|
2149
|
+
const sock = connect({
|
|
2150
|
+
port,
|
|
2151
|
+
host: HOST
|
|
2152
|
+
});
|
|
2153
|
+
const done = (ok) => {
|
|
2154
|
+
sock.removeAllListeners();
|
|
2155
|
+
sock.destroy();
|
|
2156
|
+
resolve(ok);
|
|
2157
|
+
};
|
|
2158
|
+
sock.setTimeout(timeoutMs, () => done(false));
|
|
2159
|
+
sock.once("connect", () => done(true));
|
|
2160
|
+
sock.once("error", () => done(false));
|
|
2161
|
+
});
|
|
1620
2162
|
}
|
|
1621
2163
|
/**
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
* unlinked on close, best effort).
|
|
1625
|
-
*/
|
|
1626
|
-
function createOperatorServer(logDir, id, onLine) {
|
|
1627
|
-
const path = operatorSocketPath(logDir, id);
|
|
1628
|
-
try {
|
|
1629
|
-
if (existsSync(path)) unlinkSync(path);
|
|
1630
|
-
} catch {}
|
|
1631
|
-
const server = createServer((socket) => {
|
|
1632
|
-
let buf = "";
|
|
1633
|
-
socket.on("data", (chunk) => {
|
|
1634
|
-
buf += chunk.toString("utf8");
|
|
1635
|
-
let nl;
|
|
1636
|
-
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1637
|
-
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
1638
|
-
buf = buf.slice(nl + 1);
|
|
1639
|
-
if (line.trim()) onLine(line);
|
|
1640
|
-
socket.write("ok\n");
|
|
1641
|
-
}
|
|
1642
|
-
});
|
|
1643
|
-
});
|
|
1644
|
-
server.listen(path);
|
|
1645
|
-
server.on("close", () => {
|
|
1646
|
-
try {
|
|
1647
|
-
if (existsSync(path)) unlinkSync(path);
|
|
1648
|
-
} catch {}
|
|
1649
|
-
});
|
|
1650
|
-
return server;
|
|
1651
|
-
}
|
|
1652
|
-
/**
|
|
1653
|
-
* `pai worker say <id> "<text>"`: forward one line to a running worker.
|
|
1654
|
-
* Resolves "ok", rejects with a clear message when the worker is not running.
|
|
1655
|
-
*/
|
|
1656
|
-
function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
|
|
1657
|
-
const status = loadStatus(logDir, id);
|
|
1658
|
-
if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
|
|
1659
|
-
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>"`));
|
|
1660
|
-
const path = operatorSocketPath(logDir, id);
|
|
1661
|
-
if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
|
|
1662
|
-
return new Promise((resolve, reject) => {
|
|
1663
|
-
const sock = connect(path);
|
|
1664
|
-
const fail = (e) => {
|
|
1665
|
-
sock.destroy();
|
|
1666
|
-
reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
|
|
1667
|
-
};
|
|
1668
|
-
sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
|
|
1669
|
-
sock.once("error", (e) => fail(e));
|
|
1670
|
-
sock.once("connect", () => {
|
|
1671
|
-
sock.write(text.replace(/\n/g, " ") + "\n");
|
|
1672
|
-
});
|
|
1673
|
-
sock.once("data", () => {
|
|
1674
|
-
sock.end();
|
|
1675
|
-
resolve("ok");
|
|
1676
|
-
});
|
|
1677
|
-
});
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
//#endregion
|
|
1681
|
-
//#region src/workers/proxy/server.ts
|
|
1682
|
-
/**
|
|
1683
|
-
* server.ts — the PAI worker proxy: Anthropic Messages API on the front,
|
|
1684
|
-
* OpenAI Chat Completions on the back, loopback only.
|
|
1685
|
-
*
|
|
1686
|
-
* One proxy serves every OpenAI-protocol provider: Claude Code points
|
|
1687
|
-
* ANTHROPIC_BASE_URL at `http://127.0.0.1:8797/<provider>` and the provider
|
|
1688
|
-
* name in the path selects the upstream (`upstreamUrl` + `keyFile` from the
|
|
1689
|
-
* workers config, re-read per request so config edits apply without a
|
|
1690
|
-
* restart). `run` starts the proxy on demand (detached, pid file under the
|
|
1691
|
-
* logDir) and `pai worker proxy stop` stops it again.
|
|
1692
|
-
*
|
|
1693
|
-
* All translation lives in translate.ts; this file is only HTTP plumbing.
|
|
1694
|
-
*/
|
|
1695
|
-
const DEFAULT_PROXY_PORT = 8797;
|
|
1696
|
-
const HOST = "127.0.0.1";
|
|
1697
|
-
function proxyPidPath(logDir) {
|
|
1698
|
-
return join(logDir, "proxy.pid");
|
|
1699
|
-
}
|
|
1700
|
-
/** True when something already accepts connections on the port. */
|
|
1701
|
-
function proxyListening(port = DEFAULT_PROXY_PORT, timeoutMs = 400) {
|
|
1702
|
-
return new Promise((resolve) => {
|
|
1703
|
-
const sock = connect({
|
|
1704
|
-
port,
|
|
1705
|
-
host: HOST
|
|
1706
|
-
});
|
|
1707
|
-
const done = (ok) => {
|
|
1708
|
-
sock.removeAllListeners();
|
|
1709
|
-
sock.destroy();
|
|
1710
|
-
resolve(ok);
|
|
1711
|
-
};
|
|
1712
|
-
sock.setTimeout(timeoutMs, () => done(false));
|
|
1713
|
-
sock.once("connect", () => done(true));
|
|
1714
|
-
sock.once("error", () => done(false));
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
/**
|
|
1718
|
-
* Where the standalone proxy artefact lives, found from this module's own
|
|
1719
|
-
* location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
|
|
2164
|
+
* Where the standalone proxy artefact lives, found from this module's own
|
|
2165
|
+
* location (dist/cli bundle → ../hooks/, dev checkout → <repo>/dist/hooks).
|
|
1720
2166
|
*/
|
|
1721
2167
|
function standaloneProxyPath() {
|
|
1722
2168
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -2014,6 +2460,10 @@ function stdinUserMessage(text) {
|
|
|
2014
2460
|
}
|
|
2015
2461
|
});
|
|
2016
2462
|
}
|
|
2463
|
+
/** An operator line as the worker sees it: carrying the contract's marker. */
|
|
2464
|
+
function operatorUserText(text) {
|
|
2465
|
+
return `${OPERATOR_MARK} ${text}`;
|
|
2466
|
+
}
|
|
2017
2467
|
/**
|
|
2018
2468
|
* ISO stamp with seconds, attached to every mirrored event (2g): local time
|
|
2019
2469
|
* with its offset (`2026-09-17T14:19:23+02:00`), so the viewer can render the
|
|
@@ -2049,6 +2499,12 @@ async function runWorker(opts) {
|
|
|
2049
2499
|
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)");
|
|
2050
2500
|
const logDir = workersLogDir(config);
|
|
2051
2501
|
mkdirSync(logDir, { recursive: true });
|
|
2502
|
+
if (opts.className === "plan" && !opts._planner) {
|
|
2503
|
+
const { runPlanner } = await import("./planner-BDI7bE9B.mjs");
|
|
2504
|
+
return runPlanner(opts);
|
|
2505
|
+
}
|
|
2506
|
+
const parent = launchParent(opts.parent);
|
|
2507
|
+
if (parent) assertChildAllowed(logDir, parent, config.tree);
|
|
2052
2508
|
const target = resolveTarget(config, logDir, {
|
|
2053
2509
|
flagProvider: opts.providerFlag,
|
|
2054
2510
|
className: opts.className
|
|
@@ -2068,10 +2524,13 @@ async function runWorker(opts) {
|
|
|
2068
2524
|
claudeArgs: opts.claudeArgs,
|
|
2069
2525
|
noPane: opts.noPane ?? false,
|
|
2070
2526
|
cwd: opts.cwd,
|
|
2071
|
-
parent:
|
|
2527
|
+
parent: parent ?? void 0,
|
|
2072
2528
|
stage: opts.stage,
|
|
2073
2529
|
quiet: opts.quiet,
|
|
2074
|
-
onWorkerStart: opts.onWorkerStart
|
|
2530
|
+
onWorkerStart: opts.onWorkerStart,
|
|
2531
|
+
id: opts.id,
|
|
2532
|
+
worktreeFlag: opts.worktreeFlag,
|
|
2533
|
+
className: opts.className
|
|
2075
2534
|
});
|
|
2076
2535
|
return await executeRun({
|
|
2077
2536
|
config,
|
|
@@ -2084,10 +2543,13 @@ async function runWorker(opts) {
|
|
|
2084
2543
|
noPane: opts.noPane ?? false,
|
|
2085
2544
|
mcpFlag: opts.mcpFlag,
|
|
2086
2545
|
cwd: opts.cwd,
|
|
2087
|
-
parent:
|
|
2546
|
+
parent: parent ?? void 0,
|
|
2088
2547
|
stage: opts.stage,
|
|
2089
2548
|
quiet: opts.quiet,
|
|
2090
2549
|
onWorkerStart: opts.onWorkerStart,
|
|
2550
|
+
id: opts.id,
|
|
2551
|
+
worktreeFlag: opts.worktreeFlag,
|
|
2552
|
+
className: opts.className,
|
|
2091
2553
|
reroutes: opts._reroutes ?? 0
|
|
2092
2554
|
});
|
|
2093
2555
|
} catch (e) {
|
|
@@ -2101,10 +2563,26 @@ async function executeRun(a) {
|
|
|
2101
2563
|
let proxyUrl;
|
|
2102
2564
|
if (target.provider.protocol === "openai") proxyUrl = `${await ensureProxyRunning(DEFAULT_PROXY_PORT, logDir)}/${target.providerName}`;
|
|
2103
2565
|
const env = buildRunEnv(target.provider, headless, proxyUrl);
|
|
2104
|
-
const wid = newWorkerId();
|
|
2566
|
+
const wid = a.id ?? newWorkerId();
|
|
2105
2567
|
const cwd = a.cwd ?? process.cwd();
|
|
2106
2568
|
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
2107
2569
|
const session = resolveSession(term);
|
|
2570
|
+
let worktree = null;
|
|
2571
|
+
if (headless && worktreeWanted(a.worktreeFlag, {
|
|
2572
|
+
cwd,
|
|
2573
|
+
className: a.className,
|
|
2574
|
+
prompt: parsed.prompt
|
|
2575
|
+
})) try {
|
|
2576
|
+
worktree = addWorktree(logDir, wid, cwd);
|
|
2577
|
+
} catch (e) {
|
|
2578
|
+
const why = e.message;
|
|
2579
|
+
process.stderr.write(`pai worker: no worktree (${why}) — running in place\n`);
|
|
2580
|
+
appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
|
|
2581
|
+
id: wid,
|
|
2582
|
+
note: `no worktree: ${why}`
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
env.PAI_WORKER_ID = wid;
|
|
2108
2586
|
const status = {
|
|
2109
2587
|
id: wid,
|
|
2110
2588
|
pid: process.pid,
|
|
@@ -2126,6 +2604,11 @@ async function executeRun(a) {
|
|
|
2126
2604
|
...a.parent ? {
|
|
2127
2605
|
parent: a.parent,
|
|
2128
2606
|
stage: a.stage
|
|
2607
|
+
} : {},
|
|
2608
|
+
...worktree ? {
|
|
2609
|
+
worktreeDir: worktree.dir,
|
|
2610
|
+
branch: worktree.branch,
|
|
2611
|
+
worktreeBase: worktree.base
|
|
2129
2612
|
} : {}
|
|
2130
2613
|
};
|
|
2131
2614
|
saveStatus(logDir, status);
|
|
@@ -2165,11 +2648,12 @@ async function executeRun(a) {
|
|
|
2165
2648
|
if (headless) {
|
|
2166
2649
|
cmd.push("--output-format", "stream-json", "--verbose", "--input-format", "stream-json");
|
|
2167
2650
|
if (!parsed.callerSystemPrompt) cmd.push("--append-system-prompt", WORKER_CONTRACT_PROMPT);
|
|
2651
|
+
if (worktree) cmd.push("--append-system-prompt", worktreeSystemPrompt(wid, worktree.branch, worktree.dir));
|
|
2168
2652
|
}
|
|
2169
2653
|
const t0 = Date.now();
|
|
2170
2654
|
const proc = spawn(cmd[0], cmd.slice(1), {
|
|
2171
2655
|
env,
|
|
2172
|
-
cwd,
|
|
2656
|
+
cwd: worktree?.dir ?? cwd,
|
|
2173
2657
|
stdio: headless ? [
|
|
2174
2658
|
"pipe",
|
|
2175
2659
|
"pipe",
|
|
@@ -2204,10 +2688,11 @@ async function executeRun(a) {
|
|
|
2204
2688
|
}
|
|
2205
2689
|
writeEvent({
|
|
2206
2690
|
type: "operator",
|
|
2207
|
-
text
|
|
2691
|
+
text,
|
|
2692
|
+
handoff: isHandoffMessage(text)
|
|
2208
2693
|
});
|
|
2209
2694
|
try {
|
|
2210
|
-
proc.stdin?.write(stdinUserMessage(text) + "\n");
|
|
2695
|
+
proc.stdin?.write(stdinUserMessage(operatorUserText(text)) + "\n");
|
|
2211
2696
|
} catch {}
|
|
2212
2697
|
}) : null;
|
|
2213
2698
|
let killed = false;
|
|
@@ -2313,7 +2798,8 @@ async function executeRun(a) {
|
|
|
2313
2798
|
tools: status.tools,
|
|
2314
2799
|
label
|
|
2315
2800
|
});
|
|
2316
|
-
if (
|
|
2801
|
+
if (worktree) recordWorktree(logDir, status, worktree, ok);
|
|
2802
|
+
if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport, worktreeExtras(status));
|
|
2317
2803
|
const resultText = resultEvent?.result ?? "";
|
|
2318
2804
|
if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
|
|
2319
2805
|
setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
|
|
@@ -2334,20 +2820,64 @@ async function executeRun(a) {
|
|
|
2334
2820
|
stage: a.stage,
|
|
2335
2821
|
claudeArgs: a.claudeArgs,
|
|
2336
2822
|
onWorkerStart: a.onWorkerStart,
|
|
2823
|
+
worktreeFlag: a.worktreeFlag,
|
|
2824
|
+
className: a.className,
|
|
2337
2825
|
_reroutes: a.reroutes + 1
|
|
2338
2826
|
});
|
|
2339
2827
|
}
|
|
2340
2828
|
}
|
|
2341
|
-
|
|
2829
|
+
const finalRc = rc !== 0 ? rc : ok ? 0 : 1;
|
|
2830
|
+
if (status.parent && isWorkerId(logDir, status.parent)) try {
|
|
2831
|
+
await deliverHandoff(logDir, {
|
|
2832
|
+
from: wid,
|
|
2833
|
+
to: status.parent,
|
|
2834
|
+
kind: "result",
|
|
2835
|
+
text: shortText(ctx.resultReport?.notes ?? resultEvent?.result ?? (ok ? "done" : "failed"), 400),
|
|
2836
|
+
data: {
|
|
2837
|
+
rc: finalRc,
|
|
2838
|
+
ok,
|
|
2839
|
+
...status.branch ? {
|
|
2840
|
+
branch: status.branch,
|
|
2841
|
+
commits: status.commits ?? 0
|
|
2842
|
+
} : {},
|
|
2843
|
+
...ctx.resultReport ? { report: ctx.resultReport } : {}
|
|
2844
|
+
}
|
|
2845
|
+
});
|
|
2846
|
+
} catch {}
|
|
2847
|
+
return finalRc;
|
|
2848
|
+
}
|
|
2849
|
+
/** The worktree fields printResult adds to a json payload, when there is one. */
|
|
2850
|
+
function worktreeExtras(s) {
|
|
2851
|
+
return s.branch ? {
|
|
2852
|
+
branch: s.branch,
|
|
2853
|
+
commits: s.commits ?? 0
|
|
2854
|
+
} : void 0;
|
|
2342
2855
|
}
|
|
2343
2856
|
async function executeCodexRun(a) {
|
|
2344
2857
|
const { config, logDir, target, model, label, parsed, noPane } = a;
|
|
2345
2858
|
if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine codex) supports headless runs only: pass the task with -p '<prompt>'`);
|
|
2346
2859
|
const env = buildCodexEnv(target.provider);
|
|
2347
|
-
const wid = newWorkerId();
|
|
2860
|
+
const wid = a.id ?? newWorkerId();
|
|
2348
2861
|
const cwd = a.cwd ?? process.cwd();
|
|
2349
2862
|
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
2350
2863
|
const session = resolveSession(term);
|
|
2864
|
+
let worktree = null;
|
|
2865
|
+
if (worktreeWanted(a.worktreeFlag, {
|
|
2866
|
+
cwd,
|
|
2867
|
+
className: a.className,
|
|
2868
|
+
prompt: parsed.prompt
|
|
2869
|
+
})) try {
|
|
2870
|
+
worktree = addWorktree(logDir, wid, cwd);
|
|
2871
|
+
} catch (e) {
|
|
2872
|
+
const why = e.message;
|
|
2873
|
+
process.stderr.write(`pai worker: no worktree (${why}) — running in place\n`);
|
|
2874
|
+
appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
|
|
2875
|
+
id: wid,
|
|
2876
|
+
note: `no worktree: ${why}`
|
|
2877
|
+
});
|
|
2878
|
+
}
|
|
2879
|
+
env.PAI_WORKER_ID = wid;
|
|
2880
|
+
const prompt = (worktree ? worktreeSystemPrompt(wid, worktree.branch, worktree.dir) + "\n\n" : "") + parsed.prompt;
|
|
2351
2881
|
const status = {
|
|
2352
2882
|
id: wid,
|
|
2353
2883
|
pid: process.pid,
|
|
@@ -2369,6 +2899,11 @@ async function executeCodexRun(a) {
|
|
|
2369
2899
|
...a.parent ? {
|
|
2370
2900
|
parent: a.parent,
|
|
2371
2901
|
stage: a.stage
|
|
2902
|
+
} : {},
|
|
2903
|
+
...worktree ? {
|
|
2904
|
+
worktreeDir: worktree.dir,
|
|
2905
|
+
branch: worktree.branch,
|
|
2906
|
+
worktreeBase: worktree.base
|
|
2372
2907
|
} : {}
|
|
2373
2908
|
};
|
|
2374
2909
|
saveStatus(logDir, status);
|
|
@@ -2390,9 +2925,9 @@ async function executeCodexRun(a) {
|
|
|
2390
2925
|
});
|
|
2391
2926
|
if (!noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
|
|
2392
2927
|
const t0 = Date.now();
|
|
2393
|
-
const proc = spawn("codex", buildCodexArgs(
|
|
2928
|
+
const proc = spawn("codex", buildCodexArgs(prompt, parsed.callerModel ? void 0 : model), {
|
|
2394
2929
|
env,
|
|
2395
|
-
cwd,
|
|
2930
|
+
cwd: worktree?.dir ?? cwd,
|
|
2396
2931
|
stdio: [
|
|
2397
2932
|
"ignore",
|
|
2398
2933
|
"pipe",
|
|
@@ -2479,10 +3014,29 @@ async function executeCodexRun(a) {
|
|
|
2479
3014
|
tools: status.tools,
|
|
2480
3015
|
label
|
|
2481
3016
|
});
|
|
2482
|
-
if (
|
|
2483
|
-
|
|
3017
|
+
if (worktree) recordWorktree(logDir, status, worktree, ok);
|
|
3018
|
+
if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report, worktreeExtras(status));
|
|
3019
|
+
const finalRc = rc !== 0 ? rc : ok ? 0 : 1;
|
|
3020
|
+
if (status.parent && isWorkerId(logDir, status.parent)) try {
|
|
3021
|
+
await deliverHandoff(logDir, {
|
|
3022
|
+
from: wid,
|
|
3023
|
+
to: status.parent,
|
|
3024
|
+
kind: "result",
|
|
3025
|
+
text: shortText(report?.notes ?? finalText ?? (ok ? "done" : "failed"), 400),
|
|
3026
|
+
data: {
|
|
3027
|
+
rc: finalRc,
|
|
3028
|
+
ok,
|
|
3029
|
+
...status.branch ? {
|
|
3030
|
+
branch: status.branch,
|
|
3031
|
+
commits: status.commits ?? 0
|
|
3032
|
+
} : {},
|
|
3033
|
+
...report ? { report } : {}
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
} catch {}
|
|
3037
|
+
return finalRc;
|
|
2484
3038
|
}
|
|
2485
|
-
function printResult(fmt, resultEvent, rc, logDir, wid, report) {
|
|
3039
|
+
function printResult(fmt, resultEvent, rc, logDir, wid, report, extras) {
|
|
2486
3040
|
if (fmt === "stream-json") return;
|
|
2487
3041
|
if (fmt === "json") {
|
|
2488
3042
|
const payload = report ? {
|
|
@@ -2491,11 +3045,15 @@ function printResult(fmt, resultEvent, rc, logDir, wid, report) {
|
|
|
2491
3045
|
result: "no result event",
|
|
2492
3046
|
rc
|
|
2493
3047
|
},
|
|
2494
|
-
report
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
3048
|
+
report,
|
|
3049
|
+
...extras
|
|
3050
|
+
} : {
|
|
3051
|
+
...resultEvent ?? {
|
|
3052
|
+
is_error: true,
|
|
3053
|
+
result: "no result event",
|
|
3054
|
+
rc
|
|
3055
|
+
},
|
|
3056
|
+
...extras
|
|
2499
3057
|
};
|
|
2500
3058
|
console.log(JSON.stringify(payload));
|
|
2501
3059
|
return;
|
|
@@ -2763,1269 +3321,5 @@ async function runChain(opts, deps = {}) {
|
|
|
2763
3321
|
}
|
|
2764
3322
|
|
|
2765
3323
|
//#endregion
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
* chatui.ts — the chat line of a `follow` pane.
|
|
2769
|
-
*
|
|
2770
|
-
* A follow pane with a target behaves like a small chat, Claude Code style:
|
|
2771
|
-
* the transcript lives in a terminal scroll region that ends two rows above
|
|
2772
|
-
* the pane's bottom; the last two rows are fixed — the prompt row (`› `,
|
|
2773
|
-
* readline line editing) and the ticker row. A transcript line is inserted
|
|
2774
|
-
* above the fixed rows with a save-cursor / scroll-region / restore-cursor
|
|
2775
|
-
* write that never touches them; the scroll region makes the transcript roll
|
|
2776
|
-
* inside itself. Everything here builds strings (or parses one line), so the
|
|
2777
|
-
* tests assert exact byte sequences — no terminal needed.
|
|
2778
|
-
*/
|
|
2779
|
-
/** The prompt marker of the chat row. */
|
|
2780
|
-
const CHAT_PROMPT = "› ";
|
|
2781
|
-
/** Dim hint shown once behind the cursor until the first line is typed. */
|
|
2782
|
-
const CHAT_HINT = "type here and press Enter · /help for commands";
|
|
2783
|
-
/** What `/help` prints (one command per line, dim). */
|
|
2784
|
-
const CHAT_HELP = [
|
|
2785
|
-
"/quit close this pane",
|
|
2786
|
-
"/resume <text> continue the finished worker with <text>",
|
|
2787
|
-
"/status one-line worker status",
|
|
2788
|
-
"anything else is sent to the worker — said while it runs, resumed after"
|
|
2789
|
-
];
|
|
2790
|
-
/** Index just past the escape starting at `i` (CSI, OSC or a two-char one). */
|
|
2791
|
-
function endOfEscape(s, i) {
|
|
2792
|
-
const n = s[i + 1];
|
|
2793
|
-
if (n === "[") {
|
|
2794
|
-
let j = i + 2;
|
|
2795
|
-
while (j < s.length && !(s[j] >= "@" && s[j] <= "~")) j++;
|
|
2796
|
-
return Math.min(s.length, j + 1);
|
|
2797
|
-
}
|
|
2798
|
-
if (n === "]") {
|
|
2799
|
-
let j = i + 2;
|
|
2800
|
-
while (j < s.length && s[j] !== "\x07") j++;
|
|
2801
|
-
return Math.min(s.length, j + 1);
|
|
2802
|
-
}
|
|
2803
|
-
return i + 2;
|
|
2804
|
-
}
|
|
2805
|
-
/** Printable columns of `s` — ANSI escape sequences measure zero. */
|
|
2806
|
-
function visibleWidth(s) {
|
|
2807
|
-
let w = 0;
|
|
2808
|
-
let i = 0;
|
|
2809
|
-
while (i < s.length) {
|
|
2810
|
-
if (s[i] === "\x1B") {
|
|
2811
|
-
i = endOfEscape(s, i);
|
|
2812
|
-
continue;
|
|
2813
|
-
}
|
|
2814
|
-
w += 1;
|
|
2815
|
-
i += 1;
|
|
2816
|
-
}
|
|
2817
|
-
return w;
|
|
2818
|
-
}
|
|
2819
|
-
/**
|
|
2820
|
-
* The SGR sequences in effect at `upto`: everything opened since the last
|
|
2821
|
-
* reset, in order. Anything that is not an SGR escape is ignored (it does
|
|
2822
|
-
* not change colour state).
|
|
2823
|
-
*/
|
|
2824
|
-
function sgrStateAt(text, upto) {
|
|
2825
|
-
const open = [];
|
|
2826
|
-
let i = 0;
|
|
2827
|
-
while (i < Math.min(upto, text.length)) {
|
|
2828
|
-
if (text[i] === "\x1B") {
|
|
2829
|
-
const end = endOfEscape(text, i);
|
|
2830
|
-
const esc = text.slice(i, end);
|
|
2831
|
-
if (/^\x1b\[[0-9;]*m$/.test(esc)) {
|
|
2832
|
-
const params = esc.slice(2, -1);
|
|
2833
|
-
if (params === "" || params.split(";").includes("0")) open.length = 0;
|
|
2834
|
-
if (!(params === "" || params === "0")) open.push(esc);
|
|
2835
|
-
}
|
|
2836
|
-
i = end;
|
|
2837
|
-
continue;
|
|
2838
|
-
}
|
|
2839
|
-
i += 1;
|
|
2840
|
-
}
|
|
2841
|
-
return open;
|
|
2842
|
-
}
|
|
2843
|
-
/**
|
|
2844
|
-
* Wrap one rendered row to `width` printable columns. Breaks on whitespace
|
|
2845
|
-
* where possible, hard-wraps words longer than the width, never splits an
|
|
2846
|
-
* ANSI escape, and re-opens the colours it wraps inside of, so a diff row
|
|
2847
|
-
* keeps its `-`/`+` colour on every continuation row.
|
|
2848
|
-
*/
|
|
2849
|
-
function wrapText(text, width) {
|
|
2850
|
-
if (width < 1 || visibleWidth(text) <= width) return [text];
|
|
2851
|
-
const chars = [];
|
|
2852
|
-
for (let i = 0; i < text.length; i++) {
|
|
2853
|
-
if (text[i] === "\x1B") {
|
|
2854
|
-
i = endOfEscape(text, i) - 1;
|
|
2855
|
-
continue;
|
|
2856
|
-
}
|
|
2857
|
-
chars.push(i);
|
|
2858
|
-
}
|
|
2859
|
-
const words = [];
|
|
2860
|
-
{
|
|
2861
|
-
let s = -1;
|
|
2862
|
-
let w = 0;
|
|
2863
|
-
for (let k = 0; k <= chars.length; k++) if ((k === chars.length ? " " : text[chars[k]]) === " ") {
|
|
2864
|
-
if (s >= 0) {
|
|
2865
|
-
words.push({
|
|
2866
|
-
s,
|
|
2867
|
-
e: k - 1,
|
|
2868
|
-
w
|
|
2869
|
-
});
|
|
2870
|
-
s = -1;
|
|
2871
|
-
w = 0;
|
|
2872
|
-
}
|
|
2873
|
-
} else {
|
|
2874
|
-
if (s < 0) s = k;
|
|
2875
|
-
w++;
|
|
2876
|
-
}
|
|
2877
|
-
}
|
|
2878
|
-
if (words.length && words[0].s > 0) words[0] = {
|
|
2879
|
-
s: 0,
|
|
2880
|
-
e: words[0].e,
|
|
2881
|
-
w: words[0].w + words[0].s
|
|
2882
|
-
};
|
|
2883
|
-
const spans = [];
|
|
2884
|
-
let cs = -1;
|
|
2885
|
-
let ce = -1;
|
|
2886
|
-
for (const word of words) {
|
|
2887
|
-
if (word.w > width) {
|
|
2888
|
-
const room = cs < 0 ? 0 : width - (ce - cs + 1);
|
|
2889
|
-
const take = room > 0 ? ce + room - word.s + 1 : 0;
|
|
2890
|
-
if (take > 0) spans.push([cs, ce + room]);
|
|
2891
|
-
else if (cs >= 0) spans.push([cs, ce]);
|
|
2892
|
-
let pos = word.s + Math.max(0, take);
|
|
2893
|
-
let remaining = word.w - Math.max(0, take);
|
|
2894
|
-
while (remaining > width) {
|
|
2895
|
-
spans.push([pos, pos + width - 1]);
|
|
2896
|
-
pos += width;
|
|
2897
|
-
remaining -= width;
|
|
2898
|
-
}
|
|
2899
|
-
cs = pos;
|
|
2900
|
-
ce = pos + remaining - 1;
|
|
2901
|
-
continue;
|
|
2902
|
-
}
|
|
2903
|
-
if (cs < 0) {
|
|
2904
|
-
cs = word.s;
|
|
2905
|
-
ce = word.e;
|
|
2906
|
-
continue;
|
|
2907
|
-
}
|
|
2908
|
-
if (word.e - cs + 1 <= width) {
|
|
2909
|
-
ce = word.e;
|
|
2910
|
-
continue;
|
|
2911
|
-
}
|
|
2912
|
-
spans.push([cs, ce]);
|
|
2913
|
-
cs = word.s;
|
|
2914
|
-
ce = word.e;
|
|
2915
|
-
}
|
|
2916
|
-
if (cs >= 0) spans.push([cs, ce]);
|
|
2917
|
-
const out = [];
|
|
2918
|
-
for (const [a, b] of spans) {
|
|
2919
|
-
const from = chars[a];
|
|
2920
|
-
let end = chars[b] + 1;
|
|
2921
|
-
while (end < text.length && text[end] === "\x1B") end = endOfEscape(text, end);
|
|
2922
|
-
let piece = text.slice(from, end);
|
|
2923
|
-
const reopen = sgrStateAt(text, from);
|
|
2924
|
-
if (reopen.length) piece = reopen.join("") + piece;
|
|
2925
|
-
if (sgrStateAt(text, end).length) piece += "\x1B[0m";
|
|
2926
|
-
out.push(piece);
|
|
2927
|
-
}
|
|
2928
|
-
return out.length ? out : [""];
|
|
2929
|
-
}
|
|
2930
|
-
/** Restrict scrolling to the transcript region (rows 1 … rows-2). */
|
|
2931
|
-
function chatScrollRegion(rows) {
|
|
2932
|
-
return `\x1b[1;${Math.max(1, rows - 2)}r`;
|
|
2933
|
-
}
|
|
2934
|
-
/**
|
|
2935
|
-
* Enter the chat layout: clear the pane, set the scroll region, park the
|
|
2936
|
-
* cursor at column 1 of the prompt row (rows-1). The ticker owns row `rows`.
|
|
2937
|
-
*/
|
|
2938
|
-
function chatEnter(rows) {
|
|
2939
|
-
return "\x1B[2J" + chatScrollRegion(rows) + `\x1b[${Math.max(1, rows - 1)};1H`;
|
|
2940
|
-
}
|
|
2941
|
-
/** Leave it: reset the scroll region, show the cursor, drop to the last row. */
|
|
2942
|
-
function chatLeave(rows) {
|
|
2943
|
-
return `[r[?25h\x1b[${Math.max(1, rows)};1H`;
|
|
2944
|
-
}
|
|
2945
|
-
/** Redraw the ticker on its own row without moving the user's cursor. */
|
|
2946
|
-
function chatTickerRow(text, rows) {
|
|
2947
|
-
return `7\x1b[${Math.max(1, rows)};1H\x1b[K` + text + "\x1B8";
|
|
2948
|
-
}
|
|
2949
|
-
/** Move to column 1 of the prompt row and draw prompt (and hint). */
|
|
2950
|
-
function chatPromptRow(rows, prompt = CHAT_PROMPT, hint) {
|
|
2951
|
-
return `\x1b[${Math.max(1, rows - 1)};1H` + prompt + (hint ?? "");
|
|
2952
|
-
}
|
|
2953
|
-
/**
|
|
2954
|
-
* Insert one transcript row above the fixed prompt/ticker rows. While the
|
|
2955
|
-
* region is still filling (`fill < regionRows`) the row is placed top-down;
|
|
2956
|
-
* once full, the cursor moves to the region's bottom row and a newline
|
|
2957
|
-
* scrolls the region up by one — the two fixed rows are never touched. The
|
|
2958
|
-
* user's cursor is saved before and restored after, so readline keeps its
|
|
2959
|
-
* position on the prompt row.
|
|
2960
|
-
*/
|
|
2961
|
-
function chatInsertLine(line, fill, regionRows) {
|
|
2962
|
-
return {
|
|
2963
|
-
seq: "\x1B7" + (fill < regionRows ? `\x1b[${fill + 1};1H${line}\x1b[K` : `\x1b[${regionRows};1H\n${line}\x1b[K`) + "\x1B8",
|
|
2964
|
-
fill: Math.min(fill + 1, regionRows)
|
|
2965
|
-
};
|
|
2966
|
-
}
|
|
2967
|
-
/**
|
|
2968
|
-
* One submitted prompt line → what to do with it. `/help`, `/quit`,
|
|
2969
|
-
* `/status` and `/resume <text>` are commands (a bare `/resume` comes back
|
|
2970
|
-
* with empty text so the caller can print its usage); anything else,
|
|
2971
|
-
* including any other `/word`, is a message for the worker.
|
|
2972
|
-
*/
|
|
2973
|
-
function parseChatLine(raw) {
|
|
2974
|
-
const text = raw.trim();
|
|
2975
|
-
if (text === "/help") return { kind: "help" };
|
|
2976
|
-
if (text === "/quit") return { kind: "quit" };
|
|
2977
|
-
if (text === "/status") return { kind: "status" };
|
|
2978
|
-
if (text.startsWith("/resume")) return {
|
|
2979
|
-
kind: "resume",
|
|
2980
|
-
text: text.slice(7).trim()
|
|
2981
|
-
};
|
|
2982
|
-
return {
|
|
2983
|
-
kind: "message",
|
|
2984
|
-
text
|
|
2985
|
-
};
|
|
2986
|
-
}
|
|
2987
|
-
/**
|
|
2988
|
-
* The auto-exit countdown must not fire while the prompt holds unsent text:
|
|
2989
|
-
* true while it does. null/undefined (no prompt wired) never holds.
|
|
2990
|
-
*/
|
|
2991
|
-
function holdAutoExit(promptText) {
|
|
2992
|
-
return typeof promptText === "string" && promptText.trim() !== "";
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
//#endregion
|
|
2996
|
-
//#region src/workers/render.ts
|
|
2997
|
-
/**
|
|
2998
|
-
* render.ts — turn worker events into the lines a human reads.
|
|
2999
|
-
*
|
|
3000
|
-
* Ports the glm-ps transcript rendering: the gutter rules (dim `>` for reads
|
|
3001
|
-
* and searches, `$` for shell, magenta `~` + red/green diff for edits, green
|
|
3002
|
-
* `+` for writes), Read-result trimming (first 3 lines then a count), and the
|
|
3003
|
-
* result footer. Colors are applied only when the output is a TTY, so MCP
|
|
3004
|
-
* tool output stays plain text.
|
|
3005
|
-
*/
|
|
3006
|
-
const CODES = {
|
|
3007
|
-
dim: "2",
|
|
3008
|
-
bold: "1",
|
|
3009
|
-
red: "31",
|
|
3010
|
-
green: "32",
|
|
3011
|
-
yellow: "33",
|
|
3012
|
-
blue: "34",
|
|
3013
|
-
mag: "35",
|
|
3014
|
-
cyan: "36"
|
|
3015
|
-
};
|
|
3016
|
-
function makeColor(enabled) {
|
|
3017
|
-
return (name, s) => enabled ? `\x1b[${CODES[name]}m${s}\x1b[0m` : s;
|
|
3018
|
-
}
|
|
3019
|
-
/** Path relative to cwd when it lies inside it, otherwise unchanged. */
|
|
3020
|
-
function relPath(path, cwd) {
|
|
3021
|
-
if (!path || !cwd) return path;
|
|
3022
|
-
let r;
|
|
3023
|
-
try {
|
|
3024
|
-
r = relative(cwd, path);
|
|
3025
|
-
} catch {
|
|
3026
|
-
return path;
|
|
3027
|
-
}
|
|
3028
|
-
return r.startsWith("..") ? path : r;
|
|
3029
|
-
}
|
|
3030
|
-
/** Minimal line diff for Edit previews: common prefix/suffix, one hunk. */
|
|
3031
|
-
function unifiedDiffLines(oldStr, newStr) {
|
|
3032
|
-
const oldL = oldStr.split("\n");
|
|
3033
|
-
const newL = newStr.split("\n");
|
|
3034
|
-
let start = 0;
|
|
3035
|
-
while (start < oldL.length && start < newL.length && oldL[start] === newL[start]) start++;
|
|
3036
|
-
let endOld = oldL.length;
|
|
3037
|
-
let endNew = newL.length;
|
|
3038
|
-
while (endOld > start && endNew > start && oldL[endOld - 1] === newL[endNew - 1]) {
|
|
3039
|
-
endOld--;
|
|
3040
|
-
endNew--;
|
|
3041
|
-
}
|
|
3042
|
-
const out = [];
|
|
3043
|
-
for (let i = start; i < endOld; i++) out.push("-" + oldL[i]);
|
|
3044
|
-
for (let i = start; i < endNew; i++) out.push("+" + newL[i]);
|
|
3045
|
-
return out;
|
|
3046
|
-
}
|
|
3047
|
-
/**
|
|
3048
|
-
* `HH:MM:SS` at an offset east of UTC in minutes (Date.getTimezoneOffset()
|
|
3049
|
-
* negated), from any ISO stamp the runner wrote — `Z` or a local `+HH:MM`.
|
|
3050
|
-
* null when the stamp cannot be parsed. Offsets make this testable without
|
|
3051
|
-
* depending on the machine's zone; the default is this machine's.
|
|
3052
|
-
*/
|
|
3053
|
-
function clockOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
|
|
3054
|
-
const t = Date.parse(ts);
|
|
3055
|
-
if (Number.isNaN(t)) return null;
|
|
3056
|
-
return new Date(t + offMin * 6e4).toISOString().slice(11, 19);
|
|
3057
|
-
}
|
|
3058
|
-
/** `YYYY-MM-DD` at the same offset — the local day a date separator shows. */
|
|
3059
|
-
function dayOf(ts, offMin = -(/* @__PURE__ */ new Date()).getTimezoneOffset()) {
|
|
3060
|
-
const t = Date.parse(ts);
|
|
3061
|
-
if (Number.isNaN(t)) return null;
|
|
3062
|
-
return new Date(t + offMin * 6e4).toISOString().slice(0, 10);
|
|
3063
|
-
}
|
|
3064
|
-
/**
|
|
3065
|
-
* The transcript gutter (2g): `HH:MM:SS │ ` from the event's `_ts`, dim, with
|
|
3066
|
-
* the worker tag in front when several run at once. Continuation lines get
|
|
3067
|
-
* blanks of the same width so wrapped text stays aligned; when the viewer
|
|
3068
|
-
* wraps lines itself, continuation rows carry the `│` bar instead (barCont)
|
|
3069
|
-
* so the bar runs unbroken down the pane. null when the event carries no
|
|
3070
|
-
* stamp (logs from before 2g render with the plain prefix). The time is the
|
|
3071
|
-
* stamp's wall clock at `offMin` — the default renders local time, whatever
|
|
3072
|
-
* zone stamped the log (old logs were stamped in UTC).
|
|
3073
|
-
*/
|
|
3074
|
-
function gutterFor(c, e, tag, offMin) {
|
|
3075
|
-
if (!e._ts) return null;
|
|
3076
|
-
const time = clockOf(e._ts, offMin) ?? (e._ts.length >= 19 ? e._ts.slice(11, 19) : e._ts);
|
|
3077
|
-
const head = tag ? `${tag} ${time}` : time;
|
|
3078
|
-
const width = head.length + 3;
|
|
3079
|
-
return {
|
|
3080
|
-
first: c("dim", `${head} │ `),
|
|
3081
|
-
cont: " ".repeat(width),
|
|
3082
|
-
barCont: c("dim", `${" ".repeat(head.length)} │ `),
|
|
3083
|
-
width
|
|
3084
|
-
};
|
|
3085
|
-
}
|
|
3086
|
-
/**
|
|
3087
|
-
* The ticker's tool part: `$ <command>` for Bash (first 60 chars), the file
|
|
3088
|
-
* basename for the file tools, the bare name for everything else.
|
|
3089
|
-
*/
|
|
3090
|
-
function tickerTool(name, inp) {
|
|
3091
|
-
const i = typeof inp === "object" && inp !== null ? inp : {};
|
|
3092
|
-
const get = (k) => typeof i[k] === "string" ? i[k] : "";
|
|
3093
|
-
if (name === "Bash") return `$ ${shortText(get("command").replace(/\s+/g, " ").trim(), 60)}`;
|
|
3094
|
-
if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return (get("file_path").split("/").pop() ?? "") || name;
|
|
3095
|
-
return name;
|
|
3096
|
-
}
|
|
3097
|
-
/**
|
|
3098
|
-
* The liveness line: `⋯ 12s · run tests before the fix · $ bun run test` —
|
|
3099
|
-
* seconds since the last *rendered* event, the worker's last stated intent
|
|
3100
|
-
* (its last assistant text, ≤60 chars) and the tool it is currently running.
|
|
3101
|
-
* Parts that are empty drop out.
|
|
3102
|
-
*/
|
|
3103
|
-
function tickerText(secs, intent, tool, meter) {
|
|
3104
|
-
const head = `⋯ ${secs}s`;
|
|
3105
|
-
const tail = [intent, tool].map((p) => p.trim()).filter(Boolean).join(" · ");
|
|
3106
|
-
const line = tail ? `${head} · ${tail}` : head;
|
|
3107
|
-
return meter ? `${line} · ${meter}` : line;
|
|
3108
|
-
}
|
|
3109
|
-
/**
|
|
3110
|
-
* One blank line between turns, none inside one: a blank goes before an
|
|
3111
|
-
* assistant message that follows a tool result or an operator message (the
|
|
3112
|
-
* worker starting to speak again after its tools were answered / it was told
|
|
3113
|
-
* something), not between the text, tool calls and results of one turn.
|
|
3114
|
-
*/
|
|
3115
|
-
function blankBetween(prev, e) {
|
|
3116
|
-
if (!prev) return false;
|
|
3117
|
-
if (e.type !== "assistant") return false;
|
|
3118
|
-
return prev.type === "user" || prev.type === "operator";
|
|
3119
|
-
}
|
|
3120
|
-
/** The worker's last stated intent: the first line of its last text, ≤60. */
|
|
3121
|
-
function intentOf(text) {
|
|
3122
|
-
return shortText((text.trim().split("\n").find((l) => l.trim()) ?? "").trim().replace(/\s+/g, " "), 60);
|
|
3123
|
-
}
|
|
3124
|
-
/** Compact token count: 84k, 200k, 900. */
|
|
3125
|
-
function fmtK(n) {
|
|
3126
|
-
return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
3127
|
-
}
|
|
3128
|
-
/**
|
|
3129
|
-
* The context meter `ctx 84k/200k (42%)`, yellow from 70 %, red from 85 %.
|
|
3130
|
-
* null when the numbers are missing or below `minPct` (the table only shows
|
|
3131
|
-
* it past 60; the pane liveness line always shows it).
|
|
3132
|
-
*/
|
|
3133
|
-
function contextMeter(c, s, minPct = 0) {
|
|
3134
|
-
const pct = contextPercent(s);
|
|
3135
|
-
if (pct === null || pct <= minPct) return null;
|
|
3136
|
-
const label = `ctx ${fmtK(s.contextTokens ?? 0)}/${fmtK(s.contextWindow ?? 0)} (${pct}%)`;
|
|
3137
|
-
if (pct > 85) return c("red", label);
|
|
3138
|
-
if (pct > 70) return c("yellow", label);
|
|
3139
|
-
return label;
|
|
3140
|
-
}
|
|
3141
|
-
function renderToolUse(c, prefix, name, inp, cwd) {
|
|
3142
|
-
const i = typeof inp === "object" && inp !== null ? inp : {};
|
|
3143
|
-
const get = (k) => typeof i[k] === "string" ? i[k] : "";
|
|
3144
|
-
if (name === "Read") return [`${prefix}${c("dim", ">")} Reading ${relPath(get("file_path"), cwd)}`];
|
|
3145
|
-
if (name === "Grep" || name === "Glob") return [`${prefix}${c("dim", ">")} Searching ${c("cyan", get("pattern"))} in ${relPath(get("path") || ".", cwd)}`];
|
|
3146
|
-
if (name === "Bash") return [`${prefix}${c("dim", "$")} ${shortText(get("command"), 160)}`];
|
|
3147
|
-
if (name === "Edit") {
|
|
3148
|
-
const out = [`${prefix}${c("mag", "~")} Editing ${relPath(get("file_path"), cwd)}`];
|
|
3149
|
-
for (const line of unifiedDiffLines(get("old_string"), get("new_string"))) if (line.startsWith("-")) out.push(`${prefix} ${c("red", line)}`);
|
|
3150
|
-
else if (line.startsWith("+")) out.push(`${prefix} ${c("green", line)}`);
|
|
3151
|
-
else out.push(`${prefix} ${c("dim", line)}`);
|
|
3152
|
-
return out;
|
|
3153
|
-
}
|
|
3154
|
-
if (name === "Write") {
|
|
3155
|
-
const n = get("content").split("\n").length;
|
|
3156
|
-
return [`${prefix}${c("green", "+")} Writing ${relPath(get("file_path"), cwd)} (${n} lines)`];
|
|
3157
|
-
}
|
|
3158
|
-
if (name === "WebSearch" || name === "WebFetch") return [`${prefix}${c("dim", ">")} ${name} ${shortText(get("query") || get("url"), 100)}`];
|
|
3159
|
-
return [`${prefix}${c("dim", ">")} ${name} ${shortText(JSON.stringify(i), 120)}`];
|
|
3160
|
-
}
|
|
3161
|
-
/** Render one stream-json event; `tools` maps tool_use_id → tool name. */
|
|
3162
|
-
function renderEvent(c, prefix, e, cwd, tools, gutter) {
|
|
3163
|
-
const out = [];
|
|
3164
|
-
if (e.type === "system" && e.subtype === "init") out.push(`${prefix}${c("dim", `worker started · model ${e.model ?? "?"} · cwd ${basename(e.cwd || cwd || "")}`)}`);
|
|
3165
|
-
else if (e.type === "operator") for (const ln of String(e.text ?? "").split("\n")) out.push(`${prefix}${c("cyan", "» " + ln)}`);
|
|
3166
|
-
else if (e.type === "assistant") {
|
|
3167
|
-
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}`);
|
|
3168
|
-
else if (b.type === "tool_use") out.push(...renderToolUse(c, prefix, b.name ?? "?", b.input, cwd));
|
|
3169
|
-
} else if (e.type === "user") for (const b of e.message?.content ?? []) {
|
|
3170
|
-
if (b.type !== "tool_result") continue;
|
|
3171
|
-
let content = b.content ?? "";
|
|
3172
|
-
if (Array.isArray(content)) content = content.map((x) => typeof x === "object" && x !== null && "text" in x ? String(x.text ?? "") : "").join("\n");
|
|
3173
|
-
const text = String(content);
|
|
3174
|
-
const isError = b.is_error === true;
|
|
3175
|
-
if (!isError && tools[b.tool_use_id ?? ""] === "Read") {
|
|
3176
|
-
const lines = text.split("\n");
|
|
3177
|
-
for (const ln of lines.slice(0, 3)) out.push(`${prefix}${c("dim", ln)}`);
|
|
3178
|
-
if (lines.length > 3) out.push(`${prefix}${c("dim", ` … ${lines.length} lines`)}`);
|
|
3179
|
-
} else if (isError) out.push(`${prefix} ${c("red", "! " + shortText(text, 200))}`);
|
|
3180
|
-
else if (text.trim()) out.push(`${prefix} ${c("dim", shortText(text, 120))}`);
|
|
3181
|
-
}
|
|
3182
|
-
else if (e.type === "result") {
|
|
3183
|
-
const mark = !e.is_error ? c("green", "✓ done") : c("red", "✗ failed");
|
|
3184
|
-
out.push(`${prefix}${mark} · ${e.num_turns ?? "?"} turns · ${Math.floor((e.duration_ms ?? 0) / 1e3)}s`);
|
|
3185
|
-
const report = parseWorkerReport(String(e.result ?? ""));
|
|
3186
|
-
if (report) out.push(...renderReport(c, prefix, report, cwd));
|
|
3187
|
-
else for (const ln of String(e.result ?? "").trim().split("\n")) out.push(`${prefix} ${ln}`);
|
|
3188
|
-
}
|
|
3189
|
-
if (!gutter) return out;
|
|
3190
|
-
return out.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
|
|
3191
|
-
}
|
|
3192
|
-
/** Transcript header: id, provider, label, session name, project dir. */
|
|
3193
|
-
function headerLine(c, s) {
|
|
3194
|
-
const bits = [s.provider ? `[${s.provider}]` : "", sessionTag(s)].filter(Boolean).join(" ");
|
|
3195
|
-
const sep = bits ? ` ${bits}` : "";
|
|
3196
|
-
return c("bold", `━━ ${s.id}${sep} ${s.label} (${basename(s.cwd)})`);
|
|
3197
|
-
}
|
|
3198
|
-
/** The chain label behind a stage label: strip the trailing " · <stage>". */
|
|
3199
|
-
function chainLabelOf(stages) {
|
|
3200
|
-
const first = stages[0];
|
|
3201
|
-
if (!first) return "";
|
|
3202
|
-
const suffix = first.stage ? ` · ${first.stage}` : "";
|
|
3203
|
-
return first.label.endsWith(suffix) && suffix ? first.label.slice(0, first.label.length - suffix.length) : first.label;
|
|
3204
|
-
}
|
|
3205
|
-
/**
|
|
3206
|
-
* The ps table (RUNNING + FINISHED last 8). Chain stages carry `parent` and
|
|
3207
|
-
* render as a tree under one `chain <id>` header; plain workers render as
|
|
3208
|
-
* before.
|
|
3209
|
-
*/
|
|
3210
|
-
function renderTable(c, statuses, scopeLabel, now = /* @__PURE__ */ new Date()) {
|
|
3211
|
-
const running = [];
|
|
3212
|
-
const done = [];
|
|
3213
|
-
for (const s of statuses) if (s.state === "running" && alive(s.pid)) running.push(s);
|
|
3214
|
-
else {
|
|
3215
|
-
if (s.state === "running") s.state = "lost";
|
|
3216
|
-
done.push(s);
|
|
3217
|
-
}
|
|
3218
|
-
const group = (list) => {
|
|
3219
|
-
const out = [];
|
|
3220
|
-
for (const s of list) out.push({
|
|
3221
|
-
s,
|
|
3222
|
-
chain: s.parent ?? null
|
|
3223
|
-
});
|
|
3224
|
-
return out;
|
|
3225
|
-
};
|
|
3226
|
-
const treeLine = (line, chain, last) => {
|
|
3227
|
-
if (chain === null) return line;
|
|
3228
|
-
const mark = last ? "└" : "├";
|
|
3229
|
-
const bar = last ? " " : "│";
|
|
3230
|
-
return line.startsWith(" ") ? ` ${bar} ${line.slice(6)}` : ` ${mark} ${line.slice(2)}`;
|
|
3231
|
-
};
|
|
3232
|
-
const lines = [c("bold", `Workers ${`${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`}`), ""];
|
|
3233
|
-
lines.push(c("bold", `RUNNING (${running.length})`));
|
|
3234
|
-
if (!running.length) lines.push(" none");
|
|
3235
|
-
const runEntries = group(running);
|
|
3236
|
-
for (let i = 0; i < runEntries.length; i++) {
|
|
3237
|
-
const { s, chain } = runEntries[i];
|
|
3238
|
-
if (chain) {
|
|
3239
|
-
const prev = runEntries[i - 1];
|
|
3240
|
-
if (!prev || prev.chain !== chain) {
|
|
3241
|
-
const stages = runEntries.filter((e) => e.chain === chain).map((e) => e.s);
|
|
3242
|
-
lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
|
|
3243
|
-
}
|
|
3244
|
-
}
|
|
3245
|
-
const last = !chain || !runEntries[i + 1] || runEntries[i + 1].chain !== chain;
|
|
3246
|
-
const meter = contextMeter(c, s, 60);
|
|
3247
|
-
lines.push(treeLine(` ${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 : ""}`, chain, last));
|
|
3248
|
-
lines.push(treeLine(` task: ${s.label}`, chain, last));
|
|
3249
|
-
lines.push(treeLine(` now: ${c("yellow", s.last)} (${ageOf(s.updated, now)} ago)`, chain, last));
|
|
3250
|
-
}
|
|
3251
|
-
lines.push("");
|
|
3252
|
-
lines.push(c("bold", "FINISHED (last 8)"));
|
|
3253
|
-
const doneEntries = group(done.slice(-8));
|
|
3254
|
-
for (let i = 0; i < doneEntries.length; i++) {
|
|
3255
|
-
const { s, chain } = doneEntries[i];
|
|
3256
|
-
if (chain) {
|
|
3257
|
-
const prev = doneEntries[i - 1];
|
|
3258
|
-
if (!prev || prev.chain !== chain) {
|
|
3259
|
-
const stages = doneEntries.filter((e) => e.chain === chain).map((e) => e.s);
|
|
3260
|
-
lines.push(` ${c("bold", `chain ${chain}`)} ${chainLabelOf(stages)}`);
|
|
3261
|
-
}
|
|
3262
|
-
}
|
|
3263
|
-
const last = !chain || !doneEntries[i + 1] || doneEntries[i + 1].chain !== chain;
|
|
3264
|
-
const col = s.state === "done" ? "green" : "red";
|
|
3265
|
-
const tag = sessionTag(s);
|
|
3266
|
-
lines.push(treeLine(` ${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}`, chain, last));
|
|
3267
|
-
}
|
|
3268
|
-
lines.push("");
|
|
3269
|
-
lines.push(c("dim", "worker follow live transcript of running workers"));
|
|
3270
|
-
lines.push(c("dim", "worker <id> replay one worker"));
|
|
3271
|
-
lines.push(c("dim", scopeLabel));
|
|
3272
|
-
return lines.join("\n");
|
|
3273
|
-
}
|
|
3274
|
-
/** One-line status-bar summary (same shape glm-ps --status printed). */
|
|
3275
|
-
function renderStatusLine(mine, now = /* @__PURE__ */ new Date(), c = makeColor(true)) {
|
|
3276
|
-
if (!mine.length) return "";
|
|
3277
|
-
const running = mine.filter((s) => s.state === "running" && alive(s.pid));
|
|
3278
|
-
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
3279
|
-
const doneToday = mine.filter((s) => s.state !== "running" && s.started.startsWith(today));
|
|
3280
|
-
const ok = doneToday.filter((s) => s.state === "done").length;
|
|
3281
|
-
const bad = doneToday.length - ok;
|
|
3282
|
-
const providers = new Set(running.map((s) => s.provider));
|
|
3283
|
-
let head = `${providers.size === 1 ? [...providers][0] : "workers"} ▶${running.length}`;
|
|
3284
|
-
const parts = running.slice(0, 3).map((s) => {
|
|
3285
|
-
const meter = contextMeter(c, s, 60);
|
|
3286
|
-
return `${s.id.slice(-4)} ${s.label.slice(0, 26)} ${ageOf(s.started, now)} · ${s.last.slice(0, 30)}` + (meter ? ` ${meter}` : "");
|
|
3287
|
-
});
|
|
3288
|
-
if (parts.length) head += " " + parts.join(" | ");
|
|
3289
|
-
if (doneToday.length) head += ` ✓${ok} ✗${bad} today`;
|
|
3290
|
-
return head;
|
|
3291
|
-
}
|
|
3292
|
-
|
|
3293
|
-
//#endregion
|
|
3294
|
-
//#region src/workers/viewer.ts
|
|
3295
|
-
/**
|
|
3296
|
-
* viewer.ts — ps / follow / replay / status line over the worker logDir.
|
|
3297
|
-
*
|
|
3298
|
-
* Scoping: workers launched from this terminal's AIBroker session (or, as the
|
|
3299
|
-
* fallback, its iTerm tab) unless --all or an explicit worker id is given.
|
|
3300
|
-
* Outside iTerm, everything degrades to "all workers" — the Python behaviour.
|
|
3301
|
-
*
|
|
3302
|
-
* Transcripts render with a `HH:MM:SS │ ` gutter (2g): dim, taken from the
|
|
3303
|
-
* `_ts` stamp on every mirrored event (local wall clock — stamps carry a local
|
|
3304
|
-
* offset and old UTC stamps are converted), the worker tag in front when
|
|
3305
|
-
* several run at once, a date separator when the day changes, and — on a TTY —
|
|
3306
|
-
* a liveness line (`⋯ 12s · run tests before the fix · $ bun run test`) that
|
|
3307
|
-
* is rewritten in place between events. Attaching to a worker that is already
|
|
3308
|
-
* running first replays its last events (backfill), then continues live.
|
|
3309
|
-
*
|
|
3310
|
-
* Following one worker turns the pane into a small chat (see chatui.ts): the
|
|
3311
|
-
* transcript scrolls in a region that ends two rows above the bottom, the
|
|
3312
|
-
* prompt row (`› `, readline editing) and the ticker row stay fixed, and every
|
|
3313
|
-
* submitted line is said to the worker while it runs and resumes it (same
|
|
3314
|
-
* Claude session) once it has finished. Lines the pane wraps itself keep the
|
|
3315
|
-
* `│` bar on continuation rows, so no content ever lands left of the bar.
|
|
3316
|
-
* Non-TTY output keeps the plain scrolling behaviour.
|
|
3317
|
-
*/
|
|
3318
|
-
/** How many existing events a fresh follow pane replays before going live. */
|
|
3319
|
-
const BACKFILL_EVENTS = 200;
|
|
3320
|
-
function psOutput(logDir, showAll, env = process.env, color = process.stdout.isTTY === true) {
|
|
3321
|
-
const c = makeColor(color);
|
|
3322
|
-
const term = env.ITERM_SESSION_ID ?? "";
|
|
3323
|
-
const statuses = loadStatuses(logDir);
|
|
3324
|
-
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)`);
|
|
3325
|
-
}
|
|
3326
|
-
function initialFollowState(intent = "waiting for first event") {
|
|
3327
|
-
return {
|
|
3328
|
-
lastDay: "",
|
|
3329
|
-
tools: {},
|
|
3330
|
-
intent,
|
|
3331
|
-
tool: "",
|
|
3332
|
-
prev: null
|
|
3333
|
-
};
|
|
3334
|
-
}
|
|
3335
|
-
/**
|
|
3336
|
-
* Gutter the rendered body of one event, wrapping when the pane width is
|
|
3337
|
-
* known. Unwrapped (null `wrapWidth`, e.g. piped output): the first row gets
|
|
3338
|
-
* the stamped gutter, later rows of the event blanks — exactly the pre-chat
|
|
3339
|
-
* rendering. Wrapped: every row is folded at `wrapWidth` columns and each
|
|
3340
|
-
* continuation row carries the blank gutter with the `│` bar, so the bar runs
|
|
3341
|
-
* unbroken down the pane and no content ever lands left of it.
|
|
3342
|
-
*/
|
|
3343
|
-
function gutterBody(body, gutter, wrapWidth) {
|
|
3344
|
-
if (!gutter) return body;
|
|
3345
|
-
if (wrapWidth === null || wrapWidth <= gutter.width) return body.map((ln, i) => (i === 0 ? gutter.first : gutter.cont) + ln);
|
|
3346
|
-
const out = [];
|
|
3347
|
-
for (const ln of body) for (const piece of wrapText(ln, wrapWidth - gutter.width)) out.push((out.length === 0 ? gutter.first : gutter.barCont) + piece);
|
|
3348
|
-
return out;
|
|
3349
|
-
}
|
|
3350
|
-
/**
|
|
3351
|
-
* Render one event and advance the follow state. Everything the viewer shows
|
|
3352
|
-
* between events of one worker comes from here: replay, the backfill on
|
|
3353
|
-
* attach and the live tail all use it, so they space identically — events
|
|
3354
|
-
* back to back, one blank line between turns. `activity` is false for events
|
|
3355
|
-
* that render nothing (stream noise, empty tool results): they leave the
|
|
3356
|
-
* ticker's "since last event" clock running. `wrapWidth` (the pane's column
|
|
3357
|
-
* count, re-read on resize) makes the viewer wrap rows itself; null keeps
|
|
3358
|
-
* the terminal's own wrapping.
|
|
3359
|
-
*/
|
|
3360
|
-
function applyEvent(c, s, e, cwd, tag, offMin, wrapWidth) {
|
|
3361
|
-
const tools = { ...s.tools };
|
|
3362
|
-
if (e.type === "assistant") {
|
|
3363
|
-
for (const b of e.message?.content ?? []) if (b.type === "tool_use" && b.id) tools[b.id] = b.name ?? "?";
|
|
3364
|
-
}
|
|
3365
|
-
const day = typeof e._ts === "string" ? dayOf(e._ts, offMin) ?? rawDay(e._ts) : "";
|
|
3366
|
-
const state = {
|
|
3367
|
-
lastDay: day && day !== s.lastDay ? day : s.lastDay,
|
|
3368
|
-
tools,
|
|
3369
|
-
intent: s.intent,
|
|
3370
|
-
tool: s.tool,
|
|
3371
|
-
prev: e
|
|
3372
|
-
};
|
|
3373
|
-
if (e.type === "assistant") {
|
|
3374
|
-
for (const b of e.message?.content ?? []) if (b.type === "text" && (b.text ?? "").trim()) state.intent = intentOf(b.text ?? "");
|
|
3375
|
-
else if (b.type === "tool_use") state.tool = tickerTool(b.name ?? "?", b.input);
|
|
3376
|
-
}
|
|
3377
|
-
const gutter = gutterFor(c, e, tag, offMin);
|
|
3378
|
-
const body = gutterBody(renderEvent(c, "", e, cwd, tools), gutter, wrapWidth ?? null);
|
|
3379
|
-
const lines = blankBetween(s.prev, e) ? ["", ...body] : body;
|
|
3380
|
-
return {
|
|
3381
|
-
lines,
|
|
3382
|
-
day: day && day !== s.lastDay ? day : null,
|
|
3383
|
-
activity: lines.some((ln) => ln !== ""),
|
|
3384
|
-
state
|
|
3385
|
-
};
|
|
3386
|
-
}
|
|
3387
|
-
/** `YYYY-MM-DD` straight out of an unparsable stamp, "" when it has none. */
|
|
3388
|
-
function rawDay(ts) {
|
|
3389
|
-
return /^\d{4}-\d{2}-\d{2}/.test(ts) ? ts.slice(0, 10) : "";
|
|
3390
|
-
}
|
|
3391
|
-
/**
|
|
3392
|
-
* The events a fresh follow replays before going live: the last `cap`
|
|
3393
|
-
* non-empty log lines, oldest first.
|
|
3394
|
-
*/
|
|
3395
|
-
function backfillLines(raw, cap = BACKFILL_EVENTS) {
|
|
3396
|
-
return raw.split("\n").filter((l) => l.trim()).slice(-cap);
|
|
3397
|
-
}
|
|
3398
|
-
/** The rendered transcript of one worker, from the start, as one string. */
|
|
3399
|
-
function replayOutput(logDir, wid, color = process.stdout.isTTY === true, tailLines) {
|
|
3400
|
-
const path = eventsPath(logDir, wid);
|
|
3401
|
-
if (!existsSync(path)) throw new Error(`no event log for ${wid}`);
|
|
3402
|
-
const c = makeColor(color);
|
|
3403
|
-
const st = loadStatuses(logDir).find((s) => s.id === wid) ?? {
|
|
3404
|
-
id: wid,
|
|
3405
|
-
label: "",
|
|
3406
|
-
cwd: "",
|
|
3407
|
-
provider: ""
|
|
3408
|
-
};
|
|
3409
|
-
const wrapWidth = typeof process.stdout.columns === "number" ? process.stdout.columns : null;
|
|
3410
|
-
const out = [headerLine(c, st)];
|
|
3411
|
-
const raw = readFileSync(path, "utf8");
|
|
3412
|
-
const lines = tailLines !== void 0 ? raw.split("\n").slice(-tailLines) : raw.split("\n");
|
|
3413
|
-
let state = initialFollowState("");
|
|
3414
|
-
for (const line of lines) {
|
|
3415
|
-
if (!line.trim()) continue;
|
|
3416
|
-
let e;
|
|
3417
|
-
try {
|
|
3418
|
-
e = JSON.parse(line);
|
|
3419
|
-
} catch {
|
|
3420
|
-
continue;
|
|
3421
|
-
}
|
|
3422
|
-
const step = applyEvent(c, state, e, st.cwd ?? "", void 0, void 0, wrapWidth);
|
|
3423
|
-
if (step.day) out.push(c("dim", `── ${step.day} ──`));
|
|
3424
|
-
out.push(...step.lines);
|
|
3425
|
-
state = step.state;
|
|
3426
|
-
}
|
|
3427
|
-
return out.join("\n");
|
|
3428
|
-
}
|
|
3429
|
-
/**
|
|
3430
|
-
* The follow exit decision for one worker: it is over once its result event
|
|
3431
|
-
* was rendered, or once its status left "running" while its pid is gone — a
|
|
3432
|
-
* worker killed without writing a result still ends. A status never read
|
|
3433
|
-
* (undefined) means "not over": follow keeps waiting for the first event.
|
|
3434
|
-
*/
|
|
3435
|
-
function workerEnded(resultRendered, state, pidAlive) {
|
|
3436
|
-
return resultRendered || state !== void 0 && state !== "running" && !pidAlive;
|
|
3437
|
-
}
|
|
3438
|
-
/**
|
|
3439
|
-
* One typed stdin line → say (worker running) or resume (worker finished):
|
|
3440
|
-
* the operator channel of a `follow <id>` pane. Trimmed; empty lines and a
|
|
3441
|
-
* missing target are ignored.
|
|
3442
|
-
*/
|
|
3443
|
-
function makeOperatorInput(d) {
|
|
3444
|
-
return (raw) => {
|
|
3445
|
-
const text = raw.trim();
|
|
3446
|
-
if (!text) return;
|
|
3447
|
-
const id = d.target();
|
|
3448
|
-
if (id === null) return;
|
|
3449
|
-
d.say(id, text).then(() => d.sent ? d.sent(id) : d.note(d.paint("dim", `» sent to ${id}`)), (e) => {
|
|
3450
|
-
if (d.workerKnown(id)) d.resume(text, id);
|
|
3451
|
-
else d.note(d.paint("red", `» ${e.message}`));
|
|
3452
|
-
});
|
|
3453
|
-
};
|
|
3454
|
-
}
|
|
3455
|
-
/**
|
|
3456
|
-
* Tail one worker (target) or the running workers of this scope, live.
|
|
3457
|
-
* Workers whose event log already exists are first replayed (last
|
|
3458
|
-
* BACKFILL_EVENTS events), then tailed. Auto-exit: with a target, wait
|
|
3459
|
-
* `autoExit` seconds after its end; without one, exit once no worker in
|
|
3460
|
-
* scope has run for that many seconds in a row, never within the first 30 s.
|
|
3461
|
-
*
|
|
3462
|
-
* With a target on a TTY the pane becomes a chat (chatui.ts): transcript in
|
|
3463
|
-
* a scroll region, fixed prompt and ticker rows, submitted lines said to the
|
|
3464
|
-
* worker (or resuming it after it finished) and echoed as `»` rows. A draft
|
|
3465
|
-
* in the prompt holds the auto-exit countdown. Non-TTY output keeps the
|
|
3466
|
-
* plain scrolling behaviour; FORCE_TTY=1 emits the chat layout over a pipe.
|
|
3467
|
-
*/
|
|
3468
|
-
async function followWorkers(logDir, target, showAll, autoExit, env = process.env, color = process.stdout.isTTY === true, io) {
|
|
3469
|
-
const c = makeColor(color);
|
|
3470
|
-
const out_ = io?.stdout ?? process.stdout;
|
|
3471
|
-
const in_ = io?.stdin ?? process.stdin;
|
|
3472
|
-
const tty = out_.isTTY === true || env.FORCE_TTY === "1";
|
|
3473
|
-
const term = env.ITERM_SESSION_ID ?? "";
|
|
3474
|
-
const scopeTab = target || showAll ? "" : currentTabKey(env);
|
|
3475
|
-
const handles = /* @__PURE__ */ new Map();
|
|
3476
|
-
const seenHeader = /* @__PURE__ */ new Set();
|
|
3477
|
-
const finished = /* @__PURE__ */ new Set();
|
|
3478
|
-
const states = /* @__PURE__ */ new Map();
|
|
3479
|
-
const started = Date.now();
|
|
3480
|
-
let idleSince = null;
|
|
3481
|
-
let aborted = false;
|
|
3482
|
-
let lastEventAt = Date.now();
|
|
3483
|
-
let meterStatus = null;
|
|
3484
|
-
const onInt = () => {
|
|
3485
|
-
aborted = true;
|
|
3486
|
-
};
|
|
3487
|
-
process.once("SIGINT", onInt);
|
|
3488
|
-
const chat = tty && target !== null;
|
|
3489
|
-
let rows = out_.rows ?? 24;
|
|
3490
|
-
const columns = () => typeof out_.columns === "number" ? out_.columns : null;
|
|
3491
|
-
let fill = 0;
|
|
3492
|
-
const regionRows = () => Math.max(1, rows - 2);
|
|
3493
|
-
/** Every line the pane shows goes through here: plain newline, or a row
|
|
3494
|
-
* inserted above the fixed prompt/ticker rows (chatui.chatInsertLine). */
|
|
3495
|
-
const out = (line) => {
|
|
3496
|
-
if (!chat) {
|
|
3497
|
-
out_.write(line + "\n");
|
|
3498
|
-
return;
|
|
3499
|
-
}
|
|
3500
|
-
const r = chatInsertLine(line, fill, regionRows());
|
|
3501
|
-
out_.write(r.seq);
|
|
3502
|
-
fill = r.fill;
|
|
3503
|
-
};
|
|
3504
|
-
const eraseLiveness = () => {
|
|
3505
|
-
if (chat) return;
|
|
3506
|
-
if (tty) out_.write("\r\x1B[K");
|
|
3507
|
-
};
|
|
3508
|
-
let ticker = initialFollowState();
|
|
3509
|
-
const writeLiveness = () => {
|
|
3510
|
-
if (!tty) return;
|
|
3511
|
-
const secs = Math.max(0, Math.floor((Date.now() - lastEventAt) / 1e3));
|
|
3512
|
-
const meter = meterStatus ? contextMeter(c, meterStatus) : null;
|
|
3513
|
-
const text = tickerText(secs, ticker.intent, ticker.tool, meter);
|
|
3514
|
-
if (chat) out_.write(chatTickerRow(text, rows));
|
|
3515
|
-
else {
|
|
3516
|
-
out_.write("\r\x1B[K");
|
|
3517
|
-
out_.write(text);
|
|
3518
|
-
}
|
|
3519
|
-
};
|
|
3520
|
-
const runningIds = () => loadStatuses(logDir).filter((s) => s.state === "running" && alive(s.pid) && (target !== null || showAll || (scopeTab ? workerInScope(s, term) : true))).map((s) => s.id);
|
|
3521
|
-
const emitEvent = (e, wid, st, multi) => {
|
|
3522
|
-
if (e.type === "operator" && chat && suppressMirror(String(e.text ?? ""))) return;
|
|
3523
|
-
if (!seenHeader.has(wid)) {
|
|
3524
|
-
eraseLiveness();
|
|
3525
|
-
out(headerLine(c, st));
|
|
3526
|
-
seenHeader.add(wid);
|
|
3527
|
-
}
|
|
3528
|
-
const step = applyEvent(c, states.get(wid) ?? initialFollowState(), e, st.cwd ?? "", multi ? c("cyan", wid.slice(-4)) : void 0, void 0, columns());
|
|
3529
|
-
if (step.day) out(c("dim", `── ${step.day} ──`));
|
|
3530
|
-
states.set(wid, step.state);
|
|
3531
|
-
if (wid === target) ticker = step.state;
|
|
3532
|
-
eraseLiveness();
|
|
3533
|
-
for (const ln of step.lines) out(ln);
|
|
3534
|
-
if (step.activity) lastEventAt = Date.now();
|
|
3535
|
-
if (e.type === "result") {
|
|
3536
|
-
meterStatus = st;
|
|
3537
|
-
finished.add(wid);
|
|
3538
|
-
} else if (wid === target || target === null) meterStatus = st;
|
|
3539
|
-
};
|
|
3540
|
-
const attachHandle = (wid, path, st, multi) => {
|
|
3541
|
-
const handle = {
|
|
3542
|
-
fd: openSync(path, "r"),
|
|
3543
|
-
buf: ""
|
|
3544
|
-
};
|
|
3545
|
-
try {
|
|
3546
|
-
const existing = readFileSync(path, "utf8");
|
|
3547
|
-
if (existing.trim()) for (const line of backfillLines(existing)) {
|
|
3548
|
-
let e;
|
|
3549
|
-
try {
|
|
3550
|
-
e = JSON.parse(line);
|
|
3551
|
-
} catch {
|
|
3552
|
-
continue;
|
|
3553
|
-
}
|
|
3554
|
-
emitEvent(e, wid, st, multi);
|
|
3555
|
-
}
|
|
3556
|
-
else if (!seenHeader.has(wid)) {
|
|
3557
|
-
out(headerLine(c, st));
|
|
3558
|
-
seenHeader.add(wid);
|
|
3559
|
-
}
|
|
3560
|
-
const sink = Buffer.alloc(65536);
|
|
3561
|
-
for (;;) {
|
|
3562
|
-
let n;
|
|
3563
|
-
try {
|
|
3564
|
-
n = readSync(handle.fd, sink, 0, sink.length, null);
|
|
3565
|
-
} catch {
|
|
3566
|
-
break;
|
|
3567
|
-
}
|
|
3568
|
-
if (n <= 0) break;
|
|
3569
|
-
}
|
|
3570
|
-
} catch {}
|
|
3571
|
-
return handle;
|
|
3572
|
-
};
|
|
3573
|
-
const noteLine = (s) => {
|
|
3574
|
-
eraseLiveness();
|
|
3575
|
-
out(s);
|
|
3576
|
-
};
|
|
3577
|
-
const spawnResume = io?.spawnResume ?? ((id, text) => spawn("pai", [
|
|
3578
|
-
"worker",
|
|
3579
|
-
"resume",
|
|
3580
|
-
id,
|
|
3581
|
-
text,
|
|
3582
|
-
"--print-id",
|
|
3583
|
-
"--no-pane"
|
|
3584
|
-
], { stdio: [
|
|
3585
|
-
"ignore",
|
|
3586
|
-
"pipe",
|
|
3587
|
-
"inherit"
|
|
3588
|
-
] }));
|
|
3589
|
-
const resumeTarget = (text, id) => {
|
|
3590
|
-
noteLine(c("dim", `» resuming ${id} …`));
|
|
3591
|
-
const child = spawnResume(id, text);
|
|
3592
|
-
let idOut = "";
|
|
3593
|
-
child.stdout?.on("data", (chunk) => {
|
|
3594
|
-
idOut += chunk.toString("utf8");
|
|
3595
|
-
});
|
|
3596
|
-
child.on("close", (rc) => {
|
|
3597
|
-
const newId = idOut.trim().split("\n").pop() ?? "";
|
|
3598
|
-
if (rc === 0 && /^\d{8}-\d{6}-\d+$/.test(newId)) {
|
|
3599
|
-
noteLine(c("dim", `» resumed as ${newId}`));
|
|
3600
|
-
target = newId;
|
|
3601
|
-
finished.delete(newId);
|
|
3602
|
-
seenHeader.delete(newId);
|
|
3603
|
-
states.delete(newId);
|
|
3604
|
-
ticker = initialFollowState("resumed");
|
|
3605
|
-
lastEventAt = Date.now();
|
|
3606
|
-
} else noteLine(c("red", `» resume failed (rc=${rc})`));
|
|
3607
|
-
});
|
|
3608
|
-
};
|
|
3609
|
-
const handleOperatorLine = makeOperatorInput({
|
|
3610
|
-
target: () => target,
|
|
3611
|
-
say: (id, text) => sayToWorker(logDir, id, text),
|
|
3612
|
-
workerKnown: (id) => loadStatuses(logDir).some((s) => s.id === id),
|
|
3613
|
-
resume: resumeTarget,
|
|
3614
|
-
note: noteLine,
|
|
3615
|
-
paint: c,
|
|
3616
|
-
...chat ? { sent: () => void 0 } : {}
|
|
3617
|
-
});
|
|
3618
|
-
const terminalIn = in_.isTTY === true;
|
|
3619
|
-
let hintUp = true;
|
|
3620
|
-
let rlIn = null;
|
|
3621
|
-
let onResize = null;
|
|
3622
|
-
const echoed = /* @__PURE__ */ new Map();
|
|
3623
|
-
const suppressMirror = (text) => {
|
|
3624
|
-
const g = echoed.get(text);
|
|
3625
|
-
if (!g || Date.now() > g.until) return false;
|
|
3626
|
-
g.n -= 1;
|
|
3627
|
-
if (g.n <= 0) echoed.delete(text);
|
|
3628
|
-
return true;
|
|
3629
|
-
};
|
|
3630
|
-
const drawPrompt = () => {
|
|
3631
|
-
if (!chat || terminalIn) return;
|
|
3632
|
-
out_.write(chatPromptRow(rows, CHAT_PROMPT, hintUp ? c("dim", CHAT_HINT) : void 0));
|
|
3633
|
-
};
|
|
3634
|
-
if (chat) {
|
|
3635
|
-
const echoOperator = (text) => {
|
|
3636
|
-
const id = target;
|
|
3637
|
-
if (id === null) return;
|
|
3638
|
-
echoed.set(text, {
|
|
3639
|
-
n: 1,
|
|
3640
|
-
until: Date.now() + 1e4
|
|
3641
|
-
});
|
|
3642
|
-
const step = applyEvent(c, states.get(id) ?? initialFollowState(), {
|
|
3643
|
-
type: "operator",
|
|
3644
|
-
_ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3645
|
-
text
|
|
3646
|
-
}, "", void 0, void 0, columns());
|
|
3647
|
-
if (step.day) out(c("dim", `── ${step.day} ──`));
|
|
3648
|
-
for (const ln of step.lines) out(ln);
|
|
3649
|
-
states.set(id, step.state);
|
|
3650
|
-
};
|
|
3651
|
-
const handleChatLine = (raw) => {
|
|
3652
|
-
hintUp = false;
|
|
3653
|
-
const act = parseChatLine(raw);
|
|
3654
|
-
switch (act.kind) {
|
|
3655
|
-
case "message":
|
|
3656
|
-
if (!act.text) break;
|
|
3657
|
-
echoOperator(act.text);
|
|
3658
|
-
handleOperatorLine(act.text);
|
|
3659
|
-
break;
|
|
3660
|
-
case "resume":
|
|
3661
|
-
if (!act.text) {
|
|
3662
|
-
out(c("dim", "usage: /resume <text>"));
|
|
3663
|
-
break;
|
|
3664
|
-
}
|
|
3665
|
-
echoOperator(act.text);
|
|
3666
|
-
if (target !== null) resumeTarget(act.text, target);
|
|
3667
|
-
break;
|
|
3668
|
-
case "help":
|
|
3669
|
-
for (const ln of CHAT_HELP) out(c("dim", ln));
|
|
3670
|
-
break;
|
|
3671
|
-
case "status": {
|
|
3672
|
-
const s = target !== null ? loadStatuses(logDir).find((x) => x.id === target) : void 0;
|
|
3673
|
-
out(c("dim", s ? `${s.id} · ${s.state} · ${s.last}` : `${target ?? "?"} · no status`));
|
|
3674
|
-
break;
|
|
3675
|
-
}
|
|
3676
|
-
case "quit":
|
|
3677
|
-
aborted = true;
|
|
3678
|
-
break;
|
|
3679
|
-
}
|
|
3680
|
-
drawPrompt();
|
|
3681
|
-
};
|
|
3682
|
-
rlIn = createInterface({
|
|
3683
|
-
input: in_,
|
|
3684
|
-
output: out_,
|
|
3685
|
-
terminal: terminalIn
|
|
3686
|
-
});
|
|
3687
|
-
rlIn.on("line", handleChatLine);
|
|
3688
|
-
rlIn.on("SIGINT", () => {
|
|
3689
|
-
if ((rlIn?.line ?? "").trim() === "") aborted = true;
|
|
3690
|
-
else rlIn?.write(null, {
|
|
3691
|
-
ctrl: true,
|
|
3692
|
-
name: "u"
|
|
3693
|
-
});
|
|
3694
|
-
});
|
|
3695
|
-
rlIn.on("close", () => {
|
|
3696
|
-
aborted = true;
|
|
3697
|
-
});
|
|
3698
|
-
out_.write(chatEnter(rows));
|
|
3699
|
-
if (terminalIn) {
|
|
3700
|
-
rlIn.setPrompt(CHAT_PROMPT);
|
|
3701
|
-
rlIn.prompt();
|
|
3702
|
-
out_.write(c("dim", CHAT_HINT));
|
|
3703
|
-
} else drawPrompt();
|
|
3704
|
-
onResize = () => {
|
|
3705
|
-
if (typeof out_.rows === "number") rows = out_.rows;
|
|
3706
|
-
fill = 0;
|
|
3707
|
-
out_.write(chatScrollRegion(rows));
|
|
3708
|
-
drawPrompt();
|
|
3709
|
-
};
|
|
3710
|
-
out_.on?.("resize", onResize);
|
|
3711
|
-
} else if (target !== null && in_.isTTY) {
|
|
3712
|
-
rlIn = createInterface({ input: in_ });
|
|
3713
|
-
rlIn.on("line", handleOperatorLine);
|
|
3714
|
-
}
|
|
3715
|
-
/** The prompt's unsent text — a draft holds the auto-exit countdown. */
|
|
3716
|
-
const promptText = () => io?.promptLine ? io.promptLine() : rlIn?.line ?? "";
|
|
3717
|
-
try {
|
|
3718
|
-
for (;;) {
|
|
3719
|
-
if (aborted) return;
|
|
3720
|
-
const statuses = new Map(loadStatuses(logDir).map((s) => [s.id, s]));
|
|
3721
|
-
const wanted = target ? [target] : runningIds();
|
|
3722
|
-
for (const wid of wanted) {
|
|
3723
|
-
if (handles.has(wid) || finished.has(wid)) continue;
|
|
3724
|
-
const path = eventsPath(logDir, wid);
|
|
3725
|
-
const st = statuses.get(wid) ?? {
|
|
3726
|
-
id: wid,
|
|
3727
|
-
label: "",
|
|
3728
|
-
cwd: "",
|
|
3729
|
-
provider: ""
|
|
3730
|
-
};
|
|
3731
|
-
if (existsSync(path)) {
|
|
3732
|
-
const multi = target === null || handles.size > 0;
|
|
3733
|
-
handles.set(wid, attachHandle(wid, path, st, multi));
|
|
3734
|
-
states.set(wid, states.get(wid) ?? initialFollowState());
|
|
3735
|
-
} else if (!seenHeader.has(wid)) {
|
|
3736
|
-
out(headerLine(c, st));
|
|
3737
|
-
seenHeader.add(wid);
|
|
3738
|
-
}
|
|
3739
|
-
}
|
|
3740
|
-
const multi = handles.size > 1 || target === null;
|
|
3741
|
-
let progressed = false;
|
|
3742
|
-
for (const [wid, h] of [...handles.entries()]) {
|
|
3743
|
-
const st = statuses.get(wid) ?? {
|
|
3744
|
-
id: wid,
|
|
3745
|
-
label: "",
|
|
3746
|
-
cwd: "",
|
|
3747
|
-
provider: ""
|
|
3748
|
-
};
|
|
3749
|
-
const buffer = Buffer.alloc(65536);
|
|
3750
|
-
for (;;) {
|
|
3751
|
-
let n;
|
|
3752
|
-
try {
|
|
3753
|
-
n = readSync(h.fd, buffer, 0, buffer.length, null);
|
|
3754
|
-
} catch {
|
|
3755
|
-
n = 0;
|
|
3756
|
-
}
|
|
3757
|
-
if (n <= 0) break;
|
|
3758
|
-
h.buf += buffer.toString("utf8", 0, n);
|
|
3759
|
-
}
|
|
3760
|
-
const lines = h.buf.split("\n");
|
|
3761
|
-
h.buf = lines.pop() ?? "";
|
|
3762
|
-
for (const line of lines) {
|
|
3763
|
-
if (!line.trim()) continue;
|
|
3764
|
-
progressed = true;
|
|
3765
|
-
let e;
|
|
3766
|
-
try {
|
|
3767
|
-
e = JSON.parse(line);
|
|
3768
|
-
} catch {
|
|
3769
|
-
continue;
|
|
3770
|
-
}
|
|
3771
|
-
emitEvent(e, wid, st, multi);
|
|
3772
|
-
}
|
|
3773
|
-
if (workerEnded(finished.has(wid), st.state, alive(st.pid))) {
|
|
3774
|
-
if (!finished.has(wid)) {
|
|
3775
|
-
eraseLiveness();
|
|
3776
|
-
out(`${multi ? c("cyan", wid.slice(-4)) + c("dim", " ┃ ") : " "}${c("red", "✗ " + (st.state || "ended"))} · ${st.last ?? ""}`);
|
|
3777
|
-
finished.add(wid);
|
|
3778
|
-
}
|
|
3779
|
-
closeSync(h.fd);
|
|
3780
|
-
handles.delete(wid);
|
|
3781
|
-
}
|
|
3782
|
-
}
|
|
3783
|
-
const lingerOn = target;
|
|
3784
|
-
if (lingerOn !== null && finished.has(lingerOn)) {
|
|
3785
|
-
if (autoExit) {
|
|
3786
|
-
if (holdAutoExit(promptText())) {
|
|
3787
|
-
writeLiveness();
|
|
3788
|
-
await sleep(250);
|
|
3789
|
-
continue;
|
|
3790
|
-
}
|
|
3791
|
-
const until = Date.now() + autoExit * 1e3;
|
|
3792
|
-
while (Date.now() < until && !aborted && target === lingerOn && !holdAutoExit(promptText())) {
|
|
3793
|
-
writeLiveness();
|
|
3794
|
-
await sleep(250);
|
|
3795
|
-
}
|
|
3796
|
-
if (aborted || target !== lingerOn || holdAutoExit(promptText())) continue;
|
|
3797
|
-
eraseLiveness();
|
|
3798
|
-
out(c("dim", "closing"));
|
|
3799
|
-
return;
|
|
3800
|
-
}
|
|
3801
|
-
if (rlIn === null) return;
|
|
3802
|
-
}
|
|
3803
|
-
if (autoExit && !target) {
|
|
3804
|
-
if (runningIds().length) idleSince = null;
|
|
3805
|
-
else if (idleSince === null) idleSince = Date.now();
|
|
3806
|
-
else if (Date.now() - started >= 3e4 && Date.now() - idleSince >= autoExit * 1e3) {
|
|
3807
|
-
eraseLiveness();
|
|
3808
|
-
out(c("dim", "closing"));
|
|
3809
|
-
return;
|
|
3810
|
-
}
|
|
3811
|
-
}
|
|
3812
|
-
if (!progressed) {
|
|
3813
|
-
writeLiveness();
|
|
3814
|
-
await sleep(500);
|
|
3815
|
-
}
|
|
3816
|
-
}
|
|
3817
|
-
} finally {
|
|
3818
|
-
rlIn?.close();
|
|
3819
|
-
process.removeListener("SIGINT", onInt);
|
|
3820
|
-
if (onResize) out_.removeListener?.("resize", onResize);
|
|
3821
|
-
if (chat) out_.write(chatLeave(rows));
|
|
3822
|
-
for (const h of handles.values()) try {
|
|
3823
|
-
closeSync(h.fd);
|
|
3824
|
-
} catch {}
|
|
3825
|
-
}
|
|
3826
|
-
}
|
|
3827
|
-
function sleep(ms) {
|
|
3828
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
3829
|
-
}
|
|
3830
|
-
/** Workers of this terminal for the status bar; "" when there are none. */
|
|
3831
|
-
function statusLineOutput(logDir, term, cwd, now = /* @__PURE__ */ new Date()) {
|
|
3832
|
-
return renderStatusLine(loadStatuses(logDir).filter((s) => {
|
|
3833
|
-
const sameScope = term && workerInScope(s, term);
|
|
3834
|
-
const sameDir = cwd && s.cwd.startsWith(cwd);
|
|
3835
|
-
return sameScope || !term && sameDir;
|
|
3836
|
-
}), now);
|
|
3837
|
-
}
|
|
3838
|
-
|
|
3839
|
-
//#endregion
|
|
3840
|
-
//#region src/workers/providers.ts
|
|
3841
|
-
/**
|
|
3842
|
-
* providers.ts — provider, class and switch management over the workers config.
|
|
3843
|
-
*
|
|
3844
|
-
* One layer under both `pai worker providers …` and the MCP worker_providers
|
|
3845
|
-
* tool. Every mutation re-reads the config file, changes only the workers
|
|
3846
|
-
* section, and writes it back atomically — the file is shared with everything
|
|
3847
|
-
* else PAI runs, so a torn write is not an option.
|
|
3848
|
-
*
|
|
3849
|
-
* Keys are never stored in the config and never accepted inline from the CLI:
|
|
3850
|
-
* only file paths. (The MCP `add` tool additionally accepts a raw `key`, which
|
|
3851
|
-
* it immediately parks in ~/.config/pai/keys/<name>, mode 0600, storing only
|
|
3852
|
-
* the path — a chat is not a place to leave a credential lying around.)
|
|
3853
|
-
*/
|
|
3854
|
-
function addProvider(input) {
|
|
3855
|
-
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 _`);
|
|
3856
|
-
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.");
|
|
3857
|
-
if (!input.baseUrl && input.protocol !== "openai") throw new WorkersConfigError("baseUrl is required");
|
|
3858
|
-
let keyFile = input.keyFile ?? null;
|
|
3859
|
-
if (input.key !== void 0) {
|
|
3860
|
-
const dir = keysDir();
|
|
3861
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
3862
|
-
const path = `${dir}/${input.name}`;
|
|
3863
|
-
writeFileSync(path, input.key.trim() + "\n", {
|
|
3864
|
-
encoding: "utf8",
|
|
3865
|
-
mode: 384
|
|
3866
|
-
});
|
|
3867
|
-
try {
|
|
3868
|
-
chmodSync(path, 384);
|
|
3869
|
-
} catch {}
|
|
3870
|
-
keyFile = path;
|
|
3871
|
-
}
|
|
3872
|
-
const { raw, workers } = readWorkersSection();
|
|
3873
|
-
const provider = {
|
|
3874
|
-
enabled: true,
|
|
3875
|
-
protocol: input.protocol ?? "anthropic",
|
|
3876
|
-
baseUrl: input.baseUrl,
|
|
3877
|
-
keyFile,
|
|
3878
|
-
models: input.fastModel ? {
|
|
3879
|
-
default: input.model,
|
|
3880
|
-
fast: input.fastModel
|
|
3881
|
-
} : { default: input.model },
|
|
3882
|
-
env: input.env ?? {},
|
|
3883
|
-
...input.note ? { note: input.note } : {},
|
|
3884
|
-
...input.upstreamUrl ? { upstreamUrl: input.upstreamUrl } : {},
|
|
3885
|
-
...input.engine && input.engine !== "claude" ? { engine: input.engine } : {},
|
|
3886
|
-
...input.quotaProbe ? { quotaProbe: input.quotaProbe } : {},
|
|
3887
|
-
...input.contextWindow ? { contextWindow: input.contextWindow } : {},
|
|
3888
|
-
...input.costTier ? { costTier: input.costTier } : {},
|
|
3889
|
-
...input.tags?.length ? { tags: input.tags } : {}
|
|
3890
|
-
};
|
|
3891
|
-
workers.providers[input.name] = provider;
|
|
3892
|
-
if (Object.keys(workers.providers).length === 1) {
|
|
3893
|
-
workers.enabled = true;
|
|
3894
|
-
workers.active = input.name;
|
|
3895
|
-
workers.logDir = workers.logDir || DEFAULT_LOG_DIR;
|
|
3896
|
-
const fast = input.fastModel ? `${input.name}/fast` : input.name;
|
|
3897
|
-
workers.classes = {
|
|
3898
|
-
draft: fast,
|
|
3899
|
-
plan: input.name,
|
|
3900
|
-
implement: input.name,
|
|
3901
|
-
review: input.name,
|
|
3902
|
-
research: input.name,
|
|
3903
|
-
spotcheck: fast,
|
|
3904
|
-
simple: fast,
|
|
3905
|
-
complex: input.name,
|
|
3906
|
-
image: input.name
|
|
3907
|
-
};
|
|
3908
|
-
}
|
|
3909
|
-
writeWorkersSection(raw, workers);
|
|
3910
|
-
return workers;
|
|
3911
|
-
}
|
|
3912
|
-
/** Change costTier / tags on an existing provider (MCP action "update"). */
|
|
3913
|
-
function updateProvider(name, changes) {
|
|
3914
|
-
const { raw, workers } = readWorkersSection();
|
|
3915
|
-
const p = workers.providers[name];
|
|
3916
|
-
if (!p) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
3917
|
-
if (changes.costTier !== void 0) {
|
|
3918
|
-
if (!Number.isInteger(changes.costTier) || changes.costTier < 1 || changes.costTier > 5) throw new WorkersConfigError("costTier must be an integer 1 (cheapest) … 5 (most expensive)");
|
|
3919
|
-
p.costTier = changes.costTier;
|
|
3920
|
-
}
|
|
3921
|
-
if (changes.tags !== void 0) {
|
|
3922
|
-
for (const t of changes.tags) if (!PROVIDER_TAGS.includes(t)) throw new WorkersConfigError(`"${t}" is not a tag (from: ${PROVIDER_TAGS.join(", ")})`);
|
|
3923
|
-
p.tags = changes.tags;
|
|
3924
|
-
}
|
|
3925
|
-
writeWorkersSection(raw, workers);
|
|
3926
|
-
return workers;
|
|
3927
|
-
}
|
|
3928
|
-
function removeProvider(name) {
|
|
3929
|
-
const { raw, workers } = readWorkersSection();
|
|
3930
|
-
if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}"`);
|
|
3931
|
-
delete workers.providers[name];
|
|
3932
|
-
for (const [cls, target] of Object.entries(workers.classes)) if ((typeof target === "string" ? target.split("/")[0] : target.provider) === name) delete workers.classes[cls];
|
|
3933
|
-
if (workers.active === name) workers.active = null;
|
|
3934
|
-
writeWorkersSection(raw, workers);
|
|
3935
|
-
return workers;
|
|
3936
|
-
}
|
|
3937
|
-
function useProvider(name) {
|
|
3938
|
-
const { raw, workers } = readWorkersSection();
|
|
3939
|
-
if (!workers.providers[name]) throw new WorkersConfigError(`no provider named "${name}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
3940
|
-
workers.active = name;
|
|
3941
|
-
writeWorkersSection(raw, workers);
|
|
3942
|
-
return workers;
|
|
3943
|
-
}
|
|
3944
|
-
function setProviderEnabled(name, enabled) {
|
|
3945
|
-
const { raw, workers } = readWorkersSection();
|
|
3946
|
-
const p = workers.providers[name];
|
|
3947
|
-
if (!p) throw new WorkersConfigError(`no provider named "${name}"`);
|
|
3948
|
-
p.enabled = enabled;
|
|
3949
|
-
writeWorkersSection(raw, workers);
|
|
3950
|
-
if (enabled) clearCooldown(workersLogDir(parseWorkersConfig(raw.workers)), name);
|
|
3951
|
-
return workers;
|
|
3952
|
-
}
|
|
3953
|
-
/**
|
|
3954
|
-
* Point a class at a target ("provider", "provider/fast", or an object with
|
|
3955
|
-
* provider + optional mcp/maxCostTier/requireTags/order). An object without
|
|
3956
|
-
* a provider only constrains auto-routing for that class.
|
|
3957
|
-
*/
|
|
3958
|
-
function setClass(name, target) {
|
|
3959
|
-
const { raw, workers } = readWorkersSection();
|
|
3960
|
-
if (typeof target === "string") {
|
|
3961
|
-
const [provider, alias] = target.split("/");
|
|
3962
|
-
const p = workers.providers[provider];
|
|
3963
|
-
if (!p) throw new WorkersConfigError(`no provider named "${provider}" in "${target}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
3964
|
-
if (alias && alias !== "default" && alias !== "fast") throw new WorkersConfigError(`unknown model alias "${alias}" — providers expose "default" and "fast"`);
|
|
3965
|
-
if (alias === "fast" && !p.models.fast) throw new WorkersConfigError(`provider "${provider}" has no fast model configured`);
|
|
3966
|
-
} else if (target.provider) {
|
|
3967
|
-
if (!workers.providers[target.provider]) throw new WorkersConfigError(`no provider named "${target.provider}". Configured: ${Object.keys(workers.providers).join(", ") || "(none)"}`);
|
|
3968
|
-
}
|
|
3969
|
-
workers.classes[name] = target;
|
|
3970
|
-
writeWorkersSection(raw, workers);
|
|
3971
|
-
return workers;
|
|
3972
|
-
}
|
|
3973
|
-
function unsetClass(name) {
|
|
3974
|
-
const { raw, workers } = readWorkersSection();
|
|
3975
|
-
if (!(name in workers.classes)) throw new WorkersConfigError(`no class named "${name}"`);
|
|
3976
|
-
delete workers.classes[name];
|
|
3977
|
-
writeWorkersSection(raw, workers);
|
|
3978
|
-
return workers;
|
|
3979
|
-
}
|
|
3980
|
-
function setWorkersEnabled(enabled) {
|
|
3981
|
-
const { raw, workers } = readWorkersSection();
|
|
3982
|
-
workers.enabled = enabled;
|
|
3983
|
-
writeWorkersSection(raw, workers);
|
|
3984
|
-
return workers;
|
|
3985
|
-
}
|
|
3986
|
-
/** `glm/fast` | `{provider, mcp, …}` → one printable target line. */
|
|
3987
|
-
function classTargetText(target) {
|
|
3988
|
-
if (typeof target === "string") return target;
|
|
3989
|
-
return [
|
|
3990
|
-
target.provider ?? "(routing)",
|
|
3991
|
-
...target.mcp?.length ? [`mcp(${target.mcp.join(",")})`] : [],
|
|
3992
|
-
...target.maxCostTier !== void 0 ? [`max tier ${target.maxCostTier}`] : [],
|
|
3993
|
-
...target.requireTags?.length ? [`needs ${target.requireTags.join(",")}`] : [],
|
|
3994
|
-
...target.order?.length ? [`order [${target.order.join(",")}]`] : []
|
|
3995
|
-
].join(" ");
|
|
3996
|
-
}
|
|
3997
|
-
/** Human-readable provider listing (quota probe included when configured). */
|
|
3998
|
-
function describeProviders(workers) {
|
|
3999
|
-
const lines = [];
|
|
4000
|
-
const names = Object.keys(workers.providers);
|
|
4001
|
-
if (!names.length) {
|
|
4002
|
-
lines.push(`no providers configured. Add one with:`);
|
|
4003
|
-
lines.push(` pai worker providers add <name> --base-url <url> --key-file <path> --model <model>`);
|
|
4004
|
-
return lines;
|
|
4005
|
-
}
|
|
4006
|
-
for (const name of names) {
|
|
4007
|
-
const p = workers.providers[name];
|
|
4008
|
-
const flags = [p.enabled ? "enabled" : "disabled", workers.active === name ? "active" : null].filter(Boolean);
|
|
4009
|
-
const quota = p.quotaProbe ? probeQuota(p) : null;
|
|
4010
|
-
const quotaNote = quota === null ? "" : ` quota ${quota}% (skip at ${quotaSkipThreshold(p)})`;
|
|
4011
|
-
const tierTags = [`tier ${providerCostTier(p)}`, ...p.tags ?? []].join(", ");
|
|
4012
|
-
lines.push(`${name} [${flags.join(", ")}] ${p.baseUrl}`);
|
|
4013
|
-
lines.push(` model ${p.models.default}${p.models.fast ? ` (fast: ${p.models.fast})` : ""}${quotaNote}`);
|
|
4014
|
-
lines.push(` ${tierTags}`);
|
|
4015
|
-
if (p.keyFile) lines.push(` key file ${expandHome(p.keyFile)}`);
|
|
4016
|
-
else lines.push(` no key file (token "local")`);
|
|
4017
|
-
if (p.note) lines.push(` ${p.note}`);
|
|
4018
|
-
if (p.protocol === "openai") lines.push(` via PAI proxy ← ${p.upstreamUrl ?? "(upstreamUrl missing)"}`);
|
|
4019
|
-
if (p.engine === "codex") lines.push(` engine codex (runs through the Codex CLI)`);
|
|
4020
|
-
}
|
|
4021
|
-
const setNames = Object.keys(workers.mcpSets);
|
|
4022
|
-
if (setNames.length) lines.push(`mcp sets: ${setNames.map((s) => `${s}=[${workers.mcpSets[s].join(",")}]`).join(" ")}`);
|
|
4023
|
-
const classNames = Object.keys(workers.classes);
|
|
4024
|
-
if (classNames.length) lines.push(`classes: ${classNames.map((cl) => `${cl}=${classTargetText(workers.classes[cl])}`).join(" ")}`);
|
|
4025
|
-
if (workers.active === "auto") lines.push(`routing: auto — order [${workers.routing.order.join(", ")}], cooldown ${workers.routing.cooldownMinutes}m`);
|
|
4026
|
-
return lines;
|
|
4027
|
-
}
|
|
4028
|
-
|
|
4029
|
-
//#endregion
|
|
4030
|
-
export { ledgerPath as A, checkPaneForWorker as C, ledgerSummary as D, loadStatus as E, readJsonStrict as F, writeJsonAtomic as I, PROVIDER_TAGS as M, WorkersConfigError as N, parseRunnerArgs as O, readWorkersSection as P, describeMcp as S, openPaneForWorker as T, testProvider as _, setClass as a, stopProxy as b, unsetClass as c, followWorkers as d, psOutput as f, runWorker as g, runChain as h, removeProvider as i, workersLogDir as j, eventsPath as k, updateProvider as l, statusLineOutput as m, classTargetText as n, setProviderEnabled as o, replayOutput as p, describeProviders as r, setWorkersEnabled as s, addProvider as t, useProvider as u, DEFAULT_PROXY_PORT as v, openFollowPane as w, sayToWorker as x, ensureProxyRunning as y };
|
|
4031
|
-
//# sourceMappingURL=providers-DUshcB-d.mjs.map
|
|
3324
|
+
export { contextPercent as A, workersLogDir as B, quotaSkipThreshold as C, workerInScope as D, sessionTag as E, ledgerSummary as F, keysDir as G, PROVIDER_TAGS as H, parseRunnerArgs as I, readWorkersSection as J, parseWorkersConfig as K, shortText as L, loadStatuses as M, newWorkerId as N, ageOf as O, appendLedger as P, eventsPath as R, probeQuota as S, resolveSession as T, WorkersConfigError as U, DEFAULT_LOG_DIR as V, expandHome as W, readJsonStrict as X, writeWorkersSection as Y, writeJsonAtomic as Z, workerDepth as _, testProvider as a, openPaneForWorker as b, stopProxy as c, renderReport as d, discardWorker as f, sayToWorker as g, readInbox as h, runWorker as i, loadStatus as j, alive as k, describeMcp as l, handoffFromInside as m, swapPromptArg as n, DEFAULT_PROXY_PORT as o, mergeWorker as p, providerCostTier as q, printResult as r, ensureProxyRunning as s, runChain as t, parseWorkerReport as u, checkPaneForWorker as v, currentTabKey as w, clearCooldown as x, openFollowPane as y, ledgerPath as z };
|
|
3325
|
+
//# sourceMappingURL=chain-CTtHligI.mjs.map
|