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,1511 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_BINARY = "/Applications/Codex.app/Contents/Resources/codex";
|
|
6
|
+
const DEFAULT_LISTEN_URL = "ws://127.0.0.1:4321";
|
|
7
|
+
const SESSION_LOG_TAIL_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function delay(ms) {
|
|
10
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sendInitializedNotification(socket) {
|
|
14
|
+
socket.send(
|
|
15
|
+
JSON.stringify({
|
|
16
|
+
jsonrpc: "2.0",
|
|
17
|
+
method: "initialized",
|
|
18
|
+
params: {}
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toTurnInput({
|
|
24
|
+
text = "",
|
|
25
|
+
attachments = []
|
|
26
|
+
} = {}) {
|
|
27
|
+
const items = [];
|
|
28
|
+
const trimmed = String(text || "").trim();
|
|
29
|
+
|
|
30
|
+
if (trimmed) {
|
|
31
|
+
items.push({
|
|
32
|
+
type: "text",
|
|
33
|
+
text: trimmed,
|
|
34
|
+
text_elements: []
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const attachment of attachments || []) {
|
|
39
|
+
if (attachment?.type === "localImage" && attachment.path) {
|
|
40
|
+
items.push({
|
|
41
|
+
type: "localImage",
|
|
42
|
+
path: attachment.path
|
|
43
|
+
});
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (attachment?.type === "image" && attachment.url) {
|
|
48
|
+
items.push({
|
|
49
|
+
type: "image",
|
|
50
|
+
url: attachment.url
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return items;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function joinContent(content = []) {
|
|
59
|
+
return content
|
|
60
|
+
.map((part) => {
|
|
61
|
+
if (part.type === "text") {
|
|
62
|
+
return part.text || "";
|
|
63
|
+
}
|
|
64
|
+
if (part.type === "image") {
|
|
65
|
+
return "[image attachment]";
|
|
66
|
+
}
|
|
67
|
+
if (part.type === "localImage") {
|
|
68
|
+
return "[local image attachment]";
|
|
69
|
+
}
|
|
70
|
+
if (part.type === "skill" || part.type === "mention") {
|
|
71
|
+
return `[${part.type}] ${part.name || ""} ${part.path || ""}`.trim();
|
|
72
|
+
}
|
|
73
|
+
return `[${part.type || "content"}]`;
|
|
74
|
+
})
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function joinSessionLogContent(content = []) {
|
|
80
|
+
return content
|
|
81
|
+
.map((part) => {
|
|
82
|
+
if (part.type === "text" || part.type === "input_text" || part.type === "output_text") {
|
|
83
|
+
return part.text || "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
part.type === "image" ||
|
|
88
|
+
part.type === "input_image" ||
|
|
89
|
+
part.type === "output_image" ||
|
|
90
|
+
part.type === "localImage"
|
|
91
|
+
) {
|
|
92
|
+
return "[image attachment]";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (part.type === "local_image") {
|
|
96
|
+
return "[local image attachment]";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return "";
|
|
100
|
+
})
|
|
101
|
+
.filter(Boolean)
|
|
102
|
+
.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeTranscriptKey(role, kind, text) {
|
|
106
|
+
return [role || "", kind || "", String(text || "").replace(/\s+/g, " ").trim().toLowerCase()].join("|");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function humanizeIdentifier(value) {
|
|
110
|
+
return String(value || "")
|
|
111
|
+
.replaceAll("_", " ")
|
|
112
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
113
|
+
.replace(/\s+/g, " ")
|
|
114
|
+
.trim()
|
|
115
|
+
.toLowerCase();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function trimToolOutput(text, maxLength = 240) {
|
|
119
|
+
const normalized = String(text || "").trim();
|
|
120
|
+
if (!normalized) {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const firstMeaningfulLine = normalized
|
|
125
|
+
.split("\n")
|
|
126
|
+
.map((line) => line.trim())
|
|
127
|
+
.find(Boolean) || normalized;
|
|
128
|
+
|
|
129
|
+
if (firstMeaningfulLine.length <= maxLength) {
|
|
130
|
+
return firstMeaningfulLine;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return `${firstMeaningfulLine.slice(0, maxLength - 3)}...`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readUtf8Tail(filePath, maxBytes = SESSION_LOG_TAIL_BYTES) {
|
|
137
|
+
try {
|
|
138
|
+
const fd = openSync(filePath, "r");
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const { size } = fstatSync(fd);
|
|
142
|
+
const length = Math.min(size, maxBytes);
|
|
143
|
+
if (length === 0) {
|
|
144
|
+
return "";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const buffer = Buffer.alloc(length);
|
|
148
|
+
readSync(fd, buffer, 0, length, size - length);
|
|
149
|
+
|
|
150
|
+
let text = buffer.toString("utf8");
|
|
151
|
+
if (size > length) {
|
|
152
|
+
const firstNewline = text.indexOf("\n");
|
|
153
|
+
text = firstNewline === -1 ? "" : text.slice(firstNewline + 1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return text;
|
|
157
|
+
} finally {
|
|
158
|
+
closeSync(fd);
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readUtf8File(filePath) {
|
|
166
|
+
try {
|
|
167
|
+
return readFileSync(filePath, "utf8");
|
|
168
|
+
} catch {
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function mapSessionLogEntry(entry) {
|
|
174
|
+
if (entry.type === "response_item" && entry.payload?.type === "message") {
|
|
175
|
+
return {
|
|
176
|
+
role: entry.payload.role || "assistant",
|
|
177
|
+
kind: entry.payload.phase || "message",
|
|
178
|
+
text: joinSessionLogContent(entry.payload.content)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
entry.type === "response_item" &&
|
|
184
|
+
(entry.payload?.type === "function_call_output" || entry.payload?.type === "custom_tool_call_output")
|
|
185
|
+
) {
|
|
186
|
+
return {
|
|
187
|
+
role: "tool",
|
|
188
|
+
kind: "tool_output",
|
|
189
|
+
text: trimToolOutput(entry.payload.output)
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (entry.type === "compacted") {
|
|
194
|
+
return {
|
|
195
|
+
role: "system",
|
|
196
|
+
kind: "context_compaction",
|
|
197
|
+
text: "Context compacted."
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function parseTranscriptFromSessionLogText(text = "") {
|
|
205
|
+
if (!text) {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const transcript = [];
|
|
210
|
+
|
|
211
|
+
for (const line of text.split("\n")) {
|
|
212
|
+
if (!line.trim()) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let entry;
|
|
217
|
+
try {
|
|
218
|
+
entry = JSON.parse(line);
|
|
219
|
+
} catch {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const mapped = mapSessionLogEntry(entry);
|
|
224
|
+
if (!mapped?.text || !String(mapped.text).trim()) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
transcript.push({
|
|
229
|
+
role: mapped.role || "assistant",
|
|
230
|
+
kind: mapped.kind || null,
|
|
231
|
+
text: mapped.text,
|
|
232
|
+
phase: mapped.kind || null,
|
|
233
|
+
turnId: null,
|
|
234
|
+
timestamp: entry.timestamp || null
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return transcript;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function readTranscriptFromSessionLog(threadPath, {
|
|
242
|
+
limit = null,
|
|
243
|
+
maxBytes = SESSION_LOG_TAIL_BYTES
|
|
244
|
+
} = {}) {
|
|
245
|
+
if (!threadPath) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const tail = readUtf8Tail(threadPath, maxBytes);
|
|
250
|
+
if (!tail) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const transcript = parseTranscriptFromSessionLogText(tail);
|
|
255
|
+
|
|
256
|
+
if (!limit || limit <= 0 || transcript.length <= limit) {
|
|
257
|
+
return transcript;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return transcript.slice(-limit);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function pageTranscriptEntries(transcript = [], {
|
|
264
|
+
beforeIndex = null,
|
|
265
|
+
limit = 40,
|
|
266
|
+
visibleCount = null
|
|
267
|
+
} = {}) {
|
|
268
|
+
const normalizedTranscript = Array.isArray(transcript) ? transcript : [];
|
|
269
|
+
const totalCount = normalizedTranscript.length;
|
|
270
|
+
if (totalCount === 0) {
|
|
271
|
+
return {
|
|
272
|
+
hasMore: false,
|
|
273
|
+
items: [],
|
|
274
|
+
nextBeforeIndex: null,
|
|
275
|
+
totalCount: 0
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parsedBeforeIndex = Number.parseInt(beforeIndex, 10);
|
|
280
|
+
const parsedVisibleCount = Number.parseInt(visibleCount, 10);
|
|
281
|
+
let endExclusive = totalCount;
|
|
282
|
+
|
|
283
|
+
if (Number.isFinite(parsedBeforeIndex) && parsedBeforeIndex >= 0) {
|
|
284
|
+
endExclusive = Math.max(0, Math.min(totalCount, parsedBeforeIndex));
|
|
285
|
+
} else if (Number.isFinite(parsedVisibleCount) && parsedVisibleCount >= 0) {
|
|
286
|
+
endExclusive = Math.max(0, totalCount - parsedVisibleCount);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (endExclusive <= 0) {
|
|
290
|
+
return {
|
|
291
|
+
hasMore: false,
|
|
292
|
+
items: [],
|
|
293
|
+
nextBeforeIndex: null,
|
|
294
|
+
totalCount
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const pageSize = Math.max(1, Number.parseInt(limit, 10) || 40);
|
|
299
|
+
const start = Math.max(0, endExclusive - pageSize);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
hasMore: start > 0,
|
|
303
|
+
items: normalizedTranscript.slice(start, endExclusive),
|
|
304
|
+
nextBeforeIndex: start > 0 ? start : null,
|
|
305
|
+
totalCount
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function readTranscriptHistoryPageFromSessionLog(threadPath, {
|
|
310
|
+
beforeIndex = null,
|
|
311
|
+
limit = 40,
|
|
312
|
+
visibleCount = null
|
|
313
|
+
} = {}) {
|
|
314
|
+
if (!threadPath) {
|
|
315
|
+
return {
|
|
316
|
+
hasMore: false,
|
|
317
|
+
items: [],
|
|
318
|
+
nextBeforeIndex: null,
|
|
319
|
+
totalCount: 0
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return pageTranscriptEntries(
|
|
324
|
+
parseTranscriptFromSessionLogText(readUtf8File(threadPath)),
|
|
325
|
+
{
|
|
326
|
+
beforeIndex,
|
|
327
|
+
limit,
|
|
328
|
+
visibleCount
|
|
329
|
+
}
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function buildSessionLogSnapshot(thread, {
|
|
334
|
+
limit = 40,
|
|
335
|
+
maxBytes = SESSION_LOG_TAIL_BYTES
|
|
336
|
+
} = {}) {
|
|
337
|
+
const transcript = readTranscriptFromSessionLog(thread?.path, {
|
|
338
|
+
limit,
|
|
339
|
+
maxBytes
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
thread: {
|
|
344
|
+
id: thread?.id || null,
|
|
345
|
+
name: thread?.name || null,
|
|
346
|
+
preview: thread?.preview || null,
|
|
347
|
+
source: thread?.source || null,
|
|
348
|
+
cwd: thread?.cwd || null,
|
|
349
|
+
status: thread?.status || null,
|
|
350
|
+
activeTurnId: thread?.activeTurnId || null,
|
|
351
|
+
activeTurnStatus: thread?.activeTurnStatus || null,
|
|
352
|
+
livePlan: thread?.livePlan || null,
|
|
353
|
+
lastTurnId: thread?.lastTurnId || null,
|
|
354
|
+
lastTurnStatus: thread?.lastTurnStatus || null,
|
|
355
|
+
tokenUsage: thread?.tokenUsage || null,
|
|
356
|
+
updatedAt: thread?.updatedAt || null,
|
|
357
|
+
path: thread?.path || null
|
|
358
|
+
},
|
|
359
|
+
transcript,
|
|
360
|
+
transcriptCount: transcript.length
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function buildTimestampQueues(threadPath) {
|
|
365
|
+
const tail = readUtf8Tail(threadPath);
|
|
366
|
+
if (!tail) {
|
|
367
|
+
return new Map();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const queues = new Map();
|
|
371
|
+
|
|
372
|
+
for (const line of tail.split("\n")) {
|
|
373
|
+
if (!line.trim()) {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
let entry;
|
|
378
|
+
try {
|
|
379
|
+
entry = JSON.parse(line);
|
|
380
|
+
} catch {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const mapped = mapSessionLogEntry(entry);
|
|
385
|
+
if (!mapped?.text) {
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const key = normalizeTranscriptKey(mapped.role, mapped.kind, mapped.text);
|
|
390
|
+
const queue = queues.get(key) || [];
|
|
391
|
+
queue.push(entry.timestamp || null);
|
|
392
|
+
queues.set(key, queue);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return queues;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function enrichTranscriptTimestamps(thread, transcript) {
|
|
399
|
+
if (!thread?.path) {
|
|
400
|
+
return transcript;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const queues = buildTimestampQueues(thread.path);
|
|
404
|
+
if (queues.size === 0) {
|
|
405
|
+
return transcript;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return transcript.map((entry) => {
|
|
409
|
+
if (entry.timestamp) {
|
|
410
|
+
return entry;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const key = normalizeTranscriptKey(entry.role, entry.kind, entry.text);
|
|
414
|
+
const queue = queues.get(key);
|
|
415
|
+
if (!queue?.length) {
|
|
416
|
+
return entry;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return {
|
|
420
|
+
...entry,
|
|
421
|
+
timestamp: queue.shift()
|
|
422
|
+
};
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function joinReasoningSummary(summary = []) {
|
|
427
|
+
return summary
|
|
428
|
+
.map((part) => {
|
|
429
|
+
if (typeof part === "string") {
|
|
430
|
+
return part;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (part?.text) {
|
|
434
|
+
return part.text;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return "";
|
|
438
|
+
})
|
|
439
|
+
.filter(Boolean)
|
|
440
|
+
.join("\n");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function summarizeUnknownItem(item) {
|
|
444
|
+
const summary = {};
|
|
445
|
+
|
|
446
|
+
for (const key of ["status", "command", "tool", "message", "output", "stdout", "stderr"]) {
|
|
447
|
+
if (item[key] != null) {
|
|
448
|
+
summary[key] = item[key];
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (Object.keys(summary).length === 0) {
|
|
453
|
+
return `${humanizeIdentifier(item.type || "item")} event`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return `${item.type || "item"}: ${JSON.stringify(summary)}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function describeServerRequest(msg) {
|
|
460
|
+
switch (msg.method) {
|
|
461
|
+
case "account/chatgptAuthTokens/refresh":
|
|
462
|
+
return "app-server requested ChatGPT auth token refresh; the standalone Dextunnel bridge cannot satisfy that yet.";
|
|
463
|
+
case "item/commandExecution/requestApproval":
|
|
464
|
+
return "app-server requested command approval; the standalone Dextunnel bridge does not support live approval callbacks yet.";
|
|
465
|
+
case "item/fileChange/requestApproval":
|
|
466
|
+
return "app-server requested file-change approval; the standalone Dextunnel bridge does not support live approval callbacks yet.";
|
|
467
|
+
case "item/permissions/requestApproval":
|
|
468
|
+
return "app-server requested additional permissions; the standalone Dextunnel bridge does not support that approval flow yet.";
|
|
469
|
+
case "item/tool/requestUserInput":
|
|
470
|
+
return "app-server requested tool user input; the standalone Dextunnel bridge does not support that interaction yet.";
|
|
471
|
+
case "mcpServer/elicitation/request":
|
|
472
|
+
return "app-server requested MCP elicitation input; the standalone Dextunnel bridge does not support that interaction yet.";
|
|
473
|
+
case "item/tool/call":
|
|
474
|
+
return "app-server requested a client-side dynamic tool call; the standalone Dextunnel bridge does not implement client tools yet.";
|
|
475
|
+
default:
|
|
476
|
+
return `app-server sent an unsupported server request: ${msg.method}`;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function mapThreadItemToCompanionEntry(item, turn) {
|
|
481
|
+
switch (item.type) {
|
|
482
|
+
case "userMessage":
|
|
483
|
+
return {
|
|
484
|
+
itemId: item.id || null,
|
|
485
|
+
role: "user",
|
|
486
|
+
kind: "message",
|
|
487
|
+
text: joinContent(item.content),
|
|
488
|
+
phase: null,
|
|
489
|
+
turnId: turn.id,
|
|
490
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
491
|
+
};
|
|
492
|
+
case "agentMessage":
|
|
493
|
+
return {
|
|
494
|
+
itemId: item.id || null,
|
|
495
|
+
role: "assistant",
|
|
496
|
+
kind: item.phase || "message",
|
|
497
|
+
text: item.text || "",
|
|
498
|
+
phase: item.phase || null,
|
|
499
|
+
turnId: turn.id,
|
|
500
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
501
|
+
};
|
|
502
|
+
case "commandExecution":
|
|
503
|
+
const outputPreview = trimToolOutput(item.output || "");
|
|
504
|
+
return {
|
|
505
|
+
itemId: item.id || null,
|
|
506
|
+
role: "tool",
|
|
507
|
+
kind: "command",
|
|
508
|
+
text: [item.command ? `$ ${item.command}` : null, outputPreview || null].filter(Boolean).join("\n"),
|
|
509
|
+
phase: item.status || null,
|
|
510
|
+
turnId: turn.id,
|
|
511
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
512
|
+
};
|
|
513
|
+
case "reasoning":
|
|
514
|
+
return {
|
|
515
|
+
itemId: item.id || null,
|
|
516
|
+
role: "system",
|
|
517
|
+
kind: "reasoning",
|
|
518
|
+
text: joinReasoningSummary(item.summary),
|
|
519
|
+
phase: null,
|
|
520
|
+
turnId: turn.id,
|
|
521
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
522
|
+
};
|
|
523
|
+
case "contextCompaction":
|
|
524
|
+
return {
|
|
525
|
+
itemId: item.id || null,
|
|
526
|
+
role: "system",
|
|
527
|
+
kind: "context_compaction",
|
|
528
|
+
text: "Context compacted.",
|
|
529
|
+
phase: null,
|
|
530
|
+
turnId: turn.id,
|
|
531
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
532
|
+
};
|
|
533
|
+
case "mcpToolCall":
|
|
534
|
+
case "dynamicToolCall":
|
|
535
|
+
case "collabToolCall":
|
|
536
|
+
case "fileChange":
|
|
537
|
+
return {
|
|
538
|
+
itemId: item.id || null,
|
|
539
|
+
role: "tool",
|
|
540
|
+
kind: item.type,
|
|
541
|
+
text: summarizeUnknownItem(item),
|
|
542
|
+
phase: item.status || null,
|
|
543
|
+
turnId: turn.id,
|
|
544
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
545
|
+
};
|
|
546
|
+
default:
|
|
547
|
+
return {
|
|
548
|
+
itemId: item.id || null,
|
|
549
|
+
role: "system",
|
|
550
|
+
kind: item.type || "event",
|
|
551
|
+
text: summarizeUnknownItem(item),
|
|
552
|
+
phase: null,
|
|
553
|
+
turnId: turn.id,
|
|
554
|
+
timestamp: turn.updatedAt || turn.startedAt || null
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function mapThreadToCompanionSnapshot(thread, { limit = null } = {}) {
|
|
560
|
+
const turns = thread.turns || [];
|
|
561
|
+
const activeTurn = [...turns].reverse().find((turn) => turn.status === "inProgress") || null;
|
|
562
|
+
const lastTurn = turns.at(-1) || null;
|
|
563
|
+
const transcript = enrichTranscriptTimestamps(
|
|
564
|
+
thread,
|
|
565
|
+
turns
|
|
566
|
+
.flatMap((turn) => (turn.items || []).map((item) => mapThreadItemToCompanionEntry(item, turn)))
|
|
567
|
+
.filter((entry) => entry.text && entry.text.trim().length > 0)
|
|
568
|
+
);
|
|
569
|
+
const visibleTranscript = limit ? transcript.slice(-limit) : transcript;
|
|
570
|
+
|
|
571
|
+
return {
|
|
572
|
+
thread: {
|
|
573
|
+
id: thread.id,
|
|
574
|
+
name: thread.name || null,
|
|
575
|
+
preview: thread.preview || null,
|
|
576
|
+
source: thread.source || null,
|
|
577
|
+
cwd: thread.cwd || null,
|
|
578
|
+
status: thread.status || null,
|
|
579
|
+
activeTurnId: activeTurn?.id || null,
|
|
580
|
+
activeTurnStatus: activeTurn?.status || null,
|
|
581
|
+
livePlan: activeTurn?.plan || lastTurn?.plan || null,
|
|
582
|
+
lastTurnId: lastTurn?.id || null,
|
|
583
|
+
lastTurnStatus: lastTurn?.status || null,
|
|
584
|
+
tokenUsage: thread.tokenUsage || null,
|
|
585
|
+
updatedAt: thread.updatedAt || null,
|
|
586
|
+
path: thread.path || null
|
|
587
|
+
},
|
|
588
|
+
transcript: visibleTranscript,
|
|
589
|
+
transcriptCount: transcript.length
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function buildSnapshotFromNotifications(thread, turn, notifications, { limit = 40 } = {}) {
|
|
594
|
+
const priorTurns = Array.isArray(thread?.turns) ? thread.turns.filter((entry) => entry.id !== turn?.id) : [];
|
|
595
|
+
const turnItems = notifications
|
|
596
|
+
.filter((msg) => msg.method === "item/completed" && msg.params?.turnId === turn?.id && msg.params?.item)
|
|
597
|
+
.map((msg) => msg.params.item);
|
|
598
|
+
const synthesizedTurn = turn
|
|
599
|
+
? {
|
|
600
|
+
...turn,
|
|
601
|
+
items: turnItems
|
|
602
|
+
}
|
|
603
|
+
: null;
|
|
604
|
+
|
|
605
|
+
return mapThreadToCompanionSnapshot(
|
|
606
|
+
{
|
|
607
|
+
...thread,
|
|
608
|
+
turns: synthesizedTurn ? [...priorTurns, synthesizedTurn] : priorTurns
|
|
609
|
+
},
|
|
610
|
+
{ limit }
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function getWritableTurnStrategy(thread) {
|
|
615
|
+
const turns = thread.turns || [];
|
|
616
|
+
const activeTurn = [...turns].reverse().find((turn) => turn.status === "inProgress") || null;
|
|
617
|
+
|
|
618
|
+
if (activeTurn) {
|
|
619
|
+
return {
|
|
620
|
+
mode: "steer",
|
|
621
|
+
expectedTurnId: activeTurn.id
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
mode: "start",
|
|
627
|
+
expectedTurnId: null
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function createCodexAppServerBridge({
|
|
632
|
+
binaryPath = DEFAULT_BINARY,
|
|
633
|
+
listenUrl = DEFAULT_LISTEN_URL,
|
|
634
|
+
clientInfo = { name: "dextunnel", version: "0.1.0" }
|
|
635
|
+
} = {}) {
|
|
636
|
+
const readyUrl = new URL(listenUrl.replace(/^ws/, "http"));
|
|
637
|
+
readyUrl.pathname = "/readyz";
|
|
638
|
+
|
|
639
|
+
let child = null;
|
|
640
|
+
let startPromise = null;
|
|
641
|
+
let startupLogs = [];
|
|
642
|
+
let lastError = null;
|
|
643
|
+
|
|
644
|
+
function appendLog(line) {
|
|
645
|
+
if (!line) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
startupLogs.push(line);
|
|
649
|
+
startupLogs = startupLogs.slice(-40);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function isReady() {
|
|
653
|
+
try {
|
|
654
|
+
const response = await fetch(readyUrl, { method: "GET" });
|
|
655
|
+
return response.ok;
|
|
656
|
+
} catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function ensureStarted() {
|
|
662
|
+
if (await isReady()) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (startPromise) {
|
|
667
|
+
return startPromise;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
startPromise = (async () => {
|
|
671
|
+
if (await isReady()) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
lastError = null;
|
|
676
|
+
child = spawn(binaryPath, ["app-server", "--listen", listenUrl], {
|
|
677
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
child.stdout.setEncoding("utf8");
|
|
681
|
+
child.stderr.setEncoding("utf8");
|
|
682
|
+
child.stdout.on("data", (chunk) => appendLog(chunk.trim()));
|
|
683
|
+
child.stderr.on("data", (chunk) => appendLog(chunk.trim()));
|
|
684
|
+
child.on("exit", (code, signal) => {
|
|
685
|
+
appendLog(`app-server exited with code=${code} signal=${signal}`);
|
|
686
|
+
child = null;
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const deadline = Date.now() + 6000;
|
|
690
|
+
while (Date.now() < deadline) {
|
|
691
|
+
if (await isReady()) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (child && child.exitCode != null) {
|
|
695
|
+
throw new Error(`codex app-server exited early with code ${child.exitCode}`);
|
|
696
|
+
}
|
|
697
|
+
await delay(150);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
throw new Error("Timed out waiting for codex app-server readiness.");
|
|
701
|
+
})()
|
|
702
|
+
.catch((error) => {
|
|
703
|
+
lastError = error.message;
|
|
704
|
+
throw error;
|
|
705
|
+
})
|
|
706
|
+
.finally(() => {
|
|
707
|
+
startPromise = null;
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
return startPromise;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function rpc(method, params) {
|
|
714
|
+
await ensureStarted();
|
|
715
|
+
|
|
716
|
+
return new Promise((resolve, reject) => {
|
|
717
|
+
let stage = "init";
|
|
718
|
+
let settled = false;
|
|
719
|
+
const initId = 1;
|
|
720
|
+
const requestId = 2;
|
|
721
|
+
const notifications = [];
|
|
722
|
+
const ws = new WebSocket(listenUrl);
|
|
723
|
+
|
|
724
|
+
const timeout = setTimeout(() => {
|
|
725
|
+
if (!settled) {
|
|
726
|
+
settled = true;
|
|
727
|
+
ws.close();
|
|
728
|
+
reject(new Error(`Timed out waiting for ${method} response.`));
|
|
729
|
+
}
|
|
730
|
+
}, 6000);
|
|
731
|
+
|
|
732
|
+
function finish(fn) {
|
|
733
|
+
return (value) => {
|
|
734
|
+
if (settled) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
settled = true;
|
|
738
|
+
clearTimeout(timeout);
|
|
739
|
+
try {
|
|
740
|
+
ws.close();
|
|
741
|
+
} catch {
|
|
742
|
+
// Ignore close failures on teardown.
|
|
743
|
+
}
|
|
744
|
+
fn(value);
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
ws.addEventListener("open", () => {
|
|
749
|
+
ws.send(
|
|
750
|
+
JSON.stringify({
|
|
751
|
+
jsonrpc: "2.0",
|
|
752
|
+
id: initId,
|
|
753
|
+
method: "initialize",
|
|
754
|
+
params: {
|
|
755
|
+
clientInfo,
|
|
756
|
+
capabilities: {
|
|
757
|
+
experimentalApi: true
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
})
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
ws.addEventListener("message", (event) => {
|
|
765
|
+
const msg = JSON.parse(event.data.toString());
|
|
766
|
+
|
|
767
|
+
if (msg.method) {
|
|
768
|
+
notifications.push(msg);
|
|
769
|
+
|
|
770
|
+
if (msg.id != null) {
|
|
771
|
+
finish(reject)(new Error(describeServerRequest(msg)));
|
|
772
|
+
}
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (msg.error) {
|
|
777
|
+
finish(reject)(new Error(msg.error.message || `RPC error calling ${method}`));
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (msg.id === initId && stage === "init") {
|
|
782
|
+
stage = "request";
|
|
783
|
+
sendInitializedNotification(ws);
|
|
784
|
+
ws.send(
|
|
785
|
+
JSON.stringify({
|
|
786
|
+
jsonrpc: "2.0",
|
|
787
|
+
id: requestId,
|
|
788
|
+
method,
|
|
789
|
+
params
|
|
790
|
+
})
|
|
791
|
+
);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (msg.id === requestId) {
|
|
796
|
+
finish(resolve)({
|
|
797
|
+
result: msg.result,
|
|
798
|
+
notifications
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
ws.addEventListener("error", () => {
|
|
804
|
+
finish(reject)(new Error(`WebSocket transport error while calling ${method}.`));
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function listThreads({
|
|
810
|
+
cwd = null,
|
|
811
|
+
limit = 10,
|
|
812
|
+
archived = false,
|
|
813
|
+
sourceKinds = null
|
|
814
|
+
} = {}) {
|
|
815
|
+
const params = {
|
|
816
|
+
cwd,
|
|
817
|
+
limit,
|
|
818
|
+
archived,
|
|
819
|
+
sortKey: "updated_at"
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
if (Array.isArray(sourceKinds) && sourceKinds.length > 0) {
|
|
823
|
+
params.sourceKinds = sourceKinds;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const { result } = await rpc("thread/list", params);
|
|
827
|
+
return result.data || [];
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function readThread(threadId, includeTurns = true) {
|
|
831
|
+
const { result } = await rpc("thread/read", {
|
|
832
|
+
threadId,
|
|
833
|
+
includeTurns
|
|
834
|
+
});
|
|
835
|
+
return result.thread;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function getLatestThreadForCwd(cwd) {
|
|
839
|
+
const threads = await listThreads({ cwd, limit: 1, archived: false });
|
|
840
|
+
if (threads.length === 0) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return readThread(threads[0].id, true);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
async function resumeThread({
|
|
848
|
+
threadId,
|
|
849
|
+
cwd = null,
|
|
850
|
+
persistExtendedHistory = true
|
|
851
|
+
}) {
|
|
852
|
+
const { result } = await rpc("thread/resume", {
|
|
853
|
+
threadId,
|
|
854
|
+
cwd,
|
|
855
|
+
persistExtendedHistory
|
|
856
|
+
});
|
|
857
|
+
return result.thread;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function startThread({
|
|
861
|
+
cwd = process.cwd(),
|
|
862
|
+
approvalPolicy = "never",
|
|
863
|
+
sandbox = "workspace-write",
|
|
864
|
+
ephemeral = false,
|
|
865
|
+
persistExtendedHistory = true
|
|
866
|
+
} = {}) {
|
|
867
|
+
const { result } = await rpc("thread/start", {
|
|
868
|
+
cwd,
|
|
869
|
+
approvalPolicy,
|
|
870
|
+
sandbox,
|
|
871
|
+
ephemeral,
|
|
872
|
+
experimentalRawEvents: false,
|
|
873
|
+
persistExtendedHistory
|
|
874
|
+
});
|
|
875
|
+
return result.thread;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
async function startTurn({
|
|
879
|
+
threadId,
|
|
880
|
+
text,
|
|
881
|
+
attachments = [],
|
|
882
|
+
approvalPolicy = "never"
|
|
883
|
+
}) {
|
|
884
|
+
const { result } = await rpc("turn/start", {
|
|
885
|
+
threadId,
|
|
886
|
+
input: toTurnInput({ text, attachments }),
|
|
887
|
+
approvalPolicy
|
|
888
|
+
});
|
|
889
|
+
return result.turn;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function steerTurn({
|
|
893
|
+
threadId,
|
|
894
|
+
expectedTurnId,
|
|
895
|
+
text,
|
|
896
|
+
attachments = []
|
|
897
|
+
}) {
|
|
898
|
+
const { result } = await rpc("turn/steer", {
|
|
899
|
+
threadId,
|
|
900
|
+
expectedTurnId,
|
|
901
|
+
input: toTurnInput({ text, attachments })
|
|
902
|
+
});
|
|
903
|
+
return result.turn;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async function interruptTurn({
|
|
907
|
+
threadId,
|
|
908
|
+
turnId
|
|
909
|
+
}) {
|
|
910
|
+
const { result } = await rpc("turn/interrupt", {
|
|
911
|
+
threadId,
|
|
912
|
+
turnId
|
|
913
|
+
});
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
async function watchThread({
|
|
918
|
+
threadId,
|
|
919
|
+
cwd = null,
|
|
920
|
+
onClose = null,
|
|
921
|
+
onError = null,
|
|
922
|
+
onReady = null,
|
|
923
|
+
onServerRequest = null,
|
|
924
|
+
onNotification = null
|
|
925
|
+
}) {
|
|
926
|
+
await ensureStarted();
|
|
927
|
+
|
|
928
|
+
return new Promise((resolve, reject) => {
|
|
929
|
+
let closed = false;
|
|
930
|
+
let initialized = false;
|
|
931
|
+
const initId = 1;
|
|
932
|
+
const resumeId = 2;
|
|
933
|
+
const ws = new WebSocket(listenUrl);
|
|
934
|
+
|
|
935
|
+
function send(payload) {
|
|
936
|
+
if (closed || ws.readyState !== WebSocket.OPEN) {
|
|
937
|
+
throw new Error("Thread watcher socket is not open.");
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
ws.send(JSON.stringify(payload));
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function close() {
|
|
944
|
+
if (closed) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
closed = true;
|
|
949
|
+
try {
|
|
950
|
+
ws.close();
|
|
951
|
+
} catch {
|
|
952
|
+
// Ignore close failures during teardown.
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function respond(requestId, result) {
|
|
957
|
+
send({
|
|
958
|
+
jsonrpc: "2.0",
|
|
959
|
+
id: requestId,
|
|
960
|
+
result
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function respondError(requestId, message, code = -32000) {
|
|
965
|
+
send({
|
|
966
|
+
jsonrpc: "2.0",
|
|
967
|
+
id: requestId,
|
|
968
|
+
error: {
|
|
969
|
+
code,
|
|
970
|
+
message
|
|
971
|
+
}
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
ws.addEventListener("open", () => {
|
|
976
|
+
ws.send(
|
|
977
|
+
JSON.stringify({
|
|
978
|
+
jsonrpc: "2.0",
|
|
979
|
+
id: initId,
|
|
980
|
+
method: "initialize",
|
|
981
|
+
params: {
|
|
982
|
+
clientInfo,
|
|
983
|
+
capabilities: {
|
|
984
|
+
experimentalApi: true
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
})
|
|
988
|
+
);
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
ws.addEventListener("message", (event) => {
|
|
992
|
+
const msg = JSON.parse(event.data.toString());
|
|
993
|
+
|
|
994
|
+
if (msg.method) {
|
|
995
|
+
if (msg.id != null) {
|
|
996
|
+
onServerRequest?.({
|
|
997
|
+
method: msg.method,
|
|
998
|
+
params: msg.params || {},
|
|
999
|
+
requestId: msg.id,
|
|
1000
|
+
respond,
|
|
1001
|
+
respondError
|
|
1002
|
+
});
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
onNotification?.({
|
|
1007
|
+
method: msg.method,
|
|
1008
|
+
params: msg.params || {}
|
|
1009
|
+
});
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (msg.error) {
|
|
1014
|
+
const error = new Error(msg.error.message || "Thread watch RPC failed.");
|
|
1015
|
+
|
|
1016
|
+
if (!initialized) {
|
|
1017
|
+
reject(error);
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
onError?.(error);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (msg.id === initId && !initialized) {
|
|
1026
|
+
sendInitializedNotification(ws);
|
|
1027
|
+
ws.send(
|
|
1028
|
+
JSON.stringify({
|
|
1029
|
+
jsonrpc: "2.0",
|
|
1030
|
+
id: resumeId,
|
|
1031
|
+
method: "thread/resume",
|
|
1032
|
+
params: {
|
|
1033
|
+
threadId,
|
|
1034
|
+
cwd,
|
|
1035
|
+
persistExtendedHistory: true
|
|
1036
|
+
}
|
|
1037
|
+
})
|
|
1038
|
+
);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (msg.id === resumeId && !initialized) {
|
|
1043
|
+
initialized = true;
|
|
1044
|
+
const controller = {
|
|
1045
|
+
close,
|
|
1046
|
+
respond,
|
|
1047
|
+
respondError
|
|
1048
|
+
};
|
|
1049
|
+
onReady?.(msg.result?.thread || null, controller);
|
|
1050
|
+
resolve(controller);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
ws.addEventListener("close", () => {
|
|
1055
|
+
const wasInitialized = initialized;
|
|
1056
|
+
close();
|
|
1057
|
+
|
|
1058
|
+
if (!wasInitialized) {
|
|
1059
|
+
reject(new Error(`Thread watcher closed before subscribing to ${threadId}.`));
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
onClose?.();
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
ws.addEventListener("error", () => {
|
|
1067
|
+
const error = new Error(`WebSocket transport error while watching thread ${threadId}.`);
|
|
1068
|
+
|
|
1069
|
+
if (!initialized) {
|
|
1070
|
+
reject(error);
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
onError?.(error);
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function runTurnSession({
|
|
1080
|
+
threadId = null,
|
|
1081
|
+
cwd = process.cwd(),
|
|
1082
|
+
text,
|
|
1083
|
+
attachments = [],
|
|
1084
|
+
createThreadIfMissing = true,
|
|
1085
|
+
allowSteer = false,
|
|
1086
|
+
approvalPolicy = "never",
|
|
1087
|
+
timeoutMs = 120000,
|
|
1088
|
+
waitForAcceptanceOnly = false
|
|
1089
|
+
}) {
|
|
1090
|
+
await ensureStarted();
|
|
1091
|
+
|
|
1092
|
+
return new Promise((resolve, reject) => {
|
|
1093
|
+
let settled = false;
|
|
1094
|
+
let stage = "init";
|
|
1095
|
+
let targetThread = null;
|
|
1096
|
+
let mode = null;
|
|
1097
|
+
let requestResult = null;
|
|
1098
|
+
let activeTurnId = null;
|
|
1099
|
+
let completedTurn = null;
|
|
1100
|
+
let snapshotRequested = false;
|
|
1101
|
+
const initId = 1;
|
|
1102
|
+
const threadSetupId = 2;
|
|
1103
|
+
const turnRequestId = 3;
|
|
1104
|
+
const snapshotReadId = 4;
|
|
1105
|
+
const notifications = [];
|
|
1106
|
+
const ws = new WebSocket(listenUrl);
|
|
1107
|
+
|
|
1108
|
+
const timeout = setTimeout(() => {
|
|
1109
|
+
if (!settled) {
|
|
1110
|
+
settled = true;
|
|
1111
|
+
ws.close();
|
|
1112
|
+
reject(
|
|
1113
|
+
new Error(
|
|
1114
|
+
`Timed out waiting for app-server turn completion${targetThread?.id ? ` on thread ${targetThread.id}` : ""}.`
|
|
1115
|
+
)
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}, timeoutMs);
|
|
1119
|
+
|
|
1120
|
+
function finish(fn) {
|
|
1121
|
+
return (value) => {
|
|
1122
|
+
if (settled) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
settled = true;
|
|
1126
|
+
clearTimeout(timeout);
|
|
1127
|
+
try {
|
|
1128
|
+
ws.close();
|
|
1129
|
+
} catch {
|
|
1130
|
+
// Ignore close failures on teardown.
|
|
1131
|
+
}
|
|
1132
|
+
fn(value);
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function sendThreadSetupRequest() {
|
|
1137
|
+
if (threadId) {
|
|
1138
|
+
ws.send(
|
|
1139
|
+
JSON.stringify({
|
|
1140
|
+
jsonrpc: "2.0",
|
|
1141
|
+
id: threadSetupId,
|
|
1142
|
+
method: "thread/resume",
|
|
1143
|
+
params: {
|
|
1144
|
+
threadId,
|
|
1145
|
+
cwd,
|
|
1146
|
+
persistExtendedHistory: true
|
|
1147
|
+
}
|
|
1148
|
+
})
|
|
1149
|
+
);
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (!createThreadIfMissing) {
|
|
1154
|
+
finish(reject)(new Error(`No Codex thread found for ${cwd}.`));
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
ws.send(
|
|
1159
|
+
JSON.stringify({
|
|
1160
|
+
jsonrpc: "2.0",
|
|
1161
|
+
id: threadSetupId,
|
|
1162
|
+
method: "thread/start",
|
|
1163
|
+
params: {
|
|
1164
|
+
cwd,
|
|
1165
|
+
approvalPolicy,
|
|
1166
|
+
sandbox: "workspace-write",
|
|
1167
|
+
ephemeral: false,
|
|
1168
|
+
experimentalRawEvents: false,
|
|
1169
|
+
persistExtendedHistory: true
|
|
1170
|
+
}
|
|
1171
|
+
})
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function sendTurnRequest() {
|
|
1176
|
+
const params =
|
|
1177
|
+
mode === "steer"
|
|
1178
|
+
? {
|
|
1179
|
+
threadId: targetThread.id,
|
|
1180
|
+
expectedTurnId: activeTurnId,
|
|
1181
|
+
input: toTurnInput({ text, attachments })
|
|
1182
|
+
}
|
|
1183
|
+
: {
|
|
1184
|
+
threadId: targetThread.id,
|
|
1185
|
+
input: toTurnInput({ text, attachments }),
|
|
1186
|
+
approvalPolicy
|
|
1187
|
+
};
|
|
1188
|
+
|
|
1189
|
+
ws.send(
|
|
1190
|
+
JSON.stringify({
|
|
1191
|
+
jsonrpc: "2.0",
|
|
1192
|
+
id: turnRequestId,
|
|
1193
|
+
method: mode === "steer" ? "turn/steer" : "turn/start",
|
|
1194
|
+
params
|
|
1195
|
+
})
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function requestFinalSnapshot(turn = null) {
|
|
1200
|
+
if (snapshotRequested) {
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
snapshotRequested = true;
|
|
1205
|
+
completedTurn = turn || completedTurn;
|
|
1206
|
+
ws.send(
|
|
1207
|
+
JSON.stringify({
|
|
1208
|
+
jsonrpc: "2.0",
|
|
1209
|
+
id: snapshotReadId,
|
|
1210
|
+
method: "thread/read",
|
|
1211
|
+
params: {
|
|
1212
|
+
threadId: targetThread.id,
|
|
1213
|
+
includeTurns: true
|
|
1214
|
+
}
|
|
1215
|
+
})
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
ws.addEventListener("open", () => {
|
|
1220
|
+
ws.send(
|
|
1221
|
+
JSON.stringify({
|
|
1222
|
+
jsonrpc: "2.0",
|
|
1223
|
+
id: initId,
|
|
1224
|
+
method: "initialize",
|
|
1225
|
+
params: {
|
|
1226
|
+
clientInfo,
|
|
1227
|
+
capabilities: {
|
|
1228
|
+
experimentalApi: true
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
})
|
|
1232
|
+
);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
ws.addEventListener("message", (event) => {
|
|
1236
|
+
const msg = JSON.parse(event.data.toString());
|
|
1237
|
+
|
|
1238
|
+
if (msg.method) {
|
|
1239
|
+
notifications.push(msg);
|
|
1240
|
+
|
|
1241
|
+
if (msg.id != null) {
|
|
1242
|
+
finish(reject)(new Error(describeServerRequest(msg)));
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (msg.method === "turn/started" && msg.params?.turn?.id) {
|
|
1247
|
+
activeTurnId = msg.params.turn.id;
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (msg.method === "turn/completed") {
|
|
1252
|
+
const completedTurnId = msg.params?.turn?.id || null;
|
|
1253
|
+
if (!activeTurnId || completedTurnId === activeTurnId) {
|
|
1254
|
+
requestFinalSnapshot(msg.params?.turn || null);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
if (msg.error) {
|
|
1261
|
+
if (msg.id === snapshotReadId && completedTurn) {
|
|
1262
|
+
finish(resolve)({
|
|
1263
|
+
mode,
|
|
1264
|
+
thread: targetThread,
|
|
1265
|
+
notifications,
|
|
1266
|
+
result: requestResult,
|
|
1267
|
+
turn: completedTurn
|
|
1268
|
+
});
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
finish(reject)(new Error(msg.error.message || `RPC error during ${mode} turn`));
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (msg.id === initId && stage === "init") {
|
|
1277
|
+
stage = "thread";
|
|
1278
|
+
sendInitializedNotification(ws);
|
|
1279
|
+
sendThreadSetupRequest();
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (msg.id === threadSetupId) {
|
|
1284
|
+
targetThread = msg.result?.thread || null;
|
|
1285
|
+
if (!targetThread?.id) {
|
|
1286
|
+
finish(reject)(new Error("app-server did not return a thread for write setup."));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const strategy = getWritableTurnStrategy(targetThread);
|
|
1291
|
+
mode = strategy.mode;
|
|
1292
|
+
activeTurnId = strategy.expectedTurnId;
|
|
1293
|
+
|
|
1294
|
+
if (mode === "steer" && !allowSteer) {
|
|
1295
|
+
finish(reject)(
|
|
1296
|
+
new Error(
|
|
1297
|
+
`Thread ${targetThread.id} already has an active turn (${activeTurnId}). Wait for completion before sending a new message.`
|
|
1298
|
+
)
|
|
1299
|
+
);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
stage = "turn";
|
|
1304
|
+
sendTurnRequest();
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (msg.id === turnRequestId) {
|
|
1309
|
+
requestResult = msg.result;
|
|
1310
|
+
activeTurnId = msg.result?.turn?.id || activeTurnId;
|
|
1311
|
+
if (waitForAcceptanceOnly) {
|
|
1312
|
+
finish(resolve)({
|
|
1313
|
+
mode,
|
|
1314
|
+
thread: targetThread,
|
|
1315
|
+
notifications,
|
|
1316
|
+
result: requestResult,
|
|
1317
|
+
turn: msg.result?.turn || null
|
|
1318
|
+
});
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
requestFinalSnapshot(msg.result?.turn || null);
|
|
1322
|
+
return;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (msg.id === snapshotReadId) {
|
|
1326
|
+
const threadWithTurns = msg.result?.thread || targetThread;
|
|
1327
|
+
const finalTurnId = completedTurn?.id || activeTurnId;
|
|
1328
|
+
const finalTurn =
|
|
1329
|
+
(threadWithTurns?.turns || []).find((entry) => entry.id === finalTurnId) || completedTurn || null;
|
|
1330
|
+
|
|
1331
|
+
finish(resolve)({
|
|
1332
|
+
mode,
|
|
1333
|
+
thread: threadWithTurns,
|
|
1334
|
+
notifications,
|
|
1335
|
+
result: requestResult,
|
|
1336
|
+
turn: finalTurn
|
|
1337
|
+
});
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
});
|
|
1342
|
+
|
|
1343
|
+
ws.addEventListener("error", () => {
|
|
1344
|
+
finish(reject)(new Error(`WebSocket transport error during ${mode} turn.`));
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
async function waitForTurnCompletion(threadId, turnId, timeoutMs = 45000) {
|
|
1350
|
+
const deadline = Date.now() + timeoutMs;
|
|
1351
|
+
|
|
1352
|
+
while (Date.now() < deadline) {
|
|
1353
|
+
try {
|
|
1354
|
+
const thread = await readThread(threadId, true);
|
|
1355
|
+
const turn = (thread.turns || []).find((entry) => entry.id === turnId) || null;
|
|
1356
|
+
|
|
1357
|
+
if (turn && turn.status !== "inProgress") {
|
|
1358
|
+
return {
|
|
1359
|
+
thread,
|
|
1360
|
+
turn
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
if (!String(error.message).includes("not materialized yet")) {
|
|
1365
|
+
throw error;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
await delay(1200);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
throw new Error(`Timed out waiting for turn ${turnId} to complete.`);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async function sendText({
|
|
1376
|
+
threadId = null,
|
|
1377
|
+
cwd = process.cwd(),
|
|
1378
|
+
text,
|
|
1379
|
+
attachments = [],
|
|
1380
|
+
createThreadIfMissing = true,
|
|
1381
|
+
allowSteer = false,
|
|
1382
|
+
timeoutMs = 45000,
|
|
1383
|
+
waitForCompletion = true
|
|
1384
|
+
}) {
|
|
1385
|
+
const trimmed = String(text || "").trim();
|
|
1386
|
+
if (!trimmed && (!Array.isArray(attachments) || attachments.length === 0)) {
|
|
1387
|
+
throw new Error("Message cannot be empty.");
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
let targetThreadId = threadId;
|
|
1391
|
+
|
|
1392
|
+
if (!targetThreadId) {
|
|
1393
|
+
const latestThread = await getLatestThreadForCwd(cwd);
|
|
1394
|
+
targetThreadId = latestThread?.id || null;
|
|
1395
|
+
}
|
|
1396
|
+
if (!targetThreadId && !createThreadIfMissing) {
|
|
1397
|
+
throw new Error(`No Codex thread found for ${cwd}.`);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const session = await runTurnSession({
|
|
1401
|
+
threadId: targetThreadId,
|
|
1402
|
+
cwd,
|
|
1403
|
+
text: trimmed,
|
|
1404
|
+
attachments,
|
|
1405
|
+
createThreadIfMissing,
|
|
1406
|
+
allowSteer,
|
|
1407
|
+
approvalPolicy: "never",
|
|
1408
|
+
timeoutMs,
|
|
1409
|
+
waitForAcceptanceOnly: !waitForCompletion
|
|
1410
|
+
});
|
|
1411
|
+
const turnId = session.turn?.id || session.result?.turn?.id;
|
|
1412
|
+
if (!turnId) {
|
|
1413
|
+
throw new Error("app-server did not report a turn id for the submitted message.");
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!waitForCompletion) {
|
|
1417
|
+
const lightweightThread = await readThread(session.thread.id, false).catch(() => session.thread);
|
|
1418
|
+
const sessionLogSnapshot = buildSessionLogSnapshot(lightweightThread, { limit: 40, maxBytes: 64 * 1024 });
|
|
1419
|
+
|
|
1420
|
+
return {
|
|
1421
|
+
mode: session.mode,
|
|
1422
|
+
thread: lightweightThread,
|
|
1423
|
+
turn: session.result?.turn || session.turn || null,
|
|
1424
|
+
snapshot:
|
|
1425
|
+
sessionLogSnapshot.transcriptCount > 0
|
|
1426
|
+
? sessionLogSnapshot
|
|
1427
|
+
: buildSnapshotFromNotifications(session.thread, session.result?.turn || session.turn || null, session.notifications, { limit: 40 })
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const finalTurnFromSession =
|
|
1432
|
+
(session.thread?.turns || []).find((entry) => entry.id === turnId) || session.turn || null;
|
|
1433
|
+
|
|
1434
|
+
if (finalTurnFromSession?.status === "inProgress" || session.result?.turn?.status === "inProgress") {
|
|
1435
|
+
return {
|
|
1436
|
+
mode: session.mode,
|
|
1437
|
+
thread: session.thread,
|
|
1438
|
+
turn: finalTurnFromSession || session.result?.turn || null,
|
|
1439
|
+
snapshot:
|
|
1440
|
+
session.thread?.turns
|
|
1441
|
+
? mapThreadToCompanionSnapshot(session.thread, { limit: 40 })
|
|
1442
|
+
: buildSnapshotFromNotifications(session.thread, session.result?.turn || finalTurnFromSession, session.notifications, { limit: 40 })
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
let completed = null;
|
|
1447
|
+
|
|
1448
|
+
if (finalTurnFromSession && finalTurnFromSession.status !== "inProgress") {
|
|
1449
|
+
completed = {
|
|
1450
|
+
thread: session.thread,
|
|
1451
|
+
turn: finalTurnFromSession
|
|
1452
|
+
};
|
|
1453
|
+
} else {
|
|
1454
|
+
completed = await waitForTurnCompletion(session.thread.id, turnId, timeoutMs);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
return {
|
|
1458
|
+
mode: session.mode,
|
|
1459
|
+
thread: completed.thread,
|
|
1460
|
+
turn: completed.turn,
|
|
1461
|
+
snapshot:
|
|
1462
|
+
(completed.thread?.turns || []).some((entry) => entry.id === completed.turn.id)
|
|
1463
|
+
? mapThreadToCompanionSnapshot(completed.thread, { limit: 40 })
|
|
1464
|
+
: buildSnapshotFromNotifications(session.thread, completed.turn, session.notifications, { limit: 40 })
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
async function dispose() {
|
|
1469
|
+
if (!child) {
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
child.kill("SIGINT");
|
|
1474
|
+
try {
|
|
1475
|
+
await once(child, "exit");
|
|
1476
|
+
} catch {
|
|
1477
|
+
// Ignore shutdown races.
|
|
1478
|
+
}
|
|
1479
|
+
child = null;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
return {
|
|
1483
|
+
dispose,
|
|
1484
|
+
ensureStarted,
|
|
1485
|
+
getLatestThreadForCwd,
|
|
1486
|
+
getWritableTurnStrategy,
|
|
1487
|
+
getStatus() {
|
|
1488
|
+
return {
|
|
1489
|
+
binaryPath,
|
|
1490
|
+
listenUrl,
|
|
1491
|
+
readyUrl: readyUrl.toString(),
|
|
1492
|
+
started: Boolean(child),
|
|
1493
|
+
pid: child?.pid || null,
|
|
1494
|
+
startupLogs,
|
|
1495
|
+
lastError
|
|
1496
|
+
};
|
|
1497
|
+
},
|
|
1498
|
+
listThreads,
|
|
1499
|
+
interruptTurn,
|
|
1500
|
+
readThread,
|
|
1501
|
+
rpc,
|
|
1502
|
+
runTurnSession,
|
|
1503
|
+
resumeThread,
|
|
1504
|
+
sendText,
|
|
1505
|
+
startThread,
|
|
1506
|
+
startTurn,
|
|
1507
|
+
steerTurn,
|
|
1508
|
+
watchThread,
|
|
1509
|
+
waitForTurnCompletion
|
|
1510
|
+
};
|
|
1511
|
+
}
|