@join-pantheon/cli 0.19.1 → 0.21.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
@@ -67,7 +67,10 @@ every Conductor window, nothing to commit), the wake-up hook so an agent finishe
67
67
  waiting for, the **worker** so the machine answers the team even while its human is away (on by
68
68
  default when run inside a git repo; `--no-worker` opts out — and a peer still needs an explicit
69
69
  `--may-wake` grant before they can use it), native decision alerts, and the menu bar. Each command
70
- ends with a "You're set" summary. Restart your agent once and ask it *"check pantheon_status"*.
70
+ ends with a "You're set" summary, then a short guided setup on a terminal: whether teammates may wake
71
+ your machine while you are away, what a woken run may execute, who you are and which decisions are
72
+ yours alone (so agents stop and ask you), and which alerts you want. Plain questions, mostly options;
73
+ `pantheon setup` re-runs it any time. Restart your agent once and ask it *"check pantheon_status"*.
71
74
 
72
75
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
73
76
  `pantheon install --user` (from your project's repo).
@@ -89,6 +92,15 @@ pantheon: that invite was already used by "alex" — ask for a fresh one
89
92
 
90
93
  Need another (a third person, or your own second machine)? `pantheon invite`.
91
94
 
95
+ ### Plans on the hosted relay
96
+
97
+ A free account creates **one space** with **two people** in it — you and one invite. Personas,
98
+ messages and history are not limited. Joining spaces other people created is not limited either,
99
+ so a colleague on a bigger plan can always invite you. When you hit a limit the command says so
100
+ and what to do; `pantheon status` shows your plan and how much of it you are using. Need more?
101
+ [joinpantheon.network](https://www.joinpantheon.network). A space you founded and no longer need
102
+ can be dissolved with `pantheon space delete <team> --yes`, which frees the slot.
103
+
92
104
  ### Reaching each other across cities
93
105
 
94
106
  On the hosted relay there is nothing to work out: every signed-in machine dials
@@ -107,6 +119,28 @@ That address only works on this machine.
107
119
  The relay should outlive your editor — if it sits on your laptop and you close the lid, your
108
120
  teammate's delegate goes offline too. `pantheon start --relay wss://your-host` points at a hosted one.
109
121
 
122
+ ### On more than one team
123
+
124
+ One laptop can be in several spaces at once. `pantheon join` adds a team instead of replacing
125
+ the one you had, and every team keeps its own token, so switching never needs a fresh invite.
126
+ What decides which team an agent speaks in is **the repo it is open in**: `start` and `join`
127
+ bind the checkout they were run from to that team, and an agent opened there acts in it. An
128
+ agent opened in a folder bound to no team refuses to send rather than guess.
129
+
130
+ ```bash
131
+ pantheon spaces # your teams, which repo is bound to which, where this folder points
132
+ pantheon use mobile --here # bind this repo to a team (and register the repo with it)
133
+ pantheon repo list # the repos a team works from — a delivery must come from one of them
134
+ pantheon repo add --base main # the branch workers cut from and deliveries are judged against (default: origin's HEAD)
135
+ pantheon leave mobile # drop one team from this machine
136
+ pantheon profile set --space mobile --human-required pricing # authority that applies in one team only
137
+ ```
138
+
139
+ The relay checks too: a session whose checkout is not one of the team's repos is refused, and a
140
+ `DELIVERED` must name a commit in one of them, so work from the wrong repo cannot land in a team.
141
+ The worker serves every bound repo from one process, one run at a time, on `pantheon/<team>/<goal>`
142
+ branches. Details and the scenario list: `docs/MULTI-SPACE.md`.
143
+
110
144
  ### If something is wrong
111
145
 
112
146
  ```bash
@@ -416,6 +450,7 @@ pantheon watch live tail of everything the delegates say to
416
450
  pantheon doctor preflight: node, keys, MCP entry, relay, membership
417
451
  pantheon worker / service stay answerable while you are away (install as a service)
418
452
  pantheon persona list / init / show — what a woken worker may do
453
+ pantheon spaces / use / repo several teams on one machine — what is bound where; bind this repo; a team's repos
419
454
  pantheon sessions · logout your devices and sessions; sign this machine out
420
455
  pantheon account delete remove your account and everything the relay holds on you
421
456
  pantheon upgrade pull the latest release and re-wire your agents
@@ -448,6 +483,8 @@ verified email, username, and device public keys; the IPs and timestamps of your
448
483
  an audit trail of identity and membership events; and, per space, the acts your delegates
449
484
  exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
450
485
  machine — the relay has no filesystem or shell access by design.
486
+ Whoever operates the relay can read those acts — the operator dashboard shows each space's
487
+ message history — so treat a space like a shared team channel, not a private notebook.
451
488
 
452
489
  - `pantheon sessions` shows every device and session on your account; `revoke` signs one out.
453
490
  - `pantheon audit` is your own trail; `pantheon account delete` removes your account, devices,
package/dist/accounts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_PLANS, parsePlans, parseLimits, planLimits } from "./plans.js";
1
2
  import { createRequire } from "node:module";
2
3
  import { createHash, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
3
4
  import { chmodSync, mkdirSync, readdirSync, rmSync } from "node:fs";
@@ -99,7 +100,29 @@ export class Accounts {
99
100
  CREATE INDEX IF NOT EXISTS idx_sessions_account ON sessions(account_id);
100
101
  CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts);
101
102
  CREATE INDEX IF NOT EXISTS idx_audit_space ON audit(space);
103
+ CREATE TABLE IF NOT EXISTS blocks (
104
+ pattern TEXT PRIMARY KEY,
105
+ kind TEXT NOT NULL,
106
+ created_at INTEGER NOT NULL
107
+ );
108
+ CREATE TABLE IF NOT EXISTS settings (
109
+ key TEXT PRIMARY KEY,
110
+ json TEXT NOT NULL,
111
+ updated_at INTEGER NOT NULL
112
+ );
113
+ CREATE TABLE IF NOT EXISTS spaces (
114
+ name TEXT PRIMARY KEY,
115
+ owner_account TEXT REFERENCES accounts(id) ON DELETE SET NULL,
116
+ created_at INTEGER NOT NULL,
117
+ deleted_at INTEGER
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_account);
102
120
  `);
121
+ const cols = new Set(this.db.prepare("PRAGMA table_info(accounts)").all().map((c) => c.name));
122
+ if (!cols.has("plan"))
123
+ this.db.exec("ALTER TABLE accounts ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'");
124
+ if (!cols.has("limits_json"))
125
+ this.db.exec("ALTER TABLE accounts ADD COLUMN limits_json TEXT");
103
126
  }
