@join-pantheon/cli 0.18.2 → 0.19.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 CHANGED
@@ -28,8 +28,7 @@ npm install -g @join-pantheon/cli
28
28
  Or run it without installing at all:
29
29
 
30
30
  ```bash
31
- npx @join-pantheon/cli start # you
32
- npx @join-pantheon/cli join <invite> # them
31
+ npx @join-pantheon/cli # guided sign-in; it tells you what to run next
33
32
  ```
34
33
 
35
34
  **First, verify your email (once per person):**
@@ -55,9 +54,13 @@ pantheon start
55
54
  **Your teammate, from their clone of the project:**
56
55
 
57
56
  ```bash
58
- pantheon join pantheon://100.92.14.7:8787/acme#C1qhJfdHBaPjuG3jF48vcqrP
57
+ pantheon join pantheons://relay.joinpantheon.network/acme#C1qhJfdHBaPjuG3jF48vcqrP
59
58
  ```
60
59
 
60
+ Signed in, `pantheon start` puts your space on the hosted relay, so that link works from any
61
+ network — no tunnel, no VPN. The space is named after your repo; if another team already took
62
+ that name, start picks `<repo>-<you>` and says so (or choose your own with `--space`).
63
+
61
64
  That's it — one command each sets up the **whole machine**: a relay and space (you) or membership
62
65
  (them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
63
66
  every Conductor window, nothing to commit), the wake-up hook so an agent finishes what a peer is
@@ -88,7 +91,10 @@ Need another (a third person, or your own second machine)? `pantheon invite`.
88
91
 
89
92
  ### Reaching each other across cities
90
93
 
91
- `pantheon start` works this out for you. If Tailscale is running it uses your tailnet address, which
94
+ On the hosted relay there is nothing to work out: every signed-in machine dials
95
+ `relay.joinpantheon.network`. The rest of this section is for **self-hosting** (`pantheon start
96
+ --relay …`, or a machine with no account). Then `pantheon start` runs a relay on your machine and
97
+ works out where a teammate can reach it. If Tailscale is running it uses your tailnet address, which
92
98
  is private and needs no TLS. Otherwise it tells you the address is local-only and how to fix it:
93
99
 
94
100
  ```
@@ -114,7 +120,7 @@ internet, and whether you were actually admitted. Non-zero exit means don't star
114
120
  <details><summary>The long way round, if you want to control each piece</summary>
115
121
 
116
122
  `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who> --may-wake`
117
- · `pantheon install` (project-scope, needs `npm link` and a commit). Membership by explicit
123
+ · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
118
124
  allowlist with fingerprint confirmation, rather than by invite. Everything the two-command path
119
125
  does, one step at a time.
120
126
 
@@ -222,7 +228,7 @@ Human decisions use a separate, model-free path. `ESCALATE` creates a durable
222
228
  ```sh
223
229
  pantheon service install notifier # starts at login; no wake permission needed
224
230
  pantheon profile set --surface codex # codex | claude-code | cursor
225
- pantheon profile set --name "Praneet" --title "Co-founder & CEO" \
231
+ pantheon profile set --name "Alex" --title "Head of Product" \
226
232
  --responsibilities product-ui,gtm,marketing \
227
233
  --human-required product-ui,gtm,marketing
228
234
  pantheon directory # shared responsibility and authority graph
@@ -249,7 +255,8 @@ local. Before a consequential decision, agents call `pantheon_route_decision`
249
255
  with explicit domains. Matching `human_required` owners receive separate
250
256
  `HUMAN_REQUIRED` acts; matching `agent_can_decide` owners may answer through
251
257
  their delegate. Missing authority fails closed and escalates to the caller's
252
- human. See [Pantheon / Pantheon V1 acceptance](docs/PANTHEON_V1_ACCEPTANCE.md).
258
+ human. The security model is written up at
259
+ [joinpantheon.network/security](https://www.joinpantheon.network/security).
253
260
 
254
261
  Delegation is one hop, time-bounded, and preserves the original authority
255
262
  level: a human-only decision cannot become agent-decidable through delegation.
@@ -312,7 +319,7 @@ dead air on an unattended machine. And the worker finally says what it is doing:
312
319
  and cap refusal is logged with its reason, and `worker-state.json` feeds the same line to
313
320
  `pantheon ps` and the menu bar.
314
321
 
315
- Proven by team-shaped tests (`team-flow.test.ts`): a full unattended round trip where the asking
322
+ Proven by end-to-end tests with real workers and a real relay: a full unattended round trip where the asking
316
323
  session closes before the reply and both workers finish the goal anyway; deliver→review→accept
317
324
  with no human; the cap and lock governors under two workers per member.
318
325
 
@@ -328,12 +335,11 @@ the same "no shell unless you said so" rule spelled in its own dialect:
328
335
  | codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--sandbox workspace-write` |
329
336
  | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` |
330
337
 
331
- Claude Code and Cursor are verified with **real live woken runs** (see `live-smoke.test.ts`;
332
- `PANTHEON_LIVE_SMOKE=1 PANTHEON_LIVE_BACKEND=cursor …`). The codex invocation is built against the
333
- verified `codex exec` surface but has not yet run against a real codex install smoke it on a
334
- machine that has one before leaning on it. Note codex quirks the tooling handles for you: no
335
- per-run approval flag exists in exec, MCP arrives via `-c` overrides (a worktree `.codex/` is
336
- ignored), and codex silently skips a newly installed hook until you approve it with `/hooks`.
338
+ All three backends are verified with **real live woken runs** an idle machine answering a
339
+ peer's ASK end to end with Claude Code, Cursor, and Codex. Codex quirks the tooling handles for
340
+ you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
341
+ `.codex/` is ignored), and codex silently skips a newly installed hook until you approve it with
342
+ `/hooks`.
337
343
 
338
344
  ### Personas — telling the worker who to be
339
345
 
@@ -401,19 +407,25 @@ on each laptop; what crosses the wire is acts, contracts, diff summaries and dec
401
407
  ## Commands
402
408
 
403
409
  ```
404
- pantheon relay run the coordination plane
405
- pantheon init claim a space, print an invite
406
- pantheon join join a colleague's space
407
- pantheon install wire the MCP server into claude code / cursor / conductor here
408
- pantheon status who's online, open goals, what you owe
409
- pantheon watch live tail of everything the delegates say to each other
410
- pantheon persona list / init / show what a woken worker may do and how it behaves
411
- pantheon say send an act by hand (kick things off, or debug)
412
- pantheon mcp the stdio MCP serveryour agent runs this, not you
410
+ pantheon signup / verify / login verify your email once; sign a second machine in
411
+ pantheon start create your team on the hosted relay, get the line to send
412
+ pantheon join <line> join a teammate's team from their line
413
+ pantheon invite another single-use line (third person, second machine)
414
+ pantheon status · brief · team who is here, what needs you, who is in the space
415
+ pantheon watch live tail of everything the delegates say to each other
416
+ pantheon doctor preflight: node, keys, MCP entry, relay, membership
417
+ pantheon worker / service stay answerable while you are away (install as a service)
418
+ pantheon persona list / init / show what a woken worker may do
419
+ pantheon sessions · logout your devices and sessions; sign this machine out
420
+ pantheon account delete remove your account and everything the relay holds on you
421
+ pantheon upgrade pull the latest release and re-wire your agents
422
+ pantheon relay run your own relay (self-hosting)
423
+ pantheon mcp the stdio MCP server — your agent runs this, not you
413
424
  ```
