@btatum5/codex-bridge 0.1.0 → 1.3.2

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.
@@ -0,0 +1,688 @@
1
+ // FILE: push-notification-tracker.js
2
+ // Purpose: Tracks per-turn titles and failure context so the bridge can emit completion pushes even after the iPhone disconnects.
3
+ // Layer: Bridge helper
4
+ // Exports: createPushNotificationTracker
5
+ // Depends on: ./push-notification-completion-dedupe
6
+
7
+ const {
8
+ createPushNotificationCompletionDedupe,
9
+ } = require("./push-notification-completion-dedupe");
10
+
11
+ const DEFAULT_PREVIEW_MAX_CHARS = 160;
12
+
13
+ function createPushNotificationTracker({
14
+ sessionId,
15
+ pushServiceClient,
16
+ previewMaxChars = DEFAULT_PREVIEW_MAX_CHARS,
17
+ logPrefix = "[codex-bridge]",
18
+ now = () => Date.now(),
19
+ } = {}) {
20
+ const threadTitleById = new Map();
21
+ const threadIdByTurnId = new Map();
22
+ const turnStateByKey = new Map();
23
+ const completionDedupe = createPushNotificationCompletionDedupe({ now });
24
+
25
+ // ─── ENTRY POINT ─────────────────────────────────────────────
26
+
27
+ function handleOutbound(rawMessage) {
28
+ const message = parseOutboundMessage(rawMessage);
29
+ if (!message) {
30
+ return;
31
+ }
32
+
33
+ rememberMessageContext(message);
34
+ clearFallbackSuppressionForNewRun(message);
35
+
36
+ if (isAssistantDeltaMethod(message.method)) {
37
+ recordAssistantDelta(message.threadId, message.turnId, message.params, message.eventObject);
38
+ return;
39
+ }
40
+
41
+ if (isAssistantCompletedMethod(message.method, message.params, message.eventObject)) {
42
+ recordAssistantCompletion(message.threadId, message.turnId, message.params, message.eventObject);
43
+ return;
44
+ }
45
+
46
+ routeTerminalMessage(message);
47
+ }
48
+
49
+ // Keeps the top-level handler focused on orchestration while helpers own terminal edge cases.
50
+ function routeTerminalMessage({ method, params, eventObject, threadId, turnId }) {
51
+ if (method === "turn/failed" || isFailureEnvelope(method, eventObject)) {
52
+ if (shouldIgnoreRetriableFailure(params, eventObject)) {
53
+ return;
54
+ }
55
+
56
+ recordFailure(threadId, turnId, params, eventObject);
57
+ void notifyCompletion(threadId, turnId, params, eventObject, { forcedResult: "failed" });
58
+ return;
59
+ }
60
+
61
+ if (isTerminalThreadStatusMethod(method)) {
62
+ void notifyCompletion(threadId, turnId, params, eventObject, {
63
+ forcedResult: resolveThreadStatusResult(params, eventObject),
64
+ });
65
+ return;
66
+ }
67
+
68
+ if (method === "turn/completed") {
69
+ void notifyCompletion(threadId, turnId, params, eventObject);
70
+ }
71
+ }
72
+
73
+ // Remembers thread/turn linkage before the terminal event arrives on a different payload shape.
74
+ function rememberMessageContext({ threadId, turnId, params, eventObject }) {
75
+ if (threadId && turnId) {
76
+ threadIdByTurnId.set(turnId, threadId);
77
+ ensureTurnState(threadId, turnId);
78
+ }
79
+
80
+ if (!threadId) {
81
+ return;
82
+ }
83
+
84
+ const nextTitle = extractThreadTitle(params, eventObject);
85
+ if (nextTitle) {
86
+ threadTitleById.set(threadId, nextTitle);
87
+ }
88
+ }
89
+
90
+ // A new run on the same thread must not inherit duplicate-suppression from the previous run.
91
+ function clearFallbackSuppressionForNewRun({ method, threadId, params, eventObject }) {
92
+ if (!threadId) {
93
+ return;
94
+ }
95
+
96
+ if (method === "turn/started" || isActiveThreadStatus(method, params, eventObject)) {
97
+ completionDedupe.clearForNewRun(threadId);
98
+ }
99
+ }
100
+
101
+ // Buckets turnless completions so repeated terminal events dedupe briefly instead of forever.
102
+ async function notifyCompletion(threadId, turnId, params, eventObject, { forcedResult = null } = {}) {
103
+ const resolvedThreadId = threadId || (turnId ? threadIdByTurnId.get(turnId) : null);
104
+ if (!pushServiceClient?.hasConfiguredBaseUrl || !resolvedThreadId) {
105
+ return;
106
+ }
107
+
108
+ const result = forcedResult || resolveCompletionResult(params, eventObject);
109
+ if (!result) {
110
+ cleanupTurnState(resolvedThreadId, turnId);
111
+ return;
112
+ }
113
+
114
+ if (completionDedupe.shouldSuppressThreadStatusFallback({
115
+ threadId: resolvedThreadId,
116
+ turnId,
117
+ result,
118
+ })) {
119
+ cleanupTurnState(resolvedThreadId, turnId);
120
+ return;
121
+ }
122
+
123
+ const dedupeKey = completionDedupeKey({
124
+ sessionId,
125
+ threadId: resolvedThreadId,
126
+ turnId,
127
+ result,
128
+ now,
129
+ });
130
+ if (completionDedupe.hasActiveDedupeKey(dedupeKey)) {
131
+ cleanupTurnState(resolvedThreadId, turnId);
132
+ return;
133
+ }
134
+
135
+ const state = getTurnState(resolvedThreadId, turnId);
136
+ const title = normalizePreviewText(threadTitleById.get(resolvedThreadId)) || "Conversation";
137
+ const body = buildNotificationBody({
138
+ result,
139
+ state,
140
+ params,
141
+ eventObject,
142
+ previewMaxChars,
143
+ });
144
+
145
+ try {
146
+ completionDedupe.beginNotification({
147
+ dedupeKey,
148
+ threadId: resolvedThreadId,
149
+ turnId,
150
+ result,
151
+ });
152
+ await pushServiceClient.notifyCompletion({
153
+ threadId: resolvedThreadId,
154
+ turnId,
155
+ result,
156
+ title,
157
+ body,
158
+ dedupeKey,
159
+ });
160
+ completionDedupe.commitNotification({
161
+ dedupeKey,
162
+ threadId: resolvedThreadId,
163
+ turnId,
164
+ result,
165
+ });
166
+ } catch (error) {
167
+ completionDedupe.abortNotification({
168
+ dedupeKey,
169
+ threadId: resolvedThreadId,
170
+ turnId,
171
+ result,
172
+ });
173
+ console.error(`${logPrefix} push notify failed: ${error.message}`);
174
+ } finally {
175
+ cleanupTurnState(resolvedThreadId, turnId);
176
+ }
177
+ }
178
+
179
+ function recordAssistantDelta(threadId, turnId, params, eventObject) {
180
+ const resolvedTurnId = turnId || resolveTurnId("assistant", params, eventObject);
181
+ const resolvedThreadId = threadId || (resolvedTurnId ? threadIdByTurnId.get(resolvedTurnId) : null);
182
+ if (!resolvedThreadId || !resolvedTurnId) {
183
+ return;
184
+ }
185
+
186
+ const delta = extractAssistantDeltaText(params, eventObject);
187
+ if (!delta) {
188
+ return;
189
+ }
190
+
191
+ const state = ensureTurnState(resolvedThreadId, resolvedTurnId);
192
+ state.latestAssistantPreview = truncatePreview(`${state.latestAssistantPreview || ""}${delta}`, previewMaxChars);
193
+ }
194
+
195
+ function recordAssistantCompletion(threadId, turnId, params, eventObject) {
196
+ const resolvedTurnId = turnId || resolveTurnId("assistant", params, eventObject);
197
+ const resolvedThreadId = threadId || (resolvedTurnId ? threadIdByTurnId.get(resolvedTurnId) : null);
198
+ if (!resolvedThreadId || !resolvedTurnId) {
199
+ return;
200
+ }
201
+
202
+ const completedText = extractAssistantCompletedText(params, eventObject);
203
+ if (!completedText) {
204
+ return;
205
+ }
206
+
207
+ const state = ensureTurnState(resolvedThreadId, resolvedTurnId);
208
+ state.latestAssistantPreview = truncatePreview(completedText, previewMaxChars);
209
+ }
210
+
211
+ function recordFailure(threadId, turnId, params, eventObject) {
212
+ const resolvedTurnId = turnId || resolveTurnId("failure", params, eventObject);
213
+ const resolvedThreadId = threadId || (resolvedTurnId ? threadIdByTurnId.get(resolvedTurnId) : null);
214
+ if (!resolvedThreadId || !resolvedTurnId) {
215
+ return;
216
+ }
217
+
218
+ const failureMessage = extractFailureMessage(params, eventObject);
219
+ const state = ensureTurnState(resolvedThreadId, resolvedTurnId);
220
+ if (failureMessage) {
221
+ state.latestFailurePreview = truncatePreview(failureMessage, previewMaxChars);
222
+ }
223
+ }
224
+
225
+ function ensureTurnState(threadId, turnId) {
226
+ const key = turnStateKey(threadId, turnId);
227
+ if (!turnStateByKey.has(key)) {
228
+ turnStateByKey.set(key, {
229
+ latestAssistantPreview: "",
230
+ latestFailurePreview: "",
231
+ });
232
+ }
233
+
234
+ return turnStateByKey.get(key);
235
+ }
236
+
237
+ function getTurnState(threadId, turnId) {
238
+ if (!threadId) {
239
+ return null;
240
+ }
241
+ return turnStateByKey.get(turnStateKey(threadId, turnId)) || null;
242
+ }
243
+
244
+ function cleanupTurnState(threadId, turnId) {
245
+ if (!threadId) {
246
+ return;
247
+ }
248
+
249
+ const resolvedTurnId = turnId || null;
250
+ if (resolvedTurnId) {
251
+ threadIdByTurnId.delete(resolvedTurnId);
252
+ }
253
+ turnStateByKey.delete(turnStateKey(threadId, resolvedTurnId));
254
+ }
255
+
256
+ return {
257
+ handleOutbound,
258
+ };
259
+ }
260
+
261
+ // Normalizes the message envelope once so downstream helpers can share the same parsed view.
262
+ function parseOutboundMessage(rawMessage) {
263
+ const parsed = safeParseJSON(rawMessage);
264
+ if (!parsed || typeof parsed.method !== "string") {
265
+ return null;
266
+ }
267
+
268
+ const method = parsed.method.trim();
269
+ const params = objectValue(parsed.params) || {};
270
+ const eventObject = envelopeEventObject(params);
271
+
272
+ return {
273
+ method,
274
+ params,
275
+ eventObject,
276
+ threadId: resolveThreadId(method, params, eventObject),
277
+ turnId: resolveTurnId(method, params, eventObject),
278
+ };
279
+ }
280
+
281
+ function envelopeEventObject(params) {
282
+ if (params?.event && typeof params.event === "object") {
283
+ return params.event;
284
+ }
285
+ if (params?.msg && typeof params.msg === "object") {
286
+ return params.msg;
287
+ }
288
+ return null;
289
+ }
290
+
291
+ function resolveThreadId(method, params, eventObject) {
292
+ const candidates = [
293
+ params?.threadId,
294
+ params?.thread_id,
295
+ params?.conversationId,
296
+ params?.conversation_id,
297
+ params?.thread?.id,
298
+ params?.thread?.threadId,
299
+ params?.thread?.thread_id,
300
+ params?.turn?.threadId,
301
+ params?.turn?.thread_id,
302
+ eventObject?.threadId,
303
+ eventObject?.thread_id,
304
+ eventObject?.conversationId,
305
+ eventObject?.conversation_id,
306
+ ];
307
+
308
+ for (const candidate of candidates) {
309
+ const value = readString(candidate);
310
+ if (value) {
311
+ return value;
312
+ }
313
+ }
314
+
315
+ const turnId = resolveTurnId(method, params, eventObject);
316
+ if (turnId) {
317
+ return null;
318
+ }
319
+
320
+ return null;
321
+ }
322
+
323
+ function resolveTurnId(_method, params, eventObject) {
324
+ const itemObject = incomingItemObject(params, eventObject);
325
+ const candidates = [
326
+ params?.turnId,
327
+ params?.turn_id,
328
+ params?.id,
329
+ params?.turn?.id,
330
+ params?.turn?.turnId,
331
+ params?.turn?.turn_id,
332
+ eventObject?.id,
333
+ eventObject?.turnId,
334
+ eventObject?.turn_id,
335
+ itemObject?.turnId,
336
+ itemObject?.turn_id,
337
+ ];
338
+
339
+ for (const candidate of candidates) {
340
+ const value = readString(candidate);
341
+ if (value) {
342
+ return value;
343
+ }
344
+ }
345
+
346
+ return null;
347
+ }
348
+
349
+ function incomingItemObject(params, eventObject) {
350
+ if (params?.item && typeof params.item === "object") {
351
+ return params.item;
352
+ }
353
+ if (eventObject?.item && typeof eventObject.item === "object") {
354
+ return eventObject.item;
355
+ }
356
+ if (eventObject && typeof eventObject === "object" && typeof eventObject.type === "string") {
357
+ return eventObject;
358
+ }
359
+ return null;
360
+ }
361
+
362
+ function extractThreadTitle(params, eventObject) {
363
+ const threadObject = (params?.thread && typeof params.thread === "object") ? params.thread : null;
364
+ const candidates = [
365
+ params?.threadName,
366
+ params?.thread_name,
367
+ params?.name,
368
+ params?.title,
369
+ threadObject?.name,
370
+ threadObject?.title,
371
+ eventObject?.threadName,
372
+ eventObject?.thread_name,
373
+ eventObject?.name,
374
+ eventObject?.title,
375
+ ];
376
+
377
+ for (const candidate of candidates) {
378
+ const value = normalizePreviewText(candidate);
379
+ if (value) {
380
+ return value;
381
+ }
382
+ }
383
+
384
+ return null;
385
+ }
386
+
387
+ function isAssistantDeltaMethod(method) {
388
+ return method === "item/agentMessage/delta"
389
+ || method === "codex/event/agent_message_content_delta"
390
+ || method === "codex/event/agent_message_delta";
391
+ }
392
+
393
+ function isAssistantCompletedMethod(method, params, eventObject) {
394
+ if (method === "codex/event/agent_message") {
395
+ return true;
396
+ }
397
+
398
+ if (method !== "item/completed" && method !== "codex/event/item_completed") {
399
+ return false;
400
+ }
401
+
402
+ return isAssistantMessageItem(incomingItemObject(params, eventObject));
403
+ }
404
+
405
+ function isFailureEnvelope(method, eventObject) {
406
+ if (method === "error" || method === "codex/event/error") {
407
+ return true;
408
+ }
409
+
410
+ return readString(eventObject?.type) === "error";
411
+ }
412
+
413
+ function extractAssistantDeltaText(params, eventObject) {
414
+ const candidates = [
415
+ params?.delta,
416
+ params?.textDelta,
417
+ params?.text_delta,
418
+ eventObject?.delta,
419
+ eventObject?.text,
420
+ params?.event?.delta,
421
+ params?.event?.text,
422
+ ];
423
+
424
+ for (const candidate of candidates) {
425
+ const value = readString(candidate);
426
+ if (value) {
427
+ return value;
428
+ }
429
+ }
430
+
431
+ return "";
432
+ }
433
+
434
+ function extractAssistantCompletedText(params, eventObject) {
435
+ const itemObject = incomingItemObject(params, eventObject);
436
+ const candidates = [
437
+ itemObject?.message,
438
+ itemObject?.text,
439
+ itemObject?.summary,
440
+ params?.message,
441
+ eventObject?.message,
442
+ eventObject?.text,
443
+ ];
444
+
445
+ for (const candidate of candidates) {
446
+ const value = normalizePreviewText(candidate);
447
+ if (value) {
448
+ return value;
449
+ }
450
+ }
451
+
452
+ return "";
453
+ }
454
+
455
+ function extractFailureMessage(params, eventObject) {
456
+ const candidates = [
457
+ params?.message,
458
+ params?.error?.message,
459
+ params?.turn?.error?.message,
460
+ eventObject?.message,
461
+ eventObject?.error?.message,
462
+ eventObject?.turn?.error?.message,
463
+ ];
464
+
465
+ for (const candidate of candidates) {
466
+ const value = normalizePreviewText(candidate);
467
+ if (value) {
468
+ return value;
469
+ }
470
+ }
471
+
472
+ return "";
473
+ }
474
+
475
+ function resolveCompletionResult(params, eventObject) {
476
+ const rawStatus = readString(
477
+ params?.turn?.status
478
+ || params?.status
479
+ || eventObject?.turn?.status
480
+ || eventObject?.status
481
+ ) || "completed";
482
+
483
+ const normalizedStatus = rawStatus.toLowerCase();
484
+ if (normalizedStatus.includes("fail") || normalizedStatus.includes("error")) {
485
+ return "failed";
486
+ }
487
+ if (normalizedStatus.includes("interrupt") || normalizedStatus.includes("stop")) {
488
+ return null;
489
+ }
490
+
491
+ return "completed";
492
+ }
493
+
494
+ function completionDedupeKey({ sessionId, threadId, turnId, result, now }) {
495
+ if (turnId) {
496
+ return [sessionId || "", threadId, turnId, result].join("|");
497
+ }
498
+
499
+ const timeBucket = Math.floor(now() / 30_000);
500
+ return [sessionId || "", threadId, "no-turn", result, `bucket-${timeBucket}`].join("|");
501
+ }
502
+
503
+ // Mirrors the iOS terminal-state mapping so managed pushes fire on the same end states.
504
+ function resolveThreadStatusResult(params, eventObject) {
505
+ const statusObject = objectValue(params?.status)
506
+ || objectValue(eventObject?.status)
507
+ || objectValue(params?.event?.status);
508
+ const rawStatus = readString(
509
+ statusObject?.type
510
+ || statusObject?.statusType
511
+ || statusObject?.status_type
512
+ || params?.status
513
+ || eventObject?.status
514
+ || params?.event?.status
515
+ );
516
+ const normalizedStatus = normalizeStatusToken(rawStatus);
517
+ if (!normalizedStatus) {
518
+ return null;
519
+ }
520
+
521
+ if (
522
+ normalizedStatus.includes("cancel")
523
+ || normalizedStatus.includes("abort")
524
+ || normalizedStatus.includes("interrupt")
525
+ || normalizedStatus.includes("stopped")
526
+ ) {
527
+ return null;
528
+ }
529
+
530
+ if (normalizedStatus.includes("fail") || normalizedStatus.includes("error")) {
531
+ return "failed";
532
+ }
533
+
534
+ if (
535
+ normalizedStatus === "idle"
536
+ || normalizedStatus === "notloaded"
537
+ || normalizedStatus === "completed"
538
+ || normalizedStatus === "done"
539
+ || normalizedStatus === "finished"
540
+ ) {
541
+ return "completed";
542
+ }
543
+
544
+ return null;
545
+ }
546
+
547
+ function isTerminalThreadStatusMethod(method) {
548
+ return method === "thread/status/changed"
549
+ || method === "thread/status"
550
+ || method === "codex/event/thread_status_changed";
551
+ }
552
+
553
+ function isActiveThreadStatus(method, params, eventObject) {
554
+ if (!isTerminalThreadStatusMethod(method)) {
555
+ return false;
556
+ }
557
+
558
+ const statusObject = objectValue(params?.status)
559
+ || objectValue(eventObject?.status)
560
+ || objectValue(params?.event?.status);
561
+ const rawStatus = readString(
562
+ statusObject?.type
563
+ || statusObject?.statusType
564
+ || statusObject?.status_type
565
+ || params?.status
566
+ || eventObject?.status
567
+ || params?.event?.status
568
+ );
569
+ const normalizedStatus = normalizeStatusToken(rawStatus);
570
+
571
+ return normalizedStatus === "active"
572
+ || normalizedStatus === "running"
573
+ || normalizedStatus === "processing"
574
+ || normalizedStatus === "inprogress"
575
+ || normalizedStatus === "started"
576
+ || normalizedStatus === "pending";
577
+ }
578
+
579
+ function shouldIgnoreRetriableFailure(params, eventObject) {
580
+ const retryCandidates = [
581
+ params?.willRetry,
582
+ params?.will_retry,
583
+ eventObject?.willRetry,
584
+ eventObject?.will_retry,
585
+ params?.event?.willRetry,
586
+ params?.event?.will_retry,
587
+ ];
588
+
589
+ return retryCandidates.some((candidate) => parseBooleanFlag(candidate) === true);
590
+ }
591
+
592
+ function buildNotificationBody({ result, state, params, eventObject, previewMaxChars }) {
593
+ if (result === "failed") {
594
+ return truncatePreview(
595
+ state?.latestFailurePreview
596
+ || extractFailureMessage(params, eventObject)
597
+ || "Run failed",
598
+ previewMaxChars
599
+ ) || "Run failed";
600
+ }
601
+
602
+ return "Response ready";
603
+ }
604
+
605
+ function truncatePreview(value, limit) {
606
+ const normalized = normalizePreviewText(value);
607
+ if (!normalized) {
608
+ return "";
609
+ }
610
+
611
+ if (normalized.length <= limit) {
612
+ return normalized;
613
+ }
614
+
615
+ return `${normalized.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
616
+ }
617
+
618
+ function normalizePreviewText(value) {
619
+ if (typeof value !== "string") {
620
+ return "";
621
+ }
622
+
623
+ return value.replace(/\s+/g, " ").trim();
624
+ }
625
+
626
+ function normalizeStatusToken(value) {
627
+ return typeof value === "string"
628
+ ? value.toLowerCase().replace(/[_-\s]+/g, "")
629
+ : "";
630
+ }
631
+
632
+ function objectValue(value) {
633
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
634
+ }
635
+
636
+ function parseBooleanFlag(value) {
637
+ if (typeof value === "boolean") {
638
+ return value;
639
+ }
640
+ if (typeof value === "string") {
641
+ const normalizedValue = value.trim().toLowerCase();
642
+ if (normalizedValue === "true" || normalizedValue === "1") {
643
+ return true;
644
+ }
645
+ if (normalizedValue === "false" || normalizedValue === "0") {
646
+ return false;
647
+ }
648
+ }
649
+ return null;
650
+ }
651
+
652
+ function readString(value) {
653
+ return typeof value === "string" && value.trim() ? value.trim() : null;
654
+ }
655
+
656
+ function isAssistantMessageItem(itemObject) {
657
+ if (!itemObject || typeof itemObject !== "object") {
658
+ return false;
659
+ }
660
+
661
+ const normalizedType = normalizeToken(itemObject.type);
662
+ const normalizedRole = normalizeToken(itemObject.role);
663
+ return normalizedType === "agentmessage"
664
+ || normalizedType === "assistantmessage"
665
+ || normalizedRole === "assistant";
666
+ }
667
+
668
+ function normalizeToken(value) {
669
+ return typeof value === "string"
670
+ ? value.toLowerCase().replace(/[_-\s]+/g, "")
671
+ : "";
672
+ }
673
+
674
+ function safeParseJSON(value) {
675
+ try {
676
+ return JSON.parse(value);
677
+ } catch {
678
+ return null;
679
+ }
680
+ }
681
+
682
+ function turnStateKey(threadId, turnId) {
683
+ return `${threadId}|${turnId || "no-turn"}`;
684
+ }
685
+
686
+ module.exports = {
687
+ createPushNotificationTracker,
688
+ };
package/src/qr.js ADDED
@@ -0,0 +1,19 @@
1
+ // FILE: qr.js
2
+ // Purpose: Prints the bridge QR payload for explicit iPhone pairing.
3
+ // Layer: CLI helper
4
+ // Exports: printQR
5
+ // Depends on: qrcode-terminal
6
+
7
+ const qrcode = require("qrcode-terminal");
8
+
9
+ function printQR(pairingPayload) {
10
+ const payload = JSON.stringify(pairingPayload);
11
+
12
+ console.log("\nScan this QR with the iPhone:\n");
13
+ qrcode.generate(payload, { small: true });
14
+ console.log(`\nSession ID: ${pairingPayload.sessionId}`);
15
+ console.log(`Device ID: ${pairingPayload.macDeviceId}`);
16
+ console.log(`Expires: ${new Date(pairingPayload.expiresAt).toISOString()}\n`);
17
+ }
18
+
19
+ module.exports = { printQR };