@join-pantheon/cli 0.24.4 → 0.25.1
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 +26 -1
- package/dist/admin-stats.js +106 -0
- package/dist/cli.js +77 -10
- package/dist/client.js +2 -0
- package/dist/config.js +28 -0
- package/dist/decision-ui.js +31 -9
- package/dist/doctor.js +118 -62
- package/dist/feedback.js +3 -0
- package/dist/grants.js +38 -17
- package/dist/humanize.js +2 -0
- package/dist/install.js +97 -21
- package/dist/machine.js +12 -5
- package/dist/mcp-instructions.js +85 -0
- package/dist/mcp-supervisor.js +2 -1
- package/dist/mcp.js +47 -82
- package/dist/measure.js +142 -0
- package/dist/notice.js +7 -2
- package/dist/relay.js +86 -2
- package/dist/roster-file.js +17 -0
- package/dist/run-failure.js +97 -0
- package/dist/service.js +9 -1
- package/dist/setup-guide.js +30 -2
- package/dist/tools.js +9 -2
- package/dist/tray.js +10 -3
- package/dist/upgrade.js +11 -1
- package/dist/worker-policy.js +3 -1
- package/dist/worker.js +107 -13
- package/helper/macos/main.swift +22 -1
- package/helper/macos/menubar.swift +51 -13
- package/package.json +1 -1
package/dist/doctor.js
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { instructionBudget } from "./mcp-instructions.js";
|
|
2
3
|
import { describeWindows, scanMcpWindows } from "./mcp-windows.js";
|
|
4
|
+
import { grantsSeen, measureMcp, toolsReport, HOST_STARTUP_ALLOWANCE_MS, STARTUP_WARN_MS } from "./measure.js";
|
|
5
|
+
import { FEEDBACK_ADDRESS } from "./feedback.js";
|
|
3
6
|
import { loadProfile, profileInitialized } from "./profile.js";
|
|
4
7
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
8
|
import { join } from "node:path";
|
|
6
9
|
import { PantheonClient } from "./client.js";
|
|
7
10
|
import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
|
|
8
11
|
import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
|
|
9
|
-
import { onPath } from "./install.js";
|
|
12
|
+
import { approvalSummary, chosenApproval, onPath } from "./install.js";
|
|
10
13
|
import { hookState, readIntent, agentHookStates } from "./notify.js";
|
|
11
14
|
import { loadPersonas } from "./persona.js";
|
|
12
|
-
import { workerProc } from "./machine.js";
|
|
15
|
+
import { workerProc, menubarProc, notifierProc } from "./machine.js";
|
|
13
16
|
import { VERSION } from "./version.js";
|
|
14
17
|
import { serviceInstalled } from "./service.js";
|
|
15
18
|
import { hostEnv } from "./host-env.js";
|
|
19
|
+
const HOSTED_RELAY = "relay.joinpantheon.network";
|
|
16
20
|
const sh = (cmd, cwd) => {
|
|
17
21
|
try {
|
|
18
22
|
return execFileSync("sh", ["-lc", cmd], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
@@ -43,58 +47,60 @@ function isPrivate(host) {
|
|
|
43
47
|
}
|
|
44
48
|
export async function doctor(projectDir) {
|
|
45
49
|
const out = [];
|
|
46
|
-
const add = (level, name, detail, fix) => out.push({ level, name, detail, ...(fix ? { fix } : {}) });
|
|
50
|
+
const add = (level, name, detail, fix, share) => out.push({ level, name, detail, ...(fix ? { fix } : {}), ...(share ? { share } : {}) });
|
|
47
51
|
const major = Number(process.versions.node.split(".")[0]);
|
|
48
52
|
major >= 22
|
|
49
|
-
? add("pass", "node", `v${process.versions.node}`)
|
|
50
|
-
: 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");
|
|
53
|
+
? add("pass", "node", `v${process.versions.node}`, undefined, `v${process.versions.node}`)
|
|
54
|
+
: 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`);
|
|
51
55
|
const host = hostEnv();
|
|
52
56
|
if (host.ephemeral) {
|
|
53
|
-
add("warn", "machine", `${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 ${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
|
|
57
|
+
add("warn", "machine", `${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 ${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"}`);
|
|
54
58
|
}
|
|
55
59
|
else {
|
|
56
|
-
add("pass", "machine", `${host.platform}${host.desktop ? " — menu bar and desktop alerts can run here" : " — no menu bar or desktop alerts (macOS only); use phone or chat delivery"}`);
|
|
60
|
+
add("pass", "machine", `${host.platform}${host.desktop ? " — menu bar and desktop alerts can run here" : " — no menu bar or desktop alerts (macOS only); use phone or chat delivery"}`, undefined, `${host.platform}${host.desktop ? ", desktop" : ""}`);
|
|
57
61
|
}
|
|
58
62
|
let cfg = null;
|
|
59
63
|
try {
|
|
60
64
|
cfg = load();
|
|
61
65
|
}
|
|
62
66
|
catch (e) {
|
|
63
|
-
add("fail", "config", `unreadable: ${e.message}
|
|
67
|
+
add("fail", "config", `unreadable: ${e.message}`, undefined, "unreadable");
|
|
64
68
|
}
|
|
65
69
|
if (!cfg) {
|
|
66
|
-
|
|
70
|
+
if (!out.some((c) => c.name === "config")) {
|
|
71
|
+
add("fail", "config", "no config found", "run `pantheon signup` (new here) then `pantheon start` (create a team), or `pantheon join <invite>` (join one)", "none");
|
|
72
|
+
}
|
|
67
73
|
return out;
|
|
68
74
|
}
|
|
69
|
-
add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})
|
|
75
|
+
add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`, undefined, "present");
|
|
70
76
|
const here = resolveFor(projectDir, { strict: true });
|
|
71
77
|
const nTeams = Object.keys(loadAll()?.spaces ?? {}).length;
|
|
72
78
|
if (here.reason === "binding")
|
|
73
|
-
add("pass", "team", `this repo is bound to "${here.cfg.space}"${here.repo?.origin ? ` (${here.repo.origin})` : ""}`);
|
|
79
|
+
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)`);
|
|
74
80
|
else if (here.reason === "single")
|
|
75
|
-
add("pass", "team", `"${cfg.space}" — your only team, so every folder resolves to it
|
|
81
|
+
add("pass", "team", `"${cfg.space}" — your only team, so every folder resolves to it`, undefined, "one team on this machine");
|
|
76
82
|
else if (here.reason === "unbound")
|
|
77
|
-
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)`);
|
|
83
|
+
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`);
|
|
78
84
|
else if (here.reason === "stale")
|
|
79
|
-
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>");
|
|
85
|
+
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");
|
|
80
86
|
if (here.repo && !here.repo.shared)
|
|
81
|
-
add("warn", "team", "this repo has no origin remote", "DELIVERED must name a commit a teammate can fetch; add a remote before delivering");
|
|
87
|
+
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");
|
|
82
88
|
const nonce = "doctor-selftest";
|
|
83
89
|
const sig = signChallenge(cfg.privateKey, cfg.space, cfg.member, nonce);
|
|
84
90
|
verifyChallenge(cfg.publicKey, cfg.space, cfg.member, nonce, sig)
|
|
85
|
-
? add("pass", "identity", `keypair valid — fingerprint ${fingerprint(cfg.publicKey)}
|
|
86
|
-
: add("fail", "identity", "your keypair does not verify against itself", "the config is corrupt — re-run `pantheon join` to mint a new identity, then get re-admitted");
|
|
91
|
+
? add("pass", "identity", `keypair valid — fingerprint ${fingerprint(cfg.publicKey)}`, undefined, "keypair valid")
|
|
92
|
+
: add("fail", "identity", "your keypair does not verify against itself", "the config is corrupt — re-run `pantheon join` to mint a new identity, then get re-admitted", "keypair does not verify");
|
|
87
93
|
const prof = loadProfile(cfg);
|
|
88
94
|
if (profileInitialized(prof)) {
|
|
89
95
|
const owns = Object.entries(prof.authority).filter(([, mode]) => mode === "human_required").map(([d]) => d);
|
|
90
|
-
add("pass", "profile", `${prof.human.display_name} — ${prof.human.title}${owns.length ? `; you personally decide: ${owns.join(", ")}` : "; no human-required domains yet"}`);
|
|
96
|
+
add("pass", "profile", `${prof.human.display_name} — ${prof.human.title}${owns.length ? `; you personally decide: ${owns.join(", ")}` : "; no human-required domains yet"}`, undefined, `published, ${owns.length} human-required domain${owns.length === 1 ? "" : "s"}`);
|
|
91
97
|
}
|
|
92
98
|
else {
|
|
93
|
-
add("warn", "profile", "no profile published — decisions cannot be routed to you, and a HUMAN_REQUIRED naming you arrives marked unverified", "pantheon profile set --name <you> --title <role> --human-required <domains you personally decide>");
|
|
99
|
+
add("warn", "profile", "no profile published — decisions cannot be routed to you, and a HUMAN_REQUIRED naming you arrives marked unverified", "pantheon profile set --name <you> --title <role> --human-required <domains you personally decide>", "none published");
|
|
94
100
|
}
|
|
95
101
|
const mcpFile = join(projectDir, ".mcp.json");
|
|
96
102
|
if (!existsSync(mcpFile)) {
|
|
97
|
-
add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`");
|
|
103
|
+
add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`", "no .mcp.json in this project");
|
|
98
104
|
}
|
|
99
105
|
else {
|
|
100
106
|
let entry = {};
|
|
@@ -103,64 +109,67 @@ export async function doctor(projectDir) {
|
|
|
103
109
|
}
|
|
104
110
|
catch { }
|
|
105
111
|
if (!entry.command) {
|
|
106
|
-
add("fail", "mcp entry", ".mcp.json has no pantheon server", "run `pantheon install`");
|
|
112
|
+
add("fail", "mcp entry", ".mcp.json has no pantheon server", "run `pantheon install`", ".mcp.json has no pantheon server");
|
|
107
113
|
}
|
|
108
114
|
else if (entry.command === "pantheon") {
|
|
109
|
-
add("pass", "mcp entry", "portable (`pantheon mcp`) — safe to commit, works on your teammate's machine");
|
|
115
|
+
add("pass", "mcp entry", "portable (`pantheon mcp`) — safe to commit, works on your teammate's machine", undefined, "portable");
|
|
110
116
|
}
|
|
111
117
|
else {
|
|
112
|
-
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.");
|
|
118
|
+
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");
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
onPath()
|
|
116
|
-
? add("pass", "PATH", "`pantheon` resolves by name")
|
|
117
|
-
: 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");
|
|
122
|
+
? add("pass", "PATH", "`pantheon` resolves by name", undefined, "pantheon resolves by name")
|
|
123
|
+
: 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");
|
|
118
124
|
const inRepo = sh("git rev-parse --is-inside-work-tree", projectDir) === "true";
|
|
119
125
|
if (!inRepo) {
|
|
120
|
-
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");
|
|
126
|
+
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");
|
|
121
127
|
}
|
|
122
128
|
else {
|
|
123
129
|
const remote = sh("git remote get-url origin", projectDir);
|
|
124
130
|
remote
|
|
125
|
-
? add("pass", "git", `repo with origin ${remote}
|
|
126
|
-
: add("fail", "git", "repo has no `origin` remote", "DELIVERED names a commit your teammate fetches. Without a shared remote they cannot verify anything you deliver.");
|
|
131
|
+
? add("pass", "git", `repo with origin ${remote}`, undefined, "repo with an origin remote")
|
|
132
|
+
: add("fail", "git", "repo has no `origin` remote", "DELIVERED names a commit your teammate fetches. Without a shared remote they cannot verify anything you deliver.", "repo without an origin remote");
|
|
127
133
|
const branch = sh("git rev-parse --abbrev-ref HEAD", projectDir);
|
|
128
134
|
if (branch)
|
|
129
135
|
add("pass", "git branch", branch);
|
|
130
136
|
}
|
|
131
137
|
const agents = ["claude", "codex", "cursor-agent"].filter((a) => sh(`command -v ${a}`));
|
|
132
138
|
agents.length
|
|
133
|
-
? add("pass", "agent cli", agents.join(", "))
|
|
134
|
-
: add("warn", "agent cli", "found none of claude / codex / cursor-agent on PATH", "fine if you only drive agents through a GUI (Conductor, Cursor), but `pantheon worker` needs a CLI to spawn");
|
|
139
|
+
? add("pass", "agent cli", agents.join(", "), undefined, agents.join(", "))
|
|
140
|
+
: add("warn", "agent cli", "found none of claude / codex / cursor-agent on PATH", "fine if you only drive agents through a GUI (Conductor, Cursor), but `pantheon worker` needs a CLI to spawn", "none of claude / codex / cursor-agent on PATH");
|
|
135
141
|
const url = new URL(cfg.relay);
|
|
142
|
+
const hosted = url.host === HOSTED_RELAY;
|
|
136
143
|
if (url.protocol === "ws:" && !isPrivate(url.host)) {
|
|
137
|
-
add("fail", "transport", `plain ws:// to a public address (${url.host}) — your space token crosses the internet in clear text`, "put it behind TLS (cloudflared/Caddy) and use a pantheons:// invite, or move the relay onto a private network (Tailscale)");
|
|
144
|
+
add("fail", "transport", `plain ws:// to a public address (${url.host}) — your space token crosses the internet in clear text`, "put it behind TLS (cloudflared/Caddy) and use a pantheons:// invite, or move the relay onto a private network (Tailscale)", "plain ws:// to a public address");
|
|
138
145
|
}
|
|
139
146
|
else {
|
|
140
|
-
add("pass", "transport", url.protocol === "wss:" ? "wss:// (TLS)" : `ws:// on a private address (${url.host})
|
|
147
|
+
add("pass", "transport", url.protocol === "wss:" ? "wss:// (TLS)" : `ws:// on a private address (${url.host})`, undefined, url.protocol === "wss:" ? "wss:// (TLS)" : "ws:// on a private address");
|
|
141
148
|
}
|
|
142
149
|
const health = `${cfg.relay.replace(/^ws/, "http")}/health`;
|
|
150
|
+
const where = hosted ? "hosted" : "self-hosted";
|
|
143
151
|
try {
|
|
144
152
|
const res = await fetch(health, { signal: AbortSignal.timeout(5000) });
|
|
145
153
|
const body = (await res.json());
|
|
146
154
|
if (!body.ok) {
|
|
147
|
-
add("fail", "relay", `answered but not healthy: ${JSON.stringify(body)}`);
|
|
155
|
+
add("fail", "relay", `answered but not healthy: ${JSON.stringify(body)}`, undefined, `answered but not healthy (${where})`);
|
|
148
156
|
}
|
|
149
157
|
else {
|
|
150
|
-
add("pass", "relay", `reachable at ${health}`);
|
|
158
|
+
add("pass", "relay", `reachable at ${health}`, undefined, `reachable (${where})`);
|
|
151
159
|
if (body.pkg && body.pkg !== VERSION) {
|
|
152
|
-
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");
|
|
160
|
+
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}`);
|
|
153
161
|
}
|
|
154
162
|
else if (body.pkg) {
|
|
155
|
-
add("pass", "version", `${VERSION}, matching the relay`);
|
|
163
|
+
add("pass", "version", `${VERSION}, matching the relay`, undefined, `${VERSION}, matching the relay`);
|
|
156
164
|
}
|
|
157
165
|
else {
|
|
158
|
-
add("warn", "version", `this machine is ${VERSION}; the relay is too old to report its own`, "update the relay host, then re-run this");
|
|
166
|
+
add("warn", "version", `this machine is ${VERSION}; the relay is too old to report its own`, "update the relay host, then re-run this", `this machine ${VERSION}; relay too old to say`);
|
|
159
167
|
}
|
|
160
168
|
}
|
|
161
169
|
}
|
|
162
170
|
catch (e) {
|
|
163
|
-
|
|
171
|
+
const msg = e.message;
|
|
172
|
+
add("fail", "relay", `cannot reach ${health} — ${msg}`, "start it with `pantheon relay`, or check the tunnel/tailnet. Your teammate must be able to reach the SAME address.", `unreachable (${where}): ${/abort|timeout/i.test(msg) ? "timed out" : /ECONNREFUSED/.test(msg) ? "connection refused" : /ENOTFOUND|EAI_AGAIN/.test(msg) ? "name did not resolve" : "fetch failed"}`);
|
|
164
173
|
return out;
|
|
165
174
|
}
|
|
166
175
|
const client = new PantheonClient(cfg);
|
|
@@ -168,22 +177,23 @@ export async function doctor(projectDir) {
|
|
|
168
177
|
await client.connect().then(() => { joined = true; }).catch(() => { });
|
|
169
178
|
if (!joined) {
|
|
170
179
|
client.pending
|
|
171
|
-
? add("fail", "membership", "registered but not admitted", `ask an admitted member to run: pantheon admit ${cfg.member} --may-wake (confirm fingerprint ${fingerprint(cfg.publicKey)} with them by voice first)
|
|
172
|
-
: add("fail", "membership", `refused: ${client.lastError ?? "unknown"}`, "if this is KEY_MISMATCH, someone else already registered your member name — pick another with --me, or have them revoke it");
|
|
180
|
+
? add("fail", "membership", "registered but not admitted", `ask an admitted member to run: pantheon admit ${cfg.member} --may-wake (confirm fingerprint ${fingerprint(cfg.publicKey)} with them by voice first)`, "registered, not admitted")
|
|
181
|
+
: add("fail", "membership", `refused: ${client.lastError ?? "unknown"}`, "if this is KEY_MISMATCH, someone else already registered your member name — pick another with --me, or have them revoke it", `refused: ${String(client.lastError ?? "unknown").split(/[\s:]/)[0]}`);
|
|
173
182
|
client.close();
|
|
174
183
|
return out;
|
|
175
184
|
}
|
|
176
185
|
await new Promise((r) => setTimeout(r, 400));
|
|
177
|
-
add("pass", "membership", `admitted as ${cfg.member}
|
|
186
|
+
add("pass", "membership", `admitted as ${cfg.member}`, undefined, "admitted");
|
|
178
187
|
const admittedPeers = client.members.filter((m) => m.admitted && m.member !== cfg.member);
|
|
179
188
|
const online = new Set(client.roster.filter((m) => m.online).map((m) => m.id));
|
|
180
189
|
const pending = client.members.filter((m) => !m.admitted);
|
|
190
|
+
const peersOnline = admittedPeers.filter((p) => online.has(p.member)).length;
|
|
181
191
|
admittedPeers.length
|
|
182
192
|
? add("pass", "peers", admittedPeers
|
|
183
|
-
.map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "))
|
|
184
|
-
: 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)");
|
|
193
|
+
.map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "), undefined, `${admittedPeers.length} admitted, ${peersOnline} with a session open`)
|
|
194
|
+
: 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");
|
|
185
195
|
if (pending.length) {
|
|
186
|
-
add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} --may-wake`);
|
|
196
|
+
add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} --may-wake`, `${pending.length} waiting to be admitted`);
|
|
187
197
|
}
|
|
188
198
|
const wakers = client.members.filter((m) => m.admitted && m.may_wake && m.member !== cfg.member);
|
|
189
199
|
const hookStates = agentHookStates(projectDir);
|
|
@@ -191,57 +201,91 @@ export async function doctor(projectDir) {
|
|
|
191
201
|
const reportsActivity = hookStates.some((s) => s.events?.some((e) => /prompt|UserPromptSubmit|beforeSubmitPrompt/i.test(e))) || (hook.events?.includes("UserPromptSubmit") ?? false);
|
|
192
202
|
const workerUp = workerProc().running;
|
|
193
203
|
if (!wakers.length) {
|
|
194
|
-
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");
|
|
204
|
+
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"}`);
|
|
195
205
|
}
|
|
196
206
|
else {
|
|
197
207
|
const who = `${wakers.map((m) => m.member).join(", ")} may wake a worker (space-wide grant)`;
|
|
198
208
|
if (workerUp) {
|
|
199
|
-
add("pass", "wake rights", `${who} — worker is running`);
|
|
209
|
+
add("pass", "wake rights", `${who} — worker is running`, undefined, `${wakers.length} may wake; worker running`);
|
|
200
210
|
}
|
|
201
211
|
else {
|
|
202
|
-
add("warn", "wake rights", `${who}, but no worker is running here, so nothing can be woken`, "start one with `pantheon service install worker --project <a git repo>`; until then a peer's act waits for you to type");
|
|
212
|
+
add("warn", "wake rights", `${who}, but no worker is running here, so nothing can be woken`, "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`);
|
|
203
213
|
}
|
|
204
214
|
}
|
|
205
215
|
if (host.desktop) {
|
|
206
|
-
serviceInstalled("menubar")
|
|
207
|
-
|
|
208
|
-
|
|
216
|
+
if (!serviceInstalled("menubar")) {
|
|
217
|
+
add("warn", "menu bar", "not installed on a Mac that can run it", "install it with: pantheon service install menubar (needs the Xcode Command Line Tools — xcode-select --install — and a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice", "not installed");
|
|
218
|
+
}
|
|
219
|
+
else if (!menubarProc().running) {
|
|
220
|
+
add("warn", "menu bar", "installed, but not running — launchd is not holding it, so there is no icon right now", "pantheon service restart menubar (loads it back; something unloaded it — a stop, a crash loop, or a test run on this machine)", "installed, not running");
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
add("pass", "menu bar", "installed and running — status, pause/resume, and a badge when a decision needs you", undefined, "installed and running");
|
|
224
|
+
}
|
|
225
|
+
if (!serviceInstalled("notifier")) {
|
|
226
|
+
add("warn", "alerts", "not installed — no desktop notification when a teammate needs you", "pantheon service install notifier (--no-notifications at setup also leaves it off; that is fine if it was your choice)", "not installed");
|
|
227
|
+
}
|
|
228
|
+
else if (!notifierProc().running) {
|
|
229
|
+
add("warn", "alerts", "installed, but not running — nothing on this Mac will show you a teammate's message", "pantheon service restart notifier (loads it back; something unloaded it — a stop, a crash loop, or a test run on this machine)", "installed, not running");
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
add("pass", "alerts", "installed and running — a desktop notification when a teammate needs you", undefined, "installed and running");
|
|
233
|
+
}
|
|
209
234
|
}
|
|
210
235
|
if (!hookStates.length && !hook.installed) {
|
|
211
|
-
add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor on PATH)");
|
|
236
|
+
add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor on PATH)", "not installed");
|
|
212
237
|
}
|
|
213
238
|
else if (!reportsActivity) {
|
|
214
|
-
add("warn", "wake-up hook", `installed (${hook.events?.join(", ")}) but predates the activity hook, so a worker cannot tell your idle editor from one you are using`, "re-run `pantheon hook install --user` to add the UserPromptSubmit half");
|
|
239
|
+
add("warn", "wake-up hook", `installed (${hook.events?.join(", ")}) but predates the activity hook, so a worker cannot tell your idle editor from one you are using`, "re-run `pantheon hook install --user` to add the UserPromptSubmit half", `installed (${hook.events?.join(", ")}), predates the activity hook`);
|
|
215
240
|
}
|
|
216
241
|
else {
|
|
217
242
|
const intent = readIntent();
|
|
218
243
|
intent?.repairs
|
|
219
|
-
? add("warn", "wake-up hook", `${hook.events?.join(", ")} (${hook.scope} scope) — but it has been removed and put back ${intent.repairs} time${intent.repairs === 1 ? "" : "s"}`, "something on this machine rewrites ~/.claude/settings.json and drops the hooks key. The worker repairs it, but only while the worker is running")
|
|
220
|
-
: add("pass", "wake-up hook", `${hook.events?.join(", ")} (${hook.scope} scope)`);
|
|
244
|
+
? add("warn", "wake-up hook", `${hook.events?.join(", ")} (${hook.scope} scope) — but it has been removed and put back ${intent.repairs} time${intent.repairs === 1 ? "" : "s"}`, "something on this machine rewrites ~/.claude/settings.json and drops the hooks key. The worker repairs it, but only while the worker is running", `${hook.events?.join(", ")} (${hook.scope} scope), repaired ${intent.repairs} time${intent.repairs === 1 ? "" : "s"}`)
|
|
245
|
+
: add("pass", "wake-up hook", `${hook.events?.join(", ")} (${hook.scope} scope)`, undefined, `${hook.events?.join(", ")} (${hook.scope} scope)`);
|
|
246
|
+
}
|
|
247
|
+
const budget = instructionBudget(cfg);
|
|
248
|
+
const budgetLine = `${budget.chars} chars (~${budget.tokens} tokens) of session instructions per turn; the ${budget.protocolChars}-char protocol arrives with the first tool call instead`;
|
|
249
|
+
add(budget.chars > 1_200 ? "warn" : "pass", "mcp context", budgetLine, budget.chars > 1_200 ? "this used to be ~3,800 chars on every turn, and it competed with the work people asked for" : undefined, budgetLine);
|
|
250
|
+
const approval = chosenApproval();
|
|
251
|
+
add("pass", "approval", approvalSummary(approval), `change with pantheon setup --ask-before-sending (or --approve-all); pantheon leave removes the Codex line`, approval);
|
|
252
|
+
const measured = await measureMcp({ cwd: projectDir });
|
|
253
|
+
if ("error" in measured) {
|
|
254
|
+
add("warn", "mcp startup", `could not measure: ${measured.error}`, "this is what an agent window sees when it starts pantheon here — reinstall with npm install -g @join-pantheon/cli, then re-run this", `could not measure: ${/missing/.test(measured.error) ? "server file missing" : /no answer/.test(measured.error) ? "no answer to initialize" : "exited before answering"}`);
|
|
221
255
|
}
|
|
256
|
+
else {
|
|
257
|
+
const startupLine = `answered initialize in ${measured.initializeMs}ms, listed its tools by ${measured.toolsMs}ms (Codex gives it ${HOST_STARTUP_ALLOWANCE_MS / 1000}s)`;
|
|
258
|
+
add(measured.initializeMs > STARTUP_WARN_MS ? "warn" : "pass", "mcp startup", startupLine, measured.initializeMs > STARTUP_WARN_MS ? "slow for a step that no longer waits on the network — a loaded machine, a slow disk, or a very old Node; re-run when the machine is quiet" : undefined, startupLine);
|
|
259
|
+
}
|
|
260
|
+
const tools = toolsReport("error" in measured ? null : measured.tools, grantsSeen(), approval);
|
|
261
|
+
add(tools.level, "mcp tools", tools.detail, tools.fix, tools.detail);
|
|
222
262
|
if (hookStates.some((s) => s.agent === "codex")) {
|
|
223
|
-
add("warn", "codex hooks", "installed — but codex skips new or changed hooks until they are approved", "run /hooks inside codex once to approve them (needs codex ≥ 0.115; sessions still report presence over MCP either way)");
|
|
263
|
+
add("warn", "codex hooks", "installed — but codex skips new or changed hooks until they are approved", "run /hooks inside codex once to approve them (needs codex ≥ 0.115; sessions still report presence over MCP either way)", "installed, not yet approved inside codex");
|
|
224
264
|
}
|
|
225
265
|
const windows = scanMcpWindows();
|
|
226
266
|
const w = describeWindows(windows, VERSION);
|
|
227
|
-
|
|
267
|
+
const live = windows.filter((x) => x.kind !== "orphan");
|
|
268
|
+
const byVersion = [...live.reduce((m, x) => m.set(x.running ?? "unknown", (m.get(x.running ?? "unknown") ?? 0) + 1), new Map())]
|
|
269
|
+
.map(([v, n]) => `${n} on ${v}`).join(", ");
|
|
270
|
+
add(w.level, "agent windows", w.detail, w.fix, live.length ? `${live.length} open: ${byVersion}` : "none open");
|
|
228
271
|
const orphans = windows.filter((x) => x.kind === "orphan");
|
|
229
272
|
if (orphans.length) {
|
|
230
|
-
add("warn", "orphans", `${orphans.length} pantheon server${orphans.length > 1 ? "s" : ""} left behind by windows that no longer exist (pid ${orphans.map((x) => x.pid).join(", ")})`, `harmless but pointless — pantheon upgrade ends them, or: kill ${orphans.map((x) => x.pid).join(" ")}`);
|
|
273
|
+
add("warn", "orphans", `${orphans.length} pantheon server${orphans.length > 1 ? "s" : ""} left behind by windows that no longer exist (pid ${orphans.map((x) => x.pid).join(", ")})`, `harmless but pointless — pantheon upgrade ends them, or: kill ${orphans.map((x) => x.pid).join(" ")}`, `${orphans.length}`);
|
|
231
274
|
}
|
|
232
275
|
const personas = loadPersonas(projectDir);
|
|
233
276
|
if (personas.length) {
|
|
234
277
|
const broken = personas.filter((p) => p.warnings.length);
|
|
235
278
|
broken.length
|
|
236
|
-
? add("warn", "personas", `${personas.map((p) => p.name).join(", ")} — ${broken.map((p) => `${p.name}: ${p.warnings[0]}`).join("; ")}`, "a persona line that does not parse grants nothing and filters nothing — pantheon persona show <name>")
|
|
237
|
-
: add("pass", "personas", `${personas.map((p) => p.name).join(", ")} — the worker only takes work a persona covers`);
|
|
279
|
+
? add("warn", "personas", `${personas.map((p) => p.name).join(", ")} — ${broken.map((p) => `${p.name}: ${p.warnings[0]}`).join("; ")}`, "a persona line that does not parse grants nothing and filters nothing — pantheon persona show <name>", `${personas.length}, ${broken.length} that do not parse`)
|
|
280
|
+
: add("pass", "personas", `${personas.map((p) => p.name).join(", ")} — the worker only takes work a persona covers`, undefined, `${personas.length}`);
|
|
238
281
|
}
|
|
239
282
|
const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
|
|
240
|
-
|
|
283
|
+
const stateLine = `${client.head} acts in the ledger, ${open.length} open goal(s) — restored across relay restarts`;
|
|
284
|
+
add("pass", "state", stateLine, undefined, stateLine);
|
|
241
285
|
if (existsSync(join(projectDir, "pantheon-data", `${cfg.space}.jsonl`))) {
|
|
242
286
|
serviceInstalled("relay")
|
|
243
|
-
? add("pass", "relay service", "installed — survives closing this terminal and restarts at login")
|
|
244
|
-
: add("warn", "relay service", "this machine hosts the relay, but not as a service", "closing the terminal running it takes the space down for your teammates too: pantheon service install relay");
|
|
287
|
+
? add("pass", "relay service", "installed — survives closing this terminal and restarts at login", undefined, "this machine hosts the relay, as a service")
|
|
288
|
+
: add("warn", "relay service", "this machine hosts the relay, but not as a service", "closing the terminal running it takes the space down for your teammates too: pantheon service install relay", "this machine hosts the relay, not as a service");
|
|
245
289
|
}
|
|
246
290
|
client.close();
|
|
247
291
|
return out;
|
|
@@ -261,6 +305,18 @@ export function render(checks) {
|
|
|
261
305
|
? `\x1b[31m${failed} blocking problem${failed > 1 ? "s" : ""}\x1b[0m${warned ? `, ${warned} warning${warned > 1 ? "s" : ""}` : ""} — fix the FAILs before you start`
|
|
262
306
|
: warned ? `\x1b[36mno blockers\x1b[0m, ${warned} warning${warned > 1 ? "s" : ""} — readable above`
|
|
263
307
|
: `\x1b[36mall clear\x1b[0m — both machines are ready`);
|
|
308
|
+
lines.push(`\x1b[2mpantheon doctor --share prints this without names or paths, to paste to ${FEEDBACK_ADDRESS}\x1b[0m`);
|
|
264
309
|
return { text: lines.join("\n"), failed };
|
|
265
310
|
}
|
|
311
|
+
export function shareBlock(checks, now = new Date()) {
|
|
312
|
+
const mark = { pass: "ok ", warn: "warn", fail: "FAIL" };
|
|
313
|
+
return [
|
|
314
|
+
`pantheon ${VERSION} — doctor, ${now.toISOString().slice(0, 16).replace("T", " ")} UTC`,
|
|
315
|
+
"no names, teams, paths or addresses are in this block; it is safe to paste",
|
|
316
|
+
"",
|
|
317
|
+
...checks.filter((c) => c.share).map((c) => ` ${mark[c.level]} ${c.name.padEnd(12)} ${c.share}`),
|
|
318
|
+
"",
|
|
319
|
+
`send it to ${FEEDBACK_ADDRESS} with one line on what you expected to happen`,
|
|
320
|
+
].join("\n");
|
|
321
|
+
}
|
|
266
322
|
//# sourceMappingURL=doctor.js.map
|
package/dist/feedback.js
ADDED
package/dist/grants.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import { PANTHEON_TOOLS, PANTHEON_TOOL_NAMES } from "./tools.js";
|
|
4
|
+
import { PANTHEON_TOOLS, PANTHEON_TOOL_NAMES, toolsAllowed } from "./tools.js";
|
|
5
5
|
export const CLAUDE_ALLOW = PANTHEON_TOOLS;
|
|
6
6
|
export const CURSOR_ALLOW = PANTHEON_TOOL_NAMES.map((t) => `Mcp(pantheon:${t})`);
|
|
7
|
+
export const claudeAllow = (approval) => toolsAllowed(approval).map((t) => `mcp__pantheon__${t}`);
|
|
8
|
+
export const cursorAllow = (approval) => toolsAllowed(approval).map((t) => `Mcp(pantheon:${t})`);
|
|
9
|
+
const isOursClaude = (t) => t.startsWith("mcp__pantheon__");
|
|
10
|
+
const isOursCursor = (t) => t.startsWith("Mcp(pantheon:");
|
|
7
11
|
export const claudeSettingsFile = () => join(homedir(), ".claude", "settings.json");
|
|
8
12
|
export const cursorCliConfigFile = () => join(homedir(), ".cursor", "cli-config.json");
|
|
9
13
|
function readJson(file) {
|
|
@@ -26,16 +30,21 @@ function allowList(doc) {
|
|
|
26
30
|
perms.allow = [];
|
|
27
31
|
return perms.allow;
|
|
28
32
|
}
|
|
29
|
-
|
|
33
|
+
function reconcile(allow, wanted, isOurs) {
|
|
34
|
+
const keep = allow.filter((t) => !isOurs(t) || wanted.includes(t));
|
|
35
|
+
const missing = wanted.filter((t) => !keep.includes(t));
|
|
36
|
+
if (keep.length === allow.length && !missing.length)
|
|
37
|
+
return false;
|
|
38
|
+
allow.splice(0, allow.length, ...keep, ...missing);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
export function grantClaudeToolPermissions(file = claudeSettingsFile(), approval = "approve") {
|
|
30
42
|
const doc = readJson(file);
|
|
31
43
|
if (doc === "invalid")
|
|
32
44
|
return `failed: ${file} is not valid JSON — fix it, then re-run pantheon install --user`;
|
|
33
45
|
const next = doc ?? {};
|
|
34
|
-
|
|
35
|
-
const missing = CLAUDE_ALLOW.filter((t) => !allow.includes(t));
|
|
36
|
-
if (!missing.length)
|
|
46
|
+
if (!reconcile(allowList(next), claudeAllow(approval), isOursClaude))
|
|
37
47
|
return "already granted";
|
|
38
|
-
allow.push(...missing);
|
|
39
48
|
try {
|
|
40
49
|
writeJson(file, next);
|
|
41
50
|
}
|
|
@@ -44,38 +53,50 @@ export function grantClaudeToolPermissions(file = claudeSettingsFile()) {
|
|
|
44
53
|
}
|
|
45
54
|
return "granted";
|
|
46
55
|
}
|
|
47
|
-
export function grantCursorToolPermissions(file = cursorCliConfigFile()) {
|
|
56
|
+
export function grantCursorToolPermissions(file = cursorCliConfigFile(), approval = "approve") {
|
|
48
57
|
const doc = readJson(file);
|
|
49
58
|
if (doc === "invalid")
|
|
50
59
|
return `failed: ${file} is not valid JSON — fix it, then re-run pantheon install --user`;
|
|
51
60
|
const next = doc ?? { version: 1 };
|
|
52
61
|
const allow = allowList(next);
|
|
53
62
|
const perms = next.permissions;
|
|
54
|
-
|
|
63
|
+
const hadDeny = Array.isArray(perms.deny);
|
|
64
|
+
if (!hadDeny)
|
|
55
65
|
perms.deny = [];
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
66
|
+
const changed = reconcile(allow, cursorAllow(approval), isOursCursor);
|
|
67
|
+
if (!changed && hadDeny)
|
|
58
68
|
return "already granted";
|
|
59
|
-
allow.push(...missing);
|
|
60
69
|
try {
|
|
61
70
|
writeJson(file, next);
|
|
62
71
|
}
|
|
63
72
|
catch (e) {
|
|
64
73
|
return `failed: ${e.message}`;
|
|
65
74
|
}
|
|
66
|
-
return "granted";
|
|
75
|
+
return changed ? "granted" : "already granted";
|
|
76
|
+
}
|
|
77
|
+
function granted(file, isOurs) {
|
|
78
|
+
const doc = readJson(file);
|
|
79
|
+
if (doc === null)
|
|
80
|
+
return { state: "absent" };
|
|
81
|
+
if (doc === "invalid")
|
|
82
|
+
return { state: "invalid" };
|
|
83
|
+
const perms = doc.permissions;
|
|
84
|
+
const allow = perms && Array.isArray(perms.allow) ? perms.allow : [];
|
|
85
|
+
return { state: "ok", tools: allow.filter((t) => typeof t === "string" && isOurs(t)) };
|
|
67
86
|
}
|
|
68
|
-
export
|
|
87
|
+
export const claudeGranted = (file = claudeSettingsFile()) => granted(file, isOursClaude);
|
|
88
|
+
export const cursorGranted = (file = cursorCliConfigFile()) => granted(file, isOursCursor);
|
|
89
|
+
export function grantToolPermissions(approval = "approve") {
|
|
69
90
|
return [
|
|
70
|
-
{ tool: "claude perms", status: grantClaudeToolPermissions() },
|
|
71
|
-
{ tool: "cursor perms", status: grantCursorToolPermissions() },
|
|
91
|
+
{ tool: "claude perms", status: grantClaudeToolPermissions(undefined, approval) },
|
|
92
|
+
{ tool: "cursor perms", status: grantCursorToolPermissions(undefined, approval) },
|
|
72
93
|
];
|
|
73
94
|
}
|
|
74
95
|
export function revokeToolPermissions(claudeFile = claudeSettingsFile(), cursorFile = cursorCliConfigFile()) {
|
|
75
96
|
const out = [];
|
|
76
97
|
for (const [file, isOurs] of [
|
|
77
|
-
[claudeFile,
|
|
78
|
-
[cursorFile,
|
|
98
|
+
[claudeFile, isOursClaude],
|
|
99
|
+
[cursorFile, isOursCursor],
|
|
79
100
|
]) {
|
|
80
101
|
const doc = readJson(file);
|
|
81
102
|
if (!doc || doc === "invalid")
|
package/dist/humanize.js
CHANGED
|
@@ -9,6 +9,7 @@ export function stripIds(text, goalTitle) {
|
|
|
9
9
|
}
|
|
10
10
|
return out
|
|
11
11
|
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
12
|
+
.replace(/^decision (?:for|required for|needed from) (?:you|[a-z0-9._-]+)\s*[:—-]\s*/i, "")
|
|
12
13
|
.replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
|
|
13
14
|
.replace(/\(\s*goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
|
|
14
15
|
.replace(/\s*\(\s*goal\s+g_[a-z0-9]+\s*\)/gi, "")
|
|
@@ -198,6 +199,7 @@ export function humanize(body, opts = {}) {
|
|
|
198
199
|
let headline = sentences(firstPara?.text ?? clean)[0] ?? clean;
|
|
199
200
|
headline = headline
|
|
200
201
|
.replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
|
|
202
|
+
.replace(/^decision (?:for|required for|needed from) (?:you|[a-z0-9._-]+)\s*[:—-]\s*/i, "")
|
|
201
203
|
.replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
|
|
202
204
|
.trim();
|
|
203
205
|
if (headline)
|