@gajae-code/coding-agent 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/append-only-mode.d.ts +31 -0
  3. package/dist/types/capability/rule.d.ts +8 -0
  4. package/dist/types/cli/fixture-report.d.ts +20 -0
  5. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  6. package/dist/types/cli/nofile-limit.d.ts +11 -0
  7. package/dist/types/cli/update-cli.d.ts +22 -0
  8. package/dist/types/config/settings-schema.d.ts +105 -1
  9. package/dist/types/discovery/index.d.ts +1 -0
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/export/ttsr.d.ts +14 -3
  12. package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
  13. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
  14. package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
  15. package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
  16. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  17. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
  18. package/dist/types/goals/runtime.d.ts +1 -0
  19. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  20. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  21. package/dist/types/modes/components/status-line.d.ts +9 -0
  22. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  24. package/dist/types/modes/interactive-mode.d.ts +2 -0
  25. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  26. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  27. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  28. package/dist/types/modes/types.d.ts +1 -0
  29. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  30. package/dist/types/notifications/config.d.ts +6 -0
  31. package/dist/types/notifications/telegram-daemon.d.ts +8 -0
  32. package/dist/types/notifications/threaded-render.d.ts +6 -0
  33. package/dist/types/sdk.d.ts +3 -0
  34. package/dist/types/session/agent-session.d.ts +43 -1
  35. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  36. package/dist/types/session/messages.d.ts +4 -0
  37. package/dist/types/session/session-manager.d.ts +17 -1
  38. package/dist/types/skill-state/active-state.d.ts +18 -1
  39. package/dist/types/system-prompt.d.ts +18 -2
  40. package/dist/types/task/executor.d.ts +1 -0
  41. package/dist/types/task/fork-context-budget.d.ts +7 -0
  42. package/dist/types/task/token-log.d.ts +30 -0
  43. package/dist/types/task/token-log.test.d.ts +1 -0
  44. package/dist/types/tools/bisect.d.ts +81 -0
  45. package/dist/types/tools/index.d.ts +1 -0
  46. package/dist/types/tools/search.d.ts +2 -2
  47. package/dist/types/utils/file-mentions.d.ts +14 -0
  48. package/dist/types/utils/git.d.ts +32 -0
  49. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  50. package/package.json +7 -7
  51. package/src/append-only-mode.ts +58 -0
  52. package/src/autoresearch/prompt.md +1 -1
  53. package/src/autoresearch/tools/log-experiment.ts +1 -1
  54. package/src/capability/rule.ts +9 -0
  55. package/src/cli/config-cli.ts +27 -3
  56. package/src/cli/fixture-report.test.ts +230 -0
  57. package/src/cli/fixture-report.ts +171 -0
  58. package/src/cli/nofile-limit.ts +48 -0
  59. package/src/cli/update-cli.ts +85 -9
  60. package/src/cli.ts +26 -0
  61. package/src/commands/contribution-prep.ts +1 -1
  62. package/src/config/settings-schema.ts +68 -1
  63. package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
  64. package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
  65. package/src/defaults/gjc/skills/team/SKILL.md +16 -13
  66. package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
  67. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  68. package/src/defaults/gjc-defaults.ts +9 -0
  69. package/src/discovery/helpers.ts +10 -0
  70. package/src/discovery/index.ts +1 -0
  71. package/src/export/html/template.generated.ts +1 -1
  72. package/src/export/html/template.js +60 -2
  73. package/src/export/ttsr.ts +96 -18
  74. package/src/extensibility/extensions/runner.ts +181 -219
  75. package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
  76. package/src/gjc-runtime/deep-interview-state.ts +31 -2
  77. package/src/gjc-runtime/session-state-sidecar.ts +208 -13
  78. package/src/gjc-runtime/state-writer.ts +15 -0
  79. package/src/gjc-runtime/team-runtime.ts +164 -15
  80. package/src/gjc-runtime/ultragoal-guard.ts +55 -66
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
  82. package/src/gjc-runtime/ultragoal-runtime.ts +1593 -143
  83. package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
  84. package/src/gjc-runtime/workflow-manifest.ts +74 -1
  85. package/src/goals/runtime.ts +10 -2
  86. package/src/hindsight/backend.ts +1 -3
  87. package/src/hooks/native-skill-hook.ts +95 -4
  88. package/src/internal-urls/docs-index.generated.ts +14 -5
  89. package/src/main.ts +43 -2
  90. package/src/memories/index.ts +12 -1
  91. package/src/modes/components/assistant-message.ts +241 -82
  92. package/src/modes/components/custom-editor.ts +1 -0
  93. package/src/modes/components/status-line.ts +167 -54
  94. package/src/modes/components/tool-execution.ts +44 -19
  95. package/src/modes/controllers/command-controller.ts +39 -2
  96. package/src/modes/controllers/event-controller.ts +66 -47
  97. package/src/modes/controllers/input-controller.ts +1 -1
  98. package/src/modes/interactive-mode.ts +9 -1
  99. package/src/modes/rpc/rpc-mode.ts +29 -3
  100. package/src/modes/rpc/rpc-types.ts +9 -0
  101. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  102. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  103. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  104. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  105. package/src/modes/types.ts +1 -0
  106. package/src/modes/utils/ui-helpers.ts +17 -4
  107. package/src/notifications/config.ts +16 -4
  108. package/src/notifications/index.ts +78 -5
  109. package/src/notifications/telegram-daemon-cli.ts +2 -2
  110. package/src/notifications/telegram-daemon-control.ts +2 -2
  111. package/src/notifications/telegram-daemon.ts +85 -8
  112. package/src/notifications/threaded-render.ts +17 -1
  113. package/src/prompts/agents/architect.md +19 -10
  114. package/src/prompts/agents/critic.md +16 -18
  115. package/src/prompts/agents/executor.md +14 -11
  116. package/src/prompts/agents/planner.md +18 -13
  117. package/src/prompts/ci-green-request.md +3 -8
  118. package/src/prompts/goals/goal-continuation.md +0 -5
  119. package/src/prompts/goals/goal-mode-active.md +0 -5
  120. package/src/prompts/system/agent-creation-architect.md +2 -2
  121. package/src/prompts/system/plan-mode-active.md +1 -1
  122. package/src/prompts/system/plan-mode-subagent.md +3 -3
  123. package/src/prompts/system/project-prompt.md +0 -13
  124. package/src/prompts/system/subagent-system-prompt.md +3 -2
  125. package/src/prompts/system/system-prompt.md +26 -19
  126. package/src/prompts/system/volatile-project-context.md +10 -0
  127. package/src/prompts/system/web-search.md +4 -7
  128. package/src/prompts/tools/apply-patch.md +6 -0
  129. package/src/prompts/tools/ast-edit.md +1 -0
  130. package/src/prompts/tools/bash.md +1 -1
  131. package/src/prompts/tools/bisect.md +22 -0
  132. package/src/prompts/tools/browser.md +1 -1
  133. package/src/prompts/tools/cron.md +0 -2
  134. package/src/prompts/tools/eval.md +2 -2
  135. package/src/prompts/tools/find.md +1 -1
  136. package/src/prompts/tools/github.md +6 -6
  137. package/src/prompts/tools/goal.md +1 -3
  138. package/src/prompts/tools/image-gen.md +12 -5
  139. package/src/prompts/tools/irc.md +5 -4
  140. package/src/prompts/tools/job.md +2 -2
  141. package/src/prompts/tools/lsp.md +1 -1
  142. package/src/prompts/tools/monitor.md +2 -2
  143. package/src/prompts/tools/patch.md +2 -2
  144. package/src/prompts/tools/replace.md +2 -21
  145. package/src/prompts/tools/rewind.md +1 -1
  146. package/src/prompts/tools/search-tool-bm25.md +1 -11
  147. package/src/prompts/tools/search.md +1 -1
  148. package/src/prompts/tools/skill.md +3 -2
  149. package/src/prompts/tools/ssh.md +2 -1
  150. package/src/prompts/tools/subagent.md +2 -2
  151. package/src/prompts/tools/task.md +4 -5
  152. package/src/prompts/tools/todo-write.md +1 -1
  153. package/src/prompts/tools/vim.md +1 -0
  154. package/src/prompts/tools/web-search.md +2 -0
  155. package/src/prompts/tools/write.md +8 -2
  156. package/src/sdk.ts +32 -22
  157. package/src/session/agent-session.ts +732 -107
  158. package/src/session/file-mention-pruning.ts +64 -0
  159. package/src/session/messages.ts +4 -0
  160. package/src/session/session-manager.ts +59 -2
  161. package/src/skill-state/active-state.ts +141 -5
  162. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  163. package/src/slash-commands/builtin-registry.ts +65 -0
  164. package/src/system-prompt.ts +57 -16
  165. package/src/task/executor.ts +125 -20
  166. package/src/task/fork-context-advisory.ts +2 -7
  167. package/src/task/fork-context-budget.ts +9 -0
  168. package/src/task/index.ts +4 -3
  169. package/src/task/output-manager.ts +4 -2
  170. package/src/task/token-log.test.ts +188 -0
  171. package/src/task/token-log.ts +179 -0
  172. package/src/tools/bisect.ts +367 -0
  173. package/src/tools/index.ts +3 -0
  174. package/src/tools/output-meta.ts +116 -4
  175. package/src/tools/search.ts +8 -2
  176. package/src/tools/skill.ts +15 -0
  177. package/src/utils/file-mentions.ts +39 -7
  178. package/src/utils/git.ts +73 -0
  179. package/src/web/scrapers/youtube.ts +24 -12
