@makerbi/remodex 1.5.4 → 1.5.8

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.
@@ -13,6 +13,7 @@ function createCodexTransport({
13
13
  endpoint = "",
14
14
  env = process.env,
15
15
  appPath = "",
16
+ platform = process.platform,
16
17
  spawnImpl = spawn,
17
18
  WebSocketImpl = WebSocket,
18
19
  } = {}) {
@@ -20,11 +21,11 @@ function createCodexTransport({
20
21
  return createWebSocketTransport({ endpoint, WebSocketImpl });
21
22
  }
22
23
 
23
- return createSpawnTransport({ env, appPath, spawnImpl });
24
+ return createSpawnTransport({ env, appPath, platform, spawnImpl });
24
25
  }
25
26
 
26
- function createSpawnTransport({ env, appPath, spawnImpl = spawn }) {
27
- const launchPlans = createCodexLaunchPlans({ env, appPath });
27
+ function createSpawnTransport({ env, appPath, platform, spawnImpl = spawn }) {
28
+ const launchPlans = createCodexLaunchPlans({ env, appPath, platform });
28
29
  let launchIndex = -1;
29
30
  let activeLaunch = null;
30
31
  let codex = null;
@@ -154,13 +155,12 @@ function createSpawnTransport({ env, appPath, spawnImpl = spawn }) {
154
155
  return;
155
156
  }
156
157
  stdoutBuffer += chunk.toString("utf8");
157
- const lines = stdoutBuffer.split("\n");
158
- stdoutBuffer = lines.pop() || "";
159
-
160
- for (const line of lines) {
161
- const trimmedLine = line.trim();
162
- if (trimmedLine) {
163
- listeners.emitMessage(trimmedLine);
158
+ let newlineIndex;
159
+ while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
160
+ const line = stdoutBuffer.substring(0, newlineIndex).trim();
161
+ stdoutBuffer = stdoutBuffer.substring(newlineIndex + 1);
162
+ if (line) {
163
+ listeners.emitMessage(line);
164
164
  }
165
165
  }
166
166
  });
@@ -1,5 +1,5 @@
1
1
  // FILE: desktop-handler.js
2
- // Purpose: Handles explicit desktop handoff, display wake, and bridge preference RPCs for Codex.app.
2
+ // Purpose: Handles explicit desktop handoff, display wake, bridge update, and preference RPCs for Codex.app.
3
3
  // Layer: Bridge handler
4
4
  // Exports: handleDesktopRequest
5
5
  // Depends on: child_process, fs, os, path, ./rollout-watch
@@ -126,6 +126,8 @@ async function handleDesktopMethod(method, params, options = {}) {
126
126
  return readBridgePreferences(options);
127
127
  case "desktop/preferences/update":
128
128
  return updateBridgePreferences(params, options);
129
+ case "desktop/bridge/updateAndRestart":
130
+ return updateBridgePackageAndRestart(options);
129
131
  default:
130
132
  throw desktopError("unknown_method", `Unknown desktop method: ${method}`);
131
133
  }
@@ -360,6 +362,17 @@ async function updateBridgePreferences(params, options = {}) {
360
362
  });
361
363
  }
362
364
 
