@join-pantheon/cli 0.24.4 → 0.25.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 CHANGED
@@ -87,6 +87,15 @@ asks you in chat, and passes your answers back as flags — to `pantheon setup -
87
87
  `pantheon start` / `pantheon join`, which apply them the moment the team exists — so nobody has to
88
88
  open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
89
89
 
90
+ One thing the setup grants that you should know about: your own agent runs the Pantheon tools
91
+ without a per-call prompt, in Claude Code, Cursor and Codex, so it can coordinate for you without
92
+ stopping to ask. For Codex that is one line, `default_tools_approval_mode = "approve"`, written under
93
+ `[mcp_servers.pantheon]` in `~/.codex/config.toml` — it covers Pantheon's tools only, never the shell or
94
+ any other server. The install output prints that exact line, the defaults screen states the grant,
95
+ `pantheon doctor` shows it, and `pantheon leave` removes it. If you would rather approve each message
96
+ your agent sends in your name, `pantheon setup --ask-before-sending` (or the same flag on start/join)
97
+ makes reads silent and prompts for send, open goal and claim; `--approve-all` switches back.
98
+
90
99
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
91
100
  `pantheon install --user` (from your project's repo if you have one).
92
101
 
@@ -166,6 +175,18 @@ Checks node, your keypair, the MCP entry, whether the repo has a remote (`DELIVE
166
175
  whether the relay is reachable **from this machine**, whether a plain `ws://` is crossing the public
167
176
  internet, and whether you were actually admitted. Non-zero exit means don't start yet.
168
177
 
178
+ It also measures what Pantheon costs your agent, on this machine rather than by estimate: it starts
179
+ the MCP server the way your agent does and times its answer (`mcp startup`, typically ~0.2s), reports
180
+ the instruction text every turn carries (`mcp context`), and lists how many tools the server
181
+ registers against how many each agent on this machine has been told to allow (`mcp tools`). If an
182
+ allow-list has drifted — a tool that would prompt on every call, or one allowed against your
183
+ `--ask-before-sending` choice — the line names the tool, and `pantheon install --user` fixes it.
184
+
185
+ If something is wrong and you want to tell us, `pantheon doctor --share` prints the same run with
186
+ nothing identifying in it — no member or team names, no teammates, no paths, no relay address, no
187
+ fingerprint — ready to paste into an email to sid@joinpantheon.network. Nothing is ever sent by the
188
+ product itself; you read it, and you decide.
189
+
169
190
  <details><summary>The long way round, if you want to control each piece</summary>
170
191
 
171
192
  `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who> --may-wake`
@@ -462,7 +483,7 @@ pantheon join <line> join a teammate's team from their line
462
483
  pantheon invite another single-use line (third person, second machine)
463
484
  pantheon status · brief · team who is here, what needs you, who is in the space
464
485
  pantheon watch live tail of everything the delegates say to each other
465
- pantheon doctor preflight: node, keys, MCP entry, relay, membership
486
+ pantheon doctor [--share] preflight: node, keys, MCP entry, relay, membership, what pantheon costs your agent; --share = paste-safe
466
487
  pantheon worker / service stay answerable while you are away (install as a service)
467
488
  pantheon persona list / init / show — what a woken worker may do
468
489
  pantheon spaces / use / repo several teams on one machine — what is bound where; bind this repo; a team's repos
@@ -113,4 +113,101 @@ export function feed(records, f = {}) {
113
113
  const rows = more ? hits.slice(0, limit) : hits;
114
114
  return { rows, next: more ? rows[rows.length - 1].seq : null };
115
115
  }
