@join-pantheon/cli 0.30.0 → 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.
package/dist/client.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import WebSocket from "ws";
2
- import { updateMembershipToken } from "./config.js";
3
- import { signChallenge } from "./identity.js";
2
+ import { adoptSpaceId, rememberTeamRepos, updateMembershipToken } from "./config.js";
3
+ import { markContinued } from "./continued.js";
4
+ import { markAnswered, nonceOf } from "./nonces.js";
5
+ import { signedRead } from "./reads.js";
6
+ import { repoName } from "./repo-route.js";
7
+ import { signChallenge, signBytes, actPayload, goalPayload, newActId, newActNonce, canonicalJson } from "./identity.js";
8
+ import { expectsSigs, joinedSigning, loadPins, markMemberSigns, markSigs, memberSigns, observeMembers, pinFor, pinScope, teamPinId, updatePins } from "./pins.js";
9
+ export { joinedSigning } from "./pins.js";
10
+ import { trusted, verifyAct, verifyGoal } from "./verify-act.js";
4
11
  import { newId } from "./protocol.js";
5
12
  import { VERSION } from "./version.js";
6
13
  import { describeNetError, explainNetError, noteRelayAnswered, settleSystemFallback, systemFallbackPending, trySystemFallback, wsOptions } from "./network.js";
@@ -56,11 +63,20 @@ export class PantheonClient {
56
63
  constructor(cfg, onEvent, opts) {
57
64
  this.cfg = cfg;
58
65
  this.onEvent = onEvent;
66
+ this.pinTeam = teamPinId(cfg);
67
+ if (opts?.fromSeq && opts.fromSeq > 0)
68
+ this.seq = opts.fromSeq;
59
69
  this.persistToken = opts?.persistToken ?? ((space, oldToken, newToken) => { updateMembershipToken(space, oldToken, newToken); });
60
70
  this.enroll = opts?.enroll ?? null;
61
71
  this.session = opts?.session ?? newId("s");
62
72
  this.role = opts?.role ?? "interactive";
63
73
  }
74
+ keepRepos() {
75
+ try {
76
+ rememberTeamRepos({ spaceId: this.cfg.spaceId ?? this.spaceId, space: this.cfg.space, relay: this.cfg.relay }, this.repos);
77
+ }
78
+ catch { }
79
+ }
64
80
  adoptToken(token) {
65
81
  const old = this.cfg.token;
66
82
  if (!token || token === old)
@@ -198,14 +214,15 @@ export class PantheonClient {
198
214
  ...(this.enroll && !this.enrolledToken ? { enroll: this.enroll } : { token: this.cfg.token }),
199
215
  pubkey: this.cfg.publicKey,
200
216
  nonce: msg.nonce,
201
- sig: signChallenge(this.cfg.privateKey, this.cfg.space, this.cfg.member, msg.nonce),
217
+ sig: signChallenge(this.cfg.delegate?.privateKey ?? this.cfg.privateKey, this.cfg.space, this.cfg.member, msg.nonce),
218
+ ...(this.cfg.delegate ? { delegate: this.cfg.delegate.cert } : {}),
202
219
  session: this.session,
203
220
  role: this.role,
204
221
  display: this.cfg.display ?? this.cfg.member,
205
222
  agent: this.cfg.agent ?? "unknown",
206
223
  pkg: VERSION,
207
224
  ...(this.seq > 0 ? { from_seq: this.seq } : {}),
208
- ...(this.cfg.repo ? { repo: this.cfg.repo, ...(this.cfg.repoOrigin ? { repo_origin: this.cfg.repoOrigin } : {}) } : {}),
225
+ ...(this.cfg.repo ? { repo: this.cfg.repo, ...(this.cfg.repoOrigin ? { repo_origin: repoName(this.cfg.repoOrigin) } : {}) } : {}),
209
226
  }));
210
227
  break;
211
228
  case "pending":
