@averyyy/pi-coding-agent 0.86.1-piclient.1 → 0.87.0-piclient.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +36 -0
  3. package/dist/bundle/chunks/{chunk-K53F3E6X.js → chunk-3OMQWEMP.js} +44 -38
  4. package/dist/bundle/chunks/chunk-CJ5EZ24X.js +2 -0
  5. package/dist/bundle/chunks/devin-runtime.js +7 -2
  6. package/dist/bundle/chunks/github-copilot.js +1 -1
  7. package/dist/bundle/chunks/{openai-completions-CYGM3XXP.js → openai-completions-XHML6MTL.js} +1 -1
  8. package/dist/bundle/chunks/{virtual-modules-TAAJ5574.js → virtual-modules-N7I2ZIFC.js} +1 -1
  9. package/dist/bundle/cli-runtime.js +1 -1
  10. package/dist/bundle/index.js +1 -1
  11. package/dist/bundle/rpc-entry.js +1 -1
  12. package/dist/cli/file-processor.d.ts +1 -1
  13. package/dist/cli/file-processor.d.ts.map +1 -1
  14. package/dist/cli/file-processor.js.map +1 -1
  15. package/dist/core/agent-session-runtime.d.ts.map +1 -1
  16. package/dist/core/agent-session-runtime.js +1 -1
  17. package/dist/core/agent-session-runtime.js.map +1 -1
  18. package/dist/core/agent-session.d.ts +28 -3
  19. package/dist/core/agent-session.d.ts.map +1 -1
  20. package/dist/core/agent-session.js +482 -145
  21. package/dist/core/agent-session.js.map +1 -1
  22. package/dist/core/cache-warmer.d.ts +1 -0
  23. package/dist/core/cache-warmer.d.ts.map +1 -1
  24. package/dist/core/cache-warmer.js +15 -1
  25. package/dist/core/cache-warmer.js.map +1 -1
  26. package/dist/core/compaction/compaction.d.ts +3 -1
  27. package/dist/core/compaction/compaction.d.ts.map +1 -1
  28. package/dist/core/compaction/compaction.js +146 -48
  29. package/dist/core/compaction/compaction.js.map +1 -1
  30. package/dist/core/crash-log.d.ts +5 -0
  31. package/dist/core/crash-log.d.ts.map +1 -1
  32. package/dist/core/crash-log.js +68 -0
  33. package/dist/core/crash-log.js.map +1 -1
  34. package/dist/core/export-html/template.js +6 -1
  35. package/dist/core/extensions/index.d.ts +1 -1
  36. package/dist/core/extensions/index.d.ts.map +1 -1
  37. package/dist/core/extensions/index.js.map +1 -1
  38. package/dist/core/extensions/runner.d.ts +16 -3
  39. package/dist/core/extensions/runner.d.ts.map +1 -1
  40. package/dist/core/extensions/runner.js +110 -5
  41. package/dist/core/extensions/runner.js.map +1 -1
  42. package/dist/core/extensions/types.d.ts +77 -6
  43. package/dist/core/extensions/types.d.ts.map +1 -1
  44. package/dist/core/extensions/types.js.map +1 -1
  45. package/dist/core/index.d.ts +1 -1
  46. package/dist/core/index.d.ts.map +1 -1
  47. package/dist/core/index.js.map +1 -1
  48. package/dist/core/model-config.d.ts +52 -0
  49. package/dist/core/model-config.d.ts.map +1 -1
  50. package/dist/core/model-config.js +16 -0
  51. package/dist/core/model-config.js.map +1 -1
  52. package/dist/core/prompt-templates.d.ts +6 -1
  53. package/dist/core/prompt-templates.d.ts.map +1 -1
  54. package/dist/core/prompt-templates.js +61 -35
  55. package/dist/core/prompt-templates.js.map +1 -1
  56. package/dist/core/provider-composer.d.ts +1 -0
  57. package/dist/core/provider-composer.d.ts.map +1 -1
  58. package/dist/core/provider-composer.js +19 -0
  59. package/dist/core/provider-composer.js.map +1 -1
  60. package/dist/core/resource-loader.d.ts.map +1 -1
  61. package/dist/core/resource-loader.js +6 -2
  62. package/dist/core/resource-loader.js.map +1 -1
  63. package/dist/core/sdk.d.ts.map +1 -1
  64. package/dist/core/sdk.js +2 -2
  65. package/dist/core/sdk.js.map +1 -1
  66. package/dist/core/session-manager.d.ts +36 -9
  67. package/dist/core/session-manager.d.ts.map +1 -1
  68. package/dist/core/session-manager.js +97 -7
  69. package/dist/core/session-manager.js.map +1 -1
  70. package/dist/core/tools/read.d.ts +4 -1
  71. package/dist/core/tools/read.d.ts.map +1 -1
  72. package/dist/core/tools/read.js +5 -1
  73. package/dist/core/tools/read.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.d.ts.map +1 -1
  92. package/dist/utils/mime.js +1 -1
  93. package/dist/utils/mime.js.map +1 -1
  94. package/dist/utils/tool-result-images.d.ts +3 -1
  95. package/dist/utils/tool-result-images.d.ts.map +1 -1
  96. package/dist/utils/tool-result-images.js +4 -1
  97. package/dist/utils/tool-result-images.js.map +1 -1
  98. package/docs/compaction.md +23 -4
  99. package/docs/extensions.md +70 -6
  100. package/docs/models.md +29 -1
  101. package/docs/sdk.md +13 -2
  102. package/docs/session-format.md +15 -2
  103. package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
  104. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  105. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  106. package/examples/extensions/gondolin/package-lock.json +2 -2
  107. package/examples/extensions/gondolin/package.json +1 -1
  108. package/examples/extensions/sandbox/package-lock.json +2 -2
  109. package/examples/extensions/sandbox/package.json +1 -1
  110. package/examples/extensions/with-deps/package-lock.json +2 -2
  111. package/examples/extensions/with-deps/package.json +1 -1
  112. package/examples/plugins/pi-example-plugin/src/session.ts +3 -2
  113. package/package.json +8 -8
  114. package/dist/bundle/chunks/chunk-WF5BMKD5.js +0 -2
@@ -18,22 +18,24 @@ import { contentText, getCurrentSystemMessage, normalizeContext, retryDelayMs }
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 { resolvePath } from "../utils/paths.js";
22
23
  import { sleep } from "../utils/sleep.js";
23
24
  import { normalizeToolResultImages } from "../utils/tool-result-images.js";
24
25
  import { formatNoApiKeyFoundMessage, formatNoModelSelectedMessage } from "./auth-guidance.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, 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 { cancelPiServerOperations, compactPiServer, } from "./pi-server-client.js";
35
37
  import { expandPromptTemplate } from "./prompt-templates.js";
36
- import { CURRENT_SESSION_VERSION, getLatestCompactionEntry } from "./session-manager.js";
38
+ import { CURRENT_SESSION_VERSION, getLatestCompactionEntry, SessionManager, } from "./session-manager.js";
37
39
  import { createSyntheticSourceInfo } from "./source-info.js";
38
40
  import { buildSystemPrompt, buildSystemPromptSections, diffSystemPromptSections, normalizeBuildSystemPromptOptions, } from "./system-prompt.js";
39
41
  import { createLocalBashOperations } from "./tools/bash.js";
@@ -208,6 +210,15 @@ export class AgentSession {
208
210
  // Extension system
209
211
  _extensionRunner;
210
212
  _turnIndex = 0;
213
+ _entryIdsByMessage = new WeakMap();
214
+ _boundaryDispatchedMessages = new WeakSet();
215
+ _lastAssistantMessage;
216
+ _lastAssistantToolResults = [];
217
+ _lastActivityOutcome = "completed";
218
+ _isBeforeSettle = false;
219
+ _abortDuringBeforeSettle = false;
220
+ _isEmittingAgentSettled = false;
221
+ _deferredSettledActions = [];
211
222
  _resourceLoader;
212
223
  _customTools;
213
224
  _baseToolDefinitions = new Map();
@@ -265,6 +276,8 @@ export class AgentSession {
265
276
  this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
266
277
  this._installAgentToolHooks();
267
278
  this._installAgentNextTurnRefresh();
279
+ this._installAgentRequestProjection();
280
+ this._installAgentBoundaryHooks();
268
281
  this._installAgentForcedPromptProjection();
269
282
  this._buildRuntime({
270
283
  activeToolNames: this._initialActiveToolNames,
@@ -382,8 +395,10 @@ export class AgentSession {
382
395
  : undefined;
383
396
  const content = hookResult?.content ?? result.content ?? [];
384
397
  // Runs after the extension hook so images injected or replaced by extensions are normalized too.
398
+ const resizeOptions = this.model?.inputLimits?.images?.resize;
385
399
  const normalizedContent = await normalizeToolResultImages(content, {
386
400
  autoResizeImages: this.settingsManager.getImageAutoResize(),
401
+ ...(resizeOptions ? { resizeOptions } : {}),
387
402
  });
388
403
  if (!hookResult && normalizedContent === content) {
389
404
  return undefined;
@@ -396,16 +411,112 @@ export class AgentSession {
396
411
  };
397
412
  };
398
413
  }
414
+ async _compactBeforeNextAssistantResponse(context, signal) {
415
+ if (signal?.aborted)
416
+ return context;
417
+ const model = this.model;
418
+ const settings = this.settingsManager.getCompactionSettings(model);
419
+ const projection = this.sessionManager.buildSessionProjection();
420
+ if (!model ||
421
+ model.contextWindow <= 0 ||
422
+ !shouldCompact(estimateProjectedContextTokens(projection, this.sessionManager.getBranch()).tokens, model.contextWindow, settings)) {
423
+ return { ...context, messages: projection.messages };
424
+ }
425
+ await this._runAutoCompaction("threshold", false, undefined, undefined, signal);
426
+ return { ...context, messages: this.sessionManager.buildSessionProjection().messages };
427
+ }
428
+ _installAgentRequestProjection() {
429
+ const previousPrepareRequest = this.agent.prepareRequest;
430
+ this.agent.prepareRequest = async (request, signal) => {
431
+ const canonicalContext = {
432
+ ...request.context,
433
+ messages: this.sessionManager.buildSessionProjection().messages,
434
+ // Messages declare the provider-visible loadout; context.tools keeps executable implementations.
435
+ tools: this.agent.state.tools.slice(),
436
+ };
437
+ const previous = await previousPrepareRequest?.({
438
+ ...request,
439
+ context: canonicalContext,
440
+ model: this.agent.state.model,
441
+ thinkingLevel: this.agent.state.thinkingLevel,
442
+ }, signal);
443
+ return {
444
+ ...previous,
445
+ context: previous?.context ?? canonicalContext,
446
+ model: previous?.model ?? this.agent.state.model,
447
+ thinkingLevel: previous?.thinkingLevel ?? this.agent.state.thinkingLevel,
448
+ };
449
+ };
450
+ }
451
+ async _dispatchTurnEndBoundary(message, toolResults) {
452
+ this._lastActivityOutcome =
453
+ message.stopReason === "aborted" ? "aborted" : message.stopReason === "error" ? "error" : "completed";
454
+ const messageEntryId = this._findPersistedMessageEntryId(message);
455
+ if (!this._extensionRunner.hasHandlers("turn_end"))
456
+ return false;
457
+ if (!messageEntryId) {
458
+ this._extensionRunner.emitError({
459
+ extensionPath: "<boundary>",
460
+ event: "turn_end",
461
+ error: "turn_end could not resolve the persisted assistant entry ID",
462
+ });
463
+ return false;
464
+ }
465
+ const toolResultEntryIds = toolResults.flatMap((result) => {
466
+ const entryId = this._findPersistedMessageEntryId(result);
467
+ return entryId ? [entryId] : [];
468
+ });
469
+ const boundary = await this._extensionRunner.emitBoundary({
470
+ type: "turn_end",
471
+ turnIndex: this._turnIndex,
472
+ message,
473
+ toolResults,
474
+ messageEntryId,
475
+ toolResultEntryIds,
476
+ outcome: this._lastActivityOutcome,
477
+ }, (entries) => this._buildBoundaryContext(entries, "turn_end"));
478
+ this._commitBoundaryDrafts(boundary.entries);
479
+ if (boundary.continue && !this._buildBoundaryContext([], "turn_end").canContinue) {
480
+ this._reportInvalidBoundaryContinuation("turn_end");
481
+ return false;
482
+ }
483
+ return boundary.continue;
484
+ }
485
+ _installAgentBoundaryHooks() {
486
+ const previousFinishTurn = this.agent.finishTurn;
487
+ this.agent.finishTurn = async (turn, signal) => {
488
+ this._boundaryDispatchedMessages.add(turn.message);
489
+ const extensionContinue = await this._dispatchTurnEndBoundary(turn.message, turn.toolResults);
490
+ const previousDecision = await previousFinishTurn?.(turn, signal);
491
+ if (previousDecision?.action === "end")
492
+ return previousDecision;
493
+ if (extensionContinue || previousDecision?.action === "continue")
494
+ return { action: "continue" };
495
+ return undefined;
496
+ };
497
+ }
399
498
  _installAgentNextTurnRefresh() {
400
499
  const previousPrepareNextTurnWithContext = this.agent.prepareNextTurnWithContext ??
401
500
  (this.agent.prepareNextTurn
402
501
  ? async (_turn, signal) => await this.agent.prepareNextTurn?.(signal)
403
502
  : undefined);
404
503
  this.agent.prepareNextTurnWithContext = async (turn, signal) => {
405
- const compactedContext = await this._compactBeforeNextProviderRequest(turn, signal);
406
- const nextTurn = compactedContext ? { ...turn, context: compactedContext } : turn;
504
+ const projectedTurn = {
505
+ ...turn,
506
+ context: {
507
+ ...turn.context,
508
+ messages: this.sessionManager.buildSessionProjection().messages,
509
+ },
510
+ };
511
+ const compactedContext = await this._compactBeforeNextProviderRequest(projectedTurn, signal);
512
+ const context = signal?.aborted
513
+ ? projectedTurn.context
514
+ : compactedContext === undefined
515
+ ? await this._compactBeforeNextAssistantResponse(projectedTurn.context, signal)
516
+ : (compactedContext ?? projectedTurn.context);
517
+ const nextTurn = { ...turn, context };
407
518
  const previousSnapshot = await previousPrepareNextTurnWithContext?.(nextTurn, signal);
408
- const nextContext = previousSnapshot?.context ?? nextTurn.context;
519
+ const nextContext = previousSnapshot?.context ?? context;
409
520
  const runOptions = this._runSystemPromptOptions ?? this._baseSystemPromptOptions;
410
521
  const options = normalizeBuildSystemPromptOptions({
411
522
  ...runOptions,
@@ -433,6 +544,84 @@ export class AgentSession {
433
544
  // =========================================================================
434
545
  // Event Subscription
435
546
  // =========================================================================
547
+ _refreshFinalizedContext() {
548
+ const projection = this.sessionManager.buildSessionProjection();
549
+ for (const entry of projection.entries) {
550
+ for (const message of entry.messages)
551
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
552
+ }
553
+ this.agent.state.messages = projection.messages;
554
+ }
555
+ _applyBoundaryDrafts(manager, drafts) {
556
+ const appended = [];
557
+ for (const draft of drafts) {
558
+ let entryId;
559
+ switch (draft.type) {
560
+ case "custom":
561
+ entryId = manager.appendCustomEntry(draft.customType, draft.data);
562
+ break;
563
+ case "custom_message":
564
+ entryId = manager.appendCustomMessageEntry(draft.customType, draft.content, draft.display, draft.details);
565
+ break;
566
+ case "context_edit":
567
+ entryId = manager.appendContextEdit(draft.targetId, draft.replacement);
568
+ break;
569
+ case "compaction": {
570
+ const tokensBefore = estimateProjectedContextTokens(manager.buildSessionProjection(), manager.getBranch()).tokens;
571
+ entryId = manager.appendCompaction(draft.summary, draft.firstKeptEntryId, tokensBefore, draft.details, true, draft.usage);
572
+ break;
573
+ }
574
+ }
575
+ const entry = manager.getEntry(entryId);
576
+ if (entry)
577
+ appended.push(entry);
578
+ }
579
+ return appended;
580
+ }
581
+ _createBoundaryPreviewManager(drafts) {
582
+ const header = this.sessionManager.getHeader();
583
+ if (!header)
584
+ throw new Error("Session header is missing");
585
+ const manager = SessionManager.inMemory(this._cwd, undefined, [header, ...this.sessionManager.getBranch()]);
586
+ this._applyBoundaryDrafts(manager, drafts);
587
+ return manager;
588
+ }
589
+ _getPendingBoundaryMessages() {
590
+ return [...this.agent.peekQueuedMessages(), ...this._pendingCustomMessages];
591
+ }
592
+ _buildBoundaryContext(drafts, boundary) {
593
+ const projection = this._createBoundaryPreviewManager(drafts).buildSessionProjection();
594
+ const pendingMessages = this._getPendingBoundaryMessages();
595
+ const llmMessages = convertToLlm(projection.messages);
596
+ const finalRole = llmMessages[llmMessages.length - 1]?.role;
597
+ const hasNonSystemContext = llmMessages.some((message) => message.role !== "system");
598
+ const contextCanContinue = hasNonSystemContext && finalRole !== "assistant";
599
+ const pendingCustomContext = this._pendingCustomMessages.length > 0;
600
+ return {
601
+ contextEntries: projection.entries,
602
+ contextMessages: projection.messages,
603
+ llmMessages,
604
+ pendingMessages,
605
+ canContinue: contextCanContinue ||
606
+ pendingCustomContext ||
607
+ (boundary === "turn_end"
608
+ ? this.agent.hasQueuedMessages()
609
+ : finalRole === "assistant" && this.agent.hasQueuedMessages()),
610
+ };
611
+ }
612
+ _commitBoundaryDrafts(drafts) {
613
+ const appended = this._applyBoundaryDrafts(this.sessionManager, drafts);
614
+ this._refreshFinalizedContext();
615
+ for (const entry of appended)
616
+ this._emit({ type: "entry_appended", entry });
617
+ }
618
+ _reportInvalidBoundaryContinuation(event) {
619
+ this._extensionRunner.emitError({
620
+ extensionPath: "<boundary>",
621
+ event,
622
+ error: `${event} requested continuation without runnable model context`,
623
+ });
624
+ }
436
625
  /** Emit an event to all listeners */
437
626
  _emit(event) {
438
627
  for (const l of this._eventListeners) {
@@ -471,16 +660,27 @@ export class AgentSession {
471
660
  async _emitAgentSettled() {
472
661
  this._cacheWarmer?.onAgentSettled();
473
662
  this._isAgentRunActive = false;
663
+ this._isEmittingAgentSettled = true;
474
664
  try {
475
665
  await this._extensionRunner.emit({ type: "agent_settled" });
476
666
  this._emit({ type: "agent_settled" });
477
667
  }
478
668
  finally {
479
- this._resolveIdleWaitIfIdle();
669
+ this._isEmittingAgentSettled = false;
670
+ }
671
+ const deferred = this._deferredSettledActions.splice(0);
672
+ if (deferred.length > 0) {
673
+ try {
674
+ for (const action of deferred)
675
+ await action();
676
+ }
677
+ finally {
678
+ this._resolveIdleWaitIfIdle();
679
+ }
680
+ return;
480
681
  }
682
+ this._resolveIdleWaitIfIdle();
481
683
  }
482
- // Track last assistant message for auto-compaction check
483
- _lastAssistantMessage = undefined;
484
684
  /** Internal handler for agent events - shared by subscribe and reconnect */
485
685
  _handleAgentEvent = async (event, _signal) => {
486
686
  // When a user message starts, check if it's from either queue and remove it BEFORE emitting
@@ -505,29 +705,30 @@ export class AgentSession {
505
705
  }
506
706
  }
507
707
  }
508
- // Emit to extensions first
708
+ // Emit to extensions first, then notify public listeners.
509
709
  await this._emitExtensionEvent(event);
510
- // Notify all listeners
511
710
  this._emit(event.type === "agent_end" ? { ...event, willRetry: this._willRetryAfterAgentEnd(event) } : event);
512
711
  // Handle session persistence
513
712
  if (event.type === "message_end") {
713
+ let entryId;
514
714
  // Check if this is a custom message from extensions
515
715
  if (event.message.role === "custom") {
516
716
  // Persist as CustomMessageEntry
517
- this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
717
+ entryId = this.sessionManager.appendCustomMessageEntry(event.message.customType, event.message.content, event.message.display, event.message.details);
518
718
  }
519
719
  else if (event.message.role === "system" ||
520
720
  event.message.role === "user" ||
521
721
  event.message.role === "assistant" ||
522
722
  event.message.role === "toolResult") {
523
723
  // Regular LLM message - persist as SessionMessageEntry
524
- this.sessionManager.appendMessage(event.message);
724
+ entryId = this.sessionManager.appendMessage(event.message);
525
725
  }
726
+ if (entryId)
727
+ this._entryIdsByMessage.set(event.message, entryId);
526
728
  // Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
527
- // Track assistant message for auto-compaction (checked on agent_end)
528
729
  if (event.message.role === "assistant") {
529
- this._lastAssistantMessage = event.message;
530
730
  const assistantMsg = event.message;
731
+ this._lastAssistantMessage = assistantMsg;
531
732
  if (!this._isTerminalAssistantFailure(assistantMsg)) {
532
733
  this._overflowRecoveryAttempted = false;
533
734
  }
@@ -549,6 +750,7 @@ export class AgentSession {
549
750
  // extension and listener dispatch above also picks up messages that turn_end
550
751
  // handlers queued.
551
752
  if (event.type === "turn_end") {
753
+ this._lastAssistantToolResults = event.toolResults;
552
754
  this._flushPendingCustomMessages();
553
755
  }
554
756
  };
@@ -567,6 +769,47 @@ export class AgentSession {
567
769
  }
568
770
  return false;
569
771
  }
772
+ _findPersistedMessageEntryId(message) {
773
+ const mapped = this._entryIdsByMessage.get(message);
774
+ if (mapped)
775
+ return mapped;
776
+ for (const entry of [...this.sessionManager.getBranch()].reverse()) {
777
+ if (entry.type === "message" && entry.message === message)
778
+ return entry.id;
779
+ }
780
+ const messageIndex = this.agent.state.messages.indexOf(message);
781
+ if (messageIndex < 0)
782
+ return undefined;
783
+ const projection = this.sessionManager.buildSessionProjection();
784
+ let projectedIndex = 0;
785
+ for (const entry of projection.entries) {
786
+ for (let i = 0; i < entry.messages.length; i++) {
787
+ if (projectedIndex === messageIndex) {
788
+ this._entryIdsByMessage.set(message, entry.sourceEntry.id);
789
+ return entry.sourceEntry.id;
790
+ }
791
+ projectedIndex++;
792
+ }
793
+ }
794
+ return undefined;
795
+ }
796
+ _omitRecoveryAttempt(message, toolResults = []) {
797
+ const targets = [message, ...toolResults];
798
+ const targetIds = targets.map((target) => this._findPersistedMessageEntryId(target));
799
+ const unresolvedProjectedTarget = targets.some((target, index) => targetIds[index] === undefined && this.agent.state.messages.includes(target));
800
+ if (unresolvedProjectedTarget) {
801
+ throw new Error("Cannot persist recovery omission because a projected message has no source entry");
802
+ }
803
+ for (const targetId of targetIds) {
804
+ if (!targetId)
805
+ continue;
806
+ const editId = this.sessionManager.appendContextEdit(targetId, null);
807
+ const entry = this.sessionManager.getEntry(editId);
808
+ if (entry)
809
+ this._emit({ type: "entry_appended", entry });
810
+ }
811
+ this._refreshFinalizedContext();
812
+ }
570
813
  /** Find the last assistant message in agent state (including aborted ones) */
571
814
  _findLastAssistantMessage() {
572
815
  const messages = this.agent.state.messages;
@@ -612,14 +855,42 @@ export class AgentSession {
612
855
  if (!this._isTerminalAssistantFailure(assistant)) {
613
856
  return;
614
857
  }
615
- if (terminalMessageEntry.parentId) {
616
- this.sessionManager.branch(terminalMessageEntry.parentId);
858
+ const terminalIndex = branch.findIndex((entry) => entry.id === terminalMessageEntry?.id);
859
+ const trailingEntries = terminalIndex >= 0 ? branch.slice(terminalIndex + 1) : [];
860
+ const trailingOmissionOnly = trailingEntries.every((entry) => entry.type === "context_edit" && entry.targetId === terminalMessageEntry?.id && entry.replacement === null);
861
+ if (trailingEntries.length === 0 || trailingOmissionOnly) {
862
+ // With no later durable metadata, moving the active leaf keeps the old
863
+ // failure out of the active branch while retaining it in full history.
864
+ if (terminalMessageEntry.parentId) {
865
+ this.sessionManager.branch(terminalMessageEntry.parentId);
866
+ }
867
+ else {
868
+ this.sessionManager.resetLeaf();
869
+ }
870
+ this._refreshFinalizedContext();
871
+ return;
617
872
  }
618
- else {
619
- this.sessionManager.resetLeaf();
873
+ // Preserve metadata after the failed attempt. The canonical projection can
874
+ // omit the attempt through an append-only edit; rewinding the raw leaf would
875
+ // also discard usage, boundary, or replacement entries persisted after it.
876
+ const projection = this.sessionManager.buildSessionProjection();
877
+ const projectedEntry = projection.entries.find((entry) => entry.sourceEntry.id === terminalMessageEntry.id);
878
+ if (!projectedEntry ||
879
+ !projectedEntry.messages.some((message) => message.role === "assistant" && this._isTerminalAssistantFailure(message))) {
880
+ this._refreshFinalizedContext();
881
+ return;
620
882
  }
621
- const sessionContext = this.sessionManager.buildSessionContext();
622
- this.agent.state.messages = sessionContext.messages;
883
+ let latestEdit;
884
+ for (let index = branch.length - 1; index >= 0; index--) {
885
+ const entry = branch[index];
886
+ if (entry.type === "context_edit" && entry.targetId === terminalMessageEntry.id) {
887
+ latestEdit = entry;
888
+ break;
889
+ }
890
+ }
891
+ if (latestEdit?.replacement !== null)
892
+ this.sessionManager.appendContextEdit(terminalMessageEntry.id, null);
893
+ this._refreshFinalizedContext();
623
894
  }
624
895
  _detachPiServerTerminalAssistantFailure() {
625
896
  this._detachTerminalAssistantFailure();
@@ -666,13 +937,9 @@ export class AgentSession {
666
937
  await this._extensionRunner.emit(extensionEvent);
667
938
  }
668
939
  else if (event.type === "turn_end") {
669
- const extensionEvent = {
670
- type: "turn_end",
671
- turnIndex: this._turnIndex,
672
- message: event.message,
673
- toolResults: event.toolResults,
674
- };
675
- await this._extensionRunner.emit(extensionEvent);
940
+ if (event.message.role === "assistant" && !this._boundaryDispatchedMessages.delete(event.message)) {
941
+ await this._dispatchTurnEndBoundary(event.message, event.toolResults);
942
+ }
676
943
  this._turnIndex++;
677
944
  }
678
945
  else if (event.type === "message_start") {
@@ -788,6 +1055,10 @@ export class AgentSession {
788
1055
  // =========================================================================
789
1056
  // Read-only State Access
790
1057
  // =========================================================================
1058
+ /** Refresh the public finalized transcript from the canonical session projection. */
1059
+ refreshContext() {
1060
+ this._refreshFinalizedContext();
1061
+ }
791
1062
  /** Full agent state */
792
1063
  get state() {
793
1064
  return this.agent.state;
@@ -1030,12 +1301,14 @@ export class AgentSession {
1030
1301
  this._isAgentRunActive = true;
1031
1302
  try {
1032
1303
  await start();
1033
- while (await this._handlePostAgentRun()) {
1034
- // Esc cancels the current agent run. Keep messages queued during that
1035
- // cancellation for the single deferred continuation in abort().
1036
- if (this._isAborting || this._agentRunAbortRequested) {
1037
- break;
1304
+ while (!this._agentRunAbortRequested) {
1305
+ const shouldContinue = await this._handlePostAgentRun();
1306
+ if (!shouldContinue) {
1307
+ if (this._isAborting || this._agentRunAbortRequested || !(await this._runBeforeSettleBoundary()))
1308
+ break;
1038
1309
  }
1310
+ if (this._isAborting || this._agentRunAbortRequested)
1311
+ break;
1039
1312
  try {
1040
1313
  await this.agent.continue();
1041
1314
  }
@@ -1141,22 +1414,23 @@ export class AgentSession {
1141
1414
  }
1142
1415
  }
1143
1416
  async _handlePostAgentRun() {
1144
- const msg = this._lastAssistantMessage;
1417
+ const message = this._lastAssistantMessage;
1418
+ const toolResults = this._lastAssistantToolResults;
1145
1419
  this._lastAssistantMessage = undefined;
1420
+ this._lastAssistantToolResults = [];
1146
1421
  if (this._agentRunAbortRequested) {
1147
1422
  this._finishCancelledRetry();
1148
1423
  return false;
1149
1424
  }
1150
- if (!msg) {
1151
- return false;
1152
- }
1425
+ if (!message)
1426
+ return this.agent.hasQueuedMessages();
1153
1427
  if (this._isAborting) {
1154
- if (isPiServerMode() && this._isTerminalAssistantFailure(msg)) {
1428
+ if (isPiServerMode() && this._isTerminalAssistantFailure(message)) {
1155
1429
  this._detachPiServerTerminalAssistantFailure();
1156
1430
  }
1157
1431
  return false;
1158
1432
  }
1159
- if (this._isRetryableError(msg) && (await this._prepareRetry(msg))) {
1433
+ if (this._isRetryableError(message) && (await this._prepareRetry(message))) {
1160
1434
  if (this._agentRunAbortRequested)
1161
1435
  this._finishCancelledRetry();
1162
1436
  return !this._agentRunAbortRequested;
@@ -1168,25 +1442,49 @@ export class AgentSession {
1168
1442
  if (this._isAborting) {
1169
1443
  return false;
1170
1444
  }
1171
- if (msg.stopReason === "error" && this._retryAttempt > 0) {
1445
+ if (message.stopReason === "error" && this._retryAttempt > 0) {
1172
1446
  this._emit({
1173
1447
  type: "auto_retry_end",
1174
1448
  success: false,
1175
1449
  attempt: this._retryAttempt,
1176
- finalError: msg.errorMessage,
1450
+ finalError: message.errorMessage,
1177
1451
  });
1178
1452
  this._retryAttempt = 0;
1179
1453
  }
1180
- if (await this._checkCompaction(msg)) {
1454
+ if (await this._checkCompaction(message, true, toolResults)) {
1181
1455
  return !this._agentRunAbortRequested;
1182
1456
  }
1183
- if (isPiServerMode() && this._isTerminalAssistantFailure(msg)) {
1457
+ if (isPiServerMode() && this._isTerminalAssistantFailure(message)) {
1184
1458
  this._detachPiServerTerminalAssistantFailure();
1185
1459
  }
1186
1460
  // The agent loop drains both queues before emitting agent_end. Any messages
1187
1461
  // here were queued by agent_end extension handlers and need a continuation.
1188
1462
  return !this._agentRunAbortRequested && this.agent.hasQueuedMessages();
1189
1463
  }
1464
+ async _runBeforeSettleBoundary() {
1465
+ if (!this._extensionRunner.hasHandlers("agent_before_settle"))
1466
+ return this.agent.hasQueuedMessages();
1467
+ this._isBeforeSettle = true;
1468
+ this._abortDuringBeforeSettle = false;
1469
+ try {
1470
+ const result = await this._extensionRunner.emitBoundary({ type: "agent_before_settle", outcome: this._lastActivityOutcome }, (entries) => this._buildBoundaryContext(entries, "agent_before_settle"));
1471
+ this._commitBoundaryDrafts(result.entries);
1472
+ this._flushPendingCustomMessages();
1473
+ const finalContext = this._buildBoundaryContext([], "agent_before_settle");
1474
+ if (this._abortDuringBeforeSettle)
1475
+ return false;
1476
+ const shouldContinue = result.continue || this.agent.hasQueuedMessages();
1477
+ if (shouldContinue && !finalContext.canContinue) {
1478
+ if (result.continue)
1479
+ this._reportInvalidBoundaryContinuation("agent_before_settle");
1480
+ return false;
1481
+ }
1482
+ return shouldContinue;
1483
+ }
1484
+ finally {
1485
+ this._isBeforeSettle = false;
1486
+ }
1487
+ }
1190
1488
  async _runInputHandlers(text, images, source, streamingBehavior) {
1191
1489
  if (!this._extensionRunner.hasHandlers("input")) {
1192
1490
  return { text, images };
@@ -1200,6 +1498,25 @@ export class AgentSession {
1200
1498
  }
1201
1499
  return { text, images };
1202
1500
  }
1501
+ async _normalizePromptImages(images) {
1502
+ if (!images)
1503
+ return { images: [], hints: [] };
1504
+ const normalizedImages = [];
1505
+ const hints = [];
1506
+ for (const image of images) {
1507
+ const processed = await processImage(Buffer.from(image.data, "base64"), image.mimeType, {
1508
+ autoResizeImages: this.settingsManager.getImageAutoResize(),
1509
+ resizeOptions: this.model?.inputLimits?.images?.resize,
1510
+ });
1511
+ if (!processed.ok) {
1512
+ hints.push(processed.message);
1513
+ continue;
1514
+ }
1515
+ normalizedImages.push({ type: "image", data: processed.data, mimeType: processed.mimeType });
1516
+ hints.push(...processed.hints);
1517
+ }
1518
+ return { images: normalizedImages, hints };
1519
+ }
1203
1520
  /**
1204
1521
  * Send a prompt to the agent.
1205
1522
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -1210,6 +1527,10 @@ export class AgentSession {
1210
1527
  * @throws Error if no model selected or no API key available (when not streaming)
1211
1528
  */
1212
1529
  async prompt(text, options) {
1530
+ if (this._isEmittingAgentSettled) {
1531
+ this._deferredSettledActions.push(async () => await this.prompt(text, options));
1532
+ return;
1533
+ }
1213
1534
  const expandPromptTemplates = options?.expandPromptTemplates ?? true;
1214
1535
  const preflightResult = options?.preflightResult;
1215
1536
  let messages;
@@ -1288,7 +1609,7 @@ export class AgentSession {
1288
1609
  this._prePromptAbortController = prePromptAbortController;
1289
1610
  this._prePromptCompletionPromise = prePromptCompletion;
1290
1611
  try {
1291
- await this._checkCompaction(lastAssistant, false, prePromptAbortController.signal);
1612
+ await this._checkCompaction(lastAssistant, false, [], prePromptAbortController.signal);
1292
1613
  }
1293
1614
  finally {
1294
1615
  if (this._prePromptAbortController === prePromptAbortController) {
@@ -1307,13 +1628,23 @@ export class AgentSession {
1307
1628
  if (isPiServerMode()) {
1308
1629
  this._detachPiServerTerminalAssistantFailure();
1309
1630
  }
1310
- // Build messages array (custom message if any, then user message)
1631
+ // Emit before_agent_start before normalizing images so extension-driven model
1632
+ // selection determines the resize profile used for the request and history.
1633
+ const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
1634
+ const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptOptions);
1635
+ // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
1636
+ // which updates the live loadout instead. An explicit edit wins; otherwise the live
1637
+ // loadout is authoritative, so a setActiveTools() call is not undone here.
1638
+ const handlerEditedTools = result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
1639
+ result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
1640
+ if (!handlerEditedTools)
1641
+ result.systemPromptOptions.selectedTools = this.getActiveToolNames();
1642
+ const normalized = await this._normalizePromptImages(currentImages);
1643
+ const userText = normalized.hints.length > 0 ? `${expandedText}\n\n${normalized.hints.join("\n")}` : expandedText;
1644
+ // Build messages only after hooks and image normalization have completed.
1311
1645
  messages = [];
1312
- // Add user message
1313
- const userContent = [{ type: "text", text: expandedText }];
1314
- if (currentImages) {
1315
- userContent.push(...currentImages);
1316
- }
1646
+ const userContent = [{ type: "text", text: userText }];
1647
+ userContent.push(...normalized.images);
1317
1648
  messages.push({
1318
1649
  role: "user",
1319
1650
  content: userContent,
@@ -1324,16 +1655,6 @@ export class AgentSession {
1324
1655
  messages.push(msg);
1325
1656
  }
1326
1657
  this._pendingNextTurnMessages = [];
1327
- // Emit before_agent_start extension event
1328
- const selectedToolsBefore = this._baseSystemPromptOptions.selectedTools;
1329
- const result = await this._extensionRunner.emitBeforeAgentStart(expandedText, currentImages, this._baseSystemPromptOptions);
1330
- // Handlers may edit event.systemPromptOptions.selectedTools or call setActiveTools(),
1331
- // which updates the live loadout instead. An explicit edit wins; otherwise the live
1332
- // loadout is authoritative, so a setActiveTools() call is not undone here.
1333
- const handlerEditedTools = result.systemPromptOptions.selectedTools.length !== selectedToolsBefore.length ||
1334
- result.systemPromptOptions.selectedTools.some((name, index) => name !== selectedToolsBefore[index]);
1335
- if (!handlerEditedTools)
1336
- result.systemPromptOptions.selectedTools = this.getActiveToolNames();
1337
1658
  for (const msg of result.messages) {
1338
1659
  messages.push({
1339
1660
  role: "custom",
@@ -1546,6 +1867,10 @@ export class AgentSession {
1546
1867
  }
1547
1868
  }
1548
1869
  else if (options?.triggerTurn) {
1870
+ if (this._isEmittingAgentSettled) {
1871
+ this._deferredSettledActions.push(async () => await this._runAgentPrompt(appMessage));
1872
+ return;
1873
+ }
1549
1874
  await this._runAgentPrompt(appMessage);
1550
1875
  }
1551
1876
  else if (this.isStreaming) {
@@ -1560,8 +1885,8 @@ export class AgentSession {
1560
1885
  }
1561
1886
  }
1562
1887
  _appendCustomMessage(appMessage) {
1563
- this.agent.state.messages.push(appMessage);
1564
1888
  this.sessionManager.appendCustomMessageEntry(appMessage.customType, appMessage.content, appMessage.display, appMessage.details);
1889
+ this._refreshFinalizedContext();
1565
1890
  this._emit({ type: "message_start", message: appMessage });
1566
1891
  this._emit({ type: "message_end", message: appMessage });
1567
1892
  }
@@ -1666,6 +1991,8 @@ export class AgentSession {
1666
1991
  this.abortBash();
1667
1992
  this._prePromptAbortController?.abort();
1668
1993
  this._sessionNameAbortController?.abort();
1994
+ if (this._isBeforeSettle)
1995
+ this._abortDuringBeforeSettle = true;
1669
1996
  this.agent.abort();
1670
1997
  // _runAgentOperation owns the settled event and the idle waiter. Do not
1671
1998
  // start a queued continuation until that cleanup has completed.
@@ -2094,9 +2421,8 @@ export class AgentSession {
2094
2421
  }
2095
2422
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
2096
2423
  const newEntries = this.sessionManager.getEntries();
2097
- const sessionContext = this.sessionManager.buildSessionContext();
2098
- this.agent.state.messages = sessionContext.messages;
2099
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2424
+ this._refreshFinalizedContext();
2425
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
2100
2426
  // Get the saved compaction entry for the extension event
2101
2427
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
2102
2428
  if (this._extensionRunner && savedCompactionEntry) {
@@ -2186,7 +2512,7 @@ export class AgentSession {
2186
2512
  * @param assistantMessage The assistant message to check
2187
2513
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
2188
2514
  */
2189
- async _checkCompaction(assistantMessage, skipAbortedCheck = true, externalSignal) {
2515
+ async _checkCompaction(assistantMessage, skipAbortedCheck = true, toolResults = [], externalSignal) {
2190
2516
  if (externalSignal?.aborted)
2191
2517
  return false;
2192
2518
  const settings = this.settingsManager.getCompactionSettings(this.model);
@@ -2214,16 +2540,31 @@ export class AgentSession {
2214
2540
  // Case 1: Recoverable failure. Explicit/silent context overflow still uses context metadata.
2215
2541
  // A length stop is recoverable when output ended below the model's original desired limit,
2216
2542
  // independent of the configured context size or any context-clamped provider request limit.
2217
- // A successful response over the configured window should compact but must not retry: the
2218
- // assistant answer already completed and agent.continue() cannot continue from an assistant.
2219
- const contextOverflow = sameModel && isContextOverflow(assistantMessage, contextWindow);
2220
- const recoverableLength = sameModel && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
2543
+ const currentProjection = this.sessionManager.buildSessionProjection();
2544
+ const assistantEntryId = this._findPersistedMessageEntryId(assistantMessage);
2545
+ const assistantIsProjected = assistantEntryId === undefined ||
2546
+ currentProjection.entries.some((entry) => entry.sourceEntry.id === assistantEntryId &&
2547
+ entry.messages.some((message) => message.role === "assistant"));
2548
+ const branch = this.sessionManager.getBranch();
2549
+ const assistantIndex = assistantEntryId ? branch.findIndex((entry) => entry.id === assistantEntryId) : -1;
2550
+ const entriesAfterAssistant = assistantIndex >= 0 ? branch.slice(assistantIndex + 1) : [];
2551
+ const hasPostAssistantContextEdit = entriesAfterAssistant.some((entry) => entry.type === "context_edit");
2552
+ const latestAssistantEdit = entriesAfterAssistant
2553
+ .filter((entry) => entry.type === "context_edit" && entry.targetId === assistantEntryId)
2554
+ .at(-1);
2555
+ const assistantRetainedForExplicitRecovery = assistantEntryId === undefined ||
2556
+ (!entriesAfterAssistant.some((entry) => entry.type === "compaction") &&
2557
+ latestAssistantEdit?.replacement !== null);
2558
+ const assistantUsageMatchesProjection = assistantIsProjected && !hasPostAssistantContextEdit;
2559
+ const explicitOverflow = assistantMessage.stopReason === "error" && isContextOverflow(assistantMessage);
2560
+ const contextOverflow = sameModel &&
2561
+ ((explicitOverflow && assistantRetainedForExplicitRecovery) ||
2562
+ (assistantUsageMatchesProjection && isContextOverflow(assistantMessage, contextWindow)));
2563
+ const recoverableLength = sameModel && assistantIsProjected && isRecoverableLength(assistantMessage, this.model?.maxTokens ?? 0);
2221
2564
  if (contextOverflow || recoverableLength) {
2222
2565
  const willRetry = assistantMessage.stopReason !== "stop";
2223
2566
  if (!willRetry) {
2224
- return externalSignal
2225
- ? await this._runAutoCompaction("overflow", false, undefined, undefined, externalSignal)
2226
- : await this._runAutoCompaction("overflow", false);
2567
+ return await this._runAutoCompaction("overflow", false, undefined, undefined, externalSignal);
2227
2568
  }
2228
2569
  if (this._overflowRecoveryAttempted) {
2229
2570
  const errorMessage = contextOverflow
@@ -2239,30 +2580,25 @@ export class AgentSession {
2239
2580
  });
2240
2581
  return false;
2241
2582
  }
2583
+ // Persistently omit the selected final attempt before post-run recovery compaction.
2242
2584
  this._overflowRecoveryAttempted = true;
2243
- const terminalFailurePreparation = this._isTerminalAssistantFailure(assistantMessage)
2244
- ? prepareCompaction(this.sessionManager.getBranch(), settings)
2245
- : undefined;
2246
- // Remove the error message from agent state (it IS saved to session for history,
2247
- // but we don't want it in context for the retry)
2248
- if (this._isTerminalAssistantFailure(assistantMessage)) {
2249
- this._detachTerminalAssistantFailure();
2250
- }
2251
- else {
2252
- const messages = this.agent.state.messages;
2253
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2254
- this.agent.state.messages = messages.slice(0, -1);
2255
- }
2256
- }
2257
- return await this._runAutoCompaction("overflow", willRetry, terminalFailurePreparation, undefined, externalSignal);
2585
+ this._omitRecoveryAttempt(assistantMessage, toolResults);
2586
+ if (isPiServerMode())
2587
+ this._detachPiServerTerminalAssistantFailure();
2588
+ return await this._runAutoCompaction("overflow", willRetry, undefined, undefined, externalSignal);
2258
2589
  }
2259
2590
  // Case 2: Threshold - context is getting large
2260
2591
  // For error messages or all-zero usage messages, estimate from the last valid response.
2261
2592
  // This ensures sessions that hit persistent API errors (e.g. 529) or malformed zero-usage
2262
2593
  // responses can still compact and do not reset context accounting.
2263
2594
  let contextTokens;
2595
+ const projection = currentProjection;
2596
+ const hasContextEdits = projection.entries.some((entry) => entry.sourceEntry.type === "context_edit");
2264
2597
  const directContextTokens = assistantMessage.usage ? calculateContextTokens(assistantMessage.usage) : 0;
2265
- if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
2598
+ if (hasContextEdits) {
2599
+ contextTokens = estimateProjectedContextTokens(projection, branch).tokens;
2600
+ }
2601
+ else if (assistantMessage.stopReason === "error" || directContextTokens === 0) {
2266
2602
  const messages = this.agent.state.messages;
2267
2603
  const estimate = estimateContextTokens(messages);
2268
2604
  // When a valid usage entry exists, verify it is post-compaction. Kept
@@ -2410,9 +2746,8 @@ export class AgentSession {
2410
2746
  abortController.signal.throwIfAborted();
2411
2747
  this.sessionManager.appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromExtension, usage);
2412
2748
  const newEntries = this.sessionManager.getEntries();
2413
- const sessionContext = this.sessionManager.buildSessionContext();
2414
- this.agent.state.messages = sessionContext.messages;
2415
- const estimatedTokensAfter = estimateMessagesTokens(sessionContext.messages);
2749
+ this._refreshFinalizedContext();
2750
+ const estimatedTokensAfter = estimateMessagesTokens(this.sessionManager.buildSessionProjection().messages);
2416
2751
  const savedCompactionEntry = newEntries.find((e) => e.type === "compaction" && e.summary === summary);
2417
2752
  if (this._extensionRunner && savedCompactionEntry) {
2418
2753
  await this._extensionRunner.emit({
@@ -2494,7 +2829,38 @@ export class AgentSession {
2494
2829
  const contextWindow = this.model?.contextWindow ?? 0;
2495
2830
  if (contextWindow <= 0)
2496
2831
  return false;
2497
- const projectedTokens = estimateContextTokens(turn.context.messages).tokens;
2832
+ const branch = this.sessionManager.getBranch();
2833
+ const projection = this.sessionManager.buildSessionProjection();
2834
+ const toolResultEntryIndexes = turn.toolResults
2835
+ .map((message) => this._findPersistedMessageEntryId(message))
2836
+ .flatMap((id) => {
2837
+ const index = id ? branch.findIndex((entry) => entry.id === id) : -1;
2838
+ return index >= 0 ? [index] : [];
2839
+ });
2840
+ const lastToolResultIndex = Math.max(...toolResultEntryIndexes, -1);
2841
+ if (lastToolResultIndex >= 0) {
2842
+ for (const entry of branch.slice(lastToolResultIndex + 1)) {
2843
+ if (entry.type === "custom_message")
2844
+ return false;
2845
+ if (entry.type === "message" &&
2846
+ (entry.message.role === "user" ||
2847
+ entry.message.role === "custom" ||
2848
+ entry.message.role === "bashExecution")) {
2849
+ return false;
2850
+ }
2851
+ if (entry.type === "context_edit" && entry.replacement !== null) {
2852
+ const target = branch.find((candidate) => candidate.id === entry.targetId);
2853
+ if (target?.type === "custom_message" ||
2854
+ (target?.type === "message" &&
2855
+ (target.message.role === "user" ||
2856
+ target.message.role === "custom" ||
2857
+ target.message.role === "bashExecution"))) {
2858
+ return false;
2859
+ }
2860
+ }
2861
+ }
2862
+ }
2863
+ const projectedTokens = estimateProjectedContextTokens(projection, branch).tokens;
2498
2864
  return projectedTokens >= contextWindow - settings.reserveTokens;
2499
2865
  }
2500
2866
  _appendIntraTurnCompactionMarker() {
@@ -2530,13 +2896,13 @@ export class AgentSession {
2530
2896
  const preparation = prepareCompaction(branchWithMarker, settings, preparationOptions);
2531
2897
  if (!preparation) {
2532
2898
  this._restoreSessionLeaf(markerParentId);
2533
- return undefined;
2899
+ return null;
2534
2900
  }
2535
- await this._runAutoCompaction("threshold", false, preparation, preparationOptions);
2901
+ await this._runAutoCompaction("threshold", false, preparation, preparationOptions, signal);
2536
2902
  const currentCompactionId = getLatestCompactionEntry(this.sessionManager.getBranch())?.id;
2537
2903
  if (currentCompactionId === previousCompactionId) {
2538
2904
  this._restoreSessionLeaf(markerParentId);
2539
- return undefined;
2905
+ return null;
2540
2906
  }
2541
2907
  return this._currentAgentContext(turn.context);
2542
2908
  }
@@ -2964,28 +3330,10 @@ export class AgentSession {
2964
3330
  delayMs,
2965
3331
  errorMessage: message.errorMessage || "Unknown error",
2966
3332
  });
2967
- if (isPiServerMode()) {
3333
+ // Keep the failed attempt in raw history while durably omitting it from model projection.
3334
+ this._omitRecoveryAttempt(message);
3335
+ if (isPiServerMode())
2968
3336
  this._detachPiServerTerminalAssistantFailure();
2969
- }
2970
- else {
2971
- // Remove error message from agent state (keep in session for history)
2972
- const messages = this.agent.state.messages;
2973
- if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
2974
- this.agent.state.messages = messages.slice(0, -1);
2975
- }
2976
- }
2977
- const lastMessage = this.agent.state.messages[this.agent.state.messages.length - 1];
2978
- if (!lastMessage || lastMessage.role === "assistant") {
2979
- const attempt = this._retryAttempt;
2980
- this._retryAttempt = 0;
2981
- this._emit({
2982
- type: "auto_retry_end",
2983
- success: false,
2984
- attempt,
2985
- finalError: lastMessage ? "Cannot continue from message role: assistant" : "No messages to continue from",
2986
- });
2987
- return false;
2988
- }
2989
3337
  // Wait with exponential backoff (abortable)
2990
3338
  this._retryAbortController = new AbortController();
2991
3339
  try {
@@ -3084,10 +3432,8 @@ export class AgentSession {
3084
3432
  this._pendingBashMessages.push(bashMessage);
3085
3433
  }
3086
3434
  else {
3087
- // Add to agent state immediately
3088
- this.agent.state.messages.push(bashMessage);
3089
- // Save to session
3090
3435
  this.sessionManager.appendMessage(bashMessage);
3436
+ this._refreshFinalizedContext();
3091
3437
  }
3092
3438
  }
3093
3439
  /**
@@ -3114,12 +3460,10 @@ export class AgentSession {
3114
3460
  if (this._pendingBashMessages.length === 0)
3115
3461
  return;
3116
3462
  for (const bashMessage of this._pendingBashMessages) {
3117
- // Add to agent state
3118
- this.agent.state.messages.push(bashMessage);
3119
- // Save to session
3120
3463
  this.sessionManager.appendMessage(bashMessage);
3121
3464
  }
3122
3465
  this._pendingBashMessages = [];
3466
+ this._refreshFinalizedContext();
3123
3467
  }
3124
3468
  // =========================================================================
3125
3469
  // Session Management
@@ -3298,9 +3642,8 @@ export class AgentSession {
3298
3642
  if (label && !summaryText) {
3299
3643
  this.sessionManager.appendLabelChange(targetId, label);
3300
3644
  }
3301
- // Update agent state
3302
- const sessionContext = this.sessionManager.buildSessionContext();
3303
- this.agent.state.messages = sessionContext.messages;
3645
+ // Update finalized context from the canonical session projection.
3646
+ this._refreshFinalizedContext();
3304
3647
  this._restoreToolsFromTranscript();
3305
3648
  // Emit session_tree event
3306
3649
  await this._extensionRunner.emit({
@@ -3410,30 +3753,24 @@ export class AgentSession {
3410
3753
  // After compaction, the last assistant usage reflects pre-compaction context size.
3411
3754
  // We can only trust usage from an assistant that responded after the latest compaction.
3412
3755
  // If no such assistant exists, context token count is unknown until the next LLM response.
3413
- const branchEntries = this.sessionManager.getBranch();
3414
- const latestCompaction = getLatestCompactionEntry(branchEntries);
3756
+ const projection = this.sessionManager.buildSessionProjection();
3757
+ const branch = this.sessionManager.getBranch();
3758
+ const latestCompaction = getLatestCompactionEntry(branch);
3415
3759
  if (latestCompaction) {
3416
- // Check if there's a valid assistant usage after the compaction boundary
3417
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
3418
- let hasPostCompactionUsage = false;
3419
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
3420
- const entry = branchEntries[i];
3421
- if (entry.type === "message" && entry.message.role === "assistant") {
3422
- const assistant = entry.message;
3423
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
3424
- const contextTokens = calculateContextTokens(assistant.usage);
3425
- if (contextTokens > 0) {
3426
- hasPostCompactionUsage = true;
3427
- break;
3428
- }
3429
- }
3430
- }
3431
- }
3432
- if (!hasPostCompactionUsage) {
3760
+ const projectedAssistants = new Set(projection.entries.flatMap((entry) => entry.messages.some((message) => message.role === "assistant" &&
3761
+ message.stopReason !== "aborted" &&
3762
+ message.stopReason !== "error" &&
3763
+ calculateContextTokens(message.usage) > 0)
3764
+ ? [entry.sourceEntry.id]
3765
+ : []));
3766
+ const compactionIndex = branch.findIndex((entry) => entry.id === latestCompaction.id);
3767
+ const hasPostCompactionUsage = branch
3768
+ .slice(compactionIndex + 1)
3769
+ .some((entry) => projectedAssistants.has(entry.id));
3770
+ if (!hasPostCompactionUsage)
3433
3771
  return { tokens: null, contextWindow, percent: null };
3434
- }
3435
3772
  }
3436
- const estimate = estimateContextTokens(this.messages);
3773
+ const estimate = estimateProjectedContextTokens(projection, branch);
3437
3774
  const percent = (estimate.tokens / contextWindow) * 100;
3438
3775
  return {
3439
3776
  tokens: estimate.tokens,