@tekmidian/pai 0.61.0 → 0.63.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 (69) 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-C5oeyUg4.mjs → chain-CUfE6WYK.mjs} +2 -2
  4. package/dist/{chain-C5oeyUg4.mjs.map → chain-CUfE6WYK.mjs.map} +1 -1
  5. package/dist/cli/index.mjs +11 -11
  6. package/dist/cli/program.mjs +11 -11
  7. package/dist/{context-handover-cache-BdtJZpgv.mjs → context-handover-cache-DFNnlWv2.mjs} +2 -2
  8. package/dist/{context-handover-cache-BdtJZpgv.mjs.map → context-handover-cache-DFNnlWv2.mjs.map} +1 -1
  9. package/dist/daemon/index.mjs +8 -8
  10. package/dist/daemon-C8-uzOuV.mjs +19 -0
  11. package/dist/{daemon-DSsIEqzW.mjs → daemon-Cex3dT9f.mjs} +32 -18
  12. package/dist/{daemon-DSsIEqzW.mjs.map → daemon-Cex3dT9f.mjs.map} +1 -1
  13. package/dist/daemon-mcp/index.mjs +4 -4
  14. package/dist/{factory-EUUhSSoq.mjs → factory-BozZa81q.mjs} +3 -3
  15. package/dist/{factory-EUUhSSoq.mjs.map → factory-BozZa81q.mjs.map} +1 -1
  16. package/dist/{factory-DW7BCJJT.mjs → factory-UO9_DFMN.mjs} +1 -1
  17. package/dist/{fallback-DDbJVaR8.mjs → fallback-ktbR0aR7.mjs} +2 -2
  18. package/dist/{fallback-DDbJVaR8.mjs.map → fallback-ktbR0aR7.mjs.map} +1 -1
  19. package/dist/{helpers-D9WkIf9R.mjs → helpers-Dj-B314s.mjs} +3 -2
  20. package/dist/{helpers-D9WkIf9R.mjs.map → helpers-Dj-B314s.mjs.map} +1 -1
  21. package/dist/hooks/block-sleep-poll.mjs.map +2 -2
  22. package/dist/hooks/load-project-context.mjs +8519 -133
  23. package/dist/hooks/load-project-context.mjs.map +4 -4
  24. package/dist/hooks/security-validator.mjs.map +1 -1
  25. package/dist/hooks/worker-guard.mjs +7859 -0
  26. package/dist/hooks/worker-guard.mjs.map +7 -0
  27. package/dist/hooks/worker-status-line.mjs +101 -8
  28. package/dist/hooks/worker-status-line.mjs.map +4 -4
  29. package/dist/index.mjs +1 -1
  30. package/dist/{indexer-backend-DcDtin1O.mjs → indexer-backend-BN9rpp9O.mjs} +2 -2
  31. package/dist/{main-resolver-DIQ6m_RA.mjs → main-resolver-BIft_FVF.mjs} +3 -3
  32. package/dist/{main-resolver-DIQ6m_RA.mjs.map → main-resolver-BIft_FVF.mjs.map} +1 -1
  33. package/dist/{main-resolver-CxOsxtOr.mjs → main-resolver-C9-poMdi.mjs} +1 -1
  34. package/dist/{planner-D8jAr_sx.mjs → planner-DAQMTMjP.mjs} +4 -4
  35. package/dist/{planner-D8jAr_sx.mjs.map → planner-DAQMTMjP.mjs.map} +1 -1
  36. package/dist/{postgres-nvU5pShF.mjs → postgres-CxD6_Dca.mjs} +3 -3
  37. package/dist/{postgres-nvU5pShF.mjs.map → postgres-CxD6_Dca.mjs.map} +1 -1
  38. package/dist/{program-BTdspOOa.mjs → program-De1GVVM4.mjs} +178 -48
  39. package/dist/program-De1GVVM4.mjs.map +1 -0
  40. package/dist/{run-CxxZKEZw.mjs → run-CZVCCvXE.mjs} +353 -133
  41. package/dist/run-CZVCCvXE.mjs.map +1 -0
  42. package/dist/{session-keepalive-urk--pfa.mjs → session-keepalive-CWGzLBVU.mjs} +2 -2
  43. package/dist/{session-keepalive-urk--pfa.mjs.map → session-keepalive-CWGzLBVU.mjs.map} +1 -1
  44. package/dist/{sqlite-D9p5a4qe.mjs → sqlite-CfKfuggn.mjs} +2 -2
  45. package/dist/{sqlite-D9p5a4qe.mjs.map → sqlite-CfKfuggn.mjs.map} +1 -1
  46. package/dist/{tools-r-7ZG8B5.mjs → tools-B52IvaU6.mjs} +3 -3
  47. package/dist/{tools-qXyHFwNj.mjs → tools-DFdPbX-X.mjs} +2 -2
  48. package/dist/{tools-qXyHFwNj.mjs.map → tools-DFdPbX-X.mjs.map} +1 -1
  49. package/dist/{vault-indexer-DHYBpRZ5.mjs → vault-indexer-mCFus_AM.mjs} +2 -2
  50. package/dist/{vault-indexer-DHYBpRZ5.mjs.map → vault-indexer-mCFus_AM.mjs.map} +1 -1
  51. package/dist/{wakeup-C48hcN6O.mjs → wakeup-DO1O4udw.mjs} +2 -2
  52. package/dist/{wakeup-C48hcN6O.mjs.map → wakeup-DO1O4udw.mjs.map} +1 -1
  53. package/dist/{work-queue-worker-DUvPiwGO.mjs → work-queue-worker-BbrC3I_R.mjs} +2 -2
  54. package/dist/{work-queue-worker-DUvPiwGO.mjs.map → work-queue-worker-BbrC3I_R.mjs.map} +1 -1
  55. package/dist/{work-queue-worker-1_VBfI_k.mjs → work-queue-worker-z-AATZ2t.mjs} +3 -3
  56. package/docs/commands/README.md +4 -0
  57. package/docs/commands/project.md +14 -5
  58. package/docs/commands/projects.md +14 -5
  59. package/docs/commands/worker.md +27 -0
  60. package/package.json +2 -2
  61. package/scripts/build-hooks.mjs +6 -5
  62. package/scripts/build-skill-stubs.mjs +6 -5
  63. package/src/hooks/ts/lib/worker-guard.test.ts +188 -0
  64. package/src/hooks/ts/lib/worker-guard.ts +315 -0
  65. package/src/hooks/ts/pre-tool-use/worker-guard.ts +75 -0
  66. package/src/hooks/ts/session-start/load-project-context.ts +23 -0
  67. package/dist/daemon-MoFF4oeg.mjs +0 -19
  68. package/dist/program-BTdspOOa.mjs.map +0 -1
  69. package/dist/run-CxxZKEZw.mjs.map +0 -1
@@ -1,8 +1,8 @@
1
- import { i as getRegistryBackend } from "./factory-EUUhSSoq.mjs";
1
+ import { i as getRegistryBackend } from "./factory-BozZa81q.mjs";
2
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
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
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, tmpdir } from "node:os";
5
+ import { homedir, platform, tmpdir } from "node:os";
6
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";
@@ -157,6 +157,72 @@ function describeMcp(config, claudeJson = CLAUDE_JSON) {
157
157
  return lines;
158
158
  }
159
159
 
160
+ //#endregion
161
+ //#region src/workers/project-config.ts
162
+ /**
163
+ * project-config.ts — the project registry's optional launch pin, read for
164
+ * the interactive branch of run.ts only: which MCP servers and built-in
165
+ * tools a project's supervisor session (`pai worker run`, no -p) loads. Set
166
+ * with `pai project mcp` / `pai project tools` (src/cli/commands/project/
167
+ * session-config.ts), stored in the same `projects.session_config` JSON
168
+ * column as the rest of a project's launch config. Unset = today's
169
+ * behavior: every server, every tool.
170
+ *
171
+ * A standalone SQL lookup rather than importing the CLI's detectProject:
172
+ * workers/ is a dependency of cli/, not the other way around.
173
+ */
174
+ function asStringArray(v) {
175
+ if (!Array.isArray(v)) return void 0;
176
+ const out = v.filter((x) => typeof x === "string");
177
+ return out.length ? out : void 0;
178
+ }
179
+ /**
180
+ * The launch config of the project whose root_path is `cwd` or an ancestor
181
+ * of it (longest match wins) — null when no registered project covers it,
182
+ * or when the matching project has no mcp/tools pin set. `registry` is
183
+ * injectable for tests; production callers take the process-wide backend.
184
+ */
185
+ async function projectLaunchConfig(cwd, registry) {
186
+ const target = resolve(cwd);
187
+ const rows = await (registry ?? await getRegistryBackend()).listProjectsByPathLengthDesc({ excludeArchived: true });
188
+ for (const row of rows) {
189
+ const root = resolve(row.root_path);
190
+ if (target !== root && !target.startsWith(root + "/")) continue;
191
+ if (!row.session_config) return null;
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(row.session_config);
195
+ } catch {
196
+ return null;
197
+ }
198
+ const mcp = asStringArray(parsed.mcp);
199
+ const tools = asStringArray(parsed.tools);
200
+ return mcp || tools ? {
201
+ slug: row.slug,
202
+ mcp,
203
+ tools
204
+ } : null;
205
+ }
206
+ return null;
207
+ }
208
+ /**
209
+ * One line per active pin, so a pin never silently removes a server or tool:
210
+ * shown by the SessionStart hook and by `pai worker run`'s interactive launch.
211
+ * `claudeJson` is injectable for tests; read-only.
212
+ */
213
+ function pinNotices(launch, claudeJson) {
214
+ if (!launch) return [];
215
+ const lines = [];
216
+ if (launch.mcp) {
217
+ const all = Object.keys(readMcpServers(claudeJson));
218
+ const loaded = all.filter((n) => launch.mcp.includes(n));
219
+ const missing = all.filter((n) => !launch.mcp.includes(n));
220
+ lines.push(`MCP pinned for ${launch.slug}: loading ${loaded.length} of ${all.length} servers; not loaded: ${missing.join(", ") || "(none)"} — \`pai project mcp --clear\` to load all`);
221
+ }
222
+ if (launch.tools) lines.push(`Tools pinned for ${launch.slug}: ${launch.tools.join(",")} — \`pai project tools --clear\``);
223
+ return lines;
224
+ }
225
+
160
226
  //#endregion
