@join-pantheon/cli 0.20.0 → 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
@@ -119,6 +131,7 @@ agent opened in a folder bound to no team refuses to send rather than guess.
119
131
  pantheon spaces # your teams, which repo is bound to which, where this folder points
120
132
  pantheon use mobile --here # bind this repo to a team (and register the repo with it)
121
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)
122
135
  pantheon leave mobile # drop one team from this machine
123
136
  pantheon profile set --space mobile --human-required pricing # authority that applies in one team only
124
137
  ```
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";
@@ -104,7 +105,24 @@ export class Accounts {
104
105
  kind TEXT NOT NULL,
105
106
  created_at INTEGER NOT NULL
106
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);
107
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");
108
126
  }
109
127
  close() { this.db.close(); }
110
128
  accountByUsername(username) {
@@ -201,7 +219,7 @@ export class Accounts {
201
219
  else {
202
220
  const id = newId("acct");
203
221
  run("INSERT INTO accounts (id, email, username, status, created_at, tos_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, e, v.pending_username, now(), now());
204
- 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 };
205
223
  }
206
224
  }
207
225
  else {
@@ -340,6 +358,93 @@ export class Accounts {
340
358
  recentVerifications(limit = 50) {
341
359
  return this.db.prepare("SELECT email, purpose, created_at, expires_at, attempts FROM verifications ORDER BY created_at DESC LIMIT ?").all(limit);
342
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
+ }
343
448
  allAccounts() {
344
449
  return this.db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all();
345
450
  }
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.base ? { PANTHEON_BASE: opts.base } : {}),
97
+ ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
96
98
  ...(opts.cfg.repo ? { PANTHEON_REPO: opts.cfg.repo } : {}),
97
99
  ...(opts.cfg.spaceId ? { PANTHEON_SPACE_ID: opts.cfg.spaceId } : {}),
98
100
  };
@@ -101,6 +103,8 @@ export function headlessSpawn(opts) {
101
103
  PANTHEON_AGENT: opts.backend.id,
102
104
  PANTHEON_ROLE: "worker",
103
105
  PANTHEON_BRANCH: opts.branch,
106
+ ...(opts.base ? { PANTHEON_BASE: opts.base } : {}),
107
+ ...(opts.goal ? { PANTHEON_GOAL: opts.goal } : {}),
104
108
  ...(home ? { PANTHEON_HOME: home } : {}),
105
109
  };
106
110
  if (opts.backend.id === "claude-code") {
package/dist/cli.js CHANGED
@@ -9,14 +9,16 @@ process.emitWarning = ((warning, ...rest) => {
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { PantheonClient } from "./client.js";
12
- import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY } from "./config.js";
12
+ import { fromInvite, load, save, toInvite, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
13
+ import { fmtLimit } from "./plans.js";
13
14
  import { validEmail, validUsername } from "./accounts.js";
14
15
  import { install, launcher } from "./install.js";
15
16
  import { Relay } from "./relay.js";
16
17
  import { runMcp } from "./mcp.js";
17
18
  import { Worker } from "./worker.js";
18
19
  import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
19
- import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile } from "./profile.js";
20
+ import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
21
+ import { runGuide, terminalIO } from "./setup-guide.js";
20
22
  import { doctor, render } from "./doctor.js";
21
23
  import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
22
24
  import { assess } from "./untrusted.js";
@@ -266,6 +268,39 @@ function unattendedProject() {
266
268
  return { project: resolve(flag("project", ".")), how: "explicit" };
267
269
  return { project: resolve("."), how: "default" };
268
270
  }
271
+ async function guidedSetup(cfg, project, opts = {}) {
272
+ if (argv.includes("--no-guide"))
273
+ return;
274
+ const interactive = !!process.stdin.isTTY && !argv.includes("--yes");
275
+ if (!interactive && !opts.force)
276
+ return;
277
+ const rl = interactive ? createInterface({ input: process.stdin, output: process.stderr }) : null;
278
+ const io = rl ? terminalIO(rl, (l) => console.error(l)) : null;
279
+ console.error(`\n${B("A few choices, then you're done.")}${io ? "" : D(" (no terminal — taking the defaults)")}`);
280
+ try {
281
+ const r = await runGuide(io, { cfg, project }, {
282
+ publish: async (p) => {
283
+ const c = new PantheonClient(cfg, undefined, { role: "cli" });
284
+ try {
285
+ await c.connect();
286
+ await c.publishProfile(organizationProfile(p, cfg.spaceId, cfg.space));
287
+ }
288
+ finally {
289
+ c.close();
290
+ }
291
+ },
292
+ });
293
+ console.error("");
294
+ for (const l of r.applied)
295
+ console.error(` ${C("✓")} ${l}`);
296
+ for (const w of r.warnings)
297
+ console.error(`\x1b[33m ! ${w}\x1b[0m`);
298
+ console.error(D(`\n Change any of this later: pantheon setup`));
299
+ }
300
+ finally {
301
+ rl?.close();
302
+ }
303
+ }
269
304
  function printReady(tools) {
270
305
  const workerOn = tools.some((t) => t.tool === "worker" && t.status.startsWith("on"));
271
306
  console.error(`\n${B("You're set.")} Restart your agent once (quit and reopen) and it is connected.`);
@@ -317,7 +352,9 @@ ${B("several teams on one machine")}
317
352
  pantheon spaces your teams, which repo is bound to which, and where this folder points
318
353
  pantheon use <team> --here bind this repo to a team (agents opened here speak in it)
319
354
  pantheon repo add | list the repos a team works from; a delivery must come from one of them
355
+ (repo add --base <branch>: the branch workers cut from, else origin's default)
320
356
  pantheon leave <team> drop one team from this machine (its token and repo bindings)
357
+ pantheon space delete <team> --yes dissolve a team you founded: disconnects everyone, frees a plan slot
321
358
  pantheon profile set --space <team> … authority that applies in one team only (e.g. --human-required pricing)
322
359
 
323
360
  ${B("day to day")}
@@ -350,6 +387,8 @@ ${B("control")}
350
387
  pantheon leave remove what pantheon put on this machine
351
388
  pantheon panic cut off all unattended work on this machine, now
352
389
  pantheon doctor check this machine end to end
390
+ pantheon setup the guided choices: who may wake this machine, what a run may execute,
391
+ what you personally decide, which alerts you want — re-run any time
353
392
  pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
354
393
 
355
394
  ${D("more: pantheon help --all")}
@@ -428,6 +467,8 @@ async function main() {
428
467
  console.error(D(` ${r.reach.hint}`));
429
468
  }
430
469
  printReady(r.tools);
470
+ if (r.claimed)
471
+ await guidedSetup(load() ?? r.cfg, repoInfo(process.cwd())?.root ?? null);
431
472
  if (r.relay) {
432
473
  console.error(D(`\nKeeping the relay running here. Ctrl-C stops it (your teammate goes offline too).`));
433
474
  await new Promise(() => { });
@@ -440,7 +481,8 @@ async function main() {
440
481
  await client.connect().catch((e) => die(client.pending ?? e.message));
441
482
  await new Promise((r) => setTimeout(r, 250));
442
483
  const ttl = Number(flag("ttl", "1440"));
443
- const inv = await client.makeInvite(!argv.includes("--no-wake"), ttl).catch((e) => die(e.message));
484
+ const mayWake = argv.includes("--may-wake") ? true : argv.includes("--no-wake") ? false : wakeDefault(loadProfile(cfg));
485
+ const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
444
486
  console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
445
487
  console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
446
488
  client.close();
@@ -558,6 +600,11 @@ async function main() {
558
600
  console.error(`\x1b[33m ! ${w}\x1b[0m`);
559
601
  printGranted(r.granted);
560
602
  printReady(r.tools);
603
+ {
604
+ const joined = load();
605
+ if (joined)
606
+ await guidedSetup(joined, repoInfo(process.cwd())?.root ?? null);
607
+ }
561
608
  console.error(`\n${D('Then ask your agent: "check pantheon_status and say hello to the team".')}`);
562
609
  return;
563
610
  }
@@ -618,6 +665,11 @@ async function main() {
618
665
  }
619
666
  return;
620
667
  }
668
+ case "setup": {
669
+ const cfg = need();
670
+ await guidedSetup(cfg, repoInfo(process.cwd())?.root ?? null, { force: true });
671
+ return;
672
+ }
621
673
  case "version": {
622
674
  console.log(VERSION);
623
675
  return;
@@ -636,6 +688,8 @@ async function main() {
636
688
  await new Promise((r) => setTimeout(r, 350));
637
689
  const peers = client.roster.filter((m) => m.id !== cfg.member);
638
690
  console.log(`${B(cfg.space)} you are ${C(cfg.member)} ${D(cfg.relay)} ${D(`${client.head} acts`)}\n`);
691
+ if (client.plan)
692
+ console.log(D(` ${client.plan.name} plan · ${client.plan.usage.spaces} of ${fmtLimit(client.plan.limits.spaces)} spaces created · ${client.plan.usage.members} of ${fmtLimit(client.plan.limits.members)} people here${client.plan.limits.wake ? "" : " · no unattended workers"}`) + "\n");
639
693
  console.log(B("members"));
640
694
  if (!peers.length)
641
695
  console.log(` ${D("(nobody else yet — send them an invite from `pantheon invite`)")}`);
@@ -944,7 +998,7 @@ async function main() {
944
998
  }
945
999
  case "hook": {
946
1000
  const sub = argv[1] ?? "status";
947
- const hookCfg = needStrict();
1001
+ const hookCfg = resolveForHook();
948
1002
  const reportIdle = async (session, seen) => {
949
1003
  if (!hookCfg)
950
1004
  return true;
@@ -1047,7 +1101,7 @@ async function main() {
1047
1101
  const { pending: pendingFn } = await import("./pending.js");
1048
1102
  const { load: loadCfg } = await import("./config.js");
1049
1103
  const d = await decideStop(JSON.stringify({ session_id: session }), {
1050
- pending: async (s) => pendingFn({ session: s }),
1104
+ pending: async (s) => (hookCfg ? pendingFn({ session: s, cfg: hookCfg }) : Promise.resolve(null)),
1051
1105
  ...(loadCfg()?.member ? { me: loadCfg().member } : {}),
1052
1106
  });
1053
1107
  if (d.exit === 2 && d.message) {
@@ -1080,7 +1134,7 @@ async function main() {
1080
1134
  const { pending: pendingFn } = await import("./pending.js");
1081
1135
  const { load: loadCfg } = await import("./config.js");
1082
1136
  const d = await decideStop(raw, {
1083
- pending: async (session) => pendingFn({ session }),
1137
+ pending: async (session) => (hookCfg ? pendingFn({ session, cfg: hookCfg }) : Promise.resolve(null)),
1084
1138
  ...(loadCfg()?.member ? { me: loadCfg().member } : {}),
1085
1139
  });
1086
1140
  if (d.exit === 2 && d.message) {
@@ -1118,7 +1172,9 @@ async function main() {
1118
1172
  let surfaced;
1119
1173
  const d = await decideStop(raw, {
1120
1174
  pending: async (session) => {
1121
- const r = await pendingFn({ session });
1175
+ if (!hookCfg)
1176
+ return null;
1177
+ const r = await pendingFn({ session, cfg: hookCfg });
1122
1178
  if (r)
1123
1179
  surfaced = r.seq;
1124
1180
  return r;
@@ -1188,6 +1244,24 @@ async function main() {
1188
1244
  }
1189
1245
  return;
1190
1246
  }
1247
+ case "space": {
1248
+ const sub = argv[1];
1249
+ const name = argv[2];
1250
+ if (sub !== "delete" || !name || name.startsWith("--"))
1251
+ die("usage: pantheon space delete <team> --yes");
1252
+ const all = loadAll() ?? die("not configured");
1253
+ const m = findMembership(all, name) ?? die(`you are not in a team called "${name}". Yours: ${Object.values(all.spaces ?? {}).map((x) => x.name).join(", ") || "none"}`);
1254
+ if (!argv.includes("--yes"))
1255
+ die(`this deletes "${m.name}" for everyone in it — its history, goals and invites — and cannot be undone.\n If you mean it: pantheon space delete ${m.name} --yes`);
1256
+ const client = new PantheonClient(configFor(all, m, null), undefined, { role: "cli" });
1257
+ await client.connect().catch((e) => die(client.pending ?? e.message));
1258
+ await client.deleteSpace(m.name).catch((e) => { client.close(); die(e.message); });
1259
+ client.close();
1260
+ const bound = Object.values(all.bindings ?? {}).filter((b) => b.space === m.id).length;
1261
+ removeMembership(m.id);
1262
+ console.error(`${B("deleted")} "${m.name}" — everyone in it was disconnected; its token${bound ? ` and ${bound} repo binding${bound === 1 ? "" : "s"}` : ""} left this machine`);
1263
+ return;
1264
+ }
1191
1265
  case "leave": {
1192
1266
  if (argv[1] && !argv[1].startsWith("--")) {
1193
1267
  const all = loadAll() ?? die("not configured");
@@ -2159,16 +2233,17 @@ async function main() {
2159
2233
  await client.connect().catch((e) => die(client.pending ?? e.message));
2160
2234
  if (sub === "add") {
2161
2235
  const r = repoInfo(flag("path") ?? process.cwd()) ?? die("not inside a git repo");
2162
- await client.bindRepo(r.fingerprint, r.origin).catch((e) => die(e.message));
2236
+ const base = flag("base") ?? null;
2237
+ await client.bindRepo(r.fingerprint, r.origin, base).catch((e) => die(e.message));
2163
2238
  if (cfg.spaceId)
2164
2239
  bindRepo(r, cfg.spaceId);
2165
- console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
2240
+ console.error(`${B("registered")} ${r.origin ?? r.root} as a repo "${cfg.space}" works from${base ? ` — workers cut from ${C(base)}` : ""}${r.shared ? "" : D(" (no origin remote — deliveries from it cannot be fetched)")}`);
2166
2241
  }
2167
2242
  else {
2168
2243
  await new Promise((res) => setTimeout(res, 150));
2169
2244
  console.error(`${B(`repos "${cfg.space}" works from`)} ${D(`(${client.repos.length})`)}`);
2170
2245
  for (const r of client.repos)
2171
- console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}`);
2246
+ console.error(` ${(r.origin ?? r.fingerprint).padEnd(48)} ${D(`added by ${r.added_by}`)}${r.base ? ` ${D("base")} ${C(r.base)}` : ""}`);
2172
2247
  if (!client.repos.length)
2173
2248
  console.error(D(" none yet — the first member to connect from a checkout registers it"));
2174
2249
  }
package/dist/client.js CHANGED
@@ -38,6 +38,7 @@ export class PantheonClient {
38
38
  enrolledToken = null;
39
39
  spaceId = null;
40
40
  repos = [];
41
+ plan = null;
41
42
  enroll = null;
42
43
  inviteUrl = null;
43
44
  constructor(cfg, onEvent, opts) {
@@ -157,6 +158,7 @@ export class PantheonClient {
157
158
  this.cfg.spaceId = msg.space_id;
158
159
  }
159
160
  this.repos = msg.repos ?? [];
161
+ this.plan = msg.plan ?? null;
160
162
  this.adoptKnownRepo();
161
163
  this.backoff = 500;
162
164
  this.roster = msg.roster;
@@ -375,29 +377,42 @@ export class PantheonClient {
375
377
  if (c.cwdRepoOrigin)
376
378
  c.repoOrigin = c.cwdRepoOrigin;
377
379
  }
378
- bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null) {
380
+ reportWorkerState(state, detail, until) {
381
+ try {
382
+ this.ws?.send(JSON.stringify({ t: "worker_state", state, ...(detail ? { detail: detail.slice(0, 200) } : {}), ...(until ? { until } : {}) }));
383
+ }
384
+ catch { }
385
+ }
386
+ deleteSpace(confirm) {
387
+ return this.rpc({ t: "delete_space", confirm });
388
+ }
389
+ bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null, base = null) {
379
390
  if (!fingerprint)
380
391
  return Promise.reject(new Error("not inside a git repo — run this from the team's checkout"));
381
- return this.rpc({ t: "bind_repo", repo: fingerprint, origin });
392
+ return this.rpc({ t: "bind_repo", repo: fingerprint, origin, ...(base ? { base } : {}) });
382
393
  }
383
394
  makeInvite(mayWake = true, ttlMinutes = 60) {
384
395
  return new Promise((res, rej) => {
385
- const t = setTimeout(() => rej(new Error("relay did not return an invite in 10s")), 10_000);
396
+ const cid = `c${++this.cid}`;
397
+ const finish = () => { clearInterval(poll); clearTimeout(t); this.acks.delete(cid); };
398
+ const t = setTimeout(() => { finish(); rej(new Error("relay did not return an invite in 10s")); }, 10_000);
386
399
  const poll = setInterval(() => {
387
400
  if (this.inviteUrl) {
388
- clearInterval(poll);
389
- clearTimeout(t);
401
+ finish();
390
402
  const v = this.inviteUrl;
391
403
  this.inviteUrl = null;
392
404
  res(v);
393
405
  }
394
406
  }, 40);
407
+ this.acks.set(cid, (v) => { if ("error" in v) {
408
+ finish();
409
+ rej(new Error(v.error));
410
+ } });
395
411
  try {
396
- this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes }));
412
+ this.ws?.send(JSON.stringify({ t: "invite", may_wake: mayWake, ttl_minutes: ttlMinutes, cid }));
397
413
  }
398
414
  catch (e) {
399
- clearInterval(poll);
400
- clearTimeout(t);
415
+ finish();
401
416
  rej(e);
402
417
  }
403
418
  });
package/dist/config.js CHANGED
@@ -153,6 +153,20 @@ export function resolveFor(cwd = process.cwd(), opts = {}) {
153
153
  }
154
154
  return { cfg: null, reason: "unbound", memberships: n, repo };
155
155
  }
