@dinoxx/dinox-cli 2.15.0 → 2.16.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 CHANGED
@@ -937,6 +937,8 @@ daemon 通过用户数据目录下的私有 Unix domain socket 通信,Windows
937
937
 
938
938
  ### 6.15 `gateway`:把本机 Agent 挂成远程设备
939
939
 
940
+ 在 Dinox App 中为每个 Agent 选择工作目录;会话始终保留开始时的目录,开启新话题也不改变。App 可以浏览该会话在这台电脑上的目录。
941
+
940
942
  ```bash
941
943
  dino gateway detect
942
944
  dino gateway detect --add
@@ -37,11 +37,15 @@ export declare class AcpGatewayAdapter implements GatewayAdapter {
37
37
  * MCP servers offered to sessions as they are created or loaded, e.g. the
38
38
  * Dinox tools; `sessionToken` lets their `send_file` reach this session.
39
39
  */
40
- mcpServers?: (sessionToken: string) => McpServer[]);
40
+ mcpServers?: (sessionToken: string, cwd: string) => McpServer[]);
41
41
  info(): GatewayAdapterInfo;
42
42
  runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
43
43
  sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
44
+ private catalogExpiresAt;
45
+ private catalogRevision;
46
+ private probing;
44
47
  describeConfig(): Promise<GatewayConfigOption[]>;
48
+ private probeConfig;
45
49
  dispose(): Promise<void>;
46
50
  /** Codex leaves generated images on disk; send the ones this turn made when it sent nothing itself. */
47
51
  private sendGeneratedImages;
@@ -140,7 +140,7 @@ export class AcpGatewayAdapter {
140
140
  };
141
141
  input.signal.addEventListener('abort', onAbort, { once: true });
142
142
  try {
143
- const text = created && input.sessionPreamble ? `${input.sessionPreamble}\n\n${input.prompt}` : input.prompt;
143
+ const text = await input.prompt({ created });
144
144
  const prompt = [{ type: 'text', text }];
145
145
  for (const resource of input.resources) {
146
146
  prompt.push({ type: 'resource_link', uri: resource.uri, name: resource.name, mimeType: resource.mediaType ?? null });
@@ -170,16 +170,23 @@ export class AcpGatewayAdapter {
170
170
  turn.sent += 1;
171
171
  return turn.sendFile(file);
172
172
  }
173
+ catalogExpiresAt = 0;
174
+ catalogRevision = 0;
175
+ probing = null;
173
176
  async describeConfig() {
174
- if (this.catalog)
177
+ if (this.catalog && Date.now() < this.catalogExpiresAt)
175
178
  return this.catalog;
179
+ if (!this.probing)
180
+ this.probing = this.probeConfig().finally(() => { this.probing = null; });
181
+ return this.probing;
182
+ }
183
+ async probeConfig() {
176
184
  const agent = await this.ensureProcess();
177
- if (this.catalog)
178
- return this.catalog;
179
185
  // No session yet: open one only to read the agent's choices. It never runs a turn,
180
186
  // so close it where the agent allows, or it shows up in the user's session list.
187
+ const revision = this.catalogRevision;
181
188
  const created = await agent.connection.newSession({ cwd: this.gateway.cwd ?? os.homedir(), mcpServers: [] });
182
- this.rememberConfig(agent, created.sessionId, created.configOptions);
189
+ this.rememberConfig(agent, created.sessionId, created.configOptions, revision === this.catalogRevision);
183
190
  if (agent.init.agentCapabilities?.sessionCapabilities?.close) {
184
191
  await agent.connection.closeSession({ sessionId: created.sessionId }).catch((error) => this.log('closing the settings session failed', { error: String(error) }));
185
192
  }
@@ -221,7 +228,7 @@ export class AcpGatewayAdapter {
221
228
  try {
222
229
  // No turn is registered yet, so the history the agent replays is ignored.
223
230
  const token = crypto.randomBytes(18).toString('base64url');
224
- const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers(token) });
231
+ const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers(token, cwd) });
225
232
  this.sessionTokens.set(token, sessionId);
226
233
  agent.liveSessions.add(sessionId);
227
234
  this.rememberConfig(agent, sessionId, loaded.configOptions);
@@ -232,7 +239,7 @@ export class AcpGatewayAdapter {
232
239
  }
233
240
  }
234
241
  const token = crypto.randomBytes(18).toString('base64url');
235
- const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers(token) });
242
+ const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers(token, cwd) });
236
243
  this.sessionTokens.set(token, created.sessionId);
237
244
  agent.liveSessions.add(created.sessionId);
238
245
  this.rememberConfig(agent, created.sessionId, created.configOptions);
@@ -260,12 +267,17 @@ export class AcpGatewayAdapter {
260
267
  }
261
268
  }
262
269
  }
263
- rememberConfig(agent, sessionId, options) {
270
+ rememberConfig(agent, sessionId, options, updateCatalog = true) {
264
271
  if (!options)
265
272
  return;
266
273
  const normalized = normalizeConfigOptions(options);
267
274
  agent.sessionConfig.set(sessionId, normalized);
268
- this.catalog = normalized;
275
+ // A slow probe must not overwrite a newer live session update.
276
+ if (updateCatalog) {
277
+ this.catalog = normalized;
278
+ this.catalogExpiresAt = Date.now() + 5 * 60_000;
279
+ this.catalogRevision += 1;
280
+ }
269
281
  }
270
282
  ensureProcess() {
271
283
  if (this.process)
@@ -24,18 +24,21 @@ export class EchoGatewayAdapter {
24
24
  async runTurn(input) {
25
25
  const sessionId = input.sessionId ?? `echo-${generateId()}`;
26
26
  try {
27
- if (input.prompt.includes('/approve')) {
27
+ const text = await input.prompt({ created: input.sessionId === null });
28
+ // Echo the user text while exercising the full prompt factory.
29
+ const prompt = text.replace(/<dinox_(delivery|knowledge|agent_instructions)>[\s\S]*?<\/dinox_\1>\n\n/g, '');
30
+ if (prompt.includes('/approve')) {
28
31
  const toolCallId = `tool-${generateId()}`;
29
32
  const approvalId = `approval-${generateId()}`;
30
- input.onEvent({ type: 'tool', toolCallId, title: 'echo.write', status: 'pending', input: { text: input.prompt } });
31
- input.onEvent({ type: 'approval-request', approvalId, toolCallId, toolName: 'echo.write', input: { text: input.prompt } });
33
+ input.onEvent({ type: 'tool', toolCallId, title: 'echo.write', status: 'pending', input: { text: prompt } });
34
+ input.onEvent({ type: 'approval-request', approvalId, toolCallId, toolName: 'echo.write', input: { text: prompt } });
32
35
  const approved = await input.awaitApproval(approvalId);
33
36
  input.onEvent({ type: 'approval-resolved', approvalId, approved });
34
37
  input.onEvent(approved
35
38
  ? { type: 'tool', toolCallId, status: 'completed', output: 'approved' }
36
39
  : { type: 'tool', toolCallId, status: 'failed', output: 'denied by user' });
37
40
  }
38
- for (const word of `Echo: ${input.prompt}`.split(/(\s+)/)) {
41
+ for (const word of `Echo: ${prompt}`.split(/(\s+)/)) {
39
42
  await delay(CHUNK_DELAY_MS, input.signal);
40
43
  input.onEvent({ type: 'text-delta', text: word });
41
44
  }
@@ -8,5 +8,6 @@ import type { GatewayAdapter, GatewayDefinition } from '../types.js';
8
8
  export declare function dinoxMcpServerConfig(options: {
9
9
  sessionToken: string;
10
10
  knowledgeTools: boolean;
11
+ cwd?: string;
11
12
  }): McpServer | null;
12
13
  export declare function createGatewayAdapter(gateway: GatewayDefinition, log: (message: string, details?: Record<string, unknown>) => void): GatewayAdapter;
@@ -1,4 +1,4 @@
1
- import { DINOX_MCP_SERVER_NAME, GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from '../mcpEnv.js';
1
+ import { DINOX_MCP_SERVER_NAME, GATEWAY_SESSION_CWD_ENV, GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from '../mcpEnv.js';
2
2
  import { AcpGatewayAdapter } from './acp.js';
3
3
  import { EchoGatewayAdapter } from './echo.js';
4
4
  /**
@@ -12,6 +12,7 @@ export function dinoxMcpServerConfig(options) {
12
12
  return null;
13
13
  const inherited = Object.entries(process.env).filter((entry) => entry[0].startsWith('DINOX_') &&
14
14
  entry[0] !== GATEWAY_SESSION_TOKEN_ENV &&
15
+ entry[0] !== GATEWAY_SESSION_CWD_ENV &&
15
16
  entry[0] !== GATEWAY_KNOWLEDGE_TOOLS_ENV &&
16
17
  typeof entry[1] === 'string');
17
18
  return {
@@ -20,6 +21,7 @@ export function dinoxMcpServerConfig(options) {
20
21
  args: [...process.execArgv, entrypoint, 'gateway', 'mcp'],
21
22
  env: [
22
23
  ...inherited,
24
+ [GATEWAY_SESSION_CWD_ENV, options.cwd ?? process.cwd()],
23
25
  [GATEWAY_SESSION_TOKEN_ENV, options.sessionToken],
24
26
  [GATEWAY_KNOWLEDGE_TOOLS_ENV, options.knowledgeTools ? '1' : '0'],
25
27
  ].map(([name, value]) => ({ name, value })),
@@ -30,8 +32,8 @@ export function createGatewayAdapter(gateway, log) {
30
32
  case 'acp':
31
33
  // Read per session, so turning the knowledge tools off applies to the next new conversation.
32
34
  // `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 new AcpGatewayAdapter(gateway, log, (sessionToken, cwd) => {
36
+ const tools = dinoxMcpServerConfig({ sessionToken, cwd, knowledgeTools: gateway.dinoxTools !== false });
35
37
  return tools ? [tools] : [];
36
38
  });
37
39
  case 'echo':
@@ -77,8 +77,10 @@ export declare class GatewayDeviceHost {
77
77
  private publishRunEvent;
78
78
  private flushPendingEvents;
79
79
  private handleDiscover;
80
- /** Only the gateway's own queries are served; desktop queries (skills, workspace) are not advertised. */
80
+ /** Every reply-bearing query gets a result, including unsupported extensions. */
81
81
  private handleQuery;
82
+ private dispatchQuery;
83
+ private prepareFolder;
82
84
  private settingsResult;
83
85
  private changeSettings;
84
86
  private listFolders;
@@ -1,13 +1,14 @@
1
1
  import os from 'node:os';
2
+ import { listGatewayWorkspace } from './workspaceListing.js';
2
3
  import { credsAuthenticator, wsconnect, } from '@nats-io/nats-core';
3
4
  import { AI_SERVICE_URL } from '../config/serviceEndpoints.js';
4
5
  import { toAuthorizationHeaderValue } from '../utils/authToken.js';
5
6
  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';
6
7
  import { projectPendingApprovals } from './assistantProjection.js';
7
- import { isAgentConfigQuery, isAgentFoldersQuery, isAgentSettingsQuery, isCancelRequest, isRunStatusRequest, readAgentSettingsUpdate, reconnectDelayMs, validateApprovalRequest, validateRunRequest, } from './protocol.js';
8
+ import { isAgentConfigQuery, isAgentFolderPrepareQuery, isAgentFoldersQuery, isAgentSettingsQuery, isCancelRequest, isRunStatusRequest, readAgentSettingsUpdate, reconnectDelayMs, validateApprovalRequest, validateRunRequest, } from './protocol.js';
8
9
  import { GatewayRunExecutor, MAX_QUEUED_TURNS, QueueFullError } from './runExecutor.js';
9
10
  import { RunStreamPublisher } from './streamPublisher.js';
10
- import { findRun, readRecentSessionFolders, resolveTrustedResource, TERMINAL_RUN_STATUSES, threadExists, } from './threadStore.js';
11
+ import { findRun, readRecentSessionFolders, readSessionBinding, resolveTrustedResource, TERMINAL_RUN_STATUSES, threadExists, } from './threadStore.js';
11
12
  import { uploadToOfficialStorage } from './fileUpload.js';
12
13
  import { agentHomeFolder, ensureAgentHomeFolder, existingWorkspaceFolders, listWorkspaceFolders, resolveWorkspaceFolder, WorkspaceFolderError, } from './workspaceFolders.js';
13
14
  export const DEFAULT_NATS_CREDENTIALS_URL = `${AI_SERVICE_URL}/api/nats/credentials`;
@@ -240,6 +241,7 @@ export class GatewayDeviceHost {
240
241
  connectedAt: this.connectedAt ?? new Date().toISOString(),
241
242
  busyThreadIds: this.executor.busyThreadIds(),
242
243
  capabilities: {
244
+ workspace: { list: true, scope: 'thread', filePreview: false },
243
245
  threadRun: true,
244
246
  cancel: true,
245
247
  stream: 'assistant-snapshot',
@@ -250,6 +252,7 @@ export class GatewayDeviceHost {
250
252
  // Additive v2 extension: tells clients this device is an external agent
251
253
  // (the agent owns context; Dinox model/tool settings do not apply).
252
254
  info.capabilities.executor = {
255
+ agentWorkspace: true,
253
256
  kind: 'gateway',
254
257
  gatewayId: this.deps.gateway.id,
255
258
  runtime: this.deps.gateway.kind,
@@ -334,17 +337,55 @@ export class GatewayDeviceHost {
334
337
  return;
335
338
  msg.respond(encode({ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'device.discovered', requestId, device: this.deviceInfo() }));
336
339
  }
337
- /** Only the gateway's own queries are served; desktop queries (skills, workspace) are not advertised. */
340
+ /** Every reply-bearing query gets a result, including unsupported extensions. */
338
341
  async handleQuery(msg) {
339
342
  const { value, byteLength } = decode(msg);
340
343
  if (!msg.reply)
341
344
  return;
345
+ try {
346
+ await this.dispatchQuery(msg, value, byteLength);
347
+ }
348
+ catch (error) {
349
+ const requestId = value?.requestId;
350
+ if (typeof requestId !== 'string')
351
+ return;
352
+ msg.respond(encode({
353
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
354
+ type: 'query.error', requestId, ok: false,
355
+ error: { code: 'query_failed', message: error instanceof Error ? error.message : String(error) },
356
+ }));
357
+ }
358
+ }
359
+ async dispatchQuery(msg, value, byteLength) {
360
+ const raw = value;
361
+ if (raw?.type === 'query.cancel')
362
+ return;
363
+ if (raw?.type === 'agent.folder.prepare' && typeof raw.requestId === 'string') {
364
+ msg.respond(encode(await this.prepareFolder(value, raw.requestId, byteLength)));
365
+ return;
366
+ }
367
+ if (raw?.type === 'workspace.list' && typeof raw.requestId === 'string') {
368
+ msg.respond(encode(await listGatewayWorkspace(this.deps.db, this.deps.userId, this.deps.gateway, value)));
369
+ return;
370
+ }
371
+ if ((raw?.type === 'workspace.file.read' || raw?.type === 'skills.list') && typeof raw.requestId === 'string') {
372
+ const base = { protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, requestId: raw.requestId, threadId: String(raw.threadId ?? ''), ok: false, fetchedAt: new Date().toISOString(), error: { code: 'unsupported', message: 'This gateway does not support this query' } };
373
+ const result = raw.type === 'skills.list'
374
+ ? { ...base, type: 'skills.list.result', skills: [] }
375
+ : { ...base, type: 'workspace.file.read.result', path: String(raw.path ?? ''), preview: null, size: null };
376
+ msg.respond(encode(result));
377
+ return;
378
+ }
342
379
  if (isAgentConfigQuery(value)) {
343
380
  msg.respond(encode(await this.agentConfig(value)));
344
381
  return;
345
382
  }
346
383
  if (isAgentSettingsQuery(value)) {
347
- msg.respond(encode(this.settingsResult(value.requestId)));
384
+ const result = this.settingsResult(value.requestId);
385
+ if (value.threadId && await threadExists(this.deps.db, value.threadId, this.deps.userId)) {
386
+ result.threadCwd = (await readSessionBinding(this.deps.db, value.threadId, this.deps.gateway.id))?.cwd ?? null;
387
+ }
388
+ msg.respond(encode(result));
348
389
  return;
349
390
  }
350
391
  if (isAgentFoldersQuery(value)) {
@@ -357,7 +398,34 @@ export class GatewayDeviceHost {
357
398
  msg.respond(encode('error' in update
358
399
  ? this.settingsResult(requestId, { code: 'invalid_request', message: update.error })
359
400
  : await this.changeSettings(update.update)));
401
+ return;
402
+ }
403
+ if (typeof raw?.requestId === 'string')
404
+ msg.respond(encode({ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'query.error', requestId: raw.requestId, ok: false, error: { code: 'unsupported', message: 'Unsupported gateway query' } }));
405
+ }
406
+ async prepareFolder(value, requestId, byteLength) {
407
+ const result = {
408
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION,
409
+ type: 'agent.folder.result',
410
+ requestId,
411
+ ok: false,
412
+ path: null,
413
+ home: os.homedir(),
414
+ fetchedAt: new Date().toISOString(),
415
+ };
416
+ if (!isAgentFolderPrepareQuery(value, byteLength)) {
417
+ return { ...result, error: { code: 'invalid_request', message: 'Choose exactly one path or agent name' } };
418
+ }
419
+ try {
420
+ result.path = value.path !== undefined
421
+ ? await resolveWorkspaceFolder(value.path)
422
+ : await ensureAgentHomeFolder(value.agentName, undefined, value.agentId);
423
+ result.ok = true;
424
+ }
425
+ catch (error) {
426
+ result.error = settingsError(error);
360
427
  }
428
+ return result;
361
429
  }
362
430
  settingsResult(requestId, error) {
363
431
  const gateway = this.deps.gateway;
@@ -366,6 +434,7 @@ export class GatewayDeviceHost {
366
434
  type: 'agent.settings.result',
367
435
  requestId,
368
436
  ok: !error,
437
+ threadCwd: null,
369
438
  settings: {
370
439
  cwd: gateway.cwd ?? null,
371
440
  approval: gateway.approval ?? 'ask',
@@ -7,3 +7,5 @@ export declare const GATEWAY_KNOWLEDGE_TOOLS_ENV = "DINOX_GATEWAY_KNOWLEDGE_TOOL
7
7
  export declare const DINOX_MCP_SERVER_NAME = "dinox-gateway";
8
8
  /** `dino mcp`, the server users register with their own MCP clients. */
9
9
  export declare const DINOX_STANDALONE_MCP_SERVER_NAME = "dinox";
10
+ /** Session folder for resolving relative send_file paths. */
11
+ export declare const GATEWAY_SESSION_CWD_ENV = "DINOX_GATEWAY_SESSION_CWD";
@@ -7,3 +7,5 @@ export const GATEWAY_KNOWLEDGE_TOOLS_ENV = 'DINOX_GATEWAY_KNOWLEDGE_TOOLS';
7
7
  export const DINOX_MCP_SERVER_NAME = 'dinox-gateway';
8
8
  /** `dino mcp`, the server users register with their own MCP clients. */
9
9
  export const DINOX_STANDALONE_MCP_SERVER_NAME = 'dinox';
10
+ /** Session folder for resolving relative send_file paths. */
11
+ export const GATEWAY_SESSION_CWD_ENV = 'DINOX_GATEWAY_SESSION_CWD';
@@ -4,7 +4,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
5
  import { z } from 'zod';
6
6
  import { sendDaemonRequest } from '../daemon/client.js';
7
- import { DINOX_MCP_SERVER_NAME, DINOX_STANDALONE_MCP_SERVER_NAME, GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from './mcpEnv.js';
7
+ import { DINOX_MCP_SERVER_NAME, DINOX_STANDALONE_MCP_SERVER_NAME, GATEWAY_SESSION_CWD_ENV, GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from './mcpEnv.js';
8
8
  import { READ_CONVERSATION_MAX_LIMIT } from './conversationContext.js';
9
9
  import { getPackageVersion } from '../utils/version.js';
10
10
  /**
@@ -204,6 +204,7 @@ async function serveOverStdio(server) {
204
204
  }
205
205
  export async function serveDinoxMcpOverStdio() {
206
206
  await serveOverStdio(createDinoxMcpServer(undefined, {
207
+ cwd: process.env[GATEWAY_SESSION_CWD_ENV] || process.cwd(),
207
208
  knowledgeTools: process.env[GATEWAY_KNOWLEDGE_TOOLS_ENV] !== '0',
208
209
  sessionToken: process.env[GATEWAY_SESSION_TOKEN_ENV]?.trim() || undefined,
209
210
  }));
@@ -20,8 +20,11 @@ export type AgentConfigResult = {
20
20
  };
21
21
  };
22
22
  export declare function isAgentConfigQuery(value: unknown): value is AgentConfigQuery;
23
+ export declare const MAX_PATH_LENGTH = 4096;
24
+ /** Host-side workspace choice, never forwarded as an ACP session option. */
25
+ export declare const AGENT_CWD_CONFIG_KEY = "dinox.cwd";
23
26
  export type AgentHostSettings = {
24
- /** Folder new conversations start in; null until chosen (the home folder is used). */
27
+ /** Fallback when neither the conversation nor its agent has chosen a folder. */
25
28
  cwd: string | null;
26
29
  approval: GatewayApprovalMode;
27
30
  /** Online but refusing new turns. */
@@ -32,7 +35,7 @@ export type AgentHostSettings = {
32
35
  export type AgentSettingsChange = {
33
36
  /** An existing folder (absolute or `~/...`), checked on this computer. */
34
37
  cwd?: string;
35
- /** Creates and uses the agent's own folder (`~/Dinox Agents/<name>`). */
38
+ /** Creates and uses the agent's own folder (`~/dinox-agents/<name>`). */
36
39
  useAgentFolder?: true;
37
40
  approval?: GatewayApprovalMode;
38
41
  paused?: boolean;
@@ -41,6 +44,7 @@ export type AgentSettingsChange = {
41
44
  export type AgentSettingsQuery = {
42
45
  protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
43
46
  type: 'agent.settings.get';
47
+ threadId?: string;
44
48
  requestId: string;
45
49
  clientDeviceId: string;
46
50
  };
@@ -58,6 +62,8 @@ export type AgentSettingsResult = {
58
62
  requestId: string;
59
63
  ok: boolean;
60
64
  settings: AgentHostSettings;
65
+ /** The conversation's folder, even after its topic has changed. */
66
+ threadCwd: string | null;
61
67
  /** For showing paths as `~/...`. */
62
68
  home: string;
63
69
  /** Where `useAgentFolder` puts the agent's own folder. */
@@ -99,6 +105,29 @@ export type AgentFoldersResult = {
99
105
  message: string;
100
106
  };
101
107
  };
108
+ export type AgentFolderPrepareQuery = {
109
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
110
+ type: 'agent.folder.prepare';
111
+ requestId: string;
112
+ clientDeviceId: string;
113
+ path?: string;
114
+ agentName?: string;
115
+ agentId?: string;
116
+ };
117
+ export type AgentFolderPrepareResult = {
118
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
119
+ type: 'agent.folder.result';
120
+ requestId: string;
121
+ ok: boolean;
122
+ path: string | null;
123
+ home: string;
124
+ fetchedAt: string;
125
+ error?: {
126
+ code: AgentSettingsErrorCode;
127
+ message: string;
128
+ };
129
+ };
130
+ export declare function isAgentFolderPrepareQuery(value: unknown, byteLength: number): value is AgentFolderPrepareQuery;
102
131
  export declare function isAgentSettingsQuery(value: unknown): value is AgentSettingsQuery;
103
132
  export declare function isAgentFoldersQuery(value: unknown): value is AgentFoldersQuery;
104
133
  /** The change an `agent.settings.set` asks for, or why it is malformed. */
@@ -76,9 +76,21 @@ export function isAgentConfigQuery(value) {
76
76
  * `agent.settings.set`, and picks a working folder with `agent.folders.list`.
77
77
  */
78
78
  const MAX_SETTINGS_REQUEST_BYTES = 16 * 1024;
79
- const MAX_PATH_LENGTH = 4096;
79
+ export const MAX_PATH_LENGTH = 4096;
80
+ /** Host-side workspace choice, never forwarded as an ACP session option. */
81
+ export const AGENT_CWD_CONFIG_KEY = 'dinox.cwd';
82
+ export function isAgentFolderPrepareQuery(value, byteLength) {
83
+ if (!isMessageOfType(value, 'agent.folder.prepare') || !isDeviceId(value.clientDeviceId) || byteLength > MAX_SETTINGS_REQUEST_BYTES)
84
+ return false;
85
+ if ((value.path === undefined) === (value.agentName === undefined))
86
+ return false;
87
+ if (value.agentId !== undefined && (value.agentName === undefined || typeof value.agentId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(value.agentId)))
88
+ return false;
89
+ const text = value.path ?? value.agentName;
90
+ return typeof text === 'string' && !!text.trim() && text.length <= (value.path === undefined ? MAX_AGENT_CONFIG_TEXT : MAX_PATH_LENGTH);
91
+ }
80
92
  export function isAgentSettingsQuery(value) {
81
- return isMessageOfType(value, 'agent.settings.get') && isDeviceId(value.clientDeviceId);
93
+ return isMessageOfType(value, 'agent.settings.get') && isDeviceId(value.clientDeviceId) && (value.threadId === undefined || isSubjectId(value.threadId));
82
94
  }
83
95
  export function isAgentFoldersQuery(value) {
84
96
  if (!isMessageOfType(value, 'agent.folders.list') || !isDeviceId(value.clientDeviceId))
@@ -139,7 +151,7 @@ export function readAgentConfig(request) {
139
151
  for (const [id, value] of entries) {
140
152
  if (!id || id.length > MAX_AGENT_CONFIG_TEXT)
141
153
  return 'invalid';
142
- if (typeof value !== 'string' || !value || value.length > MAX_AGENT_CONFIG_TEXT)
154
+ if (typeof value !== 'string' || !value || value.length > (id === AGENT_CWD_CONFIG_KEY ? MAX_PATH_LENGTH : MAX_AGENT_CONFIG_TEXT))
143
155
  return 'invalid';
144
156
  config[id] = value;
145
157
  }
@@ -1,8 +1,8 @@
1
- import os from 'node:os';
2
1
  import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
3
2
  import { CONTEXT_MESSAGE_LIMIT, withConversationContext } from './conversationContext.js';
4
3
  import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, TERMINAL_RUN_STATUSES, withdrawQueuedRun, readAgentInstructions, readCurrentTopic, readSessionBinding, readTranscriptPage, resolveTrustedResource, updateRunSnapshot, waitForMessage, withGatewaySession, writeSessionBinding, } from './threadStore.js';
5
- import { readAgentConfig } from './protocol.js';
4
+ import { AGENT_CWD_CONFIG_KEY, readAgentConfig } from './protocol.js';
5
+ import { resolveTurnWorkspace, queuedTurnPrompt } from './workspace.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`. */
8
8
  export const MAX_QUEUED_TURNS = 10;
@@ -313,24 +313,35 @@ export class GatewayRunExecutor {
313
313
  readSessionBinding(db, active.threadId, gateway.id),
314
314
  ]);
315
315
  const topicId = topic?.id ?? null;
316
- const continued = binding?.topicId === topicId ? binding : null;
316
+ let continued = binding?.topicId === topicId ? binding : null;
317
317
  const turns = [...merged, request];
318
- const [resources, context, instructions] = await Promise.all([
318
+ const tailSynced = this.clientTailSynced(turns[0], active.controller.signal);
319
+ const [resources, initialContext, instructions] = await Promise.all([
319
320
  Promise.all(turns.map((turn) => this.resolveResources(turn.userMessage))).then((lists) => lists.flat()),
320
- this.clientTailSynced(turns[0], active.controller.signal).then((synced) => this.unseenContext(active.threadId, turns[0].userMessage.id, topic?.createdAt ?? null, continued, synced)),
321
+ tailSynced.then((synced) => this.unseenContext(active.threadId, turns[0].userMessage.id, topic?.createdAt ?? null, continued, synced)),
321
322
  readAgentInstructions(db, active.threadId).catch(() => ''),
322
323
  ]);
323
- // A session keeps the directory it started in; changing the default only affects new ones.
324
- const cwd = continued ? (continued.cwd ?? gateway.previousCwd ?? gateway.cwd ?? os.homedir()) : (gateway.cwd ?? os.homedir());
325
- // Validated on receipt; a malformed value never reaches here.
324
+ let context = initialContext;
326
325
  const requestedConfig = readAgentConfig(request);
327
- const agentConfig = requestedConfig && requestedConfig !== 'invalid' ? requestedConfig : undefined;
328
- const prompt = turns.map((turn) => turn.prompt.trim()).filter(Boolean).join('\n\n');
326
+ const agentConfig = requestedConfig && requestedConfig !== 'invalid' ? { ...requestedConfig } : undefined;
327
+ const cwd = await resolveTurnWorkspace(gateway, binding, !!continued, agentConfig);
328
+ if (agentConfig)
329
+ delete agentConfig[AGENT_CWD_CONFIG_KEY];
330
+ const prompt = queuedTurnPrompt(turns.map((turn) => turn.prompt));
329
331
  const result = await adapter.runTurn({
330
332
  sessionId: continued?.sessionId ?? null,
331
333
  cwd,
332
- sessionPreamble: sessionPreamble(instructions, { delivery: !!this.deps.uploadFile, knowledge: gateway.dinoxTools !== false }),
333
- prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
334
+ prompt: async ({ created }) => {
335
+ if (created && continued) {
336
+ continued = null;
337
+ // A failed load has no retained history, including this gateway's own replies.
338
+ const synced = await tailSynced;
339
+ context = await this.unseenContext(active.threadId, turns[0].userMessage.id, topic?.createdAt ?? null, null, synced);
340
+ }
341
+ const text = withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false });
342
+ const preamble = created && sessionPreamble(instructions, { delivery: !!this.deps.uploadFile, knowledge: gateway.dinoxTools !== false });
343
+ return preamble ? `${preamble}\n\n${text}` : text;
344
+ },
334
345
  resources,
335
346
  signal: active.controller.signal,
336
347
  config: agentConfig,
@@ -15,11 +15,13 @@ export type GatewayServiceDeps = {
15
15
  */
16
16
  export declare class GatewayService {
17
17
  private readonly deps;
18
+ private readonly definitions;
18
19
  private hosts;
19
20
  private readonly log;
20
21
  private readonly keepAwake;
21
22
  constructor(deps: GatewayServiceDeps);
22
23
  start(): Promise<void>;
24
+ private reconcile;
23
25
  stop(): Promise<void>;
24
26
  /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
25
27
  reload(): Promise<DeviceHostStatus[]>;
@@ -23,6 +23,7 @@ const defaultLog = (message, details) => writeDaemonLogLine('dinox-gateway', mes
23
23
  */
24
24
  export class GatewayService {
25
25
  deps;
26
+ definitions = new Map();
26
27
  hosts = [];
27
28
  log;
28
29
  keepAwake;
@@ -32,7 +33,9 @@ export class GatewayService {
32
33
  this.keepAwake = new KeepAwake(this.log);
33
34
  }
34
35
  async start() {
35
- const configured = await loadGateways(this.deps.userId);
36
+ await this.reconcile(await loadGateways(this.deps.userId), true);
37
+ }
38
+ async reconcile(configured, recoverOrphans) {
36
39
  const machineId = await getMachineId();
37
40
  // Reconcile before anything else, also when the last gateway was removed.
38
41
  const retired = await retireRemovedRobots(this.deps.db, {
@@ -48,10 +51,17 @@ export class GatewayService {
48
51
  const gateways = configured.filter((gateway) => gateway.enabled);
49
52
  if (gateways.length === 0)
50
53
  return;
51
- const interrupted = await interruptOrphanedRuns(this.deps.db, this.deps.userId);
54
+ const interrupted = recoverOrphans && this.hosts.length === 0 ? await interruptOrphanedRuns(this.deps.db, this.deps.userId) : 0;
52
55
  if (interrupted > 0)
53
56
  this.log('marked runs from a previous host as interrupted', { count: interrupted });
54
57
  for (const gateway of gateways) {
58
+ const existing = this.hosts.find((host) => this.definitions.get(host)?.id === gateway.id);
59
+ if (existing) {
60
+ const definition = this.definitions.get(existing);
61
+ applyLiveSettings(definition, gateway);
62
+ await upsertRobot(this.deps.db, this.robotRegistration(definition, machineId)).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
63
+ continue;
64
+ }
55
65
  await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
56
66
  const host = new GatewayDeviceHost({
57
67
  gateway,
@@ -67,18 +77,33 @@ export class GatewayService {
67
77
  });
68
78
  host.start();
69
79
  this.hosts.push(host);
80
+ this.definitions.set(host, gateway);
70
81
  }
71
82
  }
72
83
  async stop() {
73
84
  const hosts = this.hosts;
74
85
  this.hosts = [];
86
+ this.definitions.clear();
75
87
  await Promise.allSettled(hosts.map((host) => host.stop()));
76
88
  this.keepAwake.dispose();
77
89
  }
78
90
  /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
79
91
  async reload() {
80
- await this.stop();
81
- await this.start();
92
+ const configured = await loadGateways(this.deps.userId);
93
+ const identity = (gateway) => JSON.stringify([
94
+ gateway.id, gateway.deviceId, gateway.name, gateway.kind, gateway.command, gateway.args ?? [],
95
+ Object.entries(gateway.env ?? {}).sort(([a], [b]) => a.localeCompare(b)), gateway.enabled,
96
+ ]);
97
+ for (const host of [...this.hosts]) {
98
+ const previous = this.definitions.get(host);
99
+ const next = configured.find((gateway) => gateway.id === previous.id);
100
+ if (next && identity(previous) === identity(next))
101
+ continue;
102
+ await host.stop();
103
+ this.hosts = this.hosts.filter((candidate) => candidate !== host);
104
+ this.definitions.delete(host);
105
+ }
106
+ await this.reconcile(configured, false);
82
107
  return this.status();
83
108
  }
84
109
  /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
@@ -219,4 +219,6 @@ export declare function retireRemovedRobots(db: Tx, input: {
219
219
  keepIds: string[];
220
220
  }): Promise<string[]>;
221
221
  export declare function softDeleteRobot(db: Tx, robotId: string): Promise<void>;
222
+ /** Only the settings for this robot may choose its agent's workspace. */
223
+ export declare function readAgentWorkspace(db: Tx, threadId: string, gatewayId: string): Promise<string | null>;
222
224
  export {};
@@ -595,3 +595,16 @@ function robotMachineId(metaData) {
595
595
  export async function softDeleteRobot(db, robotId) {
596
596
  await db.execute('UPDATE c_robot SET is_del = 1, updated_at = ? WHERE id = ?', [new Date().toISOString(), robotId]);
597
597
  }
598
+ /** Only the settings for this robot may choose its agent's workspace. */
599
+ export async function readAgentWorkspace(db, threadId, gatewayId) {
600
+ const row = await db.getOptional(`SELECT p.meta_data FROM c_thread t JOIN c_project p ON p.id = t.project_id
601
+ WHERE t.id = ? AND (p.is_del IS NULL OR p.is_del = 0) LIMIT 1`, [threadId]);
602
+ try {
603
+ const settings = JSON.parse(row?.meta_data ?? '{}').runSettings;
604
+ const value = settings?.robotId === gatewayId ? settings.values?.['dinox.cwd'] : null;
605
+ return typeof value === 'string' && value.length > 0 && value.length <= 4096 ? value : null;
606
+ }
607
+ catch {
608
+ return null;
609
+ }
610
+ }
@@ -26,7 +26,7 @@ export type GatewayDefinition = {
26
26
  /** ACP: executable started over stdio, e.g. `hermes` with args `["acp"]`. */
27
27
  command?: string;
28
28
  args?: string[];
29
- /** Working directory for new agent sessions (each session keeps the one it started in). */
29
+ /** Last configured fallback after the conversation's and agent's working folders. */
30
30
  cwd?: string;
31
31
  /**
32
32
  * Directory of sessions bound before sessions recorded their own: the
@@ -118,9 +118,10 @@ export type GatewayTurnInput = {
118
118
  sessionId: string | null;
119
119
  /** Working directory of the session (a new one starts here; a continued one is loaded with it). */
120
120
  cwd: string;
121
- prompt: string;
122
- /** Prepended to the prompt when the turn starts a new session, e.g. the agent's instructions. */
123
- sessionPreamble?: string;
121
+ /** Build context only after the adapter knows whether history survived. */
122
+ prompt: (session: {
123
+ created: boolean;
124
+ }) => Promise<string>;
124
125
  /** Attachments the user sent with this turn (trusted, https). */
125
126
  resources: Array<{
126
127
  uri: string;
@@ -0,0 +1,5 @@
1
+ import type { GatewayDefinition } from './types.js';
2
+ import type { GatewaySessionBinding } from './threadStore.js';
3
+ /** A topic reset changes context, while the conversation keeps its folder. */
4
+ export declare function resolveTurnWorkspace(gateway: GatewayDefinition, binding: GatewaySessionBinding | null, continued: boolean, config?: Record<string, string>): Promise<string>;
5
+ export declare function queuedTurnPrompt(prompts: string[]): string;
@@ -0,0 +1,30 @@
1
+ import os from 'node:os';
2
+ import { AGENT_CWD_CONFIG_KEY } from './protocol.js';
3
+ import { resolveWorkspaceFolder } from './workspaceFolders.js';
4
+ /** A topic reset changes context, while the conversation keeps its folder. */
5
+ export async function resolveTurnWorkspace(gateway, binding, continued, config) {
6
+ if (continued)
7
+ return binding?.cwd ?? gateway.previousCwd ?? gateway.cwd ?? os.homedir();
8
+ if (binding?.cwd)
9
+ return binding.cwd;
10
+ const requested = config?.[AGENT_CWD_CONFIG_KEY];
11
+ if (requested) {
12
+ try {
13
+ return await resolveWorkspaceFolder(requested);
14
+ }
15
+ catch (error) {
16
+ throw new Error(`Working folder is unavailable: ${requested} (${error instanceof Error ? error.message : String(error)})`);
17
+ }
18
+ }
19
+ return gateway.cwd ?? os.homedir();
20
+ }
21
+ export function queuedTurnPrompt(prompts) {
22
+ if (prompts.length === 1)
23
+ return prompts[0].trim();
24
+ return [
25
+ '<dinox_queued_messages>',
26
+ 'The user sent these messages one after another while you were busy. Answer them together.',
27
+ ...prompts.map((text, index) => `[Message ${index + 1}] ${text.trim().replaceAll('</dinox_queued_messages>', '<\\/dinox_queued_messages>')}`),
28
+ '</dinox_queued_messages>',
29
+ ].join('\n');
30
+ }
@@ -18,10 +18,12 @@ export type WorkspaceFolder = {
18
18
  };
19
19
  /** The folder an agent may work in, resolved to its real path. */
20
20
  export declare function resolveWorkspaceFolder(input: string, home?: string): Promise<string>;
21
- /** `~/Dinox Agents/<agent name>`: a folder of the agent's own, offered as the default choice. */
22
- export declare function agentHomeFolder(agentName: string, home?: string): string;
21
+ /** A portable folder name, optionally distinguished by the agent ID's last six characters. */
22
+ export declare function agentFolderName(name: string, agentId?: string | null): string;
23
+ /** `~/dinox-agents/<name>`: a folder of the agent's own, offered as the default choice. */
24
+ export declare function agentHomeFolder(agentName: string, home?: string, agentId?: string | null): string;
23
25
  /** Creates the agent's own folder when missing; the only folder the app can create. */
24
- export declare function ensureAgentHomeFolder(agentName: string, home?: string): Promise<string>;
26
+ export declare function ensureAgentHomeFolder(agentName: string, home?: string, agentId?: string | null): Promise<string>;
25
27
  /** The visible subfolders of a folder (the home folder included), for browsing. */
26
28
  export declare function listWorkspaceFolders(input: string | null | undefined, home?: string): Promise<{
27
29
  path: string;
@@ -13,7 +13,7 @@ const MAX_FOLDERS = 200;
13
13
  /** Home-level folders that hold system data rather than the user's work. */
14
14
  const SYSTEM_FOLDERS = new Set(['Library', 'Applications']);
15
15
  const SKIPPED_FOLDERS = new Set(['node_modules']);
16
- const AGENT_HOME_PARENT = 'Dinox Agents';
16
+ const AGENT_HOME_PARENT = 'dinox-agents';
17
17
  async function realHome(home) {
18
18
  return fs.realpath(home).catch(() => path.resolve(home));
19
19
  }
@@ -73,14 +73,27 @@ export async function resolveWorkspaceFolder(input, home = os.homedir()) {
73
73
  }
74
74
  return real;
75
75
  }
76
- /** `~/Dinox Agents/<agent name>`: a folder of the agent's own, offered as the default choice. */
77
- export function agentHomeFolder(agentName, home = os.homedir()) {
78
- const safeName = agentName.replace(/[\u0000-\u001f\u007f/\\:*?"<>|]/g, '').trim() || 'Agent';
79
- return path.join(home, AGENT_HOME_PARENT, safeName.slice(0, 64));
76
+ /** A portable folder name, optionally distinguished by the agent ID's last six characters. */
77
+ export function agentFolderName(name, agentId) {
78
+ let base = name.normalize('NFC')
79
+ .replace(/[\u0000-\u001f\u007f/\\:*?"<>|]/g, '')
80
+ .replace(/\s+/g, '-')
81
+ .toLowerCase()
82
+ .replace(/-+/g, '-')
83
+ .replace(/^[-.]+|[-.]+$/g, '');
84
+ base = Array.from(base).slice(0, 48).join('').replace(/[-.]+$/g, '') || 'agent';
85
+ if (/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/.test(base))
86
+ base += '-agent';
87
+ const suffix = agentId?.replace(/[^a-z0-9]/gi, '').toLowerCase().slice(-6);
88
+ return suffix ? `${base}-${suffix}` : base;
89
+ }
90
+ /** `~/dinox-agents/<name>`: a folder of the agent's own, offered as the default choice. */
91
+ export function agentHomeFolder(agentName, home = os.homedir(), agentId) {
92
+ return path.join(home, AGENT_HOME_PARENT, agentFolderName(agentName, agentId));
80
93
  }
81
94
  /** Creates the agent's own folder when missing; the only folder the app can create. */
82
- export async function ensureAgentHomeFolder(agentName, home = os.homedir()) {
83
- const folder = agentHomeFolder(agentName, home);
95
+ export async function ensureAgentHomeFolder(agentName, home = os.homedir(), agentId) {
96
+ const folder = agentHomeFolder(agentName, home, agentId);
84
97
  await fs.mkdir(folder, { recursive: true, mode: 0o755 });
85
98
  return resolveWorkspaceFolder(folder, home);
86
99
  }
@@ -0,0 +1,5 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import { type RemoteDeviceWorkspaceListRequest, type RemoteDeviceWorkspaceListResponse } from '../vendor/electron/shared/remote-device-agent.js';
3
+ import type { GatewayDefinition } from './types.js';
4
+ /** Wire paths stay relative even when a directory on disk is a symlink. */
5
+ export declare function listGatewayWorkspace(db: AbstractPowerSyncDatabase, userId: string, gateway: GatewayDefinition, request: RemoteDeviceWorkspaceListRequest): Promise<RemoteDeviceWorkspaceListResponse>;
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { REMOTE_DEVICE_PROTOCOL_VERSION, } from '../vendor/electron/shared/remote-device-agent.js';
4
+ import { readAgentWorkspace, readSessionBinding, threadExists } from './threadStore.js';
5
+ import { resolveWorkspaceFolder } from './workspaceFolders.js';
6
+ const SUBJECT_ID = /^[a-z0-9][a-z0-9_-]{0,127}$/;
7
+ const DEVICE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
8
+ const IGNORED_NAMES = new Set(['node_modules', 'build', 'coverage', 'dist']);
9
+ function withinRoot(root, target) {
10
+ const relative = path.relative(root, target);
11
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
12
+ }
13
+ /** Wire paths stay relative even when a directory on disk is a symlink. */
14
+ export async function listGatewayWorkspace(db, userId, gateway, request) {
15
+ const base = {
16
+ protocolVersion: REMOTE_DEVICE_PROTOCOL_VERSION, type: 'workspace.list.result', requestId: request.requestId,
17
+ threadId: request.threadId, parentPath: '', ok: false, entries: [], nextCursor: null, truncated: false, fetchedAt: new Date().toISOString(),
18
+ };
19
+ const fail = (code, message) => ({ ...base, error: { code, message } });
20
+ const parent = request.parentPath ?? '';
21
+ if (request.protocolVersion !== REMOTE_DEVICE_PROTOCOL_VERSION ||
22
+ typeof request.requestId !== 'string' || !SUBJECT_ID.test(request.requestId) ||
23
+ typeof request.threadId !== 'string' || !SUBJECT_ID.test(request.threadId) ||
24
+ typeof request.clientDeviceId !== 'string' || !DEVICE_ID.test(request.clientDeviceId))
25
+ return fail('invalid_request', 'Invalid query identity');
26
+ if (typeof parent !== 'string' || parent.length > 2048 || parent.includes('\\') ||
27
+ path.win32.isAbsolute(parent) || parent.startsWith('/') ||
28
+ (parent && parent.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git' || part === 'node_modules')))
29
+ return fail('invalid_request', 'Invalid workspace path');
30
+ base.parentPath = parent;
31
+ const cursor = request.cursor;
32
+ const offset = cursor === undefined || cursor === null || cursor === '' ? 0 : typeof cursor === 'string' && /^\d{1,8}$/.test(cursor) ? Number(cursor) : null;
33
+ if (offset === null)
34
+ return fail('invalid_request', 'Invalid workspace cursor');
35
+ try {
36
+ if (!(await threadExists(db, request.threadId, userId)))
37
+ return fail('thread_not_found', 'Conversation not found');
38
+ const binding = await readSessionBinding(db, request.threadId, gateway.id);
39
+ const agentFolder = binding?.cwd ? null : await readAgentWorkspace(db, request.threadId, gateway.id);
40
+ const root = binding?.cwd ?? (agentFolder ? await resolveWorkspaceFolder(agentFolder) : gateway.cwd);
41
+ if (!root)
42
+ return { ...base, ok: true };
43
+ const rootReal = await fs.realpath(root);
44
+ const target = await fs.realpath(path.resolve(rootReal, ...parent.split('/')));
45
+ if (path.relative(rootReal, target).split(path.sep).some((part) => part === '.git' || part === 'node_modules'))
46
+ return fail('invalid_request', 'Directory contents are unavailable');
47
+ if (!withinRoot(rootReal, target))
48
+ return fail('invalid_request', 'Workspace path escapes its root');
49
+ if (!(await fs.stat(target)).isDirectory())
50
+ return fail('invalid_request', 'Workspace path is not a directory');
51
+ const rank = (entry) => entry.isDirectory() ? 0 : entry.isSymbolicLink() ? 2 : 1;
52
+ const all = (await fs.readdir(target, { withFileTypes: true }))
53
+ .filter((entry) => !!entry.name && !entry.name.startsWith('.') && !entry.name.includes('\\') && !IGNORED_NAMES.has(entry.name))
54
+ .sort((a, b) => rank(a) - rank(b) || a.name.localeCompare(b.name, undefined, { numeric: true }));
55
+ const bounded = all.slice(0, 5000);
56
+ if (offset > bounded.length)
57
+ return fail('invalid_request', 'Invalid workspace cursor');
58
+ const limit = typeof request.limit === 'number' && Number.isFinite(request.limit) ? Math.min(200, Math.max(1, Math.trunc(request.limit))) : 100;
59
+ const page = bounded.slice(offset, offset + limit);
60
+ const entries = await Promise.all(page.map(async (entry) => {
61
+ const kind = entry.isSymbolicLink() ? 'symlink' : entry.isDirectory() ? 'directory' : 'file';
62
+ const stat = await fs.lstat(path.join(target, entry.name)).catch(() => null);
63
+ return {
64
+ path: parent ? `${parent}/${entry.name}` : entry.name,
65
+ name: entry.name,
66
+ kind,
67
+ hasChildren: kind === 'directory',
68
+ ...(stat?.isFile() ? { size: stat.size } : {}),
69
+ ...(stat ? { modifiedAt: stat.mtime.toISOString() } : {}),
70
+ };
71
+ }));
72
+ const next = offset + page.length;
73
+ return { ...base, ok: true, entries, nextCursor: next < bounded.length ? String(next) : null, truncated: all.length > bounded.length };
74
+ }
75
+ catch (error) {
76
+ return fail('query_failed', error instanceof Error ? error.message : String(error));
77
+ }
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "2.15.0",
3
+ "version": "2.16.0",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {