@dinoxx/dinox-cli 2.4.0 → 2.6.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 (42) hide show
  1. package/dist/commands/gateway/index.js +26 -4
  2. package/dist/commands/schema/renderSkills.js +1 -1
  3. package/dist/daemon/ensure.js +10 -19
  4. package/dist/daemon/env.d.ts +8 -0
  5. package/dist/daemon/env.js +81 -0
  6. package/dist/daemon/index.d.ts +27 -0
  7. package/dist/daemon/index.js +240 -57
  8. package/dist/daemon/launch.d.ts +27 -0
  9. package/dist/daemon/launch.js +87 -0
  10. package/dist/daemon/log.d.ts +12 -0
  11. package/dist/daemon/log.js +44 -0
  12. package/dist/daemon/server.js +18 -1
  13. package/dist/daemon/supervisor.d.ts +59 -0
  14. package/dist/daemon/supervisor.js +253 -0
  15. package/dist/daemon/types.d.ts +2 -0
  16. package/dist/gateway/adapters/acp.d.ts +10 -5
  17. package/dist/gateway/adapters/acp.js +30 -13
  18. package/dist/gateway/adapters/index.js +3 -2
  19. package/dist/gateway/config.d.ts +11 -1
  20. package/dist/gateway/config.js +38 -0
  21. package/dist/gateway/conversationContext.d.ts +44 -0
  22. package/dist/gateway/conversationContext.js +75 -0
  23. package/dist/gateway/deviceHost.d.ts +10 -1
  24. package/dist/gateway/deviceHost.js +119 -8
  25. package/dist/gateway/keepAwake.d.ts +24 -0
  26. package/dist/gateway/keepAwake.js +64 -0
  27. package/dist/gateway/mcpServer.js +20 -0
  28. package/dist/gateway/protocol.d.ts +88 -1
  29. package/dist/gateway/protocol.js +55 -0
  30. package/dist/gateway/runExecutor.d.ts +24 -0
  31. package/dist/gateway/runExecutor.js +118 -12
  32. package/dist/gateway/service.d.ts +4 -0
  33. package/dist/gateway/service.js +44 -15
  34. package/dist/gateway/threadStore.d.ts +71 -6
  35. package/dist/gateway/threadStore.js +189 -22
  36. package/dist/gateway/types.d.ts +22 -3
  37. package/dist/gateway/types.js +3 -2
  38. package/dist/gateway/workspaceFolders.d.ts +33 -0
  39. package/dist/gateway/workspaceFolders.js +129 -0
  40. package/dist/utils/errorCodes.d.ts +8 -0
  41. package/dist/utils/errorCodes.js +2 -0
  42. package/package.json +1 -1
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A service manager that owns the daemon process. Without one, the daemon is a
3
+ * detached child of whatever ran the first `dino` command; when that was a
4
+ * coding agent, the agent's host may reap it together with the session and
5
+ * every gateway goes offline. Under launchd / systemd the daemon belongs to
6
+ * the login session instead, starts at login, and is restarted after a crash.
7
+ *
8
+ * Restart policy is "on failure" only: a clean exit (`dino daemon stop`, a
9
+ * version/account restart, exclusive DB access for `doctor`) leaves it
10
+ * stopped until the CLI starts it again through the manager.
11
+ */
12
+ export type DaemonServiceKind = 'launchd' | 'systemd';
13
+ export type DaemonServiceSpec = {
14
+ program: string;
15
+ args: string[];
16
+ env: Record<string, string>;
17
+ workingDirectory: string;
18
+ logPath: string;
19
+ };
20
+ export type ServiceCommandResult = {
21
+ code: number;
22
+ stdout: string;
23
+ stderr: string;
24
+ };
25
+ export type ServiceCommandRunner = (command: string, args: string[]) => Promise<ServiceCommandResult>;
26
+ export type DaemonService = {
27
+ readonly kind: DaemonServiceKind;
28
+ readonly label: string;
29
+ readonly definitionPath: string;
30
+ render(spec: DaemonServiceSpec): string;
31
+ isInstalled(): boolean;
32
+ /** Writes the definition and (re)loads it, which starts the daemon. */
33
+ install(spec: DaemonServiceSpec): Promise<void>;
34
+ /** Starts the installed daemon when it is not running. */
35
+ start(): Promise<void>;
36
+ /** Stops the daemon and removes the definition; false when nothing was installed. */
37
+ uninstall(): Promise<boolean>;
38
+ };
39
+ /** Set in the service definition so the daemon can report who supervises it. */
40
+ export declare const DAEMON_SERVICE_ENV = "DINOX_DAEMON_SUPERVISOR";
41
+ export declare const runServiceCommand: ServiceCommandRunner;
42
+ /**
43
+ * One daemon per data directory (it owns that directory's socket), so a
44
+ * non-default `DINOX_DATA_DIR` gets its own service.
45
+ */
46
+ export declare function daemonServiceLabel(env?: NodeJS.ProcessEnv): string;
47
+ /** True when the installed definition differs from what this CLI would write. */
48
+ export declare function serviceDefinitionIsStale(service: DaemonService, spec: DaemonServiceSpec): boolean;
49
+ export declare function renderLaunchdPlist(label: string, spec: DaemonServiceSpec): string;
50
+ export declare function renderSystemdUnit(spec: DaemonServiceSpec): string;
51
+ export type DaemonServiceOptions = {
52
+ platform?: NodeJS.Platform;
53
+ env?: NodeJS.ProcessEnv;
54
+ homeDir?: string;
55
+ uid?: number;
56
+ run?: ServiceCommandRunner;
57
+ };
58
+ /** The service manager for this platform, or null where none is supported (Windows). */
59
+ export declare function getDaemonService(options?: DaemonServiceOptions): DaemonService | null;
@@ -0,0 +1,253 @@
1
+ import crypto from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { getDataDir } from '../config/paths.js';
7
+ import { DinoxError } from '../utils/errors.js';
8
+ /** Set in the service definition so the daemon can report who supervises it. */
9
+ export const DAEMON_SERVICE_ENV = 'DINOX_DAEMON_SUPERVISOR';
10
+ const DEFAULT_LABEL = 'com.dinox.cli.daemon';
11
+ const COMMAND_TIMEOUT_MS = 15_000;
12
+ const LAUNCHD_BOOTSTRAP_ATTEMPTS = 5;
13
+ const LAUNCHD_BOOTSTRAP_RETRY_MS = 300;
14
+ export const runServiceCommand = (command, args) => new Promise((resolve) => {
15
+ execFile(command, args, { timeout: COMMAND_TIMEOUT_MS, encoding: 'utf8' }, (error, stdout, stderr) => {
16
+ const code = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
17
+ resolve({ code, stdout: stdout ?? '', stderr: stderr ?? (error ? String(error.message) : '') });
18
+ });
19
+ });
20
+ /**
21
+ * One daemon per data directory (it owns that directory's socket), so a
22
+ * non-default `DINOX_DATA_DIR` gets its own service.
23
+ */
24
+ export function daemonServiceLabel(env = process.env) {
25
+ if (!env.DINOX_DATA_DIR)
26
+ return DEFAULT_LABEL;
27
+ const hash = crypto.createHash('sha256').update(getDataDir(), 'utf8').digest('hex').slice(0, 8);
28
+ return `${DEFAULT_LABEL}.${hash}`;
29
+ }
30
+ function failed(kind, step, result) {
31
+ const output = (result.stderr || result.stdout).trim();
32
+ return new DinoxError(`${kind} ${step} failed${output ? `: ${output}` : ''}`, {
33
+ code: 'DAEMON_SERVICE_FAILED',
34
+ details: { service: kind, step, exitCode: result.code },
35
+ });
36
+ }
37
+ function writeDefinition(definitionPath, content) {
38
+ mkdirSync(path.dirname(definitionPath), { recursive: true });
39
+ writeFileSync(definitionPath, content, { mode: 0o644 });
40
+ }
41
+ function readDefinition(definitionPath) {
42
+ try {
43
+ return readFileSync(definitionPath, 'utf8');
44
+ }
45
+ catch {
46
+ return null;
47
+ }
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
+ // ---------------------------------------------------------------- launchd
54
+ function xmlEscape(value) {
55
+ return value
56
+ .replace(/&/g, '&amp;')
57
+ .replace(/</g, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;');
60
+ }
61
+ export function renderLaunchdPlist(label, spec) {
62
+ const string = (value) => `<string>${xmlEscape(value)}</string>`;
63
+ const args = [spec.program, ...spec.args].map((arg) => `\t\t${string(arg)}`).join('\n');
64
+ const env = Object.keys(spec.env)
65
+ .sort()
66
+ .map((name) => `\t\t<key>${xmlEscape(name)}</key>\n\t\t${string(spec.env[name])}`)
67
+ .join('\n');
68
+ return `<?xml version="1.0" encoding="UTF-8"?>
69
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
70
+ <plist version="1.0">
71
+ <dict>
72
+ \t<key>Label</key>
73
+ \t${string(label)}
74
+ \t<key>ProgramArguments</key>
75
+ \t<array>
76
+ ${args}
77
+ \t</array>
78
+ \t<key>EnvironmentVariables</key>
79
+ \t<dict>
80
+ ${env}
81
+ \t</dict>
82
+ \t<key>WorkingDirectory</key>
83
+ \t${string(spec.workingDirectory)}
84
+ \t<key>RunAtLoad</key>
85
+ \t<true/>
86
+ \t<key>KeepAlive</key>
87
+ \t<dict>
88
+ \t\t<key>SuccessfulExit</key>
89
+ \t\t<false/>
90
+ \t</dict>
91
+ \t<key>ThrottleInterval</key>
92
+ \t<integer>30</integer>
93
+ \t<key>StandardOutPath</key>
94
+ \t${string(spec.logPath)}
95
+ \t<key>StandardErrorPath</key>
96
+ \t${string(spec.logPath)}
97
+ </dict>
98
+ </plist>
99
+ `;
100
+ }
101
+ class LaunchdService {
102
+ label;
103
+ run;
104
+ kind = 'launchd';
105
+ definitionPath;
106
+ domain;
107
+ target;
108
+ constructor(label, run, homeDir, uid) {
109
+ this.label = label;
110
+ this.run = run;
111
+ this.definitionPath = path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
112
+ this.domain = `gui/${uid}`;
113
+ this.target = `${this.domain}/${label}`;
114
+ }
115
+ render(spec) {
116
+ return renderLaunchdPlist(this.label, spec);
117
+ }
118
+ isInstalled() {
119
+ return existsSync(this.definitionPath);
120
+ }
121
+ async isLoaded() {
122
+ return (await this.run('launchctl', ['print', this.target])).code === 0;
123
+ }
124
+ async bootstrap() {
125
+ // bootout returns before launchd has fully released the label, so a
126
+ // bootstrap right after it can fail transiently.
127
+ let result = null;
128
+ for (let attempt = 0; attempt < LAUNCHD_BOOTSTRAP_ATTEMPTS; attempt += 1) {
129
+ result = await this.run('launchctl', ['bootstrap', this.domain, this.definitionPath]);
130
+ if (result.code === 0)
131
+ return;
132
+ await new Promise((resolve) => setTimeout(resolve, LAUNCHD_BOOTSTRAP_RETRY_MS));
133
+ }
134
+ throw failed(this.kind, 'bootstrap', result);
135
+ }
136
+ async install(spec) {
137
+ if (await this.isLoaded())
138
+ await this.run('launchctl', ['bootout', this.target]);
139
+ writeDefinition(this.definitionPath, this.render(spec));
140
+ await this.bootstrap();
141
+ }
142
+ async start() {
143
+ if (!(await this.isLoaded())) {
144
+ await this.bootstrap();
145
+ return;
146
+ }
147
+ const result = await this.run('launchctl', ['kickstart', this.target]);
148
+ if (result.code !== 0)
149
+ throw failed(this.kind, 'kickstart', result);
150
+ }
151
+ async uninstall() {
152
+ const installed = this.isInstalled();
153
+ if (await this.isLoaded()) {
154
+ const result = await this.run('launchctl', ['bootout', this.target]);
155
+ if (result.code !== 0 && (await this.isLoaded()))
156
+ throw failed(this.kind, 'bootout', result);
157
+ }
158
+ if (installed)
159
+ unlinkSync(this.definitionPath);
160
+ return installed;
161
+ }
162
+ }
163
+ // ---------------------------------------------------------------- systemd
164
+ /** Quotes one word for a systemd command line or assignment value. */
165
+ function systemdQuote(value) {
166
+ const escaped = value
167
+ .replace(/\\/g, '\\\\')
168
+ .replace(/"/g, '\\"')
169
+ .replace(/%/g, '%%')
170
+ .replace(/\$/g, '$$$$')
171
+ .replace(/\n/g, '\\n');
172
+ return `"${escaped}"`;
173
+ }
174
+ export function renderSystemdUnit(spec) {
175
+ const execStart = [spec.program, ...spec.args].map(systemdQuote).join(' ');
176
+ const env = Object.keys(spec.env)
177
+ .sort()
178
+ .map((name) => `Environment=${systemdQuote(`${name}=${spec.env[name]}`)}`)
179
+ .join('\n');
180
+ return `[Unit]
181
+ Description=Dinox CLI daemon
182
+ After=network-online.target
183
+
184
+ [Service]
185
+ Type=simple
186
+ ExecStart=${execStart}
187
+ WorkingDirectory=${spec.workingDirectory.replace(/%/g, '%%')}
188
+ ${env}
189
+ Restart=on-failure
190
+ RestartSec=30
191
+ StandardOutput=append:${spec.logPath}
192
+ StandardError=append:${spec.logPath}
193
+
194
+ [Install]
195
+ WantedBy=default.target
196
+ `;
197
+ }
198
+ class SystemdService {
199
+ label;
200
+ run;
201
+ kind = 'systemd';
202
+ definitionPath;
203
+ unit;
204
+ constructor(label, run, configHome) {
205
+ this.label = label;
206
+ this.run = run;
207
+ this.unit = `${label}.service`;
208
+ this.definitionPath = path.join(configHome, 'systemd', 'user', this.unit);
209
+ }
210
+ render(spec) {
211
+ return renderSystemdUnit(spec);
212
+ }
213
+ isInstalled() {
214
+ return existsSync(this.definitionPath);
215
+ }
216
+ async systemctl(step, ...args) {
217
+ const result = await this.run('systemctl', ['--user', ...args]);
218
+ if (result.code !== 0)
219
+ throw failed(this.kind, step, result);
220
+ }
221
+ async install(spec) {
222
+ writeDefinition(this.definitionPath, this.render(spec));
223
+ await this.systemctl('daemon-reload', 'daemon-reload');
224
+ await this.systemctl('enable', 'enable', this.unit);
225
+ await this.systemctl('restart', 'restart', this.unit);
226
+ }
227
+ async start() {
228
+ await this.systemctl('start', 'start', this.unit);
229
+ }
230
+ async uninstall() {
231
+ if (!this.isInstalled())
232
+ return false;
233
+ await this.run('systemctl', ['--user', 'disable', '--now', this.unit]);
234
+ unlinkSync(this.definitionPath);
235
+ await this.run('systemctl', ['--user', 'daemon-reload']);
236
+ return true;
237
+ }
238
+ }
239
+ /** The service manager for this platform, or null where none is supported (Windows). */
240
+ export function getDaemonService(options = {}) {
241
+ const platform = options.platform ?? process.platform;
242
+ const env = options.env ?? process.env;
243
+ const homeDir = options.homeDir ?? os.homedir();
244
+ const run = options.run ?? runServiceCommand;
245
+ const label = daemonServiceLabel(env);
246
+ if (platform === 'darwin') {
247
+ return new LaunchdService(label, run, homeDir, options.uid ?? process.getuid?.() ?? 0);
248
+ }
249
+ if (platform === 'linux') {
250
+ return new SystemdService(label, run, env.XDG_CONFIG_HOME || path.join(homeDir, '.config'));
251
+ }
252
+ return null;
253
+ }
@@ -42,6 +42,8 @@ export type DaemonPingResult = {
42
42
  runtimeConfigFingerprint: string | null;
43
43
  /** True only for daemons using the current authenticated local protocol. */
44
44
  authenticated: boolean;
45
+ /** Service manager that owns the process; null or absent (older daemons) when unmanaged. */
46
+ supervisor?: 'launchd' | 'systemd' | null;
45
47
  };
46
48
  /** Kept only for old scripts that pass --port; the daemon transport is socket-based. */
47
49
  export declare const DEFAULT_DAEMON_PORT = 45678;
@@ -1,8 +1,9 @@
1
- import { type McpServer, type SessionConfigOption } from '@agentclientprotocol/sdk';
1
+ import { type McpServer, type SessionConfigOption, type ToolKind } from '@agentclientprotocol/sdk';
2
2
  import type { GatewayAdapter, GatewayAdapterInfo, GatewayConfigOption, GatewayDefinition, GatewayTurnInput, GatewayTurnResult } from '../types.js';
3
3
  type Logger = (message: string, details?: Record<string, unknown>) => void;
4
4
  /** Select options only; boolean toggles have no use in the app yet. */
5
5
  export declare function normalizeConfigOptions(options: SessionConfigOption[] | null | undefined): GatewayConfigOption[];
6
+ export declare function isReadOnlyToolKind(kind: ToolKind | null | undefined): boolean;
6
7
  /**
7
8
  * Drives any Agent Client Protocol agent over stdio (`hermes acp`, `openclaw acp`,
8
9
  * `claude-code-acp`, `codex-acp`, `pi-acp`, ...). One long-lived process per
@@ -10,9 +11,10 @@ export declare function normalizeConfigOptions(options: SessionConfigOption[] |
10
11
  * each turn sends only the new prompt.
11
12
  */
12
13
  export declare class AcpGatewayAdapter implements GatewayAdapter {
14
+ /** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
13
15
  private readonly gateway;
14
16
  private readonly log;
15
- /** MCP servers offered to every session, e.g. the Dinox knowledge tools. */
17
+ /** MCP servers offered to sessions as they are created or loaded, e.g. the Dinox knowledge tools. */
16
18
  private readonly mcpServers;
17
19
  readonly kind: "acp";
18
20
  private process;
@@ -20,13 +22,16 @@ export declare class AcpGatewayAdapter implements GatewayAdapter {
20
22
  private readonly turns;
21
23
  /** The latest settings any session reported: the agent-wide choices the app lists. */
22
24
  private catalog;
23
- constructor(gateway: GatewayDefinition, log?: Logger,
24
- /** MCP servers offered to every session, e.g. the Dinox knowledge tools. */
25
- mcpServers?: McpServer[]);
25
+ constructor(
26
+ /** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
27
+ gateway: GatewayDefinition, log?: Logger,
28
+ /** MCP servers offered to sessions as they are created or loaded, e.g. the Dinox knowledge tools. */
29
+ mcpServers?: () => McpServer[]);
26
30
  info(): GatewayAdapterInfo;
27
31
  runTurn(input: GatewayTurnInput): Promise<GatewayTurnResult>;
28
32
  describeConfig(): Promise<GatewayConfigOption[]>;
29
33
  dispose(): Promise<void>;
34
+ /** Continues the session (loading it in `cwd` when this process has not seen it) or starts one there. */
30
35
  private resolveSession;
31
36
  /**
32
37
  * Brings the session to the requested settings before its turn. Entries the
@@ -52,6 +52,11 @@ export function normalizeConfigOptions(options) {
52
52
  }
53
53
  return normalized;
54
54
  }
55
+ /** Tool kinds that only look at things; `auto_read` lets them run without asking. */
56
+ const READ_ONLY_TOOL_KINDS = new Set(['read', 'search', 'think']);
57
+ export function isReadOnlyToolKind(kind) {
58
+ return kind != null && READ_ONLY_TOOL_KINDS.has(kind);
59
+ }
55
60
  function pickOption(options, approved) {
56
61
  const preferred = approved ? ['allow_once', 'allow_always'] : ['reject_once', 'reject_always'];
57
62
  for (const kind of preferred) {
@@ -77,9 +82,11 @@ export class AcpGatewayAdapter {
77
82
  turns = new Map();
78
83
  /** The latest settings any session reported: the agent-wide choices the app lists. */
79
84
  catalog = null;
80
- constructor(gateway, log = () => { },
81
- /** MCP servers offered to every session, e.g. the Dinox knowledge tools. */
82
- mcpServers = []) {
85
+ constructor(
86
+ /** Live: settings changed from the app (approval, Dinox tools) apply to the next request. */
87
+ gateway, log = () => { },
88
+ /** MCP servers offered to sessions as they are created or loaded, e.g. the Dinox knowledge tools. */
89
+ mcpServers = () => []) {
83
90
  this.gateway = gateway;
84
91
  this.log = log;
85
92
  this.mcpServers = mcpServers;
@@ -90,7 +97,7 @@ export class AcpGatewayAdapter {
90
97
  }
91
98
  async runTurn(input) {
92
99
  const agent = await this.ensureProcess();
93
- const sessionId = await this.resolveSession(agent, input.sessionId);
100
+ const { sessionId, created } = await this.resolveSession(agent, input.sessionId, input.cwd);
94
101
  await this.applyConfig(agent, sessionId, input.config);
95
102
  this.turns.set(sessionId, { onEvent: input.onEvent, awaitApproval: input.awaitApproval });
96
103
  const onAbort = () => {
@@ -98,7 +105,8 @@ export class AcpGatewayAdapter {
98
105
  };
99
106
  input.signal.addEventListener('abort', onAbort, { once: true });
100
107
  try {
101
- const prompt = [{ type: 'text', text: input.prompt }];
108
+ const text = created && input.sessionPreamble ? `${input.sessionPreamble}\n\n${input.prompt}` : input.prompt;
109
+ const prompt = [{ type: 'text', text }];
102
110
  for (const resource of input.resources) {
103
111
  prompt.push({ type: 'resource_link', uri: resource.uri, name: resource.name, mimeType: resource.mediaType ?? null });
104
112
  }
@@ -121,9 +129,13 @@ export class AcpGatewayAdapter {
121
129
  const agent = await this.ensureProcess();
122
130
  if (this.catalog)
123
131
  return this.catalog;
124
- // No session yet: open one only to read the agent's choices. It never runs a turn.
132
+ // No session yet: open one only to read the agent's choices. It never runs a turn,
133
+ // so close it where the agent allows, or it shows up in the user's session list.
125
134
  const created = await agent.connection.newSession({ cwd: this.gateway.cwd ?? os.homedir(), mcpServers: [] });
126
135
  this.rememberConfig(agent, created.sessionId, created.configOptions);
136
+ if (agent.init.agentCapabilities?.sessionCapabilities?.close) {
137
+ await agent.connection.closeSession({ sessionId: created.sessionId }).catch((error) => this.log('closing the settings session failed', { error: String(error) }));
138
+ }
127
139
  return this.catalog ?? [];
128
140
  }
129
141
  async dispose() {
@@ -135,26 +147,26 @@ export class AcpGatewayAdapter {
135
147
  agent.child.stdin.end();
136
148
  agent.child.kill('SIGTERM');
137
149
  }
138
- async resolveSession(agent, sessionId) {
139
- const cwd = this.gateway.cwd ?? os.homedir();
150
+ /** Continues the session (loading it in `cwd` when this process has not seen it) or starts one there. */
151
+ async resolveSession(agent, sessionId, cwd) {
140
152
  if (sessionId && agent.liveSessions.has(sessionId))
141
- return sessionId;
153
+ return { sessionId, created: false };
142
154
  if (sessionId && agent.init.agentCapabilities?.loadSession) {
143
155
  try {
144
156
  // 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 });
157
+ const loaded = await agent.connection.loadSession({ sessionId, cwd, mcpServers: this.mcpServers() });
146
158
  agent.liveSessions.add(sessionId);
147
159
  this.rememberConfig(agent, sessionId, loaded.configOptions);
148
- return sessionId;
160
+ return { sessionId, created: false };
149
161
  }
150
162
  catch (error) {
151
163
  this.log('session/load failed; starting a new session', { sessionId, error: String(error) });
152
164
  }
153
165
  }
154
- const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers });
166
+ const created = await agent.connection.newSession({ cwd, mcpServers: this.mcpServers() });
155
167
  agent.liveSessions.add(created.sessionId);
156
168
  this.rememberConfig(agent, created.sessionId, created.configOptions);
157
- return created.sessionId;
169
+ return { sessionId: created.sessionId, created: true };
158
170
  }
159
171
  /**
160
172
  * Brings the session to the requested settings before its turn. Entries the
@@ -269,6 +281,11 @@ export class AcpGatewayAdapter {
269
281
  const turn = this.turns.get(params.sessionId);
270
282
  if (!turn)
271
283
  return { outcome: { outcome: 'cancelled' } };
284
+ if (this.gateway.approval === 'auto_read' && isReadOnlyToolKind(params.toolCall.kind)) {
285
+ const option = pickOption(params.options, true);
286
+ if (option)
287
+ return { outcome: { outcome: 'selected', optionId: option.optionId } };
288
+ }
272
289
  const approvalId = `acp-${generateId()}`;
273
290
  turn.onEvent({
274
291
  type: 'approval-request',
@@ -21,8 +21,9 @@ export function dinoxMcpServerConfig() {
21
21
  export function createGatewayAdapter(gateway, log) {
22
22
  switch (gateway.kind) {
23
23
  case 'acp': {
24
- const dinoxTools = gateway.dinoxTools === false ? null : dinoxMcpServerConfig();
25
- return new AcpGatewayAdapter(gateway, log, dinoxTools ? [dinoxTools] : []);
24
+ const dinoxTools = dinoxMcpServerConfig();
25
+ // Read per session, so turning the Dinox tools off applies to the next new conversation.
26
+ return new AcpGatewayAdapter(gateway, log, () => (gateway.dinoxTools !== false && dinoxTools ? [dinoxTools] : []));
26
27
  }
27
28
  case 'echo':
28
29
  return new EchoGatewayAdapter();
@@ -1,4 +1,4 @@
1
- import type { GatewayDefinition, GatewayKind } from './types.js';
1
+ import type { GatewayApprovalMode, GatewayDefinition, GatewayKind } from './types.js';
2
2
  /** Gateways belong to an account: each one registers a robot for that user. */
3
3
  export declare function getGatewaysFilePath(userId: string): string;
4
4
  /**
@@ -46,5 +46,15 @@ export declare function adoptDetectedAgent(userId: string, agent: {
46
46
  gateway: GatewayDefinition;
47
47
  change: 'added' | 'updated' | 'unchanged';
48
48
  }>;
49
+ /** What the owner may change from the app; never commands, arguments or environment. */
50
+ export type GatewaySettingsPatch = {
51
+ /** An absolute directory already checked by the caller. */
52
+ cwd?: string;
53
+ approval?: GatewayApprovalMode;
54
+ paused?: boolean;
55
+ dinoxTools?: boolean;
56
+ };
57
+ /** Applies a settings change to one gateway and saves it; returns the updated definition. */
58
+ export declare function updateGatewaySettings(userId: string, gatewayId: string, patch: GatewaySettingsPatch): Promise<GatewayDefinition>;
49
59
  export declare function findGateway(gateways: GatewayDefinition[], ref: string): GatewayDefinition | undefined;
50
60
  export declare function removeGateway(userId: string, ref: string): Promise<GatewayDefinition>;
@@ -1,5 +1,6 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs/promises';
3
+ import os from 'node:os';
3
4
  import path from 'node:path';
4
5
  import { getConfigDir, normalizeUserPathSegment } from '../config/paths.js';
5
6
  import { DinoxError } from '../utils/errors.js';
@@ -119,9 +120,12 @@ function parseGateway(value) {
119
120
  ...(typeof raw.command === 'string' ? { command: raw.command } : {}),
120
121
  ...(Array.isArray(raw.args) ? { args: raw.args.filter((arg) => typeof arg === 'string') } : {}),
121
122
  ...(typeof raw.cwd === 'string' ? { cwd: raw.cwd } : {}),
123
+ ...(typeof raw.previousCwd === 'string' ? { previousCwd: raw.previousCwd } : {}),
122
124
  ...(env ? { env } : {}),
123
125
  enabled: raw.enabled !== false,
124
126
  ...(raw.dinoxTools === false ? { dinoxTools: false } : {}),
127
+ ...(raw.approval === 'auto_read' ? { approval: 'auto_read' } : {}),
128
+ ...(raw.paused === true ? { paused: true } : {}),
125
129
  createdAt: typeof raw.createdAt === 'string' ? raw.createdAt : new Date(0).toISOString(),
126
130
  };
127
131
  }
@@ -239,6 +243,40 @@ export async function adoptDetectedAgent(userId, agent) {
239
243
  });
240
244
  return { gateway, change: 'added' };
241
245
  }
246
+ /** Applies a settings change to one gateway and saves it; returns the updated definition. */
247
+ export async function updateGatewaySettings(userId, gatewayId, patch) {
248
+ const gateways = await loadGateways(userId);
249
+ const existing = gateways.find((gateway) => gateway.id === gatewayId);
250
+ if (!existing) {
251
+ throw new DinoxError(`Gateway not found: ${gatewayId}`, { code: 'NOT_FOUND' });
252
+ }
253
+ const updated = { ...existing };
254
+ if (patch.cwd !== undefined && patch.cwd !== existing.cwd) {
255
+ // Sessions bound before they recorded a directory started in the old default.
256
+ updated.previousCwd = existing.previousCwd ?? existing.cwd ?? os.homedir();
257
+ updated.cwd = patch.cwd;
258
+ }
259
+ if (patch.approval !== undefined) {
260
+ if (patch.approval === 'ask')
261
+ delete updated.approval;
262
+ else
263
+ updated.approval = patch.approval;
264
+ }
265
+ if (patch.paused !== undefined) {
266
+ if (patch.paused)
267
+ updated.paused = true;
268
+ else
269
+ delete updated.paused;
270
+ }
271
+ if (patch.dinoxTools !== undefined) {
272
+ if (patch.dinoxTools)
273
+ delete updated.dinoxTools;
274
+ else
275
+ updated.dinoxTools = false;
276
+ }
277
+ await saveGateways(userId, gateways.map((gateway) => (gateway.id === gatewayId ? updated : gateway)));
278
+ return updated;
279
+ }
242
280
  export function findGateway(gateways, ref) {
243
281
  return gateways.find((gateway) => gateway.id === ref || gateway.name === ref || gateway.deviceId === ref);
244
282
  }
@@ -0,0 +1,44 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
2
+ import { type TranscriptMessage } from './threadStore.js';
3
+ /**
4
+ * Catch-up context for an agent session. A Dinox thread can switch agents
5
+ * mid-conversation, and each agent keeps only its own session, so before a
6
+ * turn the gateway hands the agent the transcript messages its session has not
7
+ * seen. Only recent messages travel with the prompt; the agent can read the
8
+ * rest with the `read_conversation` Dinox tool (pull on demand, like a handoff
9
+ * brief plus a link rather than a full transcript dump).
10
+ */
11
+ /** Messages read for one catch-up; anything older is only counted. */
12
+ export declare const CONTEXT_MESSAGE_LIMIT = 20;
13
+ export type ConversationContext = {
14
+ threadId: string;
15
+ /** Unseen messages, oldest first. */
16
+ messages: TranscriptMessage[];
17
+ /** Unseen messages older than `messages`. */
18
+ earlier: number;
19
+ /** The latest messages could not be loaded here (not synced yet, or the read failed). */
20
+ incomplete?: boolean;
21
+ };
22
+ /**
23
+ * Prefixes the turn's prompt with the unseen messages, newest kept first
24
+ * within the size budget. Returns the prompt unchanged when there is nothing
25
+ * to catch up on.
26
+ */
27
+ export declare function withConversationContext(prompt: string, context: ConversationContext | null, options: {
28
+ canReadConversation: boolean;
29
+ }): string;
30
+ export type ReadConversationParams = {
31
+ threadId: string;
32
+ beforeMessageId?: string;
33
+ limit?: number;
34
+ };
35
+ export declare const READ_CONVERSATION_MAX_LIMIT = 50;
36
+ /**
37
+ * A page of one of the user's conversations for the `read_conversation` tool:
38
+ * the newest messages before `beforeMessageId`, oldest first. Page back by
39
+ * passing the first returned id as the next `beforeMessageId`.
40
+ */
41
+ export declare function readConversation(db: AbstractPowerSyncDatabase, userId: string, params: ReadConversationParams): Promise<{
42
+ messages: TranscriptMessage[];
43
+ hasMore: boolean;
44
+ } | null>;