@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.
- package/README.md +91 -449
- package/gui/dist/assets/index-OY43ubAq.css +1 -0
- package/gui/dist/assets/index-YwNnKZcL.js +67 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/pi.svg +21 -0
- package/package.json +1 -1
- package/src/cli/account.ts +3 -5
- package/src/cli/claude-desktop.ts +43 -7
- package/src/cli/doctor.ts +12 -0
- package/src/cli/help.ts +1 -1
- package/src/cli/provider-runtime.ts +7 -0
- package/src/cli/star-prompt.ts +25 -4
- package/src/cli/status.ts +7 -2
- package/src/codex/app-server-processes.ts +299 -54
- package/src/codex/catalog/metadata.ts +9 -11
- package/src/codex/catalog/provider-fetch.ts +10 -10
- package/src/codex/catalog/sync.ts +27 -2
- package/src/codex/catalog.ts +1 -1
- package/src/config.ts +15 -1
- package/src/lib/bun-stream-caps.ts +14 -0
- package/src/oauth/index.ts +48 -3
- package/src/providers/registry.ts +62 -0
- package/src/server/index.ts +39 -3
- package/src/server/management/agent-settings-routes.ts +40 -3
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/provider-routes.ts +4 -1
- package/src/server/management/sidebar-routes.ts +3 -1
- package/src/server/relay-eager.ts +100 -2
- package/src/server/responses/collaboration.ts +21 -0
- package/src/server/responses/core.ts +20 -12
- package/src/service.ts +393 -14
- package/src/storage/cleanup.ts +76 -5
- package/src/storage/policy.ts +6 -1
- package/src/types.ts +2 -0
- package/src/update/index.ts +9 -2
- package/src/update/job.ts +87 -11
- package/gui/dist/assets/index-CHwf3tTD.css +0 -1
- 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
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2135
|
-
|
|
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
|
-
|
package/src/storage/cleanup.ts
CHANGED
|
@@ -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(
|
|
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 =
|
|
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>;
|