@bitkyc08/opencodex 2.8.0 → 2.9.0

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 (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/update/job.ts CHANGED
@@ -1,12 +1,27 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
3
+ import { dirname, join, sep } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { atomicWriteFile, getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
5
+ import {
6
+ atomicWriteFile,
7
+ getConfigDir,
8
+ loadConfig,
9
+ readPid,
10
+ readRuntimePort,
11
+ removePid,
12
+ removeRuntimePort,
13
+ verifyPidIdentity,
14
+ } from "../config";
6
15
  import { isProcessAlive, killProxy } from "../lib/process-control";
7
- import { reclaimListenPort } from "../server/port-reclaim";
16
+ import {
17
+ buildWindowsElevatedArgumentList,
18
+ resolveTrustedWindowsPowerShellExe,
19
+ } from "../lib/windows-elevation";
20
+ import { stopWinswService } from "../lib/winsw";
21
+ import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } from "../server/port-reclaim";
22
+ import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop";
8
23
  import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
9
- import { isServiceInstalled } from "../service";
24
+ import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service";
10
25
  import {
11
26
  type Channel,
12
27
  type Installer,
@@ -20,6 +35,7 @@ import {
20
35
  updateCommandStr,
21
36
  } from "./index";
22
37
  import { isNewer } from "./notify";
38
+ import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
23
39
  import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
24
40
 
25
41
  const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
@@ -102,9 +118,103 @@ function nodeBin(): string {
102
118
  return process.platform === "win32" ? "node.exe" : "node";
103
119
  }
104
120
 
121
+ /**
122
+ * Strict bind script: exit 0 only after listen+close. Any listen error (including
123
+ * Windows ghost-TCB failures under Bun) is busy — matches published `ocx start`
124
+ * probes that treat every listen error as unavailable.
125
+ */
126
+ function strictBindProbeScript(port: number, hostname: string): string {
127
+ return [
128
+ "const net=require('net');",
129
+ "const s=net.createServer();",
130
+ "s.once('error',()=>process.exit(2));",
131
+ `s.listen(${Math.trunc(port)},${JSON.stringify(hostname)},()=>s.close(()=>process.exit(0)));`,
132
+ "setTimeout(()=>process.exit(3),2500);",
133
+ ].join("");
134
+ }
135
+
136
+ function spawnBindProbe(bin: string, script: string): boolean {
137
+ try {
138
+ const r = spawnSync(bin, ["-e", script], {
139
+ windowsHide: true,
140
+ timeout: 4000,
141
+ stdio: "ignore",
142
+ });
143
+ return r.status === 0;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Live global package Bun — not the npm rename tree the update worker may still
151
+ * be executing from (`@bitkyc08/.opencodex-*`). Reject the tiny postinstall
152
+ * stub so probes fall back to the worker runtime instead of failing forever.
153
+ */
154
+ function livePackageBunPath(): string | null {
155
+ const launcher = packageLauncherPath();
156
+ const root = join(dirname(launcher), "..");
157
+ for (const name of ["bun.exe", "bun"]) {
158
+ const candidate = join(root, "node_modules", "bun", "bin", name);
159
+ if (isRealBunBinary(candidate)) return candidate;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Port is free for post-update `ocx start` only when the runtime that will
166
+ * actually execute the start can bind. Prefer live package Bun; fall back to the
167
+ * worker runtime. Do not require a separate `node` binary (Bun-only installs).
168
+ */
169
+ async function strictRuntimePortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
170
+ const script = strictBindProbeScript(port, hostname);
171
+ const bun = livePackageBunPath();
172
+ if (bun) return spawnBindProbe(bun, script);
173
+ return spawnBindProbe(process.execPath, script);
174
+ }
175
+
176
+ /**
177
+ * Wait until netstat reports no LISTEN owners on `port` AND the start runtime
178
+ * can bind. Dead PIDs still appear as holders while the ghost TCB lives;
179
+ * SetTcpEntry is a no-op without elevation (rc 317), so wait them out.
180
+ */
181
+ async function waitForGhostListenClear(
182
+ port: number,
183
+ hostname: string,
184
+ listPids: (port: number) => number[],
185
+ timeoutMs: number,
186
+ sleep: (ms: number) => Promise<void>,
187
+ aliveFn: (pid: number) => boolean = isProcessAlive,
188
+ ): Promise<{ ok: boolean; accessDenied: boolean }> {
189
+ const deadline = Date.now() + timeoutMs;
190
+ let accessDenied = false;
191
+ while (Date.now() < deadline) {
192
+ const holders = listPids(port).filter(pid => pid !== process.pid);
193
+ const liveHolders = holders.filter(pid => aliveFn(pid));
194
+ // Never SetTcpEntry while a live process still owns the port (foreign or ocx).
195
+ if (process.platform === "win32" && liveHolders.length === 0) {
196
+ try {
197
+ const drop = dropWindowsTcpRowsForLocalPort(port);
198
+ if (drop.accessDenied > 0) accessDenied = true;
199
+ } catch { /* best-effort */ }
200
+ }
201
+ if (holders.length === 0 && await strictRuntimePortAvailable(port, hostname)) {
202
+ return { ok: true, accessDenied };
203
+ }
204
+ await sleep(500);
205
+ }
206
+ return { ok: false, accessDenied };
207
+ }
208
+
105
209
  function packageLauncherPath(): string {
106
210
  // This module lives at src/update/job.ts — the launcher is <pkg-root>/bin/ocx.mjs.
107
- return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
211
+ // After `npm install -g`, import.meta.url can still point at npm's renamed temp
212
+ // tree (`@bitkyc08/.opencodex-*`). Prefer the live package path when that happens.
213
+ const fromMeta = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
214
+ if (!/[\\/]\.opencodex-/i.test(fromMeta) && existsSync(fromMeta)) return fromMeta;
215
+ const live = fromMeta.replace(/[\\/]@bitkyc08[\\/]\.opencodex-[^\\/]+/i, `${sep}@bitkyc08${sep}opencodex`);
216
+ if (live !== fromMeta && existsSync(live)) return live;
217
+ return fromMeta;
108
218
  }
109
219
 
110
220
  function formatCommand(bin: string, args: string[]): string {
@@ -267,18 +377,68 @@ export function staleActiveUpdateJobReason(
267
377
  return null;
268
378
  }
269
379
 
270
- const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
271
- checkForUpdateFn: channel => checkForUpdate(channel),
272
- spawnWorkerFn: (jobId, channel, restart) => spawn(
273
- process.execPath,
274
- [process.argv[1], "__gui-update-worker", jobId, channel, restart ? "restart" : "no-restart"],
275
- {
380
+ /**
381
+ * Spawn the GUI update worker without inheriting the proxy's LISTEN socket.
382
+ *
383
+ * On Windows, `spawn(..., { detached: true, stdio: "ignore" })` still inherits
384
+ * inheritable handles including Bun.serve's LISTEN socket. After stop-first
385
+ * update kills the proxy PID, netstat keeps showing that dead PID as LISTENING
386
+ * until every inheriting child exits. The update worker was that child, so the
387
+ * port stayed busy for the whole job. Launch via PowerShell Start-Process so
388
+ * the worker is a fresh process tree with no inherited LISTEN handle.
389
+ */
390
+ export function spawnGuiUpdateWorker(
391
+ jobId: string,
392
+ channel: Channel,
393
+ restart: boolean,
394
+ ): UpdateWorkerProcess {
395
+ const script = process.argv[1];
396
+ const args = [
397
+ script,
398
+ "__gui-update-worker",
399
+ jobId,
400
+ channel,
401
+ restart ? "restart" : "no-restart",
402
+ ];
403
+ if (process.platform !== "win32") {
404
+ return spawn(process.execPath, args, {
276
405
  detached: true,
277
406
  stdio: "ignore",
278
407
  windowsHide: true,
279
408
  env: { ...process.env, OCX_SERVICE: "1" },
280
- },
281
- ),
409
+ });
410
+ }
411
+
412
+ // Single -ArgumentList string with CommandLineToArgvW quoting so paths with
413
+ // spaces survive Start-Process's space-join (array elements lose outer quotes).
414
+ const psQuote = (value: string): string => `'${value.replace(/'/g, "''")}'`;
415
+ const argumentList = buildWindowsElevatedArgumentList(args);
416
+ const ps = [
417
+ `$env:OCX_SERVICE = '1'`,
418
+ `$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(argumentList)} -WindowStyle Hidden -PassThru`,
419
+ `if (-not $p) { exit 1 }`,
420
+ `Write-Output $p.Id`,
421
+ ].join("; ");
422
+ const launched = spawnSync(
423
+ resolveTrustedWindowsPowerShellExe(),
424
+ ["-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps],
425
+ { encoding: "utf8", windowsHide: true, timeout: 15_000 },
426
+ );
427
+ const pid = Number(String(launched.stdout ?? "").trim().split(/\r?\n/).pop());
428
+ if (launched.status !== 0 || !Number.isSafeInteger(pid) || pid <= 0) {
429
+ const detail = String(launched.stderr ?? launched.stdout ?? "").trim() || `status ${launched.status}`;
430
+ throw new Error(`Windows update worker Start-Process failed: ${detail}`);
431
+ }
432
+ return {
433
+ pid,
434
+ unref() { /* Start-Process already detached */ },
435
+ once() { /* startup errors are not wired across Start-Process */ },
436
+ };
437
+ }
438
+
439
+ const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
440
+ checkForUpdateFn: channel => checkForUpdate(channel),
441
+ spawnWorkerFn: spawnGuiUpdateWorker,
282
442
  isProcessAliveFn: isProcessAlive,
283
443
  nowMs: Date.now,
284
444
  };
@@ -369,18 +529,90 @@ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], time
369
529
  return { status: result.status, signal: result.signal };
370
530
  }
371
531
 
372
- function spawnDetachedStart(job: UpdateJobState, installer: Installer, port?: number): void {
532
+ /**
533
+ * Tear down anything that would make `ocx start` exit 1 with "already running"
534
+ * (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn.
535
+ */
536
+ function preparePortForPinnedStart(
537
+ job: UpdateJobState,
538
+ port: number,
539
+ listPids: (port: number) => number[],
540
+ aliveFn: (pid: number) => boolean,
541
+ verifyOcx: (pid: number) => number | null = verifyPidIdentity,
542
+ ): void {
543
+ stopWindowsServiceWrappersBestEffort();
544
+ const pid = readPid();
545
+ if (pid) {
546
+ updateJob(job, {}, `Clearing pre-start proxy PID ${pid} before pinned start.`);
547
+ try { killProxy(pid); } catch { /* best-effort */ }
548
+ removePid(pid);
549
+ } else {
550
+ removePid();
551
+ }
552
+ removeRuntimePort();
553
+ for (const holder of listPids(port)) {
554
+ if (holder === process.pid || !aliveFn(holder)) continue;
555
+ if (verifyOcx(holder) !== holder) {
556
+ updateJob(
557
+ job,
558
+ {},
559
+ `Leaving foreign listen holder PID ${holder} on port ${port}; refusing collateral kill.`,
560
+ );
561
+ continue;
562
+ }
563
+ updateJob(job, {}, `Stopping live ocx listen holder PID ${holder} on port ${port} before pinned start.`);
564
+ try { killProxy(holder); } catch { /* best-effort */ }
565
+ }
566
+ // Match reclaimListenPort: never SetTcpEntry while a live holder remains.
567
+ const liveRemain = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
568
+ if (process.platform === "win32" && liveRemain.length === 0) {
569
+ try { dropWindowsTcpRowsForLocalPort(port); } catch { /* best-effort */ }
570
+ }
571
+ }
572
+
573
+ function spawnDetachedStart(
574
+ job: UpdateJobState,
575
+ installer: Installer,
576
+ port?: number,
577
+ ): ChildProcess {
373
578
  const cmd = restartCommand(false, installer, packageLauncherPath(), port);
374
579
  const env = { ...process.env };
375
580
  delete env.OCX_SERVICE;
376
581
  updateJob(job, {}, `$ ${cmd.display}`);
582
+ let stdio: "ignore" | [ "ignore", number, number ] = "ignore";
583
+ let logFd: number | undefined;
584
+ try {
585
+ const logPath = join(getConfigDir(), "update-pinned-start.log");
586
+ mkdirSync(getConfigDir(), { recursive: true });
587
+ logFd = openSync(logPath, "a");
588
+ writeSync(logFd, `\n--- ${new Date().toISOString()} ---\n$ ${cmd.display}\n`);
589
+ stdio = ["ignore", logFd, logFd];
590
+ } catch { /* fall back to ignored stdio */ }
377
591
  const child = spawn(cmd.bin, cmd.args, {
378
592
  detached: true,
379
- stdio: "ignore",
593
+ stdio,
380
594
  windowsHide: true,
381
595
  env,
382
596
  });
597
+ child.once("error", err => {
598
+ try {
599
+ updateJob(job, {}, `Pinned start spawn error: ${err instanceof Error ? err.message : String(err)}`);
600
+ } catch { /* best-effort */ }
601
+ });
602
+ // Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly
603
+ // with stdio ignored — surface that so the job log explains a silent miss.
604
+ child.once("exit", (code, signal) => {
605
+ if (code === 0 && !signal) return;
606
+ try {
607
+ updateJob(
608
+ job,
609
+ {},
610
+ `Pinned start exited early (code=${code ?? "null"} signal=${signal ?? "null"}).`,
611
+ );
612
+ } catch { /* best-effort */ }
613
+ });
383
614
  child.unref();
615
+ return child;
384
616
  }
385
617
 
386
618
  /** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */
@@ -394,9 +626,17 @@ export interface RestartIo {
394
626
  waitForPort?: typeof reclaimListenPort;
395
627
  spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
396
628
  serviceInstalledFn?: () => boolean;
629
+ /**
630
+ * After a service reinstall exits 0, only trust the service path when this is true.
631
+ * Defaults to {@link isServiceViable} — installed-but-stale assets must fall through
632
+ * to a direct proxy start so dashboard updates never leave /healthz dead.
633
+ */
634
+ serviceViableFn?: () => boolean;
397
635
  probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
398
636
  /** Richer /healthz read for update-correlated restart evidence (pid + version). */
399
637
  probeProxyIdentity?: (port: number, hostname?: string) => Promise<RestartProxyIdentity | null>;
638
+ /** Override the /healthz appearance window (default {@link RESTART_HEALTH_TIMEOUT_MS}). */
639
+ healthTimeoutMs?: number;
400
640
  sleepMs?: (ms: number) => Promise<void>;
401
641
  now?: () => number;
402
642
  /** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */
@@ -411,6 +651,21 @@ export interface RestartIo {
411
651
  captured?: { port: number; hostname: string; oldPid?: number },
412
652
  io?: RestartIo,
413
653
  ) => Promise<void>;
654
+ /**
655
+ * PIDs currently LISTENing on the captured port. Used to widen the post-update
656
+ * kill allowlist beyond the pre-update PID (Windows often leaves a respawned
657
+ * ocx child that would otherwise be treated as a protected listener).
658
+ */
659
+ listListenPidsFn?: (port: number) => number[];
660
+ /**
661
+ * Full listen-PID scan (ok/fail). When omitted, {@link scanListenPids} is used
662
+ * so a probe failure is not mistaken for "no listeners".
663
+ */
664
+ scanListenPidsFn?: (port: number) => ListenPidScan;
665
+ /** Identity check for listeners discovered via {@link listListenPidsFn}. */
666
+ verifyOcxFn?: (pid: number) => number | null;
667
+ /** Liveness check when deciding whether a reclaim timeout still has live holders. */
668
+ isAliveFn?: (pid: number) => boolean;
414
669
  }
415
670
 
416
671
  async function restartAfterUpdate(
@@ -437,60 +692,291 @@ async function restartAfterUpdate(
437
692
  }
438
693
  const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
439
694
  const waitFn = io.waitForPort ?? reclaimListenPort;
440
- const reclaimOpts = {
695
+ const listPids = io.listListenPidsFn ?? listListenPids;
696
+ const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity;
697
+ const aliveFn = io.isAliveFn ?? isProcessAlive;
698
+ // Pre-update PID plus any ocx still LISTENing on the captured port. After a
699
+ // stop-first npm self-update Windows often leaves a respawned bun/node child
700
+ // that is not the captured PID; treating it as protected blocks reclaim and
701
+ // the direct-start fallback never binds.
702
+ const reclaimKillAllowlist = (): number[] => {
703
+ const allow = new Set<number>();
704
+ if (oldPid != null) allow.add(oldPid);
705
+ for (const pid of listPids(port)) {
706
+ if (pid === process.pid) continue;
707
+ if (verifyOcx(pid) === pid) allow.add(pid);
708
+ }
709
+ return [...allow];
710
+ };
711
+ const reclaimOptsFor = (onlyKillPids: number[]) => ({
441
712
  timeoutMs: RESTART_PORT_RECLAIM_MS,
442
713
  intervalMs: 100,
443
714
  scanIntervalMs: 500,
444
- killOcxHolders: oldPid != null,
445
- onlyKillPids: oldPid != null ? [oldPid] : [],
446
- };
715
+ killOcxHolders: true,
716
+ // Windows scheduler wrappers can mint a *new* bun PID during the wait; keep
717
+ // killing every ocx listener on this port, not only the pre-wait snapshot.
718
+ // npm rename trees under `@bitkyc08/.opencodex-*` are classified as ocx by
719
+ // isOcxStartCommandLine — never kill unknown foreign claimants on this port.
720
+ killAllOcxOnPort: true,
721
+ onlyKillPids,
722
+ });
447
723
 
448
724
  if (serviceInstalled) {
449
- // Stop-first update already unloaded the service; reclaim the socket (only the
450
- // captured old PID when trusted), then reinstall wrappers that bake `--port`.
451
- const freed = await waitFn(port, hostname, reclaimOpts);
452
- if (!freed) {
453
- updateJob(job, {}, `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — reinstall may fail until the port is free.`);
725
+ // schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop
726
+ // respawns `ocx start` a few seconds later and races port reclaim. End the
727
+ // task again and best-effort kill those wrappers before we touch the socket.
728
+ stopWindowsServiceWrappersBestEffort();
729
+ // Stop-first update already unloaded the service; reclaim the socket, then
730
+ // reinstall wrappers that bake `--port`.
731
+ const preServiceAllow = reclaimKillAllowlist();
732
+ const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow));
733
+ let skipServiceInstall = false;
734
+ // Windows GUI update worker sets OCX_SERVICE=1 and is never elevated.
735
+ // `schtasks /create` will UAC-fail and can race the subsequent direct start.
736
+ // Keep systemd/launchd reinstall on non-Windows supervisors.
737
+ if (process.platform === "win32" && process.env.OCX_SERVICE === "1") {
738
+ updateJob(job, {}, "Skipping service reinstall from the non-elevated update worker; falling back to a direct proxy start.");
739
+ skipServiceInstall = true;
454
740
  }
455
- const prevBake = process.env.OCX_BAKE_PORT;
456
- process.env.OCX_BAKE_PORT = String(Math.trunc(port));
457
- let serviceOk = false;
458
- try {
459
- const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
460
- const result = run(job, cmd.bin, cmd.args);
461
- serviceOk = result.status === 0;
462
- if (!serviceOk) {
463
- // On Windows, `schtasks /create` requires an elevated token. The update worker
464
- // inherits the (non-admin) proxy's privileges, so a service-managed install
465
- // updated from the GUI or a normal terminal fails here with access denied.
466
- // Falling back to a direct proxy start keeps the update from leaving the proxy
467
- // stopped; the stale service manager can be refreshed later with an admin
468
- // `ocx service install`.
469
- updateJob(job, {}, `Service reinstall failed (exit ${result.status ?? "?"}); falling back to a direct proxy start. Run 'ocx service install' as administrator to refresh the background service manager.`);
741
+ if (!freed && !skipServiceInstall) {
742
+ updateJob(
743
+ job,
744
+ {},
745
+ `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop reinstall may fail until the port is free.`
746
+ + ` ${formatPortHolders(port, listPids, verifyOcx, preServiceAllow)}`,
747
+ );
748
+ const liveAfter = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
749
+ if (liveAfter.length === 0) {
750
+ // Non-elevated `service install` will UAC-fail anyway; skip straight to
751
+ // the direct-start fallthrough instead of burning another minute on it.
752
+ updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start.");
753
+ skipServiceInstall = true;
754
+ }
755
+ }
756
+ if (!skipServiceInstall) {
757
+ const prevBake = process.env.OCX_BAKE_PORT;
758
+ process.env.OCX_BAKE_PORT = String(Math.trunc(port));
759
+ let serviceOk = false;
760
+ try {
761
+ const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
762
+ const result = run(job, cmd.bin, cmd.args);
763
+ serviceOk = result.status === 0;
764
+ if (!serviceOk) {
765
+ // On Windows, `schtasks /create` requires an elevated token. The update worker
766
+ // inherits the (non-admin) proxy's privileges, so a service-managed install
767
+ // updated from the GUI or a normal terminal fails here with access denied.
768
+ // Falling back to a direct proxy start keeps the update from leaving the proxy
769
+ // stopped; the stale service manager can be refreshed later with an admin
770
+ // `ocx service install`.
771
+ updateJob(job, {}, `Service reinstall failed (exit ${result.status ?? "?"}); falling back to a direct proxy start. Run 'ocx service install' as administrator to refresh the background service manager.`);
772
+ }
773
+ } finally {
774
+ if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
775
+ else process.env.OCX_BAKE_PORT = prevBake;
776
+ }
777
+ if (serviceOk) {
778
+ // Exit 0 is not enough: a reinstall can leave stale/missing assets (or a
779
+ // disabled/conflicting manager) that never brings /healthz back. Fall through
780
+ // to a direct start so browser-dashboard updates do not require a viable
781
+ // Background Service for recovery.
782
+ const viable = (io.serviceViableFn ?? isServiceViable)();
783
+ if (viable) return;
784
+ updateJob(
785
+ job,
786
+ {},
787
+ "Service reinstall exited 0 but the background service is not viable (stale or missing assets, disabled, or conflicting); falling back to a direct proxy start.",
788
+ );
470
789
  }
471
- } finally {
472
- if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
473
- else process.env.OCX_BAKE_PORT = prevBake;
474
790
  }
475
- if (serviceOk) return;
476
791
  // Fall through to the direct proxy start below so the update never leaves the
477
- // proxy stopped when the service reinstall could not run.
792
+ // proxy stopped when the service reinstall could not run or did not leave a
793
+ // viable supervisor.
478
794
  }
479
795
 
480
796
  const pid = readPid();
481
797
  if (pid) {
482
798
  updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
483
- killProxy(pid);
799
+ try {
800
+ killProxy(pid);
801
+ } catch {
802
+ // A PID that resists taskkill must not abort recovery: reclaim + pinned start
803
+ // below are the path that repairs stuck Windows listeners.
804
+ }
484
805
  }
806
+ if (serviceInstalled) stopWindowsServiceWrappersBestEffort();
485
807
  // Reclaim the captured port before the pinned start. Spawning `--port` while the old
486
808
  // socket is still busy is how Windows updates used to fail health checks (or hop).
487
- // Only the trusted pre-update PID may be killed; never an arbitrary ocx listener.
488
- const freed = await waitFn(port, hostname, reclaimOpts);
809
+ // killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait.
810
+ const directAllow = reclaimKillAllowlist();
811
+ const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow));
489
812
  if (!freed) {
490
- updateJob(job, {}, `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s (reclaim could not free the socket); not starting on another port. Retry 'ocx start --port ${port}'.`);
813
+ const liveHolders = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
814
+ updateJob(
815
+ job,
816
+ {},
817
+ `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s (reclaim could not free the socket).`
818
+ + ` ${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
819
+ );
820
+ if (liveHolders.length > 0) {
821
+ updateJob(job, {}, `Live holder(s) remain on port ${port}; not starting on another port. Retry 'ocx start --port ${port}'.`);
822
+ return;
823
+ }
824
+ // Dead PIDs can still own LISTEN rows. SetTcpEntry needs elevation (rc 317 on a
825
+ // normal update worker), so poll until netstat is empty and the start runtime can bind.
826
+ updateJob(
827
+ job,
828
+ {},
829
+ `No live holders on port ${port}; waiting for ghost LISTEN rows to clear before pinned start.`,
830
+ );
831
+ // Injected spawnStart is the unit-test seam — skip the long OS wait.
832
+ if (!io.spawnStart) {
833
+ const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
834
+ const cleared = await waitForGhostListenClear(port, hostname, listPids, 90_000, sleep);
835
+ if (cleared.accessDenied) {
836
+ updateJob(job, {}, "SetTcpEntry is non-elevated (access denied); relying on OS ghost-LISTEN expiry.");
837
+ }
838
+ if (!cleared.ok) {
839
+ updateJob(
840
+ job,
841
+ {},
842
+ `Ghost LISTEN rows on port ${port} did not clear in time. `
843
+ + `${formatPortHolders(port, listPids, verifyOcx, directAllow)} `
844
+ + `Retry 'ocx start --port ${port}'.`,
845
+ );
846
+ return;
847
+ }
848
+ }
849
+ }
850
+ // Injected spawnStart keeps unit tests deterministic (one call). Production path
851
+ // retries on missing /healthz after prepare + ghost-LISTEN clear.
852
+ if (io.spawnStart) {
853
+ io.spawnStart(job, job.installer, port);
491
854
  return;
492
855
  }
493
- (io.spawnStart ?? spawnDetachedStart)(job, job.installer, port);
856
+ const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
857
+ const probe = io.probeProxy ?? (async (p: number, host?: string) => (
858
+ !!(await proxyIdentityAt(p, { hostname: host }))
859
+ ));
860
+ const probeIdentity = io.probeProxyIdentity ?? defaultProbeProxyIdentity;
861
+ const expectedVersion = typeof job.latestVersion === "string" && job.latestVersion.length > 0
862
+ ? job.latestVersion
863
+ : null;
864
+ // Service wrappers can respawn a listener during reclaim; if it already reports the
865
+ // update target version, do not spawn a second start that exits "already running".
866
+ {
867
+ const identity = await probeIdentity(port, hostname);
868
+ if (identity && expectedVersion && identity.version === expectedVersion) {
869
+ updateJob(
870
+ job,
871
+ {},
872
+ `Proxy already healthy on ${hostname}:${port} at ${expectedVersion}; skipping pinned start.`,
873
+ );
874
+ return;
875
+ }
876
+ }
877
+ const attempts = 3;
878
+ // Longer than published hard-pin reclaim (30s) so a slow start can still report healthy.
879
+ const perAttemptHealthMs = 70_000;
880
+ let lastChild: ChildProcess | null = null;
881
+ for (let attempt = 1; attempt <= attempts; attempt++) {
882
+ if (attempt > 1) {
883
+ updateJob(
884
+ job,
885
+ {},
886
+ `Pinned start attempt ${attempt - 1} did not become healthy on port ${port}; `
887
+ + `retrying (${attempt}/${attempts}).`,
888
+ );
889
+ if (lastChild?.pid && aliveFn(lastChild.pid)) {
890
+ try { killProxy(lastChild.pid); } catch { /* best-effort */ }
891
+ }
892
+ lastChild = null;
893
+ }
894
+ preparePortForPinnedStart(job, port, listPids, aliveFn, verifyOcx);
895
+ const ready = await waitForGhostListenClear(
896
+ port,
897
+ hostname,
898
+ listPids,
899
+ attempt === 1 ? (freed ? 15_000 : 5_000) : 30_000,
900
+ sleep,
901
+ );
902
+ if (!ready.ok) {
903
+ updateJob(
904
+ job,
905
+ {},
906
+ `Port ${port} not bindable before pinned start attempt ${attempt}; `
907
+ + `${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
908
+ );
909
+ continue;
910
+ }
911
+ lastChild = spawnDetachedStart(job, job.installer, port);
912
+ const healthDeadline = Date.now() + perAttemptHealthMs;
913
+ while (Date.now() < healthDeadline) {
914
+ if (await probe(port, hostname)) return;
915
+ await sleep(500);
916
+ }
917
+ }
918
+ // Exhausted retries: do not leave a hung pinned-start child owning the port.
919
+ if (lastChild?.pid && aliveFn(lastChild.pid)) {
920
+ try { killProxy(lastChild.pid); } catch { /* best-effort */ }
921
+ }
922
+ }
923
+
924
+ /** Compact listen-holder summary for update-job logs when reclaim fails. */
925
+ function formatPortHolders(
926
+ port: number,
927
+ listPids: (port: number) => number[],
928
+ verifyOcx: (pid: number) => number | null,
929
+ allow: number[],
930
+ ): string {
931
+ const allowSet = new Set(allow);
932
+ const holders = listPids(port).map(pid => {
933
+ const tags = [
934
+ verifyOcx(pid) === pid ? "ocx" : "foreign",
935
+ allowSet.has(pid) ? "allow" : "deny",
936
+ isProcessAlive(pid) ? "live" : "dead",
937
+ ];
938
+ return `${pid}(${tags.join(",")})`;
939
+ });
940
+ return `holders=[${holders.join(", ") || "none"}] allow=[${allow.join(", ") || "none"}]`;
941
+ }
942
+
943
+ /** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */
944
+ function stopWindowsServiceWrappersBestEffort(): void {
945
+ if (process.platform !== "win32") return;
946
+ try {
947
+ if (readServiceBackend() === "native") {
948
+ stopWinswService();
949
+ return;
950
+ }
951
+ stopWindows();
952
+ } catch { /* already stopped */ }
953
+ killWindowsServiceWrapperProcesses();
954
+ }
955
+
956
+ /**
957
+ * Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
958
+ * `schtasks /end` ends the task instance but often leaves wscript/cmd running the
959
+ * `:loop` batch, which brings the proxy back during post-update reclaim.
960
+ */
961
+ function killWindowsServiceWrapperProcesses(): void {
962
+ if (process.platform !== "win32") return;
963
+ try {
964
+ const ps = [
965
+ "$pats = @('opencodex-service.cmd','opencodex-service-launcher.vbs');",
966
+ "Get-CimInstance Win32_Process | Where-Object {",
967
+ " if ($_.ProcessId -eq $PID) { return $false };",
968
+ " $c = $_.CommandLine; if (-not $c) { return $false };",
969
+ " foreach ($p in $pats) { if ($c -like ('*' + $p + '*')) { return $true } };",
970
+ " $false",
971
+ "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
972
+ ].join(" ");
973
+ spawnSync(resolveTrustedWindowsPowerShellExe(), [
974
+ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
975
+ "-Command", ps,
976
+ ], { stdio: "ignore", timeout: 5000, windowsHide: true });
977
+ } catch {
978
+ /* best-effort */
979
+ }
494
980
  }
495
981
 
496
982
  /** Exposed for tests: drives the non-service restart path with injected io. */
@@ -522,8 +1008,10 @@ async function awaitRestartedProxyHealthy(
522
1008
  captured: { port: number; hostname: string },
523
1009
  io: RestartIo = {},
524
1010
  ): Promise<AwaitHealthyResult> {
1011
+ // Fresh post-update starts are busy with catalog sync / OAuth; a single 750ms
1012
+ // /healthz miss must not fail the job. Use a longer probe and tolerate brief blips.
525
1013
  const probe = io.probeProxy ?? (async (port: number, hostname?: string) => (
526
- !!(await proxyIdentityAt(port, { hostname }))
1014
+ !!(await proxyIdentityAt(port, { hostname }, { timeoutMs: 2_000, attempts: 3 }))
527
1015
  ));
528
1016
  const sleep = io.sleepMs ?? (async (ms: number) => {
529
1017
  await new Promise(resolve => setTimeout(resolve, ms));
@@ -531,7 +1019,9 @@ async function awaitRestartedProxyHealthy(
531
1019
  const now = io.now ?? (() => Date.now());
532
1020
  const port = captured.port;
533
1021
  const hostname = captured.hostname;
534
- const startDeadline = now() + RESTART_HEALTH_TIMEOUT_MS;
1022
+ const startDeadline = now() + (io.healthTimeoutMs ?? RESTART_HEALTH_TIMEOUT_MS);
1023
+ /** Consecutive failed probes before the stability window counts as a flap. */
1024
+ const stabilityMissLimit = 3;
535
1025
 
536
1026
  while (true) {
537
1027
  // Always make one identity-aware probe at or after the boundary. A replacement
@@ -540,10 +1030,16 @@ async function awaitRestartedProxyHealthy(
540
1030
  if (await probe(port, hostname)) {
541
1031
  updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
542
1032
  const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
1033
+ let misses = 0;
543
1034
  while (now() < stableUntil) {
544
- if (!(await probe(port, hostname))) {
545
- updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`);
546
- return { ok: false, reason: "flapped" };
1035
+ if (await probe(port, hostname)) {
1036
+ misses = 0;
1037
+ } else {
1038
+ misses += 1;
1039
+ if (misses >= stabilityMissLimit) {
1040
+ updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`);
1041
+ return { ok: false, reason: "flapped" };
1042
+ }
547
1043
  }
548
1044
  await sleep(500);
549
1045
  }
@@ -679,6 +1175,10 @@ export function npmSelfUpdateRestartEvidence(
679
1175
  * and/or target version) so a surviving pre-update process cannot look like success.
680
1176
  * After an explicit npm restart the same evidence is required again — health alone is
681
1177
  * not enough when a no-op restart or failed port reclaim leaves the old proxy up.
1178
+ *
1179
+ * Browser-dashboard update recovery must not require a viable Background Service: when
1180
+ * no service is installed (or reinstall leaves a non-viable/stale manager), the explicit
1181
+ * path always falls through to a direct `ocx start --port` so /healthz can recover.
682
1182
  */
683
1183
  export async function finishGuiUpdateRestart(
684
1184
  job: UpdateJobState,
@@ -689,28 +1189,53 @@ export async function finishGuiUpdateRestart(
689
1189
  if (installer === "npm") {
690
1190
  const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
691
1191
  if (serviceInstalled) {
692
- const already = await awaitRestartedProxyHealthy(job, captured, io);
693
- if (already.ok) {
694
- const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
695
- captured.port,
696
- captured.hostname,
697
- );
698
- const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
699
- if (evidence.ok) {
1192
+ // Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the
1193
+ // full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind.
1194
+ // If nothing live owns the port, skip straight to explicit restart. A failed
1195
+ // listener scan must not look like "no listeners" — fall back to /healthz.
1196
+ const aliveFn = io.isAliveFn ?? isProcessAlive;
1197
+ const scan: ListenPidScan = io.scanListenPidsFn
1198
+ ? io.scanListenPidsFn(captured.port)
1199
+ : io.listListenPidsFn
1200
+ // Test seam: injected list is always a successful scan.
1201
+ ? { ok: true, pids: io.listListenPidsFn(captured.port) }
1202
+ : scanListenPids(captured.port);
1203
+ const liveListeners = scan.ok
1204
+ ? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
1205
+ : null;
1206
+ if (liveListeners !== null && liveListeners.length === 0) {
1207
+ updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart...");
1208
+ } else {
1209
+ if (!scan.ok) {
700
1210
  updateJob(
701
1211
  job,
702
1212
  {},
703
- `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`,
1213
+ "Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...",
704
1214
  );
705
- return true;
706
1215
  }
707
- updateJob(
708
- job,
709
- {},
710
- `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
711
- );
712
- } else {
713
- updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart...");
1216
+ const already = await awaitRestartedProxyHealthy(job, captured, io);
1217
+ if (already.ok) {
1218
+ const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
1219
+ captured.port,
1220
+ captured.hostname,
1221
+ );
1222
+ const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
1223
+ if (evidence.ok) {
1224
+ updateJob(
1225
+ job,
1226
+ {},
1227
+ `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`,
1228
+ );
1229
+ return true;
1230
+ }
1231
+ updateJob(
1232
+ job,
1233
+ {},
1234
+ `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
1235
+ );
1236
+ } else {
1237
+ updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart...");
1238
+ }
714
1239
  }
715
1240
  }
716
1241
  }