@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.5

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 (112) hide show
  1. package/CHANGELOG.md +86 -43
  2. package/dist/cli.js +3540 -3521
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  13. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  14. package/dist/types/launch/spawn-options.d.ts +10 -0
  15. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  16. package/dist/types/modes/components/status-line/component.d.ts +2 -3
  17. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  18. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  20. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  21. package/dist/types/modes/interactive-mode.d.ts +2 -0
  22. package/dist/types/modes/print-mode.d.ts +4 -0
  23. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/types.d.ts +2 -0
  26. package/dist/types/sdk.d.ts +2 -0
  27. package/dist/types/session/agent-session.d.ts +19 -1
  28. package/dist/types/session/messages.d.ts +6 -0
  29. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  30. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  31. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  32. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  33. package/dist/types/tools/gh.d.ts +5 -3
  34. package/dist/types/tools/hub/index.d.ts +1 -1
  35. package/dist/types/tools/hub/jobs.d.ts +1 -0
  36. package/dist/types/tools/hub/types.d.ts +2 -0
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/git.d.ts +33 -0
  39. package/dist/types/web/search/providers/codex.d.ts +1 -1
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +150 -0
  42. package/src/advisor/advise-tool.ts +4 -0
  43. package/src/advisor/runtime.ts +38 -14
  44. package/src/async/job-manager.ts +3 -0
  45. package/src/cli/bench-cli.ts +8 -2
  46. package/src/cli/dry-balance-cli.ts +1 -0
  47. package/src/config/model-registry.ts +89 -8
  48. package/src/config/model-resolver.ts +78 -14
  49. package/src/config/models-config-schema.ts +1 -0
  50. package/src/config/settings.ts +3 -1
  51. package/src/dap/client.ts +168 -1
  52. package/src/dap/config.ts +51 -1
  53. package/src/dap/session.ts +575 -234
  54. package/src/dap/types.ts +6 -5
  55. package/src/discovery/agents.ts +2 -2
  56. package/src/extensibility/extensions/runner.ts +6 -4
  57. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  58. package/src/launch/broker.ts +34 -31
  59. package/src/launch/client.ts +7 -1
  60. package/src/launch/spawn-options.test.ts +31 -0
  61. package/src/launch/spawn-options.ts +17 -0
  62. package/src/lsp/types.ts +5 -1
  63. package/src/main.ts +17 -4
  64. package/src/mcp/transports/stdio.test.ts +9 -1
  65. package/src/modes/components/ask-dialog.ts +137 -73
  66. package/src/modes/components/status-line/component.ts +2 -2
  67. package/src/modes/components/status-line/segments.ts +20 -3
  68. package/src/modes/components/status-line/types.ts +3 -1
  69. package/src/modes/components/tool-execution.ts +5 -0
  70. package/src/modes/components/transcript-container.ts +23 -122
  71. package/src/modes/components/tree-selector.ts +10 -4
  72. package/src/modes/controllers/event-controller.ts +1 -2
  73. package/src/modes/controllers/input-controller.ts +1 -1
  74. package/src/modes/controllers/selector-controller.ts +29 -8
  75. package/src/modes/interactive-mode.ts +40 -8
  76. package/src/modes/noninteractive-dispose.test.ts +12 -1
  77. package/src/modes/print-mode.ts +21 -9
  78. package/src/modes/rpc/rpc-input.ts +38 -0
  79. package/src/modes/rpc/rpc-mode.ts +7 -2
  80. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  81. package/src/modes/types.ts +2 -0
  82. package/src/modes/utils/ui-helpers.ts +3 -2
  83. package/src/prompts/tools/browser.md +3 -2
  84. package/src/prompts/tools/debug.md +2 -7
  85. package/src/prompts/tools/github.md +6 -1
  86. package/src/prompts/tools/hub.md +4 -2
  87. package/src/prompts/tools/task-async-contract.md +1 -0
  88. package/src/prompts/tools/task.md +9 -2
  89. package/src/sdk.ts +38 -6
  90. package/src/session/agent-session.ts +395 -162
  91. package/src/session/messages.test.ts +91 -0
  92. package/src/session/messages.ts +248 -110
  93. package/src/slash-commands/builtin-registry.ts +1 -0
  94. package/src/task/executor.ts +59 -33
  95. package/src/task/index.ts +46 -9
  96. package/src/task/worktree.ts +10 -0
  97. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  98. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  99. package/src/tools/browser/cmux/socket-client.ts +139 -3
  100. package/src/tools/browser/run-cancellation.ts +4 -0
  101. package/src/tools/browser/tab-protocol.ts +2 -0
  102. package/src/tools/browser/tab-supervisor.ts +21 -11
  103. package/src/tools/browser/tab-worker.ts +199 -33
  104. package/src/tools/debug.ts +3 -0
  105. package/src/tools/gh.ts +40 -2
  106. package/src/tools/hub/index.ts +4 -1
  107. package/src/tools/hub/jobs.ts +42 -1
  108. package/src/tools/hub/types.ts +2 -0
  109. package/src/tools/tool-timeouts.ts +1 -1
  110. package/src/utils/git.ts +237 -0
  111. package/src/web/search/index.ts +9 -5
  112. package/src/web/search/providers/codex.ts +195 -99
