@tekmidian/pai 0.49.0 → 0.51.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.
Files changed (109) hide show
  1. package/dist/{aibroker-client-D5NT0S1N.mjs → aibroker-client-Bu72yPKj.mjs} +1 -1
  2. package/dist/{aibroker-client-Bn9SnGMa.mjs → aibroker-client-CDIXmq-e.mjs} +1 -1
  3. package/dist/{aibroker-client-Bn9SnGMa.mjs.map → aibroker-client-CDIXmq-e.mjs.map} +1 -1
  4. package/dist/{chain-CNSa0A3G.mjs → chain-BBbVbWVv.mjs} +4 -4
  5. package/dist/{chain-CNSa0A3G.mjs.map → chain-BBbVbWVv.mjs.map} +1 -1
  6. package/dist/cli/index.mjs +14 -14
  7. package/dist/cli/program.mjs +14 -14
  8. package/dist/config-Bn9jpblO.mjs +653 -0
  9. package/dist/config-Bn9jpblO.mjs.map +1 -0
  10. package/dist/{config-DxMZjpvY.mjs → config-DYXARgk8.mjs} +180 -151
  11. package/dist/config-DYXARgk8.mjs.map +1 -0
  12. package/dist/config-RjeVGJ7K.mjs +3 -0
  13. package/dist/{context-handover-cache-B9NKSjwR.mjs → context-handover-cache-yOuK9RIk.mjs} +5 -5
  14. package/dist/{context-handover-cache-B9NKSjwR.mjs.map → context-handover-cache-yOuK9RIk.mjs.map} +1 -1
  15. package/dist/daemon/index.mjs +13 -12
  16. package/dist/daemon/index.mjs.map +1 -1
  17. package/dist/{daemon-DZ4Q0jrs.mjs → daemon-CBTQYtHF.mjs} +49 -30
  18. package/dist/daemon-CBTQYtHF.mjs.map +1 -0
  19. package/dist/daemon-Vf7pHnjR.mjs +25 -0
  20. package/dist/daemon-mcp/index.mjs +186 -23
  21. package/dist/daemon-mcp/index.mjs.map +1 -1
  22. package/dist/factory-Ba8j9K7q.mjs +3 -0
  23. package/dist/{factory-DNYAqjH0.mjs → factory-Bt7TWQje.mjs} +3 -3
  24. package/dist/{factory-DNYAqjH0.mjs.map → factory-Bt7TWQje.mjs.map} +1 -1
  25. package/dist/{fallback-IeR2FrFn.mjs → fallback-CIGspCSi.mjs} +260 -10
  26. package/dist/fallback-CIGspCSi.mjs.map +1 -0
  27. package/dist/hooks/capture-all-events.mjs +80 -8
  28. package/dist/hooks/capture-all-events.mjs.map +4 -4
  29. package/dist/hooks/context-compression-hook.mjs +54 -39
  30. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  31. package/dist/hooks/post-compact-inject.mjs +54 -39
  32. package/dist/hooks/post-compact-inject.mjs.map +4 -4
  33. package/dist/hooks/route-agents-to-worker.mjs +204 -109
  34. package/dist/hooks/route-agents-to-worker.mjs.map +4 -4
  35. package/dist/hooks/whisper-rules.mjs +7613 -9
  36. package/dist/hooks/whisper-rules.mjs.map +4 -4
  37. package/dist/hooks/worker-proxy.mjs +204 -109
  38. package/dist/hooks/worker-proxy.mjs.map +4 -4
  39. package/dist/hooks/worker-status-line.mjs +211 -116
  40. package/dist/hooks/worker-status-line.mjs.map +4 -4
  41. package/dist/hooks/worker-supervision.mjs +208 -113
  42. package/dist/hooks/worker-supervision.mjs.map +4 -4
  43. package/dist/{kg-entity-BSjM5xZh.mjs → kg-entity-BSW7ra8i.mjs} +1 -1
  44. package/dist/{kg-entity-BSjM5xZh.mjs.map → kg-entity-BSW7ra8i.mjs.map} +1 -1
  45. package/dist/main-resolver-BTastpp4.mjs +7 -0
  46. package/dist/{main-resolver-DvYu9jbl.mjs → main-resolver-BmZPCmgj.mjs} +3 -3
  47. package/dist/{main-resolver-DvYu9jbl.mjs.map → main-resolver-BmZPCmgj.mjs.map} +1 -1
  48. package/dist/{planner-Cn8HmT7l.mjs → planner-BiH9msqq.mjs} +6 -6
  49. package/dist/{planner-Cn8HmT7l.mjs.map → planner-BiH9msqq.mjs.map} +1 -1
  50. package/dist/{program-D9-wfmkZ.mjs → program-srUeFUfW.mjs} +5806 -5615
  51. package/dist/program-srUeFUfW.mjs.map +1 -0
  52. package/dist/query-feedback-Bm6XvCmg.mjs +3 -0
  53. package/dist/{query-feedback-CYyOGi3y.mjs → query-feedback-CTs_jx6i.mjs} +1 -1
  54. package/dist/{query-feedback-CYyOGi3y.mjs.map → query-feedback-CTs_jx6i.mjs.map} +1 -1
  55. package/dist/{run-Cls0YGgV.mjs → run-TChOnMO_.mjs} +1388 -1183
  56. package/dist/run-TChOnMO_.mjs.map +1 -0
  57. package/dist/{server-Ch99xt5K.mjs → server-R9UkCn3v.mjs} +42 -3
  58. package/dist/server-R9UkCn3v.mjs.map +1 -0
  59. package/dist/session-keepalive-BuG3wvik.mjs +579 -0
  60. package/dist/session-keepalive-BuG3wvik.mjs.map +1 -0
  61. package/dist/skills/VaultContext/SKILL.md +1 -1
  62. package/dist/{state-DnYGRmS7.mjs → state-Ca0ltLgI.mjs} +1 -1
  63. package/dist/{state-PJrgfDLW.mjs → state-DwrsOoMi.mjs} +1 -1
  64. package/dist/{state-PJrgfDLW.mjs.map → state-DwrsOoMi.mjs.map} +1 -1
  65. package/dist/{tools-B77WHeVZ.mjs → tools-BEqiJt_j.mjs} +13 -13
  66. package/dist/{tools-B77WHeVZ.mjs.map → tools-BEqiJt_j.mjs.map} +1 -1
  67. package/dist/{tools-NnR_n-Ja.mjs → tools-BwsOTmjh.mjs} +2 -2
  68. package/dist/{wakeup-UrhiW-KU.mjs → wakeup-Yk5lQ0jb.mjs} +1 -1
  69. package/dist/{wakeup-UrhiW-KU.mjs.map → wakeup-Yk5lQ0jb.mjs.map} +1 -1
  70. package/dist/{work-queue-worker-BA5IDBwQ.mjs → work-queue-worker-B9zVWImP.mjs} +3 -3
  71. package/dist/{work-queue-worker-BA5IDBwQ.mjs.map → work-queue-worker-B9zVWImP.mjs.map} +1 -1
  72. package/dist/work-queue-worker-D-DnQ7oh.mjs +14 -0
  73. package/dist/{zettelkasten-BBjA7GcP.mjs → zettelkasten-DezbXghl.mjs} +2 -2
  74. package/dist/{zettelkasten-BBjA7GcP.mjs.map → zettelkasten-DezbXghl.mjs.map} +1 -1
  75. package/docs/cache-keepalive.md +138 -0
  76. package/docs/commands/README.md +7 -0
  77. package/docs/commands/audit.md +1 -1
  78. package/docs/commands/config.md +84 -0
  79. package/docs/commands/daemon.md +6 -0
  80. package/docs/commands/worker.md +37 -2
  81. package/docs/config.md +171 -0
  82. package/docs/model-catalogue.md +502 -0
  83. package/docs/provider-independence-details.md +1 -1
  84. package/docs/task-bus.md +1 -1
  85. package/docs/worker.md +33 -5
  86. package/docs/workers-config.md +105 -12
  87. package/package.json +1 -1
  88. package/src/hooks/ts/capture-all-events.test.ts +71 -0
  89. package/src/hooks/ts/capture-all-events.ts +10 -3
  90. package/src/hooks/ts/lib/context-fill.test.ts +124 -46
  91. package/src/hooks/ts/lib/context-fill.ts +57 -14
  92. package/src/hooks/ts/user-prompt/whisper-rules.test.ts +56 -1
  93. package/src/hooks/ts/user-prompt/whisper-rules.ts +24 -0
  94. package/statusline-command.sh +103 -16
  95. package/dist/config-DCu8THKi.mjs +0 -228
  96. package/dist/config-DCu8THKi.mjs.map +0 -1
  97. package/dist/config-DeqFqa1N.mjs +0 -3
  98. package/dist/config-DxMZjpvY.mjs.map +0 -1
  99. package/dist/daemon-CYpVy_Dw.mjs +0 -24
  100. package/dist/daemon-DZ4Q0jrs.mjs.map +0 -1
  101. package/dist/factory-47Qf_df7.mjs +0 -3
  102. package/dist/fallback-IeR2FrFn.mjs.map +0 -1
  103. package/dist/indexer-C5Le7XE7.mjs +0 -1
  104. package/dist/main-resolver-CnfnWa8C.mjs +0 -7
  105. package/dist/program-D9-wfmkZ.mjs.map +0 -1
  106. package/dist/query-feedback-C7rSfiXU.mjs +0 -3
  107. package/dist/run-Cls0YGgV.mjs.map +0 -1
  108. package/dist/server-Ch99xt5K.mjs.map +0 -1
  109. 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 { _ as providerKeyPath, b as resolveProviderKey, c as classModelCapability, f as isModelCapability, o as WorkersConfigError, s as assertProviderRunnable, v as readWorkersSection, y as resolveModelCapability } from "./config-DxMZjpvY.mjs";
