@join-pantheon/cli 0.33.0-rc.1 → 0.33.0-rc.3

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,136 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { win32 } from "node:path";
4
+ const isPort = (n) => Number.isInteger(n) && n > 0 && n <= 65535;
5
+ export const lsofArgs = (clientPort) => ["-nP", "-w", `-iTCP@127.0.0.1:${clientPort}`, "-sTCP:ESTABLISHED", "-a", "-Fpun"];
6
+ export function parseLsofPeers(out, clientPort, boardPort) {
7
+ const want = `127.0.0.1:${clientPort}->127.0.0.1:${boardPort}`;
8
+ const found = [];
9
+ let pid = NaN, uid = NaN;
10
+ for (const line of out.split("\n")) {
11
+ const k = line[0], v = line.slice(1).trim();
12
+ if (k === "p") {
13
+ pid = Number(v);
14
+ uid = NaN;
15
+ }
16
+ else if (k === "u")
17
+ uid = Number(v);
18
+ else if (k === "n" && v === want && Number.isInteger(pid) && Number.isInteger(uid))
19
+ found.push({ pid, uid });
20
+ }
21
+ return found;
22
+ }
23
+ function runLsof(args) {
24
+ const exe = existsSync("/usr/sbin/lsof") ? "/usr/sbin/lsof" : "lsof";
25
+ return new Promise((done) => {
26
+ execFile(exe, args, { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }, (err, out) => {
27
+ if (err && typeof err.code !== "number") {
28
+ done(null);
29
+ return;
30
+ }
31
+ done(out ?? "");
32
+ });
33
+ });
34
+ }
35
+ const LSOF_AT_ONCE = 4;
36
+ let lsofRunning = 0;
37
+ const lsofWaiting = [];
38
+ async function lsofSlot(fn) {
39
+ while (lsofRunning >= LSOF_AT_ONCE)
40
+ await new Promise((r) => lsofWaiting.push(r));
41
+ lsofRunning++;
42
+ try {
43
+ return await fn();
44
+ }
45
+ finally {
46
+ lsofRunning--;
47
+ lsofWaiting.shift()?.();
48
+ }
49
+ }
50
+ export async function macPeer(clientPort, boardPort, me, run = (a) => lsofSlot(() => runLsof(a))) {
51
+ if (me === null || !isPort(clientPort) || !isPort(boardPort))
52
+ return "unknown";
53
+ const out = await run(lsofArgs(clientPort)).catch(() => null);
54
+ if (out === null)
55
+ return "unknown";
56
+ const owners = parseLsofPeers(out, clientPort, boardPort);
57
+ if (!owners.length)
58
+ return "unknown";
59
+ return owners.every((o) => o.uid === me) ? "same" : "other";
60
+ }
61
+ export function windowsPeerScript(boardPort) {
62
+ if (!isPort(boardPort))
63
+ throw new Error("not a port");
64
+ return [
65
+ "[Console]::OutputEncoding = [Text.Encoding]::UTF8",
66
+ "$me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value",
67
+ "'ME ' + $me",
68
+ `foreach ($c in @(Get-NetTCPConnection -RemotePort ${boardPort} -State Established -ErrorAction SilentlyContinue)) {`,
69
+ " if (@('127.0.0.1', '::ffff:127.0.0.1') -notcontains $c.LocalAddress -or @('127.0.0.1', '::ffff:127.0.0.1') -notcontains $c.RemoteAddress) { continue }",
70
+ " $sid = ''",
71
+ " try { $p = Get-CimInstance Win32_Process -Filter ('ProcessId=' + $c.OwningProcess); if ($p) { $o = Invoke-CimMethod -InputObject $p -MethodName GetOwnerSid; if ($o.ReturnValue -eq 0) { $sid = [string]$o.Sid } } } catch { $sid = '' }",
72
+ " 'C ' + $c.LocalPort + ' ' + $c.OwningProcess + ' ' + $sid",
73
+ "}",
74
+ ].join("\n");
75
+ }
76
+ export function parseWindowsPeers(out) {
77
+ let me = null;
78
+ const ports = new Map();
79
+ for (const raw of out.split(/\r?\n/)) {
80
+ const line = raw.trim();
81
+ const m = /^ME (S-1-[0-9-]+)$/.exec(line);
82
+ if (m) {
83
+ me = m[1];
84
+ continue;
85
+ }
86
+ const c = /^C (\d{1,5}) (\d+)(?: (\S*))?$/.exec(line);
87
+ if (!c)
88
+ continue;
89
+ const sid = /^S-1-[0-9-]+$/.test(c[3] ?? "") ? c[3] : "";
90
+ ports.set(Number(c[1]), [...(ports.get(Number(c[1])) ?? []), sid]);
91
+ }
92
+ return { me, ports };
93
+ }
94
+ export function windowsVerdict(parsed, clientPort) {
95
+ const owners = parsed.ports.get(clientPort);
96
+ if (!parsed.me || !owners?.length)
97
+ return "unknown";
98
+ if (owners.every((s) => s === parsed.me))
99
+ return "same";
100
+ return owners.some((s) => s && s !== parsed.me) ? "other" : "unknown";
101
+ }
102
+ function runWindowsPowerShell(script) {
103
+ const exe = win32.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
104
+ return new Promise((done) => {
105
+ execFile(exe, ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: 15_000, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, out) => {
106
+ done(err && !out ? null : (out ?? null));
107
+ });
108
+ });
109
+ }
110
+ export function windowsPeerCheck(run = runWindowsPowerShell) {
111
+ const waiting = new Map();
112
+ const busy = new Set();
113
+ const go = (boardPort) => {
114
+ const batch = waiting.get(boardPort) ?? [];
115
+ waiting.delete(boardPort);
116
+ if (!batch.length)
117
+ return;
118
+ busy.add(boardPort);
119
+ run(windowsPeerScript(boardPort)).catch(() => null).then((out) => {
120
+ const parsed = out === null ? null : parseWindowsPeers(out);
121
+ for (const w of batch)
122
+ w.done(parsed ? windowsVerdict(parsed, w.port) : "unknown");
123
+ }).finally(() => { busy.delete(boardPort); go(boardPort); });
124
+ };
125
+ return (clientPort, boardPort) => new Promise((done) => {
126
+ if (!isPort(clientPort) || !isPort(boardPort)) {
127
+ done("unknown");
128
+ return;
129
+ }
130
+ waiting.set(boardPort, [...(waiting.get(boardPort) ?? []), { port: clientPort, done }]);
131
+ if (!busy.has(boardPort))
132
+ setImmediate(() => { if (!busy.has(boardPort))
133
+ go(boardPort); });
134
+ });
135
+ }
136
+ //# sourceMappingURL=board-peer.js.map
package/dist/board.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import { spawn } from "node:child_process";
4
- import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
5
  import { homedir, platform } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CONFIG_DIR } from "./config.js";
