@join-pantheon/cli 0.18.2 → 0.19.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
@@ -55,9 +55,13 @@ pantheon start
55
55
  **Your teammate, from their clone of the project:**
56
56
 
57
57
  ```bash
58
- pantheon join pantheon://100.92.14.7:8787/acme#C1qhJfdHBaPjuG3jF48vcqrP
58
+ pantheon join pantheons://relay.joinpantheon.network/acme#C1qhJfdHBaPjuG3jF48vcqrP
59
59
  ```
60
60
 
61
+ Signed in, `pantheon start` puts your space on the hosted relay, so that link works from any
62
+ network — no tunnel, no VPN. The space is named after your repo; if another team already took
63
+ that name, start picks `<repo>-<you>` and says so (or choose your own with `--space`).
64
+
61
65
  That's it — one command each sets up the **whole machine**: a relay and space (you) or membership
62
66
  (them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
63
67
  every Conductor window, nothing to commit), the wake-up hook so an agent finishes what a peer is
@@ -88,7 +92,10 @@ Need another (a third person, or your own second machine)? `pantheon invite`.
88
92
 
89
93
  ### Reaching each other across cities
90
94
 
91
- `pantheon start` works this out for you. If Tailscale is running it uses your tailnet address, which
95
+ On the hosted relay there is nothing to work out: every signed-in machine dials
96
+ `relay.joinpantheon.network`. The rest of this section is for **self-hosting** (`pantheon start
97
+ --relay …`, or a machine with no account). Then `pantheon start` runs a relay on your machine and
98
+ works out where a teammate can reach it. If Tailscale is running it uses your tailnet address, which
92
99
  is private and needs no TLS. Otherwise it tells you the address is local-only and how to fix it:
93
100
 
