@join-pantheon/cli 0.21.3 → 0.23.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
@@ -45,21 +45,32 @@ out with `pantheon sessions`; every identity and membership event is in `pantheo
45
45
  audit`. (Self-hosting or on a LAN/tailnet? Accounts are optional — skip straight
46
46
  to `pantheon start`.)
47
47
 
48
- **Then, from your project's repo:**
48
+ **Then, from your project's repo — or anywhere, if there is no repo yet (see below):**
49
49
 
50
50
  ```bash
51
51
  pantheon start
52
52
  ```
53
53
 
54
- **Your teammate, from their clone of the project:**
54
+ **Your teammate, from their clone of the project if they have one:**
55
55
 
56
56
  ```bash
57
57
  pantheon join pantheons://relay.joinpantheon.network/acme#C1qhJfdHBaPjuG3jF48vcqrP
58
58
  ```
59
59
 
60
+ On the hosted relay, `pantheon start` and `pantheon invite` also print the same invite as a link,
61
+ `https://www.joinpantheon.network/join/acme#C1qhJfdHBaPjuG3jF48vcqrP`. Pasted into a chat it
62
+ unfurls with the team's name and hands the recipient the line for their agent. The token stays in the
63
+ URL fragment, which browsers never send to any server. `pantheon join` accepts either form.
64
+
60
65
  Signed in, `pantheon start` puts your space on the hosted relay, so that link works from any
61
- network — no tunnel, no VPN. The space is named after your repo; if another team already took
62
- that name, start picks `<repo>-<you>` and says so (or choose your own with `--space`).
66
+ network — no tunnel, no VPN. The space is named after the folder you run it from (your repo,
67
+ usually); if another team already took that name, start picks `<name>-<you>` and says so. Choose
68
+ your own with `--space` — always do that when there is no repo to name it after.
69
+
70
+ **You do not need a git repository.** Messages, questions, decisions and escalations work from any
71
+ folder. A repo adds exactly two things: handing over code (a `DELIVERED` names a pushed branch and
72
+ commit) and letting a teammate wake this machine while you are away; `pantheon repo add` binds one
73
+ whenever you have it.
63
74
 
64
75
  That's it — one command each sets up the **whole machine**: a relay and space (you) or membership