@@ -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 "@gajae-code/agent-core/compaction";
53
54
  import {
54
55
  DEFAULT_PRUNE_CONFIG,
56
+ estimateToolOutputPruneSavings,
55
57
  pruneAssistantToolArguments,
56
58
  pruneToolOutputs,
59
+ shouldRunMaintenancePrune,
57
60
  } from "@gajae-code/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 "@gajae-code/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";
@@ -238,6 +242,7 @@ import {
238
242
  } from "../skill-state/active-state";
239
243
  import { assertWorkflowMutationAllowed } from "../skill-state/deep-interview-mutation-guard";
240
244
  import { invalidateHostMetadata } from "../ssh/connection-manager";
245
+ import { buildVolatileProjectContext } from "../system-prompt";
241
246
  import { resolveThinkingLevelForModel, toReasoningEffort } from "../thinking";
242
247
  import {
243
248
  buildDiscoverableToolSearchIndex,
@@ -252,7 +257,7 @@ import { assertEditableFile } from "../tools/auto-generated-guard";
252
257
  import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
253
258
  import type { CheckpointState } from "../tools/checkpoint";
254
259
  import { outputMeta, wrapToolWithMetaNotice } from "../tools/output-meta";
255
- import { normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
260
+ import { normalizeLocalScheme, resolveReadPath, resolveToCwd } from "../tools/path-utils";
256
261
  import { registerResourceGcSession } from "../tools/resource-gc";
257
262
  import { getLatestTodoPhasesFromEntries, type TodoItem, type TodoPhase } from "../tools/todo-write";
258
263
  import { ToolAbortError, ToolError } from "../tools/tool-errors";
@@ -264,6 +269,7 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode";
264
269
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
265
270
  import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-choice";
266
271
  import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
272
+ import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
267
273
  import type { AuthStorage } from "./auth-storage";
268
274
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
269
275
  import {
@@ -271,6 +277,7 @@ import {
271
277
  type ContributionPrepResult,
272
278
  prepareContributionPrep,
273
279
  } from "./contribution-prep";
280
+ import { pruneStaleFileMentions } from "./file-mention-pruning";
274
281
  import {
275
282
  type BashExecutionMessage,
276
283
  type CompactionSummaryMessage,
@@ -396,6 +403,8 @@ export interface AgentSessionConfig {
396
403
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
397
404
  /** System prompt builder that can consider tool availability. Returns ordered provider-facing blocks. */
398
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;
399
408
  /** Rebuild the SSH tool from current capability discovery results. */
400
409
  reloadSshTool?: () => Promise<AgentTool | null>;
401
410
  requestedToolNames?: ReadonlySet<string>;
@@ -616,6 +625,13 @@ const IRC_REPLY_MAX_BYTES = 4096;
616
625
  */
617
626
  const SIGNAL_TEARDOWN_TIMEOUT_MS = 5_000;
618
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
+
619
635
  /**
620
636
  * Collapse degenerate IRC ephemeral replies before they hit the relay.
621
637
  * Models occasionally loop on a single line (~16 reports of N-times-repeated
@@ -936,6 +952,136 @@ export type BeforeAgentStartContributor = (event: {
936
952
  sessionId: string | undefined;
937
953
  }) => Promise<BeforeAgentStartInternalMessage | undefined>;
938
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
+ }
939
1085
  export class AgentSession {
940
1086
  readonly agent: Agent;
941
1087
  readonly sessionManager: SessionManager;
@@ -959,6 +1105,12 @@ export class AgentSession {
959
1105
  /** Last (enable, providerId) tuple resolved by `#syncAppendOnlyContext` — used to skip no-op invalidations. */
960
1106
  #lastAppendOnlyResolution?: { enable: boolean; providerId: string | undefined };
961
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
+ }
962
1114
 
963
1115
  /** Tracks pending steering messages for UI display. Removed when delivered.
964
1116
  * Entry shape: `{ text }` for plain-text steers (user-message dequeue
@@ -977,13 +1129,18 @@ export class AgentSession {
977
1129
  #goalModeState: GoalModeState | undefined;
978
1130
  #workflowGateEmitter: WorkflowGateEmitter | undefined;
979
1131
  #goalRuntime: GoalRuntime;
1132
+ #lastInjectedGoalContextSig: string | undefined = undefined;
1133
+ #lastInjectedPlanContextSig: string | undefined = undefined;
980
1134
  #goalTurnCounter = 0;
1135
+ #streamingEditParsedToolCallCache = new Map<string, StreamingEditParsedCacheEntry>();
981
1136
  #planReferenceSent = false;
982
1137
  #planReferencePath = "local://PLAN.md";
983
1138
  #clientBridge: ClientBridge | undefined;
984
1139
  #allowAcpAgentInitiatedTurns = false;
985
1140
  /** Per-session memory of allow_always / reject_always decisions for gated tools. */
986
1141
  #acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
1142
+ #guardedToolWrapperCache = new WeakMap<AgentTool, Map<string, AgentTool>>();
1143
+ #acpPermissionWrapperVersion = 0;
987
1144
 
988
1145
  // Compaction state
989
1146
  #compactionAbortController: AbortController | undefined = undefined;
@@ -1045,6 +1202,11 @@ export class AgentSession {
1045
1202
  // Extension system
1046
1203
  #extensionRunner: ExtensionRunner | undefined = undefined;
1047
1204
  #turnIndex = 0;
1205
+ #workerIntegrationScheduler = new WorkerIntegrationRequestScheduler(async () => {
1206
+ await requestGjcWorkerIntegrationAttempt(this.sessionManager.getCwd(), process.env).catch(error => {
1207
+ logger.warn("GJC team worker integration request failed", { error: String(error) });
1208
+ });
1209
+ });
1048
1210
  // First-party internal before-agent-start contributors (not user hooks).
1049
1211
  #beforeAgentStartContributors: BeforeAgentStartContributor[] = [];
1050
1212
 
@@ -1077,6 +1239,10 @@ export class AgentSession {
1077
1239
  #reloadSshTool: (() => Promise<AgentTool | null>) | undefined;
1078
1240
  #requestedToolNames: ReadonlySet<string> | undefined;
1079
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;
1080
1246
  /**
1081
1247
  * Signature of the (toolNames, tool descriptions) tuple passed to the most
1082
1248
  * recent successful `rebuildSystemPrompt` call. Used to skip redundant rebuilds
@@ -1136,10 +1302,31 @@ export class AgentSession {
1136
1302
 
1137
1303
  #streamingEditAbortTriggered = false;
1138
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
+ >();
1139
1319
 
1140
1320
  #streamingEditPrecheckedToolCallIds = new Set<string>();
1141
1321
 
1142
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
+ };
1143
1330
  #promptInFlightCount = 0;
1144
1331
  // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
1145
1332
  // Local subscribers and extension hooks both receive the deferred event from
@@ -1327,6 +1514,7 @@ export class AgentSession {
1327
1514
  this.#getMcpServerInstructions = config.getMcpServerInstructions;
1328
1515
  this.#reloadSshTool = config.reloadSshTool;
1329
1516
  this.#baseSystemPrompt = this.agent.state.systemPrompt;
1517
+ this.#initialWorkspaceTree = config.workspaceTree;
1330
1518
  this.#mcpDiscoveryEnabled = config.mcpDiscoveryEnabled ?? false;
1331
1519
  this.#discoverableToolAllowedNames = config.discoverableToolAllowedNames
1332
1520
  ? new Set(config.discoverableToolAllowedNames.map(name => name.toLowerCase()))
@@ -1357,16 +1545,6 @@ export class AgentSession {
1357
1545
  this.#agentRegistry = config.agentRegistry;
1358
1546
  this.#providerSessionId = config.providerSessionId;
1359
1547
  this.#providerCacheSessionId = config.providerCacheSessionId;
1360
- this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
1361
- const event: AgentEvent = {
1362
- type: "message_update",
1363
- message,
1364
- assistantMessageEvent,
1365
- };
1366
- const generation = this.#promptGeneration;
1367
- this.#preCacheStreamingEditFile(event);
1368
- this.#maybeAbortStreamingEdit(event, generation);
1369
- });
1370
1548
  // Per-tool TTSR reminders are folded into the matched tool's result via this hook.
1371
1549
  this.agent.afterToolCall = ctx => this.#ttsrAfterToolCall(ctx);
1372
1550
  this.agent.providerSessionState = this.#providerSessionState;
@@ -1514,13 +1692,30 @@ export class AgentSession {
1514
1692
  }
1515
1693
 
1516
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
+ }
1517
1714
  const transformedMessages = await this.#transformContext([...this.messages], options.signal);
1518
1715
  const convertedMessages = await this.#convertToLlm(transformedMessages);
1519
1716
  const providerMessages = this.model
1520
1717
  ? normalizeMessagesForProvider(convertedMessages, this.model)
1521
1718
  : convertedMessages;
1522
- const maxMessages = Math.min(500, Math.max(0, Math.trunc(options.maxMessages)));
1523
- const maxTokens = Math.max(0, Math.trunc(options.maxTokens));
1524
1719
  const selected: Message[] = [];
1525
1720
  const skippedReasons: Record<string, number> = {};
1526
1721
  let skippedMessages = 0;
@@ -1567,6 +1762,33 @@ export class AgentSession {
1567
1762
  return cloned;
1568
1763
  };
1569
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
+
1570
1792
  for (let i = providerMessages.length - 1; i >= 0; i--) {
1571
1793
  if (selected.length >= maxMessages) {
1572
1794
  recordSkip("message-limit");
@@ -1580,8 +1802,14 @@ export class AgentSession {
1580
1802
  // messages. `continue` here would skip the oversized recent message and scavenge
1581
1803
  // smaller OLDER ones, yielding a non-contiguous seed that misrepresents the
1582
1804
  // conversation and violates the recency contract of the receipt/last-turn/bounded modes.
1583
- if (maxTokens > 0 && approximateTokens + messageTokens > maxTokens) {
1805
+ if (approximateTokens + messageTokens > maxTokens) {
1584
1806
  recordSkip("token-limit");
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
+ }
1585
1813
  break;
1586
1814
  }
1587
1815
  selected.unshift(sanitized);
@@ -1736,15 +1964,23 @@ export class AgentSession {
1736
1964
  manager.cancelAll({ ownerId: this.#agentId });
1737
1965
  }
1738
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
+
1739
1977
  // =========================================================================
1740
1978
  // Event Subscription
1741
1979
  // =========================================================================
1742
1980
 
1743
1981
  /** Emit an event to all listeners */
1744
1982
  #emit(event: AgentSessionEvent): void {
1745
- // Copy array before iteration to avoid mutation during iteration
1746
- const listeners = [...this.#eventListeners];
1747
- for (const l of listeners) {
1983
+ for (const l of this.#eventListenerSnapshot) {
1748
1984
  l(event);
1749
1985
  }
1750
1986
  }
@@ -1774,19 +2010,22 @@ export class AgentSession {
1774
2010
  }
1775
2011
 
1776
2012
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
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).
2016
+ this.#emit(event);
2017
+ if (this.#hasStreamingExtensionHandlers()) {
2018
+ __agentSessionPerfCounters.messageUpdateExtensionQueues += 1;
2019
+ void this.#queueExtensionEvent(event);
2020
+ }
2021
+ return;
2022
+ }
1777
2023
  const persistRuntimeState = () =>
1778
2024
  persistCoordinatorRuntimeStateFromEvent(event, {
1779
2025
  sessionId: this.sessionId,
1780
2026
  cwd: this.sessionManager.getCwd(),
1781
2027
  sessionFile: this.sessionManager.getSessionFile(),
1782
2028
  });
1783
-
1784
- if (event.type === "message_update") {
1785
- this.#emit(event);
1786
- void persistRuntimeState();
1787
- void this.#queueExtensionEvent(event);
1788
- return;
1789
- }
1790
2029
  // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
1791
2030
  // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
1792
2031
  // emitting while #promptInFlightCount > 0 lets a client fire its next
@@ -1893,7 +2132,7 @@ export class AgentSession {
1893
2132
  }
1894
2133
  }
1895
2134
 
1896
- if (event.type === "turn_start") {
2135
+ if (event.type === "turn_start" && this.#goalRuntime.shouldTrackTurnBaseline()) {
1897
2136
  const usage = this.getSessionStats().tokens;
1898
2137
  this.#goalRuntime.onTurnStart(`turn-${++this.#goalTurnCounter}`, {
1899
2138
  input: usage.input,
@@ -2645,8 +2884,10 @@ export class AgentSession {
2645
2884
  // The TTSR manager was already claimed at bucket time; only persistence remains.
2646
2885
  const ruleNames = rules.map(r => r.name.trim()).filter(n => n.length > 0);
2647
2886
  if (ruleNames.length > 0) {
2648
- 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());
2649
2889
  }
2890
+
2650
2891
  return {
2651
2892
  content: [{ type: "text", text: reminder }, ...ctx.result.content],
2652
2893
  };
@@ -2671,7 +2912,8 @@ export class AgentSession {
2671
2912
  return;
2672
2913
  }
2673
2914
  this.#ttsrManager?.markInjectedByNames(uniqueRuleNames);
2674
- 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());
2675
2917
  }
2676
2918
 
2677
2919
  #findTtsrAssistantIndex(targetTimestamp: number | undefined): number {
@@ -2855,55 +3097,16 @@ export class AgentSession {
2855
3097
  #resetStreamingEditState(): void {
2856
3098
  this.#streamingEditAbortTriggered = false;
2857
3099
  this.#streamingEditCheckedLineCounts.clear();
3100
+ this.#streamingEditToolCallStates.clear();
2858
3101
  this.#streamingEditPrecheckedToolCallIds.clear();
3102
+ this.#streamingEditParsedToolCallCache.clear();
2859
3103
  this.#streamingEditFileCache.clear();
2860
3104
  }
2861
3105
 
2862
- #getStreamingEditToolCall(event: AgentEvent):
2863
- | {
2864
- toolCall: ToolCall;
2865
- path: string;
2866
- resolvedPath: string;
2867
- diff?: string;
2868
- op?: string;
2869
- rename?: string;
2870
- }
2871
- | undefined {
2872
- if (event.type !== "message_update") return undefined;
2873
- if (event.message.role !== "assistant") return undefined;
2874
-
2875
- const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
2876
- const messageContent = event.message.content;
2877
- if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) {
2878
- return undefined;
2879
- }
2880
-
2881
- const toolCall = messageContent[contentIndex] as ToolCall;
2882
- if (toolCall.name !== "edit") return undefined;
2883
-
2884
- const args = toolCall.arguments;
2885
- if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
2886
- if ("old_text" in args || "new_text" in args) return undefined;
2887
-
2888
- const path = typeof args.path === "string" ? args.path : undefined;
2889
- if (!path) return undefined;
2890
-
2891
- // `local://` URLs (e.g. local://PLAN.md for plan-mode) resolve to a real
2892
- // on-disk artifacts path; pre-caching works as long as we ask the
2893
- // local-protocol handler. Other internal-scheme URLs have no stable filesystem representation;
2894
- // skip pre-cache entirely for those — the edit tool itself will reject
2895
- // them through its normal dispatch path.
2896
- const resolvedPath = this.#resolveSessionFsPath(path);
2897
- if (resolvedPath === undefined) return undefined;
2898
-
2899
- return {
2900
- toolCall,
2901
- path,
2902
- resolvedPath,
2903
- diff: typeof args.diff === "string" ? args.diff : undefined,
2904
- op: typeof args.op === "string" ? args.op : undefined,
2905
- rename: typeof args.rename === "string" ? args.rename : undefined,
2906
- };
3106
+ #getStreamingEditToolCall(event: AgentEvent): StreamingEditParsedToolCall | undefined {
3107
+ return getStreamingEditToolCallForEvent(event, this.#streamingEditParsedToolCallCache, filePath =>
3108
+ this.#resolveSessionFsPath(filePath),
3109
+ );
2907
3110
  }
2908
3111
 
2909
3112
  #lastStreamingEditToolCallId: string | undefined;
@@ -3022,36 +3225,122 @@ export class AgentSession {
3022
3225
  const assistantEvent = event.assistantMessageEvent;
3023
3226
  if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
3024
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
+
3025
3245
  const streamingEdit = this.#getStreamingEditToolCall(event);
3026
- 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
+ }
3027
3261
 
3028
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
+ }
3029
3287
  if (!diff) return;
3030
- if (op && op !== "update") return;
3031
3288
 
3032
- if (!diff.includes("\n")) return;
3033
3289
  const lastNewlineIndex = diff.lastIndexOf("\n");
3034
3290
  if (lastNewlineIndex < 0) return;
3035
- const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
3036
- 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;
3037
3306
 
3038
3307
  let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
3039
3308
  if (!normalizedDiff) return;
3040
- // Deobfuscate the diff so removed lines match real file content
3041
3309
  if (this.#obfuscator) normalizedDiff = this.#obfuscator.deobfuscate(normalizedDiff);
3042
3310
  if (!normalizedDiff) return;
3043
3311
  const lines = normalizedDiff.split("\n");
3044
3312
  const hasChangeLine = lines.some(line => line.startsWith("+") || line.startsWith("-"));
3045
- 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;
3046
3327
 
3047
3328
  const lineCount = lines.length;
3048
- const lastChecked = this.#streamingEditCheckedLineCounts.get(toolCall.id);
3049
- if (lastChecked !== undefined && lineCount <= lastChecked) return;
3050
3329
  this.#streamingEditCheckedLineCounts.set(toolCall.id, lineCount);
3051
3330
 
3052
3331
  const removedLines = lines
3053
3332
  .filter(line => line.startsWith("-") && !line.startsWith("--- "))
3054
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
+
3055
3344
  if (removedLines.length > 0) {
3056
3345
  let cachedContent = this.#streamingEditFileCache.get(resolvedPath);
3057
3346
  if (cachedContent === undefined) {
@@ -3062,6 +3351,7 @@ export class AgentSession {
3062
3351
  const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
3063
3352
  if (missing) {
3064
3353
  this.#streamingEditAbortTriggered = true;
3354
+ state.settledVerdict = "aborted";
3065
3355
  logger.warn("Streaming edit aborted due to patch preview failure", {
3066
3356
  toolCallId: toolCall.id,
3067
3357
  path,
@@ -3141,12 +3431,21 @@ export class AgentSession {
3141
3431
  }
3142
3432
  }
3143
3433
 
3434
+ #requestWorkerIntegrationAttempt(): void {
3435
+ this.#workerIntegrationScheduler.enqueue();
3436
+ }
3437
+
3438
+ async #flushWorkerIntegrationAttempt(): Promise<void> {
3439
+ await this.#workerIntegrationScheduler.flush();
3440
+ }
3441
+
3144
3442
  /** Emit extension events based on session events */
3145
3443
  async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
3146
3444
  if (event.type === "turn_end") {
3147
- await requestGjcWorkerIntegrationAttempt(this.sessionManager.getCwd(), process.env).catch(error => {
3148
- logger.warn("GJC team worker integration request failed", { error: String(error) });
3149
- });
3445
+ this.#requestWorkerIntegrationAttempt();
3446
+ }
3447
+ if (event.type === "agent_end") {
3448
+ await this.#flushWorkerIntegrationAttempt();
3150
3449
  }
3151
3450
  if (!this.#extensionRunner) return;
3152
3451
  if (event.type === "agent_start") {
@@ -3278,12 +3577,14 @@ export class AgentSession {
3278
3577
  */
3279
3578
  subscribe(listener: AgentSessionEventListener): () => void {
3280
3579
  this.#eventListeners.push(listener);
3580
+ this.#rebuildEventListenerSnapshot();
3281
3581
 
3282
3582
  // Return unsubscribe function for this specific listener
3283
3583
  return () => {
3284
3584
  const index = this.#eventListeners.indexOf(listener);
3285
3585
  if (index !== -1) {
3286
3586
  this.#eventListeners.splice(index, 1);
3587
+ this.#rebuildEventListenerSnapshot();
3287
3588
  }
3288
3589
  };
3289
3590
  }
@@ -3360,6 +3661,7 @@ export class AgentSession {
3360
3661
  } catch (error) {
3361
3662
  logger.warn("Failed to emit session_shutdown event", { error: String(error) });
3362
3663
  }
3664
+ await this.#flushWorkerIntegrationAttempt();
3363
3665
  await this.#cancelPostPromptTasks();
3364
3666
  // Cancel jobs this agent registered so a subagent's teardown doesn't
3365
3667
  // leak its background bash/task work into the parent's manager. Only
@@ -3420,6 +3722,7 @@ export class AgentSession {
3420
3722
  this.#unsubscribeAppendOnly = undefined;
3421
3723
  }
3422
3724
  this.#eventListeners = [];
3725
+ this.#rebuildEventListenerSnapshot();
3423
3726
  }
3424
3727
 
3425
3728
  /**
@@ -3953,7 +4256,7 @@ export class AgentSession {
3953
4256
  * prompts or tool execution can run.
3954
4257
  */
3955
4258
  #wrapToolForDeepInterviewMutationGuard<T extends AgentTool>(tool: T): T {
3956
- if (!["edit", "write", "ast_edit"].includes(tool.name)) return tool;
4259
+ if (!["edit", "write", "ast_edit", "bash"].includes(tool.name)) return tool;
3957
4260
  return new Proxy(tool, {
3958
4261
  get: (target, prop) => {
3959
4262
  if (prop !== "execute") return Reflect.get(target, prop, target);
@@ -3976,8 +4279,25 @@ export class AgentSession {
3976
4279
  }) as T;
3977
4280
  }
3978
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
+
3979
4295
  #prepareToolForExecution<T extends AgentTool>(tool: T): T {
3980
- return this.#wrapToolForDeepInterviewMutationGuard(
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(
3981
4301
  this.#wrapToolForAcpPermission(
3982
4302
  guardToolForUltragoalAsk(
3983
4303
  tool,
@@ -3989,6 +4309,12 @@ export class AgentSession {
3989
4309
  ),
3990
4310
  ),
3991
4311
  );
4312
+ if (!wrappersByVersion) {
4313
+ wrappersByVersion = new Map();
4314
+ this.#guardedToolWrapperCache.set(tool, wrappersByVersion);
4315
+ }
4316
+ wrappersByVersion.set(cacheKey, wrapped);
4317
+ return wrapped;
3992
4318
  }
3993
4319
 
3994
4320
  #setGuardedAgentTools(tools: AgentTool[]): void {
@@ -4183,11 +4509,15 @@ export class AgentSession {
4183
4509
  * For everything else, callers must explicitly call `refreshBaseSystemPrompt()`
4184
4510
  * after side-effecting changes; see e.g. the memory hooks and
4185
4511
  * `#syncEditToolModeAfterModelChange`.
4186
- *
4187
- * The current calendar date IS covered (appended as a segment) because
4188
- * `buildSystemPrompt` injects it into the prompt body (`Today is '{{date}}'`).
4189
- * Without this, a session spanning midnight with only tool-stable MCP
4190
- * 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.
4191
4521
  */
4192
4522
  #computeAppliedToolSignature(toolNames: string[], tools: AgentTool[]): string {
4193
4523
  // Order-preserving join: any reorder must produce a different signature so
@@ -4218,8 +4548,7 @@ export class AgentSession {
4218
4548
  entries.sort();
4219
4549
  instructionsSegment = entries.join("\u0006");
4220
4550
  }
4221
- const date = new Date().toISOString().slice(0, 10);
4222
- return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}|${date}`;
4551
+ return `${nameSegment}\u0003${descriptionSegment}\u0005${registrySegment}\u0007${instructionsSegment}`;
4223
4552
  }
4224
4553
 
4225
4554
  /**
@@ -4645,6 +4974,7 @@ export class AgentSession {
4645
4974
  setClientBridge(bridge: ClientBridge | undefined): void {
4646
4975
  this.#clientBridge = bridge;
4647
4976
  this.#acpPermissionDecisions.clear();
4977
+ this.#acpPermissionWrapperVersion++;
4648
4978
  const activeToolNames = this.getActiveToolNames();
4649
4979
  const activeTools = activeToolNames
4650
4980
  .map(name => this.#toolRegistry.get(name))
@@ -4839,6 +5169,94 @@ export class AgentSession {
4839
5169
  };
4840
5170
  }
4841
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
+
4842
5260
  /**
4843
5261
  * Send a prompt to the agent.
4844
5262
  * - Handles extension commands (registered via pi.registerCommand) immediately, even during streaming
@@ -5089,14 +5507,16 @@ export class AgentSession {
5089
5507
  if (planReferenceMessage) {
5090
5508
  messages.push(planReferenceMessage);
5091
5509
  }
5092
- const planModeMessage = await this.#buildPlanModeMessage();
5510
+ const planModeMessage = await this.#buildAutomaticPlanModeMessage();
5093
5511
  if (planModeMessage) {
5094
5512
  messages.push(planModeMessage);
5095
5513
  }
5096
- const goalModeMessage = this.#buildGoalModeMessage();
5514
+ const goalModeMessage = this.#buildAutomaticGoalModeMessage();
5097
5515
  if (goalModeMessage) {
5098
5516
  messages.push(goalModeMessage);
5099
5517
  }
5518
+ const volatileProjectContextMessage = await this.#buildVolatileProjectContextMessage();
5519
+ messages.push(volatileProjectContextMessage);
5100
5520
  if (options?.prependMessages) {
5101
5521
  messages.push(...options.prependMessages);
5102
5522
  }
@@ -5106,6 +5526,7 @@ export class AgentSession {
5106
5526
  // Early bail-out: if a newer abort/prompt cycle started during setup,
5107
5527
  // return before mutating shared state (nextTurn messages, system prompt).
5108
5528
  if (this.#promptGeneration !== generation) {
5529
+ this.#resetInjectedContextSignatures();
5109
5530
  return;
5110
5531
  }
5111
5532
 
@@ -5118,9 +5539,28 @@ export class AgentSession {
5118
5539
  // Auto-read @filepath mentions
5119
5540
  const fileMentions = extractFileMentions(expandedText);
5120
5541
  if (fileMentions.length > 0) {
5121
- 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, {
5122
5560
  autoResizeImages: this.settings.get("images.autoResize"),
5123
5561
  useHashLines: resolveFileDisplayMode(this).hashLines,
5562
+ maxInlineBytes: this.settings.get("tools.fileMentionInlineBytes") * 1024,
5563
+ recentlyShownPaths,
5124
5564
  });
5125
5565
  messages.push(...fileMentionMessages);
5126
5566
  }
@@ -5170,8 +5610,12 @@ export class AgentSession {
5170
5610
  this.#appendBeforeAgentStartCustomMessages(messages, contributed, promptAttribution, message.role);
5171
5611
  }
5172
5612
 
5173
- // 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.
5174
5617
  if (this.#promptGeneration !== generation) {
5618
+ this.#resetInjectedContextSignatures();
5175
5619
  return;
5176
5620
  }
5177
5621
 
@@ -5879,6 +6323,9 @@ export class AgentSession {
5879
6323
  if (eviction.evictedEntries > 0) await this.sessionManager.rewriteEntries();
5880
6324
  const sessionContext = this.buildDisplaySessionContext();
5881
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();
5882
6329
  this.#syncTodoPhasesFromBranch();
5883
6330
  this.#closeCodexProviderSessionsForHistoryRewrite();
5884
6331
 
@@ -6097,6 +6544,41 @@ export class AgentSession {
6097
6544
  return true;
6098
6545
  }
6099
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
+
6100
6582
  /**
6101
6583
  * Set a display name for the current session.
6102
6584
  */
@@ -6668,24 +7150,92 @@ export class AgentSession {
6668
7150
  const branchEntries = this.sessionManager.getBranch();
6669
7151
  const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
6670
7152
  const argumentResult = pruneAssistantToolArguments(branchEntries, DEFAULT_PRUNE_CONFIG);
6671
- const tokensSaved = result.tokensSaved + argumentResult.argumentTokensSaved;
6672
- 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;
6673
7159
  if (prunedCount === 0) {
6674
7160
  return undefined;
6675
7161
  }
6676
7162
 
6677
7163
  // getBranch() returns materialized copies for blob-externalized entries, so
6678
7164
  // the pruning mutations must be written back into the canonical store.
6679
- const combined = [...result.prunedEntries, ...argumentResult.prunedEntries];
7165
+ const combined = [...result.prunedEntries, ...argumentResult.prunedEntries, ...fileMentionResult.changed];
6680
7166
  this.sessionManager.applyEntryMessageUpdates(combined);
6681
7167
  await this.sessionManager.rewriteEntries();
6682
7168
  const sessionContext = this.buildDisplaySessionContext();
6683
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();
6684
7173
  this.#syncTodoPhasesFromBranch();
6685
7174
  this.#closeCodexProviderSessionsForHistoryRewrite();
6686
7175
  return { prunedCount, tokensSaved };
6687
7176
  }
6688
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
+
6689
7239
  /**
6690
7240
  * Manually compact the session context.
6691
7241
  * Aborts current agent operation first.
@@ -6708,7 +7258,9 @@ export class AgentSession {
6708
7258
 
6709
7259
  const compactionSettings = this.settings.getGroup("compaction");
6710
7260
  const pathEntries = this.sessionManager.getBranch();
6711
- const preparation = prepareCompaction(pathEntries, compactionSettings);
7261
+ const preparation = prepareCompaction(pathEntries, compactionSettings, {
7262
+ tokenCorrectionRatio: this.#computeCompactionTokenCorrectionRatio(),
7263
+ });
6712
7264
  if (!preparation) {
6713
7265
  // Check why we can't compact
6714
7266
  const lastEntry = pathEntries[pathEntries.length - 1];
@@ -7020,6 +7572,9 @@ export class AgentSession {
7020
7572
  // Rebuild agent messages from session
7021
7573
  const sessionContext = this.buildDisplaySessionContext();
7022
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();
7023
7578
  this.#syncTodoPhasesFromBranch();
7024
7579
 
7025
7580
  return { document: handoffText, savedPath };
@@ -7202,7 +7757,12 @@ export class AgentSession {
7202
7757
  // Model maxTokens is a capability ceiling, not a per-turn reservation.
7203
7758
  // Auto maintenance should track actual context fullness.
7204
7759
  const autoCompactionOutputReserveTokens = 0;
7205
- 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
+ }
7206
7766
 
7207
7767
  const pruneResult = await this.#pruneToolOutputs();
7208
7768
  if (pruneResult) {
@@ -7252,6 +7812,7 @@ export class AgentSession {
7252
7812
  }
7253
7813
  const safeCount = Math.max(0, Math.min(checkpointState.checkpointMessageCount, this.agent.state.messages.length));
7254
7814
  this.agent.replaceMessages(this.agent.state.messages.slice(0, safeCount));
7815
+ this.#resetInjectedContextSignatures();
7255
7816
  try {
7256
7817
  this.sessionManager.branchWithSummary(checkpointState.checkpointEntryId, report, {
7257
7818
  startedAt: checkpointState.startedAt,
@@ -7340,7 +7901,7 @@ export class AgentSession {
7340
7901
  return undefined;
7341
7902
  }
7342
7903
 
7343
- if (!this.#toolRegistry.has("todo_write")) {
7904
+ if (!this.#toolRegistry.has("todo_write") || !this.getActiveToolNames().includes("todo_write")) {
7344
7905
  logger.warn("Eager todo enforcement skipped because todo_write is unavailable", {
7345
7906
  activeToolNames: this.agent.state.tools.map(tool => tool.name),
7346
7907
  });
@@ -7557,13 +8118,13 @@ export class AgentSession {
7557
8118
  #syncAppendOnlyContext(model: Model | null | undefined): void {
7558
8119
  const setting = this.settings.get("provider.appendOnlyContext") ?? "auto";
7559
8120
  const providerId = model?.provider;
7560
- const enable = setting === "on" || (setting === "auto" && providerId === "deepseek");
8121
+ const enable = resolveAppendOnlyMode(setting, providerId ?? "");
7561
8122
  const prev = this.#lastAppendOnlyResolution;
7562
8123
  if (prev && prev.enable === enable && prev.providerId === providerId) return;
7563
8124
  this.#lastAppendOnlyResolution = { enable, providerId };
7564
8125
 
7565
8126
  if (enable && !this.agent.appendOnlyContext) {
7566
- this.agent.setAppendOnlyContext(new AppendOnlyContextManager());
8127
+ this.agent.setAppendOnlyContext(createAppendOnlyContextManager(providerId));
7567
8128
  } else if (enable && this.agent.appendOnlyContext) {
7568
8129
  // Already active — invalidate prefix + log so the next turn
7569
8130
  // rebuilds for the current model's normalization.
@@ -8166,7 +8727,13 @@ export class AgentSession {
8166
8727
 
8167
8728
  const pathEntries = this.sessionManager.getBranch();
8168
8729
 
8169
- 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
+ });
8170
8737
  if (!preparation) {
8171
8738
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
8172
8739
  await this.#emitSessionEvent({
@@ -9966,6 +10533,7 @@ export class AgentSession {
9966
10533
  }
9967
10534
 
9968
10535
  this.agent.replaceMessages(sessionContext.messages);
10536
+ this.#resetInjectedContextSignatures();
9969
10537
  this.#syncTodoPhasesFromBranch();
9970
10538
  if (switchingToDifferentSession) {
9971
10539
  this.#closeAllProviderSessions("session switch");
@@ -10138,6 +10706,7 @@ export class AgentSession {
10138
10706
 
10139
10707
  if (!skipConversationRestore) {
10140
10708
  this.agent.replaceMessages(sessionContext.messages);
10709
+ this.#resetInjectedContextSignatures();
10141
10710
  this.#closeCodexProviderSessionsForHistoryRewrite();
10142
10711
  }
10143
10712
 
@@ -10305,6 +10874,7 @@ export class AgentSession {
10305
10874
  const displayContext = deobfuscateSessionContext(stateContext, this.#obfuscator);
10306
10875
  await this.#restoreMCPSelectionsForSessionContext(displayContext);
10307
10876
  this.agent.replaceMessages(displayContext.messages);
10877
+ this.#resetInjectedContextSignatures();
10308
10878
  this.#syncTodoPhasesFromBranch();
10309
10879
  this.#closeCodexProviderSessionsForHistoryRewrite();
10310
10880
 
@@ -10503,6 +11073,57 @@ export class AgentSession {
10503
11073
  return this.#estimateContextTokensWith(message => this.#estimateMessageDisplayTokens(message));
10504
11074
  }
10505
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
+
10506
11127
  #estimateContextTokensForCompaction(pendingMessages: readonly AgentMessage[]): {
10507
11128
  tokens: number;
10508
11129
  } {
@@ -10791,6 +11412,10 @@ export class AgentSession {
10791
11412
  return this.#extensionRunner?.hasHandlers(eventType) ?? false;
10792
11413
  }
10793
11414
 
11415
+ #hasStreamingExtensionHandlers(): boolean {
11416
+ return this.hasExtensionHandlers("message_update");
11417
+ }
11418
+
10794
11419
  /**
10795
11420
  * Register a first-party internal before-agent-start contributor. Returns an
10796
11421
  * unregister function. This is NOT user-facing hook discovery; it is an