116
+ export const VERDICT_ORDER = ["went quiet", "never started", "new", "active", "idle"];
117
+ export function versionBehind(pkg, relay) {
118
+ const a = pkg.split(".").map(Number), b = relay.split(".").map(Number);
119
+ for (let i = 0; i < 3; i++) {
120
+ const x = a[i] ?? 0, y = b[i] ?? 0;
121
+ if (Number.isNaN(x) || Number.isNaN(y))
122
+ return false;
123
+ if (x !== y)
124
+ return x < y;
125
+ }
126
+ return false;
127
+ }
128
+ const daysAgo = (ts, now) => Math.max(0, Math.floor((now - ts) / DAY));
129
+ export function spaceHealth(input) {
130
+ const now = input.now ?? Date.now();
131
+ const admitted = input.members.filter((m) => m.admitted);
132
+ const messages = input.records.filter((r) => r.act !== "JOIN" && r.act !== "LEAVE");
133
+ let messages_7d = 0, messages_prev_21d = 0, last_message = null;
134
+ const days = new Set();
135
+ for (const r of messages) {
136
+ const d = daysAgo(r.ts, now);
137
+ if (d < 7)
138
+ messages_7d++;
139
+ else if (d < 28) {
140
+ messages_prev_21d++;
141
+ days.add(d);
142
+ }
143
+ if (last_message === null || r.ts > last_message)
144
+ last_message = r.ts;
145
+ }
146
+ const seenAt = (m) => m.last_seen ?? m.first_seen;
147
+ const last_seen = admitted.length ? Math.max(...admitted.map(seenAt)) : null;
148
+ const born = input.members.length ? Math.min(...input.members.map((m) => m.first_seen)) : now;
149
+ const age = daysAgo(born, now);
150
+ const versions = {};
151
+ const behind = [];
152
+ for (const m of admitted) {
153
+ if (!m.last_pkg)
154
+ continue;
155
+ versions[m.last_pkg] = (versions[m.last_pkg] ?? 0) + 1;
156
+ if (versionBehind(m.last_pkg, input.relayPkg))
157
+ behind.push({ member: m.member, pkg: m.last_pkg, days: daysAgo(seenAt(m), now) });
158
+ }
159
+ behind.sort((a, b) => a.days - b.days);
160
+ const newest = admitted.filter((m) => m.last_pkg).sort((a, b) => seenAt(b) - seenAt(a))[0];
161
+ const seenSays = last_seen === null ? "" : `; last seen ${daysAgo(last_seen, now)}d ago${newest ? ` on ${newest.last_pkg}` : ""}`;
162
+ let verdict, says;
163
+ if (input.online > 0 || messages_7d > 0) {
164
+ verdict = "active";
165
+ says = `${messages_7d} message${messages_7d === 1 ? "" : "s"} this week${input.online ? `, ${input.online} online now` : ""}`;
166
+ }
167
+ else if (days.size >= 3) {
168
+ verdict = "went quiet";
169
+ says = `${messages_prev_21d} messages on ${days.size} days in the three weeks before, none this week${seenSays}`;
170
+ }
171
+ else if (age < 7) {
172
+ verdict = "new";
173
+ says = `set up ${age}d ago${messages.length ? "" : ", no message yet"}`;
174
+ }
175
+ else if (messages.length === 0 && age < 28) {
176
+ verdict = "never started";
177
+ says = `set up ${age}d ago, not one message${seenSays}`;
178
+ }
179
+ else {
180
+ verdict = "idle";
181
+ const since = last_message ?? last_seen;
182
+ says = since === null ? "nothing on record" : `quiet for ${daysAgo(since, now)}d${seenSays}`;
183
+ }
184
+ return {
185
+ name: input.name, verdict, says, members: admitted.length, online: input.online,
186
+ messages_7d, messages_prev_21d, active_days_prev_21d: days.size, last_message, last_seen, versions, behind,
187
+ };
188
+ }
189
+ export function fleetReport(rows, relayPkg, now = Date.now()) {
190
+ const date = new Date(now).toISOString().slice(0, 10);
191
+ const counts = VERDICT_ORDER.map((v) => [v, rows.filter((r) => r.verdict === v).length]).filter(([, n]) => n > 0);
192
+ const lines = [
193
+ `pantheon relay ${relayPkg} — the week to ${date}`,
194
+ `${rows.length} space${rows.length === 1 ? "" : "s"}${counts.map(([v, n]) => ` · ${n} ${v}`).join("")}`,
195
+ ];
196
+ const sorted = [...rows].sort((a, b) => VERDICT_ORDER.indexOf(a.verdict) - VERDICT_ORDER.indexOf(b.verdict) || b.messages_7d - a.messages_7d || a.name.localeCompare(b.name));
197
+ const width = Math.max(4, ...sorted.map((r) => r.name.length));
198
+ for (const r of sorted)
199
+ lines.push(`${r.verdict.padEnd(13)} ${r.name.padEnd(width)} ${r.says}`);
200
+ const builds = {};
201
+ const behind = [];
202
+ for (const r of rows) {
203
+ for (const [v, n] of Object.entries(r.versions))
204
+ builds[v] = (builds[v] ?? 0) + n;
205
+ for (const b of r.behind)
206
+ behind.push(`${b.member} in ${r.name} on ${b.pkg}, ${b.days}d ago`);
207
+ }
208
+ const spread = Object.keys(builds).sort((a, b) => (versionBehind(a, b) ? 1 : versionBehind(b, a) ? -1 : 0)).map((v) => `${v} ×${builds[v]}`).join(" · ");
209
+ if (spread)
210
+ lines.push(`builds in use: ${spread}${behind.length ? ` (behind the relay: ${behind.join("; ")})` : ""}`);
211
+ return lines.join("\n");
212
+ }
116
213
  //# sourceMappingURL=admin-stats.js.map
