claude-code-session-manager 0.109.0 → 0.111.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 (60) hide show
  1. package/dist/assets/{AgentLibrary-Br4Ispr4.js → AgentLibrary-rBO1daqM.js} +1 -1
  2. package/dist/assets/{Button-C8Mc6MEP.js → Button-DsEYRZg0.js} +1 -1
  3. package/dist/assets/{DataModel-Bz0MypYO.js → DataModel-D8sbvvBC.js} +1 -1
  4. package/dist/assets/{History-DT26Iy38.js → History-BEHLtxia.js} +1 -1
  5. package/dist/assets/{Hooks-e4xUGEf-.js → Hooks-CKpvlXrg.js} +3 -3
  6. package/dist/assets/{Library-B3rzEhvN.js → Library-CeF4qh9d.js} +1 -1
  7. package/dist/assets/{ListDetail-BAvci_aP.js → ListDetail-DcfmxPFk.js} +1 -1
  8. package/dist/assets/{MarkdownEditor-DBWTGgRz.js → MarkdownEditor-CvK1pyto.js} +1 -1
  9. package/dist/assets/{McpServers-C-grBN21.js → McpServers-D-h2ihzJ.js} +2 -2
  10. package/dist/assets/{Memory-BmM0Fz-C.js → Memory-BnF89x8v.js} +1 -1
  11. package/dist/assets/{Panel-Dc6F8wb4.js → Panel-CQY5zano.js} +1 -1
  12. package/dist/assets/{Permissions-m_NnldFh.js → Permissions-DLukmmsz.js} +3 -3
  13. package/dist/assets/{Plugins-BB0TLUC9.js → Plugins-0Pf0OZ4l.js} +2 -2
  14. package/dist/assets/{ProvenanceBadge-k9PH0S2n.js → ProvenanceBadge-vC7Tj-JP.js} +1 -1
  15. package/dist/assets/RemoteMachines-CWnKbFHc.js +3 -0
  16. package/dist/assets/{SaveBar-DSMnbUVI.js → SaveBar-DjijiUb0.js} +1 -1
  17. package/dist/assets/{Scheduler-CbIcJEfM.js → Scheduler-Cm3EXZvE.js} +7 -7
  18. package/dist/assets/{ScopeSwitcher-tHtloWbN.js → ScopeSwitcher-B-P-kSfq.js} +1 -1
  19. package/dist/assets/Settings-DYDlp2m_.js +4 -0
  20. package/dist/assets/{SkillReferenceGraph-Dndl1tzr.js → SkillReferenceGraph-Epv2GJmj.js} +1 -1
  21. package/dist/assets/{Skills-CGnxroik.js → Skills-DHO0N0C2.js} +1 -1
  22. package/dist/assets/{SystemPrompt-B3A3rHNp.js → SystemPrompt-DxeNofL9.js} +1 -1
  23. package/dist/assets/{TagLibrary-k4CTiAYo.js → TagLibrary-QOAs7ISO.js} +1 -1
  24. package/dist/assets/{TiptapBody-C2LfNQXw.js → TiptapBody-DAMO3DhO.js} +1 -1
  25. package/dist/assets/{Toggle-CTXTU1q5.js → Toggle-Ba_f7q_m.js} +1 -1
  26. package/dist/assets/{ViewTabs-CfEfOSE1.js → ViewTabs-CGX6MAhc.js} +1 -1
  27. package/dist/assets/{historyTotals-Db0xy2Fg.js → historyTotals-vgfk8EOL.js} +1 -1
  28. package/dist/assets/index-CPieAFsl.js +3078 -0
  29. package/dist/assets/{index-C8Smiz7z.css → index-Dutcsly-.css} +1 -1
  30. package/dist/assets/{settingsSchema-xxO9p4yF.js → settingsSchema-D0FxNrTy.js} +1 -1
  31. package/dist/assets/{useAgentsDirWatch-DLS1_moj.js → useAgentsDirWatch-DCu6BNYN.js} +1 -1
  32. package/dist/assets/useRemotePeers-D4cdJDyh.js +1 -0
  33. package/dist/index.html +2 -2
  34. package/package.json +2 -1
  35. package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
  36. package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
  37. package/scripts/scheduler-mcp-server.cjs +29 -2
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/chatRunner.cjs +7 -0
  40. package/src/main/index.cjs +6 -0
  41. package/src/main/ipcSchemas.cjs +28 -0
  42. package/src/main/lib/autoUpdater.cjs +190 -0
  43. package/src/main/lib/builtinMacros.cjs +2 -2
  44. package/src/main/lib/crossProjectFeedback.cjs +143 -6
  45. package/src/main/lib/mcpToolCatalog.cjs +12 -3
  46. package/src/main/lib/reaperHelpers.cjs +13 -1
  47. package/src/main/lib/relayClientAuth.cjs +133 -0
  48. package/src/main/lib/remoteClientSessions.cjs +84 -0
  49. package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
  50. package/src/main/lib/updaterSettings.cjs +64 -0
  51. package/src/main/remoteClient.cjs +42 -13
  52. package/src/main/remoteClientIpc.cjs +33 -1
  53. package/src/main/scheduler.cjs +16 -0
  54. package/src/main/updaterIpc.cjs +110 -0
  55. package/src/main/webRemote.cjs +276 -132
  56. package/src/preload/api.d.ts +55 -2
  57. package/src/preload/index.cjs +24 -1
  58. package/src/seed/agents/demo-video-builder.md +62 -19
  59. package/dist/assets/Settings-CW3w7knp.js +0 -6
  60. package/dist/assets/index-fJxuoRvi.js +0 -3077
@@ -0,0 +1,133 @@
1
+ 'use strict';
2
+
3
+ // Desktop credential for acting as a 'browser'-role relay client against
4
+ // bilko.run's sm-relay client endpoints. Pairs with a one-time client code,
5
+ // stores the long-lived client token at 0600, and exchanges it for one-time WS
6
+ // tickets and the owner's device list. No Electron imports.
7
+
8
+ const fs = require('node:fs');
9
+ const https = require('node:https');
10
+ const path = require('node:path');
11
+ const { claudeHome } = require('./schedulerPaths.cjs');
12
+ const { writeJsonAtomicSync, readJsonOrSync } = require('./atomicFs.cjs');
13
+
14
+ const RELAY_CLIENT_API_BASE = 'https://bilko.run/api/sm-relay';
15
+ const REQUEST_TIMEOUT_MS = 15_000;
16
+ const CLIENT_NAME_MAX = 64;
17
+
18
+ /** Default transport: resolves { status, json }; never includes headers in errors. */
19
+ function defaultRequest({ method, url, headers = {}, body }) {
20
+ return new Promise((resolve, reject) => {
21
+ let parsed;
22
+ try { parsed = new URL(url); } catch { reject(new Error('invalid_url')); return; }
23
+ if (parsed.protocol !== 'https:') {
24
+ reject(new Error('Only https:// allowed for relay API calls'));
25
+ return;
26
+ }
27
+ const outHeaders = { ...headers };
28
+ if (body != null) outHeaders['Content-Length'] = Buffer.byteLength(body);
29
+ const req = https.request({
30
+ hostname: parsed.hostname,
31
+ port: parsed.port || 443,
32
+ path: parsed.pathname + (parsed.search || ''),
33
+ method,
34
+ headers: outHeaders,
35
+ rejectUnauthorized: true,
36
+ }, (res) => {
37
+ let data = '';
38
+ res.on('data', (c) => { data += c; });
39
+ res.on('end', () => {
40
+ let json = null;
41
+ try { json = JSON.parse(data); } catch { /* non-JSON body */ }
42
+ resolve({ status: res.statusCode, json });
43
+ });
44
+ });
45
+ req.on('error', (err) => reject(new Error(err.message)));
46
+ req.setTimeout(REQUEST_TIMEOUT_MS, () => { req.destroy(new Error('timeout')); });
47
+ if (body != null) req.write(body);
48
+ req.end();
49
+ });
50
+ }
51
+
52
+ function defaultTokenPath() {
53
+ return path.join(claudeHome(), 'session-manager', 'relay-client.json');
54
+ }
55
+
56
+ function createRelayClientAuth({ tokenPath, request = defaultRequest } = {}) {
57
+ const file = tokenPath || defaultTokenPath();
58
+
59
+ function readStored() {
60
+ const data = readJsonOrSync(file, null);
61
+ return data && typeof data.clientToken === 'string' && data.clientToken ? data : null;
62
+ }
63
+ function clearToken() {
64
+ fs.rmSync(file, { force: true });
65
+ }
66
+ function isPaired() { return readStored() !== null; }
67
+ function getClientId() { return readStored()?.clientId ?? null; }
68
+
69
+ async function pair(code, clientName) {
70
+ const body = { code: String(code ?? '').trim().toUpperCase() };
71
+ const name = typeof clientName === 'string' ? clientName.slice(0, CLIENT_NAME_MAX) : '';
72
+ if (name) body.clientName = name;
73
+ const res = await request({
74
+ method: 'POST',
75
+ url: `${RELAY_CLIENT_API_BASE}/client-pair`,
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify(body),
78
+ });
79
+ const json = res.json || {};
80
+ if (res.status === 404) return { ok: false, error: 'relay_client_auth_unavailable' };
81
+ if (res.status !== 200) return { ok: false, error: json.error || `HTTP ${res.status}` };
82
+ if (!json.clientToken) return { ok: false, error: 'relay_auth_failed' };
83
+ writeJsonAtomicSync(file, {
84
+ clientToken: json.clientToken,
85
+ clientId: json.clientId,
86
+ pairedAt: new Date().toISOString(),
87
+ }, { mode: 0o600 });
88
+ return { ok: true, clientId: json.clientId };
89
+ }
90
+
91
+ /** Authenticated call; maps status codes to the module's error vocabulary. */
92
+ async function authed(method, route, body) {
93
+ const stored = readStored();
94
+ if (!stored) throw new Error('relay_client_unpaired');
95
+ const headers = { Authorization: `Bearer ${stored.clientToken}` };
96
+ if (body != null) headers['Content-Type'] = 'application/json';
97
+ const res = await request({ method, url: `${RELAY_CLIENT_API_BASE}${route}`, headers, body });
98
+ if (res.status === 401) {
99
+ clearToken();
100
+ throw new Error('relay_client_unpaired');
101
+ }
102
+ if (res.status === 404) throw new Error('relay_client_auth_unavailable');
103
+ if (res.status < 200 || res.status >= 300) {
104
+ throw new Error((res.json && res.json.error) || `HTTP ${res.status}`);
105
+ }
106
+ return res.json || {};
107
+ }
108
+
109
+ // Optional peer argument is accepted and ignored (remoteClient passes it).
110
+ async function getBrowserTicket(_peer) {
111
+ const json = await authed('POST', '/client-ticket', '{}');
112
+ if (!json.ticket) throw new Error('relay_auth_failed');
113
+ return json.ticket;
114
+ }
115
+
116
+ async function listDevices() {
117
+ const json = await authed('GET', '/client/devices');
118
+ return Array.isArray(json.devices) ? json.devices : [];
119
+ }
120
+
121
+ async function getDevicePubKey(deviceId) {
122
+ const devices = await listDevices();
123
+ const hit = devices.find((d) => d && d.deviceId === deviceId);
124
+ if (!hit) throw new Error('device_not_found');
125
+ return hit.devicePubKey;
126
+ }
127
+
128
+ return { pair, getBrowserTicket, getDevicePubKey, listDevices, isPaired, getClientId, clearToken };
129
+ }
130
+
131
+ const relayClientAuth = createRelayClientAuth();
132
+
133
+ module.exports = { createRelayClientAuth, relayClientAuth, RELAY_CLIENT_API_BASE };
@@ -0,0 +1,84 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Per-client E2E session registry for the web-remote agent.
5
+ * Pure — no Electron, no I/O — importable in unit tests.
6
+ *
7
+ * One record per connected client, keyed by the relay's `fromConn`. Envelopes
8
+ * without a usable `fromConn` (relay not yet stamping) key to LEGACY_CLIENT_KEY,
9
+ * which preserves single-client behaviour.
10
+ */
11
+
12
+ const { makeState } = require('./e2eStateMachine.cjs')
13
+
14
+ const LEGACY_CLIENT_KEY = 'legacy'
15
+ const MAX_KEY_LEN = 64
16
+
17
+ /**
18
+ * @param {{ fromConn?: unknown } | null | undefined} envelope
19
+ * @returns {string}
20
+ */
21
+ function clientKeyOf(envelope) {
22
+ const k = envelope && envelope.fromConn
23
+ if (typeof k === 'string' && k.length > 0 && k.length <= MAX_KEY_LEN) return k
24
+ return LEGACY_CLIENT_KEY
25
+ }
26
+
27
+ function makeRecord(key, clientId) {
28
+ return {
29
+ key,
30
+ clientId: typeof clientId === 'string' ? clientId : null,
31
+ e2e: makeState(),
32
+ peerPubKey: null,
33
+ deviceId: null,
34
+ ptyStreams: new Map(),
35
+ subscriptions: new Set(),
36
+ lastSessionListJson: null,
37
+ }
38
+ }
39
+
40
+ function createClientSessions() {
41
+ const records = new Map()
42
+
43
+ function teardown(key) {
44
+ const rec = records.get(key)
45
+ if (!rec) return null
46
+ for (const unsub of rec.ptyStreams.values()) {
47
+ try { if (typeof unsub === 'function') unsub() } catch { /* swallow */ }
48
+ }
49
+ rec.ptyStreams.clear()
50
+ rec.subscriptions.clear()
51
+ records.delete(key)
52
+ return rec
53
+ }
54
+
55
+ return {
56
+ clientKeyOf,
57
+ get: (key) => records.get(key) || null,
58
+ getOrCreate(key, { clientId = null } = {}) {
59
+ let rec = records.get(key)
60
+ if (!rec) {
61
+ rec = makeRecord(key, clientId)
62
+ records.set(key, rec)
63
+ } else if (rec.clientId === null && typeof clientId === 'string') {
64
+ rec.clientId = clientId
65
+ }
66
+ return rec
67
+ },
68
+ delete: (key) => records.delete(key),
69
+ keys: () => [...records.keys()],
70
+ values: () => [...records.values()],
71
+ authenticated: () => [...records.values()].filter((r) => r.e2e.state === 'authenticated'),
72
+ teardown,
73
+ teardownAll() {
74
+ let n = 0
75
+ for (const key of [...records.keys()]) {
76
+ if (teardown(key)) n++
77
+ }
78
+ return n
79
+ },
80
+ size: () => records.size,
81
+ }
82
+ }
83
+
84
+ module.exports = { createClientSessions, clientKeyOf, LEGACY_CLIENT_KEY }
@@ -285,6 +285,17 @@ const SHIPPED_PERSONA_SEEDS = deepFreeze({
285
285
  effort: "medium",
286
286
  },
287
287
  },
