@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.
- package/README.md +142 -40
- package/bin/pantheon.js +12 -0
- package/dist/accounts.js +151 -26
- package/dist/agents.js +224 -17
- package/dist/claude-confine.js +299 -0
- package/dist/cli.js +1116 -137
- package/dist/client.js +294 -10
- package/dist/command-state.js +102 -0
- package/dist/config.js +287 -14
- package/dist/continued.js +42 -10
- package/dist/doctor.js +191 -23
- package/dist/file-lock.js +197 -0
- package/dist/grants.js +1 -1
- package/dist/history-view.js +2 -1
- package/dist/home-guard.js +565 -0
- package/dist/hooks-install.js +2 -1
- package/dist/identity.js +101 -1
- package/dist/install.js +76 -26
- package/dist/kiro-files.js +2 -2
- package/dist/ledger.js +111 -28
- package/dist/machine.js +494 -32
- package/dist/mcp-supervisor.js +29 -7
- package/dist/mcp-windows.js +47 -25
- package/dist/mcp.js +99 -36
- package/dist/network.js +39 -8
- package/dist/nonces.js +130 -0
- package/dist/notify.js +26 -9
- package/dist/pending.js +121 -22
- package/dist/persona.js +21 -0
- package/dist/pins.js +161 -0
- package/dist/profile.js +22 -1
- package/dist/protocol.js +40 -1
- package/dist/quickstart.js +173 -24
- package/dist/reads.js +37 -0
- package/dist/refusal.js +14 -1
- package/dist/relay.js +1341 -207
- package/dist/repo-find.js +127 -0
- package/dist/repo-route.js +70 -0
- package/dist/roster-file.js +190 -25
- package/dist/run-failure.js +99 -3
- package/dist/secret-guard.js +141 -0
- package/dist/service.js +91 -35
- package/dist/setup-guide.js +91 -31
- package/dist/spend.js +6 -2
- package/dist/store.js +94 -10
- package/dist/text-file.js +74 -0
- package/dist/tray.js +24 -3
- package/dist/untrusted.js +24 -5
- package/dist/update-check.js +2 -0
- package/dist/upgrade.js +9 -5
- package/dist/verify-act.js +102 -0
- package/dist/which.js +69 -12
- package/dist/worker-fleet.js +30 -9
- package/dist/worker-policy.js +231 -7
- package/dist/worker.js +1717 -130
- package/helper/macos/menubar.swift +63 -2
- 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 =
|
|
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
|
-
|
|
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
|
|
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("
|
|
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 = (
|
|
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
|
-
|
|
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, `
|
|
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
|
|
181
|
-
:
|
|
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
|
|
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
|
-
|
|
309
|
-
|
|
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 = `${
|
|
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
|
|
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
package/dist/history-view.js
CHANGED
|
@@ -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
|
|
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)
|