@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.
- package/dist/daemon/server.js +10 -0
- package/dist/gateway/adapters/acp.d.ts +10 -5
- package/dist/gateway/adapters/acp.js +30 -13
- package/dist/gateway/adapters/index.js +3 -2
- package/dist/gateway/config.d.ts +11 -1
- package/dist/gateway/config.js +38 -0
- package/dist/gateway/conversationContext.d.ts +44 -0
- package/dist/gateway/conversationContext.js +75 -0
- package/dist/gateway/deviceHost.d.ts +10 -1
- package/dist/gateway/deviceHost.js +119 -8
- package/dist/gateway/keepAwake.d.ts +24 -0
- package/dist/gateway/keepAwake.js +64 -0
- package/dist/gateway/mcpServer.js +20 -0
- package/dist/gateway/protocol.d.ts +88 -1
- package/dist/gateway/protocol.js +55 -0
- package/dist/gateway/runExecutor.d.ts +24 -0
- package/dist/gateway/runExecutor.js +118 -12
- package/dist/gateway/service.d.ts +4 -0
- package/dist/gateway/service.js +42 -12
- package/dist/gateway/threadStore.d.ts +71 -6
- package/dist/gateway/threadStore.js +189 -22
- package/dist/gateway/types.d.ts +22 -3
- package/dist/gateway/types.js +3 -2
- package/dist/gateway/workspaceFolders.d.ts +33 -0
- package/dist/gateway/workspaceFolders.js +129 -0
- package/package.json +1 -1
|
@@ -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;
|
package/dist/gateway/protocol.js
CHANGED
|
@@ -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 {
|
|
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 [
|
|
241
|
-
|
|
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
|
|
272
|
+
const topicId = topic?.id ?? null;
|
|
273
|
+
const continued = binding?.topicId === topicId ? binding : null;
|
|
245
274
|
const turns = [...merged, request];
|
|
246
|
-
const resources =
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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:
|
|
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 {};
|
package/dist/gateway/service.js
CHANGED
|
@@ -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
|
}
|