@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
@@ -0,0 +1,6 @@
1
+ import { join } from "node:path";
2
+ import { getConfigDir } from "./config";
3
+
4
+ export function serviceApiTokenFilePath(): string {
5
+ return join(getConfigDir(), "service-api-token");
6
+ }
package/src/service.ts CHANGED
@@ -6,12 +6,15 @@
6
6
  * restore it via the command.
7
7
  */
8
8
  import { execFileSync, execSync } from "node:child_process";
9
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
9
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
- import { join } from "node:path";
12
- import { getConfigDir } from "./config";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import { getConfigDir, readPid, removePid } from "./config";
13
+ import { loadConfig } from "./config";
13
14
  import { restoreNativeCodex } from "./codex-inject";
14
- import { durableBunPath } from "./bun-runtime";
15
+ import { durableBunPath, durableBunRuntime } from "./bun-runtime";
16
+ import { isProcessAlive, killProxy } from "./process-control";
17
+ import { serviceApiTokenFilePath } from "./service-secrets";
15
18
 
16
19
  const LABEL = "com.opencodex.proxy";
17
20
  const TASK = "opencodex-proxy";
@@ -31,10 +34,99 @@ function logPath(): string {
31
34
  return join(getConfigDir(), "service.log");
32
35
  }
33
36
 
37
+ export function serviceLogPath(): string {
38
+ return logPath();
39
+ }
40
+
34
41
  function windowsServiceScriptPath(): string {
35
42
  return join(getConfigDir(), "opencodex-service.cmd");
36
43
  }
37
44
 
