@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.
- package/README.md +6 -5
- package/dist/accounts.js +10 -0
- package/dist/agents.js +11 -0
- package/dist/board.js +55 -9
- package/dist/claude-confine.js +1 -1
- package/dist/cli.js +556 -89
- package/dist/client.js +8 -4
- package/dist/config.js +18 -4
- package/dist/continued.js +5 -0
- package/dist/desktop-app.js +103 -0
- package/dist/doctor.js +84 -32
- package/dist/folder-guard.js +12 -0
- package/dist/folder-snapshot.js +432 -0
- package/dist/install.js +15 -8
- package/dist/machine.js +84 -4
- package/dist/managed.js +2 -2
- package/dist/mcp.js +5 -3
- package/dist/network.js +156 -0
- package/dist/notice-linux.js +6 -5
- package/dist/notice-windows.js +2 -2
- package/dist/notice.js +13 -0
- package/dist/notifier.js +29 -2
- package/dist/notify.js +11 -0
- package/dist/onboard-defaults.js +37 -1
- package/dist/pins.js +7 -1
- package/dist/plans.js +23 -0
- package/dist/protocol.js +5 -0
- package/dist/quickstart.js +17 -11
- package/dist/refusal.js +4 -4
- package/dist/relay.js +332 -22
- package/dist/repo-find.js +6 -3
- package/dist/roster-file.js +50 -5
- package/dist/tray.js +12 -1
- package/dist/uninstall.js +38 -3
- package/dist/uri-handler.js +3 -2
- package/dist/verify-act.js +17 -0
- package/dist/worker-fleet.js +140 -5
- package/dist/worker.js +201 -63
- package/installer/install.ps1 +251 -15
- package/installer/install.sh +147 -18
- package/package.json +1 -1
- package/helper/macos/prebuilt/manifest.json +0 -29
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- 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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
2449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2541
|
-
|
|
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
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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 =
|
|
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
|
|
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(
|
|
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 (
|
|
2788
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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}`);
|