@oh-my-pi/pi-coding-agent 17.0.7 → 17.0.9

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 (153) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +5025 -4930
  3. package/dist/types/config/__tests__/model-registry.test.d.ts +1 -0
  4. package/dist/types/config/model-registry.d.ts +28 -1
  5. package/dist/types/config/model-resolver.d.ts +5 -1
  6. package/dist/types/config/settings-schema.d.ts +50 -3
  7. package/dist/types/dap/client.d.ts +19 -0
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  9. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  10. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +1 -0
  11. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +10 -0
  12. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +1 -1
  13. package/dist/types/extensibility/typebox.d.ts +4 -0
  14. package/dist/types/hindsight/client.d.ts +10 -0
  15. package/dist/types/hindsight/config.d.ts +8 -0
  16. package/dist/types/internal-urls/registry-helpers.d.ts +11 -0
  17. package/dist/types/lsp/client.d.ts +2 -0
  18. package/dist/types/mcp/manager.d.ts +6 -2
  19. package/dist/types/mcp/render.d.ts +2 -2
  20. package/dist/types/mcp/tool-bridge.d.ts +16 -3
  21. package/dist/types/mcp/types.d.ts +6 -0
  22. package/dist/types/mnemopi/state.d.ts +2 -0
  23. package/dist/types/modes/components/oauth-selector.d.ts +8 -0
  24. package/dist/types/modes/components/settings-defs.d.ts +1 -0
  25. package/dist/types/modes/components/user-message.d.ts +25 -1
  26. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  27. package/dist/types/modes/controllers/mcp-command-controller.d.ts +3 -0
  28. package/dist/types/modes/interactive-mode.d.ts +7 -1
  29. package/dist/types/modes/rpc/rpc-client.d.ts +10 -1
  30. package/dist/types/modes/rpc/rpc-frame.d.ts +16 -0
  31. package/dist/types/modes/rpc/rpc-messages.d.ts +26 -0
  32. package/dist/types/modes/rpc/rpc-types.d.ts +40 -0
  33. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +1 -1
  34. package/dist/types/modes/setup-wizard/scenes/types.d.ts +9 -1
  35. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +1 -1
  36. package/dist/types/modes/types.d.ts +14 -1
  37. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  38. package/dist/types/sdk.d.ts +1 -0
  39. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  40. package/dist/types/session/agent-session.d.ts +86 -3
  41. package/dist/types/session/session-loader.d.ts +6 -4
  42. package/dist/types/task/executor.d.ts +10 -0
  43. package/dist/types/task/index.d.ts +1 -0
  44. package/dist/types/task/prewalk.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +14 -0
  46. package/dist/types/tools/ask.d.ts +10 -0
  47. package/dist/types/tools/hub/jobs.d.ts +1 -1
  48. package/dist/types/tools/index.d.ts +3 -0
  49. package/dist/types/tools/report-tool-issue.d.ts +24 -9
  50. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  51. package/dist/types/utils/changelog.d.ts +4 -6
  52. package/dist/types/web/search/providers/firecrawl.d.ts +9 -0
  53. package/dist/types/web/search/types.d.ts +1 -1
  54. package/package.json +13 -13
  55. package/scripts/legacy-pi-virtual-module.ts +1 -1
  56. package/src/cli/grievances-cli.ts +2 -2
  57. package/src/cli/models-cli.ts +37 -17
  58. package/src/cli/update-cli.ts +14 -1
  59. package/src/cli/usage-cli.ts +1 -1
  60. package/src/config/__tests__/model-registry.test.ts +147 -0
  61. package/src/config/model-registry.ts +87 -24
  62. package/src/config/model-resolver.ts +56 -32
  63. package/src/config/settings-schema.ts +46 -3
  64. package/src/config/settings.ts +68 -5
  65. package/src/dap/client.ts +86 -7
  66. package/src/edit/diff.ts +4 -4
  67. package/src/eval/py/prelude.py +5 -5
  68. package/src/export/html/template.js +23 -9
  69. package/src/extensibility/extensions/runner.ts +9 -4
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +1 -0
  72. package/src/extensibility/legacy-pi-tui-shim.ts +10 -0
  73. package/src/extensibility/plugins/legacy-pi-compat.ts +851 -310
  74. package/src/extensibility/typebox.ts +22 -9
  75. package/src/hindsight/client.test.ts +42 -0
  76. package/src/hindsight/client.ts +40 -3
  77. package/src/hindsight/config.ts +18 -0
  78. package/src/internal-urls/registry-helpers.ts +40 -0
  79. package/src/launch/broker.ts +31 -5
  80. package/src/lsp/client.ts +23 -0
  81. package/src/lsp/clients/biome-client.ts +3 -4
  82. package/src/lsp/index.ts +26 -8
  83. package/src/main.ts +16 -5
  84. package/src/mcp/manager.ts +39 -8
  85. package/src/mcp/render.ts +94 -35
  86. package/src/mcp/tool-bridge.ts +116 -11
  87. package/src/mcp/types.ts +7 -0
  88. package/src/mnemopi/state.ts +70 -3
  89. package/src/modes/components/agent-dashboard.ts +6 -2
  90. package/src/modes/components/agent-hub.ts +26 -9
  91. package/src/modes/components/chat-transcript-builder.ts +13 -2
  92. package/src/modes/components/diff.ts +2 -2
  93. package/src/modes/components/oauth-selector.ts +24 -7
  94. package/src/modes/components/plan-review-overlay.ts +20 -4
  95. package/src/modes/components/settings-defs.ts +5 -2
  96. package/src/modes/components/settings-selector.ts +9 -5
  97. package/src/modes/components/tool-execution.test.ts +63 -2
  98. package/src/modes/components/user-message.ts +100 -1
  99. package/src/modes/controllers/command-controller.ts +36 -12
  100. package/src/modes/controllers/event-controller.ts +4 -1
  101. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  102. package/src/modes/controllers/input-controller.ts +47 -12
  103. package/src/modes/controllers/mcp-command-controller.ts +70 -40
  104. package/src/modes/controllers/selector-controller.ts +82 -6
  105. package/src/modes/interactive-mode.ts +39 -4
  106. package/src/modes/rpc/rpc-client.ts +94 -3
  107. package/src/modes/rpc/rpc-frame.ts +164 -4
  108. package/src/modes/rpc/rpc-messages.ts +127 -0
  109. package/src/modes/rpc/rpc-mode.ts +79 -7
  110. package/src/modes/rpc/rpc-types.ts +34 -2
  111. package/src/modes/setup-wizard/scenes/model.ts +5 -7
  112. package/src/modes/setup-wizard/scenes/providers.ts +3 -2
  113. package/src/modes/setup-wizard/scenes/sign-in.ts +8 -2
  114. package/src/modes/setup-wizard/scenes/theme.ts +13 -3
  115. package/src/modes/setup-wizard/scenes/types.ts +9 -1
  116. package/src/modes/setup-wizard/scenes/web-search.ts +6 -1
  117. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  118. package/src/modes/types.ts +16 -3
  119. package/src/modes/utils/ui-helpers.ts +40 -20
  120. package/src/prompts/goals/guided-goal-system.md +24 -3
  121. package/src/prompts/system/workflow-notice.md +1 -1
  122. package/src/prompts/tools/task.md +12 -2
  123. package/src/sdk.ts +179 -51
  124. package/src/session/agent-session-error-log.test.ts +59 -0
  125. package/src/session/agent-session.ts +351 -46
  126. package/src/session/session-context.test.ts +224 -1
  127. package/src/session/session-context.ts +41 -2
  128. package/src/session/session-loader.ts +10 -5
  129. package/src/slash-commands/helpers/usage-report.ts +3 -1
  130. package/src/system-prompt.ts +1 -2
  131. package/src/task/executor.ts +44 -27
  132. package/src/task/index.ts +63 -8
  133. package/src/task/prewalk.ts +6 -0
  134. package/src/task/structured-subagent.ts +3 -1
  135. package/src/task/types.ts +16 -0
  136. package/src/task/worktree.ts +25 -11
  137. package/src/tools/ask.ts +15 -0
  138. package/src/tools/bash.ts +14 -6
  139. package/src/tools/browser.ts +1 -1
  140. package/src/tools/debug.ts +1 -1
  141. package/src/tools/eval.ts +4 -5
  142. package/src/tools/fetch.ts +1 -1
  143. package/src/tools/hub/index.ts +1 -1
  144. package/src/tools/hub/jobs.ts +67 -8
  145. package/src/tools/hub/launch.ts +7 -0
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/report-tool-issue.ts +79 -28
  148. package/src/tools/tool-timeouts.ts +10 -3
  149. package/src/tools/write.ts +31 -0
  150. package/src/utils/changelog.ts +54 -58
  151. package/src/utils/git.ts +57 -49
  152. package/src/web/search/providers/firecrawl.ts +46 -13
  153. package/src/web/search/types.ts +5 -1
