shennian 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @shennian/cli
2
2
 
3
- > Shennian AI Agent 移动控制台的本机 daemon / CLI
3
+ > Shennian AI Agent 移动控制台的本机后台服务 / CLI
4
4
  >
5
5
  > **架构设计** → [docs/architecture/cli/](../../docs/architecture/cli/)
6
6
  > - [Daemon 架构](../../docs/architecture/cli/daemon.md) — 模块拓扑、数据流、守护进程
@@ -13,10 +13,17 @@
13
13
 
14
14
  | 命令 | 说明 |
15
15
  |------|------|
16
- | `shennian` | 智能启动:必要时 pair 再启动 daemon |
16
+ | `shennian` | 智能启动:必要时 pair 再启动后台服务 |
17
17
  | `shennian pair` | 重新与账号 / server 配对 |
18
- | `shennian daemon status` | 查看 daemon 运行状态 |
18
+ | `shennian start` | 启动后台服务 |
19
+ | `shennian stop` | 停止后台服务 |
20
+ | `shennian status` | 查看后台服务状态 |
21
+ | `shennian logs -n 100` | 查看最近日志 |
19
22
  | `shennian config` | 查看本地配置 |
23
+ | `shennian agent add/list/remove` | 管理自定义 Agent |
24
+ | `shennian upgrade` | 升级 CLI |
25
+
26
+ `start`、`stop`、`status` 可选加 `--json`。`logs` 输出日志文本,不支持 `--json`。
20
27
 
21
28
  ## 开发
22
29
 
@@ -1,5 +1,16 @@
1
1
  import type { Command } from 'commander';
2
+ type Platform = 'darwin' | 'linux' | 'win32';
2
3
  type ServiceLaunchMode = 'direct' | 'global-shim' | 'npx';
