@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.
- package/dist/commands/router.d.ts +4 -0
- package/dist/config/mesh-config.d.ts +3 -0
- package/dist/git/git-worktree.d.ts +64 -0
- package/dist/git/index.d.ts +2 -0
- package/dist/index.js +598 -311
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +618 -336
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/coordinator-prompt.d.ts +1 -0
- package/dist/repo-mesh-types.d.ts +4 -0
- package/dist/shared-types.d.ts +18 -1
- package/package.json +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +4 -3
- package/src/commands/mesh-coordinator.ts +1 -1
- package/src/commands/router.ts +157 -10
- package/src/config/mesh-config.ts +6 -0
- package/src/git/git-worktree.ts +214 -0
- package/src/git/index.ts +14 -0
- package/src/mesh/coordinator-prompt.ts +25 -10
- package/src/repo-mesh-types.ts +4 -0
- package/src/shared-types.ts +20 -1
- package/src/status/builders.ts +17 -12
|
@@ -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;
|
package/dist/shared-types.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
270
|
-
const
|
|
271
|
-
|
|
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
|
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
|
1021
|
-
const
|
|
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';
|