trantor 0.18.104 → 0.18.106
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/.claude-plugin/plugin.json +1 -1
- package/bin/balances.mjs +10 -14
- package/bin/crew-runner.mjs +32 -5
- package/hooks/lib/balance-check.mjs +3 -2
- package/hooks/statusline.mjs +2 -0
- package/hub/routes/admin.mjs +37 -10
- package/hub/store.mjs +2 -1
- package/lib/balances.mjs +8 -0
- package/lib/store-pg.mjs +2 -0
- package/lib/turn-policy.mjs +44 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.106",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/balances.mjs
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/* oxlint-disable anti-slop/no-runtime-typeof -- SAFETY: config.json is user-editable external input; thresholds validates its optional number/object fields at that I/O boundary. */
|
|
3
|
-
// trantor balances
|
|
4
|
-
//
|
|
5
|
-
// balance API, prints them, and pushes the snapshot to the hub so the dashboard + other sessions see it.
|
|
6
|
-
//
|
|
3
|
+
// trantor balances: how much credit is left on each provider, so you can refill before a build
|
|
4
|
+
// stalls. Queries each provider with the env keys and pushes the snapshot to the hub.
|
|
7
5
|
// Usage: trantor balances [--json] [--no-push]
|
|
8
6
|
import { readFileSync, existsSync } from "node:fs";
|
|
9
7
|
import { join } from "node:path";
|
|
10
8
|
import { homedir } from "node:os";
|
|
11
|
-
import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../lib/balances.mjs";
|
|
9
|
+
import { fetchBalances, isLow, fmtBalance, snapshotPayload, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../lib/balances.mjs";
|
|
10
|
+
import { hostId } from "../lib/project.mjs";
|
|
12
11
|
import { detectedCliBalanceRows } from "../lib/providers.mjs";
|
|
13
12
|
import { loadProfile } from "./profile.mjs";
|
|
14
13
|
import { resolveKeys } from "../lib/provider-keys.mjs";
|
|
@@ -19,9 +18,10 @@ const noPush = args.includes("--no-push");
|
|
|
19
18
|
|
|
20
19
|
// API-key providers stay profile-scoped so ambient keys are never scraped. Claude and Codex are
|
|
21
20
|
// machine CLI logins: the registry admits them from binary + credential + live-probe detection.
|
|
22
|
-
const
|
|
21
|
+
const profileProviders = loadProfile().providers || {};
|
|
22
|
+
const configured = Object.keys(profileProviders);
|
|
23
23
|
|
|
24
|
-
// Signed via the shared client
|
|
24
|
+
// Signed via the shared client: an unsigned POST is rejected under enforce.
|
|
25
25
|
import { signedPost } from "../hooks/lib/api.mjs";
|
|
26
26
|
let _qpct = DEFAULT_LOW_QUOTA_PCT;
|
|
27
27
|
function thresholds() {
|
|
@@ -35,14 +35,10 @@ const profileScoped = await fetchBalances(env, { only: configured.filter((provid
|
|
|
35
35
|
const balances = [...detected, ...profileScoped];
|
|
36
36
|
const low = thresholds();
|
|
37
37
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// the desktop app reads balances from 127.0.0.1 because the data is machine-local, and the
|
|
41
|
-
// project-resolved push goes to the REMOTE hub for pinned projects. That mismatch left the
|
|
42
|
-
// local snapshot 11 days stale on 2026-08-28: the header chips faithfully rendered Aug-16
|
|
43
|
-
// numbers, dimmed, while fresh pushes landed on a hub the app never asks.
|
|
38
|
+
// Push the snapshot to the project hub and the LOCAL hub (best-effort); the machine key and
|
|
39
|
+
// profile let a shared hub keep one snapshot per machine.
|
|
44
40
|
if (!noPush) {
|
|
45
|
-
const snap =
|
|
41
|
+
const snap = snapshotPayload(balances, { machine: hostId(), profile: profileProviders });
|
|
46
42
|
try { await signedPost("/balances", snap, { timeoutMs: 2500 }); } catch {}
|
|
47
43
|
try { await signedPost("http://127.0.0.1:4477/balances", snap, { timeoutMs: 2500 }); } catch {}
|
|
48
44
|
}
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "../lib/classify-failure.mjs";
|
|
24
24
|
import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
|
|
25
25
|
import {
|
|
26
|
-
cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, PARKING_REASONS,
|
|
26
|
+
cardRefs, servedContractCard, carriesWork, parseTurnTokens, parseResetAt, reasonWithBalances, quotaResetAt, queuedWording, turnDelivered, budgetPause, PARKING_REASONS,
|
|
27
27
|
senderProjectOf, isLinkedProject, stateSkipReason, isMessageCardTitle, OPEN_CARD_STATUSES,
|
|
28
28
|
CUT_CHAIN_PARK_MIN, cutChainEvidence, isBoundedPark, askDuringTurn, askedVerdict,
|
|
29
29
|
} from "../lib/turn-policy.mjs";
|
|
@@ -627,6 +627,13 @@ async function notifyAssigners(pairs, text) {
|
|
|
627
627
|
async function latestBusEventId() {
|
|
628
628
|
try { const r = await api("/events?limit=1"); return Number(r?.latest) || 0; } catch { return 0; }
|
|
629
629
|
}
|
|
630
|
+
// #11860: this seat's direct messages since `seq`, to tell a reply to the assigner from silence.
|
|
631
|
+
async function busMessagesSince(seq) {
|
|
632
|
+
try {
|
|
633
|
+
const r = await api(`/events?by=${encodeURIComponent(SESSION)}&since=${seq}&limit=500`);
|
|
634
|
+
return Array.isArray(r?.events) ? r.events.filter(e => e?.type === "message") : [];
|
|
635
|
+
} catch { return []; }
|
|
636
|
+
}
|
|
630
637
|
async function busActivitySince(seq) {
|
|
631
638
|
try {
|
|
632
639
|
const r = await api(`/events?by=${encodeURIComponent(SESSION)}&since=${seq}&limit=500`);
|
|
@@ -1149,9 +1156,10 @@ exit $turn_exit`;
|
|
|
1149
1156
|
// Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
|
|
1150
1157
|
let cardStatusAtEnd = "", noDelivery = false, blockedVerdict = "";
|
|
1151
1158
|
if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
|
|
1152
|
-
let endStatus = null;
|
|
1159
|
+
let endStatus = null, endTask = null;
|
|
1153
1160
|
try {
|
|
1154
1161
|
const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
|
|
1162
|
+
endTask = task;
|
|
1155
1163
|
endStatus = task ? String(task.status || "") : null;
|
|
1156
1164
|
const freshNote = (task?.log || []).filter(n => n.by === SESSION && n.ts >= t0
|
|
1157
1165
|
&& !cardLogBefore.some(old => old.ts === n.ts && old.by === n.by && old.text === n.text)).at(-1);
|
|
@@ -1170,7 +1178,11 @@ exit $turn_exit`;
|
|
|
1170
1178
|
// still IS testing/done: a mid-turn bounce to doing (endStatus doing) reopens the contract.
|
|
1171
1179
|
const alreadyDelivered = ["testing", "done"].includes(cardStatusBefore)
|
|
1172
1180
|
&& ["testing", "done"].includes(endStatus);
|
|
1173
|
-
noDelivery = !
|
|
1181
|
+
noDelivery = !alreadyDelivered && !askVerdict && !blockedVerdict && !newCommit && !moved;
|
|
1182
|
+
if (noDelivery) {
|
|
1183
|
+
const events = await busMessagesSince(busSeqBefore);
|
|
1184
|
+
noDelivery = !turnDelivered({ newCommit, moved, session: SESSION, t0, cardLogBefore, cardLog: endTask?.log || [], events, assigners: opts.assigners, seqBefore: busSeqBefore });
|
|
1185
|
+
}
|
|
1174
1186
|
}
|
|
1175
1187
|
}
|
|
1176
1188
|
lastNoDelivery = noDelivery;
|
|
@@ -1692,7 +1704,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1692
1704
|
}
|
|
1693
1705
|
// Respect an active backoff: a new message during an outage joins the batch, it does not
|
|
1694
1706
|
// reset the clock and hammer a CLI that is already failing.
|
|
1695
|
-
if (Date.now() < retryAt) { log(
|
|
1707
|
+
if (Date.now() < retryAt) { log(queuedWording(retryAt, parkState?.reason, AGENT, pendingWake.length)); continue; }
|
|
1696
1708
|
await deliverWake();
|
|
1697
1709
|
log("parked — waiting for the next message");
|
|
1698
1710
|
}
|
|
@@ -1761,7 +1773,7 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1761
1773
|
await releaseDutyNudgeClaims({ ids: skippedNudgeIds, statePath: DUTY_NUDGE_STATE, owner: dutyOwner });
|
|
1762
1774
|
}
|
|
1763
1775
|
const claimedIds = new Set(dutyPlan.items.map(item => item.id));
|
|
1764
|
-
|
|
1776
|
+
let wakeForTurn = DUTY_NUDGES
|
|
1765
1777
|
? wake.filter(message => {
|
|
1766
1778
|
const escalation = dutyEscalations([message])[0];
|
|
1767
1779
|
return !escalation || claimedIds.has(escalation.id);
|
|
@@ -1773,6 +1785,21 @@ async function resolveWakeCard(messages, { session }) {
|
|
|
1773
1785
|
log("duty escalation already nudged or reserved by another turn — consumed without a duplicate model wake");
|
|
1774
1786
|
return;
|
|
1775
1787
|
}
|
|
1788
|
+
// #11864: the pause lifts when its card moves on, and a newer contract for another card beats
|
|
1789
|
+
// the stale messages that still cite the paused one.
|
|
1790
|
+
if (budgetStop) {
|
|
1791
|
+
const status = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${budgetStop.card}`).then(r => String(r?.task?.status || "")).catch(() => "");
|
|
1792
|
+
const bp = budgetPause({ paused: budgetStop, status, messages: wakeForTurn, session: SESSION });
|
|
1793
|
+
if (bp.action === "lift") {
|
|
1794
|
+
log(`#${budgetStop.card} is ${status} — token-budget pause lifted`);
|
|
1795
|
+
budgetStop = null; budgetRetryAt = 0; cardTokens = 0;
|
|
1796
|
+
} else if (bp.action === "bind") {
|
|
1797
|
+
log(`#${budgetStop.card} is paused over its token budget — serving the newer contract for #${bp.card}, dropping ${bp.drop.length} stale message(s)`);
|
|
1798
|
+
wakeForTurn = wakeForTurn.filter(m => !bp.drop.includes(m));
|
|
1799
|
+
pendingWake = pendingWake.filter(m => !bp.drop.includes(m));
|
|
1800
|
+
savePending(pendingWake, pendingBcast);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1776
1803
|
const wakeCapped = capWake(wakeForTurn);
|
|
1777
1804
|
const bcastCapped = capBcast(pendingBcast);
|
|
1778
1805
|
const wakeText = wakeCapped.text
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
|
-
import { fetchBalances, isLow, fmtBalance, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../../lib/balances.mjs";
|
|
8
|
+
import { fetchBalances, isLow, fmtBalance, snapshotPayload, DEFAULT_LOW, DEFAULT_LOW_QUOTA_PCT } from "../../lib/balances.mjs";
|
|
9
|
+
import { hostId } from "../../lib/project.mjs";
|
|
9
10
|
import { loadProfile } from "../../bin/profile.mjs";
|
|
10
11
|
import { resolveKeys } from "../../lib/provider-keys.mjs";
|
|
11
12
|
import { signedPost } from "./api.mjs";
|
|
@@ -38,7 +39,7 @@ export async function maybeCheckBalances() {
|
|
|
38
39
|
if (!Array.isArray(balances) || !balances.length) { try { writeFileSync(STAMP, JSON.stringify({ ts: Date.now(), low: [] })); } catch {} return { low: [] }; }
|
|
39
40
|
|
|
40
41
|
// push the fresh snapshot to the hub for the dashboard + other sessions (best-effort, signed)
|
|
41
|
-
const snap = {
|
|
42
|
+
const snap = snapshotPayload(balances, { machine: hostId(), profile: loadProfile().providers, by: process.env.TRANTOR_SESSION || "" });
|
|
42
43
|
await signedPost("/balances", snap, { timeoutMs: 2000 });
|
|
43
44
|
// The desktop reads balances from the LOCAL hub (machine-local data); a pinned project's
|
|
44
45
|
// default push lands on the REMOTE one — the 11-day-stale-header bug. Push both.
|
package/hooks/statusline.mjs
CHANGED
|
@@ -40,6 +40,8 @@ async function main() {
|
|
|
40
40
|
try { writeFileSync(join(dir, "usage-claude-live.json"), JSON.stringify({ ts: Date.now(), ...payload })); } catch {}
|
|
41
41
|
// SECONDARY, best-effort: the hub copy, for dashboard surfaces that read hub state.
|
|
42
42
|
const { signedPost } = await import("./lib/api.mjs");
|
|
43
|
+
const { hostId } = await import("../lib/project.mjs");
|
|
44
|
+
payload.machine = hostId();
|
|
43
45
|
await signedPost("/usage/claude", payload, { timeoutMs: 2500 });
|
|
44
46
|
}
|
|
45
47
|
|
package/hub/routes/admin.mjs
CHANGED
|
@@ -387,8 +387,21 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
387
387
|
const b = await body(req);
|
|
388
388
|
const entries = Array.isArray(b.balances) ? b.balances.slice(0, 30) : [];
|
|
389
389
|
const ts = (Number.isFinite(b.ts) && b.ts > 0) ? Math.floor(b.ts) : now();
|
|
390
|
+
const by = String(b.by || "").slice(0, 120);
|
|
390
391
|
// only accept a newer snapshot (avoid an older session clobbering a fresh push)
|
|
391
|
-
if (ts >= (state.balances?.ts || 0)) { state.balances = { ts, by
|
|
392
|
+
if (ts >= (state.balances?.ts || 0)) { state.balances = { ts, by, entries }; markDirty(); }
|
|
393
|
+
// Per-machine copy: two Macs on one hub each keep their own providers and profile.
|
|
394
|
+
const machine = String(b.machine || "").trim().slice(0, 60);
|
|
395
|
+
if (machine) {
|
|
396
|
+
const byM = (state.balancesByMachine ||= {});
|
|
397
|
+
if (ts >= (byM[machine]?.ts || 0)) {
|
|
398
|
+
const prof = (b.profile && typeof b.profile === "object" && !Array.isArray(b.profile)) ? Object.fromEntries(Object.entries(b.profile).slice(0, 30)) : {};
|
|
399
|
+
byM[machine] = { ts, by, entries, profile: prof };
|
|
400
|
+
const keys = Object.keys(byM);
|
|
401
|
+
if (keys.length > 20) for (const k of keys.sort((x, y) => byM[x].ts - byM[y].ts).slice(0, keys.length - 20)) delete byM[k];
|
|
402
|
+
markDirty();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
392
405
|
return json(res, 200, { ok: true });
|
|
393
406
|
}
|
|
394
407
|
// USAGE v2: the Claude statusline sidechannel. Claude Code >=2.1.80 pipes rate_limits into the
|
|
@@ -402,14 +415,23 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
402
415
|
const wins = [["fiveHour", "5h"], ["sevenDay", "7d"], ["fable", "Fable"]]
|
|
403
416
|
.map(([k, n]) => win(b[k], n)).filter(Boolean);
|
|
404
417
|
if (!wins.length) return json(res, 400, { error: "no usable windows" });
|
|
405
|
-
state.balances ||= { ts: 0, by: "", entries: [] };
|
|
406
|
-
|
|
407
|
-
if (
|
|
418
|
+
const patchSnaps = [(state.balances ||= { ts: 0, by: "", entries: [] })];
|
|
419
|
+
const machine = String(b.machine || "").trim().slice(0, 60);
|
|
420
|
+
if (machine) patchSnaps.push(((state.balancesByMachine ||= {})[machine] ||= { ts: now(), by: "", entries: [], profile: {} }));
|
|
421
|
+
const claudeEntry = (snap) => {
|
|
422
|
+
let c = snap.entries.find(x => x.provider === "claude");
|
|
423
|
+
if (!c) { c = { provider: "claude", label: "Claude", kind: "windows", ok: true, windows: [] }; snap.entries.push(c); }
|
|
424
|
+
return c;
|
|
425
|
+
};
|
|
426
|
+
const e = claudeEntry(patchSnaps[patchSnaps.length - 1]); // dedupe against the most specific copy
|
|
408
427
|
// Same-value posts inside 30s are dropped (the statusline ticks ~3x/sec while streaming).
|
|
409
428
|
const sig = JSON.stringify(wins);
|
|
410
429
|
if (e._liveSig === sig && now() - (e.liveTs || 0) < 30_000) return json(res, 200, { ok: true, deduped: true });
|
|
411
|
-
for (const
|
|
412
|
-
|
|
430
|
+
for (const snap of patchSnaps) {
|
|
431
|
+
const t = claudeEntry(snap);
|
|
432
|
+
for (const w of wins) { const cur = (t.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else t.windows.push({ ...w }); }
|
|
433
|
+
t.ok = true; t.liveTs = now(); t._liveSig = sig; t.liveSource = "statusline";
|
|
434
|
+
}
|
|
413
435
|
markDirty();
|
|
414
436
|
return json(res, 200, { ok: true, windows: wins.length });
|
|
415
437
|
}
|
|
@@ -422,7 +444,12 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
422
444
|
: (e.remaining != null && e.remaining < (low[e.currency] ?? low.USD ?? 5)));
|
|
423
445
|
const ALIAS = { kimi: "moonshot", moonshot: "moonshot", glm: "zhipu", zai: "zhipu", zhipu: "zhipu" };
|
|
424
446
|
const canonP = p => ALIAS[p] || p;
|
|
425
|
-
|
|
447
|
+
// ?machine=X serves that machine's own snapshot and profile; otherwise the legacy latest.
|
|
448
|
+
const mSnap = q.machine ? state.balancesByMachine?.[String(q.machine).trim().slice(0, 60)] : null;
|
|
449
|
+
const snap = mSnap || state.balances;
|
|
450
|
+
let prof = {};
|
|
451
|
+
if (mSnap?.profile && Object.keys(mSnap.profile).length) prof = mSnap.profile;
|
|
452
|
+
else { try { prof = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {} }
|
|
426
453
|
const profByCanon = {}; for (const [p, v] of Object.entries(prof)) profByCanon[canonP(p)] = v;
|
|
427
454
|
const detectedCli = new Set(["claude", "codex"]);
|
|
428
455
|
// API-key rows remain profile-scoped so a stray ambient key never appears. Claude and Codex
|
|
@@ -430,7 +457,7 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
430
457
|
// missing quota declaration must not hide those machine-login rows from the bottom bar. A
|
|
431
458
|
// prepaid entry that ERRORED but is a subscription per profile is really one (401 expected).
|
|
432
459
|
const isSub = (t) => !!t && t !== "api"; // capped-sub / high-sub → a subscription (nothing to refill)
|
|
433
|
-
const entries = (
|
|
460
|
+
const entries = (snap?.entries || []).filter(e => detectedCli.has(e.provider) || profByCanon[canonP(e.provider)]).map(e => {
|
|
434
461
|
const pv = profByCanon[canonP(e.provider)];
|
|
435
462
|
if (!e.ok && isSub(pv?.tier)) return { provider: e.provider, label: e.label, kind: "subscription", plan: pv.plan, ok: true, remaining: null, low: false };
|
|
436
463
|
return { ...e, low: lowOf(e) };
|
|
@@ -441,8 +468,8 @@ export async function routeAdmin({ req, res, q, P, auth, ctx }) {
|
|
|
441
468
|
const subs = Object.entries(prof)
|
|
442
469
|
.filter(([p, v]) => !detectedCli.has(p) && isSub(v?.tier) && !known.has(canonP(p)))
|
|
443
470
|
.map(([p, v]) => ({ provider: p, label: p, kind: "subscription", plan: v.plan, ok: true, remaining: null, low: false }));
|
|
444
|
-
return json(res, 200, { ts:
|
|
445
|
-
entries: [...entries, ...subs], lowCount: entries.filter(e => e.low).length, stale: (now() - (
|
|
471
|
+
return json(res, 200, { ts: snap?.ts || 0, by: snap?.by || "", thresholds: low,
|
|
472
|
+
entries: [...entries, ...subs], lowCount: entries.filter(e => e.low).length, stale: (now() - (snap?.ts || 0)) > 6 * 3600e3 });
|
|
446
473
|
}
|
|
447
474
|
// Rebuild cc-subagent notional cards for a project from recomputed on-disk transcript costs
|
|
448
475
|
// (`trantor recost`). REPLACES the project's existing cc-subagent cards with the supplied set so the
|
package/hub/store.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { DEFAULT_ORG } from "../lib/store-contract.mjs";
|
|
|
6
6
|
|
|
7
7
|
export async function createStoreRuntime({ STORE_KIND, PG_URL, ORG_ID, DATA }) {
|
|
8
8
|
function emptyState() {
|
|
9
|
-
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
|
|
9
|
+
return { messages: [], peers: {}, seq: 0, tasks: [], taskSeq: 0, projectMeta: {}, lessons: [], events: [], cardEventsBackfilled: false, aliases: {}, phaseMeta: {}, verifyGates: [], verifyGateSeq: 0, proposals: [], proposalSeq: 0, balances: { ts: 0, by: "", entries: [] }, balancesByMachine: {}, subagentCostReset: false, handoffLog: [], identities: {}, inviteTokens: {}, focus: {}, orgPolicy: {}, overseerState: {}, instances: {}, dutySession: "", contractReap: {}, eventSeq: 0 };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const CARD_LOG_MAX = 40;
|
|
@@ -103,6 +103,7 @@ function normalizeState(loaded = {}) {
|
|
|
103
103
|
s.proposals = Array.isArray(loaded.proposals) ? loaded.proposals : [];
|
|
104
104
|
s.proposalSeq = Number(loaded.proposalSeq || Math.max(0, ...s.proposals.map(p => Number(p.id) || 0))) || 0;
|
|
105
105
|
s.balances = loaded.balances && typeof loaded.balances === "object" ? loaded.balances : { ts: 0, by: "", entries: [] };
|
|
106
|
+
s.balancesByMachine = loaded.balancesByMachine && typeof loaded.balancesByMachine === "object" ? loaded.balancesByMachine : {};
|
|
106
107
|
s.subagentCostReset = !!loaded.subagentCostReset;
|
|
107
108
|
s.handoffLog = Array.isArray(loaded.handoffLog) ? loaded.handoffLog : [];
|
|
108
109
|
s.identities = loaded.identities && typeof loaded.identities === "object" ? loaded.identities : {};
|
package/lib/balances.mjs
CHANGED
|
@@ -313,3 +313,11 @@ export function fmtReset(t) {
|
|
|
313
313
|
if (hrs < 48) return `${Math.round(hrs)}h`;
|
|
314
314
|
return `${Math.round(hrs / 24)}d`;
|
|
315
315
|
}
|
|
316
|
+
|
|
317
|
+
// The snapshot a machine pushes to a hub: its balances plus the machine key and the provider
|
|
318
|
+
// profile, so a shared hub can keep one snapshot per machine and filter by that machine's profile.
|
|
319
|
+
export function snapshotPayload(balances, { machine, profile, by, ts = Date.now() }) {
|
|
320
|
+
const snap = { balances, ts, machine: String(machine || ""), profile: profile ?? {} };
|
|
321
|
+
if (by) snap.by = by;
|
|
322
|
+
return snap;
|
|
323
|
+
}
|
package/lib/store-pg.mjs
CHANGED
|
@@ -162,6 +162,7 @@ function kvFromState(state) {
|
|
|
162
162
|
eventSeq: Number(state.eventSeq || 0),
|
|
163
163
|
inviteTokens: state.inviteTokens || {},
|
|
164
164
|
instances: state.instances || {},
|
|
165
|
+
balancesByMachine: state.balancesByMachine || {},
|
|
165
166
|
},
|
|
166
167
|
subagentCostReset: !!state.subagentCostReset,
|
|
167
168
|
seq: Number(state.seq || 0),
|
|
@@ -731,6 +732,7 @@ export class PgStore {
|
|
|
731
732
|
identities,
|
|
732
733
|
inviteTokens: meta.inviteTokens && typeof meta.inviteTokens === "object" ? meta.inviteTokens : {},
|
|
733
734
|
instances: meta.instances && typeof meta.instances === "object" ? meta.instances : {},
|
|
735
|
+
balancesByMachine: meta.balancesByMachine && typeof meta.balancesByMachine === "object" ? meta.balancesByMachine : {},
|
|
734
736
|
focus: kv.focus && typeof kv.focus === "object" ? kv.focus : {},
|
|
735
737
|
overseerState: meta.overseerState || {},
|
|
736
738
|
orgPolicy: kv.orgPolicy && typeof kv.orgPolicy === "object" ? kv.orgPolicy : {},
|
package/lib/turn-policy.mjs
CHANGED
|
@@ -173,8 +173,11 @@ const RESET_ABS_RE = new RegExp(
|
|
|
173
173
|
"i",
|
|
174
174
|
);
|
|
175
175
|
const RESET_REL_RE = /try again in\s+(\d+)\s*(second|minute|hour|day)s?/i;
|
|
176
|
+
// A fixed rolling window ("5-hour usage limit") names no timestamp; the error time + N hours is the bound.
|
|
177
|
+
const RESET_WINDOW_RE = /\b(\d{1,3})[-\s]hours?\s+(?:usage\s+)?limit/i;
|
|
176
178
|
|
|
177
179
|
/// The epoch ms at which this seat may be retried, or 0 when the output named no time.
|
|
180
|
+
/// A "<N>-hour usage limit" window counts as N hours from `now`, the moment of the error.
|
|
178
181
|
export function parseResetAt(text, now = Date.now()) {
|
|
179
182
|
const s = String(text || "");
|
|
180
183
|
|
|
@@ -191,9 +194,50 @@ export function parseResetAt(text, now = Date.now()) {
|
|
|
191
194
|
if (unit) return now + Number(rel[1]) * unit;
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
const win = RESET_WINDOW_RE.exec(s);
|
|
198
|
+
if (win && Number(win[1]) > 0) return now + Number(win[1]) * 3600e3;
|
|
199
|
+
|
|
194
200
|
return 0;
|
|
195
201
|
}
|
|
196
202
|
|
|
203
|
+
/// #11860: a design, research or drill step legitimately ends with no commit and the card unmoved.
|
|
204
|
+
/// It still delivered when this seat wrote a note on its OWN card during the turn, or messaged
|
|
205
|
+
/// the contract's sender directly. Reading and saying nothing stays no-delivery.
|
|
206
|
+
export function turnDelivered({ newCommit, moved, session, t0, cardLogBefore = [], cardLog = [], events = [], assigners = [], seqBefore = 0 }) {
|
|
207
|
+
if (newCommit || moved) return true;
|
|
208
|
+
const ownNote = (cardLog || []).some(n => n && n.by === session && n.ts >= t0
|
|
209
|
+
&& !(cardLogBefore || []).some(old => old.ts === n.ts && old.by === n.by && old.text === n.text));
|
|
210
|
+
if (ownNote) return true;
|
|
211
|
+
const owed = new Set((assigners || []).map(a => a && a.from).filter(f => f && f !== session));
|
|
212
|
+
return (events || []).some(e => e && e.type === "message" && e.by === session
|
|
213
|
+
&& Number(e.id) > seqBefore && owed.has(e.toSession));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// #11864: what a card's token-budget pause means for this wake batch. `lift` once the paused card
|
|
217
|
+
/// is in testing/done/failed; `bind` when the batch also holds a newer contract for ANOTHER card
|
|
218
|
+
/// (the turn serves that card, and `drop` are the stale messages for the paused one); else `hold`.
|
|
219
|
+
/// `none` when nothing in the batch cites the paused card.
|
|
220
|
+
export function budgetPause({ paused, status = "", messages = [], session = "" }) {
|
|
221
|
+
if (!paused || !(paused.card > 0)) return { action: "none" };
|
|
222
|
+
if (["testing", "done", "failed"].includes(status)) return { action: "lift" };
|
|
223
|
+
const all = (Array.isArray(messages) ? messages : []).filter(Boolean);
|
|
224
|
+
if (!all.some((m) => cardRefs(m.text).includes(paused.card) || Number(m.card) === paused.card)) return { action: "none" };
|
|
225
|
+
const own = (m) => (Number(m.card) > 0 ? Number(m.card) : (assignedCardRef(m.text) || cardRefs(m.text)[0] || 0));
|
|
226
|
+
const direct = newestLast(all.filter((m) => !session || m.to === session));
|
|
227
|
+
const fresh = direct.at(-1);
|
|
228
|
+
const next = fresh ? own(fresh) : 0;
|
|
229
|
+
if (!(next > 0) || next === paused.card) return { action: "hold" };
|
|
230
|
+
const drop = all.filter((m) => m !== fresh && cardRefs(m.text).includes(paused.card) && !cardRefs(m.text).includes(next));
|
|
231
|
+
return { action: "bind", card: next, drop };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// The "queued" log line for an undelivered batch behind a park: a clock time when the retry is
|
|
235
|
+
/// bounded, a `trantor up` pointer when it is not.
|
|
236
|
+
export function queuedWording(retryAt, reason, agent, undelivered, now = Date.now()) {
|
|
237
|
+
if (retryAt >= Number.MAX_SAFE_INTEGER / 2) return `parked (${reason || "exhausted"}) until \`trantor up ${agent}\` — ${undelivered} undelivered`;
|
|
238
|
+
return `queued — ${undelivered} undelivered, next attempt at ${new Date(retryAt).toLocaleTimeString()} (in ${Math.max(0, Math.round((retryAt - now) / 1000))}s)`;
|
|
239
|
+
}
|
|
240
|
+
|
|
197
241
|
/// #6131: a qwen seat whose token plan is spent does not error — it stalls and returns nothing, so
|
|
198
242
|
/// the runner classified it `empty-output` and kept the ladder running against a wall. A silent
|
|
199
243
|
/// turn on a seat whose own balance row reads spent IS exhaustion, and parks like one.
|