@dinoxx/dinox-cli 2.4.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ import { spawn } from 'node:child_process';
2
+ /**
3
+ * Keeps the computer from idle-sleeping while any agent turn runs, so a task
4
+ * started from the phone is not cut off when the screen locks. macOS only
5
+ * (`caffeinate -i`, released as soon as the last turn ends); elsewhere a no-op.
6
+ * It cannot stop sleep from closing a laptop lid on battery.
7
+ */
8
+ export class KeepAwake {
9
+ log;
10
+ platform;
11
+ spawnProcess;
12
+ holders = 0;
13
+ child = null;
14
+ constructor(log = () => { }, platform = process.platform, spawnProcess = (command, args) => spawn(command, args, { stdio: 'ignore' })) {
15
+ this.log = log;
16
+ this.platform = platform;
17
+ this.spawnProcess = spawnProcess;
18
+ }
19
+ /** Holds the computer awake until the returned release is called (idempotent). */
20
+ acquire() {
21
+ this.holders += 1;
22
+ if (this.holders === 1)
23
+ this.start();
24
+ let released = false;
25
+ return () => {
26
+ if (released)
27
+ return;
28
+ released = true;
29
+ this.holders -= 1;
30
+ if (this.holders === 0)
31
+ this.stop();
32
+ };
33
+ }
34
+ /** Releases everything, e.g. when the daemon stops. */
35
+ dispose() {
36
+ this.holders = 0;
37
+ this.stop();
38
+ }
39
+ start() {
40
+ if (this.platform !== 'darwin' || this.child)
41
+ return;
42
+ try {
43
+ const child = this.spawnProcess('caffeinate', ['-i']);
44
+ child.once('error', (error) => {
45
+ this.log('keep awake unavailable', { error: String(error) });
46
+ if (this.child === child)
47
+ this.child = null;
48
+ });
49
+ child.once('exit', () => {
50
+ if (this.child === child)
51
+ this.child = null;
52
+ });
53
+ this.child = child;
54
+ }
55
+ catch (error) {
56
+ this.log('keep awake unavailable', { error: String(error) });
57
+ }
58
+ }
59
+ stop() {
60
+ const child = this.child;
61
+ this.child = null;
62
+ child?.kill('SIGTERM');
63
+ }
64
+ }
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { z } from 'zod';
4
4
  import { sendDaemonRequest } from '../daemon/client.js';
5
+ import { READ_CONVERSATION_MAX_LIMIT } from './conversationContext.js';
5
6
  import { getPackageVersion } from '../utils/version.js';
