@oh-my-pi/pi-coding-agent 16.3.5 → 16.3.6

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli.js +3388 -3374
  3. package/dist/types/edit/file-snapshot-store.d.ts +4 -2
  4. package/dist/types/edit/renderer.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  6. package/dist/types/extensibility/shared-events.d.ts +8 -1
  7. package/dist/types/modes/components/assistant-message.d.ts +15 -10
  8. package/dist/types/modes/components/bash-execution.d.ts +6 -0
  9. package/dist/types/modes/components/eval-execution.d.ts +6 -0
  10. package/dist/types/modes/components/tool-execution.d.ts +3 -13
  11. package/dist/types/modes/components/transcript-container.d.ts +26 -28
  12. package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
  13. package/dist/types/session/agent-session.d.ts +2 -0
  14. package/dist/types/tools/bash.d.ts +0 -2
  15. package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
  16. package/dist/types/tools/eval-render.d.ts +0 -2
  17. package/dist/types/tools/renderers.d.ts +0 -20
  18. package/dist/types/tools/ssh.d.ts +0 -2
  19. package/dist/types/tools/write.d.ts +1 -1
  20. package/package.json +12 -12
  21. package/src/edit/file-snapshot-store.ts +5 -2
  22. package/src/edit/renderer.ts +0 -5
  23. package/src/extensibility/custom-tools/types.ts +2 -0
  24. package/src/extensibility/shared-events.ts +9 -1
  25. package/src/modes/components/assistant-message.ts +134 -82
  26. package/src/modes/components/bash-execution.ts +9 -0
  27. package/src/modes/components/chat-transcript-builder.ts +8 -4
  28. package/src/modes/components/eval-execution.ts +9 -0
  29. package/src/modes/components/tool-execution.ts +4 -50
  30. package/src/modes/components/transcript-container.ts +82 -432
  31. package/src/modes/components/tree-selector.ts +9 -3
  32. package/src/modes/controllers/command-controller.ts +0 -3
  33. package/src/modes/controllers/event-controller.ts +74 -14
  34. package/src/modes/controllers/extension-ui-controller.ts +0 -1
  35. package/src/modes/controllers/input-controller.ts +4 -10
  36. package/src/modes/interactive-mode.ts +12 -8
  37. package/src/modes/utils/transcript-render-helpers.ts +40 -13
  38. package/src/modes/utils/ui-helpers.ts +12 -7
  39. package/src/sdk.ts +1 -0
  40. package/src/session/agent-session.ts +148 -1
  41. package/src/session/session-context.ts +7 -0
  42. package/src/tools/bash.ts +0 -4
  43. package/src/tools/browser/tab-supervisor.ts +47 -3
  44. package/src/tools/eval-render.ts +0 -20
  45. package/src/tools/read.ts +63 -20
  46. package/src/tools/renderers.ts +0 -20
  47. package/src/tools/ssh.ts +0 -16
  48. package/src/tools/write.ts +13 -6
@@ -1029,9 +1029,6 @@ export class CommandController {
1029
1029
  return;
1030
1030
  }
1031
1031
  const name = this.ctx.sessionManager.getSessionName()!;
1032
- setSessionTerminalTitle(name, this.ctx.sessionManager.getCwd());
1033
- this.ctx.statusLine.invalidate();
1034
- this.ctx.updateEditorBorderColor();
1035
1032
  this.ctx.showStatus(`Session renamed to "${name}".`);
