@cjhyy/code-shell-core 0.6.0-rc.1 → 0.6.0-rc.11

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 (69) hide show
  1. package/dist/context/compaction.d.ts +30 -0
  2. package/dist/context/compaction.js +93 -0
  3. package/dist/context/manager.d.ts +18 -0
  4. package/dist/context/manager.js +156 -44
  5. package/dist/context/token-counter.js +13 -0
  6. package/dist/engine/engine.d.ts +22 -12
  7. package/dist/engine/engine.js +263 -81
  8. package/dist/engine/model-connections-pool.js +1 -0
  9. package/dist/engine/model-facade.js +2 -12
  10. package/dist/engine/query.js +2 -0
  11. package/dist/engine/runtime.d.ts +2 -0
  12. package/dist/engine/runtime.js +25 -0
  13. package/dist/engine/session-usage.d.ts +12 -0
  14. package/dist/engine/session-usage.js +56 -0
  15. package/dist/engine/steer-queue.d.ts +2 -1
  16. package/dist/engine/steer-queue.js +2 -2
  17. package/dist/engine/turn-loop.d.ts +28 -2
  18. package/dist/engine/turn-loop.js +153 -26
  19. package/dist/git/utils.d.ts +12 -0
  20. package/dist/git/utils.js +33 -6
  21. package/dist/index.d.ts +4 -3
  22. package/dist/index.js +4 -3
  23. package/dist/llm/capabilities/rules.js +1 -1
  24. package/dist/llm/model-pool.d.ts +7 -0
  25. package/dist/llm/model-pool.js +8 -1
  26. package/dist/model-catalog/builtin.js +6 -1
  27. package/dist/preset/index.d.ts +5 -1
  28. package/dist/preset/index.js +21 -2
  29. package/dist/prompt/composer.d.ts +5 -0
  30. package/dist/prompt/composer.js +10 -2
  31. package/dist/prompt/sections/base.md +1 -0
  32. package/dist/protocol/chat-session-manager.d.ts +1 -0
  33. package/dist/protocol/chat-session-manager.js +2 -0
  34. package/dist/protocol/chat-session.d.ts +4 -1
  35. package/dist/protocol/chat-session.js +9 -3
  36. package/dist/protocol/client.d.ts +5 -1
  37. package/dist/protocol/client.js +8 -2
  38. package/dist/protocol/server.d.ts +13 -12
  39. package/dist/protocol/server.js +199 -67
  40. package/dist/protocol/types.d.ts +14 -0
  41. package/dist/runtime/background-shell.js +14 -0
  42. package/dist/runtime/safe-spawn.js +89 -11
  43. package/dist/runtime/spawn-common.d.ts +15 -4
  44. package/dist/runtime/spawn-common.js +113 -12
  45. package/dist/session/session-manager.js +7 -1
  46. package/dist/session/transcript.d.ts +4 -0
  47. package/dist/session/transcript.js +21 -0
  48. package/dist/tool-system/builtin/bash.js +3 -2
  49. package/dist/tool-system/builtin/cron.js +10 -2
  50. package/dist/tool-system/builtin/edit-model-catalog.js +15 -5
  51. package/dist/tool-system/builtin/generate-video.js +3 -0
  52. package/dist/tool-system/builtin/grep.d.ts +9 -0
  53. package/dist/tool-system/builtin/grep.js +100 -3
  54. package/dist/tool-system/builtin/index.d.ts +3 -1
  55. package/dist/tool-system/builtin/index.js +5 -5
  56. package/dist/tool-system/builtin/powershell.js +4 -1
  57. package/dist/tool-system/builtin/sleep.js +5 -0
  58. package/dist/tool-system/context.d.ts +10 -0
  59. package/dist/tool-system/executor.js +25 -2
  60. package/dist/tool-system/mcp-manager.js +17 -0
  61. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  62. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  63. package/dist/tool-system/permission.d.ts +3 -1
  64. package/dist/tool-system/permission.js +2 -1
  65. package/dist/tool-system/sandbox/off.js +7 -1
  66. package/dist/types.d.ts +35 -1
  67. package/dist/utils/exec.d.ts +8 -0
  68. package/dist/utils/exec.js +10 -0
  69. package/package.json +1 -1
@@ -1,5 +1,17 @@
1
1
  import type { TokenUsage } from "../types.js";
2
2
  import type { LLMUsageTracker } from "../llm/types.js";
3
+ export interface CumulativeUsageCounters {
4
+ cumulativePromptTokens: number;
5
+ cumulativeCacheReadTokens: number;
6
+ cumulativeCacheCreationTokens: number;
7
+ }
8
+ export declare function emptyCumulativeUsageCounters(): CumulativeUsageCounters;
9
+ export declare function normalizeCumulativeUsageCounters(counters: Partial<CumulativeUsageCounters> | undefined, legacyUsage?: TokenUsage): CumulativeUsageCounters;
10
+ export declare function addCumulativeUsage(counters: Partial<CumulativeUsageCounters> | undefined, usage: TokenUsage): CumulativeUsageCounters;
11
+ export declare function addTokenUsage(left: TokenUsage, right: TokenUsage): TokenUsage;
12
+ export declare function cacheHitRateFromTokens(promptTokens: number, cacheReadTokens: number | undefined, cacheCreationTokens: number | undefined): number | undefined;
13
+ export declare function cacheHitRateFromUsage(usage: TokenUsage | undefined): number | undefined;
14
+ export declare function cumulativeCacheHitRate(counters: CumulativeUsageCounters): number | undefined;
3
15
  /**
4
16
  * Fold one run's cumulative usage onto a session baseline, producing the new
5
17
  * session-cumulative TokenUsage.
@@ -1,3 +1,59 @@
1
+ export function emptyCumulativeUsageCounters() {
2
+ return {
3
+ cumulativePromptTokens: 0,
4
+ cumulativeCacheReadTokens: 0,
5
+ cumulativeCacheCreationTokens: 0,
6
+ };
7
+ }
8
+ export function normalizeCumulativeUsageCounters(counters, legacyUsage) {
9
+ return {
10
+ cumulativePromptTokens: typeof counters?.cumulativePromptTokens === "number"
11
+ ? counters.cumulativePromptTokens
12
+ : (legacyUsage?.promptTokens ?? 0),
13
+ cumulativeCacheReadTokens: typeof counters?.cumulativeCacheReadTokens === "number"
14
+ ? counters.cumulativeCacheReadTokens
15
+ : (legacyUsage?.cacheReadTokens ?? 0),
16
+ cumulativeCacheCreationTokens: typeof counters?.cumulativeCacheCreationTokens === "number"
17
+ ? counters.cumulativeCacheCreationTokens
18
+ : (legacyUsage?.cacheCreationTokens ?? 0),
19
+ };
20
+ }
21
+ export function addCumulativeUsage(counters, usage) {
22
+ const current = normalizeCumulativeUsageCounters(counters);
23
+ return {
24
+ cumulativePromptTokens: current.cumulativePromptTokens + (usage.promptTokens ?? 0),
25
+ cumulativeCacheReadTokens: current.cumulativeCacheReadTokens + (usage.cacheReadTokens ?? 0),
26
+ cumulativeCacheCreationTokens: current.cumulativeCacheCreationTokens + (usage.cacheCreationTokens ?? 0),
27
+ };
28
+ }
29
+ export function addTokenUsage(left, right) {
30
+ return {
31
+ promptTokens: left.promptTokens + (right.promptTokens ?? 0),
32
+ completionTokens: left.completionTokens + (right.completionTokens ?? 0),
33
+ totalTokens: left.totalTokens + (right.totalTokens ?? 0),
34
+ cacheReadTokens: (left.cacheReadTokens ?? 0) + (right.cacheReadTokens ?? 0),
35
+ cacheCreationTokens: (left.cacheCreationTokens ?? 0) + (right.cacheCreationTokens ?? 0),
36
+ };
37
+ }
38
+ export function cacheHitRateFromTokens(promptTokens, cacheReadTokens, cacheCreationTokens) {
39
+ const read = cacheReadTokens ?? 0;
40
+ const creation = cacheCreationTokens ?? 0;
41
+ if (read === 0 && creation === 0)
42
+ return undefined;
43
+ const uncached = Math.max(0, promptTokens - read - creation);
44
+ const denom = read + creation + uncached;
45
+ if (denom === 0)
46
+ return undefined;
47
+ return read / denom;
48
+ }
49
+ export function cacheHitRateFromUsage(usage) {
50
+ if (!usage)
51
+ return undefined;
52
+ return cacheHitRateFromTokens(usage.promptTokens ?? 0, usage.cacheReadTokens, usage.cacheCreationTokens);
53
+ }
54
+ export function cumulativeCacheHitRate(counters) {
55
+ return cacheHitRateFromTokens(counters.cumulativePromptTokens, counters.cumulativeCacheReadTokens, counters.cumulativeCacheCreationTokens);
56
+ }
1
57
  /**
2
58
  * Fold one run's cumulative usage onto a session baseline, producing the new
3
59
  * session-cumulative TokenUsage.
@@ -9,9 +9,10 @@
9
9
  export interface SteerItem {
10
10
  id: string;
11
11
  text: string;
12
+ clientMessageId?: string;
12
13
  }
13
14
  /** Append a steer entry. Blank text is dropped (returns the list unchanged). */
14
- export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string): SteerItem[];
15
+ export declare function enqueueSteerItem(list: SteerItem[], id: string, text: string, clientMessageId?: string): SteerItem[];
15
16
  /**
16
17
  * Take everything currently queued and clear the list. Returns the drained
17
18
  * entries (in order) and the now-empty remainder. The turn loop calls this at
@@ -1,9 +1,9 @@
1
1
  /** Append a steer entry. Blank text is dropped (returns the list unchanged). */
2
- export function enqueueSteerItem(list, id, text) {
2
+ export function enqueueSteerItem(list, id, text, clientMessageId) {
3
3
  const t = text?.trim();
4
4
  if (!id || !t)
5
5
  return list;
6
- return [...list, { id, text: t }];
6
+ return [...list, { id, text: t, ...(clientMessageId ? { clientMessageId } : {}) }];
7
7
  }
8
8
  /**
9
9
  * Take everything currently queued and clear the list. Returns the drained
@@ -4,13 +4,14 @@
4
4
  * Following Claude Code's po_() pattern:
5
5
  * pre_check → model_call → post_check → tool_exec → context_mgmt → hook_notify → next turn
6
6
  */
7
- import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult } from "../types.js";
7
+ import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult, TokenUsage } from "../types.js";
8
8
  import type { SteerItem } from "./steer-queue.js";
9
9
  import { ModelFacade } from "./model-facade.js";
10
10
  import { ToolExecutor } from "../tool-system/executor.js";
11
11
  import { ContextManager } from "../context/manager.js";
12
12
  import { HookRegistry } from "../hooks/registry.js";
13
13
  import { Transcript } from "../session/transcript.js";
14
+ import { type CumulativeUsageCounters } from "./session-usage.js";
14
15
  import { type GoalConfig, type GoalExtension } from "./goal.js";
15
16
  export interface TurnLoopConfig {
16
17
  maxTurns: number;
@@ -18,6 +19,12 @@ export interface TurnLoopConfig {
18
19
  tokenBudget?: number;
19
20
  onStream?: StreamCallback;
20
21
  signal?: AbortSignal;
22
+ /**
23
+ * Image-bearing messages added for the current request and not yet consumed
24
+ * by the model. TurnLoop preserves their base64 for one successful model
25
+ * response, then downgrades them to placeholders in the working history.
26
+ */
27
+ freshImageMessages?: Iterable<Message>;
21
28
  /**
22
29
  * Fired after each turn boundary is recorded. Lets the engine flush an
23
30
  * up-to-date snapshot to state.json mid-run, so a long run doesn't leave
@@ -76,6 +83,11 @@ export interface TurnLoopDeps {
76
83
  before: number;
77
84
  after: number;
78
85
  } | null;
86
+ /**
87
+ * Records one LLM response into the session-monotonic cumulative counters.
88
+ * Returns the updated counters so usage_update can carry both metric scopes.
89
+ */
90
+ recordCumulativeUsage?: (usage: TokenUsage) => CumulativeUsageCounters;
79
91
  /**
80
92
  * Reads/clears any user messages queued for THIS session via the steering
81
93
  * channel (Engine.enqueueSteer) while a run is in flight. Consumed at the top
@@ -84,7 +96,13 @@ export interface TurnLoopDeps {
84
96
  * Returns [] when nothing is queued. Wired by Engine; absent in standalone
85
97
  * tests (turn loop tolerates undefined).
86
98
  */
87
- consumeSteer?: () => SteerItem[];
99
+ consumeSteer?: (source?: "normal_step" | "finalize_backfill") => SteerItem[];
100
+ /**
101
+ * Execution-level idempotency guard for host-supplied user/steer intents.
102
+ * Returns false only when a present clientMessageId has already entered this
103
+ * run or the persisted transcript. Messages without an id bypass this guard.
104
+ */
105
+ claimClientMessageId?: (clientMessageId: string, source: "steer") => boolean;
88
106
  /**
89
107
  * Clear this session's PERSISTED goal (state.activeGoal) and drop the
90
108
  * in-flight goal-stop hook, so a user-initiated cancel_goal both stops the
@@ -121,6 +139,9 @@ export declare class TurnLoop {
121
139
  private currentTurnLog;
122
140
  /** Last emitted ctx token estimate; used to skip no-op usage_update events. */
123
141
  private lastCtxEmit;
142
+ private currentTurnUsage;
143
+ private currentCumulativeUsage;
144
+ private readonly pendingImageMessages;
124
145
  /**
125
146
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
126
147
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -178,6 +199,9 @@ export declare class TurnLoop {
178
199
  * for one Stop still writes a single marker.
179
200
  */
180
201
  private markStopped;
202
+ private prepareMessagesForModel;
203
+ private markPendingImagesConsumed;
204
+ private trackFreshImageMessage;
181
205
  private emitHook;
182
206
  /**
183
207
  * Emit a usage_update so the UI ctx bar reflects current message-array
@@ -187,6 +211,7 @@ export declare class TurnLoop {
187
211
  * promptTokens to override our estimate).
188
212
  */
189
213
  private emitCtxFromMessages;
214
+ private recordResponseUsage;
190
215
  private emitCtxFromUsage;
191
216
  /**
192
217
  * Run the multi-turn agent loop until completion.
@@ -198,6 +223,7 @@ export declare class TurnLoop {
198
223
  */
199
224
  private callModelWithFallback;
200
225
  get currentTurn(): number;
226
+ private consumeQueuedSteer;
201
227
  /**
202
228
  * Generate synthetic error tool_results for any dangling tool_use blocks
203
229
  * that never received results (e.g. because the API call failed).
@@ -11,12 +11,13 @@ 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 { addTokenUsage, cacheHitRateFromUsage, cumulativeCacheHitRate, } from "./session-usage.js";
20
21
  import { createGoalBudgetTracker, recordGoalUsage, goalBudgetExceeded, applyGoalExtension, limitProximity, GOAL_DEFAULT_MAX_STOP_BLOCKS, } from "./goal.js";
21
22
  /**
22
23
  * 把一个 ToolResult 映射成发给 LLM 的 tool_result ContentBlock。
@@ -29,7 +30,7 @@ export function toolResultToBlock(result) {
29
30
  tool_use_id: result.id,
30
31
  content: result.error
31
32
  ? `Error: ${result.error}`
32
- : result.contentBlocks ?? (result.result ?? "(no output)"),
33
+ : (result.contentBlocks ?? result.result ?? "(no output)"),
33
34
  };
34
35
  if (result.isError || result.error)
35
36
  block.is_error = true;
@@ -50,6 +51,15 @@ export class TurnLoop {
50
51
  currentTurnLog = logger;
51
52
  /** Last emitted ctx token estimate; used to skip no-op usage_update events. */
52
53
  lastCtxEmit = -1;
54
+ currentTurnUsage = {
55
+ promptTokens: 0,
56
+ completionTokens: 0,
57
+ totalTokens: 0,
58
+ cacheReadTokens: 0,
59
+ cacheCreationTokens: 0,
60
+ };
61
+ currentCumulativeUsage;
62
+ pendingImageMessages = new Set();
53
63
  /**
54
64
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
55
65
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -145,6 +155,9 @@ export class TurnLoop {
145
155
  config) {
146
156
  this.deps = deps;
147
157
  this.config = config;
158
+ for (const msg of this.config.freshImageMessages ?? []) {
159
+ this.pendingImageMessages.add(msg);
160
+ }
148
161
  // Wrap onStream so a single throwing handler can't silently break
149
162
  // the channel for the rest of the run. A 2026-05-25 incident saw a
150
163
  // sub-agent's events stop reaching the renderer ~23s into its run —
@@ -197,6 +210,38 @@ export class TurnLoop {
197
210
  return;
198
211
  this.deps.transcript.appendTurnStopped();
199
212
  }
213
+ prepareMessagesForModel(messages) {
214
+ const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
215
+ const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
216
+ if (result.replacedCount > 0) {
217
+ this.currentTurnLog.info("context.image_payload_downgrade", {
218
+ cat: "context",
219
+ images: result.replacedCount,
220
+ pendingFresh: this.pendingImageMessages.size,
221
+ });
222
+ }
223
+ return result.messages;
224
+ }
225
+ markPendingImagesConsumed(messages) {
226
+ if (this.pendingImageMessages.size === 0)
227
+ return messages;
228
+ const consumedMessages = this.pendingImageMessages.size;
229
+ this.pendingImageMessages.clear();
230
+ const result = downgradeImagePayloadsInHistory(messages);
231
+ if (result.replacedCount > 0) {
232
+ this.currentTurnLog.info("context.image_payload_consumed", {
233
+ cat: "context",
234
+ images: result.replacedCount,
235
+ messages: consumedMessages,
236
+ });
237
+ }
238
+ return result.messages;
239
+ }
240
+ trackFreshImageMessage(message) {
241
+ if (messageHasBase64ImagePayload(message)) {
242
+ this.pendingImageMessages.add(message);
243
+ }
244
+ }
200
245
  async emitHook(event, data = {}) {
201
246
  return this.deps.hooks.emit(event, {
202
247
  ...data,
@@ -238,6 +283,10 @@ export class TurnLoop {
238
283
  this.lastCtxEmit = ctx;
239
284
  this.config.onStream({ type: "usage_update", promptTokens: ctx });
240
285
  }
286
+ recordResponseUsage(usage) {
287
+ this.currentTurnUsage = addTokenUsage(this.currentTurnUsage, usage);
288
+ this.currentCumulativeUsage = this.deps.recordCumulativeUsage?.(usage);
289
+ }
241
290
  emitCtxFromUsage(usage, messages) {
242
291
  if (!this.config.onStream)
243
292
  return;
@@ -254,23 +303,46 @@ export class TurnLoop {
254
303
  derivedOverhead: overhead,
255
304
  prev: this.lastCtxEmit,
256
305
  });
257
- if (promptTokens === this.lastCtxEmit)
258
- return;
259
- this.lastCtxEmit = promptTokens;
306
+ const promptChanged = promptTokens !== this.lastCtxEmit;
307
+ if (promptChanged)
308
+ this.lastCtxEmit = promptTokens;
260
309
  // Forward the provider's cache counts so the UI can show a hit rate. Only
261
310
  // attach fields the provider actually reported — a spread keeps them off
262
311
  // the event entirely when undefined, so the renderer can tell "no cache
263
312
  // info this turn" from "0 cached". Estimate-path emits don't call this and
264
313
  // so carry no cache fields (correct: an estimate has no cache reading).
314
+ const singleTurnCacheHitRate = cacheHitRateFromUsage(this.currentTurnUsage);
315
+ const cumulative = this.currentCumulativeUsage;
316
+ const cumulativeHitRate = cumulative ? cumulativeCacheHitRate(cumulative) : undefined;
317
+ if (!promptChanged && singleTurnCacheHitRate === undefined && cumulativeHitRate === undefined) {
318
+ return;
319
+ }
265
320
  this.config.onStream({
266
321
  type: "usage_update",
267
322
  promptTokens,
268
- ...(usage.cacheReadTokens !== undefined
269
- ? { cacheReadTokens: usage.cacheReadTokens }
270
- : {}),
323
+ ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}),
271
324
  ...(usage.cacheCreationTokens !== undefined
272
325
  ? { cacheCreationTokens: usage.cacheCreationTokens }
273
326
  : {}),
327
+ singleTurnPromptTokens: this.currentTurnUsage.promptTokens,
328
+ singleTurnCacheReadTokens: this.currentTurnUsage.cacheReadTokens ?? 0,
329
+ singleTurnCacheCreationTokens: this.currentTurnUsage.cacheCreationTokens ?? 0,
330
+ ...(singleTurnCacheHitRate !== undefined ? { singleTurnCacheHitRate } : {}),
331
+ ...(cumulative
332
+ ? {
333
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
334
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
335
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
336
+ ...(cumulativeHitRate !== undefined
337
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
338
+ : {}),
339
+ // Legacy aliases for existing renderer builds. New UI code reads the
340
+ // cumulative* fields above.
341
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
342
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
343
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
344
+ }
345
+ : {}),
274
346
  });
275
347
  }
276
348
  /**
@@ -300,6 +372,13 @@ export class TurnLoop {
300
372
  try {
301
373
  while (this.turnCount < this.config.maxTurns) {
302
374
  this.turnCount++;
375
+ this.currentTurnUsage = {
376
+ promptTokens: 0,
377
+ completionTokens: 0,
378
+ totalTokens: 0,
379
+ cacheReadTokens: 0,
380
+ cacheCreationTokens: 0,
381
+ };
303
382
  // Abort fast-path: bail at the loop TOP before doing any per-turn work.
304
383
  // Without this, an aborted child (parent abort, or the 30min per-call
305
384
  // registry timeout) would run a full contextManager.manageAsync (itself
@@ -319,14 +398,7 @@ export class TurnLoop {
319
398
  // they join THIS step's request — no abort, no lost in-flight work. Same
320
399
  // loop-top user-push pattern as turnStartInjection / turn-limit warnings
321
400
  // 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
- }
401
+ this.consumeQueuedSteer(messages, "normal_step");
330
402
  const state = initialTurnState(this.turnCount);
331
403
  // Per-turn correlation ID. Every log written through `tlog` (or any
332
404
  // child derived from it) is stamped with `turn` + `turnId`, so
@@ -378,7 +450,11 @@ export class TurnLoop {
378
450
  // Goal mode: announce once when nearing EITHER stop ceiling (turns or
379
451
  // stop-blocks) so the UI can offer a "再续" button while still live.
380
452
  this.maybeAnnounceApproachingLimit();
381
- // Pre-check: context management (async may trigger LLM summarization)
453
+ // Pre-check: downgrade image payloads that have already had their one
454
+ // model-consumption turn, then run context management. Fresh images in
455
+ // pendingImageMessages are preserved through this next model request.
456
+ messages = this.prepareMessagesForModel(messages);
457
+ // Context management (async — may trigger LLM summarization)
382
458
  messages = await this.deps.contextManager.manageAsync(messages);
383
459
  // manageAsync can itself issue an LLM summarization call lasting several
384
460
  // seconds; if the signal aborted during it, stop here rather than
@@ -468,8 +544,9 @@ export class TurnLoop {
468
544
  return { text: finalText, reason: "model_error", messages };
469
545
  }
470
546
  }
471
- // UI ctx bar: prefer the provider's authoritative promptTokens.
547
+ // Record the response once into current-turn and whole-session counters.
472
548
  if (response.usage?.promptTokens !== undefined) {
549
+ this.recordResponseUsage(response.usage);
473
550
  this.emitCtxFromUsage(response.usage, messages);
474
551
  }
475
552
  // Feed actual token usage back to the context manager so subsequent
@@ -478,6 +555,7 @@ export class TurnLoop {
478
555
  if (response.usage?.promptTokens !== undefined) {
479
556
  this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length);
480
557
  }
558
+ messages = this.markPendingImagesConsumed(messages);
481
559
  // Truncation that cut off a TOOL CALL: the model overflowed
482
560
  // max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
483
561
  // Write whose `content` was clipped, leaving file_path unset). Executing
@@ -506,6 +584,7 @@ export class TurnLoop {
506
584
  response.toolCalls.length === 0 &&
507
585
  response.text) {
508
586
  let combinedText = response.text;
587
+ let continuedResponse = false;
509
588
  for (let retry = 0; retry < 3; retry++) {
510
589
  // Don't fire another continuation call if the user cancelled — without
511
590
  // this an abort during a truncated response could still issue up to 3
@@ -522,7 +601,11 @@ export class TurnLoop {
522
601
  },
523
602
  ];
524
603
  try {
525
- const contResponse = await this.deps.model.call(this.deps.systemPrompt, contMessages, this.deps.tools, this.config.onStream, this.config.signal);
604
+ const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
605
+ if (contResponse.usage?.promptTokens !== undefined) {
606
+ this.recordResponseUsage(contResponse.usage);
607
+ continuedResponse = true;
608
+ }
526
609
  combinedText += contResponse.text;
527
610
  if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
528
611
  response = { ...contResponse, text: combinedText };
@@ -534,10 +617,9 @@ export class TurnLoop {
534
617
  }
535
618
  }
536
619
  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);
620
+ if (continuedResponse && response.usage?.promptTokens !== undefined) {
621
+ this.emitCtxFromUsage(response.usage, messages);
622
+ }
541
623
  }
542
624
  // Goal-mode run-scoped accounting: add this turn's total token usage
543
625
  // (prompt + completion) to the running total. Done after continuation so
@@ -587,6 +669,9 @@ export class TurnLoop {
587
669
  hasToolUse: false,
588
670
  });
589
671
  messages.push({ role: "assistant", content: finalText });
672
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
673
+ continue;
674
+ }
590
675
  // on_stop seam: the model wants to stop. Give handlers (Goal mode)
591
676
  // a chance to BLOCK termination and keep the agent working. A
592
677
  // handler returning continueSession=true injects its messages and
@@ -668,10 +753,16 @@ export class TurnLoop {
668
753
  });
669
754
  }
670
755
  this.stopBlockCount = 0;
756
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
757
+ continue;
758
+ }
671
759
  return { text: finalText, reason: "completed", messages };
672
760
  }
673
761
  // Tool execution phase
674
- tlog.info("turn.tool_use", { cat: "turn", tools: response.toolCalls.map((t) => t.toolName) });
762
+ tlog.info("turn.tool_use", {
763
+ cat: "turn",
764
+ tools: response.toolCalls.map((t) => t.toolName),
765
+ });
675
766
  const toolCalls = response.toolCalls.slice(0, this.config.maxToolCallsPerTurn);
676
767
  // Per-turn cap: any calls beyond maxToolCallsPerTurn are NOT executed and
677
768
  // NOT added to the assistant message below, so the model never sees a
@@ -734,7 +825,9 @@ export class TurnLoop {
734
825
  });
735
826
  });
736
827
  }
737
- messages.push({ role: "user", content: resultBlocks });
828
+ const toolResultMessage = { role: "user", content: resultBlocks };
829
+ messages.push(toolResultMessage);
830
+ this.trackFreshImageMessage(toolResultMessage);
738
831
  // B-3: tell the model which of its requested tool calls were dropped by
739
832
  // the per-turn cap so it can re-issue them, instead of silently assuming
740
833
  // they ran. Appended to the same user message that carries the results.
@@ -773,6 +866,9 @@ export class TurnLoop {
773
866
  tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
774
867
  this.stopBlockCount = 0;
775
868
  this.deps.clearPersistedGoal?.();
869
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
870
+ continue;
871
+ }
776
872
  return { text: finalText, reason: "completed", messages };
777
873
  }
778
874
  // Goal mode: user-initiated cancellation. cancel_goal is the "strong
@@ -785,6 +881,9 @@ export class TurnLoop {
785
881
  tlog.info("turn.goal_user_cancelled", { cat: "goal" });
786
882
  this.stopBlockCount = 0;
787
883
  this.deps.clearPersistedGoal?.();
884
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
885
+ continue;
886
+ }
788
887
  return { text: finalText, reason: "completed", messages };
789
888
  }
790
889
  // Token budget check
@@ -801,6 +900,9 @@ export class TurnLoop {
801
900
  message: { role: "assistant", content: finalText },
802
901
  });
803
902
  messages.push({ role: "assistant", content: finalText });
903
+ if (this.consumeQueuedSteer(messages, "finalize_backfill")) {
904
+ continue;
905
+ }
804
906
  return { text: finalText, reason: "completed", messages };
805
907
  }
806
908
  if (budgetDecision === "nudge") {
@@ -877,6 +979,8 @@ export class TurnLoop {
877
979
  maxTurns: this.config.maxTurns,
878
980
  turnCount: this.turnCount,
879
981
  });
982
+ this.consumeQueuedSteer(messages, "finalize_backfill");
983
+ messages = this.prepareMessagesForModel(messages);
880
984
  messages = this.deps.contextManager.manage(messages);
881
985
  messages.push({
882
986
  role: "user",
@@ -884,8 +988,9 @@ export class TurnLoop {
884
988
  });
885
989
  this.emitCtxFromMessages(messages);
886
990
  try {
887
- const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, messages, [], // No tools available for summary turn
991
+ const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
888
992
  this.config.onStream, this.config.signal);
993
+ messages = this.markPendingImagesConsumed(messages);
889
994
  if (summaryResponse.text) {
890
995
  finalText = summaryResponse.text;
891
996
  }
@@ -973,6 +1078,28 @@ export class TurnLoop {
973
1078
  get currentTurn() {
974
1079
  return this.turnCount;
975
1080
  }
1081
+ consumeQueuedSteer(messages, source) {
1082
+ const steered = this.deps.consumeSteer?.(source) ?? [];
1083
+ let consumed = false;
1084
+ for (const { id, text, clientMessageId } of steered) {
1085
+ if (!text)
1086
+ continue;
1087
+ if (clientMessageId && this.deps.claimClientMessageId?.(clientMessageId, "steer") === false) {
1088
+ logger.info("steer.submit.duplicate_ignored", {
1089
+ clientMessageId,
1090
+ steerId: id,
1091
+ sessionId: this.deps.sessionId,
1092
+ source,
1093
+ });
1094
+ continue;
1095
+ }
1096
+ consumed = true;
1097
+ messages.push({ role: "user", content: text });
1098
+ this.deps.transcript.appendMessage("user", text, { steerId: id, clientMessageId });
1099
+ this.config.onStream?.({ type: "steer_injected", text, id });
1100
+ }
1101
+ return consumed;
1102
+ }
976
1103
  /**
977
1104
  * Generate synthetic error tool_results for any dangling tool_use blocks
978
1105
  * that never received results (e.g. because the API call failed).
@@ -14,6 +14,18 @@ export interface GitStatusEntry {
14
14
  }
15
15
  export type { GitLogEntry };
16
16
  export declare function isGitRepo(cwd: string): boolean;
17
+ /**
18
+ * Resolve a directory to its PROJECT ROOT: the enclosing git repository's
19
+ * top-level dir if `cwd` is inside a git repo, otherwise `cwd` unchanged.
20
+ *
21
+ * This is the project-boundary rule the desktop uses when adding/identifying a
22
+ * project: picking a SUBDIRECTORY of a git repo should belong to that one repo
23
+ * (its root), not spawn a separate project per subdir — mirrors how editors
24
+ * (and Claude Code) treat a repo as one workspace. A non-git folder is its own
25
+ * project (returned as-is). Never throws; on any git failure falls back to cwd.
26
+ * Returns the git-reported toplevel (already absolute, forward-slashed on win).
27
+ */
28
+ export declare function resolveProjectRoot(cwd: string): string;
17
29
  export declare function getCurrentBranch(cwd: string): string;
18
30
  export declare function getGitStatus(cwd: string): GitStatusEntry[];
19
31
  export declare function getGitDiff(cwd: string, opts?: {
package/dist/git/utils.js CHANGED
@@ -8,15 +8,15 @@
8
8
  * into the string form.
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
+ import { realpathSync } from "node:fs";
11
12
  import { parseGitLog } from "./parse-log.js";
12
- import { resolveExecutable } from "../utils/exec.js";
13
+ import { resolveExecutable, resolveGit } from "../utils/exec.js";
13
14
  // Resolve git/gh through PATH×PATHEXT on Windows so a .cmd/.exe shim is found
14
15
  // (bare execFile doesn't walk PATHEXT). No-op on POSIX. See utils/exec.ts.
15
- const GIT_BIN = resolveExecutable("git");
16
16
  const GH_BIN = resolveExecutable("gh");
17
17
  /** Run git with an argv array and return its trimmed stdout. */
18
18
  function git(cwd, args, timeoutMs = 10000) {
19
- return execFileSync(GIT_BIN, args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
19
+ return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
20
20
  }
21
21
  /** Run gh with an argv array and return its trimmed stdout. */
22
22
  function gh(cwd, args, timeoutMs = 10000) {
@@ -31,6 +31,33 @@ export function isGitRepo(cwd) {
31
31
  return false;
32
32
  }
33
33
  }
34
+ /**
35
+ * Resolve a directory to its PROJECT ROOT: the enclosing git repository's
36
+ * top-level dir if `cwd` is inside a git repo, otherwise `cwd` unchanged.
37
+ *
38
+ * This is the project-boundary rule the desktop uses when adding/identifying a
39
+ * project: picking a SUBDIRECTORY of a git repo should belong to that one repo
40
+ * (its root), not spawn a separate project per subdir — mirrors how editors
41
+ * (and Claude Code) treat a repo as one workspace. A non-git folder is its own
42
+ * project (returned as-is). Never throws; on any git failure falls back to cwd.
43
+ * Returns the git-reported toplevel (already absolute, forward-slashed on win).
44
+ */
45
+ export function resolveProjectRoot(cwd) {
46
+ let realCwd = cwd;
47
+ try {
48
+ realCwd = realpathSync(cwd);
49
+ }
50
+ catch {
51
+ // Non-existent paths are allowed to fall back unchanged below.
52
+ }
53
+ try {
54
+ const top = git(realCwd, ["rev-parse", "--show-toplevel"], 5000);
55
+ return top ? realpathSync(top) : realCwd;
56
+ }
57
+ catch {
58
+ return realCwd;
59
+ }
60
+ }
34
61
  export function getCurrentBranch(cwd) {
35
62
  return git(cwd, ["branch", "--show-current"], 5000);
36
63
  }
@@ -82,13 +109,13 @@ export function gitAdd(cwd, files = ["."]) {
82
109
  // `--` ensures a path starting with `-` cannot be parsed as a flag.
83
110
  // Each file is its own argv token, so spaces / quotes / non-ASCII pass
84
111
  // through verbatim with no shell parsing.
85
- execFileSync(GIT_BIN, ["add", "--", ...files], { cwd, timeout: 10000 });
112
+ execFileSync(resolveGit(), ["add", "--", ...files], { cwd, timeout: 10000 });
86
113
  }
87
114
  export function gitCommit(cwd, message) {
88
115
  // Pre-fix this used `JSON.stringify(message)` which only happened to be
89
116
  // safe because JSON.stringify covers most shell metacharacters — but it's
90
117
  // not real escaping. The argv form is.
91
- return execFileSync(GIT_BIN, ["commit", "-m", message], {
118
+ return execFileSync(resolveGit(), ["commit", "-m", message], {
92
119
  cwd,
93
120
  encoding: "utf-8",
94
121
  timeout: 30000,
@@ -115,7 +142,7 @@ export function gitCheckout(cwd, branch, create = false) {
115
142
  throw new Error(`refusing branch name that starts with '-': ${branch}`);
116
143
  }
117
144
  const args = create ? ["checkout", "-b", branch] : ["checkout", branch];
118
- execFileSync(GIT_BIN, args, { cwd, timeout: 10000 });
145
+ execFileSync(resolveGit(), args, { cwd, timeout: 10000 });
119
146
  }
120
147
  export function ghAvailable() {
121
148
  try {