@dinoxx/dinox-cli 2.6.0 → 2.8.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/dist/commands/gateway/index.js +1 -1
- package/dist/commands/schema/renderSkills.js +1 -1
- package/dist/commands/storage/repo/remote.d.ts +1 -1
- package/dist/commands/storage/repo/remote.js +2 -2
- package/dist/daemon/index.js +37 -6
- package/dist/daemon/launch.d.ts +5 -1
- package/dist/daemon/launch.js +14 -6
- package/dist/daemon/log.d.ts +2 -0
- package/dist/daemon/log.js +16 -3
- package/dist/daemon/server.js +17 -1
- package/dist/daemon/supervise.d.ts +29 -0
- package/dist/daemon/supervise.js +98 -0
- package/dist/daemon/supervisor.d.ts +21 -5
- package/dist/daemon/supervisor.js +195 -7
- package/dist/daemon/types.d.ts +1 -1
- package/dist/gateway/adapters/acp.d.ts +21 -5
- package/dist/gateway/adapters/acp.js +86 -7
- package/dist/gateway/adapters/codexImages.d.ts +4 -0
- package/dist/gateway/adapters/codexImages.js +34 -0
- package/dist/gateway/adapters/index.d.ts +5 -2
- package/dist/gateway/adapters/index.js +19 -10
- package/dist/gateway/assistantProjection.d.ts +5 -1
- package/dist/gateway/assistantProjection.js +48 -11
- package/dist/gateway/detect.js +1 -1
- package/dist/gateway/deviceHost.d.ts +3 -1
- package/dist/gateway/deviceHost.js +6 -0
- package/dist/gateway/fileUpload.d.ts +12 -0
- package/dist/gateway/fileUpload.js +154 -0
- package/dist/gateway/mcpEnv.d.ts +5 -0
- package/dist/gateway/mcpEnv.js +5 -0
- package/dist/gateway/mcpServer.d.ts +12 -2
- package/dist/gateway/mcpServer.js +41 -7
- package/dist/gateway/runExecutor.d.ts +9 -1
- package/dist/gateway/runExecutor.js +43 -2
- package/dist/gateway/service.d.ts +3 -0
- package/dist/gateway/service.js +9 -0
- package/dist/gateway/types.d.ts +27 -0
- package/package.json +1 -1
|
@@ -38,18 +38,14 @@ function writeDefinition(definitionPath, content) {
|
|
|
38
38
|
mkdirSync(path.dirname(definitionPath), { recursive: true });
|
|
39
39
|
writeFileSync(definitionPath, content, { mode: 0o644 });
|
|
40
40
|
}
|
|
41
|
-
function readDefinition(definitionPath) {
|
|
41
|
+
function readDefinition(definitionPath, encoding = 'utf8') {
|
|
42
42
|
try {
|
|
43
|
-
return readFileSync(definitionPath,
|
|
43
|
+
return readFileSync(definitionPath, encoding);
|
|
44
44
|
}
|
|
45
45
|
catch {
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
-
/** True when the installed definition differs from what this CLI would write. */
|
|
50
|
-
export function serviceDefinitionIsStale(service, spec) {
|
|
51
|
-
return readDefinition(service.definitionPath) !== service.render(spec);
|
|
52
|
-
}
|
|
53
49
|
// ---------------------------------------------------------------- launchd
|
|
54
50
|
function xmlEscape(value) {
|
|
55
51
|
return value
|
|
@@ -118,6 +114,9 @@ class LaunchdService {
|
|
|
118
114
|
isInstalled() {
|
|
119
115
|
return existsSync(this.definitionPath);
|
|
120
116
|
}
|
|
117
|
+
isCurrent(spec) {
|
|
118
|
+
return readDefinition(this.definitionPath) === this.render(spec);
|
|
119
|
+
}
|
|
121
120
|
async isLoaded() {
|
|
122
121
|
return (await this.run('launchctl', ['print', this.target])).code === 0;
|
|
123
122
|
}
|
|
@@ -213,6 +212,9 @@ class SystemdService {
|
|
|
213
212
|
isInstalled() {
|
|
214
213
|
return existsSync(this.definitionPath);
|
|
215
214
|
}
|
|
215
|
+
isCurrent(spec) {
|
|
216
|
+
return readDefinition(this.definitionPath) === this.render(spec);
|
|
217
|
+
}
|
|
216
218
|
async systemctl(step, ...args) {
|
|
217
219
|
const result = await this.run('systemctl', ['--user', ...args]);
|
|
218
220
|
if (result.code !== 0)
|
|
@@ -236,7 +238,188 @@ class SystemdService {
|
|
|
236
238
|
return true;
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
|
-
|
|
241
|
+
// ---------------------------------------------------------------- Windows
|
|
242
|
+
/** Quotes one argument the way CommandLineToArgvW (and so Node) parses it back. */
|
|
243
|
+
export function quoteWindowsArg(value) {
|
|
244
|
+
if (value !== '' && !/[\s"]/.test(value))
|
|
245
|
+
return value;
|
|
246
|
+
let quoted = '"';
|
|
247
|
+
let backslashes = 0;
|
|
248
|
+
for (const char of value) {
|
|
249
|
+
if (char === '\\') {
|
|
250
|
+
backslashes += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (char === '"') {
|
|
254
|
+
quoted += '\\'.repeat(backslashes * 2 + 1) + '"';
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
quoted += '\\'.repeat(backslashes) + char;
|
|
258
|
+
}
|
|
259
|
+
backslashes = 0;
|
|
260
|
+
}
|
|
261
|
+
return `${quoted}${'\\'.repeat(backslashes * 2)}"`;
|
|
262
|
+
}
|
|
263
|
+
function vbsString(value) {
|
|
264
|
+
return `"${value.replace(/"/g, '""').replace(/\r?\n/g, '" & vbLf & "')}"`;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Task Scheduler runs console programs in a visible window, so the task runs
|
|
268
|
+
* this script with wscript, which sets the daemon environment and starts the
|
|
269
|
+
* supervisor with a hidden window (the launcher pattern pm2-windows-startup
|
|
270
|
+
* uses). The supervisor then outlives the script.
|
|
271
|
+
*/
|
|
272
|
+
export function renderWindowsLauncher(spec) {
|
|
273
|
+
const commandLine = [spec.program, ...spec.args].map(quoteWindowsArg).join(' ');
|
|
274
|
+
const env = Object.keys(spec.env)
|
|
275
|
+
.sort()
|
|
276
|
+
.map((name) => `env(${vbsString(name)}) = ${vbsString(spec.env[name])}`)
|
|
277
|
+
.join('\r\n');
|
|
278
|
+
return [
|
|
279
|
+
"' Generated by `dino daemon install`; rewritten when the CLI changes.",
|
|
280
|
+
'Set shell = CreateObject("WScript.Shell")',
|
|
281
|
+
'Set env = shell.Environment("Process")',
|
|
282
|
+
env,
|
|
283
|
+
`shell.CurrentDirectory = ${vbsString(spec.workingDirectory)}`,
|
|
284
|
+
`shell.Run ${vbsString(commandLine)}, 0, False`,
|
|
285
|
+
'',
|
|
286
|
+
].join('\r\n');
|
|
287
|
+
}
|
|
288
|
+
export function renderWindowsTaskXml(userId, wscriptPath, launcherPath) {
|
|
289
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
290
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
291
|
+
<RegistrationInfo>
|
|
292
|
+
<Description>Dinox CLI daemon (dino daemon install)</Description>
|
|
293
|
+
</RegistrationInfo>
|
|
294
|
+
<Triggers>
|
|
295
|
+
<LogonTrigger>
|
|
296
|
+
<Enabled>true</Enabled>
|
|
297
|
+
<UserId>${xmlEscape(userId)}</UserId>
|
|
298
|
+
</LogonTrigger>
|
|
299
|
+
</Triggers>
|
|
300
|
+
<Principals>
|
|
301
|
+
<Principal id="Author">
|
|
302
|
+
<UserId>${xmlEscape(userId)}</UserId>
|
|
303
|
+
<LogonType>InteractiveToken</LogonType>
|
|
304
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
305
|
+
</Principal>
|
|
306
|
+
</Principals>
|
|
307
|
+
<Settings>
|
|
308
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
309
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
310
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
311
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
312
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
313
|
+
<IdleSettings>
|
|
314
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
315
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
316
|
+
</IdleSettings>
|
|
317
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
318
|
+
<Enabled>true</Enabled>
|
|
319
|
+
<Hidden>false</Hidden>
|
|
320
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
321
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
322
|
+
<Priority>7</Priority>
|
|
323
|
+
</Settings>
|
|
324
|
+
<Actions Context="Author">
|
|
325
|
+
<Exec>
|
|
326
|
+
<Command>${xmlEscape(wscriptPath)}</Command>
|
|
327
|
+
<Arguments>${xmlEscape(`//B //Nologo ${quoteWindowsArg(launcherPath)}`)}</Arguments>
|
|
328
|
+
</Exec>
|
|
329
|
+
</Actions>
|
|
330
|
+
</Task>
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
333
|
+
/** UTF-16LE with a BOM: what schtasks /XML expects and how wscript reads non-ASCII paths. */
|
|
334
|
+
function writeUtf16(filePath, content) {
|
|
335
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
336
|
+
writeFileSync(filePath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(content, 'utf16le')]));
|
|
337
|
+
}
|
|
338
|
+
function readUtf16(filePath) {
|
|
339
|
+
const content = readDefinition(filePath, 'utf16le');
|
|
340
|
+
return content === null ? null : content.replace(/^\uFEFF/, '');
|
|
341
|
+
}
|
|
342
|
+
class WindowsTaskService {
|
|
343
|
+
label;
|
|
344
|
+
run;
|
|
345
|
+
launcherDir;
|
|
346
|
+
wscriptPath;
|
|
347
|
+
userId;
|
|
348
|
+
kind = 'schtasks';
|
|
349
|
+
definitionPath;
|
|
350
|
+
taskName;
|
|
351
|
+
constructor(label, run, launcherDir, wscriptPath, userId) {
|
|
352
|
+
this.label = label;
|
|
353
|
+
this.run = run;
|
|
354
|
+
this.launcherDir = launcherDir;
|
|
355
|
+
this.wscriptPath = wscriptPath;
|
|
356
|
+
this.userId = userId;
|
|
357
|
+
this.taskName = `Dinox\\${label}`;
|
|
358
|
+
this.definitionPath = path.join(launcherDir, `${label}.vbs`);
|
|
359
|
+
}
|
|
360
|
+
render(spec) {
|
|
361
|
+
return renderWindowsLauncher(spec);
|
|
362
|
+
}
|
|
363
|
+
isInstalled() {
|
|
364
|
+
return existsSync(this.definitionPath);
|
|
365
|
+
}
|
|
366
|
+
isCurrent(spec) {
|
|
367
|
+
return readUtf16(this.definitionPath) === this.render(spec);
|
|
368
|
+
}
|
|
369
|
+
async isRegistered() {
|
|
370
|
+
return (await this.run('schtasks', ['/Query', '/TN', this.taskName])).code === 0;
|
|
371
|
+
}
|
|
372
|
+
async register() {
|
|
373
|
+
if (!existsSync(this.wscriptPath)) {
|
|
374
|
+
throw new DinoxError(`Windows Script Host (${this.wscriptPath}) is not available, so the daemon cannot start hidden at logon.`, {
|
|
375
|
+
code: 'DAEMON_SERVICE_UNSUPPORTED',
|
|
376
|
+
details: { reason: 'wscript-missing' },
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const xmlPath = path.join(this.launcherDir, `${this.label}.task.xml`);
|
|
380
|
+
writeUtf16(xmlPath, renderWindowsTaskXml(this.userId, this.wscriptPath, this.definitionPath));
|
|
381
|
+
try {
|
|
382
|
+
const result = await this.run('schtasks', ['/Create', '/TN', this.taskName, '/XML', xmlPath, '/F']);
|
|
383
|
+
if (result.code !== 0)
|
|
384
|
+
throw failed(this.kind, 'create', result);
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
try {
|
|
388
|
+
unlinkSync(xmlPath);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Already gone.
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async runTask() {
|
|
396
|
+
const result = await this.run('schtasks', ['/Run', '/TN', this.taskName]);
|
|
397
|
+
if (result.code !== 0)
|
|
398
|
+
throw failed(this.kind, 'run', result);
|
|
399
|
+
}
|
|
400
|
+
async install(spec) {
|
|
401
|
+
writeUtf16(this.definitionPath, this.render(spec));
|
|
402
|
+
await this.register();
|
|
403
|
+
await this.runTask();
|
|
404
|
+
}
|
|
405
|
+
async start() {
|
|
406
|
+
if (!(await this.isRegistered()))
|
|
407
|
+
await this.register();
|
|
408
|
+
await this.runTask();
|
|
409
|
+
}
|
|
410
|
+
async uninstall() {
|
|
411
|
+
const installed = this.isInstalled();
|
|
412
|
+
if (await this.isRegistered()) {
|
|
413
|
+
const result = await this.run('schtasks', ['/Delete', '/TN', this.taskName, '/F']);
|
|
414
|
+
if (result.code !== 0)
|
|
415
|
+
throw failed(this.kind, 'delete', result);
|
|
416
|
+
}
|
|
417
|
+
if (installed)
|
|
418
|
+
unlinkSync(this.definitionPath);
|
|
419
|
+
return installed;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** The service manager for this platform, or null where none is supported. */
|
|
240
423
|
export function getDaemonService(options = {}) {
|
|
241
424
|
const platform = options.platform ?? process.platform;
|
|
242
425
|
const env = options.env ?? process.env;
|
|
@@ -249,5 +432,10 @@ export function getDaemonService(options = {}) {
|
|
|
249
432
|
if (platform === 'linux') {
|
|
250
433
|
return new SystemdService(label, run, env.XDG_CONFIG_HOME || path.join(homeDir, '.config'));
|
|
251
434
|
}
|
|
435
|
+
if (platform === 'win32') {
|
|
436
|
+
const systemRoot = env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows';
|
|
437
|
+
const user = env.USERNAME || os.userInfo().username;
|
|
438
|
+
return new WindowsTaskService(label, run, options.launcherDir ?? path.join(getDataDir(), 'daemon'), path.join(systemRoot, 'System32', 'wscript.exe'), options.windowsUserId ?? (env.USERDOMAIN ? `${env.USERDOMAIN}\\${user}` : user));
|
|
439
|
+
}
|
|
252
440
|
return null;
|
|
253
441
|
}
|
package/dist/daemon/types.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type DaemonPingResult = {
|
|
|
43
43
|
/** True only for daemons using the current authenticated local protocol. */
|
|
44
44
|
authenticated: boolean;
|
|
45
45
|
/** Service manager that owns the process; null or absent (older daemons) when unmanaged. */
|
|
46
|
-
supervisor?: 'launchd' | 'systemd' | null;
|
|
46
|
+
supervisor?: 'launchd' | 'systemd' | 'schtasks' | null;
|
|
47
47
|
};
|
|
48
48
|
/** Kept only for old scripts that pass --port; the daemon transport is socket-based. */
|
|
49
49
|
export declare const DEFAULT_DAEMON_PORT = 45678;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { type McpServer, type SessionConfigOption, type ToolKind } from '@agentclientprotocol/sdk';
|
|
2
|
-
import type { GatewayAdapter, GatewayAdapterInfo, GatewayConfigOption, GatewayDefinition, GatewayTurnInput, GatewayTurnResult } from '../types.js';
|
|
1
|
+
import { type ContentBlock, type McpServer, type SessionConfigOption, type ToolKind } from '@agentclientprotocol/sdk';
|
|
2
|
+
import type { GatewayAdapter, GatewayAdapterInfo, GatewayConfigOption, GatewayDefinition, GatewayFileSource, GatewaySentFile, GatewayTurnInput, GatewayTurnResult } from '../types.js';
|
|
3
3
|
type Logger = (message: string, details?: Record<string, unknown>) => void;
|
|
4
|
+
export declare function withoutTransportNotice(text: string): string;
|
|
5
|
+
/** A file in an agent message (an image, or a resource it attached), as something to send the user. */
|
|
6
|
+
export declare function fileFromContent(content: ContentBlock): GatewayFileSource | null;
|
|
4
7
|
/** Select options only; boolean toggles have no use in the app yet. */
|
|
5
8
|
export declare function normalizeConfigOptions(options: SessionConfigOption[] | null | undefined): GatewayConfigOption[];
|
|
6
9
|
export declare function isReadOnlyToolKind(kind: ToolKind | null | undefined): boolean;
|
|
@@ -14,7 +17,10 @@ export declare class AcpGatewayAdapter implements GatewayAdapter {
|
|
|
14
17
|
/** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
|
|
15
18
|
private readonly gateway;
|
|
16
19
|
private readonly log;
|
|
17
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* MCP servers offered to sessions as they are created or loaded, e.g. the
|
|
22
|
+
* Dinox tools; `sessionToken` lets their `send_file` reach this session.
|
|
23
|
+
*/
|
|
18
24
|
private readonly mcpServers;
|
|
19
25
|
readonly kind: "acp";
|
|
20
26
|
private process;
|
|
@@ -22,15 +28,25 @@ export declare class AcpGatewayAdapter implements GatewayAdapter {
|
|
|
22
28
|
private readonly turns;
|
|
23
29
|
/** The latest settings any session reported: the agent-wide choices the app lists. */
|
|
24
30
|
private catalog;
|
|
31
|
+
/** Token each session's Dinox tools were started with → that session, for routing `send_file`. */
|
|
32
|
+
private readonly sessionTokens;
|
|
25
33
|
constructor(
|
|
26
34
|
/** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
|
|
27
35
|
gateway: GatewayDefinition, log?: Logger,
|
|
28
|
-
/**
|
|
29
|
-
|
|
36
|
+
/**
|
|
37
|
+
* MCP servers offered to sessions as they are created or loaded, e.g. the
|
|
38
|
+
* Dinox tools; `sessionToken` lets their `send_file` reach this session.
|
|
39
|
+
*/
|
|
40
|
+
mcpServers?: (sessionToken: string) => McpServer[]);
|
|
30
41
|
info(): GatewayAdapterInfo;
|
|
31
42
|
runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
|
|
43
|
+
sendFile(token: string, file: GatewayFileSource): Promise<GatewaySentFile> | null;
|
|
32
44
|
describeConfig(): Promise<GatewayConfigOption[]>;
|
|
33
45
|
dispose(): Promise<void>;
|
|
46
|
+
/** Codex leaves generated images on disk; send the ones this turn made when it sent nothing itself. */
|
|
47
|
+
private sendGeneratedImages;
|
|
48
|
+
/** Reports a file the agent put in its message to the user, without failing the turn. */
|
|
49
|
+
private sendContentFile;
|
|
34
50
|
/** Continues the session (loading it in `cwd` when this process has not seen it) or starts one there. */
|
|
35
51
|
private resolveSession;
|
|
36
52
|
/**
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
3
5
|
import { Readable, Writable } from 'node:stream';
|
|
4
6
|
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from '@agentclientprotocol/sdk';
|
|
5
7
|
import { generateId } from '../../utils/id.js';
|
|
6
8
|
import { getPackageVersion } from '../../utils/version.js';
|
|
9
|
+
import { codexGeneratedImagesDir, codexImagesSince } from './codexImages.js';
|
|
7
10
|
const TOOL_STATUS = {
|
|
8
11
|
pending: 'pending',
|
|
9
12
|
in_progress: 'running',
|
|
@@ -17,6 +20,30 @@ const STOP_REASON = {
|
|
|
17
20
|
refusal: 'refusal',
|
|
18
21
|
cancelled: 'cancelled',
|
|
19
22
|
};
|
|
23
|
+
/** Codex reports its transport falling back as message text; it is not part of the answer. */
|
|
24
|
+
const TRANSPORT_NOTICE = /^Warning: Falling back from WebSockets to HTTPS transport\.[^\n]*(\n+|$)/;
|
|
25
|
+
export function withoutTransportNotice(text) {
|
|
26
|
+
return text.replace(TRANSPORT_NOTICE, '');
|
|
27
|
+
}
|
|
28
|
+
/** A file in an agent message (an image, or a resource it attached), as something to send the user. */
|
|
29
|
+
export function fileFromContent(content) {
|
|
30
|
+
switch (content.type) {
|
|
31
|
+
case 'image':
|
|
32
|
+
return content.data ? { kind: 'data', data: content.data, mediaType: content.mimeType } : null;
|
|
33
|
+
case 'resource_link':
|
|
34
|
+
return content.uri.startsWith('file://')
|
|
35
|
+
? { kind: 'path', path: fileURLToPath(content.uri), name: content.name }
|
|
36
|
+
: null;
|
|
37
|
+
case 'resource': {
|
|
38
|
+
const resource = content.resource;
|
|
39
|
+
if (!('blob' in resource))
|
|
40
|
+
return null;
|
|
41
|
+
return { kind: 'data', data: resource.blob, mediaType: resource.mimeType ?? 'application/octet-stream' };
|
|
42
|
+
}
|
|
43
|
+
default:
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
20
47
|
function toolContentText(content) {
|
|
21
48
|
const text = (content ?? [])
|
|
22
49
|
.map((item) => (item.type === 'content' && item.content.type === 'text' ? item.content.text : ''))
|
|
@@ -82,10 +109,15 @@ export class AcpGatewayAdapter {
|
|
|
82
109
|
turns = new Map();
|
|
83
110
|
/** The latest settings any session reported: the agent-wide choices the app lists. */
|
|
84
111
|
catalog = null;
|
|
112
|
+
/** Token each session's Dinox tools were started with → that session, for routing `send_file`. */
|
|
113
|
+
sessionTokens = new Map();
|
|
85
114
|
constructor(
|
|
86
115
|
/** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
|
|
87
116
|
gateway, log = () => { },
|
|
88
|
-
/**
|
|
117
|
+
/**
|
|
118
|
+
* MCP servers offered to sessions as they are created or loaded, e.g. the
|
|
119
|
+
* Dinox tools; `sessionToken` lets their `send_file` reach this session.
|
|
120
|
+
*/
|
|
89
121
|
mcpServers = () => []) {
|
|
90
122
|
this.gateway = gateway;
|
|
91
123
|
this.log = log;
|
|
@@ -99,7 +131,9 @@ export class AcpGatewayAdapter {
|
|
|
99
131
|
const agent = await this.ensureProcess();
|
|
100
132
|
const { sessionId, created } = await this.resolveSession(agent, input.sessionId, input.cwd);
|
|
101
133
|
await this.applyConfig(agent, sessionId, input.config);
|
|
102
|
-
|
|
134
|
+
const turn = { onEvent: input.onEvent, awaitApproval: input.awaitApproval, sendFile: input.sendFile, sent: 0 };
|
|
135
|
+
this.turns.set(sessionId, turn);
|
|
136
|
+
const startedAt = Date.now();
|
|
103
137
|
const onAbort = () => {
|
|
104
138
|
void agent.connection.cancel({ sessionId }).catch(() => undefined);
|
|
105
139
|
};
|
|
@@ -111,6 +145,8 @@ export class AcpGatewayAdapter {
|
|
|
111
145
|
prompt.push({ type: 'resource_link', uri: resource.uri, name: resource.name, mimeType: resource.mediaType ?? null });
|
|
112
146
|
}
|
|
113
147
|
const response = await agent.connection.prompt({ sessionId, prompt });
|
|
148
|
+
if (turn.sent === 0)
|
|
149
|
+
await this.sendGeneratedImages(turn, sessionId, startedAt);
|
|
114
150
|
return { sessionId, stopReason: STOP_REASON[response.stopReason] ?? 'end_turn' };
|
|
115
151
|
}
|
|
116
152
|
catch (error) {
|
|
@@ -123,6 +159,16 @@ export class AcpGatewayAdapter {
|
|
|
123
159
|
this.turns.delete(sessionId);
|
|
124
160
|
}
|
|
125
161
|
}
|
|
162
|
+
sendFile(token, file) {
|
|
163
|
+
const sessionId = this.sessionTokens.get(token);
|
|
164
|
+
if (!sessionId)
|
|
165
|
+
return null;
|
|
166
|
+
const turn = this.turns.get(sessionId);
|
|
167
|
+
if (!turn)
|
|
168
|
+
return Promise.reject(new Error('Files can only be sent while answering the user'));
|
|
169
|
+
turn.sent += 1;
|
|
170
|
+
return turn.sendFile(file);
|
|
171
|
+
}
|
|
126
172
|
async describeConfig() {
|
|
127
173
|
if (this.catalog)
|
|
128
174
|
return this.catalog;
|
|
@@ -146,6 +192,25 @@ export class AcpGatewayAdapter {
|
|
|
146
192
|
return;
|
|
147
193
|
agent.child.stdin.end();
|
|
148
194
|
agent.child.kill('SIGTERM');
|
|
195
|
+
this.sessionTokens.clear();
|
|
196
|
+
}
|
|
197
|
+
/** Codex leaves generated images on disk; send the ones this turn made when it sent nothing itself. */
|
|
198
|
+
async sendGeneratedImages(turn, sessionId, startedAt) {
|
|
199
|
+
const dir = codexGeneratedImagesDir(sessionId, { ...process.env, ...(this.gateway.env ?? {}) });
|
|
200
|
+
if (!dir)
|
|
201
|
+
return;
|
|
202
|
+
for (const file of await codexImagesSince(dir, startedAt)) {
|
|
203
|
+
turn.sent += 1;
|
|
204
|
+
await turn.sendFile({ kind: 'path', path: file }).catch((error) => this.log('sending a generated image failed', { error: String(error) }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Reports a file the agent put in its message to the user, without failing the turn. */
|
|
208
|
+
sendContentFile(turn, content) {
|
|
209
|
+
const file = fileFromContent(content);
|
|
210
|
+
if (!file)
|
|
211
|
+
return;
|
|
212
|
+
turn.sent += 1;
|
|
213
|
+
void turn.sendFile(file).catch((error) => this.log('sending a file from the agent message failed', { error: String(error) }));
|
|
149
214
|
}
|
|
150
215
|
/** Continues the session (loading it in `cwd` when this process has not seen it) or starts one there. */
|
|
151
216
|
async resolveSession(agent, sessionId, cwd) {
|
|
@@ -154,7 +219,9 @@ export class AcpGatewayAdapter {
|
|
|
154
219
|
if (sessionId && agent.init.agentCapabilities?.loadSession) {
|
|
155
220
|
try {
|
|
156
221
|
// No turn is registered yet, so the history the agent replays is ignored.
|
|
157
|
-
const
|
|
222
|
+
const token = crypto.randomBytes(18).toString('base64url');
|
|
223
|
+
const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers(token) });
|
|
224
|
+
this.sessionTokens.set(token, sessionId);
|
|
158
225
|
agent.liveSessions.add(sessionId);
|
|
159
226
|
this.rememberConfig(agent, sessionId, loaded.configOptions);
|
|
160
227
|
return { sessionId, created: false };
|
|
@@ -163,7 +230,9 @@ export class AcpGatewayAdapter {
|
|
|
163
230
|
this.log('session/load failed; starting a new session', { sessionId, error: String(error) });
|
|
164
231
|
}
|
|
165
232
|
}
|
|
166
|
-
const
|
|
233
|
+
const token = crypto.randomBytes(18).toString('base64url');
|
|
234
|
+
const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers(token) });
|
|
235
|
+
this.sessionTokens.set(token, created.sessionId);
|
|
167
236
|
agent.liveSessions.add(created.sessionId);
|
|
168
237
|
this.rememberConfig(agent, created.sessionId, created.configOptions);
|
|
169
238
|
return { sessionId: created.sessionId, created: true };
|
|
@@ -213,6 +282,8 @@ export class AcpGatewayAdapter {
|
|
|
213
282
|
cwd: this.gateway.cwd ?? os.homedir(),
|
|
214
283
|
env: { ...process.env, ...(this.gateway.env ?? {}) },
|
|
215
284
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
285
|
+
// The daemon runs without a visible console on Windows; keep agents that way.
|
|
286
|
+
windowsHide: true,
|
|
216
287
|
});
|
|
217
288
|
child.stderr.on('data', (chunk) => this.log('agent stderr', { text: chunk.toString('utf8').slice(0, 2000) }));
|
|
218
289
|
const exited = new Promise((_, reject) => {
|
|
@@ -220,8 +291,10 @@ export class AcpGatewayAdapter {
|
|
|
220
291
|
child.once('exit', (code, signal) => reject(new Error(`agent exited (code ${code}, signal ${signal})`)));
|
|
221
292
|
});
|
|
222
293
|
child.once('exit', () => {
|
|
223
|
-
if (this.process?.child === child)
|
|
294
|
+
if (this.process?.child === child) {
|
|
224
295
|
this.process = null;
|
|
296
|
+
this.sessionTokens.clear();
|
|
297
|
+
}
|
|
225
298
|
});
|
|
226
299
|
const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
227
300
|
const connection = new ClientSideConnection(() => ({
|
|
@@ -253,8 +326,14 @@ export class AcpGatewayAdapter {
|
|
|
253
326
|
return;
|
|
254
327
|
switch (update.sessionUpdate) {
|
|
255
328
|
case 'agent_message_chunk':
|
|
256
|
-
if (update.content.type === 'text')
|
|
257
|
-
|
|
329
|
+
if (update.content.type === 'text') {
|
|
330
|
+
const text = withoutTransportNotice(update.content.text);
|
|
331
|
+
if (text)
|
|
332
|
+
turn.onEvent({ type: 'text-delta', text });
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
this.sendContentFile(turn, update.content);
|
|
336
|
+
}
|
|
258
337
|
break;
|
|
259
338
|
case 'agent_thought_chunk':
|
|
260
339
|
if (update.content.type === 'text')
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const MAX_FALLBACK_IMAGES = 4;
|
|
2
|
+
export declare function codexGeneratedImagesDir(sessionId: string, env: Record<string, string | undefined>): string | null;
|
|
3
|
+
/** Images written to the session's folder since `sinceMs`, oldest first. */
|
|
4
|
+
export declare function codexImagesSince(dir: string, sinceMs: number): Promise<string[]>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Codex saves the images it generates to `$CODEX_HOME/generated_images/<session>/`
|
|
6
|
+
* and only tells the model where they are, so a turn that produced images but
|
|
7
|
+
* never sent them would leave the user with nothing. The newest ones are sent instead.
|
|
8
|
+
*/
|
|
9
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif']);
|
|
10
|
+
export const MAX_FALLBACK_IMAGES = 4;
|
|
11
|
+
export function codexGeneratedImagesDir(sessionId, env) {
|
|
12
|
+
// The session id names a folder; refuse anything that could leave it.
|
|
13
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId))
|
|
14
|
+
return null;
|
|
15
|
+
const codexHome = env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex');
|
|
16
|
+
return path.join(codexHome, 'generated_images', sessionId);
|
|
17
|
+
}
|
|
18
|
+
/** Images written to the session's folder since `sinceMs`, oldest first. */
|
|
19
|
+
export async function codexImagesSince(dir, sinceMs) {
|
|
20
|
+
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
21
|
+
const images = [];
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
if (!entry.isFile() || !IMAGE_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
|
|
24
|
+
continue;
|
|
25
|
+
const file = path.join(dir, entry.name);
|
|
26
|
+
const stats = await fsp.stat(file).catch(() => null);
|
|
27
|
+
if (stats && stats.mtimeMs >= sinceMs)
|
|
28
|
+
images.push({ file, mtimeMs: stats.mtimeMs });
|
|
29
|
+
}
|
|
30
|
+
return images
|
|
31
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs)
|
|
32
|
+
.slice(-MAX_FALLBACK_IMAGES)
|
|
33
|
+
.map((image) => image.file);
|
|
34
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import type { McpServer } from '@agentclientprotocol/sdk';
|
|
2
2
|
import type { GatewayAdapter, GatewayDefinition } from '../types.js';
|
|
3
3
|
/**
|
|
4
|
-
* The Dinox
|
|
4
|
+
* The Dinox tools as a stdio MCP server: this same CLI, run as
|
|
5
5
|
* `dino gateway mcp`, reading through the daemon. DINOX_* variables are passed
|
|
6
6
|
* on so the child finds the same config, data dir and daemon socket.
|
|
7
7
|
*/
|
|
8
|
-
export declare function dinoxMcpServerConfig(
|
|
8
|
+
export declare function dinoxMcpServerConfig(options: {
|
|
9
|
+
sessionToken: string;
|
|
10
|
+
knowledgeTools: boolean;
|
|
11
|
+
}): McpServer | null;
|
|
9
12
|
export declare function createGatewayAdapter(gateway: GatewayDefinition, log: (message: string, details?: Record<string, unknown>) => void): GatewayAdapter;
|
|
@@ -1,30 +1,39 @@
|
|
|
1
|
+
import { GATEWAY_KNOWLEDGE_TOOLS_ENV, GATEWAY_SESSION_TOKEN_ENV } from '../mcpEnv.js';
|
|
1
2
|
import { AcpGatewayAdapter } from './acp.js';
|
|
2
3
|
import { EchoGatewayAdapter } from './echo.js';
|
|
3
4
|
/**
|
|
4
|
-
* The Dinox
|
|
5
|
+
* The Dinox tools as a stdio MCP server: this same CLI, run as
|
|
5
6
|
* `dino gateway mcp`, reading through the daemon. DINOX_* variables are passed
|
|
6
7
|
* on so the child finds the same config, data dir and daemon socket.
|
|
7
8
|
*/
|
|
8
|
-
export function dinoxMcpServerConfig() {
|
|
9
|
+
export function dinoxMcpServerConfig(options) {
|
|
9
10
|
const entrypoint = process.argv[1];
|
|
10
11
|
if (!entrypoint)
|
|
11
12
|
return null;
|
|
13
|
+
const inherited = Object.entries(process.env).filter((entry) => entry[0].startsWith('DINOX_') &&
|
|
14
|
+
entry[0] !== GATEWAY_SESSION_TOKEN_ENV &&
|
|
15
|
+
entry[0] !== GATEWAY_KNOWLEDGE_TOOLS_ENV &&
|
|
16
|
+
typeof entry[1] === 'string');
|
|
12
17
|
return {
|
|
13
18
|
name: 'dinox',
|
|
14
19
|
command: process.execPath,
|
|
15
20
|
args: [...process.execArgv, entrypoint, 'gateway', 'mcp'],
|
|
16
|
-
env:
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
env: [
|
|
22
|
+
...inherited,
|
|
23
|
+
[GATEWAY_SESSION_TOKEN_ENV, options.sessionToken],
|
|
24
|
+
[GATEWAY_KNOWLEDGE_TOOLS_ENV, options.knowledgeTools ? '1' : '0'],
|
|
25
|
+
].map(([name, value]) => ({ name, value })),
|
|
19
26
|
};
|
|
20
27
|
}
|
|
21
28
|
export function createGatewayAdapter(gateway, log) {
|
|
22
29
|
switch (gateway.kind) {
|
|
23
|
-
case 'acp':
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
return new AcpGatewayAdapter(gateway, log, () =>
|
|
27
|
-
|
|
30
|
+
case 'acp':
|
|
31
|
+
// Read per session, so turning the knowledge tools off applies to the next new conversation.
|
|
32
|
+
// `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 tools ? [tools] : [];
|
|
36
|
+
});
|
|
28
37
|
case 'echo':
|
|
29
38
|
return new EchoGatewayAdapter();
|
|
30
39
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChatUIMessage } from '../vendor/electron/shared/external-types.js';
|
|
2
2
|
import type { RemoteToolApprovalPrompt } from '../vendor/electron/shared/remote-device-agent.js';
|
|
3
|
-
import type { GatewayEvent } from './types.js';
|
|
3
|
+
import type { GatewayEvent, GatewaySentFile } from './types.js';
|
|
4
|
+
export declare function compactToolValue(value: unknown, depth?: number): unknown;
|
|
4
5
|
export declare class AssistantProjection {
|
|
5
6
|
private readonly base;
|
|
6
7
|
private readonly parts;
|
|
@@ -10,11 +11,14 @@ export declare class AssistantProjection {
|
|
|
10
11
|
constructor(base: ChatUIMessage);
|
|
11
12
|
get revision(): number;
|
|
12
13
|
apply(event: GatewayEvent): void;
|
|
14
|
+
/** A file sent to the user, shown where the agent sent it (the app renders images inline). */
|
|
15
|
+
addFile(file: GatewaySentFile): void;
|
|
13
16
|
/** Closes streaming parts; called once when the turn ends. */
|
|
14
17
|
finish(): void;
|
|
15
18
|
snapshot(): ChatUIMessage;
|
|
16
19
|
private appendText;
|
|
17
20
|
private ensureTool;
|
|
21
|
+
private closeTextSegment;
|
|
18
22
|
private findToolByApproval;
|
|
19
23
|
private applyTool;
|
|
20
24
|
}
|