@join-pantheon/cli 0.21.0 → 0.21.2

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
@@ -70,7 +70,7 @@ default when run inside a git repo; `--no-worker` opts out — and a peer still
70
70
  ends with a "You're set" summary, then a short guided setup on a terminal: whether teammates may wake
71
71
  your machine while you are away, what a woken run may execute, who you are and which decisions are
72
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"*.
73
+ `pantheon setup` re-runs it any time — and an agent setting you up can run `pantheon setup --questions`, ask you in chat, and pass your answers back as flags, so nobody has to open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
74
74
 
75
75
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
76
76
  `pantheon install --user` (from your project's repo).
@@ -499,8 +499,12 @@ Security details and how to report a vulnerability:
499
499
  ## Upgrading
500
500
 
501
501
  ```bash
502
- pantheon upgrade # installs the latest release and re-wires your agents
502
+ pantheon upgrade # installs the latest release and finishes the job
503
503
  ```
504
504
 
505
- Every command prints its version and nudges you when a newer release exists. Restart your agent
506
- 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
@@ -123,6 +123,11 @@ export class Accounts {
123
123
  this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
124
124
  if (!cols.has("limits_json"))
125
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");
126
131
  }
127
132
  close() { this.db.close(); }
128
133
  accountByUsername(username) {
@@ -249,18 +254,28 @@ export class Accounts {
249
254
  this.audit({ actor_account: accountId, event: "device_added", target: d.fingerprint });
250
255
  return d;
251
256
  }
252
- openSession(accountId, deviceId, space, ip) {
253
- const s = { id: newId("ses"), account_id: accountId, device_id: deviceId, space, ip, created_at: now(), last_seen: now(), revoked_at: null };
254
- this.db.prepare("INSERT INTO sessions (id, account_id, device_id, space, ip, created_at, last_seen, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL)")
255
- .run(s.id, s.account_id, s.device_id, s.space, s.ip, s.created_at, s.last_seen);
256
- 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 });
257
266
  return s;
258
267
  }
259
268
  touchSession(id) {
260
- 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);
261
276
  }
262
277
  sessionsFor(accountId) {
263
- 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);
264
279
  }