@@ -63,6 +63,7 @@ import {
63
63
  estimateTokens,
64
64
  generateBranchSummary,
65
65
  generateHandoffFromContext,
66
+ invalidateMessageCache,
66
67
  prepareCompaction,
67
68
  renderHandoffPrompt,
68
69
  resolveBudgetReserveTokens,
@@ -121,6 +122,7 @@ import {
121
122
  } from "@oh-my-pi/pi-ai";
122
123
  import * as AIError from "@oh-my-pi/pi-ai/error";
123
124
  import { resetOpenAICodexHistoryAfterCompaction } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
125
+ import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
124
126
  import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
125
127
  import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
126
128
  import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
@@ -137,6 +139,7 @@ import {
137
139
  getInstallId,
138
140
  isBunTestRuntime,
139
141
  isEnoent,
142
+ isInteractiveHost,
140
143
  logger,
141
144
  postmortem,
142
145
  prompt,
@@ -1896,6 +1899,8 @@ export class AgentSession {
1896
1899
  * suppresses advisor concern/blocker auto-resume until the user next resumes.
1897
1900
  * Advisor advice is still recorded into the transcript, just not auto-run. */
1898
1901
  #advisorAutoResumeSuppressed = false;
1902
+ /** Print-mode sessions preserve advisor notes without starting hidden primary turns. */
1903
+ #preserveAdvisorAdvice = false;
1899
1904
  #advisorPrimaryTurnsCompleted = 0;
1900
1905
  #advisorInterruptImmuneTurnStart: number | undefined;
1901
1906
  #planModeState: PlanModeState | undefined;
@@ -2034,6 +2039,8 @@ export class AgentSession {
2034
2039
  #turnIndex = 0;
2035
2040
  #messageEndPersistenceTail: Promise<void> = Promise.resolve();
2036
2041
  #pendingMessageEndPersistence = new Map<string, Promise<void>>();
2042
+ /** Async lifecycle handlers for visible advisor cards emitted outside the primary loop. */
2043
+ #pendingAdvisorCardEvents = new Set<Promise<void>>();
2037
2044
  #persistedMessageKeys: { anchor: string; keys: Set<string> } | undefined;
2038
2045
 
2039
2046
  #skills: Skill[];
@@ -2524,7 +2531,13 @@ export class AgentSession {
2524
2531
  const isPlanNudge = (m: AgentMessage): boolean =>
2525
2532
  m.role === "custom" && m.customType === PREWALK_PLAN_MESSAGE_TYPE;
2526
2533
  for (let i = liveMessages.length - 1; i >= 0; i--) {
2527
- if (isPlanNudge(liveMessages[i])) liveMessages.splice(i, 1);
2534
+ if (isPlanNudge(liveMessages[i])) {
2535
+ // Interior removal on the live array: drop the scrubbed message from
2536
+ // the convert/estimate caches so the next convert can't reuse a prefix
2537
+ // that still carries its fragment (the array shrinks in place).
2538
+ invalidateMessageCache(liveMessages[i]);
2539
+ liveMessages.splice(i, 1);
2540
+ }
2528
2541
  }
2529
2542
  const stateMessages = this.agent.state.messages;
2530
2543
  const filtered = stateMessages.filter(m => !isPlanNudge(m));
@@ -3364,6 +3377,7 @@ export class AgentSession {
3364
3377
  const channel = resolveAdvisorDeliveryChannel({
3365
3378
  severity,
3366
3379
  autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
3380
+ preserveOnly: this.#preserveAdvisorAdvice,
3367
3381
  // Key on the live agent-core loop, not session `isStreaming` (which also
3368
3382
  // counts `#promptInFlightCount` during post-turn unwind). Only a running
3369
3383
  // loop consumes a steer at its next boundary.
@@ -4163,30 +4177,58 @@ export class AgentSession {
4163
4177
  return queued;
4164
4178
  }
4165
4179
 
4180
+ /**
4181
+ * Orders subscriber fan-out across concurrent `#emitSessionEvent` calls.
4182
+ * Extension emits only await when the event type has handlers, so an event
4183
+ * with no handlers could otherwise overtake an earlier event still inside
4184
+ * its extension emit — an instant refusal delivered its assistant
4185
+ * `message_end` to the TUI before its own `message_start`, skipping the
4186
+ * turn-ending error render entirely.
4187
+ */
4188
+ #subscriberEmitGate: Promise<void> = Promise.resolve();
4189
+
4166
4190
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
4167
4191
  if (event.type === "message_update") {
4168
4192
  this.#emit(event);
4169
4193
  void this.#queueExtensionEvent(event);
4170
4194
  return;
4171
4195
  }
4172
- await this.#emitExtensionEvent(event);
4173
- // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
4174
- // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
4175
- // emitting while #promptInFlightCount > 0 lets a client fire its next
4176
- // `prompt` into a session that still reports isStreaming === true. Flush
4177
- // happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
4178
- // an auto-compaction turn that starts before the original prompt unwinds)
4179
- // supersedes the pending one, which is what subscribers want — they only
4180
- // care about the final settle.
4181
- if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
4182
- this.#pendingAgentEndEmit = event;
4183
- return;
4196
+ // Take a FIFO ticket before the extension emit: extension deliveries for
4197
+ // consecutive events still run concurrently, but subscriber fan-out waits
4198
+ // for every earlier event's fan-out (or deferral) to happen first.
4199
+ const previousGate = this.#subscriberEmitGate;
4200
+ const { promise: gate, resolve: releaseGate } = Promise.withResolvers<void>();
4201
+ this.#subscriberEmitGate = gate;
4202
+ try {
4203
+ await this.#emitExtensionEvent(event);
4204
+ await previousGate;
4205
+ // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
4206
+ // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
4207
+ // emitting while #promptInFlightCount > 0 lets a client fire its next
4208
+ // `prompt` into a session that still reports isStreaming === true. Flush
4209
+ // happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
4210
+ // an auto-compaction turn that starts before the original prompt unwinds)
4211
+ // supersedes the pending one, which is what subscribers want — they only
4212
+ // care about the final settle.
4213
+ if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
4214
+ this.#pendingAgentEndEmit = event;
4215
+ return;
4216
+ }
4217
+ this.#emit(event);
4218
+ } finally {
4219
+ releaseGate();
4184
4220
  }
4185
- this.#emit(event);
4186
4221
  }
4187
4222
 
4188
4223
  // Track last assistant message for auto-compaction check
4189
4224
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
4225
+ /**
4226
+ * Classifier-refusal turn pruned from active context at settle (#3591).
4227
+ * Retained until the next run starts so post-settle readers
4228
+ * ({@link getLastAssistantMessage}: print mode, task executor) still see
4229
+ * the terminal error instead of a silently successful-looking state.
4230
+ */
4231
+ #prunedTerminalRefusal: AssistantMessage | undefined = undefined;
4190
4232
 
4191
4233
  /** Internal handler for agent events - shared by subscribe and reconnect.
4192
4234
  *
@@ -4204,7 +4246,12 @@ export class AgentSession {
4204
4246
  * everything it schedules — settles. */
4205
4247
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
4206
4248
  if (event.type !== "agent_end") {
4207
- return this.#processAgentEvent(event);
4249
+ const processing = this.#processAgentEvent(event);
4250
+ if ((event.type === "message_start" || event.type === "message_end") && isAdvisorCard(event.message)) {
4251
+ this.#pendingAdvisorCardEvents.add(processing);
4252
+ void processing.finally(() => this.#pendingAdvisorCardEvents.delete(processing)).catch(() => {});
4253
+ }
4254
+ return processing;
4208
4255
  }
4209
4256
  const { promise, resolve } = Promise.withResolvers<void>();
4210
4257
  this.#trackPostPromptTask(promise);
@@ -4446,6 +4493,11 @@ export class AgentSession {
4446
4493
  }
4447
4494
 
4448
4495
  #processAgentEvent = async (event: AgentEvent): Promise<void> => {
4496
+ // A fresh run supersedes the previously settled (and pruned) refusal
4497
+ // turn: state-based lookups take over again.
4498
+ if (event.type === "agent_start") {
4499
+ this.#prunedTerminalRefusal = undefined;
4500
+ }
4449
4501
  // Step the mid-run todo counter synchronously, BEFORE any await in this
4450
4502
  // handler. The agent loop's next-turn `getAsideMessages` poll can run
4451
4503
  // before queued microtasks drain, so `#takeMidRunTodoNudge` MUST see the
@@ -4464,6 +4516,19 @@ export class AgentSession {
4464
4516
  } else if (!isError && MID_RUN_TODO_NUDGE_MUTATING_TOOLS[toolName]) {
4465
4517
  this.#mutationsSinceLastTodoTouch++;
4466
4518
  }
4519
+ // A tool actually ran. Clear the post-reminder suppression synchronously
4520
+ // too: the settle check (`#checkTodoCompletion` in agent_end maintenance)
4521
+ // can otherwise read the stale flag when a tool result and the terminal
4522
+ // stop land in the same tick, swallowing the earned re-escalation.
4523
+ this.#todoReminderAwaitingProgress = false;
4524
+ }
4525
+ // Track the settled assistant turn synchronously as well: agent_end
4526
+ // maintenance reads `#lastAssistantMessage`, and when a turn's events all
4527
+ // land in one tick its handler can run before this handler's post-emit
4528
+ // bookkeeping — leaving maintenance looking at the previous (e.g.
4529
+ // toolUse) assistant message and skipping settle-only work.
4530
+ if (event.type === "message_end" && event.message.role === "assistant") {
4531
+ this.#lastAssistantMessage = event.message;
4467
4532
  }
4468
4533
  // Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
4469
4534
  // persisted message BEFORE the obfuscator's display-side copy below.
@@ -4670,9 +4735,7 @@ export class AgentSession {
4670
4735
  }
4671
4736
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
4672
4737
 
4673
- // Track assistant message for auto-compaction (checked on agent_end)
4674
4738
  if (event.message.role === "assistant") {
4675
- this.#lastAssistantMessage = event.message;
4676
4739
  const assistantMsg = event.message as AssistantMessage;
4677
4740
  // Fold this turn's timing into per-model perf aggregates (drives the
4678
4741
  // /models TPS/TTFT display). Errored turns measure nothing; aborted
@@ -4742,10 +4805,6 @@ export class AgentSession {
4742
4805
  const details = isRecord(event.message.details) ? event.message.details : undefined;
4743
4806
  const semanticResult = semanticToolResult(toolName, event.message);
4744
4807
  const semanticDetails = isRecord(semanticResult?.details) ? semanticResult.details : undefined;
4745
- // A tool actually ran. Clear the post-reminder suppression: the agent did
4746
- // productive work in response to the prior nudge, so the next text-only stop
4747
- // is allowed to escalate to the next reminder if todos remain incomplete.
4748
- this.#todoReminderAwaitingProgress = false;
4749
4808
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
4750
4809
  const editedPath = details ? getStringProperty(details, "path") : undefined;
4751
4810
  if (toolName === "edit" && editedPath) {
@@ -4971,8 +5030,12 @@ export class AgentSession {
4971
5030
  return;
4972
5031
  }
4973
5032
  }
4974
- if (this.#isRetryableError(msg)) {
4975
- const didRetry = await this.#handleRetryableError(msg);
5033
+ const resumeCursorStreamStall = this.#canResumeCursorStreamStall(msg);
5034
+ if (resumeCursorStreamStall || this.#isRetryableError(msg)) {
5035
+ const didRetry = await this.#handleRetryableError(
5036
+ msg,
5037
+ resumeCursorStreamStall ? { preserveFailedTurn: true } : undefined,
5038
+ );
4976
5039
  if (didRetry) {
4977
5040
  await emitAgentEndNotification({ willContinue: true });
4978
5041
  return;
@@ -4991,10 +5054,14 @@ export class AgentSession {
4991
5054
  }
4992
5055
  // Classifier refusals are persisted-skipped above; also prune the trailing
4993
5056
  // stub from active context so the next turn's prompt does not replay it.
5057
+ // Keep a reference for post-settle readers (print mode, task executor via
5058
+ // getLastAssistantMessage) — pruning made the terminal error invisible to
5059
+ // anything inspecting agent state after prompt() resolved.
4994
5060
  // Fall through to the standard error tail so `session_stop` hooks (block,
4995
5061
  // continue, telemetry) still fire — matching the pre-fix flow for
4996
5062
  // `stopReason === "error"`.
4997
5063
  if (this.#isClassifierRefusal(msg)) {
5064
+ this.#prunedTerminalRefusal = msg;
4998
5065
  this.#removeAssistantMessageFromActiveContext(msg);
4999
5066
  }
5000
5067
  this.#resolveRetry();
@@ -6714,6 +6781,83 @@ export class AgentSession {
6714
6781
  return this.#disposeCall;
6715
6782
  }
6716
6783
 
6784
+ async #disposeOwnedAsyncJobs(): Promise<void> {
6785
+ this.#cancelOwnAsyncJobs();
6786
+ const manager = this.#ownedAsyncJobManager;
6787
+ if (!manager) return;
6788
+
6789
+ try {
6790
+ const drained = await manager.dispose({ timeoutMs: 3_000 });
6791
+ const deliveryState = manager.getDeliveryState();
6792
+ if (drained === false && deliveryState) {
6793
+ logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
6794
+ }
6795
+ } finally {
6796
+ if (AsyncJobManager.instance() === manager) {
6797
+ AsyncJobManager.setInstance(undefined);
6798
+ }
6799
+ }
6800
+ }
6801
+
6802
+ async #disposeEvalKernels(): Promise<void> {
6803
+ const settled = await this.#prepareEvalExecutionsForDispose();
6804
+ if (!settled) {
6805
+ logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
6806
+ }
6807
+
6808
+ const results = await Promise.allSettled([
6809
+ disposeKernelSessionsByOwner(this.#evalKernelOwnerId),
6810
+ disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId),
6811
+ disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId),
6812
+ ]);
6813
+ const errors: unknown[] = [];
6814
+ for (const result of results) {
6815
+ if (result.status === "rejected") errors.push(result.reason);
6816
+ }
6817
+ if (errors.length > 0) throw new AggregateError(errors, "Failed to dispose one or more eval kernels");
6818
+ }
6819
+
6820
+ async #releaseOwnedBrowserTabs(ownerId: string | undefined): Promise<void> {
6821
+ if (!ownerId) return;
6822
+ try {
6823
+ const released = await withTimeout(
6824
+ releaseTabsForOwner(ownerId, { kill: true }),
6825
+ 3_000,
6826
+ "Timed out releasing owned browser tabs during dispose",
6827
+ );
6828
+ if (released > 0) {
6829
+ logger.debug("Released owned browser tabs during dispose", { ownerId, released });
6830
+ }
6831
+ } catch (error) {
6832
+ logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
6833
+ }
6834
+ }
6835
+
6836
+ async #disconnectOwnedMcp(): Promise<void> {
6837
+ if (!this.#disconnectOwnedMcpManager) return;
6838
+ try {
6839
+ await withTimeout(
6840
+ this.#disconnectOwnedMcpManager(),
6841
+ 3_000,
6842
+ "Timed out disconnecting owned MCP manager during dispose",
6843
+ );
6844
+ } catch (error) {
6845
+ logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
6846
+ }
6847
+ }
6848
+
6849
+ async #disposeMnemopi(
6850
+ state: MnemopiSessionState | undefined,
6851
+ consolidateTimeoutMs: number | undefined,
6852
+ ): Promise<void> {
6853
+ try {
6854
+ await state?.dispose({ timeoutMs: consolidateTimeoutMs });
6855
+ } finally {
6856
+ // Consolidation may embed final memories, so terminate its worker only afterward.
6857
+ await shutdownMnemopiEmbedClient();
6858
+ }
6859
+ }
6860
+
6717
6861
  async #doDispose(options: AgentSessionDisposeOptions = {}): Promise<void> {
6718
6862
  this.beginDispose();
6719
6863
  this.#recordSessionExit(options.reason ?? "dispose");
@@ -6726,124 +6870,50 @@ export class AgentSession {
6726
6870
  } catch (error) {
6727
6871
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6728
6872
  }
6729
- // Clear any timers extensions scheduled via `ctx.setInterval`/`ctx.setTimeout`
6730
- // so their background work does not outlive the session (issue #5664).
6731
- // Optional-called: hosts and tests may inject partial runner facades that
6732
- // implement only the dispatch surface.
6873
+
6874
+ // Stop extension timers before aborting deferred work they could enqueue.
6733
6875
  this.#extensionRunner?.clearManagedTimers?.();
6734
6876
  this.#fallbackExtensionTimers?.clearAll();
6735
- // Abort post-prompt work so the drain below can complete. Without this, a
6736
- // deferred-handoff task that has already advanced into
6737
- // `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
6738
- // — Promise.allSettled() in #cancelPostPromptTasks then waits forever, freezing
6739
- // /exit and Ctrl+C-double-tap. The post-prompt task's own AbortSignal does not
6740
- // propagate into the inner handoff/compaction controllers, so we abort them
6741
- // explicitly. agent.abort() is needed for an agent.continue() that may have
6742
- // raced the deferred handoff (its streaming loop is awaited by the wrapper IIFE).
6743
- //
6744
- // Tool work (bash/eval/python) is NOT aborted here — those have their own
6745
- // dispose paths and shared kernels are contractually allowed to survive a
6746
- // session's dispose.
6747
6877
  this.abortRetry();
6748
6878
  this.abortCompaction();
6749
6879
  const postPromptDrain = this.#cancelPostPromptTasks();
6750
6880
  this.agent.abort();
6751
- await postPromptDrain;
6752
- await this.#drainAutolearnCapture();
6753
- // Cancel jobs this agent registered so a subagent's teardown doesn't
6754
- // leak its background bash/task work into the parent's manager. Only
6755
- // the session that owns the manager goes on to dispose it (which itself
6756
- // nukes any leftover jobs and pending deliveries).
6757
- this.#cancelOwnAsyncJobs();
6758
- const ownedAsyncManager = this.#ownedAsyncJobManager;
6759
- if (ownedAsyncManager) {
6760
- const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
6761
- const deliveryState = ownedAsyncManager.getDeliveryState();
6762
- if (drained === false && deliveryState) {
6763
- logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
6764
- }
6765
- if (AsyncJobManager.instance() === ownedAsyncManager) {
6766
- AsyncJobManager.setInstance(undefined);
6767
- }
6768
- }
6769
- const evalExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
6770
- if (!evalExecutionsSettled) {
6771
- logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
6881
+ try {
6882
+ await withTimeout(postPromptDrain, 5_000, "Timed out draining post-prompt tasks during dispose");
6883
+ } catch (error) {
6884
+ logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
6772
6885
  }
6773
- await disposeKernelSessionsByOwner(this.#evalKernelOwnerId);
6774
- await disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId);
6775
- await disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId);
6776
- // Release headless / spawned Chromium and worker tabs this session
6777
- // opened via the browser tool. The tool's `tabs`/`browsers` maps are
6778
- // module-global subagents and future sessions share them — so we
6779
- // walk by `ownerSessionId` (assigned at `acquireTab` creation, never on
6780
- // reuse) and touch only what THIS session created. Bounded so a broken
6781
- // CDP close cannot stall `/exit`; mirrors the async-job/MCP pattern.
6782
- // (Issue #3963.)
6783
- const browserOwnerId = this.sessionManager.getSessionId();
6784
- if (browserOwnerId) {
6785
- try {
6786
- const released = await withTimeout(
6787
- releaseTabsForOwner(browserOwnerId, { kill: true }),
6788
- 3_000,
6789
- "Timed out releasing owned browser tabs during dispose",
6790
- );
6791
- if (released > 0) {
6792
- logger.debug("Released owned browser tabs during dispose", { ownerId: browserOwnerId, released });
6793
- }
6794
- } catch (error) {
6795
- logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
6886
+ await this.#drainAutolearnCapture();
6887
+
6888
+ const hindsightState = this.getHindsightSessionState();
6889
+ const mnemopiState = setMnemopiSessionState(this, undefined);
6890
+ const advisorRecorderClosed = this.#advisorRecorderClosed;
6891
+ const results = await Promise.allSettled([
6892
+ this.#disposeOwnedAsyncJobs(),
6893
+ this.#disposeEvalKernels(),
6894
+ this.#releaseOwnedBrowserTabs(this.sessionManager.getSessionId()),
6895
+ shutdownTinyTitleClient(),
6896
+ this.#disconnectOwnedMcp(),
6897
+ advisorRecorderClosed,
6898
+ hindsightState?.flushRetainQueue() ?? Promise.resolve(),
6899
+ this.#disposeMnemopi(mnemopiState, options.mnemopiConsolidateTimeoutMs),
6900
+ ]);
6901
+ for (const result of results) {
6902
+ if (result.status === "rejected") {
6903
+ logger.warn("Session dispose subsystem failed during parallel teardown", {
6904
+ error: String(result.reason),
6905
+ });
6796
6906
  }
6797
6907
  }
6798
- await shutdownTinyTitleClient();
6908
+
6799
6909
  this.#releasePowerAssertion();
6800
- // Clean up an empty session created by this session's /move so it doesn't accumulate.
6801
6910
  await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
6802
6911
  this.#movedFromEmptySessionFile = undefined;
6912
+ // All teardown branches that can append session entries have settled.
6803
6913
  await this.sessionManager.close();
6804
- // beginDispose() stopped the advisor and captured its recorder close; await
6805
- // it so the final advisor turn is flushed before the process may exit.
6806
- await this.#advisorRecorderClosed;
6807
6914
  this.#closeAllProviderSessions("dispose");
6808
- // Disconnect the MCP manager this session OWNS so its stdio servers are
6809
- // not orphaned at exit. Best-effort: a failure here must never throw out
6810
- // of dispose. Only owning (top-level) sessions provide this callback;
6811
- // subagents reuse a parent's manager and must not tear it down. Idempotent
6812
- // with the deferred-discovery disconnect in `createAgentSession`.
6813
- //
6814
- // BOUNDED: an owned manager may hold an HTTP/SSE server whose session-
6815
- // termination DELETE blocks up to the MCP request timeout (30s default,
6816
- // unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()`
6817
- // unbounded would stall /exit and print-mode shutdown on a broken remote
6818
- // endpoint. Race it against a short deadline — stdio close (the subprocess
6819
- // reap this targets) completes well within the bound; a slow transport
6820
- // close is left to finish detached. Mirrors the bounded async-job teardown.
6821
- if (this.#disconnectOwnedMcpManager) {
6822
- try {
6823
- await withTimeout(
6824
- this.#disconnectOwnedMcpManager(),
6825
- 3_000,
6826
- "Timed out disconnecting owned MCP manager during dispose",
6827
- );
6828
- } catch (error) {
6829
- logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
6830
- }
6831
- }
6832
- // Flush the retain queue BEFORE clearing the session's pointer so
6833
- // `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
6834
- // Reversed, the spliced batch survives just long enough to fail the
6835
- // identity check and get dropped with a `session vanished` warning.
6836
- const hindsightState = this.getHindsightSessionState();
6837
- await hindsightState?.flushRetainQueue();
6838
6915
  this.setHindsightSessionState(undefined);
6839
6916
  hindsightState?.dispose();
6840
- const mnemopiState = setMnemopiSessionState(this, undefined);
6841
- await mnemopiState?.dispose({ timeoutMs: options.mnemopiConsolidateTimeoutMs });
6842
- // Tear down the embeddings subprocess AFTER mnemopi state.dispose:
6843
- // consolidate-on-dispose may still call `embed()` to store the final
6844
- // memories, and that round-trips through the worker we are about to
6845
- // hard-kill (issue #3031).
6846
- await shutdownMnemopiEmbedClient();
6847
6917
  this.#disconnectFromAgent();
6848
6918
  if (this.#unsubscribeAppendOnly) {
6849
6919
  this.#unsubscribeAppendOnly();
@@ -6944,6 +7014,53 @@ export class AgentSession {
6944
7014
  await this.agent.waitForIdle();
6945
7015
  await this.#waitForPostPromptRecovery();
6946
7016
  }
7017
+ /**
7018
+ * Prevent advisor notes from starting hidden primary turns while a headless
7019
+ * caller prints and drains the final primary response.
7020
+ */
7021
+ prepareForHeadlessAdvisorDrain(): void {
7022
+ this.#preserveAdvisorAdvice = true;
7023
+ }
7024
+
7025
+ async #waitForPendingAdvisorCardEvents(timeoutMs: number): Promise<boolean> {
7026
+ const deadline = Date.now() + Math.max(0, timeoutMs);
7027
+ while (this.#pendingAdvisorCardEvents.size > 0) {
7028
+ const remainingMs = deadline - Date.now();
7029
+ if (remainingMs <= 0) return false;
7030
+ const settled = Promise.allSettled([...this.#pendingAdvisorCardEvents]).then(() => true as const);
7031
+ const { promise: timedOut, resolve } = Promise.withResolvers<false>();
7032
+ const timer = setTimeout(() => resolve(false), remainingMs);
7033
+ try {
7034
+ if (!(await Promise.race([settled, timedOut]))) return false;
7035
+ } finally {
7036
+ clearTimeout(timer);
7037
+ }
7038
+ }
7039
+ return true;
7040
+ }
7041
+
7042
+ /**
7043
+ * Wait for active advisor reviews and their emitted card events before a
7044
+ * headless caller disposes the session. Returns `false` and logs work disposal
7045
+ * will abandon when the shared deadline expires or an advisor fails.
7046
+ */
7047
+ async waitForAdvisorCatchup(timeoutMs: number): Promise<boolean> {
7048
+ const deadline = Date.now() + timeoutMs;
7049
+ const results = await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(timeoutMs, 1)));
7050
+ const cardEventsCaughtUp = await this.#waitForPendingAdvisorCardEvents(Math.max(0, deadline - Date.now()));
7051
+ const abandoned = this.#advisors.filter(
7052
+ (advisor, index) => results[index] === false && advisor.runtime.backlog > 0,
7053
+ );
7054
+ if (abandoned.length > 0 || !cardEventsCaughtUp) {
7055
+ logger.warn("advisor shutdown drain incomplete; disposal will abandon reviews or cards", {
7056
+ timeoutMs,
7057
+ advisors: abandoned.map(advisor => ({ name: advisor.name, backlog: advisor.runtime.backlog })),
7058
+ pendingAdvisorCards: this.#pendingAdvisorCardEvents.size,
7059
+ });
7060
+ return false;
7061
+ }
7062
+ return true;
7063
+ }
6947
7064
 
6948
7065
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
6949
7066
  const manager = this.#asyncJobManager;
@@ -6962,9 +7079,14 @@ export class AgentSession {
6962
7079
  }
6963
7080
  }
6964
7081
 
6965
- /** Most recent assistant message in agent state. */
7082
+ /**
7083
+ * Most recent settled assistant message. A classifier-refusal turn pruned
7084
+ * from active context at settle is still reported until the next run
7085
+ * starts, so terminal-outcome consumers (print mode, task executor) see
7086
+ * the refusal error rather than the previous turn — or nothing.
7087
+ */
6966
7088
  getLastAssistantMessage(): AssistantMessage | undefined {
6967
- return this.#findLastAssistantMessage();
7089
+ return this.#prunedTerminalRefusal ?? this.#findLastAssistantMessage();
6968
7090
  }
6969
7091
  /** Current effective system prompt blocks (includes any per-turn extension modifications) */
6970
7092
  get systemPrompt(): string[] {
@@ -7695,6 +7817,12 @@ export class AgentSession {
7695
7817
  return this.#postPromptTasks.size > 0;
7696
7818
  }
7697
7819
 
7820
+ /** Register post-prompt work in tests without driving a full agent turn. */
7821
+ trackPostPromptTaskForTests(task: Promise<unknown>): void {
7822
+ if (!isBunTestRuntime()) throw new Error("trackPostPromptTaskForTests is test-only");
7823
+ this.#trackPostPromptTask(task);
7824
+ }
7825
+
7698
7826
  /** All messages including custom types like BashExecutionMessage */
7699
7827
  get messages(): AgentMessage[] {
7700
7828
  return this.agent.state.messages;
@@ -9574,6 +9702,13 @@ export class AgentSession {
9574
9702
  }
9575
9703
 
9576
9704
  #scheduleReplanTitleRefresh(): void {
9705
+ // Headless subagent sessions have no operator-visible title, so a todo-init
9706
+ // replan refresh only burns a tiny-model call whose result lands in JSONL
9707
+ // and is never shown (issue #5910). In an interactive host the operator can
9708
+ // focus a live subagent from the Agent Hub, where the status line renders
9709
+ // its session name — so keep the refresh there and only skip subagents when
9710
+ // no focusable UI exists (print/RPC/ACP/eval/SDK/CI).
9711
+ if (this.#agentKind === "sub" && !isInteractiveHost()) return;
9577
9712
  if (this.#replanTitleRefreshInFlight) return;
9578
9713
  if (!this.settings.get("title.refreshOnReplan")) return;
9579
9714
  if (this.sessionManager.titleSource === "user") return;
@@ -11929,7 +12064,8 @@ export class AgentSession {
11929
12064
  this.#emptyStopRetryCount++;
11930
12065
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
11931
12066
  const attempts = this.#emptyStopRetryCount - 1;
11932
- const finalError = "Assistant returned empty stop after retry cap";
12067
+ const finalError =
12068
+ "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
11933
12069
  logger.warn(finalError, {
11934
12070
  attempts,
11935
12071
  model: assistantMessage.model,
@@ -11944,11 +12080,11 @@ export class AgentSession {
11944
12080
  this.#clearPendingRecoveredRetryErrors();
11945
12081
  this.#retryAttempt = 0;
11946
12082
  this.#resolveRetry();
11947
- // Tool-use orphans corrupt Anthropic message history (tool_result without
11948
- // matching tool_use). Always remove them even when the retry cap is hit.
11949
- if (assistantMessage.stopReason === "toolUse") {
11950
- this.#discardAssistantTurn(assistantMessage);
11951
- }
12083
+ // A zero-content turn carries no transcript value, while its provider usage
12084
+ // can anchor the next prompt at the full failed-request size and re-trigger
12085
+ // compaction at the same boundary. Remove every capped empty stop; toolUse
12086
+ // orphans still need this for Anthropic message-history validity.
12087
+ await this.#dropPersistedAssistantTurn(assistantMessage);
11952
12088
  return false;
11953
12089
  }
11954
12090
  this.#discardAssistantTurn(assistantMessage);
@@ -13758,35 +13894,80 @@ export class AgentSession {
13758
13894
  this.#getCompactionModelCandidates(availableModels),
13759
13895
  this.sessionId,
13760
13896
  );
13761
- const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates);
13897
+ let pathEntriesForCompaction = pathEntries;
13898
+ let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, autoCompactionCandidates);
13762
13899
  if (!preparation) {
13763
- await this.#emitSessionEvent({
13764
- type: "auto_compaction_end",
13765
- action,
13766
- result: undefined,
13767
- aborted: false,
13768
- willRetry: false,
13769
- skipped: true,
13770
- });
13771
- const noProgressDeadEnd = reason !== "idle";
13772
- let continuationScheduled = false;
13773
- if (!suppressContinuation && this.agent.hasQueuedMessages()) {
13774
- this.#scheduleAgentContinue({
13775
- delayMs: 100,
13776
- generation,
13777
- shouldContinue: () => this.agent.hasQueuedMessages(),
13900
+ // prepareCompaction found nothing to summarize because the kept region
13901
+ // is a single oversized recent turn — findCutPoint never cuts inside a
13902
+ // tool result, so a huge tool-result / fenced block tail leaves nothing
13903
+ // on the summarizable side and summary compaction cannot even start.
13904
+ // That is exactly the dead-end the elide shake rescues: it reaches
13905
+ // INSIDE the tail and offloads heavy content to an artifact placeholder,
13906
+ // shrinking the tail so findCutPoint can then move the cut and leave
13907
+ // older turns to summarize. Run the same tiered rescue the
13908
+ // post-maintenance guard uses (elide, then image drop), with progress
13909
+ // defined as "prepareCompaction now succeeds on the rewritten branch",
13910
+ // and fall through to the normal compaction body when it does (writing
13911
+ // a compaction entry anchors the stale billed usage so the
13912
+ // auto-continue re-check cannot re-trip and loop the warning — issue
13913
+ // #4786). `skipElide` when we already fell through from a shake
13914
+ // strategy pass (it tried and found nothing); skip entirely on the
13915
+ // idle timer (it re-checks usage on its own cadence).
13916
+ let rescueRewroteHistory = false;
13917
+ if (reason !== "idle") {
13918
+ await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
13919
+ skipElide: fallbackFromShake,
13920
+ hasProgress: () => {
13921
+ // Only reached when a tier actually freed something, so the
13922
+ // branch has been rewritten either way.
13923
+ rescueRewroteHistory = true;
13924
+ pathEntriesForCompaction = this.sessionManager.getBranch();
13925
+ preparation = prepareCompaction(
13926
+ pathEntriesForCompaction,
13927
+ compactionSettings,
13928
+ autoCompactionCandidates,
13929
+ );
13930
+ return preparation !== undefined;
13931
+ },
13778
13932
  });
13779
- continuationScheduled = true;
13780
13933
  }
13781
- if (noProgressDeadEnd) {
13782
- this.emitNotice(
13783
- "warning",
13784
- compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
13785
- "compaction",
13786
- );
13934
+ if (!preparation) {
13935
+ await this.#emitSessionEvent({
13936
+ type: "auto_compaction_end",
13937
+ action,
13938
+ result: undefined,
13939
+ aborted: false,
13940
+ willRetry: false,
13941
+ skipped: true,
13942
+ });
13943
+ const noProgressDeadEnd = reason !== "idle";
13944
+ let continuationScheduled = false;
13945
+ if (!suppressContinuation && this.agent.hasQueuedMessages()) {
13946
+ this.#scheduleAgentContinue({
13947
+ delayMs: 100,
13948
+ generation,
13949
+ shouldContinue: () => this.agent.hasQueuedMessages(),
13950
+ });
13951
+ continuationScheduled = true;
13952
+ }
13953
+ if (noProgressDeadEnd) {
13954
+ this.emitNotice(
13955
+ "warning",
13956
+ compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
13957
+ "compaction",
13958
+ );
13959
+ }
13960
+ // A rescue that offloaded content but still could not produce a
13961
+ // preparation rewrote the branch; flag it so the overflow-recovery
13962
+ // rollback does not re-restore the just-failed assistant turn on top
13963
+ // of the elided tail.
13964
+ const base = continuationScheduled
13965
+ ? COMPACTION_CHECK_CONTINUATION
13966
+ : noProgressDeadEnd
13967
+ ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION
13968
+ : COMPACTION_CHECK_NONE;
13969
+ return rescueRewroteHistory ? { ...base, historyRewritten: true } : base;
13787
13970
  }
13788
- if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
13789
- return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
13790
13971
  }
13791
13972
 
13792
13973
  let hookCompaction: CompactionResult | undefined;
@@ -13798,7 +13979,7 @@ export class AgentSession {
13798
13979
  const hookResult = (await this.#extensionRunner.emit({
13799
13980
  type: "session_before_compact",
13800
13981
  preparation,
13801
- branchEntries: pathEntries,
13982
+ branchEntries: pathEntriesForCompaction,
13802
13983
  customInstructions: undefined,
13803
13984
  signal: autoCompactionSignal,
13804
13985
  })) as SessionBeforeCompactResult | undefined;
@@ -14512,6 +14693,50 @@ export class AgentSession {
14512
14693
  if (this.#isClassifierRefusal(message)) return true;
14513
14694
  return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
14514
14695
  }
14696
+
14697
+ /**
14698
+ * Resume a stalled Cursor turn after every server-executed tool has produced
14699
+ * a result. The failed assistant/tool-result pair must stay in context: it
14700
+ * records completed side effects and lets the next request continue from
14701
+ * them instead of replaying the original turn.
14702
+ */
14703
+ #canResumeCursorStreamStall(message: AssistantMessage): boolean {
14704
+ if (
14705
+ message.provider !== "cursor" ||
14706
+ message.stopReason !== "error" ||
14707
+ !message.errorMessage?.toLowerCase().includes("stream stall")
14708
+ ) {
14709
+ return false;
14710
+ }
14711
+ const id = this.#classifyRetryMessage(message);
14712
+ if (!AIError.retriable(id)) return false;
14713
+
14714
+ const resolvedToolCallIds: string[] = [];
14715
+ for (const block of message.content) {
14716
+ if (block.type !== "toolCall") continue;
14717
+ if (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true) return false;
14718
+ resolvedToolCallIds.push(block.id);
14719
+ }
14720
+ if (resolvedToolCallIds.length === 0) return false;
14721
+
14722
+ const messages = this.agent.state.messages;
14723
+ let assistantIndex = -1;
14724
+ for (let i = messages.length - 1; i >= 0; i--) {
14725
+ const candidate = messages[i];
14726
+ if (candidate.role === "assistant" && this.#isSameAssistantMessage(candidate, message)) {
14727
+ assistantIndex = i;
14728
+ break;
14729
+ }
14730
+ }
14731
+ if (assistantIndex < 0) return false;
14732
+
14733
+ const unresolvedToolCallIds = new Set(resolvedToolCallIds);
14734
+ for (let i = assistantIndex + 1; i < messages.length; i++) {
14735
+ const candidate = messages[i];
14736
+ if (candidate.role === "toolResult") unresolvedToolCallIds.delete(candidate.toolCallId);
14737
+ }
14738
+ return unresolvedToolCallIds.size === 0;
14739
+ }
14515
14740
  /**
14516
14741
  * Retried turns remove the failed assistant message from active context.
14517
14742
  * Text/thinking-only partials are safe to discard and replay. Retained
@@ -15089,7 +15314,12 @@ export class AgentSession {
15089
15314
  */
15090
15315
  async #handleRetryableError(
15091
15316
  message: AssistantMessage,
15092
- options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean; hardErrorFallback?: boolean },
15317
+ options?: {
15318
+ allowModelFallback?: boolean;
15319
+ fireworksFastFallback?: boolean;
15320
+ hardErrorFallback?: boolean;
15321
+ preserveFailedTurn?: boolean;
15322
+ },
15093
15323
  ): Promise<boolean> {
15094
15324
  const retrySettings = this.settings.getGroup("retry");
15095
15325
  // The Fireworks Fast→base degrade is an intrinsic model-selection safety net,
@@ -15280,8 +15510,11 @@ export class AgentSession {
15280
15510
  errorId: message.errorId,
15281
15511
  });
15282
15512
 
15283
- // Remove the failed assistant message from active context before retrying.
15284
- this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
15513
+ // Cursor exec-channel tools have already run and emitted results. Keep that
15514
+ // failed turn intact so continuation cannot repeat their side effects.
15515
+ if (!options?.preserveFailedTurn) {
15516
+ this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
15517
+ }
15285
15518
 
15286
15519
  // A thinking/response loop retried into identical context loops again. Inject a
15287
15520
  // hidden redirect so the retried turn sees a directive to break the repeated