@adhdev/daemon-core 0.9.82-rc.364 → 0.9.82-rc.366

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.
Files changed (41) hide show
  1. package/dist/commands/high-family/index.d.ts +3 -0
  2. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  3. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  4. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  5. package/dist/commands/high-family/types.d.ts +60 -0
  6. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  7. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  8. package/dist/commands/med-family/ide.d.ts +10 -0
  9. package/dist/commands/med-family/index.d.ts +3 -0
  10. package/dist/commands/med-family/mesh-crud.d.ts +2 -0
  11. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  12. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  13. package/dist/commands/med-family/types.d.ts +116 -0
  14. package/dist/commands/router.d.ts +291 -0
  15. package/dist/index.js +3824 -3565
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +3811 -3553
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/mesh/mesh-events-coordinator.d.ts +8 -0
  20. package/dist/system/hash.d.ts +8 -0
  21. package/package.json +2 -2
  22. package/src/commands/cli-manager.ts +30 -3
  23. package/src/commands/high-family/index.ts +28 -0
  24. package/src/commands/high-family/mesh-coordinator-launch.ts +592 -0
  25. package/src/commands/high-family/mesh-events.ts +47 -0
  26. package/src/commands/high-family/mesh-status.ts +639 -0
  27. package/src/commands/high-family/types.ts +76 -0
  28. package/src/commands/med-family/cli-agent.ts +218 -0
  29. package/src/commands/med-family/fast-forward.ts +198 -0
  30. package/src/commands/med-family/ide.ts +163 -0
  31. package/src/commands/med-family/index.ts +35 -0
  32. package/src/commands/med-family/mesh-crud.ts +788 -0
  33. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  34. package/src/commands/med-family/mesh-queue.ts +131 -0
  35. package/src/commands/med-family/types.ts +120 -0
  36. package/src/commands/mesh-coordinator.ts +2 -2
  37. package/src/commands/router.ts +328 -2847
  38. package/src/config/mesh-config.ts +3 -2
  39. package/src/mesh/mesh-active-work.ts +59 -81
  40. package/src/mesh/mesh-events-coordinator.ts +35 -1
  41. package/src/system/hash.ts +23 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * RF-ROUTER HIGH family — shared types for the extracted high-coupling command
