@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
@@ -1,4 +1,3 @@
1
- import { spawn } from 'child_process';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { DaemonServer } from './server.js';
4
3
  import { DEFAULT_DAEMON_PORT } from './types.js';
@@ -14,12 +13,32 @@ import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
14
13
  import { withAsyncRelease } from '../utils/asyncRelease.js';
15
14
  import { remainingDeadlineMs } from '../utils/deadline.js';
16
15
  import { getPackageVersion } from '../utils/version.js';
16
+ import { DAEMON_CHILD_ENV, daemonServiceSpec, envTokenOverridesLogin, launchDaemon } from './launch.js';
17
+ import { getDaemonLogPath, rotateDaemonLog, writeDaemonLogLine } from './log.js';
18
+ import { DAEMON_SERVICE_ENV, getDaemonService } from './supervisor.js';
19
+ import { normalizeAuthToken } from '../utils/authToken.js';
17
20
  const DAEMON_STOP_POLL_INTERVAL_MS = 100;
18
21
  const DAEMON_STOP_TIMEOUT_MS = 5_000;
19
- const DAEMON_CHILD_ENV = 'DINOX_INTERNAL_DAEMON_CHILD';
22
+ const DAEMON_SERVICE_READY_TIMEOUT_MS = 20_000;
23
+ const DAEMON_CRASH_STOP_TIMEOUT_MS = 5_000;
20
24
  async function withDaemonStartLock(run) {
21
25
  return withAsyncRelease(() => acquireDaemonStartLock(), run);
22
26
  }