@@ -30,6 +30,8 @@ import {
30
30
  type AgentMessage,
31
31
  type AgentState,
32
32
  type AgentTool,
33
+ type AgentToolCall,
34
+ type AgentToolContext,
33
35
  type AgentToolResult,
34
36
  type AgentTurnEndContext,
35
37
  AppendOnlyContextManager,
@@ -37,6 +39,7 @@ import {
37
39
  type CompactionSummaryMessage,
38
40
  countTokens,
39
41
  createToolScopedAbortReason,
42
+ isSyntheticToolResultMessage,
40
43
  resolveTelemetry,
41
44
  type StreamFn,
42
45
  TERMINAL_TOOL_RESULT_ABORT_REASON,
@@ -105,6 +108,7 @@ import type {
105
108
  TextContent,
106
109
  ToolCall,
107
110
  ToolChoice,
111
+ ToolResultMessage,
108
112
  Usage,
109
113
  UsageReport,
110
114
  } from "@oh-my-pi/pi-ai";
@@ -238,6 +242,7 @@ import type {
238
242
  TurnEndEvent,
239
243
  TurnStartEvent,
240
244
  } from "../extensibility/extensions";
245
+ import { emitSessionShutdownEvent } from "../extensibility/extensions";
241
246
  import { ManagedTimers } from "../extensibility/extensions/managed-timers";
242
247
  import { createExtensionModelQuery } from "../extensibility/extensions/model-api";
243
248
  import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
@@ -302,6 +307,7 @@ import { AgentRegistry } from "../registry/agent-registry";
302
307
  import {
303
308
  deobfuscateAssistantContent,
304
309
  deobfuscateSessionContext,
310
+ deobfuscateToolArguments,
305
311
  obfuscateMessages,
306
312
  obfuscateProviderContext,
307
313
  type SecretObfuscator,
@@ -320,6 +326,7 @@ import {
320
326
  } from "../thinking";
321
327
  import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/message-preproc";
322
328
  import { shutdownTinyTitleClient } from "../tiny/title-client";
329
+ import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
323
330
  import { assertEditableFile } from "../tools/auto-generated-guard";
324
331
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
325
332
  import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
@@ -872,6 +879,16 @@ export interface PlanYolo {
872
879
  // Types
873
880
  // ============================================================================
874
881
 
882
+ /** Identifies a retry fallback chain already entered during startup model resolution. */
883
+ export interface InitialRetryFallbackState {
884
+ /** Role whose configured primary was unavailable. */
885
+ role: string;
886
+ /** Configured primary selector retained for restoration when it becomes available. */
887
+ originalSelector: string;
888
+ /** Thinking selector configured for the unavailable primary. */
889
+ originalThinkingLevel: ConfiguredThinkingLevel | undefined;
890
+ }
891
+
875
892
  export interface AgentSessionConfig {
876
893
  agent: Agent;
877
894
  sessionManager: SessionManager;
@@ -882,6 +899,8 @@ export interface AgentSessionConfig {
882
899
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
883
900
  /** Initial session thinking selector. */
884
901
  thinkingLevel?: ConfiguredThinkingLevel;
902
+ /** Retry chain ownership when startup selected one of its fallback entries. */
903
+ initialRetryFallback?: InitialRetryFallbackState;
885
904
  /** Prewalk from the starting model to a fast/cheap target at the first edit/write once the todo list exists. */
886
905
  prewalk?: Prewalk;
887
906
  /** Force read-only plan mode at start, auto-approve on the model's first
@@ -1682,6 +1701,25 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
1682
1701
  return message.role === "custom" && message.customType === "advisor";
1683
1702
  }
1684
1703
 
1704
+ /**
1705
+ * Emit a warn-level log for a turn that ended in a provider error so recurring
1706
+ * stream failures are diagnosable from the main log alone. The `agent_end`
1707
+ * routing trace is debug-only and omits the error fields; without this a
1708
+ * session dying on provider errors leaves only `stopReason:"error"` debug lines
1709
+ * and the real cause lives solely in the session transcript (issue #6177).
1710
+ * No-op for any non-error stop reason.
1711
+ */
1712
+ export function logProviderTurnError(msg: AssistantMessage): void {
1713
+ if (msg.stopReason !== "error") return;
1714
+ logger.warn("agent turn ended with provider error", {
1715
+ provider: msg.provider,
1716
+ model: msg.model,
1717
+ errorMessage: msg.errorMessage,
1718
+ errorStatus: msg.errorStatus,
1719
+ errorId: msg.errorId,
1720
+ });
1721
+ }
1722
+
1685
1723
  function isTerminalTextAssistantAnswer(message: AgentMessage | undefined): message is AssistantMessage {
1686
1724
  if (message?.role !== "assistant" || message.stopReason !== "stop") return false;
1687
1725
  let hasText = false;
@@ -1843,6 +1881,22 @@ function titleConversationTurnFromMessage(message: AgentMessage): TitleConversat
1843
1881
  return { role: message.role, ...(text ? { text } : {}), ...(thinking ? { thinking } : {}) };
1844
1882
  }
1845
1883
 
1884
+ function syntheticToolResultTailStart(messages: readonly AgentMessage[]): number {
1885
+ let index = messages.length;
1886
+ while (index > 0 && isSyntheticToolResultMessage(messages[index - 1])) {
1887
+ index--;
1888
+ }
1889
+ return index;
1890
+ }
1891
+
1892
+ function retryableAssistantTurnEnd(messages: readonly AgentMessage[]): number | undefined {
1893
+ const turnEnd = syntheticToolResultTailStart(messages);
1894
+ const message = messages[turnEnd - 1];
1895
+ if (message?.role !== "assistant") return undefined;
1896
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") return undefined;
1897
+ return turnEnd;
1898
+ }
1899
+
1846
1900
  export class AgentSession {
1847
1901
  readonly agent: Agent;
1848
1902
  readonly sessionManager: SessionManager;
@@ -2698,6 +2752,13 @@ export class AgentSession {
2698
2752
  } else {
2699
2753
  this.#thinkingLevel = config.thinkingLevel;
2700
2754
  }
2755
+ if (config.initialRetryFallback) {
2756
+ this.#activeRetryFallback = {
2757
+ ...config.initialRetryFallback,
2758
+ lastAppliedFallbackThinkingLevel: this.configuredThinkingLevel(),
2759
+ pinned: false,
2760
+ };
2761
+ }
2701
2762
  if (config.prewalk) {
2702
2763
  this.#prewalk = config.prewalk;
2703
2764
  }
@@ -4828,9 +4889,10 @@ export class AgentSession {
4828
4889
  if (toolName === "edit" && editedPath) {
4829
4890
  this.#invalidateFileCacheForPath(editedPath);
4830
4891
  }
4892
+ // TodoTool commits its state during execute. Replaying the result after
4893
+ // awaited event fan-out can overwrite a newer call from the same batch.
4831
4894
  const phases = details?.phases;
4832
4895
  if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) {
4833
- this.setTodoPhases(phases);
4834
4896
  if (this.#isTodoInitResult(details, toolCallId)) {
4835
4897
  this.#scheduleReplanTitleRefresh();
4836
4898
  }
@@ -4930,6 +4992,12 @@ export class AgentSession {
4930
4992
  };
4931
4993
  maintenanceRoute("entered");
4932
4994
 
4995
+ // Surface provider stream failures in the main log. The routing trace
4996
+ // above is debug-only and drops the error fields, so a session dying
4997
+ // repeatedly on provider errors otherwise leaves no actionable trace
4998
+ // outside the session transcript (issue #6177).
4999
+ logProviderTurnError(msg);
5000
+
4933
5001
  // Invalidate GitHub Copilot credentials on auth failure so stale tokens
4934
5002
  // aren't reused on the next request
4935
5003
  if (
@@ -5081,6 +5149,15 @@ export class AgentSession {
5081
5149
  if (this.#isClassifierRefusal(msg)) {
5082
5150
  this.#prunedTerminalRefusal = msg;
5083
5151
  this.#removeAssistantMessageFromActiveContext(msg);
5152
+ } else if (!AIError.isContextOverflow(msg, this.model?.contextWindow ?? 0)) {
5153
+ // No retry, fallback, or compaction continuation fired: this errored
5154
+ // turn ends the run. #persistSessionMessageIfMissing dropped it as an
5155
+ // empty error turn, so record it here — otherwise the JSONL stops at
5156
+ // the last tool result and the provider's errorMessage is lost (#6249).
5157
+ // Idempotent and a no-op for non-empty turns. Content-less overflow
5158
+ // rejections stay live-UI only per the auto-compaction progress guard:
5159
+ // persisting one would replay an empty assistant turn on reload.
5160
+ await this.#persistTerminalEmptyErrorTurn(msg);
5084
5161
  }
5085
5162
  this.#resolveRetry();
5086
5163
 
@@ -6399,6 +6476,10 @@ export class AgentSession {
6399
6476
  messages: AgentMessage[],
6400
6477
  lastAssistantMessage = this.getLastAssistantMessage(),
6401
6478
  ): Promise<boolean> {
6479
+ if (this.#abortInProgress || this.#isDisposed) {
6480
+ this.#resetSessionStopContinuationState();
6481
+ return false;
6482
+ }
6402
6483
  if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) {
6403
6484
  return false;
6404
6485
  }
@@ -6882,15 +6963,12 @@ export class AgentSession {
6882
6963
  this.#cancelExitRecorder?.();
6883
6964
  this.#cancelExitRecorder = undefined;
6884
6965
  try {
6885
- if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
6886
- await this.#extensionRunner.emit({ type: "session_shutdown" });
6887
- }
6966
+ await emitSessionShutdownEvent(this.#extensionRunner);
6888
6967
  } catch (error) {
6889
6968
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6890
6969
  }
6891
6970
 
6892
- // Stop extension timers before aborting deferred work they could enqueue.
6893
- this.#extensionRunner?.clearManagedTimers?.();
6971
+ // Stop fallback extension timers before aborting deferred work they could enqueue.
6894
6972
  this.#fallbackExtensionTimers?.clearAll();
6895
6973
  this.abortRetry();
6896
6974
  this.abortCompaction();
@@ -6991,6 +7069,12 @@ export class AgentSession {
6991
7069
  return this.agent.state.model;
6992
7070
  }
6993
7071
 
7072
+ /** Resolved selector while retry routing is using a fallback model. */
7073
+ get retryFallbackModel(): string | undefined {
7074
+ const model = this.model;
7075
+ return this.#activeRetryFallback && model ? formatRetryFallbackSelector(model, this.thinkingLevel) : undefined;
7076
+ }
7077
+
6994
7078
  /** Effective thinking level applied to the agent (the resolved level when `auto`). */
6995
7079
  get thinkingLevel(): ThinkingLevel | undefined {
6996
7080
  return this.#thinkingLevel;
@@ -8889,12 +8973,16 @@ export class AgentSession {
8889
8973
  );
8890
8974
  }
8891
8975
 
8892
- // Check if we need to compact before sending (catches aborted responses). Run
8893
- // inline (allowDefer=false) so the handoff/maintenance fully settles before this
8894
- // prompt's agent loop starts otherwise a deferred handoff would fire on the
8895
- // next microtask alongside the new turn.
8976
+ // Recover a previously failed/incomplete assistant turn before sending.
8977
+ // Successful historical turns take the cheaper pre-prompt threshold path
8978
+ // below; re-running the full post-turn check on resume can synchronously
8979
+ // rewrite/re-render old context before the new prompt starts.
8896
8980
  const lastAssistant = this.#findLastAssistantMessage();
8897
- if (lastAssistant && !options?.skipCompactionCheck) {
8981
+ if (
8982
+ lastAssistant &&
8983
+ !options?.skipCompactionCheck &&
8984
+ (lastAssistant.stopReason === "error" || lastAssistant.stopReason === "length")
8985
+ ) {
8898
8986
  await this.#checkCompaction(lastAssistant, false, false, false);
8899
8987
  }
8900
8988
 
@@ -11988,7 +12076,17 @@ export class AgentSession {
11988
12076
  this.#pendingRecoveredRetryErrors = [];
11989
12077
  }
11990
12078
 
11991
- async #persistRetryLifecycleErrorMessage(message: AssistantMessage): Promise<void> {
12079
+ /**
12080
+ * Durably record a terminal empty error turn (`stopReason: "error"` with no
12081
+ * substantive content) that `#persistSessionMessageIfMissing` skipped, so the
12082
+ * session JSONL keeps a record of why the run stopped instead of ending at the
12083
+ * last tool result. A no-op for non-empty/non-error turns and idempotent via
12084
+ * the already-persisted guard; the turn is dropped from active context by the
12085
+ * caller (or `isProviderRefusalMessage`/`isEmptyErrorTurn` filters) so it is
12086
+ * never replayed on the wire. Used by the retry-lifecycle dead-ends and the
12087
+ * non-retry terminal error tail.
12088
+ */
12089
+ async #persistTerminalEmptyErrorTurn(message: AssistantMessage): Promise<void> {
11992
12090
  await this.#waitForSessionMessagePersistence(message);
11993
12091
  if (!isEmptyErrorTurn(message)) return;
11994
12092
  if (this.#sessionMessageAlreadyPersisted(message)) return;
@@ -12030,7 +12128,7 @@ export class AgentSession {
12030
12128
  id: number,
12031
12129
  options: { switchedCredential: boolean; switchedModel: boolean; delayMs: number },
12032
12130
  ): Promise<void> {
12033
- await this.#persistRetryLifecycleErrorMessage(message);
12131
+ await this.#persistTerminalEmptyErrorTurn(message);
12034
12132
  const persistenceKey = sessionMessagePersistenceKey(message);
12035
12133
  if (!persistenceKey) return;
12036
12134
  let branchEntry: SessionEntry | undefined;
@@ -14810,15 +14908,34 @@ export class AgentSession {
14810
14908
  return message.content.some(block => block.type === "toolCall");
14811
14909
  }
14812
14910
 
14911
+ /**
14912
+ * OpenRouter can repeatedly close Gemini streams at the reasoning-to-payload
14913
+ * transition. One retry covers a transient edge failure; the normal ten-retry
14914
+ * budget would otherwise re-run the same expensive reasoning cycle unchanged.
14915
+ */
14916
+ #isOpenRouterThinkingStreamClose(message: AssistantMessage): boolean {
14917
+ return (
14918
+ message.provider === "openrouter" &&
14919
+ /server_error:\s*stream closed with reason:\s*error/i.test(message.errorMessage ?? "") &&
14920
+ message.content.some(block => block.type === "thinking" && block.thinking.trim().length > 0)
14921
+ );
14922
+ }
14923
+
14813
14924
  #isClassifierRefusal(message: AssistantMessage): boolean {
14814
14925
  if (message.stopReason !== "error") return false;
14815
14926
  const stopType = message.stopDetails?.type;
14816
14927
  return stopType === "refusal" || stopType === "sensitive";
14817
14928
  }
14818
14929
 
14819
- /** True when any registered model belongs to `provider`. */
14820
- #hasProviderModels(provider: string): boolean {
14821
- return this.#modelRegistry.getAll().some(model => model.provider === provider);
14930
+ /**
14931
+ * True when `provider` has registered models or is configured for dynamic
14932
+ * discovery. Discovery-only providers (e.g. a models.yml provider with
14933
+ * `discovery:` and no static models) can hold zero models until the online
14934
+ * refresh completes, so a models-only check would misreport them as
14935
+ * unknown during session construction.
14936
+ */
14937
+ #isKnownProvider(provider: string): boolean {
14938
+ return this.#modelRegistry.hasProvider(provider);
14822
14939
  }
14823
14940
 
14824
14941
  #getRetryFallbackChains(): RetryFallbackChains {
@@ -14851,8 +14968,8 @@ export class AgentSession {
14851
14968
  const keyKind = isRetryFallbackModelKey(key) ? "model" : "role";
14852
14969
  if (keyKind === "model") {
14853
14970
  if (isRetryFallbackWildcardKey(key)) {
14854
- const { provider } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
14855
- if (!this.#hasProviderModels(provider)) {
14971
+ const { provider } = parseRetryFallbackWildcard(key, p => this.#isKnownProvider(p));
14972
+ if (!this.#isKnownProvider(provider)) {
14856
14973
  const msg = `retry.fallbackChains wildcard key references unknown provider: ${key}`;
14857
14974
  logger.warn(msg);
14858
14975
  this.configWarnings.push(msg);
@@ -14884,8 +15001,8 @@ export class AgentSession {
14884
15001
  continue;
14885
15002
  }
14886
15003
  if (isRetryFallbackWildcardKey(selectorStr)) {
14887
- const { provider } = parseRetryFallbackWildcard(selectorStr, p => this.#hasProviderModels(p));
14888
- if (!this.#hasProviderModels(provider)) {
15004
+ const { provider } = parseRetryFallbackWildcard(selectorStr, p => this.#isKnownProvider(p));
15005
+ if (!this.#isKnownProvider(provider)) {
14889
15006
  const msg = `Fallback chain for ${keyKind} '${key}' references unknown provider: ${selectorStr}`;
14890
15007
  logger.warn(msg);
14891
15008
  this.configWarnings.push(msg);
@@ -14985,7 +15102,7 @@ export class AgentSession {
14985
15102
  let wildcardPrefixLength = -1;
14986
15103
  for (const key in chains) {
14987
15104
  if (!isRetryFallbackWildcardKey(key) || !Array.isArray(chains[key])) continue;
14988
- const { provider, idPrefix } = parseRetryFallbackWildcard(key, p => this.#hasProviderModels(p));
15105
+ const { provider, idPrefix } = parseRetryFallbackWildcard(key, p => this.#isKnownProvider(p));
14989
15106
  if (provider !== parsedCurrent.provider) continue;
14990
15107
  if (idPrefix !== undefined && !parsedCurrent.id.startsWith(`${idPrefix}/`)) continue;
14991
15108
  const prefixLength = idPrefix === undefined ? 0 : idPrefix.length;
@@ -15025,7 +15142,7 @@ export class AgentSession {
15025
15142
  ): RetryFallbackSelector | undefined {
15026
15143
  if (isRetryFallbackWildcardKey(entry)) {
15027
15144
  if (!current) return undefined;
15028
- const { provider, idPrefix } = parseRetryFallbackWildcard(entry, p => this.#hasProviderModels(p));
15145
+ const { provider, idPrefix } = parseRetryFallbackWildcard(entry, p => this.#isKnownProvider(p));
15029
15146
  const bareId = current.id.slice(current.id.lastIndexOf("/") + 1);
15030
15147
  let id: string;
15031
15148
  if (idPrefix !== undefined) {
@@ -15408,7 +15525,10 @@ export class AgentSession {
15408
15525
  // (every rotation sets switchedCredential and skips it), so without
15409
15526
  // this last resort a provider-wide usage cap never fails over to the
15410
15527
  // configured chain.
15411
- const retryBudgetExhausted = this.#retryAttempt > retrySettings.maxRetries;
15528
+ const maxRetries = this.#isOpenRouterThinkingStreamClose(message)
15529
+ ? Math.min(retrySettings.maxRetries, 1)
15530
+ : retrySettings.maxRetries;
15531
+ const retryBudgetExhausted = this.#retryAttempt > maxRetries;
15412
15532
 
15413
15533
  const errorMessage = message.errorMessage || "Unknown error";
15414
15534
  const id = this.#classifyRetryMessage(message);
@@ -15501,7 +15621,7 @@ export class AgentSession {
15501
15621
  }
15502
15622
  if (retryBudgetExhausted) {
15503
15623
  if (!switchedModel) {
15504
- await this.#persistRetryLifecycleErrorMessage(message);
15624
+ await this.#persistTerminalEmptyErrorTurn(message);
15505
15625
  // Max retries exceeded and no fallback model to switch to: emit
15506
15626
  // final failure and reset.
15507
15627
  await this.#emitSessionEvent({
@@ -15548,7 +15668,7 @@ export class AgentSession {
15548
15668
  // can act on it.
15549
15669
  const maxDelayMs = retrySettings.maxDelayMs;
15550
15670
  if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
15551
- await this.#persistRetryLifecycleErrorMessage(message);
15671
+ await this.#persistTerminalEmptyErrorTurn(message);
15552
15672
  const attempt = this.#retryAttempt;
15553
15673
  this.#retryAttempt = 0;
15554
15674
  await this.#emitSessionEvent({
@@ -15567,7 +15687,7 @@ export class AgentSession {
15567
15687
  await this.#emitSessionEvent({
15568
15688
  type: "auto_retry_start",
15569
15689
  attempt: this.#retryAttempt,
15570
- maxAttempts: retrySettings.maxRetries,
15690
+ maxAttempts: maxRetries,
15571
15691
  delayMs,
15572
15692
  errorMessage,
15573
15693
  errorId: message.errorId,
@@ -15692,21 +15812,40 @@ export class AgentSession {
15692
15812
  }
15693
15813
  /**
15694
15814
  * Manually retry the last failed assistant turn.
15695
- * Removes the error message from agent state and re-attempts with a fresh retry budget.
15815
+ * Removes the error message from active agent state when present and
15816
+ * re-attempts with a fresh retry budget.
15817
+ *
15818
+ * A stream that stalls or aborts mid-tool-call ends the turn with
15819
+ * `stopReason: "error" | "aborted"` and then appends one synthetic
15820
+ * {@link isSyntheticToolResultMessage tool_result} per emitted tool call to
15821
+ * preserve the provider's tool_use/tool_result pairing (see
15822
+ * `createAbortedToolResult` in `agent-loop.ts`). Those placeholders trail the
15823
+ * failed assistant turn, so the retry lookback walks back over them before
15824
+ * checking the assistant message; it strips both the placeholders and the
15825
+ * failed turn before re-attempting.
15826
+ *
15827
+ * A restored session deliberately omits failed assistant turns from provider
15828
+ * context. In that case, the persisted display transcript remains the source
15829
+ * of truth for whether the current branch has a retryable failed tail.
15830
+ *
15696
15831
  * @returns true if retry was initiated, false if no failed turn to retry or agent is busy
15697
15832
  */
15698
15833
  async retry(): Promise<boolean> {
15699
15834
  if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
15700
15835
 
15701
15836
  const messages = this.agent.state.messages;
15702
- const lastMsg = messages[messages.length - 1];
15703
- if (lastMsg?.role !== "assistant") return false;
15704
-
15705
- const assistantMsg = lastMsg as AssistantMessage;
15706
- if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
15707
-
15708
- // Remove the failed/aborted assistant message (same as auto-retry does before re-attempting)
15709
- this.agent.replaceMessages(messages.slice(0, -1));
15837
+ const activeTurnEnd = retryableAssistantTurnEnd(messages);
15838
+ if (activeTurnEnd !== undefined) {
15839
+ // Remove the failed/aborted assistant message plus its synthetic tool
15840
+ // results (same as auto-retry does before re-attempting).
15841
+ this.agent.replaceMessages(messages.slice(0, activeTurnEnd - 1));
15842
+ } else {
15843
+ // A restored session already dropped the failed assistant turn (and its
15844
+ // paired synthetic tool results) from provider context, so the persisted
15845
+ // display transcript is the source of truth for a retryable failed tail.
15846
+ const transcriptMessages = this.sessionManager.buildSessionContext({ transcript: true }).messages;
15847
+ if (retryableAssistantTurnEnd(transcriptMessages) === undefined) return false;
15848
+ }
15710
15849
 
15711
15850
  // Reset retry budget for a fresh attempt
15712
15851
  this.#retryAttempt = 0;
@@ -15941,7 +16080,7 @@ export class AgentSession {
15941
16080
  signal: abortController.signal,
15942
16081
  sessionKey: target.sessionId,
15943
16082
  cwd,
15944
- timeout: clampTimeout("bash") * 1000,
16083
+ timeout: clampTimeout("bash", undefined, this.settings.get("tools.maxTimeout")) * 1000,
15945
16084
  onMinimizedSave: originalText => this.#saveBashOriginalArtifact(target, originalText),
15946
16085
  useUserShell: options?.useUserShell,
15947
16086
  });
@@ -17023,7 +17162,30 @@ export class AgentSession {
17023
17162
  */
17024
17163
  async navigateTree(
17025
17164
  targetId: string,
17026
- options: { summarize?: boolean; customInstructions?: string } = {},
17165
+ options: {
17166
+ summarize?: boolean;
17167
+ customInstructions?: string;
17168
+ /**
17169
+ * Opts into the two-phase `ask` toolResult re-answer protocol
17170
+ * (issue #5642): set only by the interactive `/tree` selector, which
17171
+ * knows how to re-open the picker on `reopenAsk` and complete the
17172
+ * navigation with `reanswerAskResult`. Every other public caller
17173
+ * (extensions, hooks, ACP, session-extension actions) leaves this
17174
+ * unset and gets the pre-#5642 plain leaf move onto `ask`
17175
+ * toolResults instead — they have no picker to re-open and would
17176
+ * otherwise report a successful no-op navigation (roboomp review on
17177
+ * #5895).
17178
+ */
17179
+ allowAskReopen?: boolean;
17180
+ /**
17181
+ * Completes an in-progress `ask` re-answer (issue #5642): the caller
17182
+ * already received `reopenAsk` from a prior call on the same
17183
+ * `targetId`, re-opened the picker, and is handing back the fresh
17184
+ * answer. Branches a new toolResult sibling instead of landing on
17185
+ * the original one.
17186
+ */
17187
+ reanswerAskResult?: AgentToolResult<AskToolDetails>;
17188
+ } = {},
17027
17189
  ): Promise<{
17028
17190
  editorText?: string;
17029
17191
  cancelled: boolean;
@@ -17031,12 +17193,36 @@ export class AgentSession {
17031
17193
  summaryEntry?: BranchSummaryEntry;
17032
17194
  /** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
17033
17195
  sessionContext?: SessionContext;
17196
+ /**
17197
+ * Set when `targetId` is an `ask` toolResult, `options.allowAskReopen`
17198
+ * was set, and `options.reanswerAskResult` was not supplied: nothing was
17199
+ * mutated. The caller must re-open the ask picker with these
17200
+ * `questions`, then call `navigateTree(targetId, { ...options,
17201
+ * reanswerAskResult })` with the produced result to actually branch
17202
+ * (issue #5642).
17203
+ */
17204
+ reopenAsk?: { toolCallId: string; questions: AskToolInput["questions"] };
17034
17205
  }> {
17035
17206
  await this.#flushPendingBashMessages();
17036
17207
  const oldLeafId = this.sessionManager.getLeafId();
17037
17208
 
17038
- // No-op if already at target
17039
- if (targetId === oldLeafId) {
17209
+ const targetEntry = this.sessionManager.getEntry(targetId);
17210
+ if (!targetEntry) {
17211
+ throw new Error(`Entry ${targetId} not found`);
17212
+ }
17213
+ const targetIsAskResult =
17214
+ targetEntry.type === "message" &&
17215
+ targetEntry.message.role === "toolResult" &&
17216
+ targetEntry.message.toolName === "ask";
17217
+
17218
+ // No-op if already at target — except mid-flight through the `ask`
17219
+ // re-answer protocol (issue #5642): a probe or completion call can
17220
+ // legitimately target the *current* leaf (e.g. the user interrupted
17221
+ // right after answering `ask`, before a follow-up assistant message
17222
+ // landed, or another caller navigated straight onto the ask result),
17223
+ // and must still return `reopenAsk` / branch the new answer instead of
17224
+ // silently reporting a no-op (chatgpt-codex review on #5895).
17225
+ if (targetId === oldLeafId && !(options.allowAskReopen && targetIsAskResult)) {
17040
17226
  return { cancelled: false };
17041
17227
  }
17042
17228
 
@@ -17045,16 +17231,47 @@ export class AgentSession {
17045
17231
  throw new Error("No model available for summarization");
17046
17232
  }
17047
17233
 
17048
- const targetEntry = this.sessionManager.getEntry(targetId);
17049
- if (!targetEntry) {
17050
- throw new Error(`Entry ${targetId} not found`);
17051
- }
17052
-
17053
- // Collect entries to summarize (from old leaf to common ancestor)
17234
+ // `ask` toolResult, first pass: hand control back to the caller to
17235
+ // re-open the picker instead of landing on the stale answer in place.
17236
+ // Nothing is mutated here — see the `reanswerAskResult` branch below for
17237
+ // the actual sibling-branch construction once the caller has an answer.
17238
+ // Gated on `allowAskReopen` — callers that don't understand `reopenAsk`
17239
+ // fall straight through to the plain leaf move below instead of
17240
+ // reporting a successful no-op (roboomp review on #5895).
17241
+ if (
17242
+ options.allowAskReopen &&
17243
+ !options.reanswerAskResult &&
17244
+ targetEntry.type === "message" &&
17245
+ targetEntry.message.role === "toolResult" &&
17246
+ targetEntry.message.toolName === "ask"
17247
+ ) {
17248
+ const toolCallId = targetEntry.message.toolCallId;
17249
+ const questions = this.#recoverAskReanswerQuestions(targetEntry.parentId, toolCallId);
17250
+ if (questions) {
17251
+ return { cancelled: false, reopenAsk: { toolCallId, questions } };
17252
+ }
17253
+ // Original arguments couldn't be recovered (corrupted/legacy session
17254
+ // data) — fall through to a plain leaf move so navigation still works.
17255
+ }
17256
+
17257
+ // Collect entries to summarize (from old leaf to common ancestor). For an
17258
+ // `ask` re-answer completion, the branch point is `targetEntry.parentId`
17259
+ // (the new sibling toolResult lands there, not on `targetId`) — anchor
17260
+ // the collection there too, or the old answer entry is neither on the
17261
+ // new branch nor included in the summary (chatgpt-codex review on
17262
+ // #5895).
17263
+ const summaryAnchorId =
17264
+ options.reanswerAskResult !== undefined &&
17265
+ targetEntry.type === "message" &&
17266
+ targetEntry.message.role === "toolResult" &&
17267
+ targetEntry.message.toolName === "ask" &&
17268
+ targetEntry.parentId !== null
17269
+ ? targetEntry.parentId
17270
+ : targetId;
17054
17271
  const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
17055
17272
  this.sessionManager,
17056
17273
  oldLeafId,
17057
- targetId,
17274
+ summaryAnchorId,
17058
17275
  );
17059
17276
 
17060
17277
  // Prepare event data
@@ -17150,6 +17367,27 @@ export class AgentSession {
17150
17367
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
17151
17368
  .map(c => c.text)
17152
17369
  .join("");
17370
+ } else if (
17371
+ targetEntry.type === "message" &&
17372
+ targetEntry.message.role === "toolResult" &&
17373
+ targetEntry.message.toolName === "ask" &&
17374
+ options.reanswerAskResult
17375
+ ) {
17376
+ // `ask` toolResult, second pass: the caller re-opened the picker and
17377
+ // is handing back a fresh answer. Branch a *new* sibling toolResult
17378
+ // off the same `ask` toolCall instead of reusing `targetId` — the
17379
+ // original answer's branch stays reachable (issue #5642).
17380
+ const reanswer = options.reanswerAskResult;
17381
+ const toolResultMessage: ToolResultMessage = {
17382
+ role: "toolResult",
17383
+ toolCallId: targetEntry.message.toolCallId,
17384
+ toolName: "ask",
17385
+ content: reanswer.content,
17386
+ details: reanswer.details,
17387
+ isError: reanswer.isError === true,
17388
+ timestamp: Date.now(),
17389
+ };
17390
+ newLeafId = this.sessionManager.appendMessageToBranch(toolResultMessage, targetEntry.parentId);
17153
17391
  } else {
17154
17392
  // Non-user message (or a user-invoked skill-prompt injection): land the
17155
17393
  // leaf on the selected node so it stays on the active branch. Skill
@@ -17212,6 +17450,73 @@ export class AgentSession {
17212
17450
  return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
17213
17451
  }
17214
17452
 
17453
+ /**
17454
+ * Look up the `ask` toolCall's persisted `arguments` and validate them
17455
+ * back into `questions`, for `/tree` `ask` re-answer (issue #5642). Walks
17456
+ * up from the toolResult's parent past any interleaved ancestor entries
17457
+ * — sibling toolResults from other tool calls in the same turn (`ask`
17458
+ * runs `exclusive`, which only serializes *execution*, not persistence
17459
+ * order — roboomp review on #5895), and bookkeeping entries such as the
17460
+ * `tool_execution_start` custom entry `#recordToolExecutionStart()`
17461
+ * appends before every toolResult in real persisted sessions (chatgpt-codex
17462
+ * review on #5895) — until it finds the assistant entry that actually
17463
+ * emitted `toolCallId`. Stops at a `user` message (turn boundary) or a
17464
+ * dead end. Returns `undefined` when no ancestor entry holds a matching
17465
+ * `ask` toolCall, or the arguments can't be resolved — the caller falls
17466
+ * back to a plain leaf move rather than opening a picker with bad data.
17467
+ */
17468
+ #recoverAskReanswerQuestions(parentId: string | null, toolCallId: string): AskToolInput["questions"] | undefined {
17469
+ let current = parentId;
17470
+ while (current !== null) {
17471
+ const entry = this.sessionManager.getEntry(current);
17472
+ if (!entry) return undefined;
17473
+ if (entry.type === "message") {
17474
+ if (entry.message.role === "assistant") {
17475
+ const toolCall = entry.message.content.find(
17476
+ (block): block is AgentToolCall => block.type === "toolCall" && block.id === toolCallId,
17477
+ );
17478
+ if (!toolCall) return undefined;
17479
+ if (toolCall.name !== "ask") return undefined;
17480
+ const args = this.#obfuscator?.hasSecrets()
17481
+ ? deobfuscateToolArguments(this.#obfuscator, toolCall.arguments)
17482
+ : toolCall.arguments;
17483
+ return recoverAskQuestions(args);
17484
+ }
17485
+ if (entry.message.role === "user") return undefined;
17486
+ }
17487
+ current = entry.parentId;
17488
+ }
17489
+ return undefined;
17490
+ }
17491
+
17492
+ /**
17493
+ * Build a standalone `AgentToolContext` for running `AskTool.execute()`
17494
+ * outside a normal agent turn, for `/tree` `ask` re-answer (issue #5642).
17495
+ * `SelectorController` has no reachable `ToolContextStore` (that store is
17496
+ * built inside `sdk.ts` and never threaded through to mode controllers),
17497
+ * so this mirrors `refreshMCPTools()`'s `getCustomToolContext` factory
17498
+ * with real session state instead of a `{ ... } as unknown as
17499
+ * AgentToolContext` cast that could silently compile with an incomplete
17500
+ * context (roboomp review on #5895) — every `CustomToolContext` field is
17501
+ * backed by live session state, so a future required field fails to
17502
+ * compile here instead of surfacing as `undefined` at runtime.
17503
+ */
17504
+ buildAskReanswerContext(uiContext: ExtensionUIContext): AgentToolContext {
17505
+ return {
17506
+ sessionManager: this.sessionManager,
17507
+ modelRegistry: this.#modelRegistry,
17508
+ model: this.model,
17509
+ isIdle: () => !this.isStreaming,
17510
+ hasQueuedMessages: () => this.queuedMessageCount > 0,
17511
+ abort: () => {
17512
+ this.agent.abort();
17513
+ },
17514
+ settings: this.settings,
17515
+ ui: uiContext,
17516
+ hasUI: true,
17517
+ };
17518
+ }
17519
+
17215
17520
  /**
17216
17521
  * Get all user messages from session for branch selector.
17217
17522
  */