@join-pantheon/cli 0.33.1 → 0.33.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.
Files changed (44) hide show
  1. package/README.md +6 -5
  2. package/dist/accounts.js +10 -0
  3. package/dist/agents.js +11 -0
  4. package/dist/board.js +55 -9
  5. package/dist/claude-confine.js +1 -1
  6. package/dist/cli.js +556 -89
  7. package/dist/client.js +8 -4
  8. package/dist/config.js +18 -4
  9. package/dist/continued.js +5 -0
  10. package/dist/desktop-app.js +103 -0
  11. package/dist/doctor.js +84 -32
  12. package/dist/folder-guard.js +12 -0
  13. package/dist/folder-snapshot.js +432 -0
  14. package/dist/install.js +15 -8
  15. package/dist/machine.js +84 -4
  16. package/dist/managed.js +2 -2
  17. package/dist/mcp.js +5 -3
  18. package/dist/network.js +156 -0
  19. package/dist/notice-linux.js +6 -5
  20. package/dist/notice-windows.js +2 -2
  21. package/dist/notice.js +13 -0
  22. package/dist/notifier.js +29 -2
  23. package/dist/notify.js +11 -0
  24. package/dist/onboard-defaults.js +37 -1
  25. package/dist/pins.js +7 -1
  26. package/dist/plans.js +23 -0
  27. package/dist/protocol.js +5 -0
  28. package/dist/quickstart.js +17 -11
  29. package/dist/refusal.js +4 -4
  30. package/dist/relay.js +332 -22
  31. package/dist/repo-find.js +6 -3
  32. package/dist/roster-file.js +50 -5
  33. package/dist/tray.js +12 -1
  34. package/dist/uninstall.js +38 -3
  35. package/dist/uri-handler.js +3 -2
  36. package/dist/verify-act.js +17 -0
  37. package/dist/worker-fleet.js +140 -5
  38. package/dist/worker.js +201 -63
  39. package/installer/install.ps1 +251 -15
  40. package/installer/install.sh +147 -18
  41. package/package.json +1 -1
  42. package/helper/macos/prebuilt/manifest.json +0 -29
  43. package/helper/macos/prebuilt/pantheon-hud +0 -0
  44. package/helper/macos/prebuilt/pantheon-tray +0 -0
package/dist/client.js CHANGED
@@ -7,8 +7,8 @@ import { repoName } from "./repo-route.js";
7
7
  import { signChallenge, signBytes, actPayload, goalPayload, newActId, newActNonce, canonicalJson } from "./identity.js";
8
8
  import { expectsSigs, joinedSigning, loadPins, markMemberSigns, markSigs, memberSigns, observeMembers, pinFor, pinScope, teamPinId, updatePins } from "./pins.js";
9
9
  export { joinedSigning } from "./pins.js";
10
- import { trusted, verifyAct, verifyGoal } from "./verify-act.js";
11
- import { newId } from "./protocol.js";
10
+ import { trusted, verifyAct, verifyGoal, vouchedMove } from "./verify-act.js";
11
+ import { newId, UPKEEP_AGENT } from "./protocol.js";
12
12
  import { VERSION } from "./version.js";
13
13
  import { describeNetError, explainNetError, noteRelayAnswered, settleSystemFallback, systemFallbackPending, trySystemFallback, wsOptions } from "./network.js";