414
425
 
415
- `pantheon watch` is how a human stays oriented without being in the loop. Every act is also
416
- appended to `pantheon-data/<space>.jsonl`, so the full history is replayable with `tail`.
426
+ `pantheon help --all` lists everything. `pantheon watch` is how a human stays oriented without
427
+ being in the loop; every act is also appended to the relay's ledger, so the full history is
428
+ replayable.
417
429
 
418
430
  ## What this still is not
419
431
 
@@ -429,10 +441,29 @@ appended to `pantheon-data/<space>.jsonl`, so the full history is replayable wit
429
441
  - **A woken run is trusted with your repo.** `--allow-bash` in particular means a peer's request
430
442
  can execute commands on your machine. Grant `--may-wake` deliberately.
431
443
 
432
- ## Tests
444
+ ## Privacy, data, and support
445
+
446
+ The hosted relay at `relay.joinpantheon.network` holds only what coordination needs: your
447
+ verified email, username, and device public keys; the IPs and timestamps of your sessions and
448
+ an audit trail of identity and membership events; and, per space, the acts your delegates
449
+ exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
450
+ machine — the relay has no filesystem or shell access by design.
451
+
452
+ - `pantheon sessions` shows every device and session on your account; `revoke` signs one out.
453
+ - `pantheon audit` is your own trail; `pantheon account delete` removes your account, devices,
454
+ sessions, and memberships.
455
+ - Prefer to run your own? `pantheon relay` is the whole server; point machines at it with
456
+ `pantheon start --relay wss://your-host`. Accounts are optional there.
457
+
458
+ Security details and how to report a vulnerability:
459
+ [joinpantheon.network/security](https://www.joinpantheon.network/security). Everything else:
460
+ [joinpantheon.network](https://www.joinpantheon.network).
461
+
462
+ ## Upgrading
433
463
 
434
464
  ```bash
435
- npm run build
436
- node --test dist/test/fabric.test.js # protocol, obligations, guards, auth, claims, durability
437
- node --test dist/test/mcp.test.js # real stdio JSON-RPC against the MCP server
465
+ pantheon upgrade # installs the latest release and re-wires your agents
438
466
  ```
467
+
468
+ Every command prints its version and nudges you when a newer release exists. Restart your agent
469
+ session after upgrading so it loads the new MCP server.
package/dist/cli.js CHANGED
@@ -89,12 +89,14 @@ async function interactiveOnboard(relayArg) {
89
89
  }
90
90
  let started = null;
91
91
  let mode = "signup";
92
+ let signupUsername = "";
92
93
  while (!started || started.status !== 200) {
93
94
  const u = await ask(" Pick a username: ");
94
95
  if (!validUsername(u.toLowerCase())) {
95
96
  console.error(D(" 2–32 chars: letters, digits, . _ - (not at the ends)"));
96
97
  continue;
97
98
  }
99
+ signupUsername = u;
98
100
  started = await postJson("/auth/signup", { email, username: u, pubkey: kp.publicKey, label }).catch((e) => { console.error(D(` could not reach the relay: ${e.message}`)); return null; });
99
101
  if (!started)
100
102
  return;
@@ -117,15 +119,28 @@ async function interactiveOnboard(relayArg) {
117
119
  console.error(D(` ${started?.j.error ?? "sign-up failed"}`));
118
120
  return;
119
121
  }
120
- const via = started.j.via === "resend" ? "" : `\n ${D("(this is a dev relay — your code is printed in its log)")}`;
121
- console.error(`\n ${C("Sent a 6-digit code")} to ${B(email)}. Check your inbox.${via}\n`);
122
+ const authUser = mode === "signup" ? signupUsername : undefined;
123
+ save({ ...(prior ?? {}), relay, member: signupUsername || prior?.member || "me",
124
+ space: prior?.space ?? "", token: prior?.token ?? "",
125
+ publicKey: kp.publicKey, privateKey: kp.privateKey, agent: prior?.agent ?? "unknown",
126
+ pendingAuth: { email, ...(authUser ? { username: authUser } : {}), purpose: mode } });
127
+ if (started.j.sent === false && started.j.via === "resend") {
128
+ console.error(`\n ${D("The relay could not send the email just now.")} Type 'resend' to retry, or run ${C("pantheon")} again shortly.\n`);
129
+ }
130
+ else {
131
+ const via = started.j.via === "resend" ? "" : `\n ${D("(this is a dev relay — your code is printed in its log)")}`;
132
+ console.error(`\n ${C("Sent a 6-digit code")} to ${B(email)}. Check your inbox.${via}\n`);
133
+ }
122
134
  for (let attempt = 0; attempt < 6; attempt++) {
123
135
  const code = await ask(" Enter the code (or 'resend'): ");
124
136
  if (/^resend$/i.test(code)) {
125
- await postJson(mode === "signup" ? "/auth/signup" : "/auth/login", mode === "signup"
126
- ? { email, username: prior?.member, pubkey: kp.publicKey, label }
127
- : { email, pubkey: kp.publicKey, label });
128
- console.error(D(" sent a fresh code."));
137
+ const rs = await postJson(mode === "signup" ? "/auth/signup" : "/auth/login", mode === "signup"
138
+ ? { email, username: signupUsername, pubkey: kp.publicKey, label }
139
+ : { email, pubkey: kp.publicKey, label }).catch(() => null);
140
+ if (rs && rs.status === 200 && rs.j.sent !== false)
141
+ console.error(D(" sent a fresh code."));
142
+ else
143
+ console.error(D(` could not resend: ${rs?.j.error ?? "the relay did not send an email"} — try again in a minute.`));
129
144
  continue;
130
145
  }
131
146
  const v = await postJson("/auth/verify", { email, code });
@@ -195,8 +210,16 @@ function die(msg) {
195
210
  console.error(`pantheon: ${msg}`);
196
211
  process.exit(1);
197
212
  }
213
+ function sameHost(a, b) {
214
+ try {
215
+ return new URL(a).host === new URL(b).host;
216
+ }
217
+ catch {
218
+ return false;
219
+ }
220
+ }
198
221
  function need() {
199
- return load() ?? die("not configured. Run `pantheon init` (first machine) or `pantheon join <invite>` (other machines).");
222
+ return load() ?? die("not configured. Run `pantheon signup` then `pantheon start` (create a team), or `pantheon join <invite>` (join one).");
200
223
  }
201
224
  function printGranted(granted) {
202
225
  if (!granted.length)
@@ -303,7 +326,8 @@ ${B("less common")}
303
326
  pantheon whoami your name and fingerprint
304
327
  pantheon pending what a live agent window has not yet seen
305
328
  pantheon say <ACT> <text> send one act by hand (NOTE, ASK, ANSWER, PROPOSE, ...)
306
- pantheon init / install the step-by-step setup path, instead of \`start\`
329
+ pantheon invite mint another single-use invite link to send
330
+ pantheon install re-wire this machine's agents (after a Node/agent change)
307
331
  pantheon mcp the server your agent runs; not for you
308
332
  pantheon limits the guard values in force
309
333
  pantheon version the pantheon version this machine is running
@@ -350,6 +374,12 @@ async function main() {
350
374
  for (const w of r.warnings)
351
375
  console.error(`\x1b[33m ! ${w}\x1b[0m`);
352
376
  printGranted(r.granted);
377
+ if (!r.claimed) {
378
+ console.error(`\n\x1b[31m No team was created.\x1b[0m ${D("Fix the warning above, then run `pantheon start` again.")}`);
379
+ if (r.relay)
380
+ await r.relay.close().catch(() => { });
381
+ process.exit(1);
382
+ }
353
383
  if (r.invite) {
354
384
  console.error(`\n${B("Send your teammate this one line — it sets up their whole machine:")}\n`);
355
385
  console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
@@ -391,6 +421,21 @@ async function main() {
391
421
  console.error(D(`health: http://localhost:${port}/health`));
392
422
  console.error(D(`\nFor a colleague on another network, expose this port (ssh -R, cloudflared, tailscale)`));
393
423
  console.error(D(`and hand them the invite from \`pantheon init\`. Ctrl-C to stop.`));
424
+ let shuttingDown = false;
425
+ const shutdown = async (sig) => {
426
+ if (shuttingDown)
427
+ return;
428
+ shuttingDown = true;
429
+ console.error(D(`\n${sig} — flushing state and closing…`));
430
+ try {
431
+ await relay.close();
432
+ }
433
+ catch { }
434
+ process.exit(0);
435
+ };
436
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
437
+ process.on("SIGINT", () => void shutdown("SIGINT"));
438
+ await new Promise(() => { });
394
439
  return;
395
440
  }
396
441
  case "init": {
@@ -443,6 +488,9 @@ async function main() {
443
488
  catch (e) {
444
489
  die(e.message);
445
490
  }
491
+ if (!load()?.account && sameHost(parsed.relay, DEFAULT_RELAY)) {
492
+ die(`sign in first — this relay verifies email.\n New here: pantheon signup --email you@work.com --username you\n Have an account: pantheon login --email you@work.com\n Then run this same \`pantheon join\` command again — the invite is still good.`);
493
+ }
446
494
  const wp = unattendedProject();
447
495
  console.error(`${B("pantheon join")}\n`);
448
496
  const r = await qsJoin({
@@ -553,7 +601,7 @@ async function main() {
553
601
  console.log(`${B(cfg.space)} you are ${C(cfg.member)} ${D(cfg.relay)} ${D(`${client.head} acts`)}\n`);
554
602
  console.log(B("members"));
555
603
  if (!peers.length)
556
- console.log(` ${D("(nobody else yet — send them the invite from `pantheon init`)")}`);
604
+ console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
557
605
  for (const p of peers) {
558
606
  const behind = p.seen_seq === undefined ? null : client.head - p.seen_seq;
559
607
  const reach = behind === null
@@ -1275,6 +1323,11 @@ async function main() {
1275
1323
  save({ ...(prior ?? {}), relay, member: username ?? prior?.member ?? "me", space: prior?.space ?? "", token: prior?.token ?? "",
1276
1324
  publicKey: kp.publicKey, privateKey: kp.privateKey, agent: prior?.agent ?? "unknown",
1277
1325
  pendingAuth: { email, ...(username ? { username } : {}), purpose } });
1326
+ if (r.j.sent === false && r.j.via === "resend") {
1327
+ console.error(`${B("signup accepted, but the email could not be sent just now.")}`);
1328
+ console.error(`Retry in a minute: ${C(`pantheon ${purpose} --email ${email}${username ? ` --username ${username}` : ""}`)}`);
1329
+ return;
1330
+ }
1278
1331
  const via = r.j.via === "resend" ? "" : D(" (dev relay: the code is in the relay log)");
1279
1332
  console.error(`${B("check your email")} — we sent a 6-digit code to ${C(email)}.${via}`);
1280
1333
  console.error(`Then run: ${C("pantheon verify <code>")}`);
package/dist/client.js CHANGED
@@ -57,7 +57,7 @@ export class PantheonClient {
57
57
  return null;
58
58
  const port = u.port || "8787";
59
59
  try {
60
- const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1200) });
60
+ const res = await fetch(`http://127.0.0.1:${port}/health?space=${encodeURIComponent(this.cfg.space)}`, { signal: AbortSignal.timeout(1200) });
61
61
  const body = (await res.json());
62
62
  if (!body.spaces?.some((sp) => sp.name === this.cfg.space))
63
63
  return null;
package/dist/doctor.js CHANGED
@@ -42,9 +42,9 @@ export async function doctor(projectDir) {
42
42
  const out = [];
43
43
  const add = (level, name, detail, fix) => out.push({ level, name, detail, ...(fix ? { fix } : {}) });
44
44
  const major = Number(process.versions.node.split(".")[0]);
45
- major >= 20
45
+ major >= 22
46
46
  ? add("pass", "node", `v${process.versions.node}`)
47
- : add("fail", "node", `v${process.versions.node} is too old`, "install Node 20 or newer");
47
+ : 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");
48
48
  let cfg = null;
49
49
  try {
50
50
  cfg = load();
@@ -53,7 +53,7 @@ export async function doctor(projectDir) {
53
53
  add("fail", "config", `unreadable: ${e.message}`);
54
54
  }
55
55
  if (!cfg) {
56
- add("fail", "config", "no config found", "run `pantheon init` (first machine) or `pantheon join <invite>` (other machines)");
56
+ add("fail", "config", "no config found", "run `pantheon signup` (new here) then `pantheon start` (create a team), or `pantheon join <invite>` (join one)");
57
57
  return out;
58
58
  }
59
59
  add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`);
@@ -79,12 +79,12 @@ export async function doctor(projectDir) {
79
79
  add("pass", "mcp entry", "portable (`pantheon mcp`) — safe to commit, works on your teammate's machine");
80
80
  }
81
81
  else {
82
- add("fail", "mcp entry", `hard-codes this machine: ${entry.command}`, "run `npm link` in the pantheon repo, then `pantheon install` again. Committed as-is it silently fails on the other machine the MCP server never starts and no error is shown.");
82
+ 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.");
83
83
  }
84
84
  }
85
85
  onPath()
86
86
  ? add("pass", "PATH", "`pantheon` resolves by name")
87
- : add("warn", "PATH", "`pantheon` is not on PATH", "run `npm link` in the pantheon repo");
87
+ : 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");
88
88
  const inRepo = sh("git rev-parse --is-inside-work-tree", projectDir) === "true";
89
89
  if (!inRepo) {
90
90
  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");
@@ -151,7 +151,7 @@ export async function doctor(projectDir) {
151
151
  admittedPeers.length
152
152
  ? add("pass", "peers", admittedPeers
153
153
  .map((p) => `${p.member}[${online.has(p.member) ? "online" : "no session open"}]`).join(" "))
154
- : add("warn", "peers", "nobody else is admitted to this space yet", "send your teammate the invite from `pantheon init`, then admit them with `pantheon admit <them> --may-wake`");
154
+ : 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)");
155
155
  if (pending.length) {
156
156
  add("warn", "pending", pending.map((p) => `${p.member} (${p.fingerprint})`).join(" "), `confirm the fingerprint by voice, then: pantheon admit ${pending[0].member} --may-wake`);
157
157
  }
package/dist/install.js CHANGED
@@ -115,7 +115,8 @@ export function install(projectDir, cfg, force) {
115
115
  out.push(" machine's node binary and checkout path. It works here and silently fails on any");
116
116
  out.push(" other machine — the MCP server just never starts, with no error.");
117
117
  out.push("");
118
- out.push(" Fix it before committing: \x1b[1mnpm link\x1b[0m (in the pantheon repo), then re-run `pantheon install`");
118
+ out.push(" Fix it before committing: install pantheon globally (\x1b[1mnpm install -g @join-pantheon/cli\x1b[0m)");
119
+ out.push(" and put your npm global bin (`npm bin -g`) on PATH, then re-run `pantheon install`");
119
120
  out.push(" Or keep it local-only: add .mcp.json to .gitignore and have your teammate run");
120
121
  out.push(" `pantheon install` on their own machine instead.");
121
122
  }
@@ -127,13 +128,18 @@ function localEntry() {
127
128
  function userEntry() {
128
129
  try {
129
130
  const abs = execFileSync("sh", ["-lc", "command -v pantheon"], { encoding: "utf8" }).trim();
130
- if (abs)
131
+ if (abs && !isVersionManagedPath(abs))
131
132
  return { command: abs, args: ["mcp"] };
133
+ if (abs)
134
+ return { command: "pantheon", args: ["mcp"] };
132
135
  }
133
136
  catch { }
134
137
  const l = launcher();
135
138
  return { command: l.command, args: l.args };
136
139
  }
140
+ function isVersionManagedPath(p) {
141
+ return /[\/\\]\.nvm[\/\\]versions[\/\\]node[\/\\]|[\/\\]\.fnm[\/\\]|[\/\\]\.asdf[\/\\]installs[\/\\]nodejs[\/\\]|[\/\\]n[\/\\]versions[\/\\]node[\/\\]|[\/\\]\.volta[\/\\]tools[\/\\]/.test(p);
142
+ }
137
143
  function claudeUserScope(entry) {
138
144
  const tool = "claude code";
139
145
  try {
package/dist/notifier.js CHANGED
@@ -53,10 +53,19 @@ export function ensureNativeNotifier(home = CONFIG_DIR) {
53
53
  throw new Error(`Pantheon notification helper source is missing: ${source}`);
54
54
  const binary = hudBinaryPath(home);
55
55
  mkdirSync(dirname(binary), { recursive: true });
56
+ requireDeveloperTools("notifications", "notifier");
56
57
  execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
57
58
  chmodSync(binary, 0o755);
58
59
  return binary;
59
60
  }
61
+ export function requireDeveloperTools(what, service) {
62
+ try {
63
+ execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
64
+ }
65
+ catch {
66
+ throw new Error(`building ${what} needs the Xcode Command Line Tools — run \`xcode-select --install\`, then \`pantheon service install ${service}\``);
67
+ }
68
+ }
60
69
  export function sendDesktopNotification(title, subtitle, body, profile, prompt = "", eyebrow = "Decision needed") {
61
70
  if (platform() !== "darwin")
62
71
  return false;
@@ -3,7 +3,7 @@ import { randomBytes } from "node:crypto";
3
3
  import { basename } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { PantheonClient } from "./client.js";
6
- import { load, save, toInvite } from "./config.js";
6
+ import { load, save, toInvite, DEFAULT_RELAY } from "./config.js";
7
7
  import { newKeypair } from "./identity.js";
8
8
  import { installForUser } from "./install.js";
9
9
  import { writeIntent, installHooks } from "./notify.js";
@@ -131,12 +131,15 @@ export function findReach(port) {
131
131
  }
132
132
  export async function start(opts) {
133
133
  const warnings = [];
134
- const reach = opts.relay
135
- ? (/127\.0\.0\.1|localhost|\[::1\]/.test(opts.relay)
136
- ? { url: opts.relay, how: "as given", shareable: false,
134
+ const priorCfg = load();
135
+ const signedInRelay = priorCfg?.account ? (priorCfg.relay ?? DEFAULT_RELAY) : undefined;
136
+ const chosen = opts.relay ?? signedInRelay;
137
+ const reach = chosen
138
+ ? (/127\.0\.0\.1|localhost|\[::1\]/.test(chosen)
139
+ ? { url: chosen, how: "as given", shareable: false,
137
140
  hint: `That address is this machine only — a teammate cannot reach it.\n` +
138
141
  ` Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
139
- : { url: opts.relay, how: "as given", shareable: true })
142
+ : { url: chosen, how: opts.relay ? "as given" : "your signed-in relay", shareable: true })
140
143
  : findReach(opts.port);
141
144
  let relay = null;
142
145
  const health = reach.url.replace(/^ws/, "http") + "/health";
@@ -149,11 +152,11 @@ export async function start(opts) {
149
152
  await relay.listen();
150
153
  opts.log(` relay started on ${reach.url} (${reach.how})`);
151
154
  }
152
- const existing = load();
155
+ const existing = priorCfg;
153
156
  const hasOwnSpace = !!existing?.space && !!existing?.token;
154
157
  const reuse = !!existing && !opts.reset && hasOwnSpace && (!opts.space || opts.space === existing.space);
155
158
  const keepIdentity = !!existing && !opts.reset && !!existing.publicKey && !!existing.privateKey;
156
- const cfg = reuse
159
+ let cfg = reuse
157
160
  ? { ...existing, relay: reach.url, agent: detectAgent() }
158
161
  : {
159
162
  relay: reach.url,
@@ -176,22 +179,47 @@ export async function start(opts) {
176
179
  else if (existing && existing.space) {
177
180
  warnings.push(`replacing your identity in "${existing.space}" — that space is no longer reachable from this machine`);
178
181
  }
179
- const client = new PantheonClient(cfg);
180
182
  let invite = null;
183
+ let claimed = false;
184
+ const claim = async (c) => {
185
+ const client = new PantheonClient(c);
186
+ try {
187
+ await client.connect();
188
+ save(c);
189
+ opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}`);
190
+ const inv = await client.makeInvite(true, opts.ttlMinutes);
191
+ invite = toInvite({ ...c, token: inv.url });
192
+ claimed = true;
193
+ }
194
+ finally {
195
+ client.close();
196
+ }
197
+ };
181
198
  try {
182
- await client.connect();
183
- save(cfg);
184
- opts.log(` space ${cfg.space} ${reuse ? "— reconnected" : "— created, you own it"}`);
185
- const inv = await client.makeInvite(true, opts.ttlMinutes);
186
- invite = toInvite({ ...cfg, token: inv.url });
199
+ await claim(cfg);
187
200
  }
188
201
  catch (e) {
189
- warnings.push(`could not claim the space: ${e.message}`);
190
- if (!reuse && existing)
191
- warnings.push(`your existing config was left untouched`);
192
- }
193
- finally {
194
- client.close();
202
+ const msg = e.message;
203
+ const nameTaken = !reuse && /BAD_TOKEN/.test(msg);
204
+ if (nameTaken && !opts.space) {
205
+ const alt = `${cfg.space}-${cfg.member}`;
206
+ opts.log(` space "${cfg.space}" is already another team's space on this relay — using "${alt}"`);
207
+ cfg = { ...cfg, space: alt };
208
+ try {
209
+ await claim(cfg);
210
+ }
211
+ catch (e2) {
212
+ warnings.push(`could not claim the space "${alt}": ${e2.message}`);
213
+ }
214
+ }
215
+ else if (nameTaken) {
216
+ warnings.push(`the name "${cfg.space}" is already another team's space on this relay — pick a different one: pantheon start --space <name>`);
217
+ }
218
+ else {
219
+ warnings.push(`could not claim the space: ${msg}`);
220
+ if (!reuse && existing)
221
+ warnings.push(`your existing config was left untouched`);
222
+ }
195
223
  }
196
224
  const wired = wireUp({
197
225
  hook: opts.hook !== false,
@@ -201,7 +229,7 @@ export async function start(opts) {
201
229
  ...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
202
230
  });
203
231
  warnings.push(...wired.warnings);
204
- return { cfg, invite, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
232
+ return { cfg, invite, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
205
233
  }
206
234
  export async function join(opts) {
207
235
  const existing = opts.reset ? null : load();
@@ -218,6 +246,7 @@ export async function join(opts) {
218
246
  member: opts.me ?? existing?.member ?? (process.env.USER ?? "me").toLowerCase().replace(/[^a-z0-9._-]/g, "-"),
219
247
  agent: detectAgent(),
220
248
  ...keys,
249
+ ...(existing?.account ? { account: existing.account } : {}),
221
250
  };
222
251
  const client = new PantheonClient(cfg, undefined, { enroll: opts.invite.token });
223
252
  await client.connect();
package/dist/relay.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
3
3
  import { rmSync } from "node:fs";
4
+ import { spawn } from "node:child_process";
4
5
  import { join } from "node:path";
5
6
  import { WebSocketServer } from "ws";
6
7
  import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
@@ -78,7 +79,7 @@ export class Relay {
78
79
  dataDir;
79
80
  log;
80
81
  http = createServer((req, res) => this.onHttp(req, res));
81
- wss = new WebSocketServer({ noServer: true });
82
+ wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
82
83
  heartbeat;
83
84
  compactedAt = new Map();
84
85
  sessionSeq = 0;
@@ -88,8 +89,11 @@ export class Relay {
88
89
  pruneMs;
89
90
  accounts;
90
91
  requireAccounts;
92
+ trustLocal;
91
93
  email;
92
94
  authHits = new Map();
95
+ adminHits = new Map();
96
+ emailSends = [];
93
97
  adminToken;
94
98
  backupTimer;
95
99
  constructor(opts = {}) {
@@ -97,6 +101,9 @@ export class Relay {
97
101
  this.dataDir = opts.dataDir ?? "./pantheon-data";
98
102
  this.log = opts.log ?? ((l) => console.error(l));
99
103
  this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
104
+ this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
105
+ ? process.env.PANTHEON_TRUST_LOCAL === "1"
106
+ : !this.requireAccounts;
100
107
  this.adminToken = process.env.PANTHEON_ADMIN_TOKEN?.trim() || null;
101
108
  const wantStore = this.requireAccounts || this.adminToken !== null;
102
109
  this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
@@ -160,20 +167,26 @@ export class Relay {
160
167
  return this.onAuthHttp(req, res, url, ip);
161
168
  }
162
169
  if (url.pathname === "/health") {
170
+ const adminView = !!this.adminToken && this.adminTokenPresented(req, url);
171
+ const detail = (s) => ({
172
+ name: s.name,
173
+ members: s.members.size,
174
+ online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
175
+ sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
176
+ goals: s.goals.size,
177
+ seq: s.ledger.head,
178
+ });
179
+ const asked = url.searchParams.get("space");
180
+ const one = asked ? this.spaces.get(asked) : undefined;
163
181
  return this.json(res, 200, {
164
182
  ok: true,
165
183
  v: PROTOCOL_VERSION,
166
184
  pkg: VERSION,
167
185
  space_count: this.spaces.size,
168
186
  space_capacity: this.limits.maxSpaces,
169
- spaces: [...this.spaces.values()].map((s) => ({
170
- name: s.name,
171
- members: s.members.size,
172
- online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
173
- sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
174
- goals: s.goals.size,
175
- seq: s.ledger.head,
176
- })),
187
+ ...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
188
+ : asked ? { spaces: one ? [detail(one)] : [] }
189
+ : {}),
177
190
  });
178
191
  }
179
192
  if (url.pathname === "/history") {
@@ -263,7 +276,7 @@ export class Relay {
263
276
  onAuthHttp(req, res, url, ip) {
264
277
  if (url.pathname === "/")
265
278
  return this.landingPage(res);
266
- if (url.pathname === "/admin" || url.pathname === "/admin/export" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
279
+ if (url.pathname === "/admin" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
267
280
  const A0 = this.accounts;
268
281
  if (!A0 || !this.adminToken)
269
282
  return this.json(res, 404, { error: "not found" });
@@ -271,10 +284,17 @@ export class Relay {
271
284
  const fromCookie = cookieVal(req, "pantheon_admin");
272
285
  const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
273
286
  if (url.pathname === "/admin/login" && req.method === "POST") {
287
+ if (!this.adminLoginOk(ip)) {
288
+ res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
289
+ return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
290
+ }
274
291
  return this.readForm(req, (body) => {
275
- if (!tokenOk(body.token ?? ""))
292
+ if (!tokenOk(body.token ?? "")) {
293
+ this.noteAdminFail(ip);
276
294
  return this.adminLoginPage(res, true);
277
- res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200`, location: "/admin" });
295
+ }
296
+ const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
297
+ res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
278
298
  return void res.end();
279
299
  });
280
300
  }
@@ -288,6 +308,11 @@ export class Relay {
288
308
  return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
289
309
  return this.adminExport(res, A0, url);
290
310
  }
311
+ if (url.pathname === "/admin/backup") {
312
+ if (!authed)
313
+ return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
314
+ return this.adminBackup(res, A0);
315
+ }
291
316
  if (!authed)
292
317
  return this.adminLoginPage(res, false);
293
318
  return this.adminDashboard(res, A0);
@@ -315,7 +340,11 @@ export class Relay {
315
340
  return this.json(res, 409, { error: msg, reason: started.reason });
316
341
  }
317
342
  A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
343
+ if (!this.emailBudgetOk())
344
+ return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
318
345
  const sent = await this.email.sendCode(b.email, started.code, "signup");
346
+ if (sent)
347
+ this.emailSends.push(Date.now());
319
348
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
320
349
  });
321
350
  }
@@ -331,7 +360,11 @@ export class Relay {
331
360
  const msg = started.reason === "too_soon" ? "a code was just sent — check your email, or wait a minute" : "no active account for that email — `pantheon signup` first";
332
361
  return this.json(res, code, { error: msg, reason: started.reason });
333
362
  }
363
+ if (!this.emailBudgetOk())
364
+ return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
334
365
  const sent = await this.email.sendCode(b.email, started.code, "login");
366
+ if (sent)
367
+ this.emailSends.push(Date.now());
335
368
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
336
369
  });
337
370
  }
@@ -426,8 +459,48 @@ export class Relay {
426
459
  return null;
427
460
  return { account, device };
428
461
  }
462
+ localTrusted(ip) {
463
+ return this.trustLocal && isTrustedSource(ip);
464
+ }
465
+ adminTokenPresented(req, url) {
466
+ if (!this.adminToken)
467
+ return false;
468
+ const t = url.searchParams.get("token")
469
+ ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "")
470
+ ?? "";
471
+ if (!t)
472
+ return false;
473
+ const a = Buffer.from(t), b = Buffer.from(this.adminToken);
474
+ return a.length === b.length && timingSafeEqualBuf(a, b);
475
+ }
476
+ adminLoginOk(ip) {
477
+ const now = Date.now();
478
+ const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
479
+ return recent.length < 10;
480
+ }
481
+ noteAdminFail(ip) {
482
+ const now = Date.now();
483
+ const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
484
+ recent.push(now);
485
+ this.adminHits.set(ip, recent);
486
+ if (this.adminHits.size > 10_000) {
487
+ for (const [k, v] of this.adminHits) {
488
+ const live = v.filter((t) => now - t < 300_000);
489
+ if (live.length)
490
+ this.adminHits.set(k, live);
491
+ else
492
+ this.adminHits.delete(k);
493
+ }
494
+ }
495
+ }
496
+ emailBudgetOk() {
497
+ const now = Date.now();
498
+ this.emailSends = this.emailSends.filter((t) => now - t < 3_600_000);
499
+ const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
500
+ return this.emailSends.length < cap;
501
+ }
429
502
  authRateOk(ip) {
430
- if (isTrustedSource(ip))
503
+ if (this.localTrusted(ip))
431
504
  return true;
432
505
  const now = Date.now();
433
506
  const recent = (this.authHits.get(ip) ?? []).filter((t) => now - t < 60_000);
@@ -579,6 +652,41 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
579
652
  res.writeHead(200, { "content-type": "text/csv", "content-disposition": `attachment; filename="${table}.csv"` });
580
653
  res.end(csv);
581
654
  }
655
+ adminBackup(res, A) {
656
+ try {
657
+ A.backup(join(this.dataDir, "backups"));
658
+ }
659
+ catch (e) {
660
+ this.log(`! backup snapshot before archive failed: ${e.message}`);
661
+ }
662
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
663
+ const tar = spawn("tar", ["czf", "-", "-C", this.dataDir, "."], { stdio: ["ignore", "pipe", "pipe"] });
664
+ let err = "";
665
+ tar.stderr.on("data", (c) => { err += String(c); });
666
+ tar.on("error", (e) => {
667
+ this.log(`! backup archive failed to start: ${e.message}`);
668
+ if (!res.headersSent)
669
+ this.json(res, 500, { error: `could not start tar: ${e.message}` });
670
+ });
671
+ tar.stdout.once("data", () => {
672
+ if (!res.headersSent) {
673
+ res.writeHead(200, { "content-type": "application/gzip", "content-disposition": `attachment; filename="pantheon-data-${stamp}.tgz"` });
674
+ }
675
+ });
676
+ tar.stdout.pipe(res);
677
+ tar.on("close", (code) => {
678
+ if (code !== 0) {
679
+ this.log(`! backup archive exited ${code}: ${err.trim()}`);
680
+ if (!res.headersSent)
681
+ this.json(res, 500, { error: `tar exited ${code}` });
682
+ }
683
+ res.end();
684
+ });
685
+ res.on("close", () => { try {
686
+ tar.kill();
687
+ }
688
+ catch { } });
689
+ }
582
690
  landingPage(res) {
583
691
  res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
584
692
  res.end(`<!doctype html><meta charset=utf-8><title>pantheon</title>
@@ -616,6 +724,19 @@ pantheon signup --email you@work.com --username you</code></pre>
616
724
  }
617
725
  catch { } }, 10);
618
726
  };
727
+ let helloTimer = setTimeout(() => {
728
+ if (!space) {
729
+ try {
730
+ ws.close(1008, "HANDSHAKE_TIMEOUT");
731
+ }
732
+ catch { }
733
+ }
734
+ }, 20_000);
735
+ const clearHelloTimer = () => { if (helloTimer) {
736
+ clearTimeout(helloTimer);
737
+ helloTimer = null;
738
+ } };
739
+ ws.on("close", clearHelloTimer);
619
740
  this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
620
741
  ws.on("message", (raw) => {
621
742
  let msg;
@@ -644,19 +765,11 @@ pantheon signup --email you@work.com --username you</code></pre>
644
765
  if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
645
766
  return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
646
767
  }
647
- let enrolled = null;
648
768
  let sp;
649
769
  if (m.enroll) {
650
770
  sp = this.spaces.get(m.space) ?? null;
651
771
  if (!sp)
652
772
  return reject("NO_SUCH_SPACE", `no space "${m.space}" on this relay`);
653
- const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
654
- if (!r.ok) {
655
- this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
656
- return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
657
- }
658
- enrolled = sp.token;
659
- this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
660
773
  }
661
774
  else {
662
775
  if (!this.spaces.has(m.space)) {
@@ -671,7 +784,7 @@ pantheon signup --email you@work.com --username you</code></pre>
671
784
  this.noteSpaceCreate(ip);
672
785
  }
673
786
  let acctSession = null;
674
- if (this.requireAccounts && this.accounts && !isTrustedSource(ip)) {
787
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
675
788
  const auth = this.accounts.authorize(m.pubkey, m.member);
676
789
  if (!auth.ok) {
677
790
  const msg = auth.reason === "username_mismatch"
@@ -683,6 +796,16 @@ pantheon signup --email you@work.com --username you</code></pre>
683
796
  m.member = auth.account.username;
684
797
  acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
685
798
  }
799
+ let enrolled = null;
800
+ if (m.enroll) {
801
+ const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
802
+ if (!r.ok) {
803
+ this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
804
+ return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
805
+ }
806
+ enrolled = sp.token;
807
+ this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
808
+ }
686
809
  const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
687
810
  if (verdict === "key-mismatch") {
688
811
  this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
@@ -701,6 +824,7 @@ pantheon signup --email you@work.com --username you</code></pre>
701
824
  return;
702
825
  }
703
826
  space = sp;
827
+ clearHelloTimer();
704
828
  sessionId = acctSession ?? this.mintSession();
705
829
  role = m.role;
706
830
  me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
@@ -1273,7 +1397,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1273
1397
  }
1274
1398
  mayCreateSpace(ip) {
1275
1399
  const now = Date.now();
1276
- if (!isTrustedSource(ip)) {
1400
+ if (!this.localTrusted(ip)) {
1277
1401
  const recent = (this.spaceCreates.get(ip) ?? []).filter((t) => now - t < 3_600_000);
1278
1402
  this.spaceCreates.set(ip, recent);
1279
1403
  if (recent.length >= this.limits.spaceCreatesPerHourPerIp) {
@@ -1291,7 +1415,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1291
1415
  return null;
1292
1416
  }
1293
1417
  noteSpaceCreate(ip) {
1294
- if (isTrustedSource(ip))
1418
+ if (this.localTrusted(ip))
1295
1419
  return;
1296
1420
  const arr = this.spaceCreates.get(ip) ?? [];
1297
1421
  arr.push(Date.now());
@@ -1360,12 +1484,18 @@ pantheon signup --email you@work.com --username you</code></pre>
1360
1484
  if (existing)
1361
1485
  return existing.token === token ? existing : null;
1362
1486
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
1487
+ const allow = new Allowlist(this.dataDir, safe);
1488
+ const match = allow.tokenMatches(token);
1489
+ if (match === false)
1490
+ return null;
1491
+ if (match === null)
1492
+ allow.bindToken(token);
1363
1493
  const store = new StateStore(this.dataDir, safe);
1364
1494
  const restored = store.load();
1365
1495
  const sp = {
1366
1496
  name, token,
1367
1497
  ledger: new Ledger(this.dataDir, safe),
1368
- allow: new Allowlist(this.dataDir, safe),
1498
+ allow,
1369
1499
  store,
1370
1500
  members: new Map(),
1371
1501
  recent: new Map(),
@@ -1,9 +1,14 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
2
+ import { createHash, timingSafeEqual } from "node:crypto";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fingerprint } from "./identity.js";
5
+ function hashToken(token) {
6
+ return createHash("sha256").update(token).digest("hex");
7
+ }
4
8
  export class Allowlist {
5
9
  entries = new Map();
6
10
  invites = new Map();
11
+ tokenHash = null;
7
12
  file;
8
13
  constructor(dir, space) {
9
14
  this.file = join(dir, `${space}.members.json`);
@@ -14,14 +19,35 @@ export class Allowlist {
14
19
  this.entries.set(e.member, e);
15
20
  for (const i of doc.invites ?? [])
16
21
  this.invites.set(i.token, i);
22
+ this.tokenHash = doc.tokenHash ?? null;
17
23
  }
18
24
  }
19
25
  flush() {
20
26
  mkdirSync(dirname(this.file), { recursive: true });
21
- writeFileSync(this.file, JSON.stringify({
27
+ const tmp = `${this.file}.tmp`;
28
+ writeFileSync(tmp, JSON.stringify({
22
29
  members: [...this.entries.values()],
23
30
  invites: [...this.invites.values()],
31
+ ...(this.tokenHash ? { tokenHash: this.tokenHash } : {}),
24
32
  }, null, 2) + "\n", { mode: 0o600 });
33
+ renameSync(tmp, this.file);
34
+ }
35
+ tokenMatches(token) {
36
+ if (!this.tokenHash)
37
+ return null;
38
+ const a = Buffer.from(hashToken(token), "hex");
39
+ const b = Buffer.from(this.tokenHash, "hex");
40
+ return a.length === b.length && timingSafeEqual(a, b);
41
+ }
42
+ bindToken(token) {
43
+ const h = hashToken(token);
44
+ if (this.tokenHash === h)
45
+ return;
46
+ this.tokenHash = h;
47
+ this.flush();
48
+ }
49
+ get hasToken() {
50
+ return this.tokenHash !== null;
25
51
  }
26
52
  mintInvite(by, mayWake, ttlMinutes, token) {
27
53
  const inv = {
package/dist/tray.js CHANGED
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { CONFIG_DIR, load } from "./config.js";
7
7
  import { workerProc, notifierProc } from "./machine.js";
8
8
  import { agentHookStates } from "./notify.js";
9
+ import { requireDeveloperTools } from "./notifier.js";
9
10
  import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
10
11
  import { VERSION } from "./version.js";
11
12
  import { LIMITS } from "./protocol.js";
@@ -22,6 +23,7 @@ export function ensureTrayBinary(home = CONFIG_DIR) {
22
23
  throw new Error(`menu bar source is missing: ${source}`);
23
24
  const binary = trayBinaryPath(home);
24
25
  mkdirSync(dirname(binary), { recursive: true });
26
+ requireDeveloperTools("the menu bar", "menubar");
25
27
  execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
26
28
  chmodSync(binary, 0o755);
27
29
  return binary;
@@ -75,7 +77,7 @@ export async function trayStatus(cfg = load()) {
75
77
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
76
78
  const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
77
79
  const [health, obligations] = await Promise.all([
78
- getJson(`${base}/health`),
80
+ getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
79
81
  getJson(`${base}/obligations?${q}&member=${encodeURIComponent(`human:${cfg.member}`)}`),
80
82
  ]);
81
83
  const space = health
@@ -2,27 +2,50 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { CONFIG_DIR } from "./config.js";
5
- import { repoRoot } from "./upgrade.js";
5
+ import { repoRoot, PKG_NAME } from "./upgrade.js";
6
+ import { VERSION } from "./version.js";
7
+ export function semverGt(x, y) {
8
+ const p = (s) => s.split(".").map((n) => Number(n) || 0);
9
+ const [a, b] = [p(x), p(y)];
10
+ for (let i = 0; i < 3; i++) {
11
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
12
+ return (a[i] ?? 0) > (b[i] ?? 0);
13
+ }
14
+ return false;
15
+ }
6
16
  export function updateStatus(deps) {
7
17
  const cache = deps.readCache();
8
18
  if (cache && deps.now() - cache.checkedAt < deps.throttleMs) {
9
- return { behind: cache.behind, refreshed: false };
19
+ return { behind: cache.behind, refreshed: false, latest: cache.latest };
20
+ }
21
+ if (deps.isGit()) {
22
+ try {
23
+ const behind = deps.fetchBehind();
24
+ deps.writeCache({ checkedAt: deps.now(), behind });
25
+ return { behind, refreshed: true };
26
+ }
27
+ catch {
28
+ return { behind: cache?.behind ?? 0, refreshed: false };
29
+ }
10
30
  }
11
- if (!deps.isGit())
12
- return { behind: cache?.behind ?? 0, refreshed: false };
13
31
  try {
14
- const behind = deps.fetchBehind();
15
- deps.writeCache({ checkedAt: deps.now(), behind });
16
- return { behind, refreshed: true };
32
+ const latest = deps.npmLatest();
33
+ if (!latest)
34
+ return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
35
+ const behind = semverGt(latest, deps.current()) ? 1 : 0;
36
+ deps.writeCache({ checkedAt: deps.now(), behind, latest });
37
+ return { behind, refreshed: true, latest };
17
38
  }
18
39
  catch {
19
- return { behind: cache?.behind ?? 0, refreshed: false };
40
+ return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
20
41
  }
21
42
  }
22
- export function versionLine(version, behind) {
23
- return behind > 0
24
- ? `pantheon ${version} · ${behind} update${behind === 1 ? "" : "s"} behind — run \`pantheon upgrade\``
25
- : `pantheon ${version}`;
43
+ export function versionLine(version, behind, latest) {
44
+ if (behind <= 0)
45
+ return `pantheon ${version}`;
46
+ return latest
47
+ ? `pantheon ${version} · ${latest} available — run \`pantheon upgrade\``
48
+ : `pantheon ${version} · ${behind} update${behind === 1 ? "" : "s"} behind — run \`pantheon upgrade\``;
26
49
  }
27
50
  const cachePath = (home) => join(home ?? CONFIG_DIR, "update-check.json");
28
51
  export function defaultDeps(home) {
@@ -53,10 +76,19 @@ export function defaultDeps(home) {
53
76
  const out = execFileSync("git", ["-C", root, "rev-list", "--count", "HEAD..@{u}"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 2500 }).trim();
54
77
  return Number(out) || 0;
55
78
  },
79
+ npmLatest: () => {
80
+ try {
81
+ return execFileSync("npm", ["view", PKG_NAME, "version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ },
87
+ current: () => VERSION,
56
88
  };
57
89
  }
58
90
  export function updateBanner(version, deps = defaultDeps()) {
59
- const { behind } = updateStatus(deps);
60
- return versionLine(version, behind);
91
+ const { behind, latest } = updateStatus(deps);
92
+ return versionLine(version, behind, latest);
61
93
  }
62
94
  //# sourceMappingURL=update-check.js.map
package/dist/upgrade.js CHANGED
@@ -15,6 +15,7 @@ function pkgVersion(root) {
15
15
  return "unknown";
16
16
  }
17
17
  }
18
+ export const PKG_NAME = "@join-pantheon/cli";
18
19
  export function defaultDeps() {
19
20
  const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
20
21
  return {
@@ -43,6 +44,15 @@ export function defaultDeps() {
43
44
  catch { }
44
45
  return up;
45
46
  },
47
+ npmUpgrade: () => execFileSync("npm", ["install", "-g", `${PKG_NAME}@latest`, "--no-audit", "--no-fund"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
48
+ npmLatest: () => {
49
+ try {
50
+ return execFileSync("npm", ["view", `${PKG_NAME}`, "version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim() || null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ },
46
56
  };
47
57
  }
48
58
  export function upgrade(deps = defaultDeps()) {
@@ -51,7 +61,25 @@ export function upgrade(deps = defaultDeps()) {
51
61
  const steps = [];
52
62
  const stop = (name, detail) => ({ root, from, to: from, steps: [...steps, { name, ok: false, detail }], ok: false, alreadyCurrent: false, restart: [] });
53
63
  if (!deps.isGit(root)) {
54
- return stop("locate", `${root} is not a git checkout, so there is nothing to pull. This build was installed some other way — reinstall from the repo, or once pantheon is on npm, \`npm update -g @join-pantheon/cli\`.`);
64
+ try {
65
+ deps.npmUpgrade();
66
+ steps.push({ name: "npm", ok: true, detail: `installed ${PKG_NAME}@latest` });
67
+ }
68
+ catch (e) {
69
+ return stop("npm", `\`npm install -g ${PKG_NAME}@latest\` failed: ${e.message.split("\n")[0]}. ` +
70
+ `You may need to re-run it with the right permissions (or your Node version manager's global scope).`);
71
+ }
72
+ let wired = [];
73
+ try {
74
+ wired = deps.rewire();
75
+ steps.push({ name: "wire", ok: true, detail: wired.map((w) => `${w.tool}: ${w.status}`).join(" ") });
76
+ }
77
+ catch (e) {
78
+ steps.push({ name: "wire", ok: false, detail: `could not refresh agent config: ${e.message.split("\n")[0]} (run \`pantheon install --user\`)` });
79
+ }
80
+ const to = deps.npmLatest() ?? "latest";
81
+ const restart = [...deps.running(), "your agent session"];
82
+ return { root, from, to, steps, ok: true, alreadyCurrent: false, restart };
55
83
  }
56
84
  const dirty = deps.dirty(root);
57
85
  if (dirty.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,9 +37,9 @@
37
37
  "README.md",
38
38
  "LICENSE"
39
39
  ],
40
- "repository": {
41
- "type": "git",
42
- "url": "git+https://github.com/guptasiddharth/agents-pantheon.git"
40
+ "homepage": "https://www.joinpantheon.network",
41
+ "bugs": {
42
+ "url": "https://www.joinpantheon.network/security"
43
43
  },
44
44
  "license": "MIT",
45
45
  "keywords": [