@join-pantheon/cli 0.33.5 → 0.33.6
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 +34 -2
- package/dist/agents.js +114 -13
- package/dist/board.js +4 -0
- package/dist/claude-confine.js +46 -2
- package/dist/cli.js +194 -23
- package/dist/client.js +143 -6
- package/dist/doctor.js +49 -7
- package/dist/file-lock.js +2 -2
- package/dist/git-safe.js +5 -1
- package/dist/home-guard.js +57 -2
- package/dist/home-safety.js +2 -0
- package/dist/linux-sandbox.js +71 -0
- package/dist/machine.js +18 -0
- package/dist/mcp.js +13 -5
- package/dist/notifier.js +57 -7
- package/dist/persona.js +34 -10
- package/dist/pickup.js +82 -0
- package/dist/profile.js +24 -1
- package/dist/protocol.js +19 -2
- package/dist/relay.js +128 -20
- package/dist/removed.js +132 -0
- package/dist/run-failure.js +13 -0
- package/dist/setup-guide.js +27 -4
- package/dist/store.js +59 -0
- package/dist/tray.js +45 -7
- package/dist/usage.js +97 -0
- package/dist/which.js +3 -2
- package/dist/worker-fleet.js +33 -3
- package/dist/worker-policy.js +58 -0
- package/dist/worker.js +215 -29
- package/helper/macos/prebuilt/manifest.json +1 -1
- package/package.json +1 -1
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) =>
|
|
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
|
|
3231
|
-
if (
|
|
3232
|
-
|
|
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
|
-
|
|
3324
|
-
|
|
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;
|
package/dist/removed.js
ADDED
|
@@ -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
|
package/dist/run-failure.js
CHANGED
|
@@ -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 {
|
package/dist/setup-guide.js
CHANGED
|
@@ -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
|
|
147
|
-
const
|
|
148
|
-
|
|
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
|
}
|