@cjhyy/code-shell-core 0.6.0-rc.9 → 0.7.0

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 (193) hide show
  1. package/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/capability-control/service.d.ts +2 -0
  5. package/dist/capability-control/service.js +4 -2
  6. package/dist/cc-orchestrator/agent-adapter.d.ts +4 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +11 -1
  8. package/dist/cc-orchestrator/codex-session-history.d.ts +14 -1
  9. package/dist/cc-orchestrator/codex-session-history.js +65 -5
  10. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  11. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  13. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  14. package/dist/cc-orchestrator/external-agent-changes.js +22 -5
  15. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  16. package/dist/cc-orchestrator/external-agent-driver.js +265 -50
  17. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  18. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  19. package/dist/cc-orchestrator/session-history.d.ts +35 -0
  20. package/dist/cc-orchestrator/session-history.js +98 -15
  21. package/dist/cli/agent-server-stdio.js +9 -2
  22. package/dist/context/compaction.d.ts +8 -1
  23. package/dist/context/compaction.js +49 -4
  24. package/dist/context/manager.d.ts +16 -2
  25. package/dist/context/manager.js +103 -19
  26. package/dist/credentials/access.d.ts +57 -0
  27. package/dist/credentials/access.js +185 -0
  28. package/dist/credentials/index.d.ts +3 -1
  29. package/dist/credentials/index.js +2 -0
  30. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  31. package/dist/credentials/inject-credential-tool.js +30 -11
  32. package/dist/credentials/oauth.d.ts +20 -0
  33. package/dist/credentials/oauth.js +114 -0
  34. package/dist/credentials/store.d.ts +1 -0
  35. package/dist/credentials/store.js +3 -1
  36. package/dist/credentials/types.d.ts +49 -3
  37. package/dist/credentials/use-credential-tool.d.ts +9 -1
  38. package/dist/credentials/use-credential-tool.js +58 -45
  39. package/dist/engine/engine.d.ts +23 -3
  40. package/dist/engine/engine.js +380 -255
  41. package/dist/engine/goal.d.ts +17 -0
  42. package/dist/engine/goal.js +16 -6
  43. package/dist/engine/image-policy.d.ts +6 -0
  44. package/dist/engine/image-policy.js +17 -6
  45. package/dist/engine/input-attachments.d.ts +13 -0
  46. package/dist/engine/input-attachments.js +398 -0
  47. package/dist/engine/model-facade.d.ts +5 -2
  48. package/dist/engine/model-facade.js +4 -4
  49. package/dist/engine/parse-task.d.ts +10 -0
  50. package/dist/engine/parse-task.js +5 -0
  51. package/dist/engine/run-image-input.d.ts +22 -0
  52. package/dist/engine/run-image-input.js +195 -0
  53. package/dist/engine/steer-queue.d.ts +3 -1
  54. package/dist/engine/steer-queue.js +10 -2
  55. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  56. package/dist/engine/streaming-tool-queue.js +11 -7
  57. package/dist/engine/turn-loop.d.ts +37 -2
  58. package/dist/engine/turn-loop.js +228 -43
  59. package/dist/engine/types.d.ts +8 -0
  60. package/dist/git/worktree/crud.d.ts +69 -0
  61. package/dist/git/worktree/crud.js +206 -0
  62. package/dist/git/worktree/diff.d.ts +14 -0
  63. package/dist/git/worktree/diff.js +82 -0
  64. package/dist/git/worktree/git-exec.d.ts +7 -0
  65. package/dist/git/worktree/git-exec.js +51 -0
  66. package/dist/git/worktree/index.d.ts +5 -0
  67. package/dist/git/worktree/index.js +5 -0
  68. package/dist/git/worktree/query.d.ts +42 -0
  69. package/dist/git/worktree/query.js +121 -0
  70. package/dist/git/worktree/slug.d.ts +11 -0
  71. package/dist/git/worktree/slug.js +58 -0
  72. package/dist/git/worktree.d.ts +1 -84
  73. package/dist/git/worktree.js +5 -230
  74. package/dist/hooks/goal-stop-hook.d.ts +33 -1
  75. package/dist/hooks/goal-stop-hook.js +202 -34
  76. package/dist/index.d.ts +31 -28
  77. package/dist/index.js +27 -25
  78. package/dist/logging/logger.js +6 -6
  79. package/dist/logging/sanitize-messages.d.ts +10 -2
  80. package/dist/logging/sanitize-messages.js +21 -6
  81. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  82. package/dist/plugins/installer/checkUpdate.js +4 -2
  83. package/dist/plugins/installer/install.js +2 -0
  84. package/dist/plugins/installer/installFromSource.js +9 -1
  85. package/dist/plugins/installer/parseSource.d.ts +4 -1
  86. package/dist/plugins/installer/parseSource.js +28 -10
  87. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  88. package/dist/plugins/installer/sourcePath.js +50 -0
  89. package/dist/plugins/installer/update.d.ts +4 -1
  90. package/dist/plugins/installer/update.js +5 -3
  91. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  92. package/dist/plugins/parseMarketplaceInput.js +4 -3
  93. package/dist/plugins/pluginInstaller.js +24 -22
  94. package/dist/preset/index.d.ts +1 -0
  95. package/dist/preset/index.js +30 -13
  96. package/dist/prompt/sections/base.md +1 -1
  97. package/dist/protocol/chat-session-manager.d.ts +2 -0
  98. package/dist/protocol/chat-session-manager.js +38 -2
  99. package/dist/protocol/chat-session.d.ts +3 -0
  100. package/dist/protocol/chat-session.js +1 -0
  101. package/dist/protocol/client.d.ts +9 -4
  102. package/dist/protocol/client.js +18 -1
  103. package/dist/protocol/server.d.ts +30 -0
  104. package/dist/protocol/server.js +247 -47
  105. package/dist/protocol/types.d.ts +49 -0
  106. package/dist/protocol/types.js +6 -0
  107. package/dist/run/FileRunStore.js +10 -1
  108. package/dist/run/Heartbeat.js +12 -0
  109. package/dist/run/RunApprovalBackend.d.ts +3 -0
  110. package/dist/run/RunApprovalBackend.js +41 -6
  111. package/dist/run/RunLock.js +2 -0
  112. package/dist/run/RunManager.d.ts +2 -0
  113. package/dist/run/RunManager.js +64 -24
  114. package/dist/run/ids.d.ts +2 -0
  115. package/dist/run/ids.js +23 -0
  116. package/dist/runtime/background-shell.d.ts +1 -0
  117. package/dist/runtime/background-shell.js +23 -13
  118. package/dist/runtime/spawn-common.js +10 -0
  119. package/dist/services/auto-dream.d.ts +15 -4
  120. package/dist/services/auto-dream.js +20 -20
  121. package/dist/services/dream-consolidation.d.ts +2 -3
  122. package/dist/services/dream-consolidation.js +65 -15
  123. package/dist/services/extract-memories.d.ts +14 -5
  124. package/dist/services/extract-memories.js +20 -3
  125. package/dist/services/global-dream-promotion.d.ts +23 -0
  126. package/dist/services/global-dream-promotion.js +112 -0
  127. package/dist/services/memory-orchestrator.js +347 -34
  128. package/dist/session/memory.d.ts +61 -18
  129. package/dist/session/memory.js +342 -79
  130. package/dist/session/session-manager.d.ts +35 -1
  131. package/dist/session/session-manager.js +224 -4
  132. package/dist/session/transcript.d.ts +1 -1
  133. package/dist/session/transcript.js +17 -5
  134. package/dist/settings/manager.d.ts +1 -0
  135. package/dist/settings/manager.js +87 -37
  136. package/dist/settings/schema-export.d.ts +2 -3
  137. package/dist/settings/schema-export.js +2 -3
  138. package/dist/settings/schema.d.ts +21 -0
  139. package/dist/settings/schema.js +12 -0
  140. package/dist/skills/scanner.d.ts +3 -2
  141. package/dist/skills/scanner.js +12 -9
  142. package/dist/tool-system/builtin/agent-notifications.d.ts +11 -4
  143. package/dist/tool-system/builtin/agent-notifications.js +19 -7
  144. package/dist/tool-system/builtin/background-jobs.d.ts +35 -6
  145. package/dist/tool-system/builtin/background-jobs.js +116 -7
  146. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  147. package/dist/tool-system/builtin/background-work.js +51 -5
  148. package/dist/tool-system/builtin/config.d.ts +2 -1
  149. package/dist/tool-system/builtin/config.js +16 -11
  150. package/dist/tool-system/builtin/cron-list.definition.d.ts +3 -0
  151. package/dist/tool-system/builtin/cron-list.definition.js +6 -0
  152. package/dist/tool-system/builtin/cron.d.ts +1 -2
  153. package/dist/tool-system/builtin/cron.js +9 -7
  154. package/dist/tool-system/builtin/drive-claude-code.d.ts +23 -2
  155. package/dist/tool-system/builtin/drive-claude-code.js +589 -48
  156. package/dist/tool-system/builtin/edit.js +5 -2
  157. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  158. package/dist/tool-system/builtin/generate-video.js +13 -4
  159. package/dist/tool-system/builtin/index.d.ts +8 -3
  160. package/dist/tool-system/builtin/index.js +50 -22
  161. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  162. package/dist/tool-system/builtin/lsp.js +6 -3
  163. package/dist/tool-system/builtin/memory.js +40 -8
  164. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  165. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  166. package/dist/tool-system/builtin/powershell.js +11 -7
  167. package/dist/tool-system/builtin/read.js +118 -6
  168. package/dist/tool-system/builtin/sleep.d.ts +1 -2
  169. package/dist/tool-system/builtin/sleep.definition.d.ts +8 -0
  170. package/dist/tool-system/builtin/sleep.definition.js +28 -0
  171. package/dist/tool-system/builtin/sleep.js +1 -22
  172. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  173. package/dist/tool-system/builtin/view-image.js +109 -19
  174. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  175. package/dist/tool-system/builtin/worktree.js +297 -74
  176. package/dist/tool-system/builtin/write.js +5 -3
  177. package/dist/tool-system/context.d.ts +41 -1
  178. package/dist/tool-system/executor.d.ts +1 -5
  179. package/dist/tool-system/executor.js +94 -115
  180. package/dist/tool-system/mcp-manager.d.ts +31 -6
  181. package/dist/tool-system/mcp-manager.js +193 -78
  182. package/dist/tool-system/path-policy.d.ts +2 -0
  183. package/dist/tool-system/path-policy.js +41 -12
  184. package/dist/tool-system/permission.d.ts +28 -7
  185. package/dist/tool-system/permission.js +130 -49
  186. package/dist/tool-system/registry.js +11 -4
  187. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  188. package/dist/tool-system/tool-result-redaction.js +48 -0
  189. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  190. package/dist/tool-system/workspace-bridge.js +1 -0
  191. package/dist/types.d.ts +78 -11
  192. package/dist/utils/toolDisplay.js +1 -1
  193. package/package.json +4 -3
