@tekmidian/pai 0.66.6 → 0.66.8
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/{aibroker-client-WCZi_tn-.mjs → aibroker-client-BfnVwGZl.mjs} +3 -3
- package/dist/{aibroker-client-WCZi_tn-.mjs.map → aibroker-client-BfnVwGZl.mjs.map} +1 -1
- package/dist/{aibroker-client-Ck3p3k_-.mjs → aibroker-client-C-O3Fflh.mjs} +1 -1
- package/dist/{auto-route-BnizyALK.mjs → auto-route-D8TBDNqb.mjs} +3 -3
- package/dist/{auto-route-BnizyALK.mjs.map → auto-route-D8TBDNqb.mjs.map} +1 -1
- package/dist/auto-route-DCxTKz19.mjs +7 -0
- package/dist/{chain-2_uoiEdD.mjs → chain-BL4khGgy.mjs} +4 -3
- package/dist/{chain-2_uoiEdD.mjs.map → chain-BL4khGgy.mjs.map} +1 -1
- package/dist/cli/index.mjs +20 -16
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/program.mjs +19 -15
- package/dist/{config-Ddc4DIPk.mjs → config-DjZELvoA.mjs} +12 -3
- package/dist/config-DjZELvoA.mjs.map +1 -0
- package/dist/{run-env-CQRnxNht.mjs → config-Dpe2JlbY.mjs} +5 -114
- package/dist/config-Dpe2JlbY.mjs.map +1 -0
- package/dist/{config-CnsDMETf.mjs → config-yomayAwe.mjs} +1 -1
- package/dist/{context-handover-cache-SFkWucuT.mjs → context-handover-cache-DFqxNh-H.mjs} +27 -13
- package/dist/context-handover-cache-DFqxNh-H.mjs.map +1 -0
- package/dist/daemon/index.mjs +20 -15
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/{daemon-vLtT3yaI.mjs → daemon-CsR1S_Qr.mjs} +762 -24
- package/dist/daemon-CsR1S_Qr.mjs.map +1 -0
- package/dist/daemon-WrncnGua.mjs +23 -0
- package/dist/daemon-mcp/index.mjs +15 -12
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/embedding-gate-DqbKvn56.mjs +378 -0
- package/dist/embedding-gate-DqbKvn56.mjs.map +1 -0
- package/dist/embedding-gate-SJMCPwxG.mjs +5 -0
- package/dist/{embeddings-DMVzpvRZ.mjs → embeddings-BUjZMD9F.mjs} +16 -2
- package/dist/{embeddings-DMVzpvRZ.mjs.map → embeddings-BUjZMD9F.mjs.map} +1 -1
- package/dist/embeddings-DrJ-ewfi.mjs +3 -0
- package/dist/{env-DiolswKQ.mjs → env-CPCNHz3i.mjs} +1 -1
- package/dist/{env-DiolswKQ.mjs.map → env-CPCNHz3i.mjs.map} +1 -1
- package/dist/factory-DjtXElOI.mjs +9 -0
- package/dist/{factory-CXFHwvcv.mjs → factory-yElzJc6f.mjs} +31 -9
- package/dist/factory-yElzJc6f.mjs.map +1 -0
- package/dist/{fallback-DNzd3jJc.mjs → fallback-2yWDDYiT.mjs} +5 -4
- package/dist/{fallback-DNzd3jJc.mjs.map → fallback-2yWDDYiT.mjs.map} +1 -1
- package/dist/{helpers-CZsi_49C.mjs → helpers-B9LHRJ8R.mjs} +1 -1
- package/dist/{helpers-CZsi_49C.mjs.map → helpers-B9LHRJ8R.mjs.map} +1 -1
- package/dist/hooks/block-sleep-poll.mjs +7 -2
- package/dist/hooks/block-sleep-poll.mjs.map +3 -3
- package/dist/hooks/context-compression-hook.mjs +2 -0
- package/dist/hooks/context-compression-hook.mjs.map +2 -2
- package/dist/hooks/load-core-context.mjs +5 -5
- package/dist/hooks/load-core-context.mjs.map +2 -2
- package/dist/hooks/load-project-context.mjs +2 -0
- package/dist/hooks/load-project-context.mjs.map +2 -2
- package/dist/hooks/post-compact-inject.mjs +2 -0
- package/dist/hooks/post-compact-inject.mjs.map +2 -2
- package/dist/hooks/route-agents-to-worker.mjs +2 -0
- package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
- package/dist/hooks/security-validator.mjs +2 -0
- package/dist/hooks/security-validator.mjs.map +2 -2
- package/dist/hooks/whisper-rules.mjs +2 -0
- package/dist/hooks/whisper-rules.mjs.map +2 -2
- package/dist/hooks/worker-guard.mjs +82 -5
- package/dist/hooks/worker-guard.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +2 -0
- package/dist/hooks/worker-proxy.mjs.map +2 -2
- package/dist/hooks/worker-status-line.mjs +7 -2
- package/dist/hooks/worker-status-line.mjs.map +3 -3
- package/dist/hooks/worker-supervision.mjs +2 -0
- package/dist/hooks/worker-supervision.mjs.map +2 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +7 -4
- package/dist/{ipc-client-DkxRay-o.mjs → ipc-client-DVZ_EGnu.mjs} +2 -2
- package/dist/{ipc-client-DkxRay-o.mjs.map → ipc-client-DVZ_EGnu.mjs.map} +1 -1
- package/dist/main-resolver-DjQsexge.mjs +8 -0
- package/dist/{main-resolver-SfGGg1DB.mjs → main-resolver-Dxvn4Mv8.mjs} +7 -6
- package/dist/{main-resolver-SfGGg1DB.mjs.map → main-resolver-Dxvn4Mv8.mjs.map} +1 -1
- package/dist/{pai-marker-DXVpFsYz.mjs → pai-marker-1B7iC7oq.mjs} +121 -3
- package/dist/pai-marker-1B7iC7oq.mjs.map +1 -0
- package/dist/{planner-C-gzg7ye.mjs → planner-CspP59ya.mjs} +13 -10
- package/dist/{planner-C-gzg7ye.mjs.map → planner-CspP59ya.mjs.map} +1 -1
- package/dist/{postgres-BxmyQHoA.mjs → postgres-CCAGq1qp.mjs} +86 -5
- package/dist/postgres-CCAGq1qp.mjs.map +1 -0
- package/dist/postgres-CzkEPfAm.mjs +7 -0
- package/dist/{program-CfG1T_ko.mjs → program-cWqZI0_L.mjs} +231 -125
- package/dist/program-cWqZI0_L.mjs.map +1 -0
- package/dist/{query-feedback-BPdapXKd.mjs → query-feedback-GPeVGvIa.mjs} +2 -2
- package/dist/{query-feedback-BPdapXKd.mjs.map → query-feedback-GPeVGvIa.mjs.map} +1 -1
- package/dist/{reranker-3lnggwgq.mjs → reranker-BTiOia8Y.mjs} +1 -1
- package/dist/{reranker-3lnggwgq.mjs.map → reranker-BTiOia8Y.mjs.map} +1 -1
- package/dist/{reranker-CZ2mP4cf.mjs → reranker-YwDT2n5e.mjs} +1 -1
- package/dist/{router-BaTbc9VX.mjs → router-Bk_ySIZT.mjs} +2 -2
- package/dist/{router-BaTbc9VX.mjs.map → router-Bk_ySIZT.mjs.map} +1 -1
- package/dist/router-DyiZNI6o.mjs +3 -0
- package/dist/{run-ChlIbDJ0.mjs → run-BZNdpUGQ.mjs} +226 -1797
- package/dist/run-BZNdpUGQ.mjs.map +1 -0
- package/dist/run-env-Cd0Son-Y.mjs +115 -0
- package/dist/run-env-Cd0Son-Y.mjs.map +1 -0
- package/dist/{pai-home-UncxWxlX.mjs → runtime-paths-DkZoxofH.mjs} +50 -2
- package/dist/runtime-paths-DkZoxofH.mjs.map +1 -0
- package/dist/{search-CNAGTiJP.mjs → search-cPDj-9fO.mjs} +2 -2
- package/dist/{search-CNAGTiJP.mjs.map → search-cPDj-9fO.mjs.map} +1 -1
- package/dist/{sources-CM2g-CLT.mjs → sources-CvvzNZad.mjs} +1 -1
- package/dist/{sources-CM2g-CLT.mjs.map → sources-CvvzNZad.mjs.map} +1 -1
- package/dist/{stop-words-DtxaTWU_.mjs → stop-words-LtkS-Pej.mjs} +1 -1
- package/dist/{stop-words-DtxaTWU_.mjs.map → stop-words-LtkS-Pej.mjs.map} +1 -1
- package/dist/worktree-CwMRTa0H.mjs +1578 -0
- package/dist/worktree-CwMRTa0H.mjs.map +1 -0
- package/dist/{zettelkasten-BRIaHzFm.mjs → zettelkasten-DSIe6wpF.mjs} +6 -5
- package/dist/zettelkasten-DSIe6wpF.mjs.map +1 -0
- package/dist/{zettelkasten-G0-ZTybr.mjs → zettelkasten-DtkAvA18.mjs} +5 -3
- package/docker/docker-compose.yml +4 -0
- package/docs/commands/README.md +6 -0
- package/docs/commands/memory.md +52 -0
- package/docs/commands/worker.md +22 -0
- package/docs/install-linux.md +1 -0
- package/docs/memory.md +69 -0
- package/package.json +1 -1
- package/src/hooks/ts/lib/sleep-poll-gate.test.ts +2 -2
- package/src/hooks/ts/lib/sleep-poll-gate.ts +4 -4
- package/src/hooks/ts/lib/worker-guard-longwait.test.ts +48 -0
- package/src/hooks/ts/lib/worker-guard.ts +50 -6
- package/src/hooks/ts/pre-tool-use/block-sleep-poll.test.ts +1 -1
- package/src/hooks/ts/session-start/load-core-context.ts +11 -8
- package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +34 -2
- package/dist/auto-route-D6fW1Q8z.mjs +0 -3
- package/dist/config-Ddc4DIPk.mjs.map +0 -1
- package/dist/context-handover-cache-SFkWucuT.mjs.map +0 -1
- package/dist/daemon-BHQoEjt0.mjs +0 -18
- package/dist/daemon-vLtT3yaI.mjs.map +0 -1
- package/dist/embeddings-D0WOQpYR.mjs +0 -3
- package/dist/factory-CXFHwvcv.mjs.map +0 -1
- package/dist/factory-CnKsU6zX.mjs +0 -8
- package/dist/main-resolver-DrHtx8iW.mjs +0 -7
- package/dist/pai-home-UncxWxlX.mjs.map +0 -1
- package/dist/pai-marker-DXVpFsYz.mjs.map +0 -1
- package/dist/postgres-B2BEBT-J.mjs +0 -5
- package/dist/postgres-BxmyQHoA.mjs.map +0 -1
- package/dist/program-CfG1T_ko.mjs.map +0 -1
- package/dist/router-BK-hFeQ6.mjs +0 -3
- package/dist/run-ChlIbDJ0.mjs.map +0 -1
- package/dist/run-env-CQRnxNht.mjs.map +0 -1
- package/dist/runtime-paths-Bw3Jk8qJ.mjs +0 -52
- package/dist/runtime-paths-Bw3Jk8qJ.mjs.map +0 -1
- package/dist/zettelkasten-BRIaHzFm.mjs.map +0 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { C as providerCostTier, D as resolveModelCapability, E as resolveCapability, O as resolveProviderKey, T as readWorkersSection, f as WORKER_CLASSES, h as classModelCapability, m as assertProviderRunnable, p as WorkersConfigError, t as ANTHROPIC_NATIVE, u as NATIVE_ANTHROPIC_MODELS, v as getProviderOrNative, w as providerKeyPath, y as isModelCapability } from "./config-Dpe2JlbY.mjs";
|
|
2
|
+
import { A as newWorkerId, B as shortText, D as isLive, G as panesDir, H as ensureNoMcpConfig, K as routingStatePath, L as appendSystemPromptArgs, N as parseSleepSecs, O as loadStatus, P as saveStatus, U as eventsPath, V as stripPromptValues, W as ledgerPath, _ as appendLedger, c as recordWorktree, d as worktreeSystemPrompt, f as worktreeWanted, i as gcWorktreesThrottled, j as nowStamp, k as loadStatuses, l as salvageOnExit, o as inPlaceSystemPrompt, q as workersLogDir, t as addWorktree, w as describeTool, y as UNLABELED, z as parseRunnerArgs } from "./worktree-CwMRTa0H.mjs";
|
|
3
|
+
import { i as getRegistryBackend } from "./factory-yElzJc6f.mjs";
|
|
4
|
+
import { n as claudeCommand, t as buildRunEnv } from "./run-env-Cd0Son-Y.mjs";
|
|
5
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
7
9
|
import { fileURLToPath } from "node:url";
|
|
8
10
|
import { connect, createServer } from "node:net";
|
|
9
|
-
import {
|
|
11
|
+
import { createServer as createServer$1 } from "node:http";
|
|
10
12
|
import { createInterface } from "node:readline";
|
|
11
13
|
|
|
12
14
|
//#region src/workers/mcp.ts
|
|
@@ -224,59 +226,6 @@ function pinNotices(launch, claudeJson) {
|
|
|
224
226
|
return lines;
|
|
225
227
|
}
|
|
226
228
|
|
|
227
|
-
//#endregion
|
|
228
|
-
//#region src/workers/paths.ts
|
|
229
|
-
/**
|
|
230
|
-
* paths.ts — where worker run artefacts live.
|
|
231
|
-
*
|
|
232
|
-
* Everything a run writes (event mirror, status file, pane registry, routing
|
|
233
|
-
* state, the empty MCP config) sits under one logDir so the whole tree is
|
|
234
|
-
* disposable and configurable: `workers.logDir`, default ~/.claude/logs/workers.
|
|
235
|
-
*/
|
|
236
|
-
/**
|
|
237
|
-
* Absolute logDir for the given config. An explicit `logDir` in workers.yaml
|
|
238
|
-
* is honored verbatim (the user chose it). Otherwise this resolves like every
|
|
239
|
-
* other PAI_HOME file: PAI_HOME/logs/workers if it exists, else the old
|
|
240
|
-
* ~/.claude/logs/workers (with a one-time notice), else the new path.
|
|
241
|
-
*/
|
|
242
|
-
function workersLogDir(config) {
|
|
243
|
-
const expanded = expandHome(config.logDir);
|
|
244
|
-
if (config.logDir !== DEFAULT_LOG_DIR) return expanded;
|
|
245
|
-
return resolvePaiFile(paiHomePath("logs", "workers"), [expanded], "pai config migrate --logs");
|
|
246
|
-
}
|
|
247
|
-
/** The strict empty MCP config for headless runs, written on demand. */
|
|
248
|
-
function ensureNoMcpConfig(logDir) {
|
|
249
|
-
const path = noMcpConfigPath(logDir);
|
|
250
|
-
if (!existsSync(path)) {
|
|
251
|
-
mkdirSync(logDir, { recursive: true });
|
|
252
|
-
writeFileSync(path, "{ \"mcpServers\": {} }\n", "utf8");
|
|
253
|
-
}
|
|
254
|
-
return path;
|
|
255
|
-
}
|
|
256
|
-
function statusPath(logDir, id) {
|
|
257
|
-
return join(logDir, `${id}.status`);
|
|
258
|
-
}
|
|
259
|
-
function eventsPath(logDir, id) {
|
|
260
|
-
return join(logDir, `${id}.jsonl`);
|
|
261
|
-
}
|
|
262
|
-
function ledgerPath(logDir) {
|
|
263
|
-
return join(logDir, "ledger.log");
|
|
264
|
-
}
|
|
265
|
-
function routingStatePath(logDir) {
|
|
266
|
-
return join(logDir, "routing-state.json");
|
|
267
|
-
}
|
|
268
|
-
function panesDir(logDir) {
|
|
269
|
-
return join(logDir, "panes");
|
|
270
|
-
}
|
|
271
|
-
/**
|
|
272
|
-
* The strict empty MCP config handed to headless workers. Written into the
|
|
273
|
-
* logDir on demand (never into the user's vendor config directory — this is
|
|
274
|
-
* PAI state, not vendor state).
|
|
275
|
-
*/
|
|
276
|
-
function noMcpConfigPath(logDir) {
|
|
277
|
-
return join(logDir, "no-mcp.json");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
229
|
//#endregion
|
|
281
230
|
//#region src/workers/routing.ts
|
|
282
231
|
/**
|
|
@@ -617,1522 +566,6 @@ function stopProxy(logDir) {
|
|
|
617
566
|
return `proxy pid ${pid} stopped`;
|
|
618
567
|
}
|
|
619
568
|
|
|
620
|
-
//#endregion
|
|
621
|
-
//#region src/workers/args.ts
|
|
622
|
-
function parseRunnerArgs(argv) {
|
|
623
|
-
let prompt = null;
|
|
624
|
-
let outputFormat = "text";
|
|
625
|
-
const rest = [];
|
|
626
|
-
let headless = false;
|
|
627
|
-
let callerModel = false;
|
|
628
|
-
let callerMcpConfig = false;
|
|
629
|
-
let callerSystemPrompt = false;
|
|
630
|
-
const callerSystemPrompts = [];
|
|
631
|
-
let callerTools = false;
|
|
632
|
-
const mcp = [];
|
|
633
|
-
const allowedTools = [];
|
|
634
|
-
let i = 0;
|
|
635
|
-
while (i < argv.length) {
|
|
636
|
-
const a = argv[i];
|
|
637
|
-
if (a === "-p" || a === "--print") {
|
|
638
|
-
headless = true;
|
|
639
|
-
rest.push(a);
|
|
640
|
-
if (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
|
|
641
|
-
prompt = argv[i + 1];
|
|
642
|
-
rest.push(prompt);
|
|
643
|
-
i += 1;
|
|
644
|
-
}
|
|
645
|
-
} else if (a === "--output-format") {
|
|
646
|
-
const v = argv[i + 1];
|
|
647
|
-
if (v === "json" || v === "stream-json") outputFormat = v;
|
|
648
|
-
i += 1;
|
|
649
|
-
} else if (a.startsWith("--output-format=")) {
|
|
650
|
-
const v = a.slice(16);
|
|
651
|
-
if (v === "json" || v === "stream-json") outputFormat = v;
|
|
652
|
-
} else if (a === "--verbose") {} else if (a === "--append-system-prompt") {
|
|
653
|
-
callerSystemPrompt = true;
|
|
654
|
-
if (i + 1 < argv.length) callerSystemPrompts.push(argv[++i]);
|
|
655
|
-
} else if (a.startsWith("--append-system-prompt=")) {
|
|
656
|
-
callerSystemPrompt = true;
|
|
657
|
-
callerSystemPrompts.push(a.slice(23));
|
|
658
|
-
} else if (a === "--mcp") {
|
|
659
|
-
const v = argv[i + 1];
|
|
660
|
-
if (v !== void 0 && !v.startsWith("-")) {
|
|
661
|
-
mcp.push(v);
|
|
662
|
-
i += 1;
|
|
663
|
-
}
|
|
664
|
-
} else if (a.startsWith("--mcp=")) mcp.push(a.slice(6));
|
|
665
|
-
else if (a === "--allowedTools") {
|
|
666
|
-
rest.push(a);
|
|
667
|
-
const v = argv[i + 1];
|
|
668
|
-
if (v !== void 0 && !v.startsWith("-")) {
|
|
669
|
-
allowedTools.push(v);
|
|
670
|
-
rest.push(v);
|
|
671
|
-
i += 1;
|
|
672
|
-
}
|
|
673
|
-
} else if (a.startsWith("--allowedTools=")) {
|
|
674
|
-
allowedTools.push(a.slice(15));
|
|
675
|
-
rest.push(a);
|
|
676
|
-
} else {
|
|
677
|
-
if (a === "--model") callerModel = true;
|
|
678
|
-
if (a.startsWith("--model=")) callerModel = true;
|
|
679
|
-
if (a === "--mcp-config") callerMcpConfig = true;
|
|
680
|
-
if (a.startsWith("--mcp-config=")) callerMcpConfig = true;
|
|
681
|
-
if (a === "--tools") callerTools = true;
|
|
682
|
-
if (a.startsWith("--tools=")) callerTools = true;
|
|
683
|
-
if (prompt === null && !a.startsWith("-") && rest.length > 0 && (rest[rest.length - 1] === "-p" || rest[rest.length - 1] === "--print")) prompt = a;
|
|
684
|
-
rest.push(a);
|
|
685
|
-
}
|
|
686
|
-
i += 1;
|
|
687
|
-
}
|
|
688
|
-
return {
|
|
689
|
-
prompt,
|
|
690
|
-
outputFormat,
|
|
691
|
-
rest,
|
|
692
|
-
headless,
|
|
693
|
-
callerModel,
|
|
694
|
-
callerMcpConfig,
|
|
695
|
-
callerSystemPrompt,
|
|
696
|
-
callerSystemPrompts,
|
|
697
|
-
callerTools,
|
|
698
|
-
mcp,
|
|
699
|
-
allowedTools
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
/**
|
|
703
|
-
* The one --append-system-prompt flag for a spawn: claude keeps only the LAST
|
|
704
|
-
* occurrence, so every piece is joined (blank line between) into a single flag.
|
|
705
|
-
* Empty parts are dropped; no parts, no flag.
|
|
706
|
-
*/
|
|
707
|
-
function appendSystemPromptArgs(parts) {
|
|
708
|
-
const text = parts.filter((p) => p.trim()).join("\n\n");
|
|
709
|
-
return text ? ["--append-system-prompt", text] : [];
|
|
710
|
-
}
|
|
711
|
-
/**
|
|
712
|
-
* Turn `-p "<prompt>"` into bare `-p` (same for --print): for stream-json
|
|
713
|
-
* stdin runs the prompt moves to the first user message on stdin, so the
|
|
714
|
-
* value must not stay on the command line. Only prompt values directly
|
|
715
|
-
* following the flag are touched; everything else passes through.
|
|
716
|
-
*/
|
|
717
|
-
function stripPromptValues(argv) {
|
|
718
|
-
const out = [];
|
|
719
|
-
for (let i = 0; i < argv.length; i++) {
|
|
720
|
-
const a = argv[i];
|
|
721
|
-
const isPrint = a === "-p" || a === "--print";
|
|
722
|
-
out.push(a);
|
|
723
|
-
if (isPrint && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i += 1;
|
|
724
|
-
}
|
|
725
|
-
return out;
|
|
726
|
-
}
|
|
727
|
-
/**
|
|
728
|
-
* A one-line stderr hint (never blocking) for an inline -p prompt long or
|
|
729
|
-
* multi-line enough to be a shell-quoting risk — write it to a file and pass
|
|
730
|
-
* --spec instead. null when the prompt is short/simple enough to be fine.
|
|
731
|
-
*/
|
|
732
|
-
function longInlinePromptHint(prompt) {
|
|
733
|
-
if (!prompt) return null;
|
|
734
|
-
const newlines = (prompt.match(/\n/g) ?? []).length;
|
|
735
|
-
if (prompt.length > 600 || newlines > 3) return "hint: long inline prompts break on shell quoting — write the spec to a file and use --spec <file>";
|
|
736
|
-
return null;
|
|
737
|
-
}
|
|
738
|
-
/** Collapse whitespace and cut to n chars with an ellipsis (label rendering). */
|
|
739
|
-
function shortText(s, n) {
|
|
740
|
-
const t = String(s ?? "").split(/\s+/).filter(Boolean).join(" ");
|
|
741
|
-
return t.length <= n ? t : t.slice(0, n - 1) + "…";
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
//#endregion
|
|
745
|
-
//#region src/workers/status.ts
|
|
746
|
-
/**
|
|
747
|
-
* status.ts — the live per-worker status file.
|
|
748
|
-
*
|
|
749
|
-
* One JSON file per run, rewritten atomically on every turn, read by `ps`,
|
|
750
|
-
* `follow`, the status line and the pane logic. Same field set the Python
|
|
751
|
-
* runner wrote, plus `provider` (routing is multi-provider now) and `session`
|
|
752
|
-
* (the AIBroker identity of the launching session, see scope.ts).
|
|
753
|
-
*/
|
|
754
|
-
/** Label a worker gets when launched with neither --label nor a prompt. */
|
|
755
|
-
const UNLABELED = "unlabeled";
|
|
756
|
-
/**
|
|
757
|
-
* Whether a status is the terminal's interactive chat pane, not a spawned
|
|
758
|
-
* task worker. `origin` is the real discriminator; the fallback catches
|
|
759
|
-
* running entries from before the flag existed (unlabeled, no turns yet).
|
|
760
|
-
* Removable once every pane runs code that writes `origin`.
|
|
761
|
-
*/
|
|
762
|
-
function isChatPane(s) {
|
|
763
|
-
return s.origin === "chat" || !s.origin && (s.label === UNLABELED || s.label === "(no prompt)") && s.turns === 0;
|
|
764
|
-
}
|
|
765
|
-
/** Context-meter percentage 0–100, null when the numbers are missing. */
|
|
766
|
-
function contextPercent(s) {
|
|
767
|
-
if (!s.contextTokens || !s.contextWindow) return null;
|
|
768
|
-
return Math.round(s.contextTokens / s.contextWindow * 100);
|
|
769
|
-
}
|
|
770
|
-
/** Compact token count: 84k, 200k, 900. */
|
|
771
|
-
function fmtContextK(n) {
|
|
772
|
-
return n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
773
|
-
}
|
|
774
|
-
/**
|
|
775
|
-
* The one context-meter label, shared by every renderer so identical
|
|
776
|
-
* numbers always render identically: `ctx 84k/200k (42%)` — used-style
|
|
777
|
-
* percent. Empty when the numbers are missing (callers drop the part).
|
|
778
|
-
*/
|
|
779
|
-
function contextLabel(s) {
|
|
780
|
-
const pct = contextPercent(s);
|
|
781
|
-
if (pct === null) return "";
|
|
782
|
-
return `ctx ${fmtContextK(s.contextTokens ?? 0)}/${fmtContextK(s.contextWindow ?? 0)} (${pct}%)`;
|
|
783
|
-
}
|
|
784
|
-
/** Timestamp format shared by status files and the ledger. */
|
|
785
|
-
function nowStamp(d = /* @__PURE__ */ new Date()) {
|
|
786
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
787
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
788
|
-
}
|
|
789
|
-
let lastId = "";
|
|
790
|
-
let idSeq = 0;
|
|
791
|
-
function newWorkerId(d = /* @__PURE__ */ new Date(), pid = process.pid) {
|
|
792
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
793
|
-
const base = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}-${pid}`;
|
|
794
|
-
if (base === lastId) {
|
|
795
|
-
idSeq += 1;
|
|
796
|
-
return `${base}-${String(idSeq).padStart(2, "0")}`;
|
|
797
|
-
}
|
|
798
|
-
lastId = base;
|
|
799
|
-
idSeq = 0;
|
|
800
|
-
return base;
|
|
801
|
-
}
|
|
802
|
-
let tmpSeq = 0;
|
|
803
|
-
/** A temp path only this call owns — never the same string twice. */
|
|
804
|
-
function statusTmpPath(logDir, id) {
|
|
805
|
-
return `${statusPath(logDir, id)}.${process.pid}.${tmpSeq++}.tmp`;
|
|
806
|
-
}
|
|
807
|
-
const firstWrittenLabel = /* @__PURE__ */ new Map();
|
|
808
|
-
/** Write status atomically (temp + rename) and stamp `updated`. */
|
|
809
|
-
function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
|
|
810
|
-
status.updated = nowStamp(d);
|
|
811
|
-
const path = statusPath(logDir, status.id);
|
|
812
|
-
const baseline = firstWrittenLabel.get(status.id);
|
|
813
|
-
if (baseline === void 0) firstWrittenLabel.set(status.id, status.label);
|
|
814
|
-
const onDisk = baseline === void 0 ? void 0 : loadStatus(logDir, status.id);
|
|
815
|
-
if (onDisk) {
|
|
816
|
-
if (status.label === baseline && onDisk.label !== status.label) status.label = onDisk.label;
|
|
817
|
-
if (onDisk.controlsHeld) status.controlsHeld = true;
|
|
818
|
-
}
|
|
819
|
-
const tmp = statusTmpPath(logDir, status.id);
|
|
820
|
-
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
821
|
-
try {
|
|
822
|
-
writeFileSync(tmp, JSON.stringify(status), "utf8");
|
|
823
|
-
renameSync(tmp, path);
|
|
824
|
-
} catch (e) {
|
|
825
|
-
try {
|
|
826
|
-
rmSync(tmp, { force: true });
|
|
827
|
-
} catch {}
|
|
828
|
-
throw e;
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* Set a worker's goal (the operator's `--label`), atomically, through the
|
|
833
|
-
* same saveStatus path everything else writes with. Used by `pai worker
|
|
834
|
-
* goal` and the `--goal` option of `say`: a fresh call in a fresh process, so
|
|
835
|
-
* it always carries no baseline yet and always wins over the worker's own
|
|
836
|
-
* next periodic write (see saveStatus above).
|
|
837
|
-
*/
|
|
838
|
-
function setWorkerLabel(logDir, id, label) {
|
|
839
|
-
const status = loadStatus(logDir, id);
|
|
840
|
-
if (!status) throw new Error(`no worker named "${id}"`);
|
|
841
|
-
status.label = label;
|
|
842
|
-
saveStatus(logDir, status);
|
|
843
|
-
return status;
|
|
844
|
-
}
|
|
845
|
-
/** Load every status file in the logDir, oldest id first, skipping damage. */
|
|
846
|
-
function loadStatuses(logDir) {
|
|
847
|
-
if (!existsSync(logDir)) return [];
|
|
848
|
-
const out = [];
|
|
849
|
-
for (const name of readdirSync(logDir).sort()) {
|
|
850
|
-
if (!name.endsWith(".status")) continue;
|
|
851
|
-
try {
|
|
852
|
-
out.push(JSON.parse(readFileSync(join(logDir, name), "utf8")));
|
|
853
|
-
} catch {}
|
|
854
|
-
}
|
|
855
|
-
return out;
|
|
856
|
-
}
|
|
857
|
-
function loadStatus(logDir, id) {
|
|
858
|
-
const path = statusPath(logDir, id);
|
|
859
|
-
if (!existsSync(path)) return null;
|
|
860
|
-
try {
|
|
861
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
862
|
-
} catch {
|
|
863
|
-
return null;
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
/**
|
|
867
|
-
* Wait for a worker to record its own terminal state (the run's signal
|
|
868
|
-
* handler writes killed/rc/secs). Returns that status, or null when it is
|
|
869
|
-
* still "running" after `timeoutMs` — the caller then writes the state itself.
|
|
870
|
-
*/
|
|
871
|
-
async function waitForTerminalStatus(logDir, id, timeoutMs = 2e3, stepMs = 50) {
|
|
872
|
-
const deadline = Date.now() + timeoutMs;
|
|
873
|
-
for (;;) {
|
|
874
|
-
const s = loadStatus(logDir, id);
|
|
875
|
-
if (s && s.state !== "running") return s;
|
|
876
|
-
if (Date.now() >= deadline) return null;
|
|
877
|
-
await new Promise((r) => setTimeout(r, stepMs));
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
function alive(pid) {
|
|
881
|
-
if (!pid || pid <= 0) return false;
|
|
882
|
-
try {
|
|
883
|
-
process.kill(pid, 0);
|
|
884
|
-
return true;
|
|
885
|
-
} catch {
|
|
886
|
-
return false;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Whether `status.pid` still names the process that was started as this
|
|
891
|
-
* worker, not a later, unrelated process the OS handed the same pid to after
|
|
892
|
-
* the worker exited. `alive()` alone can't tell these apart — a pid observed
|
|
893
|
-
* live on 2026-09-19 (worker 20260919-083549-90913) had been reused, and
|
|
894
|
-
* `pai worker kill` signalled the wrong, unrelated process. `ps -o lstart=`
|
|
895
|
-
* reads the running process's actual start time and compares it against the
|
|
896
|
-
* status file's own `started` stamp; a reused pid almost never starts within
|
|
897
|
-
* 120s of the original, so a wider drift means "different process".
|
|
898
|
-
*/
|
|
899
|
-
function ownsPid(status) {
|
|
900
|
-
if (status.pid <= 0 || !alive(status.pid)) return false;
|
|
901
|
-
try {
|
|
902
|
-
const out = execFileSync("ps", [
|
|
903
|
-
"-o",
|
|
904
|
-
"lstart=",
|
|
905
|
-
"-p",
|
|
906
|
-
String(status.pid)
|
|
907
|
-
], {
|
|
908
|
-
encoding: "utf8",
|
|
909
|
-
env: {
|
|
910
|
-
...process.env,
|
|
911
|
-
LC_ALL: "C"
|
|
912
|
-
}
|
|
913
|
-
}).trim();
|
|
914
|
-
const psStart = Date.parse(out);
|
|
915
|
-
const started = Date.parse(status.started.replace(" ", "T"));
|
|
916
|
-
if (Number.isNaN(psStart) || Number.isNaN(started)) return alive(status.pid);
|
|
917
|
-
return Math.abs(psStart - started) <= 12e4;
|
|
918
|
-
} catch {
|
|
919
|
-
return alive(status.pid);
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
/** Whether a status is both marked "running" and still owns its recorded pid. */
|
|
923
|
-
function isLive(status) {
|
|
924
|
-
return status.state === "running" && ownsPid(status);
|
|
925
|
-
}
|
|
926
|
-
/** "42s" under 90s, "7m" above — the coarse age the table and bar show. */
|
|
927
|
-
function ageOf(ts, now = /* @__PURE__ */ new Date()) {
|
|
928
|
-
const t = Date.parse(ts.replace(" ", "T"));
|
|
929
|
-
if (Number.isNaN(t)) return "?";
|
|
930
|
-
const s = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
|
|
931
|
-
return s < 90 ? `${s}s` : `${Math.floor(s / 60)}m`;
|
|
932
|
-
}
|
|
933
|
-
/** Precise elapsed time: "45s", "1m 23s", "12m 05s", "1h 02m 05s" — seconds zero-padded after a larger unit. */
|
|
934
|
-
function elapsedOf(ts, now = /* @__PURE__ */ new Date()) {
|
|
935
|
-
const t = Date.parse(ts.replace(" ", "T"));
|
|
936
|
-
if (Number.isNaN(t)) return "?";
|
|
937
|
-
const secs = Math.max(0, Math.floor((now.getTime() - t) / 1e3));
|
|
938
|
-
const hours = Math.floor(secs / 3600);
|
|
939
|
-
const mins = Math.floor(secs % 3600 / 60);
|
|
940
|
-
const sec = secs % 60;
|
|
941
|
-
if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m ${String(sec).padStart(2, "0")}s`;
|
|
942
|
-
if (mins > 0) return `${mins}m ${String(sec).padStart(2, "0")}s`;
|
|
943
|
-
return `${sec}s`;
|
|
944
|
-
}
|
|
945
|
-
/** Shorter sleeps are legitimate polling — not worth a supervision event. */
|
|
946
|
-
const SLEEP_FLOOR_SECS = 60;
|
|
947
|
-
const SLEEP_RE = /^\s*sleep\s+([0-9]+(?:\.[0-9]+)?)([smhd]?)\s*(?:$|[;&|])/;
|
|
948
|
-
/** s/m/h/d suffix to seconds multiplier, shared by every `sleep N<unit>` parse below. */
|
|
949
|
-
function sleepUnitMultiplier(unit) {
|
|
950
|
-
return unit === "m" ? 60 : unit === "h" ? 3600 : unit === "d" ? 86400 : 1;
|
|
951
|
-
}
|
|
952
|
-
/**
|
|
953
|
-
* Seconds of a `sleep N` (N plain or with an s/m/h/d suffix) that stands alone
|
|
954
|
-
* or leads a compound command (`sleep 150; cat f`, `sleep 2m && tail f`,
|
|
955
|
-
* `sleep 90 || true`, `sleep 60 &`), or null when no long sleep leads: anchored
|
|
956
|
-
* to the start of the command, so a filename containing "sleep" or an
|
|
957
|
-
* `echo sleep 500` never trips it, and floored at SLEEP_FLOOR_SECS so ordinary
|
|
958
|
-
* polling stays invisible.
|
|
959
|
-
*/
|
|
960
|
-
function parseSleepSecs(command) {
|
|
961
|
-
const m = command.match(SLEEP_RE);
|
|
962
|
-
if (!m) return null;
|
|
963
|
-
const secs = Number(m[1]) * sleepUnitMultiplier(m[2]);
|
|
964
|
-
return Number.isFinite(secs) && secs >= SLEEP_FLOOR_SECS ? secs : null;
|
|
965
|
-
}
|
|
966
|
-
/** One-line description of a tool call, e.g. "Bash: npm test". */
|
|
967
|
-
function describeTool(name, inp) {
|
|
968
|
-
if (typeof inp !== "object" || inp === null) return name;
|
|
969
|
-
const i = inp;
|
|
970
|
-
const get = (k) => typeof i[k] === "string" ? i[k] : "";
|
|
971
|
-
if (name === "Bash") return `Bash: ${shortText(get("command"), 70)}`;
|
|
972
|
-
if (name === "Read" || name === "Edit" || name === "Write" || name === "MultiEdit") return `${name}: ${get("file_path").split("/").pop() ?? ""}`;
|
|
973
|
-
if (name === "Grep" || name === "Glob") return `${name}: ${shortText(get("pattern"), 50)}`;
|
|
974
|
-
if (name === "WebSearch") return `${name}: ${shortText(get("query"), 50)}`;
|
|
975
|
-
if (name === "WebFetch") return `${name}: ${shortText(get("url"), 60)}`;
|
|
976
|
-
return name;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
//#endregion
|
|
980
|
-
//#region src/workers/ledger.ts
|
|
981
|
-
/**
|
|
982
|
-
* ledger.ts — the routing ledger, one append-only log for every worker event.
|
|
983
|
-
*
|
|
984
|
-
* Line shapes (whitespace-aligned so `tail` reads as a table):
|
|
985
|
-
*
|
|
986
|
-
* 2026-09-17 12:00:00 WORKER-START id=<id> provider=<p> mode=headless model=<m> cwd=<cwd> label=<label>
|
|
987
|
-
* 2026-09-17 12:01:00 WORKER-END id=<id> provider=<p> mode=headless model=<m> rc=0 secs=60 turns=3 tools=8 label=<label>
|
|
988
|
-
* 2026-09-17 12:00:20 WORKER-REROUTE from=<a> to=<b> reason=quota
|
|
989
|
-
* 2026-09-17 12:00:00 DENIED-ANTHROPIC-AGENT cwd=<cwd> desc=<desc>
|
|
990
|
-
* 2026-09-17 12:00:00 ALLOWED-ANTHROPIC-AGENT cwd=<cwd> desc=<desc>
|
|
991
|
-
*
|
|
992
|
-
* `pai worker log` (and glm-log before it) counts these; keep the tags stable.
|
|
993
|
-
*/
|
|
994
|
-
function ledgerStamp(d = /* @__PURE__ */ new Date()) {
|
|
995
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
996
|
-
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
997
|
-
}
|
|
998
|
-
/** Parse `2026-09-17 12:00:00 TAG key=value …` into its parts. */
|
|
999
|
-
function parseLedgerLine(line) {
|
|
1000
|
-
const m = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\S+)(?: (.*))?$/);
|
|
1001
|
-
if (!m) return null;
|
|
1002
|
-
const fields = {};
|
|
1003
|
-
for (const part of (m[3] ?? "").split(" ")) {
|
|
1004
|
-
if (!part) continue;
|
|
1005
|
-
const eq = part.indexOf("=");
|
|
1006
|
-
if (eq <= 0) continue;
|
|
1007
|
-
fields[part.slice(0, eq)] = part.slice(eq + 1);
|
|
1008
|
-
}
|
|
1009
|
-
return {
|
|
1010
|
-
stamp: m[1],
|
|
1011
|
-
event: m[2],
|
|
1012
|
-
fields
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
/** Append one ledger line. `kv` order is the caller's; values are flattened. */
|
|
1016
|
-
function appendLedger(path, event, kv, now = /* @__PURE__ */ new Date()) {
|
|
1017
|
-
const parts = [];
|
|
1018
|
-
for (const [k, v] of Object.entries(kv)) {
|
|
1019
|
-
if (v === void 0 || v === null) continue;
|
|
1020
|
-
parts.push(`${k}=${String(v).replace(/\s+/g, " ")}`);
|
|
1021
|
-
}
|
|
1022
|
-
const dir = dirname(path);
|
|
1023
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1024
|
-
const tail = parts.length ? " " + parts.join(" ") : "";
|
|
1025
|
-
appendFileSync(path, `${ledgerStamp(now)} ${event}${tail}\n`, "utf8");
|
|
1026
|
-
}
|
|
1027
|
-
/** The counts `pai worker log` prints, over today's lines or the whole file. */
|
|
1028
|
-
function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()) {
|
|
1029
|
-
if (!existsSync(path)) return null;
|
|
1030
|
-
const text = readFileSync(path, "utf8");
|
|
1031
|
-
const today = ledgerStamp(now).slice(0, 10);
|
|
1032
|
-
const lines = text.split("\n").filter((l) => l.trim());
|
|
1033
|
-
const parsed = (scope === "all" ? lines : lines.filter((l) => l.startsWith(today))).map(parseLedgerLine).filter((x) => x !== null);
|
|
1034
|
-
const count = (ev) => parsed.filter((p) => p.event === ev).length;
|
|
1035
|
-
const ends = parsed.filter((p) => p.event === "WORKER-END");
|
|
1036
|
-
const ok = ends.filter((p) => p.fields.rc === "0").length;
|
|
1037
|
-
return {
|
|
1038
|
-
scope: scope === "all" ? "all time" : `today ${today}`,
|
|
1039
|
-
started: count("WORKER-START"),
|
|
1040
|
-
endedOk: ok,
|
|
1041
|
-
endedFailed: ends.length - ok,
|
|
1042
|
-
denied: count("DENIED-ANTHROPIC-AGENT"),
|
|
1043
|
-
allowed: count("ALLOWED-ANTHROPIC-AGENT"),
|
|
1044
|
-
reroutes: count("WORKER-REROUTE"),
|
|
1045
|
-
lastLines: lines.slice(-lastN)
|
|
1046
|
-
};
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
//#endregion
|
|
1050
|
-
//#region src/workers/pending.ts
|
|
1051
|
-
/**
|
|
1052
|
-
* Pending worker results: finished workers whose worktree branch still holds
|
|
1053
|
-
* work that never reached the repo (commits not in HEAD, or uncommitted
|
|
1054
|
-
* files). Surfaced by `pai worker ps/pending`, the SessionStart hook, the
|
|
1055
|
-
* status line and the prepublish gate so an unmerged fix cannot be forgotten.
|
|
1056
|
-
*/
|
|
1057
|
-
/** Stale after this long: `pai worker gc` (archive) instead of merge. */
|
|
1058
|
-
const STALE_HOURS = 24;
|
|
1059
|
-
function toplevel(dir) {
|
|
1060
|
-
try {
|
|
1061
|
-
return git(dir, ["rev-parse", "--show-toplevel"]);
|
|
1062
|
-
} catch {
|
|
1063
|
-
return null;
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
function fmtAge(ms) {
|
|
1067
|
-
const h = Math.floor(ms / 36e5);
|
|
1068
|
-
return h >= 48 ? `${Math.floor(h / 24)}d` : h >= 1 ? `${h}h` : `${Math.floor(ms / 6e4)}m`;
|
|
1069
|
-
}
|
|
1070
|
-
/** Finished workers with unmerged work; `repoRoot` limits to one repo, else every repo. */
|
|
1071
|
-
function pendingResults(logDir, repoRoot, now = Date.now()) {
|
|
1072
|
-
const want = repoRoot ? toplevel(repoRoot) : null;
|
|
1073
|
-
if (repoRoot && !want) return [];
|
|
1074
|
-
const out = [];
|
|
1075
|
-
for (const st of loadStatuses(logDir)) {
|
|
1076
|
-
if (st.archived || st.merged || isLive(st)) continue;
|
|
1077
|
-
if (!st.worktreeDir || !existsSync(st.worktreeDir)) continue;
|
|
1078
|
-
const repo = toplevel(st.cwd);
|
|
1079
|
-
if (!repo || want && repo !== want) continue;
|
|
1080
|
-
let commits = 0;
|
|
1081
|
-
let dirty = 0;
|
|
1082
|
-
try {
|
|
1083
|
-
if (st.branch) commits = git(repo, [
|
|
1084
|
-
"cherry",
|
|
1085
|
-
"HEAD",
|
|
1086
|
-
`refs/heads/${st.branch}`
|
|
1087
|
-
]).split("\n").filter((l) => l.startsWith("+")).length;
|
|
1088
|
-
dirty = uncommittedPaths(st.worktreeDir).filter((p) => !/(^|\/)node_modules(\/|$)/.test(p)).length;
|
|
1089
|
-
} catch {
|
|
1090
|
-
continue;
|
|
1091
|
-
}
|
|
1092
|
-
if (!commits && !dirty) continue;
|
|
1093
|
-
let ageMs = 0;
|
|
1094
|
-
try {
|
|
1095
|
-
ageMs = Math.max(0, now - statSync(statusPath(logDir, st.id)).mtimeMs);
|
|
1096
|
-
} catch {}
|
|
1097
|
-
out.push({
|
|
1098
|
-
id: st.id,
|
|
1099
|
-
label: st.label,
|
|
1100
|
-
repo,
|
|
1101
|
-
ageMs,
|
|
1102
|
-
age: fmtAge(ageMs),
|
|
1103
|
-
commits,
|
|
1104
|
-
dirty
|
|
1105
|
-
});
|
|
1106
|
-
}
|
|
1107
|
-
return out;
|
|
1108
|
-
}
|
|
1109
|
-
/** One human line per result, with the exact next command. */
|
|
1110
|
-
function formatPending(results, showRepo = false) {
|
|
1111
|
-
return results.map((r) => {
|
|
1112
|
-
const next = r.ageMs > STALE_HOURS * 36e5 ? "pai worker gc" : `pai worker merge ${r.id}`;
|
|
1113
|
-
const repo = showRepo ? ` ${r.repo}` : "";
|
|
1114
|
-
return `${r.id} ${r.age} ${r.commits} commit(s), ${r.dirty} dirty ${r.label}${repo} -> ${next}`;
|
|
1115
|
-
}).join("\n");
|
|
1116
|
-
}
|
|
1117
|
-
/**
|
|
1118
|
-
* Release gate: exit code and message for `pai worker pending --gate`.
|
|
1119
|
-
* PAI_ALLOW_PENDING=1 overrides.
|
|
1120
|
-
*/
|
|
1121
|
-
function pendingGate(results, env = process.env) {
|
|
1122
|
-
if (!results.length || env.PAI_ALLOW_PENDING === "1") return {
|
|
1123
|
-
code: 0,
|
|
1124
|
-
message: ""
|
|
1125
|
-
};
|
|
1126
|
-
return {
|
|
1127
|
-
code: 1,
|
|
1128
|
-
message: `${results.length} unmerged worker result(s) in this repo — merge or discard before publishing:\n${formatPending(results)}\nOverride deliberately with PAI_ALLOW_PENDING=1.`
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1131
|
-
/** Invalidate pending cache entries for a repo and its subdirectories. */
|
|
1132
|
-
function invalidatePendingCache(logDir, repoRoot) {
|
|
1133
|
-
const file = join(logDir, "pending-cache.json");
|
|
1134
|
-
let cache = {};
|
|
1135
|
-
try {
|
|
1136
|
-
cache = JSON.parse(readFileSync(file, "utf8"));
|
|
1137
|
-
} catch {
|
|
1138
|
-
return;
|
|
1139
|
-
}
|
|
1140
|
-
const resolved = repoRoot.endsWith("/") ? repoRoot : `${repoRoot}/`;
|
|
1141
|
-
let changed = false;
|
|
1142
|
-
for (const key of Object.keys(cache)) if (key === repoRoot || key.startsWith(resolved)) {
|
|
1143
|
-
delete cache[key];
|
|
1144
|
-
changed = true;
|
|
1145
|
-
}
|
|
1146
|
-
if (!changed) return;
|
|
1147
|
-
try {
|
|
1148
|
-
writeFileSync(file, JSON.stringify(cache));
|
|
1149
|
-
} catch {}
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
//#endregion
|
|
1153
|
-
//#region src/workers/worktree.ts
|
|
1154
|
-
/**
|
|
1155
|
-
* worktree.ts — one git worktree per writing worker.
|
|
1156
|
-
*
|
|
1157
|
-
* A run whose class edits files (implement, complex, plan) and whose cwd is a
|
|
1158
|
-
* git repository gets its own worktree by default: `git worktree add
|
|
1159
|
-
* <logDir>/worktrees/<id> -b worker/<id>` from the current HEAD. The worker
|
|
1160
|
-
* commits its own work on that branch — the no-commit rule applies to the
|
|
1161
|
-
* main branch only — and the parent (or the operator) merges the result back:
|
|
1162
|
-
*
|
|
1163
|
-
* pai worker merge <id> git merge --no-ff worker/<id> + remove worktree + delete branch
|
|
1164
|
-
* pai worker discard <id> remove worktree and branch, keep nothing
|
|
1165
|
-
*
|
|
1166
|
-
* `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
|
|
1167
|
-
* place; `--no-worktree` opts out, `--worktree` forces one on.
|
|
1168
|
-
*/
|
|
1169
|
-
function worktreesDir(logDir) {
|
|
1170
|
-
return join(logDir, "worktrees");
|
|
1171
|
-
}
|
|
1172
|
-
function worktreeBranch(id) {
|
|
1173
|
-
return `worker/${id}`;
|
|
1174
|
-
}
|
|
1175
|
-
function worktreePath(logDir, id) {
|
|
1176
|
-
return join(worktreesDir(logDir), id);
|
|
1177
|
-
}
|
|
1178
|
-
/** Run git in `cwd` with an optional env override, trimmed stdout, stderr on failure. */
|
|
1179
|
-
function runGit(cwd, args, env, timeout = 3e4) {
|
|
1180
|
-
try {
|
|
1181
|
-
return execFileSync("git", [
|
|
1182
|
-
"-C",
|
|
1183
|
-
cwd,
|
|
1184
|
-
...args
|
|
1185
|
-
], {
|
|
1186
|
-
encoding: "utf8",
|
|
1187
|
-
timeout,
|
|
1188
|
-
stdio: [
|
|
1189
|
-
"ignore",
|
|
1190
|
-
"pipe",
|
|
1191
|
-
"pipe"
|
|
1192
|
-
],
|
|
1193
|
-
...env ? { env } : {}
|
|
1194
|
-
}).trim();
|
|
1195
|
-
} catch (e) {
|
|
1196
|
-
const err = e;
|
|
1197
|
-
const why = (typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e);
|
|
1198
|
-
throw new Error(`git ${args.join(" ")} in ${cwd}: ${why.trim()}`);
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
/** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
|
|
1202
|
-
function git(cwd, args, timeoutMs) {
|
|
1203
|
-
return runGit(cwd, args, void 0, timeoutMs);
|
|
1204
|
-
}
|
|
1205
|
-
/** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
|
|
1206
|
-
function isGitRepo(cwd) {
|
|
1207
|
-
try {
|
|
1208
|
-
return git(cwd, ["rev-parse", "--git-dir"]) !== "";
|
|
1209
|
-
} catch {
|
|
1210
|
-
return false;
|
|
1211
|
-
}
|
|
1212
|
-
}
|
|
1213
|
-
/** Tools that can write to the checkout (Bash can, via the shell). */
|
|
1214
|
-
const WRITING_TOOLS = [
|
|
1215
|
-
"Edit",
|
|
1216
|
-
"Write",
|
|
1217
|
-
"MultiEdit",
|
|
1218
|
-
"NotebookEdit",
|
|
1219
|
-
"Bash"
|
|
1220
|
-
];
|
|
1221
|
-
/** Can a run with these --allowedTools write? No restriction at all means yes. */
|
|
1222
|
-
function toolsCanWrite(allowedTools) {
|
|
1223
|
-
const names = allowedTools.flatMap((e) => e.split(",")).map((s) => s.trim().replace(/\(.*$/, "")).filter(Boolean);
|
|
1224
|
-
return !names.length || names.some((n) => WRITING_TOOLS.includes(n));
|
|
1225
|
-
}
|
|
1226
|
-
/** Should this run get a worktree? Explicit flag first, then: can it write? */
|
|
1227
|
-
function worktreeWanted(flag, opts) {
|
|
1228
|
-
if (flag !== void 0) return flag;
|
|
1229
|
-
return toolsCanWrite(opts.allowedTools) && isGitRepo(opts.cwd);
|
|
1230
|
-
}
|
|
1231
|
-
/**
|
|
1232
|
-
* Commit `cwd`'s current index + working tree (including untracked files) as
|
|
1233
|
-
* a floating commit on top of HEAD, without touching the real index, working
|
|
1234
|
-
* tree or HEAD: a temporary `GIT_INDEX_FILE` collects the snapshot, `git
|
|
1235
|
-
* commit-tree` writes the commit object directly (it updates no ref). The
|
|
1236
|
-
* caller then branches a worktree from that commit, so the worktree sees
|
|
1237
|
-
* exactly what was on disk, uncommitted or not.
|
|
1238
|
-
*/
|
|
1239
|
-
function snapshotUncommitted(cwd, id, head) {
|
|
1240
|
-
const idxDir = mkdtempSync(join(tmpdir(), "pai-worktree-idx-"));
|
|
1241
|
-
const idx = join(idxDir, "index");
|
|
1242
|
-
try {
|
|
1243
|
-
const env = {
|
|
1244
|
-
...process.env,
|
|
1245
|
-
GIT_INDEX_FILE: idx
|
|
1246
|
-
};
|
|
1247
|
-
runGit(cwd, ["read-tree", head], env);
|
|
1248
|
-
runGit(cwd, ["add", "-A"], env);
|
|
1249
|
-
return runGit(cwd, [
|
|
1250
|
-
"commit-tree",
|
|
1251
|
-
runGit(cwd, ["write-tree"], env),
|
|
1252
|
-
"-p",
|
|
1253
|
-
head,
|
|
1254
|
-
"-m",
|
|
1255
|
-
`worker ${id}: snapshot of uncommitted checkout`
|
|
1256
|
-
]);
|
|
1257
|
-
} finally {
|
|
1258
|
-
rmSync(idxDir, {
|
|
1259
|
-
recursive: true,
|
|
1260
|
-
force: true
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
}
|
|
1264
|
-
/**
|
|
1265
|
-
* Create the worktree and branch for `id` from `cwd`'s HEAD — or, when the
|
|
1266
|
-
* checkout has uncommitted changes, from a snapshot commit of that dirty
|
|
1267
|
-
* state (see `snapshotUncommitted`), so a worktree worker sees the same
|
|
1268
|
-
* files the operator sees, not just what was last committed. Throws when git
|
|
1269
|
-
* refuses (no commits yet, branch exists, …) — the caller decides whether to
|
|
1270
|
-
* degrade to an in-place run.
|
|
1271
|
-
*/
|
|
1272
|
-
function addWorktree(logDir, id, cwd) {
|
|
1273
|
-
const dir = worktreePath(logDir, id);
|
|
1274
|
-
const branch = worktreeBranch(id);
|
|
1275
|
-
const head = git(cwd, ["rev-parse", "HEAD"]);
|
|
1276
|
-
const dirty = dirtyPaths(cwd).length > 0;
|
|
1277
|
-
const base = dirty ? snapshotUncommitted(cwd, id, head) : head;
|
|
1278
|
-
git(cwd, [
|
|
1279
|
-
"worktree",
|
|
1280
|
-
"add",
|
|
1281
|
-
dir,
|
|
1282
|
-
"-b",
|
|
1283
|
-
branch,
|
|
1284
|
-
base
|
|
1285
|
-
]);
|
|
1286
|
-
provisionDeps(logDir, id, cwd, dir);
|
|
1287
|
-
return {
|
|
1288
|
-
dir,
|
|
1289
|
-
branch,
|
|
1290
|
-
base,
|
|
1291
|
-
snapshot: dirty
|
|
1292
|
-
};
|
|
1293
|
-
}
|
|
1294
|
-
/**
|
|
1295
|
-
* Give the worktree its own dependencies so the worker never has to improvise
|
|
1296
|
-
* (symlinking node_modules to the main checkout, building in a /tmp copy).
|
|
1297
|
-
* A real node_modules directory in the main checkout is APFS-cloned
|
|
1298
|
-
* (`cp -c`, copy-on-write, near instant); elsewhere the lockfile installs it.
|
|
1299
|
-
* Never a symlink. Time-boxed and best effort: a failure is logged, the run
|
|
1300
|
-
* goes on.
|
|
1301
|
-
*/
|
|
1302
|
-
function provisionDeps(logDir, id, cwd, wtDir) {
|
|
1303
|
-
const note = (n) => appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
|
|
1304
|
-
id,
|
|
1305
|
-
note: n
|
|
1306
|
-
});
|
|
1307
|
-
try {
|
|
1308
|
-
const main = join(git(cwd, ["rev-parse", "--show-toplevel"]), "node_modules");
|
|
1309
|
-
const dest = join(wtDir, "node_modules");
|
|
1310
|
-
if (!existsSync(main) || lstatSync(main).isSymbolicLink() || existsSync(dest)) return;
|
|
1311
|
-
try {
|
|
1312
|
-
git(wtDir, [
|
|
1313
|
-
"check-ignore",
|
|
1314
|
-
"-q",
|
|
1315
|
-
"node_modules"
|
|
1316
|
-
]);
|
|
1317
|
-
} catch {
|
|
1318
|
-
return;
|
|
1319
|
-
}
|
|
1320
|
-
const t0 = Date.now();
|
|
1321
|
-
const opts = {
|
|
1322
|
-
cwd: wtDir,
|
|
1323
|
-
stdio: "ignore",
|
|
1324
|
-
timeout: 18e4
|
|
1325
|
-
};
|
|
1326
|
-
if (platform() === "darwin") try {
|
|
1327
|
-
execFileSync("cp", [
|
|
1328
|
-
"-cR",
|
|
1329
|
-
main,
|
|
1330
|
-
dest
|
|
1331
|
-
], opts);
|
|
1332
|
-
note(`node_modules cloned (apfs) in ${Date.now() - t0}ms`);
|
|
1333
|
-
return;
|
|
1334
|
-
} catch {
|
|
1335
|
-
rmSync(dest, {
|
|
1336
|
-
recursive: true,
|
|
1337
|
-
force: true
|
|
1338
|
-
});
|
|
1339
|
-
}
|
|
1340
|
-
if (existsSync(join(wtDir, "bun.lock"))) execFileSync("bun", ["install", "--frozen-lockfile"], opts);
|
|
1341
|
-
else if (existsSync(join(wtDir, "package-lock.json"))) execFileSync("npm", ["ci"], opts);
|
|
1342
|
-
else return;
|
|
1343
|
-
note(`node_modules installed from lockfile in ${Date.now() - t0}ms`);
|
|
1344
|
-
} catch (e) {
|
|
1345
|
-
try {
|
|
1346
|
-
note(`node_modules provisioning failed: ${e.message}`);
|
|
1347
|
-
} catch {}
|
|
1348
|
-
}
|
|
1349
|
-
}
|
|
1350
|
-
/** Commits the branch collected on top of its base. */
|
|
1351
|
-
function commitsSince(dir, base) {
|
|
1352
|
-
try {
|
|
1353
|
-
return parseInt(git(dir, [
|
|
1354
|
-
"rev-list",
|
|
1355
|
-
"--count",
|
|
1356
|
-
`${base}..HEAD`
|
|
1357
|
-
]), 10) || 0;
|
|
1358
|
-
} catch {
|
|
1359
|
-
return 0;
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
/**
|
|
1363
|
-
* Record the worktree result in the status file: branch and commit count on
|
|
1364
|
-
* success, the worktree cleaned up on failure. Returns the updated status.
|
|
1365
|
-
*/
|
|
1366
|
-
function recordWorktree(logDir, status, info, ok) {
|
|
1367
|
-
const s = { ...status };
|
|
1368
|
-
if (ok) {
|
|
1369
|
-
s.branch = info.branch;
|
|
1370
|
-
s.commits = commitsSince(info.dir, info.base);
|
|
1371
|
-
s.worktreeDir = info.dir;
|
|
1372
|
-
s.worktreeBase = info.base;
|
|
1373
|
-
s.worktreeSnapshot = info.snapshot;
|
|
1374
|
-
} else if (safeCommitsSince(info.dir, info.base) > 0) {
|
|
1375
|
-
s.branch = info.branch;
|
|
1376
|
-
s.commits = commitsSince(info.dir, info.base);
|
|
1377
|
-
s.worktreeDir = info.dir;
|
|
1378
|
-
s.worktreeBase = info.base;
|
|
1379
|
-
s.worktreeSnapshot = info.snapshot;
|
|
1380
|
-
} else {
|
|
1381
|
-
removeWorktree(s.cwd, info.dir, true);
|
|
1382
|
-
try {
|
|
1383
|
-
git(s.cwd, [
|
|
1384
|
-
"branch",
|
|
1385
|
-
"-D",
|
|
1386
|
-
info.branch
|
|
1387
|
-
]);
|
|
1388
|
-
} catch {}
|
|
1389
|
-
s.branch = null;
|
|
1390
|
-
s.worktreeDir = null;
|
|
1391
|
-
s.worktreeBase = null;
|
|
1392
|
-
s.commits = null;
|
|
1393
|
-
s.worktreeSnapshot = null;
|
|
1394
|
-
}
|
|
1395
|
-
saveStatus(logDir, s);
|
|
1396
|
-
invalidatePendingCache(logDir, s.cwd);
|
|
1397
|
-
return s;
|
|
1398
|
-
}
|
|
1399
|
-
function safeCommitsSince(dir, base) {
|
|
1400
|
-
try {
|
|
1401
|
-
return commitsSince(dir, base);
|
|
1402
|
-
} catch {
|
|
1403
|
-
return 0;
|
|
1404
|
-
}
|
|
1405
|
-
}
|
|
1406
|
-
/**
|
|
1407
|
-
* Salvage for an exit path (run finaliser, kill signal): commit whatever the
|
|
1408
|
-
* worker left uncommitted, log WORKER-SALVAGE, never throw. Bounded by
|
|
1409
|
-
* `timeoutMs` per git call. Returns the committed paths.
|
|
1410
|
-
*/
|
|
1411
|
-
function salvageOnExit(ledger, id, label, wtDir, timeoutMs) {
|
|
1412
|
-
try {
|
|
1413
|
-
const { committed, skipped } = salvageUncommitted(wtDir, label, timeoutMs);
|
|
1414
|
-
if (committed.length) appendLedger(ledger, "WORKER-SALVAGE", {
|
|
1415
|
-
id,
|
|
1416
|
-
paths: committed.join(","),
|
|
1417
|
-
skipped: skipped.length
|
|
1418
|
-
});
|
|
1419
|
-
if (skipped.length) appendLedger(ledger, "WORKER-NOTE", {
|
|
1420
|
-
id,
|
|
1421
|
-
note: `salvage skipped: ${skipped.join("; ")}`
|
|
1422
|
-
});
|
|
1423
|
-
return committed;
|
|
1424
|
-
} catch (e) {
|
|
1425
|
-
try {
|
|
1426
|
-
appendLedger(ledger, "WORKER-NOTE", {
|
|
1427
|
-
id,
|
|
1428
|
-
note: `salvage failed: ${e.message}`
|
|
1429
|
-
});
|
|
1430
|
-
} catch {}
|
|
1431
|
-
return [];
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
/** Remove a worktree directory from git's books and the filesystem. */
|
|
1435
|
-
function removeWorktree(cwd, dir, force) {
|
|
1436
|
-
try {
|
|
1437
|
-
git(cwd, [
|
|
1438
|
-
"worktree",
|
|
1439
|
-
"remove",
|
|
1440
|
-
...force ? ["--force"] : [],
|
|
1441
|
-
dir
|
|
1442
|
-
]);
|
|
1443
|
-
return;
|
|
1444
|
-
} catch {}
|
|
1445
|
-
if (existsSync(dir)) try {
|
|
1446
|
-
rmSync(dir, {
|
|
1447
|
-
recursive: true,
|
|
1448
|
-
force: true
|
|
1449
|
-
});
|
|
1450
|
-
git(cwd, ["worktree", "prune"]);
|
|
1451
|
-
} catch {}
|
|
1452
|
-
}
|
|
1453
|
-
/**
|
|
1454
|
-
* Tracked changes (staged or not) plus untracked files — everything a
|
|
1455
|
-
* `git add -A` in `wtDir` would commit. The same two calls the old patch
|
|
1456
|
-
* carry used; name-only, not porcelain: a worktree-only change renders as
|
|
1457
|
-
* " M path" and the shared git() helper trims that leading space away.
|
|
1458
|
-
*/
|
|
1459
|
-
function uncommittedPaths(wtDir, timeoutMs) {
|
|
1460
|
-
const tracked = git(wtDir, [
|
|
1461
|
-
"diff",
|
|
1462
|
-
"--name-only",
|
|
1463
|
-
"HEAD"
|
|
1464
|
-
], timeoutMs).split("\n").filter(Boolean);
|
|
1465
|
-
const untracked = git(wtDir, [
|
|
1466
|
-
"ls-files",
|
|
1467
|
-
"--others",
|
|
1468
|
-
"--exclude-standard"
|
|
1469
|
-
], timeoutMs).split("\n").filter(Boolean);
|
|
1470
|
-
return [...tracked, ...untracked];
|
|
1471
|
-
}
|
|
1472
|
-
/**
|
|
1473
|
-
* True when `wtDir`/`relPath` is a symlink whose target resolves outside
|
|
1474
|
-
* `wtDir` — e.g. a worker's `node_modules -> <main checkout>/node_modules`
|
|
1475
|
-
* convenience link. A `.gitignore` using `dir/` (directories only) does not
|
|
1476
|
-
* ignore such a link, so it turns up as untracked; staging and merging it
|
|
1477
|
-
* lets git replace the real directory on the other side with a symlink to
|
|
1478
|
-
* itself. Never a candidate for salvage.
|
|
1479
|
-
*/
|
|
1480
|
-
function symlinkEscapesWorktree(wtDir, relPath) {
|
|
1481
|
-
const full = join(wtDir, relPath);
|
|
1482
|
-
let stat;
|
|
1483
|
-
try {
|
|
1484
|
-
stat = lstatSync(full);
|
|
1485
|
-
} catch {
|
|
1486
|
-
return false;
|
|
1487
|
-
}
|
|
1488
|
-
if (!stat.isSymbolicLink()) return false;
|
|
1489
|
-
const target = readlinkSync(full);
|
|
1490
|
-
const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(full), target);
|
|
1491
|
-
const realTarget = existsSync(resolvedTarget) ? realpathSync(resolvedTarget) : resolvedTarget;
|
|
1492
|
-
const realWtDir = realpathSync(wtDir);
|
|
1493
|
-
return realTarget !== realWtDir && !realTarget.startsWith(realWtDir + sep);
|
|
1494
|
-
}
|
|
1495
|
-
/**
|
|
1496
|
-
* Commit a worktree's uncommitted changes to its branch so the merge carries
|
|
1497
|
-
* them. `git merge` only moves committed work, so a worker that stopped
|
|
1498
|
-
* without committing would lose its edits to `worktree remove` — exactly
|
|
1499
|
-
* what happened live on 2026-09-17. Returns the salvaged paths, empty when
|
|
1500
|
-
* the worktree is clean. A symlink pointing outside the worktree (see
|
|
1501
|
-
* `symlinkEscapesWorktree`) is never staged; it is left on disk and reported
|
|
1502
|
-
* as skipped instead. A failed commit throws with the worktree untouched:
|
|
1503
|
-
* its edits are still on disk, so nothing is lost.
|
|
1504
|
-
*/
|
|
1505
|
-
function salvageUncommitted(wtDir, label, timeoutMs) {
|
|
1506
|
-
const paths = uncommittedPaths(wtDir, timeoutMs);
|
|
1507
|
-
if (!paths.length) return {
|
|
1508
|
-
committed: [],
|
|
1509
|
-
skipped: []
|
|
1510
|
-
};
|
|
1511
|
-
const skipped = [];
|
|
1512
|
-
const missing = new Set(git(wtDir, ["ls-files", "--deleted"], timeoutMs).split("\n").filter(Boolean));
|
|
1513
|
-
const toStage = [];
|
|
1514
|
-
for (const p of paths) if (missing.has(p)) skipped.push(`${p} (missing, not committed)`);
|
|
1515
|
-
else if (symlinkEscapesWorktree(wtDir, p)) skipped.push(`symlink ${p} -> ${readlinkSync(join(wtDir, p))} (points outside the worktree)`);
|
|
1516
|
-
else toStage.push(p);
|
|
1517
|
-
if (!toStage.length) return {
|
|
1518
|
-
committed: [],
|
|
1519
|
-
skipped
|
|
1520
|
-
};
|
|
1521
|
-
try {
|
|
1522
|
-
git(wtDir, [
|
|
1523
|
-
"add",
|
|
1524
|
-
"--ignore-removal",
|
|
1525
|
-
"--",
|
|
1526
|
-
".",
|
|
1527
|
-
...paths.filter((p) => !toStage.includes(p)).map((p) => `:(exclude,literal)${p}`)
|
|
1528
|
-
], timeoutMs);
|
|
1529
|
-
const deleted = git(wtDir, [
|
|
1530
|
-
"diff",
|
|
1531
|
-
"--cached",
|
|
1532
|
-
"--diff-filter=D",
|
|
1533
|
-
"--name-only"
|
|
1534
|
-
], timeoutMs).split("\n").filter(Boolean);
|
|
1535
|
-
if (deleted.length > MAX_SALVAGE_DELETES) {
|
|
1536
|
-
git(wtDir, ["reset", "-q"], timeoutMs);
|
|
1537
|
-
throw new Error(`salvage would delete ${deleted.length} tracked files (${deleted.slice(0, 5).join(", ")}, ...), refused`);
|
|
1538
|
-
}
|
|
1539
|
-
git(wtDir, [
|
|
1540
|
-
"commit",
|
|
1541
|
-
"-m",
|
|
1542
|
-
`salvaged: ${label}`
|
|
1543
|
-
], timeoutMs);
|
|
1544
|
-
} catch (e) {
|
|
1545
|
-
throw new Error(`cannot salvage the uncommitted changes in ${wtDir} — ${e.message}; nothing was lost: commit them there by hand, then re-run merge`);
|
|
1546
|
-
}
|
|
1547
|
-
return {
|
|
1548
|
-
committed: toStage,
|
|
1549
|
-
skipped
|
|
1550
|
-
};
|
|
1551
|
-
}
|
|
1552
|
-
/** A salvage commit that deletes more tracked files than this is refused. */
|
|
1553
|
-
const MAX_SALVAGE_DELETES = 5;
|
|
1554
|
-
/**
|
|
1555
|
-
* The dirty paths of a checkout, parsed from `git status --porcelain -z`:
|
|
1556
|
-
* NUL-separated (a path with a newline in it cannot corrupt the parse),
|
|
1557
|
-
* rename entries contribute both sides, and any quoting is stripped.
|
|
1558
|
-
*/
|
|
1559
|
-
function dirtyPaths(cwd) {
|
|
1560
|
-
const fields = execFileSync("git", [
|
|
1561
|
-
"-C",
|
|
1562
|
-
cwd,
|
|
1563
|
-
"status",
|
|
1564
|
-
"--porcelain",
|
|
1565
|
-
"-z"
|
|
1566
|
-
], {
|
|
1567
|
-
encoding: "utf8",
|
|
1568
|
-
timeout: 3e4,
|
|
1569
|
-
stdio: [
|
|
1570
|
-
"ignore",
|
|
1571
|
-
"pipe",
|
|
1572
|
-
"pipe"
|
|
1573
|
-
]
|
|
1574
|
-
}).split("\0");
|
|
1575
|
-
const strip = (p) => p.startsWith("\"") && p.endsWith("\"") ? p.slice(1, -1) : p;
|
|
1576
|
-
const out = [];
|
|
1577
|
-
for (let i = 0; i < fields.length; i++) {
|
|
1578
|
-
const f = fields[i];
|
|
1579
|
-
if (!f || f.length < 4 || f.charAt(2) !== " ") continue;
|
|
1580
|
-
out.push(strip(f.slice(3)));
|
|
1581
|
-
const xy = f.slice(0, 2);
|
|
1582
|
-
if (xy.includes("R") || xy.includes("C")) {
|
|
1583
|
-
const orig = fields[i + 1];
|
|
1584
|
-
if (orig && orig.charAt(2) !== " ") {
|
|
1585
|
-
out.push(strip(orig));
|
|
1586
|
-
i += 1;
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
return out;
|
|
1591
|
-
}
|
|
1592
|
-
/**
|
|
1593
|
-
* Refuse the merge when the original checkout is dirty in paths the branch
|
|
1594
|
-
* touches: the merge would overwrite those edits or fail on them, either way
|
|
1595
|
-
* leaving a half-state. No merge is made, the worktree stays.
|
|
1596
|
-
*/
|
|
1597
|
-
function assertNoDirtyOverlap(cwd, incoming, id, branch) {
|
|
1598
|
-
const dirty = new Set(dirtyPaths(cwd));
|
|
1599
|
-
const overlap = [...new Set(incoming)].filter((p) => dirty.has(p)).sort();
|
|
1600
|
-
if (!overlap.length) return;
|
|
1601
|
-
throw new Error(`worker ${id}: the checkout ${cwd} has uncommitted changes in paths ${branch} touches: ${overlap.join(", ")}. Commit or stash them in the checkout, then re-run: pai worker merge ${id}. No merge was made; the worktree was kept.`);
|
|
1602
|
-
}
|
|
1603
|
-
/**
|
|
1604
|
-
* Never remove a worktree that still holds uncommitted changes — except a
|
|
1605
|
-
* symlink escaping the worktree (see `symlinkEscapesWorktree`): it was
|
|
1606
|
-
* deliberately left uncommitted by `salvageUncommitted`, and deleting the
|
|
1607
|
-
* link itself touches nothing outside the worktree.
|
|
1608
|
-
*/
|
|
1609
|
-
function assertWorktreeClean(wtDir, id) {
|
|
1610
|
-
const leftover = uncommittedPaths(wtDir).filter((p) => !symlinkEscapesWorktree(wtDir, p));
|
|
1611
|
-
if (leftover.length) throw new Error(`worker ${id}: the worktree ${wtDir} still holds uncommitted changes (${leftover.join(", ")}) — it was NOT removed; commit or copy them by hand, then re-run merge`);
|
|
1612
|
-
}
|
|
1613
|
-
/** Blob sha of `path` at `rev` in `cwd`, or null when it does not exist there. */
|
|
1614
|
-
function blobAt(cwd, rev, path) {
|
|
1615
|
-
try {
|
|
1616
|
-
return git(cwd, [
|
|
1617
|
-
"rev-parse",
|
|
1618
|
-
"--verify",
|
|
1619
|
-
"-q",
|
|
1620
|
-
`${rev}:${path}`
|
|
1621
|
-
]);
|
|
1622
|
-
} catch {
|
|
1623
|
-
return null;
|
|
1624
|
-
}
|
|
1625
|
-
}
|
|
1626
|
-
/** Blob sha of `path` as it sits on disk in `cwd`, or null when it is absent. */
|
|
1627
|
-
function workingBlob(cwd, path) {
|
|
1628
|
-
if (!existsSync(join(cwd, path))) return null;
|
|
1629
|
-
try {
|
|
1630
|
-
return git(cwd, [
|
|
1631
|
-
"hash-object",
|
|
1632
|
-
"--",
|
|
1633
|
-
path
|
|
1634
|
-
]);
|
|
1635
|
-
} catch {
|
|
1636
|
-
return null;
|
|
1637
|
-
}
|
|
1638
|
-
}
|
|
1639
|
-
/**
|
|
1640
|
-
* Refuse a snapshot merge when the checkout differs from the snapshot the
|
|
1641
|
-
* worker branched from, for any path the branch touched: the operator edited
|
|
1642
|
-
* it, or it was created/deleted since — applying the branch's diff on top
|
|
1643
|
-
* would silently discard that. Absent-in-both counts as equal.
|
|
1644
|
-
*/
|
|
1645
|
-
function assertNoSnapshotDrift(cwd, snapshot, paths, id) {
|
|
1646
|
-
const drifted = paths.filter((p) => blobAt(cwd, snapshot, p) !== workingBlob(cwd, p)).sort();
|
|
1647
|
-
if (!drifted.length) return;
|
|
1648
|
-
throw new Error(`worker ${id}: the checkout ${cwd} has changed since the worktree's snapshot in paths the branch touches: ${drifted.join(", ")}. Resolve by hand, then re-run: pai worker merge ${id}. No merge was made; the worktree was kept.`);
|
|
1649
|
-
}
|
|
1650
|
-
/**
|
|
1651
|
-
* `pai worker merge <id>` for a snapshot-based worktree (see `addWorktree`):
|
|
1652
|
-
* git's own merge only moves committed history, and the snapshot commit was
|
|
1653
|
-
* never on any branch reachable from `cwd`'s HEAD, so there is nothing to
|
|
1654
|
-
* `git merge` here. Instead the worker's own changes — `git diff --binary
|
|
1655
|
-
* <snapshot> <branch>` — are applied straight onto the checkout's working
|
|
1656
|
-
* tree as uncommitted edits, exactly like the uncommitted state the worker
|
|
1657
|
-
* started from. Works even if `cwd`'s HEAD moved since the snapshot: the
|
|
1658
|
-
* diff is applied to files, not merged into a tree.
|
|
1659
|
-
*/
|
|
1660
|
-
function mergeSnapshotWorker(logDir, id, st, noCommit = false) {
|
|
1661
|
-
const snapshot = st.worktreeBase;
|
|
1662
|
-
const branch = st.branch;
|
|
1663
|
-
const worktreeDir = st.worktreeDir;
|
|
1664
|
-
const salvage = existsSync(worktreeDir) ? salvageUncommitted(worktreeDir, st.label || UNLABELED) : {
|
|
1665
|
-
committed: [],
|
|
1666
|
-
skipped: []
|
|
1667
|
-
};
|
|
1668
|
-
if ((parseInt(git(st.cwd, [
|
|
1669
|
-
"rev-list",
|
|
1670
|
-
"--count",
|
|
1671
|
-
`${snapshot}..${branch}`
|
|
1672
|
-
]), 10) || 0) <= 0) throw new Error(`worker ${id}: branch ${branch} has no commits beyond its snapshot to merge. The worktree ${worktreeDir} was NOT removed — uncommitted work there would be destroyed. Commit it yourself, or drop everything with: pai worker discard ${id}`);
|
|
1673
|
-
const changedPaths = git(st.cwd, [
|
|
1674
|
-
"diff",
|
|
1675
|
-
"--name-only",
|
|
1676
|
-
snapshot,
|
|
1677
|
-
branch
|
|
1678
|
-
]).split("\n").filter(Boolean);
|
|
1679
|
-
assertNoSnapshotDrift(st.cwd, snapshot, changedPaths, id);
|
|
1680
|
-
const diff = execFileSync("git", [
|
|
1681
|
-
"-C",
|
|
1682
|
-
st.cwd,
|
|
1683
|
-
"diff",
|
|
1684
|
-
"--binary",
|
|
1685
|
-
snapshot,
|
|
1686
|
-
branch
|
|
1687
|
-
], {
|
|
1688
|
-
encoding: "utf8",
|
|
1689
|
-
timeout: 3e4,
|
|
1690
|
-
maxBuffer: 64 * 1024 * 1024
|
|
1691
|
-
});
|
|
1692
|
-
if (diff.trim()) try {
|
|
1693
|
-
execFileSync("git", [
|
|
1694
|
-
"-C",
|
|
1695
|
-
st.cwd,
|
|
1696
|
-
"apply",
|
|
1697
|
-
"--binary"
|
|
1698
|
-
], {
|
|
1699
|
-
input: diff,
|
|
1700
|
-
encoding: "utf8",
|
|
1701
|
-
timeout: 3e4,
|
|
1702
|
-
stdio: [
|
|
1703
|
-
"pipe",
|
|
1704
|
-
"pipe",
|
|
1705
|
-
"pipe"
|
|
1706
|
-
]
|
|
1707
|
-
});
|
|
1708
|
-
} catch (e) {
|
|
1709
|
-
const err = e;
|
|
1710
|
-
const why = (typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e);
|
|
1711
|
-
throw new Error(`worker ${id}: git refused to apply ${branch}'s changes to ${st.cwd} — ${why.trim()}. The worktree ${worktreeDir} was kept: resolve by hand, then re-run merge`);
|
|
1712
|
-
}
|
|
1713
|
-
if (noCommit) return noCommitNextSteps(id, branch, worktreeDir, changedPaths);
|
|
1714
|
-
removeWorktree(st.cwd, worktreeDir, true);
|
|
1715
|
-
try {
|
|
1716
|
-
git(st.cwd, [
|
|
1717
|
-
"branch",
|
|
1718
|
-
"-D",
|
|
1719
|
-
branch
|
|
1720
|
-
]);
|
|
1721
|
-
} catch {}
|
|
1722
|
-
saveStatus(logDir, {
|
|
1723
|
-
...st,
|
|
1724
|
-
merged: true
|
|
1725
|
-
});
|
|
1726
|
-
const base = `applied worker ${id}'s changes (branch ${branch}) to ${st.cwd} as uncommitted changes: ${changedPaths.join(", ")} (worktree removed, branch deleted)`;
|
|
1727
|
-
const notes = [];
|
|
1728
|
-
if (salvage.committed.length) notes.push(`salvaged ${salvage.committed.length} uncommitted change(s) onto the branch first: ${salvage.committed.join(", ")}`);
|
|
1729
|
-
if (salvage.skipped.length) notes.push(`skipped ${salvage.skipped.join(", ")}`);
|
|
1730
|
-
return notes.length ? `${base}; ${notes.join("; ")}` : base;
|
|
1731
|
-
}
|
|
1732
|
-
function noCommitNextSteps(id, branch, worktreeDir, paths) {
|
|
1733
|
-
return `applied worker ${id}'s changes (branch ${branch}) to the checkout as uncommitted changes: ${paths.join(", ")}. Branch and worktree ${worktreeDir} were kept. Next: review (git diff), commit, then run: pai worker gc`;
|
|
1734
|
-
}
|
|
1735
|
-
/**
|
|
1736
|
-
* `merge --no-commit`: apply base..branch to the checkout with a 3-way
|
|
1737
|
-
* `git apply`, leaving the result uncommitted. On conflict the tree is left
|
|
1738
|
-
* as is (markers + unmerged index entries) and the conflicted paths are listed.
|
|
1739
|
-
*/
|
|
1740
|
-
function applyNoCommit(cwd, id, branch, worktreeDir, base, paths) {
|
|
1741
|
-
const diff = execFileSync("git", [
|
|
1742
|
-
"-C",
|
|
1743
|
-
cwd,
|
|
1744
|
-
"diff",
|
|
1745
|
-
"--binary",
|
|
1746
|
-
`${base}..${branch}`
|
|
1747
|
-
], {
|
|
1748
|
-
encoding: "buffer",
|
|
1749
|
-
timeout: 3e4,
|
|
1750
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
1751
|
-
env: {
|
|
1752
|
-
...process.env,
|
|
1753
|
-
LC_ALL: "C"
|
|
1754
|
-
}
|
|
1755
|
-
});
|
|
1756
|
-
try {
|
|
1757
|
-
execFileSync("git", [
|
|
1758
|
-
"-C",
|
|
1759
|
-
cwd,
|
|
1760
|
-
"apply",
|
|
1761
|
-
"--3way",
|
|
1762
|
-
"--binary"
|
|
1763
|
-
], {
|
|
1764
|
-
input: diff,
|
|
1765
|
-
timeout: 3e4,
|
|
1766
|
-
stdio: [
|
|
1767
|
-
"pipe",
|
|
1768
|
-
"pipe",
|
|
1769
|
-
"pipe"
|
|
1770
|
-
],
|
|
1771
|
-
env: {
|
|
1772
|
-
...process.env,
|
|
1773
|
-
LC_ALL: "C"
|
|
1774
|
-
}
|
|
1775
|
-
});
|
|
1776
|
-
} catch (e) {
|
|
1777
|
-
const conflicted = git(cwd, [
|
|
1778
|
-
"diff",
|
|
1779
|
-
"--name-only",
|
|
1780
|
-
"--diff-filter=U"
|
|
1781
|
-
]).split("\n").filter(Boolean);
|
|
1782
|
-
const err = e;
|
|
1783
|
-
const why = ((typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || "").trim();
|
|
1784
|
-
throw new Error(conflicted.length ? `worker ${id}: applying ${branch} conflicted in ${conflicted.join(", ")}. The tree was left for you: resolve those paths, review, commit; branch and worktree ${worktreeDir} were kept` : `worker ${id}: git refused to apply ${branch} to ${cwd} — ${why}. Nothing was changed; branch and worktree were kept`);
|
|
1785
|
-
}
|
|
1786
|
-
return noCommitNextSteps(id, branch, worktreeDir, paths);
|
|
1787
|
-
}
|
|
1788
|
-
/**
|
|
1789
|
-
* `pai worker verify <id>`: for every file the worker's branch (or its
|
|
1790
|
-
* refs/pai-archive/<id> archive once the branch is gone) changed since its
|
|
1791
|
-
* base, compare bytes with the working tree or `--against <ref>`.
|
|
1792
|
-
* identical = the target already holds the worker's version (a worker delete
|
|
1793
|
-
* counts as identical when the target lacks the file too); differs = other
|
|
1794
|
-
* bytes; missing = the worker's file is absent in the target; deleted = the
|
|
1795
|
-
* worker deleted it but the target still has it. ok = every file identical.
|
|
1796
|
-
*/
|
|
1797
|
-
function verifyWorker(logDir, id, opts = {}) {
|
|
1798
|
-
const st = loadStatus(logDir, id);
|
|
1799
|
-
if (!st) throw new Error(`no worker named "${id}"`);
|
|
1800
|
-
const env = {
|
|
1801
|
-
...process.env,
|
|
1802
|
-
LC_ALL: "C"
|
|
1803
|
-
};
|
|
1804
|
-
const bytes = (args) => {
|
|
1805
|
-
try {
|
|
1806
|
-
return execFileSync("git", [
|
|
1807
|
-
"-C",
|
|
1808
|
-
st.cwd,
|
|
1809
|
-
...args
|
|
1810
|
-
], {
|
|
1811
|
-
env,
|
|
1812
|
-
timeout: 3e4,
|
|
1813
|
-
maxBuffer: 256 * 1024 * 1024,
|
|
1814
|
-
stdio: [
|
|
1815
|
-
"ignore",
|
|
1816
|
-
"pipe",
|
|
1817
|
-
"pipe"
|
|
1818
|
-
]
|
|
1819
|
-
});
|
|
1820
|
-
} catch {
|
|
1821
|
-
return null;
|
|
1822
|
-
}
|
|
1823
|
-
};
|
|
1824
|
-
const has = (ref) => bytes([
|
|
1825
|
-
"rev-parse",
|
|
1826
|
-
"--verify",
|
|
1827
|
-
"-q",
|
|
1828
|
-
`${ref}^{commit}`
|
|
1829
|
-
]) !== null;
|
|
1830
|
-
const ref = [`refs/heads/${worktreeBranch(id)}`, `refs/pai-archive/${id}`].find(has);
|
|
1831
|
-
if (!ref) throw new Error(`worker ${id}: neither ${worktreeBranch(id)} nor refs/pai-archive/${id} exists in ${st.cwd}`);
|
|
1832
|
-
const base = st.worktreeSnapshot && st.worktreeBase ? st.worktreeBase : git(st.cwd, [
|
|
1833
|
-
"merge-base",
|
|
1834
|
-
"HEAD",
|
|
1835
|
-
ref
|
|
1836
|
-
]);
|
|
1837
|
-
const list = (bytes([
|
|
1838
|
-
"diff",
|
|
1839
|
-
"--name-status",
|
|
1840
|
-
"--no-renames",
|
|
1841
|
-
"-z",
|
|
1842
|
-
`${base}..${ref}`
|
|
1843
|
-
]) ?? Buffer.alloc(0)).toString("utf8").split("\0").filter(Boolean);
|
|
1844
|
-
const files = [];
|
|
1845
|
-
for (let i = 0; i + 1 < list.length; i += 2) {
|
|
1846
|
-
const path = list[i + 1];
|
|
1847
|
-
const deleted = list[i] === "D";
|
|
1848
|
-
const target = opts.against ? bytes(["show", `${opts.against}:${path}`]) : existsSync(join(st.cwd, path)) ? readFileSync(join(st.cwd, path)) : null;
|
|
1849
|
-
let state;
|
|
1850
|
-
if (deleted) state = target ? "deleted" : "identical";
|
|
1851
|
-
else {
|
|
1852
|
-
const mine = bytes(["show", `${ref}:${path}`]);
|
|
1853
|
-
state = !target ? "missing" : mine && mine.equals(target) ? "identical" : "differs";
|
|
1854
|
-
}
|
|
1855
|
-
files.push({
|
|
1856
|
-
path,
|
|
1857
|
-
state
|
|
1858
|
-
});
|
|
1859
|
-
}
|
|
1860
|
-
return {
|
|
1861
|
-
id,
|
|
1862
|
-
ref,
|
|
1863
|
-
base,
|
|
1864
|
-
against: opts.against ?? "working tree",
|
|
1865
|
-
files,
|
|
1866
|
-
ok: files.every((f) => f.state === "identical")
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
/**
|
|
1870
|
-
* `pai worker merge <id>`: salvage whatever the worker left uncommitted onto
|
|
1871
|
-
* its branch, refuse when the original checkout is dirty in paths the branch
|
|
1872
|
-
* touches, merge with --no-ff (the merge commit names the worker), then
|
|
1873
|
-
* remove the worktree and delete the branch; the status gains `merged: true`.
|
|
1874
|
-
* A branch with nothing to merge is refused loudly — after salvage that
|
|
1875
|
-
* genuinely means it holds nothing, and reporting success there would
|
|
1876
|
-
* destroy the worktree for no gain.
|
|
1877
|
-
*/
|
|
1878
|
-
function mergeWorker(logDir, id, opts = {}) {
|
|
1879
|
-
const st = mustHaveBranch(logDir, id);
|
|
1880
|
-
if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
|
|
1881
|
-
if (st.worktreeSnapshot) return mergeSnapshotWorker(logDir, id, st, opts.noCommit);
|
|
1882
|
-
const salvage = existsSync(st.worktreeDir) ? salvageUncommitted(st.worktreeDir, st.label || UNLABELED) : {
|
|
1883
|
-
committed: [],
|
|
1884
|
-
skipped: []
|
|
1885
|
-
};
|
|
1886
|
-
if ((parseInt(git(st.cwd, [
|
|
1887
|
-
"rev-list",
|
|
1888
|
-
"--count",
|
|
1889
|
-
`HEAD..${st.branch}`
|
|
1890
|
-
]), 10) || 0) <= 0) throw new Error(`worker ${id}: branch ${st.branch} has no commits to merge. The worktree ${st.worktreeDir} was NOT removed — uncommitted work there would be destroyed. Commit it yourself, or drop everything with: pai worker discard ${id}`);
|
|
1891
|
-
const mergeBase = git(st.cwd, [
|
|
1892
|
-
"merge-base",
|
|
1893
|
-
"HEAD",
|
|
1894
|
-
st.branch
|
|
1895
|
-
]);
|
|
1896
|
-
const incomingPaths = git(st.cwd, [
|
|
1897
|
-
"diff",
|
|
1898
|
-
"--name-only",
|
|
1899
|
-
mergeBase,
|
|
1900
|
-
st.branch
|
|
1901
|
-
]).split("\n").filter(Boolean);
|
|
1902
|
-
assertNoDirtyOverlap(st.cwd, incomingPaths, id, st.branch);
|
|
1903
|
-
if (opts.noCommit) return applyNoCommit(st.cwd, id, st.branch, st.worktreeDir, mergeBase, incomingPaths);
|
|
1904
|
-
try {
|
|
1905
|
-
git(st.cwd, [
|
|
1906
|
-
"merge",
|
|
1907
|
-
"--no-ff",
|
|
1908
|
-
st.branch,
|
|
1909
|
-
"-m",
|
|
1910
|
-
`merge worker ${id} (${st.label})`
|
|
1911
|
-
]);
|
|
1912
|
-
} catch (e) {
|
|
1913
|
-
throw new Error(`worker ${id}: git refused the merge of ${st.branch} — ${e.message}. The worktree ${st.worktreeDir} was kept: resolve the conflict, then re-run merge`);
|
|
1914
|
-
}
|
|
1915
|
-
if (existsSync(st.worktreeDir)) assertWorktreeClean(st.worktreeDir, id);
|
|
1916
|
-
removeWorktree(st.cwd, st.worktreeDir, true);
|
|
1917
|
-
let branchGone = true;
|
|
1918
|
-
try {
|
|
1919
|
-
git(st.cwd, [
|
|
1920
|
-
"branch",
|
|
1921
|
-
"-d",
|
|
1922
|
-
st.branch
|
|
1923
|
-
]);
|
|
1924
|
-
} catch {
|
|
1925
|
-
branchGone = false;
|
|
1926
|
-
}
|
|
1927
|
-
saveStatus(logDir, {
|
|
1928
|
-
...st,
|
|
1929
|
-
merged: true
|
|
1930
|
-
});
|
|
1931
|
-
invalidatePendingCache(logDir, st.cwd);
|
|
1932
|
-
const base = `merged ${st.branch} into ${st.cwd} (worktree removed${branchGone ? ", branch deleted" : "; branch kept: git refused -d"})`;
|
|
1933
|
-
const notes = [];
|
|
1934
|
-
if (salvage.committed.length) notes.push(`salvaged ${salvage.committed.length} uncommitted change(s): ${salvage.committed.join(", ")}`);
|
|
1935
|
-
if (salvage.skipped.length) notes.push(`skipped ${salvage.skipped.join(", ")}`);
|
|
1936
|
-
return notes.length ? `${base}; ${notes.join("; ")}` : base;
|
|
1937
|
-
}
|
|
1938
|
-
/** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
|
|
1939
|
-
function discardWorker(logDir, id) {
|
|
1940
|
-
const st = mustHaveBranch(logDir, id);
|
|
1941
|
-
removeWorktree(st.cwd, st.worktreeDir, true);
|
|
1942
|
-
let branchGone = false;
|
|
1943
|
-
try {
|
|
1944
|
-
git(st.cwd, [
|
|
1945
|
-
"branch",
|
|
1946
|
-
"-D",
|
|
1947
|
-
st.branch
|
|
1948
|
-
]);
|
|
1949
|
-
branchGone = true;
|
|
1950
|
-
} catch {
|
|
1951
|
-
branchGone = false;
|
|
1952
|
-
}
|
|
1953
|
-
saveStatus(logDir, {
|
|
1954
|
-
...st,
|
|
1955
|
-
branch: null,
|
|
1956
|
-
worktreeDir: null,
|
|
1957
|
-
worktreeBase: null,
|
|
1958
|
-
worktreeSnapshot: null,
|
|
1959
|
-
commits: null,
|
|
1960
|
-
merged: false
|
|
1961
|
-
});
|
|
1962
|
-
invalidatePendingCache(logDir, st.cwd);
|
|
1963
|
-
return `discarded worker ${id}: worktree removed${branchGone ? `, branch ${st.branch} deleted` : ""}`;
|
|
1964
|
-
}
|
|
1965
|
-
function mustHaveBranch(logDir, id) {
|
|
1966
|
-
const st = loadStatus(logDir, id);
|
|
1967
|
-
if (!st) throw new Error(`no worker named "${id}"`);
|
|
1968
|
-
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`);
|
|
1969
|
-
return st;
|
|
1970
|
-
}
|
|
1971
|
-
/**
|
|
1972
|
-
* The paragraph a worktree run's system prompt gains: it may commit on its
|
|
1973
|
-
* own branch (the no-commit rule holds for the main branch only), it must not
|
|
1974
|
-
* merge or push itself, and the parent or operator merges.
|
|
1975
|
-
*/
|
|
1976
|
-
function worktreeSystemPrompt(id, branch, dir, snapshot = false) {
|
|
1977
|
-
return [
|
|
1978
|
-
"You are running in your own git worktree:",
|
|
1979
|
-
` ${dir} on branch ${branch} (worker id ${id}).`,
|
|
1980
|
-
"Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
|
|
1981
|
-
"the no-commit rule applies to the main branch only, and this is not it.",
|
|
1982
|
-
"Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`.",
|
|
1983
|
-
"Use ONLY relative paths inside the worktree, never absolute worktree paths — absolute paths break after merge and leak machine layout.",
|
|
1984
|
-
...snapshot ? ["This worktree was branched from a snapshot that includes files the parent checkout had uncommitted at spawn time — not just its last commit."] : []
|
|
1985
|
-
].join("\n");
|
|
1986
|
-
}
|
|
1987
|
-
/**
|
|
1988
|
-
* For headless runs WITHOUT a worktree: the worker shares the operator's
|
|
1989
|
-
* checkout with other in-flight workers, so a hook blocks git stash (except
|
|
1990
|
-
* list/show), reset, clean, checkout/restore of paths, switch, rebase and
|
|
1991
|
-
* merge — take baselines from measurements before editing, not from stash.
|
|
1992
|
-
*/
|
|
1993
|
-
function inPlaceSystemPrompt() {
|
|
1994
|
-
return [
|
|
1995
|
-
"You are running in the operator's shared git checkout, alongside other workers editing it at the same time.",
|
|
1996
|
-
"A hook blocks git stash (except list/show), reset, clean, checkout/restore of files, switch, rebase and merge — take baselines from measurements taken before you edit, not from stashing.",
|
|
1997
|
-
"Read-only git (status, diff, log, show, stash list) is fine."
|
|
1998
|
-
].join("\n");
|
|
1999
|
-
}
|
|
2000
|
-
const GC_STAMP = "gc-last";
|
|
2001
|
-
/**
|
|
2002
|
-
* Archive worktrees of finished workers older than `olderThanHours` and
|
|
2003
|
-
* status-less orphan directories: uncommitted state is salvaged onto the
|
|
2004
|
-
* branch, the branch is x, the worktree is
|
|
2005
|
-
* removed. Nothing is deleted without a salvage first; a failed salvage skips
|
|
2006
|
-
* that worktree. Running workers are never touched. `dryRun` reports only.
|
|
2007
|
-
*/
|
|
2008
|
-
function gcWorktrees(logDir, olderThanHours = 24, dryRun = false, now = Date.now()) {
|
|
2009
|
-
const cutoff = now - olderThanHours * 36e5;
|
|
2010
|
-
const out = [];
|
|
2011
|
-
const root = worktreesDir(logDir);
|
|
2012
|
-
const known = /* @__PURE__ */ new Set();
|
|
2013
|
-
const archive = (id, dir, st) => {
|
|
2014
|
-
const target = `refs/pai-archive/${id}`;
|
|
2015
|
-
if (dryRun) return void out.push({
|
|
2016
|
-
id,
|
|
2017
|
-
archive: target
|
|
2018
|
-
});
|
|
2019
|
-
try {
|
|
2020
|
-
let repo;
|
|
2021
|
-
try {
|
|
2022
|
-
const raw = git(dir, ["rev-parse", "--git-common-dir"]);
|
|
2023
|
-
repo = isAbsolute(raw) ? raw : resolve(dir, raw);
|
|
2024
|
-
} catch {
|
|
2025
|
-
throw new Error("not a git worktree");
|
|
2026
|
-
}
|
|
2027
|
-
const label = `archive: leftover state of worker ${id}`;
|
|
2028
|
-
if (uncommittedPaths(dir).length) {
|
|
2029
|
-
const res = salvageUncommitted(dir, label);
|
|
2030
|
-
if (res.committed.length) git(dir, [
|
|
2031
|
-
"commit",
|
|
2032
|
-
"--amend",
|
|
2033
|
-
"-q",
|
|
2034
|
-
"-m",
|
|
2035
|
-
label
|
|
2036
|
-
]);
|
|
2037
|
-
if (res.skipped.length && uncommittedPaths(dir).some((p) => !symlinkEscapesWorktree(dir, p))) throw new Error("uncommitted paths remain");
|
|
2038
|
-
}
|
|
2039
|
-
const src = worktreeBranch(id);
|
|
2040
|
-
let hasBranch = true;
|
|
2041
|
-
try {
|
|
2042
|
-
git(repo, [
|
|
2043
|
-
"rev-parse",
|
|
2044
|
-
"--verify",
|
|
2045
|
-
"-q",
|
|
2046
|
-
`refs/heads/${src}`
|
|
2047
|
-
]);
|
|
2048
|
-
} catch {
|
|
2049
|
-
hasBranch = false;
|
|
2050
|
-
}
|
|
2051
|
-
const tip = git(dir, ["rev-parse", "HEAD"]);
|
|
2052
|
-
if (hasBranch) git(repo, [
|
|
2053
|
-
"merge-base",
|
|
2054
|
-
"--is-ancestor",
|
|
2055
|
-
`refs/heads/${src}`,
|
|
2056
|
-
tip
|
|
2057
|
-
]);
|
|
2058
|
-
git(repo, [
|
|
2059
|
-
"update-ref",
|
|
2060
|
-
target,
|
|
2061
|
-
tip
|
|
2062
|
-
]);
|
|
2063
|
-
git(repo, [
|
|
2064
|
-
"worktree",
|
|
2065
|
-
"remove",
|
|
2066
|
-
"--force",
|
|
2067
|
-
dir
|
|
2068
|
-
]);
|
|
2069
|
-
if (hasBranch) git(repo, [
|
|
2070
|
-
"branch",
|
|
2071
|
-
"-D",
|
|
2072
|
-
src
|
|
2073
|
-
]);
|
|
2074
|
-
if (st) saveStatus(logDir, {
|
|
2075
|
-
...st,
|
|
2076
|
-
branch: null,
|
|
2077
|
-
worktreeDir: null,
|
|
2078
|
-
archived: target
|
|
2079
|
-
});
|
|
2080
|
-
appendLedger(ledgerPath(logDir), "WORKER-GC", {
|
|
2081
|
-
id,
|
|
2082
|
-
archive: target
|
|
2083
|
-
});
|
|
2084
|
-
out.push({
|
|
2085
|
-
id,
|
|
2086
|
-
archive: target
|
|
2087
|
-
});
|
|
2088
|
-
} catch (e) {
|
|
2089
|
-
out.push({
|
|
2090
|
-
id,
|
|
2091
|
-
skipped: e.message.split("\n")[0]
|
|
2092
|
-
});
|
|
2093
|
-
}
|
|
2094
|
-
};
|
|
2095
|
-
for (const st of loadStatuses(logDir)) {
|
|
2096
|
-
known.add(st.id);
|
|
2097
|
-
if (!st.worktreeDir || !existsSync(st.worktreeDir) || isLive(st)) continue;
|
|
2098
|
-
let end = 0;
|
|
2099
|
-
try {
|
|
2100
|
-
end = statSync(statusPath(logDir, st.id)).mtimeMs;
|
|
2101
|
-
} catch {
|
|
2102
|
-
continue;
|
|
2103
|
-
}
|
|
2104
|
-
if (end > cutoff) continue;
|
|
2105
|
-
archive(st.id, st.worktreeDir, st);
|
|
2106
|
-
}
|
|
2107
|
-
if (existsSync(root)) for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
2108
|
-
if (!ent.isDirectory() || known.has(ent.name) || existsSync(statusPath(logDir, ent.name))) continue;
|
|
2109
|
-
const dir = join(root, ent.name);
|
|
2110
|
-
try {
|
|
2111
|
-
if (statSync(dir).mtimeMs > cutoff) continue;
|
|
2112
|
-
} catch {
|
|
2113
|
-
continue;
|
|
2114
|
-
}
|
|
2115
|
-
archive(ent.name, dir, null);
|
|
2116
|
-
}
|
|
2117
|
-
if (!dryRun) for (const repo of new Set(loadStatuses(logDir).map((s) => s.cwd))) {
|
|
2118
|
-
try {
|
|
2119
|
-
git(repo, ["worktree", "prune"]);
|
|
2120
|
-
} catch {}
|
|
2121
|
-
invalidatePendingCache(logDir, repo);
|
|
2122
|
-
}
|
|
2123
|
-
return out;
|
|
2124
|
-
}
|
|
2125
|
-
/** Run `gcWorktrees` at most once per `everyMin` minutes (stamp file in the logDir). */
|
|
2126
|
-
function gcWorktreesThrottled(logDir, everyMin = 60, now = Date.now()) {
|
|
2127
|
-
const stamp = join(logDir, GC_STAMP);
|
|
2128
|
-
try {
|
|
2129
|
-
if (now - statSync(stamp).mtimeMs < everyMin * 6e4) return null;
|
|
2130
|
-
} catch {}
|
|
2131
|
-
mkdirSync(logDir, { recursive: true });
|
|
2132
|
-
writeFileSync(stamp, String(now), "utf8");
|
|
2133
|
-
return gcWorktrees(logDir, 24, false, now);
|
|
2134
|
-
}
|
|
2135
|
-
|
|
2136
569
|
//#endregion
|
|
2137
570
|
//#region src/workers/scope.ts
|
|
2138
571
|
/**
|
|
@@ -2577,183 +1010,100 @@ const PROFILE_NAME = "pai-worker";
|
|
|
2577
1010
|
function dynamicProfilePath() {
|
|
2578
1011
|
return process.env.PAI_WORKER_PROFILE ?? join(homedir(), "Library", "Application Support", "iTerm2", "DynamicProfiles", "pai-worker.json");
|
|
2579
1012
|
}
|
|
2580
|
-
const
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
repeat with part in text items of s
|
|
2668
|
-
set end of out to (part as text)
|
|
2669
|
-
end repeat
|
|
2670
|
-
set AppleScript's text item delimiters to prevDels
|
|
2671
|
-
return out
|
|
2672
|
-
end splitIds`;
|
|
2673
|
-
const TAB_TTYS_SCRIPT = `on run(argv)
|
|
2674
|
-
set targetID to item 1 of argv
|
|
2675
|
-
tell application id "com.googlecode.iterm2"
|
|
2676
|
-
if not running then return "notrunning"
|
|
2677
|
-
repeat with w in windows
|
|
2678
|
-
repeat with t in tabs of w
|
|
2679
|
-
repeat with s in sessions of t
|
|
2680
|
-
if id of s is targetID then
|
|
2681
|
-
set ttys to {}
|
|
2682
|
-
repeat with other in sessions of t
|
|
2683
|
-
copy (tty of other) to end of ttys
|
|
2684
|
-
end repeat
|
|
2685
|
-
return ttys
|
|
2686
|
-
end if
|
|
2687
|
-
end repeat
|
|
2688
|
-
end repeat
|
|
2689
|
-
end repeat
|
|
2690
|
-
end tell
|
|
2691
|
-
return "notfound"
|
|
2692
|
-
end run`;
|
|
2693
|
-
const WINDOW_BOUNDS_SCRIPT = `on run(argv)
|
|
2694
|
-
set targetID to item 1 of argv
|
|
2695
|
-
tell application id "com.googlecode.iterm2"
|
|
2696
|
-
if not running then return "notrunning"
|
|
2697
|
-
repeat with w in windows
|
|
2698
|
-
repeat with t in tabs of w
|
|
2699
|
-
repeat with s in sessions of t
|
|
2700
|
-
if id of s is targetID then
|
|
2701
|
-
copy bounds of w to winBounds
|
|
2702
|
-
set prevDels to AppleScript's text item delimiters
|
|
2703
|
-
set AppleScript's text item delimiters to ", "
|
|
2704
|
-
set out to winBounds as text
|
|
2705
|
-
set AppleScript's text item delimiters to prevDels
|
|
2706
|
-
return out
|
|
2707
|
-
end if
|
|
2708
|
-
end repeat
|
|
2709
|
-
end repeat
|
|
2710
|
-
end repeat
|
|
2711
|
-
end tell
|
|
2712
|
-
return "notfound"
|
|
2713
|
-
end run`;
|
|
2714
|
-
const SPLIT_SCRIPT = `on run(argv)
|
|
2715
|
-
set targetID to item 1 of argv
|
|
2716
|
-
set followCmd to item 2 of argv
|
|
2717
|
-
tell application id "com.googlecode.iterm2"
|
|
2718
|
-
if not running then return "notrunning"
|
|
2719
|
-
repeat with w in windows
|
|
2720
|
-
repeat with t in tabs of w
|
|
2721
|
-
repeat with s in sessions of t
|
|
2722
|
-
if id of s is targetID then
|
|
2723
|
-
-- the follow command is part of the split itself (see
|
|
2724
|
-
-- WORKER_SPLIT_SCRIPT): nothing is ever typed into a
|
|
2725
|
-
-- session afterwards
|
|
2726
|
-
tell s
|
|
2727
|
-
set newS to split vertically with default profile command followCmd
|
|
2728
|
-
end tell
|
|
2729
|
-
-- keep the tab's active session where it was (see
|
|
2730
|
-
-- WORKER_SPLIT_SCRIPT)
|
|
2731
|
-
try
|
|
2732
|
-
select s
|
|
2733
|
-
end try
|
|
2734
|
-
return "opened"
|
|
2735
|
-
end if
|
|
2736
|
-
end repeat
|
|
2737
|
-
end repeat
|
|
2738
|
-
end repeat
|
|
2739
|
-
end tell
|
|
2740
|
-
return "notfound"
|
|
2741
|
-
end run`;
|
|
2742
|
-
/**
|
|
2743
|
-
* True for the transient "every window doesn't understand the count message"
|
|
2744
|
-
* (-1708) failure iTerm throws while enumerating windows during a race with
|
|
2745
|
-
* window/session churn — seen to succeed on immediate retry. No other error
|
|
2746
|
-
* matches: a retry here only re-runs the enumeration, before any split, so it
|
|
2747
|
-
* cannot double-create a pane.
|
|
2748
|
-
*/
|
|
2749
|
-
function isRetryableIterm2Error(stderr) {
|
|
2750
|
-
return stderr.includes("every window doesn") && stderr.includes("(-1708)");
|
|
2751
|
-
}
|
|
2752
|
-
const OSASCRIPT_MAX_ATTEMPTS = 6;
|
|
2753
|
-
const OSASCRIPT_RETRY_DELAY_MS = 500;
|
|
2754
|
-
function runOsascriptOnce(script, args) {
|
|
1013
|
+
const FIND_SESSION = `function findSession(app, id) {
|
|
1014
|
+
var ws = app.windows();
|
|
1015
|
+
for (var i = 0; i < ws.length; i++) {
|
|
1016
|
+
var ts = ws[i].tabs();
|
|
1017
|
+
for (var j = 0; j < ts.length; j++) {
|
|
1018
|
+
var ss = ts[j].sessions();
|
|
1019
|
+
for (var k = 0; k < ss.length; k++) {
|
|
1020
|
+
if (ss[k].id() === id) return { w: ws[i], t: ts[j], s: ss[k], all: ss };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return null;
|
|
1025
|
+
}`;
|
|
1026
|
+
const WORKER_SPLIT_SCRIPT = `${FIND_SESSION}
|
|
1027
|
+
function run(argv) {
|
|
1028
|
+
var targetID = argv[0], candList = argv[1], followCmd = argv[2], profileName = argv[3];
|
|
1029
|
+
var app = Application("iTerm2");
|
|
1030
|
+
if (!app.running()) return "notrunning";
|
|
1031
|
+
var f = findSession(app, targetID);
|
|
1032
|
+
if (!f) return "notfound";
|
|
1033
|
+
// sizing the new session would grow the whole window: pin the window's
|
|
1034
|
+
// bounds now and restore them after the split, so the panes share the
|
|
1035
|
+
// existing space
|
|
1036
|
+
var winBounds = f.w.bounds();
|
|
1037
|
+
var sessIDs = f.all.map(function (o) { return o.id(); });
|
|
1038
|
+
var lived = [];
|
|
1039
|
+
var splitS = null;
|
|
1040
|
+
(candList === "" ? [] : candList.split(",")).forEach(function (cid) {
|
|
1041
|
+
var at = sessIDs.indexOf(cid);
|
|
1042
|
+
if (at < 0) return;
|
|
1043
|
+
lived.push(cid);
|
|
1044
|
+
if (!splitS) splitS = f.all[at];
|
|
1045
|
+
});
|
|
1046
|
+
// the follow command is part of the split itself: the pane is born already
|
|
1047
|
+
// running it, so no text is ever typed into any session — a typing step
|
|
1048
|
+
// raced the operator's keystrokes and could even land in the operator's
|
|
1049
|
+
// own session
|
|
1050
|
+
var newS;
|
|
1051
|
+
if (!splitS) {
|
|
1052
|
+
newS = profileName === ""
|
|
1053
|
+
? f.s.splitVerticallyWithDefaultProfile({ command: followCmd })
|
|
1054
|
+
: f.s.splitVertically({ withProfile: profileName, command: followCmd });
|
|
1055
|
+
} else {
|
|
1056
|
+
newS = profileName === ""
|
|
1057
|
+
? splitS.splitHorizontallyWithDefaultProfile({ command: followCmd })
|
|
1058
|
+
: splitS.splitHorizontally({ withProfile: profileName, command: followCmd });
|
|
1059
|
+
}
|
|
1060
|
+
// a split makes the new session the tab's active one: re-select the
|
|
1061
|
+
// launching session so focus returns to where the operator was typing,
|
|
1062
|
+
// never the new pane
|
|
1063
|
+
try { f.s.select(); } catch (e) {}
|
|
1064
|
+
try { f.w.bounds = winBounds; } catch (e) {}
|
|
1065
|
+
return lived.map(function (l) { return l + ","; }).join("") + "|" + newS.id();
|
|
1066
|
+
}`;
|
|
1067
|
+
const TAB_TTYS_SCRIPT = `${FIND_SESSION}
|
|
1068
|
+
function run(argv) {
|
|
1069
|
+
var app = Application("iTerm2");
|
|
1070
|
+
if (!app.running()) return "notrunning";
|
|
1071
|
+
var f = findSession(app, argv[0]);
|
|
1072
|
+
if (!f) return "notfound";
|
|
1073
|
+
return f.all.map(function (o) { return o.tty(); }).join(",");
|
|
1074
|
+
}`;
|
|
1075
|
+
const WINDOW_BOUNDS_SCRIPT = `${FIND_SESSION}
|
|
1076
|
+
function run(argv) {
|
|
1077
|
+
var app = Application("iTerm2");
|
|
1078
|
+
if (!app.running()) return "notrunning";
|
|
1079
|
+
var f = findSession(app, argv[0]);
|
|
1080
|
+
if (!f) return "notfound";
|
|
1081
|
+
var b = f.w.bounds();
|
|
1082
|
+
return [b.x, b.y, b.x + b.width, b.y + b.height].join(", ");
|
|
1083
|
+
}`;
|
|
1084
|
+
const SPLIT_SCRIPT = `${FIND_SESSION}
|
|
1085
|
+
function run(argv) {
|
|
1086
|
+
var followCmd = argv[1];
|
|
1087
|
+
var app = Application("iTerm2");
|
|
1088
|
+
if (!app.running()) return "notrunning";
|
|
1089
|
+
var f = findSession(app, argv[0]);
|
|
1090
|
+
if (!f) return "notfound";
|
|
1091
|
+
// the follow command is part of the split itself (see WORKER_SPLIT_SCRIPT):
|
|
1092
|
+
// nothing is ever typed into a session afterwards
|
|
1093
|
+
f.s.splitVerticallyWithDefaultProfile({ command: followCmd });
|
|
1094
|
+
// keep the tab's active session where it was (see WORKER_SPLIT_SCRIPT)
|
|
1095
|
+
try { f.s.select(); } catch (e) {}
|
|
1096
|
+
return "opened";
|
|
1097
|
+
}`;
|
|
1098
|
+
/** Run a JXA script with argv. Rejects when osascript itself cannot run. */
|
|
1099
|
+
function osascript(script, args) {
|
|
2755
1100
|
return new Promise((resolve, reject) => {
|
|
2756
|
-
const proc = spawn("osascript", [
|
|
1101
|
+
const proc = spawn("osascript", [
|
|
1102
|
+
"-l",
|
|
1103
|
+
"JavaScript",
|
|
1104
|
+
"-",
|
|
1105
|
+
...args
|
|
1106
|
+
], { stdio: [
|
|
2757
1107
|
"pipe",
|
|
2758
1108
|
"pipe",
|
|
2759
1109
|
"pipe"
|
|
@@ -2771,15 +1121,6 @@ function runOsascriptOnce(script, args) {
|
|
|
2771
1121
|
proc.stdin.end();
|
|
2772
1122
|
});
|
|
2773
1123
|
}
|
|
2774
|
-
/** Run an AppleScript with argv. Rejects when osascript itself cannot run. */
|
|
2775
|
-
async function osascript(script, args) {
|
|
2776
|
-
let result = await runOsascriptOnce(script, args);
|
|
2777
|
-
for (let attempt = 1; attempt < OSASCRIPT_MAX_ATTEMPTS && isRetryableIterm2Error(result.stderr); attempt++) {
|
|
2778
|
-
await new Promise((r) => setTimeout(r, OSASCRIPT_RETRY_DELAY_MS));
|
|
2779
|
-
result = await runOsascriptOnce(script, args);
|
|
2780
|
-
}
|
|
2781
|
-
return result;
|
|
2782
|
-
}
|
|
2783
1124
|
function psOutput(format) {
|
|
2784
1125
|
try {
|
|
2785
1126
|
return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
|
|
@@ -3023,37 +1364,28 @@ function dropEntry(logDir, e) {
|
|
|
3023
1364
|
writeFileSync(legacy, JSON.stringify(rest.map(({ scope: _s, ...x }) => x), null, 1), "utf8");
|
|
3024
1365
|
}
|
|
3025
1366
|
}
|
|
3026
|
-
const LIST_SESSIONS_SCRIPT = `
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
close s
|
|
3049
|
-
return "closed"
|
|
3050
|
-
end if
|
|
3051
|
-
end repeat
|
|
3052
|
-
end repeat
|
|
3053
|
-
end repeat
|
|
3054
|
-
end tell
|
|
3055
|
-
return "notfound"
|
|
3056
|
-
end run`;
|
|
1367
|
+
const LIST_SESSIONS_SCRIPT = `function run(argv) {
|
|
1368
|
+
var app = Application("iTerm2");
|
|
1369
|
+
if (!app.running()) return "";
|
|
1370
|
+
var out = "";
|
|
1371
|
+
app.windows().forEach(function (w) {
|
|
1372
|
+
w.tabs().forEach(function (t) {
|
|
1373
|
+
t.sessions().forEach(function (s) {
|
|
1374
|
+
out += s.id() + "\t" + s.tty() + "\t" + s.name() + "\n";
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
});
|
|
1378
|
+
return out;
|
|
1379
|
+
}`;
|
|
1380
|
+
const END_SESSION_SCRIPT = `${FIND_SESSION}
|
|
1381
|
+
function run(argv) {
|
|
1382
|
+
var app = Application("iTerm2");
|
|
1383
|
+
if (!app.running()) return "notrunning";
|
|
1384
|
+
var f = findSession(app, argv[0]);
|
|
1385
|
+
if (!f) return "notfound";
|
|
1386
|
+
f.s.close();
|
|
1387
|
+
return "closed";
|
|
1388
|
+
}`;
|
|
3057
1389
|
const realPaneOps = {
|
|
3058
1390
|
async listIterm() {
|
|
3059
1391
|
if (process.platform !== "darwin") return [];
|
|
@@ -3607,6 +1939,103 @@ async function handoffFromInside(logDir, env, payload, deps = {}) {
|
|
|
3607
1939
|
}), deps);
|
|
3608
1940
|
}
|
|
3609
1941
|
|
|
1942
|
+
//#endregion
|
|
1943
|
+
//#region src/workers/wait-on.ts
|
|
1944
|
+
/**
|
|
1945
|
+
* wait-on.ts — bounded wait for a detached job, the worker alternative to a
|
|
1946
|
+
* long blocking foreground call (which starves operator messages and
|
|
1947
|
+
* supervision). Recipe: `nohup sh -c 'CMD; echo $? > /tmp/job.rc' > /tmp/job.log 2>&1 & echo $! > /tmp/job.pid`,
|
|
1948
|
+
* then `pai worker wait-on $(cat /tmp/job.pid) --log /tmp/job.log` until exit 0.
|
|
1949
|
+
*
|
|
1950
|
+
* Exit codes: 0 pid gone, 3 still running at --max, 2 pid never existed or not ours.
|
|
1951
|
+
*/
|
|
1952
|
+
const WAIT_DEFAULT_SECS = 110;
|
|
1953
|
+
const WAIT_CAP_SECS = 115;
|
|
1954
|
+
const POLL_MS = 2e3;
|
|
1955
|
+
const HEARTBEAT_MS = 2e4;
|
|
1956
|
+
/** Slack between the first observation and a process start time (ps lstart has 1 s resolution). */
|
|
1957
|
+
const REUSE_SLACK_MS = 2e3;
|
|
1958
|
+
const realDeps = {
|
|
1959
|
+
probe(pid) {
|
|
1960
|
+
try {
|
|
1961
|
+
process.kill(pid, 0);
|
|
1962
|
+
return "alive";
|
|
1963
|
+
} catch (e) {
|
|
1964
|
+
return e.code === "EPERM" ? "foreign" : "gone";
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
startTime(pid) {
|
|
1968
|
+
try {
|
|
1969
|
+
const t = Date.parse(execFileSync("ps", [
|
|
1970
|
+
"-o",
|
|
1971
|
+
"lstart=",
|
|
1972
|
+
"-p",
|
|
1973
|
+
String(pid)
|
|
1974
|
+
], { encoding: "utf8" }).trim());
|
|
1975
|
+
return Number.isFinite(t) ? t : null;
|
|
1976
|
+
} catch {
|
|
1977
|
+
return null;
|
|
1978
|
+
}
|
|
1979
|
+
},
|
|
1980
|
+
now: () => Date.now(),
|
|
1981
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
1982
|
+
out: (l) => console.log(l)
|
|
1983
|
+
};
|
|
1984
|
+
const readLines = (file) => {
|
|
1985
|
+
if (!file || !existsSync(file)) return [];
|
|
1986
|
+
const t = readFileSync(file, "utf8");
|
|
1987
|
+
return t === "" ? [] : t.replace(/\n$/, "").split("\n");
|
|
1988
|
+
};
|
|
1989
|
+
function rcOf(pid, log) {
|
|
1990
|
+
for (const f of [
|
|
1991
|
+
log && `${log}.rc`,
|
|
1992
|
+
log && log.replace(/\.[^./]+$/, ".rc"),
|
|
1993
|
+
`/tmp/${pid}.rc`
|
|
1994
|
+
]) if (f && existsSync(f)) return readFileSync(f, "utf8").trim();
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
async function waitOn(o, d = realDeps) {
|
|
1998
|
+
const maxMs = Math.min(o.maxSecs ?? WAIT_DEFAULT_SECS, WAIT_CAP_SECS) * 1e3;
|
|
1999
|
+
const tail = o.tail ?? 20;
|
|
2000
|
+
const t0 = d.now();
|
|
2001
|
+
const baseLines = readLines(o.log).length;
|
|
2002
|
+
const finish = () => {
|
|
2003
|
+
const rc = rcOf(o.pid, o.log);
|
|
2004
|
+
d.out(`pid ${o.pid} gone${rc !== null ? `, exit code ${rc}` : ""}`);
|
|
2005
|
+
if (tail > 0) for (const l of readLines(o.log).slice(-tail)) d.out(l);
|
|
2006
|
+
return 0;
|
|
2007
|
+
};
|
|
2008
|
+
if (!Number.isInteger(o.pid) || o.pid <= 0) {
|
|
2009
|
+
d.out(`wait-on: invalid pid ${o.pid}`);
|
|
2010
|
+
return 2;
|
|
2011
|
+
}
|
|
2012
|
+
const first = d.probe(o.pid);
|
|
2013
|
+
if (first === "foreign") {
|
|
2014
|
+
d.out(`wait-on: pid ${o.pid} is not owned by this user`);
|
|
2015
|
+
return 2;
|
|
2016
|
+
}
|
|
2017
|
+
if (first === "gone") {
|
|
2018
|
+
if (rcOf(o.pid, o.log) !== null || o.log && existsSync(o.log)) return finish();
|
|
2019
|
+
d.out(`wait-on: pid ${o.pid} does not exist`);
|
|
2020
|
+
return 2;
|
|
2021
|
+
}
|
|
2022
|
+
let nextBeat = t0 + HEARTBEAT_MS;
|
|
2023
|
+
while (d.now() - t0 < maxMs) {
|
|
2024
|
+
await d.sleep(Math.min(POLL_MS, Math.max(0, maxMs - (d.now() - t0))));
|
|
2025
|
+
const st = d.startTime(o.pid);
|
|
2026
|
+
if (d.probe(o.pid) !== "alive" || st !== null && st > t0 + REUSE_SLACK_MS) return finish();
|
|
2027
|
+
if (d.now() >= nextBeat) {
|
|
2028
|
+
d.out(`running ${Math.round((d.now() - t0) / 1e3)}s, log +${readLines(o.log).length - baseLines} lines`);
|
|
2029
|
+
nextBeat += HEARTBEAT_MS;
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
d.out(`still running after ${Math.round((d.now() - t0) / 1e3)}s (pid ${o.pid}); call wait-on again`);
|
|
2033
|
+
if (tail > 0) for (const l of readLines(o.log).slice(-tail)) d.out(l);
|
|
2034
|
+
return 3;
|
|
2035
|
+
}
|
|
2036
|
+
/** The documented long-job recipe, quoted by the worker contract and every deny message. */
|
|
2037
|
+
const WAIT_RECIPE = "`nohup sh -c 'CMD; echo $? > /tmp/job.rc' > /tmp/job.log 2>&1 & echo $! > /tmp/job.pid`, then repeat `pai worker wait-on $(cat /tmp/job.pid) --log /tmp/job.log` (each call returns within 2 min; do other work between calls) until it exits 0";
|
|
2038
|
+
|
|
3610
2039
|
//#endregion
|
|
3611
2040
|
//#region src/workers/agentish.ts
|
|
3612
2041
|
/**
|
|
@@ -3894,8 +2323,8 @@ function fullContractPrompt(format) {
|
|
|
3894
2323
|
"Never sleep-poll: no `sleep N` over 60 seconds, anywhere in a command — standalone, after `&&`/`||`/",
|
|
3895
2324
|
"`;`/`|`, or inside a loop or subshell — and the sleeps of one command add up (`sleep 58; sleep 58` is",
|
|
3896
2325
|
"116 s). Ending your turn ends this process, so a background job gets no later wake-up here:",
|
|
3897
|
-
"run_in_background is denied.
|
|
3898
|
-
|
|
2326
|
+
"run_in_background is denied. Foreground Bash calls stay under 2 min (longer ones block operator messages and supervision and are denied);",
|
|
2327
|
+
`run a long job detached: ${WAIT_RECIPE}.`,
|
|
3899
2328
|
"Kill only processes you started, by PID (`cmd & echo $! > /tmp/x.pid`, then `kill $(cat /tmp/x.pid)`); never `pkill`/`killall`/`kill $(pgrep ...)` — other workers' command lines contain your spec text.",
|
|
3900
2329
|
...fileNotInlineBlock(),
|
|
3901
2330
|
"",
|
|
@@ -4581,7 +3010,7 @@ async function runWorker(opts) {
|
|
|
4581
3010
|
if (gc?.length) process.stderr.write(`pai worker: archived ${gc.length} leftover worktree(s) (pai worker gc)\n`);
|
|
4582
3011
|
} catch {}
|
|
4583
3012
|
if (opts.className === "plan" && !opts._planner) {
|
|
4584
|
-
const { runPlanner } = await import("./planner-
|
|
3013
|
+
const { runPlanner } = await import("./planner-CspP59ya.mjs");
|
|
4585
3014
|
return runPlanner(opts);
|
|
4586
3015
|
}
|
|
4587
3016
|
const parent = launchParent(opts.parent);
|
|
@@ -5840,5 +4269,5 @@ function resultFromOutput(out) {
|
|
|
5840
4269
|
}
|
|
5841
4270
|
|
|
5842
4271
|
//#endregion
|
|
5843
|
-
export {
|
|
5844
|
-
//# sourceMappingURL=run-
|
|
4272
|
+
export { ensureProxyRunning as A, itermForClaudeSession as C, sessionTag as D, sessionMapPath as E, resolveTarget as F, pinNotices as I, projectLaunchConfig as L, clearCooldown as M, probeQuota as N, workerInScope as O, quotaSkipThreshold as P, describeMcp as R, currentTabKey as S, resolveSession as T, clickrControlsArgv as _, deadlineLabel as a, endLabel as b, waitOn as c, sayToWorker as d, workerDepth as f, openPaneForWorker as g, openFollowPane as h, usageContextTokens as i, stopProxy as j, DEFAULT_PROXY_PORT as k, handoffFromInside as l, closeEndedPanes as m, runWorker as n, parseWorkerReport as o, checkPaneForWorker as p, testProvider as r, renderReport as s, printResult as t, readInbox as u, markControlsHeld as v, itermUuid as w, requestKill as x, callerDescription as y, expandMcpNames as z };
|
|
4273
|
+
//# sourceMappingURL=run-BZNdpUGQ.mjs.map
|