156
+ export function resolveForHook(cwd = process.cwd()) {
157
+ const r = resolveFor(cwd, { strict: true });
158
+ if (!r.cfg?.space)
159
+ return null;
160
+ if (r.reason !== "single" || !r.repo)
161
+ return r.cfg;
162
+ const f = loadAll();
163
+ const m = Object.values(f?.spaces ?? {})[0];
164
+ const bound = Object.values(f?.bindings ?? {}).filter((b) => b.space === m?.id);
165
+ if (!bound.length)
166
+ return r.cfg;
167
+ const here = r.repo;
168
+ return bound.some((b) => b.path === here.root || (!!b.origin && b.origin === here.origin)) ? r.cfg : null;
169
+ }
156
170
  export function load() {
157
171
  return resolveFor(process.cwd()).cfg;
158
172
  }
package/dist/doctor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { loadProfile, profileInitialized } from "./profile.js";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { PantheonClient } from "./client.js";
@@ -74,6 +75,14 @@ export async function doctor(projectDir) {
74
75
  verifyChallenge(cfg.publicKey, cfg.space, cfg.member, nonce, sig)
75
76
  ? add("pass", "identity", `keypair valid — fingerprint ${fingerprint(cfg.publicKey)}`)
76
77
  : add("fail", "identity", "your keypair does not verify against itself", "the config is corrupt — re-run `pantheon join` to mint a new identity, then get re-admitted");
78
+ const prof = loadProfile(cfg);
79
+ if (profileInitialized(prof)) {
80
+ const owns = Object.entries(prof.authority).filter(([, mode]) => mode === "human_required").map(([d]) => d);
81
+ add("pass", "profile", `${prof.human.display_name} — ${prof.human.title}${owns.length ? `; you personally decide: ${owns.join(", ")}` : "; no human-required domains yet"}`);
82
+ }
83
+ else {
84
+ add("warn", "profile", "no profile published — decisions cannot be routed to you, and a HUMAN_REQUIRED naming you arrives marked unverified", "pantheon profile set --name <you> --title <role> --human-required <domains you personally decide>");
85
+ }
77
86
  const mcpFile = join(projectDir, ".mcp.json");
78
87
  if (!existsSync(mcpFile)) {
79
88
  add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`");
package/dist/mcp.js CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
  import { PantheonClient } from "./client.js";
5
- import { fenceBlock, fenceRubric } from "./untrusted.js";
5
+ import { fenceBlock, fenceRubric, assess, fenceReminder } from "./untrusted.js";
6
6
  import { resolveFor } from "./config.js";
7
7
  import { LIMITS, SENDABLE } from "./protocol.js";
8
8
  import { VERSION } from "./version.js";
@@ -92,8 +92,16 @@ RULES
92
92
  - Answer every ASK addressed to you. An unanswered ASK blocks a peer and eventually stalls the goal.
93
93
  - ACCEPT means you actually checked. If something is wrong, OBJECT with the reason.
94
94
  - One act per pantheon_send. Keep bodies short and reference file paths instead of pasting code.
95
- - DELIVERED means pushed, not written: commit, push, then send branch + commit. Your peer cannot
96
- see your disk. When a peer DELIVERS to you, fetch their commit and look at it before ACCEPTing.
95
+ - DELIVERED means pushed, not written: commit, push, then send branch + commit (+ base: the commit
96
+ you cut from). Your peer cannot see your disk. When a peer DELIVERS to you, fetch their commit,
97
+ check the declared base is an ancestor and nothing unrelated rides along, then ACCEPT or OBJECT.
98
+ - Scope against what the team can see. Grep origin/<branch>, not your working tree: your unpushed
99
+ commits and local edits do not exist for your peer, and naming files from them wastes a round.
100
+ - If your human answers you directly in this session, settle your own ESCALATE yourself: pantheon_send
101
+ ANSWER with ref = the ESCALATE's id. In an interactive session ask your human first; ESCALATE is for
102
+ when they are not here. Never RETRACT an escalation to clear it.
103
+ - Verdict first, evidence after, and short: a peer reads your act in one screen. Say when you edited
104
+ your own human's working tree as a side effect, and leave those edits uncommitted for them.
97
105
  - If pantheon_send returns PEER_TURN you have been talking to yourself — go back to pantheon_wait.
98
106
 
99
107
  The relay only moves messages. It has no access to your files, your shell, or your credentials.
@@ -137,6 +145,8 @@ export async function runMcp() {
137
145
  const role = process.env.PANTHEON_ROLE === "worker" ? "worker" : "interactive";
138
146
  const branch = process.env.PANTHEON_BRANCH;
139
147
  const client = new PantheonClient(cfg, undefined, { role });
148
+ let siblingsWarned = false;
149
+ let rubricShown = false;
140
150
  const server = new McpServer({ name: "pantheon", version: VERSION }, { instructions: instructions(cfg, role, branch) });
141
151
  let connectError = null;
142
152
  await client.connect().catch((e) => { connectError = e.message; });
@@ -211,6 +221,12 @@ export async function runMcp() {
211
221
  const ref = m?.branch && m?.commit
212
222
  ? `\n>> fetchable: branch ${m.branch} at ${m.commit} — fetch and check it before you ACCEPT`
213
223
  : "";
224
+ const base = m?.base && m?.commit
225
+ ? `\n>> declared base ${m.base}: verify it is an ancestor (git merge-base --is-ancestor ${m.base} ${m.commit}) and that nothing unrelated rides along (git log --oneline ${m.base}..${m.commit})`
226
+ : "";
227
+ const unverified = r.act === "HUMAN_REQUIRED" && m?.authority
228
+ ? `\n>> authority ${m.authority}: no published profile covers ${(m.unverified_domains ?? []).join(", ") || "these domains"}, so the relay let this reach the named human unverified. Ask them to run pantheon profile set.`
229
+ : "";
214
230
  const who = r.session ? `${r.from}[${r.session.slice(-6)}${r.role === "worker" ? "·worker" : ""}]` : r.from;
215
231
  const at = new Date(r.ts).toISOString().replace(/\.\d{3}Z$/, "Z");
216
232
  const safeBody = fenceBlock(who, r.body).block;
@@ -223,13 +239,25 @@ export async function runMcp() {
223
239
  .map((o) => `\n!! this ${r.act} dissolves obligation ${o.id}, which you had claimed (${o.body}) — it is no longer owed; stop working on it.`)
224
240
  .join("");
225
241
  return `[${r.seq}] ${r.act} from ${who}${r.to ? ` to ${r.to.join(",")}` : ""}` +
226
- `${r.goal ? ` (goal ${r.goal})` : ""} at ${at} id=${r.id}${r.ref ? ` ref=${r.ref}` : ""}${lead}${stale}\n${safeBody}${ref}${dissolved}`;
242
+ `${r.goal ? ` (goal ${r.goal})` : ""} at ${at} id=${r.id}${r.ref ? ` ref=${r.ref}` : ""}${lead}${stale}\n${safeBody}${ref}${base}${unverified}${dissolved}`;
243
+ };
244
+ const fmtAll = (acts) => {
245
+ const flagged = acts.some((a) => assess(a.body).length > 0);
246
+ const rubric = !rubricShown || flagged ? fenceRubric(acts.length) : fenceReminder(acts.length);
247
+ rubricShown = true;
248
+ return acts.map(fmt).join("\n\n---\n\n") + `\n\n${rubric}`;
227
249
  };
228
- const fmtAll = (acts) => acts.map(fmt).join("\n\n---\n\n") + `\n\n${fenceRubric(acts.length)}`;
229
250
  const situation = () => {
230
251
  const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
231
252
  const reach = (p) => {
232
253
  const head = client.head;
254
+ const w = p.worker;
255
+ const worker = w?.state === "holding"
256
+ ? `; their worker is holding — ${w.detail ?? "deferring to a live session"}${w.until && w.until > Date.now() ? `, ~${Math.round((w.until - Date.now()) / 1000)}s more` : ""}`
257
+ : w?.state === "running" ? `; their worker is running${w.detail ? ` (${w.detail})` : ""}` : "";
258
+ return reachText(p, head) + worker;
259
+ };
260
+ const reachText = (p, head) => {
233
261
  if (p.seen_seq === undefined) {
234
262
  return p.delivered_seq !== undefined && p.delivered_seq >= head
235
263
  ? ", delivered but no read-receipt — they have no wake-up hook, so it waits for their human"
@@ -245,10 +273,13 @@ export async function runMcp() {
245
273
  const peers = client.roster.filter((m) => m.id !== cfg.member);
246
274
  const siblings = (client.roster.find((m) => m.id === cfg.member)?.sessions ?? [])
247
275
  .filter((s) => s.id !== client.session);
276
+ const warnSiblings = siblings.length && !siblingsWarned;
277
+ if (siblings.length)
278
+ siblingsWarned = true;
248
279
  return [
249
280
  `you are: ${cfg.member} (${cfg.agent ?? "unknown agent"}, session ${client.session}, role ${role}) space: ${cfg.space} relay: ${client.connected ? "connected" : "OFFLINE"}`,
250
281
  siblings.length
251
- ? `your other sessions: ${siblings.map((s) => `${s.id}[${s.role}]`).join(" ")} — claim work before doing it, or you will duplicate theirs`
282
+ ? `your other sessions: ${siblings.map((s) => `${s.id}[${s.role}]`).join(" ")}${warnSiblings ? " — claim work before doing it, or you will duplicate theirs" : ""}`
252
283
  : `your other sessions: none`,
253
284
  peers.length
254
285
  ? `peers: ${peers.map((p) => `${p.id} [${p.online ? "online" : "offline"}, ${p.agent}${reach(p)}]`).join(" ")}`
@@ -319,7 +350,7 @@ export async function runMcp() {
319
350
  : `ASK ${owner.member}'s agent about ${owner.domains.join(", ")}.`);
320
351
  }
321
352
  if (route.unmatched_domains.length || !route.domains.length) {
322
- next.push(`Authority is unresolved${route.unmatched_domains.length ? ` for ${route.unmatched_domains.join(", ")}` : ""}; ESCALATE to your own human instead of guessing.`);
353
+ next.push(`No published profile owns${route.unmatched_domains.length ? ` ${route.unmatched_domains.join(", ")}` : " these domains"}. If one named peer's human clearly must decide, HUMAN_REQUIRED to them still goes through, marked unverified — and ask them to run pantheon profile set. If it is your own human's call, ESCALATE. Do not guess an owner.`);
323
354
  }
324
355
  return text(`${JSON.stringify(route, null, 2)}\n\nNEXT\n${next.map((x) => `- ${x}`).join("\n")}`);
325
356
  }));