65
76
  (them), pantheon wired into whichever agents each of you already has (**user scope** — every project,
@@ -67,13 +78,17 @@ every Conductor window, nothing to commit), the wake-up hook so an agent finishe
67
78
  waiting for, the **worker** so the machine answers the team even while its human is away (on by
68
79
  default when run inside a git repo; `--no-worker` opts out — and a peer still needs an explicit
69
80
  `--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, 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 — and an agent setting you up can run `pantheon setup --questions`, ask you in chat, and pass your answers back as flags, so nobody has to open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
81
+ ends with a "You're set" summary, then a short guided setup on a terminal: three questions — your
82
+ name, your role, and (inside a repo) whether a run a teammate wakes may execute commands — and one
83
+ screen of defaults to keep or change: who may wake your machine while you are away, which alerts you
84
+ want, which decisions are yours alone (so agents stop and ask you), the worker's playbook and its base
85
+ branch. `pantheon setup` re-runs it any time. An agent setting you up runs `pantheon setup --questions`,
86
+ asks you in chat, and passes your answers back as flags — to `pantheon setup --yes`, or straight to
87
+ `pantheon start` / `pantheon join`, which apply them the moment the team exists — so nobody has to
88
+ open a terminal. Restart your agent once and ask it *"check pantheon_status"*.
74
89
 
75
90
  Already a member — new laptop, or just pulled a newer build? The same full setup is one command:
76
- `pantheon install --user` (from your project's repo).
91
+ `pantheon install --user` (from your project's repo if you have one).
77
92
 
78
93
  **Consent happens once, here.** Setup pre-approves exactly the six pantheon *message* tools in
79
94
  each agent's own permission system (Claude Code `permissions.allow`, Cursor `Mcp()` tokens,
@@ -451,6 +466,7 @@ pantheon doctor preflight: node, keys, MCP entry, relay, memb
451
466
  pantheon worker / service stay answerable while you are away (install as a service)
452
467
  pantheon persona list / init / show — what a woken worker may do
453
468
  pantheon spaces / use / repo several teams on one machine — what is bound where; bind this repo; a team's repos
469
+ pantheon history / show read the thread, or one act, in full with ids — from a terminal, no agent needed
454
470
  pantheon sessions · logout your devices and sessions; sign this machine out
455
471
  pantheon account delete remove your account and everything the relay holds on you
456
472
  pantheon upgrade pull the latest release and re-wire your agents
package/dist/cli.js CHANGED
@@ -8,9 +8,9 @@ process.emitWarning = ((warning, ...rest) => {
8
8
  });
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { readFileSync } from "node:fs";
11
- import { dirname, join, resolve } from "node:path";
11
+ import { basename, dirname, join, resolve } from "node:path";
12
12
  import { PantheonClient } from "./client.js";
13
- 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 { fromInvite, load, save, toInvite, toJoinLink, resolveFor, loadAll, repoInfo, bindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
14
14
  import { fmtLimit } from "./plans.js";
15
15
  import { validEmail, validUsername } from "./accounts.js";
16
16
  import { install, launcher } from "./install.js";
@@ -18,8 +18,10 @@ import { Relay } from "./relay.js";
18
18
  import { runMcp } from "./mcp.js";
19
19
  import { Worker } from "./worker.js";
20
20
  import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
21
+ import { desktopUnavailable, hostEnv } from "./host-env.js";
21
22
  import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault } from "./profile.js";
22
23
  import { runGuide, terminalIO, parseSetupFlags, questionnaire } from "./setup-guide.js";
24
+ import { selectActs, formatAct, resolveRef } from "./history-view.js";
23
25
  import { doctor, render } from "./doctor.js";
24
26
  import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
25
27
  import { assess } from "./untrusted.js";
@@ -321,6 +323,13 @@ async function guidedSetup(cfg, project, opts = {}) {
321
323
  rl?.close();
322
324
  }
323
325
  }
326
+ async function fetchHistory(cfg, since = 0) {
327
+ const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
328
+ const res = await fetch(`${base}/history?space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}&since=${since}`, { signal: AbortSignal.timeout(8000) });
329
+ if (!res.ok)
330
+ throw new Error(`relay answered ${res.status} for the ledger`);
331
+ return (await res.json()).acts ?? [];
332
+ }
324
333
  function workerMidRun() {
325
334
  try {
326
335
  const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
@@ -365,10 +374,16 @@ ${B("first time — verify your email once")}
365
374
  pantheon login --email you@work.com a second machine, same account
366
375
  pantheon whoami · pantheon sessions who you are; your devices & sessions
367
376
 
368
- ${B("getting going — then two commands, run from your project's repo")}
377
+ ${B("getting going — two commands, from your project's repo if you have one")}
369
378
  pantheon start you: sets up this whole machine + one line to send your teammate
370
- pantheon join <line> them: that one line sets up their whole machine
371
- pantheon invite another single-use line, for a third person or a second machine
379
+ pantheon join <invite> them: the link (or the line) sets up their whole machine
380
+ pantheon invite another single-use invite, for a third person or a second machine
381
+ (--list shows the open ones; --revoke <token|all> cancels)
382
+
383
+ Both take the setup answers as flags (--name, --title, --wake, --commands, … — pantheon setup
384
+ --questions lists them), so an agent sets a person up in one command. No repo? Messages, questions
385
+ and decisions work anywhere; a repo only adds handing over code and being woken. Name the team
386
+ with --space when there is no repo to name it after.
372
387
 
373
388
  One command does everything: agents wired and pre-approved, wake-up hook (your
374
389
  agent finishes what a peer is waiting for), worker (this machine answers the
@@ -416,9 +431,11 @@ ${B("control")}
416
431
  pantheon leave remove what pantheon put on this machine
417
432
  pantheon panic cut off all unattended work on this machine, now
418
433
  pantheon doctor check this machine end to end
419
- pantheon setup the guided choices: who may wake this machine, what a run may execute,
420
- what you personally decide, which alerts you want — re-run any time
421
- (--questions prints them for an agent to ask you; answers go back as flags)
434
+ pantheon setup three questions — your name, your role, may a woken run execute commands —
435
+ then one screen of defaults to keep or change: who may wake this machine,
436
+ alerts, what you personally decide, playbook, base branch. Re-run any time.
437
+ (--questions prints them for an agent to ask you; answers go back as flags,
438
+ to setup --yes or straight to start / join)
422
439
  pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
423
440
 
424
441
  ${D("more: pantheon help --all")}
@@ -432,6 +449,9 @@ ${B("less common")}
432
449
  pantheon whoami your name and fingerprint
433
450
  pantheon pending what a live agent window has not yet seen
434
451
  pantheon say <ACT> <text> send one act by hand (NOTE, ASK, ANSWER, PROPOSE, ...)
452
+ replies pick the act they answer; --ref <id | seq | last> to choose
453
+ pantheon history the thread in full, with ids (--goal <id>, --from <who>, --last N, --short)
454
+ pantheon show <id | seq> one act in full, and what it replied to
435
455
  pantheon invite mint another single-use invite link to send
436
456
  pantheon install re-wire this machine's agents (after a Node/agent change)
437
457
  pantheon mcp the server your agent runs; not for you
@@ -457,9 +477,14 @@ async function main() {
457
477
  switch (cmd) {
458
478
  case "start": {
459
479
  const port = Number(flag("port", "8787"));
480
+ const setupFlags = parseSetupFlags(argv);
481
+ if (setupFlags.errors.length)
482
+ die(setupFlags.errors.join("\n") + "\n pantheon setup --questions lists every answer");
460
483
  const wp = unattendedProject();
461
484
  requireSignInOnHostedRelay(flag("relay"));
462
485
  console.error(`${B("pantheon start")}\n`);
486
+ if (!flag("space") && !repoInfo(process.cwd()))
487
+ console.error(D(` No git repo here, so the team will be named after this folder: "${basename(process.cwd()).toLowerCase().replace(/[^a-z0-9._-]/g, "-") || "team"}". Pass --space <name> to choose.\n`));
463
488
  const r = await qsStart({
464
489
  port,
465
490
  dataDir: resolveDataDir(flag("data")).dir,
@@ -487,12 +512,21 @@ async function main() {
487
512
  process.exit(1);
488
513
  }
489
514
  if (r.invite) {
490
- console.error(`\n${B("Send your teammate this one line — it sets up their whole machine:")}\n`);
491
- console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
492
- console.error(D(` Tell them to run it from their project's git repo. Single use, expires in ${Math.round(Number(flag("ttl", "1440")) / 60)}h.`));
515
+ const link = toJoinLink(fromInvite(r.invite));
516
+ if (link) {
517
+ console.error(`\n${B("Send your teammate this link — it unfurls in chat, and it sets up their whole machine:")}\n`);
518
+ console.error(` ${C(link)}\n`);
519
+ console.error(D(` The same invite as a command, for pasting straight into an agent:`));
520
+ console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
521
+ }
522
+ else {
523
+ console.error(`\n${B("Send your teammate this one line — it sets up their whole machine:")}\n`);
524
+ console.error(` ${C(`pantheon join ${r.invite}`)}\n`);
525
+ }
526
+ console.error(D(` From their project's repo if they have one — a repo is only needed to hand over code or to be woken. Single use, expires in ${Math.round(Number(flag("ttl", "1440")) / 60)}h.`));
493
527
  console.error(D(` Redeeming it admits them — no separate approval step. Need another? \`pantheon invite\``));