4
+ export type DaemonStatus = {
5
+ running: boolean;
6
+ pid: number | null;
7
+ stale: boolean;
8
+ remoteAccessDisabled: boolean;
9
+ platform: Platform;
10
+ shennianDir: string;
11
+ pidFile: string;
12
+ logFile: string;
13
+ };
3
14
  export type ServiceLaunchSpec = {
4
15
  command: string;
5
16
  args: string[];
@@ -11,7 +22,12 @@ export declare function resolveServiceLaunchSpec(input: {
11
22
  scriptPath: string;
12
23
  shennianCommandPath?: string | null;
13
24
  npxPath?: string | null;
25
+ desktopBridgePath?: string | null;
14
26
  }): ServiceLaunchSpec;
27
+ export declare function isRemoteAccessDisabled(): boolean;
28
+ export declare function getDaemonStatus(opts?: {
29
+ cleanupStale?: boolean;
30
+ }): DaemonStatus;
15
31
  export declare function buildWindowsLauncherCommand(spec: ServiceLaunchSpec, logFile: string): string;
16
32
  export declare function buildWindowsStartupVbs(cmdPath: string): string;
17
33
  export declare function buildWindowsScheduledTaskXml(input: {
@@ -25,7 +41,9 @@ export declare function captureEnvForService(): Record<string, string>;
25
41
  * This is critical for launchd/systemd which start with minimal env.
26
42
  */
27
43
  export declare function saveEnvSnapshot(): void;
28
- export declare function startDaemonProcess(): void;
44
+ export declare function startDaemonProcess(opts?: {
45
+ quiet?: boolean;
46
+ }): void;
29
47
  /**
30
48
  * Install the platform-native auto-start service.
31
49
  * Returns true if the service was immediately started (so caller can skip startDaemonProcess).
@@ -11,6 +11,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const SHENNIAN_DIR = getShennianDir();
12
12
  const PID_FILE = resolveShennianPath('daemon.pid');
13
13
  const LOG_FILE = resolveShennianPath('daemon.log');
14
+ const REMOTE_ACCESS_DISABLED_FILE = resolveShennianPath('remote-access.disabled');
14
15
  const SHENNIAN_SCRIPT = path.resolve(__dirname, '../../bin/shennian.js');
15
16
  const NODE_EXEC = process.execPath;
16
17
  function getPlatform() {
@@ -29,29 +30,38 @@ export function isEphemeralCliPath(candidate) {
29
30
  }
30
31
  export function resolveServiceLaunchSpec(input) {
31
32
  if (fs.existsSync(input.scriptPath) && !isEphemeralCliPath(input.scriptPath)) {
33
+ if (process.env.ELECTRON_RUN_AS_NODE === '1' &&
34
+ input.desktopBridgePath &&
35
+ fs.existsSync(input.desktopBridgePath)) {
36
+ return {
37
+ command: input.nodeExec,
38
+ args: [input.desktopBridgePath, input.scriptPath, 'run-service'],
39
+ mode: 'direct',
40
+ };
41
+ }
32
42
  return {
33
43
  command: input.nodeExec,
34
- args: [input.scriptPath, 'start'],
44
+ args: [input.scriptPath, 'run-service'],
35
45
  mode: 'direct',
36
46
  };
37
47
  }
38
48
  if (input.shennianCommandPath && !isEphemeralCliPath(input.shennianCommandPath)) {
39
49
  return {
40
50
  command: input.shennianCommandPath,
41
- args: ['start'],
51
+ args: ['run-service'],
42
52
  mode: 'global-shim',
43
53
  };
44
54
  }
45
55
  if (input.npxPath) {
46
56
  return {
47
57
  command: input.npxPath,
48
- args: ['--yes', 'shennian', 'start'],
58
+ args: ['--yes', 'shennian', 'run-service'],
49
59
  mode: 'npx',
50
60
  };
51
61
  }
52
62
  return {
53
63
  command: input.nodeExec,
54
- args: [input.scriptPath, 'start'],
64
+ args: [input.scriptPath, 'run-service'],
55
65
  mode: 'direct',
56
66
  };
57
67
  }
@@ -75,6 +85,35 @@ function isRunning(pid) {
75
85
  return false;
76
86
  }
77
87
  }
88
+ export function isRemoteAccessDisabled() {
89
+ return fs.existsSync(REMOTE_ACCESS_DISABLED_FILE);
90
+ }
91
+ export function getDaemonStatus(opts = {}) {
92
+ const pid = readPid();
93
+ const running = pid !== null && isRunning(pid);
94
+ const stale = pid !== null && !running;
95
+ if (stale && opts.cleanupStale) {
96
+ try {
97
+ fs.unlinkSync(PID_FILE);
98
+ }
99
+ catch {
100
+ // Another process may have cleaned up the stale pid file first.
101
+ }
102
+ }
103
+ return {
104
+ running,
105
+ pid,
106
+ stale,
107
+ remoteAccessDisabled: isRemoteAccessDisabled(),
108
+ platform: getPlatform(),
109
+ shennianDir: SHENNIAN_DIR,
110
+ pidFile: PID_FILE,
111
+ logFile: LOG_FILE,
112
+ };
113
+ }
114
+ function printJson(value) {
115
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
116
+ }
78
117
  // ─── Service definitions ─────────────────────────────────────────────────────
79
118
  const LAUNCHD_LABEL = 'com.shennian.agent';
80
119
  const LAUNCHD_PLIST = path.join(os.homedir(), 'Library/LaunchAgents', `${LAUNCHD_LABEL}.plist`);
@@ -113,6 +152,7 @@ function resolveCurrentServiceLaunchSpec() {
113
152
  scriptPath: SHENNIAN_SCRIPT,
114
153
  shennianCommandPath: findCommandPath('shennian'),
115
154
  npxPath: resolveNpxPath(),
155
+ desktopBridgePath: process.env.SHENNIAN_DESKTOP_CLI_BRIDGE,
116
156
  });
117
157
  }
118
158
  function escapeXml(text) {
@@ -142,12 +182,7 @@ function isWindowsCmdScript(filePath) {
142
182
  export function buildWindowsLauncherCommand(spec, logFile) {
143
183
  const commandLine = [spec.command, ...spec.args].map(quoteCmdArg).join(' ');
144
184
  const invocation = isWindowsCmdScript(spec.command) ? `call ${commandLine}` : commandLine;
145
- return [
146
- '@echo off',
147
- 'setlocal',
148
- `${invocation} >> ${quoteCmdArg(logFile)} 2>&1`,
149
- '',
150
- ].join('\r\n');
185
+ return ['@echo off', 'setlocal', `${invocation} >> ${quoteCmdArg(logFile)} 2>&1`, ''].join('\r\n');
151
186
  }
152
187
  export function buildWindowsStartupVbs(cmdPath) {
153
188
  return [
@@ -269,7 +304,9 @@ function installWindowsScheduledTask() {
269
304
  });
270
305
  fs.writeFileSync(WINDOWS_TASK_XML, taskXml, 'utf8');
271
306
  try {
272
- execSync(`schtasks /create /tn "${WINDOWS_TASK_NAME}" /xml "${WINDOWS_TASK_XML}" /f`, { stdio: 'pipe' });
307
+ execSync(`schtasks /create /tn "${WINDOWS_TASK_NAME}" /xml "${WINDOWS_TASK_XML}" /f`, {
308
+ stdio: 'pipe',
309
+ });
273
310
  execSync(`schtasks /run /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe' });
274
311
  return true;
275
312
  }
@@ -279,12 +316,31 @@ function installWindowsScheduledTask() {
279
316
  }
280
317
  export function captureEnvForService() {
281
318
  const keep = [
282
- 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'LANG',
283
- 'LC_ALL', 'LC_CTYPE', 'SSH_AUTH_SOCK', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME',
284
- 'TEMP', 'TMP',
285
- 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL',
286
- 'OPENAI_API_KEY', 'OPENAI_BASE_URL',
287
- 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
319
+ 'PATH',
320
+ 'HOME',
321
+ 'USER',
322
+ 'LOGNAME',
323
+ 'SHELL',
324
+ 'TMPDIR',
325
+ 'LANG',
326
+ 'LC_ALL',
327
+ 'LC_CTYPE',
328
+ 'SSH_AUTH_SOCK',
329
+ 'XDG_CONFIG_HOME',
330
+ 'XDG_DATA_HOME',
331
+ 'TEMP',
332
+ 'TMP',
333
+ 'ANTHROPIC_API_KEY',
334
+ 'ANTHROPIC_AUTH_TOKEN',
335
+ 'ANTHROPIC_BASE_URL',
336
+ 'OPENAI_API_KEY',
337
+ 'OPENAI_BASE_URL',
338
+ 'GEMINI_API_KEY',
339
+ 'GOOGLE_API_KEY',
340
+ 'ELECTRON_RUN_AS_NODE',
341
+ 'SHENNIAN_DESKTOP_CLI_SCRIPT',
342
+ 'SHENNIAN_DESKTOP_CLI_BRIDGE',
343
+ 'SHENNIAN_HOME',
288
344
  ];
289
345
  const env = {};
290
346
  for (const k of keep) {
@@ -379,11 +435,13 @@ export function saveEnvSnapshot() {
379
435
  fs.writeFileSync(resolveShennianPath('env.json'), JSON.stringify(snapshot, null, 2));
380
436
  }
381
437
  // ─── Exported helpers (used by pair.ts) ─────────────────────────────────────
382
- export function startDaemonProcess() {
438
+ export function startDaemonProcess(opts = {}) {
383
439
  fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
384
440
  const pid = readPid();
385
441
  if (pid !== null && isRunning(pid)) {
386
- console.log(chalk.green(`✓ Background service already running (PID ${pid})`));
442
+ if (!opts.quiet) {
443
+ console.log(chalk.green(`✓ Background service already running (PID ${pid})`));
444
+ }
387
445
  return;
388
446
  }
389
447
  const logFd = fs.openSync(LOG_FILE, 'a');
@@ -397,8 +455,10 @@ export function startDaemonProcess() {
397
455
  child.unref();
398
456
  fs.closeSync(logFd);
399
457
  fs.writeFileSync(PID_FILE, String(child.pid));
400
- console.log(chalk.green(`✓ Background service started (PID ${child.pid})`));
401
- console.log(chalk.gray(` Logs: ${LOG_FILE}`));
458
+ if (!opts.quiet) {
459
+ console.log(chalk.green(`✓ Background service started (PID ${child.pid})`));
460
+ console.log(chalk.gray(` Logs: ${LOG_FILE}`));
461
+ }
402
462
  }
403
463
  /**
404
464
  * Install the platform-native auto-start service.
@@ -409,8 +469,8 @@ export function installService() {
409
469
  const platform = getPlatform();
410
470
  const spec = resolveCurrentServiceLaunchSpec();
411
471
  if (spec.mode === 'direct' && isEphemeralCliPath(SHENNIAN_SCRIPT)) {
412
- console.warn(chalk.yellow('⚠ Warning: Current CLI path is temporary (npx). Auto-start may not work after reboot.\n'
413
- + ' Run `npm install -g shennian` for reliable auto-start.'));
472
+ console.warn(chalk.yellow('⚠ Warning: Current CLI path is temporary (npx). Auto-start may not work after reboot.\n' +
473
+ ' Run `npm install -g shennian` for reliable auto-start.'));
414
474
  }
415
475
  switch (platform) {
416
476
  case 'darwin': {
@@ -438,7 +498,9 @@ export function installService() {
438
498
  fs.mkdirSync(dir, { recursive: true });
439
499
  fs.writeFileSync(SYSTEMD_UNIT, buildSystemdUnit());
440
500
  try {
441
- execSync('systemctl --user daemon-reload && systemctl --user enable shennian', { stdio: 'pipe' });
501
+ execSync('systemctl --user daemon-reload && systemctl --user enable shennian', {
502
+ stdio: 'pipe',
503
+ });
442
504
  }
443
505
  catch {
444
506
  // Some Linux environments lack systemd user services; keep best-effort behavior.
@@ -459,11 +521,10 @@ export function installService() {
459
521
  }
460
522
  }
461
523
  // ─── Subcommand implementations ──────────────────────────────────────────────
462
- function daemonStop() {
524
+ function stopDaemonProcess() {
463
525
  const pid = readPid();
464
526
  if (pid === null) {
465
- console.log(chalk.yellow('⚠ No running background service found'));
466
- return;
527
+ return {};
467
528
  }
468
529
  if (!isRunning(pid)) {
469
530
  try {
@@ -472,42 +533,193 @@ function daemonStop() {
472
533
  catch {
473
534
  // Stale pid file is already gone.
474
535
  }
475
- console.log(chalk.yellow('⚠ Service process no longer exists, cleaned up'));
476
- return;
536
+ return { stalePid: pid };
477
537
  }
478
538
  try {
479
539
  process.kill(pid, 'SIGTERM');
540
+ return { stoppedPid: pid };
541
+ }
542
+ catch (err) {
543
+ return { error: err instanceof Error ? err.message : String(err) };
544
+ }
545
+ }
546
+ async function stopDaemonProcessAndWait(timeoutMs = 5000) {
547
+ const result = stopDaemonProcess();
548
+ if (!result.stoppedPid)
549
+ return result;
550
+ const stopped = await waitForPidExit(result.stoppedPid, timeoutMs);
551
+ if (!stopped) {
480
552
  try {
481
- fs.unlinkSync(PID_FILE);
553
+ process.kill(result.stoppedPid, 'SIGKILL');
554
+ await waitForPidExit(result.stoppedPid, 2000);
482
555
  }
483
- catch {
484
- // Best-effort cleanup after SIGTERM.
556
+ catch (err) {
557
+ return { ...result, error: err instanceof Error ? err.message : String(err) };
485
558
  }
486
- console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
487
559
  }
488
- catch (err) {
489
- console.error(chalk.red(`✗ Failed to stop: ${err instanceof Error ? err.message : String(err)}`));
560
+ try {
561
+ fs.unlinkSync(PID_FILE);
562
+ }
563
+ catch {
564
+ // Best-effort cleanup after the process exits.
490
565
  }
566
+ return result;
491
567
  }
492
- function daemonStatus() {
493
- const pid = readPid();
494
- if (pid === null) {
495
- console.log(chalk.gray('● Background service not running'));
568
+ function enableRemoteAccess(opts = {}) {
569
+ try {
570
+ fs.unlinkSync(REMOTE_ACCESS_DISABLED_FILE);
571
+ }
572
+ catch {
573
+ // Remote access may already be enabled.
574
+ }
575
+ const startedByService = installService();
576
+ if (!startedByService) {
577
+ startDaemonProcess({ quiet: opts.json });
578
+ }
579
+ if (opts.json) {
580
+ printJson(getDaemonStatus());
581
+ }
582
+ else {
583
+ console.log(chalk.green('✓ Background service started'));
584
+ }
585
+ }
586
+ async function disableRemoteAccess(opts = {}) {
587
+ fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
588
+ fs.writeFileSync(REMOTE_ACCESS_DISABLED_FILE, new Date().toISOString());
589
+ const platform = getPlatform();
590
+ switch (platform) {
591
+ case 'darwin': {
592
+ if (fs.existsSync(LAUNCHD_PLIST)) {
593
+ try {
594
+ execSync(`launchctl unload -w "${LAUNCHD_PLIST}"`, { stdio: 'pipe' });
595
+ }
596
+ catch {
597
+ // The job may already be unloaded; keep the plist for future re-enable.
598
+ }
599
+ }
600
+ break;
601
+ }
602
+ case 'linux': {
603
+ try {
604
+ execSync('systemctl --user disable --now shennian', { stdio: 'pipe' });
605
+ }
606
+ catch {
607
+ // systemd user services may be unavailable; still stop the manual daemon.
608
+ }
609
+ break;
610
+ }
611
+ case 'win32': {
612
+ try {
613
+ execSync(`schtasks /change /tn "${WINDOWS_TASK_NAME}" /disable`, { stdio: 'pipe' });
614
+ }
615
+ catch {
616
+ // Task may not exist yet; still stop the manual daemon.
617
+ }
618
+ try {
619
+ execSync(`schtasks /end /tn "${WINDOWS_TASK_NAME}"`, { stdio: 'pipe' });
620
+ }
621
+ catch {
622
+ // Task may not be running.
623
+ }
624
+ break;
625
+ }
626
+ }
627
+ const result = await stopDaemonProcessAndWait();
628
+ const status = getDaemonStatus({ cleanupStale: true });
629
+ if (opts.json) {
630
+ printJson(result.error ? { ...status, error: result.error } : status);
496
631
  return;
497
632
  }
498
- if (isRunning(pid)) {
499
- console.log(chalk.green(`● Background service running (PID ${pid})`));
500
- console.log(chalk.gray(` Logs: ${LOG_FILE}`));
633
+ if (result.error) {
634
+ console.error(chalk.red(`✗ Failed to stop: ${result.error}`));
501
635
  }
502
636
  else {
503
- console.log(chalk.yellow(`● Background service stopped (PID ${pid} is stale)`));
637
+ console.log(chalk.green('✓ Background service stopped'));
638
+ }
639
+ }
640
+ function daemonStart(opts) {
641
+ enableRemoteAccess(opts);
642
+ }
643
+ async function daemonStop(opts = {}) {
644
+ await disableRemoteAccess(opts);
645
+ }
646
+ async function waitForPidExit(pid, timeoutMs = 5000) {
647
+ const startedAt = Date.now();
648
+ while (Date.now() - startedAt < timeoutMs) {
649
+ if (!isRunning(pid))
650
+ return true;
651
+ await new Promise((resolve) => setTimeout(resolve, 100));
652
+ }
653
+ return !isRunning(pid);
654
+ }
655
+ async function daemonRestart(opts = {}) {
656
+ const pid = readPid();
657
+ if (pid !== null && isRunning(pid)) {
658
+ try {
659
+ process.kill(pid, 'SIGTERM');
660
+ const stopped = await waitForPidExit(pid);
661
+ if (!stopped) {
662
+ process.kill(pid, 'SIGKILL');
663
+ await waitForPidExit(pid, 2000);
664
+ }
665
+ try {
666
+ fs.unlinkSync(PID_FILE);
667
+ }
668
+ catch {
669
+ // Best-effort cleanup after restart stop.
670
+ }
671
+ if (!opts.json) {
672
+ console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
673
+ }
674
+ }
675
+ catch (err) {
676
+ const message = err instanceof Error ? err.message : String(err);
677
+ if (opts.json) {
678
+ printJson({ ...getDaemonStatus(), error: message });
679
+ }
680
+ else {
681
+ console.error(chalk.red(`✗ Failed to stop: ${message}`));
682
+ }
683
+ return;
684
+ }
685
+ }
686
+ else if (pid !== null) {
504
687
  try {
505
688
  fs.unlinkSync(PID_FILE);
506
689
  }
507
690
  catch {
508
- // Another process may have cleaned up the stale pid file first.
691
+ // Stale pid file is already gone.
692
+ }
693
+ if (!opts.json) {
694
+ console.log(chalk.yellow(`⚠ Service process no longer exists, cleaned up stale PID ${pid}`));
695
+ }
696
+ }
697
+ else {
698
+ if (!opts.json) {
699
+ console.log(chalk.gray('● Background service not running, starting it now'));
509
700
  }
510
701
  }
702
+ startDaemonProcess({ quiet: opts.json });
703
+ if (opts.json)
704
+ printJson(getDaemonStatus());
705
+ }
706
+ function daemonStatus(opts = {}) {
707
+ const status = getDaemonStatus({ cleanupStale: true });
708
+ if (opts.json) {
709
+ printJson(status);
710
+ return;
711
+ }
712
+ if (status.pid === null) {
713
+ console.log(chalk.gray('● Background service not running'));
714
+ return;
715
+ }
716
+ if (status.running) {
717
+ console.log(chalk.green(`● Background service running (PID ${status.pid})`));
718
+ console.log(chalk.gray(` Logs: ${LOG_FILE}`));
719
+ }
720
+ else {
721
+ console.log(chalk.yellow(`● Background service stopped (PID ${status.pid} is stale)`));
722
+ }
511
723
  }
512
724
  function daemonLogs(opts) {
513
725
  if (!fs.existsSync(LOG_FILE)) {
@@ -525,8 +737,8 @@ function daemonLogs(opts) {
525
737
  console.log(lines.join('\n'));
526
738
  }
527
739
  }
528
- function daemonUninstall() {
529
- daemonStop();
740
+ async function daemonUninstall() {
741
+ await daemonStop();
530
742
  const platform = getPlatform();
531
743
  switch (platform) {
532
744
  case 'darwin': {
@@ -589,24 +801,70 @@ function daemonUninstall() {
589
801
  }
590
802
  // ─── Register command ─────────────────────────────────────────────────────────
591
803
  export function registerDaemonCommand(program) {
592
- const daemon = program
593
- .command('daemon')
594
- .description('Manage the background service');
804
+ program
805
+ .command('start')
806
+ .description('Start the background service')
807
+ .option('--json', 'Print machine-readable status JSON')
808
+ .action((opts) => daemonStart(opts));
809
+ program
810
+ .command('stop')
811
+ .description('Stop the background service')
812
+ .option('--json', 'Print machine-readable status JSON')
813
+ .action((opts) => daemonStop(opts));
814
+ program
815
+ .command('status')
816
+ .description('Show background service status')
817
+ .option('--json', 'Print machine-readable status JSON')
818
+ .action((opts) => daemonStatus(opts));
819
+ program
820
+ .command('logs')
821
+ .description('Show recent logs')
822
+ .option('-n, --lines <n>', 'Number of lines', '50')
823
+ .action((opts) => daemonLogs({ lines: parseInt(opts.lines, 10) }));
824
+ const daemon = program.command('daemon', { hidden: true }).description('Deprecated: use top-level start/stop/status/logs');
825
+ const warnDeprecated = (replacement, json) => {
826
+ if (!json)
827
+ console.error(chalk.yellow(`⚠ Deprecated command. Use \`shennian ${replacement}\` instead.`));
828
+ };
829
+ daemon
830
+ .command('start')
831
+ .description('Start the background service')
832
+ .option('--json', 'Print machine-readable status JSON')
833
+ .action((opts) => {
834
+ warnDeprecated('start', opts.json);
835
+ daemonStart(opts);
836
+ });
595
837
  daemon
596
838
  .command('stop')
597
839
  .description('Stop the background service')
598
- .action(daemonStop);
840
+ .option('--json', 'Print machine-readable status JSON')
841
+ .action(async (opts) => {
842
+ warnDeprecated('stop', opts.json);
843
+ await daemonStop(opts);
844
+ });
845
+ daemon
846
+ .command('restart')
847
+ .description('Restart the background service')
848
+ .option('--json', 'Print machine-readable status JSON')
849
+ .action((opts) => {
850
+ warnDeprecated('stop && shennian start', opts.json);
851
+ daemonRestart(opts);
852
+ });
599
853
  daemon
600
854
  .command('status')
601
855
  .description('Show background service status')
602
- .action(daemonStatus);
856
+ .option('--json', 'Print machine-readable status JSON')
857
+ .action((opts) => {
858
+ warnDeprecated('status', opts.json);
859
+ daemonStatus(opts);
860
+ });
603
861
  daemon
604
862
  .command('logs')
605
863
  .description('Show recent logs')
606
864
  .option('-n, --lines <n>', 'Number of lines', '50')
607
- .action((opts) => daemonLogs({ lines: parseInt(opts.lines, 10) }));
608
- daemon
609
- .command('uninstall')
610
- .description('Uninstall auto-start service')
611
- .action(daemonUninstall);
865
+ .action((opts) => {
866
+ warnDeprecated('logs');
867
+ daemonLogs({ lines: parseInt(opts.lines, 10) });
868
+ });
869
+ daemon.command('uninstall').description('Uninstall auto-start service').action(daemonUninstall);
612
870
  }
package/dist/src/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // @arch docs/architecture/cli/daemon.md
2
2
  // @test src/__tests__/agents-e2e.ts
3
+ // @test src/__tests__/daemon-autostart.test.ts
3
4
  import { Command } from 'commander';
4
5
  import chalk from 'chalk';
5
6
  import os from 'node:os';
6
7
  import fs from 'node:fs';
7
- import { loadConfig, saveConfig, configPath, getShennianDir, resolveShennianPath } from './config/index.js';
8
+ import { loadConfig, saveConfig, configPath, getShennianDir, resolveShennianPath, } from './config/index.js';
8
9
  import { CliRelayClient } from './relay/client.js';
9
10
  import { registerPairCommand, runSmartStart } from './commands/pair.js';
10
- import { registerDaemonCommand } from './commands/daemon.js';
11
+ import { isRemoteAccessDisabled, registerDaemonCommand } from './commands/daemon.js';
11
12
  import { registerAgentCommand } from './commands/agent.js';
12
13
  import { registerUpgradeCommand } from './commands/upgrade.js';
13
14
  import { SessionManager } from './session/manager.js';
@@ -37,10 +38,7 @@ function formatDisconnectInfo(info) {
37
38
  return parts.join(' ');
38
39
  }
39
40
  const program = new Command();
40
- program
41
- .name('shennian')
42
- .description('Shennian — AI Agent Mobile Console')
43
- .version(cliVersion);
41
+ program.name('shennian').description('Shennian — AI Agent Mobile Console').version(cliVersion);
44
42
  program
45
43
  .option('--api <url>', 'Server URL override')
46
44
  .option('--name <name>', 'Machine name', os.hostname())
@@ -49,12 +47,16 @@ program
49
47
  const serverUrl = opts.api ?? config.serverUrl ?? undefined;
50
48
  await runSmartStart(serverUrl, opts.name);
51
49
  });
52
- // start: internal command used by the daemon process
50
+ // run-service: internal command used by the background service process
53
51
  program
54
- .command('start', { hidden: true })
55
- .description('(internal) Connect to relay server, called by daemon')
52
+ .command('run-service', { hidden: true })
53
+ .description('(internal) Connect to relay server, called by the background service')
56
54
  .option('--api <url>', 'Server URL override')
57
55
  .action(async (opts) => {
56
+ if (isRemoteAccessDisabled()) {
57
+ console.log(`[${new Date().toISOString()}] remote access disabled, service start skipped`);
58
+ process.exit(0);
59
+ }
58
60
  const envFile = resolveShennianPath('env.json');
59
61
  try {
60
62
  const saved = JSON.parse(fs.readFileSync(envFile, 'utf-8'));
@@ -81,12 +83,18 @@ program
81
83
  }
82
84
  }
83
85
  }
84
- catch { /* noop */ }
85
- fs.writeFileSync(pidFile, String(process.pid));
86
- process.on('exit', () => { try {
87
- fs.unlinkSync(pidFile);
86
+ catch {
87
+ /* noop */
88
88
  }
89
- catch { /* noop */ } });
89
+ fs.writeFileSync(pidFile, String(process.pid));
90
+ process.on('exit', () => {
91
+ try {
92
+ fs.unlinkSync(pidFile);
93
+ }
94
+ catch {
95
+ /* noop */
96
+ }
97
+ });
90
98
  // Crash detection: if we're recovering from a failed upgrade, rollback and exit
91
99
  const didRollback = await handleStartupCrashCheck();
92
100
  if (didRollback) {
@@ -109,7 +117,11 @@ program
109
117
  initCliLogReporter(serverUrl, config.machineId);
110
118
  }
111
119
  console.log(`[${new Date().toISOString()}] Connecting to ${wsUrl}... (v${cliVersion}) agents: ${agentList.join(',')}`);
112
- reportLog({ level: 'info', wsEvent: 'daemon.start', metadata: { version: cliVersion, agents: agentList } });
120
+ reportLog({
121
+ level: 'info',
122
+ wsEvent: 'daemon.start',
123
+ metadata: { version: cliVersion, agents: agentList },
124
+ });
113
125
  let nativeFusion = null;
114
126
  const client = new CliRelayClient({
115
127
  serverUrl: wsUrl,
@@ -132,7 +144,8 @@ program
132
144
  void resolveAgentInfos(detectedAgents, {
133
145
  serverUrl: config.serverUrl ?? serverUrl,
134
146
  authToken: config.machineToken ?? config.accessToken,
135
- }).then((agents) => {
147
+ })
148
+ .then((agents) => {
136
149
  if (JSON.stringify(agents) === JSON.stringify(cachedAgentInfos))
137
150
  return;
138
151
  client.sendEvent({
@@ -143,8 +156,10 @@ program
143
156
  agents,
144
157
  },
145
158
  });
146
- }).catch(() => { });
147
- import('./upgrade/engine.js').then(({ readUpgradeAttempt, clearUpgradeAttempt }) => {
159
+ })
160
+ .catch(() => { });
161
+ import('./upgrade/engine.js')
162
+ .then(({ readUpgradeAttempt, clearUpgradeAttempt }) => {
148
163
  const attempt = readUpgradeAttempt();
149
164
  clearUpgradeAttempt();
150
165
  if (attempt) {
@@ -152,10 +167,16 @@ program
152
167
  client.sendEvent({
153
168
  type: 'event',
154
169
  event: 'upgrade',
155
- payload: { state: 'success', machineId: 'self', from: attempt.from, to: attempt.to },
170
+ payload: {
171
+ state: 'success',
172
+ machineId: 'self',
173
+ from: attempt.from,
174
+ to: attempt.to,
175
+ },
156
176
  });
157
177
  }
158
- }).catch(() => { });
178
+ })
179
+ .catch(() => { });
159
180
  void scheduleAutoUpgrade(client, config.autoUpgrade ?? 'patch');
160
181
  nativeFusion?.handleConnected();
161
182
  },
@@ -176,13 +197,20 @@ program
176
197
  },
