@tekmidian/pai 0.60.1 → 0.62.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 (93) hide show
  1. package/dist/{async-CLvuY7b-.mjs → async-DNVWtQl2.mjs} +2 -2
  2. package/dist/{async-CLvuY7b-.mjs.map → async-DNVWtQl2.mjs.map} +1 -1
  3. package/dist/{chain-CGyAIHo4.mjs → chain-CuaDawK9.mjs} +4 -4
  4. package/dist/{chain-CGyAIHo4.mjs.map → chain-CuaDawK9.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +13 -13
  6. package/dist/cli/program.mjs +13 -13
  7. package/dist/{context-handover-cache-BzM7Qudn.mjs → context-handover-cache-DFNnlWv2.mjs} +4 -4
  8. package/dist/{context-handover-cache-BzM7Qudn.mjs.map → context-handover-cache-DFNnlWv2.mjs.map} +1 -1
  9. package/dist/daemon/index.mjs +10 -10
  10. package/dist/{daemon-B_TodsIS.mjs → daemon-Bf7Eock4.mjs} +45 -24
  11. package/dist/{daemon-B_TodsIS.mjs.map → daemon-Bf7Eock4.mjs.map} +1 -1
  12. package/dist/daemon-bJi1gDkG.mjs +19 -0
  13. package/dist/daemon-mcp/index.mjs +8 -10
  14. package/dist/daemon-mcp/index.mjs.map +1 -1
  15. package/dist/{factory-EUUhSSoq.mjs → factory-BozZa81q.mjs} +3 -3
  16. package/dist/{factory-EUUhSSoq.mjs.map → factory-BozZa81q.mjs.map} +1 -1
  17. package/dist/{factory-DW7BCJJT.mjs → factory-UO9_DFMN.mjs} +1 -1
  18. package/dist/{fallback-CjdakQVu.mjs → fallback-F0TWGVM2.mjs} +11 -13
  19. package/dist/{fallback-CjdakQVu.mjs.map → fallback-F0TWGVM2.mjs.map} +1 -1
  20. package/dist/{helpers-D9WkIf9R.mjs → helpers-Dj-B314s.mjs} +3 -2
  21. package/dist/{helpers-D9WkIf9R.mjs.map → helpers-Dj-B314s.mjs.map} +1 -1
  22. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  23. package/dist/hooks/context-compression-hook.mjs.map +1 -1
  24. package/dist/hooks/load-project-context.mjs +8513 -133
  25. package/dist/hooks/load-project-context.mjs.map +4 -4
  26. package/dist/hooks/post-compact-inject.mjs.map +1 -1
  27. package/dist/hooks/route-agents-to-worker.mjs +3 -3
  28. package/dist/hooks/route-agents-to-worker.mjs.map +2 -2
  29. package/dist/hooks/security-validator.mjs +58 -58
  30. package/dist/hooks/security-validator.mjs.map +3 -3
  31. package/dist/hooks/stop-hook.mjs +15 -15
  32. package/dist/hooks/stop-hook.mjs.map +3 -3
  33. package/dist/hooks/update-tab-on-action.mjs +14 -12
  34. package/dist/hooks/update-tab-on-action.mjs.map +3 -3
  35. package/dist/hooks/update-tab-titles.mjs +13 -9
  36. package/dist/hooks/update-tab-titles.mjs.map +3 -3
  37. package/dist/hooks/worker-guard.mjs +7859 -0
  38. package/dist/hooks/worker-guard.mjs.map +7 -0
  39. package/dist/hooks/worker-proxy.mjs +3 -3
  40. package/dist/hooks/worker-proxy.mjs.map +2 -2
  41. package/dist/hooks/worker-status-line.mjs +104 -11
  42. package/dist/hooks/worker-status-line.mjs.map +4 -4
  43. package/dist/hooks/worker-supervision.mjs +3 -3
  44. package/dist/hooks/worker-supervision.mjs.map +2 -2
  45. package/dist/index.mjs +1 -1
  46. package/dist/{indexer-backend-DcDtin1O.mjs → indexer-backend-BN9rpp9O.mjs} +2 -2
  47. package/dist/{main-resolver-BFQfU5xo.mjs → main-resolver-BIft_FVF.mjs} +4 -4
  48. package/dist/{main-resolver-BFQfU5xo.mjs.map → main-resolver-BIft_FVF.mjs.map} +1 -1
  49. package/dist/{main-resolver-VMBENifT.mjs → main-resolver-C9-poMdi.mjs} +2 -2
  50. package/dist/{planner-DN967Lvm.mjs → planner-BG9ivPj0.mjs} +6 -6
  51. package/dist/{planner-DN967Lvm.mjs.map → planner-BG9ivPj0.mjs.map} +1 -1
  52. package/dist/{postgres-nvU5pShF.mjs → postgres-CxD6_Dca.mjs} +3 -3
  53. package/dist/{postgres-nvU5pShF.mjs.map → postgres-CxD6_Dca.mjs.map} +1 -1
  54. package/dist/{program-BnsjB57Q.mjs → program-BRvCzhjx.mjs} +249 -82
  55. package/dist/program-BRvCzhjx.mjs.map +1 -0
  56. package/dist/{run-FeM1_UVa.mjs → run-BVQZI79D.mjs} +610 -253
  57. package/dist/run-BVQZI79D.mjs.map +1 -0
  58. package/dist/{run-env-CEHTeIUM.mjs → run-env-BE5xyCwy.mjs} +25 -6
  59. package/dist/run-env-BE5xyCwy.mjs.map +1 -0
  60. package/dist/{server-CekGOJcp.mjs → server-BY6AedmC.mjs} +2 -2
  61. package/dist/{server-CekGOJcp.mjs.map → server-BY6AedmC.mjs.map} +1 -1
  62. package/dist/{session-keepalive-Bc9aT678.mjs → session-keepalive-DJA97K2X.mjs} +4 -4
  63. package/dist/{session-keepalive-Bc9aT678.mjs.map → session-keepalive-DJA97K2X.mjs.map} +1 -1
  64. package/dist/{sqlite-D9p5a4qe.mjs → sqlite-CfKfuggn.mjs} +2 -2
  65. package/dist/{sqlite-D9p5a4qe.mjs.map → sqlite-CfKfuggn.mjs.map} +1 -1
  66. package/dist/{tools-r-7ZG8B5.mjs → tools-B52IvaU6.mjs} +3 -3
  67. package/dist/{tools-qXyHFwNj.mjs → tools-DFdPbX-X.mjs} +2 -2
  68. package/dist/{tools-qXyHFwNj.mjs.map → tools-DFdPbX-X.mjs.map} +1 -1
  69. package/dist/{vault-indexer-DHYBpRZ5.mjs → vault-indexer-mCFus_AM.mjs} +2 -2
  70. package/dist/{vault-indexer-DHYBpRZ5.mjs.map → vault-indexer-mCFus_AM.mjs.map} +1 -1
  71. package/dist/{wakeup-C48hcN6O.mjs → wakeup-DO1O4udw.mjs} +2 -2
  72. package/dist/{wakeup-C48hcN6O.mjs.map → wakeup-DO1O4udw.mjs.map} +1 -1
  73. package/dist/{work-queue-worker-BL4dq5P7.mjs → work-queue-worker-BbrC3I_R.mjs} +2 -2
  74. package/dist/{work-queue-worker-BL4dq5P7.mjs.map → work-queue-worker-BbrC3I_R.mjs.map} +1 -1
  75. package/dist/work-queue-worker-z-AATZ2t.mjs +11 -0
  76. package/docs/commands/README.md +2 -0
  77. package/docs/commands/worker.md +28 -0
  78. package/package.json +2 -2
  79. package/scripts/build-hooks.mjs +6 -5
  80. package/scripts/build-skill-stubs.mjs +6 -5
  81. package/src/hooks/ts/lib/tab-title.ts +18 -0
  82. package/src/hooks/ts/lib/worker-guard.test.ts +188 -0
  83. package/src/hooks/ts/lib/worker-guard.ts +315 -0
  84. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +3 -20
  85. package/src/hooks/ts/pre-tool-use/worker-guard.ts +75 -0
  86. package/src/hooks/ts/session-start/load-project-context.ts +14 -0
  87. package/src/hooks/ts/stop/stop-hook.ts +4 -15
  88. package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -10
  89. package/dist/daemon-jviL3VVJ.mjs +0 -19
  90. package/dist/program-BnsjB57Q.mjs.map +0 -1
  91. package/dist/run-FeM1_UVa.mjs.map +0 -1
  92. package/dist/run-env-CEHTeIUM.mjs.map +0 -1
  93. package/dist/work-queue-worker-DVOVcEnO.mjs +0 -11
@@ -1,9 +1,9 @@
1
- import { i as getRegistryBackend } from "./factory-EUUhSSoq.mjs";
2
- import { D as resolveModelCapability, O as resolveProviderKey, T as readWorkersSection, h as classModelCapability, m as assertProviderRunnable, n as claudeCommand, p as WorkersConfigError, t as buildRunEnv, w as providerKeyPath, y as isModelCapability } from "./run-env-CEHTeIUM.mjs";
3
- import { _ as statusPath, d as resolveTarget, f as setCooldown, g as panesDir, h as ledgerPath, i as capabilityForRun, m as eventsPath, n as ensureProxyRunning, o as isQuotaFailure, p as ensureNoMcpConfig, s as nextAutoProvider, t as DEFAULT_PROXY_PORT, u as resolveCapabilityRun, v as workersLogDir } from "./server-CekGOJcp.mjs";
4
- import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
- import { homedir, tmpdir } from "node:os";
6
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
1
+ import { i as getRegistryBackend } from "./factory-BozZa81q.mjs";
2
+ import { E as readWorkersSection, O as resolveModelCapability, T as providerKeyPath, b as isModelCapability, d as NATIVE_ANTHROPIC_MODELS, g as classModelCapability, h as assertProviderRunnable, k as resolveProviderKey, m as WorkersConfigError, n as claudeCommand, t as buildRunEnv } from "./run-env-BE5xyCwy.mjs";
3
+ import { _ as statusPath, d as resolveTarget, f as setCooldown, g as panesDir, h as ledgerPath, i as capabilityForRun, m as eventsPath, n as ensureProxyRunning, o as isQuotaFailure, p as ensureNoMcpConfig, s as nextAutoProvider, t as DEFAULT_PROXY_PORT, u as resolveCapabilityRun, v as workersLogDir } from "./server-BY6AedmC.mjs";
4
+ import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
5
+ import { homedir, platform, tmpdir } from "node:os";
6
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import { execFileSync, spawn } from "node:child_process";
8
8
  import { connect, createServer } from "node:net";
9
9
  import { createInterface } from "node:readline";
@@ -336,9 +336,10 @@ function saveStatus(logDir, status, d = /* @__PURE__ */ new Date()) {
336
336
  const path = statusPath(logDir, status.id);
337
337
  const baseline = firstWrittenLabel.get(status.id);
338
338
  if (baseline === void 0) firstWrittenLabel.set(status.id, status.label);
339
- else if (status.label === baseline) {
340
- const onDisk = loadStatus(logDir, status.id);
341
- if (onDisk && onDisk.label !== status.label) status.label = onDisk.label;
339
+ const onDisk = baseline === void 0 ? void 0 : loadStatus(logDir, status.id);
340
+ if (onDisk) {
341
+ if (status.label === baseline && onDisk.label !== status.label) status.label = onDisk.label;
342
+ if (onDisk.controlsHeld) status.controlsHeld = true;
342
343
  }
343
344
  const tmp = statusTmpPath(logDir, status.id);
344
345
  if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
@@ -597,7 +598,7 @@ function worktreePath(logDir, id) {
597
598
  return join(worktreesDir(logDir), id);
598
599
  }
599
600
  /** Run git in `cwd` with an optional env override, trimmed stdout, stderr on failure. */
600
- function runGit(cwd, args, env) {
601
+ function runGit(cwd, args, env, timeout = 3e4) {
601
602
  try {
602
603
  return execFileSync("git", [
603
604
  "-C",
@@ -605,7 +606,7 @@ function runGit(cwd, args, env) {
605
606
  ...args
606
607
  ], {
607
608
  encoding: "utf8",
608
- timeout: 3e4,
609
+ timeout,
609
610
  stdio: [
610
611
  "ignore",
611
612
  "pipe",
@@ -620,8 +621,8 @@ function runGit(cwd, args, env) {
620
621
  }
621
622
  }
622
623
  /** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
623
- function git(cwd, args) {
624
- return runGit(cwd, args);
624
+ function git(cwd, args, timeoutMs) {
625
+ return runGit(cwd, args, void 0, timeoutMs);
625
626
  }
626
627
  /** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
627
628
  function isGitRepo(cwd) {
@@ -631,30 +632,23 @@ function isGitRepo(cwd) {
631
632
  return false;
632
633
  }
633
634
  }
634
- /**
635
- * Does the prompt read as a read-only task? Writing classes default to a
636
- * worktree; a prompt that only asks to look at things should not pay for one.
637
- * First-word verbs plus the explicit markers people actually write.
638
- */
639
- function promptLooksReadonly(prompt) {
640
- const p = prompt.trim();
641
- if (!p) return true;
642
- const firstLine = p.split("\n", 1)[0];
643
- if (/\b(read[- ]only|do not (modify|change|edit|write)|don'?t (modify|change|edit|write)|no changes)\b/i.test(firstLine)) return true;
644
- return /^(review|read|analy[sz]e|research|summar[iy]|inspect|investigate|spotcheck|report|find|list|check|verify|describe|explain|show)\b/i.test(p);
645
- }
646
- /** The classes whose runs write files and therefore default to a worktree. */
647
- const WORKTREE_CLASSES = [
648
- "implement",
649
- "complex",
650
- "plan"
635
+ /** Tools that can write to the checkout (Bash can, via the shell). */
636
+ const WRITING_TOOLS = [
637
+ "Edit",
638
+ "Write",
639
+ "MultiEdit",
640
+ "NotebookEdit",
641
+ "Bash"
651
642
  ];
652
- /** Should this run get a worktree? Explicit flag first, then the default rule. */
643
+ /** Can a run with these --allowedTools write? No restriction at all means yes. */
644
+ function toolsCanWrite(allowedTools) {
645
+ const names = allowedTools.flatMap((e) => e.split(",")).map((s) => s.trim().replace(/\(.*$/, "")).filter(Boolean);
646
+ return !names.length || names.some((n) => WRITING_TOOLS.includes(n));
647
+ }
648
+ /** Should this run get a worktree? Explicit flag first, then: can it write? */
653
649
  function worktreeWanted(flag, opts) {
654
650
  if (flag !== void 0) return flag;
655
- if (!opts.className || !WORKTREE_CLASSES.includes(opts.className)) return false;
656
- if (!isGitRepo(opts.cwd)) return false;
657
- return !promptLooksReadonly(opts.prompt ?? "");
651
+ return toolsCanWrite(opts.allowedTools) && isGitRepo(opts.cwd);
658
652
  }
659
653
  /**
660
654
  * Commit `cwd`'s current index + working tree (including untracked files) as
@@ -698,11 +692,6 @@ function snapshotUncommitted(cwd, id, head) {
698
692
  * degrade to an in-place run.
699
693
  */
700
694
  function addWorktree(logDir, id, cwd) {
701
- const swept = sweepOrphanWorktrees(logDir);
702
- if (swept.length) appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
703
- id,
704
- note: `swept orphan worktree(s): ${swept.join(", ")}`
705
- });
706
695
  const dir = worktreePath(logDir, id);
707
696
  const branch = worktreeBranch(id);
708
697
  const head = git(cwd, ["rev-parse", "HEAD"]);
@@ -716,6 +705,7 @@ function addWorktree(logDir, id, cwd) {
716
705
  branch,
717
706
  base
718
707
  ]);
708
+ provisionDeps(logDir, id, cwd, dir);
719
709
  return {
720
710
  dir,
721
711
  branch,
@@ -724,44 +714,60 @@ function addWorktree(logDir, id, cwd) {
724
714
  };
725
715
  }
726
716
  /**
727
- * Remove worktrees (and their `worker/<id>` branches) whose worker no longer
728
- * exists — no status file left in the log dir. Killed and failed runs clean
729
- * up after themselves, but a `kill -9` or a crash strands a directory and a
730
- * branch; every worktree creation sweeps first so they cannot accumulate.
731
- * Directories younger than `minAgeMin` minutes are left alone: a run that is
732
- * just starting owns its worktree a moment before its status file exists.
717
+ * Give the worktree its own dependencies so the worker never has to improvise
718
+ * (symlinking node_modules to the main checkout, building in a /tmp copy).
719
+ * A real node_modules directory in the main checkout is APFS-cloned
720
+ * (`cp -c`, copy-on-write, near instant); elsewhere the lockfile installs it.
721
+ * Never a symlink. Time-boxed and best effort: a failure is logged, the run
722
+ * goes on.
733
723
  */
734
- function sweepOrphanWorktrees(logDir, minAgeMin = 10) {
735
- const root = worktreesDir(logDir);
736
- if (!existsSync(root)) return [];
737
- const swept = [];
738
- for (const ent of readdirSync(root, { withFileTypes: true })) {
739
- if (!ent.isDirectory()) continue;
740
- const id = ent.name;
741
- const dir = join(root, id);
742
- if (existsSync(statusPath(logDir, id))) continue;
743
- try {
744
- if ((Date.now() - statSync(dir).mtimeMs) / 6e4 < minAgeMin) continue;
745
- } catch {}
746
- let gitDir = null;
724
+ function provisionDeps(logDir, id, cwd, wtDir) {
725
+ const note = (n) => appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
726
+ id,
727
+ note: n
728
+ });
729
+ try {
730
+ const main = join(git(cwd, ["rev-parse", "--show-toplevel"]), "node_modules");
731
+ const dest = join(wtDir, "node_modules");
732
+ if (!existsSync(main) || lstatSync(main).isSymbolicLink() || existsSync(dest)) return;
747
733
  try {
748
- const raw = git(dir, ["rev-parse", "--git-common-dir"]);
749
- gitDir = isAbsolute(raw) ? raw : resolve(dir, raw);
734
+ git(wtDir, [
735
+ "check-ignore",
736
+ "-q",
737
+ "node_modules"
738
+ ]);
750
739
  } catch {
751
- gitDir = null;
740
+ return;
752
741
  }
753
- removeWorktree(gitDir ?? dir, dir, true);
754
- if (gitDir) try {
755
- git(gitDir, ["worktree", "prune"]);
756
- git(gitDir, [
757
- "branch",
758
- "-D",
759
- worktreeBranch(id)
760
- ]);
742
+ const t0 = Date.now();
743
+ const opts = {
744
+ cwd: wtDir,
745
+ stdio: "ignore",
746
+ timeout: 18e4
747
+ };
748
+ if (platform() === "darwin") try {
749
+ execFileSync("cp", [
750
+ "-cR",
751
+ main,
752
+ dest
753
+ ], opts);
754
+ note(`node_modules cloned (apfs) in ${Date.now() - t0}ms`);
755
+ return;
756
+ } catch {
757
+ rmSync(dest, {
758
+ recursive: true,
759
+ force: true
760
+ });
761
+ }
762
+ if (existsSync(join(wtDir, "bun.lock"))) execFileSync("bun", ["install", "--frozen-lockfile"], opts);
763
+ else if (existsSync(join(wtDir, "package-lock.json"))) execFileSync("npm", ["ci"], opts);
764
+ else return;
765
+ note(`node_modules installed from lockfile in ${Date.now() - t0}ms`);
766
+ } catch (e) {
767
+ try {
768
+ note(`node_modules provisioning failed: ${e.message}`);
761
769
  } catch {}
762
- swept.push(id);
763
770
  }
764
- return swept;
765
771
  }
766
772
  /** Commits the branch collected on top of its base. */
767
773
  function commitsSince(dir, base) {
@@ -787,6 +793,12 @@ function recordWorktree(logDir, status, info, ok) {
787
793
  s.worktreeDir = info.dir;
788
794
  s.worktreeBase = info.base;
789
795
  s.worktreeSnapshot = info.snapshot;
796
+ } else if (safeCommitsSince(info.dir, info.base) > 0) {
797
+ s.branch = info.branch;
798
+ s.commits = commitsSince(info.dir, info.base);
799
+ s.worktreeDir = info.dir;
800
+ s.worktreeBase = info.base;
801
+ s.worktreeSnapshot = info.snapshot;
790
802
  } else {
791
803
  removeWorktree(s.cwd, info.dir, true);
792
804
  try {
@@ -805,6 +817,37 @@ function recordWorktree(logDir, status, info, ok) {
805
817
  saveStatus(logDir, s);
806
818
  return s;
807
819
  }
820
+ function safeCommitsSince(dir, base) {
821
+ try {
822
+ return commitsSince(dir, base);
823
+ } catch {
824
+ return 0;
825
+ }
826
+ }
827
+ /**
828
+ * Salvage for an exit path (run finaliser, kill signal): commit whatever the
829
+ * worker left uncommitted, log WORKER-SALVAGE, never throw. Bounded by
830
+ * `timeoutMs` per git call. Returns the committed paths.
831
+ */
832
+ function salvageOnExit(ledger, id, label, wtDir, timeoutMs) {
833
+ try {
834
+ const { committed, skipped } = salvageUncommitted(wtDir, label, timeoutMs);
835
+ if (committed.length) appendLedger(ledger, "WORKER-SALVAGE", {
836
+ id,
837
+ paths: committed.join(","),
838
+ skipped: skipped.length
839
+ });
840
+ return committed;
841
+ } catch (e) {
842
+ try {
843
+ appendLedger(ledger, "WORKER-NOTE", {
844
+ id,
845
+ note: `salvage failed: ${e.message}`
846
+ });
847
+ } catch {}
848
+ return [];
849
+ }
850
+ }
808
851
  /** Remove a worktree directory from git's books and the filesystem. */
809
852
  function removeWorktree(cwd, dir, force) {
810
853
  try {
@@ -830,41 +873,86 @@ function removeWorktree(cwd, dir, force) {
830
873
  * carry used; name-only, not porcelain: a worktree-only change renders as
831
874
  * " M path" and the shared git() helper trims that leading space away.
832
875
  */
833
- function uncommittedPaths(wtDir) {
876
+ function uncommittedPaths(wtDir, timeoutMs) {
834
877
  const tracked = git(wtDir, [
835
878
  "diff",
836
879
  "--name-only",
837
880
  "HEAD"
838
- ]).split("\n").filter(Boolean);
881
+ ], timeoutMs).split("\n").filter(Boolean);
839
882
  const untracked = git(wtDir, [
840
883
  "ls-files",
841
884
  "--others",
842
885
  "--exclude-standard"
843
- ]).split("\n").filter(Boolean);
886
+ ], timeoutMs).split("\n").filter(Boolean);
844
887
  return [...tracked, ...untracked];
845
888
  }
846
889
  /**
890
+ * True when `wtDir`/`relPath` is a symlink whose target resolves outside
891
+ * `wtDir` — e.g. a worker's `node_modules -> <main checkout>/node_modules`
892
+ * convenience link. A `.gitignore` using `dir/` (directories only) does not
893
+ * ignore such a link, so it turns up as untracked; staging and merging it
894
+ * lets git replace the real directory on the other side with a symlink to
895
+ * itself. Never a candidate for salvage.
896
+ */
897
+ function symlinkEscapesWorktree(wtDir, relPath) {
898
+ const full = join(wtDir, relPath);
899
+ let stat;
900
+ try {
901
+ stat = lstatSync(full);
902
+ } catch {
903
+ return false;
904
+ }
905
+ if (!stat.isSymbolicLink()) return false;
906
+ const target = readlinkSync(full);
907
+ const resolvedTarget = isAbsolute(target) ? target : resolve(dirname(full), target);
908
+ const realTarget = existsSync(resolvedTarget) ? realpathSync(resolvedTarget) : resolvedTarget;
909
+ const realWtDir = realpathSync(wtDir);
910
+ return realTarget !== realWtDir && !realTarget.startsWith(realWtDir + sep);
911
+ }
912
+ /**
847
913
  * Commit a worktree's uncommitted changes to its branch so the merge carries
848
914
  * them. `git merge` only moves committed work, so a worker that stopped
849
915
  * without committing would lose its edits to `worktree remove` — exactly
850
- * what happened live on 2026-09-17. Returns the salvaged paths, [] when the
851
- * worktree is clean. A failed commit throws with the worktree untouched:
916
+ * what happened live on 2026-09-17. Returns the salvaged paths, empty when
917
+ * the worktree is clean. A symlink pointing outside the worktree (see
918
+ * `symlinkEscapesWorktree`) is never staged; it is left on disk and reported
919
+ * as skipped instead. A failed commit throws with the worktree untouched:
852
920
  * its edits are still on disk, so nothing is lost.
853
921
  */
854
- function salvageUncommitted(wtDir, label) {
855
- const paths = uncommittedPaths(wtDir);
856
- if (!paths.length) return [];
922
+ function salvageUncommitted(wtDir, label, timeoutMs) {
923
+ const paths = uncommittedPaths(wtDir, timeoutMs);
924
+ if (!paths.length) return {
925
+ committed: [],
926
+ skipped: []
927
+ };
928
+ const skipped = [];
929
+ const toStage = [];
930
+ for (const p of paths) if (symlinkEscapesWorktree(wtDir, p)) skipped.push(`symlink ${p} -> ${readlinkSync(join(wtDir, p))} (points outside the worktree)`);
931
+ else toStage.push(p);
932
+ if (!toStage.length) return {
933
+ committed: [],
934
+ skipped
935
+ };
857
936
  try {
858
- git(wtDir, ["add", "-A"]);
937
+ git(wtDir, [
938
+ "add",
939
+ "-A",
940
+ "--",
941
+ ".",
942
+ ...paths.filter((p) => !toStage.includes(p)).map((p) => `:(exclude,literal)${p}`)
943
+ ], timeoutMs);
859
944
  git(wtDir, [
860
945
  "commit",
861
946
  "-m",
862
947
  `salvaged: ${label}`
863
- ]);
948
+ ], timeoutMs);
864
949
  } catch (e) {
865
950
  throw new Error(`cannot salvage the uncommitted changes in ${wtDir} — ${e.message}; nothing was lost: commit them there by hand, then re-run merge`);
866
951
  }
867
- return paths;
952
+ return {
953
+ committed: toStage,
954
+ skipped
955
+ };
868
956
  }
869
957
  /**
870
958
  * The dirty paths of a checkout, parsed from `git status --porcelain -z`:
@@ -915,9 +1003,14 @@ function assertNoDirtyOverlap(cwd, incoming, id, branch) {
915
1003
  if (!overlap.length) return;
916
1004
  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.`);
917
1005
  }
918
- /** Never remove a worktree that still holds uncommitted changes. */
1006
+ /**
1007
+ * Never remove a worktree that still holds uncommitted changes — except a
1008
+ * symlink escaping the worktree (see `symlinkEscapesWorktree`): it was
1009
+ * deliberately left uncommitted by `salvageUncommitted`, and deleting the
1010
+ * link itself touches nothing outside the worktree.
1011
+ */
919
1012
  function assertWorktreeClean(wtDir, id) {
920
- const leftover = uncommittedPaths(wtDir);
1013
+ const leftover = uncommittedPaths(wtDir).filter((p) => !symlinkEscapesWorktree(wtDir, p));
921
1014
  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`);
922
1015
  }
923
1016
  /** Blob sha of `path` at `rev` in `cwd`, or null when it does not exist there. */
@@ -971,7 +1064,10 @@ function mergeSnapshotWorker(logDir, id, st) {
971
1064
  const snapshot = st.worktreeBase;
972
1065
  const branch = st.branch;
973
1066
  const worktreeDir = st.worktreeDir;
974
- const salvaged = existsSync(worktreeDir) ? salvageUncommitted(worktreeDir, st.label || UNLABELED) : [];
1067
+ const salvage = existsSync(worktreeDir) ? salvageUncommitted(worktreeDir, st.label || UNLABELED) : {
1068
+ committed: [],
1069
+ skipped: []
1070
+ };
975
1071
  if ((parseInt(git(st.cwd, [
976
1072
  "rev-list",
977
1073
  "--count",
@@ -1030,7 +1126,10 @@ function mergeSnapshotWorker(logDir, id, st) {
1030
1126
  merged: true
1031
1127
  });
1032
1128
  const base = `applied worker ${id}'s changes (branch ${branch}) to ${st.cwd} as uncommitted changes: ${changedPaths.join(", ")} (worktree removed, branch deleted)`;
1033
- return salvaged.length ? `${base}; salvaged ${salvaged.length} uncommitted change(s) onto the branch first: ${salvaged.join(", ")}` : base;
1129
+ const notes = [];
1130
+ if (salvage.committed.length) notes.push(`salvaged ${salvage.committed.length} uncommitted change(s) onto the branch first: ${salvage.committed.join(", ")}`);
1131
+ if (salvage.skipped.length) notes.push(`skipped ${salvage.skipped.join(", ")}`);
1132
+ return notes.length ? `${base}; ${notes.join("; ")}` : base;
1034
1133
  }
1035
1134
  /**
1036
1135
  * `pai worker merge <id>`: salvage whatever the worker left uncommitted onto
@@ -1045,7 +1144,10 @@ function mergeWorker(logDir, id) {
1045
1144
  const st = mustHaveBranch(logDir, id);
1046
1145
  if (st.merged) return `worker ${id}: branch ${st.branch} already merged`;
1047
1146
  if (st.worktreeSnapshot) return mergeSnapshotWorker(logDir, id, st);
1048
- const salvaged = existsSync(st.worktreeDir) ? salvageUncommitted(st.worktreeDir, st.label || UNLABELED) : [];
1147
+ const salvage = existsSync(st.worktreeDir) ? salvageUncommitted(st.worktreeDir, st.label || UNLABELED) : {
1148
+ committed: [],
1149
+ skipped: []
1150
+ };
1049
1151
  if ((parseInt(git(st.cwd, [
1050
1152
  "rev-list",
1051
1153
  "--count",
@@ -1075,7 +1177,7 @@ function mergeWorker(logDir, id) {
1075
1177
  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`);
1076
1178
  }
1077
1179
  if (existsSync(st.worktreeDir)) assertWorktreeClean(st.worktreeDir, id);
1078
- removeWorktree(st.cwd, st.worktreeDir, false);
1180
+ removeWorktree(st.cwd, st.worktreeDir, true);
1079
1181
  let branchGone = true;
1080
1182
  try {
1081
1183
  git(st.cwd, [
@@ -1091,7 +1193,10 @@ function mergeWorker(logDir, id) {
1091
1193
  merged: true
1092
1194
  });
1093
1195
  const base = `merged ${st.branch} into ${st.cwd} (worktree removed${branchGone ? ", branch deleted" : "; branch kept: git refused -d"})`;
1094
- return salvaged.length ? `${base}; salvaged ${salvaged.length} uncommitted change(s): ${salvaged.join(", ")}` : base;
1196
+ const notes = [];
1197
+ if (salvage.committed.length) notes.push(`salvaged ${salvage.committed.length} uncommitted change(s): ${salvage.committed.join(", ")}`);
1198
+ if (salvage.skipped.length) notes.push(`skipped ${salvage.skipped.join(", ")}`);
1199
+ return notes.length ? `${base}; ${notes.join("; ")}` : base;
1095
1200
  }
1096
1201
  /** `pai worker discard <id>`: drop worktree and branch, keep nothing. */
1097
1202
  function discardWorker(logDir, id) {
@@ -1154,6 +1259,139 @@ function inPlaceSystemPrompt() {
1154
1259
  "Read-only git (status, diff, log, show, stash list) is fine."
1155
1260
  ].join("\n");
1156
1261
  }
1262
+ const GC_STAMP = "gc-last";
1263
+ /**
1264
+ * Archive worktrees of finished workers older than `olderThanHours` and
1265
+ * status-less orphan directories: uncommitted state is salvaged onto the
1266
+ * branch, the branch is x, the worktree is
1267
+ * removed. Nothing is deleted without a salvage first; a failed salvage skips
1268
+ * that worktree. Running workers are never touched. `dryRun` reports only.
1269
+ */
1270
+ function gcWorktrees(logDir, olderThanHours = 24, dryRun = false, now = Date.now()) {
1271
+ const cutoff = now - olderThanHours * 36e5;
1272
+ const out = [];
1273
+ const root = worktreesDir(logDir);
1274
+ const known = /* @__PURE__ */ new Set();
1275
+ const archive = (id, dir, repo, st) => {
1276
+ const target = `refs/pai-archive/${id}`;
1277
+ if (dryRun) return void out.push({
1278
+ id,
1279
+ archive: target
1280
+ });
1281
+ try {
1282
+ if (!repo) throw new Error("not a git worktree");
1283
+ const label = `archive: leftover state of worker ${id}`;
1284
+ if (uncommittedPaths(dir).length) {
1285
+ const res = salvageUncommitted(dir, label);
1286
+ if (res.committed.length) git(dir, [
1287
+ "commit",
1288
+ "--amend",
1289
+ "-q",
1290
+ "-m",
1291
+ label
1292
+ ]);
1293
+ if (res.skipped.length && uncommittedPaths(dir).some((p) => !symlinkEscapesWorktree(dir, p))) throw new Error("uncommitted paths remain");
1294
+ }
1295
+ const src = worktreeBranch(id);
1296
+ let hasBranch = true;
1297
+ try {
1298
+ git(repo, [
1299
+ "rev-parse",
1300
+ "--verify",
1301
+ "-q",
1302
+ `refs/heads/${src}`
1303
+ ]);
1304
+ } catch {
1305
+ hasBranch = false;
1306
+ }
1307
+ const tip = git(dir, ["rev-parse", "HEAD"]);
1308
+ if (hasBranch) git(repo, [
1309
+ "merge-base",
1310
+ "--is-ancestor",
1311
+ `refs/heads/${src}`,
1312
+ tip
1313
+ ]);
1314
+ git(repo, [
1315
+ "update-ref",
1316
+ target,
1317
+ tip
1318
+ ]);
1319
+ git(repo, [
1320
+ "worktree",
1321
+ "remove",
1322
+ "--force",
1323
+ dir
1324
+ ]);
1325
+ if (hasBranch) git(repo, [
1326
+ "branch",
1327
+ "-D",
1328
+ src
1329
+ ]);
1330
+ if (st) saveStatus(logDir, {
1331
+ ...st,
1332
+ branch: null,
1333
+ worktreeDir: null,
1334
+ archived: target
1335
+ });
1336
+ appendLedger(ledgerPath(logDir), "WORKER-GC", {
1337
+ id,
1338
+ archive: target
1339
+ });
1340
+ out.push({
1341
+ id,
1342
+ archive: target
1343
+ });
1344
+ } catch (e) {
1345
+ out.push({
1346
+ id,
1347
+ skipped: e.message.split("\n")[0]
1348
+ });
1349
+ }
1350
+ };
1351
+ for (const st of loadStatuses(logDir)) {
1352
+ known.add(st.id);
1353
+ if (!st.worktreeDir || !existsSync(st.worktreeDir) || isLive(st)) continue;
1354
+ let end = 0;
1355
+ try {
1356
+ end = statSync(statusPath(logDir, st.id)).mtimeMs;
1357
+ } catch {
1358
+ continue;
1359
+ }
1360
+ if (end > cutoff) continue;
1361
+ archive(st.id, st.worktreeDir, st.cwd, st);
1362
+ }
1363
+ if (existsSync(root)) for (const ent of readdirSync(root, { withFileTypes: true })) {
1364
+ if (!ent.isDirectory() || known.has(ent.name) || existsSync(statusPath(logDir, ent.name))) continue;
1365
+ const dir = join(root, ent.name);
1366
+ try {
1367
+ if (statSync(dir).mtimeMs > cutoff) continue;
1368
+ } catch {
1369
+ continue;
1370
+ }
1371
+ let repo = null;
1372
+ try {
1373
+ const raw = git(dir, ["rev-parse", "--git-common-dir"]);
1374
+ repo = isAbsolute(raw) ? raw : resolve(dir, raw);
1375
+ } catch {
1376
+ repo = null;
1377
+ }
1378
+ archive(ent.name, dir, repo, null);
1379
+ }
1380
+ if (!dryRun) for (const repo of new Set(loadStatuses(logDir).map((s) => s.cwd))) try {
1381
+ git(repo, ["worktree", "prune"]);
1382
+ } catch {}
1383
+ return out;
1384
+ }
1385
+ /** Run `gcWorktrees` at most once per `everyMin` minutes (stamp file in the logDir). */
1386
+ function gcWorktreesThrottled(logDir, everyMin = 60, now = Date.now()) {
1387
+ const stamp = join(logDir, GC_STAMP);
1388
+ try {
1389
+ if (now - statSync(stamp).mtimeMs < everyMin * 6e4) return null;
1390
+ } catch {}
1391
+ mkdirSync(logDir, { recursive: true });
1392
+ writeFileSync(stamp, String(now), "utf8");
1393
+ return gcWorktrees(logDir, 24, false, now);
1394
+ }
1157
1395
 
1158
1396
  //#endregion
1159
1397
  //#region src/workers/scope.ts
@@ -1173,9 +1411,10 @@ function inPlaceSystemPrompt() {
1173
1411
  * If neither is available, viewers fall back to "all workers".
1174
1412
  *
1175
1413
  * A third case: workers spawned from a Claude Code Bash tool (the
1176
- * orchestrator pattern) have neither — Claude Code exports no terminal or
1177
- * session identity to its Bash children. Those record `spawnerSession`, the
1178
- * orchestrator's claude session id bridged through the status line's
1414
+ * orchestrator pattern) inherit the orchestrator's `ITERM_SESSION_ID`, which
1415
+ * identifies the orchestrator's own pane, not a pane for the sub-worker —
1416
+ * so it does not key the sub-worker's tab. Those record `spawnerSession`,
1417
+ * the orchestrator's claude session id bridged through the status line's
1179
1418
  * session map (see below), so their tab still claims them.
1180
1419
  */
1181
1420
  const AIBROKER_REGISTRY = join(homedir(), ".aibroker", "session-names.json");
@@ -1897,7 +2136,10 @@ function absoluteCliPath() {
1897
2136
  }
1898
2137
  /** Open (or only report on) the stacked follow pane of one worker. */
1899
2138
  async function openPaneForWorker(logDir, config, wid, term) {
1900
- if (workerPaneOpen(wid)) return `pane for ${wid} already open`;
2139
+ if (workerPaneOpen(wid)) return {
2140
+ message: `pane for ${wid} already open`,
2141
+ session: null
2142
+ };
1901
2143
  const uid = itermUuid(term);
1902
2144
  const regPath = join(panesDir(logDir), `${scopeKey(term)}.json`);
1903
2145
  const reg = loadRegistry(regPath);
@@ -1921,7 +2163,10 @@ async function openPaneForWorker(logDir, config, wid, term) {
1921
2163
  opened: (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19)
1922
2164
  });
1923
2165
  saveRegistry(regPath, pruned);
1924
- return `pane opened for ${wid}`;
2166
+ return {
2167
+ message: `pane opened for ${wid}`,
2168
+ session: out.slice(bar + 1)
2169
+ };
1925
2170
  }
1926
2171
  /**
1927
2172
  * One `--check` line with the bounds of the window hosting `term`'s iTerm
@@ -2041,6 +2286,34 @@ function launchParent(explicit, env = process.env) {
2041
2286
  function isWorkerId(logDir, id) {
2042
2287
  return loadStatus(logDir, id) !== null;
2043
2288
  }
2289
+ /**
2290
+ * A worker owns its children's results (the worker contract says so): if
2291
+ * `parent` exits leaving sub-workers unaccounted for, its own run must not
2292
+ * report success. Two shapes count, both read off `runningChildren` and the
2293
+ * raw statuses, never a second live-child lookup:
2294
+ *
2295
+ * - still live: left running untouched (killing another process's work
2296
+ * here would be its own bug) but named in the reason.
2297
+ * - marked "running" with a dead pid (the child never got to write its own
2298
+ * terminal state before its parent vanished from under it): finalised
2299
+ * here as failed, so `ps` stops showing a corpse as live.
2300
+ *
2301
+ * Returns the reason string for the parent's own report, or null when every
2302
+ * child it started is accounted for.
2303
+ */
2304
+ function checkOrphanedChildren(logDir, parent) {
2305
+ const statuses = loadStatuses(logDir);
2306
+ const live = runningChildren(statuses, parent);
2307
+ const abandoned = statuses.filter((s) => s.parent === parent && s.state === "running" && !isLive(s));
2308
+ for (const child of abandoned) saveStatus(logDir, {
2309
+ ...child,
2310
+ state: "failed",
2311
+ last: `parent ${parent} exited`
2312
+ });
2313
+ const ids = [...live, ...abandoned].map((s) => s.id);
2314
+ if (!ids.length) return null;
2315
+ return `exited with ${ids.length} sub-worker${ids.length === 1 ? "" : "s"} still running (${ids.join(", ")}); they were not waited for — run children in the foreground or wait for them before finishing`;
2316
+ }
2044
2317
 
2045
2318
  //#endregion
2046
2319
  //#region src/workers/operator.ts
@@ -2283,6 +2556,16 @@ function ag2Spec() {
2283
2556
  return cachedSpec;
2284
2557
  }
2285
2558
  /**
2559
+ * Strip what workers wrap around a report: one surrounding ``` fence (opening
2560
+ * line may carry a language tag) and a leading `[YYYY-MM-DD HH:MM]` stamp line.
2561
+ */
2562
+ function unwrapAg2(text) {
2563
+ let t = (text ?? "").trim();
2564
+ const fenced = t.match(/^```[^\n]*\n([\s\S]*?)\n?```$/);
2565
+ if (fenced) t = fenced[1].trim();
2566
+ return t.replace(/^\[\d{4}-\d{2}-\d{2}[^\]\n]*\][ \t]*\n/, "").trim();
2567
+ }
2568
+ /**
2286
2569
  * Parse one AG2 message: first non-empty line is the kind (T R S Q A X,
2287
2570
  * optionally followed by trailing text — only the first character is the
2288
2571
  * kind), later lines are `key=value` or `@n=path` symbol declarations.
@@ -2298,7 +2581,7 @@ function parseAg2Report(text) {
2298
2581
  "A",
2299
2582
  "X"
2300
2583
  ]);
2301
- const lines = (text ?? "").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("```"));
2584
+ const lines = unwrapAg2(text).split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("```"));
2302
2585
  if (!lines.length) return null;
2303
2586
  const kind = lines[0][0];
2304
2587
  if (!kinds.has(kind)) return null;
@@ -2342,11 +2625,17 @@ function ag2ToWorkerReport(parsed) {
2342
2625
  summary: entry.slice(cut + 1).trim()
2343
2626
  };
2344
2627
  });
2345
- if (f.t) out.checks = f.t.split(/\s+/).filter(Boolean).map((entry) => ({
2346
- name: entry.slice(0, -1),
2347
- ok: passed(entry),
2348
- detail: entry.slice(-1)
2349
- }));
2628
+ if (f.t) {
2629
+ const tokens = f.t.split(/\s+/).filter(Boolean);
2630
+ out.checks = tokens.length > 0 && tokens.every((tok) => /^\S+[+\-~?!]$/.test(tok)) ? tokens.map((entry) => ({
2631
+ name: entry.slice(0, -1),
2632
+ ok: passed(entry),
2633
+ detail: entry.slice(-1)
2634
+ })) : [{
2635
+ name: "tests",
2636
+ detail: f.t
2637
+ }];
2638
+ }
2350
2639
  if (f.p) out.commands = f.p.split("|").map((s) => s.trim()).filter(Boolean);
2351
2640
  const open = [];
2352
2641
  if (f.x) open.push(f.x);
@@ -2372,7 +2661,7 @@ function validateAg2(text) {
2372
2661
  "-",
2373
2662
  "--json"
2374
2663
  ], {
2375
- input: text,
2664
+ input: unwrapAg2(text),
2376
2665
  timeout: 5e3,
2377
2666
  stdio: [
2378
2667
  "pipe",
@@ -2600,7 +2889,7 @@ function renderReport(c, prefix, r, cwd = "") {
2600
2889
  if (has(r.checks)) {
2601
2890
  out.push(`${prefix}${c("bold", "checks")}`);
2602
2891
  for (const ck of r.checks) {
2603
- const mark = ck.ok === false ? c("red", "✗") : c("green", "✓");
2892
+ const mark = ck.ok === false ? c("red", "✗") : ck.ok === true ? c("green", "✓") : c("dim", "·");
2604
2893
  const detail = ck.detail ? c("dim", ` · ${shortText(ck.detail, 80)}`) : "";
2605
2894
  out.push(`${prefix} ${mark} ${ck.name ?? "?"}${detail}`);
2606
2895
  }
@@ -3012,16 +3301,22 @@ function toolResultFailed(block) {
3012
3301
  const m = toolResultText(block.content).match(/Exit code (\d+)/);
3013
3302
  return m !== null && Number(m[1]) !== 0;
3014
3303
  }
3304
+ /** CLI aliases the built-in provider hands to `--model` (NATIVE_ANTHROPIC_MODELS'
3305
+ * values): a bare name like "sonnet", not a concrete id, so the init event's
3306
+ * resolved id is always the more useful thing to display. */
3307
+ const NATIVE_MODEL_ALIASES = new Set(Object.values(NATIVE_ANTHROPIC_MODELS));
3015
3308
  /**
3016
3309
  * The model the init event announces, adopted into the status when the spawn
3017
3310
  * could not name one (a caller that passed `--model` itself in the claude
3018
- * args, or a provider whose table has no entry for the capability). The run's
3019
- * real model is then only knowable from the first event it sends. Never
3020
- * overwrites an explicit model — a resolved or pinned model stays the
3311
+ * args, or a provider whose table has no entry for the capability) or named
3312
+ * only a CLI alias (the built-in provider's "sonnet"/"haiku", which tracks
3313
+ * whatever release is current — the init event says which one actually ran).
3314
+ * The run's real model is then only knowable from the first event it sends.
3315
+ * Never overwrites a concrete resolved or pinned model — that stays the
3021
3316
  * recorded truth.
3022
3317
  */
3023
3318
  function adoptInitModel(status, e) {
3024
- if (status.model) return;
3319
+ if (status.model && !NATIVE_MODEL_ALIASES.has(status.model)) return;
3025
3320
  const m = (e.model ?? "").trim();
3026
3321
  if (m) status.model = m;
3027
3322
  }
@@ -3115,8 +3410,12 @@ async function runWorker(opts) {
3115
3410
  if (!config.enabled) throw new Error("workers are off. Turn them on with: pai worker on\n(then the Agent-tool hook stops denying Anthropic subagents only when you do)");
3116
3411
  const logDir = workersLogDir(config);
3117
3412
  mkdirSync(logDir, { recursive: true });
3413
+ try {
3414
+ const gc = gcWorktreesThrottled(logDir)?.filter((r) => r.archive);
3415
+ if (gc?.length) process.stderr.write(`pai worker: archived ${gc.length} leftover worktree(s) (pai worker gc)\n`);
3416
+ } catch {}
3118
3417
  if (opts.className === "plan" && !opts._planner) {
3119
- const { runPlanner } = await import("./planner-DN967Lvm.mjs");
3418
+ const { runPlanner } = await import("./planner-BG9ivPj0.mjs");
3120
3419
  return runPlanner(opts);
3121
3420
  }
3122
3421
  const parent = launchParent(opts.parent);
@@ -3320,6 +3619,75 @@ function interactiveMcpTools(explicitMcp, callerTools, projectLaunch) {
3320
3619
  tools: !callerTools && projectLaunch?.tools?.length ? projectLaunch.tools : []
3321
3620
  };
3322
3621
  }
3622
+ /**
3623
+ * The finalisation every engine shares: orphaned-children check, state, the
3624
+ * WORKER-END ledger line, worktree outcome, parent handoff, final rc. A worker
3625
+ * owns its children's results (the worker contract says so): if it
3626
+ * backgrounded a sub-worker and exited without waiting, the run must not
3627
+ * report success, whatever the process returned.
3628
+ */
3629
+ /** Per-git-call bound for the salvage a kill signal runs before exiting. */
3630
+ const SIGNAL_SALVAGE_MS = 5e3;
3631
+ async function finaliseRun(f) {
3632
+ const { logDir, ledger, ok, rc } = f;
3633
+ let { status, report } = f;
3634
+ const wid = status.id;
3635
+ const salvaged = f.worktree ? salvageOnExit(ledger, wid, status.label || UNLABELED, f.worktree.dir) : [];
3636
+ if (salvaged.length) {
3637
+ const msg = `WORKER-SALVAGE: committed uncommitted edits (${salvaged.join(", ")})`;
3638
+ report = {
3639
+ ...report ?? {},
3640
+ notes: [report?.notes ?? f.fallbackText, msg].filter(Boolean).join("\n"),
3641
+ result: report?.result ?? "-"
3642
+ };
3643
+ }
3644
+ const orphanReason = checkOrphanedChildren(logDir, wid);
3645
+ const succeeded = ok && !orphanReason;
3646
+ status.state = succeeded ? "done" : "failed";
3647
+ status.rc = rc;
3648
+ status.secs = f.secs;
3649
+ if (orphanReason) {
3650
+ status.last = shortText(orphanReason, 90);
3651
+ report = {
3652
+ ...report ?? {},
3653
+ notes: orphanReason,
3654
+ result: "-"
3655
+ };
3656
+ appendLedger(ledger, "WORKER-ORPHAN-CHILDREN", {
3657
+ id: wid,
3658
+ reason: orphanReason
3659
+ });
3660
+ }
3661
+ saveStatus(logDir, status);
3662
+ appendLedger(ledger, "WORKER-END", {
3663
+ id: wid,
3664
+ ...f.end,
3665
+ rc,
3666
+ secs: f.secs
3667
+ });
3668
+ if (f.worktree) status = recordWorktree(logDir, status, f.worktree, ok);
3669
+ const early = await f.beforeHandoff?.(status, report);
3670
+ if (typeof early === "number") return early;
3671
+ const finalRc = rc !== 0 ? rc : succeeded ? 0 : 1;
3672
+ if (status.parent && isWorkerId(logDir, status.parent)) try {
3673
+ await deliverHandoff(logDir, {
3674
+ from: wid,
3675
+ to: status.parent,
3676
+ kind: "result",
3677
+ text: shortText(report?.notes ?? f.fallbackText ?? (succeeded ? "done" : "failed"), 400),
3678
+ data: {
3679
+ rc: finalRc,
3680
+ ok: succeeded,
3681
+ ...status.branch ? {
3682
+ branch: status.branch,
3683
+ commits: status.commits ?? 0
3684
+ } : {},
3685
+ ...report ? { report } : {}
3686
+ }
3687
+ });
3688
+ } catch {}
3689
+ return finalRc;
3690
+ }
3323
3691
  async function executeImageRun(a) {
3324
3692
  const { logDir, target, model, label, parsed } = a;
3325
3693
  if (!parsed.headless || parsed.prompt === null) throw new Error(`provider "${target.providerName}" (engine image) supports headless runs only: pass the task with -p '<prompt>'`);
@@ -3366,6 +3734,14 @@ async function executeImageRun(a) {
3366
3734
  capability: a.capability,
3367
3735
  ...a.specPath ? { spec: a.specPath } : {}
3368
3736
  });
3737
+ const imageEnd = {
3738
+ provider: target.providerName,
3739
+ mode: "headless",
3740
+ engine: "image",
3741
+ model,
3742
+ label,
3743
+ capability: a.capability
3744
+ };
3369
3745
  const t0 = Date.now();
3370
3746
  try {
3371
3747
  const result = await runImageCapability({
@@ -3378,46 +3754,61 @@ async function executeImageRun(a) {
3378
3754
  timeoutMs: a.timeoutMs
3379
3755
  });
3380
3756
  const secs = Math.round((Date.now() - t0) / 1e3);
3381
- status.state = "done";
3382
- status.rc = 0;
3383
- status.secs = secs;
3384
3757
  status.last = `wrote ${result.path}`;
3385
- saveStatus(logDir, status);
3386
- appendLedger(ledger, "WORKER-END", {
3387
- id: wid,
3388
- provider: target.providerName,
3389
- mode: "headless",
3390
- engine: "image",
3391
- model,
3758
+ return await finaliseRun({
3759
+ logDir,
3760
+ ledger,
3761
+ status,
3762
+ ok: true,
3392
3763
  rc: 0,
3393
3764
  secs,
3394
- label,
3395
- capability: a.capability
3765
+ end: imageEnd,
3766
+ report: null,
3767
+ beforeHandoff: () => {
3768
+ if (!a.quiet) process.stdout.write(JSON.stringify(result) + "\n");
3769
+ }
3396
3770
  });
3397
- if (!a.quiet) process.stdout.write(JSON.stringify(result) + "\n");
3398
- return 0;
3399
3771
  } catch (e) {
3400
3772
  const secs = Math.round((Date.now() - t0) / 1e3);
3401
- const message = e instanceof Error ? e.message : String(e);
3402
- status.state = "failed";
3403
- status.rc = 1;
3404
- status.secs = secs;
3405
- status.last = message;
3406
- saveStatus(logDir, status);
3407
- appendLedger(ledger, "WORKER-END", {
3408
- id: wid,
3409
- provider: target.providerName,
3410
- mode: "headless",
3411
- engine: "image",
3412
- model,
3773
+ status.last = e instanceof Error ? e.message : String(e);
3774
+ await finaliseRun({
3775
+ logDir,
3776
+ ledger,
3777
+ status,
3778
+ ok: false,
3413
3779
  rc: 1,
3414
3780
  secs,
3415
- label,
3416
- capability: a.capability
3781
+ end: imageEnd,
3782
+ report: null
3417
3783
  });
3418
3784
  throw e;
3419
3785
  }
3420
3786
  }
3787
+ /**
3788
+ * Follow pane, made observable on stderr (stdout stays pure JSON for
3789
+ * `--output-format json`): one line naming the outcome — opened, skipped
3790
+ * (with reason), or failed (with reason, alongside the existing PANE-FAIL
3791
+ * ledger line). Fire-and-forget, never blocks the worker.
3792
+ */
3793
+ async function announcePane(logDir, config, wid, term, noPane, ledger) {
3794
+ const skip = !noPane ? !config.pane.enabled ? "workers.pane.enabled is false" : process.env.PAI_WORKER_AUTOPANE === "0" ? "PAI_WORKER_AUTOPANE=0" : !term ? "not in iTerm2 (no ITERM_SESSION_ID)" : null : "--no-pane";
3795
+ if (skip) {
3796
+ process.stderr.write(`[pai worker] no pane for ${wid}: ${skip}\n`);
3797
+ return;
3798
+ }
3799
+ try {
3800
+ const r = await openPaneForWorker(logDir, config, wid, term);
3801
+ const suffix = r.session ? ` (iTerm session ${r.session})` : "";
3802
+ process.stderr.write(`[pai worker] pane opened for ${wid}${suffix}\n`);
3803
+ } catch (e) {
3804
+ const message = e instanceof Error ? e.message : String(e);
3805
+ process.stderr.write(`[pai worker] no pane for ${wid}: ${message.slice(0, 200)}\n`);
3806
+ appendLedger(ledger, "PANE-FAIL", {
3807
+ id: wid,
3808
+ error: message.slice(0, 200)
3809
+ });
3810
+ }
3811
+ }
3421
3812
  async function executeRun(a) {
3422
3813
  const { config, logDir, target, model, label, parsed, noPane } = a;
3423
3814
  const headless = parsed.headless;
@@ -3432,8 +3823,7 @@ async function executeRun(a) {
3432
3823
  let worktree = null;
3433
3824
  if (headless && !a.printCmd && worktreeWanted(a.worktreeFlag, {
3434
3825
  cwd,
3435
- className: a.className,
3436
- prompt: parsed.prompt
3826
+ allowedTools: parsed.allowedTools
3437
3827
  })) try {
3438
3828
  worktree = addWorktree(logDir, wid, cwd);
3439
3829
  } catch (e) {
@@ -3549,12 +3939,7 @@ async function executeRun(a) {
3549
3939
  ...a.capability ? { capability: a.capability } : {},
3550
3940
  ...a.specPath ? { spec: a.specPath } : {}
3551
3941
  });
3552
- if (headless && !noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch((e) => {
3553
- appendLedger(ledger, "PANE-FAIL", {
3554
- id: wid,
3555
- error: (e instanceof Error ? e.message : String(e)).slice(0, 200)
3556
- });
3557
- });
3942
+ if (headless) announcePane(logDir, config, wid, term, noPane, ledger);
3558
3943
  const t0 = Date.now();
3559
3944
  const proc = spawn(cmd[0], cmd.slice(1), {
3560
3945
  env,
@@ -3622,9 +4007,12 @@ async function executeRun(a) {
3622
4007
  killed: 1,
3623
4008
  label
3624
4009
  });
3625
- if (worktree) try {
3626
- recordWorktree(logDir, status, worktree, false);
3627
- } catch {}
4010
+ if (worktree) {
4011
+ salvageOnExit(ledger, wid, label, worktree.dir, SIGNAL_SALVAGE_MS);
4012
+ try {
4013
+ recordWorktree(logDir, status, worktree, false);
4014
+ } catch {}
4015
+ }
3628
4016
  cleanup();
3629
4017
  try {
3630
4018
  proc.kill();
@@ -3707,9 +4095,6 @@ async function executeRun(a) {
3707
4095
  const secs = Math.floor((Date.now() - t0) / 1e3);
3708
4096
  const resultEvent = ctx.resultEvent;
3709
4097
  const ok = runSucceeded(headless, rc, resultEvent);
3710
- status.state = ok ? "done" : "failed";
3711
- status.rc = rc;
3712
- status.secs = secs;
3713
4098
  if (resultEvent) status.last = shortText(resultEvent.result ?? "", 90);
3714
4099
  if (ctx.resultReport?.notes) status.last = shortText(ctx.resultReport.notes, 90);
3715
4100
  if (headless && a.reportFormat === "ag2" && resultEvent?.result) {
@@ -3761,66 +4146,55 @@ async function executeRun(a) {
3761
4146
  });
3762
4147
  }
3763
4148
  }
3764
- saveStatus(logDir, status);
3765
- appendLedger(ledger, "WORKER-END", {
3766
- id: wid,
3767
- provider: target.providerName,
3768
- mode: headless ? "headless" : "interactive",
3769
- model,
4149
+ return finaliseRun({
4150
+ logDir,
4151
+ ledger,
4152
+ status,
4153
+ ok,
3770
4154
  rc,
3771
4155
  secs,
3772
- turns: status.turns,
3773
- tools: status.tools,
3774
- label
3775
- });
3776
- if (worktree) status = recordWorktree(logDir, status, worktree, ok);
3777
- if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, ctx.resultReport, worktreeExtras(status));
3778
- const resultText = resultEvent?.result ?? "";
3779
- if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
3780
- setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
3781
- const next = nextAutoProvider(config, logDir, target.providerName);
3782
- if (next && a.reroutes < config.routing.order.length) {
3783
- appendLedger(ledger, "WORKER-REROUTE", {
3784
- from: target.providerName,
3785
- to: next,
3786
- reason: "quota"
3787
- });
3788
- return runWorker({
3789
- providerFlag: next,
3790
- label,
3791
- noPane: a.noPane,
3792
- mcpFlag: a.mcpFlag,
3793
- cwd: a.cwd,
3794
- specPath: a.specPath,
3795
- parent: a.parent,
3796
- stage: a.stage,
3797
- claudeArgs: a.claudeArgs,
3798
- onWorkerStart: a.onWorkerStart,
3799
- worktreeFlag: a.worktreeFlag,
3800
- className: a.className,
3801
- _reroutes: a.reroutes + 1
3802
- });
3803
- }
3804
- }
3805
- const finalRc = rc !== 0 ? rc : ok ? 0 : 1;
3806
- if (status.parent && isWorkerId(logDir, status.parent)) try {
3807
- await deliverHandoff(logDir, {
3808
- from: wid,
3809
- to: status.parent,
3810
- kind: "result",
3811
- text: shortText(ctx.resultReport?.notes ?? resultEvent?.result ?? (ok ? "done" : "failed"), 400),
3812
- data: {
3813
- rc: finalRc,
3814
- ok,
3815
- ...status.branch ? {
3816
- branch: status.branch,
3817
- commits: status.commits ?? 0
3818
- } : {},
3819
- ...ctx.resultReport ? { report: ctx.resultReport } : {}
4156
+ end: {
4157
+ provider: target.providerName,
4158
+ mode: headless ? "headless" : "interactive",
4159
+ model,
4160
+ turns: status.turns,
4161
+ tools: status.tools,
4162
+ label
4163
+ },
4164
+ report: ctx.resultReport,
4165
+ fallbackText: resultEvent?.result,
4166
+ worktree,
4167
+ beforeHandoff: async (st, rep) => {
4168
+ if (headless && !a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, rep, worktreeExtras(st));
4169
+ const resultText = resultEvent?.result ?? "";
4170
+ if (!ok && headless && a.target.via === "auto" && config.routing.retryOnQuota && status.turns <= 1 && status.tools === 0 && isQuotaFailure(resultText)) {
4171
+ setCooldown(logDir, target.providerName, config.routing.cooldownMinutes);
4172
+ const next = nextAutoProvider(config, logDir, target.providerName);
4173
+ if (next && a.reroutes < config.routing.order.length) {
4174
+ appendLedger(ledger, "WORKER-REROUTE", {
4175
+ from: target.providerName,
4176
+ to: next,
4177
+ reason: "quota"
4178
+ });
4179
+ return runWorker({
4180
+ providerFlag: next,
4181
+ label,
4182
+ noPane: a.noPane,
4183
+ mcpFlag: a.mcpFlag,
4184
+ cwd: a.cwd,
4185
+ specPath: a.specPath,
4186
+ parent: a.parent,
4187
+ stage: a.stage,
4188
+ claudeArgs: a.claudeArgs,
4189
+ onWorkerStart: a.onWorkerStart,
4190
+ worktreeFlag: a.worktreeFlag,
4191
+ className: a.className,
4192
+ _reroutes: a.reroutes + 1
4193
+ });
4194
+ }
3820
4195
  }
3821
- });
3822
- } catch {}
3823
- return finalRc;
4196
+ }
4197
+ });
3824
4198
  }
3825
4199
  /** The worktree fields printResult adds to a json payload, when there is one. */
3826
4200
  function worktreeExtras(s) {
@@ -3879,8 +4253,7 @@ async function executeCodexRun(a) {
3879
4253
  let worktree = null;
3880
4254
  if (worktreeWanted(a.worktreeFlag, {
3881
4255
  cwd,
3882
- className: a.className,
3883
- prompt: parsed.prompt
4256
+ allowedTools: parsed.allowedTools
3884
4257
  })) try {
3885
4258
  worktree = addWorktree(logDir, wid, cwd);
3886
4259
  } catch (e) {
@@ -3945,12 +4318,7 @@ async function executeCodexRun(a) {
3945
4318
  id: wid,
3946
4319
  note: `dropped for codex: ${dropped.join(", ")}`
3947
4320
  });
3948
- if (!noPane && config.pane.enabled && term && process.env.PAI_WORKER_AUTOPANE !== "0") openPaneForWorker(logDir, config, wid, term).catch((e) => {
3949
- appendLedger(ledger, "PANE-FAIL", {
3950
- id: wid,
3951
- error: (e instanceof Error ? e.message : String(e)).slice(0, 200)
3952
- });
3953
- });
4321
+ announcePane(logDir, config, wid, term, noPane, ledger);
3954
4322
  const t0 = Date.now();
3955
4323
  const proc = spawn("codex", buildCodexArgs(prompt, parsed.callerModel ? void 0 : model), {
3956
4324
  env,
@@ -3990,9 +4358,12 @@ async function executeCodexRun(a) {
3990
4358
  status.secs = Math.floor((Date.now() - t0) / 1e3);
3991
4359
  status.last = `killed by signal ${sig}`;
3992
4360
  saveStatus(logDir, status);
3993
- if (worktree) try {
3994
- recordWorktree(logDir, status, worktree, false);
3995
- } catch {}
4361
+ if (worktree) {
4362
+ salvageOnExit(ledger, wid, label, worktree.dir, SIGNAL_SALVAGE_MS);
4363
+ try {
4364
+ recordWorktree(logDir, status, worktree, false);
4365
+ } catch {}
4366
+ }
3996
4367
  try {
3997
4368
  proc.kill();
3998
4369
  } catch {}
@@ -4029,9 +4400,6 @@ async function executeCodexRun(a) {
4029
4400
  };
4030
4401
  writeEvent(resultEvent);
4031
4402
  const ok = rc === 0 && !fold.isError;
4032
- status.state = ok ? "done" : "failed";
4033
- status.rc = rc;
4034
- status.secs = secs;
4035
4403
  status.last = shortText(report?.notes ?? finalText, 90) || (ok ? "done" : "failed");
4036
4404
  if (report?.changed?.length) {
4037
4405
  const verify = verifyReportChanges(report.changed, worktree?.dir ?? cwd, t0);
@@ -4047,40 +4415,29 @@ async function executeCodexRun(a) {
4047
4415
  });
4048
4416
  }
4049
4417
  }
4050
- saveStatus(logDir, status);
4051
- appendLedger(ledger, "WORKER-END", {
4052
- id: wid,
4053
- provider: target.providerName,
4054
- mode: "headless",
4055
- engine: "codex",
4056
- model,
4418
+ return finaliseRun({
4419
+ logDir,
4420
+ ledger,
4421
+ status,
4422
+ ok,
4057
4423
  rc,
4058
4424
  secs,
4059
- turns: status.turns,
4060
- tools: status.tools,
4061
- label
4425
+ end: {
4426
+ provider: target.providerName,
4427
+ mode: "headless",
4428
+ engine: "codex",
4429
+ model,
4430
+ turns: status.turns,
4431
+ tools: status.tools,
4432
+ label
4433
+ },
4434
+ report,
4435
+ fallbackText: finalText,
4436
+ worktree,
4437
+ beforeHandoff: (st, rep) => {
4438
+ if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, rep, worktreeExtras(st));
4439
+ }
4062
4440
  });
4063
- if (worktree) status = recordWorktree(logDir, status, worktree, ok);
4064
- if (!a.quiet) printResult(parsed.outputFormat, resultEvent, rc, logDir, wid, report, worktreeExtras(status));
4065
- const finalRc = rc !== 0 ? rc : ok ? 0 : 1;
4066
- if (status.parent && isWorkerId(logDir, status.parent)) try {
4067
- await deliverHandoff(logDir, {
4068
- from: wid,
4069
- to: status.parent,
4070
- kind: "result",
4071
- text: shortText(report?.notes ?? finalText ?? (ok ? "done" : "failed"), 400),
4072
- data: {
4073
- rc: finalRc,
4074
- ok,
4075
- ...status.branch ? {
4076
- branch: status.branch,
4077
- commits: status.commits ?? 0
4078
- } : {},
4079
- ...report ? { report } : {}
4080
- }
4081
- });
4082
- } catch {}
4083
- return finalRc;
4084
4441
  }
4085
4442
  function printResult(fmt, resultEvent, rc, logDir, wid, report, extras) {
4086
4443
  if (fmt === "stream-json") return;
@@ -4213,5 +4570,5 @@ function resultFromOutput(out) {
4213
4570
  }
4214
4571
 
4215
4572
  //#endregion
4216
- export { UNLABELED as A, newWorkerId as B, workerInScope as C, worktreesDir as D, mergeWorker as E, elapsedOf as F, waitForTerminalStatus as G, ownsPid as H, isChatPane as I, shortText as J, longInlinePromptHint as K, isLive as L, alive as M, contextLabel as N, appendLedger as O, contextPercent as P, loadStatus as R, sessionTag as S, git as T, saveStatus as U, nowStamp as V, setWorkerLabel as W, expandMcpNames as X, describeMcp as Y, currentTabKey as _, projectLaunchConfig as a, resolveSession as b, handoffFromInside as c, workerDepth as d, checkPaneForWorker as f, markControlsHeld as g, clickrControlsArgv as h, usageContextTokens as i, ageOf as j, ledgerSummary as k, readInbox as l, openPaneForWorker as m, runWorker as n, parseWorkerReport as o, openFollowPane as p, parseRunnerArgs as q, testProvider as r, renderReport as s, printResult as t, sayToWorker as u, itermForClaudeSession as v, discardWorker as w, sessionMapPath as x, itermUuid as y, loadStatuses as z };
4217
- //# sourceMappingURL=run-FeM1_UVa.mjs.map
4573
+ export { appendLedger as A, loadStatus as B, workerInScope as C, mergeWorker as D, git as E, contextLabel as F, saveStatus as G, newWorkerId as H, contextPercent as I, longInlinePromptHint as J, setWorkerLabel as K, elapsedOf as L, UNLABELED as M, ageOf as N, uncommittedPaths as O, alive as P, expandMcpNames as Q, isChatPane as R, sessionTag as S, gcWorktrees as T, nowStamp as U, loadStatuses as V, ownsPid as W, shortText as X, parseRunnerArgs as Y, describeMcp as Z, currentTabKey as _, projectLaunchConfig as a, resolveSession as b, handoffFromInside as c, workerDepth as d, checkPaneForWorker as f, markControlsHeld as g, clickrControlsArgv as h, usageContextTokens as i, ledgerSummary as j, worktreesDir as k, readInbox as l, openPaneForWorker as m, runWorker as n, parseWorkerReport as o, openFollowPane as p, waitForTerminalStatus as q, testProvider as r, renderReport as s, printResult as t, sayToWorker as u, itermForClaudeSession as v, discardWorker as w, sessionMapPath as x, itermUuid as y, isLive as z };
4574
+ //# sourceMappingURL=run-BVQZI79D.mjs.map