45
+ function windowsTaskXmlPath(): string {
46
+ return join(getConfigDir(), "opencodex-service-task.xml");
47
+ }
48
+
49
+ function serviceStatePath(): string {
50
+ return join(getConfigDir(), "service-state.json");
51
+ }
52
+
53
+ function defaultOpenCodexHome(): string {
54
+ return resolve(join(homedir(), ".opencodex"));
55
+ }
56
+
57
+ function serviceStatePaths(): string[] {
58
+ const paths = [serviceStatePath()];
59
+ const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
60
+ if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
61
+ return paths;
62
+ }
63
+
64
+ function currentCodexHome(): string {
65
+ return resolve(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"));
66
+ }
67
+
68
+ function currentOpenCodexHome(): string {
69
+ return resolve(process.env.OPENCODEX_HOME?.trim() || getConfigDir());
70
+ }
71
+
72
+ function normalizePathForCompare(path: string): string {
73
+ const resolved = resolve(path);
74
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
75
+ }
76
+
77
+ interface ServiceInstallState {
78
+ version: 1;
79
+ codexHome: string;
80
+ opencodexHome: string;
81
+ }
82
+
83
+ function writeServiceInstallState(): void {
84
+ const state: ServiceInstallState = {
85
+ version: 1,
86
+ codexHome: currentCodexHome(),
87
+ opencodexHome: currentOpenCodexHome(),
88
+ };
89
+ for (const path of serviceStatePaths()) {
90
+ const dir = dirname(path);
91
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
92
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
93
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
94
+ }
95
+ }
96
+
97
+ function readServiceInstallState(): ServiceInstallState | null {
98
+ for (const path of serviceStatePaths()) {
99
+ try {
100
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as ServiceInstallState;
101
+ if (parsed.version === 1) return parsed;
102
+ } catch {
103
+ /* try the next known state path */
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ export function assertServiceEnvironmentMatchesInstall(): void {
110
+ const state = readServiceInstallState();
111
+ if (!state) return;
112
+ const expected = normalizePathForCompare(state.codexHome);
113
+ const actual = normalizePathForCompare(currentCodexHome());
114
+ if (expected !== actual) {
115
+ throw new Error(
116
+ `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
117
+ "Run the service command from the same Codex home so native Codex restore updates the correct config.",
118
+ );
119
+ }
120
+ const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
121
+ const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
122
+ if (expectedOpenCodexHome !== actualOpenCodexHome) {
123
+ throw new Error(
124
+ `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
125
+ "Run the service command from the same OpenCodex home so service state and secrets match.",
126
+ );
127
+ }
128
+ }
129
+
38
130
  function plistString(value: string): string {
39
131
  return value
40
132
  .replace(/&/g, "&")
@@ -44,12 +136,45 @@ function plistString(value: string): string {
44
136
  .replace(/'/g, "'");
45
137
  }
46
138
 
139
+ function isLoopbackHostname(hostname: string | undefined): boolean {
140
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
141
+ return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
142
+ }
143
+
144
+ export function assertServiceAuthEnvironment(): void {
145
+ const config = loadConfig();
146
+ if (isLoopbackHostname(config.hostname)) return;
147
+ if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
148
+ throw new Error(
149
+ "OPENCODEX_API_AUTH_TOKEN is required before installing a service for non-loopback hostname. " +
150
+ "Set it in the same shell, then rerun `ocx service install`.",
151
+ );
152
+ }
153
+
154
+ function writeServiceApiTokenFile(): string | null {
155
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
156
+ if (!token) return null;
157
+ const path = serviceApiTokenFilePath();
158
+ const dir = getConfigDir();
159
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
160
+ writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
161
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
162
+ return path;
163
+ }
164
+
47
165
  export function buildPlist(): string {
48
166
  const { bun, cli } = cliEntry();
49
167
  const log = logPath();
50
168
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
51
169
  const codexHome = process.env.CODEX_HOME?.trim();
52
- const codexHomeXml = codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : "";
170
+ const opencodexHome = process.env.OPENCODEX_HOME?.trim();
171
+ const envLines = [
172
+ ` <key>OCX_SERVICE</key><string>1</string>`,
173
+ ` <key>PATH</key><string>${plistString(path)}</string>`,
174
+ codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
175
+ opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
176
+ ].filter((line): line is string => Boolean(line)).join("\n");
177
+ const command = buildServiceShellCommand(bun, cli);
53
178
  return `<?xml version="1.0" encoding="UTF-8"?>
54
179
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
55
180
  <plist version="1.0">
@@ -57,17 +182,16 @@ export function buildPlist(): string {
57
182
  <key>Label</key><string>${LABEL}</string>
58
183
  <key>ProgramArguments</key>
59
184
  <array>
60
- <string>${plistString(bun)}</string>
61
- <string>${plistString(cli)}</string>
62
- <string>start</string>
185
+ <string>/bin/sh</string>
186
+ <string>-lc</string>
187
+ <string>${plistString(command)}</string>
63
188
  </array>
64
189
  <key>RunAtLoad</key><true/>
65
190
  <key>KeepAlive</key><true/>
66
191
  <key>EnvironmentVariables</key>
67
192
  <dict>
68
- <key>OCX_SERVICE</key><string>1</string>
69
- <key>PATH</key><string>${plistString(path)}</string>
70
- ${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
193
+ ${envLines}
194
+ </dict>
71
195
  <key>StandardOutPath</key><string>${plistString(log)}</string>
72
196
  <key>StandardErrorPath</key><string>${plistString(log)}</string>
73
197
  </dict>
@@ -75,6 +199,15 @@ ${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
75
199
  `;
76
200
  }
77
201
 
202
+ function shellQuote(value: string): string {
203
+ return `'${value.replace(/'/g, "'\\''")}'`;
204
+ }
205
+
206
+ function buildServiceShellCommand(bun: string, cli: string): string {
207
+ const tokenFile = serviceApiTokenFilePath();
208
+ return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start`;
209
+ }
210
+
78
211
  function systemdQuote(value: string): string {
79
212
  return `"${value
80
213
  .replace(/\\/g, "\\\\")
@@ -112,7 +245,11 @@ function schtasks(args: string[]): string {
112
245
  }
113
246
 
114
247
  function windowsBatchValue(value: string): string {
115
- return value.replace(/%/g, "%%").replace(/[\r\n]/g, "");
248
+ return value
249
+ .replace(/%/g, "%%")
250
+ .replace(/\^/g, "^^")
251
+ .replace(/"/g, "")
252
+ .replace(/[\r\n]/g, "");
116
253
  }
117
254
 
118
255
  function windowsBatchSet(name: string, value: string | undefined): string | null {
@@ -120,8 +257,18 @@ function windowsBatchSet(name: string, value: string | undefined): string | null
120
257
  return `set "${name}=${windowsBatchValue(value)}"`;
121
258
  }
122
259
 
123
- export function buildWindowsServiceScript(): string {
124
- const { bun, cli } = cliEntry();
260
+ function taskXmlString(value: string): string {
261
+ return value
262
+ .replace(/&/g, "&amp;")
263
+ .replace(/</g, "&lt;")
264
+ .replace(/>/g, "&gt;")
265
+ .replace(/"/g, "&quot;")
266
+ .replace(/'/g, "&apos;");
267
+ }
268
+
269
+ export function buildWindowsServiceScript(entry = cliEntry()): string {
270
+ const { bun, cli } = entry;
271
+ const bunRuntime = durableBunRuntime();
125
272
  const path = process.env.PATH ?? "";
126
273
  const lines = [
127
274
  "@echo off",
@@ -129,9 +276,25 @@ export function buildWindowsServiceScript(): string {
129
276
  windowsBatchSet("OCX_SERVICE", "1"),
130
277
  windowsBatchSet("PATH", path),
131
278
  windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim()),
279
+ windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim()),
280
+ windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath()),
281
+ windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath()),
282
+ windowsBatchSet("OCX_BUN", bun),
283
+ windowsBatchSet("OCX_CLI", cli),
284
+ 'if exist "%OCX_API_TOKEN_FILE%" (',
285
+ ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
286
+ ")",
132
287
  ":loop",
133
- `"${bun}" "${cli}" start`,
288
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
289
+ '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
290
+ `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntime.source}"`,
291
+ '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
292
+ '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
293
+ '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
294
+ '>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
295
+ '"%OCX_BUN%" "%OCX_CLI%" start >>"%OCX_SERVICE_LOG%" 2>&1',
134
296
  "if %ERRORLEVEL% NEQ 0 (",
297
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
135
298
  " timeout /t 5 /nobreak >nul",
136
299
  " goto loop",
137
300
  ")",
@@ -141,7 +304,52 @@ export function buildWindowsServiceScript(): string {
141
304
  }
142
305
 
143
306
  export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
144
- return ["/create", "/tn", TASK, "/tr", `"${script}"`, "/sc", "onlogon", "/rl", "highest", "/f"];
307
+ const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
308
+ return ["/create", "/tn", TASK, "/xml", xml, "/f"];
309
+ }
310
+
311
+ export function buildWindowsTaskXml(script = windowsServiceScriptPath()): string {
312
+ const escapedScript = taskXmlString(script);
313
+ return `<?xml version="1.0" encoding="UTF-16"?>
314
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
315
+ <RegistrationInfo>
316
+ <Description>OpenCodex proxy service wrapper</Description>
317
+ </RegistrationInfo>
318
+ <Triggers>
319
+ <LogonTrigger>
320
+ <Enabled>true</Enabled>
321
+ </LogonTrigger>
322
+ </Triggers>
323
+ <Principals>
324
+ <Principal id="Author">
325
+ <LogonType>InteractiveToken</LogonType>
326
+ <RunLevel>LeastPrivilege</RunLevel>
327
+ </Principal>
328
+ </Principals>
329
+ <Settings>
330
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
331
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
332
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
333
+ <AllowHardTerminate>true</AllowHardTerminate>
334
+ <StartWhenAvailable>true</StartWhenAvailable>
335
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
336
+ <AllowStartOnDemand>true</AllowStartOnDemand>
337
+ <Enabled>true</Enabled>
338
+ <Hidden>false</Hidden>
339
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
340
+ <Priority>7</Priority>
341
+ <RestartOnFailure>
342
+ <Interval>PT1M</Interval>
343
+ <Count>3</Count>
344
+ </RestartOnFailure>
345
+ </Settings>
346
+ <Actions Context="Author">
347
+ <Exec>
348
+ <Command>${escapedScript}</Command>
349
+ </Exec>
350
+ </Actions>
351
+ </Task>
352
+ `;
145
353
  }
146
354
 
147
355
  // ── macOS (launchd) ──
@@ -149,10 +357,12 @@ function installLaunchd(): void {
149
357
  const dir = join(homedir(), "Library", "LaunchAgents");
150
358
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
151
359
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
360
+ writeServiceApiTokenFile();
152
361
  const p = plistPath();
153
362
  writeFileSync(p, buildPlist(), "utf8");
154
363
  try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
155
364
  sh(`launchctl load -w "${p}"`);
365
+ writeServiceInstallState();
156
366
  }
157
367
  function startLaunchd(): void { sh(`launchctl load -w "${plistPath()}"`); }
158
368
  function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
@@ -166,10 +376,14 @@ function uninstallLaunchd(): void {
166
376
  // ── Windows (Task Scheduler) ──
167
377
  function installWindows(): void {
168
378
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
379
+ writeServiceApiTokenFile();
169
380
  const script = windowsServiceScriptPath();
170
381
  writeFileSync(script, buildWindowsServiceScript(), "utf8");
382
+ writeFileSync(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
383
+ try { stopWindows(); } catch { /* not running */ }
171
384
  schtasks(buildWindowsSchtasksCreateArgs(script));
172
385
  schtasks(["/run", "/tn", TASK]);
386
+ writeServiceInstallState();
173
387
  }
174
388
  function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
175
389
  function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
@@ -177,6 +391,11 @@ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]
177
391
  function uninstallWindows(): void {
178
392
  try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
179
393
  if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
394
+ if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
395
+ }
396
+
397
+ function serviceDiagnosticsSummary(): string {
398
+ return `logs: ${serviceLogPath()}`;
180
399
  }
181
400
 
182
401
  // ── Linux (systemd user unit) ──
@@ -193,10 +412,12 @@ export function buildUnit(): string {
193
412
  const log = logPath();
194
413
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
195
414
  const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
415
+ const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
196
416
  const envLines = [
197
417
  systemdEnvironmentAssignment("OCX_SERVICE", "1"),
198
418
  systemdEnvironmentAssignment("PATH", path),
199
419
  codexHome,
420
+ opencodexHome,
200
421
  ].filter((line): line is string => Boolean(line)).join("\n");
201
422
  return `[Unit]
202
423
  Description=OpenCodex Proxy Server
@@ -205,7 +426,7 @@ Wants=network-online.target
205
426
 
206
427
  [Service]
207
428
  Type=simple
208
- ExecStart=${systemdQuote(bun)} ${systemdQuote(cli)} start
429
+ ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))}
209
430
  Restart=on-failure
210
431
  RestartSec=5
211
432
  ${envLines}
@@ -218,16 +439,20 @@ WantedBy=default.target
218
439
  }
219
440
 
220
441
  function isSystemd(): boolean {
221
- try { execSync("systemctl --version", { stdio: "pipe" }); return true; } catch { return false; }
442
+ try { execSync("systemctl --version", { stdio: "pipe" }); } catch { return false; }
443
+ try { execSync("systemctl --user show-environment", { stdio: "pipe" }); return true; } catch { return false; }
222
444
  }
223
445
 
224
446
  function installSystemd(): void {
225
447
  const dir = unitDir();
226
448
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
227
449
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
450
+ writeServiceApiTokenFile();
228
451
  writeFileSync(unitPath(), buildUnit(), "utf8");
229
452
  sh("systemctl --user daemon-reload");
230
- sh(`systemctl --user enable --now ${TASK}`);
453
+ sh(`systemctl --user enable ${TASK}`);
454
+ sh(`systemctl --user restart ${TASK}`);
455
+ writeServiceInstallState();
231
456
  }
232
457
  function startSystemd(): void { sh(`systemctl --user start ${TASK}`); }
233
458
  function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
@@ -253,7 +478,7 @@ function platformOps(): ServiceOps | null {
253
478
  console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
254
479
  process.exit(1);
255
480
  }
256
- if (!isSystemd()) {
481
+ if (!isSystemd() && !existsSync(unitPath())) {
257
482
  console.error("systemd not found. Run 'ocx start' under your process supervisor.");
258
483
  process.exit(1);
259
484
  }
@@ -262,11 +487,35 @@ function platformOps(): ServiceOps | null {
262
487
  return null;
263
488
  }
264
489
 
490
+ type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
491
+
492
+ function stopTrackedProxyIfRunning(): TrackedProxyCleanupResult {
493
+ const pid = readPid();
494
+ if (!pid) return "none";
495
+ if (!isProcessAlive(pid)) {
496
+ removePid(pid);
497
+ return "stale";
498
+ }
499
+ killProxy(pid);
500
+ removePid(pid);
501
+ return "stopped";
502
+ }
503
+
504
+ function stopTrackedProxyForServiceCommand(): TrackedProxyCleanupResult {
505
+ try {
506
+ return stopTrackedProxyIfRunning();
507
+ } catch (err) {
508
+ console.error(`⚠️ Failed to stop proxy: ${err instanceof Error ? err.message : String(err)}`);
509
+ return "none";
510
+ }
511
+ }
512
+
265
513
  /**
266
514
  * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
267
515
  * Returns true if a service was found and stopped.
268
516
  */
269
517
  export function stopServiceIfInstalled(): boolean {
518
+ assertServiceEnvironmentMatchesInstall();
270
519
  if (process.platform === "darwin") {
271
520
  if (existsSync(plistPath())) {
272
521
  try { stopLaunchd(); return true; } catch { return false; }
@@ -288,6 +537,7 @@ export function stopServiceIfInstalled(): boolean {
288
537
  * manager.
289
538
  */
290
539
  export function uninstallServiceIfInstalled(): boolean {
540
+ assertServiceEnvironmentMatchesInstall();
291
541
  if (process.platform === "darwin") {
292
542
  if (existsSync(plistPath())) {
293
543
  try { uninstallLaunchd(); return true; } catch { return false; }
@@ -297,8 +547,10 @@ export function uninstallServiceIfInstalled(): boolean {
297
547
  const q = schtasks(["/query", "/tn", TASK]);
298
548
  if (q.includes(TASK)) { uninstallWindows(); return true; }
299
549
  } catch { /* task not found */ }
300
- } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
301
- try { uninstallSystemd(); return true; } catch { return false; }
550
+ } else if (process.platform === "linux" && existsSync(unitPath())) {
551
+ try { uninstallSystemd(); return true; } catch {
552
+ try { unlinkSync(unitPath()); return true; } catch { return false; }
553
+ }
302
554
  }
303
555
  return false;
304
556
  }
@@ -309,21 +561,22 @@ export function isServiceInstalled(): boolean {
309
561
  }
310
562
 
311
563
  export function serviceStatusSummary(): string {
564
+ const diagnostics = serviceDiagnosticsSummary();
312
565
  if (process.platform === "darwin") {
313
- if (!existsSync(plistPath())) return "not installed";
566
+ if (!existsSync(plistPath())) return `not installed (${diagnostics})`;
314
567
  const status = statusLaunchd();
315
- return status ? "installed (launchd)" : "installed, not loaded";
568
+ return status ? `installed (launchd; ${diagnostics})` : `installed, not loaded (${diagnostics})`;
316
569
  }
317
570
  if (process.platform === "win32") {
318
571
  const status = statusWindows();
319
- return status ? "installed (Task Scheduler)" : "not installed";
572
+ return status ? `installed (Task Scheduler; ${diagnostics})` : `not installed (${diagnostics})`;
320
573
  }
321
574
  if (process.platform === "linux") {
322
575
  if (existsSync("/.dockerenv")) return "unsupported in Docker";
323
576
  if (!isSystemd()) return "unsupported: systemd not found";
324
- if (!existsSync(unitPath())) return "not installed";
577
+ if (!existsSync(unitPath())) return `not installed (${diagnostics})`;
325
578
  const status = statusSystemd();
326
- return status ? "installed (systemd user)" : "installed, not running";
579
+ return status ? `installed (systemd user; ${diagnostics})` : `installed, not running (${diagnostics})`;
327
580
  }
328
581
  return `unsupported on ${process.platform}`;
329
582
  }
@@ -336,6 +589,8 @@ export function serviceCommand(sub?: string): void {
336
589
  }
337
590
  switch (sub) {
338
591
  case "install":
592
+ assertServiceEnvironmentMatchesInstall();
593
+ assertServiceAuthEnvironment();
339
594
  ops.install();
340
595
  console.log("✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
341
596
  if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
@@ -345,23 +600,33 @@ export function serviceCommand(sub?: string): void {
345
600
  console.log("✅ service started.");
346
601
  break;
347
602
  case "stop":
603
+ assertServiceEnvironmentMatchesInstall();
348
604
  ops.stop();
605
+ stopTrackedProxyForServiceCommand();
349
606
  restoreNativeCodex();
350
607
  console.log("✅ service stopped + native Codex restored.");
351
608
  break;
352
609
  case "status": {
353
610
  const s = ops.status();
354
611
  console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
612
+ console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
355
613
  break;
356
614
  }
357
615
  case "uninstall":
358
616
  case "remove":
617
+ assertServiceEnvironmentMatchesInstall();
618
+ ops.stop();
619
+ stopTrackedProxyForServiceCommand();
359
620
  ops.uninstall();
360
621
  restoreNativeCodex();
622
+ for (const path of serviceStatePaths()) {
623
+ try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
624
+ }
625
+ try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
361
626
  console.log("✅ service uninstalled + native Codex restored.");
362
627
  break;
363
628
  default:
364
- console.error("Usage: ocx service <install|start|stop|status|uninstall>");
629
+ console.error("Usage: ocx service <install|start|stop|status|uninstall|remove>");
365
630
  process.exit(1);
366
631
  }
367
632
  }
package/src/types.ts CHANGED
@@ -123,6 +123,20 @@ export function namespacedToolName(namespace: string | undefined, name: string):
123
123
  return namespace ? `${namespace}__${name}` : name;
124
124
  }
125
125
 
126
+ export function toolChoiceAliases(tool: Pick<OcxTool, "namespace" | "name">): string[] {
127
+ const wireName = namespacedToolName(tool.namespace, tool.name);
128
+ return tool.namespace ? [wireName, `${tool.namespace}.${tool.name}`] : [wireName];
129
+ }
130
+
131
+ export function toolAllowedByChoice(tool: Pick<OcxTool, "namespace" | "name">, allowedTools: ReadonlySet<string>): boolean {
132
+ return toolChoiceAliases(tool).some(name => allowedTools.has(name));
133
+ }
134
+
135
+ export function resolveToolChoiceWireName(tools: readonly Pick<OcxTool, "namespace" | "name">[] | undefined, name: string): string {
136
+ const match = tools?.find(tool => toolChoiceAliases(tool).includes(name));
137
+ return match ? namespacedToolName(match.namespace, match.name) : name;
138
+ }
139
+
126
140
  /**
127
141
  * Whether `modelId` is in a per-provider classification list (e.g. `noVisionModels`). Matches the full
128
142
  * id, OR — for Ollama-style ids — the family before the ":size" tag, so a `gpt-oss` entry covers
@@ -135,12 +149,23 @@ export function modelInList(list: string[] | undefined, modelId: string): boolea
135
149
  return colon > 0 && list.includes(modelId.slice(0, colon));
136
150
  }
137
151
 
152
+ export type OcxToolChoice =
153
+ | "auto"
154
+ | "none"
155
+ | "required"
156
+ | { name: string }
157
+ | { allowedTools: string[]; mode: "auto" | "required" };
158
+
159
+ export function isAllowedToolChoice(value: OcxToolChoice | undefined): value is { allowedTools: string[]; mode: "auto" | "required" } {
160
+ return typeof value === "object" && value !== null && "allowedTools" in value;
161
+ }
162
+
138
163
  export interface OcxRequestOptions {
139
164
  maxOutputTokens?: number;
140
165
  temperature?: number;
141
166
  topP?: number;
142
167
  stopSequences?: string[];
143
- toolChoice?: "auto" | "none" | "required" | { name: string };
168
+ toolChoice?: OcxToolChoice;
144
169
  reasoning?: string;
145
170
  hideThinkingSummary?: boolean;
146
171
  serviceTier?: string;
package/src/update.ts CHANGED
@@ -22,36 +22,43 @@ function currentVersion(): string {
22
22
  }
23
23
  }
24
24
 
25
+ function updateTag(current: string): string {
26
+ const tagIndex = process.argv.indexOf("--tag");
27
+ if (tagIndex !== -1 && process.argv[tagIndex + 1]) return process.argv[tagIndex + 1];
28
+ return current.includes("-preview.") ? "preview" : "latest";
29
+ }
30
+
25
31
  /** Latest published version from the registry (best-effort; null if npm isn't available). */
26
- function latestVersion(): string | null {
27
- const r = spawnSync("npm", ["view", PKG, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true });
32
+ function latestVersion(tag: string): string | null {
33
+ const r = spawnSync("npm", ["view", `${PKG}@${tag}`, "version"], { encoding: "utf8", timeout: 12000, windowsHide: true });
28
34
  return r.status === 0 ? r.stdout.trim() : null;
29
35
  }
30
36
 
31
37
  /**
32
- * `ocx update` self-update opencodex to the latest published version, using the same package
33
- * manager it was installed with (bun or npm global). A source checkout is told to `git pull` instead.
38
+ * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
39
+ * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
34
40
  */
35
41
  export async function runUpdate(): Promise<void> {
36
42
  const installer = detectInstall();
37
43
  const current = currentVersion();
38
- console.log(`opencodex v${current} (installed via ${installer})`);
44
+ const tag = updateTag(current);
45
+ console.log(`opencodex v${current} (installed via ${installer}, tag ${tag})`);
39
46
 
40
47
  if (installer === "source") {
41
48
  console.log("Running from a source checkout — update with: git pull && bun install");
42
49
  return;
43
50
  }
44
51
 
45
- const latest = latestVersion();
52
+ const latest = latestVersion(tag);
46
53
  if (latest && latest === current) {
47
- console.log(`Already on the latest version (v${latest}).`);
54
+ console.log(`Already on the latest ${tag} version (v${latest}).`);
48
55
  return;
49
56
  }
50
57
 
51
58
  const bin = installer === "bun" ? "bun" : "npm";
52
59
  const cmdArgs = installer === "bun"
53
- ? ["add", "-g", `${PKG}@latest`]
54
- : ["install", "-g", `${PKG}@latest`];
60
+ ? ["add", "-g", `${PKG}@${tag}`]
61
+ : ["install", "-g", `${PKG}@${tag}`];
55
62
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
56
63
 
57
64
  const r = spawnSync(bin, cmdArgs, { stdio: "inherit", timeout: 180000, windowsHide: true });