@cjhyy/code-shell-core 0.7.0-beta.1 → 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 (146) 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/cc-orchestrator/agent-adapter.d.ts +2 -0
  19. package/dist/cc-orchestrator/agent-adapter.js +4 -0
  20. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  21. package/dist/cc-orchestrator/codex-session-history.js +64 -4
  22. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  23. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -1
  24. package/dist/cc-orchestrator/external-agent-driver.js +202 -38
  25. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  26. package/dist/cc-orchestrator/session-history.js +96 -13
  27. package/dist/cli/agent-server-tcp.js +13 -2
  28. package/dist/context/manager.d.ts +3 -3
  29. package/dist/context/manager.js +6 -6
  30. package/dist/context/token-counter.js +5 -3
  31. package/dist/context/tool-result-storage.d.ts +6 -0
  32. package/dist/context/tool-result-storage.js +25 -4
  33. package/dist/credentials/access.d.ts +11 -1
  34. package/dist/credentials/access.js +77 -1
  35. package/dist/credentials/index.d.ts +3 -1
  36. package/dist/credentials/index.js +2 -0
  37. package/dist/credentials/oauth.d.ts +25 -0
  38. package/dist/credentials/oauth.js +179 -0
  39. package/dist/credentials/store.d.ts +2 -1
  40. package/dist/credentials/store.js +19 -8
  41. package/dist/credentials/types.d.ts +84 -1
  42. package/dist/credentials/types.js +16 -1
  43. package/dist/engine/engine.d.ts +67 -34
  44. package/dist/engine/engine.js +448 -247
  45. package/dist/engine/goal.d.ts +19 -0
  46. package/dist/engine/goal.js +16 -6
  47. package/dist/engine/input-attachments.js +156 -13
  48. package/dist/engine/run-image-input.d.ts +22 -0
  49. package/dist/engine/run-image-input.js +195 -0
  50. package/dist/engine/session-title.d.ts +2 -1
  51. package/dist/engine/session-title.js +4 -1
  52. package/dist/engine/steer-queue.d.ts +3 -1
  53. package/dist/engine/steer-queue.js +10 -2
  54. package/dist/engine/turn-loop.d.ts +48 -1
  55. package/dist/engine/turn-loop.js +307 -37
  56. package/dist/engine/types.d.ts +6 -2
  57. package/dist/git/worktree/crud.d.ts +3 -0
  58. package/dist/git/worktree/crud.js +32 -3
  59. package/dist/git/worktree/git-exec.d.ts +2 -2
  60. package/dist/git/worktree/git-exec.js +47 -11
  61. package/dist/git/worktree/query.d.ts +8 -7
  62. package/dist/git/worktree/query.js +27 -20
  63. package/dist/hooks/events.d.ts +3 -0
  64. package/dist/hooks/events.js +0 -3
  65. package/dist/hooks/goal-stop-hook.d.ts +44 -2
  66. package/dist/hooks/goal-stop-hook.js +775 -52
  67. package/dist/hooks/registry.js +3 -0
  68. package/dist/hooks/shell-runner.d.ts +12 -1
  69. package/dist/hooks/shell-runner.js +160 -9
  70. package/dist/index.d.ts +7 -6
  71. package/dist/index.js +6 -5
  72. package/dist/llm/client-base.js +12 -10
  73. package/dist/llm/types.d.ts +12 -5
  74. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  75. package/dist/plugins/pluginCommandHook.js +111 -13
  76. package/dist/preset/index.js +14 -4
  77. package/dist/protocol/chat-session-manager.d.ts +13 -2
  78. package/dist/protocol/chat-session-manager.js +90 -18
  79. package/dist/protocol/chat-session.d.ts +12 -0
  80. package/dist/protocol/chat-session.js +30 -5
  81. package/dist/protocol/client.d.ts +5 -2
  82. package/dist/protocol/client.js +22 -1
  83. package/dist/protocol/server.d.ts +25 -11
  84. package/dist/protocol/server.js +291 -73
  85. package/dist/protocol/types.d.ts +36 -2
  86. package/dist/protocol/types.js +2 -0
  87. package/dist/services/dream-consolidation.d.ts +3 -0
  88. package/dist/services/dream-consolidation.js +4 -1
  89. package/dist/services/index.d.ts +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/oauth.d.ts +34 -10
  92. package/dist/services/oauth.js +233 -98
  93. package/dist/session/session-manager.d.ts +35 -6
  94. package/dist/session/session-manager.js +396 -27
  95. package/dist/session/transcript.d.ts +30 -1
  96. package/dist/session/transcript.js +119 -4
  97. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  98. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  99. package/dist/tool-system/builtin/agent.js +5 -1
  100. package/dist/tool-system/builtin/arena.js +1 -0
  101. package/dist/tool-system/builtin/background-jobs.d.ts +28 -5
  102. package/dist/tool-system/builtin/background-jobs.js +109 -7
  103. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  104. package/dist/tool-system/builtin/background-work.js +5 -1
  105. package/dist/tool-system/builtin/bash.d.ts +3 -5
  106. package/dist/tool-system/builtin/bash.js +10 -5
  107. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  108. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  109. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  110. package/dist/tool-system/builtin/cron.d.ts +1 -2
  111. package/dist/tool-system/builtin/cron.js +9 -7
  112. package/dist/tool-system/builtin/drive-claude-code.d.ts +7 -0
  113. package/dist/tool-system/builtin/drive-claude-code.js +307 -20
  114. package/dist/tool-system/builtin/edit.d.ts +2 -1
  115. package/dist/tool-system/builtin/edit.js +12 -4
  116. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  117. package/dist/tool-system/builtin/generate-video.js +138 -21
  118. package/dist/tool-system/builtin/glob.d.ts +2 -1
  119. package/dist/tool-system/builtin/glob.js +28 -3
  120. package/dist/tool-system/builtin/grep.d.ts +1 -0
  121. package/dist/tool-system/builtin/grep.js +82 -17
  122. package/dist/tool-system/builtin/index.d.ts +25 -11
  123. package/dist/tool-system/builtin/index.js +60 -5
  124. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  125. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  126. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  127. package/dist/tool-system/builtin/sleep.js +1 -22
  128. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  129. package/dist/tool-system/builtin/video-providers.js +1 -0
  130. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  131. package/dist/tool-system/builtin/web-fetch.js +44 -3
  132. package/dist/tool-system/builtin/worktree.js +25 -7
  133. package/dist/tool-system/builtin/write.d.ts +2 -1
  134. package/dist/tool-system/builtin/write.js +14 -4
  135. package/dist/tool-system/context.d.ts +35 -5
  136. package/dist/tool-system/executor.js +24 -8
  137. package/dist/tool-system/mcp-manager.d.ts +20 -2
  138. package/dist/tool-system/mcp-manager.js +111 -12
  139. package/dist/tool-system/path-policy.d.ts +19 -0
  140. package/dist/tool-system/path-policy.js +62 -1
  141. package/dist/tool-system/permission.d.ts +43 -3
  142. package/dist/tool-system/permission.js +383 -30
  143. package/dist/tool-system/registry.d.ts +3 -2
  144. package/dist/tool-system/registry.js +52 -34
  145. package/dist/types.d.ts +38 -7
  146. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
11
11
  import { getMergedCatalog } from "../model-catalog/index.js";
12
12
  import { modelEntriesFromConnections } from "./model-connections-pool.js";
13
13
  import { resolveAuxKey } from "./aux-key.js";
