@astralyn/pi 0.86.1 → 0.87.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 (105) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/bundle/chunks/chunk-INDBRWNK.js +2 -0
  3. package/dist/bundle/chunks/{chunk-GTBMYBFG.js → chunk-RKLIHRPH.js} +39 -33
  4. package/dist/bundle/chunks/github-copilot.js +1 -1
  5. package/dist/bundle/chunks/{openai-completions-6SMWQWQN.js → openai-completions-JZCEEJ43.js} +1 -1
  6. package/dist/bundle/chunks/{virtual-modules-OHUZ2T7X.js → virtual-modules-CG7NJ5WO.js} +1 -1
  7. package/dist/bundle/cli.js +1 -1
  8. package/dist/bundle/index.js +1 -1
  9. package/dist/bundle/rpc-entry.js +1 -1
  10. package/dist/cli/file-processor.d.ts +1 -1
  11. package/dist/cli/file-processor.d.ts.map +1 -1
  12. package/dist/cli/file-processor.js.map +1 -1
  13. package/dist/core/agent-session-runtime.js +1 -1
  14. package/dist/core/agent-session-runtime.js.map +1 -1
  15. package/dist/core/agent-session.d.ts +31 -4
  16. package/dist/core/agent-session.d.ts.map +1 -1
  17. package/dist/core/agent-session.js +395 -126
  18. package/dist/core/agent-session.js.map +1 -1
  19. package/dist/core/cache-warmer.d.ts +1 -0
  20. package/dist/core/cache-warmer.d.ts.map +1 -1
  21. package/dist/core/cache-warmer.js +15 -1
  22. package/dist/core/cache-warmer.js.map +1 -1
  23. package/dist/core/compaction/compaction.d.ts +3 -1
  24. package/dist/core/compaction/compaction.d.ts.map +1 -1
  25. package/dist/core/compaction/compaction.js +146 -48
  26. package/dist/core/compaction/compaction.js.map +1 -1
  27. package/dist/core/crash-log.d.ts +5 -0
  28. package/dist/core/crash-log.d.ts.map +1 -1
  29. package/dist/core/crash-log.js +68 -0
  30. package/dist/core/crash-log.js.map +1 -1
  31. package/dist/core/export-html/template.js +6 -1
  32. package/dist/core/extensions/index.d.ts +1 -1
  33. package/dist/core/extensions/index.d.ts.map +1 -1
  34. package/dist/core/extensions/index.js.map +1 -1
  35. package/dist/core/extensions/runner.d.ts +16 -3
  36. package/dist/core/extensions/runner.d.ts.map +1 -1
  37. package/dist/core/extensions/runner.js +110 -5
  38. package/dist/core/extensions/runner.js.map +1 -1
  39. package/dist/core/extensions/types.d.ts +77 -6
  40. package/dist/core/extensions/types.d.ts.map +1 -1
  41. package/dist/core/extensions/types.js.map +1 -1
  42. package/dist/core/index.d.ts +1 -1
  43. package/dist/core/index.d.ts.map +1 -1
  44. package/dist/core/index.js.map +1 -1
  45. package/dist/core/model-config.d.ts +52 -0
  46. package/dist/core/model-config.d.ts.map +1 -1
  47. package/dist/core/model-config.js +16 -0
  48. package/dist/core/model-config.js.map +1 -1
  49. package/dist/core/prompt-templates.d.ts +6 -1
  50. package/dist/core/prompt-templates.d.ts.map +1 -1
  51. package/dist/core/prompt-templates.js +61 -35
  52. package/dist/core/prompt-templates.js.map +1 -1
  53. package/dist/core/provider-composer.d.ts +1 -0
  54. package/dist/core/provider-composer.d.ts.map +1 -1
  55. package/dist/core/provider-composer.js +19 -0
  56. package/dist/core/provider-composer.js.map +1 -1
  57. package/dist/core/resource-loader.d.ts.map +1 -1
  58. package/dist/core/resource-loader.js +6 -2
  59. package/dist/core/resource-loader.js.map +1 -1
  60. package/dist/core/sdk.d.ts.map +1 -1
  61. package/dist/core/sdk.js +3 -4
  62. package/dist/core/sdk.js.map +1 -1
  63. package/dist/core/session-manager.d.ts +36 -9
  64. package/dist/core/session-manager.d.ts.map +1 -1
  65. package/dist/core/session-manager.js +97 -7
  66. package/dist/core/session-manager.js.map +1 -1
  67. package/dist/core/tools/read.d.ts +4 -1
  68. package/dist/core/tools/read.d.ts.map +1 -1
  69. package/dist/core/tools/read.js +5 -1
  70. package/dist/core/tools/read.js.map +1 -1
  71. package/dist/extensions/btw/agent.d.ts.map +1 -1
  72. package/dist/extensions/btw/agent.js +8 -5
  73. package/dist/extensions/btw/agent.js.map +1 -1
  74. package/dist/index.d.ts +2 -2
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +1 -1
  77. package/dist/index.js.map +1 -1
  78. package/dist/main.d.ts.map +1 -1
  79. package/dist/main.js +4 -3
  80. package/dist/main.js.map +1 -1
  81. package/dist/modes/interactive/bug-report.d.ts.map +1 -1
  82. package/dist/modes/interactive/bug-report.js +4 -0
  83. package/dist/modes/interactive/bug-report.js.map +1 -1
  84. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  85. package/dist/modes/interactive/components/tree-selector.js +7 -0
  86. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  87. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  88. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  89. package/dist/modes/interactive/interactive-mode.js +64 -2
  90. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  91. package/dist/utils/mime.js +1 -1
  92. package/dist/utils/mime.js.map +1 -1
  93. package/dist/utils/tool-result-images.d.ts +3 -1
  94. package/dist/utils/tool-result-images.d.ts.map +1 -1
  95. package/dist/utils/tool-result-images.js +4 -1
  96. package/dist/utils/tool-result-images.js.map +1 -1
  97. package/docs/compaction.md +23 -4
  98. package/docs/extensions.md +72 -8
  99. package/docs/models.md +30 -1
  100. package/docs/sdk.md +14 -3
  101. package/docs/session-format.md +25 -4
  102. package/examples/plugins/pi-example-plugin/src/session.ts +3 -2
  103. package/npm-shrinkwrap.json +43 -43
  104. package/package.json +8 -8
  105. 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";
