@gajae-code/coding-agent 0.4.4 → 0.5.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 (132) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/types/cli/fast-help.d.ts +1 -0
  3. package/dist/types/cli/setup-cli.d.ts +2 -0
  4. package/dist/types/commands/harness.d.ts +6 -0
  5. package/dist/types/commands/setup.d.ts +6 -0
  6. package/dist/types/config/model-profile-activation.d.ts +11 -2
  7. package/dist/types/config/model-profiles.d.ts +7 -0
  8. package/dist/types/config/model-registry.d.ts +6 -0
  9. package/dist/types/config/model-resolver.d.ts +2 -0
  10. package/dist/types/config/models-config-schema.d.ts +35 -0
  11. package/dist/types/config/settings-schema.d.ts +4 -3
  12. package/dist/types/coordinator/contract.d.ts +1 -1
  13. package/dist/types/coordinator-mcp/server.d.ts +8 -2
  14. package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
  15. package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
  16. package/dist/types/harness-control-plane/finalize.d.ts +5 -0
  17. package/dist/types/harness-control-plane/owner.d.ts +1 -1
  18. package/dist/types/harness-control-plane/phase-rollup.d.ts +23 -0
  19. package/dist/types/harness-control-plane/receipt-ingest.d.ts +19 -0
  20. package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
  21. package/dist/types/harness-control-plane/receipts.d.ts +46 -0
  22. package/dist/types/harness-control-plane/rpc-adapter.d.ts +3 -0
  23. package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
  24. package/dist/types/harness-control-plane/types.d.ts +13 -1
  25. package/dist/types/hindsight/mental-models.d.ts +5 -5
  26. package/dist/types/main.d.ts +2 -2
  27. package/dist/types/modes/components/model-selector.d.ts +1 -12
  28. package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
  29. package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
  30. package/dist/types/modes/utils/abort-message.d.ts +4 -0
  31. package/dist/types/sdk.d.ts +5 -0
  32. package/dist/types/session/agent-session.d.ts +2 -0
  33. package/dist/types/session/blob-store.d.ts +20 -1
  34. package/dist/types/session/session-manager.d.ts +32 -6
  35. package/dist/types/session/streaming-output.d.ts +3 -2
  36. package/dist/types/session/tool-choice-queue.d.ts +6 -0
  37. package/dist/types/setup/hermes-setup.d.ts +7 -0
  38. package/dist/types/task/fork-context-advisory.d.ts +13 -0
  39. package/dist/types/task/receipt.d.ts +2 -0
  40. package/dist/types/task/roi-reconciliation.d.ts +27 -0
  41. package/dist/types/task/types.d.ts +17 -0
  42. package/dist/types/thinking-metadata.d.ts +16 -0
  43. package/dist/types/thinking.d.ts +3 -12
  44. package/dist/types/tools/index.d.ts +2 -0
  45. package/dist/types/tools/resolve.d.ts +0 -10
  46. package/dist/types/utils/tool-choice.d.ts +14 -1
  47. package/package.json +8 -7
  48. package/scripts/build-binary.ts +4 -0
  49. package/src/cli/fast-help.ts +80 -0
  50. package/src/cli/setup-cli.ts +12 -3
  51. package/src/cli.ts +112 -17
  52. package/src/commands/coordinator.ts +44 -1
  53. package/src/commands/harness.ts +128 -11
  54. package/src/commands/launch.ts +2 -2
  55. package/src/commands/mcp-serve.ts +3 -2
  56. package/src/commands/session.ts +3 -1
  57. package/src/commands/setup.ts +4 -0
  58. package/src/config/model-profile-activation.ts +15 -3
  59. package/src/config/model-profiles.ts +255 -56
  60. package/src/config/model-resolver.ts +9 -6
  61. package/src/config/models-config-schema.ts +2 -0
  62. package/src/config/settings-schema.ts +6 -3
  63. package/src/coordinator/contract.ts +1 -0
  64. package/src/coordinator-mcp/server.ts +427 -193
  65. package/src/cursor.ts +46 -4
  66. package/src/defaults/gjc/skills/team/SKILL.md +3 -2
  67. package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
  68. package/src/export/html/index.ts +13 -9
  69. package/src/gjc-runtime/launch-worktree.ts +12 -1
  70. package/src/gjc-runtime/session-state-sidecar.ts +38 -0
  71. package/src/gjc-runtime/team-runtime.ts +33 -7
  72. package/src/gjc-runtime/tmux-common.ts +15 -0
  73. package/src/gjc-runtime/tmux-sessions.ts +19 -11
  74. package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
  75. package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
  76. package/src/gjc-runtime/workflow-manifest.ts +16 -1
  77. package/src/harness-control-plane/finalize.ts +39 -5
  78. package/src/harness-control-plane/owner.ts +87 -28
  79. package/src/harness-control-plane/phase-rollup.ts +96 -0
  80. package/src/harness-control-plane/receipt-ingest.ts +127 -0
  81. package/src/harness-control-plane/receipt-spool.ts +128 -0
  82. package/src/harness-control-plane/receipts.ts +229 -1
  83. package/src/harness-control-plane/rpc-adapter.ts +8 -0
  84. package/src/harness-control-plane/state-machine.ts +27 -6
  85. package/src/harness-control-plane/storage.ts +23 -0
  86. package/src/harness-control-plane/types.ts +33 -1
  87. package/src/hindsight/mental-models.ts +17 -16
  88. package/src/internal-urls/docs-index.generated.ts +8 -7
  89. package/src/main.ts +7 -3
  90. package/src/modes/components/assistant-message.ts +26 -14
  91. package/src/modes/components/diff.ts +97 -0
  92. package/src/modes/components/model-selector.ts +353 -181
  93. package/src/modes/components/status-line.ts +6 -6
  94. package/src/modes/components/tool-execution.ts +30 -13
  95. package/src/modes/controllers/event-controller.ts +5 -4
  96. package/src/modes/controllers/selector-controller.ts +33 -42
  97. package/src/modes/interactive-mode.ts +4 -5
  98. package/src/modes/print-mode.ts +1 -1
  99. package/src/modes/rpc/rpc-client.ts +3 -2
  100. package/src/modes/rpc/rpc-mode.ts +44 -14
  101. package/src/modes/rpc/rpc-types.ts +5 -2
  102. package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
  103. package/src/modes/shared/agent-wire/command-validation.ts +11 -0
  104. package/src/modes/theme/theme.ts +2 -2
  105. package/src/modes/utils/abort-message.ts +41 -0
  106. package/src/modes/utils/context-usage.ts +15 -8
  107. package/src/modes/utils/ui-helpers.ts +5 -6
  108. package/src/sdk.ts +38 -6
  109. package/src/secrets/obfuscator.ts +102 -27
  110. package/src/session/agent-session.ts +121 -25
  111. package/src/session/blob-store.ts +89 -3
  112. package/src/session/session-manager.ts +328 -57
  113. package/src/session/streaming-output.ts +185 -122
  114. package/src/session/tool-choice-queue.ts +23 -0
  115. package/src/setup/hermes/templates/operator-instructions.v1.md +3 -2
  116. package/src/setup/hermes-setup.ts +63 -8
  117. package/src/task/executor.ts +69 -6
  118. package/src/task/fork-context-advisory.ts +99 -0
  119. package/src/task/index.ts +31 -2
  120. package/src/task/receipt.ts +7 -0
  121. package/src/task/render.ts +21 -1
  122. package/src/task/roi-reconciliation.ts +90 -0
  123. package/src/task/types.ts +15 -0
  124. package/src/thinking-metadata.ts +51 -0
  125. package/src/thinking.ts +26 -46
  126. package/src/tools/bash.ts +1 -1
  127. package/src/tools/index.ts +4 -2
  128. package/src/tools/resolve.ts +93 -18
  129. package/src/tools/subagent-render.ts +10 -1
  130. package/src/utils/edit-mode.ts +1 -1
  131. package/src/utils/title-generator.ts +16 -2
  132. package/src/utils/tool-choice.ts +45 -16
@@ -27,6 +27,7 @@ import {
27
27
  } from "../../session/messages";
28
28
  import type { SessionContext } from "../../session/session-manager";
29
29
  import { formatBytes, formatDuration } from "../../tools/render-utils";
30
+ import { buildAbortDisplayMessage } from "./abort-message";
30
31
 
31
32
  type TextBlock = { type: "text"; text: string };
32
33
  interface RenderInitialMessagesOptions {
@@ -319,12 +320,10 @@ export class UiHelpers {
319
320
  !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
320
321
  const errorMessage = hasErrorStop
321
322
  ? message.stopReason === "aborted"
322
- ? (() => {
323
- const retryAttempt = this.ctx.session.retryAttempt;
324
- return retryAttempt > 0
325
- ? `Aborted after ${retryAttempt} retry attempt${retryAttempt > 1 ? "s" : ""}`
326
- : "Operation aborted";
327
- })()
323
+ ? buildAbortDisplayMessage({
324
+ errorMessage: message.errorMessage,
325
+ retryAttempt: this.ctx.session.retryAttempt,
326
+ })
328
327
  : message.errorMessage || "Error"
329
328
  : null;
330
329
 
package/src/sdk.ts CHANGED
@@ -133,7 +133,7 @@ import { ToolContextStore } from "./tools/context";
133
133
  import { getImageGenTools } from "./tools/image-gen";
134
134
  import { wrapToolWithMetaNotice } from "./tools/output-meta";
135
135
  import { EventBus } from "./utils/event-bus";
136
- import { buildNamedToolChoice } from "./utils/tool-choice";
136
+ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "./utils/tool-choice";
137
137
  import { buildWorkspaceTree, type WorkspaceTree } from "./workspace-tree";
138
138
 
139
139
  type AsyncResultEntry = {
@@ -234,6 +234,8 @@ export interface CreateAgentSessionOptions {
234
234
  modelPattern?: string;
235
235
  /** Thinking selector. Default: from settings, else unset */
236
236
  thinkingLevel?: ThinkingLevel;
237
+ /** Runtime substitution metadata for the initial model_change session event. */
238
+ modelSubstitution?: { requestedModel: Model; reason: string };
237
239
  /** Models available for cycling (Ctrl+P in interactive mode) */
238
240
  scopedModels?: ScopedModelSelection[];
239
241
 
@@ -1212,6 +1214,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1212
1214
  const m = session.model;
1213
1215
  return m ? buildNamedToolChoice(name, m) : undefined;
1214
1216
  },
1217
+ buildToolChoiceResult: name => buildNamedToolChoiceResult(name, session.model),
1215
1218
  steer: msg =>
1216
1219
  session.agent.steer({
1217
1220
  role: "custom",
@@ -1622,9 +1625,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1622
1625
  };
1623
1626
 
1624
1627
  const toolNamesFromRegistry = Array.from(toolRegistry.keys());
1625
- const requestedToolNames =
1626
- (options.toolNames ? [...new Set(options.toolNames.map(name => name.toLowerCase()))] : undefined) ??
1627
- toolNamesFromRegistry;
1628
+ const requestedToolNames = options.toolNames
1629
+ ? [
1630
+ ...new Set([
1631
+ ...options.toolNames.map(name => name.toLowerCase()),
1632
+ ...(settings.get("goal.enabled") ? ["goal"] : []),
1633
+ ]),
1634
+ ]
1635
+ : toolNamesFromRegistry;
1628
1636
  const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
1629
1637
  const requestedToolNameSet = new Set(normalizedRequested);
1630
1638
  // Effective discovery mode only covers built-in tools; MCP tool discovery
@@ -1635,7 +1643,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1635
1643
  const defaultInactiveToolNames = new Set(
1636
1644
  registeredTools.filter(tool => tool.definition.defaultInactive).map(tool => tool.definition.name),
1637
1645
  );
1638
- const requestedActiveToolNames = normalizedRequested.filter(name => name !== "goal");
1646
+ const requestedActiveToolNames = normalizedRequested;
1639
1647
  const initialRequestedActiveToolNames = options.toolNames
1640
1648
  ? requestedActiveToolNames
1641
1649
  : requestedActiveToolNames.filter(name => !defaultInactiveToolNames.has(name));
@@ -1893,6 +1901,19 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1893
1901
  }
1894
1902
  return result;
1895
1903
  },
1904
+ onToolChoiceIncapability: event => {
1905
+ const droppedLabel = session?.toolChoiceQueue.degradeInFlight(event.reason);
1906
+ logger.debug("Dropped in-flight tool choice after runtime incapability", {
1907
+ droppedLabel,
1908
+ api: event.api,
1909
+ provider: event.provider,
1910
+ model: event.model,
1911
+ requestedLevel: event.requestedLevel,
1912
+ resolvedLevel: event.resolvedLevel,
1913
+ reason: event.reason,
1914
+ registryKey: event.registryKey,
1915
+ });
1916
+ },
1896
1917
  intentTracing: !!intentField,
1897
1918
  getToolChoice: () => session?.nextToolChoice(),
1898
1919
  telemetry: options.telemetry,
@@ -1907,7 +1928,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1907
1928
  } else {
1908
1929
  // Save initial model, thinking level, and service tier for new sessions so they can be restored on resume.
1909
1930
  if (model) {
1910
- sessionManager.appendModelChange(`${model.provider}/${model.id}`);
1931
+ const substitution = options.modelSubstitution;
1932
+ sessionManager.appendModelChange(
1933
+ `${model.provider}/${model.id}`,
1934
+ undefined,
1935
+ substitution
1936
+ ? {
1937
+ previousModel: `${substitution.requestedModel.provider}/${substitution.requestedModel.id}`,
1938
+ reason: substitution.reason,
1939
+ thinkingLevel: thinkingLevel ?? null,
1940
+ }
1941
+ : undefined,
1942
+ );
1911
1943
  }
1912
1944
  sessionManager.appendThinkingLevelChange(thinkingLevel);
1913
1945
  if (initialServiceTier) {
@@ -73,9 +73,24 @@ export class SecretObfuscator {
73
73
  /** Replace-mode plain mappings: secret → replacement */
74
74
  #replaceMappings = new Map<string, string>();
75
75
 
76
+ /** Replace-mode plain mappings sorted longest-first for deterministic longest-match replacement. */
77
+ #sortedReplaceMappings: Array<{ secret: string; replacement: string }> = [];
78
+
79
+ /** Obfuscate-mode plain and regex-discovered mappings sorted longest-first. */
80
+ #sortedObfuscateMappings: Array<{ secret: string; index: number; placeholder: string }> = [];
81
+
82
+ /** Reverse lookup for obfuscate-mode secrets to avoid scanning mappings. */
83
+ #obfuscateIndexBySecret = new Map<string, number>();
84
+
76
85
  /** Reverse lookup for deobfuscation: placeholder → secret */
77
86
  #deobfuscateMap = new Map<string, string>();
78
87
 
88
+ /** Combined plain-secret regex cache for single-pass replacement. */
89
+ #combinedPlainRegex: RegExp | undefined;
90
+ #combinedPlainReplacementBySecret = new Map<string, string>();
91
+ #combinedPlainRegexDirty = true;
92
+ #useSequentialPlainReplacement = false;
93
+
79
94
  /** Next available index for regex match discoveries */
80
95
  #nextIndex: number;
81
96
 
@@ -93,6 +108,7 @@ export class SecretObfuscator {
93
108
  this.#plainMappings.set(entry.content, index);
94
109
  this.#obfuscateMappings.set(index, { secret: entry.content, placeholder });
95
110
  this.#deobfuscateMap.set(placeholder, entry.content);
111
+ this.#obfuscateIndexBySecret.set(entry.content, index);
96
112
  index++;
97
113
  } else {
98
114
  // replace mode
@@ -111,6 +127,16 @@ export class SecretObfuscator {
111
127
  }
112
128
 
113
129
  this.#nextIndex = index;
130
+ this.#sortedReplaceMappings = [...this.#replaceMappings]
131
+ .sort((a, b) => b[0].length - a[0].length)
132
+ .map(([secret, replacement]) => ({ secret, replacement }));
133
+ this.#sortedObfuscateMappings = [...this.#plainMappings]
134
+ .sort((a, b) => b[0].length - a[0].length)
135
+ .map(([secret, mappingIndex]) => ({
136
+ secret,
137
+ index: mappingIndex,
138
+ placeholder: this.#obfuscateMappings.get(mappingIndex)!.placeholder,
139
+ }));
114
140
  this.#hasAny = entries.length > 0;
115
141
  }
116
142
 
@@ -121,18 +147,7 @@ export class SecretObfuscator {
121
147
  /** Obfuscate all secrets in text. Bidirectional placeholders for obfuscate mode, one-way for replace. */
122
148
  obfuscate(text: string): string {
123
149
  if (!this.#hasAny) return text;
124
- let result = text;
125
-
126
- // 1. Process replace-mode plain secrets
127
- for (const [secret, replacement] of [...this.#replaceMappings].sort((a, b) => b[0].length - a[0].length)) {
128
- result = replaceAll(result, secret, replacement);
129
- }
130
-
131
- // 2. Process obfuscate-mode plain secrets
132
- for (const [secret, index] of [...this.#plainMappings].sort((a, b) => b[0].length - a[0].length)) {
133
- const mapping = this.#obfuscateMappings.get(index)!;
134
- result = replaceAll(result, secret, mapping.placeholder);
135
- }
150
+ let result = this.#obfuscatePlainMappings(text);
136
151
 
137
152
  // 3. Process regex entries — discover new matches
138
153
  for (const entry of this.#regexEntries) {
@@ -160,6 +175,9 @@ export class SecretObfuscator {
160
175
  const placeholder = buildPlaceholder(index);
161
176
  this.#obfuscateMappings.set(index, { secret: matchValue, placeholder });
162
177
  this.#deobfuscateMap.set(placeholder, matchValue);
178
+ this.#obfuscateIndexBySecret.set(matchValue, index);
179
+ this.#insertSortedObfuscateMapping({ secret: matchValue, index, placeholder });
180
+ this.#combinedPlainRegexDirty = true;
163
181
  }
164
182
  const mapping = this.#obfuscateMappings.get(index)!;
165
183
  result = replaceAll(result, matchValue, mapping.placeholder);
@@ -186,15 +204,74 @@ export class SecretObfuscator {
186
204
 
187
205
  /** Find the obfuscate index for a known secret value. */
188
206
  #findObfuscateIndex(secret: string): number | undefined {
189
- // Check plain mappings first
190
- const plainIndex = this.#plainMappings.get(secret);
191
- if (plainIndex !== undefined) return plainIndex;
207
+ return this.#obfuscateIndexBySecret.get(secret);
208
+ }
209
+
210
+ #insertSortedObfuscateMapping(mapping: { secret: string; index: number; placeholder: string }): void {
211
+ let lo = 0;
212
+ let hi = this.#sortedObfuscateMappings.length;
213
+ while (lo < hi) {
214
+ const mid = (lo + hi) >> 1;
215
+ if (this.#sortedObfuscateMappings[mid]!.secret.length < mapping.secret.length) {
216
+ hi = mid;
217
+ } else {
218
+ lo = mid + 1;
219
+ }
220
+ }
221
+ this.#sortedObfuscateMappings.splice(lo, 0, mapping);
222
+ }
223
+
224
+ #obfuscatePlainMappings(text: string): string {
225
+ this.#ensureCombinedPlainRegex();
226
+ if (this.#useSequentialPlainReplacement) return this.#obfuscatePlainMappingsSequential(text);
227
+ if (!this.#combinedPlainRegex) return text;
228
+ return text.replace(
229
+ this.#combinedPlainRegex,
230
+ match => this.#combinedPlainReplacementBySecret.get(match) ?? match,
231
+ );
232
+ }
192
233
 
193
- // Check regex-discovered mappings
194
- for (const [index, mapping] of this.#obfuscateMappings) {
195
- if (mapping.secret === secret) return index;
234
+ #obfuscatePlainMappingsSequential(text: string): string {
235
+ let result = text;
236
+ for (const mapping of this.#sortedReplaceMappings) {
237
+ result = replaceAll(result, mapping.secret, mapping.replacement);
238
+ }
239
+ for (const mapping of this.#sortedObfuscateMappings) {
240
+ result = replaceAll(result, mapping.secret, mapping.placeholder);
196
241
  }
197
- return undefined;
242
+ return result;
243
+ }
244
+
245
+ #ensureCombinedPlainRegex(): void {
246
+ if (!this.#combinedPlainRegexDirty) return;
247
+ this.#combinedPlainRegexDirty = false;
248
+ this.#combinedPlainReplacementBySecret = new Map<string, string>();
249
+
250
+ const mappings = [
251
+ ...this.#sortedReplaceMappings.map(mapping => ({ secret: mapping.secret, replacement: mapping.replacement })),
252
+ ...this.#sortedObfuscateMappings.map(mapping => ({
253
+ secret: mapping.secret,
254
+ replacement: mapping.placeholder,
255
+ })),
256
+ ];
257
+
258
+ this.#useSequentialPlainReplacement = mappings.some((mapping, index) =>
259
+ mappings.some(
260
+ (other, otherIndex) =>
261
+ other.secret.length > 0 &&
262
+ (mapping.replacement.includes(other.secret) ||
263
+ (index !== otherIndex &&
264
+ (mapping.secret.includes(other.secret) || other.secret.includes(mapping.secret)))),
265
+ ),
266
+ );
267
+ for (const mapping of mappings) {
268
+ if (!this.#combinedPlainReplacementBySecret.has(mapping.secret))
269
+ this.#combinedPlainReplacementBySecret.set(mapping.secret, mapping.replacement);
270
+ }
271
+ this.#combinedPlainRegex =
272
+ mappings.length > 0
273
+ ? new RegExp(mappings.map(mapping => escapeRegex(mapping.secret)).join("|"), "g")
274
+ : undefined;
198
275
  }
199
276
  }
200
277
 
@@ -238,14 +315,12 @@ export function obfuscateMessages(obfuscator: SecretObfuscator, messages: Messag
238
315
 
239
316
  /** Replace all occurrences of `search` in `text` with `replacement`. */
240
317
  function replaceAll(text: string, search: string, replacement: string): string {
241
- if (search.length === 0) return text;
242
- let result = text;
243
- let idx = result.indexOf(search);
244
- while (idx !== -1) {
245
- result = result.slice(0, idx) + replacement + result.slice(idx + search.length);
246
- idx = result.indexOf(search, idx + replacement.length);
247
- }
248
- return result;
318
+ if (search.length === 0 || !text.includes(search)) return text;
319
+ return text.split(search).join(replacement);
320
+ }
321
+
322
+ function escapeRegex(value: string): string {
323
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
249
324
  }
250
325
 
251
326
  /** Deep-walk an object, transforming all string values. */
@@ -41,6 +41,7 @@ import {
41
41
  calculatePromptTokens,
42
42
  collectEntriesForBranchSummary,
43
43
  compact,
44
+ estimateMessageTokensHeuristic,
44
45
  estimateTokens,
45
46
  generateBranchSummary,
46
47
  generateHandoff,
@@ -243,7 +244,7 @@ import { parseCommandArgs } from "../utils/command-args";
243
244
  import { type EditMode, resolveEditMode } from "../utils/edit-mode";
244
245
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
245
246
  import { extractFileMentions, generateFileMentionMessages } from "../utils/file-mentions";
246
- import { buildNamedToolChoice } from "../utils/tool-choice";
247
+ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-choice";
247
248
  import type { AuthStorage } from "./auth-storage";
248
249
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
249
250
  import {
@@ -838,6 +839,8 @@ export type BeforeAgentStartInternalMessage = Pick<
838
839
  "customType" | "content" | "display" | "details" | "attribution"
839
840
  >;
840
841
 
842
+ type ProviderReplaySourceCacheEntry = { source: string; hash: bigint };
843
+
841
844
  /**
842
845
  * Internal (first-party, non-user-hook) contributor invoked at the active
843
846
  * before-agent-start point alongside the extension runner. Returns an optional
@@ -862,6 +865,7 @@ export class AgentSession {
862
865
 
863
866
  #scopedModels: ScopedModelSelection[];
864
867
  #thinkingLevel: ThinkingLevel | undefined;
868
+ #activeModelProfile: string | undefined;
865
869
  #promptTemplates: PromptTemplate[];
866
870
  #slashCommands: FileSlashCommand[];
867
871
 
@@ -1055,6 +1059,7 @@ export class AgentSession {
1055
1059
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1056
1060
  #obfuscator: SecretObfuscator | undefined;
1057
1061
  #checkpointState: CheckpointState | undefined = undefined;
1062
+ #providerReplaySourceCache = new WeakMap<AgentMessage, ProviderReplaySourceCacheEntry>();
1058
1063
  #pendingRewindReport: string | undefined = undefined;
1059
1064
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1060
1065
  #promptGeneration = 0;
@@ -1418,7 +1423,7 @@ export class AgentSession {
1418
1423
  recordSkip("unsupported-role");
1419
1424
  return undefined;
1420
1425
  }
1421
- const cloned = structuredClone(message) as Message;
1426
+ const cloned = cloneJsonValueForForkSeed(message) as Message;
1422
1427
  if ("providerPayload" in cloned) {
1423
1428
  delete (cloned as { providerPayload?: unknown }).providerPayload;
1424
1429
  }
@@ -1465,7 +1470,7 @@ export class AgentSession {
1465
1470
  }
1466
1471
  return {
1467
1472
  messages,
1468
- agentMessages: messages.map(message => structuredClone(message) as AgentMessage),
1473
+ agentMessages: messages.map(message => cloneJsonValueForForkSeed(message) as AgentMessage),
1469
1474
  metadata: {
1470
1475
  sourceSessionId: this.sessionId,
1471
1476
  parentMessageCount: providerMessages.length,
@@ -4388,7 +4393,7 @@ export class AgentSession {
4388
4393
  return false;
4389
4394
  }
4390
4395
 
4391
- const previousTools = this.getActiveToolNames().filter(name => name !== "goal");
4396
+ const previousTools = this.getActiveToolNames();
4392
4397
  const goalTools = [...new Set([...previousTools, "goal"])];
4393
4398
  await this.#goalRuntime.createGoal({ objective: pendingGoal.objective });
4394
4399
  await this.setActiveToolsByName(goalTools);
@@ -4587,7 +4592,7 @@ export class AgentSession {
4587
4592
  : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
4588
4593
  await this.refreshGjcSubskillTools();
4589
4594
 
4590
- if (eagerTodoPrelude) {
4595
+ if (eagerTodoPrelude?.toolChoice) {
4591
4596
  this.#toolChoiceQueue.pushOnce(eagerTodoPrelude.toolChoice, {
4592
4597
  label: "eager-todo",
4593
4598
  });
@@ -4748,6 +4753,9 @@ export class AgentSession {
4748
4753
  if (lastAssistant && !options?.skipCompactionCheck) {
4749
4754
  await this.#checkCompaction(lastAssistant, false);
4750
4755
  }
4756
+ if (!options?.skipCompactionCheck) {
4757
+ await this.#checkEstimatedContextBeforePrompt();
4758
+ }
4751
4759
 
4752
4760
  // Build messages array (session context, eager todo prelude, then active prompt message)
4753
4761
  const messages: AgentMessage[] = [];
@@ -5727,6 +5735,14 @@ export class AgentSession {
5727
5735
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
5728
5736
  }
5729
5737
 
5738
+ setActiveModelProfile(name: string | undefined): void {
5739
+ this.#activeModelProfile = name;
5740
+ }
5741
+
5742
+ getActiveModelProfile(): string | undefined {
5743
+ return this.#activeModelProfile;
5744
+ }
5745
+
5730
5746
  /**
5731
5747
  * Set model temporarily (for this session only).
5732
5748
  * Validates API key, saves to session log but NOT to settings.
@@ -6063,6 +6079,9 @@ export class AgentSession {
6063
6079
  return undefined;
6064
6080
  }
6065
6081
 
6082
+ // getBranch() returns materialized copies for blob-externalized entries, so
6083
+ // the pruning mutations must be written back into the canonical store.
6084
+ this.sessionManager.applyEntryMessageUpdates(result.prunedEntries);
6066
6085
  await this.sessionManager.rewriteEntries();
6067
6086
  const sessionContext = this.buildDisplaySessionContext();
6068
6087
  this.agent.replaceMessages(sessionContext.messages);
@@ -6507,12 +6526,18 @@ export class AgentSession {
6507
6526
  // Case 2: Threshold - turn succeeded but context is getting large
6508
6527
  // Skip if this was an error (non-overflow errors don't have usage data)
6509
6528
  if (assistantMessage.stopReason === "error") return;
6510
- const pruneResult = await this.#pruneToolOutputs();
6511
6529
  let contextTokens = calculateContextTokens(assistantMessage.usage);
6530
+ const maxOutputTokens = this.model?.maxTokens ?? 0;
6531
+ // Cache-epoch invariant: pruning rewrites already-sent toolResult history,
6532
+ // which breaks the provider prompt-cache prefix mid-epoch. Only prune at a
6533
+ // sanctioned maintenance boundary, i.e. when the un-pruned context already
6534
+ // crosses the compaction threshold. Pruning may then avert full compaction.
6535
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6536
+ const pruneResult = await this.#pruneToolOutputs();
6512
6537
  if (pruneResult) {
6513
6538
  contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6514
6539
  }
6515
- if (shouldCompact(contextTokens, contextWindow, compactionSettings, this.model?.maxTokens ?? 0)) {
6540
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6516
6541
  // Try promotion first — if a larger model is available, switch instead of compacting
6517
6542
  const promoted = await this.#tryContextPromotion(assistantMessage);
6518
6543
  if (!promoted) {
@@ -6520,6 +6545,31 @@ export class AgentSession {
6520
6545
  }
6521
6546
  }
6522
6547
  }
6548
+
6549
+ async #checkEstimatedContextBeforePrompt(): Promise<void> {
6550
+ const model = this.model;
6551
+ if (!model) return;
6552
+ const contextWindow = model.contextWindow ?? 0;
6553
+ if (contextWindow <= 0) return;
6554
+ const compactionSettings = this.settings.getGroup("compaction");
6555
+ if (!compactionSettings.enabled || compactionSettings.strategy === "off") return;
6556
+
6557
+ let contextTokens = this.#estimateContextTokens().tokens;
6558
+ const maxOutputTokens = model.maxTokens ?? 0;
6559
+ if (!shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) return;
6560
+
6561
+ const pruneResult = await this.#pruneToolOutputs();
6562
+ if (pruneResult) {
6563
+ contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
6564
+ }
6565
+ if (shouldCompact(contextTokens, contextWindow, compactionSettings, maxOutputTokens)) {
6566
+ await this.#runAutoCompaction("threshold", false, false, {
6567
+ continueAfterMaintenance: false,
6568
+ deferHandoffMaintenance: false,
6569
+ });
6570
+ }
6571
+ }
6572
+
6523
6573
  #assistantEndedWithSuccessfulYield(assistantMessage: AssistantMessage): boolean {
6524
6574
  const toolCallId = this.#lastSuccessfulYieldToolCallId;
6525
6575
  if (!toolCallId) return false;
@@ -6617,7 +6667,7 @@ export class AgentSession {
6617
6667
  });
6618
6668
  }
6619
6669
 
6620
- #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice: ToolChoice } | undefined {
6670
+ #createEagerTodoPrelude(promptText: string): { message: AgentMessage; toolChoice?: ToolChoice } | undefined {
6621
6671
  const eagerTodosEnabled = this.settings.get("todo.eager");
6622
6672
  const todosEnabled = this.settings.get("todo.enabled");
6623
6673
  if (!eagerTodosEnabled || !todosEnabled) {
@@ -6651,13 +6701,15 @@ export class AgentSession {
6651
6701
  return undefined;
6652
6702
  }
6653
6703
 
6654
- const todoWriteToolChoice = buildNamedToolChoice("todo_write", this.model);
6655
- if (!todoWriteToolChoice) {
6656
- logger.warn("Eager todo enforcement skipped because the current model does not support forcing todo_write", {
6704
+ const todoWriteToolChoiceResult = buildNamedToolChoiceResult("todo_write", this.model);
6705
+ const todoWriteToolChoice = todoWriteToolChoiceResult.exactNamed ? todoWriteToolChoiceResult.choice : undefined;
6706
+ if (!todoWriteToolChoiceResult.exactNamed) {
6707
+ logger.debug("Eager todo enforcement degraded; sending reminder without forced tool choice", {
6657
6708
  modelApi: this.model?.api,
6658
6709
  modelId: this.model?.id,
6710
+ resolvedLevel: todoWriteToolChoiceResult.resolved?.resolvedLevel,
6711
+ reason: todoWriteToolChoiceResult.resolved?.reason,
6659
6712
  });
6660
- return undefined;
6661
6713
  }
6662
6714
 
6663
6715
  const eagerTodoReminder = prompt.render(eagerTodoPrompt);
@@ -7039,11 +7091,37 @@ export class AgentSession {
7039
7091
  }
7040
7092
  }
7041
7093
 
7094
+ #getProviderReplaySource(message: AgentMessage): ProviderReplaySourceCacheEntry {
7095
+ const cached = this.#providerReplaySourceCache.get(message);
7096
+ if (cached) return cached;
7097
+ const source = JSON.stringify(this.#normalizeSessionMessageForProviderReplay(message));
7098
+ const hash = this.#hashProviderReplaySource(source);
7099
+ const entry = { source, hash };
7100
+ this.#providerReplaySourceCache.set(message, entry);
7101
+ return entry;
7102
+ }
7103
+
7104
+ #hashProviderReplaySource(source: string): bigint {
7105
+ return Bun.hash.xxHash64(source);
7106
+ }
7107
+
7042
7108
  #didSessionMessagesChange(previousMessages: AgentMessage[], nextMessages: AgentMessage[]): boolean {
7043
- return (
7044
- JSON.stringify(previousMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message))) !==
7045
- JSON.stringify(nextMessages.map(message => this.#normalizeSessionMessageForProviderReplay(message)))
7046
- );
7109
+ if (previousMessages.length !== nextMessages.length) return true;
7110
+
7111
+ const previousSources: ProviderReplaySourceCacheEntry[] = [];
7112
+ const nextSources: ProviderReplaySourceCacheEntry[] = [];
7113
+ for (let i = 0; i < previousMessages.length; i++) {
7114
+ const previous = this.#getProviderReplaySource(previousMessages[i]!);
7115
+ const next = this.#getProviderReplaySource(nextMessages[i]!);
7116
+ if (previous.hash !== next.hash) return true;
7117
+ previousSources.push(previous);
7118
+ nextSources.push(next);
7119
+ }
7120
+
7121
+ for (let i = 0; i < previousSources.length; i++) {
7122
+ if (previousSources[i]!.source !== nextSources[i]!.source) return true;
7123
+ }
7124
+ return false;
7047
7125
  }
7048
7126
 
7049
7127
  #getModelKey(model: Model): string {
@@ -7248,17 +7326,24 @@ export class AgentSession {
7248
7326
  reason: "overflow" | "threshold" | "idle",
7249
7327
  willRetry: boolean,
7250
7328
  deferred = false,
7329
+ options?: { continueAfterMaintenance?: boolean; deferHandoffMaintenance?: boolean },
7251
7330
  ): Promise<void> {
7252
7331
  const compactionSettings = this.settings.getGroup("compaction");
7253
7332
  if (compactionSettings.strategy === "off") return;
7254
7333
  if (reason !== "idle" && !compactionSettings.enabled) return;
7255
7334
  const generation = this.#promptGeneration;
7256
- if (!deferred && reason !== "overflow" && reason !== "idle" && compactionSettings.strategy === "handoff") {
7335
+ if (
7336
+ options?.deferHandoffMaintenance !== false &&
7337
+ !deferred &&
7338
+ reason !== "overflow" &&
7339
+ reason !== "idle" &&
7340
+ compactionSettings.strategy === "handoff"
7341
+ ) {
7257
7342
  this.#schedulePostPromptTask(
7258
7343
  async signal => {
7259
7344
  await Promise.resolve();
7260
7345
  if (signal.aborted) return;
7261
- await this.#runAutoCompaction(reason, willRetry, true);
7346
+ await this.#runAutoCompaction(reason, willRetry, true, options);
7262
7347
  },
7263
7348
  { generation },
7264
7349
  );
@@ -7267,6 +7352,7 @@ export class AgentSession {
7267
7352
 
7268
7353
  let action: "context-full" | "handoff" =
7269
7354
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
7355
+ const continueAfterMaintenance = options?.continueAfterMaintenance !== false;
7270
7356
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
7271
7357
  // Abort any older auto-compaction before installing this run's controller.
7272
7358
  this.#autoCompactionAbortController?.abort();
@@ -7306,7 +7392,12 @@ export class AgentSession {
7306
7392
  aborted: false,
7307
7393
  willRetry: false,
7308
7394
  });
7309
- if (!autoCompactionSignal.aborted && reason !== "idle" && compactionSettings.autoContinue !== false) {
7395
+ if (
7396
+ continueAfterMaintenance &&
7397
+ !autoCompactionSignal.aborted &&
7398
+ reason !== "idle" &&
7399
+ compactionSettings.autoContinue !== false
7400
+ ) {
7310
7401
  this.#scheduleAutoContinuePrompt(generation);
7311
7402
  }
7312
7403
  return;
@@ -7368,7 +7459,7 @@ export class AgentSession {
7368
7459
  stopReason: tail?.stopReason,
7369
7460
  });
7370
7461
  }
7371
- } else if (reason !== "idle" && this.agent.hasQueuedMessages()) {
7462
+ } else if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
7372
7463
  this.#scheduleAgentContinue({
7373
7464
  delayMs: 100,
7374
7465
  generation,
@@ -7376,7 +7467,7 @@ export class AgentSession {
7376
7467
  onSkip: skipReason => this.#logCompactionContinuationSkipped("queued_continue", skipReason),
7377
7468
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
7378
7469
  });
7379
- } else if (reason !== "idle" && compactionSettings.autoContinue !== false) {
7470
+ } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
7380
7471
  this.#scheduleAutoContinuePrompt(generation);
7381
7472
  }
7382
7473
  return;
@@ -7597,7 +7688,7 @@ export class AgentSession {
7597
7688
  onError: error => this.#logCompactionContinuationError("overflow_retry", error),
7598
7689
  });
7599
7690
  }
7600
- } else if (reason !== "idle" && this.agent.hasQueuedMessages()) {
7691
+ } else if (continueAfterMaintenance && reason !== "idle" && this.agent.hasQueuedMessages()) {
7601
7692
  // Auto-compaction can complete while follow-up/steering/custom messages are waiting.
7602
7693
  // Kick the loop so queued messages are actually delivered.
7603
7694
  this.#scheduleAgentContinue({
@@ -7607,7 +7698,7 @@ export class AgentSession {
7607
7698
  onSkip: reason => this.#logCompactionContinuationSkipped("queued_continue", reason),
7608
7699
  onError: error => this.#logCompactionContinuationError("queued_continue", error),
7609
7700
  });
7610
- } else if (reason !== "idle" && compactionSettings.autoContinue !== false) {
7701
+ } else if (continueAfterMaintenance && reason !== "idle" && compactionSettings.autoContinue !== false) {
7611
7702
  this.#scheduleAutoContinuePrompt(generation);
7612
7703
  }
7613
7704
  } catch (error) {
@@ -8338,6 +8429,7 @@ export class AgentSession {
8338
8429
  onChunk,
8339
8430
  signal: abortController.signal,
8340
8431
  sessionKey: this.sessionId,
8432
+ cwd,
8341
8433
  timeout: clampTimeout("bash") * 1000,
8342
8434
  env: buildGjcRuntimeSessionEnv({
8343
8435
  sessionFile: this.sessionManager.getSessionFile(),
@@ -9527,7 +9619,7 @@ export class AgentSession {
9527
9619
  // No usage data - estimate all messages
9528
9620
  let estimated = 0;
9529
9621
  for (const message of messages) {
9530
- estimated += estimateTokens(message);
9622
+ estimated += estimateMessageTokensHeuristic(message);
9531
9623
  }
9532
9624
  return {
9533
9625
  tokens: estimated,
@@ -9537,7 +9629,7 @@ export class AgentSession {
9537
9629
  const usageTokens = calculatePromptTokens(lastUsage);
9538
9630
  let trailingTokens = 0;
9539
9631
  for (let i = lastUsageIndex + 1; i < messages.length; i++) {
9540
- trailingTokens += estimateTokens(messages[i]);
9632
+ trailingTokens += estimateMessageTokensHeuristic(messages[i]);
9541
9633
  }
9542
9634
 
9543
9635
  return {
@@ -9757,3 +9849,7 @@ export class AgentSession {
9757
9849
  return this.#extensionRunner;
9758
9850
  }
9759
9851
  }
9852
+
9853
+ function cloneJsonValueForForkSeed<T>(value: T): T {
9854
+ return JSON.parse(JSON.stringify(value)) as T;
9855
+ }