trantor 0.18.75 → 0.18.76

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.75",
3
+ "version": "0.18.76",
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": {
@@ -17,6 +17,7 @@ import {
17
17
  AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
18
18
  verdictFor, substantiveOutput, stallVerdict, cutSignalFor,
19
19
  BILLING_RE, looksLikeBillingDeath, usageSaysNoWork, zeroUsageVerdict,
20
+ permissionRejection, looksLikePermissionDeath,
20
21
  readPromptText, stripPromptEcho,
21
22
  } from "../lib/classify-failure.mjs";
22
23
  import { capWake, capBcast, pickLessons, composePrompt, contractBase, baseLine } from "./crew-payload.mjs";
@@ -401,6 +402,8 @@ async function reportFailure(exit, trigger, undelivered = 0, reasonOverride = ""
401
402
  : reason === "auth" ? " — check credentials"
402
403
  : reason === "backend-error" ? " — provider backend error (NOT quota): retry, or `trantor swap` to another provider"
403
404
  : reason === "missing-cli" ? " — CLI not on PATH"
405
+ // #9919: the sandbox refused a path outside the worktree — the fix is where the scratch goes.
406
+ : reason === "permission-rejected" ? " — the sandbox refuses paths outside the worktree: keep scratch (logs, baselines, temp files) under <worktree>/.agent-bus-out"
404
407
  // #5481: name the suspected trap, not just the symptom — the dial lives in the provider's
405
408
  // opencode model config (limit.output), not in the runner.
406
409
  : reason === "empty-output" ? (AGENT === "inception"
@@ -954,6 +957,14 @@ exit $turn_exit`;
954
957
  effExit = 1;
955
958
  log(`\x1b[31mexit 0 but the turn output IS a provider billing rejection — treating as FAILED (exhausted, "${BILLING_RE.exec(ownOut)[0]}")\x1b[0m`);
956
959
  }
960
+ // #9919: the same trap in the sandbox family — the seat reached outside its worktree, opencode
961
+ // auto-rejected the permission and killed the turn at exit 0, and glm:ibkr sat 8h on an
962
+ // uncommitted #9859 under a "success" row. Judged on the output TAIL: the rejection prints where
963
+ // the turn ended, so a turn that only quotes the phrase mid-stream stays success.
964
+ if (realExit === 0 && effExit === 0 && looksLikePermissionDeath(ownOut, newCommit)) {
965
+ effExit = 1;
966
+ log(`\x1b[31mexit 0 but the turn output IS a sandbox permission rejection — treating as FAILED (permission-rejected, "${permissionRejection(ownOut)}")\x1b[0m`);
967
+ }
957
968
  // #5481: exit 0 with an empty ERRF (the TOTAL capture, both streams) is the null-completion trap,
958
969
  // judged on echo-stripped text (#5868). #6969: on a state step ERRF is stderr only, so silence is normal.
959
970
  if (realExit === 0 && effExit === 0 && !lastErrText.trim() && !lastEnvelope.trim()) {
@@ -0,0 +1,58 @@
1
+ // relay_inbox paging (#9822): an overnight backlog once returned 197K chars in ONE tool result.
2
+ // formatInboxPage bounds every read: newest-first, ≤ INBOX_PAGE_SIZE messages, capped near
3
+ // INBOX_PAGE_CHARS, repeated context notices collapsed ×N, footer carrying the before-cursor.
4
+ // Contracts (wake) and questions are never collapsed.
5
+ export const INBOX_PAGE_SIZE = 40;
6
+ export const INBOX_PAGE_CHARS = 20000;
7
+
8
+ // Same line shape mcp.mjs has always emitted, so inbox and wait read identically.
9
+ export const fmtMessage = (m) => `#${m.id} [${m.from} -> ${m.to}] ${new Date(m.ts).toLocaleTimeString()}: ${m.text}`;
10
+
11
+ // Stripping digit runs folds "12:04:11" and "3 cards" into one shape, so the same notice at
12
+ // different times and counts is one repeat. A kind (ask), a wake (contract), or a question mark
13
+ // opts the message out of collapsing entirely.
14
+ const collapsible = (m) => !m.kind && m.wake === false && !String(m.text || "").includes("?");
15
+ const collapseKey = (m) => `${m.from}|${String(m.text || "").replace(/\d+/g, "#")}`;
16
+
17
+ // Newest-first entries {m, n}: n counts how many messages the representative stands for.
18
+ export function collapseMessages(messages) {
19
+ const entries = [];
20
+ const seen = new Map();
21
+ for (let i = (messages || []).length - 1; i >= 0; i--) {
22
+ const m = messages[i];
23
+ if (!collapsible(m)) { entries.push({ m, n: 1 }); continue; }
24
+ const key = collapseKey(m);
25
+ const at = seen.get(key);
26
+ if (at === undefined) { seen.set(key, entries.length); entries.push({ m, n: 1 }); }
27
+ else entries[at].n++;
28
+ }
29
+ return entries;
30
+ }
31
+
32
+ // messages ascending by id (the hub's order) -> bounded newest-first text. The page is the newest
33
+ // `limit` messages that fit the char cap — a CONTIGUOUS slice, so before-cursor paging has no gaps
34
+ // and no double-shown repeats; collapsing is display-only inside the window. Returns
35
+ // { text, shown, hidden, before } — before pages further back via relay_inbox({before}).
36
+ export function formatInboxPage(messages, { limit = INBOX_PAGE_SIZE, capChars = INBOX_PAGE_CHARS } = {}) {
37
+ const list = messages || [];
38
+ const window = [];
39
+ let chars = 0;
40
+ for (let i = list.length - 1; i >= 0 && window.length < limit; i--) {
41
+ const lineLen = fmtMessage(list[i]).length + 8; // slack for a ×N suffix
42
+ // The first line always lands, however long — an empty page would read as "no messages".
43
+ if (window.length && chars + lineLen + 1 > capChars) break;
44
+ window.push(list[i]);
45
+ chars += lineLen + 1;
46
+ }
47
+ const lines = collapseMessages(window.slice().reverse()).map(e => fmtMessage(e.m) + (e.n > 1 ? ` (×${e.n})` : ""));
48
+ const hidden = list.length - window.length;
49
+ let text = lines.join("\n");
50
+ let before = 0;
51
+ const oldestShownId = Number(window[window.length - 1]?.id) || 0;
52
+ if (hidden > 0 && oldestShownId) {
53
+ before = oldestShownId;
54
+ const footer = `… ${hidden} older not shown — relay_inbox({before:${before}}) for more`;
55
+ text = text ? `${text}\n${footer}` : footer;
56
+ }
57
+ return { text, shown: window.length, hidden, before };
58
+ }
@@ -91,6 +91,10 @@ export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn =
91
91
  // real work (checked by the runner via git), and it stands above the captured stream.
92
92
  const bl = looksLikeBillingDeath(ownText);
93
93
  if (bl) return `classified exhausted because ${BILLING_RE.exec(String(ownText || ""))[0]} in the CLI's own output on a clean exit`;
94
+ // #9919: the sandbox family's exit-0 trap — the rejection prints at the TAIL and the runner
95
+ // lifts effExit below; a caller that reaches here un-lifted still names the death.
96
+ const pm = permissionRejection(String(ownText || ""));
97
+ if (pm) return `classified permission-rejected because ${pm} on a clean exit`;
94
98
  return emptyTurn
95
99
  ? "classified empty-turn because exit 0 with no worktree change, no substantive output, no bus activity"
96
100
  : "classified success because exit 0 with CLI output";
@@ -105,6 +109,10 @@ export function verdictFor(realExit, effExit, emptyOutput, ownText, emptyTurn =
105
109
  // credit error must read exhausted here, never fall through to the auth label.
106
110
  const bl = String(ownText || "").match(BILLING_RE);
107
111
  if (bl) return `classified exhausted because ${bl[0]} in the CLI's own output on a clean exit`;
112
+ // #9919: the lifted permission death lands HERE (the runner raised effExit to 1) — it must
113
+ // name permission-rejected plus the refused path, never the auth label below it.
114
+ const pm = permissionRejection(String(ownText || ""));
115
+ if (pm) return `classified permission-rejected because ${pm} on a clean exit`;
108
116
  const m = AUTH_MARKER_RE.exec(String(ownText || ""));
109
117
  return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
110
118
  }
@@ -148,6 +156,37 @@ export function looksLikeBillingDeath(ownText, realWork = false) {
148
156
  return t.length < OWN_OUTPUT_ANSWER_MIN && BILLING_RE.test(t);
149
157
  }
150
158
 
159
+ // ---- the sandbox permission rejection on a clean exit (#9919) -----------------------------------
160
+ // The seat reached outside its worktree and opencode auto-rejected the permission, killed the
161
+ // turn — and exited 0, so the runner read "success" over a turn that died mid-step (#9919). Both
162
+ // deaths print at the TAIL: the turn ends right there, after real work above it.
163
+ export const PERMISSION_RE = /rejected permission to use this specific tool call|permission requested: .*?auto-rejecting/i;
164
+
165
+ /** The judged zone is the TAIL, not the whole capture: a long turn that merely QUOTES the phrase
166
+ * (a contract, a commit message, this file) has it mid-stream, never at the end where a death
167
+ * prints — #9812's whole-text answer floor cannot apply, because a dying turn's capture is long. */
168
+ export const PERMISSION_TAIL = 600;
169
+
170
+ const PERMISSION_PATH_RE = /permission requested:\s*\S+\s*\(([^)]*)\);?\s*auto-rejecting/i;
171
+
172
+ /** The tail's rejection evidence: names the refused path when the auto-reject banner printed one
173
+ * ("/tmp/w0-base/*"), else the matched phrase. Null when the tail carries no rejection. */
174
+ export function permissionRejection(ownText) {
175
+ const tail = String(ownText || "").slice(-PERMISSION_TAIL);
176
+ const m = PERMISSION_RE.exec(tail);
177
+ if (!m) return null;
178
+ const path = (PERMISSION_PATH_RE.exec(tail) || [])[1] || "";
179
+ return path ? `the sandbox auto-rejected permission for ${path}` : m[0];
180
+ }
181
+
182
+ /** #9919: a sandbox permission rejection on a clean exit is the turn dying mid-step. Same doctrine
183
+ * as looksLikeBillingDeath (a shipped commit is a live turn), but judged on the TAIL: the capture
184
+ * above the rejection is real work, not the answer floor's "just the error" shape. */
185
+ export function looksLikePermissionDeath(ownText, realWork = false) {
186
+ if (realWork) return false;
187
+ return permissionRejection(ownText) !== null;
188
+ }
189
+
151
190
  /** #9724/#8234: a usage record that RESOLVED (the #8234 lookup found the session's record) to all
152
191
  * zeros means no model work happened. Null usage — the CLI prints none and keeps none — stays
153
192
  * unknown and is never failure. */
@@ -190,6 +229,10 @@ export function classifyFailure(exit, errText, emptyOutput = false, emptyTurn =
190
229
  // phrase, and either pattern reading first would relabel the same provider-side rejection.
191
230
  const pr = providerRejected(String(errText || ""));
192
231
  if (pr) return pr;
232
+ // #9919: a permission death with a non-zero exit is still the sandbox refusing the turn — named
233
+ // on the TAIL evidence so a crash that merely quoted the phrase mid-stream stays crashed.
234
+ const perm = permissionRejection(String(errText || ""));
235
+ if (perm) return { reason: "permission-rejected", matched: perm };
193
236
  // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
194
237
  // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
195
238
  // #9724: the OpenRouter billing phrasing — "requires more credits … can only afford 16" — and
package/mcp.mjs CHANGED
@@ -15,6 +15,7 @@ import { loadSeatRecord } from "./lib/seat-record.mjs";
15
15
  import { resolveProject, hostId, resolveHubInfo, nonSeatReason, handoffDir, orchWriterSid } from "./lib/project.mjs";
16
16
  import { signedPost, signedGet } from "./hooks/lib/api.mjs";
17
17
  import { anchorCursor } from "./hooks/lib/inbox-ledger.mjs";
18
+ import { formatInboxPage, fmtMessage } from "./hooks/lib/inbox-page.mjs";
18
19
  import { assertNoSecrets } from "./lib/scrub.mjs";
19
20
  import { isMessageCardTitle, OPEN_CARD_STATUSES } from "./lib/turn-policy.mjs";
20
21
  import { markDoing, hollowVerdict, blastRadius, blastLine } from "./hooks/lib/hollow-move.mjs";
@@ -121,7 +122,7 @@ async function api(method, path, payload, { timeoutMs } = {}) {
121
122
  }
122
123
  return r.json;
123
124
  }
124
- const fmt = (m) => `#${m.id} [${m.from} -> ${m.to}] ${new Date(m.ts).toLocaleTimeString()}: ${m.text}`;
125
+ const fmt = fmtMessage;
125
126
 
126
127
  const server = new McpServer({ name: "trantor", version: "0.1.0" });
127
128
 
@@ -543,12 +544,21 @@ server.tool("relay_handoff", "Write a rich handoff for THIS session so a fresh s
543
544
  return { content: [{ type: "text", text: `handoff saved (${rec.id}). A fresh session in ${name} will load it on start. Tell the user to open a new terminal here.` }] };
544
545
  });
545
546
 
546
- server.tool("relay_inbox", "Read NEW messages addressed to this session since the last read (non-blocking).", {}, async () => {
547
- await seedCursor();
548
- const { messages, cursor: c } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=${cursor}`);
549
- cursor = c;
550
- return { content: [{ type: "text", text: messages.length ? messages.map(fmt).join("\n") : "(no new messages)" }] };
551
- });
547
+ server.tool("relay_inbox", "Read NEW messages addressed to this session since the last read (non-blocking). The result is a bounded page — newest first, repeats of the same notice collapsed with ×N, capped near 20K chars. When older messages exist it ends with a before-cursor: pass it back as relay_inbox({before}) to page into older history.",
548
+ { before: z.number().optional().describe("page BACK: return only messages with id < before (the cursor from the 'older not shown' footer). A peek — does not advance the new-message cursor.") },
549
+ async ({ before }) => {
550
+ if (before) {
551
+ // History paging peeks from 0 and filters: it must not advance the delivery ledger.
552
+ const { messages } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=0&peek=1`);
553
+ const page = formatInboxPage((messages || []).filter(m => Number(m.id) < before));
554
+ return { content: [{ type: "text", text: page.text || `(nothing older than #${before})` }] };
555
+ }
556
+ await seedCursor();
557
+ const { messages, cursor: c } = await api("GET", `/inbox?session=${encodeURIComponent(SESSION)}&since=${cursor}`);
558
+ cursor = c;
559
+ const page = formatInboxPage(messages || []);
560
+ return { content: [{ type: "text", text: page.text || "(no new messages)" }] };
561
+ });
552
562
 
553
563
  server.tool("relay_wait", "Block up to `timeout` seconds waiting for the next message to this session (long-poll). Returns the instant a message arrives. When idle, park by calling this repeatedly. IMPORTANT: MCP clients cap or background long tool calls — OpenCode ~60s; Codex ~120s; Claude Code auto-backgrounds ANY MCP call at 120s (CC 2.1.212+, tunable via CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS), which breaks inline parking. Use timeout 50 and loop for cross-client safety.",
554
564
  { timeout: z.number().optional().describe("seconds to wait, default 25. Effective wait is capped at 110s so the call always returns inline (a longer wait would be auto-backgrounded by Claude Code's 120s MCP floor or dropped by other clients). Use 50 and call repeatedly to park while idle.") },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.75",
3
+ "version": "0.18.76",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"