@join-pantheon/cli 0.32.1 → 0.33.0-rc.2

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.
package/dist/client.js CHANGED
@@ -21,6 +21,7 @@ export class PantheonClient {
21
21
  seen = new Map();
22
22
  backoff = 500;
23
23
  closed = false;
24
+ disposed = false;
24
25
  everConnected = false;
25
26
  localOverride = null;
26
27
  usingLoopback = false;
@@ -170,6 +171,11 @@ export class PantheonClient {
170
171
  }
171
172
  attempt(address) {
172
173
  return new Promise((resolve, reject) => {
174
+ if (this.disposed) {
175
+ this.closed = true;
176
+ reject(new Error("this client was disposed"));
177
+ return;
178
+ }
173
179
  let settled = false;
174
180
  let heard = false;
175
181
  const fail = reject;
@@ -195,6 +201,8 @@ export class PantheonClient {
195
201
  this.watchdog.unref?.();
196
202
  ws.on("open", () => { });
197
203
  ws.on("message", (raw) => {
204
+ if (this.disposed)
205
+ return;
198
206
  this.lastInbound = Date.now();
199
207
  let msg;
200
208
  try {
@@ -438,6 +446,14 @@ export class PantheonClient {
438
446
  this.waiters = [];
439
447
  this.ws?.close(4000, "handover");
440
448
  }
449
+ dispose() {
450
+ this.disposed = true;
451
+ this.close();
452
+ if (this.watchdog) {
453
+ clearInterval(this.watchdog);
454
+ this.watchdog = null;
455
+ }
456
+ }
441
457
  close() {
442
458
  this.closed = true;
443
459
  for (const w of this.waiters) {
@@ -5,6 +5,7 @@ import { chooseAgent, claudeSandboxable, NOT_FOR_UNATTENDED_RUNS, parseRepoComma
5
5
  import { detectClaudeFeatures } from "./claude-confine.js";
6
6
  import { loadPersonas } from "./persona.js";
7
7
  import { plistPath, existingUnitPath } from "./service.js";
8
+ import { readDefinition } from "./win-host.js";
8
9
  import { loadPolicy, unsandboxedAllowed } from "./worker-policy.js";
9
10
  export function workerServiceAllowsBash(plat = platform()) {
10
11
  try {
@@ -17,7 +18,7 @@ export function workerServiceAllowsBash(plat = platform()) {
17
18
  return args.includes("--allow-bash");
18
19
  }
19
20
  if (plat === "win32")
20
- return null;
21
+ return readDefinition("worker")?.args.includes("--allow-bash") ?? null;
21
22
  const u = existingUnitPath("worker");
22
23
  if (!u)
23
24
  return null;
package/dist/dbus.js CHANGED
@@ -193,6 +193,7 @@ export function encodeMessage(m) {
193
193
  f(FIELD.error_name, "s", m.error_name);
194
194
  f(FIELD.reply_serial, "u", m.reply_serial);
195
195
  f(FIELD.destination, "s", m.destination);
196
+ f(FIELD.sender, "s", m.sender);
196
197
  f(FIELD.signature, "g", m.signature || undefined);
197
198
  const head = new Writer();
198
199
  head.byte("l".charCodeAt(0));
@@ -363,6 +364,32 @@ export class DBus {
363
364
  onClose(h) { this.closedHandlers.push(h); }
364
365
  close() { this.sock?.end(); this.sock = null; }
365
366
  }
367
+ export async function portalOpenUri(bus, uri, waitMs = 3_000) {
368
+ const REQ = "org.freedesktop.portal.Request";
369
+ await bus.call({ destination: "org.freedesktop.DBus", path: "/org/freedesktop/DBus", interface: "org.freedesktop.DBus", member: "AddMatch", signature: "s",
370
+ body: [`type='signal',interface='${REQ}',member='Response'`] });
371
+ const responses = new Map();
372
+ let handle = null;
373
+ let answered = null;
374
+ bus.onSignal((m) => {
375
+ if (m.interface !== REQ || m.member !== "Response" || !m.path)
376
+ return;
377
+ const code = Number(m.body[0]);
378
+ if (handle === m.path && answered)
379
+ answered(code);
380
+ else
381
+ responses.set(m.path, code);
382
+ });
383
+ const r = await bus.call({ destination: "org.freedesktop.portal.Desktop", path: "/org/freedesktop/portal/desktop", interface: "org.freedesktop.portal.OpenURI",
384
+ member: "OpenURI", signature: "ssa{sv}", body: ["", uri, []] }, 10_000);
385
+ handle = String(r.body[0] ?? "");
386
+ const early = responses.get(handle);
387
+ const code = early ?? await new Promise((done) => {
388
+ const t = setTimeout(() => done(null), waitMs);
389
+ answered = (c) => { clearTimeout(t); done(c); };
390
+ });
391
+ return code === null || code === 0 || code === 1;
392
+ }
366
393
  const N = { destination: "org.freedesktop.Notifications", path: "/org/freedesktop/Notifications", interface: "org.freedesktop.Notifications" };
367
394
  export class Notifications {
368
395
  bus;
@@ -377,33 +404,84 @@ export class Notifications {
377
404
  const n = new Notifications(b);
378
405
  await b.call({ destination: "org.freedesktop.DBus", path: "/org/freedesktop/DBus", interface: "org.freedesktop.DBus", member: "AddMatch", signature: "s",
379
406
  body: ["type='signal',interface='org.freedesktop.Notifications',path='/org/freedesktop/Notifications'"] });
407
+ await b.call({ destination: "org.freedesktop.DBus", path: "/org/freedesktop/DBus", interface: "org.freedesktop.DBus", member: "AddMatch", signature: "s",
408
+ body: [`type='signal',sender='org.freedesktop.DBus',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='${N.destination}'`] });
380
409
  b.onSignal((m) => {
381
- if (m.interface !== N.interface)
410
+ if (m.sender === "org.freedesktop.DBus" && m.interface === "org.freedesktop.DBus" && m.member === "NameOwnerChanged" && m.body[0] === N.destination) {
411
+ n.serverChanged(String(m.body[2] ?? "") || null);
382
412
  return;
383
- const id = Number(m.body[0]);
384
- if (m.member === "ActivationToken")
385
- n.tokens.set(id, String(m.body[1] ?? ""));
386
- else if (m.member === "ActionInvoked") {
387
- const tok = n.tokens.get(id);
388
- for (const h of n.actionHandlers)
389
- h(id, String(m.body[1] ?? ""), tok);
390
- }
391
- else if (m.member === "NotificationClosed") {
392
- n.tokens.delete(id);
393
- for (const h of n.closedHandlers)
394
- h(id, Number(m.body[1] ?? 0));
395
413
  }
414
+ if (m.interface !== N.interface || m.path !== N.path)
415
+ return;
416
+ void n.fromServer(m.sender).then((ok) => {
417
+ if (!ok)
418
+ return;
419
+ const id = Number(m.body[0]);
420
+ if (m.member === "ActivationToken")
421
+ n.tokens.set(id, String(m.body[1] ?? ""));
422
+ else if (m.member === "ActionInvoked") {
423
+ const tok = n.tokens.get(id);
424
+ for (const h of n.actionHandlers)
425
+ h(id, String(m.body[1] ?? ""), tok);
426
+ }
427
+ else if (m.member === "NotificationClosed") {
428
+ n.tokens.delete(id);
429
+ for (const h of n.closedHandlers)
430
+ h(id, Number(m.body[1] ?? 0));
431
+ }
432
+ });
396
433
  });
397
434
  return n;
398
435
  }
436
+ owner = null;
437
+ lookup = null;
438
+ serverChangeHandlers = [];
439
+ serverChanged(owner) {
440
+ const was = this.owner;
441
+ this.owner = owner;
442
+ if (was === owner)
443
+ return;
444
+ this.tokens.clear();
445
+ for (const h of this.serverChangeHandlers) {
446
+ try {
447
+ h();
448
+ }
449
+ catch { }
450
+ }
451
+ }
452
+ fromServer(sender) {
453
+ if (!sender)
454
+ return Promise.resolve(false);
455
+ if (sender === this.owner)
456
+ return Promise.resolve(true);
457
+ this.lookup ??= this.bus.call({ destination: "org.freedesktop.DBus", path: "/org/freedesktop/DBus", interface: "org.freedesktop.DBus", member: "GetNameOwner", signature: "s", body: [N.destination] })
458
+ .then((r) => String(r.body[0] ?? "") || null, () => null)
459
+ .finally(() => { this.lookup = null; });
460
+ return this.lookup.then((o) => {
461
+ if (o && this.owner && o !== this.owner)
462
+ this.serverChanged(o);
463
+ else if (o)
464
+ this.owner = o;
465
+ return !!o && sender === o;
466
+ });
467
+ }
468
+ onServerChange(h) { this.serverChangeHandlers.push(h); }
399
469
  async capabilities() {
400
470
  const r = await this.bus.call({ ...N, member: "GetCapabilities" });
401
471
  return r.body[0] ?? [];
402
472
  }
473
+ async serverInfo() {
474
+ const r = await this.bus.call({ ...N, member: "GetServerInformation" });
475
+ const [name = "", vendor = "", version = "", spec = ""] = r.body.map(String);
476
+ return { name, vendor, version, spec };
477
+ }
403
478
  async notify(n) {
404
479
  const hints = [["urgency", { sig: "y", value: n.urgency ?? 1 }]];
405
480
  if (n.urgency === 2)
406
481
  hints.push(["resident", { sig: "b", value: true }]);
482
+ for (const h of n.hints ?? [])
483
+ if (!hints.some(([k]) => k === h[0]))
484
+ hints.push(h);
407
485
  const r = await this.bus.call({ ...N, member: "Notify", signature: "susssasa{sv}i", body: [
408
486
  n.appName ?? "Pantheon", n.replacesId ?? 0, n.icon ?? "", n.summary, n.body ?? "",
409
487
  (n.actions ?? []).flat(), hints, n.timeoutMs ?? -1,
@@ -413,6 +491,7 @@ export class Notifications {
413
491
  async close(id) { await this.bus.call({ ...N, member: "CloseNotification", signature: "u", body: [id] }).catch(() => { }); }
414
492
  onAction(h) { this.actionHandlers.push(h); }
415
493
  onClosed(h) { this.closedHandlers.push(h); }
494
+ onDisconnect(h) { this.bus.onClose(h); }
416
495
  disconnect() { this.bus.close(); }
417
496
  }
418
497
  //# sourceMappingURL=dbus.js.map
package/dist/doctor.js CHANGED
@@ -12,6 +12,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
12
12
  import { homedir, platform } from "node:os";
13
13
  import { dirname, join, resolve, sep } from "node:path";
14
14
  import { expectedLauncher, managedLayout, runtimeNodeFit, runtimeVerdict } from "./managed.js";
15
+ import { compareVersions } from "./installer.js";
15
16
  import { PKG_NAME } from "./upgrade.js";
16
17
  import { PantheonClient } from "./client.js";
17
18
  import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
@@ -23,7 +24,8 @@ import { workerProc, menubarProc, notifierProc } from "./machine.js";
23
24
  import { agentById, chooseAgent, describeChoice, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
24
25
  import { loginShellLookup, npmSpawn, onPathNamed, which, whichWindows } from "./which.js";
25
26
  import { VERSION } from "./version.js";
26
- import { serviceInstalled, servicesUnavailable } from "./service.js";
27
+ import { realSys, serviceInstalled, servicesUnavailable } from "./service.js";
28
+ import { windowsDoctorRows, windowsFacts } from "./win-host.js";
27
29
  import { hasPrebuilt } from "./helpers-build.js";
28
30
  import { traySourcePath, TRAY_PREBUILT } from "./tray.js";
29
31
  import { hudSourcePath, HUD_PREBUILT } from "./notifier.js";
@@ -63,7 +65,7 @@ export function userScopeWiring(home = homedir(), present = onPathNamed, env = p
63
65
  };
64
66
  const server = (key) => (d) => !!d[key]?.["pantheon"];
65
67
  const out = [];
66
- if (present("claude")) {
68
+ if (present("claude") || existsSync(join(home, ".claude"))) {
67
69
  const f = join(home, ".claude.json");
68
70
  out.push({ agent: "Claude Code", file: f, state: json(f, server("mcpServers")) });
69
71
  }
@@ -176,8 +178,13 @@ export async function doctor(projectDir) {
176
178
  if (host.ephemeral) {
177
179
  add("warn", "machine", `${platformName(host.platform)} in ${host.kind === "container" ? "a container" : "a cloud workspace"} — ${host.evidence.join("; ")}`, `this is not the computer you work on, which is why it reports ${platformName(host.platform)} to someone who may be sitting at a Mac. The team and your account are fine here, but the menu bar, desktop alerts and the worker belong on your own machine — run the setup from a terminal there.`, `${host.platform} in ${host.kind === "container" ? "a container" : "a cloud workspace"}`);
178
180
  }
181
+ else if (host.platform === "win32") {
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)))
184
+ add(r.level, r.name, r.detail, r.fix, r.share);
185
+ }
179
186
  else {
180
- add("pass", "machine", `${platformName(host.platform)}${host.desktop ? " — menu bar and desktop alerts can run here" : " — no menu bar or desktop alerts here yet; ask your agent for pantheon_status, or run pantheon brief, to see what needs you"}`, undefined, `${host.platform}${host.desktop ? ", desktop" : ""}`);
187
+ add("pass", "machine", `${platformName(host.platform)}${host.desktop ? (host.platform === "linux" ? " — desktop alerts can run here (the menu bar is macOS-only; pantheon board is the view)" : " — menu bar and desktop alerts can run here") : " — no menu bar or desktop alerts here yet; ask your agent for pantheon_status, or run pantheon brief, to see what needs you"}`, undefined, `${host.platform}${host.desktop ? ", desktop" : ""}`);
181
188
  }
182
189
  let cfg = null;
183
190
  try {
@@ -386,12 +393,19 @@ export async function doctor(projectDir) {
386
393
  add("pass", "relay", `reachable at ${health}`, undefined, `reachable (${where})`);
387
394
  }
388
395
  if (body.ok || body.storage) {
389
- if (body.pkg && body.pkg !== VERSION) {
396
+ const held = typeof body.upgrades_held_at === "string" ? body.upgrades_held_at : null;
397
+ if (held && body.pkg === held && VERSION !== held && compareVersions(VERSION, held) > 0) {
398
+ add("pass", "version", `${VERSION}; the relay is holding upgrades at ${held} for now, so this machine stays where it is`, undefined, `${VERSION}, upgrades held at ${held}`);
399
+ }
400
+ else if (body.pkg && body.pkg !== VERSION) {
390
401
  add("warn", "version", `this machine is ${VERSION}, the relay is ${body.pkg}`, "run `pantheon upgrade` on the older side — features land on both halves of the wire, and the protocol version cannot tell you they are missing", `this machine ${VERSION}, relay ${body.pkg}`);
391
402
  }
392
403
  else if (body.pkg) {
393
404
  add("pass", "version", `${VERSION}, matching the relay`, undefined, `${VERSION}, matching the relay`);
394
405
  }
406
+ else if (typeof body.relay_pkg === "string") {
407
+ add("pass", "version", `${VERSION}; the relay runs ${body.relay_pkg}, a prerelease, and tells no machine to upgrade`, undefined, `relay prerelease ${body.relay_pkg}`);
408
+ }
395
409
  else {
396
410
  add("warn", "version", `this machine is ${VERSION}; the relay is too old to report its own`, "update the relay host, then re-run this", `this machine ${VERSION}; relay too old to say`);
397
411
  }
@@ -545,7 +559,24 @@ export async function doctor(projectDir) {
545
559
  add("warn", "sandbox", `${missing.join(" and ")} not found — Claude Code's sandbox cannot start, so every woken command run will be refused`, "install them: sudo apt install bubblewrap socat (Debian/Ubuntu) or sudo dnf install bubblewrap socat (Fedora)", `sandbox tools missing: ${missing.join(", ")}`);
546
560
  }
547
561
  }
548
- if (host.desktop) {
562
+ if (host.platform === "linux" && !host.ephemeral) {
563
+ const { probeLinuxAlerts } = await import("./notice-linux.js");
564
+ const probe = await probeLinuxAlerts().catch((e) => ({ mode: "none", line: `none — ${e.message.split("\n")[0]}` }));
565
+ const installedN = serviceInstalled("notifier");
566
+ if (probe.mode === "none") {
567
+ add(host.desktop || installedN ? "warn" : "pass", "alerts", probe.line, host.desktop || installedN ? "decisions still wait in pantheon board --watch and pantheon brief; on a desktop, a notification server (GNOME Shell, Plasma, dunst, mako) shows them" : undefined, "none");
568
+ }
569
+ else if (!installedN) {
570
+ add("warn", "alerts", `not installed — this desktop would show them as ${probe.line}`, "pantheon service install notifier (--no-notifications at setup also leaves it off; that is fine if it was your choice)", "not installed");
571
+ }
572
+ else if (!notifierProc().running) {
573
+ add("warn", "alerts", "installed, but not running — nothing on this desktop will show you a teammate's message", "pantheon service restart notifier (journalctl --user -u pantheon-notifier says why it stopped)", "installed, not running");
574
+ }
575
+ else {
576
+ add("pass", "alerts", probe.line, undefined, probe.mode);
577
+ }
578
+ }
579
+ if (host.desktop && host.platform === "darwin") {
549
580
  const needsTools = (name, source) => hasPrebuilt(name, source) ? "" : "needs the Xcode Command Line Tools — xcode-select --install — because this build has no prebuilt helper for it; ";
550
581
  if (!serviceInstalled("menubar")) {
551
582
  add("warn", "menu bar", "not installed on a Mac that can run it", `install it with: pantheon service install menubar (${needsTools(TRAY_PREBUILT, traySourcePath())}from a setup that ran on this computer, not in a cloud agent). --no-menubar at setup also leaves it off; that is fine if it was your choice`, "not installed");
@@ -0,0 +1,89 @@
1
+ import { realpathSync } from "node:fs";
2
+ import { homedir, platform, tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ function isOneDriveRoot(dir, f, p) {
5
+ if ((f.oneDriveRoots ?? []).some((r) => same(dir, r, p)))
6
+ return true;
7
+ const d = p.resolve(dir);
8
+ return same(p.dirname(d), f.home, p) && /^OneDrive( - .+)?$/i.test(p.basename(d));
9
+ }
10
+ export const TEMP_FOLDER_ADVICE = "open your agent in your project folder, or bind one with pantheon use <team> --here from there";
11
+ export function isInside(child, parent, p = path) {
12
+ if (!child || !parent)
13
+ return false;
14
+ const rel = p.relative(p.resolve(parent), p.resolve(child));
15
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${p.sep}`) && !p.isAbsolute(rel));
16
+ }
17
+ const same = (a, b, p) => p.relative(p.resolve(a), p.resolve(b)) === "";
18
+ export function folderRefusal(here, f, p = path) {
19
+ const dir = p.resolve(here);
20
+ if (p.dirname(dir) === dir)
21
+ return { kind: "root", why: "this is the root of the disk" };
22
+ if (same(dir, f.home, p))
23
+ return { kind: "home", why: "this is your home folder" };
24
+ if (isInside(f.home, dir, p))
25
+ return { kind: "above-home", why: "this folder holds your home folder" };
26
+ if (["Downloads", "Desktop", "Documents", "Library", "tmp"].some((n) => same(dir, p.join(f.home, n), p))) {
27
+ return { kind: "catch-all", why: `this is your ${p.basename(dir)} folder, which holds unrelated things` };
28
+ }
29
+ if (p.sep === "\\") {
30
+ if (isOneDriveRoot(dir, f, p))
31
+ return { kind: "catch-all", why: `this is your OneDrive folder, which holds everything you sync` };
32
+ if (/^(Desktop|Documents|Pictures)$/i.test(p.basename(dir)) && isOneDriveRoot(p.dirname(dir), f, p)) {
33
+ return { kind: "catch-all", why: `this is your ${p.basename(dir)} folder (in OneDrive), which holds unrelated things` };
34
+ }
35
+ const appData = p.join(f.home, "AppData");
36
+ if (same(dir, appData, p) || ["Local", "LocalLow", "Roaming"].some((n) => same(dir, p.join(appData, n), p))) {
37
+ return { kind: "catch-all", why: `this is ${p.basename(dir) === "AppData" ? "your AppData folder" : `AppData\\${p.basename(dir)}`}, where every program keeps its own data` };
38
+ }
39
+ }
40
+ const parts = dir.split(p.sep === "\\" ? /[\\/]+/ : /\/+/);
41
+ const scratch = parts.findIndex((s, i) => i > 0 && s.toLowerCase() === "scratch-workspaces" && parts[i - 1].toLowerCase() === "claude");
42
+ if (scratch > 0 || f.appDirs.some((a) => isInside(dir, a, p))) {
43
+ return { kind: "app-scratch", why: `this is a scratch workspace of the Claude app, a temporary folder that goes away; ${TEMP_FOLDER_ADVICE}` };
44
+ }
45
+ const root = f.tempRoots.find((r) => isInside(dir, r, p) && !isInside(f.home, r, p));
46
+ if (root)
47
+ return { kind: "temporary", why: `this is a temporary folder (${root}) that goes away; ${TEMP_FOLDER_ADVICE}` };
48
+ return null;
49
+ }
50
+ const real = (d) => { try {
51
+ return realpathSync.native(d);
52
+ }
53
+ catch {
54
+ return path.resolve(d);
55
+ } };
56
+ export function folderFacts(env = process.env, os = platform()) {
57
+ const home = real(homedir());
58
+ const roots = new Set();
59
+ const add = (set, d) => { if (d) {
60
+ set.add(path.resolve(d));
61
+ set.add(real(d));
62
+ } };
63
+ add(roots, tmpdir());
64
+ for (const k of ["TMPDIR", "TEMP", "TMP"])
65
+ add(roots, env[k]);
66
+ if (env["LOCALAPPDATA"])
67
+ add(roots, path.join(env["LOCALAPPDATA"], "Temp"));
68
+ if (os !== "win32")
69
+ for (const d of ["/tmp", "/private/tmp", "/var/tmp", "/private/var/tmp", "/var/folders", "/private/var/folders"])
70
+ add(roots, d);
71
+ const apps = new Set();
72
+ if (os === "darwin")
73
+ add(apps, path.join(home, "Library", "Application Support", "Claude"));
74
+ if (os === "win32") {
75
+ add(apps, path.join(env["APPDATA"] ?? path.join(home, "AppData", "Roaming"), "Claude"));
76
+ }
77
+ if (os === "linux")
78
+ add(apps, path.join(env["XDG_CONFIG_HOME"] ?? path.join(home, ".config"), "Claude"));
79
+ const oneDrive = new Set();
80
+ if (os === "win32")
81
+ for (const k of ["OneDrive", "OneDriveCommercial", "OneDriveConsumer"])
82
+ add(oneDrive, env[k]);
83
+ return { home, tempRoots: [...roots], appDirs: [...apps], ...(oneDrive.size ? { oneDriveRoots: [...oneDrive] } : {}) };
84
+ }
85
+ export function folderRefusalHere(dir) {
86
+ return folderRefusal(real(dir), folderFacts());
87
+ }
88
+ export const isTemporaryRefusal = (r) => r?.kind === "temporary" || r?.kind === "app-scratch";
89
+ //# sourceMappingURL=folder-guard.js.map
package/dist/git-safe.js CHANGED
@@ -77,7 +77,7 @@ const hasGitLfs = () => {
77
77
  if (lfsBinary !== null)
78
78
  return lfsBinary;
79
79
  try {
80
- execFileSync("git-lfs", ["version"], { stdio: "ignore", timeout: 10_000 });
80
+ execFileSync("git-lfs", ["version"], { windowsHide: true, stdio: "ignore", timeout: 10_000 });
81
81
  lfsBinary = true;
82
82
  }
83
83
  catch {
@@ -94,7 +94,7 @@ export function lfsInUse(cwd) {
94
94
  }
95
95
  catch { }
96
96
  try {
97
- return execFileSync("git", ["-c", "core.fsmonitor=false", "-c", `core.hooksPath=${noHooksDir()}`, "config", "--get", "filter.lfs.clean"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim().length > 0;
97
+ return execFileSync("git", ["-c", "core.fsmonitor=false", "-c", `core.hooksPath=${noHooksDir()}`, "config", "--get", "filter.lfs.clean"], { cwd, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim().length > 0;
98
98
  }
99
99
  catch {
100
100
  return false;
@@ -217,7 +217,7 @@ function inspect(worktree, a, snapshot = false) {
217
217
  }
218
218
  function commonOf(project) {
219
219
  try {
220
- const out = execFileSync("git", [...gitHarden(), "rev-parse", "--git-common-dir"], { cwd: project, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, timeout: 10_000 }).trim();
220
+ const out = execFileSync("git", [...gitHarden(), "rev-parse", "--git-common-dir"], { cwd: project, encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "ignore"], env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" }, timeout: 10_000 }).trim();
221
221
  return real(resolve(project, out));
222
222
  }
223
223
  catch {
package/dist/host-env.js CHANGED
@@ -34,6 +34,8 @@ export function hostEnv(env = process.env, plat = process.platform, exists = exi
34
34
  if (plat === "darwin") {
35
35
  return { platform: plat, kind: "local", evidence: [], ephemeral: false, desktop: true };
36
36
  }
37
+ if (plat === "win32")
38
+ return windowsHostEnv(env);
37
39
  const evidence = [];
38
40
  let kind = "local";
39
41
  for (const [name, label] of CLOUD_VARS) {
@@ -82,9 +84,43 @@ export function hostEnv(env = process.env, plat = process.platform, exists = exi
82
84
  kind,
83
85
  evidence,
84
86
  ephemeral: kind !== "local",
85
- desktop: false,
87
+ desktop: plat === "linux" && kind === "local" && linuxGraphicalSession(env, wsl),
86
88
  };
87
89
  }
90
+ export function windowsHostEnv(env = process.env) {
91
+ const evidence = [];
92
+ let kind = "local";
93
+ for (const [name, label] of CLOUD_VARS) {
94
+ if (env[name]) {
95
+ evidence.push(label);
96
+ kind = "cloud-workspace";
97
+ }
98
+ }
99
+ for (const [name, label] of CONTAINER_VARS)
100
+ if (env[name])
101
+ evidence.push(label);
102
+ const user = (env.USERNAME ?? "").toLowerCase();
103
+ if (user === "containeradministrator" || user === "containeruser")
104
+ evidence.push(`the user is ${env.USERNAME}, a Windows container's account`);
105
+ if (evidence.length && kind === "local")
106
+ kind = "container";
107
+ const ssh = !!(env.SSH_CONNECTION || env.SSH_CLIENT);
108
+ return { platform: "win32", kind, evidence, ephemeral: kind !== "local", desktop: kind === "local" && !ssh };
109
+ }
110
+ export function linuxGraphicalSession(env, wsl = !!env.WSL_DISTRO_NAME) {
111
+ if (wsl)
112
+ return false;
113
+ const display = env.DISPLAY ?? "";
114
+ const forwarded = /^[^:]+:/.test(display);
115
+ const ssh = !!(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
116
+ if (env.WAYLAND_DISPLAY)
117
+ return true;
118
+ if (ssh)
119
+ return !!display && !forwarded && env.XDG_SESSION_TYPE !== "tty";
120
+ if (display)
121
+ return true;
122
+ return env.XDG_SESSION_TYPE === "x11" || env.XDG_SESSION_TYPE === "wayland";
123
+ }
88
124
  export const platformName = (p) => p === "win32" ? "Windows" : p === "darwin" ? "macOS" : p === "linux" ? "Linux" : p;
89
125
  export function ephemeralWarning(host) {
90
126
  return `this looks like ${host.kind === "container" ? "a container" : "a cloud workspace"}, not the computer you work on `
@@ -93,11 +129,21 @@ export function ephemeralWarning(host) {
93
129
  + `the session. Your account and team are fine — run the same setup from a terminal on your own computer to finish it.`;
94
130
  }
95
131
  export function desktopUnavailable(host, what) {
132
+ if (what === "the menu bar" && host.platform === "linux" && !host.ephemeral)
133
+ return "not available — the menu bar is macOS-only; on Linux, pantheon board shows what needs you";
96
134
  if (host.desktop)
97
135
  return null;
136
+ if (host.platform === "linux" && !host.ephemeral && what === "desktop alerts") {
137
+ return "not available — desktop alerts need a graphical login session, and this Linux shell has none (SSH or headless); pantheon board --watch shows what needs you";
138
+ }
98
139
  if (host.ephemeral) {
99
140
  return `not available — ${what} ${what === "desktop alerts" ? "need" : "needs"} your own computer, and this is ${host.kind === "container" ? "a container" : "a cloud workspace"} reporting ${platformName(host.platform)}`;
100
141
  }
142
+ if (host.platform === "win32") {
143
+ return what === "the menu bar"
144
+ ? "not available — the menu bar is macOS's; on Windows the Pantheon tray icon runs with the background host, from a session at the desktop"
145
+ : "not available — desktop alerts need the Windows desktop, and this is a remote shell (SSH); set Pantheon up from a session at the desktop";
146
+ }
101
147
  return `not available — ${what} ${host.platform === "darwin" ? "could not be built here" : `${what === "desktop alerts" ? "are" : "is"} macOS-only, and this machine is ${platformName(host.platform)}`}`;
102
148
  }
103
149
  //# sourceMappingURL=host-env.js.map
package/dist/install.js CHANGED
@@ -12,6 +12,8 @@ import { FEEDBACK_ADDRESS } from "./feedback.js";
12
12
  import { kiroDir, kiroAgentFile, refreshKiroAgentAllowed } from "./kiro-files.js";
13
13
  import { eolOf, isJsonc, stripBom, stripJsonc, withEol } from "./text-file.js";
14
14
  import { childNodeFlags, managedLayout, managedLauncher } from "./managed.js";
15
+ import { claudeHomeConflict, claudeJsonFile, claudeUserEntryShape, mergeClaudeUserEntry, removeClaudeUserEntry } from "./claude-config.js";
16
+ import { inWokenRun } from "./worker-policy.js";
15
17
  const HERE = dirname(fileURLToPath(import.meta.url));
16
18
  export function onPath() {
17
19
  return onPathNamed("pantheon");
@@ -255,11 +257,38 @@ export function claudeAddFailed(e, command) {
255
257
  const why = said || (err.message ?? "failed").split("\n")[0];
256
258
  return `failed: claude mcp add did not work (${why}) — fix: run \`${command}\` yourself to see why, then pantheon install --user`;
257
259
  }
258
- function claudeUserScope(entry) {
260
+ export const CLAUDE_DIRECT_NOTE = "written to ~/.claude.json directly (no claude CLI here) — the Claude desktop app and Claude Code read it";
261
+ export function claudeUserScope(entry, present = true, claudeBin = which("claude"), home = homedir(), direct = false) {
259
262
  const tool = "claude code";
260
- const claudeBin = which("claude");
261
- if (!claudeBin)
262
- return { tool, where: "-", status: "skipped (claude not on PATH)" };
263
+ if (!claudeBin) {
264
+ if (!present)
265
+ return { tool, where: "-", status: "skipped (claude not on PATH)" };
266
+ const file = claudeJsonFile(home);
267
+ const conflict = claudeHomeConflict();
268
+ if (conflict)
269
+ return { tool, where: "-", status: `skipped (claude not on PATH) — ${conflict}` };
270
+ if (!direct || inWokenRun()) {
271
+ const there = (() => { try {
272
+ const d = JSON.parse(stripBom(readFileSync(file, "utf8")));
273
+ return d?.mcpServers?.["pantheon"];
274
+ }
275
+ catch {
276
+ return undefined;
277
+ } })();
278
+ return there !== undefined && JSON.stringify(there) === JSON.stringify(claudeUserEntryShape(entry))
279
+ ? { tool, where: file, status: `unchanged — ${CLAUDE_DIRECT_NOTE}` }
280
+ : { tool, where: "-", status: "skipped (claude not on PATH here) — run pantheon install --user yourself to write ~/.claude.json directly" };
281
+ }
282
+ try {
283
+ const r = mergeClaudeUserEntry(file, entry, isOurMcpEntry);
284
+ if (r === "not ours")
285
+ return { tool, where: file, status: `failed: ${file} already has an MCP server called "pantheon" that Pantheon did not write — rename or remove it, then run pantheon install --user` };
286
+ return { tool, where: file, status: `${r} — ${CLAUDE_DIRECT_NOTE}` };
287
+ }
288
+ catch (e) {
289
+ return { tool, where: file, status: `failed: ${e.message}` };
290
+ }
291
+ }
263
292
  try {
264
293
  const remove = agentSpawn(claudeBin, ["mcp", "remove", "pantheon", "-s", "user"]);
265
294
  const add = agentSpawn(claudeBin, ["mcp", "add", "pantheon", "-s", "user", "--", entry.command, ...entry.args]);
@@ -277,8 +306,11 @@ function claudeUserScope(entry) {
277
306
  function claudeLocalScope(entry, projectDir) {
278
307
  const tool = "claude code";
279
308
  const claudeBin = which("claude");
280
- if (!claudeBin)
281
- return { tool, where: "-", status: "skipped (claude not on PATH) — once Claude Code is installed, run `pantheon install` here again" };
309
+ if (!claudeBin) {
310
+ return existsSync(join(homedir(), ".claude"))
311
+ ? { tool, where: "-", status: "skipped (claude not on PATH) — the user-scope entry is in ~/.claude.json, but this folder's own entry can only be written by the claude CLI, and this repo's committed .mcp.json (which starts pantheon by a name Windows cannot run) may be read first; once claude is on PATH, run `pantheon install` here again" }
312
+ : { tool, where: "-", status: "skipped (claude not on PATH) — once Claude Code is installed, run `pantheon install` here again" };
313
+ }
282
314
  try {
283
315
  const remove = agentSpawn(claudeBin, ["mcp", "remove", "pantheon", "-s", "local"]);
284
316
  const add = agentSpawn(claudeBin, ["mcp", "add", "pantheon", "-s", "local", "--", entry.command, ...entry.args]);
@@ -686,13 +718,13 @@ export function chosenApproval() {
686
718
  return "approve";
687
719
  }
688
720
  }
689
- export function installForUser(approval = chosenApproval(), present = agentPresence()) {
721
+ export function installForUser(approval = chosenApproval(), present = agentPresence(), opts = {}) {
690
722
  const entry = userEntry();
691
723
  const said = (s) => /^(granted|already granted)/.test(s)
692
724
  ? `${s} — ${approval === "ask-before-sending" ? "reads run without asking; send, open_goal and claim prompt" : `the ${PANTHEON_TOOL_NAMES.length} pantheon tools run without asking`}`
693
725
  : s;
694
726
  return [
695
- claudeUserScope(entry), cursorUserScope(entry, present.cursor), codexUserScope(entry, approval, present.codex),
727
+ claudeUserScope(entry, present.claude, undefined, undefined, !!opts.direct), cursorUserScope(entry, present.cursor), codexUserScope(entry, approval, present.codex),
696
728
  copilotUserScope(entry), vsCodeUserScope(entry), kiroUserScope(entry, approval),
697
729
  present.claude
698
730
  ? { tool: "claude perms", where: claudeSettingsFile(), status: said(grantClaudeToolPermissions(undefined, approval)) }
@@ -702,6 +734,24 @@ export function installForUser(approval = chosenApproval(), present = agentPrese
702
734
  : { tool: "cursor perms", where: "-", status: "skipped (Cursor not found on this machine)" },
703
735
  ];
704
736
  }
737
+ export function agentAsksOnce(tools) {
738
+ const asks = [
739
+ [/^claude code$/i, "claude", `claude say yes once if it asks to trust this folder`],
740
+ [/^cursor$/i, "cursor", `cursor enable the "pantheon" MCP server if it asks`],
741
+ [/^codex$/i, "codex", `codex run /hooks inside codex once to approve the wake-up hook`],
742
+ [/^kiro$/i, "kiro", `kiro run kiro-cli chat --agent pantheon (or kiro-cli agent set-default pantheon) to keep working while a teammate waits; stay signed in — a woken run never starts Kiro signed out, since Kiro would open a browser`],
743
+ [/^copilot$/i, "copilot", `copilot say yes once when it asks to trust this folder; on a Copilot Business or Enterprise plan an org owner must have the "MCP servers in Copilot" policy on`],
744
+ ];
745
+ const out = new Map();
746
+ for (const t of tools) {
747
+ if (/^(skipped|failed|could not)|unavailable|not found/i.test(t.status))
748
+ continue;
749
+ for (const [row, key, line] of asks)
750
+ if (row.test(t.tool))
751
+ out.set(key, line);
752
+ }
753
+ return [...out.values()];
754
+ }
705
755
  export function isOurMcpEntry(e) {
706
756
  if (!e || typeof e !== "object")
707
757
  return false;
@@ -759,8 +809,24 @@ export function removeUserMcpEntries(deps = {}) {
759
809
  if (os === "win32" && deps.project)
760
810
  claudeRemove("local", `Claude Code's pantheon entry for ${deps.project} (local scope)`, deps.project);
761
811
  }
762
- else if (os === "win32" && deps.project) {
763
- left.push(`any Claude Code entry for ${deps.project} (local scope) — claude is not on PATH here; once it is: claude mcp remove pantheon -s local, run in that folder`);
812
+ else {
813
+ const file = claudeJsonFile(home);
814
+ const conflict = deps.home === undefined ? claudeHomeConflict() : null;
815
+ if (conflict)
816
+ left.push(`Claude Code's pantheon MCP server, if any — ${conflict}`);
817
+ else
818
+ try {
819
+ const r = removeClaudeUserEntry(file, isOurMcpEntry);
820
+ if (r === "removed")
821
+ removed.push(`Claude Code's pantheon MCP server (user scope, ${file})`);
822
+ else if (r === "not ours")
823
+ left.push(`an entry named "pantheon" in ${file} that Pantheon did not write — yours to keep or delete`);
824
+ }
825
+ catch (e) {
826
+ left.push(`Claude Code's pantheon MCP server (${e.message}) — delete the "pantheon" server under "mcpServers" in ${file} by hand, with Claude closed`);
827
+ }
828
+ if (os === "win32" && deps.project)
829
+ left.push(`any Claude Code entry for ${deps.project} (local scope) — claude is not on PATH here; once it is: claude mcp remove pantheon -s local, run in that folder`);
764
830
  }
765
831
  const files = [
766
832
  ["Cursor", join(home, ".cursor", "mcp.json"), "mcpServers"],
package/dist/installer.js CHANGED
@@ -29,8 +29,9 @@ export function installerScript(name, dir = INSTALLER_DIR, version = VERSION) {
29
29
  export const NPM_PACKAGE = "@join-pantheon/cli";
30
30
  export const NPM_PACKUMENT_URL = "https://registry.npmjs.org/@join-pantheon%2fcli";
31
31
  export const INSTALLER_MIN_CLI = "0.32.0";
32
+ export const isVersion = (v) => /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(v);
32
33
  function parseVer(v) {
33
- const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v);
34
+ const m = isVersion(v) ? /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(v) : null;
34
35
  return m ? { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null } : null;
35
36
  }
36
37
  export function compareVersions(a, b) {