@join-pantheon/cli 0.29.1 → 0.30.0

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 (46) hide show
  1. package/README.md +45 -22
  2. package/bin/pantheon.js +12 -0
  3. package/dist/accounts.js +151 -26
  4. package/dist/agents.js +197 -14
  5. package/dist/claude-confine.js +283 -0
  6. package/dist/cli.js +725 -120
  7. package/dist/client.js +37 -2
  8. package/dist/command-state.js +79 -0
  9. package/dist/config.js +211 -10
  10. package/dist/doctor.js +103 -13
  11. package/dist/grants.js +1 -1
  12. package/dist/history-view.js +2 -1
  13. package/dist/home-guard.js +210 -0
  14. package/dist/hooks-install.js +2 -1
  15. package/dist/identity.js +24 -0
  16. package/dist/install.js +76 -26
  17. package/dist/kiro-files.js +2 -2
  18. package/dist/ledger.js +111 -28
  19. package/dist/machine.js +494 -32
  20. package/dist/mcp-supervisor.js +25 -6
  21. package/dist/mcp-windows.js +47 -25
  22. package/dist/mcp.js +25 -10
  23. package/dist/network.js +39 -8
  24. package/dist/notify.js +17 -6
  25. package/dist/pending.js +8 -14
  26. package/dist/persona.js +21 -0
  27. package/dist/profile.js +4 -1
  28. package/dist/quickstart.js +82 -14
  29. package/dist/reads.js +30 -0
  30. package/dist/refusal.js +14 -1
  31. package/dist/relay.js +1143 -180
  32. package/dist/roster-file.js +169 -23
  33. package/dist/run-failure.js +83 -3
  34. package/dist/secret-guard.js +141 -0
  35. package/dist/service.js +84 -34
  36. package/dist/setup-guide.js +28 -9
  37. package/dist/spend.js +6 -2
  38. package/dist/store.js +94 -10
  39. package/dist/text-file.js +74 -0
  40. package/dist/tray.js +11 -2
  41. package/dist/update-check.js +2 -0
  42. package/dist/which.js +69 -12
  43. package/dist/worker-policy.js +137 -3
  44. package/dist/worker.js +702 -81
  45. package/helper/macos/menubar.swift +15 -1
  46. package/package.json +2 -2
package/dist/service.js CHANGED
@@ -2,7 +2,7 @@ import { writeFileSync, mkdirSync, existsSync, unlinkSync, readFileSync } from "
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { homedir, platform, userInfo } from "node:os";
4
4
  import { which } from "./which.js";
5
- import { dirname, join, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { launcher } from "./install.js";
8
8
  import { CONFIG_DIR } from "./config.js";
@@ -57,8 +57,44 @@ export function servicesUnavailable(plat = platform()) {
57
57
  export function plistPath(kind = "worker") {
58
58
  return join(homedir(), "Library", "LaunchAgents", `${LABEL(kind)}.plist`);
59
59
  }
60
- export function unitPath(kind) {
61
- return join(homedir(), ".config", "systemd", "user", `pantheon-${kind}.service`);
60
+ export function parseSystemdEnvironment(out) {
61
+ const env = {};
62
+ for (const line of out.split(/\r?\n/)) {
63
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
64
+ if (!m)
65
+ continue;
66
+ let v = m[2];
67
+ if (v.startsWith("$'") && v.endsWith("'"))
68
+ v = v.slice(2, -1).replace(/\\(.)/g, (_, c) => (c === "n" ? "\n" : c === "t" ? "\t" : c));
69
+ env[m[1]] = v;
70
+ }
71
+ return env;
72
+ }
73
+ const sdEnvCache = new WeakMap();
74
+ export function systemdUserEnv(sys = realSys()) {
75
+ if (sys.platform !== "linux" || (sys.borrowed ?? borrowedHome)())
76
+ return {};
77
+ const home = homedir();
78
+ const hit = sdEnvCache.get(sys.exec);
79
+ if (hit && hit.home === home)
80
+ return hit.env;
81
+ let env = {};
82
+ try {
83
+ env = parseSystemdEnvironment(sys.exec("systemctl", ["--user", "show-environment"]));
84
+ }
85
+ catch { }
86
+ sdEnvCache.set(sys.exec, { home, env });
87
+ return env;
88
+ }
89
+ export function unitPath(kind, env = systemdUserEnv()) {
90
+ const xdg = env.XDG_CONFIG_HOME && isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : join(homedir(), ".config");
91
+ return join(xdg, "systemd", "user", `pantheon-${kind}.service`);
92
+ }
93
+ export function unitPaths(kind, env = systemdUserEnv(), shellEnv = process.env) {
94
+ return [...new Set([unitPath(kind, env), unitPath(kind, {}), unitPath(kind, shellEnv)])];
95
+ }
96
+ export function existingUnitPath(kind, env = systemdUserEnv(), shellEnv = process.env) {
97
+ return unitPaths(kind, env, shellEnv).find((p) => existsSync(p)) ?? null;
62
98
  }
63
99
  function xml(s) {
64
100
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@@ -70,16 +106,13 @@ function argvFor(kind, args) {
70
106
  const abs = whichPantheon();
71
107
  return abs ? [abs, kind, ...args] : [process.execPath, resolve(HERE, "cli.js"), kind, ...args];
72
108
  }
73
- export function whichPantheon() {
74
- if (platform() === "win32")
75
- return which("pantheon");
76
- try {
77
- const p = execFileSync("/usr/bin/which", ["pantheon"], { encoding: "utf8" }).trim();
78
- return p.startsWith("/") && existsSync(p) ? p : null;
79
- }
80
- catch {
109
+ export function whichPantheon(find = which) {
110
+ const p = find("pantheon");
111
+ if (!p)
81
112
  return null;
82
- }
113
+ if (platform() === "win32")
114
+ return p;
115
+ return p.startsWith("/") && existsSync(p) ? p : null;
83
116
  }
84
117
  export function servicePlist(kind, cwd, logDir, args) {
85
118
  const argv = argvFor(kind, args);
@@ -107,9 +140,15 @@ ${argv.map((a) => ` <string>${xml(a)}</string>`).join("\n")}
107
140
  `;
108
141
  }
109
142
  export function systemdQuote(arg) {
110
- return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
143
+ return `"${cEscape(arg).replace(/%/g, "%%").replace(/\$/g, "$$$$")}"`;
111
144
  }
112
- const systemdValue = (v) => v.replace(/%/g, "%%");
145
+ const cEscape = (s) => s.replace(/\\/g, "\\\\").replace(/"/g, "\\\"").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
146
+ export const systemdEnvQuote = (assignment) => `"${cEscape(assignment).replace(/%/g, "%%")}"`;
147
+ const systemdValue = (v) => {
148
+ if (/[\r\n]/.test(v))
149
+ throw new Error(`a path with a line break in it cannot be written into a systemd unit: ${JSON.stringify(v)}`);
150
+ return v.replace(/%/g, "%%");
151
+ };
113
152
  export function systemdUnit(kind, cwd, args, path = servicePath()) {
114
153
  const description = kind === "relay"
115
154
  ? "pantheon relay — the coordination plane for a space"
@@ -124,7 +163,7 @@ After=network-online.target
124
163
  Type=simple
125
164
  WorkingDirectory=${systemdValue(cwd)}
126
165
  ExecStart=${argvFor(kind, args).map(systemdQuote).join(" ")}
127
- Environment=${systemdQuote(`PATH=${path}`)}
166
+ Environment=${systemdEnvQuote(`PATH=${path}`)}
128
167
  Restart=always
129
168
  RestartSec=30
130
169
 
@@ -147,6 +186,9 @@ export function installService(kind, cwd, args, sys = realSys()) {
147
186
  if (noServices)
148
187
  throw new Error(noServices.replace(/^not available — /, ""));
149
188
  const out = [];
189
+ let loaded = true;
190
+ let reason;
191
+ const done = () => Object.assign(out, { loaded, ...(reason ? { reason } : {}) });
150
192
  const abs = resolve(cwd);
151
193
  const logDir = serviceLogDir();
152
194
  if (sys.platform === "darwin") {
@@ -168,8 +210,16 @@ export function installService(kind, cwd, args, sys = realSys()) {
168
210
  out.push(` loaded — the ${kind} now starts at login and restarts if it dies`);
169
211
  }
170
212
  catch (e) {
171
- out.push(` could not load it: ${e.message.split("\n")[0]}`);
172
- out.push(` load it yourself with: launchctl load -w ${p}`);
213
+ const why = e.message.split("\n")[0];
214
+ loaded = false;
215
+ reason = why;
216
+ out.push(` could not load it: ${why}`);
217
+ if ((sys.borrowed ?? borrowedHome)()) {
218
+ reason = `HOME (${homedir()}) is not your own home, so launchd was not touched`;
219
+ out.push(` left unloaded — ${reason}; your own services are unchanged`);
220
+ }
221
+ else
222
+ out.push(` load it yourself with: launchctl load -w ${p}`);
173
223
  }
174
224
  out.push(` logs: ${join(logDir, `${kind}.log`)}`);
175
225
  out.push(` stop: pantheon service uninstall ${kind}`);
@@ -178,8 +228,8 @@ export function installService(kind, cwd, args, sys = realSys()) {
178
228
  if (kind === "menubar" || kind === "notifier") {
179
229
  throw new Error(`${kind === "menubar" ? "the menu bar" : "desktop alerts"} run on macOS only, and this machine reports ${sys.platform}`);
180
230
  }
181
- const unit = unitPath(kind);
182
- mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
231
+ const unit = unitPath(kind, systemdUserEnv(sys));
232
+ mkdirSync(dirname(unit), { recursive: true });
183
233
  writeFileSync(unit, systemdUnit(kind, abs, args));
184
234
  out.push(` wrote ${unit}`);
185
235
  try {
@@ -196,17 +246,15 @@ export function installService(kind, cwd, args, sys = realSys()) {
196
246
  out.push(` ! the ${kind} stops when you log out (closing an SSH session counts). To keep it running: loginctl enable-linger ${sys.user || "$USER"}`);
197
247
  }
198
248
  }
199
- return out;
249
+ return done();
200
250
  }
201
251
  export function uninstallService(kind, sys = realSys()) {
202
252
  const out = [];
203
253
  if (sys.platform === "win32") {
204
- const stray = unitPath(kind);
205
- if (existsSync(stray)) {
206
- unlinkSync(stray);
207
- return [` removed ${stray}`];
208
- }
209
- return [` no ${kind} service installed`];
254
+ const strays = unitPaths(kind, {}).filter((u) => existsSync(u));
255
+ for (const u of strays)
256
+ unlinkSync(u);
257
+ return strays.length ? strays.map((u) => ` removed ${u}`) : [` no ${kind} service installed`];
210
258
  }
211
259
  if (sys.platform === "darwin") {
212
260
  const p = plistPath(kind);
@@ -228,8 +276,8 @@ export function uninstallService(kind, sys = realSys()) {
228
276
  out.push(` removed ${p}`);
229
277
  }
230
278
  else {
231
- const unit = unitPath(kind);
232
- if (!existsSync(unit))
279
+ const units = unitPaths(kind, systemdUserEnv(sys)).filter((u) => existsSync(u));
280
+ if (!units.length)
233
281
  return [` no ${kind} service installed`];
234
282
  try {
235
283
  sys.exec("systemctl", ["--user", "disable", "--now", `pantheon-${kind}`]);
@@ -238,12 +286,14 @@ export function uninstallService(kind, sys = realSys()) {
238
286
  catch {
239
287
  out.push(` could not reach systemd --user — if it runs elsewhere: systemctl --user disable --now pantheon-${kind}`);
240
288
  }
241
- unlinkSync(unit);
289
+ for (const u of units)
290
+ unlinkSync(u);
242
291
  try {
243
292
  sys.exec("systemctl", ["--user", "daemon-reload"]);
244
293
  }
245
294
  catch { }
246
- out.push(` removed ${unit}`);
295
+ for (const u of units)
296
+ out.push(` removed ${u}`);
247
297
  }
248
298
  return out;
249
299
  }
@@ -335,11 +385,11 @@ export function refreshServiceDefinition(kind, plat = platform()) {
335
385
  }
336
386
  if (plat === "win32")
337
387
  return { changed: false };
338
- const u = unitPath(kind);
339
- if (!existsSync(u))
388
+ const u = existingUnitPath(kind);
389
+ if (!u)
340
390
  return { changed: false };
341
391
  const unit = readFileSync(u, "utf8");
342
- if (/^Environment="PATH=/m.test(unit))
392
+ if (/^Environment="PATH=/m.test(unit) && !/^Environment="PATH=.*\$\$/m.test(unit))
343
393
  return { changed: false };
344
394
  const exec = /^ExecStart=(.*)$/m.exec(unit)?.[1] ?? "";
345
395
  const words = exec.startsWith('"') ? [...exec.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, "$1").replace(/%%/g, "%").replace(/\$\$/g, "$")) : exec.split(/\s+/);
@@ -359,7 +409,7 @@ export function serviceInstalled(kind, plat = platform()) {
359
409
  try {
360
410
  if (plat === "win32")
361
411
  return false;
362
- return existsSync(plat === "darwin" ? plistPath(kind) : unitPath(kind));
412
+ return plat === "darwin" ? existsSync(plistPath(kind)) : existingUnitPath(kind) !== null;
363
413
  }
364
414
  catch {
365
415
  return false;
@@ -3,7 +3,7 @@ import { approvalSummary, installForUser } from "./install.js";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { applyProfileInit, loadProfile, profilePath, saveProfile } from "./profile.js";
6
+ import { applyProfileInit, loadProfile, profilePath, saveProfile, wakeMode } from "./profile.js";
7
7
  import { installService as realInstall, uninstallService as realUninstall, serviceInstalled as realInstalled } from "./service.js";
8
8
  import { personaDir } from "./persona.js";
9
9
  import { hudBinaryPath } from "./notifier.js";
@@ -19,8 +19,8 @@ export const DECISION_AREAS = [
19
19
  ];
20
20
  export const TITLE_OPTIONS = ["Founder", "Engineer", "Designer", "Product", "Operations", "Something else"];
21
21
  export const WAKE_OPTIONS = [
22
- { id: "anyone", label: "Yes — anyone on my teams (recommended). Invites you send grant it; you can revoke a person any time." },
23
- { id: "approved", label: "Only people I approve one by one — invites do not grant it; I run `pantheon admit <name> --may-wake` per person." },
22
+ { id: "anyone", label: "Yes — anyone on my teams. Invites you send grant it; you can revoke a person any time." },
23
+ { id: "approved", label: "Only people I approve one by one (the default) — invites do not grant it; I run `pantheon wake allow <name>` per person, on this machine." },
24
24
  { id: "no", label: "No — this machine only works while I am at it." },
25
25
  ];
26
26
  export const ALERT_OPTIONS = [
@@ -32,7 +32,7 @@ export const ALERT_OPTIONS = [
32
32
  export function guideDefaults(profile, cfg) {
33
33
  const n = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
34
34
  return {
35
- wake: profile.agent.wake_default ?? "anyone",
35
+ wake: wakeMode(profile),
36
36
  commands: profile.agent.allow_bash ?? false,
37
37
  approval: profile.agent.approval ?? "approve",
38
38
  name: profile.human.display_name || cfg.display || cfg.member,
@@ -69,7 +69,9 @@ export function questionnaire() {
69
69
  "",
70
70
  "Only 3 and 4 need the person every time. Ask 2 whenever there is a repo (it is the one default people are",
71
71
  "surprised by). The rest have defaults you may take and say out loud:",
72
- " --wake anyone --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
72
+ " --wake approved --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
73
+ "--wake approved (the default) means nobody wakes this machine until they approve a person with `pantheon wake allow <name>`.",
74
+ "Never pick --wake anyone for them: it is their call, and only if they say so.",
73
75
  "Without a repo, --wake, --commands, --playbook and --base do nothing yet: skip them and say so.",
74
76
  "",
75
77
  "1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
@@ -98,7 +100,7 @@ export function questionnaire() {
98
100
  " These are switches: pass --approve-all or --ask-before-sending with no value. Either way the install",
99
101
  " output prints the exact line written to ~/.codex/config.toml, and pantheon leave removes it.",
100
102
  "",
101
- "Example: pantheon setup --yes --wake anyone --commands yes --name \"Praneet Sinha\" --title Founder \\",
103
+ "Example: pantheon setup --yes --wake approved --commands yes --name \"Praneet Sinha\" --title Founder \\",
102
104
  " --decides deployment --alerts decisions --playbook yes --base main",
103
105
  ].join("\n");
104
106
  }
@@ -198,7 +200,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
198
200
  io.say(` • Wake: ${a.wake === "no" ? "off — this machine only works while you are at it"
199
201
  : !ctx.project ? "on once this machine has a repo (run pantheon setup from your project's repo)"
200
202
  : a.wake === "anyone" ? "teammates may hand work to this machine while you're away; invites you send grant it, and you can revoke anyone (pantheon admit <name> --revoke)"
201
- : "only people you approve by hand (pantheon admit <name> --may-wake)"}`);
203
+ : "only people you approve by hand, on this machine (pantheon wake allow <name>)"}`);
202
204
  io.say(` • Alerts: ${shortAlert(a.notifications).toLowerCase()}`);
203
205
  io.say(` • Approval: ${a.approval === "approve"
204
206
  ? "your agent sends to teammates in your name without asking each time (Claude Code, Cursor, Codex, and Kiro's IDE and pantheon agent; Copilot asks once per tool per folder)"
@@ -317,7 +319,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
317
319
  }
318
320
  applied.push(a.wake === "anyone"
319
321
  ? "wake permission: invites you send grant it (revoke one person: pantheon admit <name> --revoke)"
320
- : "wake permission: only people you grant by hand (pantheon admit <name> --may-wake)");
322
+ : "wake permission: only people you approve on this machine (pantheon wake allow <name>)");
321
323
  }
322
324
  else {
323
325
  applied.push("worker: run this again from your project's repo to switch it on there");
@@ -403,8 +405,25 @@ ${never.map((n) => `- ${n}`).join("\n")}
403
405
  When in doubt, ask the teammate who woke you rather than ${who}; if only ${who} can answer, ESCALATE and finish.
404
406
  `;
405
407
  }
408
+ export function questionOrNull(rl, q) {
409
+ return new Promise((res) => {
410
+ let done = false;
411
+ const onClose = () => { if (!done) {
412
+ done = true;
413
+ res(null);
414
+ } };
415
+ rl.once?.("close", onClose);
416
+ try {
417
+ rl.question(q, (a) => { if (done)
418
+ return; done = true; rl.off?.("close", onClose); res(a.trim()); });
419
+ }
420
+ catch {
421
+ onClose();
422
+ }
423
+ });
424
+ }
406
425
  export function terminalIO(rl, out) {
407
- const ask = (q) => new Promise((res) => rl.question(q, (x) => res(x.trim())));
426
+ const ask = async (q) => (await questionOrNull(rl, q)) ?? "";
408
427
  return {
409
428
  say: out,
410
429
  async ask(question, def) {
package/dist/spend.js CHANGED
@@ -31,9 +31,13 @@ export function since(ms, home) {
31
31
  byPeer[r.triggeredBy] = (byPeer[r.triggeredBy] ?? 0) + 1;
32
32
  return { runs: runs.length, failed: runs.filter((r) => !r.ok).length, byPeer };
33
33
  }
34
- export function recentRuns(ms, home, limit = 12) {
34
+ export function recentRuns(ms, home, limit = 12, only) {
35
35
  const from = Date.now() - ms;
36
- return read(home).runs.filter((r) => r.at > from).sort((a, b) => b.at - a.at).slice(0, limit);
36
+ const teams = (only?.teams ?? []).filter((t) => !!t);
37
+ return read(home).runs
38
+ .filter((r) => r.at > from)
39
+ .filter((r) => !only || (!!r.space && teams.includes(r.space) && (!r.member || r.member === only.member)))
40
+ .sort((a, b) => b.at - a.at).slice(0, limit);
37
41
  }
38
42
  export function allowed(caps, home) {
39
43
  const hour = since(3_600_000, home).runs;
package/dist/store.js CHANGED
@@ -1,22 +1,86 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
1
+ import { readFileSync, existsSync, mkdirSync, renameSync, openSync, writeSync, fsyncSync, closeSync, chmodSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { emptyRefusals } from "./admin-stats.js";
4
+ export function ensurePrivateDir(dir) {
5
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
6
+ try {
7
+ chmodSync(dir, 0o700);
8
+ }
9
+ catch { }
10
+ }
11
+ export function writeFileAtomic(file, data, mode = 0o600) {
12
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
13
+ const tmp = `${file}.tmp`;
14
+ const fd = openSync(tmp, "w", mode);
15
+ try {
16
+ writeSync(fd, typeof data === "string" ? Buffer.from(data) : data);
17
+ fsyncSync(fd);
18
+ }
19
+ finally {
20
+ closeSync(fd);
21
+ }
22
+ renameRetrying(tmp, file);
23
+ fsyncDir(dirname(file));
24
+ }
25
+ const sleepSync = (ms) => { try {
26
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
27
+ }
28
+ catch { } };
29
+ export function renameRetrying(from, to, o = {}) {
30
+ const platform = o.platform ?? process.platform;
31
+ const rename = o.rename ?? renameSync;
32
+ for (let i = 0;; i++) {
33
+ try {
34
+ rename(from, to);
35
+ return;
36
+ }
37
+ catch (e) {
38
+ if (platform !== "win32" || i >= 20 || !["EPERM", "EACCES", "EBUSY"].includes(e.code ?? ""))
39
+ throw e;
40
+ sleepSync(o.waitMs ?? 10);
41
+ }
42
+ }
43
+ }
44
+ export function fsyncDir(dir) {
45
+ let fd = null;
46
+ try {
47
+ fd = openSync(dir, "r");
48
+ fsyncSync(fd);
49
+ }
50
+ catch { }
51
+ finally {
52
+ if (fd !== null)
53
+ try {
54
+ closeSync(fd);
55
+ }
56
+ catch { }
57
+ }
58
+ }
4
59
  export class StateStore {
5
60
  file;
6
61
  timer = null;
7
62
  dirty = false;
8
63
  refusals = emptyRefusals();
64
+ headOf;
65
+ rebuilt = null;
66
+ extra = {};
9
67
  constructor(dir, space) {
10
68
  this.file = join(dir, `${space}.state.json`);
11
69
  }
12
- load() {
70
+ load(opts = {}) {
13
71
  const goals = new Map();
14
72
  const obligations = new Map();
15
73
  const profiles = new Map();
16
- if (!existsSync(this.file))
17
- return { goals, obligations, profiles };
74
+ if (!existsSync(this.file)) {
75
+ return { goals, obligations, profiles, unclean: opts.expectHistory ? "the state snapshot is missing but the ledger has history" : null };
76
+ }
18
77
  try {
19
78
  const snap = JSON.parse(readFileSync(this.file, "utf8"));
79
+ if (!snap || typeof snap !== "object" || Array.isArray(snap)
80
+ || (snap.goals !== undefined && !Array.isArray(snap.goals))
81
+ || (snap.obligations !== undefined && !Array.isArray(snap.obligations))) {
82
+ throw new Error("not a state snapshot");
83
+ }
20
84
  for (const g of snap.goals ?? [])
21
85
  goals.set(g.id, g);
22
86
  for (const o of snap.obligations ?? []) {
@@ -24,12 +88,32 @@ export class StateStore {
24
88
  }
25
89
  for (const p of snap.profiles ?? [])
26
90
  profiles.set(p.member, p);
91
+ const known = new Set(["v", "goals", "obligations", "profiles", "refusals", "ledger_head", "rebuilt", "saved_at"]);
92
+ this.extra = {};
93
+ for (const [k, v] of Object.entries(snap))
94
+ if (!known.has(k))
95
+ this.extra[k] = v;
27
96
  if (snap.refusals)
28
97
  this.refusals = { total: snap.refusals.total ?? 0, by_code: snap.refusals.by_code ?? {}, recent: snap.refusals.recent ?? [], stamps: snap.refusals.stamps ?? [] };
98
+ return {
99
+ goals, obligations, profiles, unclean: null,
100
+ ...(typeof snap.ledger_head === "number" && Number.isFinite(snap.ledger_head) ? { ledgerHead: snap.ledger_head } : {}),
101
+ ...(typeof snap.rebuilt === "string" && snap.rebuilt ? { rebuilt: snap.rebuilt } : {}),
102
+ };
29
103
  }
30
- catch {
104
+ catch (e) {
105
+ const why = `the state snapshot could not be read (${e.message})`;
106
+ let quarantined;
107
+ if (opts.quarantine) {
108
+ const aside = `${this.file}.corrupt-${Date.now()}`;
109
+ try {
110
+ renameSync(this.file, aside);
111
+ quarantined = aside;
112
+ }
113
+ catch { }
114
+ }
115
+ return { goals: new Map(), obligations: new Map(), profiles: new Map(), unclean: why, ...(quarantined ? { quarantined } : {}) };
31
116
  }
32
- return { goals, obligations, profiles };
33
117
  }
34
118
  save(goals, obligations, profiles) {
35
119
  this.dirty = true;
@@ -52,17 +136,17 @@ export class StateStore {
52
136
  onError;
53
137
  flush(goals, obligations, profiles) {
54
138
  const snap = {
139
+ ...this.extra,
55
140
  v: 2,
56
141
  goals: [...goals.values()],
57
142
  obligations: [...obligations.values()],
58
143
  profiles: [...profiles.values()],
59
144
  ...(this.refusals.total ? { refusals: this.refusals } : {}),
145
+ ...(this.headOf ? { ledger_head: this.headOf() } : {}),
146
+ ...(this.rebuilt ? { rebuilt: this.rebuilt } : {}),
60
147
  saved_at: Date.now(),
61
148
  };
62
- mkdirSync(dirname(this.file), { recursive: true });
63
- const tmp = `${this.file}.tmp`;
64
- writeFileSync(tmp, JSON.stringify(snap, null, 2) + "\n", { mode: 0o600 });
65
- renameSync(tmp, this.file);
149
+ writeFileAtomic(this.file, JSON.stringify(snap, null, 2) + "\n");
66
150
  }
67
151
  close(goals, obligations, profiles) {
68
152
  if (this.timer) {
@@ -0,0 +1,74 @@
1
+ export const stripBom = (text) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
2
+ export const parseJsonText = (text) => JSON.parse(stripBom(text));
3
+ export function stripJsonc(text) {
4
+ let out = "";
5
+ let i = 0;
6
+ const n = text.length;
7
+ while (i < n) {
8
+ const ch = text[i];
9
+ if (ch === '"') {
10
+ let j = i + 1;
11
+ while (j < n && text[j] !== '"')
12
+ j += text[j] === "\\" ? 2 : 1;
13
+ out += text.slice(i, j + 1);
14
+ i = j + 1;
15
+ continue;
16
+ }
17
+ if (ch === "/" && text[i + 1] === "/") {
18
+ while (i < n && text[i] !== "\n")
19
+ i++;
20
+ continue;
21
+ }
22
+ if (ch === "/" && text[i + 1] === "*") {
23
+ const end = text.indexOf("*/", i + 2);
24
+ i = end < 0 ? n : end + 2;
25
+ continue;
26
+ }
27
+ if (ch === ",") {
28
+ let j = i + 1;
29
+ for (;;) {
30
+ while (j < n && /\s/.test(text[j]))
31
+ j++;
32
+ if (text[j] === "/" && text[j + 1] === "/") {
33
+ while (j < n && text[j] !== "\n")
34
+ j++;
35
+ continue;
36
+ }
37
+ if (text[j] === "/" && text[j + 1] === "*") {
38
+ const e = text.indexOf("*/", j + 2);
39
+ j = e < 0 ? n : e + 2;
40
+ continue;
41
+ }
42
+ break;
43
+ }
44
+ if (text[j] === "}" || text[j] === "]") {
45
+ i++;
46
+ continue;
47
+ }
48
+ }
49
+ out += ch;
50
+ i++;
51
+ }
52
+ return out;
53
+ }
54
+ export function isJsonc(text) {
55
+ const t = stripBom(text);
56
+ try {
57
+ JSON.parse(t);
58
+ return false;
59
+ }
60
+ catch { }
61
+ try {
62
+ JSON.parse(stripJsonc(t));
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export function eolOf(text) {
70
+ const i = text.indexOf("\n");
71
+ return i > 0 && text[i - 1] === "\r" ? "\r\n" : "\n";
72
+ }
73
+ export const withEol = (text, eol) => eol === "\n" ? text : text.replace(/\r?\n/g, "\r\n");
74
+ //# sourceMappingURL=text-file.js.map
package/dist/tray.js CHANGED
@@ -11,6 +11,7 @@ import { requireDeveloperTools } from "./notifier.js";
11
11
  import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
12
12
  import { VERSION } from "./version.js";
13
13
  import { LIMITS } from "./protocol.js";
14
+ import { signedRead } from "./reads.js";
14
15
  import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, waitingPrompt, workerSummary } from "./decision-ui.js";
15
16
  import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
16
17
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -75,6 +76,15 @@ async function getJson(url) {
75
76
  clearTimeout(t);
76
77
  }
77
78
  }
79
+ async function signedObligations(cfg) {
80
+ try {
81
+ const res = await signedRead(cfg, "/obligations", {}, { timeoutMs: LIMITS.pendingTimeoutMs });
82
+ return res.ok ? await res.json() : null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
78
88
  export async function trayStatus(cfg = load(), f = loadAll()) {
79
89
  const ms = Object.values(f?.spaces ?? {});
80
90
  if (f && ms.length > 1) {
@@ -105,10 +115,9 @@ async function trayStatusOne(cfg) {
105
115
  if (!cfg)
106
116
  return { configured: false, version: VERSION };
107
117
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
108
- const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
109
118
  const [health, obligations] = await Promise.all([
110
119
  getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
111
- getJson(`${base}/obligations?${q}`),
120
+ signedObligations(cfg),
112
121
  ]);
113
122
  const space = health
114
123
  ?.spaces?.find((s) => s.name === cfg.space);
@@ -20,6 +20,8 @@ export function updateStatus(deps) {
20
20
  if (cache && deps.now() - cache.checkedAt < deps.throttleMs) {
21
21
  return { behind: cache.behind, refreshed: false, latest: cache.latest };
22
22
  }
23
+ if (process.env["PANTHEON_NO_UPDATE_CHECK"])
24
+ return { behind: cache?.behind ?? 0, refreshed: false, latest: cache?.latest };
23
25
  if (deps.isGit()) {
24
26
  try {
25
27
  const behind = deps.fetchBehind();