@oh-my-pi/pi-coding-agent 16.0.1 → 16.0.3

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 (102) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +0 -1
  3. package/dist/cli.js +316 -371
  4. package/dist/types/advisor/advise-tool.d.ts +30 -1
  5. package/dist/types/commands/install.d.ts +1 -1
  6. package/dist/types/config/model-resolver.d.ts +22 -0
  7. package/dist/types/config/settings-schema.d.ts +0 -10
  8. package/dist/types/eval/js/shared/runtime.d.ts +1 -0
  9. package/dist/types/eval/js/worker-core.d.ts +1 -0
  10. package/dist/types/exec/non-interactive-env.d.ts +2 -0
  11. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  12. package/dist/types/goals/runtime.d.ts +0 -1
  13. package/dist/types/mcp/tool-bridge.d.ts +3 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +14 -4
  15. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  16. package/dist/types/modes/interactive-mode.d.ts +1 -1
  17. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
  18. package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
  19. package/dist/types/modes/types.d.ts +1 -1
  20. package/dist/types/registry/agent-lifecycle.d.ts +16 -1
  21. package/dist/types/sdk.d.ts +8 -0
  22. package/dist/types/session/agent-session.d.ts +20 -8
  23. package/dist/types/session/messages.d.ts +3 -0
  24. package/dist/types/session/session-dump-format.d.ts +8 -2
  25. package/dist/types/session/session-entries.d.ts +4 -0
  26. package/dist/types/session/session-history-format.d.ts +2 -0
  27. package/dist/types/session/session-manager.d.ts +22 -0
  28. package/dist/types/stt/downloader.d.ts +5 -5
  29. package/dist/types/task/executor.d.ts +6 -0
  30. package/dist/types/task/persisted-revive.d.ts +36 -0
  31. package/dist/types/tiny/models.d.ts +8 -0
  32. package/dist/types/tools/builtin-names.d.ts +1 -1
  33. package/dist/types/tools/index.d.ts +0 -1
  34. package/dist/types/utils/markit.d.ts +8 -0
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +156 -12
  37. package/src/advisor/advise-tool.ts +48 -6
  38. package/src/advisor/runtime.ts +10 -3
  39. package/src/auto-thinking/classifier.ts +12 -3
  40. package/src/cli/args.ts +1 -0
  41. package/src/cli.ts +2 -2
  42. package/src/commands/install.ts +3 -3
  43. package/src/config/model-resolver.ts +63 -12
  44. package/src/config/settings-schema.ts +0 -11
  45. package/src/discovery/github.ts +89 -1
  46. package/src/eval/agent-bridge.ts +2 -0
  47. package/src/eval/js/context-manager.ts +2 -1
  48. package/src/eval/js/shared/runtime.ts +189 -15
  49. package/src/eval/js/worker-core.ts +19 -0
  50. package/src/exec/bash-executor.ts +2 -2
  51. package/src/exec/non-interactive-env.ts +71 -0
  52. package/src/export/html/index.ts +1 -1
  53. package/src/export/html/tool-views.generated.js +34 -35
  54. package/src/extensibility/extensions/loader.ts +21 -9
  55. package/src/extensibility/extensions/runner.ts +17 -1
  56. package/src/extensibility/plugins/loader.ts +154 -21
  57. package/src/extensibility/plugins/manager.ts +40 -33
  58. package/src/goals/runtime.ts +1 -23
  59. package/src/internal-urls/docs-index.generated.ts +9 -11
  60. package/src/main.ts +20 -0
  61. package/src/mcp/render.ts +11 -1
  62. package/src/mcp/tool-bridge.ts +3 -0
  63. package/src/modes/components/custom-editor.test.ts +63 -18
  64. package/src/modes/components/custom-editor.ts +63 -15
  65. package/src/modes/controllers/command-controller.ts +2 -2
  66. package/src/modes/controllers/input-controller.ts +15 -9
  67. package/src/modes/controllers/selector-controller.ts +13 -8
  68. package/src/modes/controllers/tan-command-controller.ts +1 -0
  69. package/src/modes/interactive-mode.ts +4 -2
  70. package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
  71. package/src/modes/theme/mermaid-cache.ts +74 -11
  72. package/src/modes/theme/theme.ts +14 -1
  73. package/src/modes/types.ts +1 -1
  74. package/src/prompts/system/system-prompt.md +2 -1
  75. package/src/registry/agent-lifecycle.ts +60 -8
  76. package/src/sdk.ts +20 -26
  77. package/src/session/agent-session.ts +381 -110
  78. package/src/session/artifacts.ts +19 -1
  79. package/src/session/messages.ts +1 -1
  80. package/src/session/session-dump-format.ts +167 -23
  81. package/src/session/session-entries.ts +4 -0
  82. package/src/session/session-history-format.ts +37 -3
  83. package/src/session/session-manager.ts +94 -4
  84. package/src/slash-commands/builtin-registry.ts +4 -7
  85. package/src/stt/asr-client.ts +6 -0
  86. package/src/stt/downloader.ts +13 -6
  87. package/src/stt/stt-controller.ts +52 -11
  88. package/src/system-prompt.ts +7 -1
  89. package/src/task/executor.ts +118 -6
  90. package/src/task/index.ts +2 -2
  91. package/src/task/persisted-revive.ts +128 -0
  92. package/src/tiny/models.ts +10 -0
  93. package/src/tiny/worker.ts +4 -3
  94. package/src/tools/builtin-names.ts +0 -1
  95. package/src/tools/index.ts +0 -4
  96. package/src/tools/output-meta.ts +17 -3
  97. package/src/utils/lang-from-path.ts +5 -0
  98. package/src/utils/markit.ts +24 -1
  99. package/src/utils/title-generator.ts +4 -4
  100. package/dist/types/tools/render-mermaid.d.ts +0 -38
  101. package/src/prompts/tools/render-mermaid.md +0 -9
  102. package/src/tools/render-mermaid.ts +0 -69
@@ -126,7 +126,7 @@ import {
126
126
  AdvisorRuntime,
127
127
  type AdvisorSeverity,
128
128
  formatAdvisorBatchContent,
129
- isInterruptingSeverity,
129
+ resolveAdvisorDeliveryChannel,
130
130
  } from "../advisor";
131
131
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
132
132
  import { classifyDifficulty } from "../auto-thinking/classifier";
