claude-code-session-manager 0.108.0 → 0.110.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 (79) hide show
  1. package/dist/assets/AgentLibrary-Cm6MrOnF.js +2 -0
  2. package/dist/assets/{Button-DC6VzovQ.js → Button-CGVncJ6a.js} +1 -1
  3. package/dist/assets/DataModel-C3hlMoa8.js +1 -0
  4. package/dist/assets/History-CTyfGNKn.js +2 -0
  5. package/dist/assets/Hooks-BXxCBibx.js +3 -0
  6. package/dist/assets/{Library-DLmnlfQt.js → Library-aCs7zxAg.js} +24 -24
  7. package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-B9Hqk1_f.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-Djfgp32k.js} +1 -1
  9. package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-8HQC6QgR.js} +2 -2
  10. package/dist/assets/{Memory-C3rUQ_cg.js → Memory-c4SVE3l2.js} +5 -5
  11. package/dist/assets/{Panel-CC3JTnOI.js → Panel-BEQTwE4z.js} +1 -1
  12. package/dist/assets/Permissions-BaYzgZtc.js +3 -0
  13. package/dist/assets/Plugins-DnXsVau7.js +2 -0
  14. package/dist/assets/ProvenanceBadge-CT2vWps1.js +1 -0
  15. package/dist/assets/RemoteMachines-DHJZfdJC.js +3 -0
  16. package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DwcHt3iR.js} +1 -1
  17. package/dist/assets/Scheduler-D6Gcuo0M.js +16 -0
  18. package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-CgvWDc9Y.js} +1 -1
  19. package/dist/assets/Settings-CJoogVAB.js +4 -0
  20. package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-4fq01kxq.js} +14 -14
  21. package/dist/assets/Skills-BU4ZX4h4.js +2 -0
  22. package/dist/assets/SystemPrompt-BCEyMSEq.js +1 -0
  23. package/dist/assets/TagLibrary-m3It8Oal.js +1 -0
  24. package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-DVyBsfsT.js} +1 -1
  25. package/dist/assets/{Toggle-BGT68XHP.js → Toggle-In_AExPV.js} +1 -1
  26. package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-BLHjKUv-.js} +1 -1
  27. package/dist/assets/historyTotals-BXDd3EzP.js +1 -0
  28. package/dist/assets/index-CuiU3I-8.js +3077 -0
  29. package/dist/assets/{index-tr1w3hHB.css → index-Dutcsly-.css} +1 -1
  30. package/dist/assets/listSkills-C1aWKf0T.js +1 -0
  31. package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-DD4bc5cT.js} +1 -1
  32. package/dist/assets/useAgentsDirWatch-DMQ_XZtr.js +1 -0
  33. package/dist/assets/useRemotePeers-C9NfteSD.js +1 -0
  34. package/dist/index.html +2 -2
  35. package/package.json +6 -2
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
  37. package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
  38. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
  39. package/scripts/README.md +7 -0
  40. package/scripts/scheduler-mcp-server.cjs +29 -2
  41. package/src/main/agentLibrary.cjs +20 -9
  42. package/src/main/build-info.json +4 -4
  43. package/src/main/chatRunner.cjs +7 -0
  44. package/src/main/config.cjs +20 -10
  45. package/src/main/historyAggregator.cjs +46 -3
  46. package/src/main/index.cjs +8 -0
  47. package/src/main/ipcSchemas.cjs +30 -0
  48. package/src/main/lib/autoUpdater.cjs +190 -0
  49. package/src/main/lib/crossProjectFeedback.cjs +143 -6
  50. package/src/main/lib/mcpToolCatalog.cjs +12 -3
  51. package/src/main/lib/reaperHelpers.cjs +13 -1
  52. package/src/main/lib/relayClientAuth.cjs +133 -0
  53. package/src/main/lib/remoteClientSessions.cjs +84 -0
  54. package/src/main/lib/remoteE2eCrypto.cjs +109 -0
  55. package/src/main/lib/updaterSettings.cjs +64 -0
  56. package/src/main/pty.cjs +53 -0
  57. package/src/main/remoteClient.cjs +334 -0
  58. package/src/main/remoteClientIpc.cjs +133 -0
  59. package/src/main/scheduler.cjs +16 -0
  60. package/src/main/updaterIpc.cjs +110 -0
  61. package/src/main/webRemote.cjs +341 -215
  62. package/src/preload/api.d.ts +97 -7
  63. package/src/preload/index.cjs +51 -3
  64. package/dist/assets/AgentLibrary-Co54GThg.js +0 -2
  65. package/dist/assets/DataModel-DZ0vyJau.js +0 -1
  66. package/dist/assets/History-Df3YwpPe.js +0 -2
  67. package/dist/assets/Hooks-DplG6roH.js +0 -3
  68. package/dist/assets/Permissions-XvBiMj1-.js +0 -3
  69. package/dist/assets/Plugins-CzHY1mgS.js +0 -2
  70. package/dist/assets/ProvenanceBadge-DaJIWVcg.js +0 -1
  71. package/dist/assets/Scheduler-BehadU-0.js +0 -16
  72. package/dist/assets/Settings-Cg9LIni0.js +0 -4
  73. package/dist/assets/Skills-CMmCZfIv.js +0 -3
  74. package/dist/assets/SystemPrompt-D_-MV025.js +0 -1
  75. package/dist/assets/TagLibrary-D9Tv3yOJ.js +0 -1
  76. package/dist/assets/index-3_AtCAyd.js +0 -3077
  77. package/dist/assets/listSkills-QORduPIk.js +0 -1
  78. package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
  79. package/dist/assets/useConfigDirWatch-Cd5PnTkB.js +0 -1
