trantor 0.18.29 → 0.18.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.mjs CHANGED
@@ -209,7 +209,7 @@ switch (cmd) {
209
209
  trantor gates verification gates: "must verify before shipping" claims that survive handoffs — [--all] [--json]
210
210
  trantor backfill card past GIT work onto the board (solo commits that were never carded) — [--since "14 days ago"] [--dry-run]
211
211
  trantor init-hooks install a git post-commit hook so EVERY commit auto-cards on the board (reliable solo-work backstop) — [--uninstall]
212
- trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json] — makes the dir, git main, CLAUDE.md from the brief, hooks, hub brief + first card (never spawns a session)
212
+ trantor new project genesis: new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — creates <parent>/<name>, git main, CLAUDE.md from the brief, hooks, hub brief + first card (never spawns a session)
213
213
  trantor balances how much credit is left on each CONFIGURED provider (from your profile) — refill before you stall — [--json]
214
214
  trantor recost recompute sub-agent notional cost from on-disk transcripts + reseed the board (repair after upgrade) — [--dry-run]
215
215
  trantor handoff finish this session NOW: write a handoff, open a fresh session that takes over, and close this one (manual baton)
@@ -16,6 +16,12 @@ 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
+ verdictFor,
23
+ readPromptText, stripPromptEcho,
24
+ } from "../lib/classify-failure.mjs";
19
25
  import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
20
26
 
21
27
  const AGENT = process.argv[2];
@@ -127,7 +133,7 @@ if (!enrolment.ok && enrolment.reason !== "hub-unreachable") {
127
133
  }
128
134
  process.on("uncaughtException", (e) => { console.log(`\x1b[31m[runner] UNCAUGHT: ${e?.stack || e}\x1b[0m`); });
