@adhdev/daemon-core 1.0.18-rc.2 → 1.0.18-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.
Files changed (82) hide show
  1. package/dist/cli-adapters/cli-state-engine.d.ts +77 -0
  2. package/dist/cli-adapters/provider-cli-shared.d.ts +33 -0
  3. package/dist/cli-adapters/pty-transport.d.ts +2 -1
  4. package/dist/commands/cli-manager.d.ts +9 -0
  5. package/dist/commands/process-lifecycle.d.ts +43 -0
  6. package/dist/commands/upgrade-helper.d.ts +1 -0
  7. package/dist/commands/windows-atomic-upgrade.d.ts +17 -1
  8. package/dist/config/mesh-json-config.d.ts +62 -0
  9. package/dist/index.d.ts +4 -2
  10. package/dist/index.js +6674 -4237
  11. package/dist/index.js.map +1 -1
  12. package/dist/index.mjs +6361 -3927
  13. package/dist/index.mjs.map +1 -1
  14. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  15. package/dist/mesh/mesh-active-work.d.ts +1 -1
  16. package/dist/mesh/mesh-disk-retention.d.ts +105 -0
  17. package/dist/mesh/mesh-ledger.d.ts +28 -1
  18. package/dist/mesh/mesh-node-identity.d.ts +23 -0
  19. package/dist/mesh/mesh-refine-gates.d.ts +89 -0
  20. package/dist/mesh/mesh-remote-event-pull.d.ts +3 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +11 -0
  22. package/dist/mesh/mesh-work-queue.d.ts +24 -1
  23. package/dist/providers/auto-approve-modes.d.ts +14 -0
  24. package/dist/providers/chat-message-normalization.d.ts +22 -0
  25. package/dist/providers/cli-provider-instance-types.d.ts +4 -0
  26. package/dist/providers/cli-provider-instance.d.ts +78 -0
  27. package/dist/providers/contracts.d.ts +17 -0
  28. package/dist/providers/provider-schema.d.ts +1 -0
  29. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +14 -0
  30. package/dist/providers/types/interactive-prompt.d.ts +18 -0
  31. package/dist/repo-mesh-types.d.ts +38 -6
  32. package/dist/shared-types.d.ts +4 -2
  33. package/package.json +3 -3
  34. package/src/boot/daemon-lifecycle.ts +10 -0
  35. package/src/cli-adapters/cli-state-engine.ts +220 -3
  36. package/src/cli-adapters/provider-cli-adapter.ts +41 -11
  37. package/src/cli-adapters/provider-cli-shared.ts +48 -0
  38. package/src/cli-adapters/pty-transport.d.ts +2 -1
  39. package/src/cli-adapters/pty-transport.ts +1 -1
  40. package/src/cli-adapters/session-host-transport.ts +8 -3
  41. package/src/commands/cli-manager.ts +72 -8
  42. package/src/commands/high-family/mesh-coordinator-launch.ts +17 -2
  43. package/src/commands/high-family/mesh-events.ts +13 -2
  44. package/src/commands/med-family/mesh-crud.ts +155 -0
  45. package/src/commands/med-family/mesh-queue.ts +74 -1
  46. package/src/commands/process-lifecycle.ts +248 -0
  47. package/src/commands/router-refine.ts +71 -4
  48. package/src/commands/router.ts +8 -0
  49. package/src/commands/upgrade-helper.ts +106 -82
  50. package/src/commands/windows-atomic-upgrade.ts +281 -35
  51. package/src/config/mesh-json-config.ts +111 -0
  52. package/src/index.ts +21 -1
  53. package/src/mesh/coordinator-prompt.ts +258 -11
  54. package/src/mesh/mesh-active-work.ts +11 -1
  55. package/src/mesh/mesh-completion-synthesis.ts +12 -1
  56. package/src/mesh/mesh-disk-retention.ts +370 -0
  57. package/src/mesh/mesh-event-classify.ts +19 -0
  58. package/src/mesh/mesh-event-forwarding.ts +64 -6
  59. package/src/mesh/mesh-events-utils.ts +42 -0
  60. package/src/mesh/mesh-ledger.ts +77 -0
  61. package/src/mesh/mesh-node-identity.ts +49 -0
  62. package/src/mesh/mesh-queue-assignment.ts +210 -11
  63. package/src/mesh/mesh-reconcile-loop.ts +306 -3
  64. package/src/mesh/mesh-refine-gates.ts +300 -0
  65. package/src/mesh/mesh-remote-event-pull.ts +68 -47
  66. package/src/mesh/mesh-runtime-store.ts +21 -0
  67. package/src/mesh/mesh-work-queue.ts +85 -2
  68. package/src/providers/auto-approve-modes.ts +103 -0
  69. package/src/providers/chat-message-normalization.ts +53 -0
  70. package/src/providers/cli-provider-instance-types.ts +53 -0
  71. package/src/providers/cli-provider-instance.ts +497 -15
  72. package/src/providers/contracts.ts +25 -1
  73. package/src/providers/provider-schema.ts +83 -0
  74. package/src/providers/sdk/v1/builders/cli/detect-status.ts +23 -0
  75. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +20 -0
  76. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +44 -0
  77. package/src/providers/types/interactive-prompt.ts +77 -0
  78. package/src/repo-mesh-types.ts +112 -12
  79. package/src/session-host/managed-host.ts +34 -0
  80. package/src/shared-types.ts +9 -1
  81. package/src/status/reporter.ts +1 -0
  82. package/src/status/snapshot.ts +2 -0
@@ -0,0 +1,248 @@
1
+ import { execFileSync, type ExecFileSyncOptions } from 'child_process';
2
+ import * as path from 'path';
3
+
4
+ export interface ProcessLifecycleOptions {
5
+ platform?: NodeJS.Platform;
6
+ execFileSync?: typeof import('child_process').execFileSync;
7
+ }
8
+
9
+ export interface OwnedProcessInfo {
10
+ pid: number;
11
+ commandLine: string | null;
12
+ }
13
+
14
+ function defaultExecFileSync(): typeof import('child_process').execFileSync {
15
+ return execFileSync;
16
+ }
17
+
18
+ function getWindowsProcessCommandLine(
19
+ pid: number,
20
+ exec: typeof import('child_process').execFileSync,
21
+ ): string | null {
22
+ const pidFilter = `ProcessId=${pid}`;
23
+
24
+ try {
25
+ const psOut = exec('powershell.exe', [
26
+ '-NoProfile',
27
+ '-NonInteractive',
28
+ '-ExecutionPolicy', 'Bypass',
29
+ '-Command',
30
+ `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
31
+ ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
32
+ const text = String(psOut).trim();
33
+ if (text) return text;
34
+ } catch {
35
+ // fall through to wmic fallback
36
+ }
37
+
38
+ try {
39
+ const wmicOut = exec('wmic', [
40
+ 'process', 'where', pidFilter, 'get', 'CommandLine',
41
+ ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
42
+ const text = String(wmicOut).trim();
43
+ if (text) return text;
44
+ } catch {
45
+ // noop
46
+ }
47
+
48
+ return null;
49
+ }
50
+
51
+ export function getProcessCommandLine(
52
+ pid: number,
53
+ options: ProcessLifecycleOptions = {},
54
+ ): string | null {
55
+ if (!Number.isFinite(pid) || pid <= 0) return null;
56
+ const exec = options.execFileSync ?? defaultExecFileSync();
57
+ const platform = options.platform ?? process.platform;
58
+
59
+ if (platform === 'win32') {
60
+ return getWindowsProcessCommandLine(pid, exec);
61
+ }
62
+
63
+ try {
64
+ const text = String(exec('ps', ['-o', 'command=', '-p', String(pid)], {
65
+ encoding: 'utf8',
66
+ timeout: 3000,
67
+ stdio: ['ignore', 'pipe', 'ignore'],
68
+ })).trim();
69
+ return text || null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Extract the script argument (the token after the node executable) from a
77
+ * command line. Handles both quoted and unquoted executables/scripts.
78
+ */
79
+ export function parseNodeScriptPath(commandLine: string | null): string | null {
80
+ if (!commandLine) return null;
81
+ let rest = commandLine.trim();
82
+
83
+ // Skip the leading executable token.
84
+ if (rest.startsWith('"')) {
85
+ const end = rest.indexOf('"', 1);
86
+ if (end === -1) return null;
87
+ rest = rest.slice(end + 1).trim();
88
+ } else {
89
+ const idx = rest.search(/\s/);
90
+ if (idx === -1) return null;
91
+ rest = rest.slice(idx + 1).trim();
92
+ }
93
+ if (!rest) return null;
94
+
95
+ if (rest.startsWith('"')) {
96
+ const end = rest.indexOf('"', 1);
97
+ return end === -1 ? rest.slice(1) : rest.slice(1, end);
98
+ }
99
+ const idx = rest.search(/\s/);
100
+ return idx === -1 ? rest : rest.slice(0, idx);
101
+ }
102
+
103
+ function normalizeWindowsPath(value: string): string {
104
+ return value.toLowerCase().replace(/\//g, '\\').replace(/\\+$/, '');
105
+ }
106
+
107
+ function isCommandLineUnderPrefix(commandLine: string | null, prefix: string): boolean {
108
+ if (!commandLine) return false;
109
+ const needle = normalizeWindowsPath(prefix);
110
+ // Command-line paths may quote Windows separators as either single or
111
+ // doubled backslashes; collapse doubles before matching.
112
+ const haystack = commandLine.split('\\\\').join('\\').toLowerCase();
113
+ return haystack.includes(needle);
114
+ }
115
+
116
+ export function killProcess(pid: number, options: ProcessLifecycleOptions = {}): boolean {
117
+ if (!Number.isFinite(pid) || pid <= 0) return false;
118
+ const exec = options.execFileSync ?? defaultExecFileSync();
119
+ const platform = options.platform ?? process.platform;
120
+
121
+ try {
122
+ if (platform === 'win32') {
123
+ exec('taskkill', ['/PID', String(pid), '/T', '/F'], {
124
+ stdio: 'ignore',
125
+ windowsHide: true,
126
+ });
127
+ } else {
128
+ process.kill(pid, 'SIGTERM');
129
+ }
130
+ return true;
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ export async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
137
+ const start = Date.now();
138
+ while (Date.now() - start < timeoutMs) {
139
+ try {
140
+ process.kill(pid, 0);
141
+ await new Promise((resolve) => setTimeout(resolve, 250));
142
+ } catch {
143
+ return true;
144
+ }
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * List Node processes whose command line places them under any of the supplied
151
+ * prefixes. Windows-only — the versioned-prefix lifecycle this supports does not
152
+ * exist on POSIX, so the helper is a no-op there.
153
+ */
154
+ export function listOwnedNodeProcesses(options: {
155
+ prefixes: readonly string[];
156
+ excludePids?: readonly number[];
157
+ markers?: readonly string[];
158
+ } & ProcessLifecycleOptions): OwnedProcessInfo[] {
159
+ const platform = options.platform ?? process.platform;
160
+ if (platform !== 'win32') return [];
161
+
162
+ const exec = options.execFileSync ?? defaultExecFileSync();
163
+ const prefixes = options.prefixes.map((p) => normalizeWindowsPath(p));
164
+ const exclude = new Set((options.excludePids ?? []).filter((n) => Number.isFinite(n) && n > 0));
165
+ // Command-line paths use Windows separators; normalize marker separators the
166
+ // same way so a marker like "dist/cli/index.js" matches "dist\cli\index.js".
167
+ const markers = (options.markers ?? []).map((m) => m.toLowerCase().replace(/\//g, '\\'));
168
+
169
+ let pids: number[] = [];
170
+ try {
171
+ const out = String(exec('powershell.exe', [
172
+ '-NoProfile',
173
+ '-NonInteractive',
174
+ '-ExecutionPolicy', 'Bypass',
175
+ '-Command',
176
+ 'Get-Process node -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id | ConvertTo-Json -Compress',
177
+ ], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })).trim();
178
+ if (out) {
179
+ const parsed = JSON.parse(out);
180
+ pids = Array.isArray(parsed) ? parsed : [parsed];
181
+ }
182
+ } catch {
183
+ return [];
184
+ }
185
+
186
+ const results: OwnedProcessInfo[] = [];
187
+ for (const pid of pids) {
188
+ if (!Number.isFinite(pid) || pid <= 0 || exclude.has(pid)) continue;
189
+ const commandLine = getProcessCommandLine(pid, { platform, execFileSync: exec });
190
+ if (!commandLine) continue;
191
+ const lower = commandLine.toLowerCase();
192
+ const underPrefix = prefixes.some((prefix) => lower.includes(prefix));
193
+ if (!underPrefix) continue;
194
+ if (markers.length > 0 && !markers.some((marker) => lower.includes(marker))) continue;
195
+ results.push({ pid, commandLine });
196
+ }
197
+ return results;
198
+ }
199
+
200
+ export async function stopOwnedProcesses(options: {
201
+ processes: readonly OwnedProcessInfo[];
202
+ waitMs?: number;
203
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
204
+ const waitMs = options.waitMs ?? 15_000;
205
+ const killed = new Set<number>();
206
+
207
+ for (const p of options.processes) {
208
+ if (killProcess(p.pid, options)) killed.add(p.pid);
209
+ }
210
+
211
+ const survivors: OwnedProcessInfo[] = [];
212
+ for (const p of options.processes) {
213
+ if (!killed.has(p.pid)) {
214
+ survivors.push(p);
215
+ continue;
216
+ }
217
+ const exited = await waitForPidExit(p.pid, waitMs);
218
+ if (!exited) survivors.push(p);
219
+ }
220
+
221
+ // A process counts as stopped only when it left the process table; a failed
222
+ // kill or a post-kill timeout both land it in `survivors`.
223
+ return { stopped: options.processes.length - survivors.length, survivors };
224
+ }
225
+
226
+ export async function stopOwnedProcessesForPrefixes(options: {
227
+ prefixes: readonly string[];
228
+ excludePids?: readonly number[];
229
+ markers?: readonly string[];
230
+ waitMs?: number;
231
+ log?: (message: string) => void;
232
+ } & ProcessLifecycleOptions): Promise<{ stopped: number; survivors: OwnedProcessInfo[] }> {
233
+ const processes = listOwnedNodeProcesses(options);
234
+ if (processes.length === 0) return { stopped: 0, survivors: [] };
235
+
236
+ options.log?.(`Stopping ${processes.length} owned process(es) under prefixes: ${options.prefixes.join(', ')}`);
237
+ const result = await stopOwnedProcesses({
238
+ processes,
239
+ waitMs: options.waitMs,
240
+ platform: options.platform,
241
+ execFileSync: options.execFileSync,
242
+ });
243
+
244
+ if (result.survivors.length > 0) {
245
+ options.log?.(`Could not stop ${result.survivors.length} owned process(es): ${result.survivors.map((s) => s.pid).join(', ')}`);
246
+ }
247
+ return result;
248
+ }
@@ -36,8 +36,10 @@ import {
36
36
  RefineExecFileAsync,
37
37
  RefineStageOutcome,
38
38
  classifyPatchEquivalenceFailure,
39
+ convergeDivergedSubmoduleGitlinks,
39
40
  recordMeshRefineStage,
40
41
  resolveRefineryAutoPublishSubmoduleMainCommits,
42
+ rootRebaseResolvingGitlinks,
41
43
  runMeshRefineEffectiveDiffGate,
42
44
  runMeshRefinePatchEquivalenceGate,
43
45
  runMeshRefineSubmoduleReachabilityGate,
@@ -539,6 +541,9 @@ async function computeBranchBaseDivergence(
539
541
  export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
540
542
  const { repoRoot, baseHead, node, branch, baseBranch, refineStages, execFileAsync } = ctx;
541
543
  let branchHead = ctx.branchHead;
544
+ // Converged submodule gitlink resolutions (path → rebased commit) from STEP 1;
545
+ // consumed by the gitlink-aware root rebase (STEP 2) to resolve gitlink conflicts.
546
+ let gitlinkResolutions: Array<{ path: string; rebasedCommit: string }> = [];
542
547
  const syncStarted = Date.now();
543
548
  const divergence = await computeBranchBaseDivergence(execFileAsync, node.workspace, baseHead, branchHead);
544
549
 
@@ -571,25 +576,87 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
571
576
  const preRebasePe = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
572
577
  const alreadyMerged = !preRebasePe.actualPatchId && !!preRebasePe.expectedPatchId;
573
578
  const submoduleConflict = preRebasePe.actionableHint?.kind === 'submodule_conflict';
574
- if (alreadyMerged || submoduleConflict) {
579
+ if (alreadyMerged) {
575
580
  recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
576
581
  ahead: divergence.ahead,
577
582
  behind: divergence.behind,
578
583
  rebased: false,
579
- reason: alreadyMerged ? 'already_merged_via_other_path_skip_rebase' : 'submodule_conflict_defer_to_patch_equivalence',
584
+ reason: 'already_merged_via_other_path_skip_rebase',
580
585
  });
581
586
  return { kind: 'continue', ctx };
582
587
  }
588
+ if (submoduleConflict) {
589
+ // DS3: a "submodule conflict" here means base and branch advanced the
590
+ // SAME submodule to DIVERGED sibling commits (neither an ancestor of the
591
+ // other), so the gitlink stays in the diff and patch-equivalence fails.
592
+ // Attempt to auto-converge it (STEP 1): rebase the branch-side submodule
593
+ // commit onto the base-side commit INSIDE the worktree submodule, so the
594
+ // base-side commit becomes a strict ancestor of the rebased tip. The root
595
+ // rebase below (STEP 2) then resolves the gitlink conflict to that rebased
596
+ // commit. Together this automates the documented manual strict-ff bypass
597
+ // and keeps the landed oss history linear. On any real submodule content
598
+ // conflict it backs out cleanly → we FALL BACK to the historical
599
+ // defer→patch_equivalence path below.
600
+ const converge = convergeDivergedSubmoduleGitlinks(node.workspace, repoRoot, baseHead, branchHead);
601
+ if (converge.converged) {
602
+ gitlinkResolutions = converge.resolutions;
603
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'passed', syncStarted, {
604
+ reason: 'submodule_diverged_auto_rebased',
605
+ gitlinks: converge.gitlinks,
606
+ });
607
+ LOG.info('Mesh', `[Refinery] Auto-converged diverged submodule gitlink(s) onto base for node ${node.id}: `
608
+ + converge.resolutions.map(r => `${r.path}→${r.rebasedCommit.slice(0, 12)}`).join(', '));
609
+ // Fall through (do NOT return) → the gitlink-aware root rebase below runs.
610
+ } else {
611
+ // Fail-safe: convergence declined (conflict / unreachable / not a real
612
+ // divergence) → preserve the historical defer→blocked_review behavior.
613
+ recordMeshRefineStage(refineStages, 'submodule_gitlink_converge', 'skipped', syncStarted, {
614
+ reason: 'submodule_conflict_defer_to_patch_equivalence',
615
+ convergeReason: converge.reason,
616
+ gitlinks: converge.gitlinks,
617
+ });
618
+ recordMeshRefineStage(refineStages, 'sync_base', 'passed', syncStarted, {
619
+ ahead: divergence.ahead,
620
+ behind: divergence.behind,
621
+ rebased: false,
622
+ reason: 'submodule_conflict_defer_to_patch_equivalence',
623
+ });
624
+ return { kind: 'continue', ctx };
625
+ }
626
+ }
583
627
  } catch { /* fail-open: on gate error, fall through to the rebase */ }
584
628
 
585
629
  // behind>0: strictly-behind OR diverged. Rebase the branch onto the pinned
586
630
  // baseHead. A conflict aborts and terminates blocked_review (retryable=false —
587
631
  // a real content conflict needs human resolution, not a base-movement retry).
632
+ //
633
+ // When STEP 1 converged a diverged submodule gitlink, use the gitlink-aware
634
+ // root rebase (STEP 2): git's recursive merge refuses to auto-merge the still-
635
+ // diverged intermediate gitlink, so we drive the rebase and resolve each
636
+ // submodule-gitlink conflict to the converged commit. A non-gitlink conflict
637
+ // aborts and falls through to the same blocked_review handling as a plain
638
+ // rebase conflict below (via the thrown gitlinkRebaseError).
588
639
  const rebaseStarted = Date.now();
589
640
  try {
590
- execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
641
+ if (gitlinkResolutions.length > 0) {
642
+ const gitlinkRebase = rootRebaseResolvingGitlinks(node.workspace, baseHead, gitlinkResolutions);
643
+ if (!gitlinkRebase.ok) {
644
+ // Surface as a rebase failure so the shared blocked_review handling
645
+ // (submodule-hint recovery included) runs — nothing was left mid-rebase
646
+ // (rootRebaseResolvingGitlinks aborts on failure).
647
+ const err: any = new Error(`gitlink-aware rebase aborted: ${gitlinkRebase.reason || 'unknown'}`);
648
+ err.gitlinkRebaseReason = gitlinkRebase.reason;
649
+ err.gitlinkRebaseConflicts = gitlinkRebase.conflictPaths;
650
+ err.alreadyAborted = true;
651
+ throw err;
652
+ }
653
+ } else {
654
+ execFileSync('git', ['rebase', baseHead], { cwd: node.workspace, stdio: ['ignore', 'pipe', 'pipe'] });
655
+ }
591
656
  } catch (rebaseErr: any) {
592
- try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
657
+ if (!rebaseErr?.alreadyAborted) {
658
+ try { execFileSync('git', ['rebase', '--abort'], { cwd: node.workspace, stdio: 'ignore' }); } catch { /* ignore */ }
659
+ }
593
660
  // A rebase conflict on a submodule/gitlink divergence is a SPECIAL case the
594
661
  // patch-equivalence gate describes with a rich actionable hint (which
595
662
  // submodule, base vs branch commit, how to resolve). Run that gate against
@@ -227,6 +227,14 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
227
227
  // MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
228
228
  // doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
229
229
  'send_keys',
230
+ // interactive_prompt_response (mesh_answer_question, mission f1d25e11): the coordinator
231
+ // answers a REMOTE worker's AskUserQuestion (waiting_choice). The answer must reach the
232
+ // OWNING worker session's live instance — its activeInteractivePrompt (the authoritative
233
+ // prompt the labels/indexes resolve against) and its adapter.setInteractivePromptResponse
234
+ // live only there. Without forwarding, the coordinator's local high-family handler returns
235
+ // 'No running instance for session …' and the question is never answered — the exact
236
+ // remote-worker forwarding gap of mission 6938892f, now closed for questions too.
237
+ 'interactive_prompt_response',
230
238
  ]);
231
239
 
232
240
  function normalizeCommandSource(source: string): CommandLogEntry['source'] {
@@ -4,11 +4,18 @@ import * as fs from 'fs';
4
4
  import * as os from 'os';
5
5
  import * as path from 'path';
6
6
  import {
7
+ ADHDEV_OWNED_MARKERS,
7
8
  createDefaultWindowsAtomicHooks,
8
9
  findPortableNode22,
9
10
  performWindowsAtomicUpgrade,
10
11
  resolveWindowsInstallerLayout,
11
12
  } from './windows-atomic-upgrade.js';
13
+ import {
14
+ getProcessCommandLine,
15
+ killProcess,
16
+ stopOwnedProcessesForPrefixes,
17
+ waitForPidExit,
18
+ } from './process-lifecycle.js';
12
19
 
13
20
  const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
14
21
 
@@ -137,19 +144,68 @@ function resolveInstallPrefixFromPackageRoot(packageRoot: string, packageName: s
137
144
  return maybeLibDir;
138
145
  }
139
146
 
147
+ // True when `prefix` is the bin dir of a portable Node 22 the installer manages
148
+ // under ~/.adhdev/tools/node22/<node-vX>/. A `npm i -g adhdev` run while that
149
+ // portable node is the active `node` installs adhdev into node's own default
150
+ // global prefix (= that dir) — a "legacy node22-prefix" install that lives at
151
+ // the FRONT of PATH (Enable-NodePath prepends node22) and shadows the canonical
152
+ // dispatcher shims in ~/.adhdev/npm-global. Because self-upgrade reuses the
153
+ // running prefix, that install then re-installs into the same node22 dir forever
154
+ // and never converts to the dispatcher. Detecting it lets us force convergence.
155
+ function isPortableNode22Prefix(prefix: string | null, homeDir: string): boolean {
156
+ if (!prefix) return false;
157
+ const portableRoot = path.join(homeDir, '.adhdev', 'tools', 'node22');
158
+ const normalizedPrefix = path.resolve(prefix).replace(/[\\/]+$/, '').toLowerCase();
159
+ const normalizedRoot = path.resolve(portableRoot).replace(/[\\/]+$/, '').toLowerCase();
160
+ return normalizedPrefix === normalizedRoot || normalizedPrefix.startsWith(`${normalizedRoot}${path.sep.toLowerCase()}`);
161
+ }
162
+
163
+ // Redirect a legacy node22-prefix install to the canonical dispatcher install
164
+ // root so resolveWindowsInstallerLayout accepts it and the atomic-upgrade
165
+ // publishes the ~/.adhdev/npm-global pointer + shims. Prefer the version the
166
+ // dispatcher pointer already names (so we sit on the real active dispatcher
167
+ // prefix); otherwise synthesize a stable migration sentinel under npm-installs.
168
+ // resolveWindowsInstallerLayout only requires a `npm-installs/version-*` path —
169
+ // performWindowsAtomicUpgrade stages a fresh version- prefix of its own and uses
170
+ // this only as the "old prefix" to stop/clean, so a non-existent path is a no-op.
171
+ function canonicalDispatcherInstallPrefix(homeDir: string): string {
172
+ const installRoot = path.join(homeDir, '.adhdev', 'npm-installs');
173
+ const pointerPath = path.join(homeDir, '.adhdev', 'npm-global', '.adhdev-current');
174
+ try {
175
+ const activeVersion = fs.readFileSync(pointerPath, 'utf8').trim();
176
+ if (activeVersion.startsWith('version-')) return path.join(installRoot, activeVersion);
177
+ } catch {
178
+ // No dispatcher pointer yet (first migration off the legacy layout).
179
+ }
180
+ return path.join(installRoot, 'version-legacy-migrate');
181
+ }
182
+
140
183
  export function resolveCurrentGlobalInstallSurface(options: {
141
184
  packageName: string;
142
185
  currentCliPath?: string;
143
186
  nodeExecutable?: string;
144
187
  platform?: NodeJS.Platform;
188
+ homeDir?: string;
145
189
  }): CurrentGlobalInstallSurface {
146
190
  const packageRoot = findCurrentPackageRoot(options.currentCliPath || process.argv[1], options.packageName);
147
191
  const npmInvocation = resolveSiblingNpmInvocation(options.nodeExecutable || process.execPath, options.platform);
192
+ const platform = options.platform || process.platform;
193
+ const homeDir = options.homeDir || os.homedir();
194
+ let installPrefix = packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null;
195
+ // FIX C: on Windows, never let a self-upgrade perpetuate the legacy
196
+ // node22-prefix install. If the running adhdev lives under ~/.adhdev/tools/
197
+ // node22, force the install onto the canonical dispatcher prefix so the update
198
+ // converges to the ~/.adhdev/npm-global pointer + shims. Scoped to win32 AND a
199
+ // tools/node22 prefix so npm-linked dev / standalone / real dispatcher installs
200
+ // are untouched.
201
+ if (platform === 'win32' && isPortableNode22Prefix(installPrefix, homeDir)) {
202
+ installPrefix = canonicalDispatcherInstallPrefix(homeDir);
203
+ }
148
204
  return {
149
205
  npmExecutable: npmInvocation.executable,
150
206
  npmArgsPrefix: npmInvocation.argsPrefix,
151
207
  packageRoot,
152
- installPrefix: packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null,
208
+ installPrefix,
153
209
  execOptions: npmInvocation.execOptions,
154
210
  };
155
211
  }
@@ -235,77 +291,11 @@ export function execNpmCommandSync(
235
291
  );
236
292
  }
237
293
 
238
- function killPid(pid: number): boolean {
239
- try {
240
- if (process.platform === 'win32') {
241
- execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
242
- } else {
243
- process.kill(pid, 'SIGTERM');
244
- }
245
- return true;
246
- } catch {
247
- return false;
248
- }
249
- }
250
-
251
- function getWindowsProcessCommandLine(pid: number): string | null {
252
- const pidFilter = `ProcessId=${pid}`;
253
- try {
254
- const psOut = execFileSync('powershell.exe', [
255
- '-NoProfile',
256
- '-NonInteractive',
257
- '-ExecutionPolicy', 'Bypass',
258
- '-Command',
259
- `(Get-CimInstance Win32_Process -Filter "${pidFilter}").CommandLine`,
260
- ], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
261
- if (psOut) return psOut;
262
- } catch {
263
- // fall through to wmic fallback
264
- }
265
-
266
- try {
267
- const wmicOut = execFileSync('wmic', [
268
- 'process', 'where', pidFilter, 'get', 'CommandLine',
269
- ], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).trim();
270
- if (wmicOut) return wmicOut;
271
- } catch {
272
- // noop
273
- }
274
- return null;
275
- }
276
-
277
- function getProcessCommandLine(pid: number): string | null {
278
- if (!Number.isFinite(pid) || pid <= 0) return null;
279
- if (process.platform === 'win32') return getWindowsProcessCommandLine(pid);
280
- try {
281
- const text = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], {
282
- encoding: 'utf8',
283
- timeout: 3000,
284
- stdio: ['ignore', 'pipe', 'ignore'],
285
- }).trim();
286
- return text || null;
287
- } catch {
288
- return null;
289
- }
290
- }
291
-
292
294
  function isManagedSessionHostPid(pid: number): boolean {
293
295
  const commandLine = getProcessCommandLine(pid);
294
296
  return !!commandLine && /session-host-daemon/i.test(commandLine);
295
297
  }
296
298
 
297
- async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
298
- const start = Date.now();
299
- while (Date.now() - start < timeoutMs) {
300
- try {
301
- process.kill(pid, 0);
302
- await new Promise((resolve) => setTimeout(resolve, 250));
303
- } catch {
304
- return;
305
- }
306
- }
307
- }
308
-
309
299
  export async function stopSessionHostProcesses(appName: string): Promise<void> {
310
300
  const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
311
301
  let killedPid: number | null = null;
@@ -313,7 +303,7 @@ export async function stopSessionHostProcesses(appName: string): Promise<void> {
313
303
  if (fs.existsSync(pidFile)) {
314
304
  const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
315
305
  if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
316
- if (killPid(pid)) killedPid = pid;
306
+ if (killProcess(pid)) killedPid = pid;
317
307
  }
318
308
  }
319
309
  } catch {
@@ -430,7 +420,7 @@ export async function stopForeignNativeAddonHolders(
430
420
  appendUpgradeLog(
431
421
  `Foreign native-addon holder found: pid ${holder.pid}${holder.commandLine ? ` — ${holder.commandLine}` : ''}`,
432
422
  );
433
- const killed = killPid(holder.pid);
423
+ const killed = killProcess(holder.pid);
434
424
  if (killed) {
435
425
  await waitForPidExit(holder.pid, 15000);
436
426
  appendUpgradeLog(`Terminated foreign native-addon holder pid ${holder.pid}`);
@@ -600,21 +590,55 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
600
590
  throw new Error(`portable Node.js 22 npm CLI is missing: ${npmCliPath}`);
601
591
  }
602
592
  appendUpgradeLog(`Installer-managed pointer layout detected; active prefix will remain untouched: ${windowsInstallerLayout.activePrefix}`);
603
- await performWindowsAtomicUpgrade({
604
- layout: windowsInstallerLayout,
605
- packageName: payload.packageName,
606
- targetVersion: payload.targetVersion,
607
- portableNode,
608
- hooks: createDefaultWindowsAtomicHooks({
593
+
594
+ // Terminate any ADHDev-owned process still executing from the current active
595
+ // prefix or the legacy stable shim tree before activation. The parent daemon
596
+ // and this helper itself are excluded: the parent is already exiting, and the
597
+ // helper must survive to complete the upgrade.
598
+ const upgradePids = [process.pid, payload.parentPid].filter((n): n is number => Number.isFinite(n) && n > 0);
599
+ const preStop = await stopOwnedProcessesForPrefixes({
600
+ prefixes: [windowsInstallerLayout.activePrefix, windowsInstallerLayout.stablePrefix],
601
+ excludePids: upgradePids,
602
+ markers: Array.from(ADHDEV_OWNED_MARKERS),
603
+ waitMs: 15_000,
604
+ log: appendUpgradeLog,
605
+ });
606
+ if (preStop.survivors.length > 0) {
607
+ throw new Error(
608
+ `Cannot upgrade: owned processes still running under current prefix: ${preStop.survivors.map((s) => s.pid).join(', ')}`
609
+ );
610
+ }
611
+
612
+ try {
613
+ await performWindowsAtomicUpgrade({
614
+ layout: windowsInstallerLayout,
609
615
  packageName: payload.packageName,
610
616
  targetVersion: payload.targetVersion,
611
- npmCliPath,
612
- restartArgv,
613
- cwd: payload.cwd || process.cwd(),
614
- env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== UPGRADE_HELPER_ENV)),
615
- log: appendUpgradeLog,
616
- }),
617
- });
617
+ portableNode,
618
+ excludePids: upgradePids,
619
+ hooks: createDefaultWindowsAtomicHooks({
620
+ packageName: payload.packageName,
621
+ targetVersion: payload.targetVersion,
622
+ npmCliPath,
623
+ restartArgv,
624
+ cwd: payload.cwd || process.cwd(),
625
+ env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== UPGRADE_HELPER_ENV)),
626
+ log: appendUpgradeLog,
627
+ }),
628
+ });
629
+ } catch (error: any) {
630
+ // performWindowsAtomicUpgrade already rolled the pointer/shims back to the
631
+ // prior version before rethrowing. Without a durable notice that rollback
632
+ // was silent — the daemon simply kept running the old version (the rc.6
633
+ // stuck-upgrade defect). Leave an actionable last-error file naming the
634
+ // target that failed its health/version gate.
635
+ emitUpgradeFailureNotice([
636
+ `adhdev ${payload.packageName}@${payload.targetVersion} upgrade failed and was rolled back: ${error?.message || String(error)}`,
637
+ `Previous version preserved (active prefix: ${windowsInstallerLayout.activePrefix}).`,
638
+ 'See daemon-upgrade.log for the full install/health trace. The next daemon start will retry.',
639
+ ]);
640
+ throw error;
641
+ }
618
642
  try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
619
643
  appendUpgradeLog('Installer-managed Windows atomic upgrade completed');
620
644
  return;