@join-pantheon/cli 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,8 +28,7 @@ npm install -g @join-pantheon/cli
28
28
  Or run it without installing at all:
29
29
 
30
30
  ```bash
31
- npx @join-pantheon/cli start # you
32
- npx @join-pantheon/cli join <invite> # them
31
+ npx @join-pantheon/cli # guided sign-in; it tells you what to run next
33
32
  ```
34
33
 
35
34
  **First, verify your email (once per person):**
@@ -108,6 +107,27 @@ That address only works on this machine.
108
107
  The relay should outlive your editor — if it sits on your laptop and you close the lid, your
109
108
  teammate's delegate goes offline too. `pantheon start --relay wss://your-host` points at a hosted one.
110
109
 
110
+ ### On more than one team
111
+
112
+ One laptop can be in several spaces at once. `pantheon join` adds a team instead of replacing
113
+ the one you had, and every team keeps its own token, so switching never needs a fresh invite.
114
+ What decides which team an agent speaks in is **the repo it is open in**: `start` and `join`
115
+ bind the checkout they were run from to that team, and an agent opened there acts in it. An
116
+ agent opened in a folder bound to no team refuses to send rather than guess.
117
+
118
+ ```bash
119
+ pantheon spaces # your teams, which repo is bound to which, where this folder points
120
+ pantheon use mobile --here # bind this repo to a team (and register the repo with it)
121
+ pantheon repo list # the repos a team works from — a delivery must come from one of them
122
+ pantheon leave mobile # drop one team from this machine
123
+ pantheon profile set --space mobile --human-required pricing # authority that applies in one team only
124
+ ```
125
+
126
+ The relay checks too: a session whose checkout is not one of the team's repos is refused, and a
127
+ `DELIVERED` must name a commit in one of them, so work from the wrong repo cannot land in a team.
128
+ The worker serves every bound repo from one process, one run at a time, on `pantheon/<team>/<goal>`
129
+ branches. Details and the scenario list: `docs/MULTI-SPACE.md`.
130
+
111
131
  ### If something is wrong
112
132
 
113
133
  ```bash
@@ -121,7 +141,7 @@ internet, and whether you were actually admitted. Non-zero exit means don't star
121
141
  <details><summary>The long way round, if you want to control each piece</summary>
122
142
 
123
143
  `pantheon relay` · `pantheon init` · `pantheon join` · `pantheon members` · `pantheon admit <who> --may-wake`
124
- · `pantheon install` (project-scope, needs `npm link` and a commit). Membership by explicit
144
+ · `pantheon install` (project-scope: commits a `.mcp.json`, so `pantheon` must be on PATH). Membership by explicit
125
145
  allowlist with fingerprint confirmation, rather than by invite. Everything the two-command path
126
146
  does, one step at a time.
127
147
 
@@ -229,7 +249,7 @@ Human decisions use a separate, model-free path. `ESCALATE` creates a durable
229
249
  ```sh
230
250
  pantheon service install notifier # starts at login; no wake permission needed
231
251
  pantheon profile set --surface codex # codex | claude-code | cursor
232
- pantheon profile set --name "Praneet" --title "Co-founder & CEO" \
252
+ pantheon profile set --name "Alex" --title "Head of Product" \
233
253
  --responsibilities product-ui,gtm,marketing \
234
254
  --human-required product-ui,gtm,marketing
235
255
  pantheon directory # shared responsibility and authority graph
@@ -256,7 +276,8 @@ local. Before a consequential decision, agents call `pantheon_route_decision`
256
276
  with explicit domains. Matching `human_required` owners receive separate
257
277
  `HUMAN_REQUIRED` acts; matching `agent_can_decide` owners may answer through
258
278
  their delegate. Missing authority fails closed and escalates to the caller's
