@join-pantheon/cli 0.33.3 → 0.33.4

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.
@@ -0,0 +1,404 @@
1
+ import { promises as fsp, readFileSync, rmSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { noteDiskWait, slowDiskMs, waitsForDisk } from "./store.js";
4
+ export function supersedeFor(file) {
5
+ return file.endsWith(".state.json") ? "mark" : file.endsWith(".jsonl") ? "first" : "carry";
6
+ }
7
+ export class DiskBusy extends Error {
8
+ bytes;
9
+ cap;
10
+ code = "STORAGE_BUSY";
11
+ constructor(bytes, cap) {
12
+ super(`the relay's disk is ${(bytes / 1e6).toFixed(1)} MB behind on this team's writes`);
13
+ this.bytes = bytes;
14
+ this.cap = cap;
15
+ this.name = "DiskBusy";
16
+ }
17
+ }
18
+ export function backlogCap() {
19
+ const n = Number(process.env.PANTHEON_RELAY_WRITE_BACKLOG_BYTES ?? "");
20
+ return Number.isFinite(n) && n > 0 ? n : 8 * 1024 * 1024;
21
+ }
22
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
23
+ const APPEND_HOLD_MS = 2_000;
24
+ export function retryDelayMs(attempt) {
25
+ return Math.min(5_000, 50 * 2 ** Math.max(0, attempt - 1));
26
+ }
27
+ const reporters = new Set();
28
+ export function onWriteError(dir, fn) {
29
+ const r = { dir: resolve(dir), fn };
30
+ reporters.add(r);
31
+ return () => { reporters.delete(r); };
32
+ }
33
+ export class SpaceWriter {
34
+ dir;
35
+ stem;
36
+ key;
37
+ queue = [];
38
+ inflight = null;
39
+ issued = 0;
40
+ open = new Set();
41
+ waiters = [];
42
+ running = false;
43
+ failing = null;
44
+ backlogBytes = 0;
45
+ constructor(dir, stem, key) {
46
+ this.dir = dir;
47
+ this.stem = stem;
48
+ this.key = key;
49
+ }
50
+ get ticket() { return this.issued; }
51
+ get doneThrough() {
52
+ let low = Infinity;
53
+ for (const t of this.open)
54
+ if (t < low)
55
+ low = t;
56
+ return low === Infinity ? this.issued : low - 1;
57
+ }
58
+ get idle() { return !this.inflight && this.queue.length === 0; }
59
+ whenWritten(ticket = this.issued) {
60
+ if (this.doneThrough >= ticket)
61
+ return Promise.resolve();
62
+ return new Promise((done) => this.waiters.push({ ticket, done }));
63
+ }
64
+ append(file, data, offset) {
65
+ return this.push({ kind: "append", file, data, offset, tickets: [] });
66
+ }
67
+ replace(file, data, mode = 0o600, how = supersedeFor(file)) {
68
+ return this.push({ kind: "replace", file, data: typeof data === "string" ? Buffer.from(data) : data, mode, tickets: [] }, how);
69
+ }
70
+ remove(file) {
71
+ if (this.idle && this.appendHandle?.file !== file) {
72
+ try {
73
+ rmSync(file, { force: true });
74
+ }
75
+ catch { }
76
+ return this.issued;
77
+ }
78
+ return this.push({ kind: "remove", file, tickets: [] });
79
+ }
80
+ push(op, how = "carry") {
81
+ const t = ++this.issued;
82
+ this.open.add(t);
83
+ op.tickets.push(t);
84
+ if ("data" in op)
85
+ this.backlogBytes += op.data.length;
86
+ for (const r of recorders)
87
+ r.set(this, t);
88
+ if (op.kind === "append")
89
+ this.queue.push(op);
90
+ else {
91
+ const next = [];
92
+ let at = -1;
93
+ for (const [i, q] of this.queue.entries()) {
94
+ if (q.file !== op.file || q.kind === "noop") {
95
+ next.push(q);
96
+ continue;
97
+ }
98
+ if (how === "mark" && i === 0) {
99
+ next.push(q);
100
+ continue;
101
+ }
102
+ if ("data" in q)
103
+ this.backlogBytes -= q.data.length;
104
+ if (how === "mark") {
105
+ next.push({ kind: "noop", file: q.file, tickets: q.tickets });
106
+ continue;
107
+ }
108
+ op.tickets.push(...q.tickets);
109
+ if (how === "first" && at < 0)
110
+ at = next.length;
111
+ }
112
+ if (at < 0)
113
+ next.push(op);
114
+ else
115
+ next.splice(at, 0, op);
116
+ this.queue = next;
117
+ }
118
+ if (!this.running)
119
+ void this.pump();
120
+ return t;
121
+ }
122
+ view(file) {
123
+ const ops = [this.inflight, ...this.queue].filter((o) => !!o && o.file === file && o.kind !== "noop");
124
+ if (!ops.length)
125
+ return undefined;
126
+ let i = ops.length - 1;
127
+ while (i >= 0 && ops[i].kind === "append")
128
+ i--;
129
+ const tail = ops.slice(i + 1).map((o) => o.data);
130
+ let head;
131
+ if (i >= 0) {
132
+ const last = ops[i];
133
+ if (last.kind === "remove" && !tail.length)
134
+ return null;
135
+ head = last.kind === "replace" ? last.data : Buffer.alloc(0);
136
+ }
137
+ else {
138
+ const first = ops[0];
139
+ let disk = Buffer.alloc(0);
140
+ try {
141
+ disk = readFileSync(file);
142
+ }
143
+ catch { }
144
+ head = disk.subarray(0, first.offset);
145
+ }
146
+ return Buffer.concat([head, ...tail]);
147
+ }
148
+ async pump() {
149
+ this.running = true;
150
+ try {
151
+ while (this.queue.length) {
152
+ let op = this.queue.shift();
153
+ if (op.kind === "append") {
154
+ const parts = [op.data];
155
+ let end = op.offset + op.data.length;
156
+ const tickets = [...op.tickets];
157
+ for (let q = this.queue[0]; q && (q.kind === "noop" || (q.kind === "append" && q.file === op.file && q.offset === end)); q = this.queue[0]) {
158
+ this.queue.shift();
159
+ tickets.push(...q.tickets);
160
+ if (q.kind === "append") {
161
+ parts.push(q.data);
162
+ end += q.data.length;
163
+ }
164
+ }
165
+ if (tickets.length > op.tickets.length)
166
+ op = { kind: "append", file: op.file, data: Buffer.concat(parts), offset: op.offset, tickets };
167
+ }
168
+ this.inflight = op;
169
+ for (let attempt = 0; op.kind !== "noop"; attempt++) {
170
+ try {
171
+ const slow = slowDiskMs();
172
+ if (slow)
173
+ await sleep(slow);
174
+ await this.perform(op, attempt);
175
+ break;
176
+ }
177
+ catch (e) {
178
+ this.failing = e;
179
+ if (attempt < 3 || attempt % 12 === 0)
180
+ report(this.dir, this.stem, `writing ${op.file}${attempt ? ` (attempt ${attempt + 1})` : ""}`, e);
181
+ await sleep(retryDelayMs(attempt + 1));
182
+ }
183
+ }
184
+ this.failing = null;
185
+ if ("data" in op)
186
+ this.backlogBytes -= op.data.length;
187
+ this.inflight = null;
188
+ for (const t of op.tickets)
189
+ this.open.delete(t);
190
+ this.wake();
191
+ }
192
+ }
193
+ finally {
194
+ this.running = false;
195
+ this.forgetIfDone();
196
+ }
197
+ }
198
+ forget() { this.forgetIfDone(); }
199
+ forgetIfDone() {
200
+ if (this.idle && !this.running && !this.appendHandle && writers.get(this.key) === this)
201
+ writers.delete(this.key);
202
+ }
203
+ appendHandle = null;
204
+ async closeAppendHandle() {
205
+ const h = this.appendHandle;
206
+ if (!h)
207
+ return;
208
+ this.appendHandle = null;
209
+ clearTimeout(h.timer);
210
+ await h.fh.close().catch(() => { });
211
+ }
212
+ async perform(op, attempt) {
213
+ if (this.appendHandle && (op.kind !== "append" || op.file !== this.appendHandle.file || attempt > 0))
214
+ await this.closeAppendHandle();
215
+ if (op.kind === "remove") {
216
+ await fsp.rm(op.file, { force: true });
217
+ await fsp.rm(`${op.file}.tmp`, { force: true });
218
+ return;
219
+ }
220
+ if (op.kind === "replace")
221
+ return replaceAtomic(op.file, op.data, op.mode);
222
+ if (op.kind === "noop")
223
+ return;
224
+ if (attempt > 0) {
225
+ try {
226
+ const st = await fsp.stat(op.file);
227
+ if (st.size > op.offset)
228
+ await fsp.truncate(op.file, op.offset);
229
+ }
230
+ catch (e) {
231
+ if (e.code !== "ENOENT")
232
+ throw e;
233
+ }
234
+ }
235
+ if (!this.appendHandle)
236
+ this.appendHandle = { file: op.file, fh: await fsp.open(op.file, "a", 0o600) };
237
+ const h = this.appendHandle;
238
+ clearTimeout(h.timer);
239
+ try {
240
+ await writeAll(h.fh, op.data);
241
+ await syncHandle(h.fh);
242
+ }
243
+ catch (e) {
244
+ await this.closeAppendHandle();
245
+ throw e;
246
+ }
247
+ if (process.platform === "win32")
248
+ return this.closeAppendHandle();
249
+ h.timer = setTimeout(() => {
250
+ if (this.appendHandle !== h || this.inflight)
251
+ return;
252
+ void this.closeAppendHandle().then(() => this.forgetIfDone());
253
+ }, APPEND_HOLD_MS);
254
+ h.timer.unref?.();
255
+ }
256
+ wake() {
257
+ const through = this.doneThrough;
258
+ const ready = this.waiters.filter((w) => w.ticket <= through);
259
+ if (!ready.length)
260
+ return;
261
+ this.waiters = this.waiters.filter((w) => w.ticket > through);
262
+ for (const w of ready)
263
+ w.done();
264
+ }
265
+ }
266
+ function report(dir, stem, where, e) {
267
+ let heard = false;
268
+ for (const r of reporters) {
269
+ if (r.dir !== dir)
270
+ continue;
271
+ heard = true;
272
+ try {
273
+ r.fn(where, e, stem);
274
+ }
275
+ catch { }
276
+ }
277
+ if (!heard)
278
+ console.error(`! storage: ${where} failed: ${e.message}`);
279
+ }
280
+ async function writeAll(fh, data) {
281
+ for (let off = 0; off < data.length;) {
282
+ const { bytesWritten } = await fh.write(data, off, data.length - off);
283
+ off += bytesWritten;
284
+ }
285
+ }
286
+ async function syncHandle(fh) {
287
+ if (!waitsForDisk())
288
+ return;
289
+ const t = Date.now();
290
+ await fh.sync();
291
+ noteDiskWait(Date.now() - t);
292
+ }
293
+ async function replaceAtomic(file, data, mode) {
294
+ const tmp = `${file}.tmp`;
295
+ let fh;
296
+ try {
297
+ fh = await fsp.open(tmp, "w", mode);
298
+ }
299
+ catch (e) {
300
+ if (e.code !== "ENOENT")
301
+ throw e;
302
+ await fsp.mkdir(dirname(file), { recursive: true, mode: 0o700 });
303
+ fh = await fsp.open(tmp, "w", mode);
304
+ }
305
+ try {
306
+ await writeAll(fh, data);
307
+ await syncHandle(fh);
308
+ }
309
+ finally {
310
+ await fh.close();
311
+ }
312
+ await renameRetrying(tmp, file);
313
+ if (waitsForDisk()) {
314
+ let dh = null;
315
+ try {
316
+ dh = await fsp.open(dirname(file), "r");
317
+ await syncHandle(dh);
318
+ }
319
+ catch { }
320
+ finally {
321
+ await dh?.close().catch(() => { });
322
+ }
323
+ }
324
+ }
325
+ async function renameRetrying(from, to) {
326
+ for (let i = 0;; i++) {
327
+ try {
328
+ await fsp.rename(from, to);
329
+ return;
330
+ }
331
+ catch (e) {
332
+ if (process.platform !== "win32" || i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(e.code ?? ""))
333
+ throw e;
334
+ await sleep(10);
335
+ }
336
+ }
337
+ }
338
+ const writers = new Map();
339
+ const keyOf = (dir, stem) => join(resolve(dir), stem);
340
+ export function writerFor(dir, stem) {
341
+ const key = keyOf(dir, stem);
342
+ let w = writers.get(key);
343
+ if (!w) {
344
+ w = new SpaceWriter(resolve(dir), stem, key);
345
+ writers.set(key, w);
346
+ }
347
+ return w;
348
+ }
349
+ export function busyWriter(dir, stem) {
350
+ const w = writers.get(keyOf(dir, stem));
351
+ return w && !w.idle ? w : null;
352
+ }
353
+ export function pendingView(dir, stem, file) {
354
+ return busyWriter(dir, stem)?.view(file);
355
+ }
356
+ export function readLatest(dir, stem, file) {
357
+ const v = pendingView(dir, stem, file);
358
+ if (v === undefined)
359
+ return readFileSync(file, "utf8");
360
+ if (v === null)
361
+ throw Object.assign(new Error(`ENOENT: no such file, open '${file}'`), { code: "ENOENT" });
362
+ return v.toString("utf8");
363
+ }
364
+ export async function flushWrites(dir, timeoutMs = 30_000) {
365
+ const root = dir ? resolve(dir) : null;
366
+ const end = Date.now() + timeoutMs;
367
+ for (;;) {
368
+ const busy = [...writers.values()].filter((w) => !w.idle && (!root || w.dir === root));
369
+ if (!busy.length) {
370
+ await Promise.all([...writers.values()].filter((w) => !root || w.dir === root).map((w) => w.closeAppendHandle()));
371
+ for (const w of [...writers.values()])
372
+ if (w.idle && (!root || w.dir === root))
373
+ w.forget();
374
+ return true;
375
+ }
376
+ const left = end - Date.now();
377
+ if (left <= 0)
378
+ return false;
379
+ let timer;
380
+ await Promise.race([Promise.all(busy.map((w) => w.whenWritten())), new Promise((r) => { timer = setTimeout(r, left); })]);
381
+ clearTimeout(timer);
382
+ }
383
+ }
384
+ const recorders = new Set();
385
+ export function recordWrites() {
386
+ const got = new Map();
387
+ recorders.add(got);
388
+ const t = setImmediate(() => recorders.delete(got));
389
+ return () => { clearImmediate(t); recorders.delete(got); return got; };
390
+ }
391
+ export async function whenLanded(writes, timeoutMs = 10_000) {
392
+ const waits = [...writes].map(([w, t]) => ({ w, t }));
393
+ const end = Date.now() + timeoutMs;
394
+ for (;;) {
395
+ if (waits.every(({ w, t }) => w.doneThrough >= t))
396
+ return true;
397
+ if (waits.some(({ w, t }) => w.failing && w.doneThrough < t) || Date.now() >= end)
398
+ return false;
399
+ let timer;
400
+ await Promise.race([Promise.all(waits.map(({ w, t }) => w.whenWritten(t))), new Promise((r) => { timer = setTimeout(r, 100); })]);
401
+ clearTimeout(timer);
402
+ }
403
+ }
404
+ //# sourceMappingURL=disk-writer.js.map
package/dist/doctor.js CHANGED
@@ -15,7 +15,7 @@ import { expectedLauncher, managedLayout, runtimeNodeFit, runtimeVerdict } from
15
15
  import { compareVersions } from "./installer.js";
16
16
  import { PKG_NAME } from "./upgrade.js";
17
17
  import { PantheonClient } from "./client.js";
18
- import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
18
+ import { load, resolveFor, loadAll, configFor, CONFIG_PATH } from "./config.js";
19
19
  import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
20
20
  import { approvalSummary, chosenApproval, copilotMcpConfigFile, kiroMcpConfigFile, onPath } from "./install.js";
21
21
  import { hookState, readIntent, agentHookStates, repairNote } from "./notify.js";
@@ -204,7 +204,7 @@ export async function doctor(projectDir) {
204
204
  }
205
205
  let cfg = null;
206
206
  try {
207
- cfg = load();
207
+ cfg = load() ?? (() => { const f = loadAll(); const m = Object.values(f?.spaces ?? {})[0]; return f && m ? configFor(f, m, null) : null; })();
208
208
  }
209
209
  catch (e) {
210
210
  add("fail", "config", `unreadable: ${e.message}`, undefined, "unreadable");
@@ -215,7 +215,10 @@ export async function doctor(projectDir) {
215
215
  }
216
216
  return out;
217
217
  }
218
- add("pass", "config", `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`, undefined, "present");
218
+ const teamNames = Object.values(loadAll()?.spaces ?? {}).map((m) => m.name);
219
+ add("pass", "config", resolveFor(projectDir).cfg || teamNames.length < 2
220
+ ? `${cfg.member} in "${cfg.space}" via ${cfg.relay} (${CONFIG_PATH})`
221
+ : `${cfg.member} in ${teamNames.length} teams (${teamNames.join(", ")}), none picked for this folder (${CONFIG_PATH})`, undefined, "present");
219
222
  const here = resolveFor(projectDir, { strict: true });
220
223
  const nTeams = Object.keys(loadAll()?.spaces ?? {}).length;
221
224
  const folderHere = here.reason === "binding" && !here.repo;
package/dist/ledger.js CHANGED
@@ -1,7 +1,8 @@
1
- import { mkdirSync, existsSync, openSync, readSync, writeSync, fsyncSync, closeSync, fstatSync, copyFileSync, truncateSync, chmodSync } from "node:fs";
1
+ import { mkdirSync, existsSync, openSync, readSync, fsyncSync, closeSync, fstatSync, copyFileSync, truncateSync, chmodSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { load } from "./config.js";
4
- import { fsyncDir, renameRetrying, syncFd, writeFileAtomic } from "./store.js";
4
+ import { fsyncDir, renameRetrying } from "./store.js";
5
+ import { backlogCap, busyWriter, DiskBusy, pendingView, writerFor } from "./disk-writer.js";
5
6
  export class SpaceFull extends Error {
6
7
  bytes;
7
8
  cap;
@@ -14,6 +15,8 @@ export class SpaceFull extends Error {
14
15
  }
15
16
  import { signedRead } from "./reads.js";
16
17
  export class Ledger {
18
+ dir;
19
+ stem;
17
20
  records = [];
18
21
  byId = new Map();
19
22
  seq = 0;
@@ -21,12 +24,25 @@ export class Ledger {
21
24
  outOfOrder = 0;
22
25
  firstOutOfOrder = null;
23
26
  file;
24
- constructor(dir, space) {
25
- this.file = join(dir, `${space}.jsonl`);
27
+ constructor(dir, stem) {
28
+ this.dir = dir;
29
+ this.stem = stem;
30
+ this.file = join(dir, `${stem}.jsonl`);
26
31
  mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
27
- if (existsSync(this.file))
32
+ const pending = pendingView(dir, stem, this.file);
33
+ if (pending !== undefined)
34
+ this.readPending(pending ?? Buffer.alloc(0));
35
+ else if (existsSync(this.file))
28
36
  this.read();
29
37
  }
38
+ readPending(buf) {
39
+ let start = 0;
40
+ for (let i = buf.indexOf(10); i !== -1; i = buf.indexOf(10, start)) {
41
+ this.take(buf.subarray(start, i).toString("utf8"));
42
+ start = i + 1;
43
+ }
44
+ this.fileBytes = buf.length;
45
+ }
30
46
  read() {
31
47
  const fd = openSync(this.file, "r");
32
48
  let size = 0, lastNl = -1;
@@ -100,24 +116,25 @@ export class Ledger {
100
116
  return this.fileBytes;
101
117
  }
102
118
  append(rec, cap = 0) {
119
+ const w = writerFor(this.dir, this.stem);
120
+ if (w.failing)
121
+ throw w.failing;
103
122
  const full = { ...rec, seq: this.seq + 1 };
104
123
  const line = Buffer.from(JSON.stringify(full) + "\n");
105
124
  if (cap > 0 && this.fileBytes + line.length > cap)
106
125
  throw new SpaceFull(this.fileBytes, cap);
107
- const fd = openSync(this.file, "a", 0o600);
108
- try {
109
- writeSync(fd, line);
110
- syncFd(fd);
111
- }
112
- finally {
113
- closeSync(fd);
114
- }
126
+ w.append(this.file, line, this.fileBytes);
115
127
  this.seq = full.seq;
116
128
  this.fileBytes += line.length;
117
129
  this.records.push(full);
118
130
  this.byId.set(full.id, full);
119
131
  return full;
120
132
  }
133
+ checkBacklog() {
134
+ const w = busyWriter(this.dir, this.stem);
135
+ if (w && w.backlogBytes > backlogCap())
136
+ throw new DiskBusy(w.backlogBytes, backlogCap());
137
+ }
121
138
  since(seq, limit = 500) {
122
139
  return this.records.filter((r) => r.seq > seq).slice(0, limit);
123
140
  }
@@ -146,7 +163,7 @@ export class Ledger {
146
163
  if (dropped === 0)
147
164
  return { dropped: 0, kept: kept.length };
148
165
  const text = kept.map((r) => JSON.stringify(r)).join("\n") + "\n";
149
- writeFileAtomic(this.file, text);
166
+ writerFor(this.dir, this.stem).replace(this.file, text);
150
167
  this.records = kept;
151
168
  this.byId = new Map();
152
169
  for (const r of kept)
package/dist/notifier.js CHANGED
@@ -167,7 +167,7 @@ export class HumanNotifier {
167
167
  const peerOf = (from) => from ? { member: from, agent: client.roster.find((m) => m.id === from)?.agent } : undefined;
168
168
  const withLabel = (n) => {
169
169
  const labelled = this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
170
- return platform() === "darwin" ? labelled : { ...labelled, space: this.cfg.space };
170
+ return { ...labelled, space: this.cfg.space };
171
171
  };
172
172
  let asks = {};
173
173
  try {
@@ -9,6 +9,7 @@ import { PantheonClient } from "./client.js";
9
9
  import { holdsRepo } from "./repo-find.js";
10
10
  import { folderFacts, folderRefusal, folderRefusalHere, isInside, isTemporaryRefusal } from "./folder-guard.js";
11
11
  import { load, loadAll, identity, save, toInvite, resolveFor, repoInfo, bindRepo, bindFolder, folderBinding, DEFAULT_RELAY } from "./config.js";
12
+ import { looksLikePlaceholder, teamSlug } from "./team-name.js";
12
13
  import { commandsChosen, loadProfile, wakeChosen, wakeDefault } from "./profile.js";
13
14
  import { newKeypair } from "./identity.js";
14
15
  import { installForUser } from "./install.js";
@@ -268,7 +269,16 @@ export function deviceLabel(plat = platform(), env = process.env, info, host = h
268
269
  }
269
270
  export function teamNameFor(dir, repo = repoInfo(dir)) {
270
271
  const fromOrigin = repo?.origin ? (repo.origin.replace(/\.git$/i, "").split(/[/:]/).filter(Boolean).pop() ?? "") : "";
271
- return slugName(fromOrigin || basename(repo?.root ?? dir), "team");
272
+ const slug = teamSlug(slugName(fromOrigin || basename(repo?.root ?? dir), "team")) || "team";
273
+ if (!looksLikePlaceholder(slug))
274
+ return slug;
275
+ return ["team", "project", "folder"].map((w) => teamSlug(`${slug}-${w}`)).find((n) => n && !looksLikePlaceholder(n)) ?? "team";
276
+ }
277
+ export function startReconnects(opts, here = resolveFor(process.cwd())) {
278
+ const existing = here.cfg;
279
+ return !!existing && !opts.reset && !!existing.space && !!existing.token
280
+ && (here.reason === "binding" || here.reason === "single" || here.reason === "env")
281
+ && (!opts.space || opts.space === existing.space);
272
282
  }
273
283
  export function isLocalRelay(url) {
274
284
  let host = "";
@@ -421,6 +431,7 @@ export async function start(opts) {
421
431
  : { 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 })
422
432
  : findReach(opts.port);
423
433
  const nothing = (why) => ({ cfg: priorCfg ?? { relay: reach.url }, invite: null, inviteMayWake: false, claimed: false, reach, tools: [], granted: [], relay: null, warnings: [why] });
434
+ const reuse = startReconnects(opts, here);
424
435
  let relay = null;
425
436
  if (chosen && !hostHere) {
426
437
  const probe = await probeRelay(reach.url, opts.remoteTimeoutMs ?? 10_000, opts.fetchImpl);
@@ -453,9 +464,6 @@ export async function start(opts) {
453
464
  rememberHosted(opts.dataDir, chosen);
454
465
  }
455
466
  const existing = priorCfg;
456
- const hasOwnSpace = !!existing?.space && !!existing?.token;
457
- const reuse = !!existing && !opts.reset && hasOwnSpace && (here.reason === "binding" || here.reason === "single" || here.reason === "env")
458
- && (!opts.space || opts.space === existing.space);
459
467
  const keepIdentity = !!existing && !opts.reset && !!existing.publicKey && !!existing.privateKey;
460
468
  let cfg = reuse
461
469
  ? { ...existing, relay: reach.url, agent: detectAgent() }
@@ -534,8 +542,12 @@ export async function start(opts) {
534
542
  catch (e) {
535
543
  const msg = (await interceptedText(e, cfg.relay)) ?? e.message;
536
544
  const nameTaken = !reuse && /BAD_TOKEN/.test(msg);
537
- if (nameTaken && !opts.space) {
538
- const alt = `${cfg.space}-${cfg.member}`;
545
+ const ownSeat = nameTaken && /already a member of/i.test(msg);
546
+ if (ownSeat) {
547
+ warnings.push(msg.replace(/^BAD_TOKEN:\s*/, ""));
548
+ }
549
+ else if (nameTaken && !opts.space) {
550
+ const alt = teamSlug(`${cfg.space}-${cfg.member}`) || cfg.space;
539
551
  opts.log(` space "${cfg.space}" is already another team's space on this relay — using "${alt}"`);
540
552
  cfg = { ...cfg, space: alt };
541
553
  try {