@dinoxx/dinox-cli 2.4.0 → 2.6.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 (42) hide show
  1. package/dist/commands/gateway/index.js +26 -4
  2. package/dist/commands/schema/renderSkills.js +1 -1
  3. package/dist/daemon/ensure.js +10 -19
  4. package/dist/daemon/env.d.ts +8 -0
  5. package/dist/daemon/env.js +81 -0
  6. package/dist/daemon/index.d.ts +27 -0
  7. package/dist/daemon/index.js +240 -57
  8. package/dist/daemon/launch.d.ts +27 -0
  9. package/dist/daemon/launch.js +87 -0
  10. package/dist/daemon/log.d.ts +12 -0
  11. package/dist/daemon/log.js +44 -0
  12. package/dist/daemon/server.js +18 -1
  13. package/dist/daemon/supervisor.d.ts +59 -0
  14. package/dist/daemon/supervisor.js +253 -0
  15. package/dist/daemon/types.d.ts +2 -0
  16. package/dist/gateway/adapters/acp.d.ts +10 -5
  17. package/dist/gateway/adapters/acp.js +30 -13
  18. package/dist/gateway/adapters/index.js +3 -2
  19. package/dist/gateway/config.d.ts +11 -1
  20. package/dist/gateway/config.js +38 -0
  21. package/dist/gateway/conversationContext.d.ts +44 -0
  22. package/dist/gateway/conversationContext.js +75 -0
  23. package/dist/gateway/deviceHost.d.ts +10 -1
  24. package/dist/gateway/deviceHost.js +119 -8
  25. package/dist/gateway/keepAwake.d.ts +24 -0
  26. package/dist/gateway/keepAwake.js +64 -0
  27. package/dist/gateway/mcpServer.js +20 -0
  28. package/dist/gateway/protocol.d.ts +88 -1
  29. package/dist/gateway/protocol.js +55 -0
  30. package/dist/gateway/runExecutor.d.ts +24 -0
  31. package/dist/gateway/runExecutor.js +118 -12
  32. package/dist/gateway/service.d.ts +4 -0
  33. package/dist/gateway/service.js +44 -15
  34. package/dist/gateway/threadStore.d.ts +71 -6
  35. package/dist/gateway/threadStore.js +189 -22
  36. package/dist/gateway/types.d.ts +22 -3
  37. package/dist/gateway/types.js +3 -2
  38. package/dist/gateway/workspaceFolders.d.ts +33 -0
  39. package/dist/gateway/workspaceFolders.js +129 -0
  40. package/dist/utils/errorCodes.d.ts +8 -0
  41. package/dist/utils/errorCodes.js +2 -0
  42. package/package.json +1 -1
@@ -1,10 +1,18 @@
1
+ import os from 'node:os';
1
2
  import { AssistantProjection, projectPendingApprovals, toTranscriptMessage } from './assistantProjection.js';
2
- import { commitRunTerminal, enqueueRun, finishMergedRun, startQueuedRuns, withdrawQueuedRun, readCurrentTopicId, readSessionBinding, resolveTrustedResource, updateRunSnapshot, writeSessionBinding, } from './threadStore.js';
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';
3
5
  import { readAgentConfig } from './protocol.js';
4
6
  const SNAPSHOT_PERSIST_INTERVAL_MS = 1_000;
5
7
  /** Turns waiting behind a thread's active one; more are refused as `busy`. */
6
8
  export const MAX_QUEUED_TURNS = 10;
7
9
  const APPROVAL_TIMEOUT_MS = 30 * 60 * 1000;
