@dinoxx/dinox-cli 2.3.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.
Files changed (47) hide show
  1. package/README.md +17 -3
  2. package/dist/cli.js +2 -0
  3. package/dist/commands/gateway/index.d.ts +4 -0
  4. package/dist/commands/gateway/index.js +300 -0
  5. package/dist/commands/schema/renderSkills.js +4 -0
  6. package/dist/commands/schema/spec.js +2 -0
  7. package/dist/config/paths.d.ts +1 -0
  8. package/dist/config/paths.js +1 -1
  9. package/dist/daemon/server.d.ts +1 -0
  10. package/dist/daemon/server.js +31 -0
  11. package/dist/gateway/adapters/acp.d.ts +42 -0
  12. package/dist/gateway/adapters/acp.js +285 -0
  13. package/dist/gateway/adapters/echo.d.ts +12 -0
  14. package/dist/gateway/adapters/echo.js +51 -0
  15. package/dist/gateway/adapters/index.d.ts +9 -0
  16. package/dist/gateway/adapters/index.js +30 -0
  17. package/dist/gateway/assistantProjection.d.ts +32 -0
  18. package/dist/gateway/assistantProjection.js +193 -0
  19. package/dist/gateway/config.d.ts +50 -0
  20. package/dist/gateway/config.js +253 -0
  21. package/dist/gateway/detect.d.ts +40 -0
  22. package/dist/gateway/detect.js +145 -0
  23. package/dist/gateway/deviceHost.d.ts +82 -0
  24. package/dist/gateway/deviceHost.js +530 -0
  25. package/dist/gateway/mcpServer.d.ts +4 -0
  26. package/dist/gateway/mcpServer.js +118 -0
  27. package/dist/gateway/protocol.d.ts +29 -0
  28. package/dist/gateway/protocol.js +127 -0
  29. package/dist/gateway/runExecutor.d.ts +67 -0
  30. package/dist/gateway/runExecutor.js +316 -0
  31. package/dist/gateway/service.d.ts +26 -0
  32. package/dist/gateway/service.js +82 -0
  33. package/dist/gateway/streamPublisher.d.ts +33 -0
  34. package/dist/gateway/streamPublisher.js +93 -0
  35. package/dist/gateway/threadStore.d.ts +157 -0
  36. package/dist/gateway/threadStore.js +430 -0
  37. package/dist/gateway/types.d.ts +117 -0
  38. package/dist/gateway/types.js +9 -0
  39. package/dist/vendor/electron/domain/properties.js +12 -1
  40. package/dist/vendor/electron/domain/saved-views.js +4 -1
  41. package/dist/vendor/electron/shared/external-types.d.ts +18 -0
  42. package/dist/vendor/electron/shared/note-properties.d.ts +16 -0
  43. package/dist/vendor/electron/shared/note-properties.js +45 -0
  44. package/dist/vendor/electron/shared/powersync-read-models.d.ts +7 -1
  45. package/dist/vendor/electron/shared/remote-device-agent.d.ts +373 -0
  46. package/dist/vendor/electron/shared/remote-device-agent.js +191 -0
  47. package/package.json +5 -2
