@dinoxx/dinox-cli 2.3.0 → 2.4.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 (47) 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 +31 -0
  11. package/dist/gateway/adapters/acp.d.ts +42 -0
  12. package/dist/gateway/adapters/acp.js +285 -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 +30 -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 +50 -0
  20. package/dist/gateway/config.js +253 -0
  21. package/dist/gateway/detect.d.ts +40 -0
  22. package/dist/gateway/detect.js +145 -0
  23. package/dist/gateway/deviceHost.d.ts +82 -0
  24. package/dist/gateway/deviceHost.js +530 -0
  25. package/dist/gateway/mcpServer.d.ts +4 -0
  26. package/dist/gateway/mcpServer.js +118 -0
  27. package/dist/gateway/protocol.d.ts +29 -0
  28. package/dist/gateway/protocol.js +127 -0
  29. package/dist/gateway/runExecutor.d.ts +67 -0
  30. package/dist/gateway/runExecutor.js +316 -0
  31. package/dist/gateway/service.d.ts +26 -0
  32. package/dist/gateway/service.js +82 -0
  33. package/dist/gateway/streamPublisher.d.ts +33 -0
  34. package/dist/gateway/streamPublisher.js +93 -0
  35. package/dist/gateway/threadStore.d.ts +157 -0
  36. package/dist/gateway/threadStore.js +430 -0
  37. package/dist/gateway/types.d.ts +117 -0
  38. package/dist/gateway/types.js +9 -0
  39. package/dist/vendor/electron/domain/properties.js +12 -1
  40. package/dist/vendor/electron/domain/saved-views.js +4 -1
  41. package/dist/vendor/electron/shared/external-types.d.ts +18 -0
  42. package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
  43. package/dist/vendor/electron/shared/note-properties.js +45 -0
  44. package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
  45. package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
  46. package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
  47. package/package.json +5 -2
@@ -0,0 +1,127 @@
1
+ import { REMOTE_DEVICE_PROTOCOL_VERSION, } from '../vendor/electron/shared/remote-device-agent.js';
2
+ /** Request validation, rule-for-rule with the desktop host (RemoteDeviceHostModule). */
3
+ const SUBJECT_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
4
+ const DEVICE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
5
+ const OPAQUE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
6
+ const MAX_REQUEST_BYTES = 900_000;
7
+ const MAX_PROMPT_LENGTH = 100_000;
8
+ const MAX_FILE_PARTS = 16;
9
+ const MAX_APPROVAL_REQUEST_BYTES = 16 * 1024;
10
+ const MAX_APPROVAL_REASON_LENGTH = 500;
11
+ const MAX_AGENT_CONFIG_ENTRIES = 8;
12
+ const MAX_AGENT_CONFIG_TEXT = 512;
13
+ const isSubjectId = (value) => typeof value === 'string' && SUBJECT_ID.test(value);
14
+ const isDeviceId = (value) => typeof value === 'string' && DEVICE_ID.test(value);
15
+ function isMessageOfType(value, type) {
16
+ if (!value || typeof value !== 'object' || Array.isArray(value))
17
+ return false;
18
+ const record = value;
19
+ return record.protocolVersion === REMOTE_DEVICE_PROTOCOL_VERSION && record.type === type && isSubjectId(record.requestId);
20
+ }
21
+ function isChatMessage(value, role) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value))
23
+ return false;
24
+ const message = value;
25
+ return isSubjectId(message.id) && message.role === role && Array.isArray(message.parts);
26
+ }
27
+ function isHttpsUrl(value) {
28
+ if (typeof value !== 'string')
29
+ return false;
30
+ try {
31
+ return new URL(value.trim()).protocol === 'https:';
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ export function validateRunRequest(value, byteLength) {
38
+ if (!isMessageOfType(value, 'run.request'))
39
+ return 'invalid protocol version or type';
40
+ if (byteLength > MAX_REQUEST_BYTES)
41
+ return 'request payload too large';
42
+ if (!isDeviceId(value.clientDeviceId))
43
+ return 'invalid clientDeviceId';
44
+ if (!isSubjectId(value.runId) || !isSubjectId(value.threadId))
45
+ return 'invalid request ids';
46
+ if (typeof value.prompt !== 'string' || !value.prompt.trim())
47
+ return 'prompt is empty';
48
+ if (value.prompt.length > MAX_PROMPT_LENGTH)
49
+ return 'prompt too long';
50
+ if (!isChatMessage(value.userMessage, 'user'))
51
+ return 'invalid userMessage';
52
+ if (!isChatMessage(value.assistantMessage, 'assistant'))
53
+ return 'invalid assistantMessage';
54
+ const fileParts = value.userMessage.parts.filter((part) => part.type === 'file' && typeof part.url === 'string');
55
+ if (fileParts.length > MAX_FILE_PARTS)
56
+ return 'too many attachments';
57
+ for (const part of fileParts) {
58
+ const dinox = part.providerMetadata?.dinox;
59
+ if (!isSubjectId(dinox?.resourceId))
60
+ return 'invalid attachment resourceId';
61
+ if (!isHttpsUrl(part.url))
62
+ return 'attachment URL must use HTTPS';
63
+ }
64
+ if (value.createThread && value.createThread.id !== value.threadId)
65
+ return 'createThread.id must equal threadId';
66
+ if (readAgentConfig(value) === 'invalid')
67
+ return 'invalid agentConfig';
68
+ return null;
69
+ }
70
+ export function isAgentConfigQuery(value) {
71
+ return isMessageOfType(value, 'agent.config.get') && isDeviceId(value.clientDeviceId);
72
+ }
73
+ /** The run's chosen agent settings, `null` when none, `'invalid'` when malformed. */
74
+ export function readAgentConfig(request) {
75
+ const raw = request.agentConfig;
76
+ if (raw === undefined || raw === null)
77
+ return null;
78
+ if (typeof raw !== 'object' || Array.isArray(raw))
79
+ return 'invalid';
80
+ const entries = Object.entries(raw);
81
+ if (entries.length > MAX_AGENT_CONFIG_ENTRIES)
82
+ return 'invalid';
83
+ const config = {};
84
+ for (const [id, value] of entries) {
85
+ if (!id || id.length > MAX_AGENT_CONFIG_TEXT)
86
+ return 'invalid';
87
+ if (typeof value !== 'string' || !value || value.length > MAX_AGENT_CONFIG_TEXT)
88
+ return 'invalid';
89
+ config[id] = value;
90
+ }
91
+ return entries.length > 0 ? config : null;
92
+ }
93
+ export function validateApprovalRequest(value, byteLength) {
94
+ if (byteLength > MAX_APPROVAL_REQUEST_BYTES)
95
+ return 'approval request too large';
96
+ if (!isMessageOfType(value, 'tool.approval.respond')) {
97
+ return 'invalid protocol version or type';
98
+ }
99
+ if (!isSubjectId(value.runId) ||
100
+ !isSubjectId(value.threadId) ||
101
+ typeof value.approvalId !== 'string' ||
102
+ !OPAQUE_ID.test(value.approvalId) ||
103
+ !isDeviceId(value.clientDeviceId)) {
104
+ return 'invalid approval ids';
105
+ }
106
+ if (typeof value.approved !== 'boolean')
107
+ return 'approved must be a boolean';
108
+ if (value.reason !== undefined &&
109
+ (typeof value.reason !== 'string' || value.reason.length > MAX_APPROVAL_REASON_LENGTH)) {
110
+ return 'invalid approval reason';
111
+ }
112
+ return null;
113
+ }
114
+ export function isCancelRequest(value) {
115
+ return (isMessageOfType(value, 'run.cancel') &&
116
+ isSubjectId(value.runId) &&
117
+ isSubjectId(value.threadId));
118
+ }
119
+ export function isRunStatusRequest(value) {
120
+ return (isMessageOfType(value, 'run.status') &&
121
+ isSubjectId(value.runId) &&
122
+ isSubjectId(value.threadId));
123
+ }
124
+ /** `Math.max(250, rand * min(60s, 1s * 2^attempt))`, the desktop's full-jitter backoff. */
125
+ export function reconnectDelayMs(attempt, random = Math.random) {
126
+ return Math.max(250, Math.floor(random() * Math.min(60_000, 1000 * 2 ** attempt)));
127
+ }
@@ -0,0 +1,67 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import type { RemoteDeviceRunRequest, RemoteDeviceToolApprovalAckCode } from '../vendor/electron/shared/remote-device-agent.js';
3
+ import type { RunSnapshot } from './streamPublisher.js';
4
+ import type { GatewayAdapter, GatewayDefinition } from './types.js';
5
+ /** Turns waiting behind a thread's active one; more are refused as `busy`. */
6
+ export declare const MAX_QUEUED_TURNS = 10;
7
+ type Logger = (message: string, details?: Record<string, unknown>) => void;
8
+ type Emit = (snapshot: RunSnapshot) => void;
9
+ export declare class QueueFullError extends Error {
10
+ constructor();
11
+ }
12
+ export type RunExecutorDeps = {
13
+ db: AbstractPowerSyncDatabase;
14
+ userId: string;
15
+ gateway: GatewayDefinition;
16
+ adapter: GatewayAdapter;
17
+ log: Logger;
18
+ };
19
+ /**
20
+ * Runs accepted turns on one gateway. A thread has at most one active run;
21
+ * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
22
+ * together as one turn (each keeps its own message; the reply follows the
23
+ * last). The agent session is scoped to the thread's current topic: a "new
24
+ * topic" marker in Dinox starts a fresh agent session, everything else continues it.
25
+ */
26
+ export declare class GatewayRunExecutor {
27
+ private readonly deps;
28
+ private readonly activeByThread;
29
+ /** The request each active run answers, for parenting turns queued behind it. */
30
+ private readonly activeRequests;
31
+ private readonly queuedByThread;
32
+ private queueIndex;
33
+ /** Accepts of one thread run one at a time, so two can never both start a turn. */
34
+ private readonly acceptChains;
35
+ constructor(deps: RunExecutorDeps);
36
+ /** Threads with an active or waiting turn. */
37
+ busyThreadIds(): string[];
38
+ isBusy(threadId: string): boolean;
39
+ /** Persists the turn, then runs it now or queues it behind the thread's active turn. */
40
+ accept(request: RemoteDeviceRunRequest, emit: Emit): Promise<{
41
+ threadCreated: boolean;
42
+ queued: boolean;
43
+ }>;
44
+ private acceptInOrder;
45
+ /** Stops the active turn, or withdraws a waiting one. */
46
+ cancel(runId: string, threadId: string): boolean;
47
+ cancelAll(): void;
48
+ /** A queued turn follows the last one waiting, else the reply being written. */
49
+ private queuedParentFor;
50
+ private setQueue;
51
+ private start;
52
+ /**
53
+ * Runs everything that waited as one turn: the agent reads the messages
54
+ * together, as the user meant them, instead of answering each in isolation.
55
+ * Earlier turns end as merged into the last, which carries the reply.
56
+ */
57
+ private drain;
58
+ respondApproval(input: {
59
+ runId: string;
60
+ threadId: string;
61
+ approvalId: string;
62
+ approved: boolean;
63
+ }): RemoteDeviceToolApprovalAckCode;
64
+ private execute;
65
+ private resolveResources;
66
+ }
67
+ export {};
@@ -0,0 +1,316 @@
1
+ import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
2
+ import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, withdrawQueuedRun, readCurrentTopicId, readSessionBinding, resolveTrustedResource, updateRunSnapshot, writeSessionBinding, } from './threadStore.js';
3
+ import { readAgentConfig } from './protocol.js';
4
+ const SNAPSHOT_PERSIST_INTERVAL_MS = 1_000;
5
+ /** Turns waiting behind a thread's active one; more are refused as `busy`. */
6
+ export const MAX_QUEUED_TURNS = 10;
7
+ const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
8
+ export class QueueFullError extends Error {
9
+ constructor() {
10
+ super(`At most ${MAX_QUEUED_TURNS} messages can wait in one conversation`);
11
+ this.name = 'QueueFullError';
12
+ }
13
+ }
14
+ function statusForStop(stopReason) {
15
+ if (stopReason === 'cancelled')
16
+ return 'cancelled';
17
+ if (stopReason === 'error')
18
+ return 'failed';
19
+ return 'succeeded';
20
+ }
21
+ function withErrorText(message, error) {
22
+ return { ...message, metadata: { ...(message.metadata ?? {}), error } };
23
+ }
24
+ /**
25
+ * Runs accepted turns on one gateway. A thread has at most one active run;
26
+ * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
27
+ * together as one turn (each keeps its own message; the reply follows the
28
+ * last). The agent session is scoped to the thread's current topic: a "new
29
+ * topic" marker in Dinox starts a fresh agent session, everything else continues it.
30
+ */
31
+ export class GatewayRunExecutor {
32
+ deps;
33
+ activeByThread = new Map();
34
+ /** The request each active run answers, for parenting turns queued behind it. */
35
+ activeRequests = new Map();
36
+ queuedByThread = new Map();
37
+ queueIndex = 0;
38
+ /** Accepts of one thread run one at a time, so two can never both start a turn. */
39
+ acceptChains = new Map();
40
+ constructor(deps) {
41
+ this.deps = deps;
42
+ }
43
+ /** Threads with an active or waiting turn. */
44
+ busyThreadIds() {
45
+ return [...new Set([...this.activeByThread.keys(), ...this.queuedByThread.keys()])];
46
+ }
47
+ isBusy(threadId) {
48
+ return this.activeByThread.has(threadId) || this.queuedByThread.has(threadId);
49
+ }
50
+ /** Persists the turn, then runs it now or queues it behind the thread's active turn. */
51
+ accept(request, emit) {
52
+ const previous = this.acceptChains.get(request.threadId) ?? Promise.resolve();
53
+ const next = previous.catch(() => undefined).then(() => this.acceptInOrder(request, emit));
54
+ this.acceptChains.set(request.threadId, next);
55
+ void next
56
+ .catch(() => undefined)
57
+ .finally(() => {
58
+ if (this.acceptChains.get(request.threadId) === next)
59
+ this.acceptChains.delete(request.threadId);
60
+ });
61
+ return next;
62
+ }
63
+ async acceptInOrder(request, emit) {
64
+ const waiting = this.queuedByThread.get(request.threadId) ?? [];
65
+ const queued = this.isBusy(request.threadId);
66
+ if (queued && waiting.length >= MAX_QUEUED_TURNS)
67
+ throw new QueueFullError();
68
+ // Strictly increasing within this process, like the desktop's queue index.
69
+ this.queueIndex = Math.max(Date.now(), this.queueIndex + 1);
70
+ const { threadCreated } = await enqueueRun(this.deps.db, {
71
+ runId: request.runId,
72
+ threadId: request.threadId,
73
+ userId: this.deps.userId,
74
+ requestId: request.requestId,
75
+ userMessage: request.userMessage,
76
+ assistantMessage: request.assistantMessage,
77
+ parentMessageId: request.parentMessageId ?? null,
78
+ createThread: request.createThread ?? null,
79
+ requestPayload: { trigger: 'submit-message', gatewayId: this.deps.gateway.id, prompt: request.prompt },
80
+ queued,
81
+ queueIndex: this.queueIndex,
82
+ ...(queued ? { queuedAfterMessageId: this.queuedParentFor(request.threadId) } : {}),
83
+ });
84
+ // The active turn may have ended while this one was being persisted.
85
+ if (queued && this.isBusy(request.threadId)) {
86
+ this.queuedByThread.set(request.threadId, [...(this.queuedByThread.get(request.threadId) ?? []), { request, emit }]);
87
+ emit({ revision: 0, status: 'queued', assistant: request.assistantMessage, pendingApprovals: [] });
88
+ return { threadCreated, queued: true };
89
+ }
90
+ if (queued) {
91
+ // Persisted as queued but nothing is ahead any more: start it, dropping the marker.
92
+ await startQueuedRuns(this.deps.db, [request.userMessage.id]);
93
+ }
94
+ this.start(request, emit, []);
95
+ return { threadCreated, queued: false };
96
+ }
97
+ /** Stops the active turn, or withdraws a waiting one. */
98
+ cancel(runId, threadId) {
99
+ const active = this.activeByThread.get(threadId);
100
+ if (active?.runId === runId) {
101
+ active.controller.abort(new Error('cancelled by user'));
102
+ for (const approval of active.approvals.values())
103
+ approval.resolve(false);
104
+ return true;
105
+ }
106
+ const waiting = this.queuedByThread.get(threadId) ?? [];
107
+ const turn = waiting.find((item) => item.request.runId === runId);
108
+ if (!turn)
109
+ return false;
110
+ this.setQueue(threadId, waiting.filter((item) => item !== turn));
111
+ void withdrawQueuedRun(this.deps.db, {
112
+ runId,
113
+ userMessageId: turn.request.userMessage.id,
114
+ assistant: turn.request.assistantMessage,
115
+ })
116
+ .then((assistant) => turn.emit({ revision: 1, status: 'cancelled', assistant, pendingApprovals: [] }))
117
+ .catch((error) => this.deps.log('withdraw queued turn failed', { runId, error: String(error) }));
118
+ return true;
119
+ }
120
+ cancelAll() {
121
+ // Waiting turns are left to the next start, which marks them interrupted.
122
+ this.queuedByThread.clear();
123
+ for (const active of this.activeByThread.values())
124
+ this.cancel(active.runId, active.threadId);
125
+ }
126
+ /** A queued turn follows the last one waiting, else the reply being written. */
127
+ queuedParentFor(threadId) {
128
+ const waiting = this.queuedByThread.get(threadId);
129
+ return (waiting?.at(-1)?.request.userMessage.id ?? this.activeRequests.get(threadId)?.assistantMessage.id);
130
+ }
131
+ setQueue(threadId, turns) {
132
+ if (turns.length > 0)
133
+ this.queuedByThread.set(threadId, turns);
134
+ else
135
+ this.queuedByThread.delete(threadId);
136
+ }
137
+ start(request, emit, merged) {
138
+ const active = {
139
+ runId: request.runId,
140
+ threadId: request.threadId,
141
+ controller: new AbortController(),
142
+ approvals: new Map(),
143
+ answered: new Set(),
144
+ };
145
+ this.activeByThread.set(request.threadId, active);
146
+ this.activeRequests.set(request.threadId, request);
147
+ void this.execute(request, active, emit, merged).finally(() => {
148
+ if (this.activeByThread.get(request.threadId) === active) {
149
+ this.activeByThread.delete(request.threadId);
150
+ this.activeRequests.delete(request.threadId);
151
+ }
152
+ void this.drain(request.threadId).catch((error) => this.deps.log('start queued turns failed', { threadId: request.threadId, error: String(error) }));
153
+ });
154
+ }
155
+ /**
156
+ * Runs everything that waited as one turn: the agent reads the messages
157
+ * together, as the user meant them, instead of answering each in isolation.
158
+ * Earlier turns end as merged into the last, which carries the reply.
159
+ */
160
+ async drain(threadId) {
161
+ if (this.activeByThread.has(threadId))
162
+ return;
163
+ const waiting = this.queuedByThread.get(threadId) ?? [];
164
+ this.queuedByThread.delete(threadId);
165
+ const last = waiting.at(-1);
166
+ if (!last)
167
+ return;
168
+ const earlier = waiting.slice(0, -1);
169
+ await startQueuedRuns(this.deps.db, waiting.map((turn) => turn.request.userMessage.id));
170
+ for (const turn of earlier) {
171
+ const assistant = await finishMergedRun(this.deps.db, {
172
+ runId: turn.request.runId,
173
+ mergedIntoRunId: last.request.runId,
174
+ assistant: turn.request.assistantMessage,
175
+ });
176
+ turn.emit({ revision: 1, status: 'succeeded', assistant, pendingApprovals: [] });
177
+ }
178
+ this.start(last.request, last.emit, earlier.map((turn) => turn.request));
179
+ }
180
+ respondApproval(input) {
181
+ const active = this.activeByThread.get(input.threadId);
182
+ if (!active || active.runId !== input.runId)
183
+ return 'stale';
184
+ if (active.answered.has(input.approvalId))
185
+ return 'already_applied';
186
+ const pending = active.approvals.get(input.approvalId);
187
+ if (!pending)
188
+ return 'stale';
189
+ pending.resolve(input.approved);
190
+ return 'applied';
191
+ }
192
+ async execute(request, active, emit,
193
+ /** Earlier queued turns answered together with this one. */
194
+ merged) {
195
+ const { db, gateway, adapter, log } = this.deps;
196
+ const projection = new AssistantProjection(request.assistantMessage);
197
+ let status = 'running';
198
+ let lastPersistedAt = 0;
199
+ const publish = (persist) => {
200
+ const assistant = projection.snapshot();
201
+ emit({
202
+ revision: projection.revision,
203
+ status,
204
+ assistant,
205
+ pendingApprovals: projectPendingApprovals(assistant, { runId: active.runId, threadId: active.threadId }),
206
+ });
207
+ const now = Date.now();
208
+ if (persist || now - lastPersistedAt >= SNAPSHOT_PERSIST_INTERVAL_MS) {
209
+ lastPersistedAt = now;
210
+ void updateRunSnapshot(db, { runId: active.runId, status, assistant }).catch((error) => log('persist run snapshot failed', { runId: active.runId, error: String(error) }));
211
+ }
212
+ };
213
+ // Registered when the approval request is emitted, so a reply that races
214
+ // ahead of the adapter's await is still applied.
215
+ const decisions = new Map();
216
+ const registerApproval = (approvalId) => {
217
+ const existing = decisions.get(approvalId);
218
+ if (existing)
219
+ return existing;
220
+ const decision = new Promise((resolve) => {
221
+ const finish = (approved) => {
222
+ clearTimeout(timer);
223
+ active.approvals.delete(approvalId);
224
+ active.answered.add(approvalId);
225
+ status = active.approvals.size > 0 ? 'waiting_approval' : 'running';
226
+ resolve(approved);
227
+ };
228
+ const timer = setTimeout(() => finish(false), APPROVAL_TIMEOUT_MS);
229
+ active.approvals.set(approvalId, { resolve: finish, timer });
230
+ if (active.controller.signal.aborted)
231
+ finish(false);
232
+ });
233
+ decisions.set(approvalId, decision);
234
+ return decision;
235
+ };
236
+ const awaitApproval = registerApproval;
237
+ let errorText = null;
238
+ try {
239
+ publish(true);
240
+ const [topicId, binding] = await Promise.all([
241
+ readCurrentTopicId(db, active.threadId),
242
+ readSessionBinding(db, active.threadId),
243
+ ]);
244
+ const sessionId = binding && binding.gatewayId === gateway.id && binding.topicId === topicId ? binding.sessionId : null;
245
+ const turns = [...merged, request];
246
+ const resources = (await Promise.all(turns.map((turn) => this.resolveResources(turn.userMessage)))).flat();
247
+ // Validated on receipt; a malformed value never reaches here.
248
+ const requestedConfig = readAgentConfig(request);
249
+ const agentConfig = requestedConfig && requestedConfig !== 'invalid' ? requestedConfig : undefined;
250
+ const result = await adapter.runTurn({
251
+ sessionId,
252
+ prompt: turns.map((turn) => turn.prompt.trim()).filter(Boolean).join('\n\n'),
253
+ resources,
254
+ signal: active.controller.signal,
255
+ config: agentConfig,
256
+ awaitApproval,
257
+ onEvent: (event) => {
258
+ projection.apply(event);
259
+ if (event.type === 'approval-request') {
260
+ void registerApproval(event.approvalId);
261
+ status = 'waiting_approval';
262
+ }
263
+ publish(event.type === 'approval-request' || event.type === 'approval-resolved');
264
+ },
265
+ });
266
+ if (result.sessionId !== sessionId) {
267
+ await writeSessionBinding(db, active.threadId, { sessionId: result.sessionId, topicId, gatewayId: gateway.id });
268
+ }
269
+ status = statusForStop(result.stopReason);
270
+ errorText = result.error ?? null;
271
+ }
272
+ catch (error) {
273
+ status = active.controller.signal.aborted ? 'cancelled' : 'failed';
274
+ errorText = error instanceof Error ? error.message : String(error);
275
+ log('gateway run failed', { runId: active.runId, error: errorText });
276
+ }
277
+ finally {
278
+ for (const approval of active.approvals.values())
279
+ clearTimeout(approval.timer);
280
+ }
281
+ projection.finish();
282
+ const assistant = errorText ? withErrorText(projection.snapshot(), errorText) : projection.snapshot();
283
+ try {
284
+ await commitRunTerminal(db, {
285
+ runId: active.runId,
286
+ threadId: active.threadId,
287
+ userId: this.deps.userId,
288
+ userMessageId: request.userMessage.id,
289
+ status,
290
+ assistant: toTranscriptMessage(assistant),
291
+ errorText,
292
+ });
293
+ }
294
+ catch (error) {
295
+ log('commit run terminal failed', { runId: active.runId, error: String(error) });
296
+ }
297
+ emit({ revision: projection.revision, status, assistant, pendingApprovals: [] });
298
+ }
299
+ async resolveResources(message) {
300
+ const resources = [];
301
+ for (const part of message.parts) {
302
+ if (part.type !== 'file')
303
+ continue;
304
+ const dinox = part.providerMetadata?.dinox;
305
+ if (typeof dinox?.resourceId !== 'string')
306
+ continue;
307
+ const resource = await resolveTrustedResource(this.deps.db, {
308
+ resourceId: dinox.resourceId,
309
+ userId: this.deps.userId,
310
+ });
311
+ if (resource)
312
+ resources.push({ uri: resource.url, name: resource.name, mediaType: resource.mediaType });
313
+ }
314
+ return resources;
315
+ }
316
+ }
@@ -0,0 +1,26 @@
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
+ constructor(deps: GatewayServiceDeps);
20
+ start(): Promise<void>;
21
+ stop(): Promise<void>;
22
+ /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
23
+ reload(): Promise<DeviceHostStatus[]>;
24
+ status(): DeviceHostStatus[];
25
+ }
26
+ export {};
@@ -0,0 +1,82 @@
1
+ import os from 'node:os';
2
+ import { getPackageVersion } from '../utils/version.js';
3
+ import { createGatewayAdapter } from './adapters/index.js';
4
+ import { getMachineId, loadGateways } from './config.js';
5
+ import { GatewayDeviceHost } from './deviceHost.js';
6
+ import { interruptOrphanedRuns, retireRemovedRobots, upsertRobot } from './threadStore.js';
7
+ const defaultLog = (message, details) => {
8
+ process.stderr.write(`[dinox-gateway] ${message}${details ? ` ${JSON.stringify(details)}` : ''}\n`);
9
+ };
10
+ /**
11
+ * Runs every enabled gateway inside the daemon: the daemon owns the PowerSync
12
+ * database, so gateway writes share its connection and upload queue.
13
+ */
14
+ export class GatewayService {
15
+ deps;
16
+ hosts = [];
17
+ log;
18
+ constructor(deps) {
19
+ this.deps = deps;
20
+ this.log = deps.log ?? defaultLog;
21
+ }
22
+ async start() {
23
+ const configured = await loadGateways(this.deps.userId);
24
+ const machineId = await getMachineId();
25
+ // Reconcile before anything else, also when the last gateway was removed.
26
+ const retired = await retireRemovedRobots(this.deps.db, {
27
+ userId: this.deps.userId,
28
+ machineId,
29
+ keepIds: configured.map((gateway) => gateway.id),
30
+ }).catch((error) => {
31
+ this.log('retire removed robots failed', { error: String(error) });
32
+ return [];
33
+ });
34
+ if (retired.length > 0)
35
+ this.log('retired robots of removed gateways', { count: retired.length });
36
+ const gateways = configured.filter((gateway) => gateway.enabled);
37
+ if (gateways.length === 0)
38
+ return;
39
+ const interrupted = await interruptOrphanedRuns(this.deps.db, this.deps.userId);
40
+ if (interrupted > 0)
41
+ this.log('marked runs from a previous host as interrupted', { count: interrupted });
42
+ for (const gateway of gateways) {
43
+ await upsertRobot(this.deps.db, {
44
+ id: gateway.id,
45
+ userId: this.deps.userId,
46
+ name: gateway.name,
47
+ deviceId: gateway.deviceId,
48
+ agentType: gateway.kind,
49
+ cwd: gateway.cwd ?? null,
50
+ deviceName: os.hostname(),
51
+ machineId,
52
+ ...(gateway.agentId ? { agentId: gateway.agentId } : {}),
53
+ }).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
54
+ const host = new GatewayDeviceHost({
55
+ gateway,
56
+ db: this.deps.db,
57
+ userId: this.deps.userId,
58
+ authorization: this.deps.authorization,
59
+ adapter: createGatewayAdapter(gateway, (message, details) => this.log(message, { gateway: gateway.name, ...details })),
60
+ appVersion: getPackageVersion(),
61
+ log: (message, details) => this.log(message, { gateway: gateway.name, ...details }),
62
+ credentialsUrl: this.deps.credentialsUrl,
63
+ });
64
+ host.start();
65
+ this.hosts.push(host);
66
+ }
67
+ }
68
+ async stop() {
69
+ const hosts = this.hosts;
70
+ this.hosts = [];
71
+ await Promise.allSettled(hosts.map((host) => host.stop()));
72
+ }
73
+ /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
74
+ async reload() {
75
+ await this.stop();
76
+ await this.start();
77
+ return this.status();
78
+ }
79
+ status() {
80
+ return this.hosts.map((host) => host.status());
81
+ }
82
+ }
@@ -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
+ }