@join-pantheon/cli 0.29.1 → 0.30.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 (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -120
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +494 -32
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +82 -14
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1143 -180
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +84 -34
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
package/dist/worker.js CHANGED
@@ -1,26 +1,31 @@
1
1
  import { spawn as procSpawn } from "node:child_process";
2
2
  import { execFileSync } from "node:child_process";
3
- import { chmodSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from "node:fs";
3
+ import { chmodSync, mkdirSync, writeFileSync, existsSync, readFileSync, realpathSync, rmSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { isAbsolute, join, relative, resolve } from "node:path";
5
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
+ import { detectClaudeFeatures, describeFeatures, ensureRunTmp, NO_CLAUDE_FEATURES, runTmpDir } from "./claude-confine.js";
7
+ import { snapshotHome, restoreHome, describeChange, trustWrite } from "./home-guard.js";
6
8
  import { PantheonClient } from "./client.js";
7
9
  import { createHash } from "node:crypto";
8
- import { headlessSpawn, materializeSpawnFiles, chooseAgent, describeChoice, unattendedRefusal, quarantineRepoAgentConfig, AGENT_EDIT_SUFFIX, REPO_AGENT_CONFIG, } from "./agents.js";
10
+ import { headlessSpawn, materializeSpawnFiles, chooseAgent, describeChoice, unattendedRefusal, quarantineRepoAgentConfig, AGENT_EDIT_SUFFIX, REPO_AGENT_CONFIG, COMMAND_GRANT_SCOPE, repoCommands, REPO_COMMANDS_FILE, claudeCommandRules, } from "./agents.js";
9
11
  import { agentSpawn, CmdLineUnsafe } from "./which.js";
10
12
  import { VERSION } from "./version.js";
11
- import { applyAnswer, loadPolicy, mayAsk, needsSelfUpgrade, recordAsk, savePolicy, useGrant, } from "./worker-policy.js";
13
+ import { applyAnswer, CAPABILITY_ARGS, CARRY_REASON, carryOverWakers, dropLedgerGrants, isApprovedWaker, OLD_RELAY_WAKE, relayKnowsAdmitters, loadPolicy, mayAsk, needsSelfUpgrade, parseGrantAnswer, recordAsk, savePolicy, useGrant, WOKEN_RUN_ENV, } from "./worker-policy.js";
12
14
  import { agentHookStates, repairHook } from "./notify.js";
13
15
  import { fence, assess } from "./untrusted.js";
14
16
  import { allowed, recordRun } from "./spend.js";
15
17
  import { loadContinued, markContinued } from "./continued.js";
16
- import { loadProfile, reservedDecisionsNote } from "./profile.js";
18
+ import { loadProfile, reservedDecisionsNote, saveProfile, wakeMode } from "./profile.js";
17
19
  import { clip } from "./brief.js";
18
20
  import { composeWorker } from "./notice.js";
19
21
  import { diagnoseRun, gitProbe, workNeedsCommands } from "./run-failure.js";
20
22
  import { installedTools, preferredTool } from "./notifier.js";
21
23
  import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
24
+ import { CONFIG_PATH, loadAll, updateMembershipToken } from "./config.js";
22
25
  import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
23
26
  import { proxyEnvFor } from "./network.js";
27
+ import { signedRead } from "./reads.js";
28
+ import { commandState } from "./command-state.js";
24
29
  export function heldNote() {
25
30
  return `
26
31
 
@@ -50,6 +55,14 @@ function configuredSshCommand(cwd) {
50
55
  sshCommandSeen.set(cwd, set);
51
56
  return set;
52
57
  }
58
+ export const AGENT_ENV_SECRETS = ["PANTHEON_TOKEN", "PANTHEON_PRIVKEY"];
59
+ export function agentBaseEnv(base, proxy) {
60
+ const env = { ...base, ...proxy };
61
+ for (const k of AGENT_ENV_SECRETS)
62
+ delete env[k];
63
+ env[WOKEN_RUN_ENV] = "1";
64
+ return env;
65
+ }
53
66
  const GIT_TIMEOUT_MS = 60_000;
54
67
  export const WORKTREE_ADD_TIMEOUT_MS = 15 * 60_000;
55
68
  export const FETCH_TIMEOUT_MS = 90_000;
@@ -171,10 +184,10 @@ function planWorktree(project, scratch, goal, log, branch, base, fetch) {
171
184
  const worktree = join(scratch, goal);
172
185
  if (!isRepo(project)) {
173
186
  if (existsSync(join(worktree, ".git")))
174
- return { done: { branch, worktree, baseRef: "", baseSha: "", fetched: null } };
187
+ return { done: { branch, worktree, baseRef: "", baseSha: "", tipSha: "", fetched: null } };
175
188
  mkdirSync(worktree, { recursive: true });
176
189
  log(` scratch ${worktree} — ${project} is not a git repo, so there is no worktree and nothing to deliver`);
177
- return { done: { branch: "", worktree, baseRef: "", baseSha: "", fetched: null } };
190
+ return { done: { branch: "", worktree, baseRef: "", baseSha: "", tipSha: "", fetched: null } };
178
191
  }
179
192
  const reuse = settleWorktree(project, worktree, log);
180
193
  if (!reuse)
@@ -205,11 +218,21 @@ function planWorktree(project, scratch, goal, log, branch, base, fetch) {
205
218
  catch (e) {
206
219
  throw new Error(`${e.message.split("\n")[0]}${oldGitNote()}`);
207
220
  }
208
- const result = { branch, worktree, baseRef, baseSha, fetched };
221
+ const tipSha = baseSha;
222
+ if (reuse) {
223
+ try {
224
+ baseSha = git(worktree, "merge-base", "HEAD", tipSha) || tipSha;
225
+ }
226
+ catch { }
227
+ }
228
+ const result = { branch, worktree, baseRef, baseSha, tipSha, fetched };
209
229
  if (reuse)
210
230
  return { done: result };
211
231
  const has = git(project, "branch", "--list", "--", branch).length > 0;
212
- return { add: has ? ["worktree", "add", "--", worktree, branch] : ["worktree", "add", "-b", branch, "--", worktree, baseRef], result };
232
+ return { add: worktreeAddArgs(has ? ["worktree", "add", "--", worktree, branch] : ["worktree", "add", "-b", branch, "--", worktree, baseRef]), result };
233
+ }
234
+ export function worktreeAddArgs(add, os = process.platform) {
235
+ return os === "win32" ? ["-c", "core.longpaths=true", ...add] : add;
213
236
  }
214
237
  function finishWorktree(r, log) {
215
238
  const mark = checkoutMark(r.worktree);
@@ -237,10 +260,11 @@ export function prepareWorktree(project, scratch, goal, log, branch = `pantheon/
237
260
  return finishWorktree(p.result, log);
238
261
  }
239
262
  export async function prepareWorktreeAsync(project, scratch, goal, log, branch = `pantheon/${goal}`, base = null, opts = {}) {
240
- const needsFetch = isRepo(project) && safeSegment(goal) !== null && !settleWorktree(project, join(scratch, goal), log);
241
- const fetched = needsFetch ? await fetchOrigin(project) : undefined;
263
+ const inRepo = isRepo(project) && safeSegment(goal) !== null;
264
+ const reusing = inRepo && settleWorktree(project, join(scratch, goal), log);
265
+ const fetched = inRepo ? await fetchOrigin(project) : undefined;
242
266
  if (fetched && !fetched.ok)
243
- log(` fetch origin not fetched (${fetched.why}) — cutting from the refs this checkout already has`);
267
+ log(` fetch origin not fetched (${fetched.why}) — ${reusing ? "the base branch is read as this checkout last saw it" : "cutting from the refs this checkout already has"}`);
244
268
  const p = planWorktree(project, scratch, goal, log, branch, base, fetched ?? { ok: false, why: "not fetched" });
245
269
  if ("done" in p)
246
270
  return p.done;
@@ -269,6 +293,14 @@ export function branchName(space, goalId, title) {
269
293
  const slug = (title ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
270
294
  return `pantheon/${space}/${slug ? `${slug}-${goalId.slice(-4)}` : goalId}`;
271
295
  }
296
+ export function commandNote(worktree, rev, log = () => { }) {
297
+ const repo = repoCommands(worktree, rev);
298
+ for (const r of repo.refused)
299
+ log(` commands: ignored "${r.line}" in ${REPO_COMMANDS_FILE} — ${r.why}`);
300
+ return `\n\nCOMMANDS FOR THIS RUN: git by subcommand (status, diff, log, add, commit, push, fetch, checkout, …), the repo's test/build/run scripts through npm/pnpm/yarn/bun, make, cargo, go, pytest, mvn/gradle and dotnet` +
301
+ (repo.ok.length ? `, and this repo's own list (${REPO_COMMANDS_FILE}): ${repo.ok.join(", ")}` : "") +
302
+ `. Anything else is refused — do not try to get around it. If a command you needed was refused, say so in your reply, and end your final message with one line per command: BLOCKED-COMMAND: <the command>`;
303
+ }
272
304
  export function baseNote(baseRef, baseSha, fetched = null) {
273
305
  if (!baseSha)
274
306
  return "";
@@ -284,20 +316,52 @@ YOUR BRANCH STARTS AT ${baseSha.slice(0, 12)} (${baseRef}, ${fresh}).
284
316
  another branch's unmerged work. When you DELIVER, name this base too (pantheon_send base=${baseSha.slice(0, 12)})
285
317
  so your peer can check that nothing else came with it.`;
286
318
  }
287
- const RUN_DROPPINGS = [".pantheon-task.md", ".pantheon-run.log", ".pantheon-mcp.json", ".pantheon/", `*${AGENT_EDIT_SUFFIX}`, `*${AGENT_EDIT_SUFFIX}-*`];
319
+ const RUN_DROPPINGS = [".pantheon-task.md", ".pantheon-run.log", ".pantheon-mcp.json", ".pantheon/*", "!.pantheon/commands", "!.pantheon/personas/",
320
+ `*${AGENT_EDIT_SUFFIX}`, `*${AGENT_EDIT_SUFFIX}-*`];
321
+ const RUN_DROPPINGS_OLD = [".pantheon/"];
288
322
  export function excludeRunFiles(worktree) {
289
323
  try {
290
324
  const common = git(worktree, "rev-parse", "--git-common-dir");
291
325
  const file = resolve(worktree, common, "info", "exclude");
292
- const cur = existsSync(file) ? readFileSync(file, "utf8") : "";
326
+ const had = existsSync(file) ? readFileSync(file, "utf8") : "";
327
+ const cur = had.split("\n").filter((l) => !RUN_DROPPINGS_OLD.includes(l)).join("\n");
293
328
  const missing = RUN_DROPPINGS.filter((l) => !cur.split("\n").includes(l));
294
- if (!missing.length)
329
+ if (!missing.length && cur === had)
295
330
  return;
296
331
  mkdirSync(resolve(worktree, common, "info"), { recursive: true });
297
- writeFileSync(file, `${cur.replace(/\n?$/, "\n")}${missing.join("\n")}\n`);
332
+ writeFileSync(file, missing.length ? `${cur.replace(/\n?$/, "\n")}${missing.join("\n")}\n` : cur);
298
333
  }
299
334
  catch { }
300
335
  }
336
+ export function gitOut(cwd, args, timeoutMs = GIT_TIMEOUT_MS) {
337
+ return new Promise((res) => {
338
+ let child;
339
+ try {
340
+ child = procSpawn("git", args, { cwd, env: gitEnv(process.env, proxyEnvFor(), cwd), stdio: ["ignore", "pipe", "ignore"] });
341
+ }
342
+ catch {
343
+ res(null);
344
+ return;
345
+ }
346
+ let out = "";
347
+ child.stdout?.on("data", (b) => { if (out.length < 100_000)
348
+ out += b.toString(); });
349
+ const timer = setTimeout(() => { child.kill("SIGKILL"); res(null); }, timeoutMs);
350
+ child.on("error", () => { clearTimeout(timer); res(null); });
351
+ child.on("close", (code) => { clearTimeout(timer); res(code === 0 ? out.trim() : null); });
352
+ });
353
+ }
354
+ export async function onRemote(cwd, branch, commit) {
355
+ if (!validBase(branch))
356
+ return false;
357
+ const line = await gitOut(cwd, ["ls-remote", "origin", `refs/heads/${branch}`], FETCH_TIMEOUT_MS);
358
+ const tip = line?.split(/\s+/)[0] ?? "";
359
+ if (!tip)
360
+ return false;
361
+ if (tip.startsWith(commit.toLowerCase()) || commit.toLowerCase().startsWith(tip))
362
+ return true;
363
+ return (await gitOut(cwd, ["merge-base", "--is-ancestor", commit, tip])) !== null;
364
+ }
301
365
  export const MASS_DELETE_SHARE = 0.5;
302
366
  export const asksForMassDelete = (text) => /\b(?:delete|remove|wipe|drop|rm)\b[^\n]{0,40}\b(?:all|every|entire|whole|most)\b/i.test(text);
303
367
  const GIT_HOOK_NAMES = [
@@ -335,12 +399,16 @@ if [ -n "$PANTHEON_ORIG_HOOKS" ] && [ -x "$PANTHEON_ORIG_HOOKS/pre-commit" ]; th
335
399
  exit 0
336
400
  `;
337
401
  const PRE_PUSH = `#!/bin/sh
338
- # Pantheon: a woken run may not push the deletion of most of the repo.
402
+ # Pantheon: a woken run pushes its own branch and nothing else — never another
403
+ # branch (HEAD:main), never a deletion — and not the deletion of most of the repo.
339
404
  ${MASS_CHECK}
340
405
  input=$(cat)
341
406
  printf '%s\\n' "$input" | while read -r lref lsha rref rsha; do
342
407
  [ -z "$lsha" ] && continue
343
- case "$lsha" in 0000000*) continue;; esac
408
+ if [ -z "$PANTHEON_BRANCH" ] || [ "$rref" != "refs/heads/$PANTHEON_BRANCH" ]; then
409
+ echo "pantheon: a woken run may push only its own branch ($PANTHEON_BRANCH), not $rref" >&2; exit 1
410
+ fi
411
+ case "$lsha" in 0000000*) echo "pantheon: a woken run may not delete a branch on the remote" >&2; exit 1;; esac
344
412
  pantheon_mass_check "$lsha" push pushed || exit 1
345
413
  done || exit 1
346
414
  if [ -n "$PANTHEON_ORIG_HOOKS" ] && [ -x "$PANTHEON_ORIG_HOOKS/pre-push" ]; then printf '%s\\n' "$input" | "$PANTHEON_ORIG_HOOKS/pre-push" "$@"; exit $?; fi
@@ -390,7 +458,18 @@ function owedLine(kind) {
390
458
  default: return `${printable(kind)} — the message above`;
391
459
  }
392
460
  }
393
- export function buildBrief(o, trigger, branch, persona) {
461
+ export function deliveryStep(branch, canPush, ref) {
462
+ const r = ref ? ` ref ${printable(ref)},` : "";
463
+ return canPush
464
+ ? `If you wrote code: commit it on this branch, push it (git push origin ${branch} — this branch, never any other), then
465
+ pantheon_send DELIVERED with${r} branch ${branch} and the commit sha, so your human and your peer can review it.
466
+ If the push is refused, send the DELIVERED anyway: the worker pushes this branch after you finish, and corrects
467
+ the DELIVERED on the ledger if it cannot.`
468
+ : `This run cannot commit or push, so never send DELIVERED — your peer could not fetch it. If you wrote code,
469
+ pantheon_send NOTE with${r || " a ref,"} saying what you changed and that it waits on branch ${branch} on this machine
470
+ for your human to review and push. If the request asked for code, that NOTE is your reply.`;
471
+ }
472
+ export function buildBrief(o, trigger, branch, persona, canPush = false) {
394
473
  const placed = branch
395
474
  ? `in an isolated git worktree on branch ${branch}`
396
475
  : `in a scratch directory (your human's workspace is not a git repo, so there is no branch and nothing can be DELIVERED from here)`;
@@ -414,12 +493,11 @@ DO THIS
414
493
  1. pantheon_claim with obligation id ${printable(o.id)}. If the claim is REFUSED, another session has it —
415
494
  stop immediately and say nothing.
416
495
  2. pantheon_status and, if there is a goal, pantheon_history to understand the context.
417
- 3. Do the work here. You may read and edit files in this worktree. Do NOT merge, do NOT push,
496
+ 3. Do the work here. You may read and edit files in this worktree. Do NOT merge, ${branch && canPush ? `do NOT push any branch but ${branch}` : "do NOT push"},
418
497
  do NOT touch any checkout other than this one.
419
498
  4. Reply with pantheon_send — ANSWER an ASK, ACCEPT or OBJECT a PROPOSE, and so on.
420
499
  5. ${branch
421
- ? `If you wrote code, pantheon_send DELIVERED naming this branch (${branch}) so your human can
422
- review it when they are back.`
500
+ ? deliveryStep(branch, canPush, o.ref)
423
501
  : `Do not promise code. There is no repo here, so nothing you write can be fetched by your
424
502
  peer — say what you would do and leave the writing to your human.`}
425
503
  6. If this genuinely needs your human — money, scope, anything irreversible, or one of their
@@ -428,7 +506,7 @@ DO THIS
428
506
  There is no human in this session. Do not ask for approval; you will not get an answer.
429
507
  Keep going until you have replied, then finish.`;
430
508
  }
431
- export function buildContinuationBrief(act, trigger, branch, persona) {
509
+ export function buildContinuationBrief(act, trigger, branch, persona, canPush = false) {
432
510
  const placed = branch
433
511
  ? `in an isolated git worktree on branch ${branch}`
434
512
  : `in a scratch directory (your human's workspace is not a git repo, so nothing can be DELIVERED from here)`;
@@ -464,7 +542,8 @@ DO THIS
464
542
  already responded after this ${kind}, STOP and send nothing; it was handled live.
465
543
  2. ${what}
466
544
  3. Prefer finishing over widening: do not open a new question unless the goal cannot advance
467
- without one. Every act you send may wake an agent on another machine.
545
+ without one. Every act you send may wake an agent on another machine.${branch ? `
546
+ ${deliveryStep(branch, canPush, null)}` : ""}
468
547
  4. If this genuinely needs your human — money, scope, anything irreversible, or one of their
469
548
  stated red lines — pantheon_send ESCALATE and stop.
470
549
 
@@ -473,6 +552,98 @@ Act, reply if a reply is owed, then finish.`;
473
552
  }
474
553
  export const TASK_POINTER = "Your task for this run is in the file .pantheon-task.md in the current directory. Read that file first and follow it: it is the whole brief.";
475
554
  let fleetLock = null;
555
+ export const RUN_TIMEOUT_MS = 20 * 60_000;
556
+ export function killRunTree(pid, os = process.platform) {
557
+ if (!Number.isInteger(pid) || pid <= 1)
558
+ return;
559
+ if (os === "win32") {
560
+ try {
561
+ execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", timeout: 10_000, windowsHide: true });
562
+ }
563
+ catch { }
564
+ return;
565
+ }
566
+ try {
567
+ process.kill(-pid, "SIGKILL");
568
+ }
569
+ catch { }
570
+ }
571
+ export const killTreeOnExit = (os = process.platform) => os !== "win32";
572
+ const liveRuns = new Set();
573
+ let reaperInstalled = false;
574
+ function installRunReaper() {
575
+ if (reaperInstalled)
576
+ return;
577
+ reaperInstalled = true;
578
+ const reap = () => { for (const pid of liveRuns)
579
+ killRunTree(pid); };
580
+ process.on("exit", reap);
581
+ for (const sig of ["SIGTERM", "SIGHUP"]) {
582
+ process.on(sig, () => { reap(); process.exit(sig === "SIGTERM" ? 143 : 129); });
583
+ }
584
+ }
585
+ export const RUN_RECORD = "worker-run.json";
586
+ function writeRunRecord(dir, r) {
587
+ try {
588
+ mkdirSync(dir, { recursive: true });
589
+ writeFileSync(join(dir, RUN_RECORD), JSON.stringify(r) + "\n", { mode: 0o600 });
590
+ }
591
+ catch { }
592
+ }
593
+ function clearRunRecord(dir, pid) {
594
+ try {
595
+ const f = join(dir, RUN_RECORD);
596
+ const r = JSON.parse(readFileSync(f, "utf8"));
597
+ if (r.pid === pid)
598
+ rmSync(f, { force: true });
599
+ }
600
+ catch { }
601
+ }
602
+ export function killRecordedRun(dir, now = Date.now()) {
603
+ const f = join(dir, RUN_RECORD);
604
+ let r;
605
+ try {
606
+ r = JSON.parse(readFileSync(f, "utf8"));
607
+ }
608
+ catch {
609
+ return null;
610
+ }
611
+ rmSync(f, { force: true });
612
+ if (!r || !Number.isInteger(r.pid) || typeof r.at !== "number" || !Number.isFinite(r.at) || r.at > now + 60_000)
613
+ return null;
614
+ if (now - r.at > RUN_TIMEOUT_MS + 5 * 60_000)
615
+ return null;
616
+ if (!recordedRunAlive(r.pid))
617
+ return null;
618
+ killRunTree(r.pid);
619
+ return `stopped the woken run in progress (pid ${r.pid}, ${r.space}) and everything it started`;
620
+ }
621
+ export function recordedRunAlive(pid, os = process.platform, probe = (p) => { process.kill(p, 0); }) {
622
+ try {
623
+ probe(os === "win32" ? pid : -pid);
624
+ return true;
625
+ }
626
+ catch {
627
+ return false;
628
+ }
629
+ }
630
+ export function runInFlight(dir, now = Date.now()) {
631
+ let r;
632
+ try {
633
+ r = JSON.parse(readFileSync(join(dir, RUN_RECORD), "utf8"));
634
+ }
635
+ catch {
636
+ return null;
637
+ }
638
+ if (!r || !Number.isInteger(r.pid) || typeof r.at !== "number" || !Number.isFinite(r.at))
639
+ return null;
640
+ if (now - r.at > RUN_TIMEOUT_MS + 5 * 60_000)
641
+ return null;
642
+ if (!recordedRunAlive(r.pid))
643
+ return null;
644
+ return { pid: r.pid, at: r.at };
645
+ }
646
+ export const EXIT_GRACE_MS = 3_000;
476
647
  const CONTINUATION_HORIZON_MS = 24 * 60 * 60_000;
477
648
  export function answerWindow(askSeq, hint, fullScanDue) {
478
649
  if (askSeq !== null)
@@ -531,6 +702,13 @@ export class Worker {
531
702
  for (const id of loadContinued(this.spaceKey(), this.opts.home))
532
703
  this.contDone.add(id);
533
704
  }
705
+ failuresOf(id) {
706
+ const mem = this.failedRuns.get(id);
707
+ if (mem !== undefined)
708
+ return mem;
709
+ this.loadContinued();
710
+ return this.contDone.has(`parked:${id}`) ? 2 : this.contDone.has(`failed1:${id}`) ? 1 : 0;
711
+ }
534
712
  markDone(id) {
535
713
  this.contDone.add(id);
536
714
  markContinued(this.spaceKey(), id, this.opts.home);
@@ -578,17 +756,33 @@ export class Worker {
578
756
  get jobs() {
579
757
  return this.done;
580
758
  }
581
- runHomeDir;
582
- runHome() {
583
- if (this.runHomeDir)
584
- return this.runHomeDir;
759
+ runHomePath() {
585
760
  const base = this.opts.home ?? process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
586
- const dir = join(base, "run-homes", encodeURIComponent(this.cfg.spaceId ?? this.cfg.space));
761
+ return join(base, "run-homes", encodeURIComponent(this.cfg.spaceId ?? this.cfg.space));
762
+ }
763
+ openRunHome() {
764
+ const dir = this.runHomePath();
587
765
  mkdirSync(dir, { recursive: true, mode: 0o700 });
588
- writeFileSync(join(dir, "config.json"), JSON.stringify(this.cfg, null, 2) + "\n", { mode: 0o600 });
589
- this.runHomeDir = dir;
766
+ const file = join(dir, "config.json");
767
+ writeFileSync(file, JSON.stringify(this.cfg, null, 2) + "\n", { mode: 0o600 });
768
+ try {
769
+ chmodSync(file, 0o600);
770
+ }
771
+ catch { }
590
772
  return dir;
591
773
  }
774
+ sealRunHome(dir) {
775
+ const { token: _t, privateKey: _k, pendingAuth: _p, ...stub } = this.cfg;
776
+ try {
777
+ writeFileSync(join(dir, "config.json"), JSON.stringify({ ...stub, sealed: "the token and key are written here only while a woken run holds them" }, null, 2) + "\n", { mode: 0o600 });
778
+ }
779
+ catch {
780
+ try {
781
+ rmSync(join(dir, "config.json"), { force: true });
782
+ }
783
+ catch { }
784
+ }
785
+ }
592
786
  selectPersona(client, o) {
593
787
  const personas = this.personas();
594
788
  if (!personas.length && !this.opts.persona) {
@@ -617,7 +811,13 @@ export class Worker {
617
811
  };
618
812
  }
619
813
  async run() {
620
- const client = new PantheonClient(this.cfg, undefined, { role: "worker" });
814
+ const client = new PantheonClient(this.cfg, (e) => {
815
+ if ((e.t === "welcome" || e.t === "members") && this.client === client)
816
+ queueMicrotask(() => this.carryWakers(client));
817
+ }, {
818
+ role: "worker",
819
+ persistToken: (space, oldToken, newToken) => { updateMembershipToken(space, oldToken, newToken); trustWrite(CONFIG_PATH); },
820
+ });
621
821
  await client.connect();
622
822
  this.client = client;
623
823
  const agentLabel = this.opts.agentCmd
@@ -628,11 +828,14 @@ export class Worker {
628
828
  catch {
629
829
  return "auto";
630
830
  } })();
831
+ this.dropLedgerGrants();
631
832
  this.opts.log(`worker online as ${this.cfg.member} (session ${client.session})`);
632
833
  this.opts.log(` project ${resolve(this.opts.project)}`);
633
834
  this.opts.log(` scratch ${resolve(this.opts.scratch)}`);
634
835
  this.opts.log(` agent ${agentLabel}`);
635
- this.opts.log(` policy ${this.opts.maxRunsPerHour} runs/hour, one at a time, ${this.opts.allowBash ? "bash allowed" : "edits only (bash gated)"}`);
836
+ const cmds = commandState({ home: this.opts.home, project: this.opts.project, allowBash: this.opts.allowBash });
837
+ this.opts.log(` policy ${this.opts.maxRunsPerHour} runs/hour, one at a time, commands ${cmds.level === "off" ? "off: edits only (bash gated)" : cmds.summary}`);
838
+ this.carryWakers(client);
636
839
  const personas = this.personas();
637
840
  if (this.opts.persona && !personaByName(personas, this.opts.persona)) {
638
841
  client.close();
@@ -652,6 +855,12 @@ export class Worker {
652
855
  this.writeState("watching", "waiting for work");
653
856
  while (!this.stopped) {
654
857
  const arrived = await client.next(5000);
858
+ if (client.refused && !this.refusedSaid) {
859
+ this.refusedSaid = true;
860
+ this.opts.log(`! the relay refused this machine (${client.refused}): ${String(client.lastError ?? "").replace(/^[A-Z_]+: /, "")} — it keeps trying, every ${Math.round(PantheonClient.REFUSED_RETRY_MS / 60_000)} minutes after the first ${Math.round(PantheonClient.REFUSED_GRACE_MS / 60_000)}`);
861
+ }
862
+ else if (!client.refused)
863
+ this.refusedSaid = false;
655
864
  if (this.opts.continuations !== false)
656
865
  this.enqueueContinuations(client, arrived);
657
866
  const fixed = repairHook(this.opts.home);
@@ -679,17 +888,25 @@ export class Worker {
679
888
  .filter((o) => o.kind !== "HUMAN" && !o.claimed_by)
680
889
  .filter((o) => Date.now() - o.created_at >= grace)
681
890
  .filter((o) => {
682
- if ((this.failedRuns.get(o.id) ?? 0) < 2)
891
+ if (this.failuresOf(o.id) < 2)
683
892
  return true;
684
893
  if (!this.skipLogged.has(`parked:${o.id}`)) {
685
894
  this.skipLogged.add(`parked:${o.id}`);
686
- this.opts.log(`- parking ${o.kind} ${o.id}: 2 runs failed on it — it stays owed for a live session or your human (see the last .pantheon-run.log)`);
895
+ this.opts.log(`- parking ${o.kind} ${o.id}: its run failed in a way another run would repeat — it stays owed for a live session or your human (see the last .pantheon-run.log)`);
687
896
  }
688
897
  return false;
689
898
  });
690
899
  let candidate;
691
900
  let persona;
692
901
  for (const o of aged) {
902
+ const refusedWake = await this.wakeRefusal(client, o.ref);
903
+ if (refusedWake) {
904
+ if (!this.skipLogged.has(`wake:${o.id}`)) {
905
+ this.skipLogged.add(`wake:${o.id}`);
906
+ this.opts.log(`- not taking ${o.kind} ${o.id}: ${refusedWake.why} — it waits for a live session`);
907
+ }
908
+ continue;
909
+ }
693
910
  const sel = this.selectPersona(client, o);
694
911
  if (sel.skip) {
695
912
  if (!this.skipLogged.has(o.id)) {
@@ -705,7 +922,19 @@ export class Worker {
705
922
  let continuation;
706
923
  if (!candidate) {
707
924
  const pick = this.nextContinuation(client);
708
- if (pick)
925
+ const refusedWake = pick ? await this.wakeRefusal(client, pick.continuation.id, { from: pick.continuation.from, role: pick.continuation.role }) : null;
926
+ if (pick && refusedWake?.transient) {
927
+ this.requeueContinuation(pick.continuation, 30_000);
928
+ if (!this.skipLogged.has(`wait:${pick.continuation.id}`)) {
929
+ this.skipLogged.add(`wait:${pick.continuation.id}`);
930
+ this.opts.log(`- holding ${pick.continuation.act} ${pick.continuation.id}: ${refusedWake.why} — asking again shortly`);
931
+ }
932
+ }
933
+ else if (pick && refusedWake) {
934
+ this.markDone(pick.continuation.id);
935
+ this.opts.log(`- not continuing from ${pick.continuation.act} ${pick.continuation.id}: ${refusedWake.why}`);
936
+ }
937
+ else if (pick)
709
938
  ({ continuation, persona } = pick);
710
939
  }
711
940
  if (!candidate && !continuation) {
@@ -800,13 +1029,15 @@ export class Worker {
800
1029
  await this.reportRefused(client, job.obligation, job.diagnosis, !!candidate);
801
1030
  }
802
1031
  else if (candidate && !job.ok) {
803
- const failures = (this.failedRuns.get(candidate.id) ?? 0) + 1;
1032
+ const attempts = this.failuresOf(candidate.id) + 1;
1033
+ const failures = job.diagnosis?.cause === "no_commands" ? 2 : attempts;
804
1034
  this.failedRuns.set(candidate.id, failures);
1035
+ this.markDone(failures >= 2 ? `parked:${candidate.id}` : `failed1:${candidate.id}`);
805
1036
  if (this.failedRuns.size > 200) {
806
1037
  this.failedRuns.delete(this.failedRuns.keys().next().value);
807
1038
  }
808
1039
  if (failures >= 2 && job.diagnosis)
809
- await this.reportStuck(client, candidate, job.diagnosis);
1040
+ await this.reportStuck(client, candidate, job.diagnosis, attempts);
810
1041
  }
811
1042
  this.running = false;
812
1043
  if (fleetLock === this)
@@ -819,6 +1050,23 @@ export class Worker {
819
1050
  }
820
1051
  stop() {
821
1052
  this.stopped = true;
1053
+ for (const pid of this.runPids)
1054
+ killRunTree(pid);
1055
+ }
1056
+ runPids = new Set();
1057
+ trackRun(pid) {
1058
+ this.runPids.add(pid);
1059
+ liveRuns.add(pid);
1060
+ installRunReaper();
1061
+ writeRunRecord(this.stateDir(), { pid, member: this.cfg.member, space: this.cfg.space, at: Date.now() });
1062
+ }
1063
+ untrackRun(pid) {
1064
+ this.runPids.delete(pid);
1065
+ liveRuns.delete(pid);
1066
+ clearRunRecord(this.stateDir(), pid);
1067
+ }
1068
+ stateDir() {
1069
+ return this.opts.home ?? process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
822
1070
  }
823
1071
  hooksInstalledHere() {
824
1072
  try {
@@ -836,7 +1084,7 @@ export class Worker {
836
1084
  ?? `(the triggering act ${printable(o.ref)} is not in this session's buffer; the relay's one-line summary of it follows)\n${o.body}`;
837
1085
  const act = client.seenAct(o.ref);
838
1086
  const needs = workNeedsCommands(o.kind, trigger, o.kind === "REVISE" && act?.ref ? client.seenAct(act.ref)?.act : act?.act);
839
- return this.runJob(client, o, persona, policy, (branch) => buildBrief(o, trigger, branch, persona), true, needs, trigger);
1087
+ return this.runJob(client, o, persona, policy, (branch, canPush) => buildBrief(o, trigger, branch, persona, canPush), true, needs, trigger);
840
1088
  }
841
1089
  async handleContinuation(client, act, persona, policy) {
842
1090
  this.markDone(act.id);
@@ -850,7 +1098,7 @@ export class Worker {
850
1098
  this.client?.reportWorkerState("running", `${act.act} ${act.id}`);
851
1099
  const trigger = client.recent(act.id) ?? `${act.act} from ${act.from} id=${act.id}\n${act.body}`;
852
1100
  const needs = workNeedsCommands("CONTINUE", trigger, act.act);
853
- return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false, needs, trigger);
1101
+ return this.runJob(client, o, persona, policy, (branch, canPush) => buildContinuationBrief(act, trigger, branch, persona, canPush), false, needs, trigger);
854
1102
  }
855
1103
  commandGrant(persona) {
856
1104
  if (persona?.allowBash === false)
@@ -899,30 +1147,50 @@ export class Worker {
899
1147
  if (this.opts.allowBash && !policy.allowBash && persona) {
900
1148
  this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
901
1149
  }
902
- const allowBash = policy.allowBash || this.commandGrant(persona);
903
- let baseRef = "", baseSha = "";
1150
+ let allowBash = policy.allowBash || this.commandGrant(persona);
1151
+ let baseRef = "", baseSha = "", tipSha = "";
904
1152
  let fetched = null;
905
1153
  try {
906
- ({ branch, worktree, baseRef, baseSha, fetched } = await prepareWorktreeAsync(this.opts.project, this.opts.scratch, goal, this.opts.log, branch, this.repoBase()));
1154
+ ({ branch, worktree, baseRef, baseSha, tipSha, fetched } = await prepareWorktreeAsync(this.opts.project, this.opts.scratch, goal, this.opts.log, branch, this.repoBase()));
907
1155
  }
908
1156
  catch (e) {
909
1157
  const detail = `could not prepare a worktree: ${e.message}`;
910
1158
  this.opts.log(`! ${detail}`);
911
1159
  return { obligation: o, branch, worktree, ok: false, detail };
912
1160
  }
1161
+ const agentId = this.opts.agentCmd ? "claude-code" : choice?.backend.id;
1162
+ const binary = this.opts.agentCmd ?? choice?.binary ?? "claude";
1163
+ const features = agentId === "claude-code"
1164
+ ? (this.opts.claudeFeatures ?? (this.opts.agentCmd ? NO_CLAUDE_FEATURES : detectClaudeFeatures(binary)))
1165
+ : null;
1166
+ if (features) {
1167
+ const said = describeFeatures(features);
1168
+ if (said !== this.lastFeaturesSaid) {
1169
+ this.opts.log(` claude ${said}`);
1170
+ this.lastFeaturesSaid = said;
1171
+ }
1172
+ }
1173
+ if (allowBash && features?.unknown) {
1174
+ allowBash = false;
1175
+ this.opts.log(` bash off for this run — \`${binary} --help\` did not answer, so its sandbox and deny rules cannot be confirmed`);
1176
+ }
1177
+ const holdRepoConfig = !allowBash && !(features?.settingSources);
1178
+ const canPush = allowBash && !!branch;
913
1179
  const reserved = reservedDecisionsNote(loadProfile(this.cfg));
914
- const brief = makeBrief(branch) + baseNote(baseRef, baseSha, fetched) + (allowBash ? "" : heldNote()) + (reserved ? `\n\n${reserved}` : "");
1180
+ const brief = makeBrief(branch, canPush) + baseNote(baseRef, baseSha, fetched) +
1181
+ (allowBash ? (agentId === "claude-code" ? commandNote(worktree, tipSha || baseSha, (m) => this.opts.log(m)) : "") : holdRepoConfig ? heldNote() : "") +
1182
+ (reserved ? `\n\n${reserved}` : "");
915
1183
  const startedAt = Date.now();
916
1184
  writeFileSync(join(worktree, ".pantheon-task.md"), brief);
917
1185
  let command;
918
- const agentId = this.opts.agentCmd ? "claude-code" : choice?.backend.id;
919
1186
  let args;
920
- let env = { ...process.env, ...proxyEnvFor() };
1187
+ let env = agentBaseEnv(process.env, proxyEnvFor());
921
1188
  let sp;
1189
+ let bashRules = [];
922
1190
  let fenced = null;
1191
+ const runHome = this.openRunHome();
923
1192
  try {
924
- const runHome = this.runHome();
925
- if (!allowBash) {
1193
+ if (holdRepoConfig) {
926
1194
  fenced = quarantineRepoAgentConfig(worktree, runHome);
927
1195
  if (fenced.moved.length)
928
1196
  this.opts.log(` config moved the repo's agent config aside for this edits-only run: ${fenced.moved.join(", ")}`);
@@ -942,11 +1210,20 @@ export class Worker {
942
1210
  this.opts.log(` config ${l}`);
943
1211
  }
944
1212
  const backend = this.opts.agentCmd ? { id: "claude-code", label: "Claude Code", binaries: ["claude"] } : choice.backend;
945
- const binary = this.opts.agentCmd ?? choice.binary;
1213
+ const personHome = this.opts.personHome ?? homedir();
1214
+ if (features && allowBash && !ensureRunTmp(runTmpDir(runHome))) {
1215
+ throw new Error(`the run's temp dir ${runTmpDir(runHome)} is not this user's own folder (another user's, or a link) — a command run is not started with it`);
1216
+ }
946
1217
  const plan = headlessSpawn({
947
1218
  backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome,
948
- ...(baseSha ? { base: baseSha } : {}), goal,
1219
+ ...(baseSha ? { base: baseSha } : {}), ...(tipSha ? { commandsRev: tipSha } : {}), goal,
1220
+ ...(features ? { claude: { features, pantheonHome: this.stateDir(), home: personHome, userSettings: join(personHome, ".claude", "settings.json") } } : {}),
949
1221
  });
1222
+ for (const n of plan.notes ?? [])
1223
+ if (!this.notesSaid.has(n)) {
1224
+ this.notesSaid.add(n);
1225
+ this.opts.log(` confine ${n}`);
1226
+ }
950
1227
  materializeSpawnFiles(worktree, plan);
951
1228
  for (const f of plan.writeFiles ?? []) {
952
1229
  const rel = isAbsolute(f.path) ? relative(worktree, f.path) : f.path;
@@ -954,6 +1231,7 @@ export class Worker {
954
1231
  fenced?.wrote(rel.split("\\").join("/"));
955
1232
  }
956
1233
  ({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
1234
+ bashRules = allowBash && agentId === "claude-code" ? claudeCommandRules(repoCommands(worktree, tipSha || baseSha).ok) : [];
957
1235
  if (allowBash && baseSha)
958
1236
  env = { ...env, ...massDeleteGuardEnv(runHome, worktree, asksForMassDelete(trigger), env) };
959
1237
  if (!this.opts.agentCmd)
@@ -973,23 +1251,28 @@ export class Worker {
973
1251
  if (fenced)
974
1252
  for (const l of fenced.restore().lines)
975
1253
  this.opts.log(` config ${l}`);
1254
+ this.sealRunHome(runHome);
976
1255
  const detail = `could not plan the run: ${e.message}`;
977
1256
  this.opts.log(`! ${detail}`);
978
- recordRun({ at: Date.now(), goal: o.goal, obligation: o.id, ok: false, triggeredBy: client.recentFrom(o.ref) ?? "unknown", summary: clip(o.body) }, this.opts.home);
1257
+ recordRun({ at: Date.now(), goal: o.goal, obligation: o.id, ok: false, triggeredBy: (await this.actOf(client, o.ref))?.from ?? "unknown", summary: clip(o.body), space: this.spaceKey(), member: this.cfg.member }, this.opts.home);
979
1258
  return { obligation: o, branch, worktree, ok: false, detail };
980
1259
  }
981
1260
  let detail;
982
1261
  let transcript = "";
1262
+ const homeBefore = snapshotHome(this.stateDir());
983
1263
  try {
984
1264
  detail = await new Promise((res) => {
985
1265
  let child;
986
1266
  try {
987
- child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options });
1267
+ child = procSpawn(sp.command, sp.args, { cwd: worktree, stdio: ["ignore", "pipe", "pipe"], env, ...sp.options, detached: process.platform !== "win32" });
988
1268
  }
989
1269
  catch (e) {
990
1270
  res(`could not start "${command}": ${e.message}`);
991
1271
  return;
992
1272
  }
1273
+ const pid = child.pid;
1274
+ if (pid)
1275
+ this.trackRun(pid);
993
1276
  let head = "", tail = "";
994
1277
  const take = (b) => {
995
1278
  const s = b.toString();
@@ -999,9 +1282,46 @@ export class Worker {
999
1282
  };
1000
1283
  child.stdout.on("data", take);
1001
1284
  child.stderr.on("data", take);
1002
- const kill = setTimeout(() => child.kill("SIGKILL"), 20 * 60_000);
1003
- child.on("close", (code) => { clearTimeout(kill); transcript = `${head}\n${tail}`; res(`exit ${code}\n${tail.slice(-4000)}`); });
1004
- child.on("error", (e) => { clearTimeout(kill); res(`could not start "${command}": ${e.message}`); });
1285
+ let timedOut = false;
1286
+ let dead = false;
1287
+ const markDead = () => { if (dead)
1288
+ return; dead = true; if (pid)
1289
+ this.untrackRun(pid); };
1290
+ let settled = false;
1291
+ let exitGrace = null;
1292
+ let exitCode = null;
1293
+ const limit = this.opts.runTimeoutMs ?? RUN_TIMEOUT_MS;
1294
+ const finish = (code, signal) => {
1295
+ if (settled)
1296
+ return;
1297
+ settled = true;
1298
+ clearTimeout(kill);
1299
+ if (exitGrace)
1300
+ clearTimeout(exitGrace);
1301
+ markDead();
1302
+ transcript = `${head}\n${tail}`;
1303
+ res(`exit ${code ?? signal}${timedOut ? ` (stopped after ${Math.round(limit / 1000)}s)` : ""}\n${tail.slice(-4000)}`);
1304
+ };
1305
+ const kill = setTimeout(() => { if (dead)
1306
+ return; timedOut = true; if (pid)
1307
+ killRunTree(pid); }, limit);
1308
+ child.on("exit", (code, signal) => {
1309
+ clearTimeout(kill);
1310
+ if (pid && !dead && killTreeOnExit())
1311
+ killRunTree(pid);
1312
+ markDead();
1313
+ const c = child;
1314
+ exitCode = { code, signal };
1315
+ exitGrace = setTimeout(() => { try {
1316
+ c.stdout.destroy();
1317
+ c.stderr.destroy();
1318
+ }
1319
+ catch { } finish(code, signal); }, this.opts.exitGraceMs ?? EXIT_GRACE_MS);
1320
+ });
1321
+ child.on("close", (code, signal) => finish(exitCode?.code ?? code, exitCode?.signal ?? signal));
1322
+ child.on("error", (e) => { if (settled)
1323
+ return; settled = true; clearTimeout(kill); if (exitGrace)
1324
+ clearTimeout(exitGrace); markDead(); res(`could not start "${command}": ${e.message}`); });
1005
1325
  });
1006
1326
  }
1007
1327
  finally {
@@ -1010,29 +1330,39 @@ export class Worker {
1010
1330
  for (const l of r.lines)
1011
1331
  this.opts.log(` config ${l}`);
1012
1332
  }
1333
+ this.sealRunHome(runHome);
1334
+ this.restoreHomeAfterRun(homeBefore, o);
1013
1335
  }
1014
1336
  const runLog = join(worktree, ".pantheon-run.log");
1015
1337
  let extra = "";
1338
+ let massDelete = false;
1016
1339
  if (allowBash && baseSha) {
1017
1340
  const d = deletedSinceBase(worktree, baseSha);
1018
1341
  if (d && d.total >= 4 && d.gone > d.total * MASS_DELETE_SHARE) {
1342
+ massDelete = true;
1019
1343
  extra = `\n! this branch deletes ${d.gone} of the ${d.total} files tracked at its base — do not merge it without looking`;
1020
1344
  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`);
1021
1345
  }
1022
1346
  }
1023
1347
  writeFileSync(runLog, `obligation ${o.id}\nbranch ${branch}\n\n${detail}\n${extra}`);
1024
1348
  const exited = /^exit 0/.test(detail);
1025
- const replied = requireReply ? client.repliedTo(o) : true;
1349
+ const sent = client.mineSince(startedAt, o.goal);
1350
+ const repliedWith = sent.find((a) => ["DELIVERED", "ANSWER", "ESCALATE", "ACCEPT", "OBJECT", "PROPOSE", "CONTRACT", "CLOSE", "WITHDRAW"].includes(a.act) || (a.act === "NOTE" && !!a.ref));
1351
+ const replied = requireReply ? client.repliedTo(o) || !!repliedWith : true;
1352
+ if (requireReply && replied && !client.repliedTo(o) && repliedWith) {
1353
+ this.opts.log(` reply ${repliedWith.act} ${repliedWith.id} answers ${o.id}, though the relay still lists it as owed${repliedWith.ref ? "" : " (the act named no ref)"}`);
1354
+ }
1026
1355
  const ok = exited && replied;
1027
- const triggeredBy = client.recentFrom(o.ref) ?? "unknown";
1356
+ await this.checkDeliveries(client, sent, { branch, worktree, canPush: canPush && !massDelete, goal: o.goal ?? null });
1357
+ const triggeredBy = (await this.actOf(client, o.ref))?.from ?? "unknown";
1028
1358
  const summary = clip(o.body);
1029
1359
  recordRun({
1030
1360
  at: Date.now(), goal: o.goal, obligation: o.id, ok,
1031
- triggeredBy, summary,
1361
+ triggeredBy, summary, space: this.spaceKey(), member: this.cfg.member,
1032
1362
  }, this.opts.home);
1033
- const sent = client.mineSince(startedAt, o.goal);
1034
1363
  const quiet = ok && !requireReply && sent.length === 0;
1035
- const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, transcript, runLog, needsCommands, ...(agentId ? { agent: agentId } : {}) }, this.opts.probe ?? gitProbe);
1364
+ const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, transcript, runLog, needsCommands, ...(agentId ? { agent: agentId } : {}),
1365
+ ...(agentId === "claude-code" && allowBash ? { allowedRules: bashRules } : {}), ...(baseRef ? { baseRef } : {}) }, this.opts.probe ?? gitProbe);
1036
1366
  if (this.opts.notify && !quiet && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
1037
1367
  try {
1038
1368
  const reply = sent[sent.length - 1] ?? null;
@@ -1045,6 +1375,92 @@ export class Worker {
1045
1375
  : `! ${o.id} stopped [${diagnosis.cause}]: ${diagnosis.says} (transcript: ${runLog})`);
1046
1376
  return { obligation: o, branch, worktree, ok, detail, ...(diagnosis ? { diagnosis } : {}) };
1047
1377
  }
1378
+ lastFeaturesSaid = "";
1379
+ notesSaid = new Set();
1380
+ restoreHomeAfterRun(before, o) {
1381
+ let changes = [];
1382
+ try {
1383
+ changes = restoreHome(before);
1384
+ }
1385
+ catch (e) {
1386
+ this.opts.log(`! could not check Pantheon's own files after the run: ${e.message}`);
1387
+ return;
1388
+ }
1389
+ if (!changes.length)
1390
+ return;
1391
+ for (const c of changes)
1392
+ this.opts.log(`${c.what === "reported" ? " " : "!"} ${o.id}: ${describeChange(c)}`);
1393
+ const taken = changes.filter((c) => c.what !== "reported");
1394
+ if (taken.length && this.opts.notify) {
1395
+ try {
1396
+ this.opts.notify({
1397
+ kind: "worker", who: "Your worker", heading: "A woken run tried to widen what the next run may do", context: this.cfg.space,
1398
+ body: `${taken.map(describeChange).join(" ")} None of it took effect.`,
1399
+ ttl: 0, sound: true,
1400
+ });
1401
+ }
1402
+ catch { }
1403
+ }
1404
+ }
1405
+ async checkDeliveries(client, sent, run) {
1406
+ const deliveries = sent.filter((a) => a.act === "DELIVERED");
1407
+ if (!deliveries.length || !run.branch)
1408
+ return;
1409
+ const origin = await gitOut(this.opts.project, ["remote", "get-url", "origin"]);
1410
+ if (origin === null)
1411
+ return;
1412
+ for (const d of deliveries) {
1413
+ const m = (d.meta ?? {});
1414
+ const br = typeof m.branch === "string" ? m.branch : "";
1415
+ const commit = typeof m.commit === "string" && /^[0-9a-f]{7,64}$/i.test(m.commit) ? m.commit : "";
1416
+ if (!br || !commit)
1417
+ continue;
1418
+ if (typeof m.remote === "string" && m.remote && m.remote !== origin)
1419
+ continue;
1420
+ if (await onRemote(run.worktree, br, commit))
1421
+ continue;
1422
+ let pushed = false;
1423
+ if (run.canPush && br === run.branch && validBase(br)
1424
+ && (await gitOut(run.worktree, ["merge-base", "--is-ancestor", commit, `refs/heads/${br}`])) !== null) {
1425
+ const r = await gitOut(run.worktree, ["push", "--quiet", "origin", `refs/heads/${br}:refs/heads/${br}`], FETCH_TIMEOUT_MS);
1426
+ pushed = r !== null && await onRemote(run.worktree, br, commit);
1427
+ if (pushed)
1428
+ this.opts.log(`* pushed ${br} for the run: its DELIVERED ${d.id} named ${commit.slice(0, 7)}, which origin did not have`);
1429
+ }
1430
+ if (pushed)
1431
+ continue;
1432
+ const why = br !== run.branch ? `it names ${br}, not this run's own branch ${run.branch}, which the worker will not push`
1433
+ : !run.canPush ? "this run was not allowed to run commands, so nothing could be pushed"
1434
+ : "the worker could not push it either";
1435
+ this.opts.log(`! DELIVERED ${d.id} names ${br} at ${commit.slice(0, 7)}, which is not on origin — ${why}`);
1436
+ try {
1437
+ await client.send({
1438
+ act: "NOTE", goal: d.goal ?? run.goal, ref: d.id, after_seq: Math.max(client.readSeq, client.seq),
1439
+ ...(d.to?.length ? { to: d.to } : {}),
1440
+ summary: clip(`Correction: that delivery cannot be fetched yet — ${br} at ${commit.slice(0, 7)} is not on origin`, 190),
1441
+ body: [
1442
+ `Correction to DELIVERED ${d.id}: ${br} at ${commit.slice(0, 12)} is not on origin, so \`git fetch origin ${br}\` will not find it.`,
1443
+ `Why: ${why}. The work is on this machine, on that branch; ${this.cfg.member}'s human has been told and will push it or say what happened.`,
1444
+ `Do not ACCEPT it until the branch is fetchable.`,
1445
+ ].join("\n"),
1446
+ });
1447
+ }
1448
+ catch (e) {
1449
+ this.opts.log(`! could not correct the unpushed DELIVERED on the ledger: ${e.message}`);
1450
+ }
1451
+ if (this.opts.notify) {
1452
+ try {
1453
+ this.opts.notify({
1454
+ kind: "worker", who: "Your worker", heading: "A delivery was not pushed", context: this.cfg.space,
1455
+ body: `A woken run sent DELIVERED for ${br} at ${commit.slice(0, 7)}, but origin does not have it (${why}). Your teammate was told. Push it yourself: git push origin ${br}`,
1456
+ ttl: 0, sound: false,
1457
+ fix: { label: "Push it", dir: run.worktree, lines: [`git push origin ${br}`], why: `DELIVERED ${d.id} is not on origin` },
1458
+ });
1459
+ }
1460
+ catch { }
1461
+ }
1462
+ }
1463
+ }
1048
1464
  refusalsNotified = new Set();
1049
1465
  async reportRefused(client, o, d, owed) {
1050
1466
  if (this.opts.notify && !this.refusalsNotified.has(d.says)) {
@@ -1078,7 +1494,7 @@ export class Worker {
1078
1494
  this.opts.log(`! could not tell the teammate that ${o.id} is waiting: ${e.message}`);
1079
1495
  }
1080
1496
  }
1081
- async reportStuck(client, o, d) {
1497
+ async reportStuck(client, o, d, attempts) {
1082
1498
  const key = `stuck:${o.id}`;
1083
1499
  const space = this.spaceKey();
1084
1500
  if (loadContinued(space, this.opts.home).has(key))
@@ -1087,7 +1503,7 @@ export class Worker {
1087
1503
  const asking = !!d.capability && mayAsk(policy, d.capability);
1088
1504
  const what = clip(o.body, 120);
1089
1505
  const lines = [
1090
- `Two runs on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
1506
+ `${attempts === 1 ? "A run" : `${attempts === 2 ? "Two" : attempts} runs`} on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
1091
1507
  ``,
1092
1508
  `WHY IT STOPPED. ${d.says}`,
1093
1509
  `WHAT UNBLOCKS IT. ${d.fix}`,
@@ -1097,13 +1513,13 @@ export class Worker {
1097
1513
  `It will not be retried unattended — a live session here, or you, has to move it.`,
1098
1514
  ];
1099
1515
  if (asking) {
1100
- lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. Allowing it lets a woken`, `run execute tests and git in its own isolated worktree — never in your checkout.`);
1516
+ lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. What allowing it permits: ${COMMAND_GRANT_SCOPE}`, `A teammate this machine lets wake it can start such a run.`, `Answer it on this machine — the menu bar, or: pantheon approvals answer <this-decision-id> 'allow always'`, `(or: pantheon policy grant run-commands). An answer sent from anywhere else settles this decision but grants nothing.`);
1101
1517
  }
1102
1518
  try {
1103
1519
  const sent = await client.send({
1104
- act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: client.readSeq,
1105
- summary: clip(`Stuck on you: ${d.says}`, 190),
1106
- body: lines.join("\n"),
1520
+ act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: Math.max(client.readSeq, client.seq),
1521
+ summary: clip(this.ledgerPaths(`Stuck on you: ${d.says}`), 190),
1522
+ body: this.ledgerPaths(lines.join("\n")),
1107
1523
  });
1108
1524
  markContinued(space, key, this.opts.home);
1109
1525
  if (asking && d.capability) {
@@ -1116,6 +1532,31 @@ export class Worker {
1116
1532
  this.opts.log(`! could not report the stuck obligation on the ledger: ${e.message}`);
1117
1533
  }
1118
1534
  }
1535
+ ledgerPaths(text) {
1536
+ const swaps = [];
1537
+ const proj = resolve(this.opts.project);
1538
+ swaps.push([`${proj}${sep}`, ""], [proj, "the repo's own checkout"]);
1539
+ const scratch = resolve(this.opts.scratch);
1540
+ if (!scratch.startsWith(`${proj}${sep}`))
1541
+ swaps.push([`${scratch}${sep}`, "<worktrees>/"]);
1542
+ const home = this.opts.personHome ?? homedir();
1543
+ if (home && home !== sep)
1544
+ swaps.push([`${home}${sep}`, "~/"], [home, "~"]);
1545
+ const win = process.platform === "win32";
1546
+ const esc = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1547
+ let out = text;
1548
+ for (const [from, to] of swaps) {
1549
+ let real = from;
1550
+ try {
1551
+ real = realpathSync(from.endsWith(sep) ? from.slice(0, -1) : from) + (from.endsWith(sep) ? sep : "");
1552
+ }
1553
+ catch { }
1554
+ for (const v of new Set([from, real, from.replace(/\\/g, "/"), real.replace(/\\/g, "/")])) {
1555
+ out = out.replace(new RegExp(esc(v), win ? "gi" : "g"), () => to);
1556
+ }
1557
+ }
1558
+ return out;
1559
+ }
1119
1560
  enqueueContinuations(client, arrived) {
1120
1561
  this.loadContinued();
1121
1562
  for (const r of arrived) {
@@ -1177,17 +1618,23 @@ export class Worker {
1177
1618
  let changed = false;
1178
1619
  for (const cap of Object.keys(policy.asks)) {
1179
1620
  const open = policy.asks[cap];
1180
- if (!open)
1621
+ if (!open || this.ledgerAllowSeen.has(open.act))
1181
1622
  continue;
1182
1623
  const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref === open.act && a.from === this.cfg.member);
1183
1624
  const body = fromStream?.body ?? await this.fetchGrantAnswer(open.act, client.head - 200);
1184
1625
  if (body === null)
1185
1626
  continue;
1186
- const applied = applyAnswer(policy, open.act, body);
1187
- if (applied) {
1627
+ const verdict = parseGrantAnswer(body);
1628
+ if (verdict === "deny") {
1629
+ applyAnswer(policy, open.act, body);
1188
1630
  this.askAt.delete(open.act);
1189
1631
  changed = true;
1190
- this.opts.log(`* grant request answered: ${applied.cap} -> ${applied.verdict}`);
1632
+ this.opts.log(`* grant request answered on the ledger: ${cap} -> deny`);
1633
+ }
1634
+ else if (verdict) {
1635
+ this.ledgerAllowSeen.add(open.act);
1636
+ this.opts.log(`! an ANSWER on the ledger says "${verdict}" to this worker's ${cap} request (${open.act}) — NOT applied: ` +
1637
+ `a grant takes effect only from this machine. To grant it: pantheon policy grant ${CAPABILITY_ARGS[cap]}`);
1191
1638
  }
1192
1639
  }
1193
1640
  if (changed)
@@ -1224,7 +1671,7 @@ export class Worker {
1224
1671
  }
1225
1672
  if (result.ok && !result.alreadyCurrent) {
1226
1673
  await client.send({
1227
- act: "NOTE", goal: null, after_seq: client.readSeq,
1674
+ act: "NOTE", goal: null, after_seq: Math.max(client.readSeq, client.seq),
1228
1675
  summary: `self-upgraded pantheon ${result.from} -> ${result.to}; worker restarting on the new build`,
1229
1676
  body: `This machine's worker upgraded pantheon ${result.from} -> ${result.to} (fast-forward pull + rebuild, granted by its human) and is restarting on the new build. Agent windows on this machine opened on 0.24 or later switch to the new version by themselves; a window opened on an older pantheon needs one reconnect (/mcp → pantheon → Reconnect, or reopen it).`,
1230
1677
  }).catch((e) => this.opts.log(`! could not report the upgrade on the ledger: ${e.message}`));
@@ -1240,14 +1687,15 @@ export class Worker {
1240
1687
  if (mayAsk(policy, "self_upgrade")) {
1241
1688
  try {
1242
1689
  const sent = await client.send({
1243
- act: "ESCALATE", goal: null, after_seq: client.readSeq,
1690
+ act: "ESCALATE", goal: null, after_seq: Math.max(client.readSeq, client.seq),
1244
1691
  summary: `Allow this worker to keep pantheon updated? It runs ${VERSION}, the team's relay ${relayPkg}. Answer "allow always", "allow once", or "deny".`,
1245
1692
  body: [
1246
1693
  `Allow this worker to keep pantheon updated? Answer "allow always", "allow once", or "deny".`,
1247
1694
  ``,
1248
1695
  `Your worker is asking for a capability, not reporting a problem. The team's relay runs pantheon ${relayPkg}; this machine runs ${VERSION}, so until it upgrades it can miss or mishandle teammate requests.`,
1249
1696
  `The upgrade is mechanical and reversible: fast-forward pull of the existing checkout, rebuild, restart its own services, then report versions on the ledger. Nothing else is authorized by this grant.`,
1250
- `From a terminal: pantheon approvals answer <this-decision-id> 'allow always'`,
1697
+ `Answer it on this machine — the menu bar, or: pantheon approvals answer <this-decision-id> 'allow always' (or: pantheon policy grant self-upgrade).`,
1698
+ `An answer sent from anywhere else settles this decision for the team but grants nothing.`,
1251
1699
  ].join("\n"),
1252
1700
  });
1253
1701
  recordAsk(policy, "self_upgrade", sent.id);
@@ -1260,21 +1708,196 @@ export class Worker {
1260
1708
  }
1261
1709
  }
1262
1710
  askAt = new Map();
1711
+ ledgerAllowSeen = new Set();
1263
1712
  lastFullScan = 0;
1264
- async actSeq(id) {
1713
+ async fetchActById(id) {
1265
1714
  try {
1266
- const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
1267
- const q = `space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}`;
1268
- const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(3000) });
1715
+ const res = await signedRead(this.cfg, "/act", { id }, { timeoutMs: 3000 });
1269
1716
  if (!res.ok)
1270
1717
  return null;
1271
1718
  const j = await res.json();
1272
- return typeof j.act?.seq === "number" ? j.act.seq : null;
1719
+ return j.act ?? null;
1273
1720
  }
1274
1721
  catch {
1275
1722
  return null;
1276
1723
  }
1277
1724
  }
1725
+ async actSeq(id) {
1726
+ const a = await this.fetchActById(id);
1727
+ return typeof a?.seq === "number" ? a.seq : null;
1728
+ }
1729
+ senders = new Map();
1730
+ async actOf(client, id) {
1731
+ const seen = client.seenAct(id);
1732
+ if (seen?.from)
1733
+ return { from: seen.from, ...(seen.role ? { role: seen.role } : {}) };
1734
+ const kept = this.senders.get(id);
1735
+ if (kept)
1736
+ return kept;
1737
+ const a = await this.fetchActById(id);
1738
+ if (typeof a?.from !== "string")
1739
+ return null;
1740
+ const hit = { from: a.from, ...(typeof a.role === "string" ? { role: a.role } : {}) };
1741
+ if (this.senders.size > 500)
1742
+ this.senders.delete(this.senders.keys().next().value);
1743
+ this.senders.set(id, hit);
1744
+ return hit;
1745
+ }
1746
+ carryWakers(client) {
1747
+ if (this.stopped)
1748
+ return;
1749
+ const team = this.cfg.spaceId ?? this.cfg.space;
1750
+ let mode = "approved";
1751
+ let choice;
1752
+ let fromProfile = false;
1753
+ let p;
1754
+ let result;
1755
+ try {
1756
+ p = loadPolicy(this.opts.home);
1757
+ if (p.wakersCarried?.[team])
1758
+ return;
1759
+ const profile = loadProfile(this.cfg);
1760
+ mode = this.opts.wakeMode ?? wakeMode(profile);
1761
+ if (this.opts.wakeChoice !== undefined)
1762
+ choice = this.opts.wakeChoice === "unset" ? undefined : this.opts.wakeChoice;
1763
+ else {
1764
+ fromProfile = true;
1765
+ choice = profile.agent.wake_default;
1766
+ if (choice === undefined && this.joinedBefore030())
1767
+ choice = "unset-before-0.30";
1768
+ }
1769
+ result = carryOverWakers(p, team, this.cfg.member, client.members, choice);
1770
+ }
1771
+ catch (e) {
1772
+ this.opts.log(` wake could not carry over who may wake this machine: ${e.message}`);
1773
+ return;
1774
+ }
1775
+ if (result === null)
1776
+ return;
1777
+ if (result === "deferred") {
1778
+ if (p.wakersDeferred?.[team])
1779
+ return;
1780
+ (p.wakersDeferred ??= {})[team] = Date.now();
1781
+ try {
1782
+ savePolicy(p, this.opts.home);
1783
+ }
1784
+ catch (e) {
1785
+ this.opts.log(` wake could not save the worker policy: ${e.message}`);
1786
+ }
1787
+ const why = OLD_RELAY_WAKE;
1788
+ this.opts.log(` wake ${why}`);
1789
+ if (this.opts.notify && mode === "approved") {
1790
+ try {
1791
+ this.opts.notify({ kind: "worker", who: "Your worker", heading: "Who can wake this machine", context: this.cfg.space, body: `${why}.`, ttl: 0, sound: false });
1792
+ }
1793
+ catch { }
1794
+ }
1795
+ return;
1796
+ }
1797
+ try {
1798
+ savePolicy(p, this.opts.home);
1799
+ }
1800
+ catch (e) {
1801
+ this.opts.log(` wake could not record who may wake this machine: ${e.message} — trying again later`);
1802
+ return;
1803
+ }
1804
+ if (fromProfile && choice === "unset-before-0.30") {
1805
+ try {
1806
+ const prof = loadProfile(this.cfg);
1807
+ if (prof.agent.wake_default === undefined)
1808
+ saveProfile({ ...prof, agent: { ...prof.agent, wake_default: "approved" } });
1809
+ mode = this.opts.wakeMode ?? "approved";
1810
+ }
1811
+ catch (e) {
1812
+ this.opts.log(` wake could not record the wake setting: ${e.message}`);
1813
+ }
1814
+ }
1815
+ const carried = result;
1816
+ const names = new Set(carried.map((c) => c.member));
1817
+ const dropped = client.members.filter((m) => m.admitted && m.may_wake && m.member !== this.cfg.member && !names.has(m.member)).map((m) => m.member);
1818
+ const who = carried.map((c) => `${c.member} (${CARRY_REASON[c.why]})`).join(", ");
1819
+ const allow = "a person here allows them with: pantheon wake allow <name>";
1820
+ if (mode !== "approved") {
1821
+ const today = mode === "anyone" ? "anyone the team lets wake workers may wake this machine (you chose \"anyone\")" : "this machine is set not to be woken";
1822
+ this.opts.log(` wake ${today}${carried.length ? `; kept for if you choose "approved": ${who}` : ""}`);
1823
+ return;
1824
+ }
1825
+ const now = carried.length ? `${who} can wake this machine` : "nobody can wake this machine yet";
1826
+ const before = dropped.length ? `; ${dropped.join(", ")} could before, through someone else's approval, and no longer can — ${allow}` : "";
1827
+ const setting = fromProfile && choice === "unset-before-0.30" ? "; this machine now wakes only for the people listed (pantheon wake list; pantheon setup to change)" : "";
1828
+ this.opts.log(` wake ${now}${before}${setting}`);
1829
+ if (this.opts.notify && (carried.length || dropped.length)) {
1830
+ try {
1831
+ this.opts.notify({
1832
+ kind: "worker", who: "Your worker", heading: "Who can wake this machine", context: this.cfg.space,
1833
+ body: `${now}. ${dropped.length ? `${dropped.join(", ")} could before and no longer can — ${allow}.` : `Anyone else: ${allow.replace(/^a person here allows them with: /, "")}.`}`,
1834
+ ttl: 0, sound: false,
1835
+ });
1836
+ }
1837
+ catch { }
1838
+ }
1839
+ }
1840
+ joinedBefore030() {
1841
+ try {
1842
+ const f = loadAll();
1843
+ const m = Object.values(f?.spaces ?? {}).find((x) => (this.cfg.spaceId && x.id === this.cfg.spaceId) || (x.name === this.cfg.space && x.relay === this.cfg.relay));
1844
+ return !!m && !m.since;
1845
+ }
1846
+ catch {
1847
+ return false;
1848
+ }
1849
+ }
1850
+ refusedSaid = false;
1851
+ oldRelaySaid = false;
1852
+ dropLedgerGrants() {
1853
+ try {
1854
+ const p = loadPolicy(this.opts.home);
1855
+ const gone = dropLedgerGrants(p);
1856
+ if (!gone.length)
1857
+ return;
1858
+ savePolicy(p, this.opts.home);
1859
+ const names = gone.map((c) => CAPABILITY_ARGS[c]);
1860
+ const body = `a ${names.join(" and ")} permission recorded from a message was removed — only a person on this machine grants one now; grant it here with ${names.map((n) => `pantheon policy grant ${n}`).join(" / ")}, or answer the worker's request when it asks`;
1861
+ this.opts.log(` policy ${body}`);
1862
+ if (this.opts.notify) {
1863
+ try {
1864
+ this.opts.notify({ kind: "worker", who: "Your worker", heading: "A permission was removed", context: this.cfg.space, body: `${body}.`, ttl: 0, sound: false });
1865
+ }
1866
+ catch { }
1867
+ }
1868
+ }
1869
+ catch (e) {
1870
+ this.opts.log(` policy could not check the grants recorded here: ${e.message}`);
1871
+ }
1872
+ }
1873
+ async wakeRefusal(client, ref, known) {
1874
+ const mode = this.opts.wakeMode ?? wakeMode(loadProfile(this.cfg));
1875
+ if (mode === "no")
1876
+ return { why: "this machine is set not to be woken by teammates (pantheon setup to change it)" };
1877
+ const sender = known?.from ? { from: known.from, role: known.role ?? undefined } : await this.actOf(client, ref);
1878
+ if (sender?.from === this.cfg.member && sender.role === "worker")
1879
+ return { why: "it was sent by a woken run of this member, and a woken run never starts another" };
1880
+ if (mode === "anyone")
1881
+ return null;
1882
+ if (sender?.from === this.cfg.member)
1883
+ return null;
1884
+ if (!sender?.from)
1885
+ return { why: "cannot tell who sent it, and only teammates a person here approved may wake this machine", transient: true };
1886
+ const from = sender.from;
1887
+ const pol = loadPolicy(this.opts.home);
1888
+ if (isApprovedWaker(pol, [this.cfg.spaceId, this.cfg.space], from))
1889
+ return null;
1890
+ if (!pol.wakersCarried?.[this.cfg.spaceId ?? this.cfg.space] && !relayKnowsAdmitters(client.members, this.cfg.member)) {
1891
+ if (!this.oldRelaySaid) {
1892
+ this.oldRelaySaid = true;
1893
+ this.opts.log(` wake ${OLD_RELAY_WAKE}`);
1894
+ }
1895
+ return null;
1896
+ }
1897
+ const teamSays = client.members.find((m) => m.member === from)?.may_wake;
1898
+ return { why: `${from} is not approved to wake this machine (the team's may_wake is not enough) — a person here can: pantheon wake allow ${from}, or choose wake "anyone" in pantheon setup` +
1899
+ (teamSays === false ? `; the team does not let ${from} wake workers either (a member runs: pantheon admit ${from} --may-wake)` : "") };
1900
+ }
1278
1901
  async fetchGrantAnswer(askActId, since = 0) {
1279
1902
  let seq = this.askAt.get(askActId) ?? null;
1280
1903
  if (seq === null) {
@@ -1290,9 +1913,7 @@ export class Worker {
1290
1913
  }
1291
1914
  const from = answerWindow(seq, since, due);
1292
1915
  try {
1293
- const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
1294
- const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=${from}`;
1295
- const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
1916
+ const res = await signedRead(this.cfg, "/history", { since: from }, { timeoutMs: 3000 });
1296
1917
  const j = await res.json();
1297
1918
  const ans = (j.acts ?? []).find((a) => a.act === "ANSWER" && a.ref === askActId && a.from === this.cfg.member);
1298
1919
  return ans ? ans.body : null;