@earendil-works/pi-coding-agent 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 (114) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +36 -0
  3. package/dist/bundle/chunks/{chunk-CMRUVXTE.js → chunk-4DKZACXI.js} +38 -32
  4. package/dist/bundle/chunks/chunk-GV2E3GBU.js +2 -0
  5. package/dist/bundle/chunks/github-copilot.js +1 -1
  6. package/dist/bundle/chunks/{openai-completions-CYGM3XXP.js → openai-completions-XHML6MTL.js} +1 -1
  7. package/dist/bundle/chunks/{virtual-modules-MGTKWDID.js → virtual-modules-BNWPZYDH.js} +1 -1
  8. package/dist/bundle/cli-runtime.js +1 -1
  9. package/dist/bundle/index.js +1 -1
  10. package/dist/bundle/rpc-entry.js +1 -1
  11. package/dist/cli/file-processor.d.ts +1 -1
  12. package/dist/cli/file-processor.d.ts.map +1 -1
  13. package/dist/cli/file-processor.js.map +1 -1
  14. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  15. package/dist/core/agent-session-runtime.js +1 -1
  16. package/dist/core/agent-session-runtime.js.map +1 -1
  17. package/dist/core/agent-session.d.ts +27 -3
  18. package/dist/core/agent-session.d.ts.map +1 -1
  19. package/dist/core/agent-session.js +389 -119
  20. package/dist/core/agent-session.js.map +1 -1
  21. package/dist/core/cache-warmer.d.ts +1 -0
  22. package/dist/core/cache-warmer.d.ts.map +1 -1
  23. package/dist/core/cache-warmer.js +15 -1
  24. package/dist/core/cache-warmer.js.map +1 -1
  25. package/dist/core/compaction/compaction.d.ts +3 -1
  26. package/dist/core/compaction/compaction.d.ts.map +1 -1
  27. package/dist/core/compaction/compaction.js +146 -48
  28. package/dist/core/compaction/compaction.js.map +1 -1
  29. package/dist/core/crash-log.d.ts +5 -0
  30. package/dist/core/crash-log.d.ts.map +1 -1
  31. package/dist/core/crash-log.js +68 -0
  32. package/dist/core/crash-log.js.map +1 -1
  33. package/dist/core/export-html/template.js +6 -1
  34. package/dist/core/extensions/index.d.ts +1 -1
  35. package/dist/core/extensions/index.d.ts.map +1 -1
  36. package/dist/core/extensions/index.js.map +1 -1
  37. package/dist/core/extensions/runner.d.ts +16 -3
  38. package/dist/core/extensions/runner.d.ts.map +1 -1
  39. package/dist/core/extensions/runner.js +110 -5
  40. package/dist/core/extensions/runner.js.map +1 -1
  41. package/dist/core/extensions/types.d.ts +77 -6
  42. package/dist/core/extensions/types.d.ts.map +1 -1
  43. package/dist/core/extensions/types.js.map +1 -1
  44. package/dist/core/index.d.ts +1 -1
  45. package/dist/core/index.d.ts.map +1 -1
  46. package/dist/core/index.js.map +1 -1
  47. package/dist/core/model-config.d.ts +52 -0
  48. package/dist/core/model-config.d.ts.map +1 -1
  49. package/dist/core/model-config.js +16 -0
  50. package/dist/core/model-config.js.map +1 -1
  51. package/dist/core/prompt-templates.d.ts +6 -1
  52. package/dist/core/prompt-templates.d.ts.map +1 -1
  53. package/dist/core/prompt-templates.js +61 -35
  54. package/dist/core/prompt-templates.js.map +1 -1
  55. package/dist/core/provider-composer.d.ts +1 -0
  56. package/dist/core/provider-composer.d.ts.map +1 -1
  57. package/dist/core/provider-composer.js +19 -0
  58. package/dist/core/provider-composer.js.map +1 -1
  59. package/dist/core/resource-loader.d.ts.map +1 -1
  60. package/dist/core/resource-loader.js +6 -2
  61. package/dist/core/resource-loader.js.map +1 -1
  62. package/dist/core/sdk.d.ts.map +1 -1
  63. package/dist/core/sdk.js +3 -4
  64. package/dist/core/sdk.js.map +1 -1
  65. package/dist/core/session-manager.d.ts +36 -9
  66. package/dist/core/session-manager.d.ts.map +1 -1
  67. package/dist/core/session-manager.js +97 -7
  68. package/dist/core/session-manager.js.map +1 -1
  69. package/dist/core/tools/read.d.ts +4 -1
  70. package/dist/core/tools/read.d.ts.map +1 -1
  71. package/dist/core/tools/read.js +5 -1
  72. package/dist/core/tools/read.js.map +1 -1
  73. package/dist/index.d.ts +2 -2
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +1 -1
  76. package/dist/index.js.map +1 -1
  77. package/dist/main.d.ts.map +1 -1
  78. package/dist/main.js +4 -3
  79. package/dist/main.js.map +1 -1
  80. package/dist/modes/interactive/bug-report.d.ts.map +1 -1
  81. package/dist/modes/interactive/bug-report.js +4 -0
  82. package/dist/modes/interactive/bug-report.js.map +1 -1
  83. package/dist/modes/interactive/components/tree-selector.d.ts.map +1 -1
  84. package/dist/modes/interactive/components/tree-selector.js +7 -0
  85. package/dist/modes/interactive/components/tree-selector.js.map +1 -1
  86. package/dist/modes/interactive/interactive-mode.d.ts +3 -0
  87. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  88. package/dist/modes/interactive/interactive-mode.js +64 -2
  89. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  90. package/dist/utils/mime.d.ts.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 +70 -6
  99. package/docs/models.md +29 -1
  100. package/docs/sdk.md +13 -2
  101. package/docs/session-format.md +15 -2
  102. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  103. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  104. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  105. package/examples/extensions/gondolin/package-lock.json +2 -2
  106. package/examples/extensions/gondolin/package.json +1 -1
  107. package/examples/extensions/sandbox/package-lock.json +2 -2
  108. package/examples/extensions/sandbox/package.json +1 -1
  109. package/examples/extensions/with-deps/package-lock.json +2 -2
  110. package/examples/extensions/with-deps/package.json +1 -1
  111. package/examples/plugins/pi-example-plugin/src/session.ts +3 -2
  112. package/npm-shrinkwrap.json +20 -20
  113. package/package.json +8 -8
  114. package/dist/bundle/chunks/chunk-HTEQD2HM.js +0 -2
