@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/install.js CHANGED
@@ -10,6 +10,7 @@ import { loadProfile } from "./profile.js";
10
10
  import { agentSpawn, onPathNamed, which } from "./which.js";
11
11
  import { FEEDBACK_ADDRESS } from "./feedback.js";
12
12
  import { kiroDir, kiroAgentFile, refreshKiroAgentAllowed } from "./kiro-files.js";
13
+ import { eolOf, isJsonc, stripBom, stripJsonc, withEol } from "./text-file.js";
13
14
  const HERE = dirname(fileURLToPath(import.meta.url));
14
15
  export function onPath() {
15
16
  return onPathNamed("pantheon");
@@ -23,42 +24,65 @@ export function launcher(force, os = platform()) {
23
24
  : { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"], portable: false };
24
25
  }
25
26
  function readConfigJson(file) {
27
+ return readConfigDoc(file).doc;
28
+ }
29
+ function readConfigDoc(file) {
26
30
  if (!existsSync(file))
27
- return {};
31
+ return { doc: {}, text: null, jsonc: false };
28
32
  const text = readFileSync(file, "utf8");
29
- if (!text.trim())
30
- return {};
33
+ const body = stripBom(text);
34
+ if (!body.trim())
35
+ return { doc: {}, text, jsonc: false };
36
+ const asObject = (d) => (d && typeof d === "object" && !Array.isArray(d) ? d : null);
31
37
  try {
32
- const doc = JSON.parse(text);
33
- if (doc && typeof doc === "object" && !Array.isArray(doc))
34
- return doc;
38
+ const d = asObject(JSON.parse(body));
39
+ if (d)
40
+ return { doc: d, text, jsonc: false };
35
41
  }
36
42
  catch { }
43
+ if (isJsonc(body)) {
44
+ try {
45
+ const d = asObject(JSON.parse(stripJsonc(body)));
46
+ if (d)
47
+ return { doc: d, text, jsonc: true };
48
+ }
49
+ catch { }
50
+ }
37
51
  throw new Error(`${file} is not valid JSON — fix or move it, then re-run`);
38
52
  }
53
+ function writeConfigJson(file, doc, was) {
54
+ mkdirSync(dirname(file), { recursive: true });
55
+ const next = JSON.stringify(doc, null, 2) + "\n";
56
+ writeFileSync(file, was === null ? next : (was.charCodeAt(0) === 0xfeff ? "\uFEFF" : "") + withEol(next, eolOf(was)));
57
+ }
39
58
  function mergeMcpJson(file, entry, shape = {}) {
40
- const doc = readConfigJson(file);
59
+ const read = readConfigDoc(file);
60
+ const doc = read.doc;
41
61
  const existed = existsSync(file);
42
62
  doc.mcpServers ??= {};
43
63
  const before = JSON.stringify(doc.mcpServers["pantheon"] ?? null);
44
64
  doc.mcpServers["pantheon"] = { ...shape, ...entry, env: shape.env ?? {} };
45
65
  if (before === JSON.stringify(doc.mcpServers["pantheon"]))
46
66
  return "unchanged";
47
- mkdirSync(dirname(file), { recursive: true });
48
- writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
67
+ if (read.jsonc)
68
+ return `left as it is: ${jsoncNote(file, "mcpServers", doc.mcpServers["pantheon"])}`;
69
+ writeConfigJson(file, doc, read.text);
49
70
  return existed ? "updated" : "created";
50
71
  }
72
+ const jsoncNote = (file, key, entry) => `${file} has comments or trailing commas (JSONC), so it was left exactly as it is rather than rewritten without them — add this under "${key}" yourself: "pantheon": ${JSON.stringify(entry)}`;
51
73
  export const COPILOT_ENTRY_SHAPE = { type: "local", tools: ["*"] };
52
74
  export function mergeVsCodeMcpJson(file, entry) {
53
- const doc = readConfigJson(file);
75
+ const read = readConfigDoc(file);
76
+ const doc = read.doc;
54
77
  const existed = existsSync(file);
55
78
  doc.servers ??= {};
56
79
  const before = JSON.stringify(doc.servers["pantheon"] ?? null);
57
80
  doc.servers["pantheon"] = { type: "stdio", command: entry.command, args: entry.args };
58
81
  if (before === JSON.stringify(doc.servers["pantheon"]))
59
82
  return "unchanged";
60
- mkdirSync(dirname(file), { recursive: true });
61
- writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
83
+ if (read.jsonc)
84
+ return `left as it is: ${jsoncNote(file, "servers", doc.servers["pantheon"])}`;
85
+ writeConfigJson(file, doc, read.text);
62
86
  return existed ? "updated" : "created";
63
87
  }
64
88
  export const copilotMcpConfigFile = (home = homedir(), env = process.env) => join(env.COPILOT_HOME || join(home, ".copilot"), "mcp-config.json");
@@ -112,14 +136,16 @@ function upsertBrief(file, cfg) {
112
136
  return "created";
113
137
  }
114
138
  const cur = readFileSync(file, "utf8");
139
+ const eol = eolOf(cur);
140
+ const ours = withEol(block, eol);
115
141
  if (cur.includes(BRIEF_MARK)) {
116
- const next = cur.replace(/<!-- pantheon:begin -->[\s\S]*?<!-- pantheon:end -->/, block);
142
+ const next = cur.replace(/<!-- pantheon:begin -->[\s\S]*?<!-- pantheon:end -->/, () => ours);
117
143
  if (next === cur)
118
144
  return "unchanged";
119
145
  writeFileSync(file, next);
120
146
  return "updated";
121
147
  }
122
- writeFileSync(file, cur.trimEnd() + "\n\n" + block + "\n");
148
+ writeFileSync(file, cur.trimEnd() + eol + eol + ours + eol);
123
149
  return "updated";
124
150
  }
125
151
  export function install(projectDir, cfg, force, os = platform(), claudeLocal = claudeLocalScope) {
@@ -196,8 +222,25 @@ function userEntry(os = platform()) {
196
222
  const l = launcher();
197
223
  return { command: l.command, args: l.args };
198
224
  }
199
- function isVersionManagedPath(p) {
200
- return /[\/\\]\.nvm[\/\\]versions[\/\\]node[\/\\]|[\/\\]\.fnm[\/\\]|[\/\\]\.asdf[\/\\]installs[\/\\]nodejs[\/\\]|[\/\\]n[\/\\]versions[\/\\]node[\/\\]|[\/\\]\.volta[\/\\]tools[\/\\]/.test(p);
225
+ export function isVersionManagedPath(p) {
226
+ const s = "[\\/\\\\]";
227
+ return new RegExp([
228
+ `${s}\\.nvm${s}versions${s}node${s}`,
229
+ `${s}\\.fnm${s}`,
230
+ `${s}fnm${s}node-versions${s}`,
231
+ `${s}\\.local${s}share${s}fnm${s}`,
232
+ `${s}fnm_multishells${s}`,
233
+ `${s}(?:mise|rtx)${s}installs${s}node(?:js)?${s}`,
234
+ `${s}\\.asdf${s}installs${s}nodejs${s}`,
235
+ `${s}n${s}versions${s}node${s}`,
236
+ `${s}\\.volta${s}tools${s}`,
237
+ ].join("|")).test(p);
238
+ }
239
+ export function claudeAddFailed(e, command) {
240
+ const err = e;
241
+ const said = String(err.stderr ?? "").trim().split("\n").map((l) => l.trim()).filter(Boolean).slice(-2).join(" ").slice(0, 300);
242
+ const why = said || (err.message ?? "failed").split("\n")[0];
243
+ return `failed: claude mcp add did not work (${why}) — fix: run \`${command}\` yourself to see why, then pantheon install --user`;
201
244
  }
202
245
  function claudeUserScope(entry) {
203
246
  const tool = "claude code";
@@ -211,11 +254,11 @@ function claudeUserScope(entry) {
211
254
  execFileSync(remove.command, remove.args, { stdio: "ignore", ...remove.options });
212
255
  }
213
256
  catch { }
214
- execFileSync(add.command, add.args, { stdio: "ignore", ...add.options });
257
+ execFileSync(add.command, add.args, { stdio: ["ignore", "ignore", "pipe"], ...add.options });
215
258
  return { tool, where: "user scope", status: "ready in every project and every Conductor window" };
216
259
  }
217
260
  catch (e) {
218
- return { tool, where: "user scope", status: `failed: ${e.message.split("\n")[0]}` };
261
+ return { tool, where: "user scope", status: claudeAddFailed(e, `claude mcp add pantheon -s user -- ${[entry.command, ...entry.args].join(" ")}`) };
219
262
  }
220
263
  }
221
264
  function claudeLocalScope(entry, projectDir) {
@@ -230,11 +273,11 @@ function claudeLocalScope(entry, projectDir) {
230
273
  execFileSync(remove.command, remove.args, { stdio: "ignore", cwd: projectDir, ...remove.options });
231
274
  }
232
275
  catch { }
233
- execFileSync(add.command, add.args, { stdio: "ignore", cwd: projectDir, ...add.options });
276
+ execFileSync(add.command, add.args, { stdio: ["ignore", "ignore", "pipe"], cwd: projectDir, ...add.options });
234
277
  return { tool, where: "local scope, this folder", status: "local override written — Claude Code starts pantheon here with this machine's node, and nothing in the repo changed" };
235
278
  }
236
279
  catch (e) {
237
- return { tool, where: "local scope", status: `failed: ${e.message.split("\n")[0]}` };
280
+ return { tool, where: "local scope", status: claudeAddFailed(e, `claude mcp add pantheon -s local -- ${[entry.command, ...entry.args].join(" ")}`) };
238
281
  }
239
282
  }
240
283
  export function agentPresence(home = homedir(), onPath = onPathNamed) {
@@ -328,7 +371,7 @@ const TOOL_TABLE = /^mcp_servers\.pantheon\.tools\.(pantheon_[a-z_]+)$/;
328
371
  const OUR_SERVER_KEYS = /^\s*(command|args|env|default_tools_approval_mode)\s*=/;
329
372
  function parseTables(current) {
330
373
  const tables = [{ name: null, lines: [] }];
331
- for (const line of current.split("\n")) {
374
+ for (const line of stripBom(current).replace(/\r\n/g, "\n").split("\n")) {
332
375
  const h = HEADER.exec(line);
333
376
  if (h)
334
377
  tables.push({ name: h[1].trim(), lines: [line] });
@@ -506,8 +549,9 @@ export function upsertCodexToml(current, entry, approval = "approve") {
506
549
  let body = out.join("\n").trimEnd();
507
550
  if (!placed)
508
551
  body = (body ? body + "\n\n" : "") + ourBlock().join("\n").trimEnd();
509
- return body + "\n";
552
+ return sameShape(current, body + "\n");
510
553
  }
554
+ const sameShape = (original, next) => (original.charCodeAt(0) === 0xfeff ? "\uFEFF" : "") + withEol(next, eolOf(original));
511
555
  export function removeCodexToml(current) {
512
556
  const out = [];
513
557
  for (const t of parseTables(current)) {
@@ -528,7 +572,7 @@ export function removeCodexToml(current) {
528
572
  out.push(...t.lines);
529
573
  }
530
574
  const body = out.join("\n").trimEnd();
531
- return body ? body + "\n" : "";
575
+ return body ? sameShape(current, body + "\n") : "";
532
576
  }
533
577
  export const codexConfigFile = () => join(homedir(), ".codex", "config.toml");
534
578
  export function codexApprovalOf(current) {
@@ -569,7 +613,10 @@ function codexUserScope(entry, approval, present = true) {
569
613
  const cur = readFileSync(file, "utf8");
570
614
  const had = codexApprovalOf(cur).present;
571
615
  const kept = codexKept(cur, approval);
572
- writeFileSync(file, upsertCodexToml(cur, entry, approval));
616
+ const next = upsertCodexToml(cur, entry, approval);
617
+ if (next === cur)
618
+ return { tool: "codex", where: file, status: `unchanged — ${said} is already there` };
619
+ writeFileSync(file, next);
573
620
  const notes = [
574
621
  kept.toolTables.length ? `kept your own [mcp_servers.pantheon.tools.*] settings for ${kept.toolTables.join(", ")}` : "",
575
622
  kept.serverKeys.length ? `kept your own ${kept.serverKeys.join(", ")} in the server table` : "",
@@ -636,14 +683,17 @@ export function removeMcpJsonEntry(file, key = "mcpServers") {
636
683
  try {
637
684
  if (!existsSync(file))
638
685
  return "absent";
639
- const doc = readConfigJson(file);
686
+ const read = readConfigDoc(file);
687
+ const doc = read.doc;
640
688
  const servers = doc[key];
641
689
  if (!servers || !("pantheon" in servers))
642
690
  return "absent";
643
691
  if (!isOurMcpEntry(servers["pantheon"]))
644
692
  return "not ours";
693
+ if (read.jsonc)
694
+ return `failed: it has comments or trailing commas (JSONC), so it was left exactly as it is`;
645
695
  delete servers["pantheon"];
646
- writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
696
+ writeConfigJson(file, doc, read.text);
647
697
  return "removed";
648
698
  }
649
699
  catch (e) {
@@ -35,7 +35,7 @@ export function readKiroAgent(file, tracked = gitTracks) {
35
35
  if (!existsSync(file))
36
36
  return null;
37
37
  try {
38
- const doc = JSON.parse(readFileSync(file, "utf8"));
38
+ const doc = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""));
39
39
  const hooks = Object.entries(doc.hooks && typeof doc.hooks === "object" ? doc.hooks : {});
40
40
  const events = hooks.filter(([, es]) => Array.isArray(es) && es.some((h) => isOurKiroHook(h?.command))).map(([e]) => e);
41
41
  const onlyOurs = hooks.every(([, es]) => Array.isArray(es) && es.every((h) => isOurKiroHook(h?.command)));
@@ -64,7 +64,7 @@ export function refreshKiroAgentAllowed(file, allowed) {
64
64
  }
65
65
  export function kiroDefaultIsPantheon(base = kiroDir()) {
66
66
  try {
67
- return JSON.parse(readFileSync(join(base, "settings", "cli.json"), "utf8"))["chat.defaultAgent"] === KIRO_AGENT_NAME;
67
+ return JSON.parse(readFileSync(join(base, "settings", "cli.json"), "utf8").replace(/^\uFEFF/, ""))["chat.defaultAgent"] === KIRO_AGENT_NAME;
68
68
  }
69
69
  catch {
70
70
  return false;
package/dist/ledger.js CHANGED
@@ -1,41 +1,121 @@
1
- import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync, renameSync } from "node:fs";
1
+ import { mkdirSync, existsSync, openSync, readSync, writeSync, fsyncSync, closeSync, fstatSync, copyFileSync, truncateSync, chmodSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { load } from "./config.js";
4
+ import { fsyncDir, renameRetrying, writeFileAtomic } from "./store.js";
5
+ export class SpaceFull extends Error {
6
+ bytes;
7
+ cap;
8
+ constructor(bytes, cap) {
9
+ super(`this team's history is at the relay's limit (${(bytes / 1e6).toFixed(1)} of ${(cap / 1e6).toFixed(1)} MB)`);
10
+ this.bytes = bytes;
11
+ this.cap = cap;
12
+ this.name = "SpaceFull";
13
+ }
14
+ }
15
+ import { signedRead } from "./reads.js";
4
16
  export class Ledger {
5
17
  records = [];
18
+ byId = new Map();
6
19
  seq = 0;
20
+ fileBytes = 0;
21
+ outOfOrder = 0;
22
+ firstOutOfOrder = null;
7
23
  file;
8
24
  constructor(dir, space) {
9
25
  this.file = join(dir, `${space}.jsonl`);
10
- mkdirSync(dirname(this.file), { recursive: true });
11
- if (existsSync(this.file)) {
12
- let text = readFileSync(this.file, "utf8");
13
- if (text.length && !text.endsWith("\n")) {
14
- const keep = text.lastIndexOf("\n") + 1;
15
- text = text.slice(0, keep);
16
- writeFileSync(this.file, text);
17
- }
18
- for (const line of text.split("\n")) {
19
- if (!line.trim())
20
- continue;
21
- try {
22
- const r = JSON.parse(line);
23
- this.records.push(r);
24
- if (r.seq > this.seq)
25
- this.seq = r.seq;
26
- }
27
- catch {
26
+ mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
27
+ if (existsSync(this.file))
28
+ this.read();
29
+ }
30
+ read() {
31
+ const fd = openSync(this.file, "r");
32
+ let size = 0, lastNl = -1;
33
+ try {
34
+ size = fstatSync(fd).size;
35
+ const buf = Buffer.allocUnsafe(1 << 20);
36
+ let pos = 0;
37
+ let carry = [];
38
+ for (let n = readSync(fd, buf, 0, buf.length, pos); n > 0; n = readSync(fd, buf, 0, buf.length, pos)) {
39
+ let start = 0;
40
+ for (let i = buf.indexOf(10, 0); i !== -1 && i < n; i = buf.indexOf(10, i + 1)) {
41
+ const part = buf.subarray(start, i);
42
+ const line = carry.length ? Buffer.concat([...carry, part]).toString("utf8") : part.toString("utf8");
43
+ carry = [];
44
+ this.take(line);
45
+ start = i + 1;
46
+ lastNl = pos + i;
28
47
  }
48
+ if (start < n)
49
+ carry.push(Buffer.from(buf.subarray(start, n)));
50
+ pos += n;
51
+ }
52
+ }
53
+ finally {
54
+ closeSync(fd);
55
+ }
56
+ this.fileBytes = size;
57
+ const keep = lastNl + 1;
58
+ if (keep < size) {
59
+ const tmp = `${this.file}.tmp`;
60
+ copyFileSync(this.file, tmp);
61
+ truncateSync(tmp, keep);
62
+ const fd2 = openSync(tmp, "r+");
63
+ try {
64
+ fsyncSync(fd2);
65
+ }
66
+ finally {
67
+ closeSync(fd2);
29
68
  }
69
+ renameRetrying(tmp, this.file);
70
+ fsyncDir(dirname(this.file));
71
+ this.fileBytes = keep;
72
+ }
73
+ try {
74
+ chmodSync(this.file, 0o600);
75
+ }
76
+ catch { }
77
+ }
78
+ take(line) {
79
+ if (!line.trim())
80
+ return;
81
+ try {
82
+ const r = JSON.parse(line);
83
+ this.records.push(r);
84
+ if (typeof r.id === "string" && !this.byId.has(r.id))
85
+ this.byId.set(r.id, r);
86
+ if (r.seq > this.seq)
87
+ this.seq = r.seq;
88
+ else if (this.records.length > 1) {
89
+ this.outOfOrder++;
90
+ this.firstOutOfOrder ??= r.seq;
91
+ }
92
+ }
93
+ catch {
30
94
  }
31
95
  }
32
96
  get head() {
33
97
  return this.seq;
34
98
  }
35
- append(rec) {
36
- const full = { ...rec, seq: ++this.seq };
37
- appendFileSync(this.file, JSON.stringify(full) + "\n");
99
+ get bytes() {
100
+ return this.fileBytes;
101
+ }
102
+ append(rec, cap = 0) {
103
+ const full = { ...rec, seq: this.seq + 1 };
104
+ const line = Buffer.from(JSON.stringify(full) + "\n");
105
+ if (cap > 0 && this.fileBytes + line.length > cap)
106
+ throw new SpaceFull(this.fileBytes, cap);
107
+ const fd = openSync(this.file, "a", 0o600);
108
+ try {
109
+ writeSync(fd, line);
110
+ fsyncSync(fd);
111
+ }
112
+ finally {
113
+ closeSync(fd);
114
+ }
115
+ this.seq = full.seq;
116
+ this.fileBytes += line.length;
38
117
  this.records.push(full);
118
+ this.byId.set(full.id, full);
39
119
  return full;
40
120
  }
41
121
  since(seq, limit = 500) {
@@ -49,7 +129,7 @@ export class Ledger {
49
129
  return hits.slice(Math.max(0, hits.length - limit));
50
130
  }
51
131
  find(id) {
52
- return this.records.find((r) => r.id === id);
132
+ return this.byId.get(id);
53
133
  }
54
134
  all() {
55
135
  return this.records;
@@ -65,10 +145,14 @@ export class Ledger {
65
145
  const dropped = this.records.length - kept.length;
66
146
  if (dropped === 0)
67
147
  return { dropped: 0, kept: kept.length };
68
- const tmp = `${this.file}.tmp`;
69
- writeFileSync(tmp, kept.map((r) => JSON.stringify(r)).join("\n") + "\n");
70
- renameSync(tmp, this.file);
148
+ const text = kept.map((r) => JSON.stringify(r)).join("\n") + "\n";
149
+ writeFileAtomic(this.file, text);
71
150
  this.records = kept;
151
+ this.byId = new Map();
152
+ for (const r of kept)
153
+ if (!this.byId.has(r.id))
154
+ this.byId.set(r.id, r);
155
+ this.fileBytes = Buffer.byteLength(text);
72
156
  return { dropped, kept: kept.length };
73
157
  }
74
158
  }
@@ -105,10 +189,9 @@ export async function findDelivered(actId, opts) {
105
189
  if (!cfg)
106
190
  throw new LedgerUnreachable("no pantheon config — run `pantheon join` or `pantheon init` first");
107
191
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
108
- const url = `${base}/history?space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
109
192
  let acts;
110
193
  try {
111
- const res = await fetch(url);
194
+ const res = await signedRead(cfg, "/history");
112
195
  if (!res.ok)
113
196
  throw new LedgerUnreachable(`relay ${base} answered ${res.status} for /history`);
114
197
  acts = (await res.json()).acts ?? [];