trantor 0.18.29 → 0.18.30

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.
@@ -16,6 +16,11 @@ import { resolveProject, resolveHub, withEnvFiles, hostId } from "../lib/project
16
16
  import { loadOrCreate } from "../lib/identity.mjs";
17
17
  import { signedHeaders } from "../lib/signed-fetch.mjs";
18
18
  import { ensureEnrolled } from "../lib/enroll.mjs";
19
+ import { redactKeys } from "../lib/redact.mjs";
20
+ import {
21
+ AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
22
+ readPromptText, stripPromptEcho,
23
+ } from "../lib/classify-failure.mjs";
19
24
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
20
25
 
21
26
  const AGENT = process.argv[2];
@@ -127,7 +132,7 @@ if (!enrolment.ok && enrolment.reason !== "hub-unreachable") {
127
132
  }
128
133
  process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT: ${e?.stack || e}\x1b[0m`); });
129
134
  process.on("unhandledRejection", (e) => { console.log(`\x1b[31m[runner] UNHANDLED REJECTION: ${e?.stack || e}\x1b[0m`); });
130
- const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${s}`);
135
+ const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${redactKeys(String(s))}`);
131
136
  const LOGDIR = join(homedir(), ".agent-bus", "logs");
132
137
  try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
133
138
  let TURN = 0;
@@ -337,30 +342,19 @@ function loadPending() {
337
342
 
338
343
  // Auth-failure markers in TURN OUTPUT. opencode prints its auth error ("401 Unauthorized" /
339
344
  // "Invalid API key") and STILL exits 0, so a bare 0 from the CLI is not proof the turn ran
340
- // (card #5405). This regex gates the exit-0 path in runTurn; kept tighter than
341
- // classifyFailure's set (no bare /expired/) so a healthy transcript never trips it.
342
- const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
343
-
344
- function classifyFailure(exit, errText, emptyOutput = false) {
345
- // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
346
- if (emptyOutput) return "empty-output";
347
- const t = (errText || "").toLowerCase();
348
- if (exit === 127) return "missing-cli";
349
- // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
350
- // The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
351
- // labelled "exhausted" and the operator was advised to wait out a window that did not exist.
352
- // 401/403/429 deliberately fall through to the auth/exhausted branches below.
353
- if (/unexpected status (404|408|410|5\d\d)|internal server error|bad gateway|service unavailable|gateway time.?out|econnrefused|connection refused|socket hang ?up|network is unreachable/.test(t)) return "backend-error";
354
- // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
355
- // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
356
- if (/quota|insufficient|credit|balance|payment required|402|429|too many requests|rate.?limit|exceeded your|reached your [^.\n]*limit|usage limit|out of (credit|quota)/.test(t)) return "exhausted";
357
- if (/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/.test(t)) return "auth";
358
- return "crashed";
345
+ // (card #5405). The rules live in lib/classify-failure.mjs (#5868) so they are testable against
346
+ // the real specimens; classify() wraps them with the one-line verdict the seat log carries, and
347
+ // runTurn judges only the CLI's OWN output (the prompt echo is replay, not speech — the rules
348
+ // line "…deleting failing tests is forbidden." once classified healthy codex turns as auth).
349
+ function classify(exit) {
350
+ const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
351
+ log(`classified ${reason} because ${matched}`);
352
+ return reason;
359
353
  }
360
354
 
361
355
  async function reportFailure(exit, trigger, undelivered = 0) {
362
356
  consecFails++;
363
- const reason = classifyFailure(exit, lastErrText, lastEmptyOutput);
357
+ const reason = classify(exit);
364
358
  const down = consecFails >= 2;
365
359
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
366
360
  await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
@@ -377,9 +371,10 @@ async function reportFailure(exit, trigger, undelivered = 0) {
377
371
  // The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
378
372
  // crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
379
373
  const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
380
- const text = down
374
+ // #5869: the broadcast quotes failure context; keys never ride the bus.
375
+ const text = redactKeys(down
381
376
  ? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
382
- : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
377
+ : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`);
383
378
  // Announce a CHANGE of state, never the continuation of one. The registered status above already
384
379
  // carries "down: exhausted · N fails" for anyone who looks, which is state and costs nobody a
385
380
  // turn; the broadcast is the event, and an unchanged state is not an event.
@@ -400,6 +395,23 @@ async function reportFailure(exit, trigger, undelivered = 0) {
400
395
  log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
401
396
  }
402
397
 
398
+ // ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
399
+ // The app pulses a seat from its hub peer status. The runner is what actually knows when a
400
+ // turn starts and ends, so it reports the boundaries: `working · <trigger>` the moment a turn
401
+ // begins and `idle` the instant it lands clean. herdr's screen detection cannot see a
402
+ // runner-driven CLI mid-turn (it sets screen_detection_skipped for those panes), which is why
403
+ // seats used to read as idle while genuinely working — the desktop's herdr row is unreliable
404
+ // for runner seats, so it falls back to this hub status. Bounded 5s so a slow hub never delays
405
+ // the very turn it is reporting; one HTTP call per transition, never a poll.
406
+ async function registerStatus(status) {
407
+ const url = HUB + "/register";
408
+ const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
409
+ try {
410
+ const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
411
+ await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
412
+ } catch {}
413
+ }
414
+
403
415
  // ---- telling the ASSIGNER, mechanically ------------------------------------
404
416
  // A seat used to finish its contract and say nothing. Completion lived only in the RULES prompt
405
417
  // ("report on the bus"), so a cheap model that did the work and ended its turn left the
@@ -410,6 +422,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
410
422
  // So: whoever sent the message that woke this seat gets told DIRECTLY what became of it. Direct
411
423
  // messages wake; that is the whole difference. Kept short, like every other bus line.
412
424
  async function notifyAssigners(pairs, text) {
425
+ text = redactKeys(text); // #5869: the "asked" excerpt quotes the wake message — keys stay off the bus
413
426
  const seen = new Set();
414
427
  for (const { from: f, id } of pairs) {
415
428
  // `hub:*` senders are the hub's own pseudo-ids (hub:duty, the overseer), not sessions: nothing
@@ -438,9 +451,13 @@ async function reportHealthy() {
438
451
  }
439
452
 
440
453
  let sid = "";
441
- function runTurn(prompt, isFirst, trigger = "kickoff") {
454
+ async function runTurn(prompt, isFirst, trigger = "kickoff") {
442
455
  TURN++; banner(trigger);
443
456
  const t0 = Date.now();
457
+ // #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
458
+ // the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
459
+ // below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
460
+ await registerStatus(`working · ${trigger}`);
444
461
  const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
445
462
  appendFileSync(pf, "", { flag: "w" }); // truncate
446
463
  appendFileSync(pf, prompt);
@@ -470,7 +487,13 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
470
487
  // `crashed` and nobody knew to swap it. sid seats already fold stdout into the ERRF stream via
471
488
  // `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
472
489
  // substitution) so bash waits for tee to flush before we read the file back.
473
- const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | tee -a ${ERRF}`;
490
+ // #5869: redaction rides IN the pipeline lib/redact.mjs is a tee replacement that echoes
491
+ // stdin verbatim to the live window and appends only REDACTED bytes to ERRF, so a CLI that
492
+ // echoes its environment never parks a provider key in a file every seat can read. The tee
493
+ // topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
494
+ // path still folds stdout in via /dev/stderr → the --tee2 hop below.
495
+ const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
496
+ const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`;
474
497
  // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
475
498
  // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
476
499
  // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
@@ -482,7 +505,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
482
505
  { detached: true, stdio: "ignore" });
483
506
  wd.unref();
484
507
  } catch {}
485
- const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
508
+ const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
486
509
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
487
510
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
488
511
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
@@ -499,18 +522,31 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
499
522
  maxBuffer: 16 * 1024 * 1024,
500
523
  });
501
524
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
502
- try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
525
+ // #5869: scrub AT REST, synchronously, before anything reads the file back. The stderr hop is
526
+ // a process substitution bash does not wait for, so this pass also catches its tail — the
527
+ // auth classifier and the empty-output check below must judge REDACTED text and a settled file.
528
+ try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
529
+ // #5868: classify only what the CLI itself said. The transcript replays the whole turn prompt
530
+ // (rules, lessons, the wake text) — and those lines once classified healthy codex turns as
531
+ // auth ("…is forbidden.") and exhausted ("retries burn quota"). Prompt lines are stripped
532
+ // before anything downstream looks at the text.
533
+ let ownOut = "";
534
+ try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
535
+ lastErrText = ownOut.slice(-4000);
503
536
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
504
537
  const realExit = r.status;
505
538
  // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
506
539
  // and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
507
540
  // green through an auth outage (card #5405). Cross-check the turn output and treat an
508
- // exit-0-with-auth turn as FAILED. Telemetry keeps the REAL exit; the returned code is the
509
- // effective one every call site branches on (kickoff, pulse, deliverWake).
541
+ // exit-0-with-auth turn as FAILED but ONLY when the CLI's own output is short enough to be
542
+ // just the error (#5868): a long output is a real answer, and a warning inside it must not
543
+ // fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
544
+ // call site branches on (kickoff, pulse, deliverWake).
510
545
  let effExit = realExit;
511
- if (realExit === 0 && AUTH_MARKER_RE.test(lastErrText)) {
546
+ if (realExit === 0 && looksLikeAuthDeath(ownOut)) {
512
547
  effExit = 1;
513
- log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
548
+ const hit = AUTH_MARKER_RE.exec(ownOut)[0];
549
+ log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${hit}")\x1b[0m`);
514
550
  }
515
551
  // #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
516
552
  // capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
@@ -519,6 +555,8 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
519
555
  // real CLI prints something on success (drill C pins that), so silence is the trap, not a
520
556
  // quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
521
557
  // never fired — the tee topology is the load-bearing fact; keep this comment with it.)
558
+ // The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
559
+ // does no work has still produced nothing of its own.
522
560
  if (realExit === 0 && effExit === 0 && !lastErrText.trim()) {
523
561
  effExit = 1;
524
562
  lastEmptyOutput = true;
@@ -527,6 +565,9 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
527
565
  telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput });
528
566
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
529
567
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
568
+ // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
569
+ // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
570
+ if (realExit === 0 && effExit === 0) await registerStatus("idle");
530
571
  return effExit;
531
572
  }
532
573
 
@@ -592,7 +633,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
592
633
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
593
634
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
594
635
 
595
- const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
636
+ const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
596
637
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
597
638
  let lastTurnAt = Date.now();
598
639
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
@@ -602,7 +643,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
602
643
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
603
644
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
604
645
  if (PULSE_MS && Date.now() - lastTurnAt >= PULSE_MS) {
605
- const ecp = runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
646
+ const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
606
647
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
607
648
  lastTurnAt = Date.now();
608
649
  log("parked — waiting for the next message or pulse");
@@ -694,7 +735,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
694
735
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
695
736
  rulesText: RULES, lessons,
696
737
  });
697
- const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
738
+ const ec = await runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
698
739
  const secs = Math.round((Date.now() - tStart) / 1000);
699
740
  if (ec) {
700
741
  deliveryFails++;
@@ -704,7 +745,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
704
745
  await reportFailure(ec, "message", pendingWake.length);
705
746
  // The room hears the broadcast above; the one who is actually blocked hears it directly.
706
747
  await notifyAssigners(assigners,
707
- `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classifyFailure(ec, lastErrText)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
748
+ `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classify(ec)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
708
749
  log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
709
750
  } else {
710
751
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
package/bin/new.mjs CHANGED
@@ -18,7 +18,8 @@ import { homedir } from "node:os";
18
18
  import { dirname, isAbsolute, join, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { ensureEnrolled, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
21
- import { resolveHub } from "../lib/project.mjs";
21
+ import { setAutonomy } from "../lib/autonomy.mjs";
22
+ import { resolveHub, setProjectHub } from "../lib/project.mjs";
22
23
 
23
24
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
24
25
 
@@ -104,8 +105,16 @@ if (existsSync(claude)) {
104
105
  const hook = spawnSync(process.execPath, [join(ROOT, "bin", "init-hooks.mjs")], { cwd: dir, encoding: "utf8" });
105
106
  if (hook.status !== 0) die(`hook install failed: ${(hook.stderr || "").trim()}`);
106
107
 
107
- // ── the hub: brief + first card, signed like every other client ─────────────────────────────────
108
+ // A new project is a new trust boundary. Pin its harness dial even when the machine-wide default
109
+ // is bypass, so opening it can never inherit another project's permission choice.
110
+ setAutonomy(name, { harness: "prompt" });
111
+
112
+ // ── the hub: pin + brief + first card, signed like every other client ──────────────────────────
108
113
  const hub = resolveHub(name);
114
+ // Pin the new project to the hub it posts to (#5862 residual): without the pin, the first
115
+ // session in the dir falls back to the global default and wears the "not pinned to a hub"
116
+ // warning even though genesis chose this hub deliberately. Same persistence as `trantor hub set`.
117
+ setProjectHub(name, hub);
109
118
  const session = process.env.RELAY_SESSION || `genesis:${name}`;
110
119
  const identity = loadIdentity(session);
111
120
  let card = null;
package/bin/profile.mjs CHANGED
@@ -43,7 +43,12 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
43
43
  if (cmd === "set") {
44
44
  for (const a of args) {
45
45
  const [prov, plan] = a.split("=");
46
- if (!prov || !plan) { console.error(`bad arg '${a}' use provider=plan`); process.exit(1); }
46
+ // A flag in the provider position (`set --help=api`) is a usage mistake, not a provider
47
+ // die before the write, or profile.json grows a '--help' entry (#5998). 'help' likewise.
48
+ if (!prov || !plan || prov.startsWith("--") || prov === "help") {
49
+ console.error(`bad arg '${a}' — use provider=plan`);
50
+ process.exit(1);
51
+ }
47
52
  prof.providers[prov.toLowerCase()] = { plan: plan.toLowerCase(), tier: TIER(plan) };
48
53
  }
49
54
  prof.updated = new Date().toISOString().slice(0, 10);
package/bin/provider.mjs CHANGED
@@ -70,7 +70,14 @@ function listSeats() {
70
70
  }
71
71
 
72
72
  function addProvider(name, opts) {
73
- if (!name) { console.error("usage: trantor provider add <name> [--key sk-…] [--plan api] [--label <bus-name>] [--base-url <url> [--models m1,m2]]"); process.exit(1); }
73
+ // A flag left in the name position (`provider add --help`) is a usage question, not a provider
74
+ // name (#5998): the old path minted a '--help' provider into profile.json and a __HELP_API_KEY
75
+ // line into .env, then announced "Seat ready." The literal 'help' is guarded too — it is never
76
+ // a provider name. Both die HERE, before .env, profile.json or opencode.json are touched.
77
+ if (!name || name.startsWith("--") || name === "help") {
78
+ console.error("usage: trantor provider add <name> [--key sk-…] [--plan api] [--label <bus-name>] [--base-url <url> [--models m1,m2]]");
79
+ process.exit(1);
80
+ }
74
81
  const provider = name.toLowerCase();
75
82
  const label = (opts.label || provider).toLowerCase().replace(/[^a-z0-9-]/g, "-");
76
83
  const plan = (opts.plan || "api").toLowerCase();
@@ -117,7 +124,11 @@ function addProvider(name, opts) {
117
124
  }
118
125
 
119
126
  function removeProvider(name) {
120
- if (!name) { console.error("usage: trantor provider remove <name>"); process.exit(1); }
127
+ // Same guard as add (#5998): a flag-like name is a usage question, not a provider.
128
+ if (!name || name.startsWith("--") || name === "help") {
129
+ console.error("usage: trantor provider remove <name>");
130
+ process.exit(1);
131
+ }
121
132
  const FILE = join(H, ".agent-bus", "profile.json");
122
133
  const prof = read(FILE, { providers: {} });
123
134
  if (prof.providers && prof.providers[name.toLowerCase()]) {
@@ -0,0 +1,66 @@
1
+ // Failure classification for crew seats (#5868) — split out of crew-runner.mjs so the rules are
2
+ // unit-testable against the REAL specimens that misfired.
3
+ //
4
+ // EVIDENCE (err-codex-trantor.txt + logs/codex-trantor.jsonl, 2026-09-02): the codex seat sat
5
+ // "DOWN — 44 consecutive failures (exhausted)" while every telemetry row read exit:0 and the
6
+ // transcript showed real work ("tokens used 4,387", Stop hooks, a delivered answer). Both labels
7
+ // were echoes of the seat's own PROMPT: the rules line "…deleting failing tests is forbidden."
8
+ // matched AUTH_MARKER_RE's bare "forbidden", and the codex lesson "retries burn quota" matched
9
+ // the exhausted rule. Three fixes, one per failure mode:
10
+ // · stripPromptEcho — a prompt line reappearing in the err stream is the CLI REPLAYING what it
11
+ // was told, not the CLI speaking; classification sees only the CLI's own output.
12
+ // · looksLikeAuthDeath — the #5405 exit-0 escalation fires only on a SHORT error-only output
13
+ // (the opencode "401 Unauthorized" specimen is a couple of lines); a long output is a real
14
+ // answer, and a warning inside it must not fail the turn.
15
+ // · classifyFailure — "exhausted" demands an explicit quota/rate-limit message; the broad bare
16
+ // words ("credit", "balance", bare "insufficient") labelled ordinary prose on a dead turn and
17
+ // sent the operator to wait out a window that did not exist.
18
+ import { readFileSync } from "node:fs";
19
+
20
+ export const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
21
+
22
+ /** Prompt lines (≥40 chars — a short line carries no signature) echoed back verbatim by the CLI
23
+ * are replay, not speech. Returns the err stream with those lines removed. */
24
+ export function stripPromptEcho(errText, promptText) {
25
+ const text = String(errText || "");
26
+ if (!promptText) return text;
27
+ const echo = new Set(String(promptText).split("\n").filter(l => l.length >= 40));
28
+ return text.split("\n").filter(l => !(l.length >= 40 && echo.has(l))).join("\n");
29
+ }
30
+
31
+ /** A real answer is long; an auth death is a couple of lines. The opencode specimen (#5405)
32
+ * printed its whole failure in under a hundred characters and produced nothing else. */
33
+ export const OWN_OUTPUT_ANSWER_MIN = 400;
34
+
35
+ /** The #5405 rule, refined by #5868: exit 0 + an auth-shaped marker means FAILED only when the
36
+ * CLI's own output is short enough to be JUST the error. */
37
+ export function looksLikeAuthDeath(ownText) {
38
+ return String(ownText || "").length < OWN_OUTPUT_ANSWER_MIN && AUTH_MARKER_RE.test(ownText);
39
+ }
40
+
41
+ /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
42
+ * runner logs, so the next misclassification is diagnosable from the seat log alone. */
43
+ export function classifyFailure(exit, errText, emptyOutput = false) {
44
+ // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
45
+ if (emptyOutput) return { reason: "empty-output", matched: "exit 0 with no output on either stream" };
46
+ const t = String(errText || "").toLowerCase();
47
+ if (exit === 127) return { reason: "missing-cli", matched: "exit 127 — command not found" };
48
+ // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
49
+ // The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
50
+ // labelled "exhausted" and the operator was advised to wait out a window that did not exist.
51
+ // 401/403/429 deliberately fall through to the auth/exhausted branches below.
52
+ let m = t.match(/unexpected status (?:404|408|410|5\d\d)|internal server error|bad gateway|service unavailable|gateway time.?out|econnrefused|connection refused|socket hang ?up|network is unreachable/);
53
+ if (m) return { reason: "backend-error", matched: m[0] };
54
+ // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
55
+ // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
56
+ m = t.match(/quota|payment required|402|429|too many requests|rate.?limit|usage limit|exceeded your|reached your [^.\n]*limit|insufficient (?:credits?|funds)|out of (?:credits?|quota)/);
57
+ if (m) return { reason: "exhausted", matched: m[0] };
58
+ m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
59
+ if (m) return { reason: "auth", matched: m[0] };
60
+ return { reason: "crashed", matched: `exit ${exit} with no known failure pattern` };
61
+ }
62
+
63
+ /** Read the prompt file if it exists (the turn prompt the CLI may echo into its transcript). */
64
+ export function readPromptText(pf) {
65
+ try { return readFileSync(pf, "utf8"); } catch { return ""; }
66
+ }
package/lib/redact.mjs ADDED
@@ -0,0 +1,62 @@
1
+ // #5869 — key-material redaction for everything a seat runner writes at rest.
2
+ //
3
+ // A CLI that echoes its environment or dumps a config puts live provider keys into the seat's
4
+ // err log (err-<agent>-<project>.txt), and any seat on the machine can read ~/.agent-bus. So the
5
+ // runner scrubs known key shapes to `<redacted:NAME>` before the bytes land:
6
+ //
7
+ // sk-… / sk-sp-… / sk-ws-… → <redacted:SK> (every OpenAI-style prefix is sk-)
8
+ // AIza… → <redacted:AIZA> (Google)
9
+ // xai-… → <redacted:XAI> (xAI)
10
+ // ghp_… → <redacted:GHP> (GitHub PAT)
11
+ // <VAR>_KEY=… / <VAR>_TOKEN=… with a 32+-char hex/base64 value
12
+ // → <VAR>=<redacted:VAR> (the variable NAME is not secret)
13
+ // Authorization: Bearer … → Authorization: Bearer <redacted:BEARER>
14
+ //
15
+ // Ordinary lines are byte-identical: every rule anchors on a key prefix or a KEY=/TOKEN=/Bearer
16
+ // position, never on "looks long". The function is idempotent (already-redacted text passes
17
+ // through unchanged), so callers can scrub in the write path AND again on read-back.
18
+ const RULES = [
19
+ // VAR=value first, so the surviving name is the env var and short values after KEY=/TOKEN=
20
+ // still fall through to the bare-prefix rules below.
21
+ { re: /\b([A-Za-z0-9_.-]*(?:KEY|TOKEN))=(["']?)[A-Za-z0-9+/_=-]{32,}\2/g, sub: (m, name, q) => `${name}=${q}<redacted:${name}>` },
22
+ { re: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{7,}/g, sub: () => "<redacted:SK>" },
23
+ { re: /\bAIza[0-9A-Za-z_-]{30,}/g, sub: () => "<redacted:AIZA>" },
24
+ { re: /\bxai-[A-Za-z0-9][A-Za-z0-9_-]{9,}/g, sub: () => "<redacted:XAI>" },
25
+ { re: /\bghp_[A-Za-z0-9]{20,}/g, sub: () => "<redacted:GHP>" },
26
+ { re: /(Authorization:\s*Bearer\s+)[A-Za-z0-9._+/=-]{16,}/gi, sub: (m, p) => `${p}<redacted:BEARER>` },
27
+ ];
28
+
29
+ export function redactKeys(text) {
30
+ if (text == null) return text;
31
+ let out = String(text);
32
+ for (const { re, sub } of RULES) out = out.replace(re, sub);
33
+ return out;
34
+ }
35
+
36
+ // The runner's tee replacement. Modes:
37
+ // --tee <file> stdin → STDOUT verbatim (the live window), redacted → <file> (append)
38
+ // --tee2 <file> stdin → STDERR verbatim (the live window), redacted → <file> (append)
39
+ // The redacted append is LINE-BUFFERED: chunks can split a match mid-token (a 100-char bearer
40
+ // JWT straddling a 64KB read boundary would otherwise leak its tail), but keys never span LINES.
41
+ // Complete lines go out redacted; a partial trailing line waits for its newline. Every byte is
42
+ // appended exactly once, in order, so the file stays verbatim apart from redactions.
43
+ if (process.argv[1] && process.argv[1].endsWith("redact.mjs") && (process.argv[2] === "--tee" || process.argv[2] === "--tee2")) {
44
+ const { appendFileSync } = await import("node:fs");
45
+ const target = process.argv[3];
46
+ const passthrough = process.argv[2] === "--tee"
47
+ ? (c) => process.stdout.write(c)
48
+ : (c) => process.stderr.write(c);
49
+ let carry = "";
50
+ process.stdin.on("data", (chunk) => {
51
+ passthrough(chunk);
52
+ const lines = (carry + chunk.toString("utf8")).split("\n");
53
+ carry = lines.pop(); // the partial trailing line — or "" when the chunk ended on a newline
54
+ if (lines.length) {
55
+ try { appendFileSync(target, redactKeys(lines.join("\n")) + "\n"); } catch {}
56
+ }
57
+ });
58
+ process.stdin.on("end", () => {
59
+ if (carry) { try { appendFileSync(target, redactKeys(carry)); } catch {} }
60
+ });
61
+ process.stdin.resume();
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trantor",
3
- "version": "0.18.29",
3
+ "version": "0.18.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "trantor": "bin/cli.mjs"
@@ -11,7 +11,7 @@
11
11
  "zod": "^4.4.3"
12
12
  },
13
13
  "scripts": {
14
- "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
14
+ "test": "node bin/slop-gate.mjs --surface desktop/src && node test.mjs && node test-scenarios.mjs && node test-failure.mjs && node test-redelivery.mjs && node test-crew-completion.mjs && node test-contracts.mjs && node test-autonomy.mjs && node test-integrate.mjs && node test-handoff.mjs && node test-handoff-summarizer.mjs && node test-baton-turn-boundary.mjs && node test-agents.mjs && node test-update.mjs && node test-handoff-guard.mjs && node test-baton-latest.mjs && node test-balances.mjs && node test-usage-live.mjs && node test-subagent-cost.mjs && node test-inbox.mjs && node test-inflight.mjs && node test-notify.mjs && node test-focus.mjs && node test-cardlog.mjs && node test-checklist.mjs && node test-relay-note.mjs && node test-reaper.mjs && node test-events.mjs && node test-proposals.mjs && node test-scrub.mjs && node test-store-delta.mjs && node test-message-re.mjs && node test-claims.mjs && node test-adopt.mjs && node test-summarize.mjs && node test-identity-core.mjs && node test-identity.mjs && node test-identity-instances.mjs && node test-duty.mjs && node test-duty-dark.mjs && node test-duty-seat.mjs && node test-discovery.mjs && node test-doctor.mjs && node test-crew-env.mjs && node test-desktop-transport.mjs && node test-crew-worktree.mjs && node test-crew-redact.mjs && node test-crew-classify.mjs && bash test-crew-herdr.sh && npm --prefix desktop run test --silent && node test-splitbrain.mjs && node test-overseer.mjs && node test-overseer-lib.mjs && node test-overseer-warn.mjs && node test-provider-keys.mjs && node test-provider-cli.mjs && node test-inbox-delivery.mjs && node test-identity-drift.mjs && node test-inbox-staleness.mjs && node test-seats.mjs && node test-seat-identity.mjs && node test-hub-routing.mjs && node test-hook-routing.mjs && node test-relay-wait.mjs && node test-dsh-seat.mjs && node test-kimi-bridge.mjs && node test-kimi-events.mjs && python3 engine/test-routing.py && node test-new.mjs && node test-reconcile.mjs && node test-resources.mjs && node test-patrol.mjs && node test-bridge.mjs && bash test-crew.sh"
15
15
  },
16
16
  "description": "The hub-world for AI agent crews — orchestrate Claude Code, Codex, GLM, Kimi, DeepSeek & any OpenRouter model as live crews with a plan-aware Advisor, a Kanban/flow command center, a testing gate, and an economics brain (Scrooge).",
17
17
  "files": [