161
227
  //#region src/workers/args.ts
162
228
  function parseRunnerArgs(argv) {
@@ -598,7 +664,7 @@ function worktreePath(logDir, id) {
598
664
  return join(worktreesDir(logDir), id);
599
665
  }
600
666
  /** Run git in `cwd` with an optional env override, trimmed stdout, stderr on failure. */
601
- function runGit(cwd, args, env) {
667
+ function runGit(cwd, args, env, timeout = 3e4) {
602
668
  try {
603
669
  return execFileSync("git", [
604
670
  "-C",
@@ -606,7 +672,7 @@ function runGit(cwd, args, env) {
606
672
  ...args
607
673
  ], {
608
674
  encoding: "utf8",
609
- timeout: 3e4,
675
+ timeout,
610
676
  stdio: [
611
677
  "ignore",
612
678
  "pipe",
@@ -621,8 +687,8 @@ function runGit(cwd, args, env) {
621
687
  }
622
688
  }
623
689
  /** Run git in `cwd`, returning trimmed stdout; throws with stderr on failure. */
624
- function git(cwd, args) {
625
- return runGit(cwd, args);
690
+ function git(cwd, args, timeoutMs) {
691
+ return runGit(cwd, args, void 0, timeoutMs);
626
692
  }
627
693
  /** Is `cwd` inside a git repository (a .git dir — worktrees: a .git file)? */
628
694
  function isGitRepo(cwd) {
@@ -632,30 +698,23 @@ function isGitRepo(cwd) {
632
698
  return false;
633
699
  }
634
700
  }
635
- /**
636
- * Does the prompt read as a read-only task? Writing classes default to a
637
- * worktree; a prompt that only asks to look at things should not pay for one.
638
- * First-word verbs plus the explicit markers people actually write.
639
- */
640
- function promptLooksReadonly(prompt) {
641
- const p = prompt.trim();
642
- if (!p) return true;
643
- const firstLine = p.split("\n", 1)[0];
644
- if (/\b(read[- ]only|do not (modify|change|edit|write)|don'?t (modify|change|edit|write)|no changes)\b/i.test(firstLine)) return true;
645
- return /^(review|read|analy[sz]e|research|summar[iy]|inspect|investigate|spotcheck|report|find|list|check|verify|describe|explain|show)\b/i.test(p);
646
- }
647
- /** The classes whose runs write files and therefore default to a worktree. */
648
- const WORKTREE_CLASSES = [
649
- "implement",
650
- "complex",
651
- "plan"
701
+ /** Tools that can write to the checkout (Bash can, via the shell). */
702
+ const WRITING_TOOLS = [
703
+ "Edit",
704
+ "Write",
705
+ "MultiEdit",
706
+ "NotebookEdit",
707
+ "Bash"
652
708
  ];
653
- /** Should this run get a worktree? Explicit flag first, then the default rule. */
709
+ /** Can a run with these --allowedTools write? No restriction at all means yes. */
710
+ function toolsCanWrite(allowedTools) {
711
+ const names = allowedTools.flatMap((e) => e.split(",")).map((s) => s.trim().replace(/\(.*$/, "")).filter(Boolean);
712
+ return !names.length || names.some((n) => WRITING_TOOLS.includes(n));
713
+ }
714
+ /** Should this run get a worktree? Explicit flag first, then: can it write? */
654
715
  function worktreeWanted(flag, opts) {
655
716
  if (flag !== void 0) return flag;
656
- if (!opts.className || !WORKTREE_CLASSES.includes(opts.className)) return false;
657
- if (!isGitRepo(opts.cwd)) return false;
658
- return !promptLooksReadonly(opts.prompt ?? "");
717
+ return toolsCanWrite(opts.allowedTools) && isGitRepo(opts.cwd);
659
718
  }
660
719
  /**
661
720
  * Commit `cwd`'s current index + working tree (including untracked files) as
@@ -699,11 +758,6 @@ function snapshotUncommitted(cwd, id, head) {
699
758
  * degrade to an in-place run.
700
759
  */
701
760
  function addWorktree(logDir, id, cwd) {
702
- const swept = sweepOrphanWorktrees(logDir);
703
- if (swept.length) appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
704
- id,
705
- note: `swept orphan worktree(s): ${swept.join(", ")}`
706
- });
707
761
  const dir = worktreePath(logDir, id);
708
762
  const branch = worktreeBranch(id);
709
763
  const head = git(cwd, ["rev-parse", "HEAD"]);
@@ -717,6 +771,7 @@ function addWorktree(logDir, id, cwd) {
717
771
  branch,
718
772
  base
719
773
  ]);
774
+ provisionDeps(logDir, id, cwd, dir);
720
775
  return {
721
776
  dir,
722
777
  branch,
@@ -725,44 +780,60 @@ function addWorktree(logDir, id, cwd) {
725
780
  };
726
781
  }
727
782
  /**
728
- * Remove worktrees (and their `worker/<id>` branches) whose worker no longer
729
- * exists — no status file left in the log dir. Killed and failed runs clean
730
- * up after themselves, but a `kill -9` or a crash strands a directory and a
731
- * branch; every worktree creation sweeps first so they cannot accumulate.
732
- * Directories younger than `minAgeMin` minutes are left alone: a run that is
733
- * just starting owns its worktree a moment before its status file exists.
783
+ * Give the worktree its own dependencies so the worker never has to improvise
784
+ * (symlinking node_modules to the main checkout, building in a /tmp copy).
785
+ * A real node_modules directory in the main checkout is APFS-cloned
786
+ * (`cp -c`, copy-on-write, near instant); elsewhere the lockfile installs it.
787
+ * Never a symlink. Time-boxed and best effort: a failure is logged, the run
788
+ * goes on.
734
789
  */
735
- function sweepOrphanWorktrees(logDir, minAgeMin = 10) {
736
- const root = worktreesDir(logDir);
737
- if (!existsSync(root)) return [];
738
- const swept = [];
739
- for (const ent of readdirSync(root, { withFileTypes: true })) {
740
- if (!ent.isDirectory()) continue;
741
- const id = ent.name;
742
- const dir = join(root, id);
743
- if (existsSync(statusPath(logDir, id))) continue;
744
- try {
745
- if ((Date.now() - statSync(dir).mtimeMs) / 6e4 < minAgeMin) continue;
746
- } catch {}
747
- let gitDir = null;
790
+ function provisionDeps(logDir, id, cwd, wtDir) {
791
+ const note = (n) => appendLedger(ledgerPath(logDir), "WORKER-NOTE", {
792
+ id,
793
+ note: n
794
+ });
795
+ try {
796
+ const main = join(git(cwd, ["rev-parse", "--show-toplevel"]), "node_modules");
797
+ const dest = join(wtDir, "node_modules");
798
+ if (!existsSync(main) || lstatSync(main).isSymbolicLink() || existsSync(dest)) return;
748
799
  try {
749
- const raw = git(dir, ["rev-parse", "--git-common-dir"]);
750
- gitDir = isAbsolute(raw) ? raw : resolve(dir, raw);
800
+ git(wtDir, [
801
+ "check-ignore",
802
+ "-q",
803
+ "node_modules"
804
+ ]);
751
805
  } catch {
752
- gitDir = null;
806
+ return;
753
807
  }
754
- removeWorktree(gitDir ?? dir, dir, true);
755
- if (gitDir) try {
756
- git(gitDir, ["worktree", "prune"]);
757
- git(gitDir, [
758
- "branch",
759
- "-D",
760
- worktreeBranch(id)
761
- ]);
808
+ const t0 = Date.now();
809
+ const opts = {
810
+ cwd: wtDir,
811
+ stdio: "ignore",
812
+ timeout: 18e4
813
+ };
814
+ if (platform() === "darwin") try {
815
+ execFileSync("cp", [
816
+ "-cR",
817
+ main,
818
+ dest
819
+ ], opts);
820
+ note(`node_modules cloned (apfs) in ${Date.now() - t0}ms`);
821
+ return;
822
+ } catch {
823
+ rmSync(dest, {
824
+ recursive: true,
825
+ force: true
826
+ });
827
+ }
828
+ if (existsSync(join(wtDir, "bun.lock"))) execFileSync("bun", ["install", "--frozen-lockfile"], opts);
829
+ else if (existsSync(join(wtDir, "package-lock.json"))) execFileSync("npm", ["ci"], opts);
830
+ else return;
831
+ note(`node_modules installed from lockfile in ${Date.now() - t0}ms`);
832
+ } catch (e) {
833
+ try {
834
+ note(`node_modules provisioning failed: ${e.message}`);
762
835
  } catch {}
763
- swept.push(id);
764
836
  }
765
- return swept;
766
837
  }
767
838
  /** Commits the branch collected on top of its base. */
768
839
  function commitsSince(dir, base) {
@@ -788,6 +859,12 @@ function recordWorktree(logDir, status, info, ok) {
788
859
  s.worktreeDir = info.dir;
789
860
  s.worktreeBase = info.base;
790
861
  s.worktreeSnapshot = info.snapshot;
862
+ } else if (safeCommitsSince(info.dir, info.base) > 0) {
863
+ s.branch = info.branch;
864
+ s.commits = commitsSince(info.dir, info.base);
865
+ s.worktreeDir = info.dir;
866
+ s.worktreeBase = info.base;
867
+ s.worktreeSnapshot = info.snapshot;
791
868
  } else {
792
869
  removeWorktree(s.cwd, info.dir, true);
793
870
  try {
@@ -806,6 +883,37 @@ function recordWorktree(logDir, status, info, ok) {
806
883
  saveStatus(logDir, s);
807
884
  return s;
808
885
  }
886
+ function safeCommitsSince(dir, base) {
887
+ try {
888
+ return commitsSince(dir, base);
889
+ } catch {
890
+ return 0;
891
+ }
892
+ }
893
+ /**
894
+ * Salvage for an exit path (run finaliser, kill signal): commit whatever the
895
+ * worker left uncommitted, log WORKER-SALVAGE, never throw. Bounded by
896
+ * `timeoutMs` per git call. Returns the committed paths.
897
+ */
898
+ function salvageOnExit(ledger, id, label, wtDir, timeoutMs) {
899
+ try {
900
+ const { committed, skipped } = salvageUncommitted(wtDir, label, timeoutMs);
901
+ if (committed.length) appendLedger(ledger, "WORKER-SALVAGE", {
902
+ id,
903
+ paths: committed.join(","),
904
+ skipped: skipped.length
905
+ });
906
+ return committed;
907
+ } catch (e) {
908
+ try {
909
+ appendLedger(ledger, "WORKER-NOTE", {
910
+ id,
911
+ note: `salvage failed: ${e.message}`
912
+ });
913
+ } catch {}
914
+ return [];
915
+ }
916
+ }
809
917
  /** Remove a worktree directory from git's books and the filesystem. */
810
918
  function removeWorktree(cwd, dir, force) {
811
919
  try {
@@ -831,17 +939,17 @@ function removeWorktree(cwd, dir, force) {
831
939
  * carry used; name-only, not porcelain: a worktree-only change renders as
832
940
  * " M path" and the shared git() helper trims that leading space away.
833
941
  */
834
- function uncommittedPaths(wtDir) {
942
+ function uncommittedPaths(wtDir, timeoutMs) {
835
943
  const tracked = git(wtDir, [
836
944
  "diff",
837
945
  "--name-only",
838
946
  "HEAD"
839
- ]).split("\n").filter(Boolean);
947
+ ], timeoutMs).split("\n").filter(Boolean);
840
948
  const untracked = git(wtDir, [
841
949
  "ls-files",
842
950
  "--others",
843
951
  "--exclude-standard"
844
- ]).split("\n").filter(Boolean);
952
+ ], timeoutMs).split("\n").filter(Boolean);
845
953
  return [...tracked, ...untracked];
846
954
  }
847
955
  /**
@@ -877,8 +985,8 @@ function symlinkEscapesWorktree(wtDir, relPath) {
877
985
  * as skipped instead. A failed commit throws with the worktree untouched:
878
986
  * its edits are still on disk, so nothing is lost.
879
987
  */
880
- function salvageUncommitted(wtDir, label) {
881
- const paths = uncommittedPaths(wtDir);
988
+ function salvageUncommitted(wtDir, label, timeoutMs) {
989
+ const paths = uncommittedPaths(wtDir, timeoutMs);
882
990
  if (!paths.length) return {
883
991
  committed: [],
884
992
  skipped: []
@@ -894,14 +1002,16 @@ function salvageUncommitted(wtDir, label) {
894
1002
  try {
895
1003
  git(wtDir, [
896
1004
  "add",
1005
+ "-A",
897
1006
  "--",
898
- ...toStage
899
- ]);
1007
+ ".",
1008
+ ...paths.filter((p) => !toStage.includes(p)).map((p) => `:(exclude,literal)${p}`)
1009
+ ], timeoutMs);
900
1010
  git(wtDir, [
901
1011
  "commit",
902
1012
  "-m",
903
1013
  `salvaged: ${label}`
904
- ]);
1014
+ ], timeoutMs);
905
1015
  } catch (e) {
906
1016
  throw new Error(`cannot salvage the uncommitted changes in ${wtDir} — ${e.message}; nothing was lost: commit them there by hand, then re-run merge`);
907
1017
  }
@@ -1215,6 +1325,139 @@ function inPlaceSystemPrompt() {
1215
1325
  "Read-only git (status, diff, log, show, stash list) is fine."
1216
1326
  ].join("\n");
1217
1327
  }
1328
+ const GC_STAMP = "gc-last";
1329
+ /**
1330
+ * Archive worktrees of finished workers older than `olderThanHours` and
1331
+ * status-less orphan directories: uncommitted state is salvaged onto the
1332
+ * branch, the branch is x, the worktree is
1333
+ * removed. Nothing is deleted without a salvage first; a failed salvage skips
1334
+ * that worktree. Running workers are never touched. `dryRun` reports only.
1335
+ */
1336
+ function gcWorktrees(logDir, olderThanHours = 24, dryRun = false, now = Date.now()) {
1337
+ const cutoff = now - olderThanHours * 36e5;
1338
+ const out = [];
1339
+ const root = worktreesDir(logDir);
1340
+ const known = /* @__PURE__ */ new Set();
1341
+ const archive = (id, dir, repo, st) => {
1342
+ const target = `refs/pai-archive/${id}`;
1343
+ if (dryRun) return void out.push({
1344
+ id,
1345
+ archive: target
1346
+ });
1347
+ try {
1348
+ if (!repo) throw new Error("not a git worktree");
1349
+ const label = `archive: leftover state of worker ${id}`;
1350
+ if (uncommittedPaths(dir).length) {
1351
+ const res = salvageUncommitted(dir, label);
1352
+ if (res.committed.length) git(dir, [
1353
+ "commit",
1354
+ "--amend",
1355
+ "-q",
1356
+ "-m",
1357
+ label
1358
+ ]);
1359
+ if (res.skipped.length && uncommittedPaths(dir).some((p) => !symlinkEscapesWorktree(dir, p))) throw new Error("uncommitted paths remain");
1360
+ }
1361
+ const src = worktreeBranch(id);
1362
+ let hasBranch = true;
1363
+ try {
1364
+ git(repo, [
1365
+ "rev-parse",
1366
+ "--verify",
1367
+ "-q",
1368
+ `refs/heads/${src}`
1369
+ ]);
1370
+ } catch {
1371
+ hasBranch = false;
1372
+ }
1373
+ const tip = git(dir, ["rev-parse", "HEAD"]);
1374
+ if (hasBranch) git(repo, [
1375
+ "merge-base",
1376
+ "--is-ancestor",
1377
+ `refs/heads/${src}`,
1378
+ tip
1379
+ ]);
1380
+ git(repo, [
1381
+ "update-ref",
1382
+ target,
1383
+ tip
1384
+ ]);
1385
+ git(repo, [
1386
+ "worktree",
1387
+ "remove",
1388
+ "--force",
1389
+ dir
1390
+ ]);
1391
+ if (hasBranch) git(repo, [
1392
+ "branch",
1393
+ "-D",
1394
+ src
1395
+ ]);
1396
+ if (st) saveStatus(logDir, {
1397
+ ...st,
1398
+ branch: null,
1399
+ worktreeDir: null,
1400
+ archived: target
1401
+ });
1402
+ appendLedger(ledgerPath(logDir), "WORKER-GC", {
1403
+ id,
1404
+ archive: target
1405
+ });
1406
+ out.push({
1407
+ id,
1408
+ archive: target
1409
+ });
1410
+ } catch (e) {
1411
+ out.push({
1412
+ id,
1413
+ skipped: e.message.split("\n")[0]
1414
+ });
1415
+ }
1416
+ };
1417
+ for (const st of loadStatuses(logDir)) {
1418
+ known.add(st.id);
1419
+ if (!st.worktreeDir || !existsSync(st.worktreeDir) || isLive(st)) continue;
1420
+ let end = 0;
1421
+ try {
1422
+ end = statSync(statusPath(logDir, st.id)).mtimeMs;
1423
+ } catch {
1424
+ continue;
1425
+ }
1426
+ if (end > cutoff) continue;
1427
+ archive(st.id, st.worktreeDir, st.cwd, st);
1428
+ }
1429
+ if (existsSync(root)) for (const ent of readdirSync(root, { withFileTypes: true })) {
1430
+ if (!ent.isDirectory() || known.has(ent.name) || existsSync(statusPath(logDir, ent.name))) continue;
1431
+ const dir = join(root, ent.name);
1432
+ try {
1433
+ if (statSync(dir).mtimeMs > cutoff) continue;
1434
+ } catch {
1435
+ continue;
1436
+ }
1437
+ let repo = null;
1438
+ try {
1439
+ const raw = git(dir, ["rev-parse", "--git-common-dir"]);
1440
+ repo = isAbsolute(raw) ? raw : resolve(dir, raw);
1441
+ } catch {
1442
+ repo = null;
1443
+ }
1444
+ archive(ent.name, dir, repo, null);
1445
+ }
1446
+ if (!dryRun) for (const repo of new Set(loadStatuses(logDir).map((s) => s.cwd))) try {
1447
+ git(repo, ["worktree", "prune"]);
1448
+ } catch {}
1449
+ return out;
1450
+ }
1451
+ /** Run `gcWorktrees` at most once per `everyMin` minutes (stamp file in the logDir). */
1452
+ function gcWorktreesThrottled(logDir, everyMin = 60, now = Date.now()) {
1453
+ const stamp = join(logDir, GC_STAMP);
1454
+ try {
1455
+ if (now - statSync(stamp).mtimeMs < everyMin * 6e4) return null;
1456
+ } catch {}
1457
+ mkdirSync(logDir, { recursive: true });
1458
+ writeFileSync(stamp, String(now), "utf8");
1459
+ return gcWorktrees(logDir, 24, false, now);
1460
+ }
1218
1461
 
1219
1462
  //#endregion
1220
1463
  //#region src/workers/scope.ts
@@ -2782,54 +3025,6 @@ function relShort(p, cwd) {
2782
3025
  return r && !r.startsWith("..") ? r : p;
2783
3026
  }
2784
3027
 
2785
- //#endregion
2786
- //#region src/workers/project-config.ts
2787
- /**
2788
- * project-config.ts — the project registry's optional launch pin, read for
2789
- * the interactive branch of run.ts only: which MCP servers and built-in
2790
- * tools a project's supervisor session (`pai worker run`, no -p) loads. Set
2791
- * with `pai project mcp` / `pai project tools` (src/cli/commands/project/
2792
- * session-config.ts), stored in the same `projects.session_config` JSON
2793
- * column as the rest of a project's launch config. Unset = today's
2794
- * behavior: every server, every tool.
2795
- *
2796
- * A standalone SQL lookup rather than importing the CLI's detectProject:
2797
- * workers/ is a dependency of cli/, not the other way around.
2798
- */
2799
- function asStringArray(v) {
2800
- if (!Array.isArray(v)) return void 0;
2801
- const out = v.filter((x) => typeof x === "string");
2802
- return out.length ? out : void 0;
2803
- }
2804
- /**
2805
- * The launch config of the project whose root_path is `cwd` or an ancestor
2806
- * of it (longest match wins) — null when no registered project covers it,
2807
- * or when the matching project has no mcp/tools pin set. `registry` is
2808
- * injectable for tests; production callers take the process-wide backend.
2809
- */
2810
- async function projectLaunchConfig(cwd, registry) {
2811
- const target = resolve(cwd);
2812
- const rows = await (registry ?? await getRegistryBackend()).listProjectsByPathLengthDesc({ excludeArchived: true });
2813
- for (const row of rows) {
2814
- const root = resolve(row.root_path);
2815
- if (target !== root && !target.startsWith(root + "/")) continue;
2816
- if (!row.session_config) return null;
2817
- let parsed;
2818
- try {
2819
- parsed = JSON.parse(row.session_config);
2820
- } catch {
2821
- return null;
2822
- }
2823
- const mcp = asStringArray(parsed.mcp);
2824
- const tools = asStringArray(parsed.tools);
2825
- return mcp || tools ? {
2826
- mcp,
2827
- tools
2828
- } : null;
2829
- }
2830
- return null;
2831
- }
2832
-
2833
3028
  //#endregion
2834
3029
  //#region src/workers/codex.ts
2835
3030
  /**
@@ -3233,8 +3428,12 @@ async function runWorker(opts) {
3233
3428
  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)");
3234
3429
  const logDir = workersLogDir(config);
3235
3430
  mkdirSync(logDir, { recursive: true });
3431
+ try {
3432
+ const gc = gcWorktreesThrottled(logDir)?.filter((r) => r.archive);
3433
+ if (gc?.length) process.stderr.write(`pai worker: archived ${gc.length} leftover worktree(s) (pai worker gc)\n`);
3434
+ } catch {}
3236
3435
  if (opts.className === "plan" && !opts._planner) {
3237
- const { runPlanner } = await import("./planner-D8jAr_sx.mjs");
3436
+ const { runPlanner } = await import("./planner-DAQMTMjP.mjs");
3238
3437
  return runPlanner(opts);
3239
3438
  }
3240
3439
  const parent = launchParent(opts.parent);
@@ -3445,10 +3644,21 @@ function interactiveMcpTools(explicitMcp, callerTools, projectLaunch) {
3445
3644
  * backgrounded a sub-worker and exited without waiting, the run must not
3446
3645
  * report success, whatever the process returned.
3447
3646
  */
3647
+ /** Per-git-call bound for the salvage a kill signal runs before exiting. */
3648
+ const SIGNAL_SALVAGE_MS = 5e3;
3448
3649
  async function finaliseRun(f) {
3449
3650
  const { logDir, ledger, ok, rc } = f;
3450
3651
  let { status, report } = f;
3451
3652
  const wid = status.id;
3653
+ const salvaged = f.worktree ? salvageOnExit(ledger, wid, status.label || UNLABELED, f.worktree.dir) : [];
3654
+ if (salvaged.length) {
3655
+ const msg = `WORKER-SALVAGE: committed uncommitted edits (${salvaged.join(", ")})`;
3656
+ report = {
3657
+ ...report ?? {},
3658
+ notes: [report?.notes ?? f.fallbackText, msg].filter(Boolean).join("\n"),
3659
+ result: report?.result ?? "-"
3660
+ };
3661
+ }
3452
3662
  const orphanReason = checkOrphanedChildren(logDir, wid);
3453
3663
  const succeeded = ok && !orphanReason;
3454
3664
  status.state = succeeded ? "done" : "failed";
@@ -3631,8 +3841,7 @@ async function executeRun(a) {
3631
3841
  let worktree = null;
3632
3842
  if (headless && !a.printCmd && worktreeWanted(a.worktreeFlag, {
3633
3843
  cwd,
3634
- className: a.className,
3635
- prompt: parsed.prompt
3844
+ allowedTools: parsed.allowedTools
3636
3845
  })) try {
3637
3846
  worktree = addWorktree(logDir, wid, cwd);
3638
3847
  } catch (e) {
@@ -3673,6 +3882,12 @@ async function executeRun(a) {
3673
3882
  const explicitMcp = [...a.mcpFlag ? [a.mcpFlag] : [], ...parsed.mcp];
3674
3883
  const projectLaunch = await projectLaunchConfig(cwd);
3675
3884
  const picked = interactiveMcpTools(explicitMcp, parsed.callerTools, projectLaunch);
3885
+ const applied = projectLaunch && {
3886
+ ...projectLaunch,
3887
+ mcp: explicitMcp.length ? void 0 : projectLaunch.mcp,
3888
+ tools: picked.tools.length ? projectLaunch.tools : void 0
3889
+ };
3890
+ for (const line of pinNotices(applied)) console.error(line);
3676
3891
  if (picked.mcpNames.length) mcpArgs = [
3677
3892
  "--strict-mcp-config",
3678
3893
  "--mcp-config",
@@ -3816,9 +4031,12 @@ async function executeRun(a) {
3816
4031
  killed: 1,
3817
4032
  label
3818
4033
  });
3819
- if (worktree) try {
3820
- recordWorktree(logDir, status, worktree, false);
3821
- } catch {}
4034
+ if (worktree) {
4035
+ salvageOnExit(ledger, wid, label, worktree.dir, SIGNAL_SALVAGE_MS);
4036
+ try {
4037
+ recordWorktree(logDir, status, worktree, false);
4038
+ } catch {}
4039
+ }
3822
4040
  cleanup();
3823
4041
  try {
3824
4042
  proc.kill();
@@ -4059,8 +4277,7 @@ async function executeCodexRun(a) {
4059
4277
  let worktree = null;
4060
4278
  if (worktreeWanted(a.worktreeFlag, {
4061
4279
  cwd,
4062
- className: a.className,
4063
- prompt: parsed.prompt
4280
+ allowedTools: parsed.allowedTools
4064
4281
  })) try {
4065
4282
  worktree = addWorktree(logDir, wid, cwd);
4066
4283
  } catch (e) {
@@ -4165,9 +4382,12 @@ async function executeCodexRun(a) {
4165
4382
  status.secs = Math.floor((Date.now() - t0) / 1e3);
4166
4383
  status.last = `killed by signal ${sig}`;
4167
4384
  saveStatus(logDir, status);
4168
- if (worktree) try {
4169
- recordWorktree(logDir, status, worktree, false);
4170
- } catch {}
4385
+ if (worktree) {
4386
+ salvageOnExit(ledger, wid, label, worktree.dir, SIGNAL_SALVAGE_MS);
4387
+ try {
4388
+ recordWorktree(logDir, status, worktree, false);
4389
+ } catch {}
4390
+ }
4171
4391
  try {
4172
4392
  proc.kill();
4173
4393
  } catch {}
@@ -4374,5 +4594,5 @@ function resultFromOutput(out) {
4374
4594
  }
4375
4595
 
4376
4596
  //#endregion
4377
- 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 };
4378
- //# sourceMappingURL=run-CxxZKEZw.mjs.map
4597
+ export { expandMcpNames as $, ledgerSummary as A, loadStatuses as B, discardWorker as C, uncommittedPaths as D, mergeWorker as E, contextPercent as F, setWorkerLabel as G, nowStamp as H, elapsedOf as I, parseRunnerArgs as J, waitForTerminalStatus as K, isChatPane as L, ageOf as M, alive as N, worktreesDir as O, contextLabel as P, describeMcp as Q, isLive as R, workerInScope as S, git as T, ownsPid as U, newWorkerId as V, saveStatus as W, pinNotices as X, shortText as Y, projectLaunchConfig as Z, itermForClaudeSession as _, parseWorkerReport as a, sessionMapPath as b, readInbox as c, checkPaneForWorker as d, openFollowPane as f, currentTabKey as g, markControlsHeld as h, usageContextTokens as i, UNLABELED as j, appendLedger as k, sayToWorker as l, clickrControlsArgv as m, runWorker as n, renderReport as o, openPaneForWorker as p, longInlinePromptHint as q, testProvider as r, handoffFromInside as s, printResult as t, workerDepth as u, itermUuid as v, gcWorktrees as w, sessionTag as x, resolveSession as y, loadStatus as z };
4598
+ //# sourceMappingURL=run-CZVCCvXE.mjs.map