@hakimedes/dsh-easyremote 0.2.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/android.js +50 -0
  4. package/dist/android.js.map +1 -0
  5. package/dist/autostart.js +104 -0
  6. package/dist/autostart.js.map +1 -0
  7. package/dist/cli-views.js +32 -0
  8. package/dist/cli-views.js.map +1 -0
  9. package/dist/cli.js +584 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/cloudflared.js +54 -0
  12. package/dist/cloudflared.js.map +1 -0
  13. package/dist/command-router.js +12 -0
  14. package/dist/command-router.js.map +1 -0
  15. package/dist/connector-install.js +42 -0
  16. package/dist/connector-install.js.map +1 -0
  17. package/dist/controller.js +206 -0
  18. package/dist/controller.js.map +1 -0
  19. package/dist/doctor.js +46 -0
  20. package/dist/doctor.js.map +1 -0
  21. package/dist/domain.js +33 -0
  22. package/dist/domain.js.map +1 -0
  23. package/dist/download.js +59 -0
  24. package/dist/download.js.map +1 -0
  25. package/dist/index.js +16 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/install-state.js +48 -0
  28. package/dist/install-state.js.map +1 -0
  29. package/dist/local-runtime.js +115 -0
  30. package/dist/local-runtime.js.map +1 -0
  31. package/dist/maintenance.js +58 -0
  32. package/dist/maintenance.js.map +1 -0
  33. package/dist/named-tunnel.js +49 -0
  34. package/dist/named-tunnel.js.map +1 -0
  35. package/dist/runtime.js +110 -0
  36. package/dist/runtime.js.map +1 -0
  37. package/dist/setup-progress.js +23 -0
  38. package/dist/setup-progress.js.map +1 -0
  39. package/dist/supervisor.js +81 -0
  40. package/dist/supervisor.js.map +1 -0
  41. package/dist/wizard.js +251 -0
  42. package/dist/wizard.js.map +1 -0
  43. package/package.json +56 -0
  44. package/runtime/connector/README.md +52 -0
  45. package/runtime/connector/cordis.patch.yml +3 -0
  46. package/runtime/connector/dsh.plugin.json +16 -0
  47. package/runtime/connector/lib/client.js +262 -0
  48. package/runtime/connector/lib/command-cache.d.ts +7 -0
  49. package/runtime/connector/lib/command-cache.js +37 -0
  50. package/runtime/connector/lib/command-cache.js.map +1 -0
  51. package/runtime/connector/lib/connector-config.d.ts +20 -0
  52. package/runtime/connector/lib/connector-config.js +74 -0
  53. package/runtime/connector/lib/connector-config.js.map +1 -0
  54. package/runtime/connector/lib/dsh-api.d.ts +38 -0
  55. package/runtime/connector/lib/dsh-api.js +76 -0
  56. package/runtime/connector/lib/dsh-api.js.map +1 -0
  57. package/runtime/connector/lib/index.d.ts +4 -0
  58. package/runtime/connector/lib/index.js +913 -0
  59. package/runtime/connector/lib/index.js.map +1 -0
  60. package/runtime/connector/lib/protocol.d.ts +15 -0
  61. package/runtime/connector/lib/protocol.js +96 -0
  62. package/runtime/connector/lib/protocol.js.map +1 -0
  63. package/runtime/connector/package.json +44 -0
  64. package/runtime/hub/database.js +239 -0
  65. package/runtime/hub/database.js.map +1 -0
  66. package/runtime/hub/index.js +1804 -0
  67. package/runtime/hub/index.js.map +1 -0
  68. package/runtime/hub/schema.js +111 -0
  69. package/runtime/hub/schema.js.map +1 -0
@@ -0,0 +1,913 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
3
+ import { hostname, homedir, platform, arch } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import QRCode from 'qrcode';
6
+ import { v7 as uuidv7 } from 'uuid';
7
+ import WebSocket from 'ws';
8
+ import { CommandReplayCache } from './command-cache.js';
9
+ import { connectorConfigPath, loadConnectorConfig, watchConnectorConfig } from './connector-config.js';
10
+ import { DshApiBridge, toRemoteSessionSummary } from './dsh-api.js';
11
+ import { normalizeDshEvent } from './protocol.js';
12
+ export const name = 'dsh-easyremote-connector';
13
+ export const inject = ['webServer', 'agents', 'sessionQuery', 'agentDefaultModel', 'approval', 'apiProxy'];
14
+ const PROTOCOL_VERSION = 1;
15
+ const PLUGIN_VERSION = '0.2.0';
16
+ const HEARTBEAT_MS = 15_000;
17
+ const APPROVAL_TTL_MS = 10 * 60_000;
18
+ const PAIR_POLL_MS = 800;
19
+ /** Longest the pair page / pair-data waits for a fresh QR before answering "preparing". */
20
+ const PAIR_SNAPSHOT_WAIT_MS = 2_500;
21
+ function isRecord(value) {
22
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
23
+ }
24
+ function sha256(value) {
25
+ return createHash('sha256').update(value).digest('hex');
26
+ }
27
+ function wsUrl(hubUrl) {
28
+ const url = new URL(hubUrl);
29
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
30
+ url.pathname = '/v1/node/connect';
31
+ url.search = '';
32
+ return url.toString();
33
+ }
34
+ export function shouldRotateRecoveryAfterReconnect(endpointChanged, nodeId) {
35
+ return endpointChanged && Boolean(nodeId);
36
+ }
37
+ function html(value) {
38
+ return value
39
+ .replaceAll('&', '&')
40
+ .replaceAll('<', '&lt;')
41
+ .replaceAll('>', '&gt;')
42
+ .replaceAll('"', '&quot;');
43
+ }
44
+ /**
45
+ * Client-side enhancement for the standalone pair page: polls pair-data so a
46
+ * fresh QR appears without a manual reload, a claimed pairing flips the page
47
+ * to its connected state, and an unreachable Hub shows the last error instead
48
+ * of a spinner. The server-rendered markup stays complete on its own.
49
+ */
50
+ const PAIR_PAGE_SCRIPT = `<script>
51
+ (function () {
52
+ 'use strict';
53
+ var data = null;
54
+ function el(id) { return document.getElementById(id); }
55
+ function setText(id, text) { var node = el(id); if (node && node.textContent !== text) node.textContent = text; }
56
+ function countdown(now) {
57
+ if (!data || !data.pairingExpiresAt || !data.qrSvg) { setText('c', ''); return; }
58
+ var remaining = Math.max(0, data.pairingExpiresAt - now);
59
+ var m = Math.floor(remaining / 60000);
60
+ var s = Math.floor((remaining % 60000) / 1000);
61
+ setText('c', m + ':' + String(s).padStart(2, '0'));
62
+ }
63
+ function render(now) {
64
+ if (!data) return;
65
+ var qr = el('q');
66
+ var form = el('rc');
67
+ var pill = el('s');
68
+ if (pill) pill.textContent = String(data.status || '');
69
+ if (pill) pill.className = 'pill ' + String(data.status || '');
70
+ var title = data.nodeId ? 'DSH Remote connected'
71
+ : data.qrSvg ? (data.recovering ? 'Reconnect DSH Mobile' : 'Scan with DSH Mobile')
72
+ : (data.error ? 'Hub unreachable' : 'Preparing pairing QR…');
73
+ setText('t', title);
74
+ document.title = title;
75
+ if (qr) {
76
+ if (data.qrSvg) {
77
+ if (qr.dataset.svg !== '1') { qr.innerHTML = data.qrSvg; qr.dataset.svg = '1'; }
78
+ } else { qr.innerHTML = ''; qr.dataset.svg = ''; }
79
+ }
80
+ var hint = data.nodeId ? 'Remote access is active. Use Nodes in the mobile app to revoke it.'
81
+ : data.qrSvg ? (data.recovering ? 'Scan this one-time QR to restore access on a phone that was previously paired.' : 'The QR is one-time and expires after five minutes. This page refreshes itself.')
82
+ : (data.error ? 'Will keep retrying in the background. Last error: ' + data.error : 'Waiting for the Hub…');
83
+ setText('hint', hint);
84
+ if (form) form.hidden = !(data.nodeId && !data.qrSvg);
85
+ countdown(now);
86
+ }
87
+ async function tick() {
88
+ try {
89
+ var res = await fetch('/__dsh_remote_v1/pair-data', { cache: 'no-store' });
90
+ var next = await res.json();
91
+ if (next && next.ok) { data = next; render(Date.now()); }
92
+ } catch (error) { /* keep last state; next tick retries */ }
93
+ }
94
+ tick();
95
+ setInterval(tick, 4000);
96
+ setInterval(function () { render(Date.now()); }, 1000);
97
+ })();
98
+ </script>`;
99
+ function errorCode(error) {
100
+ if (isRecord(error) && typeof error.code === 'string' && error.code)
101
+ return error.code;
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ if (message.includes('not found') || message.includes('missing'))
104
+ return 'SESSION_NOT_FOUND';
105
+ if (message.includes('expired'))
106
+ return 'COMMAND_EXPIRED';
107
+ return 'INTERNAL_ERROR';
108
+ }
109
+ function requestBody(req, maxBytes = 64 * 1024) {
110
+ return new Promise((resolveBody, rejectBody) => {
111
+ let text = '';
112
+ req.setEncoding('utf8');
113
+ req.on('data', (chunk) => {
114
+ text += chunk;
115
+ if (text.length > maxBytes)
116
+ rejectBody(new Error('request body too large'));
117
+ });
118
+ req.on('end', () => {
119
+ if (!text)
120
+ return resolveBody({});
121
+ try {
122
+ const parsed = JSON.parse(text);
123
+ resolveBody(isRecord(parsed) ? parsed : {});
124
+ }
125
+ catch (error) {
126
+ rejectBody(error);
127
+ }
128
+ });
129
+ req.on('error', rejectBody);
130
+ });
131
+ }
132
+ class HubConnector {
133
+ ctx;
134
+ hubUrl;
135
+ hubWsUrl;
136
+ nodeName;
137
+ defaultCwd;
138
+ dshVersion;
139
+ configPath;
140
+ configWatchDisposer = null;
141
+ identityPath;
142
+ identity;
143
+ pairing = null;
144
+ socket = null;
145
+ connectionStatus = 'starting';
146
+ lastPairingError = null;
147
+ disposed = false;
148
+ pairingTask = null;
149
+ recoveryCreateTask = null;
150
+ rotateRecoveryOnAck = false;
151
+ reconnectTimer = null;
152
+ heartbeatTimer = null;
153
+ reconnectAttempt = 0;
154
+ routeDisposers = [];
155
+ ownedAgentHandles = new Map();
156
+ pendingApprovals = new Map();
157
+ commandCache = new CommandReplayCache(500);
158
+ toolNamesBySession = new Map();
159
+ agents;
160
+ sessionQuery;
161
+ agentDefaultModel;
162
+ dshApi;
163
+ webServer;
164
+ logger;
165
+ constructor(ctx) {
166
+ this.ctx = ctx;
167
+ this.configPath = connectorConfigPath();
168
+ const config = loadConnectorConfig({ path: this.configPath, fallbackNodeName: hostname() });
169
+ this.hubUrl = config.hubUrl;
170
+ this.hubWsUrl = wsUrl(this.hubUrl);
171
+ this.nodeName = config.nodeName;
172
+ this.defaultCwd = config.defaultCwd;
173
+ this.dshVersion = process.env.DSH_VERSION || '0.1.0-rc.6';
174
+ const dshHome = process.env.DSH_HOME || join(homedir(), '.dsh');
175
+ this.identityPath = join(dshHome, 'remote-hub', 'node-identity.json');
176
+ this.identity = this.loadIdentity();
177
+ this.agents = ctx.get('agents');
178
+ this.sessionQuery = ctx.get('sessionQuery');
179
+ this.agentDefaultModel = ctx.get('agentDefaultModel');
180
+ this.dshApi = new DshApiBridge(ctx.get('apiProxy'), () => uuidv7());
181
+ this.webServer = ctx.get('webServer');
182
+ this.logger = ctx.logger || console;
183
+ }
184
+ start() {
185
+ if (!this.agents || !this.sessionQuery) {
186
+ throw new Error('DSH Remote requires agents and sessionQuery services');
187
+ }
188
+ this.registerRoutes();
189
+ this.configWatchDisposer = watchConnectorConfig(this.configPath, () => this.reloadConnectorConfig(), (error) => this.logger.warn(`[dsh-easyremote] config watch failed: ${String(error)}`));
190
+ this.ctx.on('session/event', (session, event) => {
191
+ this.forwardSessionEvent(session, event);
192
+ });
193
+ if (this.ctx.get('approval')) {
194
+ this.ctx.on('approval/request', (req, next) => {
195
+ return this.requestRemoteApproval(req, next);
196
+ }, { prepend: true });
197
+ }
198
+ if (this.identity.nodeId)
199
+ this.connect();
200
+ else
201
+ void this.ensurePairing();
202
+ this.logger.info(`[dsh-easyremote] Hub connector active; scan /__dsh_remote_v1/pair in DSH Web`);
203
+ }
204
+ async dispose() {
205
+ this.disposed = true;
206
+ if (this.reconnectTimer)
207
+ clearTimeout(this.reconnectTimer);
208
+ if (this.heartbeatTimer)
209
+ clearInterval(this.heartbeatTimer);
210
+ this.configWatchDisposer?.();
211
+ this.configWatchDisposer = null;
212
+ this.socket?.close(1000, 'plugin disposed');
213
+ this.socket = null;
214
+ for (const approval of [...this.pendingApprovals.values()])
215
+ approval.settle('cancelled');
216
+ for (const dispose of this.routeDisposers) {
217
+ try {
218
+ dispose();
219
+ }
220
+ catch { }
221
+ }
222
+ await Promise.allSettled([...this.ownedAgentHandles.values()].map((handle) => handle.dispose()));
223
+ this.ownedAgentHandles.clear();
224
+ }
225
+ reloadConnectorConfig() {
226
+ let next;
227
+ try {
228
+ next = loadConnectorConfig({ path: this.configPath, fallbackNodeName: hostname() });
229
+ }
230
+ catch (error) {
231
+ this.logger.warn(`[dsh-easyremote] ignoring invalid connector config: ${error instanceof Error ? error.message : String(error)}`);
232
+ return;
233
+ }
234
+ const endpointChanged = next.hubUrl !== this.hubUrl;
235
+ const nodeNameChanged = next.nodeName !== this.nodeName;
236
+ this.hubUrl = next.hubUrl;
237
+ this.hubWsUrl = wsUrl(next.hubUrl);
238
+ this.nodeName = next.nodeName;
239
+ this.defaultCwd = next.defaultCwd;
240
+ if (!endpointChanged && !nodeNameChanged)
241
+ return;
242
+ if (shouldRotateRecoveryAfterReconnect(endpointChanged, this.identity.nodeId)) {
243
+ this.rotateRecoveryOnAck = true;
244
+ }
245
+ this.lastPairingError = null;
246
+ this.pairing = null;
247
+ this.reconnectAttempt = 0;
248
+ if (this.reconnectTimer)
249
+ clearTimeout(this.reconnectTimer);
250
+ this.reconnectTimer = null;
251
+ if (this.heartbeatTimer)
252
+ clearInterval(this.heartbeatTimer);
253
+ this.heartbeatTimer = null;
254
+ const staleSocket = this.socket;
255
+ this.socket = null;
256
+ staleSocket?.close(1000, 'connector config changed');
257
+ this.logger.info(`[dsh-easyremote] connector config updated; reconnecting to ${this.hubUrl}`);
258
+ if (this.identity.nodeId)
259
+ this.connect();
260
+ else
261
+ void this.ensurePairing();
262
+ }
263
+ loadIdentity() {
264
+ try {
265
+ if (existsSync(this.identityPath)) {
266
+ const parsed = JSON.parse(readFileSync(this.identityPath, 'utf8'));
267
+ if (parsed.installId && /^[a-f0-9]{64}$/i.test(parsed.nodeSecret))
268
+ return parsed;
269
+ }
270
+ }
271
+ catch { }
272
+ const identity = { installId: uuidv7(), nodeSecret: randomBytes(32).toString('hex') };
273
+ this.saveIdentity(identity);
274
+ return identity;
275
+ }
276
+ saveIdentity(identity = this.identity) {
277
+ mkdirSync(dirname(this.identityPath), { recursive: true, mode: 0o700 });
278
+ const tempPath = `${this.identityPath}.${process.pid}.tmp`;
279
+ writeFileSync(tempPath, `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
280
+ renameSync(tempPath, this.identityPath);
281
+ chmodSync(this.identityPath, 0o600);
282
+ }
283
+ registerRoutes() {
284
+ if (!this.webServer)
285
+ return;
286
+ const safeRegister = (path, handler) => {
287
+ try {
288
+ const dispose = this.webServer.register({ kind: 'exact', path, handler });
289
+ if (typeof dispose === 'function')
290
+ this.routeDisposers.push(dispose);
291
+ }
292
+ catch (error) {
293
+ this.logger.warn(`[dsh-remote] route ${path} unavailable: ${String(error)}`);
294
+ }
295
+ };
296
+ safeRegister('/__dsh_remote_v1/status', (_req, res) => {
297
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
298
+ res.end(JSON.stringify({
299
+ ok: true,
300
+ status: this.connectionStatus,
301
+ hub: this.hubUrl,
302
+ nodeName: this.nodeName,
303
+ nodeId: this.identity.nodeId ?? null,
304
+ pairingExpiresAt: this.pairing?.expiresAt ?? null,
305
+ error: this.lastPairingError,
306
+ pairPage: '/__dsh_remote_v1/pair',
307
+ }));
308
+ });
309
+ safeRegister('/__dsh_remote_v1/pair-data', async (_req, res) => {
310
+ const snapshot = await this.pairSnapshot();
311
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
312
+ res.end(JSON.stringify(snapshot));
313
+ });
314
+ safeRegister('/__dsh_remote_v1/pair', async (_req, res) => {
315
+ const data = await this.pairSnapshot();
316
+ const qr = data.qrSvg;
317
+ const recovering = Boolean(data.nodeId && qr);
318
+ const title = recovering
319
+ ? 'Reconnect DSH Mobile'
320
+ : data.nodeId
321
+ ? 'DSH Remote connected'
322
+ : qr
323
+ ? 'Scan with DSH Mobile'
324
+ : data.error
325
+ ? 'Hub unreachable'
326
+ : 'Preparing pairing QR…';
327
+ const hint = recovering
328
+ ? 'Scan this one-time QR to restore access on a phone that was previously paired.'
329
+ : data.nodeId
330
+ ? 'Remote access is active. Use Nodes in the mobile app to revoke it.'
331
+ : data.error
332
+ ? `Will keep retrying in the background. Last error: ${data.error}`
333
+ : 'The QR is one-time and expires after five minutes. This page refreshes itself.';
334
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
335
+ res.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><title>${html(title)}</title><style>body{font-family:system-ui;background:#0d1117;color:#f4f5f7;margin:0;min-height:100vh;display:grid;place-items:center}.card{max-width:420px;text-align:center;padding:28px;border:1px solid #30363d;border-radius:18px;background:#161b22}.qr{background:white;padding:14px;border-radius:14px;line-height:0;margin:20px auto;max-width:300px;min-height:120px;display:flex;align-items:center;justify-content:center}.pill{display:inline-block;border-radius:999px;padding:3px 12px;font-size:12px;font-weight:700;background:#21262d;color:#9da7b3}.pill.online{background:#0f3517;color:#4ade80}.pill.pairing,.pill.connecting{background:#33270f;color:#fbbf24}p{color:#9da7b3;line-height:1.5}code{font-size:12px}button{border:0;border-radius:12px;background:#4d6bfe;color:white;font:600 16px system-ui;padding:12px 18px;cursor:pointer}.count{font-size:13px;color:#6e7681}</style><main class="card"><h1 id="t">${html(title)}</h1><span class="pill" id="s">${html(data.status)}</span>${qr ? `<div class="qr" id="q">${qr}</div>` : '<div class="qr" id="q"></div>'}<p id="n">${html(this.nodeName)}</p><p><code id="h">${html(this.hubUrl)}</code></p><p id="hint">${html(hint)}</p><p class="count" id="c"></p><form id="rc" method="post" action="/__dsh_remote_v1/recover"${data.nodeId && !qr ? '' : ' hidden'}><button type="submit">Reconnect mobile</button></form></main>${PAIR_PAGE_SCRIPT}`);
336
+ });
337
+ safeRegister('/__dsh_remote_v1/recover', async (req, res) => {
338
+ if (req.method !== 'POST') {
339
+ res.writeHead(405, { allow: 'POST', 'content-type': 'text/plain; charset=utf-8' });
340
+ res.end('Method not allowed');
341
+ return;
342
+ }
343
+ if (!this.identity.nodeId) {
344
+ res.writeHead(409, { 'content-type': 'text/plain; charset=utf-8' });
345
+ res.end('Node is not connected');
346
+ return;
347
+ }
348
+ try {
349
+ await this.ensureRecoveryPairing();
350
+ res.writeHead(303, { location: '/__dsh_remote_v1/pair', 'cache-control': 'no-store' });
351
+ res.end();
352
+ }
353
+ catch (error) {
354
+ this.logger.warn(`[dsh-remote] mobile recovery unavailable: ${error instanceof Error ? error.message : String(error)}`);
355
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
356
+ res.end('Could not create a mobile recovery QR');
357
+ }
358
+ });
359
+ }
360
+ async fetchJson(path, init = {}) {
361
+ const headers = new Headers(init.headers);
362
+ headers.set('content-type', 'application/json');
363
+ const response = await fetch(`${this.hubUrl}${path}`, { ...init, headers });
364
+ if (!response.ok)
365
+ throw new Error(`${response.status} ${await response.text()}`);
366
+ return response.json();
367
+ }
368
+ ensurePairing() {
369
+ if (this.pairingTask)
370
+ return this.pairingTask;
371
+ this.pairingTask = this.runPairing().finally(() => {
372
+ this.pairingTask = null;
373
+ });
374
+ return this.pairingTask;
375
+ }
376
+ /**
377
+ * Current pairing state for UI consumers (pair page, pair-data JSON, the
378
+ * web Settings section). Never awaits a full pairing loop: an unreachable
379
+ * Hub answers quickly with status + last error instead of hanging the
380
+ * request. Expired QRs are dropped so callers never render a dead code.
381
+ */
382
+ async pairSnapshot() {
383
+ if (!this.identity.nodeId) {
384
+ if (this.pairing && this.pairing.expiresAt <= Date.now())
385
+ this.pairing = null;
386
+ if (!this.pairing) {
387
+ const task = this.ensurePairing();
388
+ await Promise.race([
389
+ task,
390
+ new Promise((resolveWait) => setTimeout(resolveWait, PAIR_SNAPSHOT_WAIT_MS)),
391
+ ]);
392
+ }
393
+ }
394
+ const fresh = this.pairing && this.pairing.expiresAt > Date.now() ? this.pairing : null;
395
+ const qrSvg = fresh ? await QRCode.toString(fresh.qrPayload, { type: 'svg', margin: 1, width: 280 }) : '';
396
+ return {
397
+ ok: true,
398
+ status: this.connectionStatus,
399
+ hub: this.hubUrl,
400
+ nodeName: this.nodeName,
401
+ nodeId: this.identity.nodeId ?? null,
402
+ recovering: Boolean(this.identity.nodeId && fresh),
403
+ qrSvg,
404
+ pairingExpiresAt: fresh?.expiresAt ?? null,
405
+ error: this.lastPairingError,
406
+ };
407
+ }
408
+ ensureRecoveryPairing() {
409
+ if (this.pairing && this.pairing.expiresAt > Date.now())
410
+ return Promise.resolve();
411
+ if (this.recoveryCreateTask)
412
+ return this.recoveryCreateTask;
413
+ this.recoveryCreateTask = this.createRecoveryPairing().finally(() => {
414
+ this.recoveryCreateTask = null;
415
+ });
416
+ return this.recoveryCreateTask;
417
+ }
418
+ async createRecoveryPairing() {
419
+ const nodeId = this.identity.nodeId;
420
+ if (!nodeId)
421
+ throw new Error('Node is not connected');
422
+ const created = await this.fetchJson('/v1/node-pairings/recover', {
423
+ method: 'POST',
424
+ body: JSON.stringify({}),
425
+ headers: { authorization: `Node ${nodeId}.${this.identity.nodeSecret}` },
426
+ });
427
+ this.pairing = created;
428
+ void this.pollRecoveryPairing(created);
429
+ }
430
+ async pollRecoveryPairing(created) {
431
+ try {
432
+ while (!this.disposed && Date.now() < created.expiresAt) {
433
+ const polled = await this.fetchJson(`/v1/node-pairings/${encodeURIComponent(created.pairingId)}`, { headers: { authorization: `Pair ${created.pollToken}` } });
434
+ if (polled.status === 'claimed' || polled.status === 'expired')
435
+ return;
436
+ await new Promise((resolveWait) => setTimeout(resolveWait, PAIR_POLL_MS));
437
+ }
438
+ }
439
+ catch (error) {
440
+ this.logger.warn(`[dsh-remote] mobile recovery polling unavailable: ${error instanceof Error ? error.message : String(error)}`);
441
+ }
442
+ finally {
443
+ if (this.pairing?.pairingId === created.pairingId)
444
+ this.pairing = null;
445
+ }
446
+ }
447
+ async runPairing() {
448
+ while (!this.disposed && !this.identity.nodeId) {
449
+ try {
450
+ this.connectionStatus = 'pairing';
451
+ const created = await this.fetchJson('/v1/node-pairings', {
452
+ method: 'POST',
453
+ body: JSON.stringify({
454
+ nodeName: this.nodeName,
455
+ platform: platform(),
456
+ arch: arch(),
457
+ pluginVersion: PLUGIN_VERSION,
458
+ dshVersion: this.dshVersion,
459
+ installId: this.identity.installId,
460
+ nodeSecretHash: sha256(this.identity.nodeSecret),
461
+ }),
462
+ });
463
+ this.pairing = created;
464
+ this.lastPairingError = null;
465
+ while (!this.disposed && !this.identity.nodeId && Date.now() < created.expiresAt) {
466
+ const polled = await this.fetchJson(`/v1/node-pairings/${encodeURIComponent(created.pairingId)}`, { headers: { authorization: `Pair ${created.pollToken}` } });
467
+ if (polled.status === 'claimed' && polled.nodeId) {
468
+ this.identity = { ...this.identity, nodeId: polled.nodeId };
469
+ this.saveIdentity();
470
+ this.pairing = null;
471
+ this.connect();
472
+ return;
473
+ }
474
+ if (polled.status === 'expired')
475
+ break;
476
+ await new Promise((resolveWait) => setTimeout(resolveWait, PAIR_POLL_MS));
477
+ }
478
+ }
479
+ catch (error) {
480
+ this.connectionStatus = 'offline';
481
+ this.lastPairingError = error instanceof Error ? error.message : String(error);
482
+ this.logger.warn(`[dsh-remote] pairing unavailable: ${this.lastPairingError}`);
483
+ await new Promise((resolveWait) => setTimeout(resolveWait, 3_000));
484
+ }
485
+ finally {
486
+ if (!this.identity.nodeId)
487
+ this.pairing = null;
488
+ }
489
+ }
490
+ }
491
+ connect() {
492
+ if (this.disposed || !this.identity.nodeId)
493
+ return;
494
+ if (this.socket
495
+ && (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING))
496
+ return;
497
+ this.connectionStatus = 'connecting';
498
+ const socket = new WebSocket(this.hubWsUrl, {
499
+ headers: { authorization: `Node ${this.identity.nodeId}.${this.identity.nodeSecret}` },
500
+ maxPayload: 512 * 1024,
501
+ });
502
+ this.socket = socket;
503
+ socket.on('open', () => {
504
+ this.reconnectAttempt = 0;
505
+ this.send({
506
+ v: 1,
507
+ kind: 'node.hello',
508
+ protocolMin: PROTOCOL_VERSION,
509
+ protocolMax: PROTOCOL_VERSION,
510
+ node: {
511
+ id: this.identity.nodeId,
512
+ name: this.nodeName,
513
+ platform: platform(),
514
+ arch: arch(),
515
+ pluginVersion: PLUGIN_VERSION,
516
+ dshVersion: this.dshVersion,
517
+ },
518
+ capabilities: [
519
+ 'session.list',
520
+ 'session.snapshot',
521
+ 'session.create',
522
+ 'agentPreset.list',
523
+ 'session.models',
524
+ 'session.selectModel',
525
+ 'session.rename',
526
+ 'session.followup',
527
+ 'session.steer',
528
+ 'session.stop',
529
+ 'session.events',
530
+ 'approval.respond',
531
+ ],
532
+ });
533
+ if (this.heartbeatTimer)
534
+ clearInterval(this.heartbeatTimer);
535
+ this.heartbeatTimer = setInterval(() => {
536
+ this.send({ v: 1, kind: 'node.heartbeat', nodeId: this.identity.nodeId });
537
+ }, HEARTBEAT_MS);
538
+ });
539
+ socket.on('message', (raw) => {
540
+ let payload;
541
+ try {
542
+ payload = JSON.parse(raw.toString());
543
+ }
544
+ catch {
545
+ return;
546
+ }
547
+ if (!isRecord(payload))
548
+ return;
549
+ if (payload.kind === 'node.hello.ack') {
550
+ this.connectionStatus = 'online';
551
+ this.lastPairingError = null;
552
+ this.replayPendingApprovals();
553
+ if (this.rotateRecoveryOnAck) {
554
+ this.rotateRecoveryOnAck = false;
555
+ void this.ensureRecoveryPairing().catch((error) => {
556
+ this.logger.warn(`[dsh-easyremote] automatic mobile recovery rotation failed: ${error instanceof Error ? error.message : String(error)}`);
557
+ });
558
+ }
559
+ return;
560
+ }
561
+ if (payload.kind === 'command')
562
+ void this.handleCommand(payload);
563
+ });
564
+ socket.on('close', (code, reasonBuffer) => {
565
+ if (this.socket !== socket)
566
+ return;
567
+ this.socket = null;
568
+ if (this.heartbeatTimer)
569
+ clearInterval(this.heartbeatTimer);
570
+ this.heartbeatTimer = null;
571
+ if (this.disposed)
572
+ return;
573
+ const reason = reasonBuffer.toString();
574
+ if (code === 4403 || reason.includes('NODE_NOT_FOUND') || reason.includes('NODE_REVOKED')) {
575
+ this.connectionStatus = 'revoked';
576
+ this.identity = { installId: this.identity.installId, nodeSecret: this.identity.nodeSecret };
577
+ this.saveIdentity();
578
+ void this.ensurePairing();
579
+ return;
580
+ }
581
+ this.connectionStatus = 'offline';
582
+ this.scheduleReconnect();
583
+ });
584
+ socket.on('error', () => {
585
+ // close schedules the bounded reconnect.
586
+ });
587
+ }
588
+ scheduleReconnect() {
589
+ if (this.reconnectTimer || this.disposed)
590
+ return;
591
+ const delay = Math.min(30_000, 1_000 * 2 ** this.reconnectAttempt++);
592
+ this.reconnectTimer = setTimeout(() => {
593
+ this.reconnectTimer = null;
594
+ this.connect();
595
+ }, delay);
596
+ }
597
+ send(payload) {
598
+ if (this.socket?.readyState === WebSocket.OPEN)
599
+ this.socket.send(JSON.stringify(payload));
600
+ }
601
+ forwardSessionEvent(session, source) {
602
+ if (!session?.id || !this.identity.nodeId)
603
+ return;
604
+ const toolNames = this.toolNamesBySession.get(session.id) ?? new Map();
605
+ this.toolNamesBySession.set(session.id, toolNames);
606
+ const normalized = normalizeDshEvent(source, toolNames);
607
+ if (!normalized)
608
+ return;
609
+ this.send({
610
+ v: 1,
611
+ kind: 'session.event',
612
+ nodeId: this.identity.nodeId,
613
+ sessionId: session.id,
614
+ ...normalized,
615
+ });
616
+ }
617
+ async handleCommand(frame) {
618
+ if (frame.v !== 1 || !frame.commandId || !frame.requestId)
619
+ return;
620
+ this.send({
621
+ v: 1,
622
+ kind: 'command.ack',
623
+ commandId: frame.commandId,
624
+ requestId: frame.requestId,
625
+ status: frame.expiresAt <= Date.now() ? 'failed' : 'acked',
626
+ ...(frame.expiresAt <= Date.now() ? { errorCode: 'COMMAND_EXPIRED' } : {}),
627
+ });
628
+ const result = await this.commandCache.execute(frame.commandId, async () => {
629
+ if (frame.expiresAt <= Date.now()) {
630
+ return this.commandError(frame, 'COMMAND_EXPIRED', 'Command expired before execution');
631
+ }
632
+ try {
633
+ return {
634
+ v: 1,
635
+ kind: 'command.result',
636
+ commandId: frame.commandId,
637
+ requestId: frame.requestId,
638
+ ok: true,
639
+ result: await this.executeCommand(frame),
640
+ };
641
+ }
642
+ catch (error) {
643
+ return this.commandError(frame, errorCode(error), error instanceof Error ? error.message : String(error));
644
+ }
645
+ });
646
+ this.send(result);
647
+ }
648
+ commandError(frame, code, message) {
649
+ return {
650
+ v: 1,
651
+ kind: 'command.result',
652
+ commandId: frame.commandId,
653
+ requestId: frame.requestId,
654
+ ok: false,
655
+ error: { code, message },
656
+ };
657
+ }
658
+ async executeCommand(frame) {
659
+ switch (frame.action) {
660
+ case 'session.list':
661
+ return { sessions: await this.listSessions() };
662
+ case 'session.snapshot':
663
+ return this.snapshot(String(frame.sessionId || ''));
664
+ case 'session.create':
665
+ return { session: await this.createSession(typeof frame.payload.agentPreset === 'string' ? frame.payload.agentPreset : undefined) };
666
+ case 'agentPreset.list':
667
+ return this.dshApi.listAgentPresets();
668
+ case 'session.models':
669
+ return this.dshApi.sessionModels(String(frame.sessionId || ''));
670
+ case 'session.selectModel':
671
+ return this.dshApi.selectModel({
672
+ sessionId: String(frame.sessionId || ''),
673
+ provider: String(frame.payload.provider || ''),
674
+ model: String(frame.payload.model || ''),
675
+ ...(typeof frame.payload.reasoningEffort === 'string'
676
+ ? { reasoningEffort: frame.payload.reasoningEffort }
677
+ : {}),
678
+ });
679
+ case 'session.rename':
680
+ return this.dshApi.renameSession(String(frame.sessionId || ''), String(frame.payload.title || ''));
681
+ case 'session.followup': {
682
+ const agent = await this.ensureAgent(String(frame.sessionId || ''));
683
+ agent.followup(this.userMessage(String(frame.payload.content || '')));
684
+ return { accepted: true };
685
+ }
686
+ case 'session.steer': {
687
+ const agent = await this.ensureAgent(String(frame.sessionId || ''));
688
+ agent.steer(this.userMessage(String(frame.payload.instruction || '')));
689
+ return { accepted: true };
690
+ }
691
+ case 'session.stop': {
692
+ const agent = this.agents.get(String(frame.sessionId || ''));
693
+ if (!agent)
694
+ throw new Error('session not found or not live');
695
+ agent.cancel({ kind: 'user' });
696
+ return { stopped: true };
697
+ }
698
+ case 'approval.respond':
699
+ return this.resolveApproval(frame.payload);
700
+ default:
701
+ throw new Error(`unsupported capability: ${frame.action}`);
702
+ }
703
+ }
704
+ defaultAgentOptions() {
705
+ try {
706
+ const selection = this.agentDefaultModel?.currentSelection?.();
707
+ return selection?.provider && selection?.model
708
+ ? { provider: selection.provider, model: selection.model }
709
+ : undefined;
710
+ }
711
+ catch {
712
+ return undefined;
713
+ }
714
+ }
715
+ userMessage(text) {
716
+ if (!text.trim())
717
+ throw new Error('message content is empty');
718
+ return {
719
+ id: `remote-${uuidv7()}`,
720
+ role: 'user',
721
+ content: [{ type: 'text', text }],
722
+ source: { kind: 'plugin', plugin: '@hakimedes/dsh-easyremote-connector' },
723
+ };
724
+ }
725
+ async ensureAgent(sessionId) {
726
+ if (!sessionId)
727
+ throw new Error('session id is missing');
728
+ const live = this.agents.get(sessionId);
729
+ if (live)
730
+ return live;
731
+ const handle = await this.agents.resume({
732
+ resumeSessionId: sessionId,
733
+ agentOptions: this.defaultAgentOptions(),
734
+ });
735
+ this.ownedAgentHandles.set(sessionId, handle);
736
+ return handle.agent;
737
+ }
738
+ async createSession(agentPreset) {
739
+ const sessionId = uuidv7();
740
+ const root = this.agents.roots?.()[0];
741
+ const cwd = this.defaultCwd
742
+ || root?.session?.header?.cwd
743
+ || resolve(process.cwd());
744
+ const created = await this.dshApi.createSession({
745
+ sessionId,
746
+ cwd,
747
+ ...(agentPreset ? { agentPreset } : {}),
748
+ });
749
+ const agent = this.agents.get(created.sessionId);
750
+ if (!agent)
751
+ throw new Error('created session agent is unavailable');
752
+ return this.sessionSummary(agent.session, 'New Session', agent.status);
753
+ }
754
+ async listSessions() {
755
+ const records = await this.sessionQuery.listSessions();
756
+ const ids = records.map((item) => item.header.id);
757
+ const titles = new Map();
758
+ try {
759
+ const observations = await this.sessionQuery.readTitleSnapshots(ids);
760
+ for (const item of observations) {
761
+ const title = item?.status === 'fulfilled' ? item.value?.title?.title : undefined;
762
+ if (typeof title === 'string' && title)
763
+ titles.set(item.sessionId, title);
764
+ }
765
+ }
766
+ catch { }
767
+ return Promise.all(records.map(async (item) => {
768
+ let snapshot = null;
769
+ try {
770
+ snapshot = await this.sessionQuery.readSession(item.header.id);
771
+ }
772
+ catch { }
773
+ const agent = this.agents.get(item.header.id);
774
+ return this.sessionSummary({ header: item.header, events: snapshot?.events || [] }, titles.get(item.header.id), agent?.status);
775
+ }));
776
+ }
777
+ sessionSummary(session, title, status) {
778
+ return toRemoteSessionSummary(session, title, status);
779
+ }
780
+ async snapshot(sessionId) {
781
+ if (!sessionId)
782
+ throw new Error('session id is missing');
783
+ const snapshot = await this.sessionQuery.readSession(sessionId);
784
+ if (!snapshot)
785
+ throw new Error('session not found');
786
+ const title = await this.readTitle(sessionId);
787
+ const toolNames = new Map();
788
+ const events = snapshot.events
789
+ .map((event) => normalizeDshEvent(event, toolNames))
790
+ .filter((event) => event !== null);
791
+ return {
792
+ session: this.sessionSummary({ header: snapshot.session, events: snapshot.events }, title, this.agents.get(sessionId)?.status),
793
+ events: this.fitSnapshotEvents(events),
794
+ };
795
+ }
796
+ async readTitle(sessionId) {
797
+ try {
798
+ const [item] = await this.sessionQuery.readTitleSnapshots([sessionId]);
799
+ return item?.status === 'fulfilled' ? item.value?.title?.title : undefined;
800
+ }
801
+ catch {
802
+ return undefined;
803
+ }
804
+ }
805
+ fitSnapshotEvents(events) {
806
+ const selected = [];
807
+ let bytes = 0;
808
+ for (let index = events.length - 1; index >= 0; index -= 1) {
809
+ const event = events[index];
810
+ if (!event)
811
+ continue;
812
+ const size = Buffer.byteLength(JSON.stringify(event));
813
+ if (bytes + size > 350 * 1024 && selected.length > 0)
814
+ break;
815
+ selected.push(event);
816
+ bytes += size;
817
+ }
818
+ return selected.reverse();
819
+ }
820
+ requestRemoteApproval(req, next) {
821
+ if (this.connectionStatus !== 'online' || !this.identity.nodeId)
822
+ return next();
823
+ const approvalId = this.findApprovalId(req.agent?.session?.events || [], req.callId);
824
+ if (!approvalId)
825
+ return next();
826
+ const sessionId = String(req.agent.session.id);
827
+ const toolCallId = String(req.callId || approvalId);
828
+ const summary = typeof req.reason === 'string' && req.reason ? req.reason : String(req.toolName || 'DSH action');
829
+ const pendingPromise = new Promise((resolveOutcome) => {
830
+ const expiresAt = Date.now() + APPROVAL_TTL_MS;
831
+ let timer;
832
+ const onAbort = () => settle('cancelled');
833
+ const settle = (outcome) => {
834
+ const current = this.pendingApprovals.get(approvalId);
835
+ if (!current)
836
+ return;
837
+ this.pendingApprovals.delete(approvalId);
838
+ clearTimeout(timer);
839
+ req.signal?.removeEventListener('abort', onAbort);
840
+ resolveOutcome(outcome);
841
+ };
842
+ timer = setTimeout(() => settle('unavailable'), APPROVAL_TTL_MS);
843
+ const cwd = req.agent?.session?.header?.cwd;
844
+ const pending = {
845
+ approvalId,
846
+ sessionId,
847
+ toolCallId,
848
+ title: `DSH wants to run ${String(req.toolName || 'an action')}`,
849
+ summary,
850
+ ...(typeof cwd === 'string' ? { cwd } : {}),
851
+ risk: String(req.toolName || '').includes('bash') ? 'high' : 'medium',
852
+ expiresAt,
853
+ settle,
854
+ };
855
+ this.pendingApprovals.set(approvalId, pending);
856
+ req.signal?.addEventListener('abort', onAbort, { once: true });
857
+ this.sendApproval(pending);
858
+ });
859
+ return pendingPromise;
860
+ }
861
+ findApprovalId(events, callId) {
862
+ const decided = new Set();
863
+ for (let index = events.length - 1; index >= 0; index -= 1) {
864
+ const event = events[index];
865
+ if (event?.type === 'approval/decided' && typeof event.data.id === 'string') {
866
+ decided.add(event.data.id);
867
+ }
868
+ if (event?.type !== 'approval/asked' || typeof event.data.id !== 'string')
869
+ continue;
870
+ if (decided.has(event.data.id) || this.pendingApprovals.has(event.data.id))
871
+ continue;
872
+ if ((event.data.callId ?? null) !== (callId ?? null))
873
+ continue;
874
+ return event.data.id;
875
+ }
876
+ return null;
877
+ }
878
+ sendApproval(pending) {
879
+ this.send({
880
+ v: 1,
881
+ kind: 'approval.request',
882
+ approval: {
883
+ approvalId: pending.approvalId,
884
+ nodeId: this.identity.nodeId,
885
+ sessionId: pending.sessionId,
886
+ toolCallId: pending.toolCallId,
887
+ title: pending.title,
888
+ summary: pending.summary,
889
+ cwd: pending.cwd,
890
+ risk: pending.risk,
891
+ expiresAt: pending.expiresAt,
892
+ },
893
+ });
894
+ }
895
+ replayPendingApprovals() {
896
+ for (const pending of this.pendingApprovals.values())
897
+ this.sendApproval(pending);
898
+ }
899
+ resolveApproval(payload) {
900
+ const approvalId = String(payload.approvalId || '');
901
+ const pending = this.pendingApprovals.get(approvalId);
902
+ if (!pending)
903
+ throw new Error('approval not found or no longer pending');
904
+ pending.settle(payload.response === 'allow_once' ? 'allowed-once' : 'rejected');
905
+ return { accepted: true, approvalId };
906
+ }
907
+ }
908
+ export function apply(ctx) {
909
+ const connector = new HubConnector(ctx);
910
+ connector.start();
911
+ ctx.effect(() => () => connector.dispose());
912
+ }
913
+ //# sourceMappingURL=index.js.map