10
+ /** The agent's instructions travel with a new session's first prompt; longer ones are cut. */
11
+ const MAX_INSTRUCTIONS_CHARS = 8_000;
12
+ /** How long a turn waits for the client's latest messages to sync to this machine. */
13
+ export const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5_000;
14
+ /** Client tails that never synced, so later turns of their thread do not wait for them again. */
15
+ const MAX_REMEMBERED_MISSING_TAILS = 200;
8
16
  export class QueueFullError extends Error {
9
17
  constructor() {
10
18
  super(`At most ${MAX_QUEUED_TURNS} messages can wait in one conversation`);
@@ -18,6 +26,20 @@ function statusForStop(stopReason) {
18
26
  return 'failed';
19
27
  return 'succeeded';
20
28
  }
29
+ /** The user's instructions for the agent, as the start of a new session. */
30
+ export function agentInstructionsPreamble(instructions) {
31
+ const text = instructions.trim();
32
+ if (!text)
33
+ return undefined;
34
+ const clipped = text.length > MAX_INSTRUCTIONS_CHARS ? `${text.slice(0, MAX_INSTRUCTIONS_CHARS)}…` : text;
35
+ return [
36
+ '<dinox_agent_instructions>',
37
+ 'Instructions the user set for you in Dinox. Follow them throughout this conversation.',
38
+ '',
39
+ clipped.replaceAll('</dinox_agent_instructions>', '<\\/dinox_agent_instructions>'),
40
+ '</dinox_agent_instructions>',
41
+ ].join('\n');
42
+ }
21
43
  function withErrorText(message, error) {
22
44
  return { ...message, metadata: { ...(message.metadata ?? {}), error } };
23
45
  }
@@ -27,6 +49,8 @@ function withErrorText(message, error) {
27
49
  * together as one turn (each keeps its own message; the reply follows the
28
50
  * last). The agent session is scoped to the thread's current topic: a "new
29
51
  * topic" marker in Dinox starts a fresh agent session, everything else continues it.
52
+ * The thread may have been answered by other agents meanwhile, so each turn
53
+ * also carries the messages this gateway's session has not seen.
30
54
  */
31
55
  export class GatewayRunExecutor {
32
56
  deps;
@@ -37,6 +61,7 @@ export class GatewayRunExecutor {
37
61
  queueIndex = 0;
38
62
  /** Accepts of one thread run one at a time, so two can never both start a turn. */
39
63
  acceptChains = new Map();
64
+ missingTails = new Set();
40
65
  constructor(deps) {
41
66
  this.deps = deps;
42
67
  }
@@ -144,7 +169,9 @@ export class GatewayRunExecutor {
144
169
  };
145
170
  this.activeByThread.set(request.threadId, active);
146
171
  this.activeRequests.set(request.threadId, request);
172
+ const releaseAwake = this.deps.keepAwake?.();
147
173
  void this.execute(request, active, emit, merged).finally(() => {
174
+ releaseAwake?.();
148
175
  if (this.activeByThread.get(request.threadId) === active) {
149
176
  this.activeByThread.delete(request.threadId);
150
177
  this.activeRequests.delete(request.threadId);
@@ -235,21 +262,32 @@ export class GatewayRunExecutor {
235
262
  };
236
263
  const awaitApproval = registerApproval;
237
264
  let errorText = null;
265
+ let nextBinding = null;
238
266
  try {
239
267
  publish(true);
240
- const [topicId, binding] = await Promise.all([
241
- readCurrentTopicId(db, active.threadId),
242
- readSessionBinding(db, active.threadId),
268
+ const [topic, binding] = await Promise.all([
269
+ readCurrentTopic(db, active.threadId),
270
+ readSessionBinding(db, active.threadId, gateway.id),
243
271
  ]);
244
- const sessionId = binding && binding.gatewayId === gateway.id && binding.topicId === topicId ? binding.sessionId : null;
272
+ const topicId = topic?.id ?? null;
273
+ const continued = binding?.topicId === topicId ? binding : null;
245
274
  const turns = [...merged, request];
246
- const resources = (await Promise.all(turns.map((turn) => this.resolveResources(turn.userMessage)))).flat();
275
+ const [resources, context, instructions] = await Promise.all([
276
+ Promise.all(turns.map((turn) => this.resolveResources(turn.userMessage))).then((lists) => lists.flat()),
277
+ this.clientTailSynced(turns[0], active.controller.signal).then((synced) => this.unseenContext(active.threadId, turns[0].userMessage.id, topic?.createdAt ?? null, continued, synced)),
278
+ readAgentInstructions(db, active.threadId).catch(() => ''),
279
+ ]);
280
+ // A session keeps the directory it started in; changing the default only affects new ones.
281
+ const cwd = continued ? (continued.cwd ?? gateway.previousCwd ?? gateway.cwd ?? os.homedir()) : (gateway.cwd ?? os.homedir());
247
282
  // Validated on receipt; a malformed value never reaches here.
248
283
  const requestedConfig = readAgentConfig(request);
249
284
  const agentConfig = requestedConfig && requestedConfig !== 'invalid' ? requestedConfig : undefined;
285
+ const prompt = turns.map((turn) => turn.prompt.trim()).filter(Boolean).join('\n\n');
250
286
  const result = await adapter.runTurn({
251
- sessionId,
252
- prompt: turns.map((turn) => turn.prompt.trim()).filter(Boolean).join('\n\n'),
287
+ sessionId: continued?.sessionId ?? null,
288
+ cwd,
289
+ sessionPreamble: agentInstructionsPreamble(instructions),
290
+ prompt: withConversationContext(prompt, context, { canReadConversation: gateway.dinoxTools !== false }),
253
291
  resources,
254
292
  signal: active.controller.signal,
255
293
  config: agentConfig,
@@ -263,9 +301,19 @@ export class GatewayRunExecutor {
263
301
  publish(event.type === 'approval-request' || event.type === 'approval-resolved');
264
302
  },
265
303
  });
266
- if (result.sessionId !== sessionId) {
267
- await writeSessionBinding(db, active.threadId, { sessionId: result.sessionId, topicId, gatewayId: gateway.id });
268
- }
304
+ nextBinding = {
305
+ sessionId: result.sessionId,
306
+ topicId,
307
+ cwd,
308
+ lastSeenMessageId: context?.incomplete
309
+ ? // Messages that had not synced yet sort before this turn: keep the mark so the
310
+ // next turn still catches up on them (its own turns are left out by session).
311
+ continued && continued.sessionId === result.sessionId && !continued.legacy
312
+ ? continued.lastSeenMessageId
313
+ : null
314
+ : // Its own reply is the newest message the session has seen.
315
+ request.assistantMessage.id,
316
+ };
269
317
  status = statusForStop(result.stopReason);
270
318
  errorText = result.error ?? null;
271
319
  }
@@ -287,15 +335,73 @@ export class GatewayRunExecutor {
287
335
  userId: this.deps.userId,
288
336
  userMessageId: request.userMessage.id,
289
337
  status,
290
- assistant: toTranscriptMessage(assistant),
338
+ assistant: nextBinding
339
+ ? withGatewaySession(toTranscriptMessage(assistant), { gatewayId: gateway.id, sessionId: nextBinding.sessionId })
340
+ : toTranscriptMessage(assistant),
291
341
  errorText,
292
342
  });
293
343
  }
294
344
  catch (error) {
295
345
  log('commit run terminal failed', { runId: active.runId, error: String(error) });
296
346
  }
347
+ if (nextBinding) {
348
+ // After the commit, so the reply it marks as seen is in the transcript.
349
+ await writeSessionBinding(db, active.threadId, gateway.id, nextBinding).catch((error) => log('write session binding failed', { runId: active.runId, error: String(error) }));
350
+ }
297
351
  emit({ revision: projection.revision, status, assistant, pendingApprovals: [] });
298
352
  }
353
+ /**
354
+ * The client sends the last message it saw; a turn reads the transcript only
355
+ * once that message has synced here (its upload may still be in flight), or
356
+ * gives up after a short wait. A tail that never arrived is not waited for again.
357
+ */
358
+ async clientTailSynced(request, signal) {
359
+ const tailId = request.parentMessageId;
360
+ if (!tailId)
361
+ return true;
362
+ const key = `${request.threadId}:${tailId}`;
363
+ const synced = await waitForMessage(this.deps.db, tailId, {
364
+ timeoutMs: this.missingTails.has(key) ? 0 : (this.deps.clientTailSyncTimeoutMs ?? CLIENT_TAIL_SYNC_TIMEOUT_MS),
365
+ signal,
366
+ });
367
+ if (synced) {
368
+ this.missingTails.delete(key);
369
+ }
370
+ else {
371
+ if (this.missingTails.size >= MAX_REMEMBERED_MISSING_TAILS)
372
+ this.missingTails.clear();
373
+ this.missingTails.add(key);
374
+ this.deps.log('client messages not synced yet', { threadId: request.threadId, tailId });
375
+ }
376
+ return synced;
377
+ }
378
+ /**
379
+ * Messages before this turn that the session has not seen: the whole topic
380
+ * for a new session, those after the session's mark for a continued one,
381
+ * leaving out the turns the session answered itself. A session bound before
382
+ * seen messages were tracked is assumed to be up to date.
383
+ */
384
+ async unseenContext(threadId, turnMessageId, topicStartedAt, continued, synced) {
385
+ if (continued?.legacy)
386
+ return synced ? null : { threadId, messages: [], earlier: 0, incomplete: true };
387
+ try {
388
+ const page = await readTranscriptPage(this.deps.db, {
389
+ threadId,
390
+ since: topicStartedAt,
391
+ afterMessageId: continued?.lastSeenMessageId ?? null,
392
+ beforeMessageId: turnMessageId,
393
+ excludeSessionId: continued?.sessionId ?? null,
394
+ limit: CONTEXT_MESSAGE_LIMIT,
395
+ });
396
+ return { threadId, ...page, ...(synced ? {} : { incomplete: true }) };
397
+ }
398
+ catch (error) {
399
+ // Missing context degrades the answer; it must not fail the turn. Marked
400
+ // incomplete so the session's mark stays and the next turn retries.
401
+ this.deps.log('read conversation context failed', { threadId, error: String(error) });
402
+ return { threadId, messages: [], earlier: 0, incomplete: true };
403
+ }
404
+ }
299
405
  async resolveResources(message) {
300
406
  const resources = [];
301
407
  for (const part of message.parts) {
@@ -16,11 +16,15 @@ export declare class GatewayService {
16
16
  private readonly deps;
17
17
  private hosts;
18
18
  private readonly log;
19
+ private readonly keepAwake;
19
20
  constructor(deps: GatewayServiceDeps);
20
21
  start(): Promise<void>;
21
22
  stop(): Promise<void>;
22
23
  /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
23
24
  reload(): Promise<DeviceHostStatus[]>;
25
+ /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
26
+ private updateSettings;
27
+ private robotRegistration;
24
28
  status(): DeviceHostStatus[];
25
29
  }
26
30
  export {};
@@ -1,12 +1,22 @@
1
1
  import os from 'node:os';
2
2
  import { getPackageVersion } from '../utils/version.js';
3
+ import { writeDaemonLogLine } from '../daemon/log.js';
3
4
  import { createGatewayAdapter } from './adapters/index.js';
4
- import { getMachineId, loadGateways } from './config.js';
5
+ import { getMachineId, loadGateways, updateGatewaySettings } from './config.js';
5
6
  import { GatewayDeviceHost } from './deviceHost.js';
7
+ import { KeepAwake } from './keepAwake.js';
6
8
  import { interruptOrphanedRuns, retireRemovedRobots, upsertRobot } from './threadStore.js';
7
- const defaultLog = (message, details) => {
8
- process.stderr.write(`[dinox-gateway] ${message}${details ? ` ${JSON.stringify(details)}` : ''}\n`);
9
- };
9
+ /** Settings the owner changes from the app; the running gateway picks them up without a restart. */
10
+ const LIVE_SETTINGS = ['cwd', 'previousCwd', 'approval', 'paused', 'dinoxTools'];
11
+ function applyLiveSettings(target, source) {
12
+ for (const key of LIVE_SETTINGS) {
13
+ if (source[key] === undefined)
14
+ delete target[key];
15
+ else
16
+ target[key] = source[key];
17
+ }
18
+ }
19
+ const defaultLog = (message, details) => writeDaemonLogLine('dinox-gateway', message, details);
10
20
  /**
11
21
  * Runs every enabled gateway inside the daemon: the daemon owns the PowerSync
12
22
  * database, so gateway writes share its connection and upload queue.
@@ -15,9 +25,11 @@ export class GatewayService {
15
25
  deps;
16
26
  hosts = [];
17
27
  log;
28
+ keepAwake;
18
29
  constructor(deps) {
19
30
  this.deps = deps;
20
31
  this.log = deps.log ?? defaultLog;
32
+ this.keepAwake = new KeepAwake(this.log);
21
33
  }
22
34
  async start() {
23
35
  const configured = await loadGateways(this.deps.userId);
@@ -40,17 +52,7 @@ export class GatewayService {
40
52
  if (interrupted > 0)
41
53
  this.log('marked runs from a previous host as interrupted', { count: interrupted });
42
54
  for (const gateway of gateways) {
43
- await upsertRobot(this.deps.db, {
44
- id: gateway.id,
45
- userId: this.deps.userId,
46
- name: gateway.name,
47
- deviceId: gateway.deviceId,
48
- agentType: gateway.kind,
49
- cwd: gateway.cwd ?? null,
50
- deviceName: os.hostname(),
51
- machineId,
52
- ...(gateway.agentId ? { agentId: gateway.agentId } : {}),
53
- }).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
55
+ await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
54
56
  const host = new GatewayDeviceHost({
55
57
  gateway,
56
58
  db: this.deps.db,
@@ -60,6 +62,8 @@ export class GatewayService {
60
62
  appVersion: getPackageVersion(),
61
63
  log: (message, details) => this.log(message, { gateway: gateway.name, ...details }),
62
64
  credentialsUrl: this.deps.credentialsUrl,
65
+ updateSettings: (change) => this.updateSettings(gateway, machineId, change),
66
+ keepAwake: () => this.keepAwake.acquire(),
63
67
  });
64
68
  host.start();
65
69
  this.hosts.push(host);
@@ -69,6 +73,7 @@ export class GatewayService {
69
73
  const hosts = this.hosts;
70
74
  this.hosts = [];
71
75
  await Promise.allSettled(hosts.map((host) => host.stop()));
76
+ this.keepAwake.dispose();
72
77
  }
73
78
  /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
74
79
  async reload() {
@@ -76,6 +81,30 @@ export class GatewayService {
76
81
  await this.start();
77
82
  return this.status();
78
83
  }
84
+ /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
85
+ async updateSettings(gateway, machineId, change) {
86
+ const updated = await updateGatewaySettings(this.deps.userId, gateway.id, change);
87
+ applyLiveSettings(gateway, updated);
88
+ await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('sync robot settings failed', { gateway: gateway.name, error: String(error) }));
89
+ }
90
+ robotRegistration(gateway, machineId) {
91
+ return {
92
+ id: gateway.id,
93
+ userId: this.deps.userId,
94
+ name: gateway.name,
95
+ deviceId: gateway.deviceId,
96
+ agentType: gateway.kind,
97
+ cwd: gateway.cwd ?? null,
98
+ deviceName: os.hostname(),
99
+ machineId,
100
+ ...(gateway.agentId ? { agentId: gateway.agentId } : {}),
101
+ settings: {
102
+ approval: gateway.approval ?? 'ask',
103
+ paused: gateway.paused === true,
104
+ dinoxTools: gateway.dinoxTools !== false,
105
+ },
106
+ };
107
+ }
79
108
  status() {
80
109
  return this.hosts.map((host) => host.status());
81
110
  }
@@ -105,20 +105,78 @@ export declare function withdrawQueuedRun(db: AbstractPowerSyncDatabase, input:
105
105
  }): Promise<ChatUIMessage>;
106
106
  /** Runs left non-terminal by a previous process can never finish; mark them interrupted. */
107
107
  export declare function interruptOrphanedRuns(db: Tx, userId: string): Promise<number>;
108
- /** Agent session bound to a thread, scoped to the thread's current topic. */
108
+ /**
109
+ * One gateway's agent session on a thread, scoped to the thread's topic when it
110
+ * started. The thread can switch agents, so every gateway keeps its own session
111
+ * and remembers the last transcript message it saw.
112
+ */
109
113
  export type GatewaySessionBinding = {
110
114
  sessionId: string;
111
115
  topicId: string | null;
112
- gatewayId: string;
116
+ /** Newest transcript message the session has seen; null when it has seen nothing of the topic yet. */
117
+ lastSeenMessageId: string | null;
118
+ /** Directory the session works in; absent for bindings written before it was recorded. */
119
+ cwd?: string;
120
+ /** Written before seen messages were tracked: the session is assumed to be up to date. */
121
+ legacy?: true;
113
122
  };
114
- export declare function readSessionBinding(db: Tx, threadId: string): Promise<GatewaySessionBinding | null>;
115
- export declare function writeSessionBinding(db: Tx, threadId: string, binding: GatewaySessionBinding): Promise<void>;
123
+ export declare function readSessionBinding(db: Tx, threadId: string, gatewayId: string): Promise<GatewaySessionBinding | null>;
124
+ /** Replaces this gateway's binding and keeps every other gateway's. */
125
+ export declare function writeSessionBinding(db: AbstractPowerSyncDatabase, threadId: string, gatewayId: string, binding: GatewaySessionBinding): Promise<void>;
126
+ /** Directories this gateway's sessions worked in, most recently used first. */
127
+ export declare function readRecentSessionFolders(db: Tx, gatewayId: string, limit?: number): Promise<string[]>;
128
+ /** The instructions the user wrote for the thread's agent (`c_project.agent_md`); empty when none. */
129
+ export declare function readAgentInstructions(db: Tx, threadId: string): Promise<string>;
116
130
  /**
117
131
  * The thread's current topic: the latest "new topic" marker the app wrote
118
132
  * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
119
- * starts a fresh agent session.
133
+ * starts a fresh agent session; like the app, it covers rows written after the marker.
134
+ */
135
+ export declare function readCurrentTopic(db: Tx, threadId: string): Promise<{
136
+ id: string;
137
+ createdAt: string | null;
138
+ } | null>;
139
+ /** Marks a reply with the agent session that wrote it (`metadata.gateway`). */
140
+ export declare function withGatewaySession(message: ChatUIMessage, gateway: {
141
+ gatewayId: string;
142
+ sessionId: string;
143
+ }): ChatUIMessage;
144
+ /**
145
+ * Waits until a message exists in the local replica (deleted or not), e.g. the
146
+ * last message the client saw before sending a turn. False when it has not
147
+ * synced within the timeout or the wait was aborted.
120
148
  */
121
- export declare function readCurrentTopicId(db: Tx, threadId: string): Promise<string | null>;
149
+ export declare function waitForMessage(db: Tx, messageId: string, options: {
150
+ timeoutMs: number;
151
+ signal?: AbortSignal;
152
+ }): Promise<boolean>;
153
+ /** A transcript message reduced to what another agent needs: who said what. */
154
+ export type TranscriptMessage = {
155
+ id: string;
156
+ role: 'user' | 'assistant';
157
+ /** The answering agent's name; null for the user or an unknown agent. */
158
+ speaker: string | null;
159
+ text: string;
160
+ createdAt: string | null;
161
+ };
162
+ /**
163
+ * The newest transcript messages in a window, oldest first, with how many
164
+ * matching messages lie before the page. Bounds are exclusive: `since` is a
165
+ * topic start (by `created_at`, as the app scopes topics), `afterMessageId` and
166
+ * `beforeMessageId` are transcript positions; an id that no longer exists is no bound.
167
+ */
168
+ export declare function readTranscriptPage(db: Tx, input: {
169
+ threadId: string;
170
+ since?: string | null;
171
+ afterMessageId?: string | null;
172
+ beforeMessageId?: string | null;
173
+ /** Leaves out the turns this agent session answered itself (its replies and their prompts). */
174
+ excludeSessionId?: string | null;
175
+ limit: number;
176
+ }): Promise<{
177
+ messages: TranscriptMessage[];
178
+ earlier: number;
179
+ }>;
122
180
  /** File parts resolve only to resources this user owns with an https storage URL. */
123
181
  export declare function resolveTrustedResource(db: Tx, input: {
124
182
  resourceId: string;
@@ -141,6 +199,13 @@ export type RobotRegistration = {
141
199
  /** Marks the robot as hosted by this installation, for reconciliation. */
142
200
  machineId: string;
143
201
  agentId?: string;
202
+ /** Owner-facing settings, synced so the app can show them while the computer is offline. */
203
+ settings?: RobotSettings;
204
+ };
205
+ export type RobotSettings = {
206
+ approval: 'ask' | 'auto_read';
207
+ paused: boolean;
208
+ dinoxTools: boolean;
144
209
  };
145
210
  export declare function upsertRobot(db: Tx, input: RobotRegistration): Promise<void>;
146
211
  /**
@@ -319,38 +319,199 @@ export async function interruptOrphanedRuns(db, userId) {
319
319
  }
320
320
  return rows.length;
321
321
  }
322
- export async function readSessionBinding(db, threadId) {
323
- const row = await db.getOptional('SELECT robot_session_id FROM c_thread WHERE id = ? LIMIT 1', [threadId]);
324
- const parsed = parseJson(row?.robot_session_id);
325
- if (parsed?.v !== 1 || typeof parsed.sessionId !== 'string' || typeof parsed.gatewayId !== 'string')
322
+ function isRecord(value) {
323
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
324
+ }
325
+ function toBinding(value, legacy = false) {
326
+ if (!isRecord(value) || typeof value.sessionId !== 'string' || !value.sessionId)
326
327
  return null;
327
328
  return {
328
- sessionId: parsed.sessionId,
329
- topicId: typeof parsed.topicId === 'string' ? parsed.topicId : null,
330
- gatewayId: parsed.gatewayId,
329
+ sessionId: value.sessionId,
330
+ topicId: typeof value.topicId === 'string' ? value.topicId : null,
331
+ lastSeenMessageId: typeof value.lastSeenMessageId === 'string' ? value.lastSeenMessageId : null,
332
+ ...(typeof value.cwd === 'string' && value.cwd ? { cwd: value.cwd } : {}),
333
+ ...(legacy ? { legacy: true } : {}),
331
334
  };
332
335
  }
333
- export async function writeSessionBinding(db, threadId, binding) {
334
- await db.execute('UPDATE c_thread SET robot_session_id = ?, updated_at = ? WHERE id = ?', [
335
- safeJson({ v: 1, ...binding }),
336
- new Date().toISOString(),
337
- threadId,
338
- ]);
336
+ /** `c_thread.robot_session_id`: v2 keeps a binding per gateway; v1 held a single one. */
337
+ function parseBindings(raw) {
338
+ const parsed = parseJson(raw);
339
+ const bindings = {};
340
+ if (parsed?.v === 1 && typeof parsed.gatewayId === 'string') {
341
+ const binding = toBinding(parsed, true);
342
+ if (binding)
343
+ bindings[parsed.gatewayId] = binding;
344
+ }
345
+ else if (parsed?.v === 2 && isRecord(parsed.sessions)) {
346
+ for (const [gatewayId, value] of Object.entries(parsed.sessions)) {
347
+ const binding = toBinding(value);
348
+ if (binding)
349
+ bindings[gatewayId] = binding;
350
+ }
351
+ }
352
+ return bindings;
353
+ }
354
+ async function readBindings(db, threadId) {
355
+ const row = await db.getOptional('SELECT robot_session_id FROM c_thread WHERE id = ? LIMIT 1', [threadId]);
356
+ return parseBindings(row?.robot_session_id);
357
+ }
358
+ export async function readSessionBinding(db, threadId, gatewayId) {
359
+ return (await readBindings(db, threadId))[gatewayId] ?? null;
360
+ }
361
+ /** Replaces this gateway's binding and keeps every other gateway's. */
362
+ export async function writeSessionBinding(db, threadId, gatewayId, binding) {
363
+ const { legacy: _legacy, ...stored } = binding;
364
+ await withPowerSyncWriteTransaction(db, async (tx) => {
365
+ const sessions = { ...(await readBindings(tx, threadId)), [gatewayId]: stored };
366
+ await tx.execute('UPDATE c_thread SET robot_session_id = ?, updated_at = ? WHERE id = ?', [
367
+ safeJson({ v: 2, sessions }),
368
+ new Date().toISOString(),
369
+ threadId,
370
+ ]);
371
+ });
372
+ }
373
+ /** Directories this gateway's sessions worked in, most recently used first. */
374
+ export async function readRecentSessionFolders(db, gatewayId, limit = 8) {
375
+ const rows = await db.getAll(`SELECT robot_session_id FROM c_thread
376
+ WHERE robot_session_id LIKE ? AND (is_del IS NULL OR is_del = 0)
377
+ ORDER BY updated_at DESC LIMIT 200`, [`%${gatewayId}%`]);
378
+ const folders = [];
379
+ for (const row of rows) {
380
+ const cwd = parseBindings(row.robot_session_id)[gatewayId]?.cwd;
381
+ if (cwd && !folders.includes(cwd))
382
+ folders.push(cwd);
383
+ if (folders.length >= limit)
384
+ break;
385
+ }
386
+ return folders;
387
+ }
388
+ /** The instructions the user wrote for the thread's agent (`c_project.agent_md`); empty when none. */
389
+ export async function readAgentInstructions(db, threadId) {
390
+ const row = await db.getOptional(`SELECT p.agent_md FROM c_thread t JOIN c_project p ON p.id = t.project_id
391
+ WHERE t.id = ? AND (p.is_del IS NULL OR p.is_del = 0) LIMIT 1`, [threadId]);
392
+ return row?.agent_md?.trim() ?? '';
339
393
  }
340
394
  /**
341
395
  * The thread's current topic: the latest "new topic" marker the app wrote
342
396
  * (`entry_type = 'internal'`, `meta_data.kind = 'context_reset'`). A new topic
343
- * starts a fresh agent session.
397
+ * starts a fresh agent session; like the app, it covers rows written after the marker.
344
398
  */
345
- export async function readCurrentTopicId(db, threadId) {
346
- const rows = await db.getAll(`SELECT id, meta_data FROM c_chat_message
399
+ export async function readCurrentTopic(db, threadId) {
400
+ // Other markers (e.g. agent switches) share the entry type, so filter by kind in SQL.
401
+ const row = await db.getOptional(`SELECT id, created_at FROM c_chat_message
347
402
  WHERE type = ? AND source_id = ? AND entry_type = ? AND deleted_at IS NULL
348
- ORDER BY julianday(created_at) DESC, id DESC LIMIT 20`, [THREAD_CHAT_TYPE, threadId, CONTEXT_RESET_ENTRY_TYPE]);
349
- for (const row of rows) {
350
- if (parseJson(row.meta_data)?.kind === 'context_reset')
351
- return row.id;
403
+ AND json_extract(meta_data, '$.kind') = 'context_reset'
404
+ ORDER BY julianday(created_at) DESC, id DESC LIMIT 1`, [THREAD_CHAT_TYPE, threadId, CONTEXT_RESET_ENTRY_TYPE]);
405
+ return row ? { id: row.id, createdAt: row.created_at } : null;
406
+ }
407
+ /** Marks a reply with the agent session that wrote it (`metadata.gateway`). */
408
+ export function withGatewaySession(message, gateway) {
409
+ return {
410
+ ...message,
411
+ metadata: { ...(message.metadata ?? {}), gateway: { id: gateway.gatewayId, sessionId: gateway.sessionId } },
412
+ };
413
+ }
414
+ const TAIL_POLL_INTERVAL_MS = 250;
415
+ /**
416
+ * Waits until a message exists in the local replica (deleted or not), e.g. the
417
+ * last message the client saw before sending a turn. False when it has not
418
+ * synced within the timeout or the wait was aborted.
419
+ */
420
+ export async function waitForMessage(db, messageId, options) {
421
+ const deadline = Date.now() + options.timeoutMs;
422
+ for (;;) {
423
+ const row = await db.getOptional('SELECT id FROM c_chat_message WHERE id = ? LIMIT 1', [messageId]);
424
+ if (row)
425
+ return true;
426
+ if (options.signal?.aborted || Date.now() >= deadline)
427
+ return false;
428
+ await new Promise((resolve) => setTimeout(resolve, TAIL_POLL_INTERVAL_MS));
429
+ }
430
+ }
431
+ function messageText(message) {
432
+ const lines = [];
433
+ for (const part of message?.parts ?? []) {
434
+ if (part.type === 'text' && typeof part.text === 'string' && part.text.trim())
435
+ lines.push(part.text.trim());
436
+ if (part.type === 'file') {
437
+ const name = part.filename;
438
+ lines.push(`[attachment: ${typeof name === 'string' && name ? name : String(part.mediaType ?? 'file')}]`);
439
+ }
352
440
  }
353
- return null;
441
+ return lines.join('\n\n');
442
+ }
443
+ /**
444
+ * The newest transcript messages in a window, oldest first, with how many
445
+ * matching messages lie before the page. Bounds are exclusive: `since` is a
446
+ * topic start (by `created_at`, as the app scopes topics), `afterMessageId` and
447
+ * `beforeMessageId` are transcript positions; an id that no longer exists is no bound.
448
+ */
449
+ export async function readTranscriptPage(db, input) {
450
+ const since = input.since ?? null;
451
+ const after = input.afterMessageId ?? null;
452
+ const before = input.beforeMessageId ?? null;
453
+ const ownSession = input.excludeSessionId ?? null;
454
+ const where = `type = ? AND source_id = ? AND deleted_at IS NULL AND ${MESSAGE_ENTRY_FILTER}
455
+ AND role IN ('user', 'assistant')
456
+ AND (? IS NULL OR julianday(created_at) > julianday(?))
457
+ AND order_index > COALESCE((SELECT order_index FROM c_chat_message WHERE id = ?), order_index - 1)
458
+ AND order_index < COALESCE((SELECT order_index FROM c_chat_message WHERE id = ?), order_index + 1)
459
+ AND (? IS NULL OR NOT (
460
+ COALESCE(json_extract(content, '$.metadata.gateway.sessionId'), '') = ?
461
+ OR id IN (SELECT parent_id FROM c_chat_message
462
+ WHERE type = ? AND source_id = ? AND parent_id IS NOT NULL
463
+ AND json_extract(content, '$.metadata.gateway.sessionId') = ?)
464
+ ))`;
465
+ const params = [
466
+ THREAD_CHAT_TYPE,
467
+ input.threadId,
468
+ MESSAGE_ENTRY_TYPE,
469
+ LEGACY_MESSAGE_ENTRY_TYPE,
470
+ since,
471
+ since,
472
+ after,
473
+ before,
474
+ ownSession,
475
+ ownSession,
476
+ THREAD_CHAT_TYPE,
477
+ input.threadId,
478
+ ownSession,
479
+ ];
480
+ const [rows, total] = await Promise.all([
481
+ db.getAll(`SELECT id, role, content, responser_id, created_at FROM c_chat_message WHERE ${where}
482
+ ORDER BY order_index DESC, created_at DESC, id DESC LIMIT ?`, [...params, input.limit]),
483
+ db.getOptional(`SELECT COUNT(*) AS count FROM c_chat_message WHERE ${where}`, params),
484
+ ]);
485
+ const parsed = rows.reverse().map((row) => {
486
+ const message = parseJson(row.content);
487
+ const responserId = message?.metadata?.responserId;
488
+ return {
489
+ row,
490
+ responserId: row.role === 'assistant' ? (typeof responserId === 'string' && responserId) || row.responser_id || null : null,
491
+ text: messageText(message),
492
+ };
493
+ });
494
+ const names = await readAgentNames(db, parsed.map((item) => item.responserId).filter((id) => Boolean(id)));
495
+ return {
496
+ messages: parsed
497
+ .filter((item) => item.text)
498
+ .map(({ row, responserId, text }) => ({
499
+ id: row.id,
500
+ role: row.role,
501
+ speaker: responserId ? (names.get(responserId) ?? null) : null,
502
+ text,
503
+ createdAt: row.created_at,
504
+ })),
505
+ earlier: Math.max(0, Number(total?.count ?? 0) - rows.length),
506
+ };
507
+ }
508
+ /** Agent (`c_project`) display names by id; unnamed agents are left out. */
509
+ async function readAgentNames(db, ids) {
510
+ const unique = [...new Set(ids)];
511
+ if (unique.length === 0)
512
+ return new Map();
513
+ const rows = await db.getAll(`SELECT id, name FROM c_project WHERE id IN (${unique.map(() => '?').join(', ')})`, unique);
514
+ return new Map(rows.filter((row) => row.name?.trim()).map((row) => [row.id, row.name.trim()]));
354
515
  }
355
516
  /** File parts resolve only to resources this user owns with an https storage URL. */
356
517
  export async function resolveTrustedResource(db, input) {
@@ -361,7 +522,13 @@ export async function resolveTrustedResource(db, input) {
361
522
  return { url: row.storage_url, name: row.name ?? 'attachment', mediaType: row.mime_type };
362
523
  }
363
524
  function robotMetaData(input) {
364
- return JSON.stringify({ gateway: { machineId: input.machineId, ...(input.agentId ? { agentId: input.agentId } : {}) } });
525
+ return JSON.stringify({
526
+ gateway: {
527
+ machineId: input.machineId,
528
+ ...(input.agentId ? { agentId: input.agentId } : {}),
529
+ ...(input.settings ? { settings: input.settings } : {}),
530
+ },
531
+ });
365
532
  }
366
533
  export async function upsertRobot(db, input) {
367
534
  const now = new Date().toISOString();