@dinoxx/dinox-cli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -3
- package/dist/cli.js +6 -0
- package/dist/commands/domain/options.d.ts +10 -0
- package/dist/commands/domain/options.js +75 -0
- package/dist/commands/domain/run.d.ts +24 -0
- package/dist/commands/domain/run.js +137 -0
- package/dist/commands/gateway/index.d.ts +4 -0
- package/dist/commands/gateway/index.js +300 -0
- package/dist/commands/notes/actions.js +47 -18
- package/dist/commands/notes/createFromTemplate.d.ts +35 -0
- package/dist/commands/notes/createFromTemplate.js +58 -0
- package/dist/commands/notes/descriptors.js +9 -4
- package/dist/commands/notes/repo/read.js +4 -23
- package/dist/commands/notes/repo/write.d.ts +2 -0
- package/dist/commands/notes/repo/write.js +1 -1
- package/dist/commands/properties/index.d.ts +16 -0
- package/dist/commands/properties/index.js +640 -0
- package/dist/commands/properties/repo.d.ts +150 -0
- package/dist/commands/properties/repo.js +272 -0
- package/dist/commands/savedViews/fields.d.ts +28 -0
- package/dist/commands/savedViews/fields.js +87 -0
- package/dist/commands/savedViews/index.js +203 -164
- package/dist/commands/savedViews/model.d.ts +27 -69
- package/dist/commands/savedViews/model.js +63 -279
- package/dist/commands/savedViews/repo.d.ts +64 -62
- package/dist/commands/savedViews/repo.js +309 -428
- package/dist/commands/schema/renderSkills.js +33 -5
- package/dist/commands/schema/spec.js +6 -0
- package/dist/commands/templates/index.d.ts +4 -0
- package/dist/commands/templates/index.js +290 -0
- package/dist/commands/templates/model.d.ts +12 -34
- package/dist/commands/templates/model.js +39 -83
- package/dist/commands/templates/repo.d.ts +53 -0
- package/dist/commands/templates/repo.js +78 -0
- package/dist/config/paths.d.ts +1 -0
- package/dist/config/paths.js +1 -1
- package/dist/daemon/server.d.ts +1 -0
- package/dist/daemon/server.js +31 -0
- package/dist/gateway/adapters/acp.d.ts +42 -0
- package/dist/gateway/adapters/acp.js +285 -0
- package/dist/gateway/adapters/echo.d.ts +12 -0
- package/dist/gateway/adapters/echo.js +51 -0
- package/dist/gateway/adapters/index.d.ts +9 -0
- package/dist/gateway/adapters/index.js +30 -0
- package/dist/gateway/assistantProjection.d.ts +32 -0
- package/dist/gateway/assistantProjection.js +193 -0
- package/dist/gateway/config.d.ts +50 -0
- package/dist/gateway/config.js +253 -0
- package/dist/gateway/detect.d.ts +40 -0
- package/dist/gateway/detect.js +145 -0
- package/dist/gateway/deviceHost.d.ts +82 -0
- package/dist/gateway/deviceHost.js +530 -0
- package/dist/gateway/mcpServer.d.ts +4 -0
- package/dist/gateway/mcpServer.js +118 -0
- package/dist/gateway/protocol.d.ts +29 -0
- package/dist/gateway/protocol.js +127 -0
- package/dist/gateway/runExecutor.d.ts +67 -0
- package/dist/gateway/runExecutor.js +316 -0
- package/dist/gateway/service.d.ts +26 -0
- package/dist/gateway/service.js +82 -0
- package/dist/gateway/streamPublisher.d.ts +33 -0
- package/dist/gateway/streamPublisher.js +93 -0
- package/dist/gateway/threadStore.d.ts +157 -0
- package/dist/gateway/threadStore.js +430 -0
- package/dist/gateway/types.d.ts +117 -0
- package/dist/gateway/types.js +9 -0
- package/dist/skills/registry.js +7 -0
- package/dist/utils/errorCodes.d.ts +212 -0
- package/dist/utils/errorCodes.js +53 -0
- package/dist/vendor/electron/domain/note-templates.d.ts +3 -0
- package/dist/vendor/electron/domain/note-templates.js +232 -0
- package/dist/vendor/electron/domain/properties.d.ts +10 -0
- package/dist/vendor/electron/domain/properties.js +1549 -0
- package/dist/vendor/electron/domain/saved-view-analytics.d.ts +45 -0
- package/dist/vendor/electron/domain/saved-view-analytics.js +365 -0
- package/dist/vendor/electron/domain/saved-view-relative-date.d.ts +21 -0
- package/dist/vendor/electron/domain/saved-view-relative-date.js +153 -0
- package/dist/vendor/electron/domain/saved-views.d.ts +61 -0
- package/dist/vendor/electron/domain/saved-views.js +1120 -0
- package/dist/vendor/electron/rpc.d.ts +22 -0
- package/dist/vendor/electron/rpc.js +27 -0
- package/dist/vendor/electron/shared/ai-errors.d.ts +11 -0
- package/dist/vendor/electron/shared/ai-errors.js +107 -0
- package/dist/vendor/electron/shared/external-types.d.ts +20 -0
- package/dist/vendor/electron/shared/external-types.js +1 -0
- package/dist/vendor/electron/shared/index.d.ts +9 -0
- package/dist/vendor/electron/shared/index.js +7 -0
- package/dist/vendor/electron/shared/note-properties.d.ts +328 -0
- package/dist/vendor/electron/shared/note-properties.js +1027 -0
- package/dist/vendor/electron/shared/note-templates.d.ts +82 -0
- package/dist/vendor/electron/shared/note-templates.js +84 -0
- package/dist/vendor/electron/shared/powersync-read-models.d.ts +1716 -0
- package/dist/vendor/electron/shared/powersync-read-models.js +1 -0
- package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
- package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
- package/dist/vendor/electron/shared/saved-views.d.ts +394 -0
- package/dist/vendor/electron/shared/saved-views.js +981 -0
- package/dist/vendor/electron/sql-utils.d.ts +5 -0
- package/dist/vendor/electron/sql-utils.js +32 -0
- package/package.json +10 -4
- package/dist/commands/savedViews/semantics.d.ts +0 -46
- package/dist/commands/savedViews/semantics.js +0 -738
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Folds normalized agent events into the AI SDK `UIMessage` the app renders.
|
|
3
|
+
* Agent tools become read-only `dynamic-tool` parts (they ran on the agent's
|
|
4
|
+
* machine); approvals use the same part/metadata shape as desktop so the app's
|
|
5
|
+
* approval bar and `run.status` recovery work unchanged.
|
|
6
|
+
*/
|
|
7
|
+
const APPROVAL_TTL_MS = 30 * 60 * 1000;
|
|
8
|
+
const MAX_PENDING_APPROVALS = 32;
|
|
9
|
+
const PREVIEW_MAX_CHARS = 1500;
|
|
10
|
+
const SENSITIVE_KEY = /password|token|secret|api[-_ ]?key|authorization|cookie|credential|private[-_ ]?key/i;
|
|
11
|
+
export class AssistantProjection {
|
|
12
|
+
base;
|
|
13
|
+
parts = [];
|
|
14
|
+
toolIndex = new Map();
|
|
15
|
+
approvals = {};
|
|
16
|
+
revisionCounter = 0;
|
|
17
|
+
constructor(base) {
|
|
18
|
+
this.base = base;
|
|
19
|
+
}
|
|
20
|
+
get revision() {
|
|
21
|
+
return this.revisionCounter;
|
|
22
|
+
}
|
|
23
|
+
apply(event) {
|
|
24
|
+
switch (event.type) {
|
|
25
|
+
case 'text-delta':
|
|
26
|
+
this.appendText('text', event.text);
|
|
27
|
+
break;
|
|
28
|
+
case 'reasoning-delta':
|
|
29
|
+
this.appendText('reasoning', event.text);
|
|
30
|
+
break;
|
|
31
|
+
case 'tool':
|
|
32
|
+
this.applyTool(event);
|
|
33
|
+
break;
|
|
34
|
+
case 'approval-request': {
|
|
35
|
+
const requestedAt = new Date().toISOString();
|
|
36
|
+
this.approvals[event.approvalId] = {
|
|
37
|
+
requestedAt,
|
|
38
|
+
expiresAt: new Date(Date.parse(requestedAt) + APPROVAL_TTL_MS).toISOString(),
|
|
39
|
+
toolName: event.toolName,
|
|
40
|
+
toolCallId: event.toolCallId,
|
|
41
|
+
};
|
|
42
|
+
const part = this.ensureTool(event.toolCallId, event.toolName);
|
|
43
|
+
part.state = 'approval-requested';
|
|
44
|
+
part.approval = { id: event.approvalId };
|
|
45
|
+
if (event.input !== undefined)
|
|
46
|
+
part.input = event.input;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'approval-resolved': {
|
|
50
|
+
const part = this.findToolByApproval(event.approvalId);
|
|
51
|
+
if (part) {
|
|
52
|
+
part.approval = { id: event.approvalId, approved: event.approved };
|
|
53
|
+
part.state = event.approved ? 'approval-responded' : 'output-denied';
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
case 'usage':
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.revisionCounter += 1;
|
|
61
|
+
}
|
|
62
|
+
/** Closes streaming parts; called once when the turn ends. */
|
|
63
|
+
finish() {
|
|
64
|
+
for (const part of this.parts) {
|
|
65
|
+
if ((part.type === 'text' || part.type === 'reasoning') && part.state === 'streaming')
|
|
66
|
+
part.state = 'done';
|
|
67
|
+
}
|
|
68
|
+
this.revisionCounter += 1;
|
|
69
|
+
}
|
|
70
|
+
snapshot() {
|
|
71
|
+
return {
|
|
72
|
+
...this.base,
|
|
73
|
+
role: 'assistant',
|
|
74
|
+
parts: this.parts.map((part) => ({ ...part })),
|
|
75
|
+
metadata: {
|
|
76
|
+
...(this.base.metadata ?? {}),
|
|
77
|
+
...(Object.keys(this.approvals).length ? { toolApprovals: { ...this.approvals } } : {}),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
appendText(type, text) {
|
|
82
|
+
if (!text)
|
|
83
|
+
return;
|
|
84
|
+
const last = this.parts.at(-1);
|
|
85
|
+
if (last?.type === type && last.state === 'streaming') {
|
|
86
|
+
last.text = `${String(last.text ?? '')}${text}`;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.parts.push({ type, text, state: 'streaming' });
|
|
90
|
+
}
|
|
91
|
+
ensureTool(toolCallId, toolName) {
|
|
92
|
+
const index = this.toolIndex.get(toolCallId);
|
|
93
|
+
if (index !== undefined)
|
|
94
|
+
return this.parts[index];
|
|
95
|
+
// A tool call closes the text segment before it.
|
|
96
|
+
const last = this.parts.at(-1);
|
|
97
|
+
if (last && (last.type === 'text' || last.type === 'reasoning'))
|
|
98
|
+
last.state = 'done';
|
|
99
|
+
const part = { type: 'dynamic-tool', toolCallId, toolName, state: 'input-streaming', input: undefined };
|
|
100
|
+
this.toolIndex.set(toolCallId, this.parts.push(part) - 1);
|
|
101
|
+
return part;
|
|
102
|
+
}
|
|
103
|
+
findToolByApproval(approvalId) {
|
|
104
|
+
return this.parts.find((part) => part.type === 'dynamic-tool' && part.approval?.id === approvalId);
|
|
105
|
+
}
|
|
106
|
+
applyTool(event) {
|
|
107
|
+
const part = this.ensureTool(event.toolCallId, event.title || 'tool');
|
|
108
|
+
if (event.title)
|
|
109
|
+
part.toolName = event.title;
|
|
110
|
+
if (event.input !== undefined)
|
|
111
|
+
part.input = event.input;
|
|
112
|
+
switch (event.status) {
|
|
113
|
+
case 'completed':
|
|
114
|
+
part.state = 'output-available';
|
|
115
|
+
part.output = event.output ?? part.output ?? null;
|
|
116
|
+
break;
|
|
117
|
+
case 'failed':
|
|
118
|
+
part.state = 'output-error';
|
|
119
|
+
part.errorText = typeof event.output === 'string' ? event.output : 'Tool failed';
|
|
120
|
+
break;
|
|
121
|
+
case 'pending':
|
|
122
|
+
case 'running':
|
|
123
|
+
if (part.state !== 'approval-requested')
|
|
124
|
+
part.state = 'input-available';
|
|
125
|
+
if (event.output !== undefined)
|
|
126
|
+
part.output = event.output;
|
|
127
|
+
break;
|
|
128
|
+
default:
|
|
129
|
+
if (event.output !== undefined)
|
|
130
|
+
part.output = event.output;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function redact(value, depth) {
|
|
135
|
+
if (depth > 4)
|
|
136
|
+
return '[…]';
|
|
137
|
+
if (Array.isArray(value))
|
|
138
|
+
return value.slice(0, 16).map((item) => redact(item, depth + 1));
|
|
139
|
+
if (value && typeof value === 'object') {
|
|
140
|
+
return Object.fromEntries(Object.entries(value)
|
|
141
|
+
.slice(0, 24)
|
|
142
|
+
.map(([key, item]) => [key, SENSITIVE_KEY.test(key) ? '[redacted]' : redact(item, depth + 1)]));
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/** Same projection the desktop host sends: open approval requests found in the snapshot. */
|
|
147
|
+
/**
|
|
148
|
+
* The reply as it is kept in the synced transcript. A computer agent's
|
|
149
|
+
* reasoning and tool calls are working state: they stream to the client while
|
|
150
|
+
* the turn runs and stay in the local run snapshot, but only the answer is
|
|
151
|
+
* stored in `c_chat_message`.
|
|
152
|
+
*/
|
|
153
|
+
export function toTranscriptMessage(message) {
|
|
154
|
+
const { toolApprovals: _approvals, ...metadata } = (message.metadata ?? {});
|
|
155
|
+
return {
|
|
156
|
+
...message,
|
|
157
|
+
parts: message.parts.filter((part) => part.type === 'text' || part.type === 'file'),
|
|
158
|
+
metadata,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function projectPendingApprovals(message, ids) {
|
|
162
|
+
if (!message)
|
|
163
|
+
return [];
|
|
164
|
+
const approvals = (message.metadata?.toolApprovals ?? {});
|
|
165
|
+
const prompts = [];
|
|
166
|
+
for (const part of message.parts) {
|
|
167
|
+
if (prompts.length >= MAX_PENDING_APPROVALS)
|
|
168
|
+
break;
|
|
169
|
+
const approvalId = part.approval?.id;
|
|
170
|
+
if (part.state !== 'approval-requested' || typeof approvalId !== 'string')
|
|
171
|
+
continue;
|
|
172
|
+
const meta = approvals[approvalId] ?? {};
|
|
173
|
+
const requestedAt = meta.requestedAt ?? new Date().toISOString();
|
|
174
|
+
let inputPreview = '';
|
|
175
|
+
try {
|
|
176
|
+
inputPreview = JSON.stringify(redact(part.input, 0)) ?? '';
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
inputPreview = '';
|
|
180
|
+
}
|
|
181
|
+
prompts.push({
|
|
182
|
+
approvalId,
|
|
183
|
+
runId: ids.runId,
|
|
184
|
+
threadId: ids.threadId,
|
|
185
|
+
toolCallId: String(part.toolCallId ?? meta.toolCallId ?? ''),
|
|
186
|
+
toolName: String(part.toolName ?? meta.toolName ?? 'tool'),
|
|
187
|
+
inputPreview: inputPreview.slice(0, PREVIEW_MAX_CHARS),
|
|
188
|
+
requestedAt,
|
|
189
|
+
expiresAt: meta.expiresAt ?? new Date(Date.parse(requestedAt) + APPROVAL_TTL_MS).toISOString(),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return prompts;
|
|
193
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GatewayDefinition, GatewayKind } from './types.js';
|
|
2
|
+
/** Gateways belong to an account: each one registers a robot for that user. */
|
|
3
|
+
export declare function getGatewaysFilePath(userId: string): string;
|
|
4
|
+
/**
|
|
5
|
+
* A random id for this installation, created once. Unlike the hostname it
|
|
6
|
+
* never changes, so it can scope this machine's robots.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getMachineId(): Promise<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Deterministic per (account, machine, agent): re-adding an agent after a removal
|
|
11
|
+
* or a lost config revives the same robot instead of registering a duplicate,
|
|
12
|
+
* and the app's bindings to it keep working.
|
|
13
|
+
*/
|
|
14
|
+
export declare function deriveGatewayIdentity(input: {
|
|
15
|
+
userId: string;
|
|
16
|
+
machineId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
agentId?: string;
|
|
19
|
+
}): {
|
|
20
|
+
id: string;
|
|
21
|
+
deviceId: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function loadGateways(userId: string): Promise<GatewayDefinition[]>;
|
|
24
|
+
/** Atomic, owner-only write: the file can hold agent commands and environment secrets. */
|
|
25
|
+
export declare function saveGateways(userId: string, gateways: GatewayDefinition[]): Promise<void>;
|
|
26
|
+
export declare function addGateway(userId: string, input: {
|
|
27
|
+
name: string;
|
|
28
|
+
kind: GatewayKind;
|
|
29
|
+
agentId?: string;
|
|
30
|
+
command?: string;
|
|
31
|
+
args?: string[];
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
}): Promise<GatewayDefinition>;
|
|
35
|
+
/**
|
|
36
|
+
* Hosts a detected agent once. An agent already hosted, matched by type (or, for
|
|
37
|
+
* gateways from before types were recorded, by executable name and arguments),
|
|
38
|
+
* is updated in place when its executable moved, e.g. after a Node upgrade.
|
|
39
|
+
*/
|
|
40
|
+
export declare function adoptDetectedAgent(userId: string, agent: {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
command: string;
|
|
44
|
+
args: string[];
|
|
45
|
+
}): Promise<{
|
|
46
|
+
gateway: GatewayDefinition;
|
|
47
|
+
change: 'added' | 'updated' | 'unchanged';
|
|
48
|
+
}>;
|
|
49
|
+
export declare function findGateway(gateways: GatewayDefinition[], ref: string): GatewayDefinition | undefined;
|
|
50
|
+
export declare function removeGateway(userId: string, ref: string): Promise<GatewayDefinition>;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getConfigDir, normalizeUserPathSegment } from '../config/paths.js';
|
|
5
|
+
import { DinoxError } from '../utils/errors.js';
|
|
6
|
+
import { generateId } from '../utils/id.js';
|
|
7
|
+
const GATEWAYS_FILE = 'gateways.json';
|
|
8
|
+
const MACHINE_ID_FILE = 'machine-id';
|
|
9
|
+
const GATEWAYS_FILE_VERSION = 1;
|
|
10
|
+
const DEVICE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
11
|
+
const NAME_MAX_LENGTH = 64;
|
|
12
|
+
/** Gateways belong to an account: each one registers a robot for that user. */
|
|
13
|
+
export function getGatewaysFilePath(userId) {
|
|
14
|
+
return path.join(getConfigDir(), 'users', normalizeUserPathSegment(requireUserId(userId)), GATEWAYS_FILE);
|
|
15
|
+
}
|
|
16
|
+
/** Pre-account location; adopted by the first account that loads its gateways. */
|
|
17
|
+
function getLegacyGatewaysFilePath() {
|
|
18
|
+
return path.join(getConfigDir(), GATEWAYS_FILE);
|
|
19
|
+
}
|
|
20
|
+
function requireUserId(userId) {
|
|
21
|
+
const normalized = userId?.trim();
|
|
22
|
+
if (!normalized) {
|
|
23
|
+
throw new DinoxError('Log in first: gateways belong to a Dinox account (`dino auth login`)', {
|
|
24
|
+
code: 'UNAUTHENTICATED',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A random id for this installation, created once. Unlike the hostname it
|
|
31
|
+
* never changes, so it can scope this machine's robots.
|
|
32
|
+
*/
|
|
33
|
+
export async function getMachineId() {
|
|
34
|
+
const filePath = path.join(getConfigDir(), MACHINE_ID_FILE);
|
|
35
|
+
try {
|
|
36
|
+
const existing = (await fs.readFile(filePath, 'utf8')).trim();
|
|
37
|
+
if (existing)
|
|
38
|
+
return existing;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code !== 'ENOENT')
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const machineId = generateId();
|
|
45
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
46
|
+
try {
|
|
47
|
+
await fs.writeFile(filePath, `${machineId}\n`, { mode: 0o600, flag: 'wx' });
|
|
48
|
+
return machineId;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// Another process created it first; use theirs.
|
|
52
|
+
if (error.code !== 'EEXIST')
|
|
53
|
+
throw error;
|
|
54
|
+
return (await fs.readFile(filePath, 'utf8')).trim();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function digest(...parts) {
|
|
58
|
+
return crypto.createHash('sha256').update(parts.join('\u0000')).digest('hex');
|
|
59
|
+
}
|
|
60
|
+
/** UUID-shaped (version 8, name-based) so it fits c_robot.id like other ids. */
|
|
61
|
+
function uuidFromHash(hex) {
|
|
62
|
+
const variant = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16);
|
|
63
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
64
|
+
}
|
|
65
|
+
/** What makes a gateway "the same" across re-adds: its agent type, else its name. */
|
|
66
|
+
function identityKey(input) {
|
|
67
|
+
return input.agentId ? `agent:${input.agentId}` : `name:${input.name.toLowerCase()}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Deterministic per (account, machine, agent): re-adding an agent after a removal
|
|
71
|
+
* or a lost config revives the same robot instead of registering a duplicate,
|
|
72
|
+
* and the app's bindings to it keep working.
|
|
73
|
+
*/
|
|
74
|
+
export function deriveGatewayIdentity(input) {
|
|
75
|
+
const key = identityKey(input);
|
|
76
|
+
const id = uuidFromHash(digest('dinox-gateway-id', input.userId, input.machineId, key));
|
|
77
|
+
const deviceId = `gw-${slugify(input.agentId ?? input.name)}-${digest('dinox-gateway-device', input.userId, input.machineId, key).slice(0, 8)}`;
|
|
78
|
+
if (!DEVICE_ID_PATTERN.test(deviceId)) {
|
|
79
|
+
throw new DinoxError(`Invalid gateway device id: ${deviceId}`, { code: 'INVALID_ARGUMENT' });
|
|
80
|
+
}
|
|
81
|
+
return { id, deviceId };
|
|
82
|
+
}
|
|
83
|
+
function normalizeName(value) {
|
|
84
|
+
const name = value.replace(/[\u0000-\u001f\u007f]/g, '').trim();
|
|
85
|
+
if (!name) {
|
|
86
|
+
throw new DinoxError('Gateway name is required', { code: 'INVALID_ARGUMENT' });
|
|
87
|
+
}
|
|
88
|
+
return name.slice(0, NAME_MAX_LENGTH);
|
|
89
|
+
}
|
|
90
|
+
function slugify(value) {
|
|
91
|
+
const slug = value
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
94
|
+
.replace(/^-+|-+$/g, '')
|
|
95
|
+
.slice(0, 24);
|
|
96
|
+
return slug || 'agent';
|
|
97
|
+
}
|
|
98
|
+
function parseGateway(value) {
|
|
99
|
+
if (!value || typeof value !== 'object')
|
|
100
|
+
return null;
|
|
101
|
+
const raw = value;
|
|
102
|
+
const kind = raw.kind === 'acp' || raw.kind === 'echo' ? raw.kind : null;
|
|
103
|
+
if (typeof raw.id !== 'string' ||
|
|
104
|
+
typeof raw.name !== 'string' ||
|
|
105
|
+
typeof raw.deviceId !== 'string' ||
|
|
106
|
+
!DEVICE_ID_PATTERN.test(raw.deviceId) ||
|
|
107
|
+
!kind) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const env = raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env)
|
|
111
|
+
? Object.fromEntries(Object.entries(raw.env).filter((entry) => typeof entry[1] === 'string'))
|
|
112
|
+
: undefined;
|
|
113
|
+
return {
|
|
114
|
+
id: raw.id,
|
|
115
|
+
name: raw.name,
|
|
116
|
+
kind,
|
|
117
|
+
deviceId: raw.deviceId,
|
|
118
|
+
...(typeof raw.agentId === 'string' && raw.agentId ? { agentId: raw.agentId } : {}),
|
|
119
|
+
...(typeof raw.command === 'string' ? { command: raw.command } : {}),
|
|
120
|
+
...(Array.isArray(raw.args) ? { args: raw.args.filter((arg) => typeof arg === 'string') } : {}),
|
|
121
|
+
...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}),
|
|
122
|
+
...(env ? { env } : {}),
|
|
123
|
+
enabled: raw.enabled !== false,
|
|
124
|
+
...(raw.dinoxTools === false ? { dinoxTools: false } : {}),
|
|
125
|
+
createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : new Date(0).toISOString(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async function readGatewaysFile(filePath) {
|
|
129
|
+
try {
|
|
130
|
+
return await fs.readFile(filePath, 'utf8');
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (error.code === 'ENOENT')
|
|
134
|
+
return null;
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function loadGateways(userId) {
|
|
139
|
+
const filePath = getGatewaysFilePath(userId);
|
|
140
|
+
let text = await readGatewaysFile(filePath);
|
|
141
|
+
if (text === null) {
|
|
142
|
+
const legacyPath = getLegacyGatewaysFilePath();
|
|
143
|
+
text = await readGatewaysFile(legacyPath);
|
|
144
|
+
if (text === null)
|
|
145
|
+
return [];
|
|
146
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
147
|
+
await fs.rename(legacyPath, filePath);
|
|
148
|
+
}
|
|
149
|
+
const parsed = JSON.parse(text);
|
|
150
|
+
return (Array.isArray(parsed.gateways) ? parsed.gateways : [])
|
|
151
|
+
.map(parseGateway)
|
|
152
|
+
.filter((gateway) => gateway !== null);
|
|
153
|
+
}
|
|
154
|
+
/** Atomic, owner-only write: the file can hold agent commands and environment secrets. */
|
|
155
|
+
export async function saveGateways(userId, gateways) {
|
|
156
|
+
const filePath = getGatewaysFilePath(userId);
|
|
157
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
158
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
159
|
+
const payload = { version: GATEWAYS_FILE_VERSION, gateways };
|
|
160
|
+
const handle = await fs.open(tmpPath, 'wx', 0o600);
|
|
161
|
+
try {
|
|
162
|
+
await handle.writeFile(`${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await handle.close();
|
|
166
|
+
}
|
|
167
|
+
await fs.rename(tmpPath, filePath);
|
|
168
|
+
}
|
|
169
|
+
export async function addGateway(userId, input) {
|
|
170
|
+
const name = normalizeName(input.name);
|
|
171
|
+
if (input.kind === 'acp' && !input.command?.trim()) {
|
|
172
|
+
throw new DinoxError('ACP gateways need --command (the agent executable)', { code: 'INVALID_ARGUMENT' });
|
|
173
|
+
}
|
|
174
|
+
const gateways = await loadGateways(userId);
|
|
175
|
+
if (gateways.some((gateway) => gateway.name === name)) {
|
|
176
|
+
throw new DinoxError(`A gateway named "${name}" already exists`, { code: 'ALREADY_EXISTS' });
|
|
177
|
+
}
|
|
178
|
+
if (input.agentId && gateways.some((gateway) => gateway.agentId === input.agentId)) {
|
|
179
|
+
throw new DinoxError(`This agent is already hosted (${input.agentId})`, { code: 'ALREADY_EXISTS' });
|
|
180
|
+
}
|
|
181
|
+
const identity = deriveGatewayIdentity({
|
|
182
|
+
userId: requireUserId(userId),
|
|
183
|
+
machineId: await getMachineId(),
|
|
184
|
+
name,
|
|
185
|
+
agentId: input.agentId,
|
|
186
|
+
});
|
|
187
|
+
if (gateways.some((gateway) => gateway.id === identity.id)) {
|
|
188
|
+
throw new DinoxError(`A gateway named "${name}" already exists`, { code: 'ALREADY_EXISTS' });
|
|
189
|
+
}
|
|
190
|
+
const gateway = {
|
|
191
|
+
...identity,
|
|
192
|
+
name,
|
|
193
|
+
kind: input.kind,
|
|
194
|
+
...(input.agentId ? { agentId: input.agentId } : {}),
|
|
195
|
+
...(input.command ? { command: input.command.trim() } : {}),
|
|
196
|
+
...(input.args?.length ? { args: input.args } : {}),
|
|
197
|
+
...(input.cwd ? { cwd: path.resolve(input.cwd) } : {}),
|
|
198
|
+
...(input.env && Object.keys(input.env).length ? { env: input.env } : {}),
|
|
199
|
+
enabled: true,
|
|
200
|
+
createdAt: new Date().toISOString(),
|
|
201
|
+
};
|
|
202
|
+
await saveGateways(userId, [...gateways, gateway]);
|
|
203
|
+
return gateway;
|
|
204
|
+
}
|
|
205
|
+
function sameLaunch(a, b) {
|
|
206
|
+
return a.command === b.command && JSON.stringify(a.args ?? []) === JSON.stringify(b.args);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Hosts a detected agent once. An agent already hosted, matched by type (or, for
|
|
210
|
+
* gateways from before types were recorded, by executable name and arguments),
|
|
211
|
+
* is updated in place when its executable moved, e.g. after a Node upgrade.
|
|
212
|
+
*/
|
|
213
|
+
export async function adoptDetectedAgent(userId, agent) {
|
|
214
|
+
const gateways = await loadGateways(userId);
|
|
215
|
+
const existing = gateways.find((gateway) => gateway.agentId === agent.id) ??
|
|
216
|
+
gateways.find((gateway) => !gateway.agentId &&
|
|
217
|
+
gateway.kind === 'acp' &&
|
|
218
|
+
gateway.command !== undefined &&
|
|
219
|
+
path.basename(gateway.command) === path.basename(agent.command) &&
|
|
220
|
+
JSON.stringify(gateway.args ?? []) === JSON.stringify(agent.args));
|
|
221
|
+
if (existing) {
|
|
222
|
+
if (existing.agentId === agent.id && sameLaunch(existing, agent)) {
|
|
223
|
+
return { gateway: existing, change: 'unchanged' };
|
|
224
|
+
}
|
|
225
|
+
const updated = { ...existing, agentId: agent.id, command: agent.command, args: agent.args };
|
|
226
|
+
await saveGateways(userId, gateways.map((gateway) => (gateway.id === existing.id ? updated : gateway)));
|
|
227
|
+
return { gateway: updated, change: 'updated' };
|
|
228
|
+
}
|
|
229
|
+
const names = new Set(gateways.map((gateway) => gateway.name));
|
|
230
|
+
let name = agent.name;
|
|
231
|
+
for (let index = 2; names.has(name); index += 1)
|
|
232
|
+
name = `${agent.name} ${index}`;
|
|
233
|
+
const gateway = await addGateway(userId, {
|
|
234
|
+
name,
|
|
235
|
+
kind: 'acp',
|
|
236
|
+
agentId: agent.id,
|
|
237
|
+
command: agent.command,
|
|
238
|
+
args: agent.args,
|
|
239
|
+
});
|
|
240
|
+
return { gateway, change: 'added' };
|
|
241
|
+
}
|
|
242
|
+
export function findGateway(gateways, ref) {
|
|
243
|
+
return gateways.find((gateway) => gateway.id === ref || gateway.name === ref || gateway.deviceId === ref);
|
|
244
|
+
}
|
|
245
|
+
export async function removeGateway(userId, ref) {
|
|
246
|
+
const gateways = await loadGateways(userId);
|
|
247
|
+
const gateway = findGateway(gateways, ref);
|
|
248
|
+
if (!gateway) {
|
|
249
|
+
throw new DinoxError(`Gateway not found: ${ref}`, { code: 'NOT_FOUND' });
|
|
250
|
+
}
|
|
251
|
+
await saveGateways(userId, gateways.filter((item) => item.id !== gateway.id));
|
|
252
|
+
return gateway;
|
|
253
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known agents that speak ACP over stdio. Each lists launch variants in order;
|
|
3
|
+
* detection keeps the first one whose process completes an ACP handshake, so
|
|
4
|
+
* a stale guess here never produces a broken gateway.
|
|
5
|
+
*/
|
|
6
|
+
export declare const KNOWN_ACP_AGENTS: ReadonlyArray<{
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
candidates: string[][];
|
|
10
|
+
/** The agent's own CLI and the ACP adapter to install when only the CLI is present. */
|
|
11
|
+
adapter?: {
|
|
12
|
+
baseBinary: string;
|
|
13
|
+
install: string;
|
|
14
|
+
};
|
|
15
|
+
}>;
|
|
16
|
+
/** The known agent a manual launch command starts, by executable name and arguments. */
|
|
17
|
+
export declare function inferAgentId(command: string | undefined, args?: string[]): string | undefined;
|
|
18
|
+
export type DetectedAgent = {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** Absolute executable path, so the daemon does not depend on the caller's PATH. */
|
|
22
|
+
command: string;
|
|
23
|
+
args: string[];
|
|
24
|
+
verified: boolean;
|
|
25
|
+
agentName: string | null;
|
|
26
|
+
agentVersion: string | null;
|
|
27
|
+
error?: string;
|
|
28
|
+
/** Set when the agent's CLI is installed but its ACP adapter is not. */
|
|
29
|
+
installHint?: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function findExecutable(name: string, envPath?: string): Promise<string | null>;
|
|
32
|
+
/** Starts the agent, runs `initialize`, and stops it. Never leaves the process running. */
|
|
33
|
+
export declare function probeAcpAgent(command: string, args: string[], timeoutMs?: number): Promise<{
|
|
34
|
+
agentName: string | null;
|
|
35
|
+
agentVersion: string | null;
|
|
36
|
+
}>;
|
|
37
|
+
/** Finds installed ACP agents; with `probe`, keeps only launch variants that handshake. */
|
|
38
|
+
export declare function detectAcpAgents(options?: {
|
|
39
|
+
probe?: boolean;
|
|
40
|
+
}): Promise<DetectedAgent[]>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { Readable, Writable } from 'node:stream';
|
|
5
|
+
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
|
|
6
|
+
import { getPackageVersion } from '../utils/version.js';
|
|
7
|
+
/**
|
|
8
|
+
* Known agents that speak ACP over stdio. Each lists launch variants in order;
|
|
9
|
+
* detection keeps the first one whose process completes an ACP handshake, so
|
|
10
|
+
* a stale guess here never produces a broken gateway.
|
|
11
|
+
*/
|
|
12
|
+
export const KNOWN_ACP_AGENTS = [
|
|
13
|
+
{ id: 'hermes', name: 'Hermes', candidates: [['hermes', 'acp']] },
|
|
14
|
+
{ id: 'openclaw', name: 'OpenClaw', candidates: [['openclaw', 'acp']] },
|
|
15
|
+
{
|
|
16
|
+
id: 'claude',
|
|
17
|
+
name: 'Claude Code',
|
|
18
|
+
candidates: [['claude-agent-acp'], ['claude-code-acp']],
|
|
19
|
+
adapter: { baseBinary: 'claude', install: 'npm install -g @agentclientprotocol/claude-agent-acp' },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'codex',
|
|
23
|
+
name: 'Codex',
|
|
24
|
+
candidates: [['codex-acp']],
|
|
25
|
+
adapter: { baseBinary: 'codex', install: 'npm install -g @agentclientprotocol/codex-acp' },
|
|
26
|
+
},
|
|
27
|
+
{ id: 'gemini', name: 'Gemini CLI', candidates: [['gemini', '--acp'], ['gemini', '--experimental-acp']] },
|
|
28
|
+
{ id: 'copilot', name: 'GitHub Copilot', candidates: [['copilot', '--acp']] },
|
|
29
|
+
{ id: 'cursor', name: 'Cursor Agent', candidates: [['cursor-agent', 'acp']] },
|
|
30
|
+
{ id: 'opencode', name: 'OpenCode', candidates: [['opencode', 'acp']] },
|
|
31
|
+
{ id: 'pi', name: 'pi', candidates: [['pi-acp']] },
|
|
32
|
+
{ id: 'goose', name: 'Goose', candidates: [['goose', 'acp']] },
|
|
33
|
+
{ id: 'qwen', name: 'Qwen Code', candidates: [['qwen', '--acp'], ['qwen', '--experimental-acp']] },
|
|
34
|
+
{ id: 'kimi', name: 'Kimi CLI', candidates: [['kimi', 'acp'], ['kimi', '--acp']] },
|
|
35
|
+
// DeepSeek Harness: ACP is a dsh profile; the model key comes from DEEPSEEK_API_KEY.
|
|
36
|
+
{ id: 'deepseek', name: 'DeepSeek Harness', candidates: [['dsh', '--profile', 'acp']] },
|
|
37
|
+
{ id: 'qoder', name: 'Qoder CLI', candidates: [['qodercli', '--acp'], ['qoderclicn', '--acp']] },
|
|
38
|
+
{
|
|
39
|
+
id: 'zcode',
|
|
40
|
+
name: 'ZCode',
|
|
41
|
+
candidates: [['zcode-acp']],
|
|
42
|
+
adapter: { baseBinary: 'zcode', install: 'npm install -g zcode-acp' },
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
/** The known agent a manual launch command starts, by executable name and arguments. */
|
|
46
|
+
export function inferAgentId(command, args = []) {
|
|
47
|
+
if (!command)
|
|
48
|
+
return undefined;
|
|
49
|
+
const binary = path.basename(command).replace(/\.(exe|cmd|bat)$/i, '');
|
|
50
|
+
return KNOWN_ACP_AGENTS.find((agent) => agent.candidates.some(([candidate, ...candidateArgs]) => candidate === binary && JSON.stringify(candidateArgs) === JSON.stringify(args)))?.id;
|
|
51
|
+
}
|
|
52
|
+
// Some agents cold-start slowly (OpenCode boots a local server: ~40s on first run).
|
|
53
|
+
const PROBE_TIMEOUT_MS = 45_000;
|
|
54
|
+
export async function findExecutable(name, envPath = process.env.PATH ?? '') {
|
|
55
|
+
const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
56
|
+
for (const dir of envPath.split(path.delimiter).filter(Boolean)) {
|
|
57
|
+
for (const extension of extensions) {
|
|
58
|
+
const candidate = path.join(dir, `${name}${extension}`);
|
|
59
|
+
try {
|
|
60
|
+
await fs.access(candidate, fs.constants.X_OK);
|
|
61
|
+
if ((await fs.stat(candidate)).isFile())
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// not here
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/** Starts the agent, runs `initialize`, and stops it. Never leaves the process running. */
|
|
72
|
+
export async function probeAcpAgent(command, args, timeoutMs = PROBE_TIMEOUT_MS) {
|
|
73
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env });
|
|
74
|
+
let timer;
|
|
75
|
+
try {
|
|
76
|
+
const exited = new Promise((_, reject) => {
|
|
77
|
+
child.once('error', reject);
|
|
78
|
+
child.once('exit', (code) => reject(new Error(`exited before handshake (code ${code})`)));
|
|
79
|
+
});
|
|
80
|
+
const timedOut = new Promise((_, reject) => {
|
|
81
|
+
timer = setTimeout(() => reject(new Error('no ACP handshake within timeout')), timeoutMs);
|
|
82
|
+
});
|
|
83
|
+
const connection = new ClientSideConnection(() => ({
|
|
84
|
+
sessionUpdate: async () => { },
|
|
85
|
+
requestPermission: async () => ({ outcome: { outcome: 'cancelled' } }),
|
|
86
|
+
}), ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
87
|
+
const init = await Promise.race([
|
|
88
|
+
connection.initialize({
|
|
89
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
90
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
91
|
+
clientInfo: { name: 'dinox-gateway-detect', version: getPackageVersion() },
|
|
92
|
+
}),
|
|
93
|
+
exited,
|
|
94
|
+
timedOut,
|
|
95
|
+
]);
|
|
96
|
+
return { agentName: init.agentInfo?.name ?? null, agentVersion: init.agentInfo?.version ?? null };
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
child.stdin.destroy();
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Finds installed ACP agents; with `probe`, keeps only launch variants that handshake. */
|
|
105
|
+
export async function detectAcpAgents(options = {}) {
|
|
106
|
+
const probe = options.probe ?? true;
|
|
107
|
+
const results = await Promise.all(KNOWN_ACP_AGENTS.map(async (agent) => {
|
|
108
|
+
let lastError;
|
|
109
|
+
for (const [binary, ...args] of agent.candidates) {
|
|
110
|
+
const command = await findExecutable(binary);
|
|
111
|
+
if (!command)
|
|
112
|
+
continue;
|
|
113
|
+
if (!probe) {
|
|
114
|
+
return { id: agent.id, name: agent.name, command, args, verified: false, agentName: null, agentVersion: null };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const info = await probeAcpAgent(command, args);
|
|
118
|
+
return { id: agent.id, name: agent.name, command, args, verified: true, ...info };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!lastError) {
|
|
125
|
+
if (agent.adapter && (await findExecutable(agent.adapter.baseBinary))) {
|
|
126
|
+
const [binary, ...args] = agent.candidates[0];
|
|
127
|
+
return {
|
|
128
|
+
id: agent.id,
|
|
129
|
+
name: agent.name,
|
|
130
|
+
command: binary,
|
|
131
|
+
args,
|
|
132
|
+
verified: false,
|
|
133
|
+
agentName: null,
|
|
134
|
+
agentVersion: null,
|
|
135
|
+
installHint: agent.adapter.install,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const [binary, ...args] = agent.candidates[0];
|
|
141
|
+
const command = (await findExecutable(binary)) ?? binary;
|
|
142
|
+
return { id: agent.id, name: agent.name, command, args, verified: false, agentName: null, agentVersion: null, error: lastError };
|
|
143
|
+
}));
|
|
144
|
+
return results.filter((agent) => agent !== null);
|
|
145
|
+
}
|