@astralyn/pi 0.86.1 → 0.87.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 (125) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/bundle/chunks/{anthropic-messages-R2LYJUDD.js → anthropic-messages-XWFTZP2N.js} +1 -1
  3. package/dist/bundle/chunks/{chunk-GTBMYBFG.js → chunk-772GXEFD.js} +50 -44
  4. package/dist/bundle/chunks/chunk-CDMUKIXY.js +2 -0
  5. package/dist/bundle/chunks/github-copilot.js +1 -1
  6. package/dist/bundle/chunks/{openai-completions-6SMWQWQN.js → openai-completions-SHS3CAEL.js} +2 -2
  7. package/dist/bundle/chunks/{virtual-modules-OHUZ2T7X.js → virtual-modules-LQO2MG3T.js} +1 -1
  8. package/dist/bundle/cli.js +1 -1
  9. package/dist/bundle/index.js +1 -1
  10. package/dist/bundle/rpc-entry.js +1 -1
  11. package/dist/cli/args.d.ts.map +1 -1
  12. package/dist/cli/args.js +14 -4
  13. package/dist/cli/args.js.map +1 -1
  14. package/dist/cli/file-processor.d.ts +1 -1
  15. package/dist/cli/file-processor.d.ts.map +1 -1
  16. package/dist/cli/file-processor.js.map +1 -1
  17. package/dist/core/agent-session-runtime.js +1 -1
  18. package/dist/core/agent-session-runtime.js.map +1 -1
  19. package/dist/core/agent-session.d.ts +47 -4
  20. package/dist/core/agent-session.d.ts.map +1 -1
  21. package/dist/core/agent-session.js +476 -139
  22. package/dist/core/agent-session.js.map +1 -1
  23. package/dist/core/background/service.d.ts +9 -0
  24. package/dist/core/background/service.d.ts.map +1 -1
  25. package/dist/core/background/service.js +13 -1
  26. package/dist/core/background/service.js.map +1 -1
  27. package/dist/core/background/session.d.ts +5 -0
  28. package/dist/core/background/session.d.ts.map +1 -1
  29. package/dist/core/background/session.js +2 -1
  30. package/dist/core/background/session.js.map +1 -1
  31. package/dist/core/cache-warmer.d.ts +1 -0
  32. package/dist/core/cache-warmer.d.ts.map +1 -1
  33. package/dist/core/cache-warmer.js +15 -1
  34. package/dist/core/cache-warmer.js.map +1 -1
  35. package/dist/core/compaction/compaction.d.ts +3 -1
  36. package/dist/core/compaction/compaction.d.ts.map +1 -1
  37. package/dist/core/compaction/compaction.js +155 -57
  38. package/dist/core/compaction/compaction.js.map +1 -1
  39. package/dist/core/crash-log.d.ts +5 -0
  40. package/dist/core/crash-log.d.ts.map +1 -1
  41. package/dist/core/crash-log.js +68 -0
  42. package/dist/core/crash-log.js.map +1 -1
  43. package/dist/core/export-html/template.js +6 -1
  44. package/dist/core/extensions/index.d.ts +1 -1
  45. package/dist/core/extensions/index.d.ts.map +1 -1
  46. package/dist/core/extensions/index.js.map +1 -1
  47. package/dist/core/extensions/runner.d.ts +16 -3
  48. package/dist/core/extensions/runner.d.ts.map +1 -1
  49. package/dist/core/extensions/runner.js +110 -5
  50. package/dist/core/extensions/runner.js.map +1 -1
  51. package/dist/core/extensions/types.d.ts +77 -6
  52. package/dist/core/extensions/types.d.ts.map +1 -1
  53. package/dist/core/extensions/types.js.map +1 -1
  54. package/dist/core/index.d.ts +1 -1
  55. package/dist/core/index.d.ts.map +1 -1
  56. package/dist/core/index.js.map +1 -1
  57. package/dist/core/model-config.d.ts +52 -0
  58. package/dist/core/model-config.d.ts.map +1 -1
  59. package/dist/core/model-config.js +16 -0
  60. package/dist/core/model-config.js.map +1 -1
  61. package/dist/core/model-resolver.js +1 -1
  62. package/dist/core/model-resolver.js.map +1 -1
  63. package/dist/core/prompt-templates.d.ts +6 -1
  64. package/dist/core/prompt-templates.d.ts.map +1 -1
  65. package/dist/core/prompt-templates.js +61 -35
  66. package/dist/core/prompt-templates.js.map +1 -1
  67. package/dist/core/provider-composer.d.ts +1 -0
  68. package/dist/core/provider-composer.d.ts.map +1 -1
  69. package/dist/core/provider-composer.js +19 -0
  70. package/dist/core/provider-composer.js.map +1 -1
  71. package/dist/core/resource-loader.d.ts.map +1 -1
  72. package/dist/core/resource-loader.js +6 -2
  73. package/dist/core/resource-loader.js.map +1 -1
  74. package/dist/core/sdk.d.ts.map +1 -1
  75. package/dist/core/sdk.js +3 -4
  76. package/dist/core/sdk.js.map +1 -1
  77. package/dist/core/session-manager.d.ts +36 -9
  78. package/dist/core/session-manager.d.ts.map +1 -1
  79. package/dist/core/session-manager.js +97 -7
  80. package/dist/core/session-manager.js.map +1 -1
  81. package/dist/core/tools/read.d.ts +4 -1
  82. package/dist/core/tools/read.d.ts.map +1 -1
  83. package/dist/core/tools/read.js +5 -1
  84. package/dist/core/tools/read.js.map +1 -1
  85. package/dist/extensions/btw/agent.d.ts.map +1 -1
  86. package/dist/extensions/btw/agent.js +8 -5
  87. package/dist/extensions/btw/agent.js.map +1 -1
  88. package/dist/index.d.ts +2 -2
  89. package/dist/index.d.ts.map +1 -1
  90. package/dist/index.js +1 -1
  91. package/dist/index.js.map +1 -1
  92. package/dist/main.d.ts.map +1 -1
  93. package/dist/main.js +4 -3
  94. package/dist/main.js.map +1 -1
  95. package/dist/modes/interactive/bug-report.d.ts.map +1 -1
  96. package/dist/modes/interactive/bug-report.js +4 -0
  97. package/dist/modes/interactive/bug-report.js.map +1 -1
  98. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  99. package/dist/modes/interactive/components/tree-selector.js +7 -0
  100. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  101. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  102. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  103. package/dist/modes/interactive/interactive-mode.js +64 -2
  104. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  105. package/dist/utils/mime.js +1 -1
  106. package/dist/utils/mime.js.map +1 -1
  107. package/dist/utils/tool-result-images.d.ts +3 -1
  108. package/dist/utils/tool-result-images.d.ts.map +1 -1
  109. package/dist/utils/tool-result-images.js +4 -1
  110. package/dist/utils/tool-result-images.js.map +1 -1
  111. package/docs/bundled/extensions/background.md +3 -3
  112. package/docs/bundled/extensions/subagent.md +1 -1
  113. package/docs/compaction.md +24 -5
  114. package/docs/extensions.md +72 -8
  115. package/docs/models.md +30 -1
  116. package/docs/sdk.md +15 -4
  117. package/docs/session-format.md +25 -4
  118. package/docs/usage.md +3 -0
  119. package/examples/README.md +11 -1
  120. package/examples/plugins/pi-example-plugin/src/session.ts +3 -2
  121. package/examples/rpc-client.ts +35 -0
  122. package/examples/rpc-extension-ui.ts +25 -5
  123. package/npm-shrinkwrap.json +43 -43
  124. package/package.json +8 -8
  125. package/dist/bundle/chunks/chunk-DAEM5JIZ.js +0 -2
