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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.104",
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 — show how much credit is left on each prepaid provider (DeepSeek, Kimi, OpenRouter…)
4
- // so you can refill BEFORE a build stalls. Reads keys from the environment, queries each provider's
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 configured = Object.keys(loadProfile().providers || {});
21
+ const profileProviders = loadProfile().providers || {};
22
+ const configured = Object.keys(profileProviders);
23
23
 
24
- // Signed via the shared client (2026-07-31, agent-UX audit): unsigned POST rejected under enforce.
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
- // push the snapshot to the hub (best-effort) so the dashboard + warning line can use it.
39
- // TWO pushes on purpose: the project hub (fleet visibility) AND the LOCAL hub explicitly —
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 = { balances, ts: Date.now() };
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
  }
@@ -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 = !newCommit && !moved && !alreadyDelivered && !askVerdict && !blockedVerdict;
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(`queued — ${pendingWake.length} undelivered, next attempt in ${Math.max(0, Math.round((retryAt - Date.now()) / 1000))}s`); continue; }
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
- const wakeForTurn = DUTY_NUDGES
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 = { balances, ts: Date.now(), by: process.env.TRANTOR_SESSION || "" };
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.
@@ -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
 
@@ -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: String(b.by || "").slice(0, 120), entries }; markDirty(); }
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
- let e = state.balances.entries.find(x => x.provider === "claude");
407
- if (!e) { e = { provider: "claude", label: "Claude", kind: "windows", ok: true, windows: [] }; state.balances.entries.push(e); }
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 w of wins) { const cur = (e.windows ||= []).find(x => x.name === w.name); if (cur) Object.assign(cur, w); else e.windows.push(w); }
412
- e.ok = true; e.liveTs = now(); e._liveSig = sig; e.liveSource = "statusline";
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
- let prof = {}; try { prof = JSON.parse(readFileSync(join(homedir(), ".agent-bus", "profile.json"), "utf8")).providers || {}; } catch {}
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 = (state.balances?.entries || []).filter(e => detectedCli.has(e.provider) || profByCanon[canonP(e.provider)]).map(e => {
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: state.balances?.ts || 0, by: state.balances?.by || "", thresholds: low,
445
- entries: [...entries, ...subs], lowCount: entries.filter(e => e.low).length, stale: (now() - (state.balances?.ts || 0)) > 6 * 3600e3 });
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 : {},
@@ -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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.104",
3
+ "version": "0.18.106",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"