@cjhyy/code-shell-core 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/arena/arena.d.ts +2 -0
  2. package/dist/arena/arena.js +30 -1
  3. package/dist/arena/phases/adjudication.d.ts +2 -1
  4. package/dist/arena/phases/adjudication.js +2 -1
  5. package/dist/arena/phases/build-consensus.d.ts +2 -1
  6. package/dist/arena/phases/build-consensus.js +3 -1
  7. package/dist/arena/phases/cross-review.d.ts +3 -1
  8. package/dist/arena/phases/cross-review.js +8 -2
  9. package/dist/arena/phases/debate-rounds.d.ts +2 -1
  10. package/dist/arena/phases/debate-rounds.js +4 -2
  11. package/dist/arena/phases/participant-research.d.ts +2 -1
  12. package/dist/arena/phases/participant-research.js +3 -1
  13. package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
  14. package/dist/arena/phases/planning-detail-expansion.js +2 -1
  15. package/dist/arena/planner.d.ts +2 -1
  16. package/dist/arena/planner.js +2 -1
  17. package/dist/arena/types.d.ts +5 -1
  18. package/dist/cli/agent-server-tcp.js +13 -2
  19. package/dist/context/manager.d.ts +3 -3
  20. package/dist/context/manager.js +6 -6
  21. package/dist/context/token-counter.js +5 -3
  22. package/dist/context/tool-result-storage.d.ts +6 -0
  23. package/dist/context/tool-result-storage.js +25 -4
  24. package/dist/credentials/access.d.ts +10 -1
  25. package/dist/credentials/access.js +76 -2
  26. package/dist/credentials/index.d.ts +3 -2
  27. package/dist/credentials/index.js +2 -1
  28. package/dist/credentials/oauth.d.ts +6 -1
  29. package/dist/credentials/oauth.js +66 -1
  30. package/dist/credentials/store.d.ts +1 -1
  31. package/dist/credentials/store.js +16 -7
  32. package/dist/credentials/types.d.ts +38 -1
  33. package/dist/credentials/types.js +16 -1
  34. package/dist/engine/engine.d.ts +61 -32
  35. package/dist/engine/engine.js +289 -55
  36. package/dist/engine/goal.d.ts +5 -3
  37. package/dist/engine/session-title.d.ts +2 -1
  38. package/dist/engine/session-title.js +4 -1
  39. package/dist/engine/turn-loop.d.ts +18 -0
  40. package/dist/engine/turn-loop.js +202 -27
  41. package/dist/engine/types.d.ts +6 -2
  42. package/dist/git/worktree/crud.d.ts +3 -0
  43. package/dist/git/worktree/crud.js +32 -3
  44. package/dist/git/worktree/git-exec.d.ts +2 -2
  45. package/dist/git/worktree/git-exec.js +47 -11
  46. package/dist/git/worktree/query.d.ts +8 -7
  47. package/dist/git/worktree/query.js +27 -20
  48. package/dist/hooks/events.d.ts +3 -0
  49. package/dist/hooks/events.js +0 -3
  50. package/dist/hooks/goal-stop-hook.d.ts +14 -4
  51. package/dist/hooks/goal-stop-hook.js +626 -71
  52. package/dist/hooks/registry.js +3 -0
  53. package/dist/hooks/shell-runner.d.ts +12 -1
  54. package/dist/hooks/shell-runner.js +160 -9
  55. package/dist/index.d.ts +7 -6
  56. package/dist/index.js +6 -5
  57. package/dist/llm/client-base.js +12 -10
  58. package/dist/llm/types.d.ts +12 -5
  59. package/dist/plugins/pluginCommandHook.d.ts +4 -4
  60. package/dist/plugins/pluginCommandHook.js +111 -13
  61. package/dist/protocol/chat-session-manager.d.ts +13 -2
  62. package/dist/protocol/chat-session-manager.js +90 -18
  63. package/dist/protocol/chat-session.d.ts +12 -0
  64. package/dist/protocol/chat-session.js +30 -5
  65. package/dist/protocol/client.d.ts +5 -2
  66. package/dist/protocol/client.js +22 -1
  67. package/dist/protocol/server.d.ts +25 -11
  68. package/dist/protocol/server.js +290 -72
  69. package/dist/protocol/types.d.ts +34 -2
  70. package/dist/protocol/types.js +2 -0
  71. package/dist/services/dream-consolidation.d.ts +3 -0
  72. package/dist/services/dream-consolidation.js +4 -1
  73. package/dist/services/index.d.ts +1 -1
  74. package/dist/services/index.js +1 -1
  75. package/dist/services/oauth.d.ts +34 -10
  76. package/dist/services/oauth.js +233 -98
  77. package/dist/session/session-manager.d.ts +35 -6
  78. package/dist/session/session-manager.js +362 -26
  79. package/dist/session/transcript.d.ts +30 -1
  80. package/dist/session/transcript.js +119 -4
  81. package/dist/tool-system/builtin/agent.js +5 -1
  82. package/dist/tool-system/builtin/arena.js +1 -0
  83. package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
  84. package/dist/tool-system/builtin/background-jobs.js +15 -11
  85. package/dist/tool-system/builtin/background-work.d.ts +6 -1
  86. package/dist/tool-system/builtin/background-work.js +5 -1
  87. package/dist/tool-system/builtin/bash.d.ts +3 -5
  88. package/dist/tool-system/builtin/bash.js +10 -5
  89. package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
  90. package/dist/tool-system/builtin/edit.d.ts +2 -1
  91. package/dist/tool-system/builtin/edit.js +12 -4
  92. package/dist/tool-system/builtin/generate-video.d.ts +4 -0
  93. package/dist/tool-system/builtin/generate-video.js +138 -21
  94. package/dist/tool-system/builtin/glob.d.ts +2 -1
  95. package/dist/tool-system/builtin/glob.js +28 -3
  96. package/dist/tool-system/builtin/grep.d.ts +1 -0
  97. package/dist/tool-system/builtin/grep.js +82 -17
  98. package/dist/tool-system/builtin/index.d.ts +25 -11
  99. package/dist/tool-system/builtin/index.js +45 -2
  100. package/dist/tool-system/builtin/video-providers.d.ts +12 -15
  101. package/dist/tool-system/builtin/video-providers.js +1 -0
  102. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  103. package/dist/tool-system/builtin/web-fetch.js +44 -3
  104. package/dist/tool-system/builtin/worktree.js +25 -7
  105. package/dist/tool-system/builtin/write.d.ts +2 -1
  106. package/dist/tool-system/builtin/write.js +14 -4
  107. package/dist/tool-system/context.d.ts +17 -5
  108. package/dist/tool-system/executor.js +24 -8
  109. package/dist/tool-system/mcp-manager.d.ts +6 -0
  110. package/dist/tool-system/mcp-manager.js +55 -5
  111. package/dist/tool-system/path-policy.d.ts +19 -0
  112. package/dist/tool-system/path-policy.js +62 -1
  113. package/dist/tool-system/permission.d.ts +43 -3
  114. package/dist/tool-system/permission.js +383 -30
  115. package/dist/tool-system/registry.d.ts +3 -2
  116. package/dist/tool-system/registry.js +52 -34
  117. package/dist/types.d.ts +15 -0
  118. package/package.json +1 -1
