@join-pantheon/cli 0.33.1 → 0.33.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.
Files changed (44) hide show
  1. package/README.md +6 -5
  2. package/dist/accounts.js +10 -0
  3. package/dist/agents.js +11 -0
  4. package/dist/board.js +55 -9
  5. package/dist/claude-confine.js +1 -1
  6. package/dist/cli.js +556 -89
  7. package/dist/client.js +8 -4
  8. package/dist/config.js +18 -4
  9. package/dist/continued.js +5 -0
  10. package/dist/desktop-app.js +103 -0
  11. package/dist/doctor.js +84 -32
  12. package/dist/folder-guard.js +12 -0
  13. package/dist/folder-snapshot.js +432 -0
  14. package/dist/install.js +15 -8
  15. package/dist/machine.js +84 -4
  16. package/dist/managed.js +2 -2
  17. package/dist/mcp.js +5 -3
  18. package/dist/network.js +156 -0
  19. package/dist/notice-linux.js +6 -5
  20. package/dist/notice-windows.js +2 -2
  21. package/dist/notice.js +13 -0
  22. package/dist/notifier.js +29 -2
  23. package/dist/notify.js +11 -0
  24. package/dist/onboard-defaults.js +37 -1
  25. package/dist/pins.js +7 -1
  26. package/dist/plans.js +23 -0
  27. package/dist/protocol.js +5 -0
  28. package/dist/quickstart.js +17 -11
  29. package/dist/refusal.js +4 -4
  30. package/dist/relay.js +332 -22
  31. package/dist/repo-find.js +6 -3
  32. package/dist/roster-file.js +50 -5
  33. package/dist/tray.js +12 -1
  34. package/dist/uninstall.js +38 -3
  35. package/dist/uri-handler.js +3 -2
  36. package/dist/verify-act.js +17 -0
  37. package/dist/worker-fleet.js +140 -5
  38. package/dist/worker.js +201 -63
  39. package/installer/install.ps1 +251 -15
  40. package/installer/install.sh +147 -18
  41. package/package.json +1 -1
  42. package/helper/macos/prebuilt/manifest.json +0 -29
  43. package/helper/macos/prebuilt/pantheon-hud +0 -0
  44. package/helper/macos/prebuilt/pantheon-tray +0 -0
package/dist/relay.js CHANGED
@@ -18,6 +18,7 @@ import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, s
18
18
  import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
19
19
  import { FEEDBACK_ADDRESS } from "./feedback.js";
20
20
  import { routeDecision } from "./profile.js";
21
+ const presentSessions = (m) => [...m.sessions.values()].filter((s) => !s.quiet);
21
22
  function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
22
23
  let known = false;
23
24
  let busy = false;
@@ -272,6 +273,8 @@ function versionBelow(a, b) {
272
273
  }
273
274
  return false;
274
275
  }
276
+ export const RECOVERY_PROOF_MS = 15 * 60_000;
277
+ const RECOVERY_KEEP_MS = 7 * 86_400_000;
275
278
  const LOCKED_MSG = "too many wrong codes from this device or your network in the last hour — wait an hour, then request a new code";
276
279
  const STORAGE_UNHEALTHY_MS = 5 * 60_000;
277
280
  function isStorageError(e) {
@@ -465,6 +468,7 @@ export class Relay {
465
468
  deletedSpaces = new Map();
466
469
  authEpoch = 0;
467
470
  rotateOnRemove;
471
+ recoveryWaitMs;
468
472
  backupTimer;
469
473
  installerGate;
470
474
  upgradeHold;
@@ -510,6 +514,10 @@ export class Relay {
510
514
  spaceMinAgeMs: opts.spaceLimits?.spaceMinAgeMs ?? LIMITS.spaceMinAgeMs,
511
515
  };
512
516
  this.rotateOnRemove = opts.rotateOnRemove ?? process.env.PANTHEON_ROTATE_ON_REMOVE === "1";
517
+ {
518
+ const h = Number(process.env.PANTHEON_RECOVERY_WAIT_HOURS ?? "24");
519
+ this.recoveryWaitMs = opts.recoveryWaitMs ?? (Number.isFinite(h) && h >= 0 ? h : 24) * 3_600_000;
520
+ }
513
521
  this.upgradeHold = upgradeHoldFrom(opts.upgradeHoldAt ?? process.env.PANTHEON_HOLD_UPGRADES_AT, VERSION, this.log);
514
522
  this.installerGate = opts.installerGate ?? new InstallerGate({ log: this.log, ...(this.upgradeHold ? { version: this.upgradeHold } : {}) });
515
523
  this.busyTtlMs = opts.activityBusyTtlMs ?? LIMITS.activityBusyTtlMs;
@@ -624,8 +632,8 @@ export class Relay {
624
632
  const detail = (s) => ({
625
633
  name: s.name,
626
634
  members: s.members.size,
627
- online: [...s.members.values()].filter((m) => m.sessions.size > 0).length,
628
- sessions: [...s.members.values()].reduce((n, m) => n + m.sessions.size, 0),
635
+ online: [...s.members.values()].filter((m) => presentSessions(m).length > 0).length,
636
+ sessions: [...s.members.values()].reduce((n, m) => n + presentSessions(m).length, 0),
629
637
  goals: s.goals.size,
630
638
  seq: s.ledger.head,
631
639
  });
@@ -982,6 +990,23 @@ export class Relay {
982
990
  this.noteAccountRemoval(who.account.username);
983
991
  return this.json(res, 200, { ok: true });
984
992
  }
993
+ if (url.pathname === "/account/recover" || url.pathname === "/account/recover/withdraw") {
994
+ if (!this.authRateOk(ip))
995
+ return this.json(res, 429, { error: "too many attempts from your network — wait a minute and try again" });
996
+ const method = url.pathname === "/account/recover" && req.method === "GET" ? "GET" : "POST";
997
+ if (req.method !== method)
998
+ return this.json(res, 405, { error: "method not allowed" });
999
+ const who = this.verifySignedAccount(url, method, url.pathname, ip);
1000
+ if (!who)
1001
+ return this.json(res, 403, { error: "this request must be signed by a device on the account" });
1002
+ if (method === "GET")
1003
+ return this.json(res, 200, { seats: this.recoverableSeats(who.account, who.device) });
1004
+ const name = url.searchParams.get("space") ?? "";
1005
+ const r = url.pathname === "/account/recover/withdraw"
1006
+ ? this.withdrawRecovery(who.account, who.device, name, ip)
1007
+ : this.recoverSeat(who.account, who.device, name, ip);
1008
+ return this.json(res, r.status, r.body);
1009
+ }
985
1010
  if (url.pathname === "/audit" && req.method === "GET") {
986
1011
  const who = this.verifySignedAccount(url, "GET", "/audit", ip);
987
1012
  if (!who)
@@ -1332,7 +1357,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1332
1357
  const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
1333
1358
  for (const sp of this.spaces.values()) {
1334
1359
  seen.add(safeOf(sp.name));
1335
- const sessions = [...sp.members.values()].flatMap((m) => [...m.sessions.values()]);
1360
+ const sessions = [...sp.members.values()].flatMap((m) => presentSessions(m));
1336
1361
  out.push(spaceHealth({
1337
1362
  name: sp.name, records: sp.ledger.all(), members: sp.allow.all(),
1338
1363
  online: sessions.length, interactive: sessions.filter((x) => x.role === "interactive").length,
@@ -1373,7 +1398,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1373
1398
  const name = sp.name;
1374
1399
  out.set(safeOf(name), {
1375
1400
  name, live: true, id: sp.allow.peekId(),
1376
- members: sp.allow.all().filter((e) => e.admitted).length, online: [...sp.members.values()].filter((m) => m.sessions.size > 0).length,
1401
+ members: sp.allow.all().filter((e) => e.admitted).length, online: [...sp.members.values()].filter((m) => presentSessions(m).length > 0).length,
1377
1402
  acts: sp.ledger.head, goals: sp.goals.size, open_goals: [...sp.goals.values()].filter((g) => g.state !== "CLOSED").length, obligations: sp.obligations.size,
1378
1403
  last: sp.lastTouch, bytes: size(safeOf(name)),
1379
1404
  activity: activity(sp.ledger.all(), now, (r) => `${name}/${r.from}`), refusals: sp.store.refusals,
@@ -1703,6 +1728,239 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1703
1728
  }
1704
1729
  this.log(`[${sp.name}] * space token rotated (${why}); sent to ${told} live session${told === 1 ? "" : "s"}`);
1705
1730
  }
1731
+ wakeForRelay(name) {
1732
+ const live = this.live(name);
1733
+ if (live)
1734
+ return live;
1735
+ if (!name || this.aliasOf(name) || !this.spaceOnDisk(name))
1736
+ return null;
1737
+ try {
1738
+ const allow = new Allowlist(this.dataDir, safeName(name));
1739
+ let token = allow.currentTokenFromInvites();
1740
+ if (!token) {
1741
+ token = randomBytes(16).toString("hex");
1742
+ allow.rotateToken(token);
1743
+ }
1744
+ return this.getOrCreateSpace(name, token);
1745
+ }
1746
+ catch (e) {
1747
+ this.noteStorageFailure(`[${name}] opening it for a recovery`, e);
1748
+ return null;
1749
+ }
1750
+ }
1751
+ rosterOf(name) {
1752
+ const live = this.live(name);
1753
+ if (live)
1754
+ return live.allow;
1755
+ if (!name || this.aliasOf(name) || !this.spaceOnDisk(name))
1756
+ return null;
1757
+ try {
1758
+ return new Allowlist(this.dataDir, safeName(name));
1759
+ }
1760
+ catch {
1761
+ return null;
1762
+ }
1763
+ }
1764
+ allRosters() {
1765
+ const out = [];
1766
+ const seen = new Set();
1767
+ for (const sp of this.spaces.values()) {
1768
+ seen.add(safeName(sp.name));
1769
+ out.push({ name: sp.name, allow: sp.allow, live: sp });
1770
+ }
1771
+ let files = [];
1772
+ try {
1773
+ files = readdirSync(this.dataDir).filter((f) => f.endsWith(".members.json"));
1774
+ }
1775
+ catch { }
1776
+ for (const f of files) {
1777
+ const stem = f.slice(0, -".members.json".length);
1778
+ if (seen.has(stem))
1779
+ continue;
1780
+ try {
1781
+ const m = statSync(join(this.dataDir, f)).mtimeMs;
1782
+ let c = this.rosterCache.get(stem);
1783
+ if (!c || c.mtimeMs !== m) {
1784
+ c = { mtimeMs: m, allow: new Allowlist(this.dataDir, stem) };
1785
+ this.rosterCache.set(stem, c);
1786
+ }
1787
+ out.push({ name: c.allow.recordedName() ?? stem, allow: c.allow, live: undefined });
1788
+ }
1789
+ catch { }
1790
+ }
1791
+ return out;
1792
+ }
1793
+ rosterCache = new Map();
1794
+ recoverableSeats(acct, device) {
1795
+ const A = this.accounts;
1796
+ const u = acct.username;
1797
+ const out = [];
1798
+ for (const { name, allow } of this.allRosters()) {
1799
+ const e = allow.get(u);
1800
+ if (!e?.admitted)
1801
+ continue;
1802
+ const founder = allow.founder() === u;
1803
+ const alone = !allow.all().some((x) => x.admitted && x.member !== u);
1804
+ const fp = fingerprint(e.publicKey);
1805
+ if (e.publicKey === device.pubkey) {
1806
+ const inv = this.openRecoveryInvite(allow, u);
1807
+ out.push({ space: name, state: inv ? "ready" : "here", founder, alone, fp, ...(inv ? { invite: inv.token } : {}) });
1808
+ continue;
1809
+ }
1810
+ const pend = allow.recoveryOf(u);
1811
+ if (pend && pend.pubkey === device.pubkey) {
1812
+ out.push({ space: name, state: "waiting", founder, alone, fp, due_at: pend.due_at });
1813
+ continue;
1814
+ }
1815
+ out.push({ space: name, state: A.wasDeviceOf(acct.id, fp, e.moved?.at ?? e.first_seen) ? "movable" : "unproven", founder, alone, fp });
1816
+ }
1817
+ return out.sort((a, b) => a.space.localeCompare(b.space));
1818
+ }
1819
+ openRecoveryInvite(allow, member) {
1820
+ const all = allow.invites;
1821
+ for (const i of all.values())
1822
+ if (i.recovery_for === member && !i.used_by && Date.now() < i.expires_at)
1823
+ return i;
1824
+ return null;
1825
+ }
1826
+ recoverSeat(acct, device, name, ip) {
1827
+ const A = this.accounts;
1828
+ const u = acct.username;
1829
+ if (!name)
1830
+ return { status: 400, body: { error: "which team? pantheon recover <team>" } };
1831
+ const noSeat = { status: 404, body: { reason: "no_seat", error: `you (${u}) have no seat in a team called "${name}" on this relay — pantheon recover lists the ones you have` } };
1832
+ const roster = this.rosterOf(name);
1833
+ const seat = roster?.get(u);
1834
+ if (!roster || !seat?.admitted)
1835
+ return noSeat;
1836
+ const known = seat.publicKey === device.pubkey || roster.recoveryOf(u)?.pubkey === device.pubkey;
1837
+ if (!known) {
1838
+ const refused = this.recoveryProofRefusal(A, acct, device, name, seat, roster.founder() === u);
1839
+ if (refused)
1840
+ return refused;
1841
+ }
1842
+ const sp = this.wakeForRelay(name);
1843
+ const e = sp?.allow.get(u);
1844
+ if (!sp || !e?.admitted)
1845
+ return noSeat;
1846
+ if (e.publicKey === device.pubkey) {
1847
+ const inv = this.openRecoveryInvite(sp.allow, u) ?? this.mintRecoveryInvite(sp, u);
1848
+ return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: e.moved?.how ?? null } };
1849
+ }
1850
+ const pend = sp.allow.recoveryOf(u);
1851
+ const now = Date.now();
1852
+ if (pend && pend.pubkey === device.pubkey) {
1853
+ if (now >= pend.due_at) {
1854
+ if (now - pend.due_at > RECOVERY_KEEP_MS) {
1855
+ sp.allow.cancelRecovery(u);
1856
+ return { status: 410, body: { reason: "lapsed", error: `that request lapsed (nobody finished it within a week of its time) — ask again: pantheon recover ${sp.name}` } };
1857
+ }
1858
+ const inv = this.completeRecovery(sp, u, device.pubkey, "waited", null, null, ip, acct.id);
1859
+ return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: "waited" } };
1860
+ }
1861
+ return { status: 202, body: { state: "waiting", space: sp.name, due_at: pend.due_at, fp: pend.fp, others: this.othersIn(sp, u) } };
1862
+ }
1863
+ const again = this.recoveryProofRefusal(A, acct, device, sp.name, e, sp.allow.founder() === u);
1864
+ if (again)
1865
+ return again;
1866
+ const oldFp = fingerprint(e.publicKey);
1867
+ const others = this.othersIn(sp, u);
1868
+ if (!others.length) {
1869
+ const inv = this.completeRecovery(sp, u, device.pubkey, "alone", null, null, ip, acct.id);
1870
+ return { status: 200, body: { state: "moved", space: sp.name, invite: inv.token, how: "alone" } };
1871
+ }
1872
+ const r = {
1873
+ member: u, pubkey: device.pubkey, fp: device.fingerprint, label: device.label,
1874
+ account: acct.id, requested_at: now, due_at: now + this.recoveryWaitMs,
1875
+ };
1876
+ sp.allow.requestRecovery(r);
1877
+ if (this.email.sendNotice) {
1878
+ void this.email.sendNotice(acct.email, `A request to move your seat in ${sp.name}`, `A device signed in to your Pantheon account ("${r.label}", key ${r.fp}) asked to move your seat in the team "${sp.name}" to it. ` +
1879
+ `It moves ${new Date(r.due_at).toUTCString()} unless someone cancels, or sooner if a teammate confirms it.\n\n` +
1880
+ `If this was you, nothing to do. If it was not: on a machine that still holds your seat, run pantheon recover cancel ${u} — and secure this email account, since whoever asked could read its sign-in code.`).catch(() => false);
1881
+ }
1882
+ A.audit({ event: "seat_recovery_requested", actor_account: acct.id, space: sp.name, target: u, detail: `${oldFp} → ${r.fp}`, ip });
1883
+ this.log(`[${sp.name}] ? RECOVERY ${u}: ${oldFp} → ${r.fp} (${r.label}), due ${new Date(r.due_at).toISOString()}`);
1884
+ const hours = Math.round(this.recoveryWaitMs / 3_600_000);
1885
+ this.commit(sp, {
1886
+ from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
1887
+ body: `${u} asked to move their seat in this team to a new device ("${r.label}", key ${r.fp}), after signing in with the code emailed to their account. ` +
1888
+ `It moves ${hours ? `in ${hours} hour${hours === 1 ? "" : "s"}` : "when they ask again"} unless someone cancels, or sooner when a teammate confirms it. ` +
1889
+ `If you have checked with ${u} (a call, or a channel you trust) that the new key is theirs: pantheon recover confirm ${u} --space ${sp.name}. ` +
1890
+ `If it is not them: pantheon recover cancel ${u} --space ${sp.name}.`,
1891
+ meta: { notice: "seat_recovery", recovery: { member: u, fp: r.fp, from_fp: oldFp, state: "waiting", due_at: r.due_at } },
1892
+ });
1893
+ this.broadcast(sp, { t: "members", members: this.members(sp) });
1894
+ return { status: 202, body: { state: "waiting", space: sp.name, due_at: r.due_at, fp: r.fp, others } };
1895
+ }
1896
+ recoveryProofRefusal(A, acct, device, name, seat, founder) {
1897
+ const proof = A.lastEmailProof(acct.id, device.fingerprint);
1898
+ if (!proof || Date.now() - proof > RECOVERY_PROOF_MS) {
1899
+ return { status: 403, body: { reason: "sign_in_again", error: `first prove it is you: run pantheon recover ${name} in a terminal (it emails a code to ${acct.email} and asks for it), or sign in with pantheon signin and within ${RECOVERY_PROOF_MS / 60_000} minutes run pantheon recover ${name} again` } };
1900
+ }
1901
+ const oldFp = fingerprint(seat.publicKey);
1902
+ if (!A.wasDeviceOf(acct.id, oldFp, seat.moved?.at ?? seat.first_seen)) {
1903
+ this.log(`[${name}] ! recovery refused for ${acct.username}: the seat's key ${oldFp} was not a device of their account when the seat was taken`);
1904
+ return { status: 403, body: { reason: "unproven", error: `the key holding your seat in "${name}" (${oldFp}) was not signed in to this account when the seat was taken (an account deleted and made again is a new account, and records over a year old are gone), so the relay cannot tell it was yours. ` +
1905
+ (founder ? `A founder's seat cannot be handed over any other way: start a new team (pantheon start) and invite your teammates to it.` : `A teammate who has checked it is you can retire it instead (pantheon admit ${acct.username} --forget) and invite you again.`) } };
1906
+ }
1907
+ return null;
1908
+ }
1909
+ withdrawRecovery(acct, device, name, ip) {
1910
+ const sp = name && this.rosterOf(name)?.recoveryOf(acct.username)?.pubkey === device.pubkey ? this.wakeForRelay(name) : null;
1911
+ const pend = sp?.allow.recoveryOf(acct.username);
1912
+ if (!sp || !pend || pend.pubkey !== device.pubkey)
1913
+ return { status: 404, body: { reason: "nothing_waiting", error: "this machine has no seat move waiting in that team" } };
1914
+ this.endRecovery(sp, acct.username, `withdrawn by ${acct.username} from the new device`, acct.username, ip);
1915
+ return { status: 200, body: { state: "withdrawn", space: sp.name } };
1916
+ }
1917
+ othersIn(sp, member) {
1918
+ return sp.allow.all().filter((x) => x.admitted && x.member !== member).map((x) => x.member);
1919
+ }
1920
+ endRecovery(sp, member, why, actor, ip) {
1921
+ const r = sp.allow.cancelRecovery(member);
1922
+ if (!r)
1923
+ return;
1924
+ this.accounts?.audit({ event: "seat_recovery_cancelled", actor_account: this.accounts.accountByUsername(actor)?.id ?? null, space: sp.name, target: member, detail: `${r.fp}: ${why}`, ip });
1925
+ this.log(`[${sp.name}] - RECOVERY of ${member} to ${r.fp} ended: ${why}`);
1926
+ this.commit(sp, {
1927
+ from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
1928
+ body: `${member}'s seat stays on its current key: the request to move it to ${r.fp} was ${why}.`,
1929
+ meta: { notice: "seat_recovery", recovery: { member, fp: r.fp, state: "ended" } },
1930
+ });
1931
+ this.broadcast(sp, { t: "members", members: this.members(sp) });
1932
+ }
1933
+ completeRecovery(sp, member, pubkey, how, by, vouch, ip, accountId) {
1934
+ const before = sp.allow.get(member);
1935
+ const fromFp = fingerprint(before.publicKey);
1936
+ const e = sp.allow.moveSeat(member, pubkey, { at: Date.now(), how, ...(by ? { by } : {}), ...(vouch ? { vouch } : {}) });
1937
+ if (!e)
1938
+ throw new Error(`no admitted seat for ${member} to move`);
1939
+ const live = sp.members.get(member);
1940
+ for (const s of live?.sessions.values() ?? [])
1941
+ this.hangUp(s.ws, "KEY_MISMATCH");
1942
+ live?.sessions.clear();
1943
+ if (this.rotateOnRemove)
1944
+ this.rotateSpaceToken(sp, `${member}'s seat moved to a new key`);
1945
+ const inv = this.mintRecoveryInvite(sp, member);
1946
+ const toFp = fingerprint(pubkey);
1947
+ this.accounts?.audit({ event: "seat_recovered", actor_account: accountId ?? this.accounts.accountByUsername(member)?.id ?? null, space: sp.name, target: member, detail: `${fromFp} → ${toFp} (${how}${by ? ` by ${by}` : ""})`, ip });
1948
+ this.log(`[${sp.name}] * RECOVERED ${member}: ${fromFp} → ${toFp} (${how}${by ? ` by ${by}` : ""})`);
1949
+ const word = how === "alone" ? "alone in the team" : how === "confirmed" ? `confirmed by ${by}` : "nobody cancelled in the time it waited";
1950
+ this.commit(sp, {
1951
+ from: RELAY_MEMBER, act: "NOTE", to: null, goal: null, ref: null,
1952
+ body: `${member} moved to a new device: their seat now has key ${toFp} (was ${fromFp}). They signed in with the code emailed to their account; ${word}.` +
1953
+ (how === "confirmed" ? "" : how === "waited" ? ` Your machine keeps trusting the old key until you check the new one with ${member}: pantheon trust ${member}` : ""),
1954
+ meta: { notice: "seat_recovery", recovery: { member, fp: toFp, from_fp: fromFp, state: "moved", how, ...(by ? { by } : {}) } },
1955
+ });
1956
+ this.broadcast(sp, { t: "members", members: this.members(sp) });
1957
+ this.broadcast(sp, { t: "roster", roster: this.roster(sp) });
1958
+ return inv;
1959
+ }
1960
+ mintRecoveryInvite(sp, member) {
1961
+ const e = sp.allow.get(member);
1962
+ return sp.allow.mintInvite(member, e.may_wake, 7 * 24 * 60, randomBytes(18).toString("base64url"), this.ensureToken(sp), member);
1963
+ }
1706
1964
  noteAccountRemoval(username) {
1707
1965
  this.authEpoch++;
1708
1966
  const u = normalizeUsername(username);
@@ -2209,7 +2467,7 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
2209
2467
  const revokeForm = (token) => `<form method=POST action="/admin/action" class=inl onsubmit="return confirm('Revoke this invite? The link stops working immediately.')"><input type=hidden name=csrf value="${csrfTok}"><input type=hidden name=action value=revoke_invite><input type=hidden name=id value="${esc(name)}"><input type=hidden name=token value="${esc(token.slice(-6))}"><button class="act danger">revoke</button></form>`;
2210
2468
  const memberRows = entries.map((e) => {
2211
2469
  const m = sp?.members.get(e.member);
2212
- const online = m ? m.sessions.size : 0;
2470
+ const online = m ? presentSessions(m).length : 0;
2213
2471
  return `<tr><td><a href="${link({ member: e.member, before: undefined })}"><code>${esc(e.member)}</code></a></td><td>${e.admitted ? '<span class="pill ok">admitted</span>' : '<span class="pill">pending</span>'}</td><td>${e.may_wake ? "yes" : "no"}</td><td>${online ? `<span class="pill ok">${online} session${online === 1 ? "" : "s"}</span> ${esc(m.agent)}` : "<span class=dim>offline</span>"}</td><td class=dim>${m ? ago(m.last_seen) : when(e.first_seen)}</td></tr>`;
2214
2472
  }).join("");
2215
2473
  const repos = allow.repoList();
@@ -2245,7 +2503,7 @@ ${ADMIN_CSS}
2245
2503
  ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchParams.get("done"))}</div>` : ""}
2246
2504
  <main>
2247
2505
  <div class=kpis>
2248
- <div class=kpi><div class=l>Members</div><div class=v>${entries.filter((e) => e.admitted).length}<small>admitted</small></div><div class=s>${sp ? [...sp.members.values()].filter((m) => m.sessions.size).length : 0} online now · ${openInvites.length} unused invite${openInvites.length === 1 ? "" : "s"}</div></div>
2506
+ <div class=kpi><div class=l>Members</div><div class=v>${entries.filter((e) => e.admitted).length}<small>admitted</small></div><div class=s>${sp ? [...sp.members.values()].filter((m) => presentSessions(m).length).length : 0} online now · ${openInvites.length} unused invite${openInvites.length === 1 ? "" : "s"}</div></div>
2249
2507
  <div class=kpi><div class=l>Goals</div><div class=v>${open}<small>/ ${goals.length} open</small></div><div class=s>${sp ? `${sp.obligations.size} obligation${sp.obligations.size === 1 ? "" : "s"} owed right now` : "asleep — reloads on the next connect"}</div></div>
2250
2508
  <div class=kpi><div class=l>Messages today</div><div class=v>${a.messages_24h}<small>/ ${a.messages} retained</small></div><div class=s>${a.senders_24h.length} delegate${a.senders_24h.length === 1 ? "" : "s"} active today · ${a.sessions_24h} sessions</div></div>
2251
2509
  <div class="kpi${obj24 + esc24 ? " warn" : ""}"><div class=l>Needs a look today</div><div class=v>${obj24 + esc24}</div><div class=s>${obj24} objection${obj24 === 1 ? "" : "s"} · ${esc24} escalation${esc24 === 1 ? "" : "s"} · ${a.by_act_24h.DELIVERED ?? 0} deliver${(a.by_act_24h.DELIVERED ?? 0) === 1 ? "y" : "ies"}</div></div>
@@ -2388,7 +2646,7 @@ ${url.searchParams.get("done") ? `<div class=done role=status>${esc(url.searchPa
2388
2646
  <style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:12vh auto;padding:0 1.2rem;color:#222}code,pre{background:#f4f4f5;border-radius:5px}code{padding:2px 6px}pre{padding:.6rem .8rem;overflow-x:auto}h1{font-size:1.6rem}</style>
2389
2647
  <h1>pantheon</h1>
2390
2648
  <p>Your coding agent and your teammates' agents, coordinating across machines.</p>
2391
- ${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, paste one line into a terminal on your own computer. It brings its own Node, needs no admin rights, and asks for your email and a 6-digit code.</p>
2649
+ ${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, paste one line into a terminal on your own computer. It brings its own Node, needs no admin rights, and asks for your email and a 6-digit code (it is emailed; check spam if it does not arrive).</p>
2392
2650
  <p>Mac or Linux (Terminal):</p>
2393
2651
  <pre><code>${esc(sh)}</code></pre>
2394
2652
  <p>Windows (Command Prompt or PowerShell):</p>
@@ -2396,7 +2654,7 @@ ${sh && win ? `<p>This is a relay endpoint, not a website. To start a team, past
2396
2654
  <p>Invited to a team? Use the line in your invite instead. Already have Node.js 22.13 or newer: <code>npm install -g @join-pantheon/cli</code>, then <code>pantheon start</code>.</p>` : `<p>This is a relay endpoint, not a website. With Node.js 22.13 or newer, install the CLI and start a team:</p>
2397
2655
  <pre><code>npm install -g @join-pantheon/cli
2398
2656
  pantheon start</code></pre>
2399
- <p>It asks for your email and sends you a 6-digit code. Invited to a team? Use the line in your invite.</p>`}`);
2657
+ <p>It asks for your email and sends you a 6-digit code (check spam if it does not arrive). Invited to a team? Use the line in your invite.</p>`}`);
2400
2658
  }
2401
2659
  startInstallerCheck() { this.installerGate.start(); }
2402
2660
  async serveInstaller(req, res, name) {
@@ -2462,6 +2720,7 @@ pantheon start</code></pre>
2462
2720
  };
2463
2721
  let sessionId = "";
2464
2722
  let role = "interactive";
2723
+ let quiet = false;
2465
2724
  let myPubkey = "";
2466
2725
  let delegated = null;
2467
2726
  let authEpoch = -1, authOk = true;
@@ -2701,10 +2960,21 @@ pantheon start</code></pre>
2701
2960
  }
2702
2961
  if (verdict === "key-mismatch") {
2703
2962
  this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
2963
+ const moved = sp.allow.get(m.member)?.moved;
2964
+ if (moved && moved.from_fp === fingerprint(m.pubkey)) {
2965
+ const how = moved.how === "confirmed" ? `, confirmed by ${moved.by}` : moved.how === "alone" ? "" : ", after nobody cancelled it";
2966
+ return reject("KEY_MISMATCH", `"${m.member}"'s seat in "${sp.name}" moved to another device on ${new Date(moved.at).toISOString().slice(0, 16).replace("T", " ")} UTC — signed in with the code emailed to ${m.member}'s account${how}. This machine's key no longer opens it. ` +
2967
+ `If that was you, use the new machine (or run pantheon recover ${sp.name} here to move it back). If it was not you, your email account may be compromised: secure it, then tell your team.`);
2968
+ }
2969
+ const recover = this.requireAccounts && this.accounts
2970
+ ? ` If this is really you on a new machine and the old one is gone: run pantheon recover ${sp.name} here, in a terminal — it signs you in with your email and moves your seat to this machine.`
2971
+ : "";
2704
2972
  if (sp.allow.founder() === m.member) {
2705
- return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key. A founder's key cannot be replaced: if this is really you on another machine, restore your original ~/.pantheon/config.json (the key is in it). Without it, join under another name (--me <new-name>) or start a new team (pantheon start).`);
2973
+ return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key.` + (recover ||
2974
+ ` A founder's key cannot be replaced: if this is really you on another machine, restore your original ~/.pantheon/config.json (the key is in it). Without it, join under another name (--me <new-name>) or start a new team (pantheon start).`));
2706
2975
  }
2707
- return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>). If this is really you on another machine, an admitted member who has checked it is you runs pantheon admit ${m.member} --forget (it removes whoever holds the name now) and sends you a new invite.`);
2976
+ return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>).` + (recover ||
2977
+ ` If this is really you on another machine, an admitted member who has checked it is you runs pantheon admit ${m.member} --forget (it removes whoever holds the name now) and sends you a new invite.`));
2708
2978
  }
2709
2979
  if (verdict === "revoked") {
2710
2980
  const e = sp.allow.get(m.member);
@@ -2747,8 +3017,9 @@ pantheon start</code></pre>
2747
3017
  clearHelloTimer();
2748
3018
  sessionId = acctSession ?? this.mintSession();
2749
3019
  role = delegated ? "worker" : m.role;
2750
- me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, role, ws, m.pkg);
2751
- const firstSession = me.sessions.size === 1;
3020
+ quiet = m.upkeep === true && role === "cli";
3021
+ me = this.attach(sp, m.member, m.display ?? m.member, m.agent ?? "unknown", sessionId, role, ws, m.pkg, quiet);
3022
+ const firstSession = !quiet && presentSessions(me).length === 1;
2752
3023
  const prior = typeof m.session === "string" ? m.session : "";
2753
3024
  const stale = prior && prior !== sessionId ? me.sessions.get(prior) : undefined;
2754
3025
  if (stale && stale.role === m.role) {
@@ -2798,7 +3069,7 @@ pantheon start</code></pre>
2798
3069
  for (let page = sp.ledger.since(from); page.length; page = sp.ledger.since(from)) {
2799
3070
  for (const r of page) {
2800
3071
  this.send(ws, { t: "act", act: r });
2801
- if (r.seq > me.delivered_seq)
3072
+ if (!quiet && r.seq > me.delivered_seq)
2802
3073
  me.delivered_seq = r.seq;
2803
3074
  }
2804
3075
  from = page[page.length - 1].seq;
@@ -3151,6 +3422,29 @@ pantheon start</code></pre>
3151
3422
  return fail("BAD_META", "goal_members, organization_profile and notice are written by the relay, not sent", m.cid);
3152
3423
  if (m.act === "NOTE" && m.body.startsWith("repo registered with this space: "))
3153
3424
  return fail("BAD_META", "that line is written by the relay, not sent", m.cid);
3425
+ let recoveryStep = null;
3426
+ if (m.act === "NOTE" && m.meta && "recovery" in m.meta) {
3427
+ const rq = m.meta.recovery;
3428
+ const target = typeof rq?.member === "string" ? rq.member : "";
3429
+ const action = rq?.action;
3430
+ if (action !== "confirm" && action !== "cancel")
3431
+ return fail("BAD_META", "recovery.action is confirm or cancel", m.cid);
3432
+ if (delegated || !m.sig || m.sig.role !== "member")
3433
+ return fail("RECOVERY_BY_PERSON", "only a person, signing with their own key, confirms or cancels a seat move — not a woken run", m.cid);
3434
+ if (!space.allow.get(me.id)?.admitted)
3435
+ return fail("NOT_ADMITTED", `only an admitted member of "${space.name}" can confirm or cancel a seat move`, m.cid);
3436
+ const pend = space.allow.recoveryOf(target);
3437
+ if (!pend)
3438
+ return fail("NO_RECOVERY", `no move of ${target || "anyone"}'s seat is waiting in "${space.name}"`, m.cid);
3439
+ if (rq.fp !== pend.fp)
3440
+ return fail("RECOVERY_KEY", `the key waiting for ${target} is ${pend.fp}, not ${String(rq.fp)} — check it with them again`, m.cid);
3441
+ if (action === "confirm" && target === me.id)
3442
+ return fail("RECOVERY_SELF", "a seat move is confirmed by a teammate, not by the person whose seat it is", m.cid);
3443
+ const curFp = fingerprint(space.allow.get(target).publicKey);
3444
+ if (action === "confirm" && rq.from_fp !== curFp)
3445
+ return fail("RECOVERY_KEY", `${target}'s seat is now on ${curFp}; the confirmation must name it (from_fp) — run the command again`, m.cid);
3446
+ recoveryStep = { action, member: target, pend };
3447
+ }
3154
3448
  const fromRepo = m.repo && space.allow.hasRepo(m.repo) ? m.repo : null;
3155
3449
  const { from_repo: _forged, ...senderMeta } = (m.meta ?? {});
3156
3450
  const hasMeta = !!m.meta && (Object.keys(senderMeta).length > 0 || _forged === undefined);
@@ -3159,7 +3453,7 @@ pantheon start</code></pre>
3159
3453
  : undefined;
3160
3454
  const fp = actFingerprint(me.id, m.act, m.body, m.goal ?? null, m.ref ?? null, m.to ?? null, storedMeta);
3161
3455
  const prior = space.recent.get(fp);
3162
- if (prior && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
3456
+ if (prior && !recoveryStep && Date.now() - prior.at < LIMITS.dedupeWindowMs) {
3163
3457
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: prior.id, seq: prior.seq });
3164
3458
  return;
3165
3459
  }
@@ -3193,7 +3487,7 @@ pantheon start</code></pre>
3193
3487
  }
3194
3488
  }
