claude-code-session-manager 0.108.0 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/assets/AgentLibrary-Cm6MrOnF.js +2 -0
  2. package/dist/assets/{Button-DC6VzovQ.js → Button-CGVncJ6a.js} +1 -1
  3. package/dist/assets/DataModel-C3hlMoa8.js +1 -0
  4. package/dist/assets/History-CTyfGNKn.js +2 -0
  5. package/dist/assets/Hooks-BXxCBibx.js +3 -0
  6. package/dist/assets/{Library-DLmnlfQt.js → Library-aCs7zxAg.js} +24 -24
  7. package/dist/assets/{ListDetail-YylbvUj_.js → ListDetail-B9Hqk1_f.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DMPKWHTF.js → MarkdownEditor-Djfgp32k.js} +1 -1
  9. package/dist/assets/{McpServers-Da-Ty5nc.js → McpServers-8HQC6QgR.js} +2 -2
  10. package/dist/assets/{Memory-C3rUQ_cg.js → Memory-c4SVE3l2.js} +5 -5
  11. package/dist/assets/{Panel-CC3JTnOI.js → Panel-BEQTwE4z.js} +1 -1
  12. package/dist/assets/Permissions-BaYzgZtc.js +3 -0
  13. package/dist/assets/Plugins-DnXsVau7.js +2 -0
  14. package/dist/assets/ProvenanceBadge-CT2vWps1.js +1 -0
  15. package/dist/assets/RemoteMachines-DHJZfdJC.js +3 -0
  16. package/dist/assets/{SaveBar-C06rFT34.js → SaveBar-DwcHt3iR.js} +1 -1
  17. package/dist/assets/Scheduler-D6Gcuo0M.js +16 -0
  18. package/dist/assets/{ScopeSwitcher-Cp5mxblN.js → ScopeSwitcher-CgvWDc9Y.js} +1 -1
  19. package/dist/assets/Settings-CJoogVAB.js +4 -0
  20. package/dist/assets/{SkillReferenceGraph-DuWRmT_f.js → SkillReferenceGraph-4fq01kxq.js} +14 -14
  21. package/dist/assets/Skills-BU4ZX4h4.js +2 -0
  22. package/dist/assets/SystemPrompt-BCEyMSEq.js +1 -0
  23. package/dist/assets/TagLibrary-m3It8Oal.js +1 -0
  24. package/dist/assets/{TiptapBody-1r4ODLJ7.js → TiptapBody-DVyBsfsT.js} +1 -1
  25. package/dist/assets/{Toggle-BGT68XHP.js → Toggle-In_AExPV.js} +1 -1
  26. package/dist/assets/{ViewTabs-T0NBc8uv.js → ViewTabs-BLHjKUv-.js} +1 -1
  27. package/dist/assets/historyTotals-BXDd3EzP.js +1 -0
  28. package/dist/assets/index-CuiU3I-8.js +3077 -0
  29. package/dist/assets/{index-tr1w3hHB.css → index-Dutcsly-.css} +1 -1
  30. package/dist/assets/listSkills-C1aWKf0T.js +1 -0
  31. package/dist/assets/{settingsSchema-CvoNk3Ye.js → settingsSchema-DD4bc5cT.js} +1 -1
  32. package/dist/assets/useAgentsDirWatch-DMQ_XZtr.js +1 -0
  33. package/dist/assets/useRemotePeers-C9NfteSD.js +1 -0
  34. package/dist/index.html +2 -2
  35. package/package.json +6 -2
  36. package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
  37. package/plugins/session-manager-dev/skills/develop/standards.md +5 -0
  38. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
  39. package/scripts/README.md +7 -0
  40. package/scripts/scheduler-mcp-server.cjs +29 -2
  41. package/src/main/agentLibrary.cjs +20 -9
  42. package/src/main/build-info.json +4 -4
  43. package/src/main/chatRunner.cjs +7 -0
  44. package/src/main/config.cjs +20 -10
  45. package/src/main/historyAggregator.cjs +46 -3
  46. package/src/main/index.cjs +8 -0
  47. package/src/main/ipcSchemas.cjs +30 -0
  48. package/src/main/lib/autoUpdater.cjs +190 -0
  49. package/src/main/lib/crossProjectFeedback.cjs +143 -6
  50. package/src/main/lib/mcpToolCatalog.cjs +12 -3
  51. package/src/main/lib/reaperHelpers.cjs +13 -1
  52. package/src/main/lib/relayClientAuth.cjs +133 -0
  53. package/src/main/lib/remoteClientSessions.cjs +84 -0
  54. package/src/main/lib/remoteE2eCrypto.cjs +109 -0
  55. package/src/main/lib/updaterSettings.cjs +64 -0
  56. package/src/main/pty.cjs +53 -0
  57. package/src/main/remoteClient.cjs +334 -0
  58. package/src/main/remoteClientIpc.cjs +133 -0
  59. package/src/main/scheduler.cjs +16 -0
  60. package/src/main/updaterIpc.cjs +110 -0
  61. package/src/main/webRemote.cjs +341 -215
  62. package/src/preload/api.d.ts +97 -7
  63. package/src/preload/index.cjs +51 -3
  64. package/dist/assets/AgentLibrary-Co54GThg.js +0 -2
  65. package/dist/assets/DataModel-DZ0vyJau.js +0 -1
  66. package/dist/assets/History-Df3YwpPe.js +0 -2
  67. package/dist/assets/Hooks-DplG6roH.js +0 -3
  68. package/dist/assets/Permissions-XvBiMj1-.js +0 -3
  69. package/dist/assets/Plugins-CzHY1mgS.js +0 -2
  70. package/dist/assets/ProvenanceBadge-DaJIWVcg.js +0 -1
  71. package/dist/assets/Scheduler-BehadU-0.js +0 -16
  72. package/dist/assets/Settings-Cg9LIni0.js +0 -4
  73. package/dist/assets/Skills-CMmCZfIv.js +0 -3
  74. package/dist/assets/SystemPrompt-D_-MV025.js +0 -1
  75. package/dist/assets/TagLibrary-D9Tv3yOJ.js +0 -1
  76. package/dist/assets/index-3_AtCAyd.js +0 -3077
  77. package/dist/assets/listSkills-QORduPIk.js +0 -1
  78. package/dist/assets/useAgentsDirWatch-1C2CBBya.js +0 -1
  79. package/dist/assets/useConfigDirWatch-Cd5PnTkB.js +0 -1
