@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,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,6 +709,104 @@ export function removeHomeLeftovers(home, keep, opts = {}) {
705
709
  }
706
710
  return { removed, left };
707
711
  }
712
+ export { PANTHEON_HOME_ENTRIES, homeEntryFor } from "./home-safety.js";
713
+ const pause = (ms) => { try {
714
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
715
+ }
716
+ catch { } };
717
+ const realSweepFs = {
718
+ list: (d) => readdirSync(d),
719
+ exists: (p) => existsSync(p),
720
+ remove: (p) => rmSync(p, { recursive: true, force: true }),
721
+ removeDir: (d) => rmdirSync(d),
722
+ };
723
+ export function sweepPantheonHome(home, o = {}) {
724
+ const fs = o.fs ?? realSweepFs;
725
+ const tries = o.tries ?? 5, waitMs = o.waitMs ?? 200;
726
+ const os = o.os ?? platform();
727
+ const removed = [], left = [];
728
+ if (!fs.exists(home))
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 };
733
+ const spare = new Set((o.spare ?? []).map((p) => resolve(p)));
734
+ const attempt = (p, rm) => {
735
+ let why = "";
736
+ for (let i = 0; i < tries; i++) {
737
+ try {
738
+ rm(p);
739
+ }
740
+ catch (e) {
741
+ why = String(e.code ?? e.message.split("\n")[0]);
742
+ }
743
+ if (!fs.exists(p))
744
+ return null;
745
+ if (i < tries - 1)
746
+ pause(waitMs * (i + 1));
747
+ }
748
+ return why || "still there";
749
+ };
750
+ const lockedHint = os === "win32" ? "; a Pantheon process (an agent window, the menu bar) may still have it open — close it, then delete it" : "; delete it yourself";
751
+ const gone = [], foreign = [], keptForIdentity = [];
752
+ let names = [];
753
+ try {
754
+ names = fs.list(home);
755
+ }
756
+ catch (e) {
757
+ return { removed, left: [`${home} — could not be read (${e.message.split("\n")[0]}), so nothing in it was removed`], gone: false };
758
+ }
759
+ for (const name of names.sort()) {
760
+ const p = join(home, name);
761
+ const e = homeEntryFor(name);
762
+ if (!e) {
763
+ foreign.push(name);
764
+ continue;
765
+ }
766
+ if (name === HOME_MARKER)
767
+ continue;
768
+ if (spare.has(resolve(p)))
769
+ continue;
770
+ if (e.kept === "managed")
771
+ continue;
772
+ if (e.kept) {
773
+ left.push(`${e.what}, ${p} — kept: ${e.kept}`);
774
+ continue;
775
+ }
776
+ if (o.keepIdentity && e.identity) {
777
+ keptForIdentity.push(name);
778
+ continue;
779
+ }
780
+ const why = attempt(p, fs.remove);
781
+ if (why === null)
782
+ gone.push(name);
783
+ else
784
+ left.push(`${e.what}, ${p} — could not remove (${why})${lockedHint}`);
785
+ }
786
+ if (gone.length)
787
+ removed.push(`Pantheon's own files in ${home}: ${gone.join(", ")}`);
788
+ if (keptForIdentity.length)
789
+ left.push(`what goes with your identity in ${home}: ${keptForIdentity.join(", ")} (--keep-identity)`);
790
+ let rest = [];
791
+ try {
792
+ rest = fs.list(home);
793
+ }
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);
797
+ if (!rest.length) {
798
+ const why = attempt(home, fs.removeDir);
799
+ if (why === null) {
800
+ removed.push(`${home} itself, now empty`);
801
+ return { removed, left, gone: true };
802
+ }
803
+ left.push(`${home} — empty, but could not remove it (${why})${lockedHint}`);
804
+ return { removed, left, gone: false };
805
+ }
806
+ if (foreign.length)
807
+ left.push(`${home}, for what is not Pantheon's: ${foreign.join(", ")} — Pantheon never writes ${foreign.length === 1 ? "it" : "these"}, so ${foreign.length === 1 ? "it was" : "they were"} not touched`);
808
+ return { removed, left, gone: false };
809
+ }
708
810
  export const accountStillExists = (email) => `your account${email ? ` ${email}` : ""} still exists — this machine is signed out. Sign back in with: pantheon login${email ? ` --email ${email}` : ""} (pantheon signup would fail: the email is taken). To delete the account itself: pantheon account delete, before leaving`;
709
811
  const LOG_KINDS = ["worker", "relay", "notifier", "menubar"];
710
812
  export function uninstallMachine(opts, io) {
@@ -749,20 +851,22 @@ export function uninstallMachine(opts, io) {
749
851
  if (!opts.stopRelay && (io.installed("relay") || io.proc("relay").running)) {
750
852
  left.push("the relay — others may be using it. Stop and remove it too: pantheon leave --stop-relay");
751
853
  }
752
- if (io.hasIntent()) {
854
+ if (!io.homeRefusal && io.hasIntent()) {
753
855
  io.clearIntent();
754
856
  removed.push("the hook intent (hook-intent.json) — nothing will put the hooks back");
755
857
  }
756
858
  const mcp = io.removeMcpEntries();
757
859
  removed.push(...mcp.removed);
758
860
  left.push(...mcp.left);
759
- try {
760
- if (existsSync(io.logDir)) {
761
- rmSync(io.logDir, { recursive: true, force: true });
762
- 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
+ }
763
867
  }
868
+ catch { }
764
869
  }
765
- catch { }
766
870
  const repoDir = join(opts.project, ".pantheon");
767
871
  let repoLogs = 0;
768
872
  for (const k of LOG_KINDS) {
@@ -916,6 +1020,7 @@ export function realUninstallIo(deps) {
916
1020
  hasIntent: deps.hasIntent,
917
1021
  clearIntent: deps.clearIntent,
918
1022
  logDir: join(CONFIG_DIR, "logs"),
1023
+ homeRefusal: deps.homeRefusal !== undefined ? deps.homeRefusal : pantheonHomeRefusal(CONFIG_DIR),
919
1024
  ownWorkerPid: () => ownWorkerPid(),
920
1025
  borrowed: () => borrowedHome(),
921
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/uninstall.js CHANGED
@@ -5,7 +5,7 @@ import { posix, win32 } from "node:path";
5
5
  import { expectedLauncher } from "./managed.js";
6
6
  export const BEGIN_MARK = "# >>> pantheon >>>";
7
7
  export const END_MARK = "# <<< pantheon <<<";
8
- export const PROFILES = [".profile", ".bash_profile", ".bashrc", ".zshrc", ".zprofile"];
8
+ export const PROFILES = [".profile", ".bash_profile", ".bash_login", ".bashrc", ".zshrc", ".zprofile"];
9
9
  export const FISH_CONF = ".config/fish/conf.d/pantheon.fish";
10
10
  export function withoutPathBlock(text) {
11
11
  const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
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";
@@ -633,10 +633,12 @@ export function realHostIo(o = {}) {
633
633
  exit: (code) => process.exit(code),
634
634
  };
635
635
  }
636
- export function takeHostLock(home = CONFIG_DIR, pid = process.pid) {
636
+ export const HOST_LOCK_STARTUP_GRACE_MS = 2 * 60_000;
637
+ export function takeHostLock(home = CONFIG_DIR, pid = process.pid, now = Date.now, alive = pidAlive) {
637
638
  const f = hostFiles(home);
638
639
  mkdirSync(f.dir, { recursive: true });
639
- for (let i = 0; i < 2; i++) {
640
+ let lastHolder = -1;
641
+ for (let i = 0; i < 4; i++) {
640
642
  try {
641
643
  const fd = openSync(f.lock, "wx");
642
644
  writeSync(fd, String(pid));
@@ -646,13 +648,63 @@ export function takeHostLock(home = CONFIG_DIR, pid = process.pid) {
646
648
  catch (e) {
647
649
  if (e.code !== "EEXIST")
648
650
  throw e;
649
- const held = Number(readFileSafe(f.lock));
650
- if (Number.isInteger(held) && held > 0 && held !== pid && pidAlive(held) && hostAlive(readHostState(home)))
651
+ }
652
+ const judged = readFileSafe(f.lock);
653
+ const held = Number(judged);
654
+ let age = 0;
655
+ try {
656
+ age = now() - statSync(f.lock).mtimeMs;
657
+ }
658
+ catch {
659
+ continue;
660
+ }
661
+ if (Number.isInteger(held) && held > 0) {
662
+ lastHolder = held;
663
+ if (held === pid)
664
+ return null;
665
+ if (alive(held) && (hostAlive(readHostState(home)) || age < HOST_LOCK_STARTUP_GRACE_MS))
651
666
  return held;
652
- rmSync(f.lock, { force: true });
653
667
  }
668
+ else if (age < 10_000) {
669
+ return lastHolder;
670
+ }
671
+ const gone = `${f.lock}.stale-${pid}-${i}`;
672
+ try {
673
+ renameSync(f.lock, gone);
674
+ }
675
+ catch {
676
+ continue;
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
+ }
684
+ rmSync(gone, { force: true });
654
685
  }
655
- return null;
686
+ return lastHolder;
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 { }
656
708
  }
657
709
  const readFileSafe = (p) => { try {
658
710
  return readFileSync(p, "utf8").trim();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "built_at": "2026-10-04T17:35:53.208Z",
3
+ "built_at": "2026-10-05T08:05:30.441Z",
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": {
@@ -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
  }