@oh-my-pi/pi-coding-agent 16.4.6 → 16.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/cli.js +3312 -3254
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +28 -3
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  22. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  23. package/dist/types/modes/components/welcome.d.ts +4 -0
  24. package/dist/types/modes/print-mode.d.ts +14 -8
  25. package/dist/types/modes/print-mode.test.d.ts +1 -0
  26. package/dist/types/modes/theme/theme.d.ts +2 -1
  27. package/dist/types/sdk.d.ts +5 -1
  28. package/dist/types/session/agent-session.d.ts +42 -0
  29. package/dist/types/session/session-context.d.ts +9 -0
  30. package/dist/types/session/session-entries.d.ts +6 -0
  31. package/dist/types/thinking.d.ts +2 -2
  32. package/dist/types/tiny/models.d.ts +1 -1
  33. package/dist/types/tools/browser/launch.d.ts +1 -0
  34. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  35. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  36. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  37. package/dist/types/tools/builtin-names.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +1 -0
  39. package/dist/types/tools/launch.d.ts +121 -0
  40. package/dist/types/tools/render-utils.d.ts +2 -0
  41. package/dist/types/tools/terminal-output.d.ts +5 -0
  42. package/dist/types/vibe/runtime.d.ts +2 -2
  43. package/dist/types/web/search/types.d.ts +0 -8
  44. package/package.json +20 -20
  45. package/src/cli/args.ts +11 -0
  46. package/src/cli/flag-tables.ts +9 -0
  47. package/src/cli/gallery-fixtures/shell.ts +82 -1
  48. package/src/cli.ts +10 -0
  49. package/src/commands/launch.ts +17 -0
  50. package/src/config/model-resolver.ts +110 -31
  51. package/src/config/model-roles.ts +14 -0
  52. package/src/config/settings-schema.ts +71 -7
  53. package/src/edit/renderer.ts +13 -10
  54. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  55. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  56. package/src/eval/completion-bridge.ts +4 -4
  57. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/extensions/model-api.ts +1 -1
  60. package/src/extensibility/extensions/types.ts +1 -1
  61. package/src/internal-urls/docs-index.ts +4 -4
  62. package/src/launch/broker.ts +1017 -0
  63. package/src/launch/client.ts +344 -0
  64. package/src/launch/paths.ts +17 -0
  65. package/src/launch/presence.ts +82 -0
  66. package/src/launch/protocol.ts +386 -0
  67. package/src/launch/terminal-output.ts +46 -0
  68. package/src/main.ts +50 -1
  69. package/src/modes/acp/acp-agent.ts +8 -1
  70. package/src/modes/components/agent-hub.ts +101 -31
  71. package/src/modes/components/compaction-summary-message.ts +8 -2
  72. package/src/modes/components/index.ts +1 -0
  73. package/src/modes/components/model-browser.ts +152 -49
  74. package/src/modes/components/model-hub.ts +55 -142
  75. package/src/modes/components/model-picker.ts +233 -0
  76. package/src/modes/components/plan-review-overlay.ts +7 -0
  77. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  78. package/src/modes/components/status-line/component.test.ts +41 -2
  79. package/src/modes/components/status-line/component.ts +4 -0
  80. package/src/modes/components/status-line/segments.ts +6 -0
  81. package/src/modes/components/status-line/types.ts +3 -0
  82. package/src/modes/components/tips.txt +2 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +9 -1
  85. package/src/modes/controllers/event-controller.ts +31 -32
  86. package/src/modes/controllers/selector-controller.ts +96 -22
  87. package/src/modes/controllers/tan-command-controller.ts +40 -1
  88. package/src/modes/interactive-mode.ts +20 -3
  89. package/src/modes/print-mode.test.ts +71 -0
  90. package/src/modes/print-mode.ts +51 -2
  91. package/src/modes/theme/theme.ts +9 -0
  92. package/src/modes/utils/ui-helpers.ts +25 -4
  93. package/src/prompts/agents/designer.md +1 -1
  94. package/src/prompts/agents/librarian.md +1 -1
  95. package/src/prompts/agents/reviewer.md +1 -1
  96. package/src/prompts/agents/scout.md +1 -1
  97. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  98. package/src/prompts/system/prewalk-checklist.md +7 -0
  99. package/src/prompts/system/prewalk-continue.md +1 -0
  100. package/src/prompts/system/prewalk-plan.md +13 -0
  101. package/src/prompts/system/system-prompt.md +9 -7
  102. package/src/prompts/system/tan-context-switch.md +17 -0
  103. package/src/prompts/tools/bash.md +6 -4
  104. package/src/prompts/tools/browser.md +4 -4
  105. package/src/prompts/tools/launch.md +25 -0
  106. package/src/sdk.ts +8 -2
  107. package/src/session/agent-session.ts +550 -87
  108. package/src/session/session-context.test.ts +10 -5
  109. package/src/session/session-context.ts +25 -8
  110. package/src/session/session-entries.ts +6 -0
  111. package/src/slash-commands/builtin-registry.ts +25 -0
  112. package/src/task/agents.ts +2 -2
  113. package/src/thinking.ts +10 -3
  114. package/src/tiny/models.ts +7 -7
  115. package/src/tools/bash-interactive.ts +5 -8
  116. package/src/tools/bash.ts +38 -24
  117. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  118. package/src/tools/browser/launch.ts +8 -4
  119. package/src/tools/browser/run-cancellation.ts +66 -6
  120. package/src/tools/browser/tab-protocol.ts +6 -0
  121. package/src/tools/browser/tab-supervisor.ts +17 -1
  122. package/src/tools/browser/tab-worker.ts +140 -21
  123. package/src/tools/builtin-names.ts +1 -0
  124. package/src/tools/eval-render.ts +16 -14
  125. package/src/tools/index.ts +5 -0
  126. package/src/tools/inspect-image.ts +2 -2
  127. package/src/tools/launch.ts +643 -0
  128. package/src/tools/render-utils.ts +3 -0
  129. package/src/tools/renderers.ts +2 -0
  130. package/src/tools/terminal-output.ts +141 -0
  131. package/src/tts/speech-enhancer.ts +2 -2
  132. package/src/utils/image-vision-fallback.ts +3 -3
  133. package/src/vibe/runtime.ts +2 -2
  134. package/src/web/search/provider.ts +0 -10
  135. package/src/web/search/providers/perplexity.ts +18 -2
  136. package/src/web/search/providers/public.ts +2 -4
  137. package/src/web/search/types.ts +0 -10
  138. package/dist/types/web/search/providers/bing.d.ts +0 -14
  139. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  140. package/src/web/search/providers/bing.ts +0 -197
  141. package/src/web/search/providers/yahoo.ts +0 -179
@@ -30,6 +30,7 @@ import {
30
30
  type AgentMessage,
31
31
  type AgentState,
32
32
  type AgentTool,
33
+ type AgentToolResult,
33
34
  type AgentTurnEndContext,
34
35
  AppendOnlyContextManager,
35
36
  type AsideMessage,
@@ -250,6 +251,7 @@ import { parseTurnBudget } from "../modes/turn-budget";
250
251
  import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
251
252
  import { computeNonMessageBreakdown, computeNonMessageTokens } from "../modes/utils/context-usage";
252
253
  import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
254
+ import { resolveApprovedPlan } from "../plan-mode/approved-plan";
253
255
  import { createPlanReadMatcher } from "../plan-mode/plan-protection";
254
256
  import type { PlanModeState } from "../plan-mode/state";
255
257
  import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
@@ -270,6 +272,10 @@ import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" w
270
272
  import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
271
273
  type: "text",
272
274
  };
275
+ import planYoloHandoffPrompt from "../prompts/system/plan-yolo-handoff.md" with { type: "text" };
276
+ import prewalkChecklistPrompt from "../prompts/system/prewalk-checklist.md" with { type: "text" };
277
+ import prewalkContinuePrompt from "../prompts/system/prewalk-continue.md" with { type: "text" };
278
+ import prewalkPlanPrompt from "../prompts/system/prewalk-plan.md" with { type: "text" };
273
279
  import rewindReportTemplate from "../prompts/system/rewind-report.md" with { type: "text" };
274
280
  import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
275
281
  import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
@@ -318,7 +324,7 @@ import type { CheckpointState, CompletedRewindState } from "../tools/checkpoint"
318
324
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
319
325
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
320
326
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
321
- import { buildResolveReminderMessage } from "../tools/resolve";
327
+ import { buildResolveReminderMessage, type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
322
328
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
323
329
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
324
330
  import { clampTimeout } from "../tools/tool-timeouts";
@@ -413,7 +419,31 @@ const MID_RUN_TODO_NUDGE_MUTATING_TOOLS: Record<string, true> = {
413
419
  /** `customType` for the hidden mid-run todo nudge; `display: false`, so it reaches
414
420
  * the model but never renders in the TUI or transcript. */
415
421
  const MID_RUN_TODO_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge";
416
-
422
+ /** Hidden plan nudge injected by prewalk; scrubbed from the LLM context
423
+ * when the switch happens. */
424
+ const PREWALK_PLAN_MESSAGE_TYPE = "prewalk-plan";
425
+ /** Hidden safety-net nudge forcing one more turn after a text-only reply to
426
+ * the plan nudge, which would otherwise end the run with no code written. */
427
+ const PREWALK_CONTINUE_MESSAGE_TYPE = "prewalk-continue";
428
+ /** Hidden "verify before finishing" checklist steered into the run at the
429
+ * switch, aimed at the fast model's specific failure patterns: partial
430
+ * multi-site fixes, unnecessarily broad rewrites, and reported-test-only
431
+ * verification. */
432
+ const PREWALK_CHECKLIST_MESSAGE_TYPE = "prewalk-checklist";
433
+ /** Tools whose first successful call triggers the switch — once the todo
434
+ * gate is open (see {@link AgentSession.#prewalkTodoSeen}). Bash is
435
+ * deliberately excluded: it doubles as exploration (ls/cat) and fired
436
+ * turn-1 switches in practice. `todo` is deliberately NOT a trigger: firing
437
+ * at the todo init handed the fast model 100% of the implementation with
438
+ * zero started work and measurably regressed pass rates. */
439
+ const PREWALK_ACTION_TOOLS: Record<string, true> = {
440
+ edit: true,
441
+ write: true,
442
+ };
443
+ /** `customType` for the hidden hand-off message steered to the target model
444
+ * once PlanYolo auto-approves the plan. Unlike prewalk's plan nudge this
445
+ * is never scrubbed — it IS the instruction the target model acts on. */
446
+ const PLAN_YOLO_HANDOFF_MESSAGE_TYPE = "plan-yolo-handoff";
417
447
  /** Abort reason for the Gemini reasoning-header runaway interrupt. Surfaced on the
418
448
  * discarded assistant turn only; never reaches the model. */
419
449
  const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
@@ -599,8 +629,9 @@ const COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION: CompactionCheckResult = {
599
629
 
600
630
  /**
601
631
  * User-facing notice for a compaction dead end: maintenance freed too little
602
- * to retry safely. `remedies` names the recovery actions available on the
603
- * emitting path (the shake-rescue path can additionally offer `/shake images`).
632
+ * to retry safely. `remedies` names the recovery actions left on the emitting
633
+ * path — by the time the post-pass dead end fires, the tiered rescue has
634
+ * already attempted both elide and image-drop automatically.
604
635
  */
605
636
  function compactionDeadEndWarning(remedies: string): string {
606
637
  return (
@@ -680,6 +711,34 @@ export interface AsyncJobSnapshot {
680
711
  }
681
712
 
682
713
  export type { ShakeMode, ShakeResult };
714
+ /**
715
+ * Prewalk: switches an active session one-way from its starting model to
716
+ * a fast/cheap `target` at the first completed turn that runs an edit/write
717
+ * tool once the todo list exists. A hidden plan nudge asks the starting
718
+ * model to write a plan, initialize its todo list from it, and start; the
719
+ * todo call opens the trigger gate (it never fires the switch itself), so
720
+ * the starting model always begins the implementation. A hidden
721
+ * checklist nudge asks the target model to verify its work before
722
+ * finishing. Both are always on — this is the one mechanism that won out
723
+ * over turn-count and ungated variants in testing.
724
+ */
725
+ export interface Prewalk {
726
+ target: Model;
727
+ thinkingLevel?: ConfiguredThinkingLevel;
728
+ }
729
+
730
+ /**
731
+ * PlanYolo: forces the session into read-only plan mode at start, then
732
+ * auto-approves the plan the instant the model calls `resolve({ action:
733
+ * "apply" })` for it — no interactive review — and switches to a fast/cheap
734
+ * `target` model to implement it. The headless counterpart to interactive
735
+ * plan mode's "Approve and execute", for print/non-interactive runs where
736
+ * there is no one to click Approve.
737
+ */
738
+ export interface PlanYolo {
739
+ target: Model;
740
+ thinkingLevel?: ConfiguredThinkingLevel;
741
+ }
683
742
 
684
743
  // ============================================================================
685
744
  // Types
@@ -695,6 +754,12 @@ export interface AgentSessionConfig {
695
754
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
696
755
  /** Initial session thinking selector. */
697
756
  thinkingLevel?: ConfiguredThinkingLevel;
757
+ /** Prewalk from the starting model to a fast/cheap target at the first edit/write once the todo list exists. */
758
+ prewalk?: Prewalk;
759
+ /** Force read-only plan mode at start, auto-approve on the model's first
760
+ * `resolve` call, then switch to the target to implement. */
761
+ planYolo?: PlanYolo;
762
+
698
763
  /** Initial per-family service tiers (OpenAI / Anthropic / Google) for the live session. */
699
764
  serviceTierByFamily?: ServiceTierByFamily;
700
765
  /** Prompt templates for expansion */
@@ -1607,6 +1672,17 @@ export class AgentSession {
1607
1672
  #autoThinking: boolean = false;
1608
1673
  /** The level `auto` last resolved to (for UI); undefined until a turn is classified. */
1609
1674
  #autoResolvedLevel: Effort | undefined;
1675
+ #prewalk: Prewalk | undefined;
1676
+ /** True once the plan nudge has been queued; scrubbed from context at the switch. */
1677
+ #prewalkPlanInjected = false;
1678
+ /** True once any successful `todo` call landed — opens the prewalk
1679
+ * trigger gate: the switch fires at the first edit/write AFTER the todo
1680
+ * list exists (sessions without a todo tool skip the gate). */
1681
+ #prewalkTodoSeen = false;
1682
+ #planYolo: PlanYolo | undefined;
1683
+ #planYoloPreviousTools: string[] | undefined;
1684
+ #planYoloArmed = false;
1685
+
1610
1686
  #promptTemplates: PromptTemplate[];
1611
1687
  #slashCommands: FileSlashCommand[];
1612
1688
 
@@ -2091,6 +2167,253 @@ export class AgentSession {
2091
2167
  this.#emit(pending);
2092
2168
  }
2093
2169
 
2170
+ /** Advance the one-way prewalk switch at a completed assistant-turn boundary. */
2171
+ async #advancePrewalk(liveMessages: AgentMessage[], context: AgentTurnEndContext | undefined): Promise<void> {
2172
+ const prewalk = this.#prewalk;
2173
+ if (!prewalk || context?.message.role !== "assistant") return;
2174
+
2175
+ // Structural safety net: every branch below assumes the agent loop will
2176
+ // run another turn. It won't if THIS turn had no tool calls — the loop
2177
+ // treats a text-only turn as "the agent is done" and ends the session
2178
+ // with no further prompting. The plan nudge explicitly asks for a prose
2179
+ // reply, which makes a text-only turn common right after it — observed
2180
+ // silently killing production SWE-bench runs before any code was ever
2181
+ // written. Force one more turn only in that specific, self-created
2182
+ // hazard window.
2183
+ if (this.#prewalkPlanInjected && context.toolResults.length === 0) {
2184
+ this.agent.steer({
2185
+ role: "custom",
2186
+ customType: PREWALK_CONTINUE_MESSAGE_TYPE,
2187
+ content: prewalkContinuePrompt,
2188
+ attribution: "agent",
2189
+ display: false,
2190
+ timestamp: Date.now(),
2191
+ });
2192
+ }
2193
+
2194
+ // Todo gate: the plan nudge instructs "finish the plan, then init the
2195
+ // todo list from it and start" — so the switch waits until a todo list
2196
+ // exists AND the model has actually started implementing (first
2197
+ // edit/write). The todo call itself never triggers: firing there handed
2198
+ // the fast model the whole implementation cold. Sessions without a todo
2199
+ // tool skip the gate.
2200
+ if (context.toolResults.some(result => result.toolName === "todo")) {
2201
+ this.#prewalkTodoSeen = true;
2202
+ }
2203
+ const todoGateOpen = this.#prewalkTodoSeen || !this.#toolRegistry.has("todo");
2204
+ const action = todoGateOpen
2205
+ ? context.toolResults.find(result => PREWALK_ACTION_TOOLS[result.toolName])
2206
+ : undefined;
2207
+ if (!action) {
2208
+ if (!this.#prewalkPlanInjected) {
2209
+ this.#prewalkPlanInjected = true;
2210
+ this.agent.steer({
2211
+ role: "custom",
2212
+ customType: PREWALK_PLAN_MESSAGE_TYPE,
2213
+ content: prewalkPlanPrompt,
2214
+ display: false,
2215
+ attribution: "agent",
2216
+ timestamp: Date.now(),
2217
+ });
2218
+ this.emitNotice("info", "Prewalk: injected deep-plan nudge.", "prewalk");
2219
+ }
2220
+ return;
2221
+ }
2222
+
2223
+ await this.#waitForSessionMessagePersistence(context.message);
2224
+ for (const toolResult of context.toolResults) {
2225
+ await this.#waitForSessionMessagePersistence(toolResult);
2226
+ }
2227
+
2228
+ this.#scrubPrewalkPlanNudge(liveMessages);
2229
+ const target = prewalk.target;
2230
+ if (this.model && modelsAreEqual(this.model, target)) {
2231
+ this.#prewalk = undefined;
2232
+ return;
2233
+ }
2234
+
2235
+ await this.setModelTemporary(target, prewalk.thinkingLevel, { ephemeral: true });
2236
+ this.#prewalk = undefined;
2237
+ this.emitNotice(
2238
+ "info",
2239
+ `Prewalk: switched to ${target.provider}/${target.id} after first ${action.toolName} call.`,
2240
+ "prewalk",
2241
+ );
2242
+ this.agent.steer({
2243
+ role: "custom",
2244
+ customType: PREWALK_CHECKLIST_MESSAGE_TYPE,
2245
+ content: prewalkChecklistPrompt,
2246
+ attribution: "agent",
2247
+ display: false,
2248
+ timestamp: Date.now(),
2249
+ });
2250
+ }
2251
+
2252
+ /**
2253
+ * Arm prewalk outside the normal startup path (the `/prewalk` slash
2254
+ * command): sets the target and immediately steers the plan nudge rather
2255
+ * than waiting for the next turn boundary, since an explicit manual
2256
+ * invocation means "start this now." A no-op with a notice if a prewalk
2257
+ * is already armed and waiting.
2258
+ */
2259
+ armPrewalk(target: Model, thinkingLevel?: ConfiguredThinkingLevel): void {
2260
+ if (this.#prewalk) {
2261
+ this.emitNotice(
2262
+ "info",
2263
+ `Prewalk: already armed for ${this.#prewalk.target.provider}/${this.#prewalk.target.id}, waiting for the first edit/write.`,
2264
+ "prewalk",
2265
+ );
2266
+ return;
2267
+ }
2268
+ this.#prewalk = { target, thinkingLevel };
2269
+ this.#prewalkPlanInjected = true;
2270
+ this.agent.steer({
2271
+ role: "custom",
2272
+ customType: PREWALK_PLAN_MESSAGE_TYPE,
2273
+ content: prewalkPlanPrompt,
2274
+ display: false,
2275
+ attribution: "agent",
2276
+ timestamp: Date.now(),
2277
+ });
2278
+ this.emitNotice(
2279
+ "info",
2280
+ `Prewalk: armed for ${target.provider}/${target.id} — will switch at the first edit/write once the todo list exists.`,
2281
+ "prewalk",
2282
+ );
2283
+ }
2284
+
2285
+ /**
2286
+ * Remove the plan nudge from the LLM context before the model switch: the
2287
+ * fast model inherits the plan the nudge produced, not the nudge itself.
2288
+ * Splices the loop's live context array in place (the run streams from
2289
+ * it) and mirrors the removal into agent state. The persisted transcript
2290
+ * keeps the message for audit; a session reload re-materializes it,
2291
+ * which is acceptable for prewalk's single-run lifecycle.
2292
+ */
2293
+ #scrubPrewalkPlanNudge(liveMessages: AgentMessage[]): void {
2294
+ if (!this.#prewalkPlanInjected) return;
2295
+ const isPlanNudge = (m: AgentMessage): boolean =>
2296
+ m.role === "custom" && m.customType === PREWALK_PLAN_MESSAGE_TYPE;
2297
+ for (let i = liveMessages.length - 1; i >= 0; i--) {
2298
+ if (isPlanNudge(liveMessages[i])) liveMessages.splice(i, 1);
2299
+ }
2300
+ const stateMessages = this.agent.state.messages;
2301
+ const filtered = stateMessages.filter(m => !isPlanNudge(m));
2302
+ if (filtered.length !== stateMessages.length) this.agent.replaceMessages(filtered);
2303
+ }
2304
+
2305
+ /**
2306
+ * Lazily arm PlanYolo before the first prompt is built: restricts tools to
2307
+ * the plan-mode read-only set (plus `resolve`/`write`, both normally
2308
+ * discovery-hidden), marks plan-mode state so `#buildPlanModeMessage`
2309
+ * injects the standard plan-mode-active instructions on this and every
2310
+ * following prompt, and registers the auto-approve resolve handler.
2311
+ * Idempotent — a no-op once armed or when PlanYolo is not configured.
2312
+ */
2313
+ async #armPlanYoloIfNeeded(): Promise<void> {
2314
+ if (!this.#planYolo || this.#planYoloArmed) return;
2315
+ this.#planYoloArmed = true;
2316
+ const previousTools = this.getActiveToolNames();
2317
+ const augmentations = ["resolve"];
2318
+ if (this.hasBuiltInTool("write")) augmentations.push("write");
2319
+ await this.setActiveToolsByName([...new Set([...previousTools, ...augmentations])]);
2320
+ this.#planYoloPreviousTools = previousTools;
2321
+ this.setPlanModeState({
2322
+ enabled: true,
2323
+ planFilePath: this.getPlanReferencePath() || "local://PLAN.md",
2324
+ workflow: "parallel",
2325
+ });
2326
+ this.setStandingResolveHandler(input => this.#runPlanYoloApprovalResolve(input));
2327
+ }
2328
+
2329
+ /**
2330
+ * Standing resolve handler while PlanYolo's plan phase is active. Auto-
2331
+ * approves the instant the model calls `resolve { action: "apply" }` for
2332
+ * the plan — no interactive review, the headless counterpart to plan
2333
+ * mode's "Approve and execute" — then restores tools, exits plan-mode
2334
+ * state, switches to the configured `target`, and hands off the approved
2335
+ * plan for it to implement.
2336
+ */
2337
+ #runPlanYoloApprovalResolve(input: unknown): Promise<AgentToolResult<ResolveToolDetails>> {
2338
+ return runResolveInvocation(input as Parameters<typeof runResolveInvocation>[0], {
2339
+ sourceToolName: "plan_approval",
2340
+ label: "Plan ready for approval",
2341
+ apply: async (_reason, extra) => {
2342
+ const planYolo = this.#planYolo;
2343
+ const state = this.getPlanModeState();
2344
+ if (!planYolo || !state?.enabled) {
2345
+ throw new ToolError("Plan mode is not active.");
2346
+ }
2347
+ const { planFilePath, title } = await resolveApprovedPlan({
2348
+ suppliedTitle: extra?.title,
2349
+ statePlanFilePath: state.planFilePath,
2350
+ readPlan: url => this.#readPlanYoloFile(url),
2351
+ listPlanFiles: () => this.#listPlanYoloFiles(),
2352
+ });
2353
+ const previousTools = this.#planYoloPreviousTools;
2354
+ if (previousTools) {
2355
+ await this.setActiveToolsByName(previousTools);
2356
+ }
2357
+ this.setStandingResolveHandler(null);
2358
+ this.setPlanModeState(undefined);
2359
+ this.#planYolo = undefined;
2360
+ this.#planYoloPreviousTools = undefined;
2361
+ await this.setModelTemporary(planYolo.target, planYolo.thinkingLevel, { ephemeral: true });
2362
+ this.emitNotice(
2363
+ "info",
2364
+ `Plan-yolo: plan approved, switched to ${planYolo.target.provider}/${planYolo.target.id} to implement "${title}".`,
2365
+ "plan-yolo",
2366
+ );
2367
+ this.agent.steer({
2368
+ role: "custom",
2369
+ customType: PLAN_YOLO_HANDOFF_MESSAGE_TYPE,
2370
+ content: prompt.render(planYoloHandoffPrompt, { planFilePath, title }),
2371
+ attribution: "agent",
2372
+ display: false,
2373
+ timestamp: Date.now(),
2374
+ });
2375
+ return {
2376
+ content: [
2377
+ { type: "text" as const, text: `Plan approved. Implementing now with ${planYolo.target.id}.` },
2378
+ ],
2379
+ details: { planFilePath, title, planExists: true },
2380
+ };
2381
+ },
2382
+ });
2383
+ }
2384
+
2385
+ async #readPlanYoloFile(planFilePath: string): Promise<string | null> {
2386
+ const resolvedPath = planFilePath.startsWith("local:")
2387
+ ? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), this.#localProtocolOptions())
2388
+ : resolveToCwd(planFilePath, this.sessionManager.getCwd());
2389
+ try {
2390
+ return await Bun.file(resolvedPath).text();
2391
+ } catch (error) {
2392
+ if (isEnoent(error)) return null;
2393
+ throw error;
2394
+ }
2395
+ }
2396
+
2397
+ /** `local://` URLs of plan files in the session-local root, newest first —
2398
+ * a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */
2399
+ async #listPlanYoloFiles(): Promise<string[]> {
2400
+ const localRoot = resolveLocalUrlToPath("local://", this.#localProtocolOptions());
2401
+ try {
2402
+ const entries = await fs.promises.readdir(localRoot, { withFileTypes: true });
2403
+ const plans = await Promise.all(
2404
+ entries
2405
+ .filter(entry => entry.isFile() && /plan\.md$/i.test(entry.name))
2406
+ .map(async entry => {
2407
+ const stat = await fs.promises.stat(path.join(localRoot, entry.name)).catch(() => null);
2408
+ return { url: `local://${entry.name}`, mtime: stat?.mtimeMs ?? 0 };
2409
+ }),
2410
+ );
2411
+ return plans.sort((a, b) => b.mtime - a.mtime).map(plan => plan.url);
2412
+ } catch {
2413
+ return [];
2414
+ }
2415
+ }
2416
+
2094
2417
  constructor(config: AgentSessionConfig) {
2095
2418
  this.agent = config.agent;
2096
2419
  this.sessionManager = config.sessionManager;
@@ -2111,7 +2434,14 @@ export class AgentSession {
2111
2434
  } else {
2112
2435
  this.#thinkingLevel = config.thinkingLevel;
2113
2436
  }
2437
+ if (config.prewalk) {
2438
+ this.#prewalk = config.prewalk;
2439
+ }
2440
+ if (config.planYolo) {
2441
+ this.#planYolo = config.planYolo;
2442
+ }
2114
2443
  this.#applyThinkingLevelToAgent(this.#thinkingLevel);
2444
+
2115
2445
  this.#promptTemplates = config.promptTemplates ?? [];
2116
2446
  this.#slashCommands = config.slashCommands ?? [];
2117
2447
  this.#extensionRunner = config.extensionRunner;
@@ -2184,6 +2514,7 @@ export class AgentSession {
2184
2514
  });
2185
2515
  if (detection) this.#maybeInjectToolCallLoopRedirect(messages, detection);
2186
2516
  }
2517
+ await this.#advancePrewalk(messages, context);
2187
2518
  this.#advisorPrimaryTurnsCompleted++;
2188
2519
  if (this.#advisors.length > 0) {
2189
2520
  for (const a of this.#advisors) {
@@ -4136,6 +4467,17 @@ export class AgentSession {
4136
4467
  await emitAgentEndNotification();
4137
4468
  return;
4138
4469
  }
4470
+ } else if (this.#isHardErrorFallbackEligible(msg)) {
4471
+ // A non-retryable hard error on a model covered by a configured
4472
+ // fallback chain: retrying the SAME model is pointless, but a
4473
+ // DIFFERENT model is a fresh chance — consult the chain before
4474
+ // surfacing the failure. #handleRetryableError bails out (no
4475
+ // backoff-retry of the failing model) when no switch happens.
4476
+ const didRetry = await this.#handleRetryableError(msg, { hardErrorFallback: true });
4477
+ if (didRetry) {
4478
+ await emitAgentEndNotification();
4479
+ return;
4480
+ }
4139
4481
  }
4140
4482
  // Classifier refusals are persisted-skipped above; also prune the trailing
4141
4483
  // stub from active context so the next turn's prompt does not replay it.
@@ -7131,6 +7473,11 @@ export class AgentSession {
7131
7473
  return this.#planModeState;
7132
7474
  }
7133
7475
 
7476
+ /** Prewalk state, if armed and active */
7477
+ getPrewalkState(): Prewalk | undefined {
7478
+ return this.#prewalk;
7479
+ }
7480
+
7134
7481
  setPlanModeState(state: PlanModeState | undefined): void {
7135
7482
  this.#planModeState = state;
7136
7483
  if (state?.enabled) {
@@ -7871,6 +8218,8 @@ export class AgentSession {
7871
8218
  await this.#checkCompaction(lastAssistant, false, false, false);
7872
8219
  }
7873
8220
 
8221
+ await this.#armPlanYoloIfNeeded();
8222
+
7874
8223
  // Build messages array (session context, eager todo prelude, then active prompt message)
7875
8224
  const messages: AgentMessage[] = [];
7876
8225
  const planReferenceMessage = await this.#buildPlanReferenceMessage?.();
@@ -9305,7 +9654,7 @@ export class AgentSession {
9305
9654
  const all = this.#modelRegistry.getAvailable();
9306
9655
  const patterns = this.settings.get("enabledModels");
9307
9656
  if (!patterns || patterns.length === 0) return all;
9308
- return filterAvailableModelsByEnabledPatterns(all, patterns);
9657
+ return filterAvailableModelsByEnabledPatterns(all, patterns, this.settings);
9309
9658
  }
9310
9659
 
9311
9660
  // =========================================================================
@@ -9641,6 +9990,11 @@ export class AgentSession {
9641
9990
  * candidate is small or the session has been idle long enough that the
9642
9991
  * provider prompt cache is cold), so it is cheap to run every turn. Gated
9643
9992
  * on the `compaction.supersedeReads` and `compaction.dropUseless` settings.
9993
+ *
9994
+ * Persists via `rewriteEntries` like every other history rewrite — the
9995
+ * session file must match the live (pruned) context or file-based forks
9996
+ * (`/fork`, `/tan`) and resume rebuild a divergent prefix and cold-miss the
9997
+ * provider prompt cache.
9644
9998
  */
9645
9999
  async #pruneStaleToolResults(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
9646
10000
  const { supersedeReads, dropUseless } = this.settings.getGroup("compaction");
@@ -9663,6 +10017,7 @@ export class AgentSession {
9663
10017
  return undefined;
9664
10018
  }
9665
10019
 
10020
+ await this.sessionManager.rewriteEntries();
9666
10021
  const sessionContext = this.buildDisplaySessionContext();
9667
10022
  this.agent.replaceMessages(sessionContext.messages);
9668
10023
  this.#resetAllAdvisorRuntimes();
@@ -10104,6 +10459,7 @@ export class AgentSession {
10104
10459
  const newEntries = this.sessionManager.getEntries();
10105
10460
  const sessionContext = this.buildDisplaySessionContext();
10106
10461
  this.agent.replaceMessages(sessionContext.messages);
10462
+ this.#rebasePendingContextSnapshotAfterCompaction();
10107
10463
  // Compaction discarded the conversation history that carried the approved
10108
10464
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
10109
10465
  // the plan from disk and re-injects it on the next turn (issue #1246).
@@ -12498,49 +12854,85 @@ export class AgentSession {
12498
12854
  }
12499
12855
 
12500
12856
  /**
12501
- * Last-resort reducer when {@link #runAutoCompaction} would otherwise dead-end.
12502
- * The summarizer cut at the only available turn boundary, but the kept tail is
12503
- * still over the recovery band because a single recent turn (a large
12504
- * tool-result, a heavy fenced/XML block) is itself bigger than the band and
12505
- * `findCutPoint` cannot cut inside one message. `shake("elide")` reaches INSIDE
12506
- * that tail — it offloads heavy tool-result / block content to one
12507
- * `artifact://` blob and leaves a recoverable placeholder — so residual context
12508
- * genuinely drops instead of the guard pausing maintenance and looping the
12509
- * warning. Without it the guard would pause/warn here; with it the caller
12510
- * re-tests its progress predicate after the elide pass and only falls through
12511
- * to the warning when residual stays over.
12857
+ * Last-resort tiered reducer when {@link #runAutoCompaction} would otherwise
12858
+ * dead-end. The summarizer cut at the only available turn boundary, but the
12859
+ * kept tail is still over the recovery band because a single recent turn (a
12860
+ * large tool-result, a heavy fenced/XML block, attached images) is itself
12861
+ * bigger than the band and `findCutPoint` cannot cut inside one message.
12512
12862
  *
12513
- * Image-only tails are out of scope: `collectShakeRegions` skips image-only
12514
- * tool results and user-message images aren't counted by the local estimate
12515
- * that gates the dead-end, so those still surface the warning (remedy:
12516
- * `/shake images`).
12863
+ * Tier 1 `shake("elide")` reaches INSIDE that tail: heavy tool-result /
12864
+ * block content is offloaded to one `artifact://` blob behind a recoverable
12865
+ * placeholder. Skipped when this pass already ran a shake (`skipElide`).
12866
+ * Tier 2 — `dropImages()`: the manual `/shake images` remedy, automated.
12867
+ * Image blocks are stripped from the branch; unlike elided text they are NOT
12868
+ * artifact-recoverable, so this tier only runs once elide has failed the
12869
+ * progress re-test.
12517
12870
  *
12518
- * Returns the elide {@link ShakeResult} when something was offloaded (so the
12519
- * caller can re-test and report), or `undefined` when nothing was eligible or
12520
- * the pass aborted/failed.
12871
+ * Each tier that rewrote history re-anchors the in-flight context snapshot,
12872
+ * then the caller's progress predicate is re-tested; the first tier that
12873
+ * restores progress emits one info notice describing everything freed and
12874
+ * stops. Returns whether progress was restored — `false` falls through to
12875
+ * the dead-end warning.
12521
12876
  */
12522
- async #tryShakeRescueForDeadEnd(signal: AbortSignal): Promise<ShakeResult | undefined> {
12523
- if (signal.aborted) return undefined;
12877
+ async #rescueCompactionDeadEnd(
12878
+ signal: AbortSignal,
12879
+ options: { skipElide: boolean; hasProgress: () => boolean },
12880
+ ): Promise<boolean> {
12881
+ if (signal.aborted) return false;
12882
+ let elided = 0;
12883
+ let elidedTokens = 0;
12884
+ let elideSink = "placeholders";
12885
+ if (!options.skipElide) {
12886
+ try {
12887
+ const result = await this.shake("elide", { signal });
12888
+ elided = result.toolResultsDropped + result.blocksDropped;
12889
+ elidedTokens = result.tokensFreed;
12890
+ if (result.artifactId) elideSink = "an artifact";
12891
+ if (elided > 0) {
12892
+ // The elide pass rewrote history; re-anchor the in-flight snapshot
12893
+ // so the caller's headroom/retry-fit re-test measures the shaken
12894
+ // context.
12895
+ this.#rebasePendingContextSnapshotAfterCompaction();
12896
+ }
12897
+ } catch (error) {
12898
+ logger.warn("Dead-end shake rescue failed", {
12899
+ error: error instanceof Error ? error.message : String(error),
12900
+ });
12901
+ }
12902
+ if (elided > 0 && options.hasProgress()) {
12903
+ this.emitNotice(
12904
+ "info",
12905
+ `Compaction dead-end recovery: ${this.#describeElideRescue(elided, elidedTokens, elideSink)} so maintenance could make progress.`,
12906
+ "compaction",
12907
+ );
12908
+ return true;
12909
+ }
12910
+ }
12911
+ if (signal.aborted) return false;
12912
+ let imagesDropped = 0;
12524
12913
  try {
12525
- const result = await this.shake("elide", { signal });
12526
- return result.toolResultsDropped + result.blocksDropped > 0 ? result : undefined;
12914
+ imagesDropped = (await this.dropImages()).removed;
12915
+ if (imagesDropped > 0) this.#rebasePendingContextSnapshotAfterCompaction();
12527
12916
  } catch (error) {
12528
- logger.warn("Dead-end shake rescue failed", {
12917
+ logger.warn("Dead-end image-drop rescue failed", {
12529
12918
  error: error instanceof Error ? error.message : String(error),
12530
12919
  });
12531
- return undefined;
12532
12920
  }
12921
+ if (imagesDropped > 0 && options.hasProgress()) {
12922
+ const elidedPart = elided > 0 ? `${this.#describeElideRescue(elided, elidedTokens, elideSink)} and ` : "";
12923
+ this.emitNotice(
12924
+ "info",
12925
+ `Compaction dead-end recovery: ${elidedPart}dropped ${imagesDropped} attached image${imagesDropped === 1 ? "" : "s"} so maintenance could make progress.`,
12926
+ "compaction",
12927
+ );
12928
+ return true;
12929
+ }
12930
+ return false;
12533
12931
  }
12534
12932
 
12535
- /** Notice describing a successful dead-end elide rescue. */
12536
- #emitShakeRescueNotice(result: ShakeResult): void {
12537
- const elided = result.toolResultsDropped + result.blocksDropped;
12538
- const sink = result.artifactId ? "an artifact" : "placeholders";
12539
- this.emitNotice(
12540
- "info",
12541
- `Compaction dead-end recovery: elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${result.tokensFreed.toLocaleString()} tokens) to ${sink} so maintenance could make progress.`,
12542
- "compaction",
12543
- );
12933
+ /** Notice fragment for a dead-end elide tier: what was freed and where it went. */
12934
+ #describeElideRescue(elided: number, tokensFreed: number, sink: string): string {
12935
+ return `elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${tokensFreed.toLocaleString()} tokens) to ${sink}`;
12544
12936
  }
12545
12937
 
12546
12938
  /**
@@ -13048,6 +13440,7 @@ export class AgentSession {
13048
13440
  const newEntries = this.sessionManager.getEntries();
13049
13441
  const sessionContext = this.buildDisplaySessionContext();
13050
13442
  this.agent.replaceMessages(sessionContext.messages);
13443
+ this.#rebasePendingContextSnapshotAfterCompaction();
13051
13444
  // Compaction discarded the conversation history that carried the approved
13052
13445
  // plan reference. Clear the sent-flag so #buildPlanReferenceMessage re-reads
13053
13446
  // the plan from disk and re-injects it on the next turn (issue #1246).
@@ -13081,23 +13474,28 @@ export class AgentSession {
13081
13474
  details,
13082
13475
  preserveData,
13083
13476
  };
13084
- await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
13085
-
13086
- // Post-maintenance progress guard. Snapcompact can project over budget and
13087
- // fall back to a context-full summary; the summarizer keeps `keepRecentTokens`
13088
- // of recent history verbatim and findCutPoint can only cut at turn
13089
- // boundaries (never tool results), so a single oversized recent turn (e.g. a
13090
- // huge tool result) leaves the rewritten context still above threshold.
13091
- // Scheduling the continuation regardless means the next agent_end re-enters
13092
- // #checkCompaction over the same oversized tail and re-fires forever. The
13093
- // retry and the threshold auto-continue use different progress tests (a
13094
- // recoverable overflow only has to fit; the auto-continue thrash needs the
13095
- // stricter recovery band), so each branch evaluates its own below.
13477
+ // Post-maintenance progress guard evaluated BEFORE emitting
13478
+ // auto_compaction_end so the TUI rebuild triggered by that event
13479
+ // already reflects any rescue rewrite (elide / image-drop) and the
13480
+ // dead-end warning stamped on the compaction entry. Snapcompact can
13481
+ // project over budget and fall back to a context-full summary; the
13482
+ // summarizer keeps `keepRecentTokens` of recent history verbatim and
13483
+ // findCutPoint can only cut at turn boundaries (never tool results),
13484
+ // so a single oversized recent turn (e.g. a huge tool result) leaves
13485
+ // the rewritten context still above threshold. Scheduling the
13486
+ // continuation regardless means the next agent_end re-enters
13487
+ // #checkCompaction over the same oversized tail and re-fires forever.
13488
+ // The retry and the threshold auto-continue use different progress
13489
+ // tests (a recoverable overflow only has to fit; the auto-continue
13490
+ // thrash needs the stricter recovery band), so each branch evaluates
13491
+ // its own below.
13096
13492
  let continuationScheduled = false;
13097
13493
  // A non-idle pass that wanted to continue (retry or auto-continue) but freed
13098
13494
  // too little for that path to proceed is a dead-end: warn once so the user
13099
13495
  // understands why maintenance paused instead of silently looping.
13100
13496
  let noProgressDeadEnd = false;
13497
+ let retryFits = false;
13498
+ let hasHeadroom = false;
13101
13499
 
13102
13500
  if (willRetry) {
13103
13501
  const messages = this.agent.state.messages;
@@ -13113,6 +13511,7 @@ export class AgentSession {
13113
13511
  (reason === "incomplete" && lastAssistant.stopReason === "length");
13114
13512
  if (shouldDrop) {
13115
13513
  this.agent.replaceMessages(messages.slice(0, -1));
13514
+ this.#rebasePendingContextSnapshotAfterCompaction();
13116
13515
  }
13117
13516
  }
13118
13517
 
@@ -13121,18 +13520,14 @@ export class AgentSession {
13121
13520
  // won't include) is excluded. Reusing the auto-continue recovery band
13122
13521
  // here turned recoverable overflows into manual dead-ends (#3412 review),
13123
13522
  // so use the looser fit budget.
13124
- let retryFits = this.#compactionCreatedRetryFit();
13125
- if (!retryFits && !fallbackFromShake) {
13126
- const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
13127
- if (rescue && this.#compactionCreatedRetryFit()) {
13128
- retryFits = true;
13129
- this.#emitShakeRescueNotice(rescue);
13130
- }
13523
+ retryFits = this.#compactionCreatedRetryFit();
13524
+ if (!retryFits) {
13525
+ retryFits = await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
13526
+ skipElide: fallbackFromShake,
13527
+ hasProgress: () => this.#compactionCreatedRetryFit(),
13528
+ });
13131
13529
  }
13132
- if (retryFits) {
13133
- this.#scheduleAgentContinue({ delayMs: 100, generation });
13134
- continuationScheduled = true;
13135
- } else {
13530
+ if (!retryFits) {
13136
13531
  noProgressDeadEnd = true;
13137
13532
  }
13138
13533
  } else if (reason !== "idle") {
@@ -13143,23 +13538,36 @@ export class AgentSession {
13143
13538
  // when auto-continue is disabled, a no-headroom threshold pass must still
13144
13539
  // block later automatic continuations (todo reminders/session_stop hooks)
13145
13540
  // from re-entering the same oversized context.
13146
- let hasHeadroom = this.#compactionCreatedHeadroom();
13147
- if (!hasHeadroom && !fallbackFromShake) {
13148
- const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
13149
- if (rescue && this.#compactionCreatedHeadroom()) {
13150
- hasHeadroom = true;
13151
- this.#emitShakeRescueNotice(rescue);
13152
- }
13541
+ hasHeadroom = this.#compactionCreatedHeadroom();
13542
+ if (!hasHeadroom) {
13543
+ hasHeadroom = await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
13544
+ skipElide: fallbackFromShake,
13545
+ hasProgress: () => this.#compactionCreatedHeadroom(),
13546
+ });
13153
13547
  }
13154
- if (hasHeadroom) {
13155
- if (shouldAutoContinue) {
13156
- this.#scheduleAutoContinuePrompt(generation);
13157
- continuationScheduled = true;
13158
- }
13159
- } else {
13548
+ if (!hasHeadroom) {
13160
13549
  noProgressDeadEnd = true;
13161
13550
  }
13162
13551
  }
13552
+
13553
+ const deadEndWarning = noProgressDeadEnd ? compactionDeadEndWarning("clear large tool output") : undefined;
13554
+ if (deadEndWarning && savedCompactionEntry) {
13555
+ // Stamp the divider: the compaction bar badges the dead-end and
13556
+ // carries the full warning in its ctrl+o detail, so the pause
13557
+ // stays explained even after the notice row scrolls away.
13558
+ savedCompactionEntry.warning = deadEndWarning;
13559
+ await this.sessionManager.rewriteEntries();
13560
+ }
13561
+
13562
+ await this.#emitSessionEvent({ type: "auto_compaction_end", action, result, aborted: false, willRetry });
13563
+
13564
+ if (retryFits) {
13565
+ this.#scheduleAgentContinue({ delayMs: 100, generation });
13566
+ continuationScheduled = true;
13567
+ } else if (hasHeadroom && shouldAutoContinue) {
13568
+ this.#scheduleAutoContinuePrompt(generation);
13569
+ continuationScheduled = true;
13570
+ }
13163
13571
  if (!continuationScheduled && !suppressContinuation && this.agent.hasQueuedMessages()) {
13164
13572
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
13165
13573
  // Kick the loop so queued messages are actually delivered. This remains separate
@@ -13172,12 +13580,8 @@ export class AgentSession {
13172
13580
  continuationScheduled = true;
13173
13581
  }
13174
13582
 
13175
- if (noProgressDeadEnd) {
13176
- this.emitNotice(
13177
- "warning",
13178
- compactionDeadEndWarning("clear large tool output, run `/shake images` to drop attached images,"),
13179
- "compaction",
13180
- );
13583
+ if (deadEndWarning) {
13584
+ this.emitNotice("warning", deadEndWarning, "compaction");
13181
13585
  }
13182
13586
  if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
13183
13587
  return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
@@ -13845,6 +14249,34 @@ export class AgentSession {
13845
14249
  return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
13846
14250
  }
13847
14251
 
14252
+ /**
14253
+ * True when a turn failed with a hard (non-retryable) provider error but a
14254
+ * configured `retry.fallbackChains` entry covers the active model: the same
14255
+ * model is not worth retrying, yet a DIFFERENT model is a fresh chance, so
14256
+ * the chain is consulted before the error becomes final. Skips failures a
14257
+ * model switch cannot fix or must not replay: cancellations (abort-flavored
14258
+ * errors are not model faults), context overflow (compaction's job),
14259
+ * classifier refusals (chain consult is handled on the retryable path with
14260
+ * `pinFallback`), and turns that already emitted a tool call (replaying
14261
+ * could duplicate work).
14262
+ */
14263
+ #isHardErrorFallbackEligible(message: AssistantMessage): boolean {
14264
+ if (message.stopReason !== "error") return false;
14265
+ const model = this.model;
14266
+ if (!model) return false;
14267
+ const retrySettings = this.settings.getGroup("retry");
14268
+ if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
14269
+ if (this.#isClassifierRefusal(message)) return false;
14270
+ const id = this.#classifyRetryMessage(message);
14271
+ if (AIError.is(id, AIError.Flag.Abort) || AIError.is(id, AIError.Flag.UserInterrupt)) return false;
14272
+ if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
14273
+ if (this.#hasReplayUnsafeToolOutput(message)) return false;
14274
+ const currentSelector = formatRetryFallbackSelector(model, this.thinkingLevel);
14275
+ const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
14276
+ if (!role) return false;
14277
+ return this.#findRetryFallbackCandidates(role, currentSelector).length > 0;
14278
+ }
14279
+
13848
14280
  /**
13849
14281
  * Switch the active model from a Fireworks Fast (`-fast`) variant to its base
13850
14282
  * (Standard) id and stick there for the rest of the session — the auto
@@ -13968,12 +14400,16 @@ export class AgentSession {
13968
14400
  }
13969
14401
 
13970
14402
  /**
13971
- * Handle retryable errors with exponential backoff.
14403
+ * Handle retryable errors with exponential backoff, credential rotation, and
14404
+ * model-fallback chains. Also entered for NON-retryable errors when a switch
14405
+ * is the recovery (`fireworksFastFallback`, `hardErrorFallback`): then a
14406
+ * successful model switch retries immediately, and a failed switch surfaces
14407
+ * the error without a same-model backoff retry.
13972
14408
  * @returns true if retry was initiated, false if max retries exceeded or disabled
13973
14409
  */
13974
14410
  async #handleRetryableError(
13975
14411
  message: AssistantMessage,
13976
- options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean },
14412
+ options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean; hardErrorFallback?: boolean },
13977
14413
  ): Promise<boolean> {
13978
14414
  const retrySettings = this.settings.getGroup("retry");
13979
14415
  // The Fireworks Fast→base degrade is an intrinsic model-selection safety net,
@@ -14115,11 +14551,16 @@ export class AgentSession {
14115
14551
  this.#resolveRetry();
14116
14552
  return false;
14117
14553
  }
14118
- // Fast→base was requested but the base switch could not happen (e.g. the
14119
- // base model has no credential). Don't fall through to backing-off and
14120
- // retrying the failing fast model for a hard router error that the generic
14121
- // classifier wouldn't retry — surface it instead.
14122
- if (options?.fireworksFastFallback && !switchedModel && !this.#isRetryableError(message)) {
14554
+ // A fallback switch was the whole reason we entered (Fast→base degrade or
14555
+ // a hard-error chain consult) but it could not happen (e.g. no candidate
14556
+ // has a credential). Don't fall through to backing-off and retrying the
14557
+ // failing model for an error the generic classifier wouldn't retry —
14558
+ // surface it instead.
14559
+ if (
14560
+ (options?.fireworksFastFallback || options?.hardErrorFallback) &&
14561
+ !switchedModel &&
14562
+ !this.#isRetryableError(message)
14563
+ ) {
14123
14564
  this.#retryAttempt = 0;
14124
14565
  this.#resolveRetry();
14125
14566
  return false;
@@ -15893,6 +16334,28 @@ export class AgentSession {
15893
16334
  this.#contextUsageRevision++;
15894
16335
  }
15895
16336
 
16337
+ /**
16338
+ * Rebase the in-flight pending context snapshot onto the current message
16339
+ * set after a compaction (or its dead-end rescue) rewrote history mid-run.
16340
+ * The snapshot captures the prompt as submitted at run start and lives for
16341
+ * the whole run; once a compaction entry lands, every earlier usage anchor
16342
+ * is hidden from {@link getContextBreakdown}, so the stale run-start figure
16343
+ * would be reported as live context until the next provider response. That
16344
+ * inflated residual is what the post-compaction headroom/retry-fit checks
16345
+ * measure — a run that started above the recovery band then trips the
16346
+ * "freed too little context" dead-end even when compaction genuinely
16347
+ * shrank the context. No-op while no prompt is in flight.
16348
+ */
16349
+ #rebasePendingContextSnapshotAfterCompaction(): void {
16350
+ if (!this.#pendingContextSnapshot) return;
16351
+ const nonMessageTokens = computeNonMessageTokens(this);
16352
+ this.#setPendingContextSnapshot({
16353
+ promptTokens: nonMessageTokens + this.messages.reduce((sum, msg) => sum + estimateTokens(msg), 0),
16354
+ nonMessageTokens,
16355
+ cutoffCount: this.messages.length,
16356
+ });
16357
+ }
16358
+
15896
16359
  #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
15897
16360
  if (model?.provider !== "anthropic") return;
15898
16361
  this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {