@join-pantheon/cli 0.33.1 → 0.33.2

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 (44) hide show
  1. package/README.md +6 -5
  2. package/dist/accounts.js +10 -0
  3. package/dist/agents.js +11 -0
  4. package/dist/board.js +55 -9
  5. package/dist/claude-confine.js +1 -1
  6. package/dist/cli.js +556 -89
  7. package/dist/client.js +8 -4
  8. package/dist/config.js +18 -4
  9. package/dist/continued.js +5 -0
  10. package/dist/desktop-app.js +103 -0
  11. package/dist/doctor.js +84 -32
  12. package/dist/folder-guard.js +12 -0
  13. package/dist/folder-snapshot.js +432 -0
  14. package/dist/install.js +15 -8
  15. package/dist/machine.js +84 -4
  16. package/dist/managed.js +2 -2
  17. package/dist/mcp.js +5 -3
  18. package/dist/network.js +156 -0
  19. package/dist/notice-linux.js +6 -5
  20. package/dist/notice-windows.js +2 -2
  21. package/dist/notice.js +13 -0
  22. package/dist/notifier.js +29 -2
  23. package/dist/notify.js +11 -0
  24. package/dist/onboard-defaults.js +37 -1
  25. package/dist/pins.js +7 -1
  26. package/dist/plans.js +23 -0
  27. package/dist/protocol.js +5 -0
  28. package/dist/quickstart.js +17 -11
  29. package/dist/refusal.js +4 -4
  30. package/dist/relay.js +332 -22
  31. package/dist/repo-find.js +6 -3
  32. package/dist/roster-file.js +50 -5
  33. package/dist/tray.js +12 -1
  34. package/dist/uninstall.js +38 -3
  35. package/dist/uri-handler.js +3 -2
  36. package/dist/verify-act.js +17 -0
  37. package/dist/worker-fleet.js +140 -5
  38. package/dist/worker.js +201 -63
  39. package/installer/install.ps1 +251 -15
  40. package/installer/install.sh +147 -18
  41. package/package.json +1 -1
  42. package/helper/macos/prebuilt/manifest.json +0 -29
  43. package/helper/macos/prebuilt/pantheon-hud +0 -0
  44. package/helper/macos/prebuilt/pantheon-tray +0 -0
package/dist/worker.js CHANGED
@@ -20,19 +20,20 @@ import { agentSpawn, CmdLineUnsafe } from "./which.js";
20
20
  import { VERSION } from "./version.js";
21
21
  import { applyAnswer, CAPABILITY_ARGS, CARRY_REASON, carryOverWakers, dropLedgerGrants, hasWakeOnce, isApprovedWaker, mayAskWake, takeWakeOnce, OLD_RELAY_WAKE, relayKnowsAdmitters, loadPolicy, mayAsk, needsSelfUpgrade, parseGrantAnswer, recordAsk, savePolicy, useGrant, WOKEN_RUN_ENV, takeTeamFlagOnYes, unsandboxedAllowed, policyPath, isNewer, } from "./worker-policy.js";
22
22
  import { runtimeNodeVersion } from "./managed.js";
23
- import { agentHookStates, repairHook } from "./notify.js";
23
+ import { agentHookStates, repairHook, repairNote } from "./notify.js";
24
24
  import { fence, assess } from "./untrusted.js";
25
25
  import { allowed, closeStartedRun, finishRun, recordRun, startRun } from "./spend.js";
26
- import { handledIn, loadContinued, markContinued, unmarkContinued } from "./continued.js";
26
+ import { handledIn, loadContinued, markContinued, unmarkContinued, wakeAlertedKey } from "./continued.js";
27
27
  import { loadProfile, profileForTeam, reservedDecisionsNote, saveProfile, wakeMode } from "./profile.js";
28
28
  import { clip } from "./brief.js";
29
- import { composeWorker } from "./notice.js";
29
+ import { composeWakeAsk, composeWorker } from "./notice.js";
30
30
  import { diagnoseRun, gitProbe, workNeedsCommands } from "./run-failure.js";
31
31
  import { installedTools, preferredTool } from "./notifier.js";
32
32
  import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
33
33
  import { CONFIG_PATH, loadAll, repoInfo, updateMembershipToken } from "./config.js";
34
34
  import { repoName, routeRequest, safeName } from "./repo-route.js";
35
35
  import { holdsRepo } from "./repo-find.js";
36
+ import { FOLDER_SNAPSHOT_LIMITS, keepDrafts, removeSnapshot, shown, snapshotDrafts, snapshotRoot, sweepSnapshots, takeSnapshot } from "./folder-snapshot.js";
36
37
  import { gitActivity } from "./workdir.js";
37
38
  import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
38
39
  import { proxyEnvFor } from "./network.js";
@@ -273,6 +274,32 @@ export function outsideLinksDiagnosis(member, worktree, scan) {
273
274
  },
274
275
  };
275
276
  }
277
+ const mb = (n) => (n < 1 << 20 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / (1 << 20)).toFixed(1)} MB`);
278
+ export function limitWords(t) {
279
+ return t.what === "bytes" ? `${Math.round(t.limit / (1 << 20))} MB` : `${t.limit.toLocaleString("en-US")} ${t.what === "files" ? "files" : "folders"}`;
280
+ }
281
+ function skippedNote(k) {
282
+ const n = (x, one, many = `${one}s`) => `${x} ${x === 1 ? one : many}`;
283
+ const parts = [
284
+ k.links.length ? n(k.links.length, "link") : "",
285
+ k.secrets.length ? n(k.secrets.length, "secret-looking file or folder", "secret-looking files and folders") : "",
286
+ k.leftOut.length ? n(k.leftOut.length, ".git or node_modules folder", ".git and node_modules folders") : "",
287
+ k.other.length ? n(k.other.length, "special or unreadable file") : "",
288
+ ].filter(Boolean);
289
+ return parts.length ? ` — left out: ${parts.join(", ")}` : "";
290
+ }
291
+ export function folderTooLargeDiagnosis(member, name, t) {
292
+ const cap = `${FOLDER_SNAPSHOT_LIMITS.files.toLocaleString("en-US")} files and ${Math.round(FOLDER_SNAPSHOT_LIMITS.bytes / (1 << 20))} MB`;
293
+ return {
294
+ cause: "folder_too_large",
295
+ says: `the folder ${name} is too big to copy for a run (over ${limitWords(t)}, not counting .git, node_modules, links and secret-looking files) — a run works in a copy so that the folder itself is never changed, so none was started`,
296
+ fix: `Bind a smaller folder of documents to this team instead (pantheon use <team> --here inside it; a run copies up to ${cap}), or take this request in a live session.`,
297
+ peer: {
298
+ summary: `${member}'s worker did not start a run: the folder ${name} is too big to copy for one`,
299
+ body: `${member}'s worker answers from a copy of the folder ${name}, so that the folder itself is never changed — and it is over ${limitWords(t)}, too big to copy for a run. It is still owed and waits for ${member}. Nothing was run.`,
300
+ },
301
+ };
302
+ }
276
303
  export function tamperedDiagnosis(member, e) {
277
304
  return {
278
305
  cause: "worktree_tampered",
@@ -568,16 +595,22 @@ sign in anywhere, enter, create or reveal a real password, token or key, or read
568
595
  `;
569
596
  return "";
570
597
  }
571
- export function workspaceNote(route, attached, sha, outbox) {
598
+ export function workspaceNote(route, attached, sha, copy, readOnly = false, skipped) {
572
599
  const w = route.primary;
573
600
  const lines = ["", "", "WHERE YOU ARE"];
574
601
  if (w.kind === "folder") {
575
- lines.push(`Your human's folder "${w.name}" is where this request belongs. You are in a scratch folder of your own (${outbox ?? "this folder"}): put any file you write for them there — they are drafts your human reviews. Their own folder is open to you to READ, never to change.`);
602
+ lines.push(`Your human's folder "${w.name}" is where this request belongs. You are working in a copy of it made for this run (${copy ?? "this folder"}): read it here.`);
603
+ lines.push(readOnly
604
+ ? `You can write nothing: answer in words, quoting what you found.`
605
+ : `Any file you write or change here is kept as a draft for your human to review; their own folder is never changed.`);
606
+ const out = skipped ? [skipped.secrets.length ? "secret-looking files (.env, keys, credentials)" : "", skipped.links.length ? "links" : "", skipped.leftOut.length ? ".git and node_modules" : ""].filter(Boolean) : [];
607
+ if (out.length)
608
+ lines.push(`Left out of the copy on purpose: ${out.join(", ")}. Do not go looking for them.`);
576
609
  }
577
610
  else {
578
611
  lines.push(`You are working in the ${w.name} repo${w.origin ? ` (${w.origin})` : ""}${sha ? ` at ${sha.slice(0, 7)}` : ""} — chosen because ${route.why}.`);
579
612
  }
580
- const others = attached.filter((a) => a.path !== w.path || w.kind === "folder");
613
+ const others = attached.filter((a) => a.path !== w.path);
581
614
  if (others.length)
582
615
  lines.push(`Also open to READ only, never to edit: ${others.map((a) => `${a.name} (${a.kind === "folder" ? "a folder" : "repo"}) at ${a.path}`).join("; ")}.`);
583
616
  if (route.notHere.length)
@@ -1369,6 +1402,28 @@ export class Worker {
1369
1402
  }
1370
1403
  catch { }
1371
1404
  }