package/dist/cli.js CHANGED
@@ -7,13 +7,13 @@ process.emitWarning = ((warning, ...rest) => {
7
7
  return _emitWarning(warning, ...rest);
8
8
  });
9
9
  import { randomBytes } from "node:crypto";
10
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { basename, dirname, join, resolve } from "node:path";
12
12
  import { PantheonClient } from "./client.js";
13
13
  import { fromInvite, load, save, toInvite, toJoinLink, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
14
14
  import { fmtLimit } from "./plans.js";
15
15
  import { validEmail, validUsername } from "./accounts.js";
16
- import { install, launcher } from "./install.js";
16
+ import { install, launcher, removeCodexUserScope } from "./install.js";
17
17
  import { Relay } from "./relay.js";
18
18
  import { runMcp } from "./mcp.js";
19
19
  import { Worker } from "./worker.js";
@@ -22,7 +22,8 @@ import { desktopUnavailable, hostEnv } from "./host-env.js";
22
22
  import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
23
23
  import { runGuide, terminalIO, parseSetupFlags, questionnaire } from "./setup-guide.js";
24
24
  import { selectActs, formatAct, resolveRef } from "./history-view.js";
25
- import { doctor, render } from "./doctor.js";
25
+ import { doctor, render, shareBlock } from "./doctor.js";
26
+ import { FEEDBACK_ADDRESS } from "./feedback.js";
26
27
  import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
27
28
  import { assess } from "./untrusted.js";
28
29
  import { report as spendReport, recentRuns } from "./spend.js";
@@ -500,7 +501,7 @@ ${B("control")}
500
501
  pantheon spend woken runs on this machine, and what pantheon cannot see
501
502
  pantheon leave remove what pantheon put on this machine
502
503
  pantheon panic cut off all unattended work on this machine, now
503
- pantheon doctor check this machine end to end
504
+ pantheon doctor [--share] check this machine end to end; --share prints it without names or paths, to paste to us
504
505
  pantheon setup three questions — your name, your role, may a woken run execute commands —
505
506
  then one screen of defaults to keep or change: who may wake this machine,
506
507
  alerts, what you personally decide, playbook, base branch. Re-run any time.
@@ -1603,6 +1604,11 @@ async function main() {
1603
1604
  const { revokeToolPermissions } = await import("./grants.js");
1604
1605
  for (const l of revokeToolPermissions())
1605
1606
  console.error(` perms:${l}`);
1607
+ {
1608
+ const l = removeCodexUserScope();
1609
+ if (l)
1610
+ console.error(` perms:${l}`);
1611
+ }
1606
1612
  const { unlinkSync, existsSync: exists, rmSync } = await import("node:fs");
1607
1613
  if (cfg && exists(CONFIG_PATH)) {
1608
1614
  if (argv.includes("--keep-identity")) {
@@ -1620,12 +1626,13 @@ async function main() {
1620
1626
  else {
1621
1627
  console.error(` config: none to remove`);
1622
1628
  }
1623
- console.error(`\n ${D("Not touched: the MCP entry in your agent (pantheon install wrote it; remove it there),")}`);
1624
- console.error(` ${D("the relay's ledger on disk, and anything you pushed to git.")}`);
1629
+ console.error(`\n ${D("Not touched: the MCP entries in Claude Code and Cursor (pantheon install wrote them; remove them there),")}`);
1630
+ console.error(` ${D("the relay's ledger on disk, and anything you pushed to git. Codex's entry and approval line were removed above.")}`);
1625
1631
  if (cfg) {
1626
1632
  console.error(`\n ${D(`Your teammates still list you in "${cfg.space}". Ask one of them to run:`)}`);
1627
1633
  console.error(` ${D(`pantheon admit ${cfg.member} --revoke`)}`);
1628
1634
  }
1635
+ console.error(`\n ${D(`If Pantheon let you down, one line on why to ${FEEDBACK_ADDRESS} would help more than you might think.`)}`);
1629
1636
  return;
1630
1637
  }
1631
1638
  case "spend": {
@@ -2201,6 +2208,46 @@ async function main() {
2201
2208
  hud = null;
2202
2209
  }
2203
2210
  }
2211
+ if (hud?.action === "fix") {
2212
+ const dir = hud.dir && existsSync(hud.dir) ? hud.dir : process.cwd();
2213
+ const steps = hud.lines ?? [];
2214
+ console.error(`${B("What stopped it")}\n ${hud.why ?? "(not recorded)"}\n`);
2215
+ console.error(`${B("Where")} ${dir}\n`);
2216
+ if (!steps.length) {
2217
+ console.error(D("Nothing to run from here."));
2218
+ return;
2219
+ }
2220
+ console.error(B("Steps"));
2221
+ for (const l of steps)
2222
+ console.error(` ${C(l)}`);
2223
+ if (!process.stdin.isTTY)
2224
+ return;
2225
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
2226
+ try {
2227
+ const answer = (await new Promise((res) => rl.question(`\n${D("run these here now? [y/N] ")}`, res))).trim().toLowerCase();
2228
+ if (answer !== "y" && answer !== "yes") {
2229
+ console.error(D("left for you — the commands are above"));
2230
+ return;
2231
+ }
2232
+ const { spawnSync } = await import("node:child_process");
2233
+ for (const line of steps) {
2234
+ const cmd = line.replace(/\s+#.*$/, "").trim();
2235
+ if (!cmd)
2236
+ continue;
2237
+ console.error(D(`\n$ ${cmd}`));
2238
+ const r = spawnSync("sh", ["-lc", cmd], { cwd: dir, stdio: "inherit" });
2239
+ if (r.status !== 0) {
2240
+ console.error(`\n${B("stopped")} — "${cmd}" exited ${r.status}. The rest is yours.`);
2241
+ return;
2242
+ }
2243
+ }
2244
+ console.error(`\n${B("done")} — if that was the blocker, the next run can finish.`);
2245
+ }
2246
+ finally {
2247
+ rl.close();
2248
+ }
2249
+ return;
2250
+ }
2204
2251
  if (hud?.action === "done") {
2205
2252
  const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
2206
2253
  await c2.connect().catch((e) => die(e.message));
@@ -2576,8 +2623,14 @@ async function main() {
2576
2623
  }
2577
2624
  case "doctor": {
2578
2625
  const dir = resolve(flag("project", "."));
2579
- console.log(`${B("pantheon doctor")} ${D(dir)}\n`);
2626
+ const share = argv.includes("--share");
2627
+ if (!share)
2628
+ console.log(`${B("pantheon doctor")} ${D(dir)}\n`);
2580
2629
  const checks = await doctor(dir);
2630
+ if (share) {
2631
+ console.log(shareBlock(checks));
2632
+ process.exit(checks.some((c) => c.level === "fail") ? 1 : 0);
2633
+ }
2581
2634
  const { text, failed } = render(checks);
2582
2635
  console.log(text);
2583
2636
  process.exit(failed ? 1 : 0);
package/dist/client.js CHANGED
@@ -72,6 +72,8 @@ export class PantheonClient {
72
72
  }
73
73
  }
74
74
  connect() {
75
+ if (!this.everConnected)
76
+ this.closed = false;
75
77
  return this.attempt(this.localOverride ?? this.cfg.relay).catch(async (e) => {
76
78
  if (this.localOverride)
77
79
  throw e;
@@ -1,16 +1,38 @@
1
1
  const oneLine = (value) => value.replace(/\s+/g, " ").trim();
2
+ const LAYERS = [
3
+ /^([a-z0-9._-]+)\s+escalated\s*:\s*/i,
4
+ /^([a-z0-9._-]+)\s+needs? a decision\s*:\s*/i,
5
+ /^([a-z0-9._-]+)\s+(?:asked|asks|said|says|wrote|reported)\s*:\s*/i,
6
+ /^([a-z0-9._-]+)\s+is\s+(?:asking|saying|reporting)(?:\s+that)?\s*:\s*/i,
7
+ /^decision (?:for|required for|needed from)\s+(?:you|[a-z0-9._-]+)\s*(?:\([^)]*\))?\s*[:—-]\s*/i,
8
+ /^(?:action|input|answer|decision)\s+(?:required|needed)\s*(?:\([^)]*\))?\s*(?:from|for)?\s*(?:you|[a-z0-9._-]+)?\s*[:—-]\s*/i,
9
+ /^needs (?:you|your input|a human)[,:]?\s*([a-z0-9._-]*)\s*[:—-]\s*/i,
10
+ /^human (?:action|input) required\s*[—:-]?\s*/i,
11
+ ];
12
+ export function stripCeremony(body) {
13
+ let text = oneLine(body);
14
+ let from;
15
+ for (let round = 0; round < 6; round++) {
16
+ const before = text;
17
+ for (const re of LAYERS) {
18
+ const m = re.exec(text);
19
+ if (!m)
20
+ continue;
21
+ const name = (m[1] ?? "").trim();
22
+ if (!from && name && !/^(you|your|a)$/i.test(name))
23
+ from = name.toLowerCase();
24
+ text = text.slice(m[0].length);
25
+ }
26
+ if (text === before)
27
+ break;
28
+ }
29
+ return { text: text.trim(), ...(from ? { from } : {}) };
30
+ }
2
31
  export function decisionTitle(body, max = 96) {
3
- let text = oneLine(body)
4
- .replace(/^[a-z0-9._-]+\s+escalated:\s*/i, "")
5
- .replace(/^[a-z0-9._-]+\s+needs a decision:\s*/i, "")
6
- .replace(/^human (?:action|input) required\s*[—:-]\s*/i, "")
7
- .replace(/^human (?:action|input) required\s*/i, "")
8
- .replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
9
- .replace(/\(goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
32
+ let text = stripCeremony(body).text
33
+ .replace(/\(\s*goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
10
34
  .replace(/\s*\b(?:of|in|for|on|from)?\s*\(?\bgoal\s+g_[a-z0-9]+\)?/gi, "")
11
35
  .replace(/\s{2,}/g, " ")
12
- .replace(/[\s,]+(?:of|in|for|on|at|to|from)$/i, "")
13
- .replace(/[\s,;:]+$/, "")
14
36
  .trim();
15
37
  const blockedAsk = text.match(/^cannot answer (.+?)\s+ask\s*:/i);
16
38
  if (blockedAsk) {