@@ -219,6 +236,7 @@ export class PantheonClient {
219
236
  break;
220
237
  case "members":
221
238
  this.members = msg.members;
239
+ this.observePins();
222
240
  break;
223
241
  case "profiles":
224
242
  this.profiles = new Map((msg.profiles ?? []).map((p) => [p.member, p]));
@@ -232,6 +250,16 @@ export class PantheonClient {
232
250
  this.inviteUrl = { url: msg.url, expires_at: msg.expires_at };
233
251
  break;
234
252
  case "welcome":
253
+ if (this.cfg.spaceId && msg.space_id && msg.space_id !== this.cfg.spaceId) {
254
+ this.lastError = `SPACE_MISMATCH: the relay answered for team ${msg.space_id}, not the ${this.cfg.spaceId} this machine joined — not connecting (pantheon spaces; rejoin with a fresh invite if the team was recreated)`;
255
+ this.closed = true;
256
+ ws.close();
257
+ if (!settled) {
258
+ settled = true;
259
+ reject(new Error(this.lastError));
260
+ }
261
+ break;
262
+ }
235
263
  this.connected = true;
236
264
  this.pending = null;
237
265
  this.refusedCode = null;
@@ -253,16 +281,38 @@ export class PantheonClient {
253
281
  this.cfg.token = msg.token;
254
282
  }
255
283
  if (msg.space_id) {
284
+ if (!this.cfg.spaceId) {
285
+ let adopted = false;
286
+ try {
287
+ adopted = adoptSpaceId(this.cfg.relay, this.cfg.space, msg.space_id);
288
+ }
289
+ catch { }
290
+ if (adopted === "conflict") {
291
+ this.lastError = `SPACE_MISMATCH: the relay says "${this.cfg.space}" is team ${msg.space_id}, which is another team this machine is in — not connecting`;
292
+ this.closed = true;
293
+ ws.close();
294
+ if (!settled) {
295
+ settled = true;
296
+ reject(new Error(this.lastError));
297
+ }
298
+ break;
299
+ }
300
+ }
301
+ if (!this.cfg.spaceId)
302
+ this.cfg.pinTeam ??= this.pinTeam;
256
303
  this.spaceId = msg.space_id;
257
304
  this.cfg.spaceId = msg.space_id;
258
305
  }
259
306
  this.repos = msg.repos ?? [];
307
+ this.keepRepos();
260
308
  this.plan = msg.plan ?? null;
261
309
  this.adoptKnownRepo();
262
310
  this.backoff = 500;
263
311
  this.roster = msg.roster;
264
312
  this.obligations = msg.obligations;
265
313
  this.members = msg.members;
314
+ this.features = msg.features ?? [];
315
+ this.observePins();
266
316
  this.profiles = new Map((msg.profiles ?? []).map((p) => [p.member, p]));
267
317
  for (const g of msg.goals)
268
318
  this.goals.set(g.id, g);
@@ -279,14 +329,25 @@ export class PantheonClient {
279
329
  break;
280
330
  case "repos":
281
331
  this.repos = msg.repos;
332
+ this.keepRepos();
282
333
  this.adoptKnownRepo();
283
334
  break;
284
335
  case "act":
336
+ if (msg.act.sig && !this.sigsSeen) {
337
+ try {
338
+ this.trustOf(msg.act);
339
+ }
340
+ catch { }
341
+ }
285
342
  if (msg.act.seq > this.seq)
286
343
  this.seq = msg.act.seq;
287
344
  if (msg.act.seq > this.head)
288
345
  this.head = msg.act.seq;
289
- this.seen.set(msg.act.id, msg.act);
346
+ {
347
+ const had = this.seen.get(msg.act.id);
348
+ if (!had || canonicalJson(had) === canonicalJson(msg.act))
349
+ this.seen.set(msg.act.id, msg.act);
350
+ }
290
351
  if (this.seen.size > 500)
291
352
  this.seen.delete(this.seen.keys().next().value);
292
353
  if (msg.act.from !== this.cfg.member && msg.act.act !== "JOIN" && msg.act.act !== "LEAVE") {
@@ -449,7 +510,48 @@ export class PantheonClient {
449
510
  async send(a) {
450
511
  const repo = this.cfg.repo;
451
512
  const meta = a.act === "DELIVERED" && repo && !(a.meta && "repo" in a.meta) ? { ...(a.meta ?? {}), repo } : a.meta;
452
- const r = await this.rpc({ t: "act", ...a, ...(meta ? { meta } : {}), ...(repo ? { repo } : {}) });
513
+ const id = newActId();
514
+ const sig = this.signAct(id, { ...a, ...(meta ? { meta } : {}) }, repo);
515
+ let unrecorded = false, unsignedRef = false;
516
+ if (a.ref && this.ws?.readyState === WebSocket.OPEN && (a.act === "ANSWER" || a.act === "ACCEPT" || a.act === "OBJECT" || a.act === "DELIVERED")) {
517
+ try {
518
+ markContinued(this.pinTeam, `handled:${a.ref}`);
519
+ }
520
+ catch { }
521
+ const noted = await this.noteAnswered(a.ref).catch(() => false);
522
+ unrecorded = noted === false && this.signaturesExpected();
523
+ unsignedRef = noted === "unsigned";
524
+ }
525
+ const r = await this.rpc({ t: "act", id, ...a, ...(meta ? { meta } : {}), ...(repo ? { repo } : {}), sig });
526
+ let sentId = r.id === id ? id : null;
527
+ if (r.id !== id && this.features.includes("act-sig")) {
528
+ let prior = this.seen.get(r.id) ?? null;
529
+ if (!prior) {
530
+ try {
531
+ const res = await signedRead(this.cfg, "/act", { id: r.id }, { timeoutMs: 3000 });
532
+ prior = res.ok ? (await res.json()).act ?? null : null;
533
+ }
534
+ catch { }
535
+ }
536
+ const retry = !!prior && prior.id === r.id && prior.from === this.cfg.member && prior.act === a.act && prior.body === a.body
537
+ && (prior.goal ?? null) === (a.goal ?? null) && (prior.ref ?? null) === (a.ref ?? null) && !!prior.sig && trusted(this.trustOf(prior).trust);
538
+ if (retry)
539
+ sentId = r.id;
540
+ else {
541
+ r.id = id;
542
+ sentId = id;
543
+ }
544
+ }
545
+ if (unrecorded)
546
+ r.unrecorded = true;
547
+ else if (unsignedRef && this.signaturesExpected())
548
+ r.unrecorded = "unsigned";
549
+ if (sentId) {
550
+ try {
551
+ markContinued(`${this.pinKey()} sent`, sentId);
552
+ }
553
+ catch { }
554
+ }
453
555
  if (a.ref) {
454
556
  for (const [id, o] of this.claimedByMe)
455
557
  if (o.ref === a.ref)
@@ -463,7 +565,150 @@ export class PantheonClient {
463
565
  return r;
464
566
  }
465
567
  openGoal(g) {
466
- return this.rpc({ t: "open_goal", acceptance: "", ...g });
568
+ const k = this.signingKey();
569
+ const nonce = newActNonce(), ts = Date.now();
570
+ const id = `g_${ts.toString(36)}${Buffer.from(nonce, "base64url").toString("hex").slice(0, 12)}`;
571
+ const invite = g.invite ? [...new Set(g.invite)] : null;
572
+ const sig = {
573
+ v: 1, role: k.role, nonce, ts, pub: k.pub, invite,
574
+ budget: g.budget ?? null, deadline: g.deadline ?? null, kind: g.kind ?? null,
575
+ ...(k.cert ? { cert: k.cert } : {}),
576
+ s: signBytes(k.priv, goalPayload({
577
+ id, team: this.teamKey(), member: this.cfg.member, keyRole: k.role, nonce, ts,
578
+ title: g.title, acceptance: g.acceptance ?? "", invite,
579
+ budget: g.budget ?? null, deadline: g.deadline ?? null, kind: g.kind ?? null,
580
+ })),
581
+ };
582
+ return this.rpc({ t: "open_goal", id, acceptance: "", ...g, ...(invite ? { invite } : {}), sig });
583
+ }
584
+ features = [];
585
+ pinChanges = [];
586
+ pinsCache = null;
587
+ observePins() {
588
+ try {
589
+ const team = this.pinKey();
590
+ let changes = [];
591
+ const p = updatePins((p) => {
592
+ if (p.corrupt)
593
+ return false;
594
+ const before = JSON.stringify(p);
595
+ changes = observeMembers(p, team, this.members, { member: this.cfg.member, publicKey: this.cfg.publicKey });
596
+ if (this.features.includes("act-sig"))
597
+ markSigs(p, team);
598
+ return JSON.stringify(p) !== before;
599
+ });
600
+ this.pinsCache = p;
601
+ this.pinChanges.push(...changes);
602
+ if (this.pinChanges.length > 50)
603
+ this.pinChanges.splice(0, this.pinChanges.length - 50);
604
+ }
605
+ catch { }
606
+ }
607
+ pins() {
608
+ try {
609
+ this.pinsCache = loadPins();
610
+ }
611
+ catch {
612
+ this.pinsCache ??= { v: 1, teams: {} };
613
+ }
614
+ return this.pinsCache;
615
+ }
616
+ trustOf(rec) {
617
+ let pin = pinFor(this.pins(), this.pinKey(), rec.from);
618
+ if (!pin || (rec.sig?.role === "run" && !pin.key)) {
619
+ this.pinsCache = null;
620
+ this.observePins();
621
+ pin = pinFor(this.pins(), this.pinKey(), rec.from);
622
+ }
623
+ const r = verifyAct(rec, [this.cfg.spaceId, this.spaceId, this.cfg.space], pin?.fp, pin?.key);
624
+ if (trusted(r.trust) && rec.sig && !this.verifiedIds.has(rec.id)) {
625
+ this.verifiedIds.set(rec.id, { member: rec.from, nonce: rec.sig.nonce });
626
+ if (this.verifiedIds.size > 5000)
627
+ this.verifiedIds.delete(this.verifiedIds.keys().next().value);
628
+ }
629
+ if ((r.trust === "verified" || r.trust === "verified-run") && !this.sigsSeen)
630
+ this.noteSigsSeen();
631
+ if ((r.trust === "verified" || r.trust === "verified-run") && !this.signersSeen.has(rec.from)) {
632
+ this.signersSeen.add(rec.from);
633
+ try {
634
+ updatePins((p) => !p.corrupt && markMemberSigns(p, this.pinKey(), rec.from));
635
+ }
636
+ catch { }
637
+ }
638
+ return r;
639
+ }
640
+ signersSeen = new Set();
641
+ memberSigns(member) { return memberSigns(this.pins(), this.pinKey(), member); }
642
+ sigsSeen = false;
643
+ noteSigsSeen() {
644
+ this.sigsSeen = true;
645
+ try {
646
+ updatePins((p) => !p.corrupt && markSigs(p, this.pinKey()));
647
+ }
648
+ catch { }
649
+ }
650
+ goalTrust(g) {
651
+ const pin = pinFor(this.pins(), this.pinKey(), g.opened_by);
652
+ return verifyGoal(g, [this.cfg.spaceId, this.spaceId, this.cfg.space], pin?.fp, pin?.key);
653
+ }
654
+ signaturesExpected() {
655
+ return this.features.includes("act-sig") || expectsSigs(this.pins(), this.pinKey()) || joinedSigning(this.cfg.joinedWith);
656
+ }
657
+ signingSince() {
658
+ return this.pins().teams[this.pinKey()]?.sigs ?? null;
659
+ }
660
+ async noteAnswered(ref) {
661
+ const known = this.verifiedIds.get(ref);
662
+ if (known) {
663
+ markAnswered(this.pinTeam, known.member, known.nonce, ref);
664
+ return true;
665
+ }
666
+ let r = this.seen.get(ref) ?? null;
667
+ if (!r) {
668
+ try {
669
+ const res = await signedRead(this.cfg, "/act", { id: ref }, { timeoutMs: 3000 });
670
+ r = res.ok ? (await res.json()).act ?? null : null;
671
+ }
672
+ catch {
673
+ r = null;
674
+ }
675
+ }
676
+ if (!r) {
677
+ const local = nonceOf(this.pinTeam, ref);
678
+ if (local)
679
+ markAnswered(this.pinTeam, local.member, local.nonce, ref);
680
+ return false;
681
+ }
682
+ if (r && r.id === ref && !r.sig && this.trustOf(r).trust === "unsigned" && !this.memberSigns(r.from))
683
+ return "unsigned";
684
+ if (!r || r.id !== ref || !r.sig || !trusted(this.trustOf(r).trust))
685
+ return false;
686
+ markAnswered(this.pinTeam, r.from, r.sig.nonce, r.id, undefined, r.sig.s);
687
+ return true;
688
+ }
689
+ verifiedIds = new Map();
690
+ pinKey() { return pinScope(this.pinTeam, this.cfg.member); }
691
+ pinTeam;
692
+ teamKey() { return this.cfg.spaceId ?? this.spaceId ?? this.cfg.space; }
693
+ signingKey() {
694
+ const d = this.cfg.delegate;
695
+ return d ? { role: "run", priv: d.privateKey, pub: d.cert.pub, cert: d.cert } : { role: "member", priv: this.cfg.privateKey, pub: this.cfg.publicKey };
696
+ }
697
+ signAct(id, a, repo) {
698
+ const k = this.signingKey();
699
+ const nonce = newActNonce(), ts = Date.now();
700
+ return {
701
+ v: 1, role: k.role, nonce, ts, pub: k.pub,
702
+ ...(a.meta ? { meta: a.meta } : {}),
703
+ ...(repo ? { repo } : {}),
704
+ ...(k.cert ? { cert: k.cert } : {}),
705
+ s: signBytes(k.priv, actPayload({
706
+ id, team: this.teamKey(), member: this.cfg.member, keyRole: k.role, nonce, ts,
707
+ act: a.act, goal: a.goal ?? null, ref: a.ref ?? null, to: a.to ?? null,
708
+ defers_to: a.defers_to ?? null, affects: a.affects ?? null, repo: repo ?? null,
709
+ summary: a.summary ?? null, body: a.body, meta: a.meta ?? {},
710
+ })),
711
+ };
467
712
  }
468
713
  claimedByMe = new Map();
469
714
  async claim(obligation) {
@@ -488,6 +733,9 @@ export class PantheonClient {
488
733
  admit(member, mayWake = false, revoke = false, forget = false) {
489
734
  return this.rpc({ t: "admit", member, may_wake: mayWake, revoke, forget });
490
735
  }
736
+ setMayWake(member, mayWake) {
737
+ return this.rpc({ t: "admit", member, may_wake: mayWake, wake_only: true });
738
+ }
491
739
  publishProfile(profile) {
492
740
  return this.rpc({ t: "publish_profile", profile });
493
741
  }
@@ -513,7 +761,7 @@ export class PantheonClient {
513
761
  bindRepo(fingerprint = this.cfg.repo, origin = this.cfg.repoOrigin ?? null, base = null) {
514
762
  if (!fingerprint)
515
763
  return Promise.reject(new Error("not inside a git repo — run this from the team's checkout"));
516
- return this.rpc({ t: "bind_repo", repo: fingerprint, origin, ...(base ? { base } : {}) });
764
+ return this.rpc({ t: "bind_repo", repo: fingerprint, origin: origin ? repoName(origin) : origin, ...(base ? { base } : {}) });
517
765
  }
518
766
  makeInvite(mayWake = true, ttlMinutes = 60) {
519
767
  return new Promise((res, rej) => {
@@ -584,7 +832,8 @@ export class PantheonClient {
584
832
  }
585
833
  recent(id) {
586
834
  const r = this.seen.get(id);
587
- return r ? `${r.act} from ${r.from}${r.goal ? ` (goal ${r.goal})` : ""} id=${r.id}\n${r.body}` : null;
835
+ const plain = (s) => (typeof s === "string" && /^[A-Za-z0-9_][A-Za-z0-9_:.\-]{0,119}$/.test(s) ? s : "(an id not printed)");
836
+ return r ? `${plain(r.act)} from ${plain(r.from)}${r.goal ? ` (goal ${plain(r.goal)})` : ""} id=${plain(r.id)}\n${r.body}` : null;
588
837
  }
589
838
  recentFrom(id) {
590
839
  return this.seen.get(id)?.from ?? null;
@@ -1,10 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { platform } from "node:os";
4
- import { parseRepoCommands, REPO_COMMANDS_FILE } from "./agents.js";
4
+ import { chooseAgent, claudeSandboxable, NOT_FOR_UNATTENDED_RUNS, parseRepoCommands, REPO_COMMANDS_FILE, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
5
+ import { detectClaudeFeatures } from "./claude-confine.js";
5
6
  import { loadPersonas } from "./persona.js";
6
7
  import { plistPath, existingUnitPath } from "./service.js";
7
- import { loadPolicy } from "./worker-policy.js";
8
+ import { loadPolicy, unsandboxedAllowed } from "./worker-policy.js";
8
9
  export function workerServiceAllowsBash(plat = platform()) {
9
10
  try {
10
11
  if (plat === "darwin") {
@@ -76,4 +77,26 @@ export function repoCommandsAtBase(project, base) {
76
77
  }
77
78
  return { ref, ...parseRepoCommands(text) };
78
79
  }
80
+ export function commandCaveat(o = {}) {
81
+ try {
82
+ const unsandboxedOk = unsandboxedAllowed(loadPolicy(o.home));
83
+ const c = (o.choose ?? chooseAgent)("auto", { check: null, unsandboxedOk });
84
+ if (NOT_FOR_UNATTENDED_RUNS.has(c.backend.id))
85
+ return `${c.backend.label}, the only agent here, answers in your live sessions — for runs while you're away, install Claude Code, Codex, Cursor or Copilot`;
86
+ if (unsandboxedOk)
87
+ return "";
88
+ if (!SANDBOXED_FOR_UNATTENDED.has(c.backend.id))
89
+ return `${c.backend.label}, the agent here, is set to leave teammates' requests for your next session (to have it answer them: pantheon policy grant unsandboxed-runs)`;
90
+ const f = (o.features ?? detectClaudeFeatures)(c.binary);
91
+ const plat = o.plat ?? process.platform;
92
+ if (f.unknown && plat !== "win32")
93
+ return `but \`${c.binary} --help\` did not answer in time, so this Claude Code's sandbox could not be confirmed and a woken run gets no commands until it does (it is asked again at each run)`;
94
+ if (!claudeSandboxable(f, plat))
95
+ return `this Claude Code (${f.version ?? "version unknown"}${plat === "win32" ? ", Windows" : ""}) is set to keep runs while you're away to edits — for commands, update it to 2.1.83+ on macOS or Linux, or: pantheon policy grant unsandboxed-runs`;
96
+ return "";
97
+ }
98
+ catch {
99
+ return "";
100
+ }
101
+ }
79
102
  //# sourceMappingURL=command-state.js.map
package/dist/config.js CHANGED
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { join, resolve, sep } from "node:path";
6
6
  import { VERSION } from "./version.js";
7
+ import { repoName } from "./repo-route.js";
7
8
  export const CONFIG_DIR = process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
8
9
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
9
10
  export const DEFAULT_RELAY = process.env.PANTHEON_DEFAULT_RELAY
@@ -112,7 +113,10 @@ export function migrate(raw) {
112
113
  f.bindings ??= {};
113
114
  if (!raw.version && f.space && f.token && !Object.values(f.spaces).some((m) => m.name === f.space && m.relay === f.relay)) {
114
115
  const id = legacySpaceId(f.relay, f.space);
115
- f.spaces[id] = { id, name: f.space, relay: f.relay, token: f.token, joined_at: Date.now() };
116
+ f.spaces[id] = {
117
+ id, name: f.space, relay: f.relay, token: f.token, joined_at: Date.now(),
118
+ ...(raw.pinTeam ? { pin_team: raw.pinTeam } : {}), ...(raw.joinedWith ? { since: raw.joinedWith } : {}),
119
+ };
116
120
  f.default_space ??= id;
117
121
  }
118
122
  if (f.default_space && !f.spaces[f.default_space])
@@ -125,6 +129,8 @@ export function migrate(raw) {
125
129
  export function loadAll() {
126
130
  if (!existsSync(CONFIG_PATH))
127
131
  return null;
132
+ if (!statSync(CONFIG_PATH).isFile())
133
+ throw new Error(`${CONFIG_PATH} is not a plain file — remove it and set up again (pantheon doctor)`);
128
134
  return migrate(JSON.parse(readFileSync(CONFIG_PATH, "utf8").replace(/^\uFEFF/, "")));
129
135
  }
130
136
  export function saveAll(f) {
@@ -250,7 +256,10 @@ function view(f, m, repo) {
250
256
  publicKey: f.publicKey, privateKey: f.privateKey,
251
257
  ...(f.display ? { display: f.display } : {}), ...(f.agent ? { agent: f.agent } : {}),
252
258
  ...(f.account ? { account: f.account } : {}), ...(f.pendingAuth ? { pendingAuth: f.pendingAuth } : {}),
253
- ...(m.id.startsWith("legacy:") ? {} : { spaceId: m.id }),
259
+ ...(f.delegate ? { delegate: f.delegate } : {}),
260
+ ...(m.since ? { joinedWith: m.since } : {}),
261
+ ...(m.pin_team ? { pinTeam: m.pin_team } : {}),
262
+ ...(m.id.startsWith("legacy:") ? (f.spaceId ? { spaceId: f.spaceId } : {}) : { spaceId: m.id }),
254
263
  ...(repo ? { repo: repo.fingerprint, ...(repo.origin ? { repoOrigin: repo.origin } : {}) } : {}),
255
264
  };
256
265
  }
@@ -261,18 +270,23 @@ function envView(onDisk) {
261
270
  const e = process.env;
262
271
  if (!(e.PANTHEON_RELAY && e.PANTHEON_SPACE && e.PANTHEON_MEMBER))
263
272
  return null;
264
- const token = e.PANTHEON_TOKEN ?? (onDisk?.member === e.PANTHEON_MEMBER
265
- ? Object.values(onDisk.spaces ?? {}).find((m) => (e.PANTHEON_SPACE_ID && m.id === e.PANTHEON_SPACE_ID) || (m.relay === e.PANTHEON_RELAY && m.name === e.PANTHEON_SPACE))?.token
266
- : undefined);
273
+ const mine = onDisk?.member === e.PANTHEON_MEMBER
274
+ ? Object.values(onDisk.spaces ?? {}).find((m) => (e.PANTHEON_SPACE_ID && m.id === e.PANTHEON_SPACE_ID) || (m.relay === e.PANTHEON_RELAY && m.name === e.PANTHEON_SPACE))
275
+ : undefined;
276
+ const token = e.PANTHEON_TOKEN ?? mine?.token;
267
277
  if (!token)
268
278
  return null;
269
279
  const pub = e.PANTHEON_PUBKEY ?? onDisk?.publicKey;
270
280
  const priv = e.PANTHEON_PRIVKEY ?? onDisk?.privateKey;
271
- if (!pub || !priv)
281
+ const delegate = onDisk?.member === e.PANTHEON_MEMBER ? onDisk?.delegate : undefined;
282
+ if (!pub || (!priv && !delegate))
272
283
  return null;
273
284
  return {
274
285
  relay: e.PANTHEON_RELAY, space: e.PANTHEON_SPACE, token, member: e.PANTHEON_MEMBER,
275
- publicKey: pub, privateKey: priv, display: e.PANTHEON_DISPLAY, agent: e.PANTHEON_AGENT,
286
+ publicKey: pub, privateKey: priv ?? "", display: e.PANTHEON_DISPLAY, agent: e.PANTHEON_AGENT,
287
+ ...(delegate ? { delegate } : {}),
288
+ ...(mine?.pin_team ? { pinTeam: mine.pin_team } : {}),
289
+ ...(mine?.since ? { joinedWith: mine.since } : {}),
276
290
  ...(e.PANTHEON_SPACE_ID ? { spaceId: e.PANTHEON_SPACE_ID } : {}),
277
291
  ...(e.PANTHEON_REPO ? { repo: e.PANTHEON_REPO } : {}),
278
292
  };
@@ -295,6 +309,14 @@ export function resolveFor(cwd = process.cwd(), opts = {}) {
295
309
  return { cfg: applyAgentEnv(view(f, m, repo)), reason: "binding", memberships: n, binding: { ...b, fingerprint: repo.fingerprint }, repo };
296
310
  return { cfg: null, reason: "stale", memberships: n, binding: { ...b, fingerprint: repo.fingerprint }, repo };
297
311
  }
312
+ const owners = f.unbound?.includes(repo.fingerprint) ? [] : Object.values(spaces).filter((m) => m.repos?.some((r) => r.fp === repo.fingerprint));
313
+ if (owners.length === 1) {
314
+ const m = owners[0];
315
+ const binding = { space: m.id, path: repo.root, origin: repo.origin, bound_at: 0, fingerprint: repo.fingerprint };
316
+ return { cfg: applyAgentEnv(view(f, m, repo)), reason: "binding", memberships: n, binding, repo, autoBound: true };
317
+ }
318
+ if (owners.length > 1)
319
+ return { cfg: null, reason: "unbound", memberships: n, repo, claimedBy: owners.map((m) => m.name) };
298
320
  }
299
321
  if (!repo) {
300
322
  const fb = folderBinding(f, cwd);
@@ -402,6 +424,34 @@ export function save(cfg) {
402
424
  }
403
425
  return saveAll(f);
404
426
  }
427
+ export function rememberTeamRepos(team, repos) {
428
+ if (!existsSync(CONFIG_PATH))
429
+ return false;
430
+ const next = repos.map((r) => ({ fp: r.fingerprint, name: r.origin ? teamRepoName(r.origin) : r.fingerprint })).sort((a, b) => a.fp.localeCompare(b.fp));
431
+ try {
432
+ return withConfigLock(() => {
433
+ let f;
434
+ try {
435
+ f = loadAll();
436
+ }
437
+ catch {
438
+ return false;
439
+ }
440
+ const m = f && Object.values(f.spaces ?? {}).find((x) => (team.spaceId && x.id === team.spaceId) || (x.name === team.space && x.relay === team.relay));
441
+ if (!f || !m)
442
+ return false;
443
+ if (JSON.stringify(m.repos ?? []) === JSON.stringify(next))
444
+ return false;
445
+ m.repos = next;
446
+ saveAll(f);
447
+ return true;
448
+ });
449
+ }
450
+ catch {
451
+ return false;
452
+ }
453
+ }
454
+ export const teamRepoName = (origin) => repoName(origin);
405
455
  export function updateMembershipToken(space, oldToken, newToken) {
406
456
  if (!space || !oldToken || !newToken || oldToken === newToken)
407
457
  return false;
@@ -430,6 +480,24 @@ export function updateMembershipToken(space, oldToken, newToken) {
430
480
  return true;
431
481
  });
432
482
  }
483
+ export function adoptSpaceId(relay, name, id) {
484
+ if (!/^[A-Za-z0-9_:.-]{1,64}$/.test(id) || !existsSync(CONFIG_PATH))
485
+ return false;
486
+ const f = loadAll();
487
+ if (!f?.spaces)
488
+ return false;
489
+ const legacy = legacySpaceId(relay, name);
490
+ const m = f.spaces[legacy];
491
+ if (!m)
492
+ return false;
493
+ if (f.spaces[id])
494
+ return "conflict";
495
+ delete f.spaces[legacy];
496
+ f.spaces[id] = { ...m, id, pin_team: m.pin_team ?? `${name}@${relay}` };
497
+ adoptId(f, legacy, id);
498
+ saveAll(f);
499
+ return true;
500
+ }
433
501
  function adoptId(f, oldId, newId) {
434
502
  for (const b of Object.values(f.bindings ?? {}))
435
503
  if (b.space === oldId)
@@ -476,6 +544,8 @@ export function bindRepo(repo, spaceId) {
476
544
  f.bindings ??= {};
477
545
  const prev = f.bindings[repo.fingerprint] ?? null;
478
546
  f.bindings[repo.fingerprint] = { space: spaceId, path: repo.root, origin: repo.origin, bound_at: Date.now() };
547
+ if (f.unbound?.includes(repo.fingerprint))
548
+ f.unbound = f.unbound.filter((x) => x !== repo.fingerprint);
479
549
  saveAll(f);
480
550
  return { replaced: prev && prev.space !== spaceId ? prev : null };
481
551
  }
@@ -484,6 +554,8 @@ export function unbindRepo(fingerprint) {
484
554
  if (!f?.bindings?.[fingerprint])
485
555
  return false;
486
556
  delete f.bindings[fingerprint];
557
+ if (/^[rl]_/.test(fingerprint))
558
+ f.unbound = [...new Set([...(f.unbound ?? []), fingerprint])].slice(-200);
487
559
  saveAll(f);
488
560
  return true;
489
561
  }
package/dist/continued.js CHANGED
@@ -1,16 +1,18 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "./config.js";
4
+ import { readStateFile, withFileLock } from "./file-lock.js";
5
+ import { writeWhole } from "./pins.js";
4
6
  const KEEP = 500;
5
7
  export function continuedPath(home) {
6
8
  return join(home ?? CONFIG_DIR, "continued.json");
7
9
  }
8
10
  function readAll(home) {
9
11
  try {
10
- const p = continuedPath(home);
11
- if (!existsSync(p))
12
+ const t = readStateFile(continuedPath(home));
13
+ if (t === null)
12
14
  return {};
13
- const v = JSON.parse(readFileSync(p, "utf8"));
15
+ const v = JSON.parse(t);
14
16
  return v && typeof v === "object" ? v : {};
15
17
  }
16
18
  catch {
@@ -20,15 +22,45 @@ function readAll(home) {
20
22
  export function loadContinued(space, home) {
21
23
  return new Set(readAll(home)[space] ?? []);
22
24
  }
25
+ export function handledIn(h) {
26
+ return [...new Set(Object.values(readAll(h)).flatMap((l) => (Array.isArray(l) ? l : [])).filter((x) => typeof x === "string" && x.startsWith("handled:")))];
27
+ }
23
28
  export function markContinued(space, id, home) {
24
29
  try {
25
- const all = readAll(home);
26
- const list = (all[space] ?? []).filter((x) => x !== id);
27
- list.push(id);
28
- all[space] = list.slice(-KEEP);
29
30
  const p = continuedPath(home);
30
- mkdirSync(dirname(p), { recursive: true });
31
- writeFileSync(p, JSON.stringify(all));
31
+ const write = () => {
32
+ const all = readAll(home);
33
+ const list = (all[space] ?? []).filter((x) => x !== id);
34
+ list.push(id);
35
+ all[space] = list.slice(-KEEP);
36
+ mkdirSync(dirname(p), { recursive: true });
37
+ writeWhole(p, JSON.stringify(all));
38
+ };
39
+ try {
40
+ withFileLock(p, write);
41
+ }
42
+ catch {
43
+ write();
44
+ }
45
+ }
46
+ catch { }
47
+ }
48
+ export function unmarkContinued(space, id, home) {
49
+ try {
50
+ const p = continuedPath(home);
51
+ const write = () => {
52
+ const all = readAll(home);
53
+ if (!(all[space] ?? []).includes(id))
54
+ return;
55
+ all[space] = (all[space] ?? []).filter((x) => x !== id);
56
+ writeWhole(p, JSON.stringify(all));
57
+ };
58
+ try {
59
+ withFileLock(p, write);
60
+ }
61
+ catch {
62
+ write();
63
+ }
32
64
  }
33
65
  catch { }
34
66
  }