@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13

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 (134) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
  3. package/dist/cli.js +3069 -3047
  4. package/dist/types/advisor/delta-split.d.ts +24 -0
  5. package/dist/types/advisor/runtime.d.ts +2 -2
  6. package/dist/types/async/job-manager.d.ts +8 -1
  7. package/dist/types/cli/update-cli.d.ts +53 -1
  8. package/dist/types/config/keybindings.d.ts +10 -0
  9. package/dist/types/config/model-resolver.d.ts +15 -2
  10. package/dist/types/config/settings-schema.d.ts +4 -0
  11. package/dist/types/discovery/agents-md.d.ts +10 -1
  12. package/dist/types/eval/runner-cache.d.ts +12 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +12 -3
  14. package/dist/types/extensibility/extensions/types.d.ts +15 -4
  15. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
  16. package/dist/types/lib/xai-http.d.ts +0 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +8 -5
  18. package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/types.d.ts +4 -0
  20. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
  21. package/dist/types/modes/interactive-mode.d.ts +17 -8
  22. package/dist/types/modes/types.d.ts +7 -10
  23. package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
  24. package/dist/types/session/agent-session-types.d.ts +2 -0
  25. package/dist/types/session/agent-session.d.ts +22 -3
  26. package/dist/types/session/messages.d.ts +20 -0
  27. package/dist/types/session/retry-fallback-chains.d.ts +13 -0
  28. package/dist/types/session/session-advisors.d.ts +1 -1
  29. package/dist/types/session/session-history-format.d.ts +10 -0
  30. package/dist/types/session/session-maintenance.d.ts +1 -1
  31. package/dist/types/session/session-tools.d.ts +24 -5
  32. package/dist/types/session/turn-recovery.d.ts +34 -5
  33. package/dist/types/slash-commands/types.d.ts +5 -1
  34. package/dist/types/task/executor.d.ts +1 -1
  35. package/dist/types/tools/approval.d.ts +7 -0
  36. package/dist/types/tools/todo.d.ts +14 -15
  37. package/dist/types/tools/write.d.ts +2 -2
  38. package/dist/types/utils/local-date.d.ts +2 -0
  39. package/dist/types/vibe/runtime.d.ts +1 -1
  40. package/dist/types/web/parallel.d.ts +1 -0
  41. package/dist/types/web/search/providers/brave.d.ts +8 -3
  42. package/dist/types/web/search/providers/codex.d.ts +6 -0
  43. package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
  44. package/dist/types/web/search/providers/jina.d.ts +3 -3
  45. package/dist/types/web/search/providers/parallel.d.ts +1 -0
  46. package/dist/types/web/search/providers/perplexity.d.ts +4 -0
  47. package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
  48. package/package.json +13 -13
  49. package/src/advisor/delta-split.ts +98 -0
  50. package/src/advisor/runtime.ts +321 -69
  51. package/src/async/job-manager.ts +14 -3
  52. package/src/cli/plugin-cli.ts +30 -2
  53. package/src/cli/update-cli.ts +259 -24
  54. package/src/config/keybindings.ts +52 -9
  55. package/src/config/model-resolver.ts +19 -3
  56. package/src/config/settings-schema.ts +5 -0
  57. package/src/cursor.ts +10 -5
  58. package/src/discovery/agents-md.ts +61 -23
  59. package/src/eval/jl/kernel.ts +2 -20
  60. package/src/eval/py/kernel.ts +2 -20
  61. package/src/eval/rb/kernel.ts +2 -20
  62. package/src/eval/runner-cache.ts +41 -0
  63. package/src/exec/non-interactive-env.ts +14 -3
  64. package/src/extensibility/extensions/loader.ts +5 -2
  65. package/src/extensibility/extensions/runner.ts +184 -66
  66. package/src/extensibility/extensions/types.ts +26 -2
  67. package/src/extensibility/extensions/wrapper.ts +13 -7
  68. package/src/extensibility/plugins/marketplace/manager.ts +6 -2
  69. package/src/hindsight/client.ts +1 -1
  70. package/src/lib/xai-http.ts +0 -4
  71. package/src/lsp/client.ts +2 -0
  72. package/src/lsp/servers.ts +1 -1
  73. package/src/mcp/tool-bridge.ts +15 -6
  74. package/src/modes/components/agent-hub-renderer.ts +9 -3
  75. package/src/modes/components/agent-hub.ts +2 -1
  76. package/src/modes/components/status-line/component.ts +58 -6
  77. package/src/modes/components/status-line/segments.ts +12 -1
  78. package/src/modes/components/status-line/types.ts +1 -0
  79. package/src/modes/components/user-message.ts +20 -5
  80. package/src/modes/controllers/event-controller.ts +48 -0
  81. package/src/modes/controllers/extension-ui-controller.ts +14 -7
  82. package/src/modes/controllers/input-controller.ts +25 -6
  83. package/src/modes/interactive-mode.ts +315 -129
  84. package/src/modes/rpc/rpc-frame.ts +13 -5
  85. package/src/modes/theme/tui-adapters.ts +4 -5
  86. package/src/modes/types.ts +13 -7
  87. package/src/modes/utils/hotkeys-markdown.ts +10 -6
  88. package/src/prompts/system/system-prompt.md +1 -1
  89. package/src/registry/persisted-agents.ts +43 -8
  90. package/src/sdk.ts +139 -8
  91. package/src/session/agent-session-types.ts +2 -0
  92. package/src/session/agent-session.ts +66 -10
  93. package/src/session/messages.ts +98 -28
  94. package/src/session/retry-fallback-chains.ts +14 -0
  95. package/src/session/session-advisors.ts +32 -15
  96. package/src/session/session-history-format.ts +15 -1
  97. package/src/session/session-maintenance.ts +8 -8
  98. package/src/session/session-manager.ts +6 -2
  99. package/src/session/session-tools.ts +321 -184
  100. package/src/session/turn-recovery.ts +225 -47
  101. package/src/slash-commands/builtin-modes.ts +41 -12
  102. package/src/slash-commands/types.ts +5 -1
  103. package/src/task/executor.ts +92 -45
  104. package/src/task/structured-subagent.ts +5 -5
  105. package/src/tools/approval.ts +44 -10
  106. package/src/tools/fetch.ts +21 -2
  107. package/src/tools/image-gen.ts +6 -8
  108. package/src/tools/todo.ts +70 -26
  109. package/src/tools/tts.ts +3 -2
  110. package/src/tools/write.ts +7 -3
  111. package/src/utils/local-date.ts +13 -0
  112. package/src/utils/tools-manager.ts +2 -2
  113. package/src/vibe/runtime.ts +22 -14
  114. package/src/web/kagi.ts +91 -34
  115. package/src/web/parallel.ts +11 -2
  116. package/src/web/scrapers/crates-io.ts +2 -2
  117. package/src/web/scrapers/discogs.ts +2 -2
  118. package/src/web/scrapers/docs-rs.ts +2 -2
  119. package/src/web/scrapers/github.ts +2 -2
  120. package/src/web/scrapers/musicbrainz.ts +1 -2
  121. package/src/web/scrapers/pubmed.ts +2 -2
  122. package/src/web/scrapers/sec-edgar.ts +2 -2
  123. package/src/web/search/providers/brave.ts +121 -46
  124. package/src/web/search/providers/codex.ts +88 -12
  125. package/src/web/search/providers/exa.ts +45 -10
  126. package/src/web/search/providers/firecrawl.ts +53 -11
  127. package/src/web/search/providers/gemini.ts +139 -27
  128. package/src/web/search/providers/jina.ts +48 -25
  129. package/src/web/search/providers/parallel.ts +23 -9
  130. package/src/web/search/providers/perplexity.ts +24 -7
  131. package/src/web/search/providers/searxng.ts +77 -1
  132. package/src/web/search/providers/tavily.ts +23 -22
  133. package/src/web/search/providers/tinyfish.ts +44 -10
  134. package/src/web/search/providers/xai.ts +85 -14
