@join-pantheon/cli 0.21.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
@@ -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";
@@ -289,6 +290,24 @@ async function guidedSetup(cfg, project, opts = {}) {
289
290
  c.close();
290
291
  }
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
+ } : {}),
292
311
  });
293
312
  console.error("");
294
313
  for (const l of r.applied)
@@ -301,6 +320,15 @@ async function guidedSetup(cfg, project, opts = {}) {
301
320
  rl?.close();
302
321
  }
303
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
+ }
304
332
  function printReady(tools) {
305
333
  const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
306
334
  console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
@@ -396,7 +424,7 @@ ${D("more: pantheon help --all")}
396
424
  const HELP_ALL = HELP + `
397
425
  ${B("less common")}
398
426
  pantheon relay run only the relay, in this terminal
399
- pantheon service install [relay|worker|notifier|menubar] | uninstall
427
+ pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
400
428
  run a long-lived component in the background, from login
401
429
  pantheon members the full list, with key fingerprints
402
430
  pantheon whoami your name and fingerprint
@@ -654,15 +682,43 @@ async function main() {
654
682
  return;
655
683
  }
656
684
  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`);
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);
663
718
  else
664
- 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.`));
665
720
  }
721
+ console.error(`\n${B("One thing only you can do:")} quit and reopen your agent windows so they load the new pantheon.`);
666
722
  return;
667
723
  }
668
724
  case "setup": {
@@ -2111,6 +2167,12 @@ async function main() {
2111
2167
  console.error(l);
2112
2168
  return;
2113
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
+ }
2114
2176
  if (kind === "relay") {
2115
2177
  const data = resolveDataDir(flag("data")).dir;
2116
2178
  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);
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,6 +40,21 @@ export function guideDefaults(profile, cfg) {
39
40
  persona: false,
40
41
  };
41
42
  }
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
+ }
42
58
  export async function runGuide(io, ctx, deps = {}) {
43
59
  const install = deps.installService ?? realInstall;
44
60
  const uninstall = deps.uninstallService ?? realUninstall;
@@ -72,6 +88,11 @@ export async function runGuide(io, ctx, deps = {}) {
72
88
  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
89
  a.persona = p === 0;
74
90
  }
91
+ const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
92
+ if (detected) {
93
+ 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.", [`${detected} (what origin calls its default)`, "A different branch"], 0);
94
+ a.base = b === 0 ? detected : ((await io.ask("Branch name", detected)).trim() || detected);
95
+ }
75
96
  }
76
97
  let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
77
98
  const authority = { ...next.authority };
@@ -169,6 +190,15 @@ export async function runGuide(io, ctx, deps = {}) {
169
190
  applied.push(`playbook written: ${personaFile} — commit it so every machine on this repo follows it`);
170
191
  }
171
192
  }
193
+ if (a.base && deps.setRepoBase) {
194
+ try {
195
+ await deps.setRepoBase(a.base);
196
+ applied.push(`workers on this repo cut from ${a.base}, and deliveries are checked against it`);
197
+ }
198
+ catch (e) {
199
+ warnings.push(`could not register the base branch with the team (${e.message}) — later: pantheon repo add --base ${a.base}`);
200
+ }
201
+ }
172
202
  return { answers: a, applied, warnings, ...(personaFile ? { personaFile } : {}) };
173
203
  }
174
204
  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.1",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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": {