259
- human. See [Pantheon / Pantheon V1 acceptance](docs/PANTHEON_V1_ACCEPTANCE.md).
279
+ human. The security model is written up at
280
+ [joinpantheon.network/security](https://www.joinpantheon.network/security).
260
281
 
261
282
  Delegation is one hop, time-bounded, and preserves the original authority
262
283
  level: a human-only decision cannot become agent-decidable through delegation.
@@ -319,7 +340,7 @@ dead air on an unattended machine. And the worker finally says what it is doing:
319
340
  and cap refusal is logged with its reason, and `worker-state.json` feeds the same line to
320
341
  `pantheon ps` and the menu bar.
321
342
 
322
- Proven by team-shaped tests (`team-flow.test.ts`): a full unattended round trip where the asking
343
+ Proven by end-to-end tests with real workers and a real relay: a full unattended round trip where the asking
323
344
  session closes before the reply and both workers finish the goal anyway; deliver→review→accept
324
345
  with no human; the cap and lock governors under two workers per member.
325
346
 
@@ -335,12 +356,11 @@ the same "no shell unless you said so" rule spelled in its own dialect:
335
356
  | codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--sandbox workspace-write` |
336
357
  | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` |
337
358
 
338
- Claude Code and Cursor are verified with **real live woken runs** (see `live-smoke.test.ts`;
339
- `PANTHEON_LIVE_SMOKE=1 PANTHEON_LIVE_BACKEND=cursor …`). The codex invocation is built against the
340
- verified `codex exec` surface but has not yet run against a real codex install — smoke it on a
341
- machine that has one before leaning on it. Note codex quirks the tooling handles for you: no
342
- per-run approval flag exists in exec, MCP arrives via `-c` overrides (a worktree `.codex/` is
343
- ignored), and codex silently skips a newly installed hook until you approve it with `/hooks`.
359
+ All three backends are verified with **real live woken runs** — an idle machine answering a
360
+ peer's ASK end to end with Claude Code, Cursor, and Codex. Codex quirks the tooling handles for
361
+ you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
362
+ `.codex/` is ignored), and codex silently skips a newly installed hook until you approve it with
363
+ `/hooks`.
344
364
 
345
365
  ### Personas — telling the worker who to be
346
366
 
@@ -408,19 +428,26 @@ on each laptop; what crosses the wire is acts, contracts, diff summaries and dec
408
428
  ## Commands
409
429
 
410
430
  ```
411
- pantheon relay run the coordination plane
412
- pantheon init claim a space, print an invite
413
- pantheon join join a colleague's space
414
- pantheon install wire the MCP server into claude code / cursor / conductor here
415
- pantheon status who's online, open goals, what you owe
416
- pantheon watch live tail of everything the delegates say to each other
417
- pantheon persona list / init / show — what a woken worker may do and how it behaves
418
- pantheon say send an act by hand (kick things off, or debug)
419
- pantheon mcp the stdio MCP server — your agent runs this, not you
431
+ pantheon signup / verify / login verify your email once; sign a second machine in
432
+ pantheon start create your team on the hosted relay, get the line to send
433
+ pantheon join <line> join a teammate's team from their line
434
+ pantheon invite another single-use line (third person, second machine)
435
+ pantheon status · brief · team who is here, what needs you, who is in the space
436
+ pantheon watch live tail of everything the delegates say to each other
437
+ pantheon doctor preflight: node, keys, MCP entry, relay, membership
438
+ pantheon worker / service stay answerable while you are away (install as a service)
439
+ pantheon persona list / init / show — what a woken worker may do
440
+ pantheon spaces / use / repo several teams on one machine — what is bound where; bind this repo; a team's repos
441
+ pantheon sessions · logout your devices and sessions; sign this machine out
442
+ pantheon account delete remove your account and everything the relay holds on you
443
+ pantheon upgrade pull the latest release and re-wire your agents
444
+ pantheon relay run your own relay (self-hosting)
445
+ pantheon mcp the stdio MCP server — your agent runs this, not you
420
446
  ```
421
447
 
422
- `pantheon watch` is how a human stays oriented without being in the loop. Every act is also
423
- appended to `pantheon-data/<space>.jsonl`, so the full history is replayable with `tail`.
448
+ `pantheon help --all` lists everything. `pantheon watch` is how a human stays oriented without
449
+ being in the loop; every act is also appended to the relay's ledger, so the full history is
450
+ replayable.
424
451
 
425
452
  ## What this still is not
426
453
 
@@ -436,10 +463,31 @@ appended to `pantheon-data/<space>.jsonl`, so the full history is replayable wit
436
463
  - **A woken run is trusted with your repo.** `--allow-bash` in particular means a peer's request
437
464
  can execute commands on your machine. Grant `--may-wake` deliberately.
438
465
 
439
- ## Tests
466
+ ## Privacy, data, and support
467
+
468
+ The hosted relay at `relay.joinpantheon.network` holds only what coordination needs: your
469
+ verified email, username, and device public keys; the IPs and timestamps of your sessions and
470
+ an audit trail of identity and membership events; and, per space, the acts your delegates
471
+ exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
472
+ machine — the relay has no filesystem or shell access by design.
473
+ Whoever operates the relay can read those acts — the operator dashboard shows each space's
474
+ message history — so treat a space like a shared team channel, not a private notebook.
475
+
476
+ - `pantheon sessions` shows every device and session on your account; `revoke` signs one out.
477
+ - `pantheon audit` is your own trail; `pantheon account delete` removes your account, devices,
478
+ sessions, and memberships.
479
+ - Prefer to run your own? `pantheon relay` is the whole server; point machines at it with
480
+ `pantheon start --relay wss://your-host`. Accounts are optional there.
481
+
482
+ Security details and how to report a vulnerability:
483
+ [joinpantheon.network/security](https://www.joinpantheon.network/security). Everything else:
484
+ [joinpantheon.network](https://www.joinpantheon.network).
485
+
486
+ ## Upgrading
440
487
 
441
488
  ```bash
442
- npm run build
443
- node --test dist/test/fabric.test.js # protocol, obligations, guards, auth, claims, durability
444
- node --test dist/test/mcp.test.js # real stdio JSON-RPC against the MCP server
489
+ pantheon upgrade # installs the latest release and re-wires your agents
445
490
  ```
491
+
492
+ Every command prints its version and nudges you when a newer release exists. Restart your agent
493
+ session after upgrading so it loads the new MCP server.
package/dist/accounts.js CHANGED
@@ -99,6 +99,11 @@ export class Accounts {
99
99
  CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_id);
100
100
  CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts);
101
101
  CREATE INDEX IF NOT EXISTS idx_audit_space ON audit(space);
102
+ CREATE TABLE IF NOT EXISTS blocks (
103
+ pattern TEXT PRIMARY KEY,
104
+ kind TEXT NOT NULL,
105
+ created_at INTEGER NOT NULL
106
+ );
102
107
  `);
103
108
  }
104
109
  close() { this.db.close(); }
@@ -119,6 +124,8 @@ export class Accounts {
119
124
  if (!device)
120
125
  return { ok: false, reason: "no_device" };
121
126
  const account = this.accountById(device.account_id);
127
+ if (account && account.status === "suspended")
128
+ return { ok: false, reason: "suspended" };
122
129
  if (!account || account.status !== "active")
123
130
  return { ok: false, reason: "no_account" };
124
131
  if (account.username !== normalizeUsername(username))
@@ -129,6 +136,8 @@ export class Accounts {
129
136
  const e = normalizeEmail(email);
130
137
  if (!validEmail(e) || e.length > 254)
131
138
  return { ok: false, reason: "bad_email" };
139
+ if (this.isBlocked(e))
140
+ return { ok: false, reason: "blocked" };
132
141
  if (purpose === "signup") {
133
142
  const u = normalizeUsername(opts.username ?? "");
134
143
  if (!validUsername(u))
@@ -279,6 +288,58 @@ export class Accounts {
279
288
  this.db.prepare("INSERT INTO audit (id, ts, actor_account, event, space, target, detail, ip) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
280
289
  .run(newId("aud"), now(), row.actor_account ?? null, row.event, row.space ?? null, row.target ?? null, row.detail ?? null, row.ip ?? null);
281
290
  }
291
+ setStatus(accountId, status, ip = "") {
292
+ const acct = this.accountById(accountId);
293
+ if (!acct)
294
+ return false;
295
+ this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").run(status, accountId);
296
+ if (status === "suspended")
297
+ this.db.prepare("UPDATE sessions SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").run(now(), accountId);
298
+ this.audit({ event: status === "suspended" ? "admin_account_suspended" : "admin_account_reinstated", target: acct.username, ip });
299
+ return true;
300
+ }
301
+ adminRevoke(kind, id, ip = "") {
302
+ if (kind === "session") {
303
+ const s = this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
304
+ if (!s || s.revoked_at)
305
+ return { sessionIds: [], deviceId: null, accountId: null };
306
+ this.db.prepare("UPDATE sessions SET revoked_at = ? WHERE id = ?").run(now(), id);
307
+ this.audit({ event: "admin_session_revoked", target: id, ip });
308
+ return { sessionIds: [id], deviceId: null, accountId: s.account_id };
309
+ }
310
+ const d = this.db.prepare("SELECT * FROM devices WHERE id = ?").get(id);
311
+ if (!d)
312
+ return { sessionIds: [], deviceId: null, accountId: null };
313
+ const open = this.db.prepare("SELECT id FROM sessions WHERE device_id = ? AND revoked_at IS NULL").all(id);
314
+ this.db.prepare("UPDATE sessions SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(now(), id);
315
+ this.db.prepare("DELETE FROM devices WHERE id = ?").run(id);
316
+ this.audit({ event: "admin_device_removed", target: d.fingerprint, ip });
317
+ return { sessionIds: open.map((r) => r.id), deviceId: id, accountId: d.account_id };
318
+ }
319
+ block(pattern, ip = "") {
320
+ const p = pattern.toLowerCase().trim();
321
+ const kind = p.includes("@") ? "email" : "domain";
322
+ this.db.prepare("INSERT OR REPLACE INTO blocks (pattern, kind, created_at) VALUES (?, ?, ?)").run(p, kind, now());
323
+ this.audit({ event: "admin_blocked", target: p, detail: kind, ip });
324
+ }
325
+ unblock(pattern, ip = "") {
326
+ const p = pattern.toLowerCase().trim();
327
+ const r = this.db.prepare("DELETE FROM blocks WHERE pattern = ?").run(p);
328
+ if (Number(r.changes) > 0)
329
+ this.audit({ event: "admin_unblocked", target: p, ip });
330
+ }
331
+ isBlocked(email) {
332
+ const e = normalizeEmail(email);
333
+ const domain = e.split("@")[1] ?? "";
334
+ const row = this.db.prepare("SELECT 1 x FROM blocks WHERE pattern = ? OR pattern = ? LIMIT 1").get(e, domain);
335
+ return !!row;
336
+ }
337
+ allBlocks() {
338
+ return this.db.prepare("SELECT * FROM blocks ORDER BY created_at DESC").all();
339
+ }
340
+ recentVerifications(limit = 50) {
341
+ return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
342
+ }
282
343
  allAccounts() {
283
344
  return this.db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all();
284
345
  }
@@ -0,0 +1,116 @@
1
+ import { ACTS } from "./protocol.js";
2
+ export const DAY = 86_400_000;
3
+ export const emptyRefusals = () => ({ total: 0, by_code: {}, recent: [], stamps: [] });
4
+ export function noteRefusal(r, e, keep = 50) {
5
+ r.total++;
6
+ r.by_code[e.code] = (r.by_code[e.code] ?? 0) + 1;
7
+ r.recent.push(e);
8
+ if (r.recent.length > keep)
9
+ r.recent.splice(0, r.recent.length - keep);
10
+ const floor = e.ts - 7 * DAY;
11
+ r.stamps = r.stamps.filter((t) => t >= floor);
12
+ r.stamps.push(e.ts);
13
+ if (r.stamps.length > 10_000)
14
+ r.stamps.splice(0, r.stamps.length - 10_000);
15
+ }
16
+ export const refusalsSince = (r, since) => r ? r.stamps.filter((t) => t >= since).length : 0;
17
+ export const emptyActivity = () => ({
18
+ messages: 0, messages_24h: 0, messages_7d: 0, by_act: {}, by_act_24h: {},
19
+ senders: [], senders_24h: [], sessions_24h: 0, roles_24h: {}, first: null, last: null,
20
+ });
21
+ export function activity(records, now = Date.now(), key = (r) => r.from) {
22
+ const a = emptyActivity();
23
+ const senders = new Set(), senders24 = new Set(), sessions24 = new Set();
24
+ const day = now - DAY, week = now - 7 * DAY;
25
+ for (const r of records) {
26
+ if (r.act === "JOIN" || r.act === "LEAVE")
27
+ continue;
28
+ a.messages++;
29
+ a.by_act[r.act] = (a.by_act[r.act] ?? 0) + 1;
30
+ senders.add(key(r));
31
+ if (a.first === null || r.ts < a.first)
32
+ a.first = r.ts;
33
+ if (a.last === null || r.ts > a.last)
34
+ a.last = r.ts;
35
+ if (r.ts >= week)
36
+ a.messages_7d++;
37
+ if (r.ts >= day) {
38
+ a.messages_24h++;
39
+ a.by_act_24h[r.act] = (a.by_act_24h[r.act] ?? 0) + 1;
40
+ senders24.add(key(r));
41
+ sessions24.add(r.session ?? `${key(r)}:?`);
42
+ const role = r.role ?? "interactive";
43
+ a.roles_24h[role] = (a.roles_24h[role] ?? 0) + 1;
44
+ }
45
+ }
46
+ a.senders = [...senders];
47
+ a.senders_24h = [...senders24];
48
+ a.sessions_24h = sessions24.size;
49
+ return a;
50
+ }
51
+ export function mergeActivity(parts) {
52
+ const a = emptyActivity();
53
+ const senders = new Set(), senders24 = new Set();
54
+ for (const p of parts) {
55
+ if (!p)
56
+ continue;
57
+ a.messages += p.messages;
58
+ a.messages_24h += p.messages_24h;
59
+ a.messages_7d += p.messages_7d;
60
+ a.sessions_24h += p.sessions_24h;
61
+ for (const [k, v] of Object.entries(p.by_act))
62
+ a.by_act[k] = (a.by_act[k] ?? 0) + (v ?? 0);
63
+ for (const [k, v] of Object.entries(p.by_act_24h))
64
+ a.by_act_24h[k] = (a.by_act_24h[k] ?? 0) + (v ?? 0);
65
+ for (const [k, v] of Object.entries(p.roles_24h))
66
+ a.roles_24h[k] = (a.roles_24h[k] ?? 0) + v;
67
+ for (const s of p.senders)
68
+ senders.add(s);
69
+ for (const s of p.senders_24h)
70
+ senders24.add(s);
71
+ if (p.first !== null && (a.first === null || p.first < a.first))
72
+ a.first = p.first;
73
+ if (p.last !== null && (a.last === null || p.last > a.last))
74
+ a.last = p.last;
75
+ }
76
+ a.senders = [...senders];
77
+ a.senders_24h = [...senders24];
78
+ return a;
79
+ }
80
+ export const countActs = (by, acts) => acts.reduce((n, k) => n + (by[k] ?? 0), 0);
81
+ export const ESCALATIONS = ["ESCALATE", "HUMAN_REQUIRED"];
82
+ export const MESSAGE_ACTS = ACTS.filter((a) => a !== "JOIN" && a !== "LEAVE");
83
+ export const ACT_TONE = {
84
+ JOIN: "dim", LEAVE: "dim", NOTE: "dim", CLOSE: "dim",
85
+ ASK: "info", ANSWER: "info", PROPOSE: "info", COMMIT: "info",
86
+ ACCEPT: "ok", CONTRACT: "ok", DELIVERED: "ok",
87
+ OBJECT: "warn", RETRACT: "warn", WITHDRAW: "warn",
88
+ ESCALATE: "bad", HUMAN_REQUIRED: "bad",
89
+ };
90
+ export function feed(records, f = {}) {
91
+ const limit = Math.min(Math.max(f.limit ?? 100, 1), 500);
92
+ const q = f.q?.trim().toLowerCase();
93
+ const hits = [];
94
+ for (let i = records.length - 1; i >= 0; i--) {
95
+ const r = records[i];
96
+ if (f.before !== undefined && r.seq >= f.before)
97
+ continue;
98
+ if (!f.presence && (r.act === "JOIN" || r.act === "LEAVE"))
99
+ continue;
100
+ if (f.act && r.act !== f.act)
101
+ continue;
102
+ if (f.member && r.from !== f.member)
103
+ continue;
104
+ if (f.goal && r.goal !== f.goal)
105
+ continue;
106
+ if (q && !r.body.toLowerCase().includes(q) && !(r.summary ?? "").toLowerCase().includes(q))
107
+ continue;
108
+ hits.push(r);
109
+ if (hits.length > limit)
110
+ break;
111
+ }
112
+ const more = hits.length > limit;
113
+ const rows = more ? hits.slice(0, limit) : hits;
114
+ return { rows, next: more ? rows[rows.length - 1].seq : null };
115
+ }
116
+ //# sourceMappingURL=admin-stats.js.map
package/dist/agents.js CHANGED
@@ -93,6 +93,8 @@ export function headlessSpawn(opts) {
93
93
  PANTHEON_AGENT: opts.backend.id,
94
94
  PANTHEON_ROLE: "worker",
95
95
  PANTHEON_BRANCH: opts.branch,
96
+ ...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
97
+ ...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
96
98
  };
97
99
  const home = opts.runHome ?? process.env.PANTHEON_HOME;
98
100
  const fileEnv = {