@@ -44,7 +44,7 @@ import type {
44
44
  UsageFallbackConfirmation,
45
45
  UsageFallbackConfirmer,
46
46
  } from "./agent-session-types";
47
- import { isEmptyErrorTurn } from "./messages";
47
+ import { assistantTurnProducedOutput, isEmptyAssistantStop, isEmptyErrorTurn } from "./messages";
48
48
  import {
49
49
  type ActiveRetryFallbackState,
50
50
  calculateRetryBackoffDelayMs,
@@ -58,6 +58,7 @@ import {
58
58
  type RetryFallbackRevertPolicy,
59
59
  type RetryFallbackSelector,
60
60
  resolveRetryFallbackChainKey,
61
+ type ServingModel,
61
62
  validateRetryFallbackChains,
62
63
  } from "./retry-fallback-chains";
63
64
  import { getLatestCompactionEntry } from "./session-context";
@@ -189,6 +190,33 @@ export class TurnRecovery {
189
190
  #emptyStopRetryCount = 0;
190
191
  #unexpectedStopRetryCount = 0;
191
192
  #acceptTerminalEmptyStopForPrompt = false;
193
+ // Three fields sit near the word "serve" and are deliberately distinct:
194
+ // `#activeRetryFallback.served` gates the one-shot `retry_fallback_succeeded`
195
+ // event for the current arm, `#fallbackRouted` says how the CURRENT model was
196
+ // reached, and `#lastServed` is the session's attribution. A fallback flipping
197
+ // to served does not by itself move attribution — only a settled turn does.
198
+ /**
199
+ * Attribution of the newest turn that produced output, tagged with the
200
+ * session it belongs to. Anchoring rather than resetting follows
201
+ * `#ensurePersistedMessageKeys`: every real switch mints a new session id, so
202
+ * stale attribution drops itself and no mutation call site has to remember to
203
+ * clear it. The id — not the file — is the anchor because an unpersisted
204
+ * session has no file, and comparing two `undefined`s would never invalidate.
205
+ */
206
+ #lastServed: { attribution: ServingModel; sessionId: string } | undefined;
207
+ /**
208
+ * Session whose current model was reached by fallback routing rather than by
209
+ * the configured primary, or `undefined` when it was not. Tracked separately
210
+ * from {@link #activeRetryFallback} because the Fireworks Fast degrade swaps
211
+ * models without arming a chain, and anchored like {@link #lastServed}:
212
+ * switching transcripts in place must not describe a fresh session's model
213
+ * with how the previous one was routed.
214
+ */
215
+ #fallbackRoutedFor: string | undefined;
216
+ /** Memoized bootstrap answer, for the window before anything has served. */
217
+ #bootstrapCache:
218
+ | { model: Model; level: ThinkingLevel | undefined; routed: boolean; value: ServingModel }
219
+ | undefined;
192
220
 
193
221
  constructor(host: TurnRecoveryHost, options: TurnRecoveryOptions = {}) {
194
222
  this.#host = host;
@@ -198,6 +226,7 @@ export class TurnRecovery {
198
226
  lastAppliedFallbackThinkingLevel: host.configuredThinkingLevel(),
199
227
  pinned: options.initialRetryFallback.pinned ?? false,
200
228
  };
229
+ this.#markFallbackRouted();
201
230
  }
202
231
  this.#validateRetryFallbackChains();
203
232
  }
@@ -212,12 +241,70 @@ export class TurnRecovery {
212
241
  return this.#retryPromise;
213
242
  }
214
243
 
215
- /** Resolved selector while fallback routing owns the current model. */
216
- get retryFallbackModel(): string | undefined {
244
+ /** Whether the CURRENT session's model was reached by fallback routing. */
245
+ get #fallbackRouted(): boolean {
246
+ return (
247
+ this.#fallbackRoutedFor !== undefined && this.#fallbackRoutedFor === this.#host.sessionManager.getSessionId()
248
+ );
249
+ }
250
+
251
+ #markFallbackRouted(): void {
252
+ this.#fallbackRoutedFor = this.#host.sessionManager.getSessionId();
253
+ }
254
+
255
+ /**
256
+ * Model this session's produced work is attributed to.
257
+ *
258
+ * A model switch is a routing decision, not evidence the target can produce
259
+ * anything: a candidate that errors on its first request produced none of the
260
+ * turns already in this session. So attribution only ever names a model that
261
+ * has settled a turn here, and a switch — into a fallback, back to a restored
262
+ * primary, or anywhere else — moves it only once the new model answers.
263
+ *
264
+ * Before anything has served there is no earlier work to miscredit, so the
265
+ * configured model is both the only available answer and a safe one.
266
+ */
267
+ get servingModel(): ServingModel | undefined {
268
+ const served = this.#lastServed;
269
+ if (served && served.sessionId === this.#host.sessionManager.getSessionId()) return served.attribution;
217
270
  const model = this.#host.model();
218
- return this.#activeRetryFallback && model
219
- ? formatRetryFallbackSelector(model, this.#host.thinkingLevel())
220
- : undefined;
271
+ if (!model) return undefined;
272
+ // Polled per streaming event and per render, so the pre-first-turn window
273
+ // must not format a selector on every call.
274
+ const level = this.#host.thinkingLevel();
275
+ const cached = this.#bootstrapCache;
276
+ if (cached && cached.model === model && cached.level === level && cached.routed === this.#fallbackRouted) {
277
+ return cached.value;
278
+ }
279
+ const value: ServingModel = {
280
+ selector: formatRetryFallbackSelector(model, level),
281
+ isFallback: this.#fallbackRouted,
282
+ };
283
+ this.#bootstrapCache = { model, level, routed: this.#fallbackRouted, value };
284
+ return value;
285
+ }
286
+
287
+ /**
288
+ * Carries attribution onto a new session id that continues this conversation.
289
+ *
290
+ * The session-id anchor assumes a new id means an unrelated transcript, which
291
+ * holds for `/new` and for resuming something else. A fork breaks that
292
+ * assumption on purpose: it clones the transcript and keeps running the same
293
+ * recovery state under a fresh id. Dropping attribution there would bootstrap
294
+ * an unproven fallback as the primary and re-credit it with the work the
295
+ * previous model did — the very bug the anchor exists to prevent.
296
+ *
297
+ * Only state belonging to `previousSessionId` moves, so an id left behind by
298
+ * an earlier switch stays expired.
299
+ */
300
+ reanchorServedAttribution(previousSessionId: string): void {
301
+ const sessionId = this.#host.sessionManager.getSessionId();
302
+ if (this.#lastServed?.sessionId === previousSessionId) {
303
+ this.#lastServed = { ...this.#lastServed, sessionId };
304
+ }
305
+ if (this.#fallbackRoutedFor === previousSessionId) {
306
+ this.#fallbackRoutedFor = sessionId;
307
+ }
221
308
  }
222
309
 
223
310
  /** Resets per-prompt recovery counters and terminal-stop acceptance. */
@@ -232,24 +319,41 @@ export class TurnRecovery {
232
319
  this.#acceptTerminalEmptyStopForPrompt = accept;
233
320
  }
234
321
 
235
- /** Closes a successful retry saga and annotates recovered persisted errors. */
322
+ /**
323
+ * Records which model produced this turn, marks an active fallback as having
324
+ * served, then closes a successful retry saga and annotates recovered
325
+ * persisted errors.
326
+ */
236
327
  async onAssistantSettledSuccessfully(message: AssistantMessage): Promise<void> {
237
- if (
238
- message.stopReason === "error" ||
239
- message.stopReason === "aborted" ||
240
- this.#isEmptyAssistantStop(message) ||
241
- this.#retryAttempt === 0
242
- ) {
328
+ if (!assistantTurnProducedOutput(message)) {
243
329
  return;
244
330
  }
245
331
  const model = this.#host.model();
246
- if (this.#activeRetryFallback && model) {
332
+ if (model) {
333
+ this.#lastServed = {
334
+ attribution: {
335
+ selector: formatRetryFallbackSelector(model, this.#host.thinkingLevel()),
336
+ isFallback: this.#fallbackRouted,
337
+ },
338
+ sessionId: this.#host.sessionManager.getSessionId(),
339
+ };
340
+ }
341
+ // Independent of the retry saga below: a usage-aware fallback is applied
342
+ // before a request without ever incrementing `#retryAttempt`, and it still
343
+ // owns every turn it serves. Gating this on the saga left such a fallback
344
+ // permanently unproven, hiding it from observers for the whole session.
345
+ if (this.#activeRetryFallback && !this.#activeRetryFallback.served && model) {
346
+ this.#activeRetryFallback.served = true;
247
347
  await this.#host.emitSessionEvent({
248
348
  type: "retry_fallback_succeeded",
249
- model: formatRetryFallbackSelector(model, this.#host.thinkingLevel()),
349
+ model:
350
+ this.#lastServed?.attribution.selector ?? formatRetryFallbackSelector(model, this.#host.thinkingLevel()),
250
351
  role: this.#activeRetryFallback.role,
251
352
  });
252
353
  }
354
+ if (this.#retryAttempt === 0) {
355
+ return;
356
+ }
253
357
  const retryErrors = await this.#markPendingRetryErrors({
254
358
  status: "recovered",
255
359
  supersedingMessage: message,
@@ -538,7 +642,7 @@ export class TurnRecovery {
538
642
  }
539
643
 
540
644
  async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
541
- if (!this.#isEmptyAssistantStop(assistantMessage)) {
645
+ if (!isEmptyAssistantStop(assistantMessage)) {
542
646
  this.#emptyStopRetryCount = 0;
543
647
  return false;
544
648
  }
@@ -587,31 +691,6 @@ export class TurnRecovery {
587
691
  return true;
588
692
  }
589
693
 
590
- #isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
591
- switch (assistantMessage.stopReason) {
592
- case "stop":
593
- // Unsigned thinking alone is not actionable, but a signature is
594
- // provider-authenticated content and makes the stop terminal.
595
- for (const content of assistantMessage.content) {
596
- if (content.type === "toolCall") return false;
597
- if (content.type === "text" && hasNonWhitespace(content.text)) return false;
598
- if (content.type === "thinking" && hasNonWhitespace(content.thinkingSignature ?? "")) return false;
599
- }
600
- return true;
601
- case "toolUse":
602
- // An orphaned toolUse stop (no tool_use block) corrupts Anthropic history:
603
- // a later tool_result has nothing to anchor to. Thinking alone cannot anchor
604
- // a tool_result, so it does not rescue a toolUse stop here.
605
- for (const content of assistantMessage.content) {
606
- if (content.type === "toolCall") return false;
607
- if (content.type === "text" && hasNonWhitespace(content.text)) return false;
608
- }
609
- return true;
610
- default:
611
- return false;
612
- }
613
- }
614
-
615
694
  #emptyStopRetryReminder(): string {
616
695
  return prompt.render(emptyStopRetryTemplate, {
617
696
  retryCount: this.#emptyStopRetryCount,
@@ -939,12 +1018,83 @@ export class TurnRecovery {
939
1018
 
940
1019
  // Credential rotation and classifier fallbacks are safe only before
941
1020
  // committed text, images, tool calls, or server tools. Thinking-only
942
- // output remains replay-safe.
943
- if (this.#hasReplayUnsafeOutput(message)) return false;
1021
+ // output remains replay-safe. The one exception is a refusal whose ONLY
1022
+ // replay-unsafe output is tool calls the agent loop proved never ran
1023
+ // (`#refusalReplaySafe`): nothing reached the user and no side effect
1024
+ // happened, so discarding the turn duplicates nothing and the fallback
1025
+ // chain gets its chance.
1026
+ if (this.#hasReplayUnsafeOutput(message) && !this.#refusalReplaySafe(message)) return false;
944
1027
  if (AIError.is(id, AIError.Flag.AccountPolicy) || this.isClassifierRefusal(message)) return true;
945
1028
  return AIError.retriable(id);
946
1029
  }
947
1030
 
1031
+ /**
1032
+ * True when a classifier refusal is replay-safe *despite* having emitted tool
1033
+ * calls, because every emitted call provably never executed.
1034
+ *
1035
+ * Anthropic's request classifier can fire after the model has already streamed
1036
+ * a tool call, which used to strand the turn: `#hasReplayUnsafeOutput` sees the
1037
+ * `toolCall` block and vetoes retry one line before the refusal could reach the
1038
+ * fallback-chain consult, so a refusal that a different model family would very
1039
+ * likely have served just ended the turn.
1040
+ *
1041
+ * That veto exists to protect against duplicating work or visible output. Neither
1042
+ * risk is present here: the agent loop pairs each emitted-but-unrun call with a
1043
+ * synthetic `executed: false` result (see {@link isSyntheticToolResultMessage}),
1044
+ * which is a positive record that `tool.execute()` never ran. So the veto is
1045
+ * lifted only when ALL of the following hold, and any uncertainty (assistant
1046
+ * message missing from state, a call with no result, a non-synthetic result, an
1047
+ * `executed` that is not exactly `false`) keeps it in place:
1048
+ *
1049
+ * - the stop is a classifier refusal/sensitivity stop;
1050
+ * - the only replay-unsafe blocks are tool calls — an `image`, an
1051
+ * `anthropicServerTool`, or committed non-whitespace text has already rendered
1052
+ * or has side effects, so replaying would duplicate it;
1053
+ * - at least one tool call was emitted (otherwise the plain refusal path already
1054
+ * handles it);
1055
+ * - every emitted call id has a result after the assistant message in state, and
1056
+ * every such result is synthetic with `executed === false`.
1057
+ */
1058
+ #refusalReplaySafe(message: AssistantMessage): boolean {
1059
+ if (!this.isClassifierRefusal(message)) return false;
1060
+
1061
+ const emittedToolCallIds = new Set<string>();
1062
+ for (const block of message.content) {
1063
+ if (block.type === "toolCall") {
1064
+ emittedToolCallIds.add(block.id);
1065
+ continue;
1066
+ }
1067
+ if (block.type === "image" || block.type === "anthropicServerTool") return false;
1068
+ if (block.type === "text" && this.#host.textOutputCommitted() && hasNonWhitespace(block.text)) return false;
1069
+ }
1070
+ if (emittedToolCallIds.size === 0) return false;
1071
+
1072
+ // The refused assistant message is NOT the tail of state: the agent loop
1073
+ // appends the synthetic results after it before the turn ends, so locate it
1074
+ // by walking backwards exactly as `classifyResolvedInterruptedToolTurn` does.
1075
+ const messages = this.#host.agent.state.messages;
1076
+ let assistantIndex = -1;
1077
+ for (let i = messages.length - 1; i >= 0; i--) {
1078
+ const candidate = messages[i];
1079
+ if (candidate.role === "assistant" && this.#isSameAssistantMessage(candidate, message)) {
1080
+ assistantIndex = i;
1081
+ break;
1082
+ }
1083
+ }
1084
+ if (assistantIndex < 0) return false;
1085
+
1086
+ const unexecutedToolCallIds = new Set<string>();
1087
+ for (let i = assistantIndex + 1; i < messages.length; i++) {
1088
+ const candidate = messages[i];
1089
+ if (candidate.role !== "toolResult" || !emittedToolCallIds.has(candidate.toolCallId)) continue;
1090
+ // Every result for an emitted call is inspected, not just the first: a
1091
+ // real result anywhere in the tail means the tool ran.
1092
+ if (!isSyntheticToolResultMessage(candidate) || candidate.details?.executed !== false) return false;
1093
+ unexecutedToolCallIds.add(candidate.toolCallId);
1094
+ }
1095
+ return unexecutedToolCallIds.size === emittedToolCallIds.size;
1096
+ }
1097
+
948
1098
  /**
949
1099
  * Classify a reasonless abort or stream stall whose emitted tool calls all
950
1100
  * have results. The failed assistant/tool-result pair stays in context so
@@ -1069,9 +1219,10 @@ export class TurnRecovery {
1069
1219
  return getRetryFallbackRevertPolicy(this.#host.settings);
1070
1220
  }
1071
1221
 
1072
- /** Clears fallback ownership after an explicit model change. */
1222
+ /** Clears fallback ownership after an explicit model change or a restore. */
1073
1223
  clearActiveRetryFallback(): void {
1074
1224
  this.#activeRetryFallback = undefined;
1225
+ this.#fallbackRoutedFor = undefined;
1075
1226
  }
1076
1227
 
1077
1228
  /** Checks whether a fallback selector remains in cooldown. */
@@ -1312,14 +1463,29 @@ export class TurnRecovery {
1312
1463
  : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling());
1313
1464
  const candidateSelector = formatModelStringWithRouting(candidate);
1314
1465
  const previousModel = this.#host.model();
1466
+ // Mark routing BEFORE the swap: `setModelWithProviderSessionReset` moves the
1467
+ // model and fans `model_changed` out to subscribers synchronously, and a
1468
+ // listener reading attribution in that window must already see the incoming
1469
+ // candidate as fallback-routed. Attribution itself is safe regardless — it
1470
+ // names the last model that served, which this swap has not changed.
1471
+ const routedBeforeSwap = this.#fallbackRoutedFor;
1472
+ const servedBeforeSwap = this.#activeRetryFallback?.served;
1473
+ this.#markFallbackRouted();
1474
+ if (this.#activeRetryFallback) this.#activeRetryFallback.served = false;
1315
1475
  await this.#host.setModelWithProviderSessionReset(candidate);
1316
1476
  if (options?.signal?.aborted) {
1477
+ this.#fallbackRoutedFor = routedBeforeSwap;
1478
+ if (this.#activeRetryFallback) this.#activeRetryFallback.served = servedBeforeSwap;
1317
1479
  if (previousModel && this.#host.model() === candidate) {
1318
1480
  await this.#host.setModelWithProviderSessionReset(previousModel);
1319
1481
  }
1320
1482
  return false;
1321
1483
  }
1322
- if (this.#host.model() !== candidate) return false;
1484
+ if (this.#host.model() !== candidate) {
1485
+ this.#fallbackRoutedFor = routedBeforeSwap;
1486
+ if (this.#activeRetryFallback) this.#activeRetryFallback.served = servedBeforeSwap;
1487
+ return false;
1488
+ }
1323
1489
  this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true);
1324
1490
  this.#host.settings.getStorage()?.recordModelUsage(candidateSelector);
1325
1491
  this.#host.setThinkingLevel(nextThinkingLevel);
@@ -1439,6 +1605,9 @@ export class TurnRecovery {
1439
1605
  const apiKey = await this.#host.modelRegistry.getApiKey(baseModel, this.#host.sessionId());
1440
1606
  if (!apiKey) return false;
1441
1607
  const baseSelector = formatModelStringWithRouting(baseModel);
1608
+ // A capability degrade is fallback routing too, even though it arms no
1609
+ // chain: the base model must not be reported as the configured primary.
1610
+ this.#markFallbackRouted();
1442
1611
  await this.#host.setModelWithProviderSessionReset(baseModel);
1443
1612
  this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true);
1444
1613
  this.#host.settings.getStorage()?.recordModelUsage(baseSelector);
@@ -1463,7 +1632,12 @@ export class TurnRecovery {
1463
1632
  } = this.#activeRetryFallback;
1464
1633
  const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#host.modelRegistry);
1465
1634
  if (!originalSelector) {
1466
- this.clearActiveRetryFallback();
1635
+ // Defensive: the stored selector is always produced by
1636
+ // `formatRetryFallbackSelector`, so it should never fail to parse. If it
1637
+ // somehow does, nothing is restored and the session keeps running on the
1638
+ // fallback — so drop the chain record but NOT `#fallbackRouted`, whose
1639
+ // clearing would report the fallback's remaining turns as the primary.
1640
+ this.#activeRetryFallback = undefined;
1467
1641
  return false;
1468
1642
  }
1469
1643
 
@@ -1493,11 +1667,15 @@ export class TurnRecovery {
1493
1667
  const thinkingToApply =
1494
1668
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
1495
1669
  const primarySelector = formatModelStringWithRouting(primaryModel);
1670
+ // Clear before the swap: `setModelWithProviderSessionReset` and
1671
+ // `setThinkingLevel` both notify subscribers, and an observer reading
1672
+ // attribution in that window would see the restored primary still tagged
1673
+ // as fallback-served.
1674
+ this.clearActiveRetryFallback();
1496
1675
  await this.#host.setModelWithProviderSessionReset(primaryModel);
1497
1676
  this.#host.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1498
1677
  this.#host.settings.getStorage()?.recordModelUsage(primarySelector);
1499
1678
  this.#host.setThinkingLevel(thinkingToApply);
1500
- this.clearActiveRetryFallback();
1501
1679
  return true;
1502
1680
  }
1503
1681
 
@@ -14,13 +14,41 @@ import { computerExposureMode } from "../tools/computer/exposure";
14
14
  import type { InspectImageMode } from "../utils/inspect-image-mode";
15
15
  import { commandConsumed, errorMessage, usage } from "./helpers/parse";
16
16
  import { handleSecurityCommand } from "./helpers/security";
17
- import type { SlashCommandSpec } from "./types";
17
+ import type { ParsedSlashCommand, SlashCommandSpec, TuiSlashCommandRuntime } from "./types";
18
18
 
19
19
  export function refreshStatusLine(ctx: InteractiveModeContext): void {
20
20
  ctx.statusLine.invalidate();
21
21
  ctx.ui.requestRender();
22
22
  }
23
23
 
24
+ async function runWithDetachedModeDraft(
25
+ command: ParsedSlashCommand,
26
+ runtime: TuiSlashCommandRuntime,
27
+ run: () => Promise<boolean>,
28
+ ): Promise<void> {
29
+ const { editor } = runtime.ctx;
30
+ if (!runtime.draftDetached) editor.clearDraft();
31
+ try {
32
+ const submitted = await run();
33
+ if (!submitted && ((runtime.input?.images?.length ?? 0) > 0 || (runtime.input?.imageLinks?.length ?? 0) > 0)) {
34
+ editor.pendingImages = [...(runtime.input?.images ?? []), ...editor.pendingImages];
35
+ editor.pendingImageLinks = [
36
+ ...(runtime.input?.imageLinks ?? runtime.input?.images?.map(() => undefined) ?? []),
37
+ ...editor.pendingImageLinks,
38
+ ];
39
+ editor.imageLinks = editor.pendingImageLinks.length > 0 ? editor.pendingImageLinks : undefined;
40
+ }
41
+ } catch (error) {
42
+ if (!editor.getText() && editor.pendingImages.length === 0) {
43
+ editor.setText(command.text);
44
+ editor.pendingImages = runtime.input?.images ? [...runtime.input.images] : [];
45
+ editor.pendingImageLinks = runtime.input?.imageLinks ? [...runtime.input.imageLinks] : [];
46
+ editor.imageLinks = editor.pendingImageLinks.length > 0 ? editor.pendingImageLinks : undefined;
47
+ }
48
+ runtime.ctx.showError(error instanceof Error ? error.message : String(error));
49
+ }
50
+ }
51
+
24
52
  /** `/fast status` label for the active model: "on" when its family is priority, else "off". */
25
53
  function formatFastModeStatus(session: AgentSession): string {
26
54
  return session.isFastModeEnabled() ? "on" : "off";
@@ -182,8 +210,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
182
210
  return "Plan: off";
183
211
  },
184
212
  handleTui: async (command, runtime) => {
185
- await runtime.ctx.handlePlanModeCommand(command.args || undefined);
186
- runtime.ctx.editor.setText("");
213
+ await runWithDetachedModeDraft(command, runtime, () =>
214
+ runtime.ctx.handlePlanModeCommand(command.args || undefined, runtime.input),
215
+ );
187
216
  },
188
217
  },
189
218
  {
@@ -208,8 +237,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
208
237
  return "Vibe: off";
209
238
  },
210
239
  handleTui: async (command, runtime) => {
211
- await runtime.ctx.handleVibeModeCommand(command.args || undefined);
212
- runtime.ctx.editor.setText("");
240
+ await runWithDetachedModeDraft(command, runtime, () =>
241
+ runtime.ctx.handleVibeModeCommand(command.args || undefined, runtime.input),
242
+ );
213
243
  },
214
244
  },
215
245
  {
@@ -232,8 +262,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
232
262
  return state ? `Goal: ${state.goal.status} (${shortDetail(state.goal.objective)})` : "Goal: off";
233
263
  },
234
264
  handleTui: async (command, runtime) => {
235
- await runtime.ctx.handleGoalModeCommand(command.args || undefined);
236
- runtime.ctx.editor.setText("");
265
+ await runWithDetachedModeDraft(command, runtime, () =>
266
+ runtime.ctx.handleGoalModeCommand(command.args || undefined, runtime.input),
267
+ );
237
268
  },
238
269
  },
239
270
  {
@@ -242,11 +273,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
242
273
  inlineHint: "[rough objective]",
243
274
  allowArgs: true,
244
275
  handleTui: async (command, runtime) => {
245
- // Clear the slash draft BEFORE the await: the handler blocks for the
246
- // whole kickoff turn, and a post-await clear would wipe an answer the
247
- // user starts typing while the first interview question streams.
248
- runtime.ctx.editor.setText("");
249
- await runtime.ctx.handleGuidedGoalCommand(command.args || undefined);
276
+ await runWithDetachedModeDraft(command, runtime, () =>
277
+ runtime.ctx.handleGuidedGoalCommand(command.args || undefined, runtime.input),
278
+ );
250
279
  },
251
280
  },
252
281
  {
@@ -1,5 +1,5 @@
1
1
  import type { Settings } from "../config/settings";
2
- import type { InteractiveModeContext } from "../modes/types";
2
+ import type { InteractiveModeContext, SubmittedUserInput } from "../modes/types";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import type { SessionManager } from "../session/session-manager";
5
5
 
@@ -83,6 +83,10 @@ export interface SlashCommandRuntime {
83
83
  */
84
84
  export interface TuiSlashCommandRuntime {
85
85
  ctx: InteractiveModeContext;
86
+ /** Post-extension-hook attachments belonging to the submitted slash draft. */
87
+ input?: Pick<SubmittedUserInput, "images" | "imageLinks">;
88
+ /** The editor snapshot was cleared before asynchronous input hooks ran. */
89
+ draftDetached?: boolean;
86
90
  }
87
91
 
88
92
  /** Unified slash-command spec consumed by both TUI and ACP dispatchers. */