@bitkyc08/opencodex 2.81.0 → 2.82.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/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -4
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +19 -17
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +25 -2
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +20 -1
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +43 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +303 -171
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +50 -4
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +6 -2
- package/src/oauth/store.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +1 -0
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/serve-options.ts +10 -2
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log.ts +31 -2
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +34 -13
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +19 -2
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +25 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +5 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type BigIntStats } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { plistPath, resolveServiceOwnership, resolveServiceState, serviceStatePaths, type ServiceStateEvidence } from "../service/state";
|
|
5
|
+
import { unitPath } from "../service/systemd";
|
|
6
|
+
import { inspectInstallStateBytes } from "../service/install-state-contract.mjs";
|
|
7
|
+
|
|
8
|
+
export interface UpdateRestartServiceRecord { schema: 1; digest: string }
|
|
9
|
+
export interface UpdateRestartServiceRecordDeps {
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
11
|
+
paths?: () => readonly string[];
|
|
12
|
+
definitionPath?: () => string;
|
|
13
|
+
open?: (path: string, flags: number) => number;
|
|
14
|
+
fstat?: (fd: number) => BigIntStats;
|
|
15
|
+
read?: (fd: number, buffer: Buffer, offset: number, length: number, position: number) => number;
|
|
16
|
+
lstat?: (path: string) => BigIntStats;
|
|
17
|
+
}
|
|
18
|
+
/** Maximum bytes per state candidate or platform definition: 1 MiB, including growth during read. */
|
|
19
|
+
export const UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES = 1024 * 1024;
|
|
20
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
21
|
+
const FAILURE = "update_restart_service_record_unverified";
|
|
22
|
+
const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
23
|
+
const metadata = (stat: BigIntStats) => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode, stat.uid, stat.gid].map(String);
|
|
24
|
+
const absent = (error: unknown) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
|
|
25
|
+
|
|
26
|
+
// Canonicalize directories, never the record itself: a record symlink is a refusal.
|
|
27
|
+
function canonicalPath(path: string): string {
|
|
28
|
+
const parent = dirname(path);
|
|
29
|
+
try { return join(realpathSync.native(parent), basename(path)); }
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (!absent(error) || parent === path) throw new Error(FAILURE);
|
|
32
|
+
return join(canonicalPath(parent), basename(path));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readBoundedRecord(fd: number, read: NonNullable<UpdateRestartServiceRecordDeps["read"]>): Buffer {
|
|
37
|
+
const chunk = Buffer.alloc(READ_CHUNK_BYTES);
|
|
38
|
+
const chunks: Buffer[] = [];
|
|
39
|
+
let total = 0;
|
|
40
|
+
for (;;) {
|
|
41
|
+
// At the limit, a one-byte read distinguishes EOF from concurrent growth.
|
|
42
|
+
const length = Math.min(chunk.length, UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES - total + 1);
|
|
43
|
+
const count = read(fd, chunk, 0, length, total);
|
|
44
|
+
if (!Number.isInteger(count) || count < 0 || count > length) throw new Error(FAILURE);
|
|
45
|
+
if (count === 0) return Buffer.concat(chunks, total);
|
|
46
|
+
total += count;
|
|
47
|
+
if (total > UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES) throw new Error(FAILURE);
|
|
48
|
+
chunks.push(Buffer.from(chunk.subarray(0, count)));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read all authority/mirror candidates and the platform definition as one frozen record. */
|
|
53
|
+
export function captureUpdateRestartServiceRecord(deps: UpdateRestartServiceRecordDeps = {}) {
|
|
54
|
+
try {
|
|
55
|
+
const platform = deps.platform ?? process.platform;
|
|
56
|
+
const paths = (deps.paths ?? serviceStatePaths)();
|
|
57
|
+
const definition = platform === "darwin" ? (deps.definitionPath ?? plistPath)()
|
|
58
|
+
: platform === "linux" ? (deps.definitionPath ?? unitPath)() : undefined;
|
|
59
|
+
const statPath = deps.lstat ?? (path => lstatSync(path, { bigint: true }));
|
|
60
|
+
const captures = [...paths, ...(definition ? [definition] : [])].map((path, index) => {
|
|
61
|
+
const canonical = canonicalPath(path);
|
|
62
|
+
let before: BigIntStats;
|
|
63
|
+
try { before = statPath(path); }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (!absent(error)) throw error;
|
|
66
|
+
return { path, canonical, index, identity: null, bytes: undefined };
|
|
67
|
+
}
|
|
68
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(FAILURE);
|
|
69
|
+
const fd = (deps.open ?? openSync)(path, constants.O_RDONLY | (platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
|
|
70
|
+
try {
|
|
71
|
+
const statFd = deps.fstat ?? (handle => fstatSync(handle, { bigint: true }));
|
|
72
|
+
const opened = statFd(fd);
|
|
73
|
+
const identity = metadata(before);
|
|
74
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
75
|
+
|| opened.size < 0n || opened.size > BigInt(UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES)
|
|
76
|
+
|| JSON.stringify(identity) !== JSON.stringify(metadata(opened))) throw new Error(FAILURE);
|
|
77
|
+
const bytes = readBoundedRecord(fd, deps.read ?? readSync);
|
|
78
|
+
const after = statFd(fd);
|
|
79
|
+
if (!after.isFile() || JSON.stringify(identity) !== JSON.stringify(metadata(after))
|
|
80
|
+
|| BigInt(bytes.length) !== after.size) throw new Error(FAILURE);
|
|
81
|
+
return { path, canonical, index, identity, bytes };
|
|
82
|
+
} finally { closeSync(fd); }
|
|
83
|
+
});
|
|
84
|
+
// Pin path-to-descriptor identity after every read; also confirm each ENOENT again.
|
|
85
|
+
for (const entry of captures) {
|
|
86
|
+
let current: BigIntStats;
|
|
87
|
+
try { current = statPath(entry.path); }
|
|
88
|
+
catch (error) { if (entry.identity === null && absent(error)) continue; throw error; }
|
|
89
|
+
if (!current.isFile() || JSON.stringify(metadata(current)) !== JSON.stringify(entry.identity)
|
|
90
|
+
|| canonicalPath(entry.path) !== entry.canonical) throw new Error(FAILURE);
|
|
91
|
+
}
|
|
92
|
+
const evidence: ServiceStateEvidence[] = captures.slice(0, paths.length).map(entry => {
|
|
93
|
+
if (!entry.bytes) return { path: entry.canonical, kind: "absent" };
|
|
94
|
+
const parsed = inspectInstallStateBytes(entry.canonical, () => entry.bytes!.toString("utf8"));
|
|
95
|
+
if (parsed.kind !== "valid") throw new Error(FAILURE);
|
|
96
|
+
return parsed as ServiceStateEvidence;
|
|
97
|
+
});
|
|
98
|
+
const state = resolveServiceState(evidence);
|
|
99
|
+
const owner = resolveServiceOwnership(evidence);
|
|
100
|
+
if (state.kind === "unknown" || owner.kind === "unknown") throw new Error(FAILURE);
|
|
101
|
+
const serviceRecord: UpdateRestartServiceRecord = { schema: 1, digest: hash(JSON.stringify({
|
|
102
|
+
platform, authority: paths.length - 1,
|
|
103
|
+
records: captures.map(entry => [entry.canonical, entry.index, entry.identity, entry.bytes ? hash(entry.bytes) : null]),
|
|
104
|
+
})) };
|
|
105
|
+
return { serviceRecord, state, owner };
|
|
106
|
+
} catch { throw new Error(FAILURE); }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Drift is terminal; never replace the transaction's original digest. */
|
|
110
|
+
export function assertUpdateRestartServiceRecord(expected: UpdateRestartServiceRecord, deps: UpdateRestartServiceRecordDeps = {}) {
|
|
111
|
+
try {
|
|
112
|
+
const current = captureUpdateRestartServiceRecord(deps);
|
|
113
|
+
if (expected.schema !== 1 || current.serviceRecord.digest !== expected.digest) throw new Error(FAILURE);
|
|
114
|
+
return current;
|
|
115
|
+
} catch { throw new Error("update_restart_home_changed"); }
|
|
116
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { statSync, type Stats } from "node:fs";
|
|
3
|
+
import { systemdUserBusEnvironment } from "../service/systemd";
|
|
4
|
+
import { assertLiveServiceManagerAllowed } from "../service/guards";
|
|
5
|
+
import { LABEL, TASK } from "../service/state";
|
|
6
|
+
|
|
7
|
+
export type UpdateRestartSupervision = "inactive" | "active" | "unknown";
|
|
8
|
+
export interface UpdateRestartSupervisorReply { status: number | null; stdout: string; stderr: string }
|
|
9
|
+
export interface UpdateRestartSupervisionDeps {
|
|
10
|
+
platform?: NodeJS.Platform;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
uid?: number;
|
|
13
|
+
stat?: (path: string) => Pick<Stats, "isFile" | "mode">;
|
|
14
|
+
environment?: NodeJS.ProcessEnv;
|
|
15
|
+
exists?: (path: string) => boolean;
|
|
16
|
+
run?: (command: string, args: string[], timeoutMs: number, environment: NodeJS.ProcessEnv) => UpdateRestartSupervisorReply;
|
|
17
|
+
}
|
|
18
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
19
|
+
export const UPDATE_RESTART_SYSTEMD_ARGS = ["--user", "show", "-p", "LoadState", "-p", "ActiveState", "-p", "MainPID", "-p", "FragmentPath", "-p", "NeedDaemonReload", TASK];
|
|
20
|
+
|
|
21
|
+
function run(command: string, args: string[], timeout: number, env: NodeJS.ProcessEnv): UpdateRestartSupervisorReply {
|
|
22
|
+
assertLiveServiceManagerAllowed("update restart supervision");
|
|
23
|
+
try {
|
|
24
|
+
return { status: 0, stdout: execFileSync(command, args, { encoding: "utf8", timeout, env,
|
|
25
|
+
killSignal: "SIGKILL", maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "pipe"] }), stderr: "" };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
const failure = error as { status?: number | null; signal?: string; code?: string; stdout?: Buffer; stderr?: Buffer };
|
|
28
|
+
if (failure.signal || failure.code || !Number.isInteger(failure.status)) throw new Error("update_restart_supervision_unverified");
|
|
29
|
+
return { status: failure.status!, stdout: failure.stdout?.toString() ?? "", stderr: failure.stderr?.toString() ?? "" };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Ignore PATH; usr-merged systemctl symlinks may resolve to a regular trusted-location target. */
|
|
34
|
+
export function resolveUpdateRestartSupervisor(deps: UpdateRestartSupervisionDeps = {}): string | null {
|
|
35
|
+
const platform = deps.platform ?? process.platform;
|
|
36
|
+
const paths = platform === "linux" ? ["/usr/bin/systemctl", "/bin/systemctl"] : platform === "darwin" ? ["/bin/launchctl"] : [];
|
|
37
|
+
for (const path of paths) {
|
|
38
|
+
try {
|
|
39
|
+
const stat = (deps.stat ?? statSync)(path);
|
|
40
|
+
if (stat.isFile() && (stat.mode & 0o002) === 0 && (stat.mode & 0o111) !== 0) return path;
|
|
41
|
+
} catch { /* Try the next trusted location; no PATH fallback. */ }
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** All manager evidence shares the transaction deadline, including the retained PID-bound probe. */
|
|
47
|
+
export function runBoundedUpdateRestartSupervisor(command: string, args: string[], deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}) {
|
|
48
|
+
const now = deps.now ?? Date.now;
|
|
49
|
+
const remaining = deadlineAt - now();
|
|
50
|
+
if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("update_restart_supervision_unverified");
|
|
51
|
+
const environment = (deps.platform ?? process.platform) === "linux"
|
|
52
|
+
? systemdUserBusEnvironment(deps.environment ?? process.env, { uid: deps.uid, exists: deps.exists })
|
|
53
|
+
: { ...(deps.environment ?? process.env) };
|
|
54
|
+
const result = (deps.run ?? run)(command, args, Math.min(PROBE_TIMEOUT_MS, remaining), environment);
|
|
55
|
+
if (now() >= deadlineAt) throw new Error("update_restart_supervision_unverified");
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Registration presence is independent of liveness; only positive inactivity admits. */
|
|
60
|
+
export function probeUpdateRestartSupervision(deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}, command = resolveUpdateRestartSupervisor(deps)): UpdateRestartSupervision {
|
|
61
|
+
const platform = deps.platform ?? process.platform;
|
|
62
|
+
if (!command) return "unknown";
|
|
63
|
+
if (platform === "darwin") {
|
|
64
|
+
const uid = deps.uid ?? process.getuid?.() ?? 0;
|
|
65
|
+
const states = [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`].map(target => {
|
|
66
|
+
try {
|
|
67
|
+
const result = runBoundedUpdateRestartSupervisor(command, ["print", target], deadlineAt, deps);
|
|
68
|
+
return result.status === 112 || result.status === 113 ? "inactive" : result.status === 0 ? "active" : "unknown";
|
|
69
|
+
} catch { return "unknown"; }
|
|
70
|
+
});
|
|
71
|
+
return states.includes("unknown") ? "unknown" : states.includes("active") ? "active" : "inactive";
|
|
72
|
+
}
|
|
73
|
+
if (platform === "linux") {
|
|
74
|
+
try {
|
|
75
|
+
const result = runBoundedUpdateRestartSupervisor(command, UPDATE_RESTART_SYSTEMD_ARGS, deadlineAt, deps);
|
|
76
|
+
return classifyUpdateRestartSystemdSupervision(result);
|
|
77
|
+
} catch { return "unknown"; }
|
|
78
|
+
}
|
|
79
|
+
return "unknown";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The retained manager observation must satisfy the same positive-inactivity contract. */
|
|
83
|
+
export function classifyUpdateRestartSystemdSupervision(result: UpdateRestartSupervisorReply): UpdateRestartSupervision {
|
|
84
|
+
if (result.status !== 0) return "unknown";
|
|
85
|
+
const fields = new Map<string, string>();
|
|
86
|
+
for (const line of result.stdout.trim().split(/\r?\n/)) {
|
|
87
|
+
const match = /^([A-Za-z]+)=(.*)$/.exec(line);
|
|
88
|
+
if (!match || fields.has(match[1]!)) return "unknown";
|
|
89
|
+
fields.set(match[1]!, match[2]!);
|
|
90
|
+
}
|
|
91
|
+
const pid = fields.get("MainPID");
|
|
92
|
+
if (!pid || !/^\d+$/.test(pid) || !Number.isSafeInteger(Number(pid))) return "unknown";
|
|
93
|
+
if (!["loaded", "not-found"].includes(fields.get("LoadState") ?? "")) return "unknown";
|
|
94
|
+
if (fields.get("ActiveState") === "inactive" && pid === "0") return "inactive";
|
|
95
|
+
return fields.get("ActiveState") === "active" || Number(pid) > 0 ? "active" : "unknown";
|
|
96
|
+
}
|
|
@@ -7,21 +7,23 @@ import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
|
|
|
7
7
|
import { isProcessAlive } from "../lib/process-control";
|
|
8
8
|
import { parseStrictSemver } from "../lib/strict-semver";
|
|
9
9
|
import { startArgv } from "../lib/self-launch-argv";
|
|
10
|
-
import {
|
|
11
|
-
import { inspectGuardedManagerTarget } from "../service/guarded-manager-target";
|
|
10
|
+
import { inspectGuardedManagerTarget, type GuardedManagerDeps } from "../service/guarded-manager-target";
|
|
12
11
|
import { acquireOwnershipMutationLease, unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs";
|
|
13
12
|
import { serviceStatePaths } from "../service/state";
|
|
14
13
|
import { findLiveProxy, probeEndpointLiveness, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
15
14
|
import { waitForPortAvailable } from "../server/ports";
|
|
16
15
|
import { UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildMarker } from "./update-restart-child";
|
|
17
|
-
import { assertUpdateRestartConfiguration, assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home";
|
|
16
|
+
import { assertUpdateRestartConfiguration, assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome, type UpdateRestartHomeDeps } from "./update-restart-home";
|
|
18
17
|
import { observeAttestedUpdateReplacement, stopAttestedUpdateTarget } from "./update-restart-transport";
|
|
19
18
|
import type { UpdateRestartCandidate } from "./update-restart-candidate";
|
|
20
19
|
import { INCOMPLETE_INSTALL_RECOVERY } from "./restart-failure";
|
|
21
20
|
import { computeVersionSkew } from "./version-skew";
|
|
21
|
+
import { createSupervisionLatch, inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
|
|
22
|
+
import { classifyUpdateRestartSystemdSupervision, probeUpdateRestartSupervision, resolveUpdateRestartSupervisor, runBoundedUpdateRestartSupervisor, UPDATE_RESTART_SYSTEMD_ARGS, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
|
|
22
23
|
|
|
23
24
|
export interface UpdateRestartChild { pid?: number; exitCode: number | null; signalCode: string | null }
|
|
24
25
|
export interface UpdateRestartIo {
|
|
26
|
+
inspectSupervision?: typeof inspectDesktopSupervision;
|
|
25
27
|
now(): number;
|
|
26
28
|
acquire(): { release(): void };
|
|
27
29
|
home(): UpdateRestartHome;
|
|
@@ -37,7 +39,7 @@ export interface UpdateRestartIo {
|
|
|
37
39
|
wait(ms: number): Promise<void>;
|
|
38
40
|
}
|
|
39
41
|
const ELIGIBILITY_REASONS = ["windows", "unsupported_platform", "foreground", "service", "shared_or_client",
|
|
40
|
-
"package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed"] as const;
|
|
42
|
+
"package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed", "desktop"] as const;
|
|
41
43
|
export type UpdateRestartEligibilityReason = (typeof ELIGIBILITY_REASONS)[number];
|
|
42
44
|
|
|
43
45
|
/** Carries only a closed reason code across the eligibility/transport boundary. */
|
|
@@ -56,6 +58,7 @@ function sameRuntime(candidate: UpdateRestartCandidate, current: RuntimePortStat
|
|
|
56
58
|
|
|
57
59
|
/** A terminal update transaction: it never enters generic restart recovery. */
|
|
58
60
|
export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadlineAt: number, io: UpdateRestartIo): Promise<UpdateRestartResult> {
|
|
61
|
+
const latch = createSupervisionLatch();
|
|
59
62
|
let phase = "eligibility";
|
|
60
63
|
let lease: { release(): void } | undefined;
|
|
61
64
|
// Recorded here because the stop transport sanitizes anything thrown by beforeStop.
|
|
@@ -79,6 +82,13 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli
|
|
|
79
82
|
eligibilityReason = currentRuntime?.siblingOfPort !== undefined ? "shared_or_client" : "target_changed";
|
|
80
83
|
throw new Error("target");
|
|
81
84
|
}
|
|
85
|
+
const supervision = (io.inspectSupervision ?? inspectDesktopSupervision)({ targetPid: candidate.target.pid });
|
|
86
|
+
const evidence = supervision.kind === "desktop" && supervision.runtimePid !== candidate.target.pid
|
|
87
|
+
? { kind: "unknown" as const, reason: "unrelated-target", desktopSeen: false } : supervision;
|
|
88
|
+
if (latch.observe(evidence)) {
|
|
89
|
+
eligibilityReason = "desktop";
|
|
90
|
+
throw new UpdateRestartEligibilityError("desktop");
|
|
91
|
+
}
|
|
82
92
|
try {
|
|
83
93
|
if (!io.standalone(candidate.target)) throw new Error("target");
|
|
84
94
|
} catch (error) {
|
|
@@ -147,6 +157,7 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar
|
|
|
147
157
|
switch (reason) {
|
|
148
158
|
case "windows": return "Restart from a newer CLI is unavailable on Windows. Run `ocx status`, then restart through the owning service or desktop app.";
|
|
149
159
|
case "unsupported_platform": return "Restart from a newer CLI requires macOS or Linux. Run `ocx status`, then use the owning lifecycle manager.";
|
|
160
|
+
case "desktop": return "OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI.";
|
|
150
161
|
case "service": return "An installed or active service owns lifecycle control. Run `ocx status`, then use the owning installation's `ocx service restart`.";
|
|
151
162
|
case "foreground": return "The proxy is attached to a parent process. Run `ocx status`; if it runs in a terminal, restart it in that terminal, otherwise use its owning app or supervisor.";
|
|
152
163
|
case "shared_or_client": return "This is a shared or connected-client runtime. Run `ocx status` and restart through its owning Hub, service or desktop app.";
|
|
@@ -180,29 +191,64 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar
|
|
|
180
191
|
}
|
|
181
192
|
}
|
|
182
193
|
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
export interface UpdateRestartStandaloneDeps {
|
|
195
|
+
platform?: NodeJS.Platform;
|
|
196
|
+
expectedHome?: UpdateRestartHome;
|
|
197
|
+
checkConfiguration?: typeof assertUpdateRestartConfiguration;
|
|
198
|
+
command?: typeof readProcessCommandLine;
|
|
199
|
+
parent?: (pid: number) => string;
|
|
200
|
+
home?: UpdateRestartHomeDeps;
|
|
201
|
+
supervision?: UpdateRestartSupervisionDeps;
|
|
202
|
+
manager?: GuardedManagerDeps;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function standalone(target: UpdateRestartCandidate["target"], deadlineAt: number, deps: UpdateRestartStandaloneDeps = {}): boolean {
|
|
206
|
+
const platform = deps.platform ?? process.platform;
|
|
207
|
+
try { (deps.checkConfiguration ?? assertUpdateRestartConfiguration)(target.hostname ?? ""); }
|
|
185
208
|
catch { throw new UpdateRestartEligibilityError("configuration_changed"); }
|
|
186
|
-
if (
|
|
187
|
-
throw new UpdateRestartEligibilityError(
|
|
209
|
+
if (platform !== "darwin" && platform !== "linux") {
|
|
210
|
+
throw new UpdateRestartEligibilityError(platform === "win32" ? "windows" : "unsupported_platform");
|
|
188
211
|
}
|
|
189
212
|
const host = probeHostname(target.hostname).replace(/^\[|\]$/g, "");
|
|
190
213
|
if (target.role === "client") throw new UpdateRestartEligibilityError("shared_or_client");
|
|
191
214
|
if (target.packageTreeFenced) throw new UpdateRestartEligibilityError("package_tree_fenced");
|
|
192
215
|
if (!isIP(host) || target.source !== "runtime") throw new UpdateRestartEligibilityError("unverifiable_ancestry");
|
|
193
|
-
const command = readProcessCommandLine(target.pid);
|
|
216
|
+
const command = (deps.command ?? readProcessCommandLine)(target.pid);
|
|
194
217
|
if (!command || !isOcxStartCommandLine(command)) throw new UpdateRestartEligibilityError("unverifiable_ancestry");
|
|
195
|
-
|
|
196
|
-
|
|
218
|
+
let home: UpdateRestartHome;
|
|
219
|
+
try {
|
|
220
|
+
home = readUpdateRestartHome(deps.home);
|
|
221
|
+
if (deps.expectedHome && JSON.stringify(home) !== JSON.stringify(deps.expectedHome)) throw new Error("changed");
|
|
222
|
+
}
|
|
223
|
+
catch { throw new UpdateRestartEligibilityError("service"); }
|
|
224
|
+
const supervision = { ...deps.supervision, platform };
|
|
225
|
+
const managerCommand = resolveUpdateRestartSupervisor(supervision);
|
|
226
|
+
if (!managerCommand) throw new UpdateRestartEligibilityError("service");
|
|
227
|
+
if (probeUpdateRestartSupervision(deadlineAt, supervision, managerCommand) !== "inactive") throw new UpdateRestartEligibilityError("service");
|
|
228
|
+
const manager = inspectGuardedManagerTarget(target.pid, target.port, {
|
|
229
|
+
...deps.manager, platform,
|
|
230
|
+
launchctl: args => {
|
|
231
|
+
const result = runBoundedUpdateRestartSupervisor(managerCommand, args, deadlineAt, supervision);
|
|
232
|
+
return { ...result, ok: result.status === 0 };
|
|
233
|
+
},
|
|
234
|
+
systemdShow: () => {
|
|
235
|
+
const result = runBoundedUpdateRestartSupervisor(managerCommand, UPDATE_RESTART_SYSTEMD_ARGS, deadlineAt, supervision);
|
|
236
|
+
if (classifyUpdateRestartSystemdSupervision(result) !== "inactive") throw new Error("update_restart_supervision_unverified");
|
|
237
|
+
return result.stdout;
|
|
238
|
+
},
|
|
239
|
+
});
|
|
197
240
|
if (manager.kind !== "absent") throw new UpdateRestartEligibilityError(manager.kind === "bound" ? "service" : "unverifiable_ancestry");
|
|
198
241
|
let parent: string;
|
|
199
242
|
try {
|
|
200
|
-
parent = execFileSync("ps", ["-o", "ppid=", "-p", String(target.pid)], {
|
|
243
|
+
parent = deps.parent ? deps.parent(target.pid) : execFileSync("ps", ["-o", "ppid=", "-p", String(target.pid)], {
|
|
201
244
|
encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"],
|
|
202
245
|
}).trim();
|
|
203
246
|
} catch { throw new UpdateRestartEligibilityError("unverifiable_ancestry"); }
|
|
204
247
|
if (!/^[1-9]\d*$/.test(parent)) throw new UpdateRestartEligibilityError("unverifiable_ancestry");
|
|
205
248
|
if (parent !== "1") throw new UpdateRestartEligibilityError("foreground");
|
|
249
|
+
try {
|
|
250
|
+
if (JSON.stringify(readUpdateRestartHome(deps.home)) !== JSON.stringify(home)) throw new Error("changed");
|
|
251
|
+
} catch { throw new UpdateRestartEligibilityError("service"); }
|
|
206
252
|
return true;
|
|
207
253
|
}
|
|
208
254
|
|
|
@@ -213,7 +259,7 @@ export function restartFromCurrentInstallation(candidate: UpdateRestartCandidate
|
|
|
213
259
|
return runUpdateRestart(candidate, deadlineAt, {
|
|
214
260
|
now: Date.now,
|
|
215
261
|
acquire: () => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs: Math.max(0, Math.min(2000, deadlineAt - Date.now())) }),
|
|
216
|
-
home: readUpdateRestartHome, checkHome: assertUpdateRestartHome, runtime: readRuntimePort, standalone,
|
|
262
|
+
home: readUpdateRestartHome, checkHome: home => assertUpdateRestartHome(home, deadlineAt), runtime: readRuntimePort, standalone: target => standalone(target, deadlineAt, { expectedHome: candidate.home }),
|
|
217
263
|
runtimeReady: () => isRealBunBinary(executable),
|
|
218
264
|
stop: async (target, deadline, beforeStop) => {
|
|
219
265
|
const token = configuredAdminToken();
|
package/src/cli/v2.ts
CHANGED
|
@@ -15,15 +15,14 @@ import { parseV2Command, V2_USAGE, type V2ParsedCommand } from "./v2-input";
|
|
|
15
15
|
import { handleV2RuntimeCommand } from "./v2-runtime";
|
|
16
16
|
import { localV2State, v2LocalSyncResult, type V2LocalSync } from "./v2-local-output";
|
|
17
17
|
import { execFileSync } from "node:child_process";
|
|
18
|
-
import {
|
|
19
|
-
import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features";
|
|
18
|
+
import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features";
|
|
20
19
|
|
|
21
20
|
import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
|
|
22
21
|
import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config";
|
|
23
22
|
import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime";
|
|
24
23
|
|
|
25
24
|
export interface V2CliDeps {
|
|
26
|
-
execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void;
|
|
25
|
+
execFile?: (file: string, args: string[], options?: SpawnInvocation["options"] & { env?: NodeJS.ProcessEnv }) => void;
|
|
27
26
|
isEnabled?: typeof isMultiAgentV2Enabled;
|
|
28
27
|
hasMaxThreads?: typeof hasAgentsMaxThreads;
|
|
29
28
|
sync?: (port?: number) => Promise<unknown>;
|
|
@@ -35,7 +34,8 @@ export interface V2CliDeps {
|
|
|
35
34
|
|
|
36
35
|
export type CodexFeaturesInvocationDeps =
|
|
37
36
|
& Parameters<typeof commandInvocation>[3]
|
|
38
|
-
& Pick<ResolveCodexRuntimeDeps, "existsSync" | "execFileSync" | "configDir" | "readFileSync"
|
|
37
|
+
& Pick<ResolveCodexRuntimeDeps, "existsSync" | "execFileSync" | "configDir" | "readFileSync">
|
|
38
|
+
& { requireAvailable?: boolean };
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Shared invocation for `codex features enable|disable <feature>` — the single
|
|
@@ -51,14 +51,18 @@ export function codexFeaturesInvocation(
|
|
|
51
51
|
platform: NodeJS.Platform = process.platform,
|
|
52
52
|
deps: CodexFeaturesInvocationDeps = {},
|
|
53
53
|
): SpawnInvocation {
|
|
54
|
-
const
|
|
54
|
+
const resolved = resolveAndPersistCodexRuntime({
|
|
55
55
|
env: deps.env ?? process.env,
|
|
56
56
|
platform,
|
|
57
57
|
existsSync: deps.existsSync,
|
|
58
58
|
execFileSync: deps.execFileSync,
|
|
59
59
|
configDir: deps.configDir,
|
|
60
60
|
readFileSync: deps.readFileSync,
|
|
61
|
-
})
|
|
61
|
+
});
|
|
62
|
+
if (deps.requireAvailable && resolved.runtime.source === "fallback" && resolved.runtime.version === null) {
|
|
63
|
+
throw Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT" });
|
|
64
|
+
}
|
|
65
|
+
const command = resolved.runtime.command || "codex";
|
|
62
66
|
return commandInvocation(command, ["features", action, feature], platform, deps);
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -70,27 +74,30 @@ export function codexFeaturesInvocation(
|
|
|
70
74
|
*/
|
|
71
75
|
export function runCodexFeaturesCommand(
|
|
72
76
|
action: "enable" | "disable",
|
|
73
|
-
feature: string
|
|
77
|
+
feature: string,
|
|
78
|
+
env: NodeJS.ProcessEnv,
|
|
79
|
+
validateBeforeSpawn: () => void,
|
|
80
|
+
preparedInvocation?: SpawnInvocation,
|
|
74
81
|
): void {
|
|
75
|
-
const inv = codexFeaturesInvocation(action, feature);
|
|
82
|
+
const inv = preparedInvocation ?? codexFeaturesInvocation(action, feature, process.platform, { env });
|
|
83
|
+
validateBeforeSpawn();
|
|
76
84
|
execFileSync(inv.file, inv.args,
|
|
77
85
|
{
|
|
78
86
|
stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, encoding: "utf8",
|
|
79
|
-
// The reader resolves $CODEX_HOME at call time (including the WSL Windows-home
|
|
80
|
-
// detection); force the same home on the child so it never toggles a different
|
|
81
|
-
// config than the one the postcondition re-reads.
|
|
82
|
-
env: { ...process.env, CODEX_HOME: dirname(activeCodexConfigPath()) },
|
|
83
87
|
...inv.options,
|
|
88
|
+
env,
|
|
84
89
|
});
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
|
|
92
|
+
function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void): void {
|
|
88
93
|
if (deps.execFile) {
|
|
89
|
-
const inv =
|
|
90
|
-
|
|
94
|
+
const inv = deps.featuresInvocation ? deps.featuresInvocation(action)
|
|
95
|
+
: codexFeaturesInvocation(action, "multi_agent_v2", process.platform, { env });
|
|
96
|
+
validateBeforeSpawn();
|
|
97
|
+
deps.execFile(inv.file, inv.args, { ...inv.options, env });
|
|
91
98
|
return;
|
|
92
99
|
}
|
|
93
|
-
runCodexFeaturesCommand(action);
|
|
100
|
+
runCodexFeaturesCommand(action, "multi_agent_v2", env, validateBeforeSpawn);
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
export function v2StatusLine(enabled: boolean): string {
|
|
@@ -182,7 +189,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
182
189
|
}
|
|
183
190
|
if (parsed.verb === "threads") {
|
|
184
191
|
const enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)();
|
|
185
|
-
const result = transitionMultiAgentV2(enabled, next => runCodexFeatures(next ? "enable" : "disable", deps), { threadLimit: parsed.value });
|
|
192
|
+
const result = transitionMultiAgentV2(enabled, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate), { threadLimit: parsed.value });
|
|
186
193
|
if (!result.ok) return emit(false, ["Unable to update the native thread limit. Read ocx v2 status before retrying."]);
|
|
187
194
|
changed = result.changed;
|
|
188
195
|
return emit(true, [changed ? `max_threads = ${parsed.value} (${enabled ? "v2" : "v1"}) — applies to new sessions.` : `max_threads already ${parsed.value} — nothing to do.`]);
|
|
@@ -194,7 +201,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
194
201
|
changed = (config.multiAgentMode ?? "default") !== parsed.value;
|
|
195
202
|
if (parsed.value !== "default") {
|
|
196
203
|
const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true;
|
|
197
|
-
const transition = transitionMultiAgentV2(target, next => runCodexFeatures(next ? "enable" : "disable", deps));
|
|
204
|
+
const transition = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate));
|
|
198
205
|
if (!transition.ok) { changed = false; return emit(false, ["Native mode transition failed. Read ocx v2 status before retrying."]); }
|
|
199
206
|
changed ||= transition.changed;
|
|
200
207
|
}
|
|
@@ -207,7 +214,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
207
214
|
unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value;
|
|
208
215
|
changed = !unchangedKeep;
|
|
209
216
|
if (parsed.value && requiresGlobalV2Disabled(config.multiAgentMode, true)) {
|
|
210
|
-
const transition = transitionMultiAgentV2(false, next => runCodexFeatures(next ? "enable" : "disable", deps));
|
|
217
|
+
const transition = transitionMultiAgentV2(false, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate));
|
|
211
218
|
if (!transition.ok) { changed = false; return emit(false, ["Native hybrid-mode transition failed. Read ocx v2 status before retrying."]); }
|
|
212
219
|
changed ||= transition.changed;
|
|
213
220
|
}
|
|
@@ -222,7 +229,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
222
229
|
if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) {
|
|
223
230
|
return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]);
|
|
224
231
|
}
|
|
225
|
-
const transition = transitionMultiAgentV2(want, next => runCodexFeatures(next ? "enable" : "disable", deps));
|
|
232
|
+
const transition = transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate));
|
|
226
233
|
if (!transition.ok) return emit(false, ["Native feature transition failed. Read ocx v2 status before retrying."]);
|
|
227
234
|
changed = transition.changed;
|
|
228
235
|
if (!changed) return emit(true, [`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`]);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readConfigDiagnostics } from "../config/diagnostics";
|
|
2
|
+
import { readRuntimePort } from "../config/process-state";
|
|
3
|
+
import { packageVersion } from "./help";
|
|
4
|
+
import { proxyIdentityAt } from "../server/proxy-liveness";
|
|
5
|
+
import { computeVersionSkew } from "./version-skew";
|
|
6
|
+
|
|
7
|
+
const LIFECYCLE = new Set(["start", "stop", "restart", "update", "service"]);
|
|
8
|
+
const NOTICE_DEADLINE_MS = 200;
|
|
9
|
+
let printed = false;
|
|
10
|
+
|
|
11
|
+
export function shouldNoticeVersionSkew(command: string | undefined, args: string[]): boolean {
|
|
12
|
+
const boundary = args.indexOf("--");
|
|
13
|
+
const prefix = boundary < 0 ? args : args.slice(0, boundary);
|
|
14
|
+
return LIFECYCLE.has(command ?? "")
|
|
15
|
+
&& !prefix.some(arg => arg === "--json" || arg === "--help" || arg === "-h")
|
|
16
|
+
&& prefix[1] !== "help";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface VersionSkewNoticeIo {
|
|
20
|
+
readRuntime?: typeof readRuntimePort;
|
|
21
|
+
readConfig?: typeof readConfigDiagnostics;
|
|
22
|
+
probe?: typeof proxyIdentityAt;
|
|
23
|
+
cliVersion?: () => string;
|
|
24
|
+
now?: () => number;
|
|
25
|
+
warn?: (message: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One proxyIdentityAt (src/server/proxy-liveness.ts) call validates /healthz identity,
|
|
30
|
+
* expected PID and terminal-safe version within the total diagnostic budget.
|
|
31
|
+
*/
|
|
32
|
+
export async function maybeNoticeVersionSkew(
|
|
33
|
+
command: string | undefined, args: string[], io: VersionSkewNoticeIo = {},
|
|
34
|
+
): Promise<void> {
|
|
35
|
+
if (printed || !shouldNoticeVersionSkew(command, args)) return;
|
|
36
|
+
const now = io.now ?? Date.now;
|
|
37
|
+
const deadlineAt = now() + NOTICE_DEADLINE_MS;
|
|
38
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
39
|
+
try {
|
|
40
|
+
const runtime = (io.readRuntime ?? readRuntimePort)();
|
|
41
|
+
let port: number;
|
|
42
|
+
let hostname: string | undefined;
|
|
43
|
+
let expectedPid: number | undefined;
|
|
44
|
+
if (runtime) {
|
|
45
|
+
({ port, hostname } = runtime);
|
|
46
|
+
expectedPid = runtime.pid;
|
|
47
|
+
} else {
|
|
48
|
+
const diagnostics = (io.readConfig ?? readConfigDiagnostics)();
|
|
49
|
+
if (diagnostics.error) return;
|
|
50
|
+
port = diagnostics.config.port ?? 10100;
|
|
51
|
+
hostname = diagnostics.config.hostname;
|
|
52
|
+
}
|
|
53
|
+
const remaining = Math.floor(deadlineAt - now());
|
|
54
|
+
if (remaining <= 0) return;
|
|
55
|
+
const cliVersion = (io.cliVersion ?? packageVersion)();
|
|
56
|
+
if (now() >= deadlineAt) return;
|
|
57
|
+
const identity = await Promise.race([
|
|
58
|
+
(io.probe ?? proxyIdentityAt)(port, { hostname, expectedPid }, {
|
|
59
|
+
timeoutMs: Math.max(1, Math.floor(deadlineAt - now())),
|
|
60
|
+
attempts: 1, deadlineAt, nowFn: now,
|
|
61
|
+
}),
|
|
62
|
+
new Promise<null>(resolve => {
|
|
63
|
+
timer = setTimeout(() => resolve(null), Math.max(1, deadlineAt - now()));
|
|
64
|
+
}),
|
|
65
|
+
]);
|
|
66
|
+
if (printed || !identity || now() >= deadlineAt) return;
|
|
67
|
+
const skew = computeVersionSkew(cliVersion, identity.version);
|
|
68
|
+
if (!skew.skewed) return;
|
|
69
|
+
printed = true;
|
|
70
|
+
(io.warn ?? console.error)(`ocx ${cliVersion} does not match the running proxy ${identity.version}. `
|
|
71
|
+
+ "Check which opencodex installation you meant to use (`ocx status` shows both).");
|
|
72
|
+
} catch {
|
|
73
|
+
// Best-effort diagnostics must not fail a lifecycle command or change its exit code.
|
|
74
|
+
} finally {
|
|
75
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
package/src/clients/lazycodex.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Detection of Codex-based omo (LazyCodex), the variant that reads Codex role files and
|
|
3
|
-
* `codex.agents.<role>.model` in `~/.omo/omo.jsonc`.
|
|
3
|
+
* `"[codex]".agents.<role>.model` in `~/.omo/omo.jsonc`.
|
|
4
4
|
*
|
|
5
5
|
* "omo" names three products. Pi-based omo (senpi) owns `~/.omo/agent` and is the omo file
|
|
6
6
|
* integration in `src/integrations/registry.ts`; OpenCode-based omo (oh-my-opencode) keeps its
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* omo (Codex / LazyCodex)'s per-role model setting, `codex.agents.<role>.model` in `~/.omo/omo.jsonc`.
|
|
2
|
+
* omo (Codex / LazyCodex)'s per-role model setting, `"[codex]".agents.<role>.model` in `~/.omo/omo.jsonc`.
|
|
3
|
+
* The section name carries its brackets: LazyCodex reads `[codex]`, and its strict loader drops a
|
|
4
|
+
* bare `codex` key as unknown, so a pick written there never reaches a role.
|
|
3
5
|
*
|
|
4
6
|
* LazyCodex 5.1.1 and later reads it, and callers reach this only after `detectLazyCodex`
|
|
5
7
|
* says LazyCodex is installed. The file is omo's, so this writes only on an explicit
|
|
@@ -30,6 +32,17 @@ export function omoJsoncPath(env: NodeJS.ProcessEnv = process.env, home: string
|
|
|
30
32
|
|
|
31
33
|
type JsonObject = Record<string, unknown>;
|
|
32
34
|
|
|
35
|
+
const CODEX_SECTION = "[codex]";
|
|
36
|
+
|
|
37
|
+
/** The reasoning levels LazyCodex accepts for a role; it names Codex's `none` `off`. */
|
|
38
|
+
const LAZYCODEX_REASONING = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
39
|
+
|
|
40
|
+
/** The `reasoning` value LazyCodex reads for a Codex effort, or null when it has no such level. */
|
|
41
|
+
export function omoReasoningFor(effort: string): string | null {
|
|
42
|
+
const level = effort === "none" ? "off" : effort;
|
|
43
|
+
return LAZYCODEX_REASONING.has(level) ? level : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
function isObject(value: unknown): value is JsonObject {
|
|
34
47
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
48
|
}
|
|
@@ -71,7 +84,7 @@ function load(path: string): Loaded {
|
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
function agentsOf(doc: JsonObject): JsonObject | null | undefined {
|
|
74
|
-
const codex = doc
|
|
87
|
+
const codex = doc[CODEX_SECTION];
|
|
75
88
|
if (codex === undefined) return undefined;
|
|
76
89
|
if (!isObject(codex)) return null;
|
|
77
90
|
const agents = codex.agents;
|
|
@@ -104,21 +117,33 @@ function serialize(text: string, doc: JsonObject): string {
|
|
|
104
117
|
return text.startsWith("\ufeff") ? `\ufeff${out}` : out;
|
|
105
118
|
}
|
|
106
119
|
|
|
107
|
-
|
|
120
|
+
/**
|
|
121
|
+
* `reasoning` left undefined keeps the entry's level; null removes it, so a level LazyCodex cannot
|
|
122
|
+
* express does not leave a stale one behind for its next install to apply.
|
|
123
|
+
*/
|
|
124
|
+
export function writeOmoRoleModel(
|
|
125
|
+
role: string,
|
|
126
|
+
model: string,
|
|
127
|
+
path: string = omoJsoncPath(),
|
|
128
|
+
reasoning?: string | null,
|
|
129
|
+
): OmoRoleModelWriteStatus {
|
|
108
130
|
const loaded = load(path);
|
|
109
131
|
if (loaded.kind !== "document") return loaded.kind === "comments" ? "skipped_comments" : loaded.kind;
|
|
110
132
|
const { doc, text } = loaded;
|
|
111
133
|
// Only an absent key is created; an explicit null is a value the reader calls invalid.
|
|
112
|
-
const codex = doc
|
|
134
|
+
const codex = doc[CODEX_SECTION] === undefined ? {} : doc[CODEX_SECTION];
|
|
113
135
|
if (!isObject(codex)) return "invalid";
|
|
114
136
|
const agents = codex.agents === undefined ? {} : codex.agents;
|
|
115
137
|
if (!isObject(agents)) return "invalid";
|
|
116
138
|
const entry = agents[role] === undefined ? {} : agents[role];
|
|
117
139
|
if (!isObject(entry)) return "invalid";
|
|
118
|
-
|
|
119
|
-
|
|
140
|
+
const next: JsonObject = { ...entry, model };
|
|
141
|
+
if (reasoning === null) delete next.reasoning;
|
|
142
|
+
else if (reasoning !== undefined) next.reasoning = reasoning;
|
|
143
|
+
if (entry.model === model && entry.reasoning === next.reasoning) return "unchanged";
|
|
144
|
+
agents[role] = next;
|
|
120
145
|
codex.agents = agents;
|
|
121
|
-
doc
|
|
146
|
+
doc[CODEX_SECTION] = codex;
|
|
122
147
|
assertIntegrationWriteOwnership(path);
|
|
123
148
|
atomicWriteFileNoFollowUnclaimed(path, serialize(text, doc));
|
|
124
149
|
return "written";
|