@@ -11,7 +11,7 @@ import { applyDynamicToolDef } from "./dynamic-tool-defs.js";
11
11
  import { getMergedCatalog } from "../model-catalog/index.js";
12
12
  import { modelEntriesFromConnections } from "./model-connections-pool.js";
13
13
  import { resolveAuxKey } from "./aux-key.js";
14
- import { addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
14
+ import { addTokenUsage, addCumulativeUsage, cumulativeCacheHitRate, foldRunUsage, normalizeCumulativeUsageCounters, } from "./session-usage.js";
15
15
  import { enqueueSteerItem, consumeSteerItems, removeSteerItem, } from "./steer-queue.js";
16
16
  import { resolveSandboxConfig } from "./sandbox-config.js";
17
17
  import { sandboxCacheKey } from "./sandbox-cache-key.js";
@@ -32,7 +32,7 @@ import { ContextManager } from "../context/manager.js";
32
32
  import { estimateTokens, clampContextRatios as clampContextRatiosImpl, } from "../context/compaction.js";
33
33
  import { PLAN_MODE_ALLOWED_TOOLS } from "../tool-system/plan-mode-allowlist.js";
34
34
  import { PromptComposer } from "../prompt/composer.js";
35
- import { SessionManager } from "../session/session-manager.js";
35
+ import { SessionManager, } from "../session/session-manager.js";
36
36
  import { ModelFacade } from "./model-facade.js";
37
37
  import { logger, runWithSid, getCurrentSid } from "../logging/logger.js";
38
38
  import { recordSessionStart, recordSessionEnd } from "../logging/session-recorder.js";
@@ -53,6 +53,7 @@ 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 { formatFriendlyError } from "./friendly-error.js";
56
57
  import { buildSessionTitle } from "./session-title.js";
57
58
  import { MemoryOrchestrator } from "../services/memory-orchestrator.js";
58
59
  import { runDreamConsolidation } from "../services/dream-consolidation.js";
@@ -229,6 +230,8 @@ export class Engine {
229
230
  /** Shared resources supplied at construction (adapter pattern — null when self-constructed). */
230
231
  runtime;
231
232
  sandboxCache = new Map();
233
+ interactiveBackends = new WeakMap();
234
+ activeApprovalRouter;
232
235
  /** Active permission mode for this Engine instance. */
233
236
  permissionMode;
234
237
  /** True when permissionMode === "plan". */
@@ -245,7 +248,7 @@ export class Engine {
245
248
  */
246
249
  auxClientCache;
247
250
  // Live state from the current/most-recent run, retained for /compact and
248
- // for live-mutating PermissionClassifier on permission-mode switch.
251
+ // run-boundary PermissionClassifier replacement/reconfiguration.
249
252
  lastContextManager;
250
253
  lastMessages;
251
254
  lastSessionId;
@@ -300,6 +303,19 @@ export class Engine {
300
303
  * Null when idle; set at run start, cleared in run's finally.
301
304
  */
302
305
  activeRunSession = null;
306
+ /**
307
+ * Same-instance run guard. Engine owns single-valued live controls and one
308
+ * HookRegistry, so a second run must not enter until the first has completed
309
+ * all state persistence and end hooks. This prevents handle contamination and
310
+ * whole-state saveState overlap only within this Engine instance. It does not
311
+ * coordinate different Engine instances sharing a sessionId, Workers, or
312
+ * processes; session-level locking/CAS for those cases is a separate finding.
313
+ */
314
+ runInProgress = false;
315
+ /** Permission update requested while runInProgress. Applied in run() finally. */
316
+ pendingPermissionMode = null;
317
+ /** Plan update paired with pendingPermissionMode for one atomic boundary apply. */
318
+ pendingPlanMode = null;
303
319
  /** Public accessor so UI/clients can read the resolved per-model window. */
304
320
  get maxContextTokens() {
305
321
  return this.resolveMaxContextTokens();
@@ -335,10 +351,11 @@ export class Engine {
335
351
  * emits should go through this wrapper to keep the context envelope
336
352
  * uniform with TurnLoop.emitHook.
337
353
  */
338
- async emitHook(event, data = {}) {
354
+ async emitHook(event, data = {}, signal) {
339
355
  return this.hooks.emit(event, {
340
356
  ...data,
341
357
  isSubAgent: this.config.isSubAgent === true,
358
+ signal,
342
359
  });
343
360
  }
344
361
  /**
@@ -747,10 +764,43 @@ export class Engine {
747
764
  sessionExistsOnDisk(sessionId) {
748
765
  return this.sessionManager.exists(sessionId);
749
766
  }
767
+ forkSession(sourceSessionId, options) {
768
+ return this.sessionManager.fork(sourceSessionId, options);
769
+ }
750
770
  /**
751
- * Run a task from start to finish.
771
+ * Run a task from start to finish. Rejects immediately when this Engine
772
+ * instance already has a run in progress; hosts that want queueing own that
773
+ * policy (for example ChatSession's FIFO queue).
752
774
  */
753
775
  async run(task, options) {
776
+ if (this.runInProgress) {
777
+ throw new Error("Engine.run() cannot start while another run is in progress");
778
+ }
779
+ this.runInProgress = true;
780
+ try {
781
+ return await this.runExclusive(task, options);
782
+ }
783
+ finally {
784
+ try {
785
+ this.applyPendingPermissionState();
786
+ }
787
+ finally {
788
+ this.runInProgress = false;
789
+ }
790
+ }
791
+ }
792
+ async runExclusive(task, options) {
793
+ // Freeze permission context once, before the first await. Per-turn protocol
794
+ // overrides live only for this run; persistent setPermissionMode/setPlanMode
795
+ // calls made while busy are staged separately and cannot mutate this pair.
796
+ let runPermissionMode = options?.permissionMode ?? this.config.permissionMode ?? "acceptEdits";
797
+ if (options?.planMode === true) {
798
+ runPermissionMode = "plan";
799
+ }
800
+ else if (options?.planMode === false && runPermissionMode === "plan") {
801
+ runPermissionMode = "acceptEdits";
802
+ }
803
+ const runPlanMode = runPermissionMode === "plan";
754
804
  const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
755
805
  ? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
756
806
  : undefined;
@@ -849,7 +899,7 @@ export class Engine {
849
899
  describe: () => ({
850
900
  cwd,
851
901
  preset: this.preset.name,
852
- permissionMode: this.config.permissionMode ?? "acceptEdits",
902
+ permissionMode: runPermissionMode,
853
903
  }),
854
904
  spawn: async (req) => {
855
905
  // Anchor this sub-agent in the PARENT transcript at spawn time — before
@@ -884,7 +934,7 @@ export class Engine {
884
934
  // rather surface failures than burn a 9 s exponential backoff loop.
885
935
  clientDefaults: { ...(this.config.clientDefaults ?? {}), retryMaxAttempts: 2 },
886
936
  cwd,
887
- permissionMode: this.config.permissionMode,
937
+ permissionMode: runPermissionMode,
888
938
  preset: this.preset.name,
889
939
  enabledBuiltinTools: childEnabled,
890
940
  disabledBuiltinTools: childDisabled,
@@ -952,7 +1002,7 @@ export class Engine {
952
1002
  onStream: childStream,
953
1003
  sessionId: childSessionId,
954
1004
  });
955
- return { text: result.text, sessionId: result.sessionId };
1005
+ return { text: result.text, sessionId: result.sessionId, usage: result.usage };
956
1006
  },
957
1007
  sessionExists: (sessionId) => this.sessionManager.exists(sessionId),
958
1008
  };
@@ -987,6 +1037,9 @@ export class Engine {
987
1037
  // after the assignment.
988
1038
  const toolCtx = {
989
1039
  ...this.buildToolContext(),
1040
+ approvalRouter: options?.approvalRouter ?? this.config.approvalRouter,
1041
+ permissionMode: runPermissionMode,
1042
+ planMode: runPlanMode,
990
1043
  subAgentSpawner,
991
1044
  agentDefinitions: this.getAgentDefinitions(cwd),
992
1045
  // Stamp the resolved network policy onto the backend the tools see so
@@ -1149,7 +1202,7 @@ export class Engine {
1149
1202
  toolCtx.setSessionWorkspace = (workspace) => {
1150
1203
  session.state.workspace = workspace;
1151
1204
  };
1152
- return runWithSid(session.state.sessionId, async () => {
1205
+ const sessionRun = runWithSid(session.state.sessionId, async () => {
1153
1206
  recordSessionStart(session.state.sessionId, {
1154
1207
  // Strip <codeshell-image> base64 payloads before they reach
1155
1208
  // <repo>/log/. Reader still sees the marker + byte count, just
@@ -1158,7 +1211,7 @@ export class Engine {
1158
1211
  cwd,
1159
1212
  model: this.config.llm.model,
1160
1213
  provider: this.config.llm.provider,
1161
- permissionMode: this.config.permissionMode ?? "acceptEdits",
1214
+ permissionMode: runPermissionMode,
1162
1215
  resumed: resumedFromDisk,
1163
1216
  });
1164
1217
  // Session-level hook: fired once per Engine.run() entry, regardless of
@@ -1171,7 +1224,7 @@ export class Engine {
1171
1224
  cwd,
1172
1225
  resumed: resumedFromDisk,
1173
1226
  source: resumedFromDisk ? "resume" : "startup",
1174
- });
1227
+ }, options?.signal);
1175
1228
  // Per-turn hook: fired every time a new user prompt enters the loop.
1176
1229
  // Equivalent to CC's UserPromptSubmit. Handlers can inject lightweight
1177
1230
  // reminders that should accompany each user turn (e.g. "skills
@@ -1185,7 +1238,7 @@ export class Engine {
1185
1238
  // exfiltration risk a curious user-installed shell hook shouldn't carry.
1186
1239
  prompt: taskText,
1187
1240
  resumed: resumedFromDisk,
1188
- });
1241
+ }, options?.signal);
1189
1242
  // updatedPrompt: handler rewrote the user's prompt text. Replace the
1190
1243
  // last user message we just pushed (cold-start: line ~511; resume:
1191
1244
  // line ~500). Original prompt is in the transcript already — we log
@@ -1272,8 +1325,14 @@ export class Engine {
1272
1325
  }
1273
1326
  // Kick off LLM client creation early (network handshake)
1274
1327
  const llmClientPromise = createLLMClient(this.config.llm, this.config.clientDefaults);
1275
- const mode = this.config.permissionMode ?? "acceptEdits";
1276
- const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd);
1328
+ // MCP connection below may keep us from awaiting this promise for a while.
1329
+ // Observe rejection immediately so a fast client-init failure cannot become
1330
+ // an unhandledRejection during that gap; Promise.all still receives the
1331
+ // original promise and routes the same error through the lifecycle catch.
1332
+ void llmClientPromise.catch(() => { });
1333
+ const mode = runPermissionMode;
1334
+ this.activeApprovalRouter = toolCtx.approvalRouter;
1335
+ const { rules: defaultRules, backend: approvalBackend } = this.buildPermissionConfig(mode, cwd, toolCtx.approvalRouter);
1277
1336
  const permission = new PermissionClassifier(defaultRules, mode, approvalBackend);
1278
1337
  this.activePermission = permission;
1279
1338
  // If the backend is the interactive one, wire it for project-scope
@@ -1435,7 +1494,7 @@ export class Engine {
1435
1494
  // PLAN_MODE_ALLOWED_TOOLS so what the model SEES and what the executor
1436
1495
  // RUNS can't drift apart. (Bash is in the set; the executor additionally
1437
1496
  // gates Bash to read-only commands at call time.)
1438
- const toolDefs = this.planMode
1497
+ const toolDefs = runPlanMode
1439
1498
  ? allToolDefs.filter((t) => PLAN_MODE_ALLOWED_TOOLS.has(t.name))
1440
1499
  : allToolDefs;
1441
1500
  const [llmClient, fullSystemPrompt, dynamicContextMsg] = await Promise.all([
@@ -1494,15 +1553,81 @@ export class Engine {
1494
1553
  // These are tiny throwaway outputs ("Wrote design doc") fired every turn;
1495
1554
  // that high-frequency, low-stakes chore is exactly what aux is for.
1496
1555
  const auxSummaryClient = await this.resolveAuxClient(llmClient);
1556
+ // Auto-compaction runs inside TurnLoop.manageAsync(), after the loop has
1557
+ // initialized its run-scoped Goal tracker. The closure is wired before
1558
+ // construction but cannot execute until turnLoop.run() starts.
1559
+ let turnLoop;
1560
+ let autoCompactionGoalTermination;
1561
+ let externalRunUsage = {
1562
+ promptTokens: 0,
1563
+ completionTokens: 0,
1564
+ totalTokens: 0,
1565
+ cacheReadTokens: 0,
1566
+ cacheCreationTokens: 0,
1567
+ };
1568
+ let runAccountingFinalized = false;
1497
1569
  Object.assign(session.state, normalizeCumulativeUsageCounters(session.state, session.state.tokenUsage));
1498
1570
  const recordCumulativeUsage = (usage) => {
1499
1571
  const next = addCumulativeUsage(session.state, usage);
1500
1572
  Object.assign(session.state, next);
1501
1573
  return next;
1502
1574
  };
1503
- contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordCumulativeUsage));
1575
+ const recordExternalBilledUsage = (usage) => {
1576
+ externalRunUsage = addTokenUsage(externalRunUsage, usage);
1577
+ const cumulative = recordCumulativeUsage(usage);
1578
+ autoCompactionGoalTermination = turnLoop.recordGoalJudgeUsage(usage);
1579
+ if (runAccountingFinalized) {
1580
+ try {
1581
+ const latest = this.sessionManager.resume(sid).state;
1582
+ const lateCumulative = addCumulativeUsage(latest, usage);
1583
+ this.sessionManager.updateSessionState(sid, {
1584
+ tokenUsage: addTokenUsage(latest.tokenUsage, usage),
1585
+ ...lateCumulative,
1586
+ ...(this.config.costStore
1587
+ ? {
1588
+ costState: this.config.costStore.serialize(),
1589
+ }
1590
+ : {}),
1591
+ });
1592
+ }
1593
+ catch (err) {
1594
+ logger.warn("engine.late_usage_persist_failed", {
1595
+ sessionId: sid,
1596
+ error: err instanceof Error ? err.message : String(err),
1597
+ });
1598
+ }
1599
+ }
1600
+ return cumulative;
1601
+ };
1602
+ contextManager.setSummarizeFn(this.buildSummarizeFn(llmClient, recordExternalBilledUsage));
1504
1603
  // Create components (requires resolved llmClient).
1505
1604
  const modelFacade = new ModelFacade(llmClient, session.transcript);
1605
+ const getRunUsage = () => {
1606
+ const visible = modelFacade.getUsage();
1607
+ return {
1608
+ ...visible,
1609
+ totalPromptTokens: visible.totalPromptTokens + externalRunUsage.promptTokens,
1610
+ totalCompletionTokens: visible.totalCompletionTokens + externalRunUsage.completionTokens,
1611
+ totalTokens: visible.totalTokens + externalRunUsage.totalTokens,
1612
+ totalCacheReadTokens: visible.totalCacheReadTokens + (externalRunUsage.cacheReadTokens ?? 0),
1613
+ totalCacheCreationTokens: visible.totalCacheCreationTokens + (externalRunUsage.cacheCreationTokens ?? 0),
1614
+ };
1615
+ };
1616
+ const callPrimaryModel = modelFacade.call.bind(modelFacade);
1617
+ modelFacade.call = async (...args) => {
1618
+ // A primary-model summary may itself exhaust the Goal budget. Do not
1619
+ // issue the main turn request after that billed sub-call; return control
1620
+ // to TurnLoop, whose existing post-response guard emits and persists the
1621
+ // canonical goal_budget_exhausted termination.
1622
+ if (autoCompactionGoalTermination) {
1623
+ return {
1624
+ text: "",
1625
+ toolCalls: [],
1626
+ stopReason: "stop",
1627
+ };
1628
+ }
1629
+ return callPrimaryModel(...args);
1630
+ };
1506
1631
  // Session-cumulative usage baseline: the LLM client is recreated per run
1507
1632
  // (its getUsage() counts only THIS run), so to accumulate across runs we
1508
1633
  // capture the persisted total at run start and fold this run's usage onto
@@ -1510,23 +1635,25 @@ export class Engine {
1510
1635
  const usageBaseline = { ...session.state.tokenUsage };
1511
1636
  // Wire getOutputTokens for token budget tracking
1512
1637
  modelFacade.getOutputTokens = () => {
1513
- const usage = llmClient.getUsage();
1638
+ const usage = getRunUsage();
1514
1639
  return usage.totalCompletionTokens;
1515
1640
  };
1516
- // Wire summarize for tool use summaries (uses lightweight call).
1517
- // recordUsage=false keeps these auxiliary sub-calls out of the main usage
1518
- // tracker so session_end.cost reflects only the user-facing turns and
1519
- // turns/requestCount stay aligned.
1641
+ // Wire summarize for tool use summaries (uses lightweight call). Keep the
1642
+ // request out of the foreground tracker while billing and reporting it to
1643
+ // the owning session/Goal budget.
1520
1644
  modelFacade.summarize = async (sysPrompt, userMsg) => {
1521
1645
  const resp = await auxSummaryClient.createMessage({
1522
1646
  systemPrompt: sysPrompt,
1523
1647
  messages: [{ role: "user", content: userMsg }],
1524
1648
  tools: [],
1525
1649
  maxTokens: 256,
1526
- recordUsage: false,
1650
+ billingEnabled: true,
1651
+ requestVisible: false,
1527
1652
  // Auxiliary call — see contextManager.setSummarizeFn above.
1528
1653
  reasoning: { mode: "off" },
1529
1654
  });
1655
+ if (resp.usage)
1656
+ recordExternalBilledUsage(resp.usage);
1530
1657
  logger.debug("summarize.call", {
1531
1658
  sysPromptLen: sysPrompt.length,
1532
1659
  userMsgLen: userMsg.length,
@@ -1580,7 +1707,7 @@ export class Engine {
1580
1707
  sessionId: session.state.sessionId,
1581
1708
  task,
1582
1709
  model: this.config.llm.model,
1583
- });
1710
+ }, options?.signal);
1584
1711
  // Goal mode: register a GoalStopHook for the lifetime of THIS run so the
1585
1712
  // turn loop keeps going until the session model judges the goal met.
1586
1713
  // Registered per-run (and cleared in `finally`) so a later goal-less
@@ -1654,6 +1781,14 @@ export class Engine {
1654
1781
  llm: llmClient,
1655
1782
  log: logger,
1656
1783
  getJudgeContext: () => goalJudgeContext,
1784
+ onJudgeUsage: (usage) => {
1785
+ // The provider records this request into llmClient.getUsage() and the
1786
+ // process-wide CostTracker. This separate callback feeds the session
1787
+ // cumulative cache counters and the live Goal hard-budget tracker.
1788
+ if (usage)
1789
+ recordCumulativeUsage(usage);
1790
+ return turnLoop.recordGoalJudgeUsage(usage);
1791
+ },
1657
1792
  // Clear the persisted active goal the moment the judge says it's met,
1658
1793
  // so a later bare send doesn't re-inherit a satisfied goal. The hook
1659
1794
  // calls this from inside its met branch (single source of truth for
@@ -1686,7 +1821,7 @@ export class Engine {
1686
1821
  options?.onStream?.({ type: "context_compact", ...info });
1687
1822
  });
1688
1823
  // Run turn loop
1689
- const turnLoop = new TurnLoop({
1824
+ turnLoop = new TurnLoop({
1690
1825
  model: modelFacade,
1691
1826
  toolExecutor,
1692
1827
  contextManager,
@@ -1789,7 +1924,7 @@ export class Engine {
1789
1924
  session.state.turnCount = turnCount;
1790
1925
  // baseline + this run's running total (idempotent per boundary,
1791
1926
  // accumulates across runs; carries cacheRead/cacheCreation too).
1792
- session.state.tokenUsage = foldRunUsage(usageBaseline, modelFacade.getUsage());
1927
+ session.state.tokenUsage = foldRunUsage(usageBaseline, getRunUsage());
1793
1928
  // Surface the whole-session monotonic cache counts to the UI.
1794
1929
  // Separate from turn-loop's authoritative per-response emit (which
1795
1930
  // drives the live context reading and single-turn metric).
@@ -1816,6 +1951,7 @@ export class Engine {
1816
1951
  this.sessionManager.saveState(session.state);
1817
1952
  },
1818
1953
  });
1954
+ toolCtx.recordBilledUsage = recordExternalBilledUsage;
1819
1955
  // Expose this run's loop for mid-run extension (TODO 3.1). Top-level only —
1820
1956
  // a sub-agent's loop is its own concern and isn't user-extendable.
1821
1957
  if (this.config.isSubAgent !== true)
@@ -1828,6 +1964,11 @@ export class Engine {
1828
1964
  const applyGoalTermination = (termination) => {
1829
1965
  if (!termination || !persistedRunGoal)
1830
1966
  return;
1967
+ // Judge prompt overflow ends only this run. The objective is unfinished
1968
+ // and may be resumed after the user reduces fixed judge context, so it
1969
+ // must not get a terminal tombstone or be cleared from activeGoal.
1970
+ if (termination === "judge_prompt_too_large")
1971
+ return;
1831
1972
  // Record the terminal identity even when a newer goal has already
1832
1973
  // replaced it. Only clear activeGoal when it is still the run's goal.
1833
1974
  session.state.goalTerminal = {
@@ -1847,8 +1988,10 @@ export class Engine {
1847
1988
  }
1848
1989
  };
1849
1990
  let result;
1991
+ let firstGoalTermination;
1850
1992
  try {
1851
1993
  result = await turnLoop.run(messages);
1994
+ firstGoalTermination = result.goalTermination;
1852
1995
  applyGoalTermination(result.goalTermination);
1853
1996
  // ── Headless: drain background sub-agents before resolving ───────
1854
1997
  // Unified background-work model (2026-06-17): the engine NO LONGER parks
@@ -1903,14 +2046,18 @@ export class Engine {
1903
2046
  role: "user",
1904
2047
  content: `<system-reminder>\n${buildNotificationMessage(pending)}\n</system-reminder>`,
1905
2048
  };
1906
- if (aborted) {
2049
+ if (aborted || firstGoalTermination) {
1907
2050
  // Mark injected: a synthetic notification, not the user's own input —
1908
2051
  // the disk reader drops it on replay so no phantom user bubble.
2052
+ // A goal termination is also a hard boundary: retain the notification
2053
+ // for recovery, but never re-enter TurnLoop (which would reset its
2054
+ // run-scoped goal budget tracker and could overwrite the first reason).
1909
2055
  session.transcript.appendMessage(injected.role, injected.content, { injected: true });
1910
2056
  result = { ...result, messages: [...result.messages, injected] };
1911
2057
  break;
1912
2058
  }
1913
2059
  result = await turnLoop.run([...result.messages, injected]);
2060
+ firstGoalTermination ??= result.goalTermination;
1914
2061
  applyGoalTermination(result.goalTermination);
1915
2062
  }
1916
2063
  }
@@ -1937,12 +2084,12 @@ export class Engine {
1937
2084
  sessionId: session.state.sessionId,
1938
2085
  reason: result.reason,
1939
2086
  turns: turnLoop.currentTurn,
1940
- tokens: modelFacade.getUsage().totalTokens,
2087
+ tokens: getRunUsage().totalTokens,
1941
2088
  });
1942
2089
  recordSessionEnd(session.state.sessionId, {
1943
2090
  reason: result.reason,
1944
2091
  turns: turnLoop.currentTurn,
1945
- cost: modelFacade.getUsage(),
2092
+ cost: getRunUsage(),
1946
2093
  });
1947
2094
  // Session-level hook: fired symmetrically with on_session_start once
1948
2095
  // the turn loop has resolved (completion, error, or abort). Handlers
@@ -1952,11 +2099,11 @@ export class Engine {
1952
2099
  sessionId: session.state.sessionId,
1953
2100
  reason: result.reason,
1954
2101
  turnCount: turnLoop.currentTurn,
1955
- });
2102
+ }, options?.signal);
1956
2103
  // Fire-and-forget memory pipeline: extract durable memories from the
1957
2104
  // transcript, save a session summary, and conditionally trigger
1958
2105
  // auto-dream consolidation. Doesn't block the Engine result.
1959
- void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient);
2106
+ void this.runMemoryPipeline(session.transcript, session.state.sessionId, cwd, llmClient, recordExternalBilledUsage);
1960
2107
  // Fire-and-forget session title generation — only after the FIRST turn.
1961
2108
  // Reuses the already-resolved auxSummaryClient (aux model, cheap). Best-
1962
2109
  // effort: failures never touch the run result. The renderer writes the
@@ -1967,20 +2114,21 @@ export class Engine {
1967
2114
  const userMsgCount = userMsgEvents.length;
1968
2115
  const onStream = options?.onStream;
1969
2116
  if (userMsgCount === 1 && onStream && result.text) {
2117
+ const sessionId = session.state.sessionId;
1970
2118
  const rawContent = userMsgEvents[0]?.data?.content;
1971
2119
  const firstUserText = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent ?? "");
1972
- void buildSessionTitle(auxSummaryClient, firstUserText, result.text)
2120
+ void buildSessionTitle(auxSummaryClient, firstUserText, result.text, recordExternalBilledUsage)
1973
2121
  .then((title) => {
1974
2122
  if (title) {
1975
2123
  // Persist the title so it survives a localStorage wipe / disk
1976
2124
  // rebuild — it used to live only in the renderer's localStorage
1977
- // index. This .then resolves AFTER the saveState below (:1892), so
1978
- // it must save again itself rather than rely on that write.
1979
- session.state.title = title;
1980
- this.sessionManager.saveState(session.state);
2125
+ // index. Read the latest persisted state at callback time and
2126
+ // merge only title; the completed run's session.state snapshot
2127
+ // may already be stale after later serial session updates.
2128
+ this.sessionManager.updateSessionState(sessionId, { title });
1981
2129
  onStream({
1982
2130
  type: "session_title",
1983
- sessionId: session.state.sessionId,
2131
+ sessionId,
1984
2132
  title,
1985
2133
  });
1986
2134
  }
@@ -1993,32 +2141,82 @@ export class Engine {
1993
2141
  // failures (model_error, prompt_too_long, ...) — previously every
1994
2142
  // non-completed outcome collapsed to "errored", which threw away the
1995
2143
  // distinction and misled anyone reading state.json.
2144
+ if (session.transcript.flushFailed()) {
2145
+ const failure = session.transcript.getFlushFailure();
2146
+ logger.error("engine.transcript_persistence_failed", {
2147
+ sessionId: session.state.sessionId,
2148
+ terminalReason: result.reason,
2149
+ degraded: true,
2150
+ ...failure,
2151
+ });
2152
+ }
1996
2153
  session.state.turnCount = turnLoop.currentTurn;
1997
2154
  session.state.status = result.reason;
1998
2155
  // Session-cumulative (baseline + this run) for persistence...
1999
- const usage = modelFacade.getUsage();
2156
+ const usage = getRunUsage();
2000
2157
  session.state.tokenUsage = foldRunUsage(usageBaseline, usage);
2001
2158
  if (this.config.costStore) {
2002
2159
  session.state.costState = this.config.costStore.serialize();
2003
2160
  }
2161
+ // runInProgress excludes another whole-state writer only on this Engine
2162
+ // instance. A different Engine using the same sessionId can still race
2163
+ // this saveState (including an old run's abort cleanup vs a replacement
2164
+ // Engine); cross-instance/process session serialization is a separate finding.
2004
2165
  this.sessionManager.saveState(session.state);
2166
+ runAccountingFinalized = true;
2005
2167
  // Hook: agent end
2006
2168
  await this.emitHook("on_agent_end", {
2007
2169
  sessionId: session.state.sessionId,
2008
2170
  reason: result.reason,
2009
2171
  turnCount: turnLoop.currentTurn,
2010
- });
2172
+ }, options?.signal);
2011
2173
  // Emit completion
2012
2174
  options?.onStream?.({ type: "turn_complete", reason: result.reason });
2013
2175
  return {
2014
2176
  text: result.text,
2015
2177
  reason: result.reason,
2178
+ goalTermination: firstGoalTermination,
2016
2179
  sessionId: session.state.sessionId,
2017
2180
  turnCount: turnLoop.currentTurn,
2018
2181
  usage: {
2019
2182
  promptTokens: usage.totalPromptTokens,
2020
2183
  completionTokens: usage.totalCompletionTokens,
2021
2184
  totalTokens: usage.totalTokens,
2185
+ cacheReadTokens: usage.totalCacheReadTokens,
2186
+ cacheCreationTokens: usage.totalCacheCreationTokens,
2187
+ },
2188
+ };
2189
+ });
2190
+ return Promise.resolve(sessionRun).catch((err) => {
2191
+ // The session is already persisted as active before runWithSid starts.
2192
+ // Initialization failures (client creation, MCP connection, prompt/hooks)
2193
+ // therefore need the same terminal lifecycle treatment as turn-loop errors.
2194
+ const error = formatFriendlyError(err);
2195
+ session.state.status = "model_error";
2196
+ this.sessionManager.saveState(session.state);
2197
+ session.transcript.appendError(error, { phase: "initialization" });
2198
+ logger.error("engine.run_lifecycle_failed", {
2199
+ sessionId: session.state.sessionId,
2200
+ error: err instanceof Error ? err.message : String(err),
2201
+ });
2202
+ recordSessionEnd(session.state.sessionId, {
2203
+ reason: "model_error",
2204
+ turns: session.state.turnCount,
2205
+ });
2206
+ options?.onStream?.({ type: "error", error });
2207
+ options?.onStream?.({ type: "turn_complete", reason: "model_error" });
2208
+ const usage = session.state.tokenUsage;
2209
+ return {
2210
+ text: `ERROR: ${error}`,
2211
+ reason: "model_error",
2212
+ sessionId: session.state.sessionId,
2213
+ turnCount: session.state.turnCount,
2214
+ usage: {
2215
+ promptTokens: usage.promptTokens ?? 0,
2216
+ completionTokens: usage.completionTokens ?? 0,
2217
+ totalTokens: usage.totalTokens ?? 0,
2218
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
2219
+ cacheCreationTokens: usage.cacheCreationTokens ?? 0,
2022
2220
  },
2023
2221
  };
2024
2222
  });
@@ -2041,16 +2239,19 @@ export class Engine {
2041
2239
  * run path and forceCompact share one definition of the summarization call.
2042
2240
  */
2043
2241
  buildSummarizeFn(auxSummaryClient, recordCumulativeUsage) {
2044
- return async (prompt) => {
2242
+ return async (prompt, signal) => {
2045
2243
  const summaryResponse = await auxSummaryClient.createMessage({
2046
2244
  systemPrompt: "You are a conversation summarizer. Be concise and factual.",
2047
2245
  messages: [{ role: "user", content: prompt }],
2048
2246
  tools: [],
2049
2247
  maxTokens: 1024,
2248
+ billingEnabled: true,
2249
+ requestVisible: false,
2050
2250
  // Auxiliary call — no need to burn reasoning tokens. On DeepSeek V4
2051
2251
  // this flips thinking off (~3x faster, fewer tokens); on every other
2052
2252
  // OpenAI-compatible provider the field is ignored.
2053
2253
  reasoning: { mode: "off" },
2254
+ signal,
2054
2255
  });
2055
2256
  if (summaryResponse.usage) {
2056
2257
  recordCumulativeUsage?.(summaryResponse.usage);
@@ -2110,7 +2311,7 @@ export class Engine {
2110
2311
  return fallback;
2111
2312
  }
2112
2313
  }
2113
- async runMemoryPipeline(transcript, sessionId, cwd, primaryClient) {
2314
+ async runMemoryPipeline(transcript, sessionId, cwd, primaryClient, recordBilledUsage) {
2114
2315
  try {
2115
2316
  // Background calls run on the auxiliary model when configured, so memory
2116
2317
  // book-keeping doesn't burn the expensive primary model every turn.
@@ -2150,12 +2351,22 @@ export class Engine {
2150
2351
  messages: [{ role: "user", content: userMsg }],
2151
2352
  tools: [],
2152
2353
  maxTokens: 1024,
2153
- recordUsage: false,
2354
+ billingEnabled: true,
2355
+ requestVisible: false,
2154
2356
  reasoning: { mode: "off" },
2155
2357
  });
2358
+ if (resp.usage)
2359
+ recordBilledUsage?.(resp.usage);
2156
2360
  return resp.text;
2157
2361
  },
2158
- runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({ systemPrompt, userPrompt, projectDir, llmClient, sessionId }),
2362
+ runDream: async ({ systemPrompt, userPrompt, projectDir }) => this.runDreamLoop({
2363
+ systemPrompt,
2364
+ userPrompt,
2365
+ projectDir,
2366
+ llmClient,
2367
+ sessionId,
2368
+ recordBilledUsage,
2369
+ }),
2159
2370
  projectDir: cwd,
2160
2371
  // settings.memories.maxCount caps memories accepted per extraction;
2161
2372
  // autoExtract=false turns the extractor off (summaries/dream stay).
@@ -2201,6 +2412,7 @@ export class Engine {
2201
2412
  toolContext: this.buildToolContext(),
2202
2413
  projectDir: opts.projectDir,
2203
2414
  sessionId: opts.sessionId,
2415
+ onUsage: opts.recordBilledUsage,
2204
2416
  });
2205
2417
  return ran;
2206
2418
  }
@@ -2648,7 +2860,7 @@ export class Engine {
2648
2860
  }
2649
2861
  return target;
2650
2862
  }
2651
- buildPermissionConfig(mode, cwd) {
2863
+ buildPermissionConfig(mode, cwd, approvalRouter) {
2652
2864
  const rules = [...this.preset.defaultPermissionRules];
2653
2865
  // Memory tools: dream scope is the LLM's own workspace, so save/delete
2654
2866
  // there go through without prompting. user-scope save/delete have no
@@ -2701,7 +2913,16 @@ export class Engine {
2701
2913
  // every `ask` permission silently fell through to deny-all and
2702
2914
  // the user saw "Permission denied by user" with NO modal — exactly
2703
2915
  // the bug that motivated this fix.
2704
- const interactive = getInteractiveApprovalBackend();
2916
+ let interactive;
2917
+ if (approvalRouter) {
2918
+ interactive =
2919
+ this.interactiveBackends.get(approvalRouter) ??
2920
+ new InteractiveApprovalBackend(approvalRouter);
2921
+ this.interactiveBackends.set(approvalRouter, interactive);
2922
+ }
2923
+ else {
2924
+ interactive = getInteractiveApprovalBackend();
2925
+ }
2705
2926
  if (interactive.hasPromptFn()) {
2706
2927
  backend = interactive;
2707
2928
  }
@@ -2716,21 +2937,38 @@ export class Engine {
2716
2937
  return { rules, backend };
2717
2938
  }
2718
2939
  /**
2719
- * Switch permission mode at runtime. Takes effect immediately for any
2720
- * in-flight ToolExecutor (which holds a reference to the same classifier),
2721
- * and the new mode is used for any subsequent run() calls.
2940
+ * Switch permission mode at runtime. Idle updates apply immediately; busy
2941
+ * updates are committed atomically when the current run settles, so its
2942
+ * classifier and ToolContext retain the immutable start-of-run snapshot.
2722
2943
  * Session-only — does not persist to settings.
2723
2944
  */
2724
2945
  setPermissionMode(mode) {
2946
+ if (this.runInProgress) {
2947
+ this.pendingPermissionMode = mode;
2948
+ this.pendingPlanMode = mode === "plan";
2949
+ return;
2950
+ }
2951
+ this.applyPermissionState(mode, mode === "plan");
2952
+ }
2953
+ applyPermissionState(mode, planMode) {
2725
2954
  this.config = { ...this.config, permissionMode: mode };
2726
2955
  this.permissionMode = mode;
2727
- this.planMode = mode === "plan";
2956
+ this.planMode = planMode;
2728
2957
  if (this.activePermission) {
2729
2958
  const cwd = this.config.cwd ?? process.cwd();
2730
- const { rules, backend } = this.buildPermissionConfig(mode, cwd);
2959
+ const { rules, backend } = this.buildPermissionConfig(mode, cwd, this.activeApprovalRouter);
2731
2960
  this.activePermission.reconfigure(mode, backend, rules);
2732
2961
  }
2733
2962
  }
2963
+ applyPendingPermissionState() {
2964
+ if (this.pendingPermissionMode === null)
2965
+ return;
2966
+ const mode = this.pendingPermissionMode;
2967
+ const planMode = this.pendingPlanMode ?? mode === "plan";
2968
+ this.pendingPermissionMode = null;
2969
+ this.pendingPlanMode = null;
2970
+ this.applyPermissionState(mode, planMode);
2971
+ }
2734
2972
  getPermissionMode() {
2735
2973
  return this.config.permissionMode ?? "acceptEdits";
2736
2974
  }
@@ -2752,8 +2990,7 @@ export class Engine {
2752
2990
  * rule set buildPermissionConfig does, without constructing a backend.
2753
2991
  */
2754
2992
  getPermissionRules() {
2755
- return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd())
2756
- .rules;
2993
+ return this.buildPermissionConfig(this.getPermissionMode(), this.config.cwd ?? process.cwd(), this.activeApprovalRouter).rules;
2757
2994
  }
2758
2995
  /**
2759
2996
  * Toggle plan mode directly. Called by the Plan tool (Task 7) via ToolContext.engine.
@@ -2763,13 +3000,10 @@ export class Engine {
2763
3000
  if (value) {
2764
3001
  this.setPermissionMode("plan");
2765
3002
  }
2766
- else if (this.permissionMode === "plan") {
3003
+ else if ((this.pendingPermissionMode ?? this.permissionMode) === "plan") {
2767
3004
  // Leaving plan mode: drop back to the default.
2768
3005
  this.setPermissionMode("acceptEdits");
2769
3006
  }
2770
- else {
2771
- this.planMode = value;
2772
- }
2773
3007
  }
2774
3008
  /**
2775
3009
  * Block until a background agent's state changes (finishes / its result is