mesurer-solid 0.2.0-beta.11 → 0.2.0-beta.13

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.
@@ -1,2037 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { spawn } from "node:child_process";
4
- import { createHash, randomUUID } from "node:crypto";
5
- import { createServer } from "node:http";
6
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
7
- import { homedir } from "node:os";
8
- import { join } from "node:path";
9
- import { parseArgs } from "node:util";
10
-
11
- const DEFAULT_PORT = 47365;
12
-
13
- const DEFAULT_BRIDGE = `http://127.0.0.1:${DEFAULT_PORT}`;
14
-
15
- const BRIDGE_NAME = "mesurer-codex";
16
-
17
- const BRIDGE_PROTOCOL_VERSION = 1;
18
-
19
- const BRIDGE_SOURCE_HASH = createHash("sha256")
20
- .update(await readFile(new URL(import.meta.url)))
21
- .digest("hex");
22
-
23
- const BRIDGE_IDENTITY = Object.freeze({
24
- name: BRIDGE_NAME,
25
- protocol: BRIDGE_PROTOCOL_VERSION,
26
- sourceHash: BRIDGE_SOURCE_HASH,
27
- pid: process.pid,
28
- canShutdown: true,
29
- });
30
-
31
- const MAX_MESSAGE_BYTES = 64 * 1024;
32
-
33
- const CODEX_TIMEOUT_MS = 30_000;
34
-
35
- const APP_SERVER_TIMEOUT_MS = 5_000;
36
-
37
- const DAEMON_RESUME_TIMEOUT_MS = 10_000;
38
-
39
- const DAEMON_START_TIMEOUT_MS = 15_000;
40
-
41
- const DELIVERY_STATE_VERSION = 1;
42
-
43
- const MAX_DISCOVERED_THREADS = 10;
44
-
45
- const MAX_DELIVERIES = 100;
46
-
47
- const TERMINAL_DELIVERY_TTL_MS = 10 * 60_000;
48
-
49
- const DELIVERY_TTL_MS = 2 * 60 * 60_000;
50
-
51
- const DESKTOP_LIFECYCLE_POLL_MS = 1_000;
52
-
53
- const DESKTOP_TURN_HISTORY_LIMIT = 10;
54
-
55
- const DELIVERY_TURN_START_SKEW_MS = 60_000;
56
-
57
- const usage = `Usage:
58
- mesurer-codex [--thread <session-id-or-name>] [options]
59
- mesurer-codex --register-current [--bridge <url>]
60
- mesurer-codex --register <session-id-or-name> [--bridge <url>]
61
-
62
- Server options:
63
- --thread <value> Initial Codex session UUID or exact session name.
64
- Defaults to CODEX_THREAD_ID when launched by Codex.
65
- --cwd <path> Project directory used to scope recent-thread discovery.
66
- Defaults to the current working directory.
67
- --port <number> Loopback port (default: ${DEFAULT_PORT}; use 0 for any free port)
68
- --origin <origin> Additional allowed browser Origin. Repeatable.
69
- --codex <path> Codex executable (default: CODEX_BIN or codex)
70
- --once Exit after one successful queued message
71
-
72
- Thread registration:
73
- --register-current Register CODEX_THREAD_ID with a running bridge and make it active.
74
- --register <value> Register a specific existing/newly-created Codex thread and make it active.
75
- --bridge <url> Running bridge URL for registration (default: ${DEFAULT_BRIDGE})
76
-
77
- Other:
78
- --help Show this help
79
-
80
- Loopback browser origins such as http://localhost:* and http://127.0.0.1:* are allowed by default.
81
- For file:// or Electron pages, pass --origin null explicitly.
82
- Browser pages may send to locally registered threads and same-project recent threads returned by Codex app-server discovery.
83
- `;
84
-
85
- const { values } = parseArgs({
86
- options: {
87
- thread: { type: "string" },
88
- cwd: { type: "string" },
89
- port: { type: "string", default: String(DEFAULT_PORT) },
90
- origin: { type: "string", multiple: true },
91
- codex: { type: "string" },
92
- once: { type: "boolean", default: false },
93
- register: { type: "string" },
94
- "register-current": { type: "boolean", default: false },
95
- bridge: { type: "string", default: DEFAULT_BRIDGE },
96
- help: { type: "boolean", default: false },
97
- },
98
- allowPositionals: false,
99
- strict: true,
100
- });
101
-
102
- if (values.help) {
103
- process.stdout.write(usage);
104
- process.exit(0);
105
- }
106
-
107
- const normalizeThread = (value) => value?.trim() || null;
108
-
109
- const normalizeCwd = (value) => value?.trim() || null;
110
-
111
- const envThread = normalizeThread(process.env.CODEX_THREAD_ID);
112
-
113
- const requestedRegistration = normalizeThread(values.register);
114
-
115
- const registerCurrent = values["register-current"];
116
-
117
- if (requestedRegistration && registerCurrent) {
118
- process.stderr.write("Use either --register or --register-current, not both.\n");
119
- process.exit(2);
120
- }
121
-
122
- const registrationThread = registerCurrent ? envThread : requestedRegistration;
123
-
124
- if (registerCurrent && !registrationThread) {
125
- process.stderr.write("--register-current requires CODEX_THREAD_ID. Run it from a Codex shell/tool command or use --register <thread>.\n");
126
- process.exit(2);
127
- }
128
-
129
- if (registrationThread) {
130
- const bridge = values.bridge?.trim() || DEFAULT_BRIDGE;
131
- const cwd = normalizeCwd(values.cwd) ?? process.cwd();
132
- let response;
133
-
134
- try {
135
- response = await fetch(new URL("threads/register", bridge.endsWith("/") ? bridge : `${bridge}/`), {
136
- method: "POST",
137
- headers: { "Content-Type": "application/json" },
138
- body: JSON.stringify({ thread: registrationThread, cwd }),
139
- });
140
- } catch (cause) {
141
- const error = cause instanceof Error ? cause.message : String(cause);
142
- process.stderr.write(`Could not reach Mesurer Codex bridge at ${bridge}: ${error}\n`);
143
- process.exit(1);
144
- }
145
-
146
- const text = await response.text();
147
- let payload = {};
148
-
149
- if (text) {
150
- try {
151
- payload = JSON.parse(text);
152
- } catch {
153
- payload = { error: text };
154
- }
155
- }
156
-
157
- if (!response.ok || payload.ok === false) {
158
- process.stderr.write(`${payload.error || `Mesurer Codex bridge returned HTTP ${response.status}.`}\n`);
159
- process.exit(1);
160
- }
161
-
162
- console.log(`Registered Codex thread: ${registrationThread}`);
163
- console.log(`BRIDGE_THREAD=${registrationThread}`);
164
- process.exit(0);
165
- }
166
-
167
- const parsedPort = Number(values.port);
168
-
169
- if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65_535) {
170
- process.stderr.write(`Invalid --port: ${values.port}\n`);
171
- process.exit(2);
172
- }
173
-
174
- const initialThread = normalizeThread(values.thread) ?? envThread;
175
-
176
- const initialCwd = initialThread ? (normalizeCwd(values.cwd) ?? process.cwd()) : null;
177
-
178
- const initialAppToolsPipe = normalizeThread(process.env.CODEX_APP_TOOLS_PIPE_PATH);
179
-
180
- const initialCodexHome = normalizeCwd(process.env.CODEX_HOME);
181
-
182
- const codexBin = values.codex?.trim() || process.env.CODEX_BIN?.trim() || "codex";
183
-
184
- const additionalOrigins = new Set(values.origin ?? []);
185
-
186
- const registeredThreads = new Map();
187
-
188
- const discoveredThreads = new Map();
189
-
190
- const deliveries = new Map();
191
-
192
- const terminalTurnEvents = new Map();
193
-
194
- const desktopDispatchTimers = new Map();
195
-
196
- const desktopLifecycleChecks = new Map();
197
-
198
- const desktopLifecycleLastCheckedAt = new Map();
199
-
200
- let activeThread = null;
201
-
202
- let activeCwd = null;
203
-
204
- const registerThread = (thread, cwd, registration = {}) => {
205
- const previous = registeredThreads.get(thread);
206
- const nextCwd = normalizeCwd(cwd) ?? previous?.cwd ?? null;
207
- const appToolsPipe = normalizeThread(registration.appToolsPipe) ?? previous?.appToolsPipe ?? null;
208
- const codexHome = normalizeCwd(registration.codexHome) ?? previous?.codexHome ?? null;
209
- registeredThreads.set(thread, {
210
- id: thread,
211
- cwd: nextCwd,
212
- appToolsPipe,
213
- codexHome,
214
- seenAt: Date.now(),
215
- });
216
- activeThread = thread;
217
-
218
- if (nextCwd) activeCwd = nextCwd;
219
- };
220
-
221
- if (initialThread) {
222
- registerThread(initialThread, initialCwd, {
223
- appToolsPipe: initialAppToolsPipe,
224
- codexHome: initialCodexHome,
225
- });
226
- }
227
-
228
- const isLoopbackOrigin = (origin) => {
229
- try {
230
- const url = new URL(origin);
231
-
232
- return url.hostname === "localhost"
233
- || url.hostname === "127.0.0.1"
234
- || url.hostname === "::1"
235
- || url.hostname === "[::1]";
236
- } catch {
237
- return false;
238
- }
239
- };
240
-
241
- const originAllowed = (origin) => {
242
- if (!origin) return true;
243
-
244
- return additionalOrigins.has(origin) || isLoopbackOrigin(origin);
245
- };
246
-
247
- const corsHeaders = (origin) => origin && originAllowed(origin)
248
- ? {
249
- "Access-Control-Allow-Origin": origin,
250
- "Access-Control-Allow-Headers": "Content-Type",
251
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
252
- "Vary": "Origin",
253
- }
254
- : {};
255
-
256
- const writeJson = (response, status, payload, origin) => {
257
- response.writeHead(status, {
258
- "Content-Type": "application/json; charset=utf-8",
259
- "Cache-Control": "no-store",
260
- ...corsHeaders(origin),
261
- });
262
- response.end(`${JSON.stringify(payload)}\n`);
263
- };
264
-
265
- const readJsonBody = async (request) => {
266
- const chunks = [];
267
- let bytes = 0;
268
-
269
- for await (const chunk of request) {
270
- bytes += chunk.length;
271
-
272
- if (bytes > MAX_MESSAGE_BYTES) throw new Error("Mesurer Codex message is too large.");
273
- chunks.push(chunk);
274
- }
275
-
276
- const text = Buffer.concat(chunks).toString("utf8");
277
-
278
- if (!text) return {};
279
-
280
- return JSON.parse(text);
281
- };
282
-
283
- const defaultCodexHome = normalizeCwd(process.env.CODEX_HOME) ?? join(homedir(), ".codex");
284
-
285
- const deliveryStatePath = join(defaultCodexHome, "mesurer", "codex-deliveries.json");
286
-
287
- let deliveryStateWrite = Promise.resolve();
288
-
289
- const persistedDelivery = (delivery) => ({
290
- id: delivery.id,
291
- thread: delivery.thread,
292
- message: delivery.message,
293
- messageHash: delivery.messageHash,
294
- transport: delivery.transport,
295
- status: delivery.status,
296
- turnId: delivery.turnId,
297
- queuedSubmissionId: delivery.queuedSubmissionId,
298
- dispatch: delivery.dispatch,
299
- dispatchError: delivery.dispatchError,
300
- createdAt: delivery.createdAt,
301
- updatedAt: delivery.updatedAt,
302
- });
303
-
304
- const persistDeliveryState = () => {
305
- const persistedDeliveries = [];
306
-
307
- for (const delivery of deliveries.values()) {
308
- if (delivery.transport === "desktop-app") {
309
- persistedDeliveries.push(persistedDelivery(delivery));
310
- }
311
- }
312
-
313
- const state = {
314
- version: DELIVERY_STATE_VERSION,
315
- deliveries: persistedDeliveries,
316
- };
317
-
318
- deliveryStateWrite = deliveryStateWrite.then(async () => {
319
- const directory = join(defaultCodexHome, "mesurer");
320
- await mkdir(directory, { recursive: true });
321
- const temporaryPath = `${deliveryStatePath}.${process.pid}.tmp`;
322
- await writeFile(temporaryPath, `${JSON.stringify(state)}\n`, "utf8");
323
- await rename(temporaryPath, deliveryStatePath);
324
- });
325
-
326
- return deliveryStateWrite;
327
- };
328
-
329
- const persistDeliveryStateSoon = () => {
330
- void persistDeliveryState().catch((cause) => {
331
- const error = cause instanceof Error ? cause.message : String(cause);
332
- process.stderr.write(`Mesurer Codex bridge could not persist delivery state: ${error}\n`);
333
- });
334
- };
335
-
336
- const loadDeliveryState = async () => {
337
- let text;
338
-
339
- try {
340
- text = await readFile(deliveryStatePath, "utf8");
341
- } catch (cause) {
342
- if (cause?.code === "ENOENT") return;
343
- throw cause;
344
- }
345
-
346
- const state = JSON.parse(text);
347
-
348
- if (state?.version !== DELIVERY_STATE_VERSION || !Array.isArray(state.deliveries)) return;
349
- const now = Date.now();
350
-
351
- for (const value of state.deliveries) {
352
- const id = normalizeThread(value?.id);
353
- const thread = normalizeThread(value?.thread);
354
- const message = value?.message == null ? "" : String(value.message);
355
- const transport = value?.transport === "desktop-app" ? "desktop-app" : "codex-queue";
356
- const status = value?.status;
357
- const createdAt = Number(value?.createdAt);
358
- const updatedAt = Number(value?.updatedAt);
359
-
360
- if (!id || !thread || !message.trim()) continue;
361
-
362
- if (!["queued", "working", "completed", "interrupted"].includes(status)) continue;
363
-
364
- if (!Number.isFinite(createdAt) || !Number.isFinite(updatedAt)) continue;
365
- const terminal = status === "completed" || status === "interrupted";
366
-
367
- if (now - updatedAt > (terminal ? TERMINAL_DELIVERY_TTL_MS : DELIVERY_TTL_MS)) continue;
368
- deliveries.set(id, {
369
- id,
370
- thread,
371
- message,
372
- messageHash: normalizeThread(value?.messageHash) ?? createHash("sha256").update(message).digest("hex"),
373
- transport,
374
- status,
375
- turnId: normalizeThread(value?.turnId),
376
- queuedSubmissionId: normalizeThread(value?.queuedSubmissionId),
377
- dispatch: normalizeThread(value?.dispatch) ?? "persisted",
378
- dispatchError: normalizeThread(value?.dispatchError),
379
- createdAt,
380
- updatedAt,
381
- });
382
- }
383
- };
384
-
385
- const queuedSubmissionFromOutput = (output, thread) => {
386
- const match = output.match(/Queued message (\S+) for thread (\S+)\.?/);
387
-
388
- if (!match) return null;
389
- const queuedThread = match[2].replace(/\.$/, "");
390
-
391
- return queuedThread === thread ? match[1] : null;
392
- };
393
-
394
- const codexControlSocketPath = () => {
395
- const codexHome = process.env.CODEX_HOME?.trim() || join(homedir(), ".codex");
396
-
397
- return join(codexHome, "app-server-control", "app-server-control.sock");
398
- };
399
-
400
- const runCodexDaemonStart = () => new Promise((resolve, reject) => {
401
- const child = spawn(codexBin, ["app-server", "daemon", "start"], {
402
- env: process.env,
403
- shell: false,
404
- stdio: ["ignore", "pipe", "pipe"],
405
- windowsHide: true,
406
- });
407
-
408
- let stdout = "";
409
- let stderr = "";
410
- let settled = false;
411
- let timeout;
412
-
413
- const finish = (error, output) => {
414
- if (settled) return;
415
- settled = true;
416
-
417
- if (timeout) clearTimeout(timeout);
418
-
419
- if (error) reject(error);
420
- else resolve(output);
421
- };
422
-
423
- child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
424
- child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
425
- child.on("error", (error) => finish(error));
426
- child.on("close", (code, signal) => {
427
- if (code === 0) {
428
- finish(null, stdout.trim());
429
-
430
- return;
431
- }
432
-
433
- const detail = stderr.trim() || stdout.trim() || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
434
- finish(new Error(`codex app-server daemon start failed: ${detail}`));
435
- });
436
-
437
- timeout = setTimeout(() => {
438
- child.kill("SIGTERM");
439
- finish(new Error(`codex app-server daemon start timed out after ${DAEMON_START_TIMEOUT_MS}ms.`));
440
- }, DAEMON_START_TIMEOUT_MS);
441
- });
442
-
443
- const missingDaemonSocket = (cause) => {
444
- if (!(cause instanceof Error)) return false;
445
-
446
- return cause.message.includes("No such file or directory")
447
- || cause.message.includes("ENOENT")
448
- || cause.message.includes("os error 2");
449
- };
450
-
451
- const resumeColdCodexThreadViaDaemon = (thread) => new Promise((resolve, reject) => {
452
- if (process.platform === "win32") {
453
- resolve({ action: "unsupported", threadStatus: null });
454
-
455
- return;
456
- }
457
-
458
- const child = spawn(codexBin, ["stdio-to-uds", codexControlSocketPath()], {
459
- env: process.env,
460
- shell: false,
461
- stdio: ["pipe", "pipe", "pipe"],
462
- windowsHide: true,
463
- });
464
-
465
- let stdoutBuffer = "";
466
- let stderr = "";
467
- let settled = false;
468
- let timeout;
469
-
470
- const finish = (error, result) => {
471
- if (settled) return;
472
- settled = true;
473
-
474
- if (timeout) clearTimeout(timeout);
475
-
476
- if (child.exitCode === null) child.kill("SIGTERM");
477
-
478
- if (error) reject(error);
479
- else resolve(result);
480
- };
481
-
482
- const send = (message) => {
483
- child.stdin.write(`${JSON.stringify(message)}\n`);
484
- };
485
-
486
- const handleMessage = (message) => {
487
- if (message?.id === "mesurer-daemon-init") {
488
- if (message.error) {
489
- finish(new Error(message.error.message || "Codex daemon initialize failed."));
490
-
491
- return;
492
- }
493
-
494
- send({ method: "initialized" });
495
- send({
496
- id: "mesurer-thread-read",
497
- method: "thread/read",
498
- params: {
499
- threadId: thread,
500
- includeTurns: false,
501
- },
502
- });
503
-
504
- return;
505
- }
506
-
507
- if (message?.id === "mesurer-thread-read") {
508
- if (message.error) {
509
- finish(new Error(message.error.message || "Codex daemon thread/read failed."));
510
-
511
- return;
512
- }
513
-
514
- const threadStatus = message.result?.thread?.status?.type ?? null;
515
-
516
- if (threadStatus !== "notLoaded") {
517
- finish(null, { action: "already-loaded", threadStatus });
518
-
519
- return;
520
- }
521
-
522
- send({
523
- id: "mesurer-thread-resume",
524
- method: "thread/resume",
525
- params: { threadId: thread },
526
- });
527
-
528
- return;
529
- }
530
-
531
- if (message?.id === "mesurer-thread-resume") {
532
- if (message.error) {
533
- finish(new Error(message.error.message || "Codex daemon thread/resume failed."));
534
-
535
- return;
536
- }
537
-
538
- finish(null, { action: "resumed", threadStatus: "notLoaded" });
539
- }
540
- };
541
-
542
- child.stdout.on("data", (chunk) => {
543
- stdoutBuffer += chunk.toString();
544
-
545
- while (true) {
546
- const newline = stdoutBuffer.indexOf("\n");
547
-
548
- if (newline < 0) break;
549
- const line = stdoutBuffer.slice(0, newline).trim();
550
- stdoutBuffer = stdoutBuffer.slice(newline + 1);
551
-
552
- if (!line) continue;
553
-
554
- try {
555
- handleMessage(JSON.parse(line));
556
- } catch {
557
- // The relay carries app-server JSONL. Ignore unrelated stdout defensively.
558
- }
559
- }
560
- });
561
- child.stderr.on("data", (chunk) => {
562
- stderr = `${stderr}${chunk.toString()}`.slice(-8_192);
563
- });
564
- child.on("error", (error) => finish(error));
565
- child.on("close", (code, signal) => {
566
- if (settled) return;
567
- const detail = stderr.trim() || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
568
- finish(new Error(`Codex daemon relay exited before thread resume check completed: ${detail}`));
569
- });
570
-
571
- timeout = setTimeout(() => {
572
- child.kill("SIGTERM");
573
- finish(new Error(`Codex daemon resume check timed out after ${DAEMON_RESUME_TIMEOUT_MS}ms.`));
574
- }, DAEMON_RESUME_TIMEOUT_MS);
575
-
576
- send({
577
- id: "mesurer-daemon-init",
578
- method: "initialize",
579
- params: {
580
- clientInfo: {
581
- name: "mesurer-solid",
582
- title: "Mesurer Solid",
583
- version: "1",
584
- },
585
- capabilities: {
586
- experimentalApi: false,
587
- },
588
- },
589
- });
590
- });
591
-
592
- const resumeColdCodexThread = async (thread) => {
593
- try {
594
- return await resumeColdCodexThreadViaDaemon(thread);
595
- } catch (cause) {
596
- if (!missingDaemonSocket(cause)) throw cause;
597
- await runCodexDaemonStart();
598
-
599
- return resumeColdCodexThreadViaDaemon(thread);
600
- }
601
- };
602
-
603
- const runCodexQueue = (thread, message) => new Promise((resolve, reject) => {
604
- const child = spawn(codexBin, ["queue", "--thread", thread, "--message", message], {
605
- env: process.env,
606
- shell: false,
607
- stdio: ["ignore", "pipe", "pipe"],
608
- windowsHide: true,
609
- });
610
-
611
- let stdout = "";
612
- let stderr = "";
613
- let settled = false;
614
- let timeout;
615
-
616
- const finish = (error, output) => {
617
- if (settled) return;
618
- settled = true;
619
-
620
- if (timeout) clearTimeout(timeout);
621
-
622
- if (error) reject(error);
623
- else resolve(output);
624
- };
625
-
626
- child.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
627
- child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
628
- child.on("error", (error) => finish(error));
629
- child.on("close", (code, signal) => {
630
- if (code === 0) {
631
- finish(null, stdout.trim());
632
-
633
- return;
634
- }
635
-
636
- const detail = stderr.trim() || stdout.trim() || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
637
- finish(new Error(`codex queue failed: ${detail}`));
638
- });
639
-
640
- timeout = setTimeout(() => {
641
- child.kill("SIGTERM");
642
- finish(new Error(`codex queue timed out after ${CODEX_TIMEOUT_MS}ms.`));
643
- }, CODEX_TIMEOUT_MS);
644
- });
645
-
646
- const runCodexThreadList = (cwd, limit) => new Promise((resolve, reject) => {
647
- const child = spawn(codexBin, ["app-server", "--listen", "stdio://"], {
648
- env: process.env,
649
- shell: false,
650
- stdio: ["pipe", "pipe", "pipe"],
651
- windowsHide: true,
652
- });
653
-
654
- let stdoutBuffer = "";
655
- let stderr = "";
656
- let settled = false;
657
- let timeout;
658
-
659
- const finish = (error, result) => {
660
- if (settled) return;
661
- settled = true;
662
-
663
- if (timeout) clearTimeout(timeout);
664
-
665
- if (child.exitCode === null) child.kill("SIGTERM");
666
-
667
- if (error) reject(error);
668
- else resolve(result);
669
- };
670
-
671
- const send = (message) => {
672
- child.stdin.write(`${JSON.stringify(message)}\n`);
673
- };
674
-
675
- const handleMessage = (message) => {
676
- if (message?.id === "mesurer-init") {
677
- if (message.error) {
678
- finish(new Error(message.error.message || "Codex app-server initialize failed."));
679
-
680
- return;
681
- }
682
-
683
- send({ method: "initialized" });
684
- send({
685
- id: "mesurer-thread-list",
686
- method: "thread/list",
687
- params: {
688
- limit,
689
- sortKey: "recency_at",
690
- sortDirection: "desc",
691
- cwd,
692
- },
693
- });
694
-
695
- return;
696
- }
697
-
698
- if (message?.id === "mesurer-thread-list") {
699
- if (message.error) {
700
- finish(new Error(message.error.message || "Codex app-server thread/list failed."));
701
-
702
- return;
703
- }
704
-
705
- finish(null, message.result ?? {});
706
- }
707
- };
708
-
709
- child.stdout.on("data", (chunk) => {
710
- stdoutBuffer += chunk.toString();
711
-
712
- while (true) {
713
- const newline = stdoutBuffer.indexOf("\n");
714
-
715
- if (newline < 0) break;
716
- const line = stdoutBuffer.slice(0, newline).trim();
717
- stdoutBuffer = stdoutBuffer.slice(newline + 1);
718
-
719
- if (!line) continue;
720
-
721
- try {
722
- handleMessage(JSON.parse(line));
723
- } catch {
724
- // App-server protocol is JSONL; ignore unrelated stdout defensively.
725
- }
726
- }
727
- });
728
- child.stderr.on("data", (chunk) => {
729
- stderr = `${stderr}${chunk.toString()}`.slice(-8_192);
730
- });
731
- child.on("error", (error) => finish(error));
732
- child.on("close", (code, signal) => {
733
- if (settled) return;
734
- const detail = stderr.trim() || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
735
- finish(new Error(`Codex app-server exited before thread/list completed: ${detail}`));
736
- });
737
-
738
- timeout = setTimeout(() => {
739
- child.kill("SIGTERM");
740
- finish(new Error(`Codex app-server thread/list timed out after ${APP_SERVER_TIMEOUT_MS}ms.`));
741
- }, APP_SERVER_TIMEOUT_MS);
742
-
743
- send({
744
- id: "mesurer-init",
745
- method: "initialize",
746
- params: {
747
- clientInfo: {
748
- name: "mesurer-solid",
749
- title: "Mesurer Solid",
750
- version: "1",
751
- },
752
- capabilities: {
753
- experimentalApi: false,
754
- },
755
- },
756
- });
757
- });
758
-
759
- const runCodexQueueLookup = (thread, queuedSubmissionId = null) => new Promise((resolve, reject) => {
760
- const child = spawn(codexBin, ["app-server", "--listen", "stdio://"], {
761
- env: process.env,
762
- shell: false,
763
- stdio: ["pipe", "pipe", "pipe"],
764
- windowsHide: true,
765
- });
766
-
767
- let stdoutBuffer = "";
768
- let stderr = "";
769
- let settled = false;
770
- let timeout;
771
- let cursor = null;
772
- let page = 0;
773
-
774
- const finish = (error, result) => {
775
- if (settled) return;
776
- settled = true;
777
-
778
- if (timeout) clearTimeout(timeout);
779
-
780
- if (child.exitCode === null) child.kill("SIGTERM");
781
-
782
- if (error) reject(error);
783
- else resolve(result);
784
- };
785
-
786
- const send = (message) => {
787
- child.stdin.write(`${JSON.stringify(message)}\n`);
788
- };
789
-
790
- const requestPage = () => {
791
- page += 1;
792
-
793
- const params = {
794
- threadId: thread,
795
- limit: queuedSubmissionId ? 100 : 2,
796
- };
797
-
798
- if (cursor) params.cursor = cursor;
799
- send({
800
- id: `mesurer-queue-list-${page}`,
801
- method: "thread/queue/list",
802
- params,
803
- });
804
- };
805
-
806
- const handleMessage = (message) => {
807
- if (message?.id === "mesurer-queue-init") {
808
- if (message.error) {
809
- finish(new Error(message.error.message || "Codex app-server initialize failed."));
810
-
811
- return;
812
- }
813
-
814
- send({ method: "initialized" });
815
- requestPage();
816
-
817
- return;
818
- }
819
-
820
- const messageId = message?.id == null ? "" : String(message.id);
821
-
822
- if (!messageId.startsWith("mesurer-queue-list-")) return;
823
-
824
- if (message.error) {
825
- finish(new Error(message.error.message || "Codex app-server thread/queue/list failed."));
826
-
827
- return;
828
- }
829
-
830
- const data = Array.isArray(message.result?.data) ? message.result.data : [];
831
- const nextCursor = normalizeThread(message.result?.nextCursor);
832
-
833
- if (queuedSubmissionId) {
834
- const submission = data.find((candidate) => candidate?.id === queuedSubmissionId);
835
-
836
- if (submission) {
837
- finish(null, { submission, ambiguous: false });
838
-
839
- return;
840
- }
841
-
842
- if (nextCursor) {
843
- cursor = nextCursor;
844
- requestPage();
845
-
846
- return;
847
- }
848
-
849
- finish(null, { submission: null, ambiguous: false });
850
-
851
- return;
852
- }
853
-
854
- if (data.length === 1 && !nextCursor) {
855
- finish(null, { submission: data[0], ambiguous: false });
856
-
857
- return;
858
- }
859
-
860
- finish(null, {
861
- submission: null,
862
- ambiguous: data.length > 1 || Boolean(nextCursor),
863
- });
864
- };
865
-
866
- child.stdout.on("data", (chunk) => {
867
- stdoutBuffer += chunk.toString();
868
-
869
- while (true) {
870
- const newline = stdoutBuffer.indexOf("\n");
871
-
872
- if (newline < 0) break;
873
- const line = stdoutBuffer.slice(0, newline).trim();
874
- stdoutBuffer = stdoutBuffer.slice(newline + 1);
875
-
876
- if (!line) continue;
877
-
878
- try {
879
- handleMessage(JSON.parse(line));
880
- } catch {
881
- // App-server protocol is JSONL; ignore unrelated stdout defensively.
882
- }
883
- }
884
- });
885
- child.stderr.on("data", (chunk) => {
886
- stderr = `${stderr}${chunk.toString()}`.slice(-8_192);
887
- });
888
- child.on("error", (error) => finish(error));
889
- child.on("close", (code, signal) => {
890
- if (settled) return;
891
- const detail = stderr.trim() || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
892
- finish(new Error(`Codex app-server exited before thread/queue/list completed: ${detail}`));
893
- });
894
-
895
- timeout = setTimeout(() => {
896
- child.kill("SIGTERM");
897
- finish(new Error(`Codex app-server thread/queue/list timed out after ${APP_SERVER_TIMEOUT_MS}ms.`));
898
- }, APP_SERVER_TIMEOUT_MS);
899
-
900
- send({
901
- id: "mesurer-queue-init",
902
- method: "initialize",
903
- params: {
904
- clientInfo: {
905
- name: "mesurer-solid",
906
- title: "Mesurer Solid",
907
- version: "1",
908
- },
909
- capabilities: {
910
- experimentalApi: true,
911
- },
912
- },
913
- });
914
- });
915
-
916
- const queuedSubmissionMessage = (submission) => {
917
- if (!Array.isArray(submission?.input) || submission.input.length !== 1) return null;
918
- const input = submission.input[0];
919
- const text = String(input?.text ?? "");
920
-
921
- return input?.type === "text" && text.trim() ? text : null;
922
- };
923
-
924
- const runCodexTurnHistory = (thread) => new Promise((resolve, reject) => {
925
- const child = spawn(codexBin, ["app-server", "--listen", "stdio://"], {
926
- env: process.env,
927
- shell: false,
928
- stdio: ["pipe", "pipe", "pipe"],
929
- windowsHide: true,
930
- });
931
-
932
- let stdoutBuffer = "";
933
- let stderr = "";
934
- let settled = false;
935
- let timeout;
936
-
937
- const finish = (error, turns) => {
938
- if (settled) return;
939
- settled = true;
940
-
941
- if (timeout) clearTimeout(timeout);
942
-
943
- if (child.exitCode === null) child.kill("SIGTERM");
944
-
945
- if (error) reject(error);
946
- else resolve(turns);
947
- };
948
-
949
- const send = (message) => {
950
- child.stdin.write(`${JSON.stringify(message)}\n`);
951
- };
952
-
953
- const handleMessage = (message) => {
954
- if (message?.id === "mesurer-history-init") {
955
- if (message.error) {
956
- finish(new Error(message.error.message || "Codex app-server initialize failed."));
957
-
958
- return;
959
- }
960
-
961
- send({ method: "initialized" });
962
- send({
963
- id: "mesurer-history-turns",
964
- method: "thread/turns/list",
965
- params: {
966
- threadId: thread,
967
- limit: DESKTOP_TURN_HISTORY_LIMIT,
968
- sortDirection: "desc",
969
- itemsView: "summary",
970
- },
971
- });
972
-
973
- return;
974
- }
975
-
976
- if (message?.id === "mesurer-history-turns") {
977
- if (message.error) {
978
- send({
979
- id: "mesurer-history-read",
980
- method: "thread/read",
981
- params: {
982
- threadId: thread,
983
- includeTurns: true,
984
- },
985
- });
986
-
987
- return;
988
- }
989
-
990
- finish(null, Array.isArray(message.result?.data) ? message.result.data : []);
991
-
992
- return;
993
- }
994
-
995
- if (message?.id === "mesurer-history-read") {
996
- if (message.error) {
997
- finish(new Error(message.error.message || "Codex app-server thread history read failed."));
998
-
999
- return;
1000
- }
1001
-
1002
- finish(null, Array.isArray(message.result?.thread?.turns) ? message.result.thread.turns : []);
1003
- }
1004
- };
1005
-
1006
- child.stdout.on("data", (chunk) => {
1007
- stdoutBuffer += chunk.toString();
1008
-
1009
- while (true) {
1010
- const newline = stdoutBuffer.indexOf("\n");
1011
-
1012
- if (newline < 0) break;
1013
- const line = stdoutBuffer.slice(0, newline).trim();
1014
- stdoutBuffer = stdoutBuffer.slice(newline + 1);
1015
-
1016
- if (!line) continue;
1017
-
1018
- try {
1019
- handleMessage(JSON.parse(line));
1020
- } catch {
1021
- // App-server protocol is JSONL; ignore unrelated stdout defensively.
1022
- }
1023
- }
1024
- });
1025
- child.stderr.on("data", (chunk) => {
1026
- stderr = `${stderr}${chunk.toString()}`.slice(-8_192);
1027
- });
1028
- child.on("error", (error) => finish(error));
1029
- child.on("close", (code, signal) => {
1030
- if (settled) return;
1031
- const detail = stderr.trim() || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
1032
- finish(new Error(`Codex app-server exited before lifecycle history completed: ${detail}`));
1033
- });
1034
-
1035
- timeout = setTimeout(() => {
1036
- child.kill("SIGTERM");
1037
- finish(new Error(`Codex app-server lifecycle history timed out after ${APP_SERVER_TIMEOUT_MS}ms.`));
1038
- }, APP_SERVER_TIMEOUT_MS);
1039
-
1040
- send({
1041
- id: "mesurer-history-init",
1042
- method: "initialize",
1043
- params: {
1044
- clientInfo: {
1045
- name: "mesurer-solid",
1046
- title: "Mesurer Solid",
1047
- version: "1",
1048
- },
1049
- capabilities: {
1050
- experimentalApi: true,
1051
- },
1052
- },
1053
- });
1054
- });
1055
-
1056
- const openDesktopThread = (thread) => new Promise((resolveOpen, rejectOpen) => {
1057
- const url = `codex://threads/${thread}`;
1058
- const override = process.env.MESURER_CODEX_DESKTOP_OPEN_BIN?.trim();
1059
- let command;
1060
- let args;
1061
-
1062
- if (override) {
1063
- command = override;
1064
- args = [url];
1065
- } else if (process.platform === "darwin") {
1066
- command = "/usr/bin/open";
1067
- args = [url];
1068
- } else if (process.platform === "win32") {
1069
- command = "powershell.exe";
1070
- const quotedUrl = url.replaceAll("'", "''");
1071
- args = [
1072
- "-NoProfile",
1073
- "-Command",
1074
- `Start-Process -FilePath '${quotedUrl}'`,
1075
- ];
1076
- } else {
1077
- rejectOpen(new Error(
1078
- `Codex Desktop thread wake is unsupported on platform ${process.platform}.`,
1079
- ));
1080
-
1081
- return;
1082
- }
1083
-
1084
- const child = spawn(command, args, {
1085
- env: process.env,
1086
- shell: false,
1087
- stdio: ["ignore", "ignore", "pipe"],
1088
- windowsHide: true,
1089
- });
1090
-
1091
- let stderr = "";
1092
- let settled = false;
1093
-
1094
- const timeout = setTimeout(() => {
1095
- child.kill("SIGTERM");
1096
-
1097
- if (settled) return;
1098
- settled = true;
1099
- rejectOpen(new Error("Codex Desktop thread open timed out."));
1100
- }, APP_SERVER_TIMEOUT_MS);
1101
-
1102
- child.stderr.on("data", (chunk) => {
1103
- stderr = `${stderr}${chunk.toString()}`.slice(-8_192);
1104
- });
1105
- child.on("error", (error) => {
1106
- if (settled) return;
1107
- settled = true;
1108
- clearTimeout(timeout);
1109
- rejectOpen(error);
1110
- });
1111
- child.on("close", (code, signal) => {
1112
- if (settled) return;
1113
- settled = true;
1114
- clearTimeout(timeout);
1115
-
1116
- if (code === 0) {
1117
- resolveOpen({ url });
1118
-
1119
- return;
1120
- }
1121
-
1122
- const detail = stderr.trim()
1123
- || `exit code ${code ?? "unknown"}${signal ? ` (${signal})` : ""}`;
1124
-
1125
- rejectOpen(new Error(`Codex Desktop thread open failed: ${detail}`));
1126
- });
1127
- });
1128
-
1129
- let maybeDispatchDesktopThread = async (_thread) => {};
1130
-
1131
- const scheduleDesktopDispatch = (thread, delay = 0) => {
1132
- if (desktopDispatchTimers.has(thread)) return;
1133
-
1134
- const timer = setTimeout(() => {
1135
- desktopDispatchTimers.delete(thread);
1136
- void maybeDispatchDesktopThread(thread);
1137
- }, delay);
1138
-
1139
- desktopDispatchTimers.set(thread, timer);
1140
- };
1141
-
1142
- maybeDispatchDesktopThread = async (thread) => {
1143
- const record = registeredThreads.get(thread);
1144
-
1145
- if (!record?.appToolsPipe) return;
1146
-
1147
- const delivery = [...deliveries.values()]
1148
- .filter((candidate) =>
1149
- candidate.thread === thread
1150
- && candidate.transport === "desktop-app"
1151
- && candidate.status === "queued"
1152
- && candidate.dispatch !== "desktop-opened")
1153
- .sort((left, right) => left.createdAt - right.createdAt)[0];
1154
-
1155
- if (!delivery) return;
1156
-
1157
- try {
1158
- if (!delivery.queuedSubmissionId) {
1159
- const output = await runCodexQueue(thread, delivery.message);
1160
- delivery.queuedSubmissionId = queuedSubmissionFromOutput(output, thread);
1161
-
1162
- if (!delivery.queuedSubmissionId) {
1163
- throw new Error("Codex queue succeeded but did not return a queued submission id.");
1164
- }
1165
-
1166
- delivery.dispatch = "persisted";
1167
- delivery.dispatchError = null;
1168
- delivery.updatedAt = Date.now();
1169
- await persistDeliveryState();
1170
- }
1171
-
1172
- await openDesktopThread(thread);
1173
- delivery.dispatch = "desktop-opened";
1174
- delivery.dispatchError = null;
1175
- delivery.updatedAt = Date.now();
1176
- persistDeliveryStateSoon();
1177
- } catch (cause) {
1178
- delivery.dispatch = "desktop-wait-failed";
1179
- delivery.dispatchError = cause instanceof Error ? cause.message : String(cause);
1180
- delivery.updatedAt = Date.now();
1181
- persistDeliveryStateSoon();
1182
- }
1183
- };
1184
-
1185
- const normalizeTitle = (value) => {
1186
- if (value == null) return null;
1187
- const title = String(value).replace(/\s+/g, " ").trim();
1188
-
1189
- if (!title) return null;
1190
-
1191
- return title.length > 80 ? `${title.slice(0, 79)}…` : title;
1192
- };
1193
-
1194
- const normalizeTimestamp = (value) => Number.isFinite(value) ? Number(value) : null;
1195
-
1196
- const shortThread = (thread) => thread.length > 16 ? `${thread.slice(0, 8)}…${thread.slice(-4)}` : thread;
1197
-
1198
- const appServerSummary = (thread, cwd) => {
1199
- const id = normalizeThread(thread?.id);
1200
-
1201
- if (!id) return null;
1202
-
1203
- const title = normalizeTitle(thread?.name)
1204
- ?? normalizeTitle(thread?.preview)
1205
- ?? `Codex ${shortThread(id)}`;
1206
-
1207
- return {
1208
- id,
1209
- title,
1210
- updatedAt: normalizeTimestamp(thread?.recencyAt ?? thread?.updatedAt),
1211
- connected: registeredThreads.has(id),
1212
- cwd,
1213
- };
1214
- };
1215
-
1216
- const registeredSummary = (record) => ({
1217
- id: record.id,
1218
- title: `Codex ${shortThread(record.id)}`,
1219
- updatedAt: Math.floor(record.seenAt / 1_000),
1220
- connected: true,
1221
- cwd: record.cwd,
1222
- });
1223
-
1224
- const listThreadSummaries = async (scopeThread, limit) => {
1225
- const scopeRecord = scopeThread
1226
- ? registeredThreads.get(scopeThread) ?? discoveredThreads.get(scopeThread)
1227
- : activeThread
1228
- ? registeredThreads.get(activeThread) ?? discoveredThreads.get(activeThread)
1229
- : null;
1230
-
1231
- const scopeCwd = scopeRecord?.cwd ?? activeCwd;
1232
- const preferredThread = scopeThread ?? activeThread;
1233
- const summaries = [];
1234
- let appHasMore = false;
1235
-
1236
- if (scopeCwd) {
1237
- try {
1238
- const listed = await runCodexThreadList(scopeCwd, MAX_DISCOVERED_THREADS);
1239
- const data = Array.isArray(listed?.data) ? listed.data : [];
1240
- appHasMore = Boolean(listed?.nextCursor);
1241
-
1242
- for (const item of data) {
1243
- const summary = appServerSummary(item, scopeCwd);
1244
-
1245
- if (!summary) continue;
1246
- discoveredThreads.set(summary.id, summary);
1247
- summaries.push(summary);
1248
- }
1249
- } catch {
1250
- // Discovery is optional. Registered SessionStart destinations still work without app-server listing.
1251
- }
1252
- }
1253
-
1254
- const byId = new Map(summaries.map((summary) => [summary.id, summary]));
1255
- const ordered = [];
1256
- const seen = new Set();
1257
-
1258
- const push = (summary) => {
1259
- if (!summary || seen.has(summary.id)) return;
1260
- seen.add(summary.id);
1261
- ordered.push({
1262
- id: summary.id,
1263
- title: summary.title,
1264
- updatedAt: summary.updatedAt,
1265
- connected: registeredThreads.has(summary.id),
1266
- });
1267
- };
1268
-
1269
- if (preferredThread) {
1270
- push(byId.get(preferredThread)
1271
- ?? (registeredThreads.get(preferredThread) ? registeredSummary(registeredThreads.get(preferredThread)) : null)
1272
- ?? discoveredThreads.get(preferredThread));
1273
- }
1274
-
1275
- for (const summary of summaries) push(summary);
1276
-
1277
- for (const record of registeredThreads.values()) {
1278
- if (scopeCwd && record.cwd && record.cwd !== scopeCwd) continue;
1279
- push(byId.get(record.id) ?? registeredSummary(record));
1280
- }
1281
-
1282
- return {
1283
- thread: preferredThread,
1284
- threadDetails: ordered.slice(0, limit),
1285
- hasMore: appHasMore || ordered.length > limit,
1286
- };
1287
- };
1288
-
1289
- const threadPayload = () => ({
1290
- thread: activeThread,
1291
- threads: [...registeredThreads.keys()],
1292
- });
1293
-
1294
- const hashMessage = (message) => createHash("sha256").update(message).digest("hex");
1295
-
1296
- const turnKey = (thread, turnId) => `${thread}:${turnId}`;
1297
-
1298
- const publicDelivery = (delivery) => ({
1299
- deliveryId: delivery.id,
1300
- thread: delivery.thread,
1301
- transport: delivery.transport,
1302
- status: delivery.status,
1303
- turnId: delivery.turnId,
1304
- queuedSubmissionId: delivery.queuedSubmissionId,
1305
- dispatch: delivery.dispatch,
1306
- dispatchError: delivery.dispatchError,
1307
- createdAt: delivery.createdAt,
1308
- updatedAt: delivery.updatedAt,
1309
- });
1310
-
1311
- const pruneDeliveries = () => {
1312
- const now = Date.now();
1313
-
1314
- for (const [id, delivery] of deliveries) {
1315
- const terminal = delivery.status === "completed" || delivery.status === "interrupted";
1316
-
1317
- if (now - delivery.updatedAt > (terminal ? TERMINAL_DELIVERY_TTL_MS : DELIVERY_TTL_MS)) {
1318
- deliveries.delete(id);
1319
- desktopLifecycleChecks.delete(id);
1320
- desktopLifecycleLastCheckedAt.delete(id);
1321
- }
1322
- }
1323
-
1324
- for (const [key, event] of terminalTurnEvents) {
1325
- if (now - event.at > TERMINAL_DELIVERY_TTL_MS) terminalTurnEvents.delete(key);
1326
- }
1327
-
1328
- if (deliveries.size <= MAX_DELIVERIES) return;
1329
- const oldest = [...deliveries.values()].sort((a, b) => a.updatedAt - b.updatedAt);
1330
-
1331
- for (const delivery of oldest.slice(0, deliveries.size - MAX_DELIVERIES)) {
1332
- deliveries.delete(delivery.id);
1333
- }
1334
- };
1335
-
1336
- const createDelivery = (thread, message, transport = "codex-queue") => {
1337
- pruneDeliveries();
1338
- const now = Date.now();
1339
-
1340
- const delivery = {
1341
- id: randomUUID(),
1342
- thread,
1343
- message,
1344
- messageHash: hashMessage(message),
1345
- transport,
1346
- status: "queued",
1347
- turnId: null,
1348
- queuedSubmissionId: null,
1349
- dispatch: "persisting",
1350
- dispatchError: null,
1351
- createdAt: now,
1352
- updatedAt: now,
1353
- };
1354
-
1355
- deliveries.set(delivery.id, delivery);
1356
-
1357
- if (delivery.transport === "desktop-app") persistDeliveryStateSoon();
1358
-
1359
- return delivery;
1360
- };
1361
-
1362
- const xmlEscape = (value) => value
1363
- .replaceAll("&", "&amp;")
1364
- .replaceAll("<", "&lt;")
1365
- .replaceAll(">", "&gt;")
1366
- .replaceAll('"', "&quot;")
1367
- .replaceAll("'", "&apos;");
1368
-
1369
- const promptMatchesDelivery = (delivery, prompt) => {
1370
- if (delivery.messageHash === hashMessage(prompt)) return true;
1371
-
1372
- if (delivery.transport !== "desktop-app" || !delivery.message) return false;
1373
-
1374
- return prompt.includes(delivery.message)
1375
- || prompt.includes(`<input>${xmlEscape(delivery.message)}</input>`);
1376
- };
1377
-
1378
- const turnUserMessages = (turn) => {
1379
- if (!Array.isArray(turn?.items)) return [];
1380
-
1381
- return turn.items.flatMap((item) => {
1382
- if (item?.type !== "userMessage" || !Array.isArray(item.content)) return [];
1383
-
1384
- const text = item.content
1385
- .filter((input) => input?.type === "text")
1386
- .map((input) => String(input.text ?? ""))
1387
- .join("\n")
1388
- .trim();
1389
-
1390
- return text ? [text] : [];
1391
- });
1392
- };
1393
-
1394
- const deliveryTurn = (delivery, turns) => {
1395
- if (delivery.turnId) {
1396
- return turns.find((turn) => normalizeThread(turn?.id) === delivery.turnId) ?? null;
1397
- }
1398
-
1399
- const earliestStartedAt = delivery.createdAt - DELIVERY_TURN_START_SKEW_MS;
1400
-
1401
- const matches = turns.filter((turn) => {
1402
- const turnId = normalizeThread(turn?.id);
1403
- const startedAt = Number(turn?.startedAt);
1404
-
1405
- if (!turnId || !Number.isFinite(startedAt) || startedAt * 1_000 < earliestStartedAt) {
1406
- return false;
1407
- }
1408
-
1409
- return turnUserMessages(turn).some((prompt) => promptMatchesDelivery(delivery, prompt));
1410
- });
1411
-
1412
- return matches.length === 1 ? matches[0] : null;
1413
- };
1414
-
1415
- const reconcileDesktopDelivery = async (delivery) => {
1416
- if (delivery.transport !== "desktop-app" || delivery.dispatch !== "desktop-opened") {
1417
- return;
1418
- }
1419
-
1420
- const turns = await runCodexTurnHistory(delivery.thread);
1421
-
1422
- if (deliveries.get(delivery.id) !== delivery) return;
1423
- const turn = deliveryTurn(delivery, turns);
1424
-
1425
- if (!turn) return;
1426
-
1427
- const turnId = normalizeThread(turn.id);
1428
- const ended = turn.completedAt != null && Number.isFinite(Number(turn.completedAt));
1429
- let nextStatus = null;
1430
-
1431
- if (turn.status === "inProgress") nextStatus = "working";
1432
-
1433
- if (turn.status === "interrupted" && !ended) nextStatus = "working";
1434
-
1435
- if (turn.status === "completed") nextStatus = "completed";
1436
-
1437
- if ((turn.status === "interrupted" && ended) || turn.status === "failed") {
1438
- nextStatus = "interrupted";
1439
- }
1440
-
1441
- if (!turnId || !nextStatus) return;
1442
-
1443
- const failed = turn.status === "failed";
1444
- const failureMessage = normalizeThread(turn?.error?.message) ?? "Codex turn failed.";
1445
-
1446
- const changed = delivery.turnId !== turnId
1447
- || delivery.status !== nextStatus
1448
- || (failed && delivery.dispatchError !== failureMessage);
1449
-
1450
- if (!changed) return;
1451
-
1452
- delivery.turnId = turnId;
1453
- delivery.status = nextStatus;
1454
-
1455
- if (failed) delivery.dispatchError = failureMessage;
1456
- delivery.updatedAt = Date.now();
1457
- await persistDeliveryState();
1458
- };
1459
-
1460
- const scheduleDesktopLifecycleCheck = (delivery) => {
1461
- if (delivery.transport !== "desktop-app"
1462
- || delivery.dispatch !== "desktop-opened"
1463
- || desktopLifecycleChecks.has(delivery.id)) {
1464
- return;
1465
- }
1466
-
1467
- const now = Date.now();
1468
- const lastCheckedAt = desktopLifecycleLastCheckedAt.get(delivery.id) ?? 0;
1469
-
1470
- if (now - lastCheckedAt < DESKTOP_LIFECYCLE_POLL_MS) return;
1471
- desktopLifecycleLastCheckedAt.set(delivery.id, now);
1472
-
1473
- const check = reconcileDesktopDelivery(delivery)
1474
- .catch(() => undefined)
1475
- .finally(() => {
1476
- if (desktopLifecycleChecks.get(delivery.id) === check) {
1477
- desktopLifecycleChecks.delete(delivery.id);
1478
- }
1479
- });
1480
-
1481
- desktopLifecycleChecks.set(delivery.id, check);
1482
- };
1483
-
1484
- const markPromptStarted = (thread, turnId, prompt) => {
1485
- const delivery = [...deliveries.values()]
1486
- .filter((candidate) =>
1487
- candidate.thread === thread
1488
- && promptMatchesDelivery(candidate, prompt)
1489
- && candidate.status === "queued")
1490
- .sort((a, b) => a.createdAt - b.createdAt)[0];
1491
-
1492
- if (!delivery) return null;
1493
- delivery.status = "working";
1494
- delivery.turnId = turnId;
1495
- delivery.updatedAt = Date.now();
1496
-
1497
- if (delivery.transport === "desktop-app") persistDeliveryStateSoon();
1498
- const terminal = terminalTurnEvents.get(turnKey(thread, turnId));
1499
-
1500
- if (terminal) {
1501
- delivery.status = terminal.status;
1502
- delivery.updatedAt = Math.max(delivery.updatedAt, terminal.at);
1503
- terminalTurnEvents.delete(turnKey(thread, turnId));
1504
-
1505
- if (delivery.transport === "desktop-app") persistDeliveryStateSoon();
1506
- }
1507
-
1508
- return delivery;
1509
- };
1510
-
1511
- const markTurnTerminal = (thread, turnId, status) => {
1512
- const delivery = [...deliveries.values()].find((candidate) =>
1513
- candidate.thread === thread
1514
- && candidate.turnId === turnId
1515
- && candidate.status === "working");
1516
-
1517
- if (delivery) {
1518
- delivery.status = status;
1519
- delivery.updatedAt = Date.now();
1520
-
1521
- if (delivery.transport === "desktop-app") persistDeliveryStateSoon();
1522
-
1523
- return delivery;
1524
- }
1525
-
1526
- terminalTurnEvents.set(turnKey(thread, turnId), { status, at: Date.now() });
1527
-
1528
- return null;
1529
- };
1530
-
1531
- const desktopOwnsLifecycle = (thread) =>
1532
- Boolean(registeredThreads.get(thread)?.appToolsPipe)
1533
- || [...deliveries.values()].some((delivery) =>
1534
- delivery.thread === thread && delivery.transport === "desktop-app");
1535
-
1536
- await loadDeliveryState();
1537
-
1538
- for (const delivery of deliveries.values()) scheduleDesktopLifecycleCheck(delivery);
1539
-
1540
- let successfulSends = 0;
1541
-
1542
- const server = createServer(async (request, response) => {
1543
- const originHeaderPresent = Object.hasOwn(request.headers, "origin");
1544
- const origin = [request.headers.origin].flat().find((value) => value !== undefined);
1545
-
1546
- if (!originAllowed(origin)) {
1547
- writeJson(response, 403, { ok: false, error: `Origin is not allowed: ${origin}` }, origin);
1548
-
1549
- return;
1550
- }
1551
-
1552
- if (request.method === "OPTIONS") {
1553
- response.writeHead(204, corsHeaders(origin));
1554
- response.end();
1555
-
1556
- return;
1557
- }
1558
-
1559
- if (request.method === "POST" && request.url === "/shutdown") {
1560
- if (originHeaderPresent) {
1561
- writeJson(response, 403, { ok: false, error: "Bridge shutdown is local-process-only." }, origin);
1562
-
1563
- return;
1564
- }
1565
-
1566
- response.setHeader("Connection", "close");
1567
- response.once("finish", () => {
1568
- server.close();
1569
- server.closeAllConnections?.();
1570
- setImmediate(() => process.exit(0));
1571
- });
1572
- writeJson(response, 200, { ok: true }, origin);
1573
-
1574
- return;
1575
- }
1576
-
1577
- if (request.method === "GET" && request.url === "/health") {
1578
- writeJson(response, 200, { ok: true, bridge: BRIDGE_IDENTITY, ...threadPayload() }, origin);
1579
-
1580
- return;
1581
- }
1582
-
1583
- if (request.method === "GET" && request.url?.startsWith("/deliveries/")) {
1584
- pruneDeliveries();
1585
- const url = new URL(request.url, DEFAULT_BRIDGE);
1586
- const prefix = "/deliveries/";
1587
- const deliveryId = decodeURIComponent(url.pathname.slice(prefix.length));
1588
- const delivery = deliveries.get(deliveryId);
1589
-
1590
- if (!delivery) {
1591
- writeJson(response, 404, { ok: false, error: `Codex delivery is not available: ${deliveryId}` }, origin);
1592
-
1593
- return;
1594
- }
1595
-
1596
- scheduleDesktopLifecycleCheck(delivery);
1597
- writeJson(response, 200, { ok: true, ...publicDelivery(delivery) }, origin);
1598
-
1599
- return;
1600
- }
1601
-
1602
- if (request.method === "POST" && request.url === "/deliveries/restore") {
1603
- try {
1604
- const body = await readJsonBody(request);
1605
- const deliveryId = normalizeThread(body?.deliveryId);
1606
- const thread = normalizeThread(body?.thread);
1607
- const queuedSubmissionId = normalizeThread(body?.queuedSubmissionId);
1608
-
1609
- if (!deliveryId || !thread) {
1610
- writeJson(response, 400, { ok: false, error: "deliveryId and thread are required." }, origin);
1611
-
1612
- return;
1613
- }
1614
-
1615
- if (!registeredThreads.has(thread) && !discoveredThreads.has(thread)) {
1616
- writeJson(response, 409, {
1617
- ok: false,
1618
- error: `Codex thread is not available to this bridge: ${thread}`,
1619
- }, origin);
1620
-
1621
- return;
1622
- }
1623
-
1624
- const existing = deliveries.get(deliveryId);
1625
-
1626
- if (existing) {
1627
- if (existing.transport === "desktop-app") scheduleDesktopDispatch(existing.thread);
1628
- writeJson(response, 200, { ok: true, restored: false, ...publicDelivery(existing) }, origin);
1629
-
1630
- return;
1631
- }
1632
-
1633
- const lookup = await runCodexQueueLookup(thread, queuedSubmissionId);
1634
-
1635
- if (!lookup.submission) {
1636
- const error = lookup.ambiguous
1637
- ? "Multiple queued Codex submissions exist for this thread; an exact queuedSubmissionId is required to restore the delivery safely."
1638
- : queuedSubmissionId
1639
- ? `Codex queued submission is not available: ${queuedSubmissionId}`
1640
- : "No queued Codex submission is available to restore for this thread.";
1641
-
1642
- writeJson(response, 409, { ok: false, error }, origin);
1643
-
1644
- return;
1645
- }
1646
-
1647
- const message = queuedSubmissionMessage(lookup.submission);
1648
-
1649
- if (!message) {
1650
- writeJson(response, 409, {
1651
- ok: false,
1652
- error: "The queued Codex submission is not a single text message and cannot be restored safely.",
1653
- }, origin);
1654
-
1655
- return;
1656
- }
1657
-
1658
- pruneDeliveries();
1659
- const now = Date.now();
1660
- const record = registeredThreads.get(thread);
1661
- const transport = record?.appToolsPipe ? "desktop-app" : "codex-queue";
1662
-
1663
- const delivery = {
1664
- id: deliveryId,
1665
- thread,
1666
- message,
1667
- messageHash: hashMessage(message),
1668
- transport,
1669
- status: "queued",
1670
- turnId: null,
1671
- queuedSubmissionId: lookup.submission.id,
1672
- dispatch: "persisted",
1673
- dispatchError: null,
1674
- createdAt: now,
1675
- updatedAt: now,
1676
- };
1677
-
1678
- deliveries.set(delivery.id, delivery);
1679
- await persistDeliveryState();
1680
-
1681
- if (transport === "desktop-app") {
1682
- delivery.dispatch = "persisted";
1683
- delivery.updatedAt = Date.now();
1684
- await persistDeliveryState();
1685
- scheduleDesktopDispatch(thread);
1686
- } else {
1687
- try {
1688
- const wake = await resumeColdCodexThread(thread);
1689
- delivery.dispatch = wake.action;
1690
- delivery.updatedAt = Date.now();
1691
- } catch (cause) {
1692
- delivery.dispatch = "wake-failed";
1693
- delivery.dispatchError = cause instanceof Error ? cause.message : String(cause);
1694
- delivery.updatedAt = Date.now();
1695
- }
1696
-
1697
- persistDeliveryStateSoon();
1698
- }
1699
-
1700
- writeJson(response, 200, {
1701
- ok: true,
1702
- restored: true,
1703
- ...publicDelivery(delivery),
1704
- }, origin);
1705
- } catch (cause) {
1706
- const error = cause instanceof Error ? cause.message : String(cause);
1707
- writeJson(response, 502, { ok: false, error }, origin);
1708
- }
1709
-
1710
- return;
1711
- }
1712
-
1713
- if (request.method === "GET" && request.url?.startsWith("/threads")) {
1714
- const url = new URL(request.url, DEFAULT_BRIDGE);
1715
-
1716
- if (url.pathname !== "/threads") {
1717
- writeJson(response, 404, { ok: false, error: "Not found." }, origin);
1718
-
1719
- return;
1720
- }
1721
-
1722
- const requestedLimit = Number(url.searchParams.get("limit") ?? "5");
1723
-
1724
- const limit = Number.isInteger(requestedLimit)
1725
- ? Math.min(MAX_DISCOVERED_THREADS, Math.max(1, requestedLimit))
1726
- : 5;
1727
-
1728
- const scopeThread = normalizeThread(url.searchParams.get("thread")) ?? activeThread;
1729
-
1730
- if (scopeThread && !registeredThreads.has(scopeThread) && !discoveredThreads.has(scopeThread)) {
1731
- writeJson(response, 409, { ok: false, error: `Codex thread is not known to this bridge: ${scopeThread}` }, origin);
1732
-
1733
- return;
1734
- }
1735
-
1736
- const payload = await listThreadSummaries(scopeThread, limit);
1737
- writeJson(response, 200, { ok: true, ...payload }, origin);
1738
-
1739
- return;
1740
- }
1741
-
1742
- if (request.method === "POST" && request.url === "/threads/register") {
1743
- if (originHeaderPresent) {
1744
- writeJson(response, 403, {
1745
- ok: false,
1746
- error: "Thread registration is available only to a local process, not a browser Origin.",
1747
- }, origin);
1748
-
1749
- return;
1750
- }
1751
-
1752
- try {
1753
- const body = await readJsonBody(request);
1754
- const thread = normalizeThread(body?.thread);
1755
- const cwd = normalizeCwd(body?.cwd);
1756
- const appToolsPipe = normalizeThread(body?.appToolsPipe);
1757
- const codexHome = normalizeCwd(body?.codexHome);
1758
-
1759
- if (!thread) {
1760
- writeJson(response, 400, { ok: false, error: "thread must be a non-empty string." }, origin);
1761
-
1762
- return;
1763
- }
1764
-
1765
- registerThread(thread, cwd, { appToolsPipe, codexHome });
1766
-
1767
- if (appToolsPipe) scheduleDesktopDispatch(thread);
1768
- writeJson(response, 200, { ok: true, ...threadPayload() }, origin);
1769
- } catch (cause) {
1770
- const error = cause instanceof Error ? cause.message : String(cause);
1771
- writeJson(response, 400, { ok: false, error }, origin);
1772
- }
1773
-
1774
- return;
1775
- }
1776
-
1777
- if (request.method === "POST" && request.url === "/lifecycle") {
1778
- if (originHeaderPresent) {
1779
- writeJson(response, 403, {
1780
- ok: false,
1781
- error: "Codex lifecycle updates are available only to a local process, not a browser Origin.",
1782
- }, origin);
1783
-
1784
- return;
1785
- }
1786
-
1787
- try {
1788
- const body = await readJsonBody(request);
1789
- const event = body?.event;
1790
- const thread = normalizeThread(body?.sessionId);
1791
- const turnId = normalizeThread(body?.turnId);
1792
-
1793
- if (!thread || !turnId) {
1794
- writeJson(response, 400, { ok: false, error: "sessionId and turnId are required." }, origin);
1795
-
1796
- return;
1797
- }
1798
-
1799
- if (desktopOwnsLifecycle(thread)) {
1800
- writeJson(response, 200, {
1801
- ok: true,
1802
- matched: false,
1803
- ignored: "desktop-history-authoritative",
1804
- }, origin);
1805
-
1806
- return;
1807
- }
1808
-
1809
- let delivery = null;
1810
-
1811
- if (event === "UserPromptSubmit") {
1812
- const prompt = body?.prompt?.trim?.() ?? "";
1813
-
1814
- if (!prompt) {
1815
- writeJson(response, 400, { ok: false, error: "UserPromptSubmit requires prompt." }, origin);
1816
-
1817
- return;
1818
- }
1819
-
1820
- delivery = markPromptStarted(thread, turnId, prompt);
1821
- } else if (event === "Stop") {
1822
- delivery = markTurnTerminal(thread, turnId, "completed");
1823
- scheduleDesktopDispatch(thread);
1824
- } else if (event === "Interrupt") {
1825
- delivery = markTurnTerminal(thread, turnId, "interrupted");
1826
- scheduleDesktopDispatch(thread);
1827
- } else {
1828
- writeJson(response, 400, { ok: false, error: `Unsupported Codex lifecycle event: ${event ?? "<missing>"}` }, origin);
1829
-
1830
- return;
1831
- }
1832
-
1833
- pruneDeliveries();
1834
-
1835
- if (delivery) {
1836
- writeJson(response, 200, {
1837
- ok: true,
1838
- matched: true,
1839
- ...publicDelivery(delivery),
1840
- }, origin);
1841
- } else {
1842
- writeJson(response, 200, { ok: true, matched: false }, origin);
1843
- }
1844
- } catch (cause) {
1845
- const error = cause instanceof Error ? cause.message : String(cause);
1846
- writeJson(response, 400, { ok: false, error }, origin);
1847
- }
1848
-
1849
- return;
1850
- }
1851
-
1852
- if (request.method === "POST" && request.url === "/target") {
1853
- try {
1854
- const body = await readJsonBody(request);
1855
- const thread = normalizeThread(body?.thread);
1856
-
1857
- if (!thread) {
1858
- writeJson(response, 400, { ok: false, error: "thread must be a non-empty string." }, origin);
1859
-
1860
- return;
1861
- }
1862
-
1863
- const record = registeredThreads.get(thread);
1864
-
1865
- if (!record) {
1866
- writeJson(response, 409, {
1867
- ok: false,
1868
- error: `Codex thread is not registered with this bridge: ${thread}`,
1869
- }, origin);
1870
-
1871
- return;
1872
- }
1873
-
1874
- activeThread = thread;
1875
-
1876
- if (record.cwd) activeCwd = record.cwd;
1877
- writeJson(response, 200, { ok: true, ...threadPayload() }, origin);
1878
- } catch (cause) {
1879
- const error = cause instanceof Error ? cause.message : String(cause);
1880
- writeJson(response, 400, { ok: false, error }, origin);
1881
- }
1882
-
1883
- return;
1884
- }
1885
-
1886
- if (request.method !== "POST" || request.url !== "/send") {
1887
- writeJson(response, 404, { ok: false, error: "Not found." }, origin);
1888
-
1889
- return;
1890
- }
1891
-
1892
- try {
1893
- const body = await readJsonBody(request);
1894
- const message = body?.message?.trim?.() ?? "";
1895
- const requestedThread = normalizeThread(body?.thread);
1896
- const thread = requestedThread ?? activeThread;
1897
-
1898
- if (!message) {
1899
- writeJson(response, 400, { ok: false, error: "message must be a non-empty string." }, origin);
1900
-
1901
- return;
1902
- }
1903
-
1904
- if (!thread) {
1905
- writeJson(response, 409, {
1906
- ok: false,
1907
- error: "No Codex thread is registered. Start the bridge from Codex, pass --thread, or run mesurer-codex --register-current.",
1908
- }, origin);
1909
-
1910
- return;
1911
- }
1912
-
1913
- if (!registeredThreads.has(thread) && !discoveredThreads.has(thread)) {
1914
- writeJson(response, 409, {
1915
- ok: false,
1916
- error: `Codex thread is not available to this bridge: ${thread}`,
1917
- }, origin);
1918
-
1919
- return;
1920
- }
1921
-
1922
- const record = registeredThreads.get(thread);
1923
-
1924
- if (record?.appToolsPipe) {
1925
- const delivery = createDelivery(thread, message, "desktop-app");
1926
- let output;
1927
-
1928
- try {
1929
- output = await runCodexQueue(thread, message);
1930
- } catch (cause) {
1931
- deliveries.delete(delivery.id);
1932
- persistDeliveryStateSoon();
1933
- throw cause;
1934
- }
1935
-
1936
- delivery.queuedSubmissionId = queuedSubmissionFromOutput(output, thread);
1937
-
1938
- if (!delivery.queuedSubmissionId) {
1939
- deliveries.delete(delivery.id);
1940
- persistDeliveryStateSoon();
1941
- throw new Error("Codex queue succeeded but did not return a queued submission id.");
1942
- }
1943
-
1944
- delivery.dispatch = "persisted";
1945
- delivery.updatedAt = Date.now();
1946
- await persistDeliveryState();
1947
- scheduleDesktopDispatch(thread);
1948
- successfulSends += 1;
1949
- writeJson(response, 200, {
1950
- ok: true,
1951
- thread,
1952
- output,
1953
- delivery: "queued",
1954
- ...publicDelivery(delivery),
1955
- }, origin);
1956
-
1957
- if (values.once && successfulSends >= 1) setImmediate(() => server.close());
1958
-
1959
- return;
1960
- }
1961
-
1962
- const delivery = createDelivery(thread, message, "codex-queue");
1963
- let output;
1964
-
1965
- try {
1966
- output = await runCodexQueue(thread, message);
1967
- } catch (cause) {
1968
- deliveries.delete(delivery.id);
1969
- persistDeliveryStateSoon();
1970
- throw cause;
1971
- }
1972
-
1973
- delivery.queuedSubmissionId = queuedSubmissionFromOutput(output, thread);
1974
- delivery.dispatch = "persisted";
1975
- delivery.updatedAt = Date.now();
1976
-
1977
- if (delivery.queuedSubmissionId) {
1978
- try {
1979
- const wake = await resumeColdCodexThread(thread);
1980
- delivery.dispatch = wake.action;
1981
- delivery.updatedAt = Date.now();
1982
- } catch (cause) {
1983
- delivery.dispatch = "wake-failed";
1984
- delivery.dispatchError = cause instanceof Error ? cause.message : String(cause);
1985
- delivery.updatedAt = Date.now();
1986
- }
1987
- } else {
1988
- delivery.dispatch = "untracked";
1989
- delivery.dispatchError = "Codex queue succeeded but did not return a queued submission id.";
1990
- delivery.updatedAt = Date.now();
1991
- }
1992
-
1993
- persistDeliveryStateSoon();
1994
-
1995
- successfulSends += 1;
1996
- writeJson(response, 200, {
1997
- ok: true,
1998
- thread,
1999
- output,
2000
- delivery: "queued",
2001
- ...publicDelivery(delivery),
2002
- }, origin);
2003
-
2004
- if (values.once && successfulSends >= 1) setImmediate(() => server.close());
2005
- } catch (cause) {
2006
- const error = cause instanceof Error ? cause.message : String(cause);
2007
- writeJson(response, 502, { ok: false, error }, origin);
2008
- }
2009
- });
2010
-
2011
- server.on("error", (error) => {
2012
- process.stderr.write(`Mesurer Codex bridge failed: ${error.message}\n`);
2013
- process.exitCode = 1;
2014
- });
2015
-
2016
- server.listen(parsedPort, "127.0.0.1", () => {
2017
- for (const [thread, record] of registeredThreads) {
2018
- if (record.appToolsPipe) scheduleDesktopDispatch(thread);
2019
- }
2020
-
2021
- const address = server.address();
2022
- const port = address?.port ?? parsedPort;
2023
- const url = `http://127.0.0.1:${port}`;
2024
- console.log(`Mesurer Codex bridge listening on ${url}`);
2025
-
2026
- if (activeThread) console.log(`Active Codex thread: ${activeThread}`);
2027
- else console.log("No Codex thread is registered yet.");
2028
- console.log(`BRIDGE_URL=${url}`);
2029
-
2030
- if (activeThread) console.log(`BRIDGE_THREAD=${activeThread}`);
2031
- });
2032
-
2033
- const shutdown = () => server.close(() => process.exit(0));
2034
-
2035
- process.on("SIGINT", shutdown);
2036
-
2037
- process.on("SIGTERM", shutdown);