@join-pantheon/cli 0.20.0 → 0.21.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
@@ -67,7 +67,10 @@ every Conductor window, nothing to commit), the wake-up hook so an agent finishe
67
67
  waiting for, the **worker** so the machine answers the team even while its human is away (on by
68
68
  default when run inside a git repo; `--no-worker` opts out — and a peer still needs an explicit
69
69
  `--may-wake` grant before they can use it), native decision alerts, and the menu bar. Each command
70
- ends with a "You're set" summary. Restart your agent once and ask it *"check pantheon_status"*.
70
+ ends with a "You're set" summary, then a short guided setup on a terminal: whether teammates may wake
71
+ your machine while you are away, what a woken run may execute, who you are and which decisions are
72
+ yours alone (so agents stop and ask you), and which alerts you want. Plain questions, mostly options;
73
+ `pantheon setup` re-runs it any time. Restart your agent once and ask it *"check pantheon_status"*.
71
74
 
72
75
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
73
76
  `pantheon install --user` (from your project's repo).
@@ -89,6 +92,15 @@ pantheon: that invite was already used by "alex" — ask for a fresh one
89
92
 
90
93
  Need another (a third person, or your own second machine)? `pantheon invite`.
91
94
 
95
+ ### Plans on the hosted relay
96
+
97
+ A free account creates **one space** with **two people** in it — you and one invite. Personas,
98
+ messages and history are not limited. Joining spaces other people created is not limited either,
99
+ so a colleague on a bigger plan can always invite you. When you hit a limit the command says so
100
+ and what to do; `pantheon status` shows your plan and how much of it you are using. Need more?
101
+ [joinpantheon.network](https://www.joinpantheon.network). A space you founded and no longer need
102
+ can be dissolved with `pantheon space delete <team> --yes`, which frees the slot.
103
+
92
104
  ### Reaching each other across cities
93
105
 
94
106
  On the hosted relay there is nothing to work out: every signed-in machine dials
@@ -119,6 +131,7 @@ agent opened in a folder bound to no team refuses to send rather than guess.
119
131
  pantheon spaces # your teams, which repo is bound to which, where this folder points
120
132
  pantheon use mobile --here # bind this repo to a team (and register the repo with it)
121
133
  pantheon repo list # the repos a team works from — a delivery must come from one of them
134
+ pantheon repo add --base main # the branch workers cut from and deliveries are judged against (default: origin's HEAD)
122
135
  pantheon leave mobile # drop one team from this machine
123
136
  pantheon profile set --space mobile --human-required pricing # authority that applies in one team only
124
137
  ```
@@ -486,8 +499,12 @@ Security details and how to report a vulnerability:
486
499
  ## Upgrading
487
500
 
488
501
  ```bash
489
- pantheon upgrade # installs the latest release and re-wires your agents
502
+ pantheon upgrade # installs the latest release and finishes the job
490
503
  ```
491
504
 
492
- Every command prints its version and nudges you when a newer release exists. Restart your agent
493
- session after upgrading so it loads the new MCP server.
505
+ Every command prints its version and nudges you when a newer release exists. `pantheon upgrade`
506
+ installs the new release, re-wires your agents, restarts pantheon's own background services (the
507
+ worker, alerts, menu bar) so they load the new code, and asks any setup question the new version
508
+ has that you have never answered. The one thing it cannot do is restart your agent windows: quit
509
+ and reopen them so they load the new server. A self-hosted relay is shared with your teammates, so
510
+ it prints the restart for that and leaves the timing to you.
package/dist/accounts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
1
2
  import { createRequire } from "node:module";
2
3
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
3
4
  import { chmodSync, mkdirSync, readdirSync, rmSync } from "node:fs";
@@ -104,7 +105,29 @@ export class Accounts {
104
105
  kind TEXT NOT NULL,
105
106
  created_at INTEGER NOT NULL
106
107
  );
108
+ CREATE TABLE IF NOT EXISTS settings (
109
+ key TEXT PRIMARY KEY,
110
+ json TEXT NOT NULL,
111
+ updated_at INTEGER NOT NULL
112
+ );
113
+ CREATE TABLE IF NOT EXISTS spaces (
114
+ name TEXT PRIMARY KEY,
115
+ owner_account TEXT REFERENCES accounts(id) ON DELETE SET NULL,
116
+ created_at INTEGER NOT NULL,
117
+ deleted_at INTEGER
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_account);
107
120
  `);
121
+ const cols = new Set(this.db.prepare("PRAGMA table_info(accounts)").all().map((c) => c.name));
122
+ if (!cols.has("plan"))
123
+ this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
124
+ if (!cols.has("limits_json"))
125
+ this.db.exec("ALTER TABLE accounts ADD COLUMN limits_json TEXT");
126
+ const scols = new Set(this.db.prepare("PRAGMA table_info(sessions)").all().map((c) => c.name));
127
+ if (!scols.has("ended_at"))
128
+ this.db.exec("ALTER TABLE sessions ADD COLUMN ended_at INTEGER");
129
+ if (!scols.has("role"))
130
+ this.db.exec("ALTER TABLE sessions ADD COLUMN role TEXT");
108
131
  }
109
132
  close() { this.db.close(); }
110
133
  accountByUsername(username) {
@@ -201,7 +224,7 @@ export class Accounts {
201
224
  else {
202
225
  const id = newId("acct");
203
226
  run("INSERT INTO accounts (id, email, username, status, created_at, tos_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, e, v.pending_username, now(), now());
204
- account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now() };
227
+ account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now(), plan: "free", limits_json: null };
205
228
  }
206
229
  }
207
230
  else {
@@ -231,18 +254,28 @@ export class Accounts {
231
254
  this.audit({ actor_account: accountId, event: "device_added", target: d.fingerprint });
232
255
  return d;
233
256
  }
234
- openSession(accountId, deviceId, space, ip) {
235
- const s = { id: newId("ses"), account_id: accountId, device_id: deviceId, space, ip, created_at: now(), last_seen: now(), revoked_at: null };
236
- this.db.prepare("INSERT INTO sessions (id, account_id, device_id, space, ip, created_at, last_seen, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)")
237
- .run(s.id, s.account_id, s.device_id, s.space, s.ip, s.created_at, s.last_seen);
238
- this.audit({ actor_account: accountId, event: "session_created", space: space || null, target: s.id, ip });
257
+ openSession(accountId, deviceId, space, ip, role = null) {
258
+ const prior = this.db.prepare("SELECT ip FROM sessions WHERE device_id = ? ORDER BY created_at DESC LIMIT 1").get(deviceId);
259
+ const s = { id: newId("ses"), account_id: accountId, device_id: deviceId, space, ip, created_at: now(), last_seen: now(), revoked_at: null, ended_at: null, role };
260
+ this.db.prepare("INSERT INTO sessions (id, account_id, device_id, space, ip, created_at, last_seen, revoked_at, ended_at, role) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)")
261
+ .run(s.id, s.account_id, s.device_id, s.space, s.ip, s.created_at, s.last_seen, role);
262
+ if (!prior)
263
+ this.audit({ actor_account: accountId, event: "session_created", space: space || null, target: s.id, ip });
264
+ else if (prior.ip !== ip)
265
+ this.audit({ actor_account: accountId, event: "device_new_ip", space: space || null, target: deviceId, detail: `${prior.ip} → ${ip}`, ip });
239
266
  return s;
240
267
  }
241
268
  touchSession(id) {
242
- this.db.prepare("UPDATE sessions SET last_seen = ? WHERE id = ? AND revoked_at IS NULL").run(now(), id);
269
+ this.db.prepare("UPDATE sessions SET last_seen = ? WHERE id = ? AND revoked_at IS NULL AND ended_at IS NULL").run(now(), id);
270
+ }
271
+ endAllOpenSessions() {
272
+ return Number(this.db.prepare("UPDATE sessions SET ended_at = ?, last_seen = last_seen WHERE ended_at IS NULL").run(now()).changes);
273
+ }
274
+ endSession(id) {
275
+ this.db.prepare("UPDATE sessions SET ended_at = ?, last_seen = ? WHERE id = ? AND ended_at IS NULL").run(now(), now(), id);
243
276
  }
244
277
  sessionsFor(accountId) {
245
- return this.db.prepare("SELECT * FROM sessions WHERE account_id = ? ORDER BY created_at DESC").all(accountId);
278
+ return this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND ended_at IS NULL ORDER BY created_at DESC").all(accountId);
246
279
  }
247
280
  devicesFor(accountId) {
248
281
  return this.db.prepare("SELECT * FROM devices WHERE account_id = ? ORDER BY created_at ASC").all(accountId);
@@ -340,12 +373,105 @@ export class Accounts {
340
373
  recentVerifications(limit = 50) {
341
374
  return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
342
375
  }
376
+ plans() {
377
+ const row = this.db.prepare("SELECT json FROM settings WHERE key = 'plans'").get();
378
+ if (row) {
379
+ try {
380
+ const p = parsePlans(JSON.parse(row.json));
381
+ if (p)
382
+ return p;
383
+ }
384
+ catch { }
385
+ }
386
+ if (process.env.PANTHEON_PLANS_JSON) {
387
+ try {
388
+ const p = parsePlans(JSON.parse(process.env.PANTHEON_PLANS_JSON));
389
+ if (p)
390
+ return p;
391
+ }
392
+ catch { }
393
+ }
394
+ return structuredClone(DEFAULT_PLANS);
395
+ }
396
+ setPlans(plans) {
397
+ if (!parsePlans(plans))
398
+ throw new Error("malformed plan table");
399
+ this.db.prepare("INSERT INTO settings (key, json, updated_at) VALUES ('plans', ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at")
400
+ .run(JSON.stringify(plans), now());
401
+ }
402
+ setPlan(accountId, plan) {
403
+ if (!this.plans()[plan])
404
+ return false;
405
+ return this.db.prepare("UPDATE accounts SET plan = ? WHERE id = ?").run(plan, accountId).changes > 0;
406
+ }
407
+ setLimits(accountId, json) {
408
+ if (json !== null) {
409
+ let ok = false;
410
+ try {
411
+ ok = !!parseLimits(JSON.parse(json));
412
+ }
413
+ catch {
414
+ ok = false;
415
+ }
416
+ if (!ok)
417
+ return false;
418
+ }
419
+ return this.db.prepare("UPDATE accounts SET limits_json = ? WHERE id = ?").run(json, accountId).changes > 0;
420
+ }
421
+ limitsFor(acct) { return planLimits(this.plans(), acct.plan, acct.limits_json); }
422
+ planCounts() {
423
+ const out = {};
424
+ for (const r of this.db.prepare("SELECT plan, COUNT(*) c FROM accounts GROUP BY plan").all())
425
+ out[r.plan] = r.c;
426
+ return out;
427
+ }
428
+ planLimitHits() {
429
+ const m = new Map();
430
+ for (const r of this.db.prepare("SELECT actor_account a, COUNT(*) c FROM audit WHERE event = 'plan_limit' AND actor_account IS NOT NULL GROUP BY actor_account").all())
431
+ m.set(r.a, r.c);
432
+ return m;
433
+ }
434
+ recordSpace(name, ownerAccount) {
435
+ this.db.prepare("INSERT INTO spaces (name, owner_account, created_at, deleted_at) VALUES (?, ?, ?, NULL) " +
436
+ "ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
437
+ }
438
+ markSpaceDeleted(name) {
439
+ this.db.prepare("UPDATE spaces SET deleted_at = ? WHERE name = ? AND deleted_at IS NULL").run(now(), name);
440
+ }
441
+ spaceOwner(name) {
442
+ const r = this.db.prepare("SELECT owner_account FROM spaces WHERE name = ? AND deleted_at IS NULL").get(name);
443
+ return r?.owner_account ? this.accountById(r.owner_account) : null;
444
+ }
445
+ spacesOwned(accountId) {
446
+ return this.db.prepare("SELECT name FROM spaces WHERE owner_account = ? AND deleted_at IS NULL ORDER BY created_at").all(accountId).map((r) => r.name);
447
+ }
448
+ spaceOwners() {
449
+ const m = new Map();
450
+ for (const r of this.db.prepare("SELECT name, owner_account FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL").all())
451
+ m.set(r.name, r.owner_account);
452
+ return m;
453
+ }
454
+ ownedCounts() {
455
+ const m = new Map();
456
+ for (const r of this.db.prepare("SELECT owner_account a, COUNT(*) c FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL GROUP BY owner_account").all())
457
+ m.set(r.a, r.c);
458
+ return m;
459
+ }
460
+ hasSpaceRecord(name) {
461
+ return !!this.db.prepare("SELECT 1 FROM spaces WHERE name = ?").get(name);
462
+ }
343
463
  allAccounts() {
344
464
  return this.db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all();
345
465
  }
346
466
  allDevices() {
347
467
  return this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
348
468
  }
469
+ liveSessions(limit = 500) {
470
+ return this.db.prepare("SELECT * FROM sessions WHERE ended_at IS NULL AND revoked_at IS NULL ORDER BY created_at DESC LIMIT ?").all(limit);
471
+ }
472
+ endedSessionsSince(sinceMs) {
473
+ return this.db.prepare("SELECT COUNT(*) c FROM sessions WHERE ended_at IS NOT NULL AND ended_at >= ?").get(sinceMs).c;
474
+ }
349
475
  allSessions(limit = 500) {
350
476
  return this.db.prepare("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?").all(limit);
351
477
  }
@@ -355,7 +481,7 @@ export class Accounts {
355
481
  accounts: n("SELECT COUNT(*) c FROM accounts"),
356
482
  devices: n("SELECT COUNT(*) c FROM devices"),
357
483
  sessions: n("SELECT COUNT(*) c FROM sessions"),
358
- live_sessions: n("SELECT COUNT(*) c FROM sessions WHERE revoked_at IS NULL"),
484
+ live_sessions: n("SELECT COUNT(*) c FROM sessions WHERE revoked_at IS NULL AND ended_at IS NULL"),
359
485
  audit: n("SELECT COUNT(*) c FROM audit"),
360
486
  };
361
487
  }
@@ -363,6 +489,7 @@ export class Accounts {
363
489
  const sessionDays = opts.sessionDays ?? 30;
364
490
  const auditDays = opts.auditDays ?? 365;
365
491
  const sCut = now() - sessionDays * 86_400_000;
492
+ this.db.prepare("DELETE FROM sessions WHERE ended_at IS NOT NULL AND ended_at < ?").run(now() - 7 * 86_400_000);
366
493
  const s = this.db.prepare("DELETE FROM sessions WHERE last_seen < ?").run(sCut);
367
494
  let a = { changes: 0 };
368
495
  if (auditDays > 0)
package/dist/agents.js CHANGED
@@ -93,6 +93,8 @@ export function headlessSpawn(opts) {
93
93
  PANTHEON_AGENT: opts.backend.id,
94
94
  PANTHEON_ROLE: "worker",
95
95
  PANTHEON_BRANCH: opts.branch,
96
+ ...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
97
+ ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
96
98
  ...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
97
99
  ...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
98
100
  };
@@ -101,6 +103,8 @@ export function headlessSpawn(opts) {
101
103
  PANTHEON_AGENT: opts.backend.id,
102
104
  PANTHEON_ROLE: "worker",
103
105
  PANTHEON_BRANCH: opts.branch,
106
+ ...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
107
+ ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
104
108
  ...(home ? { PANTHEON_HOME: home } : {}),
105
109
  };
106
110
  if (opts.backend.id === "claude-code") {
package/dist/cli.js CHANGED
@@ -7,16 +7,19 @@ process.emitWarning = ((warning, ...rest) => {
7
7
  return _emitWarning(warning, ...rest);
8
8
  });
9
9
  import { randomBytes } from "node:crypto";
10
+ import { readFileSync } from "node:fs";
10
11
  import { dirname, join, resolve } from "node:path";
11
12
  import { PantheonClient } from "./client.js";
12
- import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY } from "./config.js";
13
+ import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
14
+ import { fmtLimit } from "./plans.js";
13
15
  import { validEmail, validUsername } from "./accounts.js";
14
16
  import { install, launcher } from "./install.js";
15
17
  import { Relay } from "./relay.js";
16
18
  import { runMcp } from "./mcp.js";
17
19
  import { Worker } from "./worker.js";
18
20
  import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
19
- import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile } from "./profile.js";
21
+ import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
22
+ import { runGuide, terminalIO } from "./setup-guide.js";
20
23
  import { doctor, render } from "./doctor.js";
21
24
  import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
22
25
  import { assess } from "./untrusted.js";
@@ -266,6 +269,66 @@ function unattendedProject() {
266
269
  return { project: resolve(flag("project", ".")), how: "explicit" };
267
270
  return { project: resolve("."), how: "default" };
268
271
  }
272
+ async function guidedSetup(cfg, project, opts = {}) {
273
+ if (argv.includes("--no-guide"))
274
+ return;
275
+ const interactive = !!process.stdin.isTTY && !argv.includes("--yes");
276
+ if (!interactive && !opts.force)
277
+ return;
278
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : null;
279
+ const io = rl ? terminalIO(rl, (l) => console.error(l)) : null;
280
+ console.error(`\n${B("A few choices, then you're done.")}${io ? "" : D(" (no terminal — taking the defaults)")}`);
281
+ try {
282
+ const r = await runGuide(io, { cfg, project }, {
283
+ publish: async (p) => {
284
+ const c = new PantheonClient(cfg, undefined, { role: "cli" });
285
+ try {
286
+ await c.connect();
287
+ await c.publishProfile(organizationProfile(p, cfg.spaceId, cfg.space));
288
+ }
289
+ finally {
290
+ c.close();
291
+ }
292
+ },
293
+ ...(project ? {
294
+ setRepoBase: async (base) => {
295
+ const info = repoInfo(project);
296
+ if (!info)
297
+ throw new Error("not inside a git repo");
298
+ const { repo: _r, repoOrigin: _o, ...plain } = cfg;
299
+ const c = new PantheonClient(plain, undefined, { role: "cli" });
300
+ try {
301
+ await c.connect();
302
+ await c.bindRepo(info.fingerprint, info.origin, base);
303
+ }
304
+ finally {
305
+ c.close();
306
+ }
307
+ if (cfg.spaceId)
308
+ bindRepo(info, cfg.spaceId);
309
+ },
310
+ } : {}),
311
+ });
312
+ console.error("");
313
+ for (const l of r.applied)
314
+ console.error(` ${C("✓")} ${l}`);
315
+ for (const w of r.warnings)
316
+ console.error(`\x1b[33m ! ${w}\x1b[0m`);
317
+ console.error(D(`\n Change any of this later: pantheon setup`));
318
+ }
319
+ finally {
320
+ rl?.close();
321
+ }
322
+ }
323
+ function workerMidRun() {
324
+ try {
325
+ const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
326
+ return !!s.at && Date.now() - s.at < 10 * 60_000 && /run/i.test(String(s.state ?? ""));
327
+ }
328
+ catch {
329
+ return false;
330
+ }
331
+ }
269
332
  function printReady(tools) {
270
333
  const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
271
334
  console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
@@ -317,7 +380,9 @@ ${B("several teams on one machine")}
317
380
  pantheon spaces your teams, which repo is bound to which, and where this folder points
318
381
  pantheon use <team> --here bind this repo to a team (agents opened here speak in it)
319
382
  pantheon repo add | list the repos a team works from; a delivery must come from one of them
383
+ (repo add --base <branch>: the branch workers cut from, else origin's default)
320
384
  pantheon leave <team> drop one team from this machine (its token and repo bindings)
385
+ pantheon space delete <team> --yes dissolve a team you founded: disconnects everyone, frees a plan slot
321
386
  pantheon profile set --space <team> … authority that applies in one team only (e.g. --human-required pricing)
322
387
 
323
388
  ${B("day to day")}
@@ -350,6 +415,8 @@ ${B("control")}
350
415
  pantheon leave remove what pantheon put on this machine
351
416
  pantheon panic cut off all unattended work on this machine, now
352
417
  pantheon doctor check this machine end to end
418
+ pantheon setup the guided choices: who may wake this machine, what a run may execute,
419
+ what you personally decide, which alerts you want — re-run any time
353
420
  pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
354
421
 
355
422
  ${D("more: pantheon help --all")}
@@ -357,7 +424,7 @@ ${D("more: pantheon help --all")}
357
424
  const HELP_ALL = HELP + `
358
425
  ${B("less common")}
359
426
  pantheon relay run only the relay, in this terminal
360
- pantheon service install [relay|worker|notifier|menubar] | uninstall
427
+ pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
361
428
  run a long-lived component in the background, from login
362
429
  pantheon members the full list, with key fingerprints
363
430
  pantheon whoami your name and fingerprint
@@ -428,6 +495,8 @@ async function main() {
428
495
  console.error(D(` ${r.reach.hint}`));
429
496
  }
430
497
  printReady(r.tools);
498
+ if (r.claimed)
499
+ await guidedSetup(load() ?? r.cfg, repoInfo(process.cwd())?.root ?? null);
431
500
  if (r.relay) {
432
501
  console.error(D(`\nKeeping the relay running here. Ctrl-C stops it (your teammate goes offline too).`));
433
502
  await new Promise(() => { });
@@ -440,7 +509,8 @@ async function main() {
440
509
  await client.connect().catch((e) => die(client.pending ?? e.message));
441
510
  await new Promise((r) => setTimeout(r, 250));
442
511
  const ttl = Number(flag("ttl", "1440"));
443
- const inv = await client.makeInvite(!argv.includes("--no-wake"), ttl).catch((e) => die(e.message));
512
+ const mayWake = argv.includes("--may-wake") ? true : argv.includes("--no-wake") ? false : wakeDefault(loadProfile(cfg));
513
+ const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
444
514
  console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
445
515
  console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
446
516
  client.close();
@@ -558,6 +628,11 @@ async function main() {
558
628
  console.error(`\x1b[33m ! ${w}\x1b[0m`);
559
629
  printGranted(r.granted);
560
630
  printReady(r.tools);
631
+ {
632
+ const joined = load();
633
+ if (joined)
634
+ await guidedSetup(joined, repoInfo(process.cwd())?.root ?? null);
635
+ }
561
636
  console.error(`\n${D('Then ask your agent: "check pantheon_status and say hello to the team".')}`);
562
637
  return;
563
638
  }
@@ -607,15 +682,48 @@ async function main() {
607
682
  return;
608
683
  }
609
684
  console.error(`\n${B(`upgraded ${r.from} → ${r.to}`)}`);
610
- console.error(`\n${B("Restart to load the new code:")}`);
611
- for (const what of r.restart) {
612
- if (what === "relay")
613
- console.error(` relay launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay ${D("(briefly drops your teammates — pick the moment)")}`);
614
- else if (what === "worker")
615
- console.error(` worker launchctl kickstart -k gui/$(id -u)/dev.pantheon.worker`);
685
+ const { restartService, serviceInstalled } = await import("./service.js");
686
+ console.error("");
687
+ if (process.platform === "darwin") {
688
+ const { trayStale, ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
689
+ const { notifierStale, ensureNativeNotifier } = await import("./notifier.js");
690
+ for (const [stale, build, what] of [[trayStale, () => { ensureTrayBinary(); writeTrayCommands(); }, "menu bar"], [notifierStale, ensureNativeNotifier, "alerts"]]) {
691
+ if (!stale())
692
+ continue;
693
+ try {
694
+ build();
695
+ console.error(` ${what}: rebuilt from this release's source`);
696
+ }
697
+ catch (e) {
698
+ console.error(` ${what}: could not rebuild (${e.message.split("\n")[0]})`);
699
+ }
700
+ }
701
+ }
702
+ for (const kind of ["worker", "notifier", "menubar"]) {
703
+ if (!serviceInstalled(kind))
704
+ continue;
705
+ if (kind === "worker" && workerMidRun()) {
706
+ console.error(` ${D("worker: a run is in progress — left alone. When it is done:")} pantheon service restart worker`);
707
+ continue;
708
+ }
709
+ for (const l of restartService(kind))
710
+ console.error(l);
711
+ }
712
+ if (r.restart.includes("relay"))
713
+ console.error(` relay: ${D("shared with your teammates, so it is yours to time:")} launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay`);
714
+ const cfgNow = load();
715
+ if (cfgNow) {
716
+ if (loadProfile(cfgNow).agent.wake_default === undefined)
717
+ await guidedSetup(cfgNow, repoInfo(process.cwd())?.root ?? null);
616
718
  else
617
- console.error(` agent quit and reopen ${what.replace(/^your /, "your ")}`);
719
+ console.error(D(`\n Your setup answers still apply — pantheon setup to change them.`));
618
720
  }
721
+ console.error(`\n${B("One thing only you can do:")} quit and reopen your agent windows so they load the new pantheon.`);
722
+ return;
723
+ }
724
+ case "setup": {
725
+ const cfg = need();
726
+ await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true });
619
727
  return;
620
728
  }
621
729
  case "version": {
@@ -636,6 +744,8 @@ async function main() {
636
744
  await new Promise((r) => setTimeout(r, 350));
637
745
  const peers = client.roster.filter((m) => m.id !== cfg.member);
638
746
  console.log(`${B(cfg.space)} you are ${C(cfg.member)} ${D(cfg.relay)} ${D(`${client.head} acts`)}\n`);
747
+ if (client.plan)
748
+ console.log(D(` ${client.plan.name} plan · ${client.plan.usage.spaces} of ${fmtLimit(client.plan.limits.spaces)} spaces created · ${client.plan.usage.members} of ${fmtLimit(client.plan.limits.members)} people here${client.plan.limits.wake ? "" : " · no unattended workers"}`) + "\n");
639
749
  console.log(B("members"));
640
750
  if (!peers.length)
641
751
  console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
@@ -944,7 +1054,7 @@ async function main() {
944
1054
  }
945
1055
  case "hook": {
946
1056
  const sub = argv[1] ?? "status";
947
- const hookCfg = needStrict();
1057
+ const hookCfg = resolveForHook();
948
1058
  const reportIdle = async (session, seen) => {
949
1059
  if (!hookCfg)
950
1060
  return true;
@@ -1047,7 +1157,7 @@ async function main() {
1047
1157
  const { pending: pendingFn } = await import("./pending.js");
1048
1158
  const { load: loadCfg } = await import("./config.js");
1049
1159
  const d = await decideStop(JSON.stringify({ session_id: session }), {
1050
- pending: async (s) => pendingFn({ session: s }),
1160
+ pending: async (s) => (hookCfg ? pendingFn({ session: s, cfg: hookCfg }) : Promise.resolve(null)),
1051
1161
  ...(loadCfg()?.member ? { me: loadCfg().member } : {}),
1052
1162
  });
1053
1163
  if (d.exit === 2 && d.message) {
@@ -1080,7 +1190,7 @@ async function main() {
1080
1190
  const { pending: pendingFn } = await import("./pending.js");
1081
1191
  const { load: loadCfg } = await import("./config.js");
1082
1192
  const d = await decideStop(raw, {
1083
- pending: async (session) => pendingFn({ session }),
1193
+ pending: async (session) => (hookCfg ? pendingFn({ session, cfg: hookCfg }) : Promise.resolve(null)),
1084
1194
  ...(loadCfg()?.member ? { me: loadCfg().member } : {}),
1085
1195
  });
1086
1196
  if (d.exit === 2 && d.message) {
@@ -1118,7 +1228,9 @@ async function main() {
1118
1228
  let surfaced;
1119
1229
  const d = await decideStop(raw, {
1120
1230
  pending: async (session) => {
1121
- const r = await pendingFn({ session });
1231
+ if (!hookCfg)
1232
+ return null;
1233
+ const r = await pendingFn({ session, cfg: hookCfg });
1122
1234
  if (r)
1123
1235
  surfaced = r.seq;
1124
1236
  return r;
@@ -1188,6 +1300,24 @@ async function main() {
1188
1300
  }
1189
1301
  return;
1190
1302
  }
1303
+ case "space": {
1304
+ const sub = argv[1];
1305
+ const name = argv[2];
1306
+ if (sub !== "delete" || !name || name.startsWith("--"))
1307
+ die("usage: pantheon space delete <team> --yes");
1308
+ const all = loadAll() ?? die("not configured");
1309
+ const m = findMembership(all, name) ?? die(`you are not in a team called "${name}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
1310
+ if (!argv.includes("--yes"))
1311
+ die(`this deletes "${m.name}" for everyone in it — its history, goals and invites — and cannot be undone.\n If you mean it: pantheon space delete ${m.name} --yes`);
1312
+ const client = new PantheonClient(configFor(all, m, null), undefined, { role: "cli" });
1313
+ await client.connect().catch((e) => die(client.pending ?? e.message));
1314
+ await client.deleteSpace(m.name).catch((e) => { client.close(); die(e.message); });
1315
+ client.close();
1316
+ const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).length;
1317
+ removeMembership(m.id);
1318
+ console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} repo binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
1319
+ return;
1320
+ }
1191
1321
  case "leave": {
1192
1322
  if (argv[1] && !argv[1].startsWith("--")) {
1193
1323
  const all = loadAll() ?? die("not configured");
@@ -2037,6 +2167,12 @@ async function main() {
2037
2167
  console.error(l);
2038
2168
  return;
2039
2169
  }
2170
+ if (sub === "restart") {
2171
+ const { restartService } = await import("./service.js");
2172
+ for (const l of restartService(kind))
2173
+ console.error(l);
2174
+ return;
2175
+ }
2040
2176
  if (kind === "relay") {
2041
2177
  const data = resolveDataDir(flag("data")).dir;
2042
2178
  const port = flag("port", "8787");
@@ -2159,16 +2295,17 @@ async function main() {
2159
2295
  await client.connect().catch((e) => die(client.pending ?? e.message));
2160
2296
  if (sub === "add") {
2161
2297
  const r = repoInfo(flag("path") ?? process.cwd()) ?? die("not inside a git repo");
2162
- await client.bindRepo(r.fingerprint, r.origin).catch((e) => die(e.message));
2298
+ const base = flag("base") ?? null;
2299
+ await client.bindRepo(r.fingerprint, r.origin, base).catch((e) => die(e.message));
2163
2300
  if (cfg.spaceId)
2164
2301
  bindRepo(r, cfg.spaceId);
2165
- console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
2302
+ console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${base ? ` — workers cut from ${C(base)}` : ""}${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
2166
2303
  }
2167
2304
  else {
2168
2305
  await new Promise((res) => setTimeout(res, 150));
2169
2306
  console.error(`${B(`repos "${cfg.space}" works from`)} ${D(`(${client.repos.length})`)}`);
2170
2307
  for (const r of client.repos)
2171
- console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}`);
2308
+ console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}${r.base ? ` ${D("base")} ${C(r.base)}` : ""}`);
2172
2309
  if (!client.repos.length)
2173
2310
  console.error(D(" none yet — the first member to connect from a checkout registers it"));
2174
2311
  }
package/dist/client.js CHANGED
@@ -38,6 +38,7 @@ export class PantheonClient {
38
38
  enrolledToken = null;
39
39
  spaceId = null;
40
40
  repos = [];
41
+ plan = null;
41
42
  enroll = null;
42
43
  inviteUrl = null;
43
44
  constructor(cfg, onEvent, opts) {
@@ -157,6 +158,7 @@ export class PantheonClient {
157
158
  this.cfg.spaceId = msg.space_id;
158
159
  }
159
160
  this.repos = msg.repos ?? [];
161
+ this.plan = msg.plan ?? null;
160
162
  this.adoptKnownRepo();
161
163
  this.backoff = 500;
162
164
  this.roster = msg.roster;
@@ -375,29 +377,42 @@ export class PantheonClient {
375
377
  if (c.cwdRepoOrigin)
376
378
  c.repoOrigin = c.cwdRepoOrigin;
377
379
  }
378
- bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null) {
380
+ reportWorkerState(state, detail, until) {
381
+ try {
382
+ this.ws?.send(JSON.stringify({ t: "worker_state", state, ...(detail ? { detail: detail.slice(0, 200) } : {}), ...(until ? { until } : {}) }));
383
+ }
384
+ catch { }
385
+ }
386
+ deleteSpace(confirm) {
387
+ return this.rpc({ t: "delete_space", confirm });
388
+ }
389
+ bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null, base = null) {
379
390
  if (!fingerprint)
380
391
  return Promise.reject(new Error("not inside a git repo — run this from the team's checkout"));
381
- return this.rpc({ t: "bind_repo", repo: fingerprint, origin });
392
+ return this.rpc({ t: "bind_repo", repo: fingerprint, origin, ...(base ? { base } : {}) });
382
393
  }
383
394
  makeInvite(mayWake = true, ttlMinutes = 60) {
384
395
  return new Promise((res, rej) => {
385
- const t = setTimeout(() => rej(new Error("relay did not return an invite in 10s")), 10_000);
396
+ const cid = `c${++this.cid}`;
397
+ const finish = () => { clearInterval(poll); clearTimeout(t); this.acks.delete(cid); };
398
+ const t = setTimeout(() => { finish(); rej(new Error("relay did not return an invite in 10s")); }, 10_000);
386
399
  const poll = setInterval(() => {
387
400
  if (this.inviteUrl) {
388
- clearInterval(poll);
389
- clearTimeout(t);
401
+ finish();
390
402
  const v = this.inviteUrl;
391
403
  this.inviteUrl = null;
392
404
  res(v);
393
405
  }
394
406
  }, 40);
407
+ this.acks.set(cid, (v) => { if ("error" in v) {
408
+ finish();
409
+ rej(new Error(v.error));
410
+ } });
395
411
  try {
396
- this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes }));
412
+ this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes, cid }));
397
413
  }
398
414
  catch (e) {
399
- clearInterval(poll);
400
- clearTimeout(t);
415
+ finish();
401
416
  rej(e);
402
417
  }
403
418
  });