@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.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 (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  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/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -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,
@@ -261,7 +264,7 @@ import {
261
264
  estimateToolSchemaTokens,
262
265
  } from "../modes/utils/context-usage";
263
266
  import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
264
- import { resolveApprovedPlan } from "../plan-mode/approved-plan";
267
+ import { type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan";
265
268
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
266
269
  import type { PlanModeState } from "../plan-mode/state";
267
270
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
@@ -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));
@@ -2554,6 +2567,24 @@ export class AgentSession {
2554
2567
  this.setPlanProposalHandler(title => this.#approvePlanYoloProposal(title));
2555
2568
  }
2556
2569
 
2570
+ /** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
2571
+ async preparePlanForReview(title: string): Promise<AgentToolResult<PlanApprovalDetails>> {
2572
+ const state = this.getPlanModeState();
2573
+ if (!state?.enabled) {
2574
+ throw new ToolError("Plan mode is not active.");
2575
+ }
2576
+ const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
2577
+ suppliedTitle: title,
2578
+ statePlanFilePath: state.planFilePath,
2579
+ readPlan: url => this.#readPlanFile(url),
2580
+ listPlanFiles: () => this.#listPlanFiles(),
2581
+ });
2582
+ return {
2583
+ content: [{ type: "text", text: "Plan ready for review." }],
2584
+ details: { planFilePath, title: resolvedTitle, planExists: true },
2585
+ };
2586
+ }
2587
+
2557
2588
  /**
2558
2589
  * Plan-proposal handler while PlanYolo's plan phase is active. Auto-approves
2559
2590
  * the instant the model writes the plan slug/title to `xd://propose` — no
@@ -2574,8 +2605,8 @@ export class AgentSession {
2574
2605
  const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
2575
2606
  suppliedTitle: title,
2576
2607
  statePlanFilePath: state.planFilePath,
2577
- readPlan: url => this.#readPlanYoloFile(url),
2578
- listPlanFiles: () => this.#listPlanYoloFiles(),
2608
+ readPlan: url => this.#readPlanFile(url),
2609
+ listPlanFiles: () => this.#listPlanFiles(),
2579
2610
  });
2580
2611
  this.setPlanModeState(undefined);
2581
2612
  const previousTools = this.#planYoloPreviousTools;
@@ -2610,7 +2641,7 @@ export class AgentSession {
2610
2641
  };
2611
2642
  }
2612
2643
 
2613
- async #readPlanYoloFile(planFilePath: string): Promise<string | null> {
2644
+ async #readPlanFile(planFilePath: string): Promise<string | null> {
2614
2645
  const resolvedPath = planFilePath.startsWith("local:")
2615
2646
  ? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), this.#localProtocolOptions())
2616
2647
  : resolveToCwd(planFilePath, this.sessionManager.getCwd());
@@ -2624,7 +2655,7 @@ export class AgentSession {
2624
2655
 
2625
2656
  /** `local://` URLs of plan files in the session-local root, newest first —
2626
2657
  * a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */
2627
- async #listPlanYoloFiles(): Promise<string[]> {
2658
+ async #listPlanFiles(): Promise<string[]> {
2628
2659
  const localRoot = resolveLocalUrlToPath("local://", this.#localProtocolOptions());
2629
2660
  try {
2630
2661
  const entries = await fs.promises.readdir(localRoot, { withFileTypes: true });
@@ -3364,6 +3395,7 @@ export class AgentSession {
3364
3395
  const channel = resolveAdvisorDeliveryChannel({
3365
3396
  severity,
3366
3397
  autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
3398
+ preserveOnly: this.#preserveAdvisorAdvice,
3367
3399
  // Key on the live agent-core loop, not session `isStreaming` (which also
3368
3400
  // counts `#promptInFlightCount` during post-turn unwind). Only a running
3369
3401
  // loop consumes a steer at its next boundary.
@@ -4163,30 +4195,58 @@ export class AgentSession {
4163
4195
  return queued;
4164
4196
  }
4165
4197
 
4198
+ /**
4199
+ * Orders subscriber fan-out across concurrent `#emitSessionEvent` calls.
4200
+ * Extension emits only await when the event type has handlers, so an event
4201
+ * with no handlers could otherwise overtake an earlier event still inside
4202
+ * its extension emit — an instant refusal delivered its assistant
4203
+ * `message_end` to the TUI before its own `message_start`, skipping the
4204
+ * turn-ending error render entirely.
4205
+ */
4206
+ #subscriberEmitGate: Promise<void> = Promise.resolve();
4207
+
4166
4208
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
4167
4209
  if (event.type === "message_update") {
4168
4210
  this.#emit(event);
4169
4211
  void this.#queueExtensionEvent(event);
4170
4212
  return;
4171
4213
  }
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;
4214
+ // Take a FIFO ticket before the extension emit: extension deliveries for
4215
+ // consecutive events still run concurrently, but subscriber fan-out waits
4216
+ // for every earlier event's fan-out (or deferral) to happen first.
4217
+ const previousGate = this.#subscriberEmitGate;
4218
+ const { promise: gate, resolve: releaseGate } = Promise.withResolvers<void>();
4219
+ this.#subscriberEmitGate = gate;
4220
+ try {
4221
+ await this.#emitExtensionEvent(event);
4222
+ await previousGate;
4223
+ // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
4224
+ // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
4225
+ // emitting while #promptInFlightCount > 0 lets a client fire its next
4226
+ // `prompt` into a session that still reports isStreaming === true. Flush
4227
+ // happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
4228
+ // an auto-compaction turn that starts before the original prompt unwinds)
4229
+ // supersedes the pending one, which is what subscribers want — they only
4230
+ // care about the final settle.
4231
+ if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
4232
+ this.#pendingAgentEndEmit = event;
4233
+ return;
4234
+ }
4235
+ this.#emit(event);
4236
+ } finally {
4237
+ releaseGate();
4184
4238
  }
4185
- this.#emit(event);
4186
4239
  }
4187
4240
 
4188
4241
  // Track last assistant message for auto-compaction check
4189
4242
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
4243
+ /**
4244
+ * Classifier-refusal turn pruned from active context at settle (#3591).
4245
+ * Retained until the next run starts so post-settle readers
4246
+ * ({@link getLastAssistantMessage}: print mode, task executor) still see
4247
+ * the terminal error instead of a silently successful-looking state.
4248
+ */
4249
+ #prunedTerminalRefusal: AssistantMessage | undefined = undefined;
4190
4250
 
4191
4251
  /** Internal handler for agent events - shared by subscribe and reconnect.
4192
4252
  *
@@ -4204,7 +4264,12 @@ export class AgentSession {
4204
4264
  * everything it schedules — settles. */
4205
4265
  #handleAgentEvent = async (event: AgentEvent): Promise<void> => {
4206
4266
  if (event.type !== "agent_end") {
4207
- return this.#processAgentEvent(event);
4267
+ const processing = this.#processAgentEvent(event);
4268
+ if ((event.type === "message_start" || event.type === "message_end") && isAdvisorCard(event.message)) {
4269
+ this.#pendingAdvisorCardEvents.add(processing);
4270
+ void processing.finally(() => this.#pendingAdvisorCardEvents.delete(processing)).catch(() => {});
4271
+ }
4272
+ return processing;
4208
4273
  }
4209
4274
  const { promise, resolve } = Promise.withResolvers<void>();
4210
4275
  this.#trackPostPromptTask(promise);
@@ -4446,6 +4511,11 @@ export class AgentSession {
4446
4511
  }
4447
4512
 
4448
4513
  #processAgentEvent = async (event: AgentEvent): Promise<void> => {
4514
+ // A fresh run supersedes the previously settled (and pruned) refusal
4515
+ // turn: state-based lookups take over again.
4516
+ if (event.type === "agent_start") {
4517
+ this.#prunedTerminalRefusal = undefined;
4518
+ }
4449
4519
  // Step the mid-run todo counter synchronously, BEFORE any await in this
4450
4520
  // handler. The agent loop's next-turn `getAsideMessages` poll can run
4451
4521
  // before queued microtasks drain, so `#takeMidRunTodoNudge` MUST see the
@@ -4464,6 +4534,19 @@ export class AgentSession {
4464
4534
  } else if (!isError && MID_RUN_TODO_NUDGE_MUTATING_TOOLS[toolName]) {
4465
4535
  this.#mutationsSinceLastTodoTouch++;
4466
4536
  }
4537
+ // A tool actually ran. Clear the post-reminder suppression synchronously
4538
+ // too: the settle check (`#checkTodoCompletion` in agent_end maintenance)
4539
+ // can otherwise read the stale flag when a tool result and the terminal
4540
+ // stop land in the same tick, swallowing the earned re-escalation.
4541
+ this.#todoReminderAwaitingProgress = false;
4542
+ }
4543
+ // Track the settled assistant turn synchronously as well: agent_end
4544
+ // maintenance reads `#lastAssistantMessage`, and when a turn's events all
4545
+ // land in one tick its handler can run before this handler's post-emit
4546
+ // bookkeeping — leaving maintenance looking at the previous (e.g.
4547
+ // toolUse) assistant message and skipping settle-only work.
4548
+ if (event.type === "message_end" && event.message.role === "assistant") {
4549
+ this.#lastAssistantMessage = event.message;
4467
4550
  }
4468
4551
  // Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
4469
4552
  // persisted message BEFORE the obfuscator's display-side copy below.
@@ -4670,9 +4753,7 @@ export class AgentSession {
4670
4753
  }
4671
4754
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
4672
4755
 
4673
- // Track assistant message for auto-compaction (checked on agent_end)
4674
4756
  if (event.message.role === "assistant") {
4675
- this.#lastAssistantMessage = event.message;
4676
4757
  const assistantMsg = event.message as AssistantMessage;
4677
4758
  // Fold this turn's timing into per-model perf aggregates (drives the
4678
4759
  // /models TPS/TTFT display). Errored turns measure nothing; aborted
@@ -4742,10 +4823,6 @@ export class AgentSession {
4742
4823
  const details = isRecord(event.message.details) ? event.message.details : undefined;
4743
4824
  const semanticResult = semanticToolResult(toolName, event.message);
4744
4825
  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
4826
  // Invalidate streaming edit cache when edit tool completes to prevent stale data
4750
4827
  const editedPath = details ? getStringProperty(details, "path") : undefined;
4751
4828
  if (toolName === "edit" && editedPath) {
@@ -4971,8 +5048,12 @@ export class AgentSession {
4971
5048
  return;
4972
5049
  }
4973
5050
  }
4974
- if (this.#isRetryableError(msg)) {
4975
- const didRetry = await this.#handleRetryableError(msg);
5051
+ const resumeCursorStreamStall = this.#canResumeCursorStreamStall(msg);
5052
+ if (resumeCursorStreamStall || this.#isRetryableError(msg)) {
5053
+ const didRetry = await this.#handleRetryableError(
5054
+ msg,
5055
+ resumeCursorStreamStall ? { preserveFailedTurn: true } : undefined,
5056
+ );
4976
5057
  if (didRetry) {
4977
5058
  await emitAgentEndNotification({ willContinue: true });
4978
5059
  return;
@@ -4991,10 +5072,14 @@ export class AgentSession {
4991
5072
  }
4992
5073
  // Classifier refusals are persisted-skipped above; also prune the trailing
4993
5074
  // stub from active context so the next turn's prompt does not replay it.
5075
+ // Keep a reference for post-settle readers (print mode, task executor via
5076
+ // getLastAssistantMessage) — pruning made the terminal error invisible to
5077
+ // anything inspecting agent state after prompt() resolved.
4994
5078
  // Fall through to the standard error tail so `session_stop` hooks (block,
4995
5079
  // continue, telemetry) still fire — matching the pre-fix flow for
4996
5080
  // `stopReason === "error"`.
4997
5081
  if (this.#isClassifierRefusal(msg)) {
5082
+ this.#prunedTerminalRefusal = msg;
4998
5083
  this.#removeAssistantMessageFromActiveContext(msg);
4999
5084
  }
5000
5085
  this.#resolveRetry();
@@ -6714,6 +6799,83 @@ export class AgentSession {
6714
6799
  return this.#disposeCall;
6715
6800
  }
6716
6801
 
6802
+ async #disposeOwnedAsyncJobs(): Promise<void> {
6803
+ this.#cancelOwnAsyncJobs();
6804
+ const manager = this.#ownedAsyncJobManager;
6805
+ if (!manager) return;
6806
+
6807
+ try {
6808
+ const drained = await manager.dispose({ timeoutMs: 3_000 });
6809
+ const deliveryState = manager.getDeliveryState();
6810
+ if (drained === false && deliveryState) {
6811
+ logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
6812
+ }
6813
+ } finally {
6814
+ if (AsyncJobManager.instance() === manager) {
6815
+ AsyncJobManager.setInstance(undefined);
6816
+ }
6817
+ }
6818
+ }
6819
+
6820
+ async #disposeEvalKernels(): Promise<void> {
6821
+ const settled = await this.#prepareEvalExecutionsForDispose();
6822
+ if (!settled) {
6823
+ logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
6824
+ }
6825
+
6826
+ const results = await Promise.allSettled([
6827
+ disposeKernelSessionsByOwner(this.#evalKernelOwnerId),
6828
+ disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId),
6829
+ disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId),
6830
+ ]);
6831
+ const errors: unknown[] = [];
6832
+ for (const result of results) {
6833
+ if (result.status === "rejected") errors.push(result.reason);
6834
+ }
6835
+ if (errors.length > 0) throw new AggregateError(errors, "Failed to dispose one or more eval kernels");
6836
+ }
6837
+
6838
+ async #releaseOwnedBrowserTabs(ownerId: string | undefined): Promise<void> {
6839
+ if (!ownerId) return;
6840
+ try {
6841
+ const released = await withTimeout(
6842
+ releaseTabsForOwner(ownerId, { kill: true }),
6843
+ 3_000,
6844
+ "Timed out releasing owned browser tabs during dispose",
6845
+ );
6846
+ if (released > 0) {
6847
+ logger.debug("Released owned browser tabs during dispose", { ownerId, released });
6848
+ }
6849
+ } catch (error) {
6850
+ logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
6851
+ }
6852
+ }
6853
+
6854
+ async #disconnectOwnedMcp(): Promise<void> {
6855
+ if (!this.#disconnectOwnedMcpManager) return;
6856
+ try {
6857
+ await withTimeout(
6858
+ this.#disconnectOwnedMcpManager(),
6859
+ 3_000,
6860
+ "Timed out disconnecting owned MCP manager during dispose",
6861
+ );
6862
+ } catch (error) {
6863
+ logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
6864
+ }
6865
+ }
6866
+
6867
+ async #disposeMnemopi(
6868
+ state: MnemopiSessionState | undefined,
6869
+ consolidateTimeoutMs: number | undefined,
6870
+ ): Promise<void> {
6871
+ try {
6872
+ await state?.dispose({ timeoutMs: consolidateTimeoutMs });
6873
+ } finally {
6874
+ // Consolidation may embed final memories, so terminate its worker only afterward.
6875
+ await shutdownMnemopiEmbedClient();
6876
+ }
6877
+ }
6878
+
6717
6879
  async #doDispose(options: AgentSessionDisposeOptions = {}): Promise<void> {
6718
6880
  this.beginDispose();
6719
6881
  this.#recordSessionExit(options.reason ?? "dispose");
@@ -6726,124 +6888,50 @@ export class AgentSession {
6726
6888
  } catch (error) {
6727
6889
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
6728
6890
  }
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.
6891
+
6892
+ // Stop extension timers before aborting deferred work they could enqueue.
6733
6893
  this.#extensionRunner?.clearManagedTimers?.();
6734
6894
  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
6895
  this.abortRetry();
6748
6896
  this.abortCompaction();
6749
6897
  const postPromptDrain = this.#cancelPostPromptTasks();
6750
6898
  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");
6899
+ try {
6900
+ await withTimeout(postPromptDrain, 5_000, "Timed out draining post-prompt tasks during dispose");
6901
+ } catch (error) {
6902
+ logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
6772
6903
  }
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) });
6904
+ await this.#drainAutolearnCapture();
6905
+
6906
+ const hindsightState = this.getHindsightSessionState();
6907
+ const mnemopiState = setMnemopiSessionState(this, undefined);
6908
+ const advisorRecorderClosed = this.#advisorRecorderClosed;
6909
+ const results = await Promise.allSettled([
6910
+ this.#disposeOwnedAsyncJobs(),
6911
+ this.#disposeEvalKernels(),
6912
+ this.#releaseOwnedBrowserTabs(this.sessionManager.getSessionId()),
6913
+ shutdownTinyTitleClient(),
6914
+ this.#disconnectOwnedMcp(),
6915
+ advisorRecorderClosed,
6916
+ hindsightState?.flushRetainQueue() ?? Promise.resolve(),
6917
+ this.#disposeMnemopi(mnemopiState, options.mnemopiConsolidateTimeoutMs),
6918
+ ]);
6919
+ for (const result of results) {
6920
+ if (result.status === "rejected") {
6921
+ logger.warn("Session dispose subsystem failed during parallel teardown", {
6922
+ error: String(result.reason),
6923
+ });
6796
6924
  }
6797
6925
  }
