@join-pantheon/cli 0.33.5 → 0.33.6-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
@@ -1,11 +1,11 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createHash, randomBytes, timingSafeEqual as timingSafeEqualBuf } from "node:crypto";
3
- import { copyFileSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
3
+ import { copyFileSync, readdirSync, readFileSync, statSync, existsSync, writeFileSync, openSync, readSync, closeSync, fstatSync, promises as fsp } from "node:fs";
4
4
  import { spawn } from "node:child_process";
5
5
  import { monitorEventLoopDelay } from "node:perf_hooks";
6
6
  import { basename, join } from "node:path";
7
7
  import { WebSocketServer } from "ws";
8
- import { badRemote, Inbound, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, shouldDeferToHuman, } from "./protocol.js";
8
+ import { badRemote, Inbound, isCatchAllDomain, isContinuationFor, LIMITS, newId, PROTOCOL_VERSION, RELAY_FEATURES, SENDABLE, WORKER_REASONS, shouldDeferToHuman, } from "./protocol.js";
9
9
  import { VERSION } from "./version.js";
10
10
  import { compareVersions, INSTALLER_MIN_CLI, isVersion, installerScript, InstallerGate } from "./installer.js";
11
11
  import { installerLine } from "./managed.js";
@@ -15,6 +15,7 @@ import { Accounts, normalizeUsername, validEmail, validUsername } from "./accoun
15
15
  import { emailSenderFromEnv, maskEmail } from "./email.js";
16
16
  import { Allowlist, RosterUnreadable } from "./roster-file.js";
17
17
  import { StateStore, ensurePrivateDir, setWaitForDisk, takeSlowestDiskWait, waitsForDisk } from "./store.js";
18
+ import { countGoal, countMessage, emptyUsage, parseUsage, usageAnswer, USAGE_KEEP_DAYS, USAGE_SHOW_DAYS } from "./usage.js";
18
19
  import { busyWriter, DiskBusy, flushWrites, onWriteError, pendingView, readLatest, recordWrites, whenLanded, writerFor } from "./disk-writer.js";
19
20
  import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
20
21
  import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
@@ -182,6 +183,9 @@ const fmtAgo = (t) => {
182
183
  return m < 1 ? "just now" : m < 60 ? `${m}m ago` : m < 1440 ? `${Math.round(m / 60)}h ago` : `${Math.round(m / 1440)}d ago`;
183
184
  };
184
185
  const safeName = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
186
+ export function printableLine(s, max) {
187
+ return s.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
188
+ }
185
189
  const quoteTeam = (name) => relayShellArg(name);
186
190
  function parseLines(text) {
187
191
  const records = [];
@@ -775,6 +779,23 @@ export class Relay {
775
779
  : sinceRaw !== null ? space.ledger.since(Number(sinceRaw) || 0) : space.ledger.latest();
776
780
  return this.json(res, 200, { acts, head: space.ledger.head });
777
781
  }
782
+ if (url.pathname === "/usage") {
783
+ const v = { usage_v: 1 };
784
+ if (!url.searchParams.has("sig"))
785
+ return this.json(res, 401, { ...v, code: "SIGNED_ONLY", error: "a team's usage is read signed, by its founder: pantheon usage" });
786
+ const gate = this.readGate(req, url, ip);
787
+ if (!gate.ok)
788
+ return this.json(res, gate.status, { ...v, error: gate.error, ...(gate.code ? { code: gate.code } : {}) });
789
+ const space = gate.space;
790
+ if (url.searchParams.has("dpub"))
791
+ return this.json(res, 403, { ...v, code: "NOT_FOR_A_RUN", error: "an unattended run cannot read the team's usage" });
792
+ const founder = space.allow.founder();
793
+ if (!founder || gate.reader !== founder) {
794
+ return this.json(res, 403, { ...v, code: "FOUNDER_ONLY", error: `only ${founder ?? "the team's founder"}, who founded "${space.name}", can see its usage` });
795
+ }
796
+ const days = Number(url.searchParams.get("days") ?? USAGE_SHOW_DAYS);
797
+ return this.json(res, 200, { ...v, ...usageAnswer(space.name, space.store.usage, days) });
798
+ }
778
799
  if (url.pathname === "/obligations") {
779
800
  const gate = this.readGate(req, url, ip);
780
801
  if (!gate.ok && gate.notice)
@@ -791,7 +812,8 @@ export class Relay {
791
812
  const worker_online = member
792
813
  ? [...(space.members.get(member)?.sessions.values() ?? [])].some((s) => s.role === "worker")
793
814
  : false;
794
- return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online, space_id: space.allow.spaceId(), members: this.members(space) });
815
+ return this.json(res, 200, { obligations: owed, closed_goals: closed, seq: space.ledger.head, worker_online, space_id: space.allow.spaceId(), members: this.members(space),
816
+ ...(gate.reader ? { roster: this.roster(space), now: Date.now() } : {}) });
795
817
  }
796
818
  if (url.pathname === "/activity") {
797
819
  if (req.method !== "POST")
@@ -840,7 +862,7 @@ export class Relay {
840
862
  const seen = Number(url.searchParams.get("seen"));
841
863
  if (Number.isFinite(seen) && seen > (member.seen_seq ?? 0))
842
864
  member.seen_seq = seen;
843
- this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
865
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space), now: Date.now() }));
844
866
  return this.json(res, 200, { ok: true });
