@oh-my-pi/pi-coding-agent 15.10.11 → 15.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
@@ -29,6 +29,7 @@ import {
29
29
  type AgentState,
30
30
  type AgentTool,
31
31
  AppendOnlyContextManager,
32
+ type AsideMessage,
32
33
  resolveTelemetry,
33
34
  ThinkingLevel,
34
35
  } from "@oh-my-pi/pi-agent-core";
@@ -50,12 +51,18 @@ import {
50
51
  generateBranchSummary,
51
52
  generateHandoff,
52
53
  prepareCompaction,
54
+ resolveThresholdTokens,
53
55
  type ShakeConfig,
54
56
  type ShakeRegion,
55
57
  type SummaryOptions,
56
58
  shouldCompact,
57
59
  } from "@oh-my-pi/pi-agent-core/compaction";
58
- import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
60
+ import {
61
+ DEFAULT_PRUNE_CONFIG,
62
+ pruneSupersededToolResults,
63
+ pruneToolOutputs,
64
+ readToolSupersedeKey,
65
+ } from "@oh-my-pi/pi-agent-core/compaction/pruning";
59
66
  import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
60
67
  import type {
61
68
  AssistantMessage,
@@ -100,6 +107,7 @@ import {
100
107
  relativePathWithinRoot,
101
108
  Snowflake,
102
109
  } from "@oh-my-pi/pi-utils";
110
+ import { snapcompactCompact } from "@oh-my-pi/snapcompact";
103
111
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
104
112
  import { classifyDifficulty } from "../auto-thinking/classifier";
105
113
  import { reset as resetCapabilities } from "../capability";
@@ -108,6 +116,7 @@ import { shouldEnableAppendOnlyContext } from "../config/append-only-context-mod
108
116
  import type { ModelRegistry } from "../config/model-registry";
109
117
  import {
110
118
  extractExplicitThinkingSelector,
119
+ filterAvailableModelsByEnabledPatterns,
111
120
  formatModelSelectorValue,
112
121
  formatModelString,
113
122
  getModelMatchPreferences,
@@ -162,6 +171,7 @@ import { GoalRuntime } from "../goals/runtime";
162
171
  import type { Goal, GoalModeState } from "../goals/state";
163
172
  import type { HindsightSessionState } from "../hindsight/state";
164
173
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
174
+ import type { IrcMessage } from "../irc/bus";
165
175
  import { resolveMemoryBackend } from "../memory-backend";
166
176
  import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
167
177
  import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
@@ -183,8 +193,12 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
183
193
  };
184
194
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
185
195
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
186
- import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
187
- import { deobfuscateSessionContext, type SecretObfuscator } from "../secrets/obfuscator";
196
+ import {
197
+ deobfuscateSessionContext,
198
+ obfuscateProviderContext,
199
+ obfuscateProviderTools,
200
+ type SecretObfuscator,
201
+ } from "../secrets/obfuscator";
188
202
  import { invalidateHostMetadata } from "../ssh/connection-manager";
189
203
  import {
190
204
  AUTO_THINKING,
@@ -192,6 +206,7 @@ import {
192
206
  clampAutoThinkingEffort,
193
207
  resolveProvisionalAutoLevel,
194
208
  resolveThinkingLevelForModel,
209
+ shouldDisableReasoning,
195
210
  toReasoningEffort,
196
211
  } from "../thinking";
197
212
  import { shutdownTinyTitleClient } from "../tiny/title-client";
@@ -223,10 +238,8 @@ import type { AuthStorage } from "./auth-storage";
223
238
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
224
239
  import {
225
240
  type BashExecutionMessage,
226
- type CompactionSummaryMessage,
227
241
  type CustomMessage,
228
242
  convertToLlm,
229
- type FileMentionMessage,
230
243
  type PythonExecutionMessage,
231
244
  readPendingDisplayTag,
232
245
  SILENT_ABORT_MARKER,
@@ -252,11 +265,11 @@ export type AgentSessionEvent =
252
265
  | {
253
266
  type: "auto_compaction_start";
254
267
  reason: "threshold" | "overflow" | "idle" | "incomplete";
255
- action: "context-full" | "handoff" | "shake";
268
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
256
269
  }
257
270
  | {
258
271
  type: "auto_compaction_end";
259
- action: "context-full" | "handoff" | "shake";
272
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
260
273
  result: CompactionResult | undefined;
261
274
  aborted: boolean;
262
275
  willRetry: boolean;
@@ -290,6 +303,15 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
290
303
  const EMPTY_STOP_MAX_RETRIES = 3;
291
304
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
292
305
  const RETRY_BACKOFF_JITTER_RATIO = 0.25;
306
+ /**
307
+ * Hysteresis band for the post-shake "did we actually create headroom?" check.
308
+ * Shake counts as having resolved threshold pressure only when residual context
309
+ * lands at or below `SHAKE_RECOVERY_BAND × threshold`. Re-checking against the
310
+ * raw threshold lets shake keep reclaiming a trickle of the previous turn's
311
+ * output and land just under the line every turn, sustaining the auto-continue
312
+ * dead loop reported in #2275.
313
+ */
314
+ const SHAKE_RECOVERY_BAND = 0.8;
293
315
 
294
316
  function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
295
317
  const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
@@ -324,6 +346,8 @@ export interface AgentSessionConfig {
324
346
  agent: Agent;
325
347
  sessionManager: SessionManager;
326
348
  settings: Settings;
349
+ /** Whether the caller explicitly requested yolo/auto-approve behavior for this session. */
350
+ autoApprove?: boolean;
327
351
  /** Models to cycle through with Ctrl+P (from --models flag) */
328
352
  scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
329
353
  /** Initial session thinking selector. */
@@ -404,8 +428,6 @@ export interface AgentSessionConfig {
404
428
  asyncJobManager?: AsyncJobManager;
405
429
  /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
406
430
  agentId?: string;
407
- /** Shared agent registry (for forwarding IRC observations to the main session UI). */
408
- agentRegistry?: AgentRegistry;
409
431
  /**
410
432
  * Override the provider-facing session ID for all API requests from this session.
411
433
  * When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
@@ -548,15 +570,15 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
548
570
  return `${selector.provider}/${selector.id}`;
549
571
  }
550
572
 
551
- const IRC_REPLY_MAX_BYTES = 4096;
573
+ const EPHEMERAL_REPLY_MAX_BYTES = 4096;
552
574
 
553
575
  /**
554
- * Collapse degenerate IRC ephemeral replies before they hit the relay.
576
+ * Collapse degenerate ephemeral replies (/btw, /omfg side-channel turns).
555
577
  * Models occasionally loop on a single line (~16 reports of N-times-repeated
556
578
  * replies); compress runs longer than 3 down to one instance + `[…N×]`, then
557
579
  * cap at 4 KiB so a runaway reply can't flood the channel.
558
580
  */
559
- function dedupeIrcReply(text: string): string {
581
+ function dedupeEphemeralReply(text: string): string {
560
582
  if (!text) return text;
561
583
  const lines = text.split("\n");
562
584
  const out: string[] = [];
@@ -573,11 +595,11 @@ function dedupeIrcReply(text: string): string {
573
595
  i = j;
574
596
  }
575
597
  let result = out.join("\n");
576
- if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
598
+ if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) {
577
599
  // Trim by characters until we're under the byte budget — handles multi-byte
578
600
  // glyphs at the boundary without splitting them.
579
601
  const suffix = "\n[…truncated]";
580
- const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
602
+ const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
581
603
  while (Buffer.byteLength(result, "utf8") > budget) {
582
604
  result = result.slice(0, -1);
583
605
  }
@@ -839,6 +861,7 @@ export class AgentSession {
839
861
  readonly settings: Settings;
840
862
  readonly yieldQueue: YieldQueue;
841
863
  fileSnapshotStore?: InMemorySnapshotStore;
864
+ #autoApprove: boolean;
842
865
 
843
866
  #powerAssertion: MacOSPowerAssertion | undefined;
844
867
 
@@ -931,13 +954,11 @@ export class AgentSession {
931
954
  #activeEvalExecutions = new Set<Promise<unknown>>();
932
955
  #evalExecutionDisposing = false;
933
956
 
934
- // Background-channel IRC exchanges queued while the recipient was streaming.
935
- // Drained into history (via emitExternalEvent) once the recipient becomes idle.
936
- #pendingBackgroundExchanges: CustomMessage[][] = [];
937
- #scheduledBackgroundExchangeFlush = false;
938
- // Agent identity + registry for IRC relay forwarding to the main session UI.
957
+ // Incoming IRC messages received while a turn was streaming; drained as
958
+ // non-interrupting asides at the next step boundary (see the aside provider).
959
+ #pendingIrcAsides: CustomMessage[] = [];
960
+ // Agent identity (registry id) used for IRC routing and job ownership.
939
961
  #agentId: string | undefined;
940
- #agentRegistry: AgentRegistry | undefined;
941
962
  #providerSessionId: string | undefined;
942
963
  #freshProviderSessionId: string | undefined;
943
964
  #isDisposed = false;
@@ -1118,6 +1139,7 @@ export class AgentSession {
1118
1139
  this.agent = config.agent;
1119
1140
  this.sessionManager = config.sessionManager;
1120
1141
  this.settings = config.settings;
1142
+ this.#autoApprove = config.autoApprove === true;
1121
1143
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
1122
1144
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1123
1145
  this.#parentEvalSessionId = config.parentEvalSessionId;
@@ -1133,6 +1155,7 @@ export class AgentSession {
1133
1155
  } else {
1134
1156
  this.#thinkingLevel = config.thinkingLevel;
1135
1157
  }
1158
+ this.#applyThinkingLevelToAgent(this.#thinkingLevel);
1136
1159
  this.#promptTemplates = config.promptTemplates ?? [];
1137
1160
  this.#slashCommands = config.slashCommands ?? [];
1138
1161
  this.#extensionRunner = config.extensionRunner;
@@ -1192,7 +1215,13 @@ export class AgentSession {
1192
1215
  // Background-job completions / late diagnostics are pulled into the run at
1193
1216
  // each step boundary as non-interrupting asides (see Agent.getAsideMessages),
1194
1217
  // so they reach the model between requests without waiting for a yield.
1195
- this.agent.setAsideMessageProvider(() => this.yieldQueue.drainLazy());
1218
+ this.agent.setAsideMessageProvider(() => {
1219
+ const pendingIrc = this.#pendingIrcAsides;
1220
+ this.#pendingIrcAsides = [];
1221
+ const thunks: AsideMessage[] = pendingIrc.map(record => () => record);
1222
+ thunks.push(...this.yieldQueue.drainLazy());
1223
+ return thunks;
1224
+ });
1196
1225
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
1197
1226
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1198
1227
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -1223,7 +1252,6 @@ export class AgentSession {
1223
1252
  this.#ttsrManager = config.ttsrManager;
1224
1253
  this.#obfuscator = config.obfuscator;
1225
1254
  this.#agentId = config.agentId;
1226
- this.#agentRegistry = config.agentRegistry;
1227
1255
  this.#providerSessionId = config.providerSessionId;
1228
1256
  this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1229
1257
  const event: AgentEvent = {
@@ -3079,15 +3107,28 @@ export class AgentSession {
3079
3107
  }
3080
3108
 
3081
3109
  /**
3082
- * Remove all listeners, flush pending writes, and disconnect from agent.
3083
- * Call this when completely done with the session.
3110
+ * Synchronously mark the session as disposing so new work is rejected
3111
+ * immediately: Python/eval starts throw, queued asides are dropped, and the
3112
+ * aside provider is detached. Idempotent; `dispose()` runs it first.
3113
+ *
3114
+ * Wrappers that await other teardown before delegating to `dispose()` MUST
3115
+ * call this before their first await — otherwise work started in that async
3116
+ * gap slips past the disposal guards.
3084
3117
  */
3085
- async dispose(): Promise<void> {
3118
+ beginDispose(): void {
3086
3119
  this.#isDisposed = true;
3087
- this.#pendingBackgroundExchanges = [];
3120
+ this.#pendingIrcAsides = [];
3088
3121
  this.yieldQueue.clear();
3089
3122
  this.agent.setAsideMessageProvider(undefined);
3090
3123
  this.#evalExecutionDisposing = true;
3124
+ }
3125
+
3126
+ /**
3127
+ * Remove all listeners, flush pending writes, and disconnect from agent.
3128
+ * Call this when completely done with the session.
3129
+ */
3130
+ async dispose(): Promise<void> {
3131
+ this.beginDispose();
3091
3132
  try {
3092
3133
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
3093
3134
  await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -3529,12 +3570,26 @@ export class AgentSession {
3529
3570
  * Wrap a tool with a permission-gate proxy when an ACP client is connected.
3530
3571
  * Only wraps tools whose name is in PERMISSION_REQUIRED_TOOLS and only when
3531
3572
  * the bridge exposes `requestPermission`. No-ops for all other cases.
3573
+ *
3574
+ * When the user has explicitly opted into `yolo` / auto-approve behavior (via
3575
+ * the SDK/CLI `autoApprove` flag or a configured `tools.approvalMode: yolo`),
3576
+ * skips the gate unless the per-tool policy explicitly requires a prompt or
3577
+ * deny. The schema default is also `yolo`, so an explicit configuration or
3578
+ * explicit session flag is required: default-config ACP sessions keep the
3579
+ * client-side permission gate.
3532
3580
  */
3533
3581
  #wrapToolForAcpPermission<T extends AgentTool>(tool: T): T {
3534
3582
  const bridge = this.#clientBridge;
3535
3583
  // Match the capability+method gating pattern used by read/write/bash.
3536
3584
  if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
3537
3585
  if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
3586
+ // Skip the gate only on explicit yolo opt-in; honour per-tool policies
3587
+ // that require a prompt or deny (matching the normal approval wrapper).
3588
+ if (this.#isExplicitAutoApproveMode()) {
3589
+ const userPolicies = (this.settings.get("tools.approval") ?? {}) as Record<string, unknown>;
3590
+ const toolPolicy = userPolicies[tool.name];
3591
+ if (!toolPolicy || toolPolicy === "allow") return tool;
3592
+ }
3538
3593
  return new Proxy(tool, {
3539
3594
  get: (target, prop) => {
3540
3595
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -3622,6 +3677,13 @@ export class AgentSession {
3622
3677
  }) as T;
3623
3678
  }
3624
3679
 
3680
+ #isExplicitAutoApproveMode(): boolean {
3681
+ return (
3682
+ this.#autoApprove ||
3683
+ (this.settings.isConfigured("tools.approvalMode") && this.settings.get("tools.approvalMode") === "yolo")
3684
+ );
3685
+ }
3686
+
3625
3687
  async #applyActiveToolsByName(
3626
3688
  toolNames: string[],
3627
3689
  options?: { persistMCPSelection?: boolean; previousSelectedMCPToolNames?: string[] },
@@ -3999,6 +4061,57 @@ export class AgentSession {
3999
4061
  return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
4000
4062
  }
4001
4063
 
4064
+ /**
4065
+ * Full-history transcript for TUI display: every path entry in
4066
+ * chronological order with compactions rendered inline at the point they
4067
+ * fired (instead of replacing prior history). Display-only — NEVER feed
4068
+ * the result to `agent.replaceMessages` or a provider.
4069
+ */
4070
+ buildTranscriptSessionContext(): SessionContext {
4071
+ return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
4072
+ }
4073
+
4074
+ #obfuscateForProvider<T>(value: T): T {
4075
+ if (!this.#obfuscator?.hasSecrets()) return value;
4076
+ return this.#obfuscator.obfuscateObject(value);
4077
+ }
4078
+
4079
+ #obfuscateTextForProvider(text: string | undefined): string | undefined {
4080
+ if (!text || !this.#obfuscator?.hasSecrets()) return text;
4081
+ return this.#obfuscator.obfuscate(text);
4082
+ }
4083
+
4084
+ #obfuscatePreparationForProvider(preparation: CompactionPreparation): CompactionPreparation {
4085
+ if (!this.#obfuscator?.hasSecrets()) return preparation;
4086
+ if (!preparation.previousSummary && !preparation.previousPreserveData) return preparation;
4087
+ return {
4088
+ ...preparation,
4089
+ previousSummary: preparation.previousSummary
4090
+ ? this.#obfuscator.obfuscate(preparation.previousSummary)
4091
+ : preparation.previousSummary,
4092
+ previousPreserveData: preparation.previousPreserveData
4093
+ ? this.#obfuscator.obfuscateObject(preparation.previousPreserveData)
4094
+ : preparation.previousPreserveData,
4095
+ };
4096
+ }
4097
+
4098
+ #deobfuscateFromProvider(text: string): string {
4099
+ if (!this.#obfuscator?.hasSecrets()) return text;
4100
+ return this.#obfuscator.deobfuscate(text);
4101
+ }
4102
+
4103
+ #deobfuscatedProviderTextReadyForDelta(text: string): string {
4104
+ const deobfuscated = this.#deobfuscateFromProvider(text);
4105
+ if (!this.#obfuscator?.hasSecrets()) return deobfuscated;
4106
+ const pendingPlaceholderStart = deobfuscated.match(/#[A-Z0-9]{0,4}$/);
4107
+ if (pendingPlaceholderStart?.index === undefined) return deobfuscated;
4108
+ return deobfuscated.slice(0, pendingPlaceholderStart.index);
4109
+ }
4110
+
4111
+ #convertToLlmForSideRequest(messages: AgentMessage[]): Message[] {
4112
+ return this.#obfuscateForProvider(convertToLlm(messages));
4113
+ }
4114
+
4002
4115
  /** Convert session messages using the same pre-LLM pipeline as the active session. */
4003
4116
  async convertMessagesToLlm(messages: AgentMessage[], signal?: AbortSignal): Promise<Message[]> {
4004
4117
  const transformedMessages = await this.#transformContext(messages, signal);
@@ -4398,21 +4511,28 @@ export class AgentSession {
4398
4511
  * @throws Error if streaming and no streamingBehavior specified
4399
4512
  * @throws Error if no model selected or no API key available (when not streaming)
4400
4513
  */
4401
- async prompt(text: string, options?: PromptOptions): Promise<void> {
4514
+ /**
4515
+ * Returns `false` when the command was fully handled locally (extension or
4516
+ * custom-TS command consumed without calling the LLM). Returns `true` when
4517
+ * the prompt was forwarded to the agent — either directly or queued as a
4518
+ * steer/follow-up. Callers that render a UI or manage turn lifecycle (e.g.
4519
+ * the ACP agent) use this to know whether to expect an `agent_end` event.
4520
+ */
4521
+ async prompt(text: string, options?: PromptOptions): Promise<boolean> {
4402
4522
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
4403
4523
 
4404
4524
  // Handle extension commands first (execute immediately, even during streaming)
4405
4525
  if (expandPromptTemplates && text.startsWith("/")) {
4406
4526
  const handled = await this.#tryExecuteExtensionCommand(text);
4407
4527
  if (handled) {
4408
- return;
4528
+ return false;
4409
4529
  }
4410
4530
 
4411
4531
  // Try custom commands (TypeScript slash commands)
4412
4532
  const customResult = await this.#tryExecuteCustomCommand(text);
4413
4533
  if (customResult !== null) {
4414
4534
  if (customResult === "") {
4415
- return;
4535
+ return false;
4416
4536
  }
4417
4537
  text = customResult;
4418
4538
  }
@@ -4446,7 +4566,7 @@ export class AgentSession {
4446
4566
  for (const notice of keywordNotices) {
4447
4567
  await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
4448
4568
  }
4449
- return;
4569
+ return true;
4450
4570
  }
4451
4571
 
4452
4572
  // Skip eager todo prelude when the user has already queued a directive
@@ -4486,6 +4606,7 @@ export class AgentSession {
4486
4606
  if (!options?.synthetic) {
4487
4607
  await this.#enforcePlanModeToolDecision();
4488
4608
  }
4609
+ return true;
4489
4610
  }
4490
4611
 
4491
4612
  async promptCustomMessage<T = unknown>(
@@ -4552,7 +4673,7 @@ export class AgentSession {
4552
4673
  // Flush any pending bash messages before the new prompt
4553
4674
  this.#flushPendingBashMessages();
4554
4675
  this.#flushPendingPythonMessages();
4555
- this.#flushPendingBackgroundExchanges();
4676
+ this.#flushPendingIrcAsides();
4556
4677
 
4557
4678
  // Reset todo reminder count on new user prompt
4558
4679
  this.#todoReminderCount = 0;
@@ -5694,16 +5815,25 @@ export class AgentSession {
5694
5815
  }
5695
5816
 
5696
5817
  /**
5697
- * Get all available models with valid API keys.
5818
+ * Get all available models with valid API keys, filtered by `enabledModels` when configured.
5819
+ * See {@link filterAvailableModelsByEnabledPatterns} for supported pattern forms and limitations.
5698
5820
  */
5699
5821
  getAvailableModels(): Model[] {
5700
- return this.#modelRegistry.getAvailable();
5822
+ const all = this.#modelRegistry.getAvailable();
5823
+ const patterns = this.settings.get("enabledModels");
5824
+ if (!patterns || patterns.length === 0) return all;
5825
+ return filterAvailableModelsByEnabledPatterns(all, patterns, this.#modelRegistry);
5701
5826
  }
5702
5827
 
5703
5828
  // =========================================================================
5704
5829
  // Thinking Level Management
5705
5830
  // =========================================================================
5706
5831
 
5832
+ #applyThinkingLevelToAgent(level: ThinkingLevel | undefined): void {
5833
+ this.agent.setThinkingLevel(toReasoningEffort(level));
5834
+ this.agent.setDisableReasoning(shouldDisableReasoning(level));
5835
+ }
5836
+
5707
5837
  /**
5708
5838
  * Set the thinking level. `auto` enables per-turn classification; the selector
5709
5839
  * itself is never written to the session log, but resolved concrete levels are
@@ -5717,7 +5847,7 @@ export class AgentSession {
5717
5847
  this.#autoThinking = true;
5718
5848
  this.#autoResolvedLevel = undefined;
5719
5849
  this.#thinkingLevel = provisional;
5720
- this.agent.setThinkingLevel(toReasoningEffort(provisional));
5850
+ this.#applyThinkingLevelToAgent(provisional);
5721
5851
  if (persist) {
5722
5852
  this.settings.set("defaultThinkingLevel", AUTO_THINKING);
5723
5853
  }
@@ -5733,7 +5863,7 @@ export class AgentSession {
5733
5863
  const isChanging = effectiveLevel !== this.#thinkingLevel;
5734
5864
 
5735
5865
  this.#thinkingLevel = effectiveLevel;
5736
- this.agent.setThinkingLevel(toReasoningEffort(effectiveLevel));
5866
+ this.#applyThinkingLevelToAgent(effectiveLevel);
5737
5867
 
5738
5868
  if (isChanging) {
5739
5869
  this.sessionManager.appendThinkingLevelChange(effectiveLevel);
@@ -5823,7 +5953,7 @@ export class AgentSession {
5823
5953
  const shouldPersistResolution = this.#autoResolvedLevel !== effort;
5824
5954
  this.#autoResolvedLevel = effort;
5825
5955
  this.#thinkingLevel = effort;
5826
- this.agent.setThinkingLevel(toReasoningEffort(effort));
5956
+ this.#applyThinkingLevelToAgent(effort);
5827
5957
  if (shouldPersistResolution) {
5828
5958
  this.sessionManager.appendThinkingLevelChange(effort);
5829
5959
  }
@@ -5957,6 +6087,35 @@ export class AgentSession {
5957
6087
  return result;
5958
6088
  }
5959
6089
 
6090
+ /**
6091
+ * Per-turn supersede pass: prune older `read` results that a newer read of
6092
+ * the same file has made stale. Cache-aware (only fires when the suffix
6093
+ * after a candidate is small or the session has been idle long enough that
6094
+ * the provider prompt cache is cold), so it is cheap to run every turn.
6095
+ * Gated on the `compaction.supersedeReads` setting.
6096
+ */
6097
+ async #pruneSupersededReads(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
6098
+ if (!this.settings.getGroup("compaction").supersedeReads) return undefined;
6099
+ const branchEntries = this.sessionManager.getBranch();
6100
+ const result = pruneSupersededToolResults(
6101
+ branchEntries,
6102
+ this.#withPlanProtection({
6103
+ supersedeKey: readToolSupersedeKey,
6104
+ protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
6105
+ }),
6106
+ );
6107
+ if (result.prunedCount === 0) {
6108
+ return undefined;
6109
+ }
6110
+
6111
+ await this.sessionManager.rewriteEntries();
6112
+ const sessionContext = this.buildDisplaySessionContext();
6113
+ this.agent.replaceMessages(sessionContext.messages);
6114
+ this.#syncTodoPhasesFromBranch();
6115
+ this.#closeCodexProviderSessionsForHistoryRewrite();
6116
+ return result;
6117
+ }
6118
+
5960
6119
  /**
5961
6120
  * Strip image content blocks from every message on the current branch and
5962
6121
  * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
@@ -6146,6 +6305,20 @@ export class AgentSession {
6146
6305
 
6147
6306
  const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
6148
6307
 
6308
+ // Strategy honored on manual /compact too. Custom instructions imply a
6309
+ // directed LLM summary; a text-only model cannot read the frames back —
6310
+ // both take the summarizer path (the latter loudly).
6311
+ const wantsSnapcompact =
6312
+ compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions;
6313
+ const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
6314
+ if (wantsSnapcompact && !snapcompactReady) {
6315
+ this.emitNotice(
6316
+ "warning",
6317
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`,
6318
+ "compaction",
6319
+ );
6320
+ }
6321
+
6149
6322
  let summary: string;
6150
6323
  let shortSummary: string | undefined;
6151
6324
  let firstKeptEntryId: string;
@@ -6159,6 +6332,14 @@ export class AgentSession {
6159
6332
  tokensBefore = compactionPrep.tokensBefore;
6160
6333
  details = compactionPrep.details;
6161
6334
  preserveData = compactionPrep.preserveData;
6335
+ } else if (snapcompactReady) {
6336
+ const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
6337
+ summary = snapcompactResult.summary;
6338
+ shortSummary = snapcompactResult.shortSummary;
6339
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
6340
+ tokensBefore = snapcompactResult.tokensBefore;
6341
+ details = snapcompactResult.details;
6342
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
6162
6343
  } else {
6163
6344
  // Generate compaction result. Only convert known abort-shaped
6164
6345
  // rejections (AbortError raised while the abort signal is set,
@@ -6177,10 +6358,10 @@ export class AgentSession {
6177
6358
  customInstructions,
6178
6359
  compactionAbortController.signal,
6179
6360
  {
6180
- promptOverride: compactionPrep.hookPrompt,
6181
- extraContext: compactionPrep.hookContext,
6182
- remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
6183
- convertToLlm,
6361
+ promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
6362
+ extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
6363
+ remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
6364
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
6184
6365
  },
6185
6366
  );
6186
6367
  summary = result.summary;
@@ -6363,15 +6544,15 @@ export class AgentSession {
6363
6544
  throw new Error(`No API key for ${model.provider}`);
6364
6545
  }
6365
6546
 
6366
- const handoffText = await generateHandoff(
6547
+ const rawHandoffText = await generateHandoff(
6367
6548
  this.agent.state.messages,
6368
6549
  model,
6369
6550
  apiKey,
6370
6551
  {
6371
- systemPrompt: this.#baseSystemPrompt,
6372
- tools: this.agent.state.tools,
6373
- customInstructions,
6374
- convertToLlm,
6552
+ systemPrompt: this.#obfuscateForProvider(this.#baseSystemPrompt),
6553
+ tools: obfuscateProviderTools(this.#obfuscator, this.agent.state.tools),
6554
+ customInstructions: this.#obfuscateTextForProvider(customInstructions),
6555
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
6375
6556
  initiatorOverride: "agent",
6376
6557
  metadata: this.agent.metadataForProvider(model.provider),
6377
6558
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
@@ -6383,6 +6564,7 @@ export class AgentSession {
6383
6564
  },
6384
6565
  handoffSignal,
6385
6566
  );
6567
+ const handoffText = this.#deobfuscateFromProvider(rawHandoffText);
6386
6568
 
6387
6569
  if (handoffSignal.aborted) {
6388
6570
  throw new Error("Handoff cancelled");
@@ -6577,7 +6759,10 @@ export class AgentSession {
6577
6759
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
6578
6760
  strategy: incompleteCompactionSettings.strategy,
6579
6761
  });
6580
- await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue });
6762
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer, {
6763
+ autoContinue,
6764
+ triggerContextTokens: calculateContextTokens(assistantMessage.usage),
6765
+ });
6581
6766
  } else {
6582
6767
  // Neither promotion nor compaction is available — surface the dead-end so
6583
6768
  // the user understands why the turn yielded with nothing.
@@ -6588,6 +6773,10 @@ export class AgentSession {
6588
6773
  return false;
6589
6774
  }
6590
6775
 
6776
+ // Supersede pass runs every turn, before any threshold gating: it is cheap
6777
+ // (bails when no candidate) and independent of the compaction setting.
6778
+ const supersedeResult = await this.#pruneSupersededReads();
6779
+
6591
6780
  const compactionSettings = this.settings.getGroup("compaction");
6592
6781
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
6593
6782
 
@@ -6596,6 +6785,9 @@ export class AgentSession {
6596
6785
  if (assistantMessage.stopReason === "error") return false;
6597
6786
  const pruneResult = await this.#pruneToolOutputs();
6598
6787
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6788
+ if (supersedeResult) {
6789
+ contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
6790
+ }
6599
6791
  if (pruneResult) {
6600
6792
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6601
6793
  }
@@ -6603,7 +6795,10 @@ export class AgentSession {
6603
6795
  // Try promotion first — if a larger model is available, switch instead of compacting
6604
6796
  const promoted = await this.#tryContextPromotion(assistantMessage);
6605
6797
  if (!promoted) {
6606
- return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue });
6798
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
6799
+ autoContinue,
6800
+ triggerContextTokens: contextTokens,
6801
+ });
6607
6802
  }
6608
6803
  }
6609
6804
  return false;
@@ -7344,17 +7539,24 @@ export class AgentSession {
7344
7539
  if (!apiKey) continue;
7345
7540
 
7346
7541
  try {
7347
- return await compact(preparation, candidate, apiKey, customInstructions, signal, {
7348
- ...options,
7349
- metadata: this.agent.metadataForProvider(candidate.provider),
7350
- convertToLlm,
7351
- telemetry,
7352
- // Honor the user's /model thinking selection (incl. `off`) on
7353
- // the manual `/compact` path. Clamped per-model inside compact()
7354
- // via resolveCompactionEffort so unsupported-effort models
7355
- // (xai-oauth/grok-build) don't trip requireSupportedEffort.
7356
- thinkingLevel: this.thinkingLevel,
7357
- });
7542
+ return await compact(
7543
+ this.#obfuscatePreparationForProvider(preparation),
7544
+ candidate,
7545
+ apiKey,
7546
+ this.#obfuscateTextForProvider(customInstructions),
7547
+ signal,
7548
+ {
7549
+ ...options,
7550
+ metadata: this.agent.metadataForProvider(candidate.provider),
7551
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
7552
+ telemetry,
7553
+ // Honor the user's /model thinking selection (incl. `off`) on
7554
+ // the manual `/compact` path. Clamped per-model inside compact()
7555
+ // via resolveCompactionEffort so unsupported-effort models
7556
+ // (xai-oauth/grok-build) don't trip requireSupportedEffort.
7557
+ thinkingLevel: this.thinkingLevel,
7558
+ },
7559
+ );
7358
7560
  } catch (error) {
7359
7561
  if (!this.#isCompactionAuthFailure(error)) {
7360
7562
  throw error;
@@ -7441,7 +7643,7 @@ export class AgentSession {
7441
7643
  willRetry: boolean,
7442
7644
  deferred = false,
7443
7645
  allowDefer = true,
7444
- options: { autoContinue?: boolean } = {},
7646
+ options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
7445
7647
  ): Promise<boolean> {
7446
7648
  const compactionSettings = this.settings.getGroup("compaction");
7447
7649
  if (compactionSettings.strategy === "off") return false;
@@ -7452,7 +7654,13 @@ export class AgentSession {
7452
7654
  // reclaims nothing we fall through to the summary-compaction body below so
7453
7655
  // the oversized input still gets resolved.
7454
7656
  if (compactionSettings.strategy === "shake") {
7455
- const outcome = await this.#runAutoShake(reason, willRetry, generation, shouldAutoContinue);
7657
+ const outcome = await this.#runAutoShake(
7658
+ reason,
7659
+ willRetry,
7660
+ generation,
7661
+ shouldAutoContinue,
7662
+ options.triggerContextTokens,
7663
+ );
7456
7664
  if (outcome !== "fallback") return false;
7457
7665
  }
7458
7666
  // "overflow" and "incomplete" force inline execution because they are recovery
@@ -7479,9 +7687,25 @@ export class AgentSession {
7479
7687
 
7480
7688
  // "overflow" forces context-full because the input itself is broken — a handoff
7481
7689
  // LLM call would hit the same overflow. "incomplete" is an output-side problem,
7482
- // so a handoff request on the existing context is still viable.
7483
- let action: "context-full" | "handoff" =
7690
+ // so a handoff request on the existing context is still viable. Snapcompact is
7691
+ // safe for every reason (it makes no LLM call at all) but requires a vision
7692
+ // model to be worth anything — fall back to context-full otherwise.
7693
+ let action: "context-full" | "handoff" | "snapcompact" =
7484
7694
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
7695
+ if (compactionSettings.strategy === "snapcompact") {
7696
+ if (this.model?.input.includes("image")) {
7697
+ action = "snapcompact";
7698
+ } else {
7699
+ logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", {
7700
+ model: this.model?.id,
7701
+ });
7702
+ this.emitNotice(
7703
+ "warning",
7704
+ `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`,
7705
+ "compaction",
7706
+ );
7707
+ }
7708
+ }
7485
7709
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
7486
7710
  // Abort any older auto-compaction before installing this run's controller.
7487
7711
  this.#autoCompactionAbortController?.abort();
@@ -7620,6 +7844,16 @@ export class AgentSession {
7620
7844
  tokensBefore = compactionPrep.tokensBefore;
7621
7845
  details = compactionPrep.details;
7622
7846
  preserveData = compactionPrep.preserveData;
7847
+ } else if (action === "snapcompact") {
7848
+ // Local, deterministic: render discarded history onto PNG frames.
7849
+ // No model candidates, no API key, no retry loop.
7850
+ const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
7851
+ summary = snapcompactResult.summary;
7852
+ shortSummary = snapcompactResult.shortSummary;
7853
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
7854
+ tokensBefore = snapcompactResult.tokensBefore;
7855
+ details = snapcompactResult.details;
7856
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
7623
7857
  } else {
7624
7858
  const candidates = this.#getCompactionModelCandidates(availableModels);
7625
7859
  const retrySettings = this.settings.getGroup("retry");
@@ -7634,20 +7868,27 @@ export class AgentSession {
7634
7868
  let attempt = 0;
7635
7869
  while (true) {
7636
7870
  try {
7637
- compactResult = await compact(preparation, candidate, apiKey, undefined, autoCompactionSignal, {
7638
- promptOverride: compactionPrep.hookPrompt,
7639
- extraContext: compactionPrep.hookContext,
7640
- remoteInstructions: this.#baseSystemPrompt.join("\n\n"),
7641
- metadata: this.agent.metadataForProvider(candidate.provider),
7642
- initiatorOverride: "agent",
7643
- convertToLlm,
7644
- telemetry,
7645
- // Honor the user's /model thinking selection on the
7646
- // auto-compaction path — the most-fired compaction
7647
- // site. Clamped per-model inside compact() via
7648
- // resolveCompactionEffort.
7649
- thinkingLevel: this.thinkingLevel,
7650
- });
7871
+ compactResult = await compact(
7872
+ this.#obfuscatePreparationForProvider(preparation),
7873
+ candidate,
7874
+ apiKey,
7875
+ undefined,
7876
+ autoCompactionSignal,
7877
+ {
7878
+ promptOverride: this.#obfuscateTextForProvider(compactionPrep.hookPrompt),
7879
+ extraContext: this.#obfuscateForProvider(compactionPrep.hookContext),
7880
+ remoteInstructions: this.#obfuscateForProvider(this.#baseSystemPrompt.join("\n\n")),
7881
+ metadata: this.agent.metadataForProvider(candidate.provider),
7882
+ initiatorOverride: "agent",
7883
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
7884
+ telemetry,
7885
+ // Honor the user's /model thinking selection on the
7886
+ // auto-compaction path — the most-fired compaction
7887
+ // site. Clamped per-model inside compact() via
7888
+ // resolveCompactionEffort.
7889
+ thinkingLevel: this.thinkingLevel,
7890
+ },
7891
+ );
7651
7892
  break;
7652
7893
  } catch (error) {
7653
7894
  if (autoCompactionSignal.aborted) {
@@ -7852,6 +8093,7 @@ export class AgentSession {
7852
8093
  willRetry: boolean,
7853
8094
  generation: number,
7854
8095
  autoContinue: boolean,
8096
+ triggerContextTokens?: number,
7855
8097
  ): Promise<"handled" | "fallback"> {
7856
8098
  const action = "shake";
7857
8099
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -7872,8 +8114,8 @@ export class AgentSession {
7872
8114
  return "handled";
7873
8115
  }
7874
8116
  const reclaimed = result.toolResultsDropped + result.blocksDropped > 0;
7875
- // Detect the dead-loop reported in issue #2119: the threshold check fires,
7876
- // shake runs, but the resulting context is still above the configured
8117
+ // Detect the dead-loop reported in issues #2119/#2275: the threshold check
8118
+ // fires, shake runs, but residual context is still above the configured
7877
8119
  // threshold. The next agent_end would re-trigger shake, which has nothing
7878
8120
  // new to drop on the second pass, so the loop spins until the user kills it.
7879
8121
  // Same hazard for "incomplete" (the retry would re-hit the length cap) and
@@ -7881,10 +8123,30 @@ export class AgentSession {
7881
8123
  // reason we hand off to the summarization-driven context-full path so the
7882
8124
  // situation actually resolves; "idle" is exempt because its 60s+ timer
7883
8125
  // re-checks usage before re-firing and cannot dead-loop on its own.
8126
+ //
8127
+ // #2275: the post-shake check MUST be anchored on the same metric that
8128
+ // triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
8129
+ // undercounts thinking-signature payloads, so on thinking-heavy sessions it
8130
+ // reads well below the provider-reported usage that fired the threshold.
8131
+ // When that estimate slips under the threshold, the fallback never fires
8132
+ // and the auto-continue prompt re-injects every turn. Prefer the trigger's
8133
+ // own `contextTokens` (provider-anchored) when the caller supplies it, and
8134
+ // add hysteresis (80% recovery band) so we don't oscillate at the boundary
8135
+ // while shake keeps reclaiming a trickle of the previous turn's output.
7884
8136
  const contextWindow = this.model?.contextWindow ?? 0;
7885
8137
  const compactionSettings = this.settings.getGroup("compaction");
7886
- const postShakeTokens = contextWindow > 0 ? this.#estimatePendingPromptTokens([]) : 0;
7887
- const stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
8138
+ let stillOverThreshold = false;
8139
+ if (contextWindow > 0) {
8140
+ if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
8141
+ const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
8142
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
8143
+ const recoveryBand = Math.floor(thresholdTokens * SHAKE_RECOVERY_BAND);
8144
+ stillOverThreshold = correctedTokens > recoveryBand;
8145
+ } else {
8146
+ const postShakeTokens = this.#estimatePendingPromptTokens([]);
8147
+ stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
8148
+ }
8149
+ }
7888
8150
  const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
7889
8151
  if (shouldFallBack) {
7890
8152
  const errorMessage = reclaimed
@@ -8337,6 +8599,7 @@ export class AgentSession {
8337
8599
  {
8338
8600
  retryAfterMs,
8339
8601
  baseUrl: this.model.baseUrl,
8602
+ modelId: this.model.id,
8340
8603
  },
8341
8604
  );
8342
8605
  if (outcome.switched) {
@@ -8533,11 +8796,12 @@ export class AgentSession {
8533
8796
  * @param command The bash command to execute
8534
8797
  * @param onChunk Optional streaming callback for output
8535
8798
  * @param options.excludeFromContext If true, command output won't be sent to LLM (!! prefix)
8799
+ * @param options.useUserShell If true, allow caller to request configured user-shell routing
8536
8800
  */
8537
8801
  async executeBash(
8538
8802
  command: string,
8539
8803
  onChunk?: (chunk: string) => void,
8540
- options?: { excludeFromContext?: boolean },
8804
+ options?: { excludeFromContext?: boolean; useUserShell?: boolean },
8541
8805
  ): Promise<BashResult> {
8542
8806
  const excludeFromContext = options?.excludeFromContext === true;
8543
8807
  const cwd = this.sessionManager.getCwd();
@@ -8565,6 +8829,7 @@ export class AgentSession {
8565
8829
  sessionKey: this.sessionId,
8566
8830
  timeout: clampTimeout("bash") * 1000,
8567
8831
  onMinimizedSave: originalText => this.#saveBashOriginalArtifact(originalText),
8832
+ useUserShell: options?.useUserShell,
8568
8833
  });
8569
8834
 
8570
8835
  this.recordBashResult(command, result, options);
@@ -8690,6 +8955,7 @@ export class AgentSession {
8690
8955
  sessionId: namespacePythonSessionId(sessionId),
8691
8956
  kernelOwnerId: this.#evalKernelOwnerId,
8692
8957
  kernelMode: this.settings.get("python.kernelMode"),
8958
+ interpreter: this.settings.get("python.interpreter")?.trim() || undefined,
8693
8959
  onChunk,
8694
8960
  signal: abortController.signal,
8695
8961
  });
@@ -8816,118 +9082,56 @@ export class AgentSession {
8816
9082
  }
8817
9083
 
8818
9084
  // =========================================================================
8819
- // Background-Channel IRC Exchanges
9085
+ // IRC Delivery
8820
9086
  // =========================================================================
8821
9087
 
8822
9088
  /**
8823
- * Generate an ephemeral reply to a background message (e.g. an IRC ping from
8824
- * another agent) using this session's current model + system prompt + history.
9089
+ * Deliver an IRC message into this session (recipient side; called by the
9090
+ * IrcBus). Emits the `irc_message` session event for UI cards and injects
9091
+ * the rendered message into the model's context as an `irc:incoming`
9092
+ * custom message:
9093
+ *
9094
+ * - mid-turn → queued on the aside channel and folded in at the next step
9095
+ * boundary (non-interrupting, like async-result deliveries) → "injected";
9096
+ * - idle → starts a real turn with the message so the recipient wakes
9097
+ * → "woken".
8825
9098
  *
8826
- * The incoming message is queued for injection into the recipient's persisted
8827
- * history immediately so timeouts/abort still preserve delivery. The reply is
8828
- * computed via a side-channel `streamSimple` call (analogous to `/btw`) so it
8829
- * never blocks on the recipient's in-flight tool calls. When a reply is
8830
- * generated, it is queued separately. Injection happens immediately when the
8831
- * session is idle, otherwise it is deferred until streaming ends.
9099
+ * Never blocks on the recipient's turn: the wake turn is fire-and-forget.
8832
9100
  */
8833
- async respondAsBackground(args: {
8834
- from: string;
8835
- message: string;
8836
- awaitReply?: boolean;
8837
- signal?: AbortSignal;
8838
- }): Promise<{ replyText: string | null }> {
8839
- const awaitReply = args.awaitReply !== false;
8840
- const incomingTimestamp = Date.now();
8841
- const incomingRecord: CustomMessage = {
9101
+ async deliverIrcMessage(msg: IrcMessage): Promise<"injected" | "woken"> {
9102
+ if (this.#isDisposed) {
9103
+ throw new Error("Recipient session is disposed.");
9104
+ }
9105
+ const record: CustomMessage = {
8842
9106
  role: "custom",
8843
9107
  customType: "irc:incoming",
8844
- content: `[IRC \`${args.from}\` → you]\n\n${args.message}`,
9108
+ content: prompt.render(ircIncomingTemplate, {
9109
+ from: msg.from,
9110
+ message: msg.body,
9111
+ replyTo: msg.replyTo ?? "",
9112
+ }),
8845
9113
  display: true,
8846
- details: { from: args.from, message: args.message },
9114
+ details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
8847
9115
  attribution: "agent",
8848
- timestamp: incomingTimestamp,
9116
+ timestamp: msg.ts,
8849
9117
  };
8850
- void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
8851
- this.#forwardIrcRelayToMain({
8852
- from: args.from,
8853
- to: this.#agentId ?? "?",
8854
- body: args.message,
8855
- kind: "message",
8856
- timestamp: incomingTimestamp,
8857
- });
8858
-
8859
- this.#queueBackgroundExchangeInjection([incomingRecord]);
8860
- if (!awaitReply) {
8861
- return { replyText: null };
9118
+ void this.#emitSessionEvent({ type: "irc_message", message: record });
9119
+ if (this.isStreaming) {
9120
+ this.#pendingIrcAsides.push(record);
9121
+ return "injected";
8862
9122
  }
8863
-
8864
- const incomingPrompt = prompt.render(ircIncomingTemplate, {
8865
- from: args.from,
8866
- message: args.message,
9123
+ // Idle: same wake primitive the yield queue uses for async-result
9124
+ // delivery prompt the agent directly so a real turn runs.
9125
+ this.agent.prompt(record).catch(error => {
9126
+ logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) });
8867
9127
  });
8868
- const { replyText } = await this.runEphemeralTurn({
8869
- promptText: incomingPrompt,
8870
- signal: args.signal,
8871
- });
8872
-
8873
- const replyRecord: CustomMessage = {
8874
- role: "custom",
8875
- customType: "irc:autoreply",
8876
- content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
8877
- display: true,
8878
- details: { to: args.from, reply: replyText },
8879
- attribution: "agent",
8880
- timestamp: Date.now(),
8881
- };
8882
- void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
8883
- this.#forwardIrcRelayToMain({
8884
- from: this.#agentId ?? "?",
8885
- to: args.from,
8886
- body: replyText,
8887
- kind: "reply",
8888
- timestamp: replyRecord.timestamp,
8889
- });
8890
- this.#queueBackgroundExchangeInjection([replyRecord]);
8891
-
8892
- return { replyText };
8893
- }
8894
-
8895
- /**
8896
- * Forward an IRC exchange observation to the main agent's session UI so the
8897
- * user can see every IRC conversation in the main transcript, even when the
8898
- * main agent is not a direct participant. The relay record is display-only:
8899
- * it is NOT injected into the main agent's persisted history.
8900
- */
8901
- #forwardIrcRelayToMain(args: {
8902
- from: string;
8903
- to: string;
8904
- body: string;
8905
- kind: "message" | "reply";
8906
- timestamp: number;
8907
- }): void {
8908
- const registry = this.#agentRegistry;
8909
- if (!registry) return;
8910
- // If this session is the main agent, the local emit already reached the main UI.
8911
- if (this.#agentId === MAIN_AGENT_ID) return;
8912
- const mainRef = registry.get(MAIN_AGENT_ID);
8913
- const mainSession = mainRef?.session;
8914
- if (!mainSession || mainSession === this) return;
8915
- const arrow = args.kind === "reply" ? "→ (auto)" : "→";
8916
- const relayRecord: CustomMessage = {
8917
- role: "custom",
8918
- customType: "irc:relay",
8919
- content: `[IRC \`${args.from}\` ${arrow} \`${args.to}\`]\n\n${args.body}`,
8920
- display: true,
8921
- details: { from: args.from, to: args.to, body: args.body, kind: args.kind },
8922
- attribution: "agent",
8923
- timestamp: args.timestamp,
8924
- };
8925
- mainSession.emitIrcRelayObservation(relayRecord);
9128
+ return "woken";
8926
9129
  }
8927
9130
 
8928
9131
  /**
8929
9132
  * Emit an IRC relay observation event on this session for UI rendering only.
8930
- * Does not persist the record to history. Public so other sessions can forward.
9133
+ * Does not persist the record to history. Called by the IrcBus to surface
9134
+ * agent↔agent traffic on the main session.
8931
9135
  */
8932
9136
  emitIrcRelayObservation(record: CustomMessage): void {
8933
9137
  void this.#emitSessionEvent({ type: "irc_message", message: record });
@@ -8939,7 +9143,7 @@ export class AgentSession {
8939
9143
  * does not block on, or interfere with, any in-flight main turn. The
8940
9144
  * session's history and persisted state are NOT modified by this call.
8941
9145
  *
8942
- * Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
9146
+ * Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share
8943
9147
  * the snapshot + stream pipeline. The snapshot includes any in-flight
8944
9148
  * streaming assistant text so the model sees the half-finished response
8945
9149
  * rather than missing context.
@@ -8982,6 +9186,7 @@ export class AgentSession {
8982
9186
  promptCacheKey: cacheSessionId,
8983
9187
  preferWebsockets: false,
8984
9188
  reasoning: toReasoningEffort(this.thinkingLevel),
9189
+ disableReasoning: shouldDisableReasoning(this.thinkingLevel),
8985
9190
  hideThinkingSummary: this.agent.hideThinkingSummary,
8986
9191
  serviceTier: this.serviceTier,
8987
9192
  signal: args.signal,
@@ -8990,17 +9195,27 @@ export class AgentSession {
8990
9195
  model.provider,
8991
9196
  );
8992
9197
 
8993
- let replyText = "";
9198
+ let providerReplyText = "";
9199
+ let emittedReplyText = "";
8994
9200
  let assistantMessage: AssistantMessage | undefined;
8995
- const stream = streamSimple(model, context, options);
9201
+ const stream = streamSimple(model, obfuscateProviderContext(this.#obfuscator, context), options);
8996
9202
  for await (const event of stream) {
8997
9203
  if (event.type === "text_delta") {
8998
- replyText += event.delta;
8999
- if (args.onTextDelta) args.onTextDelta(event.delta);
9204
+ providerReplyText += event.delta;
9205
+ if (args.onTextDelta) {
9206
+ const readyText = this.#deobfuscatedProviderTextReadyForDelta(providerReplyText);
9207
+ if (readyText.length > emittedReplyText.length) {
9208
+ const delta = readyText.slice(emittedReplyText.length);
9209
+ emittedReplyText = readyText;
9210
+ args.onTextDelta(delta);
9211
+ }
9212
+ }
9000
9213
  continue;
9001
9214
  }
9002
9215
  if (event.type === "done") {
9003
- assistantMessage = event.message;
9216
+ assistantMessage = this.#obfuscator?.hasSecrets()
9217
+ ? { ...event.message, content: this.#obfuscator.deobfuscateObject(event.message.content) }
9218
+ : event.message;
9004
9219
  break;
9005
9220
  }
9006
9221
  if (event.type === "error") {
@@ -9011,8 +9226,12 @@ export class AgentSession {
9011
9226
  if (!assistantMessage) {
9012
9227
  throw new Error("Ephemeral turn ended without a final message");
9013
9228
  }
9229
+ const replyText = this.#deobfuscateFromProvider(providerReplyText);
9230
+ if (args.onTextDelta && replyText.length > emittedReplyText.length) {
9231
+ args.onTextDelta(replyText.slice(emittedReplyText.length));
9232
+ }
9014
9233
  return {
9015
- replyText: args.dedupeReply === false ? replyText.trim() : dedupeIrcReply(replyText.trim()),
9234
+ replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()),
9016
9235
  assistantMessage,
9017
9236
  };
9018
9237
  }
@@ -9063,46 +9282,21 @@ export class AgentSession {
9063
9282
  return messages;
9064
9283
  }
9065
9284
 
9066
- #queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
9067
- this.#pendingBackgroundExchanges.push(messages);
9068
- if (!this.isStreaming) {
9069
- this.#flushPendingBackgroundExchanges();
9070
- return;
9071
- }
9072
- this.#scheduleBackgroundExchangeFlush();
9073
- }
9074
-
9075
- #scheduleBackgroundExchangeFlush(): void {
9076
- if (this.#scheduledBackgroundExchangeFlush) return;
9077
- this.#scheduledBackgroundExchangeFlush = true;
9078
- const attempt = (): void => {
9079
- if (this.#pendingBackgroundExchanges.length === 0 || this.#isDisposed) {
9080
- this.#pendingBackgroundExchanges = [];
9081
- this.#scheduledBackgroundExchangeFlush = false;
9082
- return;
9083
- }
9084
- if (this.isStreaming) {
9085
- setTimeout(attempt, 50);
9086
- return;
9087
- }
9088
- this.#scheduledBackgroundExchangeFlush = false;
9089
- this.#flushPendingBackgroundExchanges();
9090
- };
9091
- setTimeout(attempt, 0);
9092
- }
9093
-
9094
- #flushPendingBackgroundExchanges(): void {
9095
- if (this.#pendingBackgroundExchanges.length === 0) return;
9096
- const batches = this.#pendingBackgroundExchanges;
9097
- this.#pendingBackgroundExchanges = [];
9098
- for (const batch of batches) {
9099
- for (const msg of batch) {
9100
- // emitExternalEvent on message_end appends to agent state and dispatches
9101
- // to all session listeners, which in turn handle TUI rendering and
9102
- // sessionManager persistence via #handleAgentEvent.
9103
- this.agent.emitExternalEvent({ type: "message_start", message: msg });
9104
- this.agent.emitExternalEvent({ type: "message_end", message: msg });
9105
- }
9285
+ /**
9286
+ * Persist any IRC asides that missed their step-boundary injection (the
9287
+ * message landed after the turn's last aside drain). Called at the start
9288
+ * of the next prompt so the model still sees them.
9289
+ */
9290
+ #flushPendingIrcAsides(): void {
9291
+ if (this.#pendingIrcAsides.length === 0) return;
9292
+ const records = this.#pendingIrcAsides;
9293
+ this.#pendingIrcAsides = [];
9294
+ for (const record of records) {
9295
+ // emitExternalEvent on message_end appends to agent state and dispatches
9296
+ // to all session listeners, which in turn handle TUI rendering and
9297
+ // sessionManager persistence via #handleAgentEvent.
9298
+ this.agent.emitExternalEvent({ type: "message_start", message: record });
9299
+ this.agent.emitExternalEvent({ type: "message_end", message: record });
9106
9300
  }
9107
9301
  }
9108
9302
 
@@ -9270,7 +9464,7 @@ export class AgentSession {
9270
9464
  this.#autoResolvedLevel = undefined;
9271
9465
  this.#thinkingLevel = resolveThinkingLevelForModel(this.model, restoredThinkingLevel);
9272
9466
  }
9273
- this.agent.setThinkingLevel(toReasoningEffort(this.#thinkingLevel));
9467
+ this.#applyThinkingLevelToAgent(this.#thinkingLevel);
9274
9468
  this.agent.serviceTier = hasServiceTierEntry
9275
9469
  ? sessionContext.serviceTier
9276
9470
  : configuredServiceTier === "none"
@@ -9327,7 +9521,7 @@ export class AgentSession {
9327
9521
  this.#thinkingLevel = previousThinkingLevel;
9328
9522
  this.#autoThinking = previousAutoThinking;
9329
9523
  this.#autoResolvedLevel = previousAutoResolvedLevel;
9330
- this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
9524
+ this.#applyThinkingLevelToAgent(previousThinkingLevel);
9331
9525
  this.agent.serviceTier = previousServiceTier;
9332
9526
  this.#syncTodoPhasesFromBranch();
9333
9527
  this.#reconnectToAgent();
@@ -9511,10 +9705,10 @@ export class AgentSession {
9511
9705
  model,
9512
9706
  apiKey,
9513
9707
  signal: this.#branchSummaryAbortController.signal,
9514
- customInstructions: options.customInstructions,
9708
+ customInstructions: this.#obfuscateTextForProvider(options.customInstructions),
9515
9709
  reserveTokens: branchSummarySettings.reserveTokens,
9516
9710
  metadata: this.agent.metadataForProvider(model.provider),
9517
- convertToLlm,
9711
+ convertToLlm: messages => this.#convertToLlmForSideRequest(messages),
9518
9712
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
9519
9713
  });
9520
9714
  this.#branchSummaryAbortController = undefined;
@@ -9907,69 +10101,6 @@ export class AgentSession {
9907
10101
  });
9908
10102
  }
9909
10103
 
9910
- /**
9911
- * Format the conversation as compact context for subagents.
9912
- * Includes only user messages and assistant text responses.
9913
- * Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
9914
- */
9915
- formatCompactContext(): string {
9916
- const lines: string[] = [];
9917
- lines.push("# Conversation Context");
9918
- lines.push("");
9919
- lines.push(
9920
- "This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
9921
- );
9922
- lines.push("");
9923
-
9924
- for (const msg of this.messages) {
9925
- if (msg.role === "user" || msg.role === "developer") {
9926
- lines.push(msg.role === "developer" ? "## Developer" : "## User");
9927
- lines.push("");
9928
- if (typeof msg.content === "string") {
9929
- lines.push(msg.content);
9930
- } else {
9931
- for (const c of msg.content) {
9932
- if (c.type === "text") {
9933
- lines.push(c.text);
9934
- } else if (c.type === "image") {
9935
- lines.push("[Image attached]");
9936
- }
9937
- }
9938
- }
9939
- lines.push("");
9940
- } else if (msg.role === "assistant") {
9941
- const assistantMsg = msg as AssistantMessage;
9942
- // Only include text content, skip tool calls and thinking
9943
- const textParts: string[] = [];
9944
- for (const c of assistantMsg.content) {
9945
- if (c.type === "text" && c.text.trim()) {
9946
- textParts.push(c.text);
9947
- }
9948
- }
9949
- if (textParts.length > 0) {
9950
- lines.push("## Assistant");
9951
- lines.push("");
9952
- lines.push(textParts.join("\n\n"));
9953
- lines.push("");
9954
- }
9955
- } else if (msg.role === "fileMention") {
9956
- const fileMsg = msg as FileMentionMessage;
9957
- const paths = fileMsg.files.map(f => f.path).join(", ");
9958
- lines.push(`[Files referenced: ${paths}]`);
9959
- lines.push("");
9960
- } else if (msg.role === "compactionSummary") {
9961
- const compactMsg = msg as CompactionSummaryMessage;
9962
- lines.push("## Earlier Context (Summarized)");
9963
- lines.push("");
9964
- lines.push(compactMsg.summary);
9965
- lines.push("");
9966
- }
9967
- // Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
9968
- }
9969
-
9970
- return lines.join("\n").trim();
9971
- }
9972
-
9973
10104
  // =========================================================================
9974
10105
  // Extension System
9975
10106
  // =========================================================================