@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,422 @@
1
+ import os from 'node:os';
2
+ import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
3
+ import { CONTEXT_MESSAGE_LIMIT, withConversationContext } from './conversationContext.js';
4
+ import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, withdrawQueuedRun, readAgentInstructions, readCurrentTopic, readSessionBinding, readTranscriptPage, resolveTrustedResource, updateRunSnapshot, waitForMessage, withGatewaySession, writeSessionBinding, } from './threadStore.js';
5
+ import { readAgentConfig } from './protocol.js';
6
+ const SNAPSHOT_PERSIST_INTERVAL_MS = 1_000;
7
+ /** Turns waiting behind a thread's active one; more are refused as `busy`. */
8
+ export const MAX_QUEUED_TURNS = 10;
9
+ const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
10
+ /** The agent's instructions travel with a new session's first prompt; longer ones are cut. */
11
+ const MAX_INSTRUCTIONS_CHARS = 8_000;
12
+ /** How long a turn waits for the client's latest messages to sync to this machine. */
13
+ export const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5_000;
14
+ /** Client tails that never synced, so later turns of their thread do not wait for them again. */
15
+ const MAX_REMEMBERED_MISSING_TAILS = 200;
16
+ export class QueueFullError extends Error {
17
+ constructor() {
18
+ super(`At most ${MAX_QUEUED_TURNS} messages can wait in one conversation`);
19
+ this.name = 'QueueFullError';
20
+ }
21
+ }
22
+ function statusForStop(stopReason) {
23
+ if (stopReason === 'cancelled')
24
+ return 'cancelled';
25
+ if (stopReason === 'error')
26
+ return 'failed';
27
+ return 'succeeded';
28
+ }
29
+ /** The user's instructions for the agent, as the start of a new session. */
30
+ export function agentInstructionsPreamble(instructions) {
31
+ const text = instructions.trim();
32
+ if (!text)
33
+ return undefined;
34
+ const clipped = text.length > MAX_INSTRUCTIONS_CHARS ? `${text.slice(0, MAX_INSTRUCTIONS_CHARS)}…` : text;
35
+ return [
36
+ '<dinox_agent_instructions>',
37
+ 'Instructions the user set for you in Dinox. Follow them throughout this conversation.',
38
+ '',
39
+ clipped.replaceAll('</dinox_agent_instructions>', '<\\/dinox_agent_instructions>'),
40
+ '</dinox_agent_instructions>',
41
+ ].join('\n');
42
+ }
43
+ function withErrorText(message, error) {
44
+ return { ...message, metadata: { ...(message.metadata ?? {}), error } };
45
+ }
46
+ /**
47
+ * Runs accepted turns on one gateway. A thread has at most one active run;
48
+ * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
49
+ * together as one turn (each keeps its own message; the reply follows the
50
+ * last). The agent session is scoped to the thread's current topic: a "new
51
+ * topic" marker in Dinox starts a fresh agent session, everything else continues it.
52
+ * The thread may have been answered by other agents meanwhile, so each turn
53
+ * also carries the messages this gateway's session has not seen.
54
+ */
55
+ export class GatewayRunExecutor {
56
+ deps;
57
+ activeByThread = new Map();
58
+ /** The request each active run answers, for parenting turns queued behind it. */
59
+ activeRequests = new Map();
60
+ queuedByThread = new Map();
61
+ queueIndex = 0;
62
+ /** Accepts of one thread run one at a time, so two can never both start a turn. */
63
+ acceptChains = new Map();
64
+ missingTails = new Set();
65
+ constructor(deps) {
66
+ this.deps = deps;
67
+ }
68
+ /** Threads with an active or waiting turn. */
69
+ busyThreadIds() {
70
+ return [...new Set([...this.activeByThread.keys(), ...this.queuedByThread.keys()])];
71
+ }
72
+ isBusy(threadId) {
73
+ return this.activeByThread.has(threadId) || this.queuedByThread.has(threadId);
74
+ }
75
+ /** Persists the turn, then runs it now or queues it behind the thread's active turn. */
76
+ accept(request, emit) {
77
+ const previous = this.acceptChains.get(request.threadId) ?? Promise.resolve();
78
+ const next = previous.catch(() => undefined).then(() => this.acceptInOrder(request, emit));
79
+ this.acceptChains.set(request.threadId, next);
80
+ void next
81
+ .catch(() => undefined)
82
+ .finally(() => {
83
+ if (this.acceptChains.get(request.threadId) === next)
84
+ this.acceptChains.delete(request.threadId);
85
+ });
86
+ return next;
87
+ }
88
+ async acceptInOrder(request, emit) {
89
+ const waiting = this.queuedByThread.get(request.threadId) ?? [];
90
+ const queued = this.isBusy(request.threadId);
91
+ if (queued && waiting.length >= MAX_QUEUED_TURNS)
92
+ throw new QueueFullError();
93
+ // Strictly increasing within this process, like the desktop's queue index.
94
+ this.queueIndex = Math.max(Date.now(), this.queueIndex + 1);
95
+ const { threadCreated } = await enqueueRun(this.deps.db, {
96
+ runId: request.runId,
97
+ threadId: request.threadId,
98
+ userId: this.deps.userId,
99
+ requestId: request.requestId,
100
+ userMessage: request.userMessage,
101
+ assistantMessage: request.assistantMessage,
102
+ parentMessageId: request.parentMessageId ?? null,
103
+ createThread: request.createThread ?? null,
104
+ requestPayload: { trigger: 'submit-message', gatewayId: this.deps.gateway.id, prompt: request.prompt },
105
+ queued,
106
+ queueIndex: this.queueIndex,
107
+ ...(queued ? { queuedAfterMessageId: this.queuedParentFor(request.threadId) } : {}),
108
+ });
109
+ // The active turn may have ended while this one was being persisted.
110
+ if (queued && this.isBusy(request.threadId)) {
111
+ this.queuedByThread.set(request.threadId, [...(this.queuedByThread.get(request.threadId) ?? []), { request, emit }]);
112
+ emit({ revision: 0, status: 'queued', assistant: request.assistantMessage, pendingApprovals: [] });
113
+ return { threadCreated, queued: true };
114
+ }
115
+ if (queued) {
116
+ // Persisted as queued but nothing is ahead any more: start it, dropping the marker.
117
+ await startQueuedRuns(this.deps.db, [request.userMessage.id]);
118
+ }
119
+ this.start(request, emit, []);
120
+ return { threadCreated, queued: false };
121
+ }
122
+ /** Stops the active turn, or withdraws a waiting one. */
123
+ cancel(runId, threadId) {
124
+ const active = this.activeByThread.get(threadId);
125
+ if (active?.runId === runId) {
126
+ active.controller.abort(new Error('cancelled by user'));
127
+ for (const approval of active.approvals.values())
128
+ approval.resolve(false);
129
+ return true;
130
+ }
131
+ const waiting = this.queuedByThread.get(threadId) ?? [];
132
+ const turn = waiting.find((item) => item.request.runId === runId);
133
+ if (!turn)
134
+ return false;
135
+ this.setQueue(threadId, waiting.filter((item) => item !== turn));
136
+ void withdrawQueuedRun(this.deps.db, {
137
+ runId,
138
+ userMessageId: turn.request.userMessage.id,
139
+ assistant: turn.request.assistantMessage,
140
+ })
141
+ .then((assistant) => turn.emit({ revision: 1, status: 'cancelled', assistant, pendingApprovals: [] }))
142
+ .catch((error) => this.deps.log('withdraw queued turn failed', { runId, error: String(error) }));
143
+ return true;
144
+ }
145
+ cancelAll() {
146
+ // Waiting turns are left to the next start, which marks them interrupted.
147
+ this.queuedByThread.clear();
148
+ for (const active of this.activeByThread.values())
149
+ this.cancel(active.runId, active.threadId);
150
+ }
151
+ /** A queued turn follows the last one waiting, else the reply being written. */
152
+ queuedParentFor(threadId) {
153
+ const waiting = this.queuedByThread.get(threadId);
154
+ return (waiting?.at(-1)?.request.userMessage.id ?? this.activeRequests.get(threadId)?.assistantMessage.id);
155
+ }
156
+ setQueue(threadId, turns) {
157
+ if (turns.length > 0)
158
+ this.queuedByThread.set(threadId, turns);
159
+ else
160
+ this.queuedByThread.delete(threadId);
161
+ }
162
+ start(request, emit, merged) {
163
+ const active = {
164
+ runId: request.runId,
165
+ threadId: request.threadId,
166
+ controller: new AbortController(),
167
+ approvals: new Map(),
168
+ answered: new Set(),
169
+ };
170
+ this.activeByThread.set(request.threadId, active);
171
+ this.activeRequests.set(request.threadId, request);
172
+ const releaseAwake = this.deps.keepAwake?.();
173
+ void this.execute(request, active, emit, merged).finally(() => {
174
+ releaseAwake?.();
175
+ if (this.activeByThread.get(request.threadId) === active) {
176
+ this.activeByThread.delete(request.threadId);
177
+ this.activeRequests.delete(request.threadId);
178
+ }
179
+ void this.drain(request.threadId).catch((error) => this.deps.log('start queued turns failed', { threadId: request.threadId, error: String(error) }));
180
+ });
181
+ }
182
+ /**
183
+ * Runs everything that waited as one turn: the agent reads the messages
184
+ * together, as the user meant them, instead of answering each in isolation.
185
+ * Earlier turns end as merged into the last, which carries the reply.
186
+ */
187
+ async drain(threadId) {
188
+ if (this.activeByThread.has(threadId))
189
+ return;
190
+ const waiting = this.queuedByThread.get(threadId) ?? [];
191
+ this.queuedByThread.delete(threadId);
192
+ const last = waiting.at(-1);
193
+ if (!last)
194
+ return;
195
+ const earlier = waiting.slice(0, -1);
196
+ await startQueuedRuns(this.deps.db, waiting.map((turn) => turn.request.userMessage.id));
197
+ for (const turn of earlier) {
198
+ const assistant = await finishMergedRun(this.deps.db, {
199
+ runId: turn.request.runId,
200
+ mergedIntoRunId: last.request.runId,
201
+ assistant: turn.request.assistantMessage,
202
+ });
203
+ turn.emit({ revision: 1, status: 'succeeded', assistant, pendingApprovals: [] });
204
+ }
205
+ this.start(last.request, last.emit, earlier.map((turn) => turn.request));
206
+ }
207
+ respondApproval(input) {
208
+ const active = this.activeByThread.get(input.threadId);
209
+ if (!active || active.runId !== input.runId)
210
+ return 'stale';
211
+ if (active.answered.has(input.approvalId))
212
+ return 'already_applied';
213
+ const pending = active.approvals.get(input.approvalId);
214
+ if (!pending)
215
+ return 'stale';
216
+ pending.resolve(input.approved);
217
+ return 'applied';
218
+ }
219
+ async execute(request, active, emit,
220
+ /** Earlier queued turns answered together with this one. */
221
+ merged) {
222
+ const { db, gateway, adapter, log } = this.deps;
223
+ const projection = new AssistantProjection(request.assistantMessage);
224
+ let status = 'running';
225
+ let lastPersistedAt = 0;
226
+ const publish = (persist) => {
227
+ const assistant = projection.snapshot();
228
+ emit({
229
+ revision: projection.revision,
230
+ status,
231
+ assistant,
232
+ pendingApprovals: projectPendingApprovals(assistant, { runId: active.runId, threadId: active.threadId }),
233
+ });
234
+ const now = Date.now();
235
+ if (persist || now - lastPersistedAt >= SNAPSHOT_PERSIST_INTERVAL_MS) {
236
+ lastPersistedAt = now;
237
+ void updateRunSnapshot(db, { runId: active.runId, status, assistant }).catch((error) => log('persist run snapshot failed', { runId: active.runId, error: String(error) }));
238
+ }
239
+ };
240
+ // Registered when the approval request is emitted, so a reply that races
241
+ // ahead of the adapter's await is still applied.
242
+ const decisions = new Map();
243
+ const registerApproval = (approvalId) => {
244
+ const existing = decisions.get(approvalId);
245
+ if (existing)
246
+ return existing;
247
+ const decision = new Promise((resolve) => {
248
+ const finish = (approved) => {
249
+ clearTimeout(timer);
250
+ active.approvals.delete(approvalId);
251
+ active.answered.add(approvalId);
252
+ status = active.approvals.size > 0 ? 'waiting_approval' : 'running';
253
+ resolve(approved);
254
+ };
255
+ const timer = setTimeout(() => finish(false), APPROVAL_TIMEOUT_MS);
256
+ active.approvals.set(approvalId, { resolve: finish, timer });
257
+ if (active.controller.signal.aborted)
258
+ finish(false);
259
+ });
260
+ decisions.set(approvalId, decision);
261
+ return decision;
262
+ };
263
+ const awaitApproval = registerApproval;
264
+ let errorText = null;
265
+ let nextBinding = null;
266
+ try {
267
+ publish(true);
268
+ const [topic, binding] = await Promise.all([
269
+ readCurrentTopic(db, active.threadId),
270
+ readSessionBinding(db, active.threadId, gateway.id),
271
+ ]);
272
+ const topicId = topic?.id ?? null;
273
+ const continued = binding?.topicId === topicId ? binding : null;
274
+ const turns = [...merged, request];
275
+ const [resources, context, instructions] = await Promise.all([
276
+ Promise.all(turns.map((turn) => this.resolveResources(turn.userMessage))).then((lists) => lists.flat()),
277
+ this.clientTailSynced(turns[0], active.controller.signal).then((synced) => this.unseenContext(active.threadId, turns[0].userMessage.id, topic?.createdAt ?? null, continued, synced)),
278
+ readAgentInstructions(db, active.threadId).catch(() => ''),
279
+ ]);
280
+ // A session keeps the directory it started in; changing the default only affects new ones.
281
+ const cwd = continued ? (continued.cwd ?? gateway.previousCwd ?? gateway.cwd ?? os.homedir()) : (gateway.cwd ?? os.homedir());
282
+ // Validated on receipt; a malformed value never reaches here.
283
+ const requestedConfig = readAgentConfig(request);
284
+ const agentConfig = requestedConfig && requestedConfig !== 'invalid' ? requestedConfig : undefined;
285
+ const prompt = turns.map((turn) => turn.prompt.trim()).filter(Boolean).join('\n\n');
286
+ const result = await adapter.runTurn({
287
+ sessionId: continued?.sessionId ?? null,
288
+ cwd,
289
+ sessionPreamble: agentInstructionsPreamble(instructions),
290
+ prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
291
+ resources,
292
+ signal: active.controller.signal,
293
+ config: agentConfig,
294
+ awaitApproval,
295
+ onEvent: (event) => {
296
+ projection.apply(event);
297
+ if (event.type === 'approval-request') {
298
+ void registerApproval(event.approvalId);
299
+ status = 'waiting_approval';
300
+ }
301
+ publish(event.type === 'approval-request' || event.type === 'approval-resolved');
302
+ },
303
+ });
304
+ nextBinding = {
305
+ sessionId: result.sessionId,
306
+ topicId,
307
+ cwd,
308
+ lastSeenMessageId: context?.incomplete
309
+ ? // Messages that had not synced yet sort before this turn: keep the mark so the
310
+ // next turn still catches up on them (its own turns are left out by session).
311
+ continued && continued.sessionId === result.sessionId && !continued.legacy
312
+ ? continued.lastSeenMessageId
313
+ : null
314
+ : // Its own reply is the newest message the session has seen.
315
+ request.assistantMessage.id,
316
+ };
317
+ status = statusForStop(result.stopReason);
318
+ errorText = result.error ?? null;
319
+ }
320
+ catch (error) {
321
+ status = active.controller.signal.aborted ? 'cancelled' : 'failed';
322
+ errorText = error instanceof Error ? error.message : String(error);
323
+ log('gateway run failed', { runId: active.runId, error: errorText });
324
+ }
325
+ finally {
326
+ for (const approval of active.approvals.values())
327
+ clearTimeout(approval.timer);
328
+ }
329
+ projection.finish();
330
+ const assistant = errorText ? withErrorText(projection.snapshot(), errorText) : projection.snapshot();
331
+ try {
332
+ await commitRunTerminal(db, {
333
+ runId: active.runId,
334
+ threadId: active.threadId,
335
+ userId: this.deps.userId,
336
+ userMessageId: request.userMessage.id,
337
+ status,
338
+ assistant: nextBinding
339
+ ? withGatewaySession(toTranscriptMessage(assistant), { gatewayId: gateway.id, sessionId: nextBinding.sessionId })
340
+ : toTranscriptMessage(assistant),
341
+ errorText,
342
+ });
343
+ }
344
+ catch (error) {
345
+ log('commit run terminal failed', { runId: active.runId, error: String(error) });
346
+ }
347
+ if (nextBinding) {
348
+ // After the commit, so the reply it marks as seen is in the transcript.
349
+ await writeSessionBinding(db, active.threadId, gateway.id, nextBinding).catch((error) => log('write session binding failed', { runId: active.runId, error: String(error) }));
350
+ }
351
+ emit({ revision: projection.revision, status, assistant, pendingApprovals: [] });
352
+ }
353
+ /**
354
+ * The client sends the last message it saw; a turn reads the transcript only
355
+ * once that message has synced here (its upload may still be in flight), or
356
+ * gives up after a short wait. A tail that never arrived is not waited for again.
357
+ */
358
+ async clientTailSynced(request, signal) {
359
+ const tailId = request.parentMessageId;
360
+ if (!tailId)
361
+ return true;
362
+ const key = `${request.threadId}:${tailId}`;
363
+ const synced = await waitForMessage(this.deps.db, tailId, {
364
+ timeoutMs: this.missingTails.has(key) ? 0 : (this.deps.clientTailSyncTimeoutMs ?? CLIENT_TAIL_SYNC_TIMEOUT_MS),
365
+ signal,
366
+ });
367
+ if (synced) {
368
+ this.missingTails.delete(key);
369
+ }
370
+ else {
371
+ if (this.missingTails.size >= MAX_REMEMBERED_MISSING_TAILS)
372
+ this.missingTails.clear();
373
+ this.missingTails.add(key);
374
+ this.deps.log('client messages not synced yet', { threadId: request.threadId, tailId });
375
+ }
376
+ return synced;
377
+ }
378
+ /**
379
+ * Messages before this turn that the session has not seen: the whole topic
380
+ * for a new session, those after the session's mark for a continued one,
381
+ * leaving out the turns the session answered itself. A session bound before
382
+ * seen messages were tracked is assumed to be up to date.
383
+ */
384
+ async unseenContext(threadId, turnMessageId, topicStartedAt, continued, synced) {
385
+ if (continued?.legacy)
386
+ return synced ? null : { threadId, messages: [], earlier: 0, incomplete: true };
387
+ try {
388
+ const page = await readTranscriptPage(this.deps.db, {
389
+ threadId,
390
+ since: topicStartedAt,
391
+ afterMessageId: continued?.lastSeenMessageId ?? null,
392
+ beforeMessageId: turnMessageId,
393
+ excludeSessionId: continued?.sessionId ?? null,
394
+ limit: CONTEXT_MESSAGE_LIMIT,
395
+ });
396
+ return { threadId, ...page, ...(synced ? {} : { incomplete: true }) };
397
+ }
398
+ catch (error) {
399
+ // Missing context degrades the answer; it must not fail the turn. Marked
400
+ // incomplete so the session's mark stays and the next turn retries.
401
+ this.deps.log('read conversation context failed', { threadId, error: String(error) });
402
+ return { threadId, messages: [], earlier: 0, incomplete: true };
403
+ }
404
+ }
405
+ async resolveResources(message) {
406
+ const resources = [];
407
+ for (const part of message.parts) {
408
+ if (part.type !== 'file')
409
+ continue;
410
+ const dinox = part.providerMetadata?.dinox;
411
+ if (typeof dinox?.resourceId !== 'string')
412
+ continue;
413
+ const resource = await resolveTrustedResource(this.deps.db, {
414
+ resourceId: dinox.resourceId,
415
+ userId: this.deps.userId,
416
+ });
417
+ if (resource)
418
+ resources.push({ uri: resource.url, name: resource.name, mediaType: resource.mediaType });
419
+ }
420
+ return resources;
421
+ }
422
+ }
@@ -0,0 +1,30 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import { type DeviceHostStatus } from './deviceHost.js';
3
+ type Logger = (message: string, details?: Record<string, unknown>) => void;
4
+ export type GatewayServiceDeps = {
5
+ db: AbstractPowerSyncDatabase;
6
+ userId: string;
7
+ authorization: string;
8
+ log?: Logger;
9
+ credentialsUrl?: string;
10
+ };
11
+ /**
12
+ * Runs every enabled gateway inside the daemon: the daemon owns the PowerSync
13
+ * database, so gateway writes share its connection and upload queue.
14
+ */
15
+ export declare class GatewayService {
16
+ private readonly deps;
17
+ private hosts;
18
+ private readonly log;
19
+ private readonly keepAwake;
20
+ constructor(deps: GatewayServiceDeps);
21
+ start(): Promise<void>;
22
+ stop(): Promise<void>;
23
+ /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
24
+ reload(): Promise<DeviceHostStatus[]>;
25
+ /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
26
+ private updateSettings;
27
+ private robotRegistration;
28
+ status(): DeviceHostStatus[];
29
+ }
30
+ export {};
@@ -0,0 +1,112 @@
1
+ import os from 'node:os';
2
+ import { getPackageVersion } from '../utils/version.js';
3
+ import { createGatewayAdapter } from './adapters/index.js';
4
+ import { getMachineId, loadGateways, updateGatewaySettings } from './config.js';
5
+ import { GatewayDeviceHost } from './deviceHost.js';
6
+ import { KeepAwake } from './keepAwake.js';
7
+ import { interruptOrphanedRuns, retireRemovedRobots, upsertRobot } from './threadStore.js';
8
+ /** Settings the owner changes from the app; the running gateway picks them up without a restart. */
9
+ const LIVE_SETTINGS = ['cwd', 'previousCwd', 'approval', 'paused', 'dinoxTools'];
10
+ function applyLiveSettings(target, source) {
11
+ for (const key of LIVE_SETTINGS) {
12
+ if (source[key] === undefined)
13
+ delete target[key];
14
+ else
15
+ target[key] = source[key];
16
+ }
17
+ }
18
+ const defaultLog = (message, details) => {
19
+ process.stderr.write(`[dinox-gateway] ${message}${details ? ` ${JSON.stringify(details)}` : ''}\n`);
20
+ };
21
+ /**
22
+ * Runs every enabled gateway inside the daemon: the daemon owns the PowerSync
23
+ * database, so gateway writes share its connection and upload queue.
24
+ */
25
+ export class GatewayService {
26
+ deps;
27
+ hosts = [];
28
+ log;
29
+ keepAwake;
30
+ constructor(deps) {
31
+ this.deps = deps;
32
+ this.log = deps.log ?? defaultLog;
33
+ this.keepAwake = new KeepAwake(this.log);
34
+ }
35
+ async start() {
36
+ const configured = await loadGateways(this.deps.userId);
37
+ const machineId = await getMachineId();
38
+ // Reconcile before anything else, also when the last gateway was removed.
39
+ const retired = await retireRemovedRobots(this.deps.db, {
40
+ userId: this.deps.userId,
41
+ machineId,
42
+ keepIds: configured.map((gateway) => gateway.id),
43
+ }).catch((error) => {
44
+ this.log('retire removed robots failed', { error: String(error) });
45
+ return [];
46
+ });
47
+ if (retired.length > 0)
48
+ this.log('retired robots of removed gateways', { count: retired.length });
49
+ const gateways = configured.filter((gateway) => gateway.enabled);
50
+ if (gateways.length === 0)
51
+ return;
52
+ const interrupted = await interruptOrphanedRuns(this.deps.db, this.deps.userId);
53
+ if (interrupted > 0)
54
+ this.log('marked runs from a previous host as interrupted', { count: interrupted });
55
+ for (const gateway of gateways) {
56
+ await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
57
+ const host = new GatewayDeviceHost({
58
+ gateway,
59
+ db: this.deps.db,
60
+ userId: this.deps.userId,
61
+ authorization: this.deps.authorization,
62
+ adapter: createGatewayAdapter(gateway, (message, details) => this.log(message, { gateway: gateway.name, ...details })),
63
+ appVersion: getPackageVersion(),
64
+ log: (message, details) => this.log(message, { gateway: gateway.name, ...details }),
65
+ credentialsUrl: this.deps.credentialsUrl,
66
+ updateSettings: (change) => this.updateSettings(gateway, machineId, change),
67
+ keepAwake: () => this.keepAwake.acquire(),
68
+ });
69
+ host.start();
70
+ this.hosts.push(host);
71
+ }
72
+ }
73
+ async stop() {
74
+ const hosts = this.hosts;
75
+ this.hosts = [];
76
+ await Promise.allSettled(hosts.map((host) => host.stop()));
77
+ this.keepAwake.dispose();
78
+ }
79
+ /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
80
+ async reload() {
81
+ await this.stop();
82
+ await this.start();
83
+ return this.status();
84
+ }
85
+ /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
86
+ async updateSettings(gateway, machineId, change) {
87
+ const updated = await updateGatewaySettings(this.deps.userId, gateway.id, change);
88
+ applyLiveSettings(gateway, updated);
89
+ await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('sync robot settings failed', { gateway: gateway.name, error: String(error) }));
90
+ }
91
+ robotRegistration(gateway, machineId) {
92
+ return {
93
+ id: gateway.id,
94
+ userId: this.deps.userId,
95
+ name: gateway.name,
96
+ deviceId: gateway.deviceId,
97
+ agentType: gateway.kind,
98
+ cwd: gateway.cwd ?? null,
99
+ deviceName: os.hostname(),
100
+ machineId,
101
+ ...(gateway.agentId ? { agentId: gateway.agentId } : {}),
102
+ settings: {
103
+ approval: gateway.approval ?? 'ask',
104
+ paused: gateway.paused === true,
105
+ dinoxTools: gateway.dinoxTools !== false,
106
+ },
107
+ };
108
+ }
109
+ status() {
110
+ return this.hosts.map((host) => host.status());
111
+ }
112
+ }
@@ -0,0 +1,33 @@
1
+ import type { ChatUIMessage, ThreadRunEventStatus } from '../vendor/electron/shared/external-types.js';
2
+ import { type RemoteDeviceRunStreamEvent, type RemoteToolApprovalPrompt } from '../vendor/electron/shared/remote-device-agent.js';
3
+ export type RunSnapshot = {
4
+ revision: number;
5
+ status: ThreadRunEventStatus;
6
+ assistant: ChatUIMessage;
7
+ pendingApprovals: RemoteToolApprovalPrompt[];
8
+ };
9
+ /**
10
+ * Keeps an event under the NATS payload budget: drop parts (the snapshot is
11
+ * self-correcting and `run.status` recovers the rest), then drop extras.
12
+ */
13
+ export declare function boundRunEvent(event: RemoteDeviceRunStreamEvent): RemoteDeviceRunStreamEvent;
14
+ /**
15
+ * One per run. Streaming snapshots are coalesced to the latest per 100ms;
16
+ * approval and terminal states go out immediately (and supersede a pending one).
17
+ */
18
+ export declare class RunStreamPublisher {
19
+ private readonly ids;
20
+ private readonly publish;
21
+ private sequence;
22
+ private pending;
23
+ private timer;
24
+ private disposed;
25
+ constructor(ids: {
26
+ runId: string;
27
+ threadId: string;
28
+ }, publish: (event: RemoteDeviceRunStreamEvent) => void);
29
+ push(snapshot: RunSnapshot): void;
30
+ dispose(): void;
31
+ private clearTimer;
32
+ private emit;
33
+ }