claude-code-session-manager 0.109.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/assets/{AgentLibrary-Br4Ispr4.js → AgentLibrary-rBO1daqM.js} +1 -1
  2. package/dist/assets/{Button-C8Mc6MEP.js → Button-DsEYRZg0.js} +1 -1
  3. package/dist/assets/{DataModel-Bz0MypYO.js → DataModel-D8sbvvBC.js} +1 -1
  4. package/dist/assets/{History-DT26Iy38.js → History-BEHLtxia.js} +1 -1
  5. package/dist/assets/{Hooks-e4xUGEf-.js → Hooks-CKpvlXrg.js} +3 -3
  6. package/dist/assets/{Library-B3rzEhvN.js → Library-CeF4qh9d.js} +1 -1
  7. package/dist/assets/{ListDetail-BAvci_aP.js → ListDetail-DcfmxPFk.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DBWTGgRz.js → MarkdownEditor-CvK1pyto.js} +1 -1
  9. package/dist/assets/{McpServers-C-grBN21.js → McpServers-D-h2ihzJ.js} +2 -2
  10. package/dist/assets/{Memory-BmM0Fz-C.js → Memory-BnF89x8v.js} +1 -1
  11. package/dist/assets/{Panel-Dc6F8wb4.js → Panel-CQY5zano.js} +1 -1
  12. package/dist/assets/{Permissions-m_NnldFh.js → Permissions-DLukmmsz.js} +3 -3
  13. package/dist/assets/{Plugins-BB0TLUC9.js → Plugins-0Pf0OZ4l.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-k9PH0S2n.js → ProvenanceBadge-vC7Tj-JP.js} +1 -1
  15. package/dist/assets/RemoteMachines-CWnKbFHc.js +3 -0
  16. package/dist/assets/{SaveBar-DSMnbUVI.js → SaveBar-DjijiUb0.js} +1 -1
  17. package/dist/assets/{Scheduler-CbIcJEfM.js → Scheduler-Cm3EXZvE.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-tHtloWbN.js → ScopeSwitcher-B-P-kSfq.js} +1 -1
  19. package/dist/assets/Settings-DYDlp2m_.js +4 -0
  20. package/dist/assets/{SkillReferenceGraph-Dndl1tzr.js → SkillReferenceGraph-Epv2GJmj.js} +1 -1
  21. package/dist/assets/{Skills-CGnxroik.js → Skills-DHO0N0C2.js} +1 -1
  22. package/dist/assets/{SystemPrompt-B3A3rHNp.js → SystemPrompt-DxeNofL9.js} +1 -1
  23. package/dist/assets/{TagLibrary-k4CTiAYo.js → TagLibrary-QOAs7ISO.js} +1 -1
  24. package/dist/assets/{TiptapBody-C2LfNQXw.js → TiptapBody-DAMO3DhO.js} +1 -1
  25. package/dist/assets/{Toggle-CTXTU1q5.js → Toggle-Ba_f7q_m.js} +1 -1
  26. package/dist/assets/{ViewTabs-CfEfOSE1.js → ViewTabs-CGX6MAhc.js} +1 -1
  27. package/dist/assets/{historyTotals-Db0xy2Fg.js → historyTotals-vgfk8EOL.js} +1 -1
  28. package/dist/assets/index-CPieAFsl.js +3078 -0
  29. package/dist/assets/{index-C8Smiz7z.css → index-Dutcsly-.css} +1 -1
  30. package/dist/assets/{settingsSchema-xxO9p4yF.js → settingsSchema-D0FxNrTy.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-DLS1_moj.js → useAgentsDirWatch-DCu6BNYN.js} +1 -1
  32. package/dist/assets/useRemotePeers-D4cdJDyh.js +1 -0
  33. package/dist/index.html +2 -2
  34. package/package.json +2 -1
  35. package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
  36. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
  37. package/scripts/scheduler-mcp-server.cjs +29 -2
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/chatRunner.cjs +7 -0
  40. package/src/main/index.cjs +6 -0
  41. package/src/main/ipcSchemas.cjs +28 -0
  42. package/src/main/lib/autoUpdater.cjs +190 -0
  43. package/src/main/lib/builtinMacros.cjs +2 -2
  44. package/src/main/lib/crossProjectFeedback.cjs +143 -6
  45. package/src/main/lib/mcpToolCatalog.cjs +12 -3
  46. package/src/main/lib/reaperHelpers.cjs +13 -1
  47. package/src/main/lib/relayClientAuth.cjs +133 -0
  48. package/src/main/lib/remoteClientSessions.cjs +84 -0
  49. package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
  50. package/src/main/lib/updaterSettings.cjs +64 -0
  51. package/src/main/remoteClient.cjs +42 -13
  52. package/src/main/remoteClientIpc.cjs +33 -1
  53. package/src/main/scheduler.cjs +16 -0
  54. package/src/main/updaterIpc.cjs +110 -0
  55. package/src/main/webRemote.cjs +276 -132
  56. package/src/preload/api.d.ts +55 -2
  57. package/src/preload/index.cjs +24 -1
  58. package/src/seed/agents/demo-video-builder.md +62 -19
  59. package/dist/assets/Settings-CW3w7knp.js +0 -6
  60. package/dist/assets/index-fJxuoRvi.js +0 -3077
@@ -31,6 +31,7 @@ const { claudeHome } = require('./lib/schedulerPaths.cjs');
31
31
  const { schemas } = require('./ipcSchemas.cjs');
32
32
  const { summarize: _sharedSummarize } = require('./lib/summarize.cjs');
33
33
  const { makeState, confirmSas: confirmSasLogic } = require('./lib/e2eStateMachine.cjs');
