claude-code-session-manager 0.108.0 → 0.109.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 (63) hide show
  1. package/dist/assets/AgentLibrary-Br4Ispr4.js +2 -0
  2. package/dist/assets/{Button-DC6VzovQ.js → Button-C8Mc6MEP.js} +1 -1
  3. package/dist/assets/DataModel-Bz0MypYO.js +1 -0
  4. package/dist/assets/History-DT26Iy38.js +2 -0
  5. package/dist/assets/Hooks-e4xUGEf-.js +3 -0
  6. package/dist/assets/{Library-DLmnlfQt.js → Library-B3rzEhvN.js} +24 -24
  7. package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-BAvci_aP.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-DBWTGgRz.js} +1 -1
  9. package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-C-grBN21.js} +2 -2
  10. package/dist/assets/{Memory-C3rUQ_cg.js → Memory-BmM0Fz-C.js} +5 -5
  11. package/dist/assets/{Panel-CC3JTnOI.js → Panel-Dc6F8wb4.js} +1 -1
  12. package/dist/assets/Permissions-m_NnldFh.js +3 -0
  13. package/dist/assets/Plugins-BB0TLUC9.js +2 -0
  14. package/dist/assets/ProvenanceBadge-k9PH0S2n.js +1 -0
  15. package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DSMnbUVI.js} +1 -1
  16. package/dist/assets/Scheduler-CbIcJEfM.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-tHtloWbN.js} +1 -1
  18. package/dist/assets/Settings-CW3w7knp.js +6 -0
  19. package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-Dndl1tzr.js} +14 -14
  20. package/dist/assets/Skills-CGnxroik.js +2 -0
  21. package/dist/assets/SystemPrompt-B3A3rHNp.js +1 -0
  22. package/dist/assets/TagLibrary-k4CTiAYo.js +1 -0
  23. package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-C2LfNQXw.js} +1 -1
  24. package/dist/assets/{Toggle-BGT68XHP.js → Toggle-CTXTU1q5.js} +1 -1
  25. package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-CfEfOSE1.js} +1 -1
  26. package/dist/assets/historyTotals-Db0xy2Fg.js +1 -0
  27. package/dist/assets/{index-tr1w3hHB.css → index-C8Smiz7z.css} +1 -1
  28. package/dist/assets/{index-3_AtCAyd.js → index-fJxuoRvi.js} +697 -697
  29. package/dist/assets/listSkills-C1aWKf0T.js +1 -0
  30. package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-xxO9p4yF.js} +1 -1
  31. package/dist/assets/useAgentsDirWatch-DLS1_moj.js +1 -0
  32. package/dist/index.html +2 -2
  33. package/package.json +5 -2
  34. package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
  35. package/scripts/README.md +7 -0
  36. package/src/main/agentLibrary.cjs +20 -9
  37. package/src/main/build-info.json +4 -4
  38. package/src/main/config.cjs +20 -10
  39. package/src/main/historyAggregator.cjs +46 -3
  40. package/src/main/index.cjs +2 -0
  41. package/src/main/ipcSchemas.cjs +2 -0
  42. package/src/main/lib/remoteE2eCrypto.cjs +109 -0
  43. package/src/main/pty.cjs +53 -0
  44. package/src/main/remoteClient.cjs +305 -0
  45. package/src/main/remoteClientIpc.cjs +101 -0
  46. package/src/main/webRemote.cjs +90 -108
  47. package/src/preload/api.d.ts +42 -5
  48. package/src/preload/index.cjs +27 -2
  49. package/dist/assets/AgentLibrary-Co54GThg.js +0 -2
  50. package/dist/assets/DataModel-DZ0vyJau.js +0 -1
  51. package/dist/assets/History-Df3YwpPe.js +0 -2
  52. package/dist/assets/Hooks-DplG6roH.js +0 -3
  53. package/dist/assets/Permissions-XvBiMj1-.js +0 -3
  54. package/dist/assets/Plugins-CzHY1mgS.js +0 -2
  55. package/dist/assets/ProvenanceBadge-DaJIWVcg.js +0 -1
  56. package/dist/assets/Scheduler-BehadU-0.js +0 -16
  57. package/dist/assets/Settings-Cg9LIni0.js +0 -4
  58. package/dist/assets/Skills-CMmCZfIv.js +0 -3
  59. package/dist/assets/SystemPrompt-D_-MV025.js +0 -1
  60. package/dist/assets/TagLibrary-D9Tv3yOJ.js +0 -1
  61. package/dist/assets/listSkills-QORduPIk.js +0 -1
  62. package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
  63. package/dist/assets/useConfigDirWatch-Cd5PnTkB.js +0 -1
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+
3
+ // Desktop-to-desktop remote: the CLIENT role. Drives a paired Session Manager
4
+ // agent (webRemote.cjs) over the relay using the same envelope protocol the
5
+ // phone app speaks (web/remote-app/src/ws.ts). No Electron imports — IPC wiring
6
+ // lives elsewhere.
7
+
8
+ const crypto = require('node:crypto');
9
+ const path = require('node:path');
10
+ const { claudeHome } = require('./lib/schedulerPaths.cjs');
11
+ const { writeJsonAtomicSync, readJsonOrSync } = require('./lib/atomicFs.cjs');
12
+ const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
13
+ const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
14
+
15
+ // Hard-coded wss:// — same relay URL as webRemote.cjs, no plaintext downgrade.
16
+ const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
17
+ const SEND_TIMEOUT_MS = 15_000;
18
+ const WS_OPEN = 1;
19
+
20
+ /** Default relay auth: the bilko.run relay has no desktop-client auth yet. */
21
+ const defaultRelayAuth = {
22
+ async getBrowserTicket() { throw new Error('relay_client_auth_unavailable'); },
23
+ async getDevicePubKey() { throw new Error('relay_client_auth_unavailable'); },
24
+ };
25
+
26
+ function defaultWsFactory(url) {
27
+ const WebSocket = require('ws');
28
+ return new WebSocket(url, { rejectUnauthorized: true });
29
+ }
30
+
31
+ function defaultConfigPath() {
32
+ return path.join(claudeHome(), 'session-manager', 'remote-peers.json');
33
+ }
34
+
35
+ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultWsFactory, configPath } = {}) {
36
+ const file = configPath || defaultConfigPath();
37
+ /** @type {Map<string, any>} peerId -> runtime state */
38
+ const runtime = new Map();
39
+ /** @type {Map<string, Set<Function>>} */
40
+ const subscribers = new Map();
41
+
42
+ function loadPeers() {
43
+ const data = readJsonOrSync(file, { peers: [] });
44
+ return Array.isArray(data?.peers) ? data.peers : [];
45
+ }
46
+ function savePeers(peers) {
47
+ writeJsonAtomicSync(file, { peers }, { mode: 0o600 });
48
+ }
49
+ function getPeer(peerId) {
50
+ return loadPeers().find((p) => p.peerId === peerId) || null;
51
+ }
52
+ function updatePeer(peerId, patch) {
53
+ const peers = loadPeers();
54
+ const i = peers.findIndex((p) => p.peerId === peerId);
55
+ if (i < 0) return;
56
+ peers[i] = { ...peers[i], ...patch };
57
+ savePeers(peers);
58
+ }
59
+
60
+ function rt(peerId) {
61
+ let s = runtime.get(peerId);
62
+ if (!s) {
63
+ s = {
64
+ status: 'idle', reason: null, sas: null, ws: null, sessionKey: null,
65
+ devicePubKey: null, pending: new Map(), attempt: 0,
66
+ };
67
+ runtime.set(peerId, s);
68
+ }
69
+ return s;
70
+ }
71
+
72
+ function failPending(s, err) {
73
+ for (const p of s.pending.values()) { clearTimeout(p.timer); p.reject(err); }
74
+ s.pending.clear();
75
+ }
76
+
77
+ function fail(peerId, reason) {
78
+ const s = rt(peerId);
79
+ s.status = 'failed';
80
+ s.reason = reason;
81
+ s.sessionKey = null;
82
+ s.sas = null;
83
+ failPending(s, new Error(reason));
84
+ const ws = s.ws;
85
+ s.ws = null;
86
+ try { ws?.close(); } catch { /* already closed */ }
87
+ }
88
+
89
+ function addPeer({ peerId, deviceId, machineName }) {
90
+ if (!peerId || !deviceId) throw new Error('peerId and deviceId required');
91
+ const peers = loadPeers();
92
+ const existing = peers.find((p) => p.peerId === peerId);
93
+ if (existing) return existing;
94
+ const kp = generateE2EKeyPair();
95
+ const peer = {
96
+ peerId,
97
+ deviceId,
98
+ machineName: machineName || '',
99
+ pairedAt: new Date().toISOString(),
100
+ clientPrivateKey: kp.e2ePrivateKey,
101
+ clientPublicKey: kp.e2ePublicKey,
102
+ verifiedDevicePubKey: null,
103
+ };
104
+ peers.push(peer);
105
+ savePeers(peers);
106
+ return peer;
107
+ }
108
+
109
+ function listPeers() {
110
+ // Never expose the private key to callers.
111
+ return loadPeers().map(({ clientPrivateKey, ...rest }) => rest);
112
+ }
113
+
114
+ function getStatus(peerId) {
115
+ const s = rt(peerId);
116
+ return { status: s.status, reason: s.reason, sas: s.sas };
117
+ }
118
+
119
+ function rawSend(ws, msg) {
120
+ ws.send(JSON.stringify(msg));
121
+ }
122
+
123
+ function handleInner(peerId, s, inner) {
124
+ if (!inner || typeof inner.type !== 'string') return;
125
+ if (inner.type.startsWith('resp:')) {
126
+ const p = s.pending.get(inner.id);
127
+ if (!p) return;
128
+ s.pending.delete(inner.id);
129
+ clearTimeout(p.timer);
130
+ p.resolve(inner.payload);
131
+ return;
132
+ }
133
+ if (inner.type.startsWith('event:')) {
134
+ for (const cb of subscribers.get(peerId) || []) {
135
+ try { cb(inner); } catch { /* subscriber errors never break the socket */ }
136
+ }
137
+ }
138
+ }
139
+
140
+ function handleMessage(peerId, s, peer, raw) {
141
+ let msg;
142
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
143
+ if (!msg || typeof msg.type !== 'string') return;
144
+
145
+ if (msg.type === 'ping') {
146
+ if (s.ws && s.ws.readyState === WS_OPEN) rawSend(s.ws, { type: 'pong', id: msg.id, ts: Date.now() });
147
+ return;
148
+ }
149
+ if (msg.type === 'error') {
150
+ const code = msg.code || msg.payload?.code;
151
+ if (code === 'token_revoked') fail(peerId, 'token_revoked');
152
+ return;
153
+ }
154
+ if (msg.type === 'auth:ok') {
155
+ // Relay accepted the ticket: start the E2E handshake.
156
+ rawSend(s.ws, {
157
+ type: 'e2e:hello',
158
+ id: crypto.randomUUID(),
159
+ deviceId: peer.deviceId,
160
+ payload: { pubKey: peer.clientPublicKey },
161
+ ts: Date.now(),
162
+ });
163
+ return;
164
+ }
165
+ if (msg.type === 'e2e:ready') {
166
+ if (s.sessionKey) return;
167
+ try {
168
+ s.sessionKey = deriveSessionKey(peer.clientPrivateKey, s.devicePubKey, peer.deviceId);
169
+ s.sas = deriveSas(peer.clientPrivateKey, s.devicePubKey, peer.deviceId);
170
+ } catch {
171
+ fail(peerId, 'e2e_derivation_failed');
172
+ return;
173
+ }
174
+ s.status = peer.verifiedDevicePubKey && peer.verifiedDevicePubKey === s.devicePubKey
175
+ ? 'authenticated'
176
+ : 'pending_sas';
177
+ return;
178
+ }
179
+ if (msg.type === 'e2e:box') {
180
+ if (!s.sessionKey) return;
181
+ const { nonce, ciphertext } = msg.payload || {};
182
+ if (!nonce || !ciphertext) return;
183
+ const plain = decryptBox(nonce, ciphertext, s.sessionKey);
184
+ if (!plain) return;
185
+ let inner;
186
+ try { inner = JSON.parse(plain); } catch { return; }
187
+ handleInner(peerId, s, inner);
188
+ }
189
+ }
190
+
191
+ async function connect(peerId) {
192
+ const peer = getPeer(peerId);
193
+ if (!peer) throw new Error('unknown_peer');
194
+ const s = rt(peerId);
195
+ const attempt = ++s.attempt;
196
+ try { s.ws?.close(); } catch { /* already closed */ }
197
+ s.ws = null;
198
+ s.sessionKey = null;
199
+ s.sas = null;
200
+ s.reason = null;
201
+ s.status = 'connecting';
202
+
203
+ let ticket;
204
+ let devicePubKey;
205
+ try {
206
+ ticket = await relayAuth.getBrowserTicket(peer);
207
+ devicePubKey = await relayAuth.getDevicePubKey(peer.deviceId);
208
+ } catch (e) {
209
+ fail(peerId, e?.message || 'relay_auth_failed');
210
+ return getStatus(peerId);
211
+ }
212
+ if (attempt !== s.attempt) return getStatus(peerId); // superseded or removed
213
+ if (!ticket || !devicePubKey) {
214
+ fail(peerId, 'relay_auth_failed');
215
+ return getStatus(peerId);
216
+ }
217
+ // TOFU: a changed device key after pinning fails closed, before any socket opens.
218
+ if (peer.verifiedDevicePubKey && peer.verifiedDevicePubKey !== devicePubKey) {
219
+ fail(peerId, 'device_key_changed');
220
+ return getStatus(peerId);
221
+ }
222
+ s.devicePubKey = devicePubKey;
223
+
224
+ let ws;
225
+ try {
226
+ ws = wsFactory(`${RELAY_WSS_URL}?ticket=${encodeURIComponent(ticket)}`);
227
+ } catch {
228
+ fail(peerId, 'ws_create_failed');
229
+ return getStatus(peerId);
230
+ }
231
+ s.ws = ws;
232
+ ws.on('message', (data) => { if (s.ws === ws) handleMessage(peerId, s, peer, data); });
233
+ ws.on('close', () => {
234
+ if (s.ws !== ws) return;
235
+ s.ws = null;
236
+ s.sessionKey = null;
237
+ failPending(s, new Error('connection_closed'));
238
+ if (s.status !== 'failed') { s.status = 'idle'; s.sas = null; }
239
+ });
240
+ ws.on('error', () => { if (s.ws === ws) fail(peerId, 'ws_error'); });
241
+ return getStatus(peerId);
242
+ }
243
+
244
+ function confirmSas(peerId) {
245
+ const s = rt(peerId);
246
+ if (s.status !== 'pending_sas' || !s.devicePubKey) return { ok: false, reason: 'not_pending_sas' };
247
+ updatePeer(peerId, { verifiedDevicePubKey: s.devicePubKey });
248
+ s.status = 'authenticated';
249
+ return { ok: true };
250
+ }
251
+
252
+ function send(peerId, type, payload) {
253
+ if (typeof type !== 'string' || !ALLOWED_COMMANDS.has(type)) {
254
+ return Promise.reject(new Error('command_not_allowed'));
255
+ }
256
+ const s = rt(peerId);
257
+ if (!s.ws || s.ws.readyState !== WS_OPEN || !s.sessionKey) {
258
+ return Promise.reject(new Error('not_connected'));
259
+ }
260
+ const id = crypto.randomUUID();
261
+ const peer = getPeer(peerId);
262
+ if (!peer) return Promise.reject(new Error('unknown_peer'));
263
+ const inner = { type, id, payload: payload ?? {}, ts: Date.now() };
264
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), s.sessionKey);
265
+ return new Promise((resolve, reject) => {
266
+ const timer = setTimeout(() => {
267
+ s.pending.delete(id);
268
+ reject(new Error('timeout'));
269
+ }, SEND_TIMEOUT_MS);
270
+ s.pending.set(id, { resolve, reject, timer });
271
+ try {
272
+ rawSend(s.ws, { type: 'e2e:box', id, deviceId: peer.deviceId, payload: { nonce, ciphertext }, ts: Date.now() });
273
+ } catch (e) {
274
+ s.pending.delete(id);
275
+ clearTimeout(timer);
276
+ reject(e);
277
+ }
278
+ });
279
+ }
280
+
281
+ function onEvent(peerId, cb) {
282
+ let set = subscribers.get(peerId);
283
+ if (!set) { set = new Set(); subscribers.set(peerId, set); }
284
+ set.add(cb);
285
+ return () => { set.delete(cb); };
286
+ }
287
+
288
+ function removePeer(peerId) {
289
+ const s = runtime.get(peerId);
290
+ if (s) {
291
+ s.attempt++;
292
+ failPending(s, new Error('peer_removed'));
293
+ try { s.ws?.close(); } catch { /* already closed */ }
294
+ runtime.delete(peerId);
295
+ }
296
+ subscribers.delete(peerId);
297
+ savePeers(loadPeers().filter((p) => p.peerId !== peerId));
298
+ }
299
+
300
+ return { addPeer, listPeers, connect, confirmSas, getStatus, send, onEvent, removePeer };
301
+ }
302
+
303
+ const remoteClient = createRemoteClient();
304
+
305
+ module.exports = { createRemoteClient, remoteClient, SEND_TIMEOUT_MS };
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * remoteClientIpc.cjs — IPC surface for the desktop-to-desktop remote client
5
+ * (remoteClient.cjs). Every payload is Zod-validated here, in main. Status
6
+ * changes and peer events are pushed to the renderer on `remoteClient:status`
7
+ * and `remoteClient:event`.
8
+ */
9
+
10
+ const { ipcMain } = require('electron');
11
+ const { z } = require('zod');
12
+ const { remoteClient } = require('./remoteClient.cjs');
13
+ const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
14
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
15
+
16
+ const STATUS_POLL_MS = 1000;
17
+
18
+ const peerId = z.string().min(1).max(128);
19
+ const schemas = {
20
+ peer: z.object({ peerId }),
21
+ send: z.object({
22
+ peerId,
23
+ type: z.string().min(1).max(128).refine((t) => ALLOWED_COMMANDS.has(t), { message: 'command_not_allowed' }),
24
+ payload: z.unknown().optional(),
25
+ }),
26
+ };
27
+
28
+ function validated(schema, handler) {
29
+ return (_event, payload) => handler(schema.parse(payload));
30
+ }
31
+
32
+ function registerRemoteClientHandlers(getWindow, client = remoteClient) {
33
+ const unsubs = new Map(); // peerId -> unsubscribe for event forwarding
34
+ const lastStatus = new Map(); // peerId -> serialized status
35
+
36
+ const toWindow = (channel, payload) => sendIfAlive(getWindow(), channel, payload);
37
+
38
+ function publishStatus(id) {
39
+ const status = client.getStatus(id);
40
+ const key = JSON.stringify(status);
41
+ if (lastStatus.get(id) === key) return status;
42
+ lastStatus.set(id, key);
43
+ toWindow('remoteClient:status', { peerId: id, ...status });
44
+ return status;
45
+ }
46
+
47
+ function ensureForwarding(id) {
48
+ if (unsubs.has(id)) return;
49
+ unsubs.set(id, client.onEvent(id, (ev) => {
50
+ toWindow('remoteClient:event', { peerId: id, type: ev.type, payload: ev.payload });
51
+ }));
52
+ }
53
+
54
+ // remoteClient has no status callback; the socket moves status on its own
55
+ // (pending_sas, failed, idle), so poll for changes on known peers.
56
+ const timer = setInterval(() => {
57
+ for (const p of client.listPeers()) publishStatus(p.peerId);
58
+ }, STATUS_POLL_MS);
59
+ if (typeof timer.unref === 'function') timer.unref();
60
+
61
+ ipcMain.handle('remoteClient:list', () => client.listPeers().map((p) => {
62
+ ensureForwarding(p.peerId);
63
+ return { ...p, ...client.getStatus(p.peerId) };
64
+ }));
65
+
66
+ ipcMain.handle('remoteClient:connect', validated(schemas.peer, async ({ peerId: id }) => {
67
+ ensureForwarding(id);
68
+ const connecting = client.connect(id);
69
+ publishStatus(id);
70
+ const status = await connecting;
71
+ publishStatus(id);
72
+ return status;
73
+ }));
74
+
75
+ ipcMain.handle('remoteClient:confirm-sas', validated(schemas.peer, ({ peerId: id }) => {
76
+ const res = client.confirmSas(id);
77
+ publishStatus(id);
78
+ return res;
79
+ }));
80
+
81
+ ipcMain.handle('remoteClient:remove', validated(schemas.peer, ({ peerId: id }) => {
82
+ client.removePeer(id);
83
+ const off = unsubs.get(id);
84
+ if (off) off();
85
+ unsubs.delete(id);
86
+ lastStatus.delete(id);
87
+ toWindow('remoteClient:status', { peerId: id, status: 'removed', reason: null, sas: null });
88
+ return { ok: true };
89
+ }));
90
+
91
+ ipcMain.handle('remoteClient:send', validated(schemas.send, ({ peerId: id, type, payload }) =>
92
+ client.send(id, type, payload)));
93
+
94
+ return () => {
95
+ clearInterval(timer);
96
+ for (const off of unsubs.values()) off();
97
+ unsubs.clear();
98
+ };
99
+ }
100
+
101
+ module.exports = { registerRemoteClientHandlers, schemas };
@@ -68,107 +68,8 @@ const MSG_MAX_BYTES = 256 * 1024;
68
68
  const { ALLOWED_COMMANDS, MUTATE_COMMANDS, SAS_GATED_READS } = require('./ipcSchemas.cjs');
