@join-pantheon/cli 0.29.1 → 0.31.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 (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. package/package.json +2 -2
package/dist/doctor.js CHANGED
@@ -1,21 +1,24 @@
1
+ import { parseJsonText, stripJsonc } from "./text-file.js";
1
2
  import { kiroDefaultIsPantheon, readKiroAgent, kiroAgentFile } from "./kiro-files.js";
2
3
  import { execFileSync } from "node:child_process";
3
4
  import { instructionBudget } from "./mcp-instructions.js";
4
5
  import { describeWindows, scanMcpWindows } from "./mcp-windows.js";
5
6
  import { grantsSeen, measureMcp, toolsReport, HOST_STARTUP_ALLOWANCE_MS, STARTUP_WARN_MS } from "./measure.js";
6
7
  import { FEEDBACK_ADDRESS } from "./feedback.js";
7
- import { loadProfile, profileInitialized } from "./profile.js";
8
+ import { loadProfile, profileInitialized, wakeMode } from "./profile.js";
9
+ import { isApprovedWaker, loadPolicy, relayKnowsAdmitters, unsandboxedAllowed } from "./worker-policy.js";
10
+ import { commandState } from "./command-state.js";
8
11
  import { existsSync, readFileSync } from "node:fs";
9
12
  import { homedir, platform } from "node:os";
10
13
  import { join, resolve } from "node:path";
11
14
  import { PantheonClient } from "./client.js";
12
15
  import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
13
16
  import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
14
- import { approvalSummary, chosenApproval, onPath } from "./install.js";
17
+ import { approvalSummary, chosenApproval, copilotMcpConfigFile, kiroMcpConfigFile, onPath } from "./install.js";
15
18
  import { hookState, readIntent, agentHookStates } from "./notify.js";
16
19
  import { loadPersonas } from "./persona.js";
17
20
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
18
- import { chooseAgent, describeChoice, unattendedRefusal, skippedSummary } from "./agents.js";
21
+ import { agentById, chooseAgent, describeChoice, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
19
22
  import { onPathNamed } from "./which.js";
20
23
  import { VERSION } from "./version.js";
21
24
  import { serviceInstalled, servicesUnavailable } from "./service.js";
@@ -33,7 +36,7 @@ const git = (args, cwd) => {
33
36
  };
34
37
  function claudeLocalOverride(projectDir) {
35
38
  try {
36
- const doc = JSON.parse(readFileSync(join(homedir(), ".claude.json"), "utf8"));
39
+ const doc = parseJsonText(readFileSync(join(homedir(), ".claude.json"), "utf8"));
37
40
  const norm = (x) => resolve(x).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
38
41
  const want = norm(projectDir);
39
42
  return Object.entries(doc.projects ?? {}).some(([k, v]) => norm(k) === want && !!v?.mcpServers?.["pantheon"]);
@@ -42,6 +45,46 @@ function claudeLocalOverride(projectDir) {
42
45
  return false;
43
46
  }
44
47
  }
48
+ export function userScopeWiring(home = homedir(), present = onPathNamed, env = process.env) {
49
+ const json = (file, pick) => {
50
+ if (!existsSync(file))
51
+ return "missing";
52
+ try {
53
+ return pick(parseJsonText(stripJsonc(readFileSync(file, "utf8")))) ? "wired" : "missing";
54
+ }
55
+ catch {
56
+ return "invalid";
57
+ }
58
+ };
59
+ const server = (key) => (d) => !!d[key]?.["pantheon"];
60
+ const out = [];
61
+ if (present("claude")) {
62
+ const f = join(home, ".claude.json");
63
+ out.push({ agent: "Claude Code", file: f, state: json(f, server("mcpServers")) });
64
+ }
65
+ if (present("cursor-agent") || existsSync(join(home, ".cursor"))) {
66
+ const f = join(home, ".cursor", "mcp.json");
67
+ out.push({ agent: "Cursor", file: f, state: json(f, server("mcpServers")) });
68
+ }
69
+ if (present("codex")) {
70
+ const f = join(home, ".codex", "config.toml");
71
+ let state = "missing";
72
+ try {
73
+ state = /^\s*\[mcp_servers\.pantheon\]/m.test(readFileSync(f, "utf8")) ? "wired" : "missing";
74
+ }
75
+ catch { }
76
+ out.push({ agent: "Codex", file: f, state });
77
+ }
78
+ if (present("copilot")) {
79
+ const f = copilotMcpConfigFile(home, env);
80
+ out.push({ agent: "Copilot", file: f, state: json(f, server("mcpServers")) });
81
+ }
82
+ if (present("kiro-cli")) {
83
+ const f = kiroMcpConfigFile(home, env);
84
+ out.push({ agent: "Kiro", file: f, state: json(f, server("mcpServers")) });
85
+ }
86
+ return out;
87
+ }
45
88
  function isPrivate(host) {
46
89
  const h = host.replace(/:\d+$/, "");
47
90
  if (/^(localhost|127\.0\.0\.1|\[::1\]|::1)$/.test(h))
@@ -62,13 +105,16 @@ function isPrivate(host) {
62
105
  return true;
63
106
  return false;
64
107
  }
108
+ export function nodeSupported(version) {
109
+ const [major = 0, minor = 0] = version.replace(/^v/, "").split(".").map(Number);
110
+ return major > 23 || (major === 23 && minor >= 4) || (major === 22 && minor >= 13);
111
+ }
65
112
  export async function doctor(projectDir) {
66
113
  const out = [];
67
114
  const add = (level, name, detail, fix, share) => out.push({ level, name, detail, ...(fix ? { fix } : {}), ...(share ? { share } : {}) });
68
- const major = Number(process.versions.node.split(".")[0]);
69
- major >= 22
115
+ nodeSupported(process.versions.node)
70
116
  ? add("pass", "node", `v${process.versions.node}`, undefined, `v${process.versions.node}`)
71
- : add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22 or newer (the relay uses node:sqlite) — install it and re-run", `v${process.versions.node}, too old`);
117
+ : add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22.13 or newer — node:sqlite, which the relay uses, is behind a flag through 22.12 — install a current Node LTS and re-run", `v${process.versions.node}, too old`);
72
118
  const host = hostEnv();
73
119
  if (host.ephemeral) {
74
120
  add("warn", "machine", `${platformName(host.platform)} in ${host.kind === "container" ? "a container" : "a cloud workspace"} — ${host.evidence.join("; ")}`, `this is not the computer you work on, which is why it reports ${platformName(host.platform)} to someone who may be sitting at a Mac. The team and your account are fine here, but the menu bar, desktop alerts and the worker belong on your own machine — run the setup from a terminal there.`, `${host.platform} in ${host.kind === "container" ? "a container" : "a cloud workspace"}`);
@@ -92,14 +138,15 @@ export async function doctor(projectDir) {
92
138
  add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`, undefined, "present");
93
139
  const here = resolveFor(projectDir, { strict: true });
94
140
  const nTeams = Object.keys(loadAll()?.spaces ?? {}).length;
141
+ const folderHere = here.reason === "binding" && !here.repo;
95
142
  if (here.reason === "binding")
96
- add("pass", "team", `this repo is bound to "${here.cfg.space}"${here.repo?.origin ? ` (${here.repo.origin})` : ""}`, undefined, `this repo is bound to a team (${nTeams} on this machine)`);
143
+ add("pass", "team", folderHere ? `this folder is bound to "${here.cfg.space}" (${here.binding?.path ?? projectDir}) — no git needed` : `this repo is bound to "${here.cfg.space}"${here.repo?.origin ? ` (${here.repo.origin})` : ""}`, undefined, `this ${folderHere ? "folder" : "repo"} is bound to a team (${nTeams} on this machine)`);
97
144
  else if (here.reason === "single")
98
145
  add("pass", "team", `"${cfg.space}" — your only team, so every folder resolves to it`, undefined, "one team on this machine");
99
146
  else if (here.reason === "unbound")
100
147
  add("fail", "team", `this folder is not bound to any of your ${nTeams} teams — agents opened here will refuse to send`, `from the team's checkout: pantheon use <team> --here (pantheon spaces lists them)`, `folder not bound to any of ${nTeams} teams`);
101
148
  else if (here.reason === "stale")
102
- add("fail", "team", `this repo is bound to a team this machine has left (${here.binding?.space})`, "pantheon use <team> --here, or pantheon join <invite>", "bound to a team this machine has left");
149
+ add("fail", "team", `this ${here.repo ? "repo" : "folder"} is bound to a team this machine has left (${here.binding?.space})`, "pantheon use <team> --here, or pantheon join <invite>", "bound to a team this machine has left");
103
150
  if (here.repo && !here.repo.shared)
104
151
  add("warn", "team", "this repo has no origin remote", "DELIVERED must name a commit a teammate can fetch; add a remote before delivering", "repo has no origin remote");
105
152
  const nonce = "doctor-selftest";
@@ -117,12 +164,12 @@ export async function doctor(projectDir) {
117
164
  }
118
165
  const mcpFile = join(projectDir, ".mcp.json");
119
166
  if (!existsSync(mcpFile)) {
120
- add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`", "no .mcp.json in this project");
167
+ add("pass", "mcp entry", "no .mcp.json in this project — your agents are wired for your user (see below), so there is nothing to commit", undefined, "none in the project (user scope)");
121
168
  }
122
169
  else {
123
170
  let entry = {};
124
171
  try {
125
- entry = (JSON.parse(readFileSync(mcpFile, "utf8")).mcpServers ?? {}).pantheon ?? {};
172
+ entry = (parseJsonText(stripJsonc(readFileSync(mcpFile, "utf8"))).mcpServers ?? {}).pantheon ?? {};
126
173
  }
127
174
  catch { }
128
175
  if (!entry.command) {
@@ -143,11 +190,29 @@ export async function doctor(projectDir) {
143
190
  add("fail", "mcp entry", `hard-codes this machine: ${entry.command}`, "make sure `pantheon` is on your PATH, then run `pantheon install` again — it writes the portable `pantheon mcp` entry. Committed as-is it silently fails on the other machine: the MCP server never starts and no error is shown.", "hard-codes a path on this machine");
144
191
  }
145
192
  }
193
+ {
194
+ const rows = userScopeWiring();
195
+ const wired = rows.filter((r) => r.state === "wired");
196
+ const bad = rows.filter((r) => r.state !== "wired");
197
+ if (rows.length && !bad.length) {
198
+ add("pass", "user wiring", `pantheon is wired at user scope for ${wired.map((r) => r.agent).join(", ")}`, undefined, `wired for ${wired.length} agent${wired.length === 1 ? "" : "s"}`);
199
+ }
200
+ else if (rows.length) {
201
+ const said = bad.map((r) => `${r.agent}: ${r.state === "invalid" ? `${r.file} does not parse` : `no pantheon server in ${r.file}`}`).join("; ");
202
+ const fix = bad.some((r) => r.state === "invalid")
203
+ ? "fix the file that does not parse (setup never overwrites it), then run `pantheon install --user`"
204
+ : "run `pantheon install --user` — and if it says failed, the line it prints is why";
205
+ add(wired.length || existsSync(mcpFile) ? "warn" : "fail", "user wiring", said, fix, `${bad.length} of ${rows.length} agent${rows.length === 1 ? "" : "s"} not wired at user scope`);
206
+ }
207
+ }
146
208
  onPath()
147
209
  ? add("pass", "PATH", "`pantheon` resolves by name", undefined, "pantheon resolves by name")
148
210
  : add("warn", "PATH", "`pantheon` is not on PATH", "install it globally (`npm install -g @join-pantheon/cli`) and make sure your npm global bin dir (`npm bin -g`) is on PATH", "pantheon is not on PATH");
149
211
  const inRepo = git(["rev-parse", "--is-inside-work-tree"], projectDir) === "true";
150
- if (!inRepo) {
212
+ if (!inRepo && folderHere) {
213
+ add("pass", "git", "a plain folder — messages, questions and decisions work here; code is handed over from a repo", undefined, "plain folder, no git needed");
214
+ }
215
+ else if (!inRepo) {
151
216
  add("warn", "git", `${projectDir} is not a git repo`, "DELIVERED requires a pushed branch + commit, so the work needs to live in a repo you both clone", "not a git repo");
152
217
  }
153
218
  else {
@@ -165,20 +230,28 @@ export async function doctor(projectDir) {
165
230
  : add("warn", "agent cli", "found none of claude / codex / cursor-agent / copilot / kiro-cli on PATH", "fine if you only drive agents through a GUI (Conductor, Cursor, VS Code, the Kiro IDE), but `pantheon worker` needs a CLI to spawn", "none of claude / codex / cursor-agent / copilot / kiro-cli on PATH");
166
231
  if (agents.length) {
167
232
  try {
168
- const choice = chooseAgent("auto", { prefer: prof.agent.primary_surface });
233
+ let unsandboxedOk = false;
234
+ try {
235
+ unsandboxedOk = unsandboxedAllowed(loadPolicy());
236
+ }
237
+ catch { }
238
+ const choice = chooseAgent("auto", { prefer: prof.agent.primary_surface, unsandboxedOk });
169
239
  const line = describeChoice(choice);
170
240
  const bad = choice.signIn && !choice.signIn.ok && !choice.signIn.unknown;
171
241
  const fellBack = !!prof.agent.primary_surface && choice.backend.id !== prof.agent.primary_surface && agents.length > 0;
172
242
  const shareLine = `${choice.backend.label} — ${choice.signIn?.detail ?? "not checked"}${choice.skipped.length ? `; ${skippedSummary(choice)}` : ""}`;
173
- const refused = unattendedRefusal(choice);
243
+ const refused = unattendedRefusal(choice, { unsandboxedOk });
244
+ const surface = prof.agent.primary_surface ? agentById(prof.agent.primary_surface) : undefined;
174
245
  if (refused)
175
- add("warn", "worker agent", line, `woken runs are refused here, not tried: ${refused}`, shareLine);
246
+ add("warn", "worker agent", line, `teammates' requests wait for your next session here: ${refused}`, shareLine);
176
247
  else if (bad)
177
248
  add("warn", "worker agent", line, `nothing is signed in, so every woken run here fails until you sign in: ${choice.skipped.map((x) => x.why).join("; ") || "sign in to the agent CLI"}`, shareLine);
178
249
  else if (fellBack)
179
250
  add("warn", "worker agent", line, prof.agent.primary_surface === "kiro"
180
- ? `you chose Kiro at setup; Kiro cannot yet be fenced to its worktree for unattended runs, so runs use ${choice.backend.label}`
181
- : `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
251
+ ? `you chose Kiro at setup; Kiro answers in your live sessions, and runs while you're away use ${choice.backend.label}`
252
+ : surface && !SANDBOXED_FOR_UNATTENDED.has(surface.id) && !unsandboxedOk
253
+ ? `you chose ${surface.label} at setup; runs while you're away use ${choice.backend.label} in its sandbox (to have ${surface.label} answer them: pantheon policy grant unsandboxed-runs)`
254
+ : `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
182
255
  else
183
256
  add("pass", "worker agent", line, undefined, shareLine);
184
257
  }
@@ -245,11 +318,17 @@ export async function doctor(projectDir) {
245
318
  try {
246
319
  const res = await fetch(health, { signal: AbortSignal.timeout(5000) });
247
320
  const body = (await res.json());
248
- if (!body.ok) {
321
+ if (!body.ok && body.storage) {
322
+ const since = body.storage.at ? ` (since ${new Date(body.storage.at).toLocaleString()})` : "";
323
+ add("fail", "relay", `the relay cannot write to its disk: ${body.storage.error ?? "unknown error"}${since}`, hosted ? `this is on the hosted relay's side — nothing on this machine can fix it; tell us at ${FEEDBACK_ADDRESS}` : "free space or fix permissions on the relay host's data folder, then restart the relay", `cannot write to its disk (${where})`);
324
+ }
325
+ else if (!body.ok) {
249
326
  add("fail", "relay", `answered but not healthy: ${JSON.stringify(body)}`, undefined, `answered but not healthy (${where})`);
250
327
  }
251
328
  else {
252
329
  add("pass", "relay", `reachable at ${health}`, undefined, `reachable (${where})`);
330
+ }
331
+ if (body.ok || body.storage) {
253
332
  if (body.pkg && body.pkg !== VERSION) {
254
333
  add("warn", "version", `this machine is ${VERSION}, the relay is ${body.pkg}`, "run `pantheon upgrade` on the older side — features land on both halves of the wire, and the protocol version cannot tell you they are missing", `this machine ${VERSION}, relay ${body.pkg}`);
255
334
  }
@@ -298,18 +377,66 @@ export async function doctor(projectDir) {
298
377
  .map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "), undefined, `${admittedPeers.length} admitted, ${peersOnline} with a session open`)
299
378
  : add("warn", "peers", "nobody else is admitted to this space yet", "send your teammate an invite from `pantheon invite` (or the one `pantheon start` printed)", "nobody else admitted yet");
300
379
  if (pending.length) {
301
- add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} (add --may-wake only if they should be able to wake this machine)`, `${pending.length} waiting to be admitted`);
380
+ add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} (add --may-wake only if the team should let them wake workers; each of you then allows them on your own machine: pantheon wake allow <name>)`, `${pending.length} waiting to be admitted`);
302
381
  }
303
382
  const wakers = client.members.filter((m) => m.admitted && m.may_wake && m.member !== cfg.member);
304
383
  const hookStates = agentHookStates(projectDir);
305
384
  const hook = hookStates[0] ?? hookState(projectDir);
306
385
  const reportsActivity = hookStates.some((s) => s.events?.some((e) => /prompt|UserPromptSubmit|beforeSubmitPrompt/i.test(e))) || (hook.events?.includes("UserPromptSubmit") ?? false);
386
+ {
387
+ const { loadPins, pinsPath } = await import("./pins.js");
388
+ const { brokenStateFiles, brokenStateFix } = await import("./file-lock.js");
389
+ const { dirname } = await import("node:path");
390
+ const broken = brokenStateFiles(dirname(pinsPath()));
391
+ if (broken.length)
392
+ add("fail", "signing", `${broken.join(", ")} in ${dirname(pinsPath())} cannot be read and written — they are what stops a request being run twice, so the worker holds every unattended run until they are fixed`, brokenStateFix(broken), "state files broken");
393
+ const all = loadPins();
394
+ if (all.corrupt)
395
+ add("fail", "signing", `${pinsPath()} could not be read — no message is trusted until it can be`, "restore it from a backup, or remove it to pin teammates' keys afresh (then check each with them: pantheon trust)", "keys.json unreadable");
396
+ const t = all.teams[client.pinKey()];
397
+ const pending = Object.entries(t?.members ?? {}).filter(([, p]) => p.pending).map(([m]) => m);
398
+ const n = Object.keys(t?.members ?? {}).filter((m) => m !== cfg.member).length;
399
+ if (client.features.includes("act-sig")) {
400
+ if (pending.length)
401
+ add("warn", "signing", `${pending.join(", ")}'s key changed — their messages are not trusted here until you check the new key with them`, `pantheon trust ${pending[0]}`, `${pending.length} key change${pending.length === 1 ? "" : "s"} waiting`);
402
+ else
403
+ add("pass", "signing", `messages are signed; this machine checks them against ${n} teammate key${n === 1 ? "" : "s"} it pinned (pantheon trust lists them)`, undefined, `signed; ${n} keys pinned`);
404
+ }
405
+ else if (t?.sigs || client.signaturesExpected()) {
406
+ add("fail", "signing", "this team's relay does not carry signatures, though this machine expects them (it signed before, or you joined with 0.31+) — requests are not run here until it does", "check the relay's version with its operator (pantheon doctor on a teammate's machine)", "relay stopped carrying signatures");
407
+ }
408
+ else {
409
+ add("warn", "signing", "this team's relay does not carry signatures yet (a relay before 0.31), so messages cannot be checked against their senders' keys", "upgrade the relay", "relay without signatures");
410
+ }
411
+ }
307
412
  const workerUp = workerProc().running;
308
- if (!wakers.length) {
309
- add("warn", "wake rights", "no peer may wake a worker on this machine", "that is a safe default; grant it with `pantheon admit <them> --may-wake` when you want unattended replies", `no peer may wake; worker ${workerUp ? "running" : "not running"}`);
413
+ const mode = wakeMode(loadProfile(cfg));
414
+ const anyoneWhy = loadProfile(cfg).agent.wake_default === "anyone" ? "you chose \"anyone\"" : "\"anyone\" is the default";
415
+ const policy = loadPolicy();
416
+ const approvedHere = admittedPeers.filter((m) => isApprovedWaker(policy, [client.spaceId ?? cfg.spaceId, cfg.space], m.member));
417
+ const teamKey = client.spaceId ?? cfg.spaceId ?? cfg.space;
418
+ const notCarried = mode === "approved" && !policy.wakersCarried?.[teamKey] && !policy.wakersCarried?.[cfg.space] && !relayKnowsAdmitters(client.members, cfg.member);
419
+ const effective = mode === "anyone" || notCarried ? wakers : mode === "no" ? [] : approvedHere.filter((m) => m.may_wake);
420
+ const blockedByTeam = mode === "approved" ? approvedHere.filter((m) => !m.may_wake) : [];
421
+ const noSvcHere = servicesUnavailable(host.platform);
422
+ if (!effective.length && mode === "approved" && !blockedByTeam.length && !notCarried) {
423
+ if (workerUp)
424
+ add("pass", "wake rights", "nobody approved yet — you are asked the first time a teammate's request needs this machine (Always / Just this once / No)", undefined, "ask on first request; worker running");
425
+ else
426
+ add("warn", "wake rights", "no worker is running here, so teammates' requests wait for you — and nobody is asked", noSvcHere ? "Pantheon does not run in the background on Windows yet, so a peer's act waits until your agent is open" : "start it: pantheon service install worker (it serves every repo and folder bound here)", "ask on first request; worker not running");
427
+ }
428
+ else if (!effective.length) {
429
+ const why = mode === "no" ? "this machine is set not to be woken"
430
+ : mode === "anyone" ? `${anyoneWhy}, and the team lets nobody wake workers`
431
+ : blockedByTeam.length ? `approved here: ${blockedByTeam.map((m) => m.member).join(", ")}, but the team does not let them wake workers yet`
432
+ : "nobody is approved on this machine";
433
+ add("warn", "wake rights", `no teammate can wake a worker on this machine — ${why}`, mode === "no" ? "that is what you chose; pantheon setup to change it"
434
+ : blockedByTeam.length ? `a member runs: pantheon admit ${blockedByTeam[0].member} --may-wake`
435
+ : "that is a safe default; let one teammate wake this machine with `pantheon wake allow <name>` when you want unattended replies", `nobody can wake this machine (${mode}); worker ${workerUp ? "running" : "not running"}`);
310
436
  }
311
437
  else {
312
- const who = `${wakers.map((m) => m.member).join(", ")} may wake a worker (space-wide grant)`;
438
+ const who = `${effective.map((m) => m.member).join(", ")} can wake a worker here (${mode === "anyone" ? anyoneWhy : notCarried ? "the team's may_wake decides until this relay is 0.30 or newer" : "approved on this machine"})`;
439
+ const wakers = effective;
313
440
  if (workerUp) {
314
441
  add("pass", "wake rights", `${who} — worker is running`, undefined, `${wakers.length} may wake; worker running`);
315
442
  }
@@ -317,7 +444,48 @@ export async function doctor(projectDir) {
317
444
  const noSvc = servicesUnavailable(host.platform);
318
445
  add("warn", "wake rights", `${who}, but no worker is running here, so nothing can be woken`, noSvc
319
446
  ? "Pantheon does not run in the background on Windows yet, so a peer's act waits until your agent is open"
320
- : "start one with `pantheon service install worker --project <a git repo>`; until then a peer's act waits for you to type", `${wakers.length} may wake; worker not running`);
447
+ : "start one with `pantheon service install worker` (it serves every repo and folder bound here); until then a peer's act waits for you to type", `${wakers.length} may wake; worker not running`);
448
+ }
449
+ }
450
+ {
451
+ const f = loadAll();
452
+ const m = f && Object.values(f.spaces ?? {}).find((x) => x.id === (client.spaceId ?? cfg.spaceId) || x.name === cfg.space);
453
+ if (f && m) {
454
+ const { teamWorkspaces } = await import("./worker-fleet.js");
455
+ const ws = teamWorkspaces(f, m.id);
456
+ const fps = new Set(ws.map((w) => w.fp).filter(Boolean));
457
+ const missing = (m.repos ?? []).filter((r) => !fps.has(r.fp)).map((r) => r.name);
458
+ if (ws.length) {
459
+ add("pass", "worker serves", `${ws.map((w) => `${w.name}${w.kind === "folder" ? " (folder)" : ""}`).join(", ")}${missing.length ? ` — not on this machine: ${missing.join(", ")}` : ""}`, missing.length ? "requests about those are told they wait for you; clone them, then: pantheon repo find" : undefined, `${ws.length} workspace${ws.length === 1 ? "" : "s"}${missing.length ? `, ${missing.length} team repo${missing.length === 1 ? "" : "s"} not here` : ""}`);
460
+ }
461
+ }
462
+ }
463
+ {
464
+ const cs = commandState({ project: projectDir });
465
+ let servesRepo = true;
466
+ try {
467
+ const f = loadAll();
468
+ const m = f && Object.values(f.spaces ?? {}).find((x) => x.id === (client.spaceId ?? cfg.spaceId) || x.name === cfg.space);
469
+ if (f && m) {
470
+ const ws = (await import("./worker-fleet.js")).teamWorkspaces(f, m.id);
471
+ servesRepo = !ws.length || ws.some((w) => w.kind === "repo");
472
+ }
473
+ }
474
+ catch { }
475
+ if (!servesRepo)
476
+ add("pass", "commands", "not used here — a docs folder is read and answered from, never run in", undefined, "docs folder only; commands not used");
477
+ else {
478
+ const { commandCaveat } = await import("./command-state.js");
479
+ const caveat = cs.level !== "off" ? commandCaveat() : "";
480
+ if (caveat)
481
+ add("warn", "commands", `woken runs: ${cs.summary} — ${caveat}`, undefined, "how woken runs here use commands");
482
+ else
483
+ add("pass", "commands", `woken runs: ${cs.summary}`, undefined, cs.level === "off" ? "woken runs edit files only" : `woken runs may run commands (${cs.allowBash ? "--allow-bash" : "granted here"})`);
484
+ }
485
+ if (cs.level !== "off" && host.platform === "linux") {
486
+ const missing = ["bwrap", "socat"].filter((b) => !onPathNamed(b, "linux"));
487
+ if (missing.length)
488
+ add("warn", "sandbox", `${missing.join(" and ")} not found — Claude Code's sandbox cannot start, so every woken command run will be refused`, "install them: sudo apt install bubblewrap socat (Debian/Ubuntu) or sudo dnf install bubblewrap socat (Fedora)", `sandbox tools missing: ${missing.join(", ")}`);
321
489
  }
322
490
  }
323
491
  if (host.desktop) {
@@ -0,0 +1,197 @@
1
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ const STALE_MS = 15_000;
5
+ const WAIT_MS = 20_000;
6
+ const sleepSync = (ms) => { try {
7
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
8
+ }
9
+ catch { } };
10
+ const readLock = (lock) => { try {
11
+ return readStateFile(lock);
12
+ }
13
+ catch {
14
+ return null;
15
+ } };
16
+ function alive(pid) {
17
+ if (!Number.isInteger(pid) || pid <= 0)
18
+ return false;
19
+ try {
20
+ process.kill(pid, 0);
21
+ return true;
22
+ }
23
+ catch (e) {
24
+ return e.code === "EPERM";
25
+ }
26
+ }
27
+ function stale(lock, content) {
28
+ let age;
29
+ try {
30
+ age = Math.abs(Date.now() - statSync(lock).mtimeMs);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ if (age > STALE_MS)
36
+ return true;
37
+ const m = /^(\d+) [0-9a-f]{24}$/.exec(content ?? "");
38
+ if (!m)
39
+ return age > 1_000;
40
+ return !alive(Number(m[1])) && age > 50;
41
+ }
42
+ const clear = (p) => { try {
43
+ rmSync(p, { recursive: true, force: true });
44
+ }
45
+ catch { } };
46
+ function takeOver(lock, seen) {
47
+ const brk = `${lock}.break`;
48
+ const mine = `${brk}.${process.pid}.${randomBytes(4).toString("hex")}`;
49
+ try {
50
+ writeFileSync(mine, "", { flag: "wx", mode: 0o600 });
51
+ }
52
+ catch {
53
+ return;
54
+ }
55
+ try {
56
+ try {
57
+ linkSync(mine, brk);
58
+ }
59
+ catch {
60
+ try {
61
+ const st = statSync(brk);
62
+ if (!st.isFile() || Math.abs(Date.now() - st.mtimeMs) > 5_000)
63
+ clear(brk);
64
+ }
65
+ catch { }
66
+ return;
67
+ }
68
+ try {
69
+ const now = readLock(lock);
70
+ if (now !== seen || !stale(lock, now))
71
+ return;
72
+ const aside = `${lock}.stale.${process.pid}.${randomBytes(4).toString("hex")}`;
73
+ try {
74
+ renameSync(lock, aside);
75
+ }
76
+ catch {
77
+ clear(lock);
78
+ return;
79
+ }
80
+ if (readLock(aside) !== seen) {
81
+ try {
82
+ linkSync(aside, lock);
83
+ }
84
+ catch { }
85
+ }
86
+ clear(aside);
87
+ }
88
+ finally {
89
+ try {
90
+ rmSync(brk, { force: true });
91
+ }
92
+ catch { }
93
+ }
94
+ }
95
+ finally {
96
+ try {
97
+ rmSync(mine, { force: true });
98
+ }
99
+ catch { }
100
+ }
101
+ }
102
+ export function readStateFile(p) {
103
+ let st;
104
+ try {
105
+ st = statSync(p);
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ if (!st.isFile())
111
+ throw new Error(`${p} is not a plain file`);
112
+ return readFileSync(p, "utf8");
113
+ }
114
+ export function brokenStateFiles(dir) {
115
+ const out = [];
116
+ if (existsSync(dir)) {
117
+ const probe = join(dir, `.write-check.${randomBytes(4).toString("hex")}`);
118
+ try {
119
+ writeFileSync(probe, "", { flag: "wx", mode: 0o600 });
120
+ rmSync(probe, { force: true });
121
+ }
122
+ catch {
123
+ out.push("(the folder)");
124
+ }
125
+ }
126
+ for (const f of ["nonces.json", "continued.json", "keys.json"]) {
127
+ const p = join(dir, f);
128
+ try {
129
+ const text = readStateFile(p);
130
+ if (text === null)
131
+ continue;
132
+ JSON.parse(text);
133
+ closeSync(openSync(p, "r+"));
134
+ }
135
+ catch {
136
+ out.push(f);
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+ export function brokenStateFix(broken) {
142
+ const files = broken.filter((b) => b !== "(the folder)");
143
+ const parts = [];
144
+ if (broken.includes("(the folder)"))
145
+ parts.push("make the Pantheon folder writable again (its permissions) — do not delete it: it holds your key and memberships");
146
+ if (files.length)
147
+ parts.push(`repair or delete ${files.join(", ")} (one made read-only or locked: make it writable again; one that is a folder or not a file: delete it)`);
148
+ return parts.join("; ");
149
+ }
150
+ export function withFileLock(f, fn) {
151
+ mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
152
+ const lock = `${f}.lock`;
153
+ const token = `${process.pid} ${randomBytes(12).toString("hex")}`;
154
+ const deadline = Date.now() + WAIT_MS;
155
+ const made = `${lock}.${randomBytes(6).toString("hex")}.new`;
156
+ writeFileSync(made, token, { mode: 0o600, flag: "wx" });
157
+ try {
158
+ for (;;) {
159
+ let got = false;
160
+ try {
161
+ linkSync(made, lock);
162
+ got = true;
163
+ }
164
+ catch (e) {
165
+ const code = e.code;
166
+ if (code !== "EEXIST" && !(process.platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBUSY")))
167
+ throw e;
168
+ }
169
+ if (got)
170
+ break;
171
+ const content = readLock(lock);
172
+ if (stale(lock, content))
173
+ takeOver(lock, content);
174
+ if (Date.now() > deadline)
175
+ throw new Error(`${f} is locked by another pantheon process (${lock})`);
176
+ sleepSync(2 + Math.floor(Math.random() * 8));
177
+ }
178
+ }
179
+ finally {
180
+ try {
181
+ rmSync(made, { force: true });
182
+ }
183
+ catch { }
184
+ }
185
+ try {
186
+ return fn();
187
+ }
188
+ finally {
189
+ if (readLock(lock) === token) {
190
+ try {
191
+ rmSync(lock, { force: true });
192
+ }
193
+ catch { }
194
+ }
195
+ }
196
+ }
197
+ //# sourceMappingURL=file-lock.js.map
package/dist/grants.js CHANGED
@@ -18,7 +18,7 @@ function readJson(file) {
18
18
  if (!existsSync(file))
19
19
  return null;
20
20
  try {
21
- return JSON.parse(readFileSync(file, "utf8"));
21
+ return JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
22
22
  }
23
23
  catch {
24
24
  return "invalid";
@@ -34,7 +34,8 @@ export function resolveRef(acts, ref, act, goal, me) {
34
34
  }
35
35
  const others = acts.filter((a) => a.from !== me && a.act !== "JOIN" && a.act !== "LEAVE" && inGoal(a));
36
36
  if (ref === "last") {
37
- const hit = others[others.length - 1];
37
+ const can = REPLIES_TO[act];
38
+ const hit = (can ? [...others].reverse().find((a) => can.includes(a.act)) : undefined) ?? others[others.length - 1];
38
39
  return hit ? { id: hit.id, goal: hit.goal ?? goal, note: `replying to [${hit.seq}] ${hit.act} from ${hit.from}` } : { id: null, goal, error: "nothing from a peer to reply to" };
39
40
  }
40
41
  if (ref)