dextunnel 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 (76) hide show
  1. package/LICENSE +211 -0
  2. package/README.md +112 -0
  3. package/SECURITY.md +27 -0
  4. package/SUPPORT.md +43 -0
  5. package/package.json +44 -0
  6. package/public/client-shared.js +1831 -0
  7. package/public/favicon.svg +11 -0
  8. package/public/host.html +29 -0
  9. package/public/host.js +2079 -0
  10. package/public/index.html +28 -0
  11. package/public/index.js +98 -0
  12. package/public/live-bridge-lifecycle.js +258 -0
  13. package/public/live-bridge-retry-state.js +61 -0
  14. package/public/live-selection-intent.js +79 -0
  15. package/public/remote-operator-state.js +316 -0
  16. package/public/remote.html +167 -0
  17. package/public/remote.js +3967 -0
  18. package/public/styles.css +2793 -0
  19. package/public/surface-view-state.js +89 -0
  20. package/public/voice-dictation.js +45 -0
  21. package/src/bin/desktop-rehydration-smoke.mjs +111 -0
  22. package/src/bin/dextunnel.mjs +41 -0
  23. package/src/bin/doctor.mjs +48 -0
  24. package/src/bin/launch-attest.mjs +39 -0
  25. package/src/bin/launch-status.mjs +49 -0
  26. package/src/bin/mobile-link-proxy.mjs +221 -0
  27. package/src/bin/mobile-proof.mjs +164 -0
  28. package/src/bin/mobile-transport-smoke.mjs +200 -0
  29. package/src/bin/probe-codex-app-server-write.mjs +36 -0
  30. package/src/bin/probe-codex-app-server.mjs +30 -0
  31. package/src/lib/agent-room-context.mjs +54 -0
  32. package/src/lib/agent-room-runtime.mjs +355 -0
  33. package/src/lib/agent-room-service.mjs +335 -0
  34. package/src/lib/agent-room-state.mjs +406 -0
  35. package/src/lib/agent-room-store.mjs +71 -0
  36. package/src/lib/agent-room-text.mjs +48 -0
  37. package/src/lib/app-server-contract.mjs +66 -0
  38. package/src/lib/app-server-runtime.mjs +60 -0
  39. package/src/lib/attachment-service.mjs +119 -0
  40. package/src/lib/bridge-api-handler.mjs +719 -0
  41. package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
  42. package/src/lib/bridge-status-builder.mjs +60 -0
  43. package/src/lib/codex-app-server-client.mjs +1511 -0
  44. package/src/lib/companion-state.mjs +453 -0
  45. package/src/lib/control-lease-service.mjs +180 -0
  46. package/src/lib/debug-harness-service.mjs +173 -0
  47. package/src/lib/desktop-integration.mjs +146 -0
  48. package/src/lib/desktop-rehydration-smoke.mjs +269 -0
  49. package/src/lib/dextunnel-cli.mjs +122 -0
  50. package/src/lib/discovery-docs.mjs +1321 -0
  51. package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
  52. package/src/lib/install-preflight.mjs +373 -0
  53. package/src/lib/interaction-resolution-service.mjs +185 -0
  54. package/src/lib/interaction-state.mjs +360 -0
  55. package/src/lib/launch-release-bar.mjs +158 -0
  56. package/src/lib/live-control-state.mjs +107 -0
  57. package/src/lib/live-payload-builder.mjs +298 -0
  58. package/src/lib/live-selection-transition-state.mjs +49 -0
  59. package/src/lib/live-transcript-state.mjs +549 -0
  60. package/src/lib/mobile-network-profile.mjs +39 -0
  61. package/src/lib/mock-codex-adapter.mjs +62 -0
  62. package/src/lib/operator-diagnostics.mjs +82 -0
  63. package/src/lib/repo-changes-service.mjs +527 -0
  64. package/src/lib/runtime-config.mjs +106 -0
  65. package/src/lib/selection-state-service.mjs +214 -0
  66. package/src/lib/session-store.mjs +355 -0
  67. package/src/lib/shared-room-state.mjs +473 -0
  68. package/src/lib/shared-selection-state.mjs +40 -0
  69. package/src/lib/sse-hub.mjs +35 -0
  70. package/src/lib/static-surface-service.mjs +71 -0
  71. package/src/lib/surface-access.mjs +189 -0
  72. package/src/lib/surface-presence-service.mjs +118 -0
  73. package/src/lib/surface-request-guard.mjs +52 -0
  74. package/src/lib/thread-sync-state.mjs +536 -0
  75. package/src/lib/watcher-lifecycle.mjs +287 -0
  76. package/src/server.mjs +1446 -0