3
+ * handlers. Like the LOW and MED families, each handler is a function of
4
+ * (context, args) that returns the exact CommandRouterResult the original
5
+ * `executeDaemonCommand` switch case returned, so the router facade is unchanged.
6
+ *
7
+ * HIGH handlers are the most router-coupled of the three families: in addition to
8
+ * the MED collaborators (mesh resolution, owner gating, inline-cache), they reach
9
+ * the router's aggregate-status memory cache and running-refine-job table — state
10
+ * the router owns and the `mesh_status` aggregate render and `get_mesh_review_inbox`
11
+ * re-entry both depend on. The router binds those onto HighFamilyContext at
12
+ * dispatch; they are NOT reachable from `deps`.
13
+ *
14
+ * Registry dispatch: DaemonCommandRouter.executeDaemonCommand looks up the cmd in
15
+ * highFamilyRegistry AFTER the LOW and MED registries and BEFORE its remaining
16
+ * switch; a hit returns the handler result, a miss falls through to the switch
17
+ * (and ultimately CommandHandler delegation).
18
+ */
19
+ import type {
20
+ CommandRouterDeps,
21
+ CommandRouterResult,
22
+ MeshGitProbeCache,
23
+ MeshRefineJobHandle,
24
+ } from '../router.js';
25
+ import type { ResolvedMeshForCommand } from '../med-family/types.js';
26
+
27
+ /**
28
+ * Router-private collaborators injected at dispatch. Each is a bound method or
29
+ * field of DaemonCommandRouter; handlers that don't need a given collaborator
30
+ * simply ignore it. The router owns this instance state (inline-mesh cache,
31
+ * aggregate-status memory cache, running-refine-job table, git-probe cache), so
32
+ * it cannot be read from `deps` — the registry receives bound references instead.
33
+ */
34
+ export interface HighFamilyContext {
35
+ deps: CommandRouterDeps;
36
+
37
+ /** Bound `DaemonCommandRouter.getMeshForCommand`. */
38
+ getMeshForCommand: (
39
+ meshId: string,
40
+ inlineMesh?: unknown,
41
+ options?: { preferInline?: boolean },
42
+ ) => Promise<ResolvedMeshForCommand>;
43
+
44
+ /** Bound `DaemonCommandRouter.getCachedAggregateMeshStatus`. */
45
+ getCachedAggregateMeshStatus: (
46
+ meshId: string,
47
+ mesh?: any,
48
+ options?: { requireDirectPeerTruth?: boolean },
49
+ ) => any | null;
50
+
51
+ /** Bound `DaemonCommandRouter.rememberAggregateMeshStatus`. */
52
+ rememberAggregateMeshStatus: (meshId: string, snapshot: any, refreshReason: string) => any;
53
+
54
+ /**
55
+ * Bound `DaemonCommandRouter.execute`. `get_mesh_review_inbox` re-enters the
56
+ * router with a `mesh_status` refresh to obtain computed node fields; this is
57
+ * the same self-call the inlined case made.
58
+ */
59
+ execute: (cmd: string, args: any, source?: string) => Promise<CommandRouterResult>;
60
+
61
+ /** Router's aggregate-status memory cache (`.has()` probe in mesh_status). */
62
+ aggregateMeshStatusCache: Map<string, { builtAt: number; snapshot: any; queueRevision: string }>;
63
+
64
+ /** Router's running-refine-job table (surfaced as activeRefineJobs in mesh_status). */
65
+ runningRefineJobs: Map<string, MeshRefineJobHandle>;
66
+
67
+ /** Router's inline-mesh cache (launch_mesh_coordinator caches cloud inline mesh). */
68
+ inlineMeshCache: Map<string, any>;
69
+
70
+ /** Router's mesh git-probe cache (shared probe dedup for mesh_status). */
71
+ meshGitProbeCache: MeshGitProbeCache;
72
+ }
73
+
74
+ export type HighFamilyHandler = (ctx: HighFamilyContext, args: any) => Promise<CommandRouterResult | null>;
75
+
76
+ export type HighFamilyRegistry = Map<string, HighFamilyHandler>;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * RF-ROUTER MED family — CLI/ACP agent + saved-session + restart commands.
3
+ *
4
+ * launch_cli, stop_cli / set_cli_view_mode / record_provider_pty, agent_command,
5
+ * list_saved_sessions and restart_session. launch_cli and agent_command stamp
6
+ * mesh-worker relay metadata and surface worktree-bootstrap-pending hints around a
7
+ * delegation to cliManager. restart_session dispatches IDE restarts (via ctx.stopIde
8
+ * + ctx.launchIde — no executeDaemonCommand recursion) or CLI/ACP restarts.
9
+ * Extracted verbatim from executeDaemonCommand.
10
+ */
11
+ import { meshNodeIdMatches } from '@adhdev/mesh-shared';
12
+ import { supportsExplicitSessionResume } from '../cli-manager.js';
13
+ import { loadState } from '../../config/state-store.js';
14
+ import { getRecentActivity } from '../../config/recent-activity.js';
15
+ import { getSavedProviderSessions } from '../../config/saved-sessions.js';
16
+ import { listProviderHistorySessions } from '../../config/chat-history.js';
17
+ import { buildMeshWorkerRelayStamp } from '../../mesh/mesh-events-utils.js';
18
+ import { readStringValue } from '../router.js';
19
+ import type { MedFamilyContext, MedFamilyHandler } from './types.js';
20
+
21
+ export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
22
+ launch_cli: async (ctx: MedFamilyContext, args: any) => {
23
+ // The coordinator routing anchor (meshCoordinatorDaemonId) is stamped
24
+ // upstream by mesh_launch_session, which resolves
25
+ // coordinatorNode.daemonId || ctx.localDaemonId || ctx.localMachineId and
26
+ // fail-closes for a remote node when none resolve. We deliberately do NOT
27
+ // self-stamp this daemon's own id when the field is missing: for a
28
+ // P2P-relayed remote worker launch, stamping the worker's own id would make
29
+ // the self-forward gate (mesh-events-coordinator: daemonIdsEquivalent) treat the
30
+ // worker as its own coordinator, suppressing the spontaneous completion-event
31
+ // forward and leaving the event in the pending inbox until a read_chat
32
+ // reconcile drains it. If the anchor is genuinely absent here, leave it
33
+ // absent rather than poison the routing.
34
+ const launchResult = await ctx.deps.cliManager.handleCliCommand('launch_cli', args);
35
+ // Bug C fix (part 1): when launching a mesh node worker session, surface
36
+ // bootstrapPending:true if the node's worktree bootstrap is still running.
37
+ // This is informational — the launch is NOT blocked here (blocking is done
38
+ // upstream by getWorktreeBootstrapLaunchBlock in the MCP layer).
39
+ const meshNodeId = readStringValue((args?.settings as any)?.meshNodeId);
40
+ const meshId = readStringValue((args?.settings as any)?.meshNodeFor);
41
+ if (meshNodeId && meshId && launchResult?.success !== false) {
42
+ try {
43
+ const { getMesh } = await import('../../config/mesh-config.js');
44
+ const meshObj = getMesh(meshId) ?? ctx.getCachedInlineMesh(meshId);
45
+ const nodeObj = Array.isArray(meshObj?.nodes)
46
+ ? meshObj.nodes.find((n: any) => meshNodeIdMatches(n, meshNodeId))
47
+ : undefined;
48
+ const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
49
+ if (bootstrapStatus === 'running') {
50
+ return { success: true, ...launchResult, bootstrapPending: true };
51
+ }
52
+ } catch { /* best-effort — do not fail launch for bootstrap probe errors */ }
53
+ }
54
+ return launchResult;
55
+ },
56
+
57
+ stop_cli: async (ctx: MedFamilyContext, args: any) => {
58
+ return ctx.deps.cliManager.handleCliCommand('stop_cli', args);
59
+ },
60
+ set_cli_view_mode: async (ctx: MedFamilyContext, args: any) => {
61
+ return ctx.deps.cliManager.handleCliCommand('set_cli_view_mode', args);
62
+ },
63
+ record_provider_pty: async (ctx: MedFamilyContext, args: any) => {
64
+ return ctx.deps.cliManager.handleCliCommand('record_provider_pty', args);
65
+ },
66
+
67
+ agent_command: async (ctx: MedFamilyContext, args: any) => {
68
+ // Relay-safety stamp: a dispatch carrying meshContext.coordinatorDaemonId
69
+ // (mesh_send_task / queue assignment over P2P) is the worker daemon's chance
70
+ // to persist the coordinator routing anchor onto the target session BEFORE the
71
+ // turn runs. Without meshCoordinatorDaemonId on the session, the core forwarder
72
+ // (injectMeshSystemMessage) cannot resolve a remote coordinator target, so the
73
+ // completion event sits in the pending queue until a read_chat reconcile drains
74
+ // it. Stamping here makes a reused/relaunched remote session relay-safe at
75
+ // dispatch time even when it was not launched via mesh_launch_session.
76
+ {
77
+ const dispatchSessionId = readStringValue(args?.targetSessionId, (args as any)?.sessionId, (args as any)?.instanceId);
78
+ const dispatchMeshContext = args?.meshContext as Record<string, unknown> | undefined;
79
+ if (dispatchSessionId && dispatchMeshContext) {
80
+ try {
81
+ const inst = ctx.deps.instanceManager.getInstance(dispatchSessionId);
82
+ if (inst && typeof inst.updateSettings === 'function') {
83
+ const stamp = buildMeshWorkerRelayStamp(
84
+ inst.getState?.()?.settings as Record<string, unknown> | undefined,
85
+ {
86
+ meshId: dispatchMeshContext.meshId,
87
+ nodeId: dispatchMeshContext.nodeId,
88
+ coordinatorDaemonId: dispatchMeshContext.coordinatorDaemonId,
89
+ // Session-level anchor: preserved across the P2P dispatch to a
90
+ // remote worker so its completion echoes back to the right session.
91
+ coordinatorSessionId: dispatchMeshContext.coordinatorSessionId,
92
+ },
93
+ );
94
+ if (stamp) inst.updateSettings(stamp);
95
+ }
96
+ } catch { /* best-effort — dispatch still proceeds without the stamp */ }
97
+ }
98
+ }
99
+ const agentResult = await ctx.deps.cliManager.handleCliCommand('agent_command', args);
100
+ // Bug C fix (part 2): when dispatching a task to a mesh node session, override
101
+ // the dispatch acknowledgement risk reason to 'bootstrap_still_running' when
102
+ // the target node's worktree bootstrap is still running. Informational only —
103
+ // dispatch is NOT blocked.
104
+ const meshCtx = args?.meshContext as Record<string, unknown> | undefined;
105
+ const dispatchNodeId = readStringValue(meshCtx?.nodeId);
106
+ const dispatchMeshId = readStringValue(meshCtx?.meshId);
107
+ if (dispatchNodeId && dispatchMeshId && agentResult?.success !== false) {
108
+ try {
109
+ const { getMesh } = await import('../../config/mesh-config.js');
110
+ const meshObj = getMesh(dispatchMeshId) ?? ctx.getCachedInlineMesh(dispatchMeshId);
111
+ const nodeObj = Array.isArray(meshObj?.nodes)
112
+ ? meshObj.nodes.find((n: any) => meshNodeIdMatches(n, dispatchNodeId))
113
+ : undefined;
114
+ const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
115
+ if (bootstrapStatus === 'running') {
116
+ return {
117
+ success: true,
118
+ ...agentResult,
119
+ dispatchAcknowledgementRisk: true,
120
+ dispatchAcknowledgementRiskReason: 'bootstrap_still_running',
121
+ nextAction: 'Wait for worktree_bootstrap_complete event before dispatching work to this node.',
122
+ };
123
+ }
124
+ } catch { /* best-effort */ }
125
+ }
126
+ return agentResult;
127
+ },
128
+
129
+ // ─── Logs ───
130
+ list_saved_sessions: async (ctx: MedFamilyContext, args: any) => {
131
+ const providerType = typeof args?.providerType === 'string'
132
+ ? args.providerType.trim()
133
+ : typeof args?.agentType === 'string'
134
+ ? args.agentType.trim()
135
+ : '';
136
+ const kind = args?.kind === 'acp' ? 'acp' : 'cli';
137
+ if (!providerType) {
138
+ return { success: false, error: 'providerType required' };
139
+ }
140
+
141
+ const wantsAll = args?.all === true;
142
+ const offset = wantsAll ? 0 : Math.max(0, Number(args?.offset) || 0);
143
+ const limit = wantsAll ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.min(100, Number(args?.limit) || 30));
144
+ const requestedWorkspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
145
+ const requestedProviderSessionId = typeof args?.providerSessionId === 'string'
146
+ ? args.providerSessionId.trim()
147
+ : typeof args?.activeProviderSessionId === 'string'
148
+ ? args.activeProviderSessionId.trim()
149
+ : '';
150
+ const providerMeta = ctx.deps.providerLoader.resolve?.(providerType) || ctx.deps.providerLoader.getMeta(providerType);
151
+ const { sessions: historySessions, hasMore, source } = listProviderHistorySessions(providerType, {
152
+ canonicalHistory: providerMeta?.nativeHistory,
153
+ offset,
154
+ limit,
155
+ historyBehavior: providerMeta?.historyBehavior,
156
+ scripts: providerMeta?.scripts as any,
157
+ });
158
+ const state = loadState();
159
+ const savedSessions = getSavedProviderSessions(state, { providerType, kind });
160
+ const recentSessions = getRecentActivity(state, 200)
161
+ .filter(entry => entry.providerType === providerType && entry.kind === kind && entry.providerSessionId);
162
+ const savedSessionById = new Map(savedSessions.map(entry => [entry.providerSessionId, entry]));
163
+ const recentSessionById = new Map(recentSessions.map(entry => [entry.providerSessionId!, entry]));
164
+ const canResumeById = supportsExplicitSessionResume(providerMeta?.resume);
165
+
166
+ return {
167
+ success: true,
168
+ sessions: historySessions.map(session => {
169
+ const saved = savedSessionById.get(session.historySessionId);
170
+ const recent = recentSessionById.get(session.historySessionId);
171
+ const workspace = saved?.workspace
172
+ || recent?.workspace
173
+ || session.workspace
174
+ || (requestedWorkspace && requestedProviderSessionId === session.historySessionId ? requestedWorkspace : undefined);
175
+ return {
176
+ id: session.historySessionId,
177
+ providerSessionId: session.historySessionId,
178
+ providerType,
179
+ providerName: saved?.providerName || recent?.providerName || providerType,
180
+ kind: saved?.kind || recent?.kind || kind,
181
+ title: saved?.title || recent?.title || session.sessionTitle || session.preview || providerType,
182
+ workspace,
183
+ summaryMetadata: saved?.summaryMetadata || recent?.summaryMetadata,
184
+ preview: session.preview,
185
+ messageCount: session.messageCount,
186
+ firstMessageAt: session.firstMessageAt,
187
+ lastMessageAt: session.lastMessageAt,
188
+ canResume: !!workspace && canResumeById,
189
+ historySource: session.source,
190
+ sourcePath: session.sourcePath,
191
+ sourceMtimeMs: session.sourceMtimeMs,
192
+ };
193
+ }),
194
+ hasMore,
195
+ source,
196
+ };
197
+ },
198
+
199
+ // ─── restart_session: IDE / CLI / ACP unified ───
200
+ restart_session: async (ctx: MedFamilyContext, args: any) => {
201
+ const targetType = args?.cliType || args?.agentType || args?.ideType;
202
+ if (!targetType) throw new Error('cliType or ideType required');
203
+
204
+ // Check if IDE (in cdpManagers or provider category is ide)
205
+ const isIde = ctx.deps.cdpManagers.has(targetType) ||
206
+ ctx.deps.providerLoader.getMeta(targetType)?.category === 'ide';
207
+
208
+ if (isIde) {
209
+ // IDE restart: stop (with process kill) → launch
210
+ await ctx.stopIde(targetType, true);
211
+ const launchResult = await ctx.launchIde({ ideType: targetType, enableCdp: true, workspace: args?.workspace });
212
+ return { success: true, restarted: true, ideType: targetType, launch: launchResult };
213
+ }
214
+
215
+ // CLI/ACP restart: delegate to CliManager
216
+ return ctx.deps.cliManager.handleCliCommand('restart_session', args);
217
+ },
218
+ };
@@ -0,0 +1,198 @@
1
+ /**
2
+ * RF-ROUTER MED family — fast-forward / refine convergence commands.
3
+ *
4
+ * mesh_init, plan_mesh_refine_node, fast_forward_mesh_node, refine_mesh_node and
5
+ * batch_refine_mesh_nodes. These resolve the target node, forward to the owning
6
+ * daemon when the node is remote, and run (or plan) the fast-forward / refine
7
+ * convergence. Extracted verbatim from executeDaemonCommand; the async execute
8
+ * paths delegate back to the router's refine-job starters via ctx.
9
+ */
10
+ import { daemonIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
11
+ import { fastForwardMeshNode } from '../../mesh/mesh-fast-forward.js';
12
+ import { runMeshInit } from '../../mesh/mesh-init.js';
13
+ import { detectCLIs } from '../../detection/cli-detector.js';
14
+ import { buildMeshRefineValidationPlan } from '../router.js';
15
+ import type { CommandRouterResult } from '../router.js';
16
+ import type { MedFamilyContext, MedFamilyHandler } from './types.js';
17
+
18
+ export const fastForwardHandlers: Record<string, MedFamilyHandler> = {
19
+ mesh_init: async (ctx: MedFamilyContext, args: any) => {
20
+ const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
21
+ const mesh = args?.inlineMesh || {};
22
+ try {
23
+ const detected = await detectCLIs(ctx.deps.providerLoader, { includeVersion: true });
24
+ return { ...runMeshInit(mesh, workspace, detected, {
25
+ write: args?.write === true,
26
+ overwrite: args?.overwrite === true,
27
+ }) };
28
+ } catch (e: any) {
29
+ return { success: false, error: e?.message || String(e) };
30
+ }
31
+ },
32
+
33
+ plan_mesh_refine_node: async (ctx: MedFamilyContext, args: any) => {
34
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
35
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
36
+ if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
37
+ // preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
38
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
39
+ const mesh = meshRecord?.mesh;
40
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
41
+ if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
42
+ return {
43
+ success: true,
44
+ dryRun: true,
45
+ nodeId,
46
+ workspace: node.workspace,
47
+ validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
48
+ mergeWillRun: false,
49
+ cleanupWillRun: false,
50
+ };
51
+ },
52
+
53
+ fast_forward_mesh_node: async (ctx: MedFamilyContext, args: any) => {
54
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
55
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
56
+ let workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
57
+ let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
58
+ ? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
59
+ : undefined;
60
+ let nodeDaemonId: string | undefined;
61
+ let allowAutoPublishSubmoduleMainCommits = false;
62
+ if (meshId && nodeId) {
63
+ // preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
64
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
65
+ const mesh = meshRecord?.mesh;
66
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
67
+ if (!workspace) {
68
+ workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
69
+ }
70
+ if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
71
+ submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
72
+ }
73
+ allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
74
+ nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
75
+ }
76
+ // If the target node belongs to a remote daemon, forward the command there.
77
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
78
+ // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
79
+ const selfDaemonId = ctx.deps.statusInstanceId;
80
+ // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
81
+ // machine's core must be treated as local (not remote) so it is not forwarded /
82
+ // P2P self-dialed. Equivalent → local.
83
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
84
+ if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
85
+ const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
86
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
87
+ workspace,
88
+ _meshDirectDispatch: true,
89
+ });
90
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
91
+ }
92
+ const result = await (fastForwardMeshNode({
93
+ meshId: meshId || undefined,
94
+ nodeId: nodeId || undefined,
95
+ workspace,
96
+ branch: typeof args?.branch === 'string' ? args.branch : undefined,
97
+ execute: args?.execute === true,
98
+ dryRun: args?.dryRun === true,
99
+ updateSubmodules: args?.updateSubmodules === true,
100
+ submoduleIgnorePaths,
101
+ mode: args?.mode === 'push' ? 'push' : 'merge',
102
+ pushSubmodules: args?.pushSubmodules === true,
103
+ allowAutoPublishSubmoduleMainCommits,
104
+ }) as Promise<unknown>);
105
+ return result as CommandRouterResult;
106
+ },
107
+
108
+ refine_mesh_node: async (ctx: MedFamilyContext, args: any) => {
109
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
110
+ const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
111
+ if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
112
+
113
+ // Remote forward: a worktree node lives on its OWN daemon's machine, so the
114
+ // refine (cd into node.workspace, merge → push → cleanup) must run on THAT
115
+ // daemon — not the coordinator, whose filesystem has no such path. The sibling
116
+ // fast_forward_mesh_node / clone_mesh_node handlers already forward to the
117
+ // node's daemon; refine_mesh_node was the gap (the coordinator would cd into a
118
+ // non-existent local path and fail), so remote-machine worktrees could not be
119
+ // converged at all. Forward both dry-run (plan reads the worktree git state)
120
+ // and execute (async merge job) so the same machine that owns the worktree
121
+ // resolves it.
122
+ //
123
+ // coordinatorDaemonId: refine is ASYNC — the completed/failed event is queued
124
+ // on the executing daemon's pending-events queue scoped to a coordinator id and
125
+ // recovered by the coordinator's reconcile loop (pullRemoteNodeQueues →
126
+ // get_pending_mesh_events). Without stamping our own status id, the remote
127
+ // daemon would fall back to ITS OWN statusInstanceId as the coordinator
128
+ // (startMeshRefineJob), scoping the terminal event to the wrong inbox where the
129
+ // real coordinator never pulls it. Stamp the canonical status id (which is in
130
+ // the coordinator's self-identity set used to scope the remote drain) so the
131
+ // event routes back here. Preserve any caller-supplied coordinatorDaemonId.
132
+ //
133
+ // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) once the call
134
+ // has landed on the owning daemon — that daemon then executes locally even if
135
+ // the stored daemonId uses a legacy form that doesn't match its own identity.
136
+ {
137
+ const meshRecordForForward = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
138
+ const forwardNode = meshRecordForForward?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
139
+ const nodeDaemonId = typeof forwardNode?.daemonId === 'string' ? forwardNode.daemonId.trim() : undefined;
140
+ const selfDaemonId = ctx.deps.statusInstanceId;
141
+ // daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core
142
+ // is local — execute locally instead of forwarding. Equivalent → local.
143
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
144
+ if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
145
+ const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
146
+ ? args.coordinatorDaemonId.trim()
147
+ : undefined;
148
+ const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'refine_mesh_node', {
149
+ ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
150
+ coordinatorDaemonId: callerCoordinatorDaemonId || selfDaemonId,
151
+ _meshDirectDispatch: true,
152
+ });
153
+ return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
154
+ }
155
+ }
156
+
157
+ // Dry-run (plan-only) is the default and stays synchronous: it does no
158
+ // validation/merge/push and returns the plan instantly. Only execute=true
159
+ // (and not dry_run) goes through the async refine job that actually
160
+ // validates → merges → pushes → cleans up. Mirrors the
161
+ // batch_refine_mesh_nodes / fast_forward_mesh_node dry_run/execute contract.
162
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
163
+ if (isDryRun) {
164
+ // preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
165
+ const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
166
+ const mesh = meshRecord?.mesh;
167
+ const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
168
+ if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
169
+ return {
170
+ success: true,
171
+ dryRun: true,
172
+ nodeId,
173
+ workspace: node.workspace,
174
+ validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
175
+ mergeWillRun: false,
176
+ cleanupWillRun: false,
177
+ hint: 'Dry-run only — no merge/push/cleanup performed. Re-invoke with execute:true to converge this node.',
178
+ };
179
+ }
180
+ return ctx.startMeshRefineJob(meshId, nodeId, args);
181
+ },
182
+
183
+ batch_refine_mesh_nodes: async (ctx: MedFamilyContext, args: any) => {
184
+ const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
185
+ if (!meshId) return { success: false, error: 'meshId required' };
186
+ const requestedNodeIds = Array.isArray(args?.nodeIds)
187
+ ? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
188
+ : undefined;
189
+ // Dry-run (plan-only) stays synchronous: it does no validation/merge and
190
+ // returns instantly. Execute goes through the async batch job — immediate
191
+ // {async:true, status:'accepted'} + background convergence + terminal event,
192
+ // matching the single-node refine_mesh_node contract so long validation
193
+ // suites can't time out the IPC and strand the coordinator.
194
+ const isDryRun = args?.dryRun !== false && args?.execute !== true;
195
+ if (isDryRun) return ctx.batchRefineMeshNodes(meshId, requestedNodeIds, args);
196
+ return ctx.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
197
+ },
198
+ };
@@ -0,0 +1,163 @@
1
+ /**
2
+ * RF-ROUTER MED family — IDE lifecycle + provider/IDE detection commands.
3
+ *
4
+ * stop_ide, restart_ide, launch_ide, detect_provider, detect_ides. launch_ide
5
+ * spawns the IDE, connects CDP, registers extension providers and refreshes
6
+ * detection. restart_ide stops (with kill) then launches.
7
+ *
8
+ * launch_ide self-recursion: the original case bodies for restart_session
9
+ * (cli-agent family) and restart_ide re-entered `executeDaemonCommand('launch_ide')`.
10
+ * Lifting the launch_ide body into the module-level `launchIde(ctx, args)` helper
11
+ * lets those handlers invoke the launch directly (ctx.launchIde) without recursing
12
+ * back through the registry, while the launch_ide handler simply delegates to it.
13
+ */
14
+ import { DaemonCdpManager } from '../../cdp/manager.js';
15
+ import { registerExtensionProviders } from '../../cdp/setup.js';
16
+ import { launchWithCdp } from '../../launch.js';
17
+ import { loadConfig } from '../../config/config.js';
18
+ import { loadState, saveState } from '../../config/state-store.js';
19
+ import { resolveIdeLaunchWorkspace } from '../../config/workspaces.js';
20
+ import { appendRecentActivity } from '../../config/recent-activity.js';
21
+ import { detectIDEs } from '../../detection/ide-detector.js';
22
+ import { detectCLI } from '../../detection/cli-detector.js';
23
+ import { LOG } from '../../logging/logger.js';
24
+ import type { CommandRouterResult } from '../router.js';
25
+ import type { MedFamilyContext, MedFamilyHandler } from './types.js';
26
+
27
+ /**
28
+ * IDE launch + CDP connect. Lifted verbatim from the original `launch_ide` switch
29
+ * case so restart_session / restart_ide can call it directly instead of recursing
30
+ * through executeDaemonCommand. Reads the router's CDP managers and detection
31
+ * caches via ctx.deps.
32
+ */
33
+ export async function launchIde(ctx: MedFamilyContext, args: any): Promise<CommandRouterResult> {
34
+ const ideKey = args?.ideId || args?.ideType;
35
+ const resolvedWorkspace = resolveIdeLaunchWorkspace(
36
+ {
37
+ workspace: args?.workspace,
38
+ workspaceId: args?.workspaceId,
39
+ useDefaultWorkspace: args?.useDefaultWorkspace,
40
+ },
41
+ loadConfig(),
42
+ );
43
+ const launchArgs = {
44
+ ideId: ideKey,
45
+ workspace: resolvedWorkspace,
46
+ newWindow: args?.newWindow,
47
+ };
48
+ LOG.info('LaunchIDE', `target=${ideKey || 'auto'}`);
49
+ const result = await launchWithCdp(launchArgs);
50
+
51
+ if (result.success && result.port && result.ideId && !ctx.deps.cdpManagers.has(result.ideId)) {
52
+ const logFn = ctx.deps.getCdpLogFn
53
+ ? ctx.deps.getCdpLogFn(result.ideId)
54
+ : LOG.forComponent(`CDP:${result.ideId}`).asLogFn();
55
+ const provider = ctx.deps.providerLoader.getMeta(result.ideId);
56
+ const manager = new DaemonCdpManager(result.port, logFn, undefined, provider?.targetFilter);
57
+ const connected = await manager.connect();
58
+ if (connected) {
59
+ // Register active extension providers for this IDE in CDP manager
60
+ registerExtensionProviders(ctx.deps.providerLoader, manager, result.ideId);
61
+ ctx.deps.cdpManagers.set(result.ideId, manager);
62
+ LOG.info('CDP', `Connected: ${result.ideId} (port ${result.port})`);
63
+ LOG.info('CDP', `${ctx.deps.cdpManagers.size} IDE(s) connected`);
64
+
65
+ // Notify consumer (e.g. setupIdeInstance)
66
+ ctx.deps.onCdpManagerCreated?.(result.ideId, manager);
67
+ }
68
+ }
69
+ ctx.deps.onIdeConnected?.();
70
+ try {
71
+ const results = await detectIDEs(ctx.deps.providerLoader);
72
+ ctx.deps.detectedIdes.value = results;
73
+ ctx.deps.providerLoader.setIdeDetectionResults(results, true);
74
+ } catch { /* ignore detection refresh errors */ }
75
+ if (result.success && resolvedWorkspace) {
76
+ try {
77
+ const next = appendRecentActivity(loadState(), {
78
+ kind: 'ide',
79
+ providerType: result.ideId || ideKey,
80
+ providerName: result.ideId || ideKey,
81
+ workspace: resolvedWorkspace,
82
+ title: result.ideId || ideKey,
83
+ });
84
+ saveState(next);
85
+ } catch { /* ignore activity persist errors */ }
86
+ } else if (result.success && (result.ideId || ideKey)) {
87
+ try {
88
+ saveState(appendRecentActivity(loadState(), {
89
+ kind: 'ide',
90
+ providerType: result.ideId || ideKey,
91
+ providerName: result.ideId || ideKey,
92
+ title: result.ideId || ideKey,
93
+ }));
94
+ } catch { /* ignore activity persist errors */ }
95
+ }
96
+ return { ...result };
97
+ }
98
+
99
+ export const ideHandlers: Record<string, MedFamilyHandler> = {
100
+ // ─── IDE stop ───
101
+ stop_ide: async (ctx: MedFamilyContext, args: any) => {
102
+ const ideType = args?.ideType;
103
+ if (!ideType) throw new Error('ideType required');
104
+ const killProcess = args?.killProcess !== false; // default true
105
+ await ctx.stopIde(ideType, killProcess);
106
+ try {
107
+ const results = await detectIDEs(ctx.deps.providerLoader);
108
+ ctx.deps.detectedIdes.value = results;
109
+ ctx.deps.providerLoader.setIdeDetectionResults(results, true);
110
+ } catch { /* ignore detection refresh errors */ }
111
+ return { success: true, ideType, stopped: true, processKilled: killProcess };
112
+ },
113
+
114
+ // ─── IDE restart ───
115
+ restart_ide: async (ctx: MedFamilyContext, args: any) => {
116
+ const ideType = args?.ideType;
117
+ if (!ideType) throw new Error('ideType required');
118
+ await ctx.stopIde(ideType, true); // always kill process on restart
119
+ const launchResult = await ctx.launchIde({ ideType, enableCdp: true, workspace: args?.workspace });
120
+ return { success: true, ideType, restarted: true, launch: launchResult };
121
+ },
122
+
123
+ // ─── IDE launch + CDP connect ───
124
+ launch_ide: async (ctx: MedFamilyContext, args: any) => {
125
+ return launchIde(ctx, args);
126
+ },
127
+
128
+ // ─── Detect providers ───
129
+ detect_provider: async (ctx: MedFamilyContext, args: any) => {
130
+ const providerType = typeof args?.providerType === 'string' ? args.providerType.trim() : '';
131
+ if (!providerType) return { success: false, error: 'providerType is required' };
132
+ const normalizedType = ctx.deps.providerLoader.resolveAlias(providerType);
133
+ const provider = ctx.deps.providerLoader.getByAlias(providerType);
134
+ if (!provider) return { success: false, error: `Provider not found: ${providerType}` };
135
+ if (provider.category !== 'cli' && provider.category !== 'acp') {
136
+ return { success: false, error: `Provider detection is only supported for CLI/ACP providers: ${providerType}` };
137
+ }
138
+ if (!ctx.deps.providerLoader.isMachineProviderEnabled(normalizedType)) {
139
+ return { success: false, error: `Provider is disabled on this machine: ${providerType}` };
140
+ }
141
+ const detected = await detectCLI(normalizedType, ctx.deps.providerLoader, { includeVersion: false });
142
+ ctx.deps.providerLoader.setCliDetectionResults([{
143
+ id: normalizedType,
144
+ installed: !!detected,
145
+ path: detected?.path,
146
+ }], false);
147
+ ctx.deps.onStatusChange?.();
148
+ return {
149
+ success: true,
150
+ providerType: normalizedType,
151
+ detected: !!detected,
152
+ path: detected?.path || null,
153
+ };
154
+ },
155
+
156
+ // ─── Detect IDEs ───
157
+ detect_ides: async (ctx: MedFamilyContext, _args: any) => {
158
+ const results = await detectIDEs(ctx.deps.providerLoader);
159
+ ctx.deps.detectedIdes.value = results;
160
+ ctx.deps.providerLoader.setIdeDetectionResults(results, true);
161
+ return { success: true, detectedInfo: results };
162
+ },
163
+ };