845
867
  }
846
868
  this.json(res, 404, { error: "not found" });
@@ -907,6 +929,14 @@ export class Relay {
907
929
  if (url.pathname === "/admin/report") {
908
930
  if (!authed)
909
931
  return this.json(res, 401, { error: "admin token required" });
932
+ if (url.searchParams.get("format") === "usage") {
933
+ const days = Number(url.searchParams.get("days") ?? USAGE_SHOW_DAYS);
934
+ void this.usageOfAll(days, url.searchParams.get("space"))
935
+ .then((spaces) => this.json(res, 200, { relay: VERSION, generated: Date.now(), kept_days: USAGE_KEEP_DAYS, spaces }))
936
+ .catch((e) => { this.log(`! usage report failed: ${e.message}`); if (!res.headersSent)
937
+ this.json(res, 500, { error: "the usage report could not be made" }); });
938
+ return;
939
+ }
910
940
  const rows = this.fleetHealth();
911
941
  const relayAgeMs = Date.now() - this.startedAt;
912
942
  if (url.searchParams.get("format") === "json")
@@ -1489,11 +1519,45 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1489
1519
  }
1490
1520
  return out;
1491
1521
  }
1522
+ async usageOfAll(days, only) {
1523
+ const out = [];
1524
+ const seen = new Set();
1525
+ const want = (name, safe) => !only || spaceKey(only) === spaceKey(name) || spaceKey(only) === spaceKey(safe);
1526
+ for (const sp of this.spaces.values()) {
1527
+ const safe = safeName(sp.name);
1528
+ seen.add(safe);
1529
+ if (want(sp.name, safe))
1530
+ out.push(usageAnswer(sp.name, sp.store.usage, days));
1531
+ }
1532
+ let files = [];
1533
+ try {
1534
+ files = await fsp.readdir(this.dataDir);
1535
+ }
1536
+ catch { }
1537
+ for (const f of files) {
1538
+ if (!f.endsWith(".usage.json"))
1539
+ continue;
1540
+ const safe = f.slice(0, -".usage.json".length);
1541
+ if (seen.has(safe))
1542
+ continue;
1543
+ let book = emptyUsage();
1544
+ try {
1545
+ const queued = pendingView(this.dataDir, safe, join(this.dataDir, f));
1546
+ if (queued !== null)
1547
+ book = parseUsage(JSON.parse(queued !== undefined ? queued.toString("utf8") : await fsp.readFile(join(this.dataDir, f), "utf8")));
1548
+ }
1549
+ catch { }
1550
+ const name = book.space ?? safe;
1551
+ if (want(name, safe))
1552
+ out.push(usageAnswer(name, book, days));
1553
+ }
1554
+ return out.sort((a, b) => a.space.localeCompare(b.space));
1555
+ }
1492
1556
  spacesOverview() {
1493
1557
  const now = Date.now();
1494
1558
  const out = new Map();
1495
1559
  const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
1496
- const size = (safe) => [".jsonl", ".state.json", ".members.json"].reduce((n, suf) => { try {
1560
+ const size = (safe) => [".jsonl", ".state.json", ".members.json", ".usage.json"].reduce((n, suf) => { try {
1497
1561
  return n + statSync(join(this.dataDir, `${safe}${suf}`)).size;
1498
1562
  }
1499
1563
  catch {
@@ -1580,6 +1644,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
1580
1644
  const sp = this.spaces.get(spaceKey(name));
1581
1645
  if (sp) {
1582
1646
  sp.deleted = true;
1647
+ sp.store.discardUsage();
1583
1648
  for (const m of sp.members.values())
1584
1649
  for (const s of m.sessions.values())
1585
1650
  this.hangUp(s.ws, "SPACE_DELETED");
@@ -2058,7 +2123,7 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
2058
2123
  meta: { notice: "seat_recovery", recovery: { member, fp: toFp, from_fp: fromFp, state: "moved", how, ...(by ? { by } : {}) } },
2059
2124
  });
2060
2125
  this.broadcast(sp, { t: "members", members: this.members(sp) });
2061
- this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
2126
+ this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp), now: Date.now() }));
2062
2127
  return inv;
2063
2128
  }
2064
2129
  mintRecoveryInvite(sp, member) {
@@ -2123,9 +2188,15 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
2123
2188
  const ts = Number(params.ts);
2124
2189
  const refused = { ok: false, status: 403, error: "a read must be signed, recently, by the key of a member admitted to this team" };
2125
2190
  const pub = reader ? target.allow.pubkeyOf(reader) : null;
2191
+ const { sig, ...signed } = params;
2192
+ if (!pub && reader && Number.isFinite(ts) && Math.abs(Date.now() - ts) < LIMITS.activityFreshMs && !signed.dpub) {
2193
+ const e = target.allow.get(reader);
2194
+ if (e && !e.admitted && e.revoked_at && e.publicKey && verifyRead(e.publicKey, req.method ?? "GET", url.pathname, signed, sig)) {
2195
+ return { ok: false, status: 403, code: "REVOKED", error: `"${reader}" was removed from "${name}"${e.revoked_by ? ` by ${e.revoked_by}` : ""}. Only a member of the team can let you back in.` };
2196
+ }
2197
+ }
2126
2198
  if (!pub || !Number.isFinite(ts) || Math.abs(Date.now() - ts) >= LIMITS.activityFreshMs)
2127
2199
  return refused;
2128
- const { sig, ...signed } = params;
2129
2200
  let signer = pub;
2130
2201
  if (signed.dpub) {
2131
2202
  const cert = { pub: signed.dpub, run: signed.drun ?? "", expires: Number(signed.dexp), sig: signed.dcert ?? "" };
@@ -2513,7 +2584,11 @@ ${done ? `<div class=done role=status>${esc(done)}</div>` : ""}
2513
2584
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
2514
2585
  const sp = this.spaces.get(spaceKey(name)) ?? null;
2515
2586
  const tomb = this.deletedSpaces.get(spaceKey(name));
2516
- const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => existsSync(join(this.dataDir, `${safe}${suf}`)));
2587
+ const onDisk = [".jsonl", ".state.json", ".members.json"].some((suf) => {
2588
+ const f = join(this.dataDir, `${safe}${suf}`);
2589
+ const queued = pendingView(this.dataDir, safe, f);
2590
+ return queued !== undefined ? queued !== null : existsSync(f);
2591
+ });
2517
2592
  if (!name || (!sp && (!onDisk || (tomb && Date.now() - tomb < 3_600_000))))
2518
2593
  return this.json(res, 404, { error: `no space "${name}" on this relay` });
2519
2594
  const records = sp ? sp.ledger.all() : this.sleepingRecords(safe);
@@ -3174,7 +3249,7 @@ pantheon start</code></pre>
3174
3249
  }
3175
3250
  this.send(ws, {
3176
3251
  t: "welcome", member: me.id, session: sessionId, space: sp.name, seq: sp.ledger.head,
3177
- roster: this.roster(sp), goals: [...sp.goals.values()],
3252
+ roster: this.roster(sp), now: Date.now(), goals: [...sp.goals.values()],
3178
3253
  obligations: [...sp.obligations.values()], members: this.members(sp), profiles: [...sp.profiles.values()],
3179
3254
  space_id: sp.allow.spaceId(), repos: sp.allow.repoList(), features: [...this.features],
3180
3255
  ...(acct ? { plan: this.planView(acct, sp) } : {}),
@@ -3202,7 +3277,7 @@ pantheon start</code></pre>
3202
3277
  if (firstSession && m.role !== "cli") {
3203
3278
  this.commit(sp, { from: me.id, act: "JOIN", body: `${me.display} online (${me.agent})`, to: null, goal: null, ref: null });
3204
3279
  }
3205
- this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
3280
+ this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp), now: Date.now() }));
3206
3281
  this.log(`[${sp.name}] + ${me.id}/${sessionId} (${m.role}, ${me.agent})${verdict === "founder" ? " [founder, auto-admitted]" : ""}`);
