@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.1

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 (158) hide show
  1. package/CHANGELOG.md +90 -4
  2. package/dist/cli.js +869 -825
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/capability/mcp.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/config/keybindings.d.ts +6 -1
  7. package/dist/types/config/settings-schema.d.ts +66 -34
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  10. package/dist/types/extensibility/shared-events.d.ts +2 -2
  11. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  12. package/dist/types/internal-urls/index.d.ts +1 -0
  13. package/dist/types/internal-urls/types.d.ts +1 -1
  14. package/dist/types/irc/bus.d.ts +66 -0
  15. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  16. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  17. package/dist/types/mcp/transports/stdio.d.ts +1 -0
  18. package/dist/types/mcp/types.d.ts +2 -0
  19. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  20. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  21. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  22. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  23. package/dist/types/modes/components/mcp-add-wizard.d.ts +2 -1
  24. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  25. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  26. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  27. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  28. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  29. package/dist/types/modes/components/welcome.d.ts +3 -9
  30. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/tool-args-reveal.d.ts +43 -0
  32. package/dist/types/modes/interactive-mode.d.ts +3 -2
  33. package/dist/types/modes/theme/theme.d.ts +3 -1
  34. package/dist/types/modes/types.d.ts +3 -2
  35. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  36. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  37. package/dist/types/registry/agent-registry.d.ts +16 -5
  38. package/dist/types/session/agent-session.d.ts +35 -30
  39. package/dist/types/session/messages.d.ts +2 -4
  40. package/dist/types/session/session-history-format.d.ts +12 -0
  41. package/dist/types/session/session-manager.d.ts +21 -3
  42. package/dist/types/session/streaming-output.d.ts +23 -0
  43. package/dist/types/task/executor.d.ts +11 -2
  44. package/dist/types/task/index.d.ts +11 -4
  45. package/dist/types/task/output-manager.d.ts +0 -7
  46. package/dist/types/task/repair-args.d.ts +8 -7
  47. package/dist/types/task/types.d.ts +55 -51
  48. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  49. package/dist/types/tools/find.d.ts +0 -11
  50. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  51. package/dist/types/tools/index.d.ts +1 -3
  52. package/dist/types/tools/irc.d.ts +76 -38
  53. package/dist/types/tools/job.d.ts +7 -1
  54. package/dist/types/tools/render-utils.d.ts +22 -0
  55. package/examples/extensions/with-deps/package.json +1 -0
  56. package/package.json +11 -10
  57. package/scripts/bundle-dist.ts +28 -19
  58. package/src/async/index.ts +0 -1
  59. package/src/capability/mcp.ts +1 -0
  60. package/src/cli/gallery-cli.ts +6 -5
  61. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  62. package/src/cli/gallery-fixtures/types.ts +5 -0
  63. package/src/cli.ts +20 -6
  64. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  65. package/src/config/keybindings.ts +6 -1
  66. package/src/config/mcp-schema.json +4 -0
  67. package/src/config/settings-schema.ts +68 -41
  68. package/src/config/settings.ts +7 -0
  69. package/src/edit/renderer.ts +96 -46
  70. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  71. package/src/eval/agent-bridge.ts +3 -16
  72. package/src/eval/js/shared/prelude.txt +1 -1
  73. package/src/eval/py/prelude.py +5 -6
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +44 -14
  76. package/src/extensibility/custom-tools/types.ts +2 -2
  77. package/src/extensibility/shared-events.ts +2 -2
  78. package/src/internal-urls/docs-index.generated.ts +9 -9
  79. package/src/internal-urls/history-protocol.ts +113 -0
  80. package/src/internal-urls/index.ts +1 -0
  81. package/src/internal-urls/router.ts +3 -1
  82. package/src/internal-urls/types.ts +1 -1
  83. package/src/irc/bus.ts +292 -0
  84. package/src/main.ts +8 -60
  85. package/src/mcp/manager.ts +3 -0
  86. package/src/mcp/oauth-discovery.ts +27 -2
  87. package/src/mcp/oauth-flow.ts +47 -1
  88. package/src/mcp/transports/stdio.ts +3 -0
  89. package/src/mcp/types.ts +2 -0
  90. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  91. package/src/modes/components/assistant-message.ts +15 -0
  92. package/src/modes/components/btw-panel.ts +5 -1
  93. package/src/modes/components/compaction-summary-message.ts +68 -32
  94. package/src/modes/components/custom-editor.ts +10 -0
  95. package/src/modes/components/mcp-add-wizard.ts +13 -0
  96. package/src/modes/components/settings-selector.ts +2 -0
  97. package/src/modes/components/status-line/component.ts +22 -12
  98. package/src/modes/components/status-line/types.ts +3 -0
  99. package/src/modes/components/tool-execution.ts +31 -1
  100. package/src/modes/components/transcript-container.ts +99 -18
  101. package/src/modes/components/tree-selector.ts +6 -1
  102. package/src/modes/components/ttsr-notification.ts +72 -30
  103. package/src/modes/components/welcome.ts +9 -33
  104. package/src/modes/controllers/event-controller.ts +93 -4
  105. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  106. package/src/modes/controllers/input-controller.ts +18 -2
  107. package/src/modes/controllers/mcp-command-controller.ts +34 -2
  108. package/src/modes/controllers/selector-controller.ts +25 -17
  109. package/src/modes/controllers/tool-args-reveal.ts +174 -0
  110. package/src/modes/interactive-mode.ts +17 -15
  111. package/src/modes/theme/theme.ts +24 -5
  112. package/src/modes/types.ts +3 -5
  113. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  114. package/src/modes/utils/ui-helpers.ts +51 -49
  115. package/src/prompts/system/irc-incoming.md +3 -4
  116. package/src/prompts/system/orchestrate-notice.md +2 -2
  117. package/src/prompts/system/subagent-system-prompt.md +0 -5
  118. package/src/prompts/system/system-prompt.md +1 -0
  119. package/src/prompts/system/workflow-notice.md +2 -2
  120. package/src/prompts/tools/eval.md +3 -3
  121. package/src/prompts/tools/irc.md +29 -19
  122. package/src/prompts/tools/read.md +2 -2
  123. package/src/prompts/tools/task-summary.md +5 -16
  124. package/src/prompts/tools/task.md +43 -29
  125. package/src/registry/agent-lifecycle.ts +218 -0
  126. package/src/registry/agent-registry.ts +16 -5
  127. package/src/sdk.ts +29 -9
  128. package/src/session/agent-session.ts +268 -241
  129. package/src/session/messages.ts +11 -78
  130. package/src/session/session-history-format.ts +246 -0
  131. package/src/session/session-manager.ts +59 -5
  132. package/src/session/streaming-output.ts +60 -0
  133. package/src/task/executor.ts +855 -466
  134. package/src/task/index.ts +723 -794
  135. package/src/task/output-manager.ts +0 -11
  136. package/src/task/render.ts +142 -66
  137. package/src/task/repair-args.ts +21 -9
  138. package/src/task/types.ts +73 -66
  139. package/src/tools/ask.ts +4 -2
  140. package/src/tools/bash.ts +15 -5
  141. package/src/tools/browser/tab-worker.ts +26 -7
  142. package/src/tools/browser.ts +28 -1
  143. package/src/tools/find.ts +2 -27
  144. package/src/tools/grouped-file-output.ts +1 -118
  145. package/src/tools/index.ts +4 -12
  146. package/src/tools/irc.ts +596 -171
  147. package/src/tools/job.ts +41 -7
  148. package/src/tools/read.ts +57 -1
  149. package/src/tools/render-utils.ts +56 -0
  150. package/src/tools/renderers.ts +2 -0
  151. package/src/tools/resolve.ts +4 -1
  152. package/src/tools/write.ts +65 -47
  153. package/src/web/search/providers/anthropic.ts +29 -4
  154. package/dist/types/async/support.d.ts +0 -2
  155. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  156. package/dist/types/task/simple-mode.d.ts +0 -8
  157. package/src/async/support.ts +0 -5
  158. 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";