6798
- await shutdownTinyTitleClient();
6926
+
6799
6927
  this.#releasePowerAssertion();
6800
- // Clean up an empty session created by this session's /move so it doesn't accumulate.
6801
6928
  await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
6802
6929
  this.#movedFromEmptySessionFile = undefined;
6930
+ // All teardown branches that can append session entries have settled.
6803
6931
  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
6932
  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
6933
  this.setHindsightSessionState(undefined);
6839
6934
  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
6935
  this.#disconnectFromAgent();
6848
6936
  if (this.#unsubscribeAppendOnly) {
6849
6937
  this.#unsubscribeAppendOnly();
@@ -6944,6 +7032,53 @@ export class AgentSession {
6944
7032
  await this.agent.waitForIdle();
6945
7033
  await this.#waitForPostPromptRecovery();
6946
7034
  }
7035
+ /**
7036
+ * Prevent advisor notes from starting hidden primary turns while a headless
7037
+ * caller prints and drains the final primary response.
7038
+ */
7039
+ prepareForHeadlessAdvisorDrain(): void {
7040
+ this.#preserveAdvisorAdvice = true;
7041
+ }
7042
+
7043
+ async #waitForPendingAdvisorCardEvents(timeoutMs: number): Promise<boolean> {
7044
+ const deadline = Date.now() + Math.max(0, timeoutMs);
7045
+ while (this.#pendingAdvisorCardEvents.size > 0) {
7046
+ const remainingMs = deadline - Date.now();
7047
+ if (remainingMs <= 0) return false;
7048
+ const settled = Promise.allSettled([...this.#pendingAdvisorCardEvents]).then(() => true as const);
7049
+ const { promise: timedOut, resolve } = Promise.withResolvers<false>();
7050
+ const timer = setTimeout(() => resolve(false), remainingMs);
7051
+ try {
7052
+ if (!(await Promise.race([settled, timedOut]))) return false;
7053
+ } finally {
7054
+ clearTimeout(timer);
7055
+ }
7056
+ }
7057
+ return true;
7058
+ }
7059
+
7060
+ /**
7061
+ * Wait for active advisor reviews and their emitted card events before a
7062
+ * headless caller disposes the session. Returns `false` and logs work disposal
7063
+ * will abandon when the shared deadline expires or an advisor fails.
7064
+ */
7065
+ async waitForAdvisorCatchup(timeoutMs: number): Promise<boolean> {
7066
+ const deadline = Date.now() + timeoutMs;
7067
+ const results = await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(timeoutMs, 1)));
7068
+ const cardEventsCaughtUp = await this.#waitForPendingAdvisorCardEvents(Math.max(0, deadline - Date.now()));
7069
+ const abandoned = this.#advisors.filter(
7070
+ (advisor, index) => results[index] === false && advisor.runtime.backlog > 0,
7071
+ );
7072
+ if (abandoned.length > 0 || !cardEventsCaughtUp) {
7073
+ logger.warn("advisor shutdown drain incomplete; disposal will abandon reviews or cards", {
7074
+ timeoutMs,
7075
+ advisors: abandoned.map(advisor => ({ name: advisor.name, backlog: advisor.runtime.backlog })),
7076
+ pendingAdvisorCards: this.#pendingAdvisorCardEvents.size,
7077
+ });
7078
+ return false;
7079
+ }
7080
+ return true;
7081
+ }
6947
7082
 