288
+ {
289
+ commit: "seed-v4",
290
+ bodySha256: "247f5244d376e9842f368955bdb9b6c7c4bfc45821ba44aa6ebf44aa30b5afb1",
291
+ fm: {
292
+ description: "30-second self-contained HTML/JS demo video for Project Home, saved via project_demo_video_write.",
293
+ tools: "Read, Grep, Glob, Bash, Write, Edit",
294
+ title: "Project Home — Demo Video",
295
+ model: "opus",
296
+ effort: "max",
297
+ },
298
+ },
288
299
  ],
289
300
  });
290
301
 
@@ -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 };
@@ -11,17 +11,17 @@ const { claudeHome } = require('./lib/schedulerPaths.cjs');
11
11
  const { writeJsonAtomicSync, readJsonOrSync } = require('./lib/atomicFs.cjs');
12
12
  const { generateE2EKeyPair, deriveSessionKey, deriveSas, encryptBox, decryptBox } = require('./lib/remoteE2eCrypto.cjs');
13
13
  const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
14
+ const { relayClientAuth } = require('./lib/relayClientAuth.cjs');
14
15
 
15
16
  // Hard-coded wss:// — same relay URL as webRemote.cjs, no plaintext downgrade.
16
17
  const RELAY_WSS_URL = 'wss://bilko.run/projects/session-manager/relay';