34
+ const { createClientSessions, LEGACY_CLIENT_KEY } = require('./lib/remoteClientSessions.cjs');
34
35
 
35
36
  // ─── Constants ───────────────────────────────────────────────────────────────
36
37
 
@@ -86,12 +87,10 @@ let _destroyed = false; // set at app shutdown to stop reconnect loops
86
87
 
87
88
  // E2E session state — reset on each new WS connection.
88
89
  // .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
89
- let _e2e = makeState();
90
- // Browser SPKI pubkey (base64url) that produced the current _e2e session, and the
91
- // deviceId it was received on — tracked so a manual SAS confirmation can pin it to
92
- // that device's `verifiedPeerPubKey` for auto-trust on future reconnects.
93
- let _e2ePeerPubKey = null;
94
- let _e2eDeviceId = null;
90
+ // One record per connected client (keyed by relay-stamped fromConn, else 'legacy').
91
+ // Each holds its own ECDH session key + SAS state, the browser pubkey that produced
92
+ // it, and the deviceId it arrived on (for TOFU pinning on manual SAS confirmation).
93
+ const _clients = createClientSessions();
95
94
 
96
95
  // ─── Config helpers ───────────────────────────────────────────────────────────
97
96
 
@@ -143,14 +142,14 @@ async function saveConfig(data) {
143
142
 
144
143
  // ─── Audit log ────────────────────────────────────────────────────────────────
145
144
 
146
- // Format: <ISO> <type> deviceId=<id> msgId=<uuid> result=ok|error:<code>
145
+ // Format: <ISO> <type> deviceId=<id> client=<clientId|connKey|-> msgId=<uuid> result=ok|error:<code>
147
146
  // NEVER log token values or payload content.
148
- async function auditLog(ts, type, deviceId, msgId, result) {
147
+ async function auditLog(ts, type, deviceId, msgId, result, client) {
149
148
  try {
150
149
  const ymd = ts.slice(0, 10);
151
150
  const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
152
151
  const clean = (s) => String(s ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 200);
153
- const line = `${ts} ${clean(type)} deviceId=${clean(deviceId) || '-'} msgId=${clean(msgId) || '-'} result=${result}\n`;
152
+ const line = `${ts} ${clean(type)} deviceId=${clean(deviceId) || '-'} client=${clean(client && (client.clientId || client.key)) || '-'} msgId=${clean(msgId) || '-'} result=${result}\n`;
154
153
  const handle = await fsp.open(logPath, 'a', 0o600);
155
154
  try {
156
155
  await handle.write(line);
@@ -218,43 +217,73 @@ async function getDeviceTicket(deviceToken) {
218
217
 
219
218
  // ─── E2E state helpers ────────────────────────────────────────────────────────
220
219
 
221
- // tabId → unsubscribe fn for the PtyManager output listener streaming to the peer.
222
- const _remotePtyStreams = new Map();
220
+ // Tear down every client session (connection-level reset).
221
+ function resetE2e() {
222
+ teardownAllClients();
223
+ }
223
224
 
224
- function stopRemotePtyStream(tabId) {
225
- const unsub = _remotePtyStreams.get(tabId);
226
- _remotePtyStreams.delete(tabId);
227
- if (unsub) {
228
- try { unsub(); } catch { /* listener already gone */ }
229
- }
225
+ // Mark one client's session failed without touching any other client.
226
+ function failClient(client) {
227
+ client.e2e = makeState('failed');
228
+ client.peerPubKey = null;
229
+ client.deviceId = null;
230
+ }
231
+
232
+ function makeClientlessState() {
233
+ return { e2e: makeState() };
234
+ }
235
+
236
+ function anyAuthenticated() {
237
+ return _clients.authenticated().length > 0;
238
+ }
239
+
240
+ // Every client currently awaiting SAS confirmation.
241
+ function pendingClients() {
242
+ return _clients.values().filter((r) => r.e2e.state === 'pending_sas');
230
243
  }
231
244
 
232
- function stopAllRemotePtyStreams() {
233
- for (const tabId of [..._remotePtyStreams.keys()]) stopRemotePtyStream(tabId);
245
+ const MAX_PINNED_PEER_KEYS = 20;
246
+
247
+ // True when `pubKey` is pinned on `device` (new list, or the legacy single key — read-only compat).
248
+ function isPinnedPeerKey(device, pubKey) {
249
+ if (!pubKey) return false;
250
+ if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === pubKey) return true;
251
+ return Array.isArray(device.verifiedPeerPubKeys) && device.verifiedPeerPubKeys.some((p) => p && p.pubKey === pubKey);
234
252
  }
235
253
 
236
- function resetE2e(state = 'idle') {
237
- stopAllRemotePtyStreams();
238
- _e2e = makeState(state);
239
- _e2ePeerPubKey = null;
240
- _e2eDeviceId = null;
254
+ // Append a pin (never removing another client's), dedupe by pubKey, keep the newest MAX.
255
+ function addPinnedPeerKey(device, pubKey, clientId) {
256
+ const prev = Array.isArray(device.verifiedPeerPubKeys) ? device.verifiedPeerPubKeys : [];
257
+ const next = prev.filter((p) => p && p.pubKey !== pubKey);
258
+ next.push({ pubKey, clientId: clientId ?? null, pinnedAt: new Date().toISOString() });
259
+ return next.slice(-MAX_PINNED_PEER_KEYS);
241
260
  }
242
261
 
243
262
  // ─── WebSocket lifecycle ──────────────────────────────────────────────────────
244
263
 
245
264
  // Single source of truth for the status payload shape shared by the
246
265
  // broadcast push (webRemote:status) and the get-status IPC pull — both must
247
- // report the same connected/e2e/device view of `cfg` + `_ws`/`_e2e` module state.
266
+ // report the same connected/e2e/device view of `cfg` + `_ws`/`_clients` module state.
248
267
  function computeStatus(cfg) {
249
268
  const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
269
+ const authed = _clients.authenticated()[0] || null;
270
+ const pending = _clients.values().find((r) => r.e2e.state === 'pending_sas') || null;
271
+ const shown = authed || pending || _clients.values()[0] || null;
272
+ const e2eState = shown ? shown.e2e.state : 'idle';
250
273
  return {
251
274
  enabled: cfg.remoteEnabled,
252
275
  remoteControlEnabled: cfg.remoteControlEnabled ?? false,
253
276
  connected,
254
- e2eActive: connected && _e2e.sessionKey !== null,
255
- e2eAuthenticated: connected && _e2e.state === 'authenticated',
256
- e2eState: connected ? _e2e.state : 'idle',
257
- pendingSas: connected && _e2e.state === 'pending_sas' ? _e2e.pendingSas : null,
277
+ e2eActive: connected && _clients.values().some((r) => r.e2e.sessionKey !== null),
278
+ e2eAuthenticated: connected && authed !== null,
279
+ e2eState: connected ? e2eState : 'idle',
280
+ pendingSas: connected && !authed && pending ? pending.e2e.pendingSas : null,
281
+ clients: _clients.values().map((r) => ({
282
+ key: r.key,
283
+ clientId: r.clientId ?? null,
284
+ state: r.e2e.state,
285
+ pendingSas: r.e2e.state === 'pending_sas' ? r.e2e.pendingSas : null,
286
+ })),
258
287
  devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
259
288
  deviceId, deviceName, issuedAt, lastConnectedAt,
260
289
  })),
@@ -324,7 +353,6 @@ function scheduleReconnect() {
324
353
  async function disconnect() {
325
354
  cancelReconnect();
326
355
  stopHeartbeat();
327
- stopAllRemotePtyStreams();
328
356
  resetE2e();
329
357
  if (_ws) {
330
358
  const ws = _ws;
@@ -399,7 +427,7 @@ async function connect() {
399
427
  broadcastStatus();
400
428
  // v2: begin pushing the live session list once connected.
401
429
  // Reset diff-guard so the new client always receives a full session-list push.
402
- _lastSessionListJson = null;
430
+ for (const c of _clients.values()) c.lastSessionListJson = null;
403
431
  startSessionListPush();
404
432
  });
405
433
 
@@ -444,7 +472,7 @@ async function connect() {
444
472
  }
445
473
 
446
474
  async function handleTokenRevoked(deviceId) {
447
- stopAllRemotePtyStreams();
475
+ teardownAllClients();
448
476
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
449
477
  const cfg = await loadConfig();
450
478
  const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
@@ -466,24 +494,48 @@ const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
466
494
 
467
495
  const _sessionWatchers = new Map(); // tabId → watcher
468
496
  let _sessionListTimer = null;
469
- let _lastSessionListJson = null; // diff-guard: skip push when snapshot unchanged
470
497
 
471
- /** Push an unsolicited event to the browser(s). Encrypts when an E2E key is active. */
472
- function pushEvent(type, payload) {
498
+ /** Push an unsolicited event to ONE client, E2E-encrypted with its key and unicast via toConn. */
499
+ function pushEventTo(client, type, payload) {
473
500
  if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
501
+ if (!client || client.e2e.state !== 'authenticated' || !client.e2e.sessionKey) return;
474
502
  const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
475
503
  try {
476
- if (_e2e.sessionKey) {
477
- const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2e.sessionKey);
478
- _ws.send(JSON.stringify({ type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() }));
479
- } else {
480
- _ws.send(JSON.stringify(inner));
481
- }
504
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
505
+ const out = { type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() };
506
+ if (client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
507
+ _ws.send(JSON.stringify(out));
482
508
  } catch (e) {
483
509
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
484
510
  }
485
511
  }
486
512
 
513
+ /** Authenticated clients subscribed to a tab's session state/summary. */
514
+ function subscribersOf(tabId) {
515
+ return _clients.authenticated().filter((c) => c.subscriptions.has(tabId));
516
+ }
517
+
518
+ /** Stop every shared session watcher that no client subscribes to any more. */
519
+ function pruneSessionWatchers() {
520
+ for (const tabId of Array.from(_sessionWatchers.keys())) {
521
+ if (!_clients.values().some((c) => c.subscriptions.has(tabId))) stopSessionWatch(tabId);
522
+ }
523
+ }
524
+
525
+ /** Tear down one client (its PTY streams + subscriptions) without touching others. */
526
+ function teardownClient(key) {
527
+ const rec = _clients.teardown(key);
528
+ pruneSessionWatchers();
529
+ return rec;
530
+ }
531
+
532
+ /** Tear down every client — no client keeps a PTY stream or subscription afterwards. */
533
+ function teardownAllClients() {
534
+ const n = _clients.teardownAll();
535
+ pruneSessionWatchers();
536
+ return n;
537
+ }
538
+
487
539
  /** Extract concatenated text from an assistant transcript line, or '' if none. */
488
540
  function extractAssistantText(raw) {
489
541
  const msg = raw?.message || raw;
@@ -579,8 +631,8 @@ async function pollSessionWatcher(w) {
579
631
  if (nextState && nextState !== w.state) {
580
632
  w.state = nextState;
581
633
  // Guard: don't push state if SAS not yet confirmed (watcher may outlive an auth reset).
582
- if (_e2e.state === 'authenticated') {
583
- pushEvent('event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
634
+ for (const c of subscribersOf(w.tabId)) {
635
+ pushEventTo(c, 'event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
584
636
  }
585
637
  }
586
638
  if (newAssistantText && newMsgId !== w.lastMsgId) {
@@ -677,8 +729,8 @@ async function pushSessionList() {
677
729
  // Don't push before SAS is confirmed — session cwds/titles are sensitive user data.
678
730
  // A relay that completes e2e:hello before the user confirms the SAS would otherwise
679
731
  // receive the full session list immediately (same threat SAS_GATED_READS blocks for
680
- // cmd:sessions:load). Guard here so _lastSessionListJson is not poisoned either.
681
- if (_e2e.state !== 'authenticated') return;
732
+ // cmd:sessions:load). Guard here so client.lastSessionListJson is not poisoned either.
733
+ if (!anyAuthenticated()) return;
682
734
  const sessionsStore = require('./sessionsStore.cjs');
683
735
  const data = await sessionsStore.load();
684
736
  // Tabs + their ACTIVE Epics → SessionMeta. tabId === claude sessionId so it
@@ -690,9 +742,11 @@ async function pushSessionList() {
690
742
  }));
691
743
  const payload = { sessions, activeTabId: data?.activeTabId ?? null };
692
744
  const json = JSON.stringify(payload);
693
- if (json === _lastSessionListJson) return;
694
- _lastSessionListJson = json;
695
- pushEvent('event:session:list', payload);
745
+ for (const c of _clients.authenticated()) {
746
+ if (json === c.lastSessionListJson) continue;
747
+ c.lastSessionListJson = json;
748
+ pushEventTo(c, 'event:session:list', payload);
749
+ }
696
750
  } catch (e) {
697
751
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
698
752
  }
@@ -717,14 +771,16 @@ const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
717
771
  * completed turn per subscribed tab (~$1/$5 per 1M tokens).
718
772
  */
719
773
  async function maybeSummarize(w) {
720
- // Guard: don't push summaries (session transcript content) before SAS confirmed.
721
- if (_e2e.state !== 'authenticated') return;
774
+ // Guard: summaries (session transcript content) go only to SAS-confirmed subscribers.
775
+ if (subscribersOf(w.tabId).length === 0) return;
722
776
  const text = w.lastAssistantText;
723
777
  if (!text) return;
724
778
  const ofMessageId = w.lastMsgId;
725
779
 
726
780
  if (text.length < SUMMARY_MIN_CHARS) {
727
- pushEvent('event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
781
+ for (const c of subscribersOf(w.tabId)) {
782
+ pushEventTo(c, 'event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
783
+ }
728
784
  return;
729
785
  }
730
786
 
@@ -732,23 +788,25 @@ async function maybeSummarize(w) {
732
788
  if (degraded) {
733
789
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: `summary degraded: ${degraded}`, meta: {} });
734
790
  }
735
- pushEvent('event:session:summary', {
736
- tabId: w.tabId, summary, ofMessageId, model,
737
- ...(degraded ? { degraded } : {}),
738
- ts: Date.now(),
739
- });
791
+ for (const c of subscribersOf(w.tabId)) {
792
+ pushEventTo(c, 'event:session:summary', {
793
+ tabId: w.tabId, summary, ofMessageId, model,
794
+ ...(degraded ? { degraded } : {}),
795
+ ts: Date.now(),
796
+ });
797
+ }
740
798
  }
741
799
 
742
800
  /**
743
- * Shared finish-up for any path that transitions _e2e into 'authenticated'
801
+ * Shared finish-up for any path that transitions a client's e2e state into 'authenticated'
744
802
  * (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
745
803
  * status, and flush the session list immediately rather than waiting for the
746
804
  * next SESSION_LIST_PUSH_MS tick.
747
805
  */
748
- function finalizeE2eAuthenticated(logMessage) {
806
+ function finalizeE2eAuthenticated(logMessage, client) {
749
807
  logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
750
808
  broadcastStatus();
751
- _lastSessionListJson = null;
809
+ if (client) client.lastSessionListJson = null;
752
810
  pushSessionList().catch(() => {});
753
811
  }
754
812
 
@@ -792,6 +850,22 @@ async function handleMessage(raw, device) {
792
850
  return;
793
851
  }
794
852
 
853
+ // Relay notice that a browser closed: tear down only that client. Never a command.
854
+ if (type === 'event:client:disconnected') {
855
+ const key = _clients.clientKeyOf({ fromConn: envelope.fromConn ?? payload?.fromConn });
856
+ if (key !== LEGACY_CLIENT_KEY) {
857
+ const reason = envelope.reason ?? payload?.reason;
858
+ const rec = teardownClient(key);
859
+ await auditLog(new Date().toISOString(), 'client:disconnected', device.deviceId, id,
860
+ `reason=${String(reason ?? '-').replace(/[^\w:.-]/g, '_').slice(0, 40)}`, rec || { key });
861
+ broadcastStatus();
862
+ }
863
+ return;
864
+ }
865
+
866
+ // Per-client E2E record, keyed by the relay-stamped fromConn (else 'legacy').
867
+ const client = _clients.getOrCreate(_clients.clientKeyOf(envelope), { clientId: envelope.fromClientId ?? null });
868
+
795
869
  // ── E2E key exchange ───────────────────────────────────────────────────────
796
870
  // Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
797
871
  // We compute the shared session key and acknowledge.
@@ -836,8 +910,8 @@ async function handleMessage(raw, device) {
836
910
  }
837
911
  } catch (e) {
838
912
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello peer key validation failed — session dropped', meta: { error: e?.message } });
839
- await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key');
840
- resetE2e('failed'); // surface key-validation failure to UI — user must reconnect
913
+ await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key', client);
914
+ failClient(client); // surface key-validation failure to UI — user must reconnect
841
915
  broadcastStatus();
842
916
  return;
843
917
  }
@@ -849,31 +923,31 @@ async function handleMessage(raw, device) {
849
923
  } catch (sasErr) {
850
924
  // SAS derivation failed — session cannot be verified by the user.
851
925
  // Mark failed so confirm-sas returns ok:false and the UI prompts retry.
852
- resetE2e('failed');
926
+ failClient(client);
853
927
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E SAS derivation failed — session marked failed', meta: { error: sasErr?.message } });
854
928
  broadcastStatus();
855
929
  return;
856
930
  }
857
- _e2ePeerPubKey = browserPubKey;
858
- _e2eDeviceId = device.deviceId;
931
+ client.peerPubKey = browserPubKey;
932
+ client.deviceId = device.deviceId;
859
933
  // TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
860
934
  // manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
861
935
  // user already verified this browser. A different/first-seen pubKey always
862
936
  // falls through to the manual pending_sas flow below, unchanged.
863
- if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === browserPubKey) {
864
- _e2e = makeState('authenticated', sessionKey, null);
865
- finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
866
- respond(id, undefined, 'e2e:ready');
937
+ if (isPinnedPeerKey(device, browserPubKey)) {
938
+ client.e2e = makeState('authenticated', sessionKey, null);
939
+ finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched', client);
940
+ respond(id, undefined, 'e2e:ready', client);
867
941
  return;
868
942
  }
869
- _e2e = makeState('pending_sas', sessionKey, pendingSas);
943
+ client.e2e = makeState('pending_sas', sessionKey, pendingSas);
870
944
  logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
871
945
  broadcastStatus();
872
946
  // Acknowledge with e2e:ready (unencrypted — session just started)
873
- respond(id, undefined, 'e2e:ready');
947
+ respond(id, undefined, 'e2e:ready', client);
874
948
  } catch (e) {
875
949
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
876
- resetE2e('failed');
950
+ failClient(client);
877
951
  broadcastStatus();
878
952
  }
879
953
  return;
@@ -881,13 +955,13 @@ async function handleMessage(raw, device) {
881
955
 
882
956
  // ── Decrypt e2e:box messages ──────────────────────────────────────────────
883
957
  if (type === 'e2e:box') {
884
- if (!_e2e.sessionKey) {
958
+ if (!client.e2e.sessionKey) {
885
959
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
886
960
  return;
887
961
  }
888
962
  const { nonce, ciphertext } = payload || {};
889
963
  if (!nonce || !ciphertext) return;
890
- const plaintext = decryptBox(nonce, ciphertext, _e2e.sessionKey);
964
+ const plaintext = decryptBox(nonce, ciphertext, client.e2e.sessionKey);
891
965
  if (!plaintext) {
892
966
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
893
967
  return;
@@ -900,7 +974,7 @@ async function handleMessage(raw, device) {
900
974
  return;
901
975
  }
902
976
  // Dispatch as if it arrived unencrypted
903
- await dispatchEnvelope(inner, device);
977
+ await dispatchEnvelope(inner, device, client);
904
978
  return;
905
979
  }
906
980
 
@@ -908,14 +982,14 @@ async function handleMessage(raw, device) {
908
982
  if (!type.startsWith('cmd:')) return;
909
983
  // After E2E is established, reject plaintext commands — a malicious relay cannot
910
984
  // silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
911
- if (_e2e.sessionKey) {
985
+ if (client.e2e.sessionKey) {
912
986
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
913
987
  return;
914
988
  }
915
- await dispatchEnvelope(envelope, device);
989
+ await dispatchEnvelope(envelope, device, client);
916
990
  }
917
991
 
918
- async function dispatchEnvelope(envelope, device) {
992
+ async function dispatchEnvelope(envelope, device, client) {
919
993
  const { type, id, payload } = envelope;
920
994
  if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
921
995
 
@@ -924,22 +998,22 @@ async function dispatchEnvelope(envelope, device) {
924
998
  // Kill switch — re-reads config with 1s TTL
925
999
  const cfg = await loadConfig();
926
1000
  if (!cfg.remoteEnabled) {
927
- await auditLog(ts, type, device.deviceId, id, 'error:disabled');
928
- respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
1001
+ await auditLog(ts, type, device.deviceId, id, 'error:disabled', client);
1002
+ respond(id, { error: 'rejected' }, undefined, client); // opaque — reason stays in audit log only
929
1003
  return;
930
1004
  }
931
1005
 
932
1006
  // Allowlist check — reject unknown cmd:* types with opaque error (oracle prevention)
933
1007
  if (!ALLOWED_COMMANDS.has(type)) {
934
- await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
935
- respond(id, { error: 'rejected' });
1008
+ await auditLog(ts, type, device.deviceId, id, 'error:not_allowed', client);
1009
+ respond(id, { error: 'rejected' }, undefined, client);
936
1010
  return;
937
1011
  }
938
1012
 
939
1013
  // MUTATE tier gate — write/exec commands require remoteControlEnabled=true (default false)
940
1014
  if (MUTATE_COMMANDS.has(type) && !cfg.remoteControlEnabled) {
941
- await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
942
- respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
1015
+ await auditLog(ts, type, device.deviceId, id, 'error:not_allowed', client);
1016
+ respond(id, { error: 'rejected' }, undefined, client); // opaque — reason stays in audit log only
943
1017
  return;
944
1018
  }
945
1019
 
@@ -947,27 +1021,27 @@ async function dispatchEnvelope(envelope, device) {
947
1021
  // user confirms the SAS on the desktop. This prevents a compromised relay
948
1022
  // from exfiltrating session lists, PRDs, run logs, or transcript summaries
949
1023
  // by completing the ECDH handshake without the user's knowledge.
950
- if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) && _e2e.state !== 'authenticated') {
951
- await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated');
952
- respond(id, { error: 'rejected' }); // opaque — reason stays in audit log only
1024
+ if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) && client.e2e.state !== 'authenticated') {
1025
+ await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated', client);
1026
+ respond(id, { error: 'rejected' }, undefined, client); // opaque — reason stays in audit log only
953
1027
  return;
954
1028
  }
955
1029
 
956
1030
  // Dispatch
957
1031
  let result;
958
1032
  try {
959
- result = await dispatchCommand(type, payload ?? {});
960
- await auditLog(ts, type, device.deviceId, id, 'ok');
1033
+ result = await dispatchCommand(type, payload ?? {}, client);
1034
+ await auditLog(ts, type, device.deviceId, id, 'ok', client);
961
1035
  } catch (e) {
962
1036
  const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
963
- await auditLog(ts, type, device.deviceId, id, `error:${code}`);
1037
+ await auditLog(ts, type, device.deviceId, id, `error:${code}`, client);
964
1038
  result = { error: 'rejected' }; // opaque on-wire — reason stays in audit log only
965
1039
  }
966
1040
 
967
- respond(id, result);
1041
+ respond(id, result, undefined, client);
968
1042
  }
969
1043
 
970
- function respond(msgId, payload, typeOverride) {
1044
+ function respond(msgId, payload, typeOverride, client) {
971
1045
  if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
972
1046
  const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
973
1047
  if (!responseType) return;
@@ -980,23 +1054,28 @@ function respond(msgId, payload, typeOverride) {
980
1054
  };
981
1055
 
982
1056
  try {
983
- // Encrypt the response if a session key is active.
984
- if (_e2e.sessionKey && !typeOverride) {
985
- const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2e.sessionKey);
986
- _ws.send(JSON.stringify({
987
- type: 'e2e:box',
988
- id: msgId,
989
- payload: { nonce, ciphertext },
990
- ts: Date.now(),
991
- }));
992
- } else {
993
- _ws.send(JSON.stringify(inner));
994
- }
1057
+ // Encrypt with the requesting client's key; unicast via toConn for relay-stamped clients.
1058
+ const out = client && client.e2e.sessionKey && !typeOverride
1059
+ ? (() => {
1060
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), client.e2e.sessionKey);
1061
+ return { type: 'e2e:box', id: msgId, payload: { nonce, ciphertext }, ts: Date.now() };
1062
+ })()
1063
+ : inner;
1064
+ if (client && client.key !== LEGACY_CLIENT_KEY) out.toConn = client.key;
1065
+ _ws.send(JSON.stringify(out));
995
1066
  } catch (e) {
996
1067
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
997
1068
  }
998
1069
  }
999
1070
 
1071
+ function stopClientPtyStream(client, tabId) {
1072
+ const unsub = client.ptyStreams.get(tabId);
1073
+ client.ptyStreams.delete(tabId);
1074
+ if (unsub) {
1075
+ try { unsub(); } catch { /* listener already gone */ }
1076
+ }
1077
+ }
1078
+
1000
1079
  // Lazy-loaded dispatch map — avoids circular require at module load time.
1001
1080
  let _dispatchMap = null;
1002
1081
 
@@ -1012,12 +1091,22 @@ function getDispatchMap() {
1012
1091
  'cmd:sessions:load': async () =>
1013
1092
  sessionsStore.load(),
1014
1093
 
1094
+ 'cmd:sessions:list': async () => {
1095
+ const { readActiveIndex } = require('./lib/epicMint.cjs');
1096
+ const data = await sessionsStore.load();
1097
+ return {
1098
+ sessions: buildRemoteSessionList(data?.tabs ?? [], readActiveIndex),
1099
+ activeTabId: data?.activeTabId ?? null,
1100
+ };
1101
+ },
1102
+
1015
1103
  'cmd:sessions:save': async (payload) => {
1016
1104
  const parsed = schemas.sessionsPayload.parse(payload);
1017
1105
  return sessionsStore.save(parsed);
1018
1106
  },
1019
1107
 
1020
- 'cmd:pty:spawn': async (payload) => {
1108
+ 'cmd:pty:spawn': async (payload, reqClient) => {
1109
+ const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1021
1110
  const parsed = schemas.ptySpawn.parse(payload);
1022
1111
  // Path safety — validatePath rejects anything outside home dir.
1023
1112
  validatePath(parsed.cwd);
@@ -1029,20 +1118,20 @@ function getDispatchMap() {
1029
1118
  // Stream output to the peer — replaces any prior subscription for this tab.
1030
1119
  // Chunk contents are sensitive: never logged.
1031
1120
  const tabId = safePayload.tabId;
1032
- stopRemotePtyStream(tabId);
1121
+ stopClientPtyStream(client, tabId);
1033
1122
  const unsub = ptyManager.addOutputListener(
1034
1123
  tabId,
1035
1124
  (_id, data) => {
1036
- if (_e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
1037
- pushEvent('event:pty:data', { tabId, data });
1125
+ if (client.e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
1126
+ pushEventTo(client, 'event:pty:data', { tabId, data });
1038
1127
  },
1039
1128
  (_id, info) => {
1040
- if (_remotePtyStreams.get(tabId) === unsub) _remotePtyStreams.delete(tabId);
1041
- if (_e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
1042
- pushEvent('event:pty:exit', { tabId, exitCode: info?.exitCode, signal: info?.signal });
1129
+ if (client.ptyStreams.get(tabId) === unsub) client.ptyStreams.delete(tabId);
1130
+ if (client.e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
1131
+ pushEventTo(client, 'event:pty:exit', { tabId, exitCode: info?.exitCode, signal: info?.signal });
1043
1132
  },
1044
1133
  );
1045
- _remotePtyStreams.set(tabId, unsub);
1134
+ client.ptyStreams.set(tabId, unsub);
1046
1135
  return result;
1047
1136
  },
1048
1137
 
@@ -1057,9 +1146,11 @@ function getDispatchMap() {
1057
1146
  return { ok: true };
1058
1147
  },
1059
1148
 
1060
- 'cmd:pty:kill': async (payload) => {
1149
+ 'cmd:pty:kill': async (payload, reqClient) => {
1061
1150
  const parsed = schemas.ptyTabId.parse(payload);
1062
- stopRemotePtyStream(parsed.tabId);
1151
+ // Kill ends the PTY itself, so every client's stream for it ends.
1152
+ for (const c of _clients.values()) stopClientPtyStream(c, parsed.tabId);
1153
+ void reqClient;
1063
1154
  ptyManager.kill(parsed.tabId);
1064
1155
  return { ok: true };
1065
1156
  },
@@ -1072,17 +1163,24 @@ function getDispatchMap() {
1072
1163
  'cmd:app:version': async () =>
1073
1164
  app.getVersion(),
1074
1165
 
1166
+ 'cmd:app:info': async () =>
1167
+ ({ version: app.getVersion(), platform: process.platform }),
1168
+
1075
1169
  // v2 mobile: start/stop pushing live state + summary for a session.
1076
- 'cmd:session:subscribe': async (payload) => {
1170
+ 'cmd:session:subscribe': async (payload, reqClient) => {
1171
+ const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1077
1172
  const parsed = schemas.sessionSubscribe.parse(payload);
1078
1173
  validatePath(parsed.cwd); // home-dir boundary before any fs access
1079
1174
  startSessionWatch(parsed.tabId, parsed.cwd);
1175
+ client.subscriptions.add(parsed.tabId);
1080
1176
  return { ok: true };
1081
1177
  },
1082
1178
 
1083
- 'cmd:session:unsubscribe': async (payload) => {
1179
+ 'cmd:session:unsubscribe': async (payload, reqClient) => {
1180
+ const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1084
1181
  const parsed = schemas.ptyTabId.parse(payload);
1085
- stopSessionWatch(parsed.tabId);
1182
+ client.subscriptions.delete(parsed.tabId);
1183
+ pruneSessionWatchers();
1086
1184
  return { ok: true };
1087
1185
  },
1088
1186
 
@@ -1094,16 +1192,43 @@ function getDispatchMap() {
1094
1192
  chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt);
1095
1193
  return { ok: true };
1096
1194
  },
1195
+
1196
+ 'cmd:session:options': async () => {
1197
+ const { EpicTagSchema } = require('./lib/promptSessionSchema.cjs');
1198
+ const agentLibrary = require('./agentLibrary.cjs');
1199
+ const macroLibrary = require('./lib/macroLibrary.cjs');
1200
+ const { tabs } = await sessionsStore.load();
1201
+ const personas = await agentLibrary.listPersonas();
1202
+ return {
1203
+ projects: tabs.map((t) => ({ cwd: t.cwd, title: t.label })),
1204
+ agents: personas.map((a) => ({ name: a.name, description: a.description })),
1205
+ tags: EpicTagSchema.options,
1206
+ macros: await macroLibrary.listMacros(),
1207
+ };
1208
+ },
1209
+
1210
+ // Input boundary: cwd must be exactly an open tab's cwd and agentName a
1211
+ // listed persona, checked before the payload goes anywhere near the fs.
1212
+ 'cmd:session:create': async (payload) => {
1213
+ const parsed = schemas.remoteSessionCreate.parse(payload);
1214
+ const agentLibrary = require('./agentLibrary.cjs');
1215
+ const { tabs } = await sessionsStore.load();
1216
+ if (!tabs.some((t) => t.cwd === parsed.cwd)) throw new Error('cwd is not an open project');
1217
+ const personas = await agentLibrary.listPersonas();
1218
+ if (!personas.some((a) => a.name === parsed.agentName)) throw new Error('unknown agent');
1219
+ chatRunner.requestExternalNewSession(parsed);
1220
+ return { ok: true };
1221
+ },
1097
1222
  };
1098
1223
 
1099
1224
  return _dispatchMap;
1100
1225
  }
1101
1226
 
1102
- async function dispatchCommand(type, payload) {
1227
+ async function dispatchCommand(type, payload, client) {
1103
1228
  const map = getDispatchMap();
1104
1229
  const handler = map[type];
1105
1230
  if (!handler) throw new Error(`no handler for ${type}`);
1106
- return handler(payload);
1231
+ return handler(payload, client);
1107
1232
  }
1108
1233
 
1109
1234
  // ─── Pairing ─────────────────────────────────────────────────────────────────
@@ -1149,6 +1274,7 @@ async function pair(otp) {
1149
1274
  // Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
1150
1275
  // on future reconnects from the same (deviceId, browserPubKey) pair.
1151
1276
  verifiedPeerPubKey: null,
1277
+ verifiedPeerPubKeys: [],
1152
1278
  }];
1153
1279
 
1154
1280
  await saveConfig({ ...cfg, devices });
@@ -1162,7 +1288,7 @@ async function pair(otp) {
1162
1288
  }
1163
1289
 
1164
1290
  async function revokeDevice(deviceId) {
1165
- stopAllRemotePtyStreams();
1291
+ teardownAllClients();
1166
1292
  invalidateConfigCache();
1167
1293
  const cfg = await loadConfig();
1168
1294
  const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
@@ -1187,7 +1313,7 @@ async function revokeDevice(deviceId) {
1187
1313
  * This is the local-side "kill everything" action (ADR §4.1).
1188
1314
  */
1189
1315
  async function revokeAllDevices() {
1190
- stopAllRemotePtyStreams();
1316
+ teardownAllClients();
1191
1317
  const cfg = await loadConfig();
1192
1318
  const revokedCount = (cfg.devices || []).length;
1193
1319
  await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
@@ -1211,20 +1337,34 @@ function registerRemoteHandlers() {
1211
1337
  // User confirmed that the SAS shown on both desktop and browser match.
1212
1338
  // Returns ok:false if the session is not in the pending_sas state (e.g. key
1213
1339
  // missing, already failed, already authenticated, or reconnected).
1214
- ipcMain.handle('webRemote:confirm-sas', async () => {
1215
- const { ok, error, next } = confirmSasLogic(_e2e);
1340
+ ipcMain.handle('webRemote:confirm-sas', validated(schemas.webRemoteConfirmSas, async (arg) => {
1341
+ const clientKey = arg?.clientKey;
1342
+ let target;
1343
+ if (clientKey !== undefined) {
1344
+ target = _clients.get(clientKey) || makeClientlessState();
1345
+ } else {
1346
+ const pending = pendingClients();
1347
+ if (pending.length > 1) {
1348
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — multiple clients pending', meta: { pending: pending.length } });
1349
+ return { ok: false, error: 'ambiguous_pending_sas' };
1350
+ }
1351
+ target = pending[0] || _clients.get(LEGACY_CLIENT_KEY) || makeClientlessState();
1352
+ }
1353
+ const { ok, error, next } = confirmSasLogic(target.e2e);
1216
1354
  if (!ok) {
1217
- logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — wrong state', meta: { state: _e2e.state, error } });
1355
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'confirm-sas rejected — wrong state', meta: { state: target.e2e.state, error } });
1218
1356
  return { ok: false, error };
1219
1357
  }
1220
- _e2e = next;
1358
+ target.e2e = next;
1221
1359
  // Pin this browser's pubkey to the device so future reconnects with the same
1222
- // key auto-authenticate (see e2e:hello above) instead of re-prompting forever.
1223
- if (_e2ePeerPubKey && _e2eDeviceId) {
1360
+ // key auto-authenticate (see e2e:hello above). Appends; other clients' pins stay.
1361
+ if (target.peerPubKey && target.deviceId) {
1224
1362
  try {
1225
1363
  const cfg = await loadConfig();
1226
1364
  const devices = (cfg.devices || []).map((d) =>
1227
- d.deviceId === _e2eDeviceId ? { ...d, verifiedPeerPubKey: _e2ePeerPubKey } : d
1365
+ d.deviceId === target.deviceId
1366
+ ? { ...d, verifiedPeerPubKeys: addPinnedPeerKey(d, target.peerPubKey, target.clientId) }
1367
+ : d
1228
1368
  );
1229
1369
  await saveConfig({ ...cfg, devices });
1230
1370
  } catch (e) {
@@ -1234,9 +1374,9 @@ function registerRemoteHandlers() {
1234
1374
  // Flush the session list immediately — the push loop was suppressed while
1235
1375
  // state !== 'authenticated', so the mobile app would otherwise wait up to
1236
1376
  // SESSION_LIST_PUSH_MS for the first useful data.
1237
- finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
1377
+ finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user', target);
1238
1378
  return { ok: true };
1239
- });
1379
+ }));
1240
1380
 
1241
1381
  ipcMain.handle('webRemote:enable', async () => {
1242
1382
  const cfg = await loadConfig();
@@ -1264,6 +1404,7 @@ function registerRemoteHandlers() {
1264
1404
  ipcMain.handle('webRemote:disable-control', async () => {
1265
1405
  const cfg = await loadConfig();
1266
1406
  await saveConfig({ ...cfg, remoteControlEnabled: false });
1407
+ teardownAllClients(); // no client keeps a PTY stream once control is off
1267
1408
  broadcastStatus();
1268
1409
  return { ok: true };
1269
1410
  });
@@ -1333,14 +1474,17 @@ module.exports = {
1333
1474
  _internal: {
1334
1475
  handleMessage,
1335
1476
  resetE2e,
1336
- getE2eState: () => _e2e,
1477
+ getE2eState: (clientKey = LEGACY_CLIENT_KEY) => (_clients.get(clientKey) || { e2e: makeState() }).e2e,
1337
1478
  },
1338
1479
  __test: {
1339
1480
  getDispatchMap,
1340
1481
  buildRemoteSessionList,
1341
1482
  setWs: (ws) => { _ws = ws; },
1342
- setE2eAuthenticated: (key) => { _e2e = makeState('authenticated', key, null); },
1483
+ invalidateConfigCache,
1484
+ setE2eAuthenticated: (key, clientKey = LEGACY_CLIENT_KEY) => {
1485
+ _clients.getOrCreate(clientKey).e2e = makeState('authenticated', key, null);
1486
+ },
1343
1487
  revokeAllDevices,
1344
- streamCount: () => _remotePtyStreams.size,
1488
+ streamCount: () => _clients.values().reduce((n, c) => n + c.ptyStreams.size, 0),
1345
1489
  },
1346
1490
  };