@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731

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 (82) hide show
  1. package/bin/ocx.mjs +34 -8
  2. package/gui/dist/assets/index-BHsKRFh9.css +1 -0
  3. package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +42 -7
  7. package/src/adapters/cursor/discovery.ts +4 -1
  8. package/src/adapters/cursor/effort-map.ts +3 -0
  9. package/src/adapters/kiro.ts +15 -1
  10. package/src/adapters/openai-chat.ts +55 -4
  11. package/src/claude/alias.ts +94 -14
  12. package/src/claude/outbound.ts +6 -3
  13. package/src/cli/catalog-prewarm.ts +24 -0
  14. package/src/cli/claude-desktop.ts +2 -2
  15. package/src/cli/claude.ts +32 -7
  16. package/src/cli/doctor.ts +48 -1
  17. package/src/cli/index.ts +5 -0
  18. package/src/cli/init.ts +129 -102
  19. package/src/cli/interactive-confirm.ts +5 -1
  20. package/src/cli/star-prompt.ts +26 -4
  21. package/src/cli/v2.ts +10 -1
  22. package/src/codex/account-store.ts +2 -0
  23. package/src/codex/catalog/bundled.ts +9 -2
  24. package/src/codex/catalog/metadata.ts +6 -0
  25. package/src/codex/catalog/parsing.ts +26 -1
  26. package/src/codex/catalog/provider-fetch.ts +240 -82
  27. package/src/codex/catalog/sync.ts +27 -5
  28. package/src/codex/catalog.ts +3 -3
  29. package/src/codex/features.ts +524 -5
  30. package/src/codex/quota.ts +77 -2
  31. package/src/codex/runtime.ts +10 -1
  32. package/src/config.ts +8 -0
  33. package/src/generated/jawcode-model-metadata.ts +12 -12
  34. package/src/github/star-state.ts +191 -0
  35. package/src/lib/bun-binary-validator.d.mts +3 -0
  36. package/src/lib/bun-binary-validator.mjs +18 -0
  37. package/src/lib/bun-runtime.ts +6 -20
  38. package/src/lib/destination-policy.ts +21 -3
  39. package/src/lib/provider-outbound.ts +8 -2
  40. package/src/lib/shadow-call.ts +30 -0
  41. package/src/lib/test-home-guard.ts +90 -0
  42. package/src/lib/win-exec.ts +12 -2
  43. package/src/lib/winsw.ts +6 -0
  44. package/src/oauth/index.ts +29 -5
  45. package/src/oauth/key-providers.ts +21 -2
  46. package/src/oauth/kiro-credentials.ts +129 -9
  47. package/src/oauth/kiro.ts +15 -3
  48. package/src/oauth/login-cli.ts +1 -1
  49. package/src/oauth/store.ts +2 -0
  50. package/src/providers/derive.ts +2 -2
  51. package/src/providers/free-directory.ts +4 -1
  52. package/src/providers/model-discovery.ts +356 -0
  53. package/src/providers/registry.ts +114 -0
  54. package/src/router.ts +5 -3
  55. package/src/server/auth-cors.ts +4 -2
  56. package/src/server/index.ts +3 -3
  57. package/src/server/live.ts +75 -25
  58. package/src/server/management/agent-settings-routes.ts +82 -8
  59. package/src/server/management/config-routes.ts +24 -7
  60. package/src/server/management/context.ts +11 -1
  61. package/src/server/management/model-routes.ts +61 -14
  62. package/src/server/management/provider-routes.ts +44 -9
  63. package/src/server/management/shared.ts +18 -5
  64. package/src/server/management/sidebar-routes.ts +39 -0
  65. package/src/server/management-api.ts +3 -1
  66. package/src/server/proxy-liveness.ts +9 -2
  67. package/src/server/responses/core.ts +31 -20
  68. package/src/server/responses/upstream-error.ts +48 -0
  69. package/src/server/startup-action-control.ts +30 -14
  70. package/src/service.ts +395 -31
  71. package/src/storage/policy-job.ts +26 -5
  72. package/src/storage/restore-job.ts +16 -5
  73. package/src/storage/worker-lifecycle.ts +81 -0
  74. package/src/tray/windows.ts +86 -13
  75. package/src/types.ts +16 -0
  76. package/src/update/badge.ts +72 -0
  77. package/src/update/job.ts +8 -4
  78. package/src/usage/expected-prices.ts +6 -5
  79. package/src/usage/log.ts +8 -0
  80. package/src/web-search/loop.ts +57 -16
  81. package/gui/dist/assets/index-Czw-jpTU.css +0 -1
  82. package/gui/dist/assets/index-cmds12BG.js +0 -67
package/src/service.ts CHANGED
@@ -6,10 +6,11 @@
6
6
  * restore it via the command.
7
7
  */
8
8
  import { execFileSync, execSync } from "node:child_process";
9
+ import { findLiveProxy } from "./server/proxy-liveness";
9
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
10
11
  import { homedir } from "node:os";
11
12
  import { dirname, join, resolve } from "node:path";
12
- import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort } from "./config";
13
+ import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
13
14
  import { loadConfig } from "./config";
14
15
  import { restoreNativeCodex } from "./codex/inject";
15
16
  import { stripGrokConfig } from "./grok/inject";
@@ -35,6 +36,7 @@ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswSer
35
36
  import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
36
37
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
37
38
  import { recordOwnedConfigPath } from "./lib/config-ownership";
39
+ import { maybeShowStarPrompt } from "./cli/star-prompt";
38
40
 
39
41
  const LABEL = "com.opencodex.proxy";
40
42
  const TASK = "opencodex-proxy";
@@ -354,8 +356,41 @@ function sh(cmd: string): string {
354
356
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
355
357
  }
356
358
 
359
+ /**
360
+ * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
361
+ * registered task document is UTF-16; reading that as UTF-8 makes every health check
362
+ * fail ("registration present but unhealthy") and rolls back a successful elevated create.
363
+ */
364
+ export function decodeSchtasksOutput(buffer: Buffer): string {
365
+ if (buffer.length === 0) return "";
366
+ const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
367
+ const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
368
+ const looksUtf16Le = buffer.length >= 4
369
+ && buffer[1] === 0x00
370
+ && buffer[3] === 0x00
371
+ && buffer[0] !== 0x00;
372
+ if (bomUtf16Le || looksUtf16Le) {
373
+ return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
374
+ }
375
+ if (bomUtf16Be) {
376
+ // Swap pairs then decode as utf16le.
377
+ const swapped = Buffer.alloc(buffer.length - 2);
378
+ for (let i = 2; i + 1 < buffer.length; i += 2) {
379
+ swapped[i - 2] = buffer[i + 1]!;
380
+ swapped[i - 1] = buffer[i]!;
381
+ }
382
+ return swapped.toString("utf16le").trim();
383
+ }
384
+ return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
385
+ }
386
+
357
387
  function runFile(file: string, args: string[]): string {
358
- return execFileSync(file, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
388
+ const buffer = execFileSync(file, args, {
389
+ encoding: "buffer",
390
+ stdio: ["ignore", "pipe", "pipe"],
391
+ windowsHide: true,
392
+ }) as Buffer;
393
+ return decodeSchtasksOutput(buffer);
359
394
  }
360
395
 
361
396
  function windowsSchtasks(): string {
@@ -393,6 +428,70 @@ export type WindowsSchedulerTaskProbe =
393
428
  | { status: "absent" }
394
429
  | { status: "unknown"; detail: string };
395
430
 
431
+ export type WindowsSchedulerProxyProbe =
432
+ | { status: "running"; port: number }
433
+ | { status: "not-running" }
434
+ | { status: "unknown" };
435
+
436
+ /**
437
+ * Render Task Scheduler status without exposing localized `schtasks` table output.
438
+ * The task probe answers installation state; the identity-checked health probe answers
439
+ * runtime state. Keep probe details out of this user-facing line because they can contain
440
+ * incorrectly decoded, locale-specific command output.
441
+ */
442
+ export function formatWindowsSchedulerServiceStatus(
443
+ task: WindowsSchedulerTaskProbe,
444
+ proxy: WindowsSchedulerProxyProbe,
445
+ ): string {
446
+ if (task.status === "present") {
447
+ if (proxy.status === "running") {
448
+ return `✅ service installed (Task Scheduler); OpenCodex proxy running on port ${proxy.port}.`;
449
+ }
450
+ if (proxy.status === "not-running") {
451
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy not running.";
452
+ }
453
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy status unknown.";
454
+ }
455
+ if (task.status === "absent") {
456
+ if (proxy.status === "running") {
457
+ return `❌ service not installed (Task Scheduler); OpenCodex proxy is running independently on port ${proxy.port}.`;
458
+ }
459
+ if (proxy.status === "unknown") {
460
+ return "❌ service not installed (Task Scheduler); OpenCodex proxy status unknown.";
461
+ }
462
+ return "❌ service not installed (Task Scheduler).";
463
+ }
464
+ if (proxy.status === "running") {
465
+ return `⚠️ Task Scheduler registration unknown; OpenCodex proxy running on port ${proxy.port}.`;
466
+ }
467
+ if (proxy.status === "not-running") {
468
+ return "⚠️ service status unknown (Task Scheduler query failed); OpenCodex proxy not running.";
469
+ }
470
+ return "⚠️ service status unknown (Task Scheduler and proxy checks failed).";
471
+ }
472
+
473
+ export async function inspectWindowsSchedulerServiceStatus(io: {
474
+ probeTask?: () => WindowsSchedulerTaskProbe;
475
+ findProxy?: () => Promise<{ port: number } | null>;
476
+ } = {}): Promise<string> {
477
+ let task: WindowsSchedulerTaskProbe;
478
+ try {
479
+ task = (io.probeTask ?? probeWindowsSchedulerTask)();
480
+ } catch (error) {
481
+ task = { status: "unknown", detail: schtasksErrorDetail(error) };
482
+ }
483
+
484
+ let proxy: WindowsSchedulerProxyProbe;
485
+ try {
486
+ const live = await (io.findProxy ?? findLiveProxy)();
487
+ proxy = live ? { status: "running", port: live.port } : { status: "not-running" };
488
+ } catch {
489
+ proxy = { status: "unknown" };
490
+ }
491
+
492
+ return formatWindowsSchedulerServiceStatus(task, proxy);
493
+ }
494
+
396
495
  function schtasksErrorDetail(error: unknown): string {
397
496
  return error instanceof Error ? error.message : String(error);
398
497
  }
@@ -486,7 +585,9 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
486
585
  : !assetsHealthy
487
586
  ? "Required scheduler service assets are missing."
488
587
  : !registrationHealthy
489
- ? "Task Scheduler registration is present but unhealthy."
588
+ ? (inputs.xml.trim()
589
+ ? "Task Scheduler registration is present but unhealthy."
590
+ : "Task Scheduler task is present but its XML could not be read.")
490
591
  : nativeStatusUnknown
491
592
  ? "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent."
492
593
  : "ok";
@@ -505,9 +606,18 @@ export function evaluateWindowsSchedulerInstallVerification(inputs: {
505
606
  /** Conflict-free postcondition check for an elevated scheduler install. */
506
607
  export function verifyWindowsSchedulerInstall(taskName = TASK): WindowsSchedulerInstallVerification {
507
608
  const taskInstalled = windowsSchedulerTaskInstalled(taskName);
508
- const xml = taskInstalled ? (() => {
509
- try { return querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { return ""; }
510
- })() : "";
609
+ let xml = "";
610
+ if (taskInstalled) {
611
+ try { xml = querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { xml = ""; }
612
+ }
613
+ // After elevated create, non-elevated `/query /xml` can fail or return empty while the
614
+ // task is still listed. Fall back to the on-disk document we registered.
615
+ if (taskInstalled && !xml.trim()) {
616
+ const diskPath = windowsTaskXmlPath();
617
+ if (existsSync(diskPath)) {
618
+ try { xml = decodeSchtasksOutput(readFileSync(diskPath)); } catch { /* keep empty */ }
619
+ }
620
+ }
511
621
  return evaluateWindowsSchedulerInstallVerification({
512
622
  taskInstalled,
513
623
  xml,
@@ -895,6 +1005,22 @@ function taskXmlString(value: string): string {
895
1005
  .replace(/'/g, "&apos;");
896
1006
  }
897
1007
 
1008
+ /**
1009
+ * RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
1010
+ * `schtasks /create` often rewrites the registered task to HighestAvailable even when
1011
+ * the source XML asked for LeastPrivilege — still InteractiveToken / same user.
1012
+ * Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
1013
+ * installs, and windowsTaskRegistrationHealthy tests encode that contract.
1014
+ */
1015
+ function taskXmlRunLevelAcceptable(principal: string): boolean {
1016
+ if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
1017
+ const count = taskXmlElementCount(principal, "RunLevel");
1018
+ if (count === 0) return true;
1019
+ if (count > 1) return false;
1020
+ const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
1021
+ return value === "leastprivilege" || value === "highestavailable";
1022
+ }
1023
+
898
1024
  export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
899
1025
  const { bun, cli } = entry;
900
1026
  const bunRuntime = durableBunRuntime();
@@ -1076,7 +1202,7 @@ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string):
1076
1202
  // `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
1077
1203
  const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
1078
1204
  if (value === undefined) return false;
1079
- return taskXmlDecodeEntities(value).trim() === expected.trim();
1205
+ return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
1080
1206
  }
1081
1207
 
1082
1208
  function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
@@ -1112,13 +1238,14 @@ export function windowsTaskRegistrationHealthy(
1112
1238
  return taskXmlElementCount(triggers, "LogonTrigger") > 0
1113
1239
  && taskXmlOptionalValueEquals(trigger, "Enabled", "true")
1114
1240
  && /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
1115
- && taskXmlOptionalValueEquals(principal, "RunLevel", "LeastPrivilege")
1241
+ && taskXmlRunLevelAcceptable(principal)
1116
1242
  && taskXmlOptionalValueEquals(settings, "Enabled", "true")
1117
1243
  && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
1118
1244
  && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
1119
1245
  // Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
1120
1246
  // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
1121
1247
  // needle never matched and a healthy task read as permanently stale (#608).
1248
+ // Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
1122
1249
  && taskXmlDecodedValueEquals(action, "Command", wscript)
1123
1250
  && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
1124
1251
  }
@@ -1191,10 +1318,23 @@ function writeServiceAssetWithRetry(path: string, content: string, encoding: "ut
1191
1318
  }
1192
1319
  }
1193
1320
 
1194
- function installWindows(): void {
1195
- recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1321
+ /**
1322
+ * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task.
1323
+ * Used by fresh install (before schtasks /create) and by repair (no elevation).
1324
+ */
1325
+ function writeWindowsSchedulerAssets(): void {
1196
1326
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1197
1327
  writeServiceApiTokenFile();
1328
+ const script = windowsServiceScriptPath();
1329
+ writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
1330
+ // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
1331
+ // paths on some WSH/codepage combinations — same contract as the task XML below.
1332
+ writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
1333
+ writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1334
+ }
1335
+
1336
+ function installWindows(): void {
1337
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1198
1338
  // Transactional backend switch: installing the scheduler backend removes a native
1199
1339
  // service first — two live managers would both respawn the proxy (conflict).
1200
1340
  if (statusWinswRaw() !== "nonexistent") {
@@ -1211,23 +1351,115 @@ function installWindows(): void {
1211
1351
  // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
1212
1352
  // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
1213
1353
  try { stopWindows(); } catch { /* not running */ }
1214
- const script = windowsServiceScriptPath();
1215
- writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
1216
- // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
1217
- // paths on some WSH/codepage combinations — same contract as the task XML below.
1218
- writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
1219
- writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1220
- schtasks(buildWindowsSchtasksCreateArgs(script));
1354
+ writeWindowsSchedulerAssets();
1355
+ schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
1221
1356
  schtasks(["/run", "/tn", TASK]);
1222
1357
  writeServiceInstallState("scheduler");
1223
1358
  }
1224
1359
 
1360
+ export interface RepairServiceDeps {
1361
+ diagnose?: () => ServiceDiagnostic;
1362
+ assertEnv?: () => void;
1363
+ assertAuth?: () => void;
1364
+ writeSchedulerAssets?: () => void;
1365
+ stopScheduler?: () => void;
1366
+ startScheduler?: () => void;
1367
+ writeSchedulerState?: () => void;
1368
+ writeNativeState?: () => void;
1369
+ repairNative?: () => void | Promise<void>;
1370
+ repairLaunchd?: () => void;
1371
+ repairSystemd?: () => void;
1372
+ /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
1373
+ platform?: NodeJS.Platform;
1374
+ }
1375
+
1376
+ /**
1377
+ * Repair an already-installed background service without Task Scheduler re-registration.
1378
+ *
1379
+ * Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC.
1380
+ * Windows native: WinSW asset rewrite + restart (skips `install /p` when present).
1381
+ * macOS/Linux: re-run the user-level install/reload path.
1382
+ */
1383
+ export async function repairService(deps: RepairServiceDeps = {}): Promise<void> {
1384
+ const diagnose = deps.diagnose ?? diagnoseService;
1385
+ const platform = deps.platform ?? process.platform;
1386
+ const diag = diagnose();
1387
+ if (!diag.supported) {
1388
+ throw new Error(`Background service is unsupported (${diag.summary}).`);
1389
+ }
1390
+ if (diag.conflict) {
1391
+ throw new Error(
1392
+ "Cannot repair while Task Scheduler and native WinSW are both present. "
1393
+ + "Run 'ocx service uninstall' then reinstall one backend with 'ocx service install'.",
1394
+ );
1395
+ }
1396
+ if (!diag.installed) {
1397
+ throw new Error("Background service is not installed. Run 'ocx service install' first.");
1398
+ }
1399
+
1400
+ (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
1401
+ (deps.assertAuth ?? assertServiceAuthEnvironment)();
1402
+
1403
+ if (platform === "win32") {
1404
+ if (diag.backend === "native") {
1405
+ await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(import.meta.dir))))();
1406
+ (deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
1407
+ return;
1408
+ }
1409
+ try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
1410
+ (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
1411
+ (deps.startScheduler ?? startWindows)();
1412
+ (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
1413
+ return;
1414
+ }
1415
+ if (platform === "darwin") {
1416
+ (deps.repairLaunchd ?? installLaunchd)();
1417
+ return;
1418
+ }
1419
+ if (platform === "linux") {
1420
+ (deps.repairSystemd ?? installSystemd)();
1421
+ return;
1422
+ }
1423
+ throw new Error(`Background service repair is unsupported on ${platform}.`);
1424
+ }
1425
+
1225
1426
  /**
1226
1427
  * Opt-in native backend (`ocx service install --native`). Transactional: removes the
1227
1428
  * scheduler backend first; on failure the machine is left with NO service (explicitly
1228
1429
  * reported) — never a silent fallback to the scheduler.
1229
1430
  */
1431
+ /** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */
1432
+ export function assertWindowsNativeServiceAccountSupported(): void {
1433
+ if (process.platform !== "win32") return;
1434
+ const source = readWindowsPrincipalSource();
1435
+ if (source?.toLowerCase() === "microsoftaccount") {
1436
+ throw new Error(
1437
+ "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. "
1438
+ + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.",
1439
+ );
1440
+ }
1441
+ }
1442
+
1443
+ function readWindowsPrincipalSource(): string | null {
1444
+ if (process.platform !== "win32") return null;
1445
+ const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
1446
+ if (!existsSync(ps)) return null;
1447
+ try {
1448
+ const out = execFileSync(ps, [
1449
+ "-NoLogo",
1450
+ "-NoProfile",
1451
+ "-NonInteractive",
1452
+ "-Command",
1453
+ "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource",
1454
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
1455
+ return out || null;
1456
+ } catch {
1457
+ return null;
1458
+ }
1459
+ }
1460
+
1230
1461
  async function installWindowsNative(): Promise<void> {
1462
+ assertWindowsNativeServiceAccountSupported();
1231
1463
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1232
1464
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1233
1465
  writeServiceApiTokenFile();
@@ -1262,11 +1494,49 @@ async function installWindowsNative(): Promise<void> {
1262
1494
  writeServiceInstallState("native");
1263
1495
  }
1264
1496
  function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
1265
- function stopWindows(): void { try { schtasks(["/end", "/tn", TASK]); } catch { /* not running */ } }
1497
+
1498
+ export function isWindowsSchedulerEndBenign(error: unknown): boolean {
1499
+ const detail = schtasksErrorDetail(error).toLowerCase();
1500
+ return detail.includes("no running instance")
1501
+ || detail.includes("not currently running")
1502
+ || detail.includes("0x41330");
1503
+ }
1504
+
1505
+ /**
1506
+ * End the scheduler task. "Already stopped" is success; other `/end` failures are
1507
+ * swallowed so callers can still run tracked-proxy + live-proxy cleanup.
1508
+ *
1509
+ * Do not key a restart-window wait on `/end` failure: the #764 case is an `/end`
1510
+ * that *succeeds* while the wrapper survives and respawns. That verification lives
1511
+ * on the stop-verification path (poll across the restart window), not here.
1512
+ */
1513
+ export function stopWindows(): void {
1514
+ try {
1515
+ schtasks(["/end", "/tn", TASK]);
1516
+ } catch (error) {
1517
+ if (isWindowsSchedulerEndBenign(error)) return;
1518
+ }
1519
+ }
1266
1520
  function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
1267
1521
  function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
1268
1522
  function uninstallWindows(): void {
1269
- try { schtasks(["/delete", "/tn", TASK, "/f"]); } catch { /* absent */ }
1523
+ const probe = probeWindowsSchedulerTask(TASK);
1524
+ if (probe.status === "present") {
1525
+ try {
1526
+ schtasks(["/delete", "/tn", TASK, "/f"]);
1527
+ } catch (error) {
1528
+ throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`);
1529
+ }
1530
+ const afterDelete = probeWindowsSchedulerTask(TASK);
1531
+ if (afterDelete.status === "present") {
1532
+ throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`);
1533
+ }
1534
+ if (afterDelete.status === "unknown") {
1535
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`);
1536
+ }
1537
+ } else if (probe.status === "unknown") {
1538
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`);
1539
+ }
1270
1540
  if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
1271
1541
  if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath());
1272
1542
  if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
@@ -1425,18 +1695,77 @@ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
1425
1695
 
1426
1696
  type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
1427
1697
 
1698
+ function verifiedKillTarget(pid: number | null | undefined): number | null {
1699
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
1700
+ const verified = verifyPidIdentity(pid);
1701
+ return verified === pid ? verified : null;
1702
+ }
1703
+
1704
+ /**
1705
+ * Whether a proxy is still answering after the service manager claimed to stop it.
1706
+ *
1707
+ * `ops.stop()` reports the outcome of the STOP COMMAND, not of the process. A Windows scheduler
1708
+ * task whose wrapper survives `schtasks /end` respawns its child a few seconds later, so a stop
1709
+ * that returned success can still leave a live proxy — and `ocx service stop` then restored
1710
+ * native Codex on top of a running one (#764). The tracked-pid cleanup does not catch it either:
1711
+ * the respawned child writes a different pid, or none this process knows about.
1712
+ *
1713
+ * Probed rather than assumed, and bounded. The respawn risk is specific to a supervisor that can
1714
+ * restart its child — the Windows scheduler wrapper — so only that case pays the restart window.
1715
+ * Everywhere else a single probe answers the question, because nothing is going to bring the
1716
+ * proxy back after `launchctl unload` or `systemctl stop`. Making every platform wait 7s on a
1717
+ * stop that already succeeded would trade one bug for a worse everyday one.
1718
+ */
1719
+ export async function proxyStillLiveAfterStop(deps: {
1720
+ findProxy?: () => Promise<{ port: number } | null>;
1721
+ sleep?: (ms: number) => Promise<void>;
1722
+ now?: () => number;
1723
+ /** Whether the stopped supervisor can respawn its child; only then is polling worth the wait. */
1724
+ canRespawn?: boolean;
1725
+ } = {}): Promise<{ port: number } | null> {
1726
+ const findProxy = deps.findProxy ?? findLiveProxy;
1727
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
1728
+ const now = deps.now ?? Date.now;
1729
+ const canRespawn = deps.canRespawn ?? process.platform === "win32";
1730
+ const deadline = now() + (canRespawn ? 7000 : 0);
1731
+ for (;;) {
1732
+ try {
1733
+ const live = await findProxy();
1734
+ if (live) return live;
1735
+ } catch {
1736
+ // A probe failure is not proof the proxy is gone; keep polling until the deadline.
1737
+ }
1738
+ if (now() >= deadline) return null;
1739
+ await sleep(1000);
1740
+ }
1741
+ }
1742
+
1428
1743
  async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
1744
+ let stopped = false;
1429
1745
  const pid = readPid();
1430
- if (!pid) return "none";
1431
- if (!isProcessAlive(pid)) {
1746
+ const trackedKillPid = verifiedKillTarget(pid);
1747
+ if (trackedKillPid !== null && isProcessAlive(trackedKillPid)) {
1748
+ await stopProxy(trackedKillPid);
1749
+ removePid(trackedKillPid);
1750
+ removeRuntimePort(trackedKillPid);
1751
+ stopped = true;
1752
+ } else if (pid) {
1432
1753
  removePid(pid);
1433
1754
  removeRuntimePort(pid);
1434
- return "stale";
1435
1755
  }
1436
- await stopProxy(pid);
1437
- removePid(pid);
1438
- removeRuntimePort(pid);
1439
- return "stopped";
1756
+ // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
1757
+ // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
1758
+ const live = await findLiveProxy({ timeoutMs: 1500 });
1759
+ const liveKillPid = verifiedKillTarget(live?.pid);
1760
+ if (liveKillPid !== null) {
1761
+ await stopProxy(liveKillPid);
1762
+ removePid(liveKillPid);
1763
+ removeRuntimePort(liveKillPid);
1764
+ stopped = true;
1765
+ }
1766
+ if (stopped) return "stopped";
1767
+ if (pid) return "stale";
1768
+ return "none";
1440
1769
  }
1441
1770
 
1442
1771
  async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
@@ -1711,6 +2040,13 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1711
2040
  console.error("--native (WinSW) is Windows-only.");
1712
2041
  process.exit(1);
1713
2042
  }
2043
+ if (command === "repair") {
2044
+ assertServiceEnvironmentMatchesInstall();
2045
+ assertServiceAuthEnvironment();
2046
+ await repairService();
2047
+ console.log("✅ opencodex background service repaired (assets refreshed, no Task Scheduler re-registration).");
2048
+ return;
2049
+ }
1714
2050
  // Non-install subcommands follow the backend recorded at install time (state v2).
1715
2051
  const backend: ServiceBackend = parsed.backend ?? (process.platform === "win32" ? readServiceBackend() : "scheduler");
1716
2052
  const ops = platformOps(backend);
@@ -1727,18 +2063,39 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1727
2063
  ? "✅ opencodex native service installed + started (windowless, starts at boot, auto-restarts on crash)."
1728
2064
  : "✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
1729
2065
  if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
2066
+ // Service users never reach the `ocx start` prompt: the proxy they run is the
2067
+ // supervised child, which always carries OCX_SERVICE=1. This command, though, is
2068
+ // hand-typed in a real terminal, so it is the one interactive moment they get.
2069
+ // Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
2070
+ await maybeShowStarPrompt();
1730
2071
  break;
1731
2072
  case "start":
1732
2073
  ops.start();
1733
2074
  console.log("✅ service started.");
1734
2075
  break;
1735
- case "stop":
2076
+ case "stop": {
1736
2077
  assertServiceEnvironmentMatchesInstall();
1737
2078
  // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
1738
2079
  // (and its Windows/Linux twins) even with nothing installed.
1739
- if (ops.status() !== null || isServiceInstalled()) ops.stop();
2080
+ if (ops.status() !== null || isServiceInstalled()) {
2081
+ ops.stop();
2082
+ }
1740
2083
  await stopTrackedProxyForServiceCommand();
1741
2084
  {
2085
+ // Verify rather than trust the stop command: a surviving wrapper respawns its child
2086
+ // seconds later, and restoring native Codex on top of a live proxy is the failure #764
2087
+ // reports as "stop reports success without stopping the proxy".
2088
+ const survivor = await proxyStillLiveAfterStop();
2089
+ if (survivor) {
2090
+ console.error(
2091
+ `❌ service stop did not take effect: a proxy is still listening on port ${survivor.port}.`
2092
+ + "\nNative Codex was NOT restored, because doing so while the proxy is running leaves"
2093
+ + " both pointing at each other. Check for a second service backend (`ocx service status`)"
2094
+ + " or a manually started proxy, then re-run `ocx service stop`.",
2095
+ );
2096
+ process.exitCode = 1;
2097
+ break;
2098
+ }
1742
2099
  const restore = restoreNativeCodex();
1743
2100
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
1744
2101
  else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
@@ -1749,9 +2106,14 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1749
2106
  else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
1750
2107
  }
1751
2108
  break;
2109
+ }
1752
2110
  case "status": {
1753
- const s = ops.status();
1754
- console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
2111
+ if (process.platform === "win32" && backend === "scheduler") {
2112
+ console.log(await inspectWindowsSchedulerServiceStatus());
2113
+ } else {
2114
+ const s = ops.status();
2115
+ console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
2116
+ }
1755
2117
  console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
1756
2118
  break;
1757
2119
  }
@@ -1783,9 +2145,11 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
1783
2145
  console.log("✅ service uninstalled.");
1784
2146
  break;
1785
2147
  default:
1786
- console.error("Usage: ocx service [install|start|stop|status|uninstall|remove] [--native|--scheduler]");
2148
+ console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
1787
2149
  console.error(" With no subcommand, installs/updates and starts the background service.");
2150
+ console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
1788
2151
  console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
1789
2152
  process.exit(1);
1790
2153
  }
1791
2154
  }
2155
+
@@ -18,6 +18,11 @@ import {
18
18
  type PolicyRunResult,
19
19
  type PolicySkipReason,
20
20
  } from "./policy";
21
+ import {
22
+ drainStorageWorkers,
23
+ registerStorageWorker,
24
+ terminateStorageWorker,
25
+ } from "./worker-lifecycle";
21
26
 
22
27
  export type PolicyJobStatus = "idle" | "running";
23
28
 
@@ -127,7 +132,7 @@ function disownActiveRun(): void {
127
132
  export function resetStorageCleanupPolicyJobForTests(): void {
128
133
  disownActiveRun();
129
134
  if (activeWorker) {
130
- try { activeWorker.terminate(); } catch { /* */ }
135
+ void terminateStorageWorker(activeWorker);
131
136
  activeWorker = null;
132
137
  }
133
138
  inflight = null;
@@ -136,11 +141,24 @@ export function resetStorageCleanupPolicyJobForTests(): void {
136
141
  state = { status: "idle" };
137
142
  }
138
143
 
144
+ /**
145
+ * Await-able sibling of the reset above, for test teardown.
146
+ *
147
+ * `bun test --isolate` reclaims a file's realm at the file boundary. A storage
148
+ * worker still exiting at that moment trips a Bun-internal assertion on Windows
149
+ * and takes the whole run down, so a suite that spawns workers must be able to
150
+ * wait for them rather than fire-and-forget.
151
+ */
152
+ export async function resetStorageCleanupPolicyJobForTestsAsync(): Promise<void> {
153
+ resetStorageCleanupPolicyJobForTests();
154
+ await drainStorageWorkers();
155
+ }
156
+
139
157
  /** Terminate an in-flight worker during process shutdown. */
140
158
  export function abortStorageCleanupPolicyJob(): void {
141
159
  disownActiveRun();
142
160
  if (activeWorker) {
143
- try { activeWorker.terminate(); } catch { /* */ }
161
+ void terminateStorageWorker(activeWorker);
144
162
  activeWorker = null;
145
163
  }
146
164
  releaseHeldMutationSlot();
@@ -218,13 +236,14 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
218
236
  const requestId = crypto.randomUUID();
219
237
  let settled = false;
220
238
  const worker = new Worker(new URL("./policy-worker.ts", import.meta.url).href);
239
+ registerStorageWorker(worker);
221
240
  activeWorker = worker;
222
241
 
223
242
  const timer = setTimeout(() => {
224
243
  if (settled) return;
225
244
  settled = true;
226
245
  cancelActiveRun = null;
227
- try { worker.terminate(); } catch { /* */ }
246
+ void terminateStorageWorker(worker);
228
247
  if (activeWorker === worker) activeWorker = null;
229
248
  reject(new Error("storage_cleanup_worker_timeout"));
230
249
  }, WORKER_TIMEOUT_MS);
@@ -235,8 +254,10 @@ function runInWorker(opts: RequestPolicyRunOptions & { blockMs?: number }): Prom
235
254
  cancelActiveRun = null;
236
255
  clearTimeout(timer);
237
256
  if (activeWorker === worker) activeWorker = null;
238
- try { worker.terminate(); } catch { /* */ }
239
- fn();
257
+ // Settle the caller only after the thread is actually gone, so a suite
258
+ // that awaits its request cannot reach the next test file with a worker
259
+ // still exiting behind it.
260
+ void terminateStorageWorker(worker).then(fn, fn);
240
261
  };
241
262
 
242
263
  cancelActiveRun = () => {