@join-pantheon/cli 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -119
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +507 -38
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +88 -15
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1193 -185
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +105 -36
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
@@ -2,11 +2,11 @@ import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:net";
4
4
  import { basename, join as pathJoin } from "node:path";
5
- import { homedir, hostname, platform } from "node:os";
5
+ import { homedir, hostname, platform, userInfo } from "node:os";
6
6
  import { lookup } from "node:dns/promises";
7
7
  import { describeNetError } from "./network.js";
8
8
  import { PantheonClient } from "./client.js";
9
- import { load, identity, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
9
+ import { load, loadAll, identity, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
10
10
  import { loadProfile, wakeDefault } from "./profile.js";
11
11
  import { newKeypair } from "./identity.js";
12
12
  import { installForUser } from "./install.js";
@@ -29,6 +29,9 @@ const sh = (cmd) => {
29
29
  return null;
30
30
  }
31
31
  };
32
+ const notLoaded = (r) => r && typeof r === "object" && r.loaded === false
33
+ ? String(r.reason ?? "the service manager did not load it")
34
+ : null;
32
35
  export function wireUp(opts, deps = {}) {
33
36
  const installSvc = deps.installService ?? installService;
34
37
  const ensureNotifier = deps.ensureNotifier ?? ensureNativeNotifier;
@@ -63,9 +66,14 @@ export function wireUp(opts, deps = {}) {
63
66
  else if (opts.notifications) {
64
67
  try {
65
68
  ensureNotifier();
66
- installSvc("notifier", homedir(), []);
69
+ const why = notLoaded(installSvc("notifier", homedir(), []));
67
70
  writeTrayCommands();
68
- tools.push({ tool: "notifications", status: "on — native alerts for decisions that require you" });
71
+ if (why) {
72
+ tools.push({ tool: "notifications", status: `installed, not running: ${why}` });
73
+ warnings.push(`human notifications are installed but not running (${why}). Try \`pantheon service restart notifier\``);
74
+ }
75
+ else
76
+ tools.push({ tool: "notifications", status: "on — native alerts for decisions that require you" });
69
77
  }
70
78
  catch (e) {
71
79
  tools.push({ tool: "notifications", status: `could not install: ${e.message}` });
@@ -82,8 +90,13 @@ export function wireUp(opts, deps = {}) {
82
90
  try {
83
91
  ensureTray();
84
92
  writeTrayCommands();
85
- installSvc("menubar", homedir(), []);
86
- tools.push({ tool: "menu bar", status: "on — status, pause/resume, and decision badge in your menu bar" });
93
+ const why = notLoaded(installSvc("menubar", homedir(), []));
94
+ if (why) {
95
+ tools.push({ tool: "menu bar", status: `installed, not running: ${why}` });
96
+ warnings.push(`the menu bar item is installed but not running (${why}). Try \`pantheon service restart menubar\``);
97
+ }
98
+ else
99
+ tools.push({ tool: "menu bar", status: "on — status, pause/resume, and decision badge in your menu bar" });
87
100
  }
88
101
  catch (e) {
89
102
  tools.push({ tool: "menu bar", status: `could not install: ${e.message}` });
@@ -102,7 +115,12 @@ export function wireUp(opts, deps = {}) {
102
115
  }
103
116
  else if (opts.unattended) {
104
117
  const project = opts.unattended;
105
- const isRepo = sh(`git -C ${JSON.stringify(project)} rev-parse --is-inside-work-tree 2>/dev/null`) === "true";
118
+ const isRepo = (() => { try {
119
+ return execFileSync("git", ["-C", project, "rev-parse", "--is-inside-work-tree"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim() === "true";
120
+ }
121
+ catch {
122
+ return false;
123
+ } })();
106
124
  if (!isRepo && how === "explicit") {
107
125
  tools.push({ tool: "worker", status: `not installed — ${project} is not a git repo` });
108
126
  warnings.push(`--unattended needs a git repo to work in; pass --project <a clone>. Nothing was installed.`);
@@ -112,9 +130,14 @@ export function wireUp(opts, deps = {}) {
112
130
  }
113
131
  else {
114
132
  try {
115
- installSvc("worker", project, []);
133
+ const why = notLoaded(installSvc("worker", project, []));
134
+ if (why) {
135
+ tools.push({ tool: "worker", status: `installed, not running: ${why}` });
136
+ 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\``);
137
+ return { tools, granted, warnings };
138
+ }
116
139
  tools.push({ tool: "worker", status: `on — answers peers while you are away (${project})` });
117
- granted.push(`A peer you have granted may_wake can start an agent run on this machine, in an isolated worktree under ${project}.`);
140
+ granted.push(`A teammate you approve from this machine (pantheon admit <name> --may-wake) — or anyone on the team, if you choose wake "anyone" — can start an agent run here, in an isolated worktree under ${project}. Nobody can until then.`);
118
141
  if (!existsSync(policyPath())) {
119
142
  const pol = loadPolicy();
120
143
  grantCap(pol, "self_upgrade", "always", "setup-default");
@@ -146,6 +169,47 @@ export function slugName(raw, fallback) {
146
169
  .replace(/^[-._]+|[-._]+$/g, "");
147
170
  return /[a-z0-9]/.test(s) ? s : fallback;
148
171
  }
172
+ export function osUsername(env = process.env, info = () => userInfo().username) {
173
+ const tries = [info, () => env.USERNAME, () => env.USER];
174
+ for (const t of tries) {
175
+ try {
176
+ const v = t()?.trim();
177
+ if (v && v.toLowerCase() !== "root")
178
+ return v;
179
+ }
180
+ catch { }
181
+ }
182
+ return null;
183
+ }
184
+ export function defaultMemberName(env = process.env, info, host = hostname) {
185
+ const user = osUsername(env, info);
186
+ const random = `member-${randomBytes(3).toString("hex")}`;
187
+ if (user) {
188
+ const s = slugName(user, "");
189
+ if (s)
190
+ return s;
191
+ }
192
+ let h = "";
193
+ try {
194
+ h = host().split(".")[0] ?? "";
195
+ }
196
+ catch { }
197
+ const s = slugName(h, "");
198
+ return s && s !== "localhost" ? `${s}` : random;
199
+ }
200
+ export function deviceLabel(plat = platform(), env = process.env, info, host = hostname) {
201
+ const os = plat === "darwin" ? "Mac" : plat === "win32" ? "Windows" : plat === "linux" ? "Linux" : plat;
202
+ let who = osUsername(env, info);
203
+ if (!who) {
204
+ try {
205
+ who = host().split(".")[0] || null;
206
+ }
207
+ catch {
208
+ who = null;
209
+ }
210
+ }
211
+ return `${who ?? "a"}'s ${os}`;
212
+ }
149
213
  export function teamNameFor(dir, repo = repoInfo(dir)) {
150
214
  const fromOrigin = repo?.origin ? (repo.origin.replace(/\.git$/i, "").split(/[/:]/).filter(Boolean).pop() ?? "") : "";
151
215
  return slugName(fromOrigin || basename(repo?.root ?? dir), "team");
@@ -297,7 +361,7 @@ export async function start(opts) {
297
361
  ` Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
298
362
  : { url: chosen, how: place === "this-machine" ? "this machine's address" : place === "tunnel" ? "a tunnel to this machine" : opts.relay ? "as given" : "your signed-in relay", shareable: true })
299
363
  : findReach(opts.port);
300
- const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
364
+ const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, inviteMayWake: false, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
301
365
  let relay = null;
302
366
  if (chosen && !hostHere) {
303
367
  const probe = await probeRelay(reach.url, opts.remoteTimeoutMs ?? 10_000, opts.fetchImpl);
@@ -340,7 +404,7 @@ export async function start(opts) {
340
404
  relay: reach.url,
341
405
  space: opts.space ?? teamNameFor(process.cwd(), repo),
342
406
  token: randomBytes(16).toString("hex"),
343
- member: opts.me ?? (keepIdentity ? existing.member : slugName(process.env.USER ?? "me", "me")),
407
+ member: opts.me ?? (keepIdentity ? existing.member : defaultMemberName()),
344
408
  agent: detectAgent(),
345
409
  ...(keepIdentity ? { publicKey: existing.publicKey, privateKey: existing.privateKey } : newKeypair()),
346
410
  ...(keepIdentity && existing.account ? { account: existing.account } : {}),
@@ -354,6 +418,10 @@ export async function start(opts) {
354
418
  warnings.push(`you are already "${cfg.member}" here; --me ${opts.me} ignored (use --reset to start over)`);
355
419
  }
356
420
  }
421
+ else if (keepIdentity && existing.space) {
422
+ opts.log(` you ${cfg.member}, as in "${existing.space}" — that team stays; this adds one`);
423
+ opts.log(` default "${cfg.space}" from now on for folders bound to no team — pantheon use ${existing.space} switches back`);
424
+ }
357
425
  else if (keepIdentity && existing.account) {
358
426
  opts.log(` you ${cfg.member} — signed in; creating your first team`);
359
427
  }
@@ -361,6 +429,7 @@ export async function start(opts) {
361
429
  warnings.push(`replacing your identity in "${existing.space}" — that space is no longer reachable from this machine`);
362
430
  }
363
431
  let invite = null;
432
+ let inviteMayWake = false;
364
433
  let claimed = false;
365
434
  const claim = async (c) => {
366
435
  const client = new PantheonClient(c);
@@ -374,13 +443,17 @@ export async function start(opts) {
374
443
  }
375
444
  if (repo && c.spaceId) {
376
445
  const { replaced } = bindRepo(repo, c.spaceId);
377
- if (replaced)
378
- warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${c.space}"`);
446
+ const was = replaced ?? (here.binding && here.binding.space !== c.spaceId ? here.binding : null);
447
+ if (was) {
448
+ const name = Object.values(loadAll()?.spaces ?? {}).find((m) => m.id === was.space)?.name ?? was.space;
449
+ warnings.push(`this repo was bound to "${name}"; it now belongs to "${c.space}" — agents opened here speak in "${c.space}" from now on (pantheon use ${name} --here moves it back)`);
450
+ }
379
451
  }
380
452
  opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : ""}`);
381
453
  const mayWake = opts.wake ? opts.wake === "anyone" : wakeDefault(loadProfile(c));
382
454
  const inv = await client.makeInvite(mayWake, opts.ttlMinutes);
383
455
  invite = toInvite({ ...c, token: inv.url });
456
+ inviteMayWake = mayWake;
384
457
  claimed = true;
385
458
  }
386
459
  finally {
@@ -421,7 +494,7 @@ export async function start(opts) {
421
494
  ...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
422
495
  });
423
496
  warnings.push(...wired.warnings);
424
- return { cfg, invite, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
497
+ return { cfg, invite, inviteMayWake, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
425
498
  }
426
499
  export async function join(opts) {
427
500
  const existing = opts.reset ? null : (load() ?? identity());
@@ -436,7 +509,7 @@ export async function join(opts) {
436
509
  relay: opts.invite.relay,
437
510
  space: opts.invite.space,
438
511
  token: "",
439
- member: opts.me ?? existing?.member ?? slugName(process.env.USER ?? "me", "me"),
512
+ member: opts.me ?? existing?.member ?? defaultMemberName(),
440
513
  agent: detectAgent(),
441
514
  ...keys,
442
515
  ...(existing?.account ? { account: existing.account } : {}),
package/dist/reads.js ADDED
@@ -0,0 +1,30 @@
1
+ import { signRead } from "./identity.js";
2
+ let clockSkewMs = 0;
3
+ export const relayClockSkew = () => clockSkewMs;
4
+ export function learnRelayClock(res) {
5
+ const there = Date.parse(res.headers.get("date") ?? "");
6
+ if (!Number.isFinite(there) || Math.abs(there - Date.now() - clockSkewMs) <= 60_000)
7
+ return false;
8
+ clockSkewMs = there - Date.now();
9
+ return true;
10
+ }
11
+ export const httpBase = (relay) => relay.replace(/^ws/, "http").replace(/\/+$/, "");
12
+ export function signedReadQuery(cfg, path, params, at) {
13
+ const full = { space: cfg.space, token: cfg.token };
14
+ for (const [k, v] of Object.entries(params))
15
+ if (v !== undefined)
16
+ full[k] = String(v);
17
+ full.reader = cfg.member;
18
+ full.ts = String(at);
19
+ const sig = signRead(cfg.privateKey, "GET", path, full);
20
+ return new URLSearchParams({ ...full, sig }).toString();
21
+ }
22
+ export async function signedRead(cfg, path, params = {}, opts = {}) {
23
+ const signal = opts.signal ?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined);
24
+ const go = () => fetch(`${httpBase(cfg.relay)}${path}?${signedReadQuery(cfg, path, params, Date.now() + clockSkewMs)}`, signal ? { signal } : {});
25
+ const res = await go();
26
+ if (res.status === 403 && learnRelayClock(res))
27
+ return go();
28
+ return res;
29
+ }
30
+ //# sourceMappingURL=reads.js.map
package/dist/refusal.js CHANGED
@@ -21,9 +21,22 @@ export function connectFailure(e, relay) {
21
21
  const r = refusalOf(e);
22
22
  if (r) {
23
23
  const fix = r.fix && !/pantheon \w/.test(r.message) ? `\n ${r.fix}` : "";
24
- return `the relay refused this machine (${r.code}): ${r.message}${fix}`;
24
+ return `the relay refused this machine (${r.code}): ${r.message}${fix}${signInHint(r.code, relay)}`;
25
25
  }
26
26
  const text = typeof e === "string" ? e : String(e?.message ?? e ?? "");
27
27
  return `cannot reach the relay at ${relay} — ${text}`;
28
28
  }
29
+ export function signInHint(code, relay, hosted = process.env.PANTHEON_DEFAULT_RELAY ?? "wss://relay.joinpantheon.network") {
30
+ if (code !== "NOT_SIGNED_IN" || !relay)
31
+ return "";
32
+ try {
33
+ if (new URL(relay).host === new URL(hosted).host)
34
+ return "";
35
+ }
36
+ catch {
37
+ return "";
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})`;
41
+ }
29
42
  //# sourceMappingURL=refusal.js.map