@adhdev/daemon-core 0.9.66 → 0.9.68

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.
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Coordinator Prompt — System prompt template for mesh coordinator sessions
3
+ *
4
+ * When an MCP server starts in mesh mode, this prompt is injected into the
5
+ * coordinator agent's context so it understands:
6
+ * 1. What the mesh is (repo, nodes, policy)
7
+ * 2. What tools are available
8
+ * 3. How to orchestrate work across nodes
9
+ *
10
+ * The prompt is generated dynamically from the current mesh state.
11
+ */
12
+ import type { LocalMeshEntry, RepoMeshStatus } from '../repo-mesh-types.js';
13
+ export interface CoordinatorPromptContext {
14
+ mesh: LocalMeshEntry;
15
+ status?: RepoMeshStatus;
16
+ userInstruction?: string;
17
+ }
18
+ export declare function buildCoordinatorSystemPrompt(ctx: CoordinatorPromptContext): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Mesh Sync — Sync local mesh config to/from cloud D1
3
+ *
4
+ * When cloud is available, this module pushes local mesh config
5
+ * to the server and pulls remote meshes that were created from
6
+ * other machines. The local ~/.adhdev/meshes.json remains the
7
+ * canonical source; cloud is a persistence/relay layer.
8
+ *
9
+ * This is called lazily (not on daemon startup) — only when the
10
+ * user explicitly opens the mesh page or runs `adhdev mesh sync`.
11
+ */
12
+ export interface MeshSyncTransport {
13
+ /** GET /api/v1/repo-meshes */
14
+ listRemoteMeshes(): Promise<{
15
+ meshes: RemoteMeshRecord[];
16
+ }>;
17
+ /** POST /api/v1/repo-meshes */
18
+ createRemoteMesh(data: {
19
+ name: string;
20
+ repo_identity: string;
21
+ repo_remote_url?: string;
22
+ default_branch?: string;
23
+ policy?: string;
24
+ }): Promise<{
25
+ mesh: RemoteMeshRecord;
26
+ }>;
27
+ /** DELETE /api/v1/repo-meshes/:id */
28
+ deleteRemoteMesh(meshId: string): Promise<void>;
29
+ }
30
+ export interface RemoteMeshRecord {
31
+ id: string;
32
+ name: string;
33
+ repo_identity: string;
34
+ repo_remote_url: string | null;
35
+ default_branch: string | null;
36
+ policy: string;
37
+ status: string;
38
+ created_at: string;
39
+ updated_at: string;
40
+ }
41
+ export interface MeshSyncResult {
42
+ pushed: number;
43
+ pulled: number;
44
+ deleted: number;
45
+ errors: string[];
46
+ }
47
+ /**
48
+ * Push local meshes to cloud (upsert by repo_identity).
49
+ * Pull remote meshes that don't exist locally.
50
+ */
51
+ export declare function syncMeshes(transport: MeshSyncTransport): Promise<MeshSyncResult>;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Repo Mesh Types — Cross-package type definitions for repo-scoped orchestration
3
+ *
4
+ * A Repo Mesh is a repo-scoped execution environment that groups
5
+ * machines/workspaces around one Git repository identity. A coordinator
6
+ * agent delegates work to mesh nodes via natural conversation.
7
+ *
8
+ * These types are OSS-level and usable without cloud infrastructure.
9
+ * Import via: import type { ... } from '@adhdev/daemon-core/repo-mesh-types'
10
+ *
11
+ * IMPORTANT: This file must remain runtime-free (types only).
12
+ */
13
+ import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
14
+ export interface RepoMesh {
15
+ id: string;
16
+ name: string;
17
+ repoIdentity: string;
18
+ repoRemoteUrl?: string;
19
+ defaultBranch?: string;
20
+ policy: RepoMeshPolicy;
21
+ coordinator: RepoMeshCoordinatorConfig;
22
+ projectContext: ProjectContextSnapshot;
23
+ nodes: RepoMeshNode[];
24
+ status: 'active' | 'archived' | 'deleted';
25
+ }
26
+ export interface RepoMeshNode {
27
+ id: string;
28
+ daemonId: string;
29
+ machineId?: string;
30
+ machineLabel: string;
31
+ workspace: string;
32
+ repoRoot?: string;
33
+ git?: GitCompactSummary;
34
+ providers: string[];
35
+ detectedCapabilities: RepoMeshNodeCapabilities;
36
+ userOverrides: Partial<RepoMeshNodeCapabilities>;
37
+ effectiveCapabilities: RepoMeshNodeCapabilities;
38
+ policy: RepoMeshNodePolicy;
39
+ health: RepoMeshNodeHealth;
40
+ status: 'enabled' | 'disabled' | 'removed';
41
+ }
42
+ export type RepoMeshNodeHealth = 'online' | 'offline' | 'degraded' | 'dirty' | 'wrong_branch' | 'unknown';
43
+ export interface RepoMeshPolicy {
44
+ requirePreTaskCheckpoint: boolean;
45
+ requirePostTaskCheckpoint: boolean;
46
+ requireApprovalForPush: boolean;
47
+ requireApprovalForDestructiveGit: boolean;
48
+ dirtyWorkspaceBehavior: 'block' | 'warn' | 'checkpoint_then_continue';
49
+ maxParallelTasks: number;
50
+ allowedProviders?: string[];
51
+ }
52
+ export interface RepoMeshNodePolicy {
53
+ readOnly?: boolean;
54
+ canPush?: boolean;
55
+ maxConcurrentSessions?: number;
56
+ }
57
+ export declare const DEFAULT_MESH_POLICY: RepoMeshPolicy;
58
+ export interface RepoMeshNodeCapabilities {
59
+ platform?: string;
60
+ packageManagers?: string[];
61
+ detectedCommands?: DetectedCommand[];
62
+ canRunLongJobs?: boolean;
63
+ canRunDocker?: boolean;
64
+ canRunBrowserE2E?: boolean;
65
+ canAccessSecrets?: boolean;
66
+ canPush?: boolean;
67
+ readOnly?: boolean;
68
+ userLabels?: string[];
69
+ }
70
+ export interface DetectedCommand {
71
+ command: string;
72
+ sourcePath: string;
73
+ confidence: 'high' | 'medium' | 'low';
74
+ requiresApproval?: boolean;
75
+ }
76
+ export interface ProjectContextSnapshot {
77
+ version: number;
78
+ generatedAt: string;
79
+ sources: ProjectContextSource[];
80
+ repo: {
81
+ identity: string;
82
+ remoteUrl?: string;
83
+ defaultBranch?: string;
84
+ currentBranches: string[];
85
+ };
86
+ layout: {
87
+ packageManager?: string;
88
+ workspaceFiles: string[];
89
+ packageRoots: string[];
90
+ likelyEntryPoints: string[];
91
+ };
92
+ commands: {
93
+ build?: DetectedCommand[];
94
+ test?: DetectedCommand[];
95
+ typecheck?: DetectedCommand[];
96
+ lint?: DetectedCommand[];
97
+ e2e?: DetectedCommand[];
98
+ };
99
+ instructions: {
100
+ files: string[];
101
+ summary: string;
102
+ };
103
+ conventions: {
104
+ pathHints: string[];
105
+ validationNotes: string[];
106
+ riskyAreas: string[];
107
+ };
108
+ }
109
+ export interface ProjectContextSource {
110
+ kind: 'daemon_status' | 'git' | 'project_file' | 'instruction_file' | 'user_override' | 'probe_error';
111
+ nodeId?: string;
112
+ path?: string;
113
+ observedAt: string;
114
+ confidence: 'high' | 'medium' | 'low';
115
+ }
116
+ export interface RepoMeshCoordinatorConfig {
117
+ /** Provider to use for coordinator session (e.g. 'claude-cli', 'cursor') */
118
+ providerType?: string;
119
+ /** Preferred node to run coordinator on (null = auto) */
120
+ preferredNodeId?: string;
121
+ /** Additional system prompt context for coordinator */
122
+ systemPromptSuffix?: string;
123
+ }
124
+ /**
125
+ * Local mesh configuration stored in ~/.adhdev/meshes.json
126
+ * Used by OSS standalone mode without cloud infrastructure.
127
+ */
128
+ export interface LocalMeshConfig {
129
+ meshes: LocalMeshEntry[];
130
+ }
131
+ export interface LocalMeshEntry {
132
+ id: string;
133
+ name: string;
134
+ repoIdentity: string;
135
+ repoRemoteUrl?: string;
136
+ defaultBranch?: string;
137
+ policy: RepoMeshPolicy;
138
+ coordinator: RepoMeshCoordinatorConfig;
139
+ nodes: LocalMeshNodeEntry[];
140
+ createdAt: string;
141
+ updatedAt: string;
142
+ }
143
+ export interface LocalMeshNodeEntry {
144
+ id: string;
145
+ workspace: string;
146
+ repoRoot?: string;
147
+ userOverrides: Partial<RepoMeshNodeCapabilities>;
148
+ policy: RepoMeshNodePolicy;
149
+ /** For single-machine mesh: same daemon, different worktree */
150
+ isLocalWorktree?: boolean;
151
+ }
152
+ export interface RepoMeshStatus {
153
+ meshId: string;
154
+ meshName: string;
155
+ repoIdentity: string;
156
+ refreshedAt: string;
157
+ nodes: RepoMeshNodeStatus[];
158
+ }
159
+ export interface RepoMeshNodeStatus {
160
+ nodeId: string;
161
+ machineLabel: string;
162
+ workspace: string;
163
+ health: RepoMeshNodeHealth;
164
+ git?: GitRepoStatus;
165
+ providers: string[];
166
+ activeSessions: string[];
167
+ error?: string;
168
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/session-host-core",
3
- "version": "0.9.66",
3
+ "version": "0.9.68",
4
4
  "description": "ADHDev local session host core \u2014 session registry, protocol, buffers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.66",
3
+ "version": "0.9.68",
4
4
  "description": "ADHDev daemon core \u2014 CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -920,6 +920,162 @@ export class DaemonCommandRouter {
920
920
  return { success: true };
921
921
  }
922
922
 
923
+ // ─── Mesh CRUD (local meshes.json) ───
924
+ case 'list_meshes': {
925
+ try {
926
+ const { listMeshes } = await import('../config/mesh-config.js');
927
+ return { success: true, meshes: listMeshes() };
928
+ } catch (e: any) {
929
+ return { success: false, error: e.message };
930
+ }
931
+ }
932
+
933
+ case 'get_mesh': {
934
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
935
+ if (!meshId) return { success: false, error: 'meshId required' };
936
+ try {
937
+ const { getMesh } = await import('../config/mesh-config.js');
938
+ const mesh = getMesh(meshId);
939
+ if (!mesh) return { success: false, error: 'Mesh not found' };
940
+ return { success: true, mesh };
941
+ } catch (e: any) {
942
+ return { success: false, error: e.message };
943
+ }
944
+ }
945
+
946
+ case 'create_mesh': {
947
+ const name = typeof args?.name === 'string' ? args.name.trim() : '';
948
+ const repoIdentity = typeof args?.repoIdentity === 'string' ? args.repoIdentity.trim() : '';
949
+ const repoRemoteUrl = typeof args?.repoRemoteUrl === 'string' ? args.repoRemoteUrl.trim() : undefined;
950
+ const defaultBranch = typeof args?.defaultBranch === 'string' ? args.defaultBranch.trim() : undefined;
951
+ if (!name) return { success: false, error: 'name required' };
952
+ try {
953
+ const { createMesh } = await import('../config/mesh-config.js');
954
+ const mesh = createMesh({ name, repoIdentity, repoRemoteUrl, defaultBranch });
955
+ return { success: true, mesh };
956
+ } catch (e: any) {
957
+ return { success: false, error: e.message };
958
+ }
959
+ }
960
+
961
+ case 'delete_mesh': {
962
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
963
+ if (!meshId) return { success: false, error: 'meshId required' };
964
+ try {
965
+ const { deleteMesh } = await import('../config/mesh-config.js');
966
+ const deleted = deleteMesh(meshId);
967
+ return { success: true, deleted };
968
+ } catch (e: any) {
969
+ return { success: false, error: e.message };
970
+ }
971
+ }
972
+
973
+ case 'add_mesh_node': {
974
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
975
+ const workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
976
+ if (!meshId) return { success: false, error: 'meshId required' };
977
+ if (!workspace) return { success: false, error: 'workspace required' };
978
+ try {
979
+ const { addNode } = await import('../config/mesh-config.js');
980
+ const node = addNode(meshId, { workspace });
981
+ if (!node) return { success: false, error: 'Mesh not found' };
982
+ return { success: true, node };
983
+ } catch (e: any) {
984
+ return { success: false, error: e.message };
985
+ }
986
+ }
987
+
988
+ case 'remove_mesh_node': {
989
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
990
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
991
+ if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
992
+ try {
993
+ const { removeNode } = await import('../config/mesh-config.js');
994
+ const removed = removeNode(meshId, nodeId);
995
+ return { success: true, removed };
996
+ } catch (e: any) {
997
+ return { success: false, error: e.message };
998
+ }
999
+ }
1000
+
1001
+ // ─── Mesh Coordinator Launch ───
1002
+ case 'launch_mesh_coordinator': {
1003
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1004
+ const cliType = typeof args?.cliType === 'string' ? args.cliType.trim() : 'claude-cli';
1005
+ if (!meshId) return { success: false, error: 'meshId required' };
1006
+
1007
+ try {
1008
+ const { getMesh } = await import('../config/mesh-config.js');
1009
+ const { buildCoordinatorSystemPrompt } = await import('../mesh/coordinator-prompt.js');
1010
+ const mesh = getMesh(meshId);
1011
+ if (!mesh) return { success: false, error: 'Mesh not found' };
1012
+ if (mesh.nodes.length === 0) return { success: false, error: 'No nodes in mesh' };
1013
+
1014
+ const workspace = mesh.nodes[0].workspace;
1015
+
1016
+ // 1. Write .mcp.json to workspace so Claude CLI auto-discovers mesh tools
1017
+ const { existsSync, readFileSync, writeFileSync, copyFileSync } = await import('fs');
1018
+ const { join } = await import('path');
1019
+ const mcpConfigPath = join(workspace, '.mcp.json');
1020
+
1021
+ // Backup existing .mcp.json if present
1022
+ const hadExistingMcpConfig = existsSync(mcpConfigPath);
1023
+ let existingMcpConfig: any = {};
1024
+ if (hadExistingMcpConfig) {
1025
+ try {
1026
+ existingMcpConfig = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
1027
+ copyFileSync(mcpConfigPath, mcpConfigPath + '.backup');
1028
+ } catch { /* empty */ }
1029
+ }
1030
+
1031
+ // Merge adhdev-mesh server into existing config
1032
+ const mcpConfig = {
1033
+ ...existingMcpConfig,
1034
+ mcpServers: {
1035
+ ...(existingMcpConfig.mcpServers || {}),
1036
+ 'adhdev-mesh': {
1037
+ command: 'adhdev-mcp',
1038
+ args: ['--repo-mesh', meshId],
1039
+ },
1040
+ },
1041
+ };
1042
+ writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2), 'utf-8');
1043
+ LOG.info('MeshCoordinator', `Wrote .mcp.json to ${workspace} with adhdev-mesh server`);
1044
+
1045
+ // 2. Build coordinator system prompt
1046
+ let systemPrompt = '';
1047
+ try {
1048
+ systemPrompt = buildCoordinatorSystemPrompt({ mesh });
1049
+ } catch {
1050
+ 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).`;
1051
+ }
1052
+
1053
+ // 3. Launch CLI session via existing cliManager
1054
+ const launchResult: any = await this.deps.cliManager.handleCliCommand('launch_cli', {
1055
+ cliType,
1056
+ dir: workspace,
1057
+ initialPrompt: systemPrompt,
1058
+ });
1059
+
1060
+ if (!launchResult?.success) {
1061
+ return { success: false, error: launchResult?.error || 'Failed to launch CLI session' };
1062
+ }
1063
+
1064
+ LOG.info('MeshCoordinator', `Launched ${cliType} coordinator for mesh ${meshId} in ${workspace}`);
1065
+ return {
1066
+ success: true,
1067
+ meshId,
1068
+ cliType,
1069
+ workspace,
1070
+ sessionId: launchResult.sessionId || launchResult.id,
1071
+ mcpConfigWritten: true,
1072
+ };
1073
+ } catch (e: any) {
1074
+ LOG.error('MeshCoordinator', `Failed: ${e.message}`);
1075
+ return { success: false, error: e.message };
1076
+ }
1077
+ }
1078
+
923
1079
  default:
924
1080
  break;
925
1081
  }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Repo Mesh Config — Local mesh configuration stored in ~/.adhdev/meshes.json
3
+ *
4
+ * Manages repo mesh definitions for OSS standalone mode.
5
+ * Cloud mode syncs these to D1 via server routes; standalone mode
6
+ * uses this file as the single source of truth.
7
+ */
8
+
9
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { randomUUID } from 'crypto';
12
+ import { getConfigDir } from './config.js';
13
+ import type {
14
+ LocalMeshConfig,
15
+ LocalMeshEntry,
16
+ LocalMeshNodeEntry,
17
+ RepoMeshPolicy,
18
+ RepoMeshNodePolicy,
19
+ RepoMeshNodeCapabilities,
20
+ RepoMeshCoordinatorConfig,
21
+ } from '../repo-mesh-types.js';
22
+ import { DEFAULT_MESH_POLICY } from '../repo-mesh-types.js';
23
+
24
+ // ─── Persistence ────────────────────────────────
25
+
26
+ function getMeshConfigPath(): string {
27
+ return join(getConfigDir(), 'meshes.json');
28
+ }
29
+
30
+ function loadMeshConfig(): LocalMeshConfig {
31
+ const path = getMeshConfigPath();
32
+ if (!existsSync(path)) return { meshes: [] };
33
+ try {
34
+ const raw = JSON.parse(readFileSync(path, 'utf-8'));
35
+ if (!raw || !Array.isArray(raw.meshes)) return { meshes: [] };
36
+ return raw as LocalMeshConfig;
37
+ } catch {
38
+ return { meshes: [] };
39
+ }
40
+ }
41
+
42
+ function saveMeshConfig(config: LocalMeshConfig): void {
43
+ const path = getMeshConfigPath();
44
+ writeFileSync(path, JSON.stringify(config, null, 2), { encoding: 'utf-8', mode: 0o600 });
45
+ }
46
+
47
+ // ─── Repo Identity Normalization ────────────────
48
+
49
+ /**
50
+ * Normalize a Git remote URL into a stable identity string.
51
+ * e.g. "git@github.com:user/repo.git" → "github.com/user/repo"
52
+ * "https://github.com/user/repo.git" → "github.com/user/repo"
53
+ */
54
+ export function normalizeRepoIdentity(remoteUrl: string): string {
55
+ let identity = remoteUrl.trim();
56
+
57
+ // HTTPS format first (takes priority over SSH fallback)
58
+ if (identity.startsWith('http://') || identity.startsWith('https://')) {
59
+ try {
60
+ const url = new URL(identity);
61
+ const path = url.pathname.replace(/^\//, '').replace(/\.git$/, '');
62
+ return `${url.hostname}/${path}`;
63
+ } catch {
64
+ // fall through
65
+ }
66
+ }
67
+
68
+ // SSH format: git@host:owner/repo.git or ssh://git@host/owner/repo.git
69
+ const sshMatch = identity.match(/^(?:ssh:\/\/)?[\w.-]+@([\w.-]+)[:/]([\w.\-/]+?)(?:\.git)?$/);
70
+ if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
71
+
72
+ return identity;
73
+ }
74
+
75
+ // ─── CRUD Operations ────────────────────────────
76
+
77
+ export function listMeshes(): LocalMeshEntry[] {
78
+ return loadMeshConfig().meshes;
79
+ }
80
+
81
+ export function getMesh(meshId: string): LocalMeshEntry | undefined {
82
+ return loadMeshConfig().meshes.find(m => m.id === meshId);
83
+ }
84
+
85
+ export function getMeshByRepo(repoIdentity: string): LocalMeshEntry | undefined {
86
+ return loadMeshConfig().meshes.find(m => m.repoIdentity === repoIdentity);
87
+ }
88
+
89
+ export interface CreateMeshOptions {
90
+ name: string;
91
+ repoRemoteUrl?: string;
92
+ repoIdentity?: string;
93
+ defaultBranch?: string;
94
+ policy?: Partial<RepoMeshPolicy>;
95
+ coordinator?: RepoMeshCoordinatorConfig;
96
+ }
97
+
98
+ export function createMesh(opts: CreateMeshOptions): LocalMeshEntry {
99
+ const config = loadMeshConfig();
100
+
101
+ if (config.meshes.length >= 20) {
102
+ throw new Error('Maximum 20 meshes allowed');
103
+ }
104
+
105
+ const repoIdentity = opts.repoIdentity || (opts.repoRemoteUrl ? normalizeRepoIdentity(opts.repoRemoteUrl) : '');
106
+ if (!repoIdentity) throw new Error('Either repoRemoteUrl or repoIdentity is required');
107
+
108
+ const now = new Date().toISOString();
109
+ const mesh: LocalMeshEntry = {
110
+ id: `mesh_${randomUUID().replace(/-/g, '')}`,
111
+ name: opts.name.trim().slice(0, 100),
112
+ repoIdentity,
113
+ repoRemoteUrl: opts.repoRemoteUrl,
114
+ defaultBranch: opts.defaultBranch,
115
+ policy: { ...DEFAULT_MESH_POLICY, ...opts.policy },
116
+ coordinator: opts.coordinator || {},
117
+ nodes: [],
118
+ createdAt: now,
119
+ updatedAt: now,
120
+ };
121
+
122
+ config.meshes.push(mesh);
123
+ saveMeshConfig(config);
124
+ return mesh;
125
+ }
126
+
127
+ export interface UpdateMeshOptions {
128
+ name?: string;
129
+ defaultBranch?: string;
130
+ policy?: Partial<RepoMeshPolicy>;
131
+ coordinator?: RepoMeshCoordinatorConfig;
132
+ }
133
+
134
+ export function updateMesh(meshId: string, opts: UpdateMeshOptions): LocalMeshEntry | undefined {
135
+ const config = loadMeshConfig();
136
+ const mesh = config.meshes.find(m => m.id === meshId);
137
+ if (!mesh) return undefined;
138
+
139
+ if (opts.name !== undefined) mesh.name = opts.name.trim().slice(0, 100);
140
+ if (opts.defaultBranch !== undefined) mesh.defaultBranch = opts.defaultBranch;
141
+ if (opts.policy) mesh.policy = { ...mesh.policy, ...opts.policy };
142
+ if (opts.coordinator) mesh.coordinator = opts.coordinator;
143
+ mesh.updatedAt = new Date().toISOString();
144
+
145
+ saveMeshConfig(config);
146
+ return mesh;
147
+ }
148
+
149
+ export function deleteMesh(meshId: string): boolean {
150
+ const config = loadMeshConfig();
151
+ const idx = config.meshes.findIndex(m => m.id === meshId);
152
+ if (idx === -1) return false;
153
+ config.meshes.splice(idx, 1);
154
+ saveMeshConfig(config);
155
+ return true;
156
+ }
157
+
158
+ // ─── Node Operations ────────────────────────────
159
+
160
+ export interface AddNodeOptions {
161
+ workspace: string;
162
+ repoRoot?: string;
163
+ userOverrides?: Partial<RepoMeshNodeCapabilities>;
164
+ policy?: RepoMeshNodePolicy;
165
+ isLocalWorktree?: boolean;
166
+ }
167
+
168
+ export function addNode(meshId: string, opts: AddNodeOptions): LocalMeshNodeEntry | undefined {
169
+ const config = loadMeshConfig();
170
+ const mesh = config.meshes.find(m => m.id === meshId);
171
+ if (!mesh) return undefined;
172
+
173
+ if (mesh.nodes.length >= 10) {
174
+ throw new Error('Maximum 10 nodes per mesh');
175
+ }
176
+
177
+ // Check duplicate workspace
178
+ if (mesh.nodes.some(n => n.workspace === opts.workspace)) {
179
+ throw new Error('This workspace is already in the mesh');
180
+ }
181
+
182
+ const node: LocalMeshNodeEntry = {
183
+ id: `node_${randomUUID().replace(/-/g, '')}`,
184
+ workspace: opts.workspace.trim(),
185
+ repoRoot: opts.repoRoot,
186
+ userOverrides: opts.userOverrides || {},
187
+ policy: opts.policy || {},
188
+ isLocalWorktree: opts.isLocalWorktree,
189
+ };
190
+
191
+ mesh.nodes.push(node);
192
+ mesh.updatedAt = new Date().toISOString();
193
+ saveMeshConfig(config);
194
+ return node;
195
+ }
196
+
197
+ export function removeNode(meshId: string, nodeId: string): boolean {
198
+ const config = loadMeshConfig();
199
+ const mesh = config.meshes.find(m => m.id === meshId);
200
+ if (!mesh) return false;
201
+
202
+ const idx = mesh.nodes.findIndex(n => n.id === nodeId);
203
+ if (idx === -1) return false;
204
+
205
+ mesh.nodes.splice(idx, 1);
206
+ mesh.updatedAt = new Date().toISOString();
207
+ saveMeshConfig(config);
208
+ return true;
209
+ }
210
+
211
+ export function updateNode(
212
+ meshId: string,
213
+ nodeId: string,
214
+ opts: { userOverrides?: Partial<RepoMeshNodeCapabilities>; policy?: RepoMeshNodePolicy },
215
+ ): LocalMeshNodeEntry | undefined {
216
+ const config = loadMeshConfig();
217
+ const mesh = config.meshes.find(m => m.id === meshId);
218
+ if (!mesh) return undefined;
219
+
220
+ const node = mesh.nodes.find(n => n.id === nodeId);
221
+ if (!node) return undefined;
222
+
223
+ if (opts.userOverrides) node.userOverrides = { ...node.userOverrides, ...opts.userOverrides };
224
+ if (opts.policy) node.policy = { ...node.policy, ...opts.policy };
225
+ mesh.updatedAt = new Date().toISOString();
226
+ saveMeshConfig(config);
227
+ return node;
228
+ }