104
127
  close() { this.db.close(); }
105
128
  accountByUsername(username) {
@@ -119,6 +142,8 @@ export class Accounts {
119
142
  if (!device)
120
143
  return { ok: false, reason: "no_device" };
121
144
  const account = this.accountById(device.account_id);
145
+ if (account && account.status === "suspended")
146
+ return { ok: false, reason: "suspended" };
122
147
  if (!account || account.status !== "active")
123
148
  return { ok: false, reason: "no_account" };
124
149
  if (account.username !== normalizeUsername(username))
@@ -129,6 +154,8 @@ export class Accounts {
129
154
  const e = normalizeEmail(email);
130
155
  if (!validEmail(e) || e.length > 254)
131
156
  return { ok: false, reason: "bad_email" };
157
+ if (this.isBlocked(e))
158
+ return { ok: false, reason: "blocked" };
132
159
  if (purpose === "signup") {
133
160
  const u = normalizeUsername(opts.username ?? "");
134
161
  if (!validUsername(u))
@@ -192,7 +219,7 @@ export class Accounts {
192
219
  else {
193
220
  const id = newId("acct");
194
221
  run("INSERT INTO accounts (id, email, username, status, created_at, tos_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, e, v.pending_username, now(), now());
195
- account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now() };
222
+ account = { id, email: e, username: v.pending_username, status: "active", created_at: now(), tos_at: now(), plan: "free", limits_json: null };
196
223
  }
197
224
  }
198
225
  else {
@@ -279,6 +306,145 @@ export class Accounts {
279
306
  this.db.prepare("INSERT INTO audit (id, ts, actor_account, event, space, target, detail, ip) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
280
307
  .run(newId("aud"), now(), row.actor_account ?? null, row.event, row.space ?? null, row.target ?? null, row.detail ?? null, row.ip ?? null);
281
308
  }
309
+ setStatus(accountId, status, ip = "") {
310
+ const acct = this.accountById(accountId);
311
+ if (!acct)
312
+ return false;
313
+ this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").run(status, accountId);
314
+ if (status === "suspended")
315
+ this.db.prepare("UPDATE sessions SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").run(now(), accountId);
316
+ this.audit({ event: status === "suspended" ? "admin_account_suspended" : "admin_account_reinstated", target: acct.username, ip });
317
+ return true;
318
+ }
319
+ adminRevoke(kind, id, ip = "") {
320
+ if (kind === "session") {
321
+ const s = this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
322
+ if (!s || s.revoked_at)
323
+ return { sessionIds: [], deviceId: null, accountId: null };
324
+ this.db.prepare("UPDATE sessions SET revoked_at = ? WHERE id = ?").run(now(), id);
325
+ this.audit({ event: "admin_session_revoked", target: id, ip });
326
+ return { sessionIds: [id], deviceId: null, accountId: s.account_id };
327
+ }
328
+ const d = this.db.prepare("SELECT * FROM devices WHERE id = ?").get(id);
329
+ if (!d)
330
+ return { sessionIds: [], deviceId: null, accountId: null };
331
+ const open = this.db.prepare("SELECT id FROM sessions WHERE device_id = ? AND revoked_at IS NULL").all(id);
332
+ this.db.prepare("UPDATE sessions SET revoked_at = ? WHERE device_id = ? AND revoked_at IS NULL").run(now(), id);
333
+ this.db.prepare("DELETE FROM devices WHERE id = ?").run(id);
334
+ this.audit({ event: "admin_device_removed", target: d.fingerprint, ip });
335
+ return { sessionIds: open.map((r) => r.id), deviceId: id, accountId: d.account_id };
336
+ }
337
+ block(pattern, ip = "") {
338
+ const p = pattern.toLowerCase().trim();
339
+ const kind = p.includes("@") ? "email" : "domain";
340
+ this.db.prepare("INSERT OR REPLACE INTO blocks (pattern, kind, created_at) VALUES (?, ?, ?)").run(p, kind, now());
341
+ this.audit({ event: "admin_blocked", target: p, detail: kind, ip });
342
+ }
343
+ unblock(pattern, ip = "") {
344
+ const p = pattern.toLowerCase().trim();
345
+ const r = this.db.prepare("DELETE FROM blocks WHERE pattern = ?").run(p);
346
+ if (Number(r.changes) > 0)
347
+ this.audit({ event: "admin_unblocked", target: p, ip });
348
+ }
349
+ isBlocked(email) {
350
+ const e = normalizeEmail(email);
351
+ const domain = e.split("@")[1] ?? "";
352
+ const row = this.db.prepare("SELECT 1 x FROM blocks WHERE pattern = ? OR pattern = ? LIMIT 1").get(e, domain);
353
+ return !!row;
354
+ }
355
+ allBlocks() {
356
+ return this.db.prepare("SELECT * FROM blocks ORDER BY created_at DESC").all();
357
+ }
358
+ recentVerifications(limit = 50) {
359
+ return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
360
+ }
361
+ plans() {
362
+ const row = this.db.prepare("SELECT json FROM settings WHERE key = 'plans'").get();
363
+ if (row) {
364
+ try {
365
+ const p = parsePlans(JSON.parse(row.json));
366
+ if (p)
367
+ return p;
368
+ }
369
+ catch { }
370
+ }
371
+ if (process.env.PANTHEON_PLANS_JSON) {
372
+ try {
373
+ const p = parsePlans(JSON.parse(process.env.PANTHEON_PLANS_JSON));
374
+ if (p)
375
+ return p;
376
+ }
377
+ catch { }
378
+ }
379
+ return structuredClone(DEFAULT_PLANS);
380
+ }
381
+ setPlans(plans) {
382
+ if (!parsePlans(plans))
383
+ throw new Error("malformed plan table");
384
+ this.db.prepare("INSERT INTO settings (key, json, updated_at) VALUES ('plans', ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at")
385
+ .run(JSON.stringify(plans), now());
386
+ }
387
+ setPlan(accountId, plan) {
388
+ if (!this.plans()[plan])
389
+ return false;
390
+ return this.db.prepare("UPDATE accounts SET plan = ? WHERE id = ?").run(plan, accountId).changes > 0;
391
+ }
392
+ setLimits(accountId, json) {
393
+ if (json !== null) {
394
+ let ok = false;
395
+ try {
396
+ ok = !!parseLimits(JSON.parse(json));
397
+ }
398
+ catch {
399
+ ok = false;
400
+ }
401
+ if (!ok)
402
+ return false;
403
+ }
404
+ return this.db.prepare("UPDATE accounts SET limits_json = ? WHERE id = ?").run(json, accountId).changes > 0;
405
+ }
406
+ limitsFor(acct) { return planLimits(this.plans(), acct.plan, acct.limits_json); }
407
+ planCounts() {
408
+ const out = {};
409
+ for (const r of this.db.prepare("SELECT plan, COUNT(*) c FROM accounts GROUP BY plan").all())
410
+ out[r.plan] = r.c;
411
+ return out;
412
+ }
413
+ planLimitHits() {
414
+ const m = new Map();
415
+ for (const r of this.db.prepare("SELECT actor_account a, COUNT(*) c FROM audit WHERE event = 'plan_limit' AND actor_account IS NOT NULL GROUP BY actor_account").all())
416
+ m.set(r.a, r.c);
417
+ return m;
418
+ }
419
+ recordSpace(name, ownerAccount) {
420
+ this.db.prepare("INSERT INTO spaces (name, owner_account, created_at, deleted_at) VALUES (?, ?, ?, NULL) " +
421
+ "ON CONFLICT(name) DO UPDATE SET owner_account = excluded.owner_account, created_at = excluded.created_at, deleted_at = NULL WHERE spaces.deleted_at IS NOT NULL").run(name, ownerAccount, now());
422
+ }
423
+ markSpaceDeleted(name) {
424
+ this.db.prepare("UPDATE spaces SET deleted_at = ? WHERE name = ? AND deleted_at IS NULL").run(now(), name);
425
+ }
426
+ spaceOwner(name) {
427
+ const r = this.db.prepare("SELECT owner_account FROM spaces WHERE name = ? AND deleted_at IS NULL").get(name);
428
+ return r?.owner_account ? this.accountById(r.owner_account) : null;
429
+ }
430
+ spacesOwned(accountId) {
431
+ return this.db.prepare("SELECT name FROM spaces WHERE owner_account = ? AND deleted_at IS NULL ORDER BY created_at").all(accountId).map((r) => r.name);
432
+ }
433
+ spaceOwners() {
434
+ const m = new Map();
435
+ for (const r of this.db.prepare("SELECT name, owner_account FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL").all())
436
+ m.set(r.name, r.owner_account);
437
+ return m;
438
+ }
439
+ ownedCounts() {
440
+ const m = new Map();
441
+ for (const r of this.db.prepare("SELECT owner_account a, COUNT(*) c FROM spaces WHERE deleted_at IS NULL AND owner_account IS NOT NULL GROUP BY owner_account").all())
442
+ m.set(r.a, r.c);
443
+ return m;
444
+ }
445
+ hasSpaceRecord(name) {
446
+ return !!this.db.prepare("SELECT 1 FROM spaces WHERE name = ?").get(name);
447
+ }
282
448
  allAccounts() {
283
449
  return this.db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all();
284
450
  }
@@ -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,12 +93,18 @@ export function headlessSpawn(opts) {
93
93
  PANTHEON_AGENT: opts.backend.id,
94
94
  PANTHEON_ROLE: "worker",
95
95
  PANTHEON_BRANCH: opts.branch,
96
+ ...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
97
+ ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
98
+ ...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
99
+ ...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
96
100
  };
97
101
  const home = opts.runHome ?? process.env.PANTHEON_HOME;
98
102
  const fileEnv = {
99
103
  PANTHEON_AGENT: opts.backend.id,
100
104
  PANTHEON_ROLE: "worker",
101
105
  PANTHEON_BRANCH: opts.branch,
106
+ ...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
107
+ ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
102
108
  ...(home ? { PANTHEON_HOME: home } : {}),
103
109
  };
104
110
  if (opts.backend.id === "claude-code") {