@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.
Files changed (87) hide show
  1. package/dist/capability-control/service.d.ts +2 -0
  2. package/dist/capability-control/service.js +4 -2
  3. package/dist/cc-orchestrator/codex-session-history.js +1 -1
  4. package/dist/cc-orchestrator/external-agent-bindings.js +1 -1
  5. package/dist/cc-orchestrator/external-agent-session-store.js +3 -1
  6. package/dist/cc-orchestrator/session-history.js +2 -2
  7. package/dist/cli/agent-server-stdio.js +7 -2
  8. package/dist/context/compaction.d.ts +8 -1
  9. package/dist/context/compaction.js +49 -4
  10. package/dist/context/manager.d.ts +11 -2
  11. package/dist/context/manager.js +64 -3
  12. package/dist/credentials/inject-credential-tool.d.ts +3 -1
  13. package/dist/credentials/inject-credential-tool.js +29 -10
  14. package/dist/credentials/use-credential-tool.d.ts +1 -0
  15. package/dist/credentials/use-credential-tool.js +3 -0
  16. package/dist/engine/engine.d.ts +9 -0
  17. package/dist/engine/engine.js +96 -17
  18. package/dist/engine/turn-loop.d.ts +3 -1
  19. package/dist/engine/turn-loop.js +4 -2
  20. package/dist/engine/types.d.ts +8 -0
  21. package/dist/git/worktree/crud.d.ts +69 -0
  22. package/dist/git/worktree/crud.js +206 -0
  23. package/dist/git/worktree/diff.d.ts +14 -0
  24. package/dist/git/worktree/diff.js +82 -0
  25. package/dist/git/worktree/git-exec.d.ts +7 -0
  26. package/dist/git/worktree/git-exec.js +51 -0
  27. package/dist/git/worktree/index.d.ts +5 -0
  28. package/dist/git/worktree/index.js +5 -0
  29. package/dist/git/worktree/query.d.ts +42 -0
  30. package/dist/git/worktree/query.js +121 -0
  31. package/dist/git/worktree/slug.d.ts +11 -0
  32. package/dist/git/worktree/slug.js +58 -0
  33. package/dist/git/worktree.d.ts +1 -127
  34. package/dist/git/worktree.js +5 -464
  35. package/dist/index.d.ts +28 -27
  36. package/dist/index.js +24 -24
  37. package/dist/plugins/installer/checkUpdate.d.ts +4 -1
  38. package/dist/plugins/installer/checkUpdate.js +4 -2
  39. package/dist/plugins/installer/install.js +2 -0
  40. package/dist/plugins/installer/parseSource.d.ts +4 -1
  41. package/dist/plugins/installer/parseSource.js +28 -10
  42. package/dist/plugins/installer/update.d.ts +4 -1
  43. package/dist/plugins/installer/update.js +5 -3
  44. package/dist/plugins/parseMarketplaceInput.d.ts +4 -1
  45. package/dist/plugins/parseMarketplaceInput.js +4 -3
  46. package/dist/preset/index.d.ts +1 -0
  47. package/dist/preset/index.js +6 -0
  48. package/dist/prompt/sections/base.md +1 -1
  49. package/dist/protocol/chat-session-manager.js +7 -0
  50. package/dist/protocol/server.d.ts +10 -0
  51. package/dist/protocol/server.js +88 -4
  52. package/dist/protocol/types.d.ts +6 -0
  53. package/dist/protocol/types.js +2 -0
  54. package/dist/runtime/background-shell.js +2 -3
  55. package/dist/services/auto-dream.d.ts +15 -4
  56. package/dist/services/auto-dream.js +20 -20
  57. package/dist/services/dream-consolidation.d.ts +2 -3
  58. package/dist/services/dream-consolidation.js +65 -15
  59. package/dist/services/extract-memories.d.ts +14 -5
  60. package/dist/services/extract-memories.js +20 -3
  61. package/dist/services/global-dream-promotion.d.ts +23 -0
  62. package/dist/services/global-dream-promotion.js +112 -0
  63. package/dist/services/memory-orchestrator.js +347 -34
  64. package/dist/session/memory.d.ts +56 -17
  65. package/dist/session/memory.js +337 -78
  66. package/dist/session/session-manager.d.ts +1 -1
  67. package/dist/session/session-manager.js +6 -6
  68. package/dist/session/transcript.d.ts +1 -1
  69. package/dist/session/transcript.js +17 -5
  70. package/dist/settings/manager.js +43 -12
  71. package/dist/settings/schema.d.ts +21 -0
  72. package/dist/settings/schema.js +12 -0
  73. package/dist/tool-system/builtin/index.js +18 -8
  74. package/dist/tool-system/builtin/memory.js +40 -8
  75. package/dist/tool-system/builtin/view-image.d.ts +2 -2
  76. package/dist/tool-system/builtin/view-image.js +100 -19
  77. package/dist/tool-system/builtin/worktree.d.ts +2 -0
  78. package/dist/tool-system/builtin/worktree.js +59 -11
  79. package/dist/tool-system/context.d.ts +11 -1
  80. package/dist/tool-system/mcp-manager.d.ts +8 -5
  81. package/dist/tool-system/mcp-manager.js +85 -55
  82. package/dist/tool-system/path-policy.d.ts +2 -0
  83. package/dist/tool-system/path-policy.js +28 -12
  84. package/dist/tool-system/workspace-bridge.d.ts +11 -0
  85. package/dist/tool-system/workspace-bridge.js +1 -0
  86. package/dist/types.d.ts +14 -0
  87. package/package.json +1 -1
@@ -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, } from "../capability-control/overlay.js";
45
+ import { effectiveDisabledList, effectiveBuiltinLists } from "../capability-control/overlay.js";
46
46
  import { computeEffectiveDisabledLists } from "../capability-control/disabled-lists.js";
47
47
  import { FileHistory } from "../session/file-history.js";
48
48
  import { patchBackupTargets } from "../tool-system/builtin/apply-patch/backup-targets.js";
49
49
  import { resolveSandboxBackend, } from "../tool-system/sandbox/index.js";
50
- import { resolveAgentPreset, resolveBuiltinToolNames, } from "../preset/index.js";
50
+ import { resolveAgentPreset, resolveBuiltinToolNames } from "../preset/index.js";
51
51
  import { ModelPool } from "../llm/model-pool.js";
52
52
  import { AgentDefinitionRegistry } from "../agent/agent-definition-registry.js";
53
53
  import { defaultCacheDir } from "../llm/model-cache.js";
@@ -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
- ? messages.reduce((sum, m) => {
1326
- const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
1327
- return sum + Math.ceil(text.length / 4);
1328
- }, 0)
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({ preset: prevPresetName })
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({ preset: nextPreset.name })
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
@@ -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
@@ -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;