6948
7083
  async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
6949
7084
  const manager = this.#asyncJobManager;
@@ -6962,9 +7097,14 @@ export class AgentSession {
6962
7097
  }
6963
7098
  }
6964
7099
 
6965
- /** Most recent assistant message in agent state. */
7100
+ /**
7101
+ * Most recent settled assistant message. A classifier-refusal turn pruned
7102
+ * from active context at settle is still reported until the next run
7103
+ * starts, so terminal-outcome consumers (print mode, task executor) see
7104
+ * the refusal error rather than the previous turn — or nothing.
7105
+ */
6966
7106
  getLastAssistantMessage(): AssistantMessage | undefined {
6967
- return this.#findLastAssistantMessage();
7107
+ return this.#prunedTerminalRefusal ?? this.#findLastAssistantMessage();
6968
7108
  }
6969
7109
  /** Current effective system prompt blocks (includes any per-turn extension modifications) */
6970
7110
  get systemPrompt(): string[] {
@@ -7414,11 +7554,54 @@ export class AgentSession {
7414
7554
  * Changes take effect before the next model call.
7415
7555
  */
7416
7556
  async setActiveToolsByName(toolNames: string[]): Promise<void> {
7417
- const mounted = this.#mountedXdevToolNames;
7418
7557
  const normalized = normalizeToolNames(toolNames);
7558
+ // Transport-write eligibility keys off the *current* active set: an ordinary
7559
+ // selection change should not demote `write` unless it is already active.
7560
+ await this.#applyToolPresentation(
7561
+ normalized,
7562
+ this.#mountedXdevToolNames,
7563
+ this.getActiveToolNames().includes("write"),
7564
+ );
7565
+ }
7566
+
7567
+ /**
7568
+ * Restore an enabled tool set with its exact top-level versus `xd://` partition.
7569
+ *
7570
+ * Both inputs are required because {@link setActiveToolsByName} only receives the
7571
+ * enabled name list and classifies mounts from the *current* `#mountedXdevToolNames`.
7572
+ * Rollback/restore callers must pass the snapshotted mounted subset so names that
7573
+ * were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
7574
+ * `xd://` remain mount-eligible, even when the live mount set has drifted.
7575
+ *
7576
+ * Names outside `mountedToolNames` are pinned top-level for this application;
7577
+ * names in the mounted subset remain eligible for xdev mounting. Delegates the
7578
+ * actual apply through `#applyActiveToolsByName` and restores the prior runtime
7579
+ * selection if that apply throws.
7580
+ */
7581
+ async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
7582
+ const normalized = normalizeToolNames(toolNames);
7583
+ // Restoration targets a snapshot, so write eligibility comes from the
7584
+ // *target* set rather than whatever happens to be active mid-rollback.
7585
+ await this.#applyToolPresentation(
7586
+ normalized,
7587
+ new Set(normalizeToolNames(mountedToolNames)),
7588
+ normalized.includes("write"),
7589
+ );
7590
+ }
7591
+
7592
+ /**
7593
+ * Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}:
7594
+ * pins non-mounted names as the runtime selection (holding `write` back when it is
7595
+ * transport-only) and applies the set, rolling the selection back if apply throws.
7596
+ */
7597
+ async #applyToolPresentation(
7598
+ normalized: string[],
7599
+ mounted: ReadonlySet<string>,
7600
+ writeSelected: boolean,
7601
+ ): Promise<void> {
7419
7602
  const transportWriteActive =
7603
+ writeSelected &&
7420
7604
  this.#builtInToolNames.has("write") &&
7421
- this.getActiveToolNames().includes("write") &&
7422
7605
  this.#presentationPinnedToolNames?.has("write") !== true &&
7423
7606
  this.#runtimeSelectedToolNames?.has("write") !== true &&
7424
7607
  (mounted.size > 0 || this.#planModeState?.enabled === true);
@@ -7695,6 +7878,12 @@ export class AgentSession {
7695
7878
  return this.#postPromptTasks.size > 0;
7696
7879
  }
7697
7880
 
7881
+ /** Register post-prompt work in tests without driving a full agent turn. */
7882
+ trackPostPromptTaskForTests(task: Promise<unknown>): void {
7883
+ if (!isBunTestRuntime()) throw new Error("trackPostPromptTaskForTests is test-only");
7884
+ this.#trackPostPromptTask(task);
7885
+ }
7886
+
7698
7887
  /** All messages including custom types like BashExecutionMessage */
7699
7888
  get messages(): AgentMessage[] {
7700
7889
  return this.agent.state.messages;
@@ -8441,19 +8630,18 @@ export class AgentSession {
8441
8630
  timestamp,
8442
8631
  });
8443
8632
  }
8444
- if (
8445
- this.#magicKeywordEnabled("workflow") &&
8446
- containsWorkflow(text) &&
8447
- this.getActiveToolNames().includes("task")
8448
- ) {
8449
- keywordNotices.push({
8450
- role: "custom",
8451
- customType: "workflow-notice",
8452
- content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
8453
- display: false,
8454
- attribution: "user",
8455
- timestamp,
8456
- });
8633
+ if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
8634
+ const activeToolNames = this.getActiveToolNames();
8635
+ if (activeToolNames.includes("task") && activeToolNames.includes("eval")) {
8636
+ keywordNotices.push({
8637
+ role: "custom",
8638
+ customType: "workflow-notice",
8639
+ content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
8640
+ display: false,
8641
+ attribution: "user",
8642
+ timestamp,
8643
+ });
8644
+ }
8457
8645
  }
8458
8646
  return keywordNotices;
8459
8647
  }
