@sayknow-cli/coding-agent 0.3.7 → 0.3.8

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/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +105 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  34. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  35. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  36. package/dist/types/modes/interactive-mode.d.ts +2 -0
  37. package/dist/types/modes/print-mode.d.ts +13 -1
  38. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  39. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  40. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  41. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  42. package/dist/types/modes/types.d.ts +1 -0
  43. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  44. package/dist/types/notifications/config.d.ts +14 -1
  45. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  46. package/dist/types/notifications/index.d.ts +6 -3
  47. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  48. package/dist/types/notifications/telegram-daemon.d.ts +23 -3
  49. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  50. package/dist/types/notifications/threaded-render.d.ts +6 -0
  51. package/dist/types/runtime-api-key.d.ts +7 -0
  52. package/dist/types/sdk.d.ts +3 -0
  53. package/dist/types/session/agent-session.d.ts +61 -6
  54. package/dist/types/session/agent-storage.d.ts +1 -0
  55. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  56. package/dist/types/session/messages.d.ts +4 -0
  57. package/dist/types/session/session-manager.d.ts +17 -1
  58. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  59. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  60. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  61. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  62. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  63. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  64. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  65. package/dist/types/skill-state/active-state.d.ts +18 -1
  66. package/dist/types/system-prompt.d.ts +18 -2
  67. package/dist/types/task/executor.d.ts +3 -0
  68. package/dist/types/task/fork-context-budget.d.ts +7 -0
  69. package/dist/types/task/token-log.d.ts +30 -0
  70. package/dist/types/task/token-log.test.d.ts +1 -0
  71. package/dist/types/tools/bisect.d.ts +81 -0
  72. package/dist/types/tools/computer.d.ts +2 -2
  73. package/dist/types/tools/index.d.ts +1 -0
  74. package/dist/types/tools/search.d.ts +2 -2
  75. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  76. package/dist/types/utils/file-mentions.d.ts +14 -0
  77. package/dist/types/utils/git.d.ts +32 -0
  78. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  79. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  80. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  81. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  82. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  83. package/package.json +8 -8
  84. package/src/append-only-mode.ts +58 -0
  85. package/src/autoresearch/prompt.md +1 -1
  86. package/src/autoresearch/tools/log-experiment.ts +1 -1
  87. package/src/capability/rule.ts +9 -0
  88. package/src/cli/completion-cli.ts +234 -0
  89. package/src/cli/config-cli.ts +61 -9
  90. package/src/cli/fast-help.ts +1 -0
  91. package/src/cli/fixture-report.test.ts +230 -0
  92. package/src/cli/fixture-report.ts +171 -0
  93. package/src/cli/nofile-limit.ts +48 -0
  94. package/src/cli/notify-cli.ts +92 -8
  95. package/src/cli/telegram-cli.ts +1 -1
  96. package/src/cli/update-cli.ts +85 -9
  97. package/src/cli.ts +29 -1
  98. package/src/commands/completion.ts +84 -0
  99. package/src/commands/config.ts +2 -0
  100. package/src/commands/contribution-prep.ts +1 -1
  101. package/src/commands/deep-interview.ts +2 -1
  102. package/src/config/file-lock.ts +73 -34
  103. package/src/config/keybindings.ts +2 -2
  104. package/src/config/model-profile-activation.ts +65 -0
  105. package/src/config/model-profiles.ts +31 -2
  106. package/src/config/settings-schema.ts +68 -1
  107. package/src/config/settings.ts +22 -0
  108. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  109. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  110. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  111. package/src/defaults/skc-defaults.ts +9 -0
  112. package/src/discovery/helpers.ts +10 -0
  113. package/src/discovery/index.ts +1 -0
  114. package/src/export/html/template.generated.ts +1 -1
  115. package/src/export/html/template.js +60 -2
  116. package/src/export/ttsr.ts +96 -18
  117. package/src/extensibility/extensions/runner.ts +183 -219
  118. package/src/extensibility/extensions/types.ts +9 -0
  119. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  120. package/src/goals/runtime.ts +10 -2
  121. package/src/hindsight/backend.ts +1 -3
  122. package/src/hooks/native-skill-hook.ts +124 -4
  123. package/src/internal-urls/docs-index.generated.ts +14 -5
  124. package/src/main.ts +95 -43
  125. package/src/memories/index.ts +12 -1
  126. package/src/modes/components/assistant-message.ts +241 -82
  127. package/src/modes/components/custom-editor.ts +1 -0
  128. package/src/modes/components/model-selector.ts +54 -11
  129. package/src/modes/components/queued-message-selector.ts +92 -0
  130. package/src/modes/components/settings-selector.ts +4 -0
  131. package/src/modes/components/status-line.ts +167 -54
  132. package/src/modes/components/tool-execution.ts +44 -19
  133. package/src/modes/components/welcome.ts +181 -44
  134. package/src/modes/controllers/command-controller.ts +40 -4
  135. package/src/modes/controllers/event-controller.ts +66 -47
  136. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  137. package/src/modes/controllers/input-controller.ts +180 -18
  138. package/src/modes/controllers/selector-controller.ts +127 -24
  139. package/src/modes/interactive-mode.ts +51 -16
  140. package/src/modes/print-mode.ts +43 -4
  141. package/src/modes/prompt-action-autocomplete.ts +21 -20
  142. package/src/modes/rpc/rpc-mode.ts +29 -3
  143. package/src/modes/rpc/rpc-types.ts +9 -0
  144. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  145. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  146. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  147. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  148. package/src/modes/types.ts +1 -0
  149. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  150. package/src/modes/utils/ui-helpers.ts +20 -7
  151. package/src/notifications/config.ts +25 -5
  152. package/src/notifications/daemon-paths.ts +2 -0
  153. package/src/notifications/index.ts +117 -14
  154. package/src/notifications/lifecycle-commands.ts +37 -7
  155. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  156. package/src/notifications/telegram-daemon-control.ts +5 -3
  157. package/src/notifications/telegram-daemon.ts +302 -51
  158. package/src/notifications/telegram-reference.ts +0 -16
  159. package/src/notifications/threaded-render.ts +17 -1
  160. package/src/prompts/agents/architect.md +19 -10
  161. package/src/prompts/agents/critic.md +16 -18
  162. package/src/prompts/agents/executor.md +14 -11
  163. package/src/prompts/agents/planner.md +18 -13
  164. package/src/prompts/ci-green-request.md +3 -8
  165. package/src/prompts/goals/goal-continuation.md +0 -5
  166. package/src/prompts/goals/goal-mode-active.md +0 -5
  167. package/src/prompts/system/agent-creation-architect.md +2 -2
  168. package/src/prompts/system/plan-mode-active.md +1 -1
  169. package/src/prompts/system/plan-mode-subagent.md +3 -3
  170. package/src/prompts/system/project-prompt.md +0 -13
  171. package/src/prompts/system/subagent-system-prompt.md +3 -2
  172. package/src/prompts/system/system-prompt.md +29 -17
  173. package/src/prompts/system/volatile-project-context.md +10 -0
  174. package/src/prompts/system/web-search.md +4 -7
  175. package/src/prompts/tools/apply-patch.md +6 -0
  176. package/src/prompts/tools/ast-edit.md +1 -0
  177. package/src/prompts/tools/bash.md +1 -1
  178. package/src/prompts/tools/bisect.md +22 -0
  179. package/src/prompts/tools/browser.md +1 -1
  180. package/src/prompts/tools/computer.md +2 -2
  181. package/src/prompts/tools/cron.md +0 -2
  182. package/src/prompts/tools/eval.md +2 -2
  183. package/src/prompts/tools/find.md +1 -1
  184. package/src/prompts/tools/github.md +6 -6
  185. package/src/prompts/tools/goal.md +1 -3
  186. package/src/prompts/tools/image-gen.md +12 -5
  187. package/src/prompts/tools/irc.md +5 -4
  188. package/src/prompts/tools/job.md +2 -2
  189. package/src/prompts/tools/lsp.md +1 -1
  190. package/src/prompts/tools/monitor.md +2 -2
  191. package/src/prompts/tools/patch.md +2 -2
  192. package/src/prompts/tools/replace.md +2 -21
  193. package/src/prompts/tools/rewind.md +1 -1
  194. package/src/prompts/tools/search-tool-bm25.md +1 -11
  195. package/src/prompts/tools/search.md +1 -1
  196. package/src/prompts/tools/skill.md +3 -2
  197. package/src/prompts/tools/ssh.md +2 -1
  198. package/src/prompts/tools/subagent.md +2 -2
  199. package/src/prompts/tools/task.md +4 -5
  200. package/src/prompts/tools/todo-write.md +1 -1
  201. package/src/prompts/tools/vim.md +1 -0
  202. package/src/prompts/tools/web-search.md +2 -0
  203. package/src/prompts/tools/write.md +8 -2
  204. package/src/runtime-api-key.ts +16 -0
  205. package/src/sdk.ts +52 -28
  206. package/src/session/agent-session.ts +1011 -141
  207. package/src/session/agent-storage.ts +4 -0
  208. package/src/session/file-mention-pruning.ts +64 -0
  209. package/src/session/messages.ts +4 -0
  210. package/src/session/session-manager.ts +103 -4
  211. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  212. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  213. package/src/skc-runtime/deep-interview-state.ts +44 -2
  214. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  215. package/src/skc-runtime/state-runtime.ts +12 -2
  216. package/src/skc-runtime/state-writer.ts +15 -0
  217. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  218. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  219. package/src/skc-runtime/ultragoal-runtime.ts +1663 -159
  220. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  221. package/src/skc-runtime/workflow-manifest.ts +74 -1
  222. package/src/skill-state/active-state.ts +141 -5
  223. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  224. package/src/slash-commands/builtin-registry.ts +218 -71
  225. package/src/system-prompt.ts +57 -16
  226. package/src/task/executor.ts +132 -24
  227. package/src/task/fork-context-advisory.ts +2 -7
  228. package/src/task/fork-context-budget.ts +9 -0
  229. package/src/task/index.ts +13 -6
  230. package/src/task/output-manager.ts +20 -8
  231. package/src/task/receipt.ts +26 -7
  232. package/src/task/token-log.test.ts +188 -0
  233. package/src/task/token-log.ts +179 -0
  234. package/src/tools/bisect.ts +367 -0
  235. package/src/tools/computer.ts +246 -43
  236. package/src/tools/index.ts +3 -0
  237. package/src/tools/output-meta.ts +116 -4
  238. package/src/tools/search.ts +8 -2
  239. package/src/tools/skill.ts +15 -0
  240. package/src/tools/telegram-send.ts +6 -0
  241. package/src/tools/ultragoal-ask-guard.ts +39 -5
  242. package/src/utils/file-mentions.ts +39 -7
  243. package/src/utils/git.ts +73 -0
  244. package/src/web/scrapers/youtube.ts +24 -12
  245. package/src/workflow/workflow-intent-diff.ts +29 -6
  246. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  247. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  248. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -26,7 +26,6 @@ import {
26
26
  type AgentMessage,
27
27
  type AgentState,
28
28
  type AgentTool,
29
- AppendOnlyContextManager,
30
29
  resolveTelemetry,
31
30
  type StablePrefixSnapshot,
32
31
  ThinkingLevel,
@@ -44,16 +43,20 @@ import {
44
43
  type EmergencyCompactionSample,
45
44
  emergencyCompactionReason,
46
45
  estimateMessageTokensHeuristic,
46
+ estimateTextTokensHeuristic,
47
47
  generateBranchSummary,
48
48
  generateHandoff,
49
+ IMAGE_TOKEN_ESTIMATE,
49
50
  prepareCompaction,
50
51
  type SummaryOptions,
51
52
  shouldCompact,
52
53
  } from "@sayknow-cli/agent-core/compaction";
53
54
  import {
54
55
  DEFAULT_PRUNE_CONFIG,
56
+ estimateToolOutputPruneSavings,
55
57
  pruneAssistantToolArguments,
56
58
  pruneToolOutputs,
59
+ shouldRunMaintenancePrune,
57
60
  } from "@sayknow-cli/agent-core/compaction/pruning";
58
61
  import type {
59
62
  AssistantMessage,
@@ -128,6 +131,7 @@ import {
128
131
  prompt,
129
132
  Snowflake,
130
133
  } from "@sayknow-cli/utils";
134
+ import { createAppendOnlyContextManager, resolveAppendOnlyMode } from "../append-only-mode";
131
135
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
132
136
  import { reset as resetCapabilities } from "../capability";
133
137
  import type { Rule } from "../capability/rule";
@@ -225,7 +229,10 @@ import {
225
229
  modeStatePath as sessionModeStatePath,
226
230
  sessionStateDir,
227
231
  } from "../skc-runtime/session-layout";
228
- import { persistCoordinatorRuntimeStateFromEvent } from "../skc-runtime/session-state-sidecar";
232
+ import {
233
+ persistCoordinatorRuntimeStateFromEvent,
234
+ registerCoordinatorRuntimeStateFinalizer,
235
+ } from "../skc-runtime/session-state-sidecar";
229
236
  import { writeArtifact } from "../skc-runtime/state-writer";
230
237
  import { requestSkcWorkerIntegrationAttempt } from "../skc-runtime/team-runtime";
231
238
  import {
@@ -235,6 +242,7 @@ import {
235
242
  } from "../skill-state/active-state";
236
243
  import { assertWorkflowMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
237
244
  import { invalidateHostMetadata } from "../ssh/connection-manager";
245
+ import { buildVolatileProjectContext } from "../system-prompt";
238
246
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
239
247
  import {
240
248
  buildDiscoverableToolSearchIndex,
@@ -249,7 +257,7 @@ import { assertEditableFile } from "../tools/auto-generated-guard";
249
257
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
250
258
  import type { CheckpointState } from "../tools/checkpoint";
251
259
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
252
- import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
260
+ import { normalizeLocalScheme, resolveReadPath, resolveToCwd } from "../tools/path-utils";
253
261
  import { registerResourceGcSession } from "../tools/resource-gc";
254
262
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
255
263
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
@@ -261,6 +269,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
261
269
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
262
270
  import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-choice";
263
271
  import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
272
+ import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
264
273
  import type { AuthStorage } from "./auth-storage";
265
274
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
266
275
  import {
@@ -268,6 +277,7 @@ import {
268
277
  type ContributionPrepResult,
269
278
  prepareContributionPrep,
270
279
  } from "./contribution-prep";
280
+ import { pruneStaleFileMentions } from "./file-mention-pruning";
271
281
  import {
272
282
  type BashExecutionMessage,
273
283
  type CompactionSummaryMessage,
@@ -354,7 +364,7 @@ export interface AgentSessionConfig {
354
364
  agent: Agent;
355
365
  sessionManager: SessionManager;
356
366
  settings: Settings;
357
- /** Models to cycle through with Ctrl+P (from --models flag) */
367
+ /** Models to cycle through with Alt+N (from --models flag) */
358
368
  scopedModels?: ScopedModelSelection[];
359
369
  /** Initial session thinking selector. */
360
370
  thinkingLevel?: ThinkingLevel;
@@ -393,6 +403,8 @@ export interface AgentSessionConfig {
393
403
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
394
404
  /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
395
405
  rebuildSystemPrompt?: (toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>;
406
+ /** Initial workspace tree snapshot used for the first volatile per-turn context message. */
407
+ workspaceTree?: WorkspaceTree;
396
408
  /** Rebuild the SSH tool from current capability discovery results. */
397
409
  reloadSshTool?: () => Promise<AgentTool | null>;
398
410
  requestedToolNames?: ReadonlySet<string>;
@@ -613,6 +625,13 @@ const IRC_REPLY_MAX_BYTES = 4096;
613
625
  */
614
626
  const SIGNAL_TEARDOWN_TIMEOUT_MS = 5_000;
615
627
 
628
+ /**
629
+ * Throttle window for the per-turn volatile workspace-tree scan. Date/cwd are
630
+ * refreshed every turn; the mtime-sorted tree is rescanned + re-embedded at most
631
+ * once per this interval to bound prompt-hot-path IO and history accumulation.
632
+ */
633
+ const VOLATILE_TREE_TTL_MS = 30_000;
634
+
616
635
  /**
617
636
  * Collapse degenerate IRC ephemeral replies before they hit the relay.
618
637
  * Models occasionally loop on a single line (~16 reports of N-times-repeated
@@ -902,8 +921,17 @@ function extractPermissionLocations(
902
921
  * `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
903
922
  * custom messages queued during streaming) and is matched by the custom-role
904
923
  * `message_start` dequeue branch; user-message pushes leave it undefined and
905
- * rely on the existing text-equality match. */
924
+ * rely on the existing text-equality match. `sequence` gives each queued chip a
925
+ * stable edit id while the display arrays preserve delivery order. */
906
926
  type QueuedDisplayEntry = { text: string; tag?: string; sequence: number };
927
+ export type QueuedMessageEditMode = "steer" | "followUp";
928
+
929
+ export interface QueuedMessageEditEntry {
930
+ id: string;
931
+ text: string;
932
+ mode: QueuedMessageEditMode;
933
+ label: string;
934
+ }
907
935
 
908
936
  /** A custom message contributed at the before-agent-start point. */
909
937
  export type BeforeAgentStartInternalMessage = Pick<
@@ -924,6 +952,136 @@ export type BeforeAgentStartContributor = (event: {
924
952
  sessionId: string | undefined;
925
953
  }) => Promise<BeforeAgentStartInternalMessage | undefined>;
926
954
 
955
+ export class WorkerIntegrationRequestScheduler {
956
+ #inFlight: Promise<void> | undefined = undefined;
957
+ #pending = false;
958
+
959
+ constructor(readonly request: () => Promise<void>) {}
960
+
961
+ enqueue(): void {
962
+ if (this.#inFlight) {
963
+ this.#pending = true;
964
+ return;
965
+ }
966
+ this.#start();
967
+ }
968
+
969
+ async flush(): Promise<void> {
970
+ while (this.#inFlight) {
971
+ await this.#inFlight;
972
+ }
973
+ }
974
+
975
+ #start(): void {
976
+ this.#pending = false;
977
+ // Isolate request rejections so flush() can never reject or deadlock: the
978
+ // request is expected to handle/log its own errors, but a throwing generic
979
+ // request must still clear #inFlight and drain any trailing pending run.
980
+ this.#inFlight = this.request()
981
+ .catch(() => {})
982
+ .finally(() => {
983
+ this.#inFlight = undefined;
984
+ if (this.#pending) this.#start();
985
+ });
986
+ }
987
+ }
988
+
989
+ export type StreamingEditParsedToolCall = {
990
+ toolCall: ToolCall;
991
+ path: string;
992
+ resolvedPath: string;
993
+ diff?: string;
994
+ op?: string;
995
+ rename?: string;
996
+ };
997
+
998
+ export type StreamingEditParsedCacheEntry = {
999
+ version: string;
1000
+ parsed: StreamingEditParsedToolCall | undefined;
1001
+ };
1002
+
1003
+ function stableStreamingEditArgsVersion(args: unknown): string | undefined {
1004
+ if (typeof args === "string") return args;
1005
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
1006
+ try {
1007
+ return JSON.stringify(args);
1008
+ } catch {
1009
+ return undefined;
1010
+ }
1011
+ }
1012
+
1013
+ export function getStreamingEditToolCallForEvent(
1014
+ event: AgentEvent,
1015
+ cache: Map<string, StreamingEditParsedCacheEntry>,
1016
+ resolvePath: (filePath: string) => string | undefined,
1017
+ ): StreamingEditParsedToolCall | undefined {
1018
+ if (event.type !== "message_update") return undefined;
1019
+ if (event.message.role !== "assistant") return undefined;
1020
+
1021
+ const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
1022
+ const messageContent = event.message.content;
1023
+ if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) {
1024
+ return undefined;
1025
+ }
1026
+
1027
+ const toolCall = messageContent[contentIndex] as ToolCall;
1028
+ if (toolCall.name !== "edit") return undefined;
1029
+
1030
+ const version = stableStreamingEditArgsVersion(toolCall.arguments);
1031
+ if (version === undefined) return undefined;
1032
+ const cacheKey = String(contentIndex);
1033
+ const cached = cache.get(cacheKey);
1034
+ if (cached?.version === version) return cached.parsed;
1035
+
1036
+ let args: unknown = toolCall.arguments;
1037
+ if (typeof args === "string") {
1038
+ try {
1039
+ args = JSON.parse(args) as unknown;
1040
+ } catch {
1041
+ cache.delete(cacheKey);
1042
+ return undefined;
1043
+ }
1044
+ }
1045
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
1046
+ if ("old_text" in args || "new_text" in args) return undefined;
1047
+
1048
+ const argsRecord = args as Record<string, unknown>;
1049
+ const path = typeof argsRecord.path === "string" ? argsRecord.path : undefined;
1050
+ if (!path) return undefined;
1051
+ const resolvedPath = resolvePath(path);
1052
+ if (resolvedPath === undefined) return undefined;
1053
+
1054
+ const parsed = {
1055
+ toolCall,
1056
+ path,
1057
+ resolvedPath,
1058
+ diff: typeof argsRecord.diff === "string" ? argsRecord.diff : undefined,
1059
+ op: typeof argsRecord.op === "string" ? argsRecord.op : undefined,
1060
+ rename: typeof argsRecord.rename === "string" ? argsRecord.rename : undefined,
1061
+ };
1062
+ cache.set(cacheKey, { version, parsed });
1063
+ return parsed;
1064
+ }
1065
+
1066
+ /** Test-only counters for AgentSession event fan-out hot-path assertions. */
1067
+ export const __agentSessionPerfCounters = {
1068
+ listenerSnapshotRebuilds: 0,
1069
+ messageUpdateExtensionQueues: 0,
1070
+ reset(): void {
1071
+ this.listenerSnapshotRebuilds = 0;
1072
+ this.messageUpdateExtensionQueues = 0;
1073
+ },
1074
+ };
1075
+
1076
+ export function buildContextInjectionSignature(kind: string, parts: readonly string[]): string {
1077
+ const hash = crypto.createHash("sha256");
1078
+ hash.update(kind);
1079
+ for (const part of parts) {
1080
+ hash.update("\0");
1081
+ hash.update(part);
1082
+ }
1083
+ return hash.digest("base64url");
1084
+ }
927
1085
  export class AgentSession {
928
1086
  readonly agent: Agent;
929
1087
  readonly sessionManager: SessionManager;
@@ -947,6 +1105,12 @@ export class AgentSession {
947
1105
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
948
1106
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
949
1107
  #eventListeners: AgentSessionEventListener[] = [];
1108
+ #eventListenerSnapshot: readonly AgentSessionEventListener[] = Object.freeze([]);
1109
+
1110
+ #rebuildEventListenerSnapshot(): void {
1111
+ this.#eventListenerSnapshot = Object.freeze([...this.#eventListeners]);
1112
+ __agentSessionPerfCounters.listenerSnapshotRebuilds += 1;
1113
+ }
950
1114
 
951
1115
  /** Tracks pending steering messages for UI display. Removed when delivered.
952
1116
  * Entry shape: `{ text }` for plain-text steers (user-message dequeue
@@ -965,13 +1129,18 @@ export class AgentSession {
965
1129
  #goalModeState: GoalModeState | undefined;
966
1130
  #workflowGateEmitter: WorkflowGateEmitter | undefined;
967
1131
  #goalRuntime: GoalRuntime;
1132
+ #lastInjectedGoalContextSig: string | undefined = undefined;
1133
+ #lastInjectedPlanContextSig: string | undefined = undefined;
968
1134
  #goalTurnCounter = 0;
1135
+ #streamingEditParsedToolCallCache = new Map<string, StreamingEditParsedCacheEntry>();
969
1136
  #planReferenceSent = false;
970
1137
  #planReferencePath = "local://PLAN.md";
971
1138
  #clientBridge: ClientBridge | undefined;
972
1139
  #allowAcpAgentInitiatedTurns = false;
973
1140
  /** Per-session memory of allow_always / reject_always decisions for gated tools. */
974
1141
  #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
1142
+ #guardedToolWrapperCache = new WeakMap<AgentTool, Map<string, AgentTool>>();
1143
+ #acpPermissionWrapperVersion = 0;
975
1144
 
976
1145
  // Compaction state
977
1146
  #compactionAbortController: AbortController | undefined = undefined;
@@ -1009,6 +1178,7 @@ export class AgentSession {
1009
1178
  #evalKernelOwnerId: string;
1010
1179
  /** Idempotent unregister handle for this session's resource-GC registration. */
1011
1180
  #unregisterResourceGc?: () => void;
1181
+ #unregisterRuntimeStateFinalizer?: () => void;
1012
1182
  /**
1013
1183
  * AsyncJobManager owned by this session (top-level only). Subagents leave
1014
1184
  * this undefined and **MUST NOT** dispose the global instance on teardown.
@@ -1032,6 +1202,11 @@ export class AgentSession {
1032
1202
  // Extension system
1033
1203
  #extensionRunner: ExtensionRunner | undefined = undefined;
1034
1204
  #turnIndex = 0;
1205
+ #workerIntegrationScheduler = new WorkerIntegrationRequestScheduler(async () => {
1206
+ await requestSkcWorkerIntegrationAttempt(this.sessionManager.getCwd(), process.env).catch(error => {
1207
+ logger.warn("SKC team worker integration request failed", { error: String(error) });
1208
+ });
1209
+ });
1035
1210
  // First-party internal before-agent-start contributors (not user hooks).
1036
1211
  #beforeAgentStartContributors: BeforeAgentStartContributor[] = [];
1037
1212
 
@@ -1064,6 +1239,10 @@ export class AgentSession {
1064
1239
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1065
1240
  #requestedToolNames: ReadonlySet<string> | undefined;
1066
1241
  #baseSystemPrompt: string[];
1242
+ #initialWorkspaceTree: WorkspaceTree | undefined;
1243
+ /** Throttle cache for the per-turn volatile workspace-tree scan (see #buildVolatileProjectContextMessage). */
1244
+ #cachedWorkspaceTree: WorkspaceTree | undefined;
1245
+ #cachedWorkspaceTreeAt = 0;
1067
1246
  /**
1068
1247
  * Signature of the (toolNames, tool descriptions) tuple passed to the most
1069
1248
  * recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
@@ -1123,23 +1302,46 @@ export class AgentSession {
1123
1302
 
1124
1303
  #streamingEditAbortTriggered = false;
1125
1304
  #streamingEditCheckedLineCounts = new Map<string, number>();
1305
+ #streamingEditToolCallStates = new Map<
1306
+ string,
1307
+ {
1308
+ op?: string;
1309
+ resolvedPath?: string;
1310
+ lastProcessedOffset: number;
1311
+ processedPrefix: string;
1312
+ settledVerdict?: "aborted" | "non-edit" | "non-update";
1313
+ debugProcessedChars: number;
1314
+ debugCheckedRemovedLines: number;
1315
+ debugFullChecks: number;
1316
+ debugGuardRuns: number;
1317
+ }
1318
+ >();
1126
1319
 
1127
1320
  #streamingEditPrecheckedToolCallIds = new Set<string>();
1128
1321
 
1129
1322
  #streamingEditFileCache = new Map<string, string>();
1323
+ readonly streamingEditDebugCounters = {
1324
+ guardRuns: 0,
1325
+ processedChars: 0,
1326
+ checkedRemovedLines: 0,
1327
+ fullChecks: 0,
1328
+ nonEditDeterminations: 0,
1329
+ };
1130
1330
  #promptInFlightCount = 0;
1131
1331
  // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
1132
- // Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
1133
- // checks in #handleAgentEvent) still fire on the original schedule — only the
1134
- // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
1135
- // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
1136
- // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1137
- // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
1138
- // both #endInFlight (normal) and #resetInFlight (abort).
1332
+ // Local subscribers and extension hooks both receive the deferred event from
1333
+ // #flushPendingAgentEnd(), preserving the local-before-extension ordering used
1334
+ // by #emitSessionEvent while still preventing external clients from resuming
1335
+ // before #promptWithMessage's finally decrements #promptInFlightCount. Without
1336
+ // this, a client that resumes on `agent_end` can fire its next `prompt` while
1337
+ // the session still reports busy and hit AgentBusyError. Flushed from both
1338
+ // #endInFlight (normal) and #resetInFlight (abort).
1139
1339
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1140
1340
  #obfuscator: SecretObfuscator | undefined;
1141
1341
  #checkpointState: CheckpointState | undefined = undefined;
1142
1342
  #providerReplaySourceCache = new WeakMap<AgentMessage, ProviderReplaySourceCacheEntry>();
1343
+ #lastOversizedAutoMaintenanceAttemptSignature: string | undefined = undefined;
1344
+
1143
1345
  #pendingRewindReport: string | undefined = undefined;
1144
1346
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1145
1347
  #promptGeneration = 0;
@@ -1218,6 +1420,7 @@ export class AgentSession {
1218
1420
  if (!pending) return;
1219
1421
  this.#pendingAgentEndEmit = undefined;
1220
1422
  this.#emit(pending);
1423
+ void this.#queueExtensionEvent(pending);
1221
1424
  }
1222
1425
 
1223
1426
  constructor(config: AgentSessionConfig) {
@@ -1234,6 +1437,11 @@ export class AgentSession {
1234
1437
  settings: this.settings,
1235
1438
  });
1236
1439
  }
1440
+ this.#unregisterRuntimeStateFinalizer = registerCoordinatorRuntimeStateFinalizer({
1441
+ sessionId: this.sessionId,
1442
+ cwd: this.sessionManager.getCwd(),
1443
+ sessionFile: this.sessionManager.getSessionFile(),
1444
+ });
1237
1445
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
1238
1446
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1239
1447
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
@@ -1306,6 +1514,7 @@ export class AgentSession {
1306
1514
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
1307
1515
  this.#reloadSshTool = config.reloadSshTool;
1308
1516
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1517
+ this.#initialWorkspaceTree = config.workspaceTree;
1309
1518
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
1310
1519
  this.#discoverableToolAllowedNames = config.discoverableToolAllowedNames
1311
1520
  ? new Set(config.discoverableToolAllowedNames.map(name => name.toLowerCase()))
@@ -1336,16 +1545,6 @@ export class AgentSession {
1336
1545
  this.#agentRegistry = config.agentRegistry;
1337
1546
  this.#providerSessionId = config.providerSessionId;
1338
1547
  this.#providerCacheSessionId = config.providerCacheSessionId;
1339
- this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1340
- const event: AgentEvent = {
1341
- type: "message_update",
1342
- message,
1343
- assistantMessageEvent,
1344
- };
1345
- const generation = this.#promptGeneration;
1346
- this.#preCacheStreamingEditFile(event);
1347
- this.#maybeAbortStreamingEdit(event, generation);
1348
- });
1349
1548
  // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
1350
1549
  this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
1351
1550
  this.agent.providerSessionState = this.#providerSessionState;
@@ -1493,13 +1692,30 @@ export class AgentSession {
1493
1692
  }
1494
1693
 
1495
1694
  async buildForkContextSeed(options: ForkContextSeedOptions): Promise<ForkContextSeed> {
1695
+ const maxMessages = Math.min(500, Math.max(0, Math.trunc(options.maxMessages)));
1696
+ const maxTokens = Math.max(0, Math.trunc(options.maxTokens));
1697
+ if (maxMessages <= 0 || maxTokens <= 0) {
1698
+ return {
1699
+ messages: [],
1700
+ agentMessages: [],
1701
+ metadata: {
1702
+ sourceSessionId: this.sessionId,
1703
+ parentMessageCount: this.messages.length,
1704
+ includedMessages: 0,
1705
+ skippedMessages: 0,
1706
+ approximateTokens: 0,
1707
+ maxMessages,
1708
+ maxTokens,
1709
+ skippedReasons: {},
1710
+ },
1711
+ cacheIdentity: options.cacheIdentity ?? this.sessionId,
1712
+ };
1713
+ }
1496
1714
  const transformedMessages = await this.#transformContext([...this.messages], options.signal);
1497
1715
  const convertedMessages = await this.#convertToLlm(transformedMessages);
1498
1716
  const providerMessages = this.model
1499
1717
  ? normalizeMessagesForProvider(convertedMessages, this.model)
1500
1718
  : convertedMessages;
1501
- const maxMessages = Math.min(500, Math.max(0, Math.trunc(options.maxMessages)));
1502
- const maxTokens = Math.max(0, Math.trunc(options.maxTokens));
1503
1719
  const selected: Message[] = [];
1504
1720
  const skippedReasons: Record<string, number> = {};
1505
1721
  let skippedMessages = 0;
@@ -1523,10 +1739,9 @@ export class AgentSession {
1523
1739
  recordSkip("unsupported-role");
1524
1740
  return undefined;
1525
1741
  }
1526
- const cloned = cloneJsonValueForForkSeed(message) as Message;
1527
- if ("providerPayload" in cloned) {
1528
- delete (cloned as { providerPayload?: unknown }).providerPayload;
1529
- }
1742
+ const messageWithoutProviderPayload = { ...message } as Message & { providerPayload?: unknown };
1743
+ delete messageWithoutProviderPayload.providerPayload;
1744
+ const cloned = cloneJsonValueForForkSeed(messageWithoutProviderPayload) as Message;
1530
1745
  if (Array.isArray(cloned.content)) {
1531
1746
  const sanitizedContent: TextContent[] = [];
1532
1747
  for (const block of cloned.content) {
@@ -1538,11 +1753,42 @@ export class AgentSession {
1538
1753
  recordSkip(`unsupported-content-${block.type}`);
1539
1754
  }
1540
1755
  }
1756
+ if (sanitizedContent.length === 0) {
1757
+ recordSkip("empty-content");
1758
+ return undefined;
1759
+ }
1541
1760
  return { ...cloned, content: sanitizedContent } as Message;
1542
1761
  }
1543
1762
  return cloned;
1544
1763
  };
1545
1764
 
1765
+ const truncateMessageToTokenBudget = (message: Message): { message: Message; tokens: number } => {
1766
+ const notice = `\n\n[fork-context seed: newest message truncated to fit the ${maxTokens}-token budget]`;
1767
+ const contentText = Array.isArray(message.content)
1768
+ ? message.content.map(block => (block.type === "text" ? block.text : "")).join("\n\n")
1769
+ : String(message.content ?? "");
1770
+ let low = 0;
1771
+ let high = contentText.length;
1772
+ let bestMessage: Message = { ...message, content: [{ type: "text", text: notice.trimStart() }] };
1773
+ let bestTokens = estimateMessageTokensHeuristic(bestMessage);
1774
+ while (low <= high) {
1775
+ const mid = Math.floor((low + high) / 2);
1776
+ const candidate: Message = {
1777
+ ...message,
1778
+ content: [{ type: "text", text: `${contentText.slice(0, mid)}${notice}` }],
1779
+ };
1780
+ const candidateTokens = estimateMessageTokensHeuristic(candidate);
1781
+ if (candidateTokens <= maxTokens) {
1782
+ bestMessage = candidate;
1783
+ bestTokens = candidateTokens;
1784
+ low = mid + 1;
1785
+ } else {
1786
+ high = mid - 1;
1787
+ }
1788
+ }
1789
+ return { message: bestMessage, tokens: Math.min(bestTokens, maxTokens) };
1790
+ };
1791
+
1546
1792
  for (let i = providerMessages.length - 1; i >= 0; i--) {
1547
1793
  if (selected.length >= maxMessages) {
1548
1794
  recordSkip("message-limit");
@@ -1551,9 +1797,20 @@ export class AgentSession {
1551
1797
  const sanitized = sanitizeMessage(providerMessages[i]!);
1552
1798
  if (!sanitized) continue;
1553
1799
  const messageTokens = estimateMessageTokensHeuristic(sanitized);
1554
- if (maxTokens > 0 && approximateTokens + messageTokens > maxTokens) {
1800
+ // Stop at the first message that overflows the token budget. The loop walks
1801
+ // newest→oldest, so `break` keeps the seed a CONTIGUOUS run of the most recent
1802
+ // messages. `continue` here would skip the oversized recent message and scavenge
1803
+ // smaller OLDER ones, yielding a non-contiguous seed that misrepresents the
1804
+ // conversation and violates the recency contract of the receipt/last-turn/bounded modes.
1805
+ if (approximateTokens + messageTokens > maxTokens) {
1555
1806
  recordSkip("token-limit");
1556
- continue;
1807
+ if (selected.length === 0) {
1808
+ const truncated = truncateMessageToTokenBudget(sanitized);
1809
+ selected.unshift(truncated.message);
1810
+ approximateTokens = truncated.tokens;
1811
+ skippedReasons["newest-message-truncated"] = (skippedReasons["newest-message-truncated"] ?? 0) + 1;
1812
+ }
1813
+ break;
1557
1814
  }
1558
1815
  selected.unshift(sanitized);
1559
1816
  approximateTokens += messageTokens;
@@ -1635,6 +1892,9 @@ export class AgentSession {
1635
1892
  return entry;
1636
1893
  }
1637
1894
 
1895
+ #queuedMessageEditId(mode: QueuedMessageEditMode, sequence: number): string {
1896
+ return `${mode}:${sequence}`;
1897
+ }
1638
1898
  /** Register a compact display string for a custom message that the caller is
1639
1899
  * about to dispatch via `promptCustomMessage` / `sendCustomMessage`.
1640
1900
  * Returns a stable tag the caller MUST embed in
@@ -1704,15 +1964,23 @@ export class AgentSession {
1704
1964
  manager.cancelAll({ ownerId: this.#agentId });
1705
1965
  }
1706
1966
 
1967
+ #suppressOwnAsyncJobDeliveries(): void {
1968
+ if (!this.#agentId) return;
1969
+ const manager = AsyncJobManager.instance();
1970
+ if (!manager) return;
1971
+ const pendingJobIds = manager.getDeliveryState({ ownerId: this.#agentId }).pendingJobIds;
1972
+ if (pendingJobIds.length > 0) {
1973
+ manager.acknowledgeDeliveries(pendingJobIds);
1974
+ }
1975
+ }
1976
+
1707
1977
  // =========================================================================
1708
1978
  // Event Subscription
1709
1979
  // =========================================================================
1710
1980
 
1711
1981
  /** Emit an event to all listeners */
1712
1982
  #emit(event: AgentSessionEvent): void {
1713
- // Copy array before iteration to avoid mutation during iteration
1714
- const listeners = [...this.#eventListeners];
1715
- for (const l of listeners) {
1983
+ for (const l of this.#eventListenerSnapshot) {
1716
1984
  l(event);
1717
1985
  }
1718
1986
  }
@@ -1742,17 +2010,22 @@ export class AgentSession {
1742
2010
  }
1743
2011
 
1744
2012
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
1745
- await persistCoordinatorRuntimeStateFromEvent(event, {
1746
- sessionId: this.sessionId,
1747
- cwd: this.sessionManager.getCwd(),
1748
- sessionFile: this.sessionManager.getSessionFile(),
1749
- });
1750
2013
  if (event.type === "message_update") {
2014
+ // Fast path: message_update maps to no sidecar state, so we must not
2015
+ // build the persistRuntimeState closure here (per-token hot path).
1751
2016
  this.#emit(event);
1752
- void this.#queueExtensionEvent(event);
2017
+ if (this.#hasStreamingExtensionHandlers()) {
2018
+ __agentSessionPerfCounters.messageUpdateExtensionQueues += 1;
2019
+ void this.#queueExtensionEvent(event);
2020
+ }
1753
2021
  return;
1754
2022
  }
1755
- await this.#emitExtensionEvent(event);
2023
+ const persistRuntimeState = () =>
2024
+ persistCoordinatorRuntimeStateFromEvent(event, {
2025
+ sessionId: this.sessionId,
2026
+ cwd: this.sessionManager.getCwd(),
2027
+ sessionFile: this.sessionManager.getSessionFile(),
2028
+ });
1756
2029
  // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
1757
2030
  // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
1758
2031
  // emitting while #promptInFlightCount > 0 lets a client fire its next
@@ -1762,10 +2035,18 @@ export class AgentSession {
1762
2035
  // supersedes the pending one, which is what subscribers want — they only
1763
2036
  // care about the final settle.
1764
2037
  if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
2038
+ void persistRuntimeState();
1765
2039
  this.#pendingAgentEndEmit = event;
1766
2040
  return;
1767
2041
  }
2042
+
2043
+ // Local subscribers are part of the AgentSession control path: retryNow(),
2044
+ // auto-continuation gates, goal reminders, and tests all observe these events
2045
+ // synchronously. Coordinator sidecar writes and extension hooks are secondary
2046
+ // sinks, so they must not delay or suppress local delivery.
1768
2047
  this.#emit(event);
2048
+ void persistRuntimeState();
2049
+ await this.#emitExtensionEvent(event);
1769
2050
  }
1770
2051
 
1771
2052
  // Track last assistant message for auto-compaction check
@@ -1851,7 +2132,7 @@ export class AgentSession {
1851
2132
  }
1852
2133
  }
1853
2134
 
1854
- if (event.type === "turn_start") {
2135
+ if (event.type === "turn_start" && this.#goalRuntime.shouldTrackTurnBaseline()) {
1855
2136
  const usage = this.getSessionStats().tokens;
1856
2137
  this.#goalRuntime.onTurnStart(`turn-${++this.#goalTurnCounter}`, {
1857
2138
  input: usage.input,
@@ -2603,8 +2884,10 @@ export class AgentSession {
2603
2884
  // The TTSR manager was already claimed at bucket time; only persistence remains.
2604
2885
  const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
2605
2886
  if (ruleNames.length > 0) {
2606
- this.sessionManager.appendTtsrInjection(ruleNames);
2887
+ const records = this.#ttsrManager?.getInjectedRecords().filter(record => ruleNames.includes(record.name));
2888
+ this.sessionManager.appendTtsrInjection(ruleNames, records, this.#ttsrManager?.getMessageCount());
2607
2889
  }
2890
+
2608
2891
  return {
2609
2892
  content: [{ type: "text", text: reminder }, ...ctx.result.content],
2610
2893
  };
@@ -2629,7 +2912,8 @@ export class AgentSession {
2629
2912
  return;
2630
2913
  }
2631
2914
  this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
2632
- this.sessionManager.appendTtsrInjection(uniqueRuleNames);
2915
+ const records = this.#ttsrManager?.getInjectedRecords().filter(record => uniqueRuleNames.includes(record.name));
2916
+ this.sessionManager.appendTtsrInjection(uniqueRuleNames, records, this.#ttsrManager?.getMessageCount());
2633
2917
  }
2634
2918
 
2635
2919
  #findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
@@ -2813,55 +3097,16 @@ export class AgentSession {
2813
3097
  #resetStreamingEditState(): void {
2814
3098
  this.#streamingEditAbortTriggered = false;
2815
3099
  this.#streamingEditCheckedLineCounts.clear();
3100
+ this.#streamingEditToolCallStates.clear();
2816
3101
  this.#streamingEditPrecheckedToolCallIds.clear();
3102
+ this.#streamingEditParsedToolCallCache.clear();
2817
3103
  this.#streamingEditFileCache.clear();
2818
3104
  }
2819
3105
 
2820
- #getStreamingEditToolCall(event: AgentEvent):
2821
- | {
2822
- toolCall: ToolCall;
2823
- path: string;
2824
- resolvedPath: string;
2825
- diff?: string;
2826
- op?: string;
2827
- rename?: string;
2828
- }
2829
- | undefined {
2830
- if (event.type !== "message_update") return undefined;
2831
- if (event.message.role !== "assistant") return undefined;
2832
-
2833
- const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
2834
- const messageContent = event.message.content;
2835
- if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) {
2836
- return undefined;
2837
- }
2838
-
2839
- const toolCall = messageContent[contentIndex] as ToolCall;
2840
- if (toolCall.name !== "edit") return undefined;
2841
-
2842
- const args = toolCall.arguments;
2843
- if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
2844
- if ("old_text" in args || "new_text" in args) return undefined;
2845
-
2846
- const path = typeof args.path === "string" ? args.path : undefined;
2847
- if (!path) return undefined;
2848
-
2849
- // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
2850
- // on-disk artifacts path; pre-caching works as long as we ask the
2851
- // local-protocol handler. Other internal-scheme URLs have no stable filesystem representation;
2852
- // skip pre-cache entirely for those — the edit tool itself will reject
2853
- // them through its normal dispatch path.
2854
- const resolvedPath = this.#resolveSessionFsPath(path);
2855
- if (resolvedPath === undefined) return undefined;
2856
-
2857
- return {
2858
- toolCall,
2859
- path,
2860
- resolvedPath,
2861
- diff: typeof args.diff === "string" ? args.diff : undefined,
2862
- op: typeof args.op === "string" ? args.op : undefined,
2863
- rename: typeof args.rename === "string" ? args.rename : undefined,
2864
- };
3106
+ #getStreamingEditToolCall(event: AgentEvent): StreamingEditParsedToolCall | undefined {
3107
+ return getStreamingEditToolCallForEvent(event, this.#streamingEditParsedToolCallCache, filePath =>
3108
+ this.#resolveSessionFsPath(filePath),
3109
+ );
2865
3110
  }
2866
3111
 
2867
3112
  #lastStreamingEditToolCallId: string | undefined;
@@ -2980,36 +3225,122 @@ export class AgentSession {
2980
3225
  const assistantEvent = event.assistantMessageEvent;
2981
3226
  if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
2982
3227
 
3228
+ const contentIndex = assistantEvent.contentIndex ?? 0;
3229
+ const messageContent = event.message.role === "assistant" ? event.message.content : undefined;
3230
+ const candidateToolCall = Array.isArray(messageContent)
3231
+ ? (messageContent[contentIndex] as ToolCall | undefined)
3232
+ : undefined;
3233
+ const candidateToolCallId = candidateToolCall?.type === "toolCall" ? candidateToolCall.id : undefined;
3234
+ if (candidateToolCallId) {
3235
+ const cached = this.#streamingEditToolCallStates.get(candidateToolCallId);
3236
+ if (
3237
+ cached?.settledVerdict === "aborted" ||
3238
+ cached?.settledVerdict === "non-edit" ||
3239
+ cached?.settledVerdict === "non-update"
3240
+ ) {
3241
+ return;
3242
+ }
3243
+ }
3244
+
2983
3245
  const streamingEdit = this.#getStreamingEditToolCall(event);
2984
- if (!streamingEdit?.toolCall.id) return;
3246
+ if (!streamingEdit?.toolCall.id) {
3247
+ if (candidateToolCallId) {
3248
+ this.#streamingEditToolCallStates.set(candidateToolCallId, {
3249
+ lastProcessedOffset: 0,
3250
+ processedPrefix: "",
3251
+ settledVerdict: "non-edit",
3252
+ debugProcessedChars: 0,
3253
+ debugCheckedRemovedLines: 0,
3254
+ debugFullChecks: 0,
3255
+ debugGuardRuns: 0,
3256
+ });
3257
+ this.streamingEditDebugCounters.nonEditDeterminations += 1;
3258
+ }
3259
+ return;
3260
+ }
2985
3261
 
2986
3262
  const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit;
3263
+ let state = this.#streamingEditToolCallStates.get(toolCall.id);
3264
+ if (!state) {
3265
+ state = {
3266
+ op,
3267
+ resolvedPath,
3268
+ lastProcessedOffset: 0,
3269
+ processedPrefix: "",
3270
+ debugProcessedChars: 0,
3271
+ debugCheckedRemovedLines: 0,
3272
+ debugFullChecks: 0,
3273
+ debugGuardRuns: 0,
3274
+ };
3275
+ this.#streamingEditToolCallStates.set(toolCall.id, state);
3276
+ } else {
3277
+ state.op = op;
3278
+ state.resolvedPath = resolvedPath;
3279
+ }
3280
+ state.debugGuardRuns += 1;
3281
+ this.streamingEditDebugCounters.guardRuns += 1;
3282
+
3283
+ if (op && op !== "update") {
3284
+ state.settledVerdict = "non-update";
3285
+ return;
3286
+ }
2987
3287
  if (!diff) return;
2988
- if (op && op !== "update") return;
2989
3288
 
2990
- if (!diff.includes("\n")) return;
2991
3289
  const lastNewlineIndex = diff.lastIndexOf("\n");
2992
3290
  if (lastNewlineIndex < 0) return;
2993
- const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
2994
- if (diffForCheck.trim().length === 0) return;
3291
+ const completeDiff = diff.slice(0, lastNewlineIndex + 1);
3292
+ if (completeDiff.trim().length === 0) return;
3293
+
3294
+ let diffForCheck = completeDiff;
3295
+ let fullCheck = false;
3296
+ // Obfuscated diffs are intentionally checked through the full path because
3297
+ // deobfuscation is not proven chunk-composable across streaming deltas.
3298
+ if (this.#obfuscator) {
3299
+ fullCheck = true;
3300
+ } else if (completeDiff.length < state.lastProcessedOffset || !completeDiff.startsWith(state.processedPrefix)) {
3301
+ fullCheck = true;
3302
+ } else {
3303
+ diffForCheck = completeDiff.slice(state.lastProcessedOffset);
3304
+ }
3305
+ if (!diffForCheck) return;
2995
3306
 
2996
3307
  let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
2997
3308
  if (!normalizedDiff) return;
2998
- // Deobfuscate the diff so removed lines match real file content
2999
3309
  if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
3000
3310
  if (!normalizedDiff) return;
3001
3311
  const lines = normalizedDiff.split("\n");
3002
3312
  const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
3003
- if (!hasChangeLine) return;
3313
+ if (!hasChangeLine) {
3314
+ if (!fullCheck) {
3315
+ state.lastProcessedOffset = completeDiff.length;
3316
+ state.processedPrefix = completeDiff;
3317
+ }
3318
+ return;
3319
+ }
3320
+
3321
+ if (fullCheck) {
3322
+ state.debugFullChecks += 1;
3323
+ this.streamingEditDebugCounters.fullChecks += 1;
3324
+ }
3325
+ state.debugProcessedChars += diffForCheck.length;
3326
+ this.streamingEditDebugCounters.processedChars += diffForCheck.length;
3004
3327
 
3005
3328
  const lineCount = lines.length;
3006
- const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
3007
- if (lastChecked !== undefined && lineCount <= lastChecked) return;
3008
3329
  this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
3009
3330
 
3010
3331
  const removedLines = lines
3011
3332
  .filter(line => line.startsWith("-") && !line.startsWith("--- "))
3012
3333
  .map(line => line.slice(1));
3334
+ state.debugCheckedRemovedLines += removedLines.length;
3335
+ this.streamingEditDebugCounters.checkedRemovedLines += removedLines.length;
3336
+ if (!fullCheck) {
3337
+ state.lastProcessedOffset = completeDiff.length;
3338
+ state.processedPrefix = completeDiff;
3339
+ } else if (!this.#obfuscator) {
3340
+ state.lastProcessedOffset = completeDiff.length;
3341
+ state.processedPrefix = completeDiff;
3342
+ }
3343
+
3013
3344
  if (removedLines.length > 0) {
3014
3345
  let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
3015
3346
  if (cachedContent === undefined) {
@@ -3020,6 +3351,7 @@ export class AgentSession {
3020
3351
  const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
3021
3352
  if (missing) {
3022
3353
  this.#streamingEditAbortTriggered = true;
3354
+ state.settledVerdict = "aborted";
3023
3355
  logger.warn("Streaming edit aborted due to patch preview failure", {
3024
3356
  toolCallId: toolCall.id,
3025
3357
  path,
@@ -3099,12 +3431,21 @@ export class AgentSession {
3099
3431
  }
3100
3432
  }
3101
3433
 
3434
+ #requestWorkerIntegrationAttempt(): void {
3435
+ this.#workerIntegrationScheduler.enqueue();
3436
+ }
3437
+
3438
+ async #flushWorkerIntegrationAttempt(): Promise<void> {
3439
+ await this.#workerIntegrationScheduler.flush();
3440
+ }
3441
+
3102
3442
  /** Emit extension events based on session events */
3103
3443
  async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
3104
3444
  if (event.type === "turn_end") {
3105
- await requestSkcWorkerIntegrationAttempt(this.sessionManager.getCwd(), process.env).catch(error => {
3106
- logger.warn("SKC team worker integration request failed", { error: String(error) });
3107
- });
3445
+ this.#requestWorkerIntegrationAttempt();
3446
+ }
3447
+ if (event.type === "agent_end") {
3448
+ await this.#flushWorkerIntegrationAttempt();
3108
3449
  }
3109
3450
  if (!this.#extensionRunner) return;
3110
3451
  if (event.type === "agent_start") {
@@ -3217,11 +3558,15 @@ export class AgentSession {
3217
3558
  maxAttempts: event.maxAttempts,
3218
3559
  });
3219
3560
  } else if (event.type === "goal_updated") {
3220
- await this.#extensionRunner.emit({
3221
- type: "goal_updated",
3222
- goal: event.goal,
3223
- state: event.state,
3224
- });
3561
+ try {
3562
+ await this.#extensionRunner.emit({
3563
+ type: "goal_updated",
3564
+ goal: event.goal,
3565
+ state: event.state,
3566
+ });
3567
+ } catch (error) {
3568
+ logger.warn("Goal updated extension hook failed", { error: String(error) });
3569
+ }
3225
3570
  }
3226
3571
  }
3227
3572
 
@@ -3232,12 +3577,14 @@ export class AgentSession {
3232
3577
  */
3233
3578
  subscribe(listener: AgentSessionEventListener): () => void {
3234
3579
  this.#eventListeners.push(listener);
3580
+ this.#rebuildEventListenerSnapshot();
3235
3581
 
3236
3582
  // Return unsubscribe function for this specific listener
3237
3583
  return () => {
3238
3584
  const index = this.#eventListeners.indexOf(listener);
3239
3585
  if (index !== -1) {
3240
3586
  this.#eventListeners.splice(index, 1);
3587
+ this.#rebuildEventListenerSnapshot();
3241
3588
  }
3242
3589
  };
3243
3590
  }
@@ -3314,6 +3661,7 @@ export class AgentSession {
3314
3661
  } catch (error) {
3315
3662
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
3316
3663
  }
3664
+ await this.#flushWorkerIntegrationAttempt();
3317
3665
  await this.#cancelPostPromptTasks();
3318
3666
  // Cancel jobs this agent registered so a subagent's teardown doesn't
3319
3667
  // leak its background bash/task work into the parent's manager. Only
@@ -3349,6 +3697,8 @@ export class AgentSession {
3349
3697
  // No-op when this session opened no tabs. Failure is logged, not thrown.
3350
3698
  this.#unregisterResourceGc?.();
3351
3699
  this.#unregisterResourceGc = undefined;
3700
+ this.#unregisterRuntimeStateFinalizer?.();
3701
+ this.#unregisterRuntimeStateFinalizer = undefined;
3352
3702
  await releaseTabsForOwner(this.sessionManager.getSessionId()).catch((error: unknown) =>
3353
3703
  logger.warn("session dispose: releaseTabsForOwner failed", { error }),
3354
3704
  );
@@ -3372,6 +3722,7 @@ export class AgentSession {
3372
3722
  this.#unsubscribeAppendOnly = undefined;
3373
3723
  }
3374
3724
  this.#eventListeners = [];
3725
+ this.#rebuildEventListenerSnapshot();
3375
3726
  }
3376
3727
 
3377
3728
  /**
@@ -3905,7 +4256,7 @@ export class AgentSession {
3905
4256
  * prompts or tool execution can run.
3906
4257
  */
3907
4258
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3908
- if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
4259
+ if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3909
4260
  return new Proxy(tool, {
3910
4261
  get: (target, prop) => {
3911
4262
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -3928,10 +4279,42 @@ export class AgentSession {
3928
4279
  }) as T;
3929
4280
  }
3930
4281
 
4282
+ #guardedToolWrapperCacheKey(): string {
4283
+ const bridge = this.#clientBridge;
4284
+ const acpEnabled = Boolean(bridge?.capabilities.requestPermission && bridge.requestPermission);
4285
+ const activeSkill = this.#activeSkillState?.skill ?? "";
4286
+ const activeSkillSession = this.#activeSkillState?.sessionId ?? "";
4287
+ return [
4288
+ "deep-interview-mutation-v1",
4289
+ "ultragoal-ask-v1",
4290
+ `active=${activeSkill}:${activeSkillSession}`,
4291
+ `acp=${acpEnabled ? "on" : "off"}:${this.#acpPermissionWrapperVersion}`,
4292
+ ].join("|");
4293
+ }
4294
+
3931
4295
  #prepareToolForExecution<T extends AgentTool>(tool: T): T {
3932
- return this.#wrapToolForDeepInterviewMutationGuard(
3933
- this.#wrapToolForAcpPermission(guardToolForUltragoalAsk(tool, () => this.sessionManager.getCwd())),
4296
+ const cacheKey = this.#guardedToolWrapperCacheKey();
4297
+ let wrappersByVersion = this.#guardedToolWrapperCache.get(tool);
4298
+ const cached = wrappersByVersion?.get(cacheKey);
4299
+ if (cached) return cached as T;
4300
+ const wrapped = this.#wrapToolForDeepInterviewMutationGuard(
4301
+ this.#wrapToolForAcpPermission(
4302
+ guardToolForUltragoalAsk(
4303
+ tool,
4304
+ () => this.sessionManager.getCwd(),
4305
+ () => ({
4306
+ activeSkillState: this.getActiveSkillState(),
4307
+ sessionId: this.sessionManager.getSessionId(),
4308
+ }),
4309
+ ),
4310
+ ),
3934
4311
  );
4312
+ if (!wrappersByVersion) {
4313
+ wrappersByVersion = new Map();
4314
+ this.#guardedToolWrapperCache.set(tool, wrappersByVersion);
4315
+ }
4316
+ wrappersByVersion.set(cacheKey, wrapped);
4317
+ return wrapped;
3935
4318
  }
3936
4319
 
3937
4320
  #setGuardedAgentTools(tools: AgentTool[]): void {
@@ -4126,11 +4509,15 @@ export class AgentSession {
4126
4509
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
4127
4510
  * after side-effecting changes; see e.g. the memory hooks and
4128
4511
  * `#syncEditToolModeAfterModelChange`.
4129
- *
4130
- * The current calendar date IS covered (appended as a segment) because
4131
- * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
4132
- * Without this, a session spanning midnight with only tool-stable MCP
4133
- * reconnects would keep yesterday's date indefinitely.
4512
+ * Volatile per-turn facts (current date, cwd, and mtime-sorted workspace tree
4513
+ * RENDERING) are intentionally NOT covered. They are delivered as user-role
4514
+ * context by `#buildVolatileProjectContextMessage()`, outside the provider
4515
+ * cached system prefix, so rollover/touched-file changes must not force a
4516
+ * stable prompt rebuild. Note the AGENTS.md file LIST (`agentsMdFiles`, sorted
4517
+ * by name) surfaced by the same workspace scan is still a stable-prefix input
4518
+ * via project-prompt's `<dir-context>`; adding/removing an AGENTS.md is an
4519
+ * intended instruction change, and name-sorting means touched files do not
4520
+ * perturb it.
4134
4521
  */
4135
4522
  #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
4136
4523
  // Order-preserving join: any reorder must produce a different signature so
@@ -4161,8 +4548,7 @@ export class AgentSession {
4161
4548
  entries.sort();
4162
4549
  instructionsSegment = entries.join("\u0006");
4163
4550
  }
4164
- const date = new Date().toISOString().slice(0, 10);
4165
- return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
4551
+ return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}`;
4166
4552
  }
4167
4553
 
4168
4554
  /**
@@ -4588,6 +4974,7 @@ export class AgentSession {
4588
4974
  setClientBridge(bridge: ClientBridge | undefined): void {
4589
4975
  this.#clientBridge = bridge;
4590
4976
  this.#acpPermissionDecisions.clear();
4977
+ this.#acpPermissionWrapperVersion++;
4591
4978
  const activeToolNames = this.getActiveToolNames();
4592
4979
  const activeTools = activeToolNames
4593
4980
  .map(name => this.#toolRegistry.get(name))
@@ -4782,6 +5169,94 @@ export class AgentSession {
4782
5169
  };
4783
5170
  }
4784
5171
 
5172
+ async #buildAutomaticPlanModeMessage(): Promise<CustomMessage | null> {
5173
+ const state = this.#planModeState;
5174
+ if (!state?.enabled) {
5175
+ this.#lastInjectedPlanContextSig = undefined;
5176
+ return null;
5177
+ }
5178
+ const message = await this.#buildPlanModeMessage();
5179
+ if (!message) {
5180
+ this.#lastInjectedPlanContextSig = undefined;
5181
+ return null;
5182
+ }
5183
+ const content = typeof message.content === "string" ? message.content : "";
5184
+ const signature = buildContextInjectionSignature("plan-mode-context", [
5185
+ "enabled",
5186
+ state.planFilePath,
5187
+ state.workflow ?? "",
5188
+ content,
5189
+ ]);
5190
+ if (signature === this.#lastInjectedPlanContextSig) return null;
5191
+ this.#lastInjectedPlanContextSig = signature;
5192
+ return message;
5193
+ }
5194
+
5195
+ #buildAutomaticGoalModeMessage(): CustomMessage | null {
5196
+ const state = this.#goalModeState;
5197
+ if (!state?.enabled || state.goal.status !== "active") {
5198
+ this.#lastInjectedGoalContextSig = undefined;
5199
+ return null;
5200
+ }
5201
+ const message = this.#buildGoalModeMessage();
5202
+ if (!message) {
5203
+ this.#lastInjectedGoalContextSig = undefined;
5204
+ return null;
5205
+ }
5206
+ const content = typeof message.content === "string" ? message.content : "";
5207
+ const signature = buildContextInjectionSignature("goal-mode-context", ["enabled", state.goal.id, content]);
5208
+ if (signature === this.#lastInjectedGoalContextSig) return null;
5209
+ this.#lastInjectedGoalContextSig = signature;
5210
+ return message;
5211
+ }
5212
+
5213
+ /**
5214
+ * Clear the goal/plan static-once injection signatures so the next prompt
5215
+ * re-injects the active mode context once. MUST be called whenever the live
5216
+ * message set is rebuilt in a way that can evict a previously injected
5217
+ * goal-mode-context/plan-mode-context copy (compaction/pruning/handoff
5218
+ * replaceMessages) or when a signature was consumed but the message was not
5219
+ * delivered (prompt-generation abort). Forward-omission relies on the injected
5220
+ * copy surviving in context; this guards that invariant.
5221
+ */
5222
+ #resetInjectedContextSignatures(): void {
5223
+ this.#lastInjectedPlanContextSig = undefined;
5224
+ this.#lastInjectedGoalContextSig = undefined;
5225
+ }
5226
+
5227
+ async #buildVolatileProjectContextMessage(): Promise<CustomMessage> {
5228
+ const cwd = this.sessionManager.getCwd();
5229
+ // Date + cwd are refreshed every turn (cheap). The mtime-sorted workspace
5230
+ // tree is expensive to scan and large to carry, so throttle it: rebuild at
5231
+ // most once per VOLATILE_TREE_TTL_MS and only embed the tree block on turns
5232
+ // where the scan actually refreshed. This bounds both the per-turn IO cost
5233
+ // and the accumulation of stale tree copies in history, while keeping the
5234
+ // content outside the cached system prefix.
5235
+ let includeTree: WorkspaceTree | undefined;
5236
+ if (this.#initialWorkspaceTree) {
5237
+ this.#cachedWorkspaceTree = this.#initialWorkspaceTree;
5238
+ this.#cachedWorkspaceTreeAt = Date.now();
5239
+ this.#initialWorkspaceTree = undefined;
5240
+ includeTree = this.#cachedWorkspaceTree;
5241
+ } else if (Date.now() - this.#cachedWorkspaceTreeAt >= VOLATILE_TREE_TTL_MS) {
5242
+ try {
5243
+ this.#cachedWorkspaceTree = await buildWorkspaceTree(cwd, { timeoutMs: 5000 });
5244
+ } catch {
5245
+ this.#cachedWorkspaceTree = undefined;
5246
+ }
5247
+ this.#cachedWorkspaceTreeAt = Date.now();
5248
+ includeTree = this.#cachedWorkspaceTree;
5249
+ }
5250
+ return {
5251
+ role: "custom",
5252
+ customType: "volatile-project-context",
5253
+ content: buildVolatileProjectContext({ cwd, workspaceTree: includeTree }),
5254
+ display: false,
5255
+ attribution: "agent",
5256
+ timestamp: Date.now(),
5257
+ };
5258
+ }
5259
+
4785
5260
  /**
4786
5261
  * Send a prompt to the agent.
4787
5262
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -5032,14 +5507,16 @@ export class AgentSession {
5032
5507
  if (planReferenceMessage) {
5033
5508
  messages.push(planReferenceMessage);
5034
5509
  }
5035
- const planModeMessage = await this.#buildPlanModeMessage();
5510
+ const planModeMessage = await this.#buildAutomaticPlanModeMessage();
5036
5511
  if (planModeMessage) {
5037
5512
  messages.push(planModeMessage);
5038
5513
  }
5039
- const goalModeMessage = this.#buildGoalModeMessage();
5514
+ const goalModeMessage = this.#buildAutomaticGoalModeMessage();
5040
5515
  if (goalModeMessage) {
5041
5516
  messages.push(goalModeMessage);
5042
5517
  }
5518
+ const volatileProjectContextMessage = await this.#buildVolatileProjectContextMessage();
5519
+ messages.push(volatileProjectContextMessage);
5043
5520
  if (options?.prependMessages) {
5044
5521
  messages.push(...options.prependMessages);
5045
5522
  }
@@ -5049,6 +5526,7 @@ export class AgentSession {
5049
5526
  // Early bail-out: if a newer abort/prompt cycle started during setup,
5050
5527
  // return before mutating shared state (nextTurn messages, system prompt).
5051
5528
  if (this.#promptGeneration !== generation) {
5529
+ this.#resetInjectedContextSignatures();
5052
5530
  return;
5053
5531
  }
5054
5532
 
@@ -5061,9 +5539,28 @@ export class AgentSession {
5061
5539
  // Auto-read @filepath mentions
5062
5540
  const fileMentions = extractFileMentions(expandedText);
5063
5541
  if (fileMentions.length > 0) {
5064
- const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
5542
+ const cwd = this.sessionManager.getCwd();
5543
+ // Collect resolved paths already shown (read or mentioned) in the recent
5544
+ // window so a repeat @mention emits a compact note instead of the full body.
5545
+ const RECENT_MENTION_WINDOW = 40;
5546
+ const recentlyShownPaths = new Set<string>();
5547
+ for (const entry of this.sessionManager.getBranch().slice(-RECENT_MENTION_WINDOW)) {
5548
+ if (entry.type !== "message") continue;
5549
+ const msg = entry.message;
5550
+ if (msg.role === "fileMention") {
5551
+ for (const file of msg.files) {
5552
+ if (!file.duplicate && !file.pruned) recentlyShownPaths.add(resolveReadPath(file.path, cwd));
5553
+ }
5554
+ } else if (msg.role === "toolResult") {
5555
+ const resolved = (msg.details as { resolvedPath?: unknown } | undefined)?.resolvedPath;
5556
+ if (typeof resolved === "string" && resolved) recentlyShownPaths.add(resolveReadPath(resolved, cwd));
5557
+ }
5558
+ }
5559
+ const fileMentionMessages = await generateFileMentionMessages(fileMentions, cwd, {
5065
5560
  autoResizeImages: this.settings.get("images.autoResize"),
5066
5561
  useHashLines: resolveFileDisplayMode(this).hashLines,
5562
+ maxInlineBytes: this.settings.get("tools.fileMentionInlineBytes") * 1024,
5563
+ recentlyShownPaths,
5067
5564
  });
5068
5565
  messages.push(...fileMentionMessages);
5069
5566
  }
@@ -5113,8 +5610,12 @@ export class AgentSession {
5113
5610
  this.#appendBeforeAgentStartCustomMessages(messages, contributed, promptAttribution, message.role);
5114
5611
  }
5115
5612
 
5116
- // Bail out if a newer abort/prompt cycle has started since we began setup
5613
+ // Bail out if a newer abort/prompt cycle has started since we began setup.
5614
+ // The injection signatures were consumed while building the automatic
5615
+ // goal/plan context above, but the message is not delivered on this
5616
+ // aborted turn, so reset them here so the next real turn re-injects once.
5117
5617
  if (this.#promptGeneration !== generation) {
5618
+ this.#resetInjectedContextSignatures();
5118
5619
  return;
5119
5620
  }
5120
5621
 
@@ -5694,8 +6195,74 @@ export class AgentSession {
5694
6195
  };
5695
6196
  }
5696
6197
 
6198
+ getQueuedMessageEntries(): QueuedMessageEditEntry[] {
6199
+ const entries: QueuedMessageEditEntry[] = [];
6200
+ for (const entry of this.#steeringMessages) {
6201
+ entries.push({
6202
+ id: this.#queuedMessageEditId("steer", entry.sequence),
6203
+ text: entry.text,
6204
+ mode: "steer",
6205
+ label: "Steer",
6206
+ });
6207
+ }
6208
+ for (const entry of this.#followUpMessages) {
6209
+ entries.push({
6210
+ id: this.#queuedMessageEditId("followUp", entry.sequence),
6211
+ text: entry.text,
6212
+ mode: "followUp",
6213
+ label: "Queued",
6214
+ });
6215
+ }
6216
+ return entries;
6217
+ }
6218
+
6219
+ removeQueuedMessageForEditing(id: string): string | undefined {
6220
+ const [mode, sequenceText] = id.split(":");
6221
+ if ((mode !== "steer" && mode !== "followUp") || sequenceText === undefined) return undefined;
6222
+ const sequence = Number(sequenceText);
6223
+ if (!Number.isInteger(sequence)) return undefined;
6224
+
6225
+ const queue = mode === "steer" ? this.#steeringMessages : this.#followUpMessages;
6226
+ const index = queue.findIndex(entry => entry.sequence === sequence);
6227
+ if (index === -1) return undefined;
6228
+
6229
+ const [entry] = queue.splice(index, 1);
6230
+ if (mode === "steer") {
6231
+ this.agent.removeSteerAt(index);
6232
+ } else {
6233
+ this.agent.removeFollowUpAt(index);
6234
+ }
6235
+ return entry?.text;
6236
+ }
6237
+
6238
+ moveQueuedMessageForEditing(id: string, direction: "up" | "down"): boolean {
6239
+ const [mode, sequenceText] = id.split(":");
6240
+ if ((mode !== "steer" && mode !== "followUp") || sequenceText === undefined) return false;
6241
+ const sequence = Number(sequenceText);
6242
+ if (!Number.isInteger(sequence)) return false;
6243
+
6244
+ const queue = mode === "steer" ? this.#steeringMessages : this.#followUpMessages;
6245
+ const fromIndex = queue.findIndex(entry => entry.sequence === sequence);
6246
+ if (fromIndex === -1) return false;
6247
+ const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1;
6248
+ const agentMoved =
6249
+ mode === "steer" ? this.agent.moveSteer(fromIndex, toIndex) : this.agent.moveFollowUp(fromIndex, toIndex);
6250
+ if (!agentMoved) return false;
6251
+ return this.#moveQueuedDisplayEntry(queue, fromIndex, toIndex);
6252
+ }
6253
+
6254
+ #moveQueuedDisplayEntry(queue: QueuedDisplayEntry[], fromIndex: number, toIndex: number): boolean {
6255
+ if (fromIndex < 0 || fromIndex >= queue.length) return false;
6256
+ if (toIndex < 0 || toIndex >= queue.length) return false;
6257
+ if (fromIndex === toIndex) return true;
6258
+ const [entry] = queue.splice(fromIndex, 1);
6259
+ if (!entry) return false;
6260
+ queue.splice(toIndex, 0, entry);
6261
+ return true;
6262
+ }
6263
+
5697
6264
  /**
5698
- * Pop the last queued message (steering first, then follow-up).
6265
+ * Pop the newest queued message across steering and follow-up queues.
5699
6266
  * Used by dequeue keybinding to restore messages to editor one at a time.
5700
6267
  * Returns the popped entry's `.text`; the tag (if any) dies with the
5701
6268
  * record — no orphan state can outlive the queue entry.
@@ -5705,15 +6272,11 @@ export class AgentSession {
5705
6272
  const followUpEntry = this.#followUpMessages.at(-1);
5706
6273
 
5707
6274
  if (steeringEntry && (!followUpEntry || steeringEntry.sequence > followUpEntry.sequence)) {
5708
- const entry = this.#steeringMessages.pop();
5709
- this.agent.popLastSteer();
5710
- return entry?.text;
6275
+ return this.removeQueuedMessageForEditing(this.#queuedMessageEditId("steer", steeringEntry.sequence));
5711
6276
  }
5712
6277
 
5713
6278
  if (followUpEntry) {
5714
- const entry = this.#followUpMessages.pop();
5715
- this.agent.popLastFollowUp();
5716
- return entry?.text;
6279
+ return this.removeQueuedMessageForEditing(this.#queuedMessageEditId("followUp", followUpEntry.sequence));
5717
6280
  }
5718
6281
 
5719
6282
  return undefined;
@@ -5760,6 +6323,9 @@ export class AgentSession {
5760
6323
  if (eviction.evictedEntries > 0) await this.sessionManager.rewriteEntries();
5761
6324
  const sessionContext = this.buildDisplaySessionContext();
5762
6325
  this.agent.replaceMessages(sessionContext.messages);
6326
+ // Compaction can evict a previously injected goal/plan-mode-context copy from
6327
+ // live context; clear the static-once signatures so the next prompt re-injects.
6328
+ this.#resetInjectedContextSignatures();
5763
6329
  this.#syncTodoPhasesFromBranch();
5764
6330
  this.#closeCodexProviderSessionsForHistoryRewrite();
5765
6331
 
@@ -5978,6 +6544,41 @@ export class AgentSession {
5978
6544
  return true;
5979
6545
  }
5980
6546
 
6547
+ /**
6548
+ * Clear active conversational/model context while preserving the current
6549
+ * session identity and durable history trail.
6550
+ */
6551
+ async clearContext(): Promise<boolean> {
6552
+ const sessionId = this.sessionId;
6553
+ this.#disconnectFromAgent();
6554
+ await this.abort();
6555
+ this.#cancelOwnAsyncJobs();
6556
+ this.#suppressOwnAsyncJobDeliveries();
6557
+ this.yieldQueue.clear();
6558
+ this.#pendingBackgroundExchanges = [];
6559
+ this.#closeAllProviderSessions("context clear");
6560
+ this.agent.reset();
6561
+ await this.sessionManager.flush();
6562
+ this.sessionManager.appendContextClearEntry({ sessionId });
6563
+ this.setTodoPhases([]);
6564
+ this.#syncAgentSessionId(sessionId);
6565
+ this.#steeringMessages = [];
6566
+ this.#followUpMessages = [];
6567
+ this.#pendingNextTurnMessages = [];
6568
+ this.#scheduledHiddenNextTurnGeneration = undefined;
6569
+
6570
+ this.sessionManager.appendThinkingLevelChange(this.thinkingLevel);
6571
+ if (this.model) {
6572
+ this.sessionManager.appendModelChange(`${this.model.provider}/${this.model.id}`);
6573
+ }
6574
+ this.sessionManager.appendServiceTierChange(this.serviceTier ?? null);
6575
+ this.#todoReminderCount = 0;
6576
+ this.#planReferenceSent = false;
6577
+ this.#planReferencePath = "local://PLAN.md";
6578
+ this.#reconnectToAgent();
6579
+ return true;
6580
+ }
6581
+
5981
6582
  /**
5982
6583
  * Set a display name for the current session.
5983
6584
  */
@@ -6549,24 +7150,92 @@ export class AgentSession {
6549
7150
  const branchEntries = this.sessionManager.getBranch();
6550
7151
  const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
6551
7152
  const argumentResult = pruneAssistantToolArguments(branchEntries, DEFAULT_PRUNE_CONFIG);
6552
- const tokensSaved = result.tokensSaved + argumentResult.argumentTokensSaved;
6553
- const prunedCount = result.prunedCount + argumentResult.argumentPrunedCount;
7153
+ const fileMentionResult = pruneStaleFileMentions(branchEntries, p =>
7154
+ resolveReadPath(p, this.sessionManager.getCwd()),
7155
+ );
7156
+ const tokensSaved =
7157
+ result.tokensSaved + argumentResult.argumentTokensSaved + Math.round(fileMentionResult.bytesSaved / 4);
7158
+ const prunedCount = result.prunedCount + argumentResult.argumentPrunedCount + fileMentionResult.changed.length;
6554
7159
  if (prunedCount === 0) {
6555
7160
  return undefined;
6556
7161
  }
6557
7162
 
6558
7163
  // getBranch() returns materialized copies for blob-externalized entries, so
6559
7164
  // the pruning mutations must be written back into the canonical store.
6560
- const combined = [...result.prunedEntries, ...argumentResult.prunedEntries];
7165
+ const combined = [...result.prunedEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed];
6561
7166
  this.sessionManager.applyEntryMessageUpdates(combined);
6562
7167
  await this.sessionManager.rewriteEntries();
6563
7168
  const sessionContext = this.buildDisplaySessionContext();
6564
7169
  this.agent.replaceMessages(sessionContext.messages);
7170
+ // Pruning can evict a previously injected goal/plan-mode-context copy; clear
7171
+ // the static-once signatures so the next prompt re-injects the mode context.
7172
+ this.#resetInjectedContextSignatures();
6565
7173
  this.#syncTodoPhasesFromBranch();
6566
7174
  this.#closeCodexProviderSessionsForHistoryRewrite();
6567
7175
  return { prunedCount, tokensSaved };
6568
7176
  }
6569
7177
 
7178
+ /**
7179
+ * Approximate cost of the prompt-cache-epoch reset that pruning forces:
7180
+ * pruning rewrites already-sent history, invalidating the provider's cached
7181
+ * prefix, so the next turn re-bills that prefix as fresh input. Use the cached
7182
+ * prefix tokens from the last provider usage as the reset-cost estimate.
7183
+ */
7184
+ #lastCacheEpochResetCost(): number {
7185
+ const messages = this.messages;
7186
+ for (let i = messages.length - 1; i >= 0; i--) {
7187
+ const msg = messages[i];
7188
+ if (msg.role === "assistant" && (msg as AssistantMessage).usage) {
7189
+ const usage = (msg as AssistantMessage).usage;
7190
+ return (usage?.cacheRead ?? 0) + (usage?.cacheWrite ?? 0);
7191
+ }
7192
+ }
7193
+ return 0;
7194
+ }
7195
+
7196
+ /**
7197
+ * Evidence-gated below-threshold maintenance pruning (Finding 13). Runs
7198
+ * #pruneToolOutputs ONCE, below the compaction threshold, only when it is
7199
+ * opted in AND the estimated stale-prunable savings both clear a high minimum
7200
+ * and exceed the one-time cache-epoch reset cost (so remaining-turn savings pay
7201
+ * it back). Default-off/blocked until live evidence justifies enabling. Emits
7202
+ * explicit maintenance/reset telemetry.
7203
+ */
7204
+ async #maybeRunBelowThresholdMaintenancePrune(): Promise<void> {
7205
+ const compactionSettings = this.settings.getGroup("compaction");
7206
+ if (!compactionSettings.maintenancePruningEnabled) return;
7207
+
7208
+ const estimate = estimateToolOutputPruneSavings(this.sessionManager.getBranch(), DEFAULT_PRUNE_CONFIG);
7209
+ const cacheEpochResetCost = this.#lastCacheEpochResetCost();
7210
+ if (
7211
+ !shouldRunMaintenancePrune({
7212
+ enabled: compactionSettings.maintenancePruningEnabled,
7213
+ estimatedSavings: estimate.tokensSaved,
7214
+ minSavings: compactionSettings.maintenancePruningMinSavingsTokens,
7215
+ cacheEpochResetCost,
7216
+ })
7217
+ ) {
7218
+ return;
7219
+ }
7220
+
7221
+ const pruneResult = await this.#pruneToolOutputs();
7222
+ if (!pruneResult || pruneResult.prunedCount === 0) return;
7223
+
7224
+ const resetReason = `below-threshold maintenance: reclaimed ${pruneResult.tokensSaved} tokens > cache-epoch reset cost ${cacheEpochResetCost}`;
7225
+ await this.#emitSessionEvent({
7226
+ type: "notice",
7227
+ level: "info",
7228
+ source: "maintenance-prune",
7229
+ message: `Maintenance pruning reclaimed ${pruneResult.tokensSaved} tokens from ${pruneResult.prunedCount} stale tool outputs (${resetReason}).`,
7230
+ });
7231
+ logger.info("Below-threshold maintenance pruning ran", {
7232
+ tokensSaved: pruneResult.tokensSaved,
7233
+ prunedCount: pruneResult.prunedCount,
7234
+ cacheEpochResetCost,
7235
+ minSavings: compactionSettings.maintenancePruningMinSavingsTokens,
7236
+ });
7237
+ }
7238
+
6570
7239
  /**
6571
7240
  * Manually compact the session context.
6572
7241
  * Aborts current agent operation first.
@@ -6589,7 +7258,9 @@ export class AgentSession {
6589
7258
 
6590
7259
  const compactionSettings = this.settings.getGroup("compaction");
6591
7260
  const pathEntries = this.sessionManager.getBranch();
6592
- const preparation = prepareCompaction(pathEntries, compactionSettings);
7261
+ const preparation = prepareCompaction(pathEntries, compactionSettings, {
7262
+ tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
7263
+ });
6593
7264
  if (!preparation) {
6594
7265
  // Check why we can't compact
6595
7266
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -6901,6 +7572,9 @@ export class AgentSession {
6901
7572
  // Rebuild agent messages from session
6902
7573
  const sessionContext = this.buildDisplaySessionContext();
6903
7574
  this.agent.replaceMessages(sessionContext.messages);
7575
+ // Handoff rebuilds live context and can evict a previously injected
7576
+ // goal/plan-mode-context copy; clear the static-once signatures.
7577
+ this.#resetInjectedContextSignatures();
6904
7578
  this.#syncTodoPhasesFromBranch();
6905
7579
 
6906
7580
  return { document: handoffText, savedPath };
@@ -7083,7 +7757,12 @@ export class AgentSession {
7083
7757
  // Model maxTokens is a capability ceiling, not a per-turn reservation.
7084
7758
  // Auto maintenance should track actual context fullness.
7085
7759
  const autoCompactionOutputReserveTokens = 0;
7086
- if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) return;
7760
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) {
7761
+ // Below the compaction threshold: optionally run evidence-gated maintenance
7762
+ // pruning (opt-in, high savings + cache-epoch payback required).
7763
+ await this.#maybeRunBelowThresholdMaintenancePrune();
7764
+ return;
7765
+ }
7087
7766
 
7088
7767
  const pruneResult = await this.#pruneToolOutputs();
7089
7768
  if (pruneResult) {
@@ -7133,6 +7812,7 @@ export class AgentSession {
7133
7812
  }
7134
7813
  const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
7135
7814
  this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
7815
+ this.#resetInjectedContextSignatures();
7136
7816
  try {
7137
7817
  this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
7138
7818
  startedAt: checkpointState.startedAt,
@@ -7221,7 +7901,7 @@ export class AgentSession {
7221
7901
  return undefined;
7222
7902
  }
7223
7903
 
7224
- if (!this.#toolRegistry.has("todo_write")) {
7904
+ if (!this.#toolRegistry.has("todo_write") || !this.getActiveToolNames().includes("todo_write")) {
7225
7905
  logger.warn("Eager todo enforcement skipped because todo_write is unavailable", {
7226
7906
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
7227
7907
  });
@@ -7438,13 +8118,13 @@ export class AgentSession {
7438
8118
  #syncAppendOnlyContext(model: Model | null | undefined): void {
7439
8119
  const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
7440
8120
  const providerId = model?.provider;
7441
- const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
8121
+ const enable = resolveAppendOnlyMode(setting, providerId ?? "");
7442
8122
  const prev = this.#lastAppendOnlyResolution;
7443
8123
  if (prev && prev.enable === enable && prev.providerId === providerId) return;
7444
8124
  this.#lastAppendOnlyResolution = { enable, providerId };
7445
8125
 
7446
8126
  if (enable && !this.agent.appendOnlyContext) {
7447
- this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
8127
+ this.agent.setAppendOnlyContext(createAppendOnlyContextManager(providerId));
7448
8128
  } else if (enable && this.agent.appendOnlyContext) {
7449
8129
  // Already active — invalidate prefix + log so the next turn
7450
8130
  // rebuilds for the current model's normalization.
@@ -7651,6 +8331,59 @@ export class AgentSession {
7651
8331
  return false;
7652
8332
  }
7653
8333
 
8334
+ #buildAutoMaintenanceAttemptSignature(
8335
+ action: "context-full" | "handoff",
8336
+ preparation: CompactionPreparation,
8337
+ hookPrompt: string | undefined,
8338
+ hookContext: string[] | undefined,
8339
+ candidateModels: readonly Model[],
8340
+ ): string {
8341
+ const source = JSON.stringify({
8342
+ action,
8343
+ model: this.model ? this.#getModelKey(this.model) : undefined,
8344
+ candidates: candidateModels.map(model => this.#getModelKey(model)),
8345
+ isSplitTurn: preparation.isSplitTurn,
8346
+ previousSummary: preparation.previousSummary,
8347
+ previousPreserveData: preparation.previousPreserveData,
8348
+ hookPrompt,
8349
+ hookContext,
8350
+ messagesToSummarize: preparation.messagesToSummarize.map(message =>
8351
+ this.#normalizeSessionMessageForProviderReplay(message),
8352
+ ),
8353
+ turnPrefixMessages: preparation.turnPrefixMessages.map(message =>
8354
+ this.#normalizeSessionMessageForProviderReplay(message),
8355
+ ),
8356
+ recentMessages: preparation.recentMessages.map(message =>
8357
+ this.#normalizeSessionMessageForProviderReplay(message),
8358
+ ),
8359
+ });
8360
+ return `${this.#hashProviderReplaySource(source).toString(16)}:${source.length}`;
8361
+ }
8362
+
8363
+ #isOversizedMaintenanceError(errorMessage: string): boolean {
8364
+ return isContextOverflow(
8365
+ {
8366
+ role: "assistant",
8367
+ content: [],
8368
+ api: this.model?.api ?? "anthropic-messages",
8369
+ provider: this.model?.provider ?? "unknown",
8370
+ model: this.model?.id ?? "unknown",
8371
+ stopReason: "error",
8372
+ errorMessage,
8373
+ usage: {
8374
+ input: 0,
8375
+ output: 0,
8376
+ cacheRead: 0,
8377
+ cacheWrite: 0,
8378
+ totalTokens: 0,
8379
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
8380
+ },
8381
+ timestamp: Date.now(),
8382
+ } as AssistantMessage,
8383
+ this.model?.contextWindow,
8384
+ );
8385
+ }
8386
+
7654
8387
  #getModelKey(model: Model): string {
7655
8388
  return `${model.provider}/${model.id}`;
7656
8389
  }
@@ -7921,6 +8654,7 @@ export class AgentSession {
7921
8654
  const autoCompactionAbortController = new AbortController();
7922
8655
  this.#autoCompactionAbortController = autoCompactionAbortController;
7923
8656
  const autoCompactionSignal = autoCompactionAbortController.signal;
8657
+ let maintenanceAttemptSignature: string | undefined;
7924
8658
 
7925
8659
  try {
7926
8660
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
@@ -7993,7 +8727,13 @@ export class AgentSession {
7993
8727
 
7994
8728
  const pathEntries = this.sessionManager.getBranch();
7995
8729
 
7996
- const preparation = prepareCompaction(pathEntries, compactionSettings);
8730
+ // Emergency/overflow-recovery compaction is conservative: apply the token
8731
+ // correction only when it SHRINKS the keep window (ratio >= 1), never when
8732
+ // it would grow it, so recovery cannot re-overflow the provider window.
8733
+ const overflowRatio = this.#computeCompactionTokenCorrectionRatio();
8734
+ const preparation = prepareCompaction(pathEntries, compactionSettings, {
8735
+ tokenCorrectionRatio: overflowRatio !== undefined ? Math.max(1, overflowRatio) : undefined,
8736
+ });
7997
8737
  if (!preparation) {
7998
8738
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
7999
8739
  await this.#emitSessionEvent({
@@ -8068,6 +8808,26 @@ export class AgentSession {
8068
8808
  preserveData = compactionPrep.preserveData;
8069
8809
  } else {
8070
8810
  const candidates = this.#getCompactionModelCandidates(availableModels);
8811
+ maintenanceAttemptSignature = this.#buildAutoMaintenanceAttemptSignature(
8812
+ action,
8813
+ preparation,
8814
+ compactionPrep.hookPrompt,
8815
+ compactionPrep.hookContext,
8816
+ candidates,
8817
+ );
8818
+ if (this.#lastOversizedAutoMaintenanceAttemptSignature === maintenanceAttemptSignature) {
8819
+ await this.#emitSessionEvent({
8820
+ type: "auto_compaction_end",
8821
+ action,
8822
+ result: undefined,
8823
+ aborted: false,
8824
+ willRetry: false,
8825
+ skipped: true,
8826
+ errorMessage:
8827
+ "Auto-compaction skipped: previous unchanged maintenance request exceeded the model context window; change or reduce the conversation before retrying maintenance.",
8828
+ });
8829
+ return;
8830
+ }
8071
8831
  const retrySettings = this.settings.getGroup("retry");
8072
8832
  const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
8073
8833
  let compactResult: CompactionResult | undefined;
@@ -8200,6 +8960,8 @@ export class AgentSession {
8200
8960
  details,
8201
8961
  preserveData,
8202
8962
  };
8963
+ this.#lastOversizedAutoMaintenanceAttemptSignature = undefined;
8964
+
8203
8965
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
8204
8966
  await this.#emitSessionEvent({
8205
8967
  type: "auto_compaction_end",
@@ -8237,6 +8999,9 @@ export class AgentSession {
8237
8999
  return;
8238
9000
  }
8239
9001
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
9002
+ if (maintenanceAttemptSignature && this.#isOversizedMaintenanceError(errorMessage)) {
9003
+ this.#lastOversizedAutoMaintenanceAttemptSignature = maintenanceAttemptSignature;
9004
+ }
8240
9005
  await this.#emitSessionEvent({
8241
9006
  type: "auto_compaction_end",
8242
9007
  action,
@@ -8361,6 +9126,16 @@ export class AgentSession {
8361
9126
  );
8362
9127
  }
8363
9128
 
9129
+ #isRefusalErrorMessage(errorMessage: string): boolean {
9130
+ // Provider safety-refusal stops. Matches the engine-generated Anthropic
9131
+ // labels from packages/ai ("Refusal (<category>): …", "Refusal (no
9132
+ // details provided)", "Content flagged by safety filters") plus generic
9133
+ // usage-policy refusal copy.
9134
+ return /^refusal(\s*\(|:|$)|content flagged by safety filters|blocked under .{0,40}usage policy/i.test(
9135
+ errorMessage,
9136
+ );
9137
+ }
9138
+
8364
9139
  #extractExplicitHttpStatusFromErrorMessage(errorMessage: string): number | undefined {
8365
9140
  // Parse only explicit HTTP/status wording. Do not treat generic
8366
9141
  // `error: 400` as an HTTP status because rate-limit copy can say
@@ -8381,6 +9156,13 @@ export class AgentSession {
8381
9156
  const contextWindow = this.model?.contextWindow ?? 0;
8382
9157
  if (isContextOverflow(message, contextWindow)) return "overflow";
8383
9158
  const err = message.errorMessage;
9159
+ // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
9160
+ // "sensitive") are deterministic for the submitted context: replaying
9161
+ // the identical conversation re-triggers the identical refusal, so an
9162
+ // auto-retry loop can never succeed and only re-bills the full context
9163
+ // on every attempt (#1655). Surface immediately instead of joining the
9164
+ // unbounded "unknown" retry class.
9165
+ if (this.#isRefusalErrorMessage(err)) return "terminal";
8384
9166
  if (isLocalModelEndpoint(this.model) && this.#isLocalProviderAvailabilityErrorMessage(err)) {
8385
9167
  return "local_unavailable";
8386
9168
  }
@@ -8944,22 +9726,52 @@ export class AgentSession {
8944
9726
  setAutoRetryEnabled(enabled: boolean): void {
8945
9727
  this.settings.set("retry.enabled", enabled);
8946
9728
  }
9729
+ #isInterruptedRetryTail(message: AgentMessage | undefined): boolean {
9730
+ if (!message) return false;
9731
+ return (
9732
+ message.role === "user" ||
9733
+ message.role === "developer" ||
9734
+ message.role === "toolResult" ||
9735
+ message.role === "fileMention" ||
9736
+ message.role === "custom" ||
9737
+ message.role === "hookMessage"
9738
+ );
9739
+ }
9740
+
9741
+ #isUnresolvedToolUseAssistant(message: AssistantMessage): boolean {
9742
+ return message.stopReason === "toolUse" && message.content.some(content => content.type === "toolCall");
9743
+ }
9744
+
8947
9745
  /**
8948
- * Manually retry the last failed assistant turn.
8949
- * Removes the error message from agent state and re-attempts with a fresh retry budget.
8950
- * @returns true if retry was initiated, false if no failed turn to retry or agent is busy
9746
+ * Manually retry the last failed assistant turn, or resume an interrupted tail
9747
+ * left by a non-graceful process exit after the user/custom/tool-result message
9748
+ * was persisted but before the agent emitted a terminal assistant response.
9749
+ * Removes failed/aborted/unresolved tool-use assistant tails before
9750
+ * re-attempting with a fresh retry budget.
9751
+ * @returns true if retry/resume was initiated, false if no retryable tail exists or agent is busy
8951
9752
  */
8952
9753
  async retry(): Promise<boolean> {
8953
9754
  if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
8954
9755
 
8955
9756
  const messages = this.agent.state.messages;
8956
9757
  const lastMsg = messages[messages.length - 1];
8957
- if (lastMsg?.role !== "assistant") return false;
9758
+ if (!lastMsg) return false;
9759
+
9760
+ if (lastMsg.role !== "assistant") {
9761
+ if (!this.#isInterruptedRetryTail(lastMsg)) return false;
9762
+ this.#retryAttempt = 0;
9763
+ this.#scheduleAgentContinue({ delayMs: 1 });
9764
+ return true;
9765
+ }
8958
9766
 
8959
9767
  const assistantMsg = lastMsg as AssistantMessage;
8960
- if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
9768
+ const shouldDropAssistant =
9769
+ assistantMsg.stopReason === "error" ||
9770
+ assistantMsg.stopReason === "aborted" ||
9771
+ this.#isUnresolvedToolUseAssistant(assistantMsg);
9772
+ if (!shouldDropAssistant) return false;
8961
9773
 
8962
- // Remove the failed/aborted assistant message (same as auto-retry does before re-attempting)
9774
+ // Remove the failed/aborted/incomplete assistant message before re-attempting.
8963
9775
  this.agent.replaceMessages(messages.slice(0, -1));
8964
9776
 
8965
9777
  // Reset retry budget for a fresh attempt
@@ -9721,6 +10533,7 @@ export class AgentSession {
9721
10533
  }
9722
10534
 
9723
10535
  this.agent.replaceMessages(sessionContext.messages);
10536
+ this.#resetInjectedContextSignatures();
9724
10537
  this.#syncTodoPhasesFromBranch();
9725
10538
  if (switchingToDifferentSession) {
9726
10539
  this.#closeAllProviderSessions("session switch");
@@ -9893,6 +10706,7 @@ export class AgentSession {
9893
10706
 
9894
10707
  if (!skipConversationRestore) {
9895
10708
  this.agent.replaceMessages(sessionContext.messages);
10709
+ this.#resetInjectedContextSignatures();
9896
10710
  this.#closeCodexProviderSessionsForHistoryRewrite();
9897
10711
  }
9898
10712
 
@@ -10060,6 +10874,7 @@ export class AgentSession {
10060
10874
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
10061
10875
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
10062
10876
  this.agent.replaceMessages(displayContext.messages);
10877
+ this.#resetInjectedContextSignatures();
10063
10878
  this.#syncTodoPhasesFromBranch();
10064
10879
  this.#closeCodexProviderSessionsForHistoryRewrite();
10065
10880
 
@@ -10258,6 +11073,57 @@ export class AgentSession {
10258
11073
  return this.#estimateContextTokensWith(message => this.#estimateMessageDisplayTokens(message));
10259
11074
  }
10260
11075
 
11076
+ /** Count inline image blocks in a message (for bucketing the fixed image token estimate). */
11077
+ #countImageBlocks(message: AgentMessage): number {
11078
+ const content = (message as { content?: unknown }).content;
11079
+ if (!Array.isArray(content)) return 0;
11080
+ let count = 0;
11081
+ for (const block of content) {
11082
+ if (block && typeof block === "object" && (block as { type?: unknown }).type === "image") count++;
11083
+ }
11084
+ return count;
11085
+ }
11086
+
11087
+ /**
11088
+ * Observed heuristic→actual token correction for the compaction keep window
11089
+ * (Finding 7). Compares the provider's real prompt tokens against the chars/4
11090
+ * heuristic estimate of the same content (stable system prefix + history through
11091
+ * the last usage-bearing assistant turn). Image-bearing content is bucketed out
11092
+ * of BOTH sides using the identical fixed IMAGE_TOKEN_ESTIMATE so the 1200-token
11093
+ * image charge cannot skew the text ratio. Returns undefined when data is
11094
+ * insufficient, so prepareCompaction applies no correction (never the confounded
11095
+ * raw promptTokens/estimatedTokens quotient). Clamped to [0.5, 2] downstream.
11096
+ */
11097
+ #computeCompactionTokenCorrectionRatio(): number | undefined {
11098
+ const messages = this.messages;
11099
+ let lastUsageIndex = -1;
11100
+ let lastUsage: Usage | undefined;
11101
+ for (let i = messages.length - 1; i >= 0; i--) {
11102
+ const msg = messages[i];
11103
+ if (msg.role === "assistant" && (msg as AssistantMessage).usage) {
11104
+ lastUsage = (msg as AssistantMessage).usage;
11105
+ lastUsageIndex = i;
11106
+ break;
11107
+ }
11108
+ }
11109
+ if (!lastUsage || lastUsageIndex < 0) return undefined;
11110
+ const actual = calculatePromptTokens(lastUsage);
11111
+ if (!(actual > 0)) return undefined;
11112
+
11113
+ let heuristic = 0;
11114
+ for (const block of this.agent.state.systemPrompt) heuristic += estimateTextTokensHeuristic(block);
11115
+ let imageBlocks = 0;
11116
+ for (let i = 0; i <= lastUsageIndex; i++) {
11117
+ heuristic += this.#estimateMessageDisplayTokens(messages[i]);
11118
+ imageBlocks += this.#countImageBlocks(messages[i]);
11119
+ }
11120
+ const imgAdjust = imageBlocks * IMAGE_TOKEN_ESTIMATE;
11121
+ const num = actual - imgAdjust;
11122
+ const den = heuristic - imgAdjust;
11123
+ if (!(num > 0) || !(den > 0)) return undefined;
11124
+ return num / den;
11125
+ }
11126
+
10261
11127
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
10262
11128
  tokens: number;
10263
11129
  } {
@@ -10546,6 +11412,10 @@ export class AgentSession {
10546
11412
  return this.#extensionRunner?.hasHandlers(eventType) ?? false;
10547
11413
  }
10548
11414
 
11415
+ #hasStreamingExtensionHandlers(): boolean {
11416
+ return this.hasExtensionHandlers("message_update");
11417
+ }
11418
+
10549
11419
  /**
10550
11420
  * Register a first-party internal before-agent-start contributor. Returns an
10551
11421
  * unregister function. This is NOT user-facing hook discovery; it is an