@join-pantheon/cli 0.29.1 → 0.31.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 (57) hide show
  1. package/README.md +142 -40
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +224 -17
  5. package/dist/claude-confine.js +299 -0
  6. package/dist/cli.js +1116 -137
  7. package/dist/client.js +294 -10
  8. package/dist/command-state.js +102 -0
  9. package/dist/config.js +287 -14
  10. package/dist/continued.js +42 -10
  11. package/dist/doctor.js +191 -23
  12. package/dist/file-lock.js +197 -0
  13. package/dist/grants.js +1 -1
  14. package/dist/history-view.js +2 -1
  15. package/dist/home-guard.js +565 -0
  16. package/dist/hooks-install.js +2 -1
  17. package/dist/identity.js +101 -1
  18. package/dist/install.js +76 -26
  19. package/dist/kiro-files.js +2 -2
  20. package/dist/ledger.js +111 -28
  21. package/dist/machine.js +494 -32
  22. package/dist/mcp-supervisor.js +29 -7
  23. package/dist/mcp-windows.js +47 -25
  24. package/dist/mcp.js +99 -36
  25. package/dist/network.js +39 -8
  26. package/dist/nonces.js +130 -0
  27. package/dist/notify.js +26 -9
  28. package/dist/pending.js +121 -22
  29. package/dist/persona.js +21 -0
  30. package/dist/pins.js +161 -0
  31. package/dist/profile.js +22 -1
  32. package/dist/protocol.js +40 -1
  33. package/dist/quickstart.js +173 -24
  34. package/dist/reads.js +37 -0
  35. package/dist/refusal.js +14 -1
  36. package/dist/relay.js +1341 -207
  37. package/dist/repo-find.js +127 -0
  38. package/dist/repo-route.js +70 -0
  39. package/dist/roster-file.js +190 -25
  40. package/dist/run-failure.js +99 -3
  41. package/dist/secret-guard.js +141 -0
  42. package/dist/service.js +91 -35
  43. package/dist/setup-guide.js +91 -31
  44. package/dist/spend.js +6 -2
  45. package/dist/store.js +94 -10
  46. package/dist/text-file.js +74 -0
  47. package/dist/tray.js +24 -3
  48. package/dist/untrusted.js +24 -5
  49. package/dist/update-check.js +2 -0
  50. package/dist/upgrade.js +9 -5
  51. package/dist/verify-act.js +102 -0
  52. package/dist/which.js +69 -12
  53. package/dist/worker-fleet.js +30 -9
  54. package/dist/worker-policy.js +231 -7
  55. package/dist/worker.js +1717 -130
  56. package/helper/macos/menubar.swift +63 -2
  57. package/package.json +2 -2
package/dist/protocol.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { randomBytes } from "node:crypto";
2
3
  export const PROTOCOL_VERSION = 3;
