@makerbi/remodex 1.3.8 → 1.3.10

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.
package/bin/remodex.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makerbi/remodex",
3
- "version": "1.3.8",
3
+ "version": "1.3.10",
4
4
  "description": "Local bridge between Codex and the Remodex mobile app. Run `remodex up` to start.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/bridge.js CHANGED
@@ -51,12 +51,17 @@ const { createShortPairingCode, SHORT_PAIRING_CODE_LENGTH } = require("./qr");
51
51
 
52
52
  const execFileAsync = promisify(execFile);
53
53
  const RELAY_WATCHDOG_PING_INTERVAL_MS = 10_000;
54
- const RELAY_WATCHDOG_STALE_AFTER_MS = 25_000;
54
+ // Keep the watchdog above the relay heartbeat cadence so quiet healthy sockets survive idle gaps.
55
+ const RELAY_WATCHDOG_STALE_AFTER_MS = 70_000;
55
56
  const BRIDGE_STATUS_HEARTBEAT_INTERVAL_MS = 5_000;
56
57
  const STALE_RELAY_STATUS_MESSAGE = "Relay heartbeat stalled; reconnect pending.";
58
+ const CLOSE_CODE_INVALID_RELAY_REQUEST = 4000;
59
+ const CLOSE_CODE_REPLACED_BY_NEW_MAC = 4001;
60
+ const CLOSE_CODE_MAC_UNAUTHORIZED = 4005;
57
61
  const RELAY_HISTORY_IMAGE_REFERENCE_URL = "remodex://history-image-elided";
58
- const RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES = 3 * 1024 * 1024;
62
+ const RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES = 4 * 1024 * 1024;
59
63
  const RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS = 24_000;
64
+ const RELAY_HISTORY_RECENT_TURN_TARGET = 40;
60
65
 
