claude-code-session-manager 0.107.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 (68) hide show
  1. package/dist/assets/AgentLibrary-Br4Ispr4.js +2 -0
  2. package/dist/assets/{Button-CRLhoI2K.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-rqaJJJ1b.js → Library-B3rzEhvN.js} +24 -24
  7. package/dist/assets/{ListDetail-qI27OYfy.js → ListDetail-BAvci_aP.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-BEFuHVNz.js → MarkdownEditor-DBWTGgRz.js} +1 -1
  9. package/dist/assets/{McpServers-Bz9O79rz.js → McpServers-C-grBN21.js} +2 -2
  10. package/dist/assets/{Memory-DobxIoZ-.js → Memory-BmM0Fz-C.js} +5 -5
  11. package/dist/assets/{Panel-Cyt1wpUf.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-BuYrg5E9.js → SaveBar-DSMnbUVI.js} +1 -1
  16. package/dist/assets/Scheduler-CbIcJEfM.js +16 -0
  17. package/dist/assets/{ScopeSwitcher-BM0gLPhd.js → ScopeSwitcher-tHtloWbN.js} +1 -1
  18. package/dist/assets/Settings-CW3w7knp.js +6 -0
  19. package/dist/assets/{SkillReferenceGraph-D0Hgdfxq.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-CP_F_mwb.js → TiptapBody-C2LfNQXw.js} +1 -1
  24. package/dist/assets/{Toggle-haRPHS5N.js → Toggle-CTXTU1q5.js} +1 -1
  25. package/dist/assets/{ViewTabs-BHV3MA5u.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-v_3LvhMx.js → index-fJxuoRvi.js} +697 -697
  29. package/dist/assets/listSkills-C1aWKf0T.js +1 -0
  30. package/dist/assets/{settingsSchema-CE46TA3V.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/scripts/scheduler-mcp-server.cjs +38 -5
  37. package/src/main/agentLibrary.cjs +20 -9
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/config.cjs +20 -10
  40. package/src/main/historyAggregator.cjs +46 -3
  41. package/src/main/index.cjs +2 -0
  42. package/src/main/ipcSchemas.cjs +2 -0
  43. package/src/main/lib/builtinMacros.cjs +2 -2
  44. package/src/main/lib/mcpToolCatalog.cjs +3 -1
  45. package/src/main/lib/remoteE2eCrypto.cjs +109 -0
  46. package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
  47. package/src/main/pty.cjs +53 -0
  48. package/src/main/remoteClient.cjs +305 -0
  49. package/src/main/remoteClientIpc.cjs +101 -0
  50. package/src/main/webRemote.cjs +90 -108
  51. package/src/preload/api.d.ts +42 -5
  52. package/src/preload/index.cjs +27 -2
  53. package/src/seed/agents/demo-video-builder.md +69 -48
  54. package/dist/assets/AgentLibrary-Bjeq2-kp.js +0 -2
  55. package/dist/assets/DataModel-iPJw5jcG.js +0 -1
  56. package/dist/assets/History-DfPR3_6N.js +0 -2
  57. package/dist/assets/Hooks-Bw7H_DmC.js +0 -3
  58. package/dist/assets/Permissions-ChSNRUJJ.js +0 -3
  59. package/dist/assets/Plugins-CII221R2.js +0 -2
  60. package/dist/assets/ProvenanceBadge-C_sk5-v3.js +0 -1
  61. package/dist/assets/Scheduler-eixbMGEk.js +0 -16
  62. package/dist/assets/Settings-qD-0W-Ut.js +0 -4
  63. package/dist/assets/Skills-C3T_2_2o.js +0 -3
  64. package/dist/assets/SystemPrompt-B-xTy9Qn.js +0 -1
  65. package/dist/assets/TagLibrary-BH67tR5z.js +0 -1
  66. package/dist/assets/listSkills-QORduPIk.js +0 -1
  67. package/dist/assets/useAgentsDirWatch-BZTi2K2e.js +0 -1
  68. package/dist/assets/useConfigDirWatch-CjcswsWR.js +0 -1
@@ -274,6 +274,17 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
274
274
  effort: "medium",
275
275
  },
276
276
  },
277
+ {
278
+ commit: "e477754c",
279
+ bodySha256: "cfb0c7fa8c74dbc731ad352a06cd94717446fe0151bb86e93acc56df45f2c3b1",
280
+ fm: {
281
+ description: "30-second self-contained HTML/JS demo video for Project Home, saved via project_demo_video_write.",
282
+ tools: "Read, Grep, Glob, Bash, Write, Edit",
283
+ title: "Project Home — Demo Video",
284
+ model: "sonnet",
285
+ effort: "medium",
286
+ },
287
+ },
277
288
  ],
278
289
  });
279
290
 
package/src/main/pty.cjs CHANGED
@@ -111,6 +111,44 @@ class PtyManager {
111
111
  this.outBuffers = new Map();
112
112
  this.flushTimers = new Map(); // tabId -> Timeout
113
113
  this.window = null;
114
+ this.outputListeners = new Map(); // tabId -> Set<{onData,onExit}>
115
+ }
116
+
117
+ /**
118
+ * Observe a tab's coalesced output (the same string the renderer gets) and
119
+ * its exit from the main process. Returns an unsubscribe function.
120
+ * @param {string} tabId
121
+ * @param {(tabId: string, chunk: string) => void} onData
122
+ * @param {(tabId: string, info: {exitCode: number, signal?: number}) => void} [onExit]
123
+ */
124
+ addOutputListener(tabId, onData, onExit) {
125
+ const entry = { onData, onExit };
126
+ let set = this.outputListeners.get(tabId);
127
+ if (!set) {
128
+ set = new Set();
129
+ this.outputListeners.set(tabId, set);
130
+ }
131
+ set.add(entry);
132
+ return () => {
133
+ const cur = this.outputListeners.get(tabId);
134
+ if (!cur) return;
135
+ cur.delete(entry);
136
+ if (cur.size === 0) this.outputListeners.delete(tabId);
137
+ };
138
+ }
139
+
140
+ /** Fire every onExit listener for `tabId` once (errors isolated), then drop them. */
141
+ #notifyExit(tabId, info) {
142
+ const set = this.outputListeners.get(tabId);
143
+ this.outputListeners.delete(tabId);
144
+ if (!set) return;
145
+ for (const l of [...set]) {
146
+ try {
147
+ l.onExit?.(tabId, info);
148
+ } catch (err) {
149
+ console.warn('[pty] output listener onExit threw', err);
150
+ }
151
+ }
114
152
  }
115
153
 
116
154
  /** Send any pending buffered output for `tabId` now, and clear its timer. */
@@ -124,6 +162,16 @@ class PtyManager {
124
162
  if (pending) {
125
163
  this.outBuffers.delete(tabId);
126
164
  sendIfAlive(this.window, `pty:data:${tabId}`, pending);
165
+ const set = this.outputListeners.get(tabId);
166
+ if (set) {
167
+ for (const l of [...set]) {
168
+ try {
169
+ l.onData?.(tabId, pending);
170
+ } catch (err) {
171
+ console.warn('[pty] output listener onData threw', err);
172
+ }
173
+ }
174
+ }
127
175
  }
128
176
  }
129
177
 
@@ -328,6 +376,7 @@ class PtyManager {
328
376
  this.sessions.delete(tabId);
329
377
  this.buffers.delete(tabId);
330
378
  this.#dropOutput(tabId);
379
+ this.#notifyExit(tabId, { exitCode, signal });
331
380
  return;
332
381
  }
333
382
  // Flush any still-buffered output BEFORE the exit event so the renderer
@@ -349,6 +398,7 @@ class PtyManager {
349
398
  });
350
399
  }
351
400
  sendIfAlive(this.window, `pty:exit:${tabId}`, { exitCode, signal });
401
+ this.#notifyExit(tabId, { exitCode, signal });
352
402
  this.sessions.delete(tabId);
353
403
  this.buffers.delete(tabId);
354
404
  });
@@ -411,6 +461,9 @@ class PtyManager {
411
461
  this.sessions.delete(tabId);
412
462
  this.buffers.delete(tabId);
413
463
  this.#dropOutput(tabId);
464
+ // Listeners learn the tab is gone now; the suppressed exit event that
465
+ // follows finds none left, so each fires exactly once.
466
+ this.#notifyExit(tabId, { exitCode: 0, signal: undefined });
414
467
  }
415
468
  }
416
469
 
@@ -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 };