@adhdev/daemon-core 0.9.82-rc.357 → 0.9.82-rc.359

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.
@@ -22,3 +22,24 @@ export declare function extractButtonsFromRule(rule: ExtractButtons, hay: string
22
22
  key: string;
23
23
  current: boolean;
24
24
  }[];
25
+ /**
26
+ * Reduce a top→bottom-ordered list of parsed numbered entries to only the
27
+ * bottom-most contiguous block — the run whose indices descend by exactly 1
28
+ * scanning upward from the last entry.
29
+ *
30
+ * Picker and approval choices always render as the LAST contiguous numbered
31
+ * block at the bottom of the modal section. The conversation history above can
32
+ * carry its own stray "1./2./3." numbered lists (and blockquote `>` lines), and
33
+ * because section anchoring can pull body lines into the modal section, a naive
34
+ * top-down scan would bind the low option indices to those body lines and drop
35
+ * the real choices (the dashboard would show, and an arrow-key picker would
36
+ * commit, the wrong row). Selecting the bottom block makes the on-screen picker
37
+ * win regardless of body content. Since pickers number their options 1..N
38
+ * contiguously and bodies use indices ≥ 1, the upward chain always breaks the
39
+ * moment it would need a "0." above the picker's "1." — so the picker block is
40
+ * isolated cleanly. Entries are assumed already in screen order; the returned
41
+ * slice keeps that order.
42
+ */
43
+ export declare function lastContiguousNumberedBlock<T extends {
44
+ index: number;
45
+ }>(entries: T[]): T[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.357",
3
+ "version": "0.9.82-rc.359",
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",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.357",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.359",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -0,0 +1,198 @@
1
+ # Windows 자동 업그레이드 실패 — 원인 분석 및 패치 명세
2
+
3
+ > 대상 파일: `oss/packages/daemon-core/src/commands/upgrade-helper.ts`
4
+ > 관련 가드: `packages/daemon-cloud/package.json`, `oss/packages/daemon-standalone/package.json` 의 `preinstall`
5
+ > 작성 근거: 2026-06-23 실 사용자(Windows 11, nvm-windows) 환경에서 `adhdev@0.9.82-rc.357 → rc.358` 자동 업그레이드가 반복 실패한 실 사례.
6
+
7
+ ## TL;DR (English)
8
+
9
+ The Windows self-upgrade fails when a process **other than** the parent CLI or the
10
+ known `session-host-daemon` keeps node-pty's `conpty.node` memory-mapped. The
11
+ helper only knows how to stop the single pid in `~/.adhdev/<app>-session-host.pid`,
12
+ so any *foreign* holder (here: three orphaned `pty_*probe*.cjs` scripts left in
13
+ `%TEMP%`) survives all 3 retries and the install dies with `EBUSY`. Two additional
14
+ weaknesses compound it: retry budget is far too small for a never-exiting holder,
15
+ and on failure the user gets no actionable message (only a log file). A separate,
16
+ independently-confirmed failure mode is the Node-24 `preinstall` guard firing when
17
+ the lifecycle-script `node` resolves to an unsupported version on a multi-node
18
+ `PATH`.
19
+
20
+ ---
21
+
22
+ ## 1. 배경 / Context
23
+
24
+ adhdev 데몬은 새 버전을 감지하면 detached 헬퍼 프로세스를 띄워
25
+ (`spawnDetachedDaemonUpgradeHelper`) `npm install -g adhdev@<target> --prefix <pinned>`
26
+ 를 실행한다 (`runDaemonUpgradeHelper`). Windows에서는 네이티브 애드온
27
+ `node-pty/prebuilds/win32-x64/conpty.node` 가 **메모리 매핑된 채 프로세스가
28
+ 완전히 종료될 때까지 배타적 잠금**된다. npm은 기존 설치본을 스테이징 디렉터리
29
+ (`node_modules/.adhdev-<hash>`)로 **복사**한 뒤 새 버전으로 교체하므로, 잠긴
30
+ `conpty.node`를 복사하려다 `EBUSY`로 실패한다.
31
+
32
+ 소스에는 이미 이 문제를 겨냥한 방어가 들어 있다:
33
+ - `stopSessionHostProcesses()` — `~/.adhdev/<app>-session-host.pid`의 pid를
34
+ 죽이고 종료를 기다림(`waitForPidExit`).
35
+ - `buildInstallEnvWithNodeOnPath()` — lifecycle 스크립트가 올바른 node를 쓰도록
36
+ `PATH` 앞에 현재 node 디렉터리를 prepend.
37
+ - 설치 재시도 루프 (Windows에서 `maxInstallAttempts = 3`, 백오프 `attempt*1500ms`).
38
+
39
+ **그런데 이번 실패는 이 방어들이 전부 적용된 상태에서도 발생한다.**
40
+
41
+ ## 2. 실제 실패 타임라인 (증거)
42
+
43
+ `~/.adhdev/daemon-upgrade.log`:
44
+
45
+ ```
46
+ [01:52:18] Upgrade helper started for adhdev@0.9.82-rc.358
47
+ [01:52:18] Using npm executable: C:\nvm4w\nodejs\node.exe
48
+ [01:52:18] Pinned install prefix: C:\Users\kjs0116\AppData\Local\nvm\v22.14.0
49
+ [01:52:18] Waiting for parent pid 38744 to exit
50
+ [01:52:21] Skipped locked stale entry (EPERM): ...\.adhdev-dTz6t6GZ — ...conpty.node
51
+ [01:52:35] Install attempt 1 hit a file lock (lock); cleaning staging and retrying after backoff
52
+ [01:52:57] Install attempt 2 hit a file lock (lock); ...
53
+ [01:53:16] Upgrade helper failed: EBUSY ... copyfile '...\adhdev\node_modules\node-pty\prebuilds\win32-x64\conpty.node'
54
+ -> '...\.adhdev-dTz6t6GZ\...\conpty.node'
55
+ ```
56
+ (01:59 에 동일 패턴으로 한 번 더 실패.)
57
+
58
+ 수동 진단으로 밝혀낸 **실제 잠금 보유자** (`Get-Process node | %{ $_.Modules | ? ModuleName -match conpty }`):
59
+
60
+ | PID | CommandLine | 시작 |
61
+ |-----|-------------|------|
62
+ | 56396 | `node %TEMP%\pty_cr_probe.cjs` | 2026-06-21 |
63
+ | 37304 | `node %TEMP%\pty_probe_parent.cjs` | 2026-06-21 |
64
+ | 34316 | `node %TEMP%\pty_probe2_parent.cjs` | 2026-06-21 |
65
+
66
+ 세 프로세스 모두
67
+ `...\AppData\Local\nvm\v22.14.0\node_modules\adhdev\node_modules\node-pty\prebuilds\win32-x64\conpty.node`
68
+ 를 로드한 채 **이틀째 살아 있었다.** 세 개를 종료한 직후 잠금이 풀렸고
69
+ (`LOCK CLEARED`), Node 22로 설치가 정상 완료됐다.
70
+
71
+ > 참고: `pty_*probe*.cjs` 는 현재 adhdev 소스 트리에 존재하지 않는다(grep 결과 0건).
72
+ > 즉 개발 중 임시로 `%TEMP%`에 떨군 PTY/ConPTY 진단 스크립트가 고아로 남은 것이다.
73
+ > 핵심은 "출처가 무엇이든, **세션 호스트가 아닌 임의의 프로세스가 conpty.node를
74
+ > 쥘 수 있다**"는 점이며, 현재 헬퍼는 이를 처리하지 못한다.
75
+
76
+ ## 3. 근본 원인
77
+
78
+ ### RC1 — (핵심) 세션 호스트가 아닌 임의의 `conpty.node` 보유자를 못 다룸
79
+ `stopSessionHostProcesses()` 는 **딱 하나의 pid**(`<app>-session-host.pid`)만,
80
+ 그것도 커맨드라인이 `/session-host-daemon/i` 에 매칭될 때만 죽인다
81
+ (`isManagedSessionHostPid`, upgrade-helper.ts:278-281, 295-313). 위 probe
82
+ 프로세스처럼 PID 파일에 없고 커맨드라인도 매칭 안 되는 보유자는 **전혀 감지/정리
83
+ 대상이 아니다.** 결과적으로 재시도 루프는 매번 같은 `EBUSY`를 다시 맞고 포기한다.
84
+
85
+ ### RC2 — 재시도/백오프가 "절대 안 죽는 보유자"에 무력
86
+ `maxInstallAttempts = 3`, 백오프 `attempt*1500ms`(1.5s, 3s) (upgrade-helper.ts:449,469).
87
+ 2일째 떠 있는 고아 프로세스에는 의미가 없다. 게다가 최종 실패 시 사용자에게
88
+ 가는 신호는 로그 파일 한 줄(`Upgrade helper failed: ...`)뿐 — **어떤 프로세스가
89
+ 막고 있는지, 어떻게 복구하는지** 알려주지 않는다.
90
+
91
+ ### RC3 — Node 24 preinstall 가드 ↔ 멀티-node PATH (별도로 재현 확인됨)
92
+ `preinstall` 가드(아래)는 Windows에서 lifecycle 스크립트를 실행하는 `node`가
93
+ 24+ 면 설치를 중단한다:
94
+ ```jsonc
95
+ // packages/daemon-cloud/package.json, oss/packages/daemon-standalone/package.json
96
+ "preinstall": "node -e \"... if (win32 && major>=24 && !ADHDEV_BOOTSTRAP && !CI) { process.exit(1) }\""
97
+ ```
98
+ npm은 preinstall을 `cmd /c node -e ...` 로, **PATH에서 찾은 bare `node`**로
99
+ 실행한다(npm을 띄운 node가 아님). 이 머신은 `C:\Program Files\nodejs`(Node 24)가
100
+ nvm node보다 PATH 앞에 있어, 일반 `npm i -g adhdev` 는 이 가드에서 바로 죽는다
101
+ (본 사례에서 수동으로 재현됨).
102
+
103
+ `buildInstallEnvWithNodeOnPath()`(upgrade-helper.ts:185-198)가 `dirname(process.execPath)`
104
+ 를 PATH 앞에 붙여 이를 완화하지만, **이는 "헬퍼 자신을 실행한 node가 지원 버전"이라는
105
+ 가정에 의존**한다. 헬퍼가 nvm 심볼릭 링크(`C:\nvm4w\nodejs\node.exe`)로 떴고 그게
106
+ 현재 Node 24를 가리키면, prepend되는 것도 Node 24라 가드가 그대로 발동한다.
107
+ 실제 설치 타깃 node는 `--prefix`(v22.14.0)로 이미 고정돼 있는데도 그렇다.
108
+
109
+ ### RC4 — 스테이징/잔여물 누적
110
+ 잠금이 유지되는 동안 `safeRemoveStaleEntry`는 항상 `EPERM`으로 스킵되어
111
+ `.adhdev-<hash>` 스테이징이 **여러 실행에 걸쳐 그대로 쌓인다**. 보유자가 죽은
112
+ 뒤에 GC하는 경로가 없다. (부수적으로, 헬퍼 밖에서 사용자가 `npm i -g adhdev`를
113
+ 기본 prefix로 돌리면 `AppData\Roaming\npm`에 **두 번째 깨진 설치본**이 생겨
114
+ shadowing 혼란을 유발 — 헬퍼 책임은 아니나 진단 문서엔 남겨둠.)
115
+
116
+ ## 4. 패치 권고
117
+
118
+ ### P1 (필수) — 임의의 네이티브-애드온 보유자 감지·종료
119
+ 설치 직전(및 각 재시도 전)에, **설치 대상 경로의** `conpty.node`(및
120
+ `ghostty-vt.dll`)를 로드 중인 프로세스를 열거해 self/parent를 제외하고 종료한 뒤
121
+ 종료를 기다린다. 이번에 동작 확인된 PowerShell 패턴:
122
+
123
+ ```powershell
124
+ Get-Process node -ErrorAction SilentlyContinue | ForEach-Object {
125
+ $p = $_
126
+ try {
127
+ if ($p.Modules | Where-Object { $_.FileName -ieq $targetConptyPath }) { $p.Id }
128
+ } catch {}
129
+ }
130
+ ```
131
+ - `stopSessionHostProcesses()` 옆에 `stopForeignNativeAddonHolders(installRoot)`
132
+ 형태로 추가. `installCommand.surface.packageRoot` 기준으로 정확한
133
+ `node_modules/node-pty/prebuilds/<plat-arch>/conpty.node` 절대경로를 만들어
134
+ **그 경로를 매핑한 프로세스만** 대상으로 한다(과잉 종료 방지).
135
+ - 종료한 pid + commandLine을 `appendUpgradeLog` 로 남긴다(진단성 확보).
136
+ - `taskkill /T /F`(기존 `killPid`) 재사용 + `waitForPidExit` 로 매핑 해제 대기.
137
+ - 안전장치: 경로 매칭이 모호하면 죽이지 말고 로그만(아래 P2의 사용자 안내로 위임).
138
+
139
+ ### P2 (필수) — 복구 가능한 실패 신호
140
+ 최종 실패 시(또는 보유자 종료 실패 시) 로그뿐 아니라 **사용자에게 보이는 메시지**를
141
+ 남긴다: 막고 있는 pid/commandLine 목록 + 그대로 붙여넣어 복구할 수 있는 수동 명령
142
+ (`Stop-Process -Id ... ; <pinned-node> <npm-cli> install -g adhdev@<v> --prefix <prefix>`).
143
+ 재시도 예산도 현실화(예: 보유자 능동 정리 후 1~2회면 충분하므로, "정리 → 확인 →
144
+ 설치" 순서로 바꾸고 맹목적 백오프 의존을 줄인다).
145
+
146
+ ### P3 (권장) — Node 가드와의 상호작용 견고화
147
+ `buildInstallEnvWithNodeOnPath()` 가 만드는 install env에 **`ADHDEV_BOOTSTRAP=1`
148
+ 을 함께 설정**한다. 자동 업그레이드 경로에서는 실제 런타임 node가 `--prefix`로
149
+ 이미 고정/검증돼 있으므로, lifecycle 가드를 PATH 순서에만 의존해 우회하는 것은
150
+ 취약하다. 더 견고하게는: `process.execPath`의 major가 지원 범위(예: 22) 밖이면
151
+ `installPrefix` 기준으로 지원되는 node를 명시적으로 찾아 npm 실행과 lifecycle
152
+ 스크립트 양쪽에 쓰도록 한다.
153
+ > 주의: 가드 자체를 약화시키지 말 것. 가드는 "사용자 수동 설치"를 막는 용도로
154
+ > 유지하고, **자동 헬퍼 경로에서만** bootstrap 우회를 적용한다.
155
+
156
+ ### P4 (권장) — 스테이징 GC 시점 추가
157
+ 보유자가 모두 사라진 것을 확인한 뒤 `cleanupStaleGlobalInstallDirs` 를 한 번 더
158
+ 돌리고, 가능하면 **CLI 정상 기동 시점**(잠금 없는 상태)에도 1회 GC를 수행해
159
+ 누적된 `.adhdev-<hash>` 를 청소한다.
160
+
161
+ ## 5. 변경 대상 파일
162
+
163
+ - `oss/packages/daemon-core/src/commands/upgrade-helper.ts` — P1~P4 핵심.
164
+ - 신규 `stopForeignNativeAddonHolders()` (P1), `runDaemonUpgradeHelper` 흐름에
165
+ `stopSessionHostProcesses` 직후 호출.
166
+ - 최종 실패 메시지/예산 조정 (P2): `runDaemonUpgradeHelper` 의 설치 루프 +
167
+ `maybeRunDaemonUpgradeHelperFromEnv` 의 catch.
168
+ - install env에 `ADHDEV_BOOTSTRAP` 주입 (P3): `buildInstallEnvWithNodeOnPath`.
169
+ - (가드는 변경 불필요 — 자동 경로에서 env로 우회하는 것이 P3.)
170
+
171
+ ## 6. 검증
172
+
173
+ 1. **재현 픽스처:** `oss/packages/daemon-core/test/commands/daemon-upgrade-runtime-version.test.ts`
174
+ 에 "세션 호스트가 **아닌** 프로세스가 대상 `conpty.node`를 매핑 중" 케이스 추가.
175
+ P1이 그 holder를 감지·종료 대상에 포함하는지 단위 테스트.
176
+ 2. **수동 E2E (Windows):**
177
+ - 대상 설치본의 `conpty.node`를 로드하는 더미 node 프로세스를 띄워 둔다.
178
+ - 구버전에서 자동 업그레이드를 트리거.
179
+ - 기대: 헬퍼가 더미 holder를 로그에 남기고 종료 → 설치 성공, 잔여 staging 없음.
180
+ - 실패 주입(더미를 못 죽이게)했을 때: 사용자에게 pid/commandLine + 수동 복구
181
+ 명령이 표시되는지 확인.
182
+ 3. **회귀:** `buildInstallEnvWithNodeOnPath` + `ADHDEV_BOOTSTRAP` 조합에서
183
+ Node 24가 PATH 앞에 있어도 preinstall 가드가 자동 경로에선 통과하는지 확인.
184
+
185
+ ## 7. 부록 — 이번 사례 수동 복구에 실제로 통한 명령
186
+
187
+ ```powershell
188
+ # 1) conpty.node 보유자 식별
189
+ Get-Process node | ? { try { $_.Modules | ? ModuleName -match 'conpty' } catch {} } | % Id
190
+ # 2) 보유자 종료 (이번엔 34316,37304,56396)
191
+ Stop-Process -Id 34316,37304,56396 -Force
192
+ # 3) 고정 node(22) + 올바른 prefix로 설치 (PATH 앞에 Node22 → preinstall 가드 통과)
193
+ $pfx="C:\Users\kjs0116\AppData\Local\nvm\v22.14.0"; $env:PATH="$pfx;$env:PATH"
194
+ & "$pfx\node.exe" "$pfx\node_modules\npm\bin\npm-cli.js" install -g adhdev@0.9.82-rc.358 --prefix $pfx
195
+ # 4) 잔여 스테이징/엉뚱한 prefix 설치본 정리
196
+ Remove-Item -Recurse -Force "$pfx\node_modules\.adhdev-*" -EA SilentlyContinue
197
+ Remove-Item -Recurse -Force "$env:APPDATA\npm\node_modules\adhdev" -EA SilentlyContinue
198
+ ```
@@ -3964,7 +3964,12 @@ export class DaemonCommandRouter {
3964
3964
  inlineMesh?: unknown,
3965
3965
  options?: { preferInline?: boolean },
3966
3966
  ): Promise<{ mesh: any; inline: boolean; source: 'inline_cache' | 'inline_bootstrap' | 'local_config' } | null> {
3967
- const preferInline = options?.preferInline === true;
3967
+ // Default to inline-cache-preferred: a caller that omits the flag still sees
3968
+ // inline-cache-only (worktree clone) nodes in the resolved mesh view, closing
3969
+ // the CLAIMSTALL gap where a missed `preferInline: true` silently dropped them.
3970
+ // An explicit `preferInline: false` is still honored for any local-config-only
3971
+ // read that deliberately bypasses the inline cache.
3972
+ const preferInline = options?.preferInline !== false;
3968
3973
  if (preferInline) {
3969
3974
  const cached = this.getCachedInlineMesh(meshId);
3970
3975
  if (cached) {
@@ -194,6 +194,15 @@ function buildInstallEnvWithNodeOnPath(baseEnv: NodeJS.ProcessEnv = process.env)
194
194
  const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
195
195
  const current = env[pathKey] || '';
196
196
  env[pathKey] = current ? `${nodeBinDir};${current}` : nodeBinDir;
197
+ // Belt-and-suspenders for the same Node-version guard: the PATH prepend above
198
+ // only works if the running helper's node is itself a supported version, but a
199
+ // helper launched via an nvm shim (e.g. `C:\nvm4w\nodejs\node.exe`) can resolve
200
+ // to Node 24 even though the real install target node is pinned via `--prefix`.
201
+ // In the AUTOMATIC upgrade path the install target is already pinned/verified,
202
+ // so authorize the lifecycle guard to proceed via the same bootstrap escape
203
+ // hatch the guard already honors. This is scoped to the helper-built env only —
204
+ // it never weakens the guard for a user-run `npm i -g adhdev`.
205
+ env.ADHDEV_BOOTSTRAP = '1';
197
206
  return env;
198
207
  }
199
208
 
@@ -327,6 +336,139 @@ export async function stopSessionHostProcesses(appName: string): Promise<void> {
327
336
  }
328
337
  }
329
338
 
339
+ // Native addons that stay EXCLUSIVELY locked on Windows while any process keeps
340
+ // them memory-mapped. node-pty's `conpty.node` is the confirmed offender; the
341
+ // ghostty VT dll has the same lifetime, so guard both.
342
+ const LOCKED_NATIVE_ADDON_BASENAMES = ['conpty.node', 'ghostty-vt.dll'];
343
+
344
+ /**
345
+ * Enumerate processes that have a locked native addon (conpty.node /
346
+ * ghostty-vt.dll) of *this* install memory-mapped.
347
+ *
348
+ * `stopSessionHostProcesses()` only knows the single managed session-host pid, so
349
+ * any *foreign* holder — e.g. an orphaned `pty_*probe*.cjs` left in `%TEMP%` — is
350
+ * invisible to it and keeps the addon locked through every install retry, dooming
351
+ * the upgrade with EBUSY. This scans by the module's full path so we only ever
352
+ * target a holder of the exact `packageRoot` being replaced (never an unrelated
353
+ * install's copy). Windows-only — these locks don't exist on POSIX.
354
+ */
355
+ export function listForeignNativeAddonHolders(
356
+ packageRoot: string | null | undefined,
357
+ ): Array<{ pid: number; commandLine: string | null }> {
358
+ if (process.platform !== 'win32' || !packageRoot) return [];
359
+ const rootLower = packageRoot.replace(/\//g, '\\').replace(/'/g, "''").toLowerCase();
360
+ const endsWithChecks = LOCKED_NATIVE_ADDON_BASENAMES
361
+ .map((name) => `$lf.EndsWith('${name}')`)
362
+ .join(' -or ');
363
+ // List pids of node processes whose loaded modules include a locked native
364
+ // addon living UNDER this install's package root. Accessing .Modules for a
365
+ // process we can't open throws — swallow per-process so one inaccessible
366
+ // process doesn't abort the whole scan.
367
+ const script = [
368
+ `$root = '${rootLower}'`,
369
+ `Get-Process node -ErrorAction SilentlyContinue | ForEach-Object {`,
370
+ ` $p = $_`,
371
+ ` try {`,
372
+ ` foreach ($m in $p.Modules) {`,
373
+ ` $fn = $m.FileName`,
374
+ ` if ($fn) {`,
375
+ ` $lf = $fn.ToLower()`,
376
+ ` if ($lf.StartsWith($root) -and (${endsWithChecks})) { $p.Id; break }`,
377
+ ` }`,
378
+ ` }`,
379
+ ` } catch {}`,
380
+ `}`,
381
+ ].join('\n');
382
+
383
+ let out = '';
384
+ try {
385
+ out = String(execFileSync('powershell.exe', [
386
+ '-NoProfile',
387
+ '-NonInteractive',
388
+ '-ExecutionPolicy', 'Bypass',
389
+ '-Command', script,
390
+ ], { encoding: 'utf8', timeout: 8000, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true })).trim();
391
+ } catch {
392
+ return [];
393
+ }
394
+
395
+ const selfPid = process.pid;
396
+ const seen = new Set<number>();
397
+ const holders: Array<{ pid: number; commandLine: string | null }> = [];
398
+ for (const line of out.split(/\r?\n/)) {
399
+ const pid = Number.parseInt(line.trim(), 10);
400
+ if (!Number.isFinite(pid) || pid <= 0 || pid === selfPid || seen.has(pid)) continue;
401
+ seen.add(pid);
402
+ holders.push({ pid, commandLine: getProcessCommandLine(pid) });
403
+ }
404
+ return holders;
405
+ }
406
+
407
+ /**
408
+ * Terminate every foreign process holding this install's native addon mapped,
409
+ * then wait for each to actually exit so the mapping is released before npm
410
+ * copies the file into its staging dir. Returns what it found/killed so the
411
+ * caller can surface an actionable recovery message on failure.
412
+ */
413
+ export async function stopForeignNativeAddonHolders(
414
+ packageRoot: string | null | undefined,
415
+ options: { parentPid?: number } = {},
416
+ ): Promise<Array<{ pid: number; commandLine: string | null; killed: boolean }>> {
417
+ if (process.platform !== 'win32' || !packageRoot) return [];
418
+ const parentPid = Number.isFinite(options.parentPid) ? Number(options.parentPid) : -1;
419
+ const holders = listForeignNativeAddonHolders(packageRoot);
420
+ const results: Array<{ pid: number; commandLine: string | null; killed: boolean }> = [];
421
+ for (const holder of holders) {
422
+ // The parent daemon pid is already awaited for exit separately; never
423
+ // double-handle it here.
424
+ if (holder.pid === parentPid) continue;
425
+ appendUpgradeLog(
426
+ `Foreign native-addon holder found: pid ${holder.pid}${holder.commandLine ? ` — ${holder.commandLine}` : ''}`,
427
+ );
428
+ const killed = killPid(holder.pid);
429
+ if (killed) {
430
+ await waitForPidExit(holder.pid, 15000);
431
+ appendUpgradeLog(`Terminated foreign native-addon holder pid ${holder.pid}`);
432
+ } else {
433
+ appendUpgradeLog(`Failed to terminate foreign native-addon holder pid ${holder.pid}`);
434
+ }
435
+ results.push({ ...holder, killed });
436
+ }
437
+ return results;
438
+ }
439
+
440
+ function getUpgradeFailureNoticePath(): string {
441
+ const home = os.homedir();
442
+ const dir = path.join(home, '.adhdev');
443
+ try {
444
+ fs.mkdirSync(dir, { recursive: true });
445
+ } catch {
446
+ // noop — appendUpgradeLog already creates the dir; this is best-effort.
447
+ }
448
+ return path.join(dir, 'daemon-upgrade-last-error.txt');
449
+ }
450
+
451
+ function buildManualRecoveryCommand(installCommand: PinnedGlobalInstallCommand): string {
452
+ return [installCommand.command, ...installCommand.args]
453
+ .map((part) => (/\s/.test(part) ? `"${part}"` : part))
454
+ .join(' ');
455
+ }
456
+
457
+ /**
458
+ * On final failure, leave the user something actionable instead of only a buried
459
+ * log line: the pids/commandlines still holding the lock and a paste-ready
460
+ * recovery command. Written to a stable path the CLI can surface on next boot.
461
+ */
462
+ function emitUpgradeFailureNotice(lines: string[]): void {
463
+ const body = lines.join('\n');
464
+ appendUpgradeLog(`Upgrade blocked — user action required:\n${body}`);
465
+ try {
466
+ fs.writeFileSync(getUpgradeFailureNoticePath(), `[${new Date().toISOString()}]\n${body}\n`, 'utf8');
467
+ } catch {
468
+ // noop
469
+ }
470
+ }
471
+
330
472
  // npm copies the current install's files into a staging dir before swapping in
331
473
  // the new version. On Windows that copy of `conpty.node` can still race a
332
474
  // just-killed session-host whose mapping hasn't been released yet, surfacing as
@@ -440,6 +582,11 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
440
582
 
441
583
  await stopSessionHostProcesses(sessionHostAppName);
442
584
  removeDaemonPidFile();
585
+ // Kill any *foreign* process still holding this install's conpty.node mapped
586
+ // (the session-host stop above only covers the single managed pid). Do this
587
+ // BEFORE the staging GC so the just-released file can also be cleaned up now
588
+ // that no process maps it.
589
+ await stopForeignNativeAddonHolders(installCommand.surface.packageRoot, { parentPid: payload.parentPid });
443
590
  cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
444
591
 
445
592
  const spec = `${payload.packageName}@${payload.targetVersion || 'latest'}`;
@@ -464,11 +611,35 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
464
611
  break;
465
612
  } catch (error: any) {
466
613
  if (attempt < maxInstallAttempts && isRetriableInstallLockError(error)) {
467
- appendUpgradeLog(`Install attempt ${attempt} hit a file lock (${error?.code || 'lock'}); cleaning staging and retrying after backoff`);
614
+ appendUpgradeLog(`Install attempt ${attempt} hit a file lock (${error?.code || 'lock'}); clearing holders + staging and retrying after backoff`);
615
+ // Re-run the active cleanup ("정리 → 확인 → 설치") rather than relying on
616
+ // backoff alone: a never-exiting foreign holder won't disappear on its
617
+ // own, so kill it again before the next attempt.
618
+ await stopForeignNativeAddonHolders(installCommand.surface.packageRoot, { parentPid: payload.parentPid });
468
619
  cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
469
620
  await new Promise((resolve) => setTimeout(resolve, attempt * 1500));
470
621
  continue;
471
622
  }
623
+ // Out of retries on a lock error: leave the user an actionable recovery
624
+ // notice naming whoever is still holding the native addon locked.
625
+ if (isRetriableInstallLockError(error)) {
626
+ const blockers = listForeignNativeAddonHolders(installCommand.surface.packageRoot);
627
+ const notice: string[] = [
628
+ `adhdev ${spec} could not be installed: a file lock (${error?.code || 'EBUSY/EPERM'}) is blocking the native addon.`,
629
+ ];
630
+ if (blockers.length > 0) {
631
+ notice.push('Processes still holding the lock:');
632
+ for (const b of blockers) {
633
+ notice.push(` pid ${b.pid}${b.commandLine ? ` — ${b.commandLine}` : ''}`);
634
+ }
635
+ notice.push('To recover, stop them and reinstall:');
636
+ notice.push(` Stop-Process -Id ${blockers.map((b) => b.pid).join(',')} -Force`);
637
+ } else {
638
+ notice.push('To recover, reinstall manually:');
639
+ }
640
+ notice.push(` ${buildManualRecoveryCommand(installCommand)}`);
641
+ emitUpgradeFailureNotice(notice);
642
+ }
472
643
  throw error;
473
644
  }
474
645
  }
@@ -19,7 +19,7 @@ import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
19
19
  import { getLastDisplayMessage } from '../status/snapshot.js';
20
20
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
21
21
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
22
- import { normalizeMeshNodeId, meshNodeIdMatches, expandDaemonIdForms, type MeshNodeIdentified } from '@adhdev/mesh-shared';
22
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, type MeshNodeIdentified } from '@adhdev/mesh-shared';
23
23
  import {
24
24
  findRecentTerminalLedgerEvidence,
25
25
  hasDispatchAfterTerminal,
@@ -690,10 +690,11 @@ function isLocalAutoLaunchNode(node: any): boolean {
690
690
  const machineId = readNonEmptyString(node?.machineId);
691
691
  const appConfig = loadConfig();
692
692
  const localMachineId = readNonEmptyString(appConfig.machineId) || readNonEmptyString(appConfig.registeredMachineId);
693
- const cloudDaemonId = localMachineId ? `daemon_${localMachineId}` : '';
694
- const standaloneDaemonId = localMachineId ? `standalone_${localMachineId}` : '';
695
693
 
696
- const daemonMatchesLocal = !daemonId || daemonId === cloudDaemonId || daemonId === standaloneDaemonId;
694
+ // Route through the canonical daemon-id equivalence helper so a node carrying the
695
+ // bare `mach_<hex>` form (not just the reassembled `daemon_`/`standalone_` prefixed
696
+ // forms) resolves to THIS coordinator instead of being misjudged as remote.
697
+ const daemonMatchesLocal = !daemonId || daemonIdsEquivalent(daemonId, localMachineId);
697
698
  const machineMatchesLocal = !machineId || (!!localMachineId && machineId === localMachineId);
698
699
 
699
700
  if (node?.isLocalWorktree === true) {
@@ -1840,7 +1841,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1840
1841
  // transcript and record the genuine completion once the worker truly finishes (commonly
1841
1842
  // after a coordinator nudge / re-dispatch). A matched queue task, or a completion with
1842
1843
  // genuine evidence, is marked terminal as before.
1843
- const leaveDirectDispatchActive = !task && opts?.tentativeIfDirect === true;
1844
+ // WARMUPGAP: a no-taskId completion from a session that holds no active assignment is a
1845
+ // pre-assignment warmup / ghost event (a worker spawns, idles, and emits idle→generating→
1846
+ // completed before any task is dispatched, with meshActiveTaskId unset so the event carries
1847
+ // no taskId). Letting it through would hit the session_id fallback in updateDirectDispatchStatus
1848
+ // and flip a sibling/stale dispatch row this event does not own — the real task later lands on
1849
+ // a corrupted row and never reaches completed. Skip the dispatch update for that case. A
1850
+ // taskId-carrying completion (real task), or any completion whose session currently holds an
1851
+ // active assignment (legacy/relayed worker), still flips as before.
1852
+ const leaveDirectDispatchActive = (!task && opts?.tentativeIfDirect === true)
1853
+ || (!eventTaskId && !sessionHasActiveAssignment(args.meshId, sessionId));
1844
1854
  if (!leaveDirectDispatchActive) {
1845
1855
  // CANON-B: flip the exact dispatch row the completion echoed its taskId for; the
1846
1856
  // session_id fallback (no echoed taskId) still covers legacy/relayed workers.
@@ -1958,7 +1968,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1958
1968
  // sibling must keep that row 'dispatched' so its own confirm can match it; acking
1959
1969
  // by session would mark it 'acked' prematurely and hide a genuine non-delivery.
1960
1970
  const startedTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1961
- updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
1971
+ // WARMUPGAP: only ack a dispatch row when the event names its task, or the session
1972
+ // currently holds an active assignment. A no-taskId generating_started from an
1973
+ // unassigned session is a pre-assignment warmup — the session_id fallback would ack a
1974
+ // sibling/stale dispatch row this event does not own, marking it 'acked' prematurely and
1975
+ // hiding a genuine non-delivery. Skip the dispatch ack for that ghost case (the delivery
1976
+ // acks below are bound to actual deliveries and stay a no-op for a warmup session).
1977
+ if (startedTaskId || sessionHasActiveAssignment(args.meshId, sessionId)) {
1978
+ updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
1979
+ }
1962
1980
  const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
1963
1981
  try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
1964
1982
  catch { return []; }
@@ -2073,7 +2091,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
2073
2091
  });
2074
2092
  LOG.info('MeshRecovery', `Auto-requeued failed task: ${task.id} for node ${autoNodeId}`);
2075
2093
 
2076
- const node = mesh?.nodes.find((n: any) => n.id === autoNodeId);
2094
+ const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, autoNodeId));
2077
2095
  if (node) {
2078
2096
  components.cliManager.handleCliCommand('launch_cli', {
2079
2097
  cliType: recoveryContext.failedProviderType,
@@ -57,7 +57,7 @@ import {
57
57
  } from './mesh-unresolved-forward-outbox.js';
58
58
  import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
59
59
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
60
- import { expandDaemonIdForms } from '@adhdev/mesh-shared';
60
+ import { expandDaemonIdForms, daemonIdsEquivalent } from '@adhdev/mesh-shared';
61
61
  import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask } from './mesh-work-queue.js';
62
62
  import { readLedgerEntries } from './mesh-ledger.js';
63
63
  import { pruneStaleDirectDispatches } from './mesh-active-work.js';
@@ -805,7 +805,7 @@ async function pullRemoteNodeQueues(
805
805
  // (`daemon_<machineId>`) which would NOT equal bare localDaemonId, and pulling
806
806
  // from ourselves over P2P is both wasteful and a self-dispatch hazard.
807
807
  if (!nodeDaemonId) continue;
808
- if (localDaemonId && nodeDaemonId === localDaemonId) continue;
808
+ if (daemonIdsEquivalent(nodeDaemonId, localDaemonId)) continue;
809
809
  if (candidateDaemonIds.includes(nodeDaemonId)) continue;
810
810
 
811
811
  for (const pendingEventArgs of pulls) {
@@ -883,7 +883,7 @@ async function reconcileUnterminatedDirectDispatches(
883
883
  // has a live instance here. Anything else is reached over P2P.
884
884
  const isLocalNode = !nodeDaemonId
885
885
  || selfIds.includes(nodeDaemonId)
886
- || (localDaemonId !== undefined && nodeDaemonId === localDaemonId)
886
+ || daemonIdsEquivalent(nodeDaemonId, localDaemonId)
887
887
  || !!components.instanceManager.getInstance(sessionId);
888
888
 
889
889
  const providerType = readNonEmptyString(dispatch.providerType);
@@ -1003,7 +1003,7 @@ async function collectLiveNodesWithSessions(
1003
1003
  const nodeDaemonId = readNonEmptyString(node.daemonId);
1004
1004
  const isLocalNode = !nodeDaemonId
1005
1005
  || selfIds.includes(nodeDaemonId)
1006
- || (localDaemonId !== undefined && nodeDaemonId === localDaemonId);
1006
+ || daemonIdsEquivalent(nodeDaemonId, localDaemonId);
1007
1007
  let statusResult: unknown;
1008
1008
  try {
1009
1009
  if (isLocalNode) {
@@ -19,6 +19,7 @@
19
19
  'use strict';
20
20
 
21
21
  import { FsmDriver, type DashboardEvent, type ISpecDriver } from './fsm-driver.js';
22
+ import { lastContiguousNumberedBlock } from './evaluator.js';
22
23
  import { executeNativeHistory } from './native-history-executor.js';
23
24
  import * as fs from 'node:fs';
24
25
  import type { NativeHistoryConfig, Control, ControlAction } from './types.js';
@@ -521,21 +522,27 @@ export class SpecCliAdapter implements CliAdapter {
521
522
  const ec = action.extract_choices;
522
523
  if (!ec?.pattern) return [];
523
524
  const text = this.readScreenSectionText(ec.section);
524
- const out: Array<{ index: number; label: string; current: boolean }> = [];
525
- const seen = new Set<number>();
525
+ // Collect EVERY matching line in screen order with no top-down de-dup.
526
+ // The picker section can include conversation history above it (a stray
527
+ // "1./2./3." list, blockquote `>` lines); a `seen.has(idx)` first-wins
528
+ // scan would let those body lines claim the option indices and shadow
529
+ // the real choices — committing the wrong model under arrow-key nav.
530
+ const all: Array<{ index: number; label: string; current: boolean }> = [];
526
531
  for (const rawLine of text.split('\n')) {
527
532
  const line = rawLine.replace(/\r$/, '');
528
533
  const m = new RegExp(ec.pattern, ec.flags ?? '').exec(line);
529
534
  if (!m) continue;
530
535
  const idx = Number(m[1]);
531
- if (!Number.isFinite(idx) || seen.has(idx)) continue;
536
+ if (!Number.isFinite(idx) || idx <= 0) continue;
532
537
  const label = (m[2] ?? '').replace(/\s+/g, ' ').trim();
533
538
  if (!label) continue;
534
539
  const current = /^\s*[❯›>]/.test(line) || /[✔✓●]\s*$/.test(label);
535
- seen.add(idx);
536
- out.push({ index: idx, label, current });
540
+ all.push({ index: idx, label, current });
537
541
  }
538
- return out;
542
+ // Real options are the bottom-most contiguous numbered block; this also
543
+ // confines the `current` cursor flag to that block so a body `>` line is
544
+ // never mistaken for the cursor row.
545
+ return lastContiguousNumberedBlock(all);
539
546
  }
540
547
 
541
548
  /** Live text of a named screen section (or the whole screen when no