@join-pantheon/cli 0.29.0 → 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 -119
  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 +507 -38
  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 +88 -15
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1193 -185
  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 +105 -36
  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/cli.js CHANGED
@@ -7,11 +7,11 @@ process.emitWarning = ((warning, ...rest) => {
7
7
  return _emitWarning(warning, ...rest);
8
8
  });
9
9
  import { randomBytes } from "node:crypto";
10
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
10
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { PantheonClient } from "./client.js";
13
13
  import { agentSpawn, CmdLineUnsafe } from "./which.js";
14
- import { fromInvite, looksLikeInviteCode, resolveInviteCode, load, save, toInvite, toJoinLink, resolveFor, loadAll, identity, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
14
+ import { fromInvite, looksLikeInviteCode, resolveInviteCode, load, save, toInvite, toJoinLink, resolveFor, loadAll, identity, repoInfo, bindRepo, bindFolder, unbindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
15
15
  import { fmtLimit } from "./plans.js";
16
16
  import { validEmail, validUsername } from "./accounts.js";
17
17
  import { install, launcher, installForUser } from "./install.js";
@@ -21,7 +21,7 @@ import { Worker } from "./worker.js";
21
21
  import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
22
22
  import { desktopUnavailable, hostEnv } from "./host-env.js";
23
23
  import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
24
- import { runGuide, terminalIO, parseSetupFlags, questionnaire, optOutsFrom } from "./setup-guide.js";
24
+ import { runGuide, terminalIO, questionOrNull, parseSetupFlags, questionnaire, optOutsFrom } from "./setup-guide.js";
25
25
  import { selectActs, formatAct, resolveRef } from "./history-view.js";
26
26
  import { doctor, render, shareBlock } from "./doctor.js";
27
27
  import { FEEDBACK_ADDRESS } from "./feedback.js";
@@ -35,16 +35,23 @@ import { createInterface } from "node:readline";
35
35
  import { homedir } from "node:os";
36
36
  import { removeHook, decideStop, writeIntent, clearIntent, readIntent, installHooks, agentHookStates } from "./notify.js";
37
37
  import { pending, render as renderPending } from "./pending.js";
38
- import { start as qsStart, join as qsJoin, wireUp, teamNameFor } from "./quickstart.js";
38
+ import { signedRead } from "./reads.js";
39
+ import { start as qsStart, join as qsJoin, wireUp, teamNameFor, deviceLabel } from "./quickstart.js";
39
40
  import { installService, uninstallService } from "./service.js";
40
- import { LIMITS } from "./protocol.js";
41
+ import { ACTS, LIMITS } from "./protocol.js";
41
42
  import { loadPersonas, personaByName, describePersona, initPersona, personaDir } from "./persona.js";
42
43
  import { fingerprint, newKeypair, signAccount } from "./identity.js";
43
44
  import { VERSION } from "./version.js";
44
- import { connectFailure } from "./refusal.js";
45
+ import { connectFailure, signInHint } from "./refusal.js";
45
46
  import * as mcpWindows from "./mcp-windows.js";
46
47
  import { updateBanner } from "./update-check.js";
48
+ import { inWokenRun } from "./worker-policy.js";
47
49
  const argv = process.argv.slice(2);
50
+ for (let i = argv.length - 1; i >= 1; i--) {
51
+ const m = /^(--[a-z][a-z0-9-]*)=(.*)$/i.exec(argv[i]);
52
+ if (m)
53
+ argv.splice(i, 1, m[1], m[2]);
54
+ }
48
55
  const cmd = argv[0] ?? "help";
49
56
  function signedQuery(cfg, method, path, params, at = Date.now()) {
50
57
  const full = { ...params, pubkey: cfg.publicKey, ts: at };
@@ -72,6 +79,21 @@ function requireSignInOnHostedRelay(relayFlag) {
72
79
  return;
73
80
  die(`sign in first — this relay verifies email.\n New here: pantheon signup --email you@work.com --username you\n Have an account: pantheon login --email you@work.com\n Then run pantheon start again.`);
74
81
  }
82
+ async function requireSignInOnAccountsRelay(relayFlag) {
83
+ if (!relayFlag || identity()?.account)
84
+ return;
85
+ let hasAccounts = false;
86
+ try {
87
+ const base = relayFlag.replace(/^ws/, "http").replace(/\/+$/, "");
88
+ const res = await fetch(`${base}/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}", signal: AbortSignal.timeout(6_000) });
89
+ hasAccounts = res.status !== 404;
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ if (hasAccounts)
95
+ die(`sign in first — ${relayFlag} verifies email.${signInHint("NOT_SIGNED_IN", relayFlag)}\n Then run this same pantheon start again.`);
96
+ }
75
97
  async function interactiveOnboard(relayArg) {
76
98
  const prior = identity();
77
99
  const relay = relayArg ?? prior?.relay ?? process.env.PANTHEON_RELAY ?? DEFAULT_RELAY;
@@ -82,9 +104,16 @@ async function interactiveOnboard(relayArg) {
82
104
  return;
83
105
  }
84
106
  const rl = createInterface({ input: process.stdin, output: process.stderr });
85
- const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a.trim())));
107
+ const ask = async (q) => {
108
+ const a = await questionOrNull(rl, q);
109
+ if (a === null) {
110
+ console.error(D("\n cancelled. Pick up where you left off: pantheon signup --email you@work.com --username you"));
111
+ process.exit(1);
112
+ }
113
+ return a;
114
+ };
86
115
  const kp = prior?.publicKey && prior.privateKey ? { publicKey: prior.publicKey, privateKey: prior.privateKey } : newKeypair();
87
- const label = `${process.env.USER ?? "a"}'s ${process.platform === "darwin" ? "mac" : process.platform}`;
116
+ const label = deviceLabel();
88
117
  const postJson = async (path, body) => fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
89
118
  .then(async (x) => ({ status: x.status, j: await x.json() }));
90
119
  try {
@@ -166,7 +195,8 @@ async function interactiveOnboard(relayArg) {
166
195
  console.error(` ${C("pantheon join <invite-link>")} ${D("(join a teammate's team)")}`);
167
196
  return;
168
197
  }
169
- console.error(D(` ${v.j.error ?? "that code is not right"} — try again${v.j.reason === "expired" ? ", or type 'resend'" : ""}`));
198
+ const spent = v.j.reason === "expired" || v.j.reason === "too_many_attempts" || v.j.tries_left === 0;
199
+ console.error(D(` ${v.j.error ?? "that code is not right"}${spent ? " — type 'resend' for a new code" : " — try again"}`));
170
200
  }
171
201
  console.error(D("\n Too many tries. Run `pantheon` again to restart, or `pantheon verify <code>` once you have it."));
172
202
  }
@@ -228,10 +258,20 @@ function sameHost(a, b) {
228
258
  return false;
229
259
  }
230
260
  }
261
+ const teamFlag = (cfg) => (flag("space") ? ` --space ${cfg.space}` : "");
231
262
  function need() {
232
263
  const r = resolveFor(process.cwd());
264
+ if (flag("space") && !["start", "profile"].includes(cmd)) {
265
+ const f = loadAll();
266
+ const m = f && findMembership(f, flag("space"));
267
+ if (!f || !m)
268
+ die(`you are not in a team called "${flag("space")}". Yours: ${Object.values(f?.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
269
+ return r.cfg && (r.cfg.spaceId === m.id || r.cfg.space === m.name) && r.reason !== "default" ? r.cfg : configFor(f, m, null);
270
+ }
233
271
  if (r.cfg && r.cfg.space)
234
272
  return r.cfg;
273
+ if (r.repoUnknown)
274
+ die(`git did not answer in this folder, so which team this repo is bound to cannot be told — and a command for the wrong team is worse than none.\n Check that git works here (git status), then run it again — or name the team: --space <team>`);
235
275
  if (r.reason === "unbound") {
236
276
  const names = Object.values(loadAll()?.spaces ?? {}).map((m) => m.name).join(", ");
237
277
  die(`this folder is not bound to one of your ${r.memberships} teams (${names}).\n From the team's repo: pantheon use <team> --here\n Or pick a default: pantheon use <team>`);
@@ -239,7 +279,7 @@ function need() {
239
279
  if (r.reason === "stale")
240
280
  die(`this repo is bound to a team this machine is no longer in (${r.binding?.space}).\n Rebind it: pantheon use <team> --here · or rejoin: pantheon join <invite>`);
241
281
  if (r.cfg && !r.cfg.space)
242
- die("signed in, but not on a team yet. Run `pantheon start` (create one) or `pantheon join <invite>` (join one).");
282
+ die(`${r.cfg.account ? "signed in" : "this machine has a key"}, but is not on a team yet. Run \`pantheon start\` (create one) or \`pantheon join <invite>\` (join one).`);
243
283
  return die("not configured. Run `pantheon signup` then `pantheon start` (create a team), or `pantheon join <invite>` (join one).");
244
284
  }
245
285
  function targets() {
@@ -303,6 +343,7 @@ function printGranted(granted) {
303
343
  console.error(` \u2022 ${g}`);
304
344
  console.error(D(` Undo: pantheon hook uninstall · pantheon service uninstall worker`));
305
345
  }
346
+ const HUD_ACTION_MAX_AGE_MS = 30_000;
306
347
  function unattendedProject() {
307
348
  if (argv.includes("--no-worker"))
308
349
  return { project: null, how: "off" };
@@ -377,21 +418,33 @@ async function workdirFor(cfg, hud) {
377
418
  return pickWorkdir(bindings, hints, team);
378
419
  }
379
420
  async function fetchHistory(cfg, opts = {}) {
380
- const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
381
- const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`
382
- + (opts.goal ? `&goal=${encodeURIComponent(opts.goal)}` : opts.since !== undefined ? `&since=${opts.since}` : "");
383
- const res = await fetch(`${base}/history?${q}`, { signal: AbortSignal.timeout(8000) });
421
+ const res = await signedRead(cfg, "/history", opts.goal ? { goal: opts.goal } : opts.since !== undefined ? { since: opts.since } : {}, { timeoutMs: 8000 });
384
422
  if (!res.ok)
385
- throw new Error(`relay answered ${res.status} for the ledger`);
423
+ throw await readFailure(cfg, res, "the ledger");
386
424
  return (await res.json()).acts ?? [];
387
425
  }
426
+ async function readFailure(cfg, res, what) {
427
+ const why = await res.json().then((j) => j.error).catch(() => undefined);
428
+ if (res.status === 401 || res.status === 403) {
429
+ const c = new PantheonClient(cfg, undefined, { role: "cli" });
430
+ try {
431
+ await c.connect();
432
+ }
433
+ catch (e) {
434
+ return new Error(c.pending ?? connectFailure(e, cfg.relay));
435
+ }
436
+ finally {
437
+ c.close();
438
+ }
439
+ }
440
+ return new Error(`relay answered ${res.status} for ${what}${why ? `: ${why}` : ""}`);
441
+ }
388
442
  async function fetchAct(cfg, id, budgetMs = Number.POSITIVE_INFINITY) {
389
443
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
390
- const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
391
444
  const deadline = Date.now() + budgetMs;
392
445
  const left = () => Math.max(1, Math.min(8000, deadline - Date.now()));
393
446
  try {
394
- const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(left()) });
447
+ const res = await signedRead(cfg, "/act", { id }, { timeoutMs: left() });
395
448
  if (res.ok) {
396
449
  const body = await res.json();
397
450
  if (body.act)
@@ -469,11 +522,36 @@ function reportWindows(installed) {
469
522
  if (ended.length)
470
523
  console.error(D(` ended ${ended.length} pantheon server${ended.length > 1 ? "s" : ""} left behind by windows that no longer exist`));
471
524
  }
525
+ const AGENT_ROWS = new Set(["claude code", "cursor", "codex", "copilot", "vs code", "kiro"]);
526
+ const rowFailed = (status) => /^(failed|could not)/i.test(status);
527
+ function readiness(tools) {
528
+ const agents = tools.filter((t) => AGENT_ROWS.has(t.tool));
529
+ const attempted = agents.filter((t) => !/^skipped/i.test(t.status));
530
+ return { failed: tools.filter((t) => rowFailed(t.status)), wired: attempted.filter((t) => !rowFailed(t.status)).length, attempted: attempted.length };
531
+ }
472
532
  function printReady(tools) {
473
533
  const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
474
- console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
534
+ const r = readiness(tools);
535
+ if (r.failed.length) {
536
+ console.error(`\n\x1b[31m${B("Not everything was set up:")}\x1b[0m`);
537
+ for (const f of r.failed)
538
+ console.error(` ${f.tool.padEnd(14)}${f.status}`);
539
+ }
540
+ if (r.attempted && !r.wired) {
541
+ console.error(`\n\x1b[31m No agent on this machine is connected to pantheon.\x1b[0m ${D("Fix the lines above, then run: pantheon install --user")}`);
542
+ process.exitCode = 1;
543
+ return;
544
+ }
545
+ if (!r.attempted) {
546
+ console.error(`\n${B("No agent CLI was found on this machine,")} so nothing is connected yet. ${D("Install Claude Code, Codex, Cursor, Copilot or Kiro, then run: pantheon install --user")}`);
547
+ return;
548
+ }
549
+ if (r.failed.length)
550
+ console.error(`\n${B("Partly set.")} ${D("The agents that did not fail above connect once you restart them; fix the rest, then: pantheon install --user")}`);
551
+ else
552
+ console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
475
553
  if (workerOn)
476
- console.error(D(` While you're away, a teammate you grant may-wake can hand work to this machine —`));
554
+ console.error(D(` While you're away, a teammate you approve to wake this machine can hand work to it —`));
477
555
  if (workerOn)
478
556
  console.error(D(` isolated branch, no secrets in the worktree. Pause any time from the menu bar.`));
479
557
  const oneTime = new Map();
@@ -497,9 +575,17 @@ function printReady(tools) {
497
575
  console.error(D(` ${l}`));
498
576
  }
499
577
  }
500
- const B = (s) => `\x1b[1m${s}\x1b[0m`;
501
- const D = (s) => `\x1b[2m${s}\x1b[0m`;
502
- const C = (s) => `\x1b[36m${s}\x1b[0m`;
578
+ const PLAIN = !!process.env["NO_COLOR"];
579
+ if (PLAIN) {
580
+ const SGR = /\x1b\[[0-9;]*m/g;
581
+ for (const stream of [process.stdout, process.stderr]) {
582
+ const write = stream.write.bind(stream);
583
+ stream.write = ((chunk, ...rest) => write(typeof chunk === "string" ? chunk.replace(SGR, "") : chunk, ...rest));
584
+ }
585
+ }
586
+ const B = (s) => PLAIN ? s : `\x1b[1m${s}\x1b[0m`;
587
+ const D = (s) => PLAIN ? s : `\x1b[2m${s}\x1b[0m`;
588
+ const C = (s) => PLAIN ? s : `\x1b[36m${s}\x1b[0m`;
503
589
  const HELP = `${B("pantheon")} — your agent and your teammates' agents, working together
504
590
 
505
591
  ${B("first time — verify your email once")}
@@ -521,14 +607,14 @@ ${B("getting going — two commands, from your project's repo if you have one")}
521
607
 
522
608
  One command does everything: agents wired and pre-approved, wake-up hook (your
523
609
  agent finishes what a peer is waiting for), worker (this machine answers the
524
- team while you are away — on when run from a git repo; a peer still needs an
525
- explicit may-wake grant to use it), native decision alerts, menu bar. Opt-outs:
610
+ team while you are away — on when run from a git repo; a peer still needs you
611
+ to approve them here to use it: pantheon wake allow), native decision alerts, menu bar. Opt-outs:
526
612
  --no-worker, --no-hook, --no-notifications, --no-menubar.
527
613
  Already a member, new machine or fresh pull? pantheon install --user does the same.
528
614
 
529
615
  ${B("several teams on one machine")}
530
616
  pantheon spaces your teams, which repo is bound to which, and where this folder points
531
- pantheon use <team> --here bind this repo to a team (agents opened here speak in it)
617
+ pantheon use <team> --here bind this repo or folder to a team (agents opened here speak in it); --unbind undoes it
532
618
  pantheon repo add | list the repos a team works from; a delivery must come from one of them
533
619
  (repo add --base <branch>: the branch workers cut from, else origin's default)
534
620
  pantheon leave <team> drop one team from this machine (its token and repo bindings)
@@ -544,7 +630,9 @@ ${B("day to day")}
544
630
  pantheon logs [-f] the relay's log
545
631
 
546
632
  ${B("control")}
547
- pantheon admit <who> let someone in (--may-wake to allow unattended work here)
633
+ pantheon admit <who> let someone in (--may-wake: the team lets them wake workers, and this machine allows them)
634
+ pantheon wake allow <who> let a teammate wake THIS machine; wake deny <who> takes it back; wake list shows who can
635
+ (this machine only — nothing is sent to the team; --space <team> to pick one)
548
636
  pantheon admit <who> --revoke remove them
549
637
  pantheon worker stay answerable while you are away
550
638
  (--agent claude-code|codex|cursor|copilot|kiro|auto, --persona <name>)
@@ -575,14 +663,14 @@ ${B("control")}
575
663
 
576
664
  ${D("more: pantheon help --all")}
577
665
  `;
578
- const HELP_ALL = HELP + `
666
+ const HELP_ALL = HELP.replace(/\n[^\n]*more: pantheon help --all[^\n]*\n?$/, "\n") + `
579
667
  ${B("less common")}
580
668
  pantheon relay run only the relay, in this terminal
581
669
  pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
582
670
  run a long-lived component in the background, from login
583
671
  pantheon members the full list, with key fingerprints
584
- pantheon whoami your name and fingerprint
585
672
  pantheon pending what a live agent window has not yet seen
673
+ pantheon goal open "<title>" --with <who> open a goal to work in (goal list · goal close <id> "<summary>")
586
674
  pantheon say <ACT> <text> send one act by hand (NOTE, ASK, ANSWER, PROPOSE, ...)
587
675
  replies pick the act they answer; --ref <id | seq | last> to choose
588
676
  pantheon history the thread in full, with ids (--goal <id>, --from <who>, --last N, --short)
@@ -607,6 +695,30 @@ function usageFor(command, help) {
607
695
  }
608
696
  return out.length ? out.join("\n") : null;
609
697
  }
698
+ const COMMAND_FLAGS = {
699
+ start: "--space <name> --relay <url> --me <name> --port <n> --ttl <minutes> --no-worker --no-hook --no-notifications --no-menubar --reset --yes\n and every setup answer as a flag (--name, --title, --wake, --commands, --alerts, --decides …): pantheon setup --questions",
700
+ join: "<invite link | line | code> --relay <url> --me <name> --no-worker --no-hook --no-notifications --no-menubar --reset --yes\n and every setup answer as a flag: pantheon setup --questions",
701
+ invite: "--list --revoke <token|all> --may-wake | --no-wake --ttl <minutes>",
702
+ admit: "<member> --may-wake | --no-wake --revoke --forget --yes",
703
+ wake: "allow <member> | deny <member> | list --space <team>",
704
+ say: "<ACT> <text> --to <member>[,<member>] --goal <id> --ref <id | seq | last> --summary '<one line>' --domains <a,b> --branch --commit --remote (DELIVERED) --space <team>",
705
+ goal: "list | open \"<title>\" --with <member>[,…] [--acceptance \"…\"] [--deadline <ISO time>] [--list] | close <id> \"<summary>\"",
706
+ history: "--goal <id> --from <member> --last <n> --since <seq> --short --presence --json",
707
+ network: "--proxy <url> [--no-proxy <hosts>] | --direct | --clear",
708
+ leave: "[<team>] --yes --keep-identity --stop-relay --project <dir>",
709
+ stop: "[relay|worker|notifier|menubar|all] --port <n>",
710
+ logs: "[relay|worker|notifier|menubar] --follow --n <lines>",
711
+ worker: "--agent <claude-code|codex|cursor|copilot|kiro|auto> --persona <name> --project <dir> --allow-bash --max-runs <n> --max-runs-day <n> --grace <s> --once",
712
+ service: "install [relay|worker|notifier|menubar] | restart <kind> | uninstall <kind> --project <dir> --allow-bash",
713
+ doctor: "--share --project <dir>",
714
+ relay: "--port <n> --host <address> --data <dir>",
715
+ use: "<team> --here --yes · pantheon use --unbind (this folder or repo bound to no team again)",
716
+ repo: "add [--base <branch>] | list [--all]",
717
+ login: "--email <address> --relay <url> --label <device name>",
718
+ signup: "--email <address> --username <name> --relay <url> --label <device name>",
719
+ audit: "--space <team> --since <hours> --json",
720
+ setup: "--questions (and every answer as a flag, with --yes)",
721
+ };
610
722
  const HELP_OPAQUE = new Set(["mcp", "hook", "pending", "tray-status", "help", "version", "-v", "--help", "-h"]);
611
723
  function rememberWorkingProxy() {
612
724
  try {
@@ -622,7 +734,54 @@ function rememberWorkingProxy() {
622
734
  }
623
735
  catch { }
624
736
  }
737
+ async function teamStanding(all, m) {
738
+ const c = new PantheonClient(configFor(all, m, null), undefined, { role: "cli" });
739
+ try {
740
+ await Promise.race([c.connect(), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 8_000))]);
741
+ return { others: c.members.filter((x) => x.admitted && x.member !== all.member).map((x) => x.member), founder: !!c.members.find((x) => x.member === all.member)?.founder };
742
+ }
743
+ catch {
744
+ return { others: null, founder: false };
745
+ }
746
+ finally {
747
+ c.close();
748
+ }
749
+ }
750
+ function slipDistance(a, b) {
751
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
752
+ for (let j = 1; j <= b.length; j++)
753
+ d[0][j] = j;
754
+ for (let i = 1; i <= a.length; i++)
755
+ for (let j = 1; j <= b.length; j++) {
756
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
757
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
758
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
759
+ }
760
+ return d[a.length][b.length];
761
+ }
762
+ const EXTRA_FLAGS = "--agent-cmd --approve-all --ask-before-sending --child --code --data --device --display --force --help --host --idle --keywords --local " +
763
+ "--no-continue --no-guide --no-notify --path --peek --poll --portable --publish --queued --quiet --remind --scratch --session --surface --token --unattended " +
764
+ "--until --user --version --all --json " +
765
+ "--name --title --wake --commands --alerts --decides --playbook --base --responsibilities --human-required --agent-can-decide --capabilities";
766
+ const KNOWN_FLAGS = new Set([...`${Object.values(COMMAND_FLAGS).join(" ")} ${EXTRA_FLAGS}`.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1]));
767
+ function refuseUnknownFlags() {
768
+ if (["mcp", "hook", "tray-status", "_menubar"].includes(cmd))
769
+ return;
770
+ for (const a of argv.slice(1)) {
771
+ if (!/^--[a-z][a-z0-9-]*(?:=\S*)?$/i.test(a))
772
+ continue;
773
+ const name = a.slice(2).split("=")[0].toLowerCase();
774
+ if (KNOWN_FLAGS.has(name))
775
+ continue;
776
+ const own = [...(COMMAND_FLAGS[cmd] ?? "").matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1]);
777
+ const rank = (ks) => ks.map((k) => ({ k, d: slipDistance(name, k) })).sort((x, y) => x.d - y.d)[0];
778
+ const mine = rank(own);
779
+ const near = mine && mine.d <= 2 ? mine : rank([...KNOWN_FLAGS]);
780
+ die(`unknown flag ${a}${near && near.d <= 2 ? ` — did you mean --${near.k}?` : ""}\n Nothing was run. pantheon ${cmd} --help shows its usage${cmd === "say" ? "; if it is part of your message, put the message in quotes" : ""}.`);
781
+ }
782
+ }
625
783
  async function main() {
784
+ refuseUnknownFlags();
626
785
  try {
627
786
  await installNetwork();
628
787
  }
@@ -641,11 +800,15 @@ async function main() {
641
800
  }
642
801
  return interactiveOnboard();
643
802
  }
644
- if (!HELP_OPAQUE.has(cmd)) {
803
+ const askedHelpOnly = argv.length === 2 && (argv[1] === "--help" || argv[1] === "-h") && ["pending", "hook"].includes(cmd);
804
+ if (!HELP_OPAQUE.has(cmd) || askedHelpOnly) {
645
805
  const helpAt = cmd === "say" ? [argv[1]] : argv.slice(1);
646
806
  if (helpAt.some((a) => a === "--help" || a === "-h")) {
647
807
  const u = usageFor(cmd, HELP_ALL);
648
- console.error(u ? `${u}\n\n${D("nothing was run · all commands: pantheon help --all")}` : HELP);
808
+ const flags = COMMAND_FLAGS[cmd];
809
+ const teamCmd = !["start", "join", "signup", "login", "verify", "whoami", "relay", "help", "version", "upgrade", "network", "ps", "stop", "service", "hook", "mcp", "pending", "tray-status"].includes(cmd);
810
+ const shown = flags && teamCmd && !/--space/.test(flags) ? `${flags}\n --space <team> (the team to act on, when this folder is bound to another or none)` : flags;
811
+ console.log(u || shown ? `${u ?? ` pantheon ${cmd}`}${shown ? `\n\n${B("flags")}\n ${shown}` : ""}\n\n${D("nothing was run · all commands: pantheon help --all")}` : HELP);
649
812
  return;
650
813
  }
651
814
  }
@@ -656,7 +819,28 @@ async function main() {
656
819
  if (set !== undefined) {
657
820
  const p = normalizeProxy(set) ?? die(`"${redactText(set)}" is not a proxy address — use http://host:port (add user:password@ if it needs a login)`);
658
821
  saveAll({ ...all, network: { proxy: p, ...(flag("no-proxy") ? { no_proxy: flag("no-proxy") } : {}), source: "pantheon network", saved_at: Date.now() } });
659
- console.error(`${B("saved")} every Pantheon process on this machine now reaches the relay through ${redactProxy(p)}`);
822
+ const knock = await (async () => {
823
+ try {
824
+ const u = new URL(p);
825
+ const { connect } = await import("node:net");
826
+ const port = Number(u.port) || (u.protocol === "https:" ? 443 : 80);
827
+ return await new Promise((done) => {
828
+ const sock = connect({ host: u.hostname, port, timeout: 3_000 });
829
+ sock.once("connect", () => { sock.destroy(); done(null); });
830
+ sock.once("timeout", () => { sock.destroy(); done("did not answer in 3s"); });
831
+ sock.once("error", (e) => done(e.code === "ENOTFOUND" ? "its name did not resolve" : e.code === "ECONNREFUSED" ? "nothing is listening there" : e.message));
832
+ });
833
+ }
834
+ catch (e) {
835
+ return e.message;
836
+ }
837
+ })();
838
+ if (knock) {
839
+ console.error(`${B("saved")} ${redactProxy(p)} — but right now ${knock}. Every Pantheon process here will try it; check the address, or undo: pantheon network --clear`);
840
+ }
841
+ else {
842
+ console.error(`${B("saved")} every Pantheon process on this machine will go through ${redactProxy(p)} (it answered). pantheon doctor checks the relay through it.`);
843
+ }
660
844
  return;
661
845
  }
662
846
  if (argv.includes("--direct")) {
@@ -686,13 +870,14 @@ async function main() {
686
870
  die(setupFlags.errors.join("\n") + "\n pantheon setup --questions lists every answer");
687
871
  const wp = unattendedProject();
688
872
  requireSignInOnHostedRelay(flag("relay"));
873
+ await requireSignInOnAccountsRelay(flag("relay"));
689
874
  console.error(`${B("pantheon start")}\n`);
690
875
  if (!flag("space") && !repoInfo(process.cwd()))
691
876
  console.error(D(` No git repo here, so the team will be named after this folder: "${teamNameFor(process.cwd())}". Pass --space <name> to choose.\n`));
692
877
  const r = await qsStart({
693
878
  port,
694
879
  dataDir: resolveDataDir(flag("data")).dir,
695
- ttlMinutes: Number(flag("ttl", "1440")),
880
+ ttlMinutes: numFlag("ttl", 1440),
696
881
  reset: argv.includes("--reset"),
697
882
  ...(flag("space") ? { space: flag("space") } : {}),
698
883
  ...(flag("me") ? { me: flag("me") } : {}),
@@ -708,7 +893,7 @@ async function main() {
708
893
  for (const t of r.tools)
709
894
  console.error(` ${t.tool.padEnd(14)}${t.status}`);
710
895
  for (const w of r.warnings)
711
- console.error(`\x1b[33m ! ${w}\x1b[0m`);
896
+ console.error(`\x1b[33m ! ${w}\x1b[0m` + (/NOT_SIGNED_IN/.test(w) ? signInHint("NOT_SIGNED_IN", r.cfg.relay) : ""));
712
897
  printGranted(r.granted);
713
898
  rememberWorkingProxy();
714
899
  if (!r.claimed) {
@@ -729,8 +914,11 @@ async function main() {
729
914
  console.error(`\n${B("Send your teammate this one line — it sets up their whole machine:")}\n`);
730
915
  console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
731
916
  }
732
- console.error(D(` From their project's repo if they have one — a repo is only needed to hand over code or to be woken. Single use, expires in ${Math.round(Number(flag("ttl", "1440")) / 60)}h.`));
917
+ console.error(D(` From their project's repo if they have one — a repo is only needed to hand over code or to be woken. Single use, expires in ${Math.round(numFlag("ttl", 1440) / 60)}h.`));
733
918
  console.error(D(` Redeeming it admits them — no separate approval step. Need another? \`pantheon invite\``));
919
+ console.error(r.inviteMayWake
920
+ ? ` ${B("It also lets them wake workers:")} ${D("their requests can start an agent run on this machine while you are away (you chose wake \"anyone\"). Take it back: pantheon admit <name> --revoke")}`
921
+ : D(` It lets them join and talk to your agent. It does NOT let them wake this machine: approve a person on this machine with pantheon wake allow <name>.`));
734
922
  console.error(D(`\n Not technical? Forward them this instead, and their agent does the rest:`));
735
923
  console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${link ?? r.invite}`)}`);
736
924
  }
@@ -774,11 +962,11 @@ async function main() {
774
962
  for (const i of open)
775
963
  console.log(`${i.mine ? C(i.token) : D(i.token)} by ${i.created_by} expires ${new Date(i.expires_at).toLocaleString()}${i.may_wake ? " may wake" : ""}${i.mine ? "" : D(" (not yours — masked)")}`);
776
964
  if (open.length)
777
- console.log(D(`\nrevoke one: pantheon invite --revoke <token or its last 6 characters> · all of yours: pantheon invite --revoke all`));
965
+ console.log(D(`\nrevoke one: pantheon invite --revoke <token or its last 6 characters>${teamFlag(cfg)} · all of yours: pantheon invite --revoke all${teamFlag(cfg)}`));
778
966
  client.close();
779
967
  return;
780
968
  }
781
- const ttl = Number(flag("ttl", "1440"));
969
+ const ttl = numFlag("ttl", 1440);
782
970
  const mayWake = argv.includes("--may-wake") ? true : argv.includes("--no-wake") ? false : wakeDefault(loadProfile(cfg));
783
971
  const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
784
972
  const raw = toInvite({ ...cfg, token: inv.url });
@@ -793,7 +981,24 @@ async function main() {
793
981
  return;
794
982
  }
795
983
  case "relay": {
984
+ for (const s of [process.stdout, process.stderr])
985
+ s.on("error", () => { });
796
986
  process.on("unhandledRejection", (e) => { console.error(`! unhandled rejection: ${e?.stack ?? String(e)}`); });
987
+ for (const s of [process.stdout, process.stderr])
988
+ s.on("error", () => { });
989
+ let inHandler = false;
990
+ process.on("uncaughtException", (e) => {
991
+ if (e?.code === "EPIPE" || e?.code === "ERR_STREAM_DESTROYED" || inHandler)
992
+ return;
993
+ inHandler = true;
994
+ try {
995
+ console.error(`! uncaught exception (kept serving): ${e?.stack ?? String(e)}`);
996
+ }
997
+ catch { }
998
+ finally {
999
+ inHandler = false;
1000
+ }
1001
+ });
797
1002
  const relay = new Relay({
798
1003
  port: Number(flag("port", process.env.PORT ?? "8787")),
799
1004
  host: flag("host", "0.0.0.0"),
@@ -804,7 +1009,7 @@ async function main() {
804
1009
  console.error(D(`ledger: ${resolveDataDir(flag("data")).dir}`));
805
1010
  console.error(D(`health: http://localhost:${port}/health`));
806
1011
  console.error(D(`\nFor a colleague on another network, expose this port (ssh -R, cloudflared, tailscale)`));
807
- console.error(D(`and hand them the invite from \`pantheon init\`. Ctrl-C to stop.`));
1012
+ console.error(D(`then, on your own machine: pantheon start --relay <this address>, and send them what pantheon invite prints. Ctrl-C to stop.`));
808
1013
  let shuttingDown = false;
809
1014
  const shutdown = async (sig) => {
810
1015
  if (shuttingDown)
@@ -877,7 +1082,8 @@ async function main() {
877
1082
  catch (e) {
878
1083
  if (!looksLikeInviteCode(invite))
879
1084
  die(e.message);
880
- const candidates = [flag("relay"), process.env.PANTHEON_RELAY, load()?.relay, DEFAULT_RELAY].filter((r) => !!r);
1085
+ const named = flag("relay") ?? process.env.PANTHEON_RELAY;
1086
+ const candidates = (named ? [named] : [load()?.relay, DEFAULT_RELAY]).filter((r) => !!r);
881
1087
  parsed = await resolveInviteCode(invite, candidates).catch((err) => die(err.message.replace(/^INVITE_CODE_[A-Z]+:\s*/, "")));
882
1088
  console.error(D(`That was just the code — it opens "${parsed.space}" on ${new URL(parsed.relay).host}. Joining that team.\n`));
883
1089
  }
@@ -900,21 +1106,28 @@ async function main() {
900
1106
  const m = e.message;
901
1107
  if (/BAD_INVITE/.test(m)) {
902
1108
  const body = m.replace(/^BAD_INVITE:\s*/, "");
903
- die(/\n/.test(body) ? body : `${body}\n Ask your teammate for a fresh one: pantheon invite`);
1109
+ die(/\n/.test(body) || /fresh one|new invite/i.test(body) ? body : `${body}\n Ask your teammate for a fresh one: pantheon invite`);
904
1110
  }
905
1111
  if (/NO_SUCH_SPACE/.test(m))
906
- die(`${m}\n Their relay may not be running, or the link points somewhere else.`);
1112
+ die(`${m}\n The relay answered, but has no team by that name — it was deleted, or the link was copied from somewhere else. Ask your teammate for a fresh invite.`);
907
1113
  if (/ECONNREFUSED|ENOTFOUND|timeout/i.test(m))
908
1114
  die(`cannot reach the relay in that invite.\n ${m}\n Is their relay still running, and are you both on the same network/tailnet?`);
909
- die(m);
1115
+ die(m + (/NOT_SIGNED_IN/.test(m) ? signInHint("NOT_SIGNED_IN", parsed.relay) : ""));
910
1116
  });
911
1117
  for (const t of r.tools)
912
1118
  console.error(` ${t.tool.padEnd(14)}${t.status}`);
913
1119
  for (const w of r.warnings)
914
- console.error(`\x1b[33m ! ${w}\x1b[0m`);
1120
+ console.error(`\x1b[33m ! ${w}\x1b[0m` + (/NOT_SIGNED_IN/.test(w) ? signInHint("NOT_SIGNED_IN", parsed.relay) : ""));
915
1121
  printGranted(r.granted);
916
1122
  rememberWorkingProxy();
917
1123
  printReady(r.tools);
1124
+ {
1125
+ const jc = load();
1126
+ const mode = jc ? loadProfile(jc).agent.wake_default ?? "approved" : "approved";
1127
+ console.error(D(mode === "anyone"
1128
+ ? `\n You chose wake "anyone": teammates this team lets wake workers can start an agent run on this machine. Change it: pantheon setup`
1129
+ : `\n Nobody on the team can wake this machine until you approve them, here: pantheon wake allow <name>${mode === "no" ? "" : " (or choose \"anyone\" below)"}.`));
1130
+ }
918
1131
  {
919
1132
  const joined = load();
920
1133
  if (joined)
@@ -1069,6 +1282,8 @@ async function main() {
1069
1282
  return;
1070
1283
  }
1071
1284
  case "setup": {
1285
+ if (inWokenRun() && !argv.includes("--questions"))
1286
+ die("a woken run cannot change this machine's setup — a person has to run this here");
1072
1287
  if (argv.includes("--questions")) {
1073
1288
  console.log(questionnaire());
1074
1289
  return;
@@ -1103,15 +1318,20 @@ async function main() {
1103
1318
  console.log(B("members"));
1104
1319
  if (!peers.length)
1105
1320
  console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
1321
+ const seens = peers.map((p) => p.seen_seq).filter((x) => x !== undefined && x < client.head);
1322
+ const since = seens.length ? Math.min(...seens) : null;
1323
+ const recent = since === null ? [] : await fetchHistory(cfg, { since }).catch(() => null);
1106
1324
  for (const p of peers) {
1107
- const behind = p.seen_seq === undefined ? null : client.head - p.seen_seq;
1325
+ const lag = p.seen_seq === undefined ? null : client.head - p.seen_seq;
1326
+ const behind = lag === null ? null : lag <= 0 ? 0
1327
+ : recent ? recent.filter((a) => a.seq > p.seen_seq && a.from === cfg.member && a.act !== "JOIN" && a.act !== "LEAVE").length : lag;
1108
1328
  const reach = behind === null
1109
1329
  ? D(p.delivered_seq !== undefined && p.delivered_seq >= client.head
1110
1330
  ? "delivered, but no read-receipt — no wake-up hook on their machine"
1111
1331
  : "no wake-up hook on their machine — nothing surfaces until their human types")
1112
1332
  : behind > 0
1113
- ? `\x1b[33mtheir agent has not seen your last ${behind} act${behind === 1 ? "" : "s"}\x1b[0m`
1114
- : D("their agent is caught up");
1333
+ ? `\x1b[33m${recent ? `their agent has not seen your last ${behind} act${behind === 1 ? "" : "s"}` : `their agent is ${behind} act${behind === 1 ? "" : "s"} behind the team`}\x1b[0m`
1334
+ : lag > 0 ? D("their agent has seen everything you sent") : D("their agent is caught up");
1115
1335
  console.log(` ${p.online ? C("●") : D("○")} ${p.id.padEnd(14)} ${D(p.agent.padEnd(12))} ${reach}`);
1116
1336
  }
1117
1337
  const goals = [...client.goals.values()];
@@ -1174,7 +1394,7 @@ async function main() {
1174
1394
  owed: client.myObligations().map((o) => ({
1175
1395
  id: o.id, kind: o.kind, body: o.body, created_at: o.created_at,
1176
1396
  })),
1177
- recentRuns: recentRuns(86_400_000),
1397
+ recentRuns: recentRuns(86_400_000, undefined, 12, { teams: [client.spaceId, cfg.spaceId, cfg.space], member: cfg.member }),
1178
1398
  }));
1179
1399
  client.close();
1180
1400
  }
@@ -1217,16 +1437,14 @@ async function main() {
1217
1437
  console.log(` ${line}`);
1218
1438
  console.log();
1219
1439
  };
1220
- const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
1221
- const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
1222
- const first = await fetch(`${base}/history?${q}`).catch((e) => die(describeNetError(e, cfg.relay)));
1440
+ const first = await signedRead(cfg, "/history").catch((e) => die(describeNetError(e, cfg.relay)));
1223
1441
  if (!first.ok)
1224
- die(`relay answered ${first.status} — check the space name and token`);
1442
+ die((await readFailure(cfg, first, "the ledger")).message);
1225
1443
  let since = ((await first.json()).acts ?? []).at(-1)?.seq ?? 0;
1226
1444
  console.log(`${B("watching")} ${cfg.space} ${D("(ctrl-c to stop)")}\n`);
1227
1445
  for (;;) {
1228
1446
  await new Promise((r) => setTimeout(r, LIMITS.watchPollMs));
1229
- const res = await fetch(`${base}/history?${q}&since=${since}`).catch(() => null);
1447
+ const res = await signedRead(cfg, "/history", { since }).catch(() => null);
1230
1448
  if (!res?.ok)
1231
1449
  continue;
1232
1450
  for (const act of (await res.json()).acts ?? []) {
@@ -1241,6 +1459,8 @@ async function main() {
1241
1459
  if (!report) {
1242
1460
  if (argv.includes("--json"))
1243
1461
  console.log(JSON.stringify({ seq: 0, acts: [], obligations: [] }));
1462
+ else if (process.stderr.isTTY)
1463
+ console.error(D(`nothing new for this session${load() ? "" : " — this folder resolves to no team (pantheon status says why)"}`));
1244
1464
  return;
1245
1465
  }
1246
1466
  console.log(argv.includes("--json") ? JSON.stringify(report) : renderPending(report));
@@ -1275,7 +1495,8 @@ async function main() {
1275
1495
  }
1276
1496
  for (const a of picked)
1277
1497
  console.log(formatAct(a, { short: argv.includes("--short"), ...(a.goal && titles.get(a.goal) ? { goalTitle: titles.get(a.goal) } : {}) }) + "\n");
1278
- console.log(D(`${picked.length} of ${acts.filter((a) => a.act !== "JOIN" && a.act !== "LEAVE").length} acts · --last N · --goal <id> · --from <member> · --short · --json · reply: pantheon say ANSWER "…" --ref <id or seq>`));
1498
+ const pool = argv.includes("--presence") ? acts.length : acts.filter((a) => a.act !== "JOIN" && a.act !== "LEAVE").length;
1499
+ console.log(D(`${picked.length} of ${pool} acts${argv.includes("--presence") ? " (joins and leaves included)" : ""} · --last N · --goal <id> · --from <member> · --short · --json · reply: pantheon say ANSWER "…" --ref <id or seq>`));
1279
1500
  return;
1280
1501
  }
1281
1502
  case "show": {
@@ -1291,13 +1512,66 @@ async function main() {
1291
1512
  }
1292
1513
  return;
1293
1514
  }
1515
+ case "goal": {
1516
+ const cfg = need();
1517
+ const sub = argv[1] ?? "list";
1518
+ const client = new PantheonClient(cfg, undefined, { role: "cli" });
1519
+ await client.connect().catch((e) => die(connectFailure(e, cfg.relay)));
1520
+ try {
1521
+ if (sub === "list") {
1522
+ const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
1523
+ if (!open.length)
1524
+ console.log(D("no open goals — pantheon goal open \"<what it is about>\" --with <member>"));
1525
+ for (const g of open)
1526
+ console.log(` ${g.id} ${g.state.padEnd(8)} ${g.title} ${D(`with ${g.members.join(", ")} · ${g.acts}/${g.budget} acts`)}`);
1527
+ return;
1528
+ }
1529
+ if (sub === "open") {
1530
+ const VALUE_FLAGS = new Set(["--with", "--acceptance", "--deadline", "--space"]);
1531
+ const title = argv.slice(2).filter((a, i, all) => !a.startsWith("--") && !(i > 0 && VALUE_FLAGS.has(all[i - 1]))).join(" ").trim()
1532
+ || die('usage: pantheon goal open "<what it is about>" --with <member>[,<member>] [--acceptance "<what done looks like>"] [--deadline <ISO time>] [--list]');
1533
+ const withs = cleanList((flag("with") ?? "").split(","));
1534
+ const dl = flag("deadline");
1535
+ const deadline = dl ? Date.parse(dl) : undefined;
1536
+ if (dl && !Number.isFinite(deadline))
1537
+ die(`--deadline takes a time like 2026-10-01T17:00:00Z, not "${dl}"`);
1538
+ const g = await client.openGoal({ title, acceptance: flag("acceptance") ?? "", ...(withs.length ? { invite: withs } : {}),
1539
+ ...(deadline ? { deadline } : {}), ...(argv.includes("--list") ? { kind: "worklist" } : {}) }).catch((e) => die(e.message));
1540
+ console.log(`opened ${g.id} "${title}"${withs.length ? ` with ${withs.join(", ")}` : ""}`);
1541
+ console.error(D(` now: pantheon say ASK "…" --to <member> --goal ${g.id}`));
1542
+ return;
1543
+ }
1544
+ if (sub === "close") {
1545
+ const id = argv[2] ?? die('usage: pantheon goal close <goal id> "<what was done>"');
1546
+ const summary = argv.slice(3).filter((a) => !a.startsWith("--")).join(" ").trim() || die("say what was done: it is the goal's record");
1547
+ const r = await client.send({ act: "CLOSE", body: summary, goal: id }).catch((e) => die(e.message));
1548
+ console.log(`closed ${id} (act ${r.id})`);
1549
+ return;
1550
+ }
1551
+ die("usage: pantheon goal list | open \"<title>\" --with <member> | close <id> \"<summary>\"");
1552
+ }
1553
+ finally {
1554
+ client.close();
1555
+ }
1556
+ }
1294
1557
  case "say": {
1295
1558
  const cfg = need();
1296
1559
  const act = argv[1]?.toUpperCase() ?? die("usage: pantheon say <ACT> <body> [--goal id] [--to a,b] [--ref <id | seq | last>] [--summary 'one line'] [--domains technical,gtm]");
1560
+ {
1561
+ const sayable = ACTS.filter((a) => a !== "JOIN" && a !== "LEAVE");
1562
+ if (!sayable.includes(act))
1563
+ die(`"${argv[1]}" is not an act. One of: ${sayable.join(", ")}`);
1564
+ }
1297
1565
  const stop = argv.findIndex((a, i) => i >= 2 && a.startsWith("--"));
1298
1566
  const body = argv.slice(2, stop === -1 ? undefined : stop).join(" ");
1299
1567
  if (!body)
1300
1568
  die("nothing to say — pantheon say NOTE 'your message here'");
1569
+ {
1570
+ const SAY_FLAGS = new Set([...COMMAND_FLAGS["say"].matchAll(/--([a-z][a-z-]*)/g)].map((m) => m[1]));
1571
+ const stray = argv.slice(stop === -1 ? argv.length : stop).find((a) => /^--[a-z]/i.test(a) && !SAY_FLAGS.has(a.slice(2).toLowerCase()) && a !== "--help");
1572
+ if (stray)
1573
+ die(`${stray} is not a flag of say, so it reads as part of your message — and the message would have stopped before it. Put the whole message in quotes: pantheon say ${act} "…"`);
1574
+ }
1301
1575
  const summary = flag("summary");
1302
1576
  const client = new PantheonClient(cfg, undefined, { role: "cli" });
1303
1577
  await client.connect().catch((e) => die(connectFailure(e, cfg.relay)));
@@ -1315,15 +1589,25 @@ async function main() {
1315
1589
  let ref = flag("ref") ?? null;
1316
1590
  if (ref !== null || ["ANSWER", "ACCEPT", "OBJECT"].includes(act)) {
1317
1591
  let acts;
1592
+ let settled = null;
1593
+ let answered = new Set();
1318
1594
  if (ref && ref !== "last") {
1319
1595
  const hit = (await fetchAct(cfg, ref))?.act;
1320
1596
  acts = hit ? [hit] : [];
1321
- if (!hit && /^a_/.test(ref))
1322
- console.error(D(` ${ref} is not in what this relay holds — sending it as given`));
1323
1597
  }
1324
- else
1598
+ else {
1325
1599
  acts = await fetchHistory(cfg, goalArg ? { goal: goalArg } : {}).catch(() => []);
1600
+ answered = new Set(acts.filter((a) => a.from === cfg.member && a.ref).map((a) => a.ref));
1601
+ const closed = new Set([...client.goals.values()].filter((g) => g.state === "CLOSED").map((g) => g.id));
1602
+ const all = acts;
1603
+ acts = acts.filter((a) => !answered.has(a.id) && !(a.goal && closed.has(a.goal)));
1604
+ settled = all.filter((a) => a.from !== cfg.member && (answered.has(a.id) || (!!a.goal && closed.has(a.goal))) && (!a.to?.length || a.to.includes(cfg.member))).at(-1) ?? null;
1605
+ }
1326
1606
  const rr = resolveRef(acts, ref, act, goalArg, cfg.member);
1607
+ if (rr.error && settled) {
1608
+ client.close();
1609
+ die(`${rr.error}\n The latest one to you, [${settled.seq}] ${settled.act} from ${settled.from}, is already ${answered.has(settled.id) ? "answered" : "in a closed goal"} — to answer it again: --ref ${settled.seq}`);
1610
+ }
1327
1611
  if (rr.error) {
1328
1612
  client.close();
1329
1613
  die(rr.error);
@@ -1340,8 +1624,17 @@ async function main() {
1340
1624
  ref,
1341
1625
  ...(summary ? { summary } : {}),
1342
1626
  ...(Object.keys(meta).length ? { meta } : {}),
1343
- }).catch((e) => die(/SUMMARY_REQUIRED/.test(e.message) ? `${e.message}\n Add one: --summary 'the point they must not miss'` : e.message));
1627
+ }).catch((e) => die(/SUMMARY_REQUIRED/.test(e.message) ? `${e.message}\n Add one: --summary 'the point they must not miss'`
1628
+ : /UNVERIFIABLE_DELIVERY/.test(e.message) ? `${e.message}\n From here: pantheon say DELIVERED "…" --branch <the pushed branch> --commit <its sha>`
1629
+ : /BAD_REF/.test(e.message) ? `${e.message}\n pantheon history lists the acts with their ids and [sequence numbers]; --ref takes either, or "last"`
1630
+ : /TO_REQUIRED/.test(e.message) ? `${e.message}\n Name who owes it: --to <member> (pantheon team lists them)`
1631
+ : /GOAL_REQUIRED|NO_SUCH_GOAL|GOAL_CLOSED/.test(e.message) ? `${e.message.replace(/ — open one with pantheon_open_goal/, "").replace(/pantheon_open_goal/g, "pantheon goal open")}\n Open one from here: pantheon goal open "<what it is about>" --with <member> then add --goal <id> (pantheon goal list shows the open ones)`
1632
+ : e.message));
1344
1633
  console.log(`sent ${act} id=${r.id} seq=${r.seq}`);
1634
+ if (flag("space"))
1635
+ console.error(D(` in "${cfg.space}"`));
1636
+ else if (resolveFor(process.cwd()).reason === "default")
1637
+ console.error(D(` in "${cfg.space}" — your default team; this folder is bound to none (pantheon use <team> --here)`));
1345
1638
  client.close();
1346
1639
  return;
1347
1640
  }
@@ -1373,7 +1666,7 @@ async function main() {
1373
1666
  }
1374
1667
  if (worker.running) {
1375
1668
  console.log(` worker ${C("running")} ${worker.managedBy === "launchd" ? `service ${worker.label}` : "started by hand"} pid ${worker.pid}`);
1376
- console.log(` ${D("a peer you granted --may-wake can start an agent run here")}`);
1669
+ console.log(` ${D("a teammate approved on this machine (pantheon wake list) can start an agent run here")}`);
1377
1670
  }
1378
1671
  else {
1379
1672
  console.log(` worker ${D("not running")} ${D("nobody can start an agent run on this machine")}`);
@@ -1452,9 +1745,10 @@ async function main() {
1452
1745
  if (!["all", "relay", "worker", "notifier", "menubar"].includes(what))
1453
1746
  die(`usage: pantheon stop [relay|worker|notifier|menubar|all]`);
1454
1747
  const { stopMachine } = await import("./machine.js");
1748
+ const { killRecordedRun } = await import("./worker.js");
1455
1749
  console.log(B("stopping"));
1456
1750
  const everyTeam = Object.values(loadAll()?.spaces ?? {}).map((m) => ({ name: m.name, relay: m.relay }));
1457
- const res = stopMachine(what, { port: Number(flag("port", "8787")), teams: everyTeam.length ? everyTeam : (load()?.relay ? [{ name: load().space, relay: load().relay }] : []) });
1751
+ const res = stopMachine(what, { port: Number(flag("port", "8787")), teams: everyTeam.length ? everyTeam : (load()?.relay ? [{ name: load().space, relay: load().relay }] : []), killRun: () => killRecordedRun(dirname(CONFIG_PATH)) });
1458
1752
  for (const l of res.lines)
1459
1753
  console.log(` ${l}`);
1460
1754
  if (res.note)
@@ -1469,12 +1763,21 @@ async function main() {
1469
1763
  die(`no ${kind} log found. ${process.platform === "linux" ? `If it runs as a service: journalctl --user -u pantheon-${kind}` : process.platform === "darwin" ? "If it runs as a service, the plist names the path" : "Services are not used on Windows"}; if you started it by hand, its output went to that terminal.`);
1470
1764
  console.error(D(`${f} (${statFile(f)})\n`));
1471
1765
  const follow = argv.includes("-f") || argv.includes("--follow");
1472
- const { spawn } = await import("node:child_process");
1473
- spawn("tail", [...(follow ? ["-f"] : []), "-n", flag("n", "40"), f], { stdio: "inherit" });
1766
+ const { tailLines, followFile } = await import("./machine.js");
1767
+ process.stdout.write(tailLines(f, Math.max(0, Number(flag("n", "40")) || 40)));
1768
+ if (follow)
1769
+ await followFile(f, (chunk) => process.stdout.write(chunk));
1474
1770
  return;
1475
1771
  }
1476
1772
  case "hook": {
1477
1773
  const sub = argv[1] ?? "status";
1774
+ if (inWokenRun() && !["install", "uninstall", "remove", "status"].includes(sub)) {
1775
+ if (sub === "cursor-prompt")
1776
+ console.log(JSON.stringify({ continue: true }));
1777
+ else if (sub === "cursor-stop")
1778
+ console.log("{}");
1779
+ process.exit(0);
1780
+ }
1478
1781
  const hookCfg = resolveForHook();
1479
1782
  const sessionOf = (raw) => {
1480
1783
  let fromJson;
@@ -1760,7 +2063,7 @@ async function main() {
1760
2063
  client.close();
1761
2064
  const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).length;
1762
2065
  removeMembership(m.id);
1763
- console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} repo binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
2066
+ console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} folder binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
1764
2067
  return;
1765
2068
  }
1766
2069
  case "leave": {
@@ -1768,9 +2071,21 @@ async function main() {
1768
2071
  const all = loadAll() ?? die("not configured");
1769
2072
  const m = findMembership(all, argv[1]) ?? die(`you are not in a team called "${argv[1]}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
1770
2073
  const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).map((b) => b.origin ?? b.path);
2074
+ const { others, founder } = await teamStanding(all, m);
2075
+ if (founder && others?.length === 0 && !argv.includes("--yes")) {
2076
+ die(`you founded "${m.name}" and nobody else is in it. Leaving keeps it on the relay — holding your plan's team slot — and takes away the only way to delete it.\n` +
2077
+ ` Delete it instead: pantheon space delete ${m.name} --yes\n Leave it anyway: pantheon leave ${m.name} --yes`);
2078
+ }
1771
2079
  removeMembership(m.id);
1772
- console.error(`${B("left")} "${m.name}" — its token is gone from this machine${bound.length ? `, and ${bound.length} repo binding${bound.length === 1 ? "" : "s"} with it (${bound.join(", ")})` : ""}.`);
1773
- console.error(D(`Your teammates still list you in "${m.name}". Ask one of them to run: pantheon admit ${m.name === all.member ? "you" : all.member} --revoke if you are not coming back.`));
2080
+ console.error(`${B("left")} "${m.name}" — its token is gone from this machine${bound.length ? `, and ${bound.length} binding${bound.length === 1 ? "" : "s"} with it (${bound.join(", ")})` : ""}.`);
2081
+ if (others === null)
2082
+ console.error(D(`The relay could not be reached to check who else is in "${m.name}". If there are teammates, they still list you; one of them removes you with: pantheon admit ${all.member} --revoke`));
2083
+ else if (others.length)
2084
+ console.error(D(founder
2085
+ ? `${others.join(", ")} ${others.length === 1 ? "is" : "are"} still in "${m.name}", with you as its founder — a founder cannot be removed by the others. To come back, ask one of them for an invite.`
2086
+ : `${others.join(", ")} still list you in "${m.name}". If you are not coming back, one of them runs: pantheon admit ${all.member} --revoke`));
2087
+ else
2088
+ console.error(D(`Nobody else was in "${m.name}"; it stays on the relay until its founder deletes it.`));
1774
2089
  return;
1775
2090
  }
1776
2091
  const all = loadAll();
@@ -1779,24 +2094,43 @@ async function main() {
1779
2094
  const plan = leavePlan(all);
1780
2095
  const project = resolve(flag("project", "."));
1781
2096
  const keepIdentity = argv.includes("--keep-identity");
1782
- if (plan.teams.length > 1 && !keepIdentity && !argv.includes("--yes")) {
2097
+ const standing = new Map();
2098
+ if (all && !keepIdentity) {
2099
+ const ms = Object.values(all.spaces ?? {});
2100
+ const got = await Promise.all(ms.map((m) => teamStanding(all, m)));
2101
+ ms.forEach((m, i) => standing.set(m.name, got[i]));
2102
+ }
2103
+ const stranded = [...standing].filter(([, v]) => v.founder && v.others?.length === 0).map(([k]) => k);
2104
+ if (stranded.length && !argv.includes("--yes")) {
2105
+ die(`you founded ${stranded.map((t) => `"${t}"`).join(", ")} and nobody else is in ${stranded.length === 1 ? "it" : "them"}. Leaving keeps ${stranded.length === 1 ? "it" : "them"} on the relay — holding your plan's team slot — and takes away the only way to delete ${stranded.length === 1 ? "it" : "them"}.\n` +
2106
+ ` Delete first: ${stranded.map((t) => `pantheon space delete ${t} --yes`).join(" · ")}\n Leave anyway: pantheon leave --yes`);
2107
+ }
2108
+ const orphaned = [...standing].filter(([, v]) => v.founder && (v.others?.length ?? 0) > 0).map(([k]) => k);
2109
+ if (orphaned.length && !argv.includes("--yes")) {
2110
+ die(`you founded ${orphaned.map((t) => `"${t}"`).join(", ")}, and others are in ${orphaned.length === 1 ? "it" : "them"}. Leaving removes this machine's key: you could not rejoin as yourself, and only a founder can remove a member or delete a team — nobody there could, ever again.\n` +
2111
+ ` To step out but keep the way back: ${orphaned.map((t) => `pantheon leave ${t}`).join(" · ")} (keeps your key)\n Leave anyway: pantheon leave --yes`);
2112
+ }
2113
+ if (plan.teams.length && !keepIdentity && !argv.includes("--yes")) {
1783
2114
  const list = plan.teams.map((t) => `"${t.name}"`).join(", ");
2115
+ const what = plan.teams.length > 1 ? `this machine is in ${plan.teams.length} teams — ${list}. Leaving removes its key, so it leaves ALL of them.` : `this removes Pantheon and this machine's key, so it leaves ${list}.`;
1784
2116
  if (!process.stdin.isTTY)
1785
- die(`this machine is in ${plan.teams.length} teams — ${list}. Leaving removes its key, so it leaves ALL of them.\n If you mean it: pantheon leave --yes\n Just one team: pantheon leave <team>`);
2117
+ die(`${what}\n If you mean it: pantheon leave --yes${plan.teams.length > 1 ? "\n Just one team: pantheon leave <team>" : ""}`);
1786
2118
  const rl = createInterface({ input: process.stdin, output: process.stderr });
1787
- const answer = await new Promise((res) => rl.question(`This machine is in ${plan.teams.length} teams: ${list}. Leaving removes its key, so it leaves ALL of them. Type "yes" to continue: `, (x) => res(x.trim())));
2119
+ const answer = (await questionOrNull(rl, `${what[0].toUpperCase()}${what.slice(1)} Type "yes" to continue: `)) ?? "";
1788
2120
  rl.close();
1789
2121
  if (answer.toLowerCase() !== "yes")
1790
- die(`nothing was changed. Leave one team with: pantheon leave <team>`);
2122
+ die(`nothing was changed.${plan.teams.length > 1 ? " Leave one team with: pantheon leave <team>" : ""}`);
1791
2123
  }
1792
2124
  console.error(`${B("leaving pantheon")}${plan.teams.length ? D(` — ${plan.teams.map((t) => t.name).join(", ")}`) : ""}\n`);
1793
2125
  const { uninstallMachine, realUninstallIo } = await import("./machine.js");
1794
2126
  const { removeUserMcpEntries } = await import("./install.js");
2127
+ const { killRecordedRun } = await import("./worker.js");
1795
2128
  const done = uninstallMachine({ project, stopRelay: argv.includes("--stop-relay") }, realUninstallIo({
1796
2129
  port: Number(flag("port", "8787")),
1797
2130
  removeMcpEntries: () => removeUserMcpEntries({ project }),
1798
2131
  hasIntent: () => readIntent() !== null,
1799
2132
  clearIntent: () => clearIntent(),
2133
+ killRun: () => killRecordedRun(dirname(CONFIG_PATH)),
1800
2134
  }));
1801
2135
  for (const l of done.lines)
1802
2136
  console.error(` ${l}`);
@@ -1857,6 +2191,20 @@ async function main() {
1857
2191
  left.push(`${join(dir, "quarantine-kept")} — agent config an interrupted run held, set aside because the repo had moved on; check it, then delete the folder`);
1858
2192
  }
1859
2193
  }
2194
+ {
2195
+ const { removePantheonWorktrees, removeHomeLeftovers } = await import("./machine.js");
2196
+ const repos = [project, ...Object.values(all?.bindings ?? {}).map((b) => b.path).filter((p) => !!p)];
2197
+ const trees = removePantheonWorktrees(repos, [join(CONFIG_DIR, "worktrees"), ...repos.map((r) => join(r, ".pantheon", "worktrees"))]);
2198
+ removed.push(...trees.removed);
2199
+ left.push(...trees.left);
2200
+ try {
2201
+ rmSync(join(CONFIG_DIR, "worktrees"), { recursive: true, force: true });
2202
+ }
2203
+ catch { }
2204
+ const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity);
2205
+ removed.push(...rest.removed);
2206
+ left.push(...rest.left);
2207
+ }
1860
2208
  console.error(`\n${B("Removed")}`);
1861
2209
  for (const r of removed)
1862
2210
  console.error(` ${C("✓")} ${r}`);
@@ -1867,9 +2215,20 @@ async function main() {
1867
2215
  console.error(` • ${l}`);
1868
2216
  console.error(` • ${D("files committed in your repos (.mcp.json, .cursor/mcp.json, .vscode/mcp.json, the brief in AGENTS.md / CLAUDE.md) — your team's; delete them in a commit if the whole team is leaving")}`);
1869
2217
  console.error(` • ${D("the relay's ledger on disk, and anything you pushed to git")}`);
1870
- if (plan.teams.length) {
1871
- console.error(`\n ${D(`Your teammates still list you in ${plan.teams.map((t) => `"${t.name}"`).join(", ")}. Ask someone in each to run:`)}`);
1872
- console.error(` ${D(`pantheon admit ${me?.member ?? plan.member ?? "<your name>"} --revoke`)}`);
2218
+ {
2219
+ const name = me?.member ?? plan.member ?? "<your name>";
2220
+ const listed = plan.teams.filter((t) => { const s = standing.get(t.name); return !s || s.others === null || (s.others.length && !s.founder); }).map((t) => `"${t.name}"`);
2221
+ const founded = plan.teams.filter((t) => { const s = standing.get(t.name); return s?.founder && s.others?.length; }).map((t) => `"${t.name}"`);
2222
+ if (listed.length) {
2223
+ console.error(`\n ${D(`Your teammates may still list you in ${listed.join(", ")}. If you are not coming back, ask one in each to run:`)}`);
2224
+ console.error(` ${D(`pantheon admit ${name} --revoke`)}`);
2225
+ }
2226
+ if (founded.length)
2227
+ console.error(`\n ${D(`You founded ${founded.join(", ")}. With this machine's key gone you cannot rejoin as yourself, and nobody there can remove a member or delete the team — only its founder could.`)}`);
2228
+ }
2229
+ if (me?.account && !keepIdentity) {
2230
+ const { accountStillExists } = await import("./machine.js");
2231
+ console.error(`\n ${accountStillExists(me.account.email)}`);
1873
2232
  }
1874
2233
  console.error(`\n ${D(`If Pantheon let you down, one line on why to ${FEEDBACK_ADDRESS} would help more than you might think.`)}`);
1875
2234
  return;
@@ -1883,6 +2242,12 @@ async function main() {
1883
2242
  }
1884
2243
  case "panic": {
1885
2244
  console.error(`${B("PANIC — locking down this machine")}\n`);
2245
+ {
2246
+ const { killRecordedRun } = await import("./worker.js");
2247
+ const killed = killRecordedRun(dirname(CONFIG_PATH));
2248
+ if (killed)
2249
+ console.error(` ${killed}`);
2250
+ }
1886
2251
  const { panicMachine, waitStopped } = await import("./machine.js");
1887
2252
  const all = loadAll();
1888
2253
  const teams = Object.values(all?.spaces ?? {});
@@ -1920,11 +2285,27 @@ async function main() {
1920
2285
  await client.connect().catch((e) => die(client.pending ?? connectFailure(e, cfg.relay)));
1921
2286
  await new Promise((r) => setTimeout(r, 300));
1922
2287
  console.log(`${B(`who is in ${cfg.space}`)}\n`);
2288
+ const { loadPolicy, isApprovedWaker, relayKnowsAdmitters } = await import("./worker-policy.js");
2289
+ const { wakeMode } = await import("./profile.js");
2290
+ const pol = loadPolicy();
2291
+ const keys = [client.spaceId ?? cfg.spaceId, cfg.space];
2292
+ const carried = keys.some((k) => !!k && !!pol.wakersCarried?.[k]);
2293
+ const mode = wakeMode(loadProfile(cfg)) === "approved" && !carried && !relayKnowsAdmitters(client.members, cfg.member) ? "anyone" : wakeMode(loadProfile(cfg));
1923
2294
  for (const e of client.members) {
1924
2295
  const who = e.member.padEnd(16);
1925
2296
  const you = e.member === cfg.member ? D(" (you)") : "";
1926
2297
  if (e.admitted) {
1927
- console.log(` ${C("in")} ${who}${e.may_wake ? "can start work here while you are away" : D("cannot start work here")}${you}`);
2298
+ const team = D(e.may_wake ? " team: may wake workers" : " team: may not wake workers");
2299
+ const here = e.member === cfg.member ? ""
2300
+ : mode === "no" ? D("cannot start work here — this machine is set not to be woken")
2301
+ : mode === "anyone" ? (e.may_wake ? "can start work here while you are away" : D("cannot start work here"))
2302
+ : isApprovedWaker(pol, keys, e.member)
2303
+ ? (e.may_wake ? "can start work here while you are away (approved here)" : D("approved here, but the team does not let them wake workers yet"))
2304
+ : D(`cannot start work here — pantheon wake allow ${e.member}${teamFlag(cfg)}`);
2305
+ console.log(` ${C("in")} ${who}${here}${e.member === cfg.member ? "" : team}${you}`);
2306
+ }
2307
+ else if (e.revoked_at) {
2308
+ console.log(` ${D("removed")} ${who}${D(`removed${e.revoked_by ? ` by ${e.revoked_by}` : ""}, ${new Date(e.revoked_at).toLocaleString()}`)}`);
1928
2309
  }
1929
2310
  else {
1930
2311
  console.log(` \x1b[33mwaiting\x1b[0m ${who}${D(`fingerprint ${e.fingerprint}`)}`);
@@ -1935,25 +2316,40 @@ async function main() {
1935
2316
  return;
1936
2317
  }
1937
2318
  case "admit": {
2319
+ if (inWokenRun())
2320
+ die("a woken run cannot admit anyone or change who may wake this machine — a person has to run this here");
1938
2321
  const cfg = need();
1939
- const who = argv[1] ?? die("usage: pantheon admit <member> [--may-wake] [--revoke] [--forget]");
2322
+ const who = argv[1] ?? die("usage: pantheon admit <member> [--may-wake | --no-wake] [--revoke] [--forget] [--yes]");
2323
+ const forget = argv.includes("--forget");
2324
+ const revoke = argv.includes("--revoke");
2325
+ const askedWake = argv.includes("--may-wake") && !revoke && !forget;
2326
+ if (askedWake && !argv.includes("--yes") && !process.stdin.isTTY) {
2327
+ die(`--may-wake lets ${who} start agent runs for the team, and there is no terminal here for a person to confirm it — pass --yes to confirm (nothing was changed)`);
2328
+ }
1940
2329
  const client = new PantheonClient(cfg, undefined, { role: "cli" });
1941
2330
  await client.connect().catch((e) => die(client.pending ?? connectFailure(e, cfg.relay)));
1942
2331
  await new Promise((r) => setTimeout(r, 250));
1943
- const forget = argv.includes("--forget");
1944
- const revoke = argv.includes("--revoke");
1945
- const mayWake = argv.includes("--may-wake");
1946
2332
  const entry = client.members.find((e) => e.member === who);
1947
2333
  if (!entry)
1948
2334
  die(`"${who}" has never connected, so there is no key to admit. Have them run \`pantheon join\` first.`);
2335
+ const mayWake = askedWake ? true : argv.includes("--no-wake") ? false : !!(entry.admitted && entry.may_wake);
1949
2336
  if (!revoke && !forget) {
1950
2337
  console.error(`${B("confirm this fingerprint with them directly:")} ${C(entry.fingerprint)}`);
1951
- if (mayWake) {
1952
- console.error(`\n${B(`--may-wake lets ${who} cause an agent to run on THIS machine:`)}`);
1953
- console.error(` ${C("can")} read this repo, and write on pantheon/* branches only`);
2338
+ if (askedWake) {
2339
+ const { COMMAND_GRANT_SCOPE } = await import("./agents.js");
2340
+ const { commandState } = await import("./command-state.js");
2341
+ const cmds = commandState({ project: process.cwd() });
2342
+ console.error(`\n${B(`--may-wake lets ${who} cause an agent to run on THIS machine (and on any teammate's that allows them — pantheon wake allow):`)}`);
2343
+ console.error(` ${C("can")} edit files in its own worktree of this repo, on a pantheon/* branch`);
2344
+ console.error(` ${C("can")} read files your agent's own rules let it read — for Codex that is the whole disk (its read-only sandbox limits writes, not reads)`);
1954
2345
  console.error(` ${C("can")} reply to your peers as you, while you are away`);
1955
- console.error(` ${D("cannot")} run shell commands (unless you start the worker with --allow-bash)`);
1956
- console.error(` ${D("cannot")} touch your working tree, your other branches, or merge anything`);
2346
+ if (cmds.level === "off") {
2347
+ console.error(` ${D("cannot")} run commands — unless a person on this machine grants that (pantheon policy grant run-commands, or answering the worker's request here) or the worker runs with --allow-bash. Then: ${COMMAND_GRANT_SCOPE}`);
2348
+ }
2349
+ else {
2350
+ console.error(` ${C("can")} run commands: ${cmds.summary}. ${COMMAND_GRANT_SCOPE}`);
2351
+ }
2352
+ console.error(` ${D("cannot")} touch your own checkout's working tree, or merge anything`);
1957
2353
  console.error(` ${D("limits")} one run at a time, 12 per hour, and only while a worker is running`);
1958
2354
  console.error(`\n ${D("Nothing happens at all unless you run `pantheon worker`. Undo instantly: pantheon panic")}`);
1959
2355
  if (argv.includes("--yes")) {
@@ -1961,11 +2357,17 @@ async function main() {
1961
2357
  }
1962
2358
  else {
1963
2359
  const rl = createInterface({ input: process.stdin, output: process.stderr });
1964
- const ans = await new Promise((res) => rl.question(`\ngrant that to ${who}? [y/N] `, res));
2360
+ const ans = (await questionOrNull(rl, `\ngrant that to ${who}? [y/N] `)) ?? "";
1965
2361
  rl.close();
1966
- if (!/^y(es)?$/i.test(ans.trim())) {
2362
+ if (!/^y(es)?$/i.test(ans)) {
2363
+ const keep = !!(entry.admitted && entry.may_wake);
2364
+ if (entry.admitted) {
2365
+ console.error(`\nnot granted. Nothing was changed.`);
2366
+ client.close();
2367
+ return;
2368
+ }
1967
2369
  console.error(`\nnot granted. Admitting ${who} without wake rights instead.`);
1968
- await client.admit(who, false, false).catch((e) => die(e.message));
2370
+ await client.admit(who, keep, false).catch((e) => die(e.message));
1969
2371
  console.log(`${C(who)} is in, but cannot start anything on this machine.`);
1970
2372
  client.close();
1971
2373
  return;
@@ -1974,13 +2376,27 @@ async function main() {
1974
2376
  }
1975
2377
  }
1976
2378
  await client.admit(who, mayWake, revoke, forget).catch((e) => die(e.message));
2379
+ let local = "";
2380
+ const { setLocalWaker, loadPolicy: lp, isApprovedWaker: approved } = await import("./worker-policy.js");
2381
+ const wakesHere = approved(lp(), [client.spaceId ?? cfg.spaceId, cfg.space], who);
2382
+ if (askedWake || revoke || forget) {
2383
+ const r = setLocalWaker([client.spaceId ?? cfg.spaceId, cfg.space], who, askedWake);
2384
+ if (!r.ok && askedWake)
2385
+ local = D(`\n Not recorded on this machine: ${r.why}.`);
2386
+ if (r.ok && r.changed && !askedWake && wakesHere)
2387
+ local = D(`\n ${who} was also taken off this machine's wake list.`);
2388
+ }
1977
2389
  console.log(forget
1978
2390
  ? `${C(who)} forgotten — the key ${D(entry.fingerprint)} is retired.\n` +
1979
2391
  ` They can now rejoin as "${who}" with a new key, keeping their goals and obligations.\n` +
1980
2392
  ` ${B("Check the new fingerprint with them")} once they are back: pantheon members`
1981
2393
  : revoke
1982
- ? `${C(who)} revoked — their sessions were disconnected`
1983
- : `${C(who)} admitted${mayWake ? ", and may wake a worker on your machine" : " (cannot wake workers — pass --may-wake to allow)"}`);
2394
+ ? `${C(who)} removed from "${cfg.space}" — their sessions were disconnected${local}`
2395
+ : askedWake
2396
+ ? `${C(who)} ${entry.admitted ? `may now wake workers in "${cfg.space}"` : `admitted to "${cfg.space}", and may wake workers there`}, including on this machine${local}\n` +
2397
+ D(` Each teammate decides for their own machine: pantheon wake allow ${who}`)
2398
+ : `${C(who)} ${entry.admitted ? `was already in "${cfg.space}"` : `admitted to "${cfg.space}"`}${mayWake ? D(" (the team still lets them wake workers)") : ""}\n` +
2399
+ (wakesHere ? D(` ${who} may already wake this machine (pantheon wake list)`) : D(` To let ${who} wake THIS machine: pantheon wake allow ${who}${teamFlag(cfg)} (only here — nothing is sent to the team)`)));
1984
2400
  client.close();
1985
2401
  return;
1986
2402
  }
@@ -2014,7 +2430,7 @@ async function main() {
2014
2430
  const prior = identity();
2015
2431
  const relay = flag("relay") ?? prior?.relay ?? process.env.PANTHEON_RELAY ?? DEFAULT_RELAY;
2016
2432
  const kp = prior?.publicKey && prior.privateKey ? { publicKey: prior.publicKey, privateKey: prior.privateKey } : newKeypair();
2017
- const label = flag("label") ?? `${process.env.USER ?? "a"}'s ${process.platform === "darwin" ? "mac" : process.platform}`;
2433
+ const label = flag("label") ?? deviceLabel();
2018
2434
  const base = relay.replace(/^ws/, "http").replace(/\/+$/, "");
2019
2435
  const body = purpose === "signup" ? { email, username, pubkey: kp.publicKey, label } : { email, pubkey: kp.publicKey, label };
2020
2436
  const r = await fetch(`${base}/auth/${purpose}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
@@ -2041,7 +2457,7 @@ async function main() {
2041
2457
  let code = (argv[1] ?? flag("code") ?? "").trim();
2042
2458
  if (!code && process.stdin.isTTY) {
2043
2459
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2044
- code = (await new Promise((res) => rl.question(`Enter the 6-digit code sent to ${pending.email}: `, res))).trim();
2460
+ code = (await questionOrNull(rl, `Enter the 6-digit code sent to ${pending.email}: `)) ?? "";
2045
2461
  rl.close();
2046
2462
  }
2047
2463
  if (!code)
@@ -2407,39 +2823,140 @@ async function main() {
2407
2823
  client.close();
2408
2824
  return;
2409
2825
  }
2826
+ case "wake": {
2827
+ const { loadPolicy, setLocalWaker } = await import("./worker-policy.js");
2828
+ const { wakeMode } = await import("./profile.js");
2829
+ const sub = argv[1] ?? "list";
2830
+ const one = () => {
2831
+ if (!flag("space"))
2832
+ return need();
2833
+ const f = loadAll();
2834
+ const m = f && findMembership(f, flag("space"));
2835
+ if (!f || !m)
2836
+ die(`you are not in a team called "${flag("space")}"`);
2837
+ return configFor(f, m, null);
2838
+ };
2839
+ if (sub === "allow" || sub === "deny") {
2840
+ const who = argv[2] && !argv[2].startsWith("--") ? argv[2] : die(`usage: pantheon wake ${sub} <name> [--space <team>]`);
2841
+ const cfg = one();
2842
+ if (who === cfg.member)
2843
+ die("your own requests always reach your own worker; there is nothing to allow");
2844
+ let unchecked = false;
2845
+ if (sub === "allow") {
2846
+ const c = new PantheonClient(cfg, undefined, { role: "cli" });
2847
+ const got = await Promise.race([c.connect().then(() => true, () => false), new Promise((r) => setTimeout(() => r(false), 3_000))]);
2848
+ if (got) {
2849
+ const names = c.members.map((m) => m.member);
2850
+ c.close();
2851
+ if (!names.includes(who))
2852
+ die(`"${who}" is not in "${cfg.space}". Its members: ${names.filter((n) => n !== cfg.member).join(", ") || "only you"}`);
2853
+ }
2854
+ else {
2855
+ try {
2856
+ c.close();
2857
+ }
2858
+ catch { }
2859
+ unchecked = true;
2860
+ }
2861
+ }
2862
+ const r = setLocalWaker([cfg.spaceId, cfg.space], who, sub === "allow");
2863
+ if (!r.ok)
2864
+ die(r.why);
2865
+ const mode = wakeMode(loadProfile(cfg));
2866
+ console.log(sub === "allow"
2867
+ ? `${C(who)} may wake this machine in ${cfg.space}${r.changed ? "" : D(" (already could)")}`
2868
+ : `${C(who)} can no longer wake this machine in ${cfg.space}${r.changed ? "" : D(" (could not before either)")}`);
2869
+ console.log(D(` Recorded on this machine only — nothing was sent to the team. Undo: pantheon wake ${sub === "allow" ? "deny" : "allow"} ${who}${flag("space") ? ` --space ${cfg.space}` : ""}`));
2870
+ if (unchecked)
2871
+ console.log(D(` The relay did not answer, so "${who}" was not checked against the team's members — check the spelling (pantheon team).`));
2872
+ if (sub === "allow") {
2873
+ if (mode === "anyone")
2874
+ console.log(D(` This machine is set to wake for anyone the team lets, so the list is used only if you choose "approved" (pantheon setup).`));
2875
+ if (mode === "no")
2876
+ console.log(D(` This machine is set not to be woken at all; the list applies once you choose "approved" (pantheon setup).`));
2877
+ console.log(D(` The team must also let ${who} wake workers at all. If their requests are refused with NOT_ALLOWED_TO_WAKE, a member runs: pantheon admit ${who} --may-wake${teamFlag(cfg)}`));
2878
+ }
2879
+ return;
2880
+ }
2881
+ if (sub === "list") {
2882
+ const pol = loadPolicy();
2883
+ const f = loadAll();
2884
+ const teams = flag("space") ? [one()] : f && Object.keys(f.spaces ?? {}).length ? Object.values(f.spaces ?? {}).map((m) => configFor(f, m, null)) : [need()];
2885
+ console.log(`${B("Who may wake this machine")} ${D("(this machine's own list — pantheon wake allow|deny <name>)")}\n`);
2886
+ for (const t of teams) {
2887
+ const names = [...new Set([...(t.spaceId ? pol.wakers?.[t.spaceId] ?? [] : []), ...(pol.wakers?.[t.space] ?? [])])].sort();
2888
+ const key = t.spaceId ?? t.space;
2889
+ const carried = !!(pol.wakersCarried?.[key] || pol.wakersCarried?.[t.space]);
2890
+ const deferred = !!(pol.wakersDeferred?.[key] || pol.wakersDeferred?.[t.space]);
2891
+ const mem = Object.values(f?.spaces ?? {}).find((m) => m.id === t.spaceId || m.name === t.space);
2892
+ const pending = !carried && (deferred || (!!mem && !mem.since));
2893
+ const why = deferred
2894
+ ? "when the worker last checked, the team's relay was older than 0.30 — until it is upgraded and the worker reconnects, the team's may_wake decides"
2895
+ : "from before 0.30 and not carried over yet — the worker does that when it next connects to a 0.30 relay; until then the team's may_wake decides";
2896
+ console.log(` ${t.space.padEnd(16)} ${names.length ? names.join(", ") : D("nobody")}${pending ? D(` (${why})`) : ""}`);
2897
+ }
2898
+ const mode = wakeMode(loadProfile(teams[0]));
2899
+ console.log(`\n ${D("setting")} ${mode === "approved" ? "only the people listed above" : mode === "anyone" ? "anyone the team lets wake workers (the list is kept for if you choose \"approved\")" : "nobody — this machine is set not to be woken"}${D(" (pantheon setup to change)")}`);
2900
+ return;
2901
+ }
2902
+ {
2903
+ const near = ["allow", "deny", "list"].map((k) => ({ k, d: slipDistance(sub, k) })).sort((x, y) => x.d - y.d)[0];
2904
+ die(`${near.d <= 2 ? `"${sub}" — did you mean: pantheon wake ${near.k}?\n` : ""}usage: pantheon wake allow <name> [--space <team>] · pantheon wake deny <name> [--space <team>] · pantheon wake list`);
2905
+ }
2906
+ }
2410
2907
  case "policy": {
2411
- const { loadPolicy, savePolicy, grant, revoke } = await import("./worker-policy.js");
2908
+ const { loadPolicy, savePolicy, grant, revoke, inWokenRun, CAPABILITY_ARGS } = await import("./worker-policy.js");
2909
+ const { COMMAND_GRANT_SCOPE } = await import("./agents.js");
2412
2910
  const sub = argv[1];
2413
2911
  const capArg = (argv[2] ?? "").replace(/-/g, "_");
2912
+ const caps = Object.keys(CAPABILITY_ARGS);
2414
2913
  if (sub === "grant" || sub === "revoke") {
2415
- if (capArg !== "self_upgrade")
2416
- die(`unknown capability "${argv[2] ?? ""}" — capabilities: self-upgrade`);
2914
+ const cap = caps.find((c) => c === capArg) ?? die(`unknown capability "${argv[2] ?? ""}" — capabilities: ${caps.map((c) => CAPABILITY_ARGS[c]).join(", ")}`);
2915
+ if (sub === "grant" && inWokenRun())
2916
+ die("a woken run cannot grant this machine anything — a person has to run this here");
2417
2917
  const p = loadPolicy();
2418
- if (sub === "grant") {
2419
- grant(p, "self_upgrade", "always", "policy-command");
2420
- }
2918
+ if (sub === "grant")
2919
+ grant(p, cap, "always", "policy-command");
2421
2920
  else
2422
- revoke(p, "self_upgrade");
2921
+ revoke(p, cap);
2423
2922
  savePolicy(p);
2424
- console.log(`${C(sub === "grant" ? "granted" : "revoked")} self-upgrade ${D(sub === "grant" ? "— the worker keeps pantheon current with the relay" : "— the worker will ask in your approvals inbox when it falls behind")}`);
2923
+ const said = cap === "self_upgrade"
2924
+ ? (sub === "grant" ? "— the worker keeps pantheon current with the relay" : "— the worker will ask in your approvals inbox when it falls behind")
2925
+ : (sub === "grant" ? `— ${COMMAND_GRANT_SCOPE}` : "— woken runs are back to edits only");
2926
+ console.log(`${C(sub === "grant" ? "granted" : "revoked")} ${CAPABILITY_ARGS[cap]} ${D(said)}`);
2425
2927
  return;
2426
2928
  }
2427
2929
  if (sub && sub !== "show")
2428
- die("usage: pantheon policy [show | grant self-upgrade | revoke self-upgrade]");
2930
+ die(`usage: pantheon policy [show | grant <capability> | revoke <capability>] capabilities: ${caps.map((c) => CAPABILITY_ARGS[c]).join(", ")}`);
2429
2931
  const p = loadPolicy();
2430
2932
  const w = workerProc();
2431
2933
  console.log(`${B("What this machine's worker may do")}\n`);
2432
2934
  console.log(` worker ${w.running ? C("running") : D("not running")}`);
2433
2935
  const su = p.grants.self_upgrade;
2434
2936
  console.log(` self-upgrade ${su ? C(su.mode === "always" ? "granted" : "granted once") : D("not granted")}${su ? D(` (${su.by}, ${new Date(su.at).toLocaleDateString()})`) : D(" — it will ask in your approvals inbox when behind the relay")}`);
2435
- const openAsk = p.asks.self_upgrade;
2436
- if (openAsk)
2437
- console.log(` ${"\x1b[33mopen request\x1b[0m"} self-upgrade — answer it: pantheon approvals`);
2937
+ const rc = p.grants.run_commands;
2938
+ const { commandState, repoCommandsAtBase } = await import("./command-state.js");
2939
+ const cs = commandState({ project: resolve(".") });
2940
+ console.log(` run-commands ${rc ? C(rc.mode === "always" ? "granted" : "granted once") : D("not granted")}${rc ? D(` (${rc.by}, ${new Date(rc.at).toLocaleDateString()})`) : cs.allowBash ? D(" — not needed: the worker runs with --allow-bash") : D(" — woken runs edit files only; it asks when a run needed more")}`);
2941
+ for (const cap of caps)
2942
+ if (p.asks[cap])
2943
+ console.log(` ${"\x1b[33mopen request\x1b[0m"} ${CAPABILITY_ARGS[cap]} — answer it here: pantheon approvals, or pantheon policy grant ${CAPABILITY_ARGS[cap]}`);
2438
2944
  const pers = loadPersonas(resolve("."), undefined);
2439
- console.log(` personas ${pers.length ? pers.map((x) => x.name).join(", ") + D(" (restricted mode — tighten-only)") : D("none — open mode; peers with may-wake can hand it any matched work")}`);
2440
- console.log(` bash ${D("gated — only a worker started with --allow-bash runs shell, and that flag always prompts")}`);
2945
+ console.log(` personas ${pers.length ? pers.map((x) => x.name).join(", ") + D(" (restricted mode — tighten-only)") : D("none — open mode; peers this machine approved to wake it can hand it any matched work")}`);
2946
+ console.log(` commands ${cs.level === "off" ? D(cs.summary) : C(cs.summary)}`);
2947
+ if (cs.level !== "off")
2948
+ console.log(` ${D(COMMAND_GRANT_SCOPE)}`);
2949
+ {
2950
+ const { REPO_COMMANDS_FILE } = await import("./agents.js");
2951
+ const root = repoInfo(process.cwd())?.root;
2952
+ const rc2 = root ? repoCommandsAtBase(root) : { ref: null, ok: [], refused: [] };
2953
+ const at = rc2.ref ? D(` (as committed on ${rc2.ref})`) : "";
2954
+ console.log(` this repo ${root ? (rc2.ok.length ? `also allows ${rc2.ok.join(", ")}${at}` : D(`adds no commands${rc2.ref ? ` on ${rc2.ref}` : ""} — list extra ones, one per line, in ${REPO_COMMANDS_FILE} and commit it`)) : D("not in a git repo")}`);
2955
+ for (const r of rc2.refused)
2956
+ console.log(` ${D(`ignored "${r.line}" — ${r.why}`)}`);
2957
+ }
2441
2958
  console.log(` runs ${D("12/hour, 40/day, one at a time, 2 failed attempts then parked (defaults; personas only lower them)")}`);
2442
- console.log(`\n${D("Change it: pantheon policy grant|revoke self-upgrade · personas: pantheon persona init · peers: pantheon admit <who> --may-wake/--revoke")}`);
2959
+ console.log(`\n${D("Change it: pantheon policy grant|revoke self-upgrade|run-commands · personas: pantheon persona init · who may wake this machine: pantheon wake allow|deny <who>")}`);
2443
2960
  return;
2444
2961
  }
2445
2962
  case "explain": {
@@ -2447,13 +2964,36 @@ async function main() {
2447
2964
  const CONFIG_DIR = dirname(CONFIG_PATH);
2448
2965
  let hud = null;
2449
2966
  if (argv.includes("--queued")) {
2967
+ const refuse = (why) => { console.log(`not sent: ${why}`); process.exit(1); };
2968
+ if (inWokenRun())
2969
+ refuse("a woken run cannot act for the menu bar — a person has to click it");
2450
2970
  const f = join(CONFIG_DIR, "bin", ".hud-action.json");
2971
+ const { statSync } = await import("node:fs");
2972
+ let st = null;
2973
+ let raw = null;
2451
2974
  try {
2452
- hud = JSON.parse(readFileSync(f, "utf8"));
2975
+ st = statSync(f);
2976
+ raw = readFileSync(f, "utf8");
2453
2977
  rmSync(f, { force: true });
2454
2978
  }
2455
2979
  catch {
2456
- hud = null;
2980
+ st = null;
2981
+ raw = null;
2982
+ }
2983
+ if (st && raw !== null) {
2984
+ const age = Date.now() - st.mtimeMs;
2985
+ const { runInFlight } = await import("./worker.js");
2986
+ const run = runInFlight(CONFIG_DIR);
2987
+ if (age > HUD_ACTION_MAX_AGE_MS)
2988
+ refuse(`that menu-bar action was written ${Math.round(age / 1000)}s ago — click it again`);
2989
+ if (run && st.mtimeMs >= run.at - 1_000)
2990
+ refuse("that menu-bar action was written while a woken run was working on this machine, so it is not trusted — click it again once the run is done");
2991
+ try {
2992
+ hud = JSON.parse(raw);
2993
+ }
2994
+ catch {
2995
+ hud = null;
2996
+ }
2457
2997
  }
2458
2998
  }
2459
2999
  const cfg = (() => {
@@ -2505,7 +3045,7 @@ async function main() {
2505
3045
  return;
2506
3046
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2507
3047
  try {
2508
- const answer = (await new Promise((res) => rl.question(`\n${D("run these here now? [y/N] ")}`, res))).trim().toLowerCase();
3048
+ const answer = ((await questionOrNull(rl, `\n${D("run these here now? [y/N] ")}`)) ?? "").toLowerCase();
2509
3049
  if (answer !== "y" && answer !== "yes") {
2510
3050
  console.error(D("left for you — the commands are above"));
2511
3051
  return;
@@ -2564,6 +3104,12 @@ async function main() {
2564
3104
  .catch((e) => notSent(e.message));
2565
3105
  c2.close();
2566
3106
  console.log(`sent ${kind} id=${sent.id} seq=${sent.seq}`);
3107
+ if (kind === "ANSWER") {
3108
+ const { applyLocalAnswer, describeLocalAnswer } = await import("./worker-policy.js");
3109
+ const g = applyLocalAnswer(ref, body);
3110
+ if (g)
3111
+ console.error(describeLocalAnswer(g));
3112
+ }
2567
3113
  return;
2568
3114
  }
2569
3115
  if (hud?.action === "retract") {
@@ -2632,7 +3178,7 @@ async function main() {
2632
3178
  const agent = pickExplainAgent(hud.tool ?? p0.agent.primary_surface, detectAvailable());
2633
3179
  const goalId = a?.goal ?? o?.goal ?? null;
2634
3180
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2635
- const ask = (q) => new Promise((res) => rl.question(q, (v) => res(v.trim())));
3181
+ const ask = async (q) => (await questionOrNull(rl, q)) ?? "";
2636
3182
  try {
2637
3183
  for (;;) {
2638
3184
  console.error(`\n${B("Next")}${agent ? ` ${C("1")} open in ${agent.id} with this loaded` : ""} ${C("2")} reply from here${goalId ? ` ${C("3")} show the whole thread` : ""} ${D("Enter) close")}`);
@@ -2674,6 +3220,12 @@ async function main() {
2674
3220
  await c3.connect();
2675
3221
  const sent = await c3.send({ act: kind, body: text, goal: o ? null : goalId, ref, to });
2676
3222
  console.error(` ${C("sent")} ${kind} id=${sent.id}`);
3223
+ if (kind === "ANSWER") {
3224
+ const { applyLocalAnswer, describeLocalAnswer } = await import("./worker-policy.js");
3225
+ const g = applyLocalAnswer(ref, text);
3226
+ if (g)
3227
+ console.error(` ${describeLocalAnswer(g)}`);
3228
+ }
2677
3229
  }
2678
3230
  catch (e) {
2679
3231
  console.error(` ${e.message}`);
@@ -2776,6 +3328,10 @@ async function main() {
2776
3328
  const sent = await hit.client.send({ act: "ANSWER", body: answer, goal: null, ref: hit.o.ref, after_seq: hit.client.readSeq, ...(summary ? { summary } : {}) })
2777
3329
  .catch((e) => { closeAll(); return die(/SUMMARY_REQUIRED/.test(e.message) ? `${e.message}\n Add one: --summary 'the point they must not miss'` : e.message); });
2778
3330
  console.log(`${C("answered")} ${id}${conns.length > 1 ? ` in ${B(hit.cfg.space)}` : ""} — ledger act ${sent.id}`);
3331
+ const { applyLocalAnswer, describeLocalAnswer } = await import("./worker-policy.js");
3332
+ const g = applyLocalAnswer(hit.o.ref, answer);
3333
+ if (g)
3334
+ console.log(` ${describeLocalAnswer(g)}`);
2779
3335
  closeAll();
2780
3336
  return;
2781
3337
  }
@@ -2839,8 +3395,9 @@ async function main() {
2839
3395
  return;
2840
3396
  }
2841
3397
  case "worker": {
3398
+ (await import("./machine.js")).recordWorkerPid();
2842
3399
  const allCfg = loadAll();
2843
- const bindings = Object.values(allCfg?.bindings ?? {});
3400
+ const bindings = Object.entries(allCfg?.bindings ?? {}).filter(([k]) => !k.startsWith("d_")).map(([, b]) => b);
2844
3401
  if (!flag("project") && bindings.length) {
2845
3402
  const { WorkerFleet } = await import("./worker-fleet.js");
2846
3403
  const fleet = new WorkerFleet({
@@ -2864,7 +3421,7 @@ async function main() {
2864
3421
  console.error(`${B("pantheon worker")} ${D(`(${bindings.length} bound repo${bindings.length === 1 ? "" : "s"} · ctrl-c to stop)`)}`);
2865
3422
  if (argv.includes("--allow-bash")) {
2866
3423
  console.error(`\x1b[33m --allow-bash: a woken run may execute commands. Only do this if you trust every\x1b[0m`);
2867
- console.error(`\x1b[33m peer you granted --may-wake, since their request is what starts the run.\x1b[0m`);
3424
+ console.error(`\x1b[33m teammate approved to wake this machine (pantheon wake list), since their request is what starts the run.\x1b[0m`);
2868
3425
  }
2869
3426
  process.on("SIGINT", () => { fleet.stop(); process.exit(0); });
2870
3427
  await fleet.run();
@@ -2895,7 +3452,7 @@ async function main() {
2895
3452
  console.error(`${B("pantheon worker")} ${D("(ctrl-c to stop)")}`);
2896
3453
  if (argv.includes("--allow-bash")) {
2897
3454
  console.error(`\x1b[33m --allow-bash: a woken run may execute commands. Only do this if you trust every\x1b[0m`);
2898
- console.error(`\x1b[33m peer you granted --may-wake, since their request is what starts the run.\x1b[0m`);
3455
+ console.error(`\x1b[33m teammate approved to wake this machine (pantheon wake list), since their request is what starts the run.\x1b[0m`);
2899
3456
  }
2900
3457
  process.on("SIGINT", () => { worker.stop(); process.exit(0); });
2901
3458
  await worker.run();
@@ -3043,7 +3600,7 @@ async function main() {
3043
3600
  }
3044
3601
  for (const l of installService(kind, homedir(), extraN))
3045
3602
  console.error(l);
3046
- console.error(`\n${D("Clicking an alert opens the primary surface in `pantheon profile`. No agent run or --may-wake permission is involved.")}`);
3603
+ console.error(`\n${D("Clicking an alert opens the primary surface in `pantheon profile`. No agent run or wake permission is involved.")}`);
3047
3604
  return;
3048
3605
  }
3049
3606
  need();
@@ -3061,7 +3618,7 @@ async function main() {
3061
3618
  console.error(D(` project ${project}`));
3062
3619
  for (const l of installService(kind, project, extra))
3063
3620
  console.error(l);
3064
- console.error(`\n${D("It only acts on requests from peers you granted --may-wake, one run at a time.")}`);
3621
+ console.error(`\n${D("It only acts on requests from teammates approved on this machine (pantheon wake allow <name>), one run at a time.")}`);
3065
3622
  return;
3066
3623
  }
3067
3624
  case "spaces": {
@@ -3078,7 +3635,7 @@ async function main() {
3078
3635
  const tags = [m.id === all.default_space ? C("default") : "", here.cfg?.spaceId === m.id || (here.cfg?.space === m.name && here.reason !== "none") ? C("← here") : ""].filter(Boolean).join(" ");
3079
3636
  console.error(` ${B(m.name.padEnd(18))} ${D(new URL(m.relay).host)} ${tags}`);
3080
3637
  for (const b of bound)
3081
- console.error(` ${D("repo")} ${b.origin ?? b.path}${b.origin ? D(` (${b.path})`) : ""}`);
3638
+ console.error(` ${D(b.origin === null && !existsSync(join(b.path, ".git")) ? "folder" : "repo")} ${b.origin ?? b.path}${b.origin ? D(` (${b.path})`) : ""}`);
3082
3639
  if (!bound.length)
3083
3640
  console.error(` ${D("repo none bound — run `pantheon use " + m.name + " --here` from its checkout")}`);
3084
3641
  }
@@ -3088,10 +3645,45 @@ async function main() {
3088
3645
  }
3089
3646
  case "use": {
3090
3647
  const all = loadAll() ?? die("not configured. Run `pantheon signup` first.");
3091
- const ref = argv[1] ?? die("usage: pantheon use <team> [--here] # --here binds this repo to the team; without it, sets your default");
3648
+ if (argv.includes("--unbind")) {
3649
+ const { folderBinding } = await import("./config.js");
3650
+ const r = repoInfo(process.cwd());
3651
+ const fb = r ? null : folderBinding(all, process.cwd());
3652
+ const key = r ? r.fingerprint : fb && fb.path === realpathSync.native(process.cwd()) ? fb.key : null;
3653
+ if (fb && !key)
3654
+ die(`this folder is not bound itself — ${fb.path} is, and this folder is inside it. Run pantheon use --unbind there.`);
3655
+ const was = key ? all.bindings?.[key] : undefined;
3656
+ if (!key || !was || !unbindRepo(key))
3657
+ die(`${r ? "this repo" : "this folder"} is not bound to a team — nothing to undo`);
3658
+ console.error(`${B("unbound")} ${r ? (r.origin ?? r.root) : was.path} — it was "${findMembership(all, was.space)?.name ?? was.space}"; it now resolves as a folder bound to no team`);
3659
+ return;
3660
+ }
3661
+ const ref = argv[1] && !argv[1].startsWith("--") ? argv[1] : die("usage: pantheon use <team> [--here] # --here binds this repo to the team; without it, sets your default");
3092
3662
  const m = findMembership(all, ref) ?? die(`you are not in a team called "${ref}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
3093
3663
  if (argv.includes("--here")) {
3094
- const r = repoInfo(process.cwd()) ?? die("this folder is not inside a git repo. Run it from the team's checkout.");
3664
+ const r = repoInfo(process.cwd());
3665
+ if (!r) {
3666
+ for (let d = resolve(process.cwd());; d = dirname(d)) {
3667
+ if (existsSync(join(d, ".git")))
3668
+ die(`this folder is inside a git repo (${d}), but git did not answer — check that git works here (git status), then run this again.`);
3669
+ if (dirname(d) === d)
3670
+ break;
3671
+ }
3672
+ const real = (d) => { try {
3673
+ return realpathSync.native(d);
3674
+ }
3675
+ catch {
3676
+ return resolve(d);
3677
+ } };
3678
+ const here = real(process.cwd());
3679
+ if ((here === real(homedir()) || dirname(here) === here) && !argv.includes("--yes")) {
3680
+ die(`this is ${dirname(here) === here ? "the root of the disk" : "your home folder"}: binding it puts every folder under it in "${m.name}", and an agent opened anywhere there would speak for that team.\n Bind a project folder instead, or if you mean it: pantheon use ${m.name} --here --yes`);
3681
+ }
3682
+ const { replaced } = bindFolder(process.cwd(), m.id);
3683
+ console.error(`${B("bound")} this folder (not a git repo) → "${m.name}"${replaced ? D(` (was: ${findMembership(all, replaced.space)?.name ?? replaced.space})`) : ""}`);
3684
+ console.error(D(` Agents opened here, or below here, now speak as you in "${m.name}". Delivering code needs a git repo. Restart any that are open.`));
3685
+ return;
3686
+ }
3095
3687
  const { replaced } = bindRepo(r, m.id);
3096
3688
  console.error(`${B("bound")} ${r.origin ?? r.root} → "${m.name}"${replaced ? D(` (was: ${findMembership(all, replaced.space)?.name ?? replaced.space})`) : ""}`);
3097
3689
  if (!r.shared)
@@ -3153,12 +3745,26 @@ async function main() {
3153
3745
  case "version":
3154
3746
  console.log(VERSION);
3155
3747
  return;
3156
- case "help":
3157
- console.error(argv.includes("--all") || argv[1] === "all" ? HELP_ALL : HELP);
3748
+ case "help": {
3749
+ const topic = argv[1] && argv[1] !== "all" && !argv[1].startsWith("-") ? argv[1] : null;
3750
+ if (topic && (usageFor(topic, HELP_ALL) || COMMAND_FLAGS[topic])) {
3751
+ console.log(`${usageFor(topic, HELP_ALL) ?? ` pantheon ${topic}`}${COMMAND_FLAGS[topic] ? `\n\n${B("flags")}\n ${COMMAND_FLAGS[topic]}` : ""}`);
3752
+ return;
3753
+ }
3754
+ console.log(argv.includes("--all") || argv[1] === "all" ? HELP_ALL : HELP);
3755
+ return;
3756
+ }
3757
+ case "--help":
3758
+ case "-h":
3759
+ console.log(argv.includes("--all") ? HELP_ALL : HELP);
3158
3760
  return;
3159
- default:
3160
- console.error(argv.includes("--all") ? HELP_ALL : HELP);
3161
- process.exit(cmd === "help" || cmd === "--help" ? 0 : 1);
3761
+ default: {
3762
+ const known = [...new Set([...HELP_ALL.matchAll(/pantheon ([a-z][a-z-]*)/g)].map((m) => m[1]))];
3763
+ const near = known.map((k) => ({ k, d: slipDistance(cmd, k) })).sort((x, y) => x.d - y.d)[0];
3764
+ console.error(`unknown command "${cmd}"${near && near.d <= 2 ? ` — did you mean: pantheon ${near.k}?` : ""}`);
3765
+ console.error(D("all commands: pantheon help · everything: pantheon help --all"));
3766
+ process.exit(1);
3767
+ }
3162
3768
  }
3163
3769
  }
3164
3770
  main().catch((e) => die(describeNetError(e, (() => { try {