@@ -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
 
@@ -68,107 +69,8 @@ const MSG_MAX_BYTES = 256 * 1024;
68
69
  const { ALLOWED_COMMANDS, MUTATE_COMMANDS, SAS_GATED_READS } = require('./ipcSchemas.cjs');
69
70
 
70
71
  // ─── E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2) ────────────
71
-
72
- /**
73
- * Generate a P-256 ECDH keypair. Public key is SPKI DER base64url; private key
74
- * is PKCS8 DER base64url. Both are stored in web-remote.json at 0600.
75
- */
76
- function generateE2EKeyPair() {
77
- const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
78
- namedCurve: 'P-256',
79
- publicKeyEncoding: { type: 'spki', format: 'der' },
80
- privateKeyEncoding: { type: 'pkcs8', format: 'der' },
81
- });
82
- return {
83
- e2ePrivateKey: privateKey.toString('base64url'),
84
- e2ePublicKey: publicKey.toString('base64url'),
85
- };
86
- }
87
-
88
- /**
89
- * Derive an AES-256-GCM session key from two P-256 public keys via ECDH + HKDF.
90
- * @param myPrivateKeyB64 Agent's PKCS8 private key, base64url DER
91
- * @param peerPublicKeyB64 Browser's SPKI public key, base64url DER
92
- * @param deviceId Used as HKDF salt for domain separation
93
- * @returns 32-byte Buffer (AES-256-GCM key)
94
- */
95
- function deriveSessionKey(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
96
- const myPrivKey = crypto.createPrivateKey({
97
- key: Buffer.from(myPrivateKeyB64, 'base64url'),
98
- format: 'der',
99
- type: 'pkcs8',
100
- });
101
- const peerPubKey = crypto.createPublicKey({
102
- key: Buffer.from(peerPublicKeyB64, 'base64url'),
103
- format: 'der',
104
- type: 'spki',
105
- });
106
- const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
107
- // HKDF: salt = deviceId bytes for domain separation, info = fixed protocol label.
108
- const salt = Buffer.from(deviceId, 'utf8');
109
- const info = Buffer.from('sm-e2e-v1', 'utf8');
110
- return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
111
- }
112
-
113
- /**
114
- * Derive a 6-digit Short Authentication String from the ECDH shared secret.
115
- * Uses a separate HKDF info label ('sm-sas-v1') so the SAS is independent of
116
- * the session key. Both sides compute the same value; user confirms they match.
117
- */
118
- function deriveSas(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
119
- const myPrivKey = crypto.createPrivateKey({
120
- key: Buffer.from(myPrivateKeyB64, 'base64url'),
121
- format: 'der',
122
- type: 'pkcs8',
123
- });
124
- const peerPubKey = crypto.createPublicKey({
125
- key: Buffer.from(peerPublicKeyB64, 'base64url'),
126
- format: 'der',
127
- type: 'spki',
128
- });
129
- const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
130
- const salt = Buffer.from(deviceId, 'utf8');
131
- const info = Buffer.from('sm-sas-v1', 'utf8');
132
- const sasBytes = Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 3));
133
- const sasNum = ((sasBytes[0] << 16) | (sasBytes[1] << 8) | sasBytes[2]) % 1_000_000;
134
- return sasNum.toString().padStart(6, '0');
135
- }
136
-
137
- /**
138
- * Encrypt a plaintext JSON string into an AES-256-GCM box.
139
- * @returns { nonce: base64url, ciphertext: base64url } — the nonce is 12 random bytes
140
- */
141
- function encryptBox(plaintext, sessionKey) {
142
- const nonce = crypto.randomBytes(12);
143
- const cipher = crypto.createCipheriv('aes-256-gcm', sessionKey, nonce);
144
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
145
- const tag = cipher.getAuthTag(); // 16-byte GCM authentication tag
146
- return {
147
- nonce: nonce.toString('base64url'),
148
- // Append the GCM tag to the ciphertext so decryptBox can locate it.
149
- ciphertext: Buffer.concat([encrypted, tag]).toString('base64url'),
150
- };
151
- }
152
-
153
- /**
154
- * Decrypt an AES-256-GCM box produced by encryptBox (or the browser's equivalent).
155
- * @returns Decrypted UTF-8 string, or null if authentication fails.
156
- */
157
- function decryptBox(nonceB64, ciphertextB64, sessionKey) {
158
- try {
159
- const nonce = Buffer.from(nonceB64, 'base64url');
160
- const data = Buffer.from(ciphertextB64, 'base64url');
161
- if (data.length < 16) return null; // too short to contain tag
162
- const tag = data.subarray(data.length - 16);
163
- const ciphertext = data.subarray(0, data.length - 16);
164
- const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, nonce);
165
- decipher.setAuthTag(tag);
166
- const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
167
- return plain;
168
- } catch {
169
- return null; // authentication failed — drop the message
170
- }
171
- }
72
+ // Implemented in ./lib/remoteE2eCrypto.cjs (shared with the desktop client role).
73
+ const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
172
74
 
