cosmoremote 2.1.0 → 2.1.2

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);
@@ -71,16 +75,41 @@ function announceCapabilities() {
71
75
  currentRelayWs.send(JSON.stringify({
72
76
  type: "capabilities",
73
77
  bridgeVersion: getPackageVersion(),
78
+ availableClis: getAvailableCliNames(),
74
79
  folders: projectFolders,
75
80
  conversations: importableConversations,
76
81
  workspaceRoot,
77
82
  }));
78
- console.log(` [relay] announced capabilities folders=${projectFolders.length} conversations=${importableConversations.length}`);
83
+ console.log(` [relay] announced capabilities clis=${getAvailableCliNames().join(",") || "none"} folders=${projectFolders.length} conversations=${importableConversations.length}`);
79
84
  }
80
85
  catch (err) {
81
86
  console.error(` [relay] failed to announce capabilities:`, err);
82
87
  }
83
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
+ }
84
113
  function generatePairingCode() {
85
114
  return String(Math.floor(Math.random() * 999999)).padStart(6, "0");
86
115
  }
@@ -145,16 +174,26 @@ function loadSessionContexts() {
145
174
  const raw = (0, fs_1.readFileSync)(SESSION_CONTEXTS_FILE, "utf-8");
146
175
  const parsed = JSON.parse(raw);
147
176
  const now = Date.now();
177
+ let dropped = 0;
148
178
  for (const [sessionId, entry] of Object.entries(parsed)) {
149
179
  if (!entry || typeof entry.expiresAt !== "number" || entry.expiresAt <= now)
150
180
  continue;
151
181
  sessionContexts.set(sessionId, {
152
182
  claudeSessionId: typeof entry.claudeSessionId === "string" ? entry.claudeSessionId : undefined,
153
183
  codexThreadId: typeof entry.codexThreadId === "string" ? entry.codexThreadId : undefined,
184
+ cursorSessionId: typeof entry.cursorSessionId === "string" ? entry.cursorSessionId : undefined,
154
185
  expiresAt: entry.expiresAt,
155
186
  });
156
187
  }
157
- 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
+ }
158
197
  }