6
7
  /**
7
8
  * Dinox knowledge tools for gateway agents, served over stdio MCP. The agent
@@ -109,6 +110,25 @@ export function createDinoxMcpServer(call = (method, params) => sendDaemonReques
109
110
  return failure(error);
110
111
  }
111
112
  });
113
+ server.registerTool('read_conversation', {
114
+ description: 'Read messages of a Dinox conversation, oldest first: who said what (the user, or which agent answered). ' +
115
+ 'Use it when the conversation context you were given says earlier messages are not shown. Returns the newest ' +
116
+ 'messages before `before`; pass the first returned id as `before` to page further back.',
117
+ inputSchema: {
118
+ conversationId: z.string().describe('Conversation id from the context block'),
119
+ before: z.string().optional().describe('Message id; only messages before it are returned'),
120
+ limit: z.number().int().min(1).max(READ_CONVERSATION_MAX_LIMIT).optional(),
121
+ },
122
+ }, async ({ conversationId, before, limit }) => {
123
+ try {
124
+ const result = (await call('thread.messages', { threadId: conversationId, beforeMessageId: before, limit }));
125
+ // `stale`: this machine is not fully synced, so the newest messages may be missing.
126
+ return text({ messages: result.messages, hasMore: result.hasMore, stale: result.stale === true });
127
+ }
128
+ catch (error) {
129
+ return failure(error);
130
+ }
131
+ });
112
132
  return server;
113
133
  }
114
134
  export async function serveDinoxMcpOverStdio() {
@@ -1,5 +1,5 @@
1
1
  import { REMOTE_DEVICE_PROTOCOL_VERSION, type RemoteDeviceCancelRequest, type RemoteDeviceRunRequest, type RemoteDeviceRunStatusRequest } from '../vendor/electron/shared/remote-device-agent.js';
2
- import type { GatewayConfigOption } from './types.js';
2
+ import type { GatewayApprovalMode, GatewayConfigOption } from './types.js';
3
3
  export declare function validateRunRequest(value: unknown, byteLength: number): string | null;
4
4
  export type AgentConfigQuery = {
5
5
  protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
@@ -20,6 +20,93 @@ export type AgentConfigResult = {
20
20
  };
21
21
  };
22
22
  export declare function isAgentConfigQuery(value: unknown): value is AgentConfigQuery;
23
+ export type AgentHostSettings = {
24
+ /** Folder new conversations start in; null until chosen (the home folder is used). */
25
+ cwd: string | null;
26
+ approval: GatewayApprovalMode;
27
+ /** Online but refusing new turns. */
28
+ paused: boolean;
29
+ /** The agent may search and read the user's notes. */
30
+ dinoxTools: boolean;
31
+ };
32
+ export type AgentSettingsChange = {
33
+ /** An existing folder (absolute or `~/...`), checked on this computer. */
34
+ cwd?: string;
35
+ /** Creates and uses the agent's own folder (`~/Dinox Agents/<name>`). */
36
+ useAgentFolder?: true;
37
+ approval?: GatewayApprovalMode;
38
+ paused?: boolean;
39
+ dinoxTools?: boolean;
40
+ };
41
+ export type AgentSettingsQuery = {
42
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
43
+ type: 'agent.settings.get';
44
+ requestId: string;
45
+ clientDeviceId: string;
46
+ };
47
+ export type AgentSettingsUpdate = {
48
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
49
+ type: 'agent.settings.set';
50
+ requestId: string;
51
+ clientDeviceId: string;
52
+ settings: AgentSettingsChange;
53
+ };
54
+ export type AgentSettingsErrorCode = 'invalid_request' | 'not_found' | 'not_allowed' | 'failed';
55
+ export type AgentSettingsResult = {
56
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
57
+ type: 'agent.settings.result';
58
+ requestId: string;
59
+ ok: boolean;
60
+ settings: AgentHostSettings;
61
+ /** For showing paths as `~/...`. */
62
+ home: string;
63
+ /** Where `useAgentFolder` puts the agent's own folder. */
64
+ agentFolder: string;
65
+ fetchedAt: string;
66
+ error?: {
67
+ code: AgentSettingsErrorCode;
68
+ message: string;
69
+ };
70
+ };
71
+ export type AgentFoldersQuery = {
72
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
73
+ type: 'agent.folders.list';
74
+ requestId: string;
75
+ clientDeviceId: string;
76
+ /** Folder to list; absent lists the home folder with suggestions. */
77
+ path?: string | null;
78
+ };
79
+ export type AgentFolder = {
80
+ name: string;
81
+ path: string;
82
+ isProject: boolean;
83
+ };
84
+ export type AgentFoldersResult = {
85
+ protocolVersion: typeof REMOTE_DEVICE_PROTOCOL_VERSION;
86
+ type: 'agent.folders.result';
87
+ requestId: string;
88
+ ok: boolean;
89
+ path: string;
90
+ parent: string | null;
91
+ folders: AgentFolder[];
92
+ truncated: boolean;
93
+ /** Only for the home listing: the current folder and recently used ones. */
94
+ suggestions: AgentFolder[];
95
+ home: string;
96
+ fetchedAt: string;
97
+ error?: {
98
+ code: AgentSettingsErrorCode;
99
+ message: string;
100
+ };
101
+ };
102
+ export declare function isAgentSettingsQuery(value: unknown): value is AgentSettingsQuery;
103
+ export declare function isAgentFoldersQuery(value: unknown): value is AgentFoldersQuery;
104
+ /** The change an `agent.settings.set` asks for, or why it is malformed. */
105
+ export declare function readAgentSettingsUpdate(value: unknown, byteLength: number): {
106
+ update: AgentSettingsUpdate;
107
+ } | {
108
+ error: string;
109
+ } | null;
23
110
  /** The run's chosen agent settings, `null` when none, `'invalid'` when malformed. */
