@cjhyy/code-shell-core 0.7.0 → 0.7.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 (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -47,8 +47,10 @@ export interface GoalConfig {
47
47
  */
48
48
  setAtMs?: number;
49
49
  }
50
- /** A forced terminal outcome for one concrete persisted goal instance. */
51
- export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted";
50
+ /** A forced stop outcome for a Goal run. Some outcomes intentionally preserve the Goal. */
51
+ export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exhausted" | "time_budget_exhausted" | "max_turns_exhausted" | "judge_prompt_too_large";
52
+ /** Outcomes that permanently close one concrete persisted Goal instance. */
53
+ export type PersistedGoalTerminationReason = Exclude<GoalTerminationReason, "judge_prompt_too_large">;
52
54
  /**
53
55
  * Persisted terminal marker used to prevent a stale whole-state writer from
54
56
  * making an exhausted goal armable again. `objective + setAtMs` is the goal
@@ -57,7 +59,7 @@ export type GoalTerminationReason = "stop_blocks_exhausted" | "token_budget_exha
57
59
  export interface GoalTerminal {
58
60
  objective: string;
59
61
  setAtMs?: number;
60
- reason: GoalTerminationReason;
62
+ reason: PersistedGoalTerminationReason;
61
63
  terminatedAtMs?: number;
62
64
  }
63
65
  /** True only when both values identify the same concrete goal instance. */
@@ -5,9 +5,10 @@
5
5
  * resolves the aux client and wiring; this only does the LLM call + cleanup.
6
6
  */
7
7
  import type { LLMClientBase } from "../llm/client-base.js";
8
+ import type { TokenUsage } from "../types.js";
8
9
  /**
9
10
  * Ask the aux client for a one-line title from the first user message + first
10
11
  * assistant reply. Returns the cleaned title, or null on any failure / empty
11
12
  * output (caller treats null as "keep existing title").
12
13
  */
13
- export declare function buildSessionTitle(client: LLMClientBase, firstUserText: string, firstAssistantText: string): Promise<string | null>;
14
+ export declare function buildSessionTitle(client: LLMClientBase, firstUserText: string, firstAssistantText: string, recordBilledUsage?: (usage: TokenUsage) => void): Promise<string | null>;
@@ -13,7 +13,7 @@ function clean(raw) {
13
13
  * assistant reply. Returns the cleaned title, or null on any failure / empty
14
14
  * output (caller treats null as "keep existing title").
15
15
  */
16
- export async function buildSessionTitle(client, firstUserText, firstAssistantText) {
16
+ export async function buildSessionTitle(client, firstUserText, firstAssistantText, recordBilledUsage) {
17
17
  try {
18
18
  const prompt = `User: ${firstUserText.slice(0, 2000)}\n\n` +
19
19
  `Assistant: ${firstAssistantText.slice(0, 2000)}\n\n` +
@@ -23,8 +23,11 @@ export async function buildSessionTitle(client, firstUserText, firstAssistantTex
23
23
  messages: [{ role: "user", content: prompt }],
24
24
  tools: [],
25
25
  maxTokens: 64,
26
+ requestVisible: false,
26
27
  reasoning: { mode: "off" },
27
28
  });
29
+ if (resp.usage)
30
+ recordBilledUsage?.(resp.usage);
28
31
  const title = clean(resp.text ?? "");
29
32
  return title.length > 0 ? title : null;
30
33
  }
@@ -172,6 +172,10 @@ export declare class TurnLoop {
172
172
  private readonly sensitiveToolResultRedactions;
173
173
  private readonly pendingImageMessages;
174
174
  private readonly volatileContextMessages;
175
+ /** Last outer-loop turn for which the model returned a complete response. */
176
+ private lastCompletedModelTurn;
177
+ /** Last model turn whose transcript/persistence boundary was finalized. */
178
+ private lastFinalizedTurn;
175
179
  /**
176
180
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
177
181
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -204,6 +208,13 @@ export declare class TurnLoop {
204
208
  timeBudgetMs?: number;
205
209
  maxStopBlocks: number;
206
210
  };
211
+ /**
212
+ * Private Goal-judge accounting seam. Judge calls bypass ModelFacade, so the
213
+ * Engine forwards their provider usage here while the run-scoped tracker is
214
+ * live. The returned reason lets the hook suppress verdict side effects when
215
+ * this very request crossed a hard token/time budget.
216
+ */
217
+ recordGoalJudgeUsage(usage: TokenUsage | undefined): Extract<GoalTerminationReason, "token_budget_exhausted" | "time_budget_exhausted"> | undefined;
207
218
  /**
208
219
  * Goal mode only: if the run is nearing EITHER stop ceiling (maxTurns or
209
220
  * maxStopBlocks) and we haven't announced it yet, emit one approaching_limit
@@ -229,6 +240,13 @@ export declare class TurnLoop {
229
240
  * for one Stop still writes a single marker.
230
241
  */
231
242
  private markStopped;
243
+ /**
244
+ * Close one completed model turn exactly once. The transcript boundary must
245
+ * be durable before the Engine flushes usage, heartbeat, and turnCount via
246
+ * onTurnBoundary, so crash recovery never folds two assistant responses into
247
+ * the same transcript turn or observes state ahead of the transcript.
248
+ */
249
+ private finalizeModelTurn;
232
250
  private prepareMessagesForModel;
233
251
  private stripVolatileContextMessages;
234
252
  private appendVolatileContextMessages;
@@ -64,6 +64,10 @@ export class TurnLoop {
64
64
  sensitiveToolResultRedactions = new Map();
65
65
  pendingImageMessages = new Set();
66
66
  volatileContextMessages = new Set();
67
+ /** Last outer-loop turn for which the model returned a complete response. */
68
+ lastCompletedModelTurn = 0;
69
+ /** Last model turn whose transcript/persistence boundary was finalized. */
70
+ lastFinalizedTurn = 0;
67
71
  /**
68
72
  * Consecutive on_stop blocks (Goal mode kept the agent going). Reset to 0
69
73
  * on any unblocked completion. When it reaches config.maxStopBlocks the
@@ -128,6 +132,20 @@ export class TurnLoop {
128
132
  }
129
133
  return { ...next, maxStopBlocks: nextCap };
130
134
  }
135
+ /**
136
+ * Private Goal-judge accounting seam. Judge calls bypass ModelFacade, so the
137
+ * Engine forwards their provider usage here while the run-scoped tracker is
138
+ * live. The returned reason lets the hook suppress verdict side effects when
139
+ * this very request crossed a hard token/time budget.
140
+ */
141
+ recordGoalJudgeUsage(usage) {
142
+ if (!this.goalTracker)
143
+ return undefined;
144
+ if (usage) {
145
+ recordGoalUsage(this.goalTracker, (usage.promptTokens ?? 0) + (usage.completionTokens ?? 0));
146
+ }
147
+ return goalBudgetTerminationReason(this.goalTracker, Date.now());
148
+ }
131
149
  /**
132
150
  * Goal mode only: if the run is nearing EITHER stop ceiling (maxTurns or
133
151
  * maxStopBlocks) and we haven't announced it yet, emit one approaching_limit
@@ -217,6 +235,21 @@ export class TurnLoop {
217
235
  return;
218
236
  this.deps.transcript.appendTurnStopped();
219
237
  }
238
+ /**
239
+ * Close one completed model turn exactly once. The transcript boundary must
240
+ * be durable before the Engine flushes usage, heartbeat, and turnCount via
241
+ * onTurnBoundary, so crash recovery never folds two assistant responses into
242
+ * the same transcript turn or observes state ahead of the transcript.
243
+ */
244
+ finalizeModelTurn() {
245
+ if (this.lastCompletedModelTurn !== this.turnCount ||
246
+ this.lastFinalizedTurn === this.turnCount) {
247
+ return;
248
+ }
249
+ this.lastFinalizedTurn = this.turnCount;
250
+ this.deps.transcript.appendTurnBoundary();
251
+ this.config.onTurnBoundary?.(this.turnCount);
252
+ }
220
253
  prepareMessagesForModel(messages) {
221
254
  const preserveMessages = this.pendingImageMessages.size > 0 ? this.pendingImageMessages : undefined;
222
255
  const result = downgradeImagePayloadsInHistory(messages, { preserveMessages });
@@ -406,13 +439,16 @@ export class TurnLoop {
406
439
  // window only after irreversible sensitive-data removal, non-text omission,
407
440
  // and per-item truncation at the current-round ToolResult boundary.
408
441
  const goalToolResults = [];
442
+ const sensitiveGoalResultTools = this.config.goal
443
+ ? new Set(this.deps.tools.filter((tool) => tool.sensitiveResult).map((tool) => tool.name))
444
+ : undefined;
409
445
  // Goal-mode run-scoped budget tracker (P0). Null when no goal. Stamps a
410
446
  // wall-clock start now and accumulates prompt+completion tokens across
411
447
  // every turn; the guardrail below force-stops the run once any configured
412
448
  // budget is blown — the unattended-safety backstop.
413
- this.goalTracker = this.config.goal
414
- ? createGoalBudgetTracker(this.config.goal, Date.now())
415
- : null;
449
+ if (!this.goalTracker && this.config.goal) {
450
+ this.goalTracker = createGoalBudgetTracker(this.config.goal, Date.now());
451
+ }
416
452
  const goalTracker = this.goalTracker;
417
453
  // Fresh run: re-arm the approaching-limit announcement.
418
454
  this.approachAnnounced = false;
@@ -425,6 +461,25 @@ export class TurnLoop {
425
461
  // hook emits, guards) and surfaces them as a model_error result.
426
462
  try {
427
463
  while (this.turnCount < this.config.maxTurns) {
464
+ const preTurnGoalTermination = goalTracker
465
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
466
+ : undefined;
467
+ if (preTurnGoalTermination) {
468
+ this.config.onStream?.({
469
+ type: "assistant_message",
470
+ message: {
471
+ role: "assistant",
472
+ content: "(Goal 预算已耗尽,强制停止。)",
473
+ },
474
+ });
475
+ messages = this.redactConsumedSensitiveToolResults(messages);
476
+ return {
477
+ text: finalText,
478
+ reason: "goal_budget_exhausted",
479
+ messages,
480
+ goalTermination: preTurnGoalTermination,
481
+ };
482
+ }
428
483
  this.turnCount++;
429
484
  this.currentTurnUsage = {
430
485
  promptTokens: 0,
@@ -523,7 +578,7 @@ export class TurnLoop {
523
578
  }
524
579
  else {
525
580
  // Context management (async — may trigger LLM summarization)
526
- messages = await this.deps.contextManager.manageAsync(messages);
581
+ messages = await this.deps.contextManager.manageAsync(messages, this.config.signal);
527
582
  // manageAsync can itself issue an LLM summarization call lasting several
528
583
  // seconds; if the signal aborted during it, stop here rather than
529
584
  // proceeding into the (expensive) main model call. Belt to the loop-top
@@ -621,12 +676,22 @@ export class TurnLoop {
621
676
  return { text: finalText, reason: "model_error", messages };
622
677
  }
623
678
  }
679
+ this.lastCompletedModelTurn = this.turnCount;
624
680
  messages = this.redactConsumedSensitiveToolResults(messages);
625
681
  // Record the response once into current-turn and whole-session counters.
626
682
  if (response.usage?.promptTokens !== undefined) {
627
683
  this.recordResponseUsage(response.usage);
628
684
  this.emitCtxFromUsage(response.usage, messages);
629
685
  }
686
+ // Charge every provider response to the run-scoped Goal budget before
687
+ // any truncation path can retry or continue the outer turn loop.
688
+ if (goalTracker && response.usage) {
689
+ const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
690
+ recordGoalUsage(goalTracker, used);
691
+ }
692
+ let budgetTermination = goalTracker
693
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
694
+ : undefined;
630
695
  // Feed actual token usage back to the context manager so subsequent
631
696
  // compaction decisions use hybrid (actual + delta) estimation rather than
632
697
  // pure heuristics. Without this the manager falls back to char/4 estimates.
@@ -637,13 +702,24 @@ export class TurnLoop {
637
702
  this.deps.recordContextUsageAnchor?.(anchor);
638
703
  }
639
704
  messages = this.markPendingImagesConsumed(messages);
705
+ // stopBlockCount guards only the no-tool final-answer loop evaluated by
706
+ // on_stop (text -> block -> text -> block). A response that structurally
707
+ // contains tool_use has left that streak, regardless of whether the call
708
+ // will execute successfully. Reset here, before truncated-tool and later
709
+ // queued-steer paths can continue the outer loop. Repeated ineffective
710
+ // tool_use turns still consume turns and are bounded by maxTurns.
711
+ if (response.toolCalls.length > 0) {
712
+ this.stopBlockCount = 0;
713
+ }
640
714
  // Truncation that cut off a TOOL CALL: the model overflowed
641
715
  // max_output_tokens mid tool-call, so the arg JSON is incomplete (e.g. a
642
716
  // Write whose `content` was clipped, leaving file_path unset). Executing
643
717
  // it raised a misleading "Missing required parameter: file_path". Instead,
644
718
  // tell the model its output was truncated and let the next turn retry —
645
719
  // bounded by the outer maxTurns loop.
646
- if (isTruncatedStop(response.stopReason) && response.toolCalls.length > 0) {
720
+ if (!budgetTermination &&
721
+ isTruncatedStop(response.stopReason) &&
722
+ response.toolCalls.length > 0) {
647
723
  tlog.info("turn.truncated_tool_call", {
648
724
  cat: "turn",
649
725
  toolCount: response.toolCalls.length,
@@ -655,13 +731,15 @@ export class TurnLoop {
655
731
  role: "user",
656
732
  content: "<system-reminder>Your previous response was truncated by the max output token limit before the tool call finished, so its arguments are incomplete. Do not assume it ran. Either retry with a smaller/more focused tool call (e.g. write the file in sections via Edit), or raise this model's maxOutputTokens.</system-reminder>",
657
733
  });
734
+ this.finalizeModelTurn();
658
735
  continue;
659
736
  }
660
737
  // Handle max_output_tokens: if response was truncated, do continuation
661
738
  // (up to 3 times). Truncation is reported as finish_reason "length"
662
739
  // (OpenAI) or stop_reason "max_tokens" (Anthropic) — isTruncatedStop
663
740
  // accepts both, so the OpenAI streaming path triggers continuation too.
664
- if (isTruncatedStop(response.stopReason) &&
741
+ if (!budgetTermination &&
742
+ isTruncatedStop(response.stopReason) &&
665
743
  response.toolCalls.length === 0 &&
666
744
  response.text) {
667
745
  let combinedText = response.text;
@@ -672,6 +750,11 @@ export class TurnLoop {
672
750
  // more model calls, emitting text after Stop.
673
751
  if (this.config.signal?.aborted)
674
752
  break;
753
+ budgetTermination = goalTracker
754
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
755
+ : undefined;
756
+ if (budgetTermination)
757
+ break;
675
758
  tlog.info("turn.max_tokens_continuation", { cat: "turn", retry: retry + 1 });
676
759
  const contMessages = [
677
760
  ...messages,
@@ -683,18 +766,40 @@ export class TurnLoop {
683
766
  ];
684
767
  try {
685
768
  const contResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(contMessages), this.deps.tools, this.config.onStream, this.config.signal);
769
+ // Continuations are separate provider responses, so preserve the
770
+ // same structural tool_use invariant before processing this one.
771
+ if (contResponse.toolCalls.length > 0) {
772
+ this.stopBlockCount = 0;
773
+ }
686
774
  if (contResponse.usage?.promptTokens !== undefined) {
687
775
  this.recordResponseUsage(contResponse.usage);
688
776
  continuedResponse = true;
689
777
  }
778
+ if (goalTracker && contResponse.usage) {
779
+ const used = (contResponse.usage.promptTokens ?? 0) +
780
+ (contResponse.usage.completionTokens ?? 0);
781
+ recordGoalUsage(goalTracker, used);
782
+ }
783
+ budgetTermination = goalTracker
784
+ ? goalBudgetTerminationReason(goalTracker, Date.now())
785
+ : undefined;
690
786
  combinedText += contResponse.text;
691
- if (!isTruncatedStop(contResponse.stopReason) || contResponse.toolCalls.length > 0) {
787
+ if (budgetTermination ||
788
+ !isTruncatedStop(contResponse.stopReason) ||
789
+ contResponse.toolCalls.length > 0) {
692
790
  response = { ...contResponse, text: combinedText };
693
791
  break;
694
792
  }
695
793
  }
696
- catch {
697
- break;
794
+ catch (err) {
795
+ if (isAbortError(err) || this.config.signal?.aborted) {
796
+ this.markStopped();
797
+ this.finalizeModelTurn();
798
+ return { text: finalText, reason: "aborted_streaming", messages };
799
+ }
800
+ this.config.onStream?.({ type: "error", error: formatFriendlyError(err) });
801
+ this.finalizeModelTurn();
802
+ return { text: finalText, reason: "model_error", messages };
698
803
  }
699
804
  }
700
805
  response = { ...response, text: combinedText };
@@ -702,16 +807,10 @@ export class TurnLoop {
702
807
  this.emitCtxFromUsage(response.usage, messages);
703
808
  }
704
809
  }
705
- // Goal-mode run-scoped accounting: add this turn's total token usage
706
- // (prompt + completion) to the running total. Done after continuation so
707
- // continued output is counted against the budget.
708
- if (goalTracker && response.usage) {
709
- const used = (response.usage.promptTokens ?? 0) + (response.usage.completionTokens ?? 0);
710
- recordGoalUsage(goalTracker, used);
711
- }
712
810
  // Aborted?
713
811
  if (this.config.signal?.aborted) {
714
812
  this.markStopped();
813
+ this.finalizeModelTurn();
715
814
  return { text: finalText, reason: "aborted_streaming", messages };
716
815
  }
717
816
  // Accumulate text
@@ -722,8 +821,8 @@ export class TurnLoop {
722
821
  // is force-stopped regardless of what the model wants to do next (stop OR
723
822
  // continue with tool calls). This is the unattended-safety backstop, so
724
823
  // it sits BEFORE the "tool calls?" branch — both paths pass the gate.
725
- const budgetTermination = goalTracker
726
- ? goalBudgetTerminationReason(goalTracker, Date.now())
824
+ budgetTermination = goalTracker
825
+ ? (budgetTermination ?? goalBudgetTerminationReason(goalTracker, Date.now()))
727
826
  : undefined;
728
827
  if (goalTracker && budgetTermination) {
729
828
  tlog.info("turn.goal_budget_exhausted", {
@@ -740,6 +839,7 @@ export class TurnLoop {
740
839
  content: "(Goal 预算已耗尽,强制停止。)",
741
840
  },
742
841
  });
842
+ this.finalizeModelTurn();
743
843
  return {
744
844
  text: finalText,
745
845
  reason: "goal_budget_exhausted",
@@ -760,6 +860,7 @@ export class TurnLoop {
760
860
  hasToolUse: false,
761
861
  });
762
862
  messages.push({ role: "assistant", content: finalText });
863
+ this.finalizeModelTurn();
763
864
  if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
764
865
  continue;
765
866
  }
@@ -791,6 +892,57 @@ export class TurnLoop {
791
892
  finalText,
792
893
  turnCount: this.turnCount,
793
894
  });
895
+ // The primary-model judge is a real billed request. Its hook forwards
896
+ // usage through recordGoalJudgeUsage above; re-check immediately so an
897
+ // over-budget verdict cannot authorize another main-model turn.
898
+ const hookGoalTermination = stopHook.goalTermination;
899
+ const hookBudgetTermination = hookGoalTermination === "token_budget_exhausted" ||
900
+ hookGoalTermination === "time_budget_exhausted"
901
+ ? hookGoalTermination
902
+ : undefined;
903
+ const postJudgeBudgetTermination = goalTracker
904
+ ? (hookBudgetTermination ?? goalBudgetTerminationReason(goalTracker, Date.now()))
905
+ : undefined;
906
+ if (goalTracker && postJudgeBudgetTermination) {
907
+ tlog.info("turn.goal_budget_exhausted_after_judge", {
908
+ cat: "goal",
909
+ reason: postJudgeBudgetTermination,
910
+ tokensUsed: goalTracker.tokensUsed,
911
+ tokenBudget: goalTracker.goal.tokenBudget,
912
+ timeBudgetMs: goalTracker.goal.timeBudgetMs,
913
+ });
914
+ this.config.onStream?.({
915
+ type: "assistant_message",
916
+ messageId: assistantMessageId,
917
+ message: {
918
+ role: "assistant",
919
+ content: "(Goal 预算已耗尽,强制停止。)",
920
+ },
921
+ });
922
+ return {
923
+ text: finalText,
924
+ reason: "goal_budget_exhausted",
925
+ messages,
926
+ goalTermination: postJudgeBudgetTermination,
927
+ };
928
+ }
929
+ if (goalTracker && hookGoalTermination === "judge_prompt_too_large") {
930
+ tlog.warn("turn.goal_judge_prompt_too_large", { cat: "goal" });
931
+ this.config.onStream?.({
932
+ type: "assistant_message",
933
+ messageId: assistantMessageId,
934
+ message: {
935
+ role: "assistant",
936
+ content: "(Goal 裁判输入超过安全上限,无法判定;本次已停止,目标已保留。)",
937
+ },
938
+ });
939
+ return {
940
+ text: finalText,
941
+ reason: "completed",
942
+ messages,
943
+ goalTermination: hookGoalTermination,
944
+ };
945
+ }
794
946
  // The judge's structured verdict (set by GoalStopHook in result.data)
795
947
  // rides back here so we can show goal progress WITHOUT a second LLM
796
948
  // call — `gaps` is whatever the judge already computed.
@@ -912,11 +1064,14 @@ export class TurnLoop {
912
1064
  const resultBlocks = [];
913
1065
  for (const result of results) {
914
1066
  if (goalTracker) {
915
- goalToolResults.push(projectGoalJudgeToolResult(result, this.turnCount));
916
- // The prompt renderer keeps at most 12, but retaining a few extra
917
- // bounded projections lets its total-char budget choose the newest useful subset.
918
- if (goalToolResults.length > 20)
919
- goalToolResults.splice(0, goalToolResults.length - 20);
1067
+ goalToolResults.push(projectGoalJudgeToolResult(result, this.turnCount, sensitiveGoalResultTools?.has(result.toolName) === true));
1068
+ // Never evict the front of one legal batch: the default executor may
1069
+ // return 25 results in a turn. Keep at least maxToolCallsPerTurn so
1070
+ // the judge renderer can preserve metadata for the entire newest batch.
1071
+ const residencyLimit = Math.max(20, this.config.maxToolCallsPerTurn);
1072
+ if (goalToolResults.length > residencyLimit) {
1073
+ goalToolResults.splice(0, goalToolResults.length - residencyLimit);
1074
+ }
920
1075
  }
921
1076
  resultBlocks.push(toolResultToBlock(result));
922
1077
  const streamResult = toolResultForDisplay(result);
@@ -994,6 +1149,7 @@ export class TurnLoop {
994
1149
  tlog.info("turn.goal_self_reported_complete", { cat: "goal" });
995
1150
  this.stopBlockCount = 0;
996
1151
  this.deps.clearPersistedGoal?.();
1152
+ this.finalizeModelTurn();
997
1153
  if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
998
1154
  continue;
999
1155
  }
@@ -1010,6 +1166,7 @@ export class TurnLoop {
1010
1166
  tlog.info("turn.goal_user_cancelled", { cat: "goal" });
1011
1167
  this.stopBlockCount = 0;
1012
1168
  this.deps.clearPersistedGoal?.();
1169
+ this.finalizeModelTurn();
1013
1170
  if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1014
1171
  continue;
1015
1172
  }
@@ -1030,6 +1187,7 @@ export class TurnLoop {
1030
1187
  message: { role: "assistant", content: finalText },
1031
1188
  });
1032
1189
  messages.push({ role: "assistant", content: finalText });
1190
+ this.finalizeModelTurn();
1033
1191
  if (await this.consumeQueuedSteer(messages, "finalize_backfill")) {
1034
1192
  continue;
1035
1193
  }
@@ -1072,9 +1230,7 @@ export class TurnLoop {
1072
1230
  hasToolUse: true,
1073
1231
  toolCallCount: toolCalls.length,
1074
1232
  });
1075
- // Record turn boundary
1076
- this.deps.transcript.appendTurnBoundary();
1077
- this.config.onTurnBoundary?.(this.turnCount);
1233
+ this.finalizeModelTurn();
1078
1234
  tlog.info("turn.end", {
1079
1235
  cat: "turn",
1080
1236
  duration_ms: Date.now() - turnStartedAt,
@@ -1089,6 +1245,9 @@ export class TurnLoop {
1089
1245
  // engine's post-run saveState records model_error instead of leaving
1090
1246
  // the session stuck at "active".
1091
1247
  this.patchOrphanedToolUses(messages);
1248
+ // If the model already returned, this is still a completed model turn.
1249
+ // The idempotent helper is a no-op when its normal branch finalized first.
1250
+ this.finalizeModelTurn();
1092
1251
  // A user-initiated Stop also bubbles here (an AbortError thrown from the
1093
1252
  // per-turn scaffolding). Treat it as a clean abort, not a model_error —
1094
1253
  // no error event, so the UI shows only the "你停止了本轮" line.
@@ -1133,12 +1292,28 @@ export class TurnLoop {
1133
1292
  try {
1134
1293
  const summaryResponse = await this.deps.model.call(this.deps.systemPrompt, this.prepareMessagesForModel(messages), [], // No tools available for summary turn
1135
1294
  this.config.onStream, this.config.signal, this.modelCallRecordingOptions());
1295
+ if (summaryResponse.usage?.promptTokens !== undefined) {
1296
+ this.recordResponseUsage(summaryResponse.usage);
1297
+ }
1298
+ if (goalTracker && summaryResponse.usage) {
1299
+ recordGoalUsage(goalTracker, (summaryResponse.usage.promptTokens ?? 0) + (summaryResponse.usage.completionTokens ?? 0));
1300
+ }
1301
+ if (this.config.signal?.aborted) {
1302
+ this.markStopped();
1303
+ messages = this.redactConsumedSensitiveToolResults(messages);
1304
+ return { text: finalText, reason: "aborted_streaming", messages };
1305
+ }
1136
1306
  messages = this.markPendingImagesConsumed(messages);
1137
1307
  if (summaryResponse.text) {
1138
1308
  finalText = summaryResponse.text;
1139
1309
  }
1140
1310
  }
1141
- catch {
1311
+ catch (err) {
1312
+ if (isAbortError(err) || this.config.signal?.aborted) {
1313
+ this.markStopped();
1314
+ messages = this.redactConsumedSensitiveToolResults(messages);
1315
+ return { text: finalText, reason: "aborted_streaming", messages };
1316
+ }
1142
1317
  // If even summary fails, just return what we have
1143
1318
  this.currentTurnLog.warn("turn.summary_failed", { cat: "turn" });
1144
1319
  }
@@ -12,8 +12,8 @@
12
12
  */
13
13
  import type { ClientDefaults, LLMConfig, SessionOrigin, TerminalReason, TokenUsage } from "../types.js";
14
14
  import type { AgentPresetName } from "../preset/index.js";
15
- import type { GoalConfig } from "./goal.js";
16
- import type { ApprovalBackend } from "../tool-system/permission.js";
15
+ import type { GoalConfig, GoalTerminationReason } from "./goal.js";
16
+ import type { ApprovalBackend, ApprovalRouter } from "../tool-system/permission.js";
17
17
  import type { SandboxConfig } from "../tool-system/sandbox/index.js";
18
18
  import type { CostStateStore } from "./cost-store.js";
19
19
  import type { AskUserFn } from "../tool-system/builtin/ask-user.js";
@@ -70,6 +70,8 @@ export interface EngineConfig {
70
70
  sessionStorageDir?: string;
71
71
  maxContextTokens?: number;
72
72
  approvalBackend?: ApprovalBackend;
73
+ /** Connection-scoped permission approval router supplied by an interactive host. */
74
+ approvalRouter?: ApprovalRouter;
73
75
  hooks?: EngineHookConfig[];
74
76
  askUser?: AskUserFn;
75
77
  /** Browser automation bridge (browser_* tools). Wired by the host (desktop)
@@ -177,6 +179,8 @@ export interface EngineHookConfig {
177
179
  export interface EngineResult {
178
180
  text: string;
179
181
  reason: TerminalReason;
182
+ /** Goal-specific stop outcome; omitted for ordinary completion/met verdicts. */
183
+ goalTermination?: GoalTerminationReason;
180
184
  sessionId: string;
181
185
  turnCount: number;
182
186
  usage: TokenUsage;
@@ -17,7 +17,10 @@ export interface PlatformScripts {
17
17
  }
18
18
  export interface CreateWorktreeOptions {
19
19
  prefix?: string;
20
+ signal?: AbortSignal;
20
21
  }
22
+ /** Best-effort rollback for an aborted `git worktree add`. */
23
+ export declare function cleanupAbortedWorktree(gitRoot: string, worktreePath: string, branchName: string): Promise<void>;
21
24
  /**
22
25
  * Pick the setup/cleanup script for the running platform, falling back to
23
26
  * `default`. Empty/whitespace-only scripts are treated as absent so a project
@@ -1,10 +1,25 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
2
+ import { rm } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { safeSpawnShell } from "../../runtime/safe-spawn.js";
4
5
  import { buildSandboxEnv, defaultShellBinary, mergeShellEnv } from "../../runtime/spawn-common.js";
5
6
  import { execGit, execGitSync, gitErrorMessage } from "./git-exec.js";
6
7
  import { assertBranchNotCheckedOut, currentBranch, findGitRoot } from "./query.js";
7
8
  import { applyPrefix, isManagedWorktreeBranch, validateWorktreeSlug } from "./slug.js";
9
+ function throwIfAborted(signal) {
10
+ signal?.throwIfAborted();
11
+ }
12
+ /** Best-effort rollback for an aborted `git worktree add`. */
13
+ export async function cleanupAbortedWorktree(gitRoot, worktreePath, branchName) {
14
+ try {
15
+ await execGit(gitRoot, ["worktree", "remove", worktreePath, "--force"], 30_000);
16
+ }
17
+ catch {
18
+ await rm(worktreePath, { recursive: true, force: true }).catch(() => { });
19
+ await execGit(gitRoot, ["worktree", "prune"], 10_000).catch(() => { });
20
+ }
21
+ await execGit(gitRoot, ["branch", "-D", branchName], 10_000).catch(() => { });
22
+ }
8
23
  /**
9
24
  * Pick the setup/cleanup script for the running platform, falling back to
10
25
  * `default`. Empty/whitespace-only scripts are treated as absent so a project
@@ -27,16 +42,30 @@ export function selectPlatformScript(scripts, platform = process.platform) {
27
42
  */
28
43
  export async function createWorktree(cwd, slug, sessionId, opts = {}) {
29
44
  validateWorktreeSlug(slug);
45
+ throwIfAborted(opts.signal);
30
46
  const gitRoot = await findGitRoot(cwd);
47
+ throwIfAborted(opts.signal);
31
48
  const branchName = applyPrefix(opts.prefix, slug, sessionId);
32
49
  const worktreePath = resolve(gitRoot, "..", `.worktrees/${slug}-${sessionId.slice(0, 8)}`);
33
50
  await assertBranchNotCheckedOut(gitRoot, branchName);
51
+ throwIfAborted(opts.signal);
34
52
  const originalBranch = await currentBranch(gitRoot);
53
+ throwIfAborted(opts.signal);
35
54
  // The argv form keeps branchName/worktreePath as literal positional
36
55
  // arguments even if a future caller bypasses validateWorktreeSlug.
37
- await execGit(gitRoot, ["worktree", "add", "-b", branchName, worktreePath], 30000);
38
- // Symlink large directories to avoid disk bloat.
39
- symlinkLargeDirectories(gitRoot, worktreePath);
56
+ try {
57
+ await execGit(gitRoot, ["worktree", "add", "-b", branchName, worktreePath], 30_000, opts.signal);
58
+ throwIfAborted(opts.signal);
59
+ // Symlink large directories to avoid disk bloat.
60
+ symlinkLargeDirectories(gitRoot, worktreePath);
61
+ throwIfAborted(opts.signal);
62
+ }
63
+ catch (error) {
64
+ if (opts.signal?.aborted) {
65
+ await cleanupAbortedWorktree(gitRoot, worktreePath, branchName);
66
+ }
67
+ throw error;
68
+ }
40
69
  return {
41
70
  originalCwd: cwd,
42
71
  worktreePath,
@@ -1,7 +1,7 @@
1
1
  export declare const GIT_BIN: string;
2
- export declare function execGit(cwd: string, args: string[], timeout?: number): Promise<string>;
2
+ export declare function execGit(cwd: string, args: string[], timeout?: number, signal?: AbortSignal): Promise<string>;
3
3
  export declare function execGitSync(cwd: string, args: string[], timeout?: number): string;
4
- export declare function gitOutput(cwd: string, args: string[], timeout?: number): Promise<string | undefined>;
4
+ export declare function gitOutput(cwd: string, args: string[], timeout?: number, signal?: AbortSignal): Promise<string | undefined>;
5
5
  export declare function gitOutputSync(cwd: string, args: string[], timeout?: number): string | undefined;
6
6
  export declare function gitErrorMessage(err: unknown): string;
7
7
  export declare function normalizeBranchName(branch: string): string;