17
18
  const SEND_TIMEOUT_MS = 15_000;
18
19
  const WS_OPEN = 1;
19
20
 
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
- };
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
25
 
26
26
  function defaultWsFactory(url) {
27
27
  const WebSocket = require('ws');
@@ -32,7 +32,7 @@ function defaultConfigPath() {
32
32
  return path.join(claudeHome(), 'session-manager', 'remote-peers.json');
33
33
  }
34
34
 
35
- function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultWsFactory, configPath } = {}) {
35
+ function createRemoteClient({ relayAuth = relayClientAuth, wsFactory = defaultWsFactory, configPath } = {}) {
36
36
  const file = configPath || defaultConfigPath();
37
37
  /** @type {Map<string, any>} peerId -> runtime state */
38
38
  const runtime = new Map();
@@ -62,7 +62,7 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
62
62
  if (!s) {
63
63
  s = {
64
64
  status: 'idle', reason: null, sas: null, ws: null, sessionKey: null,
65
- devicePubKey: null, pending: new Map(), attempt: 0,
65
+ devicePubKey: null, pending: new Map(), attempt: 0, osRequested: false,
66
66
  };
67
67
  runtime.set(peerId, s);
68
68
  }
@@ -100,6 +100,7 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
100
100
  clientPrivateKey: kp.e2ePrivateKey,
101
101
  clientPublicKey: kp.e2ePublicKey,
102
102
  verifiedDevicePubKey: null,
103
+ os: null,
103
104
  };
104
105
  peers.push(peer);
105
106
  savePeers(peers);
@@ -116,6 +117,21 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
116
117
  return { status: s.status, reason: s.reason, sas: s.sas };
117
118
  }
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
+
119
135
  function rawSend(ws, msg) {
120
136
  ws.send(JSON.stringify(msg));
121
137
  }
