@adhdev/daemon-core 0.9.76-rc.1 → 0.9.76-rc.11

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.
@@ -14,5 +14,6 @@ export interface CoordinatorPromptContext {
14
14
  mesh: LocalMeshEntry;
15
15
  status?: RepoMeshStatus;
16
16
  userInstruction?: string;
17
+ coordinatorCliType?: string;
17
18
  }
18
19
  export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string;
@@ -149,6 +149,10 @@ export interface LocalMeshNodeEntry {
149
149
  policy: RepoMeshNodePolicy;
150
150
  /** For single-machine mesh: same daemon, different worktree */
151
151
  isLocalWorktree?: boolean;
152
+ /** Branch this worktree tracks (set when created via clone_mesh_node) */
153
+ worktreeBranch?: string;
154
+ /** Node ID this worktree was cloned from */
155
+ clonedFromNodeId?: string;
152
156
  }
153
157
  export interface RepoMeshStatus {
154
158
  meshId: string;
@@ -328,6 +328,15 @@ export interface CompactSessionEntry {
328
328
  settings?: Record<string, any>;
329
329
  }
330
330
  export type VersionUpdateReason = 'force_update_below' | 'major_minor_mismatch' | 'patch_mismatch' | 'daemon_ahead';
331
+ export type ReleaseChannel = 'stable' | 'preview';
332
+ export type NpmUpdateTag = 'latest' | 'next';
333
+ export interface VersionUpdatePolicy {
334
+ channel: ReleaseChannel;
335
+ npmTag: NpmUpdateTag;
336
+ targetVersion: string;
337
+ minVersion?: string;
338
+ updateCommand: string;
339
+ }
331
340
  /** Available provider information */
332
341
  export interface AvailableProviderInfo {
333
342
  type: string;
@@ -469,6 +478,10 @@ export interface CompactDaemonEntry {
469
478
  versionMismatch?: boolean;
470
479
  versionUpdateRequired?: boolean;
471
480
  versionUpdateReason?: VersionUpdateReason;
481
+ releaseChannel?: ReleaseChannel;
482
+ updateChannel?: ReleaseChannel;
483
+ updatePolicy?: VersionUpdatePolicy;
484
+ updateCommand?: string;
472
485
  terminalBackend?: TerminalBackendStatus;
473
486
  detectedIdes?: DetectedIdeInfo[];
474
487
  availableProviders?: AvailableProviderInfo[];
@@ -490,10 +503,14 @@ export interface CloudDaemonSummaryEntry {
490
503
  versionMismatch?: boolean;
491
504
  versionUpdateRequired?: boolean;
492
505
  versionUpdateReason?: VersionUpdateReason;
506
+ releaseChannel?: ReleaseChannel;
507
+ updateChannel?: ReleaseChannel;
508
+ updatePolicy?: VersionUpdatePolicy;
509
+ updateCommand?: string;
493
510
  terminalBackend?: TerminalBackendStatus;
494
511
  }
495
512
  /** Minimal daemon bootstrap payload used by dashboard WS to initiate P2P. */
496
- export interface DashboardBootstrapDaemonEntry {
513
+ export interface DashboardBootstrapDaemonEntry extends Partial<CloudDaemonSummaryEntry> {
497
514
  id: string;
498
515
  p2p?: StatusReportPayload['p2p'];
499
516
  timestamp?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.76-rc.1",
3
+ "version": "0.9.76-rc.11",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -266,9 +266,10 @@ export class ProviderCliAdapter implements CliAdapter {
266
266
  const currentSnapshot = normalizeScreenSnapshot(screenText);
267
267
  const lastSnapshot = this.lastScreenSnapshot;
268
268
  if (!lastSnapshot || lastSnapshot === currentSnapshot) return screenText;
269
- const staleSnapshotLooksActive = /\besc to (?:interrupt|stop)\b|Enter to interrupt, Ctrl\+C to cancel/i.test(lastSnapshot);
270
- const currentScreenLooksIdle = /(?:^|\n|\r)\s*[❯›>]\s*(?:\n|\r|$)/.test(screenText)
271
- && !/\besc to (?:interrupt|stop)\b|Enter to interrupt, Ctrl\+C to cancel/i.test(screenText);
269
+ const activeScreenPattern = /\besc to (?:interrupt|stop)\b|Enter to interrupt, Ctrl\+C to cancel|Enter to confirm\s*[·•-]\s*Esc to cancel|\b(?:MCP servers?|tool calls?)\b[^\n\r]{0,160}\brequire approval\b/i;
270
+ const staleSnapshotLooksActive = activeScreenPattern.test(lastSnapshot);
271
+ const currentScreenLooksIdle = /(?:^|\n|\r)\s*[❯›>]\s*(?:Try\s+["“][^\n\r"”]+["”])?\s*(?:\n|\r|$)/.test(screenText)
272
+ && !activeScreenPattern.test(screenText);
272
273
  if (staleSnapshotLooksActive && currentScreenLooksIdle) return screenText;
273
274
  if (currentSnapshot.length >= lastSnapshot.length) return screenText;
274
275
  // Terminal screen reads can miss a just-rendered completed Hermes box while
@@ -127,7 +127,7 @@ function resolveAdhdevMcpServerLaunch(options: {
127
127
  if (!entryPath) return null
128
128
  return {
129
129
  command: options.nodeExecutable?.trim() || process.execPath,
130
- args: [entryPath, '--repo-mesh', options.meshId],
130
+ args: [entryPath, '--mode', 'ipc', '--repo-mesh', options.meshId],
131
131
  }
132
132
  }
133
133
 
@@ -41,6 +41,10 @@ import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDa
41
41
 
42
42
  type ReleaseChannel = 'stable' | 'preview';
43
43
  const CHANNEL_NPM_TAG: Record<ReleaseChannel, 'latest' | 'next'> = { stable: 'latest', preview: 'next' };
44
+ const CHANNEL_SERVER_URL: Record<ReleaseChannel, string> = {
45
+ stable: 'https://api.adhf.dev',
46
+ preview: 'https://api-preview.adhf.dev',
47
+ };
44
48
 
45
49
  function normalizeReleaseChannel(value: unknown): ReleaseChannel | null {
46
50
  if (typeof value !== 'string') return null;
@@ -222,6 +226,43 @@ export class DaemonCommandRouter {
222
226
  this.deps = deps;
223
227
  }
224
228
 
229
+ private getCachedInlineMesh(meshId: string, inlineMesh?: unknown): any | undefined {
230
+ if (inlineMesh && typeof inlineMesh === 'object') {
231
+ this.inlineMeshCache.set(meshId, inlineMesh as any);
232
+ return inlineMesh as any;
233
+ }
234
+ return this.inlineMeshCache.get(meshId);
235
+ }
236
+
237
+ private async getMeshForCommand(meshId: string, inlineMesh?: unknown): Promise<{ mesh: any; inline: boolean } | null> {
238
+ try {
239
+ const { getMesh } = await import('../config/mesh-config.js');
240
+ const mesh = getMesh(meshId);
241
+ if (mesh) return { mesh, inline: false };
242
+ } catch { /* fall through to inline cache */ }
243
+ const cached = this.getCachedInlineMesh(meshId, inlineMesh);
244
+ return cached ? { mesh: cached, inline: true } : null;
245
+ }
246
+
247
+ private updateInlineMeshNode(meshId: string, mesh: any, node: any): void {
248
+ if (!mesh || !Array.isArray(mesh.nodes) || !node?.id) return;
249
+ const idx = mesh.nodes.findIndex((entry: any) => entry?.id === node.id || entry?.nodeId === node.id);
250
+ if (idx >= 0) mesh.nodes[idx] = node;
251
+ else mesh.nodes.push(node);
252
+ mesh.updatedAt = new Date().toISOString();
253
+ this.inlineMeshCache.set(meshId, mesh);
254
+ }
255
+
256
+ private removeInlineMeshNode(meshId: string, mesh: any, nodeId: string): boolean {
257
+ if (!mesh || !Array.isArray(mesh.nodes)) return false;
258
+ const idx = mesh.nodes.findIndex((entry: any) => entry?.id === nodeId || entry?.nodeId === nodeId);
259
+ if (idx === -1) return false;
260
+ mesh.nodes.splice(idx, 1);
261
+ mesh.updatedAt = new Date().toISOString();
262
+ this.inlineMeshCache.set(meshId, mesh);
263
+ return true;
264
+ }
265
+
225
266
  private async traceSessionHostAction<T>(
226
267
  action: string,
227
268
  args: any,
@@ -892,6 +933,7 @@ export class DaemonCommandRouter {
892
933
  // Check channel-pinned dist-tag and resolve it to a concrete install version.
893
934
  const latest = String(execNpmCommandSync(['view', `${pkgName}@${npmTag}`, 'version'], { encoding: 'utf-8', timeout: 10000 }, npmSurface)).trim();
894
935
  LOG.info('Upgrade', `Latest ${pkgName}@${npmTag}: v${latest}`);
936
+ updateConfig({ updateChannel: channel, serverUrl: CHANNEL_SERVER_URL[channel] } as any);
895
937
  let currentInstalled: string | null = null;
896
938
  try {
897
939
  const currentJson = String(execNpmCommandSync(['ls', '-g', pkgName, '--depth=0', '--json'], {
@@ -1017,14 +1059,107 @@ export class DaemonCommandRouter {
1017
1059
  const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
1018
1060
  if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
1019
1061
  try {
1020
- const { removeNode } = await import('../config/mesh-config.js');
1021
- const removed = removeNode(meshId, nodeId);
1062
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
1063
+ const mesh = meshRecord?.mesh;
1064
+ const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
1065
+
1066
+ // If this is a worktree node, clean up the git worktree first
1067
+ if (node?.isLocalWorktree && node.workspace) {
1068
+ try {
1069
+ const sourceNode = node.clonedFromNodeId
1070
+ ? mesh?.nodes.find((n: any) => n.id === node.clonedFromNodeId || n.nodeId === node.clonedFromNodeId)
1071
+ : mesh?.nodes.find((n: any) => !n.isLocalWorktree);
1072
+ const repoRoot = sourceNode?.repoRoot || sourceNode?.workspace;
1073
+ if (repoRoot) {
1074
+ const { removeWorktree } = await import('../git/git-worktree.js');
1075
+ await removeWorktree(repoRoot, node.workspace);
1076
+ }
1077
+ } catch (e: any) {
1078
+ LOG.warn('MeshNode', `Worktree cleanup failed for ${nodeId}: ${e.message}`);
1079
+ // Continue with node removal even if worktree cleanup fails
1080
+ }
1081
+ }
1082
+
1083
+ let removed = false;
1084
+ if (meshRecord?.inline) {
1085
+ removed = this.removeInlineMeshNode(meshId, mesh, nodeId);
1086
+ } else {
1087
+ const { removeNode } = await import('../config/mesh-config.js');
1088
+ removed = removeNode(meshId, nodeId);
1089
+ }
1022
1090
  return { success: true, removed };
1023
1091
  } catch (e: any) {
1024
1092
  return { success: false, error: e.message };
1025
1093
  }
1026
1094
  }
1027
1095
 
1096
+ case 'clone_mesh_node': {
1097
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1098
+ const sourceNodeId = typeof args?.sourceNodeId === 'string' ? args.sourceNodeId.trim() : '';
1099
+ const branch = typeof args?.branch === 'string' ? args.branch.trim() : '';
1100
+ const baseBranch = typeof args?.baseBranch === 'string' ? args.baseBranch.trim() : undefined;
1101
+ if (!meshId) return { success: false, error: 'meshId required' };
1102
+ if (!sourceNodeId) return { success: false, error: 'sourceNodeId required' };
1103
+ if (!branch) return { success: false, error: 'branch required' };
1104
+
1105
+ try {
1106
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
1107
+ const mesh = meshRecord?.mesh;
1108
+ if (!mesh) return { success: false, error: 'Mesh not found' };
1109
+
1110
+ const sourceNode = mesh.nodes?.find((n: any) => n.id === sourceNodeId || n.nodeId === sourceNodeId);
1111
+ if (!sourceNode) return { success: false, error: `Source node '${sourceNodeId}' not found in mesh` };
1112
+
1113
+ const repoRoot = sourceNode.repoRoot || sourceNode.workspace;
1114
+ const { createWorktree } = await import('../git/git-worktree.js');
1115
+ const result = await createWorktree({
1116
+ repoRoot,
1117
+ branch,
1118
+ baseBranch,
1119
+ meshName: mesh.name,
1120
+ });
1121
+
1122
+ let node: any;
1123
+ if (meshRecord.inline) {
1124
+ const { randomUUID } = await import('crypto');
1125
+ node = {
1126
+ id: `node_${randomUUID().replace(/-/g, '')}`,
1127
+ workspace: result.worktreePath,
1128
+ repoRoot: result.worktreePath,
1129
+ daemonId: sourceNode.daemonId,
1130
+ userOverrides: { ...(sourceNode.userOverrides || {}) },
1131
+ policy: { ...(sourceNode.policy || {}) },
1132
+ isLocalWorktree: true,
1133
+ worktreeBranch: result.branch,
1134
+ clonedFromNodeId: sourceNodeId,
1135
+ };
1136
+ this.updateInlineMeshNode(meshId, mesh, node);
1137
+ } else {
1138
+ const { addNode } = await import('../config/mesh-config.js');
1139
+ node = addNode(meshId, {
1140
+ workspace: result.worktreePath,
1141
+ repoRoot: result.worktreePath,
1142
+ daemonId: sourceNode.daemonId,
1143
+ userOverrides: { ...(sourceNode.userOverrides || {}) },
1144
+ isLocalWorktree: true,
1145
+ worktreeBranch: result.branch,
1146
+ clonedFromNodeId: sourceNodeId,
1147
+ policy: { ...(sourceNode.policy || {}) },
1148
+ });
1149
+ if (!node) return { success: false, error: 'Failed to register worktree node' };
1150
+ }
1151
+
1152
+ return {
1153
+ success: true,
1154
+ node,
1155
+ worktreePath: result.worktreePath,
1156
+ branch: result.branch,
1157
+ };
1158
+ } catch (e: any) {
1159
+ return { success: false, error: e.message };
1160
+ }
1161
+ }
1162
+
1028
1163
  // ─── Mesh Coordinator Launch ───
1029
1164
  case 'launch_mesh_coordinator': {
1030
1165
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
@@ -1107,6 +1242,25 @@ export class DaemonCommandRouter {
1107
1242
  };
1108
1243
  }
1109
1244
 
1245
+ // Build the coordinator prompt before mutating workspace config or launching.
1246
+ // Prompt generation failures are configuration/data-shape errors; fail closed so
1247
+ // broken mesh state is visible instead of silently launching with weaker rules.
1248
+ let systemPrompt = '';
1249
+ try {
1250
+ systemPrompt = buildCoordinatorSystemPrompt({ mesh, coordinatorCliType: cliType });
1251
+ } catch (error: any) {
1252
+ const message = error?.message || String(error);
1253
+ LOG.error('MeshCoordinator', `Failed to build coordinator prompt: ${message}`);
1254
+ return {
1255
+ success: false,
1256
+ code: 'mesh_coordinator_prompt_failed',
1257
+ error: `Failed to build Repo Mesh coordinator prompt: ${message}`,
1258
+ meshId,
1259
+ cliType,
1260
+ workspace,
1261
+ };
1262
+ }
1263
+
1110
1264
  // 1. Write provider-declared MCP config to workspace for CLIs that auto-import it.
1111
1265
  const { existsSync, readFileSync, writeFileSync, copyFileSync } = await import('fs');
1112
1266
  const mcpConfigPath = coordinatorSetup.configPath;
@@ -1131,6 +1285,7 @@ export class DaemonCommandRouter {
1131
1285
  if (args?.inlineMesh) {
1132
1286
  mcpServerEntry.env = {
1133
1287
  ADHDEV_INLINE_MESH: JSON.stringify(mesh),
1288
+ ADHDEV_MCP_TRANSPORT: 'ipc',
1134
1289
  };
1135
1290
  }
1136
1291
  const mcpConfig = {
@@ -1143,14 +1298,6 @@ export class DaemonCommandRouter {
1143
1298
  writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
1144
1299
  LOG.info('MeshCoordinator', `Wrote ${mcpConfigPath} with ${coordinatorSetup.serverName} server`);
1145
1300
 
1146
- // 2. Build coordinator system prompt
1147
- let systemPrompt = '';
1148
- try {
1149
- systemPrompt = buildCoordinatorSystemPrompt({ mesh });
1150
- } catch {
1151
- systemPrompt = `You are a Repo Mesh Coordinator for "${mesh.name}". Use the adhdev-mesh MCP tools (mesh_status, mesh_list_nodes, mesh_send_task, mesh_read_chat, mesh_launch_session, etc.) to orchestrate work across ${mesh.nodes.length} node(s).`;
1152
- }
1153
-
1154
1301
  const cliArgs: string[] = [];
1155
1302
  if (systemPrompt) {
1156
1303
  cliArgs.push('--append-system-prompt', systemPrompt);
@@ -160,9 +160,12 @@ export function deleteMesh(meshId: string): boolean {
160
160
  export interface AddNodeOptions {
161
161
  workspace: string;
162
162
  repoRoot?: string;
163
+ daemonId?: string;
163
164
  userOverrides?: Partial<RepoMeshNodeCapabilities>;
164
165
  policy?: RepoMeshNodePolicy;
165
166
  isLocalWorktree?: boolean;
167
+ worktreeBranch?: string;
168
+ clonedFromNodeId?: string;
166
169
  }
167
170
 
168
171
  export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntry | undefined {
@@ -183,9 +186,12 @@ export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntr
183
186
  id: `node_${randomUUID().replace(/-/g, '')}`,
184
187
  workspace: opts.workspace.trim(),
185
188
  repoRoot: opts.repoRoot,
189
+ daemonId: opts.daemonId,
186
190
  userOverrides: opts.userOverrides || {},
187
191
  policy: opts.policy || {},
188
192
  isLocalWorktree: opts.isLocalWorktree,
193
+ worktreeBranch: opts.worktreeBranch,
194
+ clonedFromNodeId: opts.clonedFromNodeId,
189
195
  };
190
196
 
191
197
  mesh.nodes.push(node);
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Git Worktree — Create/remove/list worktrees for Repo Mesh node cloning
3
+ *
4
+ * Used by the `clone_mesh_node` daemon command to create isolated
5
+ * worktree-based nodes for parallel branch work within a mesh.
6
+ *
7
+ * Worktrees are placed outside the source repo to avoid .gitignore
8
+ * pollution and submodule conflicts:
9
+ * <repoParent>/.adhdev-worktrees/<meshName>/<branch>/
10
+ */
11
+
12
+ import * as path from 'node:path';
13
+ import { mkdir } from 'node:fs/promises';
14
+ import { existsSync } from 'node:fs';
15
+ import { execFile } from 'node:child_process';
16
+ import { promisify } from 'node:util';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ const WORKTREE_DIR_NAME = '.adhdev-worktrees';
21
+ const GIT_TIMEOUT_MS = 30_000;
22
+ const GIT_MAX_BUFFER = 4 * 1024 * 1024;
23
+
24
+ // ─── Types ──────────────────────────────────────
25
+
26
+ export interface WorktreeCreateOptions {
27
+ /** Absolute path to the source repo's git root */
28
+ repoRoot: string;
29
+ /** Branch name for the new worktree */
30
+ branch: string;
31
+ /** Starting point for the branch (default: HEAD) */
32
+ baseBranch?: string;
33
+ /** Mesh name, used for organizing worktree directories */
34
+ meshName: string;
35
+ /** Override the auto-resolved target directory */
36
+ targetDir?: string;
37
+ }
38
+
39
+ export interface WorktreeCreateResult {
40
+ success: true;
41
+ worktreePath: string;
42
+ branch: string;
43
+ }
44
+
45
+ export interface WorktreeEntry {
46
+ path: string;
47
+ head: string;
48
+ branch: string | null;
49
+ bare: boolean;
50
+ }
51
+
52
+ export interface WorktreeRemoveResult {
53
+ success: true;
54
+ removedPath: string;
55
+ }
56
+
57
+ // ─── Path Resolution ────────────────────────────
58
+
59
+ /**
60
+ * Resolve the target directory for a new worktree.
61
+ * Places worktrees at: <repoParent>/.adhdev-worktrees/<meshName>/<branch>/
62
+ */
63
+ export function resolveWorktreePath(repoRoot: string, meshName: string, branch: string): string {
64
+ // Sanitize branch name for filesystem (e.g. feat/auth → feat-auth)
65
+ const safeBranch = branch.replace(/[/\\:*?"<>|]/g, '-').replace(/^\.+|\.+$/g, '');
66
+ const safeMeshName = meshName.replace(/[/\\:*?"<>|]/g, '-').replace(/^\.+|\.+$/g, '');
67
+ const parentDir = path.dirname(repoRoot);
68
+ return path.join(parentDir, WORKTREE_DIR_NAME, safeMeshName, safeBranch);
69
+ }
70
+
71
+ // ─── Create ─────────────────────────────────────
72
+
73
+ /**
74
+ * Create a new git worktree with a fresh branch.
75
+ *
76
+ * Runs: git worktree add <targetDir> -b <branch> [baseBranch]
77
+ */
78
+ export async function createWorktree(opts: WorktreeCreateOptions): Promise<WorktreeCreateResult> {
79
+ const { repoRoot, branch, baseBranch, meshName } = opts;
80
+ const targetDir = opts.targetDir || resolveWorktreePath(repoRoot, meshName, branch);
81
+
82
+ if (existsSync(targetDir)) {
83
+ throw new Error(`Worktree target directory already exists: ${targetDir}`);
84
+ }
85
+
86
+ // Ensure parent directory exists
87
+ await mkdir(path.dirname(targetDir), { recursive: true });
88
+
89
+ const args = ['worktree', 'add', targetDir, '-b', branch];
90
+ if (baseBranch) {
91
+ args.push(baseBranch);
92
+ }
93
+
94
+ try {
95
+ await execFileAsync('git', args, {
96
+ cwd: repoRoot,
97
+ encoding: 'utf8',
98
+ timeout: GIT_TIMEOUT_MS,
99
+ maxBuffer: GIT_MAX_BUFFER,
100
+ windowsHide: true,
101
+ });
102
+ } catch (error: any) {
103
+ const stderr = typeof error.stderr === 'string' ? error.stderr : '';
104
+ // Clean error messages for common failures
105
+ if (/already exists/i.test(stderr)) {
106
+ throw new Error(`Branch '${branch}' already exists or is checked out in another worktree`);
107
+ }
108
+ throw new Error(`git worktree add failed: ${stderr.trim() || error.message}`);
109
+ }
110
+
111
+ return {
112
+ success: true,
113
+ worktreePath: targetDir,
114
+ branch,
115
+ };
116
+ }
117
+
118
+ // ─── Remove ─────────────────────────────────────
119
+
120
+ /**
121
+ * Remove a git worktree and clean up the directory.
122
+ *
123
+ * Runs: git worktree remove <worktreePath> --force
124
+ */
125
+ export async function removeWorktree(repoRoot: string, worktreePath: string): Promise<WorktreeRemoveResult> {
126
+ if (!existsSync(worktreePath)) {
127
+ // Already gone — just prune
128
+ await pruneWorktrees(repoRoot);
129
+ return { success: true, removedPath: worktreePath };
130
+ }
131
+
132
+ try {
133
+ await execFileAsync('git', ['worktree', 'remove', worktreePath, '--force'], {
134
+ cwd: repoRoot,
135
+ encoding: 'utf8',
136
+ timeout: GIT_TIMEOUT_MS,
137
+ maxBuffer: GIT_MAX_BUFFER,
138
+ windowsHide: true,
139
+ });
140
+ } catch (error: any) {
141
+ const stderr = typeof error.stderr === 'string' ? error.stderr : '';
142
+ throw new Error(`git worktree remove failed: ${stderr.trim() || error.message}`);
143
+ }
144
+
145
+ return { success: true, removedPath: worktreePath };
146
+ }
147
+
148
+ // ─── List ───────────────────────────────────────
149
+
150
+ /**
151
+ * List all worktrees for a repository.
152
+ *
153
+ * Runs: git worktree list --porcelain
154
+ */
155
+ export async function listWorktrees(repoRoot: string): Promise<WorktreeEntry[]> {
156
+ const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
157
+ cwd: repoRoot,
158
+ encoding: 'utf8',
159
+ timeout: GIT_TIMEOUT_MS,
160
+ maxBuffer: GIT_MAX_BUFFER,
161
+ windowsHide: true,
162
+ });
163
+
164
+ return parseWorktreeListOutput(stdout);
165
+ }
166
+
167
+ /**
168
+ * Parse `git worktree list --porcelain` output into structured entries.
169
+ */
170
+ export function parseWorktreeListOutput(output: string): WorktreeEntry[] {
171
+ const entries: WorktreeEntry[] = [];
172
+ const blocks = output.trim().split(/\n\n+/);
173
+
174
+ for (const block of blocks) {
175
+ if (!block.trim()) continue;
176
+ const lines = block.trim().split('\n');
177
+ const entry: WorktreeEntry = { path: '', head: '', branch: null, bare: false };
178
+
179
+ for (const line of lines) {
180
+ if (line.startsWith('worktree ')) {
181
+ entry.path = line.slice('worktree '.length).trim();
182
+ } else if (line.startsWith('HEAD ')) {
183
+ entry.head = line.slice('HEAD '.length).trim();
184
+ } else if (line.startsWith('branch ')) {
185
+ const ref = line.slice('branch '.length).trim();
186
+ // refs/heads/feat/auth → feat/auth
187
+ entry.branch = ref.replace(/^refs\/heads\//, '');
188
+ } else if (line === 'bare') {
189
+ entry.bare = true;
190
+ }
191
+ }
192
+
193
+ if (entry.path) {
194
+ entries.push(entry);
195
+ }
196
+ }
197
+
198
+ return entries;
199
+ }
200
+
201
+ // ─── Prune ──────────────────────────────────────
202
+
203
+ async function pruneWorktrees(repoRoot: string): Promise<void> {
204
+ try {
205
+ await execFileAsync('git', ['worktree', 'prune'], {
206
+ cwd: repoRoot,
207
+ encoding: 'utf8',
208
+ timeout: GIT_TIMEOUT_MS,
209
+ windowsHide: true,
210
+ });
211
+ } catch {
212
+ // Prune is best-effort
213
+ }
214
+ }
package/src/git/index.ts CHANGED
@@ -73,3 +73,17 @@ export type {
73
73
 
74
74
  export { TurnSnapshotTracker } from './turn-snapshot-tracker.js';
75
75
  export type { TurnCompletedCallback } from './turn-snapshot-tracker.js';
76
+
77
+ export {
78
+ createWorktree,
79
+ listWorktrees,
80
+ parseWorktreeListOutput,
81
+ removeWorktree,
82
+ resolveWorktreePath,
83
+ } from './git-worktree.js';
84
+ export type {
85
+ WorktreeCreateOptions,
86
+ WorktreeCreateResult,
87
+ WorktreeEntry,
88
+ WorktreeRemoveResult,
89
+ } from './git-worktree.js';