@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
package/dist/agent.js CHANGED
@@ -1,16 +1,17 @@
1
+ import { toolImageObservation } from "./context/tool-images.js";
1
2
  /**
2
3
  * Agent - The core decision loop.
3
4
  * It maintains message state, calls the LLM, executes tools, and auto-continues.
4
5
  */
5
- import { compactCurrentTurnToolGroups, compactMessages } from "./context/compact.js";
6
+ import { compactCurrentTurnToolGroups, compactMessages, isCompactionSummaryMessage, splitLeadingContext } from "./context/compact.js";
7
+ import { createContextCheckpoint, checkpointMessages, hasCompleteToolGroups } from "./context/checkpoint.js";
6
8
  import { randomUUID } from "node:crypto";
7
- import { compactMessagesWithLLM } from "./context/compact-llm.js";
8
9
  import { estimateToolDefinitionsTokens, getContextBudget, getMaxInputTokens, } from "./context/budget.js";
9
10
  import { buildContextUsageSnapshot } from "./context/usage.js";
10
11
  import { isContextOverflowError } from "./context/overflow.js";
11
12
  import { computeRetryDelayMs, isProviderStreamInterruption, providerStreamRetryLimit, sleepBeforeRetry, } from "./network/retry.js";
12
13
  import { projectMessages } from "./context/projector.js";
13
- import { aggressivePruneMessages, markStableCurrentToolResultsForCache, markToolResultCacheStable } from "./context/prune.js";
14
+ import { aggressivePruneMessages, pruneMessages, markStableCurrentToolResultsForCache, markToolResultCacheStable } from "./context/prune.js";
14
15
  import { normalizeToolResultForModel, truncateToolOutputForModel } from "./context/tool-output-truncate.js";
15
16
  import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
16
17
  import { HookBus } from "./orchestrator/hooks.js";