27
+ function installedServiceKind() {
28
+ const service = getDaemonService();
29
+ return service?.isInstalled() ? service.kind : null;
30
+ }
31
+ function notRunningStatus(fields) {
32
+ return {
33
+ running: false,
34
+ transport: 'socket',
35
+ version: null,
36
+ service: installedServiceKind(),
37
+ supervised: null,
38
+ logPath: getDaemonLogPath(),
39
+ ...fields,
40
+ };
41
+ }
23
42
  function runningDaemonStatus(info, pidInfo) {
24
43
  const pidFileMatches = Boolean(pidInfo
25
44
  && pidInfo.pid === info.pid
@@ -33,6 +52,9 @@ function runningDaemonStatus(info, pidInfo) {
33
52
  transport: 'socket',
34
53
  version: info.version,
35
54
  stalePidFile: Boolean(pidInfo && !pidFileMatches),
55
+ service: installedServiceKind(),
56
+ supervised: Boolean(info.supervisor),
57
+ logPath: getDaemonLogPath(),
36
58
  };
37
59
  }
38
60
  function parsePort(value) {
@@ -110,7 +132,7 @@ export async function startDaemonLocked(port, detached, options = {}) {
110
132
  const runningInfo = await getDaemonInfo(remainingStartTimeoutMs('initial-ping'));
111
133
  if (runningInfo) {
112
134
  if (matchesRuntimeTokenBinding(runningInfo, version, baseConfig)) {
113
- return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: true };
135
+ return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: true, service: runningInfo.supervisor ?? null };
114
136
  }
115
137
  throw new DinoxError('A stale daemon is already responding. Run `dino daemon restart` and retry.', {
116
138
  code: 'DAEMON_STALE',
@@ -119,54 +141,78 @@ export async function startDaemonLocked(port, detached, options = {}) {
119
141
  }
120
142
  const expected = await expectedDaemonIdentity(baseConfig, remainingStartTimeoutMs('auth-hydration'));
121
143
  if (detached) {
122
- const args = ['daemon', 'start', '--port', String(port), '--no-detach'];
123
- const entrypoint = process.argv[1];
124
- if (!entrypoint) {
125
- throw new DinoxError('Cannot determine the CLI entrypoint for daemon startup');
126
- }
127
- const child = spawn(process.execPath, [...process.execArgv, entrypoint, ...args], {
128
- detached: true,
129
- stdio: 'ignore',
130
- env: { ...process.env, [DAEMON_CHILD_ENV]: '1' },
131
- });
132
- child.unref();
144
+ const launch = await launchDaemon(port);
133
145
  const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, remainingStartTimeoutMs('ready-poll'));
134
146
  if (!ready) {
135
- child.kill('SIGTERM');
136
- throw new DinoxError('Failed to start daemon');
147
+ launch.abort();
148
+ throw new DinoxError('Failed to start daemon', {
149
+ code: 'DAEMON_START_FAILED',
150
+ details: { service: launch.service, logPath: getDaemonLogPath() },
151
+ suggestion: `Inspect ${getDaemonLogPath()} for the daemon's startup error.`,
152
+ });
137
153
  }
138
- return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false };
154
+ return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false, service: launch.service };
139
155
  }
140
156
  else {
157
+ await serveDaemonInForeground(port, options);
158
+ }
159
+ return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false, service: null };
160
+ }
161
+ /**
162
+ * Runs the daemon in this process until a signal or shutdown request. A crash
163
+ * is logged and exits non-zero, so a service manager restarts it; a clean
164
+ * stop exits 0 and stays stopped.
165
+ */
166
+ async function serveDaemonInForeground(port, options) {
167
+ if (process.env[DAEMON_SERVICE_ENV]) {
168
+ // A service manager holds the log open in append mode; keep it bounded.
169
+ rotateDaemonLog();
170
+ }
171
+ if (!options.quiet) {
172
+ printHumanReadableLine(`Starting daemon on ${getDaemonSocketPath()}...`);
173
+ }
174
+ const server = new DaemonServer();
175
+ try {
176
+ await server.initialize();
177
+ await server.start(port);
178
+ }
179
+ catch (error) {
180
+ await server.stop().catch(() => undefined);
181
+ throw error;
182
+ }
183
+ if (!options.quiet) {
184
+ printHumanReadableLine(`Daemon running on ${getDaemonSocketPath()} (PID: ${process.pid})`);
185
+ }
186
+ writeDaemonLogLine('dinox-daemon', 'started', {
187
+ pid: process.pid,
188
+ version: getPackageVersion(),
189
+ supervisor: process.env[DAEMON_SERVICE_ENV] ?? null,
190
+ });
191
+ const stopAndExit = async (reason, exitCode) => {
192
+ writeDaemonLogLine('dinox-daemon', 'stopping', { reason });
193
+ const forceExit = setTimeout(() => process.exit(exitCode), DAEMON_CRASH_STOP_TIMEOUT_MS);
194
+ forceExit.unref();
195
+ await server.stop().catch(() => undefined);
196
+ process.exit(exitCode);
197
+ };
198
+ process.on('SIGINT', () => {
141
199
  if (!options.quiet) {
142
- printHumanReadableLine(`Starting daemon on ${getDaemonSocketPath()}...`);
143
- }
144
- const server = new DaemonServer();
145
- try {
146
- await server.initialize();
147
- await server.start(port);
148
- }
149
- catch (error) {
150
- await server.stop().catch(() => undefined);
151
- throw error;
152
- }
153
- if (!options.quiet) {
154
- printHumanReadableLine(`Daemon running on ${getDaemonSocketPath()} (PID: ${process.pid})`);
200
+ printHumanReadableLine('');
201
+ printHumanReadableLine('Stopping daemon...');
155
202
  }
156
- process.on('SIGINT', async () => {
157
- if (!options.quiet) {
158
- printHumanReadableLine('');
159
- printHumanReadableLine('Stopping daemon...');
160
- }
161
- await server.stop();
162
- process.exit(0);
163
- });
164
- process.on('SIGTERM', async () => {
165
- await server.stop();
166
- process.exit(0);
203
+ void stopAndExit('SIGINT', 0);
204
+ });
205
+ process.on('SIGTERM', () => void stopAndExit('SIGTERM', 0));
206
+ // No controlling terminal to lose; never let a hangup end the daemon.
207
+ process.on('SIGHUP', () => undefined);
208
+ const crash = (kind) => (error) => {
209
+ writeDaemonLogLine('dinox-daemon', kind, {
210
+ error: error instanceof Error ? error.stack ?? error.message : String(error),
167
211
  });
168
- }
169
- return { running: true, port, socketPath: getDaemonSocketPath(), transport: 'socket', detached, alreadyRunning: false };
212
+ void stopAndExit(kind, 1);
213
+ };
214
+ process.on('uncaughtException', crash('uncaught exception'));
215
+ process.on('unhandledRejection', crash('unhandled rejection'));
170
216
  }
171
217
  async function startDaemon(port, detached = true, options = {}) {
172
218
  const internalChild = process.env[DAEMON_CHILD_ENV] === '1';
@@ -241,7 +287,7 @@ async function daemonStatus() {
241
287
  ? removePid(pidInfo.instanceId)
242
288
  : removePid(undefined, { expectedPid: pidInfo.pid });
243
289
  if (stalePidFile) {
244
- return { running: false, unreachable: false, pid: null, port: null, socketPath: null, transport: 'socket', version: null, stalePidFile: true };
290
+ return notRunningStatus({ unreachable: false, pid: null, port: null, socketPath: null, stalePidFile: true });
245
291
  }
246
292
  // A replacement rewrote the PID metadata after the initial snapshot.
247
293
  // Re-read once and leave the replacement instance untouched.
@@ -251,28 +297,22 @@ async function daemonStatus() {
251
297
  return runningDaemonStatus(replacementInfo, replacementPidInfo);
252
298
  }
253
299
  const replacementSocketExists = process.platform !== 'win32' && existsSync(getDaemonSocketPath());
254
- return {
255
- running: false,
300
+ return notRunningStatus({
256
301
  unreachable: Boolean(replacementPidInfo || replacementSocketExists),
257
302
  pid: replacementPidInfo?.pid ?? null,
258
303
  port: replacementPidInfo?.port ?? null,
259
304
  socketPath: replacementSocketExists ? getDaemonSocketPath() : null,
260
- transport: 'socket',
261
- version: null,
262
305
  stalePidFile: false,
263
- };
306
+ });
264
307
  }
265
308
  const socketExists = process.platform !== 'win32' && existsSync(getDaemonSocketPath());
266
- return {
267
- running: false,
309
+ return notRunningStatus({
268
310
  unreachable: Boolean(pidInfo || socketExists),
269
311
  pid: pidInfo?.pid ?? null,
270
312
  port: pidInfo?.port ?? null,
271
313
  socketPath: socketExists ? getDaemonSocketPath() : null,
272
- transport: 'socket',
273
- version: null,
274
314
  stalePidFile: false,
275
- };
315
+ });
276
316
  }
277
317
  }
278
318
  export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options = {}) {
@@ -284,6 +324,68 @@ export async function restartDaemonLocked(port = DEFAULT_DAEMON_PORT, options =
284
324
  export async function restartDaemon(port = DEFAULT_DAEMON_PORT, options = {}) {
285
325
  return withDaemonStartLock(() => restartDaemonLocked(port, options));
286
326
  }
327
+ /**
328
+ * Hands the daemon to the platform's service manager so it no longer belongs
329
+ * to whichever process first started it, then waits until the managed
330
+ * instance answers with the current identity.
331
+ */
332
+ export async function installDaemonService(port = DEFAULT_DAEMON_PORT) {
333
+ const service = getDaemonService();
334
+ if (!service) {
335
+ throw new DinoxError(`No supported service manager on ${process.platform}; keep using \`dino daemon start\`.`, {
336
+ code: 'DAEMON_SERVICE_UNSUPPORTED',
337
+ details: { platform: process.platform },
338
+ });
339
+ }
340
+ const raw = await loadConfig();
341
+ if (!normalizeAuthToken(raw.auth?.authorization)) {
342
+ throw new DinoxError('The daemon service runs with the saved login, and none is saved.', {
343
+ code: 'AUTH_REQUIRED',
344
+ suggestion: 'Run `dino auth login`, then retry `dino daemon install`.',
345
+ });
346
+ }
347
+ if (envTokenOverridesLogin(raw)) {
348
+ throw new DinoxError('DINOX_TOKEN selects a different account than the saved login, and the daemon service only uses the saved login.', {
349
+ code: 'DAEMON_SERVICE_UNSUPPORTED',
350
+ details: { reason: 'env-token' },
351
+ suggestion: 'Unset DINOX_TOKEN, or save that token with `dino auth login`, then retry.',
352
+ });
353
+ }
354
+ return withDaemonStartLock(async () => {
355
+ const expected = await expectedDaemonIdentity(resolveConfig(raw));
356
+ const stopResult = await stopDaemonLocked();
357
+ await service.install(daemonServiceSpec(service, port));
358
+ const ready = await waitForFreshDaemon(expected.version, expected.userId, expected.tokenFingerprint, expected.runtimeConfigFingerprint, DAEMON_SERVICE_READY_TIMEOUT_MS);
359
+ if (!ready) {
360
+ throw new DinoxError(`The ${service.kind} service was installed, but the daemon did not become ready.`, {
361
+ code: 'DAEMON_START_FAILED',
362
+ details: { service: service.kind, definitionPath: service.definitionPath, logPath: getDaemonLogPath() },
363
+ suggestion: `Inspect ${getDaemonLogPath()}, then run \`dino daemon status\`.`,
364
+ });
365
+ }
366
+ return {
367
+ installed: true,
368
+ service: service.kind,
369
+ label: service.label,
370
+ definitionPath: service.definitionPath,
371
+ logPath: getDaemonLogPath(),
372
+ running: true,
373
+ replacedRunning: stopResult.stopped,
374
+ };
375
+ });
376
+ }
377
+ /** Stops the managed daemon and removes its service definition. */
378
+ export async function uninstallDaemonService() {
379
+ const service = getDaemonService();
380
+ if (!service) {
381
+ return { uninstalled: false, service: null, definitionPath: null };
382
+ }
383
+ return withDaemonStartLock(async () => ({
384
+ uninstalled: await service.uninstall(),
385
+ service: service.kind,
386
+ definitionPath: service.definitionPath,
387
+ }));
388
+ }
287
389
  function printDaemonStartResult(result) {
288
390
  if (result.alreadyRunning) {
289
391
  printHumanReadableLine('Daemon is already running');
@@ -320,10 +422,40 @@ function printDaemonStatusResult(result) {
320
422
  printHumanReadableLine(`Transport: ${result.transport}`);
321
423
  printHumanReadableLine(`Socket: ${result.socketPath}`);
322
424
  printHumanReadableLine(`Version: ${result.version}`);
425
+ printHumanReadableLine(`Service: ${result.service ?? 'not installed'}${result.service && !result.supervised ? ' (running daemon is not managed by it)' : ''}`);
426
+ printHumanReadableLine(`Log: ${result.logPath}`);
427
+ if (!result.supervised) {
428
+ printHumanReadableLine('Note: this daemon belongs to the process that started it. Run `dino daemon install` to keep it (and agent gateways) running independently.');
429
+ }
430
+ }
431
+ function printDaemonInstallResult(result) {
432
+ printHumanReadableLine(`Installed ${result.service} service ${result.label}`);
433
+ printHumanReadableLine(`Definition: ${result.definitionPath}`);
434
+ printHumanReadableLine(`Log: ${result.logPath}`);
435
+ printHumanReadableLine('The daemon now starts at login and restarts after a crash.');
436
+ if (result.service === 'systemd') {
437
+ printHumanReadableLine('To keep it running after you log out, run `loginctl enable-linger`.');
438
+ }
439
+ }
440
+ function printDaemonUninstallResult(result) {
441
+ if (!result.service) {
442
+ printHumanReadableLine(`No supported service manager on ${process.platform}; nothing to uninstall.`);
443
+ return;
444
+ }
445
+ printHumanReadableLine(result.uninstalled
446
+ ? `Removed ${result.service} service; the daemon is stopped.`
447
+ : 'The daemon service is not installed.');
323
448
  }
324
449
  async function runDaemonStart(context) {
325
450
  const port = parsePort(context.options.port);
326
451
  const structured = Boolean(getRequestedStructuredOutputFormat());
452
+ if (context.options.detach === false && process.env[DAEMON_SERVICE_ENV]
453
+ && !resolveConfig(await loadConfig()).auth.authorization) {
454
+ // Started at login by the service while signed out: exit cleanly so the
455
+ // service does not retry; the next command after `dino auth login` starts it.
456
+ writeDaemonLogLine('dinox-daemon', 'not signed in; staying stopped until `dino auth login`');
457
+ return;
458
+ }
327
459
  const result = await startDaemon(port, context.options.detach !== false, { quiet: structured });
328
460
  if (!result.running) {
329
461
  throw new DinoxError('Failed to start daemon', { code: 'DAEMON_START_FAILED' });
@@ -350,6 +482,22 @@ async function runDaemonStatus() {
350
482
  }
351
483
  printDaemonStatusResult(result);
352
484
  }
485
+ async function runDaemonInstall(context) {
486
+ const result = await installDaemonService(parsePort(context.options.port));
487
+ if (getRequestedStructuredOutputFormat()) {
488
+ printSuccess(result);
489
+ return;
490
+ }
491
+ printDaemonInstallResult(result);
492
+ }
493
+ async function runDaemonUninstall() {
494
+ const result = await uninstallDaemonService();
495
+ if (getRequestedStructuredOutputFormat()) {
496
+ printSuccess(result);
497
+ return;
498
+ }
499
+ printDaemonUninstallResult(result);
500
+ }
353
501
  async function runDaemonRestart(context) {
354
502
  const structured = Boolean(getRequestedStructuredOutputFormat());
355
503
  const result = await restartDaemon(parsePort(context.options.port), { quiet: structured });
@@ -379,7 +527,7 @@ export const daemonCommandGroup = {
379
527
  output: {
380
528
  type: 'structured',
381
529
  summary: 'Daemon start acknowledgement',
382
- fields: ['running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning'],
530
+ fields: ['running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning', 'service'],
383
531
  },
384
532
  examples: ['dino daemon start', 'dino daemon start --port 17321', 'dino daemon start --no-detach'],
385
533
  },
@@ -413,7 +561,7 @@ export const daemonCommandGroup = {
413
561
  output: {
414
562
  type: 'structured',
415
563
  summary: 'Daemon process status',
416
- fields: ['running', 'unreachable', 'pid', 'transport', 'socketPath', 'port', 'version', 'stalePidFile'],
564
+ fields: ['running', 'unreachable', 'pid', 'transport', 'socketPath', 'port', 'version', 'stalePidFile', 'service', 'supervised', 'logPath'],
417
565
  },
418
566
  examples: ['dino daemon status'],
419
567
  },
@@ -431,12 +579,47 @@ export const daemonCommandGroup = {
431
579
  output: {
432
580
  type: 'structured',
433
581
  summary: 'Daemon restart acknowledgement',
434
- fields: ['stopped', 'running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning'],
582
+ fields: ['stopped', 'running', 'transport', 'socketPath', 'port', 'detached', 'alreadyRunning', 'service'],
435
583
  },
436
584
  examples: ['dino daemon restart', 'dino daemon restart --port 17321'],
437
585
  },
438
586
  action: runDaemonRestart,
439
587
  },
588
+ {
589
+ name: 'install',
590
+ description: 'Run the daemon as a login service (launchd / systemd) so it outlives the shell or agent that started it',
591
+ options: [{ flags: '--port <number>', description: 'Legacy compatibility field; daemon listens on a private local socket', type: 'number', defaultValue: DEFAULT_DAEMON_PORT }],
592
+ schema: {
593
+ path: 'daemon.install',
594
+ risk: 'write',
595
+ auth: 'required',
596
+ globalOptions: FORMAT_OPTIONS,
597
+ output: {
598
+ type: 'structured',
599
+ summary: 'Installed daemon service',
600
+ fields: ['installed', 'service', 'label', 'definitionPath', 'logPath', 'running', 'replacedRunning'],
601
+ },
602
+ examples: ['dino daemon install'],
603
+ },
604
+ action: runDaemonInstall,
605
+ },
606
+ {
607
+ name: 'uninstall',
608
+ description: 'Stop the daemon service and remove it from the login session',
609
+ schema: {
610
+ path: 'daemon.uninstall',
611
+ risk: 'write',
612
+ auth: 'none',
613
+ globalOptions: FORMAT_OPTIONS,
614
+ output: {
615
+ type: 'structured',
616
+ summary: 'Daemon service removal acknowledgement',
617
+ fields: ['uninstalled', 'service', 'definitionPath'],
618
+ },
619
+ examples: ['dino daemon uninstall'],
620
+ },
621
+ action: runDaemonUninstall,
622
+ },
440
623
  ],
441
624
  };
442
625
  export function registerDaemonCommands(program) {
@@ -0,0 +1,27 @@
1
+ import type { DinoxConfig } from '../config/types.js';
2
+ import { type DaemonService, type DaemonServiceKind, type DaemonServiceSpec } from './supervisor.js';
3
+ /** Tells a daemon child its parent holds the start lock (or that it runs under a service). */
4
+ export declare const DAEMON_CHILD_ENV = "DINOX_INTERNAL_DAEMON_CHILD";
5
+ export type DaemonLaunch = {
6
+ /** The service manager that started it, or null for a detached child process. */
7
+ service: DaemonServiceKind | null;
8
+ /** Gives up on a launch that never became ready. */
9
+ abort(): void;
10
+ };
11
+ /**
12
+ * A service only knows the saved login. When DINOX_TOKEN selects a different
13
+ * account, the caller needs a daemon bound to that token, which only a child
14
+ * of its own environment can have.
15
+ */
16
+ export declare function envTokenOverridesLogin(raw: DinoxConfig, env?: NodeJS.ProcessEnv): boolean;
17
+ /** What the service definition runs: this CLI, with no secrets written to disk. */
18
+ export declare function daemonServiceSpec(service: DaemonService, port: number): DaemonServiceSpec;
19
+ /** The installed service when this caller can use it, else null. */
20
+ export declare function usableDaemonService(): Promise<DaemonService | null>;
21
+ /**
22
+ * Starts a daemon. Caller must hold the daemon start lock and have stopped any
23
+ * stale daemon. Uses the installed service when there is one (refreshing its
24
+ * definition if this CLI's program, arguments or environment changed, e.g.
25
+ * after `dino update`), otherwise a detached child with a clean environment.
26
+ */
27
+ export declare function launchDaemon(port: number): Promise<DaemonLaunch>;
@@ -0,0 +1,87 @@
1
+ import { spawn } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import { loadConfig } from '../config/store.js';
4
+ import { DINOX_TOKEN_ENV_VAR, normalizeAuthToken, readEnvAuthToken } from '../utils/authToken.js';
5
+ import { DinoxError } from '../utils/errors.js';
6
+ import { buildDaemonEnv } from './env.js';
7
+ import { closeDaemonLogFd, getDaemonLogPath, openDaemonLogForChild } from './log.js';
8
+ import { ensureDaemonDir } from './pid.js';
9
+ import { DAEMON_SERVICE_ENV, getDaemonService, serviceDefinitionIsStale, } from './supervisor.js';
10
+ /** Tells a daemon child its parent holds the start lock (or that it runs under a service). */
11
+ export const DAEMON_CHILD_ENV = 'DINOX_INTERNAL_DAEMON_CHILD';
12
+ function daemonCommandLine(port) {
13
+ const entrypoint = process.argv[1];
14
+ if (!entrypoint) {
15
+ throw new DinoxError('Cannot determine the CLI entrypoint for daemon startup', { code: 'DAEMON_START_FAILED' });
16
+ }
17
+ return {
18
+ program: process.execPath,
19
+ args: [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
20
+ };
21
+ }
22
+ /**
23
+ * A service only knows the saved login. When DINOX_TOKEN selects a different
24
+ * account, the caller needs a daemon bound to that token, which only a child
25
+ * of its own environment can have.
26
+ */
27
+ export function envTokenOverridesLogin(raw, env = process.env) {
28
+ const envToken = readEnvAuthToken(env);
29
+ return Boolean(envToken && envToken !== normalizeAuthToken(raw.auth?.authorization));
30
+ }
31
+ /** What the service definition runs: this CLI, with no secrets written to disk. */
32
+ export function daemonServiceSpec(service, port) {
33
+ const { program, args } = daemonCommandLine(port);
34
+ const env = buildDaemonEnv();
35
+ delete env[DINOX_TOKEN_ENV_VAR];
36
+ env[DAEMON_SERVICE_ENV] = service.kind;
37
+ env[DAEMON_CHILD_ENV] = '1';
38
+ return { program, args, env, workingDirectory: os.homedir(), logPath: getDaemonLogPath() };
39
+ }
40
+ /** The installed service when this caller can use it, else null. */
41
+ export async function usableDaemonService() {
42
+ const service = getDaemonService();
43
+ if (!service?.isInstalled())
44
+ return null;
45
+ if (envTokenOverridesLogin(await loadConfig()))
46
+ return null;
47
+ return service;
48
+ }
49
+ function spawnDetached(port) {
50
+ const { program, args } = daemonCommandLine(port);
51
+ const log = openDaemonLogForChild();
52
+ try {
53
+ const child = spawn(program, args, {
54
+ detached: true,
55
+ stdio: ['ignore', log, log],
56
+ env: { ...buildDaemonEnv(), [DAEMON_CHILD_ENV]: '1' },
57
+ cwd: os.homedir(),
58
+ });
59
+ child.unref();
60
+ return { service: null, abort: () => child.kill('SIGTERM') };
61
+ }
62
+ finally {
63
+ closeDaemonLogFd(log);
64
+ }
65
+ }
66
+ /**
67
+ * Starts a daemon. Caller must hold the daemon start lock and have stopped any
68
+ * stale daemon. Uses the installed service when there is one (refreshing its
69
+ * definition if this CLI's program, arguments or environment changed, e.g.
70
+ * after `dino update`), otherwise a detached child with a clean environment.
71
+ */
72
+ export async function launchDaemon(port) {
73
+ const service = await usableDaemonService();
74
+ if (!service)
75
+ return spawnDetached(port);
76
+ ensureDaemonDir();
77
+ const spec = daemonServiceSpec(service, port);
78
+ if (serviceDefinitionIsStale(service, spec)) {
79
+ await service.install(spec);
80
+ }
81
+ else {
82
+ await service.start();
83
+ }
84
+ // The service manager owns the process; a launch that never becomes ready
85
+ // is reported with the log path rather than killed here.
86
+ return { service: service.kind, abort: () => undefined };
87
+ }
@@ -0,0 +1,12 @@
1
+ export declare function getDaemonLogPath(): string;
2
+ /**
3
+ * Keeps one previous generation once the log grows past the limit. Copy then
4
+ * truncate (not rename): a service manager holds the log open in append mode,
5
+ * so the file must stay the same inode.
6
+ */
7
+ export declare function rotateDaemonLog(limitBytes?: number): void;
8
+ /** Opens the log for a daemon child's stdout/stderr; the caller closes it after spawning. */
9
+ export declare function openDaemonLogForChild(): number | 'ignore';
10
+ export declare function closeDaemonLogFd(fd: number | 'ignore'): void;
11
+ /** One timestamped line on stderr, which a daemon child has pointed at its log. */
12
+ export declare function writeDaemonLogLine(scope: string, message: string, details?: Record<string, unknown>): void;
@@ -0,0 +1,44 @@
1
+ import { closeSync, copyFileSync, openSync, statSync, truncateSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { ensureDaemonDir, getDaemonDir } from './pid.js';
4
+ const MAX_DAEMON_LOG_BYTES = 5 * 1024 * 1024;
5
+ export function getDaemonLogPath() {
6
+ return join(getDaemonDir(), 'daemon.log');
7
+ }
8
+ /**
9
+ * Keeps one previous generation once the log grows past the limit. Copy then
10
+ * truncate (not rename): a service manager holds the log open in append mode,
11
+ * so the file must stay the same inode.
12
+ */
13
+ export function rotateDaemonLog(limitBytes = MAX_DAEMON_LOG_BYTES) {
14
+ const logPath = getDaemonLogPath();
15
+ try {
16
+ if (statSync(logPath).size <= limitBytes)
17
+ return;
18
+ copyFileSync(logPath, `${logPath}.1`);
19
+ truncateSync(logPath, 0);
20
+ }
21
+ catch {
22
+ // Missing log or unwritable directory: logging is best effort.
23
+ }
24
+ }
25
+ /** Opens the log for a daemon child's stdout/stderr; the caller closes it after spawning. */
26
+ export function openDaemonLogForChild() {
27
+ try {
28
+ ensureDaemonDir();
29
+ rotateDaemonLog();
30
+ return openSync(getDaemonLogPath(), 'a', 0o600);
31
+ }
32
+ catch {
33
+ return 'ignore';
34
+ }
35
+ }
36
+ export function closeDaemonLogFd(fd) {
37
+ if (fd !== 'ignore')
38
+ closeSync(fd);
39
+ }
40
+ /** One timestamped line on stderr, which a daemon child has pointed at its log. */
41
+ export function writeDaemonLogLine(scope, message, details) {
42
+ const suffix = details ? ` ${JSON.stringify(details)}` : '';
43
+ process.stderr.write(`${new Date().toISOString()} [${scope}] ${message}${suffix}\n`);
44
+ }
@@ -1,6 +1,8 @@
1
1
  import crypto from 'node:crypto';
2
2
  import { createServer } from 'http';
3
3
  import { chmodSync, existsSync } from 'node:fs';
4
+ import { DAEMON_SERVICE_ENV } from './supervisor.js';
5
+ import { writeDaemonLogLine } from './log.js';
4
6
  import { loadConfig } from '../config/store.js';
5
7
  import { fingerprintDaemonRuntimeConfig, resolveConfig } from '../config/resolve.js';
6
8
  import { hydrateResolvedAuthIdentity } from '../auth/userInfo.js';
@@ -15,7 +17,12 @@ import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
15
17
  import { buildStructuredErrorPayload, DinoxError } from '../utils/errors.js';
16
18
  import { getDaemonInfo } from './client.js';
17
19
  import { GatewayService } from '../gateway/service.js';
20
+ import { readConversation } from '../gateway/conversationContext.js';
18
21
  const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
22
+ function daemonSupervisor() {
23
+ const value = process.env[DAEMON_SERVICE_ENV];
24
+ return value === 'launchd' || value === 'systemd' ? value : null;
25
+ }
19
26
  const DAEMON_PROCESS_VERSION = getPackageVersion();
20
27
  async function readJsonBody(req) {
21
28
  return new Promise((resolve, reject) => {
@@ -96,7 +103,7 @@ export class DaemonServer {
96
103
  credentialsUrl: process.env.DINOX_NATS_CREDENTIALS_URL || undefined,
97
104
  });
98
105
  await this.gateways.start().catch((error) => {
99
- process.stderr.write(`[dinox-gateway] start failed: ${String(error)}\n`);
106
+ writeDaemonLogLine('dinox-gateway', 'start failed', { error: String(error) });
100
107
  });
101
108
  }
102
109
  // PowerSync keeps the local cache in sync continuously while connected,
@@ -124,6 +131,7 @@ export class DaemonServer {
124
131
  tokenFingerprint: this.tokenFingerprint,
125
132
  runtimeConfigFingerprint: this.runtimeConfigFingerprint,
126
133
  authenticated: Boolean(this.userId && this.tokenFingerprint),
134
+ supervisor: daemonSupervisor(),
127
135
  },
128
136
  };
129
137
  case 'shutdown': {
@@ -175,6 +183,15 @@ export class DaemonServer {
175
183
  }
176
184
  return { id: req.id, ok: true, result: { gateways: await this.gateways.reload() } };
177
185
  }
186
+ case 'thread.messages': {
187
+ if (!this.userId) {
188
+ throw new DinoxError('Reading conversations needs a signed-in daemon', { code: 'AUTH_REQUIRED' });
189
+ }
190
+ const page = await readConversation(this.db, this.userId, params);
191
+ if (!page)
192
+ throw new DinoxError('Conversation not found', { code: 'NOT_FOUND' });
193
+ return { id: req.id, ok: true, result: { ...page, stale: this.isCacheStale() } };
194
+ }
178
195
  case 'todo.search': {
179
196
  // Same executor as the local `dino todo search` path — no option/result drift.
180
197
  const result = await executeTodoSearch(this.db, params);