@dinoxx/dinox-cli 2.3.0 → 2.5.2

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 (53) hide show
  1. package/README.md +17 -3
  2. package/dist/cli.js +2 -0
  3. package/dist/commands/gateway/index.d.ts +4 -0
  4. package/dist/commands/gateway/index.js +300 -0
  5. package/dist/commands/schema/renderSkills.js +4 -0
  6. package/dist/commands/schema/spec.js +2 -0
  7. package/dist/config/paths.d.ts +1 -0
  8. package/dist/config/paths.js +1 -1
  9. package/dist/daemon/server.d.ts +1 -0
  10. package/dist/daemon/server.js +41 -0
  11. package/dist/gateway/adapters/acp.d.ts +47 -0
  12. package/dist/gateway/adapters/acp.js +302 -0
  13. package/dist/gateway/adapters/echo.d.ts +12 -0
  14. package/dist/gateway/adapters/echo.js +51 -0
  15. package/dist/gateway/adapters/index.d.ts +9 -0
  16. package/dist/gateway/adapters/index.js +31 -0
  17. package/dist/gateway/assistantProjection.d.ts +32 -0
  18. package/dist/gateway/assistantProjection.js +193 -0
  19. package/dist/gateway/config.d.ts +60 -0
  20. package/dist/gateway/config.js +291 -0
  21. package/dist/gateway/conversationContext.d.ts +44 -0
  22. package/dist/gateway/conversationContext.js +75 -0
  23. package/dist/gateway/detect.d.ts +40 -0
  24. package/dist/gateway/detect.js +145 -0
  25. package/dist/gateway/deviceHost.d.ts +91 -0
  26. package/dist/gateway/deviceHost.js +641 -0
  27. package/dist/gateway/keepAwake.d.ts +24 -0
  28. package/dist/gateway/keepAwake.js +64 -0
  29. package/dist/gateway/mcpServer.d.ts +4 -0
  30. package/dist/gateway/mcpServer.js +138 -0
  31. package/dist/gateway/protocol.d.ts +116 -0
  32. package/dist/gateway/protocol.js +182 -0
  33. package/dist/gateway/runExecutor.d.ts +91 -0
  34. package/dist/gateway/runExecutor.js +422 -0
  35. package/dist/gateway/service.d.ts +30 -0
  36. package/dist/gateway/service.js +112 -0
  37. package/dist/gateway/streamPublisher.d.ts +33 -0
  38. package/dist/gateway/streamPublisher.js +93 -0
  39. package/dist/gateway/threadStore.d.ts +222 -0
  40. package/dist/gateway/threadStore.js +597 -0
  41. package/dist/gateway/types.d.ts +136 -0
  42. package/dist/gateway/types.js +10 -0
  43. package/dist/gateway/workspaceFolders.d.ts +33 -0
  44. package/dist/gateway/workspaceFolders.js +129 -0
  45. package/dist/vendor/electron/domain/properties.js +12 -1
  46. package/dist/vendor/electron/domain/saved-views.js +4 -1
  47. package/dist/vendor/electron/shared/external-types.d.ts +18 -0
  48. package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
  49. package/dist/vendor/electron/shared/note-properties.js +45 -0
  50. package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
  51. package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
  52. package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
  53. package/package.json +5 -2
@@ -0,0 +1,641 @@
1
+ import os from 'node:os';
2
+ import { credsAuthenticator, wsconnect, } from '@nats-io/nats-core';
3
+ import { toAuthorizationHeaderValue } from '../utils/authToken.js';
4
+ import { REMOTE_DEVICE_PRESENCE_INTERVAL_MS, REMOTE_DEVICE_PROTOCOL_NAME, REMOTE_DEVICE_PROTOCOL_VERSION, remoteDeviceSubject, remoteDiscoverSubject, remotePresenceSubject, remoteRunStreamSubject, } from '../vendor/electron/shared/remote-device-agent.js';
5
+ import { projectPendingApprovals } from './assistantProjection.js';
6
+ import { isAgentConfigQuery, isAgentFoldersQuery, isAgentSettingsQuery, isCancelRequest, isRunStatusRequest, readAgentSettingsUpdate, reconnectDelayMs, validateApprovalRequest, validateRunRequest, } from './protocol.js';
7
+ import { GatewayRunExecutor, MAX_QUEUED_TURNS, QueueFullError } from './runExecutor.js';
8
+ import { RunStreamPublisher } from './streamPublisher.js';
9
+ import { findRun, readRecentSessionFolders, resolveTrustedResource, TERMINAL_RUN_STATUSES, threadExists, } from './threadStore.js';
10
+ import { agentHomeFolder, ensureAgentHomeFolder, existingWorkspaceFolders, listWorkspaceFolders, resolveWorkspaceFolder, WorkspaceFolderError, } from './workspaceFolders.js';
11
+ export const DEFAULT_NATS_CREDENTIALS_URL = 'https://aisdk.chatgo.pro/api/nats/credentials';
12
+ const CREDENTIALS_TIMEOUT_MS = 10_000;
13
+ const CREDENTIALS_REFRESH_LEAD_MS = 60_000;
14
+ const ACK_CACHE_TTL_MS = 10 * 60 * 1000;
15
+ const ACK_CACHE_MAX = 1000;
16
+ const DRAIN_TIMEOUT_MS = 3_000;
17
+ class CredentialsHttpError extends Error {
18
+ status;
19
+ constructor(status, message) {
20
+ super(message);
21
+ this.status = status;
22
+ }
23
+ }
24
+ const encoder = new TextEncoder();
25
+ function encode(value) {
26
+ return encoder.encode(JSON.stringify(value));
27
+ }
28
+ function decode(msg) {
29
+ try {
30
+ return { value: msg.json(), byteLength: msg.data.byteLength };
31
+ }
32
+ catch {
33
+ return { value: null, byteLength: msg.data.byteLength };
34
+ }
35
+ }
36
+ /**
37
+ * Hosts one gateway as a `dinox.remote-device.v2` device over NATS, the same
38
+ * contract Dinox desktop serves, so the app drives it with no client changes.
39
+ * Each gateway is its own device id (the credential service scopes a desktop
40
+ * credential to a single device subtree).
41
+ */
42
+ export class GatewayDeviceHost {
43
+ deps;
44
+ executor;
45
+ connection = null;
46
+ subscriptions = [];
47
+ userNo = null;
48
+ state = 'idle';
49
+ connectedAt = null;
50
+ lastError = null;
51
+ generation = 0;
52
+ attempt = 0;
53
+ presenceSequence = 0;
54
+ presenceTimer = null;
55
+ refreshTimer = null;
56
+ reconnectTimer = null;
57
+ stopped = false;
58
+ retryBlocked = false;
59
+ ackCache = new Map();
60
+ /** Latest event per run while offline; the snapshot model makes older ones redundant. */
61
+ pendingEvents = new Map();
62
+ constructor(deps) {
63
+ this.deps = deps;
64
+ this.executor = new GatewayRunExecutor({
65
+ db: deps.db,
66
+ userId: deps.userId,
67
+ gateway: deps.gateway,
68
+ adapter: deps.adapter,
69
+ log: deps.log,
70
+ keepAwake: deps.keepAwake,
71
+ });
72
+ }
73
+ start() {
74
+ this.stopped = false;
75
+ void this.connect();
76
+ }
77
+ async stop() {
78
+ this.stopped = true;
79
+ this.executor.cancelAll();
80
+ await this.disconnect({ publishOffline: true });
81
+ await this.deps.adapter.dispose();
82
+ this.state = 'idle';
83
+ }
84
+ status() {
85
+ return {
86
+ gatewayId: this.deps.gateway.id,
87
+ name: this.deps.gateway.name,
88
+ deviceId: this.deps.gateway.deviceId,
89
+ kind: this.deps.gateway.kind,
90
+ state: this.state,
91
+ connectedAt: this.connectedAt,
92
+ lastError: this.lastError,
93
+ busyThreadIds: this.executor.busyThreadIds(),
94
+ agentName: this.deps.adapter.info().agentName,
95
+ };
96
+ }
97
+ // ---- connection lifecycle -------------------------------------------------
98
+ async connect() {
99
+ if (this.stopped || this.retryBlocked)
100
+ return;
101
+ const generation = ++this.generation;
102
+ this.state = this.attempt > 0 ? 'reconnecting' : 'connecting';
103
+ try {
104
+ const credentials = await this.fetchCredentials();
105
+ if (generation !== this.generation || this.stopped)
106
+ return;
107
+ const connection = await (this.deps.connect ?? wsconnect)({
108
+ servers: credentials.url,
109
+ authenticator: credsAuthenticator(encoder.encode(credentials.credentials)),
110
+ inboxPrefix: credentials.inboxPrefix,
111
+ name: `dinox-gateway-${this.deps.gateway.deviceId}`,
112
+ noEcho: true,
113
+ maxReconnectAttempts: -1,
114
+ reconnectDelayHandler: () => reconnectDelayMs(this.attempt++),
115
+ timeout: CREDENTIALS_TIMEOUT_MS,
116
+ });
117
+ if (generation !== this.generation || this.stopped) {
118
+ await connection.close();
119
+ return;
120
+ }
121
+ this.connection = connection;
122
+ this.userNo = credentials.userNo;
123
+ this.attempt = 0;
124
+ this.state = 'connected';
125
+ this.connectedAt = new Date().toISOString();
126
+ this.lastError = null;
127
+ this.subscribe(connection, credentials.userNo);
128
+ this.publishPresence('online');
129
+ this.flushPendingEvents();
130
+ this.presenceTimer = setInterval(() => this.publishPresence('online'), REMOTE_DEVICE_PRESENCE_INTERVAL_MS);
131
+ this.scheduleCredentialRefresh(credentials.expiresAt, generation);
132
+ void this.watchConnection(connection, generation);
133
+ this.deps.log('gateway device online', { gateway: this.deps.gateway.name, deviceId: this.deps.gateway.deviceId });
134
+ }
135
+ catch (error) {
136
+ if (generation !== this.generation)
137
+ return;
138
+ this.state = 'error';
139
+ this.lastError = error instanceof Error ? error.message : String(error);
140
+ if (error instanceof CredentialsHttpError && error.status !== 429 && error.status < 500) {
141
+ // Auth or request errors will not heal on retry; wait for a reload.
142
+ this.retryBlocked = true;
143
+ this.deps.log('gateway credentials rejected', { status: error.status, error: this.lastError });
144
+ return;
145
+ }
146
+ this.scheduleReconnect();
147
+ }
148
+ }
149
+ async watchConnection(connection, generation) {
150
+ const error = await connection.closed();
151
+ if (generation !== this.generation || this.stopped)
152
+ return;
153
+ this.lastError = error instanceof Error ? error.message : 'connection closed';
154
+ await this.disconnect({ publishOffline: false });
155
+ this.scheduleReconnect();
156
+ }
157
+ scheduleReconnect() {
158
+ if (this.stopped || this.reconnectTimer)
159
+ return;
160
+ this.state = 'reconnecting';
161
+ this.reconnectTimer = setTimeout(() => {
162
+ this.reconnectTimer = null;
163
+ void this.connect();
164
+ }, reconnectDelayMs(this.attempt++));
165
+ }
166
+ /** Short-lived credentials: reconnect with fresh ones before they expire (runs keep going). */
167
+ scheduleCredentialRefresh(expiresAt, generation) {
168
+ const expiresAtMs = Date.parse(expiresAt);
169
+ const delay = Number.isFinite(expiresAtMs) ? Math.max(1_000, expiresAtMs - Date.now() - CREDENTIALS_REFRESH_LEAD_MS) : 60_000;
170
+ this.refreshTimer = setTimeout(() => {
171
+ if (generation !== this.generation || this.stopped)
172
+ return;
173
+ void this.disconnect({ publishOffline: false }).then(() => this.connect());
174
+ }, delay);
175
+ }
176
+ async disconnect(options) {
177
+ this.generation += 1;
178
+ for (const timer of [this.presenceTimer, this.refreshTimer, this.reconnectTimer]) {
179
+ if (timer)
180
+ clearTimeout(timer);
181
+ }
182
+ this.presenceTimer = this.refreshTimer = this.reconnectTimer = null;
183
+ const connection = this.connection;
184
+ this.connection = null;
185
+ for (const subscription of this.subscriptions)
186
+ subscription.unsubscribe();
187
+ this.subscriptions = [];
188
+ if (!connection)
189
+ return;
190
+ if (options.publishOffline)
191
+ this.publishPresence('offline', connection);
192
+ await Promise.race([
193
+ connection.drain().catch(() => undefined),
194
+ new Promise((resolve) => setTimeout(resolve, DRAIN_TIMEOUT_MS)),
195
+ ]);
196
+ await connection.close().catch(() => undefined);
197
+ }
198
+ async fetchCredentials() {
199
+ const fetchImpl = this.deps.fetchImpl ?? fetch;
200
+ const response = await fetchImpl(this.deps.credentialsUrl ?? DEFAULT_NATS_CREDENTIALS_URL, {
201
+ method: 'POST',
202
+ headers: {
203
+ Authorization: toAuthorizationHeaderValue(this.deps.authorization),
204
+ 'Content-Type': 'application/json',
205
+ Accept: 'application/json',
206
+ },
207
+ body: JSON.stringify({ deviceId: this.deps.gateway.deviceId, role: 'desktop' }),
208
+ signal: AbortSignal.timeout(CREDENTIALS_TIMEOUT_MS),
209
+ });
210
+ const envelope = (await response.json().catch(() => null));
211
+ if (!response.ok || envelope?.code !== '000000' || !envelope.data) {
212
+ throw new CredentialsHttpError(response.status, envelope?.msg ?? `credentials request failed (${response.status})`);
213
+ }
214
+ const data = envelope.data;
215
+ if (data.role !== 'desktop' || data.deviceId !== this.deps.gateway.deviceId) {
216
+ throw new Error('credentials do not match this gateway device');
217
+ }
218
+ if (data.userNo !== this.deps.userId.trim().toLowerCase()) {
219
+ throw new CredentialsHttpError(403, 'credentials belong to another account');
220
+ }
221
+ return data;
222
+ }
223
+ // ---- protocol ---------------------------------------------------------------
224
+ deviceInfo() {
225
+ const adapterInfo = this.deps.adapter.info();
226
+ const info = {
227
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
228
+ protocol: REMOTE_DEVICE_PROTOCOL_NAME,
229
+ deviceId: this.deps.gateway.deviceId,
230
+ deviceName: `${this.deps.gateway.name} · ${os.hostname()}`.slice(0, 64),
231
+ platform: process.platform,
232
+ appVersion: this.deps.appVersion,
233
+ connectedAt: this.connectedAt ?? new Date().toISOString(),
234
+ busyThreadIds: this.executor.busyThreadIds(),
235
+ capabilities: {
236
+ threadRun: true,
237
+ cancel: true,
238
+ stream: 'assistant-snapshot',
239
+ toolApproval: 'desktop',
240
+ remoteToolApproval: { respond: true, decisions: ['approve_once', 'deny'], recovery: 'run-status' },
241
+ },
242
+ };
243
+ // Additive v2 extension: tells clients this device is an external agent
244
+ // (the agent owns context; Dinox model/tool settings do not apply).
245
+ info.capabilities.executor = {
246
+ kind: 'gateway',
247
+ gatewayId: this.deps.gateway.id,
248
+ runtime: this.deps.gateway.kind,
249
+ agentName: adapterInfo.agentName,
250
+ agentVersion: adapterInfo.agentVersion,
251
+ context: 'agent-session',
252
+ // Gateway extension: `agent.config.get` lists the agent's models and modes.
253
+ config: Boolean(this.deps.adapter.describeConfig),
254
+ // Gateway extension: a busy thread queues turns instead of refusing them;
255
+ // waiting turns run together as one, and cancelling a waiting turn withdraws it.
256
+ queue: { maxQueued: MAX_QUEUED_TURNS, merge: true },
257
+ // Gateway extension: `agent.settings.get/set` and `agent.folders.list`.
258
+ settings: Boolean(this.deps.updateSettings),
259
+ // Online but refusing new turns until resumed from the app.
260
+ paused: this.deps.gateway.paused === true,
261
+ };
262
+ return info;
263
+ }
264
+ subscribe(connection, userNo) {
265
+ const deviceId = this.deps.gateway.deviceId;
266
+ const route = (subject, handler) => {
267
+ const subscription = connection.subscribe(subject);
268
+ this.subscriptions.push(subscription);
269
+ void (async () => {
270
+ for await (const msg of subscription) {
271
+ try {
272
+ await handler(msg);
273
+ }
274
+ catch (error) {
275
+ this.deps.log('gateway handler failed', { subject, error: String(error) });
276
+ }
277
+ }
278
+ })();
279
+ };
280
+ route(remoteDiscoverSubject(userNo), (msg) => this.handleDiscover(msg));
281
+ route(remoteDeviceSubject(userNo, deviceId, 'info'), (msg) => this.handleInfo(msg));
282
+ route(remoteDeviceSubject(userNo, deviceId, 'request'), (msg) => this.handleRunRequest(msg));
283
+ route(remoteDeviceSubject(userNo, deviceId, 'cancel'), (msg) => this.handleCancel(msg));
284
+ route(remoteDeviceSubject(userNo, deviceId, 'approve'), (msg) => this.handleApprove(msg));
285
+ route(remoteDeviceSubject(userNo, deviceId, 'query'), (msg) => this.handleQuery(msg));
286
+ }
287
+ publishPresence(status, connection = this.connection) {
288
+ if (!connection || !this.userNo)
289
+ return;
290
+ this.presenceSequence += 1;
291
+ try {
292
+ connection.publish(remotePresenceSubject(this.userNo, this.deps.gateway.deviceId), encode({
293
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
294
+ type: 'device.presence',
295
+ status,
296
+ sequence: this.presenceSequence,
297
+ emittedAt: new Date().toISOString(),
298
+ device: this.deviceInfo(),
299
+ }));
300
+ }
301
+ catch (error) {
302
+ this.deps.log('presence publish failed', { error: String(error) });
303
+ }
304
+ }
305
+ publishRunEvent(subject, event) {
306
+ if (!this.connection) {
307
+ this.pendingEvents.set(event.runId, { subject, event });
308
+ return;
309
+ }
310
+ try {
311
+ this.connection.publish(subject, encode(event));
312
+ }
313
+ catch {
314
+ this.pendingEvents.set(event.runId, { subject, event });
315
+ }
316
+ }
317
+ flushPendingEvents() {
318
+ const pending = [...this.pendingEvents.values()];
319
+ this.pendingEvents.clear();
320
+ for (const { subject, event } of pending)
321
+ this.publishRunEvent(subject, event);
322
+ }
323
+ handleDiscover(msg) {
324
+ const { value } = decode(msg);
325
+ const requestId = value?.requestId;
326
+ if (typeof requestId !== 'string' || !msg.reply)
327
+ return;
328
+ msg.respond(encode({ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'device.discovered', requestId, device: this.deviceInfo() }));
329
+ }
330
+ /** Only the gateway's own queries are served; desktop queries (skills, workspace) are not advertised. */
331
+ async handleQuery(msg) {
332
+ const { value, byteLength } = decode(msg);
333
+ if (!msg.reply)
334
+ return;
335
+ if (isAgentConfigQuery(value)) {
336
+ msg.respond(encode(await this.agentConfig(value)));
337
+ return;
338
+ }
339
+ if (isAgentSettingsQuery(value)) {
340
+ msg.respond(encode(this.settingsResult(value.requestId)));
341
+ return;
342
+ }
343
+ if (isAgentFoldersQuery(value)) {
344
+ msg.respond(encode(await this.listFolders(value)));
345
+ return;
346
+ }
347
+ const update = readAgentSettingsUpdate(value, byteLength);
348
+ if (update) {
349
+ const requestId = String(value.requestId);
350
+ msg.respond(encode('error' in update
351
+ ? this.settingsResult(requestId, { code: 'invalid_request', message: update.error })
352
+ : await this.changeSettings(update.update)));
353
+ }
354
+ }
355
+ settingsResult(requestId, error) {
356
+ const gateway = this.deps.gateway;
357
+ return {
358
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
359
+ type: 'agent.settings.result',
360
+ requestId,
361
+ ok: !error,
362
+ settings: {
363
+ cwd: gateway.cwd ?? null,
364
+ approval: gateway.approval ?? 'ask',
365
+ paused: gateway.paused === true,
366
+ dinoxTools: gateway.dinoxTools !== false,
367
+ },
368
+ home: os.homedir(),
369
+ agentFolder: agentHomeFolder(gateway.name),
370
+ fetchedAt: new Date().toISOString(),
371
+ ...(error ? { error } : {}),
372
+ };
373
+ }
374
+ async changeSettings(update) {
375
+ const { updateSettings } = this.deps;
376
+ if (!updateSettings) {
377
+ return this.settingsResult(update.requestId, { code: 'failed', message: 'Settings cannot be changed here' });
378
+ }
379
+ const change = update.settings;
380
+ try {
381
+ const cwd = change.useAgentFolder
382
+ ? await ensureAgentHomeFolder(this.deps.gateway.name)
383
+ : change.cwd !== undefined
384
+ ? await resolveWorkspaceFolder(change.cwd)
385
+ : undefined;
386
+ await updateSettings({
387
+ ...(cwd !== undefined ? { cwd } : {}),
388
+ ...(change.approval !== undefined ? { approval: change.approval } : {}),
389
+ ...(change.paused !== undefined ? { paused: change.paused } : {}),
390
+ ...(change.dinoxTools !== undefined ? { dinoxTools: change.dinoxTools } : {}),
391
+ });
392
+ }
393
+ catch (error) {
394
+ return this.settingsResult(update.requestId, settingsError(error));
395
+ }
396
+ // Clients learn about a pause from presence too.
397
+ this.publishPresence('online');
398
+ return this.settingsResult(update.requestId);
399
+ }
400
+ async listFolders(query) {
401
+ const home = os.homedir();
402
+ const base = {
403
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
404
+ type: 'agent.folders.result',
405
+ requestId: query.requestId,
406
+ home,
407
+ fetchedAt: new Date().toISOString(),
408
+ };
409
+ try {
410
+ const listing = await listWorkspaceFolders(query.path, home);
411
+ const suggestions = query.path
412
+ ? []
413
+ : await existingWorkspaceFolders([
414
+ this.deps.gateway.cwd,
415
+ ...(await readRecentSessionFolders(this.deps.db, this.deps.gateway.id).catch(() => [])),
416
+ `${home}/Desktop`,
417
+ `${home}/Documents`,
418
+ ], home);
419
+ return { ...base, ok: true, ...listing, suggestions };
420
+ }
421
+ catch (error) {
422
+ return {
423
+ ...base,
424
+ ok: false,
425
+ path: query.path ?? home,
426
+ parent: null,
427
+ folders: [],
428
+ truncated: false,
429
+ suggestions: [],
430
+ error: settingsError(error),
431
+ };
432
+ }
433
+ }
434
+ async agentConfig(value) {
435
+ const result = {
436
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
437
+ type: 'agent.config.result',
438
+ requestId: value.requestId,
439
+ ok: false,
440
+ options: [],
441
+ fetchedAt: new Date().toISOString(),
442
+ };
443
+ const describe = this.deps.adapter.describeConfig?.bind(this.deps.adapter);
444
+ if (!describe) {
445
+ result.error = { code: 'unsupported', message: 'This agent has no session settings' };
446
+ }
447
+ else {
448
+ try {
449
+ result.options = await describe();
450
+ result.ok = true;
451
+ }
452
+ catch (error) {
453
+ result.error = { code: 'query_failed', message: error instanceof Error ? error.message : String(error) };
454
+ }
455
+ }
456
+ return result;
457
+ }
458
+ async handleInfo(msg) {
459
+ const { value } = decode(msg);
460
+ if (!msg.reply)
461
+ return;
462
+ if (isRunStatusRequest(value)) {
463
+ const run = await findRun(this.deps.db, { runId: value.runId, userId: this.deps.userId });
464
+ const found = Boolean(run && run.threadId === value.threadId);
465
+ msg.respond(encode({
466
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
467
+ type: 'run.status.result',
468
+ requestId: value.requestId,
469
+ runId: value.runId,
470
+ threadId: value.threadId,
471
+ found,
472
+ ...(found && run
473
+ ? {
474
+ status: run.status,
475
+ updatedAt: run.updatedAt ?? undefined,
476
+ ...(run.assistant ? { assistant: { message: run.assistant } } : {}),
477
+ pendingApprovals: run.status === 'waiting_approval'
478
+ ? projectPendingApprovals(run.assistant, { runId: run.id, threadId: run.threadId })
479
+ : [],
480
+ }
481
+ : {}),
482
+ }));
483
+ return;
484
+ }
485
+ const requestId = value?.requestId;
486
+ if (typeof requestId !== 'string')
487
+ return;
488
+ msg.respond(encode({ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'device.info.result', requestId, device: this.deviceInfo() }));
489
+ }
490
+ respondAck(msg, request, code, extra = {}) {
491
+ const ack = {
492
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
493
+ type: 'run.ack',
494
+ requestId: request.requestId,
495
+ runId: request.runId,
496
+ threadId: request.threadId,
497
+ accepted: code === 'accepted',
498
+ code,
499
+ ...extra,
500
+ };
501
+ if (msg.reply)
502
+ msg.respond(encode(ack));
503
+ return ack;
504
+ }
505
+ cacheAck(ack) {
506
+ const now = Date.now();
507
+ for (const [key, entry] of this.ackCache) {
508
+ if (entry.expiresAt <= now || this.ackCache.size >= ACK_CACHE_MAX)
509
+ this.ackCache.delete(key);
510
+ }
511
+ this.ackCache.set(ack.requestId, { ack, expiresAt: now + ACK_CACHE_TTL_MS });
512
+ }
513
+ async handleRunRequest(msg) {
514
+ const { value, byteLength } = decode(msg);
515
+ const invalid = validateRunRequest(value, byteLength);
516
+ const raw = (value ?? {});
517
+ const ids = { requestId: String(raw.requestId ?? ''), runId: String(raw.runId ?? ''), threadId: String(raw.threadId ?? '') };
518
+ if (invalid) {
519
+ this.respondAck(msg, ids, 'invalid_request', { error: invalid });
520
+ return;
521
+ }
522
+ const request = value;
523
+ // Idempotent re-delivery: replay the ack, or recover it from the persisted run.
524
+ const cached = this.ackCache.get(request.requestId);
525
+ if (cached && cached.expiresAt > Date.now()) {
526
+ const sameRun = cached.ack.runId === request.runId && cached.ack.threadId === request.threadId;
527
+ if (msg.reply)
528
+ msg.respond(encode(sameRun ? cached.ack : { ...cached.ack, accepted: false, code: 'invalid_request' }));
529
+ return;
530
+ }
531
+ const existing = await findRun(this.deps.db, { runId: request.runId, userId: this.deps.userId });
532
+ if (existing) {
533
+ if (existing.requestId === request.requestId && existing.threadId === request.threadId) {
534
+ this.respondAck(msg, request, 'accepted', { status: existing.status });
535
+ }
536
+ else {
537
+ this.respondAck(msg, request, 'invalid_request', { error: 'runId already used by another request' });
538
+ }
539
+ return;
540
+ }
541
+ if (!this.deps.gateway.enabled || this.deps.gateway.paused) {
542
+ this.respondAck(msg, request, 'disabled', this.deps.gateway.paused ? { error: 'paused' } : {});
543
+ return;
544
+ }
545
+ if (!request.createThread && !(await threadExists(this.deps.db, request.threadId, this.deps.userId))) {
546
+ this.respondAck(msg, request, 'thread_not_found');
547
+ return;
548
+ }
549
+ for (const part of request.userMessage.parts) {
550
+ if (part.type !== 'file')
551
+ continue;
552
+ const resourceId = part.providerMetadata?.dinox?.resourceId;
553
+ const resource = resourceId
554
+ ? await resolveTrustedResource(this.deps.db, { resourceId, userId: this.deps.userId })
555
+ : null;
556
+ if (!resource) {
557
+ this.respondAck(msg, request, 'attachment_unavailable');
558
+ return;
559
+ }
560
+ }
561
+ const subject = remoteRunStreamSubject(this.userNo ?? this.deps.userId, request.threadId, request.runId);
562
+ const publisher = new RunStreamPublisher(request, (event) => this.publishRunEvent(subject, event));
563
+ try {
564
+ const { threadCreated, queued } = await this.executor.accept(request, (snapshot) => {
565
+ publisher.push(snapshot);
566
+ if (TERMINAL_RUN_STATUSES.has(snapshot.status)) {
567
+ publisher.dispose();
568
+ this.publishPresence('online');
569
+ }
570
+ });
571
+ const ack = this.respondAck(msg, request, 'accepted', {
572
+ status: 'queued',
573
+ // Gateway extension: the turn waits behind the thread's active one.
574
+ ...(queued ? { waiting: true } : {}),
575
+ ...(request.createThread
576
+ ? { threadDisposition: threadCreated ? 'created_desktop_local' : 'already_present_desktop_local' }
577
+ : {}),
578
+ });
579
+ this.cacheAck(ack);
580
+ this.publishPresence('online');
581
+ }
582
+ catch (error) {
583
+ publisher.dispose();
584
+ if (error instanceof QueueFullError) {
585
+ this.respondAck(msg, request, 'busy', { error: error.message });
586
+ return;
587
+ }
588
+ this.respondAck(msg, request, 'enqueue_failed', { error: error instanceof Error ? error.message : String(error) });
589
+ }
590
+ }
591
+ handleCancel(msg) {
592
+ const { value } = decode(msg);
593
+ if (!isCancelRequest(value))
594
+ return;
595
+ const cancelled = this.executor.cancel(value.runId, value.threadId);
596
+ msg.respond(encode({
597
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
598
+ type: 'run.cancel.ack',
599
+ requestId: value.requestId,
600
+ runId: value.runId,
601
+ threadId: value.threadId,
602
+ cancelled,
603
+ ...(cancelled ? {} : { error: 'run is not active on this gateway' }),
604
+ }));
605
+ }
606
+ async handleApprove(msg) {
607
+ const { value, byteLength } = decode(msg);
608
+ const invalid = validateApprovalRequest(value, byteLength);
609
+ const raw = (value ?? {});
610
+ const base = {
611
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
612
+ type: 'tool.approval.ack',
613
+ requestId: String(raw.requestId ?? ''),
614
+ runId: String(raw.runId ?? ''),
615
+ threadId: String(raw.threadId ?? ''),
616
+ approvalId: String(raw.approvalId ?? ''),
617
+ };
618
+ if (invalid) {
619
+ msg.respond(encode({ ...base, accepted: false, code: 'invalid_request', error: invalid }));
620
+ return;
621
+ }
622
+ const code = this.executor.respondApproval({
623
+ runId: base.runId,
624
+ threadId: base.threadId,
625
+ approvalId: base.approvalId,
626
+ approved: raw.approved === true,
627
+ });
628
+ const run = await findRun(this.deps.db, { runId: base.runId, userId: this.deps.userId });
629
+ msg.respond(encode({
630
+ ...base,
631
+ accepted: code === 'applied' || code === 'already_applied',
632
+ code,
633
+ ...(run ? { status: run.status, pendingApprovals: projectPendingApprovals(run.assistant, base) } : {}),
634
+ }));
635
+ }
636
+ }
637
+ function settingsError(error) {
638
+ if (error instanceof WorkspaceFolderError)
639
+ return { code: error.code, message: error.message };
640
+ return { code: 'failed', message: error instanceof Error ? error.message : String(error) };
641
+ }
@@ -0,0 +1,24 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ type Logger = (message: string, details?: Record<string, unknown>) => void;
3
+ export type KeepAwakeSpawn = (command: string, args: string[]) => ChildProcess;
4
+ /**
5
+ * Keeps the computer from idle-sleeping while any agent turn runs, so a task
6
+ * started from the phone is not cut off when the screen locks. macOS only
7
+ * (`caffeinate -i`, released as soon as the last turn ends); elsewhere a no-op.
8
+ * It cannot stop sleep from closing a laptop lid on battery.
9
+ */
10
+ export declare class KeepAwake {
11
+ private readonly log;
12
+ private readonly platform;
13
+ private readonly spawnProcess;
14
+ private holders;
15
+ private child;
16
+ constructor(log?: Logger, platform?: NodeJS.Platform, spawnProcess?: KeepAwakeSpawn);
17
+ /** Holds the computer awake until the returned release is called (idempotent). */
18
+ acquire(): () => void;
19
+ /** Releases everything, e.g. when the daemon stops. */
20
+ dispose(): void;
21
+ private start;
22
+ private stop;
23
+ }
24
+ export {};