@bitkyc08/opencodex 2.9.1 → 2.10.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 (38) hide show
  1. package/README.md +91 -449
  2. package/gui/dist/assets/index-OY43ubAq.css +1 -0
  3. package/gui/dist/assets/index-YwNnKZcL.js +67 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/pi.svg +21 -0
  6. package/package.json +1 -1
  7. package/src/cli/account.ts +3 -5
  8. package/src/cli/claude-desktop.ts +43 -7
  9. package/src/cli/doctor.ts +12 -0
  10. package/src/cli/help.ts +1 -1
  11. package/src/cli/provider-runtime.ts +7 -0
  12. package/src/cli/star-prompt.ts +25 -4
  13. package/src/cli/status.ts +7 -2
  14. package/src/codex/app-server-processes.ts +299 -54
  15. package/src/codex/catalog/metadata.ts +9 -11
  16. package/src/codex/catalog/provider-fetch.ts +10 -10
  17. package/src/codex/catalog/sync.ts +27 -2
  18. package/src/codex/catalog.ts +1 -1
  19. package/src/config.ts +15 -1
  20. package/src/lib/bun-stream-caps.ts +14 -0
  21. package/src/oauth/index.ts +48 -3
  22. package/src/providers/registry.ts +62 -0
  23. package/src/server/index.ts +39 -3
  24. package/src/server/management/agent-settings-routes.ts +40 -3
  25. package/src/server/management/logs-usage-routes.ts +2 -0
  26. package/src/server/management/provider-routes.ts +4 -1
  27. package/src/server/management/sidebar-routes.ts +3 -1
  28. package/src/server/relay-eager.ts +100 -2
  29. package/src/server/responses/collaboration.ts +21 -0
  30. package/src/server/responses/core.ts +20 -12
  31. package/src/service.ts +393 -14
  32. package/src/storage/cleanup.ts +76 -5
  33. package/src/storage/policy.ts +6 -1
  34. package/src/types.ts +2 -0
  35. package/src/update/index.ts +9 -2
  36. package/src/update/job.ts +87 -11
  37. package/gui/dist/assets/index-CHwf3tTD.css +0 -1
  38. package/gui/dist/assets/index-CuVjugeE.js +0 -67
package/src/service.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * Codex on a service-managed restart (the restarted instance re-injects); explicit stop/uninstall
6
6
  * restore it via the command.
7
7
  */
8
- import { execFileSync, execSync } from "node:child_process";
9
- import { findLiveProxy, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
8
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
9
+ import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
10
10
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, join, resolve } from "node:path";
@@ -32,7 +32,7 @@ import {
32
32
  type ElevatedSchtasksCreateAndRunExecution,
33
33
  type ElevatedSchtasksCreateAndRunResult,
34
34
  } from "./lib/windows-elevation";
35
- import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
35
+ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
36
36
  import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
37
37
  import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
38
38
  import { recordOwnedConfigPath } from "./lib/config-ownership";
