claude-code-session-manager 0.20.0 → 0.21.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 (33) hide show
  1. package/dist/assets/{TiptapBody-COZHDXvn.js → TiptapBody-DtTU-6tZ.js} +1 -1
  2. package/dist/assets/{cssMode-BGlgF50F.js → cssMode-FA1uC6B_.js} +1 -1
  3. package/dist/assets/{freemarker2-CwlJczaA.js → freemarker2-DYaF01LX.js} +1 -1
  4. package/dist/assets/{handlebars-C7ChleGP.js → handlebars-Da7b36Lf.js} +1 -1
  5. package/dist/assets/{html-C0XyedAq.js → html-CEBCag3L.js} +1 -1
  6. package/dist/assets/{htmlMode-DTJsOfuO.js → htmlMode-1_WYf3Br.js} +1 -1
  7. package/dist/assets/{index-C4joLNKY.js → index-BzEG1CLO.js} +852 -835
  8. package/dist/assets/{index-6poesY86.css → index-oGyPFfYZ.css} +1 -1
  9. package/dist/assets/{javascript-CPRB5GUm.js → javascript-DKWzZR-I.js} +1 -1
  10. package/dist/assets/{jsonMode-DKBN0s8-.js → jsonMode-BbyLfnM7.js} +1 -1
  11. package/dist/assets/{liquid-CJmNIgnK.js → liquid-BbbdIZ5H.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-CIIba3v8.js → lspLanguageFeatures-COiniR1D.js} +1 -1
  13. package/dist/assets/{mdx-BOiNk1a1.js → mdx-BKuETQUL.js} +1 -1
  14. package/dist/assets/{python-5AV3HPYJ.js → python-BiJja-9i.js} +1 -1
  15. package/dist/assets/{razor-6iMJA6dH.js → razor-DnGCqquD.js} +1 -1
  16. package/dist/assets/{tsMode-WJISqg3-.js → tsMode-CtpaN11s.js} +1 -1
  17. package/dist/assets/{typescript-CnA0yZf9.js → typescript-Cx21wAbo.js} +1 -1
  18. package/dist/assets/{xml-BLkNwYO2.js → xml-BgafHH5c.js} +1 -1
  19. package/dist/assets/{yaml-D6anZ1nO.js → yaml-BtU-Gr1g.js} +1 -1
  20. package/dist/index.html +2 -2
  21. package/package.json +3 -1
  22. package/src/main/historyAggregator.cjs +15 -9
  23. package/src/main/index.cjs +7 -2
  24. package/src/main/ipcSchemas.cjs +58 -0
  25. package/src/main/kg.cjs +128 -32
  26. package/src/main/lib/reaperHelpers.cjs +67 -0
  27. package/src/main/lib/schedulerBatch.cjs +212 -0
  28. package/src/main/scheduler.cjs +173 -125
  29. package/src/main/transcripts.cjs +1 -0
  30. package/src/main/webRemote.cjs +1228 -0
  31. package/src/preload/api.d.ts +50 -9
  32. package/src/preload/index.cjs +34 -5
  33. package/src/main/projectSkills.cjs +0 -124
@@ -0,0 +1,1228 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * webRemote.cjs — Local WebSocket agent for the web remote control channel.
5
+ *
6
+ * Security invariants (ARCHITECTURE.md §0, §6):
7
+ * - Outbound-only: opens a WS TO the relay, never listens.
8
+ * - OFF by default: remoteEnabled must be explicitly true in web-remote.json.
9
+ * - Kill switch: synchronous — drops socket + refuses all commands instantly.
10
+ * - Strict allowlist: 15 enumerated command types; unknown → silent drop.
11
+ * - Zod validation: every payload parsed before any dispatch.
12
+ * - Path safety: cwd/path fields go through validatePath (home-dir boundary).
13
+ * - Token at rest: web-remote.json written at 0600 via writeTextAtomic.
14
+ * - TLS mandatory: relay URL hard-coded wss://; no downgrade path.
15
+ * - Audit log: every dispatched command logged locally, no secret values.
16
+ * - E2E encryption: P-256 ECDH + AES-256-GCM; relay sees only ciphertext.
17
+ */
18
+
19
+ const { ipcMain, app } = require('electron');
20
+ const WebSocket = require('ws');
21
+ const https = require('node:https');
22
+ const crypto = require('node:crypto');
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+ const fsp = require('node:fs/promises');
26
+ const fs = require('node:fs');
27
+ const { writeTextAtomic, validatePath } = require('./config.cjs');
28
+ const logs = require('./logs.cjs');
29
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
30
+ const { schemas } = require('./ipcSchemas.cjs');
31
+
32
+ // ─── Constants ───────────────────────────────────────────────────────────────
33
+
34
+ // Hard-coded wss:// — no configuration allows plaintext downgrade (ADR §5.1).
35
+ // v2: relay is same-origin on bilko.run (ARCHITECTURE-V2-MOBILE.md §1). REST under
36
+ // /api/sm-relay; WS upgrade at /projects/session-manager/relay (covered by host CSP
37
+ // connect-src 'self').
38
+ const RELAY_HTTPS_BASE = 'https://bilko.run';
39
+ const RELAY_API_BASE = `${RELAY_HTTPS_BASE}/api/sm-relay`;
40
+ const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
41
+
42
+ const CONFIG_PATH = path.join(
43
+ os.homedir(), '.claude', 'session-manager', 'web-remote.json'
44
+ );
45
+ const AUDIT_LOG_DIR = path.join(
46
+ os.homedir(), '.claude', 'session-manager', 'logs'
47
+ );
48
+
49
+ // Reconnect backoff: init 1s, x2, cap 60s, ±20% jitter (ADR §2.4).
50
+ const BACKOFF_INIT_MS = 1_000;
51
+ const BACKOFF_MAX_MS = 60_000;
52
+ const BACKOFF_MULT = 2;
53
+
54
+ // Heartbeat (ADR §2.3)
55
+ const PING_INTERVAL_MS = 30_000;
56
+ const PONG_TIMEOUT_MS = 10_000;
57
+ const MAX_MISSED_PONGS = 3;
58
+
59
+ // Config re-read TTL: 1s so the kill-switch propagates within one second.
60
+ const CONFIG_CACHE_TTL_MS = 1_000;
61
+
62
+ // Max message size: 256 KiB (matches PRD_WRITE_MAX_BYTES, ADR §2.5).
63
+ const MSG_MAX_BYTES = 256 * 1024;
64
+
65
+ // ─── Command allowlist ───────────────────────────────────────────────────────
66
+
67
+ // Single source of truth lives in ipcSchemas.cjs — imported here so the test
68
+ // can verify the same Set without depending on Electron-linked modules.
69
+ const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
70
+
71
+ // ─── E2E encryption helpers (P-256 ECDH + AES-256-GCM, ADR §5.2) ────────────
72
+
73
+ /**
74
+ * Generate a P-256 ECDH keypair. Public key is SPKI DER base64url; private key
75
+ * is PKCS8 DER base64url. Both are stored in web-remote.json at 0600.
76
+ */
77
+ function generateE2EKeyPair() {
78
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', {
79
+ namedCurve: 'P-256',
80
+ publicKeyEncoding: { type: 'spki', format: 'der' },
81
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
82
+ });
83
+ return {
84
+ e2ePrivateKey: privateKey.toString('base64url'),
85
+ e2ePublicKey: publicKey.toString('base64url'),
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Derive an AES-256-GCM session key from two P-256 public keys via ECDH + HKDF.
91
+ * @param myPrivateKeyB64 Agent's PKCS8 private key, base64url DER
92
+ * @param peerPublicKeyB64 Browser's SPKI public key, base64url DER
93
+ * @param deviceId Used as HKDF salt for domain separation
94
+ * @returns 32-byte Buffer (AES-256-GCM key)
95
+ */
96
+ function deriveSessionKey(myPrivateKeyB64, peerPublicKeyB64, deviceId) {
97
+ const myPrivKey = crypto.createPrivateKey({
98
+ key: Buffer.from(myPrivateKeyB64, 'base64url'),
99
+ format: 'der',
100
+ type: 'pkcs8',
101
+ });
102
+ const peerPubKey = crypto.createPublicKey({
103
+ key: Buffer.from(peerPublicKeyB64, 'base64url'),
104
+ format: 'der',
105
+ type: 'spki',
106
+ });
107
+ const sharedSecret = crypto.diffieHellman({ privateKey: myPrivKey, publicKey: peerPubKey });
108
+ // HKDF: salt = deviceId bytes for domain separation, info = fixed protocol label.
109
+ const salt = Buffer.from(deviceId, 'utf8');
110
+ const info = Buffer.from('sm-e2e-v1', 'utf8');
111
+ return Buffer.from(crypto.hkdfSync('sha256', sharedSecret, salt, info, 32));
112
+ }
113
+
114
+ /**
115
+ * Encrypt a plaintext JSON string into an AES-256-GCM box.
116
+ * @returns { nonce: base64url, ciphertext: base64url } — the nonce is 12 random bytes
117
+ */
118
+ function encryptBox(plaintext, sessionKey) {
119
+ const nonce = crypto.randomBytes(12);
120
+ const cipher = crypto.createCipheriv('aes-256-gcm', sessionKey, nonce);
121
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
122
+ const tag = cipher.getAuthTag(); // 16-byte GCM authentication tag
123
+ return {
124
+ nonce: nonce.toString('base64url'),
125
+ // Append the GCM tag to the ciphertext so decryptBox can locate it.
126
+ ciphertext: Buffer.concat([encrypted, tag]).toString('base64url'),
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Decrypt an AES-256-GCM box produced by encryptBox (or the browser's equivalent).
132
+ * @returns Decrypted UTF-8 string, or null if authentication fails.
133
+ */
134
+ function decryptBox(nonceB64, ciphertextB64, sessionKey) {
135
+ try {
136
+ const nonce = Buffer.from(nonceB64, 'base64url');
137
+ const data = Buffer.from(ciphertextB64, 'base64url');
138
+ if (data.length < 16) return null; // too short to contain tag
139
+ const tag = data.subarray(data.length - 16);
140
+ const ciphertext = data.subarray(0, data.length - 16);
141
+ const decipher = crypto.createDecipheriv('aes-256-gcm', sessionKey, nonce);
142
+ decipher.setAuthTag(tag);
143
+ const plain = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
144
+ return plain;
145
+ } catch {
146
+ return null; // authentication failed — drop the message
147
+ }
148
+ }
149
+
150
+ // ─── Module state ────────────────────────────────────────────────────────────
151
+
152
+ let _window = null;
153
+ let _ws = null;
154
+ let _reconnectTimer = null;
155
+ let _backoffMs = BACKOFF_INIT_MS;
156
+ let _pingTimer = null;
157
+ let _pongTimer = null;
158
+ let _missedPongs = 0;
159
+ let _configCache = null;
160
+ let _configCacheAt = 0;
161
+ let _destroyed = false; // set at app shutdown to stop reconnect loops
162
+
163
+ // E2E session state — reset on each new WS connection.
164
+ let _e2eSessionKey = null; // Buffer | null
165
+
166
+ // ─── Config helpers ───────────────────────────────────────────────────────────
167
+
168
+ function defaultConfig() {
169
+ return { remoteEnabled: false, devices: [] };
170
+ }
171
+
172
+ function loadConfigSync() {
173
+ const now = Date.now();
174
+ if (_configCache && now - _configCacheAt < CONFIG_CACHE_TTL_MS) {
175
+ return _configCache;
176
+ }
177
+ try {
178
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf8');
179
+ _configCache = { ...defaultConfig(), ...JSON.parse(raw) };
180
+ } catch {
181
+ _configCache = defaultConfig();
182
+ }
183
+ _configCacheAt = now;
184
+ return _configCache;
185
+ }
186
+
187
+ async function loadConfig() {
188
+ const now = Date.now();
189
+ if (_configCache && now - _configCacheAt < CONFIG_CACHE_TTL_MS) {
190
+ return _configCache;
191
+ }
192
+ try {
193
+ const raw = await fsp.readFile(CONFIG_PATH, 'utf8');
194
+ _configCache = { ...defaultConfig(), ...JSON.parse(raw) };
195
+ } catch {
196
+ _configCache = defaultConfig();
197
+ }
198
+ _configCacheAt = now;
199
+ return _configCache;
200
+ }
201
+
202
+ function invalidateConfigCache() {
203
+ _configCacheAt = 0;
204
+ _configCache = null;
205
+ }
206
+
207
+ // Writes the config atomically at mode 0600 (ADR §4 — equivalent to ~/.ssh/id_rsa).
208
+ async function saveConfig(data) {
209
+ const pretty = JSON.stringify(data, null, 2) + '\n';
210
+ await writeTextAtomic(CONFIG_PATH, pretty, { mode: 0o600 });
211
+ invalidateConfigCache();
212
+ }
213
+
214
+ // ─── Audit log ────────────────────────────────────────────────────────────────
215
+
216
+ // Format: <ISO> <type> deviceId=<id> msgId=<uuid> result=ok|error:<code>
217
+ // NEVER log token values or payload content.
218
+ async function auditLog(ts, type, deviceId, msgId, result) {
219
+ try {
220
+ const ymd = ts.slice(0, 10);
221
+ const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
222
+ const line = `${ts} ${type} deviceId=${deviceId || '-'} msgId=${msgId || '-'} result=${result}\n`;
223
+ const handle = await fsp.open(logPath, 'a', 0o600);
224
+ try {
225
+ await handle.write(line);
226
+ } finally {
227
+ await handle.close();
228
+ }
229
+ } catch (e) {
230
+ logs.writeLine({
231
+ scope: 'webRemote', level: 'warn',
232
+ message: 'audit log write failed', meta: { error: e?.message },
233
+ });
234
+ }
235
+ }
236
+
237
+ // ─── HTTPS helpers ────────────────────────────────────────────────────────────
238
+
239
+ function httpsPost(url, body, headers = {}) {
240
+ return new Promise((resolve, reject) => {
241
+ const parsed = new URL(url);
242
+ if (parsed.protocol !== 'https:') {
243
+ reject(new Error('Only https:// allowed for relay API calls'));
244
+ return;
245
+ }
246
+ const req = https.request({
247
+ hostname: parsed.hostname,
248
+ port: parsed.port || 443,
249
+ path: parsed.pathname + (parsed.search || ''),
250
+ method: 'POST',
251
+ headers: {
252
+ 'Content-Type': 'application/json',
253
+ 'Content-Length': Buffer.byteLength(body),
254
+ ...headers,
255
+ },
256
+ rejectUnauthorized: true, // verify relay TLS cert
257
+ }, (res) => {
258
+ let data = '';
259
+ res.on('data', (c) => { data += c; });
260
+ res.on('end', () => {
261
+ if (res.statusCode >= 200 && res.statusCode < 300) {
262
+ try { resolve(JSON.parse(data)); } catch { resolve({}); }
263
+ } else {
264
+ let errMsg = `HTTP ${res.statusCode}`;
265
+ try { errMsg = JSON.parse(data).error || errMsg; } catch { /* */ }
266
+ reject(new Error(errMsg));
267
+ }
268
+ });
269
+ });
270
+ req.on('error', reject);
271
+ req.setTimeout(15_000, () => { req.destroy(new Error('timeout')); });
272
+ req.write(body);
273
+ req.end();
274
+ });
275
+ }
276
+
277
+ // POST /api/device-ticket to exchange device-token for a one-time WS ticket.
278
+ async function getDeviceTicket(deviceToken) {
279
+ const result = await httpsPost(
280
+ `${RELAY_API_BASE}/device-ticket`,
281
+ '{}',
282
+ { Authorization: `Bearer ${deviceToken}` }
283
+ );
284
+ if (!result.ticket) throw new Error('relay returned no ticket');
285
+ return result.ticket;
286
+ }
287
+
288
+ // ─── WebSocket lifecycle ──────────────────────────────────────────────────────
289
+
290
+ function broadcastStatus() {
291
+ if (!_window || _window.isDestroyed()) return;
292
+ const cfg = loadConfigSync();
293
+ const connected = _ws !== null && _ws.readyState === WebSocket.OPEN;
294
+ sendIfAlive(_window, 'webRemote:status', {
295
+ enabled: cfg.remoteEnabled,
296
+ connected,
297
+ e2eActive: connected && _e2eSessionKey !== null,
298
+ devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
299
+ deviceId, deviceName, issuedAt, lastConnectedAt,
300
+ })),
301
+ });
302
+ }
303
+
304
+ function stopHeartbeat() {
305
+ if (_pingTimer) { clearInterval(_pingTimer); _pingTimer = null; }
306
+ if (_pongTimer) { clearTimeout(_pongTimer); _pongTimer = null; }
307
+ }
308
+
309
+ function startHeartbeat() {
310
+ stopHeartbeat();
311
+ _pingTimer = setInterval(() => {
312
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) { stopHeartbeat(); return; }
313
+ const pingId = crypto.randomUUID();
314
+ _ws.send(JSON.stringify({ type: 'ping', id: pingId, ts: Date.now() }));
315
+ _pongTimer = setTimeout(() => {
316
+ _missedPongs++;
317
+ logs.writeLine({
318
+ scope: 'webRemote', level: 'warn',
319
+ message: 'missed pong', meta: { missed: _missedPongs },
320
+ });
321
+ if (_missedPongs >= MAX_MISSED_PONGS) {
322
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'closing after missed pongs' });
323
+ _ws?.terminate();
324
+ }
325
+ }, PONG_TIMEOUT_MS);
326
+ }, PING_INTERVAL_MS);
327
+ }
328
+
329
+ function cancelReconnect() {
330
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
331
+ }
332
+
333
+ // Full jitter: delay = random(0, min(cap, base * mult^n))
334
+ function nextBackoffMs() {
335
+ const raw = Math.min(_backoffMs, BACKOFF_MAX_MS);
336
+ const jitter = 0.8 + Math.random() * 0.4; // ±20%
337
+ const next = Math.floor(raw * jitter);
338
+ _backoffMs = Math.min(_backoffMs * BACKOFF_MULT, BACKOFF_MAX_MS);
339
+ return next;
340
+ }
341
+
342
+ function scheduleReconnect() {
343
+ if (_destroyed) return;
344
+ cancelReconnect();
345
+ const delay = nextBackoffMs();
346
+ logs.writeLine({
347
+ scope: 'webRemote', level: 'info',
348
+ message: 'reconnect scheduled', meta: { delayMs: delay },
349
+ });
350
+ _reconnectTimer = setTimeout(() => {
351
+ _reconnectTimer = null;
352
+ connect().catch((e) => {
353
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'connect failed', meta: { error: e?.message } });
354
+ });
355
+ }, delay);
356
+ }
357
+
358
+ async function disconnect() {
359
+ cancelReconnect();
360
+ stopHeartbeat();
361
+ _e2eSessionKey = null;
362
+ if (_ws) {
363
+ const ws = _ws;
364
+ _ws = null;
365
+ try { ws.terminate(); } catch { /* already closed */ }
366
+ }
367
+ broadcastStatus();
368
+ }
369
+
370
+ async function connect() {
371
+ if (_destroyed) return;
372
+
373
+ const cfg = await loadConfig();
374
+ if (!cfg.remoteEnabled) return;
375
+
376
+ const devices = cfg.devices || [];
377
+ const device = devices.find((d) => d.deviceToken);
378
+ if (!device) {
379
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: 'no paired device; not connecting' });
380
+ return;
381
+ }
382
+
383
+ // Step 1: get a single-use WS ticket via the device token.
384
+ let ticket;
385
+ try {
386
+ ticket = await getDeviceTicket(device.deviceToken);
387
+ } catch (e) {
388
+ logs.writeLine({
389
+ scope: 'webRemote', level: 'warn',
390
+ message: 'device ticket request failed', meta: { error: e?.message },
391
+ });
392
+ scheduleReconnect();
393
+ return;
394
+ }
395
+
396
+ // Step 2: open WSS connection with the ticket.
397
+ let ws;
398
+ try {
399
+ ws = new WebSocket(`${RELAY_WSS_URL}?ticket=${encodeURIComponent(ticket)}`, {
400
+ rejectUnauthorized: true, // verify relay TLS cert
401
+ });
402
+ } catch (e) {
403
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ws create failed', meta: { error: e?.message } });
404
+ scheduleReconnect();
405
+ return;
406
+ }
407
+
408
+ _ws = ws;
409
+ _missedPongs = 0;
410
+ _e2eSessionKey = null; // reset session key on new connection
411
+
412
+ ws.on('open', () => {
413
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: 'connected to relay' });
414
+ _backoffMs = BACKOFF_INIT_MS;
415
+ _missedPongs = 0;
416
+ startHeartbeat();
417
+ // Update lastConnectedAt without exposing token
418
+ loadConfig().then(async (c) => {
419
+ const devs = (c.devices || []).map((d) =>
420
+ d.deviceId === device.deviceId
421
+ ? { ...d, lastConnectedAt: new Date().toISOString() }
422
+ : d
423
+ );
424
+ await saveConfig({ ...c, devices: devs });
425
+ broadcastStatus();
426
+ }).catch(() => {});
427
+ broadcastStatus();
428
+ // v2: begin pushing the live session list once connected.
429
+ startSessionListPush();
430
+ });
431
+
432
+ ws.on('message', (raw) => {
433
+ if (raw.length > MSG_MAX_BYTES) {
434
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'oversized message dropped' });
435
+ return;
436
+ }
437
+ handleMessage(raw.toString(), device).catch((e) => {
438
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'handleMessage error', meta: { error: e?.message } });
439
+ });
440
+ });
441
+
442
+ ws.on('close', (code) => {
443
+ stopHeartbeat();
444
+ stopAllSessionWatches();
445
+ _e2eSessionKey = null;
446
+ if (_ws === ws) _ws = null;
447
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: 'ws closed', meta: { code } });
448
+ broadcastStatus();
449
+
450
+ if (code === 4001) {
451
+ // Token revoked by relay — stop reconnecting, clear token (ADR §4.1).
452
+ handleTokenRevoked(device.deviceId).catch(() => {});
453
+ return;
454
+ }
455
+
456
+ if (!_destroyed) scheduleReconnect();
457
+ });
458
+
459
+ ws.on('error', (e) => {
460
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'ws error', meta: { error: e?.message } });
461
+ });
462
+ }
463
+
464
+ async function handleTokenRevoked(deviceId) {
465
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'token revoked', meta: { deviceId } });
466
+ const cfg = await loadConfig();
467
+ const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
468
+ await saveConfig({ ...cfg, remoteEnabled: false, devices });
469
+ broadcastStatus();
470
+ sendIfAlive(_window, 'webRemote:token-revoked', { deviceId });
471
+ }
472
+
473
+ // ─── v2 mobile: session live state + summary push ────────────────────────────
474
+ //
475
+ // For each subscribed tab the agent tails its transcript JSONL (reusing the
476
+ // canonical classifyLine + transcriptPath from transcripts.cjs — single source of
477
+ // truth), derives a coarse state, and pushes event:session:state on change.
478
+ // The last completed assistant turn drives the Haiku summary (see maybeSummarize).
479
+
480
+ const SESSION_POLL_MS = 1500;
481
+ const SESSION_LIST_PUSH_MS = 5000;
482
+ const SESSION_INIT_TAIL_BYTES = 512 * 1024; // bound the initial read
483
+
484
+ const _sessionWatchers = new Map(); // tabId → watcher
485
+ let _sessionListTimer = null;
486
+
487
+ /** Push an unsolicited event to the browser(s). Encrypts when an E2E key is active. */
488
+ function pushEvent(type, payload) {
489
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
490
+ const inner = { type, id: crypto.randomUUID(), payload, ts: Date.now() };
491
+ try {
492
+ if (_e2eSessionKey) {
493
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2eSessionKey);
494
+ _ws.send(JSON.stringify({ type: 'e2e:box', id: inner.id, payload: { nonce, ciphertext }, ts: Date.now() }));
495
+ } else {
496
+ _ws.send(JSON.stringify(inner));
497
+ }
498
+ } catch (e) {
499
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushEvent failed', meta: { type, error: e?.message } });
500
+ }
501
+ }
502
+
503
+ /** Extract concatenated text from an assistant transcript line, or '' if none. */
504
+ function extractAssistantText(raw) {
505
+ const msg = raw?.message || raw;
506
+ const content = msg?.content;
507
+ if (typeof content === 'string') return content;
508
+ if (!Array.isArray(content)) return '';
509
+ return content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('\n').trim();
510
+ }
511
+
512
+ /** Map a classified transcript event to a coarse session state. */
513
+ function deriveState(ev, raw) {
514
+ // API/usage errors surface as a flagged message line.
515
+ if (raw?.isApiErrorMessage || raw?.level === 'error') return 'error';
516
+ switch (ev.kind) {
517
+ case 'tool_use':
518
+ case 'agent_spawn':
519
+ return 'running'; // model invoked a tool, awaiting result
520
+ case 'tool_result':
521
+ return 'thinking'; // tool finished, model resuming
522
+ case 'user':
523
+ return 'thinking'; // input submitted, model will respond
524
+ case 'assistant':
525
+ return 'idle'; // assistant text turn complete → user's turn
526
+ default:
527
+ return null; // usage/todo/plan/etc. — no state change
528
+ }
529
+ }
530
+
531
+ async function tailLines(filePath, fromOffset) {
532
+ const stat = await fsp.stat(filePath).catch(() => null);
533
+ if (!stat) return { lines: [], size: 0, inode: undefined };
534
+ let start = fromOffset;
535
+ if (start == null || start > stat.size) start = Math.max(0, stat.size - SESSION_INIT_TAIL_BYTES);
536
+ if (stat.size <= start) return { lines: [], size: stat.size, inode: stat.ino };
537
+ const fd = await fsp.open(filePath, 'r');
538
+ try {
539
+ const len = stat.size - start;
540
+ const buf = Buffer.alloc(len);
541
+ await fd.read(buf, 0, len, start);
542
+ const parts = buf.toString('utf8').split('\n').filter(Boolean);
543
+ // If we started mid-file, the first fragment may be a partial line — drop it.
544
+ if (start > 0 && parts.length) parts.shift();
545
+ return { lines: parts, size: stat.size, inode: stat.ino };
546
+ } finally {
547
+ await fd.close();
548
+ }
549
+ }
550
+
551
+ async function pollSessionWatcher(w) {
552
+ let res;
553
+ try {
554
+ res = await tailLines(w.filePath, w.offset);
555
+ } catch { return; }
556
+ // Inode change = file replaced; restart from a bounded tail.
557
+ if (w.inode !== undefined && res.inode !== undefined && res.inode !== w.inode) {
558
+ w.offset = Math.max(0, res.size - SESSION_INIT_TAIL_BYTES);
559
+ w.inode = res.inode;
560
+ return;
561
+ }
562
+ w.offset = res.size;
563
+ w.inode = res.inode;
564
+
565
+ let nextState = null;
566
+ let newAssistantText = null;
567
+ let newMsgId = null;
568
+ for (const line of res.lines) {
569
+ let obj;
570
+ try { obj = JSON.parse(line); } catch { continue; }
571
+ const ev = require('./transcripts.cjs').classifyLine(obj);
572
+ if (!ev) continue;
573
+ const s = deriveState(ev, obj);
574
+ if (s) nextState = s;
575
+ if (ev.kind === 'assistant') {
576
+ const text = extractAssistantText(obj);
577
+ if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
578
+ }
579
+ }
580
+
581
+ if (nextState && nextState !== w.state) {
582
+ w.state = nextState;
583
+ pushEvent('event:session:state', { tabId: w.tabId, state: w.state, since: Date.now() });
584
+ }
585
+ if (newAssistantText && newMsgId !== w.lastMsgId) {
586
+ w.lastAssistantText = newAssistantText;
587
+ w.lastMsgId = newMsgId;
588
+ }
589
+ // Summarize only a COMPLETED turn (state idle) — not assistant text mid-turn that
590
+ // is followed by a tool call. Cache by msgId so re-subscribe doesn't re-bill.
591
+ if (w.state === 'idle' && w.lastAssistantText && w.lastMsgId !== w.summarizedMsgId) {
592
+ w.summarizedMsgId = w.lastMsgId;
593
+ maybeSummarize(w).catch(() => {});
594
+ }
595
+ }
596
+
597
+ function startSessionWatch(tabId, cwd) {
598
+ if (_sessionWatchers.has(tabId)) return;
599
+ const filePath = require('./transcripts.cjs').transcriptPath(cwd, tabId);
600
+ // Defense in depth: the schema restricts tabId charset, but re-validate the
601
+ // FINAL joined path against the home-dir boundary (validatePath resolves
602
+ // symlinks + rejects escapes) before any fs read. Throws → dispatch drops it.
603
+ validatePath(filePath);
604
+ const w = {
605
+ tabId, cwd, filePath,
606
+ offset: null, // null → first poll reads a bounded tail then tracks EOF
607
+ inode: undefined,
608
+ state: 'idle',
609
+ lastAssistantText: null,
610
+ lastMsgId: null,
611
+ summarizedMsgId: null,
612
+ timer: null,
613
+ };
614
+ _sessionWatchers.set(tabId, w);
615
+ // Prime once immediately (captures current state + last assistant turn), then poll.
616
+ pollSessionWatcher(w).catch(() => {});
617
+ w.timer = setInterval(() => pollSessionWatcher(w).catch(() => {}), SESSION_POLL_MS);
618
+ if (typeof w.timer.unref === 'function') w.timer.unref();
619
+ }
620
+
621
+ function stopSessionWatch(tabId) {
622
+ const w = _sessionWatchers.get(tabId);
623
+ if (!w) return;
624
+ if (w.timer) clearInterval(w.timer);
625
+ _sessionWatchers.delete(tabId);
626
+ }
627
+
628
+ function stopAllSessionWatches() {
629
+ for (const tabId of Array.from(_sessionWatchers.keys())) stopSessionWatch(tabId);
630
+ if (_sessionListTimer) { clearInterval(_sessionListTimer); _sessionListTimer = null; }
631
+ }
632
+
633
+ /** Push the current session list (reuses sessionsStore — the canonical source). */
634
+ async function pushSessionList() {
635
+ try {
636
+ // Honor the kill switch: when remote is disabled, push nothing (the project
637
+ // list = cwds/titles is sensitive). dispatchEnvelope already blocks cmd:*;
638
+ // this stops the unsolicited background push too.
639
+ const cfg = await loadConfig();
640
+ if (!cfg.remoteEnabled) return;
641
+ const sessionsStore = require('./sessionsStore.cjs');
642
+ const data = await sessionsStore.load();
643
+ // Normalize persisted tabs → SessionMeta. tabId === claudeSessionId so it
644
+ // matches the transcript JSONL name used by cmd:session:subscribe.
645
+ const sessions = (data?.tabs ?? []).map((t) => ({
646
+ tabId: t.claudeSessionId,
647
+ cwd: t.cwd,
648
+ title: t.label || t.cwd,
649
+ state: _sessionWatchers.get(t.claudeSessionId)?.state ?? null,
650
+ }));
651
+ pushEvent('event:session:list', { sessions, activeTabId: data?.activeTabId ?? null });
652
+ } catch (e) {
653
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'pushSessionList failed', meta: { error: e?.message } });
654
+ }
655
+ }
656
+
657
+ function startSessionListPush() {
658
+ if (_sessionListTimer) return;
659
+ pushSessionList().catch(() => {});
660
+ _sessionListTimer = setInterval(() => pushSessionList().catch(() => {}), SESSION_LIST_PUSH_MS);
661
+ if (typeof _sessionListTimer.unref === 'function') _sessionListTimer.unref();
662
+ }
663
+
664
+ // ─── SM-V2-03: mobile summary via Claude Haiku 4.5 ───────────────────────────
665
+
666
+ const SUMMARY_MIN_CHARS = 280; // below this, push raw — not worth an API call
667
+ const SUMMARY_MODEL = 'claude-haiku-4-5';
668
+ const SUMMARY_MAX_INPUT_CHARS = 24_000; // cap the turn text sent to Haiku (~6k tokens)
669
+ const SUMMARY_SYSTEM =
670
+ 'Summarize this Claude Code assistant turn for a phone screen in 2 sentences max, ' +
671
+ 'followed by an optional list of up to 3 short action items. Plain text only — no ' +
672
+ 'markdown headers, no code blocks. Lead with what was done or decided.';
673
+
674
+ let _anthropicKeyCache = null; // memoized found key only (string); null = re-resolve
675
+
676
+ /** Resolve the Anthropic API key: env → web-remote.json → null (degrade to raw).
677
+ * Only a FOUND key is cached — if absent we re-resolve each call (cheap, loadConfig
678
+ * is TTL-cached) so adding the key to web-remote.json later takes effect without a restart. */
679
+ async function resolveAnthropicKey() {
680
+ if (_anthropicKeyCache) return _anthropicKeyCache;
681
+ const fromEnv = process.env.ANTHROPIC_API_KEY;
682
+ if (fromEnv && fromEnv.trim()) { _anthropicKeyCache = fromEnv.trim(); return _anthropicKeyCache; }
683
+ try {
684
+ const cfg = await loadConfig();
685
+ const k = cfg.anthropicApiKey;
686
+ if (typeof k === 'string' && k.trim()) { _anthropicKeyCache = k.trim(); return _anthropicKeyCache; }
687
+ } catch { /* fall through to null → re-resolve next time */ }
688
+ return null;
689
+ }
690
+
691
+ /** POST to the Anthropic Messages API. Returns the first text block, or throws. */
692
+ function anthropicSummarize(apiKey, text) {
693
+ const body = JSON.stringify({
694
+ model: SUMMARY_MODEL,
695
+ max_tokens: 320,
696
+ system: SUMMARY_SYSTEM,
697
+ messages: [{ role: 'user', content: text.slice(0, SUMMARY_MAX_INPUT_CHARS) }],
698
+ });
699
+ return new Promise((resolve, reject) => {
700
+ const req = https.request('https://api.anthropic.com/v1/messages', {
701
+ method: 'POST',
702
+ headers: {
703
+ 'content-type': 'application/json',
704
+ 'x-api-key': apiKey,
705
+ 'anthropic-version': '2023-06-01',
706
+ 'content-length': Buffer.byteLength(body),
707
+ },
708
+ timeout: 20_000,
709
+ }, (res) => {
710
+ let data = '';
711
+ res.on('data', (c) => { data += c; });
712
+ res.on('end', () => {
713
+ if (res.statusCode < 200 || res.statusCode >= 300) {
714
+ return reject(new Error(`anthropic HTTP ${res.statusCode}`));
715
+ }
716
+ try {
717
+ const json = JSON.parse(data);
718
+ const block = Array.isArray(json.content) ? json.content.find((b) => b.type === 'text') : null;
719
+ if (!block?.text) return reject(new Error('no text in response'));
720
+ resolve(block.text.trim());
721
+ } catch (e) { reject(e); }
722
+ });
723
+ });
724
+ req.on('error', reject);
725
+ req.on('timeout', () => req.destroy(new Error('anthropic request timed out')));
726
+ req.end(body);
727
+ });
728
+ }
729
+
730
+ /**
731
+ * Produce a mobile summary of the watcher's last completed assistant turn and push it.
732
+ * Short turns are pushed raw (no API call). If no API key is configured, degrades to
733
+ * the raw message so core remote control is never blocked. Cost: Haiku in+out per
734
+ * completed turn per subscribed tab (~$1/$5 per 1M tokens).
735
+ */
736
+ async function maybeSummarize(w) {
737
+ const text = w.lastAssistantText;
738
+ if (!text) return;
739
+ const ofMessageId = w.lastMsgId;
740
+
741
+ if (text.length < SUMMARY_MIN_CHARS) {
742
+ pushEvent('event:session:summary', { tabId: w.tabId, summary: text, ofMessageId, model: 'raw', ts: Date.now() });
743
+ return;
744
+ }
745
+
746
+ const apiKey = await resolveAnthropicKey();
747
+ if (!apiKey) {
748
+ // Degrade gracefully: push a trimmed raw message + a hint flag the app can surface.
749
+ pushEvent('event:session:summary', {
750
+ tabId: w.tabId, summary: text.slice(0, 600), ofMessageId, model: 'raw', degraded: 'no_api_key', ts: Date.now(),
751
+ });
752
+ return;
753
+ }
754
+
755
+ try {
756
+ const summary = await anthropicSummarize(apiKey, text);
757
+ pushEvent('event:session:summary', { tabId: w.tabId, summary, ofMessageId, model: SUMMARY_MODEL, ts: Date.now() });
758
+ } catch (e) {
759
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'summary failed; pushing raw', meta: { error: e?.message } });
760
+ pushEvent('event:session:summary', { tabId: w.tabId, summary: text.slice(0, 600), ofMessageId, model: 'raw', degraded: 'api_error', ts: Date.now() });
761
+ }
762
+ }
763
+
764
+ // ─── Message handling & command dispatch ─────────────────────────────────────
765
+
766
+ async function handleMessage(raw, device) {
767
+ let envelope;
768
+ try {
769
+ envelope = JSON.parse(raw);
770
+ } catch {
771
+ return; // malformed JSON — drop silently
772
+ }
773
+
774
+ const { type, id, payload } = envelope;
775
+ if (typeof type !== 'string') return;
776
+
777
+ // Handle relay control messages
778
+ if (type === 'pong') {
779
+ if (_pongTimer) { clearTimeout(_pongTimer); _pongTimer = null; }
780
+ _missedPongs = 0;
781
+ return;
782
+ }
783
+ if (type === 'ping') {
784
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
785
+ _ws.send(JSON.stringify({ type: 'pong', id, ts: Date.now() }));
786
+ }
787
+ return;
788
+ }
789
+ if (type === 'auth:ok') {
790
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: 'auth:ok from relay' });
791
+ return;
792
+ }
793
+ if (type === 'error') {
794
+ const code = envelope.code || payload?.code;
795
+ if (code === 'token_revoked') {
796
+ const ws = _ws;
797
+ _ws = null;
798
+ try { ws?.terminate(); } catch { /* */ }
799
+ handleTokenRevoked(device.deviceId).catch(() => {});
800
+ }
801
+ return;
802
+ }
803
+
804
+ // ── E2E key exchange ───────────────────────────────────────────────────────
805
+ // Browser sends e2e:hello with its ephemeral P-256 public key (SPKI base64url).
806
+ // We compute the shared session key and acknowledge.
807
+ if (type === 'e2e:hello') {
808
+ const browserPubKey = payload?.pubKey;
809
+ if (!browserPubKey || typeof browserPubKey !== 'string') {
810
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello missing pubKey' });
811
+ return;
812
+ }
813
+ // P-256 SPKI DER is 91 bytes → base64url ~122 chars; reject out-of-range blobs
814
+ // before they reach crypto.createPublicKey (malformed input throws and is caught,
815
+ // but repeated bad keys force plaintext fallback via the keep-e2e enforcement above).
816
+ const PUB_KEY_RE = /^[A-Za-z0-9+/=_-]+$/;
817
+ if (browserPubKey.length < 80 || browserPubKey.length > 256 || !PUB_KEY_RE.test(browserPubKey)) {
818
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello invalid pubKey format' });
819
+ return;
820
+ }
821
+ if (!device.e2ePrivateKey) {
822
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:hello but no device private key — skipping E2E' });
823
+ return;
824
+ }
825
+ try {
826
+ _e2eSessionKey = deriveSessionKey(device.e2ePrivateKey, browserPubKey, device.deviceId);
827
+ logs.writeLine({ scope: 'webRemote', level: 'info', message: 'E2E session key established' });
828
+ broadcastStatus();
829
+ // Acknowledge with e2e:ready (unencrypted — session just started)
830
+ respond(id, undefined, 'e2e:ready');
831
+ } catch (e) {
832
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'E2E key derivation failed', meta: { error: e?.message } });
833
+ _e2eSessionKey = null;
834
+ }
835
+ return;
836
+ }
837
+
838
+ // ── Decrypt e2e:box messages ──────────────────────────────────────────────
839
+ if (type === 'e2e:box') {
840
+ if (!_e2eSessionKey) {
841
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box received but no session key — dropping' });
842
+ return;
843
+ }
844
+ const { nonce, ciphertext } = payload || {};
845
+ if (!nonce || !ciphertext) return;
846
+ const plaintext = decryptBox(nonce, ciphertext, _e2eSessionKey);
847
+ if (!plaintext) {
848
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'e2e:box decryption failed (auth tag mismatch)' });
849
+ return;
850
+ }
851
+ // Replace envelope with the decrypted inner command and continue dispatch.
852
+ let inner;
853
+ try {
854
+ inner = JSON.parse(plaintext);
855
+ } catch {
856
+ return;
857
+ }
858
+ // Dispatch as if it arrived unencrypted
859
+ await dispatchEnvelope(inner, device);
860
+ return;
861
+ }
862
+
863
+ // Only dispatch cmd:* type messages
864
+ if (!type.startsWith('cmd:')) return;
865
+ // After E2E is established, reject plaintext commands — a malicious relay cannot
866
+ // silently downgrade the session by stripping e2e:hello (PENTEST.md §H1).
867
+ if (_e2eSessionKey) {
868
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'plaintext cmd rejected — e2e session active' });
869
+ return;
870
+ }
871
+ await dispatchEnvelope(envelope, device);
872
+ }
873
+
874
+ async function dispatchEnvelope(envelope, device) {
875
+ const { type, id, payload } = envelope;
876
+ if (typeof type !== 'string' || !type.startsWith('cmd:')) return;
877
+
878
+ const ts = new Date().toISOString();
879
+
880
+ // Kill switch — re-reads config with 1s TTL
881
+ const cfg = await loadConfig();
882
+ if (!cfg.remoteEnabled) {
883
+ await auditLog(ts, type, device.deviceId, id, 'error:disabled');
884
+ respond(id, { error: 'disabled' });
885
+ return;
886
+ }
887
+
888
+ // Allowlist check — unknown types are dropped without error feedback (ADR §6.2)
889
+ if (!ALLOWED_COMMANDS.has(type)) {
890
+ await auditLog(ts, type, device.deviceId, id, 'error:not_allowed');
891
+ return;
892
+ }
893
+
894
+ // Dispatch
895
+ let result;
896
+ try {
897
+ result = await dispatchCommand(type, payload ?? {});
898
+ await auditLog(ts, type, device.deviceId, id, 'ok');
899
+ } catch (e) {
900
+ const code = e?.name === 'ZodError' ? 'schema_invalid' : 'dispatch_error';
901
+ await auditLog(ts, type, device.deviceId, id, `error:${code}`);
902
+ result = { error: code }; // never leak internal error messages to the remote caller
903
+ }
904
+
905
+ respond(id, result);
906
+ }
907
+
908
+ function respond(msgId, payload, typeOverride) {
909
+ if (!_ws || _ws.readyState !== WebSocket.OPEN) return;
910
+ const responseType = typeOverride || (msgId ? `resp:${msgId}` : undefined);
911
+ if (!responseType) return;
912
+
913
+ const inner = {
914
+ type: responseType,
915
+ id: msgId,
916
+ payload,
917
+ ts: Date.now(),
918
+ };
919
+
920
+ try {
921
+ // Encrypt the response if a session key is active.
922
+ if (_e2eSessionKey && !typeOverride) {
923
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), _e2eSessionKey);
924
+ _ws.send(JSON.stringify({
925
+ type: 'e2e:box',
926
+ id: msgId,
927
+ payload: { nonce, ciphertext },
928
+ ts: Date.now(),
929
+ }));
930
+ } else {
931
+ _ws.send(JSON.stringify(inner));
932
+ }
933
+ } catch (e) {
934
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'respond send failed', meta: { error: e?.message } });
935
+ }
936
+ }
937
+
938
+ // Lazy-loaded dispatch map — avoids circular require at module load time.
939
+ let _dispatchMap = null;
940
+
941
+ function getDispatchMap() {
942
+ if (_dispatchMap) return _dispatchMap;
943
+
944
+ const { manager: ptyManager } = require('./pty.cjs');
945
+ const sessionsStore = require('./sessionsStore.cjs');
946
+ const scheduler = require('./scheduler.cjs');
947
+ const { remote: histRemote } = require('./historyAggregator.cjs');
948
+
949
+ _dispatchMap = {
950
+ 'cmd:sessions:load': async () =>
951
+ sessionsStore.load(),
952
+
953
+ 'cmd:sessions:save': async (payload) => {
954
+ const parsed = schemas.sessionsPayload.parse(payload);
955
+ return sessionsStore.save(parsed);
956
+ },
957
+
958
+ 'cmd:pty:spawn': async (payload) => {
959
+ const parsed = schemas.ptySpawn.parse(payload);
960
+ // Path safety — validatePath rejects anything outside home dir.
961
+ validatePath(parsed.cwd);
962
+ // Strip startupCommand: it is ignored by pty.cjs today, but passing a
963
+ // remotely-controlled 8 KiB string to spawn is a landmine if pty.cjs
964
+ // ever uses it. Remote callers have no legitimate need for it.
965
+ const { startupCommand: _ignored, ...safePayload } = parsed;
966
+ return ptyManager.spawn(safePayload);
967
+ },
968
+
969
+ 'cmd:pty:write': async (payload) => {
970
+ const parsed = schemas.ptyWrite.parse(payload);
971
+ ptyManager.write(parsed);
972
+ return { ok: true };
973
+ },
974
+
975
+ 'cmd:pty:resize': async (payload) => {
976
+ const parsed = schemas.ptyResize.parse(payload);
977
+ ptyManager.resize(parsed);
978
+ return { ok: true };
979
+ },
980
+
981
+ 'cmd:pty:kill': async (payload) => {
982
+ const parsed = schemas.ptyTabId.parse(payload);
983
+ ptyManager.kill(parsed.tabId);
984
+ return { ok: true };
985
+ },
986
+
987
+ 'cmd:schedule:state': async () =>
988
+ scheduler.remote.getState(),
989
+
990
+ 'cmd:schedule:read-prd': async (payload) => {
991
+ const parsed = schemas.scheduleSlug.parse(payload);
992
+ return scheduler.remote.readPrd(parsed.slug);
993
+ },
994
+
995
+ 'cmd:schedule:read-log': async (payload) => {
996
+ const parsed = schemas.scheduleReadLog.parse(payload);
997
+ return scheduler.remote.readLog(parsed.slug, parsed.runId);
998
+ },
999
+
1000
+ 'cmd:schedule:write-prd': async (payload) => {
1001
+ const parsed = schemas.scheduleWritePrd.parse(payload);
1002
+ return scheduler.remote.writePrd(parsed.slug, parsed.body);
1003
+ },
1004
+
1005
+ 'cmd:schedule:reset-job': async (payload) => {
1006
+ const parsed = schemas.scheduleSlug.parse(payload);
1007
+ return scheduler.remote.resetJob(parsed.slug);
1008
+ },
1009
+
1010
+ 'cmd:schedule:run-now': async () =>
1011
+ scheduler.remote.runNow(),
1012
+
1013
+ 'cmd:schedule:set-config': async (payload) => {
1014
+ const parsed = schemas.setConfigSchema.default({}).parse(payload ?? {});
1015
+ return scheduler.remote.setConfig(parsed);
1016
+ },
1017
+
1018
+ 'cmd:history:aggregate': async (payload) => {
1019
+ const parsed = schemas.historyAggregate.parse(payload);
1020
+ return histRemote.aggregate(parsed);
1021
+ },
1022
+
1023
+ 'cmd:app:version': async () =>
1024
+ app.getVersion(),
1025
+
1026
+ // v2 mobile: start/stop pushing live state + summary for a session.
1027
+ 'cmd:session:subscribe': async (payload) => {
1028
+ const parsed = schemas.sessionSubscribe.parse(payload);
1029
+ validatePath(parsed.cwd); // home-dir boundary before any fs access
1030
+ startSessionWatch(parsed.tabId, parsed.cwd);
1031
+ return { ok: true };
1032
+ },
1033
+
1034
+ 'cmd:session:unsubscribe': async (payload) => {
1035
+ const parsed = schemas.ptyTabId.parse(payload);
1036
+ stopSessionWatch(parsed.tabId);
1037
+ return { ok: true };
1038
+ },
1039
+ };
1040
+
1041
+ return _dispatchMap;
1042
+ }
1043
+
1044
+ async function dispatchCommand(type, payload) {
1045
+ const map = getDispatchMap();
1046
+ const handler = map[type];
1047
+ if (!handler) throw new Error(`no handler for ${type}`);
1048
+ return handler(payload);
1049
+ }
1050
+
1051
+ // ─── Pairing ─────────────────────────────────────────────────────────────────
1052
+
1053
+ async function pair(otp) {
1054
+ const deviceId = crypto.randomUUID();
1055
+
1056
+ // Generate E2E keypair at pair time — public key is sent to relay and stored
1057
+ // alongside the device token so the browser can do key agreement.
1058
+ const { e2ePrivateKey, e2ePublicKey } = generateE2EKeyPair();
1059
+
1060
+ const body = JSON.stringify({
1061
+ code: otp.trim().toUpperCase(),
1062
+ deviceId,
1063
+ devicePubKey: e2ePublicKey,
1064
+ });
1065
+
1066
+ let response;
1067
+ try {
1068
+ response = await httpsPost(`${RELAY_API_BASE}/pair`, body);
1069
+ } catch (e) {
1070
+ return { ok: false, error: e?.message || 'pairing request failed' };
1071
+ }
1072
+
1073
+ if (!response.deviceToken || !response.deviceId) {
1074
+ return { ok: false, error: 'relay returned no device token' };
1075
+ }
1076
+
1077
+ const cfg = await loadConfig();
1078
+ const devices = cfg.devices || [];
1079
+ devices.push({
1080
+ deviceId: response.deviceId,
1081
+ deviceToken: response.deviceToken, // stored only on disk at 0600
1082
+ // Private key stored at 0600 — same security model as device token.
1083
+ e2ePrivateKey,
1084
+ e2ePublicKey,
1085
+ deviceName: `Device (paired ${new Date().toISOString().slice(0, 10)})`,
1086
+ issuedAt: new Date().toISOString(),
1087
+ lastConnectedAt: null,
1088
+ });
1089
+
1090
+ await saveConfig({ ...cfg, devices });
1091
+
1092
+ if (cfg.remoteEnabled) {
1093
+ connect().catch(() => {});
1094
+ }
1095
+
1096
+ // Return only non-secret fields to the renderer
1097
+ return { ok: true, deviceId: response.deviceId };
1098
+ }
1099
+
1100
+ async function revokeDevice(deviceId) {
1101
+ invalidateConfigCache();
1102
+ const cfg = await loadConfig();
1103
+ const devices = (cfg.devices || []).filter((d) => d.deviceId !== deviceId);
1104
+ await saveConfig({ ...cfg, devices });
1105
+
1106
+ // If the active connection is for this device, disconnect
1107
+ if (_ws && _ws.readyState === WebSocket.OPEN) {
1108
+ await disconnect();
1109
+ // Reconnect to a different device if any remain
1110
+ if (devices.length > 0 && cfg.remoteEnabled) {
1111
+ connect().catch(() => {});
1112
+ }
1113
+ }
1114
+
1115
+ broadcastStatus();
1116
+ return { ok: true };
1117
+ }
1118
+
1119
+ /**
1120
+ * Panic / revoke-all: immediately disconnect the relay WS, clear all device
1121
+ * entries from web-remote.json, and set remoteEnabled = false.
1122
+ * This is the local-side "kill everything" action (ADR §4.1).
1123
+ */
1124
+ async function revokeAllDevices() {
1125
+ const cfg = await loadConfig();
1126
+ const revokedCount = (cfg.devices || []).length;
1127
+ await saveConfig({ ...cfg, remoteEnabled: false, devices: [] });
1128
+ await disconnect(); // tears down WS + clears session key
1129
+ broadcastStatus();
1130
+ sendIfAlive(_window, 'webRemote:revoked-all', { revokedCount });
1131
+ return { ok: true };
1132
+ }
1133
+
1134
+ // ─── IPC handlers ─────────────────────────────────────────────────────────────
1135
+
1136
+ function registerRemoteHandlers() {
1137
+ const { validated } = require('./ipcSchemas.cjs');
1138
+
1139
+ // Returns current status without tokens — safe to expose to renderer.
1140
+ ipcMain.handle('webRemote:get-status', async () => {
1141
+ const cfg = await loadConfig();
1142
+ return {
1143
+ enabled: cfg.remoteEnabled,
1144
+ connected: _ws !== null && _ws.readyState === WebSocket.OPEN,
1145
+ e2eActive: _ws !== null && _ws.readyState === WebSocket.OPEN && _e2eSessionKey !== null,
1146
+ devices: (cfg.devices || []).map(({ deviceId, deviceName, issuedAt, lastConnectedAt }) => ({
1147
+ deviceId, deviceName, issuedAt, lastConnectedAt,
1148
+ })),
1149
+ };
1150
+ });
1151
+
1152
+ ipcMain.handle('webRemote:enable', async () => {
1153
+ const cfg = await loadConfig();
1154
+ await saveConfig({ ...cfg, remoteEnabled: true });
1155
+ connect().catch(() => {});
1156
+ broadcastStatus();
1157
+ return { ok: true };
1158
+ });
1159
+
1160
+ ipcMain.handle('webRemote:disable', async () => {
1161
+ const cfg = await loadConfig();
1162
+ await saveConfig({ ...cfg, remoteEnabled: false });
1163
+ await disconnect();
1164
+ broadcastStatus();
1165
+ return { ok: true };
1166
+ });
1167
+
1168
+ ipcMain.handle('webRemote:pair', validated(schemas.webRemotePair, async ({ otp }) => {
1169
+ return pair(otp);
1170
+ }));
1171
+
1172
+ ipcMain.handle('webRemote:revoke-device', validated(schemas.webRemoteRevokeDevice, async ({ deviceId }) => {
1173
+ return revokeDevice(deviceId);
1174
+ }));
1175
+
1176
+ // Panic button: revoke all devices, disable remote, disconnect immediately.
1177
+ ipcMain.handle('webRemote:revoke-all', async () => {
1178
+ return revokeAllDevices();
1179
+ });
1180
+
1181
+ ipcMain.handle('webRemote:audit-tail', validated(schemas.webRemoteAuditTail, async ({ lines }) => {
1182
+ const lineCount = lines || 50;
1183
+ const ymd = new Date().toISOString().slice(0, 10);
1184
+ const logPath = path.join(AUDIT_LOG_DIR, `remote-audit-${ymd}.log`);
1185
+ try {
1186
+ const text = await fsp.readFile(logPath, 'utf8');
1187
+ const all = text.split('\n').filter(Boolean);
1188
+ return { ok: true, lines: all.slice(-lineCount) };
1189
+ } catch (e) {
1190
+ if (e?.code === 'ENOENT') return { ok: true, lines: [] };
1191
+ return { ok: false, error: e?.message };
1192
+ }
1193
+ }));
1194
+ }
1195
+
1196
+ // ─── Module lifecycle ────────────────────────────────────────────────────────
1197
+
1198
+ function attachWindow(w) {
1199
+ _window = w;
1200
+ }
1201
+
1202
+ async function init() {
1203
+ await fsp.mkdir(AUDIT_LOG_DIR, { recursive: true });
1204
+ const cfg = await loadConfig();
1205
+ if (cfg.remoteEnabled && (cfg.devices || []).some((d) => d.deviceToken)) {
1206
+ connect().catch((e) => {
1207
+ logs.writeLine({ scope: 'webRemote', level: 'warn', message: 'init connect failed', meta: { error: e?.message } });
1208
+ });
1209
+ }
1210
+ }
1211
+
1212
+ function destroy() {
1213
+ _destroyed = true;
1214
+ cancelReconnect();
1215
+ _e2eSessionKey = null;
1216
+ if (_ws) {
1217
+ try { _ws.terminate(); } catch { /* */ }
1218
+ _ws = null;
1219
+ }
1220
+ stopHeartbeat();
1221
+ }
1222
+
1223
+ module.exports = {
1224
+ attachWindow,
1225
+ registerRemoteHandlers,
1226
+ init,
1227
+ destroy,
1228
+ };