@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.
- package/README.md +17 -3
- package/dist/cli.js +2 -0
- package/dist/commands/gateway/index.d.ts +4 -0
- package/dist/commands/gateway/index.js +300 -0
- package/dist/commands/schema/renderSkills.js +4 -0
- package/dist/commands/schema/spec.js +2 -0
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +1 -1
- package/dist/daemon/server.d.ts +1 -0
- package/dist/daemon/server.js +31 -0
- package/dist/gateway/adapters/acp.d.ts +42 -0
- package/dist/gateway/adapters/acp.js +285 -0
- package/dist/gateway/adapters/echo.d.ts +12 -0
- package/dist/gateway/adapters/echo.js +51 -0
- package/dist/gateway/adapters/index.d.ts +9 -0
- package/dist/gateway/adapters/index.js +30 -0
- package/dist/gateway/assistantProjection.d.ts +32 -0
- package/dist/gateway/assistantProjection.js +193 -0
- package/dist/gateway/config.d.ts +50 -0
- package/dist/gateway/config.js +253 -0
- package/dist/gateway/detect.d.ts +40 -0
- package/dist/gateway/detect.js +145 -0
- package/dist/gateway/deviceHost.d.ts +82 -0
- package/dist/gateway/deviceHost.js +530 -0
- package/dist/gateway/mcpServer.d.ts +4 -0
- package/dist/gateway/mcpServer.js +118 -0
- package/dist/gateway/protocol.d.ts +29 -0
- package/dist/gateway/protocol.js +127 -0
- package/dist/gateway/runExecutor.d.ts +67 -0
- package/dist/gateway/runExecutor.js +316 -0
- package/dist/gateway/service.d.ts +26 -0
- package/dist/gateway/service.js +82 -0
- package/dist/gateway/streamPublisher.d.ts +33 -0
- package/dist/gateway/streamPublisher.js +93 -0
- package/dist/gateway/threadStore.d.ts +157 -0
- package/dist/gateway/threadStore.js +430 -0
- package/dist/gateway/types.d.ts +117 -0
- package/dist/gateway/types.js +9 -0
- package/dist/vendor/electron/domain/properties.js +12 -1
- package/dist/vendor/electron/domain/saved-views.js +4 -1
- package/dist/vendor/electron/shared/external-types.d.ts +18 -0
- package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
- package/dist/vendor/electron/shared/note-properties.js +45 -0
- package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
- package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
- package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
- package/package.json +5 -2
|
@@ -0,0 +1,530 @@
|
|
|
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, isCancelRequest, isRunStatusRequest, 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, resolveTrustedResource, TERMINAL_RUN_STATUSES, threadExists } from './threadStore.js';
|
|
10
|
+
export const DEFAULT_NATS_CREDENTIALS_URL = 'https://aisdk.chatgo.pro/api/nats/credentials';
|
|
11
|
+
const CREDENTIALS_TIMEOUT_MS = 10_000;
|
|
12
|
+
const CREDENTIALS_REFRESH_LEAD_MS = 60_000;
|
|
13
|
+
const ACK_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
14
|
+
const ACK_CACHE_MAX = 1000;
|
|
15
|
+
const DRAIN_TIMEOUT_MS = 3_000;
|
|
16
|
+
class CredentialsHttpError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
constructor(status, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.status = status;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const encoder = new TextEncoder();
|
|
24
|
+
function encode(value) {
|
|
25
|
+
return encoder.encode(JSON.stringify(value));
|
|
26
|
+
}
|
|
27
|
+
function decode(msg) {
|
|
28
|
+
try {
|
|
29
|
+
return { value: msg.json(), byteLength: msg.data.byteLength };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { value: null, byteLength: msg.data.byteLength };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Hosts one gateway as a `dinox.remote-device.v2` device over NATS, the same
|
|
37
|
+
* contract Dinox desktop serves, so the app drives it with no client changes.
|
|
38
|
+
* Each gateway is its own device id (the credential service scopes a desktop
|
|
39
|
+
* credential to a single device subtree).
|
|
40
|
+
*/
|
|
41
|
+
export class GatewayDeviceHost {
|
|
42
|
+
deps;
|
|
43
|
+
executor;
|
|
44
|
+
connection = null;
|
|
45
|
+
subscriptions = [];
|
|
46
|
+
userNo = null;
|
|
47
|
+
state = 'idle';
|
|
48
|
+
connectedAt = null;
|
|
49
|
+
lastError = null;
|
|
50
|
+
generation = 0;
|
|
51
|
+
attempt = 0;
|
|
52
|
+
presenceSequence = 0;
|
|
53
|
+
presenceTimer = null;
|
|
54
|
+
refreshTimer = null;
|
|
55
|
+
reconnectTimer = null;
|
|
56
|
+
stopped = false;
|
|
57
|
+
retryBlocked = false;
|
|
58
|
+
ackCache = new Map();
|
|
59
|
+
/** Latest event per run while offline; the snapshot model makes older ones redundant. */
|
|
60
|
+
pendingEvents = new Map();
|
|
61
|
+
constructor(deps) {
|
|
62
|
+
this.deps = deps;
|
|
63
|
+
this.executor = new GatewayRunExecutor({
|
|
64
|
+
db: deps.db,
|
|
65
|
+
userId: deps.userId,
|
|
66
|
+
gateway: deps.gateway,
|
|
67
|
+
adapter: deps.adapter,
|
|
68
|
+
log: deps.log,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
start() {
|
|
72
|
+
this.stopped = false;
|
|
73
|
+
void this.connect();
|
|
74
|
+
}
|
|
75
|
+
async stop() {
|
|
76
|
+
this.stopped = true;
|
|
77
|
+
this.executor.cancelAll();
|
|
78
|
+
await this.disconnect({ publishOffline: true });
|
|
79
|
+
await this.deps.adapter.dispose();
|
|
80
|
+
this.state = 'idle';
|
|
81
|
+
}
|
|
82
|
+
status() {
|
|
83
|
+
return {
|
|
84
|
+
gatewayId: this.deps.gateway.id,
|
|
85
|
+
name: this.deps.gateway.name,
|
|
86
|
+
deviceId: this.deps.gateway.deviceId,
|
|
87
|
+
kind: this.deps.gateway.kind,
|
|
88
|
+
state: this.state,
|
|
89
|
+
connectedAt: this.connectedAt,
|
|
90
|
+
lastError: this.lastError,
|
|
91
|
+
busyThreadIds: this.executor.busyThreadIds(),
|
|
92
|
+
agentName: this.deps.adapter.info().agentName,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// ---- connection lifecycle -------------------------------------------------
|
|
96
|
+
async connect() {
|
|
97
|
+
if (this.stopped || this.retryBlocked)
|
|
98
|
+
return;
|
|
99
|
+
const generation = ++this.generation;
|
|
100
|
+
this.state = this.attempt > 0 ? 'reconnecting' : 'connecting';
|
|
101
|
+
try {
|
|
102
|
+
const credentials = await this.fetchCredentials();
|
|
103
|
+
if (generation !== this.generation || this.stopped)
|
|
104
|
+
return;
|
|
105
|
+
const connection = await (this.deps.connect ?? wsconnect)({
|
|
106
|
+
servers: credentials.url,
|
|
107
|
+
authenticator: credsAuthenticator(encoder.encode(credentials.credentials)),
|
|
108
|
+
inboxPrefix: credentials.inboxPrefix,
|
|
109
|
+
name: `dinox-gateway-${this.deps.gateway.deviceId}`,
|
|
110
|
+
noEcho: true,
|
|
111
|
+
maxReconnectAttempts: -1,
|
|
112
|
+
reconnectDelayHandler: () => reconnectDelayMs(this.attempt++),
|
|
113
|
+
timeout: CREDENTIALS_TIMEOUT_MS,
|
|
114
|
+
});
|
|
115
|
+
if (generation !== this.generation || this.stopped) {
|
|
116
|
+
await connection.close();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.connection = connection;
|
|
120
|
+
this.userNo = credentials.userNo;
|
|
121
|
+
this.attempt = 0;
|
|
122
|
+
this.state = 'connected';
|
|
123
|
+
this.connectedAt = new Date().toISOString();
|
|
124
|
+
this.lastError = null;
|
|
125
|
+
this.subscribe(connection, credentials.userNo);
|
|
126
|
+
this.publishPresence('online');
|
|
127
|
+
this.flushPendingEvents();
|
|
128
|
+
this.presenceTimer = setInterval(() => this.publishPresence('online'), REMOTE_DEVICE_PRESENCE_INTERVAL_MS);
|
|
129
|
+
this.scheduleCredentialRefresh(credentials.expiresAt, generation);
|
|
130
|
+
void this.watchConnection(connection, generation);
|
|
131
|
+
this.deps.log('gateway device online', { gateway: this.deps.gateway.name, deviceId: this.deps.gateway.deviceId });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (generation !== this.generation)
|
|
135
|
+
return;
|
|
136
|
+
this.state = 'error';
|
|
137
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
138
|
+
if (error instanceof CredentialsHttpError && error.status !== 429 && error.status < 500) {
|
|
139
|
+
// Auth or request errors will not heal on retry; wait for a reload.
|
|
140
|
+
this.retryBlocked = true;
|
|
141
|
+
this.deps.log('gateway credentials rejected', { status: error.status, error: this.lastError });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.scheduleReconnect();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async watchConnection(connection, generation) {
|
|
148
|
+
const error = await connection.closed();
|
|
149
|
+
if (generation !== this.generation || this.stopped)
|
|
150
|
+
return;
|
|
151
|
+
this.lastError = error instanceof Error ? error.message : 'connection closed';
|
|
152
|
+
await this.disconnect({ publishOffline: false });
|
|
153
|
+
this.scheduleReconnect();
|
|
154
|
+
}
|
|
155
|
+
scheduleReconnect() {
|
|
156
|
+
if (this.stopped || this.reconnectTimer)
|
|
157
|
+
return;
|
|
158
|
+
this.state = 'reconnecting';
|
|
159
|
+
this.reconnectTimer = setTimeout(() => {
|
|
160
|
+
this.reconnectTimer = null;
|
|
161
|
+
void this.connect();
|
|
162
|
+
}, reconnectDelayMs(this.attempt++));
|
|
163
|
+
}
|
|
164
|
+
/** Short-lived credentials: reconnect with fresh ones before they expire (runs keep going). */
|
|
165
|
+
scheduleCredentialRefresh(expiresAt, generation) {
|
|
166
|
+
const expiresAtMs = Date.parse(expiresAt);
|
|
167
|
+
const delay = Number.isFinite(expiresAtMs) ? Math.max(1_000, expiresAtMs - Date.now() - CREDENTIALS_REFRESH_LEAD_MS) : 60_000;
|
|
168
|
+
this.refreshTimer = setTimeout(() => {
|
|
169
|
+
if (generation !== this.generation || this.stopped)
|
|
170
|
+
return;
|
|
171
|
+
void this.disconnect({ publishOffline: false }).then(() => this.connect());
|
|
172
|
+
}, delay);
|
|
173
|
+
}
|
|
174
|
+
async disconnect(options) {
|
|
175
|
+
this.generation += 1;
|
|
176
|
+
for (const timer of [this.presenceTimer, this.refreshTimer, this.reconnectTimer]) {
|
|
177
|
+
if (timer)
|
|
178
|
+
clearTimeout(timer);
|
|
179
|
+
}
|
|
180
|
+
this.presenceTimer = this.refreshTimer = this.reconnectTimer = null;
|
|
181
|
+
const connection = this.connection;
|
|
182
|
+
this.connection = null;
|
|
183
|
+
for (const subscription of this.subscriptions)
|
|
184
|
+
subscription.unsubscribe();
|
|
185
|
+
this.subscriptions = [];
|
|
186
|
+
if (!connection)
|
|
187
|
+
return;
|
|
188
|
+
if (options.publishOffline)
|
|
189
|
+
this.publishPresence('offline', connection);
|
|
190
|
+
await Promise.race([
|
|
191
|
+
connection.drain().catch(() => undefined),
|
|
192
|
+
new Promise((resolve) => setTimeout(resolve, DRAIN_TIMEOUT_MS)),
|
|
193
|
+
]);
|
|
194
|
+
await connection.close().catch(() => undefined);
|
|
195
|
+
}
|
|
196
|
+
async fetchCredentials() {
|
|
197
|
+
const fetchImpl = this.deps.fetchImpl ?? fetch;
|
|
198
|
+
const response = await fetchImpl(this.deps.credentialsUrl ?? DEFAULT_NATS_CREDENTIALS_URL, {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: {
|
|
201
|
+
Authorization: toAuthorizationHeaderValue(this.deps.authorization),
|
|
202
|
+
'Content-Type': 'application/json',
|
|
203
|
+
Accept: 'application/json',
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify({ deviceId: this.deps.gateway.deviceId, role: 'desktop' }),
|
|
206
|
+
signal: AbortSignal.timeout(CREDENTIALS_TIMEOUT_MS),
|
|
207
|
+
});
|
|
208
|
+
const envelope = (await response.json().catch(() => null));
|
|
209
|
+
if (!response.ok || envelope?.code !== '000000' || !envelope.data) {
|
|
210
|
+
throw new CredentialsHttpError(response.status, envelope?.msg ?? `credentials request failed (${response.status})`);
|
|
211
|
+
}
|
|
212
|
+
const data = envelope.data;
|
|
213
|
+
if (data.role !== 'desktop' || data.deviceId !== this.deps.gateway.deviceId) {
|
|
214
|
+
throw new Error('credentials do not match this gateway device');
|
|
215
|
+
}
|
|
216
|
+
if (data.userNo !== this.deps.userId.trim().toLowerCase()) {
|
|
217
|
+
throw new CredentialsHttpError(403, 'credentials belong to another account');
|
|
218
|
+
}
|
|
219
|
+
return data;
|
|
220
|
+
}
|
|
221
|
+
// ---- protocol ---------------------------------------------------------------
|
|
222
|
+
deviceInfo() {
|
|
223
|
+
const adapterInfo = this.deps.adapter.info();
|
|
224
|
+
const info = {
|
|
225
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
226
|
+
protocol: REMOTE_DEVICE_PROTOCOL_NAME,
|
|
227
|
+
deviceId: this.deps.gateway.deviceId,
|
|
228
|
+
deviceName: `${this.deps.gateway.name} · ${os.hostname()}`.slice(0, 64),
|
|
229
|
+
platform: process.platform,
|
|
230
|
+
appVersion: this.deps.appVersion,
|
|
231
|
+
connectedAt: this.connectedAt ?? new Date().toISOString(),
|
|
232
|
+
busyThreadIds: this.executor.busyThreadIds(),
|
|
233
|
+
capabilities: {
|
|
234
|
+
threadRun: true,
|
|
235
|
+
cancel: true,
|
|
236
|
+
stream: 'assistant-snapshot',
|
|
237
|
+
toolApproval: 'desktop',
|
|
238
|
+
remoteToolApproval: { respond: true, decisions: ['approve_once', 'deny'], recovery: 'run-status' },
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
// Additive v2 extension: tells clients this device is an external agent
|
|
242
|
+
// (the agent owns context; Dinox model/tool settings do not apply).
|
|
243
|
+
info.capabilities.executor = {
|
|
244
|
+
kind: 'gateway',
|
|
245
|
+
gatewayId: this.deps.gateway.id,
|
|
246
|
+
runtime: this.deps.gateway.kind,
|
|
247
|
+
agentName: adapterInfo.agentName,
|
|
248
|
+
agentVersion: adapterInfo.agentVersion,
|
|
249
|
+
context: 'agent-session',
|
|
250
|
+
// Gateway extension: `agent.config.get` lists the agent's models and modes.
|
|
251
|
+
config: Boolean(this.deps.adapter.describeConfig),
|
|
252
|
+
// Gateway extension: a busy thread queues turns instead of refusing them;
|
|
253
|
+
// waiting turns run together as one, and cancelling a waiting turn withdraws it.
|
|
254
|
+
queue: { maxQueued: MAX_QUEUED_TURNS, merge: true },
|
|
255
|
+
};
|
|
256
|
+
return info;
|
|
257
|
+
}
|
|
258
|
+
subscribe(connection, userNo) {
|
|
259
|
+
const deviceId = this.deps.gateway.deviceId;
|
|
260
|
+
const route = (subject, handler) => {
|
|
261
|
+
const subscription = connection.subscribe(subject);
|
|
262
|
+
this.subscriptions.push(subscription);
|
|
263
|
+
void (async () => {
|
|
264
|
+
for await (const msg of subscription) {
|
|
265
|
+
try {
|
|
266
|
+
await handler(msg);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
this.deps.log('gateway handler failed', { subject, error: String(error) });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
})();
|
|
273
|
+
};
|
|
274
|
+
route(remoteDiscoverSubject(userNo), (msg) => this.handleDiscover(msg));
|
|
275
|
+
route(remoteDeviceSubject(userNo, deviceId, 'info'), (msg) => this.handleInfo(msg));
|
|
276
|
+
route(remoteDeviceSubject(userNo, deviceId, 'request'), (msg) => this.handleRunRequest(msg));
|
|
277
|
+
route(remoteDeviceSubject(userNo, deviceId, 'cancel'), (msg) => this.handleCancel(msg));
|
|
278
|
+
route(remoteDeviceSubject(userNo, deviceId, 'approve'), (msg) => this.handleApprove(msg));
|
|
279
|
+
route(remoteDeviceSubject(userNo, deviceId, 'query'), (msg) => this.handleQuery(msg));
|
|
280
|
+
}
|
|
281
|
+
publishPresence(status, connection = this.connection) {
|
|
282
|
+
if (!connection || !this.userNo)
|
|
283
|
+
return;
|
|
284
|
+
this.presenceSequence += 1;
|
|
285
|
+
try {
|
|
286
|
+
connection.publish(remotePresenceSubject(this.userNo, this.deps.gateway.deviceId), encode({
|
|
287
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
288
|
+
type: 'device.presence',
|
|
289
|
+
status,
|
|
290
|
+
sequence: this.presenceSequence,
|
|
291
|
+
emittedAt: new Date().toISOString(),
|
|
292
|
+
device: this.deviceInfo(),
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
this.deps.log('presence publish failed', { error: String(error) });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
publishRunEvent(subject, event) {
|
|
300
|
+
if (!this.connection) {
|
|
301
|
+
this.pendingEvents.set(event.runId, { subject, event });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
this.connection.publish(subject, encode(event));
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
this.pendingEvents.set(event.runId, { subject, event });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
flushPendingEvents() {
|
|
312
|
+
const pending = [...this.pendingEvents.values()];
|
|
313
|
+
this.pendingEvents.clear();
|
|
314
|
+
for (const { subject, event } of pending)
|
|
315
|
+
this.publishRunEvent(subject, event);
|
|
316
|
+
}
|
|
317
|
+
handleDiscover(msg) {
|
|
318
|
+
const { value } = decode(msg);
|
|
319
|
+
const requestId = value?.requestId;
|
|
320
|
+
if (typeof requestId !== 'string' || !msg.reply)
|
|
321
|
+
return;
|
|
322
|
+
msg.respond(encode({ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'device.discovered', requestId, device: this.deviceInfo() }));
|
|
323
|
+
}
|
|
324
|
+
/** Only the gateway's own query is served; desktop queries (skills, workspace) are not advertised. */
|
|
325
|
+
async handleQuery(msg) {
|
|
326
|
+
const { value } = decode(msg);
|
|
327
|
+
if (!msg.reply || !isAgentConfigQuery(value))
|
|
328
|
+
return;
|
|
329
|
+
const result = {
|
|
330
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
331
|
+
type: 'agent.config.result',
|
|
332
|
+
requestId: value.requestId,
|
|
333
|
+
ok: false,
|
|
334
|
+
options: [],
|
|
335
|
+
fetchedAt: new Date().toISOString(),
|
|
336
|
+
};
|
|
337
|
+
const describe = this.deps.adapter.describeConfig?.bind(this.deps.adapter);
|
|
338
|
+
if (!describe) {
|
|
339
|
+
result.error = { code: 'unsupported', message: 'This agent has no session settings' };
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
try {
|
|
343
|
+
result.options = await describe();
|
|
344
|
+
result.ok = true;
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
result.error = { code: 'query_failed', message: error instanceof Error ? error.message : String(error) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
msg.respond(encode(result));
|
|
351
|
+
}
|
|
352
|
+
async handleInfo(msg) {
|
|
353
|
+
const { value } = decode(msg);
|
|
354
|
+
if (!msg.reply)
|
|
355
|
+
return;
|
|
356
|
+
if (isRunStatusRequest(value)) {
|
|
357
|
+
const run = await findRun(this.deps.db, { runId: value.runId, userId: this.deps.userId });
|
|
358
|
+
const found = Boolean(run && run.threadId === value.threadId);
|
|
359
|
+
msg.respond(encode({
|
|
360
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
361
|
+
type: 'run.status.result',
|
|
362
|
+
requestId: value.requestId,
|
|
363
|
+
runId: value.runId,
|
|
364
|
+
threadId: value.threadId,
|
|
365
|
+
found,
|
|
366
|
+
...(found && run
|
|
367
|
+
? {
|
|
368
|
+
status: run.status,
|
|
369
|
+
updatedAt: run.updatedAt ?? undefined,
|
|
370
|
+
...(run.assistant ? { assistant: { message: run.assistant } } : {}),
|
|
371
|
+
pendingApprovals: run.status === 'waiting_approval'
|
|
372
|
+
? projectPendingApprovals(run.assistant, { runId: run.id, threadId: run.threadId })
|
|
373
|
+
: [],
|
|
374
|
+
}
|
|
375
|
+
: {}),
|
|
376
|
+
}));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const requestId = value?.requestId;
|
|
380
|
+
if (typeof requestId !== 'string')
|
|
381
|
+
return;
|
|
382
|
+
msg.respond(encode({ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'device.info.result', requestId, device: this.deviceInfo() }));
|
|
383
|
+
}
|
|
384
|
+
respondAck(msg, request, code, extra = {}) {
|
|
385
|
+
const ack = {
|
|
386
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
387
|
+
type: 'run.ack',
|
|
388
|
+
requestId: request.requestId,
|
|
389
|
+
runId: request.runId,
|
|
390
|
+
threadId: request.threadId,
|
|
391
|
+
accepted: code === 'accepted',
|
|
392
|
+
code,
|
|
393
|
+
...extra,
|
|
394
|
+
};
|
|
395
|
+
if (msg.reply)
|
|
396
|
+
msg.respond(encode(ack));
|
|
397
|
+
return ack;
|
|
398
|
+
}
|
|
399
|
+
cacheAck(ack) {
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
for (const [key, entry] of this.ackCache) {
|
|
402
|
+
if (entry.expiresAt <= now || this.ackCache.size >= ACK_CACHE_MAX)
|
|
403
|
+
this.ackCache.delete(key);
|
|
404
|
+
}
|
|
405
|
+
this.ackCache.set(ack.requestId, { ack, expiresAt: now + ACK_CACHE_TTL_MS });
|
|
406
|
+
}
|
|
407
|
+
async handleRunRequest(msg) {
|
|
408
|
+
const { value, byteLength } = decode(msg);
|
|
409
|
+
const invalid = validateRunRequest(value, byteLength);
|
|
410
|
+
const raw = (value ?? {});
|
|
411
|
+
const ids = { requestId: String(raw.requestId ?? ''), runId: String(raw.runId ?? ''), threadId: String(raw.threadId ?? '') };
|
|
412
|
+
if (invalid) {
|
|
413
|
+
this.respondAck(msg, ids, 'invalid_request', { error: invalid });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const request = value;
|
|
417
|
+
// Idempotent re-delivery: replay the ack, or recover it from the persisted run.
|
|
418
|
+
const cached = this.ackCache.get(request.requestId);
|
|
419
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
420
|
+
const sameRun = cached.ack.runId === request.runId && cached.ack.threadId === request.threadId;
|
|
421
|
+
if (msg.reply)
|
|
422
|
+
msg.respond(encode(sameRun ? cached.ack : { ...cached.ack, accepted: false, code: 'invalid_request' }));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const existing = await findRun(this.deps.db, { runId: request.runId, userId: this.deps.userId });
|
|
426
|
+
if (existing) {
|
|
427
|
+
if (existing.requestId === request.requestId && existing.threadId === request.threadId) {
|
|
428
|
+
this.respondAck(msg, request, 'accepted', { status: existing.status });
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
this.respondAck(msg, request, 'invalid_request', { error: 'runId already used by another request' });
|
|
432
|
+
}
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!this.deps.gateway.enabled) {
|
|
436
|
+
this.respondAck(msg, request, 'disabled');
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (!request.createThread && !(await threadExists(this.deps.db, request.threadId, this.deps.userId))) {
|
|
440
|
+
this.respondAck(msg, request, 'thread_not_found');
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
for (const part of request.userMessage.parts) {
|
|
444
|
+
if (part.type !== 'file')
|
|
445
|
+
continue;
|
|
446
|
+
const resourceId = part.providerMetadata?.dinox?.resourceId;
|
|
447
|
+
const resource = resourceId
|
|
448
|
+
? await resolveTrustedResource(this.deps.db, { resourceId, userId: this.deps.userId })
|
|
449
|
+
: null;
|
|
450
|
+
if (!resource) {
|
|
451
|
+
this.respondAck(msg, request, 'attachment_unavailable');
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const subject = remoteRunStreamSubject(this.userNo ?? this.deps.userId, request.threadId, request.runId);
|
|
456
|
+
const publisher = new RunStreamPublisher(request, (event) => this.publishRunEvent(subject, event));
|
|
457
|
+
try {
|
|
458
|
+
const { threadCreated, queued } = await this.executor.accept(request, (snapshot) => {
|
|
459
|
+
publisher.push(snapshot);
|
|
460
|
+
if (TERMINAL_RUN_STATUSES.has(snapshot.status)) {
|
|
461
|
+
publisher.dispose();
|
|
462
|
+
this.publishPresence('online');
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
const ack = this.respondAck(msg, request, 'accepted', {
|
|
466
|
+
status: 'queued',
|
|
467
|
+
// Gateway extension: the turn waits behind the thread's active one.
|
|
468
|
+
...(queued ? { waiting: true } : {}),
|
|
469
|
+
...(request.createThread
|
|
470
|
+
? { threadDisposition: threadCreated ? 'created_desktop_local' : 'already_present_desktop_local' }
|
|
471
|
+
: {}),
|
|
472
|
+
});
|
|
473
|
+
this.cacheAck(ack);
|
|
474
|
+
this.publishPresence('online');
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
publisher.dispose();
|
|
478
|
+
if (error instanceof QueueFullError) {
|
|
479
|
+
this.respondAck(msg, request, 'busy', { error: error.message });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
this.respondAck(msg, request, 'enqueue_failed', { error: error instanceof Error ? error.message : String(error) });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
handleCancel(msg) {
|
|
486
|
+
const { value } = decode(msg);
|
|
487
|
+
if (!isCancelRequest(value))
|
|
488
|
+
return;
|
|
489
|
+
const cancelled = this.executor.cancel(value.runId, value.threadId);
|
|
490
|
+
msg.respond(encode({
|
|
491
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
492
|
+
type: 'run.cancel.ack',
|
|
493
|
+
requestId: value.requestId,
|
|
494
|
+
runId: value.runId,
|
|
495
|
+
threadId: value.threadId,
|
|
496
|
+
cancelled,
|
|
497
|
+
...(cancelled ? {} : { error: 'run is not active on this gateway' }),
|
|
498
|
+
}));
|
|
499
|
+
}
|
|
500
|
+
async handleApprove(msg) {
|
|
501
|
+
const { value, byteLength } = decode(msg);
|
|
502
|
+
const invalid = validateApprovalRequest(value, byteLength);
|
|
503
|
+
const raw = (value ?? {});
|
|
504
|
+
const base = {
|
|
505
|
+
protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
|
|
506
|
+
type: 'tool.approval.ack',
|
|
507
|
+
requestId: String(raw.requestId ?? ''),
|
|
508
|
+
runId: String(raw.runId ?? ''),
|
|
509
|
+
threadId: String(raw.threadId ?? ''),
|
|
510
|
+
approvalId: String(raw.approvalId ?? ''),
|
|
511
|
+
};
|
|
512
|
+
if (invalid) {
|
|
513
|
+
msg.respond(encode({ ...base, accepted: false, code: 'invalid_request', error: invalid }));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const code = this.executor.respondApproval({
|
|
517
|
+
runId: base.runId,
|
|
518
|
+
threadId: base.threadId,
|
|
519
|
+
approvalId: base.approvalId,
|
|
520
|
+
approved: raw.approved === true,
|
|
521
|
+
});
|
|
522
|
+
const run = await findRun(this.deps.db, { runId: base.runId, userId: this.deps.userId });
|
|
523
|
+
msg.respond(encode({
|
|
524
|
+
...base,
|
|
525
|
+
accepted: code === 'applied' || code === 'already_applied',
|
|
526
|
+
code,
|
|
527
|
+
...(run ? { status: run.status, pendingApprovals: projectPendingApprovals(run.assistant, base) } : {}),
|
|
528
|
+
}));
|
|
529
|
+
}
|
|
530
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
export type DaemonCall = (method: string, params?: unknown) => Promise<unknown>;
|
|
3
|
+
export declare function createDinoxMcpServer(call?: DaemonCall): McpServer;
|
|
4
|
+
export declare function serveDinoxMcpOverStdio(): Promise<void>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { sendDaemonRequest } from '../daemon/client.js';
|
|
5
|
+
import { getPackageVersion } from '../utils/version.js';
|
|
6
|
+
/**
|
|
7
|
+
* Dinox knowledge tools for gateway agents, served over stdio MCP. The agent
|
|
8
|
+
* launches this as a child process (`dino gateway mcp`); every read goes
|
|
9
|
+
* through the daemon, which owns the database. Read-only by design: writes
|
|
10
|
+
* from a third-party agent would bypass the app's approval flow.
|
|
11
|
+
*/
|
|
12
|
+
const MAX_NOTE_CHARS = 20_000;
|
|
13
|
+
const MAX_RESULTS = 50;
|
|
14
|
+
function text(value) {
|
|
15
|
+
return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
function failure(error) {
|
|
18
|
+
return { ...text(`Dinox is unavailable: ${error instanceof Error ? error.message : String(error)}`), isError: true };
|
|
19
|
+
}
|
|
20
|
+
export function createDinoxMcpServer(call = (method, params) => sendDaemonRequest(method, params)) {
|
|
21
|
+
const server = new McpServer({ name: 'dinox', version: getPackageVersion() });
|
|
22
|
+
server.registerTool('search_notes', {
|
|
23
|
+
description: "Search the user's Dinox notes by keyword. Returns id, title, summary, tags and folders; use read_note for full content.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
query: z.string().describe('Keywords; empty string lists recent notes'),
|
|
26
|
+
tags: z.string().optional().describe('Tag filter expression, e.g. "work AND !draft"'),
|
|
27
|
+
createdFrom: z.string().optional().describe('ISO date lower bound'),
|
|
28
|
+
createdTo: z.string().optional().describe('ISO date upper bound'),
|
|
29
|
+
limit: z.number().int().min(1).max(MAX_RESULTS).optional(),
|
|
30
|
+
},
|
|
31
|
+
}, async ({ query, tags, createdFrom, createdTo, limit }) => {
|
|
32
|
+
try {
|
|
33
|
+
const result = (await call('note.search', {
|
|
34
|
+
keyword: query,
|
|
35
|
+
includeDeleted: false,
|
|
36
|
+
tagsExpression: tags || undefined,
|
|
37
|
+
createdAtFrom: createdFrom,
|
|
38
|
+
createdAtTo: createdTo,
|
|
39
|
+
boxValues: [],
|
|
40
|
+
matchEmptyZettelBoxes: false,
|
|
41
|
+
limit: limit ?? 10,
|
|
42
|
+
offset: 0,
|
|
43
|
+
}));
|
|
44
|
+
return text({ totalCount: result.totalCount, notes: result.rows });
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
return failure(error);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
server.registerTool('read_note', {
|
|
51
|
+
description: 'Read one Dinox note as Markdown, with title, tags, folders and properties.',
|
|
52
|
+
inputSchema: { id: z.string().describe('Note id from search_notes') },
|
|
53
|
+
}, async ({ id }) => {
|
|
54
|
+
try {
|
|
55
|
+
const { note } = (await call('note.detail', { id }));
|
|
56
|
+
if (!note)
|
|
57
|
+
return { ...text(`Note not found: ${id}`), isError: true };
|
|
58
|
+
const markdown = String(note.content_md ?? note.content_text ?? '');
|
|
59
|
+
return text({
|
|
60
|
+
id: note.id,
|
|
61
|
+
title: note.title,
|
|
62
|
+
tags: note.tags,
|
|
63
|
+
folders: note.zettel_boxes,
|
|
64
|
+
properties: note.properties,
|
|
65
|
+
createdAt: note.created_at,
|
|
66
|
+
updatedAt: note.updated_at,
|
|
67
|
+
truncated: markdown.length > MAX_NOTE_CHARS,
|
|
68
|
+
content: markdown.slice(0, MAX_NOTE_CHARS),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
return failure(error);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
server.registerTool('search_todos', {
|
|
76
|
+
description: "Search the user's Dinox todo items.",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
query: z.string().describe('Keywords; empty string lists all'),
|
|
79
|
+
status: z.enum(['all', 'completed', 'uncompleted']).optional(),
|
|
80
|
+
limit: z.number().int().min(1).max(MAX_RESULTS).optional(),
|
|
81
|
+
},
|
|
82
|
+
}, async ({ query, status, limit }) => {
|
|
83
|
+
try {
|
|
84
|
+
const result = await call('todo.search', {
|
|
85
|
+
query,
|
|
86
|
+
includeDeleted: false,
|
|
87
|
+
taskStatus: status ?? 'uncompleted',
|
|
88
|
+
taskTagFilter: [],
|
|
89
|
+
limit: limit ?? 20,
|
|
90
|
+
scanLimit: 500,
|
|
91
|
+
});
|
|
92
|
+
return text(result);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
return failure(error);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
server.registerTool('related_notes', {
|
|
99
|
+
description: 'Notes linked to a note (backlinks and outlinks, up to the given depth).',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
id: z.string().describe('Note id'),
|
|
102
|
+
depth: z.number().int().min(1).max(3).optional(),
|
|
103
|
+
},
|
|
104
|
+
}, async ({ id, depth }) => {
|
|
105
|
+
try {
|
|
106
|
+
return text(await call('graph.related', { id, depth: depth ?? 2 }));
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
return failure(error);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
return server;
|
|
113
|
+
}
|
|
114
|
+
export async function serveDinoxMcpOverStdio() {
|
|
115
|
+
const server = createDinoxMcpServer();
|
|
116
|
+
await server.connect(new StdioServerTransport());
|
|
117
|
+
await new Promise((resolve) => process.stdin.once('close', resolve));
|
|
118
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { REMOTE_DEVICE_PROTOCOL_VERSION, type RemoteDeviceCancelRequest, type RemoteDeviceRunRequest, type RemoteDeviceRunStatusRequest } from '../vendor/electron/shared/remote-device-agent.js';
|
|
2
|
+
import type { GatewayConfigOption } from './types.js';
|
|
3
|
+
export declare function validateRunRequest(value: unknown, byteLength: number): string | null;
|
|
4
|
+
export type AgentConfigQuery = {
|
|
5
|
+
protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
|
|
6
|
+
type: 'agent.config.get';
|
|
7
|
+
requestId: string;
|
|
8
|
+
clientDeviceId: string;
|
|
9
|
+
};
|
|
10
|
+
export type AgentConfigResult = {
|
|
11
|
+
protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
|
|
12
|
+
type: 'agent.config.result';
|
|
13
|
+
requestId: string;
|
|
14
|
+
ok: boolean;
|
|
15
|
+
options: GatewayConfigOption[];
|
|
16
|
+
fetchedAt: string;
|
|
17
|
+
error?: {
|
|
18
|
+
code: 'unsupported' | 'query_failed';
|
|
19
|
+
message: string;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export declare function isAgentConfigQuery(value: unknown): value is AgentConfigQuery;
|
|
23
|
+
/** The run's chosen agent settings, `null` when none, `'invalid'` when malformed. */
|
|
24
|
+
export declare function readAgentConfig(request: RemoteDeviceRunRequest): Record<string, string> | null | 'invalid';
|
|
25
|
+
export declare function validateApprovalRequest(value: unknown, byteLength: number): string | null;
|
|
26
|
+
export declare function isCancelRequest(value: unknown): value is RemoteDeviceCancelRequest;
|
|
27
|
+
export declare function isRunStatusRequest(value: unknown): value is RemoteDeviceRunStatusRequest;
|
|
28
|
+
/** `Math.max(250, rand * min(60s, 1s * 2^attempt))`, the desktop's full-jitter backoff. */
|
|
29
|
+
export declare function reconnectDelayMs(attempt: number, random?: () => number): number;
|