@@ -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";
@@ -34,34 +34,35 @@ import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
34
34
  import { PromptComposer } from "../prompt/composer.js";
35
35
  import { SessionManager } from "../session/session-manager.js";
36
36
  import { ModelFacade } from "./model-facade.js";
37
- import { logger, setCurrentSid, runWithSid, getCurrentSid } from "../logging/logger.js";
37
+ import { logger, runWithSid, getCurrentSid } from "../logging/logger.js";
38
38
  import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
39
39
  import { sanitizeContent, sanitizeTaskString } from "../logging/sanitize-messages.js";
40
40
  import { TurnLoop } from "./turn-loop.js";
41
41
  import { MCPManager } from "../tool-system/mcp-manager.js";
42
42
  import { SettingsManager, userHome } from "../settings/manager.js";
43
- import { CredentialStore } from "../credentials/store.js";
43
+ import { getCredentialAccess } from "../credentials/access.js";
44
44
  import { isFeatureEnabled, resolveFeatureFlags, } from "../settings/feature-flags.js";
45
- import { effectiveDisabledList, effectiveBuiltinLists, } from "../capability-control/overlay.js";
45
+ import { effectiveDisabledList, effectiveBuiltinLists } from "../capability-control/overlay.js";
46
46
  import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
47
47
  import { FileHistory } from "../session/file-history.js";
48
48
  import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
49
49
  import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
50
- import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
50
+ import { resolveAgentPreset, resolveBuiltinToolNames } from "../preset/index.js";
51
51
  import { ModelPool } from "../llm/model-pool.js";
52
52
  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 { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
58
- import { tryCompressImages } from "./image-compression.js";
59
56
  import { buildSessionTitle } from "./session-title.js";
60
- import { capabilitiesFor } from "../llm/capabilities/index.js";
61
57
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
62
58
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
63
- import { join, isAbsolute } from "node:path";
59
+ import { buildRunUserMessageContent, prepareRunImageInput } from "./run-image-input.js";
60
+ import { join } from "node:path";
64
61
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
62
+ const CACHE_READ_DROP_MIN_PREVIOUS_TOKENS = 100;
63
+ const CACHE_READ_DROP_MAX_CURRENT_TOKENS = 64;
64
+ const CACHE_READ_DROP_RATIO = 0.1;
65
+ const CACHE_READ_DIAGNOSTIC_MAX_SESSIONS = 256;
65
66
  /**
66
67
  * Build ScanOptions.compatFileNames from the user's instruction compat toggles.
67
68
  * Primary file name stays hard-wired to CODESHELL.md (not exposed). Turning a
@@ -129,7 +130,7 @@ export function resolveChildLlm(modelKey, pool, parentLlm) {
129
130
  * Names in `disabledAgents` are filtered out so the LLM never sees them.
130
131
  */
131
132
  /**
132
- * Resolve the working directory for a run. Precedence:
133
+ * Resolve the working directory for a run. Precedence for legacy sessions:
133
134
  * options.cwd > resumed session's state.cwd > config.cwd > process.cwd()
134
135
  *
135
136
  * The session-cwd tier is what stops a project-bound session from being
@@ -265,6 +266,7 @@ export class Engine {
265
266
  * LLM response arrives.
266
267
  */
267
268
  ctxOverheadBySid = new Map();
269
+ lastCacheReadBySid = new Map();
268
270
  /**
269
271
  * Step-gap steering queue (per sessionId, in-memory). Host pushes user
270
272
  * messages here via enqueueSteer while a run is in flight; the turn loop
@@ -445,6 +447,7 @@ export class Engine {
445
447
  new ToolRegistry({
446
448
  builtinTools: resolveBuiltinToolNames({
447
449
  preset: this.preset.name,
450
+ host: config.builtinToolHost,
448
451
  enabledBuiltinTools: builtinLists.enabledBuiltinTools,
449
452
  disabledBuiltinTools: builtinLists.disabledBuiltinTools,
450
453
  }),
@@ -635,6 +638,10 @@ export class Engine {
635
638
  setBrowserBridge(bridge) {
636
639
  this.config.browserBridge = bridge;
637
640
  }
641
+ /** Inject the host-backed workspace bridge after construction. */
642
+ setWorkspaceBridge(bridge) {
643
+ this.config.workspaceBridge = bridge;
644
+ }
638
645
  /**
639
646
  * Queue a user message to be spliced into the in-flight run for `sessionId`
640
647
  * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
@@ -648,7 +655,7 @@ export class Engine {
648
655
  * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
649
656
  * entry. A blank id is tolerated but means the entry can't be revoked.
650
657
  */
651
- enqueueSteer(sessionId, text, id = "", clientMessageId) {
658
+ enqueueSteer(sessionId, text, id = "", clientMessageId, attachments) {
652
659
  const q = this.steerQueueBySid.get(sessionId) ?? [];
653
660
  const entryId = id || `steer-${q.length}`;
654
661
  if (!sessionId)
@@ -660,12 +667,13 @@ export class Engine {
660
667
  sessionId,
661
668
  id: entryId,
662
669
  clientMessageId,
670
+ attachmentCount: attachments?.length ?? 0,
663
671
  activeRunSessionId: activeRunSessionId ?? null,
664
672
  queueLength: q.length,
665
673
  });
666
674
  return { accepted: false, id: entryId };
667
675
  }
668
- const next = enqueueSteerItem(q, entryId, text, clientMessageId);
676
+ const next = enqueueSteerItem(q, entryId, text, clientMessageId, attachments);
669
677
  if (next === q)
670
678
  return { accepted: false, id: entryId }; // blank text dropped
671
679
  this.steerQueueBySid.set(sessionId, next);
@@ -673,6 +681,7 @@ export class Engine {
673
681
  sessionId,
674
682
  id: entryId,
675
683
  clientMessageId,
684
+ attachmentCount: attachments?.length ?? 0,
676
685
  activeRunSessionId,
677
686
  queueLength: next.length,
678
687
  });