3207
3282
  return;
3208
3283
  }
@@ -3227,9 +3302,11 @@ pantheon start</code></pre>
3227
3302
  }
3228
3303
  if (!this.writeOk(me))
3229
3304
  return fail("RATE_LIMIT", "too many changes too fast — wait a few seconds", m.cid);
3230
- const wild = Object.keys(m.profile.authority ?? {}).filter((k) => /[*?]/.test(k) || k.trim() === "");
3231
- if (wild.length)
3232
- return fail("BAD_AUTHORITY", `authority is claimed per named domain, not with "${wild[0]}"`, m.cid);
3305
+ const vague = Object.keys(m.profile.authority ?? {}).filter((k) => isCatchAllDomain(k));
3306
+ if (vague.length) {
3307
+ m.profile = { ...m.profile, authority: Object.fromEntries(Object.entries(m.profile.authority).filter(([k]) => !isCatchAllDomain(k))) };
3308
+ this.log(`[${space.name}] ~ ${me.id}'s profile: dropped ${vague.map((k) => JSON.stringify(k)).join(", ")} — not a decision area`);
3309
+ }
3233
3310
  const current = space.profiles.get(me.id);
3234
3311
  if (!current || m.profile.updated_at > current.updated_at) {
3235
3312
  space.profiles.set(me.id, m.profile);
@@ -3241,7 +3318,8 @@ pantheon start</code></pre>
3241
3318
  meta: { organization_profile: m.profile },
3242
3319
  });
3243
3320
  }
3244
- this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
3321
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head,
3322
+ ...(vague.length ? { note: `dropped ${vague.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 40))).join(", ")} — not decision areas; the rest of the profile was published` } : {}) });
3245
3323
  return;
3246
3324
  }
3247
3325
  if (m.t === "clear_authority") {
@@ -3313,15 +3391,20 @@ pantheon start</code></pre>
3313
3391
  this.log(`[${space.name}] * ADMITTED ${m.member} by ${me.id}${m.may_wake ? " (may wake workers)" : ""}`);
3314
3392
  }
3315
3393
  this.broadcast(space, { t: "members", members: this.members(space) });
3316
- this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
3394
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space), now: Date.now() }));
3317
3395
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: m.member, seq: space.ledger.head });
3318
3396
  return;
3319
3397
  }
3320
3398
  if (m.t === "worker_state") {
3321
3399
  if (role !== "worker")
3322
3400
  return;
3323
- me.worker = { state: m.state, ...(m.detail ? { detail: m.detail } : {}), ...(m.until ? { until: m.until } : {}), at: Date.now() };
3324
- this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
3401
+ const now = Date.now();
3402
+ const detail = m.detail ? printableLine(m.detail, 120) : "";
3403
+ const until = m.until_in !== undefined ? now + m.until_in : m.until;
3404
+ const reason = m.reason ? (WORKER_REASONS.includes(m.reason) ? m.reason : "unknown") : undefined;
3405
+ me.worker = { state: m.state, ...(detail ? { detail } : {}), ...(until ? { until } : {}), ...(m.idle_ms !== undefined ? { idle_ms: m.idle_ms } : {}),
3406
+ ...(reason ? { reason } : {}), ...(m.ref ? { ref: m.ref } : {}), ...(m.goal ? { goal: m.goal } : {}), at: now };
3407
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space), now: Date.now() }));
3325
3408
  return;
3326
3409
  }
3327
3410
  if (m.t === "delete_space") {
@@ -3544,6 +3627,7 @@ pantheon start</code></pre>
3544
3627
  };
3545
3628
  space.goals.set(goal.id, goal);
3546
3629
  space.store.flush(space.goals, space.obligations, space.profiles);
3630
+ this.countUsage(space, (b, now) => countGoal(b, "opened", now));
3547
3631
  this.broadcast(space, { t: "goal", goal });
3548
3632
  const rec = this.commit(space, {
3549
3633
  from: me.id, act: "NOTE", to: null, goal: goal.id, ref: null,
@@ -3647,6 +3731,12 @@ pantheon start</code></pre>
3647
3731
  ...(fromRepo ? { from_repo: fromRepo } : {}) } }
3648
3732
  : {}),
3649
3733
  });
3734
+ {
3735
+ const sp = space;
3736
+ const closed = rec.act === "CLOSE" && !!rec.goal && sp.goals.get(rec.goal)?.state === "CLOSED";
3737
+ this.countUsage(sp, (b, now) => { countMessage(b, rec.act, role, now); if (closed)
3738
+ countGoal(b, "closed", now); });
3739
+ }
3650
3740
  if (wasFull && m.act === "CLOSE") {
3651
3741
  try {
3652
3742
  this.compactSpace(space);
@@ -3748,7 +3838,7 @@ pantheon start</code></pre>
3748
3838
  this.commit(space, { from: me.id, act: "LEAVE", body: `${me.display} offline`, to: null, goal: null, ref: null });
3749
3839
  }
3750
3840
  this.pushObligations(space);
3751
- this.broadcast(space, () => ({ t: "roster", roster: this.roster(space) }));
3841
+ this.broadcast(space, () => ({ t: "roster", roster: this.roster(space), now: Date.now() }));
3752
3842
  this.log(`[${space.name}] - ${me.id}/${sessionId}`);
3753
3843
  };
3754
3844
  ws.on("close", (code) => { try {
@@ -4091,7 +4181,7 @@ pantheon start</code></pre>
4091
4181
  sp.store.rebuilt = null;
4092
4182
  this.pushObligations(sp);
4093
4183
  this.broadcast(sp, { t: "act", act: rec });
4094
- this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp) }));
4184
+ this.broadcast(sp, () => ({ t: "roster", roster: this.roster(sp), now: Date.now() }));
4095
4185
  return rec;