3195
3489
  const wasFull = this.spaceFull(space);
3196
- if (wasFull && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
3490
+ if (wasFull && !recoveryStep && !this.freesRoom(space, me.id, m.act, m.ref ?? null))
3197
3491
  return fail("SPACE_FULL", this.spaceFullMessage(space), m.cid);
3198
3492
  const rec = this.commit(space, {
3199
3493
  ...(m.sig && m.id ? { id: m.id } : {}),
@@ -3226,6 +3520,15 @@ pantheon start</code></pre>
3226
3520
  space.recent.delete(k);
3227
3521
  }
3228
3522
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
3523
+ if (recoveryStep) {
3524
+ const { action, member: who, pend } = recoveryStep;
3525
+ if (space.allow.recoveryOf(who)?.pubkey === pend.pubkey) {
3526
+ if (action === "cancel")
3527
+ this.endRecovery(space, who, `cancelled by ${me.id}`, me.id, ip);
3528
+ else
3529
+ this.completeRecovery(space, who, pend.pubkey, "confirmed", me.id, rec, ip, pend.account);
3530
+ }
3531
+ }
3229
3532
  }
3230
3533
  catch (e) {
3231
3534
  const cidPart = reqCid ? { cid: reqCid } : {};
@@ -3279,7 +3582,7 @@ pantheon start</code></pre>
3279
3582
  v.at = Date.now();
3280
3583
  }
3281
3584
  const stillMember = space.members.get(me.id) === me && !!space.allow.get(me.id)?.admitted;
3282
- if (me.sessions.size === 0 && role !== "cli" && stillMember) {
3585
+ if (presentSessions(me).length === 0 && role !== "cli" && !quiet && stillMember) {
3283
3586
  me.last_seen = Date.now();
3284
3587
  this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
3285
3588
  }
@@ -4135,12 +4438,16 @@ pantheon start</code></pre>
4135
4438
  if (dropped)
4136
4439
  this.log(`[${sp.name}] compacted ledger — dropped ${dropped} old act(s) from closed goals, ${kept} kept`);
4137
4440
  }
4138
- attach(sp, id, display, agent, session, role, ws, pkg) {
4441
+ attach(sp, id, display, agent, session, role, ws, pkg, quiet = false) {
4139
4442
  let m = sp.members.get(id);
4140
4443
  if (!m) {
4141
- m = { id, display, agent, last_seen: Date.now(), sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] };
4444
+ m = { id, display, agent, last_seen: quiet ? 0 : Date.now(), sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] };
4142
4445
  sp.members.set(id, m);
4143
4446
  }
4447
+ if (quiet) {
4448
+ m.sessions.set(session, { id: session, role, agent, ...(pkg ? { pkg } : {}), ws, quiet: true });
4449
+ return m;
4450
+ }
4144
4451
  m.display = display;
4145
4452
  m.agent = agent;
4146
4453
  m.last_seen = Date.now();
@@ -4152,10 +4459,10 @@ pantheon start</code></pre>
4152
4459
  return workState(m, Date.now(), this.busyTtlMs, this.pruneMs);
4153
4460
  }