@@ -709,6 +718,13 @@ export class Engine {
709
718
  });
710
719
  return drained;
711
720
  }
721
+ /** Put failed steer preparation back ahead of messages queued while it was being prepared. */
722
+ restoreSteer(sessionId, items) {
723
+ if (items.length === 0)
724
+ return;
725
+ const queued = this.steerQueueBySid.get(sessionId) ?? [];
726
+ this.steerQueueBySid.set(sessionId, [...items, ...queued]);
727
+ }
712
728
  /** Wire the cookie→browser injection callback (InjectCredential tool). Same
713
729
  * post-construction injection model as setBrowserBridge. */
714
730
  setInjectCredential(fn) {
@@ -735,20 +751,45 @@ export class Engine {
735
751
  * Run a task from start to finish.
736
752
  */
737
753
  async run(task, options) {
738
- // When the caller omits cwd but is resuming an existing session, recover
739
- // that session's bound cwd from disk so a project-bound session keeps
740
- // loading its own agents/settings/memory even if the host's UI repo
741
- // selection has drifted to null. Only probe on omission — an explicit cwd
742
- // always wins, and a fresh session has nothing to recover.
743
- const sessionCwd = options?.cwd === undefined && options?.sessionId
744
- ? this.sessionManager.readCwd(options.sessionId)
754
+ const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
755
+ ? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
745
756
  : undefined;
746
- const cwd = resolveRunCwd({
747
- optionCwd: options?.cwd,
748
- sessionCwd,
749
- configCwd: this.config.cwd,
750
- processCwd: process.cwd(),
751
- });
757
+ if (workspaceResume && !workspaceResume.ok) {
758
+ return {
759
+ text: `ERROR: ${workspaceResume.message}`,
760
+ reason: "completed",
761
+ sessionId: options.sessionId,
762
+ turnCount: 0,
763
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
764
+ };
765
+ }
766
+ if (workspaceResume?.ok &&
767
+ workspaceResume.reason === "worktree_missing_branch_gone" &&
768
+ workspaceResume.message) {
769
+ return {
770
+ text: workspaceResume.message,
771
+ reason: "completed",
772
+ sessionId: options.sessionId,
773
+ turnCount: 0,
774
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
775
+ };
776
+ }
777
+ // Existing P1 sessions resolve cwd from SessionWorkspace, even if the host
778
+ // passes a stale cwd. Legacy sessions without workspace keep the historical
779
+ // explicit-cwd precedence for backward compatibility.
780
+ const workspaceCwd = workspaceResume?.ok && workspaceResume.reason !== "legacy" ? workspaceResume.cwd : undefined;
781
+ const sessionCwd = workspaceCwd === undefined && options?.cwd === undefined && options?.sessionId
782
+ ? workspaceResume?.ok
783
+ ? workspaceResume.cwd
784
+ : this.sessionManager.readCwd(options.sessionId)
785
+ : undefined;
786
+ const cwd = workspaceCwd ??
787
+ resolveRunCwd({
788
+ optionCwd: options?.cwd,
789
+ sessionCwd,
790
+ configCwd: this.config.cwd,
791
+ processCwd: process.cwd(),
792
+ });
752
793
  // Wrap the caller's onStream so we can intercept `task_update`
753
794
  // events emitted by TodoWrite and keep an in-engine snapshot.
754
795
  // TaskGuard reads this snapshot at turn end to decide whether to
@@ -776,115 +817,16 @@ export class Engine {
776
817
  };
777
818
  if (options)
778
819
  options.onStream = wrappedOnStream;
779
- // ── P2-6: image input ─────────────────────────────────────────────
780
- // Parse `<codeshell-image>` blocks out of the raw task string before
781
- // any other gate looks at it. Two concerns:
782
- // 1. The noise detector below sees the raw base64 as gibberish and
783
- // would reject the whole turn — split images out first so it
784
- // only inspects the prose portion.
785
- // 2. Models that don't accept vision must be refused immediately,
786
- // with the image bytes intact for the user to retry on another
787
- // model. Silent text-only fallback was the failure mode this
788
- // gate is here to prevent.
789
- let parsedTask;
790
- try {
791
- parsedTask = parseTaskWithImages(task);
792
- }
793
- catch (err) {
794
- const msg = err.message;
795
- logger.warn("engine.run.image_parse_failed", { error: msg });
796
- return {
797
- text: `ERROR: image attachment is malformed (${msg}). Drop the image and try again, or re-attach it.`,
798
- reason: "image_error",
799
- sessionId: options?.sessionId ?? "image-parse-failed",
800
- turnCount: 0,
801
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
802
- };
803
- }
804
- if (parsedTask.hasImages) {
805
- const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
806
- if (!cap.supportsVision) {
807
- logger.warn("engine.run.vision_not_supported", {
808
- provider: this.config.llm.provider,
809
- model: this.config.llm.model,
810
- imageCount: parsedTask.images.length,
811
- });
812
- return {
813
- text: `ERROR: model "${this.config.llm.model}" does not accept image input. ` +
814
- `Switch to a vision-capable model (e.g. gpt-4o, claude-sonnet, gemini-1.5-pro) and resend.`,
815
- reason: "image_error",
816
- sessionId: options?.sessionId ?? "vision-not-supported",
817
- turnCount: 0,
818
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
819
- };
820
- }
821
- // Size gate. Hosts (desktop renderer, TUI) are expected to
822
- // pre-compress to IMAGE_TARGETS — if they didn't, we fail the turn
823
- // fast with a clear message instead of letting the OpenAI client
824
- // grind through three 16-second "Connection error" retries on a
825
- // 4 MB body. See `image-policy.ts` for the rationale and limits.
826
- let verdict = enforceImagePolicy(parsedTask.images);
827
- if (!verdict.ok && verdict.code === "image_too_large") {
828
- // One image blew the per-image cap. Try the engine-side
829
- // compressor (jimp-backed when installed; no-op otherwise) so
830
- // TUI / MCP paths that lack a host-side resize don't fail
831
- // outright on a screenshot they could have rescaled. The
832
- // re-check below is what decides whether we proceed.
833
- const compressed = await tryCompressImages(parsedTask.images);
834
- if (compressed.anyCompressed) {
835
- parsedTask.images = compressed.images;
836
- logger.info("engine.run.image_compressed", {
837
- before: verdict.offender?.bytes,
838
- after: compressed.images.reduce((s, i) => s + byteLengthFromBase64(i.base64), 0),
839
- });
840
- verdict = enforceImagePolicy(parsedTask.images);
841
- }
842
- }
843
- // After compression, anything still over the per-image cap is
844
- // dropped with a textual placeholder instead of failing the
845
- // turn (TODO-week.md #9e). The "5MB brick session" failure
846
- // mode from Claude Code (research doc §A) was the case where a
847
- // poisoned image entered history and every subsequent request
848
- // re-sent it; placeholders keep history clean while letting
849
- // the rest of the turn run.
850
- if (!verdict.ok && verdict.code === "image_too_large") {
851
- const drop = dropOversizedImages(parsedTask.images);
852
- if (drop.droppedCount > 0) {
853
- parsedTask.images = drop.kept;
854
- parsedTask.hasImages = drop.kept.length > 0;
855
- parsedTask.text = drop.placeholder + "\n\n" + parsedTask.text;
856
- logger.warn("engine.run.image_dropped", {
857
- droppedCount: drop.droppedCount,
858
- keptCount: drop.kept.length,
859
- });
860
- verdict = enforceImagePolicy(parsedTask.images);
861
- }
862
- }
863
- if (!verdict.ok) {
864
- // Cumulative / count caps can't be rescued by per-image
865
- // dropping (well — too_many_images could trim by FIFO, but
866
- // that's a bigger UX call than we want to make silently).
867
- // Refuse the turn with the policy message.
868
- logger.warn("engine.run.image_policy_failed", {
869
- code: verdict.code,
870
- imageCount: verdict.totals.imageCount,
871
- totalBytes: verdict.totals.totalBytes,
872
- offender: verdict.offender,
873
- });
874
- return {
875
- text: `ERROR: ${verdict.message}`,
876
- reason: "image_error",
877
- sessionId: options?.sessionId ?? `image-policy-${verdict.code}`,
878
- turnCount: 0,
879
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
880
- };
881
- }
882
- }
883
- // For downstream noise-detection + transcript persistence we want the
884
- // *text* portion only — base64 bytes count as "noise" by the heuristic
885
- // and would also bloat the transcript by megabytes per image. Image
886
- // bytes ride in parsedTask.images and re-enter the message tree below.
887
- const taskText = parsedTask.hasImages ? parsedTask.text : task;
820
+ const imageInput = await prepareRunImageInput({
821
+ task,
822
+ cwd,
823
+ llm: this.config.llm,
824
+ sessionId: options?.sessionId,
825
+ attachments: options?.attachments,
826
+ });
827
+ if (!imageInput.ok)
828
+ return imageInput.result;
829
+ const { parsedTask, taskText } = imageInput;
888
830
  const noise = detectPastedNoise(taskText);
889
831
  if (noise.isNoise) {
890
832
  const hint = `Your input looks like pasted terminal output (${noise.reason}). ` +
@@ -946,6 +888,7 @@ export class Engine {
946
888
  preset: this.preset.name,
947
889
  enabledBuiltinTools: childEnabled,
948
890
  disabledBuiltinTools: childDisabled,
891
+ builtinToolHost: this.config.builtinToolHost,
949
892
  customSystemPrompt: this.config.customSystemPrompt,
950
893
  appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
951
894
  undefined,
@@ -1013,23 +956,7 @@ export class Engine {
1013
956
  },
1014
957
  sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
1015
958
  };
1016
- // Priority: config.sandbox → project settings.sandbox → global → per-run
1017
- // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
1018
- // sandbox genuinely follows global, rather than inheriting global's mode and
1019
- // looking like it set one. Fixes "项目级配了不生效" + the scope model.
1020
- let projectSandbox;
1021
- let globalSandbox;
1022
- try {
1023
- const sm = this.getSettingsManager();
1024
- if (this.config.isSubAgent !== true) {
1025
- projectSandbox = sm.getForScope("project", cwd).sandbox;
1026
- }
1027
- globalSandbox = sm.getForScope("user").sandbox;
1028
- }
1029
- catch {
1030
- // settings unavailable → fall through to per-run default
1031
- }
1032
- const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
959
+ const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
1033
960
  // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
1034
961
  // per standard §S4. resolveSandboxBackend throws when an explicit
1035
962
  // mode is unavailable on this host; we let it propagate. The
@@ -1070,12 +997,16 @@ export class Engine {
1070
997
  ? sandboxBackend
1071
998
  : { ...sandboxBackend, network: sandboxConfig.network },
1072
999
  cwd,
1000
+ shellEnv: this.readShellEnv(cwd),
1073
1001
  // TodoWrite reads this to push task_update events independently
1074
1002
  // of its return value, so the UI's pinned task panel refreshes
1075
1003
  // immediately rather than after the LLM next surfaces the
1076
1004
  // snapshot. wrappedOnStream snoops the same channel to keep
1077
1005
  // latestTodos current for TaskGuard.
1078
1006
  streamCallback: options?.onStream,
1007
+ setCwd(nextCwd) {
1008
+ toolCtx.cwd = nextCwd;
1009
+ },
1079
1010
  };
1080
1011
  logger.info("engine.run", {
1081
1012
  task: taskText.slice(0, 200),
@@ -1084,40 +1015,7 @@ export class Engine {
1084
1015
  preset: this.preset.name,
1085
1016
  imageCount: parsedTask.images.length,
1086
1017
  });
1087
- // Compose the user-turn payload once so resume + cold paths agree on
1088
- // shape. With images, content becomes a ContentBlock[] holding one
1089
- // text block (when prose is present) followed by one image block per
1090
- // attachment — the provider-specific clients translate this to OpenAI
1091
- // `image_url` or Anthropic `{type:image, source:base64}` downstream.
1092
- // When an attached image came from a workspace FILE (the desktop composer's
1093
- // path-attach flow sets ParsedImage.name = the absolute path), surface that
1094
- // path to the model as text. The image bytes still ride along for vision,
1095
- // but tools that operate on files — GenerateImage(referenceImages),
1096
- // Read, etc. — need the on-disk path, not just the pixels. Without this the
1097
- // path the composer already knew was silently dropped, and the model would
1098
- // answer "图片没落到项目文件夹,找不到路径" (the seedance 图生图 dead-end).
1099
- // Only names that resolve to an existing file qualify; a pasted screenshot
1100
- // whose name is just "screenshot.png" is not a path and is left out.
1101
- const attachedPaths = collectAttachedImagePaths(parsedTask.images, (name) => (isAbsolute(name) ? name : join(cwd, name)), existsSync);
1102
- const pathHint = attachedPaths.length > 0
1103
- ? `\n\n<attached-image-paths>\n${attachedPaths.join("\n")}\n</attached-image-paths>\n` +
1104
- `(上面附带的图片在工作区的真实路径,如需把它们作为工具输入(例如 GenerateImage 的 referenceImages、图生图参考图),直接使用这些路径。)`
1105
- : "";
1106
- const userMessageContent = parsedTask.hasImages
1107
- ? [
1108
- ...(parsedTask.text || pathHint
1109
- ? [{ type: "text", text: `${parsedTask.text}${pathHint}` }]
1110
- : []),
1111
- ...parsedTask.images.map((img) => ({
1112
- type: "image",
1113
- source: {
1114
- type: "base64",
1115
- media_type: img.mime,
1116
- data: img.base64,
1117
- },
1118
- })),
1119
- ]
1120
- : taskText;
1018
+ const userMessageContent = buildRunUserMessageContent(parsedTask, cwd, taskText);
1121
1019
  // Create or resume session.
1122
1020
  //
1123
1021
  // Three valid shapes:
@@ -1134,6 +1032,7 @@ export class Engine {
1134
1032
  let session;
1135
1033
  let messages;
1136
1034
  let freshImageMessage;
1035
+ let resumedFromDisk = false;
1137
1036
  const claimedClientMessageIds = new Set();
1138
1037
  const claimClientMessageId = (bundle, clientMessageId, source) => {
1139
1038
  if (!clientMessageId)
@@ -1151,6 +1050,7 @@ export class Engine {
1151
1050
  return true;
1152
1051
  };
1153
1052
  if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
1053
+ resumedFromDisk = true;
1154
1054
  session = this.sessionManager.resume(options.sessionId);
1155
1055
  const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
1156
1056
  messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
@@ -1233,28 +1133,22 @@ export class Engine {
1233
1133
  // with this value so `/undo` reverts exactly this turn's file changes.
1234
1134
  // (Both resume and cold-start paths converge here.)
1235
1135
  session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
1236
- // Stamp the resolved session id for downstream logging.
1237
- //
1238
- // `setCurrentSid` updates the module-level fallback so any code path
1239
- // running outside an ALS scope (bootstrap, /sid before a run starts)
1240
- // still sees the latest sid.
1241
- //
1242
- // The real isolation comes from wrapping the rest of `run` in
1243
- // `runWithSid(sid, async () => { ... })`: every `getCurrentSid()`
1244
- // call inside that closure — including those inside `await`ed child
1245
- // Engine.run() calls — reads sid from this scope's
1246
- // AsyncLocalStorage binding, not the module global. Sibling Engines
1247
- // running concurrently each get their own scope; an `enterSid` inside
1248
- // a child mutates that child's scope only and doesn't leak back to
1249
- // the parent's chain after `await child.run(...)` returns.
1250
- setCurrentSid(session.state.sessionId);
1251
1136
  // B2 / Gate 1: stamp the resolved sid onto the tool context so
1252
1137
  // session-scoped side effects (background-agent completion
1253
1138
  // notifications) attribute to the right session. toolCtx is created
1254
1139
  // before the session bundle is resolved (see ~line 635), so this is
1255
1140
  // the first point we can set it. After this assignment treat the
1256
- // field as immutable for the rest of the run.
1141
+ // field follows the latest successfully injected user intent for the rest
1142
+ // of the run, so tools launched after a steer attribute their side effects
1143
+ // to that steer rather than this original submit.
1257
1144
  toolCtx.sessionId = session.state.sessionId;
1145
+ toolCtx.originClientMessageId = options?.clientMessageId;
1146
+ toolCtx.recordExternalFileChanges = (record) => {
1147
+ session.transcript.append("external_file_changes", { ...record });
1148
+ };
1149
+ toolCtx.setSessionWorkspace = (workspace) => {
1150
+ session.state.workspace = workspace;
1151
+ };
1258
1152
  return runWithSid(session.state.sessionId, async () => {
1259
1153
  recordSessionStart(session.state.sessionId, {
1260
1154
  // Strip <codeshell-image> base64 payloads before they reach
@@ -1265,7 +1159,7 @@ export class Engine {
1265
1159
  model: this.config.llm.model,
1266
1160
  provider: this.config.llm.provider,
1267
1161
  permissionMode: this.config.permissionMode ?? "acceptEdits",
1268
- resumed: !!options?.sessionId,
1162
+ resumed: resumedFromDisk,
1269
1163
  });
1270
1164
  // Session-level hook: fired once per Engine.run() entry, regardless of
1271
1165
  // cold-start vs resume. Handlers can return `messages` to inject a
@@ -1275,7 +1169,8 @@ export class Engine {
1275
1169
  const sessionStartHook = await this.emitHook("on_session_start", {
1276
1170
  sessionId: session.state.sessionId,
1277
1171
  cwd,
1278
- resumed: !!options?.sessionId,
1172
+ resumed: resumedFromDisk,
1173
+ source: resumedFromDisk ? "resume" : "startup",
1279
1174
  });
1280
1175
  // Per-turn hook: fired every time a new user prompt enters the loop.
1281
1176
  // Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
@@ -1289,7 +1184,7 @@ export class Engine {
1289
1184
  // and silently leaking attachment bytes through hooks is the kind of
1290
1185
  // exfiltration risk a curious user-installed shell hook shouldn't carry.
1291
1186
  prompt: taskText,
1292
- resumed: !!options?.sessionId,
1187
+ resumed: resumedFromDisk,
1293
1188
  });
1294
1189
  // updatedPrompt: handler rewrote the user's prompt text. Replace the
1295
1190
  // last user message we just pushed (cold-start: line ~511; resume:
@@ -1307,22 +1202,49 @@ export class Engine {
1307
1202
  messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
1308
1203
  }
1309
1204
  }
1310
- // Rough token estimate of the full prompt so the UI's ctx bar isn't 0%
1311
- // before the first real usage_update arrives. The authoritative count
1205
+ const contextManager = new ContextManager({
1206
+ maxTokens: this.resolveMaxContextTokens(),
1207
+ // Drop undefined fields so they don't clobber ContextManager defaults
1208
+ // (spread of `{x: undefined}` would override the default with undefined).
1209
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1210
+ });
1211
+ this.lastContextManager = contextManager;
1212
+ const persistedContextAnchor = session.state.contextUsageAnchor;
1213
+ const contextAnchorCompatible = persistedContextAnchor !== undefined &&
1214
+ (persistedContextAnchor.provider === undefined ||
1215
+ persistedContextAnchor.provider === this.config.llm.provider) &&
1216
+ (persistedContextAnchor.model === undefined ||
1217
+ persistedContextAnchor.model === this.config.llm.model) &&
1218
+ (persistedContextAnchor.messageCount <= messages.length ||
1219
+ persistedContextAnchor.estimateAtAnchor !== undefined);
1220
+ if (contextAnchorCompatible) {
1221
+ contextManager.seedActualUsage(persistedContextAnchor);
1222
+ }
1223
+ // Best-effort token estimate of the full prompt so the UI's ctx bar isn't
1224
+ // 0% before the first real usage_update arrives. The authoritative count
1312
1225
  // comes from `usage.promptTokens` after the first LLM response — this is
1313
- // just a display-friendly approximation for the first frame.
1226
+ // just a display-friendly approximation for the first frame, annotated
1227
+ // with source/confidence so consumers don't treat heuristics as truth.
1314
1228
  //
1315
1229
  // Only seed once per (process, sid). On subsequent turns the UI already
1316
- // shows the previous turn's accurate ctx; overwriting it with this rough
1317
- // char/4 estimate would make the bar visibly drop on every submit.
1230
+ // shows the previous turn's accurate ctx; overwriting it with a fresh
1231
+ // best-effort estimate would make the bar visibly drop on every submit.
1318
1232
  const sid = session.state.sessionId;
1319
1233
  const needsCtxSeed = !this.ctxSeedSent.has(sid);
1320
- const roughPromptTokens = needsCtxSeed
1321
- ? messages.reduce((sum, m) => {
1322
- const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
1323
- return sum + Math.ceil(text.length / 4);
1324
- }, 0)
1325
- : 0;
1234
+ const ctxSeed = needsCtxSeed
1235
+ ? (() => {
1236
+ const checked = contextManager.checkLimits(messages);
1237
+ return {
1238
+ tokens: checked.tokens,
1239
+ source: checked.promptTokensSource,
1240
+ confidence: checked.promptTokensConfidence,
1241
+ };
1242
+ })()
1243
+ : {
1244
+ tokens: 0,
1245
+ source: "heuristic_estimate",
1246
+ confidence: "low",
1247
+ };
1326
1248
  if (needsCtxSeed)
1327
1249
  this.ctxSeedSent.add(sid);
1328
1250
  // Tell the client the sid *now* instead of waiting for run() to resolve.
@@ -1331,7 +1253,9 @@ export class Engine {
1331
1253
  options?.onStream?.({
1332
1254
  type: "session_started",
1333
1255
  sessionId: sid,
1334
- promptTokens: roughPromptTokens,
1256
+ promptTokens: ctxSeed.tokens,
1257
+ promptTokensSource: ctxSeed.source,
1258
+ promptTokensConfidence: ctxSeed.confidence,
1335
1259
  });
1336
1260
  // Replay the last TodoWrite snapshot on resume so the UI's pinned
1337
1261
  // task panel re-hydrates without the LLM needing to call TodoWrite
@@ -1358,12 +1282,14 @@ export class Engine {
1358
1282
  // in this same session don't re-prompt. Headless/auto backends skip
1359
1283
  // this — they don't prompt, so there are no project rules to persist.
1360
1284
  if (approvalBackend instanceof InteractiveApprovalBackend) {
1361
- approvalBackend.setCwd(cwd);
1362
- approvalBackend.setOnProjectRules((rules) => {
1363
- // Prepend the *full* accumulated list of session-saved project rules
1364
- // so user approvals win over defaults and earlier approvals aren't
1365
- // dropped when later ones come in.
1366
- permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1285
+ approvalBackend.setSessionContext(session.state.sessionId, {
1286
+ cwd,
1287
+ onProjectRules: (rules) => {
1288
+ // Prepend the *full* accumulated list of session-saved project rules
1289
+ // so user approvals win over defaults and earlier approvals aren't
1290
+ // dropped when later ones come in.
1291
+ permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1292
+ },
1367
1293
  });
1368
1294
  }
1369
1295
  const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
@@ -1379,13 +1305,6 @@ export class Engine {
1379
1305
  // Wire abort signal for cascading cancellation + per-Engine ToolContext
1380
1306
  toolExecutor.setSignal(options?.signal);
1381
1307
  toolExecutor.setContext(toolCtx);
1382
- const contextManager = new ContextManager({
1383
- maxTokens: this.resolveMaxContextTokens(),
1384
- // Drop undefined fields so they don't clobber ContextManager defaults
1385
- // (spread of `{x: undefined}` would override the default with undefined).
1386
- ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1387
- });
1388
- this.lastContextManager = contextManager;
1389
1308
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
1390
1309
  const promptComposer = new PromptComposer({
1391
1310
  cwd,
@@ -1447,6 +1366,7 @@ export class Engine {
1447
1366
  (normalizeGoal(options?.goal) !== undefined ||
1448
1367
  session.state.activeGoal !== undefined ||
1449
1368
  normalizeGoal(this.config.goal) !== undefined),
1369
+ settingsScope: this.config.settingsScope ?? "project",
1450
1370
  };
1451
1371
  toolCtx.toolVisibility = toolVisibility;
1452
1372
  // #7: per-turn project builtin override. The toolRegistry's builtin tool
@@ -1664,17 +1584,20 @@ export class Engine {
1664
1584
  // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1665
1585
  // turn loop keeps going until the session model judges the goal met.
1666
1586
  // Registered per-run (and cleared in `finally`) so a later goal-less
1667
- // send doesn't inherit a stale goal. The judge runs on `auxSummaryClient`
1668
- // the same cheap aux model used for summarize/compaction — not the
1669
- // (potentially expensive) session model: "is this goal met?" is a classic
1670
- // aux-tier task, and a goal run can invoke the judge up to maxStopBlocks
1671
- // times.
1587
+ // send doesn't inherit a stale goal. The judge runs on the primary
1588
+ // session client; auxSummaryClient remains dedicated to low-consequence
1589
+ // summaries/titles and retains defaults.auxText routing/fallback behavior.
1672
1590
  // Normalize the raw goal (string | GoalConfig) once at the run boundary;
1673
1591
  // everything inward uses the GoalConfig. normalizeGoal() returns undefined
1674
1592
  // when there's effectively no goal (empty objective).
1675
1593
  //
1676
1594
  // PERSISTENT GOAL (CC /goal style): a goal set on one send survives across
1677
- // later sends and manual interrupts until met or cleared. Resolution:
1595
+ // later sends and manual interrupts until met or cleared. Goal completion
1596
+ // is a high-consequence decision, so V1 routes it to the primary session
1597
+ // client, which is the model expected to interpret the supplied execution
1598
+ // evidence. defaults.auxText remains in force for summaries, titles and
1599
+ // other auxiliary work through auxSummaryClient.
1600
+ // Resolution:
1678
1601
  // 1. options.goal — this send explicitly sets/replaces the goal.
1679
1602
  // 2. session.state.activeGoal — a goal set on an earlier send.
1680
1603
  // 3. config.goal — engine-level default (rare; e.g. headless).
@@ -1683,7 +1606,15 @@ export class Engine {
1683
1606
  // bare send with no options.goal inherits the stored active goal so the
1684
1607
  // model keeps working toward it — that's what makes it persistent.
1685
1608
  const explicitGoal = normalizeGoal(options?.goal);
1686
- const storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1609
+ let storedGoal = this.config.isSubAgent !== true ? session.state.activeGoal : undefined;
1610
+ // Defense in depth: a stale whole-state writer may have restored the
1611
+ // activeGoal field after this exact goal instance was force-terminated.
1612
+ // Refuse to arm it and converge the live bundle before hook registration.
1613
+ if (storedGoal && isSameGoalInstance(storedGoal, session.state.goalTerminal)) {
1614
+ session.state.activeGoal = undefined;
1615
+ storedGoal = undefined;
1616
+ this.sessionManager.saveState(session.state);
1617
+ }
1687
1618
  if (explicitGoal && this.config.isSubAgent !== true) {
1688
1619
  const replaced = !!storedGoal && storedGoal.objective !== explicitGoal.objective;
1689
1620
  // Stamp WHEN this goal was set so the judge can anchor relative deadlines
@@ -1692,7 +1623,14 @@ export class Engine {
1692
1623
  // or changed objective gets a fresh stamp; re-sending the SAME objective
1693
1624
  // keeps the original anchor (the goal continues, the user didn't restate a
1694
1625
  // new deadline). User input never carries setAtMs, so we set it here.
1695
- explicitGoal.setAtMs = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1626
+ const resolvedSetAt = resolveGoalSetAt(explicitGoal.objective, storedGoal, Date.now());
1627
+ // A user explicitly re-starting the same objective creates a new goal
1628
+ // instance. Avoid a same-millisecond collision with its old tombstone.
1629
+ explicitGoal.setAtMs =
1630
+ session.state.goalTerminal?.objective === explicitGoal.objective &&
1631
+ session.state.goalTerminal.setAtMs === resolvedSetAt
1632
+ ? resolvedSetAt + 1
1633
+ : resolvedSetAt;
1696
1634
  session.state.activeGoal = explicitGoal;
1697
1635
  this.sessionManager.saveState(session.state);
1698
1636
  options?.onStream?.({
@@ -1702,18 +1640,27 @@ export class Engine {
1702
1640
  });
1703
1641
  }
1704
1642
  const normalizedGoal = explicitGoal ?? storedGoal ?? normalizeGoal(this.config.goal);
1643
+ // Snapshot the persisted goal identity owned by THIS run. Terminal
1644
+ // cleanup compares against this immutable copy so an old run cannot
1645
+ // delete a replacement goal installed while it was finishing.
1646
+ const persistedRunGoal = normalizedGoal && isSameGoalInstance(session.state.activeGoal, normalizedGoal)
1647
+ ? { ...normalizedGoal }
1648
+ : undefined;
1705
1649
  let goalHookHandler = null;
1650
+ let goalJudgeContext;
1706
1651
  if (normalizedGoal && this.config.isSubAgent !== true) {
1707
1652
  goalHookHandler = createGoalStopHook({
1708
1653
  goal: normalizedGoal,
1709
- llm: auxSummaryClient,
1654
+ llm: llmClient,
1710
1655
  log: logger,
1656
+ getJudgeContext: () => goalJudgeContext,
1711
1657
  // Clear the persisted active goal the moment the judge says it's met,
1712
1658
  // so a later bare send doesn't re-inherit a satisfied goal. The hook
1713
1659
  // calls this from inside its met branch (single source of truth for
1714
1660
  // "goal achieved"); engine owns the persistence side-effect.
1715
1661
  onMet: () => {
1716
- if (session.state.activeGoal) {
1662
+ if (persistedRunGoal &&
1663
+ isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1717
1664
  session.state.activeGoal = undefined;
1718
1665
  this.sessionManager.saveState(session.state);
1719
1666
  }
@@ -1723,7 +1670,7 @@ export class Engine {
1723
1670
  // in-RAM session are untouched) actually stops the judge. Reads disk
1724
1671
  // via readActiveGoal — authoritative and independent of which session
1725
1672
  // instance the run closure holds.
1726
- isGoalActive: (sid) => this.sessionManager.readActiveGoal(sid) !== undefined,
1673
+ isGoalActive: (sid) => isSameGoalInstance(this.sessionManager.readActiveGoal(sid), normalizedGoal),
1727
1674
  });
1728
1675
  this.hooks.register("on_stop", goalHookHandler, 0, "goal-stop");
1729
1676
  // Expose for clearGoal() mid-run. Already guarded by isSubAgent above.
@@ -1755,14 +1702,45 @@ export class Engine {
1755
1702
  return info;
1756
1703
  },
1757
1704
  consumeSteer: (source) => this.consumeSteer(sid, source),
1705
+ restoreSteer: (items) => this.restoreSteer(sid, items),
1706
+ buildSteerUserMessageContent: async (item) => {
1707
+ const steerImageInput = await prepareRunImageInput({
1708
+ task: item.text,
1709
+ cwd,
1710
+ llm: this.config.llm,
1711
+ sessionId: sid,
1712
+ attachments: item.attachments,
1713
+ });
1714
+ if (!steerImageInput.ok) {
1715
+ throw new Error(steerImageInput.result.text);
1716
+ }
1717
+ return buildRunUserMessageContent(steerImageInput.parsedTask, cwd, steerImageInput.taskText);
1718
+ },
1758
1719
  claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1720
+ releaseClientMessageId: (clientMessageId) => {
1721
+ claimedClientMessageIds.delete(clientMessageId);
1722
+ },
1723
+ setOriginClientMessageId: (clientMessageId) => {
1724
+ toolCtx.originClientMessageId = clientMessageId;
1725
+ },
1759
1726
  recordCumulativeUsage,
1727
+ recordCacheReadDiagnostics: (usage) => {
1728
+ this.recordCacheReadDiagnostics(sid, usage);
1729
+ },
1730
+ recordContextUsageAnchor: (anchor) => {
1731
+ session.state.contextUsageAnchor = {
1732
+ ...anchor,
1733
+ provider: this.config.llm.provider,
1734
+ model: this.config.llm.model,
1735
+ };
1736
+ },
1760
1737
  // Clear the persisted goal for a self-reported completion / confirmed
1761
1738
  // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1762
1739
  // turns don't re-arm) AND persists it, and drops the in-flight stop
1763
1740
  // hook so nothing re-blocks the stop we're about to return.
1764
1741
  clearPersistedGoal: () => {
1765
- if (session.state.activeGoal !== undefined) {
1742
+ if (persistedRunGoal &&
1743
+ isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1766
1744
  session.state.activeGoal = undefined;
1767
1745
  this.sessionManager.saveState(session.state);
1768
1746
  }
@@ -1772,6 +1750,9 @@ export class Engine {
1772
1750
  this.activeGoalHook = null;
1773
1751
  }
1774
1752
  },
1753
+ updateGoalJudgeContext: (context) => {
1754
+ goalJudgeContext = context;
1755
+ },
1775
1756
  ctxOverheadStore: {
1776
1757
  get: (s) => this.ctxOverheadBySid.get(s) ?? 0,
1777
1758
  set: (s, n) => {
@@ -1796,6 +1777,7 @@ export class Engine {
1796
1777
  onStream: options?.onStream,
1797
1778
  signal: options?.signal,
1798
1779
  freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1780
+ volatileContextMessages: dynamicContextMsg ? [dynamicContextMsg] : undefined,
1799
1781
  // Goal mode: the active goal is surfaced to the on_stop handler via
1800
1782
  // ctx.data.goal; the GoalStopHook (registered above) judges it.
1801
1783
  goal: normalizedGoal,
@@ -1816,6 +1798,8 @@ export class Engine {
1816
1798
  options?.onStream?.({
1817
1799
  type: "usage_update",
1818
1800
  promptTokens: cumulative.cumulativePromptTokens,
1801
+ promptTokensSource: "session_cumulative",
1802
+ promptTokensConfidence: "high",
1819
1803
  cumulativePromptTokens: cumulative.cumulativePromptTokens,
1820
1804
  cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1821
1805
  cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
@@ -1841,9 +1825,31 @@ export class Engine {
1841
1825
  // only — sub-agents don't carry user-clearable persistent goals.
1842
1826
  if (this.config.isSubAgent !== true)
1843
1827
  this.activeRunSession = session;
1828
+ const applyGoalTermination = (termination) => {
1829
+ if (!termination || !persistedRunGoal)
1830
+ return;
1831
+ // Record the terminal identity even when a newer goal has already
1832
+ // replaced it. Only clear activeGoal when it is still the run's goal.
1833
+ session.state.goalTerminal = {
1834
+ objective: persistedRunGoal.objective,
1835
+ setAtMs: persistedRunGoal.setAtMs,
1836
+ reason: termination,
1837
+ terminatedAtMs: Date.now(),
1838
+ };
1839
+ if (isSameGoalInstance(session.state.activeGoal, persistedRunGoal)) {
1840
+ session.state.activeGoal = undefined;
1841
+ }
1842
+ this.sessionManager.saveState(session.state);
1843
+ if (goalHookHandler) {
1844
+ this.hooks.unregister("on_stop", goalHookHandler);
1845
+ if (this.activeGoalHook === goalHookHandler)
1846
+ this.activeGoalHook = null;
1847
+ }
1848
+ };
1844
1849
  let result;
1845
1850
  try {
1846
1851
  result = await turnLoop.run(messages);
1852
+ applyGoalTermination(result.goalTermination);
1847
1853
  // ── Headless: drain background sub-agents before resolving ───────
1848
1854
  // Unified background-work model (2026-06-17): the engine NO LONGER parks
1849
1855
  // every run waiting on background work. Background work (sub-agents,
@@ -1905,6 +1911,7 @@ export class Engine {
1905
1911
  break;
1906
1912
  }
1907
1913
  result = await turnLoop.run([...result.messages, injected]);
1914
+ applyGoalTermination(result.goalTermination);
1908
1915
  }
1909
1916
  }
1910
1917
  }
@@ -1924,7 +1931,8 @@ export class Engine {
1924
1931
  this.hooks.unregister("on_tool_start", fileHistoryHandler);
1925
1932
  }
1926
1933
  this.lastMessages = result.messages;
1927
- this.compactedMessagesBySession.set(session.state.sessionId, this.stripUserContextMessage(result.messages, userContextMsg));
1934
+ const cachedMessages = this.stripInjectedContextMessages(result.messages, userContextMsg, dynamicContextMsg);
1935
+ this.compactedMessagesBySession.set(session.state.sessionId, cachedMessages);
1928
1936
  logger.info("engine.done", {
1929
1937
  sessionId: session.state.sessionId,
1930
1938
  reason: result.reason,
@@ -2355,7 +2363,6 @@ export class Engine {
2355
2363
  refreshRuntimeConfig(patch, version) {
2356
2364
  if (version <= this.lastAppliedConfigVersion)
2357
2365
  return;
2358
- const prevServers = this.config.mcpServers ?? {};
2359
2366
  const prevPresetName = this.preset.name;
2360
2367
  this.config = { ...this.config, ...patch };
2361
2368
  // #2: re-resolve the prompt-affecting preset so the next-turn PromptComposer
@@ -2366,11 +2373,17 @@ export class Engine {
2366
2373
  // The builtin tool SET is ctor-frozen and may be shared via runtime — we
2367
2374
  // do NOT rebuild it here. If the new preset implies a different builtin
2368
2375
  // tool set, that part of the change only lands on session restart.
2369
- const prevTools = resolveBuiltinToolNames({ preset: prevPresetName })
2376
+ const prevTools = resolveBuiltinToolNames({
2377
+ preset: prevPresetName,
2378
+ host: this.config.builtinToolHost,
2379
+ })
2370
2380
  .slice()
2371
2381
  .sort()
2372
2382
  .join(",");
2373
- const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name })
2383
+ const nextTools = resolveBuiltinToolNames({
2384
+ preset: nextPreset.name,
2385
+ host: this.config.builtinToolHost,
2386
+ })
2374
2387
  .slice()
2375
2388
  .sort()
2376
2389
  .join(",");
@@ -2457,6 +2470,41 @@ export class Engine {
2457
2470
  }
2458
2471
  return had;
2459
2472
  }
2473
+ /**
2474
+ * Reset a session's workspace pointer back to its main root. If the session is
2475
+ * actively running, mutate that live SessionBundle first so the run's next
2476
+ * saveState cannot resurrect a stale worktree pointer.
2477
+ */
2478
+ releaseSessionWorkspace(sessionId) {
2479
+ if (!sessionId || !this.sessionManager.exists(sessionId))
2480
+ return null;
2481
+ const mainRoot = this.sessionManager.readCwd(sessionId) ??
2482
+ (this.activeRunSession?.state.sessionId === sessionId
2483
+ ? this.activeRunSession.state.cwd
2484
+ : undefined);
2485
+ if (!mainRoot)
2486
+ return null;
2487
+ const workspace = { root: mainRoot, kind: "main" };
2488
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2489
+ this.activeRunSession.state.workspace = workspace;
2490
+ }
2491
+ try {
2492
+ const bundle = this.activeRunSession?.state.sessionId === sessionId
2493
+ ? this.activeRunSession
2494
+ : this.sessionManager.resume(sessionId);
2495
+ bundle.state.workspace = workspace;
2496
+ this.sessionManager.saveState(bundle.state);
2497
+ }
2498
+ catch {
2499
+ try {
2500
+ this.sessionManager.setSessionWorkspace(sessionId, workspace);
2501
+ }
2502
+ catch {
2503
+ return null;
2504
+ }
2505
+ }
2506
+ return workspace;
2507
+ }
2460
2508
  injectContext(sessionId, content) {
2461
2509
  const session = this.sessionManager.resume(sessionId);
2462
2510
  session.transcript.appendMessage("assistant", content);
@@ -2539,11 +2587,39 @@ export class Engine {
2539
2587
  strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2540
2588
  };
2541
2589
  }
2542
- stripUserContextMessage(messages, userContextMsg) {
2590
+ stripInjectedContextMessages(messages, userContextMsg, dynamicContextMsg) {
2591
+ const withoutDynamicContext = dynamicContextMsg
2592
+ ? messages.filter((msg) => msg !== dynamicContextMsg)
2593
+ : [...messages];
2543
2594
  if (!userContextMsg || messages[0] !== userContextMsg) {
2544
- return [...messages];
2595
+ return withoutDynamicContext;
2596
+ }
2597
+ return withoutDynamicContext.slice(1);
2598
+ }
2599
+ recordCacheReadDiagnostics(sessionId, usage) {
2600
+ const current = usage.cacheReadTokens;
2601
+ if (current === undefined || !Number.isFinite(current))
2602
+ return;
2603
+ const previous = this.lastCacheReadBySid.get(sessionId);
2604
+ this.lastCacheReadBySid.delete(sessionId);
2605
+ this.lastCacheReadBySid.set(sessionId, current);
2606
+ if (this.lastCacheReadBySid.size > CACHE_READ_DIAGNOSTIC_MAX_SESSIONS) {
2607
+ const oldestSessionId = this.lastCacheReadBySid.keys().next().value;
2608
+ if (oldestSessionId !== undefined)
2609
+ this.lastCacheReadBySid.delete(oldestSessionId);
2610
+ }
2611
+ if (previous === undefined || previous < CACHE_READ_DROP_MIN_PREVIOUS_TOKENS)
2612
+ return;
2613
+ const dropRatio = previous > 0 ? current / previous : 1;
2614
+ if (current <= CACHE_READ_DROP_MAX_CURRENT_TOKENS && dropRatio <= CACHE_READ_DROP_RATIO) {
2615
+ logger.warn("engine.cache_read_drop", {
2616
+ sessionId,
2617
+ previousCacheReadTokens: previous,
2618
+ currentCacheReadTokens: current,
2619
+ dropRatio,
2620
+ hint: "Prompt cache read tokens dropped sharply. Check for changed cacheable prefix, stale dynamic context in history, tool/schema changes, or provider cache eviction.",
2621
+ });
2545
2622
  }
2546
- return messages.slice(1);
2547
2623
  }
2548
2624
  getSettingsManager() {
2549
2625
  if (!this.settingsManager) {
@@ -2575,10 +2651,10 @@ export class Engine {
2575
2651
  buildPermissionConfig(mode, cwd) {
2576
2652
  const rules = [...this.preset.defaultPermissionRules];
2577
2653
  // Memory tools: dream scope is the LLM's own workspace, so save/delete
2578
- // there go through without prompting. user-scope save/delete fall through
2579
- // to the tool's permissionDefault ("ask"), forcing the user to confirm
2580
- // any modification of memories they own. Read tools are listed in the
2581
- // tool definition as permissionDefault: "allow" no rule needed here.
2654
+ // there go through without prompting. user-scope save/delete have no
2655
+ // explicit allow rule here, so default-mode classifier fallback asks the
2656
+ // user to confirm modifications. RegisteredTool.permissionDefault is only
2657
+ // UI/metadata and is not read by the classifier.
2582
2658
  rules.push({
2583
2659
  tool: "MemorySave",
2584
2660
  argsPattern: { scope: "^dream$" },
@@ -2837,6 +2913,25 @@ export class Engine {
2837
2913
  }
2838
2914
  return cached;
2839
2915
  }
2916
+ resolveSandboxConfigForCwd(cwd) {
2917
+ // Priority: config.sandbox → project settings.sandbox → global → per-run
2918
+ // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
2919
+ // sandbox genuinely follows global, rather than inheriting global's mode and
2920
+ // looking like it set one. Fixes "项目级配了不生效" + the scope model.
2921
+ let projectSandbox;
2922
+ let globalSandbox;
2923
+ try {
2924
+ const sm = this.getSettingsManager();
2925
+ if (this.config.isSubAgent !== true) {
2926
+ projectSandbox = sm.getForScope("project", cwd).sandbox;
2927
+ }
2928
+ globalSandbox = sm.getForScope("user").sandbox;
2929
+ }
2930
+ catch {
2931
+ // settings unavailable → fall through to per-run default
2932
+ }
2933
+ return resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
2934
+ }
2840
2935
  /**
2841
2936
  * Build the shell env layered onto the Bash tool / background shells (see
2842
2937
  * mergeShellEnv). Three user-configured sources, merged lowest → highest:
@@ -2894,7 +2989,7 @@ export class Engine {
2894
2989
  // the host user's credentials (same isolation contract as top-level env).
2895
2990
  // Placed below settings.env so an explicit `env` entry can still override.
2896
2991
  const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2897
- layer(new CredentialStore(cwd).envExposures(credScope));
2992
+ layer(getCredentialAccess().envExposures(cwd, credScope));
2898
2993
  layer(settings.env); // top-level env (global ⊕ project) wins
2899
2994
  }
2900
2995
  catch {
@@ -2931,9 +3026,34 @@ export class Engine {
2931
3026
  return undefined;
2932
3027
  }
2933
3028
  }
3029
+ readWorktreeBranchPrefix(cwd) {
3030
+ if (this.config.isSubAgent === true || !cwd)
3031
+ return undefined;
3032
+ try {
3033
+ const settings = this.getSettingsManager().get();
3034
+ return settings.worktree?.branchPrefix;
3035
+ }
3036
+ catch {
3037
+ return undefined;
3038
+ }
3039
+ }
3040
+ async resolveWorktreeSetupSandbox(cwd) {
3041
+ if (!cwd)
3042
+ return undefined;
3043
+ const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
3044
+ const sandboxBackend = this.runtime
3045
+ ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
3046
+ : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
3047
+ return sandboxBackend.name === "off"
3048
+ ? sandboxBackend
3049
+ : { ...sandboxBackend, network: sandboxConfig.network };
3050
+ }
3051
+ readWorktreeSetupShellEnv(cwd) {
3052
+ return this.readShellEnv(cwd);
3053
+ }
2934
3054
  buildToolContext() {
2935
3055
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
2936
- return {
3056
+ const ctx = {
2937
3057
  shellEnv: this.readShellEnv(this.config.cwd),
2938
3058
  cwd: this.config.cwd ?? process.cwd(),
2939
3059
  llmConfig: this.config.llm,
@@ -2941,6 +3061,7 @@ export class Engine {
2941
3061
  toolRegistry: this.toolRegistry,
2942
3062
  askUser: this.config.askUser,
2943
3063
  browser: this.config.browserBridge,
3064
+ workspace: this.config.workspaceBridge,
2944
3065
  injectCredentialToBrowser: this.config.injectCredentialToBrowser,
2945
3066
  isSubAgent: this.config.isSubAgent === true,
2946
3067
  // Credential tools narrow their disk reads to this scope: a project/
@@ -2961,6 +3082,10 @@ export class Engine {
2961
3082
  // opts out via config. Otherwise allowed.
2962
3083
  allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
2963
3084
  };
3085
+ ctx.setCwd = (cwd) => {
3086
+ ctx.cwd = cwd;
3087
+ };
3088
+ return ctx;
2964
3089
  }
2965
3090
  /**
2966
3091
  * Read settings.disabledSkills + settings.disabledPlugins in a single