3
4
  export const ACTS = [
4
5
  "JOIN",
@@ -35,6 +36,7 @@ export const LIMITS = {
35
36
  activityBusyTtlMs: 30 * 60 * 1_000,
36
37
  activityPruneMs: 7 * 24 * 60 * 60 * 1_000,
37
38
  claimTtlMs: 300_000,
39
+ claimLiveTtlMs: 30 * 60_000,
38
40
  workerFallbackMs: 20_000 + 120_000 + 60_000,
39
41
  pendingTimeoutMs: 1_500,
40
42
  cursorTtlMs: 7 * 24 * 60 * 60 * 1_000,
@@ -80,6 +82,37 @@ function loopback(host) {
80
82
  return "0.0.0.0";
81
83
  return null;
82
84
  }
85
+ export const DelegateCertSchema = z.object({
86
+ pub: z.string().min(32).max(200),
87
+ run: z.string().min(1).max(64),
88
+ expires: z.number().int().positive(),
89
+ sig: z.string().min(32).max(200),
90
+ });
91
+ export const ActSigSchema = z.object({
92
+ v: z.literal(1),
93
+ role: z.enum(["member", "run"]),
94
+ nonce: z.string().min(8).max(64),
95
+ ts: z.number().int().positive(),
96
+ pub: z.string().min(32).max(200),
97
+ s: z.string().min(32).max(200),
98
+ meta: z.record(z.unknown()).optional(),
99
+ repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
100
+ cert: DelegateCertSchema.optional(),
101
+ });
102
+ export const GoalSigSchema = z.object({
103
+ v: z.literal(1),
104
+ role: z.enum(["member", "run"]),
105
+ nonce: z.string().min(8).max(64),
106
+ ts: z.number().int().positive(),
107
+ pub: z.string().min(32).max(200),
108
+ s: z.string().min(32).max(200),
109
+ invite: z.array(z.string().min(1).max(48)).max(16).nullable(),
110
+ budget: z.number().int().nullable().optional(),
111
+ deadline: z.number().int().nullable().optional(),
112
+ kind: z.string().max(32).nullable().optional(),
113
+ cert: DelegateCertSchema.optional(),
114
+ });
115
+ export const RELAY_FEATURES = ["act-sig", "delegate"];
83
116
  export const Hello = z.object({
84
117
  t: z.literal("hello"),
85
118
  v: z.number().int().optional(),
@@ -98,6 +131,7 @@ export const Hello = z.object({
98
131
  from_seq: z.number().int().nonnegative().optional(),
99
132
  repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
100
133
  repo_origin: z.string().max(200).optional(),
134
+ delegate: DelegateCertSchema.optional(),
101
135
  });
102
136
  export const OrganizationProfileSchema = z.object({
103
137
  version: z.literal(1),
@@ -147,12 +181,14 @@ export const Admit = z.object({
147
181
  member: z.string().min(1).max(48),
148
182
  may_wake: z.boolean().default(false),
149
183
  revoke: z.boolean().default(false),
184
+ wake_only: z.boolean().optional(),
150
185
  forget: z.boolean().default(false),
151
186
  });
152
187
  export const SendAct = z.object({
153
188
  t: z.literal("act"),
154
189
  cid: z.string().max(64).optional(),
155
190
  after_seq: z.number().int().nonnegative().optional(),
191
+ id: z.string().regex(/^a_[A-Za-z0-9]{6,48}$/).optional(),
156
192
  act: z.enum(ACTS),
157
193
  body: z.string().max(LIMITS.bodyChars),
158
194
  summary: z.string().max(LIMITS.summaryChars).optional(),
@@ -163,6 +199,7 @@ export const SendAct = z.object({
163
199
  affects: z.array(z.string().min(1).max(48)).max(16).optional(),
164
200
  meta: z.record(z.unknown()).optional(),
165
201
  repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/).optional(),
202
+ sig: ActSigSchema.optional(),
166
203
  });
167
204
  export const BindRepo = z.object({
168
205
  t: z.literal("bind_repo"),
@@ -174,12 +211,14 @@ export const BindRepo = z.object({
174
211
  export const OpenGoal = z.object({
175
212
  t: z.literal("open_goal"),
176
213
  cid: z.string().max(64).optional(),
214
+ id: z.string().regex(/^g_[a-z0-9]{8,40}$/).optional(),
177
215
  title: z.string().min(1).max(200),
178
216
  acceptance: z.string().max(2000).default(""),
179
217
  invite: z.array(z.string().min(1).max(48)).max(16).optional(),
180
218
  budget: z.number().int().min(4).max(1000).optional(),
181
219
  deadline: z.number().int().positive().optional(),
182
220
  kind: z.enum(["worklist"]).optional(),
221
+ sig: GoalSigSchema.optional(),
183
222
  });
184
223
  export const Ping = z.object({ t: z.literal("ping") });
185
224
  export const DeleteSpace = z.object({
@@ -223,6 +262,6 @@ export function isContinuationFor(act, me, ctx) {
223
262
  && ctx.goalMembers.includes(me);
224
263
  }
225
264
  export function newId(prefix) {
226
- return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
265
+ return `${prefix}_${Date.now().toString(36)}${randomBytes(5).readUIntBE(0, 5).toString(36).padStart(8, "0").slice(0, 8)}`;
227
266
  }
228
267
  //# sourceMappingURL=protocol.js.map
@@ -1,24 +1,25 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { createServer } from "node:net";
4
- import { basename, join as pathJoin } from "node:path";
5
- import { homedir, hostname, platform } from "node:os";
4
+ import { basename, dirname, join as pathJoin, resolve as resolvePath } from "node:path";
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";
10
- import { loadProfile, wakeDefault } from "./profile.js";
9
+ import { holdsRepo } from "./repo-find.js";
10
+ import { load, loadAll, identity, save, toInvite, resolveFor, repoInfo, bindRepo, bindFolder, folderBinding, DEFAULT_RELAY } from "./config.js";
11
+ import { commandsChosen, loadProfile, wakeChosen, wakeDefault } from "./profile.js";
11
12
  import { newKeypair } from "./identity.js";
12
13
  import { installForUser } from "./install.js";
13
14
  import { writeIntent, installHooks } from "./notify.js";
14
- import { detectAvailable, agentById } from "./agents.js";
15
+ import { detectAvailable, agentById, agentsNeedingUnsandboxed } from "./agents.js";
15
16
  import { installService, servicesUnavailable } from "./service.js";
16
17
  import { ensureNativeNotifier } from "./notifier.js";
17
18
  import { ensureTrayBinary, writeTrayCommands } from "./tray.js";
18
19
  import { desktopUnavailable, ephemeralWarning, hostEnv } from "./host-env.js";
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
20
21
  import { localAddresses } from "./machine.js";
21
- import { grant as grantCap, loadPolicy, policyPath, savePolicy } from "./worker-policy.js";
22
+ import { grant as grantCap, loadPolicy, policyPath, savePolicy, unsandboxedAllowed } from "./worker-policy.js";
22
23
  import { Relay } from "./relay.js";
23
24
  import { which } from "./which.js";
24
25
  const sh = (cmd) => {
@@ -29,6 +30,9 @@ const sh = (cmd) => {
29
30
  return null;
30
31
  }
31
32
  };
33
+ const notLoaded = (r) => r && typeof r === "object" && r.loaded === false
34
+ ? String(r.reason ?? "the service manager did not load it")
35
+ : null;
32
36
  export function wireUp(opts, deps = {}) {
33
37
  const installSvc = deps.installService ?? installService;
34
38
  const ensureNotifier = deps.ensureNotifier ?? ensureNativeNotifier;
@@ -63,9 +67,14 @@ export function wireUp(opts, deps = {}) {
63
67
  else if (opts.notifications) {
64
68
  try {
65
69
  ensureNotifier();
66
- installSvc("notifier", homedir(), []);
70
+ const why = notLoaded(installSvc("notifier", homedir(), []));
67
71
  writeTrayCommands();
68
- tools.push({ tool: "notifications", status: "on — native alerts for decisions that require you" });
72
+ if (why) {
73
+ tools.push({ tool: "notifications", status: `installed, not running: ${why}` });
74
+ warnings.push(`human notifications are installed but not running (${why}). Try \`pantheon service restart notifier\``);
75
+ }
76
+ else
77
+ tools.push({ tool: "notifications", status: "on — native alerts for decisions that require you" });
69
78
  }
70
79
  catch (e) {
71
80
  tools.push({ tool: "notifications", status: `could not install: ${e.message}` });
@@ -82,8 +91,13 @@ export function wireUp(opts, deps = {}) {
82
91
  try {
83
92
  ensureTray();
84
93
  writeTrayCommands();
85
- installSvc("menubar", homedir(), []);
86
- tools.push({ tool: "menu bar", status: "on — status, pause/resume, and decision badge in your menu bar" });
94
+ const why = notLoaded(installSvc("menubar", homedir(), []));
95
+ if (why) {
96
+ tools.push({ tool: "menu bar", status: `installed, not running: ${why}` });
97
+ warnings.push(`the menu bar item is installed but not running (${why}). Try \`pantheon service restart menubar\``);
98
+ }
99
+ else
100
+ tools.push({ tool: "menu bar", status: "on — status, pause/resume, and decision badge in your menu bar" });
87
101
  }
88
102
  catch (e) {
89
103
  tools.push({ tool: "menu bar", status: `could not install: ${e.message}` });
@@ -108,18 +122,68 @@ export function wireUp(opts, deps = {}) {
108
122
  catch {
109
123
  return false;
110
124
  } })();
111
- if (!isRepo && how === "explicit") {
125
+ const boundFolder = !isRepo && (() => { try {
126
+ const f = loadAll();
127
+ return !!f && !!folderBinding(f, project);
128
+ }
129
+ catch {
130
+ return false;
131
+ } })();
132
+ if (boundFolder) {
133
+ try {
134
+ const why = notLoaded(installSvc("worker", project, []));
135
+ if (why) {
136
+ tools.push({ tool: "worker", status: `installed, not running: ${why}` });
137
+ 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\``);
138
+ }
139
+ else {
140
+ 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)` });
141
+ 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.`);
142
+ const hasClaude = (() => { try {
143
+ return detectAvailable().some((a) => a.backend.id === "claude-code");
144
+ }
145
+ catch {
146
+ return true;
147
+ } })();
148
+ if (!hasClaude)
149
+ 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`);
150
+ }
151
+ }
152
+ catch (e) {
153
+ tools.push({ tool: "worker", status: `could not install: ${e.message}` });
154
+ warnings.push(`the worker did not install: ${e.message}`);
155
+ }
156
+ }
157
+ else if (!isRepo && how === "explicit") {
112
158
  tools.push({ tool: "worker", status: `not installed — ${project} is not a git repo` });
113
159
  warnings.push(`--unattended needs a git repo to work in; pass --project <a clone>. Nothing was installed.`);
114
160
  }
115
161
  else if (!isRepo) {
116
- tools.push({ tool: "worker", status: `not on — run this from your project's git repo to enable, or later: pantheon service install worker --project <repo>` });
162
+ tools.push({ tool: "worker", status: `not on yet — nothing here is bound to the team. Bind a repo or docs folder (pantheon repo find, or pantheon use <team> --here), then: pantheon service install worker` });
117
163
  }
118
164
  else {
119
165
  try {
120
- installSvc("worker", project, []);
166
+ const commandsOn = commandsChosen() !== false;
167
+ const why = notLoaded(installSvc("worker", project, commandsOn ? ["--allow-bash"] : []));
168
+ if (why) {
169
+ tools.push({ tool: "worker", status: `installed, not running: ${why}` });
170
+ 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\``);
171
+ return { tools, granted, warnings };
172
+ }
121
173
  tools.push({ tool: "worker", status: `on — answers peers while you are away (${project})` });
122
- granted.push(`A peer you have granted may_wake can start an agent run on this machine, in an isolated worktree under ${project}.`);
174
+ granted.push(wakeChosen() === "approved"
175
+ ? `A teammate you approve here can start an agent run on this machine, in an isolated worktree under ${project}: you are asked the first time each one's request needs it (Always / Just this once / No), or approve ahead with pantheon wake allow <name>. Under wake "anyone", every teammate the team lets wake can.`
176
+ : `Your teammates can hand work to this machine while you're away, so nothing waits on you: it runs in its own copy of ${project} and delivers on a branch for your review. To approve each teammate first: pantheon setup --wake approved`);
177
+ if (commandsOn)
178
+ granted.push(`That work runs your tests, commits, and pushes to its own branch, so it arrives ready to review. Edits only instead: pantheon setup --commands no`);
179
+ const away = (() => { try {
180
+ return agentsNeedingUnsandboxed();
181
+ }
182
+ catch {
183
+ return [];
184
+ } })();
185
+ if (away.length && unsandboxedAllowed(loadPolicy()))
186
+ granted.push(`${away.join(", ")} ${away.length === 1 ? "answers" : "answer"} those requests, so teammates get answers even when you're offline. To have them wait for you instead: pantheon setup --unsandboxed no`);
123
187
  if (!existsSync(policyPath())) {
124
188
  const pol = loadPolicy();
125
189
  grantCap(pol, "self_upgrade", "always", "setup-default");
@@ -151,6 +215,47 @@ export function slugName(raw, fallback) {
151
215
  .replace(/^[-._]+|[-._]+$/g, "");
152
216
  return /[a-z0-9]/.test(s) ? s : fallback;
153
217
  }
218
+ export function osUsername(env = process.env, info = () => userInfo().username) {
219
+ const tries = [info, () => env.USERNAME, () => env.USER];
220
+ for (const t of tries) {
221
+ try {
222
+ const v = t()?.trim();
223
+ if (v && v.toLowerCase() !== "root")
224
+ return v;
225
+ }
226
+ catch { }
227
+ }
228
+ return null;
229
+ }
230
+ export function defaultMemberName(env = process.env, info, host = hostname) {
231
+ const user = osUsername(env, info);
232
+ const random = `member-${randomBytes(3).toString("hex")}`;
233
+ if (user) {
234
+ const s = slugName(user, "");
235
+ if (s)
236
+ return s;
237
+ }
238
+ let h = "";
239
+ try {
240
+ h = host().split(".")[0] ?? "";
241
+ }
242
+ catch { }
243
+ const s = slugName(h, "");
244
+ return s && s !== "localhost" ? `${s}` : random;
245
+ }
246
+ export function deviceLabel(plat = platform(), env = process.env, info, host = hostname) {
247
+ const os = plat === "darwin" ? "Mac" : plat === "win32" ? "Windows" : plat === "linux" ? "Linux" : plat;
248
+ let who = osUsername(env, info);
249
+ if (!who) {
250
+ try {
251
+ who = host().split(".")[0] || null;
252
+ }
253
+ catch {
254
+ who = null;
255
+ }
256
+ }
257
+ return `${who ?? "a"}'s ${os}`;
258
+ }
154
259
  export function teamNameFor(dir, repo = repoInfo(dir)) {
155
260
  const fromOrigin = repo?.origin ? (repo.origin.replace(/\.git$/i, "").split(/[/:]/).filter(Boolean).pop() ?? "") : "";
156
261
  return slugName(fromOrigin || basename(repo?.root ?? dir), "team");
@@ -298,11 +403,10 @@ export async function start(opts) {
298
403
  const reach = chosen
299
404
  ? (place === "loopback"
300
405
  ? { url: chosen, how: "as given", shareable: false,
301
- hint: `That address is this machine only — a teammate cannot reach it.\n` +
302
- ` Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
406
+ hint: `A teammate cannot reach it. Use a tailnet address (tailscale ip -4) or a tunnel, then re-run pantheon start.` }
303
407
  : { 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 })
304
408
  : findReach(opts.port);
305
- const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
409
+ const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, inviteMayWake: false, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
306
410
  let relay = null;
307
411
  if (chosen && !hostHere) {
308
412
  const probe = await probeRelay(reach.url, opts.remoteTimeoutMs ?? 10_000, opts.fetchImpl);
@@ -345,7 +449,7 @@ export async function start(opts) {
345
449
  relay: reach.url,
346
450
  space: opts.space ?? teamNameFor(process.cwd(), repo),
347
451
  token: randomBytes(16).toString("hex"),
348
- member: opts.me ?? (keepIdentity ? existing.member : slugName(process.env.USER ?? "me", "me")),
452
+ member: opts.me ?? (keepIdentity ? existing.member : defaultMemberName()),
349
453
  agent: detectAgent(),
350
454
  ...(keepIdentity ? { publicKey: existing.publicKey, privateKey: existing.privateKey } : newKeypair()),
351
455
  ...(keepIdentity && existing.account ? { account: existing.account } : {}),
@@ -359,6 +463,10 @@ export async function start(opts) {
359
463
  warnings.push(`you are already "${cfg.member}" here; --me ${opts.me} ignored (use --reset to start over)`);
360
464
  }
361
465
  }
466
+ else if (keepIdentity && existing.space) {
467
+ opts.log(` you ${cfg.member}, as in "${existing.space}" — that team stays; this adds one`);
468
+ opts.log(` default "${cfg.space}" from now on for folders bound to no team — pantheon use ${existing.space} switches back`);
469
+ }
362
470
  else if (keepIdentity && existing.account) {
363
471
  opts.log(` you ${cfg.member} — signed in; creating your first team`);
364
472
  }
@@ -366,6 +474,8 @@ export async function start(opts) {
366
474
  warnings.push(`replacing your identity in "${existing.space}" — that space is no longer reachable from this machine`);
367
475
  }
368
476
  let invite = null;
477
+ let inviteMayWake = false;
478
+ let folderBound = null;
369
479
  let claimed = false;
370
480
  const claim = async (c) => {
371
481
  const client = new PantheonClient(c);
@@ -377,15 +487,23 @@ export async function start(opts) {
377
487
  if (repo && !client.repos.some((r) => r.fingerprint === repo.fingerprint)) {
378
488
  await client.bindRepo(repo.fingerprint, repo.origin).catch((e) => warnings.push(`could not register this repo with the team: ${e.message}`));
379
489
  }
490
+ if (!repo && c.spaceId && bindableFolder(process.cwd())) {
491
+ bindFolder(process.cwd(), c.spaceId);
492
+ folderBound = process.cwd();
493
+ }
380
494
  if (repo && c.spaceId) {
381
495
  const { replaced } = bindRepo(repo, c.spaceId);
382
- if (replaced)
383
- warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${c.space}"`);
496
+ const was = replaced ?? (here.binding && here.binding.space !== c.spaceId ? here.binding : null);
497
+ if (was) {
498
+ const name = Object.values(loadAll()?.spaces ?? {}).find((m) => m.id === was.space)?.name ?? was.space;
499
+ 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)`);
500
+ }
384
501
  }
385
- opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : ""}`);
502
+ opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : folderBound ? ` this folder → bound to it` : ""}`);
386
503
  const mayWake = opts.wake ? opts.wake === "anyone" : wakeDefault(loadProfile(c));
387
504
  const inv = await client.makeInvite(mayWake, opts.ttlMinutes);
388
505
  invite = toInvite({ ...c, token: inv.url });
506
+ inviteMayWake = mayWake;
389
507
  claimed = true;
390
508
  }
391
509
  finally {
@@ -426,7 +544,7 @@ export async function start(opts) {
426
544
  ...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
427
545
  });
428
546
  warnings.push(...wired.warnings);
429
- return { cfg, invite, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
547
+ return { cfg, invite, inviteMayWake, claimed, reach, tools: wired.tools, granted: wired.granted, relay, warnings };
430
548
  }
431
549
  export async function join(opts) {
432
550
  const existing = opts.reset ? null : (load() ?? identity());
@@ -441,7 +559,7 @@ export async function join(opts) {
441
559
  relay: opts.invite.relay,
442
560
  space: opts.invite.space,
443
561
  token: "",
444
- member: opts.me ?? existing?.member ?? slugName(process.env.USER ?? "me", "me"),
562
+ member: opts.me ?? existing?.member ?? defaultMemberName(),
445
563
  agent: detectAgent(),
446
564
  ...keys,
447
565
  ...(existing?.account ? { account: existing.account } : {}),
@@ -456,6 +574,10 @@ export async function join(opts) {
456
574
  cfg.spaceId = client.spaceId;
457
575
  save(cfg);
458
576
  const warnings = [];
577
+ if (!repo && cfg.spaceId && bindableFolder(process.cwd())) {
578
+ bindFolder(process.cwd(), cfg.spaceId);
579
+ opts.log(` folder ${process.cwd()} → bound to "${cfg.space}" (agents opened here speak in it; no git needed)`);
580
+ }
459
581
  if (repo && cfg.spaceId) {
460
582
  const { replaced } = bindRepo(repo, cfg.spaceId);
461
583
  if (replaced)
@@ -476,6 +598,33 @@ export async function join(opts) {
476
598
  });
477
599
  return { cfg, tools: wired.tools, granted: wired.granted, warnings: [...warnings, ...wired.warnings] };
478
600
  }
601
+ export function bindableFolder(dir) {
602
+ const real = (d) => { try {
603
+ return realpathSync.native(d);
604
+ }
605
+ catch {
606
+ return resolvePath(d);
607
+ } };
608
+ const here = real(dir), home = real(homedir());
609
+ if (dirname(here) === here || here === home || home.startsWith(here.endsWith("/") ? here : `${here}/`))
610
+ return false;
611
+ if (["Downloads", "Desktop", "Documents", "Library", "tmp"].some((n) => here === pathJoin(home, n)))
612
+ return false;
613
+ try {
614
+ if (holdsRepo(here))
615
+ return false;
616
+ }
617
+ catch {
618
+ return false;
619
+ }
620
+ try {
621
+ const f = loadAll();
622
+ if (Object.values(f?.bindings ?? {}).some((b) => { const p = real(b.path); return p !== here && p.startsWith(`${here}/`); }))
623
+ return false;
624
+ }
625
+ catch { }
626
+ return true;
627
+ }
479
628
  function detectAgent() {
480
629
  const found = detectAvailable();
481
630
  return found[0]?.backend.id ?? "unknown";
package/dist/reads.js ADDED
@@ -0,0 +1,37 @@
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 d = cfg.delegate;
20
+ if (d) {
21
+ full.dpub = d.cert.pub;
22
+ full.drun = d.cert.run;
23
+ full.dexp = String(d.cert.expires);
24
+ full.dcert = d.cert.sig;
25
+ }
26
+ const sig = signRead(d ? d.privateKey : cfg.privateKey, "GET", path, full);
27
+ return new URLSearchParams({ ...full, sig }).toString();
28
+ }
29
+ export async function signedRead(cfg, path, params = {}, opts = {}) {
30
+ const signal = opts.signal ?? (opts.timeoutMs ? AbortSignal.timeout(opts.timeoutMs) : undefined);
31
+ const go = () => fetch(`${httpBase(cfg.relay)}${path}?${signedReadQuery(cfg, path, params, Date.now() + clockSkewMs)}`, signal ? { signal } : {});
32
+ const res = await go();
33
+ if (res.status === 403 && learnRelayClock(res))
34
+ return go();
35
+ return res;
36
+ }
37
+ //# 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