61
66
  function buildRelayUserAgentHeader({ version = bridgePackageVersion } = {}) {
62
67
  const normalizedVersion = typeof version === "string" && version.trim()
@@ -144,6 +149,7 @@ function startBridge({
144
149
  let lastRelayActivityAt = 0;
145
150
  let lastPublishedBridgeStatus = null;
146
151
  let lastConnectionStatus = null;
152
+ let lastConnectionError = "";
147
153
  let codexLaunchState = config.codexEndpoint ? "connected" : "starting";
148
154
  let codexHandshakeState = config.codexEndpoint ? "warm" : "cold";
149
155
  const forwardedInitializeRequestIds = new Set();
@@ -320,35 +326,42 @@ function startBridge({
320
326
  }
321
327
 
322
328
  // Keeps npm start output compact by emitting only high-signal connection states.
323
- function logConnectionStatus(status) {
324
- if (lastConnectionStatus === status) {
329
+ function logConnectionStatus(status, lastError = "") {
330
+ if (lastConnectionStatus === status && lastConnectionError === lastError) {
325
331
  return;
326
332
  }
327
333
 
328
334
  lastConnectionStatus = status;
335
+ lastConnectionError = lastError;
329
336
  publishBridgeStatus({
330
337
  state: "running",
331
338
  connectionStatus: status,
332
339
  pid: process.pid,
333
- lastError: "",
340
+ lastError,
334
341
  });
335
342
  console.log(`[remodex] ${status}`);
343
+ if (lastError) {
344
+ console.error(`[remodex] ${lastError}`);
345
+ }
336
346
  }
337
347
 
338
348
  // Retries the relay socket while preserving the active Codex process and session id.
339
- function scheduleRelayReconnect(closeCode) {
349
+ function scheduleRelayReconnect(closeCode, closeReason = "") {
340
350
  if (isShuttingDown) {
341
351
  return;
342
352
  }
343
353
 
344
- if (closeCode === 4000 || closeCode === 4001) {
345
- logConnectionStatus("disconnected");
354
+ if (isTerminalRelayCloseCode(closeCode)) {
355
+ const lastError = buildRelayCloseStatusError(closeCode, closeReason);
356
+ logConnectionStatus("disconnected", lastError);
346
357
  shutdown(codex, () => socket, () => {
347
358
  isShuttingDown = true;
348
359
  bridgeWakeAssertion.stop();
349
360
  clearReconnectTimer();
350
361
  clearRelayWatchdog();
351
362
  clearBridgeStatusHeartbeat();
363
+ }, {
364
+ exitCode: closeCode === CLOSE_CODE_MAC_UNAUTHORIZED ? 1 : 0,
352
365
  });
353
366
  return;
354
367
  }
@@ -419,18 +432,19 @@ function startBridge({
419
432
  markRelayActivity();
420
433
  });
421
434
 
422
- nextSocket.on("close", (code) => {
435
+ nextSocket.on("close", (code, reason) => {
436
+ const closeReason = normalizeWebSocketCloseReason(reason);
423
437
  if (socket === nextSocket) {
424
438
  clearRelayWatchdog();
425
439
  }
426
- logConnectionStatus("disconnected");
440
+ logConnectionStatus("disconnected", buildRelayCloseStatusError(code, closeReason));
427
441
  if (socket === nextSocket) {
428
442
  socket = null;
429
443
  }
430
444
  stopContextUsageWatcher();
431
445
  rolloutLiveMirror?.stopAll();
432
446
  desktopRefresher.handleTransportReset();
433
- scheduleRelayReconnect(code);
447
+ scheduleRelayReconnect(code, closeReason);
434
448
  });
435
449
 
436
450
  nextSocket.on("error", () => {
@@ -471,12 +485,13 @@ function startBridge({
471
485
  codex.onClose(() => {
472
486
  clearRelayWatchdog();
473
487
  clearBridgeStatusHeartbeat();
474
- logConnectionStatus("disconnected");
488
+ const lastError = lastConnectionError || "";
489
+ logConnectionStatus("disconnected", lastError);
475
490
  publishBridgeStatus({
476
491
  state: "stopped",
477
492
  connectionStatus: "disconnected",
478
493
  pid: process.pid,
479
- lastError: "",
494
+ lastError,
480
495
  });
481
496
  isShuttingDown = true;
482
497
  bridgeWakeAssertion.stop();
@@ -1279,7 +1294,7 @@ function buildMacRegistration(deviceState, pairingSession) {
1279
1294
  };
1280
1295
  }
1281
1296
 
1282
- function shutdown(codex, getSocket, beforeExit = () => {}) {
1297
+ function shutdown(codex, getSocket, beforeExit = () => {}, { exitCode = 0 } = {}) {
1283
1298
  beforeExit();
1284
1299
 
1285
1300
  const socket = getSocket();
@@ -1289,7 +1304,41 @@ function shutdown(codex, getSocket, beforeExit = () => {}) {
1289
1304
 
1290
1305
  codex.shutdown();
1291
1306
 
1292
- setTimeout(() => process.exit(0), 100);
1307
+ setTimeout(() => process.exit(exitCode), 100);
1308
+ }
1309
+
1310
+ function isTerminalRelayCloseCode(closeCode) {
1311
+ return closeCode === CLOSE_CODE_INVALID_RELAY_REQUEST
1312
+ || closeCode === CLOSE_CODE_REPLACED_BY_NEW_MAC
1313
+ || closeCode === CLOSE_CODE_MAC_UNAUTHORIZED;
1314
+ }
1315
+
1316
+ function normalizeWebSocketCloseReason(reason) {
1317
+ if (typeof reason === "string") {
1318
+ return reason.trim();
1319
+ }
1320
+
1321
+ if (Buffer.isBuffer(reason)) {
1322
+ return reason.toString("utf8").trim();
1323
+ }
1324
+
1325
+ return "";
1326
+ }
1327
+
1328
+ function buildRelayCloseStatusError(closeCode, closeReason = "") {
1329
+ if (!Number.isInteger(closeCode) || closeCode === 1000 || closeCode === 1005) {
1330
+ return "";
1331
+ }
1332
+
1333
+ const normalizedReason = normalizeNonEmptyString(closeReason);
1334
+ if (closeCode === CLOSE_CODE_MAC_UNAUTHORIZED) {
1335
+ return normalizedReason
1336
+ || "Relay authorization failed. Set REMODEX_RELAY_ACCESS_TOKEN or use a relay that does not require a Mac access token.";
1337
+ }
1338
+
1339
+ return normalizedReason
1340
+ ? `Relay closed the connection (${closeCode}): ${normalizedReason}`
1341
+ : `Relay closed the connection (${closeCode}).`;
1293
1342
  }
1294
1343
 
1295
1344
  function extractBridgeMessageContext(rawMessage) {
@@ -1376,8 +1425,8 @@ function normalizeNonEmptyString(value) {
1376
1425
  return typeof value === "string" && value.trim() ? value.trim() : "";
1377
1426
  }
1378
1427
 
1379
- // Shrinks `thread/read` and `thread/resume` snapshots by eliding bulky history payloads
1380
- // that the iPhone does not render directly (inline images, compaction replacement history).
1428
+ // Shrinks `thread/read` and `thread/resume` snapshots for mobile relay delivery.
1429
+ // This elides bulky blobs and replaces oversized older history with a compact marker.
1381
1430
  function sanitizeThreadHistoryImagesForRelay(rawMessage, requestMethod) {
1382
1431
  if (requestMethod !== "thread/read" && requestMethod !== "thread/resume") {
1383
1432
  return rawMessage;
@@ -1779,19 +1828,25 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
1779
1828
  }
1780
1829
 
1781
1830
  const turns = thread.turns;
1782
- let trimmedTurns = turns.slice();
1831
+ let trimmedTurns = turns.length > RELAY_HISTORY_RECENT_TURN_TARGET
1832
+ ? turns.slice(-RELAY_HISTORY_RECENT_TURN_TARGET)
1833
+ : turns.slice();
1783
1834
  while (trimmedTurns.length > 1) {
1784
- trimmedTurns = trimmedTurns.slice(1);
1785
- const candidateThread = {
1786
- ...thread,
1787
- turns: trimmedTurns,
1788
- historyTailTruncatedForRelay: true,
1789
- };
1835
+ if (trimmedTurns.length === turns.length) {
1836
+ trimmedTurns = trimmedTurns.slice(1);
1837
+ }
1838
+ const candidateThread = buildRelayHistoryCompactedThread(
1839
+ thread,
1840
+ buildRelayCompactedHistoryTurns(turns, trimmedTurns),
1841
+ Math.max(0, turns.length - trimmedTurns.length),
1842
+ trimmedTurns.length
1843
+ );
1790
1844
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
1791
1845
  if (encoded != null && Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
1792
1846
  return encoded;
1793
1847
  }
1794
1848
  workingThread = candidateThread;
1849
+ trimmedTurns = trimmedTurns.slice(1);
1795
1850
  }
1796
1851
 
1797
1852
  const newestTurn = trimmedTurns[0];
@@ -1802,14 +1857,23 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
1802
1857
  let trimmedItems = newestTurn.items.slice();
1803
1858
  while (trimmedItems.length > 1) {
1804
1859
  trimmedItems = trimmedItems.slice(1);
1805
- const candidateThread = {
1806
- ...thread,
1807
- turns: [{
1860
+ const compactedTurnPrefix = buildRelayHistoryCompactionTurn(
1861
+ Math.max(0, turns.length - 1),
1862
+ 1,
1863
+ thread
1864
+ );
1865
+ const candidateThread = buildRelayHistoryCompactedThread(
1866
+ thread,
1867
+ compactedTurnPrefix ? [compactedTurnPrefix, {
1868
+ ...newestTurn,
1869
+ items: trimmedItems,
1870
+ }] : [{
1808
1871
  ...newestTurn,
1809
1872
  items: trimmedItems,
1810
1873
  }],
1811
- historyTailTruncatedForRelay: true,
1812
- };
1874
+ Math.max(0, turns.length - 1),
1875
+ 1
1876
+ );
1813
1877
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
1814
1878
  if (encoded != null && Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
1815
1879
  return encoded;
@@ -1826,28 +1890,93 @@ function trimThreadPayloadForRelay(parsed, explicitThread = undefined) {
1826
1890
  mostRecentItem,
1827
1891
  RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS
1828
1892
  );
1829
- let candidateThread = {
1830
- ...thread,
1831
- turns: [{
1832
- ...newestTurn,
1833
- items: [truncatedItem],
1834
- }],
1835
- historyTailTruncatedForRelay: true,
1836
- };
1893
+ let candidateThread = buildRelayHistoryCompactedThread(
1894
+ thread,
1895
+ [
1896
+ ...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
1897
+ {
1898
+ ...newestTurn,
1899
+ items: [truncatedItem],
1900
+ },
1901
+ ],
1902
+ Math.max(0, turns.length - 1),
1903
+ 1
1904
+ );
1837
1905
  encoded = encodeRelayThreadPayload(parsed, candidateThread);
1838
1906
  if (encoded != null && Buffer.byteLength(encoded, "utf8") <= RELAY_THREAD_PAYLOAD_SOFT_LIMIT_BYTES) {
1839
1907
  return encoded;
1840
1908
  }
1841
1909
 
1842
- candidateThread = {
1910
+ candidateThread = buildRelayHistoryCompactedThread(
1911
+ thread,
1912
+ [
1913
+ ...buildRelayCompactedHistoryTurns(turns, [newestTurn]).slice(0, -1),
1914
+ {
1915
+ ...newestTurn,
1916
+ items: [compactHistoryItemForRelay(mostRecentItem, RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS)],
1917
+ },
1918
+ ],
1919
+ Math.max(0, turns.length - 1),
1920
+ 1
1921
+ );
1922
+ return encodeRelayThreadPayload(parsed, candidateThread);
1923
+ }
1924
+
1925
+ function buildRelayHistoryCompactedThread(thread, turns, omittedTurnCount, keptTurnCount) {
1926
+ return {
1843
1927
  ...thread,
1844
- turns: [{
1845
- ...newestTurn,
1846
- items: [compactHistoryItemForRelay(mostRecentItem, RELAY_HISTORY_TEXT_TAIL_LIMIT_CHARS)],
1847
- }],
1928
+ turns,
1848
1929
  historyTailTruncatedForRelay: true,
1930
+ remodexHistoryCompacted: omittedTurnCount > 0,
1931
+ remodexOmittedTurnCount: omittedTurnCount,
1932
+ remodexKeptTurnCount: keptTurnCount,
1933
+ };
1934
+ }
1935
+
1936
+ function buildRelayCompactedHistoryTurns(allTurns, keptTurns) {
1937
+ const omittedTurnCount = Math.max(0, allTurns.length - keptTurns.length);
1938
+ const compactionTurn = buildRelayHistoryCompactionTurn(
1939
+ omittedTurnCount,
1940
+ keptTurns.length,
1941
+ allTurns[0]
1942
+ );
1943
+ return compactionTurn ? [compactionTurn, ...keptTurns] : keptTurns;
1944
+ }
1945
+
1946
+ function buildRelayHistoryCompactionTurn(omittedTurnCount, keptTurnCount, idSource = {}) {
1947
+ if (omittedTurnCount <= 0) {
1948
+ return null;
1949
+ }
1950
+
1951
+ const baseId = normalizeNonEmptyString(idSource?.id)
1952
+ || normalizeNonEmptyString(idSource?.turnId)
1953
+ || normalizeNonEmptyString(idSource?.turn_id)
1954
+ || "history";
1955
+ const text = [
1956
+ "Earlier conversation compacted for mobile loading.",
1957
+ "",
1958
+ `Older turns omitted: ${omittedTurnCount}`,
1959
+ `Recent turns kept: ${keptTurnCount}`,
1960
+ "Full history remains available on the Mac runtime.",
1961
+ ].join("\n");
1962
+
1963
+ return {
1964
+ id: `remodex-history-compacted-${baseId}`,
1965
+ remodexSynthetic: true,
1966
+ remodexHistoryCompacted: true,
1967
+ remodexOmittedTurnCount: omittedTurnCount,
1968
+ remodexKeptTurnCount: keptTurnCount,
1969
+ items: [
1970
+ {
1971
+ id: `remodex-history-compacted-item-${baseId}`,
1972
+ type: "assistant_message",
1973
+ role: "assistant",
1974
+ text,
1975
+ remodexSynthetic: true,
1976
+ remodexHistoryCompacted: true,
1977
+ },
1978
+ ],
1849
1979
  };
1850
- return encodeRelayThreadPayload(parsed, candidateThread);
1851
1980
  }
1852
1981
 
1853
1982
  function encodeRelayThreadPayload(parsed, thread) {
@@ -2021,10 +2150,12 @@ function persistBridgePreferences(
2021
2150
 
2022
2151
  module.exports = {
2023
2152
  buildHeartbeatBridgeStatus,
2153
+ buildRelayCloseStatusError,
2024
2154
  buildRelayAccessTokenHeaders,
2025
2155
  buildRelayUserAgentHeader,
2026
2156
  createMacOSBridgeWakeAssertion,
2027
2157
  hasRelayConnectionGoneStale,
2158
+ isTerminalRelayCloseCode,
2028
2159
  persistBridgePreferences,
2029
2160
  sanitizeLiveGeneratedImageMessageForRelay,
2030
2161
  sanitizeThreadHistoryImagesForRelay,
@@ -4,8 +4,8 @@
4
4
  // Exports: version comparison + bridge/iPhone compatibility helpers
5
5
  // Depends on: none
6
6
 
7
- const MINIMUM_SUPPORTED_IOS_APP_VERSION = "1.1";
8
- const IOS_APP_COMPATIBILITY_GATE_BRIDGE_VERSION = "1.3.8";
7
+ const MINIMUM_SUPPORTED_IOS_APP_VERSION = "1.5";
8
+ const IOS_APP_COMPATIBILITY_GATE_BRIDGE_VERSION = "1.3.9";
9
9
  const LEGACY_BRIDGE_VERSION_FOR_IOS_1_0 = "1.3.7";
10
10
  const LEGACY_BRIDGE_DOWNGRADE_COMMAND = `npm install -g remodex@${LEGACY_BRIDGE_VERSION_FOR_IOS_1_0}`;
11
11
  const NOTICE_BOX_WIDTH = 74;
@@ -334,6 +334,11 @@ function restartLaunchAgent({
334
334
  launchAgentDomain(env),
335
335
  plistPath,
336
336
  ], { stdio: ["ignore", "ignore", "pipe"] });
337
+ execFileSyncImpl("launchctl", [
338
+ "kickstart",
339
+ "-k",
340
+ launchAgentLabelDomain(env),
341
+ ], { stdio: ["ignore", "ignore", "pipe"] });
337
342
  }
338
343
 
339
344
  function bootoutLaunchAgent({
@@ -10,7 +10,7 @@ const { version: installedVersion = "" } = require("../package.json");
10
10
  const DEFAULT_CACHE_TTL_MS = 30 * 60 * 1000;
11
11
  const DEFAULT_EMPTY_CACHE_RETRY_MS = 60 * 1000;
12
12
  const DEFAULT_INITIAL_FETCH_WAIT_MS = 250;
13
- const REMODEX_REGISTRY_URL = "https://registry.npmjs.org/remodex/latest";
13
+ const REMODEX_REGISTRY_URL = "https://registry.npmjs.org/@makerbi%2fremodex/latest";
14
14
 
15
15
  function createBridgePackageVersionStatusReader({
16
16
  cacheTtlMs = DEFAULT_CACHE_TTL_MS,
@@ -9,6 +9,9 @@ const os = require("os");
9
9
  const path = require("path");
10
10
 
11
11
  const DEFAULT_DIRECTORY_LIMIT = 200;
12
+ const DEFAULT_DIRECTORY_SEARCH_LIMIT = 80;
13
+ const DEFAULT_DIRECTORY_SEARCH_MAX_DEPTH = 8;
14
+ const DEFAULT_DIRECTORY_SEARCH_MAX_VISITED = 5000;
12
15
  const DEFAULT_HIDDEN_DIRECTORY_NAMES = new Set(["Library"]);
13
16
 
14
17
  // ─── ENTRY POINT ─────────────────────────────────────────────
@@ -57,6 +60,8 @@ async function handleProjectMethod(method, params, options = {}) {
57
60
  return projectQuickLocations(options);
58
61
  case "project/listDirectory":
59
62
  return projectListDirectory(params, options);
63
+ case "project/searchDirectories":
64
+ return projectSearchDirectories(params, options);
60
65
  case "project/validatePath":
61
66
  return projectValidatePath(params, options);
62
67
  case "project/createDirectory":
@@ -112,6 +117,32 @@ async function projectListDirectory(params, options = {}) {
112
117
  };
113
118
  }
114
119
 
120
+ async function projectSearchDirectories(params, options = {}) {
121
+ const requestedPath = readString(params.path) || resolveHomeDir(options);
122
+ const query = readString(params.query);
123
+ const directory = await requireUsableDirectory(requestedPath, options);
124
+ if (!query) {
125
+ return {
126
+ path: directory.path,
127
+ entries: [],
128
+ };
129
+ }
130
+
131
+ const includeHidden = params.includeHidden === true;
132
+ const entries = await searchDirectoryEntries(directory.path, query, {
133
+ ...options,
134
+ includeHidden,
135
+ limit: normalizeSearchLimit(params.limit),
136
+ maxDepth: normalizeSearchDepth(params.maxDepth),
137
+ maxVisited: normalizeSearchVisitedLimit(params.maxVisited),
138
+ });
139
+
140
+ return {
141
+ path: directory.path,
142
+ entries,
143
+ };
144
+ }
145
+
115
146
  async function projectValidatePath(params, options = {}) {
116
147
  const requestedPath = readString(params.path);
117
148
  if (!requestedPath) {
@@ -181,6 +212,62 @@ async function readDirectoryEntries(directoryPath, options = {}) {
181
212
  .slice(0, options.limit || DEFAULT_DIRECTORY_LIMIT);
182
213
  }
183
214
 
215
+ async function searchDirectoryEntries(rootPath, query, options = {}) {
216
+ const tokens = searchTokens(query);
217
+ if (!tokens.length) {
218
+ return [];
219
+ }
220
+
221
+ const limit = options.limit || DEFAULT_DIRECTORY_SEARCH_LIMIT;
222
+ const maxDepth = options.maxDepth ?? DEFAULT_DIRECTORY_SEARCH_MAX_DEPTH;
223
+ const maxVisited = options.maxVisited || DEFAULT_DIRECTORY_SEARCH_MAX_VISITED;
224
+ const queue = [{ directoryPath: rootPath, depth: 0 }];
225
+ const visitedDirectories = new Set([realpathSyncIfAvailable(rootPath) || rootPath]);
226
+ const matches = [];
227
+ let visitedCount = 0;
228
+
229
+ while (queue.length && matches.length < limit && visitedCount < maxVisited) {
230
+ const { directoryPath, depth } = queue.shift();
231
+ visitedCount += 1;
232
+
233
+ let dirents;
234
+ try {
235
+ dirents = await fs.promises.readdir(directoryPath, { withFileTypes: true });
236
+ } catch {
237
+ continue;
238
+ }
239
+
240
+ for (const dirent of sortedDirents(dirents)) {
241
+ if (!options.includeHidden && isHiddenDirectoryName(dirent.name)) {
242
+ continue;
243
+ }
244
+
245
+ const childPath = path.join(directoryPath, dirent.name);
246
+ const directory = await directoryEntryForPath(childPath, dirent, options);
247
+ if (!directory) {
248
+ continue;
249
+ }
250
+
251
+ if (directoryMatchesSearch(directory, tokens)) {
252
+ matches.push(directory);
253
+ if (matches.length >= limit) {
254
+ break;
255
+ }
256
+ }
257
+
258
+ if (!dirent.isSymbolicLink() && depth < maxDepth) {
259
+ const realPath = directory.path;
260
+ if (!visitedDirectories.has(realPath)) {
261
+ visitedDirectories.add(realPath);
262
+ queue.push({ directoryPath: realPath, depth: depth + 1 });
263
+ }
264
+ }
265
+ }
266
+ }
267
+
268
+ return matches;
269
+ }
270
+
184
271
  async function directoryEntryForPath(candidatePath, dirent, options = {}) {
185
272
  if (!dirent.isDirectory() && !dirent.isSymbolicLink()) {
186
273
  return null;
@@ -325,6 +412,52 @@ function normalizeLimit(rawLimit) {
325
412
  return Math.min(Math.floor(numericLimit), DEFAULT_DIRECTORY_LIMIT);
326
413
  }
327
414
 
415
+ function normalizeSearchLimit(rawLimit) {
416
+ const numericLimit = Number(rawLimit);
417
+ if (!Number.isFinite(numericLimit) || numericLimit <= 0) {
418
+ return DEFAULT_DIRECTORY_SEARCH_LIMIT;
419
+ }
420
+
421
+ return Math.min(Math.floor(numericLimit), DEFAULT_DIRECTORY_SEARCH_LIMIT);
422
+ }
423
+
424
+ function normalizeSearchDepth(rawDepth) {
425
+ const numericDepth = Number(rawDepth);
426
+ if (!Number.isFinite(numericDepth) || numericDepth < 0) {
427
+ return DEFAULT_DIRECTORY_SEARCH_MAX_DEPTH;
428
+ }
429
+
430
+ return Math.min(Math.floor(numericDepth), DEFAULT_DIRECTORY_SEARCH_MAX_DEPTH);
431
+ }
432
+
433
+ function normalizeSearchVisitedLimit(rawLimit) {
434
+ const numericLimit = Number(rawLimit);
435
+ if (!Number.isFinite(numericLimit) || numericLimit <= 0) {
436
+ return DEFAULT_DIRECTORY_SEARCH_MAX_VISITED;
437
+ }
438
+
439
+ return Math.min(Math.floor(numericLimit), DEFAULT_DIRECTORY_SEARCH_MAX_VISITED);
440
+ }
441
+
442
+ function sortedDirents(dirents) {
443
+ return [...dirents].sort((left, right) => (
444
+ left.name.localeCompare(right.name, undefined, { sensitivity: "base" })
445
+ ));
446
+ }
447
+
448
+ function searchTokens(query) {
449
+ return query
450
+ .toLowerCase()
451
+ .split(/\s+/)
452
+ .map((token) => token.trim())
453
+ .filter(Boolean);
454
+ }
455
+
456
+ function directoryMatchesSearch(directory, tokens) {
457
+ const haystack = directory.name.toLowerCase();
458
+ return tokens.every((token) => haystack.includes(token));
459
+ }
460
+
328
461
  function resolveHomeDir(options = {}) {
329
462
  return options.homeDir || os.homedir();
330
463
  }
@@ -353,6 +486,7 @@ module.exports = {
353
486
  handleProjectMethod,
354
487
  projectQuickLocations,
355
488
  projectListDirectory,
489
+ projectSearchDirectories,
356
490
  projectValidatePath,
357
491
  projectCreateDirectory,
358
492
  validateDirectory,