@cjhyy/code-shell-core 0.6.0-rc.8 → 0.7.0-beta.1

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/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/capability-control/service.d.ts +2 -0
  5. package/dist/capability-control/service.js +4 -2
  6. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +7 -1
  8. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  9. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  10. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  11. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  13. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  14. package/dist/cc-orchestrator/external-agent-driver.js +102 -51
  15. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  16. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  17. package/dist/cc-orchestrator/session-history.js +2 -2
  18. package/dist/cli/agent-server-stdio.js +9 -2
  19. package/dist/context/compaction.d.ts +38 -1
  20. package/dist/context/compaction.js +138 -0
  21. package/dist/context/manager.d.ts +34 -2
  22. package/dist/context/manager.js +254 -58
  23. package/dist/context/token-counter.js +13 -0
  24. package/dist/credentials/access.d.ts +56 -0
  25. package/dist/credentials/access.js +183 -0
  26. package/dist/credentials/index.d.ts +1 -0
  27. package/dist/credentials/index.js +1 -0
  28. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  29. package/dist/credentials/inject-credential-tool.js +30 -11
  30. package/dist/credentials/types.d.ts +2 -2
  31. package/dist/credentials/use-credential-tool.d.ts +9 -1
  32. package/dist/credentials/use-credential-tool.js +58 -45
  33. package/dist/engine/engine.d.ts +38 -12
  34. package/dist/engine/engine.js +477 -163
  35. package/dist/engine/image-policy.d.ts +6 -0
  36. package/dist/engine/image-policy.js +17 -6
  37. package/dist/engine/input-attachments.d.ts +13 -0
  38. package/dist/engine/input-attachments.js +255 -0
  39. package/dist/engine/model-facade.d.ts +5 -2
  40. package/dist/engine/model-facade.js +6 -16
  41. package/dist/engine/parse-task.d.ts +10 -0
  42. package/dist/engine/parse-task.js +5 -0
  43. package/dist/engine/query.js +2 -0
  44. package/dist/engine/session-usage.d.ts +12 -0
  45. package/dist/engine/session-usage.js +56 -0
  46. package/dist/engine/steer-queue.d.ts +2 -1
  47. package/dist/engine/steer-queue.js +2 -2
  48. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  49. package/dist/engine/streaming-tool-queue.js +11 -7
  50. package/dist/engine/turn-loop.d.ts +34 -2
  51. package/dist/engine/turn-loop.js +269 -52
  52. package/dist/engine/types.d.ts +8 -0
  53. package/dist/git/worktree/crud.d.ts +69 -0
  54. package/dist/git/worktree/crud.js +206 -0
  55. package/dist/git/worktree/diff.d.ts +14 -0
  56. package/dist/git/worktree/diff.js +82 -0
  57. package/dist/git/worktree/git-exec.d.ts +7 -0
  58. package/dist/git/worktree/git-exec.js +51 -0
  59. package/dist/git/worktree/index.d.ts +5 -0
  60. package/dist/git/worktree/index.js +5 -0
  61. package/dist/git/worktree/query.d.ts +42 -0
  62. package/dist/git/worktree/query.js +121 -0
  63. package/dist/git/worktree/slug.d.ts +11 -0
  64. package/dist/git/worktree/slug.js +58 -0
  65. package/dist/git/worktree.d.ts +1 -84
  66. package/dist/git/worktree.js +5 -230
  67. package/dist/index.d.ts +31 -28
  68. package/dist/index.js +27 -25
  69. package/dist/logging/logger.js +6 -6
  70. package/dist/logging/sanitize-messages.d.ts +10 -2
  71. package/dist/logging/sanitize-messages.js +21 -6
  72. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  73. package/dist/plugins/installer/checkUpdate.js +4 -2
  74. package/dist/plugins/installer/install.js +2 -0
  75. package/dist/plugins/installer/installFromSource.js +9 -1
  76. package/dist/plugins/installer/parseSource.d.ts +4 -1
  77. package/dist/plugins/installer/parseSource.js +28 -10
  78. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  79. package/dist/plugins/installer/sourcePath.js +50 -0
  80. package/dist/plugins/installer/update.d.ts +4 -1
  81. package/dist/plugins/installer/update.js +5 -3
  82. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  83. package/dist/plugins/parseMarketplaceInput.js +4 -3
  84. package/dist/plugins/pluginInstaller.js +24 -22
  85. package/dist/preset/index.d.ts +1 -0
  86. package/dist/preset/index.js +16 -9
  87. package/dist/prompt/sections/base.md +1 -1
  88. package/dist/protocol/chat-session-manager.d.ts +2 -0
  89. package/dist/protocol/chat-session-manager.js +38 -2
  90. package/dist/protocol/chat-session.d.ts +5 -0
  91. package/dist/protocol/chat-session.js +2 -0
  92. package/dist/protocol/client.d.ts +13 -5
  93. package/dist/protocol/client.js +26 -3
  94. package/dist/protocol/server.d.ts +43 -12
  95. package/dist/protocol/server.js +326 -104
  96. package/dist/protocol/types.d.ts +51 -0
  97. package/dist/protocol/types.js +6 -0
  98. package/dist/run/FileRunStore.js +10 -1
  99. package/dist/run/Heartbeat.js +12 -0
  100. package/dist/run/RunApprovalBackend.d.ts +3 -0
  101. package/dist/run/RunApprovalBackend.js +41 -6
  102. package/dist/run/RunLock.js +2 -0
  103. package/dist/run/RunManager.d.ts +2 -0
  104. package/dist/run/RunManager.js +64 -24
  105. package/dist/run/ids.d.ts +2 -0
  106. package/dist/run/ids.js +23 -0
  107. package/dist/runtime/background-shell.d.ts +1 -0
  108. package/dist/runtime/background-shell.js +23 -13
  109. package/dist/runtime/safe-spawn.js +74 -11
  110. package/dist/runtime/spawn-common.js +10 -0
  111. package/dist/services/auto-dream.d.ts +15 -4
  112. package/dist/services/auto-dream.js +20 -20
  113. package/dist/services/dream-consolidation.d.ts +2 -3
  114. package/dist/services/dream-consolidation.js +65 -15
  115. package/dist/services/extract-memories.d.ts +14 -5
  116. package/dist/services/extract-memories.js +20 -3
  117. package/dist/services/global-dream-promotion.d.ts +23 -0
  118. package/dist/services/global-dream-promotion.js +112 -0
  119. package/dist/services/memory-orchestrator.js +347 -34
  120. package/dist/session/memory.d.ts +61 -18
  121. package/dist/session/memory.js +342 -79
  122. package/dist/session/session-manager.d.ts +35 -1
  123. package/dist/session/session-manager.js +197 -4
  124. package/dist/session/transcript.d.ts +5 -1
  125. package/dist/session/transcript.js +38 -5
  126. package/dist/settings/manager.d.ts +1 -0
  127. package/dist/settings/manager.js +87 -37
  128. package/dist/settings/schema-export.d.ts +2 -3
  129. package/dist/settings/schema-export.js +2 -3
  130. package/dist/settings/schema.d.ts +21 -0
  131. package/dist/settings/schema.js +12 -0
  132. package/dist/skills/scanner.d.ts +3 -2
  133. package/dist/skills/scanner.js +12 -9
  134. package/dist/tool-system/builtin/background-jobs.d.ts +10 -1
  135. package/dist/tool-system/builtin/background-jobs.js +12 -1
  136. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  137. package/dist/tool-system/builtin/background-work.js +51 -5
  138. package/dist/tool-system/builtin/config.d.ts +2 -1
  139. package/dist/tool-system/builtin/config.js +16 -11
  140. package/dist/tool-system/builtin/drive-claude-code.d.ts +16 -2
  141. package/dist/tool-system/builtin/drive-claude-code.js +301 -47
  142. package/dist/tool-system/builtin/edit.js +5 -2
  143. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  144. package/dist/tool-system/builtin/generate-video.js +13 -4
  145. package/dist/tool-system/builtin/index.d.ts +8 -3
  146. package/dist/tool-system/builtin/index.js +37 -21
  147. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  148. package/dist/tool-system/builtin/lsp.js +6 -3
  149. package/dist/tool-system/builtin/memory.js +40 -8
  150. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  151. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  152. package/dist/tool-system/builtin/powershell.js +11 -7
  153. package/dist/tool-system/builtin/read.js +118 -6
  154. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  155. package/dist/tool-system/builtin/view-image.js +109 -19
  156. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  157. package/dist/tool-system/builtin/worktree.js +297 -74
  158. package/dist/tool-system/builtin/write.js +5 -3
  159. package/dist/tool-system/context.d.ts +23 -1
  160. package/dist/tool-system/executor.d.ts +1 -5
  161. package/dist/tool-system/executor.js +94 -115
  162. package/dist/tool-system/mcp-manager.d.ts +18 -5
  163. package/dist/tool-system/mcp-manager.js +157 -74
  164. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  165. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  166. package/dist/tool-system/path-policy.d.ts +2 -0
  167. package/dist/tool-system/path-policy.js +41 -12
  168. package/dist/tool-system/permission.d.ts +28 -7
  169. package/dist/tool-system/permission.js +130 -49
  170. package/dist/tool-system/registry.js +11 -4
  171. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  172. package/dist/tool-system/tool-result-redaction.js +48 -0
  173. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  174. package/dist/tool-system/workspace-bridge.js +1 -0
  175. package/dist/types.d.ts +86 -5
  176. package/dist/utils/toolDisplay.js +1 -1
  177. package/package.json +4 -3
@@ -11,12 +11,14 @@ import { ContextLimitError } from "../exceptions.js";
11
11
  import { logger } from "../logging/logger.js";
12
12
  import { checkTokenBudget, createBudgetTracker } from "./token-budget.js";
13
13
  import { StreamingToolQueue } from "./streaming-tool-queue.js";
14
- import { estimateTokens } from "../context/compaction.js";
14
+ import { downgradeImagePayloadsInHistory, estimateTokens, messageHasBase64ImagePayload, } from "../context/compaction.js";
15
15
  import { isTruncatedStop } from "../llm/stop-reason.js";
16
16
  import { isAbortError } from "../llm/client-base.js";
17
17
  import { crossedReactiveThreshold } from "./reactive-threshold.js";
18
18
  import { COMPLETE_GOAL_TOOL_NAME } from "../tool-system/builtin/complete-goal.js";
19
19
  import { CANCEL_GOAL_TOOL_NAME } from "../tool-system/builtin/cancel-goal.js";
20
+ import { redactSensitiveToolResultsInMessages, toolResultForDisplay, toolResultTranscriptText, toolResultsForDisplay, } from "../tool-system/tool-result-redaction.js";
21
+ import { addTokenUsage, cacheHitRateFromUsage, cumulativeCacheHitRate, } from "./session-usage.js";
20
22
  import { createGoalBudgetTracker, recordGoalUsage, goalBudgetExceeded, applyGoalExtension, limitProximity, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "./goal.js";
21
23
  /**
22
24
  * 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
@@ -29,7 +31,7 @@ export function toolResultToBlock(result) {
29
31
  tool_use_id: result.id,
30
32
  content: result.error
31
33
  ? `Error: ${result.error}`
32
- : result.contentBlocks ?? (result.result ?? "(no output)"),
34
+ : (result.contentBlocks ?? result.result ?? "(no output)"),
33
35
  };
34
36
  if (result.isError || result.error)
35
37
  block.is_error = true;
@@ -50,6 +52,16 @@ export class TurnLoop {
50
52
  currentTurnLog = logger;
51
53
  /** Last emitted ctx token estimate; used to skip no-op usage_update events. */
52
54
  lastCtxEmit = -1;
55
+ currentTurnUsage = {
56
+ promptTokens: 0,
57
+ completionTokens: 0,
58
+ totalTokens: 0,
59
+ cacheReadTokens: 0,
60
+ cacheCreationTokens: 0,
61
+ };
62
+ currentCumulativeUsage;
63
+ sensitiveToolResultRedactions = new Map();
64
+ pendingImageMessages = new Set();
53
65
  /**
54
66
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
55
67
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -145,6 +157,9 @@ export class TurnLoop {
145
157
  config) {
146
158
  this.deps = deps;
147
159
  this.config = config;
160
+ for (const msg of this.config.freshImageMessages ?? []) {
161
+ this.pendingImageMessages.add(msg);
162
+ }
148
163
  // Wrap onStream so a single throwing handler can't silently break
149
164
  // the channel for the rest of the run. A 2026-05-25 incident saw a
150
165
  // sub-agent's events stop reaching the renderer ~23s into its run —
@@ -197,6 +212,52 @@ export class TurnLoop {
197
212
  return;
198
213
  this.deps.transcript.appendTurnStopped();
199
214
  }
215
+ prepareMessagesForModel(messages) {
216
+ const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
217
+ const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
218
+ if (result.replacedCount > 0) {
219
+ this.currentTurnLog.info("context.image_payload_downgrade", {
220
+ cat: "context",
221
+ images: result.replacedCount,
222
+ pendingFresh: this.pendingImageMessages.size,
223
+ });
224
+ }
225
+ return result.messages;
226
+ }
227
+ markPendingImagesConsumed(messages) {
228
+ if (this.pendingImageMessages.size === 0)
229
+ return messages;
230
+ const consumedMessages = this.pendingImageMessages.size;
231
+ this.pendingImageMessages.clear();
232
+ const result = downgradeImagePayloadsInHistory(messages);
233
+ if (result.replacedCount > 0) {
234
+ this.currentTurnLog.info("context.image_payload_consumed", {
235
+ cat: "context",
236
+ images: result.replacedCount,
237
+ messages: consumedMessages,
238
+ });
239
+ }
240
+ return result.messages;
241
+ }
242
+ redactConsumedSensitiveToolResults(messages) {
243
+ if (this.sensitiveToolResultRedactions.size === 0)
244
+ return messages;
245
+ const redacted = redactSensitiveToolResultsInMessages(messages, this.sensitiveToolResultRedactions);
246
+ this.sensitiveToolResultRedactions.clear();
247
+ return redacted;
248
+ }
249
+ modelCallRecordingOptions() {
250
+ if (this.sensitiveToolResultRedactions.size === 0)
251
+ return undefined;
252
+ return {
253
+ sensitiveToolResultRedactions: new Map(this.sensitiveToolResultRedactions),
254
+ };
255
+ }
256
+ trackFreshImageMessage(message) {
257
+ if (messageHasBase64ImagePayload(message)) {
258
+ this.pendingImageMessages.add(message);
259
+ }
260
+ }
200
261
  async emitHook(event, data = {}) {
201
262
  return this.deps.hooks.emit(event, {
202
263
  ...data,
@@ -236,7 +297,16 @@ export class TurnLoop {
236
297
  if (ctx === this.lastCtxEmit)
237
298
  return;
238
299
  this.lastCtxEmit = ctx;
239
- this.config.onStream({ type: "usage_update", promptTokens: ctx });
300
+ this.config.onStream({
301
+ type: "usage_update",
302
+ promptTokens: ctx,
303
+ promptTokensSource: overhead > 0 ? "calibrated_estimate" : "heuristic_estimate",
304
+ promptTokensConfidence: overhead > 0 ? "medium" : "low",
305
+ });
306
+ }
307
+ recordResponseUsage(usage) {
308
+ this.currentTurnUsage = addTokenUsage(this.currentTurnUsage, usage);
309
+ this.currentCumulativeUsage = this.deps.recordCumulativeUsage?.(usage);
240
310
  }
241
311
  emitCtxFromUsage(usage, messages) {
242
312
  if (!this.config.onStream)
@@ -254,29 +324,58 @@ export class TurnLoop {
254
324
  derivedOverhead: overhead,
255
325
  prev: this.lastCtxEmit,
256
326
  });
257
- if (promptTokens === this.lastCtxEmit)
258
- return;
259
- this.lastCtxEmit = promptTokens;
327
+ const promptChanged = promptTokens !== this.lastCtxEmit;
328
+ if (promptChanged)
329
+ this.lastCtxEmit = promptTokens;
260
330
  // Forward the provider's cache counts so the UI can show a hit rate. Only
261
331
  // attach fields the provider actually reported — a spread keeps them off
262
332
  // the event entirely when undefined, so the renderer can tell "no cache
263
333
  // info this turn" from "0 cached". Estimate-path emits don't call this and
264
334
  // so carry no cache fields (correct: an estimate has no cache reading).
335
+ const singleTurnCacheHitRate = cacheHitRateFromUsage(this.currentTurnUsage);
336
+ const cumulative = this.currentCumulativeUsage;
337
+ const cumulativeHitRate = cumulative ? cumulativeCacheHitRate(cumulative) : undefined;
338
+ if (!promptChanged && singleTurnCacheHitRate === undefined && cumulativeHitRate === undefined) {
339
+ return;
340
+ }
265
341
  this.config.onStream({
266
342
  type: "usage_update",
267
343
  promptTokens,
268
- ...(usage.cacheReadTokens !== undefined
269
- ? { cacheReadTokens: usage.cacheReadTokens }
270
- : {}),
344
+ promptTokensSource: "provider_usage",
345
+ promptTokensConfidence: "high",
346
+ ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}),
271
347
  ...(usage.cacheCreationTokens !== undefined
272
348
  ? { cacheCreationTokens: usage.cacheCreationTokens }
273
349
  : {}),
350
+ singleTurnPromptTokens: this.currentTurnUsage.promptTokens,
351
+ singleTurnCacheReadTokens: this.currentTurnUsage.cacheReadTokens ?? 0,
352
+ singleTurnCacheCreationTokens: this.currentTurnUsage.cacheCreationTokens ?? 0,
353
+ ...(singleTurnCacheHitRate !== undefined ? { singleTurnCacheHitRate } : {}),
354
+ ...(cumulative
355
+ ? {
356
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
357
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
358
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
359
+ ...(cumulativeHitRate !== undefined
360
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
361
+ : {}),
362
+ // Legacy aliases for existing renderer builds. New UI code reads the
363
+ // cumulative* fields above.
364
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
365
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
366
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
367
+ }
368
+ : {}),
274
369
  });
275
370
  }
276
371
  /**
277
372
  * Run the multi-turn agent loop until completion.
278
373
  */
279
374
  async run(initialMessages) {
375
+ const result = await this.runUnredacted(initialMessages);
376
+ return { ...result, messages: this.redactConsumedSensitiveToolResults(result.messages) };
377
+ }
378
+ async runUnredacted(initialMessages) {
280
379
  let messages = [...initialMessages];
281
380
  let finalText = "";
282
381
  const budgetTracker = createBudgetTracker();
@@ -300,6 +399,13 @@ export class TurnLoop {
300
399
  try {
301
400
  while (this.turnCount < this.config.maxTurns) {
302
401
  this.turnCount++;
402
+ this.currentTurnUsage = {
403
+ promptTokens: 0,
404
+ completionTokens: 0,
405
+ totalTokens: 0,
406
+ cacheReadTokens: 0,
407
+ cacheCreationTokens: 0,
408
+ };
303
409
  // Abort fast-path: bail at the loop TOP before doing any per-turn work.
304
410
  // Without this, an aborted child (parent abort, or the 30min per-call
305
411
  // registry timeout) would run a full contextManager.manageAsync (itself
@@ -319,14 +425,7 @@ export class TurnLoop {
319
425
  // they join THIS step's request — no abort, no lost in-flight work. Same
320
426
  // loop-top user-push pattern as turnStartInjection / turn-limit warnings
321
427
  // below. Push to transcript too so they persist + survive resume.
322
- const steered = this.deps.consumeSteer?.() ?? [];
323
- for (const { id, text } of steered) {
324
- if (!text)
325
- continue;
326
- messages.push({ role: "user", content: text });
327
- this.deps.transcript.appendMessage("user", text);
328
- this.config.onStream?.({ type: "steer_injected", text, id });
329
- }
428
+ this.consumeQueuedSteer(messages, "normal_step");
330
429
  const state = initialTurnState(this.turnCount);
331
430
  // Per-turn correlation ID. Every log written through `tlog` (or any
332
431
  // child derived from it) is stamped with `turn` + `turnId`, so
@@ -335,13 +434,18 @@ export class TurnLoop {
335
434
  // returns; instead, each return-causing branch logs its own terminal
336
435
  // event (model_error, completed, etc.).
337
436
  const turnId = newTurnId();
437
+ const assistantMessageId = `assistant_${turnId}`;
338
438
  const tlog = logger.child({ turn: this.turnCount, turnId });
339
439
  this.currentTurnLog = tlog;
340
440
  const turnStartedAt = Date.now();
341
441
  tlog.info("turn.start", { cat: "turn", messageCount: messages.length });
342
442
  // Tag downstream tool-exec / permission lines with this turn's IDs.
343
443
  this.deps.toolExecutor.setLogger(tlog);
344
- this.config.onStream?.({ type: "stream_request_start", turnNumber: this.turnCount });
444
+ this.config.onStream?.({
445
+ type: "stream_request_start",
446
+ turnNumber: this.turnCount,
447
+ messageId: assistantMessageId,
448
+ });
345
449
  const turnStartHook = await this.emitHook("on_turn_start", {
346
450
  turnNumber: this.turnCount,
347
451
  });
@@ -378,15 +482,28 @@ export class TurnLoop {
378
482
  // Goal mode: announce once when nearing EITHER stop ceiling (turns or
379
483
  // stop-blocks) so the UI can offer a "再续" button while still live.
380
484
  this.maybeAnnounceApproachingLimit();
381
- // Pre-check: context management (async may trigger LLM summarization)
382
- messages = await this.deps.contextManager.manageAsync(messages);
383
- // manageAsync can itself issue an LLM summarization call lasting several
384
- // seconds; if the signal aborted during it, stop here rather than
385
- // proceeding into the (expensive) main model call. Belt to the loop-top
386
- // brace: this catches an abort that landed *inside* context management.
387
- if (this.config.signal?.aborted) {
388
- this.markStopped();
389
- return { text: finalText, reason: "aborted_streaming", messages };
485
+ // Pre-check: downgrade image payloads that have already had their one
486
+ // model-consumption turn, then run context management. Fresh images in
487
+ // pendingImageMessages are preserved through this next model request.
488
+ const hasPendingSensitiveToolResults = this.sensitiveToolResultRedactions.size > 0;
489
+ messages = this.prepareMessagesForModel(messages);
490
+ if (hasPendingSensitiveToolResults) {
491
+ tlog.info("turn.sensitive_tool_result_context_management_skipped", {
492
+ cat: "turn",
493
+ count: this.sensitiveToolResultRedactions.size,
494
+ });
495
+ }
496
+ else {
497
+ // Context management (async — may trigger LLM summarization)
498
+ messages = await this.deps.contextManager.manageAsync(messages);
499
+ // manageAsync can itself issue an LLM summarization call lasting several
500
+ // seconds; if the signal aborted during it, stop here rather than
501
+ // proceeding into the (expensive) main model call. Belt to the loop-top
502
+ // brace: this catches an abort that landed *inside* context management.
503
+ if (this.config.signal?.aborted) {
504
+ this.markStopped();
505
+ return { text: finalText, reason: "aborted_streaming", messages };
506
+ }
390
507
  }
391
508
  // No pre-llm ctx emit here: the messages-only estimate would be ~16k
392
509
  // smaller than the real prompt (system + tools not included), making
@@ -400,7 +517,9 @@ export class TurnLoop {
400
517
  // call. Microcompact is lossless (just clearing redundant
401
518
  // tool_results) so we suppress hook emits for it to keep token
402
519
  // overhead down.
403
- const pending = this.deps.consumePendingCompactInfo?.();
520
+ const pending = hasPendingSensitiveToolResults
521
+ ? null
522
+ : this.deps.consumePendingCompactInfo?.();
404
523
  if (pending && pending.strategy !== "micro") {
405
524
  const compactHook = await this.emitHook("post_compact", {
406
525
  strategy: pending.strategy,
@@ -415,11 +534,12 @@ export class TurnLoop {
415
534
  // Model call (with streaming fallback and max_output_tokens continuation)
416
535
  // Track tool IDs streamed during this turn to avoid duplicate UI events
417
536
  this.streamedToolIds.clear();
418
- // Streaming tool queue: start concurrency-safe tools during streaming
537
+ // Tool queue is created before the call, but enqueue happens only after
538
+ // the complete LLMResponse is available below.
419
539
  const streamingQueue = new StreamingToolQueue(this.deps.toolExecutor);
420
540
  let response;
421
541
  try {
422
- response = await this.callModelWithFallback(messages);
542
+ response = await this.callModelWithFallback(messages, assistantMessageId);
423
543
  }
424
544
  catch (err) {
425
545
  if (err instanceof ContextLimitError) {
@@ -430,13 +550,14 @@ export class TurnLoop {
430
550
  tlog.warn("turn.ptl_recovery", { cat: "turn", retry, roundsToDrop: retry });
431
551
  messages = dropOldestRounds(messages, retry);
432
552
  try {
433
- response = await this.callModelWithFallback(messages);
553
+ response = await this.callModelWithFallback(messages, assistantMessageId);
434
554
  recovered = true;
435
555
  break;
436
556
  }
437
557
  catch (retryErr) {
438
558
  if (!(retryErr instanceof ContextLimitError)) {
439
559
  this.config.onStream?.({ type: "error", error: formatFriendlyError(retryErr) });
560
+ messages = this.redactConsumedSensitiveToolResults(messages);
440
561
  return { text: finalText, reason: "model_error", messages };
441
562
  }
442
563
  }
@@ -447,6 +568,7 @@ export class TurnLoop {
447
568
  type: "error",
448
569
  error: "Context limit exceeded after 3 recovery attempts",
449
570
  });
571
+ messages = this.redactConsumedSensitiveToolResults(messages);
450
572
  return { text: finalText, reason: "prompt_too_long", messages };
451
573
  }
452
574
  }
@@ -460,24 +582,31 @@ export class TurnLoop {
460
582
  // interrupted turn folds behind the process-card header on reload).
461
583
  this.patchOrphanedToolUses(messages);
462
584
  this.markStopped();
585
+ messages = this.redactConsumedSensitiveToolResults(messages);
463
586
  return { text: finalText, reason: "aborted_streaming", messages };
464
587
  }
465
588
  else {
466
589
  this.patchOrphanedToolUses(messages);
467
590
  this.config.onStream?.({ type: "error", error: formatFriendlyError(err) });
591
+ messages = this.redactConsumedSensitiveToolResults(messages);
468
592
  return { text: finalText, reason: "model_error", messages };
469
593
  }
470
594
  }
471
- // UI ctx bar: prefer the provider's authoritative promptTokens.
595
+ messages = this.redactConsumedSensitiveToolResults(messages);
596
+ // Record the response once into current-turn and whole-session counters.
472
597
  if (response.usage?.promptTokens !== undefined) {
598
+ this.recordResponseUsage(response.usage);
473
599
  this.emitCtxFromUsage(response.usage, messages);
474
600
  }
475
601
  // Feed actual token usage back to the context manager so subsequent
476
602
  // compaction decisions use hybrid (actual + delta) estimation rather than
477
603
  // pure heuristics. Without this the manager falls back to char/4 estimates.
478
604
  if (response.usage?.promptTokens !== undefined) {
479
- this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length);
605
+ const anchor = this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length, messages);
606
+ if (anchor)
607
+ this.deps.recordContextUsageAnchor?.(anchor);
480
608
  }
609
+ messages = this.markPendingImagesConsumed(messages);
481
610
  // Truncation that cut off a TOOL CALL: the model overflowed
482
611
  // max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
483
612
  // Write whose `content` was clipped, leaving file_path unset). Executing
@@ -506,6 +635,7 @@ export class TurnLoop {
506
635
  response.toolCalls.length === 0 &&
507
636
  response.text) {
508
637
  let combinedText = response.text;
638
+ let continuedResponse = false;
509
639
  for (let retry = 0; retry < 3; retry++) {
510
640
  // Don't fire another continuation call if the user cancelled — without
511
641
  // this an abort during a truncated response could still issue up to 3
@@ -522,7 +652,11 @@ export class TurnLoop {
522
652
  },
523
653
  ];
524
654
  try {
525
- const contResponse = await this.deps.model.call(this.deps.systemPrompt, contMessages, this.deps.tools, this.config.onStream, this.config.signal);
655
+ const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
656
+ if (contResponse.usage?.promptTokens !== undefined) {
657
+ this.recordResponseUsage(contResponse.usage);
658
+ continuedResponse = true;
659
+ }
526
660
  combinedText += contResponse.text;
527
661
  if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
528
662
  response = { ...contResponse, text: combinedText };
@@ -534,10 +668,9 @@ export class TurnLoop {
534
668
  }
535
669
  }
536
670
  response = { ...response, text: combinedText };
537
- }
538
- // After any continuation, send latest usage so ctx bar reflects real context
539
- if (response.usage?.promptTokens !== undefined) {
540
- this.emitCtxFromUsage(response.usage, messages);
671
+ if (continuedResponse && response.usage?.promptTokens !== undefined) {
672
+ this.emitCtxFromUsage(response.usage, messages);
673
+ }
541
674
  }
542
675
  // Goal-mode run-scoped accounting: add this turn's total token usage
543
676
  // (prompt + completion) to the running total. Done after continuation so
@@ -568,6 +701,7 @@ export class TurnLoop {
568
701
  });
569
702
  this.config.onStream?.({
570
703
  type: "assistant_message",
704
+ messageId: assistantMessageId,
571
705
  message: {
572
706
  role: "assistant",
573
707
  content: "(Goal 预算已耗尽,强制停止。)",
@@ -580,6 +714,7 @@ export class TurnLoop {
580
714
  // No tool use — final answer
581
715
  this.config.onStream?.({
582
716
  type: "assistant_message",
717
+ messageId: assistantMessageId,
583
718
  message: { role: "assistant", content: finalText },
584
719
  });
585
720
  await this.emitHook("on_turn_end", {
@@ -587,6 +722,9 @@ export class TurnLoop {
587
722
  hasToolUse: false,
588
723
  });
589
724
  messages.push({ role: "assistant", content: finalText });
725
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
726
+ continue;
727
+ }
590
728
  // on_stop seam: the model wants to stop. Give handlers (Goal mode)
591
729
  // a chance to BLOCK termination and keep the agent working. A
592
730
  // handler returning continueSession=true injects its messages and
@@ -652,6 +790,7 @@ export class TurnLoop {
652
790
  });
653
791
  this.config.onStream?.({
654
792
  type: "assistant_message",
793
+ messageId: assistantMessageId,
655
794
  message: {
656
795
  role: "assistant",
657
796
  content: `(Goal 续跑已达 ${maxStopBlocks} 次上限,先停下。)`,
@@ -668,10 +807,17 @@ export class TurnLoop {
668
807
  });
669
808
  }
670
809
  this.stopBlockCount = 0;
810
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
811
+ continue;
812
+ }
813
+ messages = this.redactConsumedSensitiveToolResults(messages);
671
814
  return { text: finalText, reason: "completed", messages };
672
815
  }
673
816
  // Tool execution phase
674
- tlog.info("turn.tool_use", { cat: "turn", tools: response.toolCalls.map((t) => t.toolName) });
817
+ tlog.info("turn.tool_use", {
818
+ cat: "turn",
819
+ tools: response.toolCalls.map((t) => t.toolName),
820
+ });
675
821
  const toolCalls = response.toolCalls.slice(0, this.config.maxToolCallsPerTurn);
676
822
  // Per-turn cap: any calls beyond maxToolCallsPerTurn are NOT executed and
677
823
  // NOT added to the assistant message below, so the model never sees a
@@ -709,8 +855,13 @@ export class TurnLoop {
709
855
  const resultBlocks = [];
710
856
  for (const result of results) {
711
857
  resultBlocks.push(toolResultToBlock(result));
712
- this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error);
713
- this.config.onStream?.({ type: "tool_result", result });
858
+ const streamResult = toolResultForDisplay(result);
859
+ const transcriptResult = toolResultTranscriptText(result);
860
+ if (result.sensitive && transcriptResult !== undefined) {
861
+ this.sensitiveToolResultRedactions.set(result.id, transcriptResult);
862
+ }
863
+ this.deps.transcript.appendToolResult(result.id, result.toolName, transcriptResult, result.error, result.sensitive ? undefined : result.contentBlocks);
864
+ this.config.onStream?.({ type: "tool_result", result: streamResult });
714
865
  }
715
866
  // Fire-and-forget tool use summary (non-blocking). The whole chain is
716
867
  // best-effort observability — a thrown onStream handler, a failed dynamic
@@ -721,9 +872,13 @@ export class TurnLoop {
721
872
  .then(({ generateToolUseSummary }) => {
722
873
  if (!this.deps.model.summarize)
723
874
  return;
724
- return generateToolUseSummary(toolCalls, results, this.deps.model.summarize).then((summary) => {
875
+ return generateToolUseSummary(toolCalls, toolResultsForDisplay(results), this.deps.model.summarize).then((summary) => {
725
876
  if (summary) {
726
- this.config.onStream?.({ type: "tool_summary", summary });
877
+ this.config.onStream?.({
878
+ type: "tool_summary",
879
+ summary,
880
+ toolCallIds: toolCalls.map((toolCall) => toolCall.id),
881
+ });
727
882
  }
728
883
  });
729
884
  })
@@ -734,7 +889,9 @@ export class TurnLoop {
734
889
  });
735
890
  });
736
891
  }
737
- messages.push({ role: "user", content: resultBlocks });
892
+ const toolResultMessage = { role: "user", content: resultBlocks };
893
+ messages.push(toolResultMessage);
894
+ this.trackFreshImageMessage(toolResultMessage);
738
895
  // B-3: tell the model which of its requested tool calls were dropped by
739
896
  // the per-turn cap so it can re-issue them, instead of silently assuming
740
897
  // they ran. Appended to the same user message that carries the results.
@@ -773,6 +930,10 @@ export class TurnLoop {
773
930
  tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
774
931
  this.stopBlockCount = 0;
775
932
  this.deps.clearPersistedGoal?.();
933
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
934
+ continue;
935
+ }
936
+ messages = this.redactConsumedSensitiveToolResults(messages);
776
937
  return { text: finalText, reason: "completed", messages };
777
938
  }
778
939
  // Goal mode: user-initiated cancellation. cancel_goal is the "strong
@@ -785,6 +946,9 @@ export class TurnLoop {
785
946
  tlog.info("turn.goal_user_cancelled", { cat: "goal" });
786
947
  this.stopBlockCount = 0;
787
948
  this.deps.clearPersistedGoal?.();
949
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
950
+ continue;
951
+ }
788
952
  return { text: finalText, reason: "completed", messages };
789
953
  }
790
954
  // Token budget check
@@ -798,9 +962,14 @@ export class TurnLoop {
798
962
  });
799
963
  this.config.onStream?.({
800
964
  type: "assistant_message",
965
+ messageId: assistantMessageId,
801
966
  message: { role: "assistant", content: finalText },
802
967
  });
803
968
  messages.push({ role: "assistant", content: finalText });
969
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
970
+ continue;
971
+ }
972
+ messages = this.redactConsumedSensitiveToolResults(messages);
804
973
  return { text: finalText, reason: "completed", messages };
805
974
  }
806
975
  if (budgetDecision === "nudge") {
@@ -861,6 +1030,7 @@ export class TurnLoop {
861
1030
  // no error event, so the UI shows only the "你停止了本轮" line.
862
1031
  if (isAbortError(err) || this.config.signal?.aborted) {
863
1032
  this.markStopped();
1033
+ messages = this.redactConsumedSensitiveToolResults(messages);
864
1034
  return { text: finalText, reason: "aborted_streaming", messages };
865
1035
  }
866
1036
  this.currentTurnLog.error("turn.unhandled_error", {
@@ -869,6 +1039,7 @@ export class TurnLoop {
869
1039
  stack: err.stack?.split("\n").slice(0, 4).join("\n"),
870
1040
  });
871
1041
  this.config.onStream?.({ type: "error", error: formatFriendlyError(err) });
1042
+ messages = this.redactConsumedSensitiveToolResults(messages);
872
1043
  return { text: finalText, reason: "model_error", messages };
873
1044
  }
874
1045
  // Max turns reached — do one final summarization call (no tools)
@@ -877,15 +1048,28 @@ export class TurnLoop {
877
1048
  maxTurns: this.config.maxTurns,
878
1049
  turnCount: this.turnCount,
879
1050
  });
880
- messages = this.deps.contextManager.manage(messages);
1051
+ this.consumeQueuedSteer(messages, "finalize_backfill");
1052
+ const hasPendingSensitiveToolResults = this.sensitiveToolResultRedactions.size > 0;
1053
+ messages = this.prepareMessagesForModel(messages);
1054
+ if (hasPendingSensitiveToolResults) {
1055
+ logger.info("turn.sensitive_tool_result_context_management_skipped", {
1056
+ cat: "turn",
1057
+ count: this.sensitiveToolResultRedactions.size,
1058
+ phase: "max_turns_summary",
1059
+ });
1060
+ }
1061
+ else {
1062
+ messages = this.deps.contextManager.manage(messages);
1063
+ }
881
1064
  messages.push({
882
1065
  role: "user",
883
1066
  content: "<system-reminder>Turn limit reached. Provide a final summary of what you accomplished and what remains to be done. Do NOT call any tools.</system-reminder>",
884
1067
  });
885
1068
  this.emitCtxFromMessages(messages);
886
1069
  try {
887
- const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, messages, [], // No tools available for summary turn
888
- this.config.onStream, this.config.signal);
1070
+ const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
1071
+ this.config.onStream, this.config.signal, this.modelCallRecordingOptions());
1072
+ messages = this.markPendingImagesConsumed(messages);
889
1073
  if (summaryResponse.text) {
890
1074
  finalText = summaryResponse.text;
891
1075
  }
@@ -901,14 +1085,14 @@ export class TurnLoop {
901
1085
  });
902
1086
  messages.push({ role: "assistant", content: finalText });
903
1087
  }
904
- this.config.onStream?.({ type: "turn_complete", reason: "max_turns" });
1088
+ messages = this.redactConsumedSensitiveToolResults(messages);
905
1089
  return { text: finalText, reason: "max_turns", messages };
906
1090
  }
907
1091
  /**
908
1092
  * Call model with streaming fallback.
909
1093
  * If streaming fails, emit tombstone and retry non-streaming.
910
1094
  */
911
- async callModelWithFallback(messages) {
1095
+ async callModelWithFallback(messages, assistantMessageId) {
912
1096
  // Wrap stream callback to track tool_use_start events and reactive compaction
913
1097
  let streamingResponseTokens = 0;
914
1098
  let reactiveBucket = -1;
@@ -940,7 +1124,7 @@ export class TurnLoop {
940
1124
  }
941
1125
  : undefined;
942
1126
  try {
943
- return await this.deps.model.call(this.deps.systemPrompt, messages, this.deps.tools, wrappedStream, this.config.signal);
1127
+ return await this.deps.model.call(this.deps.systemPrompt, messages, this.deps.tools, wrappedStream, this.config.signal, this.modelCallRecordingOptions());
944
1128
  }
945
1129
  catch (err) {
946
1130
  // If it's a context or rate limit error, don't fallback — propagate
@@ -960,19 +1144,52 @@ export class TurnLoop {
960
1144
  // really just a cancel. The signal is the authoritative cancel source.
961
1145
  if (isAbortError(err) || this.config.signal?.aborted)
962
1146
  throw err;
1147
+ // Sensitive tool results are model-facing exactly once. A streaming
1148
+ // fallback would re-send the same pending plaintext in a second request,
1149
+ // so fail the turn and let the unified exit redact returned history.
1150
+ if (this.sensitiveToolResultRedactions.size > 0) {
1151
+ this.config.onStream?.({ type: "tombstone", messageId: assistantMessageId });
1152
+ this.currentTurnLog.warn("turn.streaming_fallback_skipped_sensitive", {
1153
+ cat: "turn",
1154
+ error: err.message,
1155
+ });
1156
+ throw err;
1157
+ }
963
1158
  // Streaming might have partially emitted — send tombstone to revoke
964
- this.config.onStream?.({ type: "tombstone", messageId: `turn_${this.turnCount}` });
1159
+ this.config.onStream?.({ type: "tombstone", messageId: assistantMessageId });
965
1160
  this.currentTurnLog.warn("turn.streaming_fallback", {
966
1161
  cat: "turn",
967
1162
  error: err.message,
968
1163
  });
969
1164
  // Retry without streaming
970
- return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal);
1165
+ return await this.deps.model.callWithoutStreaming(this.deps.systemPrompt, messages, this.deps.tools, this.config.signal, this.modelCallRecordingOptions());
971
1166
  }
972
1167
  }
973
1168
  get currentTurn() {
974
1169
  return this.turnCount;
975
1170
  }
1171
+ consumeQueuedSteer(messages, source) {
1172
+ const steered = this.deps.consumeSteer?.(source) ?? [];
1173
+ let consumed = false;
1174
+ for (const { id, text, clientMessageId } of steered) {
1175
+ if (!text)
1176
+ continue;
1177
+ if (clientMessageId && this.deps.claimClientMessageId?.(clientMessageId, "steer") === false) {
1178
+ logger.info("steer.submit.duplicate_ignored", {
1179
+ clientMessageId,
1180
+ steerId: id,
1181
+ sessionId: this.deps.sessionId,
1182
+ source,
1183
+ });
1184
+ continue;
1185
+ }
1186
+ consumed = true;
1187
+ messages.push({ role: "user", content: text });
1188
+ this.deps.transcript.appendMessage("user", text, { steerId: id, clientMessageId });
1189
+ this.config.onStream?.({ type: "steer_injected", text, id });
1190
+ }
1191
+ return consumed;
1192
+ }
976
1193
  /**
977
1194
  * Generate synthetic error tool_results for any dangling tool_use blocks
978
1195
  * that never received results (e.g. because the API call failed).