@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.
@@ -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 sub-agent depth (Primitive #6).
343
- // Depth 0 = top-level agent's stream; depth 1 = sub-agent at that nesting
344
- // level. Each depth gets its own Markdown block that grows in place as
345
- // `message_update` events arrive, so a sub-agent's multi-chunk reply
346
- // renders as ONE growing block (not N fresh blocks). Cleared per-depth on
347
- // `tool_execution_start` (so the next message_update at that depth
348
- // creates a fresh block under the tool), and wholesale on `agent_end` /
349
- // abort (turn boundary).
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
- void applySubscription();
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. Without this, two
824
- // operators each running /agent X and /agent Y would both render
825
- // every approval card. Server-side subscribe should already filter
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
- // If another prompt is somehow already showing (shouldn't happen
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
- // Scrub the server-pushed event text before rendering (see
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
- // Per-depth streaming buffers: top-level (depth 0) and each sub-
996
- // agent (depth 1) get their OWN Markdown block that grows in
997
- // place. A child's message_update chunks now land in the child's
998
- // own buffer (not appended as N fresh blocks, and not overwriting
999
- // the parent's buffer).
1000
- const existing = activeAssistants.get(depth);
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(depth, fresh);
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
- // Close the current depth's assistant text block when a tool starts.
1044
- // Otherwise the assistant block's position is locked at first stream-
1045
- // chunk, and a long final answer flowing in AFTER the tools end ends
1046
- // up rendered ABOVE them. Strictly chronological order clearing
1047
- // the per-depth pointer lets the next message_update at THIS depth
1048
- // create a fresh block that lands below the most recent tool.
1049
- // We clear ONLY this depth's buffer so a sub-agent's tool start
1050
- // doesn't close the parent's open assistant block (separate streams).
1051
- activeAssistants.delete(depth);
1052
- // A tool starting is a turn-boundary for the open assistant
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
- insertBeforeEditor(new Text(` ${brand.dim("↻ reconnected to gateway")}`, 0, 0));
1186
- // Re-subscription after a dropped/restored gateway. BrigadeClient opens
1187
- // a BRAND-NEW socket on reconnect, so the gateway assigns a fresh
1188
- // connection id whose per-connection agent/session subscription Sets are
1189
- // EMPTY and the broadcast filter falls through to "deliver everything",
1190
- // losing server-side lane isolation. It also means the bound agent's
1191
- // per-binding state snapshot (pushed only in response to a `subscribe`
1192
- // with the agentId) is never re-sent, so a non-boot binding's header
1193
- // reverts to the BOOT agent's snapshot. Re-issue the subscription below.
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
- // Fire-and-forget: ask the gateway for the current snapshot so the
1202
- // `state` handler above updates `isAgentRunning` + the header. Errors
1203
- // are swallowed — the next state push (any tool call / turn start)
1204
- // will refresh anyway. The re-subscribe is appended AFTER this settles
1205
- // (both success and failure) so ordering is deterministic and the
1206
- // subscribe-time per-binding snapshot push lands after the get-state
1207
- // reconcile.
1208
- client.request("get-state").then((snap) => {
1209
- if (!snap)
1210
- return;
1211
- lastSnapshot = snap;
1212
- // Same one-way rule as the `state` handler: the agent-wide flag
1213
- // may only CLEAR our lane's busy state, never set it.
1214
- if (!snap.isAgentRunning)
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