@@ -127,6 +128,9 @@ export class Agent {
127
128
  _modeVersion = 0;
128
129
  onModeUpdate;
129
130
  onCompactionApplied;
131
+ onContextCheckpoint;
132
+ getContextRevision;
133
+ lastCompactionId;
130
134
  onMessageAppend;
131
135
  onProviderError;
132
136
  onToolResult;
@@ -139,6 +143,7 @@ export class Agent {
139
143
  budgetLedger;
140
144
  budgetSource;
141
145
  skillSummaries;
146
+ skillCatalogChars;
142
147
  memoryPrompt;
143
148
  fileStateTracker;
144
149
  agentCategories;
@@ -166,7 +171,8 @@ export class Agent {
166
171
  // unanswerable from the outside. `fired` counts successful compaction
167
172
  // computations including ones whose rewrite was later rejected — a
168
173
  // fired-but-never-written gap is the churn signal.
169
- compactionStats = { resident: 0, subturn: 0, llm: 0, overflow: 0, fired: 0, droppedMessages: 0 };
174
+ compactionStats = { resident: 0, subturn: 0, llm: 0, heuristic: 0, overflow: 0, fired: 0, droppedMessages: 0 };
175
+ contextEvents = [];
170
176
  constructor(options) {
171
177
  this.provider = options.provider;
172
178
  this.sessionID = options.sessionID;
@@ -180,6 +186,8 @@ export class Agent {
180
186
  this.onToolResult = options.onToolResult;
181
187
  this.onModeUpdate = options.onModeUpdate;
182
188
  this.onCompactionApplied = options.onCompactionApplied;
189
+ this.onContextCheckpoint = options.onContextCheckpoint;
190
+ this.getContextRevision = options.getContextRevision;
183
191
  this.hookDefinitions = options.hooks ?? [];
184
192
  this.externalHooks = options.externalHooks;
185
193
  this.agentRole = options.agentRole ?? "parent";
@@ -189,6 +197,7 @@ export class Agent {
189
197
  this.budgetLedger = options.budgetLedger;
190
198
  this.budgetSource = options.budgetSource ?? { runId: this.sessionID ?? "agent" };
191
199
  this.skillSummaries = options.skills ?? [];
200
+ this.skillCatalogChars = options.skillCatalogChars;
192
201
  this.memoryPrompt = options.memoryPrompt;
193
202
  this.fileStateTracker = options.fileStateTracker;
194
203
  this.agentCategories = options.agentCategories ?? {};
@@ -230,6 +239,8 @@ export class Agent {
230
239
  // must see deferred-but-registered tools (an explicit include in a
231
240
  // profile is the author pre-unlocking them).
232
241
  allTools: () => [...this.tools.values()],
242
+ getSkillSummaries: () => this.getSkillSummaries(),
243
+ getSkillCatalogChars: () => this.getSkillCatalogChars(),
233
244
  createChild: (spec) => this.createChildAgent(spec),
234
245
  recordProviderError: (error, context) => this.recordProviderError(error, context),
235
246
  runExternalHook: (input, abortSignal) => this.runExternalHook(input, abortSignal),
@@ -365,6 +376,9 @@ export class Agent {
365
376
  }
366
377
  getSystemPromptToolOptions() {
367
378
  return {
379
+ // Rebuilt prompts (model switch, /skills changes) keep the Skills catalog.
380
+ skills: this.skillSummaries,
381
+ skillCatalogChars: this.skillCatalogChars,
368
382
  ...buildToolPromptOptions(this.getActiveToolEntries()),
369
383
  // Rendered through the live accessor (design §1.5), so every host-
370
384
  // triggered prompt rebuild picks up the current catalog.
@@ -374,7 +388,13 @@ export class Agent {
374
388
  memoryPrompt: this.memoryPrompt,
375
389
  };
376
390
  }
377
- /** Refresh the live Skill catalog after /skills reloads or changes enablement. */
391
+ getSkillSummaries() {
392
+ return this.skillSummaries.slice();
393
+ }
394
+ getSkillCatalogChars() {
395
+ return this.skillCatalogChars;
396
+ }
397
+ /** Refresh the live Skill catalog after /skills reloads or changes enablement; hosts then rebuild the system prompt. */
378
398
  setSkillSummaries(skills) {
379
399
  this.skillSummaries = skills.slice();
380
400
  this.notifyContextChanged();
@@ -840,13 +860,18 @@ export class Agent {
840
860
  // [LLM summary] + [last user msg], and the projector becomes a no-op for
841
861
  // budget. If it fails (network error, etc.), the projector's existing
842
862
  // algorithmic fallback still kicks in.
843
- await this.maybeCompactWithLLM(toolDefinitionTokens);
863
+ for await (const event of this.maybeCompactWithLLM(toolDefinitionTokens, abortSignal))
864
+ yield emit(event);
865
+ for (const event of this.contextEvents.splice(0))
866
+ yield emit(event);
844
867
  const bufferedStreamingToolCallIds = new Set();
845
868
  let currentRequestEstimate;
869
+ let currentContextWindow;
846
870
  try {
847
871
  markStableCurrentToolResultsForCache(this.messages);
848
872
  const projectedMessages = projectMessages(this.messages, {
849
- mode: "budgeted",
873
+ // Semantic compaction is committed above; projection only renders/thins.
874
+ mode: "pruned",
850
875
  providerId: this.providerId,
851
876
  modelId: this.apiModel,
852
877
  // Projection changes message identity/count (meta rendering, pruning,
@@ -857,6 +882,8 @@ export class Agent {
857
882
  currentRequestMessageCount = projectedMessages.length;
858
883
  const requestBudget = getContextBudget(this.providerId, this.apiModel, projectedMessages, { additionalInputTokens: toolDefinitionTokens });
859
884
  currentRequestEstimate = requestBudget.estimatedTokens;
885
+ currentContextWindow = requestBudget.contextWindow;
886
+ yield emit({ type: "context_usage", usedTokens: currentRequestEstimate, contextWindow: currentContextWindow, estimated: true });
860
887
  const maxInputTokens = getMaxInputTokens(requestBudget.contextWindow);
861
888
  if (maxInputTokens !== undefined && currentRequestEstimate > maxInputTokens) {
862
889
  throw new LocalContextPreflightError(currentRequestEstimate, maxInputTokens);
@@ -1069,6 +1096,11 @@ export class Agent {
1069
1096
  // turn_start resets the streaming display, so re-issuing the whole
1070
1097
  // request is safe.
1071
1098
  consecutiveStreamInterruptionRetries += 1;
1099
+ this.persistProviderError(error, {
1100
+ messageCount: currentRequestMessageCount,
1101
+ toolCount: toolDefinitions.length,
1102
+ retry: { attempt: consecutiveStreamInterruptionRetries, maxAttempts: streamRetryLimit },
1103
+ });
1072
1104
  yield emit({
1073
1105
  type: "provider_retry",
1074
1106
  attempt: consecutiveStreamInterruptionRetries,
@@ -1098,17 +1130,21 @@ export class Agent {
1098
1130
  throw error;
1099
1131
  }
1100
1132
  const messagesBeforeRecovery = this.messages;
1101
- const inputTokensBeforeRecovery = this.lastInputTokens;
1102
- const anchorBeforeRecovery = this.lastAnchorMessageCount;
1133
+ const revisionBeforeRecovery = this.getContextRevision?.();
1103
1134
  const failedRequestEstimate = currentRequestEstimate ?? getContextBudget(this.providerId, this.apiModel, projectMessages(this.messages, {
1104
- mode: "budgeted",
1135
+ mode: "pruned",
1105
1136
  providerId: this.providerId,
1106
1137
  modelId: this.apiModel,
1107
1138
  additionalInputTokens: toolDefinitionTokens,
1108
1139
  }), { additionalInputTokens: toolDefinitionTokens }).estimatedTokens;
1109
- const droppedMessages = await this.recoverFromOverflow(consecutiveOverflowRecoveries, error instanceof LocalContextPreflightError ? 0.999_999 : 0.9);
1110
- const recoveredRequestEstimate = getContextBudget(this.providerId, this.apiModel, projectMessages(this.messages, {
1111
- mode: "budgeted",
1140
+ const candidate = await this.recoverFromOverflow(consecutiveOverflowRecoveries, error instanceof LocalContextPreflightError ? 0.999_999 : 0.9, abortSignal);
1141
+ if (!candidate) {
1142
+ persistTerminalProviderError(error);
1143
+ throw error;
1144
+ }
1145
+ const droppedMessages = messagesBeforeRecovery.length - candidate.length;
1146
+ const recoveredRequestEstimate = getContextBudget(this.providerId, this.apiModel, projectMessages(candidate, {
1147
+ mode: "pruned",
1112
1148
  providerId: this.providerId,
1113
1149
  modelId: this.apiModel,
1114
1150
  additionalInputTokens: toolDefinitionTokens,
@@ -1120,12 +1156,10 @@ export class Agent {
1120
1156
  const meaningfulProviderProgress = error instanceof LocalContextPreflightError
1121
1157
  || recoveredRequestEstimate <= Math.floor(failedRequestEstimate * 0.9);
1122
1158
  if (!madeProgress || !meaningfulProviderProgress) {
1123
- this.messages = messagesBeforeRecovery;
1124
- this.lastInputTokens = inputTokensBeforeRecovery;
1125
- this.lastAnchorMessageCount = anchorBeforeRecovery;
1126
1159
  persistTerminalProviderError(error);
1127
1160
  throw error;
1128
1161
  }
1162
+ this.applyContextCheckpoint(candidate, "overflow", undefined, revisionBeforeRecovery, abortSignal);
1129
1163
  consecutiveOverflowRecoveries += 1;
1130
1164
  this.compactionStats.overflow += 1;
1131
1165
  this.compactionStats.fired += 1;
@@ -1137,11 +1171,16 @@ export class Agent {
1137
1171
  afterEstimatedTokens: recoveredRequestEstimate,
1138
1172
  });
1139
1173
  yield emit({ type: "context_recovered", droppedMessages, reason: "overflow" });
1174
+ yield emit({ type: "context_compaction", status: "completed", compactionId: this.lastCompactionId, persisted: !!this.onContextCheckpoint, preTokens: failedRequestEstimate, postTokens: recoveredRequestEstimate, contextWindow: currentContextWindow });
1140
1175
  continue;
1141
1176
  }
1142
1177
  consecutiveOverflowRecoveries = 0;
1143
1178
  consecutiveEmptyAssistantRecoveries = 0;
1144
1179
  consecutiveStreamInterruptionRetries = 0;
1180
+ if (turnUsage && Number.isFinite(turnUsage.promptTokens)) {
1181
+ // Provider prompt usage already includes cached input; never add cache hits twice.
1182
+ yield emit({ type: "context_usage", usedTokens: turnUsage.promptTokens + (turnUsage.completionTokens || 0), contextWindow: currentContextWindow, estimated: false });
1183
+ }
1145
1184
  // Execute tools if any
1146
1185
  if (assistantMsg.toolCalls && assistantMsg.toolCalls.length > 0) {
1147
1186
  const parsedCalls = [];
@@ -1448,11 +1487,12 @@ export class Agent {
1448
1487
  // synchronously, so a mark added on a later turn never reaches disk.
1449
1488
  markToolResultCacheStable(tc.name, toolMessage);
1450
1489
  this.appendMessage(toolMessage);
1451
- this.compactResidentHistory();
1452
1490
  flushQueuedReminders();
1453
1491
  this.onToolResult?.(tc.name, result);
1454
1492
  executedResults.push(result);
1455
1493
  yield emit({ type: "tool_end", id: tc.id, name: tc.name, result });
1494
+ for (const event of this.contextEvents.splice(0))
1495
+ yield emit(event);
1456
1496
  for (const update of this.subagents.drainToolUpdates())
1457
1497
  yield emit(update);
1458
1498
  if (this._modeVersion !== modeVersionBefore) {
@@ -1463,6 +1503,14 @@ export class Agent {
1463
1503
  throwIfAborted(abortSignal);
1464
1504
  }
1465
1505
  }
1506
+ const imageObservation = toolImageObservation(executedResults);
1507
+ if (imageObservation)
1508
+ this.appendMessage(imageObservation);
1509
+ // A checkpoint must contain the whole batch, never provider repair's
1510
+ // placeholder for a sibling that has not executed yet.
1511
+ this.compactResidentHistory();
1512
+ for (const event of this.contextEvents.splice(0))
1513
+ yield emit(event);
1466
1514
  await hookBus.runBeforeContinuation({
1467
1515
  agent: this,
1468
1516
  cwd,
@@ -1508,12 +1556,17 @@ export class Agent {
1508
1556
  }, abortSignal);
1509
1557
  for (const event of stopHook.events)
1510
1558
  yield emit(event);
1511
- const willContinue = !!hookState.forceContinuationReason;
1559
+ const willContinue = !!hookState.forceContinuationReason
1560
+ || (options.continueOnPendingInput === true && pendingInputCount() > 0);
1512
1561
  yield emit({ type: "turn_end", usage: turnUsage, systemFingerprint: turnSystemFingerprint, willContinue });
1513
1562
  if (willContinue) {
1514
1563
  delete hookState.forceContinuationReason;
1515
1564
  continue;
1516
1565
  }
1566
+ // A follow-up can arrive while the consumer handles turn_end. Recheck
1567
+ // synchronously before closing admission so an accepted input is not lost.
1568
+ if (options.continueOnPendingInput && pendingInputCount() > 0)
1569
+ continue;
1517
1570
  for (const event of await rejectPendingInputs("no_continuation"))
1518
1571
  yield emit(event);
1519
1572
  break;
@@ -1553,25 +1606,15 @@ export class Agent {
1553
1606
  }, traceContext);
1554
1607
  }
1555
1608
  }
1556
- async recoverFromOverflow(attempt, maximumSizeRatio = 0.999_999) {
1557
- const before = this.messages.length;
1609
+ async recoverFromOverflow(attempt, maximumSizeRatio = 0.999_999, abortSignal) {
1558
1610
  const originalMessages = this.messages;
1559
1611
  const beforeBytes = serializedMessageBytes(originalMessages);
1560
- const commitIfSmaller = (candidate, summary) => {
1561
- if (!candidate
1562
- || serializedMessageBytes(candidate) > Math.floor(beforeBytes * maximumSizeRatio))
1563
- return false;
1564
- this.messages = candidate;
1565
- this.lastInputTokens = null;
1566
- this.lastAnchorMessageCount = null;
1567
- this.fileStateTracker?.invalidateReadHistory();
1568
- this.persistCompactionSummary(summary);
1569
- return true;
1570
- };
1612
+ const smaller = (candidate) => !!candidate && serializedMessageBytes(candidate) <= Math.floor(beforeBytes * maximumSizeRatio);
1613
+ throwIfAborted(abortSignal);
1571
1614
  if (attempt === 0) {
1572
- if (commitIfSmaller(aggressivePruneMessages(originalMessages))) {
1573
- return before - this.messages.length;
1574
- }
1615
+ const pruned = aggressivePruneMessages(originalMessages);
1616
+ if (smaller(pruned))
1617
+ return pruned;
1575
1618
  }
1576
1619
  // Unlike ordinary pruning, overflow recovery is allowed to thin the most
1577
1620
  // recent completed tool batch. Keeping it protected is exactly what made
@@ -1583,67 +1626,42 @@ export class Agent {
1583
1626
  const bounded = truncateToolOutputForModel(message.content, this.providerId, this.apiModel, { hardByteLimit: emergencyToolLimit });
1584
1627
  return bounded.truncated ? { ...message, content: bounded.content } : message;
1585
1628
  });
1586
- if (commitIfSmaller(thinnedLatestTools)) {
1587
- return before - this.messages.length;
1588
- }
1629
+ if (smaller(thinnedLatestTools))
1630
+ return thinnedLatestTools;
1589
1631
  const keepRecentTurns = attempt >= 1 ? 1 : 2;
1590
- const llmResult = await compactMessagesWithLLM(originalMessages, {
1591
- provider: this.provider,
1592
- model: this.apiModel,
1593
- thinkingLevel: this.thinkingLevel,
1594
- keepRecentTurns,
1595
- });
1596
- if (llmResult.compacted && commitIfSmaller(llmResult.messages, llmResult.summary)) {
1597
- return before - this.messages.length;
1598
- }
1599
- // Single-turn capable LLM compactor. compactMessagesWithLLM above no-ops
1600
- // when there's only one user turn (the "single huge prompt with many tool
1601
- // calls" case), so try the turn-internal compactor before giving up.
1632
+ // Use the same budget-aware summarizer for single- and multi-turn history.
1602
1633
  const { compactWithLLM } = await import("./context/llm-compactor.js");
1603
1634
  const singleTurnResult = await compactWithLLM(originalMessages, {
1604
1635
  provider: this.provider,
1605
1636
  modelId: this.apiModel,
1637
+ providerId: this.providerId,
1638
+ abortSignal,
1606
1639
  });
1607
- if (singleTurnResult.compacted && commitIfSmaller(singleTurnResult.messages, singleTurnResult.summary)) {
1608
- return before - this.messages.length;
1609
- }
1640
+ throwIfAborted(abortSignal);
1641
+ if (singleTurnResult.compacted && smaller(singleTurnResult.messages))
1642
+ return singleTurnResult.messages;
1610
1643
  const fallback = compactMessages(originalMessages, { keepRecentTurns });
1611
- if (fallback.compacted && commitIfSmaller(fallback.messages, fallback.summary)) {
1612
- return before - this.messages.length;
1613
- }
1644
+ if (fallback.compacted && smaller(fallback.messages))
1645
+ return fallback.messages;
1614
1646
  const subturn = compactCurrentTurnToolGroups(originalMessages, {
1615
1647
  keepRecentGroups: attempt === 0 ? 1 : 0,
1616
1648
  });
1617
- if (subturn.compacted && commitIfSmaller(subturn.messages, subturn.summary)) {
1618
- return before - this.messages.length;
1619
- }
1620
- // Codex-style last-resort: drop the single oldest non-protected message
1621
- // and let the retry loop try again. Cheap, but eventually narrows even an
1622
- // intractable single-turn overflow.
1623
- const realUserIndexes = originalMessages
1624
- .map((message, index) => message.role === "user" ? index : -1)
1625
- .filter((index) => index >= 0);
1626
- const oldestIdx = realUserIndexes.length > 1
1627
- ? realUserIndexes[0]
1628
- : -1;
1629
- const removeUntil = realUserIndexes.length > 1
1630
- ? realUserIndexes[1]
1631
- : -1;
1632
- if (oldestIdx >= 0 && removeUntil > oldestIdx && removeUntil < originalMessages.length) {
1633
- const candidate = [
1634
- ...originalMessages.slice(0, oldestIdx),
1635
- ...originalMessages.slice(removeUntil),
1636
- ];
1637
- if (!commitIfSmaller(candidate))
1638
- return 0;
1639
- return before - this.messages.length;
1649
+ if (subturn.compacted && smaller(subturn.messages))
1650
+ return subturn.messages;
1651
+ // A two-turn text history has nothing to give at keepRecentTurns 2 and no
1652
+ // tool groups; returning undefined on attempt 0 aborts before attempt 1.
1653
+ if (keepRecentTurns > 1) {
1654
+ const lastTurnOnly = compactMessages(originalMessages, { keepRecentTurns: 1 });
1655
+ if (lastTurnOnly.compacted && smaller(lastTurnOnly.messages))
1656
+ return lastTurnOnly.messages;
1640
1657
  }
1641
- return 0;
1658
+ // Fail explicitly rather than silently deleting the original instruction.
1659
+ return undefined;
1642
1660
  }
1643
1661
  compactResidentHistory() {
1644
1662
  this.maybeCompactResidentHistory();
1645
1663
  }
1646
- async maybeCompactWithLLM(additionalInputTokens = 0) {
1664
+ async *maybeCompactWithLLM(additionalInputTokens = 0, abortSignal) {
1647
1665
  if (!this.providerId || !this.apiModel)
1648
1666
  return;
1649
1667
  if (this.messages.length === 0)
@@ -1654,24 +1672,67 @@ export class Agent {
1654
1672
  const budget = getContextBudget(this.providerId, this.apiModel, this.messages, {
1655
1673
  usageAnchorTokens: this.lastInputTokens ?? undefined,
1656
1674
  tailMessages: tail,
1657
- additionalInputTokens,
1675
+ // Usage anchors already include the schemas sent with that request.
1676
+ additionalInputTokens: this.lastInputTokens === null ? additionalInputTokens : 0,
1658
1677
  });
1659
1678
  if (!budget.shouldCompact)
1660
1679
  return;
1661
- const { compactWithLLM } = await import("./context/llm-compactor.js");
1662
- const result = await compactWithLLM(this.messages, {
1663
- provider: this.provider,
1664
- modelId: this.apiModel,
1665
- });
1666
- if (result.compacted && result.messages) {
1667
- this.messages = result.messages;
1668
- this.lastInputTokens = null;
1669
- this.lastAnchorMessageCount = null;
1670
- this.fileStateTracker?.invalidateReadHistory();
1671
- this.compactionStats.llm += 1;
1672
- this.compactionStats.fired += 1;
1673
- this.persistCompactionSummary(result.summary);
1674
- traceEvent("compaction_fired", { path: "llm" });
1680
+ yield { type: "context_compaction", status: "started", preTokens: budget.estimatedTokens, contextWindow: budget.contextWindow };
1681
+ let completed = false;
1682
+ const revision = this.getContextRevision?.();
1683
+ try {
1684
+ const { compactWithLLM } = await import("./context/llm-compactor.js");
1685
+ let result = await compactWithLLM(this.messages, {
1686
+ provider: this.provider,
1687
+ providerId: this.providerId,
1688
+ modelId: this.apiModel,
1689
+ abortSignal,
1690
+ });
1691
+ throwIfAborted(abortSignal);
1692
+ let path = "llm";
1693
+ if (!result.compacted) {
1694
+ // The request projection no longer compacts, so this fallback must
1695
+ // escalate as far as the former budgeted passes did (down to one kept
1696
+ // turn / one kept tool group) or a two-turn text history has no way
1697
+ // under the window when the summarizer is unavailable. Take the first
1698
+ // pass that fits the budget, otherwise the smallest one that fired.
1699
+ const passes = [
1700
+ ["heuristic", () => compactMessages(this.messages, { keepRecentTurns: 2 })],
1701
+ ["heuristic", () => compactMessages(this.messages, { keepRecentTurns: 1 })],
1702
+ ["subturn", () => compactCurrentTurnToolGroups(this.messages, { keepRecentGroups: 2 })],
1703
+ ["subturn", () => compactCurrentTurnToolGroups(this.messages, { keepRecentGroups: 1 })],
1704
+ ];
1705
+ let smallest = Infinity;
1706
+ for (const [passPath, pass] of passes) {
1707
+ const candidate = pass();
1708
+ if (!candidate.compacted || !candidate.messages)
1709
+ continue;
1710
+ const candidateBudget = getContextBudget(this.providerId, this.apiModel, candidate.messages, { additionalInputTokens });
1711
+ if (candidateBudget.estimatedTokens < smallest) {
1712
+ smallest = candidateBudget.estimatedTokens;
1713
+ result = candidate;
1714
+ path = passPath;
1715
+ }
1716
+ if (!candidateBudget.shouldCompact)
1717
+ break;
1718
+ }
1719
+ }
1720
+ const beforeEstimate = getContextBudget(this.providerId, this.apiModel, this.messages, { additionalInputTokens }).estimatedTokens;
1721
+ const candidateEstimate = result.messages
1722
+ ? getContextBudget(this.providerId, this.apiModel, result.messages, { additionalInputTokens }).estimatedTokens : beforeEstimate;
1723
+ if (result.compacted && result.messages && candidateEstimate < beforeEstimate) {
1724
+ this.applyContextCheckpoint(result.messages, "auto", result.summary, revision, abortSignal);
1725
+ this.compactionStats[path] += 1;
1726
+ this.compactionStats.fired += 1;
1727
+ traceEvent("compaction_fired", { path });
1728
+ completed = true;
1729
+ const after = getContextBudget(this.providerId, this.apiModel, this.messages, { additionalInputTokens });
1730
+ yield { type: "context_compaction", status: "completed", compactionId: this.lastCompactionId, persisted: !!this.onContextCheckpoint, preTokens: budget.estimatedTokens, postTokens: after.estimatedTokens, contextWindow: budget.contextWindow };
1731
+ }
1732
+ }
1733
+ finally {
1734
+ if (!completed)
1735
+ yield { type: "context_compaction", status: "failed", preTokens: budget.estimatedTokens, contextWindow: budget.contextWindow };
1675
1736
  }
1676
1737
  // If LLM compaction failed for any reason, leave this.messages alone —
1677
1738
  // the projector's algorithmic budgeted-mode passes will still try.
@@ -1895,13 +1956,15 @@ export class Agent {
1895
1956
  return [{ role: "system", content: childSystemPrompt }, ...forked];
1896
1957
  }
1897
1958
  maybeCompactResidentHistory() {
1898
- if (this.messages.length === 0) {
1959
+ if (this.messages.length === 0 || !hasCompleteToolGroups(this.messages)) {
1899
1960
  return;
1900
1961
  }
1901
1962
  const before = this.messages;
1902
1963
  const beforeChars = estimateResidentChars(before);
1903
1964
  const beforeToolChars = estimateToolPayloadChars(before);
1904
- let candidate = projectMessages(before, { mode: "pruned" });
1965
+ // Compact canonical history, not the provider projection: projection
1966
+ // renders runtime meta as user text and can synthesize missing tool results.
1967
+ let candidate = pruneMessages(before);
1905
1968
  const budget = this.providerId && this.apiModel
1906
1969
  ? getContextBudget(this.providerId, this.apiModel, candidate)
1907
1970
  : undefined;
@@ -1916,7 +1979,9 @@ export class Agent {
1916
1979
  // now waits for the context-window budget itself; the heap guard stays
1917
1980
  // as process life-support, not policy.
1918
1981
  const shouldAggressivelyPrune = heapUsed >= RESIDENT_HISTORY_HEAP_HARD_LIMIT;
1919
- const shouldCompact = !!budget?.shouldCompact;
1982
+ // Model summarization runs at the request boundary. Resident compaction is
1983
+ // an emergency heap guard, not a competing token-triggered lossy policy.
1984
+ const shouldCompact = shouldAggressivelyPrune && !!budget?.shouldCompact;
1920
1985
  if (shouldAggressivelyPrune) {
1921
1986
  candidate = aggressivePruneMessages(candidate);
1922
1987
  }
@@ -1937,6 +2002,7 @@ export class Agent {
1937
2002
  if (subturn.compacted && subturn.messages) {
1938
2003
  candidate = subturn.messages;
1939
2004
  compactedPath = "subturn";
2005
+ residentSummary = subturn.summary;
1940
2006
  traceEvent("compaction_fired", { path: "subturn", droppedEntries: subturn.droppedEntries });
1941
2007
  }
1942
2008
  }
@@ -1951,31 +2017,47 @@ export class Agent {
1951
2017
  && (afterChars < beforeChars
1952
2018
  || afterToolChars < beforeToolChars
1953
2019
  || candidate.length < before.length)) {
1954
- this.messages = candidate;
1955
- this.lastInputTokens = null;
1956
- this.lastAnchorMessageCount = null;
1957
- this.fileStateTracker?.invalidateReadHistory();
2020
+ if (!compactedPath) {
2021
+ // Request-only thinning must not mutate durable history. Under heap
2022
+ // pressure below the token threshold, the pruned candidate still has to
2023
+ // replace resident memory or the life-support guard frees nothing; the
2024
+ // session log keeps the full tool output.
2025
+ if (shouldAggressivelyPrune) {
2026
+ this.messages = candidate;
2027
+ this.lastInputTokens = null;
2028
+ this.lastAnchorMessageCount = null;
2029
+ this.fileStateTracker?.invalidateReadHistory();
2030
+ }
2031
+ return;
2032
+ }
2033
+ this.applyContextCheckpoint(candidate, "resident", residentSummary, this.getContextRevision?.());
1958
2034
  if (compactedPath === "resident") {
1959
2035
  this.compactionStats.resident += 1;
1960
- this.persistCompactionSummary(residentSummary);
1961
2036
  }
1962
2037
  if (compactedPath === "subturn")
1963
2038
  this.compactionStats.subturn += 1;
2039
+ if (compactedPath && budget) {
2040
+ this.contextEvents.push({ type: "context_compaction", status: "completed", compactionId: this.lastCompactionId, persisted: !!this.onContextCheckpoint, preTokens: budget.estimatedTokens,
2041
+ postTokens: getContextBudget(this.providerId, this.apiModel, candidate).estimatedTokens, contextWindow: budget.contextWindow });
2042
+ }
1964
2043
  }
1965
2044
  }
1966
- /**
1967
- * Hand a multi-turn compaction summary to the host for session persistence.
1968
- * Failure is swallowed: losing persistence must never break the run itself.
1969
- */
1970
- persistCompactionSummary(summary) {
1971
- if (!summary?.trim())
1972
- return;
1973
- try {
2045
+ /** Persist conversational context first; keep live reminders outside the durable projection. */
2046
+ applyContextCheckpoint(messages, reason, summary, baseRevision, abortSignal) {
2047
+ throwIfAborted(abortSignal);
2048
+ const checkpoint = createContextCheckpoint(messages, reason, summary, baseRevision);
2049
+ this.onContextCheckpoint?.(checkpoint);
2050
+ if (!this.onContextCheckpoint && summary?.trim())
1974
2051
  this.onCompactionApplied?.(summary);
1975
- }
1976
- catch {
1977
- // ignore — persistence is best-effort
1978
- }
2052
+ const { leading } = splitLeadingContext(messages);
2053
+ // The compactor may have folded away reminders from its candidate. Their
2054
+ // lifecycle belongs to the live Agent, not the durable summary/checkpoint.
2055
+ const runtimeReminders = splitLeadingContext(this.messages).body.filter(message => message.role === "meta" && message.includeInLlm !== false && !isCompactionSummaryMessage(message));
2056
+ this.messages = [...leading, ...checkpointMessages(checkpoint), ...runtimeReminders];
2057
+ this.lastCompactionId = checkpoint.compactionId;
2058
+ this.lastInputTokens = null;
2059
+ this.lastAnchorMessageCount = null;
2060
+ this.fileStateTracker?.invalidateReadHistory();
1979
2061
  }
1980
2062
  appendMessage(message) {
1981
2063
  if (message.role === "assistant" && message.content) {
@@ -1985,7 +2067,7 @@ export class Agent {
1985
2067
  message.reasoning = sanitizeInternalReasoningText(message.reasoning);
1986
2068
  }
1987
2069
  if (message.role === "assistant" && message.providerMetadata) {
1988
- message.providerMetadata = sanitizeAssistantProviderMetadata(message.providerMetadata);
2070
+ message.providerMetadata = sanitizeAssistantProviderMetadata(message.providerMetadata, message.modelId ?? message.model);
1989
2071
  }
1990
2072
  this.messages.push(message);
1991
2073
  traceEvent("agent_message_append", {
@@ -2007,6 +2089,7 @@ export class Agent {
2007
2089
  thinkingLevel: this.thinkingLevel,
2008
2090
  messageCount: request.messageCount,
2009
2091
  toolCount: request.toolCount,
2092
+ retry: request.retry,
2010
2093
  });
2011
2094
  }
2012
2095
  recordProviderError(error, context) {
@@ -2058,7 +2141,7 @@ export class Agent {
2058
2141
  if (this._mode === "plan" && !tool.readOnly) {
2059
2142
  return {
2060
2143
  content: `Error: Tool "${toolCall.name}" is not allowed in plan mode. ` +
2061
- `In plan mode you may only use read-only tools (read, glob, grep, web_search, web_fetch, spawn_agent, wait_agent, send_input, skill_search, skill, tool_search, question, exit_plan_mode). ` +
2144
+ `In plan mode you may only use read-only tools (read, ls, grep, web_search, web_fetch, spawn_agent, wait_agent, send_input, skill_search, skill, tool_search, question, exit_plan_mode). ` +
2062
2145
  `To modify files or run commands, present your proposal and call exit_plan_mode so the user can review and approve it.`,
2063
2146
  isError: true,
2064
2147
  };
@@ -0,0 +1,4 @@
1
+ /** Claude thinking bound to both the model and the exact preceding conversation.
2
+ * https://platform.claude.com/docs/en/build-with-claude/preserved-thinking
3
+ */
4
+ export declare function isClaudeWithBoundThinking(model?: string): boolean;
@@ -0,0 +1,11 @@
1
+ /** Claude thinking bound to both the model and the exact preceding conversation.
2
+ * https://platform.claude.com/docs/en/build-with-claude/preserved-thinking
3
+ */
4
+ export function isClaudeWithBoundThinking(model) {
5
+ const match = model?.match(/(?:^|:)claude-(opus|fable)-(\d+)(?:-(\d{1,2})(?=-|\[|$))?/i);
6
+ if (!match)
7
+ return false;
8
+ const major = Number(match[2]);
9
+ const minor = Number(match[3] ?? 0);
10
+ return major > 5 || (major === 5 && minor >= (match[1].toLowerCase() === "opus" ? 5 : 1));
11
+ }
@@ -1,7 +1,7 @@
1
1
  import type { PermissionCheckResult, PermissionQuery, PermissionRuleSet } from "../permissions/types.js";
2
2
  import type { PermissionMode } from "../types.js";
3
3
  import type { ExternalHookController } from "../hooks/controller.js";
4
- import type { BashAllowlist } from "./session-cache.js";
4
+ import { type BashAllowlist } from "./session-cache.js";
5
5
  import type { ApprovalController, ApprovalDecision, ApprovalRequest } from "./types.js";
6
6
  export interface ApprovalControllerOptions {
7
7
  /** Reads the live agent mode on each request so mode flips take effect immediately. */
@@ -16,6 +16,11 @@ export interface ApprovalControllerOptions {
16
16
  };
17
17
  /** Session-scoped bash command prefix allowlist. Optional. */
18
18
  bashAllowlist?: BashAllowlist;
19
+ /**
20
+ * Session-scoped grants for non-bash requests (MCP tools), keyed by
21
+ * sessionGrantKey. Optional; without it hosts cannot offer "this session".
22
+ */
23
+ sessionGrants?: Set<string>;
19
24
  /** Working directory — used to anchor relative path rules. */
20
25
  cwd: string;
21
26
  /**
@@ -34,11 +39,13 @@ export interface ApprovalControllerOptions {
34
39
  * deny rule match → reject (applies even under bypassPermissions)
35
40
  * bypassPermissions → auto-approve, no prompt
36
41
  * default + edit|write → auto-approve (workspace paths only; a
37
- * path outside the workspace falls through
38
- * to allow rules / the interactive prompt)
42
+ * path outside the workspace, or a protected
43
+ * one — .git, agent permission settings —
44
+ * falls through to the interactive prompt)
39
45
  * plan → reject with instructions to use exit_plan_mode
40
- * allow rule match → auto-approve
41
- * bash in session allowlist → auto-approve
46
+ * allow rule match → auto-approve (never for protected paths)
47
+ * session grant (bash prefix,
48
+ * MCP tool) → auto-approve
42
49
  * bash / other → delegate to UI; if no UI, reject
43
50
  *
44
51
  * Deny rules sit at the top as a hard ceiling: bypassPermissions is a trust
@@ -49,7 +56,14 @@ export declare class PermissionAwareApprovalController implements ApprovalContro
49
56
  private readonly options;
50
57
  constructor(options: ApprovalControllerOptions);
51
58
  checkRules(query: PermissionQuery): PermissionCheckResult;
52
- request(req: ApprovalRequest): Promise<ApprovalDecision>;
59
+ request(original: ApprovalRequest): Promise<ApprovalDecision>;
60
+ /**
61
+ * Facts the host needs to render the prompt correctly: whether a path is
62
+ * protected (never auto-approve it host-side either) and what scope a
63
+ * "this session" approval would cover.
64
+ */
65
+ private annotate;
66
+ private rememberForSession;
53
67
  private requestToQuery;
54
68
  private checkRequestRules;
55
69
  private runPermissionRequestHook;