3
- import { _ as workersLogDir, d as setCooldown, f as ensureNoMcpConfig, g as statusPath, h as panesDir, i as buildRunEnv, m as ledgerPath, n as ensureProxyRunning, o as isQuotaFailure, p as eventsPath, s as nextAutoProvider, t as DEFAULT_PROXY_PORT, u as resolveTarget } from "./server-Ch99xt5K.mjs";
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/scope.ts
541
+ //#region src/workers/worktree.ts
542
542
  /**
543
- * scope.ts — which workers "belong" to the terminal asking about them.
544
- *
545
- * Two identities, in priority order:
543
+ * worktree.ts — one git worktree per writing worker.
546
544
  *
547
- * 1. AIBroker's session registry (~/.aibroker/session-names.json): iTerm
548
- * session UUID → session name. The launching session's UUID is stored in
549
- * each worker's status file as `session.id`, so every pane of a named
550
- * session sees its workers — regardless of tab layout.
551
- * 2. The iTerm tab key (`w<window>t<tab>` prefix of ITERM_SESSION_ID): the
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
- * If neither is available, viewers fall back to "all workers".
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
- * A third case: workers spawned from a Claude Code Bash tool (the
558
- * orchestrator pattern) have neither — Claude Code exports no terminal or
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 tabKey(term) {
569
- if (!term) return "";
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
- /** Tab key of the iTerm tab this process runs in, "" if not inside iTerm2. */
576
- function currentTabKey(env = process.env) {
577
- return tabKey(env.ITERM_SESSION_ID ?? "");
560
+ function worktreeBranch(id) {
561
+ return `worker/${id}`;
578
562
  }
579
- /** The iTerm UUID part of an ITERM_SESSION_ID (after the last colon). */
580
- function itermUuid(term) {
581
- if (!term) return "";
582
- return term.split(":").pop() ?? "";
563
+ function worktreePath(logDir, id) {
564
+ return join(worktreesDir(logDir), id);
583
565
  }
584
- /**
585
- * Resolve the AIBroker session for an ITERM_SESSION_ID. Reads the persistent
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
- if (!existsSync(registryPath)) return null;
596
- names = JSON.parse(readFileSync(registryPath, "utf8"));
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 null;
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
- * True when `worker` was launched from `term` (the terminal asking about it).
609
- * Session-id match first; the tab key only when no registry entry matched —
610
- * a session without a name keeps the tab-scoped behaviour it always had.
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 workerInScope(worker, term) {
613
- if (!term) return false;
614
- const uuid = itermUuid(term);
615
- if (worker.session?.id && uuid) return worker.session.id === uuid;
616
- return tabKey(worker.term) === tabKey(term) && tabKey(term) !== "";
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
- * Registry key for pane files: the session id when AIBroker knows this
620
- * terminal, else the tab key, else the raw iTerm UUID.
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 scopeKey(term) {
623
- const session = resolveSession(term);
624
- if (session) return session.id;
625
- return currentTabKey() || itermUuid(term);
626
- }
627
- /** `[Name]` when the worker has an AIBroker session name, else "". */
628
- function sessionTag(worker) {
629
- return worker.session?.name ? `[${worker.session.name}]` : "";
630
- }
631
- /** How old a map entry may be for a spawn to adopt it (status lines refresh constantly while a session lives). */
632
- const SPAWNER_SESSION_TTL_MS = 10 * 6e4;
633
- /** Map entries not refreshed within this window are pruned on write. */
634
- const SESSION_MAP_PRUNE_MS = 60 * 6e4;
635
- function sessionMapPath(logDir) {
636
- return join(logDir, "claude-session-map.json");
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
- * The claude session a new run was spawned by, when it can be known: a run
640
- * inside another worker inherits its spawner (chain stages keep the
641
- * orchestrator's tab that way); otherwise a fresh map entry for `cwd`.
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 resolveSpawnerSession(logDir, cwd, env = process.env, now = Date.now()) {
644
- const parentId = env.PAI_WORKER_ID;
645
- if (parentId) {
646
- const inherited = loadStatus(logDir, parentId)?.spawnerSession;
647
- if (inherited) return inherited;
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
- const path = sessionMapPath(logDir);
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
- * The iTerm UUID a claude session last rendered its status line in, when the
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
- map = JSON.parse(readFileSync(path, "utf8"));
690
+ return parseInt(git(dir, [
691
+ "rev-list",
692
+ "--count",
693
+ `${base}..HEAD`
694
+ ]), 10) || 0;
670
695
  } catch {
671
- return null;
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
- * pane.ts — the per-worker follow pane in iTerm2.
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
- const PROFILE_NAME = "pai-worker";
717
- /** Where the dynamic profile lives (env override keeps tests off the real one). */
718
- function dynamicProfilePath() {
719
- return process.env.PAI_WORKER_PROFILE ?? join(homedir(), "Library", "Application Support", "iTerm2", "DynamicProfiles", "pai-worker.json");
720
- }
721
- const WORKER_SPLIT_SCRIPT = `on run(argv)
722
- set targetID to item 1 of argv
723
- set candList to item 2 of argv
724
- set followCmd to item 3 of argv
725
- set profileName to item 4 of argv
726
- tell application id "com.googlecode.iterm2"
727
- if not running then return "notrunning"
728
- repeat with w in windows
729
- repeat with t in tabs of w
730
- repeat with s in sessions of t
731
- if id of s is targetID then
732
- -- sizing the new session would grow the whole window:
733
- -- pin the window's bounds now and restore them after
734
- -- the split, so the panes share the existing space.
735
- -- copy, never set: set stores the property
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
- return ttys;
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
- * Read New Bookmarks and Default Bookmark Guid from an iTerm preferences
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
- defaultGuid = execFileSync("plutil", [
966
- "-extract",
967
- "Default Bookmark Guid",
968
- "raw",
969
- "-o",
970
- "-",
971
- plistPath
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
- return {
978
- bookmarks,
979
- defaultGuid,
980
- error: null
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
- return readItermPlist(plist);
998
- } catch (e) {
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
- * The pane profile's font: the family of iTerm's default profile at
1027
- * `fontSize` points ("MesloLGLNFM-Regular 18" + 13 → "MesloLGLNFM-Regular 13"),
1028
- * or "Menlo-Regular <fontSize>" when the default font cannot be read.
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 paneFont(font, fontSize) {
1031
- const idx = (font ?? "").lastIndexOf(" ");
1032
- if (idx > 0) {
1033
- const family = font.slice(0, idx);
1034
- if (!Number.isNaN(parseFloat(font.slice(idx + 1)))) return `${family} ${fontSize}`;
1035
- }
1036
- return `Menlo-Regular ${fontSize}`;
1037
- }
1038
- /** Write the pai-worker dynamic profile; iTerm2 loads that directory itself. */
1039
- function writeDynamicProfile(parent, fontSize, read = itermPrefs) {
1040
- const profile = {
1041
- Name: PROFILE_NAME,
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
- * Profile for new worker panes, creating the small-font one on demand.
1057
- * "" means "split with the default profile" (also the fallback when the
1058
- * dynamic profile does not show up in time).
1059
- *
1060
- * The profile file is written whenever it is missing — even when iTerm's
1061
- * preferences cannot be read; then it gets font "Menlo-Regular <fontSize>"
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
- async function followProfile(fontSize, read = itermPrefs) {
1065
- const first = read();
1066
- const parent = defaultBookmarkFrom(first);
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
- const reg = JSON.parse(readFileSync(path, "utf8"));
1089
- return Array.isArray(reg) ? reg : [];
1090
- } catch {
1091
- return [];
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 command a worker pane runs: one exec-able line with no shell syntax —
1103
- * iTerm's split command parameter execs without a shell (proven live: panes
1104
- * survive `sleep 30` and a single-quoted `/bin/sh -c`, die instantly on
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 followCommand(wid, autoExitSecs) {
1110
- return `${process.execPath} ${absoluteCliPath()} worker follow ${wid} --auto-exit ${autoExitSecs}`;
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
- /** Absolute path of the running pai CLI; falls back to PATH lookup at spawn. */
1113
- function absoluteCliPath() {
1114
- const argv1 = process.argv[1] ?? "";
1115
- if (argv1.endsWith("pai")) return argv1;
1116
- return "pai";
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
- /** Open (or only report on) the stacked follow pane of one worker. */
1119
- async function openPaneForWorker(logDir, config, wid, term) {
1120
- if (workerPaneOpen(wid)) return `pane for ${wid} already open`;
1121
- const uid = itermUuid(term);
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
- * One `--check` line with the bounds of the window hosting `term`'s iTerm
1148
- * session — the before/after pair that shows whether a split moved it.
1149
- * Read-only; never touches the window.
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
- async function windowBoundsLine(term) {
1152
- const uid = itermUuid(term);
1153
- if (!uid) return "window bounds: (not in iTerm2)";
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
- const p = await osascript(WINDOW_BOUNDS_SCRIPT, [uid]);
1156
- const out = p.stdout.trim();
1157
- if (out && out !== "notfound" && out !== "notrunning") return `window bounds: ${out}`;
1158
- return `window bounds: (${out === "notfound" ? "iTerm2 session not found" : out === "notrunning" ? "iTerm2 not running" : (p.stderr.trim() || "no output").slice(0, 120)})`;
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
- return `window bounds: (osascript: ${String(e.message ?? e).slice(0, 120)})`;
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
- * Report-only variant used by `pai worker pane <id> --check`: whether a pane
1165
- * runs for the worker, the bounds of the window hosting the asking session,
1166
- * plus the dynamic profile's path, its existence, and the font it contains
1167
- * (or, when missing, would write).
1168
- */
1169
- async function checkPaneForWorker(wid, fontSize, term) {
1170
- const lines = [workerPaneOpen(wid) ? `pane for ${wid} open` : `no pane for ${wid}`];
1171
- lines.push(await windowBoundsLine(term));
1172
- const path = dynamicProfilePath();
1173
- if (existsSync(path)) {
1174
- let font = "(unreadable)";
1175
- try {
1176
- font = JSON.parse(readFileSync(path, "utf8")).Profiles?.[0]?.["Normal Font"] ?? "(none)";
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
- return lines.join("\n");
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
- * Split this iTerm tab and run `pai worker follow` in the new pane, unless
1189
- * one already runs here (detected by TTY — iTerm overwrites session names
1190
- * with the running command, so names cannot serve as idempotence).
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
- async function openFollowPane(logDir, _config, term, checkOnly) {
1193
- const uid = itermUuid(term);
1194
- const out = (await osascript(TAB_TTYS_SCRIPT, [uid])).stdout.trim();
1195
- if (out === "notfound") throw new Error("pai worker pane: no open iTerm2 session matches ITERM_SESSION_ID");
1196
- if (out === "notrunning") throw new Error("pai worker pane: iTerm2 is not running");
1197
- if ([...new Set(out.split(",").map((t) => t.trim()).filter(Boolean))].filter((t) => followTtys().has(t)).length > 0) return "follow pane already open";
1198
- if (checkOnly) return "no follow pane";
1199
- const s = await osascript(SPLIT_SCRIPT, [uid, `${process.execPath} ${absoluteCliPath()} worker follow`]);
1200
- const sout = s.stdout.trim();
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/tree.ts
1208
- /** The env var the runner sets in every worker's environment. */
1209
- const WORKER_ID_ENV = "PAI_WORKER_ID";
1210
- /** The worker this process runs inside, when it runs inside one. */
1211
- function parentFromEnv(env = process.env) {
1212
- const id = env[WORKER_ID_ENV];
1213
- return typeof id === "string" && id.trim() ? id.trim() : null;
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
- * Depth of `id` in the worker forest: 0 for a top-level worker, 1 + the
1217
- * parent's depth for a sub-worker. Parents without a status file (chain ids,
1218
- * unknown ids) count as roots; a cycle reads as its own depth and is cut off
1219
- * after the statuses it walked. `chatIsRoot` stops one level short of the
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 workerDepth(statuses, id, opts) {
1224
- const byId = new Map(statuses.map((s) => [s.id, s]));
1225
- let depth = 0;
1226
- let cur = byId.get(id);
1227
- const seen = new Set([id]);
1228
- while (cur?.parent && !seen.has(cur.parent)) {
1229
- seen.add(cur.parent);
1230
- const next = byId.get(cur.parent);
1231
- if (!next) break;
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
- return depth;
1236
- }
1237
- /** Children of `parent` that are still running, oldest first. */
1238
- function runningChildren(statuses, parent) {
1239
- return statuses.filter((s) => s.parent === parent && isLive(s));
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
- * Would starting a child of `parent` stay within the caps? Throws with a
1243
- * clear message when it would not; returns silently when `parent` has no
1244
- * status file (a chain id or an unknown id — not a worker, not capped).
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 assertChildAllowed(logDir, parent, caps, statuses = loadStatuses(logDir)) {
1247
- if (!statuses.some((s) => s.id === parent)) return;
1248
- const depth = workerDepth(statuses, parent);
1249
- 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":"…"}'`);
1250
- const kids = runningChildren(statuses, parent);
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
- * The parent a launch should record: an explicit one (chain stage) wins, else
1255
- * the worker this process runs inside. Returns null for top-level runs.
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 launchParent(explicit, env = process.env) {
1258
- return explicit ?? parentFromEnv(env);
1031
+ function scopeKey(term) {
1032
+ const session = resolveSession(term);
1033
+ if (session) return session.id;
1034
+ return currentTabKey() || itermUuid(term);
1259
1035
  }
1260
- /** Does a status file exist for `id` (i.e. is it a worker rather than a chain)? */
1261
- function isWorkerId(logDir, id) {
1262
- return loadStatus(logDir, id) !== null;
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
- * operator.ts — talking to a running worker.
1269
- *
1270
- * Headless workers run with `--input-format stream-json` and their stdin held
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 operatorSocketPath(logDir, id) {
1279
- return join(logDir, `${id}.sock`);
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 runner's side: listen on the worker socket, hand every received line to
1283
- * `onLine`. Returns the server (close it when the run ends; the socket file is
1284
- * unlinked on close, best effort).
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 createOperatorServer(logDir, id, onLine) {
1287
- const path = operatorSocketPath(logDir, id);
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
- if (existsSync(path)) unlinkSync(path);
1290
- } catch {}
1291
- const server = createServer((socket) => {
1292
- let buf = "";
1293
- socket.on("data", (chunk) => {
1294
- buf += chunk.toString("utf8");
1295
- let nl;
1296
- while ((nl = buf.indexOf("\n")) >= 0) {
1297
- const line = buf.slice(0, nl).replace(/\r$/, "");
1298
- buf = buf.slice(nl + 1);
1299
- if (line.trim()) onLine(line);
1300
- socket.write("ok\n");
1301
- }
1302
- });
1303
- });
1304
- server.listen(path);
1305
- server.on("close", () => {
1306
- try {
1307
- if (existsSync(path)) unlinkSync(path);
1308
- } catch {}
1309
- });
1310
- return server;
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
- * `pai worker say <id> "<text>"`: forward one line to a running worker.
1314
- * Resolves "ok", rejects with a clear message when the worker is not running.
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
- function sayToWorker(logDir, id, text, timeoutMs = 4e3) {
1317
- const status = loadStatus(logDir, id);
1318
- if (!status) return Promise.reject(/* @__PURE__ */ new Error(`no worker named "${id}"`));
1319
- 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>"`));
1320
- const path = operatorSocketPath(logDir, id);
1321
- if (!existsSync(path)) return Promise.reject(/* @__PURE__ */ new Error(`worker ${id} has no operator socket (${path}) — it may predate this PAI version`));
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 sock = connect(path);
1324
- const fail = (e) => {
1325
- sock.destroy();
1326
- reject(/* @__PURE__ */ new Error(`cannot talk to worker ${id}: ${e.message}`));
1327
- };
1328
- sock.setTimeout(timeoutMs, () => fail(/* @__PURE__ */ new Error("timeout")));
1329
- sock.once("error", (e) => fail(e));
1330
- sock.once("connect", () => {
1331
- sock.write(text.replace(/\n/g, " ") + "\n");
1332
- });
1333
- sock.once("data", () => {
1334
- sock.end();
1335
- resolve("ok");
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
- //#endregion
1341
- //#region src/workers/handoff.ts
1342
- /**
1343
- * handoff.ts — upward messages between workers.
1344
- *
1345
- * Coordination in the worker tree is upward only: a child appends a handoff
1346
- * to `<logDir>/<parent>.inbox.jsonl` and (when the parent is a running
1347
- * worker) the same text is said to it, so it lands in the parent's
1348
- * conversation as `[handoff from <child>] …`. The parent's pane renders the
1349
- * inbox as `◆` lines, `ps`/`worker_status` show an inbox count, and a child
1350
- * that finishes delivers its structured report automatically as a
1351
- * `kind: "result"` handoff. There is no downward or sideways path — telling
1352
- * a worker something is the operator's job (`pai worker say`).
1353
- */
1354
- const HANDOFF_KINDS = [
1355
- "proposal",
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
- function isHandoffKind(v) {
1365
- return typeof v === "string" && HANDOFF_KINDS.includes(v);
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
- * Normalize a raw parsed value into a Handoff, or explain what is missing.
1369
- * `from`/`to` may be preset by the caller (the CLI fills them from the
1370
- * environment); everything else must be present and well-formed.
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 parseHandoff(v, preset = {}) {
1373
- if (typeof v !== "object" || v === null || Array.isArray(v)) throw new Error("handoff payload must be a JSON object");
1374
- const o = v;
1375
- const from = typeof o.from === "string" && o.from ? o.from : preset.from;
1376
- const to = typeof o.to === "string" && o.to ? o.to : preset.to;
1377
- if (!from) throw new Error(`handoff needs "from" (the sending worker id)`);
1378
- if (!to) throw new Error(`handoff needs "to" (the parent worker id)`);
1379
- if (!isHandoffKind(o.kind)) throw new Error(`handoff "kind" must be one of: ${HANDOFF_KINDS.join(", ")}`);
1380
- const text = typeof o.text === "string" ? o.text : "";
1381
- if (!text.trim()) throw new Error(`handoff needs a non-empty "text"`);
1382
- const data = typeof o.data === "object" && o.data !== null && !Array.isArray(o.data) ? o.data : void 0;
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
- from,
1385
- to,
1386
- kind: o.kind,
1387
- text,
1388
- ...data ? { data } : {}
1460
+ bookmarks,
1461
+ defaultGuid,
1462
+ error: null
1389
1463
  };
1390
1464
  }
1391
- /** Append one handoff to its recipient's inbox, stamped now. */
1392
- function appendHandoff(logDir, h, now = /* @__PURE__ */ new Date()) {
1393
- const path = inboxPath(logDir, h.to);
1394
- const dir = dirname(path);
1395
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1396
- appendFileSync(path, JSON.stringify({
1397
- ...h,
1398
- _ts: now.toISOString()
1399
- }) + "\n", "utf8");
1400
- }
1401
- /** Every handoff in a worker's inbox, oldest first; damaged lines skip. */
1402
- function readInbox(logDir, id) {
1403
- const path = inboxPath(logDir, id);
1404
- if (!existsSync(path)) return [];
1405
- const out = [];
1406
- for (const line of readFileSync(path, "utf8").split("\n")) {
1407
- if (!line.trim()) continue;
1408
- try {
1409
- out.push(JSON.parse(line));
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
- /** How the say path prefixes a handoff so the parent model knows its source. */
1415
- function handoffMessage(h) {
1416
- return `[handoff from ${h.from}] (${h.kind}) ${h.text.replace(/\s+/g, " ").trim()}`;
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
- /** True for the exact shape of handoffMessage() — the runner marks the mirror. */
1419
- function isHandoffMessage(text) {
1420
- return new RegExp(`^\\[handoff from \\S+\\] \\((${HANDOFF_KINDS.join("|")})\\) `).test(text);
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
- * Deliver one handoff: append it to the parent's inbox, then — when the
1424
- * parent is a running worker with a live operator socket — say it so it
1425
- * enters the parent's conversation. The say is best effort: a parent that is
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
- async function deliverHandoff(logDir, h, deps = {}, now = /* @__PURE__ */ new Date()) {
1430
- appendHandoff(logDir, h, now);
1431
- const say = deps.say ?? ((id, text) => sayToWorker(logDir, id, text));
1432
- const parent = loadStatus(logDir, h.to);
1433
- if (parent && isLive(parent)) try {
1434
- await say(h.to, handoffMessage(h));
1435
- } catch {}
1436
- return h;
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
- * Send a handoff from inside a worker (the `pai worker handoff` CLI and the
1440
- * MCP tool both land here): the sender comes from PAI_WORKER_ID, the
1441
- * recipient from its status file's `parent`. Rejects with a clear message
1442
- * outside a worker or under a parentless worker.
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 handoffFromInside(logDir, env, payload, deps = {}) {
1445
- const mine = env.PAI_WORKER_ID;
1446
- 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>\"");
1447
- const st = loadStatus(logDir, mine);
1448
- if (!st) throw new Error(`no status for this worker (${mine}) — cannot find its parent`);
1449
- 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`);
1450
- return deliverHandoff(logDir, parseHandoff(payload, {
1451
- from: mine,
1452
- to: st.parent
1453
- }), deps);
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
- * worktree.ts — one git worktree per writing worker.
1460
- *
1461
- * A run whose class edits files (implement, complex, plan) and whose cwd is a
1462
- * git repository gets its own worktree by default: `git worktree add
1463
- * <logDir>/worktrees/<id> -b worker/<id>` from the current HEAD. The worker
1464
- * commits its own work on that branch — the no-commit rule applies to the
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 worktreesDir(logDir) {
1474
- return join(logDir, "worktrees");
1591
+ function followCommand(wid, autoExitSecs) {
1592
+ return `${process.execPath} ${absoluteCliPath()} worker follow ${wid} --auto-exit ${autoExitSecs}`;
1475
1593
  }
1476
- function worktreeBranch(id) {
1477
- return `worker/${id}`;
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
- function worktreePath(logDir, id) {
1480
- return join(worktreesDir(logDir), id);
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
- /** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
1483
- function git(cwd, args) {
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
- return execFileSync("git", [
1486
- "-C",
1487
- cwd,
1488
- ...args
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
- const err = e;
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
- * Does the prompt read as a read-only task? Writing classes default to a
1514
- * worktree; a prompt that only asks to look at things should not pay for one.
1515
- * First-word verbs plus the explicit markers people actually write.
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 promptLooksReadonly(prompt) {
1518
- const p = prompt.trim();
1519
- if (!p) return true;
1520
- if (/\b(read[- ]only|do not (modify|change|edit|write)|don'?t (modify|change|edit|write)|no changes)\b/i.test(p)) return true;
1521
- return /^(review|read|analy[sz]e|research|summar[iy]|inspect|investigate|spotcheck|report|find|list|check|verify|describe|explain|show)\b/i.test(p);
1522
- }
1523
- /** The classes whose runs write files and therefore default to a worktree. */
1524
- const WORKTREE_CLASSES = [
1525
- "implement",
1526
- "complex",
1527
- "plan"
1528
- ];
1529
- /** Should this run get a worktree? Explicit flag first, then the default rule. */
1530
- function worktreeWanted(flag, opts) {
1531
- if (flag !== void 0) return flag;
1532
- if (!opts.className || !WORKTREE_CLASSES.includes(opts.className)) return false;
1533
- if (!isGitRepo(opts.cwd)) return false;
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
- * Create the worktree and branch for `id` from `cwd`'s HEAD. Throws when git
1538
- * refuses (no commits yet, branch exists, …) — the caller decides whether to
1539
- * degrade to an in-place run.
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 addWorktree(logDir, id, cwd) {
1542
- const swept = sweepOrphanWorktrees(logDir);
1543
- if (swept.length) appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
1544
- id,
1545
- note: `swept orphan worktree(s): ${swept.join(", ")}`
1546
- });
1547
- const dir = worktreePath(logDir, id);
1548
- const branch = worktreeBranch(id);
1549
- const base = git(cwd, ["rev-parse", "HEAD"]);
1550
- git(cwd, [
1551
- "worktree",
1552
- "add",
1553
- dir,
1554
- "-b",
1555
- branch
1556
- ]);
1557
- return {
1558
- dir,
1559
- branch,
1560
- base
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
- * Remove worktrees (and their `worker/<id>` branches) whose worker no longer
1565
- * exists — no status file left in the log dir. Killed and failed runs clean
1566
- * up after themselves, but a `kill -9` or a crash strands a directory and a
1567
- * branch; every worktree creation sweeps first so they cannot accumulate.
1568
- * Directories younger than `minAgeMin` minutes are left alone: a run that is
1569
- * just starting owns its worktree a moment before its status file exists.
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 sweepOrphanWorktrees(logDir, minAgeMin = 10) {
1572
- const root = worktreesDir(logDir);
1573
- if (!existsSync(root)) return [];
1574
- const swept = [];
1575
- for (const ent of readdirSync(root, { withFileTypes: true })) {
1576
- if (!ent.isDirectory()) continue;
1577
- const id = ent.name;
1578
- const dir = join(root, id);
1579
- if (existsSync(statusPath(logDir, id))) continue;
1580
- try {
1581
- if ((Date.now() - statSync(dir).mtimeMs) / 6e4 < minAgeMin) continue;
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 swept;
1717
+ return depth;
1602
1718
  }
1603
- /** Commits the branch collected on top of its base. */
1604
- function commitsSince(dir, base) {
1605
- try {
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
- * Record the worktree result in the status file: branch and commit count on
1617
- * success, the worktree cleaned up on failure. Returns the updated status.
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 recordWorktree(logDir, status, info, ok) {
1620
- const s = { ...status };
1621
- if (ok) {
1622
- s.branch = info.branch;
1623
- s.commits = commitsSince(info.dir, info.base);
1624
- s.worktreeDir = info.dir;
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
- /** Remove a worktree directory from git's books and the filesystem. */
1644
- function removeWorktree(cwd, dir, force) {
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
- git(cwd, [
1647
- "worktree",
1648
- "remove",
1649
- ...force ? ["--force"] : [],
1650
- dir
1651
- ]);
1652
- return;
1771
+ if (existsSync(path)) unlinkSync(path);
1653
1772
  } catch {}
1654
- if (existsSync(dir)) try {
1655
- rmSync(dir, {
1656
- recursive: true,
1657
- force: true
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
- git(cwd, ["worktree", "prune"]);
1660
- } catch {}
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
- * Tracked changes (staged or not) plus untracked files — everything a
1664
- * `git add -A` in `wtDir` would commit. The same two calls the old patch
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 uncommittedPaths(wtDir) {
1669
- const tracked = git(wtDir, [
1670
- "diff",
1671
- "--name-only",
1672
- "HEAD"
1673
- ]).split("\n").filter(Boolean);
1674
- const untracked = git(wtDir, [
1675
- "ls-files",
1676
- "--others",
1677
- "--exclude-standard"
1678
- ]).split("\n").filter(Boolean);
1679
- return [...tracked, ...untracked];
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
- * Commit a worktree's uncommitted changes to its branch so the merge carries
1683
- * them. `git merge` only moves committed work, so a worker that stopped
1684
- * without committing would lose its edits to `worktree remove` — exactly
1685
- * what happened live on 2026-09-17. Returns the salvaged paths, [] when the
1686
- * worktree is clean. A failed commit throws with the worktree untouched:
1687
- * its edits are still on disk, so nothing is lost.
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
- function salvageUncommitted(wtDir, label) {
1690
- const paths = uncommittedPaths(wtDir);
1691
- if (!paths.length) return [];
1692
- try {
1693
- git(wtDir, ["add", "-A"]);
1694
- git(wtDir, [
1695
- "commit",
1696
- "-m",
1697
- `salvaged: ${label}`
1698
- ]);
1699
- } catch (e) {
1700
- throw new Error(`cannot salvage the uncommitted changes in ${wtDir} — ${e.message}; nothing was lost: commit them there by hand, then re-run merge`);
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
- * The dirty paths of a checkout, parsed from `git status --porcelain -z`:
1706
- * NUL-separated (a path with a newline in it cannot corrupt the parse),
1707
- * rename entries contribute both sides, and any quoting is stripped.
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 dirtyPaths(cwd) {
1710
- const fields = execFileSync("git", [
1711
- "-C",
1712
- cwd,
1713
- "status",
1714
- "--porcelain",
1715
- "-z"
1716
- ], {
1717
- encoding: "utf8",
1718
- timeout: 3e4,
1719
- stdio: [
1720
- "ignore",
1721
- "pipe",
1722
- "pipe"
1723
- ]
1724
- }).split("\0");
1725
- const strip = (p) => p.startsWith("\"") && p.endsWith("\"") ? p.slice(1, -1) : p;
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 (let i = 0; i < fields.length; i++) {
1728
- const f = fields[i];
1729
- if (!f || f.length < 4 || f.charAt(2) !== " ") continue;
1730
- out.push(strip(f.slice(3)));
1731
- const xy = f.slice(0, 2);
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
- * Refuse the merge when the original checkout is dirty in paths the branch
1744
- * touches: the merge would overwrite those edits or fail on them, either way
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
- /** Never remove a worktree that still holds uncommitted changes. */
1754
- function assertWorktreeClean(wtDir, id) {
1755
- const leftover = uncommittedPaths(wtDir);
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
- * `pai worker merge <id>`: salvage whatever the worker left uncommitted onto
1760
- * its branch, refuse when the original checkout is dirty in paths the branch
1761
- * touches, merge with --no-ff (the merge commit names the worker), then
1762
- * remove the worktree and delete the branch; the status gains `merged: true`.
1763
- * A branch with nothing to merge is refused loudly — after salvage that
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 mergeWorker(logDir, id) {
1768
- const st = mustHaveBranch(logDir, id);
1769
- if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
1770
- const salvaged = existsSync(st.worktreeDir) ? salvageUncommitted(st.worktreeDir, st.label || UNLABELED) : [];
1771
- if ((parseInt(git(st.cwd, [
1772
- "rev-list",
1773
- "--count",
1774
- `HEAD..${st.branch}`
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
- * The paragraph a worktree run's system prompt gains: it may commit on its
1851
- * own branch (the no-commit rule holds for the main branch only), it must not
1852
- * merge or push itself, and the parent or operator merges.
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 worktreeSystemPrompt(id, branch, dir) {
1855
- return [
1856
- "You are running in your own git worktree:",
1857
- ` ${dir} on branch ${branch} (worker id ${id}).`,
1858
- "Commit your work on that branch as you go (git add / git commit) — committing here is expected;",
1859
- "the no-commit rule applies to the main branch only, and this is not it.",
1860
- "Do not merge, rebase or push; the operator merges your branch back with `pai worker merge`.",
1861
- "Use ONLY relative paths inside the worktree, never absolute worktree paths — absolute paths break after merge and leak machine layout."
1862
- ].join("\n");
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-Cn8HmT7l.mjs");
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 target = resolveTarget(config, logDir, {
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 { isLive as A, parseRunnerArgs as B, ledgerSummary as C, contextLabel as D, alive as E, ownsPid as F, describeMcp as H, saveStatus as I, setWorkerLabel as L, loadStatuses as M, newWorkerId as N, contextPercent as O, nowStamp as P, waitForTerminalStatus as R, appendLedger as S, ageOf as T, expandMcpNames as U, shortText as V, currentTabKey as _, projectLaunchConfig as a, sessionTag as b, discardWorker as c, readInbox as d, sayToWorker as f, openPaneForWorker as g, openFollowPane as h, usageContextTokens as i, loadStatus as j, isChatPane as k, mergeWorker as l, checkPaneForWorker as m, runWorker as n, parseWorkerReport as o, workerDepth as p, testProvider as r, renderReport as s, printResult as t, handoffFromInside as u, itermForClaudeSession as v, UNLABELED as w, workerInScope as x, resolveSession as y, longInlinePromptHint as z };
3579
- //# sourceMappingURL=run-Cls0YGgV.mjs.map
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