@tekmidian/pai 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
- package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
- package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
- package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
- package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
- package/dist/cli/index.mjs +14 -14
- package/dist/cli/program.mjs +14 -14
- package/dist/config-Bn9jpblO.mjs +653 -0
- package/dist/config-Bn9jpblO.mjs.map +1 -0
- package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
- package/dist/config-DYXARgk8.mjs.map +1 -0
- package/dist/config-RjeVGJ7K.mjs +3 -0
- package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
- package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
- package/dist/daemon/index.mjs +13 -12
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
- package/dist/daemon-CBTQYtHF.mjs.map +1 -0
- package/dist/daemon-Vf7pHnjR.mjs +25 -0
- package/dist/daemon-mcp/index.mjs +186 -23
- package/dist/daemon-mcp/index.mjs.map +1 -1
- package/dist/factory-Ba8j9K7q.mjs +3 -0
- package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
- package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
- package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
- package/dist/fallback-CIGspCSi.mjs.map +1 -0
- package/dist/hooks/capture-all-events.mjs +80 -8
- package/dist/hooks/capture-all-events.mjs.map +4 -4
- package/dist/hooks/context-compression-hook.mjs +54 -39
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/hooks/post-compact-inject.mjs +54 -39
- package/dist/hooks/post-compact-inject.mjs.map +4 -4
- package/dist/hooks/route-agents-to-worker.mjs +204 -109
- package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
- package/dist/hooks/whisper-rules.mjs +7613 -9
- package/dist/hooks/whisper-rules.mjs.map +4 -4
- package/dist/hooks/worker-proxy.mjs +204 -109
- package/dist/hooks/worker-proxy.mjs.map +4 -4
- package/dist/hooks/worker-status-line.mjs +211 -116
- package/dist/hooks/worker-status-line.mjs.map +4 -4
- package/dist/hooks/worker-supervision.mjs +208 -113
- package/dist/hooks/worker-supervision.mjs.map +4 -4
- package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
- package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
- package/dist/main-resolver-BTastpp4.mjs +7 -0
- package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
- package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
- package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
- package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
- package/dist/{program-Bgv1lFFA.mjs → program-BX67-4I2.mjs} +536 -260
- package/dist/program-BX67-4I2.mjs.map +1 -0
- package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
- package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
- package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
- package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
- package/dist/run-TChOnMO_.mjs.map +1 -0
- package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
- package/dist/server-R9UkCn3v.mjs.map +1 -0
- package/dist/session-keepalive-BuG3wvik.mjs +579 -0
- package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
- package/dist/skills/VaultContext/SKILL.md +1 -1
- package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
- package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
- package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
- package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
- package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
- package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
- package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
- package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
- package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
- package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
- package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
- package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
- package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
- package/docs/cache-keepalive.md +138 -0
- package/docs/commands/README.md +7 -0
- package/docs/commands/audit.md +1 -0
- package/docs/commands/config.md +84 -0
- package/docs/commands/daemon.md +6 -0
- package/docs/commands/worker.md +37 -2
- package/docs/config.md +171 -0
- package/docs/model-catalogue.md +502 -0
- package/docs/provider-independence-details.md +1 -1
- package/docs/task-bus.md +1 -1
- package/docs/worker.md +33 -5
- package/docs/workers-config.md +105 -12
- package/package.json +1 -1
- package/src/hooks/ts/capture-all-events.test.ts +71 -0
- package/src/hooks/ts/capture-all-events.ts +10 -3
- package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
- package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
- package/statusline-command.sh +81 -6
- package/dist/config-DCu8THKi.mjs +0 -228
- package/dist/config-DCu8THKi.mjs.map +0 -1
- package/dist/config-DeqFqa1N.mjs +0 -3
- package/dist/config-DxMZjpvY.mjs.map +0 -1
- package/dist/daemon-CYpVy_Dw.mjs +0 -24
- package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
- package/dist/factory-47Qf_df7.mjs +0 -3
- package/dist/fallback-IeR2FrFn.mjs.map +0 -1
- package/dist/indexer-C5Le7XE7.mjs +0 -1
- package/dist/main-resolver-CnfnWa8C.mjs +0 -7
- package/dist/program-Bgv1lFFA.mjs.map +0 -1
- package/dist/query-feedback-C7rSfiXU.mjs +0 -3
- package/dist/run-Cls0YGgV.mjs.map +0 -1
- package/dist/server-Ch99xt5K.mjs.map +0 -1
- package/dist/work-queue-worker-DLM2wlqM.mjs +0 -14
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as openRegistry, r as registryDbPath } from "./db-Ddns80yf.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { _ as
|
|
2
|
+
import { C as readWorkersSection, E as resolveProviderKey, S as providerKeyPath, T as resolveModelCapability, _ as isModelCapability, d as WorkersConfigError, f as assertProviderRunnable, p as classModelCapability } from "./config-DYXARgk8.mjs";
|
|
3
|
+
import { _ as panesDir, a as capabilityForRun, c as nextAutoProvider, d as resolveCapabilityRun, f as resolveTarget, g as ledgerPath, h as eventsPath, i as buildRunEnv, m as ensureNoMcpConfig, n as ensureProxyRunning, p as setCooldown, s as isQuotaFailure, t as DEFAULT_PROXY_PORT, v as statusPath, y as workersLogDir } from "./server-R9UkCn3v.mjs";
|
|
4
4
|
import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
5
5
|
import { homedir, tmpdir } from "node:os";
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -538,1328 +538,1401 @@ function ledgerSummary(path, scope, lastN = 15, now = /* @__PURE__ */ new Date()
|
|
|
538
538
|
}
|
|
539
539
|
|
|
540
540
|
//#endregion
|
|
541
|
-
//#region src/workers/
|
|
541
|
+
//#region src/workers/worktree.ts
|
|
542
542
|
/**
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
* Two identities, in priority order:
|
|
543
|
+
* worktree.ts — one git worktree per writing worker.
|
|
546
544
|
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* original heuristic, kept as the fallback when no registry entry
|
|
553
|
-
* matches (AIBroker absent, unnamed session, non-iTerm terminal).
|
|
545
|
+
* A run whose class edits files (implement, complex, plan) and whose cwd is a
|
|
546
|
+
* git repository gets its own worktree by default: `git worktree add
|
|
547
|
+
* <logDir>/worktrees/<id> -b worker/<id>` from the current HEAD. The worker
|
|
548
|
+
* commits its own work on that branch — the no-commit rule applies to the
|
|
549
|
+
* main branch only — and the parent (or the operator) merges the result back:
|
|
554
550
|
*
|
|
555
|
-
*
|
|
551
|
+
* pai worker merge <id> git merge --no-ff worker/<id> + remove worktree + delete branch
|
|
552
|
+
* pai worker discard <id> remove worktree and branch, keep nothing
|
|
556
553
|
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
* session identity to its Bash children. Those record `spawnerSession`, the
|
|
560
|
-
* orchestrator's claude session id bridged through the status line's
|
|
561
|
-
* session map (see below), so their tab still claims them.
|
|
562
|
-
*/
|
|
563
|
-
const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
|
|
564
|
-
/**
|
|
565
|
-
* `w<window>t<tab>` prefix of an iTerm session id, "" if empty or malformed.
|
|
566
|
-
* Panes split from the same tab share this prefix and differ only in `p<n>`.
|
|
554
|
+
* `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
|
|
555
|
+
* place; `--no-worktree` opts out, `--worktree` forces one on.
|
|
567
556
|
*/
|
|
568
|
-
function
|
|
569
|
-
|
|
570
|
-
const head = term.split("p", 1)[0];
|
|
571
|
-
const parts = head.slice(1).split("t");
|
|
572
|
-
if (head.startsWith("w") && parts.length === 2 && parts.every((p) => /^\d+$/.test(p))) return head;
|
|
573
|
-
return "";
|
|
557
|
+
function worktreesDir(logDir) {
|
|
558
|
+
return join(logDir, "worktrees");
|
|
574
559
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return tabKey(env.ITERM_SESSION_ID ?? "");
|
|
560
|
+
function worktreeBranch(id) {
|
|
561
|
+
return `worker/${id}`;
|
|
578
562
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
if (!term) return "";
|
|
582
|
-
return term.split(":").pop() ?? "";
|
|
563
|
+
function worktreePath(logDir, id) {
|
|
564
|
+
return join(worktreesDir(logDir), id);
|
|
583
565
|
}
|
|
584
|
-
/**
|
|
585
|
-
|
|
586
|
-
* name registry (the same store `aibroker_rename` writes); returns null when
|
|
587
|
-
* AIBroker is absent or the session is not in it — callers then use the tab
|
|
588
|
-
* key, which is the pre-AIBroker behaviour.
|
|
589
|
-
*/
|
|
590
|
-
function resolveSession(term, registryPath = AIBROKER_REGISTRY) {
|
|
591
|
-
const uuid = itermUuid(term);
|
|
592
|
-
if (!uuid) return null;
|
|
593
|
-
let names;
|
|
566
|
+
/** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
|
|
567
|
+
function git(cwd, args) {
|
|
594
568
|
try {
|
|
595
|
-
|
|
596
|
-
|
|
569
|
+
return execFileSync("git", [
|
|
570
|
+
"-C",
|
|
571
|
+
cwd,
|
|
572
|
+
...args
|
|
573
|
+
], {
|
|
574
|
+
encoding: "utf8",
|
|
575
|
+
timeout: 3e4,
|
|
576
|
+
stdio: [
|
|
577
|
+
"ignore",
|
|
578
|
+
"pipe",
|
|
579
|
+
"pipe"
|
|
580
|
+
]
|
|
581
|
+
}).trim();
|
|
582
|
+
} catch (e) {
|
|
583
|
+
const err = e;
|
|
584
|
+
const why = (typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e);
|
|
585
|
+
throw new Error(`git ${args.join(" ")} in ${cwd}: ${why.trim()}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
|
|
589
|
+
function isGitRepo(cwd) {
|
|
590
|
+
try {
|
|
591
|
+
return git(cwd, ["rev-parse", "--git-dir"]) !== "";
|
|
597
592
|
} catch {
|
|
598
|
-
return
|
|
593
|
+
return false;
|
|
599
594
|
}
|
|
600
|
-
const name = names[uuid];
|
|
601
|
-
if (typeof name !== "string" || !name) return null;
|
|
602
|
-
return {
|
|
603
|
-
id: uuid,
|
|
604
|
-
name
|
|
605
|
-
};
|
|
606
595
|
}
|
|
607
596
|
/**
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
597
|
+
* Does the prompt read as a read-only task? Writing classes default to a
|
|
598
|
+
* worktree; a prompt that only asks to look at things should not pay for one.
|
|
599
|
+
* First-word verbs plus the explicit markers people actually write.
|
|
611
600
|
*/
|
|
612
|
-
function
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if (
|
|
616
|
-
return
|
|
601
|
+
function promptLooksReadonly(prompt) {
|
|
602
|
+
const p = prompt.trim();
|
|
603
|
+
if (!p) return true;
|
|
604
|
+
if (/\b(read[- ]only|do not (modify|change|edit|write)|don'?t (modify|change|edit|write)|no changes)\b/i.test(p)) return true;
|
|
605
|
+
return /^(review|read|analy[sz]e|research|summar[iy]|inspect|investigate|spotcheck|report|find|list|check|verify|describe|explain|show)\b/i.test(p);
|
|
606
|
+
}
|
|
607
|
+
/** The classes whose runs write files and therefore default to a worktree. */
|
|
608
|
+
const WORKTREE_CLASSES = [
|
|
609
|
+
"implement",
|
|
610
|
+
"complex",
|
|
611
|
+
"plan"
|
|
612
|
+
];
|
|
613
|
+
/** Should this run get a worktree? Explicit flag first, then the default rule. */
|
|
614
|
+
function worktreeWanted(flag, opts) {
|
|
615
|
+
if (flag !== void 0) return flag;
|
|
616
|
+
if (!opts.className || !WORKTREE_CLASSES.includes(opts.className)) return false;
|
|
617
|
+
if (!isGitRepo(opts.cwd)) return false;
|
|
618
|
+
return !promptLooksReadonly(opts.prompt ?? "");
|
|
617
619
|
}
|
|
618
620
|
/**
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
+
* Create the worktree and branch for `id` from `cwd`'s HEAD. Throws when git
|
|
622
|
+
* refuses (no commits yet, branch exists, …) — the caller decides whether to
|
|
623
|
+
* degrade to an in-place run.
|
|
621
624
|
*/
|
|
622
|
-
function
|
|
623
|
-
const
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
625
|
+
function addWorktree(logDir, id, cwd) {
|
|
626
|
+
const swept = sweepOrphanWorktrees(logDir);
|
|
627
|
+
if (swept.length) appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
|
|
628
|
+
id,
|
|
629
|
+
note: `swept orphan worktree(s): ${swept.join(", ")}`
|
|
630
|
+
});
|
|
631
|
+
const dir = worktreePath(logDir, id);
|
|
632
|
+
const branch = worktreeBranch(id);
|
|
633
|
+
const base = git(cwd, ["rev-parse", "HEAD"]);
|
|
634
|
+
git(cwd, [
|
|
635
|
+
"worktree",
|
|
636
|
+
"add",
|
|
637
|
+
dir,
|
|
638
|
+
"-b",
|
|
639
|
+
branch
|
|
640
|
+
]);
|
|
641
|
+
return {
|
|
642
|
+
dir,
|
|
643
|
+
branch,
|
|
644
|
+
base
|
|
645
|
+
};
|
|
637
646
|
}
|
|
638
647
|
/**
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
648
|
+
* Remove worktrees (and their `worker/<id>` branches) whose worker no longer
|
|
649
|
+
* exists — no status file left in the log dir. Killed and failed runs clean
|
|
650
|
+
* up after themselves, but a `kill -9` or a crash strands a directory and a
|
|
651
|
+
* branch; every worktree creation sweeps first so they cannot accumulate.
|
|
652
|
+
* Directories younger than `minAgeMin` minutes are left alone: a run that is
|
|
653
|
+
* just starting owns its worktree a moment before its status file exists.
|
|
642
654
|
*/
|
|
643
|
-
function
|
|
644
|
-
const
|
|
645
|
-
if (
|
|
646
|
-
|
|
647
|
-
|
|
655
|
+
function sweepOrphanWorktrees(logDir, minAgeMin = 10) {
|
|
656
|
+
const root = worktreesDir(logDir);
|
|
657
|
+
if (!existsSync(root)) return [];
|
|
658
|
+
const swept = [];
|
|
659
|
+
for (const ent of readdirSync(root, { withFileTypes: true })) {
|
|
660
|
+
if (!ent.isDirectory()) continue;
|
|
661
|
+
const id = ent.name;
|
|
662
|
+
const dir = join(root, id);
|
|
663
|
+
if (existsSync(statusPath(logDir, id))) continue;
|
|
664
|
+
try {
|
|
665
|
+
if ((Date.now() - statSync(dir).mtimeMs) / 6e4 < minAgeMin) continue;
|
|
666
|
+
} catch {}
|
|
667
|
+
let gitDir = null;
|
|
668
|
+
try {
|
|
669
|
+
const raw = git(dir, ["rev-parse", "--git-common-dir"]);
|
|
670
|
+
gitDir = isAbsolute(raw) ? raw : resolve(dir, raw);
|
|
671
|
+
} catch {
|
|
672
|
+
gitDir = null;
|
|
673
|
+
}
|
|
674
|
+
removeWorktree(gitDir ?? dir, dir, true);
|
|
675
|
+
if (gitDir) try {
|
|
676
|
+
git(gitDir, ["worktree", "prune"]);
|
|
677
|
+
git(gitDir, [
|
|
678
|
+
"branch",
|
|
679
|
+
"-D",
|
|
680
|
+
worktreeBranch(id)
|
|
681
|
+
]);
|
|
682
|
+
} catch {}
|
|
683
|
+
swept.push(id);
|
|
648
684
|
}
|
|
649
|
-
|
|
650
|
-
if (!existsSync(path) || !cwd) return null;
|
|
651
|
-
try {
|
|
652
|
-
const entry = JSON.parse(readFileSync(path, "utf8"))[cwd];
|
|
653
|
-
if (entry && entry.session && now - entry.ts < SPAWNER_SESSION_TTL_MS) return entry.session;
|
|
654
|
-
} catch {}
|
|
655
|
-
return null;
|
|
685
|
+
return swept;
|
|
656
686
|
}
|
|
657
|
-
/**
|
|
658
|
-
|
|
659
|
-
* map knows one: the freshest entry naming that session. This is the reverse
|
|
660
|
-
* of resolveSpawnerSession — worker → orchestrator there, orchestrator →
|
|
661
|
-
* terminal here — and it exists so daemon-side pushes (supervision) can reach
|
|
662
|
-
* a session that has no iTerm identity of its own in any worker status.
|
|
663
|
-
*/
|
|
664
|
-
function itermForClaudeSession(logDir, claudeSession, now = Date.now()) {
|
|
665
|
-
const path = sessionMapPath(logDir);
|
|
666
|
-
if (!existsSync(path) || !claudeSession) return null;
|
|
667
|
-
let map;
|
|
687
|
+
/** Commits the branch collected on top of its base. */
|
|
688
|
+
function commitsSince(dir, base) {
|
|
668
689
|
try {
|
|
669
|
-
|
|
690
|
+
return parseInt(git(dir, [
|
|
691
|
+
"rev-list",
|
|
692
|
+
"--count",
|
|
693
|
+
`${base}..HEAD`
|
|
694
|
+
]), 10) || 0;
|
|
670
695
|
} catch {
|
|
671
|
-
return
|
|
672
|
-
}
|
|
673
|
-
let best = null;
|
|
674
|
-
for (const e of Object.values(map)) {
|
|
675
|
-
if (e.session !== claudeSession || !e.term) continue;
|
|
676
|
-
if (now - e.ts >= SESSION_MAP_PRUNE_MS) continue;
|
|
677
|
-
if (!best || e.ts > best.ts) best = e;
|
|
696
|
+
return 0;
|
|
678
697
|
}
|
|
679
|
-
const term = best?.term;
|
|
680
|
-
return term ? itermUuid(term) : null;
|
|
681
698
|
}
|
|
682
|
-
|
|
683
|
-
//#endregion
|
|
684
|
-
//#region src/workers/pane.ts
|
|
685
699
|
/**
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
* One small-font pane per worker, stacked in a right-hand column: the first
|
|
689
|
-
* worker of a scope splits the launching session vertically, every further
|
|
690
|
-
* one splits the lowest live worker pane horizontally, so panes stack top to
|
|
691
|
-
* bottom. The split never sizes the new session — that would grow the whole
|
|
692
|
-
* window; instead the window's bounds are read before the split and restored
|
|
693
|
-
* right after, so the panes share the space the window already had. The follow
|
|
694
|
-
* command is part of the split itself — iTerm creates the new session already
|
|
695
|
-
* running it — so no text is ever typed into any session afterwards; a separate
|
|
696
|
-
* typing step raced the operator's keystrokes (focus briefly sits on the new
|
|
697
|
-
* pane after a split, and in one observed failure the command landed in the
|
|
698
|
-
* operator's own shell). A split still makes the new session the tab's active
|
|
699
|
-
* one — the scripts re-select the launching session right after, so a pane
|
|
700
|
-
* opening never steals the keystrokes the operator is typing. Each
|
|
701
|
-
* pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
|
|
702
|
-
* dynamic profile (Close Sessions On End), so panes disappear by themselves.
|
|
703
|
-
*
|
|
704
|
-
* Panes are tracked per scope (AIBroker session id, else tab key) in
|
|
705
|
-
* <logDir>/panes/<key>.json, keyed by iTerm session unique id, newest first —
|
|
706
|
-
* the split lands below the lowest live worker pane.
|
|
707
|
-
*
|
|
708
|
-
* The dynamic profile's font is the family of iTerm's DEFAULT profile (the
|
|
709
|
-
* `Default Bookmark Guid` entry in New Bookmarks) at workers.pane.fontSize —
|
|
710
|
-
* never the launching session's profile, which the Python version did and
|
|
711
|
-
* which produced iTerm's "unknown parent name" dialog whenever the two
|
|
712
|
-
* differed. `Dynamic Profile Parent Name` is only written when that name
|
|
713
|
-
* actually exists in New Bookmarks; when iTerm's preferences cannot be read
|
|
714
|
-
* the profile is still written, with font Menlo-Regular and no parent.
|
|
700
|
+
* Record the worktree result in the status file: branch and commit count on
|
|
701
|
+
* success, the worktree cleaned up on failure. Returns the updated status.
|
|
715
702
|
*/
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
-- reference lazily, so restoring it would re-read the
|
|
737
|
-
-- post-split bounds instead of these — observed as the
|
|
738
|
-
-- window jumping to the main display
|
|
739
|
-
copy bounds of w to winBounds
|
|
740
|
-
set sessIDs to {}
|
|
741
|
-
repeat with other in sessions of t
|
|
742
|
-
set end of sessIDs to (id of other as text)
|
|
743
|
-
end repeat
|
|
744
|
-
set lived to {}
|
|
745
|
-
set splitS to missing value
|
|
746
|
-
repeat with cid in my splitIds(candList)
|
|
747
|
-
set cidText to (cid as text)
|
|
748
|
-
if sessIDs contains cidText then
|
|
749
|
-
set end of lived to cidText
|
|
750
|
-
if splitS is missing value then
|
|
751
|
-
set splitS to first session of t whose id is cidText
|
|
752
|
-
end if
|
|
753
|
-
end if
|
|
754
|
-
end repeat
|
|
755
|
-
-- the follow command is part of the split itself: the
|
|
756
|
-
-- pane is born already running it, so no text is ever
|
|
757
|
-
-- typed into any session — a typing step raced the
|
|
758
|
-
-- operator's keystrokes and could even land in the
|
|
759
|
-
-- operator's own session
|
|
760
|
-
if profileName is "" then
|
|
761
|
-
if splitS is missing value then
|
|
762
|
-
tell s
|
|
763
|
-
set newS to split vertically with default profile command followCmd
|
|
764
|
-
end tell
|
|
765
|
-
else
|
|
766
|
-
tell splitS
|
|
767
|
-
set newS to split horizontally with default profile command followCmd
|
|
768
|
-
end tell
|
|
769
|
-
end if
|
|
770
|
-
else
|
|
771
|
-
if splitS is missing value then
|
|
772
|
-
tell s
|
|
773
|
-
set newS to split vertically with profile profileName command followCmd
|
|
774
|
-
end tell
|
|
775
|
-
else
|
|
776
|
-
tell splitS
|
|
777
|
-
set newS to split horizontally with profile profileName command followCmd
|
|
778
|
-
end tell
|
|
779
|
-
end if
|
|
780
|
-
end if
|
|
781
|
-
-- a split makes the new session the tab's active one:
|
|
782
|
-
-- re-select the launching session so focus returns to
|
|
783
|
-
-- where the operator was typing, never the new pane
|
|
784
|
-
try
|
|
785
|
-
select s
|
|
786
|
-
end try
|
|
787
|
-
try
|
|
788
|
-
set bounds of w to winBounds
|
|
789
|
-
end try
|
|
790
|
-
set out to ""
|
|
791
|
-
repeat with lid in lived
|
|
792
|
-
set out to out & lid & ","
|
|
793
|
-
end repeat
|
|
794
|
-
return out & "|" & (id of newS as text)
|
|
795
|
-
end if
|
|
796
|
-
end repeat
|
|
797
|
-
end repeat
|
|
798
|
-
end repeat
|
|
799
|
-
end tell
|
|
800
|
-
return "notfound"
|
|
801
|
-
end run
|
|
802
|
-
|
|
803
|
-
on splitIds(s)
|
|
804
|
-
set out to {}
|
|
805
|
-
if s is "" then return out
|
|
806
|
-
set prevDels to AppleScript's text item delimiters
|
|
807
|
-
set AppleScript's text item delimiters to ","
|
|
808
|
-
repeat with part in text items of s
|
|
809
|
-
set end of out to (part as text)
|
|
810
|
-
end repeat
|
|
811
|
-
set AppleScript's text item delimiters to prevDels
|
|
812
|
-
return out
|
|
813
|
-
end splitIds`;
|
|
814
|
-
const TAB_TTYS_SCRIPT = `on run(argv)
|
|
815
|
-
set targetID to item 1 of argv
|
|
816
|
-
tell application id "com.googlecode.iterm2"
|
|
817
|
-
if not running then return "notrunning"
|
|
818
|
-
repeat with w in windows
|
|
819
|
-
repeat with t in tabs of w
|
|
820
|
-
repeat with s in sessions of t
|
|
821
|
-
if id of s is targetID then
|
|
822
|
-
set ttys to {}
|
|
823
|
-
repeat with other in sessions of t
|
|
824
|
-
copy (tty of other) to end of ttys
|
|
825
|
-
end repeat
|
|
826
|
-
return ttys
|
|
827
|
-
end if
|
|
828
|
-
end repeat
|
|
829
|
-
end repeat
|
|
830
|
-
end repeat
|
|
831
|
-
end tell
|
|
832
|
-
return "notfound"
|
|
833
|
-
end run`;
|
|
834
|
-
const WINDOW_BOUNDS_SCRIPT = `on run(argv)
|
|
835
|
-
set targetID to item 1 of argv
|
|
836
|
-
tell application id "com.googlecode.iterm2"
|
|
837
|
-
if not running then return "notrunning"
|
|
838
|
-
repeat with w in windows
|
|
839
|
-
repeat with t in tabs of w
|
|
840
|
-
repeat with s in sessions of t
|
|
841
|
-
if id of s is targetID then
|
|
842
|
-
copy bounds of w to winBounds
|
|
843
|
-
set prevDels to AppleScript's text item delimiters
|
|
844
|
-
set AppleScript's text item delimiters to ", "
|
|
845
|
-
set out to winBounds as text
|
|
846
|
-
set AppleScript's text item delimiters to prevDels
|
|
847
|
-
return out
|
|
848
|
-
end if
|
|
849
|
-
end repeat
|
|
850
|
-
end repeat
|
|
851
|
-
end repeat
|
|
852
|
-
end tell
|
|
853
|
-
return "notfound"
|
|
854
|
-
end run`;
|
|
855
|
-
const SPLIT_SCRIPT = `on run(argv)
|
|
856
|
-
set targetID to item 1 of argv
|
|
857
|
-
set followCmd to item 2 of argv
|
|
858
|
-
tell application id "com.googlecode.iterm2"
|
|
859
|
-
if not running then return "notrunning"
|
|
860
|
-
repeat with w in windows
|
|
861
|
-
repeat with t in tabs of w
|
|
862
|
-
repeat with s in sessions of t
|
|
863
|
-
if id of s is targetID then
|
|
864
|
-
-- the follow command is part of the split itself (see
|
|
865
|
-
-- WORKER_SPLIT_SCRIPT): nothing is ever typed into a
|
|
866
|
-
-- session afterwards
|
|
867
|
-
tell s
|
|
868
|
-
set newS to split vertically with default profile command followCmd
|
|
869
|
-
end tell
|
|
870
|
-
-- keep the tab's active session where it was (see
|
|
871
|
-
-- WORKER_SPLIT_SCRIPT)
|
|
872
|
-
try
|
|
873
|
-
select s
|
|
874
|
-
end try
|
|
875
|
-
return "opened"
|
|
876
|
-
end if
|
|
877
|
-
end repeat
|
|
878
|
-
end repeat
|
|
879
|
-
end repeat
|
|
880
|
-
end tell
|
|
881
|
-
return "notfound"
|
|
882
|
-
end run`;
|
|
883
|
-
/** Run an AppleScript with argv. Rejects when osascript itself cannot run. */
|
|
884
|
-
function osascript(script, args) {
|
|
885
|
-
return new Promise((resolve, reject) => {
|
|
886
|
-
const proc = spawn("osascript", ["-", ...args], { stdio: [
|
|
887
|
-
"pipe",
|
|
888
|
-
"pipe",
|
|
889
|
-
"pipe"
|
|
890
|
-
] });
|
|
891
|
-
let out = "";
|
|
892
|
-
let err = "";
|
|
893
|
-
proc.stdout.on("data", (c) => out += c.toString("utf8"));
|
|
894
|
-
proc.stderr.on("data", (c) => err += c.toString("utf8"));
|
|
895
|
-
proc.on("error", reject);
|
|
896
|
-
proc.on("close", () => resolve({
|
|
897
|
-
stdout: out,
|
|
898
|
-
stderr: err
|
|
899
|
-
}));
|
|
900
|
-
proc.stdin.write(script);
|
|
901
|
-
proc.stdin.end();
|
|
902
|
-
});
|
|
903
|
-
}
|
|
904
|
-
function psOutput(format) {
|
|
905
|
-
try {
|
|
906
|
-
return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
|
|
907
|
-
} catch {
|
|
908
|
-
return "";
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
/** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
|
|
912
|
-
function followTtys() {
|
|
913
|
-
const ttys = /* @__PURE__ */ new Set();
|
|
914
|
-
for (const line of psOutput("tty=,command=").split("\n")) {
|
|
915
|
-
const parts = line.trim().split(/\s+/, 2);
|
|
916
|
-
if (parts.length < 2 || parts[0] === "ps") continue;
|
|
917
|
-
if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
|
|
918
|
-
if (parts[0].startsWith("ttys")) ttys.add(`/dev/${parts[0]}`);
|
|
703
|
+
function recordWorktree(logDir, status, info, ok) {
|
|
704
|
+
const s = { ...status };
|
|
705
|
+
if (ok) {
|
|
706
|
+
s.branch = info.branch;
|
|
707
|
+
s.commits = commitsSince(info.dir, info.base);
|
|
708
|
+
s.worktreeDir = info.dir;
|
|
709
|
+
s.worktreeBase = info.base;
|
|
710
|
+
} else {
|
|
711
|
+
removeWorktree(s.cwd, info.dir, true);
|
|
712
|
+
try {
|
|
713
|
+
git(s.cwd, [
|
|
714
|
+
"branch",
|
|
715
|
+
"-D",
|
|
716
|
+
info.branch
|
|
717
|
+
]);
|
|
718
|
+
} catch {}
|
|
719
|
+
s.branch = null;
|
|
720
|
+
s.worktreeDir = null;
|
|
721
|
+
s.worktreeBase = null;
|
|
722
|
+
s.commits = null;
|
|
919
723
|
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
/** True while a `follow <wid>` process runs (its pane shows that worker). */
|
|
923
|
-
function workerPaneOpen(wid) {
|
|
924
|
-
const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
925
|
-
return psOutput("command=").split("\n").some((ln) => pat.test(ln));
|
|
926
|
-
}
|
|
927
|
-
/** plutil's message for an ExecFileSync failure, else the exception text. */
|
|
928
|
-
function toolErr(e) {
|
|
929
|
-
const err = e;
|
|
930
|
-
return ((typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e)).trim();
|
|
724
|
+
saveStatus(logDir, s);
|
|
725
|
+
return s;
|
|
931
726
|
}
|
|
932
|
-
/**
|
|
933
|
-
|
|
934
|
-
* plist. Key-scoped `plutil -extract`, never a whole-file JSON conversion:
|
|
935
|
-
* real iTerm preferences carry `<date>` objects (SULastCheckTime & friends)
|
|
936
|
-
* which `plutil -convert json` rejects outright — "Invalid object in plist
|
|
937
|
-
* for JSON format" — so the whole-file read always failed and the dynamic
|
|
938
|
-
* profile was never written.
|
|
939
|
-
*/
|
|
940
|
-
function readItermPlist(plistPath) {
|
|
941
|
-
let bookmarks = [];
|
|
942
|
-
try {
|
|
943
|
-
const out = execFileSync("plutil", [
|
|
944
|
-
"-extract",
|
|
945
|
-
"New Bookmarks",
|
|
946
|
-
"json",
|
|
947
|
-
"-o",
|
|
948
|
-
"-",
|
|
949
|
-
plistPath
|
|
950
|
-
], {
|
|
951
|
-
encoding: "utf8",
|
|
952
|
-
timeout: 1e4
|
|
953
|
-
});
|
|
954
|
-
const parsed = JSON.parse(out);
|
|
955
|
-
if (Array.isArray(parsed)) bookmarks = parsed;
|
|
956
|
-
} catch (e) {
|
|
957
|
-
return {
|
|
958
|
-
bookmarks: [],
|
|
959
|
-
defaultGuid: null,
|
|
960
|
-
error: `extracting New Bookmarks: ${toolErr(e)}`
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
let defaultGuid = null;
|
|
727
|
+
/** Remove a worktree directory from git's books and the filesystem. */
|
|
728
|
+
function removeWorktree(cwd, dir, force) {
|
|
964
729
|
try {
|
|
965
|
-
|
|
966
|
-
"
|
|
967
|
-
"
|
|
968
|
-
"
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
], {
|
|
973
|
-
encoding: "utf8",
|
|
974
|
-
timeout: 1e4
|
|
975
|
-
}).trim() || null;
|
|
730
|
+
git(cwd, [
|
|
731
|
+
"worktree",
|
|
732
|
+
"remove",
|
|
733
|
+
...force ? ["--force"] : [],
|
|
734
|
+
dir
|
|
735
|
+
]);
|
|
736
|
+
return;
|
|
976
737
|
} catch {}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
};
|
|
982
|
-
}
|
|
983
|
-
/** iTerm's exported preferences, via a temp plist (no shell pipe involved). */
|
|
984
|
-
function itermPrefs() {
|
|
985
|
-
let dir = null;
|
|
986
|
-
try {
|
|
987
|
-
dir = mkdtempSync(join(tmpdir(), "pai-iterm-"));
|
|
988
|
-
const plist = join(dir, "iterm2.plist");
|
|
989
|
-
execFileSync("defaults", [
|
|
990
|
-
"export",
|
|
991
|
-
"com.googlecode.iterm2",
|
|
992
|
-
plist
|
|
993
|
-
], {
|
|
994
|
-
encoding: "utf8",
|
|
995
|
-
timeout: 1e4
|
|
738
|
+
if (existsSync(dir)) try {
|
|
739
|
+
rmSync(dir, {
|
|
740
|
+
recursive: true,
|
|
741
|
+
force: true
|
|
996
742
|
});
|
|
997
|
-
|
|
998
|
-
} catch
|
|
999
|
-
return {
|
|
1000
|
-
bookmarks: [],
|
|
1001
|
-
defaultGuid: null,
|
|
1002
|
-
error: `defaults export: ${toolErr(e)}`
|
|
1003
|
-
};
|
|
1004
|
-
} finally {
|
|
1005
|
-
if (dir) try {
|
|
1006
|
-
rmSync(dir, {
|
|
1007
|
-
recursive: true,
|
|
1008
|
-
force: true
|
|
1009
|
-
});
|
|
1010
|
-
} catch {}
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
/** The New Bookmarks entry of that profile name, null if absent. */
|
|
1014
|
-
function bookmarkFrom(read, named) {
|
|
1015
|
-
return read.bookmarks.find((b) => b.Name === named) ?? null;
|
|
1016
|
-
}
|
|
1017
|
-
/** The bookmark iTerm marks default, null when it is missing or unreadable. */
|
|
1018
|
-
function defaultBookmarkFrom(read) {
|
|
1019
|
-
return read.defaultGuid ? read.bookmarks.find((b) => b.Guid === read.defaultGuid) ?? null : null;
|
|
1020
|
-
}
|
|
1021
|
-
/** The default profile, read fresh from iTerm's preferences. */
|
|
1022
|
-
function defaultBookmark() {
|
|
1023
|
-
return defaultBookmarkFrom(itermPrefs());
|
|
743
|
+
git(cwd, ["worktree", "prune"]);
|
|
744
|
+
} catch {}
|
|
1024
745
|
}
|
|
1025
746
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
* `
|
|
1028
|
-
*
|
|
747
|
+
* Tracked changes (staged or not) plus untracked files — everything a
|
|
748
|
+
* `git add -A` in `wtDir` would commit. The same two calls the old patch
|
|
749
|
+
* carry used; name-only, not porcelain: a worktree-only change renders as
|
|
750
|
+
* " M path" and the shared git() helper trims that leading space away.
|
|
1029
751
|
*/
|
|
1030
|
-
function
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
Guid: "pai-worker-dynamic-profile",
|
|
1043
|
-
"Normal Font": paneFont(parent?.["Normal Font"], fontSize),
|
|
1044
|
-
"Close Sessions On End": true
|
|
1045
|
-
};
|
|
1046
|
-
if (parent?.Name && bookmarkFrom(read(), parent.Name)) profile["Dynamic Profile Parent Name"] = parent.Name;
|
|
1047
|
-
const path = dynamicProfilePath();
|
|
1048
|
-
const dir = dirname(path);
|
|
1049
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1050
|
-
const tmp = `${path}.tmp`;
|
|
1051
|
-
writeFileSync(tmp, JSON.stringify({ Profiles: [profile] }, null, 2) + "\n", "utf8");
|
|
1052
|
-
renameSync(tmp, path);
|
|
752
|
+
function uncommittedPaths(wtDir) {
|
|
753
|
+
const tracked = git(wtDir, [
|
|
754
|
+
"diff",
|
|
755
|
+
"--name-only",
|
|
756
|
+
"HEAD"
|
|
757
|
+
]).split("\n").filter(Boolean);
|
|
758
|
+
const untracked = git(wtDir, [
|
|
759
|
+
"ls-files",
|
|
760
|
+
"--others",
|
|
761
|
+
"--exclude-standard"
|
|
762
|
+
]).split("\n").filter(Boolean);
|
|
763
|
+
return [...tracked, ...untracked];
|
|
1053
764
|
}
|
|
1054
|
-
let warnedPrefs = false;
|
|
1055
765
|
/**
|
|
1056
|
-
*
|
|
1057
|
-
*
|
|
1058
|
-
*
|
|
1059
|
-
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
* and no parent, and the reason is logged to stderr once.
|
|
766
|
+
* Commit a worktree's uncommitted changes to its branch so the merge carries
|
|
767
|
+
* them. `git merge` only moves committed work, so a worker that stopped
|
|
768
|
+
* without committing would lose its edits to `worktree remove` — exactly
|
|
769
|
+
* what happened live on 2026-09-17. Returns the salvaged paths, [] when the
|
|
770
|
+
* worktree is clean. A failed commit throws with the worktree untouched:
|
|
771
|
+
* its edits are still on disk, so nothing is lost.
|
|
1063
772
|
*/
|
|
1064
|
-
|
|
1065
|
-
const
|
|
1066
|
-
|
|
1067
|
-
const path = dynamicProfilePath();
|
|
1068
|
-
const existed = existsSync(path);
|
|
1069
|
-
if (!existed) writeDynamicProfile(parent, fontSize, () => first);
|
|
1070
|
-
if (!parent) {
|
|
1071
|
-
if (!warnedPrefs) {
|
|
1072
|
-
warnedPrefs = true;
|
|
1073
|
-
const reason = first.error ?? "no profile is marked default";
|
|
1074
|
-
const what = existed ? `keeping ${path} as-is` : `wrote ${path} with Menlo-Regular ${fontSize} and no parent`;
|
|
1075
|
-
process.stderr.write(`pai worker pane: cannot read iTerm's default profile (${reason}) — ${what}\n`);
|
|
1076
|
-
}
|
|
1077
|
-
if (first.error) return "";
|
|
1078
|
-
}
|
|
1079
|
-
for (let i = 0; i < 10; i++) {
|
|
1080
|
-
if (bookmarkFrom(read(), PROFILE_NAME)) return PROFILE_NAME;
|
|
1081
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
1082
|
-
}
|
|
1083
|
-
process.stderr.write(`pai worker pane: profile ${PROFILE_NAME} not visible, splitting with default\n`);
|
|
1084
|
-
return "";
|
|
1085
|
-
}
|
|
1086
|
-
function loadRegistry(path) {
|
|
773
|
+
function salvageUncommitted(wtDir, label) {
|
|
774
|
+
const paths = uncommittedPaths(wtDir);
|
|
775
|
+
if (!paths.length) return [];
|
|
1087
776
|
try {
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
777
|
+
git(wtDir, ["add", "-A"]);
|
|
778
|
+
git(wtDir, [
|
|
779
|
+
"commit",
|
|
780
|
+
"-m",
|
|
781
|
+
`salvaged: ${label}`
|
|
782
|
+
]);
|
|
783
|
+
} catch (e) {
|
|
784
|
+
throw new Error(`cannot salvage the uncommitted changes in ${wtDir} — ${e.message}; nothing was lost: commit them there by hand, then re-run merge`);
|
|
1092
785
|
}
|
|
1093
|
-
|
|
1094
|
-
function saveRegistry(path, reg) {
|
|
1095
|
-
const dir = dirname(path);
|
|
1096
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1097
|
-
const tmp = `${path}.tmp`;
|
|
1098
|
-
writeFileSync(tmp, JSON.stringify(reg, null, 1), "utf8");
|
|
1099
|
-
renameSync(tmp, path);
|
|
786
|
+
return paths;
|
|
1100
787
|
}
|
|
1101
788
|
/**
|
|
1102
|
-
* The
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
1105
|
-
* `export …; …` or a redirect), so the command must be one exec-able line.
|
|
1106
|
-
* Absolute node on the CLI entry file also sidesteps the panes' bare launchd
|
|
1107
|
-
* PATH (no pai, no node) and the `#!/usr/bin/env node` shebang.
|
|
789
|
+
* The dirty paths of a checkout, parsed from `git status --porcelain -z`:
|
|
790
|
+
* NUL-separated (a path with a newline in it cannot corrupt the parse),
|
|
791
|
+
* rename entries contribute both sides, and any quoting is stripped.
|
|
1108
792
|
*/
|
|
1109
|
-
function
|
|
1110
|
-
|
|
793
|
+
function dirtyPaths(cwd) {
|
|
794
|
+
const fields = execFileSync("git", [
|
|
795
|
+
"-C",
|
|
796
|
+
cwd,
|
|
797
|
+
"status",
|
|
798
|
+
"--porcelain",
|
|
799
|
+
"-z"
|
|
800
|
+
], {
|
|
801
|
+
encoding: "utf8",
|
|
802
|
+
timeout: 3e4,
|
|
803
|
+
stdio: [
|
|
804
|
+
"ignore",
|
|
805
|
+
"pipe",
|
|
806
|
+
"pipe"
|
|
807
|
+
]
|
|
808
|
+
}).split("\0");
|
|
809
|
+
const strip = (p) => p.startsWith("\"") && p.endsWith("\"") ? p.slice(1, -1) : p;
|
|
810
|
+
const out = [];
|
|
811
|
+
for (let i = 0; i < fields.length; i++) {
|
|
812
|
+
const f = fields[i];
|
|
813
|
+
if (!f || f.length < 4 || f.charAt(2) !== " ") continue;
|
|
814
|
+
out.push(strip(f.slice(3)));
|
|
815
|
+
const xy = f.slice(0, 2);
|
|
816
|
+
if (xy.includes("R") || xy.includes("C")) {
|
|
817
|
+
const orig = fields[i + 1];
|
|
818
|
+
if (orig && orig.charAt(2) !== " ") {
|
|
819
|
+
out.push(strip(orig));
|
|
820
|
+
i += 1;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
1111
825
|
}
|
|
1112
|
-
/**
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
826
|
+
/**
|
|
827
|
+
* Refuse the merge when the original checkout is dirty in paths the branch
|
|
828
|
+
* touches: the merge would overwrite those edits or fail on them, either way
|
|
829
|
+
* leaving a half-state. No merge is made, the worktree stays.
|
|
830
|
+
*/
|
|
831
|
+
function assertNoDirtyOverlap(cwd, incoming, id, branch) {
|
|
832
|
+
const dirty = new Set(dirtyPaths(cwd));
|
|
833
|
+
const overlap = [...new Set(incoming)].filter((p) => dirty.has(p)).sort();
|
|
834
|
+
if (!overlap.length) return;
|
|
835
|
+
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.`);
|
|
1117
836
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const regPath = join(panesDir(logDir), `${scopeKey(term)}.json`);
|
|
1123
|
-
const reg = loadRegistry(regPath);
|
|
1124
|
-
const profile = await followProfile(config.pane.fontSize);
|
|
1125
|
-
const cmd = followCommand(wid, config.pane.autoExitSecs);
|
|
1126
|
-
const p = await osascript(WORKER_SPLIT_SCRIPT, [
|
|
1127
|
-
uid,
|
|
1128
|
-
[...reg].reverse().map((e) => e.session).join(","),
|
|
1129
|
-
cmd,
|
|
1130
|
-
profile
|
|
1131
|
-
]);
|
|
1132
|
-
const out = p.stdout.trim();
|
|
1133
|
-
if (out === "notfound" || out === "notrunning") throw new Error(out === "notfound" ? "pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID" : "pai worker pane: iTerm2 is not running");
|
|
1134
|
-
const bar = out.indexOf("|");
|
|
1135
|
-
if (bar < 0) throw new Error(`pai worker pane: osascript failed: ${(p.stderr || out).slice(0, 200)}`);
|
|
1136
|
-
const live = new Set(out.slice(0, bar).split(",").filter(Boolean));
|
|
1137
|
-
const pruned = reg.filter((e) => live.has(e.session));
|
|
1138
|
-
pruned.push({
|
|
1139
|
-
session: out.slice(bar + 1),
|
|
1140
|
-
worker: wid,
|
|
1141
|
-
opened: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
1142
|
-
});
|
|
1143
|
-
saveRegistry(regPath, pruned);
|
|
1144
|
-
return `pane opened for ${wid}`;
|
|
837
|
+
/** Never remove a worktree that still holds uncommitted changes. */
|
|
838
|
+
function assertWorktreeClean(wtDir, id) {
|
|
839
|
+
const leftover = uncommittedPaths(wtDir);
|
|
840
|
+
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`);
|
|
1145
841
|
}
|
|
1146
842
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
843
|
+
* `pai worker merge <id>`: salvage whatever the worker left uncommitted onto
|
|
844
|
+
* its branch, refuse when the original checkout is dirty in paths the branch
|
|
845
|
+
* touches, merge with --no-ff (the merge commit names the worker), then
|
|
846
|
+
* remove the worktree and delete the branch; the status gains `merged: true`.
|
|
847
|
+
* A branch with nothing to merge is refused loudly — after salvage that
|
|
848
|
+
* genuinely means it holds nothing, and reporting success there would
|
|
849
|
+
* destroy the worktree for no gain.
|
|
1150
850
|
*/
|
|
1151
|
-
|
|
1152
|
-
const
|
|
1153
|
-
if (
|
|
851
|
+
function mergeWorker(logDir, id) {
|
|
852
|
+
const st = mustHaveBranch(logDir, id);
|
|
853
|
+
if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
|
|
854
|
+
const salvaged = existsSync(st.worktreeDir) ? salvageUncommitted(st.worktreeDir, st.label || UNLABELED) : [];
|
|
855
|
+
if ((parseInt(git(st.cwd, [
|
|
856
|
+
"rev-list",
|
|
857
|
+
"--count",
|
|
858
|
+
`HEAD..${st.branch}`
|
|
859
|
+
]), 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}`);
|
|
860
|
+
const mergeBase = git(st.cwd, [
|
|
861
|
+
"merge-base",
|
|
862
|
+
"HEAD",
|
|
863
|
+
st.branch
|
|
864
|
+
]);
|
|
865
|
+
const incomingPaths = git(st.cwd, [
|
|
866
|
+
"diff",
|
|
867
|
+
"--name-only",
|
|
868
|
+
mergeBase,
|
|
869
|
+
st.branch
|
|
870
|
+
]).split("\n").filter(Boolean);
|
|
871
|
+
assertNoDirtyOverlap(st.cwd, incomingPaths, id, st.branch);
|
|
1154
872
|
try {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
873
|
+
git(st.cwd, [
|
|
874
|
+
"merge",
|
|
875
|
+
"--no-ff",
|
|
876
|
+
st.branch,
|
|
877
|
+
"-m",
|
|
878
|
+
`merge worker ${id} (${st.label})`
|
|
879
|
+
]);
|
|
1159
880
|
} catch (e) {
|
|
1160
|
-
|
|
881
|
+
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`);
|
|
882
|
+
}
|
|
883
|
+
if (existsSync(st.worktreeDir)) assertWorktreeClean(st.worktreeDir, id);
|
|
884
|
+
removeWorktree(st.cwd, st.worktreeDir, false);
|
|
885
|
+
let branchGone = true;
|
|
886
|
+
try {
|
|
887
|
+
git(st.cwd, [
|
|
888
|
+
"branch",
|
|
889
|
+
"-d",
|
|
890
|
+
st.branch
|
|
891
|
+
]);
|
|
892
|
+
} catch {
|
|
893
|
+
branchGone = false;
|
|
1161
894
|
}
|
|
895
|
+
saveStatus(logDir, {
|
|
896
|
+
...st,
|
|
897
|
+
merged: true
|
|
898
|
+
});
|
|
899
|
+
const base = `merged ${st.branch} into ${st.cwd} (worktree removed${branchGone ? ", branch deleted" : "; branch kept: git refused -d"})`;
|
|
900
|
+
return salvaged.length ? `${base}; salvaged ${salvaged.length} uncommitted change(s): ${salvaged.join(", ")}` : base;
|
|
1162
901
|
}
|
|
1163
|
-
/**
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
} catch {}
|
|
1178
|
-
lines.push(`profile file: ${path} (exists)`);
|
|
1179
|
-
lines.push(`profile font: ${font}`);
|
|
1180
|
-
} else {
|
|
1181
|
-
const parent = defaultBookmark();
|
|
1182
|
-
lines.push(`profile file: ${path} (missing)`);
|
|
1183
|
-
lines.push(`profile font (would write): ${paneFont(parent?.["Normal Font"], fontSize)}`);
|
|
902
|
+
/** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
|
|
903
|
+
function discardWorker(logDir, id) {
|
|
904
|
+
const st = mustHaveBranch(logDir, id);
|
|
905
|
+
removeWorktree(st.cwd, st.worktreeDir, true);
|
|
906
|
+
let branchGone = false;
|
|
907
|
+
try {
|
|
908
|
+
git(st.cwd, [
|
|
909
|
+
"branch",
|
|
910
|
+
"-D",
|
|
911
|
+
st.branch
|
|
912
|
+
]);
|
|
913
|
+
branchGone = true;
|
|
914
|
+
} catch {
|
|
915
|
+
branchGone = false;
|
|
1184
916
|
}
|
|
1185
|
-
|
|
917
|
+
saveStatus(logDir, {
|
|
918
|
+
...st,
|
|
919
|
+
branch: null,
|
|
920
|
+
worktreeDir: null,
|
|
921
|
+
worktreeBase: null,
|
|
922
|
+
commits: null,
|
|
923
|
+
merged: false
|
|
924
|
+
});
|
|
925
|
+
return `discarded worker ${id}: worktree removed${branchGone ? `, branch ${st.branch} deleted` : ""}`;
|
|
926
|
+
}
|
|
927
|
+
function mustHaveBranch(logDir, id) {
|
|
928
|
+
const st = loadStatus(logDir, id);
|
|
929
|
+
if (!st) throw new Error(`no worker named "${id}"`);
|
|
930
|
+
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`);
|
|
931
|
+
return st;
|
|
1186
932
|
}
|
|
1187
933
|
/**
|
|
1188
|
-
*
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
934
|
+
* The paragraph a worktree run's system prompt gains: it may commit on its
|
|
935
|
+
* own branch (the no-commit rule holds for the main branch only), it must not
|
|
936
|
+
* merge or push itself, and the parent or operator merges.
|
|
1191
937
|
*/
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
if (sout === "opened") return "follow pane opened";
|
|
1202
|
-
if (sout === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
|
|
1203
|
-
throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
|
|
938
|
+
function worktreeSystemPrompt(id, branch, dir) {
|
|
939
|
+
return [
|
|
940
|
+
"You are running in your own git worktree:",
|
|
941
|
+
` ${dir} on branch ${branch} (worker id ${id}).`,
|
|
942
|
+
"Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
|
|
943
|
+
"the no-commit rule applies to the main branch only, and this is not it.",
|
|
944
|
+
"Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`.",
|
|
945
|
+
"Use ONLY relative paths inside the worktree, never absolute worktree paths — absolute paths break after merge and leak machine layout."
|
|
946
|
+
].join("\n");
|
|
1204
947
|
}
|
|
1205
948
|
|
|
1206
949
|
//#endregion
|
|
1207
|
-
//#region src/workers/
|
|
1208
|
-
/**
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
950
|
+
//#region src/workers/scope.ts
|
|
951
|
+
/**
|
|
952
|
+
* scope.ts — which workers "belong" to the terminal asking about them.
|
|
953
|
+
*
|
|
954
|
+
* Two identities, in priority order:
|
|
955
|
+
*
|
|
956
|
+
* 1. AIBroker's session registry (~/.aibroker/session-names.json): iTerm
|
|
957
|
+
* session UUID → session name. The launching session's UUID is stored in
|
|
958
|
+
* each worker's status file as `session.id`, so every pane of a named
|
|
959
|
+
* session sees its workers — regardless of tab layout.
|
|
960
|
+
* 2. The iTerm tab key (`w<window>t<tab>` prefix of ITERM_SESSION_ID): the
|
|
961
|
+
* original heuristic, kept as the fallback when no registry entry
|
|
962
|
+
* matches (AIBroker absent, unnamed session, non-iTerm terminal).
|
|
963
|
+
*
|
|
964
|
+
* If neither is available, viewers fall back to "all workers".
|
|
965
|
+
*
|
|
966
|
+
* A third case: workers spawned from a Claude Code Bash tool (the
|
|
967
|
+
* orchestrator pattern) have neither — Claude Code exports no terminal or
|
|
968
|
+
* session identity to its Bash children. Those record `spawnerSession`, the
|
|
969
|
+
* orchestrator's claude session id bridged through the status line's
|
|
970
|
+
* session map (see below), so their tab still claims them.
|
|
971
|
+
*/
|
|
972
|
+
const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
|
|
973
|
+
/**
|
|
974
|
+
* `w<window>t<tab>` prefix of an iTerm session id, "" if empty or malformed.
|
|
975
|
+
* Panes split from the same tab share this prefix and differ only in `p<n>`.
|
|
976
|
+
*/
|
|
977
|
+
function tabKey(term) {
|
|
978
|
+
if (!term) return "";
|
|
979
|
+
const head = term.split("p", 1)[0];
|
|
980
|
+
const parts = head.slice(1).split("t");
|
|
981
|
+
if (head.startsWith("w") && parts.length === 2 && parts.every((p) => /^\d+$/.test(p))) return head;
|
|
982
|
+
return "";
|
|
983
|
+
}
|
|
984
|
+
/** Tab key of the iTerm tab this process runs in, "" if not inside iTerm2. */
|
|
985
|
+
function currentTabKey(env = process.env) {
|
|
986
|
+
return tabKey(env.ITERM_SESSION_ID ?? "");
|
|
987
|
+
}
|
|
988
|
+
/** The iTerm UUID part of an ITERM_SESSION_ID (after the last colon). */
|
|
989
|
+
function itermUuid(term) {
|
|
990
|
+
if (!term) return "";
|
|
991
|
+
return term.split(":").pop() ?? "";
|
|
1214
992
|
}
|
|
1215
993
|
/**
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
* chat-pane tracker (origin "chat"): the status line treats the pane as the
|
|
1221
|
-
* bar itself, so the workers it spawned are its top level.
|
|
994
|
+
* Resolve the AIBroker session for an ITERM_SESSION_ID. Reads the persistent
|
|
995
|
+
* name registry (the same store `aibroker_rename` writes); returns null when
|
|
996
|
+
* AIBroker is absent or the session is not in it — callers then use the tab
|
|
997
|
+
* key, which is the pre-AIBroker behaviour.
|
|
1222
998
|
*/
|
|
1223
|
-
function
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1226
|
-
let
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if (!(opts?.chatIsRoot && next.origin === "chat")) depth += 1;
|
|
1233
|
-
cur = next;
|
|
999
|
+
function resolveSession(term, registryPath = AIBROKER_REGISTRY) {
|
|
1000
|
+
const uuid = itermUuid(term);
|
|
1001
|
+
if (!uuid) return null;
|
|
1002
|
+
let names;
|
|
1003
|
+
try {
|
|
1004
|
+
if (!existsSync(registryPath)) return null;
|
|
1005
|
+
names = JSON.parse(readFileSync(registryPath, "utf8"));
|
|
1006
|
+
} catch {
|
|
1007
|
+
return null;
|
|
1234
1008
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1009
|
+
const name = names[uuid];
|
|
1010
|
+
if (typeof name !== "string" || !name) return null;
|
|
1011
|
+
return {
|
|
1012
|
+
id: uuid,
|
|
1013
|
+
name
|
|
1014
|
+
};
|
|
1240
1015
|
}
|
|
1241
1016
|
/**
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1244
|
-
*
|
|
1017
|
+
* True when `worker` was launched from `term` (the terminal asking about it).
|
|
1018
|
+
* Session-id match first; the tab key only when no registry entry matched —
|
|
1019
|
+
* a session without a name keeps the tab-scoped behaviour it always had.
|
|
1245
1020
|
*/
|
|
1246
|
-
function
|
|
1247
|
-
if (!
|
|
1248
|
-
const
|
|
1249
|
-
if (
|
|
1250
|
-
|
|
1251
|
-
if (kids.length >= caps.maxChildren) throw new Error(`worker tree: ${parent} already has ${kids.length} running sub-workers (${kids.map((k) => k.id).join(", ")}) and workers.tree.maxChildren is ${caps.maxChildren} — wait for one to finish, or raise the cap in the workers config`);
|
|
1021
|
+
function workerInScope(worker, term) {
|
|
1022
|
+
if (!term) return false;
|
|
1023
|
+
const uuid = itermUuid(term);
|
|
1024
|
+
if (worker.session?.id && uuid) return worker.session.id === uuid;
|
|
1025
|
+
return tabKey(worker.term) === tabKey(term) && tabKey(term) !== "";
|
|
1252
1026
|
}
|
|
1253
1027
|
/**
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1028
|
+
* Registry key for pane files: the session id when AIBroker knows this
|
|
1029
|
+
* terminal, else the tab key, else the raw iTerm UUID.
|
|
1256
1030
|
*/
|
|
1257
|
-
function
|
|
1258
|
-
|
|
1031
|
+
function scopeKey(term) {
|
|
1032
|
+
const session = resolveSession(term);
|
|
1033
|
+
if (session) return session.id;
|
|
1034
|
+
return currentTabKey() || itermUuid(term);
|
|
1259
1035
|
}
|
|
1260
|
-
/**
|
|
1261
|
-
function
|
|
1262
|
-
return
|
|
1036
|
+
/** `[Name]` when the worker has an AIBroker session name, else "". */
|
|
1037
|
+
function sessionTag(worker) {
|
|
1038
|
+
return worker.session?.name ? `[${worker.session.name}]` : "";
|
|
1039
|
+
}
|
|
1040
|
+
/** How old a map entry may be for a spawn to adopt it (status lines refresh constantly while a session lives). */
|
|
1041
|
+
const SPAWNER_SESSION_TTL_MS = 10 * 6e4;
|
|
1042
|
+
/** Map entries not refreshed within this window are pruned on write. */
|
|
1043
|
+
const SESSION_MAP_PRUNE_MS = 60 * 6e4;
|
|
1044
|
+
function sessionMapPath(logDir) {
|
|
1045
|
+
return join(logDir, "claude-session-map.json");
|
|
1263
1046
|
}
|
|
1264
|
-
|
|
1265
|
-
//#endregion
|
|
1266
|
-
//#region src/workers/operator.ts
|
|
1267
1047
|
/**
|
|
1268
|
-
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* open, so a run is a conversation: every line sent to the per-worker Unix
|
|
1272
|
-
* socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
|
|
1273
|
-
* mirrored into the transcript as an `operator` event (rendered with a »
|
|
1274
|
-
* marker). Once the worker has finished its turn, stdin closes 2 s later
|
|
1275
|
-
* unless a new message arrives — after that, `say` refuses and `resume`
|
|
1276
|
-
* continues the same Claude session with the worker's context intact.
|
|
1048
|
+
* The claude session a new run was spawned by, when it can be known: a run
|
|
1049
|
+
* inside another worker inherits its spawner (chain stages keep the
|
|
1050
|
+
* orchestrator's tab that way); otherwise a fresh map entry for `cwd`.
|
|
1277
1051
|
*/
|
|
1278
|
-
function
|
|
1279
|
-
|
|
1052
|
+
function resolveSpawnerSession(logDir, cwd, env = process.env, now = Date.now()) {
|
|
1053
|
+
const parentId = env.PAI_WORKER_ID;
|
|
1054
|
+
if (parentId) {
|
|
1055
|
+
const inherited = loadStatus(logDir, parentId)?.spawnerSession;
|
|
1056
|
+
if (inherited) return inherited;
|
|
1057
|
+
}
|
|
1058
|
+
const path = sessionMapPath(logDir);
|
|
1059
|
+
if (!existsSync(path) || !cwd) return null;
|
|
1060
|
+
try {
|
|
1061
|
+
const entry = JSON.parse(readFileSync(path, "utf8"))[cwd];
|
|
1062
|
+
if (entry && entry.session && now - entry.ts < SPAWNER_SESSION_TTL_MS) return entry.session;
|
|
1063
|
+
} catch {}
|
|
1064
|
+
return null;
|
|
1280
1065
|
}
|
|
1281
1066
|
/**
|
|
1282
|
-
* The
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
1067
|
+
* The iTerm UUID a claude session last rendered its status line in, when the
|
|
1068
|
+
* map knows one: the freshest entry naming that session. This is the reverse
|
|
1069
|
+
* of resolveSpawnerSession — worker → orchestrator there, orchestrator →
|
|
1070
|
+
* terminal here — and it exists so daemon-side pushes (supervision) can reach
|
|
1071
|
+
* a session that has no iTerm identity of its own in any worker status.
|
|
1285
1072
|
*/
|
|
1286
|
-
function
|
|
1287
|
-
const path =
|
|
1073
|
+
function itermForClaudeSession(logDir, claudeSession, now = Date.now()) {
|
|
1074
|
+
const path = sessionMapPath(logDir);
|
|
1075
|
+
if (!existsSync(path) || !claudeSession) return null;
|
|
1076
|
+
let map;
|
|
1288
1077
|
try {
|
|
1289
|
-
|
|
1290
|
-
} catch {
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1078
|
+
map = JSON.parse(readFileSync(path, "utf8"));
|
|
1079
|
+
} catch {
|
|
1080
|
+
return null;
|
|
1081
|
+
}
|
|
1082
|
+
let best = null;
|
|
1083
|
+
for (const e of Object.values(map)) {
|
|
1084
|
+
if (e.session !== claudeSession || !e.term) continue;
|
|
1085
|
+
if (now - e.ts >= SESSION_MAP_PRUNE_MS) continue;
|
|
1086
|
+
if (!best || e.ts > best.ts) best = e;
|
|
1087
|
+
}
|
|
1088
|
+
const term = best?.term;
|
|
1089
|
+
return term ? itermUuid(term) : null;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
//#endregion
|
|
1093
|
+
//#region src/workers/engines/image.ts
|
|
1094
|
+
/**
|
|
1095
|
+
* engines/image.ts — the `engine: image` worker.
|
|
1096
|
+
*
|
|
1097
|
+
* No claude-code process, no worktree, no MCP: `pai worker run --capability
|
|
1098
|
+
* image` POSTs one OpenAI-compatible images-generations request straight to
|
|
1099
|
+
* the provider's `url` and writes the PNG it gets back. One request, no
|
|
1100
|
+
* retries beyond the fetch's own timeout — a caller that wants another try
|
|
1101
|
+
* just runs again.
|
|
1102
|
+
*/
|
|
1103
|
+
const DEFAULT_SIZE = "1024x1024";
|
|
1104
|
+
const DEFAULT_IMAGE_TIMEOUT_MS = 12e4;
|
|
1105
|
+
/**
|
|
1106
|
+
* `POST {url}/images/generations` with `{model, prompt, size, n: 1,
|
|
1107
|
+
* response_format: "b64_json"}` and the provider's bearer token, decode the
|
|
1108
|
+
* base64 PNG it returns, and write it to `outPath`.
|
|
1109
|
+
*/
|
|
1110
|
+
async function runImageCapability(opts) {
|
|
1111
|
+
if (!opts.provider.baseUrl) throw new WorkersConfigError(`provider "${opts.providerName}" (engine image) has no "url" configured — set one with: pai worker providers update ${opts.providerName} --base-url <url>`);
|
|
1112
|
+
const token = resolveProviderKey(opts.provider);
|
|
1113
|
+
const url = `${opts.provider.baseUrl.replace(/\/+$/, "")}/images/generations`;
|
|
1114
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_IMAGE_TIMEOUT_MS;
|
|
1115
|
+
const controller = new AbortController();
|
|
1116
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1117
|
+
const t0 = Date.now();
|
|
1118
|
+
let res;
|
|
1119
|
+
try {
|
|
1120
|
+
res = await fetch(url, {
|
|
1121
|
+
method: "POST",
|
|
1122
|
+
headers: {
|
|
1123
|
+
"content-type": "application/json",
|
|
1124
|
+
...token ? { authorization: `Bearer ${token}` } : {}
|
|
1125
|
+
},
|
|
1126
|
+
body: JSON.stringify({
|
|
1127
|
+
model: opts.model,
|
|
1128
|
+
prompt: opts.prompt,
|
|
1129
|
+
size: opts.size ?? DEFAULT_SIZE,
|
|
1130
|
+
n: 1,
|
|
1131
|
+
response_format: "b64_json"
|
|
1132
|
+
}),
|
|
1133
|
+
signal: controller.signal
|
|
1134
|
+
});
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
if (e instanceof Error && e.name === "AbortError") throw new WorkersConfigError(`provider "${opts.providerName}": image request timed out after ${timeoutMs}ms`);
|
|
1137
|
+
throw new WorkersConfigError(`provider "${opts.providerName}": image request failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1138
|
+
} finally {
|
|
1139
|
+
clearTimeout(timer);
|
|
1140
|
+
}
|
|
1141
|
+
const durationMs = Date.now() - t0;
|
|
1142
|
+
const text = await res.text();
|
|
1143
|
+
if (!res.ok) throw new WorkersConfigError(`provider "${opts.providerName}": image request failed (${res.status}): ${text.slice(0, 500)}`);
|
|
1144
|
+
let parsed;
|
|
1145
|
+
try {
|
|
1146
|
+
parsed = JSON.parse(text);
|
|
1147
|
+
} catch {
|
|
1148
|
+
throw new WorkersConfigError(`provider "${opts.providerName}": image response was not JSON`);
|
|
1149
|
+
}
|
|
1150
|
+
const b64 = parsed.data?.[0]?.b64_json;
|
|
1151
|
+
if (!b64) throw new WorkersConfigError(`provider "${opts.providerName}": image response had no data[0].b64_json`);
|
|
1152
|
+
const bytes = Buffer.from(b64, "base64");
|
|
1153
|
+
mkdirSync(dirname(opts.outPath), { recursive: true });
|
|
1154
|
+
writeFileSync(opts.outPath, bytes);
|
|
1155
|
+
return {
|
|
1156
|
+
ok: true,
|
|
1157
|
+
path: opts.outPath,
|
|
1158
|
+
model: opts.model,
|
|
1159
|
+
provider: opts.providerName,
|
|
1160
|
+
durationMs,
|
|
1161
|
+
bytes: bytes.length
|
|
1162
|
+
};
|
|
1311
1163
|
}
|
|
1164
|
+
|
|
1165
|
+
//#endregion
|
|
1166
|
+
//#region src/workers/pane.ts
|
|
1312
1167
|
/**
|
|
1313
|
-
*
|
|
1314
|
-
*
|
|
1168
|
+
* pane.ts — the per-worker follow pane in iTerm2.
|
|
1169
|
+
*
|
|
1170
|
+
* One small-font pane per worker, stacked in a right-hand column: the first
|
|
1171
|
+
* worker of a scope splits the launching session vertically, every further
|
|
1172
|
+
* one splits the lowest live worker pane horizontally, so panes stack top to
|
|
1173
|
+
* bottom. The split never sizes the new session — that would grow the whole
|
|
1174
|
+
* window; instead the window's bounds are read before the split and restored
|
|
1175
|
+
* right after, so the panes share the space the window already had. The follow
|
|
1176
|
+
* command is part of the split itself — iTerm creates the new session already
|
|
1177
|
+
* running it — so no text is ever typed into any session afterwards; a separate
|
|
1178
|
+
* typing step raced the operator's keystrokes (focus briefly sits on the new
|
|
1179
|
+
* pane after a split, and in one observed failure the command landed in the
|
|
1180
|
+
* operator's own shell). A split still makes the new session the tab's active
|
|
1181
|
+
* one — the scripts re-select the launching session right after, so a pane
|
|
1182
|
+
* opening never steals the keystrokes the operator is typing. Each
|
|
1183
|
+
* pane runs `pai worker follow <id> --auto-exit <n>` under the `pai-worker`
|
|
1184
|
+
* dynamic profile (Close Sessions On End), so panes disappear by themselves.
|
|
1185
|
+
*
|
|
1186
|
+
* Panes are tracked per scope (AIBroker session id, else tab key) in
|
|
1187
|
+
* <logDir>/panes/<key>.json, keyed by iTerm session unique id, newest first —
|
|
1188
|
+
* the split lands below the lowest live worker pane.
|
|
1189
|
+
*
|
|
1190
|
+
* The dynamic profile's font is the family of iTerm's DEFAULT profile (the
|
|
1191
|
+
* `Default Bookmark Guid` entry in New Bookmarks) at workers.pane.fontSize —
|
|
1192
|
+
* never the launching session's profile, which the Python version did and
|
|
1193
|
+
* which produced iTerm's "unknown parent name" dialog whenever the two
|
|
1194
|
+
* differed. `Dynamic Profile Parent Name` is only written when that name
|
|
1195
|
+
* actually exists in New Bookmarks; when iTerm's preferences cannot be read
|
|
1196
|
+
* the profile is still written, with font Menlo-Regular and no parent.
|
|
1315
1197
|
*/
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1198
|
+
const PROFILE_NAME = "pai-worker";
|
|
1199
|
+
/** Where the dynamic profile lives (env override keeps tests off the real one). */
|
|
1200
|
+
function dynamicProfilePath() {
|
|
1201
|
+
return process.env.PAI_WORKER_PROFILE ?? join(homedir(), "Library", "Application Support", "iTerm2", "DynamicProfiles", "pai-worker.json");
|
|
1202
|
+
}
|
|
1203
|
+
const WORKER_SPLIT_SCRIPT = `on run(argv)
|
|
1204
|
+
set targetID to item 1 of argv
|
|
1205
|
+
set candList to item 2 of argv
|
|
1206
|
+
set followCmd to item 3 of argv
|
|
1207
|
+
set profileName to item 4 of argv
|
|
1208
|
+
tell application id "com.googlecode.iterm2"
|
|
1209
|
+
if not running then return "notrunning"
|
|
1210
|
+
repeat with w in windows
|
|
1211
|
+
repeat with t in tabs of w
|
|
1212
|
+
repeat with s in sessions of t
|
|
1213
|
+
if id of s is targetID then
|
|
1214
|
+
-- sizing the new session would grow the whole window:
|
|
1215
|
+
-- pin the window's bounds now and restore them after
|
|
1216
|
+
-- the split, so the panes share the existing space.
|
|
1217
|
+
-- copy, never set: set stores the property
|
|
1218
|
+
-- reference lazily, so restoring it would re-read the
|
|
1219
|
+
-- post-split bounds instead of these — observed as the
|
|
1220
|
+
-- window jumping to the main display
|
|
1221
|
+
copy bounds of w to winBounds
|
|
1222
|
+
set sessIDs to {}
|
|
1223
|
+
repeat with other in sessions of t
|
|
1224
|
+
set end of sessIDs to (id of other as text)
|
|
1225
|
+
end repeat
|
|
1226
|
+
set lived to {}
|
|
1227
|
+
set splitS to missing value
|
|
1228
|
+
repeat with cid in my splitIds(candList)
|
|
1229
|
+
set cidText to (cid as text)
|
|
1230
|
+
if sessIDs contains cidText then
|
|
1231
|
+
set end of lived to cidText
|
|
1232
|
+
if splitS is missing value then
|
|
1233
|
+
set splitS to first session of t whose id is cidText
|
|
1234
|
+
end if
|
|
1235
|
+
end if
|
|
1236
|
+
end repeat
|
|
1237
|
+
-- the follow command is part of the split itself: the
|
|
1238
|
+
-- pane is born already running it, so no text is ever
|
|
1239
|
+
-- typed into any session — a typing step raced the
|
|
1240
|
+
-- operator's keystrokes and could even land in the
|
|
1241
|
+
-- operator's own session
|
|
1242
|
+
if profileName is "" then
|
|
1243
|
+
if splitS is missing value then
|
|
1244
|
+
tell s
|
|
1245
|
+
set newS to split vertically with default profile command followCmd
|
|
1246
|
+
end tell
|
|
1247
|
+
else
|
|
1248
|
+
tell splitS
|
|
1249
|
+
set newS to split horizontally with default profile command followCmd
|
|
1250
|
+
end tell
|
|
1251
|
+
end if
|
|
1252
|
+
else
|
|
1253
|
+
if splitS is missing value then
|
|
1254
|
+
tell s
|
|
1255
|
+
set newS to split vertically with profile profileName command followCmd
|
|
1256
|
+
end tell
|
|
1257
|
+
else
|
|
1258
|
+
tell splitS
|
|
1259
|
+
set newS to split horizontally with profile profileName command followCmd
|
|
1260
|
+
end tell
|
|
1261
|
+
end if
|
|
1262
|
+
end if
|
|
1263
|
+
-- a split makes the new session the tab's active one:
|
|
1264
|
+
-- re-select the launching session so focus returns to
|
|
1265
|
+
-- where the operator was typing, never the new pane
|
|
1266
|
+
try
|
|
1267
|
+
select s
|
|
1268
|
+
end try
|
|
1269
|
+
try
|
|
1270
|
+
set bounds of w to winBounds
|
|
1271
|
+
end try
|
|
1272
|
+
set out to ""
|
|
1273
|
+
repeat with lid in lived
|
|
1274
|
+
set out to out & lid & ","
|
|
1275
|
+
end repeat
|
|
1276
|
+
return out & "|" & (id of newS as text)
|
|
1277
|
+
end if
|
|
1278
|
+
end repeat
|
|
1279
|
+
end repeat
|
|
1280
|
+
end repeat
|
|
1281
|
+
end tell
|
|
1282
|
+
return "notfound"
|
|
1283
|
+
end run
|
|
1284
|
+
|
|
1285
|
+
on splitIds(s)
|
|
1286
|
+
set out to {}
|
|
1287
|
+
if s is "" then return out
|
|
1288
|
+
set prevDels to AppleScript's text item delimiters
|
|
1289
|
+
set AppleScript's text item delimiters to ","
|
|
1290
|
+
repeat with part in text items of s
|
|
1291
|
+
set end of out to (part as text)
|
|
1292
|
+
end repeat
|
|
1293
|
+
set AppleScript's text item delimiters to prevDels
|
|
1294
|
+
return out
|
|
1295
|
+
end splitIds`;
|
|
1296
|
+
const TAB_TTYS_SCRIPT = `on run(argv)
|
|
1297
|
+
set targetID to item 1 of argv
|
|
1298
|
+
tell application id "com.googlecode.iterm2"
|
|
1299
|
+
if not running then return "notrunning"
|
|
1300
|
+
repeat with w in windows
|
|
1301
|
+
repeat with t in tabs of w
|
|
1302
|
+
repeat with s in sessions of t
|
|
1303
|
+
if id of s is targetID then
|
|
1304
|
+
set ttys to {}
|
|
1305
|
+
repeat with other in sessions of t
|
|
1306
|
+
copy (tty of other) to end of ttys
|
|
1307
|
+
end repeat
|
|
1308
|
+
return ttys
|
|
1309
|
+
end if
|
|
1310
|
+
end repeat
|
|
1311
|
+
end repeat
|
|
1312
|
+
end repeat
|
|
1313
|
+
end tell
|
|
1314
|
+
return "notfound"
|
|
1315
|
+
end run`;
|
|
1316
|
+
const WINDOW_BOUNDS_SCRIPT = `on run(argv)
|
|
1317
|
+
set targetID to item 1 of argv
|
|
1318
|
+
tell application id "com.googlecode.iterm2"
|
|
1319
|
+
if not running then return "notrunning"
|
|
1320
|
+
repeat with w in windows
|
|
1321
|
+
repeat with t in tabs of w
|
|
1322
|
+
repeat with s in sessions of t
|
|
1323
|
+
if id of s is targetID then
|
|
1324
|
+
copy bounds of w to winBounds
|
|
1325
|
+
set prevDels to AppleScript's text item delimiters
|
|
1326
|
+
set AppleScript's text item delimiters to ", "
|
|
1327
|
+
set out to winBounds as text
|
|
1328
|
+
set AppleScript's text item delimiters to prevDels
|
|
1329
|
+
return out
|
|
1330
|
+
end if
|
|
1331
|
+
end repeat
|
|
1332
|
+
end repeat
|
|
1333
|
+
end repeat
|
|
1334
|
+
end tell
|
|
1335
|
+
return "notfound"
|
|
1336
|
+
end run`;
|
|
1337
|
+
const SPLIT_SCRIPT = `on run(argv)
|
|
1338
|
+
set targetID to item 1 of argv
|
|
1339
|
+
set followCmd to item 2 of argv
|
|
1340
|
+
tell application id "com.googlecode.iterm2"
|
|
1341
|
+
if not running then return "notrunning"
|
|
1342
|
+
repeat with w in windows
|
|
1343
|
+
repeat with t in tabs of w
|
|
1344
|
+
repeat with s in sessions of t
|
|
1345
|
+
if id of s is targetID then
|
|
1346
|
+
-- the follow command is part of the split itself (see
|
|
1347
|
+
-- WORKER_SPLIT_SCRIPT): nothing is ever typed into a
|
|
1348
|
+
-- session afterwards
|
|
1349
|
+
tell s
|
|
1350
|
+
set newS to split vertically with default profile command followCmd
|
|
1351
|
+
end tell
|
|
1352
|
+
-- keep the tab's active session where it was (see
|
|
1353
|
+
-- WORKER_SPLIT_SCRIPT)
|
|
1354
|
+
try
|
|
1355
|
+
select s
|
|
1356
|
+
end try
|
|
1357
|
+
return "opened"
|
|
1358
|
+
end if
|
|
1359
|
+
end repeat
|
|
1360
|
+
end repeat
|
|
1361
|
+
end repeat
|
|
1362
|
+
end tell
|
|
1363
|
+
return "notfound"
|
|
1364
|
+
end run`;
|
|
1365
|
+
/** Run an AppleScript with argv. Rejects when osascript itself cannot run. */
|
|
1366
|
+
function osascript(script, args) {
|
|
1322
1367
|
return new Promise((resolve, reject) => {
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
};
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
});
|
|
1368
|
+
const proc = spawn("osascript", ["-", ...args], { stdio: [
|
|
1369
|
+
"pipe",
|
|
1370
|
+
"pipe",
|
|
1371
|
+
"pipe"
|
|
1372
|
+
] });
|
|
1373
|
+
let out = "";
|
|
1374
|
+
let err = "";
|
|
1375
|
+
proc.stdout.on("data", (c) => out += c.toString("utf8"));
|
|
1376
|
+
proc.stderr.on("data", (c) => err += c.toString("utf8"));
|
|
1377
|
+
proc.on("error", reject);
|
|
1378
|
+
proc.on("close", () => resolve({
|
|
1379
|
+
stdout: out,
|
|
1380
|
+
stderr: err
|
|
1381
|
+
}));
|
|
1382
|
+
proc.stdin.write(script);
|
|
1383
|
+
proc.stdin.end();
|
|
1337
1384
|
});
|
|
1338
1385
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
"result",
|
|
1357
|
-
"question",
|
|
1358
|
-
"blocker"
|
|
1359
|
-
];
|
|
1360
|
-
/** Where a worker's handoffs land: <logDir>/<id>.inbox.jsonl. */
|
|
1361
|
-
function inboxPath(logDir, id) {
|
|
1362
|
-
return join(logDir, `${id}.inbox.jsonl`);
|
|
1386
|
+
function psOutput(format) {
|
|
1387
|
+
try {
|
|
1388
|
+
return execFileSync("ps", ["-axo", format], { encoding: "utf8" });
|
|
1389
|
+
} catch {
|
|
1390
|
+
return "";
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
/** TTys of running `worker follow` processes, normalised to /dev/ttysNNN. */
|
|
1394
|
+
function followTtys() {
|
|
1395
|
+
const ttys = /* @__PURE__ */ new Set();
|
|
1396
|
+
for (const line of psOutput("tty=,command=").split("\n")) {
|
|
1397
|
+
const parts = line.trim().split(/\s+/, 2);
|
|
1398
|
+
if (parts.length < 2 || parts[0] === "ps") continue;
|
|
1399
|
+
if (!/(^|\/)(worker-follow|pai worker follow|glm-ps follow)\b/.test(parts[1])) continue;
|
|
1400
|
+
if (parts[0].startsWith("ttys")) ttys.add(`/dev/${parts[0]}`);
|
|
1401
|
+
}
|
|
1402
|
+
return ttys;
|
|
1363
1403
|
}
|
|
1364
|
-
|
|
1365
|
-
|
|
1404
|
+
/** True while a `follow <wid>` process runs (its pane shows that worker). */
|
|
1405
|
+
function workerPaneOpen(wid) {
|
|
1406
|
+
const pat = new RegExp(`(?:worker follow|worker-follow|glm-ps follow) ${wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
1407
|
+
return psOutput("command=").split("\n").some((ln) => pat.test(ln));
|
|
1408
|
+
}
|
|
1409
|
+
/** plutil's message for an ExecFileSync failure, else the exception text. */
|
|
1410
|
+
function toolErr(e) {
|
|
1411
|
+
const err = e;
|
|
1412
|
+
return ((typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e)).trim();
|
|
1366
1413
|
}
|
|
1367
1414
|
/**
|
|
1368
|
-
*
|
|
1369
|
-
*
|
|
1370
|
-
*
|
|
1415
|
+
* Read New Bookmarks and Default Bookmark Guid from an iTerm preferences
|
|
1416
|
+
* plist. Key-scoped `plutil -extract`, never a whole-file JSON conversion:
|
|
1417
|
+
* real iTerm preferences carry `<date>` objects (SULastCheckTime & friends)
|
|
1418
|
+
* which `plutil -convert json` rejects outright — "Invalid object in plist
|
|
1419
|
+
* for JSON format" — so the whole-file read always failed and the dynamic
|
|
1420
|
+
* profile was never written.
|
|
1371
1421
|
*/
|
|
1372
|
-
function
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1422
|
+
function readItermPlist(plistPath) {
|
|
1423
|
+
let bookmarks = [];
|
|
1424
|
+
try {
|
|
1425
|
+
const out = execFileSync("plutil", [
|
|
1426
|
+
"-extract",
|
|
1427
|
+
"New Bookmarks",
|
|
1428
|
+
"json",
|
|
1429
|
+
"-o",
|
|
1430
|
+
"-",
|
|
1431
|
+
plistPath
|
|
1432
|
+
], {
|
|
1433
|
+
encoding: "utf8",
|
|
1434
|
+
timeout: 1e4
|
|
1435
|
+
});
|
|
1436
|
+
const parsed = JSON.parse(out);
|
|
1437
|
+
if (Array.isArray(parsed)) bookmarks = parsed;
|
|
1438
|
+
} catch (e) {
|
|
1439
|
+
return {
|
|
1440
|
+
bookmarks: [],
|
|
1441
|
+
defaultGuid: null,
|
|
1442
|
+
error: `extracting New Bookmarks: ${toolErr(e)}`
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
let defaultGuid = null;
|
|
1446
|
+
try {
|
|
1447
|
+
defaultGuid = execFileSync("plutil", [
|
|
1448
|
+
"-extract",
|
|
1449
|
+
"Default Bookmark Guid",
|
|
1450
|
+
"raw",
|
|
1451
|
+
"-o",
|
|
1452
|
+
"-",
|
|
1453
|
+
plistPath
|
|
1454
|
+
], {
|
|
1455
|
+
encoding: "utf8",
|
|
1456
|
+
timeout: 1e4
|
|
1457
|
+
}).trim() || null;
|
|
1458
|
+
} catch {}
|
|
1383
1459
|
return {
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
text,
|
|
1388
|
-
...data ? { data } : {}
|
|
1460
|
+
bookmarks,
|
|
1461
|
+
defaultGuid,
|
|
1462
|
+
error: null
|
|
1389
1463
|
};
|
|
1390
1464
|
}
|
|
1391
|
-
/**
|
|
1392
|
-
function
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1465
|
+
/** iTerm's exported preferences, via a temp plist (no shell pipe involved). */
|
|
1466
|
+
function itermPrefs() {
|
|
1467
|
+
let dir = null;
|
|
1468
|
+
try {
|
|
1469
|
+
dir = mkdtempSync(join(tmpdir(), "pai-iterm-"));
|
|
1470
|
+
const plist = join(dir, "iterm2.plist");
|
|
1471
|
+
execFileSync("defaults", [
|
|
1472
|
+
"export",
|
|
1473
|
+
"com.googlecode.iterm2",
|
|
1474
|
+
plist
|
|
1475
|
+
], {
|
|
1476
|
+
encoding: "utf8",
|
|
1477
|
+
timeout: 1e4
|
|
1478
|
+
});
|
|
1479
|
+
return readItermPlist(plist);
|
|
1480
|
+
} catch (e) {
|
|
1481
|
+
return {
|
|
1482
|
+
bookmarks: [],
|
|
1483
|
+
defaultGuid: null,
|
|
1484
|
+
error: `defaults export: ${toolErr(e)}`
|
|
1485
|
+
};
|
|
1486
|
+
} finally {
|
|
1487
|
+
if (dir) try {
|
|
1488
|
+
rmSync(dir, {
|
|
1489
|
+
recursive: true,
|
|
1490
|
+
force: true
|
|
1491
|
+
});
|
|
1410
1492
|
} catch {}
|
|
1411
1493
|
}
|
|
1412
|
-
return out;
|
|
1413
1494
|
}
|
|
1414
|
-
/**
|
|
1415
|
-
function
|
|
1416
|
-
return
|
|
1495
|
+
/** The New Bookmarks entry of that profile name, null if absent. */
|
|
1496
|
+
function bookmarkFrom(read, named) {
|
|
1497
|
+
return read.bookmarks.find((b) => b.Name === named) ?? null;
|
|
1417
1498
|
}
|
|
1418
|
-
/**
|
|
1419
|
-
function
|
|
1420
|
-
return
|
|
1499
|
+
/** The bookmark iTerm marks default, null when it is missing or unreadable. */
|
|
1500
|
+
function defaultBookmarkFrom(read) {
|
|
1501
|
+
return read.defaultGuid ? read.bookmarks.find((b) => b.Guid === read.defaultGuid) ?? null : null;
|
|
1502
|
+
}
|
|
1503
|
+
/** The default profile, read fresh from iTerm's preferences. */
|
|
1504
|
+
function defaultBookmark() {
|
|
1505
|
+
return defaultBookmarkFrom(itermPrefs());
|
|
1421
1506
|
}
|
|
1422
1507
|
/**
|
|
1423
|
-
*
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
* busy, finished or gone still has the inbox line, and nothing here may make
|
|
1427
|
-
* a finishing child's exit path fail.
|
|
1508
|
+
* The pane profile's font: the family of iTerm's default profile at
|
|
1509
|
+
* `fontSize` points ("MesloLGLNFM-Regular 18" + 13 → "MesloLGLNFM-Regular 13"),
|
|
1510
|
+
* or "Menlo-Regular <fontSize>" when the default font cannot be read.
|
|
1428
1511
|
*/
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1512
|
+
function paneFont(font, fontSize) {
|
|
1513
|
+
const idx = (font ?? "").lastIndexOf(" ");
|
|
1514
|
+
if (idx > 0) {
|
|
1515
|
+
const family = font.slice(0, idx);
|
|
1516
|
+
if (!Number.isNaN(parseFloat(font.slice(idx + 1)))) return `${family} ${fontSize}`;
|
|
1517
|
+
}
|
|
1518
|
+
return `Menlo-Regular ${fontSize}`;
|
|
1519
|
+
}
|
|
1520
|
+
/** Write the pai-worker dynamic profile; iTerm2 loads that directory itself. */
|
|
1521
|
+
function writeDynamicProfile(parent, fontSize, read = itermPrefs) {
|
|
1522
|
+
const profile = {
|
|
1523
|
+
Name: PROFILE_NAME,
|
|
1524
|
+
Guid: "pai-worker-dynamic-profile",
|
|
1525
|
+
"Normal Font": paneFont(parent?.["Normal Font"], fontSize),
|
|
1526
|
+
"Close Sessions On End": true
|
|
1527
|
+
};
|
|
1528
|
+
if (parent?.Name && bookmarkFrom(read(), parent.Name)) profile["Dynamic Profile Parent Name"] = parent.Name;
|
|
1529
|
+
const path = dynamicProfilePath();
|
|
1530
|
+
const dir = dirname(path);
|
|
1531
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1532
|
+
const tmp = `${path}.tmp`;
|
|
1533
|
+
writeFileSync(tmp, JSON.stringify({ Profiles: [profile] }, null, 2) + "\n", "utf8");
|
|
1534
|
+
renameSync(tmp, path);
|
|
1437
1535
|
}
|
|
1536
|
+
let warnedPrefs = false;
|
|
1438
1537
|
/**
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1538
|
+
* Profile for new worker panes, creating the small-font one on demand.
|
|
1539
|
+
* "" means "split with the default profile" (also the fallback when the
|
|
1540
|
+
* dynamic profile does not show up in time).
|
|
1541
|
+
*
|
|
1542
|
+
* The profile file is written whenever it is missing — even when iTerm's
|
|
1543
|
+
* preferences cannot be read; then it gets font "Menlo-Regular <fontSize>"
|
|
1544
|
+
* and no parent, and the reason is logged to stderr once.
|
|
1443
1545
|
*/
|
|
1444
|
-
async function
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
const
|
|
1448
|
-
|
|
1449
|
-
if (!
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1546
|
+
async function followProfile(fontSize, read = itermPrefs) {
|
|
1547
|
+
const first = read();
|
|
1548
|
+
const parent = defaultBookmarkFrom(first);
|
|
1549
|
+
const path = dynamicProfilePath();
|
|
1550
|
+
const existed = existsSync(path);
|
|
1551
|
+
if (!existed) writeDynamicProfile(parent, fontSize, () => first);
|
|
1552
|
+
if (!parent) {
|
|
1553
|
+
if (!warnedPrefs) {
|
|
1554
|
+
warnedPrefs = true;
|
|
1555
|
+
const reason = first.error ?? "no profile is marked default";
|
|
1556
|
+
const what = existed ? `keeping ${path} as-is` : `wrote ${path} with Menlo-Regular ${fontSize} and no parent`;
|
|
1557
|
+
process.stderr.write(`pai worker pane: cannot read iTerm's default profile (${reason}) — ${what}\n`);
|
|
1558
|
+
}
|
|
1559
|
+
if (first.error) return "";
|
|
1560
|
+
}
|
|
1561
|
+
for (let i = 0; i < 10; i++) {
|
|
1562
|
+
if (bookmarkFrom(read(), PROFILE_NAME)) return PROFILE_NAME;
|
|
1563
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1564
|
+
}
|
|
1565
|
+
process.stderr.write(`pai worker pane: profile ${PROFILE_NAME} not visible, splitting with default\n`);
|
|
1566
|
+
return "";
|
|
1567
|
+
}
|
|
1568
|
+
function loadRegistry(path) {
|
|
1569
|
+
try {
|
|
1570
|
+
const reg = JSON.parse(readFileSync(path, "utf8"));
|
|
1571
|
+
return Array.isArray(reg) ? reg : [];
|
|
1572
|
+
} catch {
|
|
1573
|
+
return [];
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function saveRegistry(path, reg) {
|
|
1577
|
+
const dir = dirname(path);
|
|
1578
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1579
|
+
const tmp = `${path}.tmp`;
|
|
1580
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 1), "utf8");
|
|
1581
|
+
renameSync(tmp, path);
|
|
1454
1582
|
}
|
|
1455
|
-
|
|
1456
|
-
//#endregion
|
|
1457
|
-
//#region src/workers/worktree.ts
|
|
1458
1583
|
/**
|
|
1459
|
-
*
|
|
1460
|
-
*
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
* main branch only — and the parent (or the operator) merges the result back:
|
|
1466
|
-
*
|
|
1467
|
-
* pai worker merge <id> git merge --no-ff worker/<id> + remove worktree + delete branch
|
|
1468
|
-
* pai worker discard <id> remove worktree and branch, keep nothing
|
|
1469
|
-
*
|
|
1470
|
-
* `ps` marks a worker with an unmerged branch `⎇`. Draft and review run in
|
|
1471
|
-
* place; `--no-worktree` opts out, `--worktree` forces one on.
|
|
1584
|
+
* The command a worker pane runs: one exec-able line with no shell syntax —
|
|
1585
|
+
* iTerm's split command parameter execs without a shell (proven live: panes
|
|
1586
|
+
* survive `sleep 30` and a single-quoted `/bin/sh -c`, die instantly on
|
|
1587
|
+
* `export …; …` or a redirect), so the command must be one exec-able line.
|
|
1588
|
+
* Absolute node on the CLI entry file also sidesteps the panes' bare launchd
|
|
1589
|
+
* PATH (no pai, no node) and the `#!/usr/bin/env node` shebang.
|
|
1472
1590
|
*/
|
|
1473
|
-
function
|
|
1474
|
-
return
|
|
1591
|
+
function followCommand(wid, autoExitSecs) {
|
|
1592
|
+
return `${process.execPath} ${absoluteCliPath()} worker follow ${wid} --auto-exit ${autoExitSecs}`;
|
|
1475
1593
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1594
|
+
/** Absolute path of the running pai CLI; falls back to PATH lookup at spawn. */
|
|
1595
|
+
function absoluteCliPath() {
|
|
1596
|
+
const argv1 = process.argv[1] ?? "";
|
|
1597
|
+
if (argv1.endsWith("pai")) return argv1;
|
|
1598
|
+
return "pai";
|
|
1478
1599
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1600
|
+
/** Open (or only report on) the stacked follow pane of one worker. */
|
|
1601
|
+
async function openPaneForWorker(logDir, config, wid, term) {
|
|
1602
|
+
if (workerPaneOpen(wid)) return `pane for ${wid} already open`;
|
|
1603
|
+
const uid = itermUuid(term);
|
|
1604
|
+
const regPath = join(panesDir(logDir), `${scopeKey(term)}.json`);
|
|
1605
|
+
const reg = loadRegistry(regPath);
|
|
1606
|
+
const profile = await followProfile(config.pane.fontSize);
|
|
1607
|
+
const cmd = followCommand(wid, config.pane.autoExitSecs);
|
|
1608
|
+
const p = await osascript(WORKER_SPLIT_SCRIPT, [
|
|
1609
|
+
uid,
|
|
1610
|
+
[...reg].reverse().map((e) => e.session).join(","),
|
|
1611
|
+
cmd,
|
|
1612
|
+
profile
|
|
1613
|
+
]);
|
|
1614
|
+
const out = p.stdout.trim();
|
|
1615
|
+
if (out === "notfound" || out === "notrunning") throw new Error(out === "notfound" ? "pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID" : "pai worker pane: iTerm2 is not running");
|
|
1616
|
+
const bar = out.indexOf("|");
|
|
1617
|
+
if (bar < 0) throw new Error(`pai worker pane: osascript failed: ${(p.stderr || out).slice(0, 200)}`);
|
|
1618
|
+
const live = new Set(out.slice(0, bar).split(",").filter(Boolean));
|
|
1619
|
+
const pruned = reg.filter((e) => live.has(e.session));
|
|
1620
|
+
pruned.push({
|
|
1621
|
+
session: out.slice(bar + 1),
|
|
1622
|
+
worker: wid,
|
|
1623
|
+
opened: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
|
|
1624
|
+
});
|
|
1625
|
+
saveRegistry(regPath, pruned);
|
|
1626
|
+
return `pane opened for ${wid}`;
|
|
1481
1627
|
}
|
|
1482
|
-
/**
|
|
1483
|
-
|
|
1628
|
+
/**
|
|
1629
|
+
* One `--check` line with the bounds of the window hosting `term`'s iTerm
|
|
1630
|
+
* session — the before/after pair that shows whether a split moved it.
|
|
1631
|
+
* Read-only; never touches the window.
|
|
1632
|
+
*/
|
|
1633
|
+
async function windowBoundsLine(term) {
|
|
1634
|
+
const uid = itermUuid(term);
|
|
1635
|
+
if (!uid) return "window bounds: (not in iTerm2)";
|
|
1484
1636
|
try {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
], {
|
|
1490
|
-
encoding: "utf8",
|
|
1491
|
-
timeout: 3e4,
|
|
1492
|
-
stdio: [
|
|
1493
|
-
"ignore",
|
|
1494
|
-
"pipe",
|
|
1495
|
-
"pipe"
|
|
1496
|
-
]
|
|
1497
|
-
}).trim();
|
|
1637
|
+
const p = await osascript(WINDOW_BOUNDS_SCRIPT, [uid]);
|
|
1638
|
+
const out = p.stdout.trim();
|
|
1639
|
+
if (out && out !== "notfound" && out !== "notrunning") return `window bounds: ${out}`;
|
|
1640
|
+
return `window bounds: (${out === "notfound" ? "iTerm2 session not found" : out === "notrunning" ? "iTerm2 not running" : (p.stderr.trim() || "no output").slice(0, 120)})`;
|
|
1498
1641
|
} catch (e) {
|
|
1499
|
-
|
|
1500
|
-
const why = (typeof err.stderr === "string" ? err.stderr : err.stderr?.toString("utf8")) || err.message || String(e);
|
|
1501
|
-
throw new Error(`git ${args.join(" ")} in ${cwd}: ${why.trim()}`);
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
/** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
|
|
1505
|
-
function isGitRepo(cwd) {
|
|
1506
|
-
try {
|
|
1507
|
-
return git(cwd, ["rev-parse", "--git-dir"]) !== "";
|
|
1508
|
-
} catch {
|
|
1509
|
-
return false;
|
|
1642
|
+
return `window bounds: (osascript: ${String(e.message ?? e).slice(0, 120)})`;
|
|
1510
1643
|
}
|
|
1511
1644
|
}
|
|
1512
1645
|
/**
|
|
1513
|
-
*
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1646
|
+
* Report-only variant used by `pai worker pane <id> --check`: whether a pane
|
|
1647
|
+
* runs for the worker, the bounds of the window hosting the asking session,
|
|
1648
|
+
* plus the dynamic profile's path, its existence, and the font it contains
|
|
1649
|
+
* (or, when missing, would write).
|
|
1516
1650
|
*/
|
|
1517
|
-
function
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
return !promptLooksReadonly(opts.prompt ?? "");
|
|
1651
|
+
async function checkPaneForWorker(wid, fontSize, term) {
|
|
1652
|
+
const lines = [workerPaneOpen(wid) ? `pane for ${wid} open` : `no pane for ${wid}`];
|
|
1653
|
+
lines.push(await windowBoundsLine(term));
|
|
1654
|
+
const path = dynamicProfilePath();
|
|
1655
|
+
if (existsSync(path)) {
|
|
1656
|
+
let font = "(unreadable)";
|
|
1657
|
+
try {
|
|
1658
|
+
font = JSON.parse(readFileSync(path, "utf8")).Profiles?.[0]?.["Normal Font"] ?? "(none)";
|
|
1659
|
+
} catch {}
|
|
1660
|
+
lines.push(`profile file: ${path} (exists)`);
|
|
1661
|
+
lines.push(`profile font: ${font}`);
|
|
1662
|
+
} else {
|
|
1663
|
+
const parent = defaultBookmark();
|
|
1664
|
+
lines.push(`profile file: ${path} (missing)`);
|
|
1665
|
+
lines.push(`profile font (would write): ${paneFont(parent?.["Normal Font"], fontSize)}`);
|
|
1666
|
+
}
|
|
1667
|
+
return lines.join("\n");
|
|
1535
1668
|
}
|
|
1536
1669
|
/**
|
|
1537
|
-
*
|
|
1538
|
-
*
|
|
1539
|
-
*
|
|
1670
|
+
* Split this iTerm tab and run `pai worker follow` in the new pane, unless
|
|
1671
|
+
* one already runs here (detected by TTY — iTerm overwrites session names
|
|
1672
|
+
* with the running command, so names cannot serve as idempotence).
|
|
1540
1673
|
*/
|
|
1541
|
-
function
|
|
1542
|
-
const
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
const
|
|
1549
|
-
const
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1674
|
+
async function openFollowPane(logDir, _config, term, checkOnly) {
|
|
1675
|
+
const uid = itermUuid(term);
|
|
1676
|
+
const out = (await osascript(TAB_TTYS_SCRIPT, [uid])).stdout.trim();
|
|
1677
|
+
if (out === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
|
|
1678
|
+
if (out === "notrunning") throw new Error("pai worker pane: iTerm2 is not running");
|
|
1679
|
+
if ([...new Set(out.split(",").map((t) => t.trim()).filter(Boolean))].filter((t) => followTtys().has(t)).length > 0) return "follow pane already open";
|
|
1680
|
+
if (checkOnly) return "no follow pane";
|
|
1681
|
+
const s = await osascript(SPLIT_SCRIPT, [uid, `${process.execPath} ${absoluteCliPath()} worker follow`]);
|
|
1682
|
+
const sout = s.stdout.trim();
|
|
1683
|
+
if (sout === "opened") return "follow pane opened";
|
|
1684
|
+
if (sout === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
|
|
1685
|
+
throw new Error(`pai worker pane: osascript failed: ${(s.stderr || sout).slice(0, 200)}`);
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
//#endregion
|
|
1689
|
+
//#region src/workers/tree.ts
|
|
1690
|
+
/** The env var the runner sets in every worker's environment. */
|
|
1691
|
+
const WORKER_ID_ENV = "PAI_WORKER_ID";
|
|
1692
|
+
/** The worker this process runs inside, when it runs inside one. */
|
|
1693
|
+
function parentFromEnv(env = process.env) {
|
|
1694
|
+
const id = env[WORKER_ID_ENV];
|
|
1695
|
+
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
1562
1696
|
}
|
|
1563
1697
|
/**
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1698
|
+
* Depth of `id` in the worker forest: 0 for a top-level worker, 1 + the
|
|
1699
|
+
* parent's depth for a sub-worker. Parents without a status file (chain ids,
|
|
1700
|
+
* unknown ids) count as roots; a cycle reads as its own depth and is cut off
|
|
1701
|
+
* after the statuses it walked. `chatIsRoot` stops one level short of the
|
|
1702
|
+
* chat-pane tracker (origin "chat"): the status line treats the pane as the
|
|
1703
|
+
* bar itself, so the workers it spawned are its top level.
|
|
1570
1704
|
*/
|
|
1571
|
-
function
|
|
1572
|
-
const
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
const
|
|
1579
|
-
if (
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
} catch {}
|
|
1583
|
-
let gitDir = null;
|
|
1584
|
-
try {
|
|
1585
|
-
const raw = git(dir, ["rev-parse", "--git-common-dir"]);
|
|
1586
|
-
gitDir = isAbsolute(raw) ? raw : resolve(dir, raw);
|
|
1587
|
-
} catch {
|
|
1588
|
-
gitDir = null;
|
|
1589
|
-
}
|
|
1590
|
-
removeWorktree(gitDir ?? dir, dir, true);
|
|
1591
|
-
if (gitDir) try {
|
|
1592
|
-
git(gitDir, ["worktree", "prune"]);
|
|
1593
|
-
git(gitDir, [
|
|
1594
|
-
"branch",
|
|
1595
|
-
"-D",
|
|
1596
|
-
worktreeBranch(id)
|
|
1597
|
-
]);
|
|
1598
|
-
} catch {}
|
|
1599
|
-
swept.push(id);
|
|
1705
|
+
function workerDepth(statuses, id, opts) {
|
|
1706
|
+
const byId = new Map(statuses.map((s) => [s.id, s]));
|
|
1707
|
+
let depth = 0;
|
|
1708
|
+
let cur = byId.get(id);
|
|
1709
|
+
const seen = new Set([id]);
|
|
1710
|
+
while (cur?.parent && !seen.has(cur.parent)) {
|
|
1711
|
+
seen.add(cur.parent);
|
|
1712
|
+
const next = byId.get(cur.parent);
|
|
1713
|
+
if (!next) break;
|
|
1714
|
+
if (!(opts?.chatIsRoot && next.origin === "chat")) depth += 1;
|
|
1715
|
+
cur = next;
|
|
1600
1716
|
}
|
|
1601
|
-
return
|
|
1717
|
+
return depth;
|
|
1602
1718
|
}
|
|
1603
|
-
/**
|
|
1604
|
-
function
|
|
1605
|
-
|
|
1606
|
-
return parseInt(git(dir, [
|
|
1607
|
-
"rev-list",
|
|
1608
|
-
"--count",
|
|
1609
|
-
`${base}..HEAD`
|
|
1610
|
-
]), 10) || 0;
|
|
1611
|
-
} catch {
|
|
1612
|
-
return 0;
|
|
1613
|
-
}
|
|
1719
|
+
/** Children of `parent` that are still running, oldest first. */
|
|
1720
|
+
function runningChildren(statuses, parent) {
|
|
1721
|
+
return statuses.filter((s) => s.parent === parent && isLive(s));
|
|
1614
1722
|
}
|
|
1615
1723
|
/**
|
|
1616
|
-
*
|
|
1617
|
-
*
|
|
1724
|
+
* Would starting a child of `parent` stay within the caps? Throws with a
|
|
1725
|
+
* clear message when it would not; returns silently when `parent` has no
|
|
1726
|
+
* status file (a chain id or an unknown id — not a worker, not capped).
|
|
1618
1727
|
*/
|
|
1619
|
-
function
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
s.worktreeBase = info.base;
|
|
1626
|
-
} else {
|
|
1627
|
-
removeWorktree(s.cwd, info.dir, true);
|
|
1628
|
-
try {
|
|
1629
|
-
git(s.cwd, [
|
|
1630
|
-
"branch",
|
|
1631
|
-
"-D",
|
|
1632
|
-
info.branch
|
|
1633
|
-
]);
|
|
1634
|
-
} catch {}
|
|
1635
|
-
s.branch = null;
|
|
1636
|
-
s.worktreeDir = null;
|
|
1637
|
-
s.worktreeBase = null;
|
|
1638
|
-
s.commits = null;
|
|
1639
|
-
}
|
|
1640
|
-
saveStatus(logDir, s);
|
|
1641
|
-
return s;
|
|
1728
|
+
function assertChildAllowed(logDir, parent, caps, statuses = loadStatuses(logDir)) {
|
|
1729
|
+
if (!statuses.some((s) => s.id === parent)) return;
|
|
1730
|
+
const depth = workerDepth(statuses, parent);
|
|
1731
|
+
if (depth + 1 > caps.maxDepth) throw new Error(`worker tree: ${parent} sits at depth ${depth} and workers.tree.maxDepth is ${caps.maxDepth} — it cannot start another level of sub-workers. Hand the task up instead: pai worker handoff '{"kind":"proposal","text":"…"}'`);
|
|
1732
|
+
const kids = runningChildren(statuses, parent);
|
|
1733
|
+
if (kids.length >= caps.maxChildren) throw new Error(`worker tree: ${parent} already has ${kids.length} running sub-workers (${kids.map((k) => k.id).join(", ")}) and workers.tree.maxChildren is ${caps.maxChildren} — wait for one to finish, or raise the cap in the workers config`);
|
|
1642
1734
|
}
|
|
1643
|
-
/**
|
|
1644
|
-
|
|
1735
|
+
/**
|
|
1736
|
+
* The parent a launch should record: an explicit one (chain stage) wins, else
|
|
1737
|
+
* the worker this process runs inside. Returns null for top-level runs.
|
|
1738
|
+
*/
|
|
1739
|
+
function launchParent(explicit, env = process.env) {
|
|
1740
|
+
return explicit ?? parentFromEnv(env);
|
|
1741
|
+
}
|
|
1742
|
+
/** Does a status file exist for `id` (i.e. is it a worker rather than a chain)? */
|
|
1743
|
+
function isWorkerId(logDir, id) {
|
|
1744
|
+
return loadStatus(logDir, id) !== null;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
//#endregion
|
|
1748
|
+
//#region src/workers/operator.ts
|
|
1749
|
+
/**
|
|
1750
|
+
* operator.ts — talking to a running worker.
|
|
1751
|
+
*
|
|
1752
|
+
* Headless workers run with `--input-format stream-json` and their stdin held
|
|
1753
|
+
* open, so a run is a conversation: every line sent to the per-worker Unix
|
|
1754
|
+
* socket `<logDir>/<id>.sock` is forwarded to the child as a user message and
|
|
1755
|
+
* mirrored into the transcript as an `operator` event (rendered with a »
|
|
1756
|
+
* marker). Once the worker has finished its turn, stdin closes 2 s later
|
|
1757
|
+
* unless a new message arrives — after that, `say` refuses and `resume`
|
|
1758
|
+
* continues the same Claude session with the worker's context intact.
|
|
1759
|
+
*/
|
|
1760
|
+
function operatorSocketPath(logDir, id) {
|
|
1761
|
+
return join(logDir, `${id}.sock`);
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* The runner's side: listen on the worker socket, hand every received line to
|
|
1765
|
+
* `onLine`. Returns the server (close it when the run ends; the socket file is
|
|
1766
|
+
* unlinked on close, best effort).
|
|
1767
|
+
*/
|
|
1768
|
+
function createOperatorServer(logDir, id, onLine) {
|
|
1769
|
+
const path = operatorSocketPath(logDir, id);
|
|
1645
1770
|
try {
|
|
1646
|
-
|
|
1647
|
-
"worktree",
|
|
1648
|
-
"remove",
|
|
1649
|
-
...force ? ["--force"] : [],
|
|
1650
|
-
dir
|
|
1651
|
-
]);
|
|
1652
|
-
return;
|
|
1771
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1653
1772
|
} catch {}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1773
|
+
const server = createServer((socket) => {
|
|
1774
|
+
let buf = "";
|
|
1775
|
+
socket.on("data", (chunk) => {
|
|
1776
|
+
buf += chunk.toString("utf8");
|
|
1777
|
+
let nl;
|
|
1778
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
1779
|
+
const line = buf.slice(0, nl).replace(/\r$/, "");
|
|
1780
|
+
buf = buf.slice(nl + 1);
|
|
1781
|
+
if (line.trim()) onLine(line);
|
|
1782
|
+
socket.write("ok\n");
|
|
1783
|
+
}
|
|
1658
1784
|
});
|
|
1659
|
-
|
|
1660
|
-
|
|
1785
|
+
});
|
|
1786
|
+
server.listen(path);
|
|
1787
|
+
server.on("close", () => {
|
|
1788
|
+
try {
|
|
1789
|
+
if (existsSync(path)) unlinkSync(path);
|
|
1790
|
+
} catch {}
|
|
1791
|
+
});
|
|
1792
|
+
return server;
|
|
1661
1793
|
}
|
|
1662
1794
|
/**
|
|
1663
|
-
*
|
|
1664
|
-
*
|
|
1665
|
-
* carry used; name-only, not porcelain: a worktree-only change renders as
|
|
1666
|
-
* " M path" and the shared git() helper trims that leading space away.
|
|
1795
|
+
* `pai worker say <id> "<text>"`: forward one line to a running worker.
|
|
1796
|
+
* Resolves "ok", rejects with a clear message when the worker is not running.
|
|
1667
1797
|
*/
|
|
1668
|
-
function
|
|
1669
|
-
const
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1798
|
+
function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
|
|
1799
|
+
const status = loadStatus(logDir, id);
|
|
1800
|
+
if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
|
|
1801
|
+
if (!isLive(status)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} is not running (state: ${status.state}) — continue it instead with: pai worker resume ${id} "<text>"`));
|
|
1802
|
+
const path = operatorSocketPath(logDir, id);
|
|
1803
|
+
if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
|
|
1804
|
+
return new Promise((resolve, reject) => {
|
|
1805
|
+
const sock = connect(path);
|
|
1806
|
+
const fail = (e) => {
|
|
1807
|
+
sock.destroy();
|
|
1808
|
+
reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
|
|
1809
|
+
};
|
|
1810
|
+
sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
|
|
1811
|
+
sock.once("error", (e) => fail(e));
|
|
1812
|
+
sock.once("connect", () => {
|
|
1813
|
+
sock.write(text.replace(/\n/g, " ") + "\n");
|
|
1814
|
+
});
|
|
1815
|
+
sock.once("data", () => {
|
|
1816
|
+
sock.end();
|
|
1817
|
+
resolve("ok");
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1680
1820
|
}
|
|
1821
|
+
|
|
1822
|
+
//#endregion
|
|
1823
|
+
//#region src/workers/handoff.ts
|
|
1681
1824
|
/**
|
|
1682
|
-
*
|
|
1683
|
-
*
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
1825
|
+
* handoff.ts — upward messages between workers.
|
|
1826
|
+
*
|
|
1827
|
+
* Coordination in the worker tree is upward only: a child appends a handoff
|
|
1828
|
+
* to `<logDir>/<parent>.inbox.jsonl` and (when the parent is a running
|
|
1829
|
+
* worker) the same text is said to it, so it lands in the parent's
|
|
1830
|
+
* conversation as `[handoff from <child>] …`. The parent's pane renders the
|
|
1831
|
+
* inbox as `◆` lines, `ps`/`worker_status` show an inbox count, and a child
|
|
1832
|
+
* that finishes delivers its structured report automatically as a
|
|
1833
|
+
* `kind: "result"` handoff. There is no downward or sideways path — telling
|
|
1834
|
+
* a worker something is the operator's job (`pai worker say`).
|
|
1688
1835
|
*/
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
}
|
|
1702
|
-
return paths;
|
|
1836
|
+
const HANDOFF_KINDS = [
|
|
1837
|
+
"proposal",
|
|
1838
|
+
"result",
|
|
1839
|
+
"question",
|
|
1840
|
+
"blocker"
|
|
1841
|
+
];
|
|
1842
|
+
/** Where a worker's handoffs land: <logDir>/<id>.inbox.jsonl. */
|
|
1843
|
+
function inboxPath(logDir, id) {
|
|
1844
|
+
return join(logDir, `${id}.inbox.jsonl`);
|
|
1845
|
+
}
|
|
1846
|
+
function isHandoffKind(v) {
|
|
1847
|
+
return typeof v === "string" && HANDOFF_KINDS.includes(v);
|
|
1703
1848
|
}
|
|
1704
1849
|
/**
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
1850
|
+
* Normalize a raw parsed value into a Handoff, or explain what is missing.
|
|
1851
|
+
* `from`/`to` may be preset by the caller (the CLI fills them from the
|
|
1852
|
+
* environment); everything else must be present and well-formed.
|
|
1708
1853
|
*/
|
|
1709
|
-
function
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1854
|
+
function parseHandoff(v, preset = {}) {
|
|
1855
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error("handoff payload must be a JSON object");
|
|
1856
|
+
const o = v;
|
|
1857
|
+
const from = typeof o.from === "string" && o.from ? o.from : preset.from;
|
|
1858
|
+
const to = typeof o.to === "string" && o.to ? o.to : preset.to;
|
|
1859
|
+
if (!from) throw new Error(`handoff needs "from" (the sending worker id)`);
|
|
1860
|
+
if (!to) throw new Error(`handoff needs "to" (the parent worker id)`);
|
|
1861
|
+
if (!isHandoffKind(o.kind)) throw new Error(`handoff "kind" must be one of: ${HANDOFF_KINDS.join(", ")}`);
|
|
1862
|
+
const text = typeof o.text === "string" ? o.text : "";
|
|
1863
|
+
if (!text.trim()) throw new Error(`handoff needs a non-empty "text"`);
|
|
1864
|
+
const data = typeof o.data === "object" && o.data !== null && !Array.isArray(o.data) ? o.data : void 0;
|
|
1865
|
+
return {
|
|
1866
|
+
from,
|
|
1867
|
+
to,
|
|
1868
|
+
kind: o.kind,
|
|
1869
|
+
text,
|
|
1870
|
+
...data ? { data } : {}
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
/** Append one handoff to its recipient's inbox, stamped now. */
|
|
1874
|
+
function appendHandoff(logDir, h, now = /* @__PURE__ */ new Date()) {
|
|
1875
|
+
const path = inboxPath(logDir, h.to);
|
|
1876
|
+
const dir = dirname(path);
|
|
1877
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
1878
|
+
appendFileSync(path, JSON.stringify({
|
|
1879
|
+
...h,
|
|
1880
|
+
_ts: now.toISOString()
|
|
1881
|
+
}) + "\n", "utf8");
|
|
1882
|
+
}
|
|
1883
|
+
/** Every handoff in a worker's inbox, oldest first; damaged lines skip. */
|
|
1884
|
+
function readInbox(logDir, id) {
|
|
1885
|
+
const path = inboxPath(logDir, id);
|
|
1886
|
+
if (!existsSync(path)) return [];
|
|
1726
1887
|
const out = [];
|
|
1727
|
-
for (
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
if (xy.includes("R") || xy.includes("C")) {
|
|
1733
|
-
const orig = fields[i + 1];
|
|
1734
|
-
if (orig && orig.charAt(2) !== " ") {
|
|
1735
|
-
out.push(strip(orig));
|
|
1736
|
-
i += 1;
|
|
1737
|
-
}
|
|
1738
|
-
}
|
|
1888
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
1889
|
+
if (!line.trim()) continue;
|
|
1890
|
+
try {
|
|
1891
|
+
out.push(JSON.parse(line));
|
|
1892
|
+
} catch {}
|
|
1739
1893
|
}
|
|
1740
1894
|
return out;
|
|
1741
1895
|
}
|
|
1742
|
-
/**
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
* leaving a half-state. No merge is made, the worktree stays.
|
|
1746
|
-
*/
|
|
1747
|
-
function assertNoDirtyOverlap(cwd, incoming, id, branch) {
|
|
1748
|
-
const dirty = new Set(dirtyPaths(cwd));
|
|
1749
|
-
const overlap = [...new Set(incoming)].filter((p) => dirty.has(p)).sort();
|
|
1750
|
-
if (!overlap.length) return;
|
|
1751
|
-
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.`);
|
|
1896
|
+
/** How the say path prefixes a handoff so the parent model knows its source. */
|
|
1897
|
+
function handoffMessage(h) {
|
|
1898
|
+
return `[handoff from ${h.from}] (${h.kind}) ${h.text.replace(/\s+/g, " ").trim()}`;
|
|
1752
1899
|
}
|
|
1753
|
-
/**
|
|
1754
|
-
function
|
|
1755
|
-
|
|
1756
|
-
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`);
|
|
1900
|
+
/** True for the exact shape of handoffMessage() — the runner marks the mirror. */
|
|
1901
|
+
function isHandoffMessage(text) {
|
|
1902
|
+
return new RegExp(`^\\[handoff from \\S+\\] \\((${HANDOFF_KINDS.join("|")})\\) `).test(text);
|
|
1757
1903
|
}
|
|
1758
1904
|
/**
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
1764
|
-
* genuinely means it holds nothing, and reporting success there would
|
|
1765
|
-
* destroy the worktree for no gain.
|
|
1905
|
+
* Deliver one handoff: append it to the parent's inbox, then — when the
|
|
1906
|
+
* parent is a running worker with a live operator socket — say it so it
|
|
1907
|
+
* enters the parent's conversation. The say is best effort: a parent that is
|
|
1908
|
+
* busy, finished or gone still has the inbox line, and nothing here may make
|
|
1909
|
+
* a finishing child's exit path fail.
|
|
1766
1910
|
*/
|
|
1767
|
-
function
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
const
|
|
1771
|
-
if ((
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
]), 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}`);
|
|
1776
|
-
const mergeBase = git(st.cwd, [
|
|
1777
|
-
"merge-base",
|
|
1778
|
-
"HEAD",
|
|
1779
|
-
st.branch
|
|
1780
|
-
]);
|
|
1781
|
-
const incomingPaths = git(st.cwd, [
|
|
1782
|
-
"diff",
|
|
1783
|
-
"--name-only",
|
|
1784
|
-
mergeBase,
|
|
1785
|
-
st.branch
|
|
1786
|
-
]).split("\n").filter(Boolean);
|
|
1787
|
-
assertNoDirtyOverlap(st.cwd, incomingPaths, id, st.branch);
|
|
1788
|
-
try {
|
|
1789
|
-
git(st.cwd, [
|
|
1790
|
-
"merge",
|
|
1791
|
-
"--no-ff",
|
|
1792
|
-
st.branch,
|
|
1793
|
-
"-m",
|
|
1794
|
-
`merge worker ${id} (${st.label})`
|
|
1795
|
-
]);
|
|
1796
|
-
} catch (e) {
|
|
1797
|
-
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`);
|
|
1798
|
-
}
|
|
1799
|
-
if (existsSync(st.worktreeDir)) assertWorktreeClean(st.worktreeDir, id);
|
|
1800
|
-
removeWorktree(st.cwd, st.worktreeDir, false);
|
|
1801
|
-
let branchGone = true;
|
|
1802
|
-
try {
|
|
1803
|
-
git(st.cwd, [
|
|
1804
|
-
"branch",
|
|
1805
|
-
"-d",
|
|
1806
|
-
st.branch
|
|
1807
|
-
]);
|
|
1808
|
-
} catch {
|
|
1809
|
-
branchGone = false;
|
|
1810
|
-
}
|
|
1811
|
-
saveStatus(logDir, {
|
|
1812
|
-
...st,
|
|
1813
|
-
merged: true
|
|
1814
|
-
});
|
|
1815
|
-
const base = `merged ${st.branch} into ${st.cwd} (worktree removed${branchGone ? ", branch deleted" : "; branch kept: git refused -d"})`;
|
|
1816
|
-
return salvaged.length ? `${base}; salvaged ${salvaged.length} uncommitted change(s): ${salvaged.join(", ")}` : base;
|
|
1817
|
-
}
|
|
1818
|
-
/** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
|
|
1819
|
-
function discardWorker(logDir, id) {
|
|
1820
|
-
const st = mustHaveBranch(logDir, id);
|
|
1821
|
-
removeWorktree(st.cwd, st.worktreeDir, true);
|
|
1822
|
-
let branchGone = false;
|
|
1823
|
-
try {
|
|
1824
|
-
git(st.cwd, [
|
|
1825
|
-
"branch",
|
|
1826
|
-
"-D",
|
|
1827
|
-
st.branch
|
|
1828
|
-
]);
|
|
1829
|
-
branchGone = true;
|
|
1830
|
-
} catch {
|
|
1831
|
-
branchGone = false;
|
|
1832
|
-
}
|
|
1833
|
-
saveStatus(logDir, {
|
|
1834
|
-
...st,
|
|
1835
|
-
branch: null,
|
|
1836
|
-
worktreeDir: null,
|
|
1837
|
-
worktreeBase: null,
|
|
1838
|
-
commits: null,
|
|
1839
|
-
merged: false
|
|
1840
|
-
});
|
|
1841
|
-
return `discarded worker ${id}: worktree removed${branchGone ? `, branch ${st.branch} deleted` : ""}`;
|
|
1842
|
-
}
|
|
1843
|
-
function mustHaveBranch(logDir, id) {
|
|
1844
|
-
const st = loadStatus(logDir, id);
|
|
1845
|
-
if (!st) throw new Error(`no worker named "${id}"`);
|
|
1846
|
-
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`);
|
|
1847
|
-
return st;
|
|
1911
|
+
async function deliverHandoff(logDir, h, deps = {}, now = /* @__PURE__ */ new Date()) {
|
|
1912
|
+
appendHandoff(logDir, h, now);
|
|
1913
|
+
const say = deps.say ?? ((id, text) => sayToWorker(logDir, id, text));
|
|
1914
|
+
const parent = loadStatus(logDir, h.to);
|
|
1915
|
+
if (parent && isLive(parent)) try {
|
|
1916
|
+
await say(h.to, handoffMessage(h));
|
|
1917
|
+
} catch {}
|
|
1918
|
+
return h;
|
|
1848
1919
|
}
|
|
1849
1920
|
/**
|
|
1850
|
-
*
|
|
1851
|
-
*
|
|
1852
|
-
*
|
|
1921
|
+
* Send a handoff from inside a worker (the `pai worker handoff` CLI and the
|
|
1922
|
+
* MCP tool both land here): the sender comes from PAI_WORKER_ID, the
|
|
1923
|
+
* recipient from its status file's `parent`. Rejects with a clear message
|
|
1924
|
+
* outside a worker or under a parentless worker.
|
|
1853
1925
|
*/
|
|
1854
|
-
function
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1926
|
+
async function handoffFromInside(logDir, env, payload, deps = {}) {
|
|
1927
|
+
const mine = env.PAI_WORKER_ID;
|
|
1928
|
+
if (!mine) throw new Error("not inside a worker — handoffs are sent by workers (PAI_WORKER_ID unset). Talk to a running worker with: pai worker say <id> \"<text>\"");
|
|
1929
|
+
const st = loadStatus(logDir, mine);
|
|
1930
|
+
if (!st) throw new Error(`no status for this worker (${mine}) — cannot find its parent`);
|
|
1931
|
+
if (!st.parent || !loadStatus(logDir, st.parent)) throw new Error(`worker ${mine} has no worker parent to hand off to (parent: ${st.parent ?? "(none)"}) — handoffs go up the worker tree only`);
|
|
1932
|
+
return deliverHandoff(logDir, parseHandoff(payload, {
|
|
1933
|
+
from: mine,
|
|
1934
|
+
to: st.parent
|
|
1935
|
+
}), deps);
|
|
1863
1936
|
}
|
|
1864
1937
|
|
|
1865
1938
|
//#endregion
|
|
@@ -2677,21 +2750,51 @@ async function runWorker(opts) {
|
|
|
2677
2750
|
const logDir = workersLogDir(config);
|
|
2678
2751
|
mkdirSync(logDir, { recursive: true });
|
|
2679
2752
|
if (opts.className === "plan" && !opts._planner) {
|
|
2680
|
-
const { runPlanner } = await import("./planner-
|
|
2753
|
+
const { runPlanner } = await import("./planner-BiH9msqq.mjs");
|
|
2681
2754
|
return runPlanner(opts);
|
|
2682
2755
|
}
|
|
2683
2756
|
const parent = launchParent(opts.parent);
|
|
2684
2757
|
if (parent) assertChildAllowed(logDir, parent, config.tree);
|
|
2685
|
-
const
|
|
2758
|
+
const capability = opts.providerFlag ? null : capabilityForRun(config, {
|
|
2759
|
+
capabilityFlag: opts.capabilityFlag,
|
|
2760
|
+
className: opts.className
|
|
2761
|
+
});
|
|
2762
|
+
const capabilityResolution = capability ? resolveCapabilityRun(config, capability) : null;
|
|
2763
|
+
const target = capabilityResolution ? {
|
|
2764
|
+
providerName: capabilityResolution.providerName,
|
|
2765
|
+
provider: capabilityResolution.provider,
|
|
2766
|
+
modelAlias: null,
|
|
2767
|
+
classMcp: null,
|
|
2768
|
+
via: "class"
|
|
2769
|
+
} : resolveTarget(config, logDir, {
|
|
2686
2770
|
flagProvider: opts.providerFlag,
|
|
2687
2771
|
className: opts.className
|
|
2688
2772
|
});
|
|
2689
2773
|
assertProviderRunnable(target.providerName, target.provider);
|
|
2690
2774
|
const parsed = parseRunnerArgs(opts.claudeArgs);
|
|
2691
2775
|
const label = opts.label ?? shortText(parsed.prompt ?? UNLABELED, 70);
|
|
2692
|
-
const model = resolveRunModel(target, opts.className, opts.modelFlag);
|
|
2776
|
+
const model = capabilityResolution ? capabilityResolution.model : resolveRunModel(target, opts.className, opts.modelFlag);
|
|
2693
2777
|
const reportFormat = resolveReportFormat(opts.reportFormatFlag);
|
|
2694
2778
|
try {
|
|
2779
|
+
if (target.provider.engine === "image") return await executeImageRun({
|
|
2780
|
+
config,
|
|
2781
|
+
logDir,
|
|
2782
|
+
target,
|
|
2783
|
+
model,
|
|
2784
|
+
label,
|
|
2785
|
+
parsed,
|
|
2786
|
+
capability: capability ?? "image",
|
|
2787
|
+
outPath: opts.imageOut,
|
|
2788
|
+
size: opts.imageSize,
|
|
2789
|
+
timeoutMs: opts.imageTimeoutMs,
|
|
2790
|
+
cwd: opts.cwd,
|
|
2791
|
+
specPath: opts.specPath,
|
|
2792
|
+
parent: parent ?? void 0,
|
|
2793
|
+
stage: opts.stage,
|
|
2794
|
+
quiet: opts.quiet,
|
|
2795
|
+
onWorkerStart: opts.onWorkerStart,
|
|
2796
|
+
id: opts.id
|
|
2797
|
+
});
|
|
2695
2798
|
if (target.provider.engine === "codex") return await executeCodexRun({
|
|
2696
2799
|
config,
|
|
2697
2800
|
logDir,
|
|
@@ -2710,7 +2813,8 @@ async function runWorker(opts) {
|
|
|
2710
2813
|
id: opts.id,
|
|
2711
2814
|
worktreeFlag: opts.worktreeFlag,
|
|
2712
2815
|
className: opts.className,
|
|
2713
|
-
reportFormat
|
|
2816
|
+
reportFormat,
|
|
2817
|
+
capability: capability ?? void 0
|
|
2714
2818
|
});
|
|
2715
2819
|
return await executeRun({
|
|
2716
2820
|
config,
|
|
@@ -2729,6 +2833,7 @@ async function runWorker(opts) {
|
|
|
2729
2833
|
quiet: opts.quiet,
|
|
2730
2834
|
onWorkerStart: opts.onWorkerStart,
|
|
2731
2835
|
id: opts.id,
|
|
2836
|
+
capability: capability ?? void 0,
|
|
2732
2837
|
worktreeFlag: opts.worktreeFlag,
|
|
2733
2838
|
className: opts.className,
|
|
2734
2839
|
reroutes: opts._reroutes ?? 0,
|
|
@@ -2840,6 +2945,104 @@ function interactiveMcpTools(explicitMcp, callerTools, projectLaunch) {
|
|
|
2840
2945
|
tools: !callerTools && projectLaunch?.tools?.length ? projectLaunch.tools : []
|
|
2841
2946
|
};
|
|
2842
2947
|
}
|
|
2948
|
+
async function executeImageRun(a) {
|
|
2949
|
+
const { logDir, target, model, label, parsed } = a;
|
|
2950
|
+
if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine image) supports headless runs only: pass the task with -p '<prompt>'`);
|
|
2951
|
+
const wid = a.id ?? newWorkerId();
|
|
2952
|
+
const cwd = a.cwd ?? process.cwd();
|
|
2953
|
+
const term = process.env.ITERM_SESSION_ID ?? "";
|
|
2954
|
+
const outPath = a.outPath ?? join(logDir, `${wid}.png`);
|
|
2955
|
+
const status = {
|
|
2956
|
+
id: wid,
|
|
2957
|
+
pid: process.pid,
|
|
2958
|
+
label,
|
|
2959
|
+
cwd,
|
|
2960
|
+
term,
|
|
2961
|
+
provider: target.providerName,
|
|
2962
|
+
model,
|
|
2963
|
+
state: "running",
|
|
2964
|
+
started: nowStamp(),
|
|
2965
|
+
updated: nowStamp(),
|
|
2966
|
+
turns: 0,
|
|
2967
|
+
tools: 0,
|
|
2968
|
+
last: "generating image",
|
|
2969
|
+
rc: null,
|
|
2970
|
+
secs: null,
|
|
2971
|
+
origin: "spawn",
|
|
2972
|
+
outputFormat: parsed.outputFormat,
|
|
2973
|
+
reportFormat: "json",
|
|
2974
|
+
...a.parent ? {
|
|
2975
|
+
parent: a.parent,
|
|
2976
|
+
stage: a.stage
|
|
2977
|
+
} : {},
|
|
2978
|
+
...a.specPath ? { spec: a.specPath } : {}
|
|
2979
|
+
};
|
|
2980
|
+
saveStatus(logDir, status);
|
|
2981
|
+
a.onWorkerStart?.(wid);
|
|
2982
|
+
const ledger = ledgerPath(logDir);
|
|
2983
|
+
appendLedger(ledger, "WORKER-START", {
|
|
2984
|
+
id: wid,
|
|
2985
|
+
provider: target.providerName,
|
|
2986
|
+
mode: "headless",
|
|
2987
|
+
engine: "image",
|
|
2988
|
+
model,
|
|
2989
|
+
cwd,
|
|
2990
|
+
label,
|
|
2991
|
+
capability: a.capability,
|
|
2992
|
+
...a.specPath ? { spec: a.specPath } : {}
|
|
2993
|
+
});
|
|
2994
|
+
const t0 = Date.now();
|
|
2995
|
+
try {
|
|
2996
|
+
const result = await runImageCapability({
|
|
2997
|
+
providerName: target.providerName,
|
|
2998
|
+
provider: target.provider,
|
|
2999
|
+
model,
|
|
3000
|
+
prompt: parsed.prompt,
|
|
3001
|
+
outPath,
|
|
3002
|
+
size: a.size,
|
|
3003
|
+
timeoutMs: a.timeoutMs
|
|
3004
|
+
});
|
|
3005
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
3006
|
+
status.state = "done";
|
|
3007
|
+
status.rc = 0;
|
|
3008
|
+
status.secs = secs;
|
|
3009
|
+
status.last = `wrote ${result.path}`;
|
|
3010
|
+
saveStatus(logDir, status);
|
|
3011
|
+
appendLedger(ledger, "WORKER-END", {
|
|
3012
|
+
id: wid,
|
|
3013
|
+
provider: target.providerName,
|
|
3014
|
+
mode: "headless",
|
|
3015
|
+
engine: "image",
|
|
3016
|
+
model,
|
|
3017
|
+
rc: 0,
|
|
3018
|
+
secs,
|
|
3019
|
+
label,
|
|
3020
|
+
capability: a.capability
|
|
3021
|
+
});
|
|
3022
|
+
if (!a.quiet) process.stdout.write(JSON.stringify(result) + "\n");
|
|
3023
|
+
return 0;
|
|
3024
|
+
} catch (e) {
|
|
3025
|
+
const secs = Math.round((Date.now() - t0) / 1e3);
|
|
3026
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
3027
|
+
status.state = "failed";
|
|
3028
|
+
status.rc = 1;
|
|
3029
|
+
status.secs = secs;
|
|
3030
|
+
status.last = message;
|
|
3031
|
+
saveStatus(logDir, status);
|
|
3032
|
+
appendLedger(ledger, "WORKER-END", {
|
|
3033
|
+
id: wid,
|
|
3034
|
+
provider: target.providerName,
|
|
3035
|
+
mode: "headless",
|
|
3036
|
+
engine: "image",
|
|
3037
|
+
model,
|
|
3038
|
+
rc: 1,
|
|
3039
|
+
secs,
|
|
3040
|
+
label,
|
|
3041
|
+
capability: a.capability
|
|
3042
|
+
});
|
|
3043
|
+
throw e;
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
2843
3046
|
async function executeRun(a) {
|
|
2844
3047
|
const { config, logDir, target, model, label, parsed, noPane } = a;
|
|
2845
3048
|
const headless = parsed.headless;
|
|
@@ -2965,6 +3168,7 @@ async function executeRun(a) {
|
|
|
2965
3168
|
model,
|
|
2966
3169
|
cwd,
|
|
2967
3170
|
label,
|
|
3171
|
+
...a.capability ? { capability: a.capability } : {},
|
|
2968
3172
|
...a.specPath ? { spec: a.specPath } : {}
|
|
2969
3173
|
});
|
|
2970
3174
|
if (headless && !noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch(() => {});
|
|
@@ -3321,7 +3525,8 @@ async function executeCodexRun(a) {
|
|
|
3321
3525
|
engine: "codex",
|
|
3322
3526
|
model,
|
|
3323
3527
|
cwd,
|
|
3324
|
-
label
|
|
3528
|
+
label,
|
|
3529
|
+
...a.capability ? { capability: a.capability } : {}
|
|
3325
3530
|
});
|
|
3326
3531
|
const dropped = codexDroppedFlags(a.claudeArgs);
|
|
3327
3532
|
if (dropped.length) appendLedger(ledger, "WORKER-NOTE", {
|
|
@@ -3575,5 +3780,5 @@ function resultFromOutput(out) {
|
|
|
3575
3780
|
}
|
|
3576
3781
|
|
|
3577
3782
|
//#endregion
|
|
3578
|
-
export {
|
|
3579
|
-
//# sourceMappingURL=run-
|
|
3783
|
+
export { contextLabel as A, setWorkerLabel as B, mergeWorker as C, UNLABELED as D, ledgerSummary as E, loadStatuses as F, describeMcp as G, longInlinePromptHint as H, newWorkerId as I, expandMcpNames as K, nowStamp as L, isChatPane as M, isLive as N, ageOf as O, loadStatus as P, ownsPid as R, discardWorker as S, appendLedger as T, parseRunnerArgs as U, waitForTerminalStatus as V, shortText as W, itermUuid as _, projectLaunchConfig as a, sessionTag as b, handoffFromInside as c, workerDepth as d, checkPaneForWorker as f, itermForClaudeSession as g, currentTabKey as h, usageContextTokens as i, contextPercent as j, alive as k, readInbox as l, openPaneForWorker as m, runWorker as n, parseWorkerReport as o, openFollowPane as p, testProvider as r, renderReport as s, printResult as t, sayToWorker as u, resolveSession as v, worktreesDir as w, workerInScope as x, sessionMapPath as y, saveStatus as z };
|
|
3784
|
+
//# sourceMappingURL=run-TChOnMO_.mjs.map
|