@sayknow-cli/coding-agent 0.3.12 → 0.3.15

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 (248) hide show
  1. package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
  2. package/dist/types/autoresearch/tools/log-experiment.d.ts +2 -2
  3. package/dist/types/cli/classify-install-target.d.ts +0 -10
  4. package/dist/types/cli/daemon-cli.d.ts +2 -0
  5. package/dist/types/cli/initial-message.d.ts +1 -1
  6. package/dist/types/cli/malloc-env-guard.d.ts +65 -0
  7. package/dist/types/cli/malloc-env-guard.test.d.ts +1 -0
  8. package/dist/types/cli/notify-cli.d.ts +3 -1
  9. package/dist/types/cli/session-picker.d.ts +4 -3
  10. package/dist/types/cli/update-cli.d.ts +22 -3
  11. package/dist/types/cli.d.ts +5 -0
  12. package/dist/types/commands/daemon.d.ts +6 -2
  13. package/dist/types/commands/launch.d.ts +1 -0
  14. package/dist/types/commands/notify.d.ts +6 -0
  15. package/dist/types/commands/web-search.d.ts +1 -1
  16. package/dist/types/commit/agentic/tools/propose-changelog.d.ts +4 -4
  17. package/dist/types/commit/agentic/tools/propose-commit.d.ts +9 -9
  18. package/dist/types/commit/agentic/tools/schemas.d.ts +9 -9
  19. package/dist/types/commit/agentic/tools/split-commit.d.ts +9 -9
  20. package/dist/types/commit/analysis/conventional.d.ts +1 -1
  21. package/dist/types/commit/analysis/summary.d.ts +1 -1
  22. package/dist/types/commit/changelog/generate.d.ts +3 -3
  23. package/dist/types/commit/changelog/index.d.ts +2 -2
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +1 -1
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +1 -1
  26. package/dist/types/commit/shared-llm.d.ts +9 -9
  27. package/dist/types/config/keybindings.d.ts +134 -129
  28. package/dist/types/config/model-profile-activation.d.ts +1 -0
  29. package/dist/types/config/model-registry.d.ts +35 -35
  30. package/dist/types/config/models-config-schema.d.ts +111 -111
  31. package/dist/types/config/settings-schema.d.ts +56 -16
  32. package/dist/types/config/settings.d.ts +2 -0
  33. package/dist/types/coordinator/contract.d.ts +1 -1
  34. package/dist/types/coordinator-mcp/model-preset.d.ts +45 -0
  35. package/dist/types/coordinator-mcp/policy.d.ts +3 -0
  36. package/dist/types/coordinator-mcp/server.d.ts +79 -6
  37. package/dist/types/coordinator-mcp/session-reaper.d.ts +53 -0
  38. package/dist/types/daemon/control-types.d.ts +10 -0
  39. package/dist/types/daemon/operator-contract.d.ts +47 -0
  40. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.d.ts +4 -0
  41. package/dist/types/defaults/skc/extensions/grok-cli-vendor/src/shared/errors.d.ts +12 -12
  42. package/dist/types/discovery/helpers.d.ts +1 -1
  43. package/dist/types/discovery/substitute-plugin-root.d.ts +0 -4
  44. package/dist/types/edit/modes/patch.d.ts +2 -2
  45. package/dist/types/extensibility/extensions/types.d.ts +3 -1
  46. package/dist/types/goals/tools/goal-tool.d.ts +6 -6
  47. package/dist/types/i18n/messages/en.d.ts +8 -8
  48. package/dist/types/internal-urls/local-protocol.d.ts +13 -9
  49. package/dist/types/lsp/index.d.ts +9 -9
  50. package/dist/types/lsp/types.d.ts +9 -9
  51. package/dist/types/main.d.ts +66 -2
  52. package/dist/types/modes/acp/acp-agent.d.ts +19 -2
  53. package/dist/types/modes/acp/acp-mode.d.ts +12 -0
  54. package/dist/types/modes/components/assistant-message.d.ts +1 -1
  55. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  56. package/dist/types/modes/components/hook-selector.d.ts +5 -0
  57. package/dist/types/modes/components/irc-sidebar.d.ts +32 -0
  58. package/dist/types/modes/components/pet-selector.d.ts +11 -0
  59. package/dist/types/modes/components/sayknow-pet-widget.d.ts +59 -0
  60. package/dist/types/modes/components/session-selector.d.ts +22 -9
  61. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  62. package/dist/types/modes/components/status-line/types.d.ts +1 -1
  63. package/dist/types/modes/components/status-line.d.ts +0 -10
  64. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  65. package/dist/types/modes/components/user-message.d.ts +2 -1
  66. package/dist/types/modes/controllers/event-controller.d.ts +4 -0
  67. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  68. package/dist/types/modes/interactive-mode.d.ts +24 -2
  69. package/dist/types/modes/irc-observation-ledger.d.ts +22 -0
  70. package/dist/types/modes/print-mode.d.ts +3 -4
  71. package/dist/types/modes/rpc/rpc-client.d.ts +7 -1
  72. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -1
  73. package/dist/types/modes/rpc/rpc-types.d.ts +17 -1
  74. package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +8 -0
  75. package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +5 -0
  76. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +45 -0
  77. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +42 -2
  78. package/dist/types/modes/theme/defaults/index.d.ts +568 -568
  79. package/dist/types/modes/types.d.ts +28 -2
  80. package/dist/types/modes/utils/abort-message.d.ts +1 -1
  81. package/dist/types/modes/utils/context-usage.d.ts +4 -19
  82. package/dist/types/modes/utils/irc-message.d.ts +38 -0
  83. package/dist/types/modes/utils/ui-helpers.d.ts +13 -4
  84. package/dist/types/notifications/config.d.ts +34 -0
  85. package/dist/types/notifications/daemon-paths.d.ts +7 -0
  86. package/dist/types/notifications/index.d.ts +27 -0
  87. package/dist/types/notifications/lifecycle-control-runtime.d.ts +12 -4
  88. package/dist/types/notifications/notification-service.d.ts +144 -0
  89. package/dist/types/notifications/rate-limit-pool.d.ts +27 -9
  90. package/dist/types/notifications/telegram-daemon-control.d.ts +6 -0
  91. package/dist/types/notifications/telegram-daemon.d.ts +130 -3
  92. package/dist/types/notifications/telegram-reference.d.ts +19 -2
  93. package/dist/types/notifications/topic-registry.d.ts +24 -4
  94. package/dist/types/registry/agent-registry.d.ts +2 -0
  95. package/dist/types/rlm/index.d.ts +1 -1
  96. package/dist/types/sdk.d.ts +2 -0
  97. package/dist/types/secrets/obfuscator.d.ts +1 -1
  98. package/dist/types/session/agent-session.d.ts +40 -3
  99. package/dist/types/session/cache-economics.d.ts +79 -0
  100. package/dist/types/session/context-estimation.d.ts +31 -0
  101. package/dist/types/session/provider-safety-stop.d.ts +5 -0
  102. package/dist/types/session/session-manager.d.ts +124 -4
  103. package/dist/types/session/session-storage.d.ts +155 -13
  104. package/dist/types/setup/credential-auto-import.d.ts +2 -2
  105. package/dist/types/skc-runtime/session-state-sidecar.d.ts +34 -3
  106. package/dist/types/skc-runtime/state-schema.d.ts +19 -19
  107. package/dist/types/skc-runtime/tmux-common.d.ts +6 -0
  108. package/dist/types/skc-runtime/tmux-owner-isolation-cli.d.ts +5 -0
  109. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +309 -0
  110. package/dist/types/skc-runtime/tmux-sessions.d.ts +49 -17
  111. package/dist/types/task/agents.d.ts +2 -2
  112. package/dist/types/task/executor.d.ts +3 -0
  113. package/dist/types/task/receipt.d.ts +3 -1
  114. package/dist/types/task/types.d.ts +35 -27
  115. package/dist/types/tools/ask-answer-registry.d.ts +5 -0
  116. package/dist/types/tools/bash.d.ts +1 -1
  117. package/dist/types/tools/browser/tab-supervisor.d.ts +6 -0
  118. package/dist/types/tools/browser.d.ts +28 -28
  119. package/dist/types/tools/computer.d.ts +20 -20
  120. package/dist/types/tools/cron.d.ts +2 -2
  121. package/dist/types/tools/debug.d.ts +40 -40
  122. package/dist/types/tools/gh.d.ts +10 -10
  123. package/dist/types/tools/image-gen.d.ts +2 -2
  124. package/dist/types/tools/index.d.ts +58 -6
  125. package/dist/types/tools/monitor.d.ts +6 -6
  126. package/dist/types/tools/skill-discovery.d.ts +4 -4
  127. package/dist/types/tools/subagent.d.ts +12 -12
  128. package/dist/types/tools/telegram-send.d.ts +7 -0
  129. package/dist/types/tools/todo-write.d.ts +8 -8
  130. package/dist/types/utils/external-editor.d.ts +2 -0
  131. package/dist/types/web/insane/bridge.d.ts +9 -9
  132. package/dist/types/web/search/index.d.ts +4 -4
  133. package/package.json +8 -8
  134. package/scripts/g004-tmux-smoke.ts +196 -36
  135. package/src/cli/daemon-cli.ts +19 -29
  136. package/src/cli/malloc-env-guard.test.ts +104 -0
  137. package/src/cli/malloc-env-guard.ts +149 -0
  138. package/src/cli/notify-cli.ts +78 -14
  139. package/src/cli/session-picker.ts +23 -40
  140. package/src/cli/update-cli.ts +56 -37
  141. package/src/cli.ts +51 -10
  142. package/src/commands/daemon.ts +19 -5
  143. package/src/commands/harness.ts +515 -47
  144. package/src/commands/launch.ts +11 -3
  145. package/src/commands/notify.ts +5 -1
  146. package/src/commands/session.ts +6 -1
  147. package/src/config/keybindings.ts +5 -0
  148. package/src/config/model-profile-activation.ts +1 -1
  149. package/src/config/model-profiles.ts +14 -14
  150. package/src/config/settings-schema.ts +41 -1
  151. package/src/config/settings.ts +96 -45
  152. package/src/coordinator/contract.ts +1 -0
  153. package/src/coordinator-mcp/model-preset.ts +100 -0
  154. package/src/coordinator-mcp/policy.ts +30 -0
  155. package/src/coordinator-mcp/server.ts +2958 -430
  156. package/src/coordinator-mcp/session-reaper.ts +129 -0
  157. package/src/daemon/control-types.ts +11 -0
  158. package/src/daemon/operator-contract.ts +121 -0
  159. package/src/defaults/skc/extensions/grok-cli-vendor/src/models/catalog.ts +57 -17
  160. package/src/defaults/skc/extensions/grok-cli-vendor/src/payload/sanitize.ts +15 -2
  161. package/src/defaults/skc/extensions/grok-cli-vendor/src/provider/register.ts +5 -1
  162. package/src/extensibility/extensions/runner.ts +1 -1
  163. package/src/extensibility/extensions/types.ts +3 -1
  164. package/src/internal-urls/docs-index.generated.ts +10 -9
  165. package/src/internal-urls/local-protocol.ts +29 -10
  166. package/src/main.ts +301 -80
  167. package/src/modes/acp/acp-agent.ts +730 -100
  168. package/src/modes/acp/acp-mode.ts +29 -2
  169. package/src/modes/components/assistant-message.ts +32 -3
  170. package/src/modes/components/bash-execution.ts +43 -13
  171. package/src/modes/components/custom-editor.ts +5 -0
  172. package/src/modes/components/footer.ts +3 -3
  173. package/src/modes/components/hook-selector.ts +12 -0
  174. package/src/modes/components/irc-sidebar.ts +206 -0
  175. package/src/modes/components/pet-selector.ts +55 -0
  176. package/src/modes/components/sayknow-pet-widget.ts +360 -0
  177. package/src/modes/components/session-selector.ts +222 -221
  178. package/src/modes/components/settings-selector.ts +10 -0
  179. package/src/modes/components/status-line/segments.ts +14 -7
  180. package/src/modes/components/status-line/types.ts +1 -1
  181. package/src/modes/components/status-line.ts +4 -171
  182. package/src/modes/components/tool-execution.ts +47 -6
  183. package/src/modes/components/user-message.ts +26 -9
  184. package/src/modes/controllers/command-controller.ts +28 -2
  185. package/src/modes/controllers/event-controller.ts +159 -36
  186. package/src/modes/controllers/extension-ui-controller.ts +37 -18
  187. package/src/modes/controllers/input-controller.ts +8 -2
  188. package/src/modes/controllers/selector-controller.ts +62 -15
  189. package/src/modes/interactive-mode.ts +199 -7
  190. package/src/modes/irc-observation-ledger.ts +127 -0
  191. package/src/modes/print-mode.ts +228 -92
  192. package/src/modes/rpc/rpc-client.ts +58 -1
  193. package/src/modes/rpc/rpc-mode.ts +286 -85
  194. package/src/modes/rpc/rpc-types.ts +15 -1
  195. package/src/modes/shared/agent-wire/command-dispatch.ts +33 -1
  196. package/src/modes/shared/agent-wire/command-validation.ts +10 -0
  197. package/src/modes/shared/agent-wire/deep-interview-gate.ts +45 -2
  198. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  199. package/src/modes/shared/agent-wire/unattended-session.ts +288 -1
  200. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +130 -20
  201. package/src/modes/types.ts +34 -2
  202. package/src/modes/utils/context-usage.ts +56 -92
  203. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  204. package/src/modes/utils/irc-message.ts +138 -0
  205. package/src/modes/utils/ui-helpers.ts +216 -50
  206. package/src/notifications/config.ts +44 -0
  207. package/src/notifications/daemon-paths.ts +8 -0
  208. package/src/notifications/index.ts +535 -75
  209. package/src/notifications/lifecycle-control-runtime.ts +930 -77
  210. package/src/notifications/notification-service.ts +710 -0
  211. package/src/notifications/rate-limit-pool.ts +46 -12
  212. package/src/notifications/telegram-daemon-control.ts +27 -2
  213. package/src/notifications/telegram-daemon.ts +635 -94
  214. package/src/notifications/telegram-reference.ts +73 -13
  215. package/src/notifications/topic-registry.ts +86 -9
  216. package/src/prompts/system/irc-peer-roster.md +1 -0
  217. package/src/prompts/system/subagent-system-prompt.md +1 -0
  218. package/src/prompts/system/system-prompt.md +1 -0
  219. package/src/prompts/tools/irc.md +1 -1
  220. package/src/registry/agent-registry.ts +3 -0
  221. package/src/sdk.ts +52 -1
  222. package/src/secrets/obfuscator.ts +4 -2
  223. package/src/session/agent-session.ts +670 -217
  224. package/src/session/cache-economics.ts +275 -0
  225. package/src/session/context-estimation.ts +91 -0
  226. package/src/session/provider-safety-stop.ts +9 -0
  227. package/src/session/session-dump-format.ts +6 -0
  228. package/src/session/session-manager.ts +809 -39
  229. package/src/session/session-storage.ts +582 -38
  230. package/src/skc-runtime/session-state-sidecar.ts +756 -135
  231. package/src/skc-runtime/tmux-common.ts +26 -7
  232. package/src/skc-runtime/tmux-gc.ts +52 -9
  233. package/src/skc-runtime/tmux-owner-isolation-cli.ts +185 -0
  234. package/src/skc-runtime/tmux-owner-isolation.ts +2060 -0
  235. package/src/skc-runtime/tmux-sessions.ts +871 -68
  236. package/src/slash-commands/builtin-registry.ts +153 -9
  237. package/src/slash-commands/helpers/context-report.ts +26 -4
  238. package/src/task/executor.ts +12 -3
  239. package/src/task/index.ts +8 -0
  240. package/src/task/receipt.ts +7 -2
  241. package/src/task/types.ts +26 -0
  242. package/src/tools/ask-answer-registry.ts +25 -0
  243. package/src/tools/browser/tab-supervisor.ts +63 -6
  244. package/src/tools/browser.ts +8 -3
  245. package/src/tools/index.ts +81 -6
  246. package/src/tools/sqlite-reader.ts +8 -4
  247. package/src/tui/output-block.ts +18 -6
  248. package/src/utils/external-editor.ts +6 -1
@@ -27,6 +27,8 @@ import {
27
27
  type AgentState,
28
28
  type AgentTool,
29
29
  assertImagePlaceholdersHavePayload,
30
+ canContinuePersistedHistory,
31
+ type ResolvedThinkingLevel,
30
32
  resolveTelemetry,
31
33
  type StablePrefixSnapshot,
32
34
  ThinkingLevel,
@@ -205,6 +207,7 @@ import type { PlanModeState } from "../plan-mode/state";
205
207
  import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
206
208
  import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
207
209
  import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
210
+ import ircPeerRosterTemplate from "../prompts/system/irc-peer-roster.md" with { type: "text" };
208
211
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
209
212
  import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
210
213
  import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
@@ -254,7 +257,10 @@ import {
254
257
  } from "../tool-discovery/tool-index";
255
258
  import type { AskAnswerSource, ToolSession } from "../tools";
256
259
  import { AskTool } from "../tools/ask";
257
- import { getAskAnswerSource as getAskAnswerSourceFromRegistry } from "../tools/ask-answer-registry";
260
+ import {
261
+ getAskAnswerSource as getAskAnswerSourceFromRegistry,
262
+ notifyWorkflowGateEmitterChanged,
263
+ } from "../tools/ask-answer-registry";
258
264
  import { assertEditableFile } from "../tools/auto-generated-guard";
259
265
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
260
266
  import type { CheckpointState } from "../tools/checkpoint";
@@ -274,6 +280,7 @@ import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../wo
274
280
  import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
275
281
  import type { AuthStorage } from "./auth-storage";
276
282
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
283
+ import { computeNonMessageTokens } from "./context-estimation";
277
284
  import {
278
285
  type ContributionPrepOptions,
279
286
  type ContributionPrepResult,
@@ -291,6 +298,7 @@ import {
291
298
  SILENT_ABORT_MARKER,
292
299
  SKILL_PROMPT_MESSAGE_TYPE,
293
300
  } from "./messages";
301
+ import { isLegacyProviderSafetyStopMessage } from "./provider-safety-stop";
294
302
  import { formatSessionDumpText } from "./session-dump-format";
295
303
  import type {
296
304
  BranchSummaryEntry,
@@ -298,8 +306,13 @@ import type {
298
306
  NewSessionOptions,
299
307
  SessionContext,
300
308
  SessionManager,
309
+ SessionManagerCloseOutcome,
310
+ } from "./session-manager";
311
+ import {
312
+ getLatestCompactionEntry,
313
+ getSessionMessageObservationId,
314
+ transferSessionMessageIdentity,
301
315
  } from "./session-manager";
302
- import { getLatestCompactionEntry } from "./session-manager";
303
316
  import { ToolChoiceQueue } from "./tool-choice-queue";
304
317
  import { YieldQueue } from "./yield-queue";
305
318
 
@@ -520,6 +533,12 @@ export interface RoleModelCycleResult {
520
533
  role: string;
521
534
  }
522
535
 
536
+ export interface DefaultModelSelectionResult {
537
+ readonly provider: string;
538
+ readonly modelId: string;
539
+ readonly thinkingLevel: ResolvedThinkingLevel;
540
+ }
541
+
523
542
  /** Session statistics for /session command */
524
543
  export interface SessionStats {
525
544
  sessionFile: string | undefined;
@@ -538,6 +557,7 @@ export interface SessionStats {
538
557
  };
539
558
  premiumRequests: number;
540
559
  cost: number;
560
+ costBreakdown?: Usage["cost"];
541
561
  }
542
562
 
543
563
  /** Internal marker for hook messages queued through the agent loop */
@@ -1169,6 +1189,7 @@ export class AgentSession {
1169
1189
  #scopedModels: ScopedModelSelection[];
1170
1190
  #thinkingLevel: ThinkingLevel | undefined;
1171
1191
  #activeModelProfile: string | undefined;
1192
+ #defaultModelSelectionTail: Promise<void> = Promise.resolve();
1172
1193
  #promptTemplates: PromptTemplate[];
1173
1194
  #slashCommands: FileSlashCommand[];
1174
1195
 
@@ -1221,6 +1242,11 @@ export class AgentSession {
1221
1242
  #resourceSampler: () => EmergencyCompactionSample = () => this.#defaultResourceSample();
1222
1243
  #retainedMemorySampler: (() => RetainedMemorySample) | undefined;
1223
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;
1224
1250
 
1225
1251
  // Branch summarization state
1226
1252
  #branchSummaryAbortController: AbortController | undefined = undefined;
@@ -1270,6 +1296,9 @@ export class AgentSession {
1270
1296
  // Agent identity + registry for IRC relay forwarding to the main session UI.
1271
1297
  #agentId: string | undefined;
1272
1298
  #agentRegistry: AgentRegistry | undefined;
1299
+ #lastDeliveredIrcRosterSignature: string | null = null;
1300
+ #ircRosterEpoch = 0;
1301
+ #ircRosterClaim: { token: symbol; signature: string; epoch: number } | null = null;
1273
1302
  #providerSessionId: string | undefined;
1274
1303
  #providerCacheSessionId: string | undefined;
1275
1304
  #isDisposed = false;
@@ -2086,6 +2115,16 @@ export class AgentSession {
2086
2115
  return queued;
2087
2116
  }
2088
2117
 
2118
+ #persistRuntimeStateInBackground(event: AgentSessionEvent): void {
2119
+ void persistCoordinatorRuntimeStateFromEvent(event, {
2120
+ sessionId: this.sessionId,
2121
+ cwd: this.sessionManager.getCwd(),
2122
+ sessionFile: this.sessionManager.getSessionFile(),
2123
+ }).catch(() => {
2124
+ logger.warn("Failed to persist coordinator runtime state", { event: event.type });
2125
+ });
2126
+ }
2127
+
2089
2128
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
2090
2129
  if (event.type === "message_update") {
2091
2130
  // Fast path: message_update maps to no sidecar state, so we must not
@@ -2097,12 +2136,7 @@ export class AgentSession {
2097
2136
  }
2098
2137
  return;
2099
2138
  }
2100
- const persistRuntimeState = () =>
2101
- persistCoordinatorRuntimeStateFromEvent(event, {
2102
- sessionId: this.sessionId,
2103
- cwd: this.sessionManager.getCwd(),
2104
- sessionFile: this.sessionManager.getSessionFile(),
2105
- });
2139
+ const persistRuntimeState = () => this.#persistRuntimeStateInBackground(event);
2106
2140
  // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
2107
2141
  // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
2108
2142
  // emitting while #promptInFlightCount > 0 lets a client fire its next
@@ -2190,6 +2224,7 @@ export class AgentSession {
2190
2224
  (this.#planCompactAbortPending || this.#silentAbortPending)
2191
2225
  ) {
2192
2226
  (event.message as AssistantMessage).errorMessage = SILENT_ABORT_MARKER;
2227
+ this.agent.touchContext();
2193
2228
  this.#planCompactAbortPending = false;
2194
2229
  this.#silentAbortPending = false;
2195
2230
  }
@@ -2205,7 +2240,9 @@ export class AgentSession {
2205
2240
  const message = event.message;
2206
2241
  const deobfuscatedContent = obfuscator.deobfuscateObject(message.content);
2207
2242
  if (deobfuscatedContent !== message.content) {
2208
- displayEvent = { ...event, message: { ...message, content: deobfuscatedContent } };
2243
+ const displayMessage = { ...message, content: deobfuscatedContent };
2244
+ transferSessionMessageIdentity([message], [displayMessage]);
2245
+ displayEvent = { ...event, message: displayMessage };
2209
2246
  }
2210
2247
  }
2211
2248
 
@@ -2220,6 +2257,15 @@ export class AgentSession {
2220
2257
  }
2221
2258
 
2222
2259
  await this.#emitSessionEvent(displayEvent);
2260
+ if (
2261
+ displayEvent !== event &&
2262
+ displayEvent.type === "message_end" &&
2263
+ displayEvent.message.role === "assistant" &&
2264
+ event.type === "message_end" &&
2265
+ event.message.role === "assistant"
2266
+ ) {
2267
+ transferSessionMessageIdentity([displayEvent.message], [event.message]);
2268
+ }
2223
2269
 
2224
2270
  if (event.type === "turn_start") {
2225
2271
  this.#resetStreamingEditState();
@@ -2389,14 +2435,20 @@ export class AgentSession {
2389
2435
  if (event.type === "message_end") {
2390
2436
  // Check if this is a hook/custom message
2391
2437
  if (event.message.role === "hookMessage" || event.message.role === "custom") {
2392
- // Persist as CustomMessageEntry
2393
- this.sessionManager.appendCustomMessageEntry(
2394
- event.message.customType,
2395
- event.message.content,
2396
- event.message.display,
2397
- event.message.details,
2398
- event.message.attribution ?? "agent",
2399
- );
2438
+ // Roster reminders are request-context-only: never persist them, or a
2439
+ // reload would resurrect stale rosters into agent history.
2440
+ const isRosterReminder = event.message.role === "custom" && event.message.customType === "irc-peer-roster";
2441
+ if (!isRosterReminder) {
2442
+ // Persist as CustomMessageEntry
2443
+ this.sessionManager.appendCustomMessageEntry(
2444
+ event.message.customType,
2445
+ event.message.content,
2446
+ event.message.display,
2447
+ event.message.details,
2448
+ event.message.attribution ?? "agent",
2449
+ getSessionMessageObservationId(event.message),
2450
+ );
2451
+ }
2400
2452
  if (event.message.role === "custom" && event.message.customType === "ttsr-injection") {
2401
2453
  this.#markTtsrInjected(this.#extractTtsrRuleNames(event.message.details));
2402
2454
  }
@@ -2761,8 +2813,7 @@ export class AgentSession {
2761
2813
  }
2762
2814
 
2763
2815
  #isResumableAgentTail(): boolean {
2764
- const lastMsg = this.agent.state.messages.at(-1);
2765
- return lastMsg !== undefined && lastMsg.role !== "assistant";
2816
+ return canContinuePersistedHistory(this.agent.state.messages);
2766
2817
  }
2767
2818
 
2768
2819
  #stripOverflowFailedTurnForRetry(): void {
@@ -3765,6 +3816,8 @@ export class AgentSession {
3765
3816
  } catch (error) {
3766
3817
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
3767
3818
  }
3819
+ this.#workflowGateEmitter = undefined;
3820
+ notifyWorkflowGateEmitterChanged(this.sessionId, undefined);
3768
3821
  await this.#flushWorkerIntegrationAttempt();
3769
3822
  await this.#cancelPostPromptTasks();
3770
3823
  // Cancel jobs this agent registered so a subagent's teardown doesn't
@@ -3828,6 +3881,18 @@ export class AgentSession {
3828
3881
  this.#eventListeners = [];
3829
3882
  this.#rebuildEventListenerSnapshot();
3830
3883
  }
3884
+ /**
3885
+ * Strict writer close for ACP session delete. On the first attempt it flushes
3886
+ * pending writes, then returns the certainty-aware close outcome so the caller
3887
+ * can block destructive mutation on a non-`closed` result. When the manager
3888
+ * retains a retryable writer (a prior `close_failed_retryable`), the flush is
3889
+ * NOT repeated: the underlying writer rejects flushes while in the retryable
3890
+ * state, and `SessionManager.closeStrict()` owns the flush/close sequencing so
3891
+ * a second call can return `closed` once the OS close lands.
3892
+ */
3893
+ async closeWriterStrict(): Promise<SessionManagerCloseOutcome> {
3894
+ return this.sessionManager.flushAndCloseStrict();
3895
+ }
3831
3896
 
3832
3897
  /**
3833
3898
  * Bounded, best-effort teardown of the subprocess-spawning resources this session
@@ -3928,6 +3993,20 @@ export class AgentSession {
3928
3993
  }
3929
3994
  }
3930
3995
 
3996
+ /**
3997
+ * Owner-scoped async-delivery snapshot used by the strict ACP delete
3998
+ * quiescence barrier to PROVE quiescence after a best-effort drain. Unlike
3999
+ * {@link drainAsyncJobDeliveriesForAcp}'s boolean return (which conflates
4000
+ * "nothing to drain" with "timed out"), this reads the live state directly so
4001
+ * any remaining queued/delivering work is observable and can block mutation.
4002
+ */
4003
+ getAsyncDeliveryStateForAcp(): { queued: number; delivering: boolean } {
4004
+ const manager = AsyncJobManager.instance();
4005
+ if (!manager) return { queued: 0, delivering: false };
4006
+ const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
4007
+ return manager.getDeliveryState(ownerFilter);
4008
+ }
4009
+
3931
4010
  /** Most recent assistant message in agent state. */
3932
4011
  getLastAssistantMessage(): AssistantMessage | undefined {
3933
4012
  return this.#findLastAssistantMessage();
@@ -4895,6 +4974,14 @@ export class AgentSession {
4895
4974
  return this.agent.state.messages;
4896
4975
  }
4897
4976
 
4977
+ /** Main startup calls this exactly once, after a strict open returned `kind: "opened"`. */
4978
+ async continuePersistedHistory(): Promise<void> {
4979
+ if (!canContinuePersistedHistory(this.agent.state.messages)) {
4980
+ throw new Error("Cannot continue from persisted message history");
4981
+ }
4982
+ await this.agent.continue();
4983
+ }
4984
+
4898
4985
  buildDisplaySessionContext(): SessionContext {
4899
4986
  return deobfuscateSessionContext(this.sessionManager.buildSessionContext(), this.#obfuscator);
4900
4987
  }
@@ -5029,6 +5116,7 @@ export class AgentSession {
5029
5116
 
5030
5117
  setWorkflowGateEmitter(emitter: WorkflowGateEmitter | undefined): void {
5031
5118
  this.#workflowGateEmitter = emitter;
5119
+ notifyWorkflowGateEmitterChanged(this.sessionId, emitter);
5032
5120
  if (emitter) {
5033
5121
  this.#ensureWorkflowGateAskTool();
5034
5122
  }
@@ -5576,6 +5664,7 @@ export class AgentSession {
5576
5664
  ): Promise<void> {
5577
5665
  this.#beginInFlight();
5578
5666
  const generation = this.#promptGeneration;
5667
+ const rosterClaim = this.#claimIrcRosterCandidate();
5579
5668
  try {
5580
5669
  // Flush any pending bash messages before the new prompt
5581
5670
  this.#flushPendingBashMessages();
@@ -5627,6 +5716,11 @@ export class AgentSession {
5627
5716
  }
5628
5717
  const volatileProjectContextMessage = await this.#buildVolatileProjectContextMessage();
5629
5718
  messages.push(volatileProjectContextMessage);
5719
+ if (rosterClaim && this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch)) {
5720
+ messages.push(rosterClaim.message);
5721
+ } else if (rosterClaim) {
5722
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
5723
+ }
5630
5724
  if (options?.prependMessages) {
5631
5725
  messages.push(...options.prependMessages);
5632
5726
  }
@@ -5731,10 +5825,27 @@ export class AgentSession {
5731
5825
 
5732
5826
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
5733
5827
  await this.#promptAgentWithIdleRetry(messages, agentPromptOptions);
5828
+ const terminalAssistant = this.#findLastAssistantMessage();
5829
+ if (
5830
+ rosterClaim &&
5831
+ terminalAssistant &&
5832
+ terminalAssistant.stopReason !== "error" &&
5833
+ terminalAssistant.stopReason !== "aborted"
5834
+ ) {
5835
+ this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
5836
+ }
5734
5837
  if (!options?.skipPostPromptRecoveryWait) {
5735
5838
  await this.#waitForPostPromptRecovery();
5736
5839
  }
5737
5840
  } finally {
5841
+ if (rosterClaim) {
5842
+ this.agent.replaceMessages(
5843
+ this.agent.state.messages.filter(
5844
+ candidate => !(candidate.role === "custom" && candidate.customType === "irc-peer-roster"),
5845
+ ),
5846
+ );
5847
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
5848
+ }
5738
5849
  this.#endInFlight();
5739
5850
  }
5740
5851
  }
@@ -5825,7 +5936,7 @@ export class AgentSession {
5825
5936
  reload: async () => {
5826
5937
  await this.reload();
5827
5938
  },
5828
- getSystemPrompt: () => this.systemPrompt,
5939
+ getSystemPrompt: () => [...this.systemPrompt],
5829
5940
  };
5830
5941
  }
5831
5942
 
@@ -6162,6 +6273,7 @@ export class AgentSession {
6162
6273
  message.display,
6163
6274
  message.details,
6164
6275
  message.attribution ?? "agent",
6276
+ getSessionMessageObservationId(appMessage),
6165
6277
  );
6166
6278
  return;
6167
6279
  }
@@ -6189,6 +6301,7 @@ export class AgentSession {
6189
6301
  message.display,
6190
6302
  message.details,
6191
6303
  message.attribution ?? "agent",
6304
+ getSessionMessageObservationId(appMessage),
6192
6305
  );
6193
6306
  }
6194
6307
 
@@ -6662,6 +6775,8 @@ export class AgentSession {
6662
6775
  this.#planReferencePath = "local://PLAN.md";
6663
6776
  this.#reconnectToAgent();
6664
6777
 
6778
+ this.#resetIrcRosterDeliveryState();
6779
+
6665
6780
  // Emit session_switch event with reason "new" to hooks
6666
6781
  if (this.#extensionRunner) {
6667
6782
  await this.#extensionRunner.emit({
@@ -6769,6 +6884,8 @@ export class AgentSession {
6769
6884
  this.#syncAgentSessionId();
6770
6885
  this.#rekeyHindsightMemoryForCurrentSessionId();
6771
6886
 
6887
+ this.#resetIrcRosterDeliveryState();
6888
+
6772
6889
  // Emit session_switch event with reason "fork" to hooks
6773
6890
  if (this.#extensionRunner) {
6774
6891
  await this.#extensionRunner.emit({
@@ -6884,6 +7001,54 @@ export class AgentSession {
6884
7001
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
6885
7002
  }
6886
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.settings.setGlobalModelRoleAndFlush(
7028
+ "default",
7029
+ formatModelSelectorValue(`${model.provider}/${model.id}`, effectiveLevel),
7030
+ );
7031
+ try {
7032
+ const previousThinkingLevel = this.thinkingLevel;
7033
+ await this.setModelTemporary(model, effectiveLevel);
7034
+ if (this.thinkingLevel === previousThinkingLevel) {
7035
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
7036
+ }
7037
+ this.sessionManager.appendModelChange(`${model.provider}/${model.id}`, "default");
7038
+ } catch (error) {
7039
+ await this.settings.setGlobalModelRoleAndFlush("default", previousDefaultModelRole).catch(rollbackError => {
7040
+ logger.warn("Failed to restore durable default model selection after live apply failure", {
7041
+ error: String(rollbackError),
7042
+ });
7043
+ });
7044
+ throw error;
7045
+ }
7046
+ return { provider: model.provider, modelId: model.id, thinkingLevel: effectiveLevel };
7047
+ } finally {
7048
+ transaction.resolve();
7049
+ }
7050
+ }
7051
+
6887
7052
  /**
6888
7053
  * Cycle to next/previous model.
6889
7054
  * Uses scoped models (from --models flag) if available, otherwise all available models.
@@ -7708,6 +7873,8 @@ export class AgentSession {
7708
7873
  this.#resetInjectedContextSignatures();
7709
7874
  this.#syncTodoPhasesFromBranch();
7710
7875
 
7876
+ this.#resetIrcRosterDeliveryState();
7877
+
7711
7878
  return { document: handoffText, savedPath };
7712
7879
  } catch (error) {
7713
7880
  if (handoffSignal.aborted || (error instanceof Error && error.name === "AbortError")) {
@@ -7746,6 +7913,14 @@ export class AgentSession {
7746
7913
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
7747
7914
  */
7748
7915
  async #checkCompaction(assistantMessage: AssistantMessage, skipAbortedCheck = true): Promise<void> {
7916
+ // Safety stops are terminal and must not trigger context maintenance.
7917
+ if (
7918
+ assistantMessage.errorKind === "provider_safety_stop" ||
7919
+ (assistantMessage.errorMessage !== undefined &&
7920
+ isLegacyProviderSafetyStopMessage(assistantMessage.errorMessage))
7921
+ ) {
7922
+ return;
7923
+ }
7749
7924
  // Skip if message was aborted (user cancelled) - unless skipAbortedCheck is false
7750
7925
  if (skipAbortedCheck && assistantMessage.stopReason === "aborted") return;
7751
7926
  const contextWindow = this.model?.contextWindow ?? 0;
@@ -9266,16 +9441,6 @@ export class AgentSession {
9266
9441
  );
9267
9442
  }
9268
9443
 
9269
- #isRefusalErrorMessage(errorMessage: string): boolean {
9270
- // Provider safety-refusal stops. Matches the engine-generated Anthropic
9271
- // labels from packages/ai ("Refusal (<category>): …", "Refusal (no
9272
- // details provided)", "Content flagged by safety filters") plus generic
9273
- // usage-policy refusal copy.
9274
- return /^refusal(\s*\(|:|$)|content flagged by safety filters|blocked under .{0,40}usage policy/i.test(
9275
- errorMessage,
9276
- );
9277
- }
9278
-
9279
9444
  #extractExplicitHttpStatusFromErrorMessage(errorMessage: string): number | undefined {
9280
9445
  // Parse only explicit HTTP/status wording. Do not treat generic
9281
9446
  // `error: 400` as an HTTP status because rate-limit copy can say
@@ -9287,14 +9452,15 @@ export class AgentSession {
9287
9452
  }
9288
9453
 
9289
9454
  /**
9290
- * Ordered retry classification: overflow (compaction) -> terminal (surface)
9291
- * -> usage_limit (rotation) -> first_event_timeout (bounded retry) ->
9292
- * transient (retry) -> unknown (retry).
9455
+ * Ordered retry classification: typed safety stop (surface) -> legacy safety stop
9456
+ * (surface) -> overflow (compaction) -> terminal (surface) -> usage_limit
9457
+ * (rotation) -> first_event_timeout (bounded retry) -> transient (retry) ->
9458
+ * unknown (retry).
9293
9459
  */
9294
9460
  #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
9295
- if (message.stopReason !== "error" || !message.errorMessage) return "none";
9296
- const contextWindow = this.model?.contextWindow ?? 0;
9297
- if (isContextOverflow(message, contextWindow)) return "overflow";
9461
+ if (message.stopReason !== "error") return "none";
9462
+ if (message.errorKind === "provider_safety_stop") return "terminal";
9463
+ if (!message.errorMessage) return "none";
9298
9464
  const err = message.errorMessage;
9299
9465
  // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
9300
9466
  // "sensitive") are deterministic for the submitted context: replaying
@@ -9302,7 +9468,9 @@ export class AgentSession {
9302
9468
  // auto-retry loop can never succeed and only re-bills the full context
9303
9469
  // on every attempt (#1655). Surface immediately instead of joining the
9304
9470
  // unbounded "unknown" retry class.
9305
- if (this.#isRefusalErrorMessage(err)) return "terminal";
9471
+ if (isLegacyProviderSafetyStopMessage(err)) return "terminal";
9472
+ const contextWindow = this.model?.contextWindow ?? 0;
9473
+ if (isContextOverflow(message, contextWindow)) return "overflow";
9306
9474
  if (isLocalModelEndpoint(this.model) && this.#isLocalProviderAvailabilityErrorMessage(err)) {
9307
9475
  return "local_unavailable";
9308
9476
  }
@@ -10254,26 +10422,34 @@ export class AgentSession {
10254
10422
  }): Promise<{ replyText: string | null }> {
10255
10423
  const awaitReply = args.awaitReply !== false;
10256
10424
  const incomingTimestamp = Date.now();
10425
+ const incomingObservationId = crypto.randomUUID();
10257
10426
  const incomingRecord: CustomMessage = {
10258
10427
  role: "custom",
10259
10428
  customType: "irc:incoming",
10260
10429
  content: `[IRC \`${args.from}\` → you]\n\n${args.message}`,
10261
10430
  display: true,
10262
- details: { from: args.from, message: args.message },
10431
+ details: { observationId: incomingObservationId, from: args.from, message: args.message },
10263
10432
  attribution: "agent",
10264
10433
  timestamp: incomingTimestamp,
10265
10434
  };
10266
- void this.#emitSessionEvent({ type: "irc_message", message: incomingRecord });
10267
- this.#forwardIrcRelayToMain({
10268
- from: args.from,
10269
- to: this.#agentId ?? "?",
10270
- body: args.message,
10271
- kind: "message",
10272
- timestamp: incomingTimestamp,
10273
- });
10435
+ const announceIncoming = () => {
10436
+ this.#emitIrcObservation(incomingRecord);
10437
+ this.#forwardIrcRelayToMain({
10438
+ observationId: incomingObservationId,
10439
+ from: args.from,
10440
+ to: this.#agentId ?? "?",
10441
+ body: args.message,
10442
+ kind: "message",
10443
+ timestamp: incomingTimestamp,
10444
+ });
10445
+ };
10274
10446
 
10275
10447
  if (!awaitReply) {
10448
+ args.signal?.throwIfAborted();
10449
+ // Volatile session acceptance happens before any recipient or main-UI
10450
+ // observation, and before this delivery reports success to its sender.
10276
10451
  this.#queueBackgroundExchangeInjection([incomingRecord]);
10452
+ announceIncoming();
10277
10453
  return { replyText: null };
10278
10454
  }
10279
10455
 
@@ -10281,31 +10457,46 @@ export class AgentSession {
10281
10457
  from: args.from,
10282
10458
  message: args.message,
10283
10459
  });
10284
- const { replyText } = await this.runEphemeralTurn({
10460
+ // Generate the reply before accepting or surfacing the exchange. Provider
10461
+ // failures and sender aborts therefore leave no accepted IRC batch or UI
10462
+ // observation. The deferred roster claim is committed only after the pair
10463
+ // below is accepted.
10464
+ const { replyText, commitRosterClaim, releaseRosterClaim } = await this.runEphemeralTurn({
10285
10465
  promptText: incomingPrompt,
10286
10466
  signal: args.signal,
10467
+ deferRosterCommit: true,
10287
10468
  });
10469
+ try {
10470
+ const replyObservationId = crypto.randomUUID();
10471
+ const replyRecord: CustomMessage = {
10472
+ role: "custom",
10473
+ customType: "irc:autoreply",
10474
+ content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
10475
+ display: true,
10476
+ details: { observationId: replyObservationId, to: args.from, reply: replyText },
10477
+ attribution: "agent",
10478
+ timestamp: Date.now(),
10479
+ };
10480
+ // Accept the ordered pair as one volatile batch before committing its
10481
+ // roster claim, notifying either UI, or resolving the sender delivery.
10482
+ args.signal?.throwIfAborted();
10483
+ this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
10484
+ commitRosterClaim?.();
10485
+ announceIncoming();
10486
+ this.#emitIrcObservation(replyRecord);
10487
+ this.#forwardIrcRelayToMain({
10488
+ observationId: replyObservationId,
10489
+ from: this.#agentId ?? "?",
10490
+ to: args.from,
10491
+ body: replyText,
10492
+ kind: "reply",
10493
+ timestamp: replyRecord.timestamp,
10494
+ });
10288
10495
 
10289
- const replyRecord: CustomMessage = {
10290
- role: "custom",
10291
- customType: "irc:autoreply",
10292
- content: `[IRC you → \`${args.from}\` (auto)]\n\n${replyText}`,
10293
- display: true,
10294
- details: { to: args.from, reply: replyText },
10295
- attribution: "agent",
10296
- timestamp: Date.now(),
10297
- };
10298
- void this.#emitSessionEvent({ type: "irc_message", message: replyRecord });
10299
- this.#forwardIrcRelayToMain({
10300
- from: this.#agentId ?? "?",
10301
- to: args.from,
10302
- body: replyText,
10303
- kind: "reply",
10304
- timestamp: replyRecord.timestamp,
10305
- });
10306
- this.#queueBackgroundExchangeInjection([incomingRecord, replyRecord]);
10307
-
10308
- return { replyText };
10496
+ return { replyText };
10497
+ } finally {
10498
+ releaseRosterClaim?.();
10499
+ }
10309
10500
  }
10310
10501
 
10311
10502
  /**
@@ -10315,6 +10506,7 @@ export class AgentSession {
10315
10506
  * it is NOT injected into the main agent's persisted history.
10316
10507
  */
10317
10508
  #forwardIrcRelayToMain(args: {
10509
+ observationId: string;
10318
10510
  from: string;
10319
10511
  to: string;
10320
10512
  body: string;
@@ -10334,11 +10526,27 @@ export class AgentSession {
10334
10526
  customType: "irc:relay",
10335
10527
  content: `[IRC \`${args.from}\` ${arrow} \`${args.to}\`]\n\n${args.body}`,
10336
10528
  display: true,
10337
- details: { from: args.from, to: args.to, body: args.body, kind: args.kind },
10529
+ details: {
10530
+ observationId: args.observationId,
10531
+ from: args.from,
10532
+ to: args.to,
10533
+ body: args.body,
10534
+ kind: args.kind,
10535
+ },
10338
10536
  attribution: "agent",
10339
10537
  timestamp: args.timestamp,
10340
10538
  };
10341
- mainSession.emitIrcRelayObservation(relayRecord);
10539
+ try {
10540
+ mainSession.emitIrcRelayObservation(relayRecord);
10541
+ } catch (error) {
10542
+ logger.warn("Failed to forward IRC relay observation", { error: String(error) });
10543
+ }
10544
+ }
10545
+
10546
+ #emitIrcObservation(message: CustomMessage): void {
10547
+ void this.#emitSessionEvent({ type: "irc_message", message }).catch(error => {
10548
+ logger.warn("Failed to emit IRC observation", { error: String(error) });
10549
+ });
10342
10550
  }
10343
10551
 
10344
10552
  /**
@@ -10346,7 +10554,7 @@ export class AgentSession {
10346
10554
  * Does not persist the record to history. Public so other sessions can forward.
10347
10555
  */
10348
10556
  emitIrcRelayObservation(record: CustomMessage): void {
10349
- void this.#emitSessionEvent({ type: "irc_message", message: record });
10557
+ this.#emitIrcObservation(record);
10350
10558
  }
10351
10559
 
10352
10560
  emitSubagentSteerObservation(args: { from: string; to: string; body: string; timestamp?: number }): void {
@@ -10406,6 +10614,61 @@ export class AgentSession {
10406
10614
  void this.#emitSessionEvent({ type: "subagent_steer_message", message: record });
10407
10615
  }
10408
10616
 
10617
+ #buildIrcRosterCandidate(): { signature: string; message: CustomMessage } | null {
10618
+ const peers = (this.#agentRegistry?.listVisibleTo(this.#agentId ?? "") ?? [])
10619
+ .map(peer => ({ id: peer.id, label: peer.rosterLabel || peer.displayName }))
10620
+ .sort((left, right) => left.id.localeCompare(right.id));
10621
+ const signature = JSON.stringify(peers);
10622
+ if (peers.length === 0 && this.#lastDeliveredIrcRosterSignature === null) return null;
10623
+ return {
10624
+ signature,
10625
+ message: {
10626
+ role: "custom",
10627
+ customType: "irc-peer-roster",
10628
+ content: prompt.render(ircPeerRosterTemplate, {
10629
+ roster: peers.map(peer => `${peer.id} (${peer.label})`).join(", "),
10630
+ }),
10631
+ display: false,
10632
+ attribution: "agent",
10633
+ timestamp: Date.now(),
10634
+ },
10635
+ };
10636
+ }
10637
+
10638
+ #claimIrcRosterCandidate(): { token: symbol; epoch: number; message: CustomMessage } | null {
10639
+ if (this.#ircRosterClaim) return null;
10640
+ const candidate = this.#buildIrcRosterCandidate();
10641
+ if (!candidate || candidate.signature === this.#lastDeliveredIrcRosterSignature) return null;
10642
+ const token = Symbol("irc-roster");
10643
+ const epoch = this.#ircRosterEpoch;
10644
+ this.#ircRosterClaim = { token, signature: candidate.signature, epoch };
10645
+ return { token, epoch, message: candidate.message };
10646
+ }
10647
+
10648
+ #isCurrentIrcRosterClaim(token: symbol, epoch: number): boolean {
10649
+ return this.#ircRosterEpoch === epoch && this.#ircRosterClaim?.token === token;
10650
+ }
10651
+
10652
+ #commitIrcRosterClaim(token: symbol, epoch: number): void {
10653
+ const claim = this.#ircRosterClaim;
10654
+ if (this.#ircRosterEpoch !== epoch || claim?.token !== token) {
10655
+ this.#releaseIrcRosterClaim(token, epoch);
10656
+ return;
10657
+ }
10658
+ this.#lastDeliveredIrcRosterSignature = claim.signature;
10659
+ this.#ircRosterClaim = null;
10660
+ }
10661
+
10662
+ #releaseIrcRosterClaim(token: symbol, epoch: number): void {
10663
+ if (this.#isCurrentIrcRosterClaim(token, epoch)) this.#ircRosterClaim = null;
10664
+ }
10665
+
10666
+ #resetIrcRosterDeliveryState(): void {
10667
+ this.#ircRosterEpoch += 1;
10668
+ this.#lastDeliveredIrcRosterSignature = null;
10669
+ this.#ircRosterClaim = null;
10670
+ }
10671
+
10409
10672
  /**
10410
10673
  * Run a single ephemeral side-channel turn against this session's current
10411
10674
  * model + system prompt + history. No tools are used; the side request
@@ -10421,62 +10684,112 @@ export class AgentSession {
10421
10684
  promptText: string;
10422
10685
  onTextDelta?: (delta: string) => void;
10423
10686
  signal?: AbortSignal;
10424
- }): Promise<{ replyText: string; assistantMessage: AssistantMessage }> {
10425
- const model = this.model;
10426
- if (!model) {
10427
- throw new Error("No active model on session");
10428
- }
10429
- const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
10430
- if (!apiKey) {
10431
- throw new Error(`No API key for ${model.provider}/${model.id}`);
10432
- }
10687
+ /** Defer the successful roster-claim commit until the caller accepts its exchange. */
10688
+ deferRosterCommit?: boolean;
10689
+ }): Promise<{
10690
+ replyText: string;
10691
+ assistantMessage: AssistantMessage;
10692
+ commitRosterClaim?: () => void;
10693
+ releaseRosterClaim?: () => void;
10694
+ }> {
10695
+ const rosterClaim = this.#claimIrcRosterCandidate();
10696
+ let rosterClaimDeferred = false;
10697
+ try {
10698
+ const model = this.model;
10699
+ if (!model) {
10700
+ throw new Error("No active model on session");
10701
+ }
10702
+ const apiKey = await this.#modelRegistry.getApiKey(model, this.sessionId);
10703
+ if (!apiKey) {
10704
+ throw new Error(`No API key for ${model.provider}/${model.id}`);
10705
+ }
10706
+
10707
+ const rosterMessage =
10708
+ rosterClaim && this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch)
10709
+ ? rosterClaim.message
10710
+ : undefined;
10711
+ if (rosterClaim && !rosterMessage) {
10712
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10713
+ }
10714
+ let snapshot = this.#buildEphemeralSnapshot(args.promptText, rosterMessage ? [rosterMessage] : undefined);
10715
+ let llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
10716
+ if (rosterMessage && !this.#isCurrentIrcRosterClaim(rosterClaim!.token, rosterClaim!.epoch)) {
10717
+ this.#releaseIrcRosterClaim(rosterClaim!.token, rosterClaim!.epoch);
10718
+ // Conversion is asynchronous, so rebuild without a claim invalidated while it awaited.
10719
+ snapshot = this.#buildEphemeralSnapshot(args.promptText);
10720
+ llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
10721
+ }
10722
+ const context: Context = {
10723
+ systemPrompt: this.systemPrompt,
10724
+ messages: llmMessages,
10725
+ // Empty tools array: with toolChoice="none" some encoders still serialize the
10726
+ // recipient's tool catalog and the model leaks raw call markup
10727
+ // (<function_calls>, DSML envelopes) into IRC replies. Stripping tools here
10728
+ // removes the surface entirely.
10729
+ tools: [],
10730
+ };
10731
+ const options = this.prepareSimpleStreamOptions(
10732
+ {
10733
+ apiKey,
10734
+ sessionId: this.sessionId,
10735
+ reasoning: toReasoningEffort(this.thinkingLevel),
10736
+ hideThinkingSummary: this.agent.hideThinkingSummary,
10737
+ serviceTier: this.serviceTier,
10738
+ signal: args.signal,
10739
+ toolChoice: "none",
10740
+ },
10741
+ model.provider,
10742
+ );
10433
10743
 
10434
- const snapshot = this.#buildEphemeralSnapshot(args.promptText);
10435
- const llmMessages = await this.convertMessagesToLlm(snapshot, args.signal);
10436
- const context: Context = {
10437
- systemPrompt: this.systemPrompt,
10438
- messages: llmMessages,
10439
- // Empty tools array: with toolChoice="none" some encoders still serialize the
10440
- // recipient's tool catalog and the model leaks raw call markup
10441
- // (<function_calls>, DSML envelopes) into IRC replies. Stripping tools here
10442
- // removes the surface entirely.
10443
- tools: [],
10444
- };
10445
- const options = this.prepareSimpleStreamOptions(
10446
- {
10447
- apiKey,
10448
- sessionId: this.sessionId,
10449
- reasoning: toReasoningEffort(this.thinkingLevel),
10450
- hideThinkingSummary: this.agent.hideThinkingSummary,
10451
- serviceTier: this.serviceTier,
10452
- signal: args.signal,
10453
- toolChoice: "none",
10454
- },
10455
- model.provider,
10456
- );
10744
+ let replyText = "";
10745
+ let assistantMessage: AssistantMessage | undefined;
10746
+ const stream = streamSimple(model, context, options);
10747
+ for await (const event of stream) {
10748
+ if (event.type === "text_delta") {
10749
+ replyText += event.delta;
10750
+ if (args.onTextDelta) args.onTextDelta(event.delta);
10751
+ continue;
10752
+ }
10753
+ if (event.type === "done") {
10754
+ assistantMessage = event.message;
10755
+ break;
10756
+ }
10757
+ if (event.type === "error") {
10758
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
10759
+ }
10760
+ }
10457
10761
 
10458
- let replyText = "";
10459
- let assistantMessage: AssistantMessage | undefined;
10460
- const stream = streamSimple(model, context, options);
10461
- for await (const event of stream) {
10462
- if (event.type === "text_delta") {
10463
- replyText += event.delta;
10464
- if (args.onTextDelta) args.onTextDelta(event.delta);
10465
- continue;
10762
+ if (!assistantMessage) {
10763
+ throw new Error("Ephemeral turn ended without a final message");
10466
10764
  }
10467
- if (event.type === "done") {
10468
- assistantMessage = event.message;
10469
- break;
10765
+ if (rosterClaim && args.deferRosterCommit) {
10766
+ rosterClaimDeferred = true;
10767
+ let rosterClaimSettled = false;
10768
+ const settleRosterClaim = (commit: boolean) => {
10769
+ if (rosterClaimSettled) return;
10770
+ rosterClaimSettled = true;
10771
+ if (commit) {
10772
+ this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10773
+ } else {
10774
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10775
+ }
10776
+ };
10777
+ return {
10778
+ replyText: dedupeIrcReply(replyText.trim()),
10779
+ assistantMessage,
10780
+ commitRosterClaim: () => settleRosterClaim(true),
10781
+ releaseRosterClaim: () => settleRosterClaim(false),
10782
+ };
10470
10783
  }
10471
- if (event.type === "error") {
10472
- throw new Error(event.error.errorMessage || "Ephemeral turn failed");
10784
+ if (rosterClaim) {
10785
+ this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10786
+ }
10787
+ return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
10788
+ } finally {
10789
+ if (rosterClaim && !rosterClaimDeferred) {
10790
+ this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
10473
10791
  }
10474
10792
  }
10475
-
10476
- if (!assistantMessage) {
10477
- throw new Error("Ephemeral turn ended without a final message");
10478
- }
10479
- return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
10480
10793
  }
10481
10794
 
10482
10795
  /**
@@ -10485,7 +10798,7 @@ export class AgentSession {
10485
10798
  * the partial response in context, then appends the prompt as a virtual
10486
10799
  * user message.
10487
10800
  */
10488
- #buildEphemeralSnapshot(promptText: string): AgentMessage[] {
10801
+ #buildEphemeralSnapshot(promptText: string, prependMessages?: AgentMessage[]): AgentMessage[] {
10489
10802
  const messages = [...this.messages];
10490
10803
  const streaming = this.agent.state.streamMessage;
10491
10804
  if (streaming && streaming.role === "assistant") {
@@ -10516,6 +10829,7 @@ export class AgentSession {
10516
10829
  }
10517
10830
  }
10518
10831
  }
10832
+ if (prependMessages) messages.push(...prependMessages);
10519
10833
  messages.push({
10520
10834
  role: "user",
10521
10835
  content: [{ type: "text", text: promptText }],
@@ -10727,7 +11041,9 @@ export class AgentSession {
10727
11041
 
10728
11042
  if (switchingToDifferentSession) {
10729
11043
  this.#resetHindsightConversationTrackingIfHindsight();
11044
+ this.#resetIrcRosterDeliveryState();
10730
11045
  }
11046
+
10731
11047
  this.#reconnectToAgent();
10732
11048
  return true;
10733
11049
  } catch (error) {
@@ -10850,6 +11166,8 @@ export class AgentSession {
10850
11166
  this.#closeCodexProviderSessionsForHistoryRewrite();
10851
11167
  }
10852
11168
 
11169
+ this.#resetIrcRosterDeliveryState();
11170
+
10853
11171
  return { selectedText, cancelled: false };
10854
11172
  }
10855
11173
 
@@ -11085,6 +11403,52 @@ export class AgentSession {
11085
11403
  let totalCacheWrite = 0;
11086
11404
  let totalCost = 0;
11087
11405
  let totalPremiumRequests = 0;
11406
+ const totalCostBreakdown: Usage["cost"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
11407
+ let hasCompleteCostBreakdown = true;
11408
+ const addCostBreakdown = (cost: unknown): void => {
11409
+ if (!cost || typeof cost !== "object") {
11410
+ hasCompleteCostBreakdown = false;
11411
+ return;
11412
+ }
11413
+ const completeCost = cost as Usage["cost"];
11414
+ if (
11415
+ !Number.isFinite(completeCost.input) ||
11416
+ completeCost.input < 0 ||
11417
+ !Number.isFinite(completeCost.output) ||
11418
+ completeCost.output < 0 ||
11419
+ !Number.isFinite(completeCost.cacheRead) ||
11420
+ completeCost.cacheRead < 0 ||
11421
+ !Number.isFinite(completeCost.cacheWrite) ||
11422
+ completeCost.cacheWrite < 0 ||
11423
+ !Number.isFinite(completeCost.total) ||
11424
+ completeCost.total < 0
11425
+ ) {
11426
+ hasCompleteCostBreakdown = false;
11427
+ return;
11428
+ }
11429
+ if (!hasCompleteCostBreakdown) return;
11430
+ totalCostBreakdown.input += completeCost.input;
11431
+ totalCostBreakdown.output += completeCost.output;
11432
+ totalCostBreakdown.cacheRead += completeCost.cacheRead;
11433
+ totalCostBreakdown.cacheWrite += completeCost.cacheWrite;
11434
+ totalCostBreakdown.total += completeCost.total;
11435
+ if (
11436
+ !Number.isFinite(totalCostBreakdown.input) ||
11437
+ !Number.isFinite(totalCostBreakdown.output) ||
11438
+ !Number.isFinite(totalCostBreakdown.cacheRead) ||
11439
+ !Number.isFinite(totalCostBreakdown.cacheWrite) ||
11440
+ !Number.isFinite(totalCostBreakdown.total)
11441
+ ) {
11442
+ hasCompleteCostBreakdown = false;
11443
+ }
11444
+ };
11445
+ const hasCompleteTaskToolUsage = (details: unknown): boolean =>
11446
+ Boolean(
11447
+ details &&
11448
+ typeof details === "object" &&
11449
+ (details as Record<string, unknown>).usageCostBreakdownComplete === true,
11450
+ );
11451
+
11088
11452
  const getTaskToolUsage = (details: unknown): Usage | undefined => {
11089
11453
  if (!details || typeof details !== "object") return undefined;
11090
11454
  const record = details as Record<string, unknown>;
@@ -11108,6 +11472,7 @@ export class AgentSession {
11108
11472
  totalCacheWrite += assistantMsg.usage.cacheWrite;
11109
11473
  totalPremiumRequests += assistantMsg.usage.premiumRequests ?? 0;
11110
11474
  totalCost += assistantMsg.usage.cost.total;
11475
+ addCostBreakdown(assistantMsg.usage.cost);
11111
11476
  } else if (message.role === "toolResult") {
11112
11477
  toolResults += 1;
11113
11478
  if (message.toolName === "task") {
@@ -11119,6 +11484,11 @@ export class AgentSession {
11119
11484
  totalCacheWrite += usage.cacheWrite;
11120
11485
  totalPremiumRequests += usage.premiumRequests ?? 0;
11121
11486
  totalCost += usage.cost.total;
11487
+ if (hasCompleteTaskToolUsage(message.details)) {
11488
+ addCostBreakdown(usage.cost);
11489
+ } else {
11490
+ hasCompleteCostBreakdown = false;
11491
+ }
11122
11492
  }
11123
11493
  }
11124
11494
  }
@@ -11140,6 +11510,7 @@ export class AgentSession {
11140
11510
  total: totalInput + totalOutput + totalCacheRead + totalCacheWrite,
11141
11511
  },
11142
11512
  cost: totalCost,
11513
+ ...(hasCompleteCostBreakdown ? { costBreakdown: totalCostBreakdown } : {}),
11143
11514
  premiumRequests: totalPremiumRequests,
11144
11515
  };
11145
11516
  }
@@ -11156,43 +11527,92 @@ export class AgentSession {
11156
11527
  const contextWindow = model.contextWindow ?? 0;
11157
11528
  if (contextWindow <= 0) return undefined;
11158
11529
 
11530
+ const cacheKey = this.#contextUsageCacheKey(model, contextWindow);
11531
+ if (this.#contextUsageCache?.key === cacheKey) return { ...this.#contextUsageCache.value };
11532
+ this.#contextUsageEstimateCount++;
11533
+
11159
11534
  // After compaction, the last assistant usage reflects pre-compaction context size.
11160
11535
  // We can only trust usage from an assistant that responded after the latest compaction.
11161
11536
  // If no such assistant exists, context token count is unknown until the next LLM response.
11162
11537
  const branchEntries = this.sessionManager.getBranch();
11163
11538
  const latestCompaction = getLatestCompactionEntry(branchEntries);
11539
+ const boundaryTs = latestCompaction ? new Date(latestCompaction.timestamp).getTime() : 0;
11540
+ const anchor = this.#findAnchorableUsageIndex(this.messages, boundaryTs);
11541
+ let value: ContextUsage;
11542
+ if (latestCompaction && !anchor) {
11543
+ value = { tokens: null, contextWindow, percent: null, source: "unknown" };
11544
+ } else {
11545
+ const estimate = this.#estimateContextTokens(boundaryTs, anchor);
11546
+ value = {
11547
+ tokens: estimate.tokens,
11548
+ contextWindow,
11549
+ percent: (estimate.tokens / contextWindow) * 100,
11550
+ source: estimate.anchored ? "provider_anchor" : "heuristic",
11551
+ };
11552
+ }
11164
11553
 
11165
- if (latestCompaction) {
11166
- // Check if there's a valid assistant usage after the compaction boundary
11167
- const compactionIndex = branchEntries.lastIndexOf(latestCompaction);
11168
- let hasPostCompactionUsage = false;
11169
- for (let i = branchEntries.length - 1; i > compactionIndex; i--) {
11170
- const entry = branchEntries[i];
11171
- if (entry.type === "message" && entry.message.role === "assistant") {
11172
- const assistant = entry.message;
11173
- if (assistant.stopReason !== "aborted" && assistant.stopReason !== "error") {
11174
- const contextTokens = calculateContextTokens(assistant.usage);
11175
- if (contextTokens > 0) {
11176
- hasPostCompactionUsage = true;
11177
- }
11178
- break;
11179
- }
11180
- }
11181
- }
11554
+ this.#contextUsageCache = { key: cacheKey, value };
11555
+ return { ...value };
11556
+ }
11557
+
11558
+ getContextUsageObservabilityForTests(): { estimateCount: number } {
11559
+ return { estimateCount: this.#contextUsageEstimateCount };
11560
+ }
11561
+
11562
+ #contextUsageCacheKey(model: Model, contextWindow: number): string {
11563
+ const messages = this.messages;
11564
+ const lastMessage = messages[messages.length - 1];
11565
+ // Entry and leaf revisions change whenever the active branch changes, avoiding getBranch() on warm reads.
11566
+ const revision = this.sessionManager.revisionSnapshot();
11567
+ return `${this.agent.contextRevision}|${model.id}|${contextWindow}|${messages.length}|${this.#contextUsageMessageFingerprint(lastMessage)}|${revision.entry}:${revision.leaf}|${this.#computeContextUsageNonMessageInputsKey()}`;
11568
+ }
11569
+
11570
+ #contextUsageMessageFingerprint(message: AgentMessage | undefined): string {
11571
+ if (!message) return "";
11572
+ let messageId = this.#contextUsageMessageIds.get(message);
11573
+ if (messageId === undefined) {
11574
+ messageId = ++this.#nextContextUsageMessageId;
11575
+ this.#contextUsageMessageIds.set(message, messageId);
11576
+ }
11577
+
11578
+ const role = message.role;
11579
+ const timestamp = typeof message.timestamp === "number" ? message.timestamp : "";
11580
+ let contentLength = 0;
11581
+ let blockCount = 0;
11582
+ const record = message as {
11583
+ content?: unknown;
11584
+ command?: unknown;
11585
+ output?: unknown;
11586
+ summary?: unknown;
11587
+ stopReason?: unknown;
11588
+ usage?: Usage;
11589
+ };
11182
11590
 
11183
- if (!hasPostCompactionUsage) {
11184
- return { tokens: null, contextWindow, percent: null };
11591
+ if (typeof record.command === "string") contentLength += record.command.length;
11592
+ if (typeof record.output === "string") contentLength += record.output.length;
11593
+ if (typeof record.summary === "string") contentLength += record.summary.length;
11594
+ if (typeof record.content === "string") {
11595
+ contentLength += record.content.length;
11596
+ } else if (Array.isArray(record.content)) {
11597
+ blockCount = record.content.length;
11598
+ for (const block of record.content) {
11599
+ if (!block || typeof block !== "object") continue;
11600
+ const content = block as { text?: unknown; thinking?: unknown; name?: unknown };
11601
+ if (typeof content.text === "string") contentLength += content.text.length;
11602
+ if (typeof content.thinking === "string") contentLength += content.thinking.length;
11603
+ if (typeof content.name === "string") contentLength += content.name.length;
11185
11604
  }
11186
11605
  }
11606
+ const stopReason = typeof record.stopReason === "string" ? record.stopReason : "";
11607
+ const usageTokens = record.usage ? calculateContextTokens(record.usage) : 0;
11608
+ return `${messageId}:${role}:${timestamp}:${contentLength}:${blockCount}:${stopReason}:${usageTokens}`;
11609
+ }
11187
11610
 
11188
- const estimate = this.#estimateContextTokens();
11189
- const percent = (estimate.tokens / contextWindow) * 100;
11190
-
11191
- return {
11192
- tokens: estimate.tokens,
11193
- contextWindow,
11194
- percent,
11195
- };
11611
+ #computeContextUsageNonMessageInputsKey(): string {
11612
+ const systemPrompt = this.systemPrompt;
11613
+ let systemPromptLengths = "";
11614
+ for (const part of systemPrompt) systemPromptLengths += `${part.length},`;
11615
+ return `${systemPrompt.length}:${systemPromptLengths}|${this.agent.state.tools.length}|${this.skills.length}`;
11196
11616
  }
11197
11617
 
11198
11618
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
@@ -11207,10 +11627,19 @@ export class AgentSession {
11207
11627
  /**
11208
11628
  * Estimate context tokens from messages, using the last assistant usage when available.
11209
11629
  */
11210
- #estimateContextTokens(): {
11630
+ #estimateContextTokens(
11631
+ boundaryTs: number,
11632
+ anchor: { index: number; usage: Usage } | undefined,
11633
+ ): {
11211
11634
  tokens: number;
11635
+ anchored: boolean;
11212
11636
  } {
11213
- return this.#estimateContextTokensWith(message => this.#estimateMessageDisplayTokens(message));
11637
+ return this.#estimateContextTokensWith(
11638
+ message => this.#estimateMessageDisplayTokens(message),
11639
+ false,
11640
+ boundaryTs,
11641
+ anchor,
11642
+ );
11214
11643
  }
11215
11644
 
11216
11645
  /** Count inline image blocks in a message (for bucketing the fixed image token estimate). */
@@ -11224,11 +11653,48 @@ export class AgentSession {
11224
11653
  return count;
11225
11654
  }
11226
11655
 
11656
+ /**
11657
+ * Usage-anchor eligibility for context estimation. Mirrors the compaction
11658
+ * rule (see getAssistantUsage in compaction.ts): error/aborted turns carry
11659
+ * absent or partial usage, so they must not anchor an estimate. Anchor on
11660
+ * the last successful positive-usage assistant instead and let callers
11661
+ * estimate every later message (including error/aborted ones) as trailing
11662
+ * context.
11663
+ */
11664
+ #anchorableAssistantUsage(message: AgentMessage): Usage | undefined {
11665
+ if (message.role !== "assistant") return undefined;
11666
+ const assistant = message as AssistantMessage;
11667
+ if (assistant.stopReason === "aborted" || assistant.stopReason === "error") return undefined;
11668
+ const usage = assistant.usage;
11669
+ if (!usage || calculateContextTokens(usage) <= 0) return undefined;
11670
+ return usage;
11671
+ }
11672
+
11673
+ /** Find the newest positive successful usage anchor after a compaction boundary. */
11674
+ #findAnchorableUsageIndex(
11675
+ messages: readonly AgentMessage[],
11676
+ boundaryTs: number,
11677
+ ): { index: number; usage: Usage } | undefined {
11678
+ for (let i = messages.length - 1; i >= 0; i--) {
11679
+ const message = messages[i];
11680
+ const usage = this.#anchorableAssistantUsage(message);
11681
+ if (!usage) continue;
11682
+ if (boundaryTs > 0) {
11683
+ const timestamp = message.timestamp;
11684
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= boundaryTs) continue;
11685
+ }
11686
+ return { index: i, usage };
11687
+ }
11688
+ return undefined;
11689
+ }
11690
+
11227
11691
  /**
11228
11692
  * Observed heuristic→actual token correction for the compaction keep window
11229
11693
  * (Finding 7). Compares the provider's real prompt tokens against the chars/4
11230
- * heuristic estimate of the same content (stable system prefix + history through
11231
- * the last usage-bearing assistant turn). Image-bearing content is bucketed out
11694
+ * heuristic estimate of the same content (stable system prefix + history
11695
+ * before the last usage-bearing assistant turn — that turn's own output is
11696
+ * the response, not part of the request's prompt, so it belongs on neither
11697
+ * side of the ratio). Image-bearing content is bucketed out
11232
11698
  * of BOTH sides using the identical fixed IMAGE_TOKEN_ESTIMATE so the 1200-token
11233
11699
  * image charge cannot skew the text ratio. Returns undefined when data is
11234
11700
  * insufficient, so prepareCompaction applies no correction (never the confounded
@@ -11239,9 +11705,9 @@ export class AgentSession {
11239
11705
  let lastUsageIndex = -1;
11240
11706
  let lastUsage: Usage | undefined;
11241
11707
  for (let i = messages.length - 1; i >= 0; i--) {
11242
- const msg = messages[i];
11243
- if (msg.role === "assistant" && (msg as AssistantMessage).usage) {
11244
- lastUsage = (msg as AssistantMessage).usage;
11708
+ const usage = this.#anchorableAssistantUsage(messages[i]);
11709
+ if (usage) {
11710
+ lastUsage = usage;
11245
11711
  lastUsageIndex = i;
11246
11712
  break;
11247
11713
  }
@@ -11253,7 +11719,7 @@ export class AgentSession {
11253
11719
  let heuristic = 0;
11254
11720
  for (const block of this.agent.state.systemPrompt) heuristic += estimateTextTokensHeuristic(block);
11255
11721
  let imageBlocks = 0;
11256
- for (let i = 0; i <= lastUsageIndex; i++) {
11722
+ for (let i = 0; i < lastUsageIndex; i++) {
11257
11723
  heuristic += this.#estimateMessageDisplayTokens(messages[i]);
11258
11724
  imageBlocks += this.#countImageBlocks(messages[i]);
11259
11725
  }
@@ -11266,52 +11732,61 @@ export class AgentSession {
11266
11732
 
11267
11733
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
11268
11734
  tokens: number;
11735
+ anchored: boolean;
11269
11736
  } {
11270
- const estimate = this.#estimateContextTokensWith(message => this.#estimateMessageCompactionDeltaTokens(message));
11737
+ const estimate = this.#estimateContextTokensWith(
11738
+ message => this.#estimateMessageCompactionDeltaTokens(message),
11739
+ true,
11740
+ );
11271
11741
  return {
11272
11742
  tokens: estimate.tokens + this.#estimateMessagesCompactionDeltaTokens(pendingMessages),
11743
+ anchored: estimate.anchored,
11273
11744
  };
11274
11745
  }
11275
11746
 
11276
- #estimateContextTokensWith(estimateMessage: (message: AgentMessage) => number): {
11747
+ #estimateContextTokensWith(
11748
+ estimateMessage: (message: AgentMessage) => number,
11749
+ inflateFixed = false,
11750
+ boundaryTs?: number,
11751
+ knownAnchor?: { index: number; usage: Usage } | undefined,
11752
+ ): {
11277
11753
  tokens: number;
11754
+ anchored: boolean;
11278
11755
  } {
11279
11756
  const messages = this.messages;
11280
-
11281
- // Find last assistant message with usage
11282
- let lastUsageIndex: number | null = null;
11283
- let lastUsage: Usage | undefined;
11284
- for (let i = messages.length - 1; i >= 0; i--) {
11285
- const msg = messages[i];
11286
- if (msg.role === "assistant") {
11287
- const assistantMsg = msg as AssistantMessage;
11288
- if (assistantMsg.usage) {
11289
- lastUsage = assistantMsg.usage;
11290
- lastUsageIndex = i;
11291
- break;
11292
- }
11293
- }
11757
+ let anchor = knownAnchor;
11758
+ if (boundaryTs === undefined) {
11759
+ const latestCompaction = getLatestCompactionEntry(this.sessionManager.getBranch());
11760
+ boundaryTs = latestCompaction ? new Date(latestCompaction.timestamp).getTime() : 0;
11761
+ anchor = this.#findAnchorableUsageIndex(messages, boundaryTs);
11294
11762
  }
11295
11763
 
11296
- if (!lastUsage || lastUsageIndex === null) {
11297
- // No usage data - estimate all messages
11298
- let estimated = 0;
11764
+ if (!anchor) {
11765
+ // No usage data - estimate the full provider request.
11766
+ const fixedTokens = computeNonMessageTokens(this);
11767
+ let estimated = inflateFixed ? Math.ceil(fixedTokens * this.#compactionDeltaInflation) : fixedTokens;
11299
11768
  for (const message of messages) {
11300
11769
  estimated += estimateMessage(message);
11301
11770
  }
11302
11771
  return {
11303
11772
  tokens: estimated,
11773
+ anchored: false,
11304
11774
  };
11305
11775
  }
11306
11776
 
11307
- const usageTokens = calculatePromptTokens(lastUsage);
11777
+ // Anchor on total context tokens (input + cache + output), not prompt-only
11778
+ // tokens: the next request replays the anchor assistant's own output
11779
+ // (text/reasoning/tool calls), so dropping it undercounts the very tokens
11780
+ // a large-reasoning turn just added (Sol xhigh emits tens of thousands).
11781
+ const usageTokens = calculateContextTokens(anchor.usage);
11308
11782
  let trailingTokens = 0;
11309
- for (let i = lastUsageIndex + 1; i < messages.length; i++) {
11783
+ for (let i = anchor.index + 1; i < messages.length; i++) {
11310
11784
  trailingTokens += estimateMessage(messages[i]);
11311
11785
  }
11312
11786
 
11313
11787
  return {
11314
11788
  tokens: usageTokens + trailingTokens,
11789
+ anchored: true,
11315
11790
  };
11316
11791
  }
11317
11792
 
@@ -11332,55 +11807,33 @@ export class AgentSession {
11332
11807
  }
11333
11808
 
11334
11809
  /**
11335
- * Conservative inflation applied to the native-free chars/4 estimate of the
11336
- * UNSENT context delta. chars/4 undercounts dense code/CJK, so we bias high
11337
- * to compact slightly early rather than overflow the model window before the
11338
- * next provider response re-anchors the exact count.
11810
+ * Conservative inflation applied to the native-free estimate of the UNSENT
11811
+ * context delta. The heuristic is script-aware (CJK counted ~1 token/char),
11812
+ * but dense non-CJK content (compact JSON, diffs, hashes) can still exceed
11813
+ * chars/4, so we bias high to compact slightly early rather than overflow
11814
+ * the model window before the next provider response re-anchors the count.
11339
11815
  */
11340
11816
  #compactionDeltaInflation = 1.2;
11341
- #compactionDeltaTokenCache = new WeakMap<AgentMessage, { len: number; tokens: number }>();
11342
-
11343
- /**
11344
- * Cheap content-size signal to invalidate the compaction-delta token cache on mutation. Recursively
11345
- * sums string lengths across the whole message (depth-bounded), so it covers every
11346
- * provider-visible shape (text/thinking/tool args, toolResult output, tool names, etc.)
11347
- * without allocating a serialized copy. A size-preserving in-place edit yields only a
11348
- * benign estimate drift.
11349
- */
11350
- #messageTokenSize(value: unknown, depth = 0): number {
11351
- if (depth > 6) return 0;
11352
- if (typeof value === "string") return value.length;
11353
- if (typeof value === "number" || typeof value === "boolean") return 8;
11354
- if (Array.isArray(value)) {
11355
- let size = 0;
11356
- for (const item of value) size += this.#messageTokenSize(item, depth + 1);
11357
- return size;
11358
- }
11359
- if (value && typeof value === "object") {
11360
- let size = 0;
11361
- for (const item of Object.values(value)) size += this.#messageTokenSize(item, depth + 1);
11362
- return size;
11363
- }
11364
- return 0;
11365
- }
11366
11817
 
11367
11818
  #estimateMessageCompactionDeltaTokens(message: AgentMessage): number {
11368
- // Provider usage anchors the already-sent context (see calculatePromptTokens); this
11369
- // estimates only the UNSENT delta with the native-free chars/4 heuristic, inflated by
11819
+ // Provider usage anchors the already-sent context (see calculateContextTokens); this
11820
+ // estimates only the UNSENT delta with the script-aware heuristic, inflated by
11370
11821
  // #compactionDeltaInflation so dense input cannot undercount us past the compaction
11371
- // threshold before the next provider response re-anchors the exact count. Cached per
11372
- // message object, invalidated by a cheap content-size signal; a rare size-preserving
11373
- // in-place edit yields only a benign estimate drift, never wrong output.
11374
- const len = this.#messageTokenSize(message);
11375
- const cached = this.#compactionDeltaTokenCache.get(message);
11376
- if (cached && cached.len === len) return cached.tokens;
11822
+ // threshold before the next provider response re-anchors the exact count.
11823
+ //
11824
+ // Deliberately uncached: this feeds the compaction-threshold decision, and a
11825
+ // stale estimate after an in-place mutation (e.g. a same-length ASCII→CJK
11826
+ // edit, which changes the script-aware estimate up to 4x) could hold the
11827
+ // session under the threshold while the real prompt overflows. Any cheap
11828
+ // invalidation signal short of recomputing the estimator's own converted
11829
+ // fragments provably admits stale reuse, and the call sites run once per
11830
+ // prompt over the few messages trailing the usage anchor, so correctness
11831
+ // wins over a microcache here.
11377
11832
  let heuristic = 0;
11378
11833
  for (const llmMessage of convertToLlm([message])) {
11379
11834
  heuristic += estimateMessageTokensHeuristic(llmMessage);
11380
11835
  }
11381
- const tokens = Math.ceil(heuristic * this.#compactionDeltaInflation);
11382
- this.#compactionDeltaTokenCache.set(message, { len, tokens });
11383
- return tokens;
11836
+ return Math.ceil(heuristic * this.#compactionDeltaInflation);
11384
11837
  }
11385
11838
 
11386
11839
  /**