@btatum5/codex-bridge 0.1.0 → 1.3.3

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,147 @@
1
+ // FILE: push-notification-completion-dedupe.js
2
+ // Purpose: Owns duplicate-suppression state for completion pushes emitted by the bridge.
3
+ // Layer: Bridge helper
4
+ // Exports: createPushNotificationCompletionDedupe
5
+ // Depends on: none
6
+
7
+ const DEFAULT_SENT_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
8
+ const DEFAULT_STATUS_FALLBACK_TTL_MS = 5_000;
9
+
10
+ function createPushNotificationCompletionDedupe({
11
+ now = () => Date.now(),
12
+ sentDedupeTTLms = DEFAULT_SENT_DEDUPE_TTL_MS,
13
+ statusFallbackTTLms = DEFAULT_STATUS_FALLBACK_TTL_MS,
14
+ } = {}) {
15
+ const sentDedupeKeys = new Map();
16
+ const pendingDedupeKeys = new Set();
17
+ const recentTurnScopedCompletionsByThread = new Map();
18
+
19
+ function clearForNewRun(threadId) {
20
+ if (!readString(threadId)) {
21
+ return;
22
+ }
23
+
24
+ recentTurnScopedCompletionsByThread.delete(threadId);
25
+ }
26
+
27
+ // Thread-level terminal events are only a fallback when we have not already sent a turn-scoped completion.
28
+ function shouldSuppressThreadStatusFallback({ threadId, turnId, result } = {}) {
29
+ if (readString(turnId)) {
30
+ return false;
31
+ }
32
+
33
+ pruneRecentTurnScopedCompletions();
34
+ const previous = recentTurnScopedCompletionsByThread.get(readString(threadId));
35
+ return previous?.result === result;
36
+ }
37
+
38
+ function hasActiveDedupeKey(dedupeKey) {
39
+ const normalizedKey = readString(dedupeKey);
40
+ if (!normalizedKey) {
41
+ return false;
42
+ }
43
+
44
+ pruneSentDedupeKeys();
45
+ return sentDedupeKeys.has(normalizedKey) || pendingDedupeKeys.has(normalizedKey);
46
+ }
47
+
48
+ function beginNotification({ dedupeKey, threadId, turnId, result } = {}) {
49
+ const normalizedKey = readString(dedupeKey);
50
+ if (!normalizedKey) {
51
+ return;
52
+ }
53
+
54
+ pendingDedupeKeys.add(normalizedKey);
55
+ if (readString(turnId)) {
56
+ rememberTurnScopedCompletion(threadId, result);
57
+ }
58
+ }
59
+
60
+ function commitNotification({ dedupeKey, threadId, turnId, result } = {}) {
61
+ const normalizedKey = readString(dedupeKey);
62
+ if (normalizedKey) {
63
+ sentDedupeKeys.set(normalizedKey, now());
64
+ pendingDedupeKeys.delete(normalizedKey);
65
+ }
66
+
67
+ if (readString(turnId)) {
68
+ rememberTurnScopedCompletion(threadId, result);
69
+ }
70
+ }
71
+
72
+ function abortNotification({ dedupeKey, threadId, turnId, result } = {}) {
73
+ const normalizedKey = readString(dedupeKey);
74
+ if (normalizedKey) {
75
+ pendingDedupeKeys.delete(normalizedKey);
76
+ }
77
+
78
+ const normalizedThreadId = readString(threadId);
79
+ if (!readString(turnId) || !normalizedThreadId) {
80
+ return;
81
+ }
82
+
83
+ const previous = recentTurnScopedCompletionsByThread.get(normalizedThreadId);
84
+ if (previous?.result === result) {
85
+ recentTurnScopedCompletionsByThread.delete(normalizedThreadId);
86
+ }
87
+ }
88
+
89
+ // Exposed for focused tests so we can prove dedupe state stays bounded.
90
+ function debugState() {
91
+ pruneSentDedupeKeys();
92
+ pruneRecentTurnScopedCompletions();
93
+ return {
94
+ sentDedupeKeys: sentDedupeKeys.size,
95
+ pendingDedupeKeys: pendingDedupeKeys.size,
96
+ recentThreadFallbacks: recentTurnScopedCompletionsByThread.size,
97
+ };
98
+ }
99
+
100
+ function rememberTurnScopedCompletion(threadId, result) {
101
+ const normalizedThreadId = readString(threadId);
102
+ if (!normalizedThreadId) {
103
+ return;
104
+ }
105
+
106
+ recentTurnScopedCompletionsByThread.set(normalizedThreadId, {
107
+ result,
108
+ timestamp: now(),
109
+ });
110
+ }
111
+
112
+ function pruneSentDedupeKeys() {
113
+ const cutoff = now() - sentDedupeTTLms;
114
+ for (const [dedupeKey, timestamp] of sentDedupeKeys.entries()) {
115
+ if (timestamp < cutoff) {
116
+ sentDedupeKeys.delete(dedupeKey);
117
+ }
118
+ }
119
+ }
120
+
121
+ function pruneRecentTurnScopedCompletions() {
122
+ const cutoff = now() - statusFallbackTTLms;
123
+ for (const [threadId, entry] of recentTurnScopedCompletionsByThread.entries()) {
124
+ if (entry.timestamp < cutoff) {
125
+ recentTurnScopedCompletionsByThread.delete(threadId);
126
+ }
127
+ }
128
+ }
129
+
130
+ return {
131
+ abortNotification,
132
+ beginNotification,
133
+ clearForNewRun,
134
+ commitNotification,
135
+ debugState,
136
+ hasActiveDedupeKey,
137
+ shouldSuppressThreadStatusFallback,
138
+ };
139
+ }
140
+
141
+ function readString(value) {
142
+ return typeof value === "string" && value.trim() ? value.trim() : "";
143
+ }
144
+
145
+ module.exports = {
146
+ createPushNotificationCompletionDedupe,
147
+ };
@@ -0,0 +1,151 @@
1
+ // FILE: push-notification-service-client.js
2
+ // Purpose: Sends push registration and completion requests from the local Mac bridge to the configured notification service.
3
+ // Layer: Bridge helper
4
+ // Exports: createPushNotificationServiceClient
5
+ // Depends on: global fetch
6
+
7
+ const DEFAULT_PUSH_SERVICE_TIMEOUT_MS = 10_000;
8
+
9
+ function createPushNotificationServiceClient({
10
+ baseUrl = "",
11
+ sessionId,
12
+ notificationSecret,
13
+ fetchImpl = globalThis.fetch,
14
+ logPrefix = "[codex-bridge]",
15
+ requestTimeoutMs = DEFAULT_PUSH_SERVICE_TIMEOUT_MS,
16
+ } = {}) {
17
+ const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
18
+
19
+ async function registerDevice({
20
+ deviceToken,
21
+ alertsEnabled,
22
+ apnsEnvironment,
23
+ } = {}) {
24
+ return postJSON("/v1/push/session/register-device", {
25
+ sessionId,
26
+ notificationSecret,
27
+ deviceToken,
28
+ alertsEnabled,
29
+ apnsEnvironment,
30
+ });
31
+ }
32
+
33
+ async function notifyCompletion({
34
+ threadId,
35
+ turnId,
36
+ result,
37
+ title,
38
+ body,
39
+ dedupeKey,
40
+ } = {}) {
41
+ return postJSON("/v1/push/session/notify-completion", {
42
+ sessionId,
43
+ notificationSecret,
44
+ threadId,
45
+ turnId,
46
+ result,
47
+ title,
48
+ body,
49
+ dedupeKey,
50
+ });
51
+ }
52
+
53
+ async function postJSON(pathname, payload) {
54
+ if (!normalizedBaseUrl || typeof fetchImpl !== "function") {
55
+ return { ok: false, skipped: true };
56
+ }
57
+
58
+ const controller = typeof AbortController === "function" && requestTimeoutMs > 0
59
+ ? new AbortController()
60
+ : null;
61
+ const timeoutID = controller
62
+ ? setTimeout(() => {
63
+ controller.abort(createTimeoutAbortError(requestTimeoutMs));
64
+ }, requestTimeoutMs)
65
+ : null;
66
+
67
+ let response;
68
+ try {
69
+ response = await fetchImpl(`${normalizedBaseUrl}${pathname}`, {
70
+ method: "POST",
71
+ headers: {
72
+ "content-type": "application/json",
73
+ },
74
+ body: JSON.stringify(payload),
75
+ signal: controller?.signal,
76
+ });
77
+ } catch (error) {
78
+ if (isAbortError(error)) {
79
+ const timeoutError = new Error(`Push service request timed out after ${requestTimeoutMs}ms`);
80
+ timeoutError.code = "push_request_timeout";
81
+ throw timeoutError;
82
+ }
83
+ throw error;
84
+ } finally {
85
+ if (timeoutID) {
86
+ clearTimeout(timeoutID);
87
+ }
88
+ }
89
+
90
+ const responseText = await response.text();
91
+ const parsed = safeParseJSON(responseText);
92
+ if (!response.ok) {
93
+ const message = parsed?.error || parsed?.message || responseText || `HTTP ${response.status}`;
94
+ const error = new Error(message);
95
+ error.status = response.status;
96
+ throw error;
97
+ }
98
+
99
+ return parsed ?? { ok: true };
100
+ }
101
+
102
+ return {
103
+ hasConfiguredBaseUrl: Boolean(normalizedBaseUrl),
104
+ registerDevice,
105
+ notifyCompletion,
106
+ logUnavailable() {
107
+ if (!normalizedBaseUrl) {
108
+ console.log(`${logPrefix} push notifications disabled: no push service URL configured`);
109
+ }
110
+ },
111
+ };
112
+ }
113
+
114
+ function normalizeBaseUrl(value) {
115
+ if (typeof value !== "string") {
116
+ return "";
117
+ }
118
+
119
+ const trimmed = value.trim();
120
+ if (!trimmed) {
121
+ return "";
122
+ }
123
+
124
+ return trimmed.replace(/\/+$/, "");
125
+ }
126
+
127
+ function createTimeoutAbortError(timeoutMs) {
128
+ const error = new Error(`Push service request timed out after ${timeoutMs}ms`);
129
+ error.name = "AbortError";
130
+ return error;
131
+ }
132
+
133
+ function isAbortError(error) {
134
+ return error?.name === "AbortError" || error?.code === "ABORT_ERR";
135
+ }
136
+
137
+ function safeParseJSON(value) {
138
+ if (!value || typeof value !== "string") {
139
+ return null;
140
+ }
141
+
142
+ try {
143
+ return JSON.parse(value);
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ module.exports = {
150
+ createPushNotificationServiceClient,
151
+ };