@gajae-code/coding-agent 0.10.0 → 0.10.2

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 (65) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/settings.d.ts +2 -0
  4. package/dist/types/coordinator/contract.d.ts +1 -1
  5. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +17 -5
  7. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  9. package/dist/types/modes/components/gajae-pet-widget.d.ts +1 -0
  10. package/dist/types/modes/components/pet-capability.d.ts +37 -0
  11. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  12. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  13. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  14. package/dist/types/modes/components/status-line.d.ts +0 -10
  15. package/dist/types/modes/interactive-mode.d.ts +2 -1
  16. package/dist/types/modes/print-mode.d.ts +3 -4
  17. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  18. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  19. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  20. package/dist/types/modes/types.d.ts +11 -1
  21. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  22. package/dist/types/session/agent-session.d.ts +14 -1
  23. package/dist/types/session/context-estimation.d.ts +31 -0
  24. package/package.json +7 -7
  25. package/src/cli.ts +1 -0
  26. package/src/config/model-profiles.ts +19 -19
  27. package/src/config/settings.ts +96 -45
  28. package/src/coordinator/contract.ts +1 -0
  29. package/src/coordinator-mcp/policy.ts +30 -0
  30. package/src/coordinator-mcp/server.ts +294 -66
  31. package/src/coordinator-mcp/session-reaper.ts +129 -0
  32. package/src/extensibility/extensions/runner.ts +1 -1
  33. package/src/extensibility/extensions/types.ts +3 -1
  34. package/src/extensibility/slash-commands.ts +3 -3
  35. package/src/gjc-runtime/tmux-owner-isolation.ts +4 -1
  36. package/src/internal-urls/docs-index.generated.ts +7 -7
  37. package/src/main.ts +3 -5
  38. package/src/modes/acp/acp-agent.ts +2 -2
  39. package/src/modes/components/custom-editor.ts +1 -0
  40. package/src/modes/components/footer.ts +3 -3
  41. package/src/modes/components/gajae-pet-widget.ts +169 -20
  42. package/src/modes/components/pet-capability.ts +115 -0
  43. package/src/modes/components/pet-selector.ts +4 -6
  44. package/src/modes/components/settings-selector.ts +28 -2
  45. package/src/modes/components/status-line/segments.ts +13 -6
  46. package/src/modes/components/status-line/types.ts +1 -1
  47. package/src/modes/components/status-line.ts +4 -171
  48. package/src/modes/controllers/extension-ui-controller.ts +10 -9
  49. package/src/modes/controllers/input-controller.ts +43 -13
  50. package/src/modes/controllers/selector-controller.ts +7 -18
  51. package/src/modes/interactive-mode.ts +65 -4
  52. package/src/modes/print-mode.ts +228 -92
  53. package/src/modes/rpc/rpc-client.ts +58 -1
  54. package/src/modes/rpc/rpc-mode.ts +286 -85
  55. package/src/modes/rpc/rpc-types.ts +15 -1
  56. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  57. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  58. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  59. package/src/modes/types.ts +11 -1
  60. package/src/modes/utils/context-usage.ts +56 -92
  61. package/src/session/agent-session.ts +337 -90
  62. package/src/session/context-estimation.ts +91 -0
  63. package/src/session/session-manager.ts +9 -2
  64. package/src/slash-commands/builtin-registry.ts +31 -19
  65. package/src/slash-commands/helpers/context-report.ts +26 -4
@@ -28,6 +28,7 @@ import {
28
28
  type AgentTool,
29
29
  assertImagePlaceholdersHavePayload,
30
30
  canContinuePersistedHistory,
31
+ type ResolvedThinkingLevel,
31
32
  resolveTelemetry,
32
33
  type StablePrefixSnapshot,
33
34
  ThinkingLevel,
@@ -279,6 +280,7 @@ import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../wo
279
280
  import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
280
281
  import type { AuthStorage } from "./auth-storage";
281
282
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
283
+ import { computeNonMessageTokens } from "./context-estimation";
282
284
  import {
283
285
  type ContributionPrepOptions,
284
286
  type ContributionPrepResult,
@@ -531,6 +533,12 @@ export interface RoleModelCycleResult {
531
533
  role: string;
532
534
  }
533
535
 
536
+ export interface DefaultModelSelectionResult {
537
+ readonly provider: string;
538
+ readonly modelId: string;
539
+ readonly thinkingLevel: ResolvedThinkingLevel;
540
+ }
541
+
534
542
  /** Session statistics for /session command */
535
543
  export interface SessionStats {
536
544
  sessionFile: string | undefined;
@@ -1181,6 +1189,7 @@ export class AgentSession {
1181
1189
  #scopedModels: ScopedModelSelection[];
1182
1190
  #thinkingLevel: ThinkingLevel | undefined;
1183
1191
  #activeModelProfile: string | undefined;
1192
+ #defaultModelSelectionTail: Promise<void> = Promise.resolve();
1184
1193
  #promptTemplates: PromptTemplate[];
1185
1194
  #slashCommands: FileSlashCommand[];
1186
1195
 
@@ -1233,6 +1242,11 @@ export class AgentSession {
1233
1242
  #resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
1234
1243
  #retainedMemorySampler: (() => RetainedMemorySample) | undefined;
1235
1244
  #prePromptContextCheckPromise: Promise<void> | undefined = undefined;
1245
+ /** Display-only context snapshot; pre-prompt compaction estimates deliberately remain uncached. */
1246
+ #contextUsageCache: { key: string; value: ContextUsage } | undefined;
1247
+ #contextUsageMessageIds = new WeakMap<AgentMessage, number>();
1248
+ #nextContextUsageMessageId = 0;
1249
+ #contextUsageEstimateCount = 0;
1236
1250
 
1237
1251
  // Branch summarization state
1238
1252
  #branchSummaryAbortController: AbortController | undefined = undefined;
@@ -2210,6 +2224,7 @@ export class AgentSession {
2210
2224
  (this.#planCompactAbortPending || this.#silentAbortPending)
2211
2225
  ) {
2212
2226
  (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
2227
+ this.agent.touchContext();
2213
2228
  this.#planCompactAbortPending = false;
2214
2229
  this.#silentAbortPending = false;
2215
2230
  }
@@ -5921,7 +5936,7 @@ export class AgentSession {
5921
5936
  reload: async () => {
5922
5937
  await this.reload();
5923
5938
  },
5924
- getSystemPrompt: () => this.systemPrompt,
5939
+ getSystemPrompt: () => [...this.systemPrompt],
5925
5940
  };
5926
5941
  }
5927
5942
 
@@ -6986,6 +7001,94 @@ export class AgentSession {
6986
7001
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
6987
7002
  }
6988
7003
 
7004
+ async setDefaultModelSelection(
7005
+ model: Model,
7006
+ thinkingLevel: ThinkingLevel | undefined,
7007
+ ): Promise<DefaultModelSelectionResult> {
7008
+ const predecessor = this.#defaultModelSelectionTail;
7009
+ const transaction = Promise.withResolvers<void>();
7010
+ this.#defaultModelSelectionTail = transaction.promise;
7011
+ try {
7012
+ await predecessor;
7013
+ if (thinkingLevel === ThinkingLevel.Inherit) {
7014
+ throw new Error("Default model selection cannot inherit a thinking level");
7015
+ }
7016
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
7017
+ if (!apiKey) {
7018
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
7019
+ }
7020
+ const resolvedLevel = resolveThinkingLevelForModel(model, thinkingLevel);
7021
+ const effectiveLevel =
7022
+ resolvedLevel ??
7023
+ resolveThinkingLevelForModel(model, model.thinking?.defaultLevel ?? this.thinkingLevel) ??
7024
+ ThinkingLevel.Off;
7025
+ await this.waitForIdle();
7026
+ const previousDefaultModelRole = this.settings.getGlobal("modelRoles")?.default;
7027
+ await this.sessionManager.flush();
7028
+ const previousSessionState = this.sessionManager.captureState();
7029
+ const previousModel = this.model;
7030
+ const previousThinkingLevel = this.thinkingLevel;
7031
+ const previousActiveRetryFallback = this.#activeRetryFallback ? { ...this.#activeRetryFallback } : undefined;
7032
+ await this.settings.setGlobalModelRoleAndFlush(
7033
+ "default",
7034
+ formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel),
7035
+ );
7036
+ try {
7037
+ await this.setModelTemporary(model, effectiveLevel);
7038
+ if (this.thinkingLevel === previousThinkingLevel) {
7039
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
7040
+ }
7041
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "default");
7042
+ } catch (error) {
7043
+ await this.settings.setGlobalModelRoleAndFlush("default", previousDefaultModelRole).catch(rollbackError => {
7044
+ logger.warn("Failed to restore durable default model selection after live apply failure", {
7045
+ error: String(rollbackError),
7046
+ });
7047
+ });
7048
+ let restoredLiveState = false;
7049
+ try {
7050
+ const failedEditMode = this.#resolveActiveEditMode();
7051
+ const currentModel = this.model;
7052
+ const hasCompatibleModelTransport =
7053
+ currentModel !== undefined &&
7054
+ previousModel !== undefined &&
7055
+ currentModel.provider === previousModel.provider &&
7056
+ currentModel.id === previousModel.id &&
7057
+ currentModel.api === previousModel.api;
7058
+ if (hasCompatibleModelTransport) {
7059
+ this.agent.setModel(previousModel);
7060
+ this.#syncAppendOnlyContext(previousModel);
7061
+ } else {
7062
+ this.#setModelWithProviderSessionReset(previousModel);
7063
+ }
7064
+ this.#thinkingLevel = previousThinkingLevel;
7065
+ this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
7066
+ await this.#syncEditToolModeAfterModelChange(failedEditMode);
7067
+ restoredLiveState = true;
7068
+ } catch (rollbackError) {
7069
+ logger.warn("Failed to restore live default model selection after live apply failure", {
7070
+ error: String(rollbackError),
7071
+ });
7072
+ }
7073
+ if (restoredLiveState) {
7074
+ this.#activeRetryFallback = previousActiveRetryFallback;
7075
+ }
7076
+ try {
7077
+ this.sessionManager.restoreState(previousSessionState);
7078
+ await this.sessionManager.rewriteEntries();
7079
+ } catch (rollbackError) {
7080
+ logger.warn("Failed to restore session state after live default model selection failure", {
7081
+ error: String(rollbackError),
7082
+ });
7083
+ }
7084
+ throw error;
7085
+ }
7086
+ return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
7087
+ } finally {
7088
+ transaction.resolve();
7089
+ }
7090
+ }
7091
+
6989
7092
  /**
6990
7093
  * Cycle to next/previous model.
6991
7094
  * Uses scoped models (from --models flag) if available, otherwise all available models.
@@ -8346,7 +8449,7 @@ export class AgentSession {
8346
8449
  return candidate;
8347
8450
  }
8348
8451
 
8349
- #setModelWithProviderSessionReset(model: Model): void {
8452
+ #setModelWithProviderSessionReset(model: Model | undefined): void {
8350
8453
  const currentModel = this.model;
8351
8454
  if (currentModel) {
8352
8455
  this.#closeProviderSessionsForModelSwitch(currentModel, model);
@@ -8386,15 +8489,15 @@ export class AgentSession {
8386
8489
  }
8387
8490
  }
8388
8491
 
8389
- #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model): void {
8492
+ #closeProviderSessionsForModelSwitch(currentModel: Model, nextModel: Model | undefined): void {
8390
8493
  const providerKeys = new Set<string>();
8391
- if (currentModel.api === "openai-codex-responses" || nextModel.api === "openai-codex-responses") {
8494
+ if (currentModel.api === "openai-codex-responses" || nextModel?.api === "openai-codex-responses") {
8392
8495
  providerKeys.add("openai-codex-responses");
8393
8496
  }
8394
8497
  if (currentModel.api === "openai-responses") {
8395
8498
  providerKeys.add(`openai-responses:${currentModel.provider}`);
8396
8499
  }
8397
- if (nextModel.api === "openai-responses") {
8500
+ if (nextModel?.api === "openai-responses") {
8398
8501
  providerKeys.add(`openai-responses:${nextModel.provider}`);
8399
8502
  }
8400
8503
 
@@ -10369,18 +10472,24 @@ export class AgentSession {
10369
10472
  attribution: "agent",
10370
10473
  timestamp: incomingTimestamp,
10371
10474
  };
10372
- void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
10373
- this.#forwardIrcRelayToMain({
10374
- observationId: incomingObservationId,
10375
- from: args.from,
10376
- to: this.#agentId ?? "?",
10377
- body: args.message,
10378
- kind: "message",
10379
- timestamp: incomingTimestamp,
10380
- });
10475
+ const announceIncoming = () => {
10476
+ this.#emitIrcObservation(incomingRecord);
10477
+ this.#forwardIrcRelayToMain({
10478
+ observationId: incomingObservationId,
10479
+ from: args.from,
10480
+ to: this.#agentId ?? "?",
10481
+ body: args.message,
10482
+ kind: "message",
10483
+ timestamp: incomingTimestamp,
10484
+ });
10485
+ };
10381
10486
 
10382
10487
  if (!awaitReply) {
10488
+ args.signal?.throwIfAborted();
10489
+ // Volatile session acceptance happens before any recipient or main-UI
10490
+ // observation, and before this delivery reports success to its sender.
10383
10491
  this.#queueBackgroundExchangeInjection([incomingRecord]);
10492
+ announceIncoming();
10384
10493
  return { replyText: null };
10385
10494
  }
10386
10495
 
@@ -10388,33 +10497,47 @@ export class AgentSession {
10388
10497
  from: args.from,
10389
10498
  message: args.message,
10390
10499
  });
10391
- const { replyText } = await this.runEphemeralTurn({
10500
+ // Generate the reply before accepting or surfacing the exchange. Provider
10501
+ // failures and sender aborts therefore leave no accepted IRC batch or UI
10502
+ // observation. The deferred roster claim is committed only after the pair
10503
+ // below is accepted.
10504
+ const { replyText, commitRosterClaim, releaseRosterClaim } = await this.runEphemeralTurn({
10392
10505
  promptText: incomingPrompt,
10393
10506
  signal: args.signal,
10507
+ deferRosterCommit: true,
10394
10508
  });
10509
+ try {
10510
+ const replyObservationId = crypto.randomUUID();
10511
+ const replyRecord: CustomMessage = {
10512
+ role: "custom",
10513
+ customType: "irc:autoreply",
10514
+ content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
10515
+ display: true,
10516
+ details: { observationId: replyObservationId, to: args.from, reply: replyText },
10517
+ attribution: "agent",
10518
+ timestamp: Date.now(),
10519
+ };
10520
+ // Accept the ordered pair as one volatile batch before committing its
10521
+ // roster claim, notifying either UI, or resolving the sender delivery.
10522
+ args.signal?.throwIfAborted();
10523
+ this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord], { deferFlush: true });
10524
+ commitRosterClaim?.();
10525
+ this.#flushOrSchedulePendingBackgroundExchanges();
10526
+ announceIncoming();
10527
+ this.#emitIrcObservation(replyRecord);
10528
+ this.#forwardIrcRelayToMain({
10529
+ observationId: replyObservationId,
10530
+ from: this.#agentId ?? "?",
10531
+ to: args.from,
10532
+ body: replyText,
10533
+ kind: "reply",
10534
+ timestamp: replyRecord.timestamp,
10535
+ });
10395
10536
 
10396
- const replyObservationId = crypto.randomUUID();
10397
- const replyRecord: CustomMessage = {
10398
- role: "custom",
10399
- customType: "irc:autoreply",
10400
- content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
10401
- display: true,
10402
- details: { observationId: replyObservationId, to: args.from, reply: replyText },
10403
- attribution: "agent",
10404
- timestamp: Date.now(),
10405
- };
10406
- void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
10407
- this.#forwardIrcRelayToMain({
10408
- observationId: replyObservationId,
10409
- from: this.#agentId ?? "?",
10410
- to: args.from,
10411
- body: replyText,
10412
- kind: "reply",
10413
- timestamp: replyRecord.timestamp,
10414
- });
10415
- this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
10416
-
10417
- return { replyText };
10537
+ return { replyText };
10538
+ } finally {
10539
+ releaseRosterClaim?.();
10540
+ }
10418
10541
  }
10419
10542
 
10420
10543
  /**
@@ -10454,7 +10577,17 @@ export class AgentSession {
10454
10577
  attribution: "agent",
10455
10578
  timestamp: args.timestamp,
10456
10579
  };
10457
- mainSession.emitIrcRelayObservation(relayRecord);
10580
+ try {
10581
+ mainSession.emitIrcRelayObservation(relayRecord);
10582
+ } catch (error) {
10583
+ logger.warn("Failed to forward IRC relay observation", { error: String(error) });
10584
+ }
10585
+ }
10586
+
10587
+ #emitIrcObservation(message: CustomMessage): void {
10588
+ void this.#emitSessionEvent({ type: "irc_message", message }).catch(error => {
10589
+ logger.warn("Failed to emit IRC observation", { error: String(error) });
10590
+ });
10458
10591
  }
10459
10592
 
10460
10593
  /**
@@ -10462,7 +10595,7 @@ export class AgentSession {
10462
10595
  * Does not persist the record to history. Public so other sessions can forward.
10463
10596
  */
10464
10597
  emitIrcRelayObservation(record: CustomMessage): void {
10465
- void this.#emitSessionEvent({ type: "irc_message", message: record });
10598
+ this.#emitIrcObservation(record);
10466
10599
  }
10467
10600
 
10468
10601
  emitSubagentSteerObservation(args: { from: string; to: string; body: string; timestamp?: number }): void {
@@ -10592,8 +10725,16 @@ export class AgentSession {
10592
10725
  promptText: string;
10593
10726
  onTextDelta?: (delta: string) => void;
10594
10727
  signal?: AbortSignal;
10595
- }): Promise<{ replyText: string; assistantMessage: AssistantMessage }> {
10728
+ /** Defer the successful roster-claim commit until the caller accepts its exchange. */
10729
+ deferRosterCommit?: boolean;
10730
+ }): Promise<{
10731
+ replyText: string;
10732
+ assistantMessage: AssistantMessage;
10733
+ commitRosterClaim?: () => void;
10734
+ releaseRosterClaim?: () => void;
10735
+ }> {
10596
10736
  const rosterClaim = this.#claimIrcRosterCandidate();
10737
+ let rosterClaimDeferred = false;
10597
10738
  try {
10598
10739
  const model = this.model;
10599
10740
  if (!model) {
@@ -10662,12 +10803,31 @@ export class AgentSession {
10662
10803
  if (!assistantMessage) {
10663
10804
  throw new Error("Ephemeral turn ended without a final message");
10664
10805
  }
10806
+ if (rosterClaim && args.deferRosterCommit) {
10807
+ rosterClaimDeferred = true;
10808
+ let rosterClaimSettled = false;
10809
+ const settleRosterClaim = (commit: boolean) => {
10810
+ if (rosterClaimSettled) return;
10811
+ rosterClaimSettled = true;
10812
+ if (commit) {
10813
+ this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10814
+ } else {
10815
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10816
+ }
10817
+ };
10818
+ return {
10819
+ replyText: dedupeIrcReply(replyText.trim()),
10820
+ assistantMessage,
10821
+ commitRosterClaim: () => settleRosterClaim(true),
10822
+ releaseRosterClaim: () => settleRosterClaim(false),
10823
+ };
10824
+ }
10665
10825
  if (rosterClaim) {
10666
10826
  this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10667
10827
  }
10668
10828
  return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
10669
10829
  } finally {
10670
- if (rosterClaim) {
10830
+ if (rosterClaim && !rosterClaimDeferred) {
10671
10831
  this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10672
10832
  }
10673
10833
  }
@@ -10720,8 +10880,12 @@ export class AgentSession {
10720
10880
  return messages;
10721
10881
  }
10722
10882
 
10723
- #queueBackgroundExchangeInjection(messages: CustomMessage[]): void {
10883
+ #queueBackgroundExchangeInjection(messages: CustomMessage[], options?: { deferFlush?: boolean }): void {
10724
10884
  this.#pendingBackgroundExchanges.push(messages);
10885
+ if (!options?.deferFlush) this.#flushOrSchedulePendingBackgroundExchanges();
10886
+ }
10887
+
10888
+ #flushOrSchedulePendingBackgroundExchanges(): void {
10725
10889
  if (!this.isStreaming) {
10726
10890
  this.#flushPendingBackgroundExchanges();
10727
10891
  return;
@@ -11408,43 +11572,92 @@ export class AgentSession {
11408
11572
  const contextWindow = model.contextWindow ?? 0;
11409
11573
  if (contextWindow <= 0) return undefined;
11410
11574
 
11575
+ const cacheKey = this.#contextUsageCacheKey(model, contextWindow);
11576
+ if (this.#contextUsageCache?.key === cacheKey) return { ...this.#contextUsageCache.value };
11577
+ this.#contextUsageEstimateCount++;
11578
+
11411
11579
  // After compaction, the last assistant usage reflects pre-compaction context size.
11412
11580
  // We can only trust usage from an assistant that responded after the latest compaction.
11413
11581
  // If no such assistant exists, context token count is unknown until the next LLM response.
11414
11582
  const branchEntries = this.sessionManager.getBranch();
11415
11583
  const latestCompaction = getLatestCompactionEntry(branchEntries);
11584
+ const boundaryTs = latestCompaction ? new Date(latestCompaction.timestamp).getTime() : 0;
11585
+ const anchor = this.#findAnchorableUsageIndex(this.messages, boundaryTs);
11586
+ let value: ContextUsage;
11587
+ if (latestCompaction && !anchor) {
11588
+ value = { tokens: null, contextWindow, percent: null, source: "unknown" };
11589
+ } else {
11590
+ const estimate = this.#estimateContextTokens(boundaryTs, anchor);
11591
+ value = {
11592
+ tokens: estimate.tokens,
11593
+ contextWindow,
11594
+ percent: (estimate.tokens / contextWindow) * 100,
11595
+ source: estimate.anchored ? "provider_anchor" : "heuristic",
11596
+ };
11597
+ }
11416
11598
 
11417
- if (latestCompaction) {
11418
- // Check if there's a valid assistant usage after the compaction boundary
11419
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
11420
- let hasPostCompactionUsage = false;
11421
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
11422
- const entry = branchEntries[i];
11423
- if (entry.type === "message" && entry.message.role === "assistant") {
11424
- const assistant = entry.message;
11425
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
11426
- const contextTokens = calculateContextTokens(assistant.usage);
11427
- if (contextTokens > 0) {
11428
- hasPostCompactionUsage = true;
11429
- }
11430
- break;
11431
- }
11432
- }
11433
- }
11599
+ this.#contextUsageCache = { key: cacheKey, value };
11600
+ return { ...value };
11601
+ }
11602
+
11603
+ getContextUsageObservabilityForTests(): { estimateCount: number } {
11604
+ return { estimateCount: this.#contextUsageEstimateCount };
11605
+ }
11606
+
11607
+ #contextUsageCacheKey(model: Model, contextWindow: number): string {
11608
+ const messages = this.messages;
11609
+ const lastMessage = messages[messages.length - 1];
11610
+ // Entry and leaf revisions change whenever the active branch changes, avoiding getBranch() on warm reads.
11611
+ const revision = this.sessionManager.revisionSnapshot();
11612
+ return `${this.agent.contextRevision}|${model.id}|${contextWindow}|${messages.length}|${this.#contextUsageMessageFingerprint(lastMessage)}|${revision.entry}:${revision.leaf}|${this.#computeContextUsageNonMessageInputsKey()}`;
11613
+ }
11614
+
11615
+ #contextUsageMessageFingerprint(message: AgentMessage | undefined): string {
11616
+ if (!message) return "";
11617
+ let messageId = this.#contextUsageMessageIds.get(message);
11618
+ if (messageId === undefined) {
11619
+ messageId = ++this.#nextContextUsageMessageId;
11620
+ this.#contextUsageMessageIds.set(message, messageId);
11621
+ }
11622
+
11623
+ const role = message.role;
11624
+ const timestamp = typeof message.timestamp === "number" ? message.timestamp : "";
11625
+ let contentLength = 0;
11626
+ let blockCount = 0;
11627
+ const record = message as {
11628
+ content?: unknown;
11629
+ command?: unknown;
11630
+ output?: unknown;
11631
+ summary?: unknown;
11632
+ stopReason?: unknown;
11633
+ usage?: Usage;
11634
+ };
11434
11635
 
11435
- if (!hasPostCompactionUsage) {
11436
- return { tokens: null, contextWindow, percent: null };
11636
+ if (typeof record.command === "string") contentLength += record.command.length;
11637
+ if (typeof record.output === "string") contentLength += record.output.length;
11638
+ if (typeof record.summary === "string") contentLength += record.summary.length;
11639
+ if (typeof record.content === "string") {
11640
+ contentLength += record.content.length;
11641
+ } else if (Array.isArray(record.content)) {
11642
+ blockCount = record.content.length;
11643
+ for (const block of record.content) {
11644
+ if (!block || typeof block !== "object") continue;
11645
+ const content = block as { text?: unknown; thinking?: unknown; name?: unknown };
11646
+ if (typeof content.text === "string") contentLength += content.text.length;
11647
+ if (typeof content.thinking === "string") contentLength += content.thinking.length;
11648
+ if (typeof content.name === "string") contentLength += content.name.length;
11437
11649
  }
11438
11650
  }
11651
+ const stopReason = typeof record.stopReason === "string" ? record.stopReason : "";
11652
+ const usageTokens = record.usage ? calculateContextTokens(record.usage) : 0;
11653
+ return `${messageId}:${role}:${timestamp}:${contentLength}:${blockCount}:${stopReason}:${usageTokens}`;
11654
+ }
11439
11655
 
11440
- const estimate = this.#estimateContextTokens();
11441
- const percent = (estimate.tokens / contextWindow) * 100;
11442
-
11443
- return {
11444
- tokens: estimate.tokens,
11445
- contextWindow,
11446
- percent,
11447
- };
11656
+ #computeContextUsageNonMessageInputsKey(): string {
11657
+ const systemPrompt = this.systemPrompt;
11658
+ let systemPromptLengths = "";
11659
+ for (const part of systemPrompt) systemPromptLengths += `${part.length},`;
11660
+ return `${systemPrompt.length}:${systemPromptLengths}|${this.agent.state.tools.length}|${this.skills.length}`;
11448
11661
  }
11449
11662
 
11450
11663
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
@@ -11459,10 +11672,19 @@ export class AgentSession {
11459
11672
  /**
11460
11673
  * Estimate context tokens from messages, using the last assistant usage when available.
11461
11674
  */
11462
- #estimateContextTokens(): {
11675
+ #estimateContextTokens(
11676
+ boundaryTs: number,
11677
+ anchor: { index: number; usage: Usage } | undefined,
11678
+ ): {
11463
11679
  tokens: number;
11680
+ anchored: boolean;
11464
11681
  } {
11465
- return this.#estimateContextTokensWith(message => this.#estimateMessageDisplayTokens(message));
11682
+ return this.#estimateContextTokensWith(
11683
+ message => this.#estimateMessageDisplayTokens(message),
11684
+ false,
11685
+ boundaryTs,
11686
+ anchor,
11687
+ );
11466
11688
  }
11467
11689
 
11468
11690
  /** Count inline image blocks in a message (for bucketing the fixed image token estimate). */
@@ -11493,6 +11715,24 @@ export class AgentSession {
11493
11715
  return usage;
11494
11716
  }
11495
11717
 
11718
+ /** Find the newest positive successful usage anchor after a compaction boundary. */
11719
+ #findAnchorableUsageIndex(
11720
+ messages: readonly AgentMessage[],
11721
+ boundaryTs: number,
11722
+ ): { index: number; usage: Usage } | undefined {
11723
+ for (let i = messages.length - 1; i >= 0; i--) {
11724
+ const message = messages[i];
11725
+ const usage = this.#anchorableAssistantUsage(message);
11726
+ if (!usage) continue;
11727
+ if (boundaryTs > 0) {
11728
+ const timestamp = message.timestamp;
11729
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= boundaryTs) continue;
11730
+ }
11731
+ return { index: i, usage };
11732
+ }
11733
+ return undefined;
11734
+ }
11735
+
11496
11736
  /**
11497
11737
  * Observed heuristic→actual token correction for the compaction keep window
11498
11738
  * (Finding 7). Compares the provider's real prompt tokens against the chars/4
@@ -11537,39 +11777,45 @@ export class AgentSession {
11537
11777
 
11538
11778
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
11539
11779
  tokens: number;
11780
+ anchored: boolean;
11540
11781
  } {
11541
- const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
11782
+ const estimate = this.#estimateContextTokensWith(
11783
+ message => this.#estimateMessageCompactionDeltaTokens(message),
11784
+ true,
11785
+ );
11542
11786
  return {
11543
11787
  tokens: estimate.tokens + this.#estimateMessagesCompactionDeltaTokens(pendingMessages),
11788
+ anchored: estimate.anchored,
11544
11789
  };
11545
11790
  }
11546
11791
 
11547
- #estimateContextTokensWith(estimateMessage: (message: AgentMessage) => number): {
11792
+ #estimateContextTokensWith(
11793
+ estimateMessage: (message: AgentMessage) => number,
11794
+ inflateFixed = false,
11795
+ boundaryTs?: number,
11796
+ knownAnchor?: { index: number; usage: Usage } | undefined,
11797
+ ): {
11548
11798
  tokens: number;
11799
+ anchored: boolean;
11549
11800
  } {
11550
11801
  const messages = this.messages;
11551
-
11552
- // Find the last successful assistant message with positive usage.
11553
- // Error/aborted turns are estimated as trailing context instead.
11554
- let lastUsageIndex: number | null = null;
11555
- let lastUsage: Usage | undefined;
11556
- for (let i = messages.length - 1; i >= 0; i--) {
11557
- const usage = this.#anchorableAssistantUsage(messages[i]);
11558
- if (usage) {
11559
- lastUsage = usage;
11560
- lastUsageIndex = i;
11561
- break;
11562
- }
11802
+ let anchor = knownAnchor;
11803
+ if (boundaryTs === undefined) {
11804
+ const latestCompaction = getLatestCompactionEntry(this.sessionManager.getBranch());
11805
+ boundaryTs = latestCompaction ? new Date(latestCompaction.timestamp).getTime() : 0;
11806
+ anchor = this.#findAnchorableUsageIndex(messages, boundaryTs);
11563
11807
  }
11564
11808
 
11565
- if (!lastUsage || lastUsageIndex === null) {
11566
- // No usage data - estimate all messages
11567
- let estimated = 0;
11809
+ if (!anchor) {
11810
+ // No usage data - estimate the full provider request.
11811
+ const fixedTokens = computeNonMessageTokens(this);
11812
+ let estimated = inflateFixed ? Math.ceil(fixedTokens * this.#compactionDeltaInflation) : fixedTokens;
11568
11813
  for (const message of messages) {
11569
11814
  estimated += estimateMessage(message);
11570
11815
  }
11571
11816
  return {
11572
11817
  tokens: estimated,
11818
+ anchored: false,
11573
11819
  };
11574
11820
  }
11575
11821
 
@@ -11577,14 +11823,15 @@ export class AgentSession {
11577
11823
  // tokens: the next request replays the anchor assistant's own output
11578
11824
  // (text/reasoning/tool calls), so dropping it undercounts the very tokens
11579
11825
  // a large-reasoning turn just added (Sol xhigh emits tens of thousands).
11580
- const usageTokens = calculateContextTokens(lastUsage);
11826
+ const usageTokens = calculateContextTokens(anchor.usage);
11581
11827
  let trailingTokens = 0;
11582
- for (let i = lastUsageIndex + 1; i < messages.length; i++) {
11828
+ for (let i = anchor.index + 1; i < messages.length; i++) {
11583
11829
  trailingTokens += estimateMessage(messages[i]);
11584
11830
  }
11585
11831
 
11586
11832
  return {
11587
11833
  tokens: usageTokens + trailingTokens,
11834
+ anchored: true,
11588
11835
  };
11589
11836
  }
11590
11837