@@ -362,16 +393,19 @@ export async function runMcp() {
362
393
  ref: z.string().optional().describe("id of the act you are responding to; required for ANSWER/ACCEPT/OBJECT"),
363
394
  branch: z.string().optional().describe("REQUIRED for DELIVERED: the branch you pushed the work to"),
364
395
  commit: z.string().optional().describe("REQUIRED for DELIVERED: the commit sha your peer should fetch and check"),
396
+ base: z.string().optional().describe("for DELIVERED: the commit your branch was cut from, so your peer can check nothing unrelated rides along. Worker runs fill this in automatically."),
365
397
  remote: z.string().optional().describe("for DELIVERED: the git remote your peer should fetch from (https://, ssh://, or user@host:path). Without it they have to guess that your origin is theirs."),
366
398
  defers_to: z.string().optional().describe("on CLOSE: the goal id you are standing down in favour of. The relay refuses this if that goal is closed or is itself deferring, because both sides standing down at once leaves the work with nowhere to live."),
367
399
  affects: z.array(z.string()).optional().describe("on PROPOSE: whose agreement this actually needs. Without it EVERY member of the goal owes a verdict — including anyone offline — so a three-person goal stalls on whoever is away."),
368
400
  },
369
- }, async ({ act, body, goal, to, ref, branch, commit, remote, decision_domains, summary, defers_to, affects }) => guard(async () => {
370
- const meta = branch || commit || remote || decision_domains?.length
401
+ }, async ({ act, body, goal, to, ref, branch, commit, remote, base, decision_domains, summary, defers_to, affects }) => guard(async () => {
402
+ const baseSha = act === "DELIVERED" ? (base ?? process.env.PANTHEON_BASE) : undefined;
403
+ const meta = branch || commit || remote || baseSha || decision_domains?.length
371
404
  ? {
372
405
  ...(branch ? { branch } : {}),
373
406
  ...(commit ? { commit } : {}),
374
407
  ...(remote ? { remote } : {}),
408
+ ...(baseSha ? { base: baseSha } : {}),
375
409
  ...(decision_domains?.length ? { decision_domains } : {}),
376
410
  }
377
411
  : undefined;