4096
4186
  }
4097
4187
  effects(sp, rec, ctx) {
@@ -4377,10 +4467,28 @@ pantheon start</code></pre>
4377
4467
  }
4378
4468
  }
4379
4469
  }
4470
+ countUsage(sp, fn) {
4471
+ if (sp.deleted)
4472
+ return;
4473
+ try {
4474
+ sp.store.usage.space ??= sp.name;
4475
+ fn(sp.store.usage, Date.now());
4476
+ sp.store.saveUsage();
4477
+ }
4478
+ catch (e) {
4479
+ this.noteStorageFailure(`[${sp.name}] counting usage`, e);
4480
+ }
4481
+ }
4380
4482
  deleteSpaceFiles(name) {
4381
4483
  const safe = name.replace(/[^a-z0-9._-]/gi, "_");
4382
- for (const suffix of [".jsonl", ".state.json", ".members.json"])
4484
+ for (const suffix of [".jsonl", ".state.json", ".members.json", ".usage.json"])
4383
4485
  writerFor(this.dataDir, safe).remove(join(this.dataDir, `${safe}${suffix}`));
4486
+ try {
4487
+ for (const f of readdirSync(this.dataDir))
4488
+ if (f.startsWith(`${safe}.usage.json.corrupt-`))
4489
+ writerFor(this.dataDir, safe).remove(join(this.dataDir, f));
4490
+ }
4491
+ catch { }
4384
4492
  }
4385
4493
  getOrCreateSpace(name, token, opts = {}) {
4386
4494
  const claim = opts.claim;
@@ -0,0 +1,132 @@
1
+ import { mkdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+ import { withFileLock } from "./file-lock.js";
5
+ import { writeWhole } from "./pins.js";
6
+ import { trustWrite } from "./home-guard.js";
7
+ export const REMOVED_FIRST_MS = 5 * 60_000;
8
+ export const REMOVED_MAX_MS = 6 * 60 * 60_000;
9
+ export const REMOVED_CHECK_MS = 60_000;
10
+ export const removalKey = (t) => t.spaceId || `${t.relay}|${t.space}`;
11
+ export function removedPath(home) {
12
+ return join(home ?? process.env.PANTHEON_HOME ?? CONFIG_DIR, "removed.json");
13
+ }
14
+ export function removalRefusal(lastError) {
15
+ const m = /^REVOKED: "[^"]*" was removed from "[^"]*"(?: by (.+?))?\. Only a member/.exec(lastError ?? "");
16
+ if (!m)
17
+ return null;
18
+ return m[1] ? { by: m[1] } : {};
19
+ }
20
+ export const safeMemberName = (x) => typeof x === "string" && x.length <= 48 && /^[a-z0-9][a-z0-9._-]*$/i.test(x) ? x : undefined;
21
+ function readAll(home) {
22
+ try {
23
+ const raw = JSON.parse(readFileSync(removedPath(home), "utf8"));
24
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
25
+ return {};
26
+ const out = {};
27
+ for (const [k, v] of Object.entries(raw)) {
28
+ const r = v;
29
+ if (!r || typeof r !== "object" || typeof r.name !== "string" || typeof r.at !== "number" || typeof r.last !== "number")
30
+ continue;
31
+ const by = safeMemberName(r.by);
32
+ out[k] = { team: k, name: r.name.slice(0, 120), member: String(r.member ?? "").slice(0, 48), relay: String(r.relay ?? "").slice(0, 300), at: r.at, last: r.last,
33
+ knocks: typeof r.knocks === "number" && Number.isInteger(r.knocks) && r.knocks > 0 ? Math.min(r.knocks, 64) : 1,
34
+ ...(by ? { by } : {}), ...(typeof r.told === "number" ? { told: r.told } : {}) };
35
+ }
36
+ return out;
37
+ }
38
+ catch {
39
+ return {};
40
+ }
41
+ }
42
+ function update(home, fn) {
43
+ const f = removedPath(home);
44
+ mkdirSync(dirname(f), { recursive: true });
45
+ return withFileLock(f, () => {
46
+ let prev = null;
47
+ try {
48
+ prev = readFileSync(f);
49
+ }
50
+ catch { }
51
+ const all = readAll(home);
52
+ const r = fn(all);
53
+ if (r.changed) {
54
+ writeWhole(f, JSON.stringify(all, null, 2) + "\n");
55
+ trustWrite(f, prev);
56
+ }
57
+ return r.out;
58
+ }, 200);
59
+ }
60
+ export function teamsOf(f) {
61
+ return Object.values(f?.spaces ?? {}).map((m) => ({ space: m.name, spaceId: m.id.startsWith("legacy:") ? null : m.id, relay: m.relay, member: f.member }));
62
+ }
63
+ export function removals(teams, home) {
64
+ return teams.flatMap((t) => { const record = removalFor(t, home); return record ? [{ team: t, record }] : []; });
65
+ }
66
+ export function removalFor(t, home) {
67
+ const all = readAll(home);
68
+ const r = all[removalKey(t)] ?? (t.spaceId ? all[`${t.relay}|${t.space}`] : undefined) ?? null;
69
+ return r && r.member === t.member ? r : null;
70
+ }
71
+ export function removalWords(t, by) {
72
+ const b = safeMemberName(by);
73
+ return { name: t.space, member: t.member, ...(b ? { by: b } : {}) };
74
+ }
75
+ export function removalHoldMs(r) {
76
+ return Math.min(REMOVED_FIRST_MS * 2 ** Math.max(0, Math.min(r.knocks, 20) - 1), REMOVED_MAX_MS);
77
+ }
78
+ export function removalHolds(r, now = Date.now()) {
79
+ return !!r && now - r.last < removalHoldMs(r) && r.last <= now + 60_000;
80
+ }
81
+ export function recordRemoval(t, by, home, now = Date.now()) {
82
+ return update(home, (all) => {
83
+ const k = removalKey(t);
84
+ const was = all[k];
85
+ const knocks = !was ? 1 : now - was.last >= REMOVED_FIRST_MS - 60_000 ? Math.min(was.knocks + 1, 64) : was.knocks;
86
+ const who = safeMemberName(by) ?? (was?.member === t.member ? was?.by : undefined);
87
+ const r = { team: k, name: t.space, member: t.member, relay: t.relay, at: was?.member === t.member ? was.at : now, last: now, knocks: was?.member === t.member ? knocks : 1,
88
+ ...(who ? { by: who } : {}), ...(was?.member === t.member && was?.told ? { told: was.told } : {}) };
89
+ all[k] = r;
90
+ return { changed: true, out: r };
91
+ });
92
+ }
93
+ export function clearRemoval(t, home) {
94
+ {
95
+ const all = readAll(home);
96
+ if (!all[removalKey(t)] && !all[`${t.relay}|${t.space}`])
97
+ return false;
98
+ }
99
+ return update(home, (all) => {
100
+ let changed = false;
101
+ for (const k of [removalKey(t), `${t.relay}|${t.space}`])
102
+ if (all[k]) {
103
+ delete all[k];
104
+ changed = true;
105
+ }
106
+ return { changed, out: changed };
107
+ });
108
+ }
109
+ export function takeTold(t, home, now = Date.now()) {
110
+ if (!removalFor(t, home))
111
+ return false;
112
+ return update(home, (all) => {
113
+ const r = all[removalKey(t)] ?? all[`${t.relay}|${t.space}`];
114
+ if (!r || r.member !== t.member || r.told)
115
+ return { changed: false, out: false };
116
+ r.told = now;
117
+ return { changed: true, out: true };
118
+ });
119
+ }
120
+ export function removedLine(r) {
121
+ return `you (${r.member}) were removed from "${r.name}"${r.by ? ` by ${r.by}` : ""}. This machine has stopped connecting to it (it checks now and then, at most every 6 hours). ` +
122
+ `To come back you need a new invite from someone in "${r.name}" (pantheon join <invite>), or for a member to let you back in (pantheon admit ${r.member}). ` +
123
+ `To forget the team here: pantheon leave "${r.name}"`;
124
+ }
125
+ export function removedNotice(r) {
126
+ return {
127
+ kind: "worker", who: "Pantheon", context: r.name, ttl: 0, sound: false,
128
+ heading: `You were removed from ${r.name}`,
129
+ body: `${r.by ? `${r.by} removed you from "${r.name}". ` : ""}This machine stopped connecting to it, so no requests or alerts come from it (it checks now and then). To come back you need a new invite from someone in the team (pantheon join <invite>).`,
130
+ };
131
+ }
132
+ //# sourceMappingURL=removed.js.map
@@ -53,6 +53,19 @@ catch {
53
53
  return fallback;
54
54
  } };
55
55
  export function diagnoseRun(f, probe = gitProbe) {
56
+ if (f.timedOut) {
57
+ const m = f.timedOut.minutes;
58
+ const raise = f.timedOut.from === "flag"
59
+ ? `this worker was started with --run-minutes ${m}; start it with a larger value (up to 180), or without the flag to use this machine's setting`
60
+ : f.timedOut.from === "persona"
61
+ ? `the persona that handled it lowers the limit (max_run_minutes) — raise or remove that line, and if the machine's own limit is also too short: pantheon policy set run-minutes <minutes, up to 180>`
62
+ : `raise it on this machine: pantheon policy set run-minutes <minutes, up to 180> — it applies from the next run, no restart`;
63
+ return {
64
+ cause: "timed_out",
65
+ says: `The run reached this machine's time limit of ${m} minutes and was stopped before it finished.`,
66
+ fix: `If the work needs longer, ${raise}. Or split the request into smaller pieces. What it did so far is in its worktree; the transcript is at ${f.runLog}.`,
67
+ };
68
+ }
56
69
  if (!f.exited && AUTH_RE.test(f.detail)) {
57
70
  const fix = SIGN_IN_FIX[f.agent ?? ""] ?? "sign in to the agent CLI on this machine";
58
71
  return {
@@ -3,7 +3,8 @@ import { approvalSummary, installForUser } from "./install.js";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { applyProfileInit, loadProfile, profilePath, saveProfile, spaceOverride, teamProfile, wakeMode } from "./profile.js";
6
+ import { applyProfileInit, dropCatchAllClaims, loadProfile, profilePath, saveProfile, spaceOverride, teamProfile, wakeMode } from "./profile.js";
7
+ import { isCatchAllDomain } from "./protocol.js";
7
8
  import { installService as realInstall, uninstallService as realUninstall, serviceInstalled as realInstalled } from "./service.js";
8
9
  import { personaDir } from "./persona.js";
9
10
  import { hudBinaryPath } from "./notifier.js";
@@ -143,9 +144,13 @@ export function parseSetupFlags(argv) {
143
144
  preset.title = title.trim();
144
145
  const decides = val("decides");
145
146
  if (decides !== undefined) {
146
- const ids = decides.split(",").map((x) => x.trim().toLowerCase()).filter((x) => x && x !== "none");
147
- const bad = ids.filter((id) => !DECISION_AREAS.some((x) => x.id === id));
148
- if (bad.length)
147
+ const all = decides.split(",").map((x) => x.trim().toLowerCase()).filter(Boolean);
148
+ const ids = all.filter((x) => x !== "none");
149
+ const vague = ids.filter((id) => isCatchAllDomain(id));
150
+ const bad = ids.filter((id) => !vague.includes(id) && !DECISION_AREAS.some((x) => x.id === id));
151
+ if (vague.length)
152
+ errors.push(catchAllProblem("decides", vague));
153
+ else if (bad.length)
149
154
  errors.push(`--decides: unknown area${bad.length === 1 ? "" : "s"} ${bad.join(", ")} — use ${DECISION_AREAS.map((x) => x.id).join(", ")} or none`);
150
155
  else
151
156
  preset.decides = [...new Set(ids)];
@@ -180,10 +185,19 @@ export function decisionAreasProblem(flagName, raw) {
180
185
  if (raw === undefined)
181
186
  return null;
182
187
  const items = raw.split(",").map((x) => x.trim()).filter(Boolean);
188
+ const vague = items.filter((x) => isCatchAllDomain(x));
189
+ if (vague.length)
190
+ return catchAllProblem(flagName, vague);
183
191
  if (!raw.includes(";") && !items.some((x) => /\s/.test(x)))
184
192
  return null;
185
193
  return `--${flagName} "${raw}": these take decision areas like engineering,product; describe the role with --responsibilities`;
186
194
  }
195
+ export function catchAllProblem(flagName, words) {
196
+ const w = words.map((x) => `"${x}"`).join(", ");
197
+ return `--${flagName} ${w}: ${words.length === 1 ? "that is" : "those are"} not a decision area — it would reserve a domain by that name, which no decision is ever filed under. ` +
198
+ `Name the areas one by one from: ${DECISION_AREAS.map((x) => x.id).join(", ")}. ` +
199
+ `To reserve nothing for yourself: pantheon setup --decides none`;
200
+ }
187
201
  export async function runGuide(io, ctx, deps = {}, preset = {}) {
188
202
  const optOut = ctx.optOut ?? {};
189
203
  const serves = ctx.project ? { path: ctx.project, repos: true } : ctx.serves ?? null;
@@ -231,6 +245,11 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
231
245
  : !serves ? "nothing is bound to this team on this machine yet, so there is nothing to serve — bind a repo or a docs folder: pantheon use <team> --here"
232
246
  : a.wake === "anyone" ? "your teammates can hand work to this machine while you're away, so nothing waits on you (to approve each one first: pantheon setup --wake approved)"
233
247
  : "only people you approve — you are asked the first time a teammate's request needs this machine (Always / Just this once / No)"}`);
248
+ if (a.wake !== "no") {
249
+ const ready = deps.readiness?.();
250
+ if (ready && !ready.ok)
251
+ io.say(` ! Agent: ${ready.says}\n ${ready.fix}`);
252
+ }
234
253
  if (a.wake !== "no" && serves?.repos) {
235
254
  io.say(` • Commands: ${a.commands ? "work runs your tests, commits, and pushes to its own branch, so it arrives ready for your review" : "edits and answers only — turn commands on to have work arrive tested and ready to review"}`);
236
255
  if (awayAgents.length && unsandboxedAllowed(loadPolicy(deps.policyHome))) {
@@ -280,6 +299,10 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
280
299
  }
281
300
  }
282
301
  let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
302
+ const vague = dropCatchAllClaims(next);
303
+ next = vague.profile;
304
+ if (vague.dropped.length)
305
+ applied.push(`profile: dropped ${vague.dropped.map((d) => `"${d}"`).join(", ")} — not a decision area, so it never routed anything`);
283
306
  const ovKey = [ctx.cfg.spaceId, ctx.cfg.space].find((k) => k && spaceOverride(next, k)?.authority);
284
307
  const authority = { ...(ovKey ? next.spaces[ovKey].authority : next.authority) };
285
308
  const prior = guideDefaults(profile, ctx.cfg).decides;
package/dist/store.js CHANGED
@@ -2,6 +2,8 @@ import { readFileSync, existsSync, mkdirSync, renameSync, openSync, writeSync, f
2
2
  import { dirname, join } from "node:path";
3
3
  import { emptyRefusals } from "./admin-stats.js";
4
4
  import { pendingView, writerFor } from "./disk-writer.js";
5
+ import { emptyUsage, parseUsage } from "./usage.js";
6
+ export const USAGE_SAVE_MS = 5_000;
5
7
  export function ensurePrivateDir(dir) {
6
8
  mkdirSync(dir, { recursive: true, mode: 0o700 });
7
9
  try {
@@ -98,15 +100,67 @@ export class StateStore {
98
100
  headOf;
99
101
  rebuilt = null;
100
102
  extra = {};
103
+ usage = emptyUsage();
104
+ usageFile;
105
+ usageTimer = null;
106
+ usageDirty = false;
101
107
  constructor(dir, stem) {
102
108
  this.dir = dir;
103
109
  this.stem = stem;
104
110
  this.file = join(dir, `${stem}.state.json`);
111
+ this.usageFile = join(dir, `${stem}.usage.json`);
112
+ }
113
+ loadUsage(quarantine) {
114
+ try {
115
+ const pending = pendingView(this.dir, this.stem, this.usageFile);
116
+ if (pending === null || (pending === undefined && !existsSync(this.usageFile))) {
117
+ this.usage = emptyUsage();
118
+ return;
119
+ }
120
+ this.usage = parseUsage(JSON.parse(pending ? pending.toString("utf8") : readFileSync(this.usageFile, "utf8")));
121
+ }
122
+ catch {
123
+ if (quarantine) {
124
+ try {
125
+ if (existsSync(this.usageFile))
126
+ renameSync(this.usageFile, `${this.usageFile}.corrupt-${Date.now()}`);
127
+ }
128
+ catch { }
129
+ }
130
+ this.usage = emptyUsage();
131
+ }
132
+ }
133
+ saveUsage() {
134
+ this.usageDirty = true;
135
+ if (this.usageTimer)
136
+ return;
137
+ this.usageTimer = setTimeout(() => { this.usageTimer = null; this.writeUsage(); }, USAGE_SAVE_MS);
138
+ this.usageTimer.unref?.();
139
+ }
140
+ discardUsage() {
141
+ if (this.usageTimer) {
142
+ clearTimeout(this.usageTimer);
143
+ this.usageTimer = null;
144
+ }
145
+ this.usageDirty = false;
146
+ }
147
+ writeUsage() {
148
+ if (!this.usageDirty)
149
+ return;
150
+ this.usageDirty = false;
151
+ try {
152
+ writerFor(this.dir, this.stem).replace(this.usageFile, JSON.stringify(this.usage) + "\n", 0o600, "mark");
153
+ }
154
+ catch (e) {
155
+ this.usageDirty = true;
156
+ this.onError?.(e);
157
+ }
105
158
  }
106
159
  load(opts = {}) {
107
160
  const goals = new Map();
108
161
  const obligations = new Map();
109
162
  const profiles = new Map();
163
+ this.loadUsage(!!opts.quarantine);
110
164
  const pending = pendingView(this.dir, this.stem, this.file);
111
165
  if (pending === null || (pending === undefined && !existsSync(this.file))) {
112
166
  return { goals, obligations, profiles, unclean: opts.expectHistory ? "the state snapshot is missing but the ledger has history" : null };
@@ -190,6 +244,11 @@ export class StateStore {
190
244
  clearTimeout(this.timer);
191
245
  this.timer = null;
192
246
  }
247
+ if (this.usageTimer) {
248
+ clearTimeout(this.usageTimer);
249
+ this.usageTimer = null;
250
+ }
251
+ this.writeUsage();
193
252
  this.flush(goals, obligations, profiles);
194
253
  }
195
254
  }