minecodex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/apps/dashboard/app.js +387 -0
  4. package/apps/dashboard/favicon.svg +18 -0
  5. package/apps/dashboard/index.html +70 -0
  6. package/apps/dashboard/styles.css +297 -0
  7. package/features/images/README.md +106 -0
  8. package/features/images/codex-feature.json +24 -0
  9. package/features/images/src/http-server.mjs +302 -0
  10. package/features/images/src/image-library.mjs +582 -0
  11. package/features/images/src/main.mjs +52 -0
  12. package/features/images/src/prompt-index.mjs +182 -0
  13. package/features/images/src/save-as.mjs +40 -0
  14. package/features/images/src/thread-metadata.mjs +93 -0
  15. package/features/images/web/app.js +1045 -0
  16. package/features/images/web/index.html +162 -0
  17. package/features/images/web/styles.css +994 -0
  18. package/features/model-slider/README.md +30 -0
  19. package/features/model-slider/THIRD_PARTY_NOTICES.md +24 -0
  20. package/features/model-slider/assets/providers/anthropic.svg +1 -0
  21. package/features/model-slider/assets/providers/deepseek.svg +1 -0
  22. package/features/model-slider/assets/providers/gemini.svg +1 -0
  23. package/features/model-slider/assets/providers/grok.svg +1 -0
  24. package/features/model-slider/assets/providers/hunyuan.svg +1 -0
  25. package/features/model-slider/assets/providers/kimi.svg +1 -0
  26. package/features/model-slider/assets/providers/minimax.svg +1 -0
  27. package/features/model-slider/assets/providers/openai.svg +1 -0
  28. package/features/model-slider/assets/providers/qwen.svg +1 -0
  29. package/features/model-slider/assets/providers/xiaomimimo.svg +1 -0
  30. package/features/model-slider/assets/providers/zhipu.svg +1 -0
  31. package/features/model-slider/assets/ui/sparkles.svg +18 -0
  32. package/features/model-slider/assets/ui/star-off.svg +17 -0
  33. package/features/model-slider/assets/ui/star.svg +15 -0
  34. package/features/model-slider/codex-feature.json +56 -0
  35. package/features/model-slider/vendor/cc-switch-LICENSE +21 -0
  36. package/features/model-slider/vendor/lucide-LICENSE +35 -0
  37. package/features/notes/README.md +106 -0
  38. package/features/notes/assets/icons/circle.svg +15 -0
  39. package/features/notes/assets/icons/ellipsis.svg +17 -0
  40. package/features/notes/assets/icons/external-link.svg +17 -0
  41. package/features/notes/assets/icons/list-todo.svg +19 -0
  42. package/features/notes/assets/icons/message-square-quote.svg +17 -0
  43. package/features/notes/assets/icons/paperclip.svg +15 -0
  44. package/features/notes/assets/icons/star-off.svg +17 -0
  45. package/features/notes/assets/icons/star.svg +15 -0
  46. package/features/notes/assets/icons/sticky-note.svg +16 -0
  47. package/features/notes/assets/icons/trash-2.svg +19 -0
  48. package/features/notes/codex-feature.json +90 -0
  49. package/features/notes/src/http-server.mjs +541 -0
  50. package/features/notes/src/main.mjs +54 -0
  51. package/features/notes/src/product.mjs +771 -0
  52. package/features/notes/web/app.js +1495 -0
  53. package/features/notes/web/codex-artifact-document.svg +1 -0
  54. package/features/notes/web/codex-attachment.svg +1 -0
  55. package/features/notes/web/codex-build.svg +1 -0
  56. package/features/notes/web/codex-code.svg +1 -0
  57. package/features/notes/web/codex-cplusplus.svg +1 -0
  58. package/features/notes/web/codex-css.svg +1 -0
  59. package/features/notes/web/codex-document.svg +1 -0
  60. package/features/notes/web/codex-file.svg +1 -0
  61. package/features/notes/web/codex-folder.svg +1 -0
  62. package/features/notes/web/codex-hashes.svg +1 -0
  63. package/features/notes/web/codex-html.svg +1 -0
  64. package/features/notes/web/codex-image.svg +1 -0
  65. package/features/notes/web/codex-java.svg +1 -0
  66. package/features/notes/web/codex-javascript.svg +1 -0
  67. package/features/notes/web/codex-json.svg +1 -0
  68. package/features/notes/web/codex-notebook.svg +1 -0
  69. package/features/notes/web/codex-pdf.svg +1 -0
  70. package/features/notes/web/codex-pencil.svg +3 -0
  71. package/features/notes/web/codex-php.svg +1 -0
  72. package/features/notes/web/codex-plus.svg +3 -0
  73. package/features/notes/web/codex-presentation.svg +1 -0
  74. package/features/notes/web/codex-python.svg +1 -0
  75. package/features/notes/web/codex-react.svg +1 -0
  76. package/features/notes/web/codex-rust.svg +1 -0
  77. package/features/notes/web/codex-shell.svg +1 -0
  78. package/features/notes/web/codex-skill.svg +1 -0
  79. package/features/notes/web/codex-spreadsheet.svg +1 -0
  80. package/features/notes/web/codex-task.svg +3 -0
  81. package/features/notes/web/codex-terminal.svg +1 -0
  82. package/features/notes/web/codex-toml.svg +1 -0
  83. package/features/notes/web/codex-typescript.svg +1 -0
  84. package/features/notes/web/codex-view-all.svg +3 -0
  85. package/features/notes/web/index.html +15 -0
  86. package/features/notes/web/styles.css +1155 -0
  87. package/package.json +67 -0
  88. package/packages/cli/bin/mcx.mjs +7 -0
  89. package/packages/cli/src/browser-exchange.mjs +31 -0
  90. package/packages/cli/src/commands.mjs +389 -0
  91. package/packages/cli/src/config.mjs +101 -0
  92. package/packages/cli/src/control-server.mjs +371 -0
  93. package/packages/cli/src/npm-adapter.mjs +40 -0
  94. package/packages/cli/src/paths.mjs +33 -0
  95. package/packages/cli/src/platform.mjs +368 -0
  96. package/packages/cli/src/runtime-manager.mjs +338 -0
  97. package/packages/runtime-host/README.md +171 -0
  98. package/packages/runtime-host/src/codex-runtime.mjs +4311 -0
  99. package/packages/runtime-host/src/feature-registry.mjs +827 -0
  100. package/packages/runtime-host/src/main.mjs +98 -0
  101. package/packages/runtime-host/src/pid-file.mjs +23 -0