177
198
  onReq: (req) => {
178
199
  console.log(`[${new Date().toISOString()}] [req] ${req.method}`);
179
- reportLog({ level: 'info', type: 'ws', wsEvent: req.method, wsDirection: 'in', traceId: req.traceId });
200
+ reportLog({
201
+ level: 'info',
202
+ type: 'ws',
203
+ wsEvent: req.method,
204
+ wsDirection: 'in',
205
+ traceId: req.traceId,
206
+ });
180
207
  void sessionManager.handleReq(req);
181
208
  },
182
209
  });
183
- nativeFusion = process.env.SHENNIAN_NATIVE_FUSION_DISABLED === '1'
184
- ? null
185
- : new NativeSessionFusionService(client);
210
+ nativeFusion =
211
+ process.env.SHENNIAN_NATIVE_FUSION_DISABLED === '1'
212
+ ? null
213
+ : new NativeSessionFusionService(client);
186
214
  const sessionManager = new SessionManager(client, nativeFusion);
187
215
  fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
188
216
  fs.writeFileSync(PID_FILE, String(process.pid));
@@ -197,16 +225,16 @@ program
197
225
  try {
198
226
  fs.unlinkSync(PID_FILE);
199
227
  }
200
- catch { /* noop */ }
228
+ catch {
229
+ /* noop */
230
+ }
201
231
  process.exit(0);