@@ -9574,6 +9762,13 @@ export class AgentSession {
9574
9762
  }
9575
9763
 
9576
9764
  #scheduleReplanTitleRefresh(): void {
9765
+ // Headless subagent sessions have no operator-visible title, so a todo-init
9766
+ // replan refresh only burns a tiny-model call whose result lands in JSONL
9767
+ // and is never shown (issue #5910). In an interactive host the operator can
9768
+ // focus a live subagent from the Agent Hub, where the status line renders
9769
+ // its session name — so keep the refresh there and only skip subagents when
9770
+ // no focusable UI exists (print/RPC/ACP/eval/SDK/CI).
9771
+ if (this.#agentKind === "sub" && !isInteractiveHost()) return;
9577
9772
  if (this.#replanTitleRefreshInFlight) return;
9578
9773
  if (!this.settings.get("title.refreshOnReplan")) return;
9579
9774
  if (this.sessionManager.titleSource === "user") return;
@@ -10210,15 +10405,16 @@ export class AgentSession {
10210
10405
  }
10211
10406
 
10212
10407
  /**
10213
- * Set the thinking level. `auto` enables per-turn classification; the selector
10214
- * itself is never written to the session log, but resolved concrete levels are
10215
- * persisted when real user turns are classified so resumed sessions keep the
10216
- * last resolved effort instead of reverting to pending auto.
10408
+ * Set the thinking level. `auto` enables per-turn classification. Entering
10409
+ * auto writes its provisional level plus `configured: "auto"` immediately,
10410
+ * giving external readers an authoritative selection receipt before the next
10411
+ * user turn. Later classifications persist only changed concrete resolutions.
10217
10412
  */
10218
10413
  setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void {
10219
10414
  if (level === AUTO_THINKING) {
10220
10415
  const provisional = resolveProvisionalAutoLevel(this.model);
10221
10416
  const wasAuto = this.#autoThinking;
10417
+ const previousLevel = this.#thinkingLevel;
10222
10418
  this.#autoThinking = true;
10223
10419
  this.#autoResolvedLevel = undefined;
10224
10420
  this.#thinkingLevel = provisional;
@@ -10229,7 +10425,9 @@ export class AgentSession {
10229
10425
  if (persist) {
10230
10426
  this.settings.set("defaultThinkingLevel", AUTO_THINKING);
10231
10427
  }
10232
- if (!wasAuto || this.#thinkingLevel !== provisional) {
10428
+ const isChanging = !wasAuto || previousLevel !== provisional;
10429
+ if (isChanging) {
10430
+ this.sessionManager.appendThinkingLevelChange(provisional, AUTO_THINKING);
10233
10431
  this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING });
10234
10432
  }
10235
10433
  return;
@@ -10338,7 +10536,7 @@ export class AgentSession {
10338
10536
 
10339
10537
  const effort = resolved ?? resolveProvisionalAutoLevel(model);
10340
10538
  if (effort === undefined) return;
10341
- const shouldPersistResolution = this.#autoResolvedLevel !== effort;
10539
+ const shouldPersistResolution = this.#thinkingLevel !== effort;
10342
10540
  this.#autoResolvedLevel = effort;
10343
10541
  this.#thinkingLevel = effort;
10344
10542
  this.#applyThinkingLevelToAgent(effort);
@@ -11929,7 +12127,8 @@ export class AgentSession {
11929
12127
  this.#emptyStopRetryCount++;
11930
12128
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
11931
12129
  const attempts = this.#emptyStopRetryCount - 1;
11932
- const finalError = "Assistant returned empty stop after retry cap";
12130
+ const finalError =
12131
+ "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
11933
12132
  logger.warn(finalError, {
11934
12133
  attempts,
11935
12134
  model: assistantMessage.model,
@@ -11944,11 +12143,11 @@ export class AgentSession {
11944
12143
  this.#clearPendingRecoveredRetryErrors();
11945
12144
  this.#retryAttempt = 0;
11946
12145
  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
- }
12146
+ // A zero-content turn carries no transcript value, while its provider usage
12147
+ // can anchor the next prompt at the full failed-request size and re-trigger
12148
+ // compaction at the same boundary. Remove every capped empty stop; toolUse
12149
+ // orphans still need this for Anthropic message-history validity.
12150
+ await this.#dropPersistedAssistantTurn(assistantMessage);
11952
12151
  return false;
11953
12152
  }
11954
12153
  this.#discardAssistantTurn(assistantMessage);
@@ -13758,35 +13957,80 @@ export class AgentSession {
13758
13957
  this.#getCompactionModelCandidates(availableModels),
13759
13958
  this.sessionId,
13760
13959
  );
13761
- const preparation = prepareCompaction(pathEntries, compactionSettings, autoCompactionCandidates);
13960
+ let pathEntriesForCompaction = pathEntries;
13961
+ let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, autoCompactionCandidates);
13762
13962
  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(),
13963
+ // prepareCompaction found nothing to summarize because the kept region
13964
+ // is a single oversized recent turn — findCutPoint never cuts inside a
13965
+ // tool result, so a huge tool-result / fenced block tail leaves nothing
13966
+ // on the summarizable side and summary compaction cannot even start.
13967
+ // That is exactly the dead-end the elide shake rescues: it reaches
13968
+ // INSIDE the tail and offloads heavy content to an artifact placeholder,
13969
+ // shrinking the tail so findCutPoint can then move the cut and leave
13970
+ // older turns to summarize. Run the same tiered rescue the
13971
+ // post-maintenance guard uses (elide, then image drop), with progress
13972
+ // defined as "prepareCompaction now succeeds on the rewritten branch",
13973
+ // and fall through to the normal compaction body when it does (writing
13974
+ // a compaction entry anchors the stale billed usage so the
13975
+ // auto-continue re-check cannot re-trip and loop the warning — issue
13976
+ // #4786). `skipElide` when we already fell through from a shake
13977
+ // strategy pass (it tried and found nothing); skip entirely on the
13978
+ // idle timer (it re-checks usage on its own cadence).
13979
+ let rescueRewroteHistory = false;
13980
+ if (reason !== "idle") {
13981
+ await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
13982
+ skipElide: fallbackFromShake,
13983
+ hasProgress: () => {
13984
+ // Only reached when a tier actually freed something, so the
13985
+ // branch has been rewritten either way.
13986
+ rescueRewroteHistory = true;
13987
+ pathEntriesForCompaction = this.sessionManager.getBranch();
13988
+ preparation = prepareCompaction(
13989
+ pathEntriesForCompaction,
13990
+ compactionSettings,
13991
+ autoCompactionCandidates,
13992
+ );
13993
+ return preparation !== undefined;
13994
+ },
13778
13995
  });
13779
- continuationScheduled = true;
13780
13996
  }
13781
- if (noProgressDeadEnd) {
13782
- this.emitNotice(
13783
- "warning",
13784
- compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
13785
- "compaction",
13786
- );
13997
+ if (!preparation) {
13998
+ await this.#emitSessionEvent({
13999
+ type: "auto_compaction_end",
14000
+ action,
14001
+ result: undefined,
14002
+ aborted: false,
14003
+ willRetry: false,
14004
+ skipped: true,
14005
+ });
14006
+ const noProgressDeadEnd = reason !== "idle";
14007
+ let continuationScheduled = false;
14008
+ if (!suppressContinuation && this.agent.hasQueuedMessages()) {
14009
+ this.#scheduleAgentContinue({
14010
+ delayMs: 100,
14011
+ generation,
14012
+ shouldContinue: () => this.agent.hasQueuedMessages(),
14013
+ });
14014
+ continuationScheduled = true;
14015
+ }
14016
+ if (noProgressDeadEnd) {
14017
+ this.emitNotice(
14018
+ "warning",
14019
+ compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
14020
+ "compaction",
14021
+ );
14022
+ }
14023
+ // A rescue that offloaded content but still could not produce a
14024
+ // preparation rewrote the branch; flag it so the overflow-recovery
14025
+ // rollback does not re-restore the just-failed assistant turn on top
14026
+ // of the elided tail.
14027
+ const base = continuationScheduled
14028
+ ? COMPACTION_CHECK_CONTINUATION
14029
+ : noProgressDeadEnd
14030
+ ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION
14031
+ : COMPACTION_CHECK_NONE;
14032
+ return rescueRewroteHistory ? { ...base, historyRewritten: true } : base;
13787
14033
  }
13788
- if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
13789
- return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
13790
14034
  }
