@dinoxx/dinox-cli 2.7.0 → 2.8.1

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.
@@ -291,7 +291,7 @@ export const gatewayCommandGroup = {
291
291
  },
292
292
  {
293
293
  name: 'mcp',
294
- description: 'Serve read-only Dinox knowledge tools over stdio MCP (started by gateway agents)',
294
+ description: 'Serve Dinox tools over stdio MCP: read-only knowledge tools and send_file (started by gateway agents)',
295
295
  schema: {
296
296
  path: 'gateway.mcp',
297
297
  risk: 'read',
@@ -28,5 +28,5 @@ export declare function defaultUploadBytes(input: {
28
28
  }): Promise<UploadObjectResult>;
29
29
  type SharpFactory = typeof import('sharp');
30
30
  export declare function loadSharp(): Promise<SharpFactory>;
31
- export declare function generateThumbnailBuffer(sharp: SharpFactory, localPath: string): Promise<Buffer>;
31
+ export declare function generateThumbnailBuffer(sharp: SharpFactory, input: string | Buffer): Promise<Buffer>;
32
32
  export {};
@@ -124,8 +124,8 @@ export async function loadSharp() {
124
124
  });
125
125
  }
126
126
  }
127
- export async function generateThumbnailBuffer(sharp, localPath) {
128
- return sharp(localPath)
127
+ export async function generateThumbnailBuffer(sharp, input) {
128
+ return sharp(input)
129
129
  .resize({ width: 400, withoutEnlargement: true })
130
130
  .webp({ quality: 50 })
131
131
  .toBuffer();
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { createServer } from 'http';
3
3
  import { chmodSync, existsSync } from 'node:fs';
4
+ import path from 'node:path';
4
5
  import { DAEMON_SERVICE_ENV } from './supervisor.js';
5
6
  import { writeDaemonLogLine } from './log.js';
6
7
  import { loadConfig } from '../config/store.js';
@@ -183,6 +184,21 @@ export class DaemonServer {
183
184
  }
184
185
  return { id: req.id, ok: true, result: { gateways: await this.gateways.reload() } };
185
186
  }
187
+ case 'gateway.sendFile': {
188
+ if (!this.gateways) {
189
+ throw new DinoxError('Gateways need a signed-in daemon', { code: 'AUTH_REQUIRED' });
190
+ }
191
+ const { token, path: filePath, name } = (params ?? {});
192
+ if (typeof token !== 'string' || !token || typeof filePath !== 'string' || !path.isAbsolute(filePath)) {
193
+ throw new DinoxError('gateway.sendFile needs a session token and an absolute path', { code: 'USAGE_ERROR' });
194
+ }
195
+ const sent = await this.gateways.sendFile(token, {
196
+ kind: 'path',
197
+ path: filePath,
198
+ ...(typeof name === 'string' && name.trim() ? { name: name.trim() } : {}),
199
+ });
200
+ return { id: req.id, ok: true, result: sent };
201
+ }
186
202
  case 'thread.messages': {
187
203
  if (!this.userId) {
188
204
  throw new DinoxError('Reading conversations needs a signed-in daemon', { code: 'AUTH_REQUIRED' });
@@ -1,6 +1,9 @@
1
- import { type McpServer, type SessionConfigOption, type ToolKind } from '@agentclientprotocol/sdk';
2
- import type { GatewayAdapter, GatewayAdapterInfo, GatewayConfigOption, GatewayDefinition, GatewayTurnInput, GatewayTurnResult } from '../types.js';
1
+ import { type ContentBlock, type McpServer, type SessionConfigOption, type ToolKind } from '@agentclientprotocol/sdk';
2
+ import type { GatewayAdapter, GatewayAdapterInfo, GatewayConfigOption, GatewayDefinition, GatewayFileSource, GatewaySentFile, GatewayTurnInput, GatewayTurnResult } from '../types.js';
3
3
  type Logger = (message: string, details?: Record<string, unknown>) => void;
4
+ export declare function withoutTransportNotice(text: string): string;
5
+ /** A file in an agent message (an image, or a resource it attached), as something to send the user. */
6
+ export declare function fileFromContent(content: ContentBlock): GatewayFileSource | null;
4
7
  /** Select options only; boolean toggles have no use in the app yet. */
5
8
  export declare function normalizeConfigOptions(options: SessionConfigOption[] | null | undefined): GatewayConfigOption[];
6
9
  export declare function isReadOnlyToolKind(kind: ToolKind | null | undefined): boolean;
@@ -14,7 +17,10 @@ export declare class AcpGatewayAdapter implements GatewayAdapter {
14
17
  /** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
15
18
  private readonly gateway;
16
19
  private readonly log;
17
- /** MCP servers offered to sessions as they are created or loaded, e.g. the Dinox knowledge tools. */
20
+ /**
21
+ * MCP servers offered to sessions as they are created or loaded, e.g. the
22
+ * Dinox tools; `sessionToken` lets their `send_file` reach this session.
23
+ */
18
24
  private readonly mcpServers;
19
25
  readonly kind: "acp";
20
26
  private process;
@@ -22,15 +28,25 @@ export declare class AcpGatewayAdapter implements GatewayAdapter {
22
28
  private readonly turns;
23
29
  /** The latest settings any session reported: the agent-wide choices the app lists. */
24
30
  private catalog;
31
+ /** Token each session's Dinox tools were started with → that session, for routing `send_file`. */
32
+ private readonly sessionTokens;
25
33
  constructor(
26
34
  /** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
27
35
  gateway: GatewayDefinition, log?: Logger,
28
- /** MCP servers offered to sessions as they are created or loaded, e.g. the Dinox knowledge tools. */
29
- mcpServers?: () => McpServer[]);
36
+ /**
37
+ * MCP servers offered to sessions as they are created or loaded, e.g. the
38
+ * Dinox tools; `sessionToken` lets their `send_file` reach this session.
39
+ */
40
+ mcpServers?: (sessionToken: string) => McpServer[]);
30
41
  info(): GatewayAdapterInfo;
31
42
  runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
43
+ sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
32
44
  describeConfig(): Promise<GatewayConfigOption[]>;
33
45
  dispose(): Promise<void>;
46
+ /** Codex leaves generated images on disk; send the ones this turn made when it sent nothing itself. */
47
+ private sendGeneratedImages;
48
+ /** Reports a file the agent put in its message to the user, without failing the turn. */
49
+ private sendContentFile;
34
50
  /** Continues the session (loading it in `cwd` when this process has not seen it) or starts one there. */
35
51
  private resolveSession;
36
52
  /**
@@ -1,9 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import crypto from 'node:crypto';
2
3
  import os from 'node:os';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { Readable, Writable } from 'node:stream';
4
6
  import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from '@agentclientprotocol/sdk';
5
7
  import { generateId } from '../../utils/id.js';
6
8
  import { getPackageVersion } from '../../utils/version.js';
9
+ import { codexGeneratedImagesDir, codexImagesSince } from './codexImages.js';
10
+ import { agentLaunch } from '../spawnAgent.js';
7
11
  const TOOL_STATUS = {
8
12
  pending: 'pending',
9
13
  in_progress: 'running',
@@ -17,6 +21,30 @@ const STOP_REASON = {
17
21
  refusal: 'refusal',
18
22
  cancelled: 'cancelled',
19
23
  };
24
+ /** Codex reports its transport falling back as message text; it is not part of the answer. */
25
+ const TRANSPORT_NOTICE = /^Warning: Falling back from WebSockets to HTTPS transport\.[^\n]*(\n+|$)/;
26
+ export function withoutTransportNotice(text) {
27
+ return text.replace(TRANSPORT_NOTICE, '');
28
+ }
29
+ /** A file in an agent message (an image, or a resource it attached), as something to send the user. */
30
+ export function fileFromContent(content) {
31
+ switch (content.type) {
32
+ case 'image':
33
+ return content.data ? { kind: 'data', data: content.data, mediaType: content.mimeType } : null;
34
+ case 'resource_link':
35
+ return content.uri.startsWith('file://')
36
+ ? { kind: 'path', path: fileURLToPath(content.uri), name: content.name }
37
+ : null;
38
+ case 'resource': {
39
+ const resource = content.resource;
40
+ if (!('blob' in resource))
41
+ return null;
42
+ return { kind: 'data', data: resource.blob, mediaType: resource.mimeType ?? 'application/octet-stream' };
43
+ }
44
+ default:
45
+ return null;
46
+ }
47
+ }
20
48
  function toolContentText(content) {
21
49
  const text = (content ?? [])
22
50
  .map((item) => (item.type === 'content' && item.content.type === 'text' ? item.content.text : ''))
@@ -82,10 +110,15 @@ export class AcpGatewayAdapter {
82
110
  turns = new Map();
83
111
  /** The latest settings any session reported: the agent-wide choices the app lists. */
84
112
  catalog = null;
113
+ /** Token each session's Dinox tools were started with → that session, for routing `send_file`. */
114
+ sessionTokens = new Map();
85
115
  constructor(
86
116
  /** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
87
117
  gateway, log = () => { },
88
- /** MCP servers offered to sessions as they are created or loaded, e.g. the Dinox knowledge tools. */
118
+ /**
119
+ * MCP servers offered to sessions as they are created or loaded, e.g. the
120
+ * Dinox tools; `sessionToken` lets their `send_file` reach this session.
121
+ */
89
122
  mcpServers = () => []) {
90
123
  this.gateway = gateway;
91
124
  this.log = log;
@@ -99,7 +132,9 @@ export class AcpGatewayAdapter {
99
132
  const agent = await this.ensureProcess();
100
133
  const { sessionId, created } = await this.resolveSession(agent, input.sessionId, input.cwd);
101
134
  await this.applyConfig(agent, sessionId, input.config);
102
- this.turns.set(sessionId, { onEvent: input.onEvent, awaitApproval: input.awaitApproval });
135
+ const turn = { onEvent: input.onEvent, awaitApproval: input.awaitApproval, sendFile: input.sendFile, sent: 0 };
136
+ this.turns.set(sessionId, turn);
137
+ const startedAt = Date.now();
103
138
  const onAbort = () => {
104
139
  void agent.connection.cancel({ sessionId }).catch(() => undefined);
105
140
  };
@@ -111,6 +146,8 @@ export class AcpGatewayAdapter {
111
146
  prompt.push({ type: 'resource_link', uri: resource.uri, name: resource.name, mimeType: resource.mediaType ?? null });
112
147
  }
113
148
  const response = await agent.connection.prompt({ sessionId, prompt });
149
+ if (turn.sent === 0)
150
+ await this.sendGeneratedImages(turn, sessionId, startedAt);
114
151
  return { sessionId, stopReason: STOP_REASON[response.stopReason] ?? 'end_turn' };
115
152
  }
116
153
  catch (error) {
@@ -123,6 +160,16 @@ export class AcpGatewayAdapter {
123
160
  this.turns.delete(sessionId);
124
161
  }
125
162
  }
163
+ sendFile(token, file) {
164
+ const sessionId = this.sessionTokens.get(token);
165
+ if (!sessionId)
166
+ return null;
167
+ const turn = this.turns.get(sessionId);
168
+ if (!turn)
169
+ return Promise.reject(new Error('Files can only be sent while answering the user'));
170
+ turn.sent += 1;
171
+ return turn.sendFile(file);
172
+ }
126
173
  async describeConfig() {
127
174
  if (this.catalog)
128
175
  return this.catalog;
@@ -146,6 +193,25 @@ export class AcpGatewayAdapter {
146
193
  return;
147
194
  agent.child.stdin.end();
148
195
  agent.child.kill('SIGTERM');
196
+ this.sessionTokens.clear();
197
+ }
198
+ /** Codex leaves generated images on disk; send the ones this turn made when it sent nothing itself. */
199
+ async sendGeneratedImages(turn, sessionId, startedAt) {
200
+ const dir = codexGeneratedImagesDir(sessionId, { ...process.env, ...(this.gateway.env ?? {}) });
201
+ if (!dir)
202
+ return;
203
+ for (const file of await codexImagesSince(dir, startedAt)) {
204
+ turn.sent += 1;
205
+ await turn.sendFile({ kind: 'path', path: file }).catch((error) => this.log('sending a generated image failed', { error: String(error) }));
206
+ }
207
+ }
208
+ /** Reports a file the agent put in its message to the user, without failing the turn. */
209
+ sendContentFile(turn, content) {
210
+ const file = fileFromContent(content);
211
+ if (!file)
212
+ return;
213
+ turn.sent += 1;
214
+ void turn.sendFile(file).catch((error) => this.log('sending a file from the agent message failed', { error: String(error) }));
149
215
  }
150
216
  /** Continues the session (loading it in `cwd` when this process has not seen it) or starts one there. */
151
217
  async resolveSession(agent, sessionId, cwd) {
@@ -154,7 +220,9 @@ export class AcpGatewayAdapter {
154
220
  if (sessionId && agent.init.agentCapabilities?.loadSession) {
155
221
  try {
156
222
  // No turn is registered yet, so the history the agent replays is ignored.
157
- const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers() });
223
+ const token = crypto.randomBytes(18).toString('base64url');
224
+ const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers(token) });
225
+ this.sessionTokens.set(token, sessionId);
158
226
  agent.liveSessions.add(sessionId);
159
227
  this.rememberConfig(agent, sessionId, loaded.configOptions);
160
228
  return { sessionId, created: false };
@@ -163,7 +231,9 @@ export class AcpGatewayAdapter {
163
231
  this.log('session/load failed; starting a new session', { sessionId, error: String(error) });
164
232
  }
165
233
  }
166
- const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers() });
234
+ const token = crypto.randomBytes(18).toString('base64url');
235
+ const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers(token) });
236
+ this.sessionTokens.set(token, created.sessionId);
167
237
  agent.liveSessions.add(created.sessionId);
168
238
  this.rememberConfig(agent, created.sessionId, created.configOptions);
169
239
  return { sessionId: created.sessionId, created: true };
@@ -209,21 +279,24 @@ export class AcpGatewayAdapter {
209
279
  const command = this.gateway.command;
210
280
  if (!command)
211
281
  throw new Error(`Gateway ${this.gateway.name} has no ACP command`);
212
- const child = spawn(command, this.gateway.args ?? [], {
282
+ const launch = agentLaunch(command, this.gateway.args ?? [], {
213
283
  cwd: this.gateway.cwd ?? os.homedir(),
214
284
  env: { ...process.env, ...(this.gateway.env ?? {}) },
215
285
  stdio: ['pipe', 'pipe', 'pipe'],
216
286
  // The daemon runs without a visible console on Windows; keep agents that way.
217
287
  windowsHide: true,
218
288
  });
289
+ const child = spawn(launch.command, launch.args, launch.options);
219
290
  child.stderr.on('data', (chunk) => this.log('agent stderr', { text: chunk.toString('utf8').slice(0, 2000) }));
220
291
  const exited = new Promise((_, reject) => {
221
292
  child.once('error', reject);
222
293
  child.once('exit', (code, signal) => reject(new Error(`agent exited (code ${code}, signal ${signal})`)));
223
294
  });
224
295
  child.once('exit', () => {
225
- if (this.process?.child === child)
296
+ if (this.process?.child === child) {
226
297
  this.process = null;
298
+ this.sessionTokens.clear();
299
+ }
227
300
  });
228
301
  const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
229
302
  const connection = new ClientSideConnection(() => ({
@@ -255,8 +328,14 @@ export class AcpGatewayAdapter {
255
328
  return;
256
329
  switch (update.sessionUpdate) {
257
330
  case 'agent_message_chunk':
258
- if (update.content.type === 'text')
259
- turn.onEvent({ type: 'text-delta', text: update.content.text });
331
+ if (update.content.type === 'text') {
332
+ const text = withoutTransportNotice(update.content.text);
333
+ if (text)
334
+ turn.onEvent({ type: 'text-delta', text });
335
+ }
336
+ else {
337
+ this.sendContentFile(turn, update.content);
338
+ }
260
339
  break;
261
340
  case 'agent_thought_chunk':
262
341
  if (update.content.type === 'text')
@@ -0,0 +1,4 @@
1
+ export declare const MAX_FALLBACK_IMAGES = 4;
2
+ export declare function codexGeneratedImagesDir(sessionId: string, env: Record<string, string | undefined>): string | null;
3
+ /** Images written to the session's folder since `sinceMs`, oldest first. */
4
+ export declare function codexImagesSince(dir: string, sinceMs: number): Promise<string[]>;
@@ -0,0 +1,34 @@
1
+ import fsp from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ /**
5
+ * Codex saves the images it generates to `$CODEX_HOME/generated_images/<session>/`
6
+ * and only tells the model where they are, so a turn that produced images but
7
+ * never sent them would leave the user with nothing. The newest ones are sent instead.
8
+ */
9
+ const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
10
+ export const MAX_FALLBACK_IMAGES = 4;
11
+ export function codexGeneratedImagesDir(sessionId, env) {
12
+ // The session id names a folder; refuse anything that could leave it.
13
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId))
14
+ return null;
15
+ const codexHome = env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
16
+ return path.join(codexHome, 'generated_images', sessionId);
17
+ }
18
+ /** Images written to the session's folder since `sinceMs`, oldest first. */
19
+ export async function codexImagesSince(dir, sinceMs) {
20
+ const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
21
+ const images = [];
22
+ for (const entry of entries) {
23
+ if (!entry.isFile() || !IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
24
+ continue;
25
+ const file = path.join(dir, entry.name);
26
+ const stats = await fsp.stat(file).catch(() => null);
27
+ if (stats && stats.mtimeMs >= sinceMs)
28
+ images.push({ file, mtimeMs: stats.mtimeMs });
29
+ }
30
+ return images
31
+ .sort((a, b) => a.mtimeMs - b.mtimeMs)
32
+ .slice(-MAX_FALLBACK_IMAGES)
33
+ .map((image) => image.file);
34
+ }
@@ -1,9 +1,12 @@
1
1
  import type { McpServer } from '@agentclientprotocol/sdk';
2
2
  import type { GatewayAdapter, GatewayDefinition } from '../types.js';
3
3
  /**
4
- * The Dinox knowledge tools as a stdio MCP server: this same CLI, run as
4
+ * The Dinox tools as a stdio MCP server: this same CLI, run as
5
5
  * `dino gateway mcp`, reading through the daemon. DINOX_* variables are passed
6
6
  * on so the child finds the same config, data dir and daemon socket.
7
7
  */
8
- export declare function dinoxMcpServerConfig(): McpServer | null;
8
+ export declare function dinoxMcpServerConfig(options: {
9
+ sessionToken: string;
10
+ knowledgeTools: boolean;
11
+ }): McpServer | null;
9
12
  export declare function createGatewayAdapter(gateway: GatewayDefinition, log: (message: string, details?: Record<string, unknown>) => void): GatewayAdapter;
@@ -1,30 +1,39 @@
1
+ import { GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from '../mcpEnv.js';
1
2
  import { AcpGatewayAdapter } from './acp.js';
2
3
  import { EchoGatewayAdapter } from './echo.js';
3
4
  /**
4
- * The Dinox knowledge tools as a stdio MCP server: this same CLI, run as
5
+ * The Dinox tools as a stdio MCP server: this same CLI, run as
5
6
  * `dino gateway mcp`, reading through the daemon. DINOX_* variables are passed
6
7
  * on so the child finds the same config, data dir and daemon socket.
7
8
  */
8
- export function dinoxMcpServerConfig() {
9
+ export function dinoxMcpServerConfig(options) {
9
10
  const entrypoint = process.argv[1];
10
11
  if (!entrypoint)
11
12
  return null;
13
+ const inherited = Object.entries(process.env).filter((entry) => entry[0].startsWith('DINOX_') &&
14
+ entry[0] !== GATEWAY_SESSION_TOKEN_ENV &&
15
+ entry[0] !== GATEWAY_KNOWLEDGE_TOOLS_ENV &&
16
+ typeof entry[1] === 'string');
12
17
  return {
13
18
  name: 'dinox',
14
19
  command: process.execPath,
15
20
  args: [...process.execArgv, entrypoint, 'gateway', 'mcp'],
16
- env: Object.entries(process.env)
17
- .filter((entry) => entry[0].startsWith('DINOX_') && typeof entry[1] === 'string')
18
- .map(([name, value]) => ({ name, value })),
21
+ env: [
22
+ ...inherited,
23
+ [GATEWAY_SESSION_TOKEN_ENV, options.sessionToken],
24
+ [GATEWAY_KNOWLEDGE_TOOLS_ENV, options.knowledgeTools ? '1' : '0'],
25
+ ].map(([name, value]) => ({ name, value })),
19
26
  };
20
27
  }
21
28
  export function createGatewayAdapter(gateway, log) {
22
29
  switch (gateway.kind) {
23
- case 'acp': {
24
- const dinoxTools = dinoxMcpServerConfig();
25
- // Read per session, so turning the Dinox tools off applies to the next new conversation.
26
- return new AcpGatewayAdapter(gateway, log, () => (gateway.dinoxTools !== false && dinoxTools ? [dinoxTools] : []));
27
- }
30
+ case 'acp':
31
+ // Read per session, so turning the knowledge tools off applies to the next new conversation.
32
+ // `send_file` stays offered either way: it is how the agent hands the user a file.
33
+ return new AcpGatewayAdapter(gateway, log, (sessionToken) => {
34
+ const tools = dinoxMcpServerConfig({ sessionToken, knowledgeTools: gateway.dinoxTools !== false });
35
+ return tools ? [tools] : [];
36
+ });
28
37
  case 'echo':
29
38
  return new EchoGatewayAdapter();
30
39
  }
@@ -1,6 +1,7 @@
1
1
  import type { ChatUIMessage } from '../vendor/electron/shared/external-types.js';
2
2
  import type { RemoteToolApprovalPrompt } from '../vendor/electron/shared/remote-device-agent.js';
3
- import type { GatewayEvent } from './types.js';
3
+ import type { GatewayEvent, GatewaySentFile } from './types.js';
4
+ export declare function compactToolValue(value: unknown, depth?: number): unknown;
4
5
  export declare class AssistantProjection {
5
6
  private readonly base;
6
7
  private readonly parts;
@@ -10,11 +11,14 @@ export declare class AssistantProjection {
10
11
  constructor(base: ChatUIMessage);
11
12
  get revision(): number;
12
13
  apply(event: GatewayEvent): void;
14
+ /** A file sent to the user, shown where the agent sent it (the app renders images inline). */
15
+ addFile(file: GatewaySentFile): void;
13
16
  /** Closes streaming parts; called once when the turn ends. */
14
17
  finish(): void;
15
18
  snapshot(): ChatUIMessage;
16
19
  private appendText;
17
20
  private ensureTool;
21
+ private closeTextSegment;
18
22
  private findToolByApproval;
19
23
  private applyTool;
20
24
  }
@@ -8,6 +8,27 @@ const APPROVAL_TTL_MS = 30 * 60 * 1000;
8
8
  const MAX_PENDING_APPROVALS = 32;
9
9
  const PREVIEW_MAX_CHARS = 1500;
10
10
  const SENSITIVE_KEY = /password|token|secret|api[-_ ]?key|authorization|cookie|credential|private[-_ ]?key/i;
11
+ /** Tool input/output is shown as a preview; whole files (e.g. a generated image as base64) would overflow every event. */
12
+ const TOOL_STRING_MAX_CHARS = 4000;
13
+ const TOOL_VALUE_MAX_ITEMS = 64;
14
+ const TOOL_VALUE_MAX_DEPTH = 6;
15
+ export function compactToolValue(value, depth = 0) {
16
+ if (typeof value === 'string') {
17
+ if (value.startsWith('data:') && value.length > 256) {
18
+ return `[${value.slice(5, value.indexOf(';') > 0 ? value.indexOf(';') : 32)} data, ${value.length} chars]`;
19
+ }
20
+ return value.length > TOOL_STRING_MAX_CHARS ? `${value.slice(0, TOOL_STRING_MAX_CHARS)}… (${value.length} chars)` : value;
21
+ }
22
+ if (!value || typeof value !== 'object')
23
+ return value;
24
+ if (depth >= TOOL_VALUE_MAX_DEPTH)
25
+ return '[…]';
26
+ if (Array.isArray(value))
27
+ return value.slice(0, TOOL_VALUE_MAX_ITEMS).map((item) => compactToolValue(item, depth + 1));
28
+ return Object.fromEntries(Object.entries(value)
29
+ .slice(0, TOOL_VALUE_MAX_ITEMS)
30
+ .map(([key, item]) => [key, compactToolValue(item, depth + 1)]));
31
+ }
11
32
  export class AssistantProjection {
12
33
  base;
13
34
  parts = [];
@@ -43,7 +64,7 @@ export class AssistantProjection {
43
64
  part.state = 'approval-requested';
44
65
  part.approval = { id: event.approvalId };
45
66
  if (event.input !== undefined)
46
- part.input = event.input;
67
+ part.input = compactToolValue(event.input);
47
68
  break;
48
69
  }
49
70
  case 'approval-resolved': {
@@ -59,6 +80,18 @@ export class AssistantProjection {
59
80
  }
60
81
  this.revisionCounter += 1;
61
82
  }
83
+ /** A file sent to the user, shown where the agent sent it (the app renders images inline). */
84
+ addFile(file) {
85
+ this.closeTextSegment();
86
+ this.parts.push({
87
+ type: 'file',
88
+ mediaType: file.mediaType,
89
+ url: file.url,
90
+ filename: file.name,
91
+ providerMetadata: { dinox: { resourceId: file.resourceId } },
92
+ });
93
+ this.revisionCounter += 1;
94
+ }
62
95
  /** Closes streaming parts; called once when the turn ends. */
63
96
  finish() {
64
97
  for (const part of this.parts) {
@@ -93,13 +126,16 @@ export class AssistantProjection {
93
126
  if (index !== undefined)
94
127
  return this.parts[index];
95
128
  // A tool call closes the text segment before it.
96
- const last = this.parts.at(-1);
97
- if (last && (last.type === 'text' || last.type === 'reasoning'))
98
- last.state = 'done';
129
+ this.closeTextSegment();
99
130
  const part = { type: 'dynamic-tool', toolCallId, toolName, state: 'input-streaming', input: undefined };
100
131
  this.toolIndex.set(toolCallId, this.parts.push(part) - 1);
101
132
  return part;
102
133
  }
134
+ closeTextSegment() {
135
+ const last = this.parts.at(-1);
136
+ if (last && (last.type === 'text' || last.type === 'reasoning'))
137
+ last.state = 'done';
138
+ }
103
139
  findToolByApproval(approvalId) {
104
140
  return this.parts.find((part) => part.type === 'dynamic-tool' && part.approval?.id === approvalId);
105
141
  }
@@ -108,26 +144,27 @@ export class AssistantProjection {
108
144
  if (event.title)
109
145
  part.toolName = event.title;
110
146
  if (event.input !== undefined)
111
- part.input = event.input;
147
+ part.input = compactToolValue(event.input);
148
+ const output = event.output === undefined ? undefined : compactToolValue(event.output);
112
149
  switch (event.status) {
113
150
  case 'completed':
114
151
  part.state = 'output-available';
115
- part.output = event.output ?? part.output ?? null;
152
+ part.output = output ?? part.output ?? null;
116
153
  break;
117
154
  case 'failed':
118
155
  part.state = 'output-error';
119
- part.errorText = typeof event.output === 'string' ? event.output : 'Tool failed';
156
+ part.errorText = typeof output === 'string' ? output : 'Tool failed';
120
157
  break;
121
158
  case 'pending':
122
159
  case 'running':
123
160
  if (part.state !== 'approval-requested')
124
161
  part.state = 'input-available';
125
- if (event.output !== undefined)
126
- part.output = event.output;
162
+ if (output !== undefined)
163
+ part.output = output;
127
164
  break;
128
165
  default:
129
- if (event.output !== undefined)
130
- part.output = event.output;
166
+ if (output !== undefined)
167
+ part.output = output;
131
168
  }
132
169
  }
133
170
  }
@@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { Readable, Writable } from 'node:stream';
5
5
  import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
6
+ import { agentLaunch } from './spawnAgent.js';
6
7
  import { getPackageVersion } from '../utils/version.js';
7
8
  /**
8
9
  * Known agents that speak ACP over stdio. Each lists launch variants in order;
@@ -70,7 +71,10 @@ export async function findExecutable(name, envPath = process.env.PATH ?? '') {
70
71
  }
71
72
  /** Starts the agent, runs `initialize`, and stops it. Never leaves the process running. */
72
73
  export async function probeAcpAgent(command, args, timeoutMs = PROBE_TIMEOUT_MS) {
73
- const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env, windowsHide: true });
74
+ const launch = agentLaunch(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env, windowsHide: true });
75
+ const child = spawn(launch.command, launch.args, launch.options);
76
+ if (!child.stdin || !child.stdout)
77
+ throw new Error('ACP probe stdio was not piped');
74
78
  let timer;
75
79
  try {
76
80
  const exited = new Promise((_, reject) => {
@@ -97,7 +101,7 @@ export async function probeAcpAgent(command, args, timeoutMs = PROBE_TIMEOUT_MS)
97
101
  }
98
102
  finally {
99
103
  clearTimeout(timer);
100
- child.stdin.destroy();
104
+ child.stdin?.destroy();
101
105
  child.kill('SIGTERM');
102
106
  }
103
107
  }
@@ -1,7 +1,7 @@
1
1
  import { wsconnect } from '@nats-io/nats-core';
2
2
  import type { AbstractPowerSyncDatabase } from '@powersync/common';
3
3
  import type { GatewaySettingsPatch } from './config.js';
4
- import type { GatewayAdapter, GatewayDefinition } from './types.js';
4
+ import type { GatewayAdapter, GatewayDefinition, GatewayFileSource, GatewaySentFile } from './types.js';
5
5
  export declare const DEFAULT_NATS_CREDENTIALS_URL = "https://aisdk.chatgo.pro/api/nats/credentials";
6
6
  type Logger = (message: string, details?: Record<string, unknown>) => void;
7
7
  export type DeviceHostState = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'error';
@@ -59,6 +59,8 @@ export declare class GatewayDeviceHost {
59
59
  /** Latest event per run while offline; the snapshot model makes older ones redundant. */
60
60
  private readonly pendingEvents;
61
61
  constructor(deps: DeviceHostDeps);
62
+ /** A `send_file` from the Dinox tools of one of this gateway's sessions; `null` when the token is another's. */
63
+ sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
62
64
  start(): void;
63
65
  stop(): Promise<void>;
64
66
  status(): DeviceHostStatus;
@@ -7,6 +7,7 @@ import { isAgentConfigQuery, isAgentFoldersQuery, isAgentSettingsQuery, isCancel
7
7
  import { GatewayRunExecutor, MAX_QUEUED_TURNS, QueueFullError } from './runExecutor.js';
8
8
  import { RunStreamPublisher } from './streamPublisher.js';
9
9
  import { findRun, readRecentSessionFolders, resolveTrustedResource, TERMINAL_RUN_STATUSES, threadExists, } from './threadStore.js';
10
+ import { uploadToOfficialStorage } from './fileUpload.js';
10
11
  import { agentHomeFolder, ensureAgentHomeFolder, existingWorkspaceFolders, listWorkspaceFolders, resolveWorkspaceFolder, WorkspaceFolderError, } from './workspaceFolders.js';
11
12
  export const DEFAULT_NATS_CREDENTIALS_URL = 'https://aisdk.chatgo.pro/api/nats/credentials';
12
13
  const CREDENTIALS_TIMEOUT_MS = 10_000;
@@ -68,8 +69,13 @@ export class GatewayDeviceHost {
68
69
  adapter: deps.adapter,
69
70
  log: deps.log,
70
71
  keepAwake: deps.keepAwake,
72
+ uploadFile: (file) => uploadToOfficialStorage({ db: deps.db, userId: deps.userId, authorization: deps.authorization, fetchImpl: deps.fetchImpl }, file),
71
73
  });
72
74
  }
75
+ /** A `send_file` from the Dinox tools of one of this gateway's sessions; `null` when the token is another's. */
76
+ sendFile(token, file) {
77
+ return this.deps.adapter.sendFile?.(token, file) ?? null;
78
+ }
73
79
  start() {
74
80
  this.stopped = false;
75
81
  void this.connect();
@@ -0,0 +1,12 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import type { GatewayFileSource, GatewaySentFile } from './types.js';
3
+ export declare const MAX_SENT_FILE_BYTES: number;
4
+ export type OfficialUploadDeps = {
5
+ db: AbstractPowerSyncDatabase;
6
+ userId: string;
7
+ authorization: string;
8
+ fetchImpl?: typeof fetch;
9
+ /** Overrides the API host (tests). */
10
+ apiBaseUrl?: string;
11
+ };
12
+ export declare function uploadToOfficialStorage(deps: OfficialUploadDeps, source: GatewayFileSource): Promise<GatewaySentFile>;
@@ -0,0 +1,154 @@
1
+ import crypto from 'node:crypto';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { getTableColumns } from '../commands/storage/repo/configs.js';
5
+ import { generateThumbnailBuffer, loadSharp } from '../commands/storage/repo/remote.js';
6
+ import { OFFICIAL_STORAGE_PROVIDER } from '../commands/storage/repo/shared.js';
7
+ import { buildObjectId, generateThumbnailKey, guessContentType } from '../commands/storage/repo/targets.js';
8
+ import { POWERSYNC_DEFAULT_UPLOAD_BASE_URL } from '../config/serviceEndpoints.js';
9
+ import { toAuthorizationHeaderValue } from '../utils/authToken.js';
10
+ import { generateId } from '../utils/id.js';
11
+ /**
12
+ * Files an agent hands to the user leave this computer through the official
13
+ * Dinox storage, the way the app uploads attachments: the API signs a PUT URL,
14
+ * the bytes go straight to OSS, and a `c_resource` row (synced by PowerSync)
15
+ * records them. The message only carries the public URL, never the bytes.
16
+ */
17
+ const OFFICIAL_DOMAIN = 'https://dinoai.oss-accelerate.aliyuncs.com';
18
+ const OFFICIAL_ENDPOINT = 'https://oss-accelerate.aliyuncs.com';
19
+ const OFFICIAL_BUCKET = 'dinoai';
20
+ const CODE_OK = '000000';
21
+ const SIGN_TIMEOUT_MS = 20_000;
22
+ const PUT_TIMEOUT_MS = 120_000;
23
+ export const MAX_SENT_FILE_BYTES = 50 * 1024 * 1024;
24
+ const EXTENSION_BY_MEDIA_TYPE = {
25
+ 'image/png': 'png',
26
+ 'image/jpeg': 'jpg',
27
+ 'image/gif': 'gif',
28
+ 'image/webp': 'webp',
29
+ 'image/svg+xml': 'svg',
30
+ 'application/pdf': 'pdf',
31
+ 'text/plain': 'txt',
32
+ 'text/markdown': 'md',
33
+ };
34
+ function categoryFor(mediaType) {
35
+ if (mediaType.startsWith('image/'))
36
+ return 'images';
37
+ if (mediaType.startsWith('video/'))
38
+ return 'videos';
39
+ if (mediaType.startsWith('audio/'))
40
+ return 'audios';
41
+ return 'files';
42
+ }
43
+ async function readSource(source) {
44
+ if (source.kind === 'data') {
45
+ const bytes = Buffer.from(source.data, 'base64');
46
+ const extension = EXTENSION_BY_MEDIA_TYPE[source.mediaType];
47
+ const name = source.name?.trim() || `file${extension ? `.${extension}` : ''}`;
48
+ if (bytes.byteLength > MAX_SENT_FILE_BYTES)
49
+ throw new Error(`${name} is larger than 50 MB`);
50
+ return { bytes, name, mediaType: source.mediaType };
51
+ }
52
+ const stats = await fsp.stat(source.path).catch(() => null);
53
+ if (!stats?.isFile())
54
+ throw new Error(`Not a file: ${source.path}`);
55
+ if (stats.size > MAX_SENT_FILE_BYTES)
56
+ throw new Error(`${path.basename(source.path)} is larger than 50 MB`);
57
+ return {
58
+ bytes: await fsp.readFile(source.path),
59
+ name: source.name?.trim() || path.basename(source.path),
60
+ mediaType: guessContentType(source.path),
61
+ };
62
+ }
63
+ async function fetchWithTimeout(fetchImpl, url, init, timeoutMs) {
64
+ return fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
65
+ }
66
+ /** A presigned PUT URL for `key` from the Dinox API. */
67
+ async function signPut(deps, key, contentType) {
68
+ const fetchImpl = deps.fetchImpl ?? fetch;
69
+ const response = await fetchWithTimeout(fetchImpl, `${deps.apiBaseUrl ?? POWERSYNC_DEFAULT_UPLOAD_BASE_URL}/oss/generatePreSignedUrl`, {
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ accept: 'application/json',
74
+ Authorization: toAuthorizationHeaderValue(deps.authorization),
75
+ },
76
+ body: JSON.stringify({ objectId: key, contentType }),
77
+ }, SIGN_TIMEOUT_MS);
78
+ const body = (await response.json().catch(() => null));
79
+ const url = body?.data?.url;
80
+ if (!response.ok || body?.code !== CODE_OK || typeof url !== 'string' || !url) {
81
+ throw new Error(`Signing the upload failed (${response.status}${body?.msg ? `: ${String(body.msg)}` : ''})`);
82
+ }
83
+ return url;
84
+ }
85
+ async function putObject(deps, key, bytes, contentType) {
86
+ const signedUrl = await signPut(deps, key, contentType);
87
+ const response = await fetchWithTimeout(deps.fetchImpl ?? fetch, signedUrl, { method: 'PUT', headers: { 'Content-Type': contentType }, body: new Uint8Array(bytes) }, PUT_TIMEOUT_MS);
88
+ if (!response.ok)
89
+ throw new Error(`Uploading failed (${response.status})`);
90
+ return response.headers.get('etag');
91
+ }
92
+ /** The thumbnail the app shows in lists; optional, so a missing image library never fails the upload. */
93
+ async function uploadThumbnail(deps, key, bytes) {
94
+ try {
95
+ const sharp = await loadSharp();
96
+ const thumbnail = await generateThumbnailBuffer(sharp, bytes);
97
+ const thumbnailKey = generateThumbnailKey(key);
98
+ await putObject(deps, thumbnailKey, thumbnail, 'image/webp');
99
+ return `${OFFICIAL_DOMAIN}/${thumbnailKey}`;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ }
105
+ async function insertResource(db, values) {
106
+ // Only the columns this schema version has, like `dino storage upload`.
107
+ const columns = await getTableColumns(db, 'c_resource');
108
+ const entries = Object.entries(values).filter(([column]) => columns.has(column));
109
+ await db.execute(`INSERT INTO c_resource (${entries.map(([column]) => column).join(', ')}) VALUES (${entries.map(() => '?').join(', ')})`, entries.map(([, value]) => value));
110
+ }
111
+ export async function uploadToOfficialStorage(deps, source) {
112
+ const { bytes, name, mediaType } = await readSource(source);
113
+ const category = categoryFor(mediaType);
114
+ const extension = path.extname(name) || (EXTENSION_BY_MEDIA_TYPE[mediaType] ? `.${EXTENSION_BY_MEDIA_TYPE[mediaType]}` : '');
115
+ const key = buildObjectId(deps.userId, category, `file${extension}`);
116
+ const etag = await putObject(deps, key, bytes, mediaType);
117
+ const url = `${OFFICIAL_DOMAIN}/${key}`;
118
+ const thumbnailUrl = category === 'images' && mediaType !== 'image/svg+xml' ? await uploadThumbnail(deps, key, bytes) : null;
119
+ const id = generateId();
120
+ const now = new Date().toISOString();
121
+ // Same row the app writes for an official upload (the thumbnail URL lives in `checksum`).
122
+ await insertResource(deps.db, {
123
+ id,
124
+ user_id: deps.userId,
125
+ name,
126
+ mime_type: mediaType,
127
+ size_bytes: bytes.byteLength,
128
+ storage_config_id: null,
129
+ storage_key: key,
130
+ storage_url: url,
131
+ storage_provider: OFFICIAL_STORAGE_PROVIDER,
132
+ bucket: OFFICIAL_BUCKET,
133
+ is_public: 0,
134
+ created_at: now,
135
+ updated_at: now,
136
+ is_del: 0,
137
+ status: 'uploaded',
138
+ uploaded_at: now,
139
+ etag: etag ?? '',
140
+ checksum: thumbnailUrl ?? '',
141
+ category,
142
+ width: 0,
143
+ height: 0,
144
+ duration: 0,
145
+ endpoint: OFFICIAL_ENDPOINT,
146
+ domain: OFFICIAL_DOMAIN,
147
+ extra_data: '{}',
148
+ deleted_at: null,
149
+ content_sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
150
+ content_md: null,
151
+ summary: null,
152
+ });
153
+ return { resourceId: id, url, name, mediaType, sizeBytes: bytes.byteLength };
154
+ }
@@ -0,0 +1,5 @@
1
+ /** Environment the gateway starts `dino gateway mcp` with, for each agent session. */
2
+ /** Names the session whose turn a `send_file` belongs to. */
3
+ export declare const GATEWAY_SESSION_TOKEN_ENV = "DINOX_GATEWAY_SESSION_TOKEN";
4
+ /** `0` offers only `send_file`: the owner turned the knowledge tools off. */
5
+ export declare const GATEWAY_KNOWLEDGE_TOOLS_ENV = "DINOX_GATEWAY_KNOWLEDGE_TOOLS";
@@ -0,0 +1,5 @@
1
+ /** Environment the gateway starts `dino gateway mcp` with, for each agent session. */
2
+ /** Names the session whose turn a `send_file` belongs to. */
3
+ export const GATEWAY_SESSION_TOKEN_ENV = 'DINOX_GATEWAY_SESSION_TOKEN';
4
+ /** `0` offers only `send_file`: the owner turned the knowledge tools off. */
5
+ export const GATEWAY_KNOWLEDGE_TOOLS_ENV = 'DINOX_GATEWAY_KNOWLEDGE_TOOLS';
@@ -1,4 +1,14 @@
1
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;
2
+ export type DaemonCall = (method: string, params?: unknown, options?: {
3
+ timeoutMs?: number;
4
+ }) => Promise<unknown>;
5
+ export type DinoxMcpOptions = {
6
+ /** Offer the knowledge tools (notes, todos, conversations); default true. */
7
+ knowledgeTools?: boolean;
8
+ /** The session `send_file` belongs to; absent, `send_file` is not offered. */
9
+ sessionToken?: string;
10
+ /** Resolves relative `send_file` paths; default the process directory. */
11
+ cwd?: string;
12
+ };
13
+ export declare function createDinoxMcpServer(call?: DaemonCall, options?: DinoxMcpOptions): McpServer;
4
14
  export declare function serveDinoxMcpOverStdio(): Promise<void>;
@@ -1,25 +1,57 @@
1
+ import path from 'node:path';
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
4
  import { z } from 'zod';
4
5
  import { sendDaemonRequest } from '../daemon/client.js';
6
+ import { GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from './mcpEnv.js';
5
7
  import { READ_CONVERSATION_MAX_LIMIT } from './conversationContext.js';
6
8
  import { getPackageVersion } from '../utils/version.js';
7
9
  /**
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.
10
+ * Dinox tools for gateway agents, served over stdio MCP. The agent launches
11
+ * this as a child process (`dino gateway mcp`); every call goes through the
12
+ * daemon, which owns the database. The knowledge tools are read-only by
13
+ * design: writes from a third-party agent would bypass the app's approval
14
+ * flow. `send_file` only adds a file to the reply the agent is writing.
12
15
  */
13
16
  const MAX_NOTE_CHARS = 20_000;
14
17
  const MAX_RESULTS = 50;
18
+ /** Uploading a large file can take a while; the daemon's default request timeout is 10s. */
19
+ const SEND_FILE_TIMEOUT_MS = 150_000;
15
20
  function text(value) {
16
21
  return { content: [{ type: 'text', text: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }] };
17
22
  }
18
23
  function failure(error) {
19
24
  return { ...text(`Dinox is unavailable: ${error instanceof Error ? error.message : String(error)}`), isError: true };
20
25
  }
21
- export function createDinoxMcpServer(call = (method, params) => sendDaemonRequest(method, params)) {
26
+ export function createDinoxMcpServer(call = (method, params, options) => sendDaemonRequest(method, params, options), options = {}) {
22
27
  const server = new McpServer({ name: 'dinox', version: getPackageVersion() });
28
+ if (options.sessionToken)
29
+ registerSendFile(server, call, options.sessionToken, options.cwd ?? process.cwd());
30
+ if (options.knowledgeTools !== false)
31
+ registerKnowledgeTools(server, call);
32
+ return server;
33
+ }
34
+ function registerSendFile(server, call, sessionToken, cwd) {
35
+ server.registerTool('send_file', {
36
+ description: 'Show the user a file from this computer (an image you generated, a document, a chart, ...). The user talks to ' +
37
+ 'you from the Dinox app and cannot open files on this computer, so a path alone is useless to them: call this ' +
38
+ 'for every file they should see, then answer as usual. Images appear in the reply; other files as attachments. ' +
39
+ 'Up to 50 MB per file.',
40
+ inputSchema: {
41
+ path: z.string().describe('Absolute path of the file (relative paths resolve from your working directory)'),
42
+ name: z.string().optional().describe('File name to show; defaults to the file name in the path'),
43
+ },
44
+ }, async ({ path: filePath, name }) => {
45
+ try {
46
+ const sent = (await call('gateway.sendFile', { token: sessionToken, path: path.resolve(cwd, filePath), ...(name ? { name } : {}) }, { timeoutMs: SEND_FILE_TIMEOUT_MS }));
47
+ return text({ sent: true, name: sent.name, mediaType: sent.mediaType });
48
+ }
49
+ catch (error) {
50
+ return { ...text(`Sending the file failed: ${error instanceof Error ? error.message : String(error)}`), isError: true };
51
+ }
52
+ });
53
+ }
54
+ function registerKnowledgeTools(server, call) {
23
55
  server.registerTool('search_notes', {
24
56
  description: "Search the user's Dinox notes by keyword. Returns id, title, summary, tags and folders; use read_note for full content.",
25
57
  inputSchema: {
@@ -129,10 +161,12 @@ export function createDinoxMcpServer(call = (method, params) => sendDaemonReques
129
161
  return failure(error);
130
162
  }
131
163
  });
132
- return server;
133
164
  }
134
165
  export async function serveDinoxMcpOverStdio() {
135
- const server = createDinoxMcpServer();
166
+ const server = createDinoxMcpServer(undefined, {
167
+ knowledgeTools: process.env[GATEWAY_KNOWLEDGE_TOOLS_ENV] !== '0',
168
+ sessionToken: process.env[GATEWAY_SESSION_TOKEN_ENV]?.trim() || undefined,
169
+ });
136
170
  await server.connect(new StdioServerTransport());
137
171
  await new Promise((resolve) => process.stdin.once('close', resolve));
138
172
  }
@@ -1,9 +1,11 @@
1
1
  import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
2
  import type { RemoteDeviceRunRequest, RemoteDeviceToolApprovalAckCode } from '../vendor/electron/shared/remote-device-agent.js';
3
3
  import type { RunSnapshot } from './streamPublisher.js';
4
- import type { GatewayAdapter, GatewayDefinition } from './types.js';
4
+ import type { GatewayAdapter, GatewayDefinition, GatewayFileSource, GatewaySentFile } from './types.js';
5
5
  /** Turns waiting behind a thread's active one; more are refused as `busy`. */
6
6
  export declare const MAX_QUEUED_TURNS = 10;
7
+ /** Files one reply may carry, like the attachments of one message. */
8
+ export declare const MAX_SENT_FILES_PER_TURN = 16;
7
9
  /** How long a turn waits for the client's latest messages to sync to this machine. */
8
10
  export declare const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5000;
9
11
  type Logger = (message: string, details?: Record<string, unknown>) => void;
@@ -21,9 +23,15 @@ export type RunExecutorDeps = {
21
23
  clientTailSyncTimeoutMs?: number;
22
24
  /** Keeps the computer awake while a turn runs; returns the release. */
23
25
  keepAwake?: () => () => void;
26
+ /** Uploads a file the agent sends to the user; absent, sending files fails. */
27
+ uploadFile?: (file: GatewayFileSource) => Promise<GatewaySentFile>;
24
28
  };
25
29
  /** The user's instructions for the agent, as the start of a new session. */
26
30
  export declare function agentInstructionsPreamble(instructions: string): string | undefined;
31
+ /** How the user sees this conversation, so the agent hands over files instead of printing their paths. */
32
+ export declare const SESSION_DELIVERY_NOTE: string;
33
+ /** What a new session starts with: how replies reach the user, then the user's instructions. */
34
+ export declare function sessionPreamble(instructions: string): string;
27
35
  /**
28
36
  * Runs accepted turns on one gateway. A thread has at most one active run;
29
37
  * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
3
3
  import { CONTEXT_MESSAGE_LIMIT, withConversationContext } from './conversationContext.js';
4
- import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, withdrawQueuedRun, readAgentInstructions, readCurrentTopic, readSessionBinding, readTranscriptPage, resolveTrustedResource, updateRunSnapshot, waitForMessage, withGatewaySession, writeSessionBinding, } from './threadStore.js';
4
+ import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, TERMINAL_RUN_STATUSES, withdrawQueuedRun, readAgentInstructions, readCurrentTopic, readSessionBinding, readTranscriptPage, resolveTrustedResource, updateRunSnapshot, waitForMessage, withGatewaySession, writeSessionBinding, } from './threadStore.js';
5
5
  import { readAgentConfig } from './protocol.js';
6
6
  const SNAPSHOT_PERSIST_INTERVAL_MS = 1_000;
7
7
  /** Turns waiting behind a thread's active one; more are refused as `busy`. */
@@ -9,6 +9,8 @@ export const MAX_QUEUED_TURNS = 10;
9
9
  const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
10
10
  /** The agent's instructions travel with a new session's first prompt; longer ones are cut. */
11
11
  const MAX_INSTRUCTIONS_CHARS = 8_000;
12
+ /** Files one reply may carry, like the attachments of one message. */
13
+ export const MAX_SENT_FILES_PER_TURN = 16;
12
14
  /** How long a turn waits for the client's latest messages to sync to this machine. */
13
15
  export const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5_000;
14
16
  /** Client tails that never synced, so later turns of their thread do not wait for them again. */
@@ -40,6 +42,17 @@ export function agentInstructionsPreamble(instructions) {
40
42
  '</dinox_agent_instructions>',
41
43
  ].join('\n');
42
44
  }
45
+ /** How the user sees this conversation, so the agent hands over files instead of printing their paths. */
46
+ export const SESSION_DELIVERY_NOTE = [
47
+ '<dinox_delivery>',
48
+ 'The user reads your replies in the Dinox app, usually on another device, and cannot open files on this computer.',
49
+ 'To show them a file you made or found (an image, a document, a chart, ...), call the Dinox send_file tool with its path.',
50
+ '</dinox_delivery>',
51
+ ].join('\n');
52
+ /** What a new session starts with: how replies reach the user, then the user's instructions. */
53
+ export function sessionPreamble(instructions) {
54
+ return [SESSION_DELIVERY_NOTE, agentInstructionsPreamble(instructions)].filter(Boolean).join('\n\n');
55
+ }
43
56
  function withErrorText(message, error) {
44
57
  return { ...message, metadata: { ...(message.metadata ?? {}), error } };
45
58
  }
@@ -261,6 +274,30 @@ export class GatewayRunExecutor {
261
274
  return decision;
262
275
  };
263
276
  const awaitApproval = registerApproval;
277
+ // Uploads still running when the agent ends its turn finish before the reply is committed.
278
+ const uploads = new Set();
279
+ let filesSent = 0;
280
+ const sendFile = (file) => {
281
+ const upload = this.deps.uploadFile;
282
+ if (!upload)
283
+ return Promise.reject(new Error('Sending files is not available on this computer'));
284
+ if (filesSent >= MAX_SENT_FILES_PER_TURN) {
285
+ return Promise.reject(new Error(`At most ${MAX_SENT_FILES_PER_TURN} files can be sent in one reply`));
286
+ }
287
+ filesSent += 1;
288
+ const pending = upload(file).then((sent) => {
289
+ projection.addFile(sent);
290
+ // Once the turn has ended, the terminal snapshot carries the file.
291
+ if (!TERMINAL_RUN_STATUSES.has(status))
292
+ publish(true);
293
+ return sent;
294
+ });
295
+ uploads.add(pending);
296
+ void pending
297
+ .catch((error) => log('send file failed', { runId: active.runId, error: String(error) }))
298
+ .finally(() => uploads.delete(pending));
299
+ return pending;
300
+ };
264
301
  let errorText = null;
265
302
  let nextBinding = null;
266
303
  try {
@@ -286,12 +323,13 @@ export class GatewayRunExecutor {
286
323
  const result = await adapter.runTurn({
287
324
  sessionId: continued?.sessionId ?? null,
288
325
  cwd,
289
- sessionPreamble: agentInstructionsPreamble(instructions),
326
+ sessionPreamble: this.deps.uploadFile ? sessionPreamble(instructions) : agentInstructionsPreamble(instructions),
290
327
  prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
291
328
  resources,
292
329
  signal: active.controller.signal,
293
330
  config: agentConfig,
294
331
  awaitApproval,
332
+ sendFile,
295
333
  onEvent: (event) => {
296
334
  projection.apply(event);
297
335
  if (event.type === 'approval-request') {
@@ -301,6 +339,8 @@ export class GatewayRunExecutor {
301
339
  publish(event.type === 'approval-request' || event.type === 'approval-resolved');
302
340
  },
303
341
  });
342
+ // Files still uploading belong to this reply: the turn ends once they are in it.
343
+ await Promise.allSettled([...uploads]);
304
344
  nextBinding = {
305
345
  sessionId: result.sessionId,
306
346
  topicId,
@@ -326,6 +366,7 @@ export class GatewayRunExecutor {
326
366
  for (const approval of active.approvals.values())
327
367
  clearTimeout(approval.timer);
328
368
  }
369
+ await Promise.allSettled([...uploads]);
329
370
  projection.finish();
330
371
  const assistant = errorText ? withErrorText(projection.snapshot(), errorText) : projection.snapshot();
331
372
  try {
@@ -1,5 +1,6 @@
1
1
  import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
2
  import { type DeviceHostStatus } from './deviceHost.js';
3
+ import type { GatewayFileSource, GatewaySentFile } from './types.js';
3
4
  type Logger = (message: string, details?: Record<string, unknown>) => void;
4
5
  export type GatewayServiceDeps = {
5
6
  db: AbstractPowerSyncDatabase;
@@ -25,6 +26,8 @@ export declare class GatewayService {
25
26
  /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
26
27
  private updateSettings;
27
28
  private robotRegistration;
29
+ /** Routes a `send_file` from an agent's Dinox tools to the turn of the session that started them. */
30
+ sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile>;
28
31
  status(): DeviceHostStatus[];
29
32
  }
30
33
  export {};
@@ -105,6 +105,15 @@ export class GatewayService {
105
105
  },
106
106
  };
107
107
  }
108
+ /** Routes a `send_file` from an agent's Dinox tools to the turn of the session that started them. */
109
+ sendFile(token, file) {
110
+ for (const host of this.hosts) {
111
+ const sent = host.sendFile(token, file);
112
+ if (sent)
113
+ return sent;
114
+ }
115
+ return Promise.reject(new Error('This conversation is no longer running on this computer'));
116
+ }
108
117
  status() {
109
118
  return this.hosts.map((host) => host.status());
110
119
  }
@@ -0,0 +1,12 @@
1
+ import type { SpawnOptions } from 'node:child_process';
2
+ export type AgentLaunch = {
3
+ command: string;
4
+ args: readonly string[];
5
+ options: SpawnOptions;
6
+ };
7
+ /**
8
+ * Node's `spawn` cannot execute a Windows `.cmd` / `.bat` directly: CreateProcess
9
+ * returns EINVAL. npm global shims are those files (`claude-agent-acp.CMD`).
10
+ * Launch them through `cmd.exe /d /s /c`, with the same argument array everywhere else.
11
+ */
12
+ export declare function agentLaunch(command: string, args: readonly string[], options: SpawnOptions, platform?: NodeJS.Platform): AgentLaunch;
@@ -0,0 +1,32 @@
1
+ const WINDOWS_BATCH = /\.(?:cmd|bat)$/i;
2
+ /** Characters cmd.exe would interpret if a batch path or argument reached the shell unquoted. */
3
+ const CMD_METACHARACTERS = /[\0\r\n"&|<>^%]/;
4
+ /**
5
+ * Node's `spawn` cannot execute a Windows `.cmd` / `.bat` directly: CreateProcess
6
+ * returns EINVAL. npm global shims are those files (`claude-agent-acp.CMD`).
7
+ * Launch them through `cmd.exe /d /s /c`, with the same argument array everywhere else.
8
+ */
9
+ export function agentLaunch(command, args, options, platform = process.platform) {
10
+ if (platform !== 'win32' || !WINDOWS_BATCH.test(command)) {
11
+ return { command, args, options };
12
+ }
13
+ for (const part of [command, ...args]) {
14
+ if (CMD_METACHARACTERS.test(part)) {
15
+ throw new Error(`Cannot launch a Windows batch file containing shell metacharacters (${part})`);
16
+ }
17
+ }
18
+ const inner = [quoteCmdArg(command), ...args.map(quoteCmdArg)].join(' ');
19
+ return {
20
+ command: process.env.ComSpec || 'cmd.exe',
21
+ args: ['/d', '/s', '/c', `"${inner}"`],
22
+ options: { ...options, windowsVerbatimArguments: true },
23
+ };
24
+ }
25
+ /** Quote one argv token for CommandLineToArgvW. Tokens without spaces stay bare. */
26
+ function quoteCmdArg(value) {
27
+ if (value.length === 0)
28
+ return '""';
29
+ if (!/[\s"]/.test(value))
30
+ return value;
31
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`;
32
+ }
@@ -93,6 +93,25 @@ export type GatewayConfigOption = {
93
93
  group: string | null;
94
94
  }>;
95
95
  };
96
+ /** A file the agent hands to the user: a local path, or bytes it sent inline (base64). */
97
+ export type GatewayFileSource = {
98
+ kind: 'path';
99
+ path: string;
100
+ name?: string;
101
+ } | {
102
+ kind: 'data';
103
+ data: string;
104
+ mediaType: string;
105
+ name?: string;
106
+ };
107
+ /** A file uploaded for the user, as it appears in the reply. */
108
+ export type GatewaySentFile = {
109
+ resourceId: string;
110
+ url: string;
111
+ name: string;
112
+ mediaType: string;
113
+ sizeBytes: number;
114
+ };
96
115
  export type GatewayStopReason = 'end_turn' | 'cancelled' | 'max_tokens' | 'refusal' | 'error';
97
116
  export type GatewayTurnInput = {
98
117
  /** Agent session to continue; absent starts a new one. */
@@ -114,6 +133,8 @@ export type GatewayTurnInput = {
114
133
  onEvent: (event: GatewayEvent) => void;
115
134
  /** Resolves with the user's decision for an `approval-request` event. */
116
135
  awaitApproval: (approvalId: string) => Promise<boolean>;
136
+ /** Uploads a file for the user and adds it to the reply. */
137
+ sendFile: (file: GatewayFileSource) => Promise<GatewaySentFile>;
117
138
  };
118
139
  export type GatewayTurnResult = {
119
140
  sessionId: string;
@@ -132,5 +153,11 @@ export interface GatewayAdapter {
132
153
  runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
133
154
  /** The agent's session settings (models, modes); absent when it has none. */
134
155
  describeConfig?(): Promise<GatewayConfigOption[]>;
156
+ /**
157
+ * A `send_file` call from the Dinox tools of one of this adapter's sessions,
158
+ * named by the token the session's tools were started with; `null` when the
159
+ * token is not this adapter's.
160
+ */
161
+ sendFile?(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
135
162
  dispose(): Promise<void>;
136
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {