@adhdev/daemon-core 0.9.76-rc.2 → 0.9.76-rc.20

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;
@@ -8,6 +8,7 @@ import { type ProviderModule } from './contracts.js';
8
8
  import type { ProviderInstance, ProviderState, InstanceContext, HotChatSessionState, SessionModalState } from './provider-instance.js';
9
9
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
10
10
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
11
+ import type { ChatMessage } from '../types.js';
11
12
  type PersistableCliHistoryMessage = {
12
13
  role: string;
13
14
  content: string;
@@ -67,6 +68,7 @@ export declare class CliProviderInstance implements ProviderInstance {
67
68
  constructor(provider: ProviderModule, workingDir: string, cliArgs?: string[], instanceId?: string, transportFactory?: PtyTransportFactory, options?: {
68
69
  providerSessionId?: string;
69
70
  launchMode?: 'new' | 'resume' | 'manual';
71
+ extraEnv?: Record<string, string>;
70
72
  onProviderSessionResolved?: (info: {
71
73
  instanceId: string;
72
74
  providerType: string;
@@ -112,6 +114,7 @@ export declare class CliProviderInstance implements ProviderInstance {
112
114
  private maybeAppendRuntimeRecoveryMessage;
113
115
  private appendRuntimeSystemMessage;
114
116
  private appendRuntimeMessage;
117
+ mergeRuntimeChatMessages(parsedMessages: ChatMessage[]): ChatMessage[];
115
118
  private mergeConversationMessages;
116
119
  private formatApprovalRequestMessage;
117
120
  private promoteProviderSessionId;
@@ -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.2",
3
+ "version": "0.9.76-rc.20",
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",
@@ -52,16 +52,15 @@
52
52
  "chalk": "^5.3.0",
53
53
  "chokidar": "^5.0.0",
54
54
  "conf": "^13.0.0",
55
+ "js-yaml": "^4.1.1",
55
56
  "node-pty": "^1.2.0-beta.12",
56
57
  "ws": "^8.19.0"
57
58
  },
58
- "bundleDependencies": [
59
- "@adhdev/session-host-core"
60
- ],
61
59
  "optionalDependencies": {
62
60
  "@adhdev/ghostty-vt-node": "*"
63
61
  },
64
62
  "devDependencies": {
63
+ "@types/js-yaml": "^4.0.9",
65
64
  "@types/node": "^22.0.0",
66
65
  "@types/ws": "^8.18.1",
67
66
  "tsup": "^8.2.0",
@@ -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
@@ -421,6 +422,7 @@ export class ProviderCliAdapter implements CliAdapter {
421
422
  provider: CliProviderModule,
422
423
  workingDir: string,
423
424
  private extraArgs: string[] = [],
425
+ private extraEnv: Record<string, string> = {},
424
426
  transportFactory: PtyTransportFactory = new NodePtyTransportFactory(),
425
427
  ) {
426
428
  this.provider = provider;
@@ -522,6 +524,7 @@ export class ProviderCliAdapter implements CliAdapter {
522
524
  runtimeSettings: this.runtimeSettings,
523
525
  workingDir: this.workingDir,
524
526
  extraArgs: this.extraArgs,
527
+ extraEnv: this.extraEnv,
525
528
  });
526
529
 
527
530
  LOG.info('CLI', `[${this.cliType}] Spawning in ${this.workingDir}`);
@@ -27,8 +27,9 @@ export function resolveCliSpawnPlan(options: {
27
27
  runtimeSettings: Record<string, any>;
28
28
  workingDir: string;
29
29
  extraArgs: string[];
30
+ extraEnv?: Record<string, string>;
30
31
  }): CliSpawnPlan {
31
- const { provider, runtimeSettings, workingDir, extraArgs } = options;
32
+ const { provider, runtimeSettings, workingDir, extraArgs, extraEnv } = options;
32
33
  const { spawn: spawnConfig } = provider;
33
34
  const configuredCommand = typeof runtimeSettings.executablePath === 'string' && runtimeSettings.executablePath.trim()
34
35
  ? runtimeSettings.executablePath.trim()
@@ -65,7 +66,7 @@ export function resolveCliSpawnPlan(options: {
65
66
  shellArgs = allArgs;
66
67
  }
67
68
 
68
- const env = buildCliSpawnEnv(process.env, spawnConfig.env);
69
+ const env = buildCliSpawnEnv(process.env, { ...(spawnConfig.env || {}), ...(extraEnv || {}) });
69
70
  // Some CLI agents, notably Hermes, route their tools through TERMINAL_CWD
70
71
  // rather than process.cwd(). Keep the generic ADHDev launch workspace as
71
72
  // the single source of truth so PTY cwd and tool cwd cannot diverge.
@@ -28,6 +28,10 @@ interface ApprovalSelectableInstance extends ProviderInstance {
28
28
  recordApprovalSelection?(buttonText: string): void;
29
29
  }
30
30
 
31
+ interface RuntimeChatMessageMerger extends ProviderInstance {
32
+ mergeRuntimeChatMessages?(messages: ChatMessage[]): ChatMessage[];
33
+ }
34
+
31
35
  type LegacyStringScript = (params?: Record<string, unknown> | string) => string;
32
36
 
33
37
  function getCurrentProviderType(h: CommandHelpers, fallback = ''): string {
@@ -250,6 +254,40 @@ function normalizeReadChatCommandStatus(status: unknown, activeModal: unknown):
250
254
  }
251
255
  }
252
256
 
257
+ function isGeneratingLikeStatus(status: unknown): boolean {
258
+ return status === 'generating' || status === 'streaming' || status === 'long_generating' || status === 'starting';
259
+ }
260
+
261
+ function shouldTrustCliAdapterTerminalStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any): boolean {
262
+ if (!isGeneratingLikeStatus(parsedStatus)) return false;
263
+ if (hasNonEmptyModalButtons(activeModal)) return false;
264
+ const adapterRawStatus = typeof adapterStatus?.status === 'string' ? adapterStatus.status.trim() : '';
265
+ if (adapterRawStatus !== 'idle') return false;
266
+ if (typeof adapter.isProcessing === 'function' && adapter.isProcessing()) return false;
267
+ return true;
268
+ }
269
+
270
+ function normalizeCliReadChatStatus(parsedStatus: unknown, activeModal: unknown, adapter: CliAdapter, adapterStatus: any): string {
271
+ if (shouldTrustCliAdapterTerminalStatus(parsedStatus, activeModal, adapter, adapterStatus)) return 'idle';
272
+ return typeof parsedStatus === 'string' && parsedStatus.trim() ? parsedStatus : 'idle';
273
+ }
274
+
275
+ function finalizeStreamingMessagesWhenIdle(messages: ChatMessage[], status: string): ChatMessage[] {
276
+ if (status !== 'idle') return messages;
277
+ return messages.map((message) => {
278
+ const meta = message.meta && typeof message.meta === 'object'
279
+ ? message.meta as Record<string, unknown>
280
+ : undefined;
281
+ const hasStreamingMeta = meta?.streaming === true;
282
+ if (message.bubbleState !== 'streaming' && !hasStreamingMeta) return message;
283
+ return {
284
+ ...message,
285
+ ...(message.bubbleState === 'streaming' ? { bubbleState: 'final' as const } : {}),
286
+ ...(hasStreamingMeta ? { meta: { ...meta, streaming: false } } : {}),
287
+ };
288
+ });
289
+ }
290
+
253
291
  function buildReadChatCommandResult(payload: Record<string, any>, args: any): CommandResult {
254
292
  let validatedPayload: Record<string, any>;
255
293
  const debugReadChat = payload?.debugReadChat && typeof payload.debugReadChat === 'object'
@@ -760,10 +798,17 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
760
798
  ? parsedRecord.coverage
761
799
  : undefined;
762
800
  const activeModal = parsedRecord.activeModal ?? parsedRecord.modal ?? null;
763
- const returnedStatus = parsedRecord.status || 'idle';
764
- LOG.debug('Command', `[read_chat] cli-like parsed provider=${adapter.cliType} target=${String(args?.targetSessionId || '')} adapterStatus=${String(adapterStatus.status || '')} parsedStatus=${String(parsedRecord.status || '')} parsedMsgCount=${parsedRecord.messages.length}`);
801
+ const returnedStatus = normalizeCliReadChatStatus(parsedRecord.status, activeModal, adapter, adapterStatus);
802
+ const runtimeMessageMerger = getTargetInstance(h, args) as RuntimeChatMessageMerger | null;
803
+ const parsedMessages = finalizeStreamingMessagesWhenIdle(parsedRecord.messages as ChatMessage[], returnedStatus);
804
+ const returnedMessages = runtimeMessageMerger?.category === 'cli'
805
+ && runtimeMessageMerger.type === adapter.cliType
806
+ && typeof runtimeMessageMerger.mergeRuntimeChatMessages === 'function'
807
+ ? runtimeMessageMerger.mergeRuntimeChatMessages(parsedMessages)
808
+ : parsedMessages;
809
+ LOG.debug('Command', `[read_chat] cli-like parsed provider=${adapter.cliType} target=${String(args?.targetSessionId || '')} adapterStatus=${String(adapterStatus.status || '')} parsedStatus=${String(parsedRecord.status || '')} parsedMsgCount=${parsedRecord.messages.length} returnedMsgCount=${returnedMessages.length}`);
765
810
  return buildReadChatCommandResult({
766
- messages: parsedRecord.messages,
811
+ messages: returnedMessages,
767
812
  status: returnedStatus,
768
813
  activeModal,
769
814
  debugReadChat: {
@@ -774,7 +819,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
774
819
  returnedStatus: String(returnedStatus || ''),
775
820
  shouldPreferAdapterMessages: false,
776
821
  parsedMsgCount: parsedRecord.messages.length,
777
- returnedMsgCount: parsedRecord.messages.length,
822
+ returnedMsgCount: returnedMessages.length,
778
823
  },
779
824
  ...(title ? { title } : {}),
780
825
  ...(providerSessionId ? { providerSessionId } : {}),
@@ -132,6 +132,12 @@ type CliAdapterWithExtraArgs = CliAdapter & {
132
132
  extraArgs?: string[];
133
133
  };
134
134
 
135
+ type CliStartOptions = {
136
+ resumeSessionId?: string;
137
+ settingsOverride?: Record<string, any>;
138
+ extraEnv?: Record<string, string>;
139
+ };
140
+
135
141
  function isUuid(value: string): boolean {
136
142
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
137
143
  }
@@ -365,6 +371,7 @@ export class DaemonCliManager {
365
371
  runtimeId: string,
366
372
  providerSessionId?: string,
367
373
  attachExisting = false,
374
+ extraEnv?: Record<string, string>,
368
375
  ): CliAdapter {
369
376
  // cliType normalize (Resolve alias)
370
377
  const normalizedType = this.providerLoader.resolveAlias(cliType);
@@ -382,7 +389,7 @@ export class DaemonCliManager {
382
389
  providerSessionId,
383
390
  attachExisting,
384
391
  );
385
- return new ProviderCliAdapter(resolvedProvider as CliProviderModule, workingDir, cliArgs, transportFactory);
392
+ return new ProviderCliAdapter(resolvedProvider as CliProviderModule, workingDir, cliArgs, extraEnv || {}, transportFactory);
386
393
  }
387
394
 
388
395
  throw new Error(`No CLI provider found for '${cliType}'. Create a provider.js in providers/cli/${cliType}/`);
@@ -425,6 +432,7 @@ export class DaemonCliManager {
425
432
  options?: {
426
433
  providerSessionId?: string;
427
434
  launchMode?: CliLaunchMode;
435
+ extraEnv?: Record<string, string>;
428
436
  onProviderSessionResolved?: (info: {
429
437
  instanceId: string;
430
438
  providerType: string;
@@ -480,7 +488,7 @@ export class DaemonCliManager {
480
488
  workingDir: string,
481
489
  cliArgs?: string[],
482
490
  initialModel?: string,
483
- options?: { resumeSessionId?: string, settingsOverride?: Record<string, any> },
491
+ options?: CliStartOptions,
484
492
  ): Promise<{ runtimeSessionId: string; providerSessionId?: string }> {
485
493
  const trimmed = (workingDir || '').trim();
486
494
  if (!trimmed) throw new Error('working directory required');
@@ -629,6 +637,7 @@ export class DaemonCliManager {
629
637
  {
630
638
  providerSessionId: sessionBinding.providerSessionId,
631
639
  launchMode: sessionBinding.launchMode,
640
+ extraEnv: options?.extraEnv,
632
641
  onProviderSessionResolved: ({ providerSessionId, providerName, providerType, workspace }) => {
633
642
  this.persistRecentActivity({
634
643
  kind: 'cli',
@@ -651,6 +660,7 @@ export class DaemonCliManager {
651
660
  key,
652
661
  sessionBinding.providerSessionId,
653
662
  false,
663
+ options?.extraEnv,
654
664
  );
655
665
  try {
656
666
  await adapter.spawn();
@@ -904,7 +914,7 @@ export class DaemonCliManager {
904
914
  dir,
905
915
  args?.cliArgs,
906
916
  args?.initialModel,
907
- { resumeSessionId: args?.resumeSessionId, settingsOverride: args?.settings },
917
+ { resumeSessionId: args?.resumeSessionId, settingsOverride: args?.settings, extraEnv: args?.env },
908
918
  );
909
919
 
910
920
  return {
@@ -1,6 +1,8 @@
1
- import { existsSync, realpathSync } from 'node:fs'
1
+ import { execFileSync } from 'node:child_process'
2
+ import { existsSync, readdirSync, realpathSync } from 'node:fs'
2
3
  import { createRequire } from 'node:module'
3
- import { dirname, join, resolve } from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
4
6
  import type { ProviderModule, MeshCoordinatorMcpConfigFormat } from '../providers/contracts.js'
5
7
 
6
8
  export interface MeshCoordinatorMcpServerLaunch {
@@ -32,6 +34,7 @@ export type MeshCoordinatorSetup =
32
34
 
33
35
  export interface ResolveMeshCoordinatorSetupOptions {
34
36
  provider?: ProviderModule | null
37
+ cliType?: string
35
38
  meshId: string
36
39
  workspace: string
37
40
  adhdevMcpCommand?: string
@@ -41,6 +44,58 @@ export interface ResolveMeshCoordinatorSetupOptions {
41
44
 
42
45
  const DEFAULT_SERVER_NAME = 'adhdev-mesh'
43
46
  const DEFAULT_ADHDEV_MCP_COMMAND = 'adhdev-mcp'
47
+ const HERMES_CLI_TYPE = 'hermes-cli'
48
+ const HERMES_MCP_CONFIG_PATH = '~/.hermes/config.yaml'
49
+
50
+ function isHermesProvider(provider: ProviderModule | null | undefined, cliType?: string): boolean {
51
+ const type = cliType?.trim() || provider?.type?.trim() || ''
52
+ return type === HERMES_CLI_TYPE
53
+ }
54
+
55
+ function resolveHermesMeshCoordinatorSetup(options: ResolveMeshCoordinatorSetupOptions): MeshCoordinatorSetup {
56
+ const mcpServer = resolveAdhdevMcpServerLaunch({
57
+ meshId: options.meshId,
58
+ nodeExecutable: options.nodeExecutable,
59
+ adhdevMcpEntryPath: options.adhdevMcpEntryPath,
60
+ })
61
+ if (!mcpServer) {
62
+ return {
63
+ kind: 'unsupported',
64
+ reason: 'Could not resolve the ADHDev MCP server entrypoint and a Node runtime with WebSocket support for daemon IPC mode',
65
+ }
66
+ }
67
+ const configPath = resolveMcpConfigPath(HERMES_MCP_CONFIG_PATH, options.workspace)
68
+ if (!configPath.trim()) {
69
+ return createHermesManualMeshCoordinatorSetup(options.meshId, options.workspace)
70
+ }
71
+ return {
72
+ kind: 'auto_import',
73
+ serverName: DEFAULT_SERVER_NAME,
74
+ configPath,
75
+ configFormat: 'hermes_config_yaml',
76
+ mcpServer,
77
+ }
78
+ }
79
+
80
+ export function createHermesManualMeshCoordinatorSetup(meshId: string, workspace: string): MeshCoordinatorSetup {
81
+ return {
82
+ kind: 'manual',
83
+ serverName: DEFAULT_SERVER_NAME,
84
+ configFormat: 'hermes_config_yaml',
85
+ configPathCommand: HERMES_MCP_CONFIG_PATH,
86
+ requiresRestart: true,
87
+ instructions: 'Hermes CLI does not auto-import repo-local .mcp.json. Add this MCP server to Hermes config under mcp_servers, then start a fresh Hermes session.',
88
+ template: renderMeshCoordinatorTemplate(
89
+ 'mcp_servers:\n {{serverName}}:\n command: {{adhdevMcpCommand}}\n args:\n - --repo-mesh\n - {{meshId}}\n enabled: true\n',
90
+ {
91
+ meshId,
92
+ workspace,
93
+ serverName: DEFAULT_SERVER_NAME,
94
+ adhdevMcpCommand: DEFAULT_ADHDEV_MCP_COMMAND,
95
+ },
96
+ ),
97
+ }
98
+ }
44
99
 
45
100
  export function resolveMeshCoordinatorSetup(options: ResolveMeshCoordinatorSetupOptions): MeshCoordinatorSetup {
46
101
  const { provider, meshId, workspace } = options
@@ -52,6 +107,10 @@ export function resolveMeshCoordinatorSetup(options: ResolveMeshCoordinatorSetup
52
107
  }
53
108
  }
54
109
 
110
+ if (isHermesProvider(provider, options.cliType)) {
111
+ return resolveHermesMeshCoordinatorSetup(options)
112
+ }
113
+
55
114
  const mcpConfig = config.mcpConfig
56
115
  if (!mcpConfig || mcpConfig.mode === 'none') {
57
116
  return {
@@ -74,13 +133,13 @@ export function resolveMeshCoordinatorSetup(options: ResolveMeshCoordinatorSetup
74
133
  if (!mcpServer) {
75
134
  return {
76
135
  kind: 'unsupported',
77
- reason: 'Could not resolve the ADHDev MCP server entrypoint without relying on a PATH bin shim',
136
+ reason: 'Could not resolve the ADHDev MCP server entrypoint and a Node runtime with WebSocket support for daemon IPC mode',
78
137
  }
79
138
  }
80
139
  return {
81
140
  kind: 'auto_import',
82
141
  serverName,
83
- configPath: join(workspace, path),
142
+ configPath: resolveMcpConfigPath(path, workspace),
84
143
  configFormat: mcpConfig.format,
85
144
  mcpServer,
86
145
  }
@@ -118,6 +177,14 @@ function renderMeshCoordinatorTemplate(template: string, values: Record<string,
118
177
  return template.replace(/\{\{\s*(meshId|workspace|serverName|adhdevMcpCommand)\s*\}\}/g, (_, key: string) => values[key] || '')
119
178
  }
120
179
 
180
+ function resolveMcpConfigPath(configPath: string, workspace: string): string {
181
+ const trimmed = configPath.trim()
182
+ if (trimmed === '~') return os.homedir()
183
+ if (trimmed.startsWith('~/')) return join(os.homedir(), trimmed.slice(2))
184
+ if (isAbsolute(trimmed)) return trimmed
185
+ return join(workspace, trimmed)
186
+ }
187
+
121
188
  function resolveAdhdevMcpServerLaunch(options: {
122
189
  meshId: string
123
190
  nodeExecutable?: string
@@ -125,9 +192,85 @@ function resolveAdhdevMcpServerLaunch(options: {
125
192
  }): MeshCoordinatorMcpServerLaunch | null {
126
193
  const entryPath = resolveAdhdevMcpEntryPath(options.adhdevMcpEntryPath)
127
194
  if (!entryPath) return null
195
+ const nodeExecutable = resolveMcpNodeExecutable(options.nodeExecutable)
196
+ if (!nodeExecutable) return null
128
197
  return {
129
- command: options.nodeExecutable?.trim() || process.execPath,
130
- args: [entryPath, '--repo-mesh', options.meshId],
198
+ command: nodeExecutable,
199
+ args: [entryPath, '--mode', 'ipc', '--repo-mesh', options.meshId],
200
+ }
201
+ }
202
+
203
+ function resolveMcpNodeExecutable(explicitExecutable?: string): string | null {
204
+ const explicit = explicitExecutable?.trim()
205
+ if (explicit) return explicit
206
+
207
+ const candidates: string[] = []
208
+ const addCandidate = (candidate?: string | null) => {
209
+ const trimmed = candidate?.trim()
210
+ if (!trimmed) return
211
+ const normalized = normalizeExistingPath(trimmed) || trimmed
212
+ if (!candidates.includes(normalized)) candidates.push(normalized)
213
+ }
214
+
215
+ addCandidate(process.env.ADHDEV_MCP_NODE_EXECUTABLE)
216
+ addCandidate(process.env.ADHDEV_NODE_EXECUTABLE)
217
+ addCandidate(process.env.npm_node_execpath)
218
+ addNodeCandidatesFromPath(process.env.PATH, addCandidate)
219
+ addNodeCandidatesFromNvm(os.homedir(), addCandidate)
220
+ addCandidate('/opt/homebrew/bin/node')
221
+ addCandidate('/usr/local/bin/node')
222
+ addCandidate('/usr/bin/node')
223
+ addCandidate(process.execPath)
224
+
225
+ for (const candidate of candidates) {
226
+ if (nodeRuntimeSupportsWebSocket(candidate)) return candidate
227
+ }
228
+ return null
229
+ }
230
+
231
+ function addNodeCandidatesFromPath(pathValue: string | undefined, addCandidate: (candidate?: string | null) => void) {
232
+ for (const entry of (pathValue || '').split(':')) {
233
+ const dir = entry.trim()
234
+ if (!dir) continue
235
+ addCandidate(join(dir, 'node'))
236
+ }
237
+ }
238
+
239
+ function addNodeCandidatesFromNvm(homeDir: string, addCandidate: (candidate?: string | null) => void) {
240
+ const versionsDir = join(homeDir, '.nvm', 'versions', 'node')
241
+ try {
242
+ const versionDirs = readdirSync(versionsDir, { withFileTypes: true })
243
+ .filter((entry) => entry.isDirectory())
244
+ .map((entry) => entry.name)
245
+ .sort(compareNodeVersionNamesDescending)
246
+ for (const versionDir of versionDirs) {
247
+ addCandidate(join(versionsDir, versionDir, 'bin', 'node'))
248
+ }
249
+ } catch {
250
+ // nvm is optional; PATH and process.execPath candidates still cover normal installs.
251
+ }
252
+ }
253
+
254
+ function compareNodeVersionNamesDescending(a: string, b: string): number {
255
+ const parse = (value: string) => value.replace(/^v/, '').split('.').map((part) => Number.parseInt(part, 10) || 0)
256
+ const left = parse(a)
257
+ const right = parse(b)
258
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
259
+ const diff = (right[i] || 0) - (left[i] || 0)
260
+ if (diff !== 0) return diff
261
+ }
262
+ return b.localeCompare(a)
263
+ }
264
+
265
+ function nodeRuntimeSupportsWebSocket(nodeExecutable: string): boolean {
266
+ try {
267
+ execFileSync(nodeExecutable, ['-e', "process.exit(typeof WebSocket === 'function' ? 0 : 42)"], {
268
+ stdio: 'ignore',
269
+ timeout: 3000,
270
+ })
271
+ return true
272
+ } catch {
273
+ return false
131
274
  }
132
275
  }
133
276