@join-pantheon/cli 0.33.1 → 0.33.2

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.
Files changed (44) hide show
  1. package/README.md +6 -5
  2. package/dist/accounts.js +10 -0
  3. package/dist/agents.js +11 -0
  4. package/dist/board.js +55 -9
  5. package/dist/claude-confine.js +1 -1
  6. package/dist/cli.js +556 -89
  7. package/dist/client.js +8 -4
  8. package/dist/config.js +18 -4
  9. package/dist/continued.js +5 -0
  10. package/dist/desktop-app.js +103 -0
  11. package/dist/doctor.js +84 -32
  12. package/dist/folder-guard.js +12 -0
  13. package/dist/folder-snapshot.js +432 -0
  14. package/dist/install.js +15 -8
  15. package/dist/machine.js +84 -4
  16. package/dist/managed.js +2 -2
  17. package/dist/mcp.js +5 -3
  18. package/dist/network.js +156 -0
  19. package/dist/notice-linux.js +6 -5
  20. package/dist/notice-windows.js +2 -2
  21. package/dist/notice.js +13 -0
  22. package/dist/notifier.js +29 -2
  23. package/dist/notify.js +11 -0
  24. package/dist/onboard-defaults.js +37 -1
  25. package/dist/pins.js +7 -1
  26. package/dist/plans.js +23 -0
  27. package/dist/protocol.js +5 -0
  28. package/dist/quickstart.js +17 -11
  29. package/dist/refusal.js +4 -4
  30. package/dist/relay.js +332 -22
  31. package/dist/repo-find.js +6 -3
  32. package/dist/roster-file.js +50 -5
  33. package/dist/tray.js +12 -1
  34. package/dist/uninstall.js +38 -3
  35. package/dist/uri-handler.js +3 -2
  36. package/dist/verify-act.js +17 -0
  37. package/dist/worker-fleet.js +140 -5
  38. package/dist/worker.js +201 -63
  39. package/installer/install.ps1 +251 -15
  40. package/installer/install.sh +147 -18
  41. package/package.json +1 -1
  42. package/helper/macos/prebuilt/manifest.json +0 -29
  43. package/helper/macos/prebuilt/pantheon-hud +0 -0
  44. package/helper/macos/prebuilt/pantheon-tray +0 -0
package/dist/network.js CHANGED
@@ -614,6 +614,8 @@ export function explainNetError(e, target, planArg) {
614
614
  ? { why: `the connection through the proxy ${shown} was cut`, fix: `the proxy may not allow WebSockets; ask for ${host} to be allowed`, share: "connection reset" }
615
615
  : { why: "the connection dropped before the relay answered", fix: "try again", share: "connection reset" };
616
616
  }
617
+ if (UNEXPECTED_REPLY.test(text))
618
+ return unexpectedReply(text, url ? host : "");
617
619
  return null;
618
620
  }
