@join-pantheon/cli 0.31.1 → 0.31.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/README.md CHANGED
@@ -127,8 +127,10 @@ Need another (a third person, or your own second machine)? `pantheon invite`.
127
127
 
128
128
  ### Plans on the hosted relay
129
129
 
130
- A free account creates **one space** with **two people** in it — you and one invite. Personas,
131
- messages and history are not limited. Joining spaces other people created is not limited either,
130
+ A free account creates up to **three spaces**, with **two people** in each, and can have three
131
+ invite links open per space. A link is not a seat: if a third person uses one, they are told the
132
+ team is full and who can upgrade it, the owner gets an email, and the same link works once there is
133
+ room. Personas, messages and history are not limited. Joining spaces other people created is not limited either,
132
134
  so a colleague on a bigger plan can always invite you. When you hit a limit the command says so
133
135
  and what to do; `pantheon status` shows your plan and how much of it you are using. Need more?
134
136
  [joinpantheon.network](https://www.joinpantheon.network). A space you founded and no longer need
@@ -0,0 +1,87 @@
1
+ const MIN = 60_000, HOUR = 60 * MIN;
2
+ export const MAX_TEXT_WAIT_MS = 6 * HOUR;
3
+ const TAIL_LINES = 5;
4
+ const PLAN = "its organization has disabled subscription access for it";
5
+ const LIMIT = "its usage limit is reached";
6
+ const SIGNIN = "its sign-in was refused";
7
+ const OVERLOAD = "its service is overloaded";
8
+ const KINDS = {
9
+ "claude-code": [
10
+ { re: /disabled Claude subscription access/i, why: PLAN, wait: 6 * HOUR },
11
+ { re: /Claude AI usage limit reached|(?:5-hour|weekly|Opus|session) limit reached|you(?:'|’)ve hit your (?:usage )?limit|out of extra usage|credit balance is too low/i, why: LIMIT, wait: HOUR },
12
+ { re: /OAuth token has expired|Invalid API key · Please run \/login|Please run \/login/i, why: SIGNIN, wait: HOUR },
13
+ { re: /overloaded_error/i, why: OVERLOAD, wait: 15 * MIN },
14
+ ],
15
+ codex: [
16
+ { re: /you(?:'|’)ve hit your usage limit|exceeded retry limit, last status: 429|insufficient_quota/i, why: LIMIT, wait: HOUR },
17
+ ],
18
+ cursor: [
19
+ { re: /you(?:'|’)ve hit your usage limit|usage limit (?:reached|exceeded) for|out of (?:fast )?requests/i, why: LIMIT, wait: HOUR },
20
+ ],
21
+ copilot: [
22
+ { re: /you(?:'|’)ve hit a rate limit|exceeded your (?:monthly )?(?:premium requests|copilot) (?:quota|allowance|limit)/i, why: LIMIT, wait: HOUR },
23
+ ],
24
+ kiro: [],
25
+ };
26
+ function namedTime(line, now) {
27
+ const rel = /try again in\s+(\d{1,3})\s*(minute|min|hour|hr|day)s?\b/i.exec(line);
28
+ if (rel) {
29
+ const n = Number(rel[1]);
30
+ const u = rel[2].toLowerCase();
31
+ return now + n * (u.startsWith("d") ? 24 * HOUR : u.startsWith("h") ? HOUR : MIN);
32
+ }
33
+ const m = /(?:resets?|reset at|try again (?:at|after))\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i.exec(line);
34
+ if (!m)
35
+ return null;
36
+ let h = Number(m[1]);
37
+ const min = m[2] ? Number(m[2]) : 0;
38
+ const ap = m[3]?.toLowerCase();
39
+ if (ap === "pm" && h < 12)
40
+ h += 12;
41
+ if (ap === "am" && h === 12)
42
+ h = 0;
43
+ if (h > 23 || min > 59 || (!ap && !m[2]))
44
+ return null;
45
+ const d = new Date(now);
46
+ d.setHours(h, min, 0, 0);
47
+ let t = d.getTime();
48
+ if (t <= now)
49
+ t += 24 * HOUR;
50
+ return t;
51
+ }
52
+ export function detectOutage(agent, tail, now = Date.now()) {
53
+ const lines = tail.split("\n").map((l) => l.trim()).filter(Boolean).slice(-TAIL_LINES);
54
+ for (const k of KINDS[agent] ?? []) {
55
+ const line = lines.find((l) => k.re.test(l));
56
+ if (!line)
57
+ continue;
58
+ const epoch = agent === "claude-code" ? /Claude AI usage limit reached\|(\d{10})\b/i.exec(line) : null;
59
+ const named = epoch ? Number(epoch[1]) * 1000 : namedTime(line, now);
60
+ const until = named && named > now ? Math.min(named, now + MAX_TEXT_WAIT_MS) : now + k.wait;
61
+ return { why: k.why, until: Math.max(until, now + 5 * MIN) };
62
+ }
63
+ return null;
64
+ }
65
+ export function whenAgain(until, now = Date.now()) {
66
+ const d = new Date(until);
67
+ const hm = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
68
+ return new Date(now).toDateString() === d.toDateString() ? hm : `${d.toLocaleDateString("en-GB", { weekday: "short" })} ${hm}`;
69
+ }
70
+ export function activeOutages(all, now = Date.now()) {
71
+ const out = {};
72
+ for (const [id, o] of all) {
73
+ if (o.until > now)
74
+ out[id] = o;
75
+ else
76
+ all.delete(id);
77
+ }
78
+ return out;
79
+ }
80
+ export function earliest(outages) {
81
+ let best;
82
+ for (const o of Object.values(outages))
83
+ if (o && (!best || o.until < best.until))
84
+ best = o;
85
+ return best;
86
+ }
87
+ //# sourceMappingURL=agent-outage.js.map
package/dist/agents.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { copyFileSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync } from "node:fs";
4
4
  import { constants as osConstants, homedir, platform } from "node:os";
5
- import { join, dirname, isAbsolute } from "node:path";
5
+ import { join, dirname, isAbsolute, relative } from "node:path";
6
+ import { copyInside, mkdirInside, writeInside } from "./safe-write.js";
7
+ import { gitCallEnv, safeGitArgs } from "./git-safe.js";
6
8
  import { launcher, kiroDir } from "./install.js";
7
9
  import { agentSpawn, which } from "./which.js";
10
+ import { earliest, whenAgain } from "./agent-outage.js";
8
11
  import { PANTHEON_TOOL_NAMES, PANTHEON_TOOLS } from "./tools.js";
9
12
  import { claudeRunSettings, NO_CLAUDE_FEATURES, runTmpDir, sandboxCacheEnv, versionAtLeast } from "./claude-confine.js";
10
13
  export function launchArgs(agent, prompt, name) {
@@ -211,7 +214,8 @@ export function chooseAgent(preferred, opts = {}) {
211
214
  const hit = available.find((a) => a.backend.id === backend.id);
212
215
  if (!hit)
213
216
  throw new Error(`${backend.label} is not on PATH (looked for ${backend.binaries.join(", ")})`);
214
- return { backend, binary: hit.binary, signIn: state(backend, hit.binary), because: "pinned", skipped: [] };
217
+ const out = opts.outages?.[backend.id];
218
+ return { backend, binary: hit.binary, signIn: state(backend, hit.binary), because: "pinned", skipped: [], ...(out ? { outage: out } : {}) };
215
219
  }
216
220
  if (!available.length)
217
221
  throw new Error("no agent CLI on PATH — install claude, codex, cursor-agent, copilot, or kiro-cli");
@@ -239,12 +243,29 @@ export function chooseAgent(preferred, opts = {}) {
239
243
  order.splice(0, order.length, ...boxed);
240
244
  }
241
245
  for (const c of order) {
246
+ const out = opts.outages?.[c.backend.id];
247
+ if (out) {
248
+ skipped.push({ backend: c.backend, why: `unavailable until ${whenAgain(out.until)} — ${out.why}` });
249
+ continue;
250
+ }
242
251
  const s = state(c.backend, c.binary);
243
252
  if (!s || s.ok || s.unknown)
244
253
  return { backend: c.backend, binary: c.binary, ...(s ? { signIn: s } : {}), because: c.because, skipped };
245
254
  skipped.push({ backend: c.backend, why: `${s.detail} — ${SIGN_IN_CHECK[c.backend.id].fix}` });
246
255
  }
247
- const first = order.find((o) => !NEEDS_SIGN_IN_BEFORE_SPAWN.has(o.backend.id)) ?? order[0];
256
+ const outs = {};
257
+ for (const o of order) {
258
+ const x = opts.outages?.[o.backend.id];
259
+ if (x)
260
+ outs[o.backend.id] = x;
261
+ }
262
+ const soonest = earliest(outs);
263
+ if (soonest) {
264
+ const first = order.find((o) => outs[o.backend.id] === soonest);
265
+ return { backend: first.backend, binary: first.binary, because: `${first.because} (every agent here is unavailable for now)`, skipped, outage: soonest };
266
+ }
267
+ const live = order;
268
+ const first = live.find((o) => !NEEDS_SIGN_IN_BEFORE_SPAWN.has(o.backend.id)) ?? live[0];
248
269
  return { backend: first.backend, binary: first.binary, signIn: state(first.backend, first.binary), because: `${first.because} (none signed in)`, skipped };
249
270
  }
250
271
  export function unattendedRefusal(c, o = {}) {
@@ -371,7 +392,7 @@ export function repoCommands(worktree, rev) {
371
392
  return { ok: [], refused: [] };
372
393
  let bytes;
373
394
  try {
374
- bytes = execFileSync("git", ["-C", worktree, "show", `${rev}:${REPO_COMMANDS_FILE}`], { stdio: ["ignore", "pipe", "ignore"], timeout: 10_000, maxBuffer: 1 << 20 });
395
+ bytes = execFileSync("git", safeGitArgs(worktree, ["-C", worktree, "show", `${rev}:${REPO_COMMANDS_FILE}`]), { stdio: ["ignore", "pipe", "ignore"], env: gitCallEnv(["show"], process.env), timeout: 10_000, maxBuffer: 1 << 20 });
375
396
  }
376
397
  catch {
377
398
  return { ok: [], refused: [] };
@@ -451,6 +472,7 @@ export function headlessSpawn(opts) {
451
472
  ...(opts.readOnlyDirs?.length ? { readOnlyDirs: opts.readOnlyDirs } : {}),
452
473
  runTmp, bashRules, version: f.version, ...(opts.claude.home ? { home: opts.claude.home } : {}),
453
474
  ...(opts.os ? { os: opts.os } : {}), ...(opts.claude.userSettings ? { userSettings: opts.claude.userSettings } : {}),
475
+ ...(opts.claude.gitCommon ? { gitCommon: opts.claude.gitCommon } : {}),
454
476
  });
455
477
  if (f.permissionPrompts)
456
478
  extra.push("--permission-prompts", "none");
@@ -629,16 +651,28 @@ export function tomlInlineTable(kv, literal = false) {
629
651
  export function tomlLiteral(v) {
630
652
  return /['\r\n]/.test(v) ? JSON.stringify(v) : `'${v}'`;
631
653
  }
632
- export function materializeSpawnFiles(worktree, spawn) {
654
+ export function materializeSpawnFiles(worktree, spawn, runHome) {
655
+ const rootOf = (p) => {
656
+ for (const r of [runHome, worktree]) {
657
+ if (!r)
658
+ continue;
659
+ const rel = relative(r, p);
660
+ if (rel && !rel.startsWith("..") && !isAbsolute(rel))
661
+ return r;
662
+ }
663
+ if (!runHome && isAbsolute(p)) {
664
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
665
+ return dirname(p);
666
+ }
667
+ throw new Error(`${p} is neither in the worktree nor in the run's home — not written`);
668
+ };
633
669
  for (const f of spawn.writeFiles ?? []) {
634
670
  const p = isAbsolute(f.path) ? f.path : join(worktree, f.path);
635
- mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
636
- writeFileSync(p, f.contents, { mode: 0o600 });
671
+ writeInside(rootOf(p), p, f.contents, 0o600);
637
672
  }
638
673
  for (const c of spawn.copyFiles ?? []) {
639
674
  try {
640
- mkdirSync(dirname(c.to), { recursive: true, mode: 0o700 });
641
- copyFileSync(c.from, c.to);
675
+ copyInside(rootOf(c.to), c.from, c.to, 0o600);
642
676
  }
643
677
  catch { }
644
678
  }
@@ -690,7 +724,7 @@ export function hashPath(p) {
690
724
  }
691
725
  function headOf(worktree) {
692
726
  try {
693
- return execFileSync("git", ["-C", worktree, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }).trim();
727
+ return execFileSync("git", safeGitArgs(worktree, ["-C", worktree, "rev-parse", "HEAD"]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: gitCallEnv(["rev-parse"], process.env), timeout: 10_000 }).trim();
694
728
  }
695
729
  catch {
696
730
  return "";
@@ -768,11 +802,11 @@ function freeName(base) {
768
802
  if (!taken(`${base}-${i}`))
769
803
  return `${base}-${i}`;
770
804
  }
771
- const writeManifest = (dir, m) => writeFileSync(join(dir, "manifest.json"), JSON.stringify(m), { mode: 0o600 });
805
+ const writeManifest = (dir, m) => writeInside(dir, join(dir, "manifest.json"), JSON.stringify(m), 0o600);
772
806
  function keepDiscarded(stashRoot, dir, rel) {
773
807
  const to = join(stashRoot, "quarantine-kept", `${Date.now()}-${randomBytes(3).toString("hex")}`, rel);
774
808
  try {
775
- mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
809
+ mkdirInside(stashRoot, dirname(to));
776
810
  moveSync(join(dir, "files", rel), to);
777
811
  return to;
778
812
  }
@@ -826,10 +860,15 @@ function restoreFrom(stashRoot, dir, m, mode) {
826
860
  r.lines.push(`${e.rel}: could not be put back (${err.message.split("\n")[0]}); it stays held for the next run`);
827
861
  }
828
862
  }
829
- if (left.length)
830
- writeManifest(dir, { ...m, moved: left });
831
- else
832
- rmSync(dir, { recursive: true, force: true });
863
+ try {
864
+ if (left.length)
865
+ writeManifest(dir, { ...m, moved: left });
866
+ else
867
+ rmSync(dir, { recursive: true, force: true });
868
+ }
869
+ catch (err) {
870
+ r.lines.push(`the record of what is still held could not be written (${err.message.split("\n")[0]}); the held files are in ${dir}`);
871
+ }
833
872
  return r;
834
873
  }
835
874
  function readManifest(file) {
@@ -850,7 +889,7 @@ export function recoverRepoAgentConfig(worktree, stashRoot) {
850
889
  if (existsSync(join(dir, "manifest.json"))) {
851
890
  const kept = join(stashRoot, "quarantine-kept", `${Date.now()}-legacy`);
852
891
  try {
853
- mkdirSync(dirname(kept), { recursive: true });
892
+ mkdirInside(stashRoot, dirname(kept));
854
893
  moveSync(dir, kept);
855
894
  }
856
895
  catch { }
@@ -942,7 +981,7 @@ export function quarantineRepoAgentConfig(worktree, stashRoot) {
942
981
  const recovered = recoverRepoAgentConfig(worktree, stashRoot);
943
982
  const dir = quarantineDir(stashRoot, worktree);
944
983
  const m = { v: 2, worktree, head: headOf(worktree), moved: [], wrote: {} };
945
- mkdirSync(join(dir, "files"), { recursive: true, mode: 0o700 });
984
+ mkdirInside(stashRoot, join(dir, "files"));
946
985
  let done = null;
947
986
  const restore = () => {
948
987
  if (done)
@@ -962,7 +1001,7 @@ export function quarantineRepoAgentConfig(worktree, stashRoot) {
962
1001
  continue;
963
1002
  const hash = hashPath(join(worktree, unit));
964
1003
  const to = join(dir, "files", unit);
965
- mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
1004
+ mkdirInside(stashRoot, dirname(to));
966
1005
  moveSync(join(worktree, unit), to);
967
1006
  m.moved.push({ rel: unit, hash });
968
1007
  writeManifest(dir, m);
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { writeInside } from "./safe-write.js";
4
5
  import { homedir, platform, tmpdir } from "node:os";
5
6
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
7
  import { agentSpawn } from "./which.js";
@@ -97,6 +98,17 @@ export const GIT_DENY_RULES = [
97
98
  "Bash(git push --delete*)", "Bash(git push * --delete*)", "Bash(git push -d *)", "Bash(git push * -d *)",
98
99
  "Bash(git push --mirror*)", "Bash(git push * --mirror*)", "Bash(git push --all*)", "Bash(git push * --all*)",
99
100
  ];
101
+ export function gitDataDeny(common) {
102
+ const c = resolve(common);
103
+ const files = [join(c, "config"), join(c, "commondir"), join(c, "objects", "info", "alternates")];
104
+ try {
105
+ for (const w of readdirSync(join(c, "worktrees")))
106
+ for (const f of ["config.worktree", "commondir", "gitdir"])
107
+ files.push(join(c, "worktrees", w, f));
108
+ }
109
+ catch { }
110
+ return { files, dirs: [join(c, "hooks"), join(c, "info")] };
111
+ }
100
112
  const inside = (child, parent) => {
101
113
  const r = relative(parent, child);
102
114
  return r === "" || (!!r && !r.startsWith("..") && !isAbsolute(r));
@@ -175,6 +187,7 @@ export function claudeRunSettings(i) {
175
187
  return !bad;
176
188
  });
177
189
  const keeps = [worktree, ...readOnly];
190
+ const gitDeny = i.gitCommon ? gitDataDeny(i.gitCommon) : null;
178
191
  for (const t of targets) {
179
192
  if (seen.has(t.abs))
180
193
  continue;
@@ -200,6 +213,11 @@ export function claudeRunSettings(i) {
200
213
  return [`Read(${p})`, `Edit(${p})`];
201
214
  }),
202
215
  ...readOnly.flatMap((d) => [`Edit(${ruleSpelling(d, home, os)}/**)`, ...SECRET_FILE_GLOBS.map((g) => `Read(${ruleSpelling(d, home, os)}/**/${g})`)]),
216
+ ...(gitDeny ? [
217
+ ...gitDeny.dirs.map((d) => `Edit(${ruleSpelling(d, home, os)}/**)`),
218
+ ...gitDeny.files.map((f) => `Edit(${ruleSpelling(f, home, os)})`),
219
+ `Edit(${ruleSpelling(join(resolve(i.gitCommon), "worktrees"), home, os)}/*/config.worktree)`,
220
+ ] : []),
203
221
  ];
204
222
  if (!i.allowBash) {
205
223
  const permissions = { deny: ["Bash", "PowerShell", ...fileRules] };
@@ -230,7 +248,7 @@ export function claudeRunSettings(i) {
230
248
  allowUnsandboxedCommands: false,
231
249
  filesystem: {
232
250
  allowWrite: [...new Set([resolve(i.runTmp), resolve(tmpdir())])],
233
- denyWrite: readOnly.map((d) => sandboxSpelling(d, home)),
251
+ denyWrite: [...readOnly.map((d) => sandboxSpelling(d, home)), ...(gitDeny ? [...gitDeny.dirs, ...gitDeny.files].map((p) => sandboxSpelling(p, home)) : [])],
234
252
  denyRead: denied.map((t) => sandboxSpelling(t.abs, home)),
235
253
  allowRead: [hooks],
236
254
  },
@@ -253,8 +271,7 @@ export function runTmpDir(runHome) {
253
271
  if (!/^[0-9a-f]{32}$/.test(salt)) {
254
272
  salt = randomBytes(16).toString("hex");
255
273
  try {
256
- mkdirSync(runHome, { recursive: true, mode: 0o700 });
257
- writeFileSync(saltFile, salt, { mode: 0o600 });
274
+ writeInside(runHome, saltFile, salt, 0o600);
258
275
  }
259
276
  catch { }
260
277
  }