@dinoxx/dinox-cli 2.14.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.
Files changed (97) hide show
  1. package/README.md +2 -0
  2. package/dist/ai/ragxClient.d.ts +15 -0
  3. package/dist/ai/ragxClient.js +71 -0
  4. package/dist/cli.js +27 -3
  5. package/dist/commands/batch.d.ts +35 -0
  6. package/dist/commands/batch.js +202 -0
  7. package/dist/commands/boxes/index.js +21 -21
  8. package/dist/commands/mcp.d.ts +6 -0
  9. package/dist/commands/mcp.js +47 -0
  10. package/dist/commands/notes/actions.js +18 -2
  11. package/dist/commands/notes/descriptors.js +111 -5
  12. package/dist/commands/notes/detailContent.d.ts +19 -0
  13. package/dist/commands/notes/detailContent.js +62 -0
  14. package/dist/commands/notes/export.js +4 -0
  15. package/dist/commands/notes/repo/search.d.ts +2 -1
  16. package/dist/commands/notes/repo/search.js +1 -1
  17. package/dist/commands/notes/repo/semantic.d.ts +14 -0
  18. package/dist/commands/notes/repo/semantic.js +31 -0
  19. package/dist/commands/notes/searchResponse.d.ts +3 -0
  20. package/dist/commands/notes/searchResponse.js +1 -0
  21. package/dist/commands/notes/semantic.d.ts +116 -0
  22. package/dist/commands/notes/semantic.js +332 -0
  23. package/dist/commands/prompt/index.js +7 -5
  24. package/dist/commands/savedViews/index.js +12 -3
  25. package/dist/commands/schema/globalOptions.d.ts +6 -0
  26. package/dist/commands/schema/globalOptions.js +6 -0
  27. package/dist/commands/schema/renderSkills.js +5 -2
  28. package/dist/commands/schema/spec.js +18 -7
  29. package/dist/commands/storage/index.js +7 -5
  30. package/dist/commands/sync.js +3 -3
  31. package/dist/commands/tags/index.js +21 -21
  32. package/dist/commands/todo/index.js +22 -2
  33. package/dist/commands/todo/repo.d.ts +5 -0
  34. package/dist/commands/todo/repo.js +7 -2
  35. package/dist/commands/writeReceipt.d.ts +13 -0
  36. package/dist/commands/writeReceipt.js +12 -0
  37. package/dist/config/serviceEndpoints.d.ts +2 -0
  38. package/dist/config/serviceEndpoints.js +2 -0
  39. package/dist/daemon/client.d.ts +2 -0
  40. package/dist/daemon/client.js +22 -2
  41. package/dist/daemon/forward.d.ts +4 -1
  42. package/dist/daemon/forward.js +13 -4
  43. package/dist/daemon/server.d.ts +1 -1
  44. package/dist/daemon/server.js +17 -1
  45. package/dist/daemon/types.d.ts +6 -0
  46. package/dist/dinox.js +12 -10
  47. package/dist/gateway/adapters/acp.d.ts +5 -1
  48. package/dist/gateway/adapters/acp.js +21 -9
  49. package/dist/gateway/adapters/echo.js +7 -4
  50. package/dist/gateway/adapters/index.d.ts +1 -0
  51. package/dist/gateway/adapters/index.js +5 -3
  52. package/dist/gateway/deviceHost.d.ts +3 -1
  53. package/dist/gateway/deviceHost.js +75 -5
  54. package/dist/gateway/mcpCommandTools.d.ts +15 -5
  55. package/dist/gateway/mcpCommandTools.js +30 -19
  56. package/dist/gateway/mcpEnv.d.ts +4 -0
  57. package/dist/gateway/mcpEnv.js +4 -0
  58. package/dist/gateway/mcpGroups.d.ts +3 -0
  59. package/dist/gateway/mcpGroups.js +4 -0
  60. package/dist/gateway/mcpServer.d.ts +10 -1
  61. package/dist/gateway/mcpServer.js +26 -5
  62. package/dist/gateway/protocol.d.ts +31 -2
  63. package/dist/gateway/protocol.js +15 -3
  64. package/dist/gateway/runExecutor.js +23 -12
  65. package/dist/gateway/service.d.ts +2 -0
  66. package/dist/gateway/service.js +29 -4
  67. package/dist/gateway/threadStore.d.ts +2 -0
  68. package/dist/gateway/threadStore.js +13 -0
  69. package/dist/gateway/types.d.ts +5 -4
  70. package/dist/gateway/workspace.d.ts +5 -0
  71. package/dist/gateway/workspace.js +30 -0
  72. package/dist/gateway/workspaceFolders.d.ts +5 -3
  73. package/dist/gateway/workspaceFolders.js +20 -7
  74. package/dist/gateway/workspaceListing.d.ts +5 -0
  75. package/dist/gateway/workspaceListing.js +78 -0
  76. package/dist/powersync/syncPass.js +13 -1
  77. package/dist/powersync/tokenIndex.d.ts +2 -0
  78. package/dist/powersync/tokenIndex.js +1 -0
  79. package/dist/requests/execute.d.ts +11 -0
  80. package/dist/requests/execute.js +26 -0
  81. package/dist/skills/commandRefs.js +2 -10
  82. package/dist/utils/cursor.d.ts +9 -0
  83. package/dist/utils/cursor.js +61 -0
  84. package/dist/utils/defaultFormat.d.ts +8 -0
  85. package/dist/utils/defaultFormat.js +38 -0
  86. package/dist/utils/errorCodes.d.ts +4 -0
  87. package/dist/utils/errorCodes.js +1 -0
  88. package/dist/utils/output.d.ts +2 -0
  89. package/dist/utils/output.js +41 -3
  90. package/dist/utils/progress.d.ts +25 -0
  91. package/dist/utils/progress.js +59 -0
  92. package/dist/utils/requestContext.d.ts +8 -0
  93. package/dist/utils/select.d.ts +20 -0
  94. package/dist/utils/select.js +92 -0
  95. package/dist/utils/usageError.d.ts +37 -0
  96. package/dist/utils/usageError.js +127 -0
  97. package/package.json +1 -1
@@ -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
+ }
@@ -1,14 +1,26 @@
1
1
  import { createTimeoutSignal } from '../utils/abort.js';
2
+ import { reportProgress } from '../utils/progress.js';
2
3
  import { getStatusSnapshot, waitForDownloadIdle } from './runtime.js';
3
4
  import { syncNoteTokenIndex } from './tokenIndex.js';
4
5
  export async function runSingleSyncPass(db, timeoutMs) {
5
6
  const deadline = Date.now() + Math.max(1, timeoutMs);
6
7
  const remainingMs = () => Math.max(0, deadline - Date.now());
7
- const downloadIdle = await waitForDownloadIdle(db, Math.max(1, Math.min(remainingMs(), 60_000)));
8
+ // PowerSync reports operation counts while a checkpoint downloads.
9
+ const stopDownloadProgress = db.registerListener({
10
+ statusChanged: (status) => {
11
+ const progress = status.downloadProgress;
12
+ if (progress)
13
+ reportProgress({ phase: 'download', current: progress.downloadedOperations, total: progress.totalOperations });
14
+ },
15
+ });
16
+ reportProgress({ phase: 'download' });
17
+ const downloadIdle = await waitForDownloadIdle(db, Math.max(1, Math.min(remainingMs(), 60_000)))
18
+ .finally(stopDownloadProgress);
8
19
  const indexTimeoutMs = remainingMs();
9
20
  const timeout = indexTimeoutMs > 0 ? createTimeoutSignal(indexTimeoutMs) : null;
10
21
  const tokenIndex = await syncNoteTokenIndex(db, {
11
22
  signal: timeout?.signal ?? AbortSignal.abort(),
23
+ onBatch: (progress) => reportProgress({ phase: 'index', current: progress.scanned }),
12
24
  }).finally(() => timeout?.cancel());
13
25
  const status = getStatusSnapshot(db);
14
26
  const uploadIdle = !status.dataFlowStatus.uploading;
@@ -25,6 +25,8 @@ export declare function syncNoteTokenIndex(db: AbstractPowerSyncDatabase, option
25
25
  nowIso?: () => string;
26
26
  tokenize?: (text: string, limit?: number) => string[];
27
27
  signal?: AbortSignal;
28
+ /** Called after each batch with the running counters. */
29
+ onBatch?: (result: NoteTokenIndexResult) => void;
28
30
  }): Promise<NoteTokenIndexResult>;
29
31
  export declare function inspectNoteTokenIndex(db: AbstractPowerSyncDatabase): Promise<NoteTokenIndexInspection>;
30
32
  export declare function rebuildNoteTokenIndex(db: AbstractPowerSyncDatabase, options?: Parameters<typeof syncNoteTokenIndex>[1]): Promise<NoteTokenIndexResult>;
@@ -67,6 +67,7 @@ export async function syncNoteTokenIndex(db, options) {
67
67
  }
68
68
  await updateIndexCursor(db, cursorUpdatedAt, cursorId, nowIso);
69
69
  result.cursor = { updatedAt: cursorUpdatedAt, id: cursorId };
70
+ options?.onBatch?.(result);
70
71
  }
71
72
  result.complete = await reconcileSkippedRows(db, result, batchSize, nowIso, tokenize, signal);
72
73
  result.cursor = {
@@ -0,0 +1,11 @@
1
+ import type { CommandExecuteResult } from '../daemon/types.js';
2
+ /**
3
+ * Runs a command line in this process with its stdout/stderr captured instead
4
+ * of written, the same way the daemon runs a forwarded command.
5
+ */
6
+ export declare function runCapturedInProcess(argv: string[], onFatalError?: (error: unknown) => void): Promise<CommandExecuteResult>;
7
+ /**
8
+ * Runs one data command and returns its captured output: inside the daemon
9
+ * when it is available, otherwise in this process (honoring `--request-id`).
10
+ */
11
+ export declare function executeCommandLine(argv: string[]): Promise<CommandExecuteResult>;
@@ -0,0 +1,26 @@
1
+ import { tryForwardToDaemon } from '../daemon/forward.js';
2
+ import { localCommandExecution, requestContext } from '../utils/requestContext.js';
3
+ import { requestIdFromArgv, runWithRequestId } from './store.js';
4
+ /**
5
+ * Runs a command line in this process with its stdout/stderr captured instead
6
+ * of written, the same way the daemon runs a forwarded command.
7
+ */
8
+ export async function runCapturedInProcess(argv, onFatalError) {
9
+ const { runCli } = await import('../runCli.js');
10
+ const context = {
11
+ stdout: [], stderr: [], format: null, noticeProvider: null, cwd: process.cwd(),
12
+ };
13
+ const exitCode = await requestContext.run(context, () => localCommandExecution.run(true, () => runCli(argv, onFatalError)));
14
+ return { stdout: context.stdout.join(''), stderr: context.stderr.join(''), exitCode };
15
+ }
16
+ /**
17
+ * Runs one data command and returns its captured output: inside the daemon
18
+ * when it is available, otherwise in this process (honoring `--request-id`).
19
+ */
20
+ export async function executeCommandLine(argv) {
21
+ const forwarded = await tryForwardToDaemon(argv);
22
+ if (forwarded)
23
+ return forwarded;
24
+ const key = requestIdFromArgv(argv);
25
+ return key ? runWithRequestId(key, argv, () => runCapturedInProcess(argv)) : runCapturedInProcess(argv);
26
+ }
@@ -1,17 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { GLOBAL_OPTION_NAMES } from '../commands/schema/globalOptions.js';
3
4
  import { listCommandSchemas } from '../commands/schema/spec.js';
4
5
  // Global flags accepted by every command (see src/cli.ts) plus Commander builtins.
5
- const GLOBAL_FLAGS = new Set([
6
- '--json',
7
- '--format',
8
- '--offline',
9
- '--require-sync',
10
- '--sync-timeout',
11
- '--verbose',
12
- '--help',
13
- '--version',
14
- ]);
6
+ const GLOBAL_FLAGS = new Set([...GLOBAL_OPTION_NAMES, '--help', '--version']);
15
7
  const SHELL_BOUNDARY = /\s(?:\||&&|\|\||;|>|2>|#)\s/;
16
8
  function buildCommandIndex() {
17
9
  const byCommand = new Map();
@@ -0,0 +1,9 @@
1
+ export declare function encodeCursor(command: string, offset: number, query: unknown): string;
2
+ /** Returns the offset a cursor points at; throws when it is malformed or belongs to another query. */
3
+ export declare function decodeCursor(token: string, command: string, query: unknown): number;
4
+ /**
5
+ * Resolves the start offset from `--cursor` or `--offset`; the two are
6
+ * mutually exclusive (an offset of 0, the default, does not count).
7
+ */
8
+ export declare function resolveCursorOffset(cursor: unknown, offset: number, command: string, query: unknown): number;
9
+ export declare function nextCursor(command: string, query: unknown, nextOffset: number, hasMore: boolean): string | null;
@@ -0,0 +1,61 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { DinoxError } from './errors.js';
3
+ function stableJson(value) {
4
+ if (Array.isArray(value))
5
+ return `[${value.map(stableJson).join(',')}]`;
6
+ if (value && typeof value === 'object') {
7
+ const entries = Object.entries(value)
8
+ .filter(([, item]) => item !== undefined)
9
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
10
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(',')}}`;
11
+ }
12
+ return JSON.stringify(value ?? null);
13
+ }
14
+ function queryHash(command, query) {
15
+ return createHash('sha256').update(`${command}\n${stableJson(query)}`).digest('base64url').slice(0, 16);
16
+ }
17
+ export function encodeCursor(command, offset, query) {
18
+ const payload = { v: 1, c: command, o: offset, q: queryHash(command, query) };
19
+ return Buffer.from(JSON.stringify(payload)).toString('base64url');
20
+ }
21
+ function invalidCursor(message) {
22
+ return new DinoxError(message, {
23
+ code: 'INVALID_ARGUMENT',
24
+ recoverable: true,
25
+ suggestion: 'Pass the nextCursor from the previous page of the same command and query, or drop --cursor to start over.',
26
+ });
27
+ }
28
+ /** Returns the offset a cursor points at; throws when it is malformed or belongs to another query. */
29
+ export function decodeCursor(token, command, query) {
30
+ let payload;
31
+ try {
32
+ payload = JSON.parse(Buffer.from(token.trim(), 'base64url').toString('utf8'));
33
+ }
34
+ catch {
35
+ throw invalidCursor('--cursor is not a valid pagination cursor');
36
+ }
37
+ if (!payload || payload.v !== 1 || typeof payload.o !== 'number' || !Number.isInteger(payload.o) || payload.o < 0) {
38
+ throw invalidCursor('--cursor is not a valid pagination cursor');
39
+ }
40
+ if (payload.c !== command || payload.q !== queryHash(command, query)) {
41
+ throw invalidCursor(`--cursor belongs to a different ${payload.c === command ? 'query' : 'command'}`);
42
+ }
43
+ return payload.o;
44
+ }
45
+ /**
46
+ * Resolves the start offset from `--cursor` or `--offset`; the two are
47
+ * mutually exclusive (an offset of 0, the default, does not count).
48
+ */
49
+ export function resolveCursorOffset(cursor, offset, command, query) {
50
+ if (cursor === undefined || cursor === null || cursor === '')
51
+ return offset;
52
+ if (typeof cursor !== 'string')
53
+ throw invalidCursor('--cursor must be a string');
54
+ if (offset > 0) {
55
+ throw new DinoxError('--cursor and --offset cannot be used together', { code: 'INVALID_ARGUMENT', recoverable: true });
56
+ }
57
+ return decodeCursor(cursor, command, query);
58
+ }
59
+ export function nextCursor(command, query, nextOffset, hasMore) {
60
+ return hasMore ? encodeCursor(command, nextOffset, query) : null;
61
+ }
@@ -0,0 +1,8 @@
1
+ export declare const DEFAULT_FORMAT_ENV = "DINOX_FORMAT";
2
+ /**
3
+ * Applies DINOX_FORMAT (json, yaml, or auto: json when stdout is not a
4
+ * terminal) by adding `--format` when the command line has none. Agents set
5
+ * it once instead of passing `--format json` on every call. Help, version and
6
+ * protocol commands are left alone.
7
+ */
8
+ export declare function applyDefaultFormat(argv: string[], env?: NodeJS.ProcessEnv, stdoutIsTTY?: boolean): string[];
@@ -0,0 +1,38 @@
1
+ import { DinoxError } from './errors.js';
2
+ export const DEFAULT_FORMAT_ENV = 'DINOX_FORMAT';
3
+ /** Commands whose stdout is a protocol, never a command result. */
4
+ const PROTOCOL_COMMANDS = [['gateway', 'mcp'], ['mcp'], ['daemon', 'supervise']];
5
+ function commandWords(argv) {
6
+ return argv.filter((arg) => !arg.startsWith('-')).slice(0, 2);
7
+ }
8
+ /**
9
+ * Applies DINOX_FORMAT (json, yaml, or auto: json when stdout is not a
10
+ * terminal) by adding `--format` when the command line has none. Agents set
11
+ * it once instead of passing `--format json` on every call. Help, version and
12
+ * protocol commands are left alone.
13
+ */
14
+ export function applyDefaultFormat(argv, env = process.env, stdoutIsTTY = Boolean(process.stdout.isTTY)) {
15
+ const raw = env[DEFAULT_FORMAT_ENV]?.trim().toLowerCase();
16
+ if (!raw)
17
+ return argv;
18
+ if (raw !== 'json' && raw !== 'yaml' && raw !== 'auto') {
19
+ throw new DinoxError(`${DEFAULT_FORMAT_ENV} must be one of: json, yaml, auto`, {
20
+ code: 'INVALID_ARGUMENT',
21
+ recoverable: true,
22
+ });
23
+ }
24
+ const format = raw === 'auto' ? (stdoutIsTTY ? null : 'json') : raw;
25
+ if (!format || argv.length === 0)
26
+ return argv;
27
+ const terminator = argv.indexOf('--');
28
+ const options = terminator === -1 ? argv : argv.slice(0, terminator);
29
+ const explicit = options.some((arg) => arg === '--format' || arg.startsWith('--format=') || arg === '--json');
30
+ const informational = options.some((arg) => ['--help', '-h', '--version', '-V'].includes(arg)) || options[0] === 'help';
31
+ const words = commandWords(options);
32
+ const protocol = PROTOCOL_COMMANDS.some((command) => command.every((word, index) => words[index] === word));
33
+ if (explicit || informational || protocol)
34
+ return argv;
35
+ return terminator === -1
36
+ ? [...argv, '--format', format]
37
+ : [...argv.slice(0, terminator), '--format', format, ...argv.slice(terminator)];
38
+ }
@@ -471,6 +471,10 @@ export declare const ERROR_CODES: {
471
471
  readonly exitCode: 5;
472
472
  readonly description: "No command schema exists for the requested path.";
473
473
  };
474
+ readonly SEMANTIC_SEARCH_UNAVAILABLE: {
475
+ readonly exitCode: 1;
476
+ readonly description: "The semantic search service could not be reached or failed; keyword search (note search) still works.";
477
+ };
474
478
  readonly SKILLS_LOCK_INVALID: {
475
479
  readonly exitCode: 1;
476
480
  readonly description: "The skills installer lock file is malformed.";
@@ -123,6 +123,7 @@ export const ERROR_CODES = {
123
123
  SAVED_VIEW_UNSUPPORTED_LAYOUT: { exitCode: 1, description: 'The saved view layout is unsupported.' },
124
124
  SAVED_VIEW_VALUE_TYPE_MISMATCH: { exitCode: 1, description: 'A filter value does not match the field type.' },
125
125
  SCHEMA_PATH_NOT_FOUND: { exitCode: 5, description: 'No command schema exists for the requested path.' },
126
+ SEMANTIC_SEARCH_UNAVAILABLE: { exitCode: 1, description: 'The semantic search service could not be reached or failed; keyword search (note search) still works.' },
126
127
  SKILLS_LOCK_INVALID: { exitCode: 1, description: 'The skills installer lock file is malformed.' },
127
128
  SKILLS_LOCK_READ_FAILED: { exitCode: 1, description: 'The skills installer lock file could not be read.' },
128
129
  SKILLS_MIGRATION_FAILED: { exitCode: 1, description: 'The skills installer failed during migration.' },