129
135
  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}`);
136
+ const log = (s) => console.log(`\x1b[38;5;43m[runner]\x1b[0m ${redactKeys(String(s))}`);
131
137
  const LOGDIR = join(homedir(), ".agent-bus", "logs");
132
138
  try { mkdirSync(LOGDIR, { recursive: true }); } catch {}
133
139
  let TURN = 0;
@@ -337,30 +343,19 @@ function loadPending() {
337
343
 
338
344
  // Auth-failure markers in TURN OUTPUT. opencode prints its auth error ("401 Unauthorized" /
339
345
  // "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";
346
+ // (card #5405). The rules live in lib/classify-failure.mjs (#5868) so they are testable against
347
+ // the real specimens; classify() wraps them with the one-line verdict the seat log carries, and
348
+ // runTurn judges only the CLI's OWN output (the prompt echo is replay, not speech — the rules
349
+ // line "…deleting failing tests is forbidden." once classified healthy codex turns as auth).
350
+ function classify(exit) {
351
+ const { reason, matched } = classifyFailure(exit, lastErrText, lastEmptyOutput);
352
+ log(`classified ${reason} because ${matched}`);
353
+ return reason;
359
354
  }
360
355
 
361
356
  async function reportFailure(exit, trigger, undelivered = 0) {
362
357
  consecFails++;
363
- const reason = classifyFailure(exit, lastErrText, lastEmptyOutput);
358
+ const reason = classify(exit);
364
359
  const down = consecFails >= 2;
365
360
  const status = down ? `down: ${reason} · ${consecFails} fails` : `errored: ${reason}`;
366
361
  await api("/register", { session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL }).catch(() => {});
@@ -377,9 +372,10 @@ async function reportFailure(exit, trigger, undelivered = 0) {
377
372
  // The count of messages this seat is HOLDING is the operator-actionable half of a failure: a
378
373
  // crashed pulse costs nothing, a crashed turn sitting on three escalations is someone waiting.
379
374
  const held = undelivered ? ` · holding ${undelivered} undelivered message${undelivered > 1 ? "s" : ""} (will retry)` : "";
380
- const text = down
375
+ // #5869: the broadcast quotes failure context; keys never ride the bus.
376
+ const text = redactKeys(down
381
377
  ? `🛑 ${SESSION} DOWN — ${consecFails} consecutive failures (${reason}, exit ${exit})${hint}${held}`
382
- : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`;
378
+ : `⚠️ ${SESSION} turn FAILED (${trigger}, exit ${exit} · ${reason})${hint}${held}`);
383
379
  // Announce a CHANGE of state, never the continuation of one. The registered status above already
384
380
  // carries "down: exhausted · N fails" for anyone who looks, which is state and costs nobody a
385
381
  // turn; the broadcast is the event, and an unchanged state is not an event.
@@ -400,6 +396,23 @@ async function reportFailure(exit, trigger, undelivered = 0) {
400
396
  log(`\x1b[31mreported failure to bus: ${reason} (exit ${exit})\x1b[0m`);
401
397
  }
402
398
 
399
+ // ---- activity truth (#5965): the RUNNER is the source for this seat ----------------
400
+ // The app pulses a seat from its hub peer status. The runner is what actually knows when a
401
+ // turn starts and ends, so it reports the boundaries: `working · <trigger>` the moment a turn
402
+ // begins and `idle` the instant it lands clean. herdr's screen detection cannot see a
403
+ // runner-driven CLI mid-turn (it sets screen_detection_skipped for those panes), which is why
404
+ // seats used to read as idle while genuinely working — the desktop's herdr row is unreliable
405
+ // for runner seats, so it falls back to this hub status. Bounded 5s so a slow hub never delays
406
+ // the very turn it is reporting; one HTTP call per transition, never a poll.
407
+ async function registerStatus(status) {
408
+ const url = HUB + "/register";
409
+ const body = JSON.stringify({ session: SESSION, project: PROJ, status, llm: AGENT, model: MODEL });
410
+ try {
411
+ const opts = { method: "POST", headers: { "content-type": "application/json", connection: "close" }, body };
412
+ await fetch(url, { ...opts, headers: { ...opts.headers, ...signedHeaders(identity, url, opts) }, signal: AbortSignal.timeout(5000) });
413
+ } catch {}
414
+ }
415
+
403
416
  // ---- telling the ASSIGNER, mechanically ------------------------------------
404
417
  // A seat used to finish its contract and say nothing. Completion lived only in the RULES prompt
405
418
  // ("report on the bus"), so a cheap model that did the work and ended its turn left the
@@ -410,6 +423,7 @@ async function reportFailure(exit, trigger, undelivered = 0) {
410
423
  // So: whoever sent the message that woke this seat gets told DIRECTLY what became of it. Direct
411
424
  // messages wake; that is the whole difference. Kept short, like every other bus line.
412
425
  async function notifyAssigners(pairs, text) {
426
+ text = redactKeys(text); // #5869: the "asked" excerpt quotes the wake message — keys stay off the bus
413
427
  const seen = new Set();
414
428
  for (const { from: f, id } of pairs) {
415
429
  // `hub:*` senders are the hub's own pseudo-ids (hub:duty, the overseer), not sessions: nothing
@@ -438,12 +452,20 @@ async function reportHealthy() {
438
452
  }
439
453
 
440
454
  let sid = "";
441
- function runTurn(prompt, isFirst, trigger = "kickoff") {
455
+ async function runTurn(prompt, isFirst, trigger = "kickoff") {
442
456
  TURN++; banner(trigger);
443
457
  const t0 = Date.now();
458
+ // #5965 — TURN START. The hub peer row is where the app reads activity from, and the runner is
459
+ // the only one who knows a turn is starting, so say so before the CLI spawn (awaited: the spawn
460
+ // below blocks the loop, an unawaited fetch would not leave the machine until the turn ended).
461
+ await registerStatus(`working · ${trigger}`);
444
462
  const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
445
463
  appendFileSync(pf, "", { flag: "w" }); // truncate
446
464
  appendFileSync(pf, prompt);
465
+ // #5868: where HEAD stood when the turn began. A turn that moved it shipped real work, and an
466
+ // exit-0 turn with real output must never be re-labelled "auth" by the #5405 escalation — the
467
+ // qwen specimen committed aa3c340 while its captured stream still tripped the auth regex.
468
+ const headBefore = gitOut(["rev-parse", "HEAD"], TURN_DIR);
447
469
  let cmd = (isFirst || (cli.sid && !sid)) ? cli.first : cli.next;
448
470
  const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
449
471
  cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
@@ -470,7 +492,13 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
470
492
  // `crashed` and nobody knew to swap it. sid seats already fold stdout into the ERRF stream via
471
493
  // `tee /dev/stderr`; the rest now tee straight into ERRF. A real pipeline (not a process
472
494
  // 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}`;
495
+ // #5869: redaction rides IN the pipeline lib/redact.mjs is a tee replacement that echoes
496
+ // stdin verbatim to the live window and appends only REDACTED bytes to ERRF, so a CLI that
497
+ // echoes its environment never parks a provider key in a file every seat can read. The tee
498
+ // topology is load-bearing (#5481): stdout+stderr must still BOTH land in ERRF, and the sid
499
+ // path still folds stdout in via /dev/stderr → the --tee2 hop below.
500
+ const SCRUB = `node ${join(import.meta.dirname, "..", "lib", "redact.mjs")}`;
501
+ const inner = cli.sid ? `${cmd} | tee /dev/stderr` : `${cmd} | ${SCRUB} --tee ${ERRF}`;
474
502
  // #5684: runTurn is spawnSync, so the runner cannot watch its own turn — a DETACHED watchdog
475
503
  // does. Armed by a stamp file, disarmed when the turn ends (stamp removed below); a turn past
476
504
  // the window with no ERRF growth earns ONE direct stall report to the foreman, never a kill.
@@ -482,7 +510,7 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
482
510
  { detached: true, stdio: "ignore" });
483
511
  wd.unref();
484
512
  } catch {}
485
- const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(tee -a ${ERRF} >&2)`], {
513
+ const r = spawnSync("/bin/bash", ["-c", `set -o pipefail; { ${inner} ; } 2> >(${SCRUB} --tee2 ${ERRF})`], {
486
514
  cwd: TURN_DIR, encoding: "utf8", stdio: cli.sid ? ["ignore", "pipe", "inherit"] : "inherit",
487
515
  env: { ...process.env, RELAY_URL: HUB, RELAY_AGENT: AGENT, RELAY_PROJECT: PROJ,
488
516
  // A RUNNER-MANAGED SEAT MUST NEVER HAND ITSELF A BATON.
@@ -499,18 +527,36 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
499
527
  maxBuffer: 16 * 1024 * 1024,
500
528
  });
501
529
  try { unlinkSync(STAMPF); } catch {} // turn over — disarm the watchdog
502
- try { lastErrText = readFileSync(ERRF, "utf8").slice(-4000); } catch { lastErrText = ""; }
530
+ // #5869: scrub AT REST, synchronously, before anything reads the file back. The stderr hop is
531
+ // a process substitution bash does not wait for, so this pass also catches its tail — the
532
+ // auth classifier and the empty-output check below must judge REDACTED text and a settled file.
533
+ try { writeFileSync(ERRF, redactKeys(readFileSync(ERRF, "utf8"))); } catch {}
534
+ // #5868: classify only what the CLI itself said. The transcript replays the whole turn prompt
535
+ // (rules, lessons, the wake text) — and those lines once classified healthy codex turns as
536
+ // auth ("…is forbidden.") and exhausted ("retries burn quota"). Prompt lines are stripped
537
+ // before anything downstream looks at the text.
538
+ let ownOut = "";
539
+ try { ownOut = stripPromptEcho(readFileSync(ERRF, "utf8"), readPromptText(pf)); } catch { ownOut = ""; }
540
+ lastErrText = ownOut.slice(-4000);
503
541
  if (cli.sid && r.stdout) { const m = r.stdout.match(cli.sid); if (m) sid = m[1]; }
504
542
  const realExit = r.status;
505
543
  // A zero exit is NOT proof the turn ran: opencode prints "401 Unauthorized" / "Invalid API key"
506
544
  // and exits 0, so a bare 0 made the runner ack "✅ done", clear the pending queue and heartbeat
507
545
  // 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).
546
+ // exit-0-with-auth turn as FAILED but ONLY when the CLI's own output is short enough to be
547
+ // just the error (#5868): a long output is a real answer, and a warning inside it must not
548
+ // fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
549
+ // call site branches on (kickoff, pulse, deliverWake).
510
550
  let effExit = realExit;
511
- if (realExit === 0 && AUTH_MARKER_RE.test(lastErrText)) {
551
+ let authHit = "";
552
+ // #5868: a NEW commit since turn start is real work, and an exit-0 turn with real output is
553
+ // never re-labelled auth — the qwen specimen exited 0 with a shipped commit (aa3c340) while a
554
+ // short capture of echoed contract text tripped the regex.
555
+ const newCommit = !!headBefore && gitOut(["rev-parse", "HEAD"], TURN_DIR) !== headBefore;
556
+ if (realExit === 0 && looksLikeAuthDeath(ownOut, newCommit)) {
512
557
  effExit = 1;
513
- log("\x1b[31mexit 0 but turn output shows an auth failure — treating as FAILED (auth)\x1b[0m");
558
+ authHit = AUTH_MARKER_RE.exec(ownOut)[0];
559
+ log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${authHit}")\x1b[0m`);
514
560
  }
515
561
  // #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
516
562
  // capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
@@ -519,14 +565,22 @@ function runTurn(prompt, isFirst, trigger = "kickoff") {
519
565
  // real CLI prints something on success (drill C pins that), so silence is the trap, not a
520
566
  // quiet victory. (Integration note: this was nearly "fixed" into stdout-only detection that
521
567
  // never fired — the tee topology is the load-bearing fact; keep this comment with it.)
568
+ // The judgment now runs on the ECHO-STRIPPED text (#5868): a CLI that replays the prompt but
569
+ // does no work has still produced nothing of its own.
522
570
  if (realExit === 0 && effExit === 0 && !lastErrText.trim()) {
523
571
  effExit = 1;
524
572
  lastEmptyOutput = true;
525
573
  log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
526
574
  }
527
- 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 });
575
+ // #5868: the verdict rides the telemetry row so a classification survives the pane scrolling
576
+ // away — the same "classified X because Y" shape the runner logs, in the seat's jsonl forever.
577
+ const verdict = verdictFor(realExit, effExit, lastEmptyOutput, ownOut);
578
+ telemetry({ ts: Date.now(), agent: AGENT, project: PROJ, turn: TURN, trigger, model: MODEL || "cli-default", duration_ms: Date.now() - t0, exit: realExit, effExit, authFailed: effExit !== realExit, emptyOutput: lastEmptyOutput, verdict });
528
579
  log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
529
580
  if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
581
+ // #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
582
+ // pulsing it even before the next /poll heartbeat. Failure keeps reportFailure's down/errored.
583
+ if (realExit === 0 && effExit === 0) await registerStatus("idle");
530
584
  return effExit;
531
585
  }