1405
+ for (const e of client.members) {
1406
+ const r = e.recovery;
1407
+ if (!r)
1408
+ continue;
1409
+ const k = `seat-move-said:${e.member}:${r.fp}:${r.requested_at}`;
1410
+ if (this.keyChangeSaid.has(k) || this.contDone.has(k))
1411
+ continue;
1412
+ this.keyChangeSaid.add(k);
1413
+ this.markDone(k);
1414
+ const mine = e.member === this.cfg.member;
1415
+ this.opts.log(`! ${mine ? "someone asked to move YOUR seat" : `${e.member} asked to move their seat`} in ${this.cfg.space} to a new device ("${r.label}", key ${r.fp}) — ${mine ? `not you? pantheon recover cancel ${e.member}` : `check it with them, then pantheon recover confirm ${e.member} (or cancel)`}`);
1416
+ try {
1417
+ this.opts.notify?.({
1418
+ kind: "worker", who: "Your worker", context: this.cfg.space, ttl: 0, sound: true,
1419
+ heading: mine ? "Someone asked to move your seat" : `${e.member} asked to move their seat`,
1420
+ body: mine
1421
+ ? `A device signed in to your email account ("${r.label}", key ${r.fp}) asked for your seat in ${this.cfg.space}. It moves ${new Date(r.due_at).toLocaleString()} unless cancelled. Not you? In a terminal: pantheon recover cancel ${e.member} — and secure your email.`
1422
+ : `${e.member} signed in on a new device ("${r.label}", key ${r.fp}). Check the key with them on a call, then in a terminal: pantheon recover confirm ${e.member}. Not them? pantheon recover cancel ${e.member}.`,
1423
+ });
1424
+ }
1425
+ catch { }
1426
+ }
1372
1427
  }
1373
1428
  askedMembers = new Set();
