copilot-hub 0.1.20 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +3 -2
  2. package/apps/agent-engine/dist/config.js +58 -0
  3. package/apps/agent-engine/dist/index.js +90 -16
  4. package/apps/control-plane/dist/channels/codex-quota-cache.js +16 -0
  5. package/apps/control-plane/dist/channels/hub-model-utils.js +244 -24
  6. package/apps/control-plane/dist/channels/hub-ops-commands.js +631 -279
  7. package/apps/control-plane/dist/channels/telegram-channel.js +5 -7
  8. package/apps/control-plane/dist/config.js +58 -0
  9. package/apps/control-plane/dist/index.js +16 -0
  10. package/apps/control-plane/dist/test/hub-model-utils.test.js +110 -13
  11. package/package.json +3 -2
  12. package/packages/core/dist/agent-supervisor.d.ts +5 -0
  13. package/packages/core/dist/agent-supervisor.js +11 -0
  14. package/packages/core/dist/agent-supervisor.js.map +1 -1
  15. package/packages/core/dist/bot-manager.js +17 -1
  16. package/packages/core/dist/bot-manager.js.map +1 -1
  17. package/packages/core/dist/bot-runtime.d.ts +4 -0
  18. package/packages/core/dist/bot-runtime.js +5 -1
  19. package/packages/core/dist/bot-runtime.js.map +1 -1
  20. package/packages/core/dist/codex-app-client.d.ts +13 -2
  21. package/packages/core/dist/codex-app-client.js +51 -13
  22. package/packages/core/dist/codex-app-client.js.map +1 -1
  23. package/packages/core/dist/codex-app-utils.d.ts +6 -0
  24. package/packages/core/dist/codex-app-utils.js +49 -0
  25. package/packages/core/dist/codex-app-utils.js.map +1 -1
  26. package/packages/core/dist/codex-provider.d.ts +3 -1
  27. package/packages/core/dist/codex-provider.js +3 -1
  28. package/packages/core/dist/codex-provider.js.map +1 -1
  29. package/packages/core/dist/kernel-control-plane.d.ts +1 -0
  30. package/packages/core/dist/kernel-control-plane.js +132 -13
  31. package/packages/core/dist/kernel-control-plane.js.map +1 -1
  32. package/packages/core/dist/provider-factory.d.ts +2 -0
  33. package/packages/core/dist/provider-factory.js +3 -0
  34. package/packages/core/dist/provider-factory.js.map +1 -1
  35. package/packages/core/dist/provider-options.js +24 -17
  36. package/packages/core/dist/provider-options.js.map +1 -1
  37. package/packages/core/dist/state-store.d.ts +1 -0
  38. package/packages/core/dist/state-store.js +28 -2
  39. package/packages/core/dist/state-store.js.map +1 -1
  40. package/packages/core/dist/telegram-channel.d.ts +1 -0
  41. package/packages/core/dist/telegram-channel.js +3 -0
  42. package/packages/core/dist/telegram-channel.js.map +1 -1
  43. package/scripts/dist/cli.mjs +132 -203
  44. package/scripts/dist/codex-runtime.mjs +352 -0
  45. package/scripts/dist/codex-version.mjs +91 -0
  46. package/scripts/dist/daemon.mjs +58 -0
  47. package/scripts/src/cli.mts +166 -233
  48. package/scripts/src/codex-runtime.mts +499 -0
  49. package/scripts/src/codex-version.mts +114 -0
  50. package/scripts/src/daemon.mts +69 -0
  51. package/scripts/test/codex-version.test.mjs +21 -0
