@bitkyc08/opencodex 2.5.6 → 2.6.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 (51) hide show
  1. package/README.ko.md +17 -6
  2. package/README.md +19 -7
  3. package/README.zh-CN.md +12 -3
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +88 -2
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-BS4X1QDi.js +9 -0
  10. package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
  11. package/gui/dist/index.html +2 -2
  12. package/package.json +20 -6
  13. package/src/adapters/anthropic.ts +16 -5
  14. package/src/adapters/google.ts +9 -2
  15. package/src/adapters/openai-chat.ts +13 -5
  16. package/src/bun-runtime.ts +22 -1
  17. package/src/cli-help.ts +111 -0
  18. package/src/cli-status.ts +164 -0
  19. package/src/cli.ts +77 -186
  20. package/src/codex-account-store.ts +47 -8
  21. package/src/codex-auth-api.ts +111 -54
  22. package/src/codex-auth-collision.ts +5 -0
  23. package/src/codex-catalog.ts +24 -12
  24. package/src/codex-history-provider.ts +29 -13
  25. package/src/codex-inject.ts +46 -29
  26. package/src/codex-journal.ts +77 -13
  27. package/src/codex-quota.ts +11 -3
  28. package/src/codex-routing.ts +14 -4
  29. package/src/codex-shim.ts +71 -24
  30. package/src/codex-websocket-registry.ts +20 -4
  31. package/src/config.ts +138 -4
  32. package/src/init.ts +7 -2
  33. package/src/oauth/callback-server.ts +22 -15
  34. package/src/oauth/index.ts +18 -4
  35. package/src/oauth/login-cli.ts +8 -1
  36. package/src/oauth/store.ts +2 -1
  37. package/src/process-control.ts +36 -0
  38. package/src/provider-label.ts +8 -0
  39. package/src/responses/parser.ts +18 -1
  40. package/src/router.ts +61 -5
  41. package/src/server.ts +878 -94
  42. package/src/service-secrets.ts +6 -0
  43. package/src/service.ts +293 -28
  44. package/src/types.ts +26 -1
  45. package/src/update.ts +16 -9
  46. package/src/usage-debug.ts +65 -0
  47. package/src/usage-log.ts +62 -0
  48. package/src/usage-summary.ts +0 -0
  49. package/src/ws-bridge.ts +2 -2
  50. package/gui/README.md +0 -73
  51. package/gui/dist/assets/index-CSUvRNAX.js +0 -9
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { atomicWriteFile } from "./config";
@@ -9,11 +10,26 @@ interface Journal {
9
10
  version: 1;
10
11
  originalConfig: string;
11
12
  originalProfile: string | null;
13
+ injectedConfigHash?: string;
14
+ injectedProfileHash?: string | null;
12
15
  pid: number;
13
16
  timestamp: string;
14
17
  }
15
18
 