@@ -148,7 +164,12 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
148
164
  }
149
165
  if (msg.type === 'error') {
150
166
  const code = msg.code || msg.payload?.code;
151
- if (code === 'token_revoked') fail(peerId, 'token_revoked');
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
+ }
152
173
  return;
153
174
  }
154
175
  if (msg.type === 'auth:ok') {
@@ -171,9 +192,11 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
171
192
  fail(peerId, 'e2e_derivation_failed');
172
193
  return;
173
194
  }
174
- s.status = peer.verifiedDevicePubKey && peer.verifiedDevicePubKey === s.devicePubKey
175
- ? 'authenticated'
176
- : 'pending_sas';
195
+ if (peer.verifiedDevicePubKey && peer.verifiedDevicePubKey === s.devicePubKey) {
196
+ markAuthenticated(peerId, s);
197
+ } else {
198
+ s.status = 'pending_sas';
199
+ }
177
200
  return;
178
201
  }
179
202
  if (msg.type === 'e2e:box') {
@@ -198,6 +221,7 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
198
221
  s.sessionKey = null;
199
222
  s.sas = null;
200
223
  s.reason = null;
224
+ s.osRequested = false;
201
225
  s.status = 'connecting';
202
226
 
203
227
  let ticket;
@@ -230,8 +254,13 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
230
254
  }
