@join-pantheon/cli 0.33.0-rc.2 → 0.33.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.
@@ -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
@@ -2530,6 +2530,8 @@ async function main() {
2530
2530
  die(`nothing was changed.${plan.teams.length > 1 ? " Leave one team with: pantheon leave <team>" : ""}`);
2531
2531
  }
2532
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);
2533
2535
  const { uninstallMachine, realUninstallIo } = await import("./machine.js");
2534
2536
  const { removeUserMcpEntries } = await import("./install.js");
2535
2537
  const { killRecordedRun } = await import("./worker.js");
@@ -2539,6 +2541,7 @@ async function main() {
2539
2541
  hasIntent: () => readIntent() !== null,
2540
2542
  clearIntent: () => clearIntent(),
2541
2543
  killRun: () => killRecordedRun(dirname(CONFIG_PATH)),
2544
+ homeRefusal,
2542
2545
  }));
2543
2546
  for (const l of done.lines)
2544
2547
  console.error(` ${l}`);
@@ -2561,7 +2564,10 @@ async function main() {
2561
2564
  const { unlinkSync, existsSync: exists, rmSync, readdirSync } = await import("node:fs");
2562
2565
  const spare = [];
2563
2566
  const hasConfig = !!all && exists(CONFIG_PATH);
2564
- if (hasConfig && keepIdentity) {
2567
+ if (homeRefusal) {
2568
+ left.push(refusedHomeLine(CONFIG_DIR, homeRefusal));
2569
+ }
2570
+ else if (hasConfig && keepIdentity) {
2565
2571
  left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2566
2572
  }
2567
2573
  else if (!keepIdentity && (hasConfig || uninstalling)) {
@@ -2613,16 +2619,18 @@ async function main() {
2613
2619
  {
2614
2620
  const { removePantheonWorktrees, removeHomeLeftovers } = await import("./machine.js");
2615
2621
  const repos = [project, ...Object.values(all?.bindings ?? {}).map((b) => b.path).filter((p) => !!p)];
2616
- 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"))]);
2617
2623
  removed.push(...trees.removed);
2618
2624
  left.push(...trees.left);
2619
- try {
2620
- 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);
2621
2633
  }
2622
- catch { }
2623
- const rest = removeHomeLeftovers(CONFIG_DIR, keepIdentity, { uninstalling });
2624
- removed.push(...rest.removed);
2625
- left.push(...rest.left);
2626
2634
  }
2627
2635
  if (uninstalling) {
2628
2636
  if (install?.present) {
@@ -2633,9 +2641,11 @@ async function main() {
2633
2641
  if (!runningManaged)
2634
2642
  left.push(`this pantheon itself (${process.argv[1] ?? "the one you ran"}), which npm installed — remove it with: ${NPM_UNINSTALL}`);
2635
2643
  const { sweepPantheonHome } = await import("./machine.js");
2636
- const swept = sweepPantheonHome(CONFIG_DIR, { keepIdentity, spare });
2637
- removed.push(...swept.removed);
2638
- left.push(...swept.left);
2644
+ if (!homeRefusal) {
2645
+ const swept = sweepPantheonHome(CONFIG_DIR, { keepIdentity, spare, refusal: null });
2646
+ removed.push(...swept.removed);
2647
+ left.push(...swept.left);
2648
+ }
2639
2649
  }
2640
2650
  console.error(`\n${B("Removed")}`);
2641
2651
  for (const r of removed)
@@ -3564,6 +3574,25 @@ async function main() {
3564
3574
  st = null;
3565
3575
  raw = null;
3566
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
+ }
3567
3596
  if (raw !== null) {
3568
3597
  let nonce;
3569
3598
  try {
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 {
@@ -0,0 +1,195 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { homedir, platform, userInfo } from "node:os";
4
+ import { basename, dirname, join, posix, sep, win32 } from "node:path";
5
+ export const HOME_MARKER = ".pantheon-home";
6
+ export const PANTHEON_HOME_ENTRIES = [
7
+ { name: HOME_MARKER, what: "the mark that says this folder is Pantheon's home" },
8
+ { name: "config.json", what: "your key and team tokens", identity: true },
9
+ { name: "config.json.bak", what: "the previous config.json", identity: true },
10
+ { name: "keys.json", what: "the teammates' keys this machine pinned", identity: true },
11
+ { name: "nonces.json", what: "the signed requests this machine has seen", identity: true },
12
+ { name: "continued.json", what: "what this machine already handled", identity: true },
13
+ { name: "cursors.json", what: "where each agent session last read", identity: true },
14
+ { name: "worker-cursor.json", what: "where the worker last read", identity: true },
15
+ { name: "profile.json", what: "your profile", identity: true },
16
+ { name: "worker-policy.json", what: "the worker's wake policy", identity: true },
17
+ { name: "spend.json", what: "the spend ledger", identity: true },
18
+ { name: "personas", what: "the worker's personas", dir: true, identity: true },
19
+ { name: "worker-state.json", what: "the worker's last state" },
20
+ { name: "worker-run.json", what: "the worker's record of a run in flight" },
21
+ { name: "run-slot.json", what: "the worker's run slot" },
22
+ { name: "worker.pid", what: "the worker's pid file" },
23
+ { name: /^notifier-state(-.+)?\.json$/, sample: "notifier-state-team%2Fone.json", what: "the alerts' state" },
24
+ { name: "update-check.json", what: "the update check's cache" },
25
+ { name: "uri-key", what: "the key pantheon:// links are signed with" },
26
+ { name: "board.json", what: "the board's state" },
27
+ { name: "hook-intent.json", what: "the hook intent" },
28
+ { name: "hook-guard.json", what: "the hook guard" },
29
+ { name: /^(worker|relay|notifier|menubar)\.log(\.1)?$/, sample: "worker.log", what: "service logs an older version wrote here" },
30
+ { name: /^\.write-check\..+$/, sample: ".write-check.1234.5678", what: "a leftover write probe" },
31
+ { name: "bin", what: "the menu bar's and alerts' files", dir: true },
32
+ { name: "logs", what: "the service logs", dir: true },
33
+ { name: "host", what: "the Windows host's queue, state and markers", dir: true },
34
+ { name: "mcp", what: "the agent windows' server state", dir: true },
35
+ { name: "run-homes", what: "the woken runs' homes", dir: true },
36
+ { name: "worktrees", what: "the worker's worktrees", dir: true },
37
+ { name: "no-hooks", what: "the empty hooks folder woken runs' git uses", dir: true },
38
+ { name: "lfs-hooks", what: "the LFS-only hooks folder woken runs' git uses", dir: true },
39
+ { name: "runtime", what: "Pantheon's own Node", dir: true, kept: "managed" },
40
+ { name: "data", what: "the relay's ledger", dir: true, kept: "it is your team's history and may be its only copy; delete it yourself once no relay runs from it" },
41
+ { name: /^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i, sample: ".DS_Store", what: "the file browser's folder cache" },
42
+ ];
43
+ const LOCK_OR_TMP = /^(.+?)(\.lock|(\.[0-9a-f]+)+\.tmp)$/i;
44
+ export function homeEntryFor(name) {
45
+ const direct = (n) => PANTHEON_HOME_ENTRIES.find((e) => typeof e.name === "string" ? e.name === n : e.name.test(n)) ?? null;
46
+ const e = direct(name);
47
+ if (e)
48
+ return e;
49
+ const m = LOCK_OR_TMP.exec(name);
50
+ const base = m ? direct(m[1]) : null;
51
+ return base && !base.dir ? base : null;
52
+ }
53
+ const realOrNull = (p) => { try {
54
+ return realpathSync.native(p);
55
+ }
56
+ catch {
57
+ return null;
58
+ } };
59
+ function gitTopOf(dir) {
60
+ const env = { ...process.env };
61
+ for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_CEILING_DIRECTORIES"])
62
+ delete env[k];
63
+ try {
64
+ const out = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, env, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true }).trim();
65
+ return out ? realOrNull(out) ?? out : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ export const realHomeCheckIo = () => ({
72
+ real: realOrNull,
73
+ exists: (p) => existsSync(p),
74
+ read: (p) => { try {
75
+ return readFileSync(p, "utf8");
76
+ }
77
+ catch {
78
+ return null;
79
+ } },
80
+ homes: () => {
81
+ const out = [homedir()];
82
+ try {
83
+ const u = userInfo().homedir;
84
+ if (u)
85
+ out.push(u);
86
+ }
87
+ catch { }
88
+ if (process.env.USERPROFILE)
89
+ out.push(process.env.USERPROFILE);
90
+ return out;
91
+ },
92
+ cwd: () => process.cwd(),
93
+ repoTop: gitTopOf,
94
+ os: platform(),
95
+ });
96
+ export function isPantheonHomeName(dir, os = platform()) {
97
+ const b = (os === "win32" ? win32 : posix).basename(dir);
98
+ return os === "win32" ? [".pantheon", "pantheon"].includes(b.toLowerCase()) : b === ".pantheon" || b === "Pantheon";
99
+ }
100
+ function holdsPantheonIdentity(dir, io, pj = join) {
101
+ if (!io.exists(pj(dir, "keys.json")))
102
+ return false;
103
+ const raw = io.read(pj(dir, "config.json"));
104
+ if (raw === null)
105
+ return false;
106
+ try {
107
+ const c = JSON.parse(raw.replace(/^/, ""));
108
+ return !!c && typeof c === "object" && typeof c.member === "string" && typeof c.publicKey === "string" && typeof c.privateKey === "string" && typeof c.relay === "string";
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ export function pantheonHomeRefusal(dir, over = {}) {
115
+ const io = { ...realHomeCheckIo(), ...over };
116
+ const r = io.real(dir);
117
+ if (r === null)
118
+ return null;
119
+ const win = io.os === "win32";
120
+ const P = win ? win32 : posix;
121
+ const norm = (p) => { const s = p.replace(/[\\/]+$/, "") || p; return win ? s.replace(/\//g, "\\").toLowerCase() : s; };
122
+ const S = win ? "\\" : sep;
123
+ const d = norm(r);
124
+ const inside = (child, parent) => child.startsWith(parent.endsWith(S) ? parent : parent + S);
125
+ if (P.dirname(r) === r || /^[a-z]:\\?$/i.test(d) || d === "")
126
+ return `${dir} is the root of a ${win ? "drive" : "filesystem"}`;
127
+ const homes = [...new Set(io.homes().map((h) => io.real(h) ?? h).map(norm))];
128
+ for (const h of homes) {
129
+ if (d === h)
130
+ return `${dir} is your home folder`;
131
+ if (inside(h, d))
132
+ return `${dir} holds your home folder (${h})`;
133
+ }
134
+ const cwd = io.real(io.cwd());
135
+ if (cwd && norm(cwd) === d)
136
+ return `${dir} is the folder this command runs in`;
137
+ if (io.exists(P.join(r, ".git")))
138
+ return `${dir} is a git repository`;
139
+ let walked = null;
140
+ for (let p = P.dirname(r), prev = r; p !== prev; prev = p, p = P.dirname(p)) {
141
+ if (io.exists(P.join(p, ".git"))) {
142
+ walked = p;
143
+ break;
144
+ }
145
+ }
146
+ const top = io.repoTop(r) ?? walked;
147
+ if (top) {
148
+ const t = norm(top);
149
+ const dotfiles = homes.some((h) => t === h && inside(d, h));
150
+ if (t === d)
151
+ return `${dir} is a git repository`;
152
+ if (!dotfiles)
153
+ return `${dir} is inside the git repository ${top}`;
154
+ }
155
+ if (!isPantheonHomeName(r, io.os) && !io.exists(P.join(r, HOME_MARKER)) && !holdsPantheonIdentity(r, io, P.join)) {
156
+ return `${dir} is not a folder Pantheon made: it is not named .pantheon, it has no ${HOME_MARKER} mark, and it holds no Pantheon identity`;
157
+ }
158
+ return null;
159
+ }
160
+ export function pantheonNamedFiles(dir) {
161
+ let names = [];
162
+ try {
163
+ names = readdirSync(dir);
164
+ }
165
+ catch {
166
+ return [];
167
+ }
168
+ return names.sort().filter((n) => homeEntryFor(n)).map((n) => join(dir, n));
169
+ }
170
+ export function refusedHomeLine(dir, why) {
171
+ const files = pantheonNamedFiles(dir).filter((p) => !/^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i.test(basename(p)));
172
+ const cwd = /the folder this command runs in$/.test(why);
173
+ return `nothing in ${dir} was removed: ${why}, so it may hold files of your own under the names Pantheon uses.` +
174
+ (cwd ? " Run the command again from another folder to have Pantheon's files there removed." : "") +
175
+ (files.length ? ` Pantheon's files there, if they are Pantheon's — check each, then delete it yourself: ${files.join(", ")}` : " No file there has a name Pantheon writes.");
176
+ }
177
+ export function markPantheonHome(dir, createdNow, over = {}) {
178
+ try {
179
+ const f = join(dir, HOME_MARKER);
180
+ if (existsSync(f))
181
+ return;
182
+ const io = { ...realHomeCheckIo(), ...over };
183
+ const r = io.real(dir);
184
+ if (r === null || dirname(r) === r)
185
+ return;
186
+ const homes = io.homes().map((h) => io.real(h) ?? h);
187
+ if (homes.some((h) => h === r || h.startsWith(r.endsWith(sep) ? r : r + sep)))
188
+ return;
189
+ if (!createdNow && readdirSync(r).some((n) => !/^(config\.json|keys\.json)(\.tmp[\w.-]*|\.lock)?$/.test(n)))
190
+ return;
191
+ writeFileSync(f, "This folder is Pantheon's home. `pantheon uninstall` removes what Pantheon wrote here, and nothing else.\n", { flag: "wx", mode: 0o600 });
192
+ }
193
+ catch { }
194
+ }
195
+ //# sourceMappingURL=home-safety.js.map
package/dist/machine.js CHANGED
@@ -6,6 +6,7 @@ import { CONFIG_DIR } from "./config.js";
6
6
  import { borrowedHome, disableService, existingUnitPath, plistPath, serviceInstalled, serviceLogFile, stopWindowsHost, unitPath, unitPaths, uninstallService } from "./service.js";
7
7
  import { LEGACY_TASK_NAME, hostAlive, hostChildRunning, hostFiles, isHostKind, readHostState, recordedTaskName } from "./win-host.js";
8
8
  import { copilotMcpConfigFile, vsCodeUserDir, kiroMcpConfigFile, kiroDir } from "./install.js";
9
+ import { HOME_MARKER, homeEntryFor, isPantheonHomeName, pantheonHomeRefusal, refusedHomeLine } from "./home-safety.js";
9
10
  const sh = (cmd) => {
10
11
  try {
11
12
  return execFileSync("sh", ["-lc", cmd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -667,6 +668,9 @@ export function removePantheonWorktrees(repos, roots, git = (repo, args) => exec
667
668
  const MANAGED_LAUNCHER = "pantheon";
668
669
  export function removeHomeLeftovers(home, keep, opts = {}) {
669
670
  const removed = [], left = [];
671
+ const refusal = opts.refusal !== undefined ? opts.refusal : pantheonHomeRefusal(home);
672
+ if (refusal)
673
+ return { removed, left: [refusedHomeLine(home, refusal)] };
670
674
  const items = [
671
675
  ["worker-policy.json", "the worker's wake policy (who may wake it, and self-upgrade) — a rejoin starts from no grants"],
672
676
  ["profile.json", "your profile (name, title, what you decide)"],
@@ -705,52 +709,7 @@ export function removeHomeLeftovers(home, keep, opts = {}) {
705
709
  }
706
710
  return { removed, left };
707
711
  }
708
- export const PANTHEON_HOME_ENTRIES = [
709
- { name: "config.json", what: "your key and team tokens", identity: true },
710
- { name: "config.json.bak", what: "the previous config.json", identity: true },
711
- { name: "keys.json", what: "the teammates' keys this machine pinned", identity: true },
712
- { name: "nonces.json", what: "the signed requests this machine has seen", identity: true },
713
- { name: "continued.json", what: "what this machine already handled", identity: true },
714
- { name: "cursors.json", what: "where each agent session last read", identity: true },
715
- { name: "worker-cursor.json", what: "where the worker last read", identity: true },
716
- { name: "profile.json", what: "your profile", identity: true },
717
- { name: "worker-policy.json", what: "the worker's wake policy", identity: true },
718
- { name: "spend.json", what: "the spend ledger", identity: true },
719
- { name: "personas", what: "the worker's personas", dir: true, identity: true },
720
- { name: "worker-state.json", what: "the worker's last state" },
721
- { name: "worker-run.json", what: "the worker's record of a run in flight" },
722
- { name: "run-slot.json", what: "the worker's run slot" },
723
- { name: "worker.pid", what: "the worker's pid file" },
724
- { name: /^notifier-state(-.+)?\.json$/, sample: "notifier-state-team%2Fone.json", what: "the alerts' state" },
725
- { name: "update-check.json", what: "the update check's cache" },
726
- { name: "uri-key", what: "the key pantheon:// links are signed with" },
727
- { name: "board.json", what: "the board's state" },
728
- { name: "hook-intent.json", what: "the hook intent" },
729
- { name: "hook-guard.json", what: "the hook guard" },
730
- { name: /^(worker|relay|notifier|menubar)\.log(\.1)?$/, sample: "worker.log", what: "service logs an older version wrote here" },
731
- { name: /^\.write-check\..+$/, sample: ".write-check.1234.5678", what: "a leftover write probe" },
732
- { name: "bin", what: "the menu bar's and alerts' files", dir: true },
733
- { name: "logs", what: "the service logs", dir: true },
734
- { name: "host", what: "the Windows host's queue, state and markers", dir: true },
735
- { name: "mcp", what: "the agent windows' server state", dir: true },
736
- { name: "run-homes", what: "the woken runs' homes", dir: true },
737
- { name: "worktrees", what: "the worker's worktrees", dir: true },
738
- { name: "no-hooks", what: "the empty hooks folder woken runs' git uses", dir: true },
739
- { name: "lfs-hooks", what: "the LFS-only hooks folder woken runs' git uses", dir: true },
740
- { name: "runtime", what: "Pantheon's own Node", dir: true, kept: "managed" },
741
- { name: "data", what: "the relay's ledger", dir: true, kept: "it is your team's history and may be its only copy; delete it yourself once no relay runs from it" },
742
- { name: /^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i, sample: ".DS_Store", what: "the file browser's folder cache" },
743
- ];
744
- const LOCK_OR_TMP = /^(.+?)(\.lock|(\.[0-9a-f]+)+\.tmp)$/i;
745
- export function homeEntryFor(name) {
746
- const direct = (n) => PANTHEON_HOME_ENTRIES.find((e) => typeof e.name === "string" ? e.name === n : e.name.test(n)) ?? null;
747
- const e = direct(name);
748
- if (e)
749
- return e;
750
- const m = LOCK_OR_TMP.exec(name);
751
- const base = m ? direct(m[1]) : null;
752
- return base && !base.dir ? base : null;
753
- }
712
+ export { PANTHEON_HOME_ENTRIES, homeEntryFor } from "./home-safety.js";
754
713
  const pause = (ms) => { try {
755
714
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
756
715
  }
@@ -768,6 +727,9 @@ export function sweepPantheonHome(home, o = {}) {
768
727
  const removed = [], left = [];
769
728
  if (!fs.exists(home))
770
729
  return { removed, left, gone: true };
730
+ const refusal = o.refusal !== undefined ? o.refusal : o.fs ? null : pantheonHomeRefusal(home);
731
+ if (refusal)
732
+ return { removed, left: [refusedHomeLine(home, refusal)], gone: false };
771
733
  const spare = new Set((o.spare ?? []).map((p) => resolve(p)));
772
734
  const attempt = (p, rm) => {
773
735
  let why = "";
@@ -801,6 +763,8 @@ export function sweepPantheonHome(home, o = {}) {
801
763
  foreign.push(name);
802
764
  continue;
803
765
  }
766
+ if (name === HOME_MARKER)
767
+ continue;
804
768
  if (spare.has(resolve(p)))
805
769
  continue;
806
770
  if (e.kept === "managed")
@@ -828,6 +792,8 @@ export function sweepPantheonHome(home, o = {}) {
828
792
  rest = fs.list(home);
829
793
  }
830
794
  catch { }
795
+ if (rest.includes(HOME_MARKER) && (rest.length === 1 || isPantheonHomeName(home, os)) && attempt(join(home, HOME_MARKER), fs.remove) === null)
796
+ rest = rest.filter((n) => n !== HOME_MARKER);
831
797
  if (!rest.length) {
832
798
  const why = attempt(home, fs.removeDir);
833
799
  if (why === null) {
@@ -885,20 +851,22 @@ export function uninstallMachine(opts, io) {
885
851
  if (!opts.stopRelay && (io.installed("relay") || io.proc("relay").running)) {
886
852
  left.push("the relay — others may be using it. Stop and remove it too: pantheon leave --stop-relay");
887
853
  }
888
- if (io.hasIntent()) {
854
+ if (!io.homeRefusal && io.hasIntent()) {
889
855
  io.clearIntent();
890
856
  removed.push("the hook intent (hook-intent.json) — nothing will put the hooks back");
891
857
  }
892
858
  const mcp = io.removeMcpEntries();
893
859
  removed.push(...mcp.removed);
894
860
  left.push(...mcp.left);
895
- try {
896
- if (existsSync(io.logDir)) {
897
- rmSync(io.logDir, { recursive: true, force: true });
898
- removed.push(`service logs (${io.logDir})`);
861
+ if (!io.homeRefusal) {
862
+ try {
863
+ if (existsSync(io.logDir)) {
864
+ rmSync(io.logDir, { recursive: true, force: true });
865
+ removed.push(`service logs (${io.logDir})`);
866
+ }
899
867
  }
868
+ catch { }
900
869
  }
901
- catch { }
902
870
  const repoDir = join(opts.project, ".pantheon");
903
871
  let repoLogs = 0;
904
872
  for (const k of LOG_KINDS) {
@@ -1052,6 +1020,7 @@ export function realUninstallIo(deps) {
1052
1020
  hasIntent: deps.hasIntent,
1053
1021
  clearIntent: deps.clearIntent,
1054
1022
  logDir: join(CONFIG_DIR, "logs"),
1023
+ homeRefusal: deps.homeRefusal !== undefined ? deps.homeRefusal : pantheonHomeRefusal(CONFIG_DIR),
1055
1024
  ownWorkerPid: () => ownWorkerPid(),
1056
1025
  borrowed: () => borrowedHome(),
1057
1026
  kill: (pid) => killPid(pid) === null,
package/dist/service.js CHANGED
@@ -7,7 +7,7 @@ import { linuxGraphicalSession } from "./host-env.js";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { isVersionManagedPath, launcher } from "./install.js";
9
9
  import { childNodeFlags, managedLauncher, managedLayout } from "./managed.js";
10
- import { CONFIG_DIR } from "./config.js";
10
+ import { CONFIG_DIR, ensurePantheonHome } from "./config.js";
11
11
  import { HOST_KINDS, LEGACY_TASK_NAME, clearTrayQuit, ensureHostShim, fromUtf16le, hostAlive, hostArgv, hostChildRunning, hostFiles, hostLaunch, isHostKind, launchFileText, legacyTaskIsUsers, readDefinition, readHostState, taskName, taskUserId, taskXml, utf16le, windowsPowerShell, writeDefinition, } from "./win-host.js";
12
12
  const HERE = dirname(fileURLToPath(import.meta.url));
13
13
  const LABEL = (kind) => `dev.pantheon.${kind}`;
@@ -272,6 +272,10 @@ export function installService(kind, cwd, args, sys = realSys()) {
272
272
  const noServices = servicesUnavailable(sys.platform);
273
273
  if (noServices)
274
274
  throw new Error(noServices.replace(/^not available — /, ""));
275
+ try {
276
+ ensurePantheonHome();
277
+ }
278
+ catch { }
275
279
  if (sys.platform === "win32")
276
280
  return installWindows(kind, resolve(cwd), args, sys);
277
281
  const out = [];
package/dist/win-host.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
2
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
3
3
  import { createHash } from "node:crypto";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join, resolve, win32 } from "node:path";
@@ -649,7 +649,8 @@ export function takeHostLock(home = CONFIG_DIR, pid = process.pid, now = Date.no
649
649
  if (e.code !== "EEXIST")
650
650
  throw e;
651
651
  }
652
- const held = Number(readFileSafe(f.lock));
652
+ const judged = readFileSafe(f.lock);
653
+ const held = Number(judged);
653
654
  let age = 0;
654
655
  try {
655
656
  age = now() - statSync(f.lock).mtimeMs;
@@ -674,10 +675,37 @@ export function takeHostLock(home = CONFIG_DIR, pid = process.pid, now = Date.no
674
675
  catch {
675
676
  continue;
676
677
  }
678
+ const moved = readFileSafe(gone);
679
+ if (moved !== judged) {
680
+ putBack(gone, f.lock);
681
+ const other = Number(moved);
682
+ return Number.isInteger(other) && other > 0 ? other : -1;
683
+ }
677
684
  rmSync(gone, { force: true });
678
685
  }
679
686
  return lastHolder;
680
687
  }
688
+ function putBack(moved, lock) {
689
+ try {
690
+ linkSync(moved, lock);
691
+ rmSync(moved, { force: true });
692
+ return;
693
+ }
694
+ catch (e) {
695
+ if (e.code === "EEXIST") {
696
+ rmSync(moved, { force: true });
697
+ return;
698
+ }
699
+ }
700
+ if (existsSync(lock)) {
701
+ rmSync(moved, { force: true });
702
+ return;
703
+ }
704
+ try {
705
+ renameSync(moved, lock);
706
+ }
707
+ catch { }
708
+ }
681
709
  const readFileSafe = (p) => { try {
682
710
  return readFileSync(p, "utf8").trim();
683
711
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "built_at": "2026-10-05T04:55:37.913Z",
3
+ "built_at": "2026-10-05T08:38:15.336Z",
4
4
  "swiftc": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)",
5
5
  "helpers": {
6
6
  "pantheon-tray": {
@@ -17,7 +17,7 @@
17
17
  "pantheon-hud": {
18
18
  "source": "main.swift",
19
19
  "source_sha256": "bcf55a19b658d1f09a9d117c76030393ab08a2569062dd7413f884ca05f3a0eb",
20
- "sha256": "0414b397a533940e3a5c324add6250e1ed71919e67e462d11dcb2a1d0c2f9b41",
20
+ "sha256": "191f3f18e8212e11ad4438bdbef87a5cc533f5ae2fae208801db01c9a265193b",
21
21
  "archs": [
22
22
  "arm64",
23
23
  "x86_64"
Binary file
@@ -30,37 +30,65 @@
30
30
  #>
31
31
  param([string]$Mode = '')
32
32
 
33
+ # Whether toasts are switched off for an app id: only an explicit Disabled*
34
+ # setting is off. Windows THROWS "Element not found" (0x80070490) for an app id
35
+ # that has never shown a toast for this user, and a plain property read turned
36
+ # that into $null -- which was taken for "off" for every id, so a fresh PC only
37
+ # ever got balloons. Unreadable is unknown, and unknown is tried.
38
+ function global:Get-PantheonToastSetting($Notifier) {
39
+ $s = $null
40
+ try { $s = $Notifier.get_Setting() } catch { try { $s = $Notifier.Setting } catch { $s = $null } }
41
+ if ($null -eq $s) { return 'unknown' }
42
+ return [string]$s
43
+ }
44
+
33
45
  # One toast: the XML, tried under each app id in turn. Returns @{ code; said }.
34
- function global:Show-PantheonToast([string]$Xml, [string]$Tag, [string[]]$Aumids) {
46
+ # $Api stands in for the WinRT calls (the checks run it on Linux); left out,
47
+ # the real ToastNotificationManager is used.
48
+ function global:Show-PantheonToast([string]$Xml, [string]$Tag, [string[]]$Aumids, [hashtable]$Api = $null) {
35
49
  $said = New-Object System.Collections.Generic.List[string]
36
- try {
37
- [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
38
- [void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
39
- $doc = [Windows.Data.Xml.Dom.XmlDocument]::new()
40
- $doc.LoadXml($Xml)
41
- } catch {
42
- $said.Add('no-toast-api ' + $_.Exception.Message)
43
- return @{ code = 3; said = $said }
50
+ $doc = $null
51
+ if (-not $Api) {
52
+ try {
53
+ [void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
54
+ [void][Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
55
+ $doc = [Windows.Data.Xml.Dom.XmlDocument]::new()
56
+ $doc.LoadXml($Xml)
57
+ } catch {
58
+ $said.Add('no-toast-api ' + $_.Exception.Message)
59
+ return @{ code = 3; said = $said }
60
+ }
61
+ $Api = @{
62
+ notifier = { param($id) [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id) }
63
+ toast = { param($d) [Windows.UI.Notifications.ToastNotification]::new($d) }
64
+ history = { param($id) [Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory($id) }
65
+ remove = { param($t, $id) [Windows.UI.Notifications.ToastNotificationManager]::History.Remove($t, 'pantheon', $id) }
66
+ wait = { Start-Sleep -Milliseconds 400 }
67
+ }
44
68
  }
45
69
  $ids = @($Aumids | Where-Object { $_ })
46
70
  for ($i = 0; $i -lt $ids.Count; $i++) {
47
71
  $id = $ids[$i]
48
72
  $last = ($i -eq $ids.Count - 1)
49
73
  try {
50
- $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($id)
51
- if ([string]$notifier.Setting -ne 'Enabled') { $said.Add('off ' + $id + ' ' + $notifier.Setting); continue }
52
- $toast = [Windows.UI.Notifications.ToastNotification]::new($doc)
74
+ $notifier = & $Api.notifier $id
75
+ $setting = Get-PantheonToastSetting $notifier
76
+ if ($setting -like 'Disabled*') { $said.Add('off ' + $id + ' ' + $setting); continue }
77
+ $toast = & $Api.toast $doc
53
78
  if ($Tag) { $toast.Tag = $Tag; $toast.Group = 'pantheon' }
54
79
  $notifier.Show($toast)
55
80
  # Pantheon's own app id is used only when Windows then has the toast in
56
81
  # its history for that id: a build that ignores the registration falls
57
82
  # through to the next id instead of swallowing the alert.
58
- if (-not $last -and $Tag) {
59
- Start-Sleep -Milliseconds 400
83
+ # The last id too, when Windows would not say its setting: an unknown
84
+ # setting that Windows then drops silently must still end in a balloon.
85
+ $knownOn = ($setting -eq 'Enabled')
86
+ if ($Tag -and (-not $last -or -not $knownOn)) {
87
+ & $Api.wait
60
88
  $seen = $false
61
- foreach ($h in [Windows.UI.Notifications.ToastNotificationManager]::History.GetHistory($id)) { if ($h.Tag -eq $Tag) { $seen = $true } }
89
+ foreach ($h in @(& $Api.history $id)) { if ($h.Tag -eq $Tag) { $seen = $true } }
62
90
  if (-not $seen) {
63
- try { [Windows.UI.Notifications.ToastNotificationManager]::History.Remove($Tag, 'pantheon', $id) } catch { }
91
+ try { & $Api.remove $Tag $id } catch { }
64
92
  $said.Add('unseen ' + $id)
65
93
  continue
66
94
  }
@@ -186,8 +186,11 @@ function Install-Pantheon {
186
186
  # Is a task this user's? Its XML (schtasks /Query /TN <name> /XML) names its
187
187
  # principal as DOMAIN\user, a bare user name, or a SID; every one of them must
188
188
  # be this user (src/win-host.ts taskIsUsers). A task of the same name that
189
- # another person on this PC registered is never ended.
190
- function Test-PantheonTaskIsMine([string]$Xml, [string]$UserId, [string]$Sid) {
189
+ # another person on this PC registered is never ended. A SID is compared as a
190
+ # SID; a name is first turned into its account's SID ($ToSid) and compared
191
+ # by that, and only by name when Windows cannot say which account it is.
192
+ function Test-PantheonTaskIsMine([string]$Xml, [string]$UserId, [string]$Sid,
193
+ [scriptblock]$ToSid = { param($n) if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return '' }; try { ([Security.Principal.NTAccount]$n).Translate([Security.Principal.SecurityIdentifier]).Value } catch { '' } }) {
191
194
  if (-not $Xml) { return $false }
192
195
  $ids = @([regex]::Matches(($Xml -replace "`0", ''), '<UserId>([^<]*)</UserId>') | ForEach-Object { $_.Groups[1].Value.Trim().ToLowerInvariant() })
193
196
  if ($ids.Count -eq 0) { return $false }
@@ -195,14 +198,57 @@ function Install-Pantheon {
195
198
  $s = ([string]$Sid).ToLowerInvariant()
196
199
  $bare = if ($u.Contains('\')) { $u.Substring($u.LastIndexOf('\') + 1) } else { $u }
197
200
  foreach ($id in $ids) {
201
+ if ($id.StartsWith('s-1-')) { if ($s -and $id -eq $s) { continue }; return $false }
202
+ $idSid = ''
203
+ if ($s) { try { $idSid = ([string](& $ToSid $id)).ToLowerInvariant() } catch { $idSid = '' } }
204
+ if ($idSid) { if ($idSid -eq $s) { continue }; return $false }
198
205
  if ($u -and $id -eq $u) { continue }
199
- if ($s -and $id -eq $s) { continue }
200
- if ($bare -and -not $id.Contains('\') -and -not $id.StartsWith('s-1-') -and $id -eq $bare) { continue }
206
+ if ($bare -and -not $id.Contains('\') -and $id -eq $bare) { continue }
201
207
  return $false
202
208
  }
203
209
  return $true
204
210
  }
205
211
 
212
+ # What schtasks printed, as text. It writes a task's XML as UTF-16 (with or
213
+ # without a byte order mark); read in the console's code page, a user name
214
+ # with a letter outside ASCII came out as other letters and never matched.
215
+ # So the bytes are decoded by what they hold.
216
+ function ConvertFrom-TaskXmlBytes([byte[]]$Bytes) {
217
+ if (-not $Bytes -or $Bytes.Length -eq 0) { return '' }
218
+ if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFF -and $Bytes[1] -eq 0xFE) { return [Text.Encoding]::Unicode.GetString($Bytes, 2, $Bytes.Length - 2) }
219
+ if ($Bytes.Length -ge 2 -and $Bytes[0] -eq 0xFE -and $Bytes[1] -eq 0xFF) { return [Text.Encoding]::BigEndianUnicode.GetString($Bytes, 2, $Bytes.Length - 2) }
220
+ if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { return [Text.Encoding]::UTF8.GetString($Bytes, 3, $Bytes.Length - 3) }
221
+ # No mark: UTF-16LE when most odd bytes of the start are zero ('<', 0, '?', 0, ...).
222
+ $n = [Math]::Min($Bytes.Length, 64); $odd = 0; $zeros = 0
223
+ for ($i = 1; $i -lt $n; $i += 2) { $odd++; if ($Bytes[$i] -eq 0) { $zeros++ } }
224
+ if ($odd -gt 0 -and $zeros * 4 -ge $odd * 3) { return [Text.Encoding]::Unicode.GetString($Bytes) }
225
+ try { return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($Bytes) } catch { }
226
+ return [Console]::OutputEncoding.GetString($Bytes)
227
+ }
228
+
229
+ # A task's XML (schtasks /Query /TN <name> /XML), its bytes read as they are
230
+ # (ConvertFrom-TaskXmlBytes); '' when there is no such task.
231
+ function Get-PantheonTaskXml([string]$Schtasks, [string]$Name) {
232
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
233
+ $psi.FileName = $Schtasks
234
+ $psi.Arguments = ConvertTo-ArgLine @('/Query', '/TN', $Name, '/XML')
235
+ $psi.UseShellExecute = $false
236
+ $psi.CreateNoWindow = $true
237
+ $psi.RedirectStandardInput = $true
238
+ $psi.RedirectStandardOutput = $true
239
+ $psi.RedirectStandardError = $true
240
+ $p = [System.Diagnostics.Process]::Start($psi)
241
+ $p.StandardInput.Close()
242
+ $buf = New-Object System.IO.MemoryStream
243
+ $copy = $p.StandardOutput.BaseStream.CopyToAsync($buf)
244
+ $err = $p.StandardError.ReadToEndAsync()
245
+ $p.WaitForExit()
246
+ $copy.Wait()
247
+ $null = $err.Result
248
+ if ($p.ExitCode -ne 0) { return '' }
249
+ return ConvertFrom-TaskXmlBytes $buf.ToArray()
250
+ }
251
+
206
252
  # Before the runtime folder is swapped: Pantheon's background host (the
207
253
  # logon task) runs node.exe from it, and Windows will not rename a folder a
208
254
  # running program came from. A teammate's request the worker is running
@@ -268,6 +314,68 @@ function Install-Pantheon {
268
314
  return @{ code = $p.ExitCode; out = $out.Result; err = $err.Result }
269
315
  }
270
316
 
317
+ # Hand over to pantheon with the person's own arguments (join <invite>,
318
+ # start): after an install, and also when the update was put off because a
319
+ # teammate's run was in progress -- a join or a start needs a working
320
+ # pantheon, not the update. Sets $State.code to pantheon's exit code.
321
+ function Invoke-PantheonHandoff([string]$nodeExe, [string]$entry, [string[]]$ca, [string[]]$Arguments) {
322
+ if ($null -eq $ca) { $ca = @() }
323
+ # node.exe directly, not the .cmd: PowerShell hands arguments to a .cmd
324
+ # through cmd.exe, which would cut an invite at a & or expand a %. And
325
+ # Start-Process -NoNewWindow, so pantheon gets this console itself: a real
326
+ # terminal for the sign-in questions, not a pipe PowerShell reads from.
327
+ $State.step = 'starting pantheon'
328
+ # With the same --use-system-ca as npm and pantheon.cmd: this first run is the
329
+ # one that signs in, and behind a TLS-inspecting proxy the relay's
330
+ # certificate chains only to the company root in the Windows store.
331
+ $line = ConvertTo-ArgLine (@($ca) + @($entry) + $Arguments)
332
+ $noConsole = $false
333
+ try { $noConsole = [Console]::IsInputRedirected } catch { $noConsole = $false }
334
+ if (-not $noConsole) {
335
+ $p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru
336
+ $null = $p.Handle # without this, ExitCode is empty once the process is gone
337
+ $p.WaitForExit()
338
+ $State.code = $p.ExitCode
339
+ return
340
+ }
341
+
342
+ # No console to type into (CI, input piped in). pantheon still runs: it
343
+ # never asks a question without a terminal (every prompt checks
344
+ # process.stdin.isTTY) and works on flags alone, so a join to a team's own
345
+ # relay, or from a machine already signed in, needs no answers. Its input
346
+ # is an empty file, so nothing can wait on it. What it cannot do here is
347
+ # sign in: on the hosted relay join and start stop at once, before anything
348
+ # is changed, with "sign in first" (or the relay's NOT_SIGNED_IN). When that
349
+ # is how it ended and config.json still has no account, the last line says
350
+ # the one thing to do; any other failure keeps pantheon's own words last.
351
+ $quiet = Join-Path ([IO.Path]::GetTempPath()) ('pantheon-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
352
+ New-Item -ItemType Directory -Force -Path $quiet | Out-Null
353
+ try {
354
+ $in = Join-Path $quiet 'in'; $errFile = Join-Path $quiet 'err'
355
+ [IO.File]::WriteAllText($in, '')
356
+ $p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru -RedirectStandardInput $in -RedirectStandardError $errFile
357
+ $null = $p.Handle
358
+ $p.WaitForExit()
359
+ $State.code = $p.ExitCode
360
+ $said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
361
+ if ($said) { [Console]::Error.Write($said) }
362
+ if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
363
+ $pantheonHome = if ($env:PANTHEON_HOME) { $env:PANTHEON_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
364
+ $signedIn = $false
365
+ try { $signedIn = [bool]((Get-Content -LiteralPath (Join-Path $pantheonHome 'config.json') -Raw | ConvertFrom-Json).account) } catch { $signedIn = $false }
366
+ if (-not $signedIn) {
367
+ # Quoted only where PowerShell needs it, so it reads as typed.
368
+ $show = ($Arguments | ForEach-Object { if ($_ -match '^[A-Za-z0-9_./:=@+,-]+$') { $_ } else { "'" + ($_ -replace "'", "''") + "'" } }) -join ' '
369
+ $what = if ($Arguments[0] -eq 'join') { 'joining' } else { 'creating your team' }
370
+ Say ''
371
+ Say "Pantheon is installed. To finish $what, open PowerShell and run: pantheon $show"
372
+ }
373
+ }
374
+ } finally {
375
+ Remove-Item -LiteralPath $quiet -Recurse -Force -ErrorAction SilentlyContinue
376
+ }
377
+ }
378
+
271
379
  try {
272
380
  if ($PantheonVersion -like '*__*') {
273
381
  Fail "this copy of install.ps1 has no version filled in. Use the one the relay serves: irm $Site/install.ps1 | iex"
@@ -457,7 +565,7 @@ function Install-Pantheon {
457
565
  $stopped = Stop-PantheonHost -NodeDir $nodeDir -HostDir $hostDir -Tasks $tasks `
458
566
  -Holders { param($d) Get-FolderHolders $d } -EndTask { param($t) $null = Invoke-Quiet $schtasks @('/End', '/TN', $t) } `
459
567
  -RunRecord (Join-Path $pantheonHomeDir 'worker-run.json') `
460
- -TaskIsMine { param($t) Test-PantheonTaskIsMine (Invoke-Quiet $schtasks @('/Query', '/TN', $t, '/XML')).out $meUser $meSid }
568
+ -TaskIsMine { param($t) Test-PantheonTaskIsMine (Get-PantheonTaskXml $schtasks $t) $meUser $meSid }
461
569
  # A teammate's run still going after the wait: nothing was stopped,
462
570
  # and the runtime is left exactly as it is (see the catch below).
463
571
  if ($stopped.busy) { throw 'PANTHEON_BUSY:' }
@@ -617,60 +725,7 @@ function Install-Pantheon {
617
725
  return
618
726
  }
619
727
  Say ''
620
- # node.exe directly, not the .cmd: PowerShell hands arguments to a .cmd
621
- # through cmd.exe, which would cut an invite at a & or expand a %. And
622
- # Start-Process -NoNewWindow, so pantheon gets this console itself: a real
623
- # terminal for the sign-in questions, not a pipe PowerShell reads from.
624
- $State.step = 'starting pantheon'
625
- # With the same --use-system-ca as npm and pantheon.cmd: this first run is the
626
- # one that signs in, and behind a TLS-inspecting proxy the relay's
627
- # certificate chains only to the company root in the Windows store.
628
- $line = ConvertTo-ArgLine (@($ca) + @($entry) + $Arguments)
629
- $noConsole = $false
630
- try { $noConsole = [Console]::IsInputRedirected } catch { $noConsole = $false }
631
- if (-not $noConsole) {
632
- $p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru
633
- $null = $p.Handle # without this, ExitCode is empty once the process is gone
634
- $p.WaitForExit()
635
- $State.code = $p.ExitCode
636
- return
637
- }
638
-
639
- # No console to type into (CI, input piped in). pantheon still runs: it
640
- # never asks a question without a terminal (every prompt checks
641
- # process.stdin.isTTY) and works on flags alone, so a join to a team's own
642
- # relay, or from a machine already signed in, needs no answers. Its input
643
- # is an empty file, so nothing can wait on it. What it cannot do here is
644
- # sign in: on the hosted relay join and start stop at once, before anything
645
- # is changed, with "sign in first" (or the relay's NOT_SIGNED_IN). When that
646
- # is how it ended and config.json still has no account, the last line says
647
- # the one thing to do; any other failure keeps pantheon's own words last.
648
- $quiet = Join-Path ([IO.Path]::GetTempPath()) ('pantheon-' + [Guid]::NewGuid().ToString('N').Substring(0, 8))
649
- New-Item -ItemType Directory -Force -Path $quiet | Out-Null
650
- try {
651
- $in = Join-Path $quiet 'in'; $errFile = Join-Path $quiet 'err'
652
- [IO.File]::WriteAllText($in, '')
653
- $p = Start-Process -FilePath $nodeExe -ArgumentList $line -NoNewWindow -PassThru -RedirectStandardInput $in -RedirectStandardError $errFile
654
- $null = $p.Handle
655
- $p.WaitForExit()
656
- $State.code = $p.ExitCode
657
- $said = if (Test-Path -LiteralPath $errFile) { [IO.File]::ReadAllText($errFile) } else { '' }
658
- if ($said) { [Console]::Error.Write($said) }
659
- if ($State.code -ne 0 -and $said -match 'sign in first|NOT_SIGNED_IN' -and $Arguments[0] -in @('join', 'start')) {
660
- $pantheonHome = if ($env:PANTHEON_HOME) { $env:PANTHEON_HOME } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.pantheon' }
661
- $signedIn = $false
662
- try { $signedIn = [bool]((Get-Content -LiteralPath (Join-Path $pantheonHome 'config.json') -Raw | ConvertFrom-Json).account) } catch { $signedIn = $false }
663
- if (-not $signedIn) {
664
- # Quoted only where PowerShell needs it, so it reads as typed.
665
- $show = ($Arguments | ForEach-Object { if ($_ -match '^[A-Za-z0-9_./:=@+,-]+$') { $_ } else { "'" + ($_ -replace "'", "''") + "'" } }) -join ' '
666
- $what = if ($Arguments[0] -eq 'join') { 'joining' } else { 'creating your team' }
667
- Say ''
668
- Say "Pantheon is installed. To finish $what, open PowerShell and run: pantheon $show"
669
- }
670
- }
671
- } finally {
672
- Remove-Item -LiteralPath $quiet -Recurse -Force -ErrorAction SilentlyContinue
673
- }
728
+ Invoke-PantheonHandoff $nodeExe $entry $ca $Arguments
674
729
  } catch {
675
730
  $m = $_.Exception.Message
676
731
  if ($m -like 'PANTHEON_BUSY:*') {
@@ -678,6 +733,22 @@ function Install-Pantheon {
678
733
  # is none that runs is the exit code non-zero.
679
734
  $have = ''
680
735
  if ($installedCli) { try { $have = & $installedCli } catch { $have = '' } }
736
+ if ($have -and $Arguments.Count -gt 0) {
737
+ # The person's join or start still happens, on the Pantheon that is
738
+ # here: it does not touch the runtime folder the run is using.
739
+ Write-Host "Pantheon was not updated to $PantheonVersion because a teammate's run was in progress; Pantheon $have is still installed and working, so your command runs on it now. Run the same line again in a few minutes to update." -ForegroundColor Yellow
740
+ if ($State.lock) { $State.lock.Dispose(); $State.lock = $null }
741
+ $caNow = @()
742
+ try { if ((Invoke-Quiet $nodeExe @('--use-system-ca', '-e', '0')).code -eq 0) { $caNow = @('--use-system-ca') } } catch { $caNow = @() }
743
+ try {
744
+ Say ''
745
+ Invoke-PantheonHandoff $nodeExe $entry $caNow $Arguments
746
+ } catch {
747
+ Write-Host "pantheon install failed: it stopped while starting pantheon ($($_.Exception.Message)). Open a new terminal and run your pantheon command again." -ForegroundColor Red
748
+ $State.code = 1
749
+ }
750
+ return
751
+ }
681
752
  $kept = if ($have) { " Pantheon $have is still installed and working." } else { '' }
682
753
  Write-Host "Pantheon was not updated because a teammate's run was in progress; run the same line again in a few minutes.$kept" -ForegroundColor Yellow
683
754
  $State.code = if ($have) { 0 } else { 1 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.33.0-rc.2",
3
+ "version": "0.33.0",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {