@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,64 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Keeps the computer from idle-sleeping while any agent turn runs, so a task
4
+ * started from the phone is not cut off when the screen locks. macOS only
5
+ * (`caffeinate -i`, released as soon as the last turn ends); elsewhere a no-op.
6
+ * It cannot stop sleep from closing a laptop lid on battery.
7
+ */
8
+ export class KeepAwake {
9
+ log;
10
+ platform;
11
+ spawnProcess;
12
+ holders = 0;
13
+ child = null;
14
+ constructor(log = () => { }, platform = process.platform, spawnProcess = (command, args) => spawn(command, args, { stdio: 'ignore' })) {
15
+ this.log = log;
16
+ this.platform = platform;
17
+ this.spawnProcess = spawnProcess;
18
+ }
19
+ /** Holds the computer awake until the returned release is called (idempotent). */
20
+ acquire() {
21
+ this.holders += 1;
22
+ if (this.holders === 1)
23
+ this.start();
24
+ let released = false;
25
+ return () => {
26
+ if (released)
27
+ return;
28
+ released = true;
29
+ this.holders -= 1;
30
+ if (this.holders === 0)
31
+ this.stop();
32
+ };
33
+ }
34
+ /** Releases everything, e.g. when the daemon stops. */
35
+ dispose() {
36
+ this.holders = 0;
37
+ this.stop();
38
+ }
39
+ start() {
40
+ if (this.platform !== 'darwin' || this.child)
41
+ return;
42
+ try {
43
+ const child = this.spawnProcess('caffeinate', ['-i']);
44
+ child.once('error', (error) => {
45
+ this.log('keep awake unavailable', { error: String(error) });
46
+ if (this.child === child)
47
+ this.child = null;
48
+ });
49
+ child.once('exit', () => {
50
+ if (this.child === child)
51
+ this.child = null;
52
+ });
53
+ this.child = child;
54
+ }
55
+ catch (error) {
56
+ this.log('keep awake unavailable', { error: String(error) });
57
+ }
58
+ }
59
+ stop() {
60
+ const child = this.child;
61
+ this.child = null;
62
+ child?.kill('SIGTERM');
63
+ }
64
+ }
@@ -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,138 @@
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 { READ_CONVERSATION_MAX_LIMIT } from './conversationContext.js';
6
+ import { getPackageVersion } from '../utils/version.js';
7
+ /**
8
+ * Dinox knowledge tools for gateway agents, served over stdio MCP. The agent
9
+ * launches this as a child process (`dino gateway mcp`); every read goes
10
+ * through the daemon, which owns the database. Read-only by design: writes
11
+ * from a third-party agent would bypass the app's approval flow.
12
+ */
13
+ const MAX_NOTE_CHARS = 20_000;
14
+ const MAX_RESULTS = 50;
15
+ function text(value) {
16
+ return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
17
+ }
18
+ function failure(error) {
19
+ return { ...text(`Dinox is unavailable: ${error instanceof Error ? error.message : String(error)}`), isError: true };
20
+ }
21
+ export function createDinoxMcpServer(call = (method, params) => sendDaemonRequest(method, params)) {
22
+ const server = new McpServer({ name: 'dinox', version: getPackageVersion() });
23
+ server.registerTool('search_notes', {
24
+ description: "Search the user's Dinox notes by keyword. Returns id, title, summary, tags and folders; use read_note for full content.",
25
+ inputSchema: {
26
+ query: z.string().describe('Keywords; empty string lists recent notes'),
27
+ tags: z.string().optional().describe('Tag filter expression, e.g. "work AND !draft"'),
28
+ createdFrom: z.string().optional().describe('ISO date lower bound'),
29
+ createdTo: z.string().optional().describe('ISO date upper bound'),
30
+ limit: z.number().int().min(1).max(MAX_RESULTS).optional(),
31
+ },
32
+ }, async ({ query, tags, createdFrom, createdTo, limit }) => {
33
+ try {
34
+ const result = (await call('note.search', {
35
+ keyword: query,
36
+ includeDeleted: false,
37
+ tagsExpression: tags || undefined,
38
+ createdAtFrom: createdFrom,
39
+ createdAtTo: createdTo,
40
+ boxValues: [],
41
+ matchEmptyZettelBoxes: false,
42
+ limit: limit ?? 10,
43
+ offset: 0,
44
+ }));
45
+ return text({ totalCount: result.totalCount, notes: result.rows });
46
+ }
47
+ catch (error) {
48
+ return failure(error);
49
+ }
50
+ });
51
+ server.registerTool('read_note', {
52
+ description: 'Read one Dinox note as Markdown, with title, tags, folders and properties.',
53
+ inputSchema: { id: z.string().describe('Note id from search_notes') },
54
+ }, async ({ id }) => {
55
+ try {
56
+ const { note } = (await call('note.detail', { id }));
57
+ if (!note)
58
+ return { ...text(`Note not found: ${id}`), isError: true };
59
+ const markdown = String(note.content_md ?? note.content_text ?? '');
60
+ return text({
61
+ id: note.id,
62
+ title: note.title,
63
+ tags: note.tags,
64
+ folders: note.zettel_boxes,
65
+ properties: note.properties,
66
+ createdAt: note.created_at,
67
+ updatedAt: note.updated_at,
68
+ truncated: markdown.length > MAX_NOTE_CHARS,
69
+ content: markdown.slice(0, MAX_NOTE_CHARS),
70
+ });
71
+ }
72
+ catch (error) {
73
+ return failure(error);
74
+ }
75
+ });
76
+ server.registerTool('search_todos', {
77
+ description: "Search the user's Dinox todo items.",
78
+ inputSchema: {
79
+ query: z.string().describe('Keywords; empty string lists all'),
80
+ status: z.enum(['all', 'completed', 'uncompleted']).optional(),
81
+ limit: z.number().int().min(1).max(MAX_RESULTS).optional(),
82
+ },
83
+ }, async ({ query, status, limit }) => {
84
+ try {
85
+ const result = await call('todo.search', {
86
+ query,
87
+ includeDeleted: false,
88
+ taskStatus: status ?? 'uncompleted',
89
+ taskTagFilter: [],
90
+ limit: limit ?? 20,
91
+ scanLimit: 500,
92
+ });
93
+ return text(result);
94
+ }
95
+ catch (error) {
96
+ return failure(error);
97
+ }
98
+ });
99
+ server.registerTool('related_notes', {
100
+ description: 'Notes linked to a note (backlinks and outlinks, up to the given depth).',
101
+ inputSchema: {
102
+ id: z.string().describe('Note id'),
103
+ depth: z.number().int().min(1).max(3).optional(),
104
+ },
105
+ }, async ({ id, depth }) => {
106
+ try {
107
+ return text(await call('graph.related', { id, depth: depth ?? 2 }));
108
+ }
109
+ catch (error) {
110
+ return failure(error);
111
+ }
112
+ });
113
+ server.registerTool('read_conversation', {
114
+ description: 'Read messages of a Dinox conversation, oldest first: who said what (the user, or which agent answered). ' +
115
+ 'Use it when the conversation context you were given says earlier messages are not shown. Returns the newest ' +
116
+ 'messages before `before`; pass the first returned id as `before` to page further back.',
117
+ inputSchema: {
118
+ conversationId: z.string().describe('Conversation id from the context block'),
119
+ before: z.string().optional().describe('Message id; only messages before it are returned'),
120
+ limit: z.number().int().min(1).max(READ_CONVERSATION_MAX_LIMIT).optional(),
121
+ },
122
+ }, async ({ conversationId, before, limit }) => {
123
+ try {
124
+ const result = (await call('thread.messages', { threadId: conversationId, beforeMessageId: before, limit }));
125
+ // `stale`: this machine is not fully synced, so the newest messages may be missing.
126
+ return text({ messages: result.messages, hasMore: result.hasMore, stale: result.stale === true });
127
+ }
128
+ catch (error) {
129
+ return failure(error);
130
+ }
131
+ });
132
+ return server;
133
+ }
134
+ export async function serveDinoxMcpOverStdio() {
135
+ const server = createDinoxMcpServer();
136
+ await server.connect(new StdioServerTransport());
137
+ await new Promise((resolve) => process.stdin.once('close', resolve));
138
+ }
@@ -0,0 +1,116 @@
1
+ import { REMOTE_DEVICE_PROTOCOL_VERSION, type RemoteDeviceCancelRequest, type RemoteDeviceRunRequest, type RemoteDeviceRunStatusRequest } from '../vendor/electron/shared/remote-device-agent.js';
2
+ import type { GatewayApprovalMode, 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
+ export type AgentHostSettings = {
24
+ /** Folder new conversations start in; null until chosen (the home folder is used). */
25
+ cwd: string | null;
26
+ approval: GatewayApprovalMode;
27
+ /** Online but refusing new turns. */
28
+ paused: boolean;
29
+ /** The agent may search and read the user's notes. */
30
+ dinoxTools: boolean;
31
+ };
32
+ export type AgentSettingsChange = {
33
+ /** An existing folder (absolute or `~/...`), checked on this computer. */
34
+ cwd?: string;
35
+ /** Creates and uses the agent's own folder (`~/Dinox Agents/<name>`). */
36
+ useAgentFolder?: true;
37
+ approval?: GatewayApprovalMode;
38
+ paused?: boolean;
39
+ dinoxTools?: boolean;
40
+ };
41
+ export type AgentSettingsQuery = {
42
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
43
+ type: 'agent.settings.get';
44
+ requestId: string;
45
+ clientDeviceId: string;
46
+ };
47
+ export type AgentSettingsUpdate = {
48
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
49
+ type: 'agent.settings.set';
50
+ requestId: string;
51
+ clientDeviceId: string;
52
+ settings: AgentSettingsChange;
53
+ };
54
+ export type AgentSettingsErrorCode = 'invalid_request' | 'not_found' | 'not_allowed' | 'failed';
55
+ export type AgentSettingsResult = {
56
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
57
+ type: 'agent.settings.result';
58
+ requestId: string;
59
+ ok: boolean;
60
+ settings: AgentHostSettings;
61
+ /** For showing paths as `~/...`. */
62
+ home: string;
63
+ /** Where `useAgentFolder` puts the agent's own folder. */
64
+ agentFolder: string;
65
+ fetchedAt: string;
66
+ error?: {
67
+ code: AgentSettingsErrorCode;
68
+ message: string;
69
+ };
70
+ };
71
+ export type AgentFoldersQuery = {
72
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
73
+ type: 'agent.folders.list';
74
+ requestId: string;
75
+ clientDeviceId: string;
76
+ /** Folder to list; absent lists the home folder with suggestions. */
77
+ path?: string | null;
78
+ };
79
+ export type AgentFolder = {
80
+ name: string;
81
+ path: string;
82
+ isProject: boolean;
83
+ };
84
+ export type AgentFoldersResult = {
85
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
86
+ type: 'agent.folders.result';
87
+ requestId: string;
88
+ ok: boolean;
89
+ path: string;
90
+ parent: string | null;
91
+ folders: AgentFolder[];
92
+ truncated: boolean;
93
+ /** Only for the home listing: the current folder and recently used ones. */
94
+ suggestions: AgentFolder[];
95
+ home: string;
96
+ fetchedAt: string;
97
+ error?: {
98
+ code: AgentSettingsErrorCode;
99
+ message: string;
100
+ };
101
+ };
102
+ export declare function isAgentSettingsQuery(value: unknown): value is AgentSettingsQuery;
103
+ export declare function isAgentFoldersQuery(value: unknown): value is AgentFoldersQuery;
104
+ /** The change an `agent.settings.set` asks for, or why it is malformed. */
105
+ export declare function readAgentSettingsUpdate(value: unknown, byteLength: number): {
106
+ update: AgentSettingsUpdate;
107
+ } | {
108
+ error: string;
109
+ } | null;
110
+ /** The run's chosen agent settings, `null` when none, `'invalid'` when malformed. */
111
+ export declare function readAgentConfig(request: RemoteDeviceRunRequest): Record<string, string> | null | 'invalid';
112
+ export declare function validateApprovalRequest(value: unknown, byteLength: number): string | null;
113
+ export declare function isCancelRequest(value: unknown): value is RemoteDeviceCancelRequest;
114
+ export declare function isRunStatusRequest(value: unknown): value is RemoteDeviceRunStatusRequest;
115
+ /** `Math.max(250, rand * min(60s, 1s * 2^attempt))`, the desktop's full-jitter backoff. */
116
+ export declare function reconnectDelayMs(attempt: number, random?: () => number): number;
@@ -0,0 +1,182 @@
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
+ /*
74
+ * Gateway extension (`capabilities.executor.settings`): the owner reads and
75
+ * changes how this computer runs the agent with `agent.settings.get` /
76
+ * `agent.settings.set`, and picks a working folder with `agent.folders.list`.
77
+ */
78
+ const MAX_SETTINGS_REQUEST_BYTES = 16 * 1024;
79
+ const MAX_PATH_LENGTH = 4096;
80
+ export function isAgentSettingsQuery(value) {
81
+ return isMessageOfType(value, 'agent.settings.get') && isDeviceId(value.clientDeviceId);
82
+ }
83
+ export function isAgentFoldersQuery(value) {
84
+ if (!isMessageOfType(value, 'agent.folders.list') || !isDeviceId(value.clientDeviceId))
85
+ return false;
86
+ const folder = value.path;
87
+ return folder === undefined || folder === null || (typeof folder === 'string' && folder.length <= MAX_PATH_LENGTH);
88
+ }
89
+ /** The change an `agent.settings.set` asks for, or why it is malformed. */
90
+ export function readAgentSettingsUpdate(value, byteLength) {
91
+ if (!isMessageOfType(value, 'agent.settings.set'))
92
+ return null;
93
+ if (byteLength > MAX_SETTINGS_REQUEST_BYTES)
94
+ return { error: 'request too large' };
95
+ if (!isDeviceId(value.clientDeviceId))
96
+ return { error: 'invalid clientDeviceId' };
97
+ const raw = value.settings;
98
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
99
+ return { error: 'invalid settings' };
100
+ const input = raw;
101
+ const change = {};
102
+ if (input.cwd !== undefined) {
103
+ if (typeof input.cwd !== 'string' || !input.cwd.trim() || input.cwd.length > MAX_PATH_LENGTH)
104
+ return { error: 'invalid cwd' };
105
+ change.cwd = input.cwd;
106
+ }
107
+ if (input.useAgentFolder !== undefined) {
108
+ if (input.useAgentFolder !== true || change.cwd !== undefined)
109
+ return { error: 'invalid useAgentFolder' };
110
+ change.useAgentFolder = true;
111
+ }
112
+ if (input.approval !== undefined) {
113
+ if (input.approval !== 'ask' && input.approval !== 'auto_read')
114
+ return { error: 'invalid approval' };
115
+ change.approval = input.approval;
116
+ }
117
+ for (const key of ['paused', 'dinoxTools']) {
118
+ if (input[key] === undefined)
119
+ continue;
120
+ if (typeof input[key] !== 'boolean')
121
+ return { error: `invalid ${key}` };
122
+ change[key] = input[key];
123
+ }
124
+ if (Object.keys(change).length === 0)
125
+ return { error: 'nothing to change' };
126
+ return { update: { ...value, settings: change } };
127
+ }
128
+ /** The run's chosen agent settings, `null` when none, `'invalid'` when malformed. */
129
+ export function readAgentConfig(request) {
130
+ const raw = request.agentConfig;
131
+ if (raw === undefined || raw === null)
132
+ return null;
133
+ if (typeof raw !== 'object' || Array.isArray(raw))
134
+ return 'invalid';
135
+ const entries = Object.entries(raw);
136
+ if (entries.length > MAX_AGENT_CONFIG_ENTRIES)
137
+ return 'invalid';
138
+ const config = {};
139
+ for (const [id, value] of entries) {
140
+ if (!id || id.length > MAX_AGENT_CONFIG_TEXT)
141
+ return 'invalid';
142
+ if (typeof value !== 'string' || !value || value.length > MAX_AGENT_CONFIG_TEXT)
143
+ return 'invalid';
144
+ config[id] = value;
145
+ }
146
+ return entries.length > 0 ? config : null;
147
+ }
148
+ export function validateApprovalRequest(value, byteLength) {
149
+ if (byteLength > MAX_APPROVAL_REQUEST_BYTES)
150
+ return 'approval request too large';
151
+ if (!isMessageOfType(value, 'tool.approval.respond')) {
152
+ return 'invalid protocol version or type';
153
+ }
154
+ if (!isSubjectId(value.runId) ||
155
+ !isSubjectId(value.threadId) ||
156
+ typeof value.approvalId !== 'string' ||
157
+ !OPAQUE_ID.test(value.approvalId) ||
158
+ !isDeviceId(value.clientDeviceId)) {
159
+ return 'invalid approval ids';
160
+ }
161
+ if (typeof value.approved !== 'boolean')
162
+ return 'approved must be a boolean';
163
+ if (value.reason !== undefined &&
164
+ (typeof value.reason !== 'string' || value.reason.length > MAX_APPROVAL_REASON_LENGTH)) {
165
+ return 'invalid approval reason';
166
+ }
167
+ return null;
168
+ }
169
+ export function isCancelRequest(value) {
170
+ return (isMessageOfType(value, 'run.cancel') &&
171
+ isSubjectId(value.runId) &&
172
+ isSubjectId(value.threadId));
173
+ }
174
+ export function isRunStatusRequest(value) {
175
+ return (isMessageOfType(value, 'run.status') &&
176
+ isSubjectId(value.runId) &&
177
+ isSubjectId(value.threadId));
178
+ }
179
+ /** `Math.max(250, rand * min(60s, 1s * 2^attempt))`, the desktop's full-jitter backoff. */
180
+ export function reconnectDelayMs(attempt, random = Math.random) {
181
+ return Math.max(250, Math.floor(random() * Math.min(60_000, 1000 * 2 ** attempt)));
182
+ }
@@ -0,0 +1,91 @@
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
+ /** How long a turn waits for the client's latest messages to sync to this machine. */
8
+ export declare const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5000;
9
+ type Logger = (message: string, details?: Record<string, unknown>) => void;
10
+ type Emit = (snapshot: RunSnapshot) => void;
11
+ export declare class QueueFullError extends Error {
12
+ constructor();
13
+ }
14
+ export type RunExecutorDeps = {
15
+ db: AbstractPowerSyncDatabase;
16
+ userId: string;
17
+ gateway: GatewayDefinition;
18
+ adapter: GatewayAdapter;
19
+ log: Logger;
20
+ /** Overrides CLIENT_TAIL_SYNC_TIMEOUT_MS (tests). */
21
+ clientTailSyncTimeoutMs?: number;
22
+ /** Keeps the computer awake while a turn runs; returns the release. */
23
+ keepAwake?: () => () => void;
24
+ };
25
+ /** The user's instructions for the agent, as the start of a new session. */
26
+ export declare function agentInstructionsPreamble(instructions: string): string | undefined;
27
+ /**
28
+ * Runs accepted turns on one gateway. A thread has at most one active run;
29
+ * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
30
+ * together as one turn (each keeps its own message; the reply follows the
31
+ * last). The agent session is scoped to the thread's current topic: a "new
32
+ * topic" marker in Dinox starts a fresh agent session, everything else continues it.
33
+ * The thread may have been answered by other agents meanwhile, so each turn
34
+ * also carries the messages this gateway's session has not seen.
35
+ */
36
+ export declare class GatewayRunExecutor {
37
+ private readonly deps;
38
+ private readonly activeByThread;
39
+ /** The request each active run answers, for parenting turns queued behind it. */
40
+ private readonly activeRequests;
41
+ private readonly queuedByThread;
42
+ private queueIndex;
43
+ /** Accepts of one thread run one at a time, so two can never both start a turn. */
44
+ private readonly acceptChains;
45
+ private readonly missingTails;
46
+ constructor(deps: RunExecutorDeps);
47
+ /** Threads with an active or waiting turn. */
48
+ busyThreadIds(): string[];
49
+ isBusy(threadId: string): boolean;
50
+ /** Persists the turn, then runs it now or queues it behind the thread's active turn. */
51
+ accept(request: RemoteDeviceRunRequest, emit: Emit): Promise<{
52
+ threadCreated: boolean;
53
+ queued: boolean;
54
+ }>;
55
+ private acceptInOrder;
56
+ /** Stops the active turn, or withdraws a waiting one. */
57
+ cancel(runId: string, threadId: string): boolean;
58
+ cancelAll(): void;
59
+ /** A queued turn follows the last one waiting, else the reply being written. */
60
+ private queuedParentFor;
61
+ private setQueue;
62
+ private start;
63
+ /**
64
+ * Runs everything that waited as one turn: the agent reads the messages
65
+ * together, as the user meant them, instead of answering each in isolation.
66
+ * Earlier turns end as merged into the last, which carries the reply.
67
+ */
68
+ private drain;
69
+ respondApproval(input: {
70
+ runId: string;
71
+ threadId: string;
72
+ approvalId: string;
73
+ approved: boolean;
74
+ }): RemoteDeviceToolApprovalAckCode;
75
+ private execute;
76
+ /**
77
+ * The client sends the last message it saw; a turn reads the transcript only
78
+ * once that message has synced here (its upload may still be in flight), or
79
+ * gives up after a short wait. A tail that never arrived is not waited for again.
80
+ */
81
+ private clientTailSynced;
82
+ /**
83
+ * Messages before this turn that the session has not seen: the whole topic
84
+ * for a new session, those after the session's mark for a continued one,
85
+ * leaving out the turns the session answered itself. A session bound before
86
+ * seen messages were tracked is assumed to be up to date.
87
+ */
88
+ private unseenContext;
89
+ private resolveResources;
90
+ }
91
+ export {};