@join-pantheon/cli 0.33.1-rc.1 → 0.33.2-rc.1

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/dist/cli.js CHANGED
@@ -7,13 +7,13 @@ process.emitWarning = ((warning, ...rest) => {
7
7
  return _emitWarning(warning, ...rest);
8
8
  });
9
9
  import { randomBytes } from "node:crypto";
10
- import { gitConfigValue, suggestUsername } from "./onboard-defaults.js";
11
- import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { emailSuggestion, gitConfigValue, isGithubNoreply, pantheonCommand, signinCommand, usernameSuggestion } from "./onboard-defaults.js";
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
12
12
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
13
13
  import { PantheonClient } from "./client.js";
14
14
  import { agentSpawn, CmdLineUnsafe } from "./which.js";
15
15
  import { fromInvite, looksLikeInviteCode, resolveInviteCode, load, save, toInvite, toJoinLink, resolveFor, loadAll, identity, repoInfo, bindRepo, bindFolder, unbindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
16
- import { fmtLimit } from "./plans.js";
16
+ import { fmtLimit, lastSlotNotice, ownedTeamsIn, spaceLimitAdvice } from "./plans.js";
17
17
  import { validEmail, validUsername } from "./accounts.js";
18
18
  import { install, launcher, installForUser, windowsRepoOverrides, agentAsksOnce } from "./install.js";
19
19
  import { Relay } from "./relay.js";
@@ -32,14 +32,14 @@ import { assess } from "./untrusted.js";
32
32
  import { report as spendReport, recentRuns } from "./spend.js";
33
33
  import { renderBrief, renderTeam } from "./brief.js";
34
34
  import { resolveDataDir, saveAll } from "./config.js";
35
- import { currentPlan, installNetwork, redactProxy, redactText, normalizeProxy, proxyToRemember, describeNetError } from "./network.js";
35
+ import { currentPlan, installNetwork, redactProxy, redactText, normalizeProxy, proxyToRemember, describeNetError, describeRelayFailure, checkRelayCertificate, interceptedText, interceptionExplanation, relayJson } from "./network.js";
36
36
  import { createInterface } from "node:readline";
37
37
  import { homedir } from "node:os";
38
- import { removeHook, decideStop, writeIntent, clearIntent, readIntent, installHooks, agentHookStates } from "./notify.js";
38
+ import { removeHook, decideStop, writeIntent, clearIntent, readIntent, repairNote, installHooks, agentHookStates } from "./notify.js";
39
39
  import { pending, render as renderPending } from "./pending.js";
40
40
  import { signedRead } from "./reads.js";
41
- import { start as qsStart, join as qsJoin, wireUp, teamNameFor, deviceLabel } from "./quickstart.js";
42
- import { folderRefusalHere, isTemporaryRefusal } from "./folder-guard.js";
41
+ import { start as qsStart, join as qsJoin, wireUp, teamNameFor, deviceLabel, bindableFolder } from "./quickstart.js";
42
+ import { folderRefusalHere, isTemporaryRefusal, projectFolderAnswer } from "./folder-guard.js";
43
43
  import { installService, serviceInstalled, serviceLogFile, uninstallService } from "./service.js";
44
44
  import { LEGACY_TASK_NAME, hostAlive, hostFiles, readHostState, recordedTaskName } from "./win-host.js";
45
45
  import { ACTS, LIMITS } from "./protocol.js";
@@ -88,9 +88,9 @@ async function requireSignInOnHostedRelay(relayFlag) {
88
88
  console.error("");
89
89
  return;
90
90
  }
91
- die("not signed in yet.\n When the code arrives: pantheon verify <code>\n Then run pantheon start again.");
91
+ die("not signed in yet.\n When the code arrives: pantheon verify <code> (check spam if it is not in your inbox)\n Then run pantheon start again.");
92
92
  }
93
- die(`sign in first — this relay verifies email.\n New here: pantheon signup --email you@work.com --username you\n Have an account: pantheon login --email you@work.com\n Then run pantheon start again.`);
93
+ die(`sign in first — this relay verifies email.\n${signInSteps().join("\n")}\n Then run pantheon start again.\n ${SIGNIN_FOR_AGENTS}`);
94
94
  }
95
95
  async function requireSignInOnAccountsRelay(relayFlag) {
96
96
  if (!relayFlag || identity()?.account)
@@ -109,7 +109,7 @@ async function requireSignInOnAccountsRelay(relayFlag) {
109
109
  console.error("");
110
110
  return;
111
111
  }
112
- die("not signed in yet.\n When the code arrives: pantheon verify <code>\n Then run this same pantheon start again.");
112
+ die("not signed in yet.\n When the code arrives: pantheon verify <code> (check spam if it is not in your inbox)\n Then run this same pantheon start again.");
113
113
  }
114
114
  if (hasAccounts)
115
115
  die(`sign in first — ${relayFlag} verifies email.${signInHint("NOT_SIGNED_IN", relayFlag)}\n Then run this same pantheon start again.`);
@@ -120,14 +120,15 @@ async function interactiveOnboard(relayArg, opts = {}) {
120
120
  const base = relay.replace(/^ws/, "http").replace(/\/+$/, "");
121
121
  if (!process.stdin.isTTY) {
122
122
  console.error(`${B("pantheon")} — sign in to get started:`);
123
- console.error(` ${C("pantheon signup --email you@work.com --username you")} then ${C("pantheon verify <code>")}`);
123
+ for (const l of signInSteps())
124
+ console.error(l);
124
125
  return false;
125
126
  }
126
127
  const rl = createInterface({ input: process.stdin, output: process.stderr });
127
128
  const ask = async (q) => {
128
129
  const a = await questionOrNull(rl, q);
129
130
  if (a === null) {
130
- console.error(D("\n cancelled. Pick up where you left off: pantheon signup --email you@work.com --username you"));
131
+ console.error(D("\n cancelled. Pick up where you left off: pantheon signin"));
131
132
  process.exit(1);
132
133
  }
133
134
  return a;
@@ -135,84 +136,117 @@ async function interactiveOnboard(relayArg, opts = {}) {
135
136
  const kp = prior?.publicKey && prior.privateKey ? { publicKey: prior.publicKey, privateKey: prior.privateKey } : newKeypair();
136
137
  const label = deviceLabel();
137
138
  const postJson = async (path, body) => fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
138
- .then(async (x) => ({ status: x.status, j: await x.json() }));
139
+ .then(async (x) => ({ status: x.status, j: await relayJson(x, `${base}${path}`) }));
140
+ const unreachable = async (e) => { console.error(D(` could not reach the relay: ${await describeRelayFailure(e, relay)}`)); return null; };
139
141
  try {
140
142
  console.error(opts.joining
141
143
  ? `${B("Welcome to Pantheon.")} You've been invited to "${opts.joining}". First, sign this machine in — your email and a 6-digit code, nothing else.\n`
142
144
  : opts.starting
143
145
  ? `${B("Welcome to Pantheon.")} First, sign this machine in — your email and a 6-digit code, nothing else. Then your team is created.\n`
144
- : `${B("Welcome to pantheon.")} Let's get this machine signed in.\n`);
146
+ : `${B("Welcome to pantheon.")} Let's get this machine signed in — your email and a 6-digit code, nothing else.\n`);
145
147
  const gitEmail = gitConfigValue("user.email");
146
- const emailGuess = validEmail(gitEmail) ? gitEmail : "";
148
+ const pend = prior?.pendingAuth;
149
+ const emailGuess = pend?.email && validEmail(pend.email) ? pend.email : emailSuggestion(gitEmail);
147
150
  let email = "";
148
151
  while (!email) {
149
152
  const e = (await ask(` Your work email${emailGuess ? ` [${emailGuess}]` : ""}: `)) || emailGuess;
150
- if (validEmail(e))
151
- email = e;
152
- else
153
+ if (!validEmail(e)) {
153
154
  console.error(D(" that doesn't look like an email — try again"));
154
- }
155
- let started = null;
156
- let mode = "signup";
157
- let signupUsername = "";
158
- let userGuess = suggestUsername(email);
159
- while (!started || started.status !== 200) {
160
- const u = (await ask(` Pick a username${userGuess ? ` [${userGuess}]` : ""}: `)) || userGuess;
161
- if (!validUsername(u.toLowerCase())) {
162
- console.error(D(" 2–32 chars: letters, digits, . _ - (not at the ends)"));
163
155
  continue;
164
156
  }
165
- signupUsername = u;
166
- started = await postJson("/auth/signup", { email, username: u, pubkey: kp.publicKey, label }).catch((e) => { console.error(D(` could not reach the relay: ${e.message}`)); return null; });
167
- if (!started)
168
- return false;
169
- if (started.status === 404) {
170
- console.error(`\n ${D("This relay is invite-based (no email accounts). Get started with:")}`);
171
- console.error(` ${C("pantheon start")} ${D("(create a team)")} or ${C("pantheon join <invite>")}`);
172
- return false;
157
+ if (isGithubNoreply(e)) {
158
+ console.error(D(" that is GitHub's no-reply address: mail sent there goes nowhere, so the code would never arrive — use the email you read"));
159
+ continue;
173
160
  }
174
- if (started.status === 200)
175
- break;
176
- if (started.j.reason === "email_taken") {
177
- console.error(D(` that email already has an account — signing this device in instead…`));
178
- started = await postJson("/auth/login", { email, pubkey: kp.publicKey, label }).catch((e) => { console.error(D(` could not reach the relay: ${e.message}`)); return null; });
161
+ email = e;
162
+ }
163
+ const send = (m, username) => postJson(`/auth/${m}`, m === "signup" ? { email, username, pubkey: kp.publicKey, label } : { email, pubkey: kp.publicKey, label })
164
+ .catch(unreachable);
165
+ const justSent = (r) => r.j.reason === "too_soon";
166
+ let mode = "login";
167
+ let signupUsername = "";
168
+ const recentPend = pend && pend.email === email && typeof pend.at === "number" && Date.now() - pend.at < 9 * 60_000 && prior?.publicKey === kp.publicKey ? pend : null;
169
+ let started = recentPend ? { status: 200, j: { reused: true } } : await send("login");
170
+ if (recentPend) {
171
+ mode = recentPend.purpose;
172
+ signupUsername = recentPend.username ?? "";
173
+ }
174
+ if (!started)
175
+ return false;
176
+ const noAccount = started.status === 404 && (started.j.reason === "no_such_account" || /no active account/i.test(String(started.j.error ?? "")));
177
+ if (started.status === 404 && !noAccount) {
178
+ console.error(`\n ${D("This relay is invite-based (no email accounts). Get started with:")}`);
179
+ console.error(` ${C("pantheon start")} ${D("(create a team)")} or ${C("pantheon join <invite>")}`);
180
+ return false;
181
+ }
182
+ if (noAccount) {
183
+ mode = "signup";
184
+ console.error(D(` no account for ${email} yet — one more question to make it.`));
185
+ let userGuess = (pend?.purpose === "signup" && pend.email === email && pend.username) || usernameSuggestion(email, gitEmail);
186
+ for (;;) {
187
+ const u = (await ask(` Pick a username${userGuess ? ` [${userGuess}]` : ""}: `)) || userGuess;
188
+ if (!validUsername(u.toLowerCase())) {
189
+ console.error(D(" 2–32 chars: letters, digits, . _ - (not at the ends)"));
190
+ continue;
191
+ }
192
+ signupUsername = u;
193
+ started = await send("signup", u);
179
194
  if (!started)
180
195
  return false;
181
- mode = "login";
182
- break;
196
+ if (started.status === 200 || justSent(started))
197
+ break;
198
+ if (started.j.reason === "email_taken") {
199
+ console.error(D(` that email already has an account — signing this device in instead…`));
200
+ started = await send("login");
201
+ if (!started)
202
+ return false;
203
+ mode = "login";
204
+ signupUsername = "";
205
+ break;
206
+ }
207
+ if (started.j.reason !== "username_taken" && started.j.reason !== "bad_username")
208
+ break;
209
+ console.error(D(` ${started.j.error ?? "try a different username"}`));
210
+ userGuess = usernameSuggestion(email, gitEmail, Math.floor(10 + Math.random() * 90));
183
211
  }
184
- console.error(D(` ${started.j.error ?? "try a different username"}`));
185
- userGuess = suggestUsername(email, Math.floor(10 + Math.random() * 90));
186
212
  }
187
- if (!started || started.status !== 200) {
188
- console.error(D(` ${started?.j.error ?? "sign-up failed"}`));
213
+ const already = justSent(started);
214
+ if (started.status !== 200 && !already) {
215
+ console.error(D(` ${started.j.error ?? "sign-in failed"}`));
189
216
  return false;
190
217
  }
191
218
  const authUser = mode === "signup" ? signupUsername : undefined;
192
219
  save({ ...(prior ?? {}), relay, member: signupUsername || prior?.member || "me",
193
220
  space: prior?.space ?? "", token: prior?.token ?? "",
194
221
  publicKey: kp.publicKey, privateKey: kp.privateKey, agent: prior?.agent ?? "unknown",
195
- pendingAuth: { email, ...(authUser ? { username: authUser } : {}), purpose: mode } });
196
- if (started.j.sent === false && started.j.via === "resend") {
197
- console.error(`\n ${D("The relay could not send the email just now.")} Type 'resend' to retry, or run ${C("pantheon")} again shortly.\n`);
222
+ pendingAuth: { email, ...(authUser ? { username: authUser } : {}), purpose: mode, at: recentPend?.at ?? (already && pend?.email === email && pend.at ? pend.at : Date.now()) } });
223
+ if (started.j.reused) {
224
+ console.error(`\n ${C("A code was sent")} to ${B(email)} ${Math.max(1, Math.round((Date.now() - recentPend.at) / 60_000))} min ago — enter that one, or type 'resend' for a new one. ${SPAM_HINT}\n`);
225
+ }
226
+ else if (already && pend?.email !== email) {
227
+ console.error(`\n ${D("A code for this email was asked for a moment ago from another device; that one will not work here.")} Wait a minute, then type 'resend'. ${SPAM_HINT}\n`);
228
+ }
229
+ else if (already) {
230
+ console.error(`\n ${C("A code was sent")} to ${B(email)} a moment ago — use that one. ${SPAM_HINT}\n`);
231
+ }
232
+ else if (started.j.sent === false && started.j.via === "resend") {
233
+ console.error(`\n ${D("The relay could not send the email just now.")} Type 'resend' to retry, or run ${C("pantheon signin")} again shortly.\n`);
198
234
  }
199
235
  else {
200
236
  const via = started.j.via === "resend" ? "" : `\n ${D("(this is a dev relay — your code is printed in its log)")}`;
201
- console.error(`\n ${C("Sent a 6-digit code")} to ${B(email)}. Check your inbox.${via}\n`);
237
+ console.error(`\n ${C("Sent a 6-digit code")} to ${B(email)}. Check your inbox. ${SPAM_HINT}${via}\n`);
202
238
  }
203
239
  for (let attempt = 0; attempt < 6; attempt++) {
204
- const code = await ask(" Enter the code (or 'resend'): ");
240
+ const code = await ask(" Enter the code (not in your inbox? check spam · or type 'resend'): ");
205
241
  if (/^resend$/i.test(code)) {
206
- const rs = await postJson(mode === "signup" ? "/auth/signup" : "/auth/login", mode === "signup"
207
- ? { email, username: signupUsername, pubkey: kp.publicKey, label }
208
- : { email, pubkey: kp.publicKey, label }).catch(() => null);
242
+ const rs = await send(mode, mode === "signup" ? signupUsername : undefined);
209
243
  if (rs && rs.status === 200 && rs.j.sent !== false)
210
- console.error(D(" sent a fresh code."));
244
+ console.error(D(` sent a fresh code. ${SPAM_HINT}`));
211
245
  else
212
246
  console.error(D(` could not resend: ${rs?.j.error ?? "the relay did not send an email"} — try again in a minute.`));
213
247
  continue;
214
248
  }
215
- const v = await postJson("/auth/verify", { email, code, pubkey: kp.publicKey }).catch((e) => ({ status: 0, j: { error: `could not reach the relay (${e.message}) — try the same code again` } }));
249
+ const v = await postJson("/auth/verify", { email, code, pubkey: kp.publicKey }).catch(async (e) => ({ status: 0, j: { error: `could not reach the relay (${await describeRelayFailure(e, relay)}) — try the same code again` } }));
216
250
  if (v.status === 200) {
217
251
  const acct = v.j.account;
218
252
  const cfg = { ...(prior ?? {}), relay, member: acct.username, space: prior?.space ?? "", token: prior?.token ?? "",
@@ -223,6 +257,11 @@ async function interactiveOnboard(relayArg, opts = {}) {
223
257
  console.error(`\n ${C("You're in")} — signed in as ${B(acct.username)} <${acct.email}> on this machine.\n`);
224
258
  if (opts.joining || opts.starting)
225
259
  return true;
260
+ if (opts.signin) {
261
+ console.error(` ${B("Signed in — tell your agent to continue;")} it will run the join or start again.`);
262
+ console.error(D(` (On your own? ${C("pantheon start")} creates a team; ${C("pantheon join <invite-link>")} joins one.)`));
263
+ return true;
264
+ }
226
265
  console.error(` ${B("Next:")} ${C("pantheon start")} ${D("(create a team, get a link to send)")}`);
227
266
  console.error(` ${C("pantheon join <invite-link>")} ${D("(join a teammate's team)")}`);
228
267
  return true;
@@ -230,13 +269,22 @@ async function interactiveOnboard(relayArg, opts = {}) {
230
269
  const spent = v.j.reason === "expired" || v.j.reason === "too_many_attempts" || v.j.tries_left === 0;
231
270
  console.error(D(` ${v.j.error ?? "that code is not right"}${spent ? " — type 'resend' for a new code" : " — try again"}`));
232
271
  }
233
- console.error(D("\n Too many tries. Run `pantheon` again to restart, or `pantheon verify <code>` once you have it."));
272
+ console.error(D("\n Too many tries. Run `pantheon signin` again to restart, or `pantheon verify <code>` once you have it."));
234
273
  return false;
235
274
  }
236
275
  finally {
237
276
  rl.close();
238
277
  }
239
278
  }
279
+ const SIGNIN_FOR_AGENTS = "A coding agent that cannot sign you in hands you the signin line to run yourself, and runs this again once you say you're signed in.";
280
+ const SPAM_HINT = "Not there in a minute? Check your spam folder.";
281
+ function signInSteps() {
282
+ return [
283
+ ` ${C(signinCommand(managedLayout()?.launcher))} ${D("in a terminal of your own: your email, then the emailed code, in one go")}`,
284
+ ` ${D("or two commands:")} ${C("pantheon signup --email you@work.com --username you")} ${D("(an account already: pantheon login --email you@work.com)")}`,
285
+ ` ${C("pantheon verify <code>")} ${D("(the code is emailed — check spam)")}`,
286
+ ];
287
+ }
240
288
  function ago(t) {
241
289
  const m = Math.round((Date.now() - t) / 60_000);
242
290
  if (m < 1)
@@ -458,6 +506,123 @@ async function workerAfterBinding(how) {
458
506
  console.error(D(` the worker did not install: ${e.message} — pantheon service install worker`));
459
507
  }
460
508
  }
509
+ async function bindProjectFromScratch() {
510
+ const cwd = process.cwd();
511
+ if (repoInfo(cwd) || !isTemporaryRefusal(folderRefusalHere(cwd)))
512
+ return;
513
+ const all = loadAll(), j = load();
514
+ const m = all && j ? findMembership(all, j.spaceId ?? j.space) ?? findMembership(all, j.space) : null;
515
+ if (!all || !m)
516
+ return;
517
+ if (Object.values(all.bindings ?? {}).some((b) => b.space === m.id))
518
+ return;
519
+ const line = pantheonCommand(managedLayout()?.launcher, `use ${m.name} --here`);
520
+ const later = () => {
521
+ console.error(` From your project folder (the repo or folder you work in), run: ${C(line)}`);
522
+ console.error(D(` The worker stays off until then, and agents opened there do not speak for "${m.name}" yet.`));
523
+ };
524
+ console.error(`
525
+ ${B("Your project folder.")} ${D(`This is a temporary folder, so nothing in it was bound to "${m.name}".`)}`);
526
+ if (!process.stdin.isTTY) {
527
+ later();
528
+ return;
529
+ }
530
+ for (let tries = 0; tries < 3; tries++) {
531
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
532
+ const a = ((await questionOrNull(rl, ` The folder you work in for "${m.name}" — a repo or a docs folder (Enter to skip): `)) ?? "").trim();
533
+ rl.close();
534
+ if (!a)
535
+ break;
536
+ const why = await bindProjectFolder(projectFolderAnswer(a, homedir(), cwd), m);
537
+ if (!why)
538
+ return;
539
+ console.error(D(` ${why} — try another, or press Enter to skip`));
540
+ }
541
+ later();
542
+ }
543
+ async function bindProjectFolder(dir, m) {
544
+ try {
545
+ if (!statSync(dir).isDirectory())
546
+ return `${dir} is not a folder`;
547
+ }
548
+ catch {
549
+ return `there is no folder at ${dir}`;
550
+ }
551
+ const all = loadAll() ?? die("not configured");
552
+ const r = repoInfo(dir);
553
+ if (r) {
554
+ const held = all.bindings?.[r.fingerprint];
555
+ if (held && held.space !== m.id) {
556
+ const other = findMembership(all, held.space)?.name ?? held.space;
557
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
558
+ const a = ((await questionOrNull(rl, ` ${r.origin ?? r.root} works for "${other}" now. Move it to "${m.name}"? [y/N] `)) ?? "").trim().toLowerCase();
559
+ rl.close();
560
+ if (a !== "y" && a !== "yes")
561
+ return `left with "${other}"`;
562
+ }
563
+ const { replaced } = bindRepo(r, m.id);
564
+ console.error(` ${B("bound")} ${r.origin ?? r.root} → "${m.name}"${replaced ? D(` (was: ${findMembership(all, replaced.space)?.name ?? replaced.space})`) : ""}`);
565
+ if (!r.shared)
566
+ console.error(D(" this repo has no origin remote — deliveries from it cannot be fetched by teammates"));
567
+ const { repo: _r, repoOrigin: _o, ...plain } = configFor(all, m, null);
568
+ const client = new PantheonClient(plain, undefined, { role: "cli" });
569
+ try {
570
+ await client.connect();
571
+ if (!client.repos.some((x) => x.fingerprint === r.fingerprint))
572
+ await client.bindRepo(r.fingerprint, repoShare(r));
573
+ }
574
+ catch (e) {
575
+ console.error(D(` (could not register the repo with the team: ${e.message.split("\n")[0]} — pantheon repo add, from it, when the relay is reachable)`));
576
+ }
577
+ finally {
578
+ client.close();
579
+ }
580
+ return null;
581
+ }
582
+ const refused = folderRefusalHere(dir);
583
+ if (refused)
584
+ return refused.why.replace(/;.*$/, "");
585
+ if ((await import("./repo-find.js")).holdsRepo(dir)) {
586
+ const n = await bindReposIn(dir, all, m, false, `pantheon use ${m.name} --here --yes`);
587
+ return n ? null : "nothing in it was bound";
588
+ }
589
+ if (!bindableFolder(dir))
590
+ return `${dir} holds another team's folder, so it cannot be bound whole`;
591
+ bindFolder(dir, m.id);
592
+ console.error(` ${B("bound")} ${dir} → "${m.name}" ${D("(agents opened there speak in it; no git needed)")}`);
593
+ return null;
594
+ }
595
+ async function lastTeamNotice(cfg) {
596
+ if (!cfg.account)
597
+ return;
598
+ const all = loadAll();
599
+ const m = Object.values(all?.spaces ?? {}).find((x) => sameHost(x.relay, cfg.relay));
600
+ if (!all || !m)
601
+ return;
602
+ const c = new PantheonClient(configFor(all, m, null), undefined, { role: "cli" });
603
+ try {
604
+ await Promise.race([c.connect(), new Promise((_, no) => setTimeout(() => no(new Error("timeout")), 5_000))]);
605
+ const say = lastSlotNotice(c.plan);
606
+ if (say)
607
+ console.error(`\x1b[33m ! ${say}\x1b[0m`);
608
+ }
609
+ catch { }
610
+ finally {
611
+ c.dispose();
612
+ }
613
+ }
614
+ async function teamLimitAdvice(warning) {
615
+ const owned = ownedTeamsIn(warning);
616
+ const all = loadAll();
617
+ const teams = await Promise.all(owned.map(async (name) => {
618
+ const m = all ? findMembership(all, name) : null;
619
+ if (!all || !m)
620
+ return { name, here: false, alone: null };
621
+ const { others } = await teamStanding(all, m);
622
+ return { name, here: true, alone: others === null ? null : others.length === 0 };
623
+ }));
624
+ return spaceLimitAdvice(teams);
625
+ }
461
626
  const repoLabel = (r) => r.origin ?? basename(r.root);
462
627
  const repoShare = (r) => (r.origin ? r.origin.replace(/^.*[:/]([^:/]+)$/, "$1") : basename(r.root));
463
628
  const teamFlag = (cfg) => (flag("space") ? ` --space ${cfg.space}` : "");
@@ -533,7 +698,11 @@ async function launchAgent(binary, args, prompt, cwd) {
533
698
  sp = agentSpawn(binary, args(`Read the file ${rel.split(/[\\/]/).join("/")} in this folder and do what it asks.`));
534
699
  }
535
700
  try {
536
- spawnSync(sp.command, sp.args, { stdio: "inherit", ...(cwd ? { cwd } : {}), ...sp.options });
701
+ const r = spawnSync(sp.command, sp.args, { stdio: "inherit", ...(cwd ? { cwd } : {}), ...sp.options });
702
+ if (r.error || (r.status ?? 0) !== 0) {
703
+ console.error(`\n${B("the agent did not start, or stopped with an error")} ${D(`(${r.error ? r.error.message.split("\n")[0] : `exit ${r.status}`}: ${binary})`)}`);
704
+ process.exitCode = r.status || 1;
705
+ }
537
706
  }
538
707
  finally {
539
708
  if (handoff)
@@ -543,6 +712,36 @@ async function launchAgent(binary, args, prompt, cwd) {
543
712
  catch { }
544
713
  }
545
714
  }
715
+ async function claudeAppInstead(surface, prompt) {
716
+ if (surface !== "claude-code")
717
+ return false;
718
+ const { detectAvailable } = await import("./agents.js");
719
+ if (detectAvailable().some((a) => a.backend.id === "claude-code"))
720
+ return false;
721
+ const { findClaudeApp, explainInClaudeApp } = await import("./desktop-app.js");
722
+ const app = findClaudeApp();
723
+ if (!app)
724
+ return false;
725
+ const r = explainInClaudeApp(prompt, app);
726
+ if (!r.ok) {
727
+ console.error(D(` ${r.says} — opening an agent CLI instead`));
728
+ return false;
729
+ }
730
+ console.error(r.says);
731
+ if (/could not be copied/.test(r.says))
732
+ console.log(`\n${prompt}\n`);
733
+ return true;
734
+ }
735
+ async function workerServesLines() {
736
+ try {
737
+ const { workerServesReport } = await import("./worker-fleet.js");
738
+ const { detectAvailable } = await import("./agents.js");
739
+ return workerServesReport(loadAll(), detectAvailable()).map((r) => (r.warn ? `\x1b[33m${r.line}\x1b[0m` : D(r.line)));
740
+ }
741
+ catch {
742
+ return [];
743
+ }
744
+ }
546
745
  function needStrict() {
547
746
  const r = resolveFor(process.cwd(), { strict: true });
548
747
  return r.cfg && r.cfg.space ? r.cfg : null;
@@ -847,7 +1046,8 @@ const C = (s) => PLAIN ? s : `\x1b[36m${s}\x1b[0m`;
847
1046
  const HELP = `${B("pantheon")} — your agent and your teammates' agents, working together
848
1047
 
849
1048
  ${B("first time — verify your email once")}
850
- pantheon signup --email you@work.com --username you get a 6-digit code by email
1049
+ pantheon signin in a terminal: your email, then the emailed code (check spam)
1050
+ pantheon signup --email you@work.com --username you or by flags: get a 6-digit code by email (check spam)
851
1051
  pantheon verify <code> confirm it — you're in
852
1052
  pantheon login --email you@work.com a second machine, same account
853
1053
  pantheon whoami · pantheon sessions who you are; your devices & sessions
@@ -893,6 +1093,8 @@ ${B("control")}
893
1093
  pantheon trust [<who>] the teammates' keys this machine checks their messages against; trust <who> adopts a changed one
894
1094
  (this machine only — nothing is sent to the team; --space <team> to pick one)
895
1095
  pantheon admit <who> --revoke remove them
1096
+ pantheon recover [<team>] lost the machine your seat was on? sign in on this one and move it here (a teammate confirms,
1097
+ or it waits a day); recover confirm|cancel <who> is the teammate's side
896
1098
  pantheon worker stay answerable while you are away
897
1099
  (--agent claude-code|codex|cursor|copilot|kiro|auto, --persona <name>)
898
1100
  pantheon policy what this machine's worker may do, in one screen (grant/revoke self-upgrade)
@@ -995,6 +1197,7 @@ const COMMAND_FLAGS = {
995
1197
  admit: "<member> --may-wake | --no-wake --revoke --forget --yes",
996
1198
  wake: "allow <member> | deny <member> | list --space <team>",
997
1199
  trust: "[<member>] --yes --space <team>",
1200
+ recover: "[<team>] | withdraw <team> | confirm <member> | cancel <member> [--yes] --space <team> --no-worker --no-hook --no-notifications --no-menubar",
998
1201
  say: "<ACT> <text> --to <member>[,<member>] --goal <id> --ref <id | seq | last> --summary '<one line>' --domains <a,b> --branch --commit --remote (DELIVERED) --space <team>",
999
1202
  goal: "list | open \"<title>\" --with <member>[,…] [--acceptance \"…\"] [--deadline <ISO time>] [--list] | close <id> \"<summary>\"",
1000
1203
  history: "--goal <id> --from <member> --last <n> --since <seq> --short --presence --json",
@@ -1010,10 +1213,11 @@ const COMMAND_FLAGS = {
1010
1213
  use: "<team> --here --yes · pantheon use --unbind (this folder or repo bound to no team again)",
1011
1214
  repo: "add [--base <branch>] | list [--all] | find [<folder>] [--recents] [--yes]",
1012
1215
  login: "--email <address> --relay <url> --label <device name>",
1216
+ signin: "--relay <url>",
1013
1217
  signup: "--email <address> --username <name> --relay <url> --label <device name>",
1014
1218
  audit: "--space <team> --since <hours> --json",
1015
1219
  setup: "--questions (and every answer as a flag, with --yes)",
1016
- board: "--open --url --watch --keep --stop",
1220
+ board: "--open --url --watch --keep --stop --focus <id>",
1017
1221
  };
1018
1222
  const HELP_OPAQUE = new Set(["mcp", "hook", "pending", "tray-status", "help", "version", "-v", "--help", "-h"]);
1019
1223
  function rememberWorkingProxy() {
@@ -1104,7 +1308,7 @@ async function main() {
1104
1308
  if (helpAt.some((a) => a === "--help" || a === "-h")) {
1105
1309
  const u = usageFor(cmd, HELP_ALL);
1106
1310
  const flags = COMMAND_FLAGS[cmd];
1107
- const teamCmd = !["start", "join", "signup", "login", "verify", "whoami", "relay", "help", "version", "upgrade", "network", "ps", "stop", "service", "hook", "mcp", "pending", "tray-status", "uninstall"].includes(cmd);
1311
+ const teamCmd = !["start", "join", "signup", "signin", "login", "verify", "whoami", "relay", "help", "version", "upgrade", "network", "ps", "stop", "service", "hook", "mcp", "pending", "tray-status", "uninstall"].includes(cmd);
1108
1312
  const shown = flags && teamCmd && !/--space/.test(flags) ? `${flags}\n --space <team> (the team to act on, when this folder is bound to another or none)` : flags;
1109
1313
  console.log(u || shown ? `${u ?? ` pantheon ${cmd}`}${shown ? `\n\n${B("flags")}\n ${shown}` : ""}\n\n${D("nothing was run · all commands: pantheon help --all")}` : HELP);
1110
1314
  return;
@@ -1186,12 +1390,17 @@ async function main() {
1186
1390
  menubar: !argv.includes("--no-menubar"),
1187
1391
  unattended: wp.project,
1188
1392
  workerHow: wp.how,
1393
+ beforeCreate: lastTeamNotice,
1189
1394
  log: (l) => console.error(l),
1190
1395
  });
1191
1396
  for (const t of r.tools)
1192
1397
  console.error(` ${t.tool.padEnd(14)}${t.status}`);
1193
1398
  for (const w of r.warnings)
1194
1399
  console.error(`\x1b[33m ! ${w}\x1b[0m` + (/NOT_SIGNED_IN/.test(w) ? signInHint("NOT_SIGNED_IN", r.cfg.relay) : ""));
1400
+ const planWarning = r.claimed ? undefined : r.warnings.find((w) => /PLAN_LIMIT/.test(w));
1401
+ if (planWarning)
1402
+ for (const l of await teamLimitAdvice(planWarning))
1403
+ console.error(` ${l}`);
1195
1404
  printGranted(r.granted);
1196
1405
  printRelayNote(r.cfg?.relay);
1197
1406
  rememberWorkingProxy();
@@ -1225,6 +1434,7 @@ async function main() {
1225
1434
  console.error(D(` ${r.reach.hint}`));
1226
1435
  }
1227
1436
  if (r.claimed) {
1437
+ await bindProjectFromScratch();
1228
1438
  await bindReposHere(argv.includes("--yes"));
1229
1439
  await workerAfterBinding(wp.how);
1230
1440
  windowsOverridesForBoundRepos();
@@ -1389,17 +1599,25 @@ async function main() {
1389
1599
  die(e.message);
1390
1600
  const named = flag("relay") ?? process.env.PANTHEON_RELAY;
1391
1601
  const candidates = (named ? [named] : [load()?.relay, DEFAULT_RELAY]).filter((r) => !!r);
1392
- parsed = await resolveInviteCode(invite, candidates).catch((err) => die(err.message.replace(/^INVITE_CODE_[A-Z]+:\s*/, "")));
1602
+ parsed = await resolveInviteCode(invite, candidates).catch(async (err) => {
1603
+ const c = /INVITE_CODE_UNREACHABLE/.test(err.message) && err.nonJsonFrom ? await checkRelayCertificate(err.nonJsonFrom).catch(() => null) : null;
1604
+ const rest = err.message.replace(/^INVITE_CODE_[A-Z]+:\s*/, "");
1605
+ if (c?.publicCa === false) {
1606
+ const x = interceptionExplanation(c);
1607
+ die(`${x.why} — ${x.fix}.\n${rest.split("\n").slice(1).join("\n")}`);
1608
+ }
1609
+ return die(rest);
1610
+ });
1393
1611
  console.error(D(`That was just the code — it opens "${parsed.space}" on ${new URL(parsed.relay).host}. Joining that team.\n`));
1394
1612
  }
1395
1613
  if (!identity()?.account && sameHost(parsed.relay, DEFAULT_RELAY) && process.stdin.isTTY) {
1396
1614
  const ok = await interactiveOnboard(parsed.relay, { joining: parsed.space });
1397
1615
  if (!ok || !identity()?.account)
1398
- die(`not signed in yet — the invite is still good.\n When the code arrives: pantheon verify <code>\n Then run this same \`pantheon join\` command again.`);
1616
+ die(`not signed in yet — the invite is still good.\n When the code arrives: pantheon verify <code> (check spam if it is not in your inbox)\n Then run this same \`pantheon join\` command again.`);
1399
1617
  console.error("");
1400
1618
  }
1401
1619
  if (!identity()?.account && sameHost(parsed.relay, DEFAULT_RELAY)) {
1402
- die(`sign in first — this relay verifies email.\n New here: pantheon signup --email you@work.com --username you\n Have an account: pantheon login --email you@work.com\n Then run this same \`pantheon join\` command again — the invite is still good.`);
1620
+ die(`sign in first — this relay verifies email.\n${signInSteps().join("\n")}\n Then run this same \`pantheon join\` command again — the invite is still good.\n ${SIGNIN_FOR_AGENTS}`);
1403
1621
  }
1404
1622
  const wp = unattendedProject();
1405
1623
  console.error(`${B("pantheon join")}\n`);
@@ -1422,8 +1640,11 @@ async function main() {
1422
1640
  }
1423
1641
  }
1424
1642
  throw e;
1425
- }).catch((e) => {
1643
+ }).catch(async (e) => {
1426
1644
  const m = e.message;
1645
+ const intercepted = await interceptedText(e, parsed.relay);
1646
+ if (intercepted)
1647
+ die(intercepted);
1427
1648
  if (/BAD_INVITE/.test(m)) {
1428
1649
  const body = m.replace(/^BAD_INVITE:\s*/, "");
1429
1650
  die(/\n/.test(body) || /fresh one|new invite/i.test(body) ? body : `${body}\n Ask your teammate for a fresh one: pantheon invite`);
@@ -1478,6 +1699,7 @@ async function main() {
1478
1699
  await findAndBindRepos(all, m, teamRepos, { auto: !process.stdin.isTTY }).catch(() => { });
1479
1700
  }
1480
1701
  }
1702
+ await bindProjectFromScratch();
1481
1703
  await workerAfterBinding(wp.how);
1482
1704
  windowsOverridesForBoundRepos();
1483
1705
  {
@@ -2027,6 +2249,8 @@ async function main() {
2027
2249
  const worker = workerProc();
2028
2250
  const notifier = notifierProc();
2029
2251
  const cfg = load();
2252
+ const client = cfg ? new PantheonClient(cfg, undefined, { role: "cli" }) : null;
2253
+ const upP = client ? client.connect().then(() => true, () => false) : Promise.resolve(false);
2030
2254
  console.log(B("this machine"));
2031
2255
  if (relay.running) {
2032
2256
  const how = relay.managedBy === "launchd"
@@ -2044,8 +2268,16 @@ async function main() {
2044
2268
  }
2045
2269
  }
2046
2270
  else {
2047
- console.log(` relay ${D("not running")}${relay.label ? D(` (service ${relay.label} is installed but down)`) : ""}`);
2048
- console.log(` ${D("start it with: pantheon start")}`);
2271
+ const { relayRowsWhenNoneHere, relayReachable } = await import("./machine.js");
2272
+ const relays = [...new Set([...Object.values(loadAll()?.spaces ?? {}).map((m) => m.relay), ...(cfg?.relay ? [cfg.relay] : [])])];
2273
+ const reach = Object.fromEntries(await Promise.all(relays.map(async (u) => [u,
2274
+ await Promise.race([u === cfg?.relay ? upP : relayReachable(u), new Promise((r) => setTimeout(() => r(null), 5_000))])])));
2275
+ const rows = relayRowsWhenNoneHere(relays, port, reach);
2276
+ for (const [i, r] of rows.entries()) {
2277
+ const head = i === 0 ? " relay " : " ";
2278
+ const named = r.line !== "not running";
2279
+ console.log(`${head}${named ? r.line : D(r.line)}${i === 0 && relay.label && !named ? D(` (service ${relay.label} is installed but down)`) : ""}${r.dim ? ` ${D(r.dim)}` : ""}`);
2280
+ }
2049
2281
  }
2050
2282
  if (worker.running) {
2051
2283
  console.log(` worker ${C("running")} ${worker.managedBy === "launchd" ? `service ${worker.label}` : worker.managedBy === "host" ? `background host (${worker.label})` : "started by hand"} pid ${worker.pid}`);
@@ -2055,6 +2287,8 @@ async function main() {
2055
2287
  const paused = worker.managedBy === "host" && existsSync(hostFiles().disabled("worker"));
2056
2288
  console.log(` worker ${D(paused ? "paused" : "not running")} ${D(paused ? "pantheon service start worker resumes it" : "nobody can start an agent run on this machine")}`);
2057
2289
  }
2290
+ for (const l of await workerServesLines())
2291
+ console.log(` ${l}`);
2058
2292
  const menubar = menubarProc();
2059
2293
  if (process.platform === "win32") {
2060
2294
  const st = readHostState();
@@ -2086,7 +2320,7 @@ async function main() {
2086
2320
  }
2087
2321
  console.log(`\n${B("wired into")}`);
2088
2322
  for (const w of wiring())
2089
- console.log(` ${w.tool.padEnd(14)}${w.state === "not wired" ? D(w.state) : w.state}`);
2323
+ console.log(` ${w.tool.padEnd(14)}${w.state.startsWith("not wired") ? D(w.state) : w.state}`);
2090
2324
  const hk = agentHookStates();
2091
2325
  const intent = readIntent();
2092
2326
  if (!hk.length) {
@@ -2099,20 +2333,21 @@ async function main() {
2099
2333
  ? `\x1b[33mpartial\x1b[0m — ${summary}. Re-run: pantheon hook install`
2100
2334
  : `on — ${summary}`}`);
2101
2335
  }
2102
- if (intent?.repairs) {
2103
- console.log(` ${"".padEnd(14)}\x1b[33mput back ${intent.repairs} time${intent.repairs === 1 ? "" : "s"} after something removed it\x1b[0m`);
2104
- console.log(` ${"".padEnd(14)}${D("something on this machine rewrites ~/.claude/settings.json and drops the hooks key")}`);
2336
+ const repaired = repairNote(intent?.repairs);
2337
+ if (repaired) {
2338
+ console.log(` ${"".padEnd(14)}${repaired.recurring ? `\x1b[33m${repaired.said}\x1b[0m` : D(repaired.said)}`);
2339
+ if (repaired.why)
2340
+ console.log(` ${"".padEnd(14)}${D(repaired.why)}`);
2105
2341
  }
2106
2342
  const pers = loadPersonas(process.cwd());
2107
2343
  if (pers.length) {
2108
2344
  console.log(` ${"personas".padEnd(14)}${pers.map((p) => p.name).join(", ")} ${D("worker takes only matched work — pantheon persona list")}`);
2109
2345
  }
2110
- if (!cfg) {
2346
+ if (!cfg || !client) {
2111
2347
  console.log(`\n${D("not joined to a space yet — run: pantheon start")}`);
2112
2348
  return;
2113
2349
  }
2114
- const client = new PantheonClient(cfg, undefined, { role: "cli" });
2115
- const up = await client.connect().then(() => true).catch(() => false);
2350
+ const up = await upP;
2116
2351
  console.log(`\n${B("space")} ${cfg.space} you are ${C(cfg.member)}`);
2117
2352
  if (!up) {
2118
2353
  console.log(` ${D(client.pending ? "you are not admitted here right now" : "cannot reach the relay from this machine")}`);
@@ -2881,10 +3116,10 @@ async function main() {
2881
3116
  console.log(`account ${C(cfg.account.username)} ${D(`<${cfg.account.email}>`)} ${D("verified")}`);
2882
3117
  }
2883
3118
  else if (cfg.pendingAuth) {
2884
- console.log(`account ${D(`not signed in yet — a code was sent to ${cfg.pendingAuth.email}: pantheon verify <code>`)}`);
3119
+ console.log(`account ${D(`not signed in yet — a code was sent to ${cfg.pendingAuth.email} (check spam): pantheon verify <code>`)}`);
2885
3120
  }
2886
3121
  else {
2887
- console.log(`account ${D("not signed in")} ${D("— pantheon signup / pantheon login")}`);
3122
+ console.log(`account ${D("not signed in")} ${D("— pantheon signin (in a terminal), or pantheon signup / pantheon login")}`);
2888
3123
  }
2889
3124
  console.log(`member ${C(cfg.member)}`);
2890
3125
  const teams = Object.values(loadAll()?.spaces ?? {}).map((m) => m.name);
@@ -2894,6 +3129,26 @@ async function main() {
2894
3129
  console.log(D(`\nManage your devices and sessions: pantheon sessions`));
2895
3130
  return;
2896
3131
  }
3132
+ case "signin": {
3133
+ const relayArg = flag("relay");
3134
+ const had = identity();
3135
+ if (had?.account && (!relayArg || sameHost(relayArg, had.relay))) {
3136
+ console.error(`${C("already signed in")} as ${B(had.account.username)} <${had.account.email}> on this machine.`);
3137
+ console.error(D(` A different account? Sign this machine out first: pantheon logout — then pantheon signin again.`));
3138
+ console.error(` ${B("Tell your agent to continue;")} it will run the join or start again.`);
3139
+ return;
3140
+ }
3141
+ if (!process.stdin.isTTY) {
3142
+ die(`pantheon signin asks for your email and then the code it emails you, so it needs a terminal to type into.\n` +
3143
+ ` Run it in a terminal window of your own: ${pantheonCommand(managedLayout()?.launcher, `signin${relayArg ? ` --relay ${relayArg}` : ""}`)}\n` +
3144
+ ` Without a terminal, two commands do the same:\n` +
3145
+ ` pantheon signup --email you@work.com --username you${relayArg ? ` --relay ${relayArg}` : ""} (an account already: pantheon login --email you@work.com${relayArg ? ` --relay ${relayArg}` : ""})\n` +
3146
+ ` pantheon verify <code> (the code is emailed — check spam if it is not in your inbox)`);
3147
+ }
3148
+ if (!await interactiveOnboard(relayArg, { signin: true }))
3149
+ process.exit(1);
3150
+ return;
3151
+ }
2897
3152
  case "signup":
2898
3153
  case "login": {
2899
3154
  const purpose = cmd;
@@ -2910,20 +3165,20 @@ async function main() {
2910
3165
  const base = relay.replace(/^ws/, "http").replace(/\/+$/, "");
2911
3166
  const body = purpose === "signup" ? { email, username, pubkey: kp.publicKey, label } : { email, pubkey: kp.publicKey, label };
2912
3167
  const r = await fetch(`${base}/auth/${purpose}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
2913
- .then(async (x) => ({ status: x.status, j: await x.json() }))
2914
- .catch((e) => die(describeNetError(e, relay)));
3168
+ .then(async (x) => ({ status: x.status, j: await relayJson(x, `${base}/auth/${purpose}`) }))
3169
+ .catch(async (e) => die(await describeRelayFailure(e, relay)));
2915
3170
  if (r.status !== 200)
2916
3171
  die(String(r.j.error ?? `signup failed (${r.status})`));
2917
3172
  save({ ...(prior ?? {}), relay, member: username ?? prior?.member ?? "me", space: prior?.space ?? "", token: prior?.token ?? "",
2918
3173
  publicKey: kp.publicKey, privateKey: kp.privateKey, agent: prior?.agent ?? "unknown",
2919
- pendingAuth: { email, ...(username ? { username } : {}), purpose } });
3174
+ pendingAuth: { email, ...(username ? { username } : {}), purpose, at: Date.now() } });
2920
3175
  if (r.j.sent === false && r.j.via === "resend") {
2921
3176
  console.error(`${B("signup accepted, but the email could not be sent just now.")}`);
2922
3177
  console.error(`Retry in a minute: ${C(`pantheon ${purpose} --email ${email}${username ? ` --username ${username}` : ""}`)}`);
2923
3178
  return;
2924
3179
  }
2925
3180
  const via = r.j.via === "resend" ? "" : D(" (dev relay: the code is in the relay log)");
2926
- console.error(`${B("check your email")} — we sent a 6-digit code to ${C(email)}.${via}`);
3181
+ console.error(`${B("check your email")} — we sent a 6-digit code to ${C(email)}. ${SPAM_HINT}${via}`);
2927
3182
  console.error(`Then run: ${C("pantheon verify <code>")}`);
2928
3183
  return;
2929
3184
  }
@@ -2933,15 +3188,15 @@ async function main() {
2933
3188
  let code = (argv[1] ?? flag("code") ?? "").trim();
2934
3189
  if (!code && process.stdin.isTTY) {
2935
3190
  const rl = createInterface({ input: process.stdin, output: process.stderr });
2936
- code = (await questionOrNull(rl, `Enter the 6-digit code sent to ${pending.email}: `)) ?? "";
3191
+ code = (await questionOrNull(rl, `Enter the 6-digit code sent to ${pending.email} (not in your inbox? check spam): `)) ?? "";
2937
3192
  rl.close();
2938
3193
  }
2939
3194
  if (!code)
2940
3195
  die("usage: pantheon verify <6-digit-code>");
2941
3196
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
2942
3197
  const r = await fetch(`${base}/auth/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: pending.email, code, pubkey: cfg.publicKey }) })
2943
- .then(async (x) => ({ status: x.status, j: await x.json() }))
2944
- .catch((e) => die(describeNetError(e, cfg.relay)));
3198
+ .then(async (x) => ({ status: x.status, j: await relayJson(x, `${base}/auth/verify`) }))
3199
+ .catch(async (e) => die(await describeRelayFailure(e, cfg.relay)));
2945
3200
  if (r.status !== 200)
2946
3201
  die(String(r.j.error ?? "verification failed"));
2947
3202
  const acct = r.j.account;
@@ -3300,6 +3555,172 @@ async function main() {
3300
3555
  client.close();
3301
3556
  return;
3302
3557
  }
3558
+ case "recover": {
3559
+ if (inWokenRun())
3560
+ die("a woken run cannot move a seat or vouch for one — a person has to, on their own machine");
3561
+ const sub = argv[1] && !argv[1].startsWith("--") ? argv[1] : null;
3562
+ if (sub === "confirm" || sub === "cancel") {
3563
+ const who = argv[2] && !argv[2].startsWith("--") ? argv[2] : die(`usage: pantheon recover ${sub} <member> [--space <team>]`);
3564
+ const cfg = need();
3565
+ const client = new PantheonClient(cfg, undefined, { role: "cli" });
3566
+ await client.connect().catch((e) => die(client.pending ?? connectFailure(e, cfg.relay)));
3567
+ await new Promise((r) => setTimeout(r, 300));
3568
+ const e = client.members.find((x) => x.member === who);
3569
+ if (!e?.recovery) {
3570
+ client.close();
3571
+ die(`no move of ${who}'s seat is waiting in "${cfg.space}"`);
3572
+ }
3573
+ const { fp, label, requested_at, due_at } = e.recovery;
3574
+ console.log(`${B(`${who} asked to move their seat in ${cfg.space} to a new device`)}`);
3575
+ console.log(` device "${label}"`);
3576
+ console.log(` new key ${C(fp)}`);
3577
+ console.log(D(` asked ${ago(requested_at)} ago, after signing in with the code emailed to their account; it moves on its own ${new Date(due_at).toLocaleString()} unless someone cancels`));
3578
+ if (sub === "confirm") {
3579
+ if (who === cfg.member) {
3580
+ client.close();
3581
+ die("a seat move is confirmed by a teammate, not by the person whose seat it is");
3582
+ }
3583
+ console.log(D(`\n Check it with ${who} over a call or a channel you trust: they run pantheon whoami on the new machine and read you "this device". Confirm only when it matches.`));
3584
+ }
3585
+ else if (who === cfg.member) {
3586
+ console.log(D(`\n If you did not ask for this, someone has signed in to your email account: cancel it, then secure that account.`));
3587
+ }
3588
+ let go = false;
3589
+ if (sub === "confirm") {
3590
+ if (!process.stdin.isTTY) {
3591
+ client.close();
3592
+ die(`confirming is yours to do, in a terminal: pantheon recover confirm ${who}${teamFlag(cfg)} — after ${who} reads you the key from their new machine`);
3593
+ }
3594
+ const last = fp.split("-").at(-1);
3595
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3596
+ const a = ((await questionOrNull(rl, `Type the last group of the key ${who} reads you (4 characters), or press Enter to stop: `)) ?? "").trim().toLowerCase();
3597
+ rl.close();
3598
+ if (a && a !== last) {
3599
+ client.close();
3600
+ die(`that does not match the key waiting (…-${last}). Nothing changed — check again with ${who}.`);
3601
+ }
3602
+ go = a === last;
3603
+ }
3604
+ else {
3605
+ go = argv.includes("--yes");
3606
+ if (!go && process.stdin.isTTY) {
3607
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3608
+ const a = ((await questionOrNull(rl, `Cancel the move of ${who}'s seat? [y/N] `)) ?? "").trim().toLowerCase();
3609
+ rl.close();
3610
+ go = a === "y" || a === "yes";
3611
+ }
3612
+ }
3613
+ if (!go) {
3614
+ client.close();
3615
+ console.log(D(process.stdin.isTTY || sub === "confirm" ? " nothing changed" : ` nothing changed — to cancel it: pantheon recover cancel ${who} --yes${teamFlag(cfg)}`));
3616
+ return;
3617
+ }
3618
+ const body = sub === "confirm"
3619
+ ? `I checked with ${who}: the new key ${fp} is theirs. Confirming the move of their seat.`
3620
+ : `Cancelling the move of ${who}'s seat to the key ${fp}${who === cfg.member ? " — I did not ask for it" : ""}.`;
3621
+ await client.send({ act: "NOTE", body, to: null, meta: { recovery: { member: who, fp, action: sub, ...(sub === "confirm" ? { from_fp: e.fingerprint } : {}) } } })
3622
+ .catch((err) => { client.close(); die(err.message); });
3623
+ await new Promise((r) => setTimeout(r, 800));
3624
+ const { loadPins: lp, pinFor: pf } = await import("./pins.js");
3625
+ const pinned = pf(lp(), client.pinKey(), who)?.fp === fp;
3626
+ client.close();
3627
+ console.log(sub === "confirm"
3628
+ ? `${C("confirmed")} — ${who}'s seat is on their new device${pinned ? ", and this machine trusts the new key" : ""}. Teammates' machines take it from your signed confirmation.`
3629
+ : `${C("cancelled")} — ${who}'s seat stays on its current key. The team was told.`);
3630
+ return;
3631
+ }
3632
+ const id = identity() ?? die("not set up on this machine — sign in first, in a terminal: pantheon signin");
3633
+ if (!id.account)
3634
+ die("sign in on this machine first, in a terminal: pantheon signin — the code emailed to you is what proves the seat is yours");
3635
+ const acct = id.account;
3636
+ const base0 = id.relay.replace(/^ws/, "http").replace(/\/+$/, "");
3637
+ const call = (method, path, params = {}) => signedFetch(id, method, path, params)
3638
+ .then(async (x) => ({ status: x.status, j: await relayJson(x, `${base0}${path}`) }))
3639
+ .catch(async (e) => die(await describeRelayFailure(e, id.relay)));
3640
+ const noRecovery = (j) => /no accounts/.test(String(j.error ?? ""))
3641
+ ? "this relay has no accounts, so seats cannot be recovered by email here. A teammate retires the old key instead (pantheon admit <you> --forget) and invites you again."
3642
+ : "this relay is older than 0.33.2 and cannot move a seat yet — ask its operator to upgrade it, or have a teammate retire the old key (pantheon admit <you> --forget) and invite you again.";
3643
+ if (sub === "withdraw") {
3644
+ const team = argv[2] ?? die("usage: pantheon recover withdraw <team>");
3645
+ const r = await call("POST", "/account/recover/withdraw", { space: team });
3646
+ if (r.status !== 200)
3647
+ die(String(r.j.error ?? `the relay said ${r.status}`));
3648
+ console.log(`${C("withdrawn")} — your seat in ${team} stays where it was.`);
3649
+ return;
3650
+ }
3651
+ if (!sub) {
3652
+ const r = await call("GET", "/account/recover");
3653
+ if (r.status === 404)
3654
+ die(noRecovery(r.j));
3655
+ if (r.status !== 200)
3656
+ die(String(r.j.error ?? `the relay said ${r.status}`));
3657
+ const seats = (r.j.seats ?? []);
3658
+ console.log(`${B(`Your seats as ${acct.username}`)} ${D(`(this device: ${fingerprint(id.publicKey)})`)}\n`);
3659
+ if (!seats.length)
3660
+ console.log(D(" none — you are in no team on this relay yet"));
3661
+ for (const s of seats) {
3662
+ const name = s.space.padEnd(18);
3663
+ const line = s.state === "here" ? D("on this machine") :
3664
+ s.state === "ready" ? `moved here — save it on this machine: ${C(`pantheon recover ${s.space}`)}` :
3665
+ s.state === "waiting" ? `waiting — moves ${new Date(s.due_at).toLocaleString()} unless someone cancels; a teammate can confirm it sooner` :
3666
+ s.state === "movable" ? `on another of your devices (${s.fp}) — move it here: ${C(`pantheon recover ${s.space}`)}${D(s.alone ? " (you are alone in it: at once)" : " (a teammate confirms, or it waits a day)")}` :
3667
+ D(`its key ${s.fp} was never signed in to your account here, so it cannot be moved by email — a teammate who has checked it is you: pantheon admit ${acct.username} --forget, then a new invite`);
3668
+ console.log(` ${name}${line}${s.founder ? D(" (founder)") : ""}`);
3669
+ }
3670
+ return;
3671
+ }
3672
+ const team = sub;
3673
+ let r = await call("POST", "/account/recover", { space: team });
3674
+ if (r.status === 403 && r.j.reason === "sign_in_again" && process.stdin.isTTY) {
3675
+ console.error(`${B("First, prove it is you")} — a code goes to ${C(acct.email)} (look in spam too if it is not there in a minute).`);
3676
+ const base = id.relay.replace(/^ws/, "http").replace(/\/+$/, "");
3677
+ const post = (path, body) => fetch(`${base}${path}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })
3678
+ .then(async (x) => ({ status: x.status, j: await relayJson(x, `${base}${path}`) }))
3679
+ .catch(async (e) => die(await describeRelayFailure(e, id.relay)));
3680
+ const sent = await post("/auth/login", { email: acct.email, pubkey: id.publicKey, label: deviceLabel() });
3681
+ if (sent.status !== 200)
3682
+ die(String(sent.j.error ?? `the relay said ${sent.status}`));
3683
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3684
+ let ok = false;
3685
+ for (let i = 0; i < 3 && !ok; i++) {
3686
+ const code = ((await questionOrNull(rl, `Enter the 6-digit code sent to ${acct.email}: `)) ?? "").trim();
3687
+ if (!code)
3688
+ break;
3689
+ const v = await post("/auth/verify", { email: acct.email, code, pubkey: id.publicKey });
3690
+ if (v.status === 200)
3691
+ ok = true;
3692
+ else
3693
+ console.error(` ${String(v.j.error ?? "that code did not work")}`);
3694
+ }
3695
+ rl.close();
3696
+ if (!ok)
3697
+ die(`not verified — run pantheon recover ${team} again for a new code`);
3698
+ r = await call("POST", "/account/recover", { space: team });
3699
+ }
3700
+ if (r.status === 404 && !r.j.reason)
3701
+ die(noRecovery(r.j));
3702
+ if (r.status >= 400)
3703
+ die(String(r.j.error ?? `the relay said ${r.status}`));
3704
+ if (r.j.state === "waiting") {
3705
+ const others = (r.j.others ?? []);
3706
+ console.log(`${B(`Asked: your seat in ${team} moves to this machine`)} ${D(`(new key ${String(r.j.fp)})`)}`);
3707
+ console.log(` It moves when a teammate confirms it — ${others.join(", ") || "your teammates"} ${others.length === 1 ? "was" : "were"} told — or on its own ${new Date(Number(r.j.due_at)).toLocaleString()} if nobody cancels.`);
3708
+ console.log(` Speed it up: ask one of them to check your key with you (read them "this device" from pantheon whoami ) and run ${C(`pantheon recover confirm ${acct.username} --space ${team}`)}`);
3709
+ console.log(D(` Once it has moved, run pantheon recover ${team} again here to save the team on this machine.`));
3710
+ return;
3711
+ }
3712
+ const token = String(r.j.invite ?? "");
3713
+ if (r.j.state !== "moved" || !token)
3714
+ die(`unexpected answer from the relay: ${JSON.stringify(r.j).slice(0, 200)}`);
3715
+ const how = r.j.how === "alone" ? " (you are alone in it)" : r.j.how === "confirmed" ? " (a teammate confirmed it)" : r.j.how === "waited" ? " (nobody cancelled in the time it waited)" : "";
3716
+ console.error(`${C("moved")} — your seat in ${team} is on this machine now${how}. Saving the team here, as a join does:\n`);
3717
+ console.error(D(` If the old machine is lost or not yours any more, sign it out of your account too: pantheon sessions (then pantheon sessions revoke <device-id> --device)\n`));
3718
+ const { spawnSync: run } = await import("node:child_process");
3719
+ const invite = toInvite({ ...id, space: String(r.j.space ?? team), token });
3720
+ const keep = argv.slice(2).filter((a) => /^--(no-worker|no-hook|no-notifications|no-menubar|yes)$/.test(a));
3721
+ const res = run(process.execPath, [process.argv[1], "join", invite, ...keep], { stdio: "inherit" });
3722
+ process.exit(res.status ?? 1);
3723
+ }
3303
3724
  case "trust": {
3304
3725
  const { WOKEN_RUN_ENV } = await import("./worker-policy.js");
3305
3726
  const inRun = process.env[WOKEN_RUN_ENV] !== undefined || /[\\/]run-homes[\\/]/.test(process.env.PANTHEON_HOME ?? "");
@@ -3561,8 +3982,8 @@ async function main() {
3561
3982
  home: uriHome,
3562
3983
  wokenRun: inWokenRun(),
3563
3984
  runQueued: (actionId) => spawnSync(process.execPath, self(["explain", "--queued", "--only-action", "--action-id", actionId]), { stdio: "inherit", env: childEnv }).status ?? 1,
3564
- openBoard: hasBoard ? () => new Promise((done) => {
3565
- const c = spawn(process.execPath, self(["board", "--open"]), { detached: true, stdio: "ignore", windowsHide: true, env: childEnv });
3985
+ openBoard: hasBoard ? (focus) => new Promise((done) => {
3986
+ const c = spawn(process.execPath, self(["board", "--open", ...(focus ? ["--focus", focus] : [])]), { detached: true, stdio: "ignore", windowsHide: true, env: childEnv });
3566
3987
  const t = setTimeout(() => { c.unref(); done(true); }, 4_000);
3567
3988
  c.on("error", () => { clearTimeout(t); done(false); });
3568
3989
  c.on("exit", (status) => { clearTimeout(t); done(status === 0); });
@@ -3794,6 +4215,42 @@ async function main() {
3794
4215
  console.log(`sent RETRACT id=${sent.id} seq=${sent.seq}`);
3795
4216
  return;
3796
4217
  }
4218
+ if (hud?.action === "wake") {
4219
+ const notSent = (why) => { console.log(`not sent: ${why}`); process.exit(1); };
4220
+ const { loadPolicy, setLocalWaker, markTeamFlagOnYes, applyLocalAnswer, describeLocalAnswer } = await import("./worker-policy.js");
4221
+ const ref = hud.act;
4222
+ const wa = ref ? loadPolicy().wakeAsks?.[ref] : undefined;
4223
+ if (!wa || !hud.member || wa.member !== hud.member)
4224
+ notSent("that question is no longer open on this machine — pantheon wake list shows who may wake it");
4225
+ if (![cfg.spaceId, cfg.space].includes(wa.team))
4226
+ notSent(`that question is about the team "${wa.team}", not "${hud.space ?? cfg.space}" — answer it from that team's board`);
4227
+ const allow = hud.allow === true;
4228
+ const r = setLocalWaker(allow ? [wa.team] : [wa.team, cfg.spaceId, cfg.space], wa.member, allow);
4229
+ if (!r.ok)
4230
+ notSent(r.why);
4231
+ if (allow && wa.teamBlocked)
4232
+ markTeamFlagOnYes(wa.team, wa.member);
4233
+ const body = allow ? "allow always" : "deny";
4234
+ const g = applyLocalAnswer(ref, body);
4235
+ if (g)
4236
+ console.error(describeLocalAnswer(g));
4237
+ const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
4238
+ try {
4239
+ await c2.connect();
4240
+ const o = c2.obligations.find((x) => x.id === hud.decision || x.ref === ref);
4241
+ if (o) {
4242
+ const sent = await c2.send({ act: "ANSWER", ref: ref, goal: null, after_seq: c2.readSeq, body, meta: { surface: "menubar" } });
4243
+ console.log(`sent ANSWER id=${sent.id} seq=${sent.seq}`);
4244
+ }
4245
+ }
4246
+ catch (e) {
4247
+ console.error(D(` recorded on this machine; the question could not be marked answered on the relay (${e.message.split("\n")[0]}) — it leaves your board once it is`));
4248
+ }
4249
+ finally {
4250
+ c2.close();
4251
+ }
4252
+ return;
4253
+ }
3797
4254
  if (hud?.action === "done") {
3798
4255
  const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
3799
4256
  await c2.connect().catch((e) => die(connectFailure(e, cfg.relay)));
@@ -4005,6 +4462,8 @@ async function main() {
4005
4462
  }
4006
4463
  }
4007
4464
  if (hud.prompt) {
4465
+ if (await claudeAppInstead(hud.tool ?? p.agent.primary_surface, hud.prompt))
4466
+ return;
4008
4467
  const agent = pickExplainAgent(hud.tool ?? p.agent.primary_surface, detectAvailable());
4009
4468
  if (!agent)
4010
4469
  die("no agent CLI found on this machine (claude, cursor-agent, codex, copilot, or kiro-cli)");
@@ -4059,6 +4518,8 @@ async function main() {
4059
4518
  console.log(D("no decisions waiting on you — nothing to explain"));
4060
4519
  return;
4061
4520
  }
4521
+ if (await claudeAppInstead(loadProfile(one?.cfg ?? cfg).agent.primary_surface, prompt))
4522
+ return;
4062
4523
  const agent = pickExplainAgent(loadProfile(one?.cfg ?? cfg).agent.primary_surface, detectAvailable());
4063
4524
  if (!agent)
4064
4525
  die("no agent CLI found on this machine (claude, cursor-agent, codex, copilot, or kiro-cli)");
@@ -4175,7 +4636,7 @@ async function main() {
4175
4636
  (await import("./machine.js")).recordWorkerPid();
4176
4637
  const allCfg = loadAll();
4177
4638
  const bindings = Object.values(allCfg?.bindings ?? {});
4178
- if (!flag("project") && bindings.length) {
4639
+ if (!flag("project")) {
4179
4640
  const { WorkerFleet } = await import("./worker-fleet.js");
4180
4641
  const fleet = new WorkerFleet({
4181
4642
  ...(flag("agent-cmd") ? { agentCmd: flag("agent-cmd"), agentCmdFromCli: true } : { agent: flag("agent", "auto") }),
@@ -4195,7 +4656,7 @@ async function main() {
4195
4656
  }),
4196
4657
  log: (l) => console.error(l),
4197
4658
  });
4198
- console.error(`${B("pantheon worker")} ${D(`(${bindings.length} bound folder${bindings.length === 1 ? "" : "s"} · ctrl-c to stop)`)}`);
4659
+ console.error(`${B("pantheon worker")} ${D(`(${bindings.length ? `${bindings.length} bound folder${bindings.length === 1 ? "" : "s"}` : "nothing bound yet — a folder bound with pantheon use <team> --here is served as soon as it is"} · ctrl-c to stop)`)}`);
4199
4660
  if (argv.includes("--allow-bash")) {
4200
4661
  console.error(`\x1b[33m --allow-bash: a woken run may execute commands. Only do this if you trust every\x1b[0m`);
4201
4662
  console.error(`\x1b[33m teammate approved to wake this machine (pantheon wake list), since their request is what starts the run.\x1b[0m`);
@@ -4264,7 +4725,11 @@ async function main() {
4264
4725
  if (!argv.includes("--open"))
4265
4726
  return;
4266
4727
  }
4267
- const url = argv.includes("--url") ? await b.boardLinkFor(r.state).catch((e) => die(e.message)) : r.url;
4728
+ const focus = flag("focus");
4729
+ if (focus !== undefined && !b.FOCUS_RE.test(focus))
4730
+ die("--focus takes an act or decision id (a_… or o_…)");
4731
+ const plain = argv.includes("--url") ? await b.boardLinkFor(r.state).catch((e) => die(e.message)) : r.url;
4732
+ const url = focus && r.state.version === VERSION ? b.focusLink(plain, focus) : plain;
4268
4733
  const opened = argv.includes("--no-browser") ? false : await b.openBrowser(url).catch(() => false);
4269
4734
  console.error(`${B("pantheon board")} ${D(r.reused ? "(already running)" : "(started)")}`);
4270
4735
  if (!opened)
@@ -4272,6 +4737,8 @@ async function main() {
4272
4737
  else
4273
4738
  console.error(D(" opened in your browser · pantheon board --url prints its address · --watch shows it in this terminal"));
4274
4739
  console.error(D(r.state.keep ? " it stays running" : " it stops itself after 30 minutes with no open tab"));
4740
+ if (!opened && argv.includes("--open") && !argv.includes("--no-browser"))
4741
+ process.exit(1);
4275
4742
  return;
4276
4743
  }
4277
4744
  case "tray": {
@@ -4694,7 +5161,7 @@ async function main() {
4694
5161
  }
4695
5162
  }
4696
5163
  }
4697
- main().catch((e) => die(describeNetError(e, (() => { try {
5164
+ main().catch(async (e) => die(await describeRelayFailure(e, (() => { try {
4698
5165
  return load()?.relay;
4699
5166
  }
4700
5167
  catch {