@@ -0,0 +1,499 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { spawnSync } from "node:child_process";
5
+ import {
6
+ compareSemver,
7
+ codexVersionRequirementLabel,
8
+ extractSemver,
9
+ isCodexVersionCompatible,
10
+ } from "./codex-version.mjs";
11
+
12
+ type ResolvedCodexBin = {
13
+ bin: string;
14
+ source: string;
15
+ userConfigured: boolean;
16
+ };
17
+
18
+ type ProbeCodexVersionResult = {
19
+ ok: boolean;
20
+ stdout: string;
21
+ stderr: string;
22
+ errorMessage: string;
23
+ errorCode: string;
24
+ version: string;
25
+ rawVersion: string;
26
+ compatible: boolean;
27
+ };
28
+
29
+ export function resolveCodexBinForStart({
30
+ repoRoot,
31
+ agentEngineEnvPath,
32
+ controlPlaneEnvPath,
33
+ env = process.env,
34
+ }: {
35
+ repoRoot: string;
36
+ agentEngineEnvPath: string;
37
+ controlPlaneEnvPath: string;
38
+ env?: NodeJS.ProcessEnv;
39
+ }): ResolvedCodexBin {
40
+ const fromEnv = nonEmpty(env.CODEX_BIN);
41
+ if (fromEnv) {
42
+ return buildResolvedCodexBin({
43
+ value: fromEnv,
44
+ source: "process_env",
45
+ env,
46
+ repoRoot,
47
+ });
48
+ }
49
+
50
+ for (const [source, envPath] of [
51
+ ["agent_env", agentEngineEnvPath],
52
+ ["control_plane_env", controlPlaneEnvPath],
53
+ ] as const) {
54
+ const value = readEnvValue(envPath, "CODEX_BIN");
55
+ if (value) {
56
+ return buildResolvedCodexBin({
57
+ value,
58
+ source,
59
+ env,
60
+ repoRoot,
61
+ });
62
+ }
63
+ }
64
+
65
+ const detected = findDetectedCodexBin(env, repoRoot);
66
+ if (detected) {
67
+ return {
68
+ bin: detected,
69
+ source: "detected",
70
+ userConfigured: false,
71
+ };
72
+ }
73
+
74
+ return {
75
+ bin: "codex",
76
+ source: "default",
77
+ userConfigured: false,
78
+ };
79
+ }
80
+
81
+ export function resolveCompatibleInstalledCodexBin({
82
+ repoRoot,
83
+ env = process.env,
84
+ }: {
85
+ repoRoot: string;
86
+ env?: NodeJS.ProcessEnv;
87
+ }): string {
88
+ const matches: Array<{ candidate: string; version: string; priority: number }> = [];
89
+
90
+ for (const candidate of listCodexBinCandidates(env, repoRoot)) {
91
+ const probe = probeCodexVersion({
92
+ codexBin: candidate,
93
+ repoRoot,
94
+ });
95
+ if (!probe.ok || !probe.compatible) {
96
+ continue;
97
+ }
98
+
99
+ matches.push({
100
+ candidate,
101
+ version: probe.version,
102
+ priority: getCodexCandidatePriority(candidate, env, repoRoot),
103
+ });
104
+ }
105
+
106
+ if (matches.length === 0) {
107
+ return "";
108
+ }
109
+
110
+ matches.sort((left, right) => {
111
+ const versionOrder = compareSemver(right.version, left.version);
112
+ if (versionOrder !== 0) {
113
+ return versionOrder;
114
+ }
115
+ return left.priority - right.priority;
116
+ });
117
+
118
+ return matches[0]?.candidate ?? "";
119
+ }
120
+
121
+ export function probeCodexVersion({
122
+ codexBin,
123
+ repoRoot,
124
+ }: {
125
+ codexBin: string;
126
+ repoRoot: string;
127
+ }): ProbeCodexVersionResult {
128
+ const status = runCodex({
129
+ codexBin,
130
+ args: ["--version"],
131
+ repoRoot,
132
+ });
133
+ if (!status.ok) {
134
+ return {
135
+ ...status,
136
+ version: "",
137
+ rawVersion: "",
138
+ compatible: false,
139
+ };
140
+ }
141
+
142
+ const rawVersion = firstLine(status.stdout) || firstLine(status.stderr);
143
+ const version = extractSemver(rawVersion);
144
+ if (!version) {
145
+ return {
146
+ ok: false,
147
+ stdout: status.stdout,
148
+ stderr: status.stderr,
149
+ errorMessage: `Could not parse Codex version from '${rawVersion || "empty output"}'.`,
150
+ errorCode: "INVALID_VERSION",
151
+ version: "",
152
+ rawVersion,
153
+ compatible: false,
154
+ };
155
+ }
156
+
157
+ return {
158
+ ...status,
159
+ version,
160
+ rawVersion,
161
+ compatible: isCodexVersionCompatible(version),
162
+ };
163
+ }
164
+
165
+ export function buildCodexCompatibilitySummary({
166
+ resolved,
167
+ probe,
168
+ }: {
169
+ resolved: ResolvedCodexBin;
170
+ probe: ProbeCodexVersionResult;
171
+ }): string {
172
+ if (probe.ok) {
173
+ return `Codex binary '${resolved.bin}' is version ${probe.version}.`;
174
+ }
175
+
176
+ if (probe.errorCode === "ENOENT") {
177
+ return `Codex binary '${resolved.bin}' was not found.`;
178
+ }
179
+
180
+ return probe.errorMessage || `Codex binary '${resolved.bin}' is not usable.`;
181
+ }
182
+
183
+ export function buildCodexCompatibilityNotice({
184
+ resolved,
185
+ probe,
186
+ }: {
187
+ resolved: ResolvedCodexBin;
188
+ probe: ProbeCodexVersionResult;
189
+ }): string {
190
+ return [
191
+ buildCodexCompatibilitySummary({ resolved, probe }),
192
+ `copilot-hub requires Codex CLI ${codexVersionRequirementLabel}.`,
193
+ ].join("\n");
194
+ }
195
+
196
+ export function buildCodexCompatibilityError({
197
+ resolved,
198
+ probe,
199
+ includeInstallHint,
200
+ installCommand,
201
+ }: {
202
+ resolved: ResolvedCodexBin;
203
+ probe: ProbeCodexVersionResult;
204
+ includeInstallHint: boolean;
205
+ installCommand: string;
206
+ }): string {
207
+ const lines = [
208
+ buildCodexCompatibilitySummary({ resolved, probe }),
209
+ `copilot-hub requires Codex CLI ${codexVersionRequirementLabel}.`,
210
+ ];
211
+
212
+ if (includeInstallHint) {
213
+ lines.push(`Install a compatible version with '${installCommand}', then retry.`);
214
+ } else {
215
+ lines.push("Update that binary or point CODEX_BIN to a compatible executable, then retry.");
216
+ }
217
+
218
+ return lines.join("\n");
219
+ }
220
+
221
+ function buildResolvedCodexBin({
222
+ value,
223
+ source,
224
+ env,
225
+ repoRoot,
226
+ }: {
227
+ value: string;
228
+ source: string;
229
+ env: NodeJS.ProcessEnv;
230
+ repoRoot: string;
231
+ }): ResolvedCodexBin {
232
+ const normalized = String(value ?? "")
233
+ .trim()
234
+ .toLowerCase();
235
+ if (normalized && normalized !== "codex") {
236
+ return {
237
+ bin: value,
238
+ source,
239
+ userConfigured: true,
240
+ };
241
+ }
242
+
243
+ const detected = findDetectedCodexBin(env, repoRoot);
244
+ return {
245
+ bin: detected || "codex",
246
+ source,
247
+ userConfigured: false,
248
+ };
249
+ }
250
+
251
+ function findDetectedCodexBin(env: NodeJS.ProcessEnv, repoRoot: string): string {
252
+ if (process.platform !== "win32") {
253
+ return "";
254
+ }
255
+
256
+ return findWindowsNpmGlobalCodexBin(env, repoRoot) || findVscodeCodexExe(env) || "";
257
+ }
258
+
259
+ function listCodexBinCandidates(env: NodeJS.ProcessEnv, repoRoot: string): string[] {
260
+ return dedupe(["codex", findWindowsNpmGlobalCodexBin(env, repoRoot), findVscodeCodexExe(env)]);
261
+ }
262
+
263
+ function getCodexCandidatePriority(
264
+ candidate: string,
265
+ env: NodeJS.ProcessEnv,
266
+ repoRoot: string,
267
+ ): number {
268
+ if (candidate === "codex") {
269
+ return 0;
270
+ }
271
+
272
+ const npmGlobal = findWindowsNpmGlobalCodexBin(env, repoRoot);
273
+ if (npmGlobal && candidate === npmGlobal) {
274
+ return 1;
275
+ }
276
+
277
+ const vscode = findVscodeCodexExe(env);
278
+ if (vscode && candidate === vscode) {
279
+ return 2;
280
+ }
281
+
282
+ return 3;
283
+ }
284
+
285
+ function findVscodeCodexExe(env: NodeJS.ProcessEnv): string {
286
+ const userProfile = nonEmpty(env.USERPROFILE);
287
+ if (!userProfile) {
288
+ return "";
289
+ }
290
+
291
+ const extensionsDir = path.join(userProfile, ".vscode", "extensions");
292
+ if (!fs.existsSync(extensionsDir)) {
293
+ return "";
294
+ }
295
+
296
+ const candidates = fs
297
+ .readdirSync(extensionsDir, { withFileTypes: true })
298
+ .filter((entry) => entry.isDirectory())
299
+ .map((entry) => entry.name)
300
+ .filter((name) => name.startsWith("openai.chatgpt-"))
301
+ .sort()
302
+ .reverse();
303
+
304
+ for (const folder of candidates) {
305
+ const exePath = path.join(extensionsDir, folder, "bin", "windows-x86_64", "codex.exe");
306
+ if (fs.existsSync(exePath)) {
307
+ return exePath;
308
+ }
309
+ }
310
+
311
+ return "";
312
+ }
313
+
314
+ function findWindowsNpmGlobalCodexBin(env: NodeJS.ProcessEnv, repoRoot: string): string {
315
+ if (process.platform !== "win32") {
316
+ return "";
317
+ }
318
+
319
+ const candidates: string[] = [];
320
+ const appData = nonEmpty(env.APPDATA);
321
+ if (appData) {
322
+ candidates.push(path.join(appData, "npm", "codex.cmd"));
323
+ candidates.push(path.join(appData, "npm", "codex.exe"));
324
+ candidates.push(path.join(appData, "npm", "codex"));
325
+ }
326
+
327
+ const npmPrefix = readNpmPrefix(repoRoot);
328
+ if (npmPrefix) {
329
+ candidates.push(path.join(npmPrefix, "codex.cmd"));
330
+ candidates.push(path.join(npmPrefix, "codex.exe"));
331
+ candidates.push(path.join(npmPrefix, "codex"));
332
+ }
333
+
334
+ for (const candidate of dedupe(candidates)) {
335
+ if (fs.existsSync(candidate)) {
336
+ return candidate;
337
+ }
338
+ }
339
+
340
+ return "";
341
+ }
342
+
343
+ function readNpmPrefix(repoRoot: string): string {
344
+ const result = spawnNpm(["config", "get", "prefix"], repoRoot);
345
+ if (result.error || result.status !== 0) {
346
+ return "";
347
+ }
348
+
349
+ const value = String(result.stdout ?? "").trim();
350
+ if (!value || value.toLowerCase() === "undefined") {
351
+ return "";
352
+ }
353
+ return value;
354
+ }
355
+
356
+ function runCodex({
357
+ codexBin,
358
+ args,
359
+ repoRoot,
360
+ }: {
361
+ codexBin: string;
362
+ args: string[];
363
+ repoRoot: string;
364
+ }) {
365
+ const result = spawnCodex(codexBin, args, repoRoot);
366
+
367
+ if (result.error) {
368
+ return {
369
+ ok: false,
370
+ stdout: "",
371
+ stderr: "",
372
+ errorMessage: formatCodexSpawnError(codexBin, result.error),
373
+ errorCode: normalizeErrorCode(result.error),
374
+ };
375
+ }
376
+
377
+ const code = Number.isInteger(result.status) ? result.status : 1;
378
+ return {
379
+ ok: code === 0,
380
+ stdout: String(result.stdout ?? "").trim(),
381
+ stderr: String(result.stderr ?? "").trim(),
382
+ errorMessage: "",
383
+ errorCode: "",
384
+ };
385
+ }
386
+
387
+ function spawnCodex(codexBin: string, args: string[], repoRoot: string) {
388
+ if (process.platform === "win32" && /\.(cmd|bat)$/i.test(codexBin)) {
389
+ const commandLine = [
390
+ quoteWindowsShellValue(codexBin),
391
+ ...args.map(quoteWindowsShellValue),
392
+ ].join(" ");
393
+ return spawnSync(commandLine, {
394
+ cwd: repoRoot,
395
+ stdio: ["ignore", "pipe", "pipe"],
396
+ shell: true,
397
+ encoding: "utf8",
398
+ });
399
+ }
400
+
401
+ return spawnSync(codexBin, args, {
402
+ cwd: repoRoot,
403
+ stdio: ["ignore", "pipe", "pipe"],
404
+ shell: false,
405
+ encoding: "utf8",
406
+ });
407
+ }
408
+
409
+ function readEnvValue(filePath: string, key: string): string {
410
+ if (!fs.existsSync(filePath)) {
411
+ return "";
412
+ }
413
+
414
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/);
415
+ const pattern = new RegExp(`^\\s*${escapeRegex(key)}\\s*=\\s*(.*)\\s*$`);
416
+ for (const line of lines) {
417
+ const match = line.match(pattern);
418
+ if (!match) {
419
+ continue;
420
+ }
421
+ return unquote(match[1]);
422
+ }
423
+ return "";
424
+ }
425
+
426
+ function unquote(value: string): string {
427
+ const raw = String(value ?? "").trim();
428
+ if (!raw) {
429
+ return "";
430
+ }
431
+ if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
432
+ return raw.slice(1, -1).trim();
433
+ }
434
+ return raw;
435
+ }
436
+
437
+ function escapeRegex(value: string): string {
438
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
439
+ }
440
+
441
+ function nonEmpty(value: unknown): string {
442
+ const normalized = String(value ?? "").trim();
443
+ return normalized || "";
444
+ }
445
+
446
+ function firstLine(value: unknown): string {
447
+ return (
448
+ String(value ?? "")
449
+ .split(/\r?\n/)
450
+ .map((line) => line.trim())
451
+ .find(Boolean) ?? ""
452
+ );
453
+ }
454
+
455
+ function formatCodexSpawnError(command: string, error: NodeJS.ErrnoException): string {
456
+ const code = normalizeErrorCode(error);
457
+ if (code === "ENOENT") {
458
+ return `Codex binary '${command}' was not found. Install Codex CLI or set CODEX_BIN.`;
459
+ }
460
+ if (code === "EPERM") {
461
+ return `Codex binary '${command}' cannot be executed (EPERM). Check permissions or CODEX_BIN.`;
462
+ }
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ return `Failed to execute '${command}': ${firstLine(message)}`;
465
+ }
466
+
467
+ function normalizeErrorCode(error: unknown): string {
468
+ return String((error as { code?: unknown } | null | undefined)?.code ?? "")
469
+ .trim()
470
+ .toUpperCase();
471
+ }
472
+
473
+ function dedupe(values: Array<string | null | undefined>): string[] {
474
+ return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
475
+ }
476
+
477
+ function quoteWindowsShellValue(value: string): string {
478
+ return `"${String(value ?? "").replace(/"/g, '\\"')}"`;
479
+ }
480
+
481
+ function spawnNpm(args: string[], repoRoot: string) {
482
+ if (process.platform === "win32") {
483
+ const comspec = process.env.ComSpec || "cmd.exe";
484
+ const commandLine = ["npm", ...args].join(" ");
485
+ return spawnSync(comspec, ["/d", "/s", "/c", commandLine], {
486
+ cwd: repoRoot,
487
+ stdio: ["ignore", "pipe", "pipe"],
488
+ shell: false,
489
+ encoding: "utf8",
490
+ });
491
+ }
492
+
493
+ return spawnSync("npm", args, {
494
+ cwd: repoRoot,
495
+ stdio: ["ignore", "pipe", "pipe"],
496
+ shell: false,
497
+ encoding: "utf8",
498
+ });
499
+ }
@@ -0,0 +1,114 @@
1
+ export const codexNpmPackage = "@openai/codex";
2
+ export const minimumCodexVersion = "0.113.0";
3
+ export const maximumCodexVersionExclusive = "0.114.0";
4
+ export const codexInstallPackageSpec = `${codexNpmPackage}@${minimumCodexVersion}`;
5
+ export const codexVersionRequirementLabel = `>= ${minimumCodexVersion} < ${maximumCodexVersionExclusive}`;
6
+
7
+ type Semver = {
8
+ major: number;
9
+ minor: number;
10
+ patch: number;
11
+ prerelease: string[];
12
+ };
13
+
14
+ export function extractSemver(value: string): string {
15
+ const match = String(value ?? "").match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/);
16
+ return String(match?.[0] ?? "").trim();
17
+ }
18
+
19
+ export function compareSemver(left: string, right: string): number {
20
+ const a = parseSemver(left);
21
+ const b = parseSemver(right);
22
+ if (!a || !b) {
23
+ return 0;
24
+ }
25
+
26
+ if (a.major !== b.major) {
27
+ return a.major > b.major ? 1 : -1;
28
+ }
29
+ if (a.minor !== b.minor) {
30
+ return a.minor > b.minor ? 1 : -1;
31
+ }
32
+ if (a.patch !== b.patch) {
33
+ return a.patch > b.patch ? 1 : -1;
34
+ }
35
+
36
+ if (a.prerelease.length === 0 && b.prerelease.length === 0) {
37
+ return 0;
38
+ }
39
+ if (a.prerelease.length === 0) {
40
+ return 1;
41
+ }
42
+ if (b.prerelease.length === 0) {
43
+ return -1;
44
+ }
45
+
46
+ const maxLength = Math.max(a.prerelease.length, b.prerelease.length);
47
+ for (let index = 0; index < maxLength; index += 1) {
48
+ const leftPart = a.prerelease[index];
49
+ const rightPart = b.prerelease[index];
50
+ if (leftPart === undefined) {
51
+ return -1;
52
+ }
53
+ if (rightPart === undefined) {
54
+ return 1;
55
+ }
56
+ if (leftPart === rightPart) {
57
+ continue;
58
+ }
59
+
60
+ const leftNumeric = /^\d+$/.test(leftPart);
61
+ const rightNumeric = /^\d+$/.test(rightPart);
62
+ if (leftNumeric && rightNumeric) {
63
+ const leftValue = Number.parseInt(leftPart, 10);
64
+ const rightValue = Number.parseInt(rightPart, 10);
65
+ if (leftValue !== rightValue) {
66
+ return leftValue > rightValue ? 1 : -1;
67
+ }
68
+ continue;
69
+ }
70
+ if (leftNumeric) {
71
+ return -1;
72
+ }
73
+ if (rightNumeric) {
74
+ return 1;
75
+ }
76
+ return leftPart > rightPart ? 1 : -1;
77
+ }
78
+
79
+ return 0;
80
+ }
81
+
82
+ export function isCodexVersionCompatible(version: string): boolean {
83
+ const parsed = parseSemver(version);
84
+ if (!parsed || parsed.prerelease.length > 0) {
85
+ return false;
86
+ }
87
+
88
+ return (
89
+ compareSemver(version, minimumCodexVersion) >= 0 &&
90
+ compareSemver(version, maximumCodexVersionExclusive) < 0
91
+ );
92
+ }
93
+
94
+ function parseSemver(value: string): Semver | null {
95
+ const normalized = extractSemver(value);
96
+ const match = normalized.match(
97
+ /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/,
98
+ );
99
+ if (!match) {
100
+ return null;
101
+ }
102
+
103
+ const prerelease = String(match[4] ?? "")
104
+ .split(".")
105
+ .map((part) => part.trim())
106
+ .filter(Boolean);
107
+
108
+ return {
109
+ major: Number.parseInt(match[1] ?? "", 10),
110
+ minor: Number.parseInt(match[2] ?? "", 10),
111
+ patch: Number.parseInt(match[3] ?? "", 10),
112
+ prerelease,
113
+ };
114
+ }
@@ -5,6 +5,13 @@ import process from "node:process";
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { codexInstallPackageSpec } from "./codex-version.mjs";
9
+ import {
10
+ buildCodexCompatibilityError,
11
+ probeCodexVersion,
12
+ resolveCodexBinForStart,
13
+ resolveCompatibleInstalledCodexBin,
14
+ } from "./codex-runtime.mjs";
8
15
 