@@ -333,6 +333,177 @@ function buildServiceShellCommand(bun: string, cli: string, port = resolveServic
333
333
  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 --port ${port}`;
334
334
  }
335
335
 
336
+ /**
337
+ * The `--port <n>` actually baked into the installed launchd plist, or null when it
338
+ * cannot be read. macOS only — named for launchd rather than "service" so no caller
339
+ * assumes it covers systemd or the Windows wrapper.
340
+ *
341
+ * `start` needs this because it does NOT rewrite the plist: an install made under
342
+ * OCX_BAKE_PORT, or any later config.port edit, would otherwise leave launchd serving
343
+ * one port while the confirmation probes another, failing a healthy service.
344
+ *
345
+ * Anchored on the closing tag and matched LAST: the command also carries the Bun and
346
+ * CLI paths, and a path containing the literal `start --port 9999` must not shadow
347
+ * the real argument. buildPlist emits the command as the final ProgramArguments
348
+ * string, and buildServiceShellCommand puts the port at the very end of it.
349
+ */
350
+ export function launchdListenPort(deps: { readPlist?: () => string } = {}): number | null {
351
+ try {
352
+ const text = (deps.readPlist ?? (() => readFileSync(plistPath(), "utf8")))();
353
+ const last = [...text.matchAll(/start --port (\d{1,5})\s*<\/string>/g)].at(-1);
354
+ if (!last) return null;
355
+ const n = Number(last[1]);
356
+ return n > 0 && n <= 65535 ? n : null;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ /** The `--port <n>` baked into the installed systemd user unit. Linux only. */
363
+ export function systemdListenPort(deps: { readUnit?: () => string } = {}): number | null {
364
+ try {
365
+ const text = (deps.readUnit ?? (() => readFileSync(unitPath(), "utf8")))();
366
+ const last = [...text.matchAll(/start --port (\d{1,5})(?:\s|"|$)/gm)].at(-1);
367
+ if (!last) return null;
368
+ const n = Number(last[1]);
369
+ return n > 0 && n <= 65535 ? n : null;
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Shared tail parser for the baked `--port <n>`.
377
+ *
378
+ * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd
379
+ * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's `</arguments>`), and `&` (an
380
+ * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI
381
+ * paths ahead of the argument, and a path containing the literal must not shadow it.
382
+ */
383
+ function parseBakedListenPort(read: () => string): number | null {
384
+ try {
385
+ const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1);
386
+ if (!last) return null;
387
+ const n = Number(last[1]);
388
+ return n > 0 && n <= 65535 ? n : null;
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /** The `--port <n>` baked into the Task Scheduler wrapper. Windows scheduler backend. */
395
+ export function windowsListenPort(deps: { readScript?: () => string } = {}): number | null {
396
+ return parseBakedListenPort(deps.readScript ?? (() => readFileSync(windowsServiceScriptPath(), "utf8")));
397
+ }
398
+
399
+ /**
400
+ * The `--port <n>` baked into the WinSW XML's `<arguments>`. Windows native backend.
401
+ *
402
+ * Separate from {@link windowsListenPort} rather than one function branching on
403
+ * `readServiceBackend()`: the recorded backend can disagree with what is actually on
404
+ * disk (the `stale` / `backendStateMismatch` cases `deriveWindowsServiceDiagnostic`
405
+ * exists to catch), and a reader that trusted it would then read the wrong file.
406
+ * Each returns null when its own artifact is absent, so the chain needs no branch.
407
+ */
408
+ export function winswListenPort(deps: { readXml?: () => string } = {}): number | null {
409
+ return parseBakedListenPort(deps.readXml ?? (() => readFileSync(winswXmlPath(), "utf8")));
410
+ }
411
+
412
+ /**
413
+ * The listen port of the INSTALLED service artifact, falling back to the configured
414
+ * one. Each reader returns null off its own platform, so the chain needs no platform
415
+ * branch — and on Windows both return null, preserving today's behavior.
416
+ */
417
+ export function installedServiceListenPort(): number {
418
+ return launchdListenPort()
419
+ ?? systemdListenPort()
420
+ ?? windowsListenPort()
421
+ ?? winswListenPort()
422
+ ?? resolveServiceListenPort();
423
+ }
424
+
425
+ export const SERVICE_INSTALL_HEALTH_MS = 20_000;
426
+
427
+ /**
428
+ * Whether a proxy actually answers on the port this install/start just produced.
429
+ *
430
+ * Registration is not service: `launchctl list` reports a job that never bound, and
431
+ * `systemctl is-active` reports a process that bound nothing. Probing is the only
432
+ * thing that answers the question the user is actually asking.
433
+ *
434
+ * Probes the BAKED target rather than resolving one. `findLiveProxy` resolves through
435
+ * pidfile -> runtime-port -> config.port, and a service reinstall has just invalidated
436
+ * the first two while `resolveServiceListenPort` (OCX_BAKE_PORT precedence, config.port
437
+ * === 0 normalization) can disagree with the third.
438
+ *
439
+ * Soft: returns the outcome, never throws; the caller chooses between a checkmark and
440
+ * an actionable warning.
441
+ */
442
+ export async function confirmServiceServing(
443
+ deps: {
444
+ port?: number;
445
+ hostname?: string;
446
+ probe?: (port: number, hostname: string) => Promise<boolean>;
447
+ sleep?: (ms: number) => Promise<void>;
448
+ now?: () => number;
449
+ timeoutMs?: number;
450
+ } = {},
451
+ ): Promise<{ ok: true; port: number } | { ok: false; port: number }> {
452
+ const port = deps.port ?? installedServiceListenPort();
453
+ const hostname = deps.hostname ?? loadConfig().hostname ?? "127.0.0.1";
454
+ const now = deps.now ?? Date.now;
455
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
456
+ const probe = deps.probe ?? (async (p, h) => !!(await proxyIdentityAt(p, { hostname: h })));
457
+ const deadline = now() + (deps.timeoutMs ?? SERVICE_INSTALL_HEALTH_MS);
458
+ for (;;) {
459
+ if (await probe(port, hostname)) return { ok: true, port };
460
+ if (now() >= deadline) return { ok: false, port };
461
+ await sleep(500);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Print the outcome of `install` / `start` / `repair` in terms of what the user cares
467
+ * about — is it serving? — instead of whether the manager accepted the registration.
468
+ *
469
+ * Sets `process.exitCode = 1` when nothing answers. That is deliberate: the GUI update
470
+ * worker reads the child's exit status, so a registered-but-silent service now makes it
471
+ * fall back to a direct proxy start rather than reporting a successful update over a
472
+ * dead port.
473
+ */
474
+ async function reportServiceServing(
475
+ verb: "installed" | "started" | "repaired",
476
+ deps: Parameters<typeof confirmServiceServing>[0] = {},
477
+ ): Promise<void> {
478
+ const serving = await confirmServiceServing(deps);
479
+ if (serving.ok) {
480
+ console.log(`✅ opencodex service ${verb} and serving on port ${serving.port}.`);
481
+ return;
482
+ }
483
+ console.error(
484
+ `⚠️ Service ${verb}, but no proxy answered on port ${serving.port} within `
485
+ + `${Math.trunc(SERVICE_INSTALL_HEALTH_MS / 1000)}s.\n`
486
+ + ` The manager registered the job; that is not the same as serving.\n`
487
+ + ` Log: ${serviceLogPath()}\n`
488
+ + ` Meanwhile: ocx start (serves in the foreground)`,
489
+ );
490
+ process.exitCode = 1;
491
+ }
492
+
493
+ /**
494
+ * The reinstall command for the CURRENTLY INSTALLED backend.
495
+ *
496
+ * Plain `ocx service install` on a native/WinSW install runs installWindows's
497
+ * transactional backend switch, which tears down WinSW and replaces it with the Task
498
+ * Scheduler backend. Advising it in a repair hint would silently change the user's
499
+ * backend, so the hint has to carry `--native` when that is what is installed.
500
+ */
501
+ function serviceRepairCommand(): string {
502
+ return process.platform === "win32" && readServiceBackend() === "native"
503
+ ? "ocx service install --native"
504
+ : "ocx service install";
505
+ }
506
+
336
507
  function systemdQuote(value: string): string {
337
508
  return `"${value
338
509
  .replace(/\\/g, "\\\\")
@@ -356,6 +527,72 @@ function sh(cmd: string): string {
356
527
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
357
528
  }
358
529
 
530
+ /**
531
+ * Run `launchctl` and report BOTH streams regardless of exit status.
532
+ *
533
+ * `launchctl load` writes "Load failed: <n>: <reason>" to stderr and exits 0 for
534
+ * every already-bootstrapped job. `sh()` above is execSync, which throws only on a
535
+ * non-zero exit, so install and start both reported success for a load that did
536
+ * nothing — leaving launchd running the PREVIOUS plist while a freshly written one
537
+ * sat unused on disk. That is the 2026-08-02 report: `ocx service` prints a
538
+ * checkmark, `launchctl list` shows the job, and the port answers nothing.
539
+ *
540
+ * spawnSync, NOT execFileSync: execFileSync discards stderr when the child exits 0,
541
+ * which is precisely this case — a runner built on it returns an empty stderr and
542
+ * the guard below can never fire. Measured on macOS 27.0.
543
+ */
544
+ export function runLaunchctl(
545
+ args: string[],
546
+ deps: { run?: typeof spawnSync } = {},
547
+ ): { ok: boolean; stdout: string; stderr: string } {
548
+ const run = deps.run ?? spawnSync;
549
+ const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
550
+ // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
551
+ // null for a signalled child; neither may be reported as success.
552
+ if (result.error) return { ok: false, stdout: "", stderr: String(result.error.message ?? "") };
553
+ return {
554
+ ok: result.status === 0,
555
+ stdout: String(result.stdout ?? "").trim(),
556
+ stderr: String(result.stderr ?? "").trim(),
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Whether launchctl output indicates the operation did not take. Needed because
562
+ * `ok` alone is insufficient for the legacy `load`/`unload` subcommands, which
563
+ * report failure on stderr while exiting 0. `bootstrap` exits 5, so for that path
564
+ * this is belt-and-braces rather than the only signal.
565
+ */
566
+ export function launchctlLoadFailed(stderr: string): boolean {
567
+ return /\b(?:Load|Bootstrap) failed\b/i.test(stderr);
568
+ }
569
+
570
+ /** launchd domain target for the current user's GUI session. */
571
+ function launchdGuiDomain(): string {
572
+ return `gui/${process.getuid?.() ?? 0}`;
573
+ }
574
+
575
+ /**
576
+ * Whether launchd is running the job from the CURRENT plist. `launchctl list` only
577
+ * proves domain membership — a job bootstrapped from an older plist stays listed
578
+ * forever. `launchctl print` exposes the live `arguments`, which is the only way to
579
+ * catch a load that silently no-op'd.
580
+ */
581
+ export function launchdJobMatchesPlist(
582
+ expectedCommand: string,
583
+ deps: { run?: typeof runLaunchctl } = {},
584
+ ): { loaded: boolean; matchesPlist: boolean } {
585
+ const run = deps.run ?? runLaunchctl;
586
+ const printed = run(["print", `${launchdGuiDomain()}/${LABEL}`]);
587
+ if (!printed.ok) return { loaded: false, matchesPlist: false };
588
+ // `print` writes the arguments block to stdout for a live job. Search both streams
589
+ // anyway so a future launchctl that moves diagnostics between them cannot turn this
590
+ // into a false negative — a false "stale" verdict would send users to `bootout` for
591
+ // nothing.
592
+ const printedText = `${printed.stdout}\n${printed.stderr}`;
593
+ return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
594
+ }
595
+
359
596
  /**
360
597
  * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
361
598
  * registered task document is UTF-16; reading that as UTF-8 makes every health check
@@ -1287,11 +1524,58 @@ function installLaunchd(): void {
1287
1524
  writeServiceApiTokenFile();
1288
1525
  const p = plistPath();
1289
1526
  writeFileSync(p, buildPlist(), "utf8");
1290
- try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
1291
- sh(`launchctl load -w "${p}"`);
1527
+ // Best-effort: an absent job is fine here, and a failed unload is caught by the
1528
+ // load verification below with a better message than a raw unload error.
1529
+ runLaunchctl(["unload", p]);
1530
+ const loaded = runLaunchctl(["load", "-w", p]);
1531
+ if (!loaded.ok || launchctlLoadFailed(loaded.stderr)) {
1532
+ // Do NOT write install state for a load that did not take: state describing an
1533
+ // unused plist is what made this failure invisible.
1534
+ throw new Error(
1535
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1536
+ + "A previous job may still be bootstrapped. Try:\n"
1537
+ + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
1538
+ + "then re-run 'ocx service install'.",
1539
+ );
1540
+ }
1292
1541
  writeServiceInstallState();
1293
1542
  }
1294
- function startLaunchd(): void { sh(`launchctl load -w "${plistPath()}"`); }
1543
+ /**
1544
+ * Deps are named for the layer they replace, not for the process API: `launchctl`
1545
+ * returns a {@link runLaunchctl} result and `matches` a {@link launchdJobMatchesPlist}
1546
+ * result. Only `runLaunchctl` itself takes a spawnSync mock.
1547
+ *
1548
+ * Exported for the branch tests. Every parameter is optional, so this stays
1549
+ * assignable to `ServiceOps.start` (`() => void`) and `platformOps` wires the same
1550
+ * function the tests exercise.
1551
+ */
1552
+ export function startLaunchd(deps: {
1553
+ launchctl?: typeof runLaunchctl;
1554
+ matches?: typeof launchdJobMatchesPlist;
1555
+ } = {}): void {
1556
+ const run = deps.launchctl ?? runLaunchctl;
1557
+ const p = plistPath();
1558
+ const loaded = run(["load", "-w", p]);
1559
+ if (loaded.ok && !launchctlLoadFailed(loaded.stderr)) return;
1560
+ // `Load failed` on start is AMBIGUOUS in a way it is not on install: the job may
1561
+ // already be bootstrapped from THIS plist, which is a no-op rather than an error.
1562
+ // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
1563
+ // throwing here would break `ocx service start` on every healthy service.
1564
+ const entry = cliEntry();
1565
+ const live = (deps.matches ?? launchdJobMatchesPlist)(
1566
+ buildServiceShellCommand(entry.bun, entry.cli),
1567
+ );
1568
+ if (live.loaded && live.matchesPlist) {
1569
+ console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
1570
+ return;
1571
+ }
1572
+ throw new Error(
1573
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1574
+ + (live.loaded
1575
+ ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service install`
1576
+ : "The job is not loaded. Run 'ocx service install' to re-register it."),
1577
+ );
1578
+ }
1295
1579
  function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
1296
1580
  function statusLaunchd(): string { try { return sh(`launchctl list | grep ${LABEL} || true`); } catch { return ""; } }
1297
1581
  function uninstallLaunchd(): void {
@@ -1646,6 +1930,28 @@ function installSystemd(): void {
1646
1930
  sh(`systemctl --user restart ${TASK}`);
1647
1931
  writeServiceInstallState();
1648
1932
  }
1933
+ /**
1934
+ * Whether systemd's in-memory unit differs from the file on disk.
1935
+ *
1936
+ * The systemd analogue of launchd's stale-plist case: writing
1937
+ * `~/.config/systemd/user/<unit>` does not change the definition systemd has loaded
1938
+ * until `daemon-reload`, so a plain `systemctl start` would run the PREVIOUS
1939
+ * ExecStart. `NeedDaemonReload` is a per-unit property emitted as a bare
1940
+ * `NeedDaemonReload=yes|no` line; pass the unit name or `show` reports the manager's
1941
+ * own property instead, which answers a different question.
1942
+ *
1943
+ * Fail-open: if the query cannot run (no user bus, unit absent) we must not block a
1944
+ * start that would otherwise work.
1945
+ */
1946
+ export function systemdNeedsDaemonReload(deps: { show?: () => string } = {}): boolean {
1947
+ try {
1948
+ const out = (deps.show ?? (() => sh(`systemctl --user show -p NeedDaemonReload ${TASK}`)))();
1949
+ return /NeedDaemonReload\s*=\s*yes/i.test(out);
1950
+ } catch {
1951
+ return false;
1952
+ }
1953
+ }
1954
+
1649
1955
  function startSystemd(): void {
1650
1956
  ensureUserBusEnv();
1651
1957
  if (!existsSync(unitPath())) {
@@ -1653,6 +1959,19 @@ function startSystemd(): void {
1653
1959
  console.error("Run `ocx service install` first to create and enable the systemd user unit.");
1654
1960
  process.exit(1);
1655
1961
  }
1962
+ // The unit on disk may be newer than what systemd loaded; starting now would run
1963
+ // the previous definition.
1964
+ //
1965
+ // `start` alone is not enough after a reload: it is a no-op on an already-active
1966
+ // unit, so the stale process would keep running the old ExecStart. NeedDaemonReload
1967
+ // compares disk against loaded, never loaded against running, so the only way to
1968
+ // make the running process match the file is to restart it.
1969
+ if (systemdNeedsDaemonReload()) {
1970
+ console.log("ℹ️ unit file changed on disk; reloading systemd and restarting the service.");
1971
+ sh("systemctl --user daemon-reload");
1972
+ sh(`systemctl --user restart ${TASK}`);
1973
+ return;
1974
+ }
1656
1975
  sh(`systemctl --user start ${TASK}`);
1657
1976
  }
1658
1977
  function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
@@ -2011,6 +2330,60 @@ export function serviceStatusSummary(): string {
2011
2330
  return diagnoseService().summary;
2012
2331
  }
2013
2332
 
2333
+ /**
2334
+ * Status a human can act on: registration state, whether a proxy actually answers,
2335
+ * and — when it does not — whether launchd is running the plist we have on disk.
2336
+ *
2337
+ * `launchctl list` membership cannot distinguish "serving", "bootstrapped from an
2338
+ * older plist", and "loaded but never bound"; the reported failure was the middle
2339
+ * one presented as the first.
2340
+ *
2341
+ * Resolves the port through `confirmServiceServing`, i.e. the same
2342
+ * `installedServiceListenPort()` path install/start/repair use, so those surfaces can
2343
+ * never disagree about one service. The budget is short (2 probes) because this is a
2344
+ * status read, not a post-install wait.
2345
+ */
2346
+ export async function serviceStatusReport(
2347
+ deps: {
2348
+ diagnose?: () => ServiceDiagnostic;
2349
+ serving?: () => Promise<{ ok: boolean; port: number }>;
2350
+ matchesPlist?: () => { loaded: boolean; matchesPlist: boolean };
2351
+ } = {},
2352
+ ): Promise<string> {
2353
+ const diag = (deps.diagnose ?? diagnoseService)();
2354
+ if (!diag.installed) return `❌ ${diag.summary}`;
2355
+
2356
+ const serving = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))();
2357
+ if (serving.ok) return `✅ ${diag.summary}\n Serving on port ${serving.port}.`;
2358
+
2359
+ // The dep is consulted FIRST; the platform check only guards the default. Wrapping
2360
+ // the whole expression in a darwin check would discard an injected seam on
2361
+ // Linux/Windows and make the stale-plist case untestable there.
2362
+ const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
2363
+ ? (() => {
2364
+ const entry = cliEntry();
2365
+ // Pass the INSTALLED port explicitly: the default third argument is
2366
+ // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
2367
+ // a config edit the expected string would never match and every run would
2368
+ // print a false "OLDER plist".
2369
+ return launchdJobMatchesPlist(
2370
+ buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()),
2371
+ );
2372
+ })()
2373
+ : null);
2374
+ const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
2375
+ ? " launchd is running an OLDER plist than the one on disk.\n"
2376
+ + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service install\n`
2377
+ : "";
2378
+
2379
+ return `⚠️ ${diag.summary}\n`
2380
+ + ` Registered, but no proxy is answering on port ${serving.port}.\n`
2381
+ + staleLine
2382
+ + ` Log: ${serviceLogPath()}\n`
2383
+ + ` Repair: ${serviceRepairCommand()}\n`
2384
+ + " Meanwhile: ocx start (serves in the foreground)";
2385
+ }
2386
+
2014
2387
  export function normalizeServiceSubcommand(sub?: string): string {
2015
2388
  return sub ?? "install";
2016
2389
  }
@@ -2064,7 +2437,11 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2064
2437
  assertServiceEnvironmentMatchesInstall();
2065
2438
  assertServiceAuthEnvironment();
2066
2439
  await repairService();
2067
- console.log("✅ opencodex background service repaired (assets refreshed, no Task Scheduler re-registration).");
2440
+ // All three platforms: a repair that reports success while nothing serves is the
2441
+ // defect class this unit exists to close. Windows bakes its port into the
2442
+ // scheduler wrapper or the WinSW XML, both of which installedServiceListenPort()
2443
+ // now reads.
2444
+ await reportServiceServing("repaired");
2068
2445
  return;
2069
2446
  }
2070
2447
  // Non-install subcommands follow the backend recorded at install time (state v2).
@@ -2079,9 +2456,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2079
2456
  assertServiceEnvironmentMatchesInstall();
2080
2457
  assertServiceAuthEnvironment();
2081
2458
  await ops.install();
2082
- console.log(backend === "native"
2083
- ? "✅ opencodex native service installed + started (windowless, starts at boot, auto-restarts on crash)."
2084
- : "✅ opencodex service installed + started (auto-starts on login, auto-restarts on crash).");
2459
+ // The wrapper was written moments ago in this process, so the configured port
2460
+ // and the baked one cannot have diverged yet unlike `start`, which reads the
2461
+ // installed artifact instead.
2462
+ await reportServiceServing("installed", { port: resolveServiceListenPort() });
2085
2463
  if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
2086
2464
  // Service users never reach the `ocx start` prompt: the proxy they run is the
2087
2465
  // supervised child, which always carries OCX_SERVICE=1. This command, though, is
@@ -2091,7 +2469,7 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2091
2469
  break;
2092
2470
  case "start":
2093
2471
  ops.start();
2094
- console.log("✅ service started.");
2472
+ await reportServiceServing("started");
2095
2473
  break;
2096
2474
  case "stop": {
2097
2475
  assertServiceEnvironmentMatchesInstall();
@@ -2131,8 +2509,10 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2131
2509
  if (process.platform === "win32" && backend === "scheduler") {
2132
2510
  console.log(await inspectWindowsSchedulerServiceStatus());
2133
2511
  } else {
2134
- const s = ops.status();
2135
- console.log(s ? `✅ running:\n${s}` : "❌ service not installed/running.");
2512
+ // Replaces raw `ops.status()` output, which on darwin is a `launchctl list`
2513
+ // line: registration reported as if it were service. serviceStatusReport
2514
+ // subsumes the not-installed case and adds the serving / stale-plist split.
2515
+ console.log(await serviceStatusReport());
2136
2516
  }
2137
2517
  console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
2138
2518
  break;
@@ -2172,4 +2552,3 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
2172
2552
  process.exit(1);
2173
2553
  }
2174
2554
  }
2175
-
@@ -51,6 +51,7 @@ export type CleanupErrorCode =
51
51
  | "fs_failed"
52
52
  | "db_reconcile_failed"
53
53
  | "referenced_history"
54
+ | "pinned_thread"
54
55
  | "restore_pending_overlap"
55
56
  | "cleanup_failed";
56
57
 
@@ -430,12 +431,61 @@ export function collectRestorePendingAcceptedDestRels(codexHome: string): Set<st
430
431
  return out;
431
432
  }
432
433
 
434
+ /**
435
+ * Normalized rollout paths of pinned threads. Pinned threads are never
436
+ * cleanup candidates: a pin is the user's explicit "keep this" signal and
437
+ * deleting its rollout would be permanent task-data loss (#858).
438
+ *
439
+ * Selection-time use is advisory: on any DB problem this returns an empty
440
+ * set, and the write-locked re-check inside reconcileDeletedThreads stays
441
+ * the fail-closed gate. Older schemas without `is_pinned` keep prior
442
+ * behavior.
443
+ */
444
+ function collectPinnedArchivedRolloutPaths(codexHome: string): Set<string> {
445
+ const statePath = discoverRuntimeDbPaths(codexHome).state;
446
+ if (!statePath || !existsSync(statePath)) return new Set();
447
+ let db: Database | undefined;
448
+ try {
449
+ db = new Database(statePath, { readonly: true });
450
+ if (!tableExists(db, "threads") || !columnExists(db, "threads", "is_pinned")) {
451
+ return new Set();
452
+ }
453
+ const rows = db.query<{ rollout_path: string }, []>(
454
+ `SELECT rollout_path FROM threads WHERE is_pinned = 1`,
455
+ ).all();
456
+ const out = new Set<string>();
457
+ for (const row of rows) {
458
+ const normalized = normalizeArchivedRolloutPath(row.rollout_path, codexHome);
459
+ if (normalized) out.add(normalized);
460
+ }
461
+ return out;
462
+ } catch {
463
+ return new Set();
464
+ } finally {
465
+ try { db?.close(); } catch { /* */ }
466
+ }
467
+ }
468
+
469
+ /** Drop candidates whose rollout belongs to a pinned thread (#858). */
470
+ export function filterCandidatesExcludingPinned(
471
+ candidates: ArchivedCandidate[],
472
+ codexHome: string,
473
+ ): ArchivedCandidate[] {
474
+ const pinned = collectPinnedArchivedRolloutPaths(codexHome);
475
+ if (pinned.size === 0) return candidates;
476
+ return candidates.filter(c => !pinned.has(c.relPath));
477
+ }
478
+
433
479
  export function previewArchivedCleanup(
434
480
  percent: number,
435
481
  codexHome: string = resolveCodexHomeDir(),
436
482
  ): CleanupPreview {
437
483
  const all = listArchivedCandidates(codexHome);
438
- const safe = selectOldestPercentSkippingPendingRestore(all, percent, codexHome);
484
+ const safe = selectOldestPercentSkippingPendingRestore(
485
+ filterCandidatesExcludingPinned(all, codexHome),
486
+ percent,
487
+ codexHome,
488
+ );
439
489
  const pct = clampPercent(percent);
440
490
  return {
441
491
  codexHome,
@@ -452,7 +502,10 @@ export function previewExactArchivedCleanup(
452
502
  candidates: ArchivedCandidate[],
453
503
  codexHome: string = resolveCodexHomeDir(),
454
504
  ): CleanupPreview {
455
- const safe = filterCandidatesExcludingPendingRestore(candidates, codexHome);
505
+ const safe = filterCandidatesExcludingPinned(
506
+ filterCandidatesExcludingPendingRestore(candidates, codexHome),
507
+ codexHome,
508
+ );
456
509
  return {
457
510
  codexHome,
458
511
  percent: 0,
@@ -561,6 +614,7 @@ interface ThreadSnapshot {
561
614
  rollout_path: string;
562
615
  archived: number | null;
563
616
  history_mode?: string | null;
617
+ is_pinned?: number | null;
564
618
  }
565
619
 
566
620
  /**
@@ -575,11 +629,13 @@ function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], code
575
629
  const logicalSet = new Set(candidates.map(c => c.relPath));
576
630
  const hasArchived = columnExists(db, "threads", "archived");
577
631
  const hasHistoryMode = columnExists(db, "threads", "history_mode");
632
+ const hasIsPinned = columnExists(db, "threads", "is_pinned");
578
633
  const selectCols = ["id", "rollout_path"];
579
634
  if (hasArchived) selectCols.push("archived");
580
635
  if (hasHistoryMode) selectCols.push("history_mode");
636
+ if (hasIsPinned) selectCols.push("is_pinned");
581
637
  const rows = db.query<
582
- { id: string; rollout_path: string; archived?: number | null; history_mode?: string | null },
638
+ { id: string; rollout_path: string; archived?: number | null; history_mode?: string | null; is_pinned?: number | null },
583
639
  []
584
640
  >(`SELECT ${selectCols.join(", ")} FROM threads`).all();
585
641
 
@@ -597,6 +653,7 @@ function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], code
597
653
  rollout_path: row.rollout_path,
598
654
  archived: hasArchived ? (row.archived ?? null) : null,
599
655
  history_mode: hasHistoryMode ? (row.history_mode ?? null) : null,
656
+ is_pinned: hasIsPinned ? (row.is_pinned ?? null) : null,
600
657
  }));
601
658
  }
602
659
 
@@ -734,6 +791,8 @@ type SatelliteBackupRead =
734
791
  | { status: "invalid" };
735
792
 
736
793
  interface ReconcileTestHooks {
794
+ /** Runs at the top of reconcileDeletedThreads, before the write lock is taken. */
795
+ beforeReconcileLock?: () => void;
737
796
  failAfterLogsMutation?: boolean;
738
797
  failAfterMemoriesMutation?: boolean;
739
798
  failAfterGoalsMutation?: boolean;
@@ -1414,6 +1473,9 @@ function loadThreadsForCleanup(
1414
1473
  try {
1415
1474
  db = openDbWritable(stateDbPath, busyTimeoutMs);
1416
1475
  const threads = loadMatchingThreads(db, candidates, codexHome);
1476
+ if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) {
1477
+ return { ok: false, error: "pinned_thread" };
1478
+ }
1417
1479
  if (findReferencedHistory(db, threads)) {
1418
1480
  return { ok: false, error: "referenced_history" };
1419
1481
  }
@@ -1442,6 +1504,8 @@ function reconcileDeletedThreads(
1442
1504
  ): ReconcileOk | ReconcileErr {
1443
1505
  if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [] };
1444
1506
 
1507
+ if (hooks?.beforeReconcileLock) hooks.beforeReconcileLock();
1508
+
1445
1509
  let stateDb: Database | undefined;
1446
1510
  let backup: SatelliteBackup | undefined;
1447
1511
  let satellitesMutated = false;
@@ -1474,6 +1538,12 @@ function reconcileDeletedThreads(
1474
1538
 
1475
1539
  // Freeze the exact delete set under the write lock before any satellite mutation.
1476
1540
  const threads = loadMatchingThreads(stateDb, candidates, codexHome);
1541
+ // A pin applied after selection must stop the delete, even though the
1542
+ // staged files are already in trash staging — the caller restores them.
1543
+ if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) {
1544
+ stateDb.exec("ROLLBACK");
1545
+ return { ok: false, error: "pinned_thread" };
1546
+ }
1477
1547
  if (findReferencedHistory(stateDb, threads)) {
1478
1548
  stateDb.exec("ROLLBACK");
1479
1549
  return { ok: false, error: "referenced_history" };
@@ -1675,20 +1745,21 @@ export interface ExecuteCleanupOptions {
1675
1745
  failSatelliteBackupWrite?: boolean;
1676
1746
  failSatelliteBackupReplace?: boolean;
1677
1747
  afterSatelliteMutations?: () => void;
1748
+ beforeReconcileLock?: () => void;
1678
1749
  };
1679
1750
  }
1680
1751
 
1681
1752
  /** Serializable cleanup test hooks allowed on the management API wire. */
1682
1753
  export type CleanupWireTestHooks = Omit<
1683
1754
  NonNullable<ExecuteCleanupOptions["_test"]>,
1684
- "afterSatelliteMutations"
1755
+ "afterSatelliteMutations" | "beforeReconcileLock"
1685
1756
  >;
1686
1757
 
1687
1758
  function isStringArray(v: unknown): v is string[] {
1688
1759
  return Array.isArray(v) && v.every(e => typeof e === "string");
1689
1760
  }
1690
1761
 
1691
- /** Pick only allowlisted serializable hooks; drops afterSatelliteMutations and unknown keys. */
1762
+ /** Pick only allowlisted serializable hooks; drops function hooks (afterSatelliteMutations, beforeReconcileLock) and unknown keys. */
1692
1763
  export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined {
1693
1764
  if (!raw || typeof raw !== "object") return undefined;
1694
1765
  const o = raw as Record<string, unknown>;