4154
4461
  roster(sp) {
4155
- return [...sp.members.values()].map((m) => ({
4462
+ return [...sp.members.values()].filter((m) => m.last_seen > 0 || presentSessions(m).length > 0).map((m) => ({
4156
4463
  id: m.id, display: m.display, agent: m.agent,
4157
- online: m.sessions.size > 0, last_seen: m.last_seen,
4158
- sessions: [...m.sessions.values()].map((s) => ({
4464
+ online: presentSessions(m).length > 0, last_seen: m.last_seen,
4465
+ sessions: presentSessions(m).map((s) => ({
4159
4466
  id: s.id, role: s.role, agent: s.agent, ...(s.pkg ? { pkg: s.pkg } : {}),
4160
4467
  })),
4161
4468
  ...(this.work(m) ? { work: this.work(m) } : {}),
@@ -4171,6 +4478,8 @@ pantheon start</code></pre>
4171
4478
  ...(e.admitted_by ? { admitted_by: e.admitted_by } : {}),
4172
4479
  ...(e.revoked_at ? { revoked_at: e.revoked_at, ...(e.revoked_by ? { revoked_by: e.revoked_by } : {}) } : {}),
4173
4480
  ...(sp.allow.founder() === e.member ? { founder: true } : {}),
4481
+ ...((r) => r ? { recovery: { fp: r.fp, label: r.label, requested_at: r.requested_at, due_at: r.due_at } } : {})(sp.allow.recoveryOf(e.member)),
4482
+ ...(e.moved ? { moved: e.moved } : {}),
4174
4483
  }));
4175
4484
  }
4176
4485
  send(ws, msg) {
@@ -4185,7 +4494,8 @@ pantheon start</code></pre>
4185
4494
  for (const s of m.sessions.values())
4186
4495
  if (s.ws.readyState === 1) {
4187
4496
  s.ws.send(payload);
4188
- reached = true;
4497
+ if (!s.quiet)
4498
+ reached = true;
4189
4499
  }
4190
4500
  if (reached && seq !== null && seq > m.delivered_seq)
4191
4501
  m.delivered_seq = seq;
package/dist/repo-find.js CHANGED
@@ -44,12 +44,15 @@ export function reposUnder(dir, depth = 3, budget = { left: 4000 }) {
44
44
  return out;
45
45
  }
46
46
  export function holdsRepo(dir, budget = 20_000) {
47
+ return repoInside(dir, budget) !== null;
48
+ }
49
+ export function repoInside(dir, budget = 20_000) {
47
50
  let left = budget;
48
51
  const stack = [resolve(dir)];
49
52
  while (stack.length) {
50
53
  const d = stack.pop();
51
54
  if (left-- <= 0)
52
- return true;
55
+ return "too-large";
53
56
  let entries = [];
54
57
  try {
55
58
  entries = readdirSync(d, { withFileTypes: true });
@@ -59,12 +62,12 @@ export function holdsRepo(dir, budget = 20_000) {
59
62
  }
60
63
  for (const e of entries) {
61
64
  if (e.name === ".git")
62
- return true;
65
+ return d;
63
66
  if (e.isDirectory())
64
67
  stack.push(join(d, e.name));
65
68
  }
66
69
  }
67
- return false;
70
+ return null;
68
71
  }
69
72
  export function commonCodeRoots(home = homedir()) {
70
73
  return ["code", "Code", "src", "dev", "Developer", "projects", "Projects", "workspace", "work", "repos", "git", "github", "GitHub", "Documents/GitHub", "Documents/code", "Sites"]