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 +10 -3
- package/dist/src/commands/daemon.d.ts +19 -1
- package/dist/src/commands/daemon.js +314 -56
- package/dist/src/index.js +57 -29
- package/dist/src/native-fusion/parsers.js +35 -2
- package/dist/src/native-fusion/service.js +13 -2
- package/dist/src/native-fusion/types.d.ts +1 -0
- package/dist/src/session/manager.js +10 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @shennian/cli
|
|
2
2
|
|
|
3
|
-
> Shennian AI Agent
|
|
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
|
|
16
|
+
| `shennian` | 智能启动:必要时 pair 再启动后台服务 |
|
|
17
17
|
| `shennian pair` | 重新与账号 / server 配对 |
|
|
18
|
-
| `shennian
|
|
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(
|
|
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, '
|
|
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: ['
|
|
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', '
|
|
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, '
|
|
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`, {
|
|
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',
|
|
283
|
-
'
|
|
284
|
-
'
|
|
285
|
-
'
|
|
286
|
-
'
|
|
287
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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', {
|
|
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
|
|
524
|
+
function stopDaemonProcess() {
|
|
463
525
|
const pid = readPid();
|
|
464
526
|
if (pid === null) {
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
+
process.kill(result.stoppedPid, 'SIGKILL');
|
|
554
|
+
await waitForPidExit(result.stoppedPid, 2000);
|
|
482
555
|
}
|
|
483
|
-
catch {
|
|
484
|
-
|
|
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
|
-
|
|
489
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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 (
|
|
499
|
-
console.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
593
|
-
.command('
|
|
594
|
-
.description('
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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) =>
|
|
608
|
-
|
|
609
|
-
.
|
|
610
|
-
|
|
611
|
-
|
|
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
|
-
//
|
|
50
|
+
// run-service: internal command used by the background service process
|
|
53
51
|
program
|
|
54
|
-
.command('
|
|
55
|
-
.description('(internal) Connect to relay server, called by
|
|
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 {
|
|
85
|
-
|
|
86
|
-
process.on('exit', () => { try {
|
|
87
|
-
fs.unlinkSync(pidFile);
|
|
86
|
+
catch {
|
|
87
|
+
/* noop */
|
|
88
88
|
}
|
|
89
|
-
|
|
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({
|
|
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
|
-
})
|
|
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
|
-
})
|
|
147
|
-
|
|
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: {
|
|
170
|
+
payload: {
|
|
171
|
+
state: 'success',
|
|
172
|
+
machineId: 'self',
|
|
173
|
+
from: attempt.from,
|
|
174
|
+
to: attempt.to,
|
|
175
|
+
},
|
|
156
176
|
});
|
|
157
177
|
}
|
|
158
|
-
})
|
|
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({
|
|
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 =
|
|
184
|
-
|
|
185
|
-
|
|
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 {
|
|
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
|
|
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
|
|
94
|
-
const
|
|
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) {
|
|
@@ -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 (
|
|
30
|
-
return joinPath(os.homedir(),
|
|
33
|
+
if (normalizedInput.startsWith('~')) {
|
|
34
|
+
return joinPath(os.homedir(), normalizedInput.slice(1).replace(/^[/\\]+/, ''));
|
|
31
35
|
}
|
|
32
36
|
if (process.platform === 'win32') {
|
|
33
|
-
const normalized =
|
|
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(
|
|
40
|
-
return path.win32.resolve(
|
|
43
|
+
if (isWindowsAbsolutePath(normalizedInput)) {
|
|
44
|
+
return path.win32.resolve(normalizedInput);
|
|
41
45
|
}
|
|
42
|
-
return path.resolve(
|
|
46
|
+
return path.resolve(normalizedInput);
|
|
43
47
|
}
|
|
44
48
|
export class SessionManager {
|
|
45
49
|
client;
|