@dinoxx/dinox-cli 2.6.0 → 2.7.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.
@@ -4,7 +4,7 @@ import { listErrorCodes } from '../../utils/errorCodes.js';
4
4
  import { listCommandSchemas } from './spec.js';
5
5
  const SECTION_ORDER = [
6
6
  { title: 'Auth', paths: ['auth.login', 'auth.logout', 'auth.status'] },
7
- { title: 'Daemon', paths: ['daemon.start', 'daemon.status', 'daemon.restart', 'daemon.stop', 'daemon.install', 'daemon.uninstall'] },
7
+ { title: 'Daemon', paths: ['daemon.start', 'daemon.status', 'daemon.restart', 'daemon.stop', 'daemon.install', 'daemon.uninstall', 'daemon.supervise'] },
8
8
  {
9
9
  title: 'Agent Gateways',
10
10
  paths: ['gateway.detect', 'gateway.add', 'gateway.list', 'gateway.remove', 'gateway.status', 'gateway.reload', 'gateway.mcp'],
@@ -16,6 +16,7 @@ import { getPackageVersion } from '../utils/version.js';
16
16
  import { DAEMON_CHILD_ENV, daemonServiceSpec, envTokenOverridesLogin, launchDaemon } from './launch.js';
17
17
  import { getDaemonLogPath, rotateDaemonLog, writeDaemonLogLine } from './log.js';
18
18
  import { DAEMON_SERVICE_ENV, getDaemonService } from './supervisor.js';
19
+ import { superviseCurrentDaemon } from './supervise.js';
19
20
  import { normalizeAuthToken } from '../utils/authToken.js';
20
21
  const DAEMON_STOP_POLL_INTERVAL_MS = 100;
21
22
  const DAEMON_STOP_TIMEOUT_MS = 5_000;
@@ -380,11 +381,20 @@ export async function uninstallDaemonService() {
380
381
  if (!service) {
381
382
  return { uninstalled: false, service: null, definitionPath: null };
382
383
  }
383
- return withDaemonStartLock(async () => ({
384
- uninstalled: await service.uninstall(),
385
- service: service.kind,
386
- definitionPath: service.definitionPath,
387
- }));
384
+ return withDaemonStartLock(async () => {
385
+ if (service.isInstalled()) {
386
+ // Stop through the socket first: a clean exit is not restarted, and
387
+ // deleting a Windows task does not end the process it started.
388
+ const info = await getDaemonInfo();
389
+ if (info?.supervisor === service.kind)
390
+ await stopDaemonLocked();
391
+ }
392
+ return {
393
+ uninstalled: await service.uninstall(),
394
+ service: service.kind,
395
+ definitionPath: service.definitionPath,
396
+ };
397
+ });
388
398
  }
389
399
  function printDaemonStartResult(result) {
390
400
  if (result.alreadyRunning) {
@@ -432,7 +442,7 @@ function printDaemonInstallResult(result) {
432
442
  printHumanReadableLine(`Installed ${result.service} service ${result.label}`);
433
443
  printHumanReadableLine(`Definition: ${result.definitionPath}`);
434
444
  printHumanReadableLine(`Log: ${result.logPath}`);
435
- printHumanReadableLine('The daemon now starts at login and restarts after a crash.');
445
+ printHumanReadableLine(`The daemon now starts at ${result.service === 'schtasks' ? 'logon (Task Scheduler)' : 'login'} and restarts after a crash.`);
436
446
  if (result.service === 'systemd') {
437
447
  printHumanReadableLine('To keep it running after you log out, run `loginctl enable-linger`.');
438
448
  }
@@ -482,6 +492,9 @@ async function runDaemonStatus() {
482
492
  }
483
493
  printDaemonStatusResult(result);
484
494
  }
495
+ async function runDaemonSupervise(context) {
496
+ process.exitCode = await superviseCurrentDaemon(parsePort(context.options.port));
497
+ }
485
498
  async function runDaemonInstall(context) {
486
499
  const result = await installDaemonService(parsePort(context.options.port));
487
500
  if (getRequestedStructuredOutputFormat()) {
@@ -620,6 +633,24 @@ export const daemonCommandGroup = {
620
633
  },
621
634
  action: runDaemonUninstall,
622
635
  },
636
+ {
637
+ name: 'supervise',
638
+ description: 'Run the daemon in the foreground and restart it after a crash (used by the Windows logon task)',
639
+ options: [{ flags: '--port <number>', description: 'Legacy compatibility field; daemon listens on a private local socket', type: 'number', defaultValue: DEFAULT_DAEMON_PORT }],
640
+ schema: {
641
+ path: 'daemon.supervise',
642
+ risk: 'write',
643
+ auth: 'none',
644
+ globalOptions: [],
645
+ output: {
646
+ type: 'structured',
647
+ summary: 'Runs until the daemon exits cleanly or the supervisor is stopped; no structured payload',
648
+ fields: [],
649
+ },
650
+ examples: ['dino daemon supervise'],
651
+ },
652
+ action: runDaemonSupervise,
653
+ },
623
654
  ],
624
655
  };
625
656
  export function registerDaemonCommands(program) {
@@ -14,7 +14,11 @@ export type DaemonLaunch = {
14
14
  * of its own environment can have.
15
15
  */
16
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. */
17
+ /**
18
+ * What the service definition runs: this CLI, with no secrets written to disk.
19
+ * Task Scheduler cannot restart a crashed process, so on Windows the service
20
+ * runs the supervisor, which runs the daemon.
21
+ */
18
22
  export declare function daemonServiceSpec(service: DaemonService, port: number): DaemonServiceSpec;
19
23
  /** The installed service when this caller can use it, else null. */
20
24
  export declare function usableDaemonService(): Promise<DaemonService | null>;
@@ -6,17 +6,19 @@ import { DinoxError } from '../utils/errors.js';
6
6
  import { buildDaemonEnv } from './env.js';
7
7
  import { closeDaemonLogFd, getDaemonLogPath, openDaemonLogForChild } from './log.js';
8
8
  import { ensureDaemonDir } from './pid.js';
9
- import { DAEMON_SERVICE_ENV, getDaemonService, serviceDefinitionIsStale, } from './supervisor.js';
9
+ import { DAEMON_SERVICE_ENV, getDaemonService, } from './supervisor.js';
10
10
  /** Tells a daemon child its parent holds the start lock (or that it runs under a service). */
11
11
  export const DAEMON_CHILD_ENV = 'DINOX_INTERNAL_DAEMON_CHILD';
12
- function daemonCommandLine(port) {
12
+ function daemonCommandLine(port, mode = 'start') {
13
13
  const entrypoint = process.argv[1];
14
14
  if (!entrypoint) {
15
15
  throw new DinoxError('Cannot determine the CLI entrypoint for daemon startup', { code: 'DAEMON_START_FAILED' });
16
16
  }
17
17
  return {
18
18
  program: process.execPath,
19
- args: [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
19
+ args: mode === 'supervise'
20
+ ? [...process.execArgv, entrypoint, 'daemon', 'supervise', '--port', String(port)]
21
+ : [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
20
22
  };
21
23
  }
22
24
  /**
@@ -28,9 +30,13 @@ export function envTokenOverridesLogin(raw, env = process.env) {
28
30
  const envToken = readEnvAuthToken(env);
29
31
  return Boolean(envToken && envToken !== normalizeAuthToken(raw.auth?.authorization));
30
32
  }
31
- /** What the service definition runs: this CLI, with no secrets written to disk. */
33
+ /**
34
+ * What the service definition runs: this CLI, with no secrets written to disk.
35
+ * Task Scheduler cannot restart a crashed process, so on Windows the service
36
+ * runs the supervisor, which runs the daemon.
37
+ */
32
38
  export function daemonServiceSpec(service, port) {
33
- const { program, args } = daemonCommandLine(port);
39
+ const { program, args } = daemonCommandLine(port, service.kind === 'schtasks' ? 'supervise' : 'start');
34
40
  const env = buildDaemonEnv();
35
41
  delete env[DINOX_TOKEN_ENV_VAR];
36
42
  env[DAEMON_SERVICE_ENV] = service.kind;
@@ -55,6 +61,8 @@ function spawnDetached(port) {
55
61
  stdio: ['ignore', log, log],
56
62
  env: { ...buildDaemonEnv(), [DAEMON_CHILD_ENV]: '1' },
57
63
  cwd: os.homedir(),
64
+ // A detached Windows child otherwise opens its own console window.
65
+ windowsHide: true,
58
66
  });
59
67
  child.unref();
60
68
  return { service: null, abort: () => child.kill('SIGTERM') };
@@ -75,7 +83,7 @@ export async function launchDaemon(port) {
75
83
  return spawnDetached(port);
76
84
  ensureDaemonDir();
77
85
  const spec = daemonServiceSpec(service, port);
78
- if (serviceDefinitionIsStale(service, spec)) {
86
+ if (!service.isCurrent(spec)) {
79
87
  await service.install(spec);
80
88
  }
81
89
  else {
@@ -10,3 +10,5 @@ export declare function openDaemonLogForChild(): number | 'ignore';
10
10
  export declare function closeDaemonLogFd(fd: number | 'ignore'): void;
11
11
  /** One timestamped line on stderr, which a daemon child has pointed at its log. */
12
12
  export declare function writeDaemonLogLine(scope: string, message: string, details?: Record<string, unknown>): void;
13
+ /** One timestamped line appended to the log file itself, for processes with no usable stderr. */
14
+ export declare function appendDaemonLogLine(scope: string, message: string, details?: Record<string, unknown>): void;
@@ -1,4 +1,4 @@
1
- import { closeSync, copyFileSync, openSync, statSync, truncateSync } from 'node:fs';
1
+ import { appendFileSync, closeSync, copyFileSync, openSync, statSync, truncateSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { ensureDaemonDir, getDaemonDir } from './pid.js';
4
4
  const MAX_DAEMON_LOG_BYTES = 5 * 1024 * 1024;
@@ -37,8 +37,21 @@ export function closeDaemonLogFd(fd) {
37
37
  if (fd !== 'ignore')
38
38
  closeSync(fd);
39
39
  }
40
+ function formatLogLine(scope, message, details) {
41
+ const suffix = details ? ` ${JSON.stringify(details)}` : '';
42
+ return `${new Date().toISOString()} [${scope}] ${message}${suffix}\n`;
43
+ }
40
44
  /** One timestamped line on stderr, which a daemon child has pointed at its log. */
41
45
  export function writeDaemonLogLine(scope, message, details) {
42
- const suffix = details ? ` ${JSON.stringify(details)}` : '';
43
- process.stderr.write(`${new Date().toISOString()} [${scope}] ${message}${suffix}\n`);
46
+ process.stderr.write(formatLogLine(scope, message, details));
47
+ }
48
+ /** One timestamped line appended to the log file itself, for processes with no usable stderr. */
49
+ export function appendDaemonLogLine(scope, message, details) {
50
+ try {
51
+ ensureDaemonDir();
52
+ appendFileSync(getDaemonLogPath(), formatLogLine(scope, message, details), { mode: 0o600 });
53
+ }
54
+ catch {
55
+ // Logging is best effort.
56
+ }
44
57
  }
@@ -21,7 +21,7 @@ import { readConversation } from '../gateway/conversationContext.js';
21
21
  const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
22
22
  function daemonSupervisor() {
23
23
  const value = process.env[DAEMON_SERVICE_ENV];
24
- return value === 'launchd' || value === 'systemd' ? value : null;
24
+ return value === 'launchd' || value === 'systemd' || value === 'schtasks' ? value : null;
25
25
  }
26
26
  const DAEMON_PROCESS_VERSION = getPackageVersion();
27
27
  async function readJsonBody(req) {
@@ -0,0 +1,29 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ /** First restart delay after a crash; doubles per consecutive crash. */
3
+ export declare const SUPERVISE_INITIAL_BACKOFF_MS = 1000;
4
+ export declare const SUPERVISE_MAX_BACKOFF_MS = 60000;
5
+ /** A daemon that ran this long counts as healthy, so the next crash restarts quickly again. */
6
+ export declare const SUPERVISE_HEALTHY_RUN_MS = 60000;
7
+ export type SupervisedExit = {
8
+ code: number | null;
9
+ signal: NodeJS.Signals | null;
10
+ };
11
+ export type SuperviseDependencies = {
12
+ startChild: () => ChildProcess;
13
+ sleep: (ms: number) => Promise<void>;
14
+ now: () => number;
15
+ log: (message: string, details?: Record<string, unknown>) => void;
16
+ /** Registers a handler for a stop request (signal); the loop then stops the child and returns. */
17
+ onStopRequest: (handler: () => void) => void;
18
+ };
19
+ /**
20
+ * Keeps one daemon running, the way launchd's KeepAlive.SuccessfulExit=false
21
+ * and systemd's Restart=on-failure do: restarts it after a crash with an
22
+ * exponential backoff, and returns once it exits cleanly (exit code 0 from
23
+ * `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
24
+ * or when asked to stop. Used where the service manager cannot restart the
25
+ * daemon itself (Windows Task Scheduler).
26
+ */
27
+ export declare function superviseDaemon(deps: SuperviseDependencies): Promise<number>;
28
+ /** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
29
+ export declare function superviseCurrentDaemon(port: number): Promise<number>;
@@ -0,0 +1,98 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { appendDaemonLogLine, closeDaemonLogFd, openDaemonLogForChild } from './log.js';
3
+ /** First restart delay after a crash; doubles per consecutive crash. */
4
+ export const SUPERVISE_INITIAL_BACKOFF_MS = 1_000;
5
+ export const SUPERVISE_MAX_BACKOFF_MS = 60_000;
6
+ /** A daemon that ran this long counts as healthy, so the next crash restarts quickly again. */
7
+ export const SUPERVISE_HEALTHY_RUN_MS = 60_000;
8
+ function waitForExit(child) {
9
+ return new Promise((resolve) => {
10
+ let settled = false;
11
+ const settle = (exit) => {
12
+ if (settled)
13
+ return;
14
+ settled = true;
15
+ resolve(exit);
16
+ };
17
+ child.once('exit', (code, signal) => settle({ code, signal }));
18
+ // A child that cannot be spawned at all counts as a crash.
19
+ child.once('error', () => settle({ code: 1, signal: null }));
20
+ });
21
+ }
22
+ /**
23
+ * Keeps one daemon running, the way launchd's KeepAlive.SuccessfulExit=false
24
+ * and systemd's Restart=on-failure do: restarts it after a crash with an
25
+ * exponential backoff, and returns once it exits cleanly (exit code 0 from
26
+ * `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
27
+ * or when asked to stop. Used where the service manager cannot restart the
28
+ * daemon itself (Windows Task Scheduler).
29
+ */
30
+ export async function superviseDaemon(deps) {
31
+ let stopping = false;
32
+ let current = null;
33
+ let wakeSleep = null;
34
+ deps.onStopRequest(() => {
35
+ stopping = true;
36
+ current?.kill('SIGTERM');
37
+ wakeSleep?.();
38
+ });
39
+ let backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
40
+ while (!stopping) {
41
+ const startedAt = deps.now();
42
+ const child = deps.startChild();
43
+ current = child;
44
+ deps.log('daemon started', { pid: child.pid ?? null });
45
+ const exit = await waitForExit(child);
46
+ current = null;
47
+ if (stopping)
48
+ break;
49
+ if (exit.code === 0) {
50
+ deps.log('daemon exited cleanly; supervisor stopping');
51
+ return 0;
52
+ }
53
+ if (deps.now() - startedAt >= SUPERVISE_HEALTHY_RUN_MS)
54
+ backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
55
+ deps.log('daemon crashed; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
56
+ await Promise.race([
57
+ deps.sleep(backoffMs),
58
+ new Promise((resolve) => {
59
+ wakeSleep = resolve;
60
+ }),
61
+ ]);
62
+ wakeSleep = null;
63
+ backoffMs = Math.min(backoffMs * 2, SUPERVISE_MAX_BACKOFF_MS);
64
+ }
65
+ deps.log('supervisor stopped on request');
66
+ return 0;
67
+ }
68
+ /** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
69
+ export async function superviseCurrentDaemon(port) {
70
+ const entrypoint = process.argv[1];
71
+ if (!entrypoint)
72
+ throw new Error('Cannot determine the CLI entrypoint for the daemon');
73
+ // Launched hidden at logon there is no console, so write to the log file directly.
74
+ const log = (message, details) => appendDaemonLogLine('dinox-supervisor', message, details);
75
+ return superviseDaemon({
76
+ startChild: () => {
77
+ const logFd = openDaemonLogForChild();
78
+ try {
79
+ return spawn(process.execPath, [...process.execArgv, entrypoint, 'daemon', 'start', '--port', String(port), '--no-detach'],
80
+ // Same (already clean) environment, which carries the service marker.
81
+ { stdio: ['ignore', logFd, logFd], env: process.env, windowsHide: true });
82
+ }
83
+ finally {
84
+ closeDaemonLogFd(logFd);
85
+ }
86
+ },
87
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
88
+ now: () => Date.now(),
89
+ log,
90
+ onStopRequest: (handler) => {
91
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
92
+ process.on(signal, handler);
93
+ }
94
+ // Like the daemon, never stop on a hangup.
95
+ process.on('SIGHUP', () => undefined);
96
+ },
97
+ });
98
+ }
@@ -7,9 +7,11 @@
7
7
  *
8
8
  * Restart policy is "on failure" only: a clean exit (`dino daemon stop`, a
9
9
  * version/account restart, exclusive DB access for `doctor`) leaves it
10
- * stopped until the CLI starts it again through the manager.
10
+ * stopped until the CLI starts it again through the manager. launchd and
11
+ * systemd restart the daemon themselves; on Windows, Task Scheduler only
12
+ * starts `dino daemon supervise`, which restarts it (see supervise.ts).
11
13
  */
12
- export type DaemonServiceKind = 'launchd' | 'systemd';
14
+ export type DaemonServiceKind = 'launchd' | 'systemd' | 'schtasks';
13
15
  export type DaemonServiceSpec = {
14
16
  program: string;
15
17
  args: string[];
@@ -29,6 +31,8 @@ export type DaemonService = {
29
31
  readonly definitionPath: string;
30
32
  render(spec: DaemonServiceSpec): string;
31
33
  isInstalled(): boolean;
34
+ /** True when the installed definition is exactly what this CLI would write. */
35
+ isCurrent(spec: DaemonServiceSpec): boolean;
32
36
  /** Writes the definition and (re)loads it, which starts the daemon. */
33
37
  install(spec: DaemonServiceSpec): Promise<void>;
34
38
  /** Starts the installed daemon when it is not running. */
@@ -44,16 +48,28 @@ export declare const runServiceCommand: ServiceCommandRunner;
44
48
  * non-default `DINOX_DATA_DIR` gets its own service.
45
49
  */
46
50
  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
51
  export declare function renderLaunchdPlist(label: string, spec: DaemonServiceSpec): string;
50
52
  export declare function renderSystemdUnit(spec: DaemonServiceSpec): string;
53
+ /** Quotes one argument the way CommandLineToArgvW (and so Node) parses it back. */
54
+ export declare function quoteWindowsArg(value: string): string;
55
+ /**
56
+ * Task Scheduler runs console programs in a visible window, so the task runs
57
+ * this script with wscript, which sets the daemon environment and starts the
58
+ * supervisor with a hidden window (the launcher pattern pm2-windows-startup
59
+ * uses). The supervisor then outlives the script.
60
+ */
61
+ export declare function renderWindowsLauncher(spec: DaemonServiceSpec): string;
62
+ export declare function renderWindowsTaskXml(userId: string, wscriptPath: string, launcherPath: string): string;
51
63
  export type DaemonServiceOptions = {
52
64
  platform?: NodeJS.Platform;
53
65
  env?: NodeJS.ProcessEnv;
54
66
  homeDir?: string;
55
67
  uid?: number;
56
68
  run?: ServiceCommandRunner;
69
+ /** Windows: where the launcher script lives (the daemon directory). */
70
+ launcherDir?: string;
71
+ /** Windows: `DOMAIN\\user` the logon task runs as. */
72
+ windowsUserId?: string;
57
73
  };
58
- /** The service manager for this platform, or null where none is supported (Windows). */
74
+ /** The service manager for this platform, or null where none is supported. */
59
75
  export declare function getDaemonService(options?: DaemonServiceOptions): DaemonService | null;
@@ -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, 'utf8');
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
- /** The service manager for this platform, or null where none is supported (Windows). */
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
  }
@@ -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;
@@ -213,6 +213,8 @@ export class AcpGatewayAdapter {
213
213
  cwd: this.gateway.cwd ?? os.homedir(),
214
214
  env: { ...process.env, ...(this.gateway.env ?? {}) },
215
215
  stdio: ['pipe', 'pipe', 'pipe'],
216
+ // The daemon runs without a visible console on Windows; keep agents that way.
217
+ windowsHide: true,
216
218
  });
217
219
  child.stderr.on('data', (chunk) => this.log('agent stderr', { text: chunk.toString('utf8').slice(0, 2000) }));
218
220
  const exited = new Promise((_, reject) => {
@@ -70,7 +70,7 @@ export async function findExecutable(name, envPath = process.env.PATH ?? '') {
70
70
  }
71
71
  /** Starts the agent, runs `initialize`, and stops it. Never leaves the process running. */
72
72
  export async function probeAcpAgent(command, args, timeoutMs = PROBE_TIMEOUT_MS) {
73
- const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env });
73
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], env: process.env, windowsHide: true });
74
74
  let timer;
75
75
  try {
76
76
  const exited = new Promise((_, reject) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dinoxx/dinox-cli",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Dinox CLI",
5
5
  "main": "dist/dinox.js",
6
6
  "scripts": {