@oh-my-pi/pi-coding-agent 15.10.12 → 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 (125) hide show
  1. package/CHANGELOG.md +60 -3
  2. package/dist/cli.js +841 -803
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  5. package/dist/types/config/keybindings.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +56 -33
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/types.d.ts +1 -1
  13. package/dist/types/irc/bus.d.ts +66 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  16. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  18. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  19. package/dist/types/modes/components/welcome.d.ts +3 -9
  20. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  21. package/dist/types/modes/interactive-mode.d.ts +3 -2
  22. package/dist/types/modes/theme/theme.d.ts +2 -1
  23. package/dist/types/modes/types.d.ts +3 -2
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  26. package/dist/types/registry/agent-registry.d.ts +16 -5
  27. package/dist/types/session/agent-session.d.ts +35 -30
  28. package/dist/types/session/messages.d.ts +2 -4
  29. package/dist/types/session/session-history-format.d.ts +12 -0
  30. package/dist/types/session/session-manager.d.ts +21 -3
  31. package/dist/types/session/streaming-output.d.ts +23 -0
  32. package/dist/types/task/executor.d.ts +11 -2
  33. package/dist/types/task/index.d.ts +11 -4
  34. package/dist/types/task/output-manager.d.ts +0 -7
  35. package/dist/types/task/repair-args.d.ts +8 -7
  36. package/dist/types/task/types.d.ts +55 -51
  37. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  38. package/dist/types/tools/find.d.ts +0 -11
  39. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  40. package/dist/types/tools/index.d.ts +1 -3
  41. package/dist/types/tools/irc.d.ts +76 -38
  42. package/dist/types/tools/job.d.ts +7 -1
  43. package/examples/extensions/with-deps/package.json +1 -0
  44. package/package.json +11 -10
  45. package/scripts/bundle-dist.ts +28 -19
  46. package/src/async/index.ts +0 -1
  47. package/src/cli/gallery-cli.ts +1 -1
  48. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  49. package/src/cli/gallery-fixtures/types.ts +5 -0
  50. package/src/cli.ts +20 -6
  51. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  52. package/src/config/keybindings.ts +6 -1
  53. package/src/config/settings-schema.ts +56 -40
  54. package/src/config/settings.ts +7 -0
  55. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  56. package/src/eval/agent-bridge.ts +3 -16
  57. package/src/eval/js/shared/prelude.txt +1 -1
  58. package/src/eval/py/prelude.py +5 -6
  59. package/src/export/html/template.generated.ts +1 -1
  60. package/src/export/html/template.js +38 -13
  61. package/src/extensibility/custom-tools/types.ts +2 -2
  62. package/src/extensibility/shared-events.ts +2 -2
  63. package/src/internal-urls/docs-index.generated.ts +8 -8
  64. package/src/internal-urls/history-protocol.ts +113 -0
  65. package/src/internal-urls/index.ts +1 -0
  66. package/src/internal-urls/router.ts +3 -1
  67. package/src/internal-urls/types.ts +1 -1
  68. package/src/irc/bus.ts +292 -0
  69. package/src/main.ts +8 -60
  70. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  71. package/src/modes/components/compaction-summary-message.ts +68 -32
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/tool-execution.ts +31 -1
  74. package/src/modes/components/ttsr-notification.ts +72 -30
  75. package/src/modes/components/welcome.ts +9 -33
  76. package/src/modes/controllers/event-controller.ts +65 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  78. package/src/modes/controllers/input-controller.ts +18 -2
  79. package/src/modes/controllers/selector-controller.ts +21 -17
  80. package/src/modes/interactive-mode.ts +8 -13
  81. package/src/modes/theme/theme.ts +18 -5
  82. package/src/modes/types.ts +3 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/modes/utils/ui-helpers.ts +51 -49
  85. package/src/prompts/system/irc-incoming.md +3 -4
  86. package/src/prompts/system/orchestrate-notice.md +2 -2
  87. package/src/prompts/system/subagent-system-prompt.md +0 -5
  88. package/src/prompts/system/system-prompt.md +1 -0
  89. package/src/prompts/system/workflow-notice.md +2 -2
  90. package/src/prompts/tools/eval.md +3 -3
  91. package/src/prompts/tools/irc.md +29 -19
  92. package/src/prompts/tools/read.md +2 -2
  93. package/src/prompts/tools/task-summary.md +5 -16
  94. package/src/prompts/tools/task.md +38 -29
  95. package/src/registry/agent-lifecycle.ts +218 -0
  96. package/src/registry/agent-registry.ts +16 -5
  97. package/src/sdk.ts +29 -9
  98. package/src/session/agent-session.ts +243 -237
  99. package/src/session/messages.ts +11 -78
  100. package/src/session/session-history-format.ts +246 -0
  101. package/src/session/session-manager.ts +59 -5
  102. package/src/session/streaming-output.ts +60 -0
  103. package/src/task/executor.ts +855 -466
  104. package/src/task/index.ts +718 -794
  105. package/src/task/output-manager.ts +0 -11
  106. package/src/task/render.ts +133 -63
  107. package/src/task/repair-args.ts +21 -9
  108. package/src/task/types.ts +73 -66
  109. package/src/tools/ask.ts +4 -2
  110. package/src/tools/bash.ts +15 -5
  111. package/src/tools/browser/tab-worker.ts +26 -7
  112. package/src/tools/browser.ts +28 -1
  113. package/src/tools/find.ts +2 -27
  114. package/src/tools/grouped-file-output.ts +1 -118
  115. package/src/tools/index.ts +4 -12
  116. package/src/tools/irc.ts +596 -171
  117. package/src/tools/job.ts +41 -7
  118. package/src/tools/read.ts +57 -1
  119. package/src/tools/renderers.ts +2 -0
  120. package/src/tools/resolve.ts +4 -1
  121. package/dist/types/async/support.d.ts +0 -2
  122. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  123. package/dist/types/task/simple-mode.d.ts +0 -8
  124. package/src/async/support.ts +0 -5
  125. 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