24
111
  export declare function readAgentConfig(request: RemoteDeviceRunRequest): Record<string, string> | null | 'invalid';
25
112
  export declare function validateApprovalRequest(value: unknown, byteLength: number): string | null;
@@ -70,6 +70,61 @@ export function validateRunRequest(value, byteLength) {
70
70
  export function isAgentConfigQuery(value) {
71
71
  return isMessageOfType(value, 'agent.config.get') && isDeviceId(value.clientDeviceId);
72
72
  }
73
+ /*
74
+ * Gateway extension (`capabilities.executor.settings`): the owner reads and
75
+ * changes how this computer runs the agent with `agent.settings.get` /
76
+ * `agent.settings.set`, and picks a working folder with `agent.folders.list`.
77
+ */
78
+ const MAX_SETTINGS_REQUEST_BYTES = 16 * 1024;
79
+ const MAX_PATH_LENGTH = 4096;
80
+ export function isAgentSettingsQuery(value) {
81
+ return isMessageOfType(value, 'agent.settings.get') && isDeviceId(value.clientDeviceId);
82
+ }
83
+ export function isAgentFoldersQuery(value) {
84
+ if (!isMessageOfType(value, 'agent.folders.list') || !isDeviceId(value.clientDeviceId))
85
+ return false;
86
+ const folder = value.path;
87
+ return folder === undefined || folder === null || (typeof folder === 'string' && folder.length <= MAX_PATH_LENGTH);
88
+ }
89
+ /** The change an `agent.settings.set` asks for, or why it is malformed. */
90
+ export function readAgentSettingsUpdate(value, byteLength) {
91
+ if (!isMessageOfType(value, 'agent.settings.set'))
92
+ return null;
93
+ if (byteLength > MAX_SETTINGS_REQUEST_BYTES)
94
+ return { error: 'request too large' };
95
+ if (!isDeviceId(value.clientDeviceId))
96
+ return { error: 'invalid clientDeviceId' };
97
+ const raw = value.settings;
98
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
99
+ return { error: 'invalid settings' };
100
+ const input = raw;
101
+ const change = {};
102
+ if (input.cwd !== undefined) {
103
+ if (typeof input.cwd !== 'string' || !input.cwd.trim() || input.cwd.length > MAX_PATH_LENGTH)
104
+ return { error: 'invalid cwd' };
105
+ change.cwd = input.cwd;
106
+ }
107
+ if (input.useAgentFolder !== undefined) {
108
+ if (input.useAgentFolder !== true || change.cwd !== undefined)
109
+ return { error: 'invalid useAgentFolder' };
110
+ change.useAgentFolder = true;
111
+ }
112
+ if (input.approval !== undefined) {
113
+ if (input.approval !== 'ask' && input.approval !== 'auto_read')
114
+ return { error: 'invalid approval' };
115
+ change.approval = input.approval;
116
+ }
117
+ for (const key of ['paused', 'dinoxTools']) {
118
+ if (input[key] === undefined)
119
+ continue;
120
+ if (typeof input[key] !== 'boolean')
121
+ return { error: `invalid ${key}` };
122
+ change[key] = input[key];
123
+ }
124
+ if (Object.keys(change).length === 0)
125
+ return { error: 'nothing to change' };
126
+ return { update: { ...value, settings: change } };
127
+ }
73
128
  /** The run's chosen agent settings, `null` when none, `'invalid'` when malformed. */
74
129
  export function readAgentConfig(request) {
75
130
  const raw = request.agentConfig;
@@ -4,6 +4,8 @@ import type { RunSnapshot } from './streamPublisher.js';
4
4
  import type { GatewayAdapter, GatewayDefinition } from './types.js';
5
5
  /** Turns waiting behind a thread's active one; more are refused as `busy`. */
6
6
  export declare const MAX_QUEUED_TURNS = 10;
7
+ /** How long a turn waits for the client's latest messages to sync to this machine. */
8
+ export declare const CLIENT_TAIL_SYNC_TIMEOUT_MS = 5000;
7
9
  type Logger = (message: string, details?: Record<string, unknown>) => void;
8
10
  type Emit = (snapshot: RunSnapshot) => void;
9
11
  export declare class QueueFullError extends Error {
@@ -15,13 +17,21 @@ export type RunExecutorDeps = {
15
17
  gateway: GatewayDefinition;
16
18
  adapter: GatewayAdapter;
17
19
  log: Logger;
20
+ /** Overrides CLIENT_TAIL_SYNC_TIMEOUT_MS (tests). */
21
+ clientTailSyncTimeoutMs?: number;
22
+ /** Keeps the computer awake while a turn runs; returns the release. */
23
+ keepAwake?: () => () => void;
18
24
  };
25
+ /** The user's instructions for the agent, as the start of a new session. */
26
+ export declare function agentInstructionsPreamble(instructions: string): string | undefined;
19
27
  /**
20
28
  * Runs accepted turns on one gateway. A thread has at most one active run;
21
29
  * turns sent meanwhile wait in a per-thread FIFO and, when it ends, run
22
30
  * together as one turn (each keeps its own message; the reply follows the
23
31
  * last). The agent session is scoped to the thread's current topic: a "new
24
32
  * topic" marker in Dinox starts a fresh agent session, everything else continues it.
33
+ * The thread may have been answered by other agents meanwhile, so each turn
34
+ * also carries the messages this gateway's session has not seen.
25
35
  */
26
36
  export declare class GatewayRunExecutor {
27
37
  private readonly deps;
@@ -32,6 +42,7 @@ export declare class GatewayRunExecutor {
32
42
  private queueIndex;
33
43
  /** Accepts of one thread run one at a time, so two can never both start a turn. */
34
44
  private readonly acceptChains;
45
+ private readonly missingTails;
35
46
  constructor(deps: RunExecutorDeps);
36
47
  /** Threads with an active or waiting turn. */
37
48
  busyThreadIds(): string[];
@@ -62,6 +73,19 @@ export declare class GatewayRunExecutor {
62
73
  approved: boolean;
63
74
  }): RemoteDeviceToolApprovalAckCode;
64
75
  private execute;
76
+ /**
77
+ * The client sends the last message it saw; a turn reads the transcript only
78
+ * once that message has synced here (its upload may still be in flight), or
79
+ * gives up after a short wait. A tail that never arrived is not waited for again.
80
+ */
81
+ private clientTailSynced;
82
+ /**
83
+ * Messages before this turn that the session has not seen: the whole topic
84
+ * for a new session, those after the session's mark for a continued one,
85
+ * leaving out the turns the session answered itself. A session bound before
86
+ * seen messages were tracked is assumed to be up to date.
87
+ */
88
+ private unseenContext;
65
89
  private resolveResources;
66
90
  }
67
91
  export {};
@@ -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,9 +1,20 @@
1
1
  import os from 'node:os';
2
2
  import { getPackageVersion } from '../utils/version.js';
3
3
  import { createGatewayAdapter } from './adapters/index.js';
4
- import { getMachineId, loadGateways } from './config.js';
4
+ import { getMachineId, loadGateways, updateGatewaySettings } from './config.js';
5
5
  import { GatewayDeviceHost } from './deviceHost.js';
6
+ import { KeepAwake } from './keepAwake.js';
6
7
  import { interruptOrphanedRuns, retireRemovedRobots, upsertRobot } from './threadStore.js';
8
+ /** Settings the owner changes from the app; the running gateway picks them up without a restart. */
9
+ const LIVE_SETTINGS = ['cwd', 'previousCwd', 'approval', 'paused', 'dinoxTools'];
10
+ function applyLiveSettings(target, source) {
11
+ for (const key of LIVE_SETTINGS) {
12
+ if (source[key] === undefined)
13
+ delete target[key];
14
+ else
15
+ target[key] = source[key];
16
+ }
17
+ }
7
18
  const defaultLog = (message, details) => {
8
19
  process.stderr.write(`[dinox-gateway] ${message}${details ? ` ${JSON.stringify(details)}` : ''}\n`);
9
20
  };
@@ -15,9 +26,11 @@ export class GatewayService {
15
26
  deps;
16
27
  hosts = [];
17
28
  log;
29
+ keepAwake;
18
30
  constructor(deps) {
19
31
  this.deps = deps;
20
32
  this.log = deps.log ?? defaultLog;
33
+ this.keepAwake = new KeepAwake(this.log);
21
34
  }
22
35
  async start() {
23
36
  const configured = await loadGateways(this.deps.userId);
@@ -40,17 +53,7 @@ export class GatewayService {
40
53
  if (interrupted > 0)
41
54
  this.log('marked runs from a previous host as interrupted', { count: interrupted });
42
55
  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) }));
56
+ await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('register robot failed', { gateway: gateway.name, error: String(error) }));
54
57
  const host = new GatewayDeviceHost({
55
58
  gateway,
56
59
  db: this.deps.db,
@@ -60,6 +63,8 @@ export class GatewayService {
60
63
  appVersion: getPackageVersion(),
61
64
  log: (message, details) => this.log(message, { gateway: gateway.name, ...details }),
62
65
  credentialsUrl: this.deps.credentialsUrl,
66
+ updateSettings: (change) => this.updateSettings(gateway, machineId, change),
67
+ keepAwake: () => this.keepAwake.acquire(),
63
68
  });
64
69
  host.start();
65
70
  this.hosts.push(host);
@@ -69,6 +74,7 @@ export class GatewayService {
69
74
  const hosts = this.hosts;
70
75
  this.hosts = [];
71
76
  await Promise.allSettled(hosts.map((host) => host.stop()));
77
+ this.keepAwake.dispose();
72
78
  }
73
79
  /** Re-reads the account's gateways (after add/remove) without restarting the daemon. */
74
80
  async reload() {
@@ -76,6 +82,30 @@ export class GatewayService {
76
82
  await this.start();
77
83
  return this.status();
78
84
  }
85
+ /** Saves a change made in the app, applies it to the running gateway and syncs the robot row. */
86
+ async updateSettings(gateway, machineId, change) {
87
+ const updated = await updateGatewaySettings(this.deps.userId, gateway.id, change);
88
+ applyLiveSettings(gateway, updated);
89
+ await upsertRobot(this.deps.db, this.robotRegistration(gateway, machineId)).catch((error) => this.log('sync robot settings failed', { gateway: gateway.name, error: String(error) }));
90
+ }
91
+ robotRegistration(gateway, machineId) {
92
+ return {
93
+ id: gateway.id,
94
+ userId: this.deps.userId,
95
+ name: gateway.name,
96
+ deviceId: gateway.deviceId,
97
+ agentType: gateway.kind,
98
+ cwd: gateway.cwd ?? null,
99
+ deviceName: os.hostname(),
100
+ machineId,
101
+ ...(gateway.agentId ? { agentId: gateway.agentId } : {}),
102
+ settings: {
103
+ approval: gateway.approval ?? 'ask',
104
+ paused: gateway.paused === true,
105
+ dinoxTools: gateway.dinoxTools !== false,
106
+ },
107
+ };
108
+ }
79
109
  status() {
80
110
  return this.hosts.map((host) => host.status());
81
111
  }