1036
1033
  } catch (err) {
1037
1034
  this.ctx.showError(`Rename failed: ${err instanceof Error ? err.message : String(err)}`);
@@ -84,6 +84,8 @@ export class EventController {
84
84
  // restored when the banner clears at the next `agent_start` (see
85
85
  // #handleMessageEnd / #handleAgentStart).
86
86
  #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
87
+ #retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
88
+ #retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
87
89
  #idleCompactionTimer?: NodeJS.Timeout;
88
90
  #idleRecapTimer?: NodeJS.Timeout;
89
91
  // In-flight ephemeral recap turn; aborted by #cancelIdleRecap when any
@@ -325,6 +327,42 @@ export class EventController {
325
327
  this.#terminalProgressActive = false;
326
328
  }
327
329
 
330
+ #trackRetrySupersededAssistantComponent(component: AssistantMessageComponent | undefined): void {
331
+ if (!component) return;
332
+ const persistenceKey = component.messagePersistenceKey();
333
+ if (persistenceKey) this.#retrySupersededAssistantComponents.set(persistenceKey, component);
334
+ if (!this.#retrySupersededAssistantQueue.includes(component)) {
335
+ this.#retrySupersededAssistantQueue.push(component);
336
+ }
337
+ }
338
+
339
+ #takeRetrySupersededAssistantComponent(persistenceKey: string | undefined): AssistantMessageComponent | undefined {
340
+ if (persistenceKey) {
341
+ const component = this.#retrySupersededAssistantComponents.get(persistenceKey);
342
+ if (component) {
343
+ this.#retrySupersededAssistantComponents.delete(persistenceKey);
344
+ this.#retrySupersededAssistantQueue = this.#retrySupersededAssistantQueue.filter(
345
+ item => item !== component,
346
+ );
347
+ return component;
348
+ }
349
+ }
350
+ while (this.#retrySupersededAssistantQueue.length > 0) {
351
+ const component = this.#retrySupersededAssistantQueue.shift();
352
+ if (!component) continue;
353
+ const key = component.messagePersistenceKey();
354
+ if (key && this.#retrySupersededAssistantComponents.get(key) !== component) continue;
355
+ if (key) this.#retrySupersededAssistantComponents.delete(key);
356
+ return component;
357
+ }
358
+ return undefined;
359
+ }
360
+
361
+ #clearRetrySupersededAssistantComponents(): void {
362
+ this.#retrySupersededAssistantComponents.clear();
363
+ this.#retrySupersededAssistantQueue = [];
364
+ }
365
+
328
366
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
329
367
  this.#lastIntent = undefined;
330
368
  this.#readToolCallArgs.clear();