231
255
  s.ws = ws;
232
256
  ws.on('message', (data) => { if (s.ws === ws) handleMessage(peerId, s, peer, data); });
233
- ws.on('close', () => {
257
+ ws.on('close', (code) => {
234
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
+ }
235
264
  s.ws = null;
236
265
  s.sessionKey = null;
237
266
  failPending(s, new Error('connection_closed'));
@@ -245,7 +274,7 @@ function createRemoteClient({ relayAuth = defaultRelayAuth, wsFactory = defaultW
245
274
  const s = rt(peerId);
246
275
  if (s.status !== 'pending_sas' || !s.devicePubKey) return { ok: false, reason: 'not_pending_sas' };
247
276
  updatePeer(peerId, { verifiedDevicePubKey: s.devicePubKey });
248
- s.status = 'authenticated';
277
+ markAuthenticated(peerId, s);
249
278
  return { ok: true };
250
279
  }
251
280
 
@@ -7,16 +7,23 @@
7
7
  * and `remoteClient:event`.
8
8
  */
9
9
 
10
+ const os = require('node:os');
10
11
  const { ipcMain } = require('electron');
11
12
  const { z } = require('zod');
12
13
  const { remoteClient } = require('./remoteClient.cjs');
14
+ const { relayClientAuth } = require('./lib/relayClientAuth.cjs');
13
15
  const { ALLOWED_COMMANDS } = require('./ipcSchemas.cjs');
14
16
  const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
15
17
 
16
18
  const STATUS_POLL_MS = 1000;
17
19
 
18
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' });
19
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) }),
20
27
  peer: z.object({ peerId }),
21
28
  send: z.object({
22
29
  peerId,
@@ -29,7 +36,7 @@ function validated(schema, handler) {
29
36
  return (_event, payload) => handler(schema.parse(payload));
30
37
  }
31
38
 
32
- function registerRemoteClientHandlers(getWindow, client = remoteClient) {
39
+ function registerRemoteClientHandlers(getWindow, client = remoteClient, auth = relayClientAuth) {
33
40
  const unsubs = new Map(); // peerId -> unsubscribe for event forwarding
34
41
  const lastStatus = new Map(); // peerId -> serialized status
35
42
 
@@ -91,6 +98,31 @@ function registerRemoteClientHandlers(getWindow, client = remoteClient) {
91
98
  ipcMain.handle('remoteClient:send', validated(schemas.send, ({ peerId: id, type, payload }) =>
92
99
  client.send(id, type, payload)));
93
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
+
94
126
  return () => {
95
127
  clearInterval(timer);
96
128
  for (const off of unsubs.values()) off();
@@ -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
  }
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * updaterIpc.cjs — wires the autoUpdater.cjs state machine to the real electron-updater instance, the
5
+ * persisted toggle (updaterSettings) and a busy signal (sessionSlots + live PTYs), and registers the
6
+ * `updater:*` IPC handlers. Local renderer only: no remote/relay command (ipcSchemas ALLOWED_COMMANDS)
7
+ * reaches the updater, and no handler accepts a URL, feed or provider — the feed is the app-update.yml
8
+ * electron-builder bakes in.
9
+ */
10
+
11
+ const { z } = require('zod');
12
+ const { isAutoUpdateSupported, createAutoUpdater } = require('./lib/autoUpdater.cjs');
13
+ const { sendIfAlive } = require('./lib/sendToRenderer.cjs');
14
+
15
+ const schemas = {
16
+ install: z.object({ force: z.boolean() }).strict(),
17
+ setEnabled: z.object({ enabled: z.boolean() }).strict(),
18
+ };
19
+
20
+ function validated(schema, handler) {
21
+ return (_event, payload) => handler(schema.parse(payload));
22
+ }
23
+
24
+ function defaultDeps() {
25
+ return {
26
+ loadUpdater: () => require('electron-updater').autoUpdater,
27
+ isPackaged: () => require('./lib/appRuntime.cjs').isPackagedApp(),
28
+ platform: process.platform,
29
+ env: process.env,
30
+ settings: require('./lib/updaterSettings.cjs'),
31
+ sessionSlots: require('./lib/sessionSlots.cjs'),
32
+ currentVersion: () => require('electron').app.getVersion(),
33
+ log: (message, meta) => {
34
+ try {
35
+ require('./logs.cjs').writeLine({ scope: 'updater', level: 'info', message, meta });
36
+ } catch { /* logging must never break the updater */ }
37
+ },
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @param {{ ipcMain: any, getWindow: () => any, ptyManager: any, deps?: object }} opts
43
+ * @returns {{ updater: object, ready: Promise<void> }}
44
+ */
45
+ function registerUpdaterIpc({ ipcMain, getWindow, ptyManager, deps = {} }) {
46
+ const d = { ...defaultDeps(), ...deps };
47
+ const supported = isAutoUpdateSupported({ packaged: d.isPackaged() === true, platform: d.platform, env: d.env });
48
+
49
+ // electron-updater is only ever loaded for a supported build — an unpackaged/npx run never touches it.
50
+ let realUpdater = null;
51
+ if (supported) {
52
+ realUpdater = d.loadUpdater();
53
+ if (realUpdater) realUpdater.logger = null;
54
+ }
55
+
56
+ let enabled = true;
57
+ const isBusy = () => {
58
+ const jobs = d.sessionSlots.inUse();
59
+ const terminals = ptyManager && ptyManager.sessions ? ptyManager.sessions.size : 0;
60
+ return { busy: jobs > 0 || terminals > 0, reason: `${jobs} job(s), ${terminals} terminal(s) running` };
61
+ };
62
+ const currentVersion = typeof d.currentVersion === 'function' ? d.currentVersion() : d.currentVersion;
63
+
64
+ const updater = createAutoUpdater({
65
+ updater: realUpdater,
66
+ supported,
67
+ currentVersion,
68
+ isBusy,
69
+ getEnabled: () => enabled,
70
+ log: d.log,
71
+ });
72
+
73
+ const snapshot = () => ({ ...updater.status(), enabled });
74
+ updater.subscribe(() => sendIfAlive(getWindow(), 'updater:status', snapshot()));
75
+
76
+ ipcMain.handle('updater:status', () => snapshot());
77
+ ipcMain.handle('updater:check', async () => {
78
+ await updater.checkNow();
79
+ return snapshot();
80
+ });
81
+ ipcMain.handle('updater:install', validated(schemas.install, ({ force }) => {
82
+ updater.requestInstall({ force });
83
+ return snapshot();
84
+ }));
85
+ ipcMain.handle('updater:cancel-deferred', () => {
86
+ updater.cancelDeferred();
87
+ return snapshot();
88
+ });
89
+ ipcMain.handle('updater:set-enabled', validated(schemas.setEnabled, async ({ enabled: next }) => {
90
+ await d.settings.save({ enabled: next });
91
+ enabled = next;
92
+ updater.setEnabled(next);
93
+ sendIfAlive(getWindow(), 'updater:status', snapshot());
94
+ return snapshot();
95
+ }));
96
+
97
+ const ready = (async () => {
98
+ try {
99
+ const cfg = await d.settings.load();
100
+ enabled = cfg.enabled !== false;
101
+ if (enabled) updater.start();
102
+ } catch (e) {
103
+ d.log('updater start failed', { error: e && e.message });
104
+ }
105
+ })();
106
+
107
+ return { updater, ready };
108
+ }
109
+
110
+ module.exports = { registerUpdaterIpc };