@@ -18,21 +18,23 @@ import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-wo
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 { executeBashWithOperations } from "./bash-executor.js";
25
26
  import { generateBugReportSummary } from "./bug-report.js";
26
- import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
27
+ import { calculateContextTokens, collectEntriesForBranchSummary, compact, estimateContextTokens, estimateProjectedContextTokens, estimateTokens, generateBranchSummary, prepareCompaction, shouldCompact, } from "./compaction/index.js";
27
28
  import { DEFAULT_THINKING_LEVEL, THINKING_LEVEL_OPTIONS } from "./defaults.js";
28
29
  import { exportSessionToHtml } from "./export-html/index.js";
29
30
  import { createToolHtmlRenderer } from "./export-html/tool-renderer.js";
30
31
  import { ExtensionRunner, wrapRegisteredTools, } from "./extensions/index.js";
31
32
  import { emitSessionShutdownEvent } from "./extensions/runner.js";
33
+ import { convertToLlm } from "./messages.js";
32
34
  import { ModelRegistry } from "./model-registry.js";
33
35
  import { expandPromptTemplate } from "./prompt-templates.js";
34
36
  import { exportSessionToJsonl } from "./session-export.js";
35
- import { getLatestCompactionEntry } from "./session-manager.js";
37
+ import { getLatestCompactionEntry, SessionManager, } from "./session-manager.js";
36
38
  import { createSyntheticSourceInfo } from "./source-info.js";
37
39
  import { buildSystemPrompt, buildSystemPromptSections, diffSystemPromptSections, normalizeBuildSystemPromptOptions, } from "./system-prompt.js";
38
40
  import { createLocalBashOperations } from "./tools/bash.js";