19
+ interface RestoreJournalResult {
20
+ configRestored: boolean;
21
+ profileRestored: boolean;
22
+ configChanged: boolean;
23
+ profileChanged: boolean;
24
+ complete: boolean;
25
+ }
26
+
27
+ function sha256(content: string | null): string | null {
28
+ return content === null ? null : createHash("sha256").update(content).digest("hex");
29
+ }
30
+
16
31
  export function writeJournal(): void {
32
+ if (existsSync(JOURNAL_PATH) && readJournal()) return;
17
33
  if (!existsSync(CODEX_CONFIG_PATH)) return;
18
34
  const config = readFileSync(CODEX_CONFIG_PATH, "utf-8");
19
35
  const profile = existsSync(CODEX_PROFILE_PATH)
@@ -29,20 +45,73 @@ export function writeJournal(): void {
29
45
  atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
30
46
  }
31
47
 
48
+ export function markJournalInjectedState(config: string, profile: string | null): void {
49
+ const journal = readJournal();
50
+ if (!journal) return;
51
+ if (journal.injectedConfigHash) return;
52
+ journal.injectedConfigHash = sha256(config) ?? undefined;
53
+ journal.injectedProfileHash = sha256(profile);
54
+ atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
55
+ }
56
+
32
57
  export function removeJournal(): void {
33
58
  try { unlinkSync(JOURNAL_PATH); } catch { /* ignore */ }
34
59
  }
35
60
 
36
- export function reconcileJournal(): boolean {
37
- if (!existsSync(JOURNAL_PATH)) return false;
38
- let journal: Journal;
61
+ function readJournal(): Journal | null {
62
+ if (!existsSync(JOURNAL_PATH)) return null;
39
63
  try {
40
- journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8"));
64
+ const journal = JSON.parse(readFileSync(JOURNAL_PATH, "utf-8")) as Journal;
41
65
  if (journal.version !== 1) throw new Error("unknown version");
66
+ return journal;
42
67
  } catch {
43
68
  removeJournal();
44
- return false;
69
+ return null;
70
+ }
71
+ }
72
+
73
+ export function restoreJournalState(): RestoreJournalResult {
74
+ const journal = readJournal();
75
+ if (!journal) {
76
+ return { configRestored: false, profileRestored: false, configChanged: false, profileChanged: false, complete: false };
45
77
  }
78
+ const currentConfig = existsSync(CODEX_CONFIG_PATH) ? readFileSync(CODEX_CONFIG_PATH, "utf-8") : "";
79
+ const currentProfile = existsSync(CODEX_PROFILE_PATH) ? readFileSync(CODEX_PROFILE_PATH, "utf-8") : null;
80
+ const configUnchanged = !journal.injectedConfigHash || sha256(currentConfig) === journal.injectedConfigHash;
81
+ const profileUnchanged = journal.injectedProfileHash === undefined || sha256(currentProfile) === (journal.injectedProfileHash ?? null);
82
+
83
+ let configRestored = false;
84
+ let profileRestored = false;
85
+ if (configUnchanged) {
86
+ atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8"));
87
+ configRestored = true;
88
+ }
89
+ if (profileUnchanged) {
90
+ if (journal.originalProfile !== null) {
91
+ atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8"));
92
+ } else if (existsSync(CODEX_PROFILE_PATH)) {
93
+ try { unlinkSync(CODEX_PROFILE_PATH); } catch { /* ignore */ }
94
+ }
95
+ profileRestored = true;
96
+ }
97
+ const complete = configRestored && profileRestored;
98
+ if (complete) removeJournal();
99
+ return {
100
+ configRestored,
101
+ profileRestored,
102
+ configChanged: !configUnchanged,
103
+ profileChanged: !profileUnchanged,
104
+ complete,
105
+ };
106
+ }
107
+
108
+ export function restoreJournal(): boolean {
109
+ return restoreJournalState().complete;
110
+ }
111
+
112
+ export function reconcileJournal(): boolean {
113
+ const journal = readJournal();
114
+ if (!journal) return false;
46
115
  try {
47
116
  process.kill(journal.pid, 0);
48
117
  return false;
@@ -51,13 +120,8 @@ export function reconcileJournal(): boolean {
51
120
  return false;
52
121
  }
53
122
  }
54
- atomicWriteFile(CODEX_CONFIG_PATH, Buffer.from(journal.originalConfig, "base64").toString("utf-8"));
55
- if (journal.originalProfile !== null) {
56
- atomicWriteFile(CODEX_PROFILE_PATH, Buffer.from(journal.originalProfile, "base64").toString("utf-8"));
57
- } else if (existsSync(CODEX_PROFILE_PATH)) {
58
- try { unlinkSync(CODEX_PROFILE_PATH); } catch { /* ignore */ }
59
- }
60
- removeJournal();
61
- console.error(`⚠️ Previous session (PID ${journal.pid}) did not shut down cleanly. Codex config restored from journal.`);
123
+ const restored = restoreJournalState();
124
+ if (!restored.configRestored && !restored.profileRestored) return false;
125
+ console.error(`⚠️ Previous session (PID ${journal.pid}) did not shut down cleanly. Codex state restored from journal.`);
62
126
  return true;
63
127
  }
@@ -121,21 +121,29 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
121
121
  }
122
122
 
123
123
  const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
124
+ const thirtyDayOnly = data.plan_type?.trim().toLowerCase() === "go" || data.plan_type?.trim().toLowerCase() === "free";
124
125
  const weeklyPercent = normalizeUsagePercent(data.rate_limit.secondary_window?.used_percent);
125
126
  const fiveHourPercent = normalizeUsagePercent(data.rate_limit.primary_window?.used_percent);
126
127
  const monthlyPercent = normalizeUsagePercent(data.rate_limit.tertiary_window?.used_percent);
127
128
  const weeklyResetAt = normalizeResetAt(data.rate_limit.secondary_window?.reset_at);
128
129
  const fiveHourResetAt = normalizeResetAt(data.rate_limit.primary_window?.reset_at);
129
130
  const monthlyResetAt = normalizeResetAt(data.rate_limit.tertiary_window?.reset_at);
130
- if (weeklyPercent !== undefined) {
131
+ if (thirtyDayOnly) {
132
+ const goMonthlyPercent = monthlyPercent ?? fiveHourPercent;
133
+ const goMonthlyResetAt = monthlyResetAt ?? fiveHourResetAt;
134
+ if (goMonthlyPercent !== undefined) {
135
+ quota.monthlyPercent = goMonthlyPercent;
136
+ if (goMonthlyResetAt !== undefined) quota.monthlyResetAt = goMonthlyResetAt;
137
+ }
138
+ } else if (weeklyPercent !== undefined) {
131
139
  quota.weeklyPercent = weeklyPercent;
132
140
  if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
133
141
  }
134
- if (fiveHourPercent !== undefined) {
142
+ if (!thirtyDayOnly && fiveHourPercent !== undefined) {
135
143
  quota.fiveHourPercent = fiveHourPercent;
136
144
  if (fiveHourResetAt !== undefined) quota.fiveHourResetAt = fiveHourResetAt;
137
145
  }
138
- if (monthlyPercent !== undefined) {
146
+ if (!thirtyDayOnly && monthlyPercent !== undefined) {
139
147
  quota.monthlyPercent = monthlyPercent;
140
148
  if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
141
149
  }
@@ -74,8 +74,14 @@ export function computeCodexUsageScore(quota: {
74
74
  weeklyPercent?: number;
75
75
  fiveHourPercent?: number;
76
76
  monthlyPercent?: number;
77
- } | null): number {
77
+ } | null, plan?: string | null): number {
78
78
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
79
+ const normalizedPlan = plan?.trim().toLowerCase();
80
+ if (normalizedPlan === "go" || normalizedPlan === "free") {
81
+ return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
82
+ ? quota.monthlyPercent
83
+ : CODEX_UNKNOWN_USAGE_SCORE;
84
+ }
79
85
  const values = [quota.weeklyPercent, quota.fiveHourPercent, quota.monthlyPercent]
80
86
  .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
81
87
  return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
@@ -204,11 +210,15 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da
204
210
  .map(account => account.id);
205
211
  }
206
212
 
213
+ function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined {
214
+ return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan;
215
+ }
216
+
207
217
  function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: number, now: number): string {
208
218
  let best = active;
209
219
  let bestUsage = activeUsage;
210
220
  for (const id of getEligiblePoolAccounts(config, active, now)) {
211
- const usage = computeCodexUsageScore(getAccountQuota(id));
221
+ const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
212
222
  if (usage < bestUsage) {
213
223
  best = id;
214
224
  bestUsage = usage;
@@ -221,7 +231,7 @@ export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: strin
221
231
  let best: string | null = null;
222
232
  let bestUsage = Number.POSITIVE_INFINITY;
223
233
  for (const id of getEligiblePoolAccounts(config, excludeId, now)) {
224
- const usage = computeCodexUsageScore(getAccountQuota(id));
234
+ const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
225
235
  if (usage < bestUsage) {
226
236
  best = id;
227
237
  bestUsage = usage;
@@ -240,7 +250,7 @@ function applyQuotaAutoSwitch(config: OcxConfig, active: string, now: number): s
240
250
  const threshold = config.autoSwitchThreshold ?? 80;
241
251
  if (threshold <= 0) return active;
242
252
  const quota = getAccountQuota(active);
243
- const activeUsage = computeCodexUsageScore(quota);
253
+ const activeUsage = computeCodexUsageScore(quota, getPoolAccountPlan(config, active));
244
254
  if (activeUsage < threshold) return active;
245
255
  const best = pickLowerUsageAccount(config, active, activeUsage, now);
246
256
  if (best !== active) setActiveCodexAccount(config, best);
package/src/codex-shim.ts CHANGED
@@ -2,8 +2,10 @@ import { delimiter, dirname, extname, join } from "node:path";
2
2
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { getConfigDir } from "./config";
4
4
  import { durableBunPath } from "./bun-runtime";
5
+ import { serviceApiTokenFilePath } from "./service-secrets";
5
6
 
6
7
  const SHIM_MARKER = "opencodex codex autostart shim";
8
+ let lastShimDiscoveryError: string | null = null;
7
9
  const CODEX_INTERNAL_COMMANDS = [
8
10
  "app-server",
9
11
  "archive",
@@ -13,7 +15,6 @@ const CODEX_INTERNAL_COMMANDS = [
13
15
  "debug",
14
16
  "delete",
15
17
  "doctor",
16
- "exec",
17
18
  "exec-server",
18
19
  "features",
19
20
  "fork",
@@ -22,8 +23,6 @@ const CODEX_INTERNAL_COMMANDS = [
22
23
  "logout",
23
24
  "mcp",
24
25
  "plugin",
25
- "resume",
26
- "review",
27
26
  "sandbox",
28
27
  "unarchive",
29
28
  "update",
@@ -41,6 +40,8 @@ interface ShimFileState {
41
40
  wrapperPath: string;
42
41
  originalPath: string;
43
42
  backupPath: string;
43
+ realPath?: string;
44
+ preserveOnly?: boolean;
44
45
  }
45
46
 
46
47
  function cliEntry(): { bun: string; cli: string } {
@@ -79,7 +80,20 @@ function findCodexOnPath(): string | null {
79
80
  }
80
81
 
81
82
  function findWindowsCodexTargets(): ShimFileState[] | null {
83
+ lastShimDiscoveryError = null;
82
84
  for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
85
+ const exe = join(dir, "codex.exe");
86
+ if (existsSync(exe) && !isShim(exe)) {
87
+ try {
88
+ if (!lstatSync(exe).isDirectory()) {
89
+ lastShimDiscoveryError =
90
+ `Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
91
+ "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.";
92
+ return null;
93
+ }
94
+ } catch { /* keep scanning */ }
95
+ }
96
+
83
97
  const cmd = join(dir, "codex.cmd");
84
98
  const ps1 = join(dir, "codex.ps1");
85
99
  const targets: ShimFileState[] = [];
@@ -92,15 +106,6 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
92
106
  } catch { /* keep scanning */ }
93
107
  }
94
108
  if (targets.length > 0) return targets;
95
-
96
- const exe = join(dir, "codex.exe");
97
- if (!existsSync(exe) || isShim(exe)) continue;
98
- try {
99
- if (lstatSync(exe).isDirectory()) continue;
100
- const wrapperPath = join(dir, "codex.cmd");
101
- if (existsSync(wrapperPath) && !isShim(wrapperPath)) continue;
102
- return [{ wrapperPath, originalPath: exe, backupPath: backupPathFor(exe) }];
103
- } catch { /* keep scanning */ }
104
109
  }
105
110
  return null;
106
111
  }
@@ -110,36 +115,62 @@ function backupPathFor(path: string): string {
110
115
  return ext ? `${path.slice(0, -ext.length)}.opencodex-real${ext}` : `${path}.opencodex-real`;
111
116
  }
112
117
 
118
+ function shQuote(value: string): string {
119
+ return `'${value.replace(/'/g, "'\\''")}'`;
120
+ }
121
+
113
122
  export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
114
123
  const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
124
+ const tokenFile = serviceApiTokenFilePath();
115
125
  return `#!/usr/bin/env sh
116
126
  # ${SHIM_MARKER}
127
+ if [ -z "$OPENCODEX_API_AUTH_TOKEN" ] && [ -f ${shQuote(tokenFile)} ]; then
128
+ OPENCODEX_API_AUTH_TOKEN="$(cat ${shQuote(tokenFile)})"
129
+ export OPENCODEX_API_AUTH_TOKEN
130
+ fi
117
131
  case "$1" in
118
132
  ${internalCommands}|--help|-h|--version|-V)
119
133
  ;;
120
134
  *)
121
135
  if [ -z "$OCX_SHIM_BYPASS" ]; then
122
- "${bunPath}" "${cliPath}" ensure >/dev/null 2>&1 || true
136
+ ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true
123
137
  fi
124
138
  ;;
125
139
  esac
126
- exec "${realCodexPath}" "$@"
140
+ exec ${shQuote(realCodexPath)} "$@"
127
141
  `;
128
142
  }
129
143
 
144
+ function windowsBatchValue(value: string): string {
145
+ return value
146
+ .replace(/%/g, "%%")
147
+ .replace(/\^/g, "^^")
148
+ .replace(/"/g, "")
149
+ .replace(/[\r\n]/g, "");
150
+ }
151
+
152
+ function windowsBatchSet(name: string, value: string): string {
153
+ return `set "${name}=${windowsBatchValue(value)}"`;
154
+ }
155
+
130
156
  export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
131
157
  const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n");
132
158
  return `@echo off\r
133
159
  rem ${SHIM_MARKER}\r
160
+ ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r
161
+ ${windowsBatchSet("OCX_BUN", bunPath)}\r
162
+ ${windowsBatchSet("OCX_CLI", cliPath)}\r
163
+ ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r
164
+ if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
134
165
  if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
135
166
  ${internalCommandChecks}\r
136
167
  if /I "%~1"=="--help" goto run_codex\r
137
168
  if /I "%~1"=="-h" goto run_codex\r
138
169
  if /I "%~1"=="--version" goto run_codex\r
139
170
  if /I "%~1"=="-V" goto run_codex\r
140
- "${bunPath}" "${cliPath}" ensure >nul 2>nul\r
171
+ "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r
141
172
  :run_codex\r
142
- "${realCodexPath}" %*\r
173
+ "%OCX_REAL_CODEX%" %*\r
143
174
  `;
144
175
  }
145
176
 
@@ -149,8 +180,12 @@ function psString(value: string): string {
149
180
 
150
181
  export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
151
182
  const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", ");
183
+ const tokenFile = serviceApiTokenFilePath();
152
184
  return `#!/usr/bin/env pwsh
153
185
  # ${SHIM_MARKER}
186
+ if (-not $env:OPENCODEX_API_AUTH_TOKEN -and (Test-Path -LiteralPath ${psString(tokenFile)})) {
187
+ $env:OPENCODEX_API_AUTH_TOKEN = (Get-Content -Raw -LiteralPath ${psString(tokenFile)}).Trim()
188
+ }
154
189
  $internalCommands = @(${internalCommands})
155
190
  $firstArg = if ($args.Count -gt 0) { [string]$args[0] } else { "" }
156
191
  $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $firstArg -or @("--help", "-h", "--version", "-V") -contains $firstArg
@@ -218,19 +253,26 @@ function replaceOwnedBackup(sourcePath: string, backupPath: string): void {
218
253
  }
219
254
 
220
255
  function refreshShimFile(file: ShimFileState): boolean {
256
+ if (file.preserveOnly) {
257
+ if (existsSync(file.originalPath) && !isShim(file.originalPath)) {
258
+ replaceOwnedBackup(file.originalPath, file.backupPath);
259
+ return true;
260
+ }
261
+ return false;
262
+ }
221
263
  if (existsSync(file.wrapperPath) && !isShim(file.wrapperPath)) {
222
264
  if (file.wrapperPath !== file.originalPath) return false;
223
265
  replaceOwnedBackup(file.wrapperPath, file.backupPath);
224
- writeShim(file.wrapperPath, file.backupPath);
266
+ writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
225
267
  return true;
226
268
  }
227
269
  if (!existsSync(file.wrapperPath) && existsSync(file.backupPath)) {
228
- writeShim(file.wrapperPath, file.backupPath);
270
+ writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
229
271
  return true;
230
272
  }
231
273
  if (file.originalPath !== file.wrapperPath && existsSync(file.originalPath) && existsSync(file.wrapperPath) && isShim(file.wrapperPath)) {
232
274
  replaceOwnedBackup(file.originalPath, file.backupPath);
233
- writeShim(file.wrapperPath, file.backupPath);
275
+ writeShim(file.wrapperPath, file.realPath ?? file.backupPath);
234
276
  return true;
235
277
  }
236
278
  return false;
@@ -242,7 +284,11 @@ export function installCodexShim(): { installed: boolean; message: string } {
242
284
  const files = stateFiles(existing);
243
285
  let refreshed = false;
244
286
  for (const file of files) refreshed = refreshShimFile(file) || refreshed;
245
- const allInstalled = files.every(file => existsSync(file.wrapperPath) && existsSync(file.backupPath) && isShim(file.wrapperPath));
287
+ const allInstalled = files.every(file => file.preserveOnly
288
+ ? existsSync(file.backupPath) && !existsSync(file.originalPath)
289
+ : existsSync(file.wrapperPath)
290
+ && (existsSync(file.backupPath) || (file.realPath ? existsSync(file.realPath) : false))
291
+ && isShim(file.wrapperPath));
246
292
  if (refreshed || allInstalled) {
247
293
  writeState(primaryState(files));
248
294
  if (refreshed) {
@@ -258,20 +304,20 @@ export function installCodexShim(): { installed: boolean; message: string } {
258
304
  }
259
305
  }
260
306
 
261
- const targets = process.platform === "win32"
307
+ const targets: ShimFileState[] | null = process.platform === "win32"
262
308
  ? findWindowsCodexTargets()
263
309
  : (() => {
264
310
  const originalPath = findCodexOnPath();
265
311
  return originalPath ? [{ wrapperPath: originalPath, originalPath, backupPath: backupPathFor(originalPath) }] : null;
266
312
  })();
267
- if (!targets) return { installed: false, message: "Could not find a codex executable on PATH." };
313
+ if (!targets) return { installed: false, message: lastShimDiscoveryError ?? "Could not find a codex executable on PATH." };
268
314
 
269
315
  for (const target of targets) {
270
316
  if (existsSync(target.backupPath)) return { installed: false, message: `Refusing to overwrite existing backup: ${target.backupPath}` };
271
317
  }
272
318
  for (const target of targets) {
273
- renameSync(target.originalPath, target.backupPath);
274
- writeShim(target.wrapperPath, target.backupPath);
319
+ if (existsSync(target.originalPath)) renameSync(target.originalPath, target.backupPath);
320
+ if (!target.preserveOnly) writeShim(target.wrapperPath, target.realPath ?? target.backupPath);
275
321
  }
276
322
  writeState(primaryState(targets));
277
323
  return {
@@ -285,6 +331,7 @@ export function uninstallCodexShim(): { removed: boolean; message: string } {
285
331
  if (!state) return { removed: false, message: "Codex autostart shim is not installed." };
286
332
  const files = stateFiles(state);
287
333
  for (const file of files) {
334
+ if (file.preserveOnly) continue;
288
335
  if (existsSync(file.wrapperPath) && isShim(file.wrapperPath)) unlinkSync(file.wrapperPath);
289
336
  }
290
337
  for (const file of files) {
@@ -8,8 +8,7 @@ function trackedAccountId(ws: ServerWebSocket<WsData>): string | null {
8
8
  return ctx?.kind === "pool" ? ctx.accountId : null;
9
9
  }
10
10
 
11
- export function registerCodexWebSocket(ws: ServerWebSocket<WsData>): void {
12
- const accountId = trackedAccountId(ws);
11
+ function addSocketForAccount(accountId: string | null, ws: ServerWebSocket<WsData>): void {
13
12
  if (!accountId) return;
14
13
  let sockets = socketsByAccount.get(accountId);
15
14
  if (!sockets) {
@@ -19,8 +18,7 @@ export function registerCodexWebSocket(ws: ServerWebSocket<WsData>): void {
19
18
  sockets.add(ws);
20
19
  }
21
20
 
22
- export function unregisterCodexWebSocket(ws: ServerWebSocket<WsData>): void {
23
- const accountId = trackedAccountId(ws);
21
+ function removeSocketForAccount(accountId: string | null, ws: ServerWebSocket<WsData>): void {
24
22
  if (!accountId) return;
25
23
  const sockets = socketsByAccount.get(accountId);
26
24
  if (!sockets) return;
@@ -28,6 +26,24 @@ export function unregisterCodexWebSocket(ws: ServerWebSocket<WsData>): void {
28
26
  if (sockets.size === 0) socketsByAccount.delete(accountId);
29
27
  }
30
28
 
29
+ export function registerCodexWebSocket(ws: ServerWebSocket<WsData>): void {
30
+ addSocketForAccount(trackedAccountId(ws), ws);
31
+ }
32
+
33
+ export function unregisterCodexWebSocket(ws: ServerWebSocket<WsData>): void {
34
+ removeSocketForAccount(trackedAccountId(ws), ws);
35
+ }
36
+
37
+ export function updateCodexWebSocketAuthContext(
38
+ ws: ServerWebSocket<WsData>,
39
+ authContext: WsData["authContext"],
40
+ ): void {
41
+ const before = trackedAccountId(ws);
42
+ removeSocketForAccount(before, ws);
43
+ ws.data.authContext = authContext;
44
+ addSocketForAccount(trackedAccountId(ws), ws);
45
+ }
46
+
31
47
  export function invalidateCodexWebSocketsForAccount(accountId: string): number {
32
48
  const sockets = socketsByAccount.get(accountId);
33
49
  if (!sockets) return 0;