@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.7

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 (244) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-5k4dq4g6.md} +54 -0
  3. package/dist/cli.js +3342 -3274
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +39 -2
  7. package/dist/types/cursor.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  9. package/dist/types/extensibility/shared-events.d.ts +18 -1
  10. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  11. package/dist/types/internal-urls/parse.d.ts +12 -0
  12. package/dist/types/internal-urls/router.d.ts +6 -0
  13. package/dist/types/internal-urls/types.d.ts +6 -0
  14. package/dist/types/lsp/config.d.ts +1 -0
  15. package/dist/types/lsp/types.d.ts +2 -0
  16. package/dist/types/mcp/manager.d.ts +5 -0
  17. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  18. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  20. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  21. package/dist/types/sdk.d.ts +3 -1
  22. package/dist/types/session/agent-session-types.d.ts +8 -6
  23. package/dist/types/session/agent-session.d.ts +28 -1
  24. package/dist/types/session/model-controls.d.ts +4 -1
  25. package/dist/types/session/session-advisors.d.ts +7 -1
  26. package/dist/types/session/session-tools.d.ts +44 -6
  27. package/dist/types/session/streaming-output.d.ts +7 -2
  28. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  29. package/dist/types/session/turn-recovery.d.ts +5 -3
  30. package/dist/types/task/index.d.ts +1 -1
  31. package/dist/types/task/types.d.ts +3 -11
  32. package/dist/types/thinking.d.ts +21 -2
  33. package/dist/types/tools/index.d.ts +8 -3
  34. package/dist/types/tools/output-meta.d.ts +5 -0
  35. package/dist/types/tools/read.d.ts +9 -1
  36. package/dist/types/tools/xdev.d.ts +53 -67
  37. package/dist/types/utils/cpuprofile.d.ts +51 -0
  38. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  39. package/dist/types/utils/profile-tree.d.ts +47 -0
  40. package/dist/types/utils/sample-profile.d.ts +67 -0
  41. package/dist/types/utils/title-generator.d.ts +17 -16
  42. package/package.json +12 -12
  43. package/src/capability/index.ts +43 -12
  44. package/src/capability/mcp.ts +21 -0
  45. package/src/capability/types.ts +20 -1
  46. package/src/cli/read-cli.ts +44 -2
  47. package/src/config/settings-schema.ts +42 -2
  48. package/src/config/settings.ts +35 -0
  49. package/src/cursor.ts +4 -3
  50. package/src/discovery/builtin-rules/index.ts +2 -0
  51. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  52. package/src/eval/py/runner.py +16 -2
  53. package/src/extensibility/extensions/runner.ts +117 -5
  54. package/src/extensibility/extensions/types.ts +0 -1
  55. package/src/extensibility/extensions/wrapper.ts +74 -42
  56. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  57. package/src/extensibility/hooks/types.ts +0 -1
  58. package/src/extensibility/shared-events.ts +18 -1
  59. package/src/internal-urls/mcp-protocol.ts +17 -3
  60. package/src/internal-urls/parse.ts +31 -0
  61. package/src/internal-urls/router.ts +24 -4
  62. package/src/internal-urls/types.ts +6 -0
  63. package/src/live/transport.ts +2 -2
  64. package/src/lsp/client.ts +2 -2
  65. package/src/lsp/config.ts +4 -0
  66. package/src/lsp/types.ts +2 -0
  67. package/src/mcp/config.ts +26 -14
  68. package/src/mcp/manager.ts +26 -9
  69. package/src/mcp/tool-bridge.ts +52 -1
  70. package/src/memories/index.ts +25 -6
  71. package/src/modes/components/custom-editor.ts +39 -16
  72. package/src/modes/components/status-line/segments.ts +3 -1
  73. package/src/modes/components/tips.txt +2 -1
  74. package/src/modes/components/tool-execution.ts +8 -7
  75. package/src/modes/controllers/command-controller.ts +10 -10
  76. package/src/modes/controllers/extension-ui-controller.ts +7 -7
  77. package/src/modes/controllers/selector-controller.ts +7 -2
  78. package/src/modes/rpc/rpc-mode.ts +60 -47
  79. package/src/prompts/steering/user-interjection.md +2 -5
  80. package/src/prompts/tools/task.md +4 -2
  81. package/src/sdk.ts +64 -58
  82. package/src/session/agent-session-types.ts +8 -5
  83. package/src/session/agent-session.ts +144 -11
  84. package/src/session/model-controls.ts +48 -12
  85. package/src/session/session-advisors.ts +30 -14
  86. package/src/session/session-listing.ts +66 -4
  87. package/src/session/session-tools.ts +162 -54
  88. package/src/session/streaming-output.ts +18 -6
  89. package/src/session/tool-choice-queue.ts +19 -4
  90. package/src/session/turn-recovery.ts +25 -6
  91. package/src/slash-commands/builtin-registry.ts +69 -0
  92. package/src/task/executor.ts +11 -3
  93. package/src/task/index.ts +43 -32
  94. package/src/task/types.ts +12 -17
  95. package/src/thinking.ts +68 -5
  96. package/src/tools/bash.ts +16 -9
  97. package/src/tools/index.ts +36 -16
  98. package/src/tools/output-meta.ts +20 -0
  99. package/src/tools/read.ts +89 -15
  100. package/src/tools/write.ts +16 -7
  101. package/src/tools/xdev.ts +198 -210
  102. package/src/utils/cpuprofile.ts +235 -0
  103. package/src/utils/inspect-image-mode.ts +39 -0
  104. package/src/utils/profile-tree.ts +111 -0
  105. package/src/utils/sample-profile.ts +437 -0
  106. package/src/utils/title-generator.ts +88 -34
  107. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  108. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  109. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  110. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  111. package/dist/types/cli/update-cli.test.d.ts +0 -1
  112. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  113. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  114. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  115. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  116. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  117. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  118. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  119. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  120. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  121. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  122. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  123. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  124. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  125. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  126. package/dist/types/hindsight/client.test.d.ts +0 -1
  127. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  128. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  129. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  130. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  131. package/dist/types/launch/protocol.test.d.ts +0 -1
  132. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  133. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  134. package/dist/types/live/protocol.test.d.ts +0 -1
  135. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  136. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  137. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  138. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  139. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  140. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  141. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  142. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  143. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  144. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  145. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  146. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  147. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  148. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  149. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  150. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  151. package/dist/types/modes/print-mode.test.d.ts +0 -1
  152. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  153. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  154. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  155. package/dist/types/modes/warp-events.test.d.ts +0 -1
  156. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  157. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  158. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  159. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  160. package/dist/types/session/blob-store.test.d.ts +0 -1
  161. package/dist/types/session/messages.test.d.ts +0 -1
  162. package/dist/types/session/session-context.test.d.ts +0 -1
  163. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  164. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  165. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  166. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  167. package/dist/types/system-prompt.test.d.ts +0 -1
  168. package/dist/types/task/render.test.d.ts +0 -1
  169. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  170. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  171. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  172. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  173. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  174. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  175. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  176. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  177. package/src/advisor/__tests__/config.test.ts +0 -349
  178. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  179. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  180. package/src/cli/update-cli.test.ts +0 -28
  181. package/src/config/__tests__/model-registry.test.ts +0 -182
  182. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  183. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  184. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  185. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  186. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  187. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  188. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  189. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  190. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  191. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  192. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  193. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  194. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  195. package/src/hindsight/client.test.ts +0 -75
  196. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  197. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  198. package/src/launch/broker-list-order.test.ts +0 -89
  199. package/src/launch/broker-output-snapshot.test.ts +0 -126
  200. package/src/launch/protocol.test.ts +0 -59
  201. package/src/launch/spawn-options.test.ts +0 -31
  202. package/src/launch/terminal-output.test.ts +0 -107
  203. package/src/live/protocol.test.ts +0 -140
  204. package/src/mcp/config-writer.test.ts +0 -43
  205. package/src/mcp/smithery-auth.test.ts +0 -29
  206. package/src/mcp/smithery-registry.test.ts +0 -51
  207. package/src/mcp/transports/stdio.test.ts +0 -427
  208. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  209. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  210. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  211. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  212. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  213. package/src/modes/components/custom-editor.test.ts +0 -510
  214. package/src/modes/components/login-dialog.test.ts +0 -56
  215. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  216. package/src/modes/components/status-line/component.test.ts +0 -84
  217. package/src/modes/components/tool-execution.test.ts +0 -162
  218. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  219. package/src/modes/noninteractive-dispose.test.ts +0 -73
  220. package/src/modes/print-mode.test.ts +0 -71
  221. package/src/modes/session-teardown.test.ts +0 -219
  222. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  223. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  224. package/src/modes/warp-events.test.ts +0 -794
  225. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  226. package/src/plan-mode/model-transition.test.ts +0 -60
  227. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  228. package/src/session/agent-session-error-log.test.ts +0 -59
  229. package/src/session/blob-store.test.ts +0 -56
  230. package/src/session/messages.test.ts +0 -282
  231. package/src/session/session-context.test.ts +0 -384
  232. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  233. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  234. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  235. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  236. package/src/system-prompt.test.ts +0 -236
  237. package/src/task/render.test.ts +0 -290
  238. package/src/task/spawn-policy.test.ts +0 -62
  239. package/src/tools/__tests__/eval-description.test.ts +0 -18
  240. package/src/tools/__tests__/glob.test.ts +0 -37
  241. package/src/tools/__tests__/json-tree.test.ts +0 -35
  242. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  243. package/src/tools/hub/launch-compat.test.ts +0 -40
  244. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -53,12 +53,17 @@ export interface OutputSummary {
53
53
  export interface OutputSinkOptions {
54
54
  artifactPath?: string;
55
55
  artifactId?: string;
56
- /** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
56
+ /**
57
+ * Total inline body budget (bytes). Default DEFAULT_MAX_BYTES. The head
58
+ * window and rolling tail window share this budget, so a composed
59
+ * `dump()` body never exceeds it (plus the elision marker).
60
+ */
57
61
  spillThreshold?: number;
58
62
  /**
59
63
  * When > 0, the sink keeps the first `headBytes` of output in addition to
60
64
  * the rolling tail window. Output between the two windows is elided
61
- * (middle elision). Default 0 = tail-only behavior.
65
+ * (middle elision). Clamped to `spillThreshold / 2` so the tail keeps at
66
+ * least half the inline budget. Default 0 = tail-only behavior.
62
67
  */
63
68
  headBytes?: number;
64
69
  /**
@@ -799,7 +804,7 @@ export class OutputSink {
799
804
  this.#artifactPath = artifactPath;
800
805
  this.#artifactId = artifactId;
801
806
  this.#spillThreshold = spillThreshold;
802
- this.#headLimit = Math.max(0, headBytes);
807
+ this.#headLimit = Math.max(0, Math.min(headBytes, Math.floor(spillThreshold / 2)));
803
808
  this.#maxColumns = Math.max(0, maxColumns);
804
809
  this.#onChunk = onChunk;
805
810
  this.#chunkThrottleMs = chunkThrottleMs;
@@ -969,16 +974,23 @@ export class OutputSink {
969
974
  return parts.join("");
970
975
  }
971
976
 
977
+ // The rolling tail budget is whatever the head window has not consumed of
978
+ // the inline budget: `spillThreshold - #headBytes`. While the head window
979
+ // fills it shrinks toward `spillThreshold - headLimit`; after `replace()`
980
+ // (head cleared) it grows back to the full threshold. This keeps
981
+ // `head + tail <= spillThreshold`, so the composed dump body fits the
982
+ // inline byte cap by construction.
983
+
972
984
  #willOverflow(dataBytes: number): boolean {
973
985
  // Triggers file mirroring as soon as the next chunk would push us over
974
- // the tail budget (head retention does not change spill-to-artifact).
975
- return this.#bufferBytes + dataBytes > this.#spillThreshold;
986
+ // the tail budget i.e. the first byte that could be lost from memory.
987
+ return this.#bufferBytes + dataBytes > this.#spillThreshold - this.#headBytes;
976
988
  }
977
989
 
978
990
  #pushTail(chunk: string, dataBytes: number): void {
979
991
  if (dataBytes === 0) return;
980
992
 
981
- const threshold = this.#spillThreshold;
993
+ const threshold = Math.max(0, this.#spillThreshold - this.#headBytes);
982
994
  const willOverflow = this.#bufferBytes + dataBytes > threshold;
983
995
 
984
996
  if (!willOverflow) {
@@ -13,15 +13,15 @@ export interface RejectInfo {
13
13
  reason: "aborted" | "error" | "cleared" | "removed" | "unavailable" | "not_invoked";
14
14
  }
15
15
 
16
- /** "requeue" replays the lost yield next turn; "drop" (or void/undefined) discards it. */
17
- export type RejectOutcome = "requeue" | "drop";
16
+ /** Controls whether rejection replays a yield, drops it, or drops its remaining sequence. */
17
+ export type RejectOutcome = "requeue" | "drop" | "drop_sequence";
18
18
 
19
19
  export interface DirectiveCallbacks {
20
20
  /** Fires when the yield completed; onInvoked directives require the requested tool to run first. */
21
21
  onResolved?: (info: ResolveInfo) => void;
22
22
  /**
23
- * Fires when the yield is being discarded. Return "requeue" to replay the
24
- * same value at the head of the queue for the next turn. Default: "drop".
23
+ * Return "requeue" to replay the same value, or "drop_sequence" to discard
24
+ * its directive including later yields. Default: drop the rejected yield.
25
25
  */
26
26
  onRejected?: (info: RejectInfo) => RejectOutcome | undefined;
27
27
  /**
@@ -39,6 +39,8 @@ export interface ToolChoiceDirective {
39
39
  /** Stable label for targeted removal and debugging (e.g. "user-force"). */
40
40
  label: string;
41
41
  callbacks: DirectiveCallbacks;
42
+ /** Original multi-yield directive retained across one-yield replays. */
43
+ sequenceRoot?: ToolChoiceDirective;
42
44
  }
43
45
 
44
46
  export interface PushOptions {
@@ -83,6 +85,12 @@ interface PendingInvoker {
83
85
 
84
86
  export class ToolChoiceQueue {
85
87
  #queue: ToolChoiceDirective[] = [];
88
+ /**
89
+ * In-flight yield awaiting resolve()/reject(). May outlive the run that
90
+ * claimed it: a pre-model gate stop ends the run without a `turn_end`, and
91
+ * the claim is finalized later — by the next admitted turn's `turn_end`,
92
+ * the abort safety net, or an `unavailable` rejection on redeem.
93
+ */
86
94
  #inFlight: InFlight | undefined;
87
95
  /**
88
96
  * Label of the directive whose last yield was resolved this turn.
@@ -178,6 +186,12 @@ export class ToolChoiceQueue {
178
186
  reason,
179
187
  });
180
188
 
189
+ if (outcome === "drop_sequence") {
190
+ const root = inFlight.directive.sequenceRoot ?? inFlight.directive;
191
+ this.#queue = this.#queue.filter(directive => directive !== root && directive.sequenceRoot !== root);
192
+ return;
193
+ }
194
+
181
195
  if (outcome === "requeue") {
182
196
  // Re-queue only the lost yield, not the rest of the sequence. Carry forward
183
197
  // callbacks so the replayed yield still executes and finalizes correctly,
@@ -185,6 +199,7 @@ export class ToolChoiceQueue {
185
199
  this.#queue.unshift({
186
200
  generator: onceGen(inFlight.yielded),
187
201
  label: `${inFlight.directive.label}-requeued`,
202
+ sequenceRoot: inFlight.directive.sequenceRoot ?? inFlight.directive,
188
203
  callbacks: {
189
204
  onResolved: inFlight.directive.callbacks.onResolved,
190
205
  onInvoked: inFlight.directive.callbacks.onInvoked,
@@ -11,6 +11,7 @@ import type {
11
11
  AssistantRetryRecovery,
12
12
  AssistantRetryRecoveryKind,
13
13
  CodexCompactionContext,
14
+ Effort,
14
15
  Model,
15
16
  TextContent,
16
17
  ToolChoice,
@@ -27,7 +28,12 @@ import type { RecoveredRetryError } from "../extensibility/shared-events";
27
28
  import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
28
29
  import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
29
30
  import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
30
- import type { ConfiguredThinkingLevel } from "../thinking";
31
+ import {
32
+ AUTO_THINKING,
33
+ type ConfiguredThinkingLevel,
34
+ clampThinkingLevelToCeiling,
35
+ modelSupportsEffortCeiling,
36
+ } from "../thinking";
31
37
  import type { AgentSessionEvent } from "./agent-session-events";
32
38
  import type { InitialRetryFallbackState } from "./agent-session-types";
33
39
  import { isEmptyErrorTurn } from "./messages";
@@ -98,6 +104,8 @@ export interface TurnRecoveryHost {
98
104
  thinkingLevel(): ThinkingLevel | undefined;
99
105
  configuredThinkingLevel(): ConfiguredThinkingLevel | undefined;
100
106
  setThinkingLevel(level: ConfiguredThinkingLevel | undefined): void;
107
+ /** Hard per-session effort ceiling; fallback recovery must never raise thinking above it. */
108
+ thinkingLevelCeiling(): Effort | undefined;
101
109
  isDisposed(): boolean;
102
110
  isStreaming(): boolean;
103
111
  isCompacting(): boolean;
@@ -110,7 +118,7 @@ export interface TurnRecoveryHost {
110
118
  waitForSessionMessagePersistence(message: AssistantMessage): Promise<void>;
111
119
  appendSessionMessage(message: AssistantMessage): void;
112
120
  sessionMessageAlreadyPersisted(message: AssistantMessage): boolean;
113
- setModelWithProviderSessionReset(model: Model): void;
121
+ setModelWithProviderSessionReset(model: Model): Promise<void>;
114
122
  resetCurrentResponsesProviderSession(reason: string): void;
115
123
  maybeAutoRedeemCodexReset(): Promise<boolean>;
116
124
  runAutoCompaction(
@@ -1011,9 +1019,16 @@ export class TurnRecovery {
1011
1019
  // Capture the configured selector (auto-aware) so a fallback chain preserves
1012
1020
  // `auto` instead of collapsing it to the level it resolved to this turn.
1013
1021
  const currentThinkingLevel = this.#host.configuredThinkingLevel();
1014
- const nextThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
1022
+ const requestedThinkingLevel = selector.thinkingLevel ?? currentThinkingLevel;
1023
+ // A fallback selector's explicit level (or the carried level after the
1024
+ // replacement model's floor clamp) must never exceed the session's
1025
+ // per-spawn effort ceiling.
1026
+ const nextThinkingLevel =
1027
+ requestedThinkingLevel === AUTO_THINKING
1028
+ ? requestedThinkingLevel
1029
+ : clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling());
1015
1030
  const candidateSelector = formatModelStringWithRouting(candidate);
1016
- this.#host.setModelWithProviderSessionReset(candidate);
1031
+ await this.#host.setModelWithProviderSessionReset(candidate);
1017
1032
  this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1018
1033
  this.#host.settings.getStorage()?.recordModelUsage(candidateSelector);
1019
1034
  this.#host.setThinkingLevel(nextThinkingLevel);
@@ -1041,11 +1056,15 @@ export class TurnRecovery {
1041
1056
  const role = this.#activeRetryFallback?.role ?? this.resolveRetryFallbackRole(currentSelector);
1042
1057
  if (!role) return false;
1043
1058
 
1059
+ const ceiling = this.#host.thinkingLevelCeiling();
1044
1060
  for (const selector of this.findRetryFallbackCandidates(role, currentSelector)) {
1045
1061
  if (this.isRetryFallbackSelectorSuppressed(selector)) continue;
1046
1062
  const resolved = resolveModelOverride([selector.raw], this.#host.modelRegistry, this.#host.settings);
1047
1063
  const candidate = resolved.model ?? this.#host.modelRegistry.find(selector.provider, selector.id);
1048
1064
  if (!candidate) continue;
1065
+ // A candidate whose effort floor exceeds the per-spawn ceiling would be
1066
+ // clamped UP past the cap by its model floor — skip it entirely.
1067
+ if (ceiling !== undefined && !modelSupportsEffortCeiling(candidate, ceiling)) continue;
1049
1068
  const apiKey = await this.#host.modelRegistry.getApiKey(candidate, this.#host.sessionId());
1050
1069
  if (!apiKey) continue;
1051
1070
  await this.applyRetryFallbackCandidate(role, selector, currentSelector, options);
@@ -1128,7 +1147,7 @@ export class TurnRecovery {
1128
1147
  const apiKey = await this.#host.modelRegistry.getApiKey(baseModel, this.#host.sessionId());
1129
1148
  if (!apiKey) return false;
1130
1149
  const baseSelector = formatModelStringWithRouting(baseModel);
1131
- this.#host.setModelWithProviderSessionReset(baseModel);
1150
+ await this.#host.setModelWithProviderSessionReset(baseModel);
1132
1151
  this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1133
1152
  this.#host.settings.getStorage()?.recordModelUsage(baseSelector);
1134
1153
  await this.#host.emitSessionEvent({
@@ -1182,7 +1201,7 @@ export class TurnRecovery {
1182
1201
  const thinkingToApply =
1183
1202
  currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
1184
1203
  const primarySelector = formatModelStringWithRouting(primaryModel);
1185
- this.#host.setModelWithProviderSessionReset(primaryModel);
1204
+ await this.#host.setModelWithProviderSessionReset(primaryModel);
1186
1205
  this.#host.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
1187
1206
  this.#host.settings.getStorage()?.recordModelUsage(primarySelector);
1188
1207
  this.#host.setThinkingLevel(thinkingToApply);
@@ -53,6 +53,7 @@ import {
53
53
  renderChangelogEntries,
54
54
  } from "../utils/changelog";
55
55
  import { copyToClipboard } from "../utils/clipboard";
56
+ import type { InspectImageMode } from "../utils/inspect-image-mode";
56
57
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
57
58
  import { buildContextReportText } from "./helpers/context-report";
58
59
  import { formatDuration } from "./helpers/format";
@@ -151,6 +152,33 @@ async function applyComputerUseToggle(session: AgentSession, enable: boolean): P
151
152
  : "Computer use disabled for this session.";
152
153
  }
153
154
 
155
+ /** Session-effective `/vision status` line. */
156
+ function formatVisionStatus(session: AgentSession): string {
157
+ const { mode, active, model } = session.inspectImageState();
158
+ const override = session.getInspectImageModeOverride();
159
+ const modelObj = session.model;
160
+ const capability = modelObj
161
+ ? modelObj.input.includes("image")
162
+ ? "native image input"
163
+ : "no native image input"
164
+ : "no active model";
165
+ return [
166
+ `inspect_image: ${active ? "active" : "inactive"}`,
167
+ `mode: ${mode}${override ? " (session override)" : ""}`,
168
+ ...(override ? [`configured: ${session.settings.get("inspect_image.mode")}`] : []),
169
+ `model: ${model ?? "none"} (${capability})`,
170
+ ].join(" · ");
171
+ }
172
+
173
+ /** Applies a `/vision` mode for this session and returns the operator feedback line. */
174
+ async function applyVisionMode(session: AgentSession, mode: InspectImageMode): Promise<string> {
175
+ const applied = await session.setInspectImageMode(mode);
176
+ if (!applied) {
177
+ return "inspect_image is unavailable in this session.";
178
+ }
179
+ return `Vision mode: ${mode}. ${formatVisionStatus(session)}`;
180
+ }
181
+
154
182
  const AUTOCOMPLETE_DETAIL_LIMIT = 48;
155
183
 
156
184
  function shortDetail(value: string, limit = AUTOCOMPLETE_DETAIL_LIMIT): string {
@@ -647,6 +675,47 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
647
675
  runtime.ctx.editor.setText("");
648
676
  },
649
677
  },
678
+ {
679
+ name: "vision",
680
+ description: "Control the inspect_image vision-delegation tool for this session",
681
+ acpDescription: "Toggle vision delegation",
682
+ acpInputHint: "[on|off|auto|status]",
683
+ subcommands: [
684
+ { name: "on", description: "Always expose inspect_image this session" },
685
+ { name: "off", description: "Never expose inspect_image this session" },
686
+ { name: "auto", description: "Follow inspect_image.mode (auto hides it for vision-capable models)" },
687
+ { name: "status", description: "Show inspect_image status" },
688
+ ],
689
+ allowArgs: true,
690
+ getTuiAutocompleteDescription: runtime => `Vision: ${runtime.ctx.session.inspectImageState().mode}`,
691
+ handle: async (command, runtime) => {
692
+ const arg = command.args.trim().toLowerCase();
693
+ if (arg === "status") {
694
+ await runtime.output(formatVisionStatus(runtime.session));
695
+ return commandConsumed();
696
+ }
697
+ if (arg === "on" || arg === "off" || arg === "auto") {
698
+ await runtime.output(await applyVisionMode(runtime.session, arg));
699
+ return commandConsumed();
700
+ }
701
+ return usage("Usage: /vision [on|off|auto|status]", runtime);
702
+ },
703
+ handleTui: async (command, runtime) => {
704
+ const arg = command.args.trim().toLowerCase();
705
+ if (arg === "status") {
706
+ runtime.ctx.showStatus(formatVisionStatus(runtime.ctx.session));
707
+ runtime.ctx.editor.setText("");
708
+ return;
709
+ }
710
+ if (arg === "on" || arg === "off" || arg === "auto") {
711
+ runtime.ctx.showStatus(await applyVisionMode(runtime.ctx.session, arg));
712
+ runtime.ctx.editor.setText("");
713
+ return;
714
+ }
715
+ runtime.ctx.showStatus("Usage: /vision [on|off|auto|status]");
716
+ runtime.ctx.editor.setText("");
717
+ },
718
+ },
650
719
  {
651
720
  name: "prewalk",
652
721
  description: "Switch to a fast/cheap model at the next action (works even without --prewalk)",
@@ -2647,9 +2647,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2647
2647
  progress.contextWindow = model.contextWindow;
2648
2648
  }
2649
2649
  // Caller-requested coarse effort maps onto the resolved model's
2650
- // supported range; undefined (no effort, or no controllable effort
2651
- // surface) falls through to the normal selectors below.
2652
- const effortLevel = options.effort !== undefined ? resolveTaskEffortLevel(model, options.effort) : undefined;
2650
+ // supported range, then respects the operator-configured ceiling.
2651
+ // Undefined (no effort, or no controllable effort surface) falls
2652
+ // through to the normal selectors below.
2653
+ // The ceiling outlives initial resolution: it rides into the session so
2654
+ // retry-fallback recovery can never clamp effort back up past it.
2655
+ const spawnEffortCeiling = options.effort !== undefined ? settings.get("task.maxEffort") : undefined;
2656
+ const effortLevel =
2657
+ options.effort !== undefined
2658
+ ? resolveTaskEffortLevel(model, options.effort, spawnEffortCeiling)
2659
+ : undefined;
2653
2660
  if (model) {
2654
2661
  const displayLevel = effortLevel ?? (explicitThinkingLevel ? resolvedThinkingLevel : undefined);
2655
2662
  progress.resolvedModel =
@@ -2773,6 +2780,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2773
2780
  modelPatternDefaultFallbackChain:
2774
2781
  model || modelOverride === undefined ? undefined : defaultRetryFallbackChain,
2775
2782
  thinkingLevel: effectiveThinkingLevel,
2783
+ thinkingLevelCeiling: spawnEffortCeiling,
2776
2784
  toolNames,
2777
2785
  outputSchema,
2778
2786
  outputSchemaMode: options.outputSchemaMode,
package/src/task/index.ts CHANGED
@@ -156,27 +156,31 @@ export function formatResultOutputFallback(result: Pick<SingleResult, "output" |
156
156
  return result.requests > 0 ? `(no output) after ${result.requests} req` : "(no output)";
157
157
  }
158
158
 
159
- /**
160
- * Render the tool description from a cached agent list and current settings.
161
- */
162
- function renderDescription(
163
- agents: AgentDefinition[],
164
- isolationEnabled: boolean,
165
- applyIsolatedChanges: boolean,
166
- disabledAgents: string[],
167
- batchEnabled: boolean,
168
- asyncEnabled: boolean,
169
- ircEnabled: boolean,
170
- parentSpawns: string,
171
- ): string {
172
- const spawnPolicy = resolveSpawnPolicy(parentSpawns);
159
+ interface TaskDescriptionOptions {
160
+ agents: AgentDefinition[];
161
+ isolationEnabled: boolean;
162
+ applyIsolatedChanges: boolean;
163
+ disabledAgents: string[];
164
+ batchEnabled: boolean;
165
+ effortEnabled: boolean;
166
+ asyncEnabled: boolean;
167
+ ircEnabled: boolean;
168
+ parentSpawns: string;
169
+ }
170
+
171
+ /** Render the tool description from a cached agent list and current settings. */
172
+ function renderDescription(options: TaskDescriptionOptions): string {
173
+ const spawnPolicy = resolveSpawnPolicy(options.parentSpawns);
173
174
  const spawningDisabled = !spawnPolicy.enabled;
174
- let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
175
+ let filteredAgents =
176
+ options.disabledAgents.length > 0
177
+ ? options.agents.filter(agent => !options.disabledAgents.includes(agent.name))
178
+ : options.agents;
175
179
  if (spawningDisabled) {
176
180
  filteredAgents = [];
177
181
  } else if (spawnPolicy.allowedAgents !== null) {
178
182
  const allowed = new Set(spawnPolicy.allowedAgents);
179
- filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
183
+ filteredAgents = filteredAgents.filter(agent => allowed.has(agent.name));
180
184
  }
181
185
  const renderedAgents = filteredAgents.map(agent => ({
182
186
  name: agent.name,
@@ -188,12 +192,13 @@ function renderDescription(
188
192
  agents: renderedAgents,
189
193
  spawningDisabled,
190
194
  defaultAgent: spawnPolicy.defaultAgent,
191
- isolationEnabled,
192
- applyIsolatedChanges,
193
- batchEnabled,
194
- asyncEnabled,
195
+ isolationEnabled: options.isolationEnabled,
196
+ applyIsolatedChanges: options.applyIsolatedChanges,
197
+ batchEnabled: options.batchEnabled,
198
+ effortEnabled: options.effortEnabled,
199
+ asyncEnabled: options.asyncEnabled,
195
200
  hasBlockingAgents: renderedAgents.some(agent => agent.blocking),
196
- ircEnabled,
201
+ ircEnabled: options.ircEnabled,
197
202
  });
198
203
  }
199
204
 
@@ -581,28 +586,34 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
581
586
  const planMode = this.session.getPlanModeState?.()?.enabled === true;
582
587
  const isolationEnabled = !planMode && this.session.settings.get("task.isolation.mode") !== "none";
583
588
  const defaultAgent = resolveSpawnPolicy(this.session.getSessionSpawns()).defaultAgent;
584
- return getTaskSchema({ isolationEnabled, batchEnabled: this.#isBatchEnabled(), defaultAgent });
589
+ return getTaskSchema({
590
+ isolationEnabled,
591
+ batchEnabled: this.#isBatchEnabled(),
592
+ effortEnabled: this.session.settings.get("task.enableEffort"),
593
+ defaultAgent,
594
+ });
585
595
  }
586
596
 
587
597
  renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
588
598
  return renderTaskCall(repairTaskParams(args as TaskParams), options, theme);
589
599
  }
590
600
 
591
- /** Dynamic description that reflects current disabled-agent settings */
601
+ /** Dynamic description that reflects current task settings. */
592
602
  get description(): string {
593
603
  const disabledAgents = this.session.settings.get("task.disabledAgents") as string[];
594
604
  const planMode = this.session.getPlanModeState?.()?.enabled === true;
595
605
  const isolationMode = this.session.settings.get("task.isolation.mode");
596
- return renderDescription(
597
- this.#discoveredAgents,
598
- !planMode && isolationMode !== "none",
599
- this.session.settings.get("task.isolation.apply"),
606
+ return renderDescription({
607
+ agents: this.#discoveredAgents,
608
+ isolationEnabled: !planMode && isolationMode !== "none",
609
+ applyIsolatedChanges: this.session.settings.get("task.isolation.apply"),
600
610
  disabledAgents,
601
- this.#isBatchEnabled(),
602
- this.session.settings.get("async.enabled"),
603
- isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
604
- this.session.getSessionSpawns() ?? "*",
605
- );
611
+ batchEnabled: this.#isBatchEnabled(),
612
+ effortEnabled: this.session.settings.get("task.enableEffort"),
613
+ asyncEnabled: this.session.settings.get("async.enabled"),
614
+ ircEnabled: isIrcEnabled(this.session.settings, this.session.taskDepth ?? 0),
615
+ parentSpawns: this.session.getSessionSpawns() ?? "*",
616
+ });
606
617
  }
607
618
  private constructor(
608
619
  private readonly session: ToolSession,
package/src/task/types.ts CHANGED
@@ -115,7 +115,6 @@ export const taskItemSchema = type({
115
115
  "name?": "string",
116
116
  agent: "string = 'task'",
117
117
  task: "string",
118
- "effort?": effortRule,
119
118
  "outputSchema?": outputSchemaInputSchema,
120
119
  "schemaMode?": '"permissive" | "strict"',
121
120
  "+": "delete",
@@ -124,7 +123,6 @@ const taskItemSchemaIsolated = type({
124
123
  "name?": "string",
125
124
  agent: "string = 'task'",
126
125
  task: "string",
127
- "effort?": effortRule,
128
126
  "outputSchema?": outputSchemaInputSchema,
129
127
  "schemaMode?": '"permissive" | "strict"',
130
128
  "isolated?": "boolean",
@@ -153,7 +151,6 @@ export const taskSchema = type({
153
151
  "name?": "string",
154
152
  agent: "string = 'task'",
155
153
  task: "string",
156
- "effort?": effortRule,
157
154
  "outputSchema?": outputSchemaInputSchema,
158
155
  "schemaMode?": '"permissive" | "strict"',
159
156
  "isolated?": "boolean",
@@ -163,7 +160,6 @@ const taskSchemaNoIsolation = type({
163
160
  "name?": "string",
164
161
  agent: "string = 'task'",
165
162
  task: "string",
166
- "effort?": effortRule,
167
163
  "outputSchema?": outputSchemaInputSchema,
168
164
  "schemaMode?": '"permissive" | "strict"',
169
165
  "+": "delete",
@@ -200,15 +196,17 @@ function createTaskSchema(options: {
200
196
  isolationEnabled: boolean;
201
197
  batchEnabled: boolean;
202
198
  defaultAgent: string;
199
+ effortEnabled: boolean;
203
200
  }): BaseType {
204
201
  const agent = taskAgentSchemaRule(options.defaultAgent);
202
+ const effortField = options.effortEnabled ? { "effort?": effortRule } : {};
205
203
  if (options.batchEnabled) {
206
204
  if (options.isolationEnabled) {
207
205
  const item = type.raw({
208
206
  "name?": "string",
209
207
  agent,
210
208
  task: "string",
211
- "effort?": effortRule,
209
+ ...effortField,
212
210
  "outputSchema?": outputSchemaInputSchema,
213
211
  "schemaMode?": '"permissive" | "strict"',
214
212
  "isolated?": "boolean",
@@ -224,7 +222,7 @@ function createTaskSchema(options: {
224
222
  "name?": "string",
225
223
  agent,
226
224
  task: "string",
227
- "effort?": effortRule,
225
+ ...effortField,
228
226
  "outputSchema?": outputSchemaInputSchema,
229
227
  "schemaMode?": '"permissive" | "strict"',
230
228
  "+": "delete",
@@ -240,7 +238,7 @@ function createTaskSchema(options: {
240
238
  "name?": "string",
241
239
  agent,
242
240
  task: "string",
243
- "effort?": effortRule,
241
+ ...effortField,
244
242
  "outputSchema?": outputSchemaInputSchema,
245
243
  "schemaMode?": '"permissive" | "strict"',
246
244
  "isolated?": "boolean",
@@ -251,33 +249,30 @@ function createTaskSchema(options: {
251
249
  "name?": "string",
252
250
  agent,
253
251
  task: "string",
254
- "effort?": effortRule,
252
+ ...effortField,
255
253
  "outputSchema?": outputSchemaInputSchema,
256
254
  "schemaMode?": '"permissive" | "strict"',
257
255
  "+": "delete",
258
256
  });
259
257
  }
260
258
 
261
- export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema;
262
- export function getTaskSchema(options: {
263
- isolationEnabled: boolean;
264
- batchEnabled: boolean;
265
- defaultAgent: string;
266
- }): TaskToolSchemaInstance;
259
+ /** Build the task wire schema for the current settings and spawn policy. */
267
260
  export function getTaskSchema(options: {
268
261
  isolationEnabled: boolean;
269
262
  batchEnabled: boolean;
263
+ effortEnabled?: boolean;
270
264
  defaultAgent?: string;
271
265
  }): TaskToolSchemaInstance {
272
266
  const defaultAgent = options.defaultAgent ?? "task";
273
- if (defaultAgent === "task") {
267
+ const effortEnabled = options.effortEnabled ?? false;
268
+ if (defaultAgent === "task" && !effortEnabled) {
274
269
  if (options.batchEnabled) return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
275
270
  return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
276
271
  }
277
- const key = `${options.isolationEnabled ? "iso" : "flat"}:${options.batchEnabled ? "batch" : "single"}:${defaultAgent}`;
272
+ const key = `${options.isolationEnabled ? "iso" : "flat"}:${options.batchEnabled ? "batch" : "single"}:${effortEnabled ? "effort" : "default"}:${defaultAgent}`;
278
273
  const cached = taskSchemaCache.get(key);
279
274
  if (cached) return cached;
280
- const schema = createTaskSchema({ ...options, defaultAgent });
275
+ const schema = createTaskSchema({ ...options, effortEnabled, defaultAgent });
281
276
  taskSchemaCache.set(key, schema);
282
277
  return schema;
283
278
  }
package/src/thinking.ts CHANGED
@@ -271,19 +271,82 @@ export type TaskEffort = (typeof TASK_EFFORTS)[number];
271
271
  * at — high, xhigh, or max), `med` = the middle (lower of the two middles for
272
272
  * an even-sized range). Without a model, maps over the full canonical range.
273
273
  * Returns `undefined` when the model has no controllable effort surface, so
274
- * callers fall back to their default selector (e.g. `auto`).
274
+ * callers fall back to their default selector (e.g. `auto`). Throws when the
275
+ * configured ceiling is below the model's lowest supported effort.
275
276
  */
276
- export function resolveTaskEffortLevel(model: Model | undefined, effort: TaskEffort): Effort | undefined {
277
+ export function resolveTaskEffortLevel(
278
+ model: Model | undefined,
279
+ effort: TaskEffort,
280
+ maxEffort?: Effort,
281
+ ): Effort | undefined {
277
282
  const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
278
283
  if (supported.length === 0) return undefined;
284
+ let resolved: Effort;
279
285
  switch (effort) {
280
286
  case "lo":
281
- return supported[0];
287
+ resolved = supported[0];
288
+ break;
282
289
  case "med":
283
- return supported[(supported.length - 1) >> 1];
290
+ resolved = supported[(supported.length - 1) >> 1];
291
+ break;
284
292
  case "hi":
285
- return supported[supported.length - 1];
293
+ resolved = supported[supported.length - 1];
294
+ break;
295
+ }
296
+ if (maxEffort === undefined) return resolved;
297
+ const maxIndex = THINKING_EFFORTS.indexOf(maxEffort);
298
+ const ceiling = supported.findLast(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex);
299
+ if (ceiling === undefined) {
300
+ const modelName = model ? `${model.provider}/${model.id}` : "Selected model";
301
+ throw new RangeError(`${modelName} has no supported thinking effort at or below task.maxEffort=${maxEffort}`);
286
302
  }
303
+ return THINKING_EFFORTS.indexOf(resolved) > THINKING_EFFORTS.indexOf(ceiling) ? ceiling : resolved;
304
+ }
305
+
306
+ /**
307
+ * Clamps a concrete thinking selector to a per-session effort ceiling (e.g. a
308
+ * task spawn's `task.maxEffort`-capped effort hint). `off`/`inherit`/
309
+ * `undefined` pass through, as do levels already at or below the ceiling.
310
+ * Levels above it snap to the highest model-supported effort at or below the
311
+ * ceiling. A model whose floor exceeds the ceiling has nothing valid to snap
312
+ * to; the requested level is returned unchanged and the caller is responsible
313
+ * for rejecting or skipping such models (see {@link modelSupportsEffortCeiling}).
314
+ */
315
+ export function clampThinkingLevelToCeiling(
316
+ model: Model | undefined,
317
+ level: Effort | undefined,
318
+ ceiling: Effort | undefined,
319
+ ): Effort | undefined;
320
+ export function clampThinkingLevelToCeiling(
321
+ model: Model | undefined,
322
+ level: ThinkingLevel | undefined,
323
+ ceiling: Effort | undefined,
324
+ ): ThinkingLevel | undefined;
325
+ export function clampThinkingLevelToCeiling(
326
+ model: Model | undefined,
327
+ level: ThinkingLevel | undefined,
328
+ ceiling: Effort | undefined,
329
+ ): ThinkingLevel | undefined {
330
+ if (ceiling === undefined || level === undefined || level === ThinkingLevel.Off || level === ThinkingLevel.Inherit) {
331
+ return level;
332
+ }
333
+ const maxIndex = THINKING_EFFORTS.indexOf(ceiling);
334
+ if (THINKING_EFFORTS.indexOf(level) <= maxIndex) return level;
335
+ const supported = model ? getSupportedEfforts(model) : THINKING_EFFORTS;
336
+ return supported.findLast(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex) ?? level;
337
+ }
338
+
339
+ /**
340
+ * True when `model` can honor a thinking-effort ceiling: it either has no
341
+ * controllable effort surface (nothing to cap) or supports at least one effort
342
+ * at or below the ceiling. Retry-fallback candidate filtering uses this to
343
+ * skip models whose floor would force the session above the ceiling.
344
+ */
345
+ export function modelSupportsEffortCeiling(model: Model, ceiling: Effort): boolean {
346
+ const supported = getSupportedEfforts(model);
347
+ if (supported.length === 0) return true;
348
+ const maxIndex = THINKING_EFFORTS.indexOf(ceiling);
349
+ return supported.some(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex);
287
350
  }
288
351
 
289
352
  /**