trantor 0.18.103 → 0.18.105

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.103",
3
+ "version": "0.18.105",
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/crew/core.mjs CHANGED
@@ -4,6 +4,9 @@ import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { STATE_FLAGS } from "../../lib/state/flags.mjs";
6
6
 
7
+ // the card limits the runner reads (#11559 budget, #11855 context cap), tunable at `trantor up`
8
+ export const RUNNER_LIMIT_FLAGS = ["TRANTOR_CARD_TOKEN_BUDGET", "TRANTOR_CARD_CONTEXT_CAP", "TRANTOR_CARD_CAP_MIN_STEPS", "TRANTOR_CARD_CAP_MAX_HANDOFFS"];
9
+
7
10
  export const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
8
11
 
9
12
  export function commandExists(name, env = process.env) {
@@ -149,7 +152,7 @@ export function gridColumns(size) {
149
152
  // in lib/state/flags.mjs beside the resolver: the launcher env stays the forwarder (the runner boots
150
153
  // before any file is sourced), and #7159 lets the runner fall back to ~/.agent-bus/.env itself, so a
151
154
  // flag documented there finally arms the seat that reads it.
152
- const FORWARDED_ENV = STATE_FLAGS;
155
+ const FORWARDED_ENV = [...STATE_FLAGS, ...RUNNER_LIMIT_FLAGS];
153
156
 
154
157
  export function runnerCommand(ctx, agent, model = "", effort = null, persona = agent) {
155
158
  const forwarded = FORWARDED_ENV
@@ -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";
@@ -250,7 +250,9 @@ const CLI = {
250
250
  // kimi-code (successor to kimi-cli) has no --print (-p alone is non-interactive), REJECTS
251
251
  // --yolo in prompt mode (prompt mode auto-approves tools), and emits session_-prefixed ids.
252
252
  kimi: { first: `kimi{M} -p "$(cat {P})" < /dev/null`,
253
- next: `kimi{M} -r {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session: kimi -r (\S+)/ },
253
+ // -S resumes; kimi-code 0.34 silently ignores -r (its own exit hint still prints -r) and
254
+ // opens a new session, so every kimi turn ran cold with no history
255
+ next: `kimi{M} -S {SID} -p "$(cat {P})" < /dev/null`, mflag: " --model ", sid: /To resume this session:\s*kimi (?:-r|-S|--session) (\S+)/ },
254
256
  // #6154: `run -c` resumes the globally last session on this machine, any project's, and its stored
255
257
  // directory becomes the path root. So every spawn pins --dir and a resume pins -s (ocSid below).
256
258
  deepseek: { first: `opencode run --dir {DIR}{M} "$(cat {P})"`,
@@ -625,6 +627,13 @@ async function notifyAssigners(pairs, text) {
625
627
  async function latestBusEventId() {
626
628
  try { const r = await api("/events?limit=1"); return Number(r?.latest) || 0; } catch { return 0; }
627
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
+ }
628
637
  async function busActivitySince(seq) {
629
638
  try {
630
639
  const r = await api(`/events?by=${encodeURIComponent(SESSION)}&since=${seq}&limit=500`);
@@ -1051,7 +1060,8 @@ exit $turn_exit`;
1051
1060
  if (env.turn) log(`state step: ${describeTurn(env.turn)} · ${costLine(env.cost)}`);
1052
1061
  else log(`\x1b[33mstate step: no TurnResult — ${env.error}\x1b[0m`);
1053
1062
  }
1054
- if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
1063
+ // kimi prints its resume hint on stderr, so a stdout-only match never captured its sid
1064
+ if (cli.sid) { const all = [...`${r.stdout || ""}\n${ownOut}`.matchAll(new RegExp(cli.sid.source, "g"))]; if (all.length) sid = all.at(-1)[1]; }
1055
1065
  // #6154: the opencode family prints no sid on stdout — the id comes from opencode's own DB,
1056
1066
  // keyed by the worktree the session was created in. Fail-open: nothing found leaves sid empty,
1057
1067
  // and the next turn starts fresh rather than resuming whatever other project ran last.
@@ -1146,9 +1156,10 @@ exit $turn_exit`;
1146
1156
  // Judged on hub reads of the card status; a failed read keeps today's label, never no-delivery.
1147
1157
  let cardStatusAtEnd = "", noDelivery = false, blockedVerdict = "";
1148
1158
  if (!cut && !opts.state && realExit === 0 && effExit === 0 && sessionCard > 0 && cardStatusBefore !== null) {
1149
- let endStatus = null;
1159
+ let endStatus = null, endTask = null;
1150
1160
  try {
1151
1161
  const { task } = await api(`/card?project=${encodeURIComponent(PROJ)}&id=${sessionCard}`);
1162
+ endTask = task;
1152
1163
  endStatus = task ? String(task.status || "") : null;
1153
1164
  const freshNote = (task?.log || []).filter(n => n.by === SESSION && n.ts >= t0
1154
1165
  && !cardLogBefore.some(old => old.ts === n.ts && old.by === n.by && old.text === n.text)).at(-1);
@@ -1167,7 +1178,11 @@ exit $turn_exit`;
1167
1178
  // still IS testing/done: a mid-turn bounce to doing (endStatus doing) reopens the contract.
1168
1179
  const alreadyDelivered = ["testing", "done"].includes(cardStatusBefore)
1169
1180
  && ["testing", "done"].includes(endStatus);
1170
- 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
+ }
1171
1186
  }
1172
1187
  }
1173
1188
  lastNoDelivery = noDelivery;
@@ -1689,7 +1704,7 @@ async function resolveWakeCard(messages, { session }) {
1689
1704
  }
1690
1705
  // Respect an active backoff: a new message during an outage joins the batch, it does not
1691
1706
  // reset the clock and hammer a CLI that is already failing.
1692
- 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; }
1693
1708
  await deliverWake();
1694
1709
  log("parked — waiting for the next message");
1695
1710
  }
@@ -1758,7 +1773,7 @@ async function resolveWakeCard(messages, { session }) {
1758
1773
  await releaseDutyNudgeClaims({ ids: skippedNudgeIds, statePath: DUTY_NUDGE_STATE, owner: dutyOwner });
1759
1774
  }
1760
1775
  const claimedIds = new Set(dutyPlan.items.map(item => item.id));
1761
- const wakeForTurn = DUTY_NUDGES
1776
+ let wakeForTurn = DUTY_NUDGES
1762
1777
  ? wake.filter(message => {
1763
1778
  const escalation = dutyEscalations([message])[0];
1764
1779
  return !escalation || claimedIds.has(escalation.id);
@@ -1770,6 +1785,21 @@ async function resolveWakeCard(messages, { session }) {
1770
1785
  log("duty escalation already nudged or reserved by another turn — consumed without a duplicate model wake");
1771
1786
  return;
1772
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
+ }
1773
1803
  const wakeCapped = capWake(wakeForTurn);
1774
1804
  const bcastCapped = capBcast(pendingBcast);
1775
1805
  const wakeText = wakeCapped.text
@@ -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.103",
3
+ "version": "0.18.105",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"