@@ -0,0 +1,64 @@
1
+ /**
2
+ * updaterSettings — persists the in-app auto-update toggle.
3
+ *
4
+ * Storage: ~/.claude/session-manager/updater.json
5
+ * Shape: { enabled: boolean (ON by default), schemaVersion: 1 }
6
+ *
7
+ * Mirrors telemetrySettings.cjs: atomic write via config.cjs, frozen DEFAULTS,
8
+ * isValid() rejecting unknown keys. Pure data plumbing — no IPC, no UI.
9
+ */
10
+ 'use strict';
11
+
12
+ const fsp = require('node:fs/promises');
13
+ const path = require('node:path');
14
+ const os = require('node:os');
15
+ const config = require('../config.cjs');
16
+
17
+ const SCHEMA_VERSION = 1;
18
+ const DEFAULTS = Object.freeze({ enabled: true, schemaVersion: SCHEMA_VERSION });
19
+ const KNOWN_KEYS = new Set(Object.keys(DEFAULTS));
20
+
21
+ function storePath() {
22
+ return path.join(os.homedir(), '.claude', 'session-manager', 'updater.json');
23
+ }
24
+
25
+ function isValid(cfg) {
26
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return false;
27
+ for (const k of Object.keys(cfg)) {
28
+ if (!KNOWN_KEYS.has(k)) return false;
29
+ }
30
+ return typeof cfg.enabled === 'boolean' && cfg.schemaVersion === SCHEMA_VERSION;
31
+ }
32
+
33
+ /** Never throws: missing, unparseable, or invalid files all yield the defaults. */
34
+ async function load() {
35
+ try {
36
+ const data = JSON.parse(await fsp.readFile(storePath(), 'utf8'));
37
+ const merged = { ...DEFAULTS, ...data };
38
+ if (isValid(data && typeof data === 'object' && !Array.isArray(data) ? merged : data)) {
39
+ return { enabled: merged.enabled, schemaVersion: SCHEMA_VERSION };
40
+ }
41
+ } catch (e) {
42
+ if (e.code !== 'ENOENT') console.warn('[updaterSettings] load failed:', e.message);
43
+ }
44
+ return { ...DEFAULTS };
45
+ }
46
+
47
+ let writeQueue = Promise.resolve();
48
+ async function save(patch) {
49
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Invalid updater config');
50
+ for (const k of Object.keys(patch)) {
51
+ if (k !== 'enabled') throw new Error('Invalid updater config');
52
+ }
53
+ if (typeof patch.enabled !== 'boolean') throw new Error('Invalid updater config');
54
+ const next = { enabled: patch.enabled, schemaVersion: SCHEMA_VERSION };
55
+ const run = async () => {
56
+ await config.writeJson(storePath(), next, { mode: 0o600 });
57
+ return next;
58
+ };
59
+ const tail = writeQueue.then(run, run);
60
+ writeQueue = tail.catch(() => {});
61
+ return tail;
62
+ }
63
+
64
+ module.exports = { storePath, load, save, isValid, DEFAULTS, SCHEMA_VERSION };
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,334 @@
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
+ const { relayClientAuth } = require('./lib/relayClientAuth.cjs');
15
+
16
+ // Hard-coded wss:// — same relay URL as webRemote.cjs, no plaintext downgrade.
17
+ const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
18
+ const SEND_TIMEOUT_MS = 15_000;
19
+ const WS_OPEN = 1;
20
+
21
+ // The relay closes a revoked desktop client's socket with this code.
22
+ const CLOSE_TOKEN_REVOKED = 4001;
23
+ // Relay errors that end the handshake with a reason the UI can explain.
24
+ const FATAL_RELAY_ERRORS = new Set(['device_offline', 'not_your_device']);
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 = relayClientAuth, 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, osRequested: false,
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
+ os: null,
104
+ };
105
+ peers.push(peer);
106
+ savePeers(peers);
107
+ return peer;
108
+ }
109
+
110
+ function listPeers() {
111
+ // Never expose the private key to callers.
112
+ return loadPeers().map(({ clientPrivateKey, ...rest }) => rest);
113
+ }
114
+
115
+ function getStatus(peerId) {
116
+ const s = rt(peerId);
117
+ return { status: s.status, reason: s.reason, sas: s.sas };
118
+ }
119
+
120
+ // Fire-and-forget, once per connection: remember the remote's platform so the
121
+ // Remote view can show it offline. Any failure leaves `os` untouched.
122
+ function learnOs(peerId, s) {
123
+ if (s.osRequested) return;
124
+ s.osRequested = true;
125
+ send(peerId, 'cmd:app:info').then((res) => {
126
+ if (typeof res?.platform === 'string' && res.platform) updatePeer(peerId, { os: res.platform });
127
+ }).catch(() => { /* older remote or timeout: never fails the connection */ });
128
+ }
129
+
130
+ function markAuthenticated(peerId, s) {
131
+ s.status = 'authenticated';
132
+ learnOs(peerId, s);
133
+ }
134
+
135
+ function rawSend(ws, msg) {
136
+ ws.send(JSON.stringify(msg));
137
+ }
138
+
139
+ function handleInner(peerId, s, inner) {
140
+ if (!inner || typeof inner.type !== 'string') return;
141
+ if (inner.type.startsWith('resp:')) {
142
+ const p = s.pending.get(inner.id);
143
+ if (!p) return;
144
+ s.pending.delete(inner.id);
145
+ clearTimeout(p.timer);
146
+ p.resolve(inner.payload);
147
+ return;
148
+ }
149
+ if (inner.type.startsWith('event:')) {
150
+ for (const cb of subscribers.get(peerId) || []) {
151
+ try { cb(inner); } catch { /* subscriber errors never break the socket */ }
152
+ }
153
+ }
154
+ }
155
+
156
+ function handleMessage(peerId, s, peer, raw) {
157
+ let msg;
158
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
159
+ if (!msg || typeof msg.type !== 'string') return;
160
+
161
+ if (msg.type === 'ping') {
162
+ if (s.ws && s.ws.readyState === WS_OPEN) rawSend(s.ws, { type: 'pong', id: msg.id, ts: Date.now() });
163
+ return;
164
+ }
165
+ if (msg.type === 'error') {
166
+ const code = msg.code || msg.payload?.code;
167
+ if (code === 'token_revoked') {
168
+ try { relayAuth.clearToken?.(); } catch { /* best effort */ }
169
+ fail(peerId, 'token_revoked');
170
+ } else if (FATAL_RELAY_ERRORS.has(code)) {
171
+ fail(peerId, code);
172
+ }
173
+ return;
174
+ }
175
+ if (msg.type === 'auth:ok') {
176
+ // Relay accepted the ticket: start the E2E handshake.
177
+ rawSend(s.ws, {
178
+ type: 'e2e:hello',
179
+ id: crypto.randomUUID(),
180
+ deviceId: peer.deviceId,
181
+ payload: { pubKey: peer.clientPublicKey },
182
+ ts: Date.now(),
183
+ });
184
+ return;
185
+ }
186
+ if (msg.type === 'e2e:ready') {
187
+ if (s.sessionKey) return;
188
+ try {
189
+ s.sessionKey = deriveSessionKey(peer.clientPrivateKey, s.devicePubKey, peer.deviceId);
190
+ s.sas = deriveSas(peer.clientPrivateKey, s.devicePubKey, peer.deviceId);
191
+ } catch {
192
+ fail(peerId, 'e2e_derivation_failed');
193
+ return;
194
+ }
195
+ if (peer.verifiedDevicePubKey && peer.verifiedDevicePubKey === s.devicePubKey) {
196
+ markAuthenticated(peerId, s);
197
+ } else {
198
+ s.status = 'pending_sas';
199
+ }
200
+ return;
201
+ }
202
+ if (msg.type === 'e2e:box') {
203
+ if (!s.sessionKey) return;
204
+ const { nonce, ciphertext } = msg.payload || {};
205
+ if (!nonce || !ciphertext) return;
206
+ const plain = decryptBox(nonce, ciphertext, s.sessionKey);
207
+ if (!plain) return;
208
+ let inner;
209
+ try { inner = JSON.parse(plain); } catch { return; }
210
+ handleInner(peerId, s, inner);
211
+ }
212
+ }
213
+
214
+ async function connect(peerId) {
215
+ const peer = getPeer(peerId);
216
+ if (!peer) throw new Error('unknown_peer');
217
+ const s = rt(peerId);
218
+ const attempt = ++s.attempt;
219
+ try { s.ws?.close(); } catch { /* already closed */ }
220
+ s.ws = null;
221
+ s.sessionKey = null;
222
+ s.sas = null;
223
+ s.reason = null;
224
+ s.osRequested = false;
225
+ s.status = 'connecting';
226
+
227
+ let ticket;
228
+ let devicePubKey;
229
+ try {
230
+ ticket = await relayAuth.getBrowserTicket(peer);
231
+ devicePubKey = await relayAuth.getDevicePubKey(peer.deviceId);
232
+ } catch (e) {
233
+ fail(peerId, e?.message || 'relay_auth_failed');
234
+ return getStatus(peerId);
235
+ }
236
+ if (attempt !== s.attempt) return getStatus(peerId); // superseded or removed
237
+ if (!ticket || !devicePubKey) {
238
+ fail(peerId, 'relay_auth_failed');
239
+ return getStatus(peerId);
240
+ }
241
+ // TOFU: a changed device key after pinning fails closed, before any socket opens.
242
+ if (peer.verifiedDevicePubKey && peer.verifiedDevicePubKey !== devicePubKey) {
243
+ fail(peerId, 'device_key_changed');
244
+ return getStatus(peerId);
245
+ }
246
+ s.devicePubKey = devicePubKey;
247
+
248
+ let ws;
249
+ try {
250
+ ws = wsFactory(`${RELAY_WSS_URL}?ticket=${encodeURIComponent(ticket)}`);
251
+ } catch {
252
+ fail(peerId, 'ws_create_failed');
253
+ return getStatus(peerId);
254
+ }
255
+ s.ws = ws;
256
+ ws.on('message', (data) => { if (s.ws === ws) handleMessage(peerId, s, peer, data); });
257
+ ws.on('close', (code) => {
258
+ if (s.ws !== ws) return;
259
+ if (code === CLOSE_TOKEN_REVOKED) {
260
+ try { relayAuth.clearToken?.(); } catch { /* best effort */ }
261
+ fail(peerId, 'token_revoked');
262
+ return;
263
+ }
264
+ s.ws = null;
265
+ s.sessionKey = null;
266
+ failPending(s, new Error('connection_closed'));
267
+ if (s.status !== 'failed') { s.status = 'idle'; s.sas = null; }
268
+ });
269
+ ws.on('error', () => { if (s.ws === ws) fail(peerId, 'ws_error'); });
270
+ return getStatus(peerId);
271
+ }
272
+
273
+ function confirmSas(peerId) {
274
+ const s = rt(peerId);
275
+ if (s.status !== 'pending_sas' || !s.devicePubKey) return { ok: false, reason: 'not_pending_sas' };
276
+ updatePeer(peerId, { verifiedDevicePubKey: s.devicePubKey });
277
+ markAuthenticated(peerId, s);
278
+ return { ok: true };
279
+ }
280
+
281
+ function send(peerId, type, payload) {
282
+ if (typeof type !== 'string' || !ALLOWED_COMMANDS.has(type)) {
283
+ return Promise.reject(new Error('command_not_allowed'));
284
+ }
285
+ const s = rt(peerId);
286
+ if (!s.ws || s.ws.readyState !== WS_OPEN || !s.sessionKey) {
287
+ return Promise.reject(new Error('not_connected'));
288
+ }
289
+ const id = crypto.randomUUID();
290
+ const peer = getPeer(peerId);
291
+ if (!peer) return Promise.reject(new Error('unknown_peer'));
292
+ const inner = { type, id, payload: payload ?? {}, ts: Date.now() };
293
+ const { nonce, ciphertext } = encryptBox(JSON.stringify(inner), s.sessionKey);
294
+ return new Promise((resolve, reject) => {
295
+ const timer = setTimeout(() => {
296
+ s.pending.delete(id);
297
+ reject(new Error('timeout'));
298
+ }, SEND_TIMEOUT_MS);
299
+ s.pending.set(id, { resolve, reject, timer });
300
+ try {
301
+ rawSend(s.ws, { type: 'e2e:box', id, deviceId: peer.deviceId, payload: { nonce, ciphertext }, ts: Date.now() });
302
+ } catch (e) {
303
+ s.pending.delete(id);
304
+ clearTimeout(timer);
305
+ reject(e);
306
+ }
307
+ });
308
+ }
309
+
310
+ function onEvent(peerId, cb) {
311
+ let set = subscribers.get(peerId);
312
+ if (!set) { set = new Set(); subscribers.set(peerId, set); }
313
+ set.add(cb);
314
+ return () => { set.delete(cb); };
315
+ }
316
+
317
+ function removePeer(peerId) {
318
+ const s = runtime.get(peerId);
319
+ if (s) {
320
+ s.attempt++;
321
+ failPending(s, new Error('peer_removed'));
322
+ try { s.ws?.close(); } catch { /* already closed */ }
323
+ runtime.delete(peerId);
324
+ }
325
+ subscribers.delete(peerId);
326
+ savePeers(loadPeers().filter((p) => p.peerId !== peerId));
327
+ }
328
+
329
+ return { addPeer, listPeers, connect, confirmSas, getStatus, send, onEvent, removePeer };
330
+ }
331
+
332
+ const remoteClient = createRemoteClient();
333
+
334
+ module.exports = { createRemoteClient, remoteClient, SEND_TIMEOUT_MS };
@@ -0,0 +1,133 @@
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 os = require('node:os');
11
+ const { ipcMain } = require('electron');
12
+ const { z } = require('zod');
13
+ const { remoteClient } = require('./remoteClient.cjs');
14
+ const { relayClientAuth } = require('./lib/relayClientAuth.cjs');
15
+ const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
16
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
17
+
18
+ const STATUS_POLL_MS = 1000;
19
+
20
+ const peerId = z.string().min(1).max(128);
21
+ const pairCode = z.string().trim()
22
+ .transform((c) => c.replace(/[-\s]/g, '').toUpperCase())
23
+ .refine((c) => /^[A-Za-z0-9]{8}$/.test(c), { message: 'invalid_code' });
24
+ const schemas = {
25
+ pair: z.object({ code: pairCode, clientName: z.string().max(64).optional() }),
26
+ add: z.object({ deviceId: z.string().min(1).max(128), machineName: z.string().min(1).max(64) }),
27
+ peer: z.object({ peerId }),
28
+ send: z.object({
29
+ peerId,
30
+ type: z.string().min(1).max(128).refine((t) => ALLOWED_COMMANDS.has(t), { message: 'command_not_allowed' }),
31
+ payload: z.unknown().optional(),
32
+ }),
33
+ };
34
+
35
+ function validated(schema, handler) {
36
+ return (_event, payload) => handler(schema.parse(payload));
37
+ }
38
+
39
+ function registerRemoteClientHandlers(getWindow, client = remoteClient, auth = relayClientAuth) {
40
+ const unsubs = new Map(); // peerId -> unsubscribe for event forwarding
41
+ const lastStatus = new Map(); // peerId -> serialized status
42
+
43
+ const toWindow = (channel, payload) => sendIfAlive(getWindow(), channel, payload);
44
+
45
+ function publishStatus(id) {
46
+ const status = client.getStatus(id);
47
+ const key = JSON.stringify(status);
48
+ if (lastStatus.get(id) === key) return status;
49
+ lastStatus.set(id, key);
50
+ toWindow('remoteClient:status', { peerId: id, ...status });
51
+ return status;
52
+ }
53
+
54
+ function ensureForwarding(id) {
55
+ if (unsubs.has(id)) return;
56
+ unsubs.set(id, client.onEvent(id, (ev) => {
57
+ toWindow('remoteClient:event', { peerId: id, type: ev.type, payload: ev.payload });
58
+ }));
59
+ }
60
+
61
+ // remoteClient has no status callback; the socket moves status on its own
62
+ // (pending_sas, failed, idle), so poll for changes on known peers.
63
+ const timer = setInterval(() => {
64
+ for (const p of client.listPeers()) publishStatus(p.peerId);
65
+ }, STATUS_POLL_MS);
66
+ if (typeof timer.unref === 'function') timer.unref();
67
+
68
+ ipcMain.handle('remoteClient:list', () => client.listPeers().map((p) => {
69
+ ensureForwarding(p.peerId);
70
+ return { ...p, ...client.getStatus(p.peerId) };
71
+ }));
72
+
73
+ ipcMain.handle('remoteClient:connect', validated(schemas.peer, async ({ peerId: id }) => {
74
+ ensureForwarding(id);
75
+ const connecting = client.connect(id);
76
+ publishStatus(id);
77
+ const status = await connecting;
78
+ publishStatus(id);
79
+ return status;
80
+ }));
81
+
82
+ ipcMain.handle('remoteClient:confirm-sas', validated(schemas.peer, ({ peerId: id }) => {
83
+ const res = client.confirmSas(id);
84
+ publishStatus(id);
85
+ return res;
86
+ }));
87
+
88
+ ipcMain.handle('remoteClient:remove', validated(schemas.peer, ({ peerId: id }) => {
89
+ client.removePeer(id);
90
+ const off = unsubs.get(id);
91
+ if (off) off();
92
+ unsubs.delete(id);
93
+ lastStatus.delete(id);
94
+ toWindow('remoteClient:status', { peerId: id, status: 'removed', reason: null, sas: null });
95
+ return { ok: true };
96
+ }));
97
+
98
+ ipcMain.handle('remoteClient:send', validated(schemas.send, ({ peerId: id, type, payload }) =>
99
+ client.send(id, type, payload)));
100
+
101
+ ipcMain.handle('remoteClient:auth-status', () => ({ paired: auth.isPaired(), clientId: auth.getClientId() }));
102
+
103
+ ipcMain.handle('remoteClient:pair', validated(schemas.pair, ({ code, clientName }) =>
104
+ auth.pair(code, clientName ?? os.hostname())));
105
+
106
+ ipcMain.handle('remoteClient:unpair', () => {
107
+ auth.clearToken();
108
+ return { ok: true };
109
+ });
110
+
111
+ ipcMain.handle('remoteClient:devices', async () => {
112
+ try {
113
+ return { ok: true, devices: await auth.listDevices() };
114
+ } catch (e) {
115
+ return { ok: false, error: e.message };
116
+ }
117
+ });
118
+
119
+ ipcMain.handle('remoteClient:add', validated(schemas.add, ({ deviceId, machineName }) => {
120
+ const { clientPrivateKey: _omit, ...peer } = client.addPeer({ peerId: deviceId, deviceId, machineName });
121
+ ensureForwarding(deviceId);
122
+ publishStatus(deviceId);
123
+ return peer;
124
+ }));
125
+
126
+ return () => {
127
+ clearInterval(timer);
128
+ for (const off of unsubs.values()) off();
129
+ unsubs.clear();
130
+ };
131
+ }
132
+
133
+ module.exports = { registerRemoteClientHandlers, schemas };
@@ -9856,6 +9856,18 @@ async function superviseAdoptedRunsPass(jobs) {
9856
9856
  }
