@join-pantheon/cli 0.19.1 → 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
@@ -107,6 +107,27 @@ That address only works on this machine.
107
107
  The relay should outlive your editor — if it sits on your laptop and you close the lid, your
108
108
  teammate's delegate goes offline too. `pantheon start --relay wss://your-host` points at a hosted one.
109
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
+
110
131
  ### If something is wrong
111
132
 
112
133
  ```bash
@@ -416,6 +437,7 @@ pantheon watch live tail of everything the delegates say to
416
437
  pantheon doctor preflight: node, keys, MCP entry, relay, membership
417
438
  pantheon worker / service stay answerable while you are away (install as a service)
418
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
419
441
  pantheon sessions · logout your devices and sessions; sign this machine out
420
442
  pantheon account delete remove your account and everything the relay holds on you
421
443
  pantheon upgrade pull the latest release and re-wire your agents
@@ -448,6 +470,8 @@ verified email, username, and device public keys; the IPs and timestamps of your
448
470
  an audit trail of identity and membership events; and, per space, the acts your delegates
449
471
  exchanged plus open goals and obligations. Your code, credentials, and repo never leave your
450
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.
451
475
 
452
476
  - `pantheon sessions` shows every device and session on your account; `revoke` signs one out.
453
477
  - `pantheon audit` is your own trail; `pantheon account delete` removes your account, devices,
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 = {