@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.
- package/dist/arena/arena.d.ts +2 -0
- package/dist/arena/arena.js +30 -1
- package/dist/arena/phases/adjudication.d.ts +2 -1
- package/dist/arena/phases/adjudication.js +2 -1
- package/dist/arena/phases/build-consensus.d.ts +2 -1
- package/dist/arena/phases/build-consensus.js +3 -1
- package/dist/arena/phases/cross-review.d.ts +3 -1
- package/dist/arena/phases/cross-review.js +8 -2
- package/dist/arena/phases/debate-rounds.d.ts +2 -1
- package/dist/arena/phases/debate-rounds.js +4 -2
- package/dist/arena/phases/participant-research.d.ts +2 -1
- package/dist/arena/phases/participant-research.js +3 -1
- package/dist/arena/phases/planning-detail-expansion.d.ts +2 -1
- package/dist/arena/phases/planning-detail-expansion.js +2 -1
- package/dist/arena/planner.d.ts +2 -1
- package/dist/arena/planner.js +2 -1
- package/dist/arena/types.d.ts +5 -1
- package/dist/cli/agent-server-tcp.js +13 -2
- package/dist/context/manager.d.ts +3 -3
- package/dist/context/manager.js +6 -6
- package/dist/context/token-counter.js +5 -3
- package/dist/context/tool-result-storage.d.ts +6 -0
- package/dist/context/tool-result-storage.js +25 -4
- package/dist/credentials/access.d.ts +10 -1
- package/dist/credentials/access.js +76 -2
- package/dist/credentials/index.d.ts +3 -2
- package/dist/credentials/index.js +2 -1
- package/dist/credentials/oauth.d.ts +6 -1
- package/dist/credentials/oauth.js +66 -1
- package/dist/credentials/store.d.ts +1 -1
- package/dist/credentials/store.js +16 -7
- package/dist/credentials/types.d.ts +38 -1
- package/dist/credentials/types.js +16 -1
- package/dist/engine/engine.d.ts +61 -32
- package/dist/engine/engine.js +289 -55
- package/dist/engine/goal.d.ts +5 -3
- package/dist/engine/session-title.d.ts +2 -1
- package/dist/engine/session-title.js +4 -1
- package/dist/engine/turn-loop.d.ts +18 -0
- package/dist/engine/turn-loop.js +202 -27
- package/dist/engine/types.d.ts +6 -2
- package/dist/git/worktree/crud.d.ts +3 -0
- package/dist/git/worktree/crud.js +32 -3
- package/dist/git/worktree/git-exec.d.ts +2 -2
- package/dist/git/worktree/git-exec.js +47 -11
- package/dist/git/worktree/query.d.ts +8 -7
- package/dist/git/worktree/query.js +27 -20
- package/dist/hooks/events.d.ts +3 -0
- package/dist/hooks/events.js +0 -3
- package/dist/hooks/goal-stop-hook.d.ts +14 -4
- package/dist/hooks/goal-stop-hook.js +626 -71
- package/dist/hooks/registry.js +3 -0
- package/dist/hooks/shell-runner.d.ts +12 -1
- package/dist/hooks/shell-runner.js +160 -9
- package/dist/index.d.ts +7 -6
- package/dist/index.js +6 -5
- package/dist/llm/client-base.js +12 -10
- package/dist/llm/types.d.ts +12 -5
- package/dist/plugins/pluginCommandHook.d.ts +4 -4
- package/dist/plugins/pluginCommandHook.js +111 -13
- package/dist/protocol/chat-session-manager.d.ts +13 -2
- package/dist/protocol/chat-session-manager.js +90 -18
- package/dist/protocol/chat-session.d.ts +12 -0
- package/dist/protocol/chat-session.js +30 -5
- package/dist/protocol/client.d.ts +5 -2
- package/dist/protocol/client.js +22 -1
- package/dist/protocol/server.d.ts +25 -11
- package/dist/protocol/server.js +290 -72
- package/dist/protocol/types.d.ts +34 -2
- package/dist/protocol/types.js +2 -0
- package/dist/services/dream-consolidation.d.ts +3 -0
- package/dist/services/dream-consolidation.js +4 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.js +1 -1
- package/dist/services/oauth.d.ts +34 -10
- package/dist/services/oauth.js +233 -98
- package/dist/session/session-manager.d.ts +35 -6
- package/dist/session/session-manager.js +362 -26
- package/dist/session/transcript.d.ts +30 -1
- package/dist/session/transcript.js +119 -4
- package/dist/tool-system/builtin/agent.js +5 -1
- package/dist/tool-system/builtin/arena.js +1 -0
- package/dist/tool-system/builtin/background-jobs.d.ts +8 -5
- package/dist/tool-system/builtin/background-jobs.js +15 -11
- package/dist/tool-system/builtin/background-work.d.ts +6 -1
- package/dist/tool-system/builtin/background-work.js +5 -1
- package/dist/tool-system/builtin/bash.d.ts +3 -5
- package/dist/tool-system/builtin/bash.js +10 -5
- package/dist/tool-system/builtin/browser-tools.d.ts +2 -2
- package/dist/tool-system/builtin/edit.d.ts +2 -1
- package/dist/tool-system/builtin/edit.js +12 -4
- package/dist/tool-system/builtin/generate-video.d.ts +4 -0
- package/dist/tool-system/builtin/generate-video.js +138 -21
- package/dist/tool-system/builtin/glob.d.ts +2 -1
- package/dist/tool-system/builtin/glob.js +28 -3
- package/dist/tool-system/builtin/grep.d.ts +1 -0
- package/dist/tool-system/builtin/grep.js +82 -17
- package/dist/tool-system/builtin/index.d.ts +25 -11
- package/dist/tool-system/builtin/index.js +45 -2
- package/dist/tool-system/builtin/video-providers.d.ts +12 -15
- package/dist/tool-system/builtin/video-providers.js +1 -0
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/web-fetch.js +44 -3
- package/dist/tool-system/builtin/worktree.js +25 -7
- package/dist/tool-system/builtin/write.d.ts +2 -1
- package/dist/tool-system/builtin/write.js +14 -4
- package/dist/tool-system/context.d.ts +17 -5
- package/dist/tool-system/executor.js +24 -8
- package/dist/tool-system/mcp-manager.d.ts +6 -0
- package/dist/tool-system/mcp-manager.js +55 -5
- package/dist/tool-system/path-policy.d.ts +19 -0
- package/dist/tool-system/path-policy.js +62 -1
- package/dist/tool-system/permission.d.ts +43 -3
- package/dist/tool-system/permission.js +383 -30
- package/dist/tool-system/registry.d.ts +3 -2
- package/dist/tool-system/registry.js +52 -34
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
package/dist/engine/engine.js
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1276
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
1638
|
+
const usage = getRunUsage();
|
|
1514
1639
|
return usage.totalCompletionTokens;
|
|
1515
1640
|
};
|
|
1516
|
-
// Wire summarize for tool use summaries (uses lightweight call).
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
2087
|
+
tokens: getRunUsage().totalTokens,
|
|
1941
2088
|
});
|
|
1942
2089
|
recordSessionEnd(session.state.sessionId, {
|
|
1943
2090
|
reason: result.reason,
|
|
1944
2091
|
turns: turnLoop.currentTurn,
|
|
1945
|
-
cost:
|
|
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.
|
|
1978
|
-
//
|
|
1979
|
-
session.
|
|
1980
|
-
this.sessionManager.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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.
|
|
2720
|
-
*
|
|
2721
|
-
* and
|
|
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 =
|
|
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
|