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.
- package/dist/assets/{AgentLibrary-Br4Ispr4.js → AgentLibrary-rBO1daqM.js} +1 -1
- package/dist/assets/{Button-C8Mc6MEP.js → Button-DsEYRZg0.js} +1 -1
- package/dist/assets/{DataModel-Bz0MypYO.js → DataModel-D8sbvvBC.js} +1 -1
- package/dist/assets/{History-DT26Iy38.js → History-BEHLtxia.js} +1 -1
- package/dist/assets/{Hooks-e4xUGEf-.js → Hooks-CKpvlXrg.js} +3 -3
- package/dist/assets/{Library-B3rzEhvN.js → Library-CeF4qh9d.js} +1 -1
- package/dist/assets/{ListDetail-BAvci_aP.js → ListDetail-DcfmxPFk.js} +1 -1
- package/dist/assets/{MarkdownEditor-DBWTGgRz.js → MarkdownEditor-CvK1pyto.js} +1 -1
- package/dist/assets/{McpServers-C-grBN21.js → McpServers-D-h2ihzJ.js} +2 -2
- package/dist/assets/{Memory-BmM0Fz-C.js → Memory-BnF89x8v.js} +1 -1
- package/dist/assets/{Panel-Dc6F8wb4.js → Panel-CQY5zano.js} +1 -1
- package/dist/assets/{Permissions-m_NnldFh.js → Permissions-DLukmmsz.js} +3 -3
- package/dist/assets/{Plugins-BB0TLUC9.js → Plugins-0Pf0OZ4l.js} +2 -2
- package/dist/assets/{ProvenanceBadge-k9PH0S2n.js → ProvenanceBadge-vC7Tj-JP.js} +1 -1
- package/dist/assets/RemoteMachines-CWnKbFHc.js +3 -0
- package/dist/assets/{SaveBar-DSMnbUVI.js → SaveBar-DjijiUb0.js} +1 -1
- package/dist/assets/{Scheduler-CbIcJEfM.js → Scheduler-Cm3EXZvE.js} +7 -7
- package/dist/assets/{ScopeSwitcher-tHtloWbN.js → ScopeSwitcher-B-P-kSfq.js} +1 -1
- package/dist/assets/Settings-DYDlp2m_.js +4 -0
- package/dist/assets/{SkillReferenceGraph-Dndl1tzr.js → SkillReferenceGraph-Epv2GJmj.js} +1 -1
- package/dist/assets/{Skills-CGnxroik.js → Skills-DHO0N0C2.js} +1 -1
- package/dist/assets/{SystemPrompt-B3A3rHNp.js → SystemPrompt-DxeNofL9.js} +1 -1
- package/dist/assets/{TagLibrary-k4CTiAYo.js → TagLibrary-QOAs7ISO.js} +1 -1
- package/dist/assets/{TiptapBody-C2LfNQXw.js → TiptapBody-DAMO3DhO.js} +1 -1
- package/dist/assets/{Toggle-CTXTU1q5.js → Toggle-Ba_f7q_m.js} +1 -1
- package/dist/assets/{ViewTabs-CfEfOSE1.js → ViewTabs-CGX6MAhc.js} +1 -1
- package/dist/assets/{historyTotals-Db0xy2Fg.js → historyTotals-vgfk8EOL.js} +1 -1
- package/dist/assets/index-CPieAFsl.js +3078 -0
- package/dist/assets/{index-C8Smiz7z.css → index-Dutcsly-.css} +1 -1
- package/dist/assets/{settingsSchema-xxO9p4yF.js → settingsSchema-D0FxNrTy.js} +1 -1
- package/dist/assets/{useAgentsDirWatch-DLS1_moj.js → useAgentsDirWatch-DCu6BNYN.js} +1 -1
- package/dist/assets/useRemotePeers-D4cdJDyh.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/plugins/session-manager-dev/skills/develop/SKILL.md +3 -1
- package/plugins/session-manager-dev/skills/send-feedback/SKILL.md +48 -15
- package/scripts/scheduler-mcp-server.cjs +29 -2
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +7 -0
- package/src/main/index.cjs +6 -0
- package/src/main/ipcSchemas.cjs +28 -0
- package/src/main/lib/autoUpdater.cjs +190 -0
- package/src/main/lib/builtinMacros.cjs +2 -2
- package/src/main/lib/crossProjectFeedback.cjs +143 -6
- package/src/main/lib/mcpToolCatalog.cjs +12 -3
- package/src/main/lib/reaperHelpers.cjs +13 -1
- package/src/main/lib/relayClientAuth.cjs +133 -0
- package/src/main/lib/remoteClientSessions.cjs +84 -0
- package/src/main/lib/shippedPersonaSeeds.cjs +11 -0
- package/src/main/lib/updaterSettings.cjs +64 -0
- package/src/main/remoteClient.cjs +42 -13
- package/src/main/remoteClientIpc.cjs +33 -1
- package/src/main/scheduler.cjs +16 -0
- package/src/main/updaterIpc.cjs +110 -0
- package/src/main/webRemote.cjs +276 -132
- package/src/preload/api.d.ts +55 -2
- package/src/preload/index.cjs +24 -1
- package/src/seed/agents/demo-video-builder.md +62 -19
- package/dist/assets/Settings-CW3w7knp.js +0 -6
- 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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
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 =
|
|
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')
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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();
|
package/src/main/scheduler.cjs
CHANGED
|
@@ -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 };
|