532
586
 
@@ -592,7 +646,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
592
646
  let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
593
647
  if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
594
648
 
595
- const ec0 = runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
649
+ const ec0 = await runTurn(composedTurn({ base: KICKOFF, lessons: pickLessons(LESSONS_RAW, "") }), true, "kickoff");
596
650
  if (ec0) await reportFailure(ec0, "kickoff", pendingWake.length); // a failed kickoff = the "fired up, died, nobody knew" case
597
651
  let lastTurnAt = Date.now();
598
652
  if (PULSE_MS) log(`pulse armed — mission re-read every ${Math.round(PULSE_MS / 1000)}s (${MISSION_FILE})`);
@@ -602,7 +656,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
602
656
  // pulse first: a due mission beat runs even on a silent bus. Measured from the END of the
603
657
  // last turn, so a long turn doesn't stack an immediate pulse on top of itself.
604
658
  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");
659
+ const ecp = await runTurn(composedTurn({ base: PULSE_PROMPT + "\n\n", rulesText: RULES, lessons: pickLessons(LESSONS_RAW, PULSE_PROMPT) }), false, "pulse");
606
660
  if (ecp) await reportFailure(ecp, "pulse"); else await reportHealthy();
607
661
  lastTurnAt = Date.now();
608
662
  log("parked — waiting for the next message or pulse");