@@ -0,0 +1,285 @@
1
+ import { spawn } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import { Readable, Writable } from 'node:stream';
4
+ import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from '@agentclientprotocol/sdk';
5
+ import { generateId } from '../../utils/id.js';
6
+ import { getPackageVersion } from '../../utils/version.js';
7
+ const TOOL_STATUS = {
8
+ pending: 'pending',
9
+ in_progress: 'running',
10
+ completed: 'completed',
11
+ failed: 'failed',
12
+ };
13
+ const STOP_REASON = {
14
+ end_turn: 'end_turn',
15
+ max_tokens: 'max_tokens',
16
+ max_turn_requests: 'max_tokens',
17
+ refusal: 'refusal',
18
+ cancelled: 'cancelled',
19
+ };
20
+ function toolContentText(content) {
21
+ const text = (content ?? [])
22
+ .map((item) => (item.type === 'content' && item.content.type === 'text' ? item.content.text : ''))
23
+ .filter(Boolean)
24
+ .join('\n');
25
+ return text || undefined;
26
+ }
27
+ /** Select options only; boolean toggles have no use in the app yet. */
28
+ export function normalizeConfigOptions(options) {
29
+ const normalized = [];
30
+ for (const option of options ?? []) {
31
+ if (option.type !== 'select')
32
+ continue;
33
+ const values = [];
34
+ for (const entry of option.options) {
35
+ if ('group' in entry) {
36
+ for (const value of entry.options) {
37
+ values.push({ value: value.value, name: value.name, description: value.description ?? null, group: entry.name });
38
+ }
39
+ }
40
+ else {
41
+ values.push({ value: entry.value, name: entry.name, description: entry.description ?? null, group: null });
42
+ }
43
+ }
44
+ normalized.push({
45
+ id: option.id,
46
+ name: option.name,
47
+ category: option.category ?? null,
48
+ description: option.description ?? null,
49
+ currentValue: option.currentValue,
50
+ values,
51
+ });
52
+ }
53
+ return normalized;
54
+ }
55
+ function pickOption(options, approved) {
56
+ const preferred = approved ? ['allow_once', 'allow_always'] : ['reject_once', 'reject_always'];
57
+ for (const kind of preferred) {
58
+ const option = options.find((item) => item.kind === kind);
59
+ if (option)
60
+ return option;
61
+ }
62
+ return undefined;
63
+ }
64
+ /**
65
+ * Drives any Agent Client Protocol agent over stdio (`hermes acp`, `openclaw acp`,
66
+ * `claude-code-acp`, `codex-acp`, `pi-acp`, ...). One long-lived process per
67
+ * gateway hosts every thread's session; the agent keeps the conversation, so
68
+ * each turn sends only the new prompt.
69
+ */
70
+ export class AcpGatewayAdapter {
71
+ gateway;
72
+ log;
73
+ mcpServers;
74
+ kind = 'acp';
75
+ process = null;
76
+ starting = null;
77
+ turns = new Map();
78
+ /** The latest settings any session reported: the agent-wide choices the app lists. */
79
+ catalog = null;
80
+ constructor(gateway, log = () => { },
81
+ /** MCP servers offered to every session, e.g. the Dinox knowledge tools. */
82
+ mcpServers = []) {
83
+ this.gateway = gateway;
84
+ this.log = log;
85
+ this.mcpServers = mcpServers;
86
+ }
87
+ info() {
88
+ const agentInfo = this.process?.init.agentInfo;
89
+ return { kind: this.kind, agentName: agentInfo?.name ?? null, agentVersion: agentInfo?.version ?? null };
90
+ }
91
+ async runTurn(input) {
92
+ const agent = await this.ensureProcess();
93
+ const sessionId = await this.resolveSession(agent, input.sessionId);
94
+ await this.applyConfig(agent, sessionId, input.config);
95
+ this.turns.set(sessionId, { onEvent: input.onEvent, awaitApproval: input.awaitApproval });
96
+ const onAbort = () => {
97
+ void agent.connection.cancel({ sessionId }).catch(() => undefined);
98
+ };
99
+ input.signal.addEventListener('abort', onAbort, { once: true });
100
+ try {
101
+ const prompt = [{ type: 'text', text: input.prompt }];
102
+ for (const resource of input.resources) {
103
+ prompt.push({ type: 'resource_link', uri: resource.uri, name: resource.name, mimeType: resource.mediaType ?? null });
104
+ }
105
+ const response = await agent.connection.prompt({ sessionId, prompt });
106
+ return { sessionId, stopReason: STOP_REASON[response.stopReason] ?? 'end_turn' };
107
+ }
108
+ catch (error) {
109
+ if (input.signal.aborted)
110
+ return { sessionId, stopReason: 'cancelled' };
111
+ return { sessionId, stopReason: 'error', error: error instanceof Error ? error.message : String(error) };
112
+ }
113
+ finally {
114
+ input.signal.removeEventListener('abort', onAbort);
115
+ this.turns.delete(sessionId);
116
+ }
117
+ }
118
+ async describeConfig() {
119
+ if (this.catalog)
120
+ return this.catalog;
121
+ const agent = await this.ensureProcess();
122
+ if (this.catalog)
123
+ return this.catalog;
124
+ // No session yet: open one only to read the agent's choices. It never runs a turn.
125
+ const created = await agent.connection.newSession({ cwd: this.gateway.cwd ?? os.homedir(), mcpServers: [] });
126
+ this.rememberConfig(agent, created.sessionId, created.configOptions);
127
+ return this.catalog ?? [];
128
+ }
129
+ async dispose() {
130
+ const agent = this.process;
131
+ this.process = null;
132
+ this.starting = null;
133
+ if (!agent)
134
+ return;
135
+ agent.child.stdin.end();
136
+ agent.child.kill('SIGTERM');
137
+ }
138
+ async resolveSession(agent, sessionId) {
139
+ const cwd = this.gateway.cwd ?? os.homedir();
140
+ if (sessionId && agent.liveSessions.has(sessionId))
141
+ return sessionId;
142
+ if (sessionId && agent.init.agentCapabilities?.loadSession) {
143
+ try {
144
+ // No turn is registered yet, so the history the agent replays is ignored.
145
+ const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers });
146
+ agent.liveSessions.add(sessionId);
147
+ this.rememberConfig(agent, sessionId, loaded.configOptions);
148
+ return sessionId;
149
+ }
150
+ catch (error) {
151
+ this.log('session/load failed; starting a new session', { sessionId, error: String(error) });
152
+ }
153
+ }
154
+ const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers });
155
+ agent.liveSessions.add(created.sessionId);
156
+ this.rememberConfig(agent, created.sessionId, created.configOptions);
157
+ return created.sessionId;
158
+ }
159
+ /**
160
+ * Brings the session to the requested settings before its turn. Entries the
161
+ * agent does not offer are skipped, so a stale choice never fails the run.
162
+ */
163
+ async applyConfig(agent, sessionId, config) {
164
+ for (const [configId, value] of Object.entries(config ?? {})) {
165
+ const option = agent.sessionConfig.get(sessionId)?.find((item) => item.id === configId);
166
+ if (!option || option.currentValue === value)
167
+ continue;
168
+ if (!option.values.some((item) => item.value === value)) {
169
+ this.log('agent setting not offered; skipped', { configId, value });
170
+ continue;
171
+ }
172
+ try {
173
+ const response = await agent.connection.setSessionConfigOption({ sessionId, configId, value });
174
+ this.rememberConfig(agent, sessionId, response.configOptions);
175
+ }
176
+ catch (error) {
177
+ this.log('agent setting failed; continuing with the current one', { configId, error: String(error) });
178
+ }
179
+ }
180
+ }
181
+ rememberConfig(agent, sessionId, options) {
182
+ if (!options)
183
+ return;
184
+ const normalized = normalizeConfigOptions(options);
185
+ agent.sessionConfig.set(sessionId, normalized);
186
+ this.catalog = normalized;
187
+ }
188
+ ensureProcess() {
189
+ if (this.process)
190
+ return Promise.resolve(this.process);
191
+ this.starting ??= this.startProcess().finally(() => {
192
+ this.starting = null;
193
+ });
194
+ return this.starting;
195
+ }
196
+ async startProcess() {
197
+ const command = this.gateway.command;
198
+ if (!command)
199
+ throw new Error(`Gateway ${this.gateway.name} has no ACP command`);
200
+ const child = spawn(command, this.gateway.args ?? [], {
201
+ cwd: this.gateway.cwd ?? os.homedir(),
202
+ env: { ...process.env, ...(this.gateway.env ?? {}) },
203
+ stdio: ['pipe', 'pipe', 'pipe'],
204
+ });
205
+ child.stderr.on('data', (chunk) => this.log('agent stderr', { text: chunk.toString('utf8').slice(0, 2000) }));
206
+ const exited = new Promise((_, reject) => {
207
+ child.once('error', reject);
208
+ child.once('exit', (code, signal) => reject(new Error(`agent exited (code ${code}, signal ${signal})`)));
209
+ });
210
+ child.once('exit', () => {
211
+ if (this.process?.child === child)
212
+ this.process = null;
213
+ });
214
+ const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
215
+ const connection = new ClientSideConnection(() => ({
216
+ sessionUpdate: (params) => this.handleSessionUpdate(params),
217
+ requestPermission: (params) => this.handlePermission(params),
218
+ }), stream);
219
+ const init = await Promise.race([
220
+ connection.initialize({
221
+ protocolVersion: PROTOCOL_VERSION,
222
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
223
+ clientInfo: { name: 'dinox-gateway', version: getPackageVersion() },
224
+ }),
225
+ exited,
226
+ ]);
227
+ exited.catch(() => undefined);
228
+ const agent = { child, connection, init, liveSessions: new Set(), sessionConfig: new Map() };
229
+ this.process = agent;
230
+ this.log('agent connected', { agent: init.agentInfo?.name ?? command, protocol: init.protocolVersion });
231
+ return agent;
232
+ }
233
+ handleSessionUpdate(params) {
234
+ const update = params.update;
235
+ if (update.sessionUpdate === 'config_option_update' && this.process) {
236
+ this.rememberConfig(this.process, params.sessionId, update.configOptions);
237
+ return;
238
+ }
239
+ const turn = this.turns.get(params.sessionId);
240
+ if (!turn)
241
+ return;
242
+ switch (update.sessionUpdate) {
243
+ case 'agent_message_chunk':
244
+ if (update.content.type === 'text')
245
+ turn.onEvent({ type: 'text-delta', text: update.content.text });
246
+ break;
247
+ case 'agent_thought_chunk':
248
+ if (update.content.type === 'text')
249
+ turn.onEvent({ type: 'reasoning-delta', text: update.content.text });
250
+ break;
251
+ case 'tool_call':
252
+ case 'tool_call_update':
253
+ turn.onEvent({
254
+ type: 'tool',
255
+ toolCallId: update.toolCallId,
256
+ ...(update.title ? { title: update.title } : {}),
257
+ ...(update.status ? { status: TOOL_STATUS[update.status] } : {}),
258
+ ...(update.rawInput !== undefined ? { input: update.rawInput } : {}),
259
+ ...(update.rawOutput !== undefined || update.content
260
+ ? { output: update.rawOutput ?? toolContentText(update.content) }
261
+ : {}),
262
+ });
263
+ break;
264
+ default:
265
+ break;
266
+ }
267
+ }
268
+ async handlePermission(params) {
269
+ const turn = this.turns.get(params.sessionId);
270
+ if (!turn)
271
+ return { outcome: { outcome: 'cancelled' } };
272
+ const approvalId = `acp-${generateId()}`;
273
+ turn.onEvent({
274
+ type: 'approval-request',
275
+ approvalId,
276
+ toolCallId: params.toolCall.toolCallId,
277
+ toolName: params.toolCall.title || params.toolCall.name || 'tool',
278
+ input: params.toolCall.rawInput,
279
+ });
280
+ const approved = await turn.awaitApproval(approvalId);
281
+ turn.onEvent({ type: 'approval-resolved', approvalId, approved });
282
+ const option = pickOption(params.options, approved);
283
+ return option ? { outcome: { outcome: 'selected', optionId: option.optionId } } : { outcome: { outcome: 'cancelled' } };
284
+ }
285
+ }
@@ -0,0 +1,12 @@
1
+ import type { GatewayAdapter, GatewayAdapterInfo, GatewayTurnInput, GatewayTurnResult } from '../types.js';
2
+ /**
3
+ * Loopback agent for end-to-end checks of the gateway path (app ⇄ NATS ⇄ CLI ⇄
4
+ * PowerSync) without a real runtime. Echoes the prompt; a prompt containing
5
+ * `/approve` exercises the tool-approval round trip first.
6
+ */
7
+ export declare class EchoGatewayAdapter implements GatewayAdapter {
8
+ readonly kind: "echo";
9
+ info(): GatewayAdapterInfo;
10
+ runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
11
+ dispose(): Promise<void>;
12
+ }
@@ -0,0 +1,51 @@
1
+ import { generateId } from '../../utils/id.js';
2
+ const CHUNK_DELAY_MS = 40;
3
+ function delay(ms, signal) {
4
+ return new Promise((resolve, reject) => {
5
+ if (signal.aborted)
6
+ return reject(signal.reason);
7
+ const timer = setTimeout(resolve, ms);
8
+ signal.addEventListener('abort', () => {
9
+ clearTimeout(timer);
10
+ reject(signal.reason);
11
+ }, { once: true });
12
+ });
13
+ }
14
+ /**
15
+ * Loopback agent for end-to-end checks of the gateway path (app ⇄ NATS ⇄ CLI ⇄
16
+ * PowerSync) without a real runtime. Echoes the prompt; a prompt containing
17
+ * `/approve` exercises the tool-approval round trip first.
18
+ */
19
+ export class EchoGatewayAdapter {
20
+ kind = 'echo';
21
+ info() {
22
+ return { kind: this.kind, agentName: 'echo', agentVersion: null };
23
+ }
24
+ async runTurn(input) {
25
+ const sessionId = input.sessionId ?? `echo-${generateId()}`;
26
+ try {
27
+ if (input.prompt.includes('/approve')) {
28
+ const toolCallId = `tool-${generateId()}`;
29
+ const approvalId = `approval-${generateId()}`;
30
+ input.onEvent({ type: 'tool', toolCallId, title: 'echo.write', status: 'pending', input: { text: input.prompt } });
31
+ input.onEvent({ type: 'approval-request', approvalId, toolCallId, toolName: 'echo.write', input: { text: input.prompt } });
32
+ const approved = await input.awaitApproval(approvalId);
33
+ input.onEvent({ type: 'approval-resolved', approvalId, approved });
34
+ input.onEvent(approved
35
+ ? { type: 'tool', toolCallId, status: 'completed', output: 'approved' }
36
+ : { type: 'tool', toolCallId, status: 'failed', output: 'denied by user' });
37
+ }
38
+ for (const word of `Echo: ${input.prompt}`.split(/(\s+)/)) {
39
+ await delay(CHUNK_DELAY_MS, input.signal);
40
+ input.onEvent({ type: 'text-delta', text: word });
41
+ }
42
+ return { sessionId, stopReason: 'end_turn' };
43
+ }
44
+ catch (error) {
45
+ if (input.signal.aborted)
46
+ return { sessionId, stopReason: 'cancelled' };
47
+ throw error;
48
+ }
49
+ }
50
+ async dispose() { }
51
+ }
@@ -0,0 +1,9 @@
1
+ import type { McpServer } from '@agentclientprotocol/sdk';
2
+ import type { GatewayAdapter, GatewayDefinition } from '../types.js';
3
+ /**
4
+ * The Dinox knowledge tools as a stdio MCP server: this same CLI, run as
5
+ * `dino gateway mcp`, reading through the daemon. DINOX_* variables are passed
6
+ * on so the child finds the same config, data dir and daemon socket.
7
+ */
8
+ export declare function dinoxMcpServerConfig(): McpServer | null;
9
+ export declare function createGatewayAdapter(gateway: GatewayDefinition, log: (message: string, details?: Record<string, unknown>) => void): GatewayAdapter;
@@ -0,0 +1,30 @@
1
+ import { AcpGatewayAdapter } from './acp.js';
2
+ import { EchoGatewayAdapter } from './echo.js';
3
+ /**
4
+ * The Dinox knowledge tools as a stdio MCP server: this same CLI, run as
5
+ * `dino gateway mcp`, reading through the daemon. DINOX_* variables are passed
6
+ * on so the child finds the same config, data dir and daemon socket.
7
+ */
8
+ export function dinoxMcpServerConfig() {
9
+ const entrypoint = process.argv[1];
10
+ if (!entrypoint)
11
+ return null;
12
+ return {
13
+ name: 'dinox',
14
+ command: process.execPath,
15
+ args: [...process.execArgv, entrypoint, 'gateway', 'mcp'],
16
+ env: Object.entries(process.env)
17
+ .filter((entry) => entry[0].startsWith('DINOX_') && typeof entry[1] === 'string')
18
+ .map(([name, value]) => ({ name, value })),
19
+ };
20
+ }
21
+ export function createGatewayAdapter(gateway, log) {
22
+ switch (gateway.kind) {
23
+ case 'acp': {
24
+ const dinoxTools = gateway.dinoxTools === false ? null : dinoxMcpServerConfig();
25
+ return new AcpGatewayAdapter(gateway, log, dinoxTools ? [dinoxTools] : []);
26
+ }
27
+ case 'echo':
28
+ return new EchoGatewayAdapter();
29
+ }
30
+ }
@@ -0,0 +1,32 @@
1
+ import type { ChatUIMessage } from '../vendor/electron/shared/external-types.js';
2
+ import type { RemoteToolApprovalPrompt } from '../vendor/electron/shared/remote-device-agent.js';
3
+ import type { GatewayEvent } from './types.js';
4
+ export declare class AssistantProjection {
5
+ private readonly base;
6
+ private readonly parts;
7
+ private readonly toolIndex;
8
+ private readonly approvals;
9
+ private revisionCounter;
10
+ constructor(base: ChatUIMessage);
11
+ get revision(): number;
12
+ apply(event: GatewayEvent): void;
13
+ /** Closes streaming parts; called once when the turn ends. */
14
+ finish(): void;
15
+ snapshot(): ChatUIMessage;
16
+ private appendText;
17
+ private ensureTool;
18
+ private findToolByApproval;
19
+ private applyTool;
20
+ }
21
+ /** Same projection the desktop host sends: open approval requests found in the snapshot. */
22
+ /**
23
+ * The reply as it is kept in the synced transcript. A computer agent's
24
+ * reasoning and tool calls are working state: they stream to the client while
25
+ * the turn runs and stay in the local run snapshot, but only the answer is
26
+ * stored in `c_chat_message`.
27
+ */
28
+ export declare function toTranscriptMessage(message: ChatUIMessage): ChatUIMessage;
29
+ export declare function projectPendingApprovals(message: ChatUIMessage | null | undefined, ids: {
30
+ runId: string;
31
+ threadId: string;
32
+ }): RemoteToolApprovalPrompt[];
@@ -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>;