@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/cli.js CHANGED
@@ -9,24 +9,25 @@ process.emitWarning = ((warning, ...rest) => {
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { gitConfigValue, suggestUsername } from "./onboard-defaults.js";
11
11
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
12
- import { basename, dirname, join, resolve } from "node:path";
12
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
13
13
  import { PantheonClient } from "./client.js";
14
14
  import { agentSpawn, CmdLineUnsafe } from "./which.js";
15
15
  import { fromInvite, looksLikeInviteCode, resolveInviteCode, load, save, toInvite, toJoinLink, resolveFor, loadAll, identity, repoInfo, bindRepo, bindFolder, unbindRepo, findMembership, setDefaultSpace, removeMembership, configFor, CONFIG_DIR, CONFIG_PATH, DEFAULT_RELAY, resolveForHook } from "./config.js";
16
16
  import { fmtLimit } from "./plans.js";
17
17
  import { validEmail, validUsername } from "./accounts.js";
18
- import { install, launcher, installForUser, windowsRepoOverrides } from "./install.js";
18
+ import { install, launcher, installForUser, windowsRepoOverrides, agentAsksOnce } from "./install.js";
19
19
  import { Relay } from "./relay.js";
20
20
  import { runMcp } from "./mcp.js";
21
21
  import { Worker } from "./worker.js";
22
22
  import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, runNotifiers, sendDesktopNotification } from "./notifier.js";
23
+ import { linuxDesktopReachable } from "./notice-linux.js";
23
24
  import { desktopUnavailable, hostEnv } from "./host-env.js";
24
25
  import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, teamProfile, wakeDefault, wakeMode } from "./profile.js";
25
26
  import { runGuide, terminalIO, questionOrNull, parseSetupFlags, questionnaire, optOutsFrom } from "./setup-guide.js";
26
27
  import { selectActs, formatAct, resolveRef } from "./history-view.js";
27
28
  import { doctor, render, shareBlock } from "./doctor.js";
28
29
  import { FEEDBACK_ADDRESS } from "./feedback.js";
29
- import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile } from "./machine.js";
30
+ import { menubarProc, notifierProc, relayProc, workerProc, wiring, logFileFor, dataSummary, stopProc, statFile, elapsed, windowsProcessInfo } from "./machine.js";
30
31
  import { assess } from "./untrusted.js";
31
32
  import { report as spendReport, recentRuns } from "./spend.js";
32
33
  import { renderBrief, renderTeam } from "./brief.js";