@@ -163,6 +171,7 @@ import { GoalRuntime } from "../goals/runtime";
163
171
  import type { Goal, GoalModeState } from "../goals/state";
164
172
  import type { HindsightSessionState } from "../hindsight/state";
165
173
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
174
+ import type { IrcMessage } from "../irc/bus";
166
175
  import { resolveMemoryBackend } from "../memory-backend";
167
176
  import { getMnemopiSessionState, type MnemopiSessionState, setMnemopiSessionState } from "../mnemopi/state";
168
177
  import { containsOrchestrate, ORCHESTRATE_NOTICE } from "../modes/orchestrate";
@@ -184,7 +193,6 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
184
193
  };
185
194
  import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
186
195
  import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
187
- import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
188
196
  import {
189
197
  deobfuscateSessionContext,
190
198
  obfuscateProviderContext,
@@ -230,10 +238,8 @@ import type { AuthStorage } from "./auth-storage";
230
238
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
231
239
  import {
232
240
  type BashExecutionMessage,
233
- type CompactionSummaryMessage,
234
241
  type CustomMessage,
235
242
  convertToLlm,
236
- type FileMentionMessage,
237
243
  type PythonExecutionMessage,
238
244
  readPendingDisplayTag,
239
245
  SILENT_ABORT_MARKER,
@@ -259,11 +265,11 @@ export type AgentSessionEvent =
259
265
  | {
260
266
  type: "auto_compaction_start";
261
267
  reason: "threshold" | "overflow" | "idle" | "incomplete";
262
- action: "context-full" | "handoff" | "shake";
268
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
263
269
  }
264
270
  | {
265
271
  type: "auto_compaction_end";
266
- action: "context-full" | "handoff" | "shake";
272
+ action: "context-full" | "handoff" | "shake" | "snapcompact";
267
273
  result: CompactionResult | undefined;
268
274
  aborted: boolean;
269
275
  willRetry: boolean;
@@ -297,6 +303,15 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
297
303
  const EMPTY_STOP_MAX_RETRIES = 3;
298
304
  const RETRY_BACKOFF_MAX_DELAY_MS = 8_000;
299
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;
300
315
 
301
316
  function calculateRetryBackoffDelayMs(baseDelayMs: number, attempt: number): number {
302
317
  const cappedDelayMs = Math.min(Math.max(0, baseDelayMs) * 2 ** Math.max(0, attempt - 1), RETRY_BACKOFF_MAX_DELAY_MS);
@@ -413,8 +428,6 @@ export interface AgentSessionConfig {
413
428
  asyncJobManager?: AsyncJobManager;
414
429
  /** Agent identity (registry id like "Main" or "Alice") used for IRC routing. */
415
430
  agentId?: string;
416
- /** Shared agent registry (for forwarding IRC observations to the main session UI). */
417
- agentRegistry?: AgentRegistry;
418
431
  /**
419
432
  * Override the provider-facing session ID for all API requests from this session.
420
433
  * When absent, `sessionManager.getSessionId()` is used. Needed when benchmark or
@@ -533,6 +546,7 @@ interface ActiveRetryFallbackState {
533
546
  originalSelector: string;
534
547
  originalThinkingLevel: ConfiguredThinkingLevel | undefined;
535
548
  lastAppliedFallbackThinkingLevel: ConfiguredThinkingLevel | undefined;
549
+ pinned: boolean;
536
550
  }
537
551
 
538
552
  function parseRetryFallbackSelector(selector: string): RetryFallbackSelector | undefined {
@@ -557,15 +571,15 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
557
571
  return `${selector.provider}/${selector.id}`;
558
572
  }
559
573
 
560
- const IRC_REPLY_MAX_BYTES = 4096;
574
+ const EPHEMERAL_REPLY_MAX_BYTES = 4096;
561
575
 
562
576
  /**
563
- * Collapse degenerate IRC ephemeral replies before they hit the relay.
577
+ * Collapse degenerate ephemeral replies (/btw, /omfg side-channel turns).
564
578
  * Models occasionally loop on a single line (~16 reports of N-times-repeated
565
579
  * replies); compress runs longer than 3 down to one instance + `[…N×]`, then
566
580
  * cap at 4 KiB so a runaway reply can't flood the channel.
567
581
  */
568
- function dedupeIrcReply(text: string): string {
582
+ function dedupeEphemeralReply(text: string): string {
569
583
  if (!text) return text;
570
584
  const lines = text.split("\n");
571
585
  const out: string[] = [];
@@ -582,11 +596,11 @@ function dedupeIrcReply(text: string): string {
582
596
  i = j;
583
597
  }
584
598
  let result = out.join("\n");
585
- if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
599
+ if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) {
586
600
  // Trim by characters until we're under the byte budget — handles multi-byte
587
601
  // glyphs at the boundary without splitting them.
588
602
  const suffix = "\n[…truncated]";
589
- const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
603
+ const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
590
604
  while (Buffer.byteLength(result, "utf8") > budget) {
591
605
  result = result.slice(0, -1);
592
606
  }
@@ -941,13 +955,11 @@ export class AgentSession {
941
955
  #activeEvalExecutions = new Set<Promise<unknown>>();
942
956
  #evalExecutionDisposing = false;
943
957
 
944
- // Background-channel IRC exchanges queued while the recipient was streaming.
945
- // Drained into history (via emitExternalEvent) once the recipient becomes idle.
946
- #pendingBackgroundExchanges: CustomMessage[][] = [];
947
- #scheduledBackgroundExchangeFlush = false;
948
- // Agent identity + registry for IRC relay forwarding to the main session UI.
958
+ // Incoming IRC messages received while a turn was streaming; drained as
959
+ // non-interrupting asides at the next step boundary (see the aside provider).
960
+ #pendingIrcAsides: CustomMessage[] = [];
961
+ // Agent identity (registry id) used for IRC routing and job ownership.
949
962
  #agentId: string | undefined;
950
- #agentRegistry: AgentRegistry | undefined;
951
963
  #providerSessionId: string | undefined;
952
964
  #freshProviderSessionId: string | undefined;
953
965
  #isDisposed = false;
@@ -1204,7 +1216,13 @@ export class AgentSession {
1204
1216
  // Background-job completions / late diagnostics are pulled into the run at
1205
1217
  // each step boundary as non-interrupting asides (see Agent.getAsideMessages),
1206
1218
  // so they reach the model between requests without waiting for a yield.
1207
- this.agent.setAsideMessageProvider(() => this.yieldQueue.drainLazy());
1219
+ this.agent.setAsideMessageProvider(() => {
1220
+ const pendingIrc = this.#pendingIrcAsides;
1221
+ this.#pendingIrcAsides = [];
1222
+ const thunks: AsideMessage[] = pendingIrc.map(record => () => record);
1223
+ thunks.push(...this.yieldQueue.drainLazy());
1224
+ return thunks;
1225
+ });
1208
1226
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
1209
1227
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1210
1228
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -1235,7 +1253,6 @@ export class AgentSession {
1235
1253
  this.#ttsrManager = config.ttsrManager;
1236
1254
  this.#obfuscator = config.obfuscator;
1237
1255
  this.#agentId = config.agentId;
1238
- this.#agentRegistry = config.agentRegistry;
1239
1256
  this.#providerSessionId = config.providerSessionId;
1240
1257
  this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1241
1258
  const event: AgentEvent = {
@@ -3091,15 +3108,28 @@ export class AgentSession {
3091
3108
  }
3092
3109
 
3093
3110
  /**
3094
- * Remove all listeners, flush pending writes, and disconnect from agent.
3095
- * Call this when completely done with the session.
3111
+ * Synchronously mark the session as disposing so new work is rejected
3112
+ * immediately: Python/eval starts throw, queued asides are dropped, and the
3113
+ * aside provider is detached. Idempotent; `dispose()` runs it first.
3114
+ *
3115
+ * Wrappers that await other teardown before delegating to `dispose()` MUST
3116
+ * call this before their first await — otherwise work started in that async
3117
+ * gap slips past the disposal guards.
3096
3118
  */
3097
- async dispose(): Promise<void> {
3119
+ beginDispose(): void {
3098
3120
  this.#isDisposed = true;
3099
- this.#pendingBackgroundExchanges = [];
3121
+ this.#pendingIrcAsides = [];
3100
3122
  this.yieldQueue.clear();
3101
3123
  this.agent.setAsideMessageProvider(undefined);
3102
3124
  this.#evalExecutionDisposing = true;
3125
+ }
3126
+
3127
+ /**
3128
+ * Remove all listeners, flush pending writes, and disconnect from agent.
3129
+ * Call this when completely done with the session.
3130
+ */
3131
+ async dispose(): Promise<void> {
3132
+ this.beginDispose();
3103
3133
  try {
3104
3134
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
3105
3135
  await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -4032,6 +4062,16 @@ export class AgentSession {
4032
4062
  return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
4033
4063
  }
4034
4064
 
4065
+ /**
4066
+ * Full-history transcript for TUI display: every path entry in
4067
+ * chronological order with compactions rendered inline at the point they
4068
+ * fired (instead of replacing prior history). Display-only — NEVER feed
4069
+ * the result to `agent.replaceMessages` or a provider.
4070
+ */
4071
+ buildTranscriptSessionContext(): SessionContext {
4072
+ return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator);
4073
+ }
4074
+
4035
4075
  #obfuscateForProvider<T>(value: T): T {
4036
4076
  if (!this.#obfuscator?.hasSecrets()) return value;
4037
4077
  return this.#obfuscator.obfuscateObject(value);
@@ -4634,7 +4674,7 @@ export class AgentSession {
4634
4674
  // Flush any pending bash messages before the new prompt
4635
4675
  this.#flushPendingBashMessages();
4636
4676
  this.#flushPendingPythonMessages();
4637
- this.#flushPendingBackgroundExchanges();
4677
+ this.#flushPendingIrcAsides();
4638
4678
 
4639
4679
  // Reset todo reminder count on new user prompt
4640
4680
  this.#todoReminderCount = 0;
@@ -6048,6 +6088,35 @@ export class AgentSession {
6048
6088
  return result;
6049
6089
  }
6050
6090
 
6091
+ /**
6092
+ * Per-turn supersede pass: prune older `read` results that a newer read of
6093
+ * the same file has made stale. Cache-aware (only fires when the suffix
6094
+ * after a candidate is small or the session has been idle long enough that
6095
+ * the provider prompt cache is cold), so it is cheap to run every turn.
6096
+ * Gated on the `compaction.supersedeReads` setting.
6097
+ */
6098
+ async #pruneSupersededReads(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
6099
+ if (!this.settings.getGroup("compaction").supersedeReads) return undefined;
6100
+ const branchEntries = this.sessionManager.getBranch();
6101
+ const result = pruneSupersededToolResults(
6102
+ branchEntries,
6103
+ this.#withPlanProtection({
6104
+ supersedeKey: readToolSupersedeKey,
6105
+ protectedTools: [...DEFAULT_PRUNE_CONFIG.protectedTools],
6106
+ }),
6107
+ );
6108
+ if (result.prunedCount === 0) {
6109
+ return undefined;
6110
+ }
6111
+
6112
+ await this.sessionManager.rewriteEntries();
6113
+ const sessionContext = this.buildDisplaySessionContext();
6114
+ this.agent.replaceMessages(sessionContext.messages);
6115
+ this.#syncTodoPhasesFromBranch();
6116
+ this.#closeCodexProviderSessionsForHistoryRewrite();
6117
+ return result;
6118
+ }
6119
+
6051
6120
  /**
6052
6121
  * Strip image content blocks from every message on the current branch and
6053
6122
  * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
@@ -6237,6 +6306,20 @@ export class AgentSession {
6237
6306
 
6238
6307
  const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
6239
6308
 
6309
+ // Strategy honored on manual /compact too. Custom instructions imply a
6310
+ // directed LLM summary; a text-only model cannot read the frames back —
6311
+ // both take the summarizer path (the latter loudly).
6312
+ const wantsSnapcompact =
6313
+ compactionPrep.kind !== "fromHook" && compactionSettings.strategy === "snapcompact" && !customInstructions;
6314
+ const snapcompactReady = wantsSnapcompact && this.model.input.includes("image");
6315
+ if (wantsSnapcompact && !snapcompactReady) {
6316
+ this.emitNotice(
6317
+ "warning",
6318
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only) — using an LLM summary instead`,
6319
+ "compaction",
6320
+ );
6321
+ }
6322
+
6240
6323
  let summary: string;
6241
6324
  let shortSummary: string | undefined;
6242
6325
  let firstKeptEntryId: string;
@@ -6250,6 +6333,14 @@ export class AgentSession {
6250
6333
  tokensBefore = compactionPrep.tokensBefore;
6251
6334
  details = compactionPrep.details;
6252
6335
  preserveData = compactionPrep.preserveData;
6336
+ } else if (snapcompactReady) {
6337
+ const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
6338
+ summary = snapcompactResult.summary;
6339
+ shortSummary = snapcompactResult.shortSummary;
6340
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
6341
+ tokensBefore = snapcompactResult.tokensBefore;
6342
+ details = snapcompactResult.details;
6343
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
6253
6344
  } else {
6254
6345
  // Generate compaction result. Only convert known abort-shaped
6255
6346
  // rejections (AbortError raised while the abort signal is set,
@@ -6669,7 +6760,10 @@ export class AgentSession {
6669
6760
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
6670
6761
  strategy: incompleteCompactionSettings.strategy,
6671
6762
  });
6672
- await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue });
6763
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer, {
6764
+ autoContinue,
6765
+ triggerContextTokens: calculateContextTokens(assistantMessage.usage),
6766
+ });
6673
6767
  } else {
6674
6768
  // Neither promotion nor compaction is available — surface the dead-end so
6675
6769
  // the user understands why the turn yielded with nothing.
@@ -6680,6 +6774,10 @@ export class AgentSession {
6680
6774
  return false;
6681
6775
  }
6682
6776
 
6777
+ // Supersede pass runs every turn, before any threshold gating: it is cheap
6778
+ // (bails when no candidate) and independent of the compaction setting.
6779
+ const supersedeResult = await this.#pruneSupersededReads();
6780
+
6683
6781
  const compactionSettings = this.settings.getGroup("compaction");
6684
6782
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
6685
6783
 
@@ -6688,6 +6786,9 @@ export class AgentSession {
6688
6786
  if (assistantMessage.stopReason === "error") return false;
6689
6787
  const pruneResult = await this.#pruneToolOutputs();
6690
6788
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6789
+ if (supersedeResult) {
6790
+ contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
6791
+ }
6691
6792
  if (pruneResult) {
6692
6793
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6693
6794
  }
@@ -6695,7 +6796,10 @@ export class AgentSession {
6695
6796
  // Try promotion first — if a larger model is available, switch instead of compacting
6696
6797
  const promoted = await this.#tryContextPromotion(assistantMessage);
6697
6798
  if (!promoted) {
6698
- return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue });
6799
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
6800
+ autoContinue,
6801
+ triggerContextTokens: contextTokens,
6802
+ });
6699
6803
  }
6700
6804
  }
6701
6805
  return false;
@@ -7540,7 +7644,7 @@ export class AgentSession {
7540
7644
  willRetry: boolean,
7541
7645
  deferred = false,
7542
7646
  allowDefer = true,
7543
- options: { autoContinue?: boolean } = {},
7647
+ options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
7544
7648
  ): Promise<boolean> {
7545
7649
  const compactionSettings = this.settings.getGroup("compaction");
7546
7650
  if (compactionSettings.strategy === "off") return false;
@@ -7551,7 +7655,13 @@ export class AgentSession {
7551
7655
  // reclaims nothing we fall through to the summary-compaction body below so
7552
7656
  // the oversized input still gets resolved.
7553
7657
  if (compactionSettings.strategy === "shake") {
7554
- const outcome = await this.#runAutoShake(reason, willRetry, generation, shouldAutoContinue);
7658
+ const outcome = await this.#runAutoShake(
7659
+ reason,
7660
+ willRetry,
7661
+ generation,
7662
+ shouldAutoContinue,
7663
+ options.triggerContextTokens,
7664
+ );
7555
7665
  if (outcome !== "fallback") return false;
7556
7666
  }
7557
7667
  // "overflow" and "incomplete" force inline execution because they are recovery
@@ -7578,9 +7688,25 @@ export class AgentSession {
7578
7688
 
7579
7689
  // "overflow" forces context-full because the input itself is broken — a handoff
7580
7690
  // LLM call would hit the same overflow. "incomplete" is an output-side problem,
7581
- // so a handoff request on the existing context is still viable.
7582
- let action: "context-full" | "handoff" =
7691
+ // so a handoff request on the existing context is still viable. Snapcompact is
7692
+ // safe for every reason (it makes no LLM call at all) but requires a vision
7693
+ // model to be worth anything — fall back to context-full otherwise.
7694
+ let action: "context-full" | "handoff" | "snapcompact" =
7583
7695
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
7696
+ if (compactionSettings.strategy === "snapcompact") {
7697
+ if (this.model?.input.includes("image")) {
7698
+ action = "snapcompact";
7699
+ } else {
7700
+ logger.warn("Snapcompact compaction requires a vision-capable model; falling back to context-full", {
7701
+ model: this.model?.id,
7702
+ });
7703
+ this.emitNotice(
7704
+ "warning",
7705
+ `snapcompact needs a vision-capable model (${this.model?.id ?? "unknown"} is text-only) — using an LLM summary instead`,
7706
+ "compaction",
7707
+ );
7708
+ }
7709
+ }
7584
7710
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
7585
7711
  // Abort any older auto-compaction before installing this run's controller.
7586
7712
  this.#autoCompactionAbortController?.abort();
@@ -7719,6 +7845,16 @@ export class AgentSession {
7719
7845
  tokensBefore = compactionPrep.tokensBefore;
7720
7846
  details = compactionPrep.details;
7721
7847
  preserveData = compactionPrep.preserveData;
7848
+ } else if (action === "snapcompact") {
7849
+ // Local, deterministic: render discarded history onto PNG frames.
7850
+ // No model candidates, no API key, no retry loop.
7851
+ const snapcompactResult = await snapcompactCompact(preparation, { convertToLlm, model: this.model });
7852
+ summary = snapcompactResult.summary;
7853
+ shortSummary = snapcompactResult.shortSummary;
7854
+ firstKeptEntryId = snapcompactResult.firstKeptEntryId;
7855
+ tokensBefore = snapcompactResult.tokensBefore;
7856
+ details = snapcompactResult.details;
7857
+ preserveData = { ...(compactionPrep.preserveData ?? {}), ...(snapcompactResult.preserveData ?? {}) };
7722
7858
  } else {
7723
7859
  const candidates = this.#getCompactionModelCandidates(availableModels);
7724
7860
  const retrySettings = this.settings.getGroup("retry");
@@ -7958,6 +8094,7 @@ export class AgentSession {
7958
8094
  willRetry: boolean,
7959
8095
  generation: number,
7960
8096
  autoContinue: boolean,
8097
+ triggerContextTokens?: number,
7961
8098
  ): Promise<"handled" | "fallback"> {
7962
8099
  const action = "shake";
7963
8100
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -7978,8 +8115,8 @@ export class AgentSession {
7978
8115
  return "handled";
7979
8116
  }
7980
8117
  const reclaimed = result.toolResultsDropped + result.blocksDropped > 0;
7981
- // Detect the dead-loop reported in issue #2119: the threshold check fires,
7982
- // shake runs, but the resulting context is still above the configured
8118
+ // Detect the dead-loop reported in issues #2119/#2275: the threshold check
8119
+ // fires, shake runs, but residual context is still above the configured
7983
8120
  // threshold. The next agent_end would re-trigger shake, which has nothing
7984
8121
  // new to drop on the second pass, so the loop spins until the user kills it.
7985
8122
  // Same hazard for "incomplete" (the retry would re-hit the length cap) and
@@ -7987,10 +8124,30 @@ export class AgentSession {
7987
8124
  // reason we hand off to the summarization-driven context-full path so the
7988
8125
  // situation actually resolves; "idle" is exempt because its 60s+ timer
7989
8126
  // re-checks usage before re-firing and cannot dead-loop on its own.
8127
+ //
8128
+ // #2275: the post-shake check MUST be anchored on the same metric that
8129
+ // triggered compaction. The local estimator (`#estimatePendingPromptTokens`)
8130
+ // undercounts thinking-signature payloads, so on thinking-heavy sessions it
8131
+ // reads well below the provider-reported usage that fired the threshold.
8132
+ // When that estimate slips under the threshold, the fallback never fires
8133
+ // and the auto-continue prompt re-injects every turn. Prefer the trigger's
8134
+ // own `contextTokens` (provider-anchored) when the caller supplies it, and
8135
+ // add hysteresis (80% recovery band) so we don't oscillate at the boundary
8136
+ // while shake keeps reclaiming a trickle of the previous turn's output.
7990
8137
  const contextWindow = this.model?.contextWindow ?? 0;
7991
8138
  const compactionSettings = this.settings.getGroup("compaction");
7992
- const postShakeTokens = contextWindow > 0 ? this.#estimatePendingPromptTokens([]) : 0;
7993
- const stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
8139
+ let stillOverThreshold = false;
8140
+ if (contextWindow > 0) {
8141
+ if (typeof triggerContextTokens === "number" && Number.isFinite(triggerContextTokens)) {
8142
+ const correctedTokens = Math.max(0, triggerContextTokens - result.tokensFreed);
8143
+ const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
8144
+ const recoveryBand = Math.floor(thresholdTokens * SHAKE_RECOVERY_BAND);
8145
+ stillOverThreshold = correctedTokens > recoveryBand;
8146
+ } else {
8147
+ const postShakeTokens = this.#estimatePendingPromptTokens([]);
8148
+ stillOverThreshold = shouldCompact(postShakeTokens, contextWindow, compactionSettings);
8149
+ }
8150
+ }
7994
8151
  const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
7995
8152
  if (shouldFallBack) {
7996
8153
  const errorMessage = reclaimed
@@ -8101,10 +8258,18 @@ export class AgentSession {
8101
8258
  const contextWindow = this.model?.contextWindow ?? 0;
8102
8259
  if (isContextOverflow(message, contextWindow)) return false;
8103
8260
 
8261
+ if (this.#isClassifierRefusal(message)) return true;
8262
+
8104
8263
  const err = message.errorMessage;
8105
8264
  return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
8106
8265
  }
8107
8266
 
8267
+ #isClassifierRefusal(message: AssistantMessage): boolean {
8268
+ if (message.stopReason !== "error") return false;
8269
+ const stopType = message.stopDetails?.type;
8270
+ return stopType === "refusal" || stopType === "sensitive";
8271
+ }
8272
+
8108
8273
  #isTransientErrorMessage(errorMessage: string): boolean {
8109
8274
  return (
8110
8275
  this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
@@ -8248,6 +8413,7 @@ export class AgentSession {
8248
8413
  role: string,
8249
8414
  selector: RetryFallbackSelector,
8250
8415
  currentSelector: string,
8416
+ options?: { pinFallback?: boolean },
8251
8417
  ): Promise<void> {
8252
8418
  const candidate = this.#modelRegistry.find(selector.provider, selector.id);
8253
8419
  if (!candidate) {
@@ -8273,9 +8439,11 @@ export class AgentSession {
8273
8439
  originalSelector: currentSelector,
8274
8440
  originalThinkingLevel: currentThinkingLevel,
8275
8441
  lastAppliedFallbackThinkingLevel: nextThinkingLevel,
8442
+ pinned: options?.pinFallback === true,
8276
8443
  };
8277
8444
  } else {
8278
8445
  this.#activeRetryFallback.lastAppliedFallbackThinkingLevel = nextThinkingLevel;
8446
+ this.#activeRetryFallback.pinned = this.#activeRetryFallback.pinned || options?.pinFallback === true;
8279
8447
  }
8280
8448
  await this.#emitSessionEvent({
8281
8449
  type: "retry_fallback_applied",
@@ -8285,7 +8453,7 @@ export class AgentSession {
8285
8453
  });
8286
8454
  }
8287
8455
 
8288
- async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
8456
+ async #tryRetryModelFallback(currentSelector: string, options?: { pinFallback?: boolean }): Promise<boolean> {
8289
8457
  const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
8290
8458
  if (!role) return false;
8291
8459
 
@@ -8295,7 +8463,7 @@ export class AgentSession {
8295
8463
  if (!candidate) continue;
8296
8464
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
8297
8465
  if (!apiKey) continue;
8298
- await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
8466
+ await this.#applyRetryFallbackCandidate(role, selector, currentSelector, options);
8299
8467
  return true;
8300
8468
  }
8301
8469
 
@@ -8304,6 +8472,7 @@ export class AgentSession {
8304
8472
 
8305
8473
  async #maybeRestoreRetryFallbackPrimary(): Promise<void> {
8306
8474
  if (!this.#activeRetryFallback) return;
8475
+ if (this.#activeRetryFallback.pinned) return;
8307
8476
  if (this.#getRetryFallbackRevertPolicy() !== "cooldown-expiry") return;
8308
8477
 
8309
8478
  const {
@@ -8401,6 +8570,7 @@ export class AgentSession {
8401
8570
  async #handleRetryableError(message: AssistantMessage): Promise<boolean> {
8402
8571
  const retrySettings = this.settings.getGroup("retry");
8403
8572
  if (!retrySettings.enabled) return false;
8573
+ const classifierRefusal = this.#isClassifierRefusal(message);
8404
8574
 
8405
8575
  const generation = this.#promptGeneration;
8406
8576
  this.#retryAttempt++;
@@ -8474,8 +8644,10 @@ export class AgentSession {
8474
8644
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8475
8645
  if (!switchedCredential && currentSelector) {
8476
8646
  if (retrySettings.modelFallback) {
8477
- this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8478
- switchedModel = await this.#tryRetryModelFallback(currentSelector);
8647
+ if (!classifierRefusal) {
8648
+ this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8649
+ }
8650
+ switchedModel = await this.#tryRetryModelFallback(currentSelector, { pinFallback: classifierRefusal });
8479
8651
  }
8480
8652
  if (switchedModel) {
8481
8653
  delayMs = 0;
@@ -8483,6 +8655,11 @@ export class AgentSession {
8483
8655
  delayMs = parsedRetryAfterMs;
8484
8656
  }
8485
8657
  }
8658
+ if (classifierRefusal && !switchedModel) {
8659
+ this.#retryAttempt = 0;
8660
+ this.#resolveRetry();
8661
+ return false;
8662
+ }
8486
8663
 
8487
8664
  // Fail-fast cap: if the provider asks us to wait longer than
8488
8665
  // retry.maxDelayMs and we have no fallback credential or model to
@@ -8926,118 +9103,56 @@ export class AgentSession {
8926
9103
  }
8927
9104
 
8928
9105
  // =========================================================================
8929
- // Background-Channel IRC Exchanges
9106
+ // IRC Delivery
8930
9107
  // =========================================================================
8931
9108
 
8932
9109
  /**
8933
- * Generate an ephemeral reply to a background message (e.g. an IRC ping from
8934
- * another agent) using this session's current model + system prompt + history.
9110
+ * Deliver an IRC message into this session (recipient side; called by the
9111
+ * IrcBus). Emits the `irc_message` session event for UI cards and injects
9112
+ * the rendered message into the model's context as an `irc:incoming`
9113
+ * custom message:
8935
9114
  *
8936
- * The incoming message is queued for injection into the recipient's persisted
8937
- * history immediately so timeouts/abort still preserve delivery. The reply is
8938
- * computed via a side-channel `streamSimple` call (analogous to `/btw`) so it
8939
- * never blocks on the recipient's in-flight tool calls. When a reply is
8940
- * generated, it is queued separately. Injection happens immediately when the
8941
- * session is idle, otherwise it is deferred until streaming ends.
9115
+ * - mid-turn queued on the aside channel and folded in at the next step
9116
+ * boundary (non-interrupting, like async-result deliveries) "injected";
9117
+ * - idle → starts a real turn with the message so the recipient wakes
9118
+ * "woken".
9119
+ *
9120
+ * Never blocks on the recipient's turn: the wake turn is fire-and-forget.
8942
9121
  */
8943
- async respondAsBackground(args: {
8944
- from: string;
8945
- message: string;
8946
- awaitReply?: boolean;
8947
- signal?: AbortSignal;
8948
- }): Promise<{ replyText: string | null }> {
8949
- const awaitReply = args.awaitReply !== false;
8950
- const incomingTimestamp = Date.now();
8951
- const incomingRecord: CustomMessage = {
9122
+ async deliverIrcMessage(msg: IrcMessage): Promise<"injected" | "woken"> {
9123
+ if (this.#isDisposed) {
9124
+ throw new Error("Recipient session is disposed.");
9125
+ }
9126
+ const record: CustomMessage = {
8952
9127
  role: "custom",
8953
9128
  customType: "irc:incoming",
8954
- content: `[IRC \`${args.from}\` → you]\n\n${args.message}`,
9129
+ content: prompt.render(ircIncomingTemplate, {
9130
+ from: msg.from,
9131
+ message: msg.body,
9132
+ replyTo: msg.replyTo ?? "",
9133
+ }),
8955
9134
  display: true,
8956
- details: { from: args.from, message: args.message },
9135
+ details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
8957
9136
  attribution: "agent",
8958
- timestamp: incomingTimestamp,
9137
+ timestamp: msg.ts,
8959
9138
  };
8960
- void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
8961
- this.#forwardIrcRelayToMain({
8962
- from: args.from,
8963
- to: this.#agentId ?? "?",
8964
- body: args.message,
8965
- kind: "message",
8966
- timestamp: incomingTimestamp,
8967
- });
8968
-
8969
- this.#queueBackgroundExchangeInjection([incomingRecord]);
8970
- if (!awaitReply) {
8971
- return { replyText: null };
9139
+ void this.#emitSessionEvent({ type: "irc_message", message: record });
9140
+ if (this.isStreaming) {
9141
+ this.#pendingIrcAsides.push(record);
9142
+ return "injected";
8972
9143
  }
8973
-
8974
- const incomingPrompt = prompt.render(ircIncomingTemplate, {
8975
- from: args.from,
8976
- message: args.message,
8977
- });
8978
- const { replyText } = await this.runEphemeralTurn({
8979
- promptText: incomingPrompt,
8980
- signal: args.signal,
9144
+ // Idle: same wake primitive the yield queue uses for async-result
9145
+ // delivery prompt the agent directly so a real turn runs.
9146
+ this.agent.prompt(record).catch(error => {
9147
+ logger.warn("IRC wake turn failed", { from: msg.from, to: msg.to, error: String(error) });
8981
9148
  });
8982
-
8983
- const replyRecord: CustomMessage = {
8984
- role: "custom",
8985
- customType: "irc:autoreply",
8986
- content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
8987
- display: true,
8988
- details: { to: args.from, reply: replyText },
8989
- attribution: "agent",
8990
- timestamp: Date.now(),
8991
- };
8992
- void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
8993
- this.#forwardIrcRelayToMain({
8994
- from: this.#agentId ?? "?",
8995
- to: args.from,
8996
- body: replyText,
8997
- kind: "reply",
8998
- timestamp: replyRecord.timestamp,
8999
- });
9000
- this.#queueBackgroundExchangeInjection([replyRecord]);
9001
-
9002
- return { replyText };
9003
- }
9004
-
9005
- /**
9006
- * Forward an IRC exchange observation to the main agent's session UI so the
9007
- * user can see every IRC conversation in the main transcript, even when the
9008
- * main agent is not a direct participant. The relay record is display-only:
9009
- * it is NOT injected into the main agent's persisted history.
9010
- */
9011
- #forwardIrcRelayToMain(args: {
9012
- from: string;
9013
- to: string;
9014
- body: string;
9015
- kind: "message" | "reply";
9016
- timestamp: number;
9017
- }): void {
9018
- const registry = this.#agentRegistry;
9019
- if (!registry) return;
9020
- // If this session is the main agent, the local emit already reached the main UI.
9021
- if (this.#agentId === MAIN_AGENT_ID) return;
9022
- const mainRef = registry.get(MAIN_AGENT_ID);
9023
- const mainSession = mainRef?.session;
9024
- if (!mainSession || mainSession === this) return;
9025
- const arrow = args.kind === "reply" ? "→ (auto)" : "→";
9026
- const relayRecord: CustomMessage = {
9027
- role: "custom",
9028
- customType: "irc:relay",
9029
- content: `[IRC \`${args.from}\` ${arrow} \`${args.to}\`]\n\n${args.body}`,
9030
- display: true,
9031
- details: { from: args.from, to: args.to, body: args.body, kind: args.kind },
9032
- attribution: "agent",
9033
- timestamp: args.timestamp,
9034
- };
9035
- mainSession.emitIrcRelayObservation(relayRecord);
9149
+ return "woken";
9036
9150
  }
9037
9151
 
9038
9152
  /**
9039
9153
  * Emit an IRC relay observation event on this session for UI rendering only.
9040
- * Does not persist the record to history. Public so other sessions can forward.
9154
+ * Does not persist the record to history. Called by the IrcBus to surface
9155
+ * agent↔agent traffic on the main session.
9041
9156
  */
9042
9157
  emitIrcRelayObservation(record: CustomMessage): void {
9043
9158
  void this.#emitSessionEvent({ type: "irc_message", message: record });
@@ -9049,7 +9164,7 @@ export class AgentSession {
9049
9164
  * does not block on, or interfere with, any in-flight main turn. The
9050
9165
  * session's history and persisted state are NOT modified by this call.
9051
9166
  *
9052
- * Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
9167
+ * Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share
9053
9168
  * the snapshot + stream pipeline. The snapshot includes any in-flight
9054
9169
  * streaming assistant text so the model sees the half-finished response
9055
9170
  * rather than missing context.
@@ -9137,7 +9252,7 @@ export class AgentSession {
9137
9252
  args.onTextDelta(replyText.slice(emittedReplyText.length));
9138
9253
  }
9139
9254
  return {
9140
- replyText: args.dedupeReply === false ? replyText.trim() : dedupeIrcReply(replyText.trim()),
9255
+ replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()),
9141
9256
  assistantMessage,
9142
9257
  };
9143
9258
  }
@@ -9188,46 +9303,21 @@ export class AgentSession {
9188
9303
  return messages;
9189
9304
  }
9190
9305
 
9191
- #queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
9192
- this.#pendingBackgroundExchanges.push(messages);
9193
- if (!this.isStreaming) {
9194
- this.#flushPendingBackgroundExchanges();
9195
- return;
9196
- }
9197
- this.#scheduleBackgroundExchangeFlush();
9198
- }
9199
-
9200
- #scheduleBackgroundExchangeFlush(): void {
9201
- if (this.#scheduledBackgroundExchangeFlush) return;
9202
- this.#scheduledBackgroundExchangeFlush = true;
9203
- const attempt = (): void => {
9204
- if (this.#pendingBackgroundExchanges.length === 0 || this.#isDisposed) {
9205
- this.#pendingBackgroundExchanges = [];
9206
- this.#scheduledBackgroundExchangeFlush = false;
9207
- return;
9208
- }
9209
- if (this.isStreaming) {
9210
- setTimeout(attempt, 50);
9211
- return;
9212
- }
9213
- this.#scheduledBackgroundExchangeFlush = false;
9214
- this.#flushPendingBackgroundExchanges();
9215
- };
9216
- setTimeout(attempt, 0);
9217
- }
9218
-
9219
- #flushPendingBackgroundExchanges(): void {
9220
- if (this.#pendingBackgroundExchanges.length === 0) return;
9221
- const batches = this.#pendingBackgroundExchanges;
9222
- this.#pendingBackgroundExchanges = [];
9223
- for (const batch of batches) {
9224
- for (const msg of batch) {
9225
- // emitExternalEvent on message_end appends to agent state and dispatches
9226
- // to all session listeners, which in turn handle TUI rendering and
9227
- // sessionManager persistence via #handleAgentEvent.
9228
- this.agent.emitExternalEvent({ type: "message_start", message: msg });
9229
- this.agent.emitExternalEvent({ type: "message_end", message: msg });
9230
- }
9306
+ /**
9307
+ * Persist any IRC asides that missed their step-boundary injection (the
9308
+ * message landed after the turn's last aside drain). Called at the start
9309
+ * of the next prompt so the model still sees them.
9310
+ */
9311
+ #flushPendingIrcAsides(): void {
9312
+ if (this.#pendingIrcAsides.length === 0) return;
9313
+ const records = this.#pendingIrcAsides;
9314
+ this.#pendingIrcAsides = [];
9315
+ for (const record of records) {
9316
+ // emitExternalEvent on message_end appends to agent state and dispatches
9317
+ // to all session listeners, which in turn handle TUI rendering and
9318
+ // sessionManager persistence via #handleAgentEvent.
9319
+ this.agent.emitExternalEvent({ type: "message_start", message: record });
9320
+ this.agent.emitExternalEvent({ type: "message_end", message: record });
9231
9321
  }
9232
9322
  }
9233
9323
 
@@ -10032,69 +10122,6 @@ export class AgentSession {
10032
10122
  });
10033
10123
  }
10034
10124
 
10035
- /**
10036
- * Format the conversation as compact context for subagents.
10037
- * Includes only user messages and assistant text responses.
10038
- * Excludes: system prompt, tool definitions, tool calls/results, thinking blocks.
10039
- */
10040
- formatCompactContext(): string {
10041
- const lines: string[] = [];
10042
- lines.push("# Conversation Context");
10043
- lines.push("");
10044
- lines.push(
10045
- "This is a summary of the parent conversation. Read this if you need additional context about what was discussed or decided.",
10046
- );
10047
- lines.push("");
10048
-
10049
- for (const msg of this.messages) {
10050
- if (msg.role === "user" || msg.role === "developer") {
10051
- lines.push(msg.role === "developer" ? "## Developer" : "## User");
10052
- lines.push("");
10053
- if (typeof msg.content === "string") {
10054
- lines.push(msg.content);
10055
- } else {
10056
- for (const c of msg.content) {
10057
- if (c.type === "text") {
10058
- lines.push(c.text);
10059
- } else if (c.type === "image") {
10060
- lines.push("[Image attached]");
10061
- }
10062
- }
10063
- }
10064
- lines.push("");
10065
- } else if (msg.role === "assistant") {
10066
- const assistantMsg = msg as AssistantMessage;
10067
- // Only include text content, skip tool calls and thinking
10068
- const textParts: string[] = [];
10069
- for (const c of assistantMsg.content) {
10070
- if (c.type === "text" && c.text.trim()) {
10071
- textParts.push(c.text);
10072
- }
10073
- }
10074
- if (textParts.length > 0) {
10075
- lines.push("## Assistant");
10076
- lines.push("");
10077
- lines.push(textParts.join("\n\n"));
10078
- lines.push("");
10079
- }
10080
- } else if (msg.role === "fileMention") {
10081
- const fileMsg = msg as FileMentionMessage;
10082
- const paths = fileMsg.files.map(f => f.path).join(", ");
10083
- lines.push(`[Files referenced: ${paths}]`);
10084
- lines.push("");
10085
- } else if (msg.role === "compactionSummary") {
10086
- const compactMsg = msg as CompactionSummaryMessage;
10087
- lines.push("## Earlier Context (Summarized)");
10088
- lines.push("");
10089
- lines.push(compactMsg.summary);
10090
- lines.push("");
10091
- }
10092
- // Skip: toolResult, bashExecution, pythonExecution, branchSummary, custom, hookMessage
10093
- }
10094
-
10095
- return lines.join("\n").trim();
10096
- }
10097
-
10098
10125
  // =========================================================================
10099
10126
  // Extension System
10100
10127
  // =========================================================================