@@ -0,0 +1,340 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+
4
+ import { mapThreadToCompanionSnapshot } from "./codex-app-server-client.mjs";
5
+
6
+ function nowIso() {
7
+ return new Date().toISOString();
8
+ }
9
+
10
+ function defaultThreads(cwd) {
11
+ const timestamp = nowIso();
12
+ return [
13
+ {
14
+ cwd,
15
+ id: "thr_dextunnel",
16
+ name: "dextunnel",
17
+ path: `${cwd}/.codex/fake-dextunnel.jsonl`,
18
+ preview: "Semantic companion thread",
19
+ source: "vscode",
20
+ status: "idle",
21
+ tokenUsage: null,
22
+ turns: [
23
+ {
24
+ id: "turn_dextunnel_1",
25
+ items: [
26
+ {
27
+ content: [{ text: "keep going on dextunnel", type: "text" }],
28
+ id: "item_user_1",
29
+ type: "userMessage"
30
+ },
31
+ {
32
+ id: "item_agent_1",
33
+ phase: "message",
34
+ text: "Dextunnel fake bridge ready.",
35
+ type: "agentMessage"
36
+ }
37
+ ],
38
+ startedAt: timestamp,
39
+ status: "completed",
40
+ updatedAt: timestamp
41
+ }
42
+ ],
43
+ updatedAt: timestamp
44
+ },
45
+ {
46
+ cwd,
47
+ id: "thr_marketing",
48
+ name: "marketing",
49
+ path: `${cwd}/.codex/fake-marketing.jsonl`,
50
+ preview: "Marketing side thread",
51
+ source: "vscode",
52
+ status: "idle",
53
+ tokenUsage: null,
54
+ turns: [
55
+ {
56
+ id: "turn_marketing_1",
57
+ items: [
58
+ {
59
+ content: [{ text: "review marketing notes", type: "text" }],
60
+ id: "item_marketing_user_1",
61
+ type: "userMessage"
62
+ },
63
+ {
64
+ id: "item_marketing_agent_1",
65
+ phase: "message",
66
+ text: "Marketing thread ready.",
67
+ type: "agentMessage"
68
+ }
69
+ ],
70
+ startedAt: timestamp,
71
+ status: "completed",
72
+ updatedAt: timestamp
73
+ }
74
+ ],
75
+ updatedAt: timestamp
76
+ }
77
+ ];
78
+ }
79
+
80
+ export function createFakeCodexAppServerBridge({
81
+ cwd = process.cwd(),
82
+ binaryPath = "fake-codex",
83
+ listenUrl = "ws://fake-codex-app-server",
84
+ sendDelayMs = 0
85
+ } = {}) {
86
+ const threadsById = new Map(defaultThreads(cwd).map((thread) => [thread.id, structuredClone(thread)]));
87
+ const watchersByThreadId = new Map();
88
+
89
+ function listAllThreads() {
90
+ return [...threadsById.values()].sort((left, right) => {
91
+ const leftTime = new Date(left.updatedAt || 0).getTime();
92
+ const rightTime = new Date(right.updatedAt || 0).getTime();
93
+ return rightTime - leftTime;
94
+ });
95
+ }
96
+
97
+ function summarizeThread(thread) {
98
+ return {
99
+ cwd: thread.cwd || null,
100
+ id: thread.id,
101
+ name: thread.name || null,
102
+ path: thread.path || null,
103
+ preview: thread.preview || null,
104
+ source: thread.source || null,
105
+ status: thread.status || null,
106
+ updatedAt: thread.updatedAt || null
107
+ };
108
+ }
109
+
110
+ function getThread(threadId) {
111
+ const thread = threadsById.get(threadId);
112
+ return thread ? structuredClone(thread) : null;
113
+ }
114
+
115
+ function updateThread(threadId, updater) {
116
+ const current = threadsById.get(threadId);
117
+ if (!current) {
118
+ return null;
119
+ }
120
+
121
+ const next = updater(structuredClone(current));
122
+ threadsById.set(threadId, next);
123
+ return structuredClone(next);
124
+ }
125
+
126
+ async function listThreads({
127
+ cwd: requestedCwd = null,
128
+ limit = 10,
129
+ sourceKinds = null
130
+ } = {}) {
131
+ let threads = listAllThreads();
132
+ if (requestedCwd) {
133
+ threads = threads.filter((thread) => thread.cwd === requestedCwd);
134
+ }
135
+ if (Array.isArray(sourceKinds) && sourceKinds.length) {
136
+ threads = threads.filter((thread) => sourceKinds.includes(thread.source));
137
+ }
138
+ return threads.slice(0, limit).map(summarizeThread);
139
+ }
140
+
141
+ async function readThread(threadId) {
142
+ return getThread(threadId);
143
+ }
144
+
145
+ async function getLatestThreadForCwd(requestedCwd) {
146
+ const threads = await listThreads({ cwd: requestedCwd, limit: 1 });
147
+ if (threads.length === 0) {
148
+ return null;
149
+ }
150
+ return readThread(threads[0].id);
151
+ }
152
+
153
+ async function startThread({
154
+ cwd: threadCwd = cwd
155
+ } = {}) {
156
+ const id = `thr_${randomUUID().slice(0, 8)}`;
157
+ const timestamp = nowIso();
158
+ const thread = {
159
+ cwd: threadCwd,
160
+ id,
161
+ name: "new session",
162
+ path: `${threadCwd}/.codex/${id}.jsonl`,
163
+ preview: null,
164
+ source: "vscode",
165
+ status: "idle",
166
+ tokenUsage: null,
167
+ turns: [],
168
+ updatedAt: timestamp
169
+ };
170
+ threadsById.set(id, thread);
171
+ return summarizeThread(thread);
172
+ }
173
+
174
+ async function sendText({
175
+ threadId = null,
176
+ cwd: threadCwd = cwd,
177
+ text = "",
178
+ attachments = [],
179
+ createThreadIfMissing = true
180
+ } = {}) {
181
+ let nextThreadId = threadId;
182
+
183
+ if (!nextThreadId) {
184
+ if (!createThreadIfMissing) {
185
+ throw new Error("No fake thread selected.");
186
+ }
187
+ const created = await startThread({ cwd: threadCwd });
188
+ nextThreadId = created.id;
189
+ }
190
+
191
+ if (Number.isFinite(sendDelayMs) && sendDelayMs > 0) {
192
+ await delay(sendDelayMs);
193
+ }
194
+
195
+ const timestamp = nowIso();
196
+ const assistantText = text
197
+ ? `FAKE_BRIDGE_ACK: ${String(text).trim()}`
198
+ : `FAKE_BRIDGE_ACK: ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}`;
199
+ const turnId = `turn_${randomUUID().slice(0, 8)}`;
200
+ const nextThread = updateThread(nextThreadId, (thread) => {
201
+ const turn = {
202
+ id: turnId,
203
+ items: [
204
+ {
205
+ content: text ? [{ text: String(text).trim(), type: "text" }] : [],
206
+ id: `item_user_${randomUUID().slice(0, 8)}`,
207
+ type: "userMessage"
208
+ },
209
+ {
210
+ id: `item_agent_${randomUUID().slice(0, 8)}`,
211
+ phase: "message",
212
+ text: assistantText,
213
+ type: "agentMessage"
214
+ }
215
+ ],
216
+ startedAt: timestamp,
217
+ status: "completed",
218
+ updatedAt: timestamp
219
+ };
220
+ return {
221
+ ...thread,
222
+ cwd: thread.cwd || threadCwd,
223
+ preview: assistantText,
224
+ status: "idle",
225
+ turns: [...(thread.turns || []), turn],
226
+ updatedAt: timestamp
227
+ };
228
+ });
229
+
230
+ const watcher = watchersByThreadId.get(nextThreadId);
231
+ watcher?.onNotification?.({
232
+ method: "turn/started",
233
+ params: {
234
+ threadId: nextThreadId,
235
+ turn: {
236
+ id: turnId
237
+ }
238
+ }
239
+ });
240
+ watcher?.onNotification?.({
241
+ method: "turn/completed",
242
+ params: {
243
+ threadId: nextThreadId,
244
+ turn: {
245
+ id: turnId
246
+ }
247
+ }
248
+ });
249
+
250
+ return {
251
+ mode: "start",
252
+ snapshot: mapThreadToCompanionSnapshot(nextThread, { limit: 60 }),
253
+ thread: summarizeThread(nextThread),
254
+ turn: {
255
+ id: turnId,
256
+ status: "completed"
257
+ }
258
+ };
259
+ }
260
+
261
+ async function interruptTurn() {
262
+ return { ok: true };
263
+ }
264
+
265
+ async function watchThread({
266
+ threadId,
267
+ onClose,
268
+ onReady
269
+ } = {}) {
270
+ let closed = false;
271
+ const watcher = {
272
+ close() {
273
+ if (closed) {
274
+ return;
275
+ }
276
+ closed = true;
277
+ watchersByThreadId.delete(threadId);
278
+ onClose?.();
279
+ },
280
+ onClose,
281
+ onNotification() {},
282
+ onReady,
283
+ onServerRequest() {},
284
+ respond() {},
285
+ respondError() {}
286
+ };
287
+
288
+ watchersByThreadId.set(threadId, watcher);
289
+ queueMicrotask(() => {
290
+ if (!closed) {
291
+ onReady?.();
292
+ }
293
+ });
294
+
295
+ return watcher;
296
+ }
297
+
298
+ return {
299
+ async dispose() {},
300
+ async ensureStarted() {},
301
+ getLatestThreadForCwd,
302
+ getStatus() {
303
+ return {
304
+ binaryPath,
305
+ lastError: null,
306
+ listenUrl,
307
+ pid: 0,
308
+ readyUrl: "http://fake-codex-app-server/readyz",
309
+ started: true,
310
+ startupLogs: ["fake bridge ready"]
311
+ };
312
+ },
313
+ async interruptTurn(args) {
314
+ return interruptTurn(args);
315
+ },
316
+ listThreads,
317
+ readThread,
318
+ async resumeThread(threadId) {
319
+ return summarizeThread(await readThread(threadId));
320
+ },
321
+ async rpc() {
322
+ throw new Error("Fake bridge does not expose raw RPC.");
323
+ },
324
+ async runTurnSession() {
325
+ throw new Error("Fake bridge does not expose raw turn sessions.");
326
+ },
327
+ sendText,
328
+ startThread,
329
+ async startTurn() {
330
+ throw new Error("Fake bridge does not expose startTurn directly.");
331
+ },
332
+ async steerTurn() {
333
+ throw new Error("Fake bridge does not expose steerTurn directly.");
334
+ },
335
+ watchThread,
336
+ async waitForTurnCompletion() {
337
+ return { status: "completed" };
338
+ }
339
+ };
340
+ }
@@ -0,0 +1,373 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
3
+
4
+ import { buildDiscoveryLinks } from "./discovery-docs.mjs";
5
+
6
+ function pluralize(count, singular, plural = `${singular}s`) {
7
+ return `${count} ${count === 1 ? singular : plural}`;
8
+ }
9
+
10
+ function workspaceLabel(cwd) {
11
+ const parts = String(cwd || "")
12
+ .split("/")
13
+ .filter(Boolean);
14
+ return parts.length ? parts.slice(-2).join("/") : cwd || "this workspace";
15
+ }
16
+
17
+ function localBaseUrl(host, port) {
18
+ const normalizedHost = String(host || "127.0.0.1").trim() || "127.0.0.1";
19
+ const displayHost = normalizedHost === "0.0.0.0" ? "127.0.0.1" : normalizedHost;
20
+ return `http://${displayHost}:${port}`;
21
+ }
22
+
23
+ function normalizeStartupError(message, binaryPath) {
24
+ const text = String(message || "").trim();
25
+ if (!text) {
26
+ return "";
27
+ }
28
+
29
+ if (/ENOENT/i.test(text)) {
30
+ return `Could not launch Codex from ${binaryPath}. Install Codex or set DEXTUNNEL_CODEX_BINARY.`;
31
+ }
32
+
33
+ if (/Timed out waiting for codex app-server readiness\./i.test(text)) {
34
+ return "Codex was found, but app-server did not become ready in time.";
35
+ }
36
+
37
+ return text;
38
+ }
39
+
40
+ export function resolveCodexBinary(
41
+ binaryPath,
42
+ {
43
+ accessSyncImpl = accessSync,
44
+ spawnSyncImpl = spawnSync
45
+ } = {}
46
+ ) {
47
+ const configuredPath = String(binaryPath || "").trim();
48
+ if (!configuredPath) {
49
+ return {
50
+ configuredPath: "",
51
+ error: "No Codex binary is configured.",
52
+ found: false,
53
+ resolvedPath: null,
54
+ source: "missing"
55
+ };
56
+ }
57
+
58
+ const explicitPath = configuredPath.includes("/") || configuredPath.startsWith(".");
59
+ if (explicitPath) {
60
+ try {
61
+ accessSyncImpl(configuredPath, constants.X_OK);
62
+ return {
63
+ configuredPath,
64
+ error: null,
65
+ found: true,
66
+ resolvedPath: configuredPath,
67
+ source: "explicit"
68
+ };
69
+ } catch {
70
+ return {
71
+ configuredPath,
72
+ error: `Configured Codex binary is not executable: ${configuredPath}`,
73
+ found: false,
74
+ resolvedPath: null,
75
+ source: "explicit"
76
+ };
77
+ }
78
+ }
79
+
80
+ const locator = process.platform === "win32" ? "where" : "which";
81
+ try {
82
+ const result = spawnSyncImpl(locator, [configuredPath], {
83
+ encoding: "utf8"
84
+ });
85
+ const resolvedPath = String(result?.stdout || "")
86
+ .split(/\r?\n/)
87
+ .map((line) => line.trim())
88
+ .find(Boolean) || null;
89
+ if (result?.status === 0 && resolvedPath) {
90
+ return {
91
+ configuredPath,
92
+ error: null,
93
+ found: true,
94
+ resolvedPath,
95
+ source: "path"
96
+ };
97
+ }
98
+ } catch {
99
+ // Fall through to the consistent not-found payload.
100
+ }
101
+
102
+ return {
103
+ configuredPath,
104
+ error: `Could not find '${configuredPath}' on PATH.`,
105
+ found: false,
106
+ resolvedPath: null,
107
+ source: "path"
108
+ };
109
+ }
110
+
111
+ export async function checkReadyUrl(
112
+ readyUrl,
113
+ {
114
+ fetchImpl = fetch,
115
+ timeoutMs = 1500
116
+ } = {}
117
+ ) {
118
+ const target = String(readyUrl || "").trim();
119
+ if (!target) {
120
+ return {
121
+ error: "No app-server readiness URL is configured.",
122
+ ok: false,
123
+ statusCode: null
124
+ };
125
+ }
126
+
127
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
128
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
129
+ try {
130
+ const response = await fetchImpl(target, {
131
+ method: "GET",
132
+ signal: controller?.signal
133
+ });
134
+ return {
135
+ error: response.ok ? null : `Codex app-server readiness returned HTTP ${response.status}.`,
136
+ ok: response.ok,
137
+ statusCode: response.status
138
+ };
139
+ } catch (error) {
140
+ return {
141
+ error:
142
+ error?.name === "AbortError"
143
+ ? "Timed out waiting for Codex app-server readiness."
144
+ : String(error?.message || error || "Unknown readiness failure."),
145
+ ok: false,
146
+ statusCode: null
147
+ };
148
+ } finally {
149
+ if (timer) {
150
+ clearTimeout(timer);
151
+ }
152
+ }
153
+ }
154
+
155
+ function buildNextSteps({
156
+ appServerHealthy,
157
+ baseUrl,
158
+ binary,
159
+ host,
160
+ workspace
161
+ }) {
162
+ if (!binary.found) {
163
+ return [
164
+ "Install Codex locally or set DEXTUNNEL_CODEX_BINARY to the Codex CLI path.",
165
+ "Run npm run doctor again once Codex is available.",
166
+ `Then open ${baseUrl}/.`
167
+ ];
168
+ }
169
+
170
+ if (!appServerHealthy) {
171
+ return [
172
+ "Make sure the configured Codex binary can launch app-server on this machine.",
173
+ "If Codex is installed elsewhere, set DEXTUNNEL_CODEX_BINARY to the full executable path.",
174
+ "Run npm run doctor again after fixing the Codex binary path."
175
+ ];
176
+ }
177
+
178
+ if (workspace.hasThreadForCwd === false) {
179
+ return [
180
+ `Open Codex in ${workspace.label} once so Dextunnel has a thread to follow.`,
181
+ "Refresh this page or rerun npm run doctor after the thread appears.",
182
+ `Then open ${baseUrl}/.`
183
+ ];
184
+ }
185
+
186
+ return [
187
+ `Open ${baseUrl}/.`,
188
+ host === "127.0.0.1"
189
+ ? "Use npm run start:network when you want phone or tablet access over LAN or Tailscale."
190
+ : "This server is already bound beyond loopback for another device on your local network.",
191
+ "Run npm run doctor any time you want to re-check the local setup."
192
+ ];
193
+ }
194
+
195
+ function buildSummary({
196
+ appServerHealthy,
197
+ binary,
198
+ workspace
199
+ }) {
200
+ if (!binary.found) {
201
+ return "Dextunnel cannot find a usable Codex binary yet.";
202
+ }
203
+
204
+ if (!appServerHealthy) {
205
+ return "Dextunnel found Codex, but app-server is not ready yet.";
206
+ }
207
+
208
+ if (workspace.hasThreadForCwd === false) {
209
+ return "Codex is reachable, but this workspace does not have a visible thread yet.";
210
+ }
211
+
212
+ if (workspace.cwdThreadCount > 0) {
213
+ return `Dextunnel is ready. Found ${pluralize(workspace.cwdThreadCount, "thread")} for ${workspace.label}.`;
214
+ }
215
+
216
+ return "Dextunnel is ready. Open the remote and pick a live Codex thread.";
217
+ }
218
+
219
+ function buildChecks({
220
+ appServer,
221
+ appServerHealthy,
222
+ binary,
223
+ host,
224
+ runtimeProfile,
225
+ workspace
226
+ }) {
227
+ return [
228
+ {
229
+ detail: binary.found
230
+ ? `Using ${binary.resolvedPath || binary.configuredPath}.`
231
+ : binary.error,
232
+ id: "binary",
233
+ label: "Codex binary",
234
+ severity: binary.found ? "ready" : "error"
235
+ },
236
+ {
237
+ detail: appServerHealthy
238
+ ? `Ready at ${appServer.readyUrl}.`
239
+ : appServer.error || "Codex app-server is not reachable yet.",
240
+ id: "app-server",
241
+ label: "Codex app-server",
242
+ severity: appServerHealthy ? "ready" : "error"
243
+ },
244
+ {
245
+ detail:
246
+ workspace.hasThreadForCwd === null
247
+ ? `Checking ${workspace.label}...`
248
+ : workspace.hasThreadForCwd
249
+ ? `${pluralize(workspace.cwdThreadCount, "thread")} visible for ${workspace.label}.`
250
+ : `No visible Codex thread for ${workspace.label} yet.`,
251
+ id: "workspace",
252
+ label: "Current workspace",
253
+ severity:
254
+ workspace.hasThreadForCwd === null
255
+ ? "warning"
256
+ : workspace.hasThreadForCwd
257
+ ? "ready"
258
+ : "warning"
259
+ },
260
+ {
261
+ detail:
262
+ host === "127.0.0.1"
263
+ ? "Loopback-only by default. Use npm run start:network for phone or tablet access."
264
+ : `Bound on ${host} for another device on your local network.`,
265
+ id: "access",
266
+ label: "Access mode",
267
+ severity: "ready"
268
+ },
269
+ {
270
+ detail: `Using the ${runtimeProfile} runtime profile.`,
271
+ id: "profile",
272
+ label: "Runtime profile",
273
+ severity: "ready"
274
+ }
275
+ ];
276
+ }
277
+
278
+ export async function buildInstallPreflight({
279
+ codexAppServer,
280
+ cwd = process.cwd(),
281
+ runtimeConfig = {},
282
+ warmup = true,
283
+ checkReady = checkReadyUrl,
284
+ resolveBinary = resolveCodexBinary
285
+ } = {}) {
286
+ if (!codexAppServer?.getStatus || !codexAppServer?.listThreads) {
287
+ throw new Error("buildInstallPreflight requires a Codex app-server bridge.");
288
+ }
289
+
290
+ const bridgeStatusBefore = codexAppServer.getStatus();
291
+ const binary = resolveBinary(runtimeConfig.codexBinaryPath || bridgeStatusBefore.binaryPath || "");
292
+
293
+ let threads = null;
294
+ let warmupError = "";
295
+ if (warmup) {
296
+ try {
297
+ threads = await codexAppServer.listThreads({
298
+ archived: false,
299
+ limit: 50
300
+ });
301
+ } catch (error) {
302
+ warmupError = normalizeStartupError(error?.message, binary.configuredPath || bridgeStatusBefore.binaryPath || "codex");
303
+ }
304
+ }
305
+
306
+ const bridgeStatus = codexAppServer.getStatus();
307
+ const ready = await checkReady(bridgeStatus.readyUrl);
308
+ const appServerHealthy = Boolean(ready.ok || threads);
309
+ const workspaceThreads = Array.isArray(threads)
310
+ ? threads.filter((thread) => thread?.cwd === cwd)
311
+ : null;
312
+ const workspace = {
313
+ cwd,
314
+ cwdThreadCount: workspaceThreads?.length ?? null,
315
+ hasThreadForCwd: workspaceThreads == null ? null : workspaceThreads.length > 0,
316
+ label: workspaceLabel(cwd),
317
+ threadCount: Array.isArray(threads) ? threads.length : null
318
+ };
319
+ const appServer = {
320
+ error:
321
+ normalizeStartupError(bridgeStatus.lastError, binary.configuredPath || bridgeStatus.binaryPath || "codex") ||
322
+ warmupError ||
323
+ ready.error ||
324
+ null,
325
+ healthy: appServerHealthy,
326
+ lastError: bridgeStatus.lastError || null,
327
+ listenUrl: bridgeStatus.listenUrl || runtimeConfig.appServerListenUrl || "",
328
+ pid: bridgeStatus.pid || null,
329
+ ready: Boolean(ready.ok),
330
+ readyUrl: bridgeStatus.readyUrl || "",
331
+ started: Boolean(bridgeStatus.started),
332
+ startupLogTail: Array.isArray(bridgeStatus.startupLogs) ? bridgeStatus.startupLogs.slice(-4) : [],
333
+ warmupAttempted: Boolean(warmup),
334
+ warmupOk: Array.isArray(threads)
335
+ };
336
+ const baseUrl = localBaseUrl(runtimeConfig.host || "127.0.0.1", runtimeConfig.port || 4317);
337
+ const status = !binary.found || !appServerHealthy ? "error" : workspace.hasThreadForCwd ? "ready" : "warning";
338
+
339
+ return {
340
+ appServer,
341
+ checks: buildChecks({
342
+ appServer,
343
+ appServerHealthy,
344
+ binary,
345
+ host: runtimeConfig.host || "127.0.0.1",
346
+ runtimeProfile: runtimeConfig.runtimeProfile || "default",
347
+ workspace
348
+ }),
349
+ codexBinary: binary,
350
+ links: buildDiscoveryLinks({ baseUrl }),
351
+ nextSteps: buildNextSteps({
352
+ appServerHealthy,
353
+ baseUrl,
354
+ binary,
355
+ host: runtimeConfig.host || "127.0.0.1",
356
+ workspace
357
+ }),
358
+ runtime: {
359
+ appServerListenUrl: runtimeConfig.appServerListenUrl || bridgeStatus.listenUrl || "",
360
+ baseUrl,
361
+ host: runtimeConfig.host || "127.0.0.1",
362
+ port: runtimeConfig.port || 4317,
363
+ runtimeProfile: runtimeConfig.runtimeProfile || "default"
364
+ },
365
+ status,
366
+ summary: buildSummary({
367
+ appServerHealthy,
368
+ binary,
369
+ workspace
370
+ }),
371
+ workspace
372
+ };
373
+ }