@@ -38,7 +39,9 @@ import { removeHook, decideStop, writeIntent, clearIntent, readIntent, installHo
38
39
  import { pending, render as renderPending } from "./pending.js";
39
40
  import { signedRead } from "./reads.js";
40
41
  import { start as qsStart, join as qsJoin, wireUp, teamNameFor, deviceLabel } from "./quickstart.js";
41
- import { installService, uninstallService } from "./service.js";
42
+ import { folderRefusalHere, isTemporaryRefusal } from "./folder-guard.js";
43
+ import { installService, serviceInstalled, serviceLogFile, uninstallService } from "./service.js";
44
+ import { LEGACY_TASK_NAME, hostAlive, hostFiles, readHostState, recordedTaskName } from "./win-host.js";
42
45
  import { ACTS, LIMITS } from "./protocol.js";
43
46
  import { loadPersonas, personaByName, describePersona, initPersona, personaDir } from "./persona.js";
44
47
  import { fingerprint, newKeypair, signAccount } from "./identity.js";
@@ -47,7 +50,8 @@ import { connectFailure, signInHint } from "./refusal.js";
47
50
  import * as mcpWindows from "./mcp-windows.js";
48
51
  import { updateBanner } from "./update-check.js";
49
52
  import { inWokenRun } from "./worker-policy.js";
50
- import { joinInstructions, managedLayout, relayServesInstaller } from "./managed.js";
53
+ import { childNodeFlags, joinInstructions, managedLayout, relayServesInstaller } from "./managed.js";
54
+ import { spawn } from "node:child_process";
51
55
  const argv = process.argv.slice(2);
52
56
  for (let i = argv.length - 1; i >= 1; i--) {
53
57
  const m = /^(--[a-z][a-z0-9-]*)=(.*)$/i.exec(argv[i]);
@@ -822,24 +826,10 @@ function printReady(tools) {
822
826
  console.error(D(` While you're away, a teammate you approve to wake this machine can hand work to it —`));
823
827
  if (workerOn)
824
828
  console.error(D(` isolated branch, no secrets in the worktree. Pause any time from the menu bar.`));
825
- const oneTime = new Map();
826
- for (const t of tools) {
827
- if (/unavailable|not found/i.test(t.status))
828
- continue;
829
- if (/claude/i.test(t.tool))
830
- oneTime.set("claude", `claude say yes once if it asks to trust this folder`);
831
- if (/cursor/i.test(t.tool))
832
- oneTime.set("cursor", `cursor enable the "pantheon" MCP server if it asks`);
833
- if (/codex/i.test(t.tool))
834
- oneTime.set("codex", `codex run /hooks inside codex once to approve the wake-up hook`);
835
- if (/kiro/i.test(t.tool))
836
- oneTime.set("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`);
837
- if (/copilot/i.test(t.tool))
838
- oneTime.set("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`);
839
- }
840
- if (oneTime.size) {
829
+ const oneTime = agentAsksOnce(tools);
830
+ if (oneTime.length) {
841
831
  console.error(`\n${B("Your agents may each ask once")} ${D("(their own security, not ours — one yes each):")}`);
842
- for (const l of oneTime.values())
832
+ for (const l of oneTime)
843
833
  console.error(D(` ${l}`));
844
834
  }
845
835
  }
@@ -909,18 +899,23 @@ ${B("control")}
909
899
  pantheon persona how a woken worker should behave: list / init <name> / show <name>
910
900
  pantheon notifier alert you when a task requires your decision
911
901
  pantheon menubar the menu bar item: status at a glance, pause/resume, decisions
912
- pantheon sessions your devices & active sessions; revoke <id> to sign one out
902
+ pantheon tray Windows: show the tray icon again after you quit it (it also returns at your next logon)
903
+ pantheon sessions your devices & active sessions; revoke <id> to sign one out
913
904
  pantheon logout sign this machine out · pantheon account delete remove your account
914
905
  pantheon audit [--space X] the log: who did what, when (--json to export, --since <hours>)
915
906
  pantheon profile show, create (init), or set your agent and attention profile
916
907
  pantheon directory organization roles and decision authority
917
908
  pantheon approvals decisions waiting on you; show or answer one
909
+ pantheon board your board in the browser, on any OS: what needs you, what your agents are on, and act on it
910
+ (--open start or reuse it and open it · --url print its address
911
+ · --watch the same lanes in this terminal, for SSH · --keep never stop when idle · --stop)
918
912
  pantheon stop [relay|worker|notifier|menubar|all] stop it
919
913
  pantheon hook install your agent keeps working when a peer is waiting on it
920
914
  (auto-detects claude / codex / cursor / copilot / kiro on this machine)
921
915
  pantheon spend woken runs on this machine, and what pantheon cannot see
922
916
  pantheon leave remove what pantheon put on this machine
923
- pantheon uninstall leave, then remove Pantheon itself: its own Node, the pantheon command, the PATH lines
917
+ pantheon uninstall leave, then remove Pantheon itself: its own Node, the pantheon command, the PATH lines,
918
+ and all it keeps in ~/.pantheon (files of yours there stay)
924
919
  pantheon panic emergency stop: ends the run in flight and the worker, and switches off wake rights in every team you are in
925
920
  pantheon doctor [--share] check this machine end to end; --share prints it without names or paths, to paste to us
926
921
  pantheon network [--proxy <url> | --direct | --clear] how this machine reaches the relay; save a proxy for every Pantheon process here
@@ -930,6 +925,7 @@ ${B("control")}
930
925
  (--questions prints them for an agent to ask you; answers go back as flags,
931
926
  to setup --yes or straight to start / join)
932
927
  pantheon upgrade pull the latest pantheon, rebuild, and re-wire your agents
928
+ pantheon upgrade <version> install exactly that release (e.g. to try one before your team does)
933
929
 
934
930
  ${D("more: pantheon help --all")}
935
931
  `;
@@ -938,6 +934,7 @@ ${B("less common")}
938
934
  pantheon relay run only the relay, in this terminal
939
935
  pantheon service install [relay|worker|notifier|menubar] | restart | uninstall
940
936
  run a long-lived component in the background, from login
937
+ (Windows: service start <worker|notifier> resumes one stopped with pantheon stop)
941
938
  pantheon members the full list, with key fingerprints
942
939
  pantheon pending what a live agent window has not yet seen
943
940
  pantheon goal open "<title>" --with <who> open a goal to work in (goal list · goal close <id> "<summary>")
@@ -951,7 +948,32 @@ ${B("less common")}
951
948
  pantheon limits the guard values in force
952
949
  pantheon version the pantheon version this machine is running
953
950
  `;
954
- const QUIET = new Set(["mcp", "hook", "pending", "version", "tray-status"]);
951
+ let workerAlerts = null;
952
+ let workerAlertsSaid = false;
953
+ function notifyFromWorker(n) {
954
+ if (process.platform === "linux") {
955
+ if (!workerAlerts || Date.now() - workerAlerts.at > 60_000)
956
+ workerAlerts = { at: Date.now(), ok: linuxDesktopReachable() };
957
+ if (!workerAlerts.ok) {
958
+ if (!workerAlertsSaid) {
959
+ workerAlertsSaid = true;
960
+ console.error(D("alerts: none — no graphical session on this machine; run notices wait in pantheon brief and pantheon board --watch"));
961
+ }
962
+ return;
963
+ }
964
+ }
965
+ if (process.platform !== "win32") {
966
+ sendDesktopNotification(n);
967
+ return;
968
+ }
969
+ const c = spawn(process.execPath, [...childNodeFlags(), process.argv[1], "notice-show"], {
970
+ env: { ...process.env, PANTHEON_NOTICE_B64: Buffer.from(JSON.stringify(n), "utf8").toString("base64") },
971
+ detached: true, stdio: "ignore", windowsHide: true,
972
+ });
973
+ c.on("error", () => { });
974
+ c.unref();
975
+ }
976
+ const QUIET = new Set(["mcp", "hook", "pending", "version", "tray-status", "handle-uri", "notice-show"]);
955
977
  function usageFor(command, help) {
956
978
  const plain = help.replace(/\x1b\[[0-9;]*m/g, "").split("\n");
957
979
  const out = [];
@@ -990,6 +1012,7 @@ const COMMAND_FLAGS = {
990
1012
  signup: "--email <address> --username <name> --relay <url> --label <device name>",
991
1013
  audit: "--space <team> --since <hours> --json",
992
1014
  setup: "--questions (and every answer as a flag, with --yes)",
1015
+ board: "--open --url --watch --keep --stop",
993
1016
  };
994
1017
  const HELP_OPAQUE = new Set(["mcp", "hook", "pending", "tray-status", "help", "version", "-v", "--help", "-h"]);
995
1018
  function rememberWorkingProxy() {
@@ -1016,7 +1039,7 @@ async function teamStanding(all, m) {
1016
1039
  return { others: null, founder: false };
1017
1040
  }
1018
1041
  finally {
1019
- c.close();
1042
+ c.dispose();
1020
1043
  }
1021
1044
  }
1022
1045
  function slipDistance(a, b) {
@@ -1031,13 +1054,13 @@ function slipDistance(a, b) {
1031
1054
  }
1032
1055
  return d[a.length][b.length];
1033
1056
  }
1034
- const EXTRA_FLAGS = "--agent-cmd --approve-all --ask-before-sending --child --code --data --device --display --force --help --host --idle --keywords --local " +
1035
- "--no-continue --no-guide --no-notify --path --peek --poll --portable --publish --queued --quiet --remind --scratch --session --surface --token --unattended " +
1057
+ const EXTRA_FLAGS = "--action-id --agent-cmd --approve-all --ask-before-sending --child --code --data --device --display --force --help --host --idle --keywords --local " +
1058
+ "--no-browser --no-continue --no-guide --no-notify --only-action --path --peek --poll --portable --publish --queued --quiet --remind --scratch --serve --session --surface --token --unattended " +
1036
1059
  "--until --user --version --all --json " +
1037
1060
  "--name --title --wake --commands --unsandboxed --alerts --decides --playbook --base --responsibilities --human-required --agent-can-decide --capabilities";
1038
1061
  const KNOWN_FLAGS = new Set([...`${Object.values(COMMAND_FLAGS).join(" ")} ${EXTRA_FLAGS}`.matchAll(/--([a-z][a-z0-9-]*)/g)].map((m) => m[1]));
1039
1062
  function refuseUnknownFlags() {
1040
- if (["mcp", "hook", "tray-status", "_menubar"].includes(cmd))
1063
+ if (["mcp", "hook", "tray-status", "_menubar", "handle-uri"].includes(cmd))
1041
1064
  return;
1042
1065
  for (const a of argv.slice(1)) {
1043
1066
  if (!/^--[a-z][a-z0-9-]*(?:=\S*)?$/i.test(a))
@@ -1058,7 +1081,7 @@ async function main() {
1058
1081
  await installNetwork();
1059
1082
  }
1060
1083
  catch { }
1061
- if (!QUIET.has(cmd)) {
1084
+ if (!QUIET.has(cmd) && cmd !== "uninstall") {
1062
1085
  try {
1063
1086
  console.error(D(updateBanner(VERSION)));
1064
1087
  }
@@ -1522,14 +1545,28 @@ async function main() {
1522
1545
  }
1523
1546
  case "upgrade": {
1524
1547
  const { upgrade } = await import("./upgrade.js");
1525
- console.error(`${B("pantheon upgrade")}\n`);
1526
- const r = upgrade();
1548
+ const asked = argv[1] && !argv[1].startsWith("-") ? argv[1] : undefined;
1549
+ if (asked) {
1550
+ const { isVersion } = await import("./installer.js");
1551
+ if (!isVersion(asked))
1552
+ die(`usage: pantheon upgrade [version] ("${asked}" is not a version, e.g. 0.32.2)`);
1553
+ }
1554
+ console.error(`${B("pantheon upgrade")}${asked ? ` ${asked}` : ""}\n`);
1555
+ const r = upgrade(undefined, asked ? { version: asked, exact: true } : {});
1527
1556
  for (const s of r.steps)
1528
1557
  console.error(` ${s.ok ? C("ok") : "\x1b[31mFAIL\x1b[0m"} ${s.name.padEnd(6)} ${s.detail}`);
1529
1558
  if (!r.ok) {
1530
1559
  console.error(`\n${B("upgrade stopped")} — nothing was changed.${r.needsPerson ? "" : " Fix the above and run `pantheon upgrade` again."}`);
1531
1560
  process.exit(1);
1532
1561
  }
1562
+ if (r.alreadyCurrent && r.steps.some((x) => /another pantheon upgrade is running/.test(x.detail))) {
1563
+ console.error(`\n${B("nothing installed")} — another upgrade is running on this machine (most likely the worker's own). Try again in a minute.`);
1564
+ process.exit(1);
1565
+ }
1566
+ if (asked && r.alreadyCurrent && r.steps.some((x) => /not on npm yet/.test(x.detail))) {
1567
+ console.error(`\n${B("nothing installed")} — npm has no ${asked}. Check the version (\`npm view @join-pantheon/cli versions\`).`);
1568
+ process.exit(1);
1569
+ }
1533
1570
  if (r.alreadyCurrent) {
1534
1571
  console.error(`\n${B("already up to date")} ${D(`(${r.from})`)}`);
1535
1572
  if (process.platform === "darwin") {
@@ -1998,14 +2035,26 @@ async function main() {
1998
2035
  console.log(` ${D("start it with: pantheon start")}`);
1999
2036
  }
2000
2037
  if (worker.running) {
2001
- console.log(` worker ${C("running")} ${worker.managedBy === "launchd" ? `service ${worker.label}` : "started by hand"} pid ${worker.pid}`);
2038
+ console.log(` worker ${C("running")} ${worker.managedBy === "launchd" ? `service ${worker.label}` : worker.managedBy === "host" ? `background host (${worker.label})` : "started by hand"} pid ${worker.pid}`);
2002
2039
  console.log(` ${D("a teammate approved on this machine (pantheon wake list) can start an agent run here")}`);
2003
2040
  }
2004
2041
  else {
2005
- console.log(` worker ${D("not running")} ${D("nobody can start an agent run on this machine")}`);
2042
+ const paused = worker.managedBy === "host" && existsSync(hostFiles().disabled("worker"));
2043
+ console.log(` worker ${D(paused ? "paused" : "not running")} ${D(paused ? "pantheon service start worker resumes it" : "nobody can start an agent run on this machine")}`);
2006
2044
  }
2007
2045
  const menubar = menubarProc();
2008
- if (menubar.running) {
2046
+ if (process.platform === "win32") {
2047
+ const st = readHostState();
2048
+ if (hostAlive(st)) {
2049
+ console.log(` host ${C("running")} task ${recordedTaskName() ?? LEGACY_TASK_NAME} pid ${st.pid} up ${elapsed(Date.now() - st.started)}`);
2050
+ const tray = st.children.tray;
2051
+ const quit = existsSync(hostFiles().disabled("tray"));
2052
+ 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
+ }
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")}`);
2056
+ }
2057
+ else if (menubar.running) {
2009
2058
  console.log(` menu bar ${C("running")} ${menubar.managedBy === "launchd" ? `service ${menubar.label}` : "started by hand"} pid ${menubar.pid}`);
2010
2059
  }
2011
2060
  else {
@@ -2013,7 +2062,7 @@ async function main() {
2013
2062
  console.log(` menu bar ${D("not running")} ${D(why ?? "pantheon service install menubar — status and pause/resume in the menu bar")}`);
2014
2063
  }
2015
2064
  if (notifier.running) {
2016
- console.log(` notifier ${C("running")} ${notifier.managedBy === "launchd" ? `service ${notifier.label}` : "started by hand"} pid ${notifier.pid}`);
2065
+ console.log(` notifier ${C("running")} ${notifier.managedBy === "launchd" || notifier.managedBy === "systemd" ? `service ${notifier.label}` : notifier.managedBy === "host" ? `background host (${notifier.label})` : "started by hand"} pid ${notifier.pid}`);
2017
2066
  console.log(` ${D("native alerts are on for decisions that require you")}`);
2018
2067
  }
2019
2068
  else {
@@ -2093,7 +2142,7 @@ async function main() {
2093
2142
  const proc = kind === "relay" ? relayProc(Number(flag("port", "8787"))) : kind === "worker" ? workerProc() : notifierProc();
2094
2143
  const f = logFileFor(kind, proc);
2095
2144
  if (!f)
2096
- die(`no ${kind} log found. ${process.platform === "linux" ? `If it runs as a service: journalctl --user -u pantheon-${kind}` : process.platform === "darwin" ? "If it runs as a service, the plist names the path" : "Services are not used on Windows"}; if you started it by hand, its output went to that terminal.`);
2145
+ die(`no ${kind} log found. ${process.platform === "linux" ? `If it runs as a service: journalctl --user -u pantheon-${kind}` : process.platform === "darwin" ? "If it runs as a service, the plist names the path" : `The Pantheon host writes it to ${serviceLogFile(kind)} once it has started it (its own log: ${hostFiles().log})`}; if you started it by hand, its output went to that terminal.`);
2097
2146
  console.error(D(`${f} (${statFile(f)})\n`));
2098
2147
  const follow = argv.includes("-f") || argv.includes("--follow");
2099
2148
  const { tailLines, followFile } = await import("./machine.js");
@@ -2459,7 +2508,8 @@ async function main() {
2459
2508
  if (uninstalling && !argv.includes("--yes")) {
2460
2509
  const teams = plan.teams.length && !keepIdentity ? `leaves ${plan.teams.map((t) => `"${t.name}"`).join(", ")} (this machine's key goes), ` : "";
2461
2510
  const what = `this removes Pantheon from this machine: it ${teams}uninstalls its services, hooks and agent entries` +
2462
- (install?.present ? `, and deletes Pantheon's own Node in ${install.runtime}, the pantheon command and the PATH lines the installer added.` : ".");
2511
+ (install?.present ? `, and deletes Pantheon's own Node in ${install.runtime}, the pantheon command and the PATH lines the installer added` : "") +
2512
+ `. Everything Pantheon keeps in ${CONFIG_DIR} goes${keepIdentity ? " (but your identity, --keep-identity)" : ""}; files of your own there stay.`;
2463
2513
  if (!process.stdin.isTTY)
2464
2514
  die(`${what}\n If you mean it: pantheon uninstall --yes`);
2465
2515
  const rl = createInterface({ input: process.stdin, output: process.stderr });
@@ -2501,53 +2551,64 @@ async function main() {
2501
2551
  console.error(` perms:${l}`);
2502
2552
  removed.push(l.trim().replace(/^removed /, ""));
2503
2553
  }
2554
+ if (process.platform === "win32") {
2555
+ const { unregisterUriHandler, realRegIo } = await import("./uri-handler.js");
2556
+ const { borrowedProfile } = await import("./service.js");
2557
+ const u = unregisterUriHandler(realRegIo(() => borrowedProfile()));
2558
+ removed.push(...u.removed);
2559
+ left.push(...u.left);
2560
+ }
2504
2561
  const { unlinkSync, existsSync: exists, rmSync, readdirSync } = await import("node:fs");
2505
- if (all && exists(CONFIG_PATH)) {
2506
- if (keepIdentity) {
2507
- left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2508
- }
2509
- else {
2562
+ const spare = [];
2563
+ const hasConfig = !!all && exists(CONFIG_PATH);
2564
+ if (hasConfig && keepIdentity) {
2565
+ left.push(`your identity and ${plan.teams.length} team membership${plan.teams.length === 1 ? "" : "s"}, ${CONFIG_PATH} (--keep-identity)`);
2566
+ }
2567
+ else if (!keepIdentity && (hasConfig || uninstalling)) {
2568
+ if (hasConfig) {
2510
2569
  unlinkSync(CONFIG_PATH);
2511
2570
  removed.push(`${CONFIG_PATH} — your key and ${plan.teams.length ? `the token${plan.teams.length === 1 ? "" : "s"} for ${plan.teams.map((t) => `"${t.name}"`).join(", ")}` : "account"}; rejoining means a new invite`);
2512
- const runHomes = join(CONFIG_PATH, "..", "run-homes");
2513
- const { restorePendingQuarantines, pendingQuarantines } = await import("./agents.js");
2514
- const put = restorePendingQuarantines(runHomes);
2515
- if (put.length)
2516
- removed.push(`nothing of your repos' — what interrupted runs had held aside: ${put.join("; ")}`);
2517
- const still = pendingQuarantines(runHomes);
2518
- const keptAside = (dir) => { try {
2519
- return readdirSync(join(dir, "quarantine-kept")).length > 0;
2520
- }
2521
- catch {
2522
- return false;
2523
- } };
2524
- let homes = [];
2571
+ }
2572
+ const runHomes = join(CONFIG_PATH, "..", "run-homes");
2573
+ const { restorePendingQuarantines, pendingQuarantines } = await import("./agents.js");
2574
+ const put = restorePendingQuarantines(runHomes);
2575
+ if (put.length)
2576
+ removed.push(`nothing of your repos' — what interrupted runs had held aside: ${put.join("; ")}`);
2577
+ const still = pendingQuarantines(runHomes);
2578
+ const keptAside = (dir) => { try {
2579
+ return readdirSync(join(dir, "quarantine-kept")).length > 0;
2580
+ }
2581
+ catch {
2582
+ return false;
2583
+ } };
2584
+ let homes = [];
2585
+ try {
2586
+ homes = readdirSync(runHomes);
2587
+ }
2588
+ catch { }
2589
+ const keep = new Set([...still.map((x) => x.runHome), ...homes.map((h) => join(runHomes, h)).filter(keptAside)]);
2590
+ for (const h of homes) {
2591
+ const dir = join(runHomes, h);
2592
+ if (keep.has(dir))
2593
+ continue;
2525
2594
  try {
2526
- homes = readdirSync(runHomes);
2595
+ rmSync(dir, { recursive: true, force: true });
2527
2596
  }
2528
2597
  catch { }
2529
- const keep = new Set([...still.map((x) => x.runHome), ...homes.map((h) => join(runHomes, h)).filter(keptAside)]);
2530
- for (const h of homes) {
2531
- const dir = join(runHomes, h);
2532
- if (keep.has(dir))
2533
- continue;
2534
- try {
2535
- rmSync(dir, { recursive: true, force: true });
2536
- }
2537
- catch { }
2538
- }
2539
- if (!keep.size) {
2540
- try {
2541
- rmSync(runHomes, { recursive: true, force: true });
2542
- }
2543
- catch { }
2598
+ }
2599
+ if (!keep.size) {
2600
+ try {
2601
+ rmSync(runHomes, { recursive: true, force: true });
2544
2602
  }
2545
- for (const x of still)
2546
- left.push(`${x.runHome} — it still holds ${x.paths.join(", ")} from ${x.worktree}, which could not be put back safely; compare and move them back by hand, then delete the folder`);
2547
- for (const dir of keep)
2548
- if (keptAside(dir) && !still.some((x) => x.runHome === dir))
2549
- left.push(`${join(dir, "quarantine-kept")} — agent config an interrupted run held, set aside because the repo had moved on; check it, then delete the folder`);
2603
+ catch { }
2550
2604
  }
2605
+ else
2606
+ spare.push(runHomes);
2607
+ for (const x of still)
2608
+ left.push(`${x.runHome} — it still holds ${x.paths.join(", ")} from ${x.worktree}, which could not be put back safely; compare and move them back by hand, then delete the folder`);
2609
+ for (const dir of keep)
2610
+ if (keptAside(dir) && !still.some((x) => x.runHome === dir))
2611
+ left.push(`${join(dir, "quarantine-kept")} — agent config an interrupted run held, set aside because the repo had moved on; check it, then delete the folder`);
2551
2612
  }
2552
2613
  {
2553
2614
  const { removePantheonWorktrees, removeHomeLeftovers } = await import("./machine.js");
@@ -2571,6 +2632,10 @@ async function main() {
2571
2632
  }
2572
2633
  if (!runningManaged)
2573
2634
  left.push(`this pantheon itself (${process.argv[1] ?? "the one you ran"}), which npm installed — remove it with: ${NPM_UNINSTALL}`);
2635
+ const { sweepPantheonHome } = await import("./machine.js");
2636
+ const swept = sweepPantheonHome(CONFIG_DIR, { keepIdentity, spare });
2637
+ removed.push(...swept.removed);
2638
+ left.push(...swept.left);
2574
2639
  }
2575
2640
  console.error(`\n${B("Removed")}`);
2576
2641
  for (const r of removed)
@@ -3418,6 +3483,64 @@ async function main() {
3418
3483
  console.log(`\n${D("Change it: pantheon policy grant|revoke self-upgrade|run-commands|unsandboxed-runs · personas: pantheon persona init · who may wake this machine: pantheon wake allow|deny <who>")}`);
3419
3484
  return;
3420
3485
  }
3486
+ case "notice-show": {
3487
+ const raw = process.env.PANTHEON_NOTICE_B64;
3488
+ if (!raw)
3489
+ return;
3490
+ try {
3491
+ sendDesktopNotification(JSON.parse(Buffer.from(raw, "base64").toString("utf8")));
3492
+ }
3493
+ catch { }
3494
+ return;
3495
+ }
3496
+ case "handle-uri": {
3497
+ const { handleUri, parseHandleUriArgs, logUri } = await import("./uri-handler.js");
3498
+ const { spawn, spawnSync } = await import("node:child_process");
3499
+ const { childNodeFlags } = await import("./managed.js");
3500
+ const { statSync } = await import("node:fs");
3501
+ const self = (args) => [...childNodeFlags(), process.argv[1], ...args];
3502
+ const hasBoard = /\n\s*pantheon board\b/.test(HELP_ALL);
3503
+ const parsed = parseHandleUriArgs(argv.slice(1), isAbsolute, (p) => resolve(p));
3504
+ const isDir = (p) => { try {
3505
+ return statSync(p).isDirectory();
3506
+ }
3507
+ catch {
3508
+ return false;
3509
+ } };
3510
+ if ("error" in parsed || (parsed.home && !isDir(parsed.home))) {
3511
+ const why = "error" in parsed ? parsed.error : `its --home (${parsed.home}) is not a folder on this machine`;
3512
+ console.log(`Pantheon did not act on this link: ${why}`);
3513
+ logUri(CONFIG_DIR, `refused (${why})`);
3514
+ process.exit(2);
3515
+ }
3516
+ const uriHome = parsed.home ?? CONFIG_DIR;
3517
+ const childEnv = parsed.home ? { ...process.env, PANTHEON_HOME: parsed.home } : process.env;
3518
+ const code = await handleUri(parsed.rest, {
3519
+ home: uriHome,
3520
+ wokenRun: inWokenRun(),
3521
+ runQueued: (actionId) => spawnSync(process.execPath, self(["explain", "--queued", "--only-action", "--action-id", actionId]), { stdio: "inherit", env: childEnv }).status ?? 1,
3522
+ openBoard: hasBoard ? () => new Promise((done) => {
3523
+ const c = spawn(process.execPath, self(["board", "--open"]), { detached: true, stdio: "ignore", windowsHide: true, env: childEnv });
3524
+ const t = setTimeout(() => { c.unref(); done(true); }, 4_000);
3525
+ c.on("error", () => { clearTimeout(t); done(false); });
3526
+ c.on("exit", (status) => { clearTimeout(t); done(status === 0); });
3527
+ }) : null,
3528
+ runBrief: () => spawnSync(process.execPath, self(["brief"]), { stdio: "inherit", env: childEnv }).status ?? 1,
3529
+ print: (l) => console.log(l),
3530
+ pause: async () => {
3531
+ if (!process.stdin.isTTY)
3532
+ return;
3533
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
3534
+ try {
3535
+ await questionOrNull(rl, D("\npress Enter to close "));
3536
+ }
3537
+ finally {
3538
+ rl.close();
3539
+ }
3540
+ },
3541
+ });
3542
+ process.exit(code);
3543
+ }
3421
3544
  case "explain": {
3422
3545
  const { readFileSync, rmSync } = await import("node:fs");
3423
3546
  const CONFIG_DIR = dirname(CONFIG_PATH);
@@ -3428,18 +3551,48 @@ async function main() {
3428
3551
  refuse("a woken run cannot act for the menu bar — a person has to click it");
3429
3552
  const f = join(CONFIG_DIR, "bin", ".hud-action.json");
3430
3553
  const { statSync } = await import("node:fs");
3554
+ const want = flag("action-id");
3555
+ if (want !== undefined && !/^[0-9a-f]{16,64}$/.test(want))
3556
+ refuse("that is not an action id");
3431
3557
  let st = null;
3432
3558
  let raw = null;
3433
3559
  try {
3434
3560
  st = statSync(f);
3435
3561
  raw = readFileSync(f, "utf8");
3436
- rmSync(f, { force: true });
3437
3562
  }
3438
3563
  catch {
3439
3564
  st = null;
3440
3565
  raw = null;
3441
3566
  }
3567
+ if (raw !== null) {
3568
+ let nonce;
3569
+ try {
3570
+ nonce = JSON.parse(raw).nonce;
3571
+ }
3572
+ catch {
3573
+ nonce = undefined;
3574
+ }
3575
+ if (want !== undefined && nonce !== want)
3576
+ refuse("another click replaced that action before it ran — nothing was sent from here; click it again");
3577
+ if (want === undefined && typeof nonce === "string")
3578
+ refuse("another Pantheon window is sending an action right now — click it again in a moment");
3579
+ }
3580
+ if (raw !== null) {
3581
+ try {
3582
+ rmSync(f, { force: true });
3583
+ }
3584
+ catch { }
3585
+ }
3442
3586
  if (st && raw !== null) {
3587
+ let parsed = null;
3588
+ try {
3589
+ parsed = JSON.parse(raw);
3590
+ }
3591
+ catch {
3592
+ parsed = null;
3593
+ }
3594
+ if (want !== undefined && parsed && typeof parsed.nonce === "string" && /^[0-9a-f]{16,64}$/.test(parsed.nonce))
3595
+ console.log(`action ${parsed.nonce}`);
3443
3596
  const age = Date.now() - st.mtimeMs;
3444
3597
  const { runInFlight } = await import("./worker.js");
3445
3598
  const run = runInFlight(CONFIG_DIR);
@@ -3447,13 +3600,10 @@ async function main() {
3447
3600
  refuse(`that menu-bar action was written ${Math.round(age / 1000)}s ago — click it again`);
3448
3601
  if (run && st.mtimeMs >= run.at - 1_000)
3449
3602
  refuse("that menu-bar action was written while a woken run was working on this machine, so it is not trusted — click it again once the run is done");
3450
- try {
3451
- hud = JSON.parse(raw);
3452
- }
3453
- catch {
3454
- hud = null;
3455
- }
3603
+ hud = parsed;
3456
3604
  }
3605
+ if (argv.includes("--only-action") && !hud)
3606
+ refuse("that action was already taken or is no longer there — click it again");
3457
3607
  }
3458
3608
  const cfg = (() => {
3459
3609
  if (!hud?.space)
@@ -3477,7 +3627,7 @@ async function main() {
3477
3627
  const { pickExplainAgent, decisionPrompt: dp, allDecisionsPrompt: adp, decisionTitle: dt, sessionName } = await import("./decision-ui.js");
3478
3628
  const { loadProfile } = await import("./profile.js");
3479
3629
  const { detectAvailable, launchArgs } = await import("./agents.js");
3480
- let id = argv.find((a, i) => i > 0 && !a.startsWith("--"));
3630
+ let id = argv.find((a, i) => i > 0 && !a.startsWith("--") && argv[i - 1] !== "--action-id");
3481
3631
  if (argv.includes("--queued")) {
3482
3632
  const queued = join(CONFIG_DIR, "bin", ".explain-id");
3483
3633
  try {
@@ -3978,7 +4128,7 @@ async function main() {
3978
4128
  once: argv.includes("--once"),
3979
4129
  ...(argv.includes("--no-notify") ? {} : {
3980
4130
  notify: (n) => { try {
3981
- sendDesktopNotification(n);
4131
+ notifyFromWorker(n);
3982
4132
  }
3983
4133
  catch { } },
3984
4134
  }),
@@ -4009,7 +4159,7 @@ async function main() {
4009
4159
  once: argv.includes("--once"),
4010
4160
  ...(argv.includes("--no-notify") ? {} : {
4011
4161
  notify: (n) => { try {
4012
- sendDesktopNotification(n);
4162
+ notifyFromWorker(n);
4013
4163
  }
4014
4164
  catch { } },
4015
4165
  }),
@@ -4024,6 +4174,58 @@ async function main() {
4024
4174
  await worker.run();
4025
4175
  return;
4026
4176
  }
4177
+ case "board": {
4178
+ const b = await import("./board.js");
4179
+ if (inWokenRun())
4180
+ die("a woken run cannot open the board — a person has to run this here");
4181
+ if (argv.includes("--watch")) {
4182
+ await b.watchBoard();
4183
+ return;
4184
+ }
4185
+ if (argv.includes("--serve")) {
4186
+ await b.serveBoard({ keep: argv.includes("--keep") });
4187
+ process.exit(0);
4188
+ }
4189
+ if (argv.includes("--stop")) {
4190
+ const s = await b.runningBoard();
4191
+ if (!s) {
4192
+ console.error(D("no board is running"));
4193
+ return;
4194
+ }
4195
+ await b.boardPost(s, "/api/stop").catch(() => null);
4196
+ console.error(D("the board is stopped"));
4197
+ return;
4198
+ }
4199
+ const r = await b.ensureBoard({ keep: argv.includes("--keep") }).catch((e) => die(e.message));
4200
+ if (argv.includes("--url")) {
4201
+ console.log(r.url);
4202
+ console.error(D(" good once, within 30 seconds — pantheon board --url makes another"));
4203
+ if (!argv.includes("--open"))
4204
+ return;
4205
+ }
4206
+ const url = argv.includes("--url") ? await b.boardLinkFor(r.state).catch((e) => die(e.message)) : r.url;
4207
+ const opened = argv.includes("--no-browser") ? false : await b.openBrowser(url).catch(() => false);
4208
+ console.error(`${B("pantheon board")} ${D(r.reused ? "(already running)" : "(started)")}`);
4209
+ if (!opened)
4210
+ console.error(` open this in your browser within 30 seconds (it works once): ${C(url)}`);
4211
+ else
4212
+ console.error(D(" opened in your browser · pantheon board --url prints its address · --watch shows it in this terminal"));
4213
+ console.error(D(r.state.keep ? " it stays running" : " it stops itself after 30 minutes with no open tab"));
4214
+ return;
4215
+ }
4216
+ case "tray": {
4217
+ if (process.platform !== "win32")
4218
+ die(process.platform === "darwin" ? "the tray icon is Windows'; on macOS the menu bar is: pantheon menubar" : "the tray icon is Windows'; on Linux: pantheon board");
4219
+ const { showTray } = await import("./service.js");
4220
+ try {
4221
+ for (const l of showTray())
4222
+ console.error(l);
4223
+ }
4224
+ catch (e) {
4225
+ die(e.message);
4226
+ }
4227
+ return;
4228
+ }
4027
4229
  case "tray-status": {
4028
4230
  const { trayStatus } = await import("./tray.js");
4029
4231
  console.log(JSON.stringify(await trayStatus()));
@@ -4100,6 +4302,19 @@ async function main() {
4100
4302
  await new Promise((r) => child.on("close", () => r()));
4101
4303
  return;
4102
4304
  }
4305
+ case "host": {
4306
+ const { runHost, provenOrphan } = await import("./win-host.js");
4307
+ const { childNodeFlags, managedLauncher } = await import("./managed.js");
4308
+ const layout = managedLayout();
4309
+ runHost({
4310
+ node: process.execPath,
4311
+ flags: childNodeFlags(),
4312
+ managed: layout ? { pkgDir: layout.pkgDir, prevDir: layout.prevDir } : null,
4313
+ ...(managedLauncher() ? { pantheonCommand: managedLauncher() } : {}),
4314
+ isOurs: (pid, r) => process.platform === "win32" && provenOrphan(r, windowsProcessInfo(pid)),
4315
+ });
4316
+ return;
4317
+ }
4103
4318
  case "notifier": {
4104
4319
  const nf = loadAll();
4105
4320
  const teams = nf ? Object.values(nf.spaces ?? {}).map((m) => configFor(nf, m, null)) : [];
@@ -4110,6 +4325,15 @@ async function main() {
4110
4325
  log: (line) => console.error(cfgs.length > 1 ? `[${c.space}] ${line}` : line),
4111
4326
  }));
4112
4327
  console.error(`${B("pantheon notifier")} ${D(`(${cfgs.length === 1 ? "" : cfgs.length + " teams · "}ctrl-c to stop)`)}`);
4328
+ if (process.platform === "win32") {
4329
+ try {
4330
+ const { ensureUriHandler } = await import("./notice-windows.js");
4331
+ console.error(D(` ${ensureUriHandler().line}`));
4332
+ }
4333
+ catch (e) {
4334
+ console.error(D(` pantheon: links not registered (${e.message.split("\n")[0]})`));
4335
+ }
4336
+ }
4113
4337
  const stopAll = () => { for (const n of notifiers)
4114
4338
  n.stop(); process.exit(0); };
4115
4339
  process.on("SIGINT", stopAll);
@@ -4148,6 +4372,17 @@ async function main() {
4148
4372
  console.error(l);
4149
4373
  return;
4150
4374
  }
4375
+ if (sub === "start" && process.platform === "win32" && (kind === "worker" || kind === "notifier")) {
4376
+ const { enableService } = await import("./service.js");
4377
+ try {
4378
+ for (const l of enableService(kind))
4379
+ console.error(l);
4380
+ }
4381
+ catch (e) {
4382
+ die(e.message);
4383
+ }
4384
+ return;
4385
+ }
4151
4386
  if (kind === "relay") {
4152
4387
  const data = resolveDataDir(flag("data")).dir;
4153
4388
  const port = flag("port", "8787");
@@ -4184,12 +4419,30 @@ async function main() {
4184
4419
  if (flag("remind"))
4185
4420
  extraN.push("--remind", flag("remind"));
4186
4421
  console.error(`${B("installing human notifications as a background service")}`);
4187
- try {
4188
- const binary = ensureNativeNotifier();
4189
- console.error(D(` actionable HUD ${binary}`));
4422
+ if (process.platform === "linux") {
4423
+ const { linuxGraphicalSession } = await import("./host-env.js");
4424
+ if (!linuxGraphicalSession(process.env))
4425
+ console.error(D(" no graphical session in this shell (SSH or headless?) — alerts appear once you are logged in to the desktop; pantheon doctor says which way they are shown"));
4426
+ let lines = [];
4427
+ try {
4428
+ lines = installService(kind, homedir(), extraN);
4429
+ }
4430
+ catch (e) {
4431
+ die(e.message);
4432
+ }
4433
+ for (const l of lines)
4434
+ console.error(l);
4435
+ console.error(`\n${D("Alerts come from your desktop's own notifications. Done answers a decision from the alert; a click opens the board. No agent run or wake permission is involved.")}`);
4436
+ return;
4190
4437
  }
4191
- catch (e) {
4192
- die(`could not build the native notification helper: ${e.message}`);
4438
+ if (process.platform !== "win32") {
4439
+ try {
4440
+ const binary = ensureNativeNotifier();
4441
+ console.error(D(` actionable HUD ${binary}`));
4442
+ }
4443
+ catch (e) {
4444
+ die(`could not build the native notification helper: ${e.message}`);
4445
+ }
4193
4446
  }
4194
4447
  for (const l of installService(kind, homedir(), extraN))
4195
4448
  console.error(l);
@@ -4266,15 +4519,12 @@ async function main() {
4266
4519
  if (dirname(d) === d)
4267
4520
  break;
4268
4521
  }
4269
- const real = (d) => { try {
4270
- return realpathSync.native(d);
4271
- }
4272
- catch {
4273
- return resolve(d);
4274
- } };
4275
- const here = real(process.cwd());
4276
- if ((here === real(homedir()) || dirname(here) === here) && !argv.includes("--yes")) {
4277
- die(`this is ${dirname(here) === here ? "the root of the disk" : "your home folder"}: binding it puts every folder under it in "${m.name}", and an agent opened anywhere there would speak for that team.\n Bind a project folder instead, or if you mean it: pantheon use ${m.name} --here --yes`);
4522
+ const refused = folderRefusalHere(process.cwd());
4523
+ if (isTemporaryRefusal(refused))
4524
+ die(`${refused.why.replace("<team>", m.name)}.`);
4525
+ if (refused && ["root", "home", "above-home"].includes(refused.kind) && !argv.includes("--yes")) {
4526
+ const what = refused.kind === "root" ? "the root of the disk" : refused.kind === "home" ? "your home folder" : "a folder that holds your home folder";
4527
+ die(`this is ${what}: binding it puts every folder under it in "${m.name}", and an agent opened anywhere there would speak for that team.\n Bind a project folder instead, or if you mean it: pantheon use ${m.name} --here --yes`);
4278
4528
  }
4279
4529
  if ((await import("./repo-find.js")).holdsRepo(process.cwd())) {
4280
4530
  const n = await bindReposIn(process.cwd(), all, m, argv.includes("--yes"), `pantheon use ${m.name} --here --yes`);