@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/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,11 +3554,16 @@ 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,
@@ -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");
@@ -79,6 +79,10 @@ export function arrangeBoard(items, o) {
79
79
  continue;
80
80
  }
81
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
+ }
82
86
  if (i.claimed || (o.workerRunning && age < fallback)) {
83
87
  i.lane = "handling";
84
88
  continue;
@@ -175,10 +179,12 @@ async function trayStatusOne(cfg) {
175
179
  const decisions = mineHuman
176
180
  .map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id, decisionTitle(o.body)) }));
177
181
  const senderOf = (o) => stripCeremony(o.body).from;
182
+ const tidy = (o, n) => tidyLead(decisionTitle(tidyIds(o.body), n), cfg.member);
178
183
  const row = (o, kind, who, prompt) => ({
179
- 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,
180
185
  when: o.created_at ?? 0, space: cfg.space,
181
186
  ...(o.claimed ? { claimed: true } : {}),
187
+ ...(o.kind === "DELIVER" ? { promise: true } : {}),
182
188
  ...(o.goal ? { goal: o.goal } : {}),
183
189
  ...(o.ref ? { ref: o.ref } : {}),
184
190
  ...(typeof o.about === "string" && o.about ? { about: o.about } : {}),
@@ -189,7 +195,9 @@ async function trayStatusOne(cfg) {
189
195
  ...mineHuman.map((o) => {
190
196
  const from = senderOf(o);
191
197
  const choices = localChoices(o.ref);
192
- 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 } : {}) };
193
201
  }),
194
202
  ...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
195
203
  ...waiting.map((o) => {