@spinabot/brigade 1.14.0 → 1.16.0
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/convex/logs.d.ts +6 -6
- package/convex/schema.d.ts +3 -3
- package/dist/buildstamp.json +1 -1
- package/dist/cli/commands/connect-transcript.d.ts +39 -0
- package/dist/cli/commands/connect-transcript.d.ts.map +1 -0
- package/dist/cli/commands/connect-transcript.js +60 -0
- package/dist/cli/commands/connect-transcript.js.map +1 -0
- package/dist/cli/commands/connect.d.ts.map +1 -1
- package/dist/cli/commands/connect.js +315 -169
- package/dist/cli/commands/connect.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +170 -14
- package/dist/core/server.js.map +1 -1
- package/dist/protocol/errors.d.ts +14 -0
- package/dist/protocol/errors.d.ts.map +1 -1
- package/dist/protocol/errors.js +14 -0
- package/dist/protocol/errors.js.map +1 -1
- package/dist/protocol/handshake.d.ts +10 -0
- package/dist/protocol/handshake.d.ts.map +1 -1
- package/dist/protocol/stream-seq.d.ts +30 -0
- package/dist/protocol/stream-seq.d.ts.map +1 -0
- package/dist/protocol/stream-seq.js +38 -0
- package/dist/protocol/stream-seq.js.map +1 -0
- package/dist/protocol.d.ts +265 -6
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +95 -5
- package/dist/protocol.js.map +1 -1
- package/dist/tui/client.d.ts +67 -2
- package/dist/tui/client.d.ts.map +1 -1
- package/dist/tui/client.js +106 -2
- package/dist/tui/client.js.map +1 -1
- package/package.json +1 -1
|
@@ -36,6 +36,7 @@ import { summarizeToolResult } from "../../ui/tool-result.js";
|
|
|
36
36
|
import { BrigadeClient } from "../../tui/client.js";
|
|
37
37
|
import { loadConfig } from "../../core/config.js";
|
|
38
38
|
import { resolveClientToken } from "../../core/gateway-auth.js";
|
|
39
|
+
import { asstKey, clipOneLine, extractUserText, joinToolResultText } from "./connect-transcript.js";
|
|
39
40
|
import { ApprovalPrompt } from "../../tui/approval-prompt.js";
|
|
40
41
|
import { computeExplain, filterGraphToSubtree, formatExplain, parseOrgSlash, renderDepartmentsOnly, } from "./org-slash.js";
|
|
41
42
|
import { renderPrideChartWithPins, BRIGADE_FOOTER_RULE, } from "../../agents/org/pride-template.js";
|
|
@@ -339,17 +340,26 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
339
340
|
...(boundSessionKey !== undefined ? { sessionKey: boundSessionKey } : {}),
|
|
340
341
|
...extra,
|
|
341
342
|
});
|
|
342
|
-
// Streaming-assistant buffers keyed by
|
|
343
|
-
//
|
|
344
|
-
//
|
|
345
|
-
// `message_update`
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
343
|
+
// Streaming-assistant buffers keyed by MESSAGE IDENTITY, not arrival
|
|
344
|
+
// position. The key is `${depth}:${timestamp}` — Pi stamps each assistant
|
|
345
|
+
// message with a stable `timestamp` at creation that is constant across all
|
|
346
|
+
// its `message_update`s and its `message_end`, and a NEW message (e.g. the
|
|
347
|
+
// continuation after a tool call) gets a NEW timestamp. So each logical
|
|
348
|
+
// message owns exactly one growing Markdown block, and a block lands where
|
|
349
|
+
// its message belongs in the stream — never above a tool it came after, and
|
|
350
|
+
// a late/duplicate update for an earlier message updates THAT block in place
|
|
351
|
+
// instead of spawning a misplaced copy. This identity keying is also what
|
|
352
|
+
// makes `resume` idempotent: re-applying a message the client already has
|
|
353
|
+
// resolves to the same block. Depth keeps sub-agent (≥1) streams from
|
|
354
|
+
// colliding with the top-level (0) stream. Cleared wholesale on `agent_end`
|
|
355
|
+
// / abort (turn boundary). `pendingTools` is already identity-keyed by the
|
|
356
|
+
// tool call id.
|
|
350
357
|
const activeAssistants = new Map();
|
|
351
358
|
let activeLoader = null;
|
|
352
359
|
const pendingTools = new Map();
|
|
360
|
+
// `asstKey` (identity key for an assistant block) is imported from
|
|
361
|
+
// `connect-transcript.js` so the live path + the resume rebuild + the unit
|
|
362
|
+
// tests all share one definition.
|
|
353
363
|
// Streaming render coalescer. Every `setText()` on the streaming Markdown
|
|
354
364
|
// widget invalidates the parser cache, so each paint re-parses the FULL
|
|
355
365
|
// growing reply (Marked + line-wrap + ANSI styling). At 60Hz that's a
|
|
@@ -734,6 +744,230 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
734
744
|
parts.push(contentText);
|
|
735
745
|
return parts.join("\n\n");
|
|
736
746
|
};
|
|
747
|
+
/* ───────────────────── reliable-streaming recovery ───────────────────── */
|
|
748
|
+
// The transcript is the single source of truth. `resume` returns it; we
|
|
749
|
+
// clear the rendered region and rebuild from it, so a (re)connect or a
|
|
750
|
+
// detected seq gap heals with nothing missing, duplicated, or misplaced.
|
|
751
|
+
// Static renderers below share the SAME identity keys as the live path
|
|
752
|
+
// (`asstKey` for assistant blocks, `pendingTools` for tools), so a live
|
|
753
|
+
// `message_update` that arrives after the rebuild updates the rebuilt block
|
|
754
|
+
// in place rather than spawning a copy.
|
|
755
|
+
// `extractUserText`, `joinToolResultText`, `clipOneLine` are imported from
|
|
756
|
+
// `connect-transcript.js` (pure + unit-tested).
|
|
757
|
+
/** Remove the rendered transcript (everything between the header+divider
|
|
758
|
+
* chrome at indices 0/1 and the editor); the editor + trailing chrome stay.
|
|
759
|
+
* Resets the streaming maps so the rebuild starts clean. */
|
|
760
|
+
const clearTranscriptRegion = () => {
|
|
761
|
+
const children = tui.children;
|
|
762
|
+
const editorIdx = children.indexOf(editor);
|
|
763
|
+
if (editorIdx <= 2) {
|
|
764
|
+
// Nothing rendered between the chrome and the editor yet.
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
for (const c of children.slice(2, editorIdx))
|
|
768
|
+
removeChild(c);
|
|
769
|
+
}
|
|
770
|
+
activeAssistants.clear();
|
|
771
|
+
pendingTools.clear();
|
|
772
|
+
if (activeLoader) {
|
|
773
|
+
removeChild(activeLoader);
|
|
774
|
+
activeLoader = null;
|
|
775
|
+
}
|
|
776
|
+
// Drop any showing approval prompt — `resume`'s `pendingApprovals` is the
|
|
777
|
+
// authoritative pending set and re-renders it (so a resolved-while-away
|
|
778
|
+
// prompt vanishes, and a still-pending one comes back answerable).
|
|
779
|
+
if (activePrompt) {
|
|
780
|
+
try {
|
|
781
|
+
tui.removeChild(activePrompt);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
/* ignore */
|
|
785
|
+
}
|
|
786
|
+
activePrompt = null;
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
/** Render ONE persisted transcript message as final (static) blocks, using
|
|
790
|
+
* the same identity keys the live path uses. */
|
|
791
|
+
const renderTranscriptMessage = (m) => {
|
|
792
|
+
if (!m || typeof m !== "object")
|
|
793
|
+
return;
|
|
794
|
+
if (m.role === "user") {
|
|
795
|
+
const text = scrubRenderable(extractUserText(m)).trim();
|
|
796
|
+
if (text) {
|
|
797
|
+
insertBeforeEditor(new Markdown(`${brand.user("you")} ${text}`, 1, 0, markdownTheme));
|
|
798
|
+
}
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
if (m.role === "assistant") {
|
|
802
|
+
const text = scrubRenderable(extractAssistantText(m));
|
|
803
|
+
if (text) {
|
|
804
|
+
const label = lastSnapshot?.agentName ?? "brigade";
|
|
805
|
+
const block = new Markdown(`${brand.agent(label)} ${text}`, 1, 0, markdownTheme);
|
|
806
|
+
activeAssistants.set(asstKey(0, m), block);
|
|
807
|
+
insertBeforeEditor(block);
|
|
808
|
+
}
|
|
809
|
+
// Tool calls embedded in the assistant message → pending indicators;
|
|
810
|
+
// the matching toolResult message (below) fills in the ✓/✗ + preview.
|
|
811
|
+
if (Array.isArray(m.content)) {
|
|
812
|
+
for (const b of m.content) {
|
|
813
|
+
if (b?.type === "toolCall" && typeof b.id === "string") {
|
|
814
|
+
const indicator = new Text(` ${brand.tool("⚡")} ${brand.tool(typeof b.name === "string" ? b.name : "tool")}`, 0, 0);
|
|
815
|
+
pendingTools.set(b.id, indicator);
|
|
816
|
+
insertBeforeEditor(indicator);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (m.role === "toolResult" && typeof m.toolCallId === "string") {
|
|
823
|
+
const mark = m.isError ? brand.error("✗") : brand.tool("✓");
|
|
824
|
+
const name = typeof m.toolName === "string" ? m.toolName : "tool";
|
|
825
|
+
// Join → scrub terminal escapes → collapse/clip to one line.
|
|
826
|
+
const clipped = clipOneLine(scrubRenderable(joinToolResultText(m.content)));
|
|
827
|
+
const preview = clipped ? ` ${brand.dim(`· ${clipped}`)}` : "";
|
|
828
|
+
const line = ` ${mark} ${brand.tool(name)}${preview}`;
|
|
829
|
+
const indicator = pendingTools.get(m.toolCallId);
|
|
830
|
+
if (indicator) {
|
|
831
|
+
indicator.setText(line);
|
|
832
|
+
pendingTools.delete(m.toolCallId);
|
|
833
|
+
}
|
|
834
|
+
else {
|
|
835
|
+
insertBeforeEditor(new Text(line, 0, 0));
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
/** Render an inline approval card the operator can answer. Shared by the
|
|
841
|
+
* live `approval-request` handler AND `resume` recovery (so a prompt that
|
|
842
|
+
* arrived / was missed during a disconnect comes back answerable instead of
|
|
843
|
+
* hanging the turn to auto-deny). */
|
|
844
|
+
const renderApprovalPrompt = (req) => {
|
|
845
|
+
// Only one prompt at a time (exec-gate is serial per turn).
|
|
846
|
+
if (activePrompt) {
|
|
847
|
+
try {
|
|
848
|
+
tui.removeChild(activePrompt);
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
/* ignore */
|
|
852
|
+
}
|
|
853
|
+
activePrompt = null;
|
|
854
|
+
}
|
|
855
|
+
const prompt = new ApprovalPrompt({
|
|
856
|
+
tui,
|
|
857
|
+
request: {
|
|
858
|
+
id: req.id,
|
|
859
|
+
command: req.command,
|
|
860
|
+
toolName: req.toolName,
|
|
861
|
+
cwd: req.cwd,
|
|
862
|
+
...(req.subagentLabel !== undefined ? { subagentLabel: req.subagentLabel } : {}),
|
|
863
|
+
...(req.subagentDepth !== undefined ? { subagentDepth: req.subagentDepth } : {}),
|
|
864
|
+
...(req.parentRunId !== undefined ? { parentRunId: req.parentRunId } : {}),
|
|
865
|
+
},
|
|
866
|
+
onResolve: (resolution) => {
|
|
867
|
+
if (activePrompt) {
|
|
868
|
+
try {
|
|
869
|
+
tui.removeChild(activePrompt);
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
/* ignore */
|
|
873
|
+
}
|
|
874
|
+
activePrompt = null;
|
|
875
|
+
}
|
|
876
|
+
tui.setFocus(editor);
|
|
877
|
+
insertBeforeEditor(new Text(decisionConfirmation(req.command, resolution, req.subagentDepth), 0, 0));
|
|
878
|
+
tui.requestRender();
|
|
879
|
+
void client
|
|
880
|
+
.request("approval-resolve", {
|
|
881
|
+
id: req.id,
|
|
882
|
+
decision: resolution.decision,
|
|
883
|
+
pattern: resolution.pattern,
|
|
884
|
+
})
|
|
885
|
+
.catch((err) => {
|
|
886
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
887
|
+
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`approval send failed: ${msg}`)}`, 0, 0));
|
|
888
|
+
});
|
|
889
|
+
},
|
|
890
|
+
});
|
|
891
|
+
activePrompt = prompt;
|
|
892
|
+
insertBeforeEditor(prompt);
|
|
893
|
+
tui.setFocus(prompt);
|
|
894
|
+
tui.requestRender();
|
|
895
|
+
};
|
|
896
|
+
/** Render one `system-event` notice (cron announce / channel-health) as a
|
|
897
|
+
* visible Brigade-side line. Shared by the live handler + `resume` recovery. */
|
|
898
|
+
const renderSystemEventLine = (event) => {
|
|
899
|
+
const eventText = scrubRenderable(event.text);
|
|
900
|
+
const isCron = event.source === "cron" || event.jobName !== undefined;
|
|
901
|
+
if (isCron) {
|
|
902
|
+
const name = event.jobName ?? "cron";
|
|
903
|
+
const heading = brand.amber(`🦁 [cron "${name}"]`);
|
|
904
|
+
let suffix = "";
|
|
905
|
+
if (event.delivered === true)
|
|
906
|
+
suffix = ` ${brand.dim("· delivered")}`;
|
|
907
|
+
else if (event.delivered === false)
|
|
908
|
+
suffix = ` ${brand.dim("· not delivered (TUI only)")}`;
|
|
909
|
+
insertBeforeEditor(new Text(`${heading} ${eventText}${suffix}`, 0, 0));
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
insertBeforeEditor(new Text(`${brand.amber("🦁")} ${eventText}`, 0, 0));
|
|
913
|
+
}
|
|
914
|
+
tui.requestRender();
|
|
915
|
+
};
|
|
916
|
+
/** Resume the bound session and rebuild the transcript from the gateway's
|
|
917
|
+
* source of truth. Safe (and idempotent) on first connect (loads history),
|
|
918
|
+
* reconnect (backfills the gap + clears stale spinners), and `"resync"`
|
|
919
|
+
* (fills a mid-stream drop). Best-effort: on failure the current view stays
|
|
920
|
+
* and the next live frame refreshes it. */
|
|
921
|
+
// Serialize resumes: a reconnect AND a gap-resync can both fire, and live
|
|
922
|
+
// frames can trigger more while one is in flight. Run at most one at a time
|
|
923
|
+
// and coalesce concurrent requests into a single follow-up rebuild — no
|
|
924
|
+
// overlapping clears/rebuilds against the shared render maps.
|
|
925
|
+
let resumeInFlight = false;
|
|
926
|
+
let resumePending = false;
|
|
927
|
+
const doResume = async () => {
|
|
928
|
+
if (resumeInFlight) {
|
|
929
|
+
resumePending = true;
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
resumeInFlight = true;
|
|
933
|
+
try {
|
|
934
|
+
let snap;
|
|
935
|
+
try {
|
|
936
|
+
snap = await client.resume(withBinding());
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (!snap)
|
|
942
|
+
return;
|
|
943
|
+
clearTranscriptRegion();
|
|
944
|
+
const messages = Array.isArray(snap.messages) ? snap.messages : [];
|
|
945
|
+
for (const m of messages)
|
|
946
|
+
renderTranscriptMessage(m);
|
|
947
|
+
// Recover the non-transcript events too ("nothing lost"): recent
|
|
948
|
+
// system-event notices, then any tool-approval prompts STILL pending
|
|
949
|
+
// on this session — re-rendered answerable, so a prompt that arrived
|
|
950
|
+
// or was missed during the disconnect doesn't strand the turn.
|
|
951
|
+
for (const ev of snap.recentSystemEvents ?? [])
|
|
952
|
+
renderSystemEventLine(ev);
|
|
953
|
+
for (const appr of snap.pendingApprovals ?? [])
|
|
954
|
+
renderApprovalPrompt(appr);
|
|
955
|
+
if (snap.snapshot) {
|
|
956
|
+
lastSnapshot = snap.snapshot;
|
|
957
|
+
if (!snap.snapshot.isAgentRunning)
|
|
958
|
+
isAgentRunning = false;
|
|
959
|
+
}
|
|
960
|
+
updateHeader();
|
|
961
|
+
tui.requestRender();
|
|
962
|
+
}
|
|
963
|
+
finally {
|
|
964
|
+
resumeInFlight = false;
|
|
965
|
+
if (resumePending) {
|
|
966
|
+
resumePending = false;
|
|
967
|
+
void doResume();
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
};
|
|
737
971
|
/**
|
|
738
972
|
* Format an elapsed-millisecond duration into a compact label for the
|
|
739
973
|
* status line — `12s` / `1m 4s` / `2h 3m`. Brigade keeps the loader
|
|
@@ -806,7 +1040,12 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
806
1040
|
// their own applySubscription() from their handlers below.
|
|
807
1041
|
if (!seededSubscription && (boundAgentId !== undefined || boundSessionKey !== undefined)) {
|
|
808
1042
|
seededSubscription = true;
|
|
809
|
-
|
|
1043
|
+
// Subscribe to the bound lane, THEN resume to load this session's
|
|
1044
|
+
// transcript (history) from the gateway's source of truth. Runs once
|
|
1045
|
+
// per connection (the gate); reconnects drive their own resume below.
|
|
1046
|
+
void applySubscription().finally(() => {
|
|
1047
|
+
void doResume();
|
|
1048
|
+
});
|
|
810
1049
|
}
|
|
811
1050
|
updateHeader();
|
|
812
1051
|
});
|
|
@@ -820,70 +1059,13 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
820
1059
|
let activePrompt = null;
|
|
821
1060
|
client.on("approval-request", (req) => {
|
|
822
1061
|
// Wave N3 (bug #3) — defence-in-depth: drop approval prompts that
|
|
823
|
-
// don't belong to the lane this TUI is bound to.
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
// this, but a race between /agent rebind + the next gated-tool
|
|
827
|
-
// frame can still leak a stale one here.
|
|
1062
|
+
// don't belong to the lane this TUI is bound to. Server-side subscribe
|
|
1063
|
+
// should already filter this, but a race between /agent rebind + the
|
|
1064
|
+
// next gated-tool frame can still leak a stale one here.
|
|
828
1065
|
if (isOffLane(req.agentId, req.sessionId)) {
|
|
829
1066
|
return;
|
|
830
1067
|
}
|
|
831
|
-
|
|
832
|
-
// because exec-gate is serial per-turn), tear it down first so we
|
|
833
|
-
// don't stack prompts on the screen.
|
|
834
|
-
if (activePrompt) {
|
|
835
|
-
try {
|
|
836
|
-
tui.removeChild(activePrompt);
|
|
837
|
-
}
|
|
838
|
-
catch {
|
|
839
|
-
/* ignore */
|
|
840
|
-
}
|
|
841
|
-
activePrompt = null;
|
|
842
|
-
}
|
|
843
|
-
const prompt = new ApprovalPrompt({
|
|
844
|
-
tui,
|
|
845
|
-
request: {
|
|
846
|
-
id: req.id,
|
|
847
|
-
command: req.command,
|
|
848
|
-
toolName: req.toolName,
|
|
849
|
-
cwd: req.cwd,
|
|
850
|
-
...(req.subagentLabel !== undefined ? { subagentLabel: req.subagentLabel } : {}),
|
|
851
|
-
...(req.subagentDepth !== undefined ? { subagentDepth: req.subagentDepth } : {}),
|
|
852
|
-
...(req.parentRunId !== undefined ? { parentRunId: req.parentRunId } : {}),
|
|
853
|
-
},
|
|
854
|
-
onResolve: (resolution) => {
|
|
855
|
-
// Clear the prompt and hand focus back to the editor BEFORE
|
|
856
|
-
// firing the resolve — so the next agent_start event (which
|
|
857
|
-
// will follow on allow) doesn't fight the prompt for focus.
|
|
858
|
-
if (activePrompt) {
|
|
859
|
-
try {
|
|
860
|
-
tui.removeChild(activePrompt);
|
|
861
|
-
}
|
|
862
|
-
catch {
|
|
863
|
-
/* ignore */
|
|
864
|
-
}
|
|
865
|
-
activePrompt = null;
|
|
866
|
-
}
|
|
867
|
-
tui.setFocus(editor);
|
|
868
|
-
const confirmation = decisionConfirmation(req.command, resolution, req.subagentDepth);
|
|
869
|
-
insertBeforeEditor(new Text(confirmation, 0, 0));
|
|
870
|
-
tui.requestRender();
|
|
871
|
-
void client
|
|
872
|
-
.request("approval-resolve", {
|
|
873
|
-
id: req.id,
|
|
874
|
-
decision: resolution.decision,
|
|
875
|
-
pattern: resolution.pattern,
|
|
876
|
-
})
|
|
877
|
-
.catch((err) => {
|
|
878
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
879
|
-
insertBeforeEditor(new Text(` ${brand.error("✗")} ${brand.error(`approval send failed: ${msg}`)}`, 0, 0));
|
|
880
|
-
});
|
|
881
|
-
},
|
|
882
|
-
});
|
|
883
|
-
activePrompt = prompt;
|
|
884
|
-
insertBeforeEditor(prompt);
|
|
885
|
-
tui.setFocus(prompt);
|
|
886
|
-
tui.requestRender();
|
|
1068
|
+
renderApprovalPrompt(req);
|
|
887
1069
|
});
|
|
888
1070
|
// Server-side warnings/info (e.g. "primary failed, trying fallback") — the
|
|
889
1071
|
// gateway emits these via the wrapper-chain callbacks. Mirror to the TUI
|
|
@@ -917,29 +1099,7 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
917
1099
|
// for another agent shouldn't surface on this operator's connect TUI.
|
|
918
1100
|
if (isOffLane(event.agentId, event.sessionId))
|
|
919
1101
|
return;
|
|
920
|
-
|
|
921
|
-
// `scrubRenderable`). A cron `system-event` carries the cron run's
|
|
922
|
-
// MODEL-GENERATED reply verbatim (server enqueueSystemEvent), so this
|
|
923
|
-
// is an attacker-influenceable path even though it's not direct bash.
|
|
924
|
-
const eventText = scrubRenderable(event.text);
|
|
925
|
-
const isCron = event.source === "cron" || event.jobName !== undefined;
|
|
926
|
-
if (isCron) {
|
|
927
|
-
const name = event.jobName ?? "cron";
|
|
928
|
-
const heading = brand.amber(`🦁 [cron "${name}"]`);
|
|
929
|
-
let suffix = "";
|
|
930
|
-
if (event.delivered === true) {
|
|
931
|
-
suffix = ` ${brand.dim("· delivered")}`;
|
|
932
|
-
}
|
|
933
|
-
else if (event.delivered === false) {
|
|
934
|
-
suffix = ` ${brand.dim("· not delivered (TUI only)")}`;
|
|
935
|
-
}
|
|
936
|
-
insertBeforeEditor(new Text(`${heading} ${eventText}${suffix}`, 0, 0));
|
|
937
|
-
}
|
|
938
|
-
else {
|
|
939
|
-
const heading = brand.amber("🦁");
|
|
940
|
-
insertBeforeEditor(new Text(`${heading} ${eventText}`, 0, 0));
|
|
941
|
-
}
|
|
942
|
-
tui.requestRender();
|
|
1102
|
+
renderSystemEventLine(event);
|
|
943
1103
|
});
|
|
944
1104
|
// Pi events are forwarded as `{ event: <pi event>, subagentDepth? }`.
|
|
945
1105
|
// Same render logic as src/ui/chat.ts but stripped of in-process state
|
|
@@ -992,12 +1152,15 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
992
1152
|
const label = lastSnapshot?.agentName ?? "brigade";
|
|
993
1153
|
const labelPrefix = depth > 0 ? "sub-agent" : label;
|
|
994
1154
|
const renderedText = `${subIndent}${brand.agent(labelPrefix)} ${text}`;
|
|
995
|
-
//
|
|
996
|
-
//
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
// the
|
|
1000
|
-
|
|
1155
|
+
// Identity-keyed streaming block (see `asstKey`). Each logical
|
|
1156
|
+
// message — top-level or sub-agent — owns ONE growing Markdown
|
|
1157
|
+
// block, resolved by `${depth}:${timestamp}`. A continuation after
|
|
1158
|
+
// a tool call is a new message (new timestamp) → a fresh block
|
|
1159
|
+
// that lands BELOW the tool; a late/duplicate update for an
|
|
1160
|
+
// earlier message resolves to its existing block and updates it in
|
|
1161
|
+
// place, so nothing is ever misplaced or duplicated.
|
|
1162
|
+
const key = asstKey(depth, msg);
|
|
1163
|
+
const existing = activeAssistants.get(key);
|
|
1001
1164
|
if (!existing) {
|
|
1002
1165
|
// Wave N5 (bug #6) — origin attribution. When this turn is
|
|
1003
1166
|
// running on a non-`main` session (e.g. a channel-routed
|
|
@@ -1016,7 +1179,7 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
1016
1179
|
insertBeforeEditor(new Text(`${subIndent}${brand.dim(`↳ via ${originLabel}`)}`, 0, 0));
|
|
1017
1180
|
}
|
|
1018
1181
|
const fresh = new Markdown(renderedText, 1, 0, markdownTheme);
|
|
1019
|
-
activeAssistants.set(
|
|
1182
|
+
activeAssistants.set(key, fresh);
|
|
1020
1183
|
insertBeforeEditor(fresh);
|
|
1021
1184
|
}
|
|
1022
1185
|
else {
|
|
@@ -1040,25 +1203,40 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
1040
1203
|
removeChild(activeLoader);
|
|
1041
1204
|
activeLoader = null;
|
|
1042
1205
|
}
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1046
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
|
|
1052
|
-
//
|
|
1053
|
-
// stream — flush any pending debounced paint so the assistant
|
|
1054
|
-
// block above renders its full text BEFORE the tool indicator
|
|
1055
|
-
// lands underneath.
|
|
1206
|
+
// NOTE: we no longer delete any assistant block here. With
|
|
1207
|
+
// identity keying (`asstKey`), the post-tool continuation is a NEW
|
|
1208
|
+
// message with a NEW timestamp, so it naturally opens a fresh block
|
|
1209
|
+
// that lands BELOW this tool — while a late update for the
|
|
1210
|
+
// pre-tool message still resolves to its own (earlier) block and
|
|
1211
|
+
// updates in place. The old `activeAssistants.delete(depth)` hack
|
|
1212
|
+
// (forcing a fresh block by position) is exactly what let a
|
|
1213
|
+
// reordered/duplicate pre-tool update spawn a misplaced copy; it's
|
|
1214
|
+
// gone. We still flush any pending debounced paint so the assistant
|
|
1215
|
+
// text above renders in full BEFORE the tool indicator lands.
|
|
1056
1216
|
flushStreamingRender();
|
|
1057
1217
|
const indicator = new Text(`${subIndent} ${brand.tool("⚡")} ${brand.tool(event.toolName)}`, 0, 0);
|
|
1058
1218
|
pendingTools.set(event.toolCallId, indicator);
|
|
1059
1219
|
insertBeforeEditor(indicator);
|
|
1060
1220
|
break;
|
|
1061
1221
|
}
|
|
1222
|
+
case "tool_execution_update": {
|
|
1223
|
+
// LIVE tool output. Pi streams a tool's accumulating result via
|
|
1224
|
+
// `onUpdate` (e.g. `bash` fires stdout/stderr as it runs); the
|
|
1225
|
+
// gateway forwards each as a `tool_execution_update`. Repaint the
|
|
1226
|
+
// ⚡ chip with the running preview so the operator watches the tool
|
|
1227
|
+
// work in real time instead of a static spinner. The repaint is
|
|
1228
|
+
// coalesced through the streaming debouncer so a chatty command
|
|
1229
|
+
// can't thrash the terminal.
|
|
1230
|
+
const liveIndicator = pendingTools.get(event.toolCallId);
|
|
1231
|
+
if (liveIndicator) {
|
|
1232
|
+
const summary = summarizeToolResult(event.partialResult, { preserveNewlines: false });
|
|
1233
|
+
const previewText = scrubRenderable(summary.preview);
|
|
1234
|
+
const tail = summary.hasContent ? ` ${brand.dim(`· ${previewText}`)}` : "";
|
|
1235
|
+
liveIndicator.setText(`${subIndent} ${brand.tool("⚡")} ${brand.tool(event.toolName)}${tail}`);
|
|
1236
|
+
scheduleStreamingRender();
|
|
1237
|
+
}
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1062
1240
|
case "tool_execution_end": {
|
|
1063
1241
|
const indicator = pendingTools.get(event.toolCallId);
|
|
1064
1242
|
if (indicator) {
|
|
@@ -1182,64 +1360,32 @@ export async function wireConnectUi(tui, client, initialAgentId) {
|
|
|
1182
1360
|
// a "stuck" indicator that's purely a TUI state-staleness bug, not an
|
|
1183
1361
|
// actual hang.
|
|
1184
1362
|
client.on("reconnected", () => {
|
|
1185
|
-
|
|
1186
|
-
//
|
|
1187
|
-
//
|
|
1188
|
-
//
|
|
1189
|
-
//
|
|
1190
|
-
//
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
//
|
|
1195
|
-
// First clear the last-committed sub mirror: the fresh connection has NO
|
|
1196
|
-
// prior server-side subscription, so leaving these set would make
|
|
1197
|
-
// applySubscription() fire a spurious `unsubscribe` for a sub this
|
|
1198
|
-
// connection never had. Reset → re-subscribe is the correct sequence.
|
|
1363
|
+
// BrigadeClient opened a BRAND-NEW socket on reconnect, so the gateway
|
|
1364
|
+
// assigned a fresh conn id whose per-connection subscription Sets are
|
|
1365
|
+
// EMPTY. Reset the sub mirror (the fresh connection has no prior
|
|
1366
|
+
// server-side subscription, so leaving these set would fire a spurious
|
|
1367
|
+
// `unsubscribe`), re-subscribe to the bound lane, THEN resume — which
|
|
1368
|
+
// rebuilds the transcript from the gateway's source of truth. The rebuild
|
|
1369
|
+
// backfills every `pi` frame missed while disconnected AND clears any
|
|
1370
|
+
// stale tool spinners (each tool re-renders with its actual ✓/✗ outcome),
|
|
1371
|
+
// so there's no separate orphan-reconcile step — the
|
|
1372
|
+
// missing-after-tool / needs-a-refresh class of bug is gone.
|
|
1199
1373
|
lastSubscribedAgentId = undefined;
|
|
1200
1374
|
lastSubscribedSessionKey = undefined;
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
isAgentRunning = false;
|
|
1216
|
-
// If the gateway says no turn is in flight, then any tool
|
|
1217
|
-
// indicators we still hold are stale (their `tool_execution_end`
|
|
1218
|
-
// landed while we were disconnected). Mark each one as
|
|
1219
|
-
// completed-with-no-known-outcome so the TUI stops spinning.
|
|
1220
|
-
if (!snap.isAgentRunning && pendingTools.size > 0) {
|
|
1221
|
-
// Reconcile orphaned tool indicators by marking each as
|
|
1222
|
-
// completed-with-unknown-outcome. We don't know which tool's
|
|
1223
|
-
// `tool_execution_end` was missed during the disconnect, so we
|
|
1224
|
-
// can't infer the exit status; the dim ⋯ glyph signals "this
|
|
1225
|
-
// tool finished, but the TUI didn't see how" and stops the spin.
|
|
1226
|
-
for (const [, indicator] of pendingTools) {
|
|
1227
|
-
indicator.setText(` ${brand.dim("⋯ tool completed during disconnect")}`);
|
|
1228
|
-
}
|
|
1229
|
-
pendingTools.clear();
|
|
1230
|
-
}
|
|
1231
|
-
updateHeader();
|
|
1232
|
-
tui.requestRender();
|
|
1233
|
-
}, () => {
|
|
1234
|
-
/* best-effort — silently ignore */
|
|
1235
|
-
}).then(() => {
|
|
1236
|
-
// Re-subscribe the bound agent/session on the fresh connection.
|
|
1237
|
-
// Runs after get-state settles (the rejection handler above swallows
|
|
1238
|
-
// errors, so this chains in both cases) — deterministic ordering.
|
|
1239
|
-
// This also triggers the server's subscribe-time per-binding snapshot
|
|
1240
|
-
// push, restoring the correct header for a non-boot binding.
|
|
1241
|
-
void applySubscription();
|
|
1242
|
-
});
|
|
1375
|
+
void (async () => {
|
|
1376
|
+
await applySubscription();
|
|
1377
|
+
await doResume();
|
|
1378
|
+
// Notice lands AFTER the rebuild — else clearTranscriptRegion wipes it.
|
|
1379
|
+
insertBeforeEditor(new Text(` ${brand.dim("↻ reconnected to gateway")}`, 0, 0));
|
|
1380
|
+
})();
|
|
1381
|
+
});
|
|
1382
|
+
// Mid-stream gap recovery. BrigadeClient emits "resync" when it detects a
|
|
1383
|
+
// seq gap on the ordered `pi` stream — a frame dropped under backpressure or
|
|
1384
|
+
// reordered, or the gateway restarted and reset its counters. Resume to
|
|
1385
|
+
// rebuild from the transcript so the live view self-heals with no missing or
|
|
1386
|
+
// misplaced messages and WITHOUT waiting for a reconnect or a manual refresh.
|
|
1387
|
+
client.on("resync", () => {
|
|
1388
|
+
void doResume();
|
|
1243
1389
|
});
|
|
1244
1390
|
// Switch the live session onto a CONFIGURED provider by reusing the same
|
|
1245
1391
|
// `set-model` path `/model` uses. Picks a model on that provider, preferring
|