265
280
  devicesFor(accountId) {
266
281
  return this.db.prepare("SELECT * FROM devices WHERE account_id = ? ORDER BY created_at ASC").all(accountId);
@@ -451,6 +466,12 @@ export class Accounts {
451
466
  allDevices() {
452
467
  return this.db.prepare("SELECT * FROM devices ORDER BY created_at DESC").all();
453
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
+ }
454
475
  allSessions(limit = 500) {
455
476
  return this.db.prepare("SELECT * FROM sessions ORDER BY created_at DESC LIMIT ?").all(limit);
456
477
  }
@@ -460,7 +481,7 @@ export class Accounts {
460
481
  accounts: n("SELECT COUNT(*) c FROM accounts"),
461
482
  devices: n("SELECT COUNT(*) c FROM devices"),
462
483
  sessions: n("SELECT COUNT(*) c FROM sessions"),
463
- 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"),
464
485
  audit: n("SELECT COUNT(*) c FROM audit"),
465
486
  };
466
487
  }
@@ -468,6 +489,7 @@ export class Accounts {
468
489
  const sessionDays = opts.sessionDays ?? 30;
469
490
  const auditDays = opts.auditDays ?? 365;
470
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);
471
493
  const s = this.db.prepare("DELETE FROM sessions WHERE last_seen < ?").run(sCut);
472
494
  let a = { changes: 0 };
473
495
  if (auditDays > 0)
package/dist/cli.js CHANGED
@@ -7,6 +7,7 @@ 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
13
  import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
@@ -18,7 +19,7 @@ import { runMcp } from "./mcp.js";
18
19
  import { Worker } from "./worker.js";
19
20
  import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
20
21
  import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
21
- import { runGuide, terminalIO } from "./setup-guide.js";
22
+ import { runGuide, terminalIO, parseSetupFlags, questionnaire } from "./setup-guide.js";
22
23
  import { doctor, render } from "./doctor.js";
23
24
  import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
24
25
  import { assess } from "./untrusted.js";
@@ -271,12 +272,13 @@ function unattendedProject() {
271
272
  async function guidedSetup(cfg, project, opts = {}) {
272
273
  if (argv.includes("--no-guide"))
273
274
  return;
274
- const interactive = !!process.stdin.isTTY && !argv.includes("--yes");
275
+ const preset = opts.preset ?? {};
276
+ const interactive = !!process.stdin.isTTY && !argv.includes("--yes") && !Object.keys(preset).length;
275
277
  if (!interactive && !opts.force)
276
278
  return;
277
279
  const rl = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : null;
278
280
  const io = rl ? terminalIO(rl, (l) => console.error(l)) : null;
279
- console.error(`\n${B("A few choices, then you're done.")}${io ? "" : D(" (no terminal — taking the defaults)")}`);
281
+ console.error(`\n${B("A few choices, then you're done.")}${io ? "" : Object.keys(preset).length ? D(" (applying the answers you passed)") : D(" (no terminal — taking the defaults)")}`);
280
282
  try {
281
283
  const r = await runGuide(io, { cfg, project }, {
282
284
  publish: async (p) => {
@@ -289,7 +291,25 @@ async function guidedSetup(cfg, project, opts = {}) {
289
291
  c.close();
290
292
  }
291
293
  },
292
- });
294
+ ...(project ? {
295
+ setRepoBase: async (base) => {
296
+ const info = repoInfo(project);
297
+ if (!info)
298
+ throw new Error("not inside a git repo");
299
+ const { repo: _r, repoOrigin: _o, ...plain } = cfg;
300
+ const c = new PantheonClient(plain, undefined, { role: "cli" });
301
+ try {
302
+ await c.connect();
303
+ await c.bindRepo(info.fingerprint, info.origin, base);
304
+ }
305
+ finally {
306
+ c.close();
307
+ }
308
+ if (cfg.spaceId)
309
+ bindRepo(info, cfg.spaceId);
310
+ },
311
+ } : {}),
312
+ }, preset);
293
313
  console.error("");
294
314
  for (const l of r.applied)
295
315
  console.error(` ${C("✓")} ${l}`);
@@ -301,6 +321,15 @@ async function guidedSetup(cfg, project, opts = {}) {
301
321
  rl?.close();
302
322
  }
303
323
  }
324
+ function workerMidRun() {
325
+ try {
326
+ const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
327
+ return !!s.at && Date.now() - s.at < 10 * 60_000 && /run/i.test(String(s.state ?? ""));
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ }
304
333
  function printReady(tools) {
305
334
  const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
306
335
  console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
@@ -389,6 +418,7 @@ ${B("control")}
389
418
  pantheon doctor check this machine end to end
390
419
  pantheon setup the guided choices: who may wake this machine, what a run may execute,
391
420
  what you personally decide, which alerts you want — re-run any time
421
+ (--questions prints them for an agent to ask you; answers go back as flags)
392
422
  pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
393
423
 
394
424
  ${D("more: pantheon help --all")}
@@ -396,7 +426,7 @@ ${D("more: pantheon help --all")}
396
426
  const HELP_ALL = HELP + `
397
427
  ${B("less common")}
398
428
  pantheon relay run only the relay, in this terminal
399
- pantheon service install [relay|worker|notifier|menubar] | uninstall
429
+ pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
400
430
  run a long-lived component in the background, from login
401
431
  pantheon members the full list, with key fingerprints
402
432
  pantheon whoami your name and fingerprint
@@ -461,6 +491,8 @@ async function main() {
461
491
  console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
462
492
  console.error(D(` Tell them to run it from their project's git repo. Single use, expires in ${Math.round(Number(flag("ttl", "1440")) / 60)}h.`));
463
493
  console.error(D(` Redeeming it admits them — no separate approval step. Need another? \`pantheon invite\``));
494
+ console.error(D(`\n Not technical? Forward them this instead, and their agent does the rest:`));
495
+ console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${r.invite}`)}`);
464
496
  }
465
497
  if (!r.reach.shareable && r.reach.hint) {
466
498
  console.error(`\n\x1b[33m That address only works on this machine.\x1b[0m`);
@@ -485,6 +517,8 @@ async function main() {
485
517
  const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
486
518
  console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
487
519
  console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
520
+ console.error(D(`For a teammate who is not technical, forward this and their agent does the rest:`));
521
+ console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`)}`);
488
522
  client.close();
489
523
  return;
490
524
  }
@@ -654,20 +688,55 @@ async function main() {
654
688
  return;
655
689
  }
656
690
  console.error(`\n${B(`upgraded ${r.from} → ${r.to}`)}`);
657
- console.error(`\n${B("Restart to load the new code:")}`);
658
- for (const what of r.restart) {
659
- if (what === "relay")
660
- console.error(` relay launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay ${D("(briefly drops your teammates — pick the moment)")}`);
661
- else if (what === "worker")
662
- console.error(` worker launchctl kickstart -k gui/$(id -u)/dev.pantheon.worker`);
691
+ const { restartService, serviceInstalled } = await import("./service.js");
692
+ console.error("");
693
+ if (process.platform === "darwin") {
694
+ const { trayStale, ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
695
+ const { notifierStale, ensureNativeNotifier } = await import("./notifier.js");
696
+ for (const [stale, build, what] of [[trayStale, () => { ensureTrayBinary(); writeTrayCommands(); }, "menu bar"], [notifierStale, ensureNativeNotifier, "alerts"]]) {
697
+ if (!stale())
698
+ continue;
699
+ try {
700
+ build();
701
+ console.error(` ${what}: rebuilt from this release's source`);
702
+ }
703
+ catch (e) {
704
+ console.error(` ${what}: could not rebuild (${e.message.split("\n")[0]})`);
705
+ }
706
+ }
707
+ }
708
+ for (const kind of ["worker", "notifier", "menubar"]) {
709
+ if (!serviceInstalled(kind))
710
+ continue;
711
+ if (kind === "worker" && workerMidRun()) {
712
+ console.error(` ${D("worker: a run is in progress — left alone. When it is done:")} pantheon service restart worker`);
713
+ continue;
714
+ }
715
+ for (const l of restartService(kind))
716
+ console.error(l);
717
+ }
718
+ if (r.restart.includes("relay"))
719
+ console.error(` relay: ${D("shared with your teammates, so it is yours to time:")} launchctl kickstart -k gui/$(id -u)/dev.pantheon.relay`);
720
+ const cfgNow = load();
721
+ if (cfgNow) {
722
+ if (loadProfile(cfgNow).agent.wake_default === undefined)
723
+ await guidedSetup(cfgNow, repoInfo(process.cwd())?.root ?? null);
663
724
  else
664
- console.error(` agent quit and reopen ${what.replace(/^your /, "your ")}`);
725
+ console.error(D(`\n Your setup answers still apply — pantheon setup to change them.`));
665
726
  }
727
+ console.error(`\n${B("One thing only you can do:")} quit and reopen your agent windows so they load the new pantheon.`);
666
728
  return;
667
729
  }
668
730
  case "setup": {
731
+ if (argv.includes("--questions")) {
732
+ console.log(questionnaire());
733
+ return;
734
+ }
735
+ const { preset, errors } = parseSetupFlags(argv);
736
+ if (errors.length)
737
+ die(errors.join("\n") + "\n pantheon setup --questions lists every answer");
669
738
  const cfg = need();
670
- await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true });
739
+ await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true, preset });
671
740
  return;