13791
14035
 
13792
14036
  let hookCompaction: CompactionResult | undefined;
@@ -13798,7 +14042,7 @@ export class AgentSession {
13798
14042
  const hookResult = (await this.#extensionRunner.emit({
13799
14043
  type: "session_before_compact",
13800
14044
  preparation,
13801
- branchEntries: pathEntries,
14045
+ branchEntries: pathEntriesForCompaction,
13802
14046
  customInstructions: undefined,
13803
14047
  signal: autoCompactionSignal,
13804
14048
  })) as SessionBeforeCompactResult | undefined;
@@ -14512,6 +14756,50 @@ export class AgentSession {
14512
14756
  if (this.#isClassifierRefusal(message)) return true;
14513
14757
  return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
14514
14758
  }
14759
+
14760
+ /**
14761
+ * Resume a stalled Cursor turn after every server-executed tool has produced
14762
+ * a result. The failed assistant/tool-result pair must stay in context: it
14763
+ * records completed side effects and lets the next request continue from
14764
+ * them instead of replaying the original turn.
14765
+ */
14766
+ #canResumeCursorStreamStall(message: AssistantMessage): boolean {
14767
+ if (
14768
+ message.provider !== "cursor" ||
14769
+ message.stopReason !== "error" ||
14770
+ !message.errorMessage?.toLowerCase().includes("stream stall")
14771
+ ) {
14772
+ return false;
14773
+ }
14774
+ const id = this.#classifyRetryMessage(message);
14775
+ if (!AIError.retriable(id)) return false;
14776
+
14777
+ const resolvedToolCallIds: string[] = [];
14778
+ for (const block of message.content) {
14779
+ if (block.type !== "toolCall") continue;
14780
+ if (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true) return false;
14781
+ resolvedToolCallIds.push(block.id);
14782
+ }
14783
+ if (resolvedToolCallIds.length === 0) return false;
14784
+
14785
+ const messages = this.agent.state.messages;
14786
+ let assistantIndex = -1;
14787
+ for (let i = messages.length - 1; i >= 0; i--) {
14788
+ const candidate = messages[i];
14789
+ if (candidate.role === "assistant" && this.#isSameAssistantMessage(candidate, message)) {
14790
+ assistantIndex = i;
14791
+ break;
14792
+ }
14793
+ }
14794
+ if (assistantIndex < 0) return false;
14795
+
14796
+ const unresolvedToolCallIds = new Set(resolvedToolCallIds);
14797
+ for (let i = assistantIndex + 1; i < messages.length; i++) {
14798
+ const candidate = messages[i];
14799
+ if (candidate.role === "toolResult") unresolvedToolCallIds.delete(candidate.toolCallId);
14800
+ }
14801
+ return unresolvedToolCallIds.size === 0;
14802
+ }
14515
14803
  /**
14516
14804
  * Retried turns remove the failed assistant message from active context.
14517
14805
  * Text/thinking-only partials are safe to discard and replay. Retained
@@ -15089,7 +15377,12 @@ export class AgentSession {
15089
15377
  */
15090
15378
  async #handleRetryableError(
15091
15379
  message: AssistantMessage,
15092
- options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean; hardErrorFallback?: boolean },
15380
+ options?: {
15381
+ allowModelFallback?: boolean;
15382
+ fireworksFastFallback?: boolean;
15383
+ hardErrorFallback?: boolean;
15384
+ preserveFailedTurn?: boolean;
15385
+ },
15093
15386
  ): Promise<boolean> {
15094
15387
  const retrySettings = this.settings.getGroup("retry");
15095
15388
  // The Fireworks Fast→base degrade is an intrinsic model-selection safety net,
@@ -15280,8 +15573,11 @@ export class AgentSession {
15280
15573
  errorId: message.errorId,
15281
15574
  });
15282
15575
 
15283
- // Remove the failed assistant message from active context before retrying.
15284
- this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
15576
+ // Cursor exec-channel tools have already run and emitted results. Keep that
15577
+ // failed turn intact so continuation cannot repeat their side effects.
15578
+ if (!options?.preserveFailedTurn) {
15579
+ this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
15580
+ }
15285
15581
 
15286
15582
  // A thinking/response loop retried into identical context loops again. Inject a
15287
15583
  // hidden redirect so the retried turn sees a directive to break the repeated