@@ -18,23 +18,25 @@ import { contentText, getCurrentSystemMessage, getSystemMessageText, retryDelayM
18
18
  import { clampThinkingLevel, cleanupSessionResources, getSupportedThinkingLevels, isContextOverflow, isRecoverableLength, isRetryableAssistantError, modelsAreEqual, resetApiProviders, streamSimple, } from "@earendil-works/pi-ai/compat";
19
19
  import { getThemeByName, theme } from "../modes/interactive/theme/theme.js";
20
20
  import { stripFrontmatter } from "../utils/frontmatter.js";
21
+ import { processImage } from "../utils/image-process.js";
21
22
  import { sleep } from "../utils/sleep.js";
22
23
  import { normalizeToolResultImages } from "../utils/tool-result-images.js";
23
24
  import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.js";
24
25
  import { BackgroundSession } from "./background/session.js";
25
26
  import { executeBashWithOperations } from "./bash-executor.js";
26
27
  import { generateBugReportSummary } from "./bug-report.js";
27
- import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
28
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateProjectedContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
28
29
  import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.js";
29
30
  import { exportSessionToHtml } from "./export-html/index.js";
30
31
  import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
31
32
  import { ExtensionRunner, STALE_EXTENSION_CONTEXT_MESSAGE, wrapRegisteredTools, } from "./extensions/index.js";
32
33
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
34
+ import { convertToLlm } from "./messages.js";
33
35
  import { ModelRegistry } from "./model-registry.js";
34
36
  import { resolveModelStreamOptions } from "./model-stream-options.js";
35
37
  import { expandPromptTemplate } from "./prompt-templates.js";
36
38
  import { exportSessionToJsonl } from "./session-export.js";
37
- import { getLatestCompactionEntry } from "./session-manager.js";
39
+ import { getLatestCompactionEntry, SessionManager, } from "./session-manager.js";
38
40
  import { createSyntheticSourceInfo } from "./source-info.js";
39
41
  import { buildSystemPrompt, buildSystemPromptSections, diffSystemPromptSections, normalizeBuildSystemPromptOptions, } from "./system-prompt.js";
40
42
  import { createLocalBashOperations } from "./tools/bash.js";
@@ -56,9 +58,6 @@ export function parseSkillBlock(text) {
56
58
  userMessage: match[4]?.trim() || undefined,
57
59
  };
58
60
  }
59
- // ============================================================================
60
- // Types
61
- // ============================================================================
62
61
  function withoutDeletedHeaders(headers) {
63
62
  return headers
64
63
  ? Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== null))