159
198
  catch (err) {
160
199
  console.error(" [context] Failed to read session contexts:", err);
@@ -180,6 +219,7 @@ function upsertSessionContext(sessionId, patch) {
180
219
  const entry = {
181
220
  claudeSessionId: patch.claudeSessionId ?? existing?.claudeSessionId,
182
221
  codexThreadId: patch.codexThreadId ?? existing?.codexThreadId,
222
+ cursorSessionId: patch.cursorSessionId ?? existing?.cursorSessionId,
183
223
  expiresAt: Date.now() + SESSION_CONTEXT_TTL_MS,
184
224
  };
185
225
  sessionContexts.set(sessionId, entry);
@@ -228,6 +268,53 @@ function getClaudeContext(sessionId) {
228
268
  function setClaudeContext(sessionId, claudeId) {
229
269
  upsertSessionContext(sessionId, { claudeSessionId: claudeId });
230
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
+ }
231
318
  function getCodexContext(sessionId) {
232
319
  const entry = sessionContexts.get(sessionId);
233
320
  if (!entry)
@@ -242,6 +329,66 @@ function getCodexContext(sessionId) {
242
329
  function setCodexContext(sessionId, threadId) {
243
330
  upsertSessionContext(sessionId, { codexThreadId: threadId });
244
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
+ }
245
392
  function checkLocalLimit(sessionToken) {
246
393
  const now = Date.now();
247
394
  let entry = localMessageCounts.get(sessionToken);
@@ -291,6 +438,116 @@ function normalizeHttpBaseUrl(rawUrl) {
291
438
  const parsed = new URL(normalized);
292
439
  return `${parsed.protocol}//${parsed.host}`;
293
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
+ }
294
551
  function sha256Hex(input) {
295
552
  return crypto_1.default.createHash("sha256").update(input).digest("hex");
296
553
  }
@@ -432,12 +689,24 @@ async function startBridge(opts) {
432
689
  console.log(" [env] XDG_CACHE_HOME:", process.env.XDG_CACHE_HOME || "unset");
433
690
  console.log(" [env] BACKEND_URL:", opts.backendUrl || process.env.BACKEND_URL || "unset");
434
691
  console.log(" Available CLIs:");
435
- console.log(` Claude Code: ${clis.claude || "not found"}`);
436
- 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
+ }
437
706
  console.log(` Workspace: ${workspaceRoot}`);
438
707
  console.log();
439
- if (!clis.claude && !clis.codex) {
440
- 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.");
441
710
  process.exit(1);
442
711
  }
443
712
  if (opts.backendUrl) {
@@ -552,7 +821,7 @@ function handleConnection(ws, sessionToken) {
552
821
  const ticket = verifyPromptTicket(msg.promptTicket, msg);
553
822
  console.log(` [ticket] verified session=${ticket.sessionId.substring(0, 8)} msg=${ticket.messageId.substring(0, 8)} exp=${ticket.expiresAt}`);
554
823
  connectionSessionIds.add(msg.sessionId);
555
- handlePrompt(ws, msg);
824
+ handlePrompt(ws, msg, connectionSessionIds);
556
825
  }
557
826
  catch (err) {
558
827
  const error = err instanceof Error ? err.message : "Invalid prompt ticket";
@@ -565,18 +834,8 @@ function handleConnection(ws, sessionToken) {
565
834
  console.log(` [bridge] incoming kill session=${String(msg.sessionId || "-").substring(0, 8)}`);
566
835
  const session = sessions.get(msg.sessionId);
567
836
  if (session) {
568
- // Save context before killing so we can resume later
569
- if (session.claudeSessionId) {
570
- setClaudeContext(msg.sessionId, session.claudeSessionId);
571
- console.log(` [bridge] saved claude context before kill=${session.claudeSessionId}`);
572
- }
573
- if (session.codexThreadId) {
574
- setCodexContext(msg.sessionId, session.codexThreadId);
575
- console.log(` [bridge] saved codex context before kill=${session.codexThreadId}`);
576
- }
577
- sessions.delete(msg.sessionId);
578
- sessionsCreating.delete(msg.sessionId);
579
- session.kill();
837
+ killSession(msg.sessionId, "local kill request");
838
+ connectionSessionIds.delete(msg.sessionId);
580
839
  ws.send(JSON.stringify({ type: "done", sessionId: msg.sessionId, content: "[killed]" }));
581
840
  }
582
841
  }
@@ -591,78 +850,33 @@ function handleConnection(ws, sessionToken) {
591
850
  for (const sessionId of connectionSessionIds) {
592
851
  const session = sessions.get(sessionId);
593
852
  if (session) {
594
- console.log(` [bridge] killing orphaned session=${sessionId.substring(0, 8)} on client disconnect`);
595
- session.kill();
596
- sessions.delete(sessionId);
597
- sessionsCreating.delete(sessionId);
853
+ killSession(sessionId, "client disconnect");
598
854
  }
599
855
  }
600
856
  connectionSessionIds.clear();
601
857
  });
602
858
  }
603
- function handlePrompt(ws, msg) {
604
- const cliType = msg.cli?.toUpperCase() === "CODEX" ? "codex" : "claude";
605
- const binary = cliType === "codex" ? clis.codex : clis.claude;
606
- console.log(` [bridge] handlePrompt session=${msg.sessionId.substring(0, 8)} cliType=${cliType} binary=${binary || "missing"} workingDir=${msg.workingDir}`);
607
- const sendStatus = (content) => {
608
- ws.send(JSON.stringify({ type: "status", sessionId: msg.sessionId, content }));
609
- relaySend({ type: "status", sessionId: msg.sessionId, content });
610
- };
611
- sendStatus(`Bridge received prompt. Preparing ${cliType}...`);
612
- if (!binary) {
613
- ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, content: `${cliType} CLI not found on this machine` }));
614
- return;
615
- }
616
- // Race condition guard: if another prompt is currently creating this session, skip
617
- if (sessionsCreating.has(msg.sessionId)) {
618
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} already being created, skipping duplicate`);
619
- return;
620
- }
621
- let session = sessions.get(msg.sessionId);
622
- if (!session) {
623
- sessionsCreating.add(msg.sessionId);
624
- session = new session_1.CLISession({
625
- sessionId: msg.sessionId,
626
- cliBinary: binary,
627
- workingDir: msg.workingDir || workspaceRoot,
628
- initialClaudeSessionId: getClaudeContext(msg.sessionId),
629
- initialCodexThreadId: getCodexContext(msg.sessionId),
630
- });
631
- sessions.set(msg.sessionId, session);
632
- sessionsCreating.delete(msg.sessionId);
633
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} created mapSize=${sessions.size} resuming=${Boolean(getClaudeContext(msg.sessionId) || getCodexContext(msg.sessionId))}`);
634
- sendStatus(`Starting ${cliType} in ${msg.workingDir || workspaceRoot}`);
635
- session.on("output", (text) => {
636
- ws.send(JSON.stringify({ type: "output", sessionId: msg.sessionId, content: text }));
637
- relaySend({ type: "output", sessionId: msg.sessionId, content: text });
638
- });
639
- session.on("status", (text) => {
640
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} status: ${text}`);
641
- ws.send(JSON.stringify({ type: "status", sessionId: msg.sessionId, content: text }));
642
- relaySend({ type: "status", sessionId: msg.sessionId, content: text });
643
- });
644
- session.on("error", (text) => {
645
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} error: ${text.substring(0, 120)}`);
646
- ws.send(JSON.stringify({ type: "error", sessionId: msg.sessionId, content: text }));
647
- relaySend({ type: "error", sessionId: msg.sessionId, content: text });
648
- });
649
- session.on("done", (result) => {
650
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} done code=${result.code} outputLen=${result.fullOutput.length}`);
651
- const s = sessions.get(msg.sessionId);
652
- if (s?.claudeSessionId) {
653
- setClaudeContext(msg.sessionId, s.claudeSessionId);
654
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} saved claude context=${s.claudeSessionId}`);
655
- }
656
- if (s?.codexThreadId) {
657
- setCodexContext(msg.sessionId, s.codexThreadId);
658
- console.log(` [bridge] session=${msg.sessionId.substring(0, 8)} saved codex thread=${s.codexThreadId}`);
659
- }
660
- ws.send(JSON.stringify({ type: "done", sessionId: msg.sessionId, content: result.fullOutput }));
661
- relaySend({ type: "done", sessionId: msg.sessionId, content: result.fullOutput });
662
- sessions.delete(msg.sessionId);
663
- });
664
- }
665
- 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
+ });
666
880
  }
667
881
  function connectToBackendRelay(backendUrl) {
668
882
  if (backendRelayUrl === backendUrl && currentRelayWs?.readyState === ws_1.WebSocket.OPEN) {
@@ -680,6 +894,7 @@ function connectToBackendRelay(backendUrl) {
680
894
  console.log(` [relay] connecting to ${url}`);
681
895
  const ws = new ws_1.WebSocket(url);
682
896
  let pingInterval = null;
897
+ const relaySessionIds = new Set();
683
898
  const startAt = Date.now();
684
899
  ws.on("open", () => {
685
900
  console.log(` ✓ Connected to backend relay after ${Date.now() - startAt}ms`);
@@ -713,22 +928,14 @@ function connectToBackendRelay(backendUrl) {
713
928
  return;
714
929
  if (msg.type === "prompt") {
715
930
  console.log(` [relay] ← prompt from backend session=${String(msg.sessionId || "-").substring(0, 8)} cli=${msg.cli || "unknown"}`);
716
- handlePromptFromRelay(ws, msg);
931
+ handlePromptFromRelay(msg, relaySessionIds);
717
932
  }
718
933
  if (msg.type === "kill") {
719
934
  console.log(` [relay] ← kill from backend session=${String(msg.sessionId || "-").substring(0, 8)}`);
720
935
  const session = sessions.get(msg.sessionId);
721
936
  if (session) {
722
- // Save context before killing so we can resume later
723
- if (session.claudeSessionId) {
724
- setClaudeContext(msg.sessionId, session.claudeSessionId);
725
- }
726
- if (session.codexThreadId) {
727
- setCodexContext(msg.sessionId, session.codexThreadId);
728
- }
729
- sessions.delete(msg.sessionId);
730
- sessionsCreating.delete(msg.sessionId);
731
- session.kill();
937
+ killSession(msg.sessionId, "relay kill request");
938
+ relaySessionIds.delete(msg.sessionId);
732
939
  }
733
940
  }
734
941
  }
@@ -740,6 +947,13 @@ function connectToBackendRelay(backendUrl) {
740
947
  ws.on("close", (code, reason) => {
741
948
  if (pingInterval)
742
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();
743
957
  const delay = relayRetryDelay;
744
958
  console.log(` Backend relay disconnected after ${Date.now() - startAt}ms code=${code} reason="${reason?.toString() ?? ""}". Reconnecting in ${delay / 1000}s...`);
745
959
  backendRelayConnecting = false;
@@ -772,57 +986,14 @@ function relaySend(data) {
772
986
  pendingRelayMessages.push(data);
773
987
  }
774
988
  }
775
- function handlePromptFromRelay(_relayWs, msg) {
776
- console.log(` → Received prompt for session ${msg.sessionId}: "${msg.content.substring(0, 50)}"`);
777
- const cliType = msg.cli?.toUpperCase() === "CODEX" ? "codex" : "claude";
778
- const binary = cliType === "codex" ? clis.codex : clis.claude;
779
- const sendStatus = (content) => relaySend({ type: "status", sessionId: msg.sessionId, content });
780
- sendStatus(`Bridge received prompt. Preparing ${cliType}...`);
781
- if (!binary) {
782
- relaySend({ type: "error", sessionId: msg.sessionId, content: `${cliType} CLI not found on this machine` });
783
- return;
784
- }
785
- if (sessionsCreating.has(msg.sessionId)) {
786
- console.log(` [relay] session=${msg.sessionId.substring(0, 8)} already being created, skipping duplicate`);
787
- return;
788
- }
789
- let session = sessions.get(msg.sessionId);
790
- if (!session) {
791
- sessionsCreating.add(msg.sessionId);
792
- session = new session_1.CLISession({
793
- sessionId: msg.sessionId,
794
- cliBinary: binary,
795
- workingDir: msg.workingDir || workspaceRoot,
796
- initialClaudeSessionId: getClaudeContext(msg.sessionId),
797
- initialCodexThreadId: getCodexContext(msg.sessionId),
798
- });
799
- sessions.set(msg.sessionId, session);
800
- sessionsCreating.delete(msg.sessionId);
801
- sendStatus(`Starting ${cliType} in ${msg.workingDir || workspaceRoot}`);
802
- session.on("output", (text) => {
803
- relaySend({ type: "output", sessionId: msg.sessionId, content: text });
804
- });
805
- session.on("status", (text) => {
806
- console.log(` [relay] session=${msg.sessionId.substring(0, 8)} status: ${text}`);
807
- relaySend({ type: "status", sessionId: msg.sessionId, content: text });
808
- });
809
- session.on("error", (text) => {
810
- console.log(` ✗ Session error: ${text.substring(0, 100)}`);
811
- relaySend({ type: "error", sessionId: msg.sessionId, content: text });
812
- });
813
- session.on("done", (result) => {
814
- console.log(` ✓ Session done (exit ${result.code}), output length: ${result.fullOutput.length}`);
815
- const s = sessions.get(msg.sessionId);
816
- if (s?.claudeSessionId) {
817
- setClaudeContext(msg.sessionId, s.claudeSessionId);
818
- }
819
- if (s?.codexThreadId) {
820
- setCodexContext(msg.sessionId, s.codexThreadId);
821
- }
822
- relaySend({ type: "done", sessionId: msg.sessionId, content: result.fullOutput });
823
- sessions.delete(msg.sessionId);
824
- });
825
- }
826
- 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
+ });
827
998
  }
828
999
  //# sourceMappingURL=bridge.js.map