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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/THIRD_PARTY_NOTICES.md +206 -0
  2. package/dist/automation/scheduler.d.ts +13 -7
  3. package/dist/automation/scheduler.js +116 -37
  4. package/dist/capability-control/service.d.ts +2 -0
  5. package/dist/capability-control/service.js +4 -2
  6. package/dist/cc-orchestrator/agent-adapter.d.ts +2 -0
  7. package/dist/cc-orchestrator/agent-adapter.js +7 -1
  8. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  9. package/dist/cc-orchestrator/cwd-normalize.d.ts +2 -0
  10. package/dist/cc-orchestrator/cwd-normalize.js +19 -0
  11. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  12. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  13. package/dist/cc-orchestrator/external-agent-driver.d.ts +1 -0
  14. package/dist/cc-orchestrator/external-agent-driver.js +102 -51
  15. package/dist/cc-orchestrator/external-agent-session-store.d.ts +23 -0
  16. package/dist/cc-orchestrator/external-agent-session-store.js +146 -0
  17. package/dist/cc-orchestrator/session-history.js +2 -2
  18. package/dist/cli/agent-server-stdio.js +9 -2
  19. package/dist/context/compaction.d.ts +38 -1
  20. package/dist/context/compaction.js +138 -0
  21. package/dist/context/manager.d.ts +34 -2
  22. package/dist/context/manager.js +254 -58
  23. package/dist/context/token-counter.js +13 -0
  24. package/dist/credentials/access.d.ts +56 -0
  25. package/dist/credentials/access.js +183 -0
  26. package/dist/credentials/index.d.ts +1 -0
  27. package/dist/credentials/index.js +1 -0
  28. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  29. package/dist/credentials/inject-credential-tool.js +30 -11
  30. package/dist/credentials/types.d.ts +2 -2
  31. package/dist/credentials/use-credential-tool.d.ts +9 -1
  32. package/dist/credentials/use-credential-tool.js +58 -45
  33. package/dist/engine/engine.d.ts +38 -12
  34. package/dist/engine/engine.js +477 -163
  35. package/dist/engine/image-policy.d.ts +6 -0
  36. package/dist/engine/image-policy.js +17 -6
  37. package/dist/engine/input-attachments.d.ts +13 -0
  38. package/dist/engine/input-attachments.js +255 -0
  39. package/dist/engine/model-facade.d.ts +5 -2
  40. package/dist/engine/model-facade.js +6 -16
  41. package/dist/engine/parse-task.d.ts +10 -0
  42. package/dist/engine/parse-task.js +5 -0
  43. package/dist/engine/query.js +2 -0
  44. package/dist/engine/session-usage.d.ts +12 -0
  45. package/dist/engine/session-usage.js +56 -0
  46. package/dist/engine/steer-queue.d.ts +2 -1
  47. package/dist/engine/steer-queue.js +2 -2
  48. package/dist/engine/streaming-tool-queue.d.ts +11 -7
  49. package/dist/engine/streaming-tool-queue.js +11 -7
  50. package/dist/engine/turn-loop.d.ts +34 -2
  51. package/dist/engine/turn-loop.js +269 -52
  52. package/dist/engine/types.d.ts +8 -0
  53. package/dist/git/worktree/crud.d.ts +69 -0
  54. package/dist/git/worktree/crud.js +206 -0
  55. package/dist/git/worktree/diff.d.ts +14 -0
  56. package/dist/git/worktree/diff.js +82 -0
  57. package/dist/git/worktree/git-exec.d.ts +7 -0
  58. package/dist/git/worktree/git-exec.js +51 -0
  59. package/dist/git/worktree/index.d.ts +5 -0
  60. package/dist/git/worktree/index.js +5 -0
  61. package/dist/git/worktree/query.d.ts +42 -0
  62. package/dist/git/worktree/query.js +121 -0
  63. package/dist/git/worktree/slug.d.ts +11 -0
  64. package/dist/git/worktree/slug.js +58 -0
  65. package/dist/git/worktree.d.ts +1 -84
  66. package/dist/git/worktree.js +5 -230
  67. package/dist/index.d.ts +31 -28
  68. package/dist/index.js +27 -25
  69. package/dist/logging/logger.js +6 -6
  70. package/dist/logging/sanitize-messages.d.ts +10 -2
  71. package/dist/logging/sanitize-messages.js +21 -6
  72. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  73. package/dist/plugins/installer/checkUpdate.js +4 -2
  74. package/dist/plugins/installer/install.js +2 -0
  75. package/dist/plugins/installer/installFromSource.js +9 -1
  76. package/dist/plugins/installer/parseSource.d.ts +4 -1
  77. package/dist/plugins/installer/parseSource.js +28 -10
  78. package/dist/plugins/installer/sourcePath.d.ts +9 -0
  79. package/dist/plugins/installer/sourcePath.js +50 -0
  80. package/dist/plugins/installer/update.d.ts +4 -1
  81. package/dist/plugins/installer/update.js +5 -3
  82. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  83. package/dist/plugins/parseMarketplaceInput.js +4 -3
  84. package/dist/plugins/pluginInstaller.js +24 -22
  85. package/dist/preset/index.d.ts +1 -0
  86. package/dist/preset/index.js +16 -9
  87. package/dist/prompt/sections/base.md +1 -1
  88. package/dist/protocol/chat-session-manager.d.ts +2 -0
  89. package/dist/protocol/chat-session-manager.js +38 -2
  90. package/dist/protocol/chat-session.d.ts +5 -0
  91. package/dist/protocol/chat-session.js +2 -0
  92. package/dist/protocol/client.d.ts +13 -5
  93. package/dist/protocol/client.js +26 -3
  94. package/dist/protocol/server.d.ts +43 -12
  95. package/dist/protocol/server.js +326 -104
  96. package/dist/protocol/types.d.ts +51 -0
  97. package/dist/protocol/types.js +6 -0
  98. package/dist/run/FileRunStore.js +10 -1
  99. package/dist/run/Heartbeat.js +12 -0
  100. package/dist/run/RunApprovalBackend.d.ts +3 -0
  101. package/dist/run/RunApprovalBackend.js +41 -6
  102. package/dist/run/RunLock.js +2 -0
  103. package/dist/run/RunManager.d.ts +2 -0
  104. package/dist/run/RunManager.js +64 -24
  105. package/dist/run/ids.d.ts +2 -0
  106. package/dist/run/ids.js +23 -0
  107. package/dist/runtime/background-shell.d.ts +1 -0
  108. package/dist/runtime/background-shell.js +23 -13
  109. package/dist/runtime/safe-spawn.js +74 -11
  110. package/dist/runtime/spawn-common.js +10 -0
  111. package/dist/services/auto-dream.d.ts +15 -4
  112. package/dist/services/auto-dream.js +20 -20
  113. package/dist/services/dream-consolidation.d.ts +2 -3
  114. package/dist/services/dream-consolidation.js +65 -15
  115. package/dist/services/extract-memories.d.ts +14 -5
  116. package/dist/services/extract-memories.js +20 -3
  117. package/dist/services/global-dream-promotion.d.ts +23 -0
  118. package/dist/services/global-dream-promotion.js +112 -0
  119. package/dist/services/memory-orchestrator.js +347 -34
  120. package/dist/session/memory.d.ts +61 -18
  121. package/dist/session/memory.js +342 -79
  122. package/dist/session/session-manager.d.ts +35 -1
  123. package/dist/session/session-manager.js +197 -4
  124. package/dist/session/transcript.d.ts +5 -1
  125. package/dist/session/transcript.js +38 -5
  126. package/dist/settings/manager.d.ts +1 -0
  127. package/dist/settings/manager.js +87 -37
  128. package/dist/settings/schema-export.d.ts +2 -3
  129. package/dist/settings/schema-export.js +2 -3
  130. package/dist/settings/schema.d.ts +21 -0
  131. package/dist/settings/schema.js +12 -0
  132. package/dist/skills/scanner.d.ts +3 -2
  133. package/dist/skills/scanner.js +12 -9
  134. package/dist/tool-system/builtin/background-jobs.d.ts +10 -1
  135. package/dist/tool-system/builtin/background-jobs.js +12 -1
  136. package/dist/tool-system/builtin/background-work.d.ts +17 -18
  137. package/dist/tool-system/builtin/background-work.js +51 -5
  138. package/dist/tool-system/builtin/config.d.ts +2 -1
  139. package/dist/tool-system/builtin/config.js +16 -11
  140. package/dist/tool-system/builtin/drive-claude-code.d.ts +16 -2
  141. package/dist/tool-system/builtin/drive-claude-code.js +301 -47
  142. package/dist/tool-system/builtin/edit.js +5 -2
  143. package/dist/tool-system/builtin/generate-video.d.ts +1 -0
  144. package/dist/tool-system/builtin/generate-video.js +13 -4
  145. package/dist/tool-system/builtin/index.d.ts +8 -3
  146. package/dist/tool-system/builtin/index.js +37 -21
  147. package/dist/tool-system/builtin/lsp.d.ts +2 -1
  148. package/dist/tool-system/builtin/lsp.js +6 -3
  149. package/dist/tool-system/builtin/memory.js +40 -8
  150. package/dist/tool-system/builtin/notebook-edit.js +5 -2
  151. package/dist/tool-system/builtin/powershell.d.ts +5 -2
  152. package/dist/tool-system/builtin/powershell.js +11 -7
  153. package/dist/tool-system/builtin/read.js +118 -6
  154. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  155. package/dist/tool-system/builtin/view-image.js +109 -19
  156. package/dist/tool-system/builtin/worktree.d.ts +4 -4
  157. package/dist/tool-system/builtin/worktree.js +297 -74
  158. package/dist/tool-system/builtin/write.js +5 -3
  159. package/dist/tool-system/context.d.ts +23 -1
  160. package/dist/tool-system/executor.d.ts +1 -5
  161. package/dist/tool-system/executor.js +94 -115
  162. package/dist/tool-system/mcp-manager.d.ts +18 -5
  163. package/dist/tool-system/mcp-manager.js +157 -74
  164. package/dist/tool-system/mcp-stdio-diagnostics.d.ts +9 -0
  165. package/dist/tool-system/mcp-stdio-diagnostics.js +93 -0
  166. package/dist/tool-system/path-policy.d.ts +2 -0
  167. package/dist/tool-system/path-policy.js +41 -12
  168. package/dist/tool-system/permission.d.ts +28 -7
  169. package/dist/tool-system/permission.js +130 -49
  170. package/dist/tool-system/registry.js +11 -4
  171. package/dist/tool-system/tool-result-redaction.d.ts +7 -0
  172. package/dist/tool-system/tool-result-redaction.js +48 -0
  173. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  174. package/dist/tool-system/workspace-bridge.js +1 -0
  175. package/dist/types.d.ts +86 -5
  176. package/dist/utils/toolDisplay.js +1 -1
  177. package/package.json +4 -3