494
528
  console.error(D(`\n Not technical? Forward them this instead, and their agent does the rest:`));
495
- console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${r.invite}`)}`);
529
+ console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${link ?? r.invite}`)}`);
496
530
  }
497
531
  if (!r.reach.shareable && r.reach.hint) {
498
532
  console.error(`\n\x1b[33m That address only works on this machine.\x1b[0m`);
@@ -500,7 +534,7 @@ async function main() {
500
534
  }
501
535
  printReady(r.tools);
502
536
  if (r.claimed)
503
- await guidedSetup(load() ?? r.cfg, repoInfo(process.cwd())?.root ?? null);
537
+ await guidedSetup(load() ?? r.cfg, repoInfo(process.cwd())?.root ?? null, { preset: setupFlags.preset, force: Object.keys(setupFlags.preset).length > 0 || argv.includes("--yes") });
504
538
  if (r.relay) {
505
539
  console.error(D(`\nKeeping the relay running here. Ctrl-C stops it (your teammate goes offline too).`));
506
540
  await new Promise(() => { });
@@ -512,13 +546,43 @@ async function main() {
512
546
  const client = new PantheonClient(cfg, undefined, { role: "cli" });
513
547
  await client.connect().catch((e) => die(client.pending ?? e.message));
514
548
  await new Promise((r) => setTimeout(r, 250));
549
+ const revokeAt = argv.indexOf("--revoke");
550
+ const revokeArg = revokeAt >= 0 ? (argv[revokeAt + 1] && !argv[revokeAt + 1].startsWith("--") ? argv[revokeAt + 1] : "all") : undefined;
551
+ if (argv.includes("--list") || revokeArg !== undefined) {
552
+ const open = await client.listInvites().catch((e) => die(e.message));
553
+ if (revokeArg !== undefined) {
554
+ const targets = revokeArg === "all" ? open.filter((i) => i.mine) : open.filter((i) => i.token === revokeArg || i.token.endsWith(revokeArg.replace(/^…/, "")));
555
+ if (!targets.length) {
556
+ client.close();
557
+ die(revokeArg === "all" ? "no open invites of yours to revoke" : `no open invite matches "${revokeArg}" — pantheon invite --list shows them`);
558
+ }
559
+ for (const i of targets) {
560
+ await client.revokeInvite(i.token).catch((e) => { client.close(); return die(e.message); });
561
+ console.log(`revoked …${i.token.slice(-6)} (minted by ${i.created_by}, would have expired ${new Date(i.expires_at).toLocaleString()})`);
562
+ }
563
+ client.close();
564
+ return;
565
+ }
566
+ if (!open.length)
567
+ console.log(D("no open invites"));
568
+ for (const i of open)
569
+ console.log(`${i.mine ? C(i.token) : D(i.token)} by ${i.created_by} expires ${new Date(i.expires_at).toLocaleString()}${i.may_wake ? " may wake" : ""}${i.mine ? "" : D(" (not yours — masked)")}`);
570
+ if (open.length)
571
+ console.log(D(`\nrevoke one: pantheon invite --revoke <token or its last 6 characters> · all of yours: pantheon invite --revoke all`));
572
+ client.close();
573
+ return;
574
+ }
515
575
  const ttl = Number(flag("ttl", "1440"));
516
576
  const mayWake = argv.includes("--may-wake") ? true : argv.includes("--no-wake") ? false : wakeDefault(loadProfile(cfg));
517
577
  const inv = await client.makeInvite(mayWake, ttl).catch((e) => die(e.message));
518
- console.log(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`);
578
+ const raw = toInvite({ ...cfg, token: inv.url });
579
+ const link = toJoinLink({ relay: cfg.relay, space: cfg.space, token: inv.url });
580
+ console.log(`pantheon join ${raw}`);
581
+ if (link)
582
+ console.error(`\n${D("Or send this — it unfurls in chat with the team's name, and pantheon join accepts it too:")}\n ${C(link)}`);
519
583
  console.error(D(`\nSingle use, expires ${new Date(inv.expires_at).toLocaleString()}.`));
520
584
  console.error(D(`For a teammate who is not technical, forward this and their agent does the rest:`));
521
- console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${toInvite({ ...cfg, token: inv.url })}`)}`);
585
+ console.error(` ${D("Tell your agent: run")} ${C("curl -fsSL https://www.joinpantheon.network/skills/join.md")} ${D("and follow it. Invite:")} ${C(`pantheon join ${link ?? raw}`)}`);
522
586
  client.close();
523
587
  return;
524
588
  }
@@ -593,7 +657,10 @@ async function main() {
593
657
  return;
594
658
  }
595
659
  case "join": {
596
- const invite = argv[1] ?? die("usage: pantheon join pantheon://host/space#token");
660
+ const invite = argv[1] ?? die("usage: pantheon join <invite> (pantheons://host/space#token, or https://www.joinpantheon.network/join/<team>#<token>) [setup answers as flags — pantheon setup --questions]");
661
+ const setupFlags = parseSetupFlags(argv);
662
+ if (setupFlags.errors.length)
663
+ die(setupFlags.errors.join("\n") + "\n pantheon setup --questions lists every answer");
597
664
  let parsed;
598
665
  try {
599
666
  parsed = fromInvite(invite);
@@ -637,7 +704,7 @@ async function main() {
637
704
  {
638
705
  const joined = load();
639
706
  if (joined)
640
- await guidedSetup(joined, repoInfo(process.cwd())?.root ?? null);
707
+ await guidedSetup(joined, repoInfo(process.cwd())?.root ?? null, { preset: setupFlags.preset, force: Object.keys(setupFlags.preset).length > 0 || argv.includes("--yes") });
641
708
  }
642
709
  console.error(`\n${D('Then ask your agent: "check pantheon_status and say hello to the team".')}`);
643
710
  return;
@@ -648,8 +715,8 @@ async function main() {
648
715
  console.error(`${B("setting this machine up")} ${D("(user scope)")}\n`);
649
716
  const wired = wireUp({
650
717
  hook: !argv.includes("--no-hook"),
651
- notifications: !argv.includes("--no-notifications") && process.platform === "darwin",
652
- menubar: !argv.includes("--no-menubar") && process.platform === "darwin",
718
+ notifications: !argv.includes("--no-notifications"),
719
+ menubar: !argv.includes("--no-menubar"),
653
720
  unattended: wp.project,
654
721
  workerHow: wp.how,
655
722
  });
@@ -822,7 +889,7 @@ async function main() {
822
889
  for (const o of human) {
823
890
  const who = o.owner.replace(/^human:/, "");
824
891
  const stale = Date.now() - o.created_at > 3_600_000;
825
- console.log(` ${stale ? "\x1b[33m" : C("")}${who.padEnd(14)}${ageOf(o.created_at).padEnd(6)}\x1b[0m ${o.body}`);
892
+ console.log(` ${stale ? "\x1b[33m" : C("")}${who.padEnd(14)}${ageOf(o.created_at).padEnd(6)}\x1b[0m ${o.body.split("\n")[0].slice(0, 110)} ${D(o.ref)}`);
826
893
  if (who === cfg.member)
827
894
  console.log(` ${D("your peer was told this decision was coming — answer it or say it is dropped")}`);
828
895
  }
@@ -830,7 +897,10 @@ async function main() {
830
897
  if (!between.length)
831
898
  console.log(` ${D("(nothing owed)")}`);
832
899
  for (const o of between) {
833
- console.log(` ${o.kind.padEnd(7)} ${o.owner.padEnd(14)}${ageOf(o.created_at).padEnd(6)} ${o.body}`);
900
+ console.log(` ${o.kind.padEnd(7)} ${o.owner.padEnd(14)}${ageOf(o.created_at).padEnd(6)} ${o.body.split("\n")[0].slice(0, 110)} ${D(o.ref)}`);
901
+ }
902
+ if (human.length || between.length || goals.some((g) => g.state !== "CLOSED")) {
903
+ console.log(D(`\n read in full: pantheon show <id> · a thread: pantheon history --goal <goal> · reply: pantheon say ANSWER "…" --ref <id>`));
834
904
  }
835
905
  client.close();
836
906
  };
@@ -931,13 +1001,58 @@ async function main() {
931
1001
  console.log(argv.includes("--json") ? JSON.stringify(report) : renderPending(report));
932
1002
  return;
933
1003
  }
1004
+ case "history": {
1005
+ const cfg = need();
1006
+ const acts = await fetchHistory(cfg, numFlag("since", 0)).catch((e) => die(`cannot read the ledger — ${e.message}`));
1007
+ const picked = selectActs(acts, {
1008
+ ...(flag("goal") ? { goal: flag("goal") } : {}), ...(flag("from") ? { from: flag("from") } : {}),
1009
+ last: numFlag("last", 20), presence: argv.includes("--presence"),
1010
+ });
1011
+ if (argv.includes("--json")) {
1012
+ console.log(JSON.stringify(picked, null, 2));
1013
+ return;
1014
+ }
1015
+ if (!picked.length) {
1016
+ console.log(D("no acts match"));
1017
+ return;
1018
+ }
1019
+ const client = new PantheonClient(cfg, undefined, { role: "cli" });
1020
+ const titles = new Map();
1021
+ try {
1022
+ await client.connect();
1023
+ for (const g of client.goals.values())
1024
+ titles.set(g.id, g.title);
1025
+ }
1026
+ catch { }
1027
+ finally {
1028
+ client.close();
1029
+ }
1030
+ for (const a of picked)
1031
+ console.log(formatAct(a, { short: argv.includes("--short"), ...(a.goal && titles.get(a.goal) ? { goalTitle: titles.get(a.goal) } : {}) }) + "\n");
1032
+ console.log(D(`${picked.length} of ${acts.filter((a) => a.act !== "JOIN" && a.act !== "LEAVE").length} acts · --last N · --goal <id> · --from <member> · --short · --json · reply: pantheon say ANSWER "…" --ref <id or seq>`));
1033
+ return;
1034
+ }
1035
+ case "show": {
1036
+ const cfg = need();
1037
+ const key = argv[1] ?? die("usage: pantheon show <act id | sequence number>");
1038
+ const acts = await fetchHistory(cfg).catch((e) => die(`cannot read the ledger — ${e.message}`));
1039
+ const a = acts.find((x) => x.id === key || String(x.seq) === key) ?? die(`no act ${key} in what the relay still holds (pantheon history lists them)`);
1040
+ console.log(formatAct(a));
1041
+ if (a.ref) {
1042
+ const r = acts.find((x) => x.id === a.ref);
1043
+ if (r)
1044
+ console.log(D(`\nin reply to:\n`) + formatAct(r, { short: true }));
1045
+ }
1046
+ return;
1047
+ }
934
1048
  case "say": {
935
1049
  const cfg = need();
936
- const act = argv[1]?.toUpperCase() ?? die("usage: pantheon say <ACT> <body> [--goal id] [--to a,b] [--domains technical,gtm]");
1050
+ const act = argv[1]?.toUpperCase() ?? die("usage: pantheon say <ACT> <body> [--goal id] [--to a,b] [--ref <id | seq | last>] [--summary 'one line'] [--domains technical,gtm]");
937
1051
  const stop = argv.findIndex((a, i) => i >= 2 && a.startsWith("--"));
938
1052
  const body = argv.slice(2, stop === -1 ? undefined : stop).join(" ");
939
1053
  if (!body)
940
1054
  die("nothing to say — pantheon say NOTE 'your message here'");
1055
+ const summary = flag("summary");
941
1056
  const client = new PantheonClient(cfg, undefined, { role: "cli" });
942
1057
  await client.connect().catch((e) => die(`cannot reach relay — ${e.message}`));
943
1058
  const to = flag("to");
@@ -950,13 +1065,28 @@ async function main() {
950
1065
  const domains = flag("domains");
951
1066
  if (domains)
952
1067
  meta.decision_domains = cleanList(domains.split(","));
1068
+ let goalArg = flag("goal") ?? null;
1069
+ let ref = flag("ref") ?? null;
1070
+ if (ref !== null || ["ANSWER", "ACCEPT", "OBJECT"].includes(act)) {
1071
+ const acts = await fetchHistory(cfg).catch(() => []);
1072
+ const rr = resolveRef(acts, ref, act, goalArg, cfg.member);
1073
+ if (rr.error) {
1074
+ client.close();
1075
+ die(rr.error);
1076
+ }
1077
+ if (rr.note)
1078
+ console.error(D(` ${rr.note}`));
1079
+ ref = rr.id;
1080
+ goalArg = goalArg ?? rr.goal;
1081
+ }
953
1082
  const r = await client.send({
954
1083
  act: act, body,
955
- goal: flag("goal") ?? null,
1084
+ goal: goalArg,
956
1085
  to: to ? to.split(",").map((s) => s.trim()) : null,
957
- ref: flag("ref") ?? null,
1086
+ ref,
1087
+ ...(summary ? { summary } : {}),
958
1088
  ...(Object.keys(meta).length ? { meta } : {}),
959
- }).catch((e) => die(e.message));
1089
+ }).catch((e) => die(/SUMMARY_REQUIRED/.test(e.message) ? `${e.message}\n Add one: --summary 'the point they must not miss'` : e.message));
960
1090
  console.log(`sent ${act} id=${r.id} seq=${r.seq}`);
961
1091
  client.close();
962
1092
  return;
@@ -999,7 +1129,8 @@ async function main() {
999
1129
  console.log(` menu bar ${C("running")} ${menubar.managedBy === "launchd" ? `service ${menubar.label}` : "started by hand"} pid ${menubar.pid}`);
1000
1130
  }
1001
1131
  else {
1002
- console.log(` menu bar ${D("not running")} ${D("pantheon service install menubar — status and pause/resume in the menu bar")}`);
1132
+ const why = desktopUnavailable(hostEnv(), "the menu bar");
1133
+ console.log(` menu bar ${D("not running")} ${D(why ?? "pantheon service install menubar — status and pause/resume in the menu bar")}`);
1003
1134
  }
1004
1135
  if (notifier.running) {
1005
1136
  console.log(` notifier ${C("running")} ${notifier.managedBy === "launchd" ? `service ${notifier.label}` : "started by hand"} pid ${notifier.pid}`);
@@ -2013,10 +2144,13 @@ async function main() {
2013
2144
  const find = (want) => all.find((x) => x.o.id === want);
2014
2145
  if (sub === "answer") {
2015
2146
  if (!id)
2016
- die("usage: pantheon approvals answer <obligation-id> <decision>");
2147
+ die("usage: pantheon approvals answer <obligation-id> <decision> [--summary 'one line']");
2017
2148
  const hit = find(id) ?? die(`no decision ${id} is waiting on you${conns.length > 1 ? ` in any of your ${conns.length} teams` : ""}`);
2018
- const answer = argv.slice(3).join(" ").trim() || die("state the decision; it will be written to the audit ledger");
2019
- const sent = await hit.client.send({ act: "ANSWER", body: answer, goal: null, ref: hit.o.ref, after_seq: hit.client.readSeq });
2149
+ const stopAt = argv.findIndex((a, i) => i >= 3 && a.startsWith("--"));
2150
+ const answer = argv.slice(3, stopAt === -1 ? undefined : stopAt).join(" ").trim() || die("state the decision; it will be written to the audit ledger");
2151
+ const summary = flag("summary");
2152
+ const sent = await hit.client.send({ act: "ANSWER", body: answer, goal: null, ref: hit.o.ref, after_seq: hit.client.readSeq, ...(summary ? { summary } : {}) })
2153
+ .catch((e) => { closeAll(); return die(/SUMMARY_REQUIRED/.test(e.message) ? `${e.message}\n Add one: --summary 'the point they must not miss'` : e.message); });
2020
2154
  console.log(`${C("answered")} ${id}${conns.length > 1 ? ` in ${B(hit.cfg.space)}` : ""} — ledger act ${sent.id}`);
2021
2155
  closeAll();
2022
2156
  return;
package/dist/client.js CHANGED
@@ -41,6 +41,7 @@ export class PantheonClient {
41
41
  plan = null;
42
42
  enroll = null;
43
43
  inviteUrl = null;
44
+ invitesList = null;
44
45
  constructor(cfg, onEvent, opts) {
45
46
  this.cfg = cfg;
46
47
  this.onEvent = onEvent;
@@ -138,6 +139,9 @@ export class PantheonClient {
138
139
  break;
139
140
  case "claimed":
140
141
  break;
142
+ case "invites":
143
+ this.invitesList = msg.invites;
144
+ break;
141
145
  case "invite":
142
146
  this.inviteUrl = { url: msg.url, expires_at: msg.expires_at };
143
147
  break;
@@ -417,6 +421,36 @@ export class PantheonClient {
417
421
  }
418
422
  });
419
423
  }
424
+ listInvites() {
425
+ if (!this.ws || this.ws.readyState !== 1)
426
+ return Promise.reject(new Error(`not connected to relay (${this.pending ?? this.lastError ?? "offline"})`));
427
+ return new Promise((res, rej) => {
428
+ this.invitesList = null;
429
+ const cid = `c${++this.cid}`;
430
+ const finish = () => { clearInterval(poll); clearTimeout(t); this.acks.delete(cid); };
431
+ const t = setTimeout(() => { finish(); rej(new Error("relay did not list the invites in 10s")); }, 10_000);
432
+ const poll = setInterval(() => { if (this.invitesList) {
433
+ finish();
434
+ const v = this.invitesList;
435
+ this.invitesList = null;
436
+ res(v);
437
+ } }, 40);
438
+ this.acks.set(cid, (v) => { if ("error" in v) {
439
+ finish();
440
+ rej(new Error(v.error));
441
+ } });
442
+ try {
443
+ this.ws?.send(JSON.stringify({ t: "invites", cid }));
444
+ }
445
+ catch (e) {
446
+ finish();
447
+ rej(e);
448
+ }
449
+ });
450
+ }
451
+ revokeInvite(token) {
452
+ return this.rpc({ t: "revoke_invite", token: token.replace(/^…/, "") });
453
+ }
420
454
  myObligations() {
421
455
  return this.obligations.filter((o) => o.owner === this.cfg.member || o.owner === `human:${this.cfg.member}`);
422
456
  }
package/dist/config.js CHANGED
@@ -275,17 +275,29 @@ export function toInvite(cfg) {
275
275
  const scheme = u.protocol === "wss:" ? "pantheons" : "pantheon";
276
276
  return `${scheme}://${u.host}/${encodeURIComponent(cfg.space)}#${cfg.token}`;
277
277
  }
278
+ export const JOIN_LINK_BASE = "https://www.joinpantheon.network/join";
279
+ const HOSTED_RELAY = "wss://relay.joinpantheon.network";
280
+ const JOIN_SLUG = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
281
+ export function toJoinLink(cfg) {
282
+ if (cfg.relay.replace(/\/+$/, "") !== HOSTED_RELAY || !JOIN_SLUG.test(cfg.space))
283
+ return null;
284
+ return `${JOIN_LINK_BASE}/${cfg.space}#${cfg.token}`;
285
+ }
278
286
  export function fromInvite(invite) {
279
287
  const clean = invite
280
288
  .replace(/\u001b\[[0-9;]*m/g, "")
281
289
  .replace(/[\u0000-\u001f\u007f]/g, "")
282
290
  .trim()
283
291
  .replace(/^['"`]|['"`]$/g, "")
284
- .replace(/^pantheon\s+join\s+/i, "")
285
292
  .trim();
286
- const m = /^(pantheons?):\/\/([^/]+)\/([^#]+)#(.+)$/.exec(clean);
293
+ const web = /https?:\/\/(?:www\.)?joinpantheon\.network\/join\/([^\s#?/"'`]+)(?:\?[^\s#"'`]*)?#([^\s"'`]+)/i.exec(clean);
294
+ if (web) {
295
+ const [, slug, token] = web;
296
+ return { relay: HOSTED_RELAY, space: decodeURIComponent(slug), token };
297
+ }
298
+ const m = /(pantheons?):\/\/([^/\s"'`]+)\/([^#\s"'`]+)#([^\s"'`]+)/.exec(clean);
287
299
  if (!m)
288
- throw new Error("not a pantheon invite — expected pantheon://host/space#token (or pantheons:// for TLS)");
300
+ throw new Error("not a pantheon invite — expected pantheons://host/space#token (pantheon:// without TLS), or https://www.joinpantheon.network/join/<team>#<token>");
289
301
  const [, scheme, host, space, token] = m;
290
302
  return {
291
303
  relay: `${scheme === "pantheons" ? "wss" : "ws"}://${host}`,
package/dist/doctor.js CHANGED
@@ -11,6 +11,7 @@ import { loadPersonas } from "./persona.js";
11
11
  import { workerProc } from "./machine.js";
12
12
  import { VERSION } from "./version.js";
13
13
  import { serviceInstalled } from "./service.js";
14
+ import { hostEnv } from "./host-env.js";
14
15
  const sh = (cmd, cwd) => {
15
16
  try {
16
17
  return execFileSync("sh", ["-lc", cmd], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
@@ -46,6 +47,13 @@ export async function doctor(projectDir) {
46
47
  major >= 22
47
48
  ? add("pass", "node", `v${process.versions.node}`)
48
49
  : add("fail", "node", `v${process.versions.node} is too old`, "pantheon needs Node 22 or newer (the relay uses node:sqlite) — install it and re-run");
50
+ const host = hostEnv();
51
+ if (host.ephemeral) {
52
+ add("warn", "machine", `${host.platform} in ${host.kind === "container" ? "a container" : "a cloud workspace"} — ${host.evidence.join("; ")}`, `this is not the computer you work on, which is why it reports ${host.platform} to someone who may be sitting at a Mac. The team and your account are fine here, but the menu bar, desktop alerts and the worker belong on your own machine — run the setup from a terminal there.`);
53
+ }
54
+ else {
55
+ add("pass", "machine", `${host.platform}${host.desktop ? " — menu bar and desktop alerts can run here" : " — no menu bar or desktop alerts (macOS only); use phone or chat delivery"}`);
56
+ }
49
57
  let cfg = null;
50
58
  try {
51
59
  cfg = load();
@@ -193,6 +201,11 @@ export async function doctor(projectDir) {
193
201
  add("warn", "wake rights", `${who}, but no worker is running here, so nothing can be woken`, "start one with `pantheon service install worker --project <a git repo>`; until then a peer's act waits for you to type");
194
202
  }
195
203
  }
204
+ if (host.desktop) {
205
+ serviceInstalled("menubar")
206
+ ? add("pass", "menu bar", "installed — status, pause/resume, and a badge when a decision needs you")
207
+ : add("warn", "menu bar", "not installed on a Mac that can run it", "install it with: pantheon service install menubar (needs the Xcode Command Line Tools — xcode-select --install — and a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice");
208
+ }
196
209
  if (!hookStates.length && !hook.installed) {
197
210
  add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor on PATH)");
198
211
  }