@@ -468,7 +506,7 @@ export class EventController {
468
506
  if (!components) return;
469
507
  let removed = false;
470
508
  for (const component of components) {
471
- if (!this.ctx.chatContainer.isWithinLiveRegion(component)) continue;
509
+ if (!this.ctx.chatContainer.isBlockUncommitted(component)) continue;
472
510
  this.ctx.chatContainer.removeChild(component);
473
511
  removed = true;
474
512
  }
@@ -493,16 +531,20 @@ export class EventController {
493
531
  * Resolve the pending displaceable poll block before the next block lands.
494
532
  * A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
495
533
  * is removed so repeated polls read as one persistent poll — while anything
496
- * else seals it in place as final history. Removal is safe only because a
497
- * displaceable block never finalizes: commits stop at the first live block,
498
- * so none of its rows have entered native scrollback (see
499
- * ToolExecutionComponent.isDisplaceableBlock).
534
+ * else seals it in place as final history. Removal is gated on none of the
535
+ * block's rows having entered native scrollback: rows already on the tape
536
+ * are immutable visual history, so a scrolled-off poll seals instead of
537
+ * being retracted.
500
538
  */
501
539
  #resolveDisplaceablePoll(nextToolName?: string): void {
502
540
  const previous = this.#displaceablePollComponent;
503
541
  if (!previous) return;
504
542
  this.#displaceablePollComponent = undefined;
505
- if (nextToolName === "job" && previous.isDisplaceableBlock()) {
543
+ if (
544
+ nextToolName === "job" &&
545
+ previous.isDisplaceableBlock() &&
546
+ this.ctx.chatContainer.isBlockUncommitted(previous)
547
+ ) {
506
548
  this.ctx.chatContainer.removeChild(previous);
507
549
  }
508
550
  // Sealing stops the waiting-poll spinner and freezes the block (for a
@@ -520,7 +562,9 @@ export class EventController {
520
562
  }
521
563
  if (previous.canBeDisplacedBy(nextToolName)) {
522
564
  this.#displaceableTodoComponent = undefined;
523
- this.ctx.chatContainer.removeChild(previous);
565
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
566
+ this.ctx.chatContainer.removeChild(previous);
567
+ }
524
568
  previous.seal();
525
569
  this.ctx.ui.requestRender();
526
570
  return;
@@ -980,7 +1024,9 @@ export class EventController {
980
1024
  const previous = this.#displaceableTodoComponent;
981
1025
  if (previous && previous !== component && previous.isDisplaceableBlock()) {
982
1026
  this.#displaceableTodoComponent = undefined;
983
- this.ctx.chatContainer.removeChild(previous);
1027
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
1028
+ this.ctx.chatContainer.removeChild(previous);
1029
+ }
984
1030
  previous.seal();
985
1031
  }
986
1032
  this.#displaceableTodoComponent = component;
@@ -1219,6 +1265,7 @@ export class EventController {
1219
1265
  }
1220
1266
 
1221
1267
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1268
+ this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
1222
1269
  this.#stopWorkingLoader();
1223
1270
  this.ctx.statusContainer.clear();
1224
1271
  if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
@@ -1246,7 +1293,21 @@ export class EventController {
1246
1293
  this.ctx.retryLoader = undefined;
1247
1294
  this.ctx.statusContainer.clear();
1248
1295
  }
1249
- if (!event.success) {
1296
+ if (event.success) {
1297
+ let appliedRecovered = false;
1298
+ for (const recovered of event.recoveredErrors ?? []) {
1299
+ const component = this.#takeRetrySupersededAssistantComponent(recovered.persistenceKey);
1300
+ if (!component) continue;
1301
+ component.applyRetryRecovery(recovered.retryRecovery);
1302
+ if (this.#pinnedErrorComponent === component) this.#pinnedErrorComponent = undefined;
1303
+ appliedRecovered = true;
1304
+ }
1305
+ if (appliedRecovered || (event.recoveredErrors?.length ?? 0) > 0) {
1306
+ this.ctx.clearPinnedError();
1307
+ }
1308
+ this.#clearRetrySupersededAssistantComponents();
1309
+ } else {
1310
+ this.#clearRetrySupersededAssistantComponents();
1250
1311
  this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
1251
1312
  }
1252
1313
  this.#ensureWorkingLoaderWhileStreaming();
@@ -1268,15 +1329,14 @@ export class EventController {
1268
1329
  async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
1269
1330
  // Consecutive notifications (e.g. per-tool matches from one assistant
1270
1331
  // message) merge into the previous block instead of stacking. Mutating an
1271
- // existing block is only safe while it sits inside the live region — a
1272
- // still-mutating block above it means none of its rows have been committed
1273
- // to native scrollback yet (commits are prefix-only and stop at the first
1274
- // live block), so the grown block still repaints.
1332
+ // existing block is only safe while none of its rows have entered native
1333
+ // scrollback committed rows are immutable visual history and a grown
1334
+ // block would shift them.
1275
1335
  const previous = this.#lastTtsrNotification;
1276
1336
  if (
1277
1337
  previous &&
1278
1338
  this.ctx.chatContainer.children.at(-1) === previous &&
1279
- this.ctx.chatContainer.isWithinLiveRegion(previous)
1339
+ this.ctx.chatContainer.isBlockUncommitted(previous)
1280
1340
  ) {
1281
1341
  previous.addRules(event.rules);
1282
1342
  this.ctx.ui.requestRender();
@@ -880,7 +880,6 @@ export class ExtensionUiController {
880
880
 
881
881
  async #updateSessionName(name: string): Promise<void> {
882
882
  await this.ctx.sessionManager.setSessionName(name, "user");
883
- setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
884
883
  }
885
884
 
886
885
  #sendExtensionUserMessage: SendUserMessageHandler = (content, options) => {
@@ -33,7 +33,7 @@ import { EnhancedPasteController } from "../../utils/enhanced-paste";
33
33
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
34
34
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
35
35
  import { resizeImage } from "../../utils/image-resize";
36
- import { generateSessionTitle, setSessionTerminalTitle } from "../../utils/title-generator";
36
+ import { generateSessionTitle } from "../../utils/title-generator";
37
37
 
38
38
  /**
39
39
  * Slash commands that may carry secrets in their arguments should never be
@@ -843,16 +843,10 @@ export class InputController {
843
843
  )
844
844
  .then(async title => {
845
845
  // Re-check: a concurrent attempt for an earlier message may have
846
- // already named the session. Don't clobber it.
846
+ // already named the session. Don't clobber it. Terminal title and
847
+ // accent updates fire from the onSessionNameChanged listener.
847
848
  if (title && !this.ctx.sessionManager.getSessionName()) {
848
- const applied = await this.ctx.sessionManager.setSessionName(title, "auto");
849
- if (applied) {
850
- setSessionTerminalTitle(
851
- this.ctx.sessionManager.getSessionName()!,
852
- this.ctx.sessionManager.getCwd(),
853
- );
854
- this.ctx.updateEditorBorderColor();
855
- }
849
+ await this.ctx.sessionManager.setSessionName(title, "auto");
856
850
  }
857
851
  })
858
852
  .catch(err => {
@@ -930,6 +930,17 @@ export class InteractiveMode implements InteractiveModeContext {
930
930
  pushTerminalTitle();
931
931
  setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
932
932
  this.updateEditorBorderColor();
933
+ // Single side-effect point for title changes: every setSessionName caller
934
+ // (first-input titling, /rename, extension renames, plan seeding, replan
935
+ // refresh) gets the terminal title + accent updates from here. Registered
936
+ // before initHooksAndCustomTools/#reconcileModeFromSession/#enterPlanMode —
937
+ // all of which can reach setSessionName during init.
938
+ this.#eventBusUnsubscribers.push(
939
+ this.sessionManager.onSessionNameChanged(() => {
940
+ setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
941
+ this.#handleSessionAccentInputsChanged();
942
+ }),
943
+ );
933
944
  this.#syncEditorMaxHeight();
934
945
  this.isInitialized = true;
935
946
  this.ui.requestRender(true);
@@ -986,9 +997,6 @@ export class InteractiveMode implements InteractiveModeContext {
986
997
  this.session.subscribe(event => {
987
998
  void this.#handleGoalSessionEvent(event);
988
999
  }),
989
- this.sessionManager.onSessionNameChanged(() => {
990
- this.#handleSessionAccentInputsChanged();
991
- }),
992
1000
  onStatusLineSessionAccentChanged(() => {
993
1001
  this.#syncStatusLineSettings();
994
1002
  this.#handleSessionAccentInputsChanged();
@@ -2658,11 +2666,7 @@ export class InteractiveMode implements InteractiveModeContext {
2658
2666
  // when the user has already chosen a name (preserveContext paths).
2659
2667
  const seededName = humanizePlanTitle(options.title);
2660
2668
  if (seededName && !this.sessionManager.getSessionName()) {
2661
- const applied = await this.sessionManager.setSessionName(seededName, "auto");
2662
- if (applied) {
2663
- setSessionTerminalTitle(this.sessionManager.getSessionName(), this.sessionManager.getCwd());
2664
- this.updateEditorBorderColor();
2665
- }
2669
+ await this.sessionManager.setSessionName(seededName, "auto");
2666
2670
  }
2667
2671
 
2668
2672
  // markPlanReferenceSent fires only on the dispatch path so the synthetic
@@ -7,8 +7,14 @@
7
7
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
8
8
  import { type Component, Text } from "@oh-my-pi/pi-tui";
9
9
  import { formatBytes, formatDuration } from "@oh-my-pi/pi-utils";
10
- import { type CustomMessage, type FileMentionMessage, isSilentAbort, resolveAbortLabel } from "../../session/messages";
10
+ import {
11
+ type CustomMessage,
12
+ type FileMentionMessage,
13
+ resolveAbortLabel,
14
+ shouldRenderAbortReason,
15
+ } from "../../session/messages";
11
16
  import { createIrcMessageCard } from "../../tools/irc";
17
+ import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
12
18
  import { canonicalizeMessage } from "../../utils/thinking-display";
13
19
  import { TranscriptBlock } from "../components/transcript-container";
14
20
  import { theme } from "../theme/theme";
@@ -138,20 +144,41 @@ export function normalizeToolArgs(args: unknown): Record<string, unknown> {
138
144
  return args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
139
145
  }
140
146
 
147
+ export type AssistantErrorPresentation =
148
+ | { kind: "none" }
149
+ | { kind: "full"; text: string; isError: true }
150
+ | { kind: "compact-recovered"; text: string; isError: false };
151
+
152
+ function sanitizeRecoveredRetryNote(note: string): string {
153
+ const normalized = replaceTabs(note).replace(/\s+/g, " ").trim();
154
+ return truncateToWidth(normalized || "retried", TRUNCATE_LENGTHS.CONTENT);
155
+ }
156
+
141
157
  /**
142
- * Resolve the inline error label, if any, for a turn-ending assistant message.
143
- * Silent aborts yield no label. `retryAttempt` tunes the abort label wording.
158
+ * Resolve the turn-ending assistant error presentation, if any.
159
+ * Silent and user-interrupt aborts yield no label. Recovered auto-retry errors
160
+ * collapse to a single non-error note; terminal errors keep the full red presentation.
144
161
  */
145
- export function resolveAssistantErrorMessage(
162
+ export function resolveAssistantErrorPresentation(
146
163
  message: AssistantAgentMessage,
147
164
  retryAttempt = 0,
148
- ): { hasErrorStop: boolean; errorMessage: string | null } {
149
- const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message);
150
- const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
151
- const errorMessage = hasErrorStop
152
- ? message.stopReason === "aborted"
153
- ? resolveAbortLabel(message, retryAttempt)
154
- : message.errorMessage || "Error"
155
- : null;
156
- return { hasErrorStop, errorMessage };
165
+ ): AssistantErrorPresentation {
166
+ if (message.retryRecovery?.status === "recovered") {
167
+ return {
168
+ kind: "compact-recovered",
169
+ text: sanitizeRecoveredRetryNote(message.retryRecovery.note),
170
+ isError: false,
171
+ };
172
+ }
173
+ if (message.stopReason === "aborted") {
174
+ if (!shouldRenderAbortReason(message)) return { kind: "none" };
175
+ return { kind: "full", text: resolveAbortLabel(message, retryAttempt), isError: true };
176
+ }
177
+ if (message.stopReason === "error") {
178
+ return { kind: "full", text: message.errorMessage || "Error", isError: true };
179
+ }
180
+ if (message.errorMessage && shouldRenderAbortReason(message)) {
181
+ return { kind: "full", text: message.errorMessage, isError: true };
182
+ }
183
+ return { kind: "none" };
157
184
  }
@@ -54,7 +54,7 @@ import {
54
54
  buildFileMentionBlock,
55
55
  buildIrcMessageCard,
56
56
  normalizeToolArgs,
57
- resolveAssistantErrorMessage,
57
+ resolveAssistantErrorPresentation,
58
58
  } from "./transcript-render-helpers";
59
59
 
60
60
  type TextBlock = { type: "text"; text: string };
@@ -317,7 +317,11 @@ export class UiHelpers {
317
317
  const previous = waitingPoll;
318
318
  if (!previous) return;
319
319
  waitingPoll = null;
320
- if (nextToolName === "job" && previous.isDisplaceableBlock()) {
320
+ if (
321
+ nextToolName === "job" &&
322
+ previous.isDisplaceableBlock() &&
323
+ this.ctx.chatContainer.isBlockUncommitted(previous)
324
+ ) {
321
325
  this.ctx.chatContainer.removeChild(previous);
322
326
  }
323
327
  // Sealing freezes the block and stops the waiting-poll spinner that
@@ -334,7 +338,9 @@ export class UiHelpers {
334
338
  }
335
339
  if (previous.canBeDisplacedBy(nextToolName)) {
336
340
  todoSnapshot = null;
337
- this.ctx.chatContainer.removeChild(previous);
341
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
342
+ this.ctx.chatContainer.removeChild(previous);
343
+ }
338
344
  previous.seal();
339
345
  return;
340
346
  }
@@ -372,10 +378,9 @@ export class UiHelpers {
372
378
  readGroup?.seal();
373
379
  readGroup = null;
374
380
  }
375
- const { hasErrorStop, errorMessage } = resolveAssistantErrorMessage(
376
- message,
377
- this.ctx.viewSession.retryAttempt,
378
- );
381
+ const errorPresentation = resolveAssistantErrorPresentation(message, this.ctx.viewSession.retryAttempt);
382
+ const hasErrorStop = errorPresentation.kind === "full";
383
+ const errorMessage = hasErrorStop ? errorPresentation.text : null;
379
384
 
380
385
  // Render tool call components
381
386
  for (const content of message.content) {
package/src/sdk.ts CHANGED
@@ -993,6 +993,7 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
993
993
  success: event.success,
994
994
  attempt: event.attempt,
995
995
  finalError: event.finalError,
996
+ recoveredErrors: event.recoveredErrors,
996
997
  },
997
998
  ctx,
998
999
  ),
@@ -81,6 +81,8 @@ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/to
81
81
  import type {
82
82
  AssistantMessage,
83
83
  AssistantMessageEvent,
84
+ AssistantRetryRecovery,
85
+ AssistantRetryRecoveryKind,
84
86
  Context,
85
87
  ImageContent,
86
88
  Message,
@@ -219,6 +221,7 @@ import { createExtensionModelQuery } from "../extensibility/extensions/model-api
219
221
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
220
222
  import { ExtensionToolWrapper } from "../extensibility/extensions/wrapper";
221
223
  import type { HookCommandContext } from "../extensibility/hooks/types";
224
+ import type { RecoveredRetryError } from "../extensibility/shared-events";
222
225
  import type { Skill, SkillWarning } from "../extensibility/skills";
223
226
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
224
227
  import { GoalRuntime } from "../goals/runtime";
@@ -497,7 +500,13 @@ export type AgentSessionEvent =
497
500
  errorMessage: string;
498
501
  errorId?: number;
499
502
  }
500
- | { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
503
+ | {
504
+ type: "auto_retry_end";
505
+ success: boolean;
506
+ attempt: number;
507
+ finalError?: string;
508
+ recoveredErrors?: RecoveredRetryError[];
509
+ }
501
510
  | { type: "retry_fallback_applied"; from: string; to: string; role: string }
502
511
  | { type: "retry_fallback_succeeded"; model: string; role: string }
503
512
  | { type: "ttsr_triggered"; rules: Rule[] }
@@ -1425,6 +1434,13 @@ type MessageEndPersistenceSlot = {
1425
1434
  persist: (persistMessage: () => void) => Promise<void>;
1426
1435
  release: () => void;
1427
1436
  };
1437
+ type PendingRecoveredRetryError = {
1438
+ entryId: string;
1439
+ persistenceKey: string;
1440
+ recovery: AssistantRetryRecoveryKind;
1441
+ attempt: number;
1442
+ note: string;
1443
+ };
1428
1444
 
1429
1445
  type PostPromptSkipReason = "aborted" | "stale-generation";
1430
1446
 
@@ -1581,6 +1597,7 @@ export class AgentSession {
1581
1597
  #retryPromise: Promise<void> | undefined = undefined;
1582
1598
  #retryResolve: (() => void) | undefined = undefined;
1583
1599
  #activeRetryFallback: ActiveRetryFallbackState | undefined = undefined;
1600
+ #pendingRecoveredRetryErrors: PendingRecoveredRetryError[] = [];
1584
1601
  // Todo completion reminder state
1585
1602
  #todoReminderCount = 0;
1586
1603
  /**
@@ -3663,11 +3680,14 @@ export class AgentSession {
3663
3680
  role: this.#activeRetryFallback.role,
3664
3681
  });
3665
3682
  }
3683
+ const recoveredErrors = await this.#markPendingRecoveredRetryErrors(assistantMsg);
3666
3684
  await this.#emitSessionEvent({
3667
3685
  type: "auto_retry_end",
3668
3686
  success: true,
3669
3687
  attempt: this.#retryAttempt,
3688
+ recoveredErrors,
3670
3689
  });
3690
+ this.#clearPendingRecoveredRetryErrors();
3671
3691
  this.#retryAttempt = 0;
3672
3692
  }
3673
3693
  if (assistantMsg.provider === "opencode-go") {
@@ -5337,6 +5357,7 @@ export class AgentSession {
5337
5357
  success: event.success,
5338
5358
  attempt: event.attempt,
5339
5359
  finalError: event.finalError,
5360
+ recoveredErrors: event.recoveredErrors,
5340
5361
  });
5341
5362
  } else if (event.type === "ttsr_triggered") {
5342
5363
  await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
@@ -10386,6 +10407,126 @@ export class AgentSession {
10386
10407
  return undefined;
10387
10408
  }
10388
10409
 
10410
+ #clearPendingRecoveredRetryErrors(): void {
10411
+ this.#pendingRecoveredRetryErrors = [];
10412
+ }
10413
+
10414
+ #retryRecoveryKind(
10415
+ id: number,
10416
+ switchedCredential: boolean,
10417
+ switchedModel: boolean,
10418
+ delayMs: number,
10419
+ ): AssistantRetryRecoveryKind {
10420
+ if (switchedCredential) return "credential";
10421
+ if (switchedModel) return "model";
10422
+ if (AIError.is(id, AIError.Flag.UsageLimit) && delayMs > 0) return "wait";
10423
+ return "plain";
10424
+ }
10425
+
10426
+ #retryRecoveryNote(recovery: AssistantRetryRecoveryKind, rateLimited: boolean): string {
10427
+ const parts: string[] = [];
10428
+ if (rateLimited) {
10429
+ parts.push("rate-limited");
10430
+ } else if (recovery === "plain") {
10431
+ parts.push("error");
10432
+ }
10433
+ if (recovery === "credential") {
10434
+ parts.push("switched account");
10435
+ } else if (recovery === "model") {
10436
+ parts.push("switched model");
10437
+ } else if (recovery === "wait") {
10438
+ parts.push("waited");
10439
+ }
10440
+ parts.push("retried");
10441
+ return parts.join("; ");
10442
+ }
10443
+
10444
+ async #recordPendingRecoveredRetryError(
10445
+ message: AssistantMessage,
10446
+ id: number,
10447
+ options: { switchedCredential: boolean; switchedModel: boolean; delayMs: number },
10448
+ ): Promise<void> {
10449
+ await this.#waitForSessionMessagePersistence(message);
10450
+ const persistenceKey = sessionMessagePersistenceKey(message);
10451
+ if (!persistenceKey) return;
10452
+ let branchEntry: SessionEntry | undefined;
10453
+ for (const entry of this.sessionManager.getBranch().slice().reverse()) {
10454
+ if (entry.type !== "message" || entry.message.role !== "assistant") continue;
10455
+ if (sessionMessagePersistenceKey(entry.message) !== persistenceKey) continue;
10456
+ if (!sameMessageContent(entry.message, message) && !this.#isSameAssistantMessage(entry.message, message)) {
10457
+ continue;
10458
+ }
10459
+ branchEntry = entry;
10460
+ break;
10461
+ }
10462
+ if (!branchEntry) return;
10463
+ if (this.#pendingRecoveredRetryErrors.some(error => error.entryId === branchEntry.id)) return;
10464
+ const rateLimited = AIError.is(id, AIError.Flag.UsageLimit);
10465
+ const recovery = this.#retryRecoveryKind(id, options.switchedCredential, options.switchedModel, options.delayMs);
10466
+ const note = this.#retryRecoveryNote(recovery, rateLimited);
10467
+ this.#pendingRecoveredRetryErrors.push({
10468
+ entryId: branchEntry.id,
10469
+ persistenceKey,
10470
+ recovery,
10471
+ attempt: this.#retryAttempt,
10472
+ note,
10473
+ });
10474
+ }
10475
+
10476
+ async #markPendingRecoveredRetryErrors(supersedingMessage: AssistantMessage): Promise<RecoveredRetryError[]> {
10477
+ if (this.#pendingRecoveredRetryErrors.length === 0) return [];
10478
+ const branch = this.sessionManager.getBranch();
10479
+ const branchById = new Map<string, SessionEntry>();
10480
+ for (const entry of branch) {
10481
+ branchById.set(entry.id, entry);
10482
+ }
10483
+ const recoveredAt = new Date().toISOString();
10484
+ const supersededBy: AssistantRetryRecovery["supersededBy"] = {
10485
+ timestamp: supersedingMessage.timestamp,
10486
+ provider: supersedingMessage.provider,
10487
+ model: supersedingMessage.model,
10488
+ };
10489
+ if (supersedingMessage.responseId) {
10490
+ supersededBy.responseId = supersedingMessage.responseId;
10491
+ }
10492
+ const recoveredErrors: RecoveredRetryError[] = [];
10493
+ for (const pending of this.#pendingRecoveredRetryErrors) {
10494
+ let entry = branchById.get(pending.entryId);
10495
+ if (entry?.type !== "message" || entry.message.role !== "assistant") {
10496
+ entry = branch
10497
+ .slice()
10498
+ .reverse()
10499
+ .find(
10500
+ candidate =>
10501
+ candidate.type === "message" &&
10502
+ candidate.message.role === "assistant" &&
10503
+ sessionMessagePersistenceKey(candidate.message) === pending.persistenceKey,
10504
+ );
10505
+ }
10506
+ if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
10507
+ const retryRecovery: AssistantRetryRecovery = {
10508
+ kind: "auto-retry",
10509
+ status: "recovered",
10510
+ attempt: pending.attempt,
10511
+ recoveredAt,
10512
+ recovery: pending.recovery,
10513
+ note: pending.note,
10514
+ supersededBy,
10515
+ };
10516
+ entry.message.retryRecovery = retryRecovery;
10517
+ recoveredErrors.push({
10518
+ entryId: entry.id,
10519
+ persistenceKey: pending.persistenceKey,
10520
+ note: retryRecovery.note,
10521
+ retryRecovery,
10522
+ });
10523
+ }
10524
+ if (recoveredErrors.length > 0) {
10525
+ await this.sessionManager.rewriteEntries();
10526
+ }
10527
+ return recoveredErrors;
10528
+ }
10529
+
10389
10530
  async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
10390
10531
  if (!this.#isEmptyAssistantStop(assistantMessage)) {
10391
10532
  this.#emptyStopRetryCount = 0;
@@ -10406,6 +10547,7 @@ export class AgentSession {
10406
10547
  attempt: this.#retryAttempt,
10407
10548
  finalError: "Assistant returned empty stop after retry cap",
10408
10549
  });
10550
+ this.#clearPendingRecoveredRetryErrors();
10409
10551
  this.#retryAttempt = 0;
10410
10552
  }
10411
10553
  this.#resolveRetry();
@@ -13265,6 +13407,7 @@ export class AgentSession {
13265
13407
  attempt: this.#retryAttempt - 1,
13266
13408
  finalError: message.errorMessage,
13267
13409
  });
13410
+ this.#clearPendingRecoveredRetryErrors();
13268
13411
  this.#retryAttempt = 0;
13269
13412
  this.#resolveRetry(); // Resolve so waitForRetry() completes
13270
13413
  return false;
@@ -13384,10 +13527,13 @@ export class AgentSession {
13384
13527
  attempt,
13385
13528
  finalError: `Provider requested ${delayMs}ms wait, exceeds retry.maxDelayMs (${maxDelayMs}ms). Original error: ${errorMessage}`,
13386
13529
  });
13530
+ this.#clearPendingRecoveredRetryErrors();
13387
13531
  this.#resolveRetry();
13388
13532
  return false;
13389
13533
  }
13390
13534
 
13535
+ await this.#recordPendingRecoveredRetryError(message, id, { switchedCredential, switchedModel, delayMs });
13536
+
13391
13537
  await this.#emitSessionEvent({
13392
13538
  type: "auto_retry_start",
13393
13539
  attempt: this.#retryAttempt,
@@ -13425,6 +13571,7 @@ export class AgentSession {
13425
13571
  attempt,
13426
13572
  finalError: "Retry cancelled",
13427
13573
  });
13574
+ this.#clearPendingRecoveredRetryErrors();
13428
13575
  this.#resolveRetry();
13429
13576
  return false;
13430
13577
  }
@@ -251,6 +251,13 @@ export function buildSessionContext(
251
251
  const appendMessage = (entry: SessionEntry) => {
252
252
  handleEntryResetTracking(entry);
253
253
  if (entry.type === "message") {
254
+ if (
255
+ !options?.transcript &&
256
+ entry.message.role === "assistant" &&
257
+ entry.message.retryRecovery?.status === "recovered"
258
+ ) {
259
+ return;
260
+ }
254
261
  pushMessage(entry.message);
255
262
  } else if (entry.type === "custom_message") {
256
263
  pushMessage(
package/src/tools/bash.ts CHANGED
@@ -1409,10 +1409,6 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1409
1409
  },
1410
1410
  mergeCallAndResult: true,
1411
1411
  inline: true,
1412
- // Collapsed pending preview caps the command to a viewport-sized tail
1413
- // window that shifts while args stream. Expanded output is top-anchored
1414
- // enough for the transcript to commit its settled prefix.
1415
- provisionalPendingPreview: "collapsed",
1416
1412
  };
1417
1413
  }
1418
1414