69
69
 
70
70
  // ─── 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
- }
71
+ // Implemented in ./lib/remoteE2eCrypto.cjs (shared with the desktop client role).
72
+ const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
172
73
 
173
74
  // ─── Module state ────────────────────────────────────────────────────────────
174
75
 
@@ -317,7 +218,23 @@ async function getDeviceTicket(deviceToken) {
317
218
 
318
219
  // ─── E2E state helpers ────────────────────────────────────────────────────────
319
220
 
221
+ // tabId → unsubscribe fn for the PtyManager output listener streaming to the peer.
222
+ const _remotePtyStreams = new Map();
223
+
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
+ }
230
+ }
231
+
232
+ function stopAllRemotePtyStreams() {
233
+ for (const tabId of [..._remotePtyStreams.keys()]) stopRemotePtyStream(tabId);
234
+ }
235
+
320
236
  function resetE2e(state = 'idle') {
237
+ stopAllRemotePtyStreams();
321
238
  _e2e = makeState(state);
322
239
  _e2ePeerPubKey = null;
323
240
  _e2eDeviceId = null;
@@ -407,6 +324,7 @@ function scheduleReconnect() {
407
324
  async function disconnect() {
408
325
  cancelReconnect();
409
326
  stopHeartbeat();
327
+ stopAllRemotePtyStreams();
410
328
  resetE2e();
411
329
  if (_ws) {
412
330
  const ws = _ws;
@@ -526,6 +444,7 @@ async function connect() {
526
444
  }
527
445
 
528
446
  async function handleTokenRevoked(deviceId) {
447
+ stopAllRemotePtyStreams();
529
448
  logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
530
449
  const cfg = await loadConfig();
531
450
  const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
@@ -712,6 +631,41 @@ function stopAllSessionWatches() {
712
631
  if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
713
632
  }
714
633
 
634
+ /**
635
+ * Pure builder for the phone's session list: one project entry per tab, then
636
+ * each tab's ACTIVE Epics (from active-index.json via readIndex). Never throws.
637
+ */
638
+ function buildRemoteSessionList(tabs, readIndex) {
639
+ const out = [];
640
+ const seenEpics = new Set();
641
+ for (const tab of Array.isArray(tabs) ? tabs : []) {
642
+ if (!tab) continue;
643
+ const project = tab.label || path.basename(tab.cwd || '');
644
+ out.push({ kind: 'project', tabId: tab.sessionId, cwd: tab.cwd, title: tab.label || tab.cwd, project });
645
+ let sessions = null;
646
+ try { sessions = readIndex(tab.cwd)?.sessions; } catch { sessions = null; }
647
+ if (!sessions || typeof sessions !== 'object') continue;
648
+ for (const epic of Object.values(sessions)) {
649
+ if (!epic || epic.status !== 'active') continue;
650
+ if (typeof epic.claudeSessionId !== 'string' || !epic.claudeSessionId) continue;
651
+ if (seenEpics.has(epic.id)) continue;
652
+ seenEpics.add(epic.id);
653
+ const firstLine = typeof epic.goalText === 'string' ? epic.goalText.split('\n')[0].trim().slice(0, 80).trim() : '';
654
+ out.push({
655
+ kind: 'epic',
656
+ tabId: epic.claudeSessionId,
657
+ epicId: epic.id,
658
+ cwd: epic.worktree?.dir || epic.cwd,
659
+ title: firstLine || epic.id,
660
+ project,
661
+ tag: epic.tag,
662
+ agentType: epic.agentType,
663
+ });
664
+ }
665
+ }
666
+ return out;
667
+ }
668
+
715
669
  /** Push the current session list (reuses sessionsStore — the canonical source). */
716
670
  async function pushSessionList() {
717
671
  try {
@@ -727,13 +681,12 @@ async function pushSessionList() {
727
681
  if (_e2e.state !== 'authenticated') return;
728
682
  const sessionsStore = require('./sessionsStore.cjs');
729
683
  const data = await sessionsStore.load();
730
- // Normalize persisted tabs → SessionMeta. tabId === sessionId so it
684
+ // Tabs + their ACTIVE Epics → SessionMeta. tabId === claude sessionId so it
731
685
  // 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,
686
+ const { readActiveIndex } = require('./lib/epicMint.cjs');
687
+ const sessions = buildRemoteSessionList(data?.tabs ?? [], readActiveIndex).map((s) => ({
688
+ ...s,
689
+ state: _sessionWatchers.get(s.tabId)?.state ?? null,
737
690
  }));
738
691
  const payload = { sessions, activeTabId: data?.activeTabId ?? null };
739
692
  const json = JSON.stringify(payload);
@@ -1072,7 +1025,25 @@ function getDispatchMap() {
1072
1025
  // remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
1073
1026
  // ever uses it. Remote callers have no legitimate need for it.
1074
1027
  const { startupCommand: _ignored, ...safePayload } = parsed;
1075
- return ptyManager.spawn(safePayload);
1028
+ const result = await ptyManager.spawn(safePayload);
1029
+ // Stream output to the peer — replaces any prior subscription for this tab.
1030
+ // Chunk contents are sensitive: never logged.
1031
+ const tabId = safePayload.tabId;
1032
+ stopRemotePtyStream(tabId);
1033
+ const unsub = ptyManager.addOutputListener(
1034
+ tabId,
1035
+ (_id, data) => {
1036
+ if (_e2e.state !== 'authenticated' || !loadConfigSync().remoteEnabled) return;
1037
+ pushEvent('event:pty:data', { tabId, data });
1038
+ },
1039
+ (_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 });
1043
+ },
1044
+ );
1045
+ _remotePtyStreams.set(tabId, unsub);
1046
+ return result;
1076
1047
  },
1077
1048
 
1078
1049
  'cmd:pty:write': async (payload) => {
@@ -1088,6 +1059,7 @@ function getDispatchMap() {
1088
1059
 
1089
1060
  'cmd:pty:kill': async (payload) => {
1090
1061
  const parsed = schemas.ptyTabId.parse(payload);
1062
+ stopRemotePtyStream(parsed.tabId);
1091
1063
  ptyManager.kill(parsed.tabId);
1092
1064
  return { ok: true };
1093
1065
  },
@@ -1190,6 +1162,7 @@ async function pair(otp) {
1190
1162
  }
1191
1163
 
1192
1164
  async function revokeDevice(deviceId) {
1165
+ stopAllRemotePtyStreams();
1193
1166
  invalidateConfigCache();
1194
1167
  const cfg = await loadConfig();
1195
1168
  const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
@@ -1214,6 +1187,7 @@ async function revokeDevice(deviceId) {
1214
1187
  * This is the local-side "kill everything" action (ADR §4.1).
1215
1188
  */
1216
1189
  async function revokeAllDevices() {
1190
+ stopAllRemotePtyStreams();
1217
1191
  const cfg = await loadConfig();
1218
1192
  const revokedCount = (cfg.devices || []).length;
1219
1193
  await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
@@ -1361,4 +1335,12 @@ module.exports = {
1361
1335
  resetE2e,
1362
1336
  getE2eState: () => _e2e,
1363
1337
  },
1338
+ __test: {
1339
+ getDispatchMap,
1340
+ buildRemoteSessionList,
1341
+ setWs: (ws) => { _ws = ws; },
1342
+ setE2eAuthenticated: (key) => { _e2e = makeState('authenticated', key, null); },
1343
+ revokeAllDevices,
1344
+ streamCount: () => _remotePtyStreams.size,
1345
+ },
1364
1346
  };