@@ -100,6 +102,14 @@ export class AgentSession {
100
102
  this._bashAbortControllers = new Set();
101
103
  this._pendingBashMessages = [];
102
104
  this._turnIndex = 0;
105
+ this._entryIdsByMessage = new WeakMap();
106
+ this._boundaryDispatchedMessages = new WeakSet();
107
+ this._lastAssistantToolResults = [];
108
+ this._lastActivityOutcome = "completed";
109
+ this._isBeforeSettle = false;
110
+ this._abortDuringBeforeSettle = false;
111
+ this._isEmittingAgentSettled = false;
112
+ this._deferredSettledActions = [];
103
113
  this._baseToolDefinitions = new Map();
104
114
  this._extensionMode = "print";
105
115
  // Tool registry for extension getTools/setTools
@@ -107,8 +117,6 @@ export class AgentSession {
107
117
  this._toolDefinitions = new Map();
108
118
  this._toolPromptSnippets = new Map();
109
119
  this._toolPromptGuidelines = new Map();
110
- // Track last assistant message for auto-compaction check
111
- this._lastAssistantMessage = undefined;
112
120
  /** Internal handler for agent events - shared by subscribe and reconnect */
113
121
  this._handleAgentEvent = async (event) => {
114
122
  // Preserve host delivery identity before extension message_end transforms.
@@ -136,18 +144,16 @@ export class AgentSession {
136
144
  }
137
145
  }
138
146
  }
139
- // Emit to extensions first
147
+ // Emit to extensions first, then notify public listeners.
140
148
  await this._emitExtensionEvent(event);
141
- // Notify all listeners
142
149
  this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
143
150
  // Handle session persistence
144
151
  if (event.type === "message_end") {
145
152
  if (this._persistMessage(event.message))
146
153
  acknowledgeBackground?.();
147
- // Track assistant message for auto-compaction (checked on agent_end)
148
154
  if (event.message.role === "assistant") {
149
- this._lastAssistantMessage = event.message;
150
155
  const assistantMsg = event.message;
156
+ this._lastAssistantMessage = assistantMsg;
151
157
  if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "length") {
152
158
  this._overflowRecoveryAttempted = false;
153
159
  }
@@ -169,6 +175,7 @@ export class AgentSession {
169
175
  // extension and listener dispatch above also picks up messages that turn_end
170
176
  // handlers queued.
171
177
  if (event.type === "turn_end") {
178
+ this._lastAssistantToolResults = event.toolResults;
172
179
  this._flushPendingCustomMessages();
173
180
  }
174
181
  };
@@ -212,6 +219,8 @@ export class AgentSession {
212
219
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
213
220
  this._installAgentToolHooks();
214
221
  this._installAgentNextTurnRefresh();
222
+ this._installAgentRequestProjection();
223
+ this._installAgentBoundaryHooks();
215
224
  this._installAgentForcedPromptProjection();
216
225
  this._buildRuntime({
217
226
  activeToolNames: this._initialActiveToolNames,
@@ -264,7 +273,8 @@ export class AgentSession {
264
273
  }),
265
274
  };
266
275
  const currentSystemPrompt = this.systemPrompt;
267
- const messages = structuredClone(this.agent.state.messages);
276
+ // The canonical projection is what the next request converts; the inspection cache may lag it.
277
+ const messages = structuredClone(this.sessionManager.buildSessionProjection().messages);
268
278
  const convert = this.agent.convertToLlm;
269
279
  const transform = this.agent.transformContext;
270
280
  const prepared = this._captureModelContext
@@ -375,8 +385,10 @@ export class AgentSession {
375
385
  : undefined;
376
386
  const content = hookResult?.content ?? result.content ?? [];
377
387
  // Runs after the extension hook so images injected or replaced by extensions are normalized too.
388
+ const resizeOptions = this.model?.inputLimits?.images?.resize;
378
389
  const normalizedContent = await normalizeToolResultImages(content, {
379
390
  autoResizeImages: this.settingsManager.getImageAutoResize(),
391
+ ...(resizeOptions ? { resizeOptions } : {}),
380
392
  });
381
393
  if (!hookResult && normalizedContent === content) {
382
394
  return undefined;
@@ -392,15 +404,83 @@ export class AgentSession {
392
404
  async _compactBeforeNextAssistantResponse(context) {
393
405
  const model = this.model;
394
406
  const settings = this.settingsManager.getCompactionSettings(model);
407
+ const projection = this.sessionManager.buildSessionProjection();
395
408
  if (!model ||
396
409
  model.contextWindow <= 0 ||
397
- !shouldCompact(estimateContextTokens(context.messages).tokens, model.contextWindow, settings)) {
398
- return context;
410
+ !shouldCompact(estimateProjectedContextTokens(projection, this.sessionManager.getBranch()).tokens, model.contextWindow, settings)) {
411
+ return { ...context, messages: projection.messages };
399
412
  }
400
413
  await this._runAutoCompaction("threshold", false);
401
- return {
402
- ...context,
403
- messages: this.agent.state.messages.slice(),
414
+ return { ...context, messages: this.sessionManager.buildSessionProjection().messages };
415
+ }
416
+ _installAgentRequestProjection() {
417
+ const previousPrepareRequest = this.agent.prepareRequest;
418
+ this.agent.prepareRequest = async (request, signal) => {
419
+ const canonicalContext = {
420
+ ...request.context,
421
+ messages: this.sessionManager.buildSessionProjection().messages,
422
+ // Messages declare the provider-visible loadout; context.tools keeps executable implementations.
423
+ tools: this.agent.state.tools.slice(),
424
+ };
425
+ const previous = await previousPrepareRequest?.({
426
+ ...request,
427
+ context: canonicalContext,
428
+ model: this.agent.state.model,
429
+ thinkingLevel: this.agent.state.thinkingLevel,
430
+ }, signal);
431
+ return {
432
+ ...previous,
433
+ context: previous?.context ?? canonicalContext,
434
+ model: previous?.model ?? this.agent.state.model,
435
+ thinkingLevel: previous?.thinkingLevel ?? this.agent.state.thinkingLevel,
436
+ };
437
+ };
438
+ }
439
+ async _dispatchTurnEndBoundary(message, toolResults) {
440
+ this._lastActivityOutcome =
441
+ message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
442
+ const messageEntryId = this._findPersistedMessageEntryId(message);
443
+ if (!this._extensionRunner.hasHandlers("turn_end"))
444
+ return false;
445
+ if (!messageEntryId) {
446
+ this._extensionRunner.emitError({
447
+ extensionPath: "<boundary>",
448
+ event: "turn_end",
449
+ error: "turn_end could not resolve the persisted assistant entry ID",
450
+ });
451
+ return false;
452
+ }
453
+ const toolResultEntryIds = toolResults.flatMap((result) => {
454
+ const entryId = this._findPersistedMessageEntryId(result);
455
+ return entryId ? [entryId] : [];
456
+ });
457
+ const boundary = await this._extensionRunner.emitBoundary({
458
+ type: "turn_end",
459
+ turnIndex: this._turnIndex,
460
+ message,
461
+ toolResults,
462
+ messageEntryId,
463
+ toolResultEntryIds,
464
+ outcome: this._lastActivityOutcome,
465
+ }, (entries) => this._buildBoundaryContext(entries, "turn_end"));
466
+ this._commitBoundaryDrafts(boundary.entries);
467
+ if (boundary.continue && !this._buildBoundaryContext([], "turn_end").canContinue) {
468
+ this._reportInvalidBoundaryContinuation("turn_end");
469
+ return false;
470
+ }
471
+ return boundary.continue;
472
+ }
473
+ _installAgentBoundaryHooks() {
474
+ const previousFinishTurn = this.agent.finishTurn;
475
+ this.agent.finishTurn = async (turn, signal) => {
476
+ this._boundaryDispatchedMessages.add(turn.message);
477
+ const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
478
+ const previousDecision = await previousFinishTurn?.(turn, signal);
479
+ if (previousDecision?.action === "end")
480
+ return previousDecision;
481
+ if (extensionContinue || previousDecision?.action === "continue")
482
+ return { action: "continue" };
483
+ return undefined;
404
484
  };
405
485
  }
406
486
  _installAgentNextTurnRefresh() {
@@ -409,7 +489,10 @@ export class AgentSession {
409
489
  ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal)
410
490
  : undefined);
411
491
  this.agent.prepareNextTurnWithContext = async (turn, signal) => {
412
- const context = await this._compactBeforeNextAssistantResponse(turn.context);
492
+ const context = await this._compactBeforeNextAssistantResponse({
493
+ ...turn.context,
494
+ messages: this.sessionManager.buildSessionProjection().messages,
495
+ });
413
496
  const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);
414
497
  const nextContext = previousSnapshot?.context ?? context;
415
498
  const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;
@@ -439,6 +522,84 @@ export class AgentSession {
439
522
  // =========================================================================
440
523
  // Event Subscription
441
524
  // =========================================================================
525
+ _refreshFinalizedContext() {
526
+ const projection = this.sessionManager.buildSessionProjection();
527
+ for (const entry of projection.entries) {
528
+ for (const message of entry.messages)
529
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
530
+ }
531
+ this.agent.state.messages = projection.messages;
532
+ }
533
+ _applyBoundaryDrafts(manager, drafts) {
534
+ const appended = [];
535
+ for (const draft of drafts) {
536
+ let entryId;
537
+ switch (draft.type) {
538
+ case "custom":
539
+ entryId = manager.appendCustomEntry(draft.customType, draft.data);
540
+ break;
541
+ case "custom_message":
542
+ entryId = manager.appendCustomMessageEntry(draft.customType, draft.content, draft.display, draft.details);
543
+ break;
544
+ case "context_edit":
545
+ entryId = manager.appendContextEdit(draft.targetId, draft.replacement);
546
+ break;
547
+ case "compaction": {
548
+ const tokensBefore = estimateProjectedContextTokens(manager.buildSessionProjection(), manager.getBranch()).tokens;
549
+ entryId = manager.appendCompaction(draft.summary, draft.firstKeptEntryId, tokensBefore, draft.details, true, draft.usage);
550
+ break;
551
+ }
552
+ }
553
+ const entry = manager.getEntry(entryId);
554
+ if (entry)
555
+ appended.push(entry);
556
+ }
557
+ return appended;
558
+ }
559
+ _createBoundaryPreviewManager(drafts) {
560
+ const header = this.sessionManager.getHeader();
561
+ if (!header)
562
+ throw new Error("Session header is missing");
563
+ const manager = SessionManager.inMemory(this._cwd, undefined, [header, ...this.sessionManager.getBranch()]);
564
+ this._applyBoundaryDrafts(manager, drafts);
565
+ return manager;
566
+ }
567
+ _getPendingBoundaryMessages() {
568
+ return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];
569
+ }
570
+ _buildBoundaryContext(drafts, boundary) {
571
+ const projection = this._createBoundaryPreviewManager(drafts).buildSessionProjection();
572
+ const pendingMessages = this._getPendingBoundaryMessages();
573
+ const llmMessages = convertToLlm(projection.messages);
574
+ const finalRole = llmMessages[llmMessages.length - 1]?.role;
575
+ const hasNonSystemContext = llmMessages.some((message) => message.role !== "system");
576
+ const contextCanContinue = hasNonSystemContext && finalRole !== "assistant";
577
+ const pendingCustomContext = this._pendingCustomMessages.length > 0;
578
+ return {
579
+ contextEntries: projection.entries,
580
+ contextMessages: projection.messages,
581
+ llmMessages,
582
+ pendingMessages,
583
+ canContinue: contextCanContinue ||
584
+ pendingCustomContext ||
585
+ (boundary === "turn_end"
586
+ ? this.agent.hasQueuedMessages()
587
+ : finalRole === "assistant" && this.agent.hasQueuedMessages()),
588
+ };
589
+ }
590
+ _commitBoundaryDrafts(drafts) {
591
+ const appended = this._applyBoundaryDrafts(this.sessionManager, drafts);
592
+ this._refreshFinalizedContext();
593
+ for (const entry of appended)
594
+ this._emit({ type: "entry_appended", entry });
595
+ }
596
+ _reportInvalidBoundaryContinuation(event) {
597
+ this._extensionRunner.emitError({
598
+ extensionPath: "<boundary>",
599
+ event,
600
+ error: `${event} requested continuation without runnable model context`,
601
+ });
602
+ }
442
603
  /** Emit an event to all listeners */
443
604
  _emit(event) {
444
605
  for (const l of this._eventListeners) {
@@ -476,21 +637,35 @@ export class AgentSession {
476
637
  }
477
638
  async _emitAgentSettled() {
478
639
  this._cacheWarmer?.onAgentSettled();
640
+ // Background completions stay paused until runs requested by settled handlers have started,
641
+ // so a deferred prompt never races a delivery for the same idle gap.
479
642
  const resumeBackground = this.pauseBackgroundNotifications();
480
643
  this._isAgentRunActive = false;
481
644
  try {
482
- await this._extensionRunner.emit({ type: "agent_settled" });
483
- this._emit({ type: "agent_settled" });
645
+ this._isEmittingAgentSettled = true;
646
+ try {
647
+ await this._extensionRunner.emit({ type: "agent_settled" });
648
+ this._emit({ type: "agent_settled" });
649
+ }
650
+ finally {
651
+ this._isEmittingAgentSettled = false;
652
+ }
653
+ for (const action of this._deferredSettledActions.splice(0))
654
+ await action();
484
655
  }
485
656
  finally {
486
657
  this._resolveIdleWaitIfIdle();
487
658
  resumeBackground();
488
659
  }
489
660
  }
490
- /** Persist model/custom messages and consume queued context only after the append succeeds. */
661
+ /**
662
+ * Persist model/custom messages and consume queued context only after the append succeeds.
663
+ * Returns the persisted entry ID, which boundary events and recovery omissions resolve later.
664
+ */
491
665
  _persistMessage(message) {
666
+ let entryId;
492
667
  if (message.role === "custom") {
493
- this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
668
+ entryId = this.sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
494
669
  const pendingIndex = this._pendingNextTurnMessages.indexOf(message);
495
670
  if (pendingIndex !== -1)
496
671
  this._pendingNextTurnMessages.splice(pendingIndex, 1);
@@ -499,15 +674,16 @@ export class AgentSession {
499
674
  message.role === "user" ||
500
675
  message.role === "assistant" ||
501
676
  message.role === "toolResult") {
502
- this.sessionManager.appendMessage(message);
677
+ entryId = this.sessionManager.appendMessage(message);
503
678
  }
504
679
  else {
505
680
  // Bash execution, compaction and branch summaries are persisted by their owners.
506
- return false;
681
+ return undefined;
507
682
  }
683
+ this._entryIdsByMessage.set(message, entryId);
508
684
  if (message.role === "toolResult")
509
685
  this._backgroundHost.acknowledgeWaitResult(message.details);
510
- return true;
686
+ return entryId;
511
687
  }
512
688
  _willRetryAfterAgentEnd(event) {
513
689
  if (this._agentRunAbortRequested)
@@ -524,6 +700,47 @@ export class AgentSession {
524
700
  }
525
701
  return false;
526
702
  }
703
+ _findPersistedMessageEntryId(message) {
704
+ const mapped = this._entryIdsByMessage.get(message);
705
+ if (mapped)
706
+ return mapped;
707
+ for (const entry of [...this.sessionManager.getBranch()].reverse()) {
708
+ if (entry.type === "message" && entry.message === message)
709
+ return entry.id;
710
+ }
711
+ const messageIndex = this.agent.state.messages.indexOf(message);
712
+ if (messageIndex < 0)
713
+ return undefined;
714
+ const projection = this.sessionManager.buildSessionProjection();
715
+ let projectedIndex = 0;
716
+ for (const entry of projection.entries) {
717
+ for (let i = 0; i < entry.messages.length; i++) {
718
+ if (projectedIndex === messageIndex) {
719
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
720
+ return entry.sourceEntry.id;
721
+ }
722
+ projectedIndex++;
723
+ }
724
+ }
725
+ return undefined;
726
+ }
727
+ _omitRecoveryAttempt(message, toolResults = []) {
728
+ const targets = [message, ...toolResults];
729
+ const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
730
+ const unresolvedProjectedTarget = targets.some((target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target));
731
+ if (unresolvedProjectedTarget) {
732
+ throw new Error("Cannot persist recovery omission because a projected message has no source entry");
733
+ }
734
+ for (const targetId of targetIds) {
735
+ if (!targetId)
736
+ continue;
737
+ const editId = this.sessionManager.appendContextEdit(targetId, null);
738
+ const entry = this.sessionManager.getEntry(editId);
739
+ if (entry)
740
+ this._emit({ type: "entry_appended", entry });
741
+ }
742
+ this._refreshFinalizedContext();
743
+ }
527
744
  /** Find the last assistant message in agent state (including aborted ones) */
528
745
  _findLastAssistantMessage() {
529
746
  const messages = this.agent.state.messages;
@@ -567,13 +784,9 @@ export class AgentSession {
567
784
  await this._extensionRunner.emit(extensionEvent);
568
785
  }
569
786
  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);
787
+ if (event.message.role === "assistant" && !this._boundaryDispatchedMessages.delete(event.message)) {
788
+ await this._dispatchTurnEndBoundary(event.message, event.toolResults);
789
+ }
577
790
  this._turnIndex++;
578
791
  }
579
792
  else if (event.type === "message_start") {
@@ -690,6 +903,10 @@ export class AgentSession {
690
903
  // =========================================================================
691
904
  // Read-only State Access
692
905
  // =========================================================================
906
+ /** Refresh the public finalized transcript from the canonical session projection. */
907
+ refreshContext() {
908
+ this._refreshFinalizedContext();
909
+ }
693
910
  /** Full agent state */
694
911
  get state() {
695
912
  return this.agent.state;
@@ -924,7 +1141,15 @@ export class AgentSession {
924
1141
  this._isAgentRunActive = true;
925
1142
  try {
926
1143
  await this.agent.prompt(messages);
927
- while (await this._handlePostAgentRun()) {
1144
+ while (!this._agentRunAbortRequested) {
1145
+ if (await this._handlePostAgentRun()) {
1146
+ if (this._agentRunAbortRequested)
1147
+ break;
1148
+ await this.agent.continue();
1149
+ continue;
1150
+ }
1151
+ if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary()))
1152
+ break;
928
1153
  if (this._agentRunAbortRequested)
929
1154
  break;
930
1155
  await this.agent.continue();
@@ -940,16 +1165,17 @@ export class AgentSession {
940
1165
  }
941
1166
  }
942
1167
  async _handlePostAgentRun() {
943
- const msg = this._lastAssistantMessage;
1168
+ const message = this._lastAssistantMessage;
1169
+ const toolResults = this._lastAssistantToolResults;
944
1170
  this._lastAssistantMessage = undefined;
1171
+ this._lastAssistantToolResults = [];
945
1172
  if (this._agentRunAbortRequested) {
946
1173
  this._finishCancelledRetry();
947
1174
  return false;
948
1175
  }
949
- if (!msg) {
950
- return false;
951
- }
952
- if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
1176
+ if (!message)
1177
+ return this.agent.hasQueuedMessages();
1178
+ if (this._isRetryableError(message) && (await this._prepareRetry(message))) {
953
1179
  if (this._agentRunAbortRequested)
954
1180
  this._finishCancelledRetry();
955
1181
  return !this._agentRunAbortRequested;
@@ -958,22 +1184,46 @@ export class AgentSession {
958
1184
  this._finishCancelledRetry();
959
1185
  return false;
960
1186
  }
961
- if (msg.stopReason === "error" && this._retryAttempt > 0) {
1187
+ if (message.stopReason === "error" && this._retryAttempt > 0) {
962
1188
  this._emit({
963
1189
  type: "auto_retry_end",
964
1190
  success: false,
965
1191
  attempt: this._retryAttempt,
966
- finalError: msg.errorMessage,
1192
+ finalError: message.errorMessage,
967
1193
  });
968
1194
  this._retryAttempt = 0;
969
1195
  }
970
- if (await this._checkCompaction(msg)) {
1196
+ if (await this._checkCompaction(message, true, toolResults)) {
971
1197
  return !this._agentRunAbortRequested;
972
1198
  }
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.
1199
+ // The low-level loop drains both queues before agent_end. Messages queued by
1200
+ // agent_end handlers require a fresh run before pre-settlement handlers fire.
975
1201
  return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();
976
1202
  }
1203
+ async _runBeforeSettleBoundary() {
1204
+ if (!this._extensionRunner.hasHandlers("agent_before_settle"))
1205
+ return this.agent.hasQueuedMessages();
1206
+ this._isBeforeSettle = true;
1207
+ this._abortDuringBeforeSettle = false;
1208
+ try {
1209
+ const result = await this._extensionRunner.emitBoundary({ type: "agent_before_settle", outcome: this._lastActivityOutcome }, (entries) => this._buildBoundaryContext(entries, "agent_before_settle"));
1210
+ this._commitBoundaryDrafts(result.entries);
1211
+ this._flushPendingCustomMessages();
1212
+ const finalContext = this._buildBoundaryContext([], "agent_before_settle");
1213
+ if (this._abortDuringBeforeSettle)
1214
+ return false;
1215
+ const shouldContinue = result.continue || this.agent.hasQueuedMessages();
1216
+ if (shouldContinue && !finalContext.canContinue) {
1217
+ if (result.continue)
1218
+ this._reportInvalidBoundaryContinuation("agent_before_settle");
1219
+ return false;
1220
+ }
1221
+ return shouldContinue;
1222
+ }
1223
+ finally {
1224
+ this._isBeforeSettle = false;
1225
+ }
1226
+ }
977
1227
  async _runInputHandlers(text, images, source, streamingBehavior) {
978
1228
  if (!this._extensionRunner.hasHandlers("input")) {
979
1229
  return { text, images };
@@ -987,6 +1237,25 @@ export class AgentSession {
987
1237
  }
988
1238
  return { text, images };
989
1239
  }
1240
+ async _normalizePromptImages(images) {
1241
+ if (!images)
1242
+ return { images: [], hints: [] };
1243
+ const normalizedImages = [];
1244
+ const hints = [];
1245
+ for (const image of images) {
1246
+ const processed = await processImage(Buffer.from(image.data, "base64"), image.mimeType, {
1247
+ autoResizeImages: this.settingsManager.getImageAutoResize(),
1248
+ resizeOptions: this.model?.inputLimits?.images?.resize,
1249
+ });
1250
+ if (!processed.ok) {
1251
+ hints.push(processed.message);
1252
+ continue;
1253
+ }
1254
+ normalizedImages.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
1255
+ hints.push(...processed.hints);
1256
+ }
1257
+ return { images: normalizedImages, hints };
1258
+ }
990
1259
  /**
991
1260
  * Send a prompt to the agent.
992
1261
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -997,6 +1266,10 @@ export class AgentSession {
997
1266
  * @throws Error if no model selected or no API key available (when not streaming)
998
1267
  */
999
1268
  async prompt(text, options) {
1269
+ if (this._isEmittingAgentSettled) {
1270
+ this._deferredSettledActions.push(async () => await this.prompt(text, options));
1271
+ return;
1272
+ }
1000
1273
  // A user-initiated prompt is the recovery point for failed completion deliveries.
1001
1274
  this.retryBackgroundNotifications();
1002
1275
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
@@ -1073,23 +1346,8 @@ export class AgentSession {
1073
1346
  if (lastAssistant) {
1074
1347
  await this._checkCompaction(lastAssistant, false);
1075
1348
  }
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
1349
+ // Emit before_agent_start before normalizing images so extension-driven model
1350
+ // selection determines the resize profile used for the request and history.
1093
1351
  const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
1094
1352
  const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptOptions);
1095
1353
  // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
@@ -1099,6 +1357,22 @@ export class AgentSession {
1099
1357
  result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
1100
1358
  if (!handlerEditedTools)
1101
1359
  result.systemPromptOptions.selectedTools = this.getActiveToolNames();
1360
+ const normalized = await this._normalizePromptImages(currentImages);
1361
+ const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;
1362
+ // Build messages only after hooks and image normalization have completed.
1363
+ messages = [];
1364
+ const userContent = [{ type: "text", text: userText }];
1365
+ userContent.push(...normalized.images);
1366
+ messages.push({
1367
+ role: "user",
1368
+ content: userContent,
1369
+ timestamp: Date.now(),
1370
+ });
1371
+ // Inject any pending "nextTurn" messages as context alongside the user message.
1372
+ // They stay queued until _persistMessage() confirms the append.
1373
+ for (const msg of this._pendingNextTurnMessages) {
1374
+ messages.push(msg);
1375
+ }
1102
1376
  for (const msg of result.messages) {
1103
1377
  messages.push({
1104
1378
  role: "custom",
@@ -1251,11 +1525,7 @@ export class AgentSession {
1251
1525
  if (images) {
1252
1526
  content.push(...images);
1253
1527
  }
1254
- this.agent.followUp({
1255
- role: "user",
1256
- content,
1257
- timestamp: Date.now(),
1258
- });
1528
+ this.agent.followUp({ role: "user", content, timestamp: Date.now() });
1259
1529
  }
1260
1530
  /**
1261
1531
  * Throw an error if the text is an extension command.
@@ -1303,6 +1573,10 @@ export class AgentSession {
1303
1573
  }
1304
1574
  }
1305
1575
  else if (options?.triggerTurn) {
1576
+ if (this._isEmittingAgentSettled) {
1577
+ this._deferredSettledActions.push(async () => await this._runAgentPrompt(appMessage));
1578
+ return;
1579
+ }
1306
1580
  await this._runAgentPrompt(appMessage);
1307
1581
  }
1308
1582
  else if (this.isStreaming) {
@@ -1317,8 +1591,8 @@ export class AgentSession {
1317
1591
  }
1318
1592
  }
1319
1593
  _appendCustomMessage(appMessage) {
1320
- this.agent.state.messages.push(appMessage);
1321
1594
  this._persistMessage(appMessage);
1595
+ this._refreshFinalizedContext();
1322
1596
  this._backgroundHost.messageAcknowledgement(appMessage)?.();
1323
1597
  this._emit({ type: "message_start", message: appMessage });
1324
1598
  this._emit({ type: "message_end", message: appMessage });
@@ -1412,6 +1686,8 @@ export class AgentSession {
1412
1686
  this.abortRetry();
1413
1687
  this.abortCompaction();
1414
1688
  this.abortBranchSummary();
1689
+ if (this._isBeforeSettle)
1690
+ this._abortDuringBeforeSettle = true;
1415
1691
  this.agent.abort();
1416
1692
  await this.waitForIdle();
1417
1693
  }
@@ -1732,9 +2008,8 @@ export class AgentSession {
1732
2008
  }
1733
2009
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
1734
2010
  const newEntries = this.sessionManager.getEntries();
1735
- const sessionContext = this.sessionManager.buildSessionContext();
1736
- this.agent.state.messages = sessionContext.messages;
1737
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2011
+ this._refreshFinalizedContext();
2012
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
1738
2013
  // Get the saved compaction entry for the extension event
1739
2014
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
1740
2015
  if (this._extensionRunner && savedCompactionEntry) {
@@ -1829,7 +2104,7 @@ export class AgentSession {
1829
2104
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1830
2105
  * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
1831
2106
  */
1832
- async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
2107
+ async _checkCompaction(assistantMessage, skipAbortedCheck = true, toolResults = []) {
1833
2108
  const settings = this.settingsManager.getCompactionSettings(this.model);
1834
2109
  if (!settings.enabled)
1835
2110
  return false;
@@ -1853,8 +2128,27 @@ export class AgentSession {
1853
2128
  // Automatic cases 1 and 2: context overflow.
1854
2129
  // A length stop is recoverable when output ended below the model's original desired limit,
1855
2130
  // 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);
2131
+ const currentProjection = this.sessionManager.buildSessionProjection();
2132
+ const assistantEntryId = this._findPersistedMessageEntryId(assistantMessage);
2133
+ const assistantIsProjected = assistantEntryId === undefined ||
2134
+ currentProjection.entries.some((entry) => entry.sourceEntry.id === assistantEntryId &&
2135
+ entry.messages.some((message) => message.role === "assistant"));
2136
+ const branch = this.sessionManager.getBranch();
2137
+ const assistantIndex = assistantEntryId ? branch.findIndex((entry) => entry.id === assistantEntryId) : -1;
2138
+ const entriesAfterAssistant = assistantIndex >= 0 ? branch.slice(assistantIndex + 1) : [];
2139
+ const hasPostAssistantContextEdit = entriesAfterAssistant.some((entry) => entry.type === "context_edit");
2140
+ const latestAssistantEdit = entriesAfterAssistant
2141
+ .filter((entry) => entry.type === "context_edit" && entry.targetId === assistantEntryId)
2142
+ .at(-1);
2143
+ const assistantRetainedForExplicitRecovery = assistantEntryId === undefined ||
2144
+ (!entriesAfterAssistant.some((entry) => entry.type === "compaction") &&
2145
+ latestAssistantEdit?.replacement !== null);
2146
+ const assistantUsageMatchesProjection = assistantIsProjected && !hasPostAssistantContextEdit;
2147
+ const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);
2148
+ const contextOverflow = sameModel &&
2149
+ ((explicitOverflow && assistantRetainedForExplicitRecovery) ||
2150
+ (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));
2151
+ const recoverableLength = sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
1858
2152
  if (contextOverflow || recoverableLength) {
1859
2153
  const willRetry = assistantMessage.stopReason !== "stop";
1860
2154
  // Case 2: the response completed successfully. Compact, but do not retry because
@@ -1883,13 +2177,9 @@ export class AgentSession {
1883
2177
  });
1884
2178
  return false;
1885
2179
  }
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.
2180
+ // Persistently omit the selected final attempt before post-run recovery compaction.
1888
2181
  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
- }
2182
+ this._omitRecoveryAttempt(assistantMessage, toolResults);
1893
2183
  return await this._runAutoCompaction("overflow", willRetry);
1894
2184
  }
1895
2185
  // Case 3: threshold compaction without retry.
@@ -1897,8 +2187,13 @@ export class AgentSession {
1897
2187
  // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
1898
2188
  // responses can still compact and do not reset context accounting.
1899
2189
  let contextTokens;
2190
+ const projection = currentProjection;
2191
+ const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");
1900
2192
  const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
1901
- if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
2193
+ if (hasContextEdits) {
2194
+ contextTokens = estimateProjectedContextTokens(projection, branch).tokens;
2195
+ }
2196
+ else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
1902
2197
  const messages = this.agent.state.messages;
1903
2198
  const estimate = estimateContextTokens(messages);
1904
2199
  // Without provider usage, estimate.tokens is the pure message-size estimate.
@@ -2005,9 +2300,8 @@ export class AgentSession {
2005
2300
  abortController.signal.throwIfAborted();
2006
2301
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
2007
2302
  const newEntries = this.sessionManager.getEntries();
2008
- const sessionContext = this.sessionManager.buildSessionContext();
2009
- this.agent.state.messages = sessionContext.messages;
2010
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2303
+ this._refreshFinalizedContext();
2304
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
2011
2305
  // Get the saved compaction entry for the extension event
2012
2306
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
2013
2307
  if (this._extensionRunner && savedCompactionEntry) {
@@ -2028,19 +2322,8 @@ export class AgentSession {
2028
2322
  details,
2029
2323
  };
2030
2324
  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
- }
2325
+ if (willRetry)
2042
2326
  return true;
2043
- }
2044
2327
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
2045
2328
  // Continue once so queued messages are delivered.
2046
2329
  return this.agent.hasQueuedMessages();
@@ -2524,11 +2807,8 @@ export class AgentSession {
2524
2807
  delayMs,
2525
2808
  errorMessage: message.errorMessage || "Unknown error",
2526
2809
  });
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
- }
2810
+ // Keep the failed attempt in raw history while durably omitting it from model projection.
2811
+ this._omitRecoveryAttempt(message);
2532
2812
  // Wait with exponential backoff (abortable)
2533
2813
  this._retryAbortController = new AbortController();
2534
2814
  try {
@@ -2620,10 +2900,8 @@ export class AgentSession {
2620
2900
  this._pendingBashMessages.push(bashMessage);
2621
2901
  }
2622
2902
  else {
2623
- // Add to agent state immediately
2624
- this.agent.state.messages.push(bashMessage);
2625
- // Save to session
2626
2903
  this.sessionManager.appendMessage(bashMessage);
2904
+ this._refreshFinalizedContext();
2627
2905
  }
2628
2906
  }
2629
2907
  /**
@@ -2650,12 +2928,10 @@ export class AgentSession {
2650
2928
  if (this._pendingBashMessages.length === 0)
2651
2929
  return;
2652
2930
  for (const bashMessage of this._pendingBashMessages) {
2653
- // Add to agent state
2654
- this.agent.state.messages.push(bashMessage);
2655
- // Save to session
2656
2931
  this.sessionManager.appendMessage(bashMessage);
2657
2932
  }
2658
2933
  this._pendingBashMessages = [];
2934
+ this._refreshFinalizedContext();
2659
2935
  }
2660
2936
  // =========================================================================
2661
2937
  // Session Management
@@ -2832,9 +3108,8 @@ export class AgentSession {
2832
3108
  this.sessionManager.appendLabelChange(targetId, label);
2833
3109
  }
2834
3110
  this._backgroundHost.restoreHistory();
2835
- // Update agent state
2836
- const sessionContext = this.sessionManager.buildSessionContext();
2837
- this.agent.state.messages = sessionContext.messages;
3111
+ // Update finalized context from the canonical session projection.
3112
+ this._refreshFinalizedContext();
2838
3113
  this._restoreToolsFromTranscript();
2839
3114
  // Emit session_tree event
2840
3115
  await this._extensionRunner.emit({
@@ -2934,30 +3209,24 @@ export class AgentSession {
2934
3209
  // After compaction, the last assistant usage reflects pre-compaction context size.
2935
3210
  // We can only trust usage from an assistant that responded after the latest compaction.
2936
3211
  // 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);
3212
+ const projection = this.sessionManager.buildSessionProjection();
3213
+ const branch = this.sessionManager.getBranch();
3214
+ const latestCompaction = getLatestCompactionEntry(branch);
2939
3215
  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) {
3216
+ const projectedAssistants = new Set(projection.entries.flatMap((entry) => entry.messages.some((message) => message.role === "assistant" &&
3217
+ message.stopReason !== "aborted" &&
3218
+ message.stopReason !== "error" &&
3219
+ calculateContextTokens(message.usage) > 0)
3220
+ ? [entry.sourceEntry.id]
3221
+ : []));
3222
+ const compactionIndex = branch.findIndex((entry) => entry.id === latestCompaction.id);
3223
+ const hasPostCompactionUsage = branch
3224
+ .slice(compactionIndex + 1)
3225
+ .some((entry) => projectedAssistants.has(entry.id));
3226
+ if (!hasPostCompactionUsage)
2957
3227
  return { tokens: null, contextWindow, percent: null };
2958
- }
2959
3228
  }
2960
- const estimate = estimateContextTokens(this.messages);
3229
+ const estimate = estimateProjectedContextTokens(projection, branch);
2961
3230
  const percent = (estimate.tokens / contextWindow) * 100;
2962
3231
  return {
2963
3232
  tokens: estimate.tokens,