@@ -139,9 +139,11 @@ import {
139
139
  filterAvailableModelsByEnabledPatterns,
140
140
  formatModelSelectorValue,
141
141
  formatModelString,
142
+ formatModelStringWithRouting,
142
143
  getModelMatchPreferences,
143
144
  parseModelString,
144
145
  type ResolvedModelRoleValue,
146
+ resolveModelOverride,
145
147
  resolveModelRoleValue,
146
148
  resolveRoleSelection,
147
149
  } from "../config/model-resolver";
@@ -248,7 +250,7 @@ import {
248
250
  } from "../tool-discovery/tool-index";
249
251
  import { assertEditableFile } from "../tools/auto-generated-guard";
250
252
  import type { CheckpointState } from "../tools/checkpoint";
251
- import { outputMeta } from "../tools/output-meta";
253
+ import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
252
254
  import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
253
255
  import { isAutoQaEnabled } from "../tools/report-tool-issue";
254
256
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo";
@@ -273,6 +275,7 @@ import {
273
275
  type BashExecutionMessage,
274
276
  type CustomMessage,
275
277
  convertToLlm,
278
+ GENERIC_ABORT_SENTINEL,
276
279
  type PythonExecutionMessage,
277
280
  readQueueChipText,
278
281
  SILENT_ABORT_MARKER,
@@ -640,8 +643,7 @@ function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | u
640
643
  }
641
644
 
642
645
  function formatRetryFallbackSelector(model: Model, thinkingLevel: ThinkingLevel | undefined): string {
643
- const selector = formatModelString(model);
644
- return thinkingLevel ? `${selector}:${thinkingLevel}` : selector;
646
+ return formatModelSelectorValue(formatModelStringWithRouting(model), thinkingLevel);
645
647
  }
646
648
 
647
649
  function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): string {
@@ -953,6 +955,44 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
953
955
  return message.role === "custom" && message.customType === "advisor";
954
956
  }
955
957
 
958
+ /**
959
+ * A queued message the user can restore to the editor / pull back as a draft.
960
+ * Only genuinely user-authored messages qualify: plain user turns, or custom
961
+ * messages explicitly attributed to the user (e.g. `/skill` invocations).
962
+ * Agent-authored queued cards — advisor concern/blocker notes, IRC asides,
963
+ * extension notices, hidden goal/plan/budget steers — ride the same
964
+ * steer/follow-up queues but must never be dumped into the editor on Esc/Alt+Up.
965
+ */
966
+ function isUserQueuedMessage(message: AgentMessage): boolean {
967
+ if (message.role === "user") return true;
968
+ return message.role === "custom" && message.attribution === "user" && message.display !== false;
969
+ }
970
+
971
+ /** Custom-message types of the hidden magic-keyword notices that `#createMagicKeywordNotices`
972
+ * enqueues alongside a user prompt. Keep in sync with that method. */
973
+ const MAGIC_KEYWORD_NOTICE_TYPES: ReadonlySet<string> = new Set([
974
+ "ultrathink-notice",
975
+ "orchestrate-notice",
976
+ "workflow-notice",
977
+ ]);
978
+
979
+ /**
980
+ * A hidden, user-attributed companion of a queued user prompt: the magic-keyword
981
+ * notices (`ultrathink`/`orchestrate`/`workflow`) enqueued alongside the user
982
+ * message. They are `attribution: "user"` but `display: false`, so they are not
983
+ * editor-restorable; when the user pulls their prompt back out of the queue these
984
+ * must leave with it rather than linger as stale, companion-less steering. Scoped to
985
+ * the known notice types so an unrelated hidden user custom is never silently dropped.
986
+ */
987
+ function isHiddenUserCompanion(message: AgentMessage): boolean {
988
+ return (
989
+ message.role === "custom" &&
990
+ message.attribution === "user" &&
991
+ message.display === false &&
992
+ MAGIC_KEYWORD_NOTICE_TYPES.has(message.customType)
993
+ );
994
+ }
995
+
956
996
  function queueChipText(message: AgentMessage): string {
957
997
  if (message.role === "custom") {
958
998
  return readQueueChipText(message.details) ?? queuedTextContent(message) ?? "";
@@ -1258,7 +1298,70 @@ export class AgentSession {
1258
1298
  * queue was consumed normally or a new turn already started. */
1259
1299
  #drainStrandedQueuedMessages(): void {
1260
1300
  if (this.#abortInProgress) return;
1301
+ // A concern steered into a resumed streaming run after a user interrupt can
1302
+ // strand at the turn tail (steered past the loop's final boundary poll). While
1303
+ // that interrupt's suppression is still in effect, reclaim such advisor steers
1304
+ // as visible advice once idle — mirroring abort's #extractQueuedAdvisorCards —
1305
+ // so they neither auto-resume the run the user stopped (a non-empty steer queue
1306
+ // otherwise bypasses the latch in #canAutoContinueForFollowUp) nor linger to
1307
+ // flush at the next prompt. Real user steers/follow-ups are left untouched.
1308
+ if (this.#advisorAutoResumeSuppressed && !this.isStreaming) {
1309
+ for (const card of this.#extractQueuedAdvisorCards()) {
1310
+ this.#preserveAdvisorCard(card);
1311
+ }
1312
+ }
1261
1313
  this.#scheduleQueuedMessageDrain();
1314
+ this.#resumeStrandedIrcAsides();
1315
+ }
1316
+
1317
+ /** IRC asides that arrive after the loop's final aside poll — or while an abort skipped that
1318
+ * poll — land in #pendingIrcAsides with no loop left to drain them; the queued-message drain's
1319
+ * gate (agent.hasQueuedMessages()) does not count them. Once idle, wake a turn so the agent
1320
+ * responds to the peer. Skip only when a queued steer/follow-up will itself drive a resume turn
1321
+ * whose aside poll already consumes these (no double-wake). */
1322
+ #resumeStrandedIrcAsides(): void {
1323
+ if (this.#isDisposed || this.isStreaming) return;
1324
+ if (this.#pendingIrcAsides.length === 0) return;
1325
+ if (this.#canAutoContinueForFollowUp() && this.agent.hasQueuedMessages()) return;
1326
+ const records = this.#pendingIrcAsides;
1327
+ this.#pendingIrcAsides = [];
1328
+ this.#wakeForIrc(records);
1329
+ }
1330
+
1331
+ /** Fire-and-forget wake turn for incoming IRC — idle delivery and stranded-aside resume both
1332
+ * route here. Wrapped in #beginInFlight/#endInFlight so the turn is tracked and its settle
1333
+ * re-drains anything that stranded during it. A user interrupt may have intentionally left a
1334
+ * follow-up queued behind an invalid tail (seam #5); the wake turn's loop would otherwise drain
1335
+ * it, so park the follow-up queue across the wake and restore it after. It stays queued post-wake
1336
+ * because #canAutoContinueForFollowUp suppresses follow-up auto-resume while a user interrupt is
1337
+ * in effect, even though the wake left a provider-valid tail. */
1338
+ #wakeForIrc(records: CustomMessage[]): void {
1339
+ // Park only a *blocked* follow-up (one a user interrupt is intentionally holding); an
1340
+ // already-resumable follow-up can ride the wake turn normally without reordering.
1341
+ const parkedFollowUps =
1342
+ this.agent.peekSteeringQueue().length === 0 &&
1343
+ this.agent.peekFollowUpQueue().length > 0 &&
1344
+ !this.#canAutoContinueForFollowUp()
1345
+ ? [...this.agent.peekFollowUpQueue()]
1346
+ : [];
1347
+ if (parkedFollowUps.length > 0) {
1348
+ this.agent.replaceQueues([...this.agent.peekSteeringQueue()], []);
1349
+ }
1350
+ this.#beginInFlight();
1351
+ void this.agent
1352
+ .prompt(records)
1353
+ .catch(error => {
1354
+ logger.warn("IRC wake turn failed", { error: String(error) });
1355
+ })
1356
+ .finally(() => {
1357
+ if (parkedFollowUps.length > 0) {
1358
+ this.agent.replaceQueues(
1359
+ [...this.agent.peekSteeringQueue()],
1360
+ [...parkedFollowUps, ...this.agent.peekFollowUpQueue()],
1361
+ );
1362
+ }
1363
+ this.#endInFlight();
1364
+ });
1262
1365
  }
1263
1366
 
1264
1367
  /** Remove advisor concern/blocker cards from the agent-core steer/follow-up
@@ -1279,14 +1382,14 @@ export class AgentSession {
1279
1382
  }
1280
1383
 
1281
1384
  /** Record a suppressed advisor concern as visible, persisted advice without
1282
- * triggering a turn. When the agent is idle (the normal post-interrupt case),
1283
- * emit message_start/message_end like #flushPendingIrcAsides so
1284
- * #handleAgentEvent renders it live (TUI/ACP) and persists it as a
1285
- * CustomMessageEntry. While a turn is still tearing down (mid-abort), park it
1286
- * hidden so abort's settle step replays it once idle never appended into a
1287
- * live streamMessage. */
1385
+ * triggering a turn. When the agent is idle (the normal post-interrupt case,
1386
+ * including the post-prompt unwind window where the core loop has ended), emit
1387
+ * message_start/message_end like #flushPendingIrcAsides so #handleAgentEvent
1388
+ * renders it live (TUI/ACP) and persists it as a CustomMessageEntry. Only while
1389
+ * an abort is still tearing a live turn down do we park it hidden, so abort's
1390
+ * settle step replays it once idle — never appended into a live streamMessage. */
1288
1391
  #preserveAdvisorCard(card: CustomMessage): void {
1289
- if (this.isStreaming) {
1392
+ if (this.#abortInProgress && this.isStreaming) {
1290
1393
  this.#pendingNextTurnMessages.push(card);
1291
1394
  return;
1292
1395
  }
@@ -1526,33 +1629,48 @@ export class AgentSession {
1526
1629
  // channel (aborting in-flight tools at the next steering boundary); when the
1527
1630
  // loop has already yielded, triggerTurn resumes it so the advice is acted on
1528
1631
  // immediately rather than waiting for the next user prompt. After a deliberate
1529
- // user interrupt that auto-resume is suppressed: the concern is recorded as
1530
- // visible advice and re-enters context only when the user resumes. A plain nit
1531
- // rides the non-interrupting YieldQueue aside.
1632
+ // user interrupt the auto-resume is suppressed — but only while the agent is
1633
+ // idle or still tearing the interrupted turn down: a concern is then recorded
1634
+ // as a visible card and re-enters context when the user resumes. Once a turn
1635
+ // is streaming again (a resume the user already drove) it is steered in live,
1636
+ // since steering an active run auto-resumes nothing; parking it there would
1637
+ // strand the advice and dump the backlog as one burst at the next prompt. A
1638
+ // plain nit always rides the non-interrupting YieldQueue aside.
1532
1639
  const enqueueAdvice = (note: string, severity?: AdvisorSeverity) => {
1533
- if (isInterruptingSeverity(severity)) {
1534
- const notes: AdvisorNote[] = [{ note, severity }];
1535
- const content = formatAdvisorBatchContent(notes);
1536
- const details = { notes } satisfies AdvisorMessageDetails;
1537
- if (this.#advisorAutoResumeSuppressed) {
1538
- this.#preserveAdvisorCard({
1539
- role: "custom",
1540
- customType: "advisor",
1541
- content,
1542
- display: true,
1543
- attribution: "agent",
1544
- details,
1545
- timestamp: Date.now(),
1546
- });
1547
- return;
1548
- }
1549
- void this.sendCustomMessage(
1550
- { customType: "advisor", content, display: true, attribution: "agent", details },
1551
- { deliverAs: "steer", triggerTurn: true },
1552
- ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1640
+ const channel = resolveAdvisorDeliveryChannel({
1641
+ severity,
1642
+ autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
1643
+ // Key on the live agent-core loop, not session `isStreaming` (which also
1644
+ // counts `#promptInFlightCount` during post-turn unwind). Only a running
1645
+ // loop will consume a steer at its next boundary; steering into the unwind
1646
+ // window would strand the card and let #drainStrandedQueuedMessages
1647
+ // auto-resume it despite the user's interrupt.
1648
+ streaming: this.agent.state.isStreaming,
1649
+ aborting: this.#abortInProgress,
1650
+ });
1651
+ if (channel === "aside") {
1652
+ this.yieldQueue.enqueue("advisor", { note, severity });
1553
1653
  return;
1554
1654
  }
1555
- this.yieldQueue.enqueue("advisor", { note, severity });
1655
+ const notes: AdvisorNote[] = [{ note, severity }];
1656
+ const content = formatAdvisorBatchContent(notes);
1657
+ const details = { notes } satisfies AdvisorMessageDetails;
1658
+ if (channel === "preserve") {
1659
+ this.#preserveAdvisorCard({
1660
+ role: "custom",
1661
+ customType: "advisor",
1662
+ content,
1663
+ display: true,
1664
+ attribution: "agent",
1665
+ details,
1666
+ timestamp: Date.now(),
1667
+ });
1668
+ return;
1669
+ }
1670
+ void this.sendCustomMessage(
1671
+ { customType: "advisor", content, display: true, attribution: "agent", details },
1672
+ { deliverAs: "steer", triggerTurn: true },
1673
+ ).catch(err => logger.debug("advisor delivery failed", { err: String(err) }));
1556
1674
  };
1557
1675
 
1558
1676
  const adviseTool = new AdviseTool(enqueueAdvice);
@@ -2402,6 +2520,11 @@ export class AgentSession {
2402
2520
  return;
2403
2521
  }
2404
2522
 
2523
+ if (this.#isRetryableReasonlessAbort(msg)) {
2524
+ const didRetry = await this.#handleRetryableError(msg, { allowModelFallback: false });
2525
+ if (didRetry) return;
2526
+ }
2527
+
2405
2528
  // A deliberate abort should settle the current turn, not trigger queued continuations.
2406
2529
  if (msg.stopReason === "aborted") {
2407
2530
  this.#resolveRetry();
@@ -2566,6 +2689,11 @@ export class AgentSession {
2566
2689
 
2567
2690
  #scheduleAutoContinuePrompt(generation: number): void {
2568
2691
  const continuePrompt = async () => {
2692
+ // Compaction summarizes away the first-message eager preludes, so re-assert the
2693
+ // delegate-via-tasks / phased-todo reminders on this auto-resumed turn. This runs
2694
+ // at invocation (past the abort check below), so an aborted continuation queues
2695
+ // nothing; scoped to this request via prependMessages, never the shared queue.
2696
+ const eagerNudges = this.#buildPostCompactionEagerNudges();
2569
2697
  await this.#promptWithMessage(
2570
2698
  {
2571
2699
  role: "developer",
@@ -2574,7 +2702,10 @@ export class AgentSession {
2574
2702
  timestamp: Date.now(),
2575
2703
  },
2576
2704
  autoContinuePrompt,
2577
- { skipPostPromptRecoveryWait: true },
2705
+ {
2706
+ skipPostPromptRecoveryWait: true,
2707
+ prependMessages: eagerNudges.length > 0 ? eagerNudges : undefined,
2708
+ },
2578
2709
  );
2579
2710
  };
2580
2711
  this.#schedulePostPromptTask(
@@ -3629,7 +3760,7 @@ export class AgentSession {
3629
3760
  */
3630
3761
  beginDispose(): void {
3631
3762
  this.#isDisposed = true;
3632
- this.#pendingIrcAsides = [];
3763
+ this.#flushPendingIrcAsides();
3633
3764
  this.yieldQueue.clear();
3634
3765
  this.agent.setAsideMessageProvider(undefined);
3635
3766
  this.#stopAdvisorRuntime();
@@ -4480,7 +4611,7 @@ export class AgentSession {
4480
4611
  });
4481
4612
 
4482
4613
  for (const customTool of mcpTools) {
4483
- const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool;
4614
+ const wrapped = wrapToolWithMetaNotice(CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool);
4484
4615
  const finalTool = (
4485
4616
  this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped
4486
4617
  ) as AgentTool;
@@ -4540,8 +4671,9 @@ export class AgentSession {
4540
4671
  this.#rpcHostToolNames.clear();
4541
4672
 
4542
4673
  for (const tool of rpcTools) {
4674
+ const metaWrapped = wrapToolWithMetaNotice(tool);
4543
4675
  const finalTool = (
4544
- this.#extensionRunner ? new ExtensionToolWrapper(tool, this.#extensionRunner) : tool
4676
+ this.#extensionRunner ? new ExtensionToolWrapper(metaWrapped, this.#extensionRunner) : metaWrapped
4545
4677
  ) as AgentTool;
4546
4678
  this.#toolRegistry.set(finalTool.name, finalTool);
4547
4679
  this.#rpcHostToolNames.add(finalTool.name);
@@ -5107,15 +5239,16 @@ export class AgentSession {
5107
5239
  if (!options?.streamingBehavior) {
5108
5240
  throw new AgentBusyError();
5109
5241
  }
5242
+ // Steer/follow-up the keyword notices BEFORE the queued user message so the
5243
+ // model reads the steering notice ahead of the prompt it modifies.
5244
+ for (const notice of keywordNotices) {
5245
+ await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5246
+ }
5110
5247
  if (options.streamingBehavior === "followUp") {
5111
5248
  await this.#queueUserMessage(expandedText, options?.images, "followUp");
5112
5249
  } else {
5113
5250
  await this.#queueUserMessage(expandedText, options?.images, "steer");
5114
5251
  }
5115
- // Steer/follow-up the keyword notices alongside the queued user message.
5116
- for (const notice of keywordNotices) {
5117
- await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5118
- }
5119
5252
  return true;
5120
5253
  }
5121
5254
 
@@ -5154,8 +5287,10 @@ export class AgentSession {
5154
5287
  await this.#promptWithMessage(message, expandedText, {
5155
5288
  ...options,
5156
5289
  images: normalizedImages,
5157
- prependMessages: preludeMessages.length > 0 ? preludeMessages : undefined,
5158
- appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
5290
+ prependMessages:
5291
+ preludeMessages.length > 0 || keywordNotices.length > 0
5292
+ ? [...preludeMessages, ...keywordNotices]
5293
+ : undefined,
5159
5294
  });
5160
5295
  } finally {
5161
5296
  // Clean up residual eager-todo directive if the prompt never consumed it
@@ -5194,13 +5329,13 @@ export class AgentSession {
5194
5329
  if (!options?.streamingBehavior) {
5195
5330
  throw new AgentBusyError();
5196
5331
  }
5332
+ for (const notice of keywordNotices) {
5333
+ await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5334
+ }
5197
5335
  await this.sendCustomMessage(message, {
5198
5336
  deliverAs: options.streamingBehavior,
5199
5337
  queueChipText: options.queueChipText,
5200
5338
  });
5201
- for (const notice of keywordNotices) {
5202
- await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
5203
- }
5204
5339
  return;
5205
5340
  }
5206
5341
 
@@ -5216,7 +5351,7 @@ export class AgentSession {
5216
5351
 
5217
5352
  await this.#promptWithMessage(customMessage, textContent, {
5218
5353
  ...options,
5219
- appendMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
5354
+ prependMessages: keywordNotices.length > 0 ? keywordNotices : undefined,
5220
5355
  });
5221
5356
  }
5222
5357
 
@@ -5225,7 +5360,6 @@ export class AgentSession {
5225
5360
  expandedText: string,
5226
5361
  options?: Pick<PromptOptions, "toolChoice" | "images" | "skipCompactionCheck"> & {
5227
5362
  prependMessages?: AgentMessage[];
5228
- appendMessages?: AgentMessage[];
5229
5363
  skipPostPromptRecoveryWait?: boolean;
5230
5364
  },
5231
5365
  ): Promise<void> {
@@ -5292,12 +5426,6 @@ export class AgentSession {
5292
5426
 
5293
5427
  messages.push(message);
5294
5428
 
5295
- // Inject the ultrathink notice (and any other per-turn appends) right after the
5296
- // user message so the model reads it as part of the same turn.
5297
- if (options?.appendMessages) {
5298
- messages.push(...options.appendMessages);
5299
- }
5300
-
5301
5429
  // Early bail-out: if a newer abort/prompt cycle started during setup,
5302
5430
  // return before mutating shared state (nextTurn messages, system prompt).
5303
5431
  if (this.#promptGeneration !== generation) {
@@ -5619,12 +5747,24 @@ export class AgentSession {
5619
5747
  #canAutoContinueForFollowUp(): boolean {
5620
5748
  if (this.isStreaming) return false;
5621
5749
  if (this.isRetrying) return false;
5750
+ // A queued steer resumes from ANY tail: Agent.continue() runs #runLoop(undefined),
5751
+ // whose initial steering poll injects the steer before the first provider call, so the
5752
+ // request tail becomes the steer (valid) regardless of any injected custom / bashExecution
5753
+ // / pythonExecution record a user interrupt left as the literal transcript tail. This is
5754
+ // why a queued user steer stranded behind a preserved advisor card (or a flushed IRC aside
5755
+ // / eval execution record) still resumes — no tail-role enumeration needed.
5756
+ if (this.agent.peekSteeringQueue().length > 0) return true;
5757
+ // Follow-up-only auto-resume stays suppressed while a deliberate user interrupt is in effect
5758
+ // (#advisorAutoResumeSuppressed, cleared on the next user prompt): the user stopped, so their
5759
+ // queued follow-up waits for an explicit resume — even if an interleaving IRC wake turn has
5760
+ // since left a provider-valid tail.
5761
+ if (this.#advisorAutoResumeSuppressed) return false;
5762
+ // Follow-up-only resume has no steer to inject, so Agent.continue() continues from the
5763
+ // existing context tail — which must itself be a valid provider tail. An injected
5764
+ // non-conversational tail (advisor card → `developer`, bash/python execution) would make
5765
+ // the first model call invalid, so leave the follow-up queued for the next explicit resume.
5622
5766
  const messages = this.agent.state.messages;
5623
5767
  const last = messages[messages.length - 1];
5624
- // A user interrupt during tool execution can leave the transcript ending
5625
- // with the emitted tool result, not the aborted assistant message. Continuing
5626
- // from that state is still resumable: Agent.continue() first polls queued
5627
- // steering before making the next model call.
5628
5768
  return last?.role === "assistant" || last?.role === "toolResult";
5629
5769
  }
5630
5770
 
@@ -5857,15 +5997,32 @@ export class AgentSession {
5857
5997
  });
5858
5998
  }
5859
5999
 
5860
- /** Clear queued messages and return them (text plus any attached images). */
5861
- clearQueue(): { steering: RestoredQueuedMessage[]; followUp: RestoredQueuedMessage[] } {
5862
- const steering = this.agent.peekSteeringQueue().map(toRestoredQueuedMessage);
5863
- const followUp = this.agent.peekFollowUpQueue().map(toRestoredQueuedMessage);
5864
- this.agent.clearAllQueues();
6000
+ /** Clear queued messages and return the user-restorable ones (text plus any attached images).
6001
+ * Only user-authored messages (plain user turns, `attribution:"user"` custom like `/skill`) are
6002
+ * returned for editor restore. Other queued messages stay in the agent-core queues so a continuing
6003
+ * stream still delivers them — EXCEPT on `forInterrupt` (Esc+abort), where only advisor cards are
6004
+ * kept (abort()'s #extractQueuedAdvisorCards preserves them as visible advice) and every other
6005
+ * non-user steer (hidden goal/plan/budget, IRC/extension asides) is dropped, so abort()'s
6006
+ * #drainStrandedQueuedMessages can't auto-resume the run the user just interrupted (the drain only
6007
+ * fires while agent.hasQueuedMessages()). Plain Alt+Up dequeue preserves those non-user steers. */
6008
+ clearQueue(options?: { forInterrupt?: boolean }): {
6009
+ steering: RestoredQueuedMessage[];
6010
+ followUp: RestoredQueuedMessage[];
6011
+ } {
6012
+ const steeringAll = this.agent.peekSteeringQueue();
6013
+ const followUpAll = this.agent.peekFollowUpQueue();
6014
+ const steering = steeringAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage);
6015
+ const followUp = followUpAll.filter(isUserQueuedMessage).map(toRestoredQueuedMessage);
6016
+ const keep: (m: AgentMessage) => boolean = options?.forInterrupt
6017
+ ? isAdvisorCard
6018
+ : m => !isUserQueuedMessage(m) && !isHiddenUserCompanion(m);
6019
+ this.agent.replaceQueues(steeringAll.filter(keep), followUpAll.filter(keep));
5865
6020
  return { steering, followUp };
5866
6021
  }
5867
6022
 
5868
- /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages) */
6023
+ /** Number of pending displayable messages (includes steering, follow-up, and next-turn messages).
6024
+ * Reflects actual queued work (advisor cards included) — feeds hasPendingMessages()/RPC and the
6025
+ * empty-submit abort gate. The user-restorable subset is surfaced by getQueuedMessages()/clearQueue(). */
5869
6026
  get queuedMessageCount(): number {
5870
6027
  return (
5871
6028
  this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).length +
@@ -5876,18 +6033,48 @@ export class AgentSession {
5876
6033
 
5877
6034
  getQueuedMessages(): { steering: readonly string[]; followUp: readonly string[] } {
5878
6035
  return {
5879
- steering: this.agent.peekSteeringQueue().filter(isDisplayableQueuedMessage).map(queueChipText),
5880
- followUp: this.agent.peekFollowUpQueue().filter(isDisplayableQueuedMessage).map(queueChipText),
6036
+ steering: this.agent.peekSteeringQueue().filter(isUserQueuedMessage).map(queueChipText),
6037
+ followUp: this.agent.peekFollowUpQueue().filter(isUserQueuedMessage).map(queueChipText),
5881
6038
  };
5882
6039
  }
5883
6040
 
5884
6041
  /**
5885
6042
  * Pop the last queued message (steering first, then follow-up).
5886
6043
  * Used by dequeue keybinding to restore messages to editor one at a time.
6044
+ * Steps over agent-authored queued messages (advisor cards, hidden/internal steers).
5887
6045
  */
5888
6046
  popLastQueuedMessage(): RestoredQueuedMessage | undefined {
5889
- const message = this.agent.popLastSteer() ?? this.agent.popLastFollowUp();
5890
- return message ? toRestoredQueuedMessage(message) : undefined;
6047
+ const steering = this.agent.peekSteeringQueue();
6048
+ const followUp = this.agent.peekFollowUpQueue();
6049
+ const lastUserIndex = (queue: readonly AgentMessage[]): number => {
6050
+ for (let i = queue.length - 1; i >= 0; i--) {
6051
+ if (isUserQueuedMessage(queue[i])) return i;
6052
+ }
6053
+ return -1;
6054
+ };
6055
+ // Notices queue immediately before their user message, so dropping the popped
6056
+ // prompt means also dropping the contiguous hidden-user companions right before
6057
+ // it — companions of other queued prompts stay put.
6058
+ const removeWithCompanions = (queue: readonly AgentMessage[], userIndex: number): AgentMessage[] => {
6059
+ let start = userIndex;
6060
+ while (start > 0 && isHiddenUserCompanion(queue[start - 1])) start--;
6061
+ const next = queue.slice();
6062
+ next.splice(start, userIndex - start + 1);
6063
+ return next;
6064
+ };
6065
+ const fromSteer = lastUserIndex(steering);
6066
+ if (fromSteer >= 0) {
6067
+ const removed = steering[fromSteer];
6068
+ this.agent.replaceQueues(removeWithCompanions(steering, fromSteer), followUp.slice());
6069
+ return toRestoredQueuedMessage(removed);
6070
+ }
6071
+ const fromFollowUp = lastUserIndex(followUp);
6072
+ if (fromFollowUp >= 0) {
6073
+ const removed = followUp[fromFollowUp];
6074
+ this.agent.replaceQueues(steering.slice(), removeWithCompanions(followUp, fromFollowUp));
6075
+ return toRestoredQueuedMessage(removed);
6076
+ }
6077
+ return undefined;
5891
6078
  }
5892
6079
 
5893
6080
  get skillsSettings(): SkillsSettings | undefined {
@@ -7774,7 +7961,9 @@ export class AgentSession {
7774
7961
  };
7775
7962
  }
7776
7963
 
7777
- #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
7964
+ #createEagerTodoPrelude(
7965
+ promptText: string | undefined,
7966
+ ): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
7778
7967
  const mode = this.settings.get("todo.eager");
7779
7968
  const todosEnabled = this.settings.get("todo.enabled");
7780
7969
  if (mode === "default" || !todosEnabled) {
@@ -7791,14 +7980,18 @@ export class AgentSession {
7791
7980
  // Only inject on the first user message of the conversation. Subsequent user
7792
7981
  // turns must not receive the eager todo reminder — they often correct, clarify,
7793
7982
  // or redirect the prior task, and forcing a brand-new todo list there is wrong.
7794
- const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
7795
- if (hasPriorUserMessage) {
7796
- return undefined;
7797
- }
7983
+ // When `promptText` is undefined (post-compaction re-injection) there is no fresh
7984
+ // user message to gate on, so skip the first-message and prompt-suffix checks.
7985
+ if (promptText !== undefined) {
7986
+ const hasPriorUserMessage = this.agent.state.messages.some(m => m.role === "user");
7987
+ if (hasPriorUserMessage) {
7988
+ return undefined;
7989
+ }
7798
7990
 
7799
- const trimmedPromptText = promptText.trimEnd();
7800
- if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
7801
- return undefined;
7991
+ const trimmedPromptText = promptText.trimEnd();
7992
+ if (trimmedPromptText.endsWith("?") || trimmedPromptText.endsWith("!")) {
7993
+ return undefined;
7994
+ }
7802
7995
  }
7803
7996
 
7804
7997
  // Must check the active tool set, not just the registry: tool discovery
@@ -7821,8 +8014,10 @@ export class AgentSession {
7821
8014
  timestamp: Date.now(),
7822
8015
  };
7823
8016
  // `preferred` suggests a todo list (reminder only); `always` also forces the
7824
- // `todo` tool on the first turn — the previous boolean-on behavior.
7825
- if (mode === "preferred") {
8017
+ // `todo` tool on the first turn — the previous boolean-on behavior. Post-compaction
8018
+ // re-injection (`promptText === undefined`) is always reminder-only: forcing a tool
8019
+ // onto the auto-resumed turn would override the agent's in-flight action.
8020
+ if (promptText === undefined || mode === "preferred") {
7826
8021
  return { message };
7827
8022
  }
7828
8023
  const todoToolChoice = buildNamedToolChoice("todo", this.model);
@@ -7840,7 +8035,7 @@ export class AgentSession {
7840
8035
  return { message, toolChoice: todoToolChoice };
7841
8036
  }
7842
8037
 
7843
- #createEagerTaskPrelude(promptText: string): AgentMessage | undefined {
8038
+ #createEagerTaskPrelude(promptText: string | undefined): AgentMessage | undefined {
7844
8039
  if (this.settings.get("task.eager") !== "always") return undefined;
7845
8040
  // Main agent only: subagents keep `task` active (the parent only filters `todo`),
7846
8041
  // so a salient delegate-reminder there would amplify nested fan-out. Gate on the
@@ -7848,9 +8043,13 @@ export class AgentSession {
7848
8043
  // still gets the reminder.
7849
8044
  if (this.#agentKind === "sub") return undefined;
7850
8045
  if (this.#planModeState?.enabled) return undefined;
7851
- if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
7852
- const trimmed = promptText.trimEnd();
7853
- if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
8046
+ // First-message-only gates are skipped post-compaction (`promptText === undefined`),
8047
+ // where there is no fresh user message to suppress the reminder for.
8048
+ if (promptText !== undefined) {
8049
+ if (this.agent.state.messages.some(m => m.role === "user")) return undefined;
8050
+ const trimmed = promptText.trimEnd();
8051
+ if (trimmed.endsWith("?") || trimmed.endsWith("!")) return undefined;
8052
+ }
7854
8053
  if (!this.getActiveToolNames().includes("task")) return undefined;
7855
8054
  return {
7856
8055
  role: "custom",
@@ -7861,6 +8060,24 @@ export class AgentSession {
7861
8060
  timestamp: Date.now(),
7862
8061
  };
7863
8062
  }
8063
+
8064
+ /**
8065
+ * Build the eager task/todo reminders to re-inject on the auto-continuation turn that
8066
+ * follows a compaction. The first-message preludes are the oldest messages, so
8067
+ * compaction summarizes them away and the agent silently loses the delegate-via-tasks
8068
+ * and phased-todo guidance mid-work; this re-asserts them, reminder-only (the todo
8069
+ * builder drops its forced tool_choice when `promptText` is undefined). Each builder
8070
+ * still applies its own mode / agent-kind / plan-mode / tool-active / surviving-todo
8071
+ * gates, so an empty array means nothing currently warrants a nudge.
8072
+ */
8073
+ #buildPostCompactionEagerNudges(): AgentMessage[] {
8074
+ const nudges: AgentMessage[] = [];
8075
+ const todo = this.#createEagerTodoPrelude(undefined);
8076
+ if (todo) nudges.push(todo.message);
8077
+ const task = this.#createEagerTaskPrelude(undefined);
8078
+ if (task) nudges.push(task);
8079
+ return nudges;
8080
+ }
7864
8081
  /**
7865
8082
  * Check if agent stopped with incomplete todos and prompt to continue.
7866
8083
  */
@@ -9136,9 +9353,31 @@ export class AgentSession {
9136
9353
  // Auto-Retry
9137
9354
  // =========================================================================
9138
9355
 
9356
+ /**
9357
+ * Retry an empty, reason-less provider abort: a turn that ended `aborted`
9358
+ * with no content and the generic sentinel (bare `abort()`), but only while
9359
+ * the session is neither aborting nor tearing down. A user/lifecycle abort
9360
+ * (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a
9361
+ * session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` —
9362
+ * auto-generated-file guard or failed-patch preview) is deliberate and MUST
9363
+ * settle the turn instead: routing it through retry would orphan
9364
+ * `#retryPromise` on a continuation the guard skips (hanging the in-flight
9365
+ * `prompt()`) or silently undo the guard's intended abort.
9366
+ */
9367
+ #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
9368
+ return (
9369
+ message.stopReason === "aborted" &&
9370
+ message.content.length === 0 &&
9371
+ message.errorMessage === GENERIC_ABORT_SENTINEL &&
9372
+ !this.#abortInProgress &&
9373
+ !this.#isDisposed &&
9374
+ !this.#streamingEditAbortTriggered
9375
+ );
9376
+ }
9377
+
9139
9378
  /**
9140
9379
  * Check if an error is retryable (transient errors or usage limits).
9141
- * Context overflow errors are NOT retryable (handled by compaction instead).
9380
+ * Context overflow is NOT retryable (handled by compaction instead).
9142
9381
  * Usage-limit errors are retryable because the retry handler performs credential switching.
9143
9382
  */
9144
9383
  #isRetryableError(message: AssistantMessage): boolean {
@@ -9295,11 +9534,25 @@ export class AgentSession {
9295
9534
  const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9296
9535
  if (!parsedCurrent) return undefined;
9297
9536
  const currentBaseSelector = formatRetryFallbackBaseSelector(parsedCurrent);
9537
+ const currentPlainSelector = this.model
9538
+ ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
9539
+ : undefined;
9540
+ const currentPlainBaseSelector =
9541
+ currentPlainSelector && currentPlainSelector !== currentSelector
9542
+ ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
9543
+ : undefined;
9544
+
9545
+ for (const role of Object.keys(this.#getRetryFallbackChains())) {
9546
+ const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9547
+ if (primarySelector?.raw === currentSelector) return role;
9548
+ }
9298
9549
  for (const role of Object.keys(this.#getRetryFallbackChains())) {
9299
9550
  const primarySelector = this.#getRetryFallbackPrimarySelector(role);
9300
9551
  if (!primarySelector) continue;
9301
- if (primarySelector.raw === currentSelector) return role;
9302
- if (formatRetryFallbackBaseSelector(primarySelector) === currentBaseSelector) return role;
9552
+ if (currentPlainSelector && primarySelector.raw === currentPlainSelector) return role;
9553
+ const primaryBaseSelector = formatRetryFallbackBaseSelector(primarySelector);
9554
+ if (primaryBaseSelector === currentBaseSelector) return role;
9555
+ if (currentPlainBaseSelector && primaryBaseSelector === currentPlainBaseSelector) return role;
9303
9556
  }
9304
9557
  return undefined;
9305
9558
  }
@@ -9323,10 +9576,23 @@ export class AgentSession {
9323
9576
  if (chain.length <= 1) return [];
9324
9577
  const parsedCurrent = parseRetryFallbackSelector(currentSelector);
9325
9578
  const currentBaseSelector = parsedCurrent ? formatRetryFallbackBaseSelector(parsedCurrent) : undefined;
9326
- const exactIndex = chain.findIndex(selector => selector.raw === currentSelector);
9579
+ const currentPlainSelector =
9580
+ this.model && parsedCurrent
9581
+ ? formatModelSelectorValue(formatModelString(this.model), parsedCurrent.thinkingLevel)
9582
+ : undefined;
9583
+ const currentPlainBaseSelector =
9584
+ parsedCurrent && currentPlainSelector && currentPlainSelector !== currentSelector
9585
+ ? formatRetryFallbackBaseSelector(parseRetryFallbackSelector(currentPlainSelector) ?? parsedCurrent)
9586
+ : undefined;
9587
+ const exactIndex = chain.findIndex(
9588
+ selector => selector.raw === currentSelector || selector.raw === currentPlainSelector,
9589
+ );
9327
9590
  if (exactIndex >= 0) return chain.slice(exactIndex + 1);
9328
9591
  const baseIndex = currentBaseSelector
9329
- ? chain.findIndex(selector => formatRetryFallbackBaseSelector(selector) === currentBaseSelector)
9592
+ ? chain.findIndex(selector => {
9593
+ const selectorBase = formatRetryFallbackBaseSelector(selector);
9594
+ return selectorBase === currentBaseSelector || selectorBase === currentPlainBaseSelector;
9595
+ })
9330
9596
  : -1;
9331
9597
  if (baseIndex >= 0) return chain.slice(baseIndex + 1);
9332
9598
  return chain.slice(1);
@@ -9338,7 +9604,8 @@ export class AgentSession {
9338
9604
  currentSelector: string,
9339
9605
  options?: { pinFallback?: boolean },
9340
9606
  ): Promise<void> {
9341
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9607
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
9608
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
9342
9609
  if (!candidate) {
9343
9610
  throw new Error(`Retry fallback model not found: ${selector.raw}`);
9344
9611
  }
@@ -9351,10 +9618,10 @@ export class AgentSession {
9351
9618
  // `auto` instead of collapsing it to the level it resolved to this turn.
9352
9619
  const currentThinkingLevel = this.configuredThinkingLevel();
9353
9620
  const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
9354
-
9621
+ const candidateSelector = formatModelStringWithRouting(candidate);
9355
9622
  this.#setModelWithProviderSessionReset(candidate);
9356
- this.sessionManager.appendModelChange(`${candidate.provider}/${candidate.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
9357
- this.settings.getStorage()?.recordModelUsage(`${candidate.provider}/${candidate.id}`);
9623
+ this.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
9624
+ this.settings.getStorage()?.recordModelUsage(candidateSelector);
9358
9625
  this.setThinkingLevel(nextThinkingLevel);
9359
9626
  if (!this.#activeRetryFallback) {
9360
9627
  this.#activeRetryFallback = {
@@ -9382,7 +9649,8 @@ export class AgentSession {
9382
9649
 
9383
9650
  for (const selector of this.#findRetryFallbackCandidates(role, currentSelector)) {
9384
9651
  if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
9385
- const candidate = this.#modelRegistry.find(selector.provider, selector.id);
9652
+ const resolved = resolveModelOverride([selector.raw], this.#modelRegistry, this.settings);
9653
+ const candidate = resolved.model ?? this.#modelRegistry.find(selector.provider, selector.id);
9386
9654
  if (!candidate) continue;
9387
9655
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
9388
9656
  if (!apiKey) continue;
@@ -9420,7 +9688,9 @@ export class AgentSession {
9420
9688
  }
9421
9689
  if (this.#isRetryFallbackSelectorSuppressed(originalSelector)) return;
9422
9690
 
9423
- const primaryModel = this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9691
+ const resolvedPrimary = resolveModelOverride([originalSelector.raw], this.#modelRegistry, this.settings);
9692
+ const primaryModel =
9693
+ resolvedPrimary.model ?? this.#modelRegistry.find(originalSelector.provider, originalSelector.id);
9424
9694
  if (!primaryModel) return;
9425
9695
  const apiKey = await this.#modelRegistry.getApiKey(primaryModel, this.sessionId);
9426
9696
  if (!apiKey) return;
@@ -9428,9 +9698,10 @@ export class AgentSession {
9428
9698
  const currentThinkingLevel = this.configuredThinkingLevel();
9429
9699
  const thinkingToApply =
9430
9700
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
9701
+ const primarySelector = formatModelStringWithRouting(primaryModel);
9431
9702
  this.#setModelWithProviderSessionReset(primaryModel);
9432
- this.sessionManager.appendModelChange(`${primaryModel.provider}/${primaryModel.id}`, EPHEMERAL_MODEL_CHANGE_ROLE);
9433
- this.settings.getStorage()?.recordModelUsage(`${primaryModel.provider}/${primaryModel.id}`);
9703
+ this.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
9704
+ this.settings.getStorage()?.recordModelUsage(primarySelector);
9434
9705
  this.setThinkingLevel(thinkingToApply);
9435
9706
  this.#clearActiveRetryFallback();
9436
9707
  }
@@ -9490,7 +9761,10 @@ export class AgentSession {
9490
9761
  * Handle retryable errors with exponential backoff.
9491
9762
  * @returns true if retry was initiated, false if max retries exceeded or disabled
9492
9763
  */
9493
- async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
9764
+ async #handleRetryableError(
9765
+ message: AssistantMessage,
9766
+ options?: { allowModelFallback?: boolean },
9767
+ ): Promise<boolean> {
9494
9768
  const retrySettings = this.settings.getGroup("retry");
9495
9769
  if (!retrySettings.enabled) return false;
9496
9770
  const classifierRefusal = this.#isClassifierRefusal(message);
@@ -9578,9 +9852,10 @@ export class AgentSession {
9578
9852
  }
9579
9853
  }
9580
9854
 
9855
+ const allowModelFallback = options?.allowModelFallback !== false;
9581
9856
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
9582
9857
  if (!staleOpenAIResponsesReplayError && !switchedCredential && currentSelector) {
9583
- if (retrySettings.modelFallback) {
9858
+ if (allowModelFallback && retrySettings.modelFallback) {
9584
9859
  if (!classifierRefusal) {
9585
9860
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
9586
9861
  }
@@ -10089,11 +10364,8 @@ export class AgentSession {
10089
10364
  if (autoReply) void this.#runIrcAutoReply(msg);
10090
10365
  return "injected";
10091
10366
  }
10092
- // Idle: same wake primitive the yield queue uses for async-result
10093
- // delivery — prompt the agent directly so a real turn runs.
10094
- this.agent.prompt(record).catch(error => {
10095
- logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) });
10096
- });
10367
+ // Idle: wake a real turn so the recipient responds (shared with the stranded-aside resume).
10368
+ this.#wakeForIrc([record]);
10097
10369
  return "woken";
10098
10370
  }
10099
10371
 
@@ -11278,13 +11550,12 @@ export class AgentSession {
11278
11550
  }
11279
11551
 
11280
11552
  /**
11281
- * Format the entire session as plain text for clipboard export.
11282
- * Includes user messages, assistant text, thinking blocks, tool calls, and tool results.
11553
+ * Format the entire session as plain text for clipboard export: system
11554
+ * prompt, model/thinking config, tool inventory, and the full transcript
11555
+ * rendered with markdown role headings (`## User`, `## Assistant`,
11556
+ * `### Tool Call`/`### Tool Result`).
11283
11557
  */
11284
- formatSessionAsText(options?: { compact?: boolean }): string {
11285
- if (options?.compact) {
11286
- return formatSessionHistoryMarkdown(this.messages);
11287
- }
11558
+ formatSessionAsText(): string {
11288
11559
  return formatSessionDumpText({
11289
11560
  messages: this.messages,
11290
11561
  systemPrompt: this.agent.state.systemPrompt,