202
232
  };
203
233
  process.on('SIGINT', shutdown);
204
234
  process.on('SIGTERM', shutdown);
205
235
  });
206
236
  // config: view and modify settings
207
- const configCmd = program
208
- .command('config')
209
- .description('View or modify configuration');
237
+ const configCmd = program.command('config').description('View or modify configuration');
210
238
  configCmd
211
239
  .command('show', { isDefault: true })
212
240
  .description('Show current configuration')
@@ -238,7 +266,7 @@ configCmd
238
266
  config.serverUrl = regionToUrl(region);
239
267
  saveConfig(config);
240
268
  console.log(chalk.green(`✓ Server set to ${SERVERS[region].label}`));
241
- console.log(chalk.yellow(' Restart the daemon for this to take effect: shennian daemon stop && shennian'));
269
+ console.log(chalk.yellow(' Restart the background service for this to take effect: shennian stop && shennian start'));
242
270
  return;
243
271
  }
244
272
  console.error(chalk.red(`✗ Unknown config key: ${key}. Supported: server`));
@@ -4,7 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { buildUserMessagePayload } from '@shennian/wire';
7
+ import { buildUserMessagePayload, isToolPayload } from '@shennian/wire';
8
8
  function normalizeText(text) {
9
9
  return stripGitDirectiveArtifacts(text.replace(/\r\n/g, '\n').trim());
10
10
  }
@@ -256,7 +256,7 @@ function parseCodexUserMessage(payload) {
256
256
  }
257
257
  return { payload: text, titleText: text };
258
258
  }
259
- function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, payload, title, modelId, workDir, role = 'agent') {
259
+ function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, payload, title, modelId, workDir, role = 'agent', terminal = false) {
260
260
  if (!payload)
261
261
  return;
262
262
  events.push({
@@ -271,6 +271,7 @@ function pushCodexEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts
271
271
  title,
272
272
  modelId,
273
273
  workDir,
274
+ terminal,
274
275
  });
275
276
  }
276
277
  function pushCodexToolEvent(events, filePath, lineOffset, kind, sourceSessionKey, ts, toolName, title, modelId, workDir, args, result) {
@@ -327,6 +328,17 @@ function parseCodexEventMessage(events, filePath, lineOffset, payload, sourceSes
327
328
  pushCodexEvent(events, filePath, lineOffset, eventType, sourceSessionKey, ts, text, title, modelId, workDir);
328
329
  return;
329
330
  }
331
+ if (eventType === 'turn_aborted' || eventType === 'error') {
332
+ const text = typeof payload.message === 'string'
333
+ ? normalizeText(payload.message)
334
+ : typeof payload.error === 'string'
335
+ ? normalizeText(payload.error)
336
+ : '';
337
+ if (!text)
338
+ return;
339
+ pushCodexEvent(events, filePath, lineOffset, eventType, sourceSessionKey, ts, text, title, modelId, workDir, 'agent', true);
340
+ return;
341
+ }
330
342
  if (eventType === 'exec_command_end') {
331
343
  const command = Array.isArray(payload.command) ? payload.command : payload.parsed_cmd ?? payload.command;
332
344
  const exitCode = payload.exit_code ?? payload.exitCode;
@@ -565,6 +577,7 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
565
577
  const lines = chunk.split('\n').filter(Boolean);
566
578
  const events = [];
567
579
  let { sourceSessionKey, workDir, modelId, title } = readCodexSessionMeta(filePath);
580
+ let pendingTerminal = false;
568
581
  for (let index = 0, offset = startOffset; index < lines.length; index++) {
569
582
  const line = lines[index];
570
583
  const parsed = safeParse(line);
@@ -595,12 +608,32 @@ export function parseCodexRolloutChunk(filePath, startOffset) {
595
608
  }
596
609
  if (type === 'event_msg') {
597
610
  const eventType = typeof payload.type === 'string' ? payload.type : '';
611
+ if (eventType === 'turn_completed') {
612
+ for (let i = events.length - 1; i >= 0; i -= 1) {
613
+ const event = events[i];
614
+ if (event?.agentType !== 'codex')
615
+ continue;
616
+ if (event.role !== 'agent')
617
+ continue;
618
+ if (isToolPayload(event.payload))
619
+ continue;
620
+ event.terminal = true;
621
+ pendingTerminal = false;
622
+ break;
623
+ }
624
+ continue;
625
+ }
598
626
  if (eventType === 'user_message') {
599
627
  const parsedUser = parseCodexUserMessage(payload);
600
628
  if (parsedUser?.titleText && !title)
601
629
  title = parsedUser.titleText.slice(0, 80);
602
630
  }
631
+ const beforeCount = events.length;
603
632
  parseCodexEventMessage(events, filePath, lineOffset, payload, sourceSessionKey, ts, title, modelId, workDir);
633
+ if (pendingTerminal && events.length > beforeCount) {
634
+ events[events.length - 1].terminal = true;
635
+ pendingTerminal = false;
636
+ }
604
637
  }
605
638
  }
606
639
  return { nextOffset, events };
@@ -11,6 +11,12 @@ const MAX_BATCH_SIZE = 20;
11
11
  function normalizeText(text) {
12
12
  return text.replace(/\r\n/g, '\n').trim();
13
13
  }
14
+ function isCodexRolloutPath(filePath) {
15
+ return filePath.split(/[\\/]+/).includes('.codex');
16
+ }
17
+ function shouldBackfillWindowsCodex(state) {
18
+ return process.platform === 'win32' && state.codexWindowsPathParserFixed !== true;
19
+ }
14
20
  export class NativeSessionFusionService {
15
21
  client;
16
22
  timer = null;
@@ -85,13 +91,15 @@ export class NativeSessionFusionService {
85
91
  this.pruneState();
86
92
  const state = loadNativeScannerState();
87
93
  const nextFilesState = { ...state.files };
94
+ const backfillWindowsCodex = shouldBackfillWindowsCodex(state);
88
95
  const batches = [];
89
96
  const files = [...listCodexRolloutFiles(), ...listClaudeTranscriptFiles()];
90
97
  for (const filePath of files) {
91
98
  const stat = fs.statSync(filePath);
92
99
  const current = state.files[filePath];
93
- const startOffset = current?.offset ?? 0;
94
- const parsed = filePath.includes('/.codex/sessions/')
100
+ const isCodex = isCodexRolloutPath(filePath);
101
+ const startOffset = backfillWindowsCodex && isCodex ? 0 : current?.offset ?? 0;
102
+ const parsed = isCodex
95
103
  ? parseCodexRolloutChunk(filePath, startOffset)
96
104
  : parseClaudeTranscriptChunk(filePath, startOffset);
97
105
  nextFilesState[filePath] = {
@@ -121,6 +129,9 @@ export class NativeSessionFusionService {
121
129
  }, 60_000);
122
130
  }
123
131
  state.files = nextFilesState;
132
+ if (backfillWindowsCodex) {
133
+ state.codexWindowsPathParserFixed = true;
134
+ }
124
135
  saveNativeScannerState(state);
125
136
  }
126
137
  tryClaimManagedEcho(event) {
@@ -4,6 +4,7 @@ export type NativeScannerState = {
4
4
  offset: number;
5
5
  mtimeMs: number;
6
6
  }>;
7
+ codexWindowsPathParserFixed?: boolean;
7
8
  };
8
9
  export type ParsedNativeEvent = NativeSessionEventPayload;
9
10
  export type PendingManagedClaim = {
@@ -22,24 +22,28 @@ const MAX_SESSIONS = 50;
22
22
  function isWindowsAbsolutePath(input) {
23
23
  return /^[A-Za-z]:([\\/]|$)/.test(input) || /^\\\\[^\\]+\\[^\\]+/.test(input);
24
24
  }
25
+ function normalizeWindowsAbsolutePath(input) {
26
+ return input.replace(/^[/\\]([A-Za-z]:[\\/].*)$/, '$1');
27
+ }
25
28
  export function resolveSessionWorkDir(input) {
26
29
  if (!input)
27
30
  return os.homedir();
31
+ const normalizedInput = normalizeWindowsAbsolutePath(input);
28
32
  const joinPath = process.platform === 'win32' ? path.win32.join : path.join;
29
- if (input.startsWith('~')) {
30
- return joinPath(os.homedir(), input.slice(1).replace(/^[/\\]+/, ''));
33
+ if (normalizedInput.startsWith('~')) {
34
+ return joinPath(os.homedir(), normalizedInput.slice(1).replace(/^[/\\]+/, ''));
31
35
  }
32
36
  if (process.platform === 'win32') {
33
- const normalized = input.replace(/\\/g, '/');
37
+ const normalized = normalizedInput.replace(/\\/g, '/');
34
38
  if (normalized === '/tmp' || normalized.startsWith('/tmp/')) {
35
39
  const suffix = normalized.slice('/tmp'.length).replace(/^\/+/, '');
36
40
  return suffix ? path.win32.join(os.tmpdir(), ...suffix.split('/')) : os.tmpdir();
37
41
  }
38
42
  }
39
- if (isWindowsAbsolutePath(input)) {
40
- return path.win32.resolve(input);
43
+ if (isWindowsAbsolutePath(normalizedInput)) {
44
+ return path.win32.resolve(normalizedInput);
41
45
  }
42
- return path.resolve(input);
46
+ return path.resolve(normalizedInput);
43
47
  }
44
48
  export class SessionManager {
45
49
  client;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shennian",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "Shennian — AI Agent Mobile Console CLI",
5
5
  "type": "module",
6
6
  "bin": {