@@ -107,6 +109,15 @@ export class AgentSession {
107
109
  // Extension system
108
110
  _extensionRunner;
109
111
  _turnIndex = 0;
112
+ _entryIdsByMessage = new WeakMap();
113
+ _boundaryDispatchedMessages = new WeakSet();
114
+ _lastAssistantMessage;
115
+ _lastAssistantToolResults = [];
116
+ _lastActivityOutcome = "completed";
117
+ _isBeforeSettle = false;
118
+ _abortDuringBeforeSettle = false;
119
+ _isEmittingAgentSettled = false;
120
+ _deferredSettledActions = [];
110
121
  _resourceLoader;
111
122
  _customTools;
112
123
  _baseToolDefinitions = new Map();
@@ -158,6 +169,8 @@ export class AgentSession {
158
169
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
159
170
  this._installAgentToolHooks();
160
171
  this._installAgentNextTurnRefresh();
172
+ this._installAgentRequestProjection();
173
+ this._installAgentBoundaryHooks();
161
174
  this._installAgentForcedPromptProjection();
162
175
  this._buildRuntime({
163
176
  activeToolNames: this._initialActiveToolNames,
@@ -265,8 +278,10 @@ export class AgentSession {
265
278
  : undefined;
266
279
  const content = hookResult?.content ?? result.content ?? [];
267
280
  // Runs after the extension hook so images injected or replaced by extensions are normalized too.
281
+ const resizeOptions = this.model?.inputLimits?.images?.resize;
268
282
  const normalizedContent = await normalizeToolResultImages(content, {
269
283
  autoResizeImages: this.settingsManager.getImageAutoResize(),
284
+ ...(resizeOptions ? { resizeOptions } : {}),
270
285
  });
271
286
  if (!hookResult && normalizedContent === content) {
272
287
  return undefined;
@@ -282,15 +297,83 @@ export class AgentSession {
282
297
  async _compactBeforeNextAssistantResponse(context) {
283
298
  const model = this.model;
284
299
  const settings = this.settingsManager.getCompactionSettings(model);
300
+ const projection = this.sessionManager.buildSessionProjection();
285
301
  if (!model ||
286
302
  model.contextWindow <= 0 ||
287
- !shouldCompact(estimateContextTokens(context.messages).tokens, model.contextWindow, settings)) {
288
- return context;
303
+ !shouldCompact(estimateProjectedContextTokens(projection, this.sessionManager.getBranch()).tokens, model.contextWindow, settings)) {
304
+ return { ...context, messages: projection.messages };
289
305
  }
290
306
  await this._runAutoCompaction("threshold", false);
291
- return {
292
- ...context,
293
- messages: this.agent.state.messages.slice(),
307
+ return { ...context, messages: this.sessionManager.buildSessionProjection().messages };
308
+ }
309
+ _installAgentRequestProjection() {
310
+ const previousPrepareRequest = this.agent.prepareRequest;
311
+ this.agent.prepareRequest = async (request, signal) => {
312
+ const canonicalContext = {
313
+ ...request.context,
314
+ messages: this.sessionManager.buildSessionProjection().messages,
315
+ // Messages declare the provider-visible loadout; context.tools keeps executable implementations.
316
+ tools: this.agent.state.tools.slice(),
317
+ };
318
+ const previous = await previousPrepareRequest?.({
319
+ ...request,
320
+ context: canonicalContext,
321
+ model: this.agent.state.model,
322
+ thinkingLevel: this.agent.state.thinkingLevel,
323
+ }, signal);
324
+ return {
325
+ ...previous,
326
+ context: previous?.context ?? canonicalContext,
327
+ model: previous?.model ?? this.agent.state.model,
328
+ thinkingLevel: previous?.thinkingLevel ?? this.agent.state.thinkingLevel,
329
+ };
330
+ };
331
+ }
332
+ async _dispatchTurnEndBoundary(message, toolResults) {
333
+ this._lastActivityOutcome =
334
+ message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
335
+ const messageEntryId = this._findPersistedMessageEntryId(message);
336
+ if (!this._extensionRunner.hasHandlers("turn_end"))
337
+ return false;
338
+ if (!messageEntryId) {
339
+ this._extensionRunner.emitError({
340
+ extensionPath: "<boundary>",
341
+ event: "turn_end",
342
+ error: "turn_end could not resolve the persisted assistant entry ID",
343
+ });
344
+ return false;
345
+ }
346
+ const toolResultEntryIds = toolResults.flatMap((result) => {
347
+ const entryId = this._findPersistedMessageEntryId(result);
348
+ return entryId ? [entryId] : [];
349
+ });
350
+ const boundary = await this._extensionRunner.emitBoundary({
351
+ type: "turn_end",
352
+ turnIndex: this._turnIndex,
353
+ message,
354
+ toolResults,
355
+ messageEntryId,
356
+ toolResultEntryIds,
357
+ outcome: this._lastActivityOutcome,
358
+ }, (entries) => this._buildBoundaryContext(entries, "turn_end"));
359
+ this._commitBoundaryDrafts(boundary.entries);
360
+ if (boundary.continue && !this._buildBoundaryContext([], "turn_end").canContinue) {
361
+ this._reportInvalidBoundaryContinuation("turn_end");
362
+ return false;
363
+ }
364
+ return boundary.continue;
365
+ }
366
+ _installAgentBoundaryHooks() {
367
+ const previousFinishTurn = this.agent.finishTurn;
368
+ this.agent.finishTurn = async (turn, signal) => {
369
+ this._boundaryDispatchedMessages.add(turn.message);
370
+ const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
371
+ const previousDecision = await previousFinishTurn?.(turn, signal);
372
+ if (previousDecision?.action === "end")
373
+ return previousDecision;
374
+ if (extensionContinue || previousDecision?.action === "continue")
375
+ return { action: "continue" };
376
+ return undefined;
294
377
  };
295
378
  }
296
379
  _installAgentNextTurnRefresh() {
@@ -299,7 +382,10 @@ export class AgentSession {
299
382
  ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal)
300
383
  : undefined);
301
384
  this.agent.prepareNextTurnWithContext = async (turn, signal) => {
302
- const context = await this._compactBeforeNextAssistantResponse(turn.context);
385
+ const context = await this._compactBeforeNextAssistantResponse({
386
+ ...turn.context,
387
+ messages: this.sessionManager.buildSessionProjection().messages,
388
+ });
303
389
  const previousSnapshot = await previousPrepareNextTurnWithContext?.({ ...turn, context }, signal);
304
390
  const nextContext = previousSnapshot?.context ?? context;
305
391
  const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;
@@ -329,6 +415,84 @@ export class AgentSession {
329
415
  // =========================================================================
330
416
  // Event Subscription
331
417
  // =========================================================================
418
+ _refreshFinalizedContext() {
419
+ const projection = this.sessionManager.buildSessionProjection();
420
+ for (const entry of projection.entries) {
421
+ for (const message of entry.messages)
422
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
423
+ }
424
+ this.agent.state.messages = projection.messages;
425
+ }
426
+ _applyBoundaryDrafts(manager, drafts) {
427
+ const appended = [];
428
+ for (const draft of drafts) {
429
+ let entryId;
430
+ switch (draft.type) {
431
+ case "custom":
432
+ entryId = manager.appendCustomEntry(draft.customType, draft.data);
433
+ break;
434
+ case "custom_message":
435
+ entryId = manager.appendCustomMessageEntry(draft.customType, draft.content, draft.display, draft.details);
436
+ break;
437
+ case "context_edit":
438
+ entryId = manager.appendContextEdit(draft.targetId, draft.replacement);
439
+ break;
440
+ case "compaction": {
441
+ const tokensBefore = estimateProjectedContextTokens(manager.buildSessionProjection(), manager.getBranch()).tokens;
442
+ entryId = manager.appendCompaction(draft.summary, draft.firstKeptEntryId, tokensBefore, draft.details, true, draft.usage);
443
+ break;
444
+ }
445
+ }
446
+ const entry = manager.getEntry(entryId);
447
+ if (entry)
448
+ appended.push(entry);
449
+ }
450
+ return appended;
451
+ }
452
+ _createBoundaryPreviewManager(drafts) {
453
+ const header = this.sessionManager.getHeader();
454
+ if (!header)
455
+ throw new Error("Session header is missing");
456
+ const manager = SessionManager.inMemory(this._cwd, undefined, [header, ...this.sessionManager.getBranch()]);
457
+ this._applyBoundaryDrafts(manager, drafts);
458
+ return manager;
459
+ }
460
+ _getPendingBoundaryMessages() {
461
+ return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];
462
+ }
463
+ _buildBoundaryContext(drafts, boundary) {
464
+ const projection = this._createBoundaryPreviewManager(drafts).buildSessionProjection();
465
+ const pendingMessages = this._getPendingBoundaryMessages();
466
+ const llmMessages = convertToLlm(projection.messages);
467
+ const finalRole = llmMessages[llmMessages.length - 1]?.role;
468
+ const hasNonSystemContext = llmMessages.some((message) => message.role !== "system");
469
+ const contextCanContinue = hasNonSystemContext && finalRole !== "assistant";
470
+ const pendingCustomContext = this._pendingCustomMessages.length > 0;
471
+ return {
472
+ contextEntries: projection.entries,
473
+ contextMessages: projection.messages,
474
+ llmMessages,
475
+ pendingMessages,
476
+ canContinue: contextCanContinue ||
477
+ pendingCustomContext ||
478
+ (boundary === "turn_end"
479
+ ? this.agent.hasQueuedMessages()
480
+ : finalRole === "assistant" && this.agent.hasQueuedMessages()),
481
+ };
482
+ }
483
+ _commitBoundaryDrafts(drafts) {
484
+ const appended = this._applyBoundaryDrafts(this.sessionManager, drafts);
485
+ this._refreshFinalizedContext();
486
+ for (const entry of appended)
487
+ this._emit({ type: "entry_appended", entry });
488
+ }
489
+ _reportInvalidBoundaryContinuation(event) {
490
+ this._extensionRunner.emitError({
491
+ extensionPath: "<boundary>",
492
+ event,
493
+ error: `${event} requested continuation without runnable model context`,
494
+ });
495
+ }
332
496
  /** Emit an event to all listeners */
333
497
  _emit(event) {
334
498
  for (const l of this._eventListeners) {
@@ -367,16 +531,27 @@ export class AgentSession {
367
531
  async _emitAgentSettled() {
368
532
  this._cacheWarmer?.onAgentSettled();
369
533
  this._isAgentRunActive = false;
534
+ this._isEmittingAgentSettled = true;
370
535
  try {
371
536
  await this._extensionRunner.emit({ type: "agent_settled" });
372
537
  this._emit({ type: "agent_settled" });
373
538
  }
374
539
  finally {
375
- this._resolveIdleWaitIfIdle();
540
+ this._isEmittingAgentSettled = false;
541
+ }
542
+ const deferred = this._deferredSettledActions.splice(0);
543
+ if (deferred.length > 0) {
544
+ try {
545
+ for (const action of deferred)
546
+ await action();
547
+ }
548
+ finally {
549
+ this._resolveIdleWaitIfIdle();
550
+ }
551
+ return;
376
552
  }
553
+ this._resolveIdleWaitIfIdle();
377
554
  }
378
- // Track last assistant message for auto-compaction check
379
- _lastAssistantMessage = undefined;
380
555
  /** Internal handler for agent events - shared by subscribe and reconnect */
381
556
  _handleAgentEvent = async (event) => {
382
557
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
@@ -401,29 +576,30 @@ export class AgentSession {
401
576
  }
402
577
  }
403
578
  }
404
- // Emit to extensions first
579
+ // Emit to extensions first, then notify public listeners.
405
580
  await this._emitExtensionEvent(event);
406
- // Notify all listeners
407
581
  this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
408
582
  // Handle session persistence
409
583
  if (event.type === "message_end") {
584
+ let entryId;
410
585
  // Check if this is a custom message from extensions
411
586
  if (event.message.role === "custom") {
412
587
  // Persist as CustomMessageEntry
413
- this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
588
+ entryId = this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
414
589
  }
415
590
  else if (event.message.role === "system" ||
416
591
  event.message.role === "user" ||
417
592
  event.message.role === "assistant" ||
418
593
  event.message.role === "toolResult") {
419
594
  // Regular LLM message - persist as SessionMessageEntry
420
- this.sessionManager.appendMessage(event.message);
595
+ entryId = this.sessionManager.appendMessage(event.message);
421
596
  }
597
+ if (entryId)
598
+ this._entryIdsByMessage.set(event.message, entryId);
422
599
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
423
- // Track assistant message for auto-compaction (checked on agent_end)
424
600
  if (event.message.role === "assistant") {
425
- this._lastAssistantMessage = event.message;
426
601
  const assistantMsg = event.message;
602
+ this._lastAssistantMessage = assistantMsg;
427
603
  if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "length") {
428
604
  this._overflowRecoveryAttempted = false;
429
605
  }
@@ -445,6 +621,7 @@ export class AgentSession {
445
621
  // extension and listener dispatch above also picks up messages that turn_end
446
622
  // handlers queued.
447
623
  if (event.type === "turn_end") {
624
+ this._lastAssistantToolResults = event.toolResults;
448
625
  this._flushPendingCustomMessages();
449
626
  }
450
627
  };
@@ -463,6 +640,47 @@ export class AgentSession {
463
640
  }
464
641
  return false;
465
642
  }
643
+ _findPersistedMessageEntryId(message) {
644
+ const mapped = this._entryIdsByMessage.get(message);
645
+ if (mapped)
646
+ return mapped;
647
+ for (const entry of [...this.sessionManager.getBranch()].reverse()) {
648
+ if (entry.type === "message" && entry.message === message)
649
+ return entry.id;
650
+ }
651
+ const messageIndex = this.agent.state.messages.indexOf(message);
652
+ if (messageIndex < 0)
653
+ return undefined;
654
+ const projection = this.sessionManager.buildSessionProjection();
655
+ let projectedIndex = 0;
656
+ for (const entry of projection.entries) {
657
+ for (let i = 0; i < entry.messages.length; i++) {
658
+ if (projectedIndex === messageIndex) {
659
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
660
+ return entry.sourceEntry.id;
661
+ }
662
+ projectedIndex++;
663
+ }
664
+ }
665
+ return undefined;
666
+ }
667
+ _omitRecoveryAttempt(message, toolResults = []) {
668
+ const targets = [message, ...toolResults];
669
+ const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
670
+ const unresolvedProjectedTarget = targets.some((target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target));
671
+ if (unresolvedProjectedTarget) {
672
+ throw new Error("Cannot persist recovery omission because a projected message has no source entry");
673
+ }
674
+ for (const targetId of targetIds) {
675
+ if (!targetId)
676
+ continue;
677
+ const editId = this.sessionManager.appendContextEdit(targetId, null);
678
+ const entry = this.sessionManager.getEntry(editId);
679
+ if (entry)
680
+ this._emit({ type: "entry_appended", entry });
681
+ }
682
+ this._refreshFinalizedContext();
683
+ }
466
684
  /** Find the last assistant message in agent state (including aborted ones) */
467
685
  _findLastAssistantMessage() {
468
686
  const messages = this.agent.state.messages;
@@ -506,13 +724,9 @@ export class AgentSession {
506
724
  await this._extensionRunner.emit(extensionEvent);
507
725
  }
508
726
  else if (event.type === "turn_end") {
509
- const extensionEvent = {
510
- type: "turn_end",
511
- turnIndex: this._turnIndex,
512
- message: event.message,
513
- toolResults: event.toolResults,
514
- };
515
- await this._extensionRunner.emit(extensionEvent);
727
+ if (event.message.role === "assistant" && !this._boundaryDispatchedMessages.delete(event.message)) {
728
+ await this._dispatchTurnEndBoundary(event.message, event.toolResults);
729
+ }
516
730
  this._turnIndex++;
517
731
  }
518
732
  else if (event.type === "message_start") {
@@ -628,6 +842,10 @@ export class AgentSession {
628
842
  // =========================================================================
629
843
  // Read-only State Access
630
844
  // =========================================================================
845
+ /** Refresh the public finalized transcript from the canonical session projection. */
846
+ refreshContext() {
847
+ this._refreshFinalizedContext();
848
+ }
631
849
  /** Full agent state */
632
850
  get state() {
633
851
  return this.agent.state;
@@ -862,7 +1080,15 @@ export class AgentSession {
862
1080
  this._isAgentRunActive = true;
863
1081
  try {
864
1082
  await this.agent.prompt(messages);
865
- while (await this._handlePostAgentRun()) {
1083
+ while (!this._agentRunAbortRequested) {
1084
+ if (await this._handlePostAgentRun()) {
1085
+ if (this._agentRunAbortRequested)
1086
+ break;
1087
+ await this.agent.continue();
1088
+ continue;
1089
+ }
1090
+ if (this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary()))
1091
+ break;
866
1092
  if (this._agentRunAbortRequested)
867
1093
  break;
868
1094
  await this.agent.continue();
@@ -878,16 +1104,17 @@ export class AgentSession {
878
1104
  }
879
1105
  }
880
1106
  async _handlePostAgentRun() {
881
- const msg = this._lastAssistantMessage;
1107
+ const message = this._lastAssistantMessage;
1108
+ const toolResults = this._lastAssistantToolResults;
882
1109
  this._lastAssistantMessage = undefined;
1110
+ this._lastAssistantToolResults = [];
883
1111
  if (this._agentRunAbortRequested) {
884
1112
  this._finishCancelledRetry();
885
1113
  return false;
886
1114
  }
887
- if (!msg) {
888
- return false;
889
- }
890
- if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
1115
+ if (!message)
1116
+ return this.agent.hasQueuedMessages();
1117
+ if (this._isRetryableError(message) && (await this._prepareRetry(message))) {
891
1118
  if (this._agentRunAbortRequested)
892
1119
  this._finishCancelledRetry();
893
1120
  return !this._agentRunAbortRequested;
@@ -896,22 +1123,46 @@ export class AgentSession {
896
1123
  this._finishCancelledRetry();
897
1124
  return false;
898
1125
  }
899
- if (msg.stopReason === "error" && this._retryAttempt > 0) {
1126
+ if (message.stopReason === "error" && this._retryAttempt > 0) {
900
1127
  this._emit({
901
1128
  type: "auto_retry_end",
902
1129
  success: false,
903
1130
  attempt: this._retryAttempt,
904
- finalError: msg.errorMessage,
1131
+ finalError: message.errorMessage,
905
1132
  });
906
1133
  this._retryAttempt = 0;
907
1134
  }
908
- if (await this._checkCompaction(msg)) {
1135
+ if (await this._checkCompaction(message, true, toolResults)) {
909
1136
  return !this._agentRunAbortRequested;
910
1137
  }
911
- // The agent loop drains both queues before emitting agent_end. Any messages
912
- // here were queued by agent_end extension handlers and need a continuation.
1138
+ // The low-level loop drains both queues before agent_end. Messages queued by
1139
+ // agent_end handlers require a fresh run before pre-settlement handlers fire.
913
1140
  return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();
914
1141
  }
1142
+ async _runBeforeSettleBoundary() {
1143
+ if (!this._extensionRunner.hasHandlers("agent_before_settle"))
1144
+ return this.agent.hasQueuedMessages();
1145
+ this._isBeforeSettle = true;
1146
+ this._abortDuringBeforeSettle = false;
1147
+ try {
1148
+ const result = await this._extensionRunner.emitBoundary({ type: "agent_before_settle", outcome: this._lastActivityOutcome }, (entries) => this._buildBoundaryContext(entries, "agent_before_settle"));
1149
+ this._commitBoundaryDrafts(result.entries);
1150
+ this._flushPendingCustomMessages();
1151
+ const finalContext = this._buildBoundaryContext([], "agent_before_settle");
1152
+ if (this._abortDuringBeforeSettle)
1153
+ return false;
1154
+ const shouldContinue = result.continue || this.agent.hasQueuedMessages();
1155
+ if (shouldContinue && !finalContext.canContinue) {
1156
+ if (result.continue)
1157
+ this._reportInvalidBoundaryContinuation("agent_before_settle");
1158
+ return false;
1159
+ }
1160
+ return shouldContinue;
1161
+ }
1162
+ finally {
1163
+ this._isBeforeSettle = false;
1164
+ }
1165
+ }
915
1166
  async _runInputHandlers(text, images, source, streamingBehavior) {
916
1167
  if (!this._extensionRunner.hasHandlers("input")) {
917
1168
  return { text, images };
@@ -925,6 +1176,25 @@ export class AgentSession {
925
1176
  }
926
1177
  return { text, images };
927
1178
  }
1179
+ async _normalizePromptImages(images) {
1180
+ if (!images)
1181
+ return { images: [], hints: [] };
1182
+ const normalizedImages = [];
1183
+ const hints = [];
1184
+ for (const image of images) {
1185
+ const processed = await processImage(Buffer.from(image.data, "base64"), image.mimeType, {
1186
+ autoResizeImages: this.settingsManager.getImageAutoResize(),
1187
+ resizeOptions: this.model?.inputLimits?.images?.resize,
1188
+ });
1189
+ if (!processed.ok) {
1190
+ hints.push(processed.message);
1191
+ continue;
1192
+ }
1193
+ normalizedImages.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
1194
+ hints.push(...processed.hints);
1195
+ }
1196
+ return { images: normalizedImages, hints };
1197
+ }
928
1198
  /**
929
1199
  * Send a prompt to the agent.
930
1200
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -935,6 +1205,10 @@ export class AgentSession {
935
1205
  * @throws Error if no model selected or no API key available (when not streaming)
936
1206
  */
937
1207
  async prompt(text, options) {
1208
+ if (this._isEmittingAgentSettled) {
1209
+ this._deferredSettledActions.push(async () => await this.prompt(text, options));
1210
+ return;
1211
+ }
938
1212
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
939
1213
  const preflightResult = options?.preflightResult;
940
1214
  let messages;
@@ -1003,13 +1277,23 @@ export class AgentSession {
1003
1277
  if (lastAssistant) {
1004
1278
  await this._checkCompaction(lastAssistant, false);
1005
1279
  }
1006
- // Build messages array (custom message if any, then user message)
1280
+ // Emit before_agent_start before normalizing images so extension-driven model
1281
+ // selection determines the resize profile used for the request and history.
1282
+ const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
1283
+ const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptOptions);
1284
+ // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
1285
+ // which updates the live loadout instead. An explicit edit wins; otherwise the live
1286
+ // loadout is authoritative, so a setActiveTools() call is not undone here.
1287
+ const handlerEditedTools = result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
1288
+ result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
1289
+ if (!handlerEditedTools)
1290
+ result.systemPromptOptions.selectedTools = this.getActiveToolNames();
1291
+ const normalized = await this._normalizePromptImages(currentImages);
1292
+ const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;
1293
+ // Build messages only after hooks and image normalization have completed.
1007
1294
  messages = [];
1008
- // Add user message
1009
- const userContent = [{ type: "text", text: expandedText }];
1010
- if (currentImages) {
1011
- userContent.push(...currentImages);
1012
- }
1295
+ const userContent = [{ type: "text", text: userText }];
1296
+ userContent.push(...normalized.images);
1013
1297
  messages.push({
1014
1298
  role: "user",
1015
1299
  content: userContent,
@@ -1020,16 +1304,6 @@ export class AgentSession {
1020
1304
  messages.push(msg);
1021
1305
  }
1022
1306
  this._pendingNextTurnMessages = [];
1023
- // Emit before_agent_start extension event
1024
- const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
1025
- const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptOptions);
1026
- // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
1027
- // which updates the live loadout instead. An explicit edit wins; otherwise the live
1028
- // loadout is authoritative, so a setActiveTools() call is not undone here.
1029
- const handlerEditedTools = result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
1030
- result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
1031
- if (!handlerEditedTools)
1032
- result.systemPromptOptions.selectedTools = this.getActiveToolNames();
1033
1307
  for (const msg of result.messages) {
1034
1308
  messages.push({
1035
1309
  role: "custom",
@@ -1178,11 +1452,7 @@ export class AgentSession {
1178
1452
  if (images) {
1179
1453
  content.push(...images);
1180
1454
  }
1181
- this.agent.followUp({
1182
- role: "user",
1183
- content,
1184
- timestamp: Date.now(),
1185
- });
1455
+ this.agent.followUp({ role: "user", content, timestamp: Date.now() });
1186
1456
  }
1187
1457
  /**
1188
1458
  * Throw an error if the text is an extension command.
@@ -1230,6 +1500,10 @@ export class AgentSession {
1230
1500
  }
1231
1501
  }
1232
1502
  else if (options?.triggerTurn) {
1503
+ if (this._isEmittingAgentSettled) {
1504
+ this._deferredSettledActions.push(async () => await this._runAgentPrompt(appMessage));
1505
+ return;
1506
+ }
1233
1507
  await this._runAgentPrompt(appMessage);
1234
1508
  }
1235
1509
  else if (this.isStreaming) {
@@ -1244,8 +1518,8 @@ export class AgentSession {
1244
1518
  }
1245
1519
  }
1246
1520
  _appendCustomMessage(appMessage) {
1247
- this.agent.state.messages.push(appMessage);
1248
1521
  this.sessionManager.appendCustomMessageEntry(appMessage.customType, appMessage.content, appMessage.display, appMessage.details);
1522
+ this._refreshFinalizedContext();
1249
1523
  this._emit({ type: "message_start", message: appMessage });
1250
1524
  this._emit({ type: "message_end", message: appMessage });
1251
1525
  }
@@ -1338,6 +1612,8 @@ export class AgentSession {
1338
1612
  this.abortRetry();
1339
1613
  this.abortCompaction();
1340
1614
  this.abortBranchSummary();
1615
+ if (this._isBeforeSettle)
1616
+ this._abortDuringBeforeSettle = true;
1341
1617
  this.agent.abort();
1342
1618
  await this.waitForIdle();
1343
1619
  }
@@ -1656,9 +1932,8 @@ export class AgentSession {
1656
1932
  }
1657
1933
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
1658
1934
  const newEntries = this.sessionManager.getEntries();
1659
- const sessionContext = this.sessionManager.buildSessionContext();
1660
- this.agent.state.messages = sessionContext.messages;
1661
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
1935
+ this._refreshFinalizedContext();
1936
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
1662
1937
  // Get the saved compaction entry for the extension event
1663
1938
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
1664
1939
  if (this._extensionRunner && savedCompactionEntry) {
@@ -1749,7 +2024,7 @@ export class AgentSession {
1749
2024
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
1750
2025
  * @returns Whether the post-run loop should call `agent.continue()` for overflow recovery or queued messages
1751
2026
  */
1752
- async _checkCompaction(assistantMessage, skipAbortedCheck = true) {
2027
+ async _checkCompaction(assistantMessage, skipAbortedCheck = true, toolResults = []) {
1753
2028
  const settings = this.settingsManager.getCompactionSettings(this.model);
1754
2029
  if (!settings.enabled)
1755
2030
  return false;
@@ -1773,8 +2048,27 @@ export class AgentSession {
1773
2048
  // Automatic cases 1 and 2: context overflow.
1774
2049
  // A length stop is recoverable when output ended below the model's original desired limit,
1775
2050
  // independent of the configured context size or any context-clamped provider request limit.
1776
- const contextOverflow = sameModel && isContextOverflow(assistantMessage, contextWindow);
1777
- const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
2051
+ const currentProjection = this.sessionManager.buildSessionProjection();
2052
+ const assistantEntryId = this._findPersistedMessageEntryId(assistantMessage);
2053
+ const assistantIsProjected = assistantEntryId === undefined ||
2054
+ currentProjection.entries.some((entry) => entry.sourceEntry.id === assistantEntryId &&
2055
+ entry.messages.some((message) => message.role === "assistant"));
2056
+ const branch = this.sessionManager.getBranch();
2057
+ const assistantIndex = assistantEntryId ? branch.findIndex((entry) => entry.id === assistantEntryId) : -1;
2058
+ const entriesAfterAssistant = assistantIndex >= 0 ? branch.slice(assistantIndex + 1) : [];
2059
+ const hasPostAssistantContextEdit = entriesAfterAssistant.some((entry) => entry.type === "context_edit");
2060
+ const latestAssistantEdit = entriesAfterAssistant
2061
+ .filter((entry) => entry.type === "context_edit" && entry.targetId === assistantEntryId)
2062
+ .at(-1);
2063
+ const assistantRetainedForExplicitRecovery = assistantEntryId === undefined ||
2064
+ (!entriesAfterAssistant.some((entry) => entry.type === "compaction") &&
2065
+ latestAssistantEdit?.replacement !== null);
2066
+ const assistantUsageMatchesProjection = assistantIsProjected && !hasPostAssistantContextEdit;
2067
+ const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);
2068
+ const contextOverflow = sameModel &&
2069
+ ((explicitOverflow && assistantRetainedForExplicitRecovery) ||
2070
+ (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));
2071
+ const recoverableLength = sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
1778
2072
  if (contextOverflow || recoverableLength) {
1779
2073
  const willRetry = assistantMessage.stopReason !== "stop";
1780
2074
  // Case 2: the response completed successfully. Compact, but do not retry because
@@ -1803,13 +2097,9 @@ export class AgentSession {
1803
2097
  });
1804
2098
  return false;
1805
2099
  }
1806
- // Case 1: remove the failed or truncated message from agent state, compact, and
1807
- // retry once. The message remains in session history but is excluded from retry context.
2100
+ // Persistently omit the selected final attempt before post-run recovery compaction.
1808
2101
  this._overflowRecoveryAttempted = true;
1809
- const messages = this.agent.state.messages;
1810
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
1811
- this.agent.state.messages = messages.slice(0, -1);
1812
- }
2102
+ this._omitRecoveryAttempt(assistantMessage, toolResults);
1813
2103
  return await this._runAutoCompaction("overflow", willRetry);
1814
2104
  }
1815
2105
  // Case 3: threshold compaction without retry.
@@ -1817,8 +2107,13 @@ export class AgentSession {
1817
2107
  // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
1818
2108
  // responses can still compact and do not reset context accounting.
1819
2109
  let contextTokens;
2110
+ const projection = currentProjection;
2111
+ const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");
1820
2112
  const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
1821
- if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
2113
+ if (hasContextEdits) {
2114
+ contextTokens = estimateProjectedContextTokens(projection, branch).tokens;
2115
+ }
2116
+ else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
1822
2117
  const messages = this.agent.state.messages;
1823
2118
  const estimate = estimateContextTokens(messages);
1824
2119
  // Without provider usage, estimate.tokens is the pure message-size estimate.
@@ -1923,9 +2218,8 @@ export class AgentSession {
1923
2218
  abortController.signal.throwIfAborted();
1924
2219
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
1925
2220
  const newEntries = this.sessionManager.getEntries();
1926
- const sessionContext = this.sessionManager.buildSessionContext();
1927
- this.agent.state.messages = sessionContext.messages;
1928
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2221
+ this._refreshFinalizedContext();
2222
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
1929
2223
  // Get the saved compaction entry for the extension event
1930
2224
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
1931
2225
  if (this._extensionRunner && savedCompactionEntry) {
@@ -1946,18 +2240,8 @@ export class AgentSession {
1946
2240
  details,
1947
2241
  };
1948
2242
  this._emit({ type: "compaction_end", reason, result, aborted: false, willRetry });
1949
- if (willRetry) {
1950
- const messages = this.agent.state.messages;
1951
- const lastMsg = messages[messages.length - 1];
1952
- // The overflow response was persisted on message_end before _checkCompaction() removed it
1953
- // from agent state. Rebuilding state from the new compaction can restore that kept entry,
1954
- // leaving an assistant as the final message. agent.continue() rejects that state, so remove
1955
- // the retriable error or truncated-length response again before continuing the interrupted turn.
1956
- if (lastMsg?.role === "assistant" && (lastMsg.stopReason === "error" || lastMsg.stopReason === "length")) {
1957
- this.agent.state.messages = messages.slice(0, -1);
1958
- }
2243
+ if (willRetry)
1959
2244
  return true;
1960
- }
1961
2245
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
1962
2246
  // Continue once so queued messages are delivered.
1963
2247
  return this.agent.hasQueuedMessages();
@@ -2416,11 +2700,8 @@ export class AgentSession {
2416
2700
  delayMs,
2417
2701
  errorMessage: message.errorMessage || "Unknown error",
2418
2702
  });
2419
- // Remove error message from agent state (keep in session for history)
2420
- const messages = this.agent.state.messages;
2421
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2422
- this.agent.state.messages = messages.slice(0, -1);
2423
- }
2703
+ // Keep the failed attempt in raw history while durably omitting it from model projection.
2704
+ this._omitRecoveryAttempt(message);
2424
2705
  // Wait with exponential backoff (abortable)
2425
2706
  this._retryAbortController = new AbortController();
2426
2707
  try {
@@ -2512,10 +2793,8 @@ export class AgentSession {
2512
2793
  this._pendingBashMessages.push(bashMessage);
2513
2794
  }
2514
2795
  else {
2515
- // Add to agent state immediately
2516
- this.agent.state.messages.push(bashMessage);
2517
- // Save to session
2518
2796
  this.sessionManager.appendMessage(bashMessage);
2797
+ this._refreshFinalizedContext();
2519
2798
  }
2520
2799
  }
2521
2800
  /**
@@ -2542,12 +2821,10 @@ export class AgentSession {
2542
2821
  if (this._pendingBashMessages.length === 0)
2543
2822
  return;
2544
2823
  for (const bashMessage of this._pendingBashMessages) {
2545
- // Add to agent state
2546
- this.agent.state.messages.push(bashMessage);
2547
- // Save to session
2548
2824
  this.sessionManager.appendMessage(bashMessage);
2549
2825
  }
2550
2826
  this._pendingBashMessages = [];
2827
+ this._refreshFinalizedContext();
2551
2828
  }
2552
2829
  // =========================================================================
2553
2830
  // Session Management
@@ -2721,9 +2998,8 @@ export class AgentSession {
2721
2998
  if (label && !summaryText) {
2722
2999
  this.sessionManager.appendLabelChange(targetId, label);
2723
3000
  }
2724
- // Update agent state
2725
- const sessionContext = this.sessionManager.buildSessionContext();
2726
- this.agent.state.messages = sessionContext.messages;
3001
+ // Update finalized context from the canonical session projection.
3002
+ this._refreshFinalizedContext();
2727
3003
  this._restoreToolsFromTranscript();
2728
3004
  // Emit session_tree event
2729
3005
  await this._extensionRunner.emit({
@@ -2829,30 +3105,24 @@ export class AgentSession {
2829
3105
  // After compaction, the last assistant usage reflects pre-compaction context size.
2830
3106
  // We can only trust usage from an assistant that responded after the latest compaction.
2831
3107
  // If no such assistant exists, context token count is unknown until the next LLM response.
2832
- const branchEntries = this.sessionManager.getBranch();
2833
- const latestCompaction = getLatestCompactionEntry(branchEntries);
3108
+ const projection = this.sessionManager.buildSessionProjection();
3109
+ const branch = this.sessionManager.getBranch();
3110
+ const latestCompaction = getLatestCompactionEntry(branch);
2834
3111
  if (latestCompaction) {
2835
- // Check if there's a valid assistant usage after the compaction boundary
2836
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
2837
- let hasPostCompactionUsage = false;
2838
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
2839
- const entry = branchEntries[i];
2840
- if (entry.type === "message" && entry.message.role === "assistant") {
2841
- const assistant = entry.message;
2842
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
2843
- const contextTokens = calculateContextTokens(assistant.usage);
2844
- if (contextTokens > 0) {
2845
- hasPostCompactionUsage = true;
2846
- break;
2847
- }
2848
- }
2849
- }
2850
- }
2851
- if (!hasPostCompactionUsage) {
3112
+ const projectedAssistants = new Set(projection.entries.flatMap((entry) => entry.messages.some((message) => message.role === "assistant" &&
3113
+ message.stopReason !== "aborted" &&
3114
+ message.stopReason !== "error" &&
3115
+ calculateContextTokens(message.usage) > 0)
3116
+ ? [entry.sourceEntry.id]
3117
+ : []));
3118
+ const compactionIndex = branch.findIndex((entry) => entry.id === latestCompaction.id);
3119
+ const hasPostCompactionUsage = branch
3120
+ .slice(compactionIndex + 1)
3121
+ .some((entry) => projectedAssistants.has(entry.id));
3122
+ if (!hasPostCompactionUsage)
2852
3123
  return { tokens: null, contextWindow, percent: null };
2853
- }
2854
3124
  }
2855
- const estimate = estimateContextTokens(this.messages);
3125
+ const estimate = estimateProjectedContextTokens(projection, branch);
2856
3126
  const percent = (estimate.tokens / contextWindow) * 100;
2857
3127
  return {
2858
3128
  tokens: estimate.tokens,