@@ -11,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 { foldRunUsage } from "./session-usage.js";
14
+ import { 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";
@@ -34,26 +34,27 @@ 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
- import { detectProviderFromApiKey, buildModelPool, } from "../onboarding.js";
54
+ import { detectProviderFromApiKey, buildModelPool } from "../onboarding.js";
55
55
  import { detectPastedNoise } from "../utils/task-sanitizer.js";
56
- import { parseTaskWithImages, } from "./parse-task.js";
56
+ import { parseTaskWithImages } from "./parse-task.js";
57
+ import { buildInputAttachmentContext } from "./input-attachments.js";
57
58
  import { enforceImagePolicy, byteLengthFromBase64, dropOversizedImages, collectAttachedImagePaths, } from "./image-policy.js";
58
59
  import { tryCompressImages } from "./image-compression.js";
59
60
  import { buildSessionTitle } from "./session-title.js";
@@ -61,7 +62,7 @@ import { capabilitiesFor } from "../llm/capabilities/index.js";
61
62
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
62
63
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
63
64
  import { join, isAbsolute } from "node:path";
64
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
65
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
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
@@ -440,13 +441,16 @@ export class Engine {
440
441
  // per-turn path can only hide, not add, so a freshly-`on`'d builtin not in
441
442
  // the set needs a session restart to appear.
442
443
  const builtinLists = effectiveBuiltinLists(config.enabledBuiltinTools ?? [], config.disabledBuiltinTools ?? [], this.readBuiltinOverride(config.cwd));
443
- this.toolRegistry = config.runtime?.toolRegistry ?? new ToolRegistry({
444
- builtinTools: resolveBuiltinToolNames({
445
- preset: this.preset.name,
446
- enabledBuiltinTools: builtinLists.enabledBuiltinTools,
447
- disabledBuiltinTools: builtinLists.disabledBuiltinTools,
448
- }),
449
- });
444
+ this.toolRegistry =
445
+ config.runtime?.toolRegistry ??
446
+ new ToolRegistry({
447
+ builtinTools: resolveBuiltinToolNames({
448
+ preset: this.preset.name,
449
+ host: config.builtinToolHost,
450
+ enabledBuiltinTools: builtinLists.enabledBuiltinTools,
451
+ disabledBuiltinTools: builtinLists.disabledBuiltinTools,
452
+ }),
453
+ });
450
454
  this.hooks = new HookRegistry();
451
455
  // Installed-plugin hooks — declared in each plugin's hooks/hooks.json.
452
456
  // Registered first (priority 80) so user-authored hooks at lower
@@ -633,27 +637,52 @@ export class Engine {
633
637
  setBrowserBridge(bridge) {
634
638
  this.config.browserBridge = bridge;
635
639
  }
640
+ /** Inject the host-backed workspace bridge after construction. */
641
+ setWorkspaceBridge(bridge) {
642
+ this.config.workspaceBridge = bridge;
643
+ }
636
644
  /**
637
645
  * Queue a user message to be spliced into the in-flight run for `sessionId`
638
646
  * at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
639
647
  * resend). General-purpose: any host path (UI 引导, future agent coordination,
640
- * external triggers) can call it. If no run is active for the session the
641
- * message simply waits in the queue and is consumed when that session next
642
- * runs (rare race; host normally only steers while busy). No-op on blank text.
648
+ * external triggers) can call it. If no run is active for this session, reject
649
+ * without queueing so the host can downgrade to a normal run immediately.
650
+ * No-op on blank text.
643
651
  *
644
652
  * `id` is the host's stable queue-entry id. It rides through to the
645
653
  * `steer_injected` event (so the host can match the injected bubble back to
646
654
  * the queued draft) and is the handle `unsteer` uses to revoke a still-pending
647
655
  * entry. A blank id is tolerated but means the entry can't be revoked.
648
656
  */
649
- enqueueSteer(sessionId, text, id = "") {
650
- if (!sessionId)
651
- return;
657
+ enqueueSteer(sessionId, text, id = "", clientMessageId) {
652
658
  const q = this.steerQueueBySid.get(sessionId) ?? [];
653
- const next = enqueueSteerItem(q, id || `steer-${q.length}`, text);
659
+ const entryId = id || `steer-${q.length}`;
660
+ if (!sessionId)
661
+ return { accepted: false, id: entryId };
662
+ const activeRunSessionId = this.activeRunSession?.state.sessionId;
663
+ const active = this.activeTurnLoop !== null && activeRunSessionId === sessionId;
664
+ if (!active) {
665
+ logger.info("steer.enqueue.idle_rejected", {
666
+ sessionId,
667
+ id: entryId,
668
+ clientMessageId,
669
+ activeRunSessionId: activeRunSessionId ?? null,
670
+ queueLength: q.length,
671
+ });
672
+ return { accepted: false, id: entryId };
673
+ }
674
+ const next = enqueueSteerItem(q, entryId, text, clientMessageId);
654
675
  if (next === q)
655
- return; // blank text dropped
676
+ return { accepted: false, id: entryId }; // blank text dropped
656
677
  this.steerQueueBySid.set(sessionId, next);
678
+ logger.info("steer.enqueue.accepted", {
679
+ sessionId,
680
+ id: entryId,
681
+ clientMessageId,
682
+ activeRunSessionId,
683
+ queueLength: next.length,
684
+ });
685
+ return { accepted: true, id: entryId };
657
686
  }
658
687
  /**
659
688
  * Revoke a still-pending steer entry (the 撤回 path). Returns true if it was
@@ -670,12 +699,20 @@ export class Engine {
670
699
  return removed;
671
700
  }
672
701
  /** Drain + clear the steer queue for a session (turn loop consumes per step). */
673
- consumeSteer(sessionId) {
702
+ consumeSteer(sessionId, source = "normal_step") {
674
703
  const q = this.steerQueueBySid.get(sessionId);
675
704
  if (!q || q.length === 0)
676
705
  return [];
677
706
  const { drained, rest } = consumeSteerItems(q);
678
707
  this.steerQueueBySid.set(sessionId, rest);
708
+ logger.info("steer.consume.drained", {
709
+ sessionId,
710
+ source,
711
+ count: drained.length,
712
+ ids: drained.map((item) => item.id),
713
+ clientMessageIds: drained.flatMap((item) => item.clientMessageId ? [item.clientMessageId] : []),
714
+ queueLength: rest.length,
715
+ });
679
716
  return drained;
680
717
  }
681
718
  /** Wire the cookie→browser injection callback (InjectCredential tool). Same
@@ -704,20 +741,45 @@ export class Engine {
704
741
  * Run a task from start to finish.
705
742
  */
706
743
  async run(task, options) {
707
- // When the caller omits cwd but is resuming an existing session, recover
708
- // that session's bound cwd from disk so a project-bound session keeps
709
- // loading its own agents/settings/memory even if the host's UI repo
710
- // selection has drifted to null. Only probe on omission — an explicit cwd
711
- // always wins, and a fresh session has nothing to recover.
712
- const sessionCwd = options?.cwd === undefined && options?.sessionId
713
- ? this.sessionManager.readCwd(options.sessionId)
744
+ const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
745
+ ? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
714
746
  : undefined;
715
- const cwd = resolveRunCwd({
716
- optionCwd: options?.cwd,
717
- sessionCwd,
718
- configCwd: this.config.cwd,
719
- processCwd: process.cwd(),
720
- });
747
+ if (workspaceResume && !workspaceResume.ok) {
748
+ return {
749
+ text: `ERROR: ${workspaceResume.message}`,
750
+ reason: "completed",
751
+ sessionId: options.sessionId,
752
+ turnCount: 0,
753
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
754
+ };
755
+ }
756
+ if (workspaceResume?.ok &&
757
+ workspaceResume.reason === "worktree_missing_branch_gone" &&
758
+ workspaceResume.message) {
759
+ return {
760
+ text: workspaceResume.message,
761
+ reason: "completed",
762
+ sessionId: options.sessionId,
763
+ turnCount: 0,
764
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
765
+ };
766
+ }
767
+ // Existing P1 sessions resolve cwd from SessionWorkspace, even if the host
768
+ // passes a stale cwd. Legacy sessions without workspace keep the historical
769
+ // explicit-cwd precedence for backward compatibility.
770
+ const workspaceCwd = workspaceResume?.ok && workspaceResume.reason !== "legacy" ? workspaceResume.cwd : undefined;
771
+ const sessionCwd = workspaceCwd === undefined && options?.cwd === undefined && options?.sessionId
772
+ ? workspaceResume?.ok
773
+ ? workspaceResume.cwd
774
+ : this.sessionManager.readCwd(options.sessionId)
775
+ : undefined;
776
+ const cwd = workspaceCwd ??
777
+ resolveRunCwd({
778
+ optionCwd: options?.cwd,
779
+ sessionCwd,
780
+ configCwd: this.config.cwd,
781
+ processCwd: process.cwd(),
782
+ });
721
783
  // Wrap the caller's onStream so we can intercept `task_update`
722
784
  // events emitted by TodoWrite and keep an in-engine snapshot.
723
785
  // TaskGuard reads this snapshot at turn end to decide whether to
@@ -770,8 +832,32 @@ export class Engine {
770
832
  usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
771
833
  };
772
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,
839
+ });
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
+ }
773
860
  if (parsedTask.hasImages) {
774
- const cap = capabilitiesFor((this.config.llm.providerKind ?? this.config.llm.provider), this.config.llm.model);
775
861
  if (!cap.supportsVision) {
776
862
  logger.warn("engine.run.vision_not_supported", {
777
863
  provider: this.config.llm.provider,
@@ -853,7 +939,7 @@ export class Engine {
853
939
  // *text* portion only — base64 bytes count as "noise" by the heuristic
854
940
  // and would also bloat the transcript by megabytes per image. Image
855
941
  // bytes ride in parsedTask.images and re-enter the message tree below.
856
- const taskText = parsedTask.hasImages ? parsedTask.text : task;
942
+ const taskText = parsedTask.text;
857
943
  const noise = detectPastedNoise(taskText);
858
944
  if (noise.isNoise) {
859
945
  const hint = `Your input looks like pasted terminal output (${noise.reason}). ` +
@@ -915,10 +1001,10 @@ export class Engine {
915
1001
  preset: this.preset.name,
916
1002
  enabledBuiltinTools: childEnabled,
917
1003
  disabledBuiltinTools: childDisabled,
1004
+ builtinToolHost: this.config.builtinToolHost,
918
1005
  customSystemPrompt: this.config.customSystemPrompt,
919
- appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt]
920
- .filter(Boolean)
921
- .join("\n\n") || undefined,
1006
+ appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
1007
+ undefined,
922
1008
  responseLanguage: this.config.responseLanguage,
923
1009
  userProfile: this.config.userProfile,
924
1010
  instructions: this.config.instructions,
@@ -983,23 +1069,7 @@ export class Engine {
983
1069
  },
984
1070
  sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
985
1071
  };
986
- // Priority: config.sandbox → project settings.sandbox → global → per-run
987
- // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
988
- // sandbox genuinely follows global, rather than inheriting global's mode and
989
- // looking like it set one. Fixes "项目级配了不生效" + the scope model.
990
- let projectSandbox;
991
- let globalSandbox;
992
- try {
993
- const sm = this.getSettingsManager();
994
- if (this.config.isSubAgent !== true) {
995
- projectSandbox = sm.getForScope("project", cwd).sandbox;
996
- }
997
- globalSandbox = sm.getForScope("user").sandbox;
998
- }
999
- catch {
1000
- // settings unavailable → fall through to per-run default
1001
- }
1002
- const sandboxConfig = resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
1072
+ const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
1003
1073
  // A2: explicit sandbox modes (seatbelt, bwrap) must fail closed
1004
1074
  // per standard §S4. resolveSandboxBackend throws when an explicit
1005
1075
  // mode is unavailable on this host; we let it propagate. The
@@ -1040,12 +1110,16 @@ export class Engine {
1040
1110
  ? sandboxBackend
1041
1111
  : { ...sandboxBackend, network: sandboxConfig.network },
1042
1112
  cwd,
1113
+ shellEnv: this.readShellEnv(cwd),
1043
1114
  // TodoWrite reads this to push task_update events independently
1044
1115
  // of its return value, so the UI's pinned task panel refreshes
1045
1116
  // immediately rather than after the LLM next surfaces the
1046
1117
  // snapshot. wrappedOnStream snoops the same channel to keep
1047
1118
  // latestTodos current for TaskGuard.
1048
1119
  streamCallback: options?.onStream,
1120
+ setCwd(nextCwd) {
1121
+ toolCtx.cwd = nextCwd;
1122
+ },
1049
1123
  };
1050
1124
  logger.info("engine.run", {
1051
1125
  task: taskText.slice(0, 200),
@@ -1103,7 +1177,26 @@ export class Engine {
1103
1177
  // call) instead of a try/catch on resume.
1104
1178
  let session;
1105
1179
  let messages;
1180
+ let freshImageMessage;
1181
+ let resumedFromDisk = false;
1182
+ const claimedClientMessageIds = new Set();
1183
+ const claimClientMessageId = (bundle, clientMessageId, source) => {
1184
+ if (!clientMessageId)
1185
+ return true;
1186
+ if (claimedClientMessageIds.has(clientMessageId) ||
1187
+ bundle.transcript.hasClientMessageId(clientMessageId)) {
1188
+ logger.info("engine.client_message.duplicate_ignored", {
1189
+ sessionId: bundle.state.sessionId,
1190
+ clientMessageId,
1191
+ source,
1192
+ });
1193
+ return false;
1194
+ }
1195
+ claimedClientMessageIds.add(clientMessageId);
1196
+ return true;
1197
+ };
1106
1198
  if (options?.sessionId && this.sessionManager.exists(options.sessionId)) {
1199
+ resumedFromDisk = true;
1107
1200
  session = this.sessionManager.resume(options.sessionId);
1108
1201
  const cachedCompacted = this.compactedMessagesBySession.get(options.sessionId);
1109
1202
  messages = cachedCompacted ? [...cachedCompacted] : session.transcript.toMessages();
@@ -1126,8 +1219,31 @@ export class Engine {
1126
1219
  }
1127
1220
  // Append new user message
1128
1221
  const userMsg = { role: "user", content: userMessageContent };
1222
+ if (!claimClientMessageId(session, options?.clientMessageId, "submit")) {
1223
+ const usage = session.state.tokenUsage ?? {
1224
+ promptTokens: 0,
1225
+ completionTokens: 0,
1226
+ totalTokens: 0,
1227
+ };
1228
+ return {
1229
+ text: "",
1230
+ reason: "completed",
1231
+ sessionId: session.state.sessionId,
1232
+ turnCount: session.state.turnCount ?? 0,
1233
+ usage: {
1234
+ promptTokens: usage.promptTokens ?? 0,
1235
+ completionTokens: usage.completionTokens ?? 0,
1236
+ totalTokens: usage.totalTokens ?? 0,
1237
+ },
1238
+ };
1239
+ }
1240
+ if (parsedTask.hasImages)
1241
+ freshImageMessage = userMsg;
1129
1242
  messages.push(userMsg);
1130
- session.transcript.appendMessage("user", userMessageContent, { injected: options?.injected === true });
1243
+ session.transcript.appendMessage("user", userMessageContent, {
1244
+ injected: options?.injected === true,
1245
+ clientMessageId: options?.clientMessageId,
1246
+ });
1131
1247
  // Flush "active" status to disk immediately. resume() set it in memory
1132
1248
  // (session-manager.ts), but without this write the on-disk state.json
1133
1249
  // still shows the previous run's terminal reason — so any external
@@ -1139,13 +1255,20 @@ export class Engine {
1139
1255
  // Cold start: shape (2) reuses the host-supplied sid; shape (3)
1140
1256
  // lets sessionManager generate one with nanoid.
1141
1257
  session = this.sessionManager.create(cwd, this.config.llm.model, this.config.llm.provider, options?.sessionId, this.config.isSubAgent === true ? getCurrentSid() : undefined, this.config.isSubAgent === true ? "subagent" : this.config.origin);
1142
- messages = [{ role: "user", content: userMessageContent }];
1143
- session.transcript.appendMessage("user", userMessageContent);
1258
+ const userMsg = { role: "user", content: userMessageContent };
1259
+ claimClientMessageId(session, options?.clientMessageId, "submit");
1260
+ if (parsedTask.hasImages)
1261
+ freshImageMessage = userMsg;
1262
+ messages = [userMsg];
1263
+ session.transcript.appendMessage("user", userMessageContent, {
1264
+ clientMessageId: options?.clientMessageId,
1265
+ });
1144
1266
  // Save first user message as session summary — text only. The summary
1145
1267
  // shows up in the session list; "[image]" is more informative than a
1146
1268
  // truncated `[object Object]` when the prompt was purely visual.
1147
1269
  const summarySrc = parsedTask.hasImages
1148
- ? parsedTask.text || `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
1270
+ ? parsedTask.text ||
1271
+ `[image${parsedTask.images.length > 1 ? `s × ${parsedTask.images.length}` : ""}]`
1149
1272
  : taskText;
1150
1273
  session.state.summary = summarySrc.slice(0, 80).replace(/\n/g, " ");
1151
1274
  this.sessionManager.saveState(session.state);
@@ -1156,21 +1279,6 @@ export class Engine {
1156
1279
  // with this value so `/undo` reverts exactly this turn's file changes.
1157
1280
  // (Both resume and cold-start paths converge here.)
1158
1281
  session.state.turnSeq = (session.state.turnSeq ?? 0) + 1;
1159
- // Stamp the resolved session id for downstream logging.
1160
- //
1161
- // `setCurrentSid` updates the module-level fallback so any code path
1162
- // running outside an ALS scope (bootstrap, /sid before a run starts)
1163
- // still sees the latest sid.
1164
- //
1165
- // The real isolation comes from wrapping the rest of `run` in
1166
- // `runWithSid(sid, async () => { ... })`: every `getCurrentSid()`
1167
- // call inside that closure — including those inside `await`ed child
1168
- // Engine.run() calls — reads sid from this scope's
1169
- // AsyncLocalStorage binding, not the module global. Sibling Engines
1170
- // running concurrently each get their own scope; an `enterSid` inside
1171
- // a child mutates that child's scope only and doesn't leak back to
1172
- // the parent's chain after `await child.run(...)` returns.
1173
- setCurrentSid(session.state.sessionId);
1174
1282
  // B2 / Gate 1: stamp the resolved sid onto the tool context so
1175
1283
  // session-scoped side effects (background-agent completion
1176
1284
  // notifications) attribute to the right session. toolCtx is created
@@ -1178,6 +1286,9 @@ export class Engine {
1178
1286
  // the first point we can set it. After this assignment treat the
1179
1287
  // field as immutable for the rest of the run.
1180
1288
  toolCtx.sessionId = session.state.sessionId;
1289
+ toolCtx.setSessionWorkspace = (workspace) => {
1290
+ session.state.workspace = workspace;
1291
+ };
1181
1292
  return runWithSid(session.state.sessionId, async () => {
1182
1293
  recordSessionStart(session.state.sessionId, {
1183
1294
  // Strip <codeshell-image> base64 payloads before they reach
@@ -1188,7 +1299,7 @@ export class Engine {
1188
1299
  model: this.config.llm.model,
1189
1300
  provider: this.config.llm.provider,
1190
1301
  permissionMode: this.config.permissionMode ?? "acceptEdits",
1191
- resumed: !!options?.sessionId,
1302
+ resumed: resumedFromDisk,
1192
1303
  });
1193
1304
  // Session-level hook: fired once per Engine.run() entry, regardless of
1194
1305
  // cold-start vs resume. Handlers can return `messages` to inject a
@@ -1198,7 +1309,8 @@ export class Engine {
1198
1309
  const sessionStartHook = await this.emitHook("on_session_start", {
1199
1310
  sessionId: session.state.sessionId,
1200
1311
  cwd,
1201
- resumed: !!options?.sessionId,
1312
+ resumed: resumedFromDisk,
1313
+ source: resumedFromDisk ? "resume" : "startup",
1202
1314
  });
1203
1315
  // Per-turn hook: fired every time a new user prompt enters the loop.
1204
1316
  // Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
@@ -1212,7 +1324,7 @@ export class Engine {
1212
1324
  // and silently leaking attachment bytes through hooks is the kind of
1213
1325
  // exfiltration risk a curious user-installed shell hook shouldn't carry.
1214
1326
  prompt: taskText,
1215
- resumed: !!options?.sessionId,
1327
+ resumed: resumedFromDisk,
1216
1328
  });
1217
1329
  // updatedPrompt: handler rewrote the user's prompt text. Replace the
1218
1330
  // last user message we just pushed (cold-start: line ~511; resume:
@@ -1230,22 +1342,49 @@ export class Engine {
1230
1342
  messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
1231
1343
  }
1232
1344
  }
1233
- // Rough token estimate of the full prompt so the UI's ctx bar isn't 0%
1234
- // before the first real usage_update arrives. The authoritative count
1345
+ const contextManager = new ContextManager({
1346
+ maxTokens: this.resolveMaxContextTokens(),
1347
+ // Drop undefined fields so they don't clobber ContextManager defaults
1348
+ // (spread of `{x: undefined}` would override the default with undefined).
1349
+ ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1350
+ });
1351
+ this.lastContextManager = contextManager;
1352
+ const persistedContextAnchor = session.state.contextUsageAnchor;
1353
+ const contextAnchorCompatible = persistedContextAnchor !== undefined &&
1354
+ (persistedContextAnchor.provider === undefined ||
1355
+ persistedContextAnchor.provider === this.config.llm.provider) &&
1356
+ (persistedContextAnchor.model === undefined ||
1357
+ persistedContextAnchor.model === this.config.llm.model) &&
1358
+ (persistedContextAnchor.messageCount <= messages.length ||
1359
+ persistedContextAnchor.estimateAtAnchor !== undefined);
1360
+ if (contextAnchorCompatible) {
1361
+ contextManager.seedActualUsage(persistedContextAnchor);
1362
+ }
1363
+ // Best-effort token estimate of the full prompt so the UI's ctx bar isn't
1364
+ // 0% before the first real usage_update arrives. The authoritative count
1235
1365
  // comes from `usage.promptTokens` after the first LLM response — this is
1236
- // just a display-friendly approximation for the first frame.
1366
+ // just a display-friendly approximation for the first frame, annotated
1367
+ // with source/confidence so consumers don't treat heuristics as truth.
1237
1368
  //
1238
1369
  // Only seed once per (process, sid). On subsequent turns the UI already
1239
- // shows the previous turn's accurate ctx; overwriting it with this rough
1240
- // char/4 estimate would make the bar visibly drop on every submit.
1370
+ // shows the previous turn's accurate ctx; overwriting it with a fresh
1371
+ // best-effort estimate would make the bar visibly drop on every submit.
1241
1372
  const sid = session.state.sessionId;
1242
1373
  const needsCtxSeed = !this.ctxSeedSent.has(sid);
1243
- const roughPromptTokens = needsCtxSeed
1244
- ? messages.reduce((sum, m) => {
1245
- const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
1246
- return sum + Math.ceil(text.length / 4);
1247
- }, 0)
1248
- : 0;
1374
+ const ctxSeed = needsCtxSeed
1375
+ ? (() => {
1376
+ const checked = contextManager.checkLimits(messages);
1377
+ return {
1378
+ tokens: checked.tokens,
1379
+ source: checked.promptTokensSource,
1380
+ confidence: checked.promptTokensConfidence,
1381
+ };
1382
+ })()
1383
+ : {
1384
+ tokens: 0,
1385
+ source: "heuristic_estimate",
1386
+ confidence: "low",
1387
+ };
1249
1388
  if (needsCtxSeed)
1250
1389
  this.ctxSeedSent.add(sid);
1251
1390
  // Tell the client the sid *now* instead of waiting for run() to resolve.
@@ -1254,7 +1393,9 @@ export class Engine {
1254
1393
  options?.onStream?.({
1255
1394
  type: "session_started",
1256
1395
  sessionId: sid,
1257
- promptTokens: roughPromptTokens,
1396
+ promptTokens: ctxSeed.tokens,
1397
+ promptTokensSource: ctxSeed.source,
1398
+ promptTokensConfidence: ctxSeed.confidence,
1258
1399
  });
1259
1400
  // Replay the last TodoWrite snapshot on resume so the UI's pinned
1260
1401
  // task panel re-hydrates without the LLM needing to call TodoWrite
@@ -1281,12 +1422,14 @@ export class Engine {
1281
1422
  // in this same session don't re-prompt. Headless/auto backends skip
1282
1423
  // this — they don't prompt, so there are no project rules to persist.
1283
1424
  if (approvalBackend instanceof InteractiveApprovalBackend) {
1284
- approvalBackend.setCwd(cwd);
1285
- approvalBackend.setOnProjectRules((rules) => {
1286
- // Prepend the *full* accumulated list of session-saved project rules
1287
- // so user approvals win over defaults and earlier approvals aren't
1288
- // dropped when later ones come in.
1289
- permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1425
+ approvalBackend.setSessionContext(session.state.sessionId, {
1426
+ cwd,
1427
+ onProjectRules: (rules) => {
1428
+ // Prepend the *full* accumulated list of session-saved project rules
1429
+ // so user approvals win over defaults and earlier approvals aren't
1430
+ // dropped when later ones come in.
1431
+ permission.reconfigure(mode, approvalBackend, [...rules, ...defaultRules]);
1432
+ },
1290
1433
  });
1291
1434
  }
1292
1435
  const toolExecutor = new ToolExecutor(this.toolRegistry, permission, this.hooks);
@@ -1302,13 +1445,6 @@ export class Engine {
1302
1445
  // Wire abort signal for cascading cancellation + per-Engine ToolContext
1303
1446
  toolExecutor.setSignal(options?.signal);
1304
1447
  toolExecutor.setContext(toolCtx);
1305
- const contextManager = new ContextManager({
1306
- maxTokens: this.resolveMaxContextTokens(),
1307
- // Drop undefined fields so they don't clobber ContextManager defaults
1308
- // (spread of `{x: undefined}` would override the default with undefined).
1309
- ...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
1310
- });
1311
- this.lastContextManager = contextManager;
1312
1448
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
1313
1449
  const promptComposer = new PromptComposer({
1314
1450
  cwd,
@@ -1370,6 +1506,7 @@ export class Engine {
1370
1506
  (normalizeGoal(options?.goal) !== undefined ||
1371
1507
  session.state.activeGoal !== undefined ||
1372
1508
  normalizeGoal(this.config.goal) !== undefined),
1509
+ settingsScope: this.config.settingsScope ?? "project",
1373
1510
  };
1374
1511
  toolCtx.toolVisibility = toolVisibility;
1375
1512
  // #7: per-turn project builtin override. The toolRegistry's builtin tool
@@ -1483,26 +1620,27 @@ export class Engine {
1483
1620
  // run would be evaluated fresh and might get a different replacement
1484
1621
  // string than the one already in the message, breaking idempotency.
1485
1622
  contextManager.initReplacementStateFromMessages(messages);
1486
- // Summarization (context-compaction + tool-result summaries) are auxiliary
1487
- // calls — route them to the configured aux model so they don't burn the
1488
- // expensive primary model every turn (same rationale as runMemoryPipeline).
1489
- // Resolved once here (not per-call) so the magnetic-disk settings re-read
1490
- // in resolveAuxClient stays off the compaction hot path. Falls back to the
1491
- // primary client when no aux model is configured.
1623
+ // Two summarizers with DIFFERENT quality needs:
1624
+ //
1625
+ // 1. Context-compaction summary (setSummarizeFn) PRIMARY model. This
1626
+ // condenses many rounds into the running summary that REPLACES the real
1627
+ // history; a dropped decision makes the conversation "forget" and poisons
1628
+ // every subsequent turn. It fires only near the compact ratio (~0.85), so
1629
+ // it's infrequent — quality far outweighs the occasional extra cost of a
1630
+ // primary-model call. (Manual /compact uses the primary for the same
1631
+ // reason; see forceCompact.)
1632
+ //
1633
+ // 2. Tool-use one-liner summaries (modelFacade.summarize below) → AUX model.
1634
+ // These are tiny throwaway outputs ("Wrote design doc") fired every turn;
1635
+ // that high-frequency, low-stakes chore is exactly what aux is for.
1492
1636
  const auxSummaryClient = await this.resolveAuxClient(llmClient);
1493
- contextManager.setSummarizeFn(async (prompt) => {
1494
- const summaryResponse = await auxSummaryClient.createMessage({
1495
- systemPrompt: "You are a conversation summarizer. Be concise and factual.",
1496
- messages: [{ role: "user", content: prompt }],
1497
- tools: [],
1498
- maxTokens: 1024,
1499
- // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
1500
- // this flips thinking off (~3x faster, fewer tokens); on every other
1501
- // OpenAI-compatible provider the field is ignored.
1502
- reasoning: { mode: "off" },
1503
- });
1504
- return summaryResponse.text;
1505
- });
1637
+ Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
1638
+ const recordCumulativeUsage = (usage) => {
1639
+ const next = addCumulativeUsage(session.state, usage);
1640
+ Object.assign(session.state, next);
1641
+ return next;
1642
+ };
1643
+ contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
1506
1644
  // Create components (requires resolved llmClient).
1507
1645
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1508
1646
  // Session-cumulative usage baseline: the LLM client is recreated per run
@@ -1676,7 +1814,16 @@ export class Engine {
1676
1814
  pendingCompactInfo = null;
1677
1815
  return info;
1678
1816
  },
1679
- consumeSteer: () => this.consumeSteer(sid),
1817
+ consumeSteer: (source) => this.consumeSteer(sid, source),
1818
+ claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
1819
+ recordCumulativeUsage,
1820
+ recordContextUsageAnchor: (anchor) => {
1821
+ session.state.contextUsageAnchor = {
1822
+ ...anchor,
1823
+ provider: this.config.llm.provider,
1824
+ model: this.config.llm.model,
1825
+ };
1826
+ },
1680
1827
  // Clear the persisted goal for a self-reported completion / confirmed
1681
1828
  // cancel. Clears the in-RAM session's activeGoal (so THIS run's later
1682
1829
  // turns don't re-arm) AND persists it, and drops the in-flight stop
@@ -1715,6 +1862,7 @@ export class Engine {
1715
1862
  maxToolCallsPerTurn: this.config.maxToolCallsPerTurn ?? 25,
1716
1863
  onStream: options?.onStream,
1717
1864
  signal: options?.signal,
1865
+ freshImageMessages: freshImageMessage ? [freshImageMessage] : undefined,
1718
1866
  // Goal mode: the active goal is surfaced to the on_stop handler via
1719
1867
  // ctx.data.goal; the GoalStopHook (registered above) judges it.
1720
1868
  goal: normalizedGoal,
@@ -1727,16 +1875,25 @@ export class Engine {
1727
1875
  // baseline + this run's running total (idempotent per boundary,
1728
1876
  // accumulates across runs; carries cacheRead/cacheCreation too).
1729
1877
  session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1730
- // Surface the session-cumulative cache counts to the UI (the "本会话
1731
- // 累计命中率" tooltip). Separate from turn-loop's per-response
1732
- // usage_update (which drives the live context reading).
1733
- const cum = session.state.tokenUsage;
1878
+ // Surface the whole-session monotonic cache counts to the UI.
1879
+ // Separate from turn-loop's authoritative per-response emit (which
1880
+ // drives the live context reading and single-turn metric).
1881
+ const cumulative = normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage);
1882
+ const cumulativeHitRate = cumulativeCacheHitRate(cumulative);
1734
1883
  options?.onStream?.({
1735
1884
  type: "usage_update",
1736
- promptTokens: cum.promptTokens,
1737
- sessionPromptTokens: cum.promptTokens,
1738
- sessionCacheReadTokens: cum.cacheReadTokens ?? 0,
1739
- sessionCacheCreationTokens: cum.cacheCreationTokens ?? 0,
1885
+ promptTokens: cumulative.cumulativePromptTokens,
1886
+ promptTokensSource: "session_cumulative",
1887
+ promptTokensConfidence: "high",
1888
+ cumulativePromptTokens: cumulative.cumulativePromptTokens,
1889
+ cumulativeCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1890
+ cumulativeCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1891
+ ...(cumulativeHitRate !== undefined
1892
+ ? { cumulativeCacheHitRate: cumulativeHitRate }
1893
+ : {}),
1894
+ sessionPromptTokens: cumulative.cumulativePromptTokens,
1895
+ sessionCacheReadTokens: cumulative.cumulativeCacheReadTokens,
1896
+ sessionCacheCreationTokens: cumulative.cumulativeCacheCreationTokens,
1740
1897
  });
1741
1898
  if (this.config.costStore) {
1742
1899
  session.state.costState = this.config.costStore.serialize();
@@ -1940,6 +2097,28 @@ export class Engine {
1940
2097
  * active run's client) when unset, unknown, or on any build failure — aux
1941
2098
  * work is best-effort and must never break a run.
1942
2099
  */
2100
+ /**
2101
+ * Build the SummarizeFn used for context compaction. Extracted so both the
2102
+ * run path and forceCompact share one definition of the summarization call.
2103
+ */
2104
+ buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2105
+ return async (prompt) => {
2106
+ const summaryResponse = await auxSummaryClient.createMessage({
2107
+ systemPrompt: "You are a conversation summarizer. Be concise and factual.",
2108
+ messages: [{ role: "user", content: prompt }],
2109
+ tools: [],
2110
+ maxTokens: 1024,
2111
+ // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
2112
+ // this flips thinking off (~3x faster, fewer tokens); on every other
2113
+ // OpenAI-compatible provider the field is ignored.
2114
+ reasoning: { mode: "off" },
2115
+ });
2116
+ if (summaryResponse.usage) {
2117
+ recordCumulativeUsage?.(summaryResponse.usage);
2118
+ }
2119
+ return summaryResponse.text;
2120
+ };
2121
+ }
1943
2122
  async resolveAuxClient(fallback) {
1944
2123
  let auxKey;
1945
2124
  try {
@@ -2005,7 +2184,9 @@ export class Engine {
2005
2184
  // extraction, which then padded the memory store with low-signal
2006
2185
  // entries. 8 messages is roughly "more than a single back-and-forth"
2007
2186
  // — substantive enough to be worth a durable note.
2008
- const messages = transcript.toMessages().filter((m) => m.role === "user" || m.role === "assistant");
2187
+ const messages = transcript
2188
+ .toMessages()
2189
+ .filter((m) => m.role === "user" || m.role === "assistant");
2009
2190
  if (messages.length < 8)
2010
2191
  return;
2011
2192
  // Memory orchestrator + dream-loop calls are auxiliary LLM calls
@@ -2107,10 +2288,8 @@ export class Engine {
2107
2288
  return entry;
2108
2289
  }
2109
2290
  /**
2110
- * Zero a session's cumulative token/cache usage on disk. Called on a model
2111
- * switch: a different model has its own prompt cache, so the accumulated
2112
- * cache-hit stats from the prior model are no longer meaningful. The next
2113
- * run's baseline (snapshotted from state.tokenUsage) then starts from zero.
2291
+ * Zero the legacy/model-scoped token/cache usage window on disk. The
2292
+ * whole-session cumulative counters are intentionally left alone.
2114
2293
  */
2115
2294
  resetSessionUsage(sessionId) {
2116
2295
  const zero = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
@@ -2182,7 +2361,9 @@ export class Engine {
2182
2361
  try {
2183
2362
  chmodSync(file, 0o600);
2184
2363
  }
2185
- catch { /* best-effort */ }
2364
+ catch {
2365
+ /* best-effort */
2366
+ }
2186
2367
  }
2187
2368
  catch (err) {
2188
2369
  logger.warn(`persistActiveModel failed: ${err.message}`);
@@ -2243,7 +2424,6 @@ export class Engine {
2243
2424
  refreshRuntimeConfig(patch, version) {
2244
2425
  if (version <= this.lastAppliedConfigVersion)
2245
2426
  return;
2246
- const prevServers = this.config.mcpServers ?? {};
2247
2427
  const prevPresetName = this.preset.name;
2248
2428
  this.config = { ...this.config, ...patch };
2249
2429
  // #2: re-resolve the prompt-affecting preset so the next-turn PromptComposer
@@ -2254,8 +2434,20 @@ export class Engine {
2254
2434
  // The builtin tool SET is ctor-frozen and may be shared via runtime — we
2255
2435
  // do NOT rebuild it here. If the new preset implies a different builtin
2256
2436
  // tool set, that part of the change only lands on session restart.
2257
- const prevTools = resolveBuiltinToolNames({ preset: prevPresetName }).slice().sort().join(",");
2258
- const nextTools = resolveBuiltinToolNames({ preset: nextPreset.name }).slice().sort().join(",");
2437
+ const prevTools = resolveBuiltinToolNames({
2438
+ preset: prevPresetName,
2439
+ host: this.config.builtinToolHost,
2440
+ })
2441
+ .slice()
2442
+ .sort()
2443
+ .join(",");
2444
+ const nextTools = resolveBuiltinToolNames({
2445
+ preset: nextPreset.name,
2446
+ host: this.config.builtinToolHost,
2447
+ })
2448
+ .slice()
2449
+ .sort()
2450
+ .join(",");
2259
2451
  if (prevTools !== nextTools) {
2260
2452
  logger.warn("engine.preset_reload.tool_set_change_needs_restart", {
2261
2453
  from: prevPresetName,
@@ -2339,6 +2531,41 @@ export class Engine {
2339
2531
  }
2340
2532
  return had;
2341
2533
  }
2534
+ /**
2535
+ * Reset a session's workspace pointer back to its main root. If the session is
2536
+ * actively running, mutate that live SessionBundle first so the run's next
2537
+ * saveState cannot resurrect a stale worktree pointer.
2538
+ */
2539
+ releaseSessionWorkspace(sessionId) {
2540
+ if (!sessionId || !this.sessionManager.exists(sessionId))
2541
+ return null;
2542
+ const mainRoot = this.sessionManager.readCwd(sessionId) ??
2543
+ (this.activeRunSession?.state.sessionId === sessionId
2544
+ ? this.activeRunSession.state.cwd
2545
+ : undefined);
2546
+ if (!mainRoot)
2547
+ return null;
2548
+ const workspace = { root: mainRoot, kind: "main" };
2549
+ if (this.activeRunSession?.state.sessionId === sessionId) {
2550
+ this.activeRunSession.state.workspace = workspace;
2551
+ }
2552
+ try {
2553
+ const bundle = this.activeRunSession?.state.sessionId === sessionId
2554
+ ? this.activeRunSession
2555
+ : this.sessionManager.resume(sessionId);
2556
+ bundle.state.workspace = workspace;
2557
+ this.sessionManager.saveState(bundle.state);
2558
+ }
2559
+ catch {
2560
+ try {
2561
+ this.sessionManager.setSessionWorkspace(sessionId, workspace);
2562
+ }
2563
+ catch {
2564
+ return null;
2565
+ }
2566
+ }
2567
+ return workspace;
2568
+ }
2342
2569
  injectContext(sessionId, content) {
2343
2570
  const session = this.sessionManager.resume(sessionId);
2344
2571
  session.transcript.appendMessage("assistant", content);
@@ -2354,14 +2581,13 @@ export class Engine {
2354
2581
  * Force context compaction on a session.
2355
2582
  * Returns token stats before/after.
2356
2583
  */
2357
- forceCompact(sessionId) {
2584
+ async forceCompact(sessionId) {
2358
2585
  const effectiveSessionId = sessionId ?? this.lastSessionId;
2359
2586
  if (!effectiveSessionId) {
2360
2587
  return { before: 0, after: 0, strategy: "none (no active session)" };
2361
2588
  }
2362
2589
  const session = this.sessionManager.resume(effectiveSessionId);
2363
- const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ??
2364
- session.transcript.toMessages();
2590
+ const sourceMessages = this.compactedMessagesBySession.get(effectiveSessionId) ?? session.transcript.toMessages();
2365
2591
  const before = estimateTokens(sourceMessages);
2366
2592
  let contextManager = this.lastContextManager;
2367
2593
  if (!contextManager || this.lastSessionId !== effectiveSessionId) {
@@ -2373,7 +2599,45 @@ export class Engine {
2373
2599
  contextManager.initReplacementStateFromMessages(sourceMessages);
2374
2600
  this.lastContextManager = contextManager;
2375
2601
  }
2376
- const compacted = contextManager.manage(sourceMessages);
2602
+ // Manual /compact emits its UI boundary at the protocol layer from the
2603
+ // final before/after result. Capture the tier here, but avoid reusing a
2604
+ // stale run callback retained on lastContextManager, which could otherwise
2605
+ // double-emit.
2606
+ let compactStrategy;
2607
+ contextManager.setOnCompact((info) => {
2608
+ if (info.after < info.before)
2609
+ compactStrategy = info.strategy;
2610
+ });
2611
+ // Manual /compact = maximum compaction NOW. The automatic ladder waits for
2612
+ // compactAtRatio (0.85 * window), so on a 1M-window model an 800k text-only
2613
+ // conversation sits under the gate and manage() only runs a no-op micro.
2614
+ // Wire a summarizeFn (the run path does this per-run; a cold forceCompact on
2615
+ // a resumed-but-never-run session has none) and call forceSummarize, which
2616
+ // ignores the ratio gate and always summarizes (falling back to snip/window).
2617
+ //
2618
+ // Use the PRIMARY model, not the aux model. Automatic background compaction
2619
+ // routes to aux to keep the high-frequency path cheap, but summarization is
2620
+ // a high-fidelity task (drop a decision and the conversation "forgets"), and
2621
+ // a manual /compact is a low-frequency, user-initiated request for quality.
2622
+ // The aux model is sized for tiny outputs (titles, memory extraction), so
2623
+ // downgrading the one compaction the user explicitly asked for is backwards.
2624
+ try {
2625
+ const primaryClient = await createLLMClient(this.config.llm, this.config.clientDefaults);
2626
+ Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
2627
+ const recordCompactUsage = (usage) => {
2628
+ const next = addCumulativeUsage(session.state, usage);
2629
+ Object.assign(session.state, next);
2630
+ this.sessionManager.saveState(session.state);
2631
+ return next;
2632
+ };
2633
+ contextManager.setSummarizeFn(this.buildSummarizeFn(primaryClient, recordCompactUsage));
2634
+ }
2635
+ catch (err) {
2636
+ logger.warn("engine.force_compact_client_failed", {
2637
+ error: err.message,
2638
+ });
2639
+ }
2640
+ const compacted = await contextManager.forceSummarize(sourceMessages);
2377
2641
  const after = estimateTokens(compacted);
2378
2642
  this.compactedMessagesBySession.set(effectiveSessionId, compacted);
2379
2643
  this.lastSessionId = effectiveSessionId;
@@ -2381,7 +2645,7 @@ export class Engine {
2381
2645
  return {
2382
2646
  before,
2383
2647
  after,
2384
- strategy: before === after ? "no compaction needed" : "compacted",
2648
+ strategy: after >= before ? "no compaction needed" : (compactStrategy ?? "compacted"),
2385
2649
  };
2386
2650
  }
2387
2651
  stripUserContextMessage(messages, userContextMsg) {
@@ -2420,10 +2684,10 @@ export class Engine {
2420
2684
  buildPermissionConfig(mode, cwd) {
2421
2685
  const rules = [...this.preset.defaultPermissionRules];
2422
2686
  // Memory tools: dream scope is the LLM's own workspace, so save/delete
2423
- // there go through without prompting. user-scope save/delete fall through
2424
- // to the tool's permissionDefault ("ask"), forcing the user to confirm
2425
- // any modification of memories they own. Read tools are listed in the
2426
- // tool definition as permissionDefault: "allow" no rule needed here.
2687
+ // there go through without prompting. user-scope save/delete have no
2688
+ // explicit allow rule here, so default-mode classifier fallback asks the
2689
+ // user to confirm modifications. RegisteredTool.permissionDefault is only
2690
+ // UI/metadata and is not read by the classifier.
2427
2691
  rules.push({
2428
2692
  tool: "MemorySave",
2429
2693
  argsPattern: { scope: "^dream$" },
@@ -2475,7 +2739,11 @@ export class Engine {
2475
2739
  backend = interactive;
2476
2740
  }
2477
2741
  else {
2478
- backend = new HeadlessApprovalBackend(mode === "bypassPermissions" ? "approve-all" : mode === "dontAsk" ? "deny-all" : "deny-all");
2742
+ backend = new HeadlessApprovalBackend(mode === "bypassPermissions"
2743
+ ? "approve-all"
2744
+ : mode === "dontAsk"
2745
+ ? "deny-all"
2746
+ : "deny-all");
2479
2747
  }
2480
2748
  }
2481
2749
  return { rules, backend };
@@ -2517,7 +2785,8 @@ export class Engine {
2517
2785
  * rule set buildPermissionConfig does, without constructing a backend.
2518
2786
  */
2519
2787
  getPermissionRules() {
2520
- return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd()).rules;
2788
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
2789
+ .rules;
2521
2790
  }
2522
2791
  /**
2523
2792
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
@@ -2604,12 +2873,8 @@ export class Engine {
2604
2873
  getAgentDefinitions(cwd) {
2605
2874
  const disabledAgents = this.readDisabledAgents(cwd);
2606
2875
  const disabledPlugins = this.readDisabledLists().disabledPlugins;
2607
- const disabledKey = [...disabledAgents, "::", ...disabledPlugins]
2608
- .slice()
2609
- .sort()
2610
- .join(" ");
2611
- if (this.agentDefsCache?.cwd !== cwd ||
2612
- this.agentDefsCache.disabledKey !== disabledKey) {
2876
+ const disabledKey = [...disabledAgents, "::", ...disabledPlugins].slice().sort().join(" ");
2877
+ if (this.agentDefsCache?.cwd !== cwd || this.agentDefsCache.disabledKey !== disabledKey) {
2613
2878
  this.agentDefsCache = {
2614
2879
  cwd,
2615
2880
  disabledKey,
@@ -2681,6 +2946,25 @@ export class Engine {
2681
2946
  }
2682
2947
  return cached;
2683
2948
  }
2949
+ resolveSandboxConfigForCwd(cwd) {
2950
+ // Priority: config.sandbox → project settings.sandbox → global → per-run
2951
+ // default. Read UNMERGED per-scope (getForScope) so a project that wrote no
2952
+ // sandbox genuinely follows global, rather than inheriting global's mode and
2953
+ // looking like it set one. Fixes "项目级配了不生效" + the scope model.
2954
+ let projectSandbox;
2955
+ let globalSandbox;
2956
+ try {
2957
+ const sm = this.getSettingsManager();
2958
+ if (this.config.isSubAgent !== true) {
2959
+ projectSandbox = sm.getForScope("project", cwd).sandbox;
2960
+ }
2961
+ globalSandbox = sm.getForScope("user").sandbox;
2962
+ }
2963
+ catch {
2964
+ // settings unavailable → fall through to per-run default
2965
+ }
2966
+ return resolveSandboxConfig(this.config.sandbox, projectSandbox, globalSandbox, this.config.headless === true);
2967
+ }
2684
2968
  /**
2685
2969
  * Build the shell env layered onto the Bash tool / background shells (see
2686
2970
  * mergeShellEnv). Three user-configured sources, merged lowest → highest:
@@ -2738,7 +3022,7 @@ export class Engine {
2738
3022
  // the host user's credentials (same isolation contract as top-level env).
2739
3023
  // Placed below settings.env so an explicit `env` entry can still override.
2740
3024
  const credScope = (this.config.settingsScope ?? "project") === "full" ? "full" : "project";
2741
- layer(new CredentialStore(cwd).envExposures(credScope));
3025
+ layer(getCredentialAccess().envExposures(cwd, credScope));
2742
3026
  layer(settings.env); // top-level env (global ⊕ project) wins
2743
3027
  }
2744
3028
  catch {
@@ -2775,9 +3059,34 @@ export class Engine {
2775
3059
  return undefined;
2776
3060
  }
2777
3061
  }
3062
+ readWorktreeBranchPrefix(cwd) {
3063
+ if (this.config.isSubAgent === true || !cwd)
3064
+ return undefined;
3065
+ try {
3066
+ const settings = this.getSettingsManager().get();
3067
+ return settings.worktree?.branchPrefix;
3068
+ }
3069
+ catch {
3070
+ return undefined;
3071
+ }
3072
+ }
3073
+ async resolveWorktreeSetupSandbox(cwd) {
3074
+ if (!cwd)
3075
+ return undefined;
3076
+ const sandboxConfig = this.resolveSandboxConfigForCwd(cwd);
3077
+ const sandboxBackend = this.runtime
3078
+ ? await this.runtime.resolveSandbox(sandboxConfig, cwd)
3079
+ : await this.resolveSandboxWithoutRuntime(sandboxConfig, cwd);
3080
+ return sandboxBackend.name === "off"
3081
+ ? sandboxBackend
3082
+ : { ...sandboxBackend, network: sandboxConfig.network };
3083
+ }
3084
+ readWorktreeSetupShellEnv(cwd) {
3085
+ return this.readShellEnv(cwd);
3086
+ }
2778
3087
  buildToolContext() {
2779
3088
  const { disabledSkills, disabledPlugins } = this.readDisabledLists();
2780
- return {
3089
+ const ctx = {
2781
3090
  shellEnv: this.readShellEnv(this.config.cwd),
2782
3091
  cwd: this.config.cwd ?? process.cwd(),
2783
3092
  llmConfig: this.config.llm,
@@ -2785,6 +3094,7 @@ export class Engine {
2785
3094
  toolRegistry: this.toolRegistry,
2786
3095
  askUser: this.config.askUser,
2787
3096
  browser: this.config.browserBridge,
3097
+ workspace: this.config.workspaceBridge,
2788
3098
  injectCredentialToBrowser: this.config.injectCredentialToBrowser,
2789
3099
  isSubAgent: this.config.isSubAgent === true,
2790
3100
  // Credential tools narrow their disk reads to this scope: a project/
@@ -2805,6 +3115,10 @@ export class Engine {
2805
3115
  // opts out via config. Otherwise allowed.
2806
3116
  allowBackgroundShells: this.config.isSubAgent === true ? false : this.config.allowBackgroundShells !== false,
2807
3117
  };
3118
+ ctx.setCwd = (cwd) => {
3119
+ ctx.cwd = cwd;
3120
+ };
3121
+ return ctx;
2808
3122
  }
2809
3123
  /**
2810
3124
  * Read settings.disabledSkills + settings.disabledPlugins in a single