1374
1429
  async tellWakeNo(client) {
@@ -1602,10 +1657,14 @@ export class Worker {
1602
1657
  async askWake(client, o, member) {
1603
1658
  const teams = [this.cfg.spaceId, this.cfg.space];
1604
1659
  const pol = loadPolicy(this.opts.home);
1605
- if (!mayAskWake(pol, teams, member))
1606
- return;
1607
1660
  const said = client.seenAct(o.ref)?.body ?? o.body;
1608
1661
  const what = clip((said.split("\n").find((l) => l.trim()) ?? said).replace(new RegExp(`^${member.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} asked:\\s*`, "i"), ""), 140);
1662
+ if (!mayAskWake(pol, teams, member)) {
1663
+ const open = Object.entries(pol.wakeAsks ?? {}).find(([, a]) => teams.includes(a.team) && a.member === member);
1664
+ if (open)
1665
+ this.alertWakeAsk(member, what, open[0]);
1666
+ return;
1667
+ }
1609
1668
  try {
1610
1669
  const ask = (again) => client.send({
1611
1670
  act: "ESCALATE", goal: null, ref: o.ref, after_seq: Math.max(client.readSeq, client.seq),
@@ -1626,8 +1685,10 @@ export class Worker {
1626
1685
  let sent = await ask(false);
1627
1686
  let p = loadPolicy(this.opts.home);
1628
1687
  this.loadContinued();
1629
- if (p.wakeAsks?.[sent.id])
1688
+ if (p.wakeAsks?.[sent.id]) {
1689
+ this.alertWakeAsk(member, what, sent.id);
1630
1690
  return;
1691
+ }
1631
1692
  if (this.contDone.has(`wake-asked:${sent.id}`)) {
1632
1693
  sent = await ask(true);
1633
1694
  p = loadPolicy(this.opts.home);
@@ -1642,6 +1703,7 @@ export class Worker {
1642
1703
  savePolicy(p, this.opts.home);
1643
1704
  this.markDone(`wake-asked:${sent.id}`);
1644
1705
  this.opts.log(`* asked you whether ${member} may start work on this machine (their ${owedLabel(o.kind)} ${o.id} waits for the answer)`);
1706
+ this.alertWakeAsk(member, what, sent.id);
1645
1707
  await client.send({
1646
1708
  act: "NOTE", goal: o.goal, ref: o.ref, to: [member], after_seq: Math.max(client.readSeq, client.seq),
1647
1709
  summary: `${this.cfg.member} has been asked to let you start work on their machine — this waits for that`,
@@ -1652,6 +1714,20 @@ export class Worker {
1652
1714
  this.opts.log(`! could not ask whether ${member} may wake this machine: ${e.message}`);
1653
1715
  }
1654
1716
  }
1717
+ alertWakeAsk(member, what, focus) {
1718
+ if (!this.opts.notify)
1719
+ return;
1720
+ const key = wakeAlertedKey(this.cfg.spaceId ?? this.cfg.space, member, focus);
1721
+ this.loadContinued();
1722
+ if (this.contDone.has(key))
1723
+ return;
1724
+ this.markDone(key);
1725
+ try {
1726
+ this.opts.notify(composeWakeAsk({ member, what, space: this.cfg.space, focus, mac: process.platform === "darwin" }));
1727
+ }
1728
+ catch { }
1729
+ this.opts.log(`* alerted you: ${member} is waiting on your approval (once for this question)`);
1730
+ }
1655
1731
  async routeFor(client, o, trigger, wokeBy) {
1656
1732
  const workspaces = this.workspaceList();
1657
1733
  const act = wokeBy ?? await this.fetchActById(o.ref);
@@ -1686,9 +1762,17 @@ export class Worker {
1686
1762
  return null;
1687
1763
  }
1688
1764
  }
1689
- async contextCheckout(w) {
1690
- if (w.kind === "folder")
1691
- return w.path;
1765
+ async contextCheckout(w, goal, snaps) {
1766
+ if (w.kind === "folder") {
1767
+ const dir = join(snapshotRoot(this.opts.scratch, w.path), `${goal}-${Date.now().toString(36)}${randomBytes(2).toString("hex")}`);
1768
+ const taken = await takeSnapshot(w.path, dir, this.opts.folderLimits ? { limits: this.opts.folderLimits } : {});
1769
+ if (!taken.ok) {
1770
+ this.opts.log(` where ${w.name} not copied for this run: ${taken.tooLarge ? `it is over ${limitWords(taken.tooLarge)}` : taken.error}`);
1771
+ return null;
1772
+ }
1773
+ snaps.push({ ...taken.snap, kept: false });
1774
+ return dir;
1775
+ }
1692
1776
  const dir = join(this.opts.scratch, "ctx", createHash("sha256").update(w.fp ?? w.path).digest("hex").slice(0, 10));
1693
1777
  const sha = (await gitOut(w.path, ["rev-parse", "--verify", "HEAD^{commit}"]))?.trim();
1694
1778
  if (!sha || !/^[0-9a-f]{7,64}$/.test(sha))
@@ -1807,24 +1891,46 @@ export class Worker {
1807
1891
  };
1808
1892
  }
1809
1893
  async run() {
1810
- const fromSeq = this.readCursor();
1894
+ const upkeep = !!(this.opts.housekeeping || this.opts.alertsOnly);
1895
+ const fromSeq = upkeep ? 0 : this.readCursor();
1811
1896
  const client = new PantheonClient(this.cfg, (e) => {
1812
1897
  if ((e.t === "welcome" || e.t === "members") && this.client === client)
1813
1898
  queueMicrotask(() => this.carryWakers(client));
1814
1899
  }, {
1815
- role: "worker",
1900
+ role: upkeep ? "cli" : "worker",
1901
+ ...(upkeep ? { upkeep: true } : {}),
1816
1902
  ...(fromSeq ? { fromSeq } : {}),
1817
1903
  persistToken: (space, oldToken, newToken) => { updateMembershipToken(space, oldToken, newToken); trustWrite(CONFIG_PATH); },
1818
1904
  });
1819
1905
  await client.connect();
1820
1906
  this.client = client;
1821
1907
  try {
1822
- await this.serve(client);
1908
+ await (this.opts.alertsOnly ? this.sayKeyChanges(client) : this.opts.housekeeping ? this.keepHouse(client) : this.serve(client));
1823
1909
  }
1824
1910
  finally {
1825
1911
  client.close();
1826
1912
  }
1827
1913
  }
1914
+ async keepHouse(client) {
1915
+ this.dropLedgerGrants();
1916
+ this.opts.log(`worker online as ${this.cfg.member} — upkeep only (self-upgrade, alerts): nothing is bound here, so teammates' requests wait for you`);
1917
+ this.carryWakers(client);
1918
+ while (!this.stopped) {
1919
+ const arrived = await client.next(5000);
1920
+ this.putHookBack();
1921
+ await this.maintain(client, arrived);
1922
+ this.sayKeyChanges(client);
1923
+ }
1924
+ }
1925
+ putHookBack() {
1926
+ const fixed = repairHook(this.opts.home);
1927
+ if (!fixed)
1928
+ return;
1929
+ const note = repairNote(fixed.repairs);
1930
+ this.opts.log(`! the wake-up hook had been removed — put it back${note?.recurring ? ` (${fixed.repairs} times now)` : ""}.`);
1931
+ if (note?.why)
1932
+ this.opts.log(` ${note.why}.`);
1933
+ }
1828
1934
  async serve(client) {
1829
1935
  const agentLabel = this.opts.agentCmd
1830
1936
  ? this.opts.agentCmd
@@ -1841,6 +1947,12 @@ export class Worker {
1841
1947
  this.opts.log(ws.length > 1 ? ` serves ${ws.map((w) => `${w.name}${w.kind === "folder" ? " (folder)" : ""}`).join(", ")} — each request runs in the one it is about` : ` project ${resolve(this.opts.project)}`);
1842
1948
  }
1843
1949
  this.opts.log(` scratch ${resolve(this.opts.scratch)}`);
1950
+ try {
1951
+ const gone = await sweepSnapshots(this.opts.scratch);
1952
+ if (gone.length)
1953
+ this.opts.log(` cleanup removed ${gone.length} old folder cop${gone.length === 1 ? "y or transcript" : "ies and transcripts"} no run finished with`);
1954
+ }
1955
+ catch { }
1844
1956
  this.opts.log(` agent ${agentLabel}`);
1845
1957
  const cmds = commandState({ home: this.opts.home, project: this.opts.project, allowBash: this.opts.allowBash });
1846
1958
  this.opts.log(` policy ${this.opts.maxRunsPerHour} runs/hour, one at a time, commands ${cmds.level === "off" ? "off: edits only (bash gated)" : cmds.summary}`);
@@ -1876,11 +1988,7 @@ export class Worker {
1876
1988
  if (this.opts.continuations !== false)
1877
1989
  this.enqueueContinuations(client, arrived);
1878
1990
  this.writeCursor(client.seq);
1879
- const fixed = repairHook(this.opts.home);
1880
- if (fixed) {
1881
- this.opts.log(`! the wake-up hook had been removed — put it back (${fixed.repairs} time${fixed.repairs === 1 ? "" : "s"} now).`);
1882
- this.opts.log(` something on this machine rewrites ~/.claude/settings.json and drops the hooks key.`);
1883
- }
1991
+ this.putHookBack();
1884
1992
  await this.maintain(client, arrived);
1885
1993
  if (this.running)
1886
1994
  continue;
@@ -2416,6 +2524,17 @@ export class Worker {
2416
2524
  return true;
2417
2525
  }
2418
2526
  async runJob(client, o, persona, policy, makeBrief, requireReply, needsCommands = false, trigger = "", wokeBy = null) {
2527
+ const snaps = [];
2528
+ try {
2529
+ return await this.runJobIn(client, o, persona, policy, makeBrief, requireReply, needsCommands, trigger, wokeBy, snaps);
2530
+ }
2531
+ finally {
2532
+ for (const s of snaps)
2533
+ if (!s.kept)
2534
+ await removeSnapshot(s.dir);
2535
+ }
2536
+ }
2537
+ async runJobIn(client, o, persona, policy, makeBrief, requireReply, needsCommands, trigger, wokeBy, snaps) {
2419
2538
  const goal = o.goal ? (safeSegment(o.goal) ?? `goal-${createHash("sha256").update(o.goal).digest("hex").slice(0, 12)}`) : "adhoc";
2420
2539
  const gRec = this.client?.goals.get(goal);
2421
2540
  let branch = branchName(this.cfg.space, goal, gRec && (!client.signaturesExpected() || trusted(client.goalTrust(gRec).trust)) ? gRec.title : undefined);
@@ -2445,8 +2564,9 @@ export class Worker {
2445
2564
  const place = route.primary;
2446
2565
  const project = place.path;
2447
2566
  const sameAsDefault = resolve(project) === resolve(workspaceOf(this.opts.project).path);
2448
- const scratch = sameAsDefault ? this.opts.scratch : join(this.opts.scratch, `r-${createHash("sha256").update(place.fp ?? project).digest("hex").slice(0, 10)}`);
2449
- worktree = join(scratch, goal);
2567
+ const scratch = place.kind === "folder" ? snapshotRoot(this.opts.scratch, project)
2568
+ : sameAsDefault ? this.opts.scratch : join(this.opts.scratch, `r-${createHash("sha256").update(place.fp ?? project).digest("hex").slice(0, 10)}`);
2569
+ worktree = place.kind === "folder" ? join(scratch, `${goal}-${Date.now().toString(36)}${randomBytes(2).toString("hex")}`) : join(scratch, goal);
2450
2570
  const { repo: _r, repoOrigin: _ro, cwdRepo: _cr, cwdRepoOrigin: _cro, ...bare } = this.cfg;
2451
2571
  const listed = place.kind === "repo" && !!place.fp && client.repos.some((r) => r.fingerprint === place.fp);
2452
2572
  const runCfg = sameAsDefault ? this.cfg : listed ? { ...bare, repo: place.fp, ...(place.origin ? { repoOrigin: place.origin } : {}) } : bare;
@@ -2511,41 +2631,46 @@ export class Worker {
2511
2631
  this.lastFeaturesSaid = said;
2512
2632
  }
2513
2633
  }
2514
- if (place.kind === "folder" && !(features?.addDir && features?.settings)) {
2515
- const says = `The request is for the folder ${place.name}, and only Claude Code can be opened a folder to read without being able to change it — ${agentId ? `this machine's agent for unattended work is ${agentId}` : "no such Claude Code here"}.`;
2516
- const diagnosis = {
2517
- cause: "folder_needs_claude", says,
2518
- fix: "Install and sign in to Claude Code (claude) for woken runs, then pick it in pantheon setup ; until then requests for this folder wait for a live session.",
2519
- peer: {
2520
- summary: `${this.cfg.member}'s machine cannot answer from ${place.name} unattended — this waits for ${this.cfg.member}`,
2521
- body: `${this.cfg.member}'s worker could not open the folder ${place.name} to read safely, so nothing was run. It is still owed and waits for ${this.cfg.member}'s live session.`,
2522
- },
2523
- };
2524
- if (!this.skipLogged.has(`refused:${o.id}`)) {
2525
- this.skipLogged.add(`refused:${o.id}`);
2526
- this.opts.log(`! not starting a run for ${o.kind} ${o.id}: ${says}`);
2527
- }
2528
- return { obligation: o, branch, worktree, ok: false, refused: true, detail: says, diagnosis, ...(choice && this.outagesPassed(choice).length ? { forOutage: true } : {}) };
2529
- }
2634
+ const codexReadOnly = place.kind === "folder" && agentId === "codex" && !this.opts.agentCmd;
2530
2635
  const byCli = !!this.opts.agentCmd && !!this.opts.agentCmdFromCli;
2531
2636
  const noCommandsHere = features?.unknown ? "unknown"
2532
2637
  : features && ((choice?.backend.id === "claude-code" && !this.opts.agentCmd) || byCli) && !claudeSandboxable(features) && !this.unsandboxedOk() ? "unsandboxed"
2533
2638
  : null;
2534
2639
  const wantedBash = policy.allowBash || (persona?.allowBash !== false && !!loadPolicy(this.opts.home).grants.run_commands);
2535
2640
  this.spentOnce = {};
2536
- let allowBash = noCommandsHere ? false : policy.allowBash || this.commandGrant(persona);
2641
+ let allowBash = noCommandsHere || codexReadOnly ? false : policy.allowBash || this.commandGrant(persona);
2537
2642
  const bashDroppedForSandbox = noCommandsHere === "unsandboxed" && wantedBash;
2538
2643
  let baseRef = "", baseSha = "", tipSha = "";
2539
2644
  let fetched = null;
2540
- try {
2541
- ({ branch, worktree, baseRef, baseSha, tipSha, fetched } = await prepareWorktreeAsync(project, scratch, goal, this.opts.log, branch, this.repoBase(place.fp)));
2645
+ let snap = null;
2646
+ if (place.kind === "folder") {
2647
+ const taken = await takeSnapshot(project, worktree, this.opts.folderLimits ? { limits: this.opts.folderLimits } : {});
2648
+ if (!taken.ok && taken.tooLarge) {
2649
+ const diagnosis = folderTooLargeDiagnosis(this.cfg.member, place.name, taken.tooLarge);
2650
+ this.opts.log(`! not starting a run for ${o.kind} ${o.id}: ${diagnosis.says}`);
2651
+ return { obligation: o, branch: "", worktree, ok: false, refused: true, detail: diagnosis.says, diagnosis };
2652
+ }
2653
+ if (!taken.ok) {
2654
+ const detail = `could not copy the folder ${place.name} for the run: ${taken.error}`;
2655
+ this.opts.log(`! ${detail}`);
2656
+ return { obligation: o, branch: "", worktree, ok: false, detail };
2657
+ }
2658
+ snap = { ...taken.snap, kept: false };
2659
+ snaps.push(snap);
2660
+ branch = "";
2661
+ this.opts.log(` copy ${taken.snap.files.size} file${taken.snap.files.size === 1 ? "" : "s"} (${mb(taken.snap.bytes)}) of ${place.name} into ${worktree}${skippedNote(taken.snap.skipped)}`);
2542
2662
  }
2543
- catch (e) {
2544
- if (e instanceof WorktreeTampered)
2545
- throw e;
2546
- const detail = `could not prepare a worktree: ${e.message}`;
2547
- this.opts.log(`! ${detail}`);
2548
- return { obligation: o, branch, worktree, ok: false, detail };
2663
+ else {
2664
+ try {
2665
+ ({ branch, worktree, baseRef, baseSha, tipSha, fetched } = await prepareWorktreeAsync(project, scratch, goal, this.opts.log, branch, this.repoBase(place.fp)));
2666
+ }
2667
+ catch (e) {
2668
+ if (e instanceof WorktreeTampered)
2669
+ throw e;
2670
+ const detail = `could not prepare a worktree: ${e.message}`;
2671
+ this.opts.log(`! ${detail}`);
2672
+ return { obligation: o, branch, worktree, ok: false, detail };
2673
+ }
2549
2674
  }
2550
2675
  const scan = outsideLinks(worktree);
2551
2676
  if (scan.links.length || scan.unreadable) {
@@ -2570,11 +2695,11 @@ export class Worker {
2570
2695
  const holdRepoConfig = !allowBash && !(features?.settingSources);
2571
2696
  const canPush = allowBash && !!branch;
2572
2697
  const attached = [];
2573
- const canAttach = !!features?.addDir && !!features?.settings;
2574
- const toAttach = place.kind === "folder" ? [place, ...route.context] : route.context;
2698
+ const canAttach = (!!features?.addDir && !!features?.settings) || codexReadOnly;
2699
+ const toAttach = route.context;
2575
2700
  if (canAttach) {
2576
2701
  for (const w of toAttach) {
2577
- const p = await this.contextCheckout(w);
2702
+ const p = await this.contextCheckout(w, goal, snaps);
2578
2703
  if (p)
2579
2704
  attached.push({ name: w.name, path: p, kind: w.kind });
2580
2705
  else
@@ -2586,7 +2711,7 @@ export class Worker {
2586
2711
  this.opts.log(` where this agent cannot be held to read-only, so ${toAttach.map((w) => w.name).join(", ")} ${toAttach.length === 1 ? "is" : "are"} named in its brief, not opened to it`);
2587
2712
  }
2588
2713
  const multi = this.workspaceList().length > 1 || place.kind === "folder" || route.notHere.length > 0;
2589
- const whereNote = multi ? workspaceNote(route, attached, tipSha || baseSha, place.kind === "folder" ? worktree : null) : "";
2714
+ const whereNote = multi ? workspaceNote(route, attached, tipSha || baseSha, place.kind === "folder" ? worktree : null, codexReadOnly, snap?.skipped) : "";
2590
2715
  const reserved = reservedDecisionsNote(profileForTeam(this.cfg));
2591
2716
  const brief = makeBrief(branch, canPush) + whereNote + baseNote(baseRef, baseSha, fetched) +
2592
2717
  (allowBash ? (agentId === "claude-code" ? commandNote(worktree, tipSha || baseSha, (m) => this.opts.log(m)) : "") : holdRepoConfig ? heldNote() : "") +
@@ -2597,6 +2722,7 @@ export class Worker {
2597
2722
  let args;
2598
2723
  let env = agentBaseEnv(process.env, proxyEnvFor());
2599
2724
  let sp;
2725
+ let plannedFiles;
2600
2726
  let bashRules = [];
2601
2727
  let fenced = null;
2602
2728
  if (!client.features.includes("delegate") && client.signaturesExpected()) {
@@ -2636,6 +2762,7 @@ export class Worker {
2636
2762
  ...(baseSha ? { base: baseSha } : {}), ...(tipSha ? { commandsRev: tipSha } : {}), goal,
2637
2763
  ...(features ? { claude: { features, pantheonHome: this.stateDir(), home: personHome, userSettings: join(personHome, ".claude", "settings.json"), ...(() => { const c = adoptedCommon(worktree); return c ? { gitCommon: c } : {}; })() } } : {}),
2638
2764
  });
2765
+ plannedFiles = plan.writeFiles;
2639
2766
  for (const n of plan.notes ?? [])
2640
2767
  if (!this.notesSaid.has(n)) {
2641
2768
  this.notesSaid.add(n);
@@ -2757,7 +2884,8 @@ export class Worker {
2757
2884
  this.cleanupStep("sealing the run home", () => this.sealRunHome(runHome));
2758
2885
  this.cleanupStep("checking Pantheon's own files", () => this.restoreHomeAfterRun(homeBefore, o));
2759
2886
  }
2760
- const runLog = join(worktree, ".pantheon-run.log");
2887
+ const logRoot = snap ? scratch : worktree;
2888
+ const runLog = snap ? join(scratch, `${basename(worktree)}.run.log`) : join(worktree, ".pantheon-run.log");
2761
2889
  let extra = "";
2762
2890
  let massDelete = false;
2763
2891
  if (allowBash && baseSha) {
@@ -2773,7 +2901,7 @@ export class Worker {
2773
2901
  this.opts.log(`! ${o.id}: the branch ${branch} deletes ${d.gone} of the ${d.total} files tracked at ${baseSha.slice(0, 7)} — do not merge it without looking`);
2774
2902
  }
2775
2903
  }
2776
- this.writeRunLog(worktree, runLog, `obligation ${o.id}\nbranch ${branch}\n\n${detail}\n${extra}`);
2904
+ this.writeRunLog(logRoot, runLog, `obligation ${o.id}\nbranch ${branch}\n\n${detail}\n${extra}`);
2777
2905
  const exited = /^exit 0/.test(detail);
2778
2906
  const sent = client.mineSince(startedAt, o.goal);
2779
2907
  const repliedWith = sent.find((a) => ["DELIVERED", "ANSWER", "ESCALATE", "ACCEPT", "OBJECT", "PROPOSE", "CONTRACT", "CLOSE", "WITHDRAW"].includes(a.act) || (a.act === "NOTE" && !!a.ref));
@@ -2784,17 +2912,27 @@ export class Worker {
2784
2912
  const ok = exited && replied;
2785
2913
  await this.checkDeliveries(client, sent, { branch, worktree, canPush: canPush && !massDelete, goal: o.goal ?? null });
2786
2914
  this.runProject = null;
2787
- if (place.kind === "folder") {
2788
- let drafts = [];
2915
+ if (snap) {
2916
+ const ours = new Set((plannedFiles ?? []).map((f) => (isAbsolute(f.path) ? relative(worktree, f.path) : f.path).split("\\").join("/")));
2917
+ const pantheons = (rel) => rel.startsWith(".pantheon") || ours.has(rel);
2918
+ let found = { drafts: [], complete: true };
2789
2919
  try {
2790
- drafts = readdirSync(worktree).filter((f) => !f.startsWith(".") && f !== "AGENTS.md" && f !== "CLAUDE.md");
2920
+ found = await snapshotDrafts(snap, pantheons);
2791
2921
  }
2792
- catch { }
2922
+ catch (e) {
2923
+ this.opts.log(`! could not look for drafts in ${worktree}: ${shown(e.message.split("\n")[0] ?? "")}`);
2924
+ }
2925
+ const drafts = found.drafts;
2793
2926
  if (drafts.length) {
2794
- this.opts.log(` drafts ${drafts.length} file${drafts.length === 1 ? "" : "s"} for ${place.name} in ${worktree}: ${drafts.slice(0, 5).join(", ")}${drafts.length > 5 ? ", …" : ""}`);
2927
+ snap.kept = true;
2928
+ const kept = await keepDrafts(worktree, found, { folder: place.path, at: Date.now() });
2929
+ if (kept.error)
2930
+ this.opts.log(`! ${kept.error}`);
2931
+ const names = `${drafts.slice(0, 8).join(", ")}${drafts.length > 8 ? `, and ${drafts.length - 8} more` : ""}`;
2932
+ this.opts.log(` drafts ${drafts.length}${found.complete ? "" : "+"} file${drafts.length === 1 ? "" : "s"} for ${place.name} in ${worktree}: ${names}`);
2795
2933
  if (this.opts.notify) {
2796
2934
  try {
2797
- this.opts.notify({ kind: "worker", who: "Your worker", heading: `Drafts for ${place.name}`, context: this.cfg.space, body: `A request from ${(await this.actOf(client, o.ref))?.from ?? "a teammate"} left ${drafts.length} draft file${drafts.length === 1 ? "" : "s"} for you to review in ${worktree}. Nothing in ${place.path} was changed.`, ttl: 0, sound: false });
2935
+ this.opts.notify({ kind: "worker", who: "Your worker", heading: `Drafts for ${place.name}`, context: this.cfg.space, body: `A request from ${(await this.actOf(client, o.ref))?.from ?? "a teammate"} left ${drafts.length} draft file${drafts.length === 1 ? "" : "s"} for you to review in ${worktree}: ${names}. Nothing in ${place.path} was changed.`, ttl: 0, sound: false });
2798
2936
  }
2799
2937
  catch { }
2800
2938
  }
@@ -2811,10 +2949,10 @@ export class Worker {
2811
2949
  const runKeys = client.features.includes("delegate");
2812
2950
  const sentAnywhere = client.mineSince(startedAt).some((a) => !runKeys || !a.sig || a.sig.role === "run");
2813
2951
  const outage = !ok && !exited && !sentAnywhere && choice && !/\(stopped after \d+s\)/.test(detail.split("\n")[0] ?? "") ? detectOutage(choice.backend.id, detail) : null;
2952
+ const refusedLog = snap ? join(scratch, `${basename(worktree)}.${choice?.backend.id}-refused.run.log`) : join(worktree, `.pantheon-run.${choice?.backend.id}-refused.log`);
2814
2953
  if (outage) {
2815
2954
  this.outages.set(choice.backend.id, outage);
2816
- const refusedLog = join(worktree, `.pantheon-run.${choice.backend.id}-refused.log`);
2817
- this.writeRunLog(worktree, refusedLog, `obligation ${o.id}\nbranch ${branch}\nagent ${choice.backend.label}\n\n${detail}\n`);
2955
+ this.writeRunLog(logRoot, refusedLog, `obligation ${o.id}\nbranch ${branch}\nagent ${choice.backend.label}\n\n${detail}\n`);
2818
2956
  if (Date.now() - startedAt < ONCE_GIVE_BACK_MS)
2819
2957
  this.restoreOnces();
2820
2958
  }
@@ -2822,7 +2960,7 @@ export class Worker {
2822
2960
  this.outageRetries.set(o.id, (this.outageRetries.get(o.id) ?? 0) + 1);
2823
2961
  if (this.outageRetries.size > 200)
2824
2962
  this.outageRetries.delete(this.outageRetries.keys().next().value);
2825
- this.opts.log(`! ${choice.backend.label} refused the run for ${o.id}: ${outage.why} — passed over until about ${whenAgain(outage.until)}; the request goes to the next agent here (transcript: ${join(worktree, `.pantheon-run.${choice.backend.id}-refused.log`)})`);
2963
+ this.opts.log(`! ${choice.backend.label} refused the run for ${o.id}: ${outage.why} — passed over until about ${whenAgain(outage.until)}; the request goes to the next agent here (transcript: ${refusedLog})`);
2826
2964
  return { obligation: o, branch, worktree, ok: false, detail, agentOut: true };
2827
2965
  }
2828
2966
  const quiet = ok && !requireReply && sent.length === 0;
@@ -2976,7 +3114,7 @@ export class Worker {
2976
3114
  });
2977
3115
  markContinued(space, key, this.opts.home);
2978
3116
  this.opts.log(d.cause === "agent_unavailable" ? `* told ${peer ?? "the teammate"} that ${o.id} waits: ${d.says}`
2979
- : `* told ${peer ?? "the teammate"} that ${o.id} waits for a live session: ${d.cause === "repo_not_here" ? d.says : "no agent here can take unattended work"}`);
3117
+ : `* told ${peer ?? "the teammate"} that ${o.id} waits for a live session: ${d.cause === "repo_not_here" || d.cause === "folder_too_large" ? d.says : "no agent here can take unattended work"}`);
2980
3118
  }
2981
3119
  catch (e) {
2982
3120
  this.opts.log(`! could not tell the teammate that ${o.id} is waiting: ${e.message}`);