@@ -87,6 +86,8 @@ export class AgentSession {
87
86
  this._pendingNextTurnMessages = [];
88
87
  /** Context-only custom messages queued during a run, flushed once the current turn's tool results are in. */
89
88
  this._pendingCustomMessages = [];
89
+ /** Background completions queued for steering, awaiting the persistence that confirms delivery. */
90
+ this._queuedBackgroundCompletions = new Map();
90
91
  // Compaction state
91
92
  this._compactionAbortController = undefined;
92
93
  this._autoCompactionAbortController = undefined;
@@ -100,6 +101,14 @@ export class AgentSession {
100
101
  this._bashAbortControllers = new Set();
101
102
  this._pendingBashMessages = [];
102
103
  this._turnIndex = 0;
104
+ this._entryIdsByMessage = new WeakMap();
105
+ this._boundaryDispatchedMessages = new WeakSet();
106
+ this._lastAssistantToolResults = [];
107
+ this._lastActivityOutcome = "completed";
108
+ this._isBeforeSettle = false;
109
+ this._abortDuringBeforeSettle = false;
110
+ this._isEmittingAgentSettled = false;
111
+ this._deferredSettledActions = [];
103
112
  this._baseToolDefinitions = new Map();
104
113
  this._extensionMode = "print";
105
114
  // Tool registry for extension getTools/setTools
@@ -107,13 +116,17 @@ export class AgentSession {
107
116
  this._toolDefinitions = new Map();
108
117
  this._toolPromptSnippets = new Map();
109
118
  this._toolPromptGuidelines = new Map();
110
- // Track last assistant message for auto-compaction check
111
- this._lastAssistantMessage = undefined;
112
119
  /** Internal handler for agent events - shared by subscribe and reconnect */
113
120
  this._handleAgentEvent = async (event) => {
114
121
  // Preserve host delivery identity before extension message_end transforms.
115
122
  // Content/details may be replaced, but a persisted replacement is still an ack.
116
123
  const acknowledgeBackground = event.type === "message_end" ? this._backgroundHost.messageAcknowledgement(event.message) : undefined;
124
+ // A queued completion is tracked by identity too, for the same reason.
125
+ const queuedBackgroundCompletion = event.type === "message_start" || event.type === "message_end"
126
+ ? this._queuedBackgroundCompletions.get(event.message)
127
+ : undefined;
128
+ if (event.type === "message_start" && queuedBackgroundCompletion)
129
+ queuedBackgroundCompletion.drained = true;
117
130
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
118
131
  // This ensures the UI sees the updated queue state
119
132
  if (event.type === "message_start" && event.message.role === "user") {
@@ -136,18 +149,18 @@ export class AgentSession {
136
149
  }
137
150
  }
138
151
  }
139
- // Emit to extensions first
152
+ // Emit to extensions first, then notify public listeners.
140
153
  await this._emitExtensionEvent(event);
141
- // Notify all listeners
142
154
  this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
143
155
  // Handle session persistence
144
156
  if (event.type === "message_end") {
145
- if (this._persistMessage(event.message))
157
+ if (this._persistMessage(event.message)) {
146
158
  acknowledgeBackground?.();
147
- // Track assistant message for auto-compaction (checked on agent_end)
159
+ queuedBackgroundCompletion?.resolve();
160
+ }
148
161
  if (event.message.role === "assistant") {
149
- this._lastAssistantMessage = event.message;
150
162
  const assistantMsg = event.message;
163
+ this._lastAssistantMessage = assistantMsg;
151
164
  if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "length") {
152
165
  this._overflowRecoveryAttempted = false;
153
166
  }
@@ -169,8 +182,11 @@ export class AgentSession {
169
182
  // extension and listener dispatch above also picks up messages that turn_end
170
183
  // handlers queued.
171
184
  if (event.type === "turn_end") {
185
+ this._lastAssistantToolResults = event.toolResults;
172
186
  this._flushPendingCustomMessages();
173
187
  }
188
+ if (event.type === "agent_end")
189
+ this._failDrainedBackgroundCompletions();
174
190
  };
175
191
  this.agent = config.agent;
176
192
  this.sessionManager = config.sessionManager;
@@ -178,16 +194,15 @@ export class AgentSession {
178
194
  this._backgroundHost = new BackgroundSession({
179
195
  manager: this.sessionManager,
180
196
  role: config.executionRole ?? "main",
181
- canDeliver: () => this.isIdle &&
182
- !this.agent.state.isStreaming &&
183
- this.pendingMessageCount === 0 &&
184
- !this.agent.hasQueuedMessages() &&
185
- this.model !== undefined,
186
- deliver: (message) => {
187
- // Completion turns skip before_agent_start, but consume nextTurn context.
188
- const asides = this._pendingNextTurnMessages.slice();
189
- return this._runAgentPrompt(asides.length ? [message, ...asides] : message);
197
+ canDeliver: () => {
198
+ if (this.model === undefined || this.isCompacting)
199
+ return false;
200
+ // A completion steered into an active run follows the interactive steering queue, which
201
+ // already preserves the order of queued user input. An idle delivery starts its own
202
+ // turn, so it still waits for the queues to drain.
203
+ return this.isStreaming || (this.pendingMessageCount === 0 && !this.agent.hasQueuedMessages());
190
204
  },
205
+ deliver: (message) => this._deliverBackgroundCompletion(message),
191
206
  onEntry: (entry) => this._emit({ type: "entry_appended", entry }),
192
207
  onError: (event, error) => this._extensionRunner?.emitError({ extensionPath: "<background>", event, error }),
193
208
  });
@@ -212,6 +227,8 @@ export class AgentSession {
212
227
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
213
228
  this._installAgentToolHooks();
214
229
  this._installAgentNextTurnRefresh();
230
+ this._installAgentRequestProjection();
231
+ this._installAgentBoundaryHooks();
215
232
  this._installAgentForcedPromptProjection();
216
233
  this._buildRuntime({
217
234
  activeToolNames: this._initialActiveToolNames,
@@ -264,7 +281,8 @@ export class AgentSession {
264
281
  }),
265
282
  };
266
283
  const currentSystemPrompt = this.systemPrompt;
267
- const messages = structuredClone(this.agent.state.messages);
284
+ // The canonical projection is what the next request converts; the inspection cache may lag it.
285
+ const messages = structuredClone(this.sessionManager.buildSessionProjection().messages);
268
286
  const convert = this.agent.convertToLlm;
269
287
  const transform = this.agent.transformContext;
270
288
  const prepared = this._captureModelContext
@@ -375,8 +393,10 @@ export class AgentSession {
375
393
  : undefined;
376
394
  const content = hookResult?.content ?? result.content ?? [];
377
395
  // Runs after the extension hook so images injected or replaced by extensions are normalized too.
396
+ const resizeOptions = this.model?.inputLimits?.images?.resize;
378
397
  const normalizedContent = await normalizeToolResultImages(content, {
379
398
  autoResizeImages: this.settingsManager.getImageAutoResize(),
399
+ ...(resizeOptions ? { resizeOptions } : {}),
380
400
  });
381
401
  if (!hookResult && normalizedContent === content) {
382
402
  return undefined;
@@ -392,15 +412,83 @@ export class AgentSession {
392
412
  async _compactBeforeNextAssistantResponse(context) {
393
413
  const model = this.model;
394
414
  const settings = this.settingsManager.getCompactionSettings(model);
415
+ const projection = this.sessionManager.buildSessionProjection();
395
416
  if (!model ||
396
417
  model.contextWindow <= 0 ||
397
- !shouldCompact(estimateContextTokens(context.messages).tokens, model.contextWindow, settings)) {
398
- return context;
418
+ !shouldCompact(estimateProjectedContextTokens(projection, this.sessionManager.getBranch()).tokens, model.contextWindow, settings)) {
419
+ return { ...context, messages: projection.messages };
399
420
  }
400
421
  await this._runAutoCompaction("threshold", false);
401
- return {
402
- ...context,
403
- messages: this.agent.state.messages.slice(),
422
+ return { ...context, messages: this.sessionManager.buildSessionProjection().messages };
423
+ }
424
+ _installAgentRequestProjection() {
425
+ const previousPrepareRequest = this.agent.prepareRequest;
426
+ this.agent.prepareRequest = async (request, signal) => {
427
+ const canonicalContext = {
428
+ ...request.context,
429
+ messages: this.sessionManager.buildSessionProjection().messages,
430
+ // Messages declare the provider-visible loadout; context.tools keeps executable implementations.
431
+ tools: this.agent.state.tools.slice(),
432
+ };
433
+ const previous = await previousPrepareRequest?.({
434
+ ...request,
435
+ context: canonicalContext,
436
+ model: this.agent.state.model,
437
+ thinkingLevel: this.agent.state.thinkingLevel,
438
+ }, signal);
439
+ return {
440
+ ...previous,
441
+ context: previous?.context ?? canonicalContext,
442
+ model: previous?.model ?? this.agent.state.model,
443
+ thinkingLevel: previous?.thinkingLevel ?? this.agent.state.thinkingLevel,
444
+ };
445
+ };
446
+ }
447
+ async _dispatchTurnEndBoundary(message, toolResults) {
448
+ this._lastActivityOutcome =
449
+ message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
450
+ const messageEntryId = this._findPersistedMessageEntryId(message);
451
+ if (!this._extensionRunner.hasHandlers("turn_end"))
452
+ return false;
453
+ if (!messageEntryId) {
454
+ this._extensionRunner.emitError({
455
+ extensionPath: "<boundary>",
456
+ event: "turn_end",
457
+ error: "turn_end could not resolve the persisted assistant entry ID",
458
+ });
459
+ return false;
460
+ }
461
+ const toolResultEntryIds = toolResults.flatMap((result) => {
462
+ const entryId = this._findPersistedMessageEntryId(result);
463
+ return entryId ? [entryId] : [];
464
+ });
465
+ const boundary = await this._extensionRunner.emitBoundary({
466
+ type: "turn_end",
467
+ turnIndex: this._turnIndex,
468
+ message,
469
+ toolResults,
470
+ messageEntryId,
471
+ toolResultEntryIds,
472
+ outcome: this._lastActivityOutcome,
473
+ }, (entries) => this._buildBoundaryContext(entries, "turn_end"));
474
+ this._commitBoundaryDrafts(boundary.entries);
475
+ if (boundary.continue && !this._buildBoundaryContext([], "turn_end").canContinue) {
476
+ this._reportInvalidBoundaryContinuation("turn_end");
477
+ return false;
478
+ }
479
+ return boundary.continue;
480
+ }
481
+ _installAgentBoundaryHooks() {
482
+ const previousFinishTurn = this.agent.finishTurn;
483
+ this.agent.finishTurn = async (turn, signal) => {
484
+ this._boundaryDispatchedMessages.add(turn.message);
485
+ const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
486
+ const previousDecision = await previousFinishTurn?.(turn, signal);
487
+ if (previousDecision?.action === "end")
488
+ return previousDecision;
489
+ if (extensionContinue || previousDecision?.action === "continue")
490
+ return { action: "continue" };
491
+ return undefined;
404
492
  };
405
493
  }
406
494
  _installAgentNextTurnRefresh() {
@@ -409,7 +497,10 @@ export class AgentSession {
409
497
  ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal)
410
498
  : undefined);
411
499
  this.agent.prepareNextTurnWithContext = async (turn, signal) => {
412
- const context = await this._compactBeforeNextAssistantResponse(turn.context);
500
+ const context = await this._compactBeforeNextAssistantResponse({
501
+ ...turn.context,
502
+ messages: this.sessionManager.buildSessionProjection().messages,
503
+ });
413
504
  const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);
414
505
  const nextContext = previousSnapshot?.context ?? context;
415
506
  const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;
@@ -439,6 +530,84 @@ export class AgentSession {
439
530
  // =========================================================================
440
531
  // Event Subscription
441
532
  // =========================================================================
533
+ _refreshFinalizedContext() {
534
+ const projection = this.sessionManager.buildSessionProjection();
535
+ for (const entry of projection.entries) {
536
+ for (const message of entry.messages)
537
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
538
+ }
539
+ this.agent.state.messages = projection.messages;
540
+ }
541
+ _applyBoundaryDrafts(manager, drafts) {
542
+ const appended = [];
543
+ for (const draft of drafts) {
544
+ let entryId;
545
+ switch (draft.type) {
546
+ case "custom":
547
+ entryId = manager.appendCustomEntry(draft.customType, draft.data);
548
+ break;
549
+ case "custom_message":
550
+ entryId = manager.appendCustomMessageEntry(draft.customType, draft.content, draft.display, draft.details);
551
+ break;
552
+ case "context_edit":
553
+ entryId = manager.appendContextEdit(draft.targetId, draft.replacement);
554
+ break;
555
+ case "compaction": {
556
+ const tokensBefore = estimateProjectedContextTokens(manager.buildSessionProjection(), manager.getBranch()).tokens;
557
+ entryId = manager.appendCompaction(draft.summary, draft.firstKeptEntryId, tokensBefore, draft.details, true, draft.usage);
558
+ break;
559
+ }
560
+ }
561
+ const entry = manager.getEntry(entryId);
562
+ if (entry)
563
+ appended.push(entry);
564
+ }
565
+ return appended;
566
+ }
567
+ _createBoundaryPreviewManager(drafts) {
568
+ const header = this.sessionManager.getHeader();
569
+ if (!header)
570
+ throw new Error("Session header is missing");
571
+ const manager = SessionManager.inMemory(this._cwd, undefined, [header, ...this.sessionManager.getBranch()]);
572
+ this._applyBoundaryDrafts(manager, drafts);
573
+ return manager;
574
+ }
575
+ _getPendingBoundaryMessages() {
576
+ return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];
577
+ }
578
+ _buildBoundaryContext(drafts, boundary) {
579
+ const projection = this._createBoundaryPreviewManager(drafts).buildSessionProjection();
580
+ const pendingMessages = this._getPendingBoundaryMessages();
581
+ const llmMessages = convertToLlm(projection.messages);
582
+ const finalRole = llmMessages[llmMessages.length - 1]?.role;
583
+ const hasNonSystemContext = llmMessages.some((message) => message.role !== "system");
584
+ const contextCanContinue = hasNonSystemContext && finalRole !== "assistant";
585
+ const pendingCustomContext = this._pendingCustomMessages.length > 0;
586
+ return {
587
+ contextEntries: projection.entries,
588
+ contextMessages: projection.messages,
589
+ llmMessages,
590
+ pendingMessages,
591
+ canContinue: contextCanContinue ||
592
+ pendingCustomContext ||
593
+ (boundary === "turn_end"
594
+ ? this.agent.hasQueuedMessages()
595
+ : finalRole === "assistant" && this.agent.hasQueuedMessages()),
596
+ };
597
+ }
598
+ _commitBoundaryDrafts(drafts) {
599
+ const appended = this._applyBoundaryDrafts(this.sessionManager, drafts);
600
+ this._refreshFinalizedContext();
601
+ for (const entry of appended)
602
+ this._emit({ type: "entry_appended", entry });
603
+ }
604
+ _reportInvalidBoundaryContinuation(event) {
605
+ this._extensionRunner.emitError({
606
+ extensionPath: "<boundary>",
607
+ event,
608
+ error: `${event} requested continuation without runnable model context`,
609
+ });
610
+ }
442
611
  /** Emit an event to all listeners */
443
612
  _emit(event) {
444
613
  for (const l of this._eventListeners) {
@@ -476,21 +645,35 @@ export class AgentSession {
476
645
  }
477
646
  async _emitAgentSettled() {
478
647
  this._cacheWarmer?.onAgentSettled();
648
+ // Background completions stay paused until runs requested by settled handlers have started,
649
+ // so a deferred prompt never races a delivery for the same idle gap.
479
650
  const resumeBackground = this.pauseBackgroundNotifications();
480
651
  this._isAgentRunActive = false;
481
652
  try {
482
- await this._extensionRunner.emit({ type: "agent_settled" });
483
- this._emit({ type: "agent_settled" });
653
+ this._isEmittingAgentSettled = true;
654
+ try {
655
+ await this._extensionRunner.emit({ type: "agent_settled" });
656
+ this._emit({ type: "agent_settled" });
657
+ }
658
+ finally {
659
+ this._isEmittingAgentSettled = false;
660
+ }
661
+ for (const action of this._deferredSettledActions.splice(0))
662
+ await action();
484
663
  }
485
664
  finally {
486
665
  this._resolveIdleWaitIfIdle();
487
666
  resumeBackground();
488
667
  }
489
668
  }
490
- /** Persist model/custom messages and consume queued context only after the append succeeds. */
669
+ /**
670
+ * Persist model/custom messages and consume queued context only after the append succeeds.
671
+ * Returns the persisted entry ID, which boundary events and recovery omissions resolve later.
672
+ */
491
673
  _persistMessage(message) {
674
+ let entryId;
492
675
  if (message.role === "custom") {
493
- this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
676
+ entryId = this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
494
677
  const pendingIndex = this._pendingNextTurnMessages.indexOf(message);
495
678
  if (pendingIndex !== -1)
496
679
  this._pendingNextTurnMessages.splice(pendingIndex, 1);
@@ -499,15 +682,16 @@ export class AgentSession {
499
682
  message.role === "user" ||
500
683
  message.role === "assistant" ||
501
684
  message.role === "toolResult") {
502
- this.sessionManager.appendMessage(message);
685
+ entryId = this.sessionManager.appendMessage(message);
503
686
  }
504
687
  else {
505
688
  // Bash execution, compaction and branch summaries are persisted by their owners.
506
- return false;
689
+ return undefined;
507
690
  }
691
+ this._entryIdsByMessage.set(message, entryId);
508
692
  if (message.role === "toolResult")
509
693
  this._backgroundHost.acknowledgeWaitResult(message.details);
510
- return true;
694
+ return entryId;
511
695
  }
512
696
  _willRetryAfterAgentEnd(event) {
513
697
  if (this._agentRunAbortRequested)
@@ -524,6 +708,47 @@ export class AgentSession {
524
708
  }
525
709
  return false;
526
710
  }
711
+ _findPersistedMessageEntryId(message) {
712
+ const mapped = this._entryIdsByMessage.get(message);
713
+ if (mapped)
714
+ return mapped;
715
+ for (const entry of [...this.sessionManager.getBranch()].reverse()) {
716
+ if (entry.type === "message" && entry.message === message)
717
+ return entry.id;
718
+ }
719
+ const messageIndex = this.agent.state.messages.indexOf(message);
720
+ if (messageIndex < 0)
721
+ return undefined;
722
+ const projection = this.sessionManager.buildSessionProjection();
723
+ let projectedIndex = 0;
724
+ for (const entry of projection.entries) {
725
+ for (let i = 0; i < entry.messages.length; i++) {
726
+ if (projectedIndex === messageIndex) {
727
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
728
+ return entry.sourceEntry.id;
729
+ }
730
+ projectedIndex++;
731
+ }
732
+ }
733
+ return undefined;
734
+ }
735
+ _omitRecoveryAttempt(message, toolResults = []) {
736
+ const targets = [message, ...toolResults];
737
+ const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
738
+ const unresolvedProjectedTarget = targets.some((target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target));
739
+ if (unresolvedProjectedTarget) {
740
+ throw new Error("Cannot persist recovery omission because a projected message has no source entry");
741
+ }
742
+ for (const targetId of targetIds) {
743
+ if (!targetId)
744
+ continue;
745
+ const editId = this.sessionManager.appendContextEdit(targetId, null);
746
+ const entry = this.sessionManager.getEntry(editId);
747
+ if (entry)
748
+ this._emit({ type: "entry_appended", entry });
749
+ }
750
+ this._refreshFinalizedContext();
751
+ }
527
752
  /** Find the last assistant message in agent state (including aborted ones) */
528
753
  _findLastAssistantMessage() {
529
754
  const messages = this.agent.state.messages;
@@ -567,13 +792,9 @@ export class AgentSession {
567
792
  await this._extensionRunner.emit(extensionEvent);
568
793
  }
569
794
  else if (event.type === "turn_end") {
570
- const extensionEvent = {
571
- type: "turn_end",
572
- turnIndex: this._turnIndex,
573
- message: event.message,
574
- toolResults: event.toolResults,
575
- };
576
- await this._extensionRunner.emit(extensionEvent);
795
+ if (event.message.role === "assistant" && !this._boundaryDispatchedMessages.delete(event.message)) {
796
+ await this._dispatchTurnEndBoundary(event.message, event.toolResults);
797
+ }
577
798
  this._turnIndex++;
578
799
  }
579
800
  else if (event.type === "message_start") {
@@ -668,6 +889,9 @@ export class AgentSession {
668
889
  */
669
890
  dispose() {
670
891
  this._backgroundHost.dispose();
892
+ for (const queued of [...this._queuedBackgroundCompletions.values()]) {
893
+ queued.reject(new Error("the session was disposed"));
894
+ }
671
895
  try {
672
896
  this.abortRetry();
673
897
  this.abortCompaction();
@@ -690,6 +914,10 @@ export class AgentSession {
690
914
  // =========================================================================
691
915
  // Read-only State Access
692
916
  // =========================================================================
917
+ /** Refresh the public finalized transcript from the canonical session projection. */
918
+ refreshContext() {
919
+ this._refreshFinalizedContext();
920
+ }
693
921
  /** Full agent state */
694
922
  get state() {
695
923
  return this.agent.state;
@@ -924,7 +1152,15 @@ export class AgentSession {
924
1152
  this._isAgentRunActive = true;
925
1153
  try {
926
1154
  await this.agent.prompt(messages);
927
- while (await this._handlePostAgentRun()) {
1155
+ while (!this._agentRunAbortRequested) {
1156
+ if (await this._handlePostAgentRun()) {
1157
+ if (this._agentRunAbortRequested)
1158
+ break;
1159
+ await this.agent.continue();
1160
+ continue;
1161
+ }
1162
+ if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary()))
1163
+ break;
928
1164
  if (this._agentRunAbortRequested)
929
1165
  break;
930
1166
  await this.agent.continue();
@@ -939,17 +1175,67 @@ export class AgentSession {
939
1175
  await this._emitAgentSettled();
940
1176
  }
941
1177
  }
1178
+ /**
1179
+ * Deliver a background completion through the same queueing path as interactive input:
1180
+ * steering into an active run lands the result right after the current tool batch, and an
1181
+ * idle session starts its own turn. The promise settles only once the message is persisted,
1182
+ * or once it can no longer be persisted, so the caller never releases its claim for a
1183
+ * message the transcript does not contain.
1184
+ */
1185
+ _deliverBackgroundCompletion(message) {
1186
+ if (!this.isStreaming) {
1187
+ // Completion turns skip before_agent_start, but consume nextTurn context.
1188
+ const asides = this._pendingNextTurnMessages.slice();
1189
+ return this._runAgentPrompt(asides.length ? [message, ...asides] : message);
1190
+ }
1191
+ return new Promise((resolve, reject) => {
1192
+ const queued = {
1193
+ drained: false,
1194
+ resolve: () => {
1195
+ if (this._queuedBackgroundCompletions.get(message) !== queued)
1196
+ return;
1197
+ this._queuedBackgroundCompletions.delete(message);
1198
+ resolve();
1199
+ },
1200
+ reject: (reason) => {
1201
+ if (this._queuedBackgroundCompletions.get(message) !== queued)
1202
+ return;
1203
+ this._queuedBackgroundCompletions.delete(message);
1204
+ reject(reason);
1205
+ },
1206
+ };
1207
+ this._queuedBackgroundCompletions.set(message, queued);
1208
+ try {
1209
+ this.agent.steer(message);
1210
+ }
1211
+ catch (error) {
1212
+ queued.reject(error);
1213
+ }
1214
+ });
1215
+ }
1216
+ /**
1217
+ * Fail deliveries the owning run emitted but never persisted. A completion still sitting in
1218
+ * the steering queue when its run ends stays claimed: the next run delivers it, so failing
1219
+ * it here would persist the same message twice.
1220
+ */
1221
+ _failDrainedBackgroundCompletions() {
1222
+ for (const queued of [...this._queuedBackgroundCompletions.values()]) {
1223
+ if (queued.drained)
1224
+ queued.reject(new Error("the completion message was not persisted"));
1225
+ }
1226
+ }
942
1227
  async _handlePostAgentRun() {
943
- const msg = this._lastAssistantMessage;
1228
+ const message = this._lastAssistantMessage;
1229
+ const toolResults = this._lastAssistantToolResults;
944
1230
  this._lastAssistantMessage = undefined;
1231
+ this._lastAssistantToolResults = [];
945
1232
  if (this._agentRunAbortRequested) {
946
1233
  this._finishCancelledRetry();
947
1234
  return false;
948
1235
  }
949
- if (!msg) {
950
- return false;
951
- }
952
- if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
1236
+ if (!message)
1237
+ return this.agent.hasQueuedMessages();
1238
+ if (this._isRetryableError(message) && (await this._prepareRetry(message))) {
953
1239
  if (this._agentRunAbortRequested)
954
1240
  this._finishCancelledRetry();
955
1241
  return !this._agentRunAbortRequested;
@@ -958,22 +1244,46 @@ export class AgentSession {
958
1244
  this._finishCancelledRetry();
959
1245
  return false;
960
1246
  }
961
- if (msg.stopReason === "error" && this._retryAttempt > 0) {
1247
+ if (message.stopReason === "error" && this._retryAttempt > 0) {
962
1248
  this._emit({
963
1249
  type: "auto_retry_end",
964
1250
  success: false,
965
1251
  attempt: this._retryAttempt,
966
- finalError: msg.errorMessage,
1252
+ finalError: message.errorMessage,
967
1253
  });
968
1254
  this._retryAttempt = 0;
969
1255
  }
970
- if (await this._checkCompaction(msg)) {
1256
+ if (await this._checkCompaction(message, true, toolResults)) {
971
1257
  return !this._agentRunAbortRequested;
972
1258
  }
973
- // The agent loop drains both queues before emitting agent_end. Any messages
974
- // here were queued by agent_end extension handlers and need a continuation.
1259
+ // The low-level loop drains both queues before agent_end. Messages queued by
1260
+ // agent_end handlers require a fresh run before pre-settlement handlers fire.
975
1261
  return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();
976
1262
  }
1263
+ async _runBeforeSettleBoundary() {
1264
+ if (!this._extensionRunner.hasHandlers("agent_before_settle"))
1265
+ return this.agent.hasQueuedMessages();
1266
+ this._isBeforeSettle = true;
1267
+ this._abortDuringBeforeSettle = false;
1268
+ try {
1269
+ const result = await this._extensionRunner.emitBoundary({ type: "agent_before_settle", outcome: this._lastActivityOutcome }, (entries) => this._buildBoundaryContext(entries, "agent_before_settle"));
1270
+ this._commitBoundaryDrafts(result.entries);
1271
+ this._flushPendingCustomMessages();
1272
+ const finalContext = this._buildBoundaryContext([], "agent_before_settle");
1273
+ if (this._abortDuringBeforeSettle)
1274
+ return false;
1275
+ const shouldContinue = result.continue || this.agent.hasQueuedMessages();
1276
+ if (shouldContinue && !finalContext.canContinue) {
1277
+ if (result.continue)
1278
+ this._reportInvalidBoundaryContinuation("agent_before_settle");
1279
+ return false;
1280
+ }
1281
+ return shouldContinue;
1282
+ }
1283
+ finally {
1284
+ this._isBeforeSettle = false;
1285
+ }
1286
+ }
977
1287
  async _runInputHandlers(text, images, source, streamingBehavior) {
978
1288
  if (!this._extensionRunner.hasHandlers("input")) {
979
1289
  return { text, images };
@@ -987,6 +1297,25 @@ export class AgentSession {
987
1297
  }
988
1298
  return { text, images };
989
1299
  }
1300
+ async _normalizePromptImages(images) {
1301
+ if (!images)
1302
+ return { images: [], hints: [] };
1303
+ const normalizedImages = [];
1304
+ const hints = [];
1305
+ for (const image of images) {
1306
+ const processed = await processImage(Buffer.from(image.data, "base64"), image.mimeType, {
1307
+ autoResizeImages: this.settingsManager.getImageAutoResize(),
1308
+ resizeOptions: this.model?.inputLimits?.images?.resize,
1309
+ });
1310
+ if (!processed.ok) {
1311
+ hints.push(processed.message);
1312
+ continue;
1313
+ }
1314
+ normalizedImages.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
1315
+ hints.push(...processed.hints);
1316
+ }
1317
+ return { images: normalizedImages, hints };
1318
+ }
990
1319
  /**
991
1320
  * Send a prompt to the agent.
992
1321
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -997,6 +1326,10 @@ export class AgentSession {
997
1326
  * @throws Error if no model selected or no API key available (when not streaming)
998
1327
  */
999
1328
  async prompt(text, options) {
1329
+ if (this._isEmittingAgentSettled) {
1330
+ this._deferredSettledActions.push(async () => await this.prompt(text, options));
1331
+ return;
1332
+ }
1000
1333
  // A user-initiated prompt is the recovery point for failed completion deliveries.
1001
1334
  this.retryBackgroundNotifications();
1002
1335
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
@@ -1073,23 +1406,8 @@ export class AgentSession {
1073
1406
  if (lastAssistant) {
1074
1407
  await this._checkCompaction(lastAssistant, false);
1075
1408
  }
1076
- // Build messages array (custom message if any, then user message)
1077
- messages = [];
1078
- // Add user message
1079
- const userContent = [{ type: "text", text: expandedText }];
1080
- if (currentImages) {
1081
- userContent.push(...currentImages);
1082
- }
1083
- messages.push({
1084
- role: "user",
1085
- content: userContent,
1086
- timestamp: Date.now(),
1087
- });
1088
- // Inject any pending "nextTurn" messages as context alongside the user message
1089
- for (const msg of this._pendingNextTurnMessages) {
1090
- messages.push(msg);
1091
- }
1092
- // Emit before_agent_start extension event
1409
+ // Emit before_agent_start before normalizing images so extension-driven model
1410
+ // selection determines the resize profile used for the request and history.
1093
1411
  const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
1094
1412
  const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptOptions);
1095
1413
  // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
@@ -1099,6 +1417,22 @@ export class AgentSession {
1099
1417
  result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
1100
1418
  if (!handlerEditedTools)
1101
1419
  result.systemPromptOptions.selectedTools = this.getActiveToolNames();
1420
+ const normalized = await this._normalizePromptImages(currentImages);
1421
+ const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;
1422
+ // Build messages only after hooks and image normalization have completed.
1423
+ messages = [];
1424
+ const userContent = [{ type: "text", text: userText }];
1425
+ userContent.push(...normalized.images);
1426
+ messages.push({
1427
+ role: "user",
1428
+ content: userContent,
1429
+ timestamp: Date.now(),
1430
+ });
1431
+ // Inject any pending "nextTurn" messages as context alongside the user message.
1432
+ // They stay queued until _persistMessage() confirms the append.
1433
+ for (const msg of this._pendingNextTurnMessages) {
1434
+ messages.push(msg);
1435
+ }
1102
1436
  for (const msg of result.messages) {
1103
1437
  messages.push({
1104
1438
  role: "custom",
@@ -1123,6 +1457,10 @@ export class AgentSession {
1123
1457
  return;
1124
1458
  }
1125
1459
  preflightResult?.(true);
1460
+ // The user's message is now established, so a completion may join this run through the
1461
+ // steering queue instead of waiting for the turn to end. Everything above still holds
1462
+ // the pause, so no delivery can race input hooks, model validation or compaction.
1463
+ resumeBackground();
1126
1464
  await this._runAgentPrompt(messages);
1127
1465
  }
1128
1466
  finally {
@@ -1251,11 +1589,7 @@ export class AgentSession {
1251
1589
  if (images) {
1252
1590
  content.push(...images);
1253
1591
  }
1254
- this.agent.followUp({
1255
- role: "user",
1256
- content,
1257
- timestamp: Date.now(),
1258
- });
1592
+ this.agent.followUp({ role: "user", content, timestamp: Date.now() });
1259
1593
  }
1260
1594
  /**
1261
1595
  * Throw an error if the text is an extension command.
@@ -1303,6 +1637,10 @@ export class AgentSession {
1303
1637
  }
1304
1638
  }
1305
1639
  else if (options?.triggerTurn) {
1640
+ if (this._isEmittingAgentSettled) {
1641
+ this._deferredSettledActions.push(async () => await this._runAgentPrompt(appMessage));
1642
+ return;
1643
+ }
1306
1644
  await this._runAgentPrompt(appMessage);
1307
1645
  }
1308
1646
  else if (this.isStreaming) {
@@ -1317,8 +1655,8 @@ export class AgentSession {
1317
1655
  }
1318
1656
  }
1319
1657
  _appendCustomMessage(appMessage) {
1320
- this.agent.state.messages.push(appMessage);
1321
1658
  this._persistMessage(appMessage);
1659
+ this._refreshFinalizedContext();
1322
1660
  this._backgroundHost.messageAcknowledgement(appMessage)?.();
1323
1661
  this._emit({ type: "message_start", message: appMessage });
1324
1662
  this._emit({ type: "message_end", message: appMessage });
@@ -1384,6 +1722,10 @@ export class AgentSession {
1384
1722
  this._steeringMessages = [];
1385
1723
  this._followUpMessages = [];
1386
1724
  this.agent.clearAllQueues();
1725
+ // Clearing the queues drops queued completions; fail them so their claims retry.
1726
+ for (const queued of [...this._queuedBackgroundCompletions.values()]) {
1727
+ queued.reject(new Error("the completion message was dequeued before delivery"));
1728
+ }
1387
1729
  this._emitQueueUpdate();
1388
1730
  return { steering, followUp };
1389
1731
  }
@@ -1412,6 +1754,8 @@ export class AgentSession {
1412
1754
  this.abortRetry();
1413
1755
  this.abortCompaction();
1414
1756
  this.abortBranchSummary();
1757
+ if (this._isBeforeSettle)
1758
+ this._abortDuringBeforeSettle = true;
1415
1759
  this.agent.abort();
1416
1760
  await this.waitForIdle();
1417
1761
  }
@@ -1732,9 +2076,8 @@ export class AgentSession {
1732
2076
  }
1733
2077
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
1734
2078
  const newEntries = this.sessionManager.getEntries();
1735
- const sessionContext = this.sessionManager.buildSessionContext();
1736
- this.agent.state.messages = sessionContext.messages;
1737
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2079
+ this._refreshFinalizedContext();
2080
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
1738
2081
  // Get the saved compaction entry for the extension event
1739
2082
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
1740
2083
  if (this._extensionRunner && savedCompactionEntry) {
@@ -1829,7 +2172,7 @@ export class AgentSession {
1829
2172
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1830
2173
  * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
1831
2174
  */
1832
- async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
2175
+ async _checkCompaction(assistantMessage, skipAbortedCheck = true, toolResults = []) {
1833
2176
  const settings = this.settingsManager.getCompactionSettings(this.model);
1834
2177
  if (!settings.enabled)
1835
2178
  return false;
@@ -1853,8 +2196,27 @@ export class AgentSession {
1853
2196
  // Automatic cases 1 and 2: context overflow.
1854
2197
  // A length stop is recoverable when output ended below the model's original desired limit,
1855
2198
  // independent of the configured context size or any context-clamped provider request limit.
1856
- const contextOverflow = sameModel && isContextOverflow(assistantMessage, contextWindow);
1857
- const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
2199
+ const currentProjection = this.sessionManager.buildSessionProjection();
2200
+ const assistantEntryId = this._findPersistedMessageEntryId(assistantMessage);
2201
+ const assistantIsProjected = assistantEntryId === undefined ||
2202
+ currentProjection.entries.some((entry) => entry.sourceEntry.id === assistantEntryId &&
2203
+ entry.messages.some((message) => message.role === "assistant"));
2204
+ const branch = this.sessionManager.getBranch();
2205
+ const assistantIndex = assistantEntryId ? branch.findIndex((entry) => entry.id === assistantEntryId) : -1;
2206
+ const entriesAfterAssistant = assistantIndex >= 0 ? branch.slice(assistantIndex + 1) : [];
2207
+ const hasPostAssistantContextEdit = entriesAfterAssistant.some((entry) => entry.type === "context_edit");
2208
+ const latestAssistantEdit = entriesAfterAssistant
2209
+ .filter((entry) => entry.type === "context_edit" && entry.targetId === assistantEntryId)
2210
+ .at(-1);
2211
+ const assistantRetainedForExplicitRecovery = assistantEntryId === undefined ||
2212
+ (!entriesAfterAssistant.some((entry) => entry.type === "compaction") &&
2213
+ latestAssistantEdit?.replacement !== null);
2214
+ const assistantUsageMatchesProjection = assistantIsProjected && !hasPostAssistantContextEdit;
2215
+ const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);
2216
+ const contextOverflow = sameModel &&
2217
+ ((explicitOverflow && assistantRetainedForExplicitRecovery) ||
2218
+ (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));
2219
+ const recoverableLength = sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
1858
2220
  if (contextOverflow || recoverableLength) {
1859
2221
  const willRetry = assistantMessage.stopReason !== "stop";
1860
2222
  // Case 2: the response completed successfully. Compact, but do not retry because
@@ -1883,13 +2245,9 @@ export class AgentSession {
1883
2245
  });
1884
2246
  return false;
1885
2247
  }
1886
- // Case 1: remove the failed or truncated message from agent state, compact, and
1887
- // retry once. The message remains in session history but is excluded from retry context.
2248
+ // Persistently omit the selected final attempt before post-run recovery compaction.
1888
2249
  this._overflowRecoveryAttempted = true;
1889
- const messages = this.agent.state.messages;
1890
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
1891
- this.agent.state.messages = messages.slice(0, -1);
1892
- }
2250
+ this._omitRecoveryAttempt(assistantMessage, toolResults);
1893
2251
  return await this._runAutoCompaction("overflow", willRetry);
1894
2252
  }
1895
2253
  // Case 3: threshold compaction without retry.
@@ -1897,8 +2255,13 @@ export class AgentSession {
1897
2255
  // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
1898
2256
  // responses can still compact and do not reset context accounting.
1899
2257
  let contextTokens;
2258
+ const projection = currentProjection;
2259
+ const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");
1900
2260
  const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
1901
- if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
2261
+ if (hasContextEdits) {
2262
+ contextTokens = estimateProjectedContextTokens(projection, branch).tokens;
2263
+ }
2264
+ else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
1902
2265
  const messages = this.agent.state.messages;
1903
2266
  const estimate = estimateContextTokens(messages);
1904
2267
  // Without provider usage, estimate.tokens is the pure message-size estimate.
@@ -2005,9 +2368,8 @@ export class AgentSession {
2005
2368
  abortController.signal.throwIfAborted();
2006
2369
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
2007
2370
  const newEntries = this.sessionManager.getEntries();
2008
- const sessionContext = this.sessionManager.buildSessionContext();
2009
- this.agent.state.messages = sessionContext.messages;
2010
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2371
+ this._refreshFinalizedContext();
2372
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
2011
2373
  // Get the saved compaction entry for the extension event
2012
2374
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
2013
2375
  if (this._extensionRunner && savedCompactionEntry) {
@@ -2028,19 +2390,8 @@ export class AgentSession {
2028
2390
  details,
2029
2391
  };
2030
2392
  this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
2031
- if (willRetry) {
2032
- const messages = this.agent.state.messages;
2033
- const lastMsg = messages[messages.length - 1];
2034
- // The overflow response was persisted on message_end before _checkCompaction() removed it
2035
- // from agent state. Rebuilding state from the new compaction can restore that kept entry,
2036
- // leaving an assistant as the final message. agent.continue() rejects that state, so remove
2037
- // the retriable error or truncated-length response again before continuing the interrupted turn.
2038
- if (lastMsg?.role === "assistant" &&
2039
- (lastMsg.stopReason === "error" || lastMsg.stopReason === "length")) {
2040
- this.agent.state.messages = messages.slice(0, -1);
2041
- }
2393
+ if (willRetry)
2042
2394
  return true;
2043
- }
2044
2395
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
2045
2396
  // Continue once so queued messages are delivered.
2046
2397
  return this.agent.hasQueuedMessages();
@@ -2524,11 +2875,8 @@ export class AgentSession {
2524
2875
  delayMs,
2525
2876
  errorMessage: message.errorMessage || "Unknown error",
2526
2877
  });
2527
- // Remove error message from agent state (keep in session for history)
2528
- const messages = this.agent.state.messages;
2529
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2530
- this.agent.state.messages = messages.slice(0, -1);
2531
- }
2878
+ // Keep the failed attempt in raw history while durably omitting it from model projection.
2879
+ this._omitRecoveryAttempt(message);
2532
2880
  // Wait with exponential backoff (abortable)
2533
2881
  this._retryAbortController = new AbortController();
2534
2882
  try {
@@ -2620,10 +2968,8 @@ export class AgentSession {
2620
2968
  this._pendingBashMessages.push(bashMessage);
2621
2969
  }
2622
2970
  else {
2623
- // Add to agent state immediately
2624
- this.agent.state.messages.push(bashMessage);
2625
- // Save to session
2626
2971
  this.sessionManager.appendMessage(bashMessage);
2972
+ this._refreshFinalizedContext();
2627
2973
  }
2628
2974
  }
2629
2975
  /**
@@ -2650,12 +2996,10 @@ export class AgentSession {
2650
2996
  if (this._pendingBashMessages.length === 0)
2651
2997
  return;
2652
2998
  for (const bashMessage of this._pendingBashMessages) {
2653
- // Add to agent state
2654
- this.agent.state.messages.push(bashMessage);
2655
- // Save to session
2656
2999
  this.sessionManager.appendMessage(bashMessage);
2657
3000
  }
2658
3001
  this._pendingBashMessages = [];
3002
+ this._refreshFinalizedContext();
2659
3003
  }
2660
3004
  // =========================================================================
2661
3005
  // Session Management
@@ -2832,9 +3176,8 @@ export class AgentSession {
2832
3176
  this.sessionManager.appendLabelChange(targetId, label);
2833
3177
  }
2834
3178
  this._backgroundHost.restoreHistory();
2835
- // Update agent state
2836
- const sessionContext = this.sessionManager.buildSessionContext();
2837
- this.agent.state.messages = sessionContext.messages;
3179
+ // Update finalized context from the canonical session projection.
3180
+ this._refreshFinalizedContext();
2838
3181
  this._restoreToolsFromTranscript();
2839
3182
  // Emit session_tree event
2840
3183
  await this._extensionRunner.emit({
@@ -2934,30 +3277,24 @@ export class AgentSession {
2934
3277
  // After compaction, the last assistant usage reflects pre-compaction context size.
2935
3278
  // We can only trust usage from an assistant that responded after the latest compaction.
2936
3279
  // If no such assistant exists, context token count is unknown until the next LLM response.
2937
- const branchEntries = this.sessionManager.getBranch();
2938
- const latestCompaction = getLatestCompactionEntry(branchEntries);
3280
+ const projection = this.sessionManager.buildSessionProjection();
3281
+ const branch = this.sessionManager.getBranch();
3282
+ const latestCompaction = getLatestCompactionEntry(branch);
2939
3283
  if (latestCompaction) {
2940
- // Check if there's a valid assistant usage after the compaction boundary
2941
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
2942
- let hasPostCompactionUsage = false;
2943
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
2944
- const entry = branchEntries[i];
2945
- if (entry.type === "message" && entry.message.role === "assistant") {
2946
- const assistant = entry.message;
2947
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
2948
- const contextTokens = calculateContextTokens(assistant.usage);
2949
- if (contextTokens > 0) {
2950
- hasPostCompactionUsage = true;
2951
- break;
2952
- }
2953
- }
2954
- }
2955
- }
2956
- if (!hasPostCompactionUsage) {
3284
+ const projectedAssistants = new Set(projection.entries.flatMap((entry) => entry.messages.some((message) => message.role === "assistant" &&
3285
+ message.stopReason !== "aborted" &&
3286
+ message.stopReason !== "error" &&
3287
+ calculateContextTokens(message.usage) > 0)
3288
+ ? [entry.sourceEntry.id]
3289
+ : []));
3290
+ const compactionIndex = branch.findIndex((entry) => entry.id === latestCompaction.id);
3291
+ const hasPostCompactionUsage = branch
3292
+ .slice(compactionIndex + 1)
3293
+ .some((entry) => projectedAssistants.has(entry.id));
3294
+ if (!hasPostCompactionUsage)
2957
3295
  return { tokens: null, contextWindow, percent: null };
2958
- }
2959
3296
  }
2960
- const estimate = estimateContextTokens(this.messages);
3297
+ const estimate = estimateProjectedContextTokens(projection, branch);
2961
3298
  const percent = (estimate.tokens / contextWindow) * 100;
2962
3299
  return {
2963
3300
  tokens: estimate.tokens,