@join-pantheon/cli 0.31.1 → 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/README.md +4 -2
- package/dist/agent-outage.js +87 -0
- package/dist/agents.js +59 -20
- package/dist/claude-confine.js +21 -4
- package/dist/cli.js +254 -53
- package/dist/client.js +3 -3
- package/dist/config.js +1 -0
- package/dist/dbus.js +418 -0
- package/dist/decision-ui.js +89 -0
- package/dist/doctor.js +3 -3
- package/dist/email.js +21 -0
- package/dist/file-lock.js +3 -3
- package/dist/git-safe.js +274 -0
- package/dist/hooks-install.js +6 -1
- package/dist/install.js +21 -0
- package/dist/kiro-files.js +20 -4
- package/dist/mcp-instructions.js +2 -2
- package/dist/nonces.js +5 -3
- package/dist/notice.js +2 -1
- package/dist/notifier.js +1 -1
- package/dist/onboard-defaults.js +18 -0
- package/dist/persona.js +28 -2
- package/dist/plans.js +12 -3
- package/dist/profile.js +23 -5
- package/dist/relay.js +59 -16
- package/dist/roster-file.js +3 -0
- package/dist/run-failure.js +2 -1
- package/dist/safe-write.js +55 -0
- package/dist/setup-guide.js +9 -1
- package/dist/spend.js +65 -5
- package/dist/tray.js +11 -3
- package/dist/worker.js +890 -280
- package/helper/macos/menubar.swift +198 -28
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2788
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
package/dist/roster-file.js
CHANGED
|
@@ -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)
|
package/dist/run-failure.js
CHANGED
|
@@ -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
|
package/dist/setup-guide.js
CHANGED
|
@@ -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
|
-
|
|
11
|
+
text = readFileSync(bookPath(home), "utf8");
|
|
10
12
|
}
|
|
11
|
-
catch {
|
|
12
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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) => {
|