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,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
+ }