@join-pantheon/cli 0.30.0 → 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.
package/dist/doctor.js CHANGED
@@ -6,7 +6,7 @@ import { describeWindows, scanMcpWindows } from "./mcp-windows.js";
6
6
  import { grantsSeen, measureMcp, toolsReport, HOST_STARTUP_ALLOWANCE_MS, STARTUP_WARN_MS } from "./measure.js";
7
7
  import { FEEDBACK_ADDRESS } from "./feedback.js";
8
8
  import { loadProfile, profileInitialized, wakeMode } from "./profile.js";
9
- import { isApprovedWaker, loadPolicy, relayKnowsAdmitters } from "./worker-policy.js";
9
+ import { isApprovedWaker, loadPolicy, relayKnowsAdmitters, unsandboxedAllowed } from "./worker-policy.js";
10
10
  import { commandState } from "./command-state.js";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { homedir, platform } from "node:os";
@@ -18,7 +18,7 @@ import { approvalSummary, chosenApproval, copilotMcpConfigFile, kiroMcpConfigFil
18
18
  import { hookState, readIntent, agentHookStates } from "./notify.js";
19
19
  import { loadPersonas } from "./persona.js";
20
20
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
21
- import { chooseAgent, describeChoice, unattendedRefusal, skippedSummary } from "./agents.js";
21
+ import { agentById, chooseAgent, describeChoice, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
22
22
  import { onPathNamed } from "./which.js";
23
23
  import { VERSION } from "./version.js";
24
24
  import { serviceInstalled, servicesUnavailable } from "./service.js";
@@ -138,14 +138,15 @@ export async function doctor(projectDir) {
138
138
  add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`, undefined, "present");
139
139
  const here = resolveFor(projectDir, { strict: true });
140
140
  const nTeams = Object.keys(loadAll()?.spaces ?? {}).length;
141
+ const folderHere = here.reason === "binding" && !here.repo;
141
142
  if (here.reason === "binding")
142
- 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)`);
143
144
  else if (here.reason === "single")
144
145
  add("pass", "team", `"${cfg.space}" — your only team, so every folder resolves to it`, undefined, "one team on this machine");
145
146
  else if (here.reason === "unbound")
146
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`);
147
148
  else if (here.reason === "stale")
148
- 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");
149
150
  if (here.repo && !here.repo.shared)
150
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");
151
152
  const nonce = "doctor-selftest";
@@ -208,7 +209,10 @@ export async function doctor(projectDir) {
208
209
  ? add("pass", "PATH", "`pantheon` resolves by name", undefined, "pantheon resolves by name")
209
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");
210
211
  const inRepo = git(["rev-parse", "--is-inside-work-tree"], projectDir) === "true";
211
- 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) {
212
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");
213
217
  }
214
218
  else {
@@ -226,20 +230,28 @@ export async function doctor(projectDir) {
226
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");
227
231
  if (agents.length) {
228
232
  try {
229
- 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 });
230
239
  const line = describeChoice(choice);
231
240
  const bad = choice.signIn && !choice.signIn.ok && !choice.signIn.unknown;
232
241
  const fellBack = !!prof.agent.primary_surface && choice.backend.id !== prof.agent.primary_surface && agents.length > 0;
233
242
  const shareLine = `${choice.backend.label} — ${choice.signIn?.detail ?? "not checked"}${choice.skipped.length ? `; ${skippedSummary(choice)}` : ""}`;
234
- const refused = unattendedRefusal(choice);
243
+ const refused = unattendedRefusal(choice, { unsandboxedOk });
244
+ const surface = prof.agent.primary_surface ? agentById(prof.agent.primary_surface) : undefined;
235
245
  if (refused)
236
- 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);
237
247
  else if (bad)
238
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);
239
249
  else if (fellBack)
240
250
  add("warn", "worker agent", line, prof.agent.primary_surface === "kiro"
241
- ? `you chose Kiro at setup; Kiro cannot yet be fenced to its worktree for unattended runs, so runs use ${choice.backend.label}`
242
- : `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);
243
255
  else
244
256
  add("pass", "worker agent", line, undefined, shareLine);
245
257
  }
@@ -371,17 +383,51 @@ export async function doctor(projectDir) {
371
383
  const hookStates = agentHookStates(projectDir);
372
384
  const hook = hookStates[0] ?? hookState(projectDir);
373
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
+ }
374
412
  const workerUp = workerProc().running;
375
413
  const mode = wakeMode(loadProfile(cfg));
414
+ const anyoneWhy = loadProfile(cfg).agent.wake_default === "anyone" ? "you chose \"anyone\"" : "\"anyone\" is the default";
376
415
  const policy = loadPolicy();
377
416
  const approvedHere = admittedPeers.filter((m) => isApprovedWaker(policy, [client.spaceId ?? cfg.spaceId, cfg.space], m.member));
378
417
  const teamKey = client.spaceId ?? cfg.spaceId ?? cfg.space;
379
418
  const notCarried = mode === "approved" && !policy.wakersCarried?.[teamKey] && !policy.wakersCarried?.[cfg.space] && !relayKnowsAdmitters(client.members, cfg.member);
380
419
  const effective = mode === "anyone" || notCarried ? wakers : mode === "no" ? [] : approvedHere.filter((m) => m.may_wake);
381
420
  const blockedByTeam = mode === "approved" ? approvedHere.filter((m) => !m.may_wake) : [];
382
- if (!effective.length) {
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) {
383
429
  const why = mode === "no" ? "this machine is set not to be woken"
384
- : mode === "anyone" ? "you chose \"anyone\", and the team lets nobody wake workers"
430
+ : mode === "anyone" ? `${anyoneWhy}, and the team lets nobody wake workers`
385
431
  : blockedByTeam.length ? `approved here: ${blockedByTeam.map((m) => m.member).join(", ")}, but the team does not let them wake workers yet`
386
432
  : "nobody is approved on this machine";
387
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"
@@ -389,7 +435,7 @@ export async function doctor(projectDir) {
389
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"}`);
390
436
  }
391
437
  else {
392
- const who = `${effective.map((m) => m.member).join(", ")} can wake a worker here (${mode === "anyone" ? "you chose \"anyone\"" : notCarried ? "the team's may_wake decides until this relay is 0.30 or newer" : "approved on this machine"})`;
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"})`;
393
439
  const wakers = effective;
394
440
  if (workerUp) {
395
441
  add("pass", "wake rights", `${who} — worker is running`, undefined, `${wakers.length} may wake; worker running`);
@@ -398,12 +444,44 @@ export async function doctor(projectDir) {
398
444
  const noSvc = servicesUnavailable(host.platform);
399
445
  add("warn", "wake rights", `${who}, but no worker is running here, so nothing can be woken`, noSvc
400
446
  ? "Pantheon does not run in the background on Windows yet, so a peer's act waits until your agent is open"
401
- : "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
+ }
402
461
  }
403
462
  }
404
463
  {
405
464
  const cs = commandState({ project: projectDir });
406
- 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"})`);
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
+ }
407
485
  if (cs.level !== "off" && host.platform === "linux") {
408
486
  const missing = ["bwrap", "socat"].filter((b) => !onPathNamed(b, "linux"));
409
487
  if (missing.length)
@@ -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