shennian 0.2.13 → 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,17 +13,18 @@
13
13
 
14
14
  | 命令 | 说明 |
15
15
  |------|------|
16
- | `shennian` | 智能启动:必要时 pair 再启动 daemon |
16
+ | `shennian` | 智能启动:必要时 pair 再启动后台服务 |
17
17
  | `shennian pair` | 重新与账号 / server 配对 |
18
- | `shennian restart` | 重启 daemon;未运行则启动 |
19
- | `shennian daemon status` | 查看 daemon 运行状态 |
20
- | `shennian daemon stop` | 停止 daemon |
21
- | `shennian daemon restart` | 重启 daemon |
22
- | `shennian daemon logs -n 100` | 查看最近日志 |
18
+ | `shennian start` | 启动后台服务 |
19
+ | `shennian stop` | 停止后台服务 |
20
+ | `shennian status` | 查看后台服务状态 |
21
+ | `shennian logs -n 100` | 查看最近日志 |
23
22
  | `shennian config` | 查看本地配置 |
24
23
  | `shennian agent add/list/remove` | 管理自定义 Agent |
25
24
  | `shennian upgrade` | 升级 CLI |
26
25
 
26
+ `start`、`stop`、`status` 可选加 `--json`。`logs` 输出日志文本,不支持 `--json`。
27
+
27
28
  ## 开发
28
29
 
29
30
  ```bash
@@ -5,6 +5,7 @@ export type DaemonStatus = {
5
5
  running: boolean;
6
6
  pid: number | null;
7
7
  stale: boolean;
8
+ remoteAccessDisabled: boolean;
8
9
  platform: Platform;
9
10
  shennianDir: string;
10
11
  pidFile: string;
@@ -21,7 +22,9 @@ export declare function resolveServiceLaunchSpec(input: {
21
22
  scriptPath: string;
22
23
  shennianCommandPath?: string | null;
23
24
  npxPath?: string | null;
25
+ desktopBridgePath?: string | null;
24
26
  }): ServiceLaunchSpec;
27
+ export declare function isRemoteAccessDisabled(): boolean;
25
28
  export declare function getDaemonStatus(opts?: {
26
29
  cleanupStale?: boolean;
27
30
  }): DaemonStatus;
@@ -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,9 @@ function isRunning(pid) {
75
85
  return false;
76
86
  }
77
87
  }
88
+ export function isRemoteAccessDisabled() {
89
+ return fs.existsSync(REMOTE_ACCESS_DISABLED_FILE);
90
+ }
78
91
  export function getDaemonStatus(opts = {}) {
79
92
  const pid = readPid();
80
93
  const running = pid !== null && isRunning(pid);
@@ -91,6 +104,7 @@ export function getDaemonStatus(opts = {}) {
91
104
  running,
92
105
  pid,
93
106
  stale,
107
+ remoteAccessDisabled: isRemoteAccessDisabled(),
94
108
  platform: getPlatform(),
95
109
  shennianDir: SHENNIAN_DIR,
96
110
  pidFile: PID_FILE,
@@ -138,6 +152,7 @@ function resolveCurrentServiceLaunchSpec() {
138
152
  scriptPath: SHENNIAN_SCRIPT,
139
153
  shennianCommandPath: findCommandPath('shennian'),
140
154
  npxPath: resolveNpxPath(),
155
+ desktopBridgePath: process.env.SHENNIAN_DESKTOP_CLI_BRIDGE,
141
156
  });
142
157
  }
143
158
  function escapeXml(text) {
@@ -322,6 +337,10 @@ export function captureEnvForService() {
322
337
  'OPENAI_BASE_URL',
323
338
  'GEMINI_API_KEY',
324
339
  'GOOGLE_API_KEY',
340
+ 'ELECTRON_RUN_AS_NODE',
341
+ 'SHENNIAN_DESKTOP_CLI_SCRIPT',
342
+ 'SHENNIAN_DESKTOP_CLI_BRIDGE',
343
+ 'SHENNIAN_HOME',
325
344
  ];
326
345
  const env = {};
327
346
  for (const k of keep) {
@@ -502,21 +521,10 @@ export function installService() {
502
521
  }
503
522
  }
504
523
  // ─── Subcommand implementations ──────────────────────────────────────────────
505
- function daemonStart(opts) {
506
- startDaemonProcess({ quiet: opts.json });
507
- if (opts.json)
508
- printJson(getDaemonStatus());
509
- }
510
- function daemonStop(opts = {}) {
524
+ function stopDaemonProcess() {
511
525
  const pid = readPid();
512
526
  if (pid === null) {
513
- if (opts.json) {
514
- printJson(getDaemonStatus());
515
- }
516
- else {
517
- console.log(chalk.yellow('⚠ No running background service found'));
518
- }
519
- return;
527
+ return {};
520
528
  }
521
529
  if (!isRunning(pid)) {
522
530
  try {
@@ -525,38 +533,115 @@ function daemonStop(opts = {}) {
525
533
  catch {
526
534
  // Stale pid file is already gone.
527
535
  }
528
- if (opts.json) {
529
- printJson(getDaemonStatus());
530
- }
531
- else {
532
- console.log(chalk.yellow('⚠ Service process no longer exists, cleaned up'));
533
- }
534
- return;
536
+ return { stalePid: pid };
535
537
  }
536
538
  try {
537
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) {
538
552
  try {
539
- fs.unlinkSync(PID_FILE);
540
- }
541
- catch {
542
- // Best-effort cleanup after SIGTERM.
543
- }
544
- if (opts.json) {
545
- printJson(getDaemonStatus());
553
+ process.kill(result.stoppedPid, 'SIGKILL');
554
+ await waitForPidExit(result.stoppedPid, 2000);
546
555
  }
547
- else {
548
- console.log(chalk.green(`✓ Background service stopped (PID ${pid})`));
556
+ catch (err) {
557
+ return { ...result, error: err instanceof Error ? err.message : String(err) };
549
558
  }
550
559
  }
551
- catch (err) {
552
- const message = err instanceof Error ? err.message : String(err);
553
- if (opts.json) {
554
- printJson({ ...getDaemonStatus(), error: message });
560
+ try {
561
+ fs.unlinkSync(PID_FILE);
562
+ }
563
+ catch {
564
+ // Best-effort cleanup after the process exits.
565
+ }
566
+ return result;
567
+ }
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;
555
601
  }
556
- else {
557
- console.error(chalk.red(`✗ Failed to stop: ${message}`));
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;
558
625
  }
559
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);
631
+ return;
632
+ }
633
+ if (result.error) {
634
+ console.error(chalk.red(`✗ Failed to stop: ${result.error}`));
635
+ }
636
+ else {
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);
560
645
  }
561
646
  async function waitForPidExit(pid, timeoutMs = 5000) {
562
647
  const startedAt = Date.now();
@@ -652,8 +737,8 @@ function daemonLogs(opts) {
652
737
  console.log(lines.join('\n'));
653
738
  }
654
739
  }
655
- function daemonUninstall() {
656
- daemonStop();
740
+ async function daemonUninstall() {
741
+ await daemonStop();
657
742
  const platform = getPlatform();
658
743
  switch (platform) {
659
744
  case 'darwin': {
@@ -716,32 +801,70 @@ function daemonUninstall() {
716
801
  }
717
802
  // ─── Register command ─────────────────────────────────────────────────────────
718
803
  export function registerDaemonCommand(program) {
719
- const daemon = program.command('daemon').description('Manage the background service');
720
- program.command('restart').description('Restart the background service').action(() => daemonRestart());
721
- daemon
804
+ program
722
805
  .command('start')
723
806
  .description('Start the background service')
724
807
  .option('--json', 'Print machine-readable status JSON')
725
808
  .action((opts) => daemonStart(opts));
726
- daemon
809
+ program
727
810
  .command('stop')
728
811
  .description('Stop the background service')
729
812
  .option('--json', 'Print machine-readable status JSON')
730
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
+ });
837
+ daemon
838
+ .command('stop')
839
+ .description('Stop the background service')
840
+ .option('--json', 'Print machine-readable status JSON')
841
+ .action(async (opts) => {
842
+ warnDeprecated('stop', opts.json);
843
+ await daemonStop(opts);
844
+ });
731
845
  daemon
732
846
  .command('restart')
733
847
  .description('Restart the background service')
734
848
  .option('--json', 'Print machine-readable status JSON')
735
- .action((opts) => daemonRestart(opts));
849
+ .action((opts) => {
850
+ warnDeprecated('stop && shennian start', opts.json);
851
+ daemonRestart(opts);
852
+ });
736
853
  daemon
737
854
  .command('status')
738
855
  .description('Show background service status')
739
856
  .option('--json', 'Print machine-readable status JSON')
740
- .action((opts) => daemonStatus(opts));
857
+ .action((opts) => {
858
+ warnDeprecated('status', opts.json);
859
+ daemonStatus(opts);
860
+ });
741
861
  daemon
742
862
  .command('logs')
743
863
  .description('Show recent logs')
744
864
  .option('-n, --lines <n>', 'Number of lines', '50')
745
- .action((opts) => daemonLogs({ lines: parseInt(opts.lines, 10) }));
865
+ .action((opts) => {
866
+ warnDeprecated('logs');
867
+ daemonLogs({ lines: parseInt(opts.lines, 10) });
868
+ });
746
869
  daemon.command('uninstall').description('Uninstall auto-start service').action(daemonUninstall);
747
870
  }
package/dist/src/index.js CHANGED
@@ -1,5 +1,6 @@
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';
@@ -7,7 +8,7 @@ import fs from 'node:fs';
7
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';
@@ -46,12 +47,16 @@ program
46
47
  const serverUrl = opts.api ?? config.serverUrl ?? undefined;
47
48
  await runSmartStart(serverUrl, opts.name);
48
49
  });
49
- // start: internal command used by the daemon process
50
+ // run-service: internal command used by the background service process
50
51
  program
51
- .command('start', { hidden: true })
52
- .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')
53
54
  .option('--api <url>', 'Server URL override')
54
55
  .action(async (opts) => {
56
+ if (isRemoteAccessDisabled()) {
57
+ console.log(`[${new Date().toISOString()}] remote access disabled, service start skipped`);
58
+ process.exit(0);
59
+ }
55
60
  const envFile = resolveShennianPath('env.json');
56
61
  try {
57
62
  const saved = JSON.parse(fs.readFileSync(envFile, 'utf-8'));
@@ -261,7 +266,7 @@ configCmd
261
266
  config.serverUrl = regionToUrl(region);
262
267
  saveConfig(config);
263
268
  console.log(chalk.green(`✓ Server set to ${SERVERS[region].label}`));
264
- console.log(chalk.yellow(' Restart the daemon for this to take effect: shennian restart'));
269
+ console.log(chalk.yellow(' Restart the background service for this to take effect: shennian stop && shennian start'));
265
270
  return;
266
271
  }
267
272
  console.error(chalk.red(`✗ Unknown config key: ${key}. Supported: server`));
@@ -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.13",
3
+ "version": "0.2.14",
4
4
  "description": "Shennian — AI Agent Mobile Console CLI",
5
5
  "type": "module",
6
6
  "bin": {