365
+ async function updateBridgePackageAndRestart(options = {}) {
366
+ if (typeof options.updateBridgePackageAndRestart !== "function") {
367
+ throw desktopError(
368
+ "unsupported_bridge_update",
369
+ "This bridge does not support iPhone-triggered bridge updates yet."
370
+ );
371
+ }
372
+
373
+ return options.updateBridgePackageAndRestart();
374
+ }
375
+
363
376
  function resolveThreadId(params) {
364
377
  if (!params || typeof params !== "object") {
365
378
  return "";
@@ -11,6 +11,7 @@ const path = require("path");
11
11
  const FRAME_HEADER_BYTES = 4;
12
12
  const MAX_FRAME_BYTES = 256 * 1024 * 1024;
13
13
  const REQUEST_TIMEOUT_MS = 10_000;
14
+ const DESKTOP_IPC_ACTION_SOURCE = "desktop-ipc-action-follower";
14
15
  const DESKTOP_RESUME_METHODS = new Set(["thread/read", "thread/resume"]);
15
16
  const ACTION_METHODS = new Set([
16
17
  "item/commandExecution/requestApproval",
@@ -54,6 +55,7 @@ function createDesktopIpcActionFollower({
54
55
  onDisconnect,
55
56
  });
56
57
  const rawStatesByThreadId = new Map();
58
+ const assistantMessageTextsByThreadId = new Map();
57
59
  const pendingRoutesByRequestId = new Map();
58
60
  const activeThreadIds = new Set();
59
61
  const recoveringThreadIds = new Set();
@@ -84,6 +86,7 @@ function createDesktopIpcActionFollower({
84
86
 
85
87
  function stopAll() {
86
88
  rawStatesByThreadId.clear();
89
+ assistantMessageTextsByThreadId.clear();
87
90
  pendingRoutesByRequestId.clear();
88
91
  activeThreadIds.clear();
89
92
  recoveringThreadIds.clear();
@@ -132,11 +135,13 @@ function createDesktopIpcActionFollower({
132
135
  }
133
136
 
134
137
  rawStatesByThreadId.set(threadId, nextState);
138
+ syncProjectedAssistantDeltas(threadId, previousState, nextState);
135
139
  syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
136
140
  }
137
141
 
138
142
  function onDisconnect() {
139
143
  rawStatesByThreadId.clear();
144
+ assistantMessageTextsByThreadId.clear();
140
145
  pendingRoutesByRequestId.clear();
141
146
  recoveringThreadIds.clear();
142
147
  queuedChangesByThreadId.clear();
@@ -273,9 +278,34 @@ function createDesktopIpcActionFollower({
273
278
  }
274
279
 
275
280
  rawStatesByThreadId.set(threadId, nextState);
281
+ syncProjectedAssistantDeltas(threadId, baselineState, nextState);
276
282
  syncProjectedActions(threadId, projectPendingDesktopActions(threadId, nextState));
277
283
  }
278
284
 
285
+ function syncProjectedAssistantDeltas(threadId, previousState, nextState) {
286
+ const previousTexts = assistantMessageTextsByThreadId.get(threadId);
287
+ if (!previousTexts && !previousState) {
288
+ assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
289
+ return;
290
+ }
291
+
292
+ const notifications = projectDesktopAssistantDeltaNotifications(
293
+ threadId,
294
+ previousState,
295
+ nextState,
296
+ previousTexts || snapshotAssistantMessageTexts(previousState)
297
+ );
298
+ if (notifications.length === 0) {
299
+ assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
300
+ return;
301
+ }
302
+
303
+ for (const notification of notifications) {
304
+ sendApplicationResponse(JSON.stringify(notification));
305
+ }
306
+ assistantMessageTextsByThreadId.set(threadId, snapshotAssistantMessageTexts(nextState));
307
+ }
308
+
279
309
  return {
280
310
  observeInbound,
281
311
  stopAll,
@@ -550,6 +580,93 @@ function projectPendingDesktopActions(threadId, conversationState) {
550
580
  .filter(Boolean);
551
581
  }
552
582
 
583
+ // Desktop IPC exposes full conversation snapshots/patches, not app-server assistant delta events.
584
+ // Mirror only suffix growth for assistant rows so phones can render the same live text progression.
585
+ function projectDesktopAssistantDeltaNotifications(
586
+ threadId,
587
+ previousState,
588
+ nextState,
589
+ previousTexts = snapshotAssistantMessageTexts(previousState)
590
+ ) {
591
+ const nextMessages = collectAssistantMessages(nextState);
592
+ const notifications = [];
593
+
594
+ for (const message of nextMessages) {
595
+ const previousText = previousTexts.get(message.key) || "";
596
+ if (!message.text || !message.text.startsWith(previousText) || message.text.length <= previousText.length) {
597
+ continue;
598
+ }
599
+
600
+ const delta = message.text.slice(previousText.length);
601
+ notifications.push({
602
+ method: "item/agentMessage/delta",
603
+ params: {
604
+ threadId,
605
+ turnId: message.turnId,
606
+ itemId: message.itemId,
607
+ delta,
608
+ },
609
+ });
610
+ }
611
+
612
+ return notifications;
613
+ }
614
+
615
+ function snapshotAssistantMessageTexts(conversationState) {
616
+ return new Map(collectAssistantMessages(conversationState).map((message) => [message.key, message.text]));
617
+ }
618
+
619
+ function collectAssistantMessages(conversationState) {
620
+ const turns = Array.isArray(conversationState?.turns) ? conversationState.turns : [];
621
+ const messages = [];
622
+ for (const turn of turns) {
623
+ const turnId = readString(turn?.id) || readString(turn?.turnId) || readString(turn?.turn_id);
624
+ const items = Array.isArray(turn?.items) ? turn.items : [];
625
+ for (const item of items) {
626
+ if (!isAssistantMessageItem(item)) {
627
+ continue;
628
+ }
629
+
630
+ const itemId = readString(item?.id) || readString(item?.itemId) || readString(item?.item_id);
631
+ const text = assistantMessageText(item);
632
+ if (!turnId || !itemId) {
633
+ continue;
634
+ }
635
+
636
+ messages.push({
637
+ key: `${turnId}:${itemId}`,
638
+ turnId,
639
+ itemId,
640
+ text,
641
+ });
642
+ }
643
+ }
644
+ return messages;
645
+ }
646
+
647
+ function isAssistantMessageItem(item) {
648
+ const type = normalizeToken(item?.type);
649
+ if (type === "agentmessage" || type === "assistantmessage") {
650
+ return true;
651
+ }
652
+ return type === "message" && normalizeToken(item?.role) === "assistant";
653
+ }
654
+
655
+ function assistantMessageText(item) {
656
+ const directText = readString(item?.text) || readString(item?.message);
657
+ if (directText) {
658
+ return directText;
659
+ }
660
+
661
+ const content = Array.isArray(item?.content) ? item.content : [];
662
+ return content
663
+ .map((entry) => entry && typeof entry === "object" ? entry : null)
664
+ .filter(Boolean)
665
+ .map((entry) => readString(entry.text) || readString(entry?.data?.text))
666
+ .filter(Boolean)
667
+ .join("");
668
+ }
669
+
553
670
  function projectPendingDesktopAction(threadId, request) {
554
671
  const requestId = requestIdKey(request.id);
555
672
  const method = readString(request.method);
@@ -572,6 +689,7 @@ function projectPendingDesktopAction(threadId, request) {
572
689
  method,
573
690
  params: {
574
691
  ...params,
692
+ remodexActionSource: DESKTOP_IPC_ACTION_SOURCE,
575
693
  threadId: readString(params.threadId) || readString(params.thread_id) || threadId,
576
694
  },
577
695
  };
@@ -674,6 +792,10 @@ function writeFrame(socket, payload, callback) {
674
792
  }
675
793
 
676
794
  function resolveDefaultIpcSocketPath() {
795
+ if (process.platform === "win32") {
796
+ return "\\\\.\\pipe\\codex-ipc";
797
+ }
798
+
677
799
  const uid = typeof process.getuid === "function" ? process.getuid() : 0;
678
800
  return path.join(os.tmpdir(), "codex-ipc", `ipc-${uid}.sock`);
679
801
  }
@@ -699,6 +821,12 @@ function readString(value) {
699
821
  return typeof value === "string" && value.trim() ? value.trim() : "";
700
822
  }
701
823
 
824
+ function normalizeToken(value) {
825
+ return typeof value === "string"
826
+ ? value.toLowerCase().replace(/[_-\s]+/g, "")
827
+ : "";
828
+ }
829
+
702
830
  function cloneJSON(value) {
703
831
  return JSON.parse(JSON.stringify(value));
704
832
  }
@@ -715,6 +843,7 @@ module.exports = {
715
843
  applyConversationStateChange,
716
844
  createDesktopIpcActionFollower,
717
845
  desktopFollowerPayloadForResponse,
846
+ projectDesktopAssistantDeltaNotifications,
718
847
  projectPendingDesktopActions,
719
848
  resolveDefaultIpcSocketPath,
720
849
  seedConversationStateFromThreadRead,
package/src/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  // Depends on: ./bridge, ./secure-device-state, ./session-state, ./rollout-watch, ./macos-launch-agent
6
6
 
7
7
  const { startBridge } = require("./bridge");
8
- const { readBridgeDeviceState, resetBridgeDeviceState } = require("./secure-device-state");
8
+ const { readBridgeDeviceState, resetBridgeTrustState } = require("./secure-device-state");
9
9
  const { openLastActiveThread } = require("./session-state");
10
10
  const { watchThreadRollout } = require("./rollout-watch");
11
11
  const { readBridgeConfig } = require("./codex-desktop-refresher");
@@ -14,6 +14,7 @@ const {
14
14
  printMacOSBridgePairingQr,
15
15
  printMacOSBridgeServiceStatus,
16
16
  resetMacOSBridgePairing,
17
+ restartMacOSBridgeService,
17
18
  runMacOSBridgeService,
18
19
  startMacOSBridgeService,
19
20
  stopMacOSBridgeService,
@@ -26,11 +27,12 @@ module.exports = {
26
27
  readBridgeConfig,
27
28
  readBridgeDeviceState,
28
29
  resetMacOSBridgePairing,
30
+ restartMacOSBridgeService,
29
31
  startBridge,
30
32
  runMacOSBridgeService,
31
33
  startMacOSBridgeService,
32
34
  stopMacOSBridgeService,
33
- resetBridgePairing: resetBridgeDeviceState,
35
+ resetBridgePairing: resetBridgeTrustState,
34
36
  openLastActiveThread,
35
37
  watchThreadRollout,
36
38
  };
@@ -11,7 +11,7 @@ const path = require("path");
11
11
  const { startBridge } = require("./bridge");
12
12
  const { readBridgeConfig } = require("./codex-desktop-refresher");
13
13
  const { printQR } = require("./qr");
14
- const { resetBridgeDeviceState } = require("./secure-device-state");
14
+ const { resetBridgeTrustState } = require("./secure-device-state");
15
15
  const {
16
16
  clearBridgeStatus,
17
17
  clearPairingSession,
@@ -122,6 +122,65 @@ async function startMacOSBridgeService({
122
122
  };
123
123
  }
124
124
 
125
+ // Restarts the installed LaunchAgent without rewriting relay config, useful during local bridge development.
126
+ async function restartMacOSBridgeService({
127
+ env = process.env,
128
+ platform = process.platform,
129
+ fsImpl = fs,
130
+ execFileSyncImpl = execFileSync,
131
+ osImpl = os,
132
+ waitForPairing = false,
133
+ pairingTimeoutMs = DEFAULT_PAIRING_WAIT_TIMEOUT_MS,
134
+ pairingPollIntervalMs = DEFAULT_PAIRING_WAIT_INTERVAL_MS,
135
+ ...startOptions
136
+ } = {}) {
137
+ assertDarwinPlatform(platform);
138
+ const plistPath = resolveLaunchAgentPlistPath({ env, osImpl });
139
+ if (!fsImpl.existsSync(plistPath)) {
140
+ return startMacOSBridgeService({
141
+ env,
142
+ platform,
143
+ fsImpl,
144
+ execFileSyncImpl,
145
+ osImpl,
146
+ waitForPairing,
147
+ pairingTimeoutMs,
148
+ pairingPollIntervalMs,
149
+ ...startOptions,
150
+ });
151
+ }
152
+
153
+ const startedAt = Date.now();
154
+ if (waitForPairing) {
155
+ clearPairingSession({ env, fsImpl });
156
+ }
157
+
158
+ kickstartLaunchAgent({
159
+ env,
160
+ execFileSyncImpl,
161
+ plistPath,
162
+ });
163
+
164
+ if (waitForPairing) {
165
+ const pairingSession = await waitForFreshPairingSession({
166
+ env,
167
+ fsImpl,
168
+ startedAt,
169
+ timeoutMs: pairingTimeoutMs,
170
+ intervalMs: pairingPollIntervalMs,
171
+ });
172
+ return {
173
+ plistPath,
174
+ pairingSession,
175
+ };
176
+ }
177
+
178
+ return {
179
+ plistPath,
180
+ pairingSession: null,
181
+ };
182
+ }
183
+
125
184
  function stopMacOSBridgeService({
126
185
  env = process.env,
127
186
  platform = process.platform,
@@ -150,7 +209,7 @@ function resetMacOSBridgePairing({
150
209
  platform = process.platform,
151
210
  execFileSyncImpl = execFileSync,
152
211
  fsImpl = fs,
153
- resetBridgePairingImpl = resetBridgeDeviceState,
212
+ resetBridgePairingImpl = resetBridgeTrustState,
154
213
  } = {}) {
155
214
  assertDarwinPlatform(platform);
156
215
  stopMacOSBridgeService({
@@ -388,6 +447,31 @@ function restartLaunchAgent({
388
447
  ], { stdio: ["ignore", "ignore", "pipe"] });
389
448
  }
390
449
 
450
+ function kickstartLaunchAgent({
451
+ env = process.env,
452
+ execFileSyncImpl = execFileSync,
453
+ plistPath,
454
+ } = {}) {
455
+ try {
456
+ execFileSyncImpl("launchctl", [
457
+ "kickstart",
458
+ "-k",
459
+ launchAgentLabelDomain(env),
460
+ ], { stdio: ["ignore", "ignore", "pipe"] });
461
+ } catch {
462
+ execFileSyncImpl("launchctl", [
463
+ "bootstrap",
464
+ launchAgentDomain(env),
465
+ plistPath,
466
+ ], { stdio: ["ignore", "ignore", "pipe"] });
467
+ execFileSyncImpl("launchctl", [
468
+ "kickstart",
469
+ "-k",
470
+ launchAgentLabelDomain(env),
471
+ ], { stdio: ["ignore", "ignore", "pipe"] });
472
+ }
473
+ }
474
+
391
475
  function bootoutLaunchAgent({
392
476
  env = process.env,
393
477
  execFileSyncImpl = execFileSync,
@@ -552,6 +636,7 @@ module.exports = {
552
636
  printMacOSBridgeServiceStatus,
553
637
  resetMacOSBridgePairing,
554
638
  resolveLaunchAgentPlistPath,
639
+ restartMacOSBridgeService,
555
640
  runMacOSBridgeService,
556
641
  startMacOSBridgeService,
557
642
  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
  };