173
75
  // ─── Module state ────────────────────────────────────────────────────────────
174
76
 
@@ -185,12 +87,10 @@ let _destroyed = false; // set at app shutdown to stop reconnect loops
185
87
 
186
88
  // E2E session state — reset on each new WS connection.
187
89
  // .state: 'idle' | 'pending_sas' | 'authenticated' | 'failed'
188
- let _e2e = makeState();
189
- // Browser SPKI pubkey (base64url) that produced the current _e2e session, and the
190
- // deviceId it was received on — tracked so a manual SAS confirmation can pin it to
191
- // that device's `verifiedPeerPubKey` for auto-trust on future reconnects.
192
- let _e2ePeerPubKey = null;
193
- 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();
194
94
 
195
95
  // ─── Config helpers ───────────────────────────────────────────────────────────
196
96
 
@@ -242,14 +142,14 @@ async function saveConfig(data) {
242
142
 
243
143
  // ─── Audit log ────────────────────────────────────────────────────────────────
244
144
 
245
- // 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>
246
146
  // NEVER log token values or payload content.
247
- async function auditLog(ts, type, deviceId, msgId, result) {
147
+ async function auditLog(ts, type, deviceId, msgId, result, client) {
248
148
  try {
249
149
  const ymd = ts.slice(0, 10);
250
150
  const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
251
151
  const clean = (s) => String(s ?? '').replace(/[\r\n\t]+/g, ' ').slice(0, 200);
252
- 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`;
253
153
  const handle = await fsp.open(logPath, 'a', 0o600);
254
154
  try {
255
155
  await handle.write(line);
@@ -317,27 +217,73 @@ async function getDeviceTicket(deviceToken) {
317
217
 
318
218
  // ─── E2E state helpers ────────────────────────────────────────────────────────
319
219
 
320
- function resetE2e(state = 'idle') {
321
- _e2e = makeState(state);
322
- _e2ePeerPubKey = null;
323
- _e2eDeviceId = null;
220
+ // Tear down every client session (connection-level reset).
221
+ function resetE2e() {
222
+ teardownAllClients();
223
+ }
224
+
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');
243
+ }
244
+
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);
252
+ }
253
+
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);
324
260
  }
325
261
 
326
262
  // ─── WebSocket lifecycle ──────────────────────────────────────────────────────
327
263
 
328
264
  // Single source of truth for the status payload shape shared by the
329
265
  // broadcast push (webRemote:status) and the get-status IPC pull — both must
330
- // 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.
331
267
  function computeStatus(cfg) {
332
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';
333
273
  return {
334
274
  enabled: cfg.remoteEnabled,
335
275
  remoteControlEnabled: cfg.remoteControlEnabled ?? false,
336
276
  connected,
337
- e2eActive: connected && _e2e.sessionKey !== null,
338
- e2eAuthenticated: connected && _e2e.state === 'authenticated',
339
- e2eState: connected ? _e2e.state : 'idle',
340
- 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
+ })),
341
287
  devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
342
288
  deviceId, deviceName, issuedAt, lastConnectedAt,
343
289
  })),
@@ -481,7 +427,7 @@ async function connect() {
481
427
  broadcastStatus();
482
428
  // v2: begin pushing the live session list once connected.
483
429
  // Reset diff-guard so the new client always receives a full session-list push.
484
- _lastSessionListJson = null;
430
+ for (const c of _clients.values()) c.lastSessionListJson = null;
485
431
  startSessionListPush();
486
432
  });
487
433
 
@@ -526,6 +472,7 @@ async function connect() {
526
472
  }
527
473
 
528
474
  async function handleTokenRevoked(deviceId) {
475
+ teardownAllClients();
529
476
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
530
477
  const cfg = await loadConfig();
531
478
  const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
@@ -547,24 +494,48 @@ const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
547
494
 
548
495
  const _sessionWatchers = new Map(); // tabId → watcher
549
496
  let _sessionListTimer = null;
550
- let _lastSessionListJson = null; // diff-guard: skip push when snapshot unchanged
551
497
 
552
- /** Push an unsolicited event to the browser(s). Encrypts when an E2E key is active. */
553
- 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) {
554
500
  if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
501
+ if (!client || client.e2e.state !== 'authenticated' || !client.e2e.sessionKey) return;
555
502
  const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
556
503
  try {
557
- if (_e2e.sessionKey) {
558
- const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2e.sessionKey);
559
- _ws.send(JSON.stringify({ type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() }));
560
- } else {
561
- _ws.send(JSON.stringify(inner));
562
- }
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));
563
508
  } catch (e) {
564
509
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
565
510
  }
566
511
  }
567
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
+
568
539
  /** Extract concatenated text from an assistant transcript line, or '' if none. */
569
540
  function extractAssistantText(raw) {
570
541
  const msg = raw?.message || raw;
@@ -660,8 +631,8 @@ async function pollSessionWatcher(w) {
660
631
  if (nextState && nextState !== w.state) {
661
632
  w.state = nextState;
662
633
  // Guard: don't push state if SAS not yet confirmed (watcher may outlive an auth reset).
663
- if (_e2e.state === 'authenticated') {
664
- 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() });
665
636
  }
666
637
  }
667
638
  if (newAssistantText && newMsgId !== w.lastMsgId) {
@@ -712,6 +683,41 @@ function stopAllSessionWatches() {
712
683
  if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
713
684
  }
714
685
 
686
+ /**
687
+ * Pure builder for the phone's session list: one project entry per tab, then
688
+ * each tab's ACTIVE Epics (from active-index.json via readIndex). Never throws.
689
+ */
690
+ function buildRemoteSessionList(tabs, readIndex) {
691
+ const out = [];
692
+ const seenEpics = new Set();
693
+ for (const tab of Array.isArray(tabs) ? tabs : []) {
694
+ if (!tab) continue;
695
+ const project = tab.label || path.basename(tab.cwd || '');
696
+ out.push({ kind: 'project', tabId: tab.sessionId, cwd: tab.cwd, title: tab.label || tab.cwd, project });
697
+ let sessions = null;
698
+ try { sessions = readIndex(tab.cwd)?.sessions; } catch { sessions = null; }
699
+ if (!sessions || typeof sessions !== 'object') continue;
700
+ for (const epic of Object.values(sessions)) {
701
+ if (!epic || epic.status !== 'active') continue;
702
+ if (typeof epic.claudeSessionId !== 'string' || !epic.claudeSessionId) continue;
703
+ if (seenEpics.has(epic.id)) continue;
704
+ seenEpics.add(epic.id);
705
+ const firstLine = typeof epic.goalText === 'string' ? epic.goalText.split('\n')[0].trim().slice(0, 80).trim() : '';
706
+ out.push({
707
+ kind: 'epic',
708
+ tabId: epic.claudeSessionId,
709
+ epicId: epic.id,
710
+ cwd: epic.worktree?.dir || epic.cwd,
711
+ title: firstLine || epic.id,
712
+ project,
713
+ tag: epic.tag,
714
+ agentType: epic.agentType,
715
+ });
716
+ }
717
+ }
718
+ return out;
719
+ }
720
+
715
721
  /** Push the current session list (reuses sessionsStore — the canonical source). */
716
722
  async function pushSessionList() {
717
723
  try {
@@ -723,23 +729,24 @@ async function pushSessionList() {
723
729
  // Don't push before SAS is confirmed — session cwds/titles are sensitive user data.
724
730
  // A relay that completes e2e:hello before the user confirms the SAS would otherwise
725
731
  // receive the full session list immediately (same threat SAS_GATED_READS blocks for
726
- // cmd:sessions:load). Guard here so _lastSessionListJson is not poisoned either.
727
- if (_e2e.state !== 'authenticated') return;
732
+ // cmd:sessions:load). Guard here so client.lastSessionListJson is not poisoned either.
733
+ if (!anyAuthenticated()) return;
728
734
  const sessionsStore = require('./sessionsStore.cjs');
729
735
  const data = await sessionsStore.load();
730
- // Normalize persisted tabs → SessionMeta. tabId === sessionId so it
736
+ // Tabs + their ACTIVE Epics → SessionMeta. tabId === claude sessionId so it
731
737
  // matches the transcript JSONL name used by cmd:session:subscribe.
732
- const sessions = (data?.tabs ?? []).map((t) => ({
733
- tabId: t.sessionId,
734
- cwd: t.cwd,
735
- title: t.label || t.cwd,
736
- state: _sessionWatchers.get(t.sessionId)?.state ?? null,
738
+ const { readActiveIndex } = require('./lib/epicMint.cjs');
739
+ const sessions = buildRemoteSessionList(data?.tabs ?? [], readActiveIndex).map((s) => ({
740
+ ...s,
741
+ state: _sessionWatchers.get(s.tabId)?.state ?? null,
737
742
  }));
738
743
  const payload = { sessions, activeTabId: data?.activeTabId ?? null };
739
744
  const json = JSON.stringify(payload);
740
- if (json === _lastSessionListJson) return;
741
- _lastSessionListJson = json;
742
- 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
+ }
743
750
  } catch (e) {
744
751
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
745
752
  }
@@ -764,14 +771,16 @@ const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
764
771
  * completed turn per subscribed tab (~$1/$5 per 1M tokens).
765
772
  */
766
773
  async function maybeSummarize(w) {
767
- // Guard: don't push summaries (session transcript content) before SAS confirmed.
768
- 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;
769
776
  const text = w.lastAssistantText;
770
777
  if (!text) return;
771
778
  const ofMessageId = w.lastMsgId;
772
779
 
773
780
  if (text.length < SUMMARY_MIN_CHARS) {
774
- 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
+ }
775
784
  return;
776
785
  }
777
786
 
@@ -779,23 +788,25 @@ async function maybeSummarize(w) {
779
788
  if (degraded) {
780
789
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: `summary degraded: ${degraded}`, meta: {} });
781
790
  }
782
- pushEvent('event:session:summary', {
783
- tabId: w.tabId, summary, ofMessageId, model,
784
- ...(degraded ? { degraded } : {}),
785
- ts: Date.now(),
786
- });
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
+ }
787
798
  }
788
799
 
789
800
  /**
790
- * 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'
791
802
  * (manual SAS confirmation or pinned-key auto-trust): log, broadcast the new
792
803
  * status, and flush the session list immediately rather than waiting for the
793
804
  * next SESSION_LIST_PUSH_MS tick.
794
805
  */
795
- function finalizeE2eAuthenticated(logMessage) {
806
+ function finalizeE2eAuthenticated(logMessage, client) {
796
807
  logs.writeLine({ scope: 'webRemote', level: 'info', message: logMessage });
797
808
  broadcastStatus();
798
- _lastSessionListJson = null;
809
+ if (client) client.lastSessionListJson = null;
799
810
  pushSessionList().catch(() => {});
800
811
  }
801
812
 
@@ -839,6 +850,22 @@ async function handleMessage(raw, device) {
839
850
  return;
840
851
  }
841
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
+
842
869
  // ── E2E key exchange ───────────────────────────────────────────────────────
843
870
  // Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
844
871
  // We compute the shared session key and acknowledge.
@@ -883,8 +910,8 @@ async function handleMessage(raw, device) {
883
910
  }
884
911
  } catch (e) {
885
912
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello peer key validation failed — session dropped', meta: { error: e?.message } });
886
- await auditLog(new Date().toISOString(), 'e2e:hello', device.deviceId, undefined, 'error:invalid_peer_key');
887
- 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
888
915
  broadcastStatus();
889
916
  return;
890
917
  }
@@ -896,31 +923,31 @@ async function handleMessage(raw, device) {
896
923
  } catch (sasErr) {
897
924
  // SAS derivation failed — session cannot be verified by the user.
898
925
  // Mark failed so confirm-sas returns ok:false and the UI prompts retry.
899
- resetE2e('failed');
926
+ failClient(client);
900
927
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E SAS derivation failed — session marked failed', meta: { error: sasErr?.message } });
901
928
  broadcastStatus();
902
929
  return;
903
930
  }
904
- _e2ePeerPubKey = browserPubKey;
905
- _e2eDeviceId = device.deviceId;
931
+ client.peerPubKey = browserPubKey;
932
+ client.deviceId = device.deviceId;
906
933
  // TOFU pinning: if this exact (deviceId, browserPubKey) pair was already
907
934
  // manually SAS-confirmed once, skip pending_sas and auto-authenticate — the
908
935
  // user already verified this browser. A different/first-seen pubKey always
909
936
  // falls through to the manual pending_sas flow below, unchanged.
910
- if (device.verifiedPeerPubKey && device.verifiedPeerPubKey === browserPubKey) {
911
- _e2e = makeState('authenticated', sessionKey, null);
912
- finalizeE2eAuthenticated('E2E session auto-authenticated — pinned browser key matched');
913
- 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);
914
941
  return;
915
942
  }
916
- _e2e = makeState('pending_sas', sessionKey, pendingSas);
943
+ client.e2e = makeState('pending_sas', sessionKey, pendingSas);
917
944
  logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established — SAS pending confirmation' });
918
945
  broadcastStatus();
919
946
  // Acknowledge with e2e:ready (unencrypted — session just started)
920
- respond(id, undefined, 'e2e:ready');
947
+ respond(id, undefined, 'e2e:ready', client);
921
948
  } catch (e) {
922
949
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
923
- resetE2e('failed');
950
+ failClient(client);
924
951
  broadcastStatus();
925
952
  }
926
953
  return;
@@ -928,13 +955,13 @@ async function handleMessage(raw, device) {
928
955
 
929
956
  // ── Decrypt e2e:box messages ──────────────────────────────────────────────
930
957
  if (type === 'e2e:box') {
931
- if (!_e2e.sessionKey) {
958
+ if (!client.e2e.sessionKey) {
932
959
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
933
960
  return;
934
961
  }
935
962
  const { nonce, ciphertext } = payload || {};
936
963
  if (!nonce || !ciphertext) return;
937
- const plaintext = decryptBox(nonce, ciphertext, _e2e.sessionKey);
964
+ const plaintext = decryptBox(nonce, ciphertext, client.e2e.sessionKey);
938
965
  if (!plaintext) {
939
966
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
940
967
  return;
@@ -947,7 +974,7 @@ async function handleMessage(raw, device) {
947
974
  return;
948
975
  }
949
976
  // Dispatch as if it arrived unencrypted
950
- await dispatchEnvelope(inner, device);
977
+ await dispatchEnvelope(inner, device, client);
951
978
  return;
952
979
  }
953
980
 
@@ -955,14 +982,14 @@ async function handleMessage(raw, device) {
955
982
  if (!type.startsWith('cmd:')) return;
956
983
  // After E2E is established, reject plaintext commands — a malicious relay cannot
957
984
  // silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
958
- if (_e2e.sessionKey) {
985
+ if (client.e2e.sessionKey) {
959
986
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
960
987
  return;
961
988
  }
962
- await dispatchEnvelope(envelope, device);
989
+ await dispatchEnvelope(envelope, device, client);
963
990
  }
964
991
 
965
- async function dispatchEnvelope(envelope, device) {
992
+ async function dispatchEnvelope(envelope, device, client) {
966
993
  const { type, id, payload } = envelope;
967
994
  if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
968
995
 
@@ -971,22 +998,22 @@ async function dispatchEnvelope(envelope, device) {
971
998
  // Kill switch — re-reads config with 1s TTL
972
999
  const cfg = await loadConfig();
973
1000
  if (!cfg.remoteEnabled) {
974
- await auditLog(ts, type, device.deviceId, id, 'error:disabled');
975
- 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
976
1003
  return;
977
1004
  }
978
1005
 
979
1006
  // Allowlist check — reject unknown cmd:* types with opaque error (oracle prevention)
980
1007
  if (!ALLOWED_COMMANDS.has(type)) {
981
- await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
982
- respond(id, { error: 'rejected' });
1008
+ await auditLog(ts, type, device.deviceId, id, 'error:not_allowed', client);
1009
+ respond(id, { error: 'rejected' }, undefined, client);
983
1010
  return;
984
1011
  }
985
1012
 
986
1013
  // MUTATE tier gate — write/exec commands require remoteControlEnabled=true (default false)
987
1014
  if (MUTATE_COMMANDS.has(type) && !cfg.remoteControlEnabled) {
988
- await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
989
- 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
990
1017
  return;
991
1018
  }
992
1019
 
@@ -994,27 +1021,27 @@ async function dispatchEnvelope(envelope, device) {
994
1021
  // user confirms the SAS on the desktop. This prevents a compromised relay
995
1022
  // from exfiltrating session lists, PRDs, run logs, or transcript summaries
996
1023
  // by completing the ECDH handshake without the user's knowledge.
997
- if ((MUTATE_COMMANDS.has(type) || SAS_GATED_READS.has(type)) && _e2e.state !== 'authenticated') {
998
- await auditLog(ts, type, device.deviceId, id, 'error:e2e_not_authenticated');
999
- 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
1000
1027
  return;
1001
1028
  }
1002
1029
 
1003
1030
  // Dispatch
1004
1031
  let result;
1005
1032
  try {
1006
- result = await dispatchCommand(type, payload ?? {});
1007
- 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);
1008
1035
  } catch (e) {
1009
1036
  const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
1010
- await auditLog(ts, type, device.deviceId, id, `error:${code}`);
1037
+ await auditLog(ts, type, device.deviceId, id, `error:${code}`, client);
1011
1038
  result = { error: 'rejected' }; // opaque on-wire — reason stays in audit log only
1012
1039
  }
1013
1040
 
1014
- respond(id, result);
1041
+ respond(id, result, undefined, client);
1015
1042
  }
1016
1043
 
1017
- function respond(msgId, payload, typeOverride) {
1044
+ function respond(msgId, payload, typeOverride, client) {
1018
1045
  if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
1019
1046
  const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
1020
1047
  if (!responseType) return;
@@ -1027,23 +1054,28 @@ function respond(msgId, payload, typeOverride) {
1027
1054
  };
1028
1055
 
1029
1056
  try {
1030
- // Encrypt the response if a session key is active.
1031
- if (_e2e.sessionKey && !typeOverride) {
1032
- const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2e.sessionKey);
1033
- _ws.send(JSON.stringify({
1034
- type: 'e2e:box',
1035
- id: msgId,
1036
- payload: { nonce, ciphertext },
1037
- ts: Date.now(),
1038
- }));
1039
- } else {
1040
- _ws.send(JSON.stringify(inner));
1041
- }
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));
1042
1066
  } catch (e) {
1043
1067
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
1044
1068
  }
1045
1069
  }
1046
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
+
1047
1079
  // Lazy-loaded dispatch map — avoids circular require at module load time.
1048
1080
  let _dispatchMap = null;
1049
1081
 
@@ -1059,12 +1091,22 @@ function getDispatchMap() {
1059
1091
  'cmd:sessions:load': async () =>
1060
1092
  sessionsStore.load(),
1061
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
+
1062
1103
  'cmd:sessions:save': async (payload) => {
1063
1104
  const parsed = schemas.sessionsPayload.parse(payload);
1064
1105
  return sessionsStore.save(parsed);
1065
1106
  },
1066
1107
 
1067
- 'cmd:pty:spawn': async (payload) => {
1108
+ 'cmd:pty:spawn': async (payload, reqClient) => {
1109
+ const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1068
1110
  const parsed = schemas.ptySpawn.parse(payload);
1069
1111
  // Path safety — validatePath rejects anything outside home dir.
1070
1112
  validatePath(parsed.cwd);
@@ -1072,7 +1114,25 @@ function getDispatchMap() {
1072
1114
  // remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
1073
1115
  // ever uses it. Remote callers have no legitimate need for it.
1074
1116
  const { startupCommand: _ignored, ...safePayload } = parsed;
1075
- return ptyManager.spawn(safePayload);
1117
+ const result = await ptyManager.spawn(safePayload);
1118
+ // Stream output to the peer — replaces any prior subscription for this tab.
1119
+ // Chunk contents are sensitive: never logged.
1120
+ const tabId = safePayload.tabId;
1121
+ stopClientPtyStream(client, tabId);
1122
+ const unsub = ptyManager.addOutputListener(
1123
+ tabId,
1124
+ (_id, data) => {
1125
+ if (client.e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
1126
+ pushEventTo(client, 'event:pty:data', { tabId, data });
1127
+ },
1128
+ (_id, info) => {
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 });
1132
+ },
1133
+ );
1134
+ client.ptyStreams.set(tabId, unsub);
1135
+ return result;
1076
1136
  },
1077
1137
 
1078
1138
  'cmd:pty:write': async (payload) => {
@@ -1086,8 +1146,11 @@ function getDispatchMap() {
1086
1146
  return { ok: true };
1087
1147
  },
1088
1148
 
1089
- 'cmd:pty:kill': async (payload) => {
1149
+ 'cmd:pty:kill': async (payload, reqClient) => {
1090
1150
  const parsed = schemas.ptyTabId.parse(payload);
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;
1091
1154
  ptyManager.kill(parsed.tabId);
1092
1155
  return { ok: true };
1093
1156
  },
@@ -1100,17 +1163,24 @@ function getDispatchMap() {
1100
1163
  'cmd:app:version': async () =>
1101
1164
  app.getVersion(),
1102
1165
 
1166
+ 'cmd:app:info': async () =>
1167
+ ({ version: app.getVersion(), platform: process.platform }),
1168
+
1103
1169
  // v2 mobile: start/stop pushing live state + summary for a session.
1104
- 'cmd:session:subscribe': async (payload) => {
1170
+ 'cmd:session:subscribe': async (payload, reqClient) => {
1171
+ const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1105
1172
  const parsed = schemas.sessionSubscribe.parse(payload);
1106
1173
  validatePath(parsed.cwd); // home-dir boundary before any fs access
1107
1174
  startSessionWatch(parsed.tabId, parsed.cwd);
1175
+ client.subscriptions.add(parsed.tabId);
1108
1176
  return { ok: true };
1109
1177
  },
1110
1178
 
1111
- 'cmd:session:unsubscribe': async (payload) => {
1179
+ 'cmd:session:unsubscribe': async (payload, reqClient) => {
1180
+ const client = reqClient || _clients.getOrCreate(LEGACY_CLIENT_KEY);
1112
1181
  const parsed = schemas.ptyTabId.parse(payload);
1113
- stopSessionWatch(parsed.tabId);
1182
+ client.subscriptions.delete(parsed.tabId);
1183
+ pruneSessionWatchers();
1114
1184
  return { ok: true };
1115
1185
  },
1116
1186
 
@@ -1122,16 +1192,43 @@ function getDispatchMap() {
1122
1192
  chatRunner.enqueueExternalPrompt(parsed.tabId, parsed.prompt);
1123
1193
  return { ok: true };
1124
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
+ },
1125
1222
  };
1126
1223
 
1127
1224
  return _dispatchMap;
1128
1225
  }
1129
1226
 
1130
- async function dispatchCommand(type, payload) {
1227
+ async function dispatchCommand(type, payload, client) {
1131
1228
  const map = getDispatchMap();
1132
1229
  const handler = map[type];
1133
1230
  if (!handler) throw new Error(`no handler for ${type}`);
1134
- return handler(payload);
1231
+ return handler(payload, client);
1135
1232
  }
1136
1233
 
1137
1234
  // ─── Pairing ─────────────────────────────────────────────────────────────────
@@ -1177,6 +1274,7 @@ async function pair(otp) {
1177
1274
  // Set once a browser's SAS is manually confirmed; TOFU-pinned for auto-trust
1178
1275
  // on future reconnects from the same (deviceId, browserPubKey) pair.
1179
1276
  verifiedPeerPubKey: null,
1277
+ verifiedPeerPubKeys: [],
1180
1278
  }];
1181
1279
 
1182
1280
  await saveConfig({ ...cfg, devices });
@@ -1190,6 +1288,7 @@ async function pair(otp) {
1190
1288
  }
1191
1289
 
1192
1290
  async function revokeDevice(deviceId) {
1291
+ teardownAllClients();
1193
1292
  invalidateConfigCache();
1194
1293
  const cfg = await loadConfig();
1195
1294
  const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
@@ -1214,6 +1313,7 @@ async function revokeDevice(deviceId) {
1214
1313
  * This is the local-side "kill everything" action (ADR §4.1).
1215
1314
  */
1216
1315
  async function revokeAllDevices() {
1316
+ teardownAllClients();
1217
1317
  const cfg = await loadConfig();
1218
1318
  const revokedCount = (cfg.devices || []).length;
1219
1319
  await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
@@ -1237,20 +1337,34 @@ function registerRemoteHandlers() {
1237
1337
  // User confirmed that the SAS shown on both desktop and browser match.
1238
1338
  // Returns ok:false if the session is not in the pending_sas state (e.g. key
1239
1339
  // missing, already failed, already authenticated, or reconnected).
1240
- ipcMain.handle('webRemote:confirm-sas', async () => {
1241
- 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);
1242
1354
  if (!ok) {
1243
- 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 } });
1244
1356
  return { ok: false, error };
1245
1357
  }
1246
- _e2e = next;
1358
+ target.e2e = next;
1247
1359
  // Pin this browser's pubkey to the device so future reconnects with the same
1248
- // key auto-authenticate (see e2e:hello above) instead of re-prompting forever.
1249
- if (_e2ePeerPubKey && _e2eDeviceId) {
1360
+ // key auto-authenticate (see e2e:hello above). Appends; other clients' pins stay.
1361
+ if (target.peerPubKey && target.deviceId) {
1250
1362
  try {
1251
1363
  const cfg = await loadConfig();
1252
1364
  const devices = (cfg.devices || []).map((d) =>
1253
- d.deviceId === _e2eDeviceId ? { ...d, verifiedPeerPubKey: _e2ePeerPubKey } : d
1365
+ d.deviceId === target.deviceId
1366
+ ? { ...d, verifiedPeerPubKeys: addPinnedPeerKey(d, target.peerPubKey, target.clientId) }
1367
+ : d
1254
1368
  );
1255
1369
  await saveConfig({ ...cfg, devices });
1256
1370
  } catch (e) {
@@ -1260,9 +1374,9 @@ function registerRemoteHandlers() {
1260
1374
  // Flush the session list immediately — the push loop was suppressed while
1261
1375
  // state !== 'authenticated', so the mobile app would otherwise wait up to
1262
1376
  // SESSION_LIST_PUSH_MS for the first useful data.
1263
- finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user');
1377
+ finalizeE2eAuthenticated('E2E session authenticated — SAS confirmed by user', target);
1264
1378
  return { ok: true };
1265
- });
1379
+ }));
1266
1380
 
1267
1381
  ipcMain.handle('webRemote:enable', async () => {
1268
1382
  const cfg = await loadConfig();
@@ -1290,6 +1404,7 @@ function registerRemoteHandlers() {
1290
1404
  ipcMain.handle('webRemote:disable-control', async () => {
1291
1405
  const cfg = await loadConfig();
1292
1406
  await saveConfig({ ...cfg, remoteControlEnabled: false });
1407
+ teardownAllClients(); // no client keeps a PTY stream once control is off
1293
1408
  broadcastStatus();
1294
1409
  return { ok: true };
1295
1410
  });
@@ -1359,6 +1474,17 @@ module.exports = {
1359
1474
  _internal: {
1360
1475
  handleMessage,
1361
1476
  resetE2e,
1362
- getE2eState: () => _e2e,
1477
+ getE2eState: (clientKey = LEGACY_CLIENT_KEY) => (_clients.get(clientKey) || { e2e: makeState() }).e2e,
1478
+ },
1479
+ __test: {
1480
+ getDispatchMap,
1481
+ buildRemoteSessionList,
1482
+ setWs: (ws) => { _ws = ws; },
1483
+ invalidateConfigCache,
1484
+ setE2eAuthenticated: (key, clientKey = LEGACY_CLIENT_KEY) => {
1485
+ _clients.getOrCreate(clientKey).e2e = makeState('authenticated', key, null);
1486
+ },
1487
+ revokeAllDevices,
1488
+ streamCount: () => _clients.values().reduce((n, c) => n + c.ptyStreams.size, 0),
1363
1489
  },
1364
1490
  };