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.
- package/LICENSE +211 -0
- package/README.md +112 -0
- package/SECURITY.md +27 -0
- package/SUPPORT.md +43 -0
- package/package.json +44 -0
- package/public/client-shared.js +1831 -0
- package/public/favicon.svg +11 -0
- package/public/host.html +29 -0
- package/public/host.js +2079 -0
- package/public/index.html +28 -0
- package/public/index.js +98 -0
- package/public/live-bridge-lifecycle.js +258 -0
- package/public/live-bridge-retry-state.js +61 -0
- package/public/live-selection-intent.js +79 -0
- package/public/remote-operator-state.js +316 -0
- package/public/remote.html +167 -0
- package/public/remote.js +3967 -0
- package/public/styles.css +2793 -0
- package/public/surface-view-state.js +89 -0
- package/public/voice-dictation.js +45 -0
- package/src/bin/desktop-rehydration-smoke.mjs +111 -0
- package/src/bin/dextunnel.mjs +41 -0
- package/src/bin/doctor.mjs +48 -0
- package/src/bin/launch-attest.mjs +39 -0
- package/src/bin/launch-status.mjs +49 -0
- package/src/bin/mobile-link-proxy.mjs +221 -0
- package/src/bin/mobile-proof.mjs +164 -0
- package/src/bin/mobile-transport-smoke.mjs +200 -0
- package/src/bin/probe-codex-app-server-write.mjs +36 -0
- package/src/bin/probe-codex-app-server.mjs +30 -0
- package/src/lib/agent-room-context.mjs +54 -0
- package/src/lib/agent-room-runtime.mjs +355 -0
- package/src/lib/agent-room-service.mjs +335 -0
- package/src/lib/agent-room-state.mjs +406 -0
- package/src/lib/agent-room-store.mjs +71 -0
- package/src/lib/agent-room-text.mjs +48 -0
- package/src/lib/app-server-contract.mjs +66 -0
- package/src/lib/app-server-runtime.mjs +60 -0
- package/src/lib/attachment-service.mjs +119 -0
- package/src/lib/bridge-api-handler.mjs +719 -0
- package/src/lib/bridge-runtime-lifecycle.mjs +51 -0
- package/src/lib/bridge-status-builder.mjs +60 -0
- package/src/lib/codex-app-server-client.mjs +1511 -0
- package/src/lib/companion-state.mjs +453 -0
- package/src/lib/control-lease-service.mjs +180 -0
- package/src/lib/debug-harness-service.mjs +173 -0
- package/src/lib/desktop-integration.mjs +146 -0
- package/src/lib/desktop-rehydration-smoke.mjs +269 -0
- package/src/lib/dextunnel-cli.mjs +122 -0
- package/src/lib/discovery-docs.mjs +1321 -0
- package/src/lib/fake-codex-app-server-bridge.mjs +340 -0
- package/src/lib/install-preflight.mjs +373 -0
- package/src/lib/interaction-resolution-service.mjs +185 -0
- package/src/lib/interaction-state.mjs +360 -0
- package/src/lib/launch-release-bar.mjs +158 -0
- package/src/lib/live-control-state.mjs +107 -0
- package/src/lib/live-payload-builder.mjs +298 -0
- package/src/lib/live-selection-transition-state.mjs +49 -0
- package/src/lib/live-transcript-state.mjs +549 -0
- package/src/lib/mobile-network-profile.mjs +39 -0
- package/src/lib/mock-codex-adapter.mjs +62 -0
- package/src/lib/operator-diagnostics.mjs +82 -0
- package/src/lib/repo-changes-service.mjs +527 -0
- package/src/lib/runtime-config.mjs +106 -0
- package/src/lib/selection-state-service.mjs +214 -0
- package/src/lib/session-store.mjs +355 -0
- package/src/lib/shared-room-state.mjs +473 -0
- package/src/lib/shared-selection-state.mjs +40 -0
- package/src/lib/sse-hub.mjs +35 -0
- package/src/lib/static-surface-service.mjs +71 -0
- package/src/lib/surface-access.mjs +189 -0
- package/src/lib/surface-presence-service.mjs +118 -0
- package/src/lib/surface-request-guard.mjs +52 -0
- package/src/lib/thread-sync-state.mjs +536 -0
- package/src/lib/watcher-lifecycle.mjs +287 -0
- 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
|
+
}
|