trantor 0.18.31 → 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/crew-runner.mjs +66 -11
- package/bin/crew.sh +44 -11
- package/bin/new.mjs +1 -0
- package/hub.mjs +2 -1
- package/lib/classify-failure.mjs +15 -4
- package/package.json +1 -1
|
@@ -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/crew-runner.mjs
CHANGED
|
@@ -382,13 +382,13 @@ async function reportFailure(exit, trigger, undelivered = 0) {
|
|
|
382
382
|
const state = `${down ? "down" : "error"}:${reason}`;
|
|
383
383
|
if (state !== announced) {
|
|
384
384
|
announced = state;
|
|
385
|
-
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(() => {});
|
|
386
386
|
// #5684: a broadcast does not wake anyone — the incident is the operator spotting dead seats
|
|
387
387
|
// before the foreman did, twice in one morning. The same state-change event now goes DIRECT
|
|
388
388
|
// to the project's orchestrator (direct = wake), gated identically so a standing outage says
|
|
389
389
|
// it once. A seat that IS the orchestrator's own runner has nobody above it to wake.
|
|
390
390
|
const orch = `${hostId()}:${PROJ}`;
|
|
391
|
-
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(() => {});
|
|
392
392
|
} else {
|
|
393
393
|
log(`still ${state} (${consecFails} fails) — already announced, staying quiet`);
|
|
394
394
|
}
|
|
@@ -434,7 +434,7 @@ async function notifyAssigners(pairs, text) {
|
|
|
434
434
|
seen.add(f);
|
|
435
435
|
// `re` threads this outcome to the exact contract it answers, so the sender's ledger closes the
|
|
436
436
|
// right one instead of guessing from timing.
|
|
437
|
-
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" };
|
|
438
438
|
if (id) payload.re = id;
|
|
439
439
|
await api("/send", payload).catch(() => {});
|
|
440
440
|
}
|
|
@@ -447,7 +447,7 @@ async function reportHealthy() {
|
|
|
447
447
|
// Recovery is a change too, so the next failure is news again.
|
|
448
448
|
announced = "";
|
|
449
449
|
await api("/register", { session: SESSION, project: PROJ, status: `active in ${PROJ}`, llm: AGENT, model: MODEL }).catch(() => {});
|
|
450
|
-
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(() => {});
|
|
451
451
|
cmuxStatus("ok", "#14b8a6", "check"); herdrAgent("idle");
|
|
452
452
|
}
|
|
453
453
|
|
|
@@ -617,6 +617,57 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
617
617
|
return built.prompt;
|
|
618
618
|
}
|
|
619
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
|
+
|
|
620
671
|
(async () => {
|
|
621
672
|
await loadLessons();
|
|
622
673
|
// start cursor at the CURRENT tip so we don't replay history
|
|
@@ -631,7 +682,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
631
682
|
const { loadOrCreate } = await import("../lib/identity.mjs");
|
|
632
683
|
await sfetchJson(`${HUB}/send`, {
|
|
633
684
|
identity: loadOrCreate(SESSION, "agent"),
|
|
634
|
-
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})` : ""}` },
|
|
635
686
|
signal: AbortSignal.timeout(2500),
|
|
636
687
|
});
|
|
637
688
|
} catch {}
|
|
@@ -640,8 +691,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
640
691
|
// broadcasts batched behind them. Restored from disk first: a runner that was killed mid-turn
|
|
641
692
|
// (or a machine that rebooted) still owes those messages, and the hub will never send them again.
|
|
642
693
|
const restored = loadPending();
|
|
643
|
-
let pendingWake = restored.wake;
|
|
644
|
-
let pendingBcast = restored.bcast;
|
|
694
|
+
let pendingWake = restored.wake.filter(shouldWake);
|
|
695
|
+
let pendingBcast = restored.bcast.filter(m => !isReceipt(m) && !isStatusBroadcast(m));
|
|
645
696
|
let retryAt = 0; // 0 = deliver at the next opportunity
|
|
646
697
|
let deliveryFails = 0; // consecutive failed attempts at the SAME pending batch
|
|
647
698
|
if (pendingWake.length) log(`\x1b[33m${pendingWake.length} message(s) survived from a previous run — redelivering\x1b[0m`);
|
|
@@ -688,6 +739,10 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
688
739
|
// never wake on your own broadcasts: a claude seat's report contains "claude:" and matched the
|
|
689
740
|
// @mention filter, buying one echo turn per report (seen live on the first pulsed orchestrator)
|
|
690
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));
|
|
691
746
|
// #5760 (the night of 08-31): the hub's hourly "same-project-sessions" FYI woke every seat
|
|
692
747
|
// into a real CLI turn — three wedged for hours mid-chatter, one on the metered pool. That
|
|
693
748
|
// kind is pure coordination CONTEXT ("no human needs to relay this" — and no turn needs to
|
|
@@ -695,8 +750,8 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
695
750
|
// warnings still wake — those are actionable by the seat right now.
|
|
696
751
|
const fyi = msgs.filter(m => m.from === "hub:duty" && String(m.text || "").startsWith("🤝 OVERSEER same-project-sessions"));
|
|
697
752
|
const rest = msgs.filter(m => !fyi.includes(m));
|
|
698
|
-
const direct = rest.filter(m => m.to === SESSION);
|
|
699
|
-
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));
|
|
700
755
|
const bcast = [...rest.filter(m => m.to === "all" && !mentions.includes(m)), ...fyi];
|
|
701
756
|
pendingBcast.push(...bcast); // wake-policy: plain broadcasts batch, they don't wake
|
|
702
757
|
const wake = [...direct, ...mentions];
|
|
@@ -708,7 +763,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
708
763
|
const dropped = pendingWake.splice(0, pendingWake.length - PENDING_MAX);
|
|
709
764
|
log(`\x1b[31mundelivered queue overflowed — dropped ${dropped.length} oldest message(s)\x1b[0m`);
|
|
710
765
|
await api("/send", { from: SESSION, to: "all", project: PROJ,
|
|
711
|
-
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(() => {});
|
|
712
767
|
}
|
|
713
768
|
savePending(pendingWake, pendingBcast);
|
|
714
769
|
// Respect an active backoff: a new message during an outage joins the batch, it does not
|
|
@@ -741,7 +796,7 @@ function composedTurn({ base = "", wakeText = "", ctxText = "", againText = "",
|
|
|
741
796
|
// Who is owed an answer, captured BEFORE the turn: pendingWake is cleared on success.
|
|
742
797
|
const assigners = [];
|
|
743
798
|
for (const m of wake) if (m.from && !assigners.some(a => a.from === m.from)) assigners.push({ from: m.from, id: m.id });
|
|
744
|
-
const asked =
|
|
799
|
+
const asked = askedExcerpt(wake[0]);
|
|
745
800
|
const tStart = Date.now();
|
|
746
801
|
const prompt = composedTurn({
|
|
747
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),
|
|
@@ -743,11 +743,19 @@ resolve_model() {
|
|
|
743
743
|
else
|
|
744
744
|
out="$(python3 "$SCROOGE" route --provider "$provider" -t "$task" -d "$diff" --json 2>/dev/null)"
|
|
745
745
|
fi
|
|
746
|
-
[ -n "$out" ]
|
|
747
|
-
|
|
746
|
+
if [ -n "$out" ]; then
|
|
747
|
+
out="$(printf '%s' "$out" | python3 -c 'import json,sys
|
|
748
748
|
try: print(json.load(sys.stdin).get("qualified") or "")
|
|
749
749
|
except Exception: pass' 2>/dev/null)"
|
|
750
|
-
|
|
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; }
|
|
751
759
|
[ "${out%%/*}" = "$provider" ] || {
|
|
752
760
|
echo "[crew] router selected $out outside $provider — refusing cross-provider fallback" >&2
|
|
753
761
|
return 1
|
|
@@ -758,7 +766,11 @@ except Exception: pass' 2>/dev/null)"
|
|
|
758
766
|
epoch_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
|
759
767
|
|
|
760
768
|
# resolve_spec <spec> -> sets AGENT + MODEL globals (live-selects a provider-only spec).
|
|
761
|
-
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=()
|
|
762
774
|
resolve_spec() {
|
|
763
775
|
local SPEC="$1" FIELD
|
|
764
776
|
AGENT="${SPEC%%:*}"; MODEL=""
|
|
@@ -780,11 +792,26 @@ resolve_spec() {
|
|
|
780
792
|
if [ -n "$FIELD" ]; then
|
|
781
793
|
case "$FIELD" in
|
|
782
794
|
*/*) MODEL="$FIELD" ;;
|
|
783
|
-
*) MODEL="$(resolve_model "$AGENT" "$FIELD" "$TASK" "$DIFF")" ||
|
|
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
|
+
}
|
|
784
800
|
echo " → $AGENT: live model $MODEL ($FIELD · $TASK/$DIFF)" ;;
|
|
785
801
|
esac
|
|
786
802
|
fi
|
|
787
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
|
+
}
|
|
788
815
|
# Kill any runner ALREADY serving this exact agent+project before starting another.
|
|
789
816
|
#
|
|
790
817
|
# Without this, `trantor up <agent>` ADDS a runner instead of replacing one — and every duplicate
|
|
@@ -816,7 +843,7 @@ spawn_tmux() { # $@ = specs
|
|
|
816
843
|
# a pre-existing session for THIS project = the crew is already up; add missing seats as new panes.
|
|
817
844
|
tmux has-session -t "$TMUX_SESS" 2>/dev/null && first=0
|
|
818
845
|
for SPEC in "$@"; do
|
|
819
|
-
resolve_spec "$SPEC"
|
|
846
|
+
resolve_spec "$SPEC" || continue
|
|
820
847
|
local cmd; cmd="$(RUN_CMD)"
|
|
821
848
|
local pane=""
|
|
822
849
|
if [ "$first" = "1" ]; then
|
|
@@ -867,7 +894,7 @@ spawn_grid() { # $@ = specs
|
|
|
867
894
|
local ROWS=$(( (N + COLS - 1) / COLS ))
|
|
868
895
|
local CW=$(( GW / COLS )) CH=$(( GH / ROWS )) i=0 SPEC
|
|
869
896
|
for SPEC in "$@"; do
|
|
870
|
-
resolve_spec "$SPEC"
|
|
897
|
+
resolve_spec "$SPEC" || continue
|
|
871
898
|
local cmd; cmd="$(RUN_CMD)"
|
|
872
899
|
local C=$(( i % COLS )) R=$(( i / COLS )) X1 Y1 WID=""
|
|
873
900
|
X1=$(( GX + C * CW )); Y1=$(( GY + R * CH ))
|
|
@@ -952,7 +979,7 @@ spawn_cmux() { # $@ = specs
|
|
|
952
979
|
local surfs=()
|
|
953
980
|
[ -n "$REUSE_WS" ] && wsid="$REUSE_WS"
|
|
954
981
|
for SPEC in "$@"; do
|
|
955
|
-
resolve_spec "$SPEC"
|
|
982
|
+
resolve_spec "$SPEC" || continue
|
|
956
983
|
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
957
984
|
if [ -n "$REUSE_WS" ]; then
|
|
958
985
|
# replace-in-place: split the fresh pane FIRST (targeting the agent's old pane when tracked),
|
|
@@ -1054,7 +1081,7 @@ OSA
|
|
|
1054
1081
|
fi
|
|
1055
1082
|
[ -n "$REUSE_TAB" ] && { tabid="$REUSE_TAB"; echo " → reusing existing crew workspace for $PROJ ($tabid)"; }
|
|
1056
1083
|
for SPEC in "$@"; do
|
|
1057
|
-
resolve_spec "$SPEC"
|
|
1084
|
+
resolve_spec "$SPEC" || continue
|
|
1058
1085
|
local cmd launcher; cmd="$(RUN_CMD)"; launcher="$(_seat_launcher "$AGENT" "$cmd")"
|
|
1059
1086
|
# In REUSE mode, replace-in-place: split off the agent's old terminal when tracked, close it after.
|
|
1060
1087
|
local OLD_SURF=""
|
|
@@ -1175,7 +1202,11 @@ echo "— bringing up crew for $PROJ ($CREW_UI) —"
|
|
|
1175
1202
|
SPAWN_EPOCH=$(epoch_ms)
|
|
1176
1203
|
spawn_crew "$@"
|
|
1177
1204
|
|
|
1178
|
-
if [ "$DRY" = "1" ]; then
|
|
1205
|
+
if [ "$DRY" = "1" ]; then
|
|
1206
|
+
echo "— dry run: no bus verify —"
|
|
1207
|
+
report_skipped_seats
|
|
1208
|
+
exit $?
|
|
1209
|
+
fi
|
|
1179
1210
|
echo "— verifying on the bus (the spawn is not the truth; the bus is) —"
|
|
1180
1211
|
AGENTS_ONLY=$(for a in "$@"; do printf "%s " "${a%%:*}"; done)
|
|
1181
1212
|
VER=$(node "$BUS_DIR/bin/crew-verify.mjs" "$PROJ" $AGENTS_ONLY --since "$SPAWN_EPOCH" --timeout 30)
|
|
@@ -1196,3 +1227,5 @@ if [ -n "${RETRY// }" ]; then
|
|
|
1196
1227
|
fi
|
|
1197
1228
|
fi
|
|
1198
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
|
@@ -131,6 +131,7 @@ try {
|
|
|
131
131
|
// do we fall back to the plain TOFU enroll.
|
|
132
132
|
const viaOwner = await enrollViaOwnerInvite(hub, identity, name, { timeoutMs: 8000 });
|
|
133
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}`);
|
|
134
135
|
const briefForHub = (brief || `Genesis of ${name} — created by trantor new.`).slice(0, 600);
|
|
135
136
|
const r1 = await signedPost("/project", { project: name, brief: briefForHub, by: session }, { session, project: name, timeoutMs: 8000 });
|
|
136
137
|
if (!r1.ok) throw new Error(`hub ${r1.status} on /project${r1.json?.error ? `: ${r1.json.error}` : ""}`);
|
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
|
@@ -38,16 +38,27 @@ export function stripPromptEcho(errText, promptText) {
|
|
|
38
38
|
const text = String(errText || "");
|
|
39
39
|
if (!promptText) return text;
|
|
40
40
|
const norm = (l) => String(l).replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").replace(/\s+/g, " ").trim();
|
|
41
|
-
const
|
|
42
|
-
if (!
|
|
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(" ");
|
|
43
50
|
const hasRun = (p, n) => {
|
|
44
51
|
for (let i = 0; i + ECHO_RUN <= p.length; i++) if (n.includes(p.slice(i, i + ECHO_RUN))) return true;
|
|
45
52
|
return false;
|
|
46
53
|
};
|
|
47
54
|
return text.split("\n").filter(line => {
|
|
48
55
|
const n = norm(line);
|
|
49
|
-
|
|
50
|
-
|
|
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));
|
|
51
62
|
return !prompts.some(p => n.includes(p) || p.includes(n) || hasRun(p, n));
|
|
52
63
|
}).join("\n");
|
|
53
64
|
}
|