@@ -694,7 +748,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
694
748
  tailText: "\nAct on what's addressed to you, then end your turn.\n\n",
695
749
  rulesText: RULES, lessons,
696
750
  });
697
- const ec = runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
751
+ const ec = await runTurn(prompt, false, deliveryFails ? `${trigger} (redelivery)` : trigger);
698
752
  const secs = Math.round((Date.now() - tStart) / 1000);
699
753
  if (ec) {
700
754
  deliveryFails++;
@@ -704,7 +758,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
704
758
  await reportFailure(ec, "message", pendingWake.length);
705
759
  // The room hears the broadcast above; the one who is actually blocked hears it directly.
706
760
  await notifyAssigners(assigners,
707
- `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classifyFailure(ec, lastErrText)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
761
+ `⚠️ your contract FAILED on ${SESSION} (exit ${ec}, ${classify(ec)}) · retrying in ${Math.round(wait / 1000)}s · asked: "${asked}"`);
708
762
  log(`\x1b[31m${pendingWake.length} message(s) still UNDELIVERED — next attempt in ${Math.round(wait / 1000)}s\x1b[0m`);
709
763
  } else {
710
764
  pendingWake = []; pendingBcast = []; deliveryFails = 0; retryAt = 0;
package/bin/crew.sh CHANGED
@@ -733,7 +733,8 @@ echo "[crew] hub for $PROJ: $HUB_URL (baked into every seat; CREW_HUB=<url> over
733
733
  SCROOGE="$BUS_DIR/engine/bin/scrooge"
734
734
  [ -f "$SCROOGE" ] || SCROOGE="$(command -v scrooge 2>/dev/null || echo scrooge)"
735
735
 
736
- # resolve_model <agent> <provider> <task> <diff> -> echoes a runner-ready model id, or empty (→ CLI default).
736
+ # resolve_model <agent> <provider> <task> <diff> -> echoes a provider-qualified model id.
737
+ # A provider seat must never fall through to opencode's unrelated global default.
737
738
  resolve_model() {
738
739
  local agent="$1" provider="$2" task="$3" diff="$4" cands="" out=""
739
740
  cands="$(opencode models "$provider" 2>/dev/null | tr '\n' ' ')"
@@ -742,10 +743,16 @@ resolve_model() {
742
743
  else
743
744
  out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)"
744
745
  fi
745
- [ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — CLI default" >&2; return 0; }
746
- printf '%s' "$out" | python3 -c 'import json,sys
746
+ [ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — refusing opencode global default" >&2; return 1; }
747
+ out="$(printf '%s' "$out" | python3 -c 'import json,sys
747
748
  try: print(json.load(sys.stdin).get("qualified") or "")
748
- except Exception: pass' 2>/dev/null
749
+ except Exception: pass' 2>/dev/null)"
750
+ [ -n "$out" ] || { echo "[crew] router returned no model for $agent:$provider — refusing opencode global default" >&2; return 1; }
751
+ [ "${out%%/*}" = "$provider" ] || {
752
+ echo "[crew] router selected $out outside $provider — refusing cross-provider fallback" >&2
753
+ return 1
754
+ }
755
+ printf '%s' "$out"
749
756
  }
750
757
 
751
758
  epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
@@ -760,13 +767,21 @@ resolve_spec() {
760
767
  # into the launcher string. AGENT is set above and DIR is fixed, so this is the earliest safe point.
761
768
  reap_seat
762
769
  FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
763
- [ "$AGENT" = "openrouter" ] && [ -z "$FIELD" ] && FIELD="openrouter"
770
+ # Bare native seats use their own CLI defaults. Bare opencode-hosted seats MUST name their
771
+ # provider implicitly: glm is the one non-obvious alias; every discovered/BYOM seat's label is
772
+ # its provider id. Leaving FIELD empty is what handed qwen/glm to opencode's global DeepSeek.
773
+ if [ -z "$FIELD" ]; then
774
+ case "$AGENT" in
775
+ codex|kimi|claude|gemini|dsh|opencode) ;;
776
+ glm) FIELD="zai-coding-plan" ;;
777
+ *) FIELD="$AGENT" ;;
778
+ esac
779
+ fi
764
780
  if [ -n "$FIELD" ]; then
765
781
  case "$FIELD" in
766
782
  */*) MODEL="$FIELD" ;;
767
- *) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")"
768
- if [ -n "$MODEL" ]; then echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)"
769
- else echo " → $AGENT: '$FIELD' live selection unavailable — CLI default"; fi ;;
783
+ *) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || exit 1
784
+ echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
770
785
  esac
771
786
  fi
772
787
  }
package/bin/new.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // trantor new — project genesis, the CLI half (#5862). One command stands a project up:
3
3
  //
4
- // trantor new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json]
4
+ // trantor new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json]
5
5
  //
6
- // It makes the directory under the dev root (TRANTOR_DEV_ROOT or ~/development), starts git on
7
- // main (or clones --from, or adopts an existing folder with --adopt), seeds CLAUDE.md from the
6
+ // It makes the project directory at <parent>/<name> --dir names the PARENT, never the project
7
+ // directory itself (default parent: TRANTOR_DEV_ROOT or ~/development). The name is always
8
+ // appended under it, so `--dir P` with name N creates P/N. Starts git on main (or clones --from,
9
+ // or adopts an existing folder with --adopt), seeds CLAUDE.md from the
8
10
  // brief (verbatim brief + the trantor conventions block), installs the same auto-card hook as
9
11
  // `trantor init-hooks`, posts the brief as the hub project brief (POST /project — the same call
10
12
  // relay_project_brief makes), and opens the first card "genesis: <name>" on the new board.
@@ -17,8 +19,10 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, append
17
19
  import { homedir } from "node:os";
18
20
  import { dirname, isAbsolute, join, resolve } from "node:path";
19
21
  import { fileURLToPath } from "node:url";
20
- import { ensureEnrolled, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
21
- import { resolveHub } from "../lib/project.mjs";
22
+ import { ensureEnrolled as enrollTofu, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
23
+ import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
24
+ import { setAutonomy } from "../lib/autonomy.mjs";
25
+ import { resolveHub, setProjectHub } from "../lib/project.mjs";
22
26
 
23
27
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
24
28
 
@@ -51,7 +55,7 @@ const flag = (n) => { const i = args.indexOf("--" + n); return i >= 0 ? args[i +
51
55
  const has = (n) => args.includes("--" + n);
52
56
  const json = has("json");
53
57
  const name = args.find(a => !a.startsWith("--"));
54
- if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <path>] [--adopt] [--json]");
58
+ if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — project lands at <parent>/<name>");
55
59
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) die(`invalid project name "${name}" — letters, digits, dot, dash, underscore`);
56
60
  const from = flag("from");
57
61
  const briefFile = flag("brief");
@@ -104,17 +108,32 @@ if (existsSync(claude)) {
104
108
  const hook = spawnSync(process.execPath, [join(ROOT, "bin", "init-hooks.mjs")], { cwd: dir, encoding: "utf8" });
105
109
  if (hook.status !== 0) die(`hook install failed: ${(hook.stderr || "").trim()}`);
106
110
 
107
- // ── the hub: brief + first card, signed like every other client ─────────────────────────────────
111
+ // A new project is a new trust boundary. Pin its harness dial even when the machine-wide default
112
+ // is bypass, so opening it can never inherit another project's permission choice.
113
+ setAutonomy(name, { harness: "prompt" });
114
+
115
+ // ── the hub: pin + brief + first card, signed like every other client ──────────────────────────
108
116
  const hub = resolveHub(name);
117
+ // Pin the new project to the hub it posts to (#5862 residual): without the pin, the first
118
+ // session in the dir falls back to the global default and wears the "not pinned to a hub"
119
+ // warning even though genesis chose this hub deliberately. Same persistence as `trantor hub set`.
120
+ setProjectHub(name, hub);
109
121
  const session = process.env.RELAY_SESSION || `genesis:${name}`;
110
122
  const identity = loadIdentity(session);
111
123
  let card = null;
112
124
  let hubError = null;
113
125
  try {
114
- await ensureEnrolled(session, identity, name);
126
+ // The genesis identity is BRAND NEW — it cannot write to a project on an enforce hub until the
127
+ // hub knows it. TOFU /enroll only works on a local loopback hub; a remote enforce hub refuses it
128
+ // (403 "tofu enrollment refused") and the genesis silently records nothing (#6049). So enroll the
129
+ // way crew seats do: the operator's owner key mints a project-scoped write invite and the genesis
130
+ // identity spends it. Only when NO owner key is configured (a loopback hub with no owner identity)
131
+ // do we fall back to the plain TOFU enroll.
132
+ const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
133
+ if (!viaOwner.ok && viaOwner.reason === "no-owner-key") await enrollTofu(session, identity, name);
115
134
  const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
116
135
  const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
117
- if (!r1.ok) throw new Error(`hub ${r1.status} on /project`);
136
+ if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
118
137
  const r2 = await signedPost("/task", {
119
138
  project: name,
120
139
  title: `genesis: ${name}`,
@@ -122,7 +141,7 @@ try {
122
141
  by: session,
123
142
  note: "project genesis — created by trantor new",
124
143
  }, { session, project: name, timeoutMs: 8000 });
125
- if (!r2.ok) throw new Error(`hub ${r2.status} on /task`);
144
+ if (!r2.ok) throw new Error(`hub ${r2.status} on /task${r2.json?.error ? `: ${r2.json.error}` : ""}`);
126
145
  card = r2.json?.task?.id ?? null;
127
146
  } catch (e) {
128
147
  hubError = e instanceof Error ? e.message : String(e);
@@ -130,8 +149,10 @@ try {
130
149
  }
131
150
 
132
151
  // ── report ──────────────────────────────────────────────────────────────────────────────────────
152
+ // dir is the created project directory <parent>/<name>; parent is the --dir (or default) root the
153
+ // name was appended under — the two together state the parent contract explicitly.
133
154
  if (json) {
134
- console.log(JSON.stringify({ name, dir, branch, hub, card }));
155
+ console.log(JSON.stringify({ name, parent: devRoot, dir, branch, hub, card }));
135
156
  } else {
136
157
  console.log(`✓ ${dir} (${branch}${from ? ", cloned" : adopt ? ", adopted" : ""})`);
137
158
  console.log(`✓ CLAUDE.md seeded${brief ? " from the brief" : " (no brief — add the project's what/why/goal)"}`);
package/bin/patrol.mjs CHANGED
@@ -43,6 +43,11 @@ function rowMatchesRunner(row, runner) {
43
43
  return !rp || rp === runnerProject(runner);
44
44
  }
45
45
 
46
+ function seatProvider(agent) {
47
+ if (["codex", "kimi", "claude", "gemini", "dsh", "opencode"].includes(agent)) return null;
48
+ return agent === "glm" ? "zai-coding-plan" : agent;
49
+ }
50
+
46
51
  function sortedProjects(projects) {
47
52
  return [...projects].sort((a, b) => displayProject(a).localeCompare(displayProject(b)));
48
53
  }
@@ -76,6 +81,7 @@ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir
76
81
  const workspaceIds = new Set(workspaces.map(w => String(w?.id || "")).filter(Boolean));
77
82
  const orphans = [];
78
83
  const ambiguous = [];
84
+ const warnings = [];
79
85
 
80
86
  for (const runner of runners) {
81
87
  if (isBusInternalRunner(runner, bus)) continue;
@@ -85,6 +91,18 @@ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir
85
91
  } else if (!rows.some(row => rowMatchesRunner(row, runner))) {
86
92
  orphans.push({ type: "live-runner-without-row", project: p, agent: runner.agent, pid: runner.pid, dir: runner.dir });
87
93
  }
94
+ const expectedProvider = seatProvider(String(runner?.agent || ""));
95
+ const actualProvider = String(runner?.model || "").split("/")[0];
96
+ if (expectedProvider && actualProvider && actualProvider !== expectedProvider) {
97
+ warnings.push({
98
+ type: "seat-model-provider-mismatch",
99
+ project: p,
100
+ agent: runner.agent,
101
+ model: runner.model,
102
+ expectedProvider,
103
+ pid: runner.pid,
104
+ });
105
+ }
88
106
  }
89
107
 
90
108
  for (const ws of workspaces) {
@@ -124,7 +142,7 @@ export function buildPatrolReport(rawInventory = {}, reaped = [], { bus = busDir
124
142
  };
125
143
  }
126
144
 
127
- return { projects: out, orphans, ambiguous, reaped };
145
+ return { projects: out, orphans, ambiguous, warnings, reaped };
128
146
  }
129
147
 
130
148
  function oldEnough(path, now, maxAgeMs) {
@@ -196,6 +214,10 @@ export function formatHuman(report) {
196
214
  for (const item of report.orphans) lines.push(` - ${item.type}: ${displayProject(item.project)} ${item.agent || item.title || item.handle || item.id || ""}`.trimEnd());
197
215
  lines.push(`ambiguous: ${report.ambiguous.length}`);
198
216
  for (const item of report.ambiguous) lines.push(` - ${item.type}: ${item.agent || item.title || item.dir || item.id || ""}`.trimEnd());
217
+ lines.push(`warnings: ${report.warnings?.length || 0}`);
218
+ for (const item of report.warnings || []) {
219
+ lines.push(` - ${item.type}: ${displayProject(item.project)} ${item.agent} runs ${item.model}; expected ${item.expectedProvider}/*`);
220
+ }
199
221
  lines.push(`reaped: ${report.reaped.length}`);
200
222
  for (const item of report.reaped) lines.push(` - ${item.type}: ${item.path || item.output || ""}`.trimEnd());
201
223
  return `${lines.join("\n")}\n`;
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()]) {
@@ -22,6 +22,16 @@ const TIMEOUT = 2000; // contract: ev
22
22
 
23
23
  const busDir = () => process.env.RELAY_DATA_DIR || join(homedir(), ".agent-bus");
24
24
 
25
+ function opencodeDefaultModel() {
26
+ try {
27
+ const home = process.env.HOME || homedir();
28
+ const configDir = process.env.XDG_CONFIG_HOME || join(home, ".config");
29
+ const config = JSON.parse(readFileSync(join(configDir, "opencode", "opencode.json"), "utf8"));
30
+ const model = config?.model;
31
+ return /^[^\s/]+\/[^\s/]+$/.test(model) ? String(model) : "";
32
+ } catch { return ""; }
33
+ }
34
+
25
35
  // Run a subprocess, return stdout; "" on ANY failure (missing binary, nonzero exit, timeout).
26
36
  function run(cmd, args, env = {}) {
27
37
  try {
@@ -75,7 +85,7 @@ function psTable() {
75
85
  return rows;
76
86
  }
77
87
 
78
- // Live crew-runner processes → [{pid,agent,dir}]. Runner argv (crew.sh RUN_CMD) is
88
+ // Live crew-runner processes → [{pid,agent,dir,model}]. Runner argv (crew.sh RUN_CMD) is
79
89
  // `node …/crew-runner.mjs <agent> <dir>` — dir is the LAST argument, so the regex is anchored
80
90
  // on end-of-string. project=null → all runners; project given → only runners whose dir resolves
81
91
  // to that project. Resolution is the lib/project.mjs walk (git-root basename, else dir basename)
@@ -84,6 +94,7 @@ function psTable() {
84
94
  export function liveRunners(project = null) {
85
95
  try {
86
96
  const out = [];
97
+ const globalModel = opencodeDefaultModel();
87
98
  for (const { pid, cmd } of psTable()) {
88
99
  const m = cmd.match(/crew-runner\.mjs\s+(\S+)\s+(\S+)\s*$/);
89
100
  if (!m) continue;
@@ -92,7 +103,13 @@ export function liveRunners(project = null) {
92
103
  const name = basename(gitRoot(dir) || dir);
93
104
  if (name !== project) continue;
94
105
  }
95
- out.push({ pid, agent, dir });
106
+ // CREW_MODEL is fixed for the runner lifetime. Read only that named environment field —
107
+ // never return the rest of `ps eww`, which can contain provider credentials.
108
+ const envLine = run("ps", ["eww", "-p", String(pid), "-o", "command="]);
109
+ const pinnedModel = (envLine.match(/(?:^|\s)CREW_MODEL=([^\s]*)/) || [])[1] || "";
110
+ const model = pinnedModel || globalModel;
111
+ const modelSource = pinnedModel ? "crew" : (globalModel ? "opencode-global" : "");
112
+ out.push({ pid, agent, dir, model, modelSource });
96
113
  }
97
114
  return out;
98
115
  } catch { return []; }
@@ -0,0 +1,107 @@
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. The exact-
12
+ // match version (9b28036) caught nothing in the wild: the qwen specimen (turn 9, 2026-09-02,
13
+ // card #5868) echoed its CONTRACT — the #6049 wake text is ABOUT a hub 401, dense with "401",
14
+ // "unknown identity", "credentials" — wrapped in CLI framing no prompt line equals verbatim.
15
+ // The strip now normalizes (ANSI off, whitespace collapsed) and drops a line that CONTAINS a
16
+ // prompt line or is a wrapped FRAGMENT of one, so replay is caught however the CLI frames it.
17
+ // · looksLikeAuthDeath — the #5405 exit-0 escalation fires only on a SHORT error-only output
18
+ // (the opencode "401 Unauthorized" specimen is a couple of lines); a long output is a real
19
+ // answer, and a warning inside it must not fail the turn. AND never when the turn produced
20
+ // REAL WORK (a new commit): the qwen specimen exited 0, committed aa3c340, and its captured
21
+ // stream still held under 400 bytes of contract echo carrying "401" — a short capture is not
22
+ // proof of a dead turn, but a shipped commit is proof of a live one.
23
+ // · classifyFailure — "exhausted" demands an explicit quota/rate-limit message; the broad bare
24
+ // words ("credit", "balance", bare "insufficient") labelled ordinary prose on a dead turn and
25
+ // sent the operator to wait out a window that did not exist.
26
+ import { readFileSync } from "node:fs";
27
+
28
+ export const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
29
+
30
+ /** Prompt lines (≥40 chars — a short line carries no signature) echoed back by the CLI are
31
+ * replay, not speech. Echoes are rarely byte-identical (CLI framing, terminal wrapping, ANSI
32
+ * colors — the exact-match strip caught nothing in the qwen #6049 specimen), so both sides are
33
+ * normalized (ANSI stripped, whitespace collapsed) and a long line is dropped when it CONTAINS
34
+ * a prompt line, IS a fragment of one, or contains a ≥40-char RUN of one — terminal wrapping
35
+ * breaks the line but preserves long runs inside each wrapped piece. */
36
+ const ECHO_RUN = 40;
37
+ export function stripPromptEcho(errText, promptText) {
38
+ const text = String(errText || "");
39
+ if (!promptText) return text;
40
+ const norm = (l) => String(l).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\s+/g, " ").trim();
41
+ const prompts = String(promptText).split("\n").map(norm).filter(l => l.length >= 40);
42
+ if (!prompts.length) return text;
43
+ const hasRun = (p, n) => {
44
+ for (let i = 0; i + ECHO_RUN <= p.length; i++) if (n.includes(p.slice(i, i + ECHO_RUN))) return true;
45
+ return false;
46
+ };
47
+ return text.split("\n").filter(line => {
48
+ const n = norm(line);
49
+ // Short lines survive — no signature, nothing to match against.
50
+ if (n.length < 40) return true;
51
+ return !prompts.some(p => n.includes(p) || p.includes(n) || hasRun(p, n));
52
+ }).join("\n");
53
+ }
54
+
55
+ /** A real answer is long; an auth death is a couple of lines. The opencode specimen (#5405)
56
+ * printed its whole failure in under a hundred characters and produced nothing else. */
57
+ export const OWN_OUTPUT_ANSWER_MIN = 400;
58
+
59
+ /** The #5405 rule, refined twice by #5868: exit 0 + an auth-shaped marker means FAILED only when
60
+ * the CLI's own output is short enough to be JUST the error — and NEVER when the turn did real
61
+ * work (newCommit, checked by the runner via git): a shipped commit is a live turn, whatever the
62
+ * captured stream happens to hold. */
63
+ export function looksLikeAuthDeath(ownText, realWork = false) {
64
+ if (realWork) return false;
65
+ return String(ownText || "").length < OWN_OUTPUT_ANSWER_MIN && AUTH_MARKER_RE.test(ownText);
66
+ }
67
+
68
+ /** The one-line verdict the seat's jsonl carries (#5868): why the runner judged the turn the way
69
+ * it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
70
+ * scrolls away loses nothing that the telemetry row needs to say. */
71
+ export function verdictFor(realExit, effExit, emptyOutput, ownText) {
72
+ if (realExit === 0 && effExit === 0) return "classified success because exit 0 with CLI output";
73
+ if (realExit === 0 && effExit === 1) {
74
+ if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
75
+ const m = AUTH_MARKER_RE.exec(String(ownText || ""));
76
+ return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
77
+ }
78
+ const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput);
79
+ return `classified ${reason} because ${matched}`;
80
+ }
81
+
82
+ /** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
83
+ * runner logs, so the next misclassification is diagnosable from the seat log alone. */
84
+ export function classifyFailure(exit, errText, emptyOutput = false) {
85
+ // #5481: silence with a clean exit is a failure shape, not success — see lastEmptyOutput.
86
+ if (emptyOutput) return { reason: "empty-output", matched: "exit 0 with no output on either stream" };
87
+ const t = String(errText || "").toLowerCase();
88
+ if (exit === 127) return { reason: "missing-cli", matched: "exit 127 — command not found" };
89
+ // #5684: a provider BACKEND failure is not quota — it wants retry/swap, not a window wait.
90
+ // The specimen (#5683): codex's "unexpected status 404 Not Found … /responses/compact" was
91
+ // labelled "exhausted" and the operator was advised to wait out a window that did not exist.
92
+ // 401/403/429 deliberately fall through to the auth/exhausted branches below.
93
+ 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/);
94
+ if (m) return { reason: "backend-error", matched: m[0] };
95
+ // "reached your … limit" / "usage limit" catch the subscription CLIs (Claude's "You've reached
96
+ // your Fable 5 limit"), which say nothing about quota or credits and would otherwise read as a crash.
97
+ 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)/);
98
+ if (m) return { reason: "exhausted", matched: m[0] };
99
+ m = t.match(/unauthor|401|invalid[ _-]?api[ _-]?key|forbidden|403|token expired|expired/);
100
+ if (m) return { reason: "auth", matched: m[0] };
101
+ return { reason: "crashed", matched: `exit ${exit} with no known failure pattern` };
102
+ }
103
+
104
+ /** Read the prompt file if it exists (the turn prompt the CLI may echo into its transcript). */
105
+ export function readPromptText(pf) {
106
+ try { return readFileSync(pf, "utf8"); } catch { return ""; }
107
+ }
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.31",
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-crew-model-defaults.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": [