94
101
  ```
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
@@ -78,7 +78,7 @@ export class Relay {
78
78
  dataDir;
79
79
  log;
80
80
  http = createServer((req, res) => this.onHttp(req, res));
81
- wss = new WebSocketServer({ noServer: true });
81
+ wss = new WebSocketServer({ noServer: true, maxPayload: 256 * 1024 });
82
82
  heartbeat;
83
83
  compactedAt = new Map();
84
84
  sessionSeq = 0;
@@ -88,8 +88,11 @@ export class Relay {
88
88
  pruneMs;
89
89
  accounts;
90
90
  requireAccounts;
91
+ trustLocal;
91
92
  email;
92
93
  authHits = new Map();
94
+ adminHits = new Map();
95
+ emailSends = [];
93
96
  adminToken;
94
97
  backupTimer;
95
98
  constructor(opts = {}) {
@@ -97,6 +100,9 @@ export class Relay {
97
100
  this.dataDir = opts.dataDir ?? "./pantheon-data";
98
101
  this.log = opts.log ?? ((l) => console.error(l));
99
102
  this.requireAccounts = opts.requireAccounts ?? process.env.PANTHEON_REQUIRE_ACCOUNTS === "1";
103
+ this.trustLocal = process.env.PANTHEON_TRUST_LOCAL !== undefined
104
+ ? process.env.PANTHEON_TRUST_LOCAL === "1"
105
+ : !this.requireAccounts;
100
106
  this.adminToken = process.env.PANTHEON_ADMIN_TOKEN?.trim() || null;
101
107
  const wantStore = this.requireAccounts || this.adminToken !== null;
102
108
  this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
@@ -160,20 +166,26 @@ export class Relay {
160
166
  return this.onAuthHttp(req, res, url, ip);
161
167
  }
162
168
  if (url.pathname === "/health") {
169
+ const adminView = !!this.adminToken && this.adminTokenPresented(req, url);
170
+ const detail = (s) => ({
171
+ name: s.name,
172
+ members: s.members.size,
173
+ online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
174
+ sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
175
+ goals: s.goals.size,
176
+ seq: s.ledger.head,
177
+ });
178
+ const asked = url.searchParams.get("space");
179
+ const one = asked ? this.spaces.get(asked) : undefined;
163
180
  return this.json(res, 200, {
164
181
  ok: true,
165
182
  v: PROTOCOL_VERSION,
166
183
  pkg: VERSION,
167
184
  space_count: this.spaces.size,
168
185
  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
- })),
186
+ ...(adminView ? { spaces: [...this.spaces.values()].map(detail) }
187
+ : asked ? { spaces: one ? [detail(one)] : [] }
188
+ : {}),
177
189
  });
178
190
  }
179
191
  if (url.pathname === "/history") {
@@ -271,10 +283,17 @@ export class Relay {
271
283
  const fromCookie = cookieVal(req, "pantheon_admin");
272
284
  const fromQuery = url.searchParams.get("token") ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ?? "";
273
285
  if (url.pathname === "/admin/login" && req.method === "POST") {
286
+ if (!this.adminLoginOk(ip)) {
287
+ res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": "60" });
288
+ return void res.end("<p>Too many attempts. Wait a minute and try again.</p>");
289
+ }
274
290
  return this.readForm(req, (body) => {
275
- if (!tokenOk(body.token ?? ""))
291
+ if (!tokenOk(body.token ?? "")) {
292
+ this.noteAdminFail(ip);
276
293
  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" });
294
+ }
295
+ const secure = req.headers["x-forwarded-proto"] === "https" ? "; Secure" : "";
296
+ res.writeHead(302, { "set-cookie": `pantheon_admin=${encodeURIComponent(body.token)}; HttpOnly; SameSite=Strict; Path=/admin; Max-Age=43200${secure}`, location: "/admin" });
278
297
  return void res.end();
279
298
  });
280
299
  }
@@ -315,7 +334,11 @@ export class Relay {
315
334
  return this.json(res, 409, { error: msg, reason: started.reason });
316
335
  }
317
336
  A.audit({ event: "signup", target: normalizeUsername(b.username), ip });
337
+ if (!this.emailBudgetOk())
338
+ return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
318
339
  const sent = await this.email.sendCode(b.email, started.code, "signup");
340
+ if (sent)
341
+ this.emailSends.push(Date.now());
319
342
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
320
343
  });
321
344
  }
@@ -331,7 +354,11 @@ export class Relay {
331
354
  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
355
  return this.json(res, code, { error: msg, reason: started.reason });
333
356
  }
357
+ if (!this.emailBudgetOk())
358
+ return this.json(res, 429, { error: "the relay is sending too many codes right now — try again shortly" });
334
359
  const sent = await this.email.sendCode(b.email, started.code, "login");
360
+ if (sent)
361
+ this.emailSends.push(Date.now());
335
362
  return this.json(res, 200, { ok: true, sent, via: this.email.kind });
336
363
  });
337
364
  }
@@ -426,8 +453,48 @@ export class Relay {
426
453
  return null;
427
454
  return { account, device };
428
455
  }
456
+ localTrusted(ip) {
457
+ return this.trustLocal && isTrustedSource(ip);
458
+ }
459
+ adminTokenPresented(req, url) {
460
+ if (!this.adminToken)
461
+ return false;
462
+ const t = url.searchParams.get("token")
463
+ ?? req.headers["authorization"]?.replace(/^Bearer\s+/i, "")
464
+ ?? "";
465
+ if (!t)
466
+ return false;
467
+ const a = Buffer.from(t), b = Buffer.from(this.adminToken);
468
+ return a.length === b.length && timingSafeEqualBuf(a, b);
469
+ }
470
+ adminLoginOk(ip) {
471
+ const now = Date.now();
472
+ const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
473
+ return recent.length < 10;
474
+ }
475
+ noteAdminFail(ip) {
476
+ const now = Date.now();
477
+ const recent = (this.adminHits.get(ip) ?? []).filter((t) => now - t < 300_000);
478
+ recent.push(now);
479
+ this.adminHits.set(ip, recent);
480
+ if (this.adminHits.size > 10_000) {
481
+ for (const [k, v] of this.adminHits) {
482
+ const live = v.filter((t) => now - t < 300_000);
483
+ if (live.length)
484
+ this.adminHits.set(k, live);
485
+ else
486
+ this.adminHits.delete(k);
487
+ }
488
+ }
489
+ }
490
+ emailBudgetOk() {
491
+ const now = Date.now();
492
+ this.emailSends = this.emailSends.filter((t) => now - t < 3_600_000);
493
+ const cap = Number(process.env.PANTHEON_EMAIL_MAX_PER_HOUR) || 200;
494
+ return this.emailSends.length < cap;
495
+ }
429
496
  authRateOk(ip) {
430
- if (isTrustedSource(ip))
497
+ if (this.localTrusted(ip))
431
498
  return true;
432
499
  const now = Date.now();
433
500
  const recent = (this.authHits.get(ip) ?? []).filter((t) => now - t < 60_000);
@@ -616,6 +683,19 @@ pantheon signup --email you@work.com --username you</code></pre>
616
683
  }
617
684
  catch { } }, 10);
618
685
  };
686
+ let helloTimer = setTimeout(() => {
687
+ if (!space) {
688
+ try {
689
+ ws.close(1008, "HANDSHAKE_TIMEOUT");
690
+ }
691
+ catch { }
692
+ }
693
+ }, 20_000);
694
+ const clearHelloTimer = () => { if (helloTimer) {
695
+ clearTimeout(helloTimer);
696
+ helloTimer = null;
697
+ } };
698
+ ws.on("close", clearHelloTimer);
619
699
  this.send(ws, { t: "challenge", nonce, v: PROTOCOL_VERSION });
620
700
  ws.on("message", (raw) => {
621
701
  let msg;
@@ -644,19 +724,11 @@ pantheon signup --email you@work.com --username you</code></pre>
644
724
  if (!verifyChallenge(m.pubkey, m.space, m.member, m.nonce, m.sig)) {
645
725
  return reject("BAD_SIGNATURE", `signature does not match the presented key for "${m.member}"`);
646
726
  }
647
- let enrolled = null;
648
727
  let sp;
649
728
  if (m.enroll) {
650
729
  sp = this.spaces.get(m.space) ?? null;
651
730
  if (!sp)
652
731
  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
732
  }
661
733
  else {
662
734
  if (!this.spaces.has(m.space)) {
@@ -671,7 +743,7 @@ pantheon signup --email you@work.com --username you</code></pre>
671
743
  this.noteSpaceCreate(ip);
672
744
  }
673
745
  let acctSession = null;
674
- if (this.requireAccounts && this.accounts && !isTrustedSource(ip)) {
746
+ if (this.requireAccounts && this.accounts && !this.localTrusted(ip)) {
675
747
  const auth = this.accounts.authorize(m.pubkey, m.member);
676
748
  if (!auth.ok) {
677
749
  const msg = auth.reason === "username_mismatch"
@@ -683,6 +755,16 @@ pantheon signup --email you@work.com --username you</code></pre>
683
755
  m.member = auth.account.username;
684
756
  acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
685
757
  }
758
+ let enrolled = null;
759
+ if (m.enroll) {
760
+ const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
761
+ if (!r.ok) {
762
+ this.log(`[${sp.name}] ! invite refused for ${m.member}: ${r.why}`);
763
+ return reject("BAD_INVITE", r.fix ? `${r.why}\n${r.fix}` : r.why);
764
+ }
765
+ enrolled = sp.token;
766
+ this.log(`[${sp.name}] * ENROLLED ${m.member} via invite from ${r.entry.admitted_by}${r.entry.may_wake ? " (may wake)" : ""}`);
767
+ }
686
768
  const verdict = enrolled ? "ok" : sp.allow.present(m.member, m.pubkey);
687
769
  if (verdict === "key-mismatch") {
688
770
  this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
@@ -701,6 +783,7 @@ pantheon signup --email you@work.com --username you</code></pre>
701
783
  return;
702
784
  }
703
785
  space = sp;
786
+ clearHelloTimer();
704
787
  sessionId = acctSession ?? this.mintSession();
705
788
  role = m.role;
706
789
  me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, m.role, ws, m.pkg);
@@ -1273,7 +1356,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1273
1356
  }
1274
1357
  mayCreateSpace(ip) {
1275
1358
  const now = Date.now();
1276
- if (!isTrustedSource(ip)) {
1359
+ if (!this.localTrusted(ip)) {
1277
1360
  const recent = (this.spaceCreates.get(ip) ?? []).filter((t) => now - t < 3_600_000);
1278
1361
  this.spaceCreates.set(ip, recent);
1279
1362
  if (recent.length >= this.limits.spaceCreatesPerHourPerIp) {
@@ -1291,7 +1374,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1291
1374
  return null;
1292
1375
  }
1293
1376
  noteSpaceCreate(ip) {
1294
- if (isTrustedSource(ip))
1377
+ if (this.localTrusted(ip))
1295
1378
  return;
1296
1379
  const arr = this.spaceCreates.get(ip) ?? [];
1297
1380
  arr.push(Date.now());
@@ -1360,12 +1443,18 @@ pantheon signup --email you@work.com --username you</code></pre>
1360
1443
  if (existing)
1361
1444
  return existing.token === token ? existing : null;
1362
1445
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
1446
+ const allow = new Allowlist(this.dataDir, safe);
1447
+ const match = allow.tokenMatches(token);
1448
+ if (match === false)
1449
+ return null;
1450
+ if (match === null)
1451
+ allow.bindToken(token);
1363
1452
  const store = new StateStore(this.dataDir, safe);
1364
1453
  const restored = store.load();
1365
1454
  const sp = {
1366
1455
  name, token,
1367
1456
  ledger: new Ledger(this.dataDir, safe),
1368
- allow: new Allowlist(this.dataDir, safe),
1457
+ allow,
1369
1458
  store,
1370
1459
  members: new Map(),
1371
1460
  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.0",
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": {