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

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.
@@ -4,7 +4,7 @@ import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:
4
4
  import { writeInside } from "./safe-write.js";
5
5
  import { homedir, platform, tmpdir } from "node:os";
6
6
  import { isAbsolute, join, posix, relative, resolve, sep, win32 } from "node:path";
7
- import { expectedLauncher } from "./managed.js";
7
+ import { expectedLauncher, windowsManagedRoots } from "./managed.js";
8
8
  import { agentSpawn } from "./which.js";
9
9
  export const NO_CLAUDE_FEATURES = { version: null, permissionPrompts: false, settings: false, settingSources: false, disallowedTools: false };
10
10
  export function parseClaudeHelp(help, version = null) {
@@ -169,12 +169,11 @@ function aroundWorktree(top, keeps, named) {
169
169
  return out;
170
170
  }
171
171
  function managedTargets(home, os) {
172
- const launcherAt = expectedLauncher(home, os);
173
- if (!launcherAt)
174
- return [];
175
172
  const P = os === "win32" ? win32 : posix;
176
- const bin = P.dirname(launcherAt);
177
- return [{ abs: bin, dir: true }, { abs: P.join(P.dirname(bin), "runtime"), dir: true }];
173
+ const roots = os === "win32"
174
+ ? Object.values(windowsManagedRoots(home)).filter((r) => !!r)
175
+ : [expectedLauncher(home, os)].filter((l) => !!l).map((l) => P.dirname(P.dirname(l)));
176
+ return roots.flatMap((root) => [{ abs: P.join(root, "bin"), dir: true }, { abs: P.join(root, "runtime"), dir: true }]);
178
177
  }
179
178
  export function claudeRunSettings(i) {
180
179
  const home = resolve(i.home ?? homedir());
package/dist/cli.js CHANGED
@@ -914,8 +914,9 @@ ${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,
918
- and all it keeps in ~/.pantheon (files of yours there stay)
917
+ pantheon uninstall remove Pantheon itself: its services, hooks, its own Node, the pantheon command, the PATH
918
+ lines and what it keeps in ~/.pantheon — but not your identity, so installing again puts
919
+ you back in your teams (--forget-identity deletes that too; files of yours there stay)
919
920
  pantheon panic emergency stop: ends the run in flight and the worker, and switches off wake rights in every team you are in
920
921
  pantheon doctor [--share] check this machine end to end; --share prints it without names or paths, to paste to us
921
922
  pantheon network [--proxy <url> | --direct | --clear] how this machine reaches the relay; save a proxy for every Pantheon process here
@@ -999,7 +1000,7 @@ const COMMAND_FLAGS = {
999
1000
  history: "--goal <id> --from <member> --last <n> --since <seq> --short --presence --json",
1000
1001
  network: "--proxy <url> [--no-proxy <hosts>] | --direct | --clear",
1001
1002
  leave: "[<team>] --yes --keep-identity --stop-relay --project <dir>",
1002
- uninstall: "--yes --keep-identity --stop-relay --project <dir>",
1003
+ uninstall: "--yes --forget-identity --stop-relay --project <dir>",
1003
1004
  stop: "[relay|worker|notifier|menubar|all] --port <n>",
1004
1005
  logs: "[relay|worker|notifier|menubar] --follow --n <lines>",
1005
1006
  worker: "--agent <claude-code|codex|cursor|copilot|kiro|auto> --persona <name> --project <dir> --allow-bash --max-runs <n> --max-runs-day <n> --grace <s> --once",
@@ -1057,7 +1058,8 @@ function slipDistance(a, b) {
1057
1058
  const EXTRA_FLAGS = "--action-id --agent-cmd --approve-all --ask-before-sending --child --code --data --device --display --force --help --host --idle --keywords --local " +
1058
1059
  "--no-browser --no-continue --no-guide --no-notify --only-action --path --peek --poll --portable --publish --queued --quiet --remind --scratch --serve --session --surface --token --unattended " +
1059
1060
  "--until --user --version --all --json " +
1060
- "--name --title --wake --commands --unsandboxed --alerts --decides --playbook --base --responsibilities --human-required --agent-can-decide --capabilities";
1061
+ "--name --title --wake --commands --unsandboxed --alerts --decides --playbook --base --responsibilities --human-required --agent-can-decide --capabilities" +
1062
+ " --keep-tray-quit --direct";
1061
1063
  const KNOWN_FLAGS = new Set([...`${Object.values(COMMAND_FLAGS).join(" ")} ${EXTRA_FLAGS}`.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1]));
1062
1064
  function refuseUnknownFlags() {
1063
1065
  if (["mcp", "hook", "tray-status", "_menubar", "handle-uri"].includes(cmd))
@@ -1518,7 +1520,18 @@ async function main() {
1518
1520
  return;
1519
1521
  }
1520
1522
  case "rewire": {
1521
- const rows = installForUser().map((t) => ({ tool: t.tool, status: t.status }));
1523
+ const rows = installForUser(undefined, undefined, { direct: argv.includes("--direct") }).map((t) => ({ tool: t.tool, status: t.status }));
1524
+ try {
1525
+ const { refreshUserHooks } = await import("./notify.js");
1526
+ const h = refreshUserHooks();
1527
+ if (h?.refreshed.length)
1528
+ rows.push({ tool: "hooks", status: `pointed at this install (${h.refreshed.join(", ")})${h.refreshed.includes("codex") ? " — Codex runs a changed hook only once approved: run /hooks inside codex once" : ""}` });
1529
+ else if (h?.project.length)
1530
+ rows.push({ tool: "hooks", status: `skipped (in this project's files, ${h.project.join(", ")}) — pantheon hook install there points them here` });
1531
+ }
1532
+ catch (e) {
1533
+ rows.push({ tool: "hooks", status: `failed: ${e.message.split("\n")[0]}` });
1534
+ }
1522
1535
  try {
1523
1536
  const { refreshServiceDefinition, serviceInstalled: installed, restartService: restart } = await import("./service.js");
1524
1537
  for (const kind of ["worker", "notifier", "menubar"]) {
@@ -2051,8 +2064,11 @@ async function main() {
2051
2064
  const quit = existsSync(hostFiles().disabled("tray"));
2052
2065
  console.log(` tray ${tray?.phase === "running" ? `${C("running")} pid ${tray.pid}` : D(quit ? "quit — back at your next logon, or now: pantheon tray" : tray?.gaveUp === "clm" ? "not shown — PowerShell is in Constrained Language Mode here (pantheon board instead)" : "not running")}`);
2053
2066
  }
2054
- else
2055
- console.log(` host ${D("not running")} ${D(serviceInstalled("worker") || serviceInstalled("notifier") ? "pantheon service start worker (or log out and back in)" : "pantheon service install worker — the worker, alerts and the tray in the background")}`);
2067
+ else {
2068
+ const { readLaunchError } = await import("./win-host.js");
2069
+ const why = readLaunchError() ? (await import("./service.js")).hostStartProblem() : null;
2070
+ console.log(` host ${D("not running")} ${D(why ? `cannot start: ${why}` : serviceInstalled("worker") || serviceInstalled("notifier") ? "pantheon service start worker (or log out and back in)" : "pantheon service install worker — the worker, alerts and the tray in the background")}`);
2071
+ }
2056
2072
  }
2057
2073
  else if (menubar.running) {
2058
2074
  console.log(` menu bar ${C("running")} ${menubar.managedBy === "launchd" ? `service ${menubar.label}` : "started by hand"} pid ${menubar.pid}`);
@@ -2485,7 +2501,11 @@ async function main() {
2485
2501
  const { leavePlan } = await import("./machine.js");
2486
2502
  const plan = leavePlan(all);
2487
2503
  const project = resolve(flag("project", "."));
2488
- const keepIdentity = argv.includes("--keep-identity");
2504
+ const forgetIdentity = argv.includes("--forget-identity");
2505
+ if (forgetIdentity && argv.includes("--keep-identity"))
2506
+ die("--keep-identity and --forget-identity say opposite things — pick one");
2507
+ const keepIdentity = argv.includes("--keep-identity") || (uninstalling && !forgetIdentity);
2508
+ const keptByDefault = uninstalling && keepIdentity && !argv.includes("--keep-identity");
2489
2509
  const standing = new Map();
2490
2510
  if (all && !keepIdentity) {
2491
2511
  const ms = Object.values(all.spaces ?? {});
@@ -2502,14 +2522,15 @@ async function main() {
2502
2522
  die(`you founded ${orphaned.map((t) => `"${t}"`).join(", ")}, and others are in ${orphaned.length === 1 ? "it" : "them"}. Leaving removes this machine's key: you could not rejoin as yourself, and only a founder can remove a member or delete a team — nobody there could, ever again.\n` +
2503
2523
  ` To step out but keep the way back: ${orphaned.map((t) => `pantheon leave ${t}`).join(" · ")} (keeps your key)\n Leave anyway: pantheon ${self} --yes`);
2504
2524
  }
2505
- const { managedInstall, removeManagedInstall, NPM_UNINSTALL } = await import("./uninstall.js");
2506
- const install = uninstalling ? managedInstall(homedir()) : null;
2525
+ const { managedInstalls, removeManagedInstall, NPM_UNINSTALL } = await import("./uninstall.js");
2526
+ const installs = uninstalling ? managedInstalls(homedir()) : [];
2527
+ const install = installs[0] ?? null;
2507
2528
  const runningManaged = managedLayout();
2508
2529
  if (uninstalling && !argv.includes("--yes")) {
2509
2530
  const teams = plan.teams.length && !keepIdentity ? `leaves ${plan.teams.map((t) => `"${t.name}"`).join(", ")} (this machine's key goes), ` : "";
2510
2531
  const what = `this removes Pantheon from this machine: it ${teams}uninstalls its services, hooks and agent entries` +
2511
2532
  (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.`;
2533
+ `. Everything Pantheon keeps in ${CONFIG_DIR} goes${keptByDefault ? `, except your identity (${CONFIG_PATH}: this machine's key${plan.teams.length ? ` and your place in ${plan.teams.map((t) => `"${t.name}"`).join(", ")}` : ""}), so installing again puts you straight back — to delete it too: pantheon uninstall --forget-identity` : keepIdentity ? " (but your identity, --keep-identity)" : ""}; files of your own there stay.`;
2513
2534
  if (!process.stdin.isTTY)
2514
2535
  die(`${what}\n If you mean it: pantheon uninstall --yes`);
2515
2536
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -2568,7 +2589,9 @@ async function main() {
2568
2589
  left.push(refusedHomeLine(CONFIG_DIR, homeRefusal));
2569
2590
  }
2570
2591
  else if (hasConfig && keepIdentity) {
2571
- left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2592
+ left.push(keptByDefault
2593
+ ? `your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} — kept, so installing Pantheon again puts you back in ${plan.teams.length === 1 ? "it" : "them"} as yourself; to delete it: pantheon uninstall --forget-identity`
2594
+ : `your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2572
2595
  }
2573
2596
  else if (!keepIdentity && (hasConfig || uninstalling)) {
2574
2597
  if (hasConfig) {
@@ -2633,10 +2656,14 @@ async function main() {
2633
2656
  }
2634
2657
  }
2635
2658
  if (uninstalling) {
2636
- if (install?.present) {
2637
- const r = removeManagedInstall({ home: homedir(), install, running: runningManaged });
2659
+ for (const one of installs) {
2660
+ if (!one.present)
2661
+ continue;
2662
+ const r = removeManagedInstall({ home: homedir(), install: one, running: runningManaged });
2638
2663
  removed.push(...r.removed);
2639
2664
  left.push(...r.left);
2665
+ if (one.os === "win32" && runningManaged && runningManaged.root.toLowerCase() === one.root.toLowerCase())
2666
+ spare.push(one.binDir, one.runtime);
2640
2667
  }
2641
2668
  if (!runningManaged)
2642
2669
  left.push(`this pantheon itself (${process.argv[1] ?? "the one you ran"}), which npm installed — remove it with: ${NPM_UNINSTALL}`);
@@ -2661,12 +2688,17 @@ async function main() {
2661
2688
  const name = me?.member ?? plan.member ?? "<your name>";
2662
2689
  const listed = plan.teams.filter((t) => { const s = standing.get(t.name); return !s || s.others === null || (s.others.length && !s.founder); }).map((t) => `"${t.name}"`);
2663
2690
  const founded = plan.teams.filter((t) => { const s = standing.get(t.name); return s?.founder && s.others?.length; }).map((t) => `"${t.name}"`);
2664
- if (listed.length) {
2665
- console.error(`\n ${D(`Your teammates may still list you in ${listed.join(", ")}. If you are not coming back, ask one in each to run:`)}`);
2666
- console.error(` ${D(`pantheon admit ${name} --revoke`)}`);
2691
+ if (keepIdentity && plan.teams.length) {
2692
+ console.error(`\n ${D(`You are still in ${plan.teams.map((t) => `"${t.name}"`).join(", ")} — this machine just has no Pantheon on it now. Install it again (the same one line) and you are back as ${name}, with nothing to rejoin.`)}`);
2693
+ }
2694
+ else {
2695
+ if (listed.length) {
2696
+ console.error(`\n ${D(`Your teammates may still list you in ${listed.join(", ")}. If you are not coming back, ask one in each to run:`)}`);
2697
+ console.error(` ${D(`pantheon admit ${name} --revoke`)}`);
2698
+ }
2699
+ if (founded.length)
2700
+ console.error(`\n ${D(`You founded ${founded.join(", ")}. With this machine's key gone you cannot rejoin as yourself, and nobody there can remove a member or delete the team — only its founder could.`)}`);
2667
2701
  }
2668
- if (founded.length)
2669
- console.error(`\n ${D(`You founded ${founded.join(", ")}. With this machine's key gone you cannot rejoin as yourself, and nobody there can remove a member or delete the team — only its founder could.`)}`);
2670
2702
  }
2671
2703
  if (me?.account && !keepIdentity) {
2672
2704
  const { accountStillExists } = await import("./machine.js");
@@ -4404,7 +4436,7 @@ async function main() {
4404
4436
  if (sub === "start" && process.platform === "win32" && (kind === "worker" || kind === "notifier")) {
4405
4437
  const { enableService } = await import("./service.js");
4406
4438
  try {
4407
- for (const l of enableService(kind))
4439
+ for (const l of enableService(kind, undefined, { keepTrayQuit: argv.includes("--keep-tray-quit") }))
4408
4440
  console.error(l);
4409
4441
  }
4410
4442
  catch (e) {
package/dist/doctor.js CHANGED
@@ -180,7 +180,23 @@ export async function doctor(projectDir) {
180
180
  }
181
181
  else if (host.platform === "win32") {
182
182
  add("pass", "machine", `Windows${host.desktop ? " — the background host, desktop alerts and the tray icon can run here" : " — a remote shell (SSH): the background host's alerts and tray need a session at the desktop"}`, undefined, `win32${host.desktop ? ", desktop" : ""}`);
183
- for (const r of windowsDoctorRows(windowsFacts(realSys().exec)))
183
+ const { thisAppPackage, onlyInsidePackage, packageApp, INSTALL_OUTSIDE_LINE } = await import("./win-package.js");
184
+ const family = thisAppPackage();
185
+ if (family) {
186
+ const own = [process.execPath, process.argv[1] ?? ""].find((p) => p && onlyInsidePackage(p, family));
187
+ if (own)
188
+ add("warn", "app package", `this pantheon runs from ${own}, which exists only inside ${packageApp(family)} — Windows keeps what an app like it installs into AppData private to it, so Pantheon's logon task (outside the app) cannot start the worker, alerts or tray`, `${INSTALL_OUTSIDE_LINE} — it puts Pantheon in your profile folder, which every program sees`, "runs from an app-private folder");
189
+ else
190
+ add("pass", "app package", `this terminal runs inside ${packageApp(family)}; Pantheon runs from a folder every program sees, so its background host can start outside the app`, undefined, "inside an app package");
191
+ }
192
+ const wf = windowsFacts(realSys().exec);
193
+ if (!wf.hostUp) {
194
+ const { readLaunchError } = await import("./win-host.js");
195
+ const { hostStartProblem } = await import("./service.js");
196
+ if (readLaunchError())
197
+ wf.launchProblem = hostStartProblem();
198
+ }
199
+ for (const r of windowsDoctorRows(wf))
184
200
  add(r.level, r.name, r.detail, r.fix, r.share);
185
201
  }
186
202
  else {
@@ -37,6 +37,7 @@ export const PANTHEON_HOME_ENTRIES = [
37
37
  { name: "no-hooks", what: "the empty hooks folder woken runs' git uses", dir: true },
38
38
  { name: "lfs-hooks", what: "the LFS-only hooks folder woken runs' git uses", dir: true },
39
39
  { name: "runtime", what: "Pantheon's own Node", dir: true, kept: "managed" },
40
+ { name: "install.log", what: "the installer's log", kept: "managed" },
40
41
  { 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
42
  { name: /^(\.DS_Store|Thumbs\.db|desktop\.ini)$/i, sample: ".DS_Store", what: "the file browser's folder cache" },
42
43
  ];
package/dist/install.js CHANGED
@@ -293,10 +293,10 @@ export function claudeUserScope(entry, present = true, claudeBin = which("claude
293
293
  const remove = agentSpawn(claudeBin, ["mcp", "remove", "pantheon", "-s", "user"]);
294
294
  const add = agentSpawn(claudeBin, ["mcp", "add", "pantheon", "-s", "user", "--", entry.command, ...entry.args]);
295
295
  try {
296
- execFileSync(remove.command, remove.args, { stdio: "ignore", ...remove.options });
296
+ execFileSync(remove.command, remove.args, { stdio: "ignore", timeout: 60_000, ...remove.options });
297
297
  }
298
298
  catch { }
299
- execFileSync(add.command, add.args, { stdio: ["ignore", "ignore", "pipe"], ...add.options });
299
+ execFileSync(add.command, add.args, { stdio: ["ignore", "ignore", "pipe"], timeout: 60_000, ...add.options });
300
300
  return { tool, where: "user scope", status: "ready in every project and every Conductor window" };
301
301
  }
302
302
  catch (e) {
package/dist/machine.js CHANGED
@@ -665,7 +665,7 @@ export function removePantheonWorktrees(repos, roots, git = (repo, args) => exec
665
665
  }
666
666
  return { removed, left };
667
667
  }
668
- const MANAGED_LAUNCHER = "pantheon";
668
+ const MANAGED_LAUNCHERS = ["pantheon", "pantheon.cmd"];
669
669
  export function removeHomeLeftovers(home, keep, opts = {}) {
670
670
  const removed = [], left = [];
671
671
  const refusal = opts.refusal !== undefined ? opts.refusal : pantheonHomeRefusal(home);
@@ -683,12 +683,12 @@ export function removeHomeLeftovers(home, keep, opts = {}) {
683
683
  if (!existsSync(f))
684
684
  continue;
685
685
  if (keep && name !== "worker.pid" && name !== "bin") {
686
- left.push(`${what}, ${f} (--keep-identity)`);
686
+ left.push(`${what}, ${f} (kept with your identity)`);
687
687
  continue;
688
688
  }
689
- if (name === "bin" && existsSync(join(f, MANAGED_LAUNCHER))) {
689
+ if (name === "bin" && MANAGED_LAUNCHERS.some((l) => existsSync(join(f, l)))) {
690
690
  try {
691
- const others = readdirSync(f).filter((e) => e !== MANAGED_LAUNCHER);
691
+ const others = readdirSync(f).filter((e) => !MANAGED_LAUNCHERS.includes(e));
692
692
  for (const e of others)
693
693
  rmSync(join(f, e), { recursive: true, force: true });
694
694
  if (others.length)
@@ -730,7 +730,8 @@ export function sweepPantheonHome(home, o = {}) {
730
730
  const refusal = o.refusal !== undefined ? o.refusal : o.fs ? null : pantheonHomeRefusal(home);
731
731
  if (refusal)
732
732
  return { removed, left: [refusedHomeLine(home, refusal)], gone: false };
733
- const spare = new Set((o.spare ?? []).map((p) => resolve(p)));
733
+ const key = (p) => (os === "win32" ? resolve(p).toLowerCase() : resolve(p));
734
+ const spare = new Set((o.spare ?? []).map(key));
734
735
  const attempt = (p, rm) => {
735
736
  let why = "";
736
737
  for (let i = 0; i < tries; i++) {
@@ -765,7 +766,7 @@ export function sweepPantheonHome(home, o = {}) {
765
766
  }
766
767
  if (name === HOME_MARKER)
767
768
  continue;
768
- if (spare.has(resolve(p)))
769
+ if (spare.has(key(p)))
769
770
  continue;
770
771
  if (e.kept === "managed")
771
772
  continue;
@@ -786,7 +787,7 @@ export function sweepPantheonHome(home, o = {}) {
786
787
  if (gone.length)
787
788
  removed.push(`Pantheon's own files in ${home}: ${gone.join(", ")}`);
788
789
  if (keptForIdentity.length)
789
- left.push(`what goes with your identity in ${home}: ${keptForIdentity.join(", ")} (--keep-identity)`);
790
+ left.push(`what goes with your identity in ${home}: ${keptForIdentity.join(", ")} (kept with your identity)`);
790
791
  let rest = [];
791
792
  try {
792
793
  rest = fs.list(home);
package/dist/managed.js CHANGED
@@ -15,7 +15,7 @@ export function managedLayout(execPath = process.execPath, os = platform()) {
15
15
  return null;
16
16
  if (win32.basename(nodeDir).toLowerCase() !== "node" || win32.basename(runtime).toLowerCase() !== "runtime")
17
17
  return null;
18
- if (win32.basename(root).toLowerCase() !== "pantheon")
18
+ if (![".pantheon", "pantheon"].includes(win32.basename(root).toLowerCase()))
19
19
  return null;
20
20
  const binDir = win32.join(root, "bin");
21
21
  const pkgDir = win32.join(nodeDir, "node_modules", ...PKG);
@@ -98,10 +98,20 @@ export function runtimeVerdict(f) {
98
98
  share: "managed install present, another pantheon ran",
99
99
  };
100
100
  }
101
- export function expectedLauncher(home, os = platform(), env = process.env) {
101
+ export function windowsManagedRoots(home, env = process.env) {
102
+ const local = env.LOCALAPPDATA || (home ? win32.join(home, "AppData", "Local") : "");
103
+ return { current: home ? win32.join(home, ".pantheon") : null, legacy: local ? win32.join(local, "Pantheon") : null };
104
+ }
105
+ export function expectedLauncher(home, os = platform(), env = process.env, exists = existsSync) {
102
106
  if (os === "win32") {
103
- const local = env.LOCALAPPDATA || (home ? win32.join(home, "AppData", "Local") : "");
104
- return local ? win32.join(local, "Pantheon", "bin", "pantheon.cmd") : null;
107
+ const { current, legacy } = windowsManagedRoots(home, env);
108
+ const at = (root) => (root ? win32.join(root, "bin", "pantheon.cmd") : null);
109
+ const now = at(current), old = at(legacy);
110
+ if (now && exists(now))
111
+ return now;
112
+ if (old && exists(old))
113
+ return old;
114
+ return old ?? now;
105
115
  }
106
116
  return home ? posix.join(home, ".pantheon", "bin", "pantheon") : null;
107
117
  }
package/dist/notify.js CHANGED
@@ -8,6 +8,7 @@ import { dirname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { whichPantheon } from "./service.js";
10
10
  import { managedLayout } from "./managed.js";
11
+ import { inWokenRun } from "./worker-policy.js";
11
12
  import { detectForHooks, agentById } from "./agents.js";
12
13
  import { codexHookState, cursorHookState, copilotHookState, kiroHookState, installCodexHooks, installCursorHooks, installCopilotHooks, installKiroHooks, removeCodexHooks, removeCursorHooks, removeCopilotHooks, removeKiroHooks, } from "./hooks-install.js";
13
14
  export const HOOK_MARKER = "pantheon hook";
@@ -171,6 +172,37 @@ export function repairHook(home) {
171
172
  return null;
172
173
  }
173
174
  }
175
+ export function refreshUserHooks(home) {
176
+ if (inWokenRun())
177
+ return null;
178
+ const intent = readIntent(home);
179
+ const bin = hookLauncher();
180
+ if (!intent || !bin)
181
+ return null;
182
+ const targets = intent.agents?.length ? intent.agents : ["claude-code"];
183
+ if (intent.scope !== "user") {
184
+ const files = agentHookStates(intent.projectDir ?? process.cwd(), "project").map((s) => s.file).filter((f) => !!f);
185
+ return { refreshed: [], project: files };
186
+ }
187
+ const jsonBin = JSON.stringify(bin).slice(1, -1);
188
+ const states = new Map(agentHookStates(intent.projectDir ?? process.cwd(), "user").map((st) => [st.agent, st]));
189
+ const holdsBin = (f) => { try {
190
+ const t = readFileSync(f, "utf8");
191
+ return t.includes(bin) || t.includes(jsonBin);
192
+ }
193
+ catch {
194
+ return true;
195
+ } };
196
+ const stale = targets.filter((id) => {
197
+ const st = states.get(id);
198
+ return !!st?.installed && !!st.file && !holdsBin(st.file);
199
+ });
200
+ if (!stale.length)
201
+ return { refreshed: [], project: [] };
202
+ installHooks("user", intent.projectDir ?? process.cwd(), stale);
203
+ const after = new Map(agentHookStates(intent.projectDir ?? process.cwd(), "user").map((st) => [st.agent, st]));
204
+ return { refreshed: stale.filter((id) => { const st = after.get(id); return !!st?.file && holdsBin(st.file); }), project: [] };
205
+ }
174
206
  function installClaudeHook(scope, projectDir = process.cwd()) {
175
207
  const file = settingsPath(scope, projectDir);
176
208
  const doc = read(file);
package/dist/relay.js CHANGED
@@ -2701,6 +2701,9 @@ pantheon start</code></pre>
2701
2701
  }
2702
2702
  if (verdict === "key-mismatch") {
2703
2703
  this.log(`[${sp.name}] ! REFUSED ${m.member}: key mismatch (${fingerprint(m.pubkey)})`);
2704
+ if (sp.allow.founder() === m.member) {
2705
+ return reject("KEY_MISMATCH", `"${m.member}" founded "${sp.name}" and is registered here with a different key. A founder's key cannot be replaced: if this is really you on another machine, restore your original ~/.pantheon/config.json (the key is in it). Without it, join under another name (--me <new-name>) or start a new team (pantheon start).`);
2706
+ }
2704
2707
  return reject("KEY_MISMATCH", `"${m.member}" is already registered here with a different key. If you are someone else, join under your own name (--me <your-name>). If this is really you on another machine, an admitted member who has checked it is you runs pantheon admit ${m.member} --forget (it removes whoever holds the name now) and sends you a new invite.`);
2705
2708
  }
2706
2709
  if (verdict === "revoked") {
@@ -238,6 +238,18 @@ export class Allowlist {
238
238
  return { ok: false, why: `"${member}" was removed from this team${existing?.revoked_by ? ` by ${existing.revoked_by}` : ""}, so only an invite from the founder${this.founderName ? ` (${this.founderName})` : ""}, made since, can bring them back` };
239
239
  }
240
240
  if (existing && existing.publicKey !== publicKey) {
241
+ if (this.founderName && member === this.founderName) {
242
+ return {
243
+ ok: false,
244
+ why: `"${member}" founded this team and is registered with a different key — a founder's key cannot be replaced`,
245
+ fix: [
246
+ `If you ARE "${member}" on a new machine: restore your original ~/.pantheon/config.json if you still have it — the key is in it — then redeem this same invite again.`,
247
+ ` Without it, nobody can let you back in under "${member}": a team's founder is never removed, so the old key cannot be retired.`,
248
+ ` You can join under another name instead (--me <new-name>, this same invite), or start a new team: pantheon start --space <name>.`,
249
+ ` If you are not "${member}", join under your own name: --me <your-name>.`,
250
+ ].join("\n"),
251
+ };
252
+ }
241
253
  return {
242
254
  ok: false,
243
255
  why: `"${member}" is registered here with a different key — the name is taken in this team`,
package/dist/service.js CHANGED
@@ -6,9 +6,10 @@ import { basename, dirname, isAbsolute, join, posix, resolve } from "node:path";
6
6
  import { linuxGraphicalSession } from "./host-env.js";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { isVersionManagedPath, launcher } from "./install.js";
9
- import { childNodeFlags, managedLauncher, managedLayout } from "./managed.js";
9
+ import { childNodeFlags, managedLauncher, managedLayout, windowsManagedRoots } from "./managed.js";
10
10
  import { CONFIG_DIR, ensurePantheonHome } from "./config.js";
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";
11
+ import { HOST_KINDS, LEGACY_TASK_NAME, clearTrayQuit, ensureHostShim, fromUtf16le, hostAlive, hostArgv, hostChildRunning, hostFiles, hostLaunch, isHostKind, launchFileText, legacyTaskIsUsers, readDefinition, readHostState, readLaunchError, taskName, taskUserId, taskXml, utf16le, windowsPowerShell, writeDefinition, } from "./win-host.js";
12
+ import { INSTALL_OUTSIDE_LINE, onlyInsidePackage, packageApp, thisAppPackage } from "./win-package.js";
12
13
  const HERE = dirname(fileURLToPath(import.meta.url));
13
14
  const LABEL = (kind) => `dev.pantheon.${kind}`;
14
15
  export function borrowedHome() {
@@ -474,7 +475,14 @@ export function restartService(kind, exec = realExec, opts = {}) {
474
475
  }
475
476
  if (!st.loaded)
476
477
  return [` ${kind}: the ${taskName(exec)} task is not registered — pantheon service install ${kind}`];
477
- exec("schtasks", ["/Run", "/TN", taskName(exec)]);
478
+ const name = taskName(exec);
479
+ runTask(exec, name);
480
+ const ms = opts.hostStartMs ?? 20_000;
481
+ if (!waitFor(hostUp, ms, opts.sleep ?? blockingSleep)) {
482
+ return [readLaunchError() || registeredLaunch() !== "shim"
483
+ ? ` ${kind}: the ${name} task started, but the Pantheon host did not come up within ${Math.round(ms / 1000)} s — ${hostStartProblem()}`
484
+ : ` ${kind}: started the ${name} task — the Pantheon host is not up yet after ${Math.round(ms / 1000)} s; pantheon ps in a minute shows it`];
485
+ }
478
486
  return [` ${kind}: the Pantheon host was not running — started it, and the ${kind} with it, on the new code`];
479
487
  }
480
488
  if (plat === "darwin") {
@@ -614,9 +622,15 @@ export function hostRuntime(o) {
614
622
  }
615
623
  throw new Error(`this Pantheon runs from ${moving(o.cli) ? o.cli : o.execPath}, a folder a Node version manager replaces when you switch Node versions — a logon task naming it would start nothing after the next switch. Install Pantheon with its own Node (irm https://relay.joinpantheon.network/install.ps1 | iex) or with a Node from nodejs.org (npm install -g @join-pantheon/cli), then run this again`);
616
624
  }
617
- function installedManagedLayout(env) {
618
- const local = env.LOCALAPPDATA;
619
- return local ? managedLayout(`${local.replace(/[\\/]+$/, "")}\\Pantheon\\runtime\\node\\node.exe`, "win32") : null;
625
+ export function installedManagedLayout(env, home = homedir(), exists = existsSync) {
626
+ const { current, legacy } = windowsManagedRoots(home, env);
627
+ const at = (root) => (root ? managedLayout(`${root.replace(/[\\/]+$/, "")}\\runtime\\node\\node.exe`, "win32") : null);
628
+ const now = at(current), old = at(legacy);
629
+ if (now && exists(now.node))
630
+ return now;
631
+ if (old && exists(old.node))
632
+ return old;
633
+ return now ?? old;
620
634
  }
621
635
  export function desiredTask(sys, log, rt) {
622
636
  const env = sys.env ?? process.env;
@@ -626,6 +640,11 @@ export function desiredTask(sys, log, rt) {
626
640
  nodesOnPath: [which("node") ?? ""], exists: existsSync,
627
641
  ...rt,
628
642
  }) : sys.hostRuntime;
643
+ const family = sys.appPackage !== undefined ? sys.appPackage : thisAppPackage();
644
+ if (family)
645
+ for (const f of [runtime.node, runtime.cli])
646
+ if (onlyInsidePackage(f, family, env, sys.exists))
647
+ throw new Error(packagedRuntimeProblem(f, family));
629
648
  const shim = ensureHostShim({ exec: (c, a) => sys.exec(c, a), env, ...(log ? { log } : {}) });
630
649
  const home = nonDefaultHome();
631
650
  const launch = hostLaunch({ shim, node: runtime.node, flags: runtime.flags, cli: runtime.cli, home, launch: hostFiles().launch, powershell: windowsPowerShell(env) });
@@ -693,6 +712,49 @@ function replaceLegacyTask(sys, name, unattended, log) {
693
712
  }
694
713
  }
695
714
  const hostUp = () => hostAlive(readHostState());
715
+ export function packagedRuntimeProblem(program, family) {
716
+ return `${program} exists only inside ${packageApp(family)}: Windows keeps what an app like it installs into AppData private to that app, and Pantheon's logon task runs outside it, so the worker, alerts and tray could never start. To fix it once, ${INSTALL_OUTSIDE_LINE} — it puts Pantheon in your profile folder, which every program sees — then run this again`;
717
+ }
718
+ function runTask(exec, name) {
719
+ if (readLaunchError()) {
720
+ try {
721
+ exec("schtasks", ["/End", "/TN", name]);
722
+ }
723
+ catch { }
724
+ }
725
+ exec("schtasks", ["/Run", "/TN", name]);
726
+ }
727
+ function registeredLaunch() {
728
+ try {
729
+ const cmd = /<Command>([^<]*)<\/Command>/.exec(fromUtf16le(readFileSync(hostFiles().taskXml)))?.[1] ?? "";
730
+ return /pantheon-host-[0-9a-f]+\.exe$/i.test(cmd) ? "shim" : cmd ? "powershell" : null;
731
+ }
732
+ catch {
733
+ return null;
734
+ }
735
+ }
736
+ export function confirmHostStart(sys, name) {
737
+ const ms = sys.hostStartMs ?? 20_000;
738
+ if (waitFor(hostUp, ms, sys.sleep ?? blockingSleep))
739
+ return { up: true, line: ` the Pantheon host is up (pid ${readHostState()?.pid ?? "?"})` };
740
+ if (!readLaunchError() && registeredLaunch() === "shim")
741
+ return { up: false, slow: true, line: ` started the ${name} task — the Pantheon host is not up yet after ${Math.round(ms / 1000)} s (a first start can be slow while Windows scans it); pantheon ps in a minute shows it` };
742
+ const why = hostStartProblem(sys);
743
+ return { up: false, line: ` the ${name} task started, but the Pantheon host did not come up within ${Math.round(ms / 1000)} s — ${why}`, reason: why };
744
+ }
745
+ export function hostStartProblem(sys = {}) {
746
+ const e = readLaunchError();
747
+ const env = sys.env ?? process.env;
748
+ if (e?.what === "missing") {
749
+ const family = sys.appPackage !== undefined ? sys.appPackage : thisAppPackage();
750
+ if (family && onlyInsidePackage(e.program, family, env, sys.exists))
751
+ return packagedRuntimeProblem(e.program, family);
752
+ return `it cannot find ${e.program} (since ${e.at}). Run the installer again: irm https://relay.joinpantheon.network/install.ps1 | iex`;
753
+ }
754
+ if (e?.what === "start-failed")
755
+ return `Windows would not start ${e.program} (since ${e.at}) — a security product may be blocking it; ${hostFiles().log} may say more`;
756
+ return `nothing it wrote says why yet. ${hostFiles().log} and pantheon doctor may say more; logging out and back in starts the task again`;
757
+ }
696
758
  const windowsBorrowed = (sys) => (sys.borrowed ?? (() => borrowedScheduler(sys.env ?? process.env)))();
697
759
  function installWindows(kind, cwd, args, sys) {
698
760
  if (!isHostKind(kind)) {
@@ -718,8 +780,15 @@ function installWindows(kind, cwd, args, sys) {
718
780
  if (hostUp())
719
781
  out.push(` the host is running — it starts the ${kind} within seconds, and restarts it if it stops`);
720
782
  else {
721
- sys.exec("schtasks", ["/Run", "/TN", name]);
722
- out.push(` started — the ${kind} now starts at logon and restarts if it stops`);
783
+ runTask(sys.exec, name);
784
+ const c = confirmHostStart(sys, name);
785
+ out.push(c.line);
786
+ if (c.up || c.slow)
787
+ out.push(` started — the ${kind} now starts at logon and restarts if it stops`);
788
+ else {
789
+ loaded = false;
790
+ reason = c.reason;
791
+ }
723
792
  }
724
793
  }
725
794
  catch (e) {
@@ -827,7 +896,7 @@ function disableWindows(kind, sys) {
827
896
  writeFileSync(f.disabled(kind), new Date().toISOString());
828
897
  waitFor(() => !hostChildRunning(kind), 6_000, sys.sleep ?? blockingSleep);
829
898
  }
830
- export function enableService(kind, sys = realSys()) {
899
+ export function enableService(kind, sys = realSys(), o = {}) {
831
900
  if (sys.platform !== "win32")
832
901
  throw new Error("service start is the Windows host's; elsewhere: pantheon service install");
833
902
  if (!isHostKind(kind) || !readDefinition(kind))
@@ -835,13 +904,17 @@ export function enableService(kind, sys = realSys()) {
835
904
  const f = hostFiles();
836
905
  const was = existsSync(f.disabled(kind));
837
906
  rmSync(f.disabled(kind), { force: true });
838
- const tray = clearTrayQuit() ? [" the tray icon, which you had quit, shows again"] : [];
907
+ const tray = !o.keepTrayQuit && clearTrayQuit() ? [" the tray icon, which you had quit, shows again"] : [];
839
908
  if (hostUp())
840
909
  return [` ${kind}: ${was ? "resumed" : "already on"} — the Pantheon host ${was ? "starts it within seconds" : "runs it"}`, ...tray];
841
910
  const lines = [];
842
911
  ensureTask(sys, (l) => lines.push(l), { migrate: true });
843
- sys.exec("schtasks", ["/Run", "/TN", taskName(sys.exec, sys.env)]);
844
- return [...lines, ` ${kind}: ${was ? "resumed" : "on"} — started the Pantheon host, which runs it`, ...tray];
912
+ const name = taskName(sys.exec, sys.env);
913
+ runTask(sys.exec, name);
914
+ const c = confirmHostStart(sys, name);
915
+ if (!c.up && !c.slow)
916
+ throw new Error(c.line.trim());
917
+ return [...lines, c.up ? ` ${kind}: ${was ? "resumed" : "on"} — started the Pantheon host, which runs it` : ` ${kind}: ${was ? "resumed" : "on"} —${c.line.replace(/^ {2}started/, " started")}`, ...tray];
845
918
  }
846
919
  export function showTray(sys = realSys()) {
847
920
  const f = hostFiles();
@@ -856,7 +929,9 @@ export function showTray(sys = realSys()) {
856
929
  }
857
930
  const lines = [];
858
931
  ensureTask(sys, (l) => lines.push(l), { migrate: true });
859
- sys.exec("schtasks", ["/Run", "/TN", taskName(sys.exec, sys.env)]);
860
- return [...lines, " started the Pantheon host, and the tray icon with it"];
932
+ const name = taskName(sys.exec, sys.env);
933
+ runTask(sys.exec, name);
934
+ const c = confirmHostStart(sys, name);
935
+ return [...lines, c.up ? " started the Pantheon host, and the tray icon with it" : c.line];
861
936
  }
862
937
  //# sourceMappingURL=service.js.map
package/dist/uninstall.js CHANGED
@@ -2,7 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { platform } from "node:os";
4
4
  import { posix, win32 } from "node:path";
5
- import { expectedLauncher } from "./managed.js";
5
+ import { expectedLauncher, windowsManagedRoots } from "./managed.js";
6
6
  export const BEGIN_MARK = "# >>> pantheon >>>";
7
7
  export const END_MARK = "# <<< pantheon <<<";
8
8
  export const PROFILES = [".profile", ".bash_profile", ".bash_login", ".bashrc", ".zshrc", ".zprofile"];
@@ -83,8 +83,23 @@ export const realUninstallFs = {
83
83
  }
84
84
  catch { } },
85
85
  };
86
+ export function managedInstalls(home, os = platform(), env = process.env, fs = realUninstallFs) {
87
+ const first = managedInstall(home, os, env, fs);
88
+ if (!first)
89
+ return [];
90
+ if (os !== "win32")
91
+ return [first];
92
+ const { current, legacy } = windowsManagedRoots(home, env);
93
+ const other = [current, legacy].find((r) => r && r.toLowerCase() !== first.root.toLowerCase());
94
+ if (!other)
95
+ return [first];
96
+ const p = win32;
97
+ const at = { os, root: other, runtime: p.join(other, "runtime"), binDir: p.join(other, "bin"), launcher: p.join(other, "bin", "pantheon.cmd"), log: p.join(other, "install.log"), present: false };
98
+ at.present = fs.exists(at.runtime) || fs.exists(at.launcher);
99
+ return at.present ? [first, at] : [first];
100
+ }
86
101
  export function managedInstall(home, os = platform(), env = process.env, fs = realUninstallFs) {
87
- const launcher = expectedLauncher(home, os, env);
102
+ const launcher = expectedLauncher(home, os, env, fs.exists);
88
103
  if (!launcher)
89
104
  return null;
90
105
  const p = os === "win32" ? win32 : posix;
@@ -94,8 +109,8 @@ export function managedInstall(home, os = platform(), env = process.env, fs = re
94
109
  const log = os === "win32" ? p.join(root, "install.log") : p.join(root, "logs", "install.log");
95
110
  return { os, root, runtime, binDir, launcher, log, present: fs.exists(runtime) || fs.exists(launcher) };
96
111
  }
97
- export function finishAfterExit(paths, pids = [process.pid]) {
98
- const inner = cleanupScript(paths, pids);
112
+ export function finishAfterExit(paths, pids = [process.pid], ifEmpty = null) {
113
+ const inner = cleanupScript(paths, pids, ifEmpty);
99
114
  if (!inner)
100
115
  return false;
101
116
  const ps = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
@@ -113,14 +128,15 @@ export function cleanupLauncher(ps, inner) {
113
128
  const enc = Buffer.from(inner, "utf16le").toString("base64");
114
129
  return `Start-Process -FilePath '${ps.replace(/'/g, "''")}' -WindowStyle Hidden -ArgumentList '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand','${enc}'`;
115
130
  }
116
- export function cleanupScript(paths, pids) {
117
- if (!paths.length || paths.some((x) => /[\r\n]/.test(x)))
131
+ export function cleanupScript(paths, pids, ifEmpty = null) {
132
+ if (!paths.length || paths.some((x) => /[\r\n]/.test(x)) || (ifEmpty && /[\r\n]/.test(ifEmpty)))
118
133
  return null;
119
134
  const ids = pids.filter((n) => Number.isInteger(n) && n > 0);
120
135
  const steps = [
121
136
  ids.length ? `Wait-Process -Id ${ids.join(",")} -Timeout 60 -ErrorAction SilentlyContinue` : "",
122
137
  "Start-Sleep -Milliseconds 500",
123
138
  `Remove-Item -LiteralPath ${paths.map((x) => `'${x.replace(/'/g, "''")}'`).join(",")} -Recurse -Force`,
139
+ ifEmpty ? `$d = '${ifEmpty.replace(/'/g, "''")}'; if ((Test-Path -LiteralPath $d) -and -not (Get-ChildItem -LiteralPath $d -Force)) { Remove-Item -LiteralPath $d -Force }` : "",
124
140
  ].filter(Boolean).join("; ");
125
141
  return `& { ${steps} } *> (Join-Path $env:TEMP 'pantheon-uninstall.log')`;
126
142
  }
@@ -200,10 +216,11 @@ export function removeManagedInstall(o) {
200
216
  }
201
217
  const lockedHere = m.os === "win32" && !!o.running && o.running.root.toLowerCase() === m.root.toLowerCase();
202
218
  if (lockedHere) {
203
- const finish = (o.finishLater ?? finishAfterExit)([m.runtime, m.binDir, m.log]);
219
+ const finish = o.finishLater ? o.finishLater([m.runtime, m.binDir, m.log], m.root) : finishAfterExit([m.runtime, m.binDir, m.log], undefined, m.root);
220
+ const byHand = `Remove-Item -Recurse -Force ${[m.runtime, m.binDir].map((x) => `'${x.replace(/'/g, "''")}'`).join(", ")}`;
204
221
  left.push(finish
205
- ? `${m.root} — Windows keeps Pantheon's node.exe locked while this command runs, so it is removed a few seconds after this command exits. If it is still there afterwards, an agent window has it open: close them, then in PowerShell: Remove-Item -Recurse -Force "$env:LOCALAPPDATA\\Pantheon"`
206
- : `${m.root} — Windows keeps Pantheon's node.exe locked while this command runs. Close your agent windows and this one, then in a new PowerShell: Remove-Item -Recurse -Force "$env:LOCALAPPDATA\\Pantheon"`);
222
+ ? `${m.runtime} — Windows keeps Pantheon's node.exe locked while this command runs, so it is removed a few seconds after this command exits. If it is still there afterwards, an agent window has it open: close them, then in PowerShell: ${byHand}`
223
+ : `${m.runtime} — Windows keeps Pantheon's node.exe locked while this command runs. Close your agent windows and this one, then in a new PowerShell: ${byHand}`);
207
224
  return { removed, left };
208
225
  }
209
226
  for (const [path, what] of [[m.launcher, "the pantheon command"], [m.log, "the installer's log"], [m.runtime, "Pantheon's own Node and the CLI in it"]]) {
package/dist/upgrade.js CHANGED
@@ -8,6 +8,7 @@ import { proxyEnvFor } from "./network.js";
8
8
  import { relayProc, workerProc, notifierProc, menubarProc } from "./machine.js";
9
9
  import { load } from "./config.js";
10
10
  import { compareVersions, INSTALLER_MIN_CLI, isVersion } from "./installer.js";
11
+ export const WINDOWS_PROFILE_ROOT_MIN = "0.33.1-0";
11
12
  import { childNodeFlags, installerLineHere, launcherWithRepair, managedLayout, nodeSatisfies, nodeTooOldSentence, runtimeNodeVersion } from "./managed.js";
12
13
  export function repoRoot() {
13
14
  return join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -125,6 +126,9 @@ export function upgrade(deps = defaultDeps(), opts = {}) {
125
126
  if (managed && spec !== "latest" && compareVersions(spec, INSTALLER_MIN_CLI) < 0) {
126
127
  return stop("check", `${spec} is older than this install supports (${INSTALLER_MIN_CLI} or newer): it could not upgrade itself again. Nothing was changed.`);
127
128
  }
129
+ if (managed && spec !== "latest" && /^\.pantheon$/i.test(managed.root.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "") && /^[A-Za-z]:\\|^\\\\/.test(managed.root) && compareVersions(spec, WINDOWS_PROFILE_ROOT_MIN) < 0) {
130
+ return stop("check", `${spec} predates Pantheon in your profile folder (0.33.1 or newer is needed here): it could not find itself to upgrade again. Nothing was changed.`);
131
+ }
128
132
  if (managed && deps.engines) {
129
133
  const range = deps.engines(spec);
130
134
  const node = deps.runtimeNode ? deps.runtimeNode() : runtimeNodeVersion(managed);
package/dist/win-host.js CHANGED
@@ -80,6 +80,7 @@ export const hostFiles = (home = CONFIG_DIR) => {
80
80
  notices: join(dir, "notices"),
81
81
  noticesSkipped: join(dir, "notices.skipped"),
82
82
  launch: join(dir, "launch.txt"),
83
+ launchError: join(dir, "launch-error.txt"),
83
84
  clm: join(dir, "clm"),
84
85
  state: join(dir, "state.json"),
85
86
  lock: join(dir, "host.lock"),
@@ -90,7 +91,7 @@ export const hostFiles = (home = CONFIG_DIR) => {
90
91
  };
91
92
  export function readDefinition(kind, home = CONFIG_DIR) {
92
93
  try {
93
- const d = JSON.parse(readFileSync(hostFiles(home).definition(kind), "utf8"));
94
+ const d = JSON.parse(readFileSync(hostFiles(home).definition(kind), "utf8").replace(/^\uFEFF/, ""));
94
95
  if (!d || typeof d.cwd !== "string" || !Array.isArray(d.args) || !d.args.every((a) => typeof a === "string"))
95
96
  return null;
96
97
  return { kind, cwd: d.cwd, args: d.args };
@@ -117,6 +118,17 @@ function writeAtomic(f, text) {
117
118
  }
118
119
  }
119
120
  export const HOST_BEAT_STALE_MS = 30_000;
121
+ export function readLaunchError(home = CONFIG_DIR) {
122
+ try {
123
+ const [at, what, ...rest] = readFileSync(hostFiles(home).launchError, "utf8").replace(/^\uFEFF/, "").trim().split("\t");
124
+ if (!at || (what !== "missing" && what !== "start-failed") || !rest.length)
125
+ return null;
126
+ return { at, what, program: rest.join("\t") };
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ }
120
132
  export function readHostState(home = CONFIG_DIR) {
121
133
  try {
122
134
  const s = JSON.parse(readFileSync(hostFiles(home).state, "utf8"));
@@ -966,6 +978,9 @@ export function windowsDoctorRows(x) {
966
978
  rows.push({ level: "pass", name: "host", detail: `running (pid ${x.state.pid}, ${x.state.version})${restarts.length ? ` — restarted ${restarts.map(([c, n]) => `the ${c} ${n}×`).join(", ")}` : ""}`,
967
979
  share: "running" });
968
980
  }
981
+ else if (x.launchProblem) {
982
+ rows.push({ level: "warn", name: "host", detail: `not running, and will not start as things are — ${x.launchProblem}`, share: "cannot start" });
983
+ }
969
984
  else {
970
985
  rows.push({ level: "warn", name: "host", detail: "not running — the worker, alerts and tray are all down until it starts",
971
986
  fix: `pantheon service start ${x.installed.worker ? "worker" : "notifier"} (or log out and back in; its log: ~/.pantheon/logs/host.log)`, share: "not running" });
@@ -0,0 +1,70 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { win32 } from "node:path";
4
+ export const realPackageProbeFs = {
5
+ mkdir: (p) => mkdirSync(p, { recursive: true }),
6
+ write: (p, text) => writeFileSync(p, text),
7
+ exists: (p) => existsSync(p),
8
+ list: (p) => readdirSync(p),
9
+ remove: (p) => rmSync(p, { recursive: true, force: true }),
10
+ };
11
+ export function appPackage(env = process.env, fs = realPackageProbeFs, rand = () => randomBytes(6).toString("hex")) {
12
+ const local = env.LOCALAPPDATA;
13
+ if (!local)
14
+ return null;
15
+ const name = `pantheon-probe-${rand()}`;
16
+ const dir = win32.join(local, name);
17
+ try {
18
+ fs.mkdir(dir);
19
+ fs.write(win32.join(dir, "probe"), "1");
20
+ let families = [];
21
+ try {
22
+ families = fs.list(win32.join(local, "Packages"));
23
+ }
24
+ catch {
25
+ families = [];
26
+ }
27
+ return families.find((f) => fs.exists(win32.join(local, "Packages", f, "LocalCache", "Local", name, "probe"))) ?? null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ finally {
33
+ try {
34
+ fs.remove(dir);
35
+ }
36
+ catch { }
37
+ }
38
+ }
39
+ let cached = null;
40
+ export function thisAppPackage() {
41
+ if (process.platform !== "win32")
42
+ return null;
43
+ if (!cached)
44
+ cached = { value: appPackage() };
45
+ return cached.value;
46
+ }
47
+ export function onlyInsidePackage(path, family, env = process.env, exists = existsSync) {
48
+ const local = env.LOCALAPPDATA, roaming = env.APPDATA;
49
+ const norm = (p) => win32.normalize(p).replace(/[\\]+$/, "").toLowerCase();
50
+ const p = norm(path);
51
+ for (const [root, sub] of [[local, "Local"], [roaming, "Roaming"]]) {
52
+ if (!root)
53
+ continue;
54
+ const r = norm(root);
55
+ if (!p.startsWith(r + "\\"))
56
+ continue;
57
+ const rel = win32.normalize(path).slice(win32.normalize(root).replace(/[\\]+$/, "").length + 1);
58
+ if (!local)
59
+ return false;
60
+ if (exists(win32.join(local, "Packages", family, "LocalCache", sub, rel)))
61
+ return true;
62
+ }
63
+ return false;
64
+ }
65
+ export function packageApp(family) {
66
+ const name = family.replace(/_[a-z0-9]+$/i, "");
67
+ return /^claude$/i.test(name) ? "the Claude desktop app" : name.replace(/^[A-Za-z0-9]+\./, "") || family;
68
+ }
69
+ export const INSTALL_OUTSIDE_LINE = "open PowerShell from the Start menu (not through the app) and run: irm https://relay.joinpantheon.network/install.ps1 | iex";
70
+ //# sourceMappingURL=win-package.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "built_at": "2026-10-05T08:38:15.336Z",
3
+ "built_at": "2026-10-05T16:44:41.416Z",
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": "191f3f18e8212e11ad4438bdbef87a5cc533f5ae2fae208801db01c9a265193b",
20
+ "sha256": "0414b397a533940e3a5c324add6250e1ed71919e67e462d11dcb2a1d0c2f9b41",
21
21
  "archs": [
22
22
  "arm64",
23
23
  "x86_64"
Binary file
@@ -23,7 +23,12 @@
23
23
  // swapping the runtime folder, a Node upgrade in progress) it waits and looks
24
24
  // again, with the same backoff; only after --missing-grace seconds (default
25
25
  // ten minutes) of nothing to start does it conclude Pantheon was removed, and
26
- // exit 3.
26
+ // exit 3. While it waits it says so in launch-error.txt beside the launch file
27
+ // (when, and which program it could not find), so `pantheon service install`,
28
+ // `ps` and `doctor` can tell a host that is starting from one that never will:
29
+ // a Node that exists only inside an app package's private AppData (an install
30
+ // run from the Claude desktop app) looks there to the app and is not there to
31
+ // Task Scheduler. The file goes once a start succeeds.
27
32
  //
28
33
  // Each start of this shim is one session (PANTHEON_HOST_SESSION, a new GUID):
29
34
  // the host uses it to tell a restart (same session) from a new logon.
@@ -42,7 +47,7 @@ using System.Threading;
42
47
  [assembly: System.Reflection.AssemblyTitle("Pantheon background host")]
43
48
  [assembly: System.Reflection.AssemblyDescription("Keeps Pantheon's worker, alerts and tray running while you are logged on")]
44
49
  [assembly: System.Reflection.AssemblyProduct("Pantheon")]
45
- [assembly: System.Reflection.AssemblyVersion("1.1.0.0")]
50
+ [assembly: System.Reflection.AssemblyVersion("1.2.0.0")]
46
51
 
47
52
  static class PantheonHost
48
53
  {
@@ -84,12 +89,13 @@ static class PantheonHost
84
89
  while (true)
85
90
  {
86
91
  List<string> argv = ReadLaunch(launchFile);
92
+ string wanted = argv != null ? argv[0] : baked[0];
87
93
  if (argv == null || !File.Exists(argv[0])) argv = baked;
88
94
  if (!File.Exists(argv[0]))
89
95
  {
90
96
  // Nothing to start right now: wait for it to come back, and give up
91
97
  // only once it has been gone longer than any install or upgrade takes.
92
- if (missingSince == DateTime.MinValue) missingSince = DateTime.UtcNow;
98
+ if (missingSince == DateTime.MinValue) { missingSince = DateTime.UtcNow; SayLaunchError(launchFile, "missing", wanted); }
93
99
  if ((DateTime.UtcNow - missingSince).TotalSeconds >= graceSeconds) return 3;
94
100
  Thread.Sleep(delay);
95
101
  delay = Math.Min(delay * 2, MaxDelayMs);
@@ -115,12 +121,14 @@ static class PantheonHost
115
121
  psi.EnvironmentVariables["PANTHEON_HOST_SESSION"] = session;
116
122
  using (Process p = Process.Start(psi))
117
123
  {
124
+ ClearLaunchError(launchFile);
118
125
  p.WaitForExit();
119
126
  code = p.ExitCode;
120
127
  }
121
128
  }
122
- catch (Exception)
129
+ catch (Exception e)
123
130
  {
131
+ SayLaunchError(launchFile, "start-failed", argv[0] + " (" + e.Message + ")");
124
132
  code = 1;
125
133
  }
126
134
  if (code == 0) return 0;
@@ -131,6 +139,30 @@ static class PantheonHost
131
139
  }
132
140
  }
133
141
 
142
+ // launch-error.txt beside the launch file: one line, tab-separated, of when
143
+ // (UTC, ISO 8601), what ("missing" or "start-failed") and which program.
144
+ // Best effort: a host that cannot write it still waits and retries.
145
+ static string LaunchErrorFile(string launchFile)
146
+ {
147
+ if (launchFile == null) return null;
148
+ string dir = Path.GetDirectoryName(launchFile);
149
+ return string.IsNullOrEmpty(dir) ? null : Path.Combine(dir, "launch-error.txt");
150
+ }
151
+
152
+ static void SayLaunchError(string launchFile, string what, string program)
153
+ {
154
+ string f = LaunchErrorFile(launchFile);
155
+ if (f == null) return;
156
+ try { File.WriteAllText(f, DateTime.UtcNow.ToString("o") + "\t" + what + "\t" + program + "\r\n", new UTF8Encoding(false)); } catch (Exception) { }
157
+ }
158
+
159
+ static void ClearLaunchError(string launchFile)
160
+ {
161
+ string f = LaunchErrorFile(launchFile);
162
+ if (f == null) return;
163
+ try { if (File.Exists(f)) File.Delete(f); } catch (Exception) { }
164
+ }
165
+
134
166
  // The launch file: one argument per line (UTF-8), the program first. null
135
167
  // when there is none, or it cannot be read, or it names nothing.
136
168
  static List<string> ReadLaunch(string file)
@@ -56,15 +56,27 @@
56
56
  # What it does, and nothing else:
57
57
  # 1. downloads one pinned Node release (the win-x64 or win-arm64 zip) from
58
58
  # nodejs.org, checks its sha256 against the one pinned in this file, and
59
- # unpacks it into %LOCALAPPDATA%\Pantheon\runtime\node. That Node is
59
+ # unpacks it into %USERPROFILE%\.pantheon\runtime\node. That Node is
60
60
  # Pantheon's own; a Node you already have is not touched.
61
61
  # 2. installs the Pantheon CLI with THAT Node's npm into that same folder.
62
62
  # No administrator rights, no execution-policy change, no WSL.
63
- # 3. writes %LOCALAPPDATA%\Pantheon\bin\pantheon.cmd, which runs that Node.
64
- # 4. puts %LOCALAPPDATA%\Pantheon\bin first on your user PATH, and on this
63
+ # 3. writes %USERPROFILE%\.pantheon\bin\pantheon.cmd, which runs that Node.
64
+ # 4. puts %USERPROFILE%\.pantheon\bin first on your user PATH, and on this
65
65
  # window's PATH.
66
66
  # 5. runs `pantheon <the arguments you gave>`.
67
67
  #
68
+ # An install from before 0.33.1 lives in %LOCALAPPDATA%\Pantheon instead; when
69
+ # one is there and works, it is updated where it is. New installs use the
70
+ # profile folder because of apps from the Microsoft Store or any MSIX package,
71
+ # the Claude desktop app among them: Windows keeps a NEW folder that anything
72
+ # such an app starts (its agent's shell, this script) creates in AppData
73
+ # private to that app. Pantheon then ran inside the app and nowhere else, and
74
+ # its logon task -- run by Task Scheduler, outside every app -- found no Node.
75
+ # The profile folder is never kept private that way. Run from inside such an
76
+ # app, this script also sets your PATH through a one-off task of your own
77
+ # (outside the app, deleted straight after), since Windows 10 keeps that
78
+ # registry change private to the app too.
79
+ #
68
80
  # Running it again is safe: whatever is already right is left as it is. Two
69
81
  # copies at once are safe too: the second waits for the first.
70
82
  # The relay that serves this file fills in the Pantheon version below.
@@ -129,6 +141,119 @@ function Install-Pantheon {
129
141
  [IO.File]::WriteAllText($LogFile, $r.out + $r.err)
130
142
  return $r.code
131
143
  }
144
+ # Is this PowerShell inside an app package (the Claude desktop app, any
145
+ # Microsoft Store or MSIX app)? Windows keeps a NEW folder such a process
146
+ # creates in %LOCALAPPDATA% private to that app, under
147
+ # %LOCALAPPDATA%\Packages\<package>\LocalCache\Local. There is no variable
148
+ # that says so, and asking Windows needs native code; so it is asked the way
149
+ # the redirection answers: a new folder with a file in it, looked for in each
150
+ # package's private copy, then removed. The package's folder name, or ''
151
+ # (not in one, or it cannot tell).
152
+ function Get-PantheonAppPackage([string]$Local) {
153
+ if (-not $Local) { return '' }
154
+ $name = 'pantheon-probe-' + [Guid]::NewGuid().ToString('N').Substring(0, 12)
155
+ $dir = Join-Path $Local $name
156
+ $found = ''
157
+ try {
158
+ New-Item -ItemType Directory -Force -Path $dir | Out-Null
159
+ [IO.File]::WriteAllText((Join-Path $dir 'probe'), '1')
160
+ $pk = Join-Path $Local 'Packages'
161
+ if (Test-Path -LiteralPath $pk) {
162
+ foreach ($f in @(Get-ChildItem -LiteralPath $pk -Directory -Force -ErrorAction SilentlyContinue)) {
163
+ if (Test-Path -LiteralPath ([IO.Path]::Combine($f.FullName, 'LocalCache', 'Local', $name, 'probe'))) { $found = $f.Name; break }
164
+ }
165
+ }
166
+ } catch { $found = '' }
167
+ finally { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue }
168
+ return $found
169
+ }
170
+
171
+ # Where this install goes. Run inside an app package ($Package): <profile>\.pantheon,
172
+ # the one place such an app writes that every program sees, Task Scheduler
173
+ # included. Otherwise %LOCALAPPDATA%\Pantheon, as before 0.33.1 (AppData\Local
174
+ # never roams with an enterprise profile; the profile folder does). Either way
175
+ # an install already in one of them is updated where it is, so a machine that
176
+ # works keeps working the same way -- except an AppData install that exists
177
+ # only inside this app's package, which is never reused: it is what kept the
178
+ # background host from ever starting.
179
+ # @{ root; oldPrivate }: oldPrivate when such an app-only install was passed over.
180
+ function Select-PantheonRoot([string]$ProfileDir, [string]$Local, [string]$Package) {
181
+ $current = [IO.Path]::Combine($ProfileDir, '.pantheon')
182
+ $mine = @{ root = $current; oldPrivate = $false }
183
+ if (Test-Path -LiteralPath ([IO.Path]::Combine($current, 'runtime', 'node', 'node.exe'))) { return $mine }
184
+ if (-not $Local) { return $mine }
185
+ # Its Node only in this app's private copy: passed over (and said), whatever else of it is there.
186
+ if ($Package -and (Test-Path -LiteralPath ([IO.Path]::Combine($Local, 'Packages', $Package, 'LocalCache', 'Local', 'Pantheon', 'runtime', 'node', 'node.exe')))) { return @{ root = $current; oldPrivate = $true } }
187
+ $legacy = [IO.Path]::Combine($Local, 'Pantheon')
188
+ $there = (Test-Path -LiteralPath ([IO.Path]::Combine($legacy, 'runtime', 'node', 'node.exe'))) -and (Test-Path -LiteralPath ([IO.Path]::Combine($legacy, 'bin', 'pantheon.cmd')))
189
+ if ($there -or -not $Package) { return @{ root = $legacy; oldPrivate = $false } }
190
+ return $mine
191
+ }
192
+
193
+ # The user PATH, set from OUTSIDE this app's package. Inside one, Windows 10
194
+ # keeps HKCU changes private to the app (Windows 11 lets this key through),
195
+ # so a terminal opened from the Start menu would not find pantheon. Task
196
+ # Scheduler runs a task outside every package: a one-off task of this user's
197
+ # makes the same edit as the installer does below (this folder first, $Drop
198
+ # taken out, every other entry as it was, %VARS% unexpanded), writes how it
199
+ # went to a file, and is deleted. Run the way the logon task's PowerShell
200
+ # fallback runs (-Command, so no execution policy applies, nothing encoded).
201
+ # '' when it worked; otherwise why.
202
+ function Set-PantheonPathOutside([string]$BinDir, [string[]]$Drop, [string]$WorkDir, [string]$Schtasks, [string]$PowerShellExe, [string]$UserId) {
203
+ $tag = [Guid]::NewGuid().ToString('N').Substring(0, 8)
204
+ $result = Join-Path $WorkDir "path-$tag.result"
205
+ $xmlFile = Join-Path $WorkDir "path-$tag.xml"
206
+ $name = "Pantheon-Path-$tag"
207
+ # PowerShell reads the typographic single quotes as quotes too: each doubled, like '.
208
+ $q = { param($v) "'" + ([string]$v -replace "['\u2018\u2019\u201A\u201B]", '$0$0') + "'" }
209
+ $dropList = (@($Drop | Where-Object { $_ }) | ForEach-Object { & $q $_ }) -join ','
210
+ $inner = "`$ErrorActionPreference = 'Stop'; try { `$bin = $(& $q $BinDir); `$drop = @($dropList); " +
211
+ "`$same = { param(`$a, `$b) ([Environment]::ExpandEnvironmentVariables(`$a).TrimEnd([char]92)) -ieq ([Environment]::ExpandEnvironmentVariables(`$b).TrimEnd([char]92)) }; " +
212
+ "`$key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment'); " +
213
+ "try { `$raw = [string]`$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); " +
214
+ "`$parts = @(`$raw -split ';' | Where-Object { `$_ -ne '' }); " +
215
+ "`$rest = @(`$parts | Where-Object { `$p = `$_; -not (& `$same `$p `$bin) -and @(`$drop | Where-Object { & `$same `$p `$_ }).Count -eq 0 }); " +
216
+ "`$new = (@(`$bin) + `$rest) -join ';'; " +
217
+ "if (`$new -ne `$raw) { `$key.SetValue('Path', `$new, [Microsoft.Win32.RegistryValueKind]::ExpandString); [Environment]::SetEnvironmentVariable('PANTHEON_PATH_UPDATED', '1', 'User'); [Environment]::SetEnvironmentVariable('PANTHEON_PATH_UPDATED', `$null, 'User') } " +
218
+ "} finally { `$key.Close() }; [IO.File]::WriteAllText($(& $q $result), 'ok') } catch { [IO.File]::WriteAllText($(& $q $result), 'error: ' + `$_.Exception.Message) }"
219
+ $x = { param($v) [Security.SecurityElement]::Escape([string]$v) }
220
+ $taskArgs = '-NoProfile -NonInteractive -WindowStyle Hidden -Command "& { ' + $inner + ' }"'
221
+ $xml = (@(
222
+ '<?xml version="1.0" encoding="UTF-16"?>',
223
+ '<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">',
224
+ ' <RegistrationInfo><Description>Pantheon installer: puts Pantheon on your PATH once; deleted straight after.</Description></RegistrationInfo>',
225
+ (' <Principals><Principal id="Author"><UserId>' + (& $x $UserId) + '</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal></Principals>'),
226
+ ' <Settings><MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy><DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries><StopIfGoingOnBatteries>false</StopIfGoingOnBatteries><AllowHardTerminate>true</AllowHardTerminate><StartWhenAvailable>false</StartWhenAvailable><RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable><AllowStartOnDemand>true</AllowStartOnDemand><Enabled>true</Enabled><Hidden>true</Hidden><RunOnlyIfIdle>false</RunOnlyIfIdle><WakeToRun>false</WakeToRun><ExecutionTimeLimit>PT2M</ExecutionTimeLimit><Priority>7</Priority></Settings>',
227
+ (' <Actions Context="Author"><Exec><Command>' + (& $x $PowerShellExe) + '</Command><Arguments>' + (& $x $taskArgs) + '</Arguments></Exec></Actions>'),
228
+ '</Task>') -join "`r`n")
229
+ # One left by an installer that was stopped while it waited: gone first.
230
+ try {
231
+ $listed = Invoke-Quiet $Schtasks @('/Query', '/FO', 'CSV', '/NH')
232
+ foreach ($line in ($listed.out -split "`r?`n")) {
233
+ $m = [regex]::Match($line, '^"\\(Pantheon-Path-[0-9a-f]{8})"')
234
+ if ($m.Success) { $null = Invoke-Quiet $Schtasks @('/Delete', '/TN', $m.Groups[1].Value, '/F') }
235
+ }
236
+ } catch { }
237
+ try {
238
+ New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
239
+ [IO.File]::WriteAllText($xmlFile, $xml, [Text.Encoding]::Unicode)
240
+ $c = Invoke-Quiet $Schtasks @('/Create', '/TN', $name, '/XML', $xmlFile, '/F')
241
+ if ($c.code -ne 0) { return "Task Scheduler would not take the task ($(($c.err + $c.out).Trim()))" }
242
+ try {
243
+ $r = Invoke-Quiet $Schtasks @('/Run', '/TN', $name)
244
+ if ($r.code -ne 0) { return "Task Scheduler would not start the task ($(($r.err + $r.out).Trim()))" }
245
+ for ($i = 0; $i -lt 60 -and -not (Test-Path -LiteralPath $result); $i++) { Start-Sleep -Milliseconds 500 }
246
+ if (-not (Test-Path -LiteralPath $result)) { return 'the task did not finish within 30 seconds' }
247
+ $said = ([IO.File]::ReadAllText($result)).Trim()
248
+ if ($said -eq 'ok') { return '' }
249
+ return $said
250
+ } finally { $null = Invoke-Quiet $Schtasks @('/Delete', '/TN', $name, '/F') }
251
+ } catch { return $_.Exception.Message }
252
+ finally {
253
+ Remove-Item -LiteralPath $xmlFile, $result -Force -ErrorAction SilentlyContinue
254
+ }
255
+ }
256
+
132
257
  # This user's logon task: "Pantheon-<SID>" (src/win-host.ts taskNameFor), and
133
258
  # "Pantheon", the name builds before 0.33.0 gave it.
134
259
  function Get-PantheonTaskNames {
@@ -419,8 +544,26 @@ function Install-Pantheon {
419
544
 
420
545
  $localAppData = [Environment]::GetFolderPath('LocalApplicationData')
421
546
  if (-not $localAppData) { $localAppData = $env:LOCALAPPDATA }
422
- if (-not $localAppData) { Fail 'LOCALAPPDATA is not set, so there is nowhere to install. Run this from your own Windows account.' }
423
- $root = Join-Path $localAppData 'Pantheon'
547
+ $profileDir = [Environment]::GetFolderPath('UserProfile')
548
+ if (-not $profileDir) { $profileDir = $env:USERPROFILE }
549
+ if (-not $profileDir) { Fail 'USERPROFILE is not set, so there is nowhere to install. Run this from your own Windows account.' }
550
+ # Inside an app package (the Claude desktop app): said once, and acted on below.
551
+ $appPackage = Get-PantheonAppPackage $localAppData
552
+ $appName = if ($appPackage -match '^Claude_') { 'the Claude desktop app' } elseif ($appPackage) { (($appPackage -replace '_[A-Za-z0-9]+$', '') -replace '^[A-Za-z0-9]+\.', '') } else { '' }
553
+ $where = Select-PantheonRoot $profileDir $localAppData $appPackage
554
+ $root = $where.root
555
+ # A release before 0.33.1 does not know ~\.pantheon (it would not find itself to
556
+ # upgrade, and its hooks would name a bare node): never put one there. This can
557
+ # only be asked for when the relay names an older CLI than this script (npm late).
558
+ $baseVersion = ($PantheonVersion -split '-')[0]
559
+ $isProfileRoot = $root -ieq [IO.Path]::Combine($profileDir, '.pantheon')
560
+ $tooOld = $false
561
+ try { $tooOld = [version]$baseVersion -lt [version]'0.33.1' } catch { $tooOld = $false }
562
+ if ($isProfileRoot -and $tooOld) { Fail "Pantheon $PantheonVersion cannot go in $root (that needs 0.33.1 or newer), and this is all the relay offers right now. Try again in a few minutes." }
563
+ $legacyBin = if ($localAppData) { Join-Path $localAppData 'Pantheon\bin' } else { '' }
564
+ $dropFromPath = @()
565
+ if ($legacyBin -and -not ($root -ieq (Join-Path $localAppData 'Pantheon'))) { $dropFromPath = @($legacyBin) }
566
+ if ($where.oldPrivate) { Note "the Pantheon in $(Join-Path $localAppData 'Pantheon') exists only inside $appName (Windows keeps what an app like it installs into AppData private to it), so its background worker could never start. Installing into $root instead, which every program sees." }
424
567
  $runtime = Join-Path $root 'runtime'
425
568
  $nodeDir = Join-Path $runtime 'node'
426
569
  $nodeExe = Join-Path $nodeDir 'node.exe'
@@ -491,8 +634,11 @@ function Install-Pantheon {
491
634
 
492
635
  # ---- 1. Pantheon's own Node --------------------------------------------
493
636
 
637
+ # Whole means node.exe AND its npm: an uninstall that ran while something
638
+ # still held node.exe (an agent window) removes the rest of the folder
639
+ # around it, and a node.exe with no npm beside it cannot install the CLI.
494
640
  $haveNode = ''
495
- if (Test-Path -LiteralPath $nodeExe) { try { $haveNode = (& $nodeExe --version) 2>$null } catch { $haveNode = '' } }
641
+ if ((Test-Path -LiteralPath $nodeExe) -and (Test-Path -LiteralPath $npmCli)) { try { $haveNode = (& $nodeExe --version) 2>$null } catch { $haveNode = '' } }
496
642
  if ($haveNode -eq "v$NodeVersion") {
497
643
  Say "Node v$NodeVersion for Pantheon is already in $nodeDir."
498
644
  } else {
@@ -689,8 +835,9 @@ function Install-Pantheon {
689
835
  try {
690
836
  $raw = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
691
837
  $parts = @($raw -split ';' | Where-Object { $_ -ne '' })
692
- if ($parts.Count -eq 0 -or -not (& $same $parts[0] $binDir)) {
693
- $rest = @($parts | Where-Object { -not (& $same $_ $binDir) })
838
+ $stale = @($parts | Where-Object { $p = $_; @($dropFromPath | Where-Object { & $same $p $_ }).Count -gt 0 })
839
+ if ($parts.Count -eq 0 -or -not (& $same $parts[0] $binDir) -or $stale.Count -gt 0) {
840
+ $rest = @($parts | Where-Object { $p = $_; -not (& $same $p $binDir) -and @($dropFromPath | Where-Object { & $same $p $_ }).Count -eq 0 })
694
841
  $key.SetValue('Path', ((@($binDir) + $rest) -join ';'), [Microsoft.Win32.RegistryValueKind]::ExpandString)
695
842
  # SetEnvironmentVariable(..., 'User') is what tells Explorer and new
696
843
  # terminals that the user environment changed (WM_SETTINGCHANGE).
@@ -699,6 +846,17 @@ function Install-Pantheon {
699
846
  Say "Put $binDir first on your user PATH."
700
847
  }
701
848
  } finally { $key.Close() }
849
+ # Inside an app package, that change may have stayed inside the app (Windows
850
+ # 10 keeps HKCU writes private to it): made again from outside it.
851
+ if ($appPackage) {
852
+ $State.step = "adding Pantheon to your PATH from outside $appName"
853
+ $meName = ''
854
+ try { $meName = [Security.Principal.WindowsIdentity]::GetCurrent().Name } catch { $meName = "$env:USERDOMAIN\$env:USERNAME" }
855
+ $why = Set-PantheonPathOutside -BinDir $binDir -Drop $dropFromPath -WorkDir $runtime `
856
+ -Schtasks (Join-Path $env:SystemRoot 'System32\schtasks.exe') -PowerShellExe (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -UserId $meName
857
+ if ($why) { Note "this is running inside $appName, and Pantheon could not add $binDir to your PATH from outside it ($why). In a PowerShell window opened from the Start menu, pantheon may not be found by name; $launcher always works." }
858
+ else { Say "Put $binDir on your user PATH from outside $appName, so terminals opened from the Start menu find pantheon too." }
859
+ }
702
860
  # This window: the bin folder first, and the runtime's own folder (added
703
861
  # for npm above) taken off again, as no new terminal will have it.
704
862
  $env:Path = (@($binDir) + @($env:Path -split ';' | Where-Object { $_ -ne '' -and -not (& $same $_ $binDir) -and -not (& $same $_ $nodeDir) })) -join ';'
@@ -715,12 +873,58 @@ function Install-Pantheon {
715
873
  $State.lock = $null
716
874
  # The background host stopped for the swap starts again, on the new Node.
717
875
  Start-PantheonTasks $State
876
+ # A background service set up before (pantheon service install) is pointed
877
+ # at THIS install: its logon task may still name a Node that is gone, or one
878
+ # only an app package could see (why 0.33.1 moved Pantheon out of AppData).
879
+ # `service start` keeps each service's own settings (its folder, its flags),
880
+ # registers the task again for this Node, starts it, and waits until the host
881
+ # says it is up. One the person paused stays paused.
882
+ foreach ($k in @('worker', 'notifier')) {
883
+ if (-not (Test-Path -LiteralPath (Join-Path $hostDir "$k.json"))) { continue }
884
+ if (Test-Path -LiteralPath (Join-Path $hostDir "$k.disabled")) { continue }
885
+ $State.step = "starting Pantheon's background $k on this install"
886
+ $r = Invoke-Quiet $nodeExe ($ca + @($entry, 'service', 'start', $k, '--keep-tray-quit'))
887
+ $said = (($r.err + "`n" + $r.out) -split "`r?`n" | Where-Object { $_.Trim() -and $_ -notmatch '^pantheon \d' } | Select-Object -Last 2) -join ' '
888
+ if ($r.code -eq 0) { Say "Pantheon's background $k runs on this install." }
889
+ else { Note "Pantheon's background $k did not start on this install: $($said.Trim()). Run: pantheon service start $k" }
890
+ }
891
+ # Someone already signed in here (a reinstall, or moving off an install only
892
+ # an app could see): the agents' Pantheon entries and hooks name a Node by
893
+ # its full path, so they are pointed at this one -- what `pantheon upgrade`
894
+ # does after every upgrade.
895
+ $member = ''
896
+ $cfgFile = Join-Path $pantheonHomeDir 'config.json'
897
+ if (Test-Path -LiteralPath $cfgFile) {
898
+ try { $member = [string]([IO.File]::ReadAllText($cfgFile) | ConvertFrom-Json).member } catch { $member = '' }
899
+ $State.step = 'pointing your coding agents at this install'
900
+ $w = Invoke-Quiet $nodeExe ($ca + @($entry, 'rewire', '--direct', '--json'))
901
+ # Windows PowerShell 5.1 hands a JSON array on as ONE object: enumerated
902
+ # with foreach, so each row is its own, on 5.1 and 7 alike.
903
+ $rows = @()
904
+ try {
905
+ $json = $w.out -split "`r?`n" | Where-Object { $_.Trim().StartsWith('[') } | Select-Object -Last 1
906
+ if ($json) { $parsed = ConvertFrom-Json $json; foreach ($x in $parsed) { $rows += , $x } }
907
+ } catch { $rows = @() }
908
+ # What was actually pointed here, said from what rewire reported, never assumed.
909
+ $bad = @($rows | Where-Object { $_.status -match '^failed' })
910
+ $done = @($rows | Where-Object { $_.status -notmatch '^(failed|skipped|unchanged)' -and $_.tool -notmatch 'perms$' } | ForEach-Object { $_.tool })
911
+ foreach ($r in $rows) { if ($r.tool -eq 'hooks' -and $r.status -match '(Codex runs a changed hook.*|pantheon hook install there.*)$') { Note "hooks: $($Matches[1])" } }
912
+ if ($w.code -ne 0 -or $rows.Count -eq 0) { Note 'your coding agents could not be pointed at this install; run: pantheon setup' }
913
+ elseif ($bad.Count) { Note ("these could not be pointed at this install: " + (($bad | ForEach-Object { "$($_.tool) ($($_.status))" }) -join '; ') + '. Run: pantheon setup') }
914
+ elseif ($done.Count) { Say "Your coding agents now use this install: $($done -join ', ') (restart any agent window that was open)." }
915
+ else { Say 'Your coding agents already use this install.' }
916
+ }
718
917
  $State.step = 'starting pantheon'
719
918
  if ($Arguments.Count -eq 0) {
720
919
  Say ''
721
- Say "Pantheon $PantheonVersion is installed. Open a new terminal, then:"
722
- Say ' pantheon start create a team'
723
- Say " pantheon join '<invite link>' join a teammate's team"
920
+ if ($member) {
921
+ Say "Pantheon $PantheonVersion is installed, and you are still signed in as $member -- nothing to rejoin."
922
+ Say 'Open a new terminal (or restart your agent) and carry on; pantheon doctor checks everything.'
923
+ } else {
924
+ Say "Pantheon $PantheonVersion is installed. Open a new terminal, then:"
925
+ Say ' pantheon start create a team'
926
+ Say " pantheon join '<invite link>' join a teammate's team"
927
+ }
724
928
  $State.code = 0
725
929
  return
726
930
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.33.0",
3
+ "version": "0.33.1-rc.1",
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": {