9
+ import { macPeer, windowsPeerCheck } from "./board-peer.js";
9
10
  import { LIMITS } from "./protocol.js";
10
11
  import { childNodeFlags } from "./managed.js";
11
12
  import { which } from "./which.js";
@@ -61,9 +62,23 @@ export function defaultBoardDeps(home = CONFIG_DIR) {
61
62
  actionFile: join(home, "bin", ".hud-action.json"),
62
63
  openTerminal: (actionId) => openExplainTerminal(home, actionId),
63
64
  now: Date.now,
64
- peerUid: (c, b) => loopbackPeerUid(c, b),
65
+ peer: defaultPeerCheck(),
65
66
  };
66
67
  }
68
+ export function defaultPeerCheck(os = platform()) {
69
+ const me = typeof process.getuid === "function" ? process.getuid() : null;
70
+ if (os === "linux") {
71
+ return async (c, b) => {
72
+ const uid = loopbackPeerUid(c, b);
73
+ return uid === null || me === null ? "unknown" : uid === me ? "same" : "other";
74
+ };
75
+ }
76
+ if (os === "darwin")
77
+ return (c, b) => macPeer(c, b, me);
78
+ if (os === "win32")
79
+ return windowsPeerCheck();
80
+ return async () => "unknown";
81
+ }
67
82
  export function loopbackPeerUid(clientPort, boardPort, read = (p) => readFileSync(p, "utf8"), os = platform()) {
68
83
  if (os !== "linux")
69
84
  return null;
@@ -290,15 +305,13 @@ export async function startBoardServer(o = {}) {
290
305
  const known = peerChecked.get(sock);
291
306
  if (known !== undefined)
292
307
  return known;
293
- const me = typeof process.getuid === "function" ? process.getuid() : null;
294
308
  const addr = sock.remoteAddress ?? "";
295
- let ok = addr === "127.0.0.1" || addr === "::ffff:127.0.0.1";
296
- if (ok && me !== null && sock.remotePort) {
297
- const uid = deps.peerUid(sock.remotePort, port);
298
- ok = uid === null || uid === me;
299
- }
300
- peerChecked.set(sock, ok);
301
- return ok;
309
+ const local = addr === "127.0.0.1" || addr === "::ffff:127.0.0.1";
310
+ const check = local && sock.remotePort
311
+ ? deps.peer(sock.remotePort, port).then((v) => v === "same", () => false)
312
+ : Promise.resolve(false);
313
+ peerChecked.set(sock, check);
314
+ return check;
302
315
  };
303
316
  const status = async () => {
304
317
  const s = await deps.status();
@@ -370,10 +383,6 @@ export async function startBoardServer(o = {}) {
370
383
  sendJson(res, 403, { ok: false, error: "wrong host" });
371
384
  return;
372
385
  }
373
- if (!samePerson(req)) {
374
- sendJson(res, 403, { ok: false, error: "this board belongs to another user on this computer" });
375
- return;
376
- }
377
386
  const site = req.headers["sec-fetch-site"];
378
387
  if (typeof site === "string" && site !== "same-origin" && site !== "none") {
379
388
  sendJson(res, 403, { ok: false, error: "cross-site request" });
@@ -410,6 +419,10 @@ export async function startBoardServer(o = {}) {
410
419
  if (method === "POST" && url.pathname === "/api/session") {
411
420
  const nonce = bearerOf(req);
412
421
  const body = strictObject(await readBody(req), []);
422
+ if (!(await samePerson(req))) {
423
+ sendJson(res, 403, { ok: false, error: "this board belongs to another user on this computer — or this computer could not show that this connection is yours" });
424
+ return;
425
+ }
413
426
  if (!nonce || !useLink(nonce)) {
414
427
  sendJson(res, 401, { ok: false, error: "this board link has expired or was already used — run pantheon board for a fresh one" });
415
428
  return;
@@ -565,7 +578,8 @@ export async function startBoardServer(o = {}) {
565
578
  }
566
579
  catch { } });
567
580
  });
568
- server.headersTimeout = 15_000;
581
+ server.keepAliveTimeout = 30_000;
582
+ server.headersTimeout = 35_000;
569
583
  server.requestTimeout = 60_000;
570
584
  await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", () => { server.off("error", fail); ok(); }); });
571
585
  port = server.address().port;
@@ -620,31 +634,6 @@ export function browserCommand(url, os = platform(), find = (n) => which(n), o =
620
634
  const scoped = o.scope ? scopeArgv([x, url], find) : null;
621
635
  return scoped ? { cmd: scoped[0], args: scoped.slice(1), opts } : { cmd: x, args: [url], opts };
622
636
  }
623
- export function redirectPage(url, dir, now = Date.now()) {
624
- if (!LINK_RE.test(url))
625
- throw new Error("not a board link");
626
- mkdirSync(dir, { recursive: true, mode: 0o700 });
627
- for (const f of readdirSync(dir)) {
628
- if (!/^board-open-[0-9a-f]{16}\.html$/.test(f))
629
- continue;
630
- try {
631
- if (now - statSync(join(dir, f)).mtimeMs > 60_000)
632
- rmSync(join(dir, f), { force: true });
633
- }
634
- catch { }
635
- }
636
- const file = join(dir, `board-open-${randomBytes(8).toString("hex")}.html`);
637
- writeFileSync(file, `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${url}"><title>Pantheon</title><script>location.replace(${JSON.stringify(url)})</script>\n`, { mode: 0o600 });
638
- return file;
639
- }
640
- export function fileOpenCommand(file, os = platform()) {
641
- const opts = { detached: true, stdio: "ignore", windowsHide: true };
642
- if (os === "darwin")
643
- return { cmd: "open", args: [file], opts };
644
- if (os === "win32")
645
- return { cmd: "explorer.exe", args: [file], opts };
646
- return null;
647
- }
648
637
  export async function openBrowser(url, d = {}) {
649
638
  if (!LINK_RE.test(url))
650
639
  throw new Error("not a board link");
@@ -655,11 +644,6 @@ export async function openBrowser(url, d = {}) {
655
644
  if (await portal(url).catch(() => false))
656
645
  return "portal";
657
646
  }
658
- if (os === "darwin" || os === "win32") {
659
- const page = redirectPage(url, d.pageDir ?? join(CONFIG_DIR, "bin"));
660
- const how = fileOpenCommand(page, os);
661
- return (await (d.launch ?? launch)(how)) ? "direct" : false;
662
- }
663
647
  const scope = os === "linux" && (d.inService ?? (() => inServiceCgroup()))();
664
648
  const how = browserCommand(url, os, find, { scope });
665
649
  if (!how)
@@ -721,7 +705,7 @@ async function openExplainTerminal(home, actionId) {
721
705
  return { ok: false, why: `${t.error} — copy the question into your agent instead` };
722
706
  return (await launch(t)) ? { ok: true } : { ok: false, why: "the terminal could not be started — copy the question into your agent instead" };
723
707
  }
724
- export async function ping(s, timeoutMs = 1500) {
708
+ export async function ping(s, timeoutMs = 3000) {
725
709
  const challenge = randomBytes(16).toString("hex");
726
710
  try {
727
711
  const res = await fetch(`http://127.0.0.1:${s.port}/api/ping?challenge=${challenge}`, { signal: AbortSignal.timeout(timeoutMs), redirect: "error" });
package/dist/cli.js CHANGED
@@ -914,7 +914,8 @@ ${B("control")}
914
914
  (auto-detects claude / codex / cursor / copilot / kiro on this machine)
915
915
  pantheon spend woken runs on this machine, and what pantheon cannot see
916
916
  pantheon leave remove what pantheon put on this machine
917
- pantheon uninstall leave, then remove Pantheon itself: its own Node, the pantheon command, the PATH lines
917
+ pantheon uninstall leave, then remove Pantheon itself: its own Node, the pantheon command, the PATH lines,
918
+ and all it keeps in ~/.pantheon (files of yours there stay)
918
919
  pantheon panic emergency stop: ends the run in flight and the worker, and switches off wake rights in every team you are in
919
920
  pantheon doctor [--share] check this machine end to end; --share prints it without names or paths, to paste to us
920
921
  pantheon network [--proxy <url> | --direct | --clear] how this machine reaches the relay; save a proxy for every Pantheon process here
@@ -1038,7 +1039,7 @@ async function teamStanding(all, m) {
1038
1039
  return { others: null, founder: false };
1039
1040
  }
1040
1041
  finally {
1041
- c.close();
1042
+ c.dispose();
1042
1043
  }
1043
1044
  }
1044
1045
  function slipDistance(a, b) {
@@ -1080,7 +1081,7 @@ async function main() {
1080
1081
  await installNetwork();
1081
1082
  }
1082
1083
  catch { }
1083
- if (!QUIET.has(cmd)) {
1084
+ if (!QUIET.has(cmd) && cmd !== "uninstall") {
1084
1085
  try {
1085
1086
  console.error(D(updateBanner(VERSION)));
1086
1087
  }
@@ -2507,7 +2508,8 @@ async function main() {
2507
2508
  if (uninstalling && !argv.includes("--yes")) {
2508
2509
  const teams = plan.teams.length && !keepIdentity ? `leaves ${plan.teams.map((t) => `"${t.name}"`).join(", ")} (this machine's key goes), ` : "";
2509
2510
  const what = `this removes Pantheon from this machine: it ${teams}uninstalls its services, hooks and agent entries` +
2510
- (install?.present ? `, and deletes Pantheon's own Node in ${install.runtime}, the pantheon command and the PATH lines the installer added.` : ".");
2511
+ (install?.present ? `, and deletes Pantheon's own Node in ${install.runtime}, the pantheon command and the PATH lines the installer added` : "") +
2512
+ `. Everything Pantheon keeps in ${CONFIG_DIR} goes${keepIdentity ? " (but your identity, --keep-identity)" : ""}; files of your own there stay.`;
2511
2513
  if (!process.stdin.isTTY)
2512
2514
  die(`${what}\n If you mean it: pantheon uninstall --yes`);
2513
2515
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -2528,6 +2530,8 @@ async function main() {
2528
2530
  die(`nothing was changed.${plan.teams.length > 1 ? " Leave one team with: pantheon leave <team>" : ""}`);
2529
2531
  }
2530
2532
  console.error(`${B(uninstalling ? "uninstalling pantheon" : "leaving pantheon")}${plan.teams.length ? D(` — ${plan.teams.map((t) => t.name).join(", ")}`) : ""}\n`);
2533
+ const { pantheonHomeRefusal, refusedHomeLine } = await import("./home-safety.js");
2534
+ const homeRefusal = pantheonHomeRefusal(CONFIG_DIR);
2531
2535
  const { uninstallMachine, realUninstallIo } = await import("./machine.js");
2532
2536
  const { removeUserMcpEntries } = await import("./install.js");
2533
2537
  const { killRecordedRun } = await import("./worker.js");
@@ -2537,6 +2541,7 @@ async function main() {
2537
2541
  hasIntent: () => readIntent() !== null,
2538
2542
  clearIntent: () => clearIntent(),
2539
2543
  killRun: () => killRecordedRun(dirname(CONFIG_PATH)),
2544
+ homeRefusal,
2540
2545
  }));
2541
2546
  for (const l of done.lines)
2542
2547
  console.error(` ${l}`);
@@ -2557,66 +2562,75 @@ async function main() {
2557
2562
  left.push(...u.left);
2558
2563
  }
2559
2564
  const { unlinkSync, existsSync: exists, rmSync, readdirSync } = await import("node:fs");
2560
- if (all && exists(CONFIG_PATH)) {
2561
- if (keepIdentity) {
2562
- left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2563
- }
2564
- else {
2565
+ const spare = [];
2566
+ const hasConfig = !!all && exists(CONFIG_PATH);
2567
+ if (homeRefusal) {
2568
+ left.push(refusedHomeLine(CONFIG_DIR, homeRefusal));
2569
+ }
2570
+ else if (hasConfig && keepIdentity) {
2571
+ left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2572
+ }
2573
+ else if (!keepIdentity && (hasConfig || uninstalling)) {
2574
+ if (hasConfig) {
2565
2575
  unlinkSync(CONFIG_PATH);
2566
2576
  removed.push(`${CONFIG_PATH} — your key and ${plan.teams.length ? `the token${plan.teams.length === 1 ? "" : "s"} for ${plan.teams.map((t) => `"${t.name}"`).join(", ")}` : "account"}; rejoining means a new invite`);
2567
- const runHomes = join(CONFIG_PATH, "..", "run-homes");
2568
- const { restorePendingQuarantines, pendingQuarantines } = await import("./agents.js");
2569
- const put = restorePendingQuarantines(runHomes);
2570
- if (put.length)
2571
- removed.push(`nothing of your repos' — what interrupted runs had held aside: ${put.join("; ")}`);
2572
- const still = pendingQuarantines(runHomes);
2573
- const keptAside = (dir) => { try {
2574
- return readdirSync(join(dir, "quarantine-kept")).length > 0;
2575
- }
2576
- catch {
2577
- return false;
2578
- } };
2579
- let homes = [];
2577
+ }
2578
+ const runHomes = join(CONFIG_PATH, "..", "run-homes");
2579
+ const { restorePendingQuarantines, pendingQuarantines } = await import("./agents.js");
2580
+ const put = restorePendingQuarantines(runHomes);
2581
+ if (put.length)
2582
+ removed.push(`nothing of your repos' — what interrupted runs had held aside: ${put.join("; ")}`);
2583
+ const still = pendingQuarantines(runHomes);
2584
+ const keptAside = (dir) => { try {
2585
+ return readdirSync(join(dir, "quarantine-kept")).length > 0;
2586
+ }
2587
+ catch {
2588
+ return false;
2589
+ } };
2590
+ let homes = [];
2591
+ try {
2592
+ homes = readdirSync(runHomes);
2593
+ }
2594
+ catch { }
2595
+ const keep = new Set([...still.map((x) => x.runHome), ...homes.map((h) => join(runHomes, h)).filter(keptAside)]);
2596
+ for (const h of homes) {
2597
+ const dir = join(runHomes, h);
2598
+ if (keep.has(dir))
2599
+ continue;
2580
2600
  try {
2581
- homes = readdirSync(runHomes);
2601
+ rmSync(dir, { recursive: true, force: true });
2582
2602
  }
2583
2603
  catch { }
2584
- const keep = new Set([...still.map((x) => x.runHome), ...homes.map((h) => join(runHomes, h)).filter(keptAside)]);
2585
- for (const h of homes) {
2586
- const dir = join(runHomes, h);
2587
- if (keep.has(dir))
2588
- continue;
2589
- try {
2590
- rmSync(dir, { recursive: true, force: true });
2591
- }
2592
- catch { }
2593
- }
2594
- if (!keep.size) {
2595
- try {
2596
- rmSync(runHomes, { recursive: true, force: true });
2597
- }
2598
- catch { }
2604
+ }
2605
+ if (!keep.size) {
2606
+ try {
2607
+ rmSync(runHomes, { recursive: true, force: true });
2599
2608
  }
2600
- for (const x of still)
2601
- left.push(`${x.runHome} — it still holds ${x.paths.join(", ")} from ${x.worktree}, which could not be put back safely; compare and move them back by hand, then delete the folder`);
2602
- for (const dir of keep)
2603
- if (keptAside(dir) && !still.some((x) => x.runHome === dir))
2604
- left.push(`${join(dir, "quarantine-kept")} — agent config an interrupted run held, set aside because the repo had moved on; check it, then delete the folder`);
2609
+ catch { }
2605
2610
  }
2611
+ else
2612
+ spare.push(runHomes);
2613
+ for (const x of still)
2614
+ left.push(`${x.runHome} — it still holds ${x.paths.join(", ")} from ${x.worktree}, which could not be put back safely; compare and move them back by hand, then delete the folder`);
2615
+ for (const dir of keep)
2616
+ if (keptAside(dir) && !still.some((x) => x.runHome === dir))
2617
+ left.push(`${join(dir, "quarantine-kept")} — agent config an interrupted run held, set aside because the repo had moved on; check it, then delete the folder`);
2606
2618
  }
2607
2619
  {
2608
2620
  const { removePantheonWorktrees, removeHomeLeftovers } = await import("./machine.js");
2609
2621
  const repos = [project, ...Object.values(all?.bindings ?? {}).map((b) => b.path).filter((p) => !!p)];
2610
- const trees = removePantheonWorktrees(repos, [join(CONFIG_DIR, "worktrees"), ...repos.map((r) => join(r, ".pantheon", "worktrees"))]);
2622
+ const trees = removePantheonWorktrees(repos, [...(homeRefusal ? [] : [join(CONFIG_DIR, "worktrees")]), ...repos.map((r) => join(r, ".pantheon", "worktrees"))]);
2611
2623
  removed.push(...trees.removed);
2612
2624
  left.push(...trees.left);
2613
- try {
2614
- rmSync(join(CONFIG_DIR, "worktrees"), { recursive: true, force: true });
2625
+ if (!homeRefusal) {
2626
+ try {
2627
+ rmSync(join(CONFIG_DIR, "worktrees"), { recursive: true, force: true });
2628
+ }
2629
+ catch { }
2630
+ const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity, { uninstalling, refusal: null });
2631
+ removed.push(...rest.removed);
2632
+ left.push(...rest.left);
2615
2633
  }
2616
- catch { }
2617
- const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity, { uninstalling });
2618
- removed.push(...rest.removed);
2619
- left.push(...rest.left);
2620
2634
  }
2621
2635
  if (uninstalling) {
2622
2636
  if (install?.present) {
@@ -2626,6 +2640,12 @@ async function main() {
2626
2640
  }
2627
2641
  if (!runningManaged)
2628
2642
  left.push(`this pantheon itself (${process.argv[1] ?? "the one you ran"}), which npm installed — remove it with: ${NPM_UNINSTALL}`);
2643
+ const { sweepPantheonHome } = await import("./machine.js");
2644
+ if (!homeRefusal) {
2645
+ const swept = sweepPantheonHome(CONFIG_DIR, { keepIdentity, spare, refusal: null });
2646
+ removed.push(...swept.removed);
2647
+ left.push(...swept.left);
2648
+ }
2629
2649
  }
2630
2650
  console.error(`\n${B("Removed")}`);
2631
2651
  for (const r of removed)
@@ -3554,6 +3574,25 @@ async function main() {
3554
3574
  st = null;
3555
3575
  raw = null;
3556
3576
  }
3577
+ if (raw !== null) {
3578
+ let nonce;
3579
+ try {
3580
+ nonce = JSON.parse(raw).nonce;
3581
+ }
3582
+ catch {
3583
+ nonce = undefined;
3584
+ }
3585
+ const stale = st !== null && Date.now() - st.mtimeMs > HUD_ACTION_MAX_AGE_MS;
3586
+ if (stale && typeof nonce === "string" && nonce !== want) {
3587
+ try {
3588
+ rmSync(f, { force: true });
3589
+ }
3590
+ catch { }
3591
+ st = null;
3592
+ raw = null;
3593
+ nonce = undefined;
3594
+ }
3595
+ }
3557
3596
  if (raw !== null) {
3558
3597
  let nonce;
3559
3598
  try {
package/dist/client.js CHANGED
@@ -21,6 +21,7 @@ export class PantheonClient {
21
21
  seen = new Map();
22
22
  backoff = 500;
23
23
  closed = false;
24
+ disposed = false;
24
25
  everConnected = false;
25
26
  localOverride = null;
26
27
  usingLoopback = false;
@@ -170,6 +171,11 @@ export class PantheonClient {
170
171
  }
171
172
  attempt(address) {
172
173
  return new Promise((resolve, reject) => {
174
+ if (this.disposed) {
175
+ this.closed = true;
176
+ reject(new Error("this client was disposed"));
177
+ return;
178
+ }
173
179
  let settled = false;
174
180
  let heard = false;
175
181
  const fail = reject;
@@ -195,6 +201,8 @@ export class PantheonClient {
195
201
  this.watchdog.unref?.();
196
202
  ws.on("open", () => { });
197
203
  ws.on("message", (raw) => {
204
+ if (this.disposed)
205
+ return;
198
206
  this.lastInbound = Date.now();
199
207
  let msg;
200
208
  try {
@@ -438,6 +446,14 @@ export class PantheonClient {
438
446
  this.waiters = [];
439
447
  this.ws?.close(4000, "handover");
440
448
  }
449
+ dispose() {
450
+ this.disposed = true;
451
+ this.close();
452
+ if (this.watchdog) {
453
+ clearInterval(this.watchdog);
454
+ this.watchdog = null;
455
+ }
456
+ }
441
457
  close() {
442
458
  this.closed = true;
443
459
  for (const w of this.waiters) {
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { join, resolve, sep } from "node:path";
6
6
  import { VERSION } from "./version.js";
7
7
  import { repoName } from "./repo-route.js";
8
+ import { markPantheonHome } from "./home-safety.js";
8
9
  export const CONFIG_DIR = process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
9
10
  export const CONFIG_PATH = join(CONFIG_DIR, "config.json");
10
11
  export const DEFAULT_RELAY = process.env.PANTHEON_DEFAULT_RELAY
@@ -146,7 +147,7 @@ export function saveAll(f) {
146
147
  return CONFIG_PATH;
147
148
  }
148
149
  function privateDir() {
149
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
150
+ ensurePantheonHome();
150
151
  if (process.platform === "win32")
151
152
  return;
152
153
  try {
@@ -202,6 +203,11 @@ function writeConfigAtomic(data) {
202
203
  catch { }
203
204
  }
204
205
  }
206
+ export function ensurePantheonHome() {
207
+ const made = !existsSync(CONFIG_DIR);
208
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
209
+ markPantheonHome(CONFIG_DIR, made);
210
+ }
205
211
  const CONFIG_LOCK_STALE_MS = 5_000;
206
212
  const CONFIG_LOCK_WAIT_MS = 15_000;
207
213
  const sleepSync = (ms) => { try {