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,719 @@
1
+ export async function handleBridgeApiRequest({ req, res, url, deps }) {
2
+ const {
3
+ CONTROL_LEASE_TTL_MS,
4
+ accessClientId,
5
+ appServerState,
6
+ applyCompanionWakeupAction,
7
+ applyLiveControlAction,
8
+ applySurfacePresenceUpdate,
9
+ broadcast,
10
+ buildBridgeStatus,
11
+ buildInstallPreflight,
12
+ buildLivePayload,
13
+ buildLiveTurnChanges,
14
+ buildSelectedThreadSnapshot,
15
+ canServeSurfaceBootstrap,
16
+ clearDebugPendingInteraction,
17
+ codexAppServer,
18
+ createThreadSelection,
19
+ decorateSnapshot,
20
+ devToolsEnabled,
21
+ exposeHostSurface,
22
+ ensureRemoteControlLease,
23
+ errorStatusCode,
24
+ getCachedRepoChanges,
25
+ getControlLeaseForThread,
26
+ hasWatcherController = () => false,
27
+ interruptSelectedThread,
28
+ issueSurfaceBootstrap,
29
+ liveState,
30
+ mergeSelectedThreadSnapshot = (_previousSnapshot, nextSnapshot) => nextSnapshot,
31
+ cwd,
32
+ mapThreadToCompanionSnapshot,
33
+ mockAdapter,
34
+ nowIso,
35
+ openThreadInCodex,
36
+ persistImageAttachments,
37
+ readJsonBody,
38
+ recordControlEvent,
39
+ refreshLiveState,
40
+ refreshThreads,
41
+ rememberTurnOrigin,
42
+ requireSurfaceCapability,
43
+ resolvePendingInteraction,
44
+ resolveSurfaceAccess,
45
+ restartWatcher,
46
+ scheduleSnapshotRefresh = () => {},
47
+ scheduleControlLeaseExpiry,
48
+ sendJson,
49
+ setDebugCompanionWakeup,
50
+ setDebugPendingInteraction,
51
+ setSelection,
52
+ store,
53
+ streamState,
54
+ summonCompanionWakeup,
55
+ updateAgentRoom,
56
+ invalidateRepoChangesCache,
57
+ loadTranscriptHistoryPage
58
+ } = deps;
59
+
60
+ function summarizeResponseThread(thread) {
61
+ if (!thread) {
62
+ return null;
63
+ }
64
+
65
+ return {
66
+ activeTurnId: thread.activeTurnId || null,
67
+ activeTurnStatus: thread.activeTurnStatus || null,
68
+ cwd: thread.cwd || null,
69
+ id: thread.id || null,
70
+ lastTurnId: thread.lastTurnId || null,
71
+ lastTurnStatus: thread.lastTurnStatus || null,
72
+ name: thread.name || null,
73
+ path: thread.path || null,
74
+ preview: thread.preview || null,
75
+ source: thread.source || null,
76
+ status: thread.status || null,
77
+ tokenUsage: thread.tokenUsage || null,
78
+ updatedAt: thread.updatedAt || null
79
+ };
80
+ }
81
+
82
+ function summarizeResponseTurn(turn) {
83
+ if (!turn) {
84
+ return null;
85
+ }
86
+
87
+ return {
88
+ id: turn.id || null,
89
+ startedAt: turn.startedAt || null,
90
+ status: turn.status || null,
91
+ updatedAt: turn.updatedAt || null
92
+ };
93
+ }
94
+
95
+ try {
96
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/bootstrap") {
97
+ const surface = String(url.searchParams.get("surface") || "remote").trim().toLowerCase();
98
+ const pathname = surface === "host" ? "/host.html" : "/remote.html";
99
+
100
+ try {
101
+ if (
102
+ !canServeSurfaceBootstrap({
103
+ exposeHostSurface,
104
+ localAddress: req.socket?.localAddress || "",
105
+ pathname,
106
+ remoteAddress: req.socket?.remoteAddress || ""
107
+ })
108
+ ) {
109
+ sendJson(res, 403, {
110
+ error: "Host surface bootstrap is restricted to loopback unless DEXTUNNEL_EXPOSE_HOST_SURFACE is enabled."
111
+ });
112
+ return true;
113
+ }
114
+
115
+ sendJson(res, 200, issueSurfaceBootstrap(surface));
116
+ } catch (error) {
117
+ sendJson(res, errorStatusCode(error, 400), { error: error.message });
118
+ }
119
+ return true;
120
+ }
121
+
122
+ if (req.method === "GET" && url.pathname === "/api/state") {
123
+ requireSurfaceCapability(req, url, "read_room");
124
+ sendJson(res, 200, store.getState());
125
+ return true;
126
+ }
127
+
128
+ if (req.method === "GET" && url.pathname === "/api/preflight") {
129
+ try {
130
+ const payload = await buildInstallPreflight({
131
+ codexAppServer,
132
+ cwd,
133
+ runtimeConfig: deps.runtimeConfig,
134
+ warmup: url.searchParams.get("warmup") !== "0"
135
+ });
136
+ sendJson(res, 200, payload);
137
+ } catch (error) {
138
+ sendJson(res, errorStatusCode(error, 500), { error: error.message });
139
+ }
140
+ return true;
141
+ }
142
+
143
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/live-state") {
144
+ requireSurfaceCapability(req, url, "read_room");
145
+ sendJson(res, 200, buildLivePayload());
146
+ return true;
147
+ }
148
+
149
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/transcript-history") {
150
+ try {
151
+ requireSurfaceCapability(req, url, "read_room");
152
+ const threadId = url.searchParams.get("threadId") || liveState.selectedThreadId || null;
153
+ if (!threadId) {
154
+ sendJson(res, 400, { error: "threadId is required" });
155
+ return true;
156
+ }
157
+
158
+ const page = await loadTranscriptHistoryPage({
159
+ beforeIndex: url.searchParams.get("beforeIndex"),
160
+ limit: url.searchParams.get("limit"),
161
+ threadId,
162
+ visibleCount: url.searchParams.get("visibleCount")
163
+ });
164
+ sendJson(res, 200, page);
165
+ } catch (error) {
166
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
167
+ }
168
+ return true;
169
+ }
170
+
171
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/status") {
172
+ requireSurfaceCapability(req, url, "read_room");
173
+ sendJson(res, 200, buildBridgeStatus());
174
+ return true;
175
+ }
176
+
177
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/changes") {
178
+ try {
179
+ requireSurfaceCapability(req, url, "read_room");
180
+ const targetCwd = url.searchParams.get("cwd") || liveState.selectedProjectCwd || cwd;
181
+ const threadId = url.searchParams.get("threadId") || liveState.selectedThreadId || null;
182
+ const selectedThread = liveState.selectedThreadSnapshot?.thread || null;
183
+ const liveTurnPayload =
184
+ threadId &&
185
+ liveState.turnDiff?.threadId === threadId &&
186
+ liveState.turnDiff?.diff
187
+ ? buildLiveTurnChanges({
188
+ cwd: targetCwd,
189
+ diff: liveState.turnDiff.diff,
190
+ threadId,
191
+ turnId: liveState.turnDiff.turnId || null
192
+ })
193
+ : null;
194
+ const threadPath =
195
+ threadId && selectedThread?.id === threadId
196
+ ? selectedThread.path || null
197
+ : null;
198
+ const payload =
199
+ liveTurnPayload && liveTurnPayload.items.length
200
+ ? liveTurnPayload
201
+ : await getCachedRepoChanges(targetCwd, { threadPath });
202
+ sendJson(res, 200, payload);
203
+ } catch (error) {
204
+ sendJson(res, errorStatusCode(error, 500), { error: error.message });
205
+ }
206
+ return true;
207
+ }
208
+
209
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/refresh") {
210
+ try {
211
+ requireSurfaceCapability(req, url, "refresh_room");
212
+ const includeThreads = url.searchParams.get("threads") !== "0";
213
+ const payload = await refreshLiveState({ includeThreads });
214
+ sendJson(res, 200, { ok: true, state: payload });
215
+ } catch (error) {
216
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, status: buildBridgeStatus() });
217
+ }
218
+ return true;
219
+ }
220
+
221
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/reconnect") {
222
+ try {
223
+ requireSurfaceCapability(req, url, "refresh_room");
224
+ const includeThreads = url.searchParams.get("threads") !== "0";
225
+ await restartWatcher();
226
+ const payload = await refreshLiveState({ includeThreads });
227
+ sendJson(res, 200, { ok: true, state: payload });
228
+ } catch (error) {
229
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, status: buildBridgeStatus() });
230
+ }
231
+ return true;
232
+ }
233
+
234
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/open-in-codex") {
235
+ try {
236
+ requireSurfaceCapability(req, url, "open_in_codex");
237
+ const body = await readJsonBody(req);
238
+ const threadId = body.threadId || liveState.selectedThreadId || null;
239
+ if (!threadId) {
240
+ sendJson(res, 400, { error: "threadId is required" });
241
+ return true;
242
+ }
243
+
244
+ const payload = await openThreadInCodex(threadId);
245
+ sendJson(res, 200, {
246
+ ok: true,
247
+ ...payload,
248
+ message:
249
+ "Revealed this thread in Codex. Quit and reopen the Codex app manually to see new messages generated here."
250
+ });
251
+ } catch (error) {
252
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, status: buildBridgeStatus() });
253
+ }
254
+ return true;
255
+ }
256
+
257
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/selection") {
258
+ try {
259
+ const access = requireSurfaceCapability(req, url, "select_room");
260
+ const body = await readJsonBody(req);
261
+ const payload = await setSelection({
262
+ clientId: accessClientId(access),
263
+ cwd: body.cwd || null,
264
+ source: access.surface,
265
+ threadId: body.threadId || null
266
+ });
267
+ sendJson(res, 200, payload);
268
+ } catch (error) {
269
+ sendJson(res, errorStatusCode(error, 409), { error: error.message, state: buildLivePayload() });
270
+ }
271
+ return true;
272
+ }
273
+
274
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/presence") {
275
+ try {
276
+ const access = requireSurfaceCapability(req, url, "sync_presence");
277
+ const body = await readJsonBody(req);
278
+ const changed = applySurfacePresenceUpdate(
279
+ {
280
+ ...body,
281
+ clientId: accessClientId(access),
282
+ surface: access.surface
283
+ },
284
+ {
285
+ now: Date.now(),
286
+ selectedThreadId: liveState.selectedThreadId || ""
287
+ }
288
+ );
289
+
290
+ if (changed) {
291
+ broadcast("live", buildLivePayload());
292
+ }
293
+ sendJson(res, 200, { ok: true });
294
+ } catch (error) {
295
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
296
+ }
297
+ return true;
298
+ }
299
+
300
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/control") {
301
+ try {
302
+ const body = await readJsonBody(req);
303
+ const threadId = body.threadId || liveState.selectedThreadId || null;
304
+ const action = body.action || "claim";
305
+ const access = resolveSurfaceAccess(req, url);
306
+ if (!access) {
307
+ throw Object.assign(new Error("Dextunnel surface access is missing or expired."), {
308
+ statusCode: 403
309
+ });
310
+ }
311
+ const clientId = accessClientId(access);
312
+ const existingLease = getControlLeaseForThread(threadId);
313
+ const requiredCapability =
314
+ action === "release" && access.surface === "host"
315
+ ? "release_remote_control"
316
+ : "control_remote";
317
+ if (!access.capabilities.includes(requiredCapability)) {
318
+ throw Object.assign(
319
+ new Error(`${access.surface} surface is not allowed to ${requiredCapability.replaceAll("_", " ")}.`),
320
+ { statusCode: 403 }
321
+ );
322
+ }
323
+
324
+ const result = applyLiveControlAction({
325
+ action,
326
+ clientId,
327
+ existingLease,
328
+ now: Date.now(),
329
+ owner: access.surface,
330
+ reason: action === "claim" ? body.reason || "compose" : body.reason || null,
331
+ source: access.surface,
332
+ threadId,
333
+ ttlMs: CONTROL_LEASE_TTL_MS
334
+ });
335
+
336
+ liveState.controlLease = result.lease;
337
+ scheduleControlLeaseExpiry();
338
+ if (result.recordEvent && result.event) {
339
+ recordControlEvent(result.event);
340
+ }
341
+
342
+ liveState.lastError = null;
343
+ broadcast("live", buildLivePayload());
344
+ sendJson(res, 200, {
345
+ ok: true,
346
+ action,
347
+ state: buildLivePayload()
348
+ });
349
+ } catch (error) {
350
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
351
+ }
352
+ return true;
353
+ }
354
+
355
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/interaction") {
356
+ try {
357
+ const body = await readJsonBody(req);
358
+ const access = requireSurfaceCapability(req, url, "respond_interaction");
359
+ await resolvePendingInteraction({
360
+ ...body,
361
+ authorityClientId: accessClientId(access),
362
+ source: access.surface
363
+ });
364
+ sendJson(res, 200, { ok: true, state: buildLivePayload() });
365
+ } catch (error) {
366
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
367
+ }
368
+ return true;
369
+ }
370
+
371
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/companion") {
372
+ try {
373
+ requireSurfaceCapability(req, url, "use_companion");
374
+ const body = await readJsonBody(req);
375
+ const threadId = body.threadId || liveState.selectedThreadId || null;
376
+ const result =
377
+ body.action === "summon"
378
+ ? summonCompanionWakeup({
379
+ advisorId: body.advisorId || "",
380
+ threadId
381
+ })
382
+ : applyCompanionWakeupAction({
383
+ action: body.action,
384
+ threadId,
385
+ wakeKey: body.wakeKey
386
+ });
387
+ broadcast("live", buildLivePayload());
388
+ sendJson(res, 200, { ok: true, message: result.message, state: buildLivePayload() });
389
+ } catch (error) {
390
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
391
+ }
392
+ return true;
393
+ }
394
+
395
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/agent-room") {
396
+ try {
397
+ requireSurfaceCapability(req, url, "use_agent_room");
398
+ const body = await readJsonBody(req);
399
+ const threadId = body.threadId || liveState.selectedThreadId || null;
400
+ const result = await updateAgentRoom({
401
+ action: body.action || "",
402
+ memberIds: Array.isArray(body.memberIds) ? body.memberIds : null,
403
+ text: body.text || "",
404
+ threadId
405
+ });
406
+ broadcast("live", buildLivePayload());
407
+ sendJson(res, 200, { ok: true, message: result.message, state: buildLivePayload() });
408
+ } catch (error) {
409
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
410
+ }
411
+ return true;
412
+ }
413
+
414
+ if (req.method === "POST" && url.pathname === "/api/debug/live-interaction") {
415
+ if (!devToolsEnabled) {
416
+ sendJson(res, 404, { error: "Not found" });
417
+ return true;
418
+ }
419
+ try {
420
+ requireSurfaceCapability(req, url, "debug_tools");
421
+ const body = await readJsonBody(req);
422
+ const payload =
423
+ body.action === "clear"
424
+ ? clearDebugPendingInteraction()
425
+ : setDebugPendingInteraction(body.kind || "");
426
+ sendJson(res, 200, { ok: true, state: payload });
427
+ } catch (error) {
428
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
429
+ }
430
+ return true;
431
+ }
432
+
433
+ if (req.method === "POST" && url.pathname === "/api/debug/companion-wakeup") {
434
+ if (!devToolsEnabled) {
435
+ sendJson(res, 404, { error: "Not found" });
436
+ return true;
437
+ }
438
+ try {
439
+ requireSurfaceCapability(req, url, "debug_tools");
440
+ const body = await readJsonBody(req);
441
+ const payload = setDebugCompanionWakeup({
442
+ advisorId: body.advisorId || "",
443
+ threadId: body.threadId || liveState.selectedThreadId || null,
444
+ wakeKind: body.wakeKind || "summary"
445
+ });
446
+ broadcast("live", payload);
447
+ sendJson(res, 200, { ok: true, state: payload });
448
+ } catch (error) {
449
+ sendJson(res, errorStatusCode(error, 400), { error: error.message, state: buildLivePayload() });
450
+ }
451
+ return true;
452
+ }
453
+
454
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/interrupt") {
455
+ try {
456
+ requireSurfaceCapability(req, url, "control_remote");
457
+ const payload = await interruptSelectedThread();
458
+ sendJson(res, 200, payload);
459
+ } catch (error) {
460
+ sendJson(res, 400, { error: error.message, state: buildLivePayload() });
461
+ }
462
+ return true;
463
+ }
464
+
465
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/threads") {
466
+ try {
467
+ requireSurfaceCapability(req, url, "read_room");
468
+ await refreshThreads({ broadcastUpdate: false });
469
+ sendJson(res, 200, {
470
+ cwd: null,
471
+ data: liveState.threads
472
+ });
473
+ } catch (error) {
474
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, status: buildBridgeStatus() });
475
+ }
476
+ return true;
477
+ }
478
+
479
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/thread") {
480
+ try {
481
+ requireSurfaceCapability(req, url, "read_room");
482
+ const threadId = url.searchParams.get("threadId");
483
+ const limit = Number(url.searchParams.get("limit") || "40");
484
+
485
+ if (!threadId) {
486
+ sendJson(res, 400, { error: "threadId is required" });
487
+ return true;
488
+ }
489
+
490
+ const normalizedLimit = Number.isFinite(limit) && limit > 0 ? limit : null;
491
+ const thread = await codexAppServer.readThread(threadId, false);
492
+ const snapshot = thread
493
+ ? decorateSnapshot(
494
+ await buildSelectedThreadSnapshot(thread, {
495
+ limit: normalizedLimit
496
+ })
497
+ )
498
+ : null;
499
+
500
+ sendJson(res, 200, {
501
+ threadId,
502
+ found: Boolean(thread),
503
+ snapshot
504
+ });
505
+ } catch (error) {
506
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, status: buildBridgeStatus() });
507
+ }
508
+ return true;
509
+ }
510
+
511
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/thread") {
512
+ try {
513
+ const access = requireSurfaceCapability(req, url, "select_room");
514
+ const body = await readJsonBody(req);
515
+ const payload = await createThreadSelection({
516
+ clientId: accessClientId(access),
517
+ cwd: body.cwd || null,
518
+ source: access.surface
519
+ });
520
+ sendJson(res, 200, payload);
521
+ } catch (error) {
522
+ sendJson(res, errorStatusCode(error, 409), { error: error.message, state: buildLivePayload() });
523
+ }
524
+ return true;
525
+ }
526
+
527
+ if (req.method === "GET" && url.pathname === "/api/codex-app-server/latest-thread") {
528
+ try {
529
+ requireSurfaceCapability(req, url, "read_room");
530
+ const targetCwd = url.searchParams.get("cwd") || cwd;
531
+ const limit = Number(url.searchParams.get("limit") || "40");
532
+ const thread = await codexAppServer.getLatestThreadForCwd(targetCwd);
533
+ sendJson(res, 200, {
534
+ cwd: targetCwd,
535
+ found: Boolean(thread),
536
+ snapshot: thread
537
+ ? decorateSnapshot(
538
+ mapThreadToCompanionSnapshot(thread, {
539
+ limit: Number.isFinite(limit) && limit > 0 ? limit : null
540
+ })
541
+ )
542
+ : null
543
+ });
544
+ } catch (error) {
545
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, status: buildBridgeStatus() });
546
+ }
547
+ return true;
548
+ }
549
+
550
+ if (req.method === "POST" && url.pathname === "/api/codex-app-server/turn") {
551
+ let body = {};
552
+ let targetThreadId = null;
553
+ let selectedThreadIdBeforeSend = liveState.selectedThreadId || null;
554
+ try {
555
+ body = await readJsonBody(req);
556
+ const access = requireSurfaceCapability(req, url, "send_turn");
557
+ targetThreadId = body.threadId || liveState.selectedThreadId || null;
558
+ const targetCwd = body.cwd || liveState.selectedProjectCwd || cwd;
559
+ const attachments = await persistImageAttachments(body.attachments);
560
+
561
+ if (!targetThreadId && body.createThreadIfMissing === false) {
562
+ throw new Error("No selected live Codex thread is available.");
563
+ }
564
+
565
+ if (
566
+ liveState.writeLock &&
567
+ liveState.writeLock.threadId === targetThreadId &&
568
+ liveState.writeLock.status === "pending"
569
+ ) {
570
+ throw new Error("A live send is already in progress for this session.");
571
+ }
572
+
573
+ if (liveState.pendingInteraction) {
574
+ throw new Error("Resolve the pending interaction before sending another message.");
575
+ }
576
+
577
+ if (targetThreadId && access.capabilities.includes("control_remote")) {
578
+ ensureRemoteControlLease(targetThreadId, access.surface, accessClientId(access));
579
+ }
580
+
581
+ liveState.writeLock = {
582
+ at: nowIso(),
583
+ preview:
584
+ String(body.text || "").slice(0, 140) ||
585
+ (attachments.length
586
+ ? `[${attachments.length} image attachment${attachments.length === 1 ? "" : "s"}]`
587
+ : ""),
588
+ source: access.surface,
589
+ status: "pending",
590
+ threadId: targetThreadId
591
+ };
592
+ broadcast("live", buildLivePayload());
593
+
594
+ const result = await codexAppServer.sendText({
595
+ threadId: targetThreadId,
596
+ cwd: targetCwd,
597
+ text: body.text || "",
598
+ attachments,
599
+ createThreadIfMissing: body.createThreadIfMissing !== false,
600
+ waitForCompletion: false,
601
+ timeoutMs: Number(body.timeoutMs || 45000)
602
+ });
603
+
604
+ rememberTurnOrigin(result.thread.id, result.turn.id, access.surface);
605
+ invalidateRepoChangesCache({
606
+ cwd: result.thread.cwd || targetCwd,
607
+ threadPath: result.thread.path || ""
608
+ });
609
+ const selectionStillTargetsSendThread =
610
+ !liveState.selectedThreadId ||
611
+ liveState.selectedThreadId === targetThreadId ||
612
+ liveState.selectedThreadId === result.thread.id;
613
+
614
+ if (selectionStillTargetsSendThread) {
615
+ liveState.selectedThreadId = result.thread.id;
616
+ liveState.selectedProjectCwd = result.thread.cwd || targetCwd;
617
+ liveState.selectedThreadSnapshot = mergeSelectedThreadSnapshot(
618
+ liveState.selectedThreadSnapshot,
619
+ result.snapshot
620
+ );
621
+ }
622
+ liveState.lastSyncAt = nowIso();
623
+ liveState.writeLock = null;
624
+ appServerState.lastWrite = {
625
+ at: nowIso(),
626
+ mode: result.mode,
627
+ source: access.surface,
628
+ threadId: result.thread.id,
629
+ turnId: result.turn.id,
630
+ turnStatus: result.turn.status
631
+ };
632
+ const responseSnapshot =
633
+ selectionStillTargetsSendThread && liveState.selectedThreadSnapshot
634
+ ? liveState.selectedThreadSnapshot
635
+ : result.snapshot;
636
+ const decoratedSnapshot = decorateSnapshot(responseSnapshot);
637
+ broadcast("live", buildLivePayload());
638
+ sendJson(res, 200, {
639
+ ok: true,
640
+ mode: result.mode,
641
+ snapshot: decoratedSnapshot,
642
+ thread: summarizeResponseThread(decoratedSnapshot?.thread || result.thread),
643
+ turn: summarizeResponseTurn(result.turn)
644
+ });
645
+ void (async () => {
646
+ try {
647
+ await refreshThreads({ broadcastUpdate: false });
648
+ const shouldRestartWatcher =
649
+ liveState.selectedThreadId !== selectedThreadIdBeforeSend ||
650
+ (selectionStillTargetsSendThread && liveState.selectedThreadId !== result.thread.id) ||
651
+ !liveState.watcherConnected ||
652
+ !hasWatcherController();
653
+ if (shouldRestartWatcher) {
654
+ await restartWatcher();
655
+ } else {
656
+ scheduleSnapshotRefresh(120);
657
+ }
658
+ broadcast("live", buildLivePayload());
659
+ } catch (error) {
660
+ liveState.lastError = error.message;
661
+ broadcast("live", buildLivePayload());
662
+ }
663
+ })();
664
+ } catch (error) {
665
+ liveState.writeLock = null;
666
+ appServerState.lastWrite = {
667
+ at: nowIso(),
668
+ error: error.message,
669
+ source: "remote",
670
+ threadId: targetThreadId || null
671
+ };
672
+ const state = buildLivePayload();
673
+ const statusCode = errorStatusCode(
674
+ error,
675
+ /already in progress|pending interaction|holds control|take control/i.test(String(error.message || ""))
676
+ ? 409
677
+ : 500
678
+ );
679
+ broadcast("live", state);
680
+ sendJson(res, statusCode, {
681
+ error: error.message,
682
+ state,
683
+ status: state.status
684
+ });
685
+ }
686
+ return true;
687
+ }
688
+
689
+ if (req.method === "GET" && url.pathname === "/api/stream") {
690
+ requireSurfaceCapability(req, url, "read_room");
691
+ streamState(req, res);
692
+ return true;
693
+ }
694
+
695
+ if (req.method === "POST" && url.pathname === "/api/commands") {
696
+ if (!devToolsEnabled || !mockAdapter) {
697
+ sendJson(res, 404, { error: "Not found" });
698
+ return true;
699
+ }
700
+ try {
701
+ requireSurfaceCapability(req, url, "debug_tools");
702
+ const command = await readJsonBody(req);
703
+ const snapshot = store.applyCommand(command);
704
+ mockAdapter.scheduleFollowUp(command);
705
+ sendJson(res, 200, snapshot);
706
+ } catch (error) {
707
+ sendJson(res, errorStatusCode(error, 400), {
708
+ error: error.message
709
+ });
710
+ }
711
+ return true;
712
+ }
713
+
714
+ return false;
715
+ } catch (error) {
716
+ sendJson(res, errorStatusCode(error, 500), { error: error.message, state: buildLivePayload() });
717
+ return true;
718
+ }
719
+ }