@join-pantheon/cli 0.31.0 → 0.31.2-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/relay.js CHANGED
@@ -13,7 +13,8 @@ import { emailSenderFromEnv } from "./email.js";
13
13
  import { Allowlist, RosterUnreadable } from "./roster-file.js";
14
14
  import { StateStore, ensurePrivateDir } from "./store.js";
15
15
  import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
16
- import { fmtLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
16
+ import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
17
+ import { FEEDBACK_ADDRESS } from "./feedback.js";
17
18
  import { routeDecision } from "./profile.js";
18
19
  function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
19
20
  let known = false;
@@ -949,9 +950,12 @@ export class Relay {
949
950
  if (!pubkey || !sig || !Number.isFinite(ts) || Math.abs(Date.now() - ts) > LIMITS.accountFreshMs)
950
951
  return null;
951
952
  const params = {};
952
- for (const [k, v] of url.searchParams)
953
+ for (const [k, v] of url.searchParams) {
954
+ if (Object.prototype.hasOwnProperty.call(params, k) || (k === "sig" && url.searchParams.getAll("sig").length > 1))
955
+ return null;
953
956
  if (k !== "sig")
954
957
  params[k] = v;
958
+ }
955
959
  if (!verifyAccount(pubkey, method, path, params, ts, sig))
956
960
  return null;
957
961
  const device = A.deviceByPubkey(pubkey);
@@ -1520,15 +1524,15 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1520
1524
  case "set_plan_limits": {
1521
1525
  if (!PLAN_NAME.test(id))
1522
1526
  return this.json(res, 400, { error: "plan names are lowercase letters, digits, - and _" });
1523
- const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off" });
1527
+ const lim = parseLimits({ spaces: body.spaces, members: body.members, wake: body.wake ?? "off", ...(body.invites !== undefined && body.invites !== "" ? { invites: body.invites } : {}) });
1524
1528
  if (!lim)
1525
- return this.json(res, 400, { error: "spaces must be 0 or more and members 1 or more" });
1529
+ return this.json(res, 400, { error: "spaces and invites must be 0 or more and members 1 or more" });
1526
1530
  const plans = A.plans();
1527
1531
  const prev = plans[id];
1528
1532
  plans[id] = lim;
1529
1533
  A.setPlans(plans);
1530
1534
  A.audit({ event: "admin_set_plan_limits", target: id, detail: `${prev ? JSON.stringify(prev) : "(new plan)"} → ${JSON.stringify(lim)}`, ip });
1531
- return done(`${id} plan: ${fmtLimit(lim.spaces)} spaces · ${fmtLimit(lim.members)} people · unattended workers ${lim.wake ? "on" : "off"} — applies to the next create, invite or admit`);
1535
+ return done(`${id} plan: ${fmtLimit(lim.spaces)} spaces · ${fmtLimit(lim.members)} people · ${fmtLimit(inviteLimit(lim, id))} open invite links · unattended workers ${lim.wake ? "on" : "off"} — applies to the next create, invite or admit`);
1532
1536
  }
1533
1537
  case "delete_plan": {
1534
1538
  if (id === "free")
@@ -1820,6 +1824,34 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1820
1824
  const fresh = this.accounts?.accountById(acct.id) ?? acct;
1821
1825
  return { name: fresh.plan, limits: this.accounts.limitsFor(fresh), usage: { spaces: this.accounts.spacesOwned(fresh.id).length, members: sp.allow.admittedCount() } };
1822
1826
  }
1827
+ fullNoticeSent = new Map();
1828
+ tellOwnerTeamFull(sp, plan, joiner) {
1829
+ const owner = plan.owner;
1830
+ if (!owner?.email || !this.email.sendNotice)
1831
+ return;
1832
+ const key = `${sp.name}|${joiner}`;
1833
+ const last = this.fullNoticeSent.get(key) ?? 0;
1834
+ if (Date.now() - last < 24 * 3600_000)
1835
+ return;
1836
+ this.fullNoticeSent.set(key, Date.now());
1837
+ if (this.fullNoticeSent.size > 5000)
1838
+ this.fullNoticeSent.delete(this.fullNoticeSent.keys().next().value);
1839
+ const subject = `${joiner} tried to join "${sp.name}" — your team is full`;
1840
+ const text = [
1841
+ `Hi ${owner.username},`,
1842
+ ``,
1843
+ `${joiner} just tried to join "${sp.name}" on Pantheon with your invite, but the team is full: the ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people, and it has ${sp.allow.admittedCount()}.`,
1844
+ ``,
1845
+ `To let them in, either:`,
1846
+ ` - upgrade: reply to this email, or write to ${FEEDBACK_ADDRESS}`,
1847
+ ` - or remove someone: pantheon admit <name> --revoke`,
1848
+ ``,
1849
+ `Their invite link still works, so they won't need a new one once there is room.`,
1850
+ ``,
1851
+ `— Pantheon`,
1852
+ ].join("\n");
1853
+ void this.email.sendNotice(owner.email, subject, text).catch(() => false);
1854
+ }
1823
1855
  planRefusal(space, plan, target, msg, cid, fail, actor, ip) {
1824
1856
  this.accounts?.audit({ event: "plan_limit", actor_account: actor ?? plan.owner?.id ?? null, space: space.name, target, detail: `${plan.name}: ${msg.slice(0, 160)}`, ip });
1825
1857
  this.log(`[${space.name}] ! PLAN_LIMIT (${target}, ${plan.name} plan)`);
@@ -1983,9 +2015,9 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
1983
2015
  <section id=s-plans>
1984
2016
  <div class=sec><h2>Plans</h2><span class=count>limits the relay enforces · the owner's plan governs a space</span></div>
1985
2017
  <div class=wrap tabindex=0 role=region aria-label="Scrollable table"><table id=t-plans>
1986
- <thead><tr><th>plan</th><th class=num>accounts</th><th>spaces created</th><th>people per space</th><th>unattended workers</th><th></th></tr></thead>
1987
- <tbody>${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td class=num>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces aria-label="Maximum spaces" type=number min=0 value="${l.spaces}"></td><td><input form="${fid}" name=members aria-label="Maximum people per space" type=number min=1 value="${l.members}"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td class=actions><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
1988
- <tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" type=text name=id aria-label="New plan name" placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required style="width:150px"></td><td class="num dim">—</td><td><input form="plan-new" name=spaces aria-label="Maximum spaces" type=number min=0 value=3></td><td><input form="plan-new" name=members aria-label="Maximum people per space" type=number min=1 value=5></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td class=actions><button form="plan-new" class=act>add plan</button></td></tr></tbody>
2018
+ <thead><tr><th>plan</th><th class=num>accounts</th><th>spaces created</th><th>people per space</th><th>open invite links</th><th>unattended workers</th><th></th></tr></thead>
2019
+ <tbody>${rows(Object.entries(plans).map(([n, l]) => { const fid = `plan-${esc(n)}`; return `<tr><td><form id="${fid}" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits><input type=hidden name=id value="${esc(n)}"></form><code>${esc(n)}</code></td><td class=num>${planCounts[n] ?? 0}</td><td><input form="${fid}" name=spaces aria-label="Maximum spaces" type=number min=0 value="${l.spaces}"></td><td><input form="${fid}" name=members aria-label="Maximum people per space" type=number min=1 value="${l.members}"></td><td><input form="${fid}" name=invites aria-label="Open invite links per space" type=number min=0 value="${inviteLimit(l, n)}"></td><td><label><input form="${fid}" type=checkbox name=wake value=on${l.wake ? " checked" : ""}> allowed</label></td><td class=actions><button form="${fid}" class=act>save</button> ${n === "free" ? "" : form("delete_plan", n, "remove", "", "danger")}</td></tr>`; }))}
2020
+ <tr><td><form id="plan-new" method=POST action="/admin/action" class=inl><input type=hidden name=csrf value="${csrf}"><input type=hidden name=action value=set_plan_limits></form><input form="plan-new" type=text name=id aria-label="New plan name" placeholder="new plan name" pattern="[a-z][a-z0-9_-]{0,23}" required style="width:150px"></td><td class="num dim">—</td><td><input form="plan-new" name=spaces aria-label="Maximum spaces" type=number min=0 value=3></td><td><input form="plan-new" name=members aria-label="Maximum people per space" type=number min=1 value=5></td><td><input form="plan-new" name=invites aria-label="Open invite links per space" type=number min=0 value=5></td><td><label><input form="plan-new" type=checkbox name=wake value=on checked> allowed</label></td><td class=actions><button form="plan-new" class=act>add plan</button></td></tr></tbody>
1989
2021
  </table></div>
1990
2022
  <p class=note>Edits apply immediately to the next create, invite or admit of every account on that plan. Unused invites count toward people per space, so nobody mints five at once. Every change is audited.</p>
1991
2023
  </section>
@@ -2514,9 +2546,11 @@ pantheon signup --email you@work.com --username you</code></pre>
2514
2546
  let enrolled = null;
2515
2547
  if (m.enroll) {
2516
2548
  const plan = this.spacePlan(sp.name);
2517
- if (plan && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
2549
+ const realInvite = sp.allow.openInvites().some((i) => i.token === m.enroll);
2550
+ if (plan && realInvite && !sp.allow.get(m.member)?.admitted && sp.allow.admittedCount() >= plan.limits.members) {
2518
2551
  this.accounts?.audit({ event: "plan_limit", actor_account: acct?.id ?? null, space: sp.name, target: "join", detail: `${plan.name}: ${sp.allow.admittedCount()}/${plan.limits.members} people`, ip });
2519
- return reject("PLAN_LIMIT", `"${sp.name}" is full: its owner's ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. ${plan.owner ? `Ask ${plan.owner.username} to upgrade at https://www.joinpantheon.network` : "Its owner's account was deleted; the relay operator can move it to another plan."}`);
2552
+ this.tellOwnerTeamFull(sp, plan, m.member);
2553
+ return reject("PLAN_LIMIT", `"${sp.name}" is full: its ${plan.name} plan allows ${fmtLimit(plan.limits.members)} people. ${plan.owner ? `Ask ${plan.owner.username} to upgrade, or contact ${FEEDBACK_ADDRESS}.` : `Its owner's account was deleted; contact ${FEEDBACK_ADDRESS}.`} Your invite stays valid: once there is room, run the same pantheon join again.`);
2520
2554
  }
2521
2555
  const r = sp.allow.redeem(m.enroll, m.member, m.pubkey);
2522
2556
  if (!r.ok) {
@@ -2717,7 +2751,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2717
2751
  if (plan && m.may_wake && !plan.limits.wake)
2718
2752
  return this.planRefusal(space, plan, "admit", `the ${plan.name} plan does not include unattended workers — admit without --may-wake`, m.cid, fail, acct?.id ?? null, ip);
2719
2753
  if (plan && !target.admitted && space.allow.admittedCount() >= plan.limits.members)
2720
- return this.planRefusal(space, plan, "admit", `"${space.name}" is full for the ${plan.name} plan (${fmtLimit(plan.limits.members)} people). Upgrade at https://www.joinpantheon.network to admit more.`, m.cid, fail, acct?.id ?? null, ip);
2754
+ return this.planRefusal(space, plan, "admit", `"${space.name}" is full for the ${plan.name} plan (${fmtLimit(plan.limits.members)} people). Upgrade to admit more: ${FEEDBACK_ADDRESS}`, m.cid, fail, acct?.id ?? null, ip);
2721
2755
  }
2722
2756
  if (m.revoke || m.forget) {
2723
2757
  const founder = space.allow.founder();
@@ -2784,8 +2818,9 @@ pantheon signup --email you@work.com --username you</code></pre>
2784
2818
  if (m.may_wake && !plan.limits.wake)
2785
2819
  return this.planRefusal(space, plan, "invite", `the ${plan.name} plan does not include unattended workers — invite without wake: pantheon invite --no-wake`, m.cid, fail, acct?.id ?? null, ip);
2786
2820
  const open = space.allow.openInvites().length;
2787
- if (space.allow.admittedCount() + open >= plan.limits.members) {
2788
- return this.planRefusal(space, plan, "invite", `"${space.name}" is full for the ${plan.name} plan: ${fmtLimit(plan.limits.members)} people${open ? `, counting ${open} unused invite${open === 1 ? "" : "s"}` : ""}.${open ? ` An unused invite holds a seat until it expires — free them: pantheon invite --revoke all.` : ""} Upgrade at https://www.joinpantheon.network to invite more.`, m.cid, fail, acct?.id ?? null, ip);
2821
+ const maxOpen = inviteLimit(plan.limits, plan.name);
2822
+ if (open >= maxOpen) {
2823
+ return this.planRefusal(space, plan, "invite", `"${space.name}" already has ${open} unused invite link${open === 1 ? "" : "s"}, the most the ${plan.name} plan allows open at once. Cancel them (pantheon invite --revoke all) or wait for them to expire, or upgrade: ${FEEDBACK_ADDRESS}`, m.cid, fail, acct?.id ?? null, ip);
2789
2824
  }
2790
2825
  }
2791
2826
  if (!this.writeOk(me))
@@ -2795,7 +2830,10 @@ pantheon signup --email you@work.com --username you</code></pre>
2795
2830
  const token = randomBytes(18).toString("base64url");
2796
2831
  const inv = space.allow.mintInvite(me.id, m.may_wake, m.ttl_minutes, token, this.ensureToken(space));
2797
2832
  this.log(`[${space.name}] * invite minted by ${me.id}, valid ${m.ttl_minutes}m, single use`);
2798
- this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at });
2833
+ const full = plan && space.allow.admittedCount() >= plan.limits.members
2834
+ ? `"${space.name}" already has ${space.allow.admittedCount()} of ${fmtLimit(plan.limits.members)} people on the ${plan.name} plan, so this link works only once you upgrade (${FEEDBACK_ADDRESS}) or remove someone (pantheon admit <name> --revoke).`
2835
+ : undefined;
2836
+ this.send(ws, { t: "invite", url: token, expires_at: inv.expires_at, ...(full ? { note: full } : {}) });
2799
2837
  return;
2800
2838
  }
2801
2839
  if (m.t === "invites") {
@@ -3516,14 +3554,20 @@ pantheon signup --email you@work.com --username you</code></pre>
3516
3554
  break;
3517
3555
  case "CONTRACT":
3518
3556
  case "DELIVERED":
3519
- if (rec.ref)
3557
+ if (rec.ref) {
3558
+ const target = sp.ledger.find(rec.ref);
3559
+ const proposerLocks = rec.act === "CONTRACT" && target?.act === "PROPOSE" && target.from === rec.from;
3520
3560
  for (const [id, o] of sp.obligations) {
3521
- if (o.ref === rec.ref && o.goal === rec.goal && o.kind !== "HUMAN")
3561
+ if (o.ref !== rec.ref || o.goal !== rec.goal || o.kind === "HUMAN")
3562
+ continue;
3563
+ if (o.owner === rec.from || (proposerLocks && o.kind === "ACCEPT"))
3522
3564
  sp.obligations.delete(id);
3523
3565
  }
3566
+ }
3524
3567
  break;
3525
3568
  case "ESCALATE":
3526
3569
  this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
3570
+ ...(rec.ref ? { about: rec.ref } : {}),
3527
3571
  body: `${rec.from} escalated: ${first(rec.body)}` });
3528
3572
  if (goal && goal.state === "OPEN") {
3529
3573
  goal.state = "BLOCKED";
@@ -3559,6 +3603,7 @@ pantheon signup --email you@work.com --username you</code></pre>
3559
3603
  const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
3560
3604
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
3561
3605
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
3606
+ ...(asked.act === "ESCALATE" && asked.ref ? { about: asked.ref } : {}),
3562
3607
  body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
3563
3608
  const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
3564
3609
  if (g && g.state === "OPEN") {
@@ -225,6 +225,9 @@ export class Allowlist {
225
225
  const inv = this.invites.get(token);
226
226
  if (!inv)
227
227
  return { ok: false, why: "that invite is not valid for this space" };
228
+ const prior = this.entries.get(member);
229
+ if (inv.used_by && inv.used_by === member && prior?.admitted && prior.publicKey === publicKey)
230
+ return { ok: true, entry: prior };
228
231
  if (inv.used_by)
229
232
  return { ok: false, why: `that invite was already used by "${inv.used_by}" — ask for a fresh one` };
230
233
  if (Date.now() > inv.expires_at)
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { gitCallEnv, safeGitArgs } from "./git-safe.js";
2
3
  import { checkRepoCommand } from "./agents.js";
3
4
  import { bashRuleMatches, GIT_DENY_RULES } from "./claude-confine.js";
4
5
  export const AUTH_RE = /not (?:logged|signed) in|not authenticated|no (?:github )?(?:authentication|credentials?|token) (?:found|available)|login required|please (?:log|sign) in|run .*login|invalid api key|unauthori[sz]ed|authentication (?:failed|required)|\b401\b|expired (?:token|session)/i;
@@ -9,7 +10,7 @@ export const SIGN_IN_FIX = {
9
10
  copilot: "run `copilot login` (or `gh auth login` — copilot uses gh's sign-in too)",
10
11
  kiro: "run `kiro-cli login` (or set KIRO_API_KEY)",
11
12
  };
12
- const sh = (dir, ...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000 }).trim();
13
+ const sh = (dir, ...args) => execFileSync("git", safeGitArgs(dir, ["-C", dir, ...args]), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: gitCallEnv(args, process.env), timeout: 5_000 }).trim();
13
14
  export const gitProbe = {
14
15
  isRepo: (dir) => { try {
15
16
  return sh(dir, "rev-parse", "--is-inside-work-tree") === "true";
@@ -0,0 +1,55 @@
1
+ import { lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
+ function partsUnder(root, target) {
4
+ const rel = relative(root, target);
5
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
6
+ throw new Error(`${target} is not inside ${root}`);
7
+ return rel.split(sep).filter(Boolean);
8
+ }
9
+ function realRoot(root) {
10
+ let st;
11
+ try {
12
+ st = lstatSync(root);
13
+ }
14
+ catch {
15
+ mkdirSync(root, { recursive: true, mode: 0o700 });
16
+ return;
17
+ }
18
+ if (!st.isDirectory())
19
+ throw new Error(`${root} is not a folder of its own (a link or a file stands there) — nothing is written through it`);
20
+ }
21
+ function realFolders(root, parts, mode) {
22
+ let p = root;
23
+ for (const part of parts) {
24
+ p = join(p, part);
25
+ let st;
26
+ try {
27
+ st = lstatSync(p);
28
+ }
29
+ catch {
30
+ st = null;
31
+ }
32
+ if (st?.isDirectory())
33
+ continue;
34
+ if (st)
35
+ rmSync(p, { recursive: true, force: true });
36
+ mkdirSync(p, { mode });
37
+ }
38
+ }
39
+ export function mkdirInside(root, dir, mode = 0o700) {
40
+ realRoot(root);
41
+ if (relative(root, dir) === "")
42
+ return;
43
+ realFolders(root, partsUnder(root, dir), mode);
44
+ }
45
+ export function writeInside(root, target, contents, mode = 0o600) {
46
+ realRoot(root);
47
+ const parts = partsUnder(root, target);
48
+ realFolders(root, parts.slice(0, -1), 0o700);
49
+ rmSync(target, { recursive: true, force: true });
50
+ writeFileSync(target, contents, { flag: "wx", mode });
51
+ }
52
+ export function copyInside(root, from, target, mode = 0o600) {
53
+ writeInside(root, target, readFileSync(from), mode);
54
+ }
55
+ //# sourceMappingURL=safe-write.js.map
@@ -30,13 +30,21 @@ export const ALERT_OPTIONS = [
30
30
  { id: "worker", label: "Decisions, deliveries, and when my own worker finishes a run." },
31
31
  { id: "none", label: "None — I will look at pantheon status (and the menu bar, on a Mac)." },
32
32
  ];
33
+ function gitUserName() {
34
+ try {
35
+ return execFileSync("git", ["config", "--get", "user.name"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 }).trim();
36
+ }
37
+ catch {
38
+ return "";
39
+ }
40
+ }
33
41
  export function guideDefaults(profile, cfg) {
34
42
  const n = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
35
43
  return {
36
44
  wake: wakeMode(profile),
37
45
  commands: profile.agent.allow_bash ?? false,
38
46
  approval: profile.agent.approval ?? "approve",
39
- name: profile.human.display_name || cfg.display || cfg.member,
47
+ name: profile.human.display_name || cfg.display || gitUserName() || cfg.member,
40
48
  title: profile.human.title || "",
41
49
  decides: Object.entries(profile.authority).filter(([, m]) => m === "human_required").map(([d]) => d),
42
50
  notifications: !n.decisions ? "none" : n.worker_done ? "worker" : n.deliveries ? "deliveries" : "decisions",
package/dist/spend.js CHANGED
@@ -1,28 +1,85 @@
1
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
1
+ import { readFileSync, renameSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { dirname, join } from "node:path";
3
4
  import { CONFIG_DIR } from "./config.js";
4
5
  function bookPath(home) {
5
6
  return join(home ?? CONFIG_DIR, "spend.json");
6
7
  }
7
8
  function read(home) {
9
+ let text;
8
10
  try {
9
- return JSON.parse(readFileSync(bookPath(home), "utf8"));
11
+ text = readFileSync(bookPath(home), "utf8");
10
12
  }
11
- catch {
12
- return { runs: [] };
13
+ catch (e) {
14
+ if (e.code === "ENOENT")
15
+ return { runs: [] };
16
+ return { runs: [], broken: e.message.split("\n")[0] ?? "unreadable" };
13
17
  }
18
+ try {
19
+ const b = JSON.parse(text);
20
+ if (b && Array.isArray(b.runs))
21
+ return { runs: b.runs.filter((r) => !!r && typeof r === "object" && typeof r.at === "number") };
22
+ }
23
+ catch { }
24
+ return { runs: [], broken: "it is not a spend book (JSON with a list of runs)" };
14
25
  }
15
26
  function write(b, home) {
16
27
  const f = bookPath(home);
17
28
  mkdirSync(dirname(f), { recursive: true });
18
29
  const cutoff = Date.now() - 31 * 86_400_000;
19
- writeFileSync(f, JSON.stringify({ runs: b.runs.filter((r) => r.at > cutoff) }), { mode: 0o600 });
30
+ const tmp = `${f}.${randomBytes(6).toString("hex")}.tmp`;
31
+ try {
32
+ writeFileSync(tmp, JSON.stringify({ runs: b.runs.filter((r) => r.at > cutoff) }), { mode: 0o600, flag: "wx" });
33
+ renameSync(tmp, f);
34
+ }
35
+ catch (e) {
36
+ try {
37
+ rmSync(tmp, { force: true });
38
+ }
39
+ catch { }
40
+ throw e;
41
+ }
20
42
  }
21
43
  export function recordRun(r, home) {
22
44
  const b = read(home);
45
+ if (b.broken)
46
+ return;
23
47
  b.runs.push(r);
24
48
  write(b, home);
25
49
  }
50
+ export function startRun(r, home) {
51
+ const id = randomBytes(8).toString("hex");
52
+ recordRun({ ...r, id, started: true }, home);
53
+ return id;
54
+ }
55
+ export function finishRun(id, end, home) {
56
+ const b = read(home);
57
+ if (b.broken)
58
+ return;
59
+ const i = b.runs.findIndex((r) => r.id === id);
60
+ if (i < 0) {
61
+ recordRun(end, home);
62
+ return;
63
+ }
64
+ const { started: _s, ...was } = b.runs[i];
65
+ b.runs[i] = { ...was, ...end };
66
+ write(b, home);
67
+ }
68
+ export function closeStartedRun(id, summary, fallback, home) {
69
+ const b = read(home);
70
+ if (b.broken)
71
+ return;
72
+ const i = b.runs.findIndex((r) => r.id === id);
73
+ if (i < 0) {
74
+ recordRun(fallback, home);
75
+ return;
76
+ }
77
+ if (!b.runs[i].started)
78
+ return;
79
+ const { started: _s, ...was } = b.runs[i];
80
+ b.runs[i] = { ...was, ok: false, summary };
81
+ write(b, home);
82
+ }
26
83
  export function since(ms, home) {
27
84
  const from = Date.now() - ms;
28
85
  const runs = read(home).runs.filter((r) => r.at > from);
@@ -40,6 +97,9 @@ export function recentRuns(ms, home, limit = 12, only) {
40
97
  .sort((a, b) => b.at - a.at).slice(0, limit);
41
98
  }
42
99
  export function allowed(caps, home) {
100
+ const book = read(home);
101
+ if (book.broken)
102
+ return { ok: false, broken: true, why: `the spend book ${bookPath(home)} cannot be read (${book.broken}) — repair or delete it; unattended runs wait until then` };
43
103
  const hour = since(3_600_000, home).runs;
44
104
  if (hour >= caps.perHour) {
45
105
  return { ok: false, why: `${hour} runs in the last hour, cap is ${caps.perHour}` };
package/dist/tray.js CHANGED
@@ -13,7 +13,7 @@ import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
13
13
  import { VERSION } from "./version.js";
14
14
  import { LIMITS } from "./protocol.js";
15
15
  import { signedRead } from "./reads.js";
16
- import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, waitingPrompt, workerSummary } from "./decision-ui.js";
16
+ import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, tidyIds, tidyLead, waitingPrompt, workerSummary } from "./decision-ui.js";
17
17
  import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
18
18
  const HERE = dirname(fileURLToPath(import.meta.url));
19
19
  export const trayBinaryPath = (home = CONFIG_DIR) => join(home, "bin", "pantheon-tray");
@@ -52,6 +52,45 @@ export function writeTrayCommands(home = CONFIG_DIR) {
52
52
  }
53
53
  return out;
54
54
  }
55
+ const WEEK_MS = 7 * 24 * 3600_000;
56
+ const RANK = (i) => (i.kind === "decision" ? 0 : 1);
57
+ export const boardOrder = (a, b) => RANK(a) - RANK(b) || a.when - b.when;
58
+ export function arrangeBoard(items, o) {
59
+ const fallback = o.fallbackMs ?? LIMITS.workerFallbackMs;
60
+ const out = items.map((i) => ({ ...i }));
61
+ const spaceOf = (i) => i.space ?? "";
62
+ const asked = new Map();
63
+ for (const d of out)
64
+ if (d.kind === "decision" && d.about && (d.who === "your agent" || d.choices))
65
+ asked.set(`${spaceOf(d)}|${d.about}`, d);
66
+ for (const i of out) {
67
+ if (i.kind === "decision") {
68
+ i.lane = "now";
69
+ continue;
70
+ }
71
+ if (i.kind === "waiting") {
72
+ i.lane = "waiting";
73
+ continue;
74
+ }
75
+ const d = i.ref ? asked.get(`${spaceOf(i)}|${i.ref}`) : undefined;
76
+ if (d) {
77
+ d.context ??= i.headline;
78
+ i.lane = undefined;
79
+ continue;
80
+ }
81
+ const age = Math.max(0, o.now - i.when);
82
+ if (i.promise) {
83
+ i.lane = age > WEEK_MS ? "older" : "handling";
84
+ continue;
85
+ }
86
+ if (i.claimed || (o.workerRunning && age < fallback)) {
87
+ i.lane = "handling";
88
+ continue;
89
+ }
90
+ i.lane = age > WEEK_MS ? "older" : "now";
91
+ }
92
+ return out.filter((i) => i.lane).sort(boardOrder);
93
+ }
55
94
  export function localChoices(ref) {
56
95
  if (!ref)
57
96
  return null;
@@ -115,7 +154,7 @@ export async function trayStatus(cfg = load(), f = loadAll()) {
115
154
  members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
116
155
  decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
117
156
  decisions_prompt: allDecisionsPrompt(ok.reduce((n, p) => n + (p.decisions?.length ?? 0), 0)),
118
- items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))),
157
+ items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))).sort(boardOrder),
119
158
  owed_count: ok.reduce((n, p) => n + (p.owed_count ?? 0), 0),
120
159
  waiting_count: ok.reduce((n, p) => n + (p.waiting_count ?? 0), 0),
121
160
  };
@@ -140,11 +179,15 @@ async function trayStatusOne(cfg) {
140
179
  const decisions = mineHuman
141
180
  .map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id, decisionTitle(o.body)) }));
142
181
  const senderOf = (o) => stripCeremony(o.body).from;
182
+ const tidy = (o, n) => tidyLead(decisionTitle(tidyIds(o.body), n), cfg.member);
143
183
  const row = (o, kind, who, prompt) => ({
144
- id: o.id, kind, title: decisionTitle(o.body), headline: decisionTitle(o.body, 200), detail: o.body, prompt, who,
184
+ id: o.id, kind, title: tidy(o), headline: tidy(o, 200), detail: o.body, prompt, who,
145
185
  when: o.created_at ?? 0, space: cfg.space,
186
+ ...(o.claimed ? { claimed: true } : {}),
187
+ ...(o.kind === "DELIVER" ? { promise: true } : {}),
146
188
  ...(o.goal ? { goal: o.goal } : {}),
147
189
  ...(o.ref ? { ref: o.ref } : {}),
190
+ ...(typeof o.about === "string" && o.about ? { about: o.about } : {}),
148
191
  });
149
192
  const owed = all.filter((o) => o.owner === cfg.member);
150
193
  const waiting = all.filter((o) => o.owner !== cfg.member && o.owner !== `human:${cfg.member}`);
@@ -152,7 +195,9 @@ async function trayStatusOne(cfg) {
152
195
  ...mineHuman.map((o) => {
153
196
  const from = senderOf(o);
154
197
  const choices = localChoices(o.ref);
155
- return { ...row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body))), decider: cfg.member, ...(choices ? { choices } : {}) };
198
+ const r = row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body)));
199
+ const own = new RegExp(`^${cfg.member.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (?:escalated|needs a decision):\\s*`, "i");
200
+ return { ...r, detail: o.body.replace(own, ""), decider: cfg.member, ...(choices ? { choices } : {}) };
156
201
  }),
157
202
  ...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
158
203
  ...waiting.map((o) => {
@@ -163,6 +208,8 @@ async function trayStatusOne(cfg) {
163
208
  : r;
164
209
  }),
165
210
  ];
211
+ const workerRunning = workerProc().running;
212
+ const board = arrangeBoard(items, { now: Date.now(), workerRunning });
166
213
  const profile = loadProfile(cfg);
167
214
  const surface = profile.agent.primary_surface;
168
215
  const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : surface === "copilot" ? "Copilot" : surface === "kiro" ? "Kiro" : "Codex";
@@ -179,7 +226,7 @@ async function trayStatusOne(cfg) {
179
226
  unavailable_spaces: !space || !decisionsOk ? [cfg.space] : [],
180
227
  online: space?.online ?? 0,
181
228
  members: space?.members ?? 0,
182
- worker_running: workerProc().running,
229
+ worker_running: workerRunning,
183
230
  worker_installed: serviceInstalled("worker"),
184
231
  worker_plist: plistPath("worker"),
185
232
  notifier_running: notifierProc().running,
@@ -189,7 +236,7 @@ async function trayStatusOne(cfg) {
189
236
  surface_bundle_id: SURFACE_BUNDLE_IDS[surface],
190
237
  decisions_prompt: allDecisionsPrompt(decisions.length),
191
238
  decisions,
192
- items,
239
+ items: board,
193
240
  owed_count: owed.length,
194
241
  waiting_count: waiting.length,
195
242
  };