cosmoremote 2.0.22 → 2.1.1

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/dist/bridge.js CHANGED
@@ -33,6 +33,10 @@ let promptTicketPublicKeySource = null;
33
33
  let workspaceRoot = process.cwd();
34
34
  let importableConversations = [];
35
35
  let projectFolders = [];
36
+ const MAX_CONCURRENT_CLI_SESSIONS = (() => {
37
+ const raw = Number(process.env.COSMOREMOTE_MAX_CONCURRENT_CLI_SESSIONS);
38
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 4;
39
+ })();
36
40
  function refreshImportableConversations() {
37
41
  try {
38
42
  importableConversations = (0, cli_conversations_1.listImportableConversations)(workspaceRoot);
@@ -54,6 +58,58 @@ function refreshProjectFolders() {
54
58
  const CONFIG_DIR = (0, path_1.join)((0, os_1.homedir)(), ".cosmoremote");
55
59
  const CONFIG_FILE = (0, path_1.join)(CONFIG_DIR, "config.json");
56
60
  const SESSION_CONTEXTS_FILE = (0, path_1.join)(CONFIG_DIR, "session-contexts.json");
61
+ function getPackageVersion() {
62
+ try {
63
+ const packageJsonPath = (0, path_1.join)(__dirname, "..", "package.json");
64
+ const packageJson = JSON.parse((0, fs_1.readFileSync)(packageJsonPath, "utf-8"));
65
+ return packageJson.version ?? "0.0.0";
66
+ }
67
+ catch {
68
+ return "0.0.0";
69
+ }
70
+ }
71
+ function announceCapabilities() {
72
+ if (!currentRelayWs || currentRelayWs.readyState !== ws_1.WebSocket.OPEN)
73
+ return;
74
+ try {
75
+ currentRelayWs.send(JSON.stringify({
76
+ type: "capabilities",
77
+ bridgeVersion: getPackageVersion(),
78
+ availableClis: getAvailableCliNames(),
79
+ folders: projectFolders,
80
+ conversations: importableConversations,
81
+ workspaceRoot,
82
+ }));
83
+ console.log(` [relay] announced capabilities clis=${getAvailableCliNames().join(",") || "none"} folders=${projectFolders.length} conversations=${importableConversations.length}`);
84
+ }
85
+ catch (err) {
86
+ console.error(` [relay] failed to announce capabilities:`, err);
87
+ }
88
+ }
89
+ function getAvailableCliNames() {
90
+ const out = [];
91
+ if (clis?.claude)
92
+ out.push("CLAUDE");
93
+ if (clis?.codex)
94
+ out.push("CODEX");
95
+ if (clis?.cursor)
96
+ out.push("CURSOR");
97
+ return out;
98
+ }
99
+ function normalizedCliName(cli) {
100
+ const upper = String(cli ?? "").toUpperCase();
101
+ if (upper === "CODEX" || upper === "CURSOR" || upper === "CLAUDE")
102
+ return upper;
103
+ return "CLAUDE";
104
+ }
105
+ function binaryFor(cli) {
106
+ const binaries = {
107
+ CODEX: clis.codex,
108
+ CURSOR: clis.cursor,
109
+ CLAUDE: clis.claude,
110
+ };
111
+ return binaries[String(cli ?? "").toUpperCase()] ?? clis.claude;
112
+ }
57
113
  function generatePairingCode() {
58
114
  return String(Math.floor(Math.random() * 999999)).padStart(6, "0");
59
115
  }
@@ -118,16 +174,26 @@ function loadSessionContexts() {
118
174
  const raw = (0, fs_1.readFileSync)(SESSION_CONTEXTS_FILE, "utf-8");
119
175
  const parsed = JSON.parse(raw);
120
176
  const now = Date.now();
177
+ let dropped = 0;
121
178
  for (const [sessionId, entry] of Object.entries(parsed)) {
122
179
  if (!entry || typeof entry.expiresAt !== "number" || entry.expiresAt <= now)
123
180
  continue;
124
181
  sessionContexts.set(sessionId, {
125
182
  claudeSessionId: typeof entry.claudeSessionId === "string" ? entry.claudeSessionId : undefined,
126
183
  codexThreadId: typeof entry.codexThreadId === "string" ? entry.codexThreadId : undefined,
184
+ cursorSessionId: typeof entry.cursorSessionId === "string" ? entry.cursorSessionId : undefined,
127
185
  expiresAt: entry.expiresAt,
128
186
  });
129
187
  }
130
- console.log(` [context] loaded ${sessionContexts.size} persisted CLI contexts`);
188
+ // Self-heal: older bridge versions persisted claudeSessionId even when the
189
+ // turn was killed before Claude wrote its first message. Those entries
190
+ // point at non-existent .jsonl files and trigger "No conversation found"
191
+ // on the next prompt. We can't tell from the entry alone whether the file
192
+ // exists yet (workingDir lives on the session, not the context), so we
193
+ // defer the check to when the prompt arrives — see handlePromptFromRelay.
194
+ if (sessionContexts.size > 0) {
195
+ console.log(` [context] loaded ${sessionContexts.size} persisted CLI contexts (dropped=${dropped})`);
196
+ }
131
197
  }
132
198
  catch (err) {
133
199
  console.error(" [context] Failed to read session contexts:", err);
@@ -153,6 +219,7 @@ function upsertSessionContext(sessionId, patch) {
153
219
  const entry = {
154
220
  claudeSessionId: patch.claudeSessionId ?? existing?.claudeSessionId,
155
221
  codexThreadId: patch.codexThreadId ?? existing?.codexThreadId,
222
+ cursorSessionId: patch.cursorSessionId ?? existing?.cursorSessionId,
156
223
  expiresAt: Date.now() + SESSION_CONTEXT_TTL_MS,
157
224
  };
158
225
  sessionContexts.set(sessionId, entry);
@@ -201,6 +268,53 @@ function getClaudeContext(sessionId) {
201
268
  function setClaudeContext(sessionId, claudeId) {
202
269
  upsertSessionContext(sessionId, { claudeSessionId: claudeId });
203
270
  }
271
+ // Claude Code persists each conversation as a JSONL file under
272
+ // ~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl. The "encoded cwd" is the
273
+ // absolute working dir with every "/" replaced by "-" (e.g. /Users/matheus →
274
+ // -Users-matheus). If the file is missing, Claude will refuse to --resume the
275
+ // id, surfacing as "No conversation found with session ID: <uuid>" on stderr.
276
+ //
277
+ // We use this check to gate persistence of claudeSessionId so a turn that was
278
+ // killed before Claude wrote its first turn never poisons session-contexts.json.
279
+ function claudeSessionFileExists(workingDir, claudeSessionId) {
280
+ if (!workingDir || !claudeSessionId)
281
+ return false;
282
+ const encoded = workingDir.replaceAll("/", "-");
283
+ const path = (0, path_1.join)((0, os_1.homedir)(), ".claude", "projects", encoded, `${claudeSessionId}.jsonl`);
284
+ return (0, fs_1.existsSync)(path);
285
+ }
286
+ // Guarded variant of setClaudeContext — only persists the mapping if Claude
287
+ // actually wrote conversation history to disk. Without this guard, killed or
288
+ // failed-init sessions left stale entries pointing at non-existent Claude
289
+ // session ids, causing every subsequent prompt to retry with --resume and fail.
290
+ function persistClaudeContextIfValid(sessionId, claudeSessionId, workingDir) {
291
+ if (claudeSessionFileExists(workingDir, claudeSessionId)) {
292
+ setClaudeContext(sessionId, claudeSessionId);
293
+ }
294
+ else {
295
+ // Evict any pre-existing entry — it's stale and would trigger --resume
296
+ // failures on the next prompt.
297
+ const existing = sessionContexts.get(sessionId);
298
+ if (existing?.claudeSessionId === claudeSessionId) {
299
+ clearSessionContext(sessionId);
300
+ console.log(` [bridge] dropped stale claude context session=${sessionId.substring(0, 8)} claude=${claudeSessionId.substring(0, 8)} (no jsonl on disk)`);
301
+ }
302
+ }
303
+ }
304
+ // Wraps getClaudeContext with on-disk verification. Returns undefined when the
305
+ // persisted claudeSessionId points at a .jsonl that no longer exists — that
306
+ // way fresh CLISession spawns use --session-id (which creates) instead of
307
+ // --resume (which fails). Also self-heals by evicting the bad entry.
308
+ function getValidClaudeContext(sessionId, workingDir) {
309
+ const claudeSessionId = getClaudeContext(sessionId);
310
+ if (!claudeSessionId)
311
+ return undefined;
312
+ if (claudeSessionFileExists(workingDir, claudeSessionId))
313
+ return claudeSessionId;
314
+ clearSessionContext(sessionId);
315
+ console.log(` [bridge] evicted stale claude context session=${sessionId.substring(0, 8)} claude=${claudeSessionId.substring(0, 8)} (jsonl missing)`);
316
+ return undefined;
317
+ }
204
318
  function getCodexContext(sessionId) {
205
319
  const entry = sessionContexts.get(sessionId);
206
320
  if (!entry)
@@ -215,6 +329,66 @@ function getCodexContext(sessionId) {
215
329
  function setCodexContext(sessionId, threadId) {
216
330
  upsertSessionContext(sessionId, { codexThreadId: threadId });
217
331
  }
332
+ function getCursorContext(sessionId) {
333
+ const entry = sessionContexts.get(sessionId);
334
+ if (!entry)
335
+ return undefined;
336
+ if (Date.now() > entry.expiresAt) {
337
+ sessionContexts.delete(sessionId);
338
+ saveSessionContexts();
339
+ return undefined;
340
+ }
341
+ return entry.cursorSessionId;
342
+ }
343
+ function setCursorContext(sessionId, cursorSessionId) {
344
+ upsertSessionContext(sessionId, { cursorSessionId });
345
+ }
346
+ function cursorSessionFileExists(cursorSessionId) {
347
+ if (!cursorSessionId)
348
+ return false;
349
+ // cursor-agent stores transcripts in TWO shapes:
350
+ // global: ~/.cursor/projects/agent-transcripts/<id>/<id>.jsonl
351
+ // per-project: ~/.cursor/projects/<project-slug>/agent-transcripts/<id>/<id>.jsonl
352
+ // A `cursor-agent --workspace <cwd>` run (what the bridge does) writes to the
353
+ // per-project path, so checking only the global one evicts every valid resume id.
354
+ const projectsRoot = (0, path_1.join)((0, os_1.homedir)(), ".cursor", "projects");
355
+ const rel = (0, path_1.join)("agent-transcripts", cursorSessionId, `${cursorSessionId}.jsonl`);
356
+ if ((0, fs_1.existsSync)((0, path_1.join)(projectsRoot, rel)))
357
+ return true;
358
+ try {
359
+ for (const entry of (0, fs_1.readdirSync)(projectsRoot, { withFileTypes: true })) {
360
+ if (!entry.isDirectory())
361
+ continue;
362
+ if ((0, fs_1.existsSync)((0, path_1.join)(projectsRoot, entry.name, rel)))
363
+ return true;
364
+ }
365
+ }
366
+ catch {
367
+ // projects root missing/unreadable — treat as no transcript
368
+ }
369
+ return false;
370
+ }
371
+ function persistCursorContextIfValid(sessionId, cursorSessionId) {
372
+ if (cursorSessionFileExists(cursorSessionId)) {
373
+ setCursorContext(sessionId, cursorSessionId);
374
+ return;
375
+ }
376
+ const existing = sessionContexts.get(sessionId);
377
+ if (existing?.cursorSessionId === cursorSessionId) {
378
+ clearSessionContext(sessionId);
379
+ console.log(` [bridge] dropped stale cursor context session=${sessionId.substring(0, 8)} cursor=${cursorSessionId.substring(0, 8)} (no jsonl on disk)`);
380
+ }
381
+ }
382
+ function getValidCursorContext(sessionId) {
383
+ const cursorSessionId = getCursorContext(sessionId);
384
+ if (!cursorSessionId)
385
+ return undefined;
386
+ if (cursorSessionFileExists(cursorSessionId))
387
+ return cursorSessionId;
388
+ clearSessionContext(sessionId);
389
+ console.log(` [bridge] evicted stale cursor context session=${sessionId.substring(0, 8)} cursor=${cursorSessionId.substring(0, 8)} (jsonl missing)`);
390
+ return undefined;
391
+ }
218
392
  function checkLocalLimit(sessionToken) {
219
393
  const now = Date.now();
220
394
  let entry = localMessageCounts.get(sessionToken);
@@ -264,6 +438,116 @@ function normalizeHttpBaseUrl(rawUrl) {
264
438
  const parsed = new URL(normalized);
265
439
  return `${parsed.protocol}//${parsed.host}`;
266
440
  }
441
+ function resolveAllowedWorkingDir(rawWorkingDir) {
442
+ const cwd = (0, path_1.resolve)(rawWorkingDir || workspaceRoot);
443
+ if (!(0, cli_conversations_1.isWithin)(workspaceRoot, cwd)) {
444
+ return { error: `Working directory is outside the allowed workspace root. cwd=${cwd} workspaceRoot=${workspaceRoot}` };
445
+ }
446
+ return { cwd };
447
+ }
448
+ function persistSessionContextsFromSession(sessionId, session) {
449
+ if (session.claudeSessionId) {
450
+ persistClaudeContextIfValid(sessionId, session.claudeSessionId, session.workingDir);
451
+ }
452
+ if (session.codexThreadId) {
453
+ setCodexContext(sessionId, session.codexThreadId);
454
+ console.log(` [bridge] session=${sessionId.substring(0, 8)} saved codex thread=${session.codexThreadId}`);
455
+ }
456
+ if (session.cursorSessionId) {
457
+ persistCursorContextIfValid(sessionId, session.cursorSessionId);
458
+ }
459
+ }
460
+ function killSession(sessionId, reason) {
461
+ const session = sessions.get(sessionId);
462
+ if (!session)
463
+ return;
464
+ console.log(` [bridge] killing session=${sessionId.substring(0, 8)} reason=${reason}`);
465
+ persistSessionContextsFromSession(sessionId, session);
466
+ sessions.delete(sessionId);
467
+ sessionsCreating.delete(sessionId);
468
+ session.kill();
469
+ }
470
+ function handlePromptCommon(msg, handlers) {
471
+ console.log(` [${handlers.source}] prompt session=${msg.sessionId.substring(0, 8)} cli=${msg.cli || "unknown"} wd=${msg.workingDir || "n/a"}`);
472
+ const cliName = normalizedCliName(msg.cli);
473
+ const cliType = cliName.toLowerCase();
474
+ const binary = binaryFor(msg.cli);
475
+ console.log(` [${handlers.source}] session=${msg.sessionId.substring(0, 8)} cliType=${cliType} binary=${binary || "missing"} workingDir=${msg.workingDir}`);
476
+ handlers.sendStatus(`Bridge received prompt. Preparing ${cliType}...`);
477
+ if (!binary) {
478
+ const install = (0, detect_1.installCommandFor)(cliName);
479
+ handlers.sendError(install
480
+ ? `${cliType} CLI is not installed on this Mac. Install it with: ${install}`
481
+ : `${cliType} CLI not found on this machine`);
482
+ return;
483
+ }
484
+ const cwdResult = resolveAllowedWorkingDir(msg.workingDir);
485
+ if ("error" in cwdResult) {
486
+ console.log(` [${handlers.source}] rejecting session=${msg.sessionId.substring(0, 8)} reason=${cwdResult.error}`);
487
+ handlers.sendError(cwdResult.error);
488
+ return;
489
+ }
490
+ const cwd = cwdResult.cwd;
491
+ if (sessionsCreating.has(msg.sessionId)) {
492
+ console.log(` [${handlers.source}] session=${msg.sessionId.substring(0, 8)} already being created, skipping duplicate`);
493
+ return;
494
+ }
495
+ let session = sessions.get(msg.sessionId);
496
+ if (!session) {
497
+ const activeCount = sessions.size + sessionsCreating.size;
498
+ if (activeCount >= MAX_CONCURRENT_CLI_SESSIONS) {
499
+ const error = `Bridge is busy: ${activeCount}/${MAX_CONCURRENT_CLI_SESSIONS} CLI sessions are active. Try again after a session finishes.`;
500
+ console.log(` [${handlers.source}] rejecting session=${msg.sessionId.substring(0, 8)} reason=${error}`);
501
+ handlers.sendError(error);
502
+ return;
503
+ }
504
+ sessionsCreating.add(msg.sessionId);
505
+ try {
506
+ session = new session_1.CLISession({
507
+ sessionId: msg.sessionId,
508
+ cliBinary: binary,
509
+ workingDir: cwd,
510
+ initialClaudeSessionId: getValidClaudeContext(msg.sessionId, cwd),
511
+ initialCodexThreadId: getCodexContext(msg.sessionId),
512
+ initialCursorSessionId: getValidCursorContext(msg.sessionId),
513
+ });
514
+ sessions.set(msg.sessionId, session);
515
+ handlers.ownerSessionIds?.add(msg.sessionId);
516
+ }
517
+ finally {
518
+ sessionsCreating.delete(msg.sessionId);
519
+ }
520
+ console.log(` [${handlers.source}] session=${msg.sessionId.substring(0, 8)} created mapSize=${sessions.size} resuming=${Boolean(getClaudeContext(msg.sessionId) || getCodexContext(msg.sessionId) || getCursorContext(msg.sessionId))}`);
521
+ handlers.sendStatus(`Starting ${cliType} in ${cwd}`);
522
+ session.on("output", (text) => {
523
+ handlers.sendOutput(text);
524
+ });
525
+ session.on("status", (text) => {
526
+ console.log(` [${handlers.source}] session=${msg.sessionId.substring(0, 8)} status: ${text}`);
527
+ handlers.sendStatus(text);
528
+ });
529
+ session.on("error", (text) => {
530
+ console.log(` [${handlers.source}] session=${msg.sessionId.substring(0, 8)} error: ${text.substring(0, 120)}`);
531
+ handlers.sendError(text);
532
+ });
533
+ session.on("done", (result) => {
534
+ console.log(` [${handlers.source}] session=${msg.sessionId.substring(0, 8)} done code=${result.code} outputLen=${result.fullOutput.length}`);
535
+ const current = sessions.get(msg.sessionId);
536
+ if (current) {
537
+ persistSessionContextsFromSession(msg.sessionId, current);
538
+ }
539
+ handlers.sendDone(result.fullOutput);
540
+ if (!current?.hasTurnInFlight && !current?.hasQueuedPrompts) {
541
+ sessions.delete(msg.sessionId);
542
+ handlers.ownerSessionIds?.delete(msg.sessionId);
543
+ }
544
+ });
545
+ }
546
+ else {
547
+ handlers.ownerSessionIds?.add(msg.sessionId);
548
+ }
549
+ session.sendPrompt(msg.content, Array.isArray(msg.attachments) ? msg.attachments : []);
550
+ }
267
551
  function sha256Hex(input) {
268
552
  return crypto_1.default.createHash("sha256").update(input).digest("hex");
269
553
  }
@@ -376,6 +660,7 @@ async function startBridge(opts) {
376
660
  setInterval(() => {
377
661
  refreshImportableConversations();
378
662
  refreshProjectFolders();
663
+ announceCapabilities();
379
664
  }, 60_000);
380
665
  loadSessionContexts();
381
666
  // Check for updates in background (non-blocking)
@@ -404,12 +689,24 @@ async function startBridge(opts) {
404
689
  console.log(" [env] XDG_CACHE_HOME:", process.env.XDG_CACHE_HOME || "unset");
405
690
  console.log(" [env] BACKEND_URL:", opts.backendUrl || process.env.BACKEND_URL || "unset");
406
691
  console.log(" Available CLIs:");
407
- console.log(` Claude Code: ${clis.claude || "not found"}`);
408
- console.log(` Codex: ${clis.codex || "not found"}`);
692
+ const cliLines = [
693
+ ["Claude Code", "CLAUDE", clis.claude],
694
+ ["Codex ", "CODEX", clis.codex],
695
+ ["Cursor ", "CURSOR", clis.cursor],
696
+ ];
697
+ for (const [label, key, path] of cliLines) {
698
+ if (path) {
699
+ console.log(` ${label}: ${path}`);
700
+ }
701
+ else {
702
+ console.log(` ${label}: not found`);
703
+ console.log(` → install on this Mac with: ${(0, detect_1.installCommandFor)(key)}`);
704
+ }
705
+ }
409
706
  console.log(` Workspace: ${workspaceRoot}`);
410
707
  console.log();
411
- if (!clis.claude && !clis.codex) {
412
- console.error(" ✗ No supported CLIs found. Install Claude Code or Codex first.");
708
+ if (!clis.claude && !clis.codex && !clis.cursor) {
709
+ console.error(" ✗ No supported CLIs found. Install Claude Code, Codex, or Cursor Agent first.");
413
710
  process.exit(1);
414
711
  }
415
712
  if (opts.backendUrl) {
@@ -524,7 +821,7 @@ function handleConnection(ws, sessionToken) {
524
821
  const ticket = verifyPromptTicket(msg.promptTicket, msg);
525
822
  console.log(` [ticket] verified session=${ticket.sessionId.substring(0, 8)} msg=${ticket.messageId.substring(0, 8)} exp=${ticket.expiresAt}`);
526
823
  connectionSessionIds.add(msg.sessionId);
527
- handlePrompt(ws, msg);
824
+ handlePrompt(ws, msg, connectionSessionIds);
528
825
  }
529
826
  catch (err) {
530
827
  const error = err instanceof Error ? err.message : "Invalid prompt ticket";
@@ -537,18 +834,8 @@ function handleConnection(ws, sessionToken) {
537
834
  console.log(` [bridge] incoming kill session=${String(msg.sessionId || "-").substring(0, 8)}`);
538
835
  const session = sessions.get(msg.sessionId);
539
836
  if (session) {
540
- // Save context before killing so we can resume later
541
- if (session.claudeSessionId) {
542
- setClaudeContext(msg.sessionId, session.claudeSessionId);
543
- console.log(` [bridge] saved claude context before kill=${session.claudeSessionId}`);
544
- }
545
- if (session.codexThreadId) {
546
- setCodexContext(msg.sessionId, session.codexThreadId);
547
- console.log(` [bridge] saved codex context before kill=${session.codexThreadId}`);
548
- }
549
- sessions.delete(msg.sessionId);
550
- sessionsCreating.delete(msg.sessionId);
551
- session.kill();
837
+ killSession(msg.sessionId, "local kill request");
838
+ connectionSessionIds.delete(msg.sessionId);
552
839
  ws.send(JSON.stringify({ type: "done", sessionId: msg.sessionId, content: "[killed]" }));
553
840
  }
554
841
  }
@@ -563,72 +850,33 @@ function handleConnection(ws, sessionToken) {
563
850
  for (const sessionId of connectionSessionIds) {
564
851
  const session = sessions.get(sessionId);
565
852
  if (session) {
566
- console.log(` [bridge] killing orphaned session=${sessionId.substring(0, 8)} on client disconnect`);
567
- session.kill();
568
- sessions.delete(sessionId);
569
- sessionsCreating.delete(sessionId);
853
+ killSession(sessionId, "client disconnect");
570
854
  }
571
855
  }
572
856
  connectionSessionIds.clear();
573
857
  });
574
858
  }
575
- function handlePrompt(ws, msg) {
576
- const cliType = msg.cli?.toUpperCase() === "CODEX" ? "codex" : "claude";
577
- const binary = cliType === "codex" ? clis.codex : clis.claude;
578
- console.log(` [bridge] handlePrompt session=${msg.sessionId.substring(0, 8)} cliType=${cliType} binary=${binary || "missing"} workingDir=${msg.workingDir}`);
579
- if (!binary) {
580
- ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, content: `${cliType} CLI not found on this machine` }));
581
- return;
582
- }
583
- // Race condition guard: if another prompt is currently creating this session, skip
584
- if (sessionsCreating.has(msg.sessionId)) {
585
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} already being created, skipping duplicate`);
586
- return;
587
- }
588
- let session = sessions.get(msg.sessionId);
589
- if (!session) {
590
- sessionsCreating.add(msg.sessionId);
591
- session = new session_1.CLISession({
592
- sessionId: msg.sessionId,
593
- cliBinary: binary,
594
- workingDir: msg.workingDir || workspaceRoot,
595
- initialClaudeSessionId: getClaudeContext(msg.sessionId),
596
- initialCodexThreadId: getCodexContext(msg.sessionId),
597
- });
598
- sessions.set(msg.sessionId, session);
599
- sessionsCreating.delete(msg.sessionId);
600
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} created mapSize=${sessions.size} resuming=${Boolean(getClaudeContext(msg.sessionId) || getCodexContext(msg.sessionId))}`);
601
- session.on("output", (text) => {
602
- ws.send(JSON.stringify({ type: "output", sessionId: msg.sessionId, content: text }));
603
- relaySend({ type: "output", sessionId: msg.sessionId, content: text });
604
- });
605
- session.on("status", (text) => {
606
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} status: ${text}`);
607
- ws.send(JSON.stringify({ type: "status", sessionId: msg.sessionId, content: text }));
608
- relaySend({ type: "status", sessionId: msg.sessionId, content: text });
609
- });
610
- session.on("error", (text) => {
611
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} error: ${text.substring(0, 120)}`);
612
- ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, content: text }));
613
- relaySend({ type: "error", sessionId: msg.sessionId, content: text });
614
- });
615
- session.on("done", (result) => {
616
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} done code=${result.code} outputLen=${result.fullOutput.length}`);
617
- const s = sessions.get(msg.sessionId);
618
- if (s?.claudeSessionId) {
619
- setClaudeContext(msg.sessionId, s.claudeSessionId);
620
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} saved claude context=${s.claudeSessionId}`);
621
- }
622
- if (s?.codexThreadId) {
623
- setCodexContext(msg.sessionId, s.codexThreadId);
624
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} saved codex thread=${s.codexThreadId}`);
625
- }
626
- ws.send(JSON.stringify({ type: "done", sessionId: msg.sessionId, content: result.fullOutput }));
627
- relaySend({ type: "done", sessionId: msg.sessionId, content: result.fullOutput });
628
- sessions.delete(msg.sessionId);
629
- });
630
- }
631
- session.sendPrompt(msg.content, Array.isArray(msg.attachments) ? msg.attachments : []);
859
+ function handlePrompt(ws, msg, ownerSessionIds) {
860
+ handlePromptCommon(msg, {
861
+ source: "bridge",
862
+ ownerSessionIds,
863
+ sendOutput: (content) => {
864
+ ws.send(JSON.stringify({ type: "output", sessionId: msg.sessionId, content }));
865
+ relaySend({ type: "output", sessionId: msg.sessionId, content });
866
+ },
867
+ sendStatus: (content) => {
868
+ ws.send(JSON.stringify({ type: "status", sessionId: msg.sessionId, content }));
869
+ relaySend({ type: "status", sessionId: msg.sessionId, content });
870
+ },
871
+ sendError: (content) => {
872
+ ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, content }));
873
+ relaySend({ type: "error", sessionId: msg.sessionId, content });
874
+ },
875
+ sendDone: (content) => {
876
+ ws.send(JSON.stringify({ type: "done", sessionId: msg.sessionId, content }));
877
+ relaySend({ type: "done", sessionId: msg.sessionId, content });
878
+ },
879
+ });
632
880
  }
633
881
  function connectToBackendRelay(backendUrl) {
634
882
  if (backendRelayUrl === backendUrl && currentRelayWs?.readyState === ws_1.WebSocket.OPEN) {
@@ -646,6 +894,7 @@ function connectToBackendRelay(backendUrl) {
646
894
  console.log(` [relay] connecting to ${url}`);
647
895
  const ws = new ws_1.WebSocket(url);
648
896
  let pingInterval = null;
897
+ const relaySessionIds = new Set();
649
898
  const startAt = Date.now();
650
899
  ws.on("open", () => {
651
900
  console.log(` ✓ Connected to backend relay after ${Date.now() - startAt}ms`);
@@ -655,18 +904,7 @@ function connectToBackendRelay(backendUrl) {
655
904
  // Announce indexed folders + importable conversations so the iOS folder
656
905
  // picker and "Continue ended one" mode have data on first open. The
657
906
  // backend caches this in-memory keyed by macId.
658
- try {
659
- ws.send(JSON.stringify({
660
- type: "capabilities",
661
- folders: projectFolders,
662
- conversations: importableConversations,
663
- workspaceRoot,
664
- }));
665
- console.log(` [relay] announced capabilities folders=${projectFolders.length} conversations=${importableConversations.length}`);
666
- }
667
- catch (err) {
668
- console.error(` [relay] failed to announce capabilities:`, err);
669
- }
907
+ announceCapabilities();
670
908
  if (pendingRelayMessages.length > 0) {
671
909
  console.log(` ↺ Flushing ${pendingRelayMessages.length} queued relay messages`);
672
910
  const toFlush = pendingRelayMessages.splice(0);
@@ -690,22 +928,14 @@ function connectToBackendRelay(backendUrl) {
690
928
  return;
691
929
  if (msg.type === "prompt") {
692
930
  console.log(` [relay] ← prompt from backend session=${String(msg.sessionId || "-").substring(0, 8)} cli=${msg.cli || "unknown"}`);
693
- handlePromptFromRelay(ws, msg);
931
+ handlePromptFromRelay(msg, relaySessionIds);
694
932
  }
695
933
  if (msg.type === "kill") {
696
934
  console.log(` [relay] ← kill from backend session=${String(msg.sessionId || "-").substring(0, 8)}`);
697
935
  const session = sessions.get(msg.sessionId);
698
936
  if (session) {
699
- // Save context before killing so we can resume later
700
- if (session.claudeSessionId) {
701
- setClaudeContext(msg.sessionId, session.claudeSessionId);
702
- }
703
- if (session.codexThreadId) {
704
- setCodexContext(msg.sessionId, session.codexThreadId);
705
- }
706
- sessions.delete(msg.sessionId);
707
- sessionsCreating.delete(msg.sessionId);
708
- session.kill();
937
+ killSession(msg.sessionId, "relay kill request");
938
+ relaySessionIds.delete(msg.sessionId);
709
939
  }
710
940
  }
711
941
  }
@@ -717,6 +947,13 @@ function connectToBackendRelay(backendUrl) {
717
947
  ws.on("close", (code, reason) => {
718
948
  if (pingInterval)
719
949
  clearInterval(pingInterval);
950
+ if (currentRelayWs === ws) {
951
+ currentRelayWs = null;
952
+ }
953
+ for (const sessionId of relaySessionIds) {
954
+ killSession(sessionId, "relay disconnect");
955
+ }
956
+ relaySessionIds.clear();
720
957
  const delay = relayRetryDelay;
721
958
  console.log(` Backend relay disconnected after ${Date.now() - startAt}ms code=${code} reason="${reason?.toString() ?? ""}". Reconnecting in ${delay / 1000}s...`);
722
959
  backendRelayConnecting = false;
@@ -749,54 +986,14 @@ function relaySend(data) {
749
986
  pendingRelayMessages.push(data);
750
987
  }
751
988
  }
752
- function handlePromptFromRelay(_relayWs, msg) {
753
- console.log(` → Received prompt for session ${msg.sessionId}: "${msg.content.substring(0, 50)}"`);
754
- const cliType = msg.cli?.toUpperCase() === "CODEX" ? "codex" : "claude";
755
- const binary = cliType === "codex" ? clis.codex : clis.claude;
756
- if (!binary) {
757
- relaySend({ type: "error", sessionId: msg.sessionId, content: `${cliType} CLI not found on this machine` });
758
- return;
759
- }
760
- if (sessionsCreating.has(msg.sessionId)) {
761
- console.log(` [relay] session=${msg.sessionId.substring(0, 8)} already being created, skipping duplicate`);
762
- return;
763
- }
764
- let session = sessions.get(msg.sessionId);
765
- if (!session) {
766
- sessionsCreating.add(msg.sessionId);
767
- session = new session_1.CLISession({
768
- sessionId: msg.sessionId,
769
- cliBinary: binary,
770
- workingDir: msg.workingDir || workspaceRoot,
771
- initialClaudeSessionId: getClaudeContext(msg.sessionId),
772
- initialCodexThreadId: getCodexContext(msg.sessionId),
773
- });
774
- sessions.set(msg.sessionId, session);
775
- sessionsCreating.delete(msg.sessionId);
776
- session.on("output", (text) => {
777
- relaySend({ type: "output", sessionId: msg.sessionId, content: text });
778
- });
779
- session.on("status", (text) => {
780
- console.log(` [relay] session=${msg.sessionId.substring(0, 8)} status: ${text}`);
781
- relaySend({ type: "status", sessionId: msg.sessionId, content: text });
782
- });
783
- session.on("error", (text) => {
784
- console.log(` ✗ Session error: ${text.substring(0, 100)}`);
785
- relaySend({ type: "error", sessionId: msg.sessionId, content: text });
786
- });
787
- session.on("done", (result) => {
788
- console.log(` ✓ Session done (exit ${result.code}), output length: ${result.fullOutput.length}`);
789
- const s = sessions.get(msg.sessionId);
790
- if (s?.claudeSessionId) {
791
- setClaudeContext(msg.sessionId, s.claudeSessionId);
792
- }
793
- if (s?.codexThreadId) {
794
- setCodexContext(msg.sessionId, s.codexThreadId);
795
- }
796
- relaySend({ type: "done", sessionId: msg.sessionId, content: result.fullOutput });
797
- sessions.delete(msg.sessionId);
798
- });
799
- }
800
- session.sendPrompt(msg.content, Array.isArray(msg.attachments) ? msg.attachments : []);
989
+ function handlePromptFromRelay(msg, ownerSessionIds) {
990
+ handlePromptCommon(msg, {
991
+ source: "relay",
992
+ ownerSessionIds,
993
+ sendOutput: (content) => relaySend({ type: "output", sessionId: msg.sessionId, content }),
994
+ sendStatus: (content) => relaySend({ type: "status", sessionId: msg.sessionId, content }),
995
+ sendError: (content) => relaySend({ type: "error", sessionId: msg.sessionId, content }),
996
+ sendDone: (content) => relaySend({ type: "done", sessionId: msg.sessionId, content }),
997
+ });
801
998
  }
802
999
  //# sourceMappingURL=bridge.js.map