@@ -557,15 +570,15 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
557
570
  return `${selector.provider}/${selector.id}`;
558
571
  }
559
572
 
560
- const IRC_REPLY_MAX_BYTES = 4096;
573
+ const EPHEMERAL_REPLY_MAX_BYTES = 4096;
561
574
 
562
575
  /**
563
- * Collapse degenerate IRC ephemeral replies before they hit the relay.
576
+ * Collapse degenerate ephemeral replies (/btw, /omfg side-channel turns).
564
577
  * Models occasionally loop on a single line (~16 reports of N-times-repeated
565
578
  * replies); compress runs longer than 3 down to one instance + `[…N×]`, then
566
579
  * cap at 4 KiB so a runaway reply can't flood the channel.
567
580
  */
568
- function dedupeIrcReply(text: string): string {
581
+ function dedupeEphemeralReply(text: string): string {
569
582
  if (!text) return text;
570
583
  const lines = text.split("\n");
571
584
  const out: string[] = [];
@@ -582,11 +595,11 @@ function dedupeIrcReply(text: string): string {
582
595
  i = j;
583
596
  }
584
597
  let result = out.join("\n");
585
- if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
598
+ if (Buffer.byteLength(result, "utf8") > EPHEMERAL_REPLY_MAX_BYTES) {
586
599
  // Trim by characters until we're under the byte budget — handles multi-byte
587
600
  // glyphs at the boundary without splitting them.
588
601
  const suffix = "\n[…truncated]";
589
- const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
602
+ const budget = EPHEMERAL_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
590
603
  while (Buffer.byteLength(result, "utf8") > budget) {
591
604
  result = result.slice(0, -1);
592
605
  }
@@ -941,13 +954,11 @@ export class AgentSession {
941
954
  #activeEvalExecutions = new Set<Promise<unknown>>();
942
955
  #evalExecutionDisposing = false;
943
956
 
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.
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.
949
961
  #agentId: string | undefined;
950
- #agentRegistry: AgentRegistry | undefined;
951
962
  #providerSessionId: string | undefined;
952
963
  #freshProviderSessionId: string | undefined;
953
964
  #isDisposed = false;
@@ -1204,7 +1215,13 @@ export class AgentSession {
1204
1215
  // Background-job completions / late diagnostics are pulled into the run at
1205
1216
  // each step boundary as non-interrupting asides (see Agent.getAsideMessages),
1206
1217
  // so they reach the model between requests without waiting for a yield.
1207
- 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
+ });
1208
1225
  this.#convertToLlm = config.convertToLlm ?? convertToLlm;
1209
1226
  this.#rebuildSystemPrompt = config.rebuildSystemPrompt;
1210
1227
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
@@ -1235,7 +1252,6 @@ export class AgentSession {
1235
1252
  this.#ttsrManager = config.ttsrManager;
1236
1253
  this.#obfuscator = config.obfuscator;
1237
1254
  this.#agentId = config.agentId;
1238
- this.#agentRegistry = config.agentRegistry;
1239
1255
  this.#providerSessionId = config.providerSessionId;
1240
1256
  this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1241
1257
  const event: AgentEvent = {
@@ -3091,15 +3107,28 @@ export class AgentSession {
3091
3107
  }
3092
3108
 
3093
3109
  /**
3094
- * Remove all listeners, flush pending writes, and disconnect from agent.
3095
- * 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.
3096
3117
  */
3097
- async dispose(): Promise<void> {
3118
+ beginDispose(): void {
3098
3119
  this.#isDisposed = true;
3099
- this.#pendingBackgroundExchanges = [];
3120
+ this.#pendingIrcAsides = [];
3100
3121
  this.yieldQueue.clear();
3101
3122
  this.agent.setAsideMessageProvider(undefined);
3102
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();
3103
3132
  try {
3104
3133
  if (this.#extensionRunner?.hasHandlers("session_shutdown")) {
3105
3134
  await this.#extensionRunner.emit({ type: "session_shutdown" });
@@ -4032,6 +4061,16 @@ export class AgentSession {
4032
4061
  return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
4033
4062
  }
4034
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
+
4035
4074
  #obfuscateForProvider<T>(value: T): T {
4036
4075
  if (!this.#obfuscator?.hasSecrets()) return value;
4037
4076
  return this.#obfuscator.obfuscateObject(value);
@@ -4634,7 +4673,7 @@ export class AgentSession {
4634
4673
  // Flush any pending bash messages before the new prompt
4635
4674
  this.#flushPendingBashMessages();
4636
4675
  this.#flushPendingPythonMessages();
4637
- this.#flushPendingBackgroundExchanges();
4676
+ this.#flushPendingIrcAsides();
4638
4677
 
4639
4678
  // Reset todo reminder count on new user prompt
4640
4679
  this.#todoReminderCount = 0;
@@ -6048,6 +6087,35 @@ export class AgentSession {
6048
6087
  return result;
6049
6088
  }
6050
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
+
6051
6119
  /**
6052
6120
  * Strip image content blocks from every message on the current branch and
6053
6121
  * persist the rewrite. Walks `SessionManager.getBranch()` in place — both
@@ -6237,6 +6305,20 @@ export class AgentSession {
6237
6305
 
6238
6306
  const compactionPrep = await this.#prepareCompactionFromHooks(preparation, hookCompaction);
6239
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
+
6240
6322
  let summary: string;
6241
6323
  let shortSummary: string | undefined;
6242
6324
  let firstKeptEntryId: string;
@@ -6250,6 +6332,14 @@ export class AgentSession {
6250
6332
  tokensBefore = compactionPrep.tokensBefore;
6251
6333
  details = compactionPrep.details;
6252
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 ?? {}) };
6253
6343
  } else {
6254
6344
  // Generate compaction result. Only convert known abort-shaped
6255
6345
  // rejections (AbortError raised while the abort signal is set,
@@ -6669,7 +6759,10 @@ export class AgentSession {
6669
6759
  model: `${assistantMessage.provider}/${assistantMessage.model}`,
6670
6760
  strategy: incompleteCompactionSettings.strategy,
6671
6761
  });
6672
- await this.#runAutoCompaction("incomplete", true, false, allowDefer, { autoContinue });
6762
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer, {
6763
+ autoContinue,
6764
+ triggerContextTokens: calculateContextTokens(assistantMessage.usage),
6765
+ });
6673
6766
  } else {
6674
6767
  // Neither promotion nor compaction is available — surface the dead-end so
6675
6768
  // the user understands why the turn yielded with nothing.
@@ -6680,6 +6773,10 @@ export class AgentSession {
6680
6773
  return false;
6681
6774
  }
6682
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
+
6683
6780
  const compactionSettings = this.settings.getGroup("compaction");
6684
6781
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
6685
6782
 
@@ -6688,6 +6785,9 @@ export class AgentSession {
6688
6785
  if (assistantMessage.stopReason === "error") return false;
6689
6786
  const pruneResult = await this.#pruneToolOutputs();
6690
6787
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6788
+ if (supersedeResult) {
6789
+ contextTokens = Math.max(0, contextTokens - supersedeResult.tokensSaved);
6790
+ }
6691
6791
  if (pruneResult) {
6692
6792
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6693
6793
  }
@@ -6695,7 +6795,10 @@ export class AgentSession {
6695
6795
  // Try promotion first — if a larger model is available, switch instead of compacting
6696
6796
  const promoted = await this.#tryContextPromotion(assistantMessage);
6697
6797
  if (!promoted) {
6698
- return await this.#runAutoCompaction("threshold", false, false, allowDefer, { autoContinue });
6798
+ return await this.#runAutoCompaction("threshold", false, false, allowDefer, {
6799
+ autoContinue,
6800
+ triggerContextTokens: contextTokens,
6801
+ });
6699
6802
  }
6700
6803
  }
6701
6804
  return false;
@@ -7540,7 +7643,7 @@ export class AgentSession {
7540
7643
  willRetry: boolean,
7541
7644
  deferred = false,
7542
7645
  allowDefer = true,
7543
- options: { autoContinue?: boolean } = {},
7646
+ options: { autoContinue?: boolean; triggerContextTokens?: number } = {},
7544
7647
  ): Promise<boolean> {
7545
7648
  const compactionSettings = this.settings.getGroup("compaction");
7546
7649
  if (compactionSettings.strategy === "off") return false;
@@ -7551,7 +7654,13 @@ export class AgentSession {
7551
7654
  // reclaims nothing we fall through to the summary-compaction body below so
7552
7655
  // the oversized input still gets resolved.
7553
7656
  if (compactionSettings.strategy === "shake") {
7554
- 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
+ );
7555
7664
  if (outcome !== "fallback") return false;
7556
7665
  }
7557
7666
  // "overflow" and "incomplete" force inline execution because they are recovery
@@ -7578,9 +7687,25 @@ export class AgentSession {
7578
7687
 
7579
7688
  // "overflow" forces context-full because the input itself is broken — a handoff
7580
7689
  // 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" =
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" =
7583
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
+ }
7584
7709
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
7585
7710
  // Abort any older auto-compaction before installing this run's controller.
7586
7711
  this.#autoCompactionAbortController?.abort();
@@ -7719,6 +7844,16 @@ export class AgentSession {
7719
7844
  tokensBefore = compactionPrep.tokensBefore;
7720
7845
  details = compactionPrep.details;
7721
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 ?? {}) };
7722
7857
  } else {
7723
7858
  const candidates = this.#getCompactionModelCandidates(availableModels);
7724
7859
  const retrySettings = this.settings.getGroup("retry");
@@ -7958,6 +8093,7 @@ export class AgentSession {
7958
8093
  willRetry: boolean,
7959
8094
  generation: number,
7960
8095
  autoContinue: boolean,
8096
+ triggerContextTokens?: number,
7961
8097
  ): Promise<"handled" | "fallback"> {
7962
8098
  const action = "shake";
7963
8099
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -7978,8 +8114,8 @@ export class AgentSession {
7978
8114
  return "handled";
7979
8115
  }
7980
8116
  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
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
7983
8119
  // threshold. The next agent_end would re-trigger shake, which has nothing
7984
8120
  // new to drop on the second pass, so the loop spins until the user kills it.
7985
8121
  // Same hazard for "incomplete" (the retry would re-hit the length cap) and
@@ -7987,10 +8123,30 @@ export class AgentSession {
7987
8123
  // reason we hand off to the summarization-driven context-full path so the
7988
8124
  // situation actually resolves; "idle" is exempt because its 60s+ timer
7989
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.
7990
8136
  const contextWindow = this.model?.contextWindow ?? 0;
7991
8137
  const compactionSettings = this.settings.getGroup("compaction");
7992
- const postShakeTokens = contextWindow > 0 ? this.#estimatePendingPromptTokens([]) : 0;
7993
- 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
+ }
7994
8150
  const shouldFallBack = reason !== "idle" && ((reason === "overflow" && !reclaimed) || stillOverThreshold);
7995
8151
  if (shouldFallBack) {
7996
8152
  const errorMessage = reclaimed
@@ -8926,118 +9082,56 @@ export class AgentSession {
8926
9082
  }
8927
9083
 
8928
9084
  // =========================================================================
8929
- // Background-Channel IRC Exchanges
9085
+ // IRC Delivery
8930
9086
  // =========================================================================
8931
9087
 
8932
9088
  /**
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.
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:
8935
9093
  *
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.
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".
9098
+ *
9099
+ * Never blocks on the recipient's turn: the wake turn is fire-and-forget.
8942
9100
  */
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 = {
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 = {
8952
9106
  role: "custom",
8953
9107
  customType: "irc:incoming",
8954
- 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
+ }),
8955
9113
  display: true,
8956
- details: { from: args.from, message: args.message },
9114
+ details: { id: msg.id, from: msg.from, message: msg.body, ...(msg.replyTo ? { replyTo: msg.replyTo } : {}) },
8957
9115
  attribution: "agent",
8958
- timestamp: incomingTimestamp,
9116
+ timestamp: msg.ts,
8959
9117
  };
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 };
9118
+ void this.#emitSessionEvent({ type: "irc_message", message: record });
9119
+ if (this.isStreaming) {
9120
+ this.#pendingIrcAsides.push(record);
9121
+ return "injected";
8972
9122
  }
8973
-
8974
- const incomingPrompt = prompt.render(ircIncomingTemplate, {
8975
- from: args.from,
8976
- 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) });
8977
9127
  });
8978
- const { replyText } = await this.runEphemeralTurn({
8979
- promptText: incomingPrompt,
8980
- signal: args.signal,
8981
- });
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);
9128
+ return "woken";
9036
9129
  }
9037
9130
 
9038
9131
  /**
9039
9132
  * 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.
9133
+ * Does not persist the record to history. Called by the IrcBus to surface
9134
+ * agent↔agent traffic on the main session.
9041
9135
  */
9042
9136
  emitIrcRelayObservation(record: CustomMessage): void {
9043
9137
  void this.#emitSessionEvent({ type: "irc_message", message: record });
@@ -9049,7 +9143,7 @@ export class AgentSession {
9049
9143
  * does not block on, or interfere with, any in-flight main turn. The
9050
9144
  * session's history and persisted state are NOT modified by this call.
9051
9145
  *
9052
- * Used by `respondAsBackground` (IRC) and `BtwController` (`/btw`) to share
9146
+ * Used by `BtwController` (`/btw`) and `OmfgController` (`/omfg`) to share
9053
9147
  * the snapshot + stream pipeline. The snapshot includes any in-flight
9054
9148
  * streaming assistant text so the model sees the half-finished response
9055
9149
  * rather than missing context.
@@ -9137,7 +9231,7 @@ export class AgentSession {
9137
9231
  args.onTextDelta(replyText.slice(emittedReplyText.length));
9138
9232
  }
9139
9233
  return {
9140
- replyText: args.dedupeReply === false ? replyText.trim() : dedupeIrcReply(replyText.trim()),
9234
+ replyText: args.dedupeReply === false ? replyText.trim() : dedupeEphemeralReply(replyText.trim()),
9141
9235
  assistantMessage,
9142
9236
  };
9143
9237
  }
@@ -9188,46 +9282,21 @@ export class AgentSession {
9188
9282
  return messages;
9189
9283
  }
9190
9284
 
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
- }
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 });
9231
9300
  }
9232
9301
  }
9233
9302
 
@@ -10032,69 +10101,6 @@ export class AgentSession {
10032
10101
  });
10033
10102
  }
10034
10103
 
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
10104
  // =========================================================================
10099
10105
  // Extension System
10100
10106
  // =========================================================================