672
741
  }
673
742
  case "version": {
@@ -2111,6 +2180,12 @@ async function main() {
2111
2180
  console.error(l);
2112
2181
  return;
2113
2182
  }
2183
+ if (sub === "restart") {
2184
+ const { restartService } = await import("./service.js");
2185
+ for (const l of restartService(kind))
2186
+ console.error(l);
2187
+ return;
2188
+ }
2114
2189
  if (kind === "relay") {
2115
2190
  const data = resolveDataDir(flag("data")).dir;
2116
2191
  const port = flag("port", "8787");
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ export const helperHashPath = (binary) => `${binary}.source.sha256`;
4
+ export function sourceHash(source) {
5
+ return createHash("sha256").update(readFileSync(source)).digest("hex");
6
+ }
7
+ export function recordHelperBuild(binary, source) {
8
+ try {
9
+ writeFileSync(helperHashPath(binary), sourceHash(source) + "\n");
10
+ }
11
+ catch { }
12
+ }
13
+ export function helperStale(binary, source) {
14
+ if (!existsSync(binary) || !existsSync(source))
15
+ return false;
16
+ try {
17
+ return readFileSync(helperHashPath(binary), "utf8").trim() !== sourceHash(source);
18
+ }
19
+ catch {
20
+ return true;
21
+ }
22
+ }
23
+ //# sourceMappingURL=helpers-build.js.map
package/dist/notifier.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
+ import { helperStale, recordHelperBuild } from "./helpers-build.js";
2
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { platform } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -56,8 +57,10 @@ export function ensureNativeNotifier(home = CONFIG_DIR) {
56
57
  requireDeveloperTools("notifications", "notifier");
57
58
  execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
58
59
  chmodSync(binary, 0o755);
60
+ recordHelperBuild(binary, source);
59
61
  return binary;
60
62
  }
63
+ export function notifierStale(home = CONFIG_DIR) { return helperStale(hudBinaryPath(home), hudSourcePath()); }
61
64
  export function requireDeveloperTools(what, service) {
62
65
  try {
63
66
  execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
package/dist/relay.js CHANGED
@@ -145,8 +145,12 @@ export class Relay {
145
145
  this.adminAllow = (process.env.PANTHEON_ADMIN_ALLOW_IPS ?? "").split(",").map((x) => x.trim()).filter(Boolean);
146
146
  const wantStore = this.requireAccounts || this.adminToken !== null;
147
147
  this.accounts = opts.accounts ?? (wantStore ? new Accounts(join(this.dataDir, "accounts.db")) : null);
148
- if (this.accounts)
148
+ if (this.accounts) {
149
149
  this.backfillSpaceOwners();
150
+ const closed = this.accounts.endAllOpenSessions();
151
+ if (closed)
152
+ this.log(`ended ${closed} session${closed === 1 ? "" : "s"} left open by the previous relay process`);
153
+ }
150
154
  this.email = opts.emailSender ?? emailSenderFromEnv(this.log);
151
155
  const envNum = (k) => {
152
156
  const v = process.env[k];
@@ -925,7 +929,8 @@ button{width:100%;margin-top:12px;padding:10px;border:0;border-radius:8px;backgr
925
929
  const kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
926
930
  const accounts = A.allAccounts();
927
931
  const devices = A.allDevices();
928
- const sessions = A.allSessions(200);
932
+ const sessions = A.liveSessions(200);
933
+ const endedToday = A.endedSessionsSince(Date.now() - DAY);
929
934
  const audit = A.auditLog({ limit: 300 });
930
935
  const blocks = A.allBlocks();
931
936
  const verifs = A.recentVerifications(50);
@@ -970,7 +975,7 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
970
975
  <div class=stat><b>${c.accounts}</b><span>accounts</span></div>
971
976
  <div class=stat><b>${c.devices}</b><span>devices</span></div>
972
977
  <div class=stat><b>${Object.entries(planCounts).filter(([n]) => n !== "free").reduce((s, [, v]) => s + v, 0)}<span class=dim style="font-size:14px"> / ${c.accounts}</span></b><span>paid plans / accounts</span></div>
973
- <div class=stat><b>${sessions.filter((s) => !s.revoked_at && Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>sessions active / open</span></div>
978
+ <div class=stat><b>${sessions.filter((s) => Date.now() - s.last_seen <= 15 * 60_000).length}<span class=dim style="font-size:14px"> / ${c.live_sessions}</span></b><span>connections heartbeating / open</span></div>
974
979
  <div class=stat><b>${spaces.filter((s) => s.live).length}<span class=dim style="font-size:14px"> / ${spaces.length}</span></b><span>spaces live / total · cap ${this.limits.maxSpaces}</span></div>
975
980
  <div class="stat ${emailsHour > emailCap * 0.8 ? "warn" : ""}"><b>${emailsHour}<span class=dim style="font-size:14px"> / ${emailCap}</span></b><span>emails this hour</span></div>
976
981
  <div class=stat><b>${snapshots.length}</b><span>snapshots · latest ${snapshots.length ? esc(snapshots[snapshots.length - 1].replace(/^accounts-|\\.db$/g, "").replace(/T/, " ").slice(0, 16)) : "—"}</span></div>
@@ -1013,10 +1018,11 @@ ${done ? `<div class=done>${esc(done)}</div>` : ""}
1013
1018
  <p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward "people per space" so nobody mints five at once. Personas are files on the user's machine and cannot be limited here. Every change is audited.</p>
1014
1019
 
1015
1020
  <h2>Live sessions <input placeholder="filter" data-filter="t-sessions"></h2><div class=wrap><table id=t-sessions>
1016
- <tr><th>session</th><th>account</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
1017
- ${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
1018
- ${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=7 class=dim>no live sessions</td></tr>"}
1021
+ <tr><th>session</th><th>account</th><th>device</th><th>role</th><th>space</th><th>ip</th><th>started</th><th>last seen</th><th></th></tr>
1022
+ ${rows(sessions.filter((s) => !s.revoked_at).map((s) => `<tr><td><code>${esc(s.id)}</code></td><td><code>${esc(accounts.find((a) => a.id === s.account_id)?.username ?? s.account_id)}</code></td><td class=dim>${esc(devices.find((d) => d.id === s.device_id)?.label ?? s.device_id.slice(0, 10))}</td><td>${s.role ? `<span class=pill>${esc(s.role)}</span>` : "<span class=dim>—</span>"}</td><td>${esc(s.space) || "<span class=dim>—</span>"}</td><td class=dim>${esc(s.ip)}</td><td class=dim>${when(s.created_at)}</td><td class=dim>${ago(s.last_seen)}${Date.now() - s.last_seen > 15 * 60_000 ? ' <span class="pill" title="no heartbeat for 15+ minutes — usually a socket that died with a restart; pruned after 30 days">stale</span>' : ""}</td><td>${form("revoke_session", s.id, "sign out", "", "danger")} ${form("revoke_device", s.device_id, "remove device", "", "danger")}</td></tr>`))}
1023
+ ${sessions.some((s) => !s.revoked_at) ? "" : "<tr><td colspan=9 class=dim>no open connections right now</td></tr>"}
1019
1024
  </table></div>
1025
+ <p class=note>One row per open connection — an agent window, a worker, or a CLI command while it runs. Rows leave when the socket closes; ${endedToday} connection${endedToday === 1 ? "" : "s"} came and went in the last 24 hours. "Stale" means no heartbeat for 15 minutes on a socket that has not closed, usually a machine that went to sleep.</p>
1020
1026
 
1021
1027
  <h2>Blocked emails and domains</h2><div class=wrap><table id=t-blocks>
1022
1028
  <tr><th>pattern</th><th>kind</th><th>since</th><th></th></tr>
@@ -1250,12 +1256,20 @@ pantheon signup --email you@work.com --username you</code></pre>
1250
1256
  let space = null;
1251
1257
  let me = null;
1252
1258
  let acct = null;
1259
+ let dbSession = null;
1260
+ let lastTouch = 0;
1261
+ const touch = () => {
1262
+ if (dbSession && Date.now() - lastTouch > 60_000) {
1263
+ lastTouch = Date.now();
1264
+ this.accounts?.touchSession(dbSession);
1265
+ }
1266
+ };
1253
1267
  let sessionId = "";
1254
1268
  let role = "interactive";
1255
1269
  let lastMsg = "";
1256
1270
  let alive = true;
1257
1271
  const nonce = newNonce();
1258
- ws.on("pong", () => { alive = true; });
1272
+ ws.on("pong", () => { alive = true; touch(); });
1259
1273
  const probe = ws;
1260
1274
  probe._alive = () => alive;
1261
1275
  probe._clear = () => { alive = false; };
@@ -1303,6 +1317,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1303
1317
  }
1304
1318
  const m = parsed.data;
1305
1319
  lastMsg = m.t === "act" ? m.act : m.t;
1320
+ touch();
1306
1321
  if (m.t === "ping")
1307
1322
  return this.send(ws, { t: "pong" });
1308
1323
  if (m.t === "hello") {
@@ -1370,7 +1385,9 @@ pantheon signup --email you@work.com --username you</code></pre>
1370
1385
  }
1371
1386
  m.member = auth.account.username;
1372
1387
  acct = auth.account;
1373
- acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip).id;
1388
+ acctSession = this.accounts.openSession(auth.account.id, auth.device.id, sp.name, ip, m.role ?? "interactive").id;
1389
+ dbSession = acctSession;
1390
+ lastTouch = Date.now();
1374
1391
  }
1375
1392
  let enrolled = null;
1376
1393
  if (m.enroll) {
@@ -1702,6 +1719,10 @@ pantheon signup --email you@work.com --username you</code></pre>
1702
1719
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
1703
1720
  });
1704
1721
  ws.on("close", () => {
1722
+ if (dbSession) {
1723
+ this.accounts?.endSession(dbSession);
1724
+ dbSession = null;
1725
+ }
1705
1726
  if (!space || !me)
1706
1727
  return;
1707
1728
  me.sessions.delete(sessionId);
@@ -1753,7 +1774,7 @@ pantheon signup --email you@work.com --username you</code></pre>
1753
1774
  message: `over ${LIMITS.rateActs} acts in ${LIMITS.rateWindowMs / 1000}s. This is a backstop against a stuck loop, not a hard stop — wait a few seconds and send again. If you are genuinely producing this much, you are probably doing work that belongs in a commit and a DELIVERED, not in acts.` };
1754
1775
  }
1755
1776
  for (const id of to ?? []) {
1756
- if (!sp.members.has(id)) {
1777
+ if (!sp.members.has(id) && !sp.allow.get(id)?.admitted) {
1757
1778
  return { code: "NO_SUCH_MEMBER", message: `nobody here is called "${id}" — members: ${[...sp.members.keys()].join(", ")}` };
1758
1779
  }
1759
1780
  }
@@ -2190,6 +2211,11 @@ pantheon signup --email you@work.com --username you</code></pre>
2190
2211
  lastTouch: Date.now(),
2191
2212
  };
2192
2213
  this.spaces.set(name, sp);
2214
+ for (const e of allow.all()) {
2215
+ if (e.admitted && !sp.members.has(e.member)) {
2216
+ sp.members.set(e.member, { id: e.member, display: e.member, agent: "unknown", last_seen: e.first_seen, sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] });
2217
+ }
2218
+ }
2193
2219
  const open = [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length;
2194
2220
  this.log(`[${name}] space ready (seq ${sp.ledger.head}, ${sp.goals.size} goals — ${open} still open, ${sp.obligations.size} obligations restored)`);
2195
2221
  this.compactSpace(sp);
package/dist/service.js CHANGED
@@ -138,6 +138,20 @@ export function uninstallService(kind) {
138
138
  }
139
139
  return out;
140
140
  }
141
+ export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
142
+ if (!serviceInstalled(kind))
143
+ return [` ${kind}: not installed here`];
144
+ try {
145
+ if (platform() === "darwin")
146
+ exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 501}/${LABEL(kind)}`]);
147
+ else
148
+ exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
149
+ return [` ${kind}: restarted on the new code`];
150
+ }
151
+ catch (e) {
152
+ return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
153
+ }
154
+ }
141
155
  export function serviceInstalled(kind) {
142
156
  try {
143
157
  return existsSync(platform() === "darwin" ? plistPath(kind) : unitPath(kind));
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { applyProfileInit, loadProfile, profilePath, saveProfile } from "./profile.js";
@@ -39,14 +40,110 @@ export function guideDefaults(profile, cfg) {
39
40
  persona: false,
40
41
  };
41
42
  }
42
- export async function runGuide(io, ctx, deps = {}) {
43
+ export function detectBaseBranch(project) {
44
+ const git = (...a) => { try {
45
+ return execFileSync("git", a, { cwd: project, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
46
+ }
47
+ catch {
48
+ return "";
49
+ } };
50
+ if (!git("remote", "get-url", "origin"))
51
+ return null;
52
+ const head = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD");
53
+ if (head)
54
+ return head.replace(/^origin\//, "");
55
+ const cur = git("rev-parse", "--abbrev-ref", "HEAD");
56
+ return cur && cur !== "HEAD" ? cur : null;
57
+ }
58
+ export function questionnaire() {
59
+ const opt = (flag, id, label) => ` ${label} → --${flag} ${id}`;
60
+ return [
61
+ "pantheon setup — the questions, and the flag for each answer.",
62
+ "Ask your human each one in plain words, then run: pantheon setup --yes <flags>",
63
+ "",
64
+ "1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
65
+ " in a separate copy of the repo, and delivers on a branch for review)",
66
+ ...WAKE_OPTIONS.map((o) => opt("wake", o.id, o.label)),
67
+ "2. When a teammate's request wakes this machine, what may the run do? (skip if --wake no)",
68
+ opt("commands", "no", "Edit files only — it can answer and draft, but cannot run tests, commit, or push."),
69
+ opt("commands", "yes", "Run commands too — needed to run tests and to commit and push work for review."),
70
+ "3. Their name, as teammates will see it. → --name \"…\"",
71
+ `4. Their role, in a word (${TITLE_OPTIONS.slice(0, -1).join(", ")}, or their own). → --title \"…\"`,
72
+ "5. Which of these do they decide personally? Agents will stop and ask them first. Any number, or none.",
73
+ ...DECISION_AREAS.map((x) => ` ${x.label} → ${x.id}`),
74
+ " pass them comma-separated: --decides deployment,pricing (or --decides none)",
75
+ "6. Which desktop alerts do they want?",
76
+ ...ALERT_OPTIONS.map((o) => opt("alerts", o.id, o.label)),
77
+ "7. Should their worker follow a short written playbook for this repo? (skip if --wake no)",
78
+ opt("playbook", "yes", "Yes — write a starter playbook now (recommended)"),
79
+ opt("playbook", "no", "Not now"),
80
+ "8. Which branch should workers start from, and deliveries be checked against? Usually the branch this",
81
+ " project deploys or releases from (often the default branch). → --base <branch>",
82
+ "",
83
+ "Example: pantheon setup --yes --wake anyone --commands yes --name \"Praneet Sinha\" --title Founder \\",
84
+ " --decides deployment --alerts decisions --playbook yes --base main",
85
+ ].join("\n");
86
+ }
87
+ export function parseSetupFlags(argv) {
88
+ const preset = {};
89
+ const errors = [];
90
+ const val = (name) => { const i = argv.indexOf(`--${name}`); return i >= 0 ? argv[i + 1] : undefined; };
91
+ const yn = (name) => { const v = val(name); if (v === undefined)
92
+ return undefined; if (/^(yes|true|on|1)$/i.test(v))
93
+ return true; if (/^(no|false|off|0)$/i.test(v))
94
+ return false; errors.push(`--${name} takes yes or no, not "${v}"`); return undefined; };
95
+ const wake = val("wake");
96
+ if (wake !== undefined) {
97
+ if (WAKE_OPTIONS.some((o) => o.id === wake))
98
+ preset.wake = wake;
99
+ else
100
+ errors.push(`--wake takes ${WAKE_OPTIONS.map((o) => o.id).join(", ")}, not "${wake}"`);
101
+ }
102
+ const commands = yn("commands");
103
+ if (commands !== undefined)
104
+ preset.commands = commands;
105
+ const name = val("name");
106
+ if (name !== undefined)
107
+ preset.name = name.trim();
108
+ const title = val("title");
109
+ if (title !== undefined)
110
+ preset.title = title.trim();
111
+ const decides = val("decides");
112
+ if (decides !== undefined) {
113
+ const ids = decides.split(",").map((x) => x.trim().toLowerCase()).filter((x) => x && x !== "none");
114
+ const bad = ids.filter((id) => !DECISION_AREAS.some((x) => x.id === id));
115
+ if (bad.length)
116
+ errors.push(`--decides: unknown area${bad.length === 1 ? "" : "s"} ${bad.join(", ")} — use ${DECISION_AREAS.map((x) => x.id).join(", ")} or none`);
117
+ else
118
+ preset.decides = [...new Set(ids)];
119
+ }
120
+ const alerts = val("alerts");
121
+ if (alerts !== undefined) {
122
+ if (ALERT_OPTIONS.some((o) => o.id === alerts))
123
+ preset.notifications = alerts;
124
+ else
125
+ errors.push(`--alerts takes ${ALERT_OPTIONS.map((o) => o.id).join(", ")}, not "${alerts}"`);
126
+ }
127
+ const playbook = yn("playbook");
128
+ if (playbook !== undefined)
129
+ preset.persona = playbook;
130
+ const base = val("base");
131
+ if (base !== undefined && base.trim())
132
+ preset.base = base.trim();
133
+ return { preset, errors };
134
+ }
135
+ export async function runGuide(io, ctx, deps = {}, preset = {}) {
43
136
  const install = deps.installService ?? realInstall;
44
137
  const uninstall = deps.uninstallService ?? realUninstall;
45
138
  const installed = deps.serviceInstalled ?? realInstalled;
46
139
  const file = deps.profileFile ?? profilePath();
47
140
  const profile = loadProfile(ctx.cfg, file);
48
- const d = guideDefaults(profile, ctx.cfg);
141
+ const d = { ...guideDefaults(profile, ctx.cfg), ...preset };
49
142
  const a = { ...d };
143
+ if (a.wake === "no") {
144
+ a.commands = false;
145
+ a.persona = false;
146
+ }
50
147
  const applied = [], warnings = [];
51
148
  if (io) {
52
149
  io.say("A few choices only you can make. Pick a number; Enter keeps the suggested one.\n");
@@ -72,6 +169,12 @@ export async function runGuide(io, ctx, deps = {}) {
72
169
  const p = await io.choose("Should your worker follow a short written playbook for this repo? It says what a woken run may do here and what it must leave to you. You can edit it any time.", ["Yes — write a starter playbook now (recommended)", "Not now"], 0);
73
170
  a.persona = p === 0;
74
171
  }
172
+ const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
173
+ if (detected) {
174
+ const suggested = preset.base ?? detected;
175
+ const b = await io.choose("Which branch should your worker start from, and deliveries be checked against? Usually the branch this project deploys or releases from.", [`${suggested}${suggested === detected ? " (what origin calls its default)" : ""}`, "A different branch"], 0);
176
+ a.base = b === 0 ? suggested : ((await io.ask("Branch name", suggested)).trim() || suggested);
177
+ }
75
178
  }
76
179
  let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
77
180
  const authority = { ...next.authority };
@@ -169,6 +272,15 @@ export async function runGuide(io, ctx, deps = {}) {
169
272
  applied.push(`playbook written: ${personaFile} — commit it so every machine on this repo follows it`);
170
273
  }
171
274
  }
275
+ if (a.base && deps.setRepoBase) {
276
+ try {
277
+ await deps.setRepoBase(a.base);
278
+ applied.push(`workers on this repo cut from ${a.base}, and deliveries are checked against it`);
279
+ }
280
+ catch (e) {
281
+ warnings.push(`could not register the base branch with the team (${e.message}) — later: pantheon repo add --base ${a.base}`);
282
+ }
283
+ }
172
284
  return { answers: a, applied, warnings, ...(personaFile ? { personaFile } : {}) };
173
285
  }
174
286
  export function starterPersona(a) {
package/dist/tray.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { helperStale, recordHelperBuild } from "./helpers-build.js";
2
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { platform } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -26,8 +27,10 @@ export function ensureTrayBinary(home = CONFIG_DIR) {
26
27
  requireDeveloperTools("the menu bar", "menubar");
27
28
  execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
28
29
  chmodSync(binary, 0o755);
30
+ recordHelperBuild(binary, source);
29
31
  return binary;
30
32
  }
33
+ export function trayStale(home = CONFIG_DIR) { return helperStale(trayBinaryPath(home), traySourcePath()); }
31
34
  export function writeTrayCommands(home = CONFIG_DIR) {
32
35
  const bin = whichPantheon() ?? "pantheon";
33
36
  const dir = join(home, "bin");
package/dist/upgrade.js CHANGED
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { installForUser } from "./install.js";
6
- import { relayProc, workerProc } from "./machine.js";
6
+ import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
7
7
  export function repoRoot() {
8
8
  return join(dirname(fileURLToPath(import.meta.url)), "..");
9
9
  }
@@ -42,6 +42,16 @@ export function defaultDeps() {
42
42
  up.push("worker");
43
43
  }
44
44
  catch { }
45
+ try {
46
+ if (notifierProc().running)
47
+ up.push("notifier");
48
+ }
49
+ catch { }
50
+ try {
51
+ if (menubarProc().running)
52
+ up.push("menubar");
53
+ }
54
+ catch { }
45
55
  return up;
46
56
  },
47
57
  npmUpgrade: () => execFileSync("npm", ["install", "-g", `${PKG_NAME}@latest`, "--no-audit", "--no-fund"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
@@ -118,21 +118,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
118
118
  }
119
119
  }
120
120
 
121
- /// The glyph is the product's one-word status. Filled bolt: peers can wake
122
- /// work here. Hollow bolt: connected but nothing unattended will run.
123
- /// Slashed: not configured, or the relay is unreachable. A count rides
124
- /// alongside when decisions wait on the human — the badge is the point.
125
- private func paint() {
126
- let symbol = !state.configured || !state.relayOk ? "bolt.slash.circle"
127
- : state.workerRunning ? "bolt.circle.fill" : "bolt.circle"
128
- if let img = NSImage(systemSymbolName: symbol, accessibilityDescription: "pantheon") {
129
- img.isTemplate = true
130
- item.button?.image = img
131
- item.button?.title = state.decisions.isEmpty ? "" : " \(state.decisions.count)"
132
- item.button?.imagePosition = .imageLeading
133
- } else {
134
- item.button?.title = state.decisions.isEmpty ? "cw" : "cw \(state.decisions.count)"
121
+ enum MarkMode { case full, idle, off }
122
+
123
+ /// The Three Stones mark — the product's own logo, the same three arcs the
124
+ /// website draws: a lintel across the top and a gate at the bottom around a
125
+ /// centre nobody owns. Drawn with paths rather than loaded from an asset, so it
126
+ /// is a crisp template image at any menu bar scale and takes the system tint.
127
+ /// State rides on its weight: full when peers can wake work here, lighter when
128
+ /// connected but nothing unattended will run, faint with a slash when the relay
129
+ /// is unreachable or this machine is not in a space. Never rotate it.
130
+ private func markImage(_ mode: MarkMode) -> NSImage {
131
+ let img = NSImage(size: NSSize(width: 18, height: 18), flipped: false) { rect in
132
+ let s = rect.width / 24.0 // the mark is authored on a 24-unit grid
133
+ let c = NSPoint(x: rect.midX, y: rect.midY)
134
+ let r = 8.9 * s
135
+ let stones = NSBezierPath()
136
+ stones.lineWidth = 3.4 * s
137
+ stones.lineCapStyle = .round
138
+ for (a, b) in [(51.0, 129.0), (171.0, 249.0), (291.0, 369.0)] { // lintel, left stone, right stone
139
+ let rad = a * .pi / 180
140
+ stones.move(to: NSPoint(x: c.x + r * cos(rad), y: c.y + r * sin(rad)))
141
+ stones.appendArc(withCenter: c, radius: r, startAngle: a, endAngle: b, clockwise: false)
142
+ }
143
+ let alpha: CGFloat = mode == .full ? 1.0 : mode == .idle ? 0.58 : 0.32
144
+ NSColor.black.withAlphaComponent(alpha).setStroke()
145
+ stones.stroke()
146
+ if mode == .off {
147
+ let slash = NSBezierPath()
148
+ slash.lineWidth = 2.2 * s
149
+ slash.lineCapStyle = .round
150
+ slash.move(to: NSPoint(x: rect.minX + 4 * s, y: rect.minY + 4 * s))
151
+ slash.line(to: NSPoint(x: rect.maxX - 4 * s, y: rect.maxY - 4 * s))
152
+ NSColor.black.setStroke()
153
+ slash.stroke()
154
+ }
155
+ return true
135
156
  }
157
+ img.isTemplate = true
158
+ img.accessibilityDescription = "pantheon"
159
+ return img
160
+ }
161
+
162
+ /// The glyph is the product's one-word status; a count rides alongside when
163
+ /// decisions wait on the human — the badge is the point.
164
+ private func paint() {
165
+ let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
166
+ item.button?.image = markImage(mode)
167
+ item.button?.title = state.decisions.isEmpty ? "" : " \(state.decisions.count)"
168
+ item.button?.imagePosition = .imageLeading
136
169
  item.button?.toolTip = state.configured
137
170
  ? "pantheon — \(state.member) in \(state.space)\(state.relayOk ? "" : " (relay unreachable)")"
138
171
  : "pantheon — not joined to a space yet"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
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": {
@@ -15,7 +15,7 @@
15
15
  "relay": "node dist/cli.js relay",
16
16
  "test": "PANTHEON_HOME=\"$(mktemp -d)\" node --test --test-force-exit dist/test/*.test.js",
17
17
  "prepare": "npm run build",
18
- "prepublishOnly": "npm run build && node --test dist/test/*.test.js",
18
+ "prepublishOnly": "npm run build && npm test",
19
19
  "start": "node dist/cli.js relay"
20
20
  },
21
21
  "dependencies": {