@adhdev/daemon-core 0.9.76-rc.37 → 0.9.76-rc.39

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.
@@ -40,6 +40,7 @@ export interface RepoMeshNode {
40
40
  status: 'enabled' | 'disabled' | 'removed';
41
41
  }
42
42
  export type RepoMeshNodeHealth = 'online' | 'offline' | 'degraded' | 'dirty' | 'wrong_branch' | 'unknown';
43
+ export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
43
44
  export interface RepoMeshPolicy {
44
45
  requirePreTaskCheckpoint: boolean;
45
46
  requirePostTaskCheckpoint: boolean;
@@ -48,6 +49,12 @@ export interface RepoMeshPolicy {
48
49
  dirtyWorkspaceBehavior: 'block' | 'warn' | 'checkpoint_then_continue';
49
50
  maxParallelTasks: number;
50
51
  allowedProviders?: string[];
52
+ /**
53
+ * What to do with delegated session-host records for a node when it is removed.
54
+ * Defaults to 'preserve' so completed work can be reviewed later and live
55
+ * runtimes are never stopped/deleted unless the mesh owner opts in.
56
+ */
57
+ sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
51
58
  }
52
59
  export interface RepoMeshNodePolicy {
53
60
  readOnly?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.76-rc.37",
3
+ "version": "0.9.76-rc.39",
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",
@@ -21,6 +21,7 @@ export interface SessionHostControlPlane {
21
21
  }): Promise<any>;
22
22
  listSessions(): Promise<any[]>;
23
23
  stopSession(sessionId: string): Promise<any>;
24
+ deleteSession(sessionId: string, opts?: { force?: boolean }): Promise<any>;
24
25
  resumeSession(sessionId: string): Promise<any>;
25
26
  restartSession(sessionId: string): Promise<any>;
26
27
  sendSignal(sessionId: string, signal: string): Promise<any>;
@@ -40,6 +40,7 @@ import { handleMeshForwardEvent } from '../mesh/mesh-events.js';
40
40
  import { buildMachineInfo, buildStatusSnapshot } from '../status/snapshot.js';
41
41
  import { getSessionCompletionMarker } from '../status/snapshot.js';
42
42
  import { execNpmCommandSync, resolveCurrentGlobalInstallSurface, spawnDetachedDaemonUpgradeHelper } from './upgrade-helper.js';
43
+ import type { RepoMeshSessionCleanupMode } from '../repo-mesh-types.js';
43
44
 
44
45
  type ReleaseChannel = 'stable' | 'preview';
45
46
  const CHANNEL_NPM_TAG: Record<ReleaseChannel, 'latest' | 'next'> = { stable: 'latest', preview: 'next' };
@@ -141,6 +142,7 @@ export interface SessionHostControlPlane {
141
142
  getDiagnostics(payload?: { includeSessions?: boolean; limit?: number }): Promise<any>;
142
143
  listSessions(): Promise<any[]>;
143
144
  stopSession(sessionId: string): Promise<any>;
145
+ deleteSession(sessionId: string, opts?: { force?: boolean }): Promise<any>;
144
146
  resumeSession(sessionId: string): Promise<any>;
145
147
  restartSession(sessionId: string): Promise<any>;
146
148
  sendSignal(sessionId: string, signal: string): Promise<any>;
@@ -335,6 +337,98 @@ export class DaemonCommandRouter {
335
337
  return true;
336
338
  }
337
339
 
340
+ private normalizeMeshSessionCleanupMode(value: unknown): RepoMeshSessionCleanupMode {
341
+ return value === 'stop'
342
+ || value === 'delete_stopped'
343
+ || value === 'stop_and_delete'
344
+ || value === 'preserve'
345
+ ? value
346
+ : 'preserve';
347
+ }
348
+
349
+ private sessionMatchesMeshNode(record: any, node: any, nodeId: string, sessionIds?: Set<string>): boolean {
350
+ const sessionId = typeof record?.sessionId === 'string' ? record.sessionId : '';
351
+ if (!sessionId) return false;
352
+ if (sessionIds?.size) return sessionIds.has(sessionId);
353
+ const workspace = typeof node?.workspace === 'string' ? node.workspace : '';
354
+ if (workspace && record?.workspace === workspace) return true;
355
+ if (record?.meta?.meshNodeId === nodeId) return true;
356
+ return false;
357
+ }
358
+
359
+ private isCompletedHostedSession(record: any): boolean {
360
+ return record?.lifecycle === 'stopped' || record?.lifecycle === 'failed' || record?.lifecycle === 'interrupted';
361
+ }
362
+
363
+ private async cleanupMeshSessions(args: {
364
+ meshId: string;
365
+ nodeId: string;
366
+ node: any;
367
+ mode: RepoMeshSessionCleanupMode;
368
+ sessionIds?: string[];
369
+ dryRun?: boolean;
370
+ }): Promise<{ success: boolean; [key: string]: unknown }> {
371
+ if (args.mode === 'preserve') {
372
+ return { success: true, mode: 'preserve', matchedCount: 0, stoppedSessionIds: [], deletedSessionIds: [], skippedSessionIds: [] };
373
+ }
374
+ if (!this.deps.sessionHostControl) return { success: false, error: 'Session host control unavailable' };
375
+
376
+ const requestedSessionIds = Array.isArray(args.sessionIds)
377
+ ? new Set(args.sessionIds.map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean))
378
+ : undefined;
379
+ const sessions = await this.deps.sessionHostControl.listSessions();
380
+ const matched = sessions.filter(record => this.sessionMatchesMeshNode(record, args.node, args.nodeId, requestedSessionIds));
381
+ const stoppedSessionIds: string[] = [];
382
+ const deletedSessionIds: string[] = [];
383
+ const skippedSessionIds: string[] = [];
384
+ const errors: Array<{ sessionId: string; error: string }> = [];
385
+
386
+ for (const record of matched) {
387
+ const sessionId = String(record.sessionId);
388
+ const completed = this.isCompletedHostedSession(record);
389
+ try {
390
+ if (args.mode === 'stop') {
391
+ if (!completed) {
392
+ if (!args.dryRun) await this.deps.sessionHostControl.stopSession(sessionId);
393
+ stoppedSessionIds.push(sessionId);
394
+ } else {
395
+ skippedSessionIds.push(sessionId);
396
+ }
397
+ continue;
398
+ }
399
+
400
+ if (args.mode === 'delete_stopped') {
401
+ if (completed) {
402
+ if (!args.dryRun) await this.deps.sessionHostControl.deleteSession(sessionId, { force: false });
403
+ deletedSessionIds.push(sessionId);
404
+ } else {
405
+ skippedSessionIds.push(sessionId);
406
+ }
407
+ continue;
408
+ }
409
+
410
+ if (args.mode === 'stop_and_delete') {
411
+ if (!args.dryRun) await this.deps.sessionHostControl.deleteSession(sessionId, { force: true });
412
+ deletedSessionIds.push(sessionId);
413
+ continue;
414
+ }
415
+ } catch (e: any) {
416
+ errors.push({ sessionId, error: e?.message || String(e) });
417
+ }
418
+ }
419
+
420
+ return {
421
+ success: errors.length === 0,
422
+ mode: args.mode,
423
+ dryRun: args.dryRun === true,
424
+ matchedCount: matched.length,
425
+ stoppedSessionIds,
426
+ deletedSessionIds,
427
+ skippedSessionIds,
428
+ ...(errors.length ? { errors } : {}),
429
+ };
430
+ }
431
+
338
432
  private async traceSessionHostAction<T>(
339
433
  action: string,
340
434
  args: any,
@@ -1096,7 +1190,27 @@ export class DaemonCommandRouter {
1096
1190
  if (!name) return { success: false, error: 'name required' };
1097
1191
  try {
1098
1192
  const { createMesh } = await import('../config/mesh-config.js');
1099
- const mesh = createMesh({ name, repoIdentity, repoRemoteUrl, defaultBranch });
1193
+ const mesh = createMesh({ name, repoIdentity, repoRemoteUrl, defaultBranch, policy: args?.policy });
1194
+ return { success: true, mesh };
1195
+ } catch (e: any) {
1196
+ return { success: false, error: e.message };
1197
+ }
1198
+ }
1199
+
1200
+ case 'update_mesh': {
1201
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1202
+ if (!meshId) return { success: false, error: 'meshId required' };
1203
+ try {
1204
+ const { updateMesh } = await import('../config/mesh-config.js');
1205
+ const patch: Record<string, unknown> = {};
1206
+ if (typeof args?.name === 'string') patch.name = args.name;
1207
+ if (typeof args?.defaultBranch === 'string') patch.defaultBranch = args.defaultBranch;
1208
+ if (args?.policy && typeof args.policy === 'object' && !Array.isArray(args.policy)) patch.policy = args.policy;
1209
+ if (args?.coordinator && typeof args.coordinator === 'object' && !Array.isArray(args.coordinator)) patch.coordinator = args.coordinator;
1210
+ if (!Object.keys(patch).length) return { success: false, error: 'No updates provided' };
1211
+ const mesh = updateMesh(meshId, patch as any);
1212
+ if (!mesh) return { success: false, error: 'Mesh not found' };
1213
+ this.inlineMeshCache.set(meshId, mesh);
1100
1214
  return { success: true, mesh };
1101
1215
  } catch (e: any) {
1102
1216
  return { success: false, error: e.message };
@@ -1138,6 +1252,62 @@ export class DaemonCommandRouter {
1138
1252
  }
1139
1253
  }
1140
1254
 
1255
+ case 'update_mesh_node': {
1256
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1257
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
1258
+ if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
1259
+ try {
1260
+ const { updateNode } = await import('../config/mesh-config.js');
1261
+ const policy = args?.policy && typeof args.policy === 'object' && !Array.isArray(args.policy)
1262
+ ? { ...(args.policy as Record<string, unknown>) }
1263
+ : {};
1264
+ if (Array.isArray(args?.providerPriority)) {
1265
+ const providerPriority = args.providerPriority
1266
+ .map((type: any) => typeof type === 'string' ? type.trim() : '')
1267
+ .filter(Boolean);
1268
+ delete (policy as any).provider_priority;
1269
+ if (providerPriority.length) {
1270
+ (policy as any).providerPriority = providerPriority;
1271
+ } else {
1272
+ delete (policy as any).providerPriority;
1273
+ }
1274
+ }
1275
+ const node = updateNode(meshId, nodeId, { policy: policy as any });
1276
+ if (!node) return { success: false, error: 'Mesh node not found' };
1277
+ return { success: true, node };
1278
+ } catch (e: any) {
1279
+ return { success: false, error: e.message };
1280
+ }
1281
+ }
1282
+
1283
+ case 'cleanup_mesh_sessions': {
1284
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1285
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
1286
+ if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
1287
+ try {
1288
+ const meshRecord = await this.getMeshForCommand(meshId, args?.inlineMesh);
1289
+ const mesh = meshRecord?.mesh;
1290
+ if (!mesh) return { success: false, error: 'Mesh not found' };
1291
+ const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
1292
+ if (!node) return { success: false, error: `Node '${nodeId}' not found in mesh` };
1293
+ const mode = this.normalizeMeshSessionCleanupMode(args?.mode ?? mesh?.policy?.sessionCleanupOnNodeRemove);
1294
+ const sessionIds = Array.isArray(args?.sessionIds)
1295
+ ? args.sessionIds.map((id: any) => typeof id === 'string' ? id.trim() : '').filter(Boolean)
1296
+ : undefined;
1297
+ const result = await this.cleanupMeshSessions({
1298
+ meshId,
1299
+ nodeId,
1300
+ node,
1301
+ mode,
1302
+ sessionIds,
1303
+ dryRun: args?.dryRun === true,
1304
+ });
1305
+ return result;
1306
+ } catch (e: any) {
1307
+ return { success: false, error: e.message };
1308
+ }
1309
+ }
1310
+
1141
1311
  case 'remove_mesh_node': {
1142
1312
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
1143
1313
  const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
@@ -1147,6 +1317,15 @@ export class DaemonCommandRouter {
1147
1317
  const mesh = meshRecord?.mesh;
1148
1318
  const node = mesh?.nodes?.find((n: any) => n.id === nodeId || n.nodeId === nodeId);
1149
1319
 
1320
+ const sessionCleanupMode = this.normalizeMeshSessionCleanupMode(
1321
+ args?.sessionCleanupMode ?? args?.session_cleanup_mode ?? mesh?.policy?.sessionCleanupOnNodeRemove,
1322
+ );
1323
+ let sessionCleanup: Record<string, unknown> | undefined;
1324
+ if (node && sessionCleanupMode !== 'preserve') {
1325
+ sessionCleanup = await this.cleanupMeshSessions({ meshId, nodeId, node, mode: sessionCleanupMode });
1326
+ if (sessionCleanup.success === false) return { success: false, removed: false, sessionCleanup };
1327
+ }
1328
+
1150
1329
  // If this is a worktree node, clean up the git worktree first
1151
1330
  if (node?.isLocalWorktree && node.workspace) {
1152
1331
  try {
@@ -1171,7 +1350,7 @@ export class DaemonCommandRouter {
1171
1350
  const { removeNode } = await import('../config/mesh-config.js');
1172
1351
  removed = removeNode(meshId, nodeId);
1173
1352
  }
1174
- return { success: true, removed };
1353
+ return { success: true, removed, ...(sessionCleanup ? { sessionCleanup } : {}) };
1175
1354
  } catch (e: any) {
1176
1355
  return { success: false, error: e.message };
1177
1356
  }
@@ -74,6 +74,21 @@ export function normalizeRepoIdentity(remoteUrl: string): string {
74
74
 
75
75
  // ─── CRUD Operations ────────────────────────────
76
76
 
77
+ const SESSION_CLEANUP_MODES = new Set(['preserve', 'stop', 'delete_stopped', 'stop_and_delete']);
78
+
79
+ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMeshPolicy> | undefined): RepoMeshPolicy {
80
+ const policy: RepoMeshPolicy = { ...DEFAULT_MESH_POLICY, ...(base || {}), ...(patch || {}) };
81
+ if (!['block', 'warn', 'checkpoint_then_continue'].includes(policy.dirtyWorkspaceBehavior)) {
82
+ policy.dirtyWorkspaceBehavior = 'warn';
83
+ }
84
+ const maxParallelTasks = Number(policy.maxParallelTasks);
85
+ policy.maxParallelTasks = Number.isFinite(maxParallelTasks) ? Math.max(1, Math.min(8, Math.floor(maxParallelTasks))) : 2;
86
+ if (!SESSION_CLEANUP_MODES.has(String(policy.sessionCleanupOnNodeRemove))) {
87
+ policy.sessionCleanupOnNodeRemove = 'preserve';
88
+ }
89
+ return policy;
90
+ }
91
+
77
92
  export function listMeshes(): LocalMeshEntry[] {
78
93
  return loadMeshConfig().meshes;
79
94
  }
@@ -112,7 +127,7 @@ export function createMesh(opts: CreateMeshOptions): LocalMeshEntry {
112
127
  repoIdentity,
113
128
  repoRemoteUrl: opts.repoRemoteUrl,
114
129
  defaultBranch: opts.defaultBranch,
115
- policy: { ...DEFAULT_MESH_POLICY, ...opts.policy },
130
+ policy: mergeMeshPolicy(undefined, opts.policy),
116
131
  coordinator: opts.coordinator || {},
117
132
  nodes: [],
118
133
  createdAt: now,
@@ -138,7 +153,7 @@ export function updateMesh(meshId: string, opts: UpdateMeshOptions): LocalMeshEn
138
153
 
139
154
  if (opts.name !== undefined) mesh.name = opts.name.trim().slice(0, 100);
140
155
  if (opts.defaultBranch !== undefined) mesh.defaultBranch = opts.defaultBranch;
141
- if (opts.policy) mesh.policy = { ...mesh.policy, ...opts.policy };
156
+ if (opts.policy) mesh.policy = mergeMeshPolicy(mesh.policy, opts.policy);
142
157
  if (opts.coordinator) mesh.coordinator = opts.coordinator;
143
158
  mesh.updatedAt = new Date().toISOString();
144
159
 
@@ -55,6 +55,8 @@ export type RepoMeshNodeHealth =
55
55
 
56
56
  // ─── Policy Types ───────────────────────────────
57
57
 
58
+ export type RepoMeshSessionCleanupMode = 'preserve' | 'stop' | 'delete_stopped' | 'stop_and_delete';
59
+
58
60
  export interface RepoMeshPolicy {
59
61
  requirePreTaskCheckpoint: boolean;
60
62
  requirePostTaskCheckpoint: boolean;
@@ -63,6 +65,12 @@ export interface RepoMeshPolicy {
63
65
  dirtyWorkspaceBehavior: 'block' | 'warn' | 'checkpoint_then_continue';
64
66
  maxParallelTasks: number;
65
67
  allowedProviders?: string[];
68
+ /**
69
+ * What to do with delegated session-host records for a node when it is removed.
70
+ * Defaults to 'preserve' so completed work can be reviewed later and live
71
+ * runtimes are never stopped/deleted unless the mesh owner opts in.
72
+ */
73
+ sessionCleanupOnNodeRemove?: RepoMeshSessionCleanupMode;
66
74
  }
67
75
 
68
76
  export interface RepoMeshNodePolicy {
@@ -80,6 +88,7 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
80
88
  requireApprovalForDestructiveGit: true,
81
89
  dirtyWorkspaceBehavior: 'warn',
82
90
  maxParallelTasks: 2,
91
+ sessionCleanupOnNodeRemove: 'preserve',
83
92
  };
84
93
 
85
94
  // ─── Capabilities ───────────────────────────────