@makerbi/remodex 1.5.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,13 +5,14 @@
5
5
  // Depends on: child_process, fs, os, path, ./bridge, ./daemon-state, ./codex-desktop-refresher, ./qr, ./secure-device-state
6
6
 
7
7
  const { execFileSync } = require("child_process");
8
+ const { createHash } = require("crypto");
8
9
  const fs = require("fs");
9
10
  const os = require("os");
10
11
  const path = require("path");
11
12
  const { startBridge } = require("./bridge");
12
13
  const { readBridgeConfig } = require("./codex-desktop-refresher");
13
14
  const { printQR } = require("./qr");
14
- const { resetBridgeDeviceState } = require("./secure-device-state");
15
+ const { readBridgeDeviceState, resetBridgeTrustState } = require("./secure-device-state");
15
16
  const {
16
17
  clearBridgeStatus,
17
18
  clearPairingSession,
@@ -122,6 +123,65 @@ async function startMacOSBridgeService({
122
123
  };
123
124
  }
124
125
 
126
+ // Restarts the installed LaunchAgent without rewriting relay config, useful during local bridge development.
127
+ async function restartMacOSBridgeService({
128
+ env = process.env,
129
+ platform = process.platform,
130
+ fsImpl = fs,
131
+ execFileSyncImpl = execFileSync,
132
+ osImpl = os,
133
+ waitForPairing = false,
134
+ pairingTimeoutMs = DEFAULT_PAIRING_WAIT_TIMEOUT_MS,
135
+ pairingPollIntervalMs = DEFAULT_PAIRING_WAIT_INTERVAL_MS,
136
+ ...startOptions
137
+ } = {}) {
138
+ assertDarwinPlatform(platform);
139
+ const plistPath = resolveLaunchAgentPlistPath({ env, osImpl });
140
+ if (!fsImpl.existsSync(plistPath)) {
141
+ return startMacOSBridgeService({
142
+ env,
143
+ platform,
144
+ fsImpl,
145
+ execFileSyncImpl,
146
+ osImpl,
147
+ waitForPairing,
148
+ pairingTimeoutMs,
149
+ pairingPollIntervalMs,
150
+ ...startOptions,
151
+ });
152
+ }
153
+
154
+ const startedAt = Date.now();
155
+ if (waitForPairing) {
156
+ clearPairingSession({ env, fsImpl });
157
+ }
158
+
159
+ kickstartLaunchAgent({
160
+ env,
161
+ execFileSyncImpl,
162
+ plistPath,
163
+ });
164
+
165
+ if (waitForPairing) {
166
+ const pairingSession = await waitForFreshPairingSession({
167
+ env,
168
+ fsImpl,
169
+ startedAt,
170
+ timeoutMs: pairingTimeoutMs,
171
+ intervalMs: pairingPollIntervalMs,
172
+ });
173
+ return {
174
+ plistPath,
175
+ pairingSession,
176
+ };
177
+ }
178
+
179
+ return {
180
+ plistPath,
181
+ pairingSession: null,
182
+ };
183
+ }
184
+
125
185
  function stopMacOSBridgeService({
126
186
  env = process.env,
127
187
  platform = process.platform,
@@ -150,7 +210,7 @@ function resetMacOSBridgePairing({
150
210
  platform = process.platform,
151
211
  execFileSyncImpl = execFileSync,
152
212
  fsImpl = fs,
153
- resetBridgePairingImpl = resetBridgeDeviceState,
213
+ resetBridgePairingImpl = resetBridgeTrustState,
154
214
  } = {}) {
155
215
  assertDarwinPlatform(platform);
156
216
  stopMacOSBridgeService({
@@ -217,6 +277,7 @@ function getMacOSBridgeServiceStatus({
217
277
  daemonConfig: sanitizedDaemonConfigForStatus(readDaemonConfig({ env, fsImpl })),
218
278
  bridgeStatus: readBridgeStatus({ env, fsImpl }),
219
279
  pairingSession: readPairingSession({ env, fsImpl }),
280
+ trustedDevice: buildTrustedDeviceSummary(readBridgeDeviceState()),
220
281
  stdoutLogPath: resolveBridgeStdoutLogPath({ env }),
221
282
  stderrLogPath: resolveBridgeStderrLogPath({ env }),
222
283
  };
@@ -242,12 +303,18 @@ function printMacOSBridgeServiceStatus(options = {}) {
242
303
  const bridgeState = status.bridgeStatus?.state || "unknown";
243
304
  const connectionStatus = status.bridgeStatus?.connectionStatus || "unknown";
244
305
  const pairingCreatedAt = status.pairingSession?.createdAt || "none";
306
+ const activeDevice = status.bridgeStatus?.activeDevice || status.bridgeStatus?.activePhone;
307
+ const trustedPhoneCount = status.trustedDevice?.trustedPhoneCount || 0;
308
+ const activeDeviceName = formatDeviceKind(activeDevice?.deviceKind) || "device";
309
+ const trustedDeviceName = formatDeviceKind(status.trustedDevice?.lastSeenDeviceKind) || "device";
245
310
  console.log(`[remodex] Service label: ${status.label}`);
246
311
  console.log(`[remodex] Installed: ${status.installed ? "yes" : "no"}`);
247
312
  console.log(`[remodex] Launchd loaded: ${status.launchdLoaded ? "yes" : "no"}`);
248
313
  console.log(`[remodex] PID: ${status.launchdPid || status.bridgeStatus?.pid || "unknown"}`);
249
314
  console.log(`[remodex] Bridge state: ${bridgeState}`);
250
315
  console.log(`[remodex] Connection: ${connectionStatus}`);
316
+ console.log(`[remodex] Active ${activeDeviceName}: ${activeDevice?.connected ? activeDevice.phoneFingerprint || "yes" : "no"}`);
317
+ console.log(`[remodex] Trusted ${trustedDeviceName}: ${trustedPhoneCount > 0 ? "yes" : "no"}`);
251
318
  console.log(`[remodex] Pairing payload: ${pairingCreatedAt}`);
252
319
  console.log(`[remodex] Stdout log: ${status.stdoutLogPath}`);
253
320
  console.log(`[remodex] Stderr log: ${status.stderrLogPath}`);
@@ -388,6 +455,31 @@ function restartLaunchAgent({
388
455
  ], { stdio: ["ignore", "ignore", "pipe"] });
389
456
  }
390
457
 
458
+ function kickstartLaunchAgent({
459
+ env = process.env,
460
+ execFileSyncImpl = execFileSync,
461
+ plistPath,
462
+ } = {}) {
463
+ try {
464
+ execFileSyncImpl("launchctl", [
465
+ "kickstart",
466
+ "-k",
467
+ launchAgentLabelDomain(env),
468
+ ], { stdio: ["ignore", "ignore", "pipe"] });
469
+ } catch {
470
+ execFileSyncImpl("launchctl", [
471
+ "bootstrap",
472
+ launchAgentDomain(env),
473
+ plistPath,
474
+ ], { stdio: ["ignore", "ignore", "pipe"] });
475
+ execFileSyncImpl("launchctl", [
476
+ "kickstart",
477
+ "-k",
478
+ launchAgentLabelDomain(env),
479
+ ], { stdio: ["ignore", "ignore", "pipe"] });
480
+ }
481
+ }
482
+
391
483
  function bootoutLaunchAgent({
392
484
  env = process.env,
393
485
  execFileSyncImpl = execFileSync,
@@ -544,7 +636,44 @@ function normalizeNonEmptyString(value) {
544
636
  return typeof value === "string" && value.trim() ? value.trim() : "";
545
637
  }
546
638
 
639
+ function buildTrustedDeviceSummary(deviceState) {
640
+ const trustedPhoneEntries = Object.entries(deviceState?.trustedPhones || {})
641
+ .filter(([phoneDeviceId, publicKey]) => normalizeNonEmptyString(phoneDeviceId) && normalizeNonEmptyString(publicKey));
642
+ const firstTrustedPhoneId = trustedPhoneEntries[0]?.[0] || "";
643
+ const lastSeenPhoneAppVersion = normalizeNonEmptyString(deviceState?.lastSeenPhoneAppVersion) || null;
644
+ return {
645
+ macDeviceFingerprint: shortFingerprint(deviceState?.macDeviceId),
646
+ trustedPhoneCount: trustedPhoneEntries.length,
647
+ trustedPhoneFingerprint: shortFingerprint(firstTrustedPhoneId),
648
+ lastSeenDeviceKind: normalizeNonEmptyString(deviceState?.lastSeenDeviceKind) || (lastSeenPhoneAppVersion ? "iphone" : null),
649
+ lastSeenPhoneAppVersion,
650
+ };
651
+ }
652
+
653
+ function formatDeviceKind(deviceKind) {
654
+ const normalized = normalizeNonEmptyString(deviceKind).toLowerCase();
655
+ if (normalized === "iphone") {
656
+ return "iPhone";
657
+ }
658
+ if (normalized === "android") {
659
+ return "Android";
660
+ }
661
+ if (normalized === "mac") {
662
+ return "Mac";
663
+ }
664
+ return "";
665
+ }
666
+
667
+ function shortFingerprint(value) {
668
+ const normalized = normalizeNonEmptyString(value);
669
+ if (!normalized) {
670
+ return null;
671
+ }
672
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 8);
673
+ }
674
+
547
675
  module.exports = {
676
+ buildTrustedDeviceSummary,
548
677
  buildLaunchAgentPlist,
549
678
  getMacOSBridgeServiceStatus,
550
679
  mergeBridgeStatusForDaemon,
@@ -552,6 +681,7 @@ module.exports = {
552
681
  printMacOSBridgeServiceStatus,
553
682
  resetMacOSBridgePairing,
554
683
  resolveLaunchAgentPlistPath,
684
+ restartMacOSBridgeService,
555
685
  runMacOSBridgeService,
556
686
  startMacOSBridgeService,
557
687
  stopMacOSBridgeService,
@@ -2,11 +2,12 @@
2
2
  // Purpose: Serves safe Mac-local project folder discovery and creation requests from the iOS app.
3
3
  // Layer: Bridge handler
4
4
  // Exports: handleProjectRequest plus testable project filesystem helpers
5
- // Depends on: fs, os, path
5
+ // Depends on: fs, os, path, ./codex-home
6
6
 
7
7
  const fs = require("fs");
8
8
  const os = require("os");
9
9
  const path = require("path");
10
+ const { resolveCodexHome } = require("./codex-home");
10
11
 
11
12
  const DEFAULT_DIRECTORY_LIMIT = 200;
12
13
  const DEFAULT_DIRECTORY_SEARCH_LIMIT = 80;
@@ -14,6 +15,14 @@ const DEFAULT_DIRECTORY_SEARCH_MAX_DEPTH = 8;
14
15
  const DEFAULT_DIRECTORY_SEARCH_MAX_VISITED = 5000;
15
16
  const DEFAULT_HIDDEN_DIRECTORY_NAMES = new Set(["Library"]);
16
17
 
18
+ // Rootless chat slug rules mirror Codex Desktop's `~/Documents/Codex/<DATE>/<slug>`
19
+ // convention so iOS-created Quick Chats land in the same bucket Desktop classifies
20
+ // as projectless.
21
+ const ROOTLESS_CHAT_SLUG_MAX_TOKENS = 6;
22
+ const ROOTLESS_CHAT_SLUG_MAX_LENGTH = 60;
23
+ const ROOTLESS_CHAT_SLUG_FALLBACK = "new-chat";
24
+ const ROOTLESS_CHAT_DEDUP_LIMIT = 50;
25
+
17
26
  // ─── ENTRY POINT ─────────────────────────────────────────────
18
27
 
19
28
  function handleProjectRequest(rawMessage, sendResponse) {
@@ -58,6 +67,8 @@ async function handleProjectMethod(method, params, options = {}) {
58
67
  switch (method) {
59
68
  case "project/quickLocations":
60
69
  return projectQuickLocations(options);
70
+ case "project/projectlessRoots":
71
+ return projectProjectlessRoots(options);
61
72
  case "project/listDirectory":
62
73
  return projectListDirectory(params, options);
63
74
  case "project/searchDirectories":
@@ -66,6 +77,8 @@ async function handleProjectMethod(method, params, options = {}) {
66
77
  return projectValidatePath(params, options);
67
78
  case "project/createDirectory":
68
79
  return projectCreateDirectory(params, options);
80
+ case "project/createRootlessChatRoot":
81
+ return projectCreateRootlessChatRoot(params, options);
69
82
  default:
70
83
  throw projectError("unknown_method", `Unknown project method: ${method}`);
71
84
  }
@@ -99,6 +112,24 @@ async function projectQuickLocations(options = {}) {
99
112
  return { locations };
100
113
  }
101
114
 
115
+ async function projectProjectlessRoots(options = {}) {
116
+ const homeDir = resolveHomeDir(options);
117
+ const codexHome = path.resolve(readString(options.codexHome) || resolveCodexHome());
118
+ const documentedThreadsRoot = path.join(codexHome, "threads");
119
+ const desktopDocumentsRoot = path.join(homeDir, "Documents", "Codex");
120
+ const roots = uniqueExistingOrCandidatePaths([
121
+ documentedThreadsRoot,
122
+ desktopDocumentsRoot,
123
+ ]);
124
+
125
+ return {
126
+ codexHome,
127
+ roots,
128
+ documentedThreadsRoot,
129
+ desktopDocumentsRoot,
130
+ };
131
+ }
132
+
102
133
  async function projectListDirectory(params, options = {}) {
103
134
  const requestedPath = readString(params.path) || resolveHomeDir(options);
104
135
  const directory = await requireUsableDirectory(requestedPath, options);
@@ -184,6 +215,116 @@ async function projectCreateDirectory(params, options = {}) {
184
215
  };
185
216
  }
186
217
 
218
+ // Mirrors the Codex Desktop "Chats" convention by materializing a dated rootless
219
+ // chat folder under `~/Documents/Codex/<DATE>/<slug>` (or its Windows equivalent
220
+ // `%USERPROFILE%\Documents\Codex\<DATE>\<slug>`) before the iOS client issues
221
+ // `thread/start`. Without an explicit cwd the app-server falls back to its own
222
+ // process working directory (often the user's home), which would otherwise show
223
+ // up in the sidebar as a project named after the user account.
224
+ async function projectCreateRootlessChatRoot(params = {}, options = {}) {
225
+ const homeDir = resolveHomeDir(options);
226
+ const desktopDocumentsRoot = path.join(homeDir, "Documents", "Codex");
227
+ const dateFolder = readString(params.dateFolder) || formatRootlessChatDate(new Date());
228
+ if (!isISODateFolderName(dateFolder)) {
229
+ throw projectError("invalid_date_folder", "The chat date folder must be in YYYY-MM-DD format.");
230
+ }
231
+
232
+ const slugBase = rootlessChatSlugFromPromptHint(params.promptHint);
233
+ const dateRootPath = path.join(desktopDocumentsRoot, dateFolder);
234
+
235
+ try {
236
+ await fs.promises.mkdir(dateRootPath, { recursive: true });
237
+ } catch (error) {
238
+ throw projectError("create_failed", error?.message || "Unable to prepare the Codex chats folder.");
239
+ }
240
+
241
+ const targetPath = await reserveUniqueRootlessChatPath(dateRootPath, slugBase);
242
+ try {
243
+ await fs.promises.mkdir(targetPath, { recursive: false });
244
+ } catch (error) {
245
+ if (error?.code !== "EEXIST") {
246
+ throw projectError("create_failed", error?.message || "Unable to create the rootless chat folder.");
247
+ }
248
+ }
249
+
250
+ const resolvedPath = await safeRealpath(targetPath);
251
+ return {
252
+ path: resolvedPath,
253
+ parentPath: dateRootPath,
254
+ name: path.basename(resolvedPath),
255
+ slug: slugBase,
256
+ dateFolder,
257
+ root: desktopDocumentsRoot,
258
+ };
259
+ }
260
+
261
+ // Codex Desktop generates kebab-case slugs from the first words of the prompt
262
+ // (e.g. "mi-dici-la-pwd-corrente" from "mi dici la pwd corrente?"). We mirror
263
+ // the same shape so that side-by-side users with the desktop app see a
264
+ // consistent chat folder naming scheme.
265
+ function rootlessChatSlugFromPromptHint(rawPromptHint) {
266
+ const hint = typeof rawPromptHint === "string" ? rawPromptHint.normalize("NFKD") : "";
267
+ const sanitized = hint
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/gu, " ")
270
+ .trim();
271
+ if (!sanitized) {
272
+ return ROOTLESS_CHAT_SLUG_FALLBACK;
273
+ }
274
+
275
+ const tokens = sanitized
276
+ .split(/\s+/)
277
+ .filter(Boolean)
278
+ .slice(0, ROOTLESS_CHAT_SLUG_MAX_TOKENS);
279
+ if (!tokens.length) {
280
+ return ROOTLESS_CHAT_SLUG_FALLBACK;
281
+ }
282
+
283
+ let slug = tokens.join("-");
284
+ if (slug.length > ROOTLESS_CHAT_SLUG_MAX_LENGTH) {
285
+ slug = slug.slice(0, ROOTLESS_CHAT_SLUG_MAX_LENGTH).replace(/-+$/g, "");
286
+ }
287
+ return slug || ROOTLESS_CHAT_SLUG_FALLBACK;
288
+ }
289
+
290
+ // Appends "-2", "-3", … so two rapid-fire chats with the same first words
291
+ // keep distinct folders instead of fighting over the same path.
292
+ async function reserveUniqueRootlessChatPath(parentDirectory, slugBase) {
293
+ for (let attempt = 0; attempt < ROOTLESS_CHAT_DEDUP_LIMIT; attempt += 1) {
294
+ const candidateSlug = attempt === 0 ? slugBase : `${slugBase}-${attempt + 1}`;
295
+ const candidatePath = path.join(parentDirectory, candidateSlug);
296
+ try {
297
+ await fs.promises.access(candidatePath, fs.constants.F_OK);
298
+ } catch {
299
+ return candidatePath;
300
+ }
301
+ }
302
+
303
+ return path.join(parentDirectory, `${slugBase}-${Date.now()}`);
304
+ }
305
+
306
+ function formatRootlessChatDate(date) {
307
+ const year = date.getFullYear().toString().padStart(4, "0");
308
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
309
+ const day = date.getDate().toString().padStart(2, "0");
310
+ return `${year}-${month}-${day}`;
311
+ }
312
+
313
+ function isISODateFolderName(value) {
314
+ if (typeof value !== "string" || value.length !== 10) {
315
+ return false;
316
+ }
317
+ return /^\d{4}-\d{2}-\d{2}$/u.test(value);
318
+ }
319
+
320
+ async function safeRealpath(candidatePath) {
321
+ try {
322
+ return await fs.promises.realpath(candidatePath);
323
+ } catch {
324
+ return path.resolve(candidatePath);
325
+ }
326
+ }
327
+
187
328
  // ─── Filesystem Helpers ──────────────────────────────────────
188
329
 
189
330
  async function readDirectoryEntries(directoryPath, options = {}) {
@@ -462,6 +603,23 @@ function resolveHomeDir(options = {}) {
462
603
  return options.homeDir || os.homedir();
463
604
  }
464
605
 
606
+ function uniqueExistingOrCandidatePaths(paths) {
607
+ const seen = new Set();
608
+ const result = [];
609
+
610
+ for (const candidatePath of paths) {
611
+ const normalizedPath = path.resolve(candidatePath);
612
+ const realPath = realpathSyncIfAvailable(normalizedPath) || normalizedPath;
613
+ if (seen.has(realPath)) {
614
+ continue;
615
+ }
616
+ seen.add(realPath);
617
+ result.push(realPath);
618
+ }
619
+
620
+ return result;
621
+ }
622
+
465
623
  function realpathSyncIfAvailable(candidatePath) {
466
624
  try {
467
625
  return fs.realpathSync(candidatePath);
@@ -485,9 +643,12 @@ module.exports = {
485
643
  handleProjectRequest,
486
644
  handleProjectMethod,
487
645
  projectQuickLocations,
646
+ projectProjectlessRoots,
488
647
  projectListDirectory,
489
648
  projectSearchDirectories,
490
649
  projectValidatePath,
491
650
  projectCreateDirectory,
651
+ projectCreateRootlessChatRoot,
492
652
  validateDirectory,
653
+ rootlessChatSlugFromPromptHint,
493
654
  };
@@ -5,6 +5,8 @@
5
5
  // Depends on: global fetch
6
6
 
7
7
  const DEFAULT_PUSH_SERVICE_TIMEOUT_MS = 10_000;
8
+ const DEFAULT_PUSH_SERVICE_RETRY_LIMIT = 2;
9
+ const DEFAULT_PUSH_SERVICE_RETRY_BASE_DELAY_MS = 500;
8
10
 
9
11
  function createPushNotificationServiceClient({
10
12
  baseUrl = "",
@@ -13,8 +15,16 @@ function createPushNotificationServiceClient({
13
15
  fetchImpl = globalThis.fetch,
14
16
  logPrefix = "[remodex]",
15
17
  requestTimeoutMs = DEFAULT_PUSH_SERVICE_TIMEOUT_MS,
18
+ retryLimit = DEFAULT_PUSH_SERVICE_RETRY_LIMIT,
19
+ retryBaseDelayMs = DEFAULT_PUSH_SERVICE_RETRY_BASE_DELAY_MS,
20
+ sleepImpl = sleep,
16
21
  } = {}) {
17
22
  const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
23
+ const safeRetryLimit = normalizeNonNegativeInteger(retryLimit, DEFAULT_PUSH_SERVICE_RETRY_LIMIT);
24
+ const safeRetryBaseDelayMs = normalizeNonNegativeInteger(
25
+ retryBaseDelayMs,
26
+ DEFAULT_PUSH_SERVICE_RETRY_BASE_DELAY_MS
27
+ );
18
28
 
19
29
  async function registerDevice({
20
30
  deviceToken,
@@ -55,48 +65,70 @@ function createPushNotificationServiceClient({
55
65
  return { ok: false, skipped: true };
56
66
  }
57
67
 
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;
68
+ const bodyJSON = JSON.stringify(payload);
69
+ let lastError = null;
70
+ for (let attempt = 0; attempt <= safeRetryLimit; attempt += 1) {
71
+ if (attempt > 0) {
72
+ const delayMs = safeRetryBaseDelayMs * Math.pow(2, attempt - 1);
73
+ if (delayMs > 0) {
74
+ await sleepImpl(delayMs);
75
+ }
82
76
  }
83
- throw error;
84
- } finally {
85
- if (timeoutID) {
86
- clearTimeout(timeoutID);
77
+
78
+ const controller = typeof AbortController === "function" && requestTimeoutMs > 0
79
+ ? new AbortController()
80
+ : null;
81
+ const timeoutID = controller
82
+ ? setTimeout(() => {
83
+ controller.abort(createTimeoutAbortError(requestTimeoutMs));
84
+ }, requestTimeoutMs)
85
+ : null;
86
+
87
+ let response;
88
+ try {
89
+ response = await fetchImpl(`${normalizedBaseUrl}${pathname}`, {
90
+ method: "POST",
91
+ headers: {
92
+ "content-type": "application/json",
93
+ },
94
+ body: bodyJSON,
95
+ signal: controller?.signal,
96
+ });
97
+ } catch (error) {
98
+ lastError = error;
99
+ if (isAbortError(error)) {
100
+ continue;
101
+ }
102
+ if (isRetryableNetworkError(error)) {
103
+ continue;
104
+ }
105
+ throw error;
106
+ } finally {
107
+ if (timeoutID) {
108
+ clearTimeout(timeoutID);
109
+ }
110
+ }
111
+
112
+ const responseText = await response.text();
113
+ const parsed = safeParseJSON(responseText);
114
+ if (!response.ok) {
115
+ const message = parsed?.error || parsed?.message || responseText || `HTTP ${response.status}`;
116
+ const error = new Error(message);
117
+ error.status = response.status;
118
+ if (response.status >= 500 && attempt < safeRetryLimit) {
119
+ lastError = error;
120
+ continue;
121
+ }
122
+ throw error;
87
123
  }
88
- }
89
124
 
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;
125
+ return parsed ?? { ok: true };
97
126
  }
98
127
 
99
- return parsed ?? { ok: true };
128
+ if (lastError) {
129
+ throw lastError;
130
+ }
131
+ return { ok: false };
100
132
  }
101
133
 
102
134
  return {
@@ -127,6 +159,7 @@ function normalizeBaseUrl(value) {
127
159
  function createTimeoutAbortError(timeoutMs) {
128
160
  const error = new Error(`Push service request timed out after ${timeoutMs}ms`);
129
161
  error.name = "AbortError";
162
+ error.code = "push_request_timeout";
130
163
  return error;
131
164
  }
132
165
 
@@ -134,6 +167,21 @@ function isAbortError(error) {
134
167
  return error?.name === "AbortError" || error?.code === "ABORT_ERR";
135
168
  }
136
169
 
170
+ function isRetryableNetworkError(error) {
171
+ const code = error?.code ?? "";
172
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT"
173
+ || code === "ENETUNREACH" || code === "EHOSTUNREACH" || code === "ENOTFOUND"
174
+ || error?.message?.includes("fetch failed");
175
+ }
176
+
177
+ function normalizeNonNegativeInteger(value, fallback) {
178
+ return Number.isInteger(value) && value >= 0 ? value : fallback;
179
+ }
180
+
181
+ function sleep(delayMs) {
182
+ return new Promise((resolve) => setTimeout(resolve, delayMs));
183
+ }
184
+
137
185
  function safeParseJSON(value) {
138
186
  if (!value || typeof value !== "string") {
139
187
  return null;
@@ -9,6 +9,9 @@ const {
9
9
  } = require("./push-notification-completion-dedupe");
10
10
 
11
11
  const DEFAULT_PREVIEW_MAX_CHARS = 160;
12
+ const MAX_THREAD_TITLE_ENTRIES = 200;
13
+ const MAX_TURN_STATE_ENTRIES = 500;
14
+ const MAX_THREAD_ID_BY_TURN_ENTRIES = 500;
12
15
 
13
16
  function createPushNotificationTracker({
14
17
  sessionId,
@@ -73,6 +76,10 @@ function createPushNotificationTracker({
73
76
  // Remembers thread/turn linkage before the terminal event arrives on a different payload shape.
74
77
  function rememberMessageContext({ threadId, turnId, params, eventObject }) {
75
78
  if (threadId && turnId) {
79
+ if (!threadIdByTurnId.has(turnId) && threadIdByTurnId.size >= MAX_THREAD_ID_BY_TURN_ENTRIES) {
80
+ const oldest = threadIdByTurnId.keys().next().value;
81
+ threadIdByTurnId.delete(oldest);
82
+ }
76
83
  threadIdByTurnId.set(turnId, threadId);
77
84
  ensureTurnState(threadId, turnId);
78
85
  }
@@ -83,6 +90,10 @@ function createPushNotificationTracker({
83
90
 
84
91
  const nextTitle = extractThreadTitle(params, eventObject);
85
92
  if (nextTitle) {
93
+ if (!threadTitleById.has(threadId) && threadTitleById.size >= MAX_THREAD_TITLE_ENTRIES) {
94
+ const oldest = threadTitleById.keys().next().value;
95
+ threadTitleById.delete(oldest);
96
+ }
86
97
  threadTitleById.set(threadId, nextTitle);
87
98
  }
88
99
  }
@@ -225,6 +236,10 @@ function createPushNotificationTracker({
225
236
  function ensureTurnState(threadId, turnId) {
226
237
  const key = turnStateKey(threadId, turnId);
227
238
  if (!turnStateByKey.has(key)) {
239
+ if (turnStateByKey.size >= MAX_TURN_STATE_ENTRIES) {
240
+ const oldest = turnStateByKey.keys().next().value;
241
+ turnStateByKey.delete(oldest);
242
+ }
228
243
  turnStateByKey.set(key, {
229
244
  latestAssistantPreview: "",
230
245
  latestFailurePreview: "",
package/src/qr.js CHANGED
@@ -9,7 +9,6 @@ const qrcode = require("qrcode-terminal");
9
9
 
10
10
  const SHORT_PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
11
11
  const SHORT_PAIRING_CODE_LENGTH = 10;
12
-
13
12
  // Generates a short-lived human-friendly pairing token for reconnect flows.
14
13
  function createShortPairingCode({
15
14
  length = SHORT_PAIRING_CODE_LENGTH,
@@ -50,7 +49,7 @@ function printQR(pairingSessionOrPayload, options = {}) {
50
49
  console.log("\nScan this QR with the iPhone:\n");
51
50
  qrcode.generate(payload, { small: true });
52
51
  if (pairingCode) {
53
- console.log("Or paste this pairing code in the iPhone app:\n");
52
+ console.log("Or enter this pairing code in the iPhone app:\n");
54
53
  console.log(pairingCode);
55
54
  }
56
55
  console.log(`\nSession ID: ${sessionIdShort || "(none)"}`);
@@ -58,9 +57,7 @@ function printQR(pairingSessionOrPayload, options = {}) {
58
57
  console.log(`Expires: ${new Date(pairingPayload.expiresAt).toISOString()}\n`);
59
58
 
60
59
  if (shouldPrintPairingJson({ env, explicitValue: options.printPairingJson })) {
61
- // Opt-in only: this is the same bearer-like payload as the QR scan target.
62
- console.log("Pairing JSON (debug only; same sensitive bytes as the QR):\n");
63
- console.log(`${payload}\n`);
60
+ console.log("Pairing JSON debug output is disabled because the payload contains private relay metadata.\n");
64
61
  }
65
62
  }
66
63