9
16
  const __filename = fileURLToPath(import.meta.url);
10
17
  const __dirname = path.dirname(__filename);
@@ -22,6 +29,9 @@ const agentEngineLogPath = path.join(logsDir, "agent-engine.log");
22
29
  const daemonScriptPath = path.join(repoRoot, "scripts", "dist", "daemon.mjs");
23
30
  const supervisorScriptPath = path.join(repoRoot, "scripts", "dist", "supervisor.mjs");
24
31
  const nodeBin = process.execPath;
32
+ const agentEngineEnvPath = path.join(repoRoot, "apps", "agent-engine", ".env");
33
+ const controlPlaneEnvPath = path.join(repoRoot, "apps", "control-plane", ".env");
34
+ const codexInstallCommand = `npm install -g ${codexInstallPackageSpec}`;
25
35
 
26
36
  const BASE_CHECK_MS = 5000;
27
37
  const MAX_BACKOFF_MS = 60000;
@@ -120,6 +130,26 @@ async function runDaemonLoop() {
120
130
  const state = { stopping: false, shuttingDown: false };
121
131
  setupSignalHandlers(state);
122
132
 
133
+ try {
134
+ ensureCompatibleCodexForDaemon();
135
+ clearLastStartupError();
136
+ } catch (error) {
137
+ writeLastStartupError({
138
+ detectedAt: new Date().toISOString(),
139
+ reason: getErrorMessage(error),
140
+ action: buildCodexCompatibilityAction(error),
141
+ });
142
+ console.error(`[daemon] fatal startup error: ${getErrorMessage(error)}`);
143
+ console.error(`[daemon] action required: ${buildCodexCompatibilityAction(error)}`);
144
+ state.stopping = true;
145
+ await shutdownDaemon(state, {
146
+ reason: "fatal-codex-compatibility",
147
+ exitCode: 1,
148
+ pauseBeforeExit: true,
149
+ });
150
+ return;
151
+ }
152
+
123
153
  console.log(`[daemon] running (pid ${process.pid})`);
124
154
 
125
155
  let failureCount = 0;
@@ -614,6 +644,45 @@ function printLastStartupError() {
614
644
  }
615
645
  }
616
646
 
647
+ function ensureCompatibleCodexForDaemon(): void {
648
+ const resolved = resolveCodexBinForStart({
649
+ repoRoot,
650
+ agentEngineEnvPath,
651
+ controlPlaneEnvPath,
652
+ });
653
+ const currentProbe = probeCodexVersion({
654
+ codexBin: resolved.bin,
655
+ repoRoot,
656
+ });
657
+ if (currentProbe.ok && currentProbe.compatible) {
658
+ return;
659
+ }
660
+
661
+ if (!resolved.userConfigured) {
662
+ const compatibleInstalled = resolveCompatibleInstalledCodexBin({ repoRoot });
663
+ if (compatibleInstalled) {
664
+ return;
665
+ }
666
+ }
667
+
668
+ throw new Error(
669
+ buildCodexCompatibilityError({
670
+ resolved,
671
+ probe: currentProbe,
672
+ includeInstallHint: !resolved.userConfigured,
673
+ installCommand: codexInstallCommand,
674
+ }),
675
+ );
676
+ }
677
+
678
+ function buildCodexCompatibilityAction(error: unknown): string {
679
+ const message = getErrorMessage(error);
680
+ if (message.includes("Install a compatible version with")) {
681
+ return `Install a compatible version with '${codexInstallCommand}', then restart the service.`;
682
+ }
683
+ return "Update that binary or point CODEX_BIN to a compatible executable, then restart the service.";
684
+ }
685
+
617
686
  function printUsage() {
618
687
  console.log("Usage: node scripts/dist/daemon.mjs <start|run|stop|status|help>");
619
688
  }