14
14
  export class PantheonClient {
@@ -71,7 +71,9 @@ export class PantheonClient {
71
71
  this.enroll = opts?.enroll ?? null;
72
72
  this.session = opts?.session ?? newId("s");
73
73
  this.role = opts?.role ?? "interactive";
74
+ this.upkeep = opts?.upkeep === true && this.role === "cli";
74
75
  }
76
+ upkeep;
75
77
  keepRepos() {
76
78
  try {
77
79
  rememberTeamRepos({ spaceId: this.cfg.spaceId ?? this.spaceId, space: this.cfg.space, relay: this.cfg.relay }, this.repos);
@@ -227,7 +229,8 @@ export class PantheonClient {
227
229
  session: this.session,
228
230
  role: this.role,
229
231
  display: this.cfg.display ?? this.cfg.member,
230
- agent: this.cfg.agent ?? "unknown",
232
+ agent: this.upkeep ? UPKEEP_AGENT : (this.cfg.agent ?? "unknown"),
233
+ ...(this.upkeep ? { upkeep: true } : {}),
231
234
  pkg: VERSION,
232
235
  ...(this.seq > 0 ? { from_seq: this.seq } : {}),
233
236
  ...(this.cfg.repo ? { repo: this.cfg.repo, ...(this.cfg.repoOrigin ? { repo_origin: repoName(this.cfg.repoOrigin) } : {}) } : {}),
@@ -608,7 +611,8 @@ export class PantheonClient {
608
611
  if (p.corrupt)
609
612
  return false;
610
613
  const before = JSON.stringify(p);
611
- changes = observeMembers(p, team, this.members, { member: this.cfg.member, publicKey: this.cfg.publicKey });
614
+ const teams = [this.cfg.spaceId, this.spaceId, this.cfg.space];
615
+ changes = observeMembers(p, team, this.members, { member: this.cfg.member, publicKey: this.cfg.publicKey }, Date.now(), (m, fp, pins) => vouchedMove(m, fp, teams, (who) => pinFor(pins, team, who), (who) => !!this.members.find((x) => x.member === who)?.admitted));
612
616
  if (this.features.includes("act-sig"))
613
617
  markSigs(p, team);
614
618
  return JSON.stringify(p) !== before;
package/dist/config.js CHANGED
@@ -631,18 +631,32 @@ export async function resolveInviteCode(code, relays, fetchImpl = fetch) {
631
631
  const token = code.trim().replace(/^['"`]|['"`]$/g, "");
632
632
  const tried = [...new Set(relays.map((r) => r.replace(/\/+$/, "")).filter(Boolean))];
633
633
  let answered = false;
634
+ let nonJsonFrom;
635
+ const json = async (res, relay) => {
636
+ const text = await res.text();
637
+ try {
638
+ return JSON.parse(text);
639
+ }
640
+ catch {
641
+ nonJsonFrom ??= relay;
642
+ return null;
643
+ }
644
+ };
634
645
  for (const relay of tried) {
635
646
  const http = relay.replace(/^ws/, "http");
636
647
  try {
637
648
  const res = await fetchImpl(`${http}/join?token=${encodeURIComponent(token)}`, { signal: AbortSignal.timeout(6_000) });
638
649
  if (res.status === 404) {
639
- answered = true;
650
+ const j = await json(res, relay);
651
+ answered = (!!j && typeof j === "object") || answered;
640
652
  continue;
641
653
  }
654
+ if (res.status >= 502 && res.status <= 504)
655
+ continue;
656
+ const body = await json(res, relay);
642
657
  if (!res.ok)
643
658
  continue;
644
- const body = (await res.json());
645
- if (typeof body.team === "string" && body.team)
659
+ if (typeof body?.team === "string" && body.team)
646
660
  return { relay, space: body.team, token };
647
661
  }
648
662
  catch { }
@@ -651,6 +665,6 @@ export async function resolveInviteCode(code, relays, fetchImpl = fetch) {
651
665
  if (answered) {
652
666
  throw new Error(`INVITE_CODE_UNKNOWN: that is an invite code, but no open invite on ${hosts} matches it — it may have been used already or expired (they last 24 hours).\n Ask your teammate for a fresh one: pantheon invite`);
653
667
  }
654
- throw new Error(`INVITE_CODE_UNREACHABLE: that looks like an invite code, but ${hosts} could not be asked which team it opens.\n Paste the whole invite line instead: pantheon join https://www.joinpantheon.network/join/<team>#${token}`);
668
+ throw Object.assign(new Error(`INVITE_CODE_UNREACHABLE: that looks like an invite code, but ${hosts} could not be asked which team it opens.\n Paste the whole invite line instead: pantheon join https://www.joinpantheon.network/join/<team>#${token}`), nonJsonFrom ? { nonJsonFrom } : {});
655
669
  }
656
670
  //# sourceMappingURL=config.js.map
package/dist/continued.js CHANGED
@@ -25,6 +25,11 @@ export function loadContinued(space, home) {
25
25
  export function handledIn(h) {
26
26
  return [...new Set(Object.values(readAll(h)).flatMap((l) => (Array.isArray(l) ? l : [])).filter((x) => typeof x === "string" && x.startsWith("handled:")))];
27
27
  }
28
+ export const wakeAlertedKey = (team, member, askId) => `wake-alerted:${team}|${member}|${askId}`;
29
+ export function wakeAlerted(team, member, askId, home) {
30
+ const k = wakeAlertedKey(team, member, askId);
31
+ return Object.values(readAll(home)).some((l) => Array.isArray(l) && l.includes(k));
32
+ }
28
33
  export function markContinued(space, id, home) {
29
34
  try {
30
35
  const p = continuedPath(home);
@@ -0,0 +1,103 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, win32 } from "node:path";
5
+ export const CLAUDE_BUNDLE = "com.anthropic.claudefordesktop";
6
+ export const CLAUDE_APP_ID_ASSUMED = "Claude";
7
+ const AUMID_RE = /^[A-Za-z0-9.-]{1,64}_[a-z0-9]{13}![A-Za-z0-9._-]{1,64}$/;
8
+ export const AUMID_SCRIPT = "$p = Get-AppxPackage -ErrorAction SilentlyContinue | Where-Object { $_.PackageFamilyName -like 'Claude_*' } | Select-Object -First 1; " +
9
+ "if ($p) { $a = @((Get-AppxPackageManifest $p).Package.Applications.Application)[0]; if ($a) { \"$($p.PackageFamilyName)!$($a.Id)\" } }";
10
+ function runPowershell(script) {
11
+ try {
12
+ const r = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", timeout: 8_000, windowsHide: true });
13
+ return r.status === 0 ? (r.stdout ?? "").trim() : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function findClaudeApp(d = {}) {
20
+ const os = d.os ?? process.platform;
21
+ const exists = d.exists ?? existsSync;
22
+ const home = d.home ?? homedir();
23
+ if (os === "darwin") {
24
+ return ["/Applications/Claude.app", join(home, "Applications", "Claude.app")].some((p) => exists(p)) ? { how: "bundle", bundle: CLAUDE_BUNDLE } : null;
25
+ }
26
+ if (os !== "win32")
27
+ return null;
28
+ const env = d.env ?? process.env;
29
+ const local = env.LOCALAPPDATA;
30
+ if (!local)
31
+ return null;
32
+ let families = [];
33
+ try {
34
+ families = (d.list ?? ((p) => readdirSync(p)))(win32.join(local, "Packages")).filter((f) => /^Claude_[a-z0-9]{13}$/.test(f));
35
+ }
36
+ catch {
37
+ families = [];
38
+ }
39
+ if (families.length) {
40
+ const asked = (d.powershell ?? runPowershell)(AUMID_SCRIPT)?.split(/\r?\n/).map((l) => l.trim()).find((l) => AUMID_RE.test(l));
41
+ if (asked)
42
+ return { how: "appsfolder", aumid: asked, assumed: false };
43
+ const guess = `${families[0]}!${CLAUDE_APP_ID_ASSUMED}`;
44
+ if (AUMID_RE.test(guess))
45
+ return { how: "appsfolder", aumid: guess, assumed: true };
46
+ }
47
+ const exe = win32.join(local, "AnthropicClaude", "claude.exe");
48
+ return exists(exe) ? { how: "exe", path: exe } : null;
49
+ }
50
+ export function openAppCommand(app) {
51
+ if (app.how === "appsfolder")
52
+ return { cmd: "explorer.exe", args: [`shell:AppsFolder\\${app.aumid}`] };
53
+ if (app.how === "exe")
54
+ return { cmd: app.path, args: [] };
55
+ return { cmd: "open", args: ["-b", app.bundle] };
56
+ }
57
+ export function clipboardCommand(os = process.platform, find = () => true) {
58
+ if (os === "darwin")
59
+ return { cmd: "pbcopy", args: [] };
60
+ if (os === "win32")
61
+ return { cmd: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command", "[Console]::InputEncoding = [Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())"] };
62
+ if (find("wl-copy"))
63
+ return { cmd: "wl-copy", args: [] };
64
+ if (find("xclip"))
65
+ return { cmd: "xclip", args: ["-selection", "clipboard"] };
66
+ return null;
67
+ }
68
+ function copyReal(text) {
69
+ const c = clipboardCommand();
70
+ if (!c)
71
+ return false;
72
+ try {
73
+ return spawnSync(c.cmd, c.args, { input: text, timeout: 8_000, windowsHide: true }).status === 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ function startReal(cmd, args) {
80
+ try {
81
+ const ch = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: false });
82
+ ch.on("error", () => { });
83
+ ch.unref();
84
+ return true;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ export function explainInClaudeApp(prompt, app, d = {}) {
91
+ const copied = (d.copy ?? copyReal)(prompt);
92
+ const o = openAppCommand(app);
93
+ if (!(d.start ?? startReal)(o.cmd, o.args))
94
+ return { ok: false, says: "the Claude app could not be opened" };
95
+ const paste = (d.os ?? process.platform) === "darwin" ? "Cmd+V" : "Ctrl+V";
96
+ return {
97
+ ok: true,
98
+ says: copied
99
+ ? `opened the Claude app — the question is copied: paste it into a new chat (${paste})`
100
+ : "opened the Claude app — the question could not be copied here; it is printed below to copy",
101
+ };
102
+ }
103
+ //# sourceMappingURL=desktop-app.js.map
package/dist/doctor.js CHANGED
@@ -18,10 +18,10 @@ import { PantheonClient } from "./client.js";
18
18
  import { load, resolveFor, loadAll, CONFIG_PATH } from "./config.js";
19
19
  import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
20
20
  import { approvalSummary, chosenApproval, copilotMcpConfigFile, kiroMcpConfigFile, onPath } from "./install.js";
21
- import { hookState, readIntent, agentHookStates } from "./notify.js";
21
+ import { hookState, readIntent, agentHookStates, repairNote } from "./notify.js";
22
22
  import { loadPersonas } from "./persona.js";
23
23
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
24
- import { agentById, chooseAgent, describeChoice, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
24
+ import { agentById, chooseAgent, describeChoice, detectAvailable, folderReaderLine, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
25
25
  import { loginShellLookup, npmSpawn, onPathNamed, which, whichWindows } from "./which.js";
26
26
  import { VERSION } from "./version.js";
27
27
  import { realSys, serviceInstalled, servicesUnavailable } from "./service.js";
@@ -30,7 +30,7 @@ import { hasPrebuilt } from "./helpers-build.js";
30
30
  import { traySourcePath, TRAY_PREBUILT } from "./tray.js";
31
31
  import { hudSourcePath, HUD_PREBUILT } from "./notifier.js";
32
32
  import { hostEnv, platformName } from "./host-env.js";
33
- import { currentPlan, explainNetError, proxyEnvFor, routeFor, redactProxy } from "./network.js";
33
+ import { checkRelayCertificate, currentPlan, explainNetError, interceptionExplanation, proxyEnvFor, relayJson, routeFor, redactProxy, worthCertificateCheck } from "./network.js";
34
34
  import { refusalOf } from "./refusal.js";
35
35
  const HOSTED_RELAY = "relay.joinpantheon.network";
36
36
  const git = (args, cwd) => {
@@ -329,9 +329,11 @@ export async function doctor(projectDir) {
329
329
  else if (fellBack)
330
330
  add("warn", "worker agent", line, prof.agent.primary_surface === "kiro"
331
331
  ? `you chose Kiro at setup; Kiro answers in your live sessions, and runs while you're away use ${choice.backend.label}`
332
- : surface && !SANDBOXED_FOR_UNATTENDED.has(surface.id) && !unsandboxedOk
333
- ? `you chose ${surface.label} at setup; runs while you're away use ${choice.backend.label} in its sandbox (to have ${surface.label} answer them: pantheon policy grant unsandboxed-runs)`
334
- : `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
332
+ : surface && !detectAvailable().some((a) => a.backend.id === surface.id)
333
+ ? `you use ${surface.label}${surface.id === "claude-code" ? " (the Claude app puts no claude CLI on PATH)" : ""}, and its CLI is not installed here, so runs while you're away use ${choice.backend.label}`
334
+ : surface && !SANDBOXED_FOR_UNATTENDED.has(surface.id) && !unsandboxedOk
335
+ ? `you chose ${surface.label} at setup; runs while you're away use ${choice.backend.label} in its sandbox (to have ${surface.label} answer them: pantheon policy grant unsandboxed-runs)`
336
+ : `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
335
337
  else
336
338
  add("pass", "worker agent", line, undefined, shareLine);
337
339
  }
@@ -395,9 +397,32 @@ export async function doctor(projectDir) {
395
397
  }
396
398
  const health = `${cfg.relay.replace(/^ws/, "http")}/health`;
397
399
  const where = hosted ? "hosted" : "self-hosted";
400
+ const certAsk = url.protocol === "wss:" ? checkRelayCertificate(cfg.relay).catch(() => null) : Promise.resolve(null);
401
+ let cert = null;
402
+ let certSaid = false;
403
+ const certRow = async (reached) => {
404
+ cert = await certAsk;
405
+ if (!cert || certSaid)
406
+ return cert;
407
+ certSaid = true;
408
+ if (cert.publicCa === true)
409
+ add("pass", "certificate", `issued by ${cert.issuer}, a public certificate authority`, undefined, "from a public CA");
410
+ else if (cert.publicCa === false && reached) {
411
+ add("pass", "certificate", `re-signed by ${cert.issuer}, not a public certificate authority: an antivirus or company proxy inspects this connection, and the relay still answers through it`, undefined, "re-signed by an inspecting proxy; relay answers");
412
+ }
413
+ else if (cert.publicCa === false) {
414
+ const x = interceptionExplanation(cert);
415
+ add("fail", "certificate", x.why, x.fix, x.share);
416
+ }
417
+ else {
418
+ add("warn", "certificate", `could not be verified here (${cert.error ?? "unknown error"})`, "check this machine's date and time; if it is right, try another network", "not verifiable");
419
+ }
420
+ return cert;
421
+ };
398
422
  try {
399
423
  const res = await fetch(health, { signal: AbortSignal.timeout(5000) });
400
- const body = (await res.json());
424
+ const body = (await relayJson(res, health));
425
+ await certRow(true);
401
426
  if (!body.ok && body.storage) {
402
427
  const since = body.storage.at ? ` (since ${new Date(body.storage.at).toLocaleString()})` : "";
403
428
  add("fail", "relay", `the relay cannot write to its disk: ${body.storage.error ?? "unknown error"}${since}`, hosted ? `this is on the hosted relay's side — nothing on this machine can fix it; tell us at ${FEEDBACK_ADDRESS}` : "free space or fix permissions on the relay host's data folder, then restart the relay", `cannot write to its disk (${where})`);
@@ -429,7 +454,8 @@ export async function doctor(projectDir) {
429
454
  }
430
455
  catch (e) {
431
456
  const msg = e.message;
432
- const nx = explainNetError(e, health);
457
+ const c = await certRow(false);
458
+ const nx = c?.publicCa === false && worthCertificateCheck(e) ? interceptionExplanation(c) : explainNetError(e, health);
433
459
  if (nx) {
434
460
  add("fail", "relay", `cannot reach ${health} — ${nx.why}`, nx.fix, `unreachable (${where}): ${nx.share}`);
435
461
  return out;
@@ -441,7 +467,10 @@ export async function doctor(projectDir) {
441
467
  let joined = false;
442
468
  await client.connect().then(() => { joined = true; }).catch(() => { });
443
469
  if (!joined) {
444
- const nx = client.pending ? null : explainNetError(new Error(client.lastError ?? ""), cfg.relay);
470
+ const lastErr = new Error(client.lastError ?? "");
471
+ const seen = cert;
472
+ const nx = client.pending ? null
473
+ : seen?.publicCa === false && worthCertificateCheck(lastErr) ? interceptionExplanation(seen) : explainNetError(lastErr, cfg.relay);
445
474
  if (nx) {
446
475
  add("fail", "live link", `the health check got through but the live connection did not — ${nx.why}`, nx.fix, `live connection failed: ${nx.share}`);
447
476
  client.close();
@@ -538,13 +567,22 @@ export async function doctor(projectDir) {
538
567
  const f = loadAll();
539
568
  const m = f && Object.values(f.spaces ?? {}).find((x) => x.id === (client.spaceId ?? cfg.spaceId) || x.name === cfg.space);
540
569
  if (f && m) {
541
- const { teamWorkspaces } = await import("./worker-fleet.js");
570
+ const { teamWorkspaces, unservedFolders } = await import("./worker-fleet.js");
542
571
  const ws = teamWorkspaces(f, m.id);
572
+ for (const u of unservedFolders(f, m.id, ws))
573
+ add("warn", "worker serves", `${u.path} is bound but not served: ${u.why}`, undefined, "a bound folder is not served");
543
574
  const fps = new Set(ws.map((w) => w.fp).filter(Boolean));
544
575
  const missing = (m.repos ?? []).filter((r) => !fps.has(r.fp)).map((r) => r.name);
576
+ if (!ws.length && !unservedFolders(f, m.id, ws).length) {
577
+ add("warn", "worker serves", `nothing on this machine is bound to "${cfg.space}" — teammates' requests to you wait for your live session`, "from inside the repo or docs folder: pantheon use <team> --here (the worker serves it at once, no restart)", "nothing bound to this team");
578
+ }
545
579
  if (ws.length) {
546
580
  add("pass", "worker serves", `${ws.map((w) => `${w.name}${w.kind === "folder" ? " (folder)" : ""}`).join(", ")}${missing.length ? ` — not on this machine: ${missing.join(", ")}` : ""}`, missing.length ? "requests about those are told they wait for you; clone them, then: pantheon repo find" : undefined, `${ws.length} workspace${ws.length === 1 ? "" : "s"}${missing.length ? `, ${missing.length} team repo${missing.length === 1 ? "" : "s"} not here` : ""}`);
547
581
  }
582
+ if (ws.some((w) => w.kind === "folder")) {
583
+ const r = folderReaderLine(detectAvailable());
584
+ add(r.ok ? "pass" : "warn", "folders", r.line, undefined, r.ok ? "any agent here answers folder requests, in a copy" : "no agent here can answer folder requests");
585
+ }
548
586
  }
549
587
  }
550
588
  {
@@ -613,25 +651,8 @@ export async function doctor(projectDir) {
613
651
  add("pass", "alerts", "installed and running — a desktop notification when a teammate needs you", undefined, "installed and running");
614
652
  }
615
653
  }
616
- if (!hookStates.length && !hook.installed) {
617
- add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor / copilot / kiro on PATH)", "not installed");
618
- }
619
- else if (!reportsActivity) {
620
- add("warn", "wake-up hook", `installed (${hook.events?.join(", ")}) but predates the activity hook, so a worker cannot tell your idle editor from one you are using`, "re-run `pantheon hook install --user` to add the UserPromptSubmit half", `installed (${hook.events?.join(", ")}), predates the activity hook`);
621
- }
622
- else {
623
- const intent = readIntent();
624
- intent?.repairs
625
- ? add("warn", "wake-up hook", `${hook.events?.join(", ")} (${hook.scope} scope) — but it has been removed and put back ${intent.repairs} time${intent.repairs === 1 ? "" : "s"}`, "something on this machine rewrites ~/.claude/settings.json and drops the hooks key. The worker repairs it, but only while the worker is running", `${hook.events?.join(", ")} (${hook.scope} scope), repaired ${intent.repairs} time${intent.repairs === 1 ? "" : "s"}`)
626
- : add("pass", "wake-up hook", `${hook.events?.join(", ")} (${hook.scope} scope)`, undefined, `${hook.events?.join(", ")} (${hook.scope} scope)`);
627
- }
628
- if (hookStates.some((s) => s.agent === "codex")) {
629
- add("warn", "codex hooks", "installed — but codex skips new or changed hooks until they are approved", "run /hooks inside codex once to approve them (needs codex ≥ 0.115; sessions still report presence over MCP either way)", "installed, not yet approved inside codex");
630
- }
631
- if (hookStates.some((s) => s.agent === "kiro")) {
632
- kiroDefaultIsPantheon()
633
- ? add("pass", "kiro hooks", "in the pantheon agent, which is your Kiro default — every kiro-cli chat keeps working while a teammate waits", undefined, "in the pantheon agent, the default")
634
- : add("warn", "kiro hooks", "in the pantheon agent — used only when you run kiro-cli chat --agent pantheon", "kiro-cli agent set-default pantheon makes it every session; without it, Kiro sessions still report presence but end their turn with a teammate waiting", "in the pantheon agent, not the default");
654
+ for (const r of hookChecks({ states: hookStates, hook, reportsActivity, repairs: readIntent()?.repairs, kiroDefault: kiroDefaultIsPantheon })) {
655
+ add(r.level, r.name, r.detail, r.fix, r.share);
635
656
  }
636
657
  const personas = loadPersonas(projectDir);
637
658
  if (personas.length) {
@@ -651,8 +672,38 @@ export async function doctor(projectDir) {
651
672
  client.close();
652
673
  return out;
653
674
  }
675
+ export function hookChecks(f) {
676
+ const out = [];
677
+ const add = (level, name, detail, fix, share) => out.push({ level, name, detail, ...(fix ? { fix } : {}), ...(share ? { share } : {}) });
678
+ const { hook } = f;
679
+ if (!f.states.length && !hook.installed) {
680
+ add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor / copilot / kiro on PATH)", "not installed");
681
+ }
682
+ else if (!f.reportsActivity) {
683
+ add("warn", "wake-up hook", `installed (${hook.events?.join(", ")}) but predates the activity hook, so a worker cannot tell your idle editor from one you are using`, "re-run `pantheon hook install --user` to add the UserPromptSubmit half", `installed (${hook.events?.join(", ")}), predates the activity hook`);
684
+ }
685
+ else {
686
+ const where = `${hook.events?.join(", ")} (${hook.scope} scope)`;
687
+ const repaired = repairNote(f.repairs);
688
+ if (!repaired)
689
+ add("pass", "wake-up hook", where, undefined, where);
690
+ else if (!repaired.recurring)
691
+ add("info", "wake-up hook", `${where} — ${repaired.said}`, undefined, `${where}, repaired once`);
692
+ else
693
+ add("warn", "wake-up hook", `${where} — but it has been removed and put back ${f.repairs} times`, `${repaired.why}. The worker repairs it, but only while the worker is running`, `${where}, repaired ${f.repairs} times`);
694
+ }
695
+ if (f.states.some((s) => s.agent === "codex")) {
696
+ add("info", "codex hooks", "installed — codex runs them once they are approved inside codex, a one-time step (and again after an upgrade changes them)", "run /hooks inside codex once to approve them (needs codex ≥ 0.115; sessions still report presence over MCP either way)", "installed, approval inside codex is a one-time step");
697
+ }
698
+ if (f.states.some((s) => s.agent === "kiro")) {
699
+ f.kiroDefault()
700
+ ? add("pass", "kiro hooks", "in the pantheon agent, which is your Kiro default — every kiro-cli chat keeps working while a teammate waits", undefined, "in the pantheon agent, the default")
701
+ : add("info", "kiro hooks", "in the pantheon agent — every kiro-cli chat uses it once it is Kiro's default, a one-time step", "kiro-cli agent set-default pantheon makes it every session; without it, Kiro sessions still report presence but end their turn with a teammate waiting", "in the pantheon agent, not the default (a one-time step)");
702
+ }
703
+ return out;
704
+ }
654
705
  export function render(checks) {
655
- const mark = { pass: "\x1b[36m ok \x1b[0m", warn: "\x1b[33mwarn\x1b[0m", fail: "\x1b[31mFAIL\x1b[0m" };
706
+ const mark = { pass: "\x1b[36m ok \x1b[0m", info: "\x1b[2minfo\x1b[0m", warn: "\x1b[33mwarn\x1b[0m", fail: "\x1b[31mFAIL\x1b[0m" };
656
707
  const lines = [];
657
708
  for (const c of checks) {
658
709
  lines.push(` ${mark[c.level]} ${c.name.padEnd(12)} ${c.detail}`);
@@ -661,16 +712,17 @@ export function render(checks) {
661
712
  }
662
713
  const failed = checks.filter((c) => c.level === "fail").length;
663
714
  const warned = checks.filter((c) => c.level === "warn").length;
715
+ const noted = checks.filter((c) => c.level === "info").length;
664
716
  lines.push("");
665
717
  lines.push(failed
666
718
  ? `\x1b[31m${failed} blocking problem${failed > 1 ? "s" : ""}\x1b[0m${warned ? `, ${warned} warning${warned > 1 ? "s" : ""}` : ""} — fix the FAILs before you start`
667
719
  : warned ? `\x1b[36mno blockers\x1b[0m, ${warned} warning${warned > 1 ? "s" : ""} — readable above`
668
- : `\x1b[36mall clear\x1b[0m — both machines are ready`);
720
+ : `\x1b[36mall clear\x1b[0m — both machines are ready${noted ? ` (${noted} note${noted > 1 ? "s" : ""} above, marked info)` : ""}`);
669
721
  lines.push(`\x1b[2mpantheon doctor --share prints this without names or paths, to paste to ${FEEDBACK_ADDRESS}\x1b[0m`);
670
722
  return { text: lines.join("\n"), failed };
671
723
  }
672
724
  export function shareBlock(checks, now = new Date()) {
673
- const mark = { pass: "ok ", warn: "warn", fail: "FAIL" };
725
+ const mark = { pass: "ok ", info: "info", warn: "warn", fail: "FAIL" };
674
726
  return [
675
727
  `pantheon ${VERSION} — doctor, ${now.toISOString().slice(0, 16).replace("T", " ")} UTC`,
676
728
  "no names, teams, paths or addresses are in this block; it is safe to paste",
@@ -86,4 +86,16 @@ export function folderRefusalHere(dir) {
86
86
  return folderRefusal(real(dir), folderFacts());
87
87
  }
88
88
  export const isTemporaryRefusal = (r) => r?.kind === "temporary" || r?.kind === "app-scratch";
89
+ export function projectFolderAnswer(answer, home, cwd, p = path) {
90
+ let a = answer.trim();
91
+ if (a.length >= 2 && ((a.startsWith('"') && a.endsWith('"')) || (a.startsWith("'") && a.endsWith("'"))))
92
+ a = a.slice(1, -1);
93
+ if (p.sep === "/")
94
+ a = a.replace(/\\([ ()&'])/g, "$1");
95
+ if (a === "~")
96
+ a = home;
97
+ else if (/^~[\\/]/.test(a))
98
+ a = p.join(home, a.slice(2));
99
+ return p.resolve(cwd, a);
100
+ }
89
101
  //# sourceMappingURL=folder-guard.js.map