trantor 0.18.30 → 0.18.32
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/.claude-plugin/plugin.json +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/crew-runner.mjs +83 -15
- package/bin/crew.sh +63 -15
- package/bin/new.mjs +22 -9
- package/bin/patrol.mjs +23 -1
- package/hooks/lib/resources.mjs +19 -2
- package/hub.mjs +2 -1
- package/lib/classify-failure.mjs +61 -9
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.32",
|
|
4
4
|
"description": "Trantor — the hub-world for AI agent crews: live message bus, presence, project Kanban/flow board + crew orchestration for independent AI coding agents (Claude, Codex, Gemini, Kimi, DeepSeek)",
|
|
5
5
|
"mcpServers": {
|
|
6
6
|
"relay": {
|
package/bin/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 <
|
|
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)
|
package/bin/crew-runner.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import { ensureEnrolled } from "../lib/enroll.mjs";
|
|
|
19
19
|
import { redactKeys } from "../lib/redact.mjs";
|
|
20
20
|
import {
|
|
21
21
|
AUTH_MARKER_RE, classifyFailure, looksLikeAuthDeath,
|
|
22
|
+
verdictFor,
|
|
22
23
|
readPromptText, stripPromptEcho,
|
|
23
24
|
} from "../lib/classify-failure.mjs";
|
|
24
25
|
import { capWake, capBcast, pickLessons, composePrompt } from "./crew-payload.mjs";
|
|
@@ -381,13 +382,13 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
381
382
|
const state = `${down ? "down" : "error"}:${reason}`;
|
|
382
383
|
if (state !== announced) {
|
|
383
384
|
announced = state;
|
|
384
|
-
await api("/send", { from: SESSION, to: "all", text, project: PROJ }).catch(() => {});
|
|
385
|
+
await api("/send", { from: SESSION, to: "all", text, project: PROJ, kind: "status" }).catch(() => {});
|
|
385
386
|
// #5684: a broadcast does not wake anyone — the incident is the operator spotting dead seats
|
|
386
387
|
// before the foreman did, twice in one morning. The same state-change event now goes DIRECT
|
|
387
388
|
// to the project's orchestrator (direct = wake), gated identically so a standing outage says
|
|
388
389
|
// it once. A seat that IS the orchestrator's own runner has nobody above it to wake.
|
|
389
390
|
const orch = `${hostId()}:${PROJ}`;
|
|
390
|
-
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ }).catch(() => {});
|
|
391
|
+
if (orch !== SESSION) await api("/send", { from: SESSION, to: orch, text, project: PROJ, kind: "alert" }).catch(() => {});
|
|
391
392
|
} else {
|
|
392
393
|
log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
|
|
393
394
|
}
|
|
@@ -433,7 +434,7 @@ async function notifyAssigners(pairs, text) {
|
|
|
433
434
|
seen.add(f);
|
|
434
435
|
// `re` threads this outcome to the exact contract it answers, so the sender's ledger closes the
|
|
435
436
|
// right one instead of guessing from timing.
|
|
436
|
-
const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ };
|
|
437
|
+
const payload = { from: SESSION, to: f, text: text.slice(0, 280), project: PROJ, kind: "receipt" };
|
|
437
438
|
if (id) payload.re = id;
|
|
438
439
|
await api("/send", payload).catch(() => {});
|
|
439
440
|
}
|
|
@@ -446,7 +447,7 @@ async function reportHealthy() {
|
|
|
446
447
|
// Recovery is a change too, so the next failure is news again.
|
|
447
448
|
announced = "";
|
|
448
449
|
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
|
|
449
|
-
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ }).catch(() => {});
|
|
450
|
+
await api("/send", { from: SESSION, to: "all", text: `✅ ${SESSION} recovered`, project: PROJ, kind: "status" }).catch(() => {});
|
|
450
451
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
451
452
|
}
|
|
452
453
|
|
|
@@ -461,6 +462,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
461
462
|
const pf = join(homedir(), ".agent-bus", `turn-${AGENT}-${PROJ}.txt`);
|
|
462
463
|
appendFileSync(pf, "", { flag: "w" }); // truncate
|
|
463
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);
|
|
464
469
|
let cmd = (isFirst || (cli.sid && !sid)) ? cli.first : cli.next;
|
|
465
470
|
const mfrag = MODEL && cli.mflag ? `${cli.mflag}${MODEL}` : "";
|
|
466
471
|
cmd = cmd.replaceAll("{M}", mfrag).replaceAll("{P}", pf).replaceAll("{SID}", sid);
|
|
@@ -543,10 +548,15 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
543
548
|
// fail the turn. Telemetry keeps the REAL exit; the returned code is the effective one every
|
|
544
549
|
// call site branches on (kickoff, pulse, deliverWake).
|
|
545
550
|
let effExit = realExit;
|
|
546
|
-
|
|
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)) {
|
|
547
557
|
effExit = 1;
|
|
548
|
-
|
|
549
|
-
log(`\x1b[31mexit 0 but the turn output IS an auth failure — treating as FAILED (auth, "${
|
|
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`);
|
|
550
560
|
}
|
|
551
561
|
// #5481: the Inception/Mercury trap — exit 0 with a NULL completion. ERRF is the TOTAL output
|
|
552
562
|
// capture, not just stderr: every seat's stdout is tee'd into it (`| tee -a ERRF` for the
|
|
@@ -562,7 +572,10 @@ async function runTurn(prompt, isFirst, trigger = "kickoff") {
|
|
|
562
572
|
lastEmptyOutput = true;
|
|
563
573
|
log("\x1b[31mexit 0 but the turn produced NO output — treating as FAILED (empty-output)\x1b[0m");
|
|
564
574
|
}
|
|
565
|
-
|
|
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 });
|
|
566
579
|
log(`turn ended (exit ${realExit}${effExit !== realExit ? ` → effective ${effExit} (${lastEmptyOutput ? "empty-output" : "auth"})` : ""}, ${((Date.now() - t0) / 1000).toFixed(0)}s)`);
|
|
567
580
|
if (realExit === 0 && effExit === 0) { cmuxStatus("idle", "#8a94a6", "robot"); herdrAgent("idle"); } // finished this turn, waiting for the next
|
|
568
581
|
// #5965 — TURN END. A clean exit means the seat is idle again; say so right away so the app stops
|
|
@@ -604,6 +617,57 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
604
617
|
return built.prompt;
|
|
605
618
|
}
|
|
606
619
|
|
|
620
|
+
const RECEIPT_MARKER = "✅ done on";
|
|
621
|
+
const CARD_REF_RE = /#\d{1,7}(?!\d)/;
|
|
622
|
+
|
|
623
|
+
// Runner-authored metadata is bus state, not work. Typed messages are authoritative; `re` and the
|
|
624
|
+
// stable text marker keep a mixed-version crew safe while older runners are still on the bus.
|
|
625
|
+
function isReceipt(message) {
|
|
626
|
+
const text = String(message?.text || "").trimStart();
|
|
627
|
+
return message?.kind === "receipt" || Number(message?.re) > 0 || text.startsWith(RECEIPT_MARKER);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function isStatusBroadcast(message) {
|
|
631
|
+
if (message?.to !== "all") return false;
|
|
632
|
+
if (message?.kind === "status") return true;
|
|
633
|
+
const text = String(message?.text || "").trim();
|
|
634
|
+
return /^[A-Za-z0-9_.-]+ reporting — ready for a contract\b/.test(text)
|
|
635
|
+
|| /^[✅⚠️🛑]\s+\S+\s+(?:recovered|turn FAILED|DOWN)\b/.test(text);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isContract(message) {
|
|
639
|
+
const text = String(message?.text || "");
|
|
640
|
+
return message?.kind === "contract" || /^\s*contract\s*:/i.test(text) || CARD_REF_RE.test(text);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function isRunnerSession(session) {
|
|
644
|
+
const suffix = `:${PROJ}`;
|
|
645
|
+
const name = String(session || "");
|
|
646
|
+
if (!name.endsWith(suffix)) return false;
|
|
647
|
+
const label = name.slice(0, -suffix.length);
|
|
648
|
+
// Crew labels are CLI/provider slugs. Host sessions keep their machine-style identity and remain
|
|
649
|
+
// valid direct assigners; runner-to-runner prose needs `contract:` or a card reference.
|
|
650
|
+
return /^[a-z0-9_.-]+$/.test(label) && !label.startsWith("hub:");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function shouldWake(message) {
|
|
654
|
+
if (isReceipt(message) || isStatusBroadcast(message)) return false;
|
|
655
|
+
if (message?.to === SESSION) {
|
|
656
|
+
if (message?.kind === "status") return false;
|
|
657
|
+
return !isRunnerSession(message?.from) || isContract(message);
|
|
658
|
+
}
|
|
659
|
+
return message?.to === "all"
|
|
660
|
+
&& isContract(message)
|
|
661
|
+
&& (message.text.includes(`@${AGENT}`) || message.text.toLowerCase().includes(`${AGENT}:`));
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function askedExcerpt(message) {
|
|
665
|
+
let text = String(message?.text || "").replace(/\s+/g, " ").trim();
|
|
666
|
+
const nested = text.search(/\s+[·|]\s*asked\s*:/i);
|
|
667
|
+
if (nested >= 0) text = text.slice(0, nested).trim();
|
|
668
|
+
return text.slice(0, 120);
|
|
669
|
+
}
|
|
670
|
+
|
|
607
671
|
(async () => {
|
|
608
672
|
await loadLessons();
|
|
609
673
|
// start cursor at the CURRENT tip so we don't replay history
|
|
@@ -618,7 +682,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
618
682
|
const { loadOrCreate } = await import("../lib/identity.mjs");
|
|
619
683
|
await sfetchJson(`${HUB}/send`, {
|
|
620
684
|
identity: loadOrCreate(SESSION, "agent"),
|
|
621
|
-
payload: { from: SESSION, to: "all", project: PROJ, text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
|
|
685
|
+
payload: { from: SESSION, to: "all", project: PROJ, kind: "status", text: `${AGENT} reporting — ready for a contract${MODEL ? ` (${MODEL})` : ""}` },
|
|
622
686
|
signal: AbortSignal.timeout(2500),
|
|
623
687
|
});
|
|
624
688
|
} catch {}
|
|
@@ -627,8 +691,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
627
691
|
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
628
692
|
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
629
693
|
const restored = loadPending();
|
|
630
|
-
let pendingWake = restored.wake;
|
|
631
|
-
let pendingBcast = restored.bcast;
|
|
694
|
+
let pendingWake = restored.wake.filter(shouldWake);
|
|
695
|
+
let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
632
696
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
633
697
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
634
698
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
@@ -675,6 +739,10 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
675
739
|
// never wake on your own broadcasts: a claude seat's report contains "claude:" and matched the
|
|
676
740
|
// @mention filter, buying one echo turn per report (seen live on the first pulsed orchestrator)
|
|
677
741
|
msgs = msgs.filter(m => m.from !== SESSION);
|
|
742
|
+
// A receipt is the terminal state of a contract, never a new contract. Consume typed receipts,
|
|
743
|
+
// reply-linked outcomes, and the old stable marker before direct-address logic sees them. Status
|
|
744
|
+
// broadcasts are presence chatter and are dropped rather than saved as future prompt context.
|
|
745
|
+
msgs = msgs.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
678
746
|
// #5760 (the night of 08-31): the hub's hourly "same-project-sessions" FYI woke every seat
|
|
679
747
|
// into a real CLI turn — three wedged for hours mid-chatter, one on the metered pool. That
|
|
680
748
|
// kind is pure coordination CONTEXT ("no human needs to relay this" — and no turn needs to
|
|
@@ -682,8 +750,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
682
750
|
// warnings still wake — those are actionable by the seat right now.
|
|
683
751
|
const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
|
|
684
752
|
const rest = msgs.filter(m => !fyi.includes(m));
|
|
685
|
-
const direct = rest.filter(m => m.to === SESSION);
|
|
686
|
-
const mentions = rest.filter(m => m.to === "all" && (m
|
|
753
|
+
const direct = rest.filter(m => m.to === SESSION && shouldWake(m));
|
|
754
|
+
const mentions = rest.filter(m => m.to === "all" && shouldWake(m));
|
|
687
755
|
const bcast = [...rest.filter(m => m.to === "all" && !mentions.includes(m)), ...fyi];
|
|
688
756
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
689
757
|
const wake = [...direct, ...mentions];
|
|
@@ -695,7 +763,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
695
763
|
const dropped = pendingWake.splice(0, pendingWake.length - PENDING_MAX);
|
|
696
764
|
log(`\x1b[31mundelivered queue overflowed — dropped ${dropped.length} oldest message(s)\x1b[0m`);
|
|
697
765
|
await api("/send", { from: SESSION, to: "all", project: PROJ,
|
|
698
|
-
text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
|
|
766
|
+
kind: "status", text: `⚠️ ${SESSION} dropped ${dropped.length} undelivered message(s) — queue hit its ${PENDING_MAX} cap during a failure streak` }).catch(() => {});
|
|
699
767
|
}
|
|
700
768
|
savePending(pendingWake, pendingBcast);
|
|
701
769
|
// Respect an active backoff: a new message during an outage joins the batch, it does not
|
|
@@ -728,7 +796,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
728
796
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
729
797
|
const assigners = [];
|
|
730
798
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
731
|
-
const asked =
|
|
799
|
+
const asked = askedExcerpt(wake[0]);
|
|
732
800
|
const tStart = Date.now();
|
|
733
801
|
const prompt = composedTurn({
|
|
734
802
|
wakeText, ctxText, againText,
|
package/bin/crew.sh
CHANGED
|
@@ -352,7 +352,7 @@ spawn_herdr() { # $@ = specs
|
|
|
352
352
|
local surfs=()
|
|
353
353
|
[ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
|
|
354
354
|
for SPEC in "$@"; do
|
|
355
|
-
resolve_spec "$SPEC"
|
|
355
|
+
resolve_spec "$SPEC" || continue
|
|
356
356
|
local cmd; cmd="$(RUN_CMD)"
|
|
357
357
|
if [ -n "$REUSE_WS" ]; then
|
|
358
358
|
# replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
|
|
@@ -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
|
|
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,16 +743,34 @@ 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" ]
|
|
746
|
-
|
|
746
|
+
if [ -n "$out" ]; then
|
|
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
|
+
fi
|
|
751
|
+
# The router can be absent (a fresh machine, a dry run, scrooge without its registry). That must
|
|
752
|
+
# never hand the seat to opencode's GLOBAL default (#6068, the DeepSeek bill) and must not drop
|
|
753
|
+
# the seat either (#6110): fall back INSIDE the provider — the head of its own catalog — and say so.
|
|
754
|
+
if [ -z "$out" ] && [ -n "$cands" ]; then
|
|
755
|
+
out="$provider/${cands%% *}"
|
|
756
|
+
echo "[crew] router unavailable for $agent:$provider — using the provider's own catalog head ($out)" >&2
|
|
757
|
+
fi
|
|
758
|
+
[ -n "$out" ] || { echo "[crew] live model selection failed for $agent:$provider — no router and no catalog; refusing opencode global default" >&2; return 1; }
|
|
759
|
+
[ "${out%%/*}" = "$provider" ] || {
|
|
760
|
+
echo "[crew] router selected $out outside $provider — refusing cross-provider fallback" >&2
|
|
761
|
+
return 1
|
|
762
|
+
}
|
|
763
|
+
printf '%s' "$out"
|
|
749
764
|
}
|
|
750
765
|
|
|
751
766
|
epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
|
752
767
|
|
|
753
768
|
# resolve_spec <spec> -> sets AGENT + MODEL globals (live-selects a provider-only spec).
|
|
754
|
-
AGENT
|
|
769
|
+
# On failure, returns 1 (AGENT is still set; MODEL is empty) instead of exiting the whole script —
|
|
770
|
+
# callers are responsible for skipping that one seat and continuing the batch. SKIPPED_SEATS
|
|
771
|
+
# accumulates "agent: reason" entries across every spawn path so `up` can report + exit non-zero
|
|
772
|
+
# at the end without killing seats that already launched earlier in the loop.
|
|
773
|
+
AGENT=""; MODEL=""; SKIPPED_SEATS=()
|
|
755
774
|
resolve_spec() {
|
|
756
775
|
local SPEC="$1" FIELD
|
|
757
776
|
AGENT="${SPEC%%:*}"; MODEL=""
|
|
@@ -760,16 +779,39 @@ resolve_spec() {
|
|
|
760
779
|
# into the launcher string. AGENT is set above and DIR is fixed, so this is the earliest safe point.
|
|
761
780
|
reap_seat
|
|
762
781
|
FIELD=""; [ "$SPEC" != "$AGENT" ] && FIELD="${SPEC#*:}"
|
|
763
|
-
|
|
782
|
+
# Bare native seats use their own CLI defaults. Bare opencode-hosted seats MUST name their
|
|
783
|
+
# provider implicitly: glm is the one non-obvious alias; every discovered/BYOM seat's label is
|
|
784
|
+
# its provider id. Leaving FIELD empty is what handed qwen/glm to opencode's global DeepSeek.
|
|
785
|
+
if [ -z "$FIELD" ]; then
|
|
786
|
+
case "$AGENT" in
|
|
787
|
+
codex|kimi|claude|gemini|dsh|opencode) ;;
|
|
788
|
+
glm) FIELD="zai-coding-plan" ;;
|
|
789
|
+
*) FIELD="$AGENT" ;;
|
|
790
|
+
esac
|
|
791
|
+
fi
|
|
764
792
|
if [ -n "$FIELD" ]; then
|
|
765
793
|
case "$FIELD" in
|
|
766
794
|
*/*) MODEL="$FIELD" ;;
|
|
767
|
-
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")"
|
|
768
|
-
|
|
769
|
-
|
|
795
|
+
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" || {
|
|
796
|
+
echo "[crew] ✗ skipping seat '$AGENT' — model resolution failed for $FIELD ($TASK/$DIFF); remaining seats still launch" >&2
|
|
797
|
+
SKIPPED_SEATS+=("$AGENT: model resolution failed for $FIELD ($TASK/$DIFF)")
|
|
798
|
+
return 1
|
|
799
|
+
}
|
|
800
|
+
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
|
|
770
801
|
esac
|
|
771
802
|
fi
|
|
772
803
|
}
|
|
804
|
+
# report_skipped_seats: prints a summary of every seat resolve_spec skipped this run (if any) and
|
|
805
|
+
# returns 1 so callers can propagate a non-zero exit — the whole point is that a batch with some
|
|
806
|
+
# skips still launched the rest of the crew, so this is a REPORT, not an abort.
|
|
807
|
+
report_skipped_seats() {
|
|
808
|
+
[ "${#SKIPPED_SEATS[@]}" -gt 0 ] || return 0
|
|
809
|
+
echo ""
|
|
810
|
+
echo "✗✗ ${#SKIPPED_SEATS[@]} seat(s) skipped (model resolution failed) — the rest of the crew still launched:"
|
|
811
|
+
local s
|
|
812
|
+
for s in "${SKIPPED_SEATS[@]}"; do echo " - $s"; done
|
|
813
|
+
return 1
|
|
814
|
+
}
|
|
773
815
|
# Kill any runner ALREADY serving this exact agent+project before starting another.
|
|
774
816
|
#
|
|
775
817
|
# Without this, `trantor up <agent>` ADDS a runner instead of replacing one — and every duplicate
|
|
@@ -801,7 +843,7 @@ spawn_tmux() { # $@ = specs
|
|
|
801
843
|
# a pre-existing session for THIS project = the crew is already up; add missing seats as new panes.
|
|
802
844
|
tmux has-session -t "$TMUX_SESS" 2>/dev/null && first=0
|
|
803
845
|
for SPEC in "$@"; do
|
|
804
|
-
resolve_spec "$SPEC"
|
|
846
|
+
resolve_spec "$SPEC" || continue
|
|
805
847
|
local cmd; cmd="$(RUN_CMD)"
|
|
806
848
|
local pane=""
|
|
807
849
|
if [ "$first" = "1" ]; then
|
|
@@ -852,7 +894,7 @@ spawn_grid() { # $@ = specs
|
|
|
852
894
|
local ROWS=$(( (N + COLS - 1) / COLS ))
|
|
853
895
|
local CW=$(( GW / COLS )) CH=$(( GH / ROWS )) i=0 SPEC
|
|
854
896
|
for SPEC in "$@"; do
|
|
855
|
-
resolve_spec "$SPEC"
|
|
897
|
+
resolve_spec "$SPEC" || continue
|
|
856
898
|
local cmd; cmd="$(RUN_CMD)"
|
|
857
899
|
local C=$(( i % COLS )) R=$(( i / COLS )) X1 Y1 WID=""
|
|
858
900
|
X1=$(( GX + C * CW )); Y1=$(( GY + R * CH ))
|
|
@@ -937,7 +979,7 @@ spawn_cmux() { # $@ = specs
|
|
|
937
979
|
local surfs=()
|
|
938
980
|
[ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
|
|
939
981
|
for SPEC in "$@"; do
|
|
940
|
-
resolve_spec "$SPEC"
|
|
982
|
+
resolve_spec "$SPEC" || continue
|
|
941
983
|
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
942
984
|
if [ -n "$REUSE_WS" ]; then
|
|
943
985
|
# replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
|
|
@@ -1039,7 +1081,7 @@ OSA
|
|
|
1039
1081
|
fi
|
|
1040
1082
|
[ -n "$REUSE_TAB" ] && { tabid="$REUSE_TAB"; echo " → reusing existing crew workspace for $PROJ ($tabid)"; }
|
|
1041
1083
|
for SPEC in "$@"; do
|
|
1042
|
-
resolve_spec "$SPEC"
|
|
1084
|
+
resolve_spec "$SPEC" || continue
|
|
1043
1085
|
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
1044
1086
|
# In REUSE mode, replace-in-place: split off the agent's old terminal when tracked, close it after.
|
|
1045
1087
|
local OLD_SURF=""
|
|
@@ -1160,7 +1202,11 @@ echo "— bringing up crew for $PROJ ($CREW_UI) —"
|
|
|
1160
1202
|
SPAWN_EPOCH=$(epoch_ms)
|
|
1161
1203
|
spawn_crew "$@"
|
|
1162
1204
|
|
|
1163
|
-
if [ "$DRY" = "1" ]; then
|
|
1205
|
+
if [ "$DRY" = "1" ]; then
|
|
1206
|
+
echo "— dry run: no bus verify —"
|
|
1207
|
+
report_skipped_seats
|
|
1208
|
+
exit $?
|
|
1209
|
+
fi
|
|
1164
1210
|
echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
|
|
1165
1211
|
AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
|
|
1166
1212
|
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --since "$SPAWN_EPOCH" --timeout 30)
|
|
@@ -1181,3 +1227,5 @@ if [ -n "${RETRY// }" ]; then
|
|
|
1181
1227
|
fi
|
|
1182
1228
|
fi
|
|
1183
1229
|
echo "— crew verified on the bus. Send contracts with relay_send; runners keep agents alive for free. Teardown (this project only): trantor down —"
|
|
1230
|
+
report_skipped_seats
|
|
1231
|
+
exit $?
|
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 <
|
|
4
|
+
// trantor new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json]
|
|
5
5
|
//
|
|
6
|
-
// It makes the directory
|
|
7
|
-
//
|
|
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,7 +19,8 @@ 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";
|
|
22
|
+
import { ensureEnrolled as enrollTofu, loadIdentity, signedPost } from "../hooks/lib/api.mjs";
|
|
23
|
+
import { ensureEnrolled as enrollViaOwnerInvite } from "../lib/enroll.mjs";
|
|
21
24
|
import { setAutonomy } from "../lib/autonomy.mjs";
|
|
22
25
|
import { resolveHub, setProjectHub } from "../lib/project.mjs";
|
|
23
26
|
|
|
@@ -52,7 +55,7 @@ const flag = (n) => { const i = args.indexOf("--" + n); return i >= 0 ? args[i +
|
|
|
52
55
|
const has = (n) => args.includes("--" + n);
|
|
53
56
|
const json = has("json");
|
|
54
57
|
const name = args.find(a => !a.startsWith("--"));
|
|
55
|
-
if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <
|
|
58
|
+
if (!name) die("usage: trantor new <name> [--from <git-url>] [--brief <file>] [--dir <parent>] [--adopt] [--json] — project lands at <parent>/<name>");
|
|
56
59
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) die(`invalid project name "${name}" — letters, digits, dot, dash, underscore`);
|
|
57
60
|
const from = flag("from");
|
|
58
61
|
const briefFile = flag("brief");
|
|
@@ -120,10 +123,18 @@ const identity = loadIdentity(session);
|
|
|
120
123
|
let card = null;
|
|
121
124
|
let hubError = null;
|
|
122
125
|
try {
|
|
123
|
-
|
|
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);
|
|
134
|
+
else if (!viaOwner.ok) console.error(`genesis: enrollment via owner invite failed: ${viaOwner.reason}`);
|
|
124
135
|
const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
|
|
125
136
|
const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
|
|
126
|
-
if (!r1.ok) throw new Error(`hub ${r1.status} on /project`);
|
|
137
|
+
if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
|
|
127
138
|
const r2 = await signedPost("/task", {
|
|
128
139
|
project: name,
|
|
129
140
|
title: `genesis: ${name}`,
|
|
@@ -131,7 +142,7 @@ try {
|
|
|
131
142
|
by: session,
|
|
132
143
|
note: "project genesis — created by trantor new",
|
|
133
144
|
}, { session, project: name, timeoutMs: 8000 });
|
|
134
|
-
if (!r2.ok) throw new Error(`hub ${r2.status} on /task`);
|
|
145
|
+
if (!r2.ok) throw new Error(`hub ${r2.status} on /task${r2.json?.error ? `: ${r2.json.error}` : ""}`);
|
|
135
146
|
card = r2.json?.task?.id ?? null;
|
|
136
147
|
} catch (e) {
|
|
137
148
|
hubError = e instanceof Error ? e.message : String(e);
|
|
@@ -139,8 +150,10 @@ try {
|
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
// ── report ──────────────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
// dir is the created project directory <parent>/<name>; parent is the --dir (or default) root the
|
|
154
|
+
// name was appended under — the two together state the parent contract explicitly.
|
|
142
155
|
if (json) {
|
|
143
|
-
console.log(JSON.stringify({ name, dir, branch, hub, card }));
|
|
156
|
+
console.log(JSON.stringify({ name, parent: devRoot, dir, branch, hub, card }));
|
|
144
157
|
} else {
|
|
145
158
|
console.log(`✓ ${dir} (${branch}${from ? ", cloned" : adopt ? ", adopted" : ""})`);
|
|
146
159
|
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/hooks/lib/resources.mjs
CHANGED
|
@@ -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
|
-
|
|
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 []; }
|
package/hub.mjs
CHANGED
|
@@ -2627,7 +2627,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2627
2627
|
// is guesswork: a seat still working and a seat that died look identical from the sender's
|
|
2628
2628
|
// side, which is how an orchestrator ends up waiting forever on a dead peer.
|
|
2629
2629
|
const re = Number.isFinite(Number(b.re)) && Number(b.re) > 0 ? Number(b.re) : 0;
|
|
2630
|
-
const
|
|
2630
|
+
const kind = String(b.kind || "").slice(0, 40);
|
|
2631
|
+
const msg = { id: ++state.seq, ts: now(), from: b.from || "anon", to: b.to || "all", text, project: String(b.project || fromProj || "").slice(0, 80), ...(re ? { re } : {}), ...(kind ? { kind } : {}) };
|
|
2631
2632
|
state.messages.push(msg); if (state.messages.length > 5000) state.messages.splice(0, 1000);
|
|
2632
2633
|
dirty = true; pushToStreams(msg); // <-- instant push to live watchers
|
|
2633
2634
|
// Mirror onto the unified log. `refs` = the card ids this message cites (#3701), which is what
|
package/lib/classify-failure.mjs
CHANGED
|
@@ -8,10 +8,18 @@
|
|
|
8
8
|
// matched AUTH_MARKER_RE's bare "forbidden", and the codex lesson "retries burn quota" matched
|
|
9
9
|
// the exhausted rule. Three fixes, one per failure mode:
|
|
10
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.
|
|
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.
|
|
12
17
|
// · looksLikeAuthDeath — the #5405 exit-0 escalation fires only on a SHORT error-only output
|
|
13
18
|
// (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.
|
|
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.
|
|
15
23
|
// · classifyFailure — "exhausted" demands an explicit quota/rate-limit message; the broad bare
|
|
16
24
|
// words ("credit", "balance", bare "insufficient") labelled ordinary prose on a dead turn and
|
|
17
25
|
// sent the operator to wait out a window that did not exist.
|
|
@@ -19,25 +27,69 @@ import { readFileSync } from "node:fs";
|
|
|
19
27
|
|
|
20
28
|
export const AUTH_MARKER_RE = /unauthor|401|403|forbidden|invalid[ _-]?api[ _-]?key|authentication? failed|token expired/i;
|
|
21
29
|
|
|
22
|
-
/** Prompt lines (≥40 chars — a short line carries no signature) echoed back
|
|
23
|
-
*
|
|
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;
|
|
24
37
|
export function stripPromptEcho(errText, promptText) {
|
|
25
38
|
const text = String(errText || "");
|
|
26
39
|
if (!promptText) return text;
|
|
27
|
-
const
|
|
28
|
-
|
|
40
|
+
const norm = (l) => String(l).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\s+/g, " ").trim();
|
|
41
|
+
const promptLines = String(promptText).split("\n").map(norm).filter(Boolean);
|
|
42
|
+
if (!promptLines.length) return text;
|
|
43
|
+
const prompts = promptLines.filter(l => l.length >= 40);
|
|
44
|
+
// Whole-prompt normalized text, for the short-line VERBATIM check below: a short line has no
|
|
45
|
+
// 40-char run to fuzzy-match against (that's the ECHO_RUN heuristic for long lines), but a short
|
|
46
|
+
// echoed wake fragment ("check the 401 on the hub") still reappears byte-for-byte (post-
|
|
47
|
+
// normalization) as a line or substring of the prompt — that's still replay, not the CLI's own
|
|
48
|
+
// voice, and must not survive to trip looksLikeAuthDeath on a healthy exit-0 turn (#6110).
|
|
49
|
+
const promptFull = promptLines.join(" ");
|
|
50
|
+
const hasRun = (p, n) => {
|
|
51
|
+
for (let i = 0; i + ECHO_RUN <= p.length; i++) if (n.includes(p.slice(i, i + ECHO_RUN))) return true;
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
return text.split("\n").filter(line => {
|
|
55
|
+
const n = norm(line);
|
|
56
|
+
if (!n) return true;
|
|
57
|
+
// Short lines: survive unless they are a multi-word fragment (has a space — excludes a bare
|
|
58
|
+
// token like "codex" or "4,387" that trivially co-occurs with unrelated prompt text) that
|
|
59
|
+
// appears verbatim in the prompt. A genuine short CLI error the prompt never mentioned, or a
|
|
60
|
+
// single echoed token, still passes through untouched; a full echoed phrase does not.
|
|
61
|
+
if (n.length < 40) return !(n.includes(" ") && promptFull.includes(n));
|
|
62
|
+
return !prompts.some(p => n.includes(p) || p.includes(n) || hasRun(p, n));
|
|
63
|
+
}).join("\n");
|
|
29
64
|
}
|
|
30
65
|
|
|
31
66
|
/** A real answer is long; an auth death is a couple of lines. The opencode specimen (#5405)
|
|
32
67
|
* printed its whole failure in under a hundred characters and produced nothing else. */
|
|
33
68
|
export const OWN_OUTPUT_ANSWER_MIN = 400;
|
|
34
69
|
|
|
35
|
-
/** The #5405 rule, refined by #5868: exit 0 + an auth-shaped marker means FAILED only when
|
|
36
|
-
* CLI's own output is short enough to be JUST the error
|
|
37
|
-
|
|
70
|
+
/** The #5405 rule, refined twice by #5868: exit 0 + an auth-shaped marker means FAILED only when
|
|
71
|
+
* the CLI's own output is short enough to be JUST the error — and NEVER when the turn did real
|
|
72
|
+
* work (newCommit, checked by the runner via git): a shipped commit is a live turn, whatever the
|
|
73
|
+
* captured stream happens to hold. */
|
|
74
|
+
export function looksLikeAuthDeath(ownText, realWork = false) {
|
|
75
|
+
if (realWork) return false;
|
|
38
76
|
return String(ownText || "").length < OWN_OUTPUT_ANSWER_MIN && AUTH_MARKER_RE.test(ownText);
|
|
39
77
|
}
|
|
40
78
|
|
|
79
|
+
/** The one-line verdict the seat's jsonl carries (#5868): why the runner judged the turn the way
|
|
80
|
+
* it did, phrased exactly like the runner's own "classified X because Y" log — so a pane that
|
|
81
|
+
* scrolls away loses nothing that the telemetry row needs to say. */
|
|
82
|
+
export function verdictFor(realExit, effExit, emptyOutput, ownText) {
|
|
83
|
+
if (realExit === 0 && effExit === 0) return "classified success because exit 0 with CLI output";
|
|
84
|
+
if (realExit === 0 && effExit === 1) {
|
|
85
|
+
if (emptyOutput) return "classified empty-output because exit 0 with no output on either stream";
|
|
86
|
+
const m = AUTH_MARKER_RE.exec(String(ownText || ""));
|
|
87
|
+
return `classified auth because ${m ? m[0] : "auth marker"} in the CLI's own short output`;
|
|
88
|
+
}
|
|
89
|
+
const { reason, matched } = classifyFailure(realExit, String(ownText || ""), emptyOutput);
|
|
90
|
+
return `classified ${reason} because ${matched}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
41
93
|
/** Classify one failed turn. Returns { reason, matched } — matched is the evidence excerpt the
|
|
42
94
|
* runner logs, so the next misclassification is diagnosable from the seat log alone. */
|
|
43
95
|
export function classifyFailure(exit, errText, emptyOutput = false) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trantor",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.32",
|
|
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 && 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"
|
|
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": [
|