@cjhyy/code-shell-core 0.6.0-rc.13 → 0.6.0-rc.15
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/capability-control/service.d.ts +2 -0
- package/dist/capability-control/service.js +4 -2
- package/dist/cc-orchestrator/codex-session-history.js +1 -1
- package/dist/cc-orchestrator/external-agent-bindings.js +1 -1
- package/dist/cc-orchestrator/external-agent-session-store.js +3 -1
- package/dist/cc-orchestrator/session-history.js +2 -2
- package/dist/cli/agent-server-stdio.js +7 -2
- package/dist/context/compaction.d.ts +8 -1
- package/dist/context/compaction.js +49 -4
- package/dist/context/manager.d.ts +11 -2
- package/dist/context/manager.js +64 -3
- package/dist/credentials/inject-credential-tool.d.ts +3 -1
- package/dist/credentials/inject-credential-tool.js +29 -10
- package/dist/credentials/use-credential-tool.d.ts +1 -0
- package/dist/credentials/use-credential-tool.js +3 -0
- package/dist/engine/engine.d.ts +9 -0
- package/dist/engine/engine.js +96 -17
- package/dist/engine/turn-loop.d.ts +3 -1
- package/dist/engine/turn-loop.js +4 -2
- package/dist/engine/types.d.ts +8 -0
- package/dist/git/worktree/crud.d.ts +69 -0
- package/dist/git/worktree/crud.js +206 -0
- package/dist/git/worktree/diff.d.ts +14 -0
- package/dist/git/worktree/diff.js +82 -0
- package/dist/git/worktree/git-exec.d.ts +7 -0
- package/dist/git/worktree/git-exec.js +51 -0
- package/dist/git/worktree/index.d.ts +5 -0
- package/dist/git/worktree/index.js +5 -0
- package/dist/git/worktree/query.d.ts +42 -0
- package/dist/git/worktree/query.js +121 -0
- package/dist/git/worktree/slug.d.ts +11 -0
- package/dist/git/worktree/slug.js +58 -0
- package/dist/git/worktree.d.ts +1 -127
- package/dist/git/worktree.js +5 -464
- package/dist/index.d.ts +28 -27
- package/dist/index.js +24 -24
- package/dist/plugins/installer/checkUpdate.d.ts +4 -1
- package/dist/plugins/installer/checkUpdate.js +4 -2
- package/dist/plugins/installer/install.js +2 -0
- package/dist/plugins/installer/parseSource.d.ts +4 -1
- package/dist/plugins/installer/parseSource.js +28 -10
- package/dist/plugins/installer/update.d.ts +4 -1
- package/dist/plugins/installer/update.js +5 -3
- package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
- package/dist/plugins/parseMarketplaceInput.js +4 -3
- package/dist/preset/index.d.ts +1 -0
- package/dist/preset/index.js +6 -0
- package/dist/prompt/sections/base.md +1 -1
- package/dist/protocol/chat-session-manager.js +7 -0
- package/dist/protocol/server.d.ts +10 -0
- package/dist/protocol/server.js +88 -4
- package/dist/protocol/types.d.ts +6 -0
- package/dist/protocol/types.js +2 -0
- package/dist/runtime/background-shell.js +2 -3
- package/dist/services/auto-dream.d.ts +15 -4
- package/dist/services/auto-dream.js +20 -20
- package/dist/services/dream-consolidation.d.ts +2 -3
- package/dist/services/dream-consolidation.js +65 -15
- package/dist/services/extract-memories.d.ts +14 -5
- package/dist/services/extract-memories.js +20 -3
- package/dist/services/global-dream-promotion.d.ts +23 -0
- package/dist/services/global-dream-promotion.js +112 -0
- package/dist/services/memory-orchestrator.js +347 -34
- package/dist/session/memory.d.ts +56 -17
- package/dist/session/memory.js +337 -78
- package/dist/session/session-manager.d.ts +1 -1
- package/dist/session/session-manager.js +6 -6
- package/dist/session/transcript.d.ts +1 -1
- package/dist/session/transcript.js +17 -5
- package/dist/settings/manager.js +43 -12
- package/dist/settings/schema.d.ts +21 -0
- package/dist/settings/schema.js +12 -0
- package/dist/tool-system/builtin/index.js +18 -8
- package/dist/tool-system/builtin/memory.js +40 -8
- package/dist/tool-system/builtin/view-image.d.ts +2 -2
- package/dist/tool-system/builtin/view-image.js +100 -19
- package/dist/tool-system/builtin/worktree.d.ts +2 -0
- package/dist/tool-system/builtin/worktree.js +59 -11
- package/dist/tool-system/context.d.ts +11 -1
- package/dist/tool-system/mcp-manager.d.ts +8 -5
- package/dist/tool-system/mcp-manager.js +85 -55
- package/dist/tool-system/path-policy.d.ts +2 -0
- package/dist/tool-system/path-policy.js +28 -12
- package/dist/tool-system/workspace-bridge.d.ts +11 -0
- package/dist/tool-system/workspace-bridge.js +1 -0
- package/dist/types.d.ts +14 -0
- package/package.json +1 -1
package/dist/engine/engine.js
CHANGED
|
@@ -42,12 +42,12 @@ import { MCPManager } from "../tool-system/mcp-manager.js";
|
|
|
42
42
|
import { SettingsManager, userHome } from "../settings/manager.js";
|
|
43
43
|
import { CredentialStore } from "../credentials/store.js";
|
|
44
44
|
import { isFeatureEnabled, resolveFeatureFlags, } from "../settings/feature-flags.js";
|
|
45
|
-
import { effectiveDisabledList, effectiveBuiltinLists
|
|
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
|
|
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";
|
|
@@ -445,6 +445,7 @@ export class Engine {
|
|
|
445
445
|
new ToolRegistry({
|
|
446
446
|
builtinTools: resolveBuiltinToolNames({
|
|
447
447
|
preset: this.preset.name,
|
|
448
|
+
host: config.builtinToolHost,
|
|
448
449
|
enabledBuiltinTools: builtinLists.enabledBuiltinTools,
|
|
449
450
|
disabledBuiltinTools: builtinLists.disabledBuiltinTools,
|
|
450
451
|
}),
|
|
@@ -635,6 +636,10 @@ export class Engine {
|
|
|
635
636
|
setBrowserBridge(bridge) {
|
|
636
637
|
this.config.browserBridge = bridge;
|
|
637
638
|
}
|
|
639
|
+
/** Inject the host-backed workspace bridge after construction. */
|
|
640
|
+
setWorkspaceBridge(bridge) {
|
|
641
|
+
this.config.workspaceBridge = bridge;
|
|
642
|
+
}
|
|
638
643
|
/**
|
|
639
644
|
* Queue a user message to be spliced into the in-flight run for `sessionId`
|
|
640
645
|
* at the next turn-loop step boundary — the 不打断 steering path (vs cancel +
|
|
@@ -736,7 +741,7 @@ export class Engine {
|
|
|
736
741
|
*/
|
|
737
742
|
async run(task, options) {
|
|
738
743
|
const workspaceResume = options?.sessionId && this.sessionManager.exists(options.sessionId)
|
|
739
|
-
? this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
|
|
744
|
+
? await this.sessionManager.resolveSessionWorkspaceForResume(options.sessionId)
|
|
740
745
|
: undefined;
|
|
741
746
|
if (workspaceResume && !workspaceResume.ok) {
|
|
742
747
|
return {
|
|
@@ -971,6 +976,7 @@ export class Engine {
|
|
|
971
976
|
preset: this.preset.name,
|
|
972
977
|
enabledBuiltinTools: childEnabled,
|
|
973
978
|
disabledBuiltinTools: childDisabled,
|
|
979
|
+
builtinToolHost: this.config.builtinToolHost,
|
|
974
980
|
customSystemPrompt: this.config.customSystemPrompt,
|
|
975
981
|
appendSystemPrompt: [this.config.appendSystemPrompt, req.appendSystemPrompt].filter(Boolean).join("\n\n") ||
|
|
976
982
|
undefined,
|
|
@@ -1311,6 +1317,24 @@ export class Engine {
|
|
|
1311
1317
|
messages[lastIdx] = { role: "user", content: promptSubmitHook.updatedPrompt };
|
|
1312
1318
|
}
|
|
1313
1319
|
}
|
|
1320
|
+
const contextManager = new ContextManager({
|
|
1321
|
+
maxTokens: this.resolveMaxContextTokens(),
|
|
1322
|
+
// Drop undefined fields so they don't clobber ContextManager defaults
|
|
1323
|
+
// (spread of `{x: undefined}` would override the default with undefined).
|
|
1324
|
+
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
1325
|
+
});
|
|
1326
|
+
this.lastContextManager = contextManager;
|
|
1327
|
+
const persistedContextAnchor = session.state.contextUsageAnchor;
|
|
1328
|
+
const contextAnchorCompatible = persistedContextAnchor !== undefined &&
|
|
1329
|
+
(persistedContextAnchor.provider === undefined ||
|
|
1330
|
+
persistedContextAnchor.provider === this.config.llm.provider) &&
|
|
1331
|
+
(persistedContextAnchor.model === undefined ||
|
|
1332
|
+
persistedContextAnchor.model === this.config.llm.model) &&
|
|
1333
|
+
(persistedContextAnchor.messageCount <= messages.length ||
|
|
1334
|
+
persistedContextAnchor.estimateAtAnchor !== undefined);
|
|
1335
|
+
const seededContextAnchor = contextAnchorCompatible
|
|
1336
|
+
? contextManager.seedActualUsage(persistedContextAnchor)
|
|
1337
|
+
: undefined;
|
|
1314
1338
|
// Rough token estimate of the full prompt so the UI's ctx bar isn't 0%
|
|
1315
1339
|
// before the first real usage_update arrives. The authoritative count
|
|
1316
1340
|
// comes from `usage.promptTokens` after the first LLM response — this is
|
|
@@ -1322,10 +1346,12 @@ export class Engine {
|
|
|
1322
1346
|
const sid = session.state.sessionId;
|
|
1323
1347
|
const needsCtxSeed = !this.ctxSeedSent.has(sid);
|
|
1324
1348
|
const roughPromptTokens = needsCtxSeed
|
|
1325
|
-
?
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1349
|
+
? seededContextAnchor
|
|
1350
|
+
? contextManager.checkLimits(messages).tokens
|
|
1351
|
+
: messages.reduce((sum, m) => {
|
|
1352
|
+
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
1353
|
+
return sum + Math.ceil(text.length / 4);
|
|
1354
|
+
}, 0)
|
|
1329
1355
|
: 0;
|
|
1330
1356
|
if (needsCtxSeed)
|
|
1331
1357
|
this.ctxSeedSent.add(sid);
|
|
@@ -1383,13 +1409,6 @@ export class Engine {
|
|
|
1383
1409
|
// Wire abort signal for cascading cancellation + per-Engine ToolContext
|
|
1384
1410
|
toolExecutor.setSignal(options?.signal);
|
|
1385
1411
|
toolExecutor.setContext(toolCtx);
|
|
1386
|
-
const contextManager = new ContextManager({
|
|
1387
|
-
maxTokens: this.resolveMaxContextTokens(),
|
|
1388
|
-
// Drop undefined fields so they don't clobber ContextManager defaults
|
|
1389
|
-
// (spread of `{x: undefined}` would override the default with undefined).
|
|
1390
|
-
...Object.fromEntries(Object.entries(this.resolveContextRatios()).filter(([, v]) => v !== undefined)),
|
|
1391
|
-
});
|
|
1392
|
-
this.lastContextManager = contextManager;
|
|
1393
1412
|
const { disabledSkills, disabledPlugins } = this.readDisabledLists();
|
|
1394
1413
|
const promptComposer = new PromptComposer({
|
|
1395
1414
|
cwd,
|
|
@@ -1451,6 +1470,7 @@ export class Engine {
|
|
|
1451
1470
|
(normalizeGoal(options?.goal) !== undefined ||
|
|
1452
1471
|
session.state.activeGoal !== undefined ||
|
|
1453
1472
|
normalizeGoal(this.config.goal) !== undefined),
|
|
1473
|
+
settingsScope: this.config.settingsScope ?? "project",
|
|
1454
1474
|
};
|
|
1455
1475
|
toolCtx.toolVisibility = toolVisibility;
|
|
1456
1476
|
// #7: per-turn project builtin override. The toolRegistry's builtin tool
|
|
@@ -1761,6 +1781,13 @@ export class Engine {
|
|
|
1761
1781
|
consumeSteer: (source) => this.consumeSteer(sid, source),
|
|
1762
1782
|
claimClientMessageId: (clientMessageId, source) => claimClientMessageId(session, clientMessageId, source),
|
|
1763
1783
|
recordCumulativeUsage,
|
|
1784
|
+
recordContextUsageAnchor: (anchor) => {
|
|
1785
|
+
session.state.contextUsageAnchor = {
|
|
1786
|
+
...anchor,
|
|
1787
|
+
provider: this.config.llm.provider,
|
|
1788
|
+
model: this.config.llm.model,
|
|
1789
|
+
};
|
|
1790
|
+
},
|
|
1764
1791
|
// Clear the persisted goal for a self-reported completion / confirmed
|
|
1765
1792
|
// cancel. Clears the in-RAM session's activeGoal (so THIS run's later
|
|
1766
1793
|
// turns don't re-arm) AND persists it, and drops the in-flight stop
|
|
@@ -2359,7 +2386,6 @@ export class Engine {
|
|
|
2359
2386
|
refreshRuntimeConfig(patch, version) {
|
|
2360
2387
|
if (version <= this.lastAppliedConfigVersion)
|
|
2361
2388
|
return;
|
|
2362
|
-
const prevServers = this.config.mcpServers ?? {};
|
|
2363
2389
|
const prevPresetName = this.preset.name;
|
|
2364
2390
|
this.config = { ...this.config, ...patch };
|
|
2365
2391
|
// #2: re-resolve the prompt-affecting preset so the next-turn PromptComposer
|
|
@@ -2370,11 +2396,17 @@ export class Engine {
|
|
|
2370
2396
|
// The builtin tool SET is ctor-frozen and may be shared via runtime — we
|
|
2371
2397
|
// do NOT rebuild it here. If the new preset implies a different builtin
|
|
2372
2398
|
// tool set, that part of the change only lands on session restart.
|
|
2373
|
-
const prevTools = resolveBuiltinToolNames({
|
|
2399
|
+
const prevTools = resolveBuiltinToolNames({
|
|
2400
|
+
preset: prevPresetName,
|
|
2401
|
+
host: this.config.builtinToolHost,
|
|
2402
|
+
})
|
|
2374
2403
|
.slice()
|
|
2375
2404
|
.sort()
|
|
2376
2405
|
.join(",");
|
|
2377
|
-
const nextTools = resolveBuiltinToolNames({
|
|
2406
|
+
const nextTools = resolveBuiltinToolNames({
|
|
2407
|
+
preset: nextPreset.name,
|
|
2408
|
+
host: this.config.builtinToolHost,
|
|
2409
|
+
})
|
|
2378
2410
|
.slice()
|
|
2379
2411
|
.sort()
|
|
2380
2412
|
.join(",");
|
|
@@ -2461,6 +2493,41 @@ export class Engine {
|
|
|
2461
2493
|
}
|
|
2462
2494
|
return had;
|
|
2463
2495
|
}
|
|
2496
|
+
/**
|
|
2497
|
+
* Reset a session's workspace pointer back to its main root. If the session is
|
|
2498
|
+
* actively running, mutate that live SessionBundle first so the run's next
|
|
2499
|
+
* saveState cannot resurrect a stale worktree pointer.
|
|
2500
|
+
*/
|
|
2501
|
+
releaseSessionWorkspace(sessionId) {
|
|
2502
|
+
if (!sessionId || !this.sessionManager.exists(sessionId))
|
|
2503
|
+
return null;
|
|
2504
|
+
const mainRoot = this.sessionManager.readCwd(sessionId) ??
|
|
2505
|
+
(this.activeRunSession?.state.sessionId === sessionId
|
|
2506
|
+
? this.activeRunSession.state.cwd
|
|
2507
|
+
: undefined);
|
|
2508
|
+
if (!mainRoot)
|
|
2509
|
+
return null;
|
|
2510
|
+
const workspace = { root: mainRoot, kind: "main" };
|
|
2511
|
+
if (this.activeRunSession?.state.sessionId === sessionId) {
|
|
2512
|
+
this.activeRunSession.state.workspace = workspace;
|
|
2513
|
+
}
|
|
2514
|
+
try {
|
|
2515
|
+
const bundle = this.activeRunSession?.state.sessionId === sessionId
|
|
2516
|
+
? this.activeRunSession
|
|
2517
|
+
: this.sessionManager.resume(sessionId);
|
|
2518
|
+
bundle.state.workspace = workspace;
|
|
2519
|
+
this.sessionManager.saveState(bundle.state);
|
|
2520
|
+
}
|
|
2521
|
+
catch {
|
|
2522
|
+
try {
|
|
2523
|
+
this.sessionManager.setSessionWorkspace(sessionId, workspace);
|
|
2524
|
+
}
|
|
2525
|
+
catch {
|
|
2526
|
+
return null;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
return workspace;
|
|
2530
|
+
}
|
|
2464
2531
|
injectContext(sessionId, content) {
|
|
2465
2532
|
const session = this.sessionManager.resume(sessionId);
|
|
2466
2533
|
session.transcript.appendMessage("assistant", content);
|
|
@@ -2954,6 +3021,17 @@ export class Engine {
|
|
|
2954
3021
|
return undefined;
|
|
2955
3022
|
}
|
|
2956
3023
|
}
|
|
3024
|
+
readWorktreeBranchPrefix(cwd) {
|
|
3025
|
+
if (this.config.isSubAgent === true || !cwd)
|
|
3026
|
+
return undefined;
|
|
3027
|
+
try {
|
|
3028
|
+
const settings = this.getSettingsManager().get();
|
|
3029
|
+
return settings.worktree?.branchPrefix;
|
|
3030
|
+
}
|
|
3031
|
+
catch {
|
|
3032
|
+
return undefined;
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
2957
3035
|
async resolveWorktreeSetupSandbox(cwd) {
|
|
2958
3036
|
if (!cwd)
|
|
2959
3037
|
return undefined;
|
|
@@ -2978,6 +3056,7 @@ export class Engine {
|
|
|
2978
3056
|
toolRegistry: this.toolRegistry,
|
|
2979
3057
|
askUser: this.config.askUser,
|
|
2980
3058
|
browser: this.config.browserBridge,
|
|
3059
|
+
workspace: this.config.workspaceBridge,
|
|
2981
3060
|
injectCredentialToBrowser: this.config.injectCredentialToBrowser,
|
|
2982
3061
|
isSubAgent: this.config.isSubAgent === true,
|
|
2983
3062
|
// Credential tools narrow their disk reads to this scope: a project/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Following Claude Code's po_() pattern:
|
|
5
5
|
* pre_check → model_call → post_check → tool_exec → context_mgmt → hook_notify → next turn
|
|
6
6
|
*/
|
|
7
|
-
import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult, TokenUsage } from "../types.js";
|
|
7
|
+
import type { Message, StreamCallback, TerminalReason, ContentBlock, ToolResult, TokenUsage, ContextUsageAnchor } from "../types.js";
|
|
8
8
|
import type { SteerItem } from "./steer-queue.js";
|
|
9
9
|
import { ModelFacade } from "./model-facade.js";
|
|
10
10
|
import { ToolExecutor } from "../tool-system/executor.js";
|
|
@@ -88,6 +88,8 @@ export interface TurnLoopDeps {
|
|
|
88
88
|
* Returns the updated counters so usage_update can carry both metric scopes.
|
|
89
89
|
*/
|
|
90
90
|
recordCumulativeUsage?: (usage: TokenUsage) => CumulativeUsageCounters;
|
|
91
|
+
/** Persist the latest context-estimation anchor derived from provider usage. */
|
|
92
|
+
recordContextUsageAnchor?: (anchor: ContextUsageAnchor) => void;
|
|
91
93
|
/**
|
|
92
94
|
* Reads/clears any user messages queued for THIS session via the steering
|
|
93
95
|
* channel (Engine.enqueueSteer) while a run is in flight. Consumed at the top
|
package/dist/engine/turn-loop.js
CHANGED
|
@@ -553,7 +553,9 @@ export class TurnLoop {
|
|
|
553
553
|
// compaction decisions use hybrid (actual + delta) estimation rather than
|
|
554
554
|
// pure heuristics. Without this the manager falls back to char/4 estimates.
|
|
555
555
|
if (response.usage?.promptTokens !== undefined) {
|
|
556
|
-
this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length, messages);
|
|
556
|
+
const anchor = this.deps.contextManager.recordActualUsage(response.usage.promptTokens, messages.length, messages);
|
|
557
|
+
if (anchor)
|
|
558
|
+
this.deps.recordContextUsageAnchor?.(anchor);
|
|
557
559
|
}
|
|
558
560
|
messages = this.markPendingImagesConsumed(messages);
|
|
559
561
|
// Truncation that cut off a TOOL CALL: the model overflowed
|
|
@@ -800,7 +802,7 @@ export class TurnLoop {
|
|
|
800
802
|
const resultBlocks = [];
|
|
801
803
|
for (const result of results) {
|
|
802
804
|
resultBlocks.push(toolResultToBlock(result));
|
|
803
|
-
this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error);
|
|
805
|
+
this.deps.transcript.appendToolResult(result.id, result.toolName, result.result, result.error, result.contentBlocks);
|
|
804
806
|
this.config.onStream?.({ type: "tool_result", result });
|
|
805
807
|
}
|
|
806
808
|
// Fire-and-forget tool use summary (non-blocking). The whole chain is
|
package/dist/engine/types.d.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface EngineConfig {
|
|
|
75
75
|
/** Browser automation bridge (browser_* tools). Wired by the host (desktop)
|
|
76
76
|
* after construction via setBrowserBridge. Undefined → tools degrade. */
|
|
77
77
|
browserBridge?: import("../tool-system/browser-bridge.js").BrowserBridge;
|
|
78
|
+
/** Host-backed workspace switch bridge (desktop only). */
|
|
79
|
+
workspaceBridge?: import("../tool-system/workspace-bridge.js").WorkspaceBridge;
|
|
78
80
|
/** Inject a cookie credential into the built-in browser (InjectCredential
|
|
79
81
|
* tool). Wired by the host after construction via setInjectCredential.
|
|
80
82
|
* Undefined → the tool degrades with a clear "no browser" error. */
|
|
@@ -127,6 +129,12 @@ export interface EngineConfig {
|
|
|
127
129
|
* automation shown; tui hidden). Sub-agents override to "subagent".
|
|
128
130
|
*/
|
|
129
131
|
origin?: SessionOrigin;
|
|
132
|
+
/**
|
|
133
|
+
* Host-specific builtin tool surface. Desktop replaces the legacy core
|
|
134
|
+
* worktree tools with SwitchSessionWorkspace because Electron main owns the
|
|
135
|
+
* authoritative workspace switch service and UI refresh path.
|
|
136
|
+
*/
|
|
137
|
+
builtinToolHost?: "desktop";
|
|
130
138
|
/**
|
|
131
139
|
* Optional shared EngineRuntime providing pre-constructed shared resources
|
|
132
140
|
* (modelPool, toolRegistry, etc.). When provided, Engine uses these instead
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SandboxBackend } from "../../tool-system/sandbox/index.js";
|
|
2
|
+
export interface WorktreeSession {
|
|
3
|
+
originalCwd: string;
|
|
4
|
+
worktreePath: string;
|
|
5
|
+
worktreeName: string;
|
|
6
|
+
worktreeBranch: string;
|
|
7
|
+
originalBranch?: string;
|
|
8
|
+
sessionId: string;
|
|
9
|
+
createdAt: number;
|
|
10
|
+
}
|
|
11
|
+
/** Per-platform setup/cleanup scripts (a project's localEnvironment). */
|
|
12
|
+
export interface PlatformScripts {
|
|
13
|
+
default?: string;
|
|
14
|
+
macos?: string;
|
|
15
|
+
linux?: string;
|
|
16
|
+
windows?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CreateWorktreeOptions {
|
|
19
|
+
prefix?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Pick the setup/cleanup script for the running platform, falling back to
|
|
23
|
+
* `default`. Empty/whitespace-only scripts are treated as absent so a project
|
|
24
|
+
* can leave a platform key blank without spawning an empty shell. `platform`
|
|
25
|
+
* defaults to `process.platform` so callers usually omit it; tests pass a
|
|
26
|
+
* fixed value.
|
|
27
|
+
*/
|
|
28
|
+
export declare function selectPlatformScript(scripts: PlatformScripts | undefined, platform?: NodeJS.Platform): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Create an isolated git worktree for an agent session.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createWorktree(cwd: string, slug: string, sessionId: string, opts?: CreateWorktreeOptions): Promise<WorktreeSession>;
|
|
33
|
+
export interface WorktreeSetupResult {
|
|
34
|
+
/** True if no setup script was configured for this platform (nothing ran). */
|
|
35
|
+
skipped: boolean;
|
|
36
|
+
/** True if a script ran and exited 0. */
|
|
37
|
+
ok: boolean;
|
|
38
|
+
/** Combined stdout/stderr, for surfacing to the user on failure. */
|
|
39
|
+
output: string;
|
|
40
|
+
/** Exit code when a script ran; undefined when skipped. */
|
|
41
|
+
exitCode?: number | null;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Run a project's `localEnvironment.setupScripts` once, in the freshly-created
|
|
45
|
+
* worktree's root, right after `git worktree add`.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runWorktreeSetup(worktreePath: string, script: string | undefined, opts?: {
|
|
48
|
+
sandbox?: SandboxBackend;
|
|
49
|
+
shellEnv?: Record<string, string>;
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}): Promise<WorktreeSetupResult>;
|
|
53
|
+
export interface RemoveWorktreeResult {
|
|
54
|
+
/** True once `git worktree remove` completed and the directory is gone. */
|
|
55
|
+
dirRemoved: boolean;
|
|
56
|
+
/** The branch targeted for deletion when removeBranch=true. */
|
|
57
|
+
branch?: string;
|
|
58
|
+
/** True when removeBranch=true and branch deletion completed. */
|
|
59
|
+
branchDeleted?: boolean;
|
|
60
|
+
/** Non-empty when the worktree directory is gone but branch deletion failed. */
|
|
61
|
+
branchError?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface RemoveWorktreeOptions {
|
|
64
|
+
prefix?: string;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Remove a worktree and optionally its branch.
|
|
68
|
+
*/
|
|
69
|
+
export declare function removeWorktree(worktreePath: string, removeBranch?: boolean, opts?: RemoveWorktreeOptions): RemoveWorktreeResult;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { safeSpawnShell } from "../../runtime/safe-spawn.js";
|
|
4
|
+
import { buildSandboxEnv, defaultShellBinary, mergeShellEnv } from "../../runtime/spawn-common.js";
|
|
5
|
+
import { execGit, execGitSync, gitErrorMessage } from "./git-exec.js";
|
|
6
|
+
import { assertBranchNotCheckedOut, currentBranch, findGitRoot } from "./query.js";
|
|
7
|
+
import { applyPrefix, isManagedWorktreeBranch, validateWorktreeSlug } from "./slug.js";
|
|
8
|
+
/**
|
|
9
|
+
* Pick the setup/cleanup script for the running platform, falling back to
|
|
10
|
+
* `default`. Empty/whitespace-only scripts are treated as absent so a project
|
|
11
|
+
* can leave a platform key blank without spawning an empty shell. `platform`
|
|
12
|
+
* defaults to `process.platform` so callers usually omit it; tests pass a
|
|
13
|
+
* fixed value.
|
|
14
|
+
*/
|
|
15
|
+
export function selectPlatformScript(scripts, platform = process.platform) {
|
|
16
|
+
if (!scripts)
|
|
17
|
+
return undefined;
|
|
18
|
+
const key = platform === "darwin" ? "macos" : platform === "win32" ? "windows" : "linux";
|
|
19
|
+
// A blank platform key shouldn't shadow a real `default` — fall through.
|
|
20
|
+
const platformScript = scripts[key]?.trim() ? scripts[key] : undefined;
|
|
21
|
+
const candidate = platformScript ?? scripts.default;
|
|
22
|
+
const trimmed = candidate?.trim();
|
|
23
|
+
return trimmed ? candidate : undefined;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create an isolated git worktree for an agent session.
|
|
27
|
+
*/
|
|
28
|
+
export async function createWorktree(cwd, slug, sessionId, opts = {}) {
|
|
29
|
+
validateWorktreeSlug(slug);
|
|
30
|
+
const gitRoot = await findGitRoot(cwd);
|
|
31
|
+
const branchName = applyPrefix(opts.prefix, slug, sessionId);
|
|
32
|
+
const worktreePath = resolve(gitRoot, "..", `.worktrees/${slug}-${sessionId.slice(0, 8)}`);
|
|
33
|
+
await assertBranchNotCheckedOut(gitRoot, branchName);
|
|
34
|
+
const originalBranch = await currentBranch(gitRoot);
|
|
35
|
+
// The argv form keeps branchName/worktreePath as literal positional
|
|
36
|
+
// arguments even if a future caller bypasses validateWorktreeSlug.
|
|
37
|
+
await execGit(gitRoot, ["worktree", "add", "-b", branchName, worktreePath], 30000);
|
|
38
|
+
// Symlink large directories to avoid disk bloat.
|
|
39
|
+
symlinkLargeDirectories(gitRoot, worktreePath);
|
|
40
|
+
return {
|
|
41
|
+
originalCwd: cwd,
|
|
42
|
+
worktreePath,
|
|
43
|
+
worktreeName: slug,
|
|
44
|
+
worktreeBranch: branchName,
|
|
45
|
+
originalBranch,
|
|
46
|
+
sessionId,
|
|
47
|
+
createdAt: Date.now(),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Run a project's `localEnvironment.setupScripts` once, in the freshly-created
|
|
52
|
+
* worktree's root, right after `git worktree add`.
|
|
53
|
+
*/
|
|
54
|
+
export async function runWorktreeSetup(worktreePath, script, opts = {}) {
|
|
55
|
+
const trimmed = script?.trim();
|
|
56
|
+
if (!trimmed)
|
|
57
|
+
return { skipped: true, ok: true, output: "" };
|
|
58
|
+
const shell = defaultShellBinary();
|
|
59
|
+
const backend = opts.sandbox;
|
|
60
|
+
const baseEnv = backend && backend.name !== "off" ? buildSandboxEnv() : { ...process.env };
|
|
61
|
+
const env = mergeShellEnv(baseEnv, opts.shellEnv);
|
|
62
|
+
const result = await safeSpawnShell(trimmed, {
|
|
63
|
+
cwd: worktreePath,
|
|
64
|
+
env,
|
|
65
|
+
timeoutMs: opts.timeoutMs ?? 120_000,
|
|
66
|
+
maxOutputBytes: 1024 * 1024,
|
|
67
|
+
sandbox: backend,
|
|
68
|
+
shell,
|
|
69
|
+
signal: opts.signal,
|
|
70
|
+
});
|
|
71
|
+
const parts = [];
|
|
72
|
+
if (result.stdout)
|
|
73
|
+
parts.push(result.stdout);
|
|
74
|
+
if (result.stderr)
|
|
75
|
+
parts.push(result.stderr);
|
|
76
|
+
const output = parts.join("\n").trim();
|
|
77
|
+
if (result.aborted)
|
|
78
|
+
return { skipped: false, ok: false, output: output || "setup aborted" };
|
|
79
|
+
if (result.timedOut)
|
|
80
|
+
return { skipped: false, ok: false, output: output || "setup timed out" };
|
|
81
|
+
if (result.spawnFailed) {
|
|
82
|
+
return { skipped: false, ok: false, output: result.error ?? "failed to spawn setup script" };
|
|
83
|
+
}
|
|
84
|
+
return { skipped: false, ok: result.exitCode === 0, output, exitCode: result.exitCode };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Remove a worktree and optionally its branch.
|
|
88
|
+
*/
|
|
89
|
+
export function removeWorktree(worktreePath, removeBranch = false, opts = {}) {
|
|
90
|
+
// The MAIN repo root, not the worktree's own toplevel. `git rev-parse
|
|
91
|
+
// --show-toplevel` from inside a worktree returns the worktree path, which
|
|
92
|
+
// is about to be deleted; the branch-delete must run from the main repo,
|
|
93
|
+
// which outlives the worktree. Derive it from the common git dir.
|
|
94
|
+
let mainRoot;
|
|
95
|
+
try {
|
|
96
|
+
const commonDir = execGitSync(worktreePath, ["rev-parse", "--path-format=absolute", "--git-common-dir"], 5000).trim();
|
|
97
|
+
mainRoot = dirname(commonDir);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
throw new Error(`failed to inspect worktree ${worktreePath}: ${gitErrorMessage(err)}`, {
|
|
101
|
+
cause: err,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Capture the worktree's branch BEFORE removing the worktree — afterwards
|
|
105
|
+
// the directory is gone and `git branch --show-current` in it would fail.
|
|
106
|
+
let branch = "";
|
|
107
|
+
if (removeBranch) {
|
|
108
|
+
try {
|
|
109
|
+
branch = execGitSync(worktreePath, ["branch", "--show-current"], 5000).trim();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
throw new Error(`failed to determine branch for worktree ${worktreePath}: ${gitErrorMessage(err)}`, { cause: err });
|
|
113
|
+
}
|
|
114
|
+
if (!branch) {
|
|
115
|
+
throw new Error(`failed to determine branch for worktree ${worktreePath}`);
|
|
116
|
+
}
|
|
117
|
+
if (!isManagedWorktreeBranch(branch, opts.prefix)) {
|
|
118
|
+
throw new Error(`refusing to delete non-CodeShell worktree branch ${branch}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
execGitSync(mainRoot, ["worktree", "remove", worktreePath, "--force"], 30000);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
throw new Error(`failed to remove worktree ${worktreePath}: ${gitErrorMessage(err)}`, {
|
|
126
|
+
cause: err,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (removeBranch) {
|
|
130
|
+
try {
|
|
131
|
+
execGitSync(mainRoot, ["branch", "-D", branch], 10000);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
return {
|
|
135
|
+
dirRemoved: true,
|
|
136
|
+
branch,
|
|
137
|
+
branchDeleted: false,
|
|
138
|
+
branchError: gitErrorMessage(err),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return removeBranch ? { dirRemoved: true, branch, branchDeleted: true } : { dirRemoved: true };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Symlink large directories (node_modules, .venv, etc.) from main repo to
|
|
146
|
+
* worktree. Also links each monorepo workspace package's private
|
|
147
|
+
* `node_modules` (e.g. `packages/desktop/node_modules`, where bun keeps
|
|
148
|
+
* un-hoisted deps like electron) so a fresh worktree can build/run the whole
|
|
149
|
+
* workspace, not just the packages whose deps happen to be hoisted to the root.
|
|
150
|
+
*/
|
|
151
|
+
function symlinkLargeDirectories(sourceRoot, worktreePath) {
|
|
152
|
+
const largeDirs = ["node_modules", ".venv", "vendor", ".pnpm-store"];
|
|
153
|
+
for (const dir of largeDirs) {
|
|
154
|
+
linkDir(join(sourceRoot, dir), join(worktreePath, dir));
|
|
155
|
+
}
|
|
156
|
+
// Monorepo workspace packages keep private node_modules that are NOT hoisted
|
|
157
|
+
// to the root. Link `<pkg>/node_modules` for each package under `packages/`.
|
|
158
|
+
const packagesDir = join(sourceRoot, "packages");
|
|
159
|
+
if (!dirExists(packagesDir))
|
|
160
|
+
return;
|
|
161
|
+
let pkgNames;
|
|
162
|
+
try {
|
|
163
|
+
pkgNames = readdirSync(packagesDir);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
for (const pkg of pkgNames) {
|
|
169
|
+
const source = join(packagesDir, pkg, "node_modules");
|
|
170
|
+
if (!dirExists(source))
|
|
171
|
+
continue;
|
|
172
|
+
const targetPkgDir = join(worktreePath, "packages", pkg);
|
|
173
|
+
// The worktree only has package dirs that exist in this branch's tree; a
|
|
174
|
+
// package present in the main repo but not checked out here is skipped.
|
|
175
|
+
if (!dirExists(targetPkgDir)) {
|
|
176
|
+
try {
|
|
177
|
+
mkdirSync(targetPkgDir, { recursive: true });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
linkDir(source, join(targetPkgDir, "node_modules"));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function dirExists(path) {
|
|
187
|
+
try {
|
|
188
|
+
return lstatSync(path).isDirectory();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function linkDir(source, target) {
|
|
195
|
+
// Windows directory symlinks need admin/Developer Mode and throw EPERM for a
|
|
196
|
+
// normal user; NTFS junctions don't and behave the same for our purpose.
|
|
197
|
+
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
198
|
+
if (dirExists(source) && !existsSync(target)) {
|
|
199
|
+
try {
|
|
200
|
+
symlinkSync(source, target, linkType);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Symlink/junction might fail on some systems — non-fatal.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface WorktreeDiffSummary {
|
|
2
|
+
/** Best-effort base used for the branch comparison, usually main/master. */
|
|
3
|
+
baseRef?: string;
|
|
4
|
+
/** Unique files changed either by commits ahead of base or by uncommitted changes. */
|
|
5
|
+
changedFiles: number;
|
|
6
|
+
/** Commits reachable from HEAD but not from baseRef. */
|
|
7
|
+
aheadCommits: number;
|
|
8
|
+
/** True when `git status --porcelain` reports local changes. */
|
|
9
|
+
hasUncommittedChanges: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function getWorktreeDiff(worktreePath: string, baseRef?: string): Promise<WorktreeDiffSummary>;
|
|
12
|
+
export declare function worktreeHasUncommittedChanges(worktreePath: string): Promise<boolean>;
|
|
13
|
+
export declare function worktreeHasUncommittedOrAheadChanges(worktreePath: string, baseRef?: string): Promise<boolean>;
|
|
14
|
+
export declare function findComparisonBaseRef(cwd: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { gitOutput } from "./git-exec.js";
|
|
2
|
+
export async function getWorktreeDiff(worktreePath, baseRef) {
|
|
3
|
+
const comparisonBase = baseRef && (await commitRefExists(worktreePath, baseRef))
|
|
4
|
+
? baseRef
|
|
5
|
+
: await findComparisonBaseRef(worktreePath);
|
|
6
|
+
return diffSummary(worktreePath, comparisonBase);
|
|
7
|
+
}
|
|
8
|
+
export async function worktreeHasUncommittedChanges(worktreePath) {
|
|
9
|
+
const out = await gitOutput(worktreePath, ["status", "--porcelain"], 10000);
|
|
10
|
+
return (out?.trim().length ?? 0) > 0;
|
|
11
|
+
}
|
|
12
|
+
export async function worktreeHasUncommittedOrAheadChanges(worktreePath, baseRef) {
|
|
13
|
+
if (await worktreeHasUncommittedChanges(worktreePath))
|
|
14
|
+
return true;
|
|
15
|
+
const comparisonBase = baseRef && (await commitRefExists(worktreePath, baseRef))
|
|
16
|
+
? baseRef
|
|
17
|
+
: await findComparisonBaseRef(worktreePath);
|
|
18
|
+
return comparisonBase ? (await aheadCommitCount(worktreePath, comparisonBase)) > 0 : false;
|
|
19
|
+
}
|
|
20
|
+
export async function findComparisonBaseRef(cwd) {
|
|
21
|
+
for (const ref of ["main", "master", "origin/main", "origin/master"]) {
|
|
22
|
+
if (await commitRefExists(cwd, ref))
|
|
23
|
+
return ref;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
async function commitRefExists(cwd, ref) {
|
|
28
|
+
const out = await gitOutput(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], 5000);
|
|
29
|
+
return out !== undefined;
|
|
30
|
+
}
|
|
31
|
+
async function diffSummary(cwd, baseRef) {
|
|
32
|
+
const [dirtyFiles, committedFiles, aheadCommits] = await Promise.all([
|
|
33
|
+
statusFileSet(cwd),
|
|
34
|
+
baseRef ? changedFilesSinceBase(cwd, baseRef) : Promise.resolve(new Set()),
|
|
35
|
+
baseRef ? aheadCommitCount(cwd, baseRef) : Promise.resolve(0),
|
|
36
|
+
]);
|
|
37
|
+
const changedFiles = new Set([...dirtyFiles, ...committedFiles]);
|
|
38
|
+
return {
|
|
39
|
+
...(baseRef ? { baseRef } : {}),
|
|
40
|
+
changedFiles: changedFiles.size,
|
|
41
|
+
aheadCommits,
|
|
42
|
+
hasUncommittedChanges: dirtyFiles.size > 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
async function changedFilesSinceBase(cwd, baseRef) {
|
|
46
|
+
const out = await gitOutput(cwd, ["diff", "--name-only", `${baseRef}...HEAD`]);
|
|
47
|
+
const raw = out ?? (await gitOutput(cwd, ["diff", "--name-only", `${baseRef}..HEAD`])) ?? "";
|
|
48
|
+
return new Set(raw
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter(Boolean));
|
|
52
|
+
}
|
|
53
|
+
async function aheadCommitCount(cwd, baseRef) {
|
|
54
|
+
const out = await gitOutput(cwd, ["rev-list", "--count", `${baseRef}..HEAD`]);
|
|
55
|
+
const n = Number.parseInt(out?.trim() ?? "0", 10);
|
|
56
|
+
return Number.isFinite(n) ? n : 0;
|
|
57
|
+
}
|
|
58
|
+
async function statusFileSet(cwd) {
|
|
59
|
+
const raw = (await gitOutput(cwd, ["status", "--porcelain=v1"])) ?? "";
|
|
60
|
+
const files = new Set();
|
|
61
|
+
for (const line of raw.split("\n")) {
|
|
62
|
+
if (!line.trim())
|
|
63
|
+
continue;
|
|
64
|
+
const path = line.slice(3).trim();
|
|
65
|
+
if (!path)
|
|
66
|
+
continue;
|
|
67
|
+
const renameTarget = path.includes(" -> ") ? path.split(" -> ").at(-1) : path;
|
|
68
|
+
if (renameTarget)
|
|
69
|
+
files.add(unquoteGitPath(renameTarget));
|
|
70
|
+
}
|
|
71
|
+
return files;
|
|
72
|
+
}
|
|
73
|
+
function unquoteGitPath(path) {
|
|
74
|
+
if (!path.startsWith('"') || !path.endsWith('"'))
|
|
75
|
+
return path;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(path);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return path.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const GIT_BIN: string;
|
|
2
|
+
export declare function execGit(cwd: string, args: string[], timeout?: number): Promise<string>;
|
|
3
|
+
export declare function execGitSync(cwd: string, args: string[], timeout?: number): string;
|
|
4
|
+
export declare function gitOutput(cwd: string, args: string[], timeout?: number): Promise<string | undefined>;
|
|
5
|
+
export declare function gitOutputSync(cwd: string, args: string[], timeout?: number): string | undefined;
|
|
6
|
+
export declare function gitErrorMessage(err: unknown): string;
|
|
7
|
+
export declare function normalizeBranchName(branch: string): string;
|