619
621
  export function describeNetError(e, target, plan) {
@@ -623,4 +625,158 @@ export function describeNetError(e, target, plan) {
623
625
  const fix = x ? x.fix.charAt(0).toUpperCase() + x.fix.slice(1) : "";
624
626
  return x ? `${x.why}. ${fix}. (${first})`.replace(/\.\. \(/, ". (").replace(/\?\. \(/, "? (") : raw;
625
627
  }
628
+ export class RelayReplyError extends Error {
629
+ status;
630
+ kind;
631
+ code = "RELAY_UNEXPECTED_REPLY";
632
+ constructor(status, kind, url) {
633
+ super(`${url} answered HTTP ${status} with ${kind === "empty" ? "an empty body" : kind === "html" ? "a web page" : "something that is not JSON"}, where the relay always answers JSON`);
634
+ this.status = status;
635
+ this.kind = kind;
636
+ this.name = "RelayReplyError";
637
+ }
638
+ }
639
+ export function replyKind(body, contentType) {
640
+ if (!body.trim())
641
+ return "empty";
642
+ return /html/i.test(contentType) || /^\s*<(!doctype|html|head|body|\?xml)/i.test(body) ? "html" : "other";
643
+ }
644
+ export async function relayJson(res, url) {
645
+ const text = await res.text();
646
+ try {
647
+ const j = JSON.parse(text);
648
+ if (j && typeof j === "object" && !Array.isArray(j))
649
+ return j;
650
+ }
651
+ catch { }
652
+ throw new RelayReplyError(res.status, replyKind(text, res.headers.get("content-type") ?? ""), url ?? (res.url || "the relay"));
653
+ }
654
+ const UNEXPECTED_REPLY = /RELAY_UNEXPECTED_REPLY|Unexpected server response: \d+|answered HTTP \d+/;
655
+ const CHAIN_NOT_PUBLIC = /SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_GET_ISSUER_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE|CERT_UNTRUSTED|self[- ]signed certificate|unable to get local issuer/i;
656
+ export const worthCertificateCheck = (e, o = {}) => {
657
+ const t = errText(e);
658
+ const status = replyStatus(t);
659
+ if (status !== null && status >= 502 && status <= 504)
660
+ return false;
661
+ if (UNEXPECTED_REPLY.test(t) || CHAIN_NOT_PUBLIC.test(t))
662
+ return true;
663
+ return !!o.nonJsonSeen && /ECONNRESET|socket hang up/i.test(t);
664
+ };
665
+ const replyStatus = (text) => {
666
+ const m = /Unexpected server response: (\d+)|answered HTTP (\d+)/.exec(text);
667
+ return m ? Number(m[1] ?? m[2]) : null;
668
+ };
669
+ export const isPantheonHost = (host) => /(^|\.)joinpantheon\.network$/i.test(host.replace(/\.$/, ""));
670
+ function unexpectedReply(text, host) {
671
+ const status = replyStatus(text);
672
+ if (status !== null && status >= 502 && status <= 504) {
673
+ return { why: `the relay's host answered ${status}: the relay is restarting or briefly down`, fix: "try again in a minute; pantheon doctor shows more", share: `relay host answered ${status}` };
674
+ }
675
+ const what = /empty body|Unexpected end of JSON input/.test(text) ? `an empty ${status ?? "reply"}`
676
+ : /web page|Unexpected token '?<|<!doctype|<html/i.test(text) ? "a web page" : status ? `HTTP ${status}` : "something that is not the relay";
677
+ const allow = !host ? "the relay's address" : isPantheonHost(host) ? "*.joinpantheon.network" : host;
678
+ return {
679
+ why: `something other than the relay answered (${what}), which is what an antivirus, a company proxy or a captive portal does when it blocks a site`,
680
+ fix: `allow ${allow} in your antivirus or proxy (with HTTPS scanning), or ask your IT; pantheon doctor names who signed the connection`,
681
+ share: "something other than the relay answered",
682
+ };
683
+ }
684
+ const firstOf = (v) => (Array.isArray(v) ? v[0] : v)?.trim() ?? "";
685
+ export function issuerName(issuer) {
686
+ const cn = firstOf(issuer?.CN), o = firstOf(issuer?.O);
687
+ if (cn && o && !cn.toLowerCase().includes(o.toLowerCase()))
688
+ return `${cn}, ${o}`;
689
+ return cn || o || firstOf(issuer?.OU) || "an unnamed certificate authority";
690
+ }
691
+ export function judgeCertificate(host, authorized, authorizationError, issuer) {
692
+ const name = issuerName(issuer);
693
+ if (authorized)
694
+ return { host, issuer: name, publicCa: true };
695
+ const err = authorizationError instanceof Error ? (authorizationError.code ?? authorizationError.message) : String(authorizationError ?? "");
696
+ return { host, issuer: name, publicCa: CHAIN_NOT_PUBLIC.test(err) ? false : null, ...(err ? { error: err } : {}) };
697
+ }
698
+ export async function checkRelayCertificate(target, opts = {}) {
699
+ let u;
700
+ try {
701
+ u = new URL(target.replace(/^ws/i, "http"));
702
+ }
703
+ catch {
704
+ return null;
705
+ }
706
+ if (u.protocol !== "https:")
707
+ return null;
708
+ const host = u.hostname.replace(/^\[|\]$/g, "");
709
+ const port = Number(u.port || 443);
710
+ const proxy = routeFor(u.href, opts.plan ?? currentPlan());
711
+ const options = {
712
+ host, port, ca: opts.ca ?? [...tls.rootCertificates], rejectUnauthorized: false,
713
+ ...(net.isIP(host) ? {} : { servername: host }),
714
+ };
715
+ return new Promise((done) => {
716
+ let settled = false;
717
+ let sock = null;
718
+ const finish = (r) => {
719
+ if (settled)
720
+ return;
721
+ settled = true;
722
+ clearTimeout(timer);
723
+ sock?.destroy();
724
+ done(r);
725
+ };
726
+ const timer = setTimeout(() => finish(null), opts.timeoutMs ?? 4_000);
727
+ timer.unref?.();
728
+ const read = (s) => {
729
+ sock = s;
730
+ const c = s.getPeerCertificate(false);
731
+ if (!c || !Object.keys(c).length)
732
+ return finish(null);
733
+ finish(judgeCertificate(host, s.authorized, s.authorizationError, c.issuer));
734
+ };
735
+ if (proxy) {
736
+ tunnelAgent(proxy, true)
737
+ .createConnection(options, (err, s) => {
738
+ if (settled) {
739
+ s?.destroy();
740
+ return;
741
+ }
742
+ if (err || !s)
743
+ finish(null);
744
+ else
745
+ read(s);
746
+ });
747
+ }
748
+ else {
749
+ const s = tls.connect(options, () => read(s));
750
+ sock = s;
751
+ s.once("error", () => finish(null));
752
+ }
753
+ });
754
+ }
755
+ export function interceptionExplanation(c) {
756
+ if (isPantheonHost(c.host)) {
757
+ return {
758
+ why: `your antivirus or proxy (${c.issuer}) intercepts ${c.host}`,
759
+ fix: "allow *.joinpantheon.network in its web/anti-phishing settings (with HTTPS scanning), or ask your IT",
760
+ share: "relay intercepted: its certificate is not from a public CA (antivirus or proxy)",
761
+ };
762
+ }
763
+ return {
764
+ why: `${c.host}'s certificate here does not check out against the public certificate authorities (it names ${c.issuer}): the relay may use a private certificate authority or send an incomplete chain, or an antivirus or proxy may be in between`,
765
+ fix: `ask whoever runs ${c.host} which it is — a private root goes in NODE_EXTRA_CA_CERTS=/path/to/root.pem; an antivirus or proxy needs ${c.host} allowed`,
766
+ share: "relay certificate not from a public CA",
767
+ };
768
+ }
769
+ export async function interceptedText(e, target, check = checkRelayCertificate, o = {}) {
770
+ if (!target || !worthCertificateCheck(e, o))
771
+ return null;
772
+ const c = await check(target).catch(() => null);
773
+ if (!c || c.publicCa !== false)
774
+ return null;
775
+ const x = interceptionExplanation(c);
776
+ const raw = (e?.message ?? String(e)).split("\n")[0].slice(0, 120);
777
+ return `${x.why} — ${x.fix}. (${raw})`;
778
+ }
779
+ export async function describeRelayFailure(e, target, check) {
780
+ return (await interceptedText(e, target, check)) ?? describeNetError(e, target);
781
+ }
626
782
  //# sourceMappingURL=network.js.map
@@ -46,7 +46,7 @@ export function noticeActions(n) {
46
46
  const out = [["default", "Open board"]];
47
47
  if (n.kind === "decision" && n.details?.decision)
48
48
  out.push(["done", "Done"]);
49
- out.push(["board", "Open board"]);
49
+ out.push(["board", n.focus ? "Approve or decline" : "Open board"]);
50
50
  return out;
51
51
  }
52
52
  export function noticeUrgency(n) {
@@ -157,7 +157,7 @@ export class LinuxAlerts {
157
157
  run,
158
158
  cli,
159
159
  log: deps.log ?? ((l) => console.error(l)),
160
- openBoard: deps.openBoard ?? ((env) => openBoardWith(cli, env)),
160
+ openBoard: deps.openBoard ?? ((env, focus) => openBoardWith(cli, env, focus)),
161
161
  sessionEnv: deps.sessionEnv ?? realSessionEnv,
162
162
  };
163
163
  }
@@ -296,7 +296,7 @@ export class LinuxAlerts {
296
296
  else if (action === "board" || action === "default") {
297
297
  const display = this.d.env.DISPLAY || this.d.env.WAYLAND_DISPLAY ? {} : pickDisplay(this.d.sessionEnv());
298
298
  const env = { ...this.d.env, ...display, ...(token ? { XDG_ACTIVATION_TOKEN: token, DESKTOP_STARTUP_ID: token } : {}) };
299
- const opening = this.d.openBoard(env).catch(() => false).then(async (opened) => {
299
+ const opening = this.d.openBoard(env, s.notice.focus).catch(() => false).then(async (opened) => {
300
300
  if (!opened)
301
301
  await this.note("Open the board in a terminal", "Run pantheon board (or pantheon brief) to see everything that needs you.");
302
302
  });
@@ -436,7 +436,7 @@ export function writeHudAction(home, hud) {
436
436
  renameSync(tmp, f);
437
437
  return f;
438
438
  }
439
- function openBoardWith(cli, env) {
439
+ function openBoardWith(cli, env, focus) {
440
440
  return new Promise((done) => {
441
441
  const [file, ...args] = cli;
442
442
  let settled = false;
@@ -445,8 +445,9 @@ function openBoardWith(cli, env) {
445
445
  done(v);
446
446
  } };
447
447
  let child;
448
+ const at = focus && /^[ao]_[A-Za-z0-9]{6,48}$/.test(focus) ? ["--focus", focus] : [];
448
449
  try {
449
- child = spawn(file, [...args, "board", "--open"], { env, stdio: "ignore", detached: true });
450
+ child = spawn(file, [...args, "board", "--open", ...at], { env, stdio: "ignore", detached: true });
450
451
  }
451
452
  catch {
452
453
  settle(false);
@@ -42,7 +42,7 @@ export function toastButtons(notice, key) {
42
42
  else if (d.act) {
43
43
  add("Show details", buildUri("details", { act: d.act, decision: d.decision, space }, key));
44
44
  }
45
- add("Open board", buildUri("board", {}, key));
45
+ add(notice.focus ? "Approve or decline" : "Open board", buildUri("board", notice.focus ? { focus: notice.focus } : {}, key));
46
46
  return out;
47
47
  }
48
48
  export function toastTag(notice) {
@@ -60,7 +60,7 @@ export function toastFields(notice, key) {
60
60
  return {
61
61
  lines,
62
62
  who: toastText(notice.who, TOAST_CAPS.who),
63
- launch: buildUri("board", {}, key),
63
+ launch: (notice.focus && buildUri("board", { focus: notice.focus }, key)) || buildUri("board", {}, key),
64
64
  buttons: toastButtons(notice, key).map((b) => ({ content: toastText(b.content, TOAST_CAPS.button), uri: b.uri })),
65
65
  scenario: notice.ttl === 0 ? "reminder" : "",
66
66
  duration: notice.ttl !== 0 && notice.ttl > 7 ? "long" : "",
package/dist/notice.js CHANGED
@@ -174,4 +174,17 @@ export function composeWorker(run, ctx) {
174
174
  : {}),
175
175
  };
176
176
  }
177
+ export function composeWakeAsk(a) {
178
+ const where = a.mac ? "in the menu bar, or on the board (pantheon board)" : "on the board";
179
+ return {
180
+ kind: "worker",
181
+ who: fit(`${a.member} · waiting on you`, BUDGET.who),
182
+ heading: fit(`${a.member} is waiting on your approval`, BUDGET.heading),
183
+ context: fit(a.space, BUDGET.context),
184
+ body: fit(`${a.member} asked: ${plain(a.what)} — your worker takes requests only from teammates you approved here. Approve or decline ${where}.`, BUDGET.body),
185
+ ttl: 0,
186
+ sound: true,
187
+ focus: a.focus,
188
+ };
189
+ }
177
190
  //# sourceMappingURL=notice.js.map
package/dist/notifier.js CHANGED
@@ -9,12 +9,17 @@ import { fileURLToPath } from "node:url";
9
9
  import { PantheonClient } from "./client.js";
10
10
  import { CONFIG_DIR } from "./config.js";
11
11
  import { loadProfile } from "./profile.js";
12
- import { APP_TOOLS, composeDecision, composeDelivery, TOOL_LABELS } from "./notice.js";
12
+ import { APP_TOOLS, composeDecision, composeDelivery, composeWakeAsk, TOOL_LABELS } from "./notice.js";
13
+ import { loadPolicy } from "./worker-policy.js";
14
+ import { wakeAlerted } from "./continued.js";
13
15
  import { cliOnPath, detectAvailable } from "./agents.js";
14
16
  import { whichPantheon } from "./service.js";
15
17
  export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
16
18
  export const MAX_REMINDERS = 4;
17
19
  const DAY_MS = 24 * 60 * 60 * 1_000;
20
+ export function wakeAskWhat(body) {
21
+ return /answer while you are away:\s*(.+)$/m.exec(body)?.[1]?.trim() ?? body.split("\n")[0] ?? "";
22
+ }
18
23
  export function humanObligations(obligations, member) {
19
24
  return obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${member}`);
20
25
  }
@@ -164,10 +169,32 @@ export class HumanNotifier {
164
169
  const labelled = this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
165
170
  return platform() === "darwin" ? labelled : { ...labelled, space: this.cfg.space };
166
171
  };
172
+ let asks = {};
173
+ try {
174
+ asks = this.opts.wakeAsks ? this.opts.wakeAsks() : loadPolicy().wakeAsks ?? {};
175
+ }
176
+ catch {
177
+ asks = {};
178
+ }
167
179
  for (const obligation of due) {
168
180
  const isReminder = state.notified_at[obligation.id] !== undefined;
169
181
  const act = client.seenAct(obligation.ref);
170
- const notice = withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: preferredTool(profile, tools), now: now(), member: this.cfg.member }));
182
+ const wake = asks[obligation.ref];
183
+ const workerSaid = (w) => { try {
184
+ return this.opts.wakeAlerted ? this.opts.wakeAlerted(w, obligation.ref) : wakeAlerted(w.team, w.member, obligation.ref);
185
+ }
186
+ catch {
187
+ return false;
188
+ } };
189
+ if (wake && !isReminder && workerSaid(wake)) {
190
+ state.notified_at[obligation.id] = now();
191
+ dirty = true;
192
+ this.opts.log(`> ${obligation.id}: the worker alerted this one itself (may ${wake.member} start work here)`);
193
+ continue;
194
+ }
195
+ const notice = wake
196
+ ? withLabel(composeWakeAsk({ member: wake.member, what: wakeAskWhat(act?.body ?? obligation.body), space: this.opts.label ? "" : this.cfg.space, focus: obligation.ref, mac: platform() === "darwin" }))
197
+ : withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: preferredTool(profile, tools), now: now(), member: this.cfg.member }));
171
198
  const ok = await send(notice);
172
199
  if (ok) {
173
200
  state.notified_at[obligation.id] = now();
package/dist/notify.js CHANGED
@@ -95,6 +95,17 @@ export function readIntent(home) {
95
95
  return null;
96
96
  }
97
97
  }
98
+ export function repairNote(repairs) {
99
+ if (!repairs || repairs < 1)
100
+ return null;
101
+ if (repairs === 1)
102
+ return { said: "put back once (it had been removed)", why: null, recurring: false };
103
+ return {
104
+ said: `put back ${repairs} times after something removed it`,
105
+ why: "something on this machine rewrites ~/.claude/settings.json and drops the hooks key",
106
+ recurring: true,
107
+ };
108
+ }
98
109
  export function writeIntent(next, home) {
99
110
  try {
100
111
  const f = intentPath(home);
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { validUsername } from "./accounts.js";
2
+ import { validEmail, validUsername } from "./accounts.js";
3
3
  export function gitConfigValue(key) {
4
4
  try {
5
5
  return execFileSync("git", ["config", "--get", key], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }).trim();
@@ -15,4 +15,40 @@ export function suggestUsername(email, n) {
15
15
  const u = n ? `${local}${n}` : local;
16
16
  return validUsername(u) ? u : "";
17
17
  }
18
+ const GITHUB_NOREPLY = /@users\.noreply\.github\.com$/i;
19
+ export function isGithubNoreply(email) {
20
+ return GITHUB_NOREPLY.test(email.trim());
21
+ }
22
+ export function githubNoreplyLogin(email) {
23
+ if (!isGithubNoreply(email))
24
+ return "";
25
+ const local = email.trim().split("@")[0];
26
+ return /^(?:\d+\+)?([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)$/i.exec(local)?.[1]?.toLowerCase() ?? "";
27
+ }
28
+ export function emailSuggestion(gitEmail) {
29
+ const e = gitEmail.trim();
30
+ return validEmail(e) && !isGithubNoreply(e) ? e : "";
31
+ }
32
+ export function usernameSuggestion(email, gitEmail = "", n) {
33
+ const fromEmail = isGithubNoreply(email) ? "" : suggestUsername(email, n);
34
+ if (fromEmail)
35
+ return fromEmail;
36
+ const login = githubNoreplyLogin(email) || githubNoreplyLogin(gitEmail);
37
+ if (!login)
38
+ return "";
39
+ const u = n ? `${login.slice(0, 28)}${n}` : login.slice(0, 32);
40
+ return validUsername(u) ? u : "";
41
+ }
42
+ export function signinCommand(launcher, platform = process.platform) {
43
+ return pantheonCommand(launcher, "signin", platform);
44
+ }
45
+ export function pantheonCommand(launcher, rest, platform = process.platform) {
46
+ if (!launcher)
47
+ return `pantheon ${rest}`;
48
+ if (!/\s/.test(launcher))
49
+ return `${launcher} ${rest}`;
50
+ if (platform === "win32")
51
+ return `& "${launcher}" ${rest} (Command Prompt: "${launcher}" ${rest})`;
52
+ return `"${launcher}" ${rest}`;
53
+ }
18
54
  //# sourceMappingURL=onboard-defaults.js.map
package/dist/pins.js CHANGED
@@ -74,7 +74,7 @@ export function writeWhole(f, text) {
74
74
  throw e;
75
75
  }
76
76
  }
77
- export function observeMembers(p, team, members, me, at = Date.now()) {
77
+ export function observeMembers(p, team, members, me, at = Date.now(), vouch) {
78
78
  const t = (p.teams[team] ??= { members: dict() });
79
79
  const out = [];
80
80
  const mine = fingerprint(me.publicKey);
@@ -98,6 +98,12 @@ export function observeMembers(p, team, members, me, at = Date.now()) {
98
98
  delete cur.pending;
99
99
  continue;
100
100
  }
101
+ const by = vouch?.(m, fp, p) ?? null;
102
+ if (by) {
103
+ t.members[m.member] = { fp, ...(key ? { key } : {}), since: at, ...(cur.signs ? { signs: cur.signs } : {}) };
104
+ out.push({ member: m.member, kind: "moved", fp, was: cur.fp, by });
105
+ continue;
106
+ }
101
107
  if (cur.pending?.fp !== fp) {
102
108
  cur.pending = { fp, ...(key ? { key } : {}), since: at };
103
109
  out.push({ member: m.member, kind: "changed", fp, was: cur.fp });
package/dist/plans.js CHANGED
@@ -52,4 +52,27 @@ export function planLimits(plans, plan, override) {
52
52
  return plans[plan] ?? plans["free"] ?? DEFAULT_PLANS["free"];
53
53
  }
54
54
  export const fmtLimit = (n) => n >= UNLIMITED ? "unlimited" : String(n);
55
+ export function lastSlotNotice(plan) {
56
+ if (!plan || plan.limits.spaces >= UNLIMITED)
57
+ return null;
58
+ if (plan.usage.spaces + 1 !== plan.limits.spaces)
59
+ return null;
60
+ return `this is the last team your ${plan.name} plan allows (${plan.limits.spaces}); if one of your teams was only a test, pantheon space delete <name> frees its place`;
61
+ }
62
+ export function ownedTeamsIn(message) {
63
+ const m = /you own ((?:"[^"]+"(?:, )?)+)/.exec(message);
64
+ return m ? [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]) : [];
65
+ }
66
+ export function spaceLimitAdvice(teams) {
67
+ if (!teams.length)
68
+ return ["Free a slot: pantheon space delete <team> --yes (from a machine in that team), or upgrade at https://www.joinpantheon.network"];
69
+ const out = ["Free a slot by deleting a team you no longer need (it ends that team for everyone in it):"];
70
+ const width = Math.max(...teams.map((t) => t.name.length));
71
+ for (const t of teams) {
72
+ const note = !t.here ? "not on this machine: run it from one that is in it" : t.alone ? "nobody else is in it" : t.alone === false ? "others are in it" : "";
73
+ out.push(` pantheon space delete ${t.name.padEnd(width)} --yes${note ? ` (${note})` : ""}`);
74
+ }
75
+ out.push("Then run the same pantheon start again. Or upgrade at https://www.joinpantheon.network");
76
+ return out;
77
+ }
55
78
  //# sourceMappingURL=plans.js.map
package/dist/protocol.js CHANGED
@@ -20,6 +20,10 @@ export const ACTS = [
20
20
  "CLOSE",
21
21
  ];
22
22
  export const SENDABLE = ACTS.filter((a) => a !== "JOIN" && a !== "LEAVE");
23
+ export const UPKEEP_AGENT = "pantheon-upkeep";
24
+ export function siblingSessions(roster, member, session) {
25
+ return (roster.find((m) => m.id === member)?.sessions ?? []).filter((s) => s.id !== session && s.agent !== UPKEEP_AGENT);
26
+ }
23
27
  export const LIMITS = {
24
28
  bodyChars: 32000,
25
29
  summaryRequiredOver: 2000,
@@ -132,6 +136,7 @@ export const Hello = z.object({
132
136
  repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
133
137
  repo_origin: z.string().max(200).optional(),
134
138
  delegate: DelegateCertSchema.optional(),
139
+ upkeep: z.boolean().optional(),
135
140
  });
136
141
  export const OrganizationProfileSchema = z.object({
137
142
  version: z.literal(1),
@@ -4,7 +4,7 @@ import { createServer } from "node:net";
4
4
  import { basename, join as pathJoin, resolve as resolvePath } from "node:path";
5
5
  import { homedir, hostname, platform, userInfo } from "node:os";
6
6
  import { lookup } from "node:dns/promises";
7
- import { describeNetError } from "./network.js";
7
+ import { describeRelayFailure, interceptedText, RelayReplyError } from "./network.js";
8
8
  import { PantheonClient } from "./client.js";
9
9
  import { holdsRepo } from "./repo-find.js";
10
10
  import { folderFacts, folderRefusal, folderRefusalHere, isInside, isTemporaryRefusal } from "./folder-guard.js";
@@ -13,7 +13,7 @@ import { commandsChosen, loadProfile, wakeChosen, wakeDefault } from "./profile.
13
13
  import { newKeypair } from "./identity.js";
14
14
  import { installForUser } from "./install.js";
15
15
  import { writeIntent, installHooks } from "./notify.js";
16
- import { detectAvailable, agentById, agentsNeedingUnsandboxed } from "./agents.js";
16
+ import { detectAvailable, agentById, agentsNeedingUnsandboxed, folderReaderLine } from "./agents.js";
17
17
  import { installService, servicesUnavailable } from "./service.js";
18
18
  import { clearTrayQuit } from "./win-host.js";
19
19
  import { ensureNativeNotifier } from "./notifier.js";
@@ -147,16 +147,16 @@ export function wireUp(opts, deps = {}) {
147
147
  warnings.push(`the worker is installed but not running (${why}) — this machine cannot answer the team while you are away. Try \`pantheon service restart worker\``);
148
148
  }
149
149
  else {
150
- tools.push({ tool: "worker", status: `on — answers peers from this folder while you are away (it reads it; anything it writes goes to a drafts folder for you)` });
151
- granted.push(`${wakeChosen() === "approved" ? "A teammate you approve from this machine (pantheon wake allow <name>)" : "Your teammates"} can start an agent run that READS ${project} and answers them. It never changes that folder: drafts go to a folder of its own for you to review.`);
152
- const hasClaude = (() => { try {
153
- return detectAvailable().some((a) => a.backend.id === "claude-code");
150
+ tools.push({ tool: "worker", status: `on — answers peers from this folder while you are away (it works in a copy; anything it writes is kept as drafts for you)` });
151
+ granted.push(`${wakeChosen() === "approved" ? "A teammate you approve from this machine (pantheon wake allow <name>)" : "Your teammates"} can start an agent run that READS a copy of ${project} and answers them. It never changes that folder: anything it writes is kept as drafts for you to review.`);
152
+ const reader = (() => { try {
153
+ return folderReaderLine(detectAvailable());
154
154
  }
155
155
  catch {
156
- return true;
156
+ return null;
157
157
  } })();
158
- if (!hasClaude)
159
- warnings.push(`answering from a plain folder while you are away needs Claude Code (claude), and it is not installed here — until it is, teammates' requests for this folder wait for your live session`);
158
+ if (reader && !reader.ok)
159
+ warnings.push(`${reader.line} — until then, teammates' requests for this folder wait for your live session`);
160
160
  }
161
161
  }
162
162
  catch (e) {
@@ -352,6 +352,10 @@ export async function probeRelay(url, timeoutMs, fetchImpl = fetch) {
352
352
  const health = url.replace(/^ws/i, "http").replace(/\/+$/, "") + "/health";
353
353
  try {
354
354
  const r = await fetchImpl(health, { signal: AbortSignal.timeout(timeoutMs) });
355
+ if (r.ok && /html/i.test(r.headers.get("content-type") ?? "")) {
356
+ void r.body?.cancel().catch(() => { });
357
+ return { ok: false, error: new RelayReplyError(r.status, "html", health) };
358
+ }
355
359
  if (r.ok)
356
360
  return { ok: true };
357
361
  return { ok: false, error: new Error(`${health} answered HTTP ${r.status}`) };
@@ -421,7 +425,7 @@ export async function start(opts) {
421
425
  if (chosen && !hostHere) {
422
426
  const probe = await probeRelay(reach.url, opts.remoteTimeoutMs ?? 10_000, opts.fetchImpl);
423
427
  if (!probe.ok) {
424
- return nothing(`cannot reach the relay at ${reach.url}: ${describeNetError(probe.error, reach.url)}` +
428
+ return nothing(`cannot reach the relay at ${reach.url}: ${await describeRelayFailure(probe.error, reach.url)}` +
425
429
  (place === "remote" ? `\n If that address is a tunnel to this machine, start the tunnel first (cloudflared tunnel --url http://localhost:${opts.port}) and run this again — Pantheon hosts the relay here when it sees the tunnel.` : ""));
426
430
  }
427
431
  opts.log(` relay ${reach.url} (${reach.how})`);
@@ -522,11 +526,13 @@ export async function start(opts) {
522
526
  client.close();
523
527
  }
524
528
  };
529
+ if (!reuse && opts.beforeCreate)
530
+ await opts.beforeCreate(cfg).catch(() => { });
525
531
  try {
526
532
  await claim(cfg);
527
533
  }
528
534
  catch (e) {
529
- const msg = e.message;
535
+ const msg = (await interceptedText(e, cfg.relay)) ?? e.message;
530
536
  const nameTaken = !reuse && /BAD_TOKEN/.test(msg);
531
537
  if (nameTaken && !opts.space) {
532
538
  const alt = `${cfg.space}-${cfg.member}`;
package/dist/refusal.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const NET_CODE = /^(ECONN\w*|ENOTFOUND|ETIMEDOUT|EAI_\w+|EPROTO|EHOSTUNREACH|ENETUNREACH|EPIPE|ECONNRESET|UND_ERR_\w*|ERR_\w+)$/;
2
2
  const FIX = {
3
- NOT_SIGNED_IN: "sign in on this machine: pantheon login --email you@work.com (or pantheon signup), then try again",
4
- KEY_MISMATCH: "a member of the team runs pantheon admit <you> --forget and sends you a new invite (or pick another name with --me)",
3
+ NOT_SIGNED_IN: "sign in on this machine, in a terminal: pantheon signin (your email, then the emailed code — check spam), then try again",
4
+ KEY_MISMATCH: "if this is you on a new machine: run pantheon recover <team> here in a terminal (it signs you in with your email first) to move your seat; otherwise a member of the team runs pantheon admit <you> --forget and sends you a new invite (or pick another name with --me)",
5
5
  REVOKED: "ask a member of the team to let you back in: pantheon admit <you>",
6
6
  PLAN_LIMIT: "the team's owner frees a seat (pantheon invite --revoke all cancels unused invites) or upgrades the plan",
7
7
  BAD_TOKEN: "this machine's copy of the team token is wrong — rejoin with a fresh invite: pantheon join <invite>",
@@ -36,7 +36,7 @@ export function signInHint(code, relay, hosted = process.env.PANTHEON_DEFAULT_RE
36
36
  catch {
37
37
  return "";
38
38
  }
39
- return `\n This team is on ${relay}, so sign in there: pantheon login --email you@work.com --relay ${relay}` +
40
- `\n (new here: pantheon signup --email you@work.com --username you --relay ${relay})`;
39
+ return `\n This team is on ${relay}, so sign in there: pantheon signin --relay ${relay} (in a terminal: your email, then the emailed code — check spam)` +
40
+ `\n Or two commands: pantheon login --email you@work.com --relay ${relay} (new here: pantheon signup --email you@work.com --username you --relay ${relay}), then pantheon verify <code>`;
41
41
  }
42
42
  //# sourceMappingURL=refusal.js.map