9857
9857
  }
9858
9858
 
9859
+ // A dead-pid row whose spawnJob() is still in flight (slug in runningSet) is
9860
+ // mid-finalize: integrating its sm-job branch, then writing the terminal row.
9861
+ // Reaping it races that merge (family-frame 137, 2026-10-10). The bound keeps
9862
+ // a wedged spawnJob from shielding its row forever — past it, reap as before.
9863
+ const FINALIZE_OWNERSHIP_MAX_MS = 30 * 60_000;
9864
+ const finalizeOwnedSince = new Map();
9865
+ function spawnJobStillFinalizing(slug, now) {
9866
+ if (!runningSet.has(slug)) return false;
9867
+ if (!finalizeOwnedSince.has(slug)) finalizeOwnedSince.set(slug, now);
9868
+ return now - finalizeOwnedSince.get(slug) < FINALIZE_OWNERSHIP_MAX_MS;
9869
+ }
9870
+
9859
9871
  async function reapDeadRunningJobs() {
9860
9872
  try {
9861
9873
  // Do NOT gate on runningSet: spawnJob()'s finally block unconditionally
@@ -9881,7 +9893,11 @@ async function reapDeadRunningJobs() {
9881
9893
  getLogPid: (j) => readSpawnedPidFromLog(logPathForJob(j)),
9882
9894
  getLogMtimeMs: (j) => readLogMtimeMs(logPathForJob(j)),
9883
9895
  logFreshWindowMs: IDLE_OUTPUT_KILL_MS,
9896
+ finalizeOwned: (j) => spawnJobStillFinalizing(j.slug, Date.now()),
9884
9897
  });
9898
+ for (const slug of finalizeOwnedSince.keys()) {
9899
+ if (!runningSet.has(slug)) finalizeOwnedSince.delete(slug);
9900
+ }
9885
9901
  for (const w of warnings) {
9886
9902
  console.warn(`[scheduler] reapDeadRunningJobs: ${w.reason} slug=${w.slug} — leaving row alone`);
9887
9903
  }