@@ -0,0 +1,827 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import { readdir, readFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const MANIFEST_NAME = "codex-feature.json";
8
+ const HOST_ACTIONS = new Set([
9
+ "insert-text",
10
+ "attach-file",
11
+ "open-detail-tab",
12
+ "open-editor-modal",
13
+ "resolve-file-paths",
14
+ ]);
15
+
16
+ export const FEATURE_PROTOCOL_VERSION = 1;
17
+ export const FEATURE_HEALTH_TIMEOUT_MS = 750;
18
+ export const FEATURE_STARTUP_TIMEOUT_MS = 1_000;
19
+ export const FEATURE_HEALTH_POLL_INTERVAL_MS = 50;
20
+ export const FEATURE_STOP_TIMEOUT_MS = 1_000;
21
+ export const FEATURE_KILL_TIMEOUT_MS = 500;
22
+
23
+ function normalizeUrl(value, field) {
24
+ const url = new URL(value);
25
+ if (!new Set(["http:", "https:"]).has(url.protocol)) {
26
+ throw new Error(`${field} must use http or https`);
27
+ }
28
+ return url.href;
29
+ }
30
+
31
+ function normalizeLocaleKey(value) {
32
+ const key = String(value).trim();
33
+ if (key.toLowerCase().startsWith("zh")) return "zh-CN";
34
+ if (key.toLowerCase().startsWith("en")) return "en";
35
+ return key;
36
+ }
37
+
38
+ function normalizeLocalizedText(value, field) {
39
+ if (typeof value === "string" && value.trim()) {
40
+ return { value, locales: { en: value } };
41
+ }
42
+ if (value && typeof value === "object" && !Array.isArray(value)) {
43
+ const locales = {};
44
+ for (const [locale, text] of Object.entries(value)) {
45
+ if (typeof text !== "string" || !text.trim()) {
46
+ throw new Error(`${field}.${locale} must be a non-empty string`);
47
+ }
48
+ locales[normalizeLocaleKey(locale)] = text;
49
+ }
50
+ const fallback = locales.en ?? Object.values(locales)[0];
51
+ if (!fallback) throw new Error(`${field} must define at least one locale`);
52
+ return { value: fallback, locales: { en: fallback, ...locales } };
53
+ }
54
+ throw new Error(`${field} must be a string or locale map`);
55
+ }
56
+
57
+ async function normalizeIcon(icon, projectDir) {
58
+ if (!icon) return null;
59
+ if (icon.markup) return icon;
60
+ if (!icon.resource) throw new Error("icon must include markup or resource");
61
+ const resourcePath = path.resolve(projectDir, icon.resource);
62
+ const relative = path.relative(projectDir, resourcePath);
63
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
64
+ throw new Error("icon.resource must stay inside the feature project");
65
+ }
66
+ const svg = await readFile(resourcePath, "utf8");
67
+ const match = svg.match(/<svg\b([^>]*)>([\s\S]*?)<\/svg>/i);
68
+ if (!match) throw new Error("icon.resource must contain one SVG root");
69
+ const viewBox = match[1].match(/viewBox=["']([^"']+)["']/i)?.[1] ?? "0 0 24 24";
70
+ return { viewBox, markup: match[2].trim(), resource: icon.resource };
71
+ }
72
+
73
+ async function normalizeModelSelector(modelSelector, projectDir) {
74
+ if (!modelSelector || typeof modelSelector !== "object" || Array.isArray(modelSelector)) {
75
+ throw new Error("composer-model-selector entries require modelSelector");
76
+ }
77
+ const maxVisibleItems = Number(modelSelector.maxVisibleItems ?? 6);
78
+ if (!Number.isInteger(maxVisibleItems) || maxVisibleItems < 1) {
79
+ throw new Error("modelSelector.maxVisibleItems must be a positive integer");
80
+ }
81
+ const favoriteStorageKey = String(modelSelector.favoriteStorageKey ?? "").trim();
82
+ if (!favoriteStorageKey) {
83
+ throw new Error("modelSelector.favoriteStorageKey is required");
84
+ }
85
+
86
+ const providerAliases = {};
87
+ for (const [provider, label] of Object.entries(modelSelector.providerAliases ?? {})) {
88
+ if (!String(provider).trim() || !String(label).trim()) {
89
+ throw new Error("modelSelector.providerAliases must contain non-empty strings");
90
+ }
91
+ providerAliases[String(provider).trim().toLowerCase()] = String(label).trim();
92
+ }
93
+
94
+ const brands = await Promise.all((modelSelector.brands ?? []).map(async (brand, index) => {
95
+ const id = String(brand.id ?? "").trim();
96
+ const keywords = (brand.keywords ?? []).map((keyword) => String(keyword).trim().toLowerCase()).filter(Boolean);
97
+ if (!id || !keywords.length || !brand.icon) {
98
+ throw new Error(`modelSelector.brands.${index} requires id, keywords, and icon`);
99
+ }
100
+ return { id, keywords, icon: await normalizeIcon(brand.icon, projectDir) };
101
+ }));
102
+ if (new Set(brands.map((brand) => brand.id)).size !== brands.length) {
103
+ throw new Error("modelSelector brand ids must be unique");
104
+ }
105
+
106
+ const icons = modelSelector.icons ?? {};
107
+ if (!icons.fallback || !icons.star || !icons.unstar) {
108
+ throw new Error("modelSelector.icons requires fallback, star, and unstar");
109
+ }
110
+ const strings = {};
111
+ for (const key of ["star", "unstar"]) {
112
+ const localized = normalizeLocalizedText(modelSelector.strings?.[key], `modelSelector.strings.${key}`);
113
+ strings[key] = { value: localized.value, locales: localized.locales };
114
+ }
115
+
116
+ return {
117
+ maxVisibleItems,
118
+ favoriteStorageKey,
119
+ providerAliases,
120
+ strings,
121
+ icons: {
122
+ fallback: await normalizeIcon(icons.fallback, projectDir),
123
+ star: await normalizeIcon(icons.star, projectDir),
124
+ unstar: await normalizeIcon(icons.unstar, projectDir),
125
+ },
126
+ brands,
127
+ };
128
+ }
129
+
130
+ async function normalizeFeature(manifest, projectDir) {
131
+ if (manifest.schemaVersion !== 1) throw new Error("schemaVersion must be 1");
132
+ if (!manifest.id || !manifest.label) {
133
+ throw new Error("id and label are required");
134
+ }
135
+
136
+ const label = normalizeLocalizedText(manifest.label, "label");
137
+ const entryKind = manifest.entry?.kind ?? "sidebar";
138
+ if (!new Set(["sidebar", "conversation-toolbar", "composer-model-selector"]).has(entryKind)) {
139
+ throw new Error("entry.kind must be sidebar, conversation-toolbar, or composer-model-selector");
140
+ }
141
+ const isModelSelector = entryKind === "composer-model-selector";
142
+ if (!isModelSelector && !manifest.surfaceUrl) {
143
+ throw new Error("surfaceUrl is required for rendered features");
144
+ }
145
+ if (entryKind === "conversation-toolbar" && !manifest.entry?.ariaLabel) {
146
+ throw new Error("conversation-toolbar entries require entry.ariaLabel");
147
+ }
148
+ if (entryKind === "conversation-toolbar" && !manifest.pinnedSummary?.surfaceUrl) {
149
+ throw new Error("conversation-toolbar entries require pinnedSummary.surfaceUrl");
150
+ }
151
+ const summaryPresentation = manifest.pinnedSummary?.presentation ?? "responsive-summary";
152
+ if (summaryPresentation !== "responsive-summary") {
153
+ throw new Error("pinnedSummary.presentation must be responsive-summary");
154
+ }
155
+
156
+ const detailTabs = await Promise.all((manifest.detailTabs ?? []).map(async (detail) => {
157
+ if (!detail.id || !detail.label || !detail.surfaceUrl) {
158
+ throw new Error("each detailTabs entry requires id, label, and surfaceUrl");
159
+ }
160
+ const detailLabel = normalizeLocalizedText(detail.label, `detailTabs.${detail.id}.label`);
161
+ return {
162
+ id: String(detail.id),
163
+ label: detailLabel.value,
164
+ labels: detailLabel.locales,
165
+ surfaceUrl: normalizeUrl(detail.surfaceUrl, `detailTabs.${detail.id}.surfaceUrl`),
166
+ icon: await normalizeIcon(detail.icon, projectDir),
167
+ };
168
+ }));
169
+ if (new Set(detailTabs.map((detail) => detail.id)).size !== detailTabs.length) {
170
+ throw new Error("detailTabs ids must be unique");
171
+ }
172
+ const hostActions = (manifest.hostActions ?? []).map(String);
173
+ if (hostActions.some((action) => !HOST_ACTIONS.has(action))) {
174
+ throw new Error("hostActions contains an unsupported action");
175
+ }
176
+
177
+ const surfaceUrl = manifest.surfaceUrl ? normalizeUrl(manifest.surfaceUrl, "surfaceUrl") : null;
178
+ const entryLabel = normalizeLocalizedText(manifest.entry?.ariaLabel ?? manifest.label, "entry.ariaLabel");
179
+ const summaryLabel = manifest.pinnedSummary
180
+ ? normalizeLocalizedText(manifest.pinnedSummary.label ?? manifest.label, "pinnedSummary.label")
181
+ : null;
182
+ const overlayLabel = manifest.pinnedSummary
183
+ ? normalizeLocalizedText(manifest.pinnedSummary.overlayLabel ?? manifest.label, "pinnedSummary.overlayLabel")
184
+ : null;
185
+ const pinnedLabel = manifest.pinnedSummary
186
+ ? normalizeLocalizedText(
187
+ manifest.pinnedSummary.pinnedLabel
188
+ ?? manifest.entry?.ariaLabel
189
+ ?? (typeof manifest.label === "string"
190
+ ? `Toggle pinned ${manifest.label.toLowerCase()}`
191
+ : manifest.label),
192
+ "pinnedSummary.pinnedLabel",
193
+ )
194
+ : null;
195
+
196
+ return {
197
+ id: String(manifest.id),
198
+ label: label.value,
199
+ labels: label.locales,
200
+ surfaceUrl,
201
+ healthUrl: manifest.healthUrl || surfaceUrl
202
+ ? normalizeUrl(manifest.healthUrl ?? surfaceUrl, "healthUrl")
203
+ : null,
204
+ icon: await normalizeIcon(manifest.icon, projectDir),
205
+ entry: {
206
+ kind: entryKind,
207
+ ariaLabel: entryLabel.value,
208
+ ariaLabels: entryLabel.locales,
209
+ },
210
+ pinnedSummary: manifest.pinnedSummary ? {
211
+ label: summaryLabel.value,
212
+ labels: summaryLabel.locales,
213
+ surfaceUrl: normalizeUrl(manifest.pinnedSummary.surfaceUrl, "pinnedSummary.surfaceUrl"),
214
+ presentation: summaryPresentation,
215
+ overlayLabel: overlayLabel.value,
216
+ overlayLabels: overlayLabel.locales,
217
+ pinnedLabel: pinnedLabel.value,
218
+ pinnedLabels: pinnedLabel.locales,
219
+ } : null,
220
+ detailTabs,
221
+ hostActions,
222
+ modelSelector: isModelSelector
223
+ ? await normalizeModelSelector(manifest.modelSelector, projectDir)
224
+ : null,
225
+ placement: {
226
+ after: manifest.placement?.after ?? "sites",
227
+ order: Number(manifest.placement?.order ?? 100),
228
+ },
229
+ command: Array.isArray(manifest.command) ? manifest.command.map(String) : null,
230
+ projectDir,
231
+ };
232
+ }
233
+
234
+ export async function discoverFeatures(featuresRoot) {
235
+ const entries = await readdir(featuresRoot, { withFileTypes: true });
236
+ const features = [];
237
+
238
+ for (const entry of entries) {
239
+ if (!entry.isDirectory()) continue;
240
+ const projectDir = path.join(featuresRoot, entry.name);
241
+ let source;
242
+ try {
243
+ source = await readFile(path.join(projectDir, MANIFEST_NAME), "utf8");
244
+ } catch (error) {
245
+ if (error.code === "ENOENT") continue;
246
+ throw error;
247
+ }
248
+ try {
249
+ const manifest = JSON.parse(source);
250
+ features.push(await normalizeFeature(manifest, projectDir));
251
+ } catch (error) {
252
+ throw new Error(`Invalid ${entry.name}/${MANIFEST_NAME}: ${error.message}`);
253
+ }
254
+ }
255
+
256
+ return features.sort((left, right) => (
257
+ left.placement.order - right.placement.order || left.label.localeCompare(right.label)
258
+ ));
259
+ }
260
+
261
+ export function selectEnabledFeatures(features, configuredValue) {
262
+ if (configuredValue === undefined) return features;
263
+ const enabled = new Set(String(configuredValue).split(",").map((value) => value.trim()).filter(Boolean));
264
+ return features.filter((feature) => enabled.has(feature.id));
265
+ }
266
+
267
+ export async function loadConfiguredModelCatalog(
268
+ configPath = path.join(os.homedir(), ".codex", "config.toml"),
269
+ ) {
270
+ try {
271
+ const config = await readFile(configPath, "utf8");
272
+ const configuredPath = config.match(/^\s*model_catalog_json\s*=\s*["']([^"']+)["']/m)?.[1];
273
+ if (!configuredPath) return [];
274
+ const catalogPath = path.isAbsolute(configuredPath)
275
+ ? configuredPath
276
+ : path.resolve(path.dirname(configPath), configuredPath);
277
+ const payload = JSON.parse(await readFile(catalogPath, "utf8"));
278
+ const models = Array.isArray(payload) ? payload : payload.models;
279
+ if (!Array.isArray(models)) return [];
280
+ return models.map((model) => ({
281
+ slug: String(model.slug ?? "").trim(),
282
+ displayName: String(model.display_name ?? model.slug ?? "").trim(),
283
+ defaultReasoningLevel: String(model.default_reasoning_level ?? "").trim(),
284
+ supportedReasoningLevels: (model.supported_reasoning_levels ?? []).map((level) => ({
285
+ effort: String(level.effort ?? "").trim(),
286
+ description: String(level.description ?? "").trim(),
287
+ })).filter((level) => level.effort),
288
+ additionalSpeedTiers: (model.additional_speed_tiers ?? []).map(String),
289
+ serviceTiers: (model.service_tiers ?? []).map((tier) => ({
290
+ id: String(tier.id ?? "").trim(),
291
+ name: String(tier.name ?? "").trim(),
292
+ })),
293
+ })).filter((model) => model.slug);
294
+ } catch (error) {
295
+ if (error.code === "ENOENT") return [];
296
+ throw new Error(`Could not load Codex model catalog: ${error.message}`);
297
+ }
298
+ }
299
+
300
+ function sleep(timeoutMs) {
301
+ return new Promise((resolve) => setTimeout(resolve, timeoutMs));
302
+ }
303
+
304
+ function failure(code, message, phase = "health") {
305
+ return { code, message, phase };
306
+ }
307
+
308
+ function childHasExited(child) {
309
+ return child && (
310
+ (child.exitCode !== null && child.exitCode !== undefined)
311
+ || (child.signalCode !== null && child.signalCode !== undefined)
312
+ );
313
+ }
314
+
315
+ function waitForChildExit(child, timeoutMs) {
316
+ if (childHasExited(child)) return Promise.resolve(true);
317
+ return new Promise((resolve) => {
318
+ let settled = false;
319
+ let timer;
320
+ const finish = (exited) => {
321
+ if (settled) return;
322
+ settled = true;
323
+ clearTimeout(timer);
324
+ child.removeListener?.("exit", onExit);
325
+ resolve(exited || childHasExited(child));
326
+ };
327
+ const onExit = () => finish(true);
328
+ child.once?.("exit", onExit);
329
+ timer = setTimeout(() => finish(false), timeoutMs);
330
+ });
331
+ }
332
+
333
+ async function terminateOwnedChild(record, options, { preserveState = false } = {}) {
334
+ const child = record.child;
335
+ if (!record.owned || !child) return;
336
+ if (!childHasExited(child)) {
337
+ try {
338
+ child.kill?.("SIGTERM");
339
+ } catch (error) {
340
+ error.code ??= "FEATURE_CHILD_TERMINATE_FAILED";
341
+ throw error;
342
+ }
343
+ let exited = await waitForChildExit(child, options.stopTimeoutMs ?? FEATURE_STOP_TIMEOUT_MS);
344
+ if (!exited && !childHasExited(child)) {
345
+ try {
346
+ child.kill?.("SIGKILL");
347
+ } catch (error) {
348
+ error.code ??= "FEATURE_CHILD_TERMINATE_FAILED";
349
+ throw error;
350
+ }
351
+ exited = await waitForChildExit(child, options.killTimeoutMs ?? FEATURE_KILL_TIMEOUT_MS);
352
+ }
353
+ if (!exited && !childHasExited(child)) {
354
+ const error = new Error(
355
+ `${record.featureId} child did not confirm exit after bounded SIGTERM/SIGKILL shutdown`,
356
+ );
357
+ error.code = "FEATURE_CHILD_SHUTDOWN_TIMEOUT";
358
+ error.phase = "shutdown";
359
+ throw error;
360
+ }
361
+ }
362
+ if (!preserveState) record.state = "stopped";
363
+ }
364
+
365
+ export function validateFeatureHealth(payload, featureId, instanceId) {
366
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
367
+ return { ok: false, failure: failure("HEALTH_INVALID_RESPONSE", "Health response must be a JSON object") };
368
+ }
369
+ if (payload.ok !== true) {
370
+ return {
371
+ ok: false,
372
+ failure: failure("HEALTH_NOT_READY", "Health response must report ok: true"),
373
+ };
374
+ }
375
+ if (payload.service !== featureId) {
376
+ return {
377
+ ok: false,
378
+ failure: failure(
379
+ "HEALTH_IDENTITY_MISMATCH",
380
+ `Health service ${JSON.stringify(payload.service ?? null)} does not match ${JSON.stringify(featureId)}`,
381
+ ),
382
+ };
383
+ }
384
+ if (payload.protocolVersion !== FEATURE_PROTOCOL_VERSION) {
385
+ return {
386
+ ok: false,
387
+ failure: failure(
388
+ "HEALTH_PROTOCOL_MISMATCH",
389
+ `Health protocolVersion must be ${FEATURE_PROTOCOL_VERSION}`,
390
+ ),
391
+ };
392
+ }
393
+ if (typeof instanceId !== "string" || !instanceId || payload.instanceId !== instanceId) {
394
+ return {
395
+ ok: false,
396
+ failure: failure("HEALTH_INSTANCE_MISMATCH", "Health instanceId is not owned by this RuntimeHost"),
397
+ };
398
+ }
399
+ return { ok: true, health: payload };
400
+ }
401
+
402
+ export async function checkFeatureHealth(
403
+ feature,
404
+ {
405
+ instanceId,
406
+ fetchImpl = globalThis.fetch,
407
+ timeoutMs = FEATURE_HEALTH_TIMEOUT_MS,
408
+ signal,
409
+ } = {},
410
+ ) {
411
+ if (!feature.healthUrl) {
412
+ return {
413
+ ok: false,
414
+ failure: failure("HEALTH_NOT_CONFIGURED", `${feature.label ?? feature.id} has no healthUrl`),
415
+ };
416
+ }
417
+
418
+ const controller = new AbortController();
419
+ const boundedTimeoutMs = Math.max(1, Number(timeoutMs) || FEATURE_HEALTH_TIMEOUT_MS);
420
+ let timedOut = false;
421
+ let timer;
422
+ let cancelFromParent;
423
+ const request = (async () => {
424
+ const response = await fetchImpl(feature.healthUrl, { signal: controller.signal });
425
+ if (!response?.ok) {
426
+ return {
427
+ ok: false,
428
+ failure: failure("HEALTH_HTTP", `Health endpoint returned HTTP ${response?.status ?? "error"}`),
429
+ };
430
+ }
431
+ let payload;
432
+ try {
433
+ payload = await response.json();
434
+ } catch {
435
+ return { ok: false, failure: failure("HEALTH_INVALID_JSON", "Health endpoint returned invalid JSON") };
436
+ }
437
+ return validateFeatureHealth(payload, feature.id, instanceId);
438
+ })();
439
+ const timeout = new Promise((resolve) => {
440
+ timer = setTimeout(() => {
441
+ timedOut = true;
442
+ controller.abort();
443
+ resolve({
444
+ ok: false,
445
+ failure: failure("HEALTH_TIMEOUT", `Health check timed out after ${boundedTimeoutMs}ms`),
446
+ });
447
+ }, boundedTimeoutMs);
448
+ });
449
+ const cancellation = signal
450
+ ? new Promise((resolve) => {
451
+ cancelFromParent = () => {
452
+ controller.abort();
453
+ resolve({
454
+ ok: false,
455
+ failure: failure(
456
+ "HEALTH_ABORTED",
457
+ `Health check for ${feature.label ?? feature.id} was cancelled`,
458
+ "startup",
459
+ ),
460
+ });
461
+ };
462
+ if (signal.aborted) cancelFromParent();
463
+ else signal.addEventListener("abort", cancelFromParent, { once: true });
464
+ })
465
+ : new Promise(() => {});
466
+
467
+ try {
468
+ const result = await Promise.race([request, timeout, cancellation]);
469
+ if (timedOut) return result;
470
+ return result;
471
+ } catch (error) {
472
+ if (signal?.aborted) {
473
+ return {
474
+ ok: false,
475
+ failure: failure(
476
+ "HEALTH_ABORTED",
477
+ `Health check for ${feature.label ?? feature.id} was cancelled`,
478
+ "startup",
479
+ ),
480
+ };
481
+ }
482
+ if (timedOut || error?.name === "AbortError") {
483
+ return {
484
+ ok: false,
485
+ failure: failure("HEALTH_TIMEOUT", `Health check timed out after ${boundedTimeoutMs}ms`),
486
+ };
487
+ }
488
+ return {
489
+ ok: false,
490
+ failure: failure("HEALTH_UNREACHABLE", error?.message ?? "Health endpoint was unreachable"),
491
+ };
492
+ } finally {
493
+ clearTimeout(timer);
494
+ if (cancelFromParent) signal.removeEventListener("abort", cancelFromParent);
495
+ }
496
+ }
497
+
498
+ async function waitForFeatureHealth(feature, child, options) {
499
+ const deadline = Date.now() + Math.max(1, Number(options.startupTimeoutMs) || FEATURE_STARTUP_TIMEOUT_MS);
500
+ let lastFailure = failure("HEALTH_TIMEOUT", `Timed out waiting for ${feature.label ?? feature.id}`);
501
+ while (Date.now() < deadline) {
502
+ if (options.signal?.aborted) {
503
+ return {
504
+ ok: false,
505
+ failure: failure("HEALTH_ABORTED", `Startup for ${feature.label ?? feature.id} was cancelled`, "startup"),
506
+ };
507
+ }
508
+ if (options.childState?.failure) {
509
+ return { ok: false, failure: options.childState.failure };
510
+ }
511
+ if (childHasExited(child)) {
512
+ return {
513
+ ok: false,
514
+ failure: failure("CHILD_EXITED", `${feature.label ?? feature.id} exited before health became valid`, "startup"),
515
+ };
516
+ }
517
+ const remainingBeforeProbe = deadline - Date.now();
518
+ const result = await checkFeatureHealth(feature, {
519
+ ...options,
520
+ timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingBeforeProbe)),
521
+ });
522
+ if (result.ok) return result;
523
+ if (options.signal?.aborted || result.failure?.code === "HEALTH_ABORTED") return result;
524
+ if (options.childState?.failure) {
525
+ return { ok: false, failure: options.childState.failure };
526
+ }
527
+ lastFailure = result.failure;
528
+ if (options.childState?.failure || childHasExited(child)) break;
529
+ const remaining = deadline - Date.now();
530
+ if (remaining <= 0) break;
531
+ await options.sleep(Math.min(
532
+ Math.max(1, Number(options.pollIntervalMs) || FEATURE_HEALTH_POLL_INTERVAL_MS),
533
+ remaining,
534
+ ));
535
+ }
536
+ return { ok: false, failure: lastFailure };
537
+ }
538
+
539
+ function normalizeStartOptions(loggerOrOptions, maybeOptions) {
540
+ const looksLikeLogger = loggerOrOptions
541
+ && (typeof loggerOrOptions.log === "function" || typeof loggerOrOptions.warn === "function");
542
+ if (looksLikeLogger) {
543
+ return { ...maybeOptions, logger: loggerOrOptions };
544
+ }
545
+ const options = loggerOrOptions ?? {};
546
+ return { ...options, logger: options.logger ?? console };
547
+ }
548
+
549
+ function featureFailure(record) {
550
+ if (!record.failure) return null;
551
+ return { featureId: record.featureId, ...record.failure };
552
+ }
553
+
554
+ export async function startFeatureProcesses(features, loggerOrOptions = console, maybeOptions = {}) {
555
+ const options = normalizeStartOptions(loggerOrOptions, maybeOptions);
556
+ const instanceId = String(options.instanceId ?? randomUUID());
557
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
558
+ const spawnProcess = options.spawnProcess ?? spawn;
559
+ const sleepImpl = options.sleep ?? sleep;
560
+ const startupController = new AbortController();
561
+ const terminationPromises = new WeakMap();
562
+ let stopping = false;
563
+ const records = features.map((feature) => ({
564
+ featureId: feature.id,
565
+ state: feature.surfaceUrl ? "launching" : "configured",
566
+ owned: false,
567
+ child: null,
568
+ health: null,
569
+ failure: null,
570
+ }));
571
+
572
+ function notifyStatusChange() {
573
+ try {
574
+ const result = options.onStatusChange?.();
575
+ if (result && typeof result.then === "function") {
576
+ Promise.resolve(result).catch((error) => {
577
+ options.logger.warn?.("Feature status update failed", error.message);
578
+ });
579
+ }
580
+ } catch (error) {
581
+ options.logger.warn?.("Feature status update failed", error.message);
582
+ }
583
+ }
584
+
585
+ function markFailed(record, nextFailure) {
586
+ record.state = "failed";
587
+ record.health = null;
588
+ record.failure = nextFailure;
589
+ notifyStatusChange();
590
+ }
591
+
592
+ function childExitFailure(feature, code, signal, phase) {
593
+ const reason = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
594
+ return {
595
+ ...failure("CHILD_EXITED", `${feature.label ?? feature.id} child exited with ${reason}`, phase),
596
+ exitCode: code ?? null,
597
+ signal: signal ?? null,
598
+ };
599
+ }
600
+
601
+ function observeOwnedChild(feature, record, child, childState) {
602
+ child.on?.("error", (error) => {
603
+ if (record.child !== child || stopping) return;
604
+ if (record.state === "ready") {
605
+ markFailed(record, {
606
+ ...failure("CHILD_ERROR", error.message, "runtime"),
607
+ name: error.name ?? "Error",
608
+ });
609
+ return;
610
+ }
611
+ childState.failure ??= failure("CHILD_SPAWN_FAILED", error.message, "startup");
612
+ });
613
+ child.once?.("exit", (code, signal) => {
614
+ if (record.child !== child || stopping) return;
615
+ if (record.state === "ready") {
616
+ markFailed(record, childExitFailure(feature, code, signal, "runtime"));
617
+ return;
618
+ }
619
+ if (record.state === "launching") {
620
+ childState.failure ??= childExitFailure(feature, code, signal, "startup");
621
+ }
622
+ });
623
+ }
624
+
625
+ function terminateRecord(record, { preserveState = false } = {}) {
626
+ const pending = terminationPromises.get(record);
627
+ if (pending) return pending;
628
+ let operation;
629
+ operation = terminateOwnedChild(record, options, { preserveState }).finally(() => {
630
+ if (terminationPromises.get(record) === operation) terminationPromises.delete(record);
631
+ });
632
+ terminationPromises.set(record, operation);
633
+ return operation;
634
+ }
635
+
636
+ async function failOwnedStartup(feature, record, nextFailure) {
637
+ markFailed(record, nextFailure);
638
+ if (record.owned && record.child) {
639
+ try {
640
+ await terminateRecord(record, { preserveState: true });
641
+ } catch (error) {
642
+ record.failure = {
643
+ ...record.failure,
644
+ cleanup: {
645
+ code: error.code ?? "FEATURE_CHILD_TERMINATE_FAILED",
646
+ message: error.message,
647
+ phase: error.phase ?? "shutdown",
648
+ },
649
+ };
650
+ notifyStatusChange();
651
+ options.logger.warn?.(`${feature.label}: owned child cleanup failed (${error.message})`);
652
+ }
653
+ }
654
+ options.logger.warn?.(`${feature.label}: unavailable (${record.failure.message})`);
655
+ }
656
+
657
+ async function startOne(feature, index) {
658
+ const record = records[index];
659
+ if (!feature.surfaceUrl) return;
660
+
661
+ const healthOptions = {
662
+ instanceId,
663
+ fetchImpl,
664
+ timeoutMs: options.healthTimeoutMs ?? FEATURE_HEALTH_TIMEOUT_MS,
665
+ startupTimeoutMs: options.startupTimeoutMs ?? FEATURE_STARTUP_TIMEOUT_MS,
666
+ pollIntervalMs: options.pollIntervalMs ?? FEATURE_HEALTH_POLL_INTERVAL_MS,
667
+ sleep: sleepImpl,
668
+ signal: startupController.signal,
669
+ };
670
+ const existing = await checkFeatureHealth(feature, healthOptions);
671
+ if (stopping) return;
672
+ if (existing.ok) {
673
+ record.state = "ready";
674
+ record.health = existing.health;
675
+ record.failure = null;
676
+ notifyStatusChange();
677
+ options.logger.log?.(`${feature.label}: using existing service at ${feature.healthUrl}`);
678
+ return;
679
+ }
680
+ if (!feature.command?.length) {
681
+ await failOwnedStartup(feature, record, failure(
682
+ "FEATURE_COMMAND_MISSING",
683
+ `${feature.label} is offline and has no command in ${MANIFEST_NAME}`,
684
+ "startup",
685
+ ));
686
+ return;
687
+ }
688
+
689
+ record.state = "launching";
690
+ let child;
691
+ const childState = { failure: null };
692
+ try {
693
+ child = spawnProcess(feature.command[0], feature.command.slice(1), {
694
+ cwd: feature.projectDir,
695
+ env: {
696
+ ...process.env,
697
+ CODEX_RUNTIME_HOST: "1",
698
+ MINECODEX_FEATURE_INSTANCE_ID: instanceId,
699
+ },
700
+ stdio: "inherit",
701
+ });
702
+ record.child = child;
703
+ record.owned = true;
704
+ observeOwnedChild(feature, record, child, childState);
705
+ notifyStatusChange();
706
+ } catch (error) {
707
+ await failOwnedStartup(feature, record, failure("CHILD_SPAWN_FAILED", error.message, "startup"));
708
+ return;
709
+ }
710
+
711
+ const result = await waitForFeatureHealth(feature, child, {
712
+ ...healthOptions,
713
+ childState,
714
+ });
715
+ if (stopping) return;
716
+ if (result.ok && !childState.failure && !childHasExited(child)) {
717
+ record.state = "ready";
718
+ record.health = result.health;
719
+ record.failure = null;
720
+ notifyStatusChange();
721
+ options.logger.log?.(`${feature.label}: started at ${feature.surfaceUrl}`);
722
+ return;
723
+ }
724
+ await failOwnedStartup(
725
+ feature,
726
+ record,
727
+ childState.failure
728
+ ?? (childHasExited(child)
729
+ ? childExitFailure(feature, child.exitCode, child.signalCode, "startup")
730
+ : result.failure),
731
+ );
732
+ }
733
+
734
+ let startupPromise = Promise.resolve();
735
+ let stopPromise = null;
736
+ const supervisor = {
737
+ instanceId,
738
+ plugins: records,
739
+ getFailures() {
740
+ return records.map(featureFailure).filter(Boolean);
741
+ },
742
+ getStatuses({ rendererActive = false } = {}) {
743
+ return records.map((record) => {
744
+ let state = record.state;
745
+ if (rendererActive && (state === "configured" || state === "ready")) state = "injected";
746
+ return {
747
+ featureId: record.featureId,
748
+ state,
749
+ owned: record.owned,
750
+ health: record.health,
751
+ failure: record.failure,
752
+ };
753
+ });
754
+ },
755
+ waitForStartup() {
756
+ return startupPromise;
757
+ },
758
+ async stop() {
759
+ if (stopPromise) return stopPromise;
760
+ stopping = true;
761
+ startupController.abort();
762
+ stopPromise = (async () => {
763
+ await startupPromise;
764
+ const shutdownErrors = [];
765
+ for (const record of records) {
766
+ if (record.owned && record.child) {
767
+ try {
768
+ await terminateRecord(record, { preserveState: record.state === "failed" });
769
+ } catch (error) {
770
+ record.state = "failed";
771
+ record.health = null;
772
+ record.failure ??= {
773
+ code: error.code ?? "FEATURE_CHILD_TERMINATE_FAILED",
774
+ message: error.message,
775
+ phase: error.phase ?? "shutdown",
776
+ };
777
+ record.failure.cleanup = {
778
+ code: error.code ?? "FEATURE_CHILD_TERMINATE_FAILED",
779
+ message: error.message,
780
+ phase: error.phase ?? "shutdown",
781
+ };
782
+ notifyStatusChange();
783
+ shutdownErrors.push(error);
784
+ continue;
785
+ }
786
+ }
787
+ if (record.state !== "failed") {
788
+ const changed = record.state !== "stopped" || record.health !== null;
789
+ record.state = "stopped";
790
+ record.health = null;
791
+ record.failure = null;
792
+ if (changed) notifyStatusChange();
793
+ }
794
+ }
795
+ if (shutdownErrors.length) {
796
+ const error = new Error(
797
+ `Feature child shutdown failed: ${shutdownErrors.map((failure) => failure.code ?? "UNKNOWN").join(", ")}`,
798
+ );
799
+ error.code = "FEATURE_CHILD_SHUTDOWN_FAILED";
800
+ error.failures = shutdownErrors;
801
+ throw error;
802
+ }
803
+ })();
804
+ return stopPromise;
805
+ },
806
+ };
807
+ Object.defineProperty(supervisor, "failures", {
808
+ enumerable: true,
809
+ get() {
810
+ return supervisor.getFailures();
811
+ },
812
+ });
813
+ startupPromise = Promise.all(features.map(async (feature, index) => {
814
+ try {
815
+ await startOne(feature, index);
816
+ } catch (error) {
817
+ if (stopping) return;
818
+ await failOwnedStartup(
819
+ feature,
820
+ records[index],
821
+ failure("FEATURE_START_FAILED", error?.message ?? "Feature startup failed", "startup"),
822
+ );
823
+ }
824
+ })).then(() => supervisor);
825
+ if (!options.deferredStart) await startupPromise;
826
+ return supervisor;
827
+ }