@bitkyc08/opencodex 2.7.42 → 2.7.43

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 (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Detect / optionally terminate long-lived Codex app-server processes that keep an
3
+ * in-memory model catalog after `ocx sync` rewrites on-disk files (#476).
4
+ *
5
+ * Matching is intentionally narrow: require `app-server` as the Codex subcommand
6
+ * (not merely as a substring in some later argument) or `codex-code-mode-host`.
7
+ * Never match broad `*codex*` patterns that hit unrelated tools such as
8
+ * `hermes-codex-bridge-mcp`.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
+ import { isProcessAlive, waitForExit } from "../lib/process-control";
13
+
14
+ export const STALE_CODEX_APP_SERVER_HINT =
15
+ "If Codex still shows an older model list, restart its long-lived app-server process after sync (ocx sync --restart-codex).";
16
+
17
+ /** Attach the shared dashboard hint only after a catalog or models_cache write. */
18
+ export function attachStaleAppServerHint<T extends {
19
+ catalogWritten: boolean;
20
+ cacheSynced: boolean;
21
+ }>(result: T): T & { staleAppServerHint?: string } {
22
+ if (result.catalogWritten || result.cacheSynced) {
23
+ return { ...result, staleAppServerHint: STALE_CODEX_APP_SERVER_HINT };
24
+ }
25
+ return { ...result };
26
+ }
27
+ /**
28
+ * Rust-style target-triple body on official platform-baked Codex binaries
29
+ * (e.g. `x86_64-unknown-linux-musl`, `aarch64-apple-darwin`,
30
+ * `x86_64-pc-windows-msvc`). Requires arch-vendor-os with an optional env
31
+ * segment — not a broad `codex-*` wildcard.
32
+ */
33
+ const CODEX_TARGET_TRIPLE_BODY = "[a-z0-9_]+-[a-z0-9_]+-[a-z0-9_]+(?:-[a-z0-9_]+)?";
34
+
35
+ /**
36
+ * Narrow Win32_Process CommandLine pre-filter (JS + .NET compatible).
37
+ * Allows an optional closing quote after the executable basename so paths like
38
+ * `"C:\Program Files\...\codex.exe" app-server` still reach GetOwner.
39
+ * Also admits official target-triple basenames such as
40
+ * `codex-x86_64-pc-windows-msvc.exe`.
41
+ */
42
+ export const WINDOWS_CODEX_BASENAME_CANDIDATE_RE = new RegExp(
43
+ `(^|[/\\\\\\s'"=])codex(-${CODEX_TARGET_TRIPLE_BODY})?([.]exe|[.]cmd)?['"]?(\\s|$)`,
44
+ "i",
45
+ );
46
+
47
+ export const WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE = /codex-code-mode-host/i;
48
+
49
+ /** Basename of an official Codex release binary (plain or target-triple). */
50
+ const CODEX_TARGET_TRIPLE_BASENAME_RE = new RegExp(
51
+ `^codex-${CODEX_TARGET_TRIPLE_BODY}(?:\\.exe|\\.cmd)?$`,
52
+ );
53
+
54
+ /** True when a Windows CommandLine is worth paying GetOwner for (current-user scoped later). */
55
+ export function isWindowsCodexCandidateCommandLine(commandLine: string): boolean {
56
+ return WINDOWS_CODEX_BASENAME_CANDIDATE_RE.test(commandLine)
57
+ || WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.test(commandLine);
58
+ }
59
+
60
+ /** Embed a regex source in a PowerShell single-quoted -match operand (`''` escapes `'`). */
61
+ function powerShellSingleQuotedIgnoreCaseMatch(patternSource: string): string {
62
+ return `'(?i)${patternSource.replace(/'/g, "''")}'`;
63
+ }
64
+
65
+ export interface CodexAppServerProcess {
66
+ pid: number;
67
+ commandLine: string;
68
+ }
69
+
70
+ export interface ProcessSnapshot {
71
+ pid: number;
72
+ commandLine: string;
73
+ uid?: number;
74
+ owner?: string;
75
+ }
76
+
77
+ export interface CodexAppServerProcessIo {
78
+ platform?: NodeJS.Platform;
79
+ getuid?: () => number | undefined;
80
+ listSnapshots?: () => ProcessSnapshot[];
81
+ isAlive?: (pid: number) => boolean;
82
+ kill?: (pid: number, signal: NodeJS.Signals) => void;
83
+ waitExit?: (pid: number, timeoutMs: number) => boolean;
84
+ now?: () => number;
85
+ }
86
+
87
+ /** Split a process command line into argv-like tokens (handles simple quotes). */
88
+ export function tokenizeCommandLine(commandLine: string): string[] {
89
+ const tokens: string[] = [];
90
+ let current = "";
91
+ let quote: '"' | "'" | null = null;
92
+ for (let i = 0; i < commandLine.length; i++) {
93
+ const ch = commandLine[i]!;
94
+ if (quote) {
95
+ if (ch === quote) quote = null;
96
+ else current += ch;
97
+ continue;
98
+ }
99
+ if (ch === '"' || ch === "'") {
100
+ quote = ch;
101
+ continue;
102
+ }
103
+ if (/\s/.test(ch)) {
104
+ if (current) {
105
+ tokens.push(current);
106
+ current = "";
107
+ }
108
+ continue;
109
+ }
110
+ current += ch;
111
+ }
112
+ if (current) tokens.push(current);
113
+ return tokens;
114
+ }
115
+
116
+ function tokenBasename(token: string): string {
117
+ return token.toLowerCase().replace(/\\/g, "/").split("/").pop() ?? "";
118
+ }
119
+
120
+ function isCodexExecutableToken(token: string): boolean {
121
+ const base = tokenBasename(token);
122
+ return base === "codex" || base === "codex.exe" || base === "codex.cmd"
123
+ || CODEX_TARGET_TRIPLE_BASENAME_RE.test(base);
124
+ }
125
+
126
+ function isCodeModeHostToken(token: string): boolean {
127
+ const base = tokenBasename(token);
128
+ return base === "codex-code-mode-host" || base === "codex-code-mode-host.exe";
129
+ }
130
+
131
+ function isInterpreterToken(token: string): boolean {
132
+ const base = tokenBasename(token);
133
+ return base === "node" || base === "node.exe"
134
+ || base === "bun" || base === "bun.exe"
135
+ || base === "deno" || base === "deno.exe";
136
+ }
137
+
138
+ /**
139
+ * Codex global options that take a following value when written without `=`.
140
+ * Keep this list explicit so unknown flags stay boolean (narrow matching).
141
+ */
142
+ const CODEX_GLOBAL_OPTIONS_WITH_VALUE = new Set([
143
+ "--enable",
144
+ "--disable",
145
+ "--config",
146
+ "-c",
147
+ "--profile",
148
+ "-p",
149
+ "--model",
150
+ "-m",
151
+ "--sandbox",
152
+ "-s",
153
+ "--ask-for-approval",
154
+ "-a",
155
+ "--local-provider",
156
+ "--add-dir",
157
+ "--cd",
158
+ "-C",
159
+ "--color",
160
+ "--image",
161
+ "-i",
162
+ "--output-schema",
163
+ "--output-last-message",
164
+ "-o",
165
+ ]);
166
+
167
+ /** Parse a CLI option token into its flag name and whether a value is inline (`--opt=value`). */
168
+ function splitCliOptionToken(token: string): { name: string; hasInlineValue: boolean } | null {
169
+ if (!token.startsWith("-") || token === "-" || token === "--") return null;
170
+ if (token.startsWith("--")) {
171
+ const eq = token.indexOf("=");
172
+ if (eq >= 0) return { name: token.slice(0, eq).toLowerCase(), hasInlineValue: true };
173
+ return { name: token.toLowerCase(), hasInlineValue: false };
174
+ }
175
+ // Preserve short-option case: `-c` (config) vs `-C` (cd).
176
+ const eq = token.indexOf("=");
177
+ if (eq >= 0) return { name: token.slice(0, eq), hasInlineValue: true };
178
+ return { name: token, hasInlineValue: false };
179
+ }
180
+
181
+ /** Advance past one argv token, consuming a value for known Codex global options. */
182
+ function advancePastCodexGlobalOption(tokens: readonly string[], index: number): number {
183
+ const option = splitCliOptionToken(tokens[index]!);
184
+ if (!option) return index + 1;
185
+ let next = index + 1;
186
+ if (
187
+ !option.hasInlineValue
188
+ && CODEX_GLOBAL_OPTIONS_WITH_VALUE.has(option.name)
189
+ && next < tokens.length
190
+ && !tokens[next]!.startsWith("-")
191
+ ) {
192
+ next += 1; // skip the option value
193
+ }
194
+ return next;
195
+ }
196
+
197
+ /** True when code-mode-host is the process executable or interpreter entrypoint, not a later arg. */
198
+ function isCodeModeHostProcess(tokens: readonly string[]): boolean {
199
+ if (tokens.length === 0) return false;
200
+ if (isCodeModeHostToken(tokens[0]!)) return true;
201
+ return isInterpreterToken(tokens[0]!) && tokens.length > 1 && isCodeModeHostToken(tokens[1]!);
202
+ }
203
+
204
+ /** Stable identity for PID reuse checks: pid + normalized command line. */
205
+ export function codexAppServerProcessIdentity(proc: Pick<CodexAppServerProcess, "pid" | "commandLine">): string {
206
+ return `${proc.pid}\0${proc.commandLine.trim().replace(/\s+/g, " ")}`;
207
+ }
208
+
209
+ /** True when the command line is a Codex app-server (or code-mode host) worth restarting. */
210
+ export function isCodexAppServerCommandLine(commandLine: string): boolean {
211
+ const tokens = tokenizeCommandLine(commandLine.trim());
212
+ if (tokens.length === 0) return false;
213
+ if (isCodeModeHostProcess(tokens)) return true;
214
+
215
+ // Require Codex as argv0 so later-argument occurrences stay unmatched
216
+ // (e.g. `node worker.js codex app-server`).
217
+ if (!isCodexExecutableToken(tokens[0]!)) return false;
218
+
219
+ let i = 1;
220
+ while (i < tokens.length) {
221
+ const token = tokens[i]!;
222
+ if (token.startsWith("-")) {
223
+ i = advancePastCodexGlobalOption(tokens, i);
224
+ continue;
225
+ }
226
+ // First non-option after globals is the Codex subcommand.
227
+ return token.toLowerCase() === "app-server";
228
+ }
229
+ return false;
230
+ }
231
+
232
+ function parseUnixProcStatusUid(status: string): number | undefined {
233
+ const match = /^Uid:\s+(\d+)/m.exec(status);
234
+ if (!match) return undefined;
235
+ const uid = Number(match[1]);
236
+ return Number.isSafeInteger(uid) ? uid : undefined;
237
+ }
238
+
239
+ function listUnixProcSnapshots(uid: number | undefined): ProcessSnapshot[] {
240
+ if (!existsSync("/proc")) return [];
241
+ const out: ProcessSnapshot[] = [];
242
+ for (const ent of readdirSync("/proc")) {
243
+ if (!/^\d+$/.test(ent)) continue;
244
+ const pid = Number(ent);
245
+ if (!Number.isSafeInteger(pid) || pid <= 1) continue;
246
+ try {
247
+ const status = readFileSync(`/proc/${pid}/status`, "utf8");
248
+ const processUid = parseUnixProcStatusUid(status);
249
+ if (uid !== undefined && processUid !== undefined && processUid !== uid) continue;
250
+ const commandLine = readFileSync(`/proc/${pid}/cmdline`)
251
+ .toString("utf8")
252
+ .replace(/\0/g, " ")
253
+ .trim();
254
+ if (!commandLine) continue;
255
+ out.push({ pid, commandLine, uid: processUid });
256
+ } catch {
257
+ /* process exited mid-scan */
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ function listDarwinSnapshots(uid: number | undefined): ProcessSnapshot[] {
264
+ const out: ProcessSnapshot[] = [];
265
+ try {
266
+ const output = uid !== undefined
267
+ ? execFileSync("ps", ["-u", String(uid), "-o", "pid=,command="], {
268
+ encoding: "utf-8",
269
+ stdio: ["ignore", "pipe", "ignore"],
270
+ timeout: 8_000,
271
+ })
272
+ : execFileSync("ps", ["-axo", "pid=,uid=,command="], {
273
+ encoding: "utf-8",
274
+ stdio: ["ignore", "pipe", "ignore"],
275
+ timeout: 8_000,
276
+ });
277
+ for (const raw of output.split(/\r?\n/)) {
278
+ const line = raw.trim();
279
+ if (!line) continue;
280
+ if (uid !== undefined) {
281
+ const match = /^(\d+)\s+(.*)$/.exec(line);
282
+ if (!match) continue;
283
+ const pid = Number(match[1]);
284
+ const commandLine = match[2]?.trim() ?? "";
285
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue;
286
+ out.push({ pid, commandLine, uid });
287
+ continue;
288
+ }
289
+ const match = /^(\d+)\s+(\d+)\s+(.*)$/.exec(line);
290
+ if (!match) continue;
291
+ const pid = Number(match[1]);
292
+ const processUid = Number(match[2]);
293
+ const commandLine = match[3]?.trim() ?? "";
294
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine) continue;
295
+ out.push({ pid, commandLine, uid: Number.isSafeInteger(processUid) ? processUid : undefined });
296
+ }
297
+ } catch {
298
+ return out;
299
+ }
300
+ return out;
301
+ }
302
+
303
+ /**
304
+ * Windows snapshots scoped to the invoking user via Win32_Process GetOwner.
305
+ * PowerShell is the sole path: WMIC lacks reliable owner data and is disabled on
306
+ * many Windows 11 installs; returning unscoped rows would contradict the
307
+ * current-user restart contract.
308
+ *
309
+ * CIM instance methods must use Invoke-CimMethod (direct .GetOwner() calls fail).
310
+ * Candidates are pre-filtered to Codex basename / code-mode-host command lines
311
+ * so we do not pay GetOwner per every process on the machine.
312
+ * Exported for the Windows integration regression that exercises the real
313
+ * PowerShell enumeration.
314
+ */
315
+ export function listWindowsSnapshots(): ProcessSnapshot[] {
316
+ const out: ProcessSnapshot[] = [];
317
+ // Newlines keep -Command as a real script (space-joined statements need ';').
318
+ // Double-quoted format string so `t expands to a real tab.
319
+ // Codex candidates only: basename token codex / codex.exe / codex.cmd /
320
+ // official target-triple binaries (optional closing quote after the
321
+ // basename), or code-mode-host — not incidental substrings like a repo
322
+ // path with "opencodex".
323
+ const basenameMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_BASENAME_CANDIDATE_RE.source);
324
+ const codeModeMatch = powerShellSingleQuotedIgnoreCaseMatch(WINDOWS_CODEX_CODE_MODE_HOST_CANDIDATE_RE.source);
325
+ const psCommand = [
326
+ "$ErrorActionPreference='SilentlyContinue'",
327
+ "$me=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name",
328
+ "Get-CimInstance Win32_Process | Where-Object {",
329
+ " -not [string]::IsNullOrWhiteSpace($_.CommandLine) -and (",
330
+ ` $_.CommandLine -match ${basenameMatch} -or`,
331
+ ` $_.CommandLine -match ${codeModeMatch}`,
332
+ " )",
333
+ "} | ForEach-Object {",
334
+ " try {",
335
+ " $o=Invoke-CimMethod -InputObject $_ -MethodName GetOwner -ErrorAction Stop",
336
+ " if($null -eq $o -or $o.ReturnValue -ne 0 -or [string]::IsNullOrWhiteSpace($o.User)){return}",
337
+ " $owner=if($o.Domain){\"$($o.Domain)\\$($o.User)\"}else{$o.User}",
338
+ " if($owner -ine $me){return}",
339
+ " $cmd=($_.CommandLine -replace \"`t\",\" \")",
340
+ " \"{0}`t{1}`t{2}\" -f $_.ProcessId, $cmd, $owner",
341
+ " } catch { }",
342
+ "}",
343
+ ].join("\n");
344
+ try {
345
+ const output = execFileSync("powershell.exe", [
346
+ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
347
+ "-Command",
348
+ psCommand,
349
+ ], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 12_000, windowsHide: true });
350
+ for (const line of output.split(/\r?\n/)) {
351
+ const tab = line.indexOf("\t");
352
+ if (tab <= 0) continue;
353
+ const tab2 = line.indexOf("\t", tab + 1);
354
+ if (tab2 <= tab) continue;
355
+ const pid = Number(line.slice(0, tab));
356
+ const commandLine = line.slice(tab + 1, tab2).trim();
357
+ const owner = line.slice(tab2 + 1).trim();
358
+ if (!Number.isSafeInteger(pid) || pid <= 1 || !commandLine || !owner) continue;
359
+ out.push({ pid, commandLine, owner });
360
+ }
361
+ } catch {
362
+ return out;
363
+ }
364
+ return out;
365
+ }
366
+
367
+ function defaultListSnapshots(platform: NodeJS.Platform, getuid: () => number | undefined): ProcessSnapshot[] {
368
+ if (platform === "win32") return listWindowsSnapshots();
369
+ if (platform === "darwin") return listDarwinSnapshots(getuid());
370
+ return listUnixProcSnapshots(getuid());
371
+ }
372
+
373
+ export function listCodexAppServerProcesses(io: CodexAppServerProcessIo = {}): CodexAppServerProcess[] {
374
+ const platform = io.platform ?? process.platform;
375
+ const getuid = io.getuid ?? (() => {
376
+ try {
377
+ return typeof process.getuid === "function" ? process.getuid() : undefined;
378
+ } catch {
379
+ return undefined;
380
+ }
381
+ });
382
+ const snapshots = io.listSnapshots?.() ?? defaultListSnapshots(platform, getuid);
383
+ const seen = new Set<number>();
384
+ const matched: CodexAppServerProcess[] = [];
385
+ for (const snapshot of snapshots) {
386
+ if (seen.has(snapshot.pid)) continue;
387
+ if (!isCodexAppServerCommandLine(snapshot.commandLine)) continue;
388
+ seen.add(snapshot.pid);
389
+ matched.push({ pid: snapshot.pid, commandLine: snapshot.commandLine });
390
+ }
391
+ return matched;
392
+ }
393
+
394
+ export function formatStaleCodexAppServerWarning(processes: readonly CodexAppServerProcess[]): string {
395
+ const pids = processes.map(process => process.pid).join(", ");
396
+ return (
397
+ `WARNING: ${processes.length} Codex app-server process(es) still running (PID${processes.length === 1 ? "" : "s"}: ${pids}). `
398
+ + "Disk catalog/cache were updated, but Codex may keep showing the old model list until those processes restart. "
399
+ + "Re-run with `ocx sync --restart-codex` (or `ocx sync-cache --restart-codex`) to send SIGTERM only to matching app-server processes. "
400
+ + "Active turns may be interrupted."
401
+ );
402
+ }
403
+
404
+ export interface RestartCodexAppServersResult {
405
+ requested: number[];
406
+ stopped: number[];
407
+ surviving: number[];
408
+ failed: Array<{ pid: number; error: string }>;
409
+ }
410
+
411
+ /** Send SIGTERM to matched processes and wait briefly; never escalates to SIGKILL. */
412
+ export function restartCodexAppServers(
413
+ processes: readonly CodexAppServerProcess[] = listCodexAppServerProcesses(),
414
+ io: CodexAppServerProcessIo = {},
415
+ ): RestartCodexAppServersResult {
416
+ const isAlive = io.isAlive ?? isProcessAlive;
417
+ const kill = io.kill ?? ((pid, signal) => { process.kill(pid, signal); });
418
+ const wait = io.waitExit ?? waitForExit;
419
+ const now = io.now ?? Date.now;
420
+ const requested = processes.map(process => process.pid);
421
+ const stopped: number[] = [];
422
+ const surviving: number[] = [];
423
+ const failed: Array<{ pid: number; error: string }> = [];
424
+
425
+ // Re-resolve immediately before signaling so a recycled PID is never killed.
426
+ // Require the same pid+command-line identity as the original match — a new
427
+ // Codex-shaped process that reused the PID must not receive SIGTERM.
428
+ const liveByPid = new Map(
429
+ listCodexAppServerProcesses(io).map(process => [process.pid, process] as const),
430
+ );
431
+ const signaled: CodexAppServerProcess[] = [];
432
+
433
+ for (const proc of processes) {
434
+ const live = liveByPid.get(proc.pid);
435
+ if (!live || codexAppServerProcessIdentity(live) !== codexAppServerProcessIdentity(proc)) {
436
+ // Original target exited (or identity changed); do not signal a replacement.
437
+ if (!isAlive(proc.pid)) stopped.push(proc.pid);
438
+ continue;
439
+ }
440
+ try {
441
+ kill(proc.pid, "SIGTERM");
442
+ signaled.push(proc);
443
+ } catch (error) {
444
+ if (isAlive(proc.pid)) {
445
+ failed.push({
446
+ pid: proc.pid,
447
+ error: error instanceof Error ? error.message : String(error),
448
+ });
449
+ surviving.push(proc.pid);
450
+ } else {
451
+ stopped.push(proc.pid);
452
+ }
453
+ }
454
+ }
455
+
456
+ // Shared deadline so N survivors wait ~2s total, not N×2s.
457
+ const deadline = now() + 2_000;
458
+ for (const proc of signaled) {
459
+ const remaining = Math.max(0, deadline - now());
460
+ if (wait(proc.pid, remaining) || !isAlive(proc.pid)) stopped.push(proc.pid);
461
+ else surviving.push(proc.pid);
462
+ }
463
+
464
+ return { requested, stopped, surviving, failed };
465
+ }
466
+
467
+ export interface AfterCatalogWriteAppServerOptions {
468
+ restart: boolean;
469
+ log?: Pick<Console, "log" | "error"> | null;
470
+ io?: CodexAppServerProcessIo;
471
+ }
472
+
473
+ export interface AfterCatalogWriteAppServerResult {
474
+ processes: CodexAppServerProcess[];
475
+ warned: boolean;
476
+ restart?: RestartCodexAppServersResult;
477
+ hint: string;
478
+ }
479
+
480
+ /** Warn about stale app-servers after catalog/cache writes, or restart them when requested. */
481
+ export function afterCatalogWriteHandleAppServers(
482
+ options: AfterCatalogWriteAppServerOptions,
483
+ ): AfterCatalogWriteAppServerResult {
484
+ const processes = listCodexAppServerProcesses(options.io);
485
+ const hint = STALE_CODEX_APP_SERVER_HINT;
486
+ if (processes.length === 0) {
487
+ return { processes, warned: false, hint };
488
+ }
489
+ if (!options.restart) {
490
+ options.log?.error(formatStaleCodexAppServerWarning(processes));
491
+ return { processes, warned: true, hint };
492
+ }
493
+ options.log?.log(
494
+ `Stopping Codex app-server process(es): ${processes.map(process => process.pid).join(", ")} `
495
+ + "(active turns may be interrupted).",
496
+ );
497
+ const restart = restartCodexAppServers(processes, options.io);
498
+ if (restart.stopped.length > 0) {
499
+ options.log?.log(`Stopped Codex app-server PID(s): ${restart.stopped.join(", ")}`);
500
+ }
501
+ for (const failure of restart.failed) {
502
+ options.log?.error(`Failed to stop Codex app-server PID ${failure.pid}: ${failure.error}`);
503
+ }
504
+ if (restart.surviving.length > 0) {
505
+ options.log?.error(
506
+ `Codex app-server PID(s) still running after SIGTERM: ${restart.surviving.join(", ")}. `
507
+ + "Stop them manually if the model list stays stale.",
508
+ );
509
+ }
510
+ return { processes, warned: false, restart, hint };
511
+ }