14
- import { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
14
+ import { addTokenUsage, addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
15
15
  import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
16
16
  import { resolveSandboxConfig } from "./sandbox-config.js";
17
17
  import { sandboxCacheKey } from "./sandbox-cache-key.js";
@@ -23,7 +23,7 @@ import { PermissionClassifier, HeadlessApprovalBackend, AutoApprovalBackend, Int
23
23
  import { HookRegistry } from "../hooks/registry.js";
24
24
  import { wrapHookMessages } from "../hooks/inject.js";
25
25
  import { createGoalStopHook } from "../hooks/goal-stop-hook.js";
26
- import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, } from "./goal.js";
26
+ import { normalizeGoal, resolveGoalSetAt, resolveMaxTurns, resolveMaxStopBlocks, isSameGoalInstance, } from "./goal.js";
27
27
  import { loadPluginHooks } from "../plugins/loadPluginHooks.js";
28
28
  import { pluginAgentDirs } from "../plugins/installer/loadPluginAgents.js";
29
29
  import { patchOrphanedToolUses } from "./patch-orphaned-tools.js";
@@ -32,7 +32,7 @@ import { ContextManager } from "../context/manager.js";
32
32
  import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
33
33
  import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
34
34
  import { PromptComposer } from "../prompt/composer.js";
35
- import { SessionManager } from "../session/session-manager.js";
35
+ import { SessionManager, } from "../session/session-manager.js";
36
36
  import { ModelFacade } from "./model-facade.js";
37
37
  import { logger, runWithSid, getCurrentSid } from "../logging/logger.js";
38
38
  import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
@@ -53,16 +53,17 @@ import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
53
53
  import { defaultCacheDir } from "../llm/model-cache.js";
54
54
  import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
55
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
56
- import { parseTaskWithImages } from "./parse-task.js";
57
- import { buildInputAttachmentContext } from "./input-attachments.js";
58
- import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
59
- import { tryCompressImages } from "./image-compression.js";
56
+ import { formatFriendlyError } from "./friendly-error.js";
60
57
  import { buildSessionTitle } from "./session-title.js";
61
- import { capabilitiesFor } from "../llm/capabilities/index.js";
62
58
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
63
59
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
64
- import { join, isAbsolute } from "node:path";
60
+ import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
61
+ import { join } from "node:path";
65
62
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
63
+ const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
64
+ const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
65
+ const CACHE_READ_DROP_RATIO = 0.1;
66
+ const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
66
67
  /**
67
68
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
68
69
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -229,6 +230,8 @@ export class Engine {
229
230
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
230
231
  runtime;
231
232
  sandboxCache = new Map();
233
+ interactiveBackends = new WeakMap();
234
+ activeApprovalRouter;
232
235
  /** Active permission mode for this Engine instance. */
233
236
  permissionMode;
234
237
  /** True when permissionMode === "plan". */
@@ -245,7 +248,7 @@ export class Engine {
245
248
  */
246
249
  auxClientCache;
247
250
  // Live state from the current/most-recent run, retained for /compact and
248
- // for live-mutating PermissionClassifier on permission-mode switch.
251
+ // run-boundary PermissionClassifier replacement/reconfiguration.
249
252
  lastContextManager;
250
253
  lastMessages;
251
254
  lastSessionId;
@@ -266,6 +269,7 @@ export class Engine {
266
269
  * LLM response arrives.
267
270
  */
268
271
  ctxOverheadBySid = new Map();
272
+ lastCacheReadBySid = new Map();
269
273
  /**
270
274
  * Step-gap steering queue (per sessionId, in-memory). Host pushes user
271
275
  * messages here via enqueueSteer while a run is in flight; the turn loop
@@ -299,6 +303,19 @@ export class Engine {
299
303
  * Null when idle; set at run start, cleared in run's finally.
300
304
  */
301
305
  activeRunSession = null;
306
+ /**
307
+ * Same-instance run guard. Engine owns single-valued live controls and one
308
+ * HookRegistry, so a second run must not enter until the first has completed
309
+ * all state persistence and end hooks. This prevents handle contamination and
310
+ * whole-state saveState overlap only within this Engine instance. It does not
311
+ * coordinate different Engine instances sharing a sessionId, Workers, or
312
+ * processes; session-level locking/CAS for those cases is a separate finding.
313
+ */
314
+ runInProgress = false;
315
+ /** Permission update requested while runInProgress. Applied in run() finally. */
316
+ pendingPermissionMode = null;
317
+ /** Plan update paired with pendingPermissionMode for one atomic boundary apply. */
318
+ pendingPlanMode = null;
302
319
  /** Public accessor so UI/clients can read the resolved per-model window. */
303
320
  get maxContextTokens() {
304
321
  return this.resolveMaxContextTokens();
@@ -334,10 +351,11 @@ export class Engine {
334
351
  * emits should go through this wrapper to keep the context envelope
335
352
  * uniform with TurnLoop.emitHook.
336
353
  */
337
- async emitHook(event, data = {}) {
354
+ async emitHook(event, data = {}, signal) {
338
355
  return this.hooks.emit(event, {
339
356
  ...data,
340
357
  isSubAgent: this.config.isSubAgent === true,
358
+ signal,
341
359
  });
342
360
  }
343
361
  /**
@@ -654,7 +672,7 @@ export class Engine {
654
672
  * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
655
673
  * entry. A blank id is tolerated but means the entry can't be revoked.
656
674
  */
657
- enqueueSteer(sessionId, text, id = "", clientMessageId) {
675
+ enqueueSteer(sessionId, text, id = "", clientMessageId, attachments) {
658
676
  const q = this.steerQueueBySid.get(sessionId) ?? [];
659
677
  const entryId = id || `steer-${q.length}`;
660
678
  if (!sessionId)
@@ -666,12 +684,13 @@ export class Engine {
666
684
  sessionId,
667
685
  id: entryId,
668
686
  clientMessageId,
687
+ attachmentCount: attachments?.length ?? 0,
669
688
  activeRunSessionId: activeRunSessionId ?? null,
670
689
  queueLength: q.length,
671
690
  });
672
691
  return { accepted: false, id: entryId };
673
692
  }
674
- const next = enqueueSteerItem(q, entryId, text, clientMessageId);
693
+ const next = enqueueSteerItem(q, entryId, text, clientMessageId, attachments);
675
694
  if (next === q)
676
695
  return { accepted: false, id: entryId }; // blank text dropped
677
696
  this.steerQueueBySid.set(sessionId, next);
@@ -679,6 +698,7 @@ export class Engine {
679
698
  sessionId,
680
699
  id: entryId,
681
700
  clientMessageId,
701
+ attachmentCount: attachments?.length ?? 0,
682
702
  activeRunSessionId,
683
703
  queueLength: next.length,
684
704
  });
@@ -715,6 +735,13 @@ export class Engine {
715
735
  });
716
736
  return drained;
717
737
  }
738
+ /** Put failed steer preparation back ahead of messages queued while it was being prepared. */
739
+ restoreSteer(sessionId, items) {
740
+ if (items.length === 0)
741
+ return;
742
+ const queued = this.steerQueueBySid.get(sessionId) ?? [];
743
+ this.steerQueueBySid.set(sessionId, [...items, ...queued]);
744
+ }
718
745
  /** Wire the cookie→browser injection callback (InjectCredential tool). Same
719
746
  * post-construction injection model as setBrowserBridge. */
720
747
  setInjectCredential(fn) {
@@ -737,10 +764,43 @@ export class Engine {
737
764
  sessionExistsOnDisk(sessionId) {
738
765
  return this.sessionManager.exists(sessionId);
739
766
  }
767
+ forkSession(sourceSessionId, options) {
768
+ return this.sessionManager.fork(sourceSessionId, options);
769
+ }
740
770
  /**
741
- * Run a task from start to finish.
771
+ * Run a task from start to finish. Rejects immediately when this Engine
772
+ * instance already has a run in progress; hosts that want queueing own that
773
+ * policy (for example ChatSession's FIFO queue).
742
774
  */
743
775
  async run(task, options) {
776
+ if (this.runInProgress) {
777
+ throw new Error("Engine.run() cannot start while another run is in progress");
778
+ }
779
+ this.runInProgress = true;
780
+ try {
781
+ return await this.runExclusive(task, options);
782
+ }
783
+ finally {
784
+ try {
785
+ this.applyPendingPermissionState();
786
+ }
787
+ finally {
788
+ this.runInProgress = false;
789
+ }
790
+ }
791
+ }
792
+ async runExclusive(task, options) {
793
+ // Freeze permission context once, before the first await. Per-turn protocol
794
+ // overrides live only for this run; persistent setPermissionMode/setPlanMode
795
+ // calls made while busy are staged separately and cannot mutate this pair.
796
+ let runPermissionMode = options?.permissionMode ?? this.config.permissionMode ?? "acceptEdits";
797
+ if (options?.planMode === true) {
798
+ runPermissionMode = "plan";
799
+ }
800
+ else if (options?.planMode === false && runPermissionMode === "plan") {
801
+ runPermissionMode = "acceptEdits";
802
+ }
803
+ const runPlanMode = runPermissionMode === "plan";
744
804
  const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
745
805
  ? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
746
806
  : undefined;
@@ -807,139 +867,16 @@ export class Engine {
807
867
  };
808
868
  if (options)
809
869
  options.onStream = wrappedOnStream;
810
- // ── P2-6: image input ─────────────────────────────────────────────
811
- // Parse `<codeshell-image>` blocks out of the raw task string before
812
- // any other gate looks at it. Two concerns:
813
- // 1. The noise detector below sees the raw base64 as gibberish and
814
- // would reject the whole turn — split images out first so it
815
- // only inspects the prose portion.
816
- // 2. Models that don't accept vision must be refused immediately,
817
- // with the image bytes intact for the user to retry on another
818
- // model. Silent text-only fallback was the failure mode this
819
- // gate is here to prevent.
820
- let parsedTask;
821
- try {
822
- parsedTask = parseTaskWithImages(task);
823
- }
824
- catch (err) {
825
- const msg = err.message;
826
- logger.warn("engine.run.image_parse_failed", { error: msg });
827
- return {
828
- text: `ERROR: image attachment is malformed (${msg}). Drop the image and try again, or re-attach it.`,
829
- reason: "image_error",
830
- sessionId: options?.sessionId ?? "image-parse-failed",
831
- turnCount: 0,
832
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
833
- };
834
- }
835
- const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
836
- const attachmentContext = await buildInputAttachmentContext(options?.attachments, cwd, {
837
- includeImageBytes: cap.supportsVision,
838
- expectedSessionId: options?.sessionId,
870
+ const imageInput = await prepareRunImageInput({
871
+ task,
872
+ cwd,
873
+ llm: this.config.llm,
874
+ sessionId: options?.sessionId,
875
+ attachments: options?.attachments,
839
876
  });
840
- if (attachmentContext.errors.length > 0) {
841
- const detail = attachmentContext.errors.join("; ");
842
- logger.warn("engine.run.input_attachment_failed", { error: detail });
843
- return {
844
- text: `ERROR: input attachment could not be read (${detail}). Re-attach it or choose a path inside the workspace.`,
845
- reason: "image_error",
846
- sessionId: options?.sessionId ?? "input-attachment-failed",
847
- turnCount: 0,
848
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
849
- };
850
- }
851
- if (attachmentContext.text || attachmentContext.hasStructuredImageAttachments) {
852
- parsedTask = {
853
- text: [parsedTask.text, attachmentContext.text].filter(Boolean).join("\n\n"),
854
- images: [...parsedTask.images, ...attachmentContext.images],
855
- hasImages: parsedTask.hasImages ||
856
- attachmentContext.images.length > 0 ||
857
- attachmentContext.hasStructuredImageAttachments,
858
- };
859
- }
860
- if (parsedTask.hasImages) {
861
- if (!cap.supportsVision) {
862
- logger.warn("engine.run.vision_not_supported", {
863
- provider: this.config.llm.provider,
864
- model: this.config.llm.model,
865
- imageCount: parsedTask.images.length,
866
- });
867
- return {
868
- text: `ERROR: model "${this.config.llm.model}" does not accept image input. ` +
869
- `Switch to a vision-capable model (e.g. gpt-4o, claude-sonnet, gemini-1.5-pro) and resend.`,
870
- reason: "image_error",
871
- sessionId: options?.sessionId ?? "vision-not-supported",
872
- turnCount: 0,
873
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
874
- };
875
- }
876
- // Size gate. Hosts (desktop renderer, TUI) are expected to
877
- // pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
878
- // fast with a clear message instead of letting the OpenAI client
879
- // grind through three 16-second "Connection error" retries on a
880
- // 4 MB body. See `image-policy.ts` for the rationale and limits.
881
- let verdict = enforceImagePolicy(parsedTask.images);
882
- if (!verdict.ok && verdict.code === "image_too_large") {
883
- // One image blew the per-image cap. Try the engine-side
884
- // compressor (jimp-backed when installed; no-op otherwise) so
885
- // TUI / MCP paths that lack a host-side resize don't fail
886
- // outright on a screenshot they could have rescaled. The
887
- // re-check below is what decides whether we proceed.
888
- const compressed = await tryCompressImages(parsedTask.images);
889
- if (compressed.anyCompressed) {
890
- parsedTask.images = compressed.images;
891
- logger.info("engine.run.image_compressed", {
892
- before: verdict.offender?.bytes,
893
- after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
894
- });
895
- verdict = enforceImagePolicy(parsedTask.images);
896
- }
897
- }
898
- // After compression, anything still over the per-image cap is
899
- // dropped with a textual placeholder instead of failing the
900
- // turn (TODO-week.md #9e). The "5MB brick session" failure
901
- // mode from Claude Code (research doc §A) was the case where a
902
- // poisoned image entered history and every subsequent request
903
- // re-sent it; placeholders keep history clean while letting
904
- // the rest of the turn run.
905
- if (!verdict.ok && verdict.code === "image_too_large") {
906
- const drop = dropOversizedImages(parsedTask.images);
907
- if (drop.droppedCount > 0) {
908
- parsedTask.images = drop.kept;
909
- parsedTask.hasImages = drop.kept.length > 0;
910
- parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
911
- logger.warn("engine.run.image_dropped", {
912
- droppedCount: drop.droppedCount,
913
- keptCount: drop.kept.length,
914
- });
915
- verdict = enforceImagePolicy(parsedTask.images);
916
- }
917
- }
918
- if (!verdict.ok) {
919
- // Cumulative / count caps can't be rescued by per-image
920
- // dropping (well — too_many_images could trim by FIFO, but
921
- // that's a bigger UX call than we want to make silently).
922
- // Refuse the turn with the policy message.
923
- logger.warn("engine.run.image_policy_failed", {
924
- code: verdict.code,
925
- imageCount: verdict.totals.imageCount,
926
- totalBytes: verdict.totals.totalBytes,
927
- offender: verdict.offender,
928
- });
929
- return {
930
- text: `ERROR: ${verdict.message}`,
931
- reason: "image_error",
932
- sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
933
- turnCount: 0,
934
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
935
- };
936
- }
937
- }
938
- // For downstream noise-detection + transcript persistence we want the
939
- // *text* portion only — base64 bytes count as "noise" by the heuristic
940
- // and would also bloat the transcript by megabytes per image. Image
941
- // bytes ride in parsedTask.images and re-enter the message tree below.
942
- const taskText = parsedTask.text;
877
+ if (!imageInput.ok)
878
+ return imageInput.result;
879
+ const { parsedTask, taskText } = imageInput;
943
880
  const noise = detectPastedNoise(taskText);
944
881
  if (noise.isNoise) {
945
882
  const hint = `Your input looks like pasted terminal output (${noise.reason}). ` +
@@ -962,7 +899,7 @@ export class Engine {
962
899
  describe: () => ({
963
900
  cwd,
964
901
  preset: this.preset.name,
965
- permissionMode: this.config.permissionMode ?? "acceptEdits",
902
+ permissionMode: runPermissionMode,
966
903
  }),
967
904
  spawn: async (req) => {
968
905
  // Anchor this sub-agent in the PARENT transcript at spawn time — before
@@ -997,7 +934,7 @@ export class Engine {
997
934
  // rather surface failures than burn a 9 s exponential backoff loop.
998
935
  clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
999
936
  cwd,
1000
- permissionMode: this.config.permissionMode,
937
+ permissionMode: runPermissionMode,
1001
938
  preset: this.preset.name,
1002
939
  enabledBuiltinTools: childEnabled,
1003
940
  disabledBuiltinTools: childDisabled,
@@ -1065,7 +1002,7 @@ export class Engine {
1065
1002
  onStream: childStream,
1066
1003
  sessionId: childSessionId,
1067
1004
  });
1068
- return { text: result.text, sessionId: result.sessionId };
1005
+ return { text: result.text, sessionId: result.sessionId, usage: result.usage };
1069
1006
  },
1070
1007
  sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
1071
1008
  };
@@ -1100,6 +1037,9 @@ export class Engine {
1100
1037
  // after the assignment.
1101
1038
  const toolCtx = {
1102
1039
  ...this.buildToolContext(),
1040
+ approvalRouter: options?.approvalRouter ?? this.config.approvalRouter,
1041
+ permissionMode: runPermissionMode,
1042
+ planMode: runPlanMode,
1103
1043
  subAgentSpawner,
1104
1044
  agentDefinitions: this.getAgentDefinitions(cwd),
1105
1045
  // Stamp the resolved network policy onto the backend the tools see so
@@ -1128,40 +1068,7 @@ export class Engine {
1128
1068
  preset: this.preset.name,
1129
1069
  imageCount: parsedTask.images.length,
1130
1070
  });
1131
- // Compose the user-turn payload once so resume + cold paths agree on
1132
- // shape. With images, content becomes a ContentBlock[] holding one
1133
- // text block (when prose is present) followed by one image block per
1134
- // attachment — the provider-specific clients translate this to OpenAI
1135
- // `image_url` or Anthropic `{type:image, source:base64}` downstream.
1136
- // When an attached image came from a workspace FILE (the desktop composer's
1137
- // path-attach flow sets ParsedImage.name = the absolute path), surface that
1138
- // path to the model as text. The image bytes still ride along for vision,
1139
- // but tools that operate on files — GenerateImage(referenceImages),
1140
- // Read, etc. — need the on-disk path, not just the pixels. Without this the
1141
- // path the composer already knew was silently dropped, and the model would
1142
- // answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
1143
- // Only names that resolve to an existing file qualify; a pasted screenshot
1144
- // whose name is just "screenshot.png" is not a path and is left out.
1145
- const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
1146
- const pathHint = attachedPaths.length > 0
1147
- ? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
1148
- `(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
1149
- : "";
1150
- const userMessageContent = parsedTask.hasImages
1151
- ? [
1152
- ...(parsedTask.text || pathHint
1153
- ? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
1154
- : []),
1155
- ...parsedTask.images.map((img) => ({
1156
- type: "image",
1157
- source: {
1158
- type: "base64",
1159
- media_type: img.mime,
1160
- data: img.base64,
1161
- },
1162
- })),
1163
- ]
1164
- : taskText;
1071
+ const userMessageContent = buildRunUserMessageContent(parsedTask, cwd, taskText);
1165
1072
  // Create or resume session.
1166
1073
  //
1167
1074
  // Three valid shapes:
@@ -1284,12 +1191,18 @@ export class Engine {
1284
1191
  // notifications) attribute to the right session. toolCtx is created
1285
1192
  // before the session bundle is resolved (see ~line 635), so this is
1286
1193
  // the first point we can set it. After this assignment treat the
1287
- // field as immutable for the rest of the run.
1194
+ // field follows the latest successfully injected user intent for the rest
1195
+ // of the run, so tools launched after a steer attribute their side effects
1196
+ // to that steer rather than this original submit.
1288
1197
  toolCtx.sessionId = session.state.sessionId;
1198
+ toolCtx.originClientMessageId = options?.clientMessageId;
1199
+ toolCtx.recordExternalFileChanges = (record) => {
1200
+ session.transcript.append("external_file_changes", { ...record });
1201
+ };
1289
1202
  toolCtx.setSessionWorkspace = (workspace) => {
1290
1203
  session.state.workspace = workspace;
1291
1204
  };
1292
- return runWithSid(session.state.sessionId, async () => {
1205
+ const sessionRun = runWithSid(session.state.sessionId, async () => {
1293
1206
  recordSessionStart(session.state.sessionId, {
1294
1207
  // Strip <codeshell-image> base64 payloads before they reach
1295
1208
  // <repo>/log/. Reader still sees the marker + byte count, just
@@ -1298,7 +1211,7 @@ export class Engine {
1298
1211
  cwd,
1299
1212
  model: this.config.llm.model,
1300
1213
  provider: this.config.llm.provider,
1301
- permissionMode: this.config.permissionMode ?? "acceptEdits",
1214
+ permissionMode: runPermissionMode,
1302
1215
  resumed: resumedFromDisk,
1303
1216
  });
1304
1217
  // Session-level hook: fired once per Engine.run() entry, regardless of
@@ -1311,7 +1224,7 @@ export class Engine {
1311
1224
  cwd,
1312
1225
  resumed: resumedFromDisk,
1313
1226
  source: resumedFromDisk ? "resume" : "startup",
1314
- });
1227
+ }, options?.signal);
1315
1228
  // Per-turn hook: fired every time a new user prompt enters the loop.
1316
1229
  // Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
1317
1230
  // reminders that should accompany each user turn (e.g. "skills
@@ -1325,7 +1238,7 @@ export class Engine {
1325
1238
  // exfiltration risk a curious user-installed shell hook shouldn't carry.
1326
1239
  prompt: taskText,
1327
1240
  resumed: resumedFromDisk,
1328
- });
1241
+ }, options?.signal);
1329
1242
  // updatedPrompt: handler rewrote the user's prompt text. Replace the
1330
1243
  // last user message we just pushed (cold-start: line ~511; resume:
1331
1244
  // line ~500). Original prompt is in the transcript already — we log
@@ -1412,8 +1325,14 @@ export class Engine {
1412
1325
  }
1413
1326
  // Kick off LLM client creation early (network handshake)
1414
1327
  const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
1415
- const mode = this.config.permissionMode ?? "acceptEdits";
1416
- const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
1328
+ // MCP connection below may keep us from awaiting this promise for a while.
1329
+ // Observe rejection immediately so a fast client-init failure cannot become
1330
+ // an unhandledRejection during that gap; Promise.all still receives the
1331
+ // original promise and routes the same error through the lifecycle catch.
1332
+ void llmClientPromise.catch(() => { });
1333
+ const mode = runPermissionMode;
1334
+ this.activeApprovalRouter = toolCtx.approvalRouter;
1335
+ const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd, toolCtx.approvalRouter);
1417
1336
  const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
1418
1337
  this.activePermission = permission;
1419
1338
  // If the backend is the interactive one, wire it for project-scope
@@ -1575,7 +1494,7 @@ export class Engine {
1575
1494
  // PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
1576
1495
  // RUNS can't drift apart. (Bash is in the set; the executor additionally
1577
1496
  // gates Bash to read-only commands at call time.)
1578
- const toolDefs = this.planMode
1497
+ const toolDefs = runPlanMode
1579
1498
  ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
1580
1499
  : allToolDefs;
1581
1500
  const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
@@ -1634,15 +1553,81 @@ export class Engine {
1634
1553
  // These are tiny throwaway outputs ("Wrote design doc") fired every turn;
1635
1554
  // that high-frequency, low-stakes chore is exactly what aux is for.
1636
1555
  const auxSummaryClient = await this.resolveAuxClient(llmClient);
1556
+ // Auto-compaction runs inside TurnLoop.manageAsync(), after the loop has
1557
+ // initialized its run-scoped Goal tracker. The closure is wired before
1558
+ // construction but cannot execute until turnLoop.run() starts.
1559
+ let turnLoop;
1560
+ let autoCompactionGoalTermination;
1561
+ let externalRunUsage = {
1562
+ promptTokens: 0,
1563
+ completionTokens: 0,
1564
+ totalTokens: 0,
1565
+ cacheReadTokens: 0,
1566
+ cacheCreationTokens: 0,
1567
+ };
1568
+ let runAccountingFinalized = false;
1637
1569
  Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
1638
1570
  const recordCumulativeUsage = (usage) => {
1639
1571
  const next = addCumulativeUsage(session.state, usage);
1640
1572
  Object.assign(session.state, next);
1641
1573
  return next;
1642
1574
  };
1643
- contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
1575
+ const recordExternalBilledUsage = (usage) => {
1576
+ externalRunUsage = addTokenUsage(externalRunUsage, usage);
1577
+ const cumulative = recordCumulativeUsage(usage);
1578
+ autoCompactionGoalTermination = turnLoop.recordGoalJudgeUsage(usage);
1579
+ if (runAccountingFinalized) {
1580
+ try {
1581
+ const latest = this.sessionManager.resume(sid).state;
1582
+ const lateCumulative = addCumulativeUsage(latest, usage);
1583
+ this.sessionManager.updateSessionState(sid, {
1584
+ tokenUsage: addTokenUsage(latest.tokenUsage, usage),
1585
+ ...lateCumulative,
1586
+ ...(this.config.costStore
1587
+ ? {
1588
+ costState: this.config.costStore.serialize(),
1589
+ }
1590
+ : {}),
1591
+ });
1592
+ }
1593
+ catch (err) {
1594
+ logger.warn("engine.late_usage_persist_failed", {
1595
+ sessionId: sid,
1596
+ error: err instanceof Error ? err.message : String(err),
1597
+ });
1598
+ }
1599
+ }
1600
+ return cumulative;
1601
+ };
1602
+ contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordExternalBilledUsage));
1644
1603
  // Create components (requires resolved llmClient).
1645
1604
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1605
+ const getRunUsage = () => {
1606
+ const visible = modelFacade.getUsage();
1607
+ return {
1608
+ ...visible,
1609
+ totalPromptTokens: visible.totalPromptTokens + externalRunUsage.promptTokens,
1610
+ totalCompletionTokens: visible.totalCompletionTokens + externalRunUsage.completionTokens,
1611
+ totalTokens: visible.totalTokens + externalRunUsage.totalTokens,
1612
+ totalCacheReadTokens: visible.totalCacheReadTokens + (externalRunUsage.cacheReadTokens ?? 0),
1613
+ totalCacheCreationTokens: visible.totalCacheCreationTokens + (externalRunUsage.cacheCreationTokens ?? 0),
1614
+ };
1615
+ };
1616
+ const callPrimaryModel = modelFacade.call.bind(modelFacade);
1617
+ modelFacade.call = async (...args) => {
1618
+ // A primary-model summary may itself exhaust the Goal budget. Do not
1619
+ // issue the main turn request after that billed sub-call; return control
1620
+ // to TurnLoop, whose existing post-response guard emits and persists the
1621
+ // canonical goal_budget_exhausted termination.
1622
+ if (autoCompactionGoalTermination) {
1623
+ return {
1624
+ text: "",
1625
+ toolCalls: [],
1626
+ stopReason: "stop",
1627
+ };
1628
+ }
1629
+ return callPrimaryModel(...args);
1630
+ };
1646
1631
  // Session-cumulative usage baseline: the LLM client is recreated per run
1647
1632
  // (its getUsage() counts only THIS run), so to accumulate across runs we
1648
1633
  // capture the persisted total at run start and fold this run's usage onto
@@ -1650,23 +1635,25 @@ export class Engine {
1650
1635
  const usageBaseline = { ...session.state.tokenUsage };
1651
1636
  // Wire getOutputTokens for token budget tracking
1652
1637
  modelFacade.getOutputTokens = () => {
1653
- const usage = llmClient.getUsage();
1638
+ const usage = getRunUsage();
1654
1639
  return usage.totalCompletionTokens;
1655
1640
  };
1656
- // Wire summarize for tool use summaries (uses lightweight call).
1657
- // recordUsage=false keeps these auxiliary sub-calls out of the main usage
1658
- // tracker so session_end.cost reflects only the user-facing turns and
1659
- // turns/requestCount stay aligned.
1641
+ // Wire summarize for tool use summaries (uses lightweight call). Keep the
1642
+ // request out of the foreground tracker while billing and reporting it to
1643
+ // the owning session/Goal budget.
1660
1644
  modelFacade.summarize = async (sysPrompt, userMsg) => {
1661
1645
  const resp = await auxSummaryClient.createMessage({
1662
1646
  systemPrompt: sysPrompt,
1663
1647
  messages: [{ role: "user", content: userMsg }],
1664
1648
  tools: [],
1665
1649
  maxTokens: 256,
1666
- recordUsage: false,
1650
+ billingEnabled: true,
1651
+ requestVisible: false,
1667
1652
  // Auxiliary call — see contextManager.setSummarizeFn above.
1668
1653
  reasoning: { mode: "off" },
1669
1654
  });
1655
+ if (resp.usage)
1656
+ recordExternalBilledUsage(resp.usage);
1670
1657
  logger.debug("summarize.call", {
1671
1658
  sysPromptLen: sysPrompt.length,
1672
1659
  userMsgLen: userMsg.length,
@@ -1720,21 +1707,24 @@ export class Engine {
1720
1707
  sessionId: session.state.sessionId,
1721
1708
  task,
1722
1709
  model: this.config.llm.model,
1723
- });
1710
+ }, options?.signal);
1724
1711
  // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1725
1712
  // turn loop keeps going until the session model judges the goal met.
1726
1713
  // Registered per-run (and cleared in `finally`) so a later goal-less
1727
- // send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
1728
- // the same cheap aux model used for summarize/compaction — not the
1729
- // (potentially expensive) session model: "is this goal met?" is a classic
1730
- // aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
1731
- // times.
1714
+ // send doesn't inherit a stale goal. The judge runs on the primary
1715
+ // session client; auxSummaryClient remains dedicated to low-consequence
1716
+ // summaries/titles and retains defaults.auxText routing/fallback behavior.
1732
1717
  // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1733
1718
  // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1734
1719
  // when there's effectively no goal (empty objective).
1735
1720
  //
1736
1721
  // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1737
- // later sends and manual interrupts until met or cleared. Resolution:
1722
+ // later sends and manual interrupts until met or cleared. Goal completion
1723
+ // is a high-consequence decision, so V1 routes it to the primary session
1724
+ // client, which is the model expected to interpret the supplied execution
1725
+ // evidence. defaults.auxText remains in force for summaries, titles and
1726
+ // other auxiliary work through auxSummaryClient.
1727
+ // Resolution:
1738
1728
  // 1. options.goal — this send explicitly sets/replaces the goal.
1739
1729
  // 2. session.state.activeGoal — a goal set on an earlier send.
1740
1730
  // 3. config.goal — engine-level default (rare; e.g. headless).
@@ -1743,7 +1733,15 @@ export class Engine {
1743
1733
  // bare send with no options.goal inherits the stored active goal so the
1744
1734
  // model keeps working toward it — that's what makes it persistent.
1745
1735
  const explicitGoal = normalizeGoal(options?.goal);
1746
- const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1736
+ let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1737
+ // Defense in depth: a stale whole-state writer may have restored the
1738
+ // activeGoal field after this exact goal instance was force-terminated.
1739
+ // Refuse to arm it and converge the live bundle before hook registration.
1740
+ if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
1741
+ session.state.activeGoal = undefined;
1742
+ storedGoal = undefined;
1743
+ this.sessionManager.saveState(session.state);
1744
+ }
1747
1745
  if (explicitGoal && this.config.isSubAgent !== true) {
1748
1746
  const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1749
1747
  // Stamp WHEN this goal was set so the judge can anchor relative deadlines
@@ -1752,7 +1750,14 @@ export class Engine {
1752
1750
  // or changed objective gets a fresh stamp; re-sending the SAME objective
1753
1751
  // keeps the original anchor (the goal continues, the user didn't restate a
1754
1752
  // new deadline). User input never carries setAtMs, so we set it here.
1755
- explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1753
+ const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1754
+ // A user explicitly re-starting the same objective creates a new goal
1755
+ // instance. Avoid a same-millisecond collision with its old tombstone.
1756
+ explicitGoal.setAtMs =
1757
+ session.state.goalTerminal?.objective === explicitGoal.objective &&
1758
+ session.state.goalTerminal.setAtMs === resolvedSetAt
1759
+ ? resolvedSetAt + 1
1760
+ : resolvedSetAt;
1756
1761
  session.state.activeGoal = explicitGoal;
1757
1762
  this.sessionManager.saveState(session.state);
1758
1763
  options?.onStream?.({
@@ -1762,18 +1767,35 @@ export class Engine {
1762
1767
  });
1763
1768
  }
1764
1769
  const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1770
+ // Snapshot the persisted goal identity owned by THIS run. Terminal
1771
+ // cleanup compares against this immutable copy so an old run cannot
1772
+ // delete a replacement goal installed while it was finishing.
1773
+ const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
1774
+ ? { ...normalizedGoal }
1775
+ : undefined;
1765
1776
  let goalHookHandler = null;
1777
+ let goalJudgeContext;
1766
1778
  if (normalizedGoal && this.config.isSubAgent !== true) {
1767
1779
  goalHookHandler = createGoalStopHook({
1768
1780
  goal: normalizedGoal,
1769
- llm: auxSummaryClient,
1781
+ llm: llmClient,
1770
1782
  log: logger,
1783
+ getJudgeContext: () => goalJudgeContext,
1784
+ onJudgeUsage: (usage) => {
1785
+ // The provider records this request into llmClient.getUsage() and the
1786
+ // process-wide CostTracker. This separate callback feeds the session
1787
+ // cumulative cache counters and the live Goal hard-budget tracker.
1788
+ if (usage)
1789
+ recordCumulativeUsage(usage);
1790
+ return turnLoop.recordGoalJudgeUsage(usage);
1791
+ },
1771
1792
  // Clear the persisted active goal the moment the judge says it's met,
1772
1793
  // so a later bare send doesn't re-inherit a satisfied goal. The hook
1773
1794
  // calls this from inside its met branch (single source of truth for
1774
1795
  // "goal achieved"); engine owns the persistence side-effect.
1775
1796
  onMet: () => {
1776
- if (session.state.activeGoal) {
1797
+ if (persistedRunGoal &&
1798
+ isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1777
1799
  session.state.activeGoal = undefined;
1778
1800
  this.sessionManager.saveState(session.state);
1779
1801
  }
@@ -1783,7 +1805,7 @@ export class Engine {
1783
1805
  // in-RAM session are untouched) actually stops the judge. Reads disk
1784
1806
  // via readActiveGoal — authoritative and independent of which session
1785
1807
  // instance the run closure holds.
1786
- isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
1808
+ isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
1787
1809
  });
1788
1810
  this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1789
1811
  // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
@@ -1799,7 +1821,7 @@ export class Engine {
1799
1821
  options?.onStream?.({ type: "context_compact", ...info });
1800
1822
  });
1801
1823
  // Run turn loop
1802
- const turnLoop = new TurnLoop({
1824
+ turnLoop = new TurnLoop({
1803
1825
  model: modelFacade,
1804
1826
  toolExecutor,
1805
1827
  contextManager,
@@ -1815,8 +1837,31 @@ export class Engine {
1815
1837
  return info;
1816
1838
  },
1817
1839
  consumeSteer: (source) => this.consumeSteer(sid, source),
1840
+ restoreSteer: (items) => this.restoreSteer(sid, items),
1841
+ buildSteerUserMessageContent: async (item) => {
1842
+ const steerImageInput = await prepareRunImageInput({
1843
+ task: item.text,
1844
+ cwd,
1845
+ llm: this.config.llm,
1846
+ sessionId: sid,
1847
+ attachments: item.attachments,
1848
+ });
1849
+ if (!steerImageInput.ok) {
1850
+ throw new Error(steerImageInput.result.text);
1851
+ }
1852
+ return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
1853
+ },
1818
1854
  claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1855
+ releaseClientMessageId: (clientMessageId) => {
1856
+ claimedClientMessageIds.delete(clientMessageId);
1857
+ },
1858
+ setOriginClientMessageId: (clientMessageId) => {
1859
+ toolCtx.originClientMessageId = clientMessageId;
1860
+ },
1819
1861
  recordCumulativeUsage,
1862
+ recordCacheReadDiagnostics: (usage) => {
1863
+ this.recordCacheReadDiagnostics(sid, usage);
1864
+ },
1820
1865
  recordContextUsageAnchor: (anchor) => {
1821
1866
  session.state.contextUsageAnchor = {
1822
1867
  ...anchor,
@@ -1829,7 +1874,8 @@ export class Engine {
1829
1874
  // turns don't re-arm) AND persists it, and drops the in-flight stop
1830
1875
  // hook so nothing re-blocks the stop we're about to return.
1831
1876
  clearPersistedGoal: () => {
1832
- if (session.state.activeGoal !== undefined) {
1877
+ if (persistedRunGoal &&
1878
+ isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1833
1879
  session.state.activeGoal = undefined;
1834
1880
  this.sessionManager.saveState(session.state);
1835
1881
  }
@@ -1839,6 +1885,9 @@ export class Engine {
1839
1885
  this.activeGoalHook = null;
1840
1886
  }
1841
1887
  },
1888
+ updateGoalJudgeContext: (context) => {
1889
+ goalJudgeContext = context;
1890
+ },
1842
1891
  ctxOverheadStore: {
1843
1892
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
1844
1893
  set: (s, n) => {
@@ -1863,6 +1912,7 @@ export class Engine {
1863
1912
  onStream: options?.onStream,
1864
1913
  signal: options?.signal,
1865
1914
  freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1915
+ volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
1866
1916
  // Goal mode: the active goal is surfaced to the on_stop handler via
1867
1917
  // ctx.data.goal; the GoalStopHook (registered above) judges it.
1868
1918
  goal: normalizedGoal,
@@ -1874,7 +1924,7 @@ export class Engine {
1874
1924
  session.state.turnCount = turnCount;
1875
1925
  // baseline + this run's running total (idempotent per boundary,
1876
1926
  // accumulates across runs; carries cacheRead/cacheCreation too).
1877
- session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1927
+ session.state.tokenUsage = foldRunUsage(usageBaseline, getRunUsage());
1878
1928
  // Surface the whole-session monotonic cache counts to the UI.
1879
1929
  // Separate from turn-loop's authoritative per-response emit (which
1880
1930
  // drives the live context reading and single-turn metric).
@@ -1901,6 +1951,7 @@ export class Engine {
1901
1951
  this.sessionManager.saveState(session.state);
1902
1952
  },
1903
1953
  });
1954
+ toolCtx.recordBilledUsage = recordExternalBilledUsage;
1904
1955
  // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1905
1956
  // a sub-agent's loop is its own concern and isn't user-extendable.
1906
1957
  if (this.config.isSubAgent !== true)
@@ -1910,9 +1961,38 @@ export class Engine {
1910
1961
  // only — sub-agents don't carry user-clearable persistent goals.
1911
1962
  if (this.config.isSubAgent !== true)
1912
1963
  this.activeRunSession = session;
1964
+ const applyGoalTermination = (termination) => {
1965
+ if (!termination || !persistedRunGoal)
1966
+ return;
1967
+ // Judge prompt overflow ends only this run. The objective is unfinished
1968
+ // and may be resumed after the user reduces fixed judge context, so it
1969
+ // must not get a terminal tombstone or be cleared from activeGoal.
1970
+ if (termination === "judge_prompt_too_large")
1971
+ return;
1972
+ // Record the terminal identity even when a newer goal has already
1973
+ // replaced it. Only clear activeGoal when it is still the run's goal.
1974
+ session.state.goalTerminal = {
1975
+ objective: persistedRunGoal.objective,
1976
+ setAtMs: persistedRunGoal.setAtMs,
1977
+ reason: termination,
1978
+ terminatedAtMs: Date.now(),
1979
+ };
1980
+ if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1981
+ session.state.activeGoal = undefined;
1982
+ }
1983
+ this.sessionManager.saveState(session.state);
1984
+ if (goalHookHandler) {
1985
+ this.hooks.unregister("on_stop", goalHookHandler);
1986
+ if (this.activeGoalHook === goalHookHandler)
1987
+ this.activeGoalHook = null;
1988
+ }
1989
+ };
1913
1990
  let result;
1991
+ let firstGoalTermination;
1914
1992
  try {
1915
1993
  result = await turnLoop.run(messages);
1994
+ firstGoalTermination = result.goalTermination;
1995
+ applyGoalTermination(result.goalTermination);
1916
1996
  // ── Headless: drain background sub-agents before resolving ───────
1917
1997
  // Unified background-work model (2026-06-17): the engine NO LONGER parks
1918
1998
  // every run waiting on background work. Background work (sub-agents,
@@ -1966,14 +2046,19 @@ export class Engine {
1966
2046
  role: "user",
1967
2047
  content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
1968
2048
  };
1969
- if (aborted) {
2049
+ if (aborted || firstGoalTermination) {
1970
2050
  // Mark injected: a synthetic notification, not the user's own input —
1971
2051
  // the disk reader drops it on replay so no phantom user bubble.
2052
+ // A goal termination is also a hard boundary: retain the notification
2053
+ // for recovery, but never re-enter TurnLoop (which would reset its
2054
+ // run-scoped goal budget tracker and could overwrite the first reason).
1972
2055
  session.transcript.appendMessage(injected.role, injected.content, { injected: true });
1973
2056
  result = { ...result, messages: [...result.messages, injected] };
1974
2057
  break;
1975
2058
  }
1976
2059
  result = await turnLoop.run([...result.messages, injected]);
2060
+ firstGoalTermination ??= result.goalTermination;
2061
+ applyGoalTermination(result.goalTermination);
1977
2062
  }
1978
2063
  }
1979
2064
  }
@@ -1993,17 +2078,18 @@ export class Engine {
1993
2078
  this.hooks.unregister("on_tool_start", fileHistoryHandler);
1994
2079
  }
1995
2080
  this.lastMessages = result.messages;
1996
- this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
2081
+ const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
2082
+ this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
1997
2083
  logger.info("engine.done", {
1998
2084
  sessionId: session.state.sessionId,
1999
2085
  reason: result.reason,
2000
2086
  turns: turnLoop.currentTurn,
2001
- tokens: modelFacade.getUsage().totalTokens,
2087
+ tokens: getRunUsage().totalTokens,
2002
2088
  });
2003
2089
  recordSessionEnd(session.state.sessionId, {
2004
2090
  reason: result.reason,
2005
2091
  turns: turnLoop.currentTurn,
2006
- cost: modelFacade.getUsage(),
2092
+ cost: getRunUsage(),
2007
2093
  });
2008
2094
  // Session-level hook: fired symmetrically with on_session_start once
2009
2095
  // the turn loop has resolved (completion, error, or abort). Handlers
@@ -2013,11 +2099,11 @@ export class Engine {
2013
2099
  sessionId: session.state.sessionId,
2014
2100
  reason: result.reason,
2015
2101
  turnCount: turnLoop.currentTurn,
2016
- });
2102
+ }, options?.signal);
2017
2103
  // Fire-and-forget memory pipeline: extract durable memories from the
2018
2104
  // transcript, save a session summary, and conditionally trigger
2019
2105
  // auto-dream consolidation. Doesn't block the Engine result.
2020
- void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
2106
+ void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient, recordExternalBilledUsage);
2021
2107
  // Fire-and-forget session title generation — only after the FIRST turn.
2022
2108
  // Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
2023
2109
  // effort: failures never touch the run result. The renderer writes the
@@ -2028,20 +2114,21 @@ export class Engine {
2028
2114
  const userMsgCount = userMsgEvents.length;
2029
2115
  const onStream = options?.onStream;
2030
2116
  if (userMsgCount === 1 && onStream && result.text) {
2117
+ const sessionId = session.state.sessionId;
2031
2118
  const rawContent = userMsgEvents[0]?.data?.content;
2032
2119
  const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
2033
- void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
2120
+ void buildSessionTitle(auxSummaryClient, firstUserText, result.text, recordExternalBilledUsage)
2034
2121
  .then((title) => {
2035
2122
  if (title) {
2036
2123
  // Persist the title so it survives a localStorage wipe / disk
2037
2124
  // rebuild — it used to live only in the renderer's localStorage
2038
- // index. This .then resolves AFTER the saveState below (:1892), so
2039
- // it must save again itself rather than rely on that write.
2040
- session.state.title = title;
2041
- this.sessionManager.saveState(session.state);
2125
+ // index. Read the latest persisted state at callback time and
2126
+ // merge only title; the completed run's session.state snapshot
2127
+ // may already be stale after later serial session updates.
2128
+ this.sessionManager.updateSessionState(sessionId, { title });
2042
2129
  onStream({
2043
2130
  type: "session_title",
2044
- sessionId: session.state.sessionId,
2131
+ sessionId,
2045
2132
  title,
2046
2133
  });
2047
2134
  }
@@ -2054,32 +2141,82 @@ export class Engine {
2054
2141
  // failures (model_error, prompt_too_long, ...) — previously every
2055
2142
  // non-completed outcome collapsed to "errored", which threw away the
2056
2143
  // distinction and misled anyone reading state.json.
2144
+ if (session.transcript.flushFailed()) {
2145
+ const failure = session.transcript.getFlushFailure();
2146
+ logger.error("engine.transcript_persistence_failed", {
2147
+ sessionId: session.state.sessionId,
2148
+ terminalReason: result.reason,
2149
+ degraded: true,
2150
+ ...failure,
2151
+ });
2152
+ }
2057
2153
  session.state.turnCount = turnLoop.currentTurn;
2058
2154
  session.state.status = result.reason;
2059
2155
  // Session-cumulative (baseline + this run) for persistence...
2060
- const usage = modelFacade.getUsage();
2156
+ const usage = getRunUsage();
2061
2157
  session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
2062
2158
  if (this.config.costStore) {
2063
2159
  session.state.costState = this.config.costStore.serialize();
2064
2160
  }
2161
+ // runInProgress excludes another whole-state writer only on this Engine
2162
+ // instance. A different Engine using the same sessionId can still race
2163
+ // this saveState (including an old run's abort cleanup vs a replacement
2164
+ // Engine); cross-instance/process session serialization is a separate finding.
2065
2165
  this.sessionManager.saveState(session.state);
2166
+ runAccountingFinalized = true;
2066
2167
  // Hook: agent end
2067
2168
  await this.emitHook("on_agent_end", {
2068
2169
  sessionId: session.state.sessionId,
2069
2170
  reason: result.reason,
2070
2171
  turnCount: turnLoop.currentTurn,
2071
- });
2172
+ }, options?.signal);
2072
2173
  // Emit completion
2073
2174
  options?.onStream?.({ type: "turn_complete", reason: result.reason });
2074
2175
  return {
2075
2176
  text: result.text,
2076
2177
  reason: result.reason,
2178
+ goalTermination: firstGoalTermination,
2077
2179
  sessionId: session.state.sessionId,
2078
2180
  turnCount: turnLoop.currentTurn,
2079
2181
  usage: {
2080
2182
  promptTokens: usage.totalPromptTokens,
2081
2183
  completionTokens: usage.totalCompletionTokens,
2082
2184
  totalTokens: usage.totalTokens,
2185
+ cacheReadTokens: usage.totalCacheReadTokens,
2186
+ cacheCreationTokens: usage.totalCacheCreationTokens,
2187
+ },
2188
+ };
2189
+ });
2190
+ return Promise.resolve(sessionRun).catch((err) => {
2191
+ // The session is already persisted as active before runWithSid starts.
2192
+ // Initialization failures (client creation, MCP connection, prompt/hooks)
2193
+ // therefore need the same terminal lifecycle treatment as turn-loop errors.
2194
+ const error = formatFriendlyError(err);
2195
+ session.state.status = "model_error";
2196
+ this.sessionManager.saveState(session.state);
2197
+ session.transcript.appendError(error, { phase: "initialization" });
2198
+ logger.error("engine.run_lifecycle_failed", {
2199
+ sessionId: session.state.sessionId,
2200
+ error: err instanceof Error ? err.message : String(err),
2201
+ });
2202
+ recordSessionEnd(session.state.sessionId, {
2203
+ reason: "model_error",
2204
+ turns: session.state.turnCount,
2205
+ });
2206
+ options?.onStream?.({ type: "error", error });
2207
+ options?.onStream?.({ type: "turn_complete", reason: "model_error" });
2208
+ const usage = session.state.tokenUsage;
2209
+ return {
2210
+ text: `ERROR: ${error}`,
2211
+ reason: "model_error",
2212
+ sessionId: session.state.sessionId,
2213
+ turnCount: session.state.turnCount,
2214
+ usage: {
2215
+ promptTokens: usage.promptTokens ?? 0,
2216
+ completionTokens: usage.completionTokens ?? 0,
2217
+ totalTokens: usage.totalTokens ?? 0,
2218
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
2219
+ cacheCreationTokens: usage.cacheCreationTokens ?? 0,
2083
2220
  },
2084
2221
  };
2085
2222
  });
@@ -2102,16 +2239,19 @@ export class Engine {
2102
2239
  * run path and forceCompact share one definition of the summarization call.
2103
2240
  */
2104
2241
  buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2105
- return async (prompt) => {
2242
+ return async (prompt, signal) => {
2106
2243
  const summaryResponse = await auxSummaryClient.createMessage({
2107
2244
  systemPrompt: "You are a conversation summarizer. Be concise and factual.",
2108
2245
  messages: [{ role: "user", content: prompt }],
2109
2246
  tools: [],
2110
2247
  maxTokens: 1024,
2248
+ billingEnabled: true,
2249
+ requestVisible: false,
2111
2250
  // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
2112
2251
  // this flips thinking off (~3x faster, fewer tokens); on every other
2113
2252
  // OpenAI-compatible provider the field is ignored.
2114
2253
  reasoning: { mode: "off" },
2254
+ signal,
2115
2255
  });
2116
2256
  if (summaryResponse.usage) {
2117
2257
  recordCumulativeUsage?.(summaryResponse.usage);
@@ -2171,7 +2311,7 @@ export class Engine {
2171
2311
  return fallback;
2172
2312
  }
2173
2313
  }
2174
- async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
2314
+ async runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage) {
2175
2315
  try {
2176
2316
  // Background calls run on the auxiliary model when configured, so memory
2177
2317
  // book-keeping doesn't burn the expensive primary model every turn.
@@ -2211,12 +2351,22 @@ export class Engine {
2211
2351
  messages: [{ role: "user", content: userMsg }],
2212
2352
  tools: [],
2213
2353
  maxTokens: 1024,
2214
- recordUsage: false,
2354
+ billingEnabled: true,
2355
+ requestVisible: false,
2215
2356
  reasoning: { mode: "off" },
2216
2357
  });
2358
+ if (resp.usage)
2359
+ recordBilledUsage?.(resp.usage);
2217
2360
  return resp.text;
2218
2361
  },
2219
- runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
2362
+ runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({
2363
+ systemPrompt,
2364
+ userPrompt,
2365
+ projectDir,
2366
+ llmClient,
2367
+ sessionId,
2368
+ recordBilledUsage,
2369
+ }),
2220
2370
  projectDir: cwd,
2221
2371
  // settings.memories.maxCount caps memories accepted per extraction;
2222
2372
  // autoExtract=false turns the extractor off (summaries/dream stay).
@@ -2262,6 +2412,7 @@ export class Engine {
2262
2412
  toolContext: this.buildToolContext(),
2263
2413
  projectDir: opts.projectDir,
2264
2414
  sessionId: opts.sessionId,
2415
+ onUsage: opts.recordBilledUsage,
2265
2416
  });
2266
2417
  return ran;
2267
2418
  }
@@ -2648,11 +2799,39 @@ export class Engine {
2648
2799
  strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2649
2800
  };
2650
2801
  }
2651
- stripUserContextMessage(messages, userContextMsg) {
2802
+ stripInjectedContextMessages(messages, userContextMsg, dynamicContextMsg) {
2803
+ const withoutDynamicContext = dynamicContextMsg
2804
+ ? messages.filter((msg) => msg !== dynamicContextMsg)
2805
+ : [...messages];
2652
2806
  if (!userContextMsg || messages[0] !== userContextMsg) {
2653
- return [...messages];
2807
+ return withoutDynamicContext;
2808
+ }
2809
+ return withoutDynamicContext.slice(1);
2810
+ }
2811
+ recordCacheReadDiagnostics(sessionId, usage) {
2812
+ const current = usage.cacheReadTokens;
2813
+ if (current === undefined || !Number.isFinite(current))
2814
+ return;
2815
+ const previous = this.lastCacheReadBySid.get(sessionId);
2816
+ this.lastCacheReadBySid.delete(sessionId);
2817
+ this.lastCacheReadBySid.set(sessionId, current);
2818
+ if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
2819
+ const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
2820
+ if (oldestSessionId !== undefined)
2821
+ this.lastCacheReadBySid.delete(oldestSessionId);
2822
+ }
2823
+ if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
2824
+ return;
2825
+ const dropRatio = previous > 0 ? current / previous : 1;
2826
+ if (current <= CACHE_READ_DROP_MAX_CURRENT_TOKENS && dropRatio <= CACHE_READ_DROP_RATIO) {
2827
+ logger.warn("engine.cache_read_drop", {
2828
+ sessionId,
2829
+ previousCacheReadTokens: previous,
2830
+ currentCacheReadTokens: current,
2831
+ dropRatio,
2832
+ hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
2833
+ });
2654
2834
  }
2655
- return messages.slice(1);
2656
2835
  }
2657
2836
  getSettingsManager() {
2658
2837
  if (!this.settingsManager) {
@@ -2681,7 +2860,7 @@ export class Engine {
2681
2860
  }
2682
2861
  return target;
2683
2862
  }
2684
- buildPermissionConfig(mode, cwd) {
2863
+ buildPermissionConfig(mode, cwd, approvalRouter) {
2685
2864
  const rules = [...this.preset.defaultPermissionRules];
2686
2865
  // Memory tools: dream scope is the LLM's own workspace, so save/delete
2687
2866
  // there go through without prompting. user-scope save/delete have no
@@ -2734,7 +2913,16 @@ export class Engine {
2734
2913
  // every `ask` permission silently fell through to deny-all and
2735
2914
  // the user saw "Permission denied by user" with NO modal — exactly
2736
2915
  // the bug that motivated this fix.
2737
- const interactive = getInteractiveApprovalBackend();
2916
+ let interactive;
2917
+ if (approvalRouter) {
2918
+ interactive =
2919
+ this.interactiveBackends.get(approvalRouter) ??
2920
+ new InteractiveApprovalBackend(approvalRouter);
2921
+ this.interactiveBackends.set(approvalRouter, interactive);
2922
+ }
2923
+ else {
2924
+ interactive = getInteractiveApprovalBackend();
2925
+ }
2738
2926
  if (interactive.hasPromptFn()) {
2739
2927
  backend = interactive;
2740
2928
  }
@@ -2749,21 +2937,38 @@ export class Engine {
2749
2937
  return { rules, backend };
2750
2938
  }
2751
2939
  /**
2752
- * Switch permission mode at runtime. Takes effect immediately for any
2753
- * in-flight ToolExecutor (which holds a reference to the same classifier),
2754
- * and the new mode is used for any subsequent run() calls.
2940
+ * Switch permission mode at runtime. Idle updates apply immediately; busy
2941
+ * updates are committed atomically when the current run settles, so its
2942
+ * classifier and ToolContext retain the immutable start-of-run snapshot.
2755
2943
  * Session-only — does not persist to settings.
2756
2944
  */
2757
2945
  setPermissionMode(mode) {
2946
+ if (this.runInProgress) {
2947
+ this.pendingPermissionMode = mode;
2948
+ this.pendingPlanMode = mode === "plan";
2949
+ return;
2950
+ }
2951
+ this.applyPermissionState(mode, mode === "plan");
2952
+ }
2953
+ applyPermissionState(mode, planMode) {
2758
2954
  this.config = { ...this.config, permissionMode: mode };
2759
2955
  this.permissionMode = mode;
2760
- this.planMode = mode === "plan";
2956
+ this.planMode = planMode;
2761
2957
  if (this.activePermission) {
2762
2958
  const cwd = this.config.cwd ?? process.cwd();
2763
- const { rules, backend } = this.buildPermissionConfig(mode, cwd);
2959
+ const { rules, backend } = this.buildPermissionConfig(mode, cwd, this.activeApprovalRouter);
2764
2960
  this.activePermission.reconfigure(mode, backend, rules);
2765
2961
  }
2766
2962
  }
2963
+ applyPendingPermissionState() {
2964
+ if (this.pendingPermissionMode === null)
2965
+ return;
2966
+ const mode = this.pendingPermissionMode;
2967
+ const planMode = this.pendingPlanMode ?? mode === "plan";
2968
+ this.pendingPermissionMode = null;
2969
+ this.pendingPlanMode = null;
2970
+ this.applyPermissionState(mode, planMode);
2971
+ }
2767
2972
  getPermissionMode() {
2768
2973
  return this.config.permissionMode ?? "acceptEdits";
2769
2974
  }
@@ -2785,8 +2990,7 @@ export class Engine {
2785
2990
  * rule set buildPermissionConfig does, without constructing a backend.
2786
2991
  */
2787
2992
  getPermissionRules() {
2788
- return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
2789
- .rules;
2993
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd(), this.activeApprovalRouter).rules;
2790
2994
  }
2791
2995
  /**
2792
2996
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
@@ -2796,13 +3000,10 @@ export class Engine {
2796
3000
  if (value) {
2797
3001
  this.setPermissionMode("plan");
2798
3002
  }
2799
- else if (this.permissionMode === "plan") {
3003
+ else if ((this.pendingPermissionMode ?? this.permissionMode) === "plan") {
2800
3004
  // Leaving plan mode: drop back to the default.
2801
3005
  this.setPermissionMode("acceptEdits");
2802
3006
  }
2803
- else {
2804
- this.planMode = value;
2805
- }
2806
3007
  }
2807
3008
  /**
2808
3009
  * Block until a background agent's state changes (finishes / its result is