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