shennian 0.2.78 → 0.2.83
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/dist/scripts/wechat-rpa-win-visual.mjs +1155 -127
- package/dist/scripts/wechat-rpa-win.mjs +227 -1
- package/dist/src/agents/external-channel-instructions.js +1 -3
- package/dist/src/channels/base.d.ts +9 -2
- package/dist/src/channels/runtime.d.ts +2 -1
- package/dist/src/channels/runtime.js +16 -32
- package/dist/src/channels/secret-registry.d.ts +3 -1
- package/dist/src/channels/wechat-rpa/macos-flow.d.ts +6 -5
- package/dist/src/channels/wechat-rpa/macos-flow.js +7 -80
- package/dist/src/channels/wechat-rpa/normalizer.d.ts +5 -1
- package/dist/src/channels/wechat-rpa/normalizer.js +14 -1
- package/dist/src/channels/wechat-rpa/windows-visual-flow.d.ts +4 -0
- package/dist/src/channels/wechat-rpa/windows-visual-flow.js +13 -6
- package/dist/src/channels/wechat-rpa.d.ts +12 -5
- package/dist/src/channels/wechat-rpa.js +362 -71
- package/dist/src/commands/daemon.d.ts +12 -2
- package/dist/src/commands/daemon.js +177 -14
- package/dist/src/commands/manager.d.ts +1 -1
- package/dist/src/commands/manager.js +13 -10
- package/dist/src/index.js +64 -39
- package/dist/src/manager/runtime.js +35 -3
- package/dist/src/native-fusion/opencode-parser.js +2 -0
- package/dist/src/native-fusion/parsers.js +15 -0
- package/dist/src/native-fusion/service.js +3 -23
- package/package.json +3 -3
|
@@ -18,6 +18,8 @@ export type DaemonStatus = {
|
|
|
18
18
|
machineId?: string;
|
|
19
19
|
paired?: boolean;
|
|
20
20
|
serverUrl?: string;
|
|
21
|
+
instanceId?: string;
|
|
22
|
+
adopted?: boolean;
|
|
21
23
|
};
|
|
22
24
|
export type ServiceLaunchSpec = {
|
|
23
25
|
command: string;
|
|
@@ -39,9 +41,17 @@ export declare function resolveServiceLaunchSpec(input: {
|
|
|
39
41
|
export declare function isRemoteAccessDisabled(): boolean;
|
|
40
42
|
export declare function clearRemoteAccessDisabled(): void;
|
|
41
43
|
export declare function getCurrentProcessDaemonLauncher(): DaemonLauncher;
|
|
42
|
-
export declare function
|
|
43
|
-
export declare function
|
|
44
|
+
export declare function createDaemonInstanceId(): string;
|
|
45
|
+
export declare function writeDaemonPid(pid: number, instanceId: string, meta?: {
|
|
46
|
+
version?: string;
|
|
47
|
+
launcher?: DaemonLauncher;
|
|
48
|
+
adopted?: boolean;
|
|
49
|
+
}): void;
|
|
50
|
+
export declare function clearDaemonPidIfOwner(pid: number, instanceId: string): void;
|
|
51
|
+
export declare function writeDaemonLauncher(pid: number, launcher?: DaemonLauncher, instanceId?: string): void;
|
|
52
|
+
export declare function clearDaemonLauncher(instanceId?: string): void;
|
|
44
53
|
export declare function recordStartedDaemon(childPid: number | undefined): void;
|
|
54
|
+
export declare function findRunningDaemonProcessIds(excludePid?: number): number[];
|
|
45
55
|
export declare function getDaemonStatus(opts?: {
|
|
46
56
|
cleanupStale?: boolean;
|
|
47
57
|
}): DaemonStatus;
|
|
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import { execSync, spawn } from 'node:child_process';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { getShennianDir, loadConfig, resolveShennianPath, saveConfig } from '../config/index.js';
|
|
10
11
|
import { buildAugmentedPath } from '../env-path.js';
|
|
@@ -83,17 +84,45 @@ export function resolveServiceLaunchSpec(input) {
|
|
|
83
84
|
mode: 'direct',
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
function parsePidRecord(raw) {
|
|
88
|
+
const trimmed = raw.trim();
|
|
89
|
+
if (!trimmed)
|
|
90
|
+
return null;
|
|
91
|
+
if (trimmed.startsWith('{')) {
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(trimmed);
|
|
94
|
+
const pid = Number(parsed.pid);
|
|
95
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
96
|
+
return null;
|
|
97
|
+
return {
|
|
98
|
+
pid,
|
|
99
|
+
...(typeof parsed.instanceId === 'string' ? { instanceId: parsed.instanceId } : {}),
|
|
100
|
+
...(typeof parsed.version === 'string' ? { version: parsed.version } : {}),
|
|
101
|
+
...(parsed.launcher === 'desktop-managed' || parsed.launcher === 'global-cli' || parsed.launcher === 'unknown'
|
|
102
|
+
? { launcher: parsed.launcher }
|
|
103
|
+
: {}),
|
|
104
|
+
...(typeof parsed.startedAt === 'string' ? { startedAt: parsed.startedAt } : {}),
|
|
105
|
+
...(parsed.adopted === true ? { adopted: true } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const pid = parseInt(trimmed, 10);
|
|
113
|
+
return isNaN(pid) ? null : { pid };
|
|
114
|
+
}
|
|
115
|
+
function readPidRecord() {
|
|
88
116
|
try {
|
|
89
|
-
|
|
90
|
-
const pid = parseInt(raw, 10);
|
|
91
|
-
return isNaN(pid) ? null : pid;
|
|
117
|
+
return parsePidRecord(fs.readFileSync(PID_FILE, 'utf-8'));
|
|
92
118
|
}
|
|
93
119
|
catch {
|
|
94
120
|
return null;
|
|
95
121
|
}
|
|
96
122
|
}
|
|
123
|
+
function readPid() {
|
|
124
|
+
return readPidRecord()?.pid ?? null;
|
|
125
|
+
}
|
|
97
126
|
function isRunning(pid) {
|
|
98
127
|
try {
|
|
99
128
|
process.kill(pid, 0);
|
|
@@ -103,6 +132,15 @@ function isRunning(pid) {
|
|
|
103
132
|
return false;
|
|
104
133
|
}
|
|
105
134
|
}
|
|
135
|
+
function waitForPidExitSync(pid, timeoutMs = 3000) {
|
|
136
|
+
const startedAt = Date.now();
|
|
137
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
138
|
+
if (!isRunning(pid))
|
|
139
|
+
return true;
|
|
140
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
141
|
+
}
|
|
142
|
+
return !isRunning(pid);
|
|
143
|
+
}
|
|
106
144
|
export function isRemoteAccessDisabled() {
|
|
107
145
|
return fs.existsSync(REMOTE_ACCESS_DISABLED_FILE);
|
|
108
146
|
}
|
|
@@ -117,15 +155,51 @@ export function clearRemoteAccessDisabled() {
|
|
|
117
155
|
export function getCurrentProcessDaemonLauncher() {
|
|
118
156
|
return 'global-cli';
|
|
119
157
|
}
|
|
120
|
-
export function
|
|
158
|
+
export function createDaemonInstanceId() {
|
|
159
|
+
return `${process.pid}-${Date.now()}-${randomUUID()}`;
|
|
160
|
+
}
|
|
161
|
+
export function writeDaemonPid(pid, instanceId, meta = {}) {
|
|
162
|
+
fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
|
|
163
|
+
fs.writeFileSync(PID_FILE, JSON.stringify({
|
|
164
|
+
pid,
|
|
165
|
+
instanceId,
|
|
166
|
+
...(meta.version ? { version: meta.version } : {}),
|
|
167
|
+
launcher: meta.launcher ?? getCurrentProcessDaemonLauncher(),
|
|
168
|
+
...(meta.adopted ? { adopted: true } : {}),
|
|
169
|
+
startedAt: new Date().toISOString(),
|
|
170
|
+
}, null, 2));
|
|
171
|
+
}
|
|
172
|
+
export function clearDaemonPidIfOwner(pid, instanceId) {
|
|
173
|
+
const current = readPidRecord();
|
|
174
|
+
if (current?.pid !== pid || current.instanceId !== instanceId)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(PID_FILE);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// PID metadata may already be gone.
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export function writeDaemonLauncher(pid, launcher = getCurrentProcessDaemonLauncher(), instanceId) {
|
|
121
184
|
fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
|
|
122
185
|
fs.writeFileSync(LAUNCHER_FILE, JSON.stringify({
|
|
123
186
|
pid,
|
|
124
187
|
launcher,
|
|
188
|
+
...(instanceId ? { instanceId } : {}),
|
|
125
189
|
updatedAt: new Date().toISOString(),
|
|
126
190
|
}, null, 2));
|
|
127
191
|
}
|
|
128
|
-
export function clearDaemonLauncher() {
|
|
192
|
+
export function clearDaemonLauncher(instanceId) {
|
|
193
|
+
if (instanceId) {
|
|
194
|
+
try {
|
|
195
|
+
const raw = JSON.parse(fs.readFileSync(LAUNCHER_FILE, 'utf-8'));
|
|
196
|
+
if (raw.instanceId !== instanceId)
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
129
203
|
try {
|
|
130
204
|
fs.unlinkSync(LAUNCHER_FILE);
|
|
131
205
|
}
|
|
@@ -174,10 +248,48 @@ function inferDaemonLauncherFromProcess(pid) {
|
|
|
174
248
|
}
|
|
175
249
|
return 'unknown';
|
|
176
250
|
}
|
|
251
|
+
function isShennianRunServiceCommand(command) {
|
|
252
|
+
const normalized = command.replace(/\\/g, '/');
|
|
253
|
+
return (normalized.includes(' run-service') &&
|
|
254
|
+
(normalized.includes('/node_modules/shennian/') ||
|
|
255
|
+
normalized.includes('/dist/bin/shennian.js') ||
|
|
256
|
+
normalized.includes(' shennian run-service') ||
|
|
257
|
+
normalized.includes(' shennian.js run-service')));
|
|
258
|
+
}
|
|
259
|
+
export function findRunningDaemonProcessIds(excludePid = process.pid) {
|
|
260
|
+
if (getPlatform() === 'win32')
|
|
261
|
+
return [];
|
|
262
|
+
try {
|
|
263
|
+
const output = execSync('ps -axo pid=,command=', {
|
|
264
|
+
encoding: 'utf-8',
|
|
265
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
266
|
+
timeout: 1000,
|
|
267
|
+
windowsHide: true,
|
|
268
|
+
});
|
|
269
|
+
return output
|
|
270
|
+
.split(/\r?\n/)
|
|
271
|
+
.map((line) => {
|
|
272
|
+
const match = line.trim().match(/^(\d+)\s+(.+)$/);
|
|
273
|
+
if (!match)
|
|
274
|
+
return null;
|
|
275
|
+
const pid = Number(match[1]);
|
|
276
|
+
const command = match[2];
|
|
277
|
+
if (!Number.isInteger(pid) || pid === excludePid)
|
|
278
|
+
return null;
|
|
279
|
+
return isShennianRunServiceCommand(command) ? pid : null;
|
|
280
|
+
})
|
|
281
|
+
.filter((pid) => typeof pid === 'number');
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
177
287
|
export function getDaemonStatus(opts = {}) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
288
|
+
let record = readPidRecord();
|
|
289
|
+
let pid = record?.pid ?? null;
|
|
290
|
+
let running = pid !== null && isRunning(pid);
|
|
291
|
+
let stale = pid !== null && !running;
|
|
292
|
+
let adopted = record?.adopted === true;
|
|
181
293
|
const config = loadConfig();
|
|
182
294
|
if (stale && opts.cleanupStale) {
|
|
183
295
|
try {
|
|
@@ -186,6 +298,28 @@ export function getDaemonStatus(opts = {}) {
|
|
|
186
298
|
catch {
|
|
187
299
|
// Another process may have cleaned up the stale pid file first.
|
|
188
300
|
}
|
|
301
|
+
record = null;
|
|
302
|
+
pid = null;
|
|
303
|
+
running = false;
|
|
304
|
+
stale = false;
|
|
305
|
+
}
|
|
306
|
+
if (!running) {
|
|
307
|
+
const discoveredPid = findRunningDaemonProcessIds()[0];
|
|
308
|
+
if (discoveredPid) {
|
|
309
|
+
pid = discoveredPid;
|
|
310
|
+
running = true;
|
|
311
|
+
stale = false;
|
|
312
|
+
adopted = true;
|
|
313
|
+
if (opts.cleanupStale) {
|
|
314
|
+
const instanceId = createDaemonInstanceId();
|
|
315
|
+
writeDaemonPid(discoveredPid, instanceId, {
|
|
316
|
+
launcher: inferDaemonLauncherFromProcess(discoveredPid),
|
|
317
|
+
adopted: true,
|
|
318
|
+
});
|
|
319
|
+
writeDaemonLauncher(discoveredPid, inferDaemonLauncherFromProcess(discoveredPid));
|
|
320
|
+
record = { pid: discoveredPid, instanceId, adopted: true };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
189
323
|
}
|
|
190
324
|
return {
|
|
191
325
|
running,
|
|
@@ -197,6 +331,8 @@ export function getDaemonStatus(opts = {}) {
|
|
|
197
331
|
shennianDir: SHENNIAN_DIR,
|
|
198
332
|
pidFile: PID_FILE,
|
|
199
333
|
logFile: LOG_FILE,
|
|
334
|
+
...(record?.instanceId ? { instanceId: record.instanceId } : {}),
|
|
335
|
+
...(adopted ? { adopted: true } : {}),
|
|
200
336
|
...(config.machineId ? { machineId: config.machineId } : {}),
|
|
201
337
|
paired: Boolean(config.machineToken && config.machineId),
|
|
202
338
|
...(config.serverUrl ? { serverUrl: config.serverUrl } : {}),
|
|
@@ -426,13 +562,28 @@ export function saveEnvSnapshot() {
|
|
|
426
562
|
// ─── Exported helpers (used by pair.ts) ─────────────────────────────────────
|
|
427
563
|
export function startDaemonProcess(opts = {}) {
|
|
428
564
|
fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
|
|
429
|
-
const
|
|
430
|
-
if (pid !== null &&
|
|
565
|
+
const status = getDaemonStatus({ cleanupStale: true });
|
|
566
|
+
if (status.pid !== null && status.running && !status.adopted) {
|
|
431
567
|
if (!opts.quiet) {
|
|
432
|
-
console.log(chalk.green(`✓ Background service already running (PID ${pid})`));
|
|
568
|
+
console.log(chalk.green(`✓ Background service already running (PID ${status.pid})`));
|
|
433
569
|
}
|
|
434
570
|
return;
|
|
435
571
|
}
|
|
572
|
+
if (status.pid !== null && status.running && status.adopted) {
|
|
573
|
+
try {
|
|
574
|
+
process.kill(status.pid, 'SIGTERM');
|
|
575
|
+
waitForPidExitSync(status.pid);
|
|
576
|
+
try {
|
|
577
|
+
fs.unlinkSync(PID_FILE);
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// The adopted daemon may have cleaned up or another start may have taken over.
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// The adopted orphan may already have exited; continue with a clean start.
|
|
585
|
+
}
|
|
586
|
+
}
|
|
436
587
|
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
437
588
|
const launch = buildDetachedLaunchSpec(resolveCurrentServiceLaunchSpec());
|
|
438
589
|
const child = spawn(launch.command, launch.args, buildDaemonSpawnOptions(launch, logFd));
|
|
@@ -514,7 +665,8 @@ export function installService() {
|
|
|
514
665
|
}
|
|
515
666
|
// ─── Subcommand implementations ──────────────────────────────────────────────
|
|
516
667
|
function stopDaemonProcess() {
|
|
517
|
-
const
|
|
668
|
+
const status = getDaemonStatus({ cleanupStale: true });
|
|
669
|
+
const pid = status.pid;
|
|
518
670
|
if (pid === null) {
|
|
519
671
|
return {};
|
|
520
672
|
}
|
|
@@ -556,6 +708,17 @@ async function stopDaemonProcessAndWait(timeoutMs = 5000) {
|
|
|
556
708
|
// Best-effort cleanup after the process exits.
|
|
557
709
|
}
|
|
558
710
|
clearDaemonLauncher();
|
|
711
|
+
for (const orphanPid of findRunningDaemonProcessIds()) {
|
|
712
|
+
if (orphanPid === result.stoppedPid)
|
|
713
|
+
continue;
|
|
714
|
+
try {
|
|
715
|
+
process.kill(orphanPid, 'SIGTERM');
|
|
716
|
+
await waitForPidExit(orphanPid, 2000);
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
// Best-effort orphan cleanup; the main managed daemon has already stopped.
|
|
720
|
+
}
|
|
721
|
+
}
|
|
559
722
|
return result;
|
|
560
723
|
}
|
|
561
724
|
function enableRemoteAccess(opts = {}) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Command } from 'commander';
|
|
2
2
|
export declare function registerManagerCommand(program: Command): void;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// @test src/__tests__/manager-runtime.test.ts
|
|
3
3
|
// @test src/__tests__/manager-command.test.ts
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import { Option } from 'commander';
|
|
5
6
|
import chalk from 'chalk';
|
|
6
7
|
import { readExternalAttachment } from './external-attachments.js';
|
|
7
8
|
function requireManagerContext() {
|
|
@@ -74,9 +75,9 @@ function printWeChatRpaStatus(channel) {
|
|
|
74
75
|
channel.wechatRpaLastRunAt ? `lastRun=${String(channel.wechatRpaLastRunAt)}` : '',
|
|
75
76
|
channel.wechatRpaLastMessageAt ? `lastMessage=${String(channel.wechatRpaLastMessageAt)}` : '',
|
|
76
77
|
channel.wechatRpaLastInterruptedAt ? `lastInterrupted=${String(channel.wechatRpaLastInterruptedAt)}` : '',
|
|
77
|
-
channel.wechatRpaLastCloudOcrAt ? `
|
|
78
|
-
channel.wechatRpaLastCloudOcrPurpose ? `
|
|
79
|
-
channel.wechatRpaLastCloudOcrRequestId ? `
|
|
78
|
+
channel.wechatRpaLastCloudOcrAt ? `lastLocalOcr=${String(channel.wechatRpaLastCloudOcrAt)}` : '',
|
|
79
|
+
channel.wechatRpaLastCloudOcrPurpose ? `lastLocalOcrPurpose=${String(channel.wechatRpaLastCloudOcrPurpose)}` : '',
|
|
80
|
+
channel.wechatRpaLastCloudOcrRequestId ? `lastLocalOcrRequestId=${String(channel.wechatRpaLastCloudOcrRequestId)}` : '',
|
|
80
81
|
channel.wechatRpaLastError ? `lastError=${String(channel.wechatRpaLastError)}` : 'lastError=none',
|
|
81
82
|
].filter(Boolean);
|
|
82
83
|
console.log(fields.join('\n'));
|
|
@@ -352,8 +353,9 @@ export function registerManagerCommand(program) {
|
|
|
352
353
|
.option('--id <id>', 'Channel id')
|
|
353
354
|
.option('--name <name>', 'Channel display name')
|
|
354
355
|
.requiredOption('--enabled <true|false>', 'Whether the channel should be enabled')
|
|
355
|
-
.option('--group <name>', 'WeChat group name;
|
|
356
|
+
.option('--group <name>', 'Bound WeChat group name; pass once per conversation', collect, [])
|
|
356
357
|
.option('--can-reply <true|false>', 'Whether reply should be allowed')
|
|
358
|
+
.option('--source <macos-flow|windows-visual-flow|wechat-rpa-lab|macos-probe|fixture-jsonl>', 'WeChat RPA implementation source')
|
|
357
359
|
.option('--poll-interval-ms <n>', 'Polling interval in milliseconds')
|
|
358
360
|
.option('--recent-limit <n>', 'Recent message OCR debug limit')
|
|
359
361
|
.option('--idle-seconds <n>', 'Minimum user idle seconds before foreground automation')
|
|
@@ -362,17 +364,21 @@ export function registerManagerCommand(program) {
|
|
|
362
364
|
.option('--download-attachments <true|false>', 'Click and localize inbound attachment candidates')
|
|
363
365
|
.option('--download-attachments-dir <path>', 'Directory for localized inbound WeChat attachments')
|
|
364
366
|
.option('--flow-script-path <path>', 'Override macOS flow script path')
|
|
365
|
-
.
|
|
366
|
-
.
|
|
367
|
-
.
|
|
367
|
+
.addOption(new Option('--cloud-ocr-url <url>', 'Deprecated; ignored because WeChat RPA uses local-only OCR').hideHelp())
|
|
368
|
+
.addOption(new Option('--cloud-ocr-token <token>', 'Deprecated; ignored because WeChat RPA uses local-only OCR').hideHelp())
|
|
369
|
+
.addOption(new Option('--cloud-ocr-mode <off|fallback|always>', 'Deprecated; ignored because WeChat RPA uses local-only OCR').hideHelp())
|
|
368
370
|
.option('--json', 'Print JSON')
|
|
369
371
|
.action(async (opts) => {
|
|
372
|
+
if (opts.enabled === 'true' && opts.group.length > 1) {
|
|
373
|
+
throw new Error('WeChat RPA 每个对话只能绑定一个群');
|
|
374
|
+
}
|
|
370
375
|
const result = await ipc('/wechat-rpa/channel/upsert', {
|
|
371
376
|
id: opts.id,
|
|
372
377
|
name: opts.name,
|
|
373
378
|
enabled: opts.enabled === 'true',
|
|
374
379
|
groups: opts.group.map((name) => ({ name })),
|
|
375
380
|
canReply: parseBool(opts.canReply),
|
|
381
|
+
source: opts.source,
|
|
376
382
|
pollIntervalMs: parseNumber(opts.pollIntervalMs),
|
|
377
383
|
recentLimit: parseNumber(opts.recentLimit),
|
|
378
384
|
idleSeconds: parseNumber(opts.idleSeconds),
|
|
@@ -381,9 +387,6 @@ export function registerManagerCommand(program) {
|
|
|
381
387
|
downloadAttachments: parseBool(opts.downloadAttachments),
|
|
382
388
|
downloadAttachmentsDir: opts.downloadAttachmentsDir,
|
|
383
389
|
flowScriptPath: opts.flowScriptPath,
|
|
384
|
-
cloudOcrUrl: opts.cloudOcrUrl,
|
|
385
|
-
cloudOcrToken: opts.cloudOcrToken,
|
|
386
|
-
cloudOcrMode: opts.cloudOcrMode,
|
|
387
390
|
});
|
|
388
391
|
if (opts.json)
|
|
389
392
|
printJson(result);
|
package/dist/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from 'node:fs';
|
|
|
8
8
|
import { loadConfig, saveConfig, configPath, getShennianDir, resolveShennianPath, } from './config/index.js';
|
|
9
9
|
import { CliRelayClient } from './relay/client.js';
|
|
10
10
|
import { registerPairCommand, runSmartStart } from './commands/pair.js';
|
|
11
|
-
import { clearDaemonLauncher, isRemoteAccessDisabled, registerDaemonCommand, writeDaemonLauncher, } from './commands/daemon.js';
|
|
11
|
+
import { clearDaemonPidIfOwner, createDaemonInstanceId, clearDaemonLauncher, findRunningDaemonProcessIds, isRemoteAccessDisabled, registerDaemonCommand, writeDaemonPid, writeDaemonLauncher, } from './commands/daemon.js';
|
|
12
12
|
import { registerAgentCommand } from './commands/agent.js';
|
|
13
13
|
import { registerManagerCommand } from './commands/manager.js';
|
|
14
14
|
import { registerExternalCommand } from './commands/external.js';
|
|
@@ -29,6 +29,21 @@ import { NativeSessionFusionService } from './native-fusion/service.js';
|
|
|
29
29
|
import { startDaemonLogRetention } from './daemon-log.js';
|
|
30
30
|
const SHENNIAN_DIR = getShennianDir();
|
|
31
31
|
const PID_FILE = resolveShennianPath('daemon.pid');
|
|
32
|
+
function readDaemonPidFile() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(PID_FILE, 'utf-8').trim();
|
|
35
|
+
if (raw.startsWith('{')) {
|
|
36
|
+
const parsed = JSON.parse(raw);
|
|
37
|
+
const pid = Number(parsed.pid);
|
|
38
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
39
|
+
}
|
|
40
|
+
const pid = parseInt(raw, 10);
|
|
41
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
32
47
|
function httpToWs(url) {
|
|
33
48
|
return url.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
|
|
34
49
|
}
|
|
@@ -100,23 +115,25 @@ program
|
|
|
100
115
|
}
|
|
101
116
|
// Single-instance guard. Service-manager starts are authoritative and may
|
|
102
117
|
// need to take over from an older detached process after app/daemon upgrades.
|
|
103
|
-
const
|
|
118
|
+
const serviceManagedStart = Boolean(process.env.INVOCATION_ID ||
|
|
119
|
+
process.env.JOURNAL_STREAM ||
|
|
120
|
+
process.env.SHENNIAN_DESKTOP_SERVER_URL);
|
|
121
|
+
const stopExistingDaemon = async (oldPid, reason) => {
|
|
122
|
+
console.log(`[${new Date().toISOString()}] ${reason} (PID ${oldPid})`);
|
|
123
|
+
process.kill(oldPid, 'SIGTERM');
|
|
124
|
+
const stopped = await waitForPidExit(oldPid);
|
|
125
|
+
if (!stopped) {
|
|
126
|
+
process.kill(oldPid, 'SIGKILL');
|
|
127
|
+
await waitForPidExit(oldPid, 2000);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
104
130
|
try {
|
|
105
|
-
const oldPid =
|
|
131
|
+
const oldPid = readDaemonPidFile();
|
|
106
132
|
if (oldPid && oldPid !== process.pid) {
|
|
107
133
|
try {
|
|
108
134
|
process.kill(oldPid, 0);
|
|
109
|
-
const serviceManagedStart = Boolean(process.env.INVOCATION_ID ||
|
|
110
|
-
process.env.JOURNAL_STREAM ||
|
|
111
|
-
process.env.SHENNIAN_DESKTOP_SERVER_URL);
|
|
112
135
|
if (serviceManagedStart) {
|
|
113
|
-
|
|
114
|
-
process.kill(oldPid, 'SIGTERM');
|
|
115
|
-
const stopped = await waitForPidExit(oldPid);
|
|
116
|
-
if (!stopped) {
|
|
117
|
-
process.kill(oldPid, 'SIGKILL');
|
|
118
|
-
await waitForPidExit(oldPid, 2000);
|
|
119
|
-
}
|
|
136
|
+
await stopExistingDaemon(oldPid, 'managed start taking over from existing daemon');
|
|
120
137
|
}
|
|
121
138
|
else {
|
|
122
139
|
console.log(`[${new Date().toISOString()}] daemon already running (PID ${oldPid}), skipping duplicate start`);
|
|
@@ -131,16 +148,29 @@ program
|
|
|
131
148
|
catch {
|
|
132
149
|
/* noop */
|
|
133
150
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
151
|
+
const orphanPids = findRunningDaemonProcessIds(process.pid);
|
|
152
|
+
if (orphanPids.length > 0) {
|
|
153
|
+
if (serviceManagedStart) {
|
|
154
|
+
for (const orphanPid of orphanPids) {
|
|
155
|
+
try {
|
|
156
|
+
await stopExistingDaemon(orphanPid, 'managed start taking over from orphan daemon');
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Keep booting; the normal pid-file guard below still protects the managed daemon.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
139
162
|
}
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
else {
|
|
164
|
+
console.log(`[${new Date().toISOString()}] daemon already running (PID ${orphanPids[0]}), skipping duplicate start`);
|
|
165
|
+
process.exit(0);
|
|
142
166
|
}
|
|
143
|
-
|
|
167
|
+
}
|
|
168
|
+
const daemonInstanceId = createDaemonInstanceId();
|
|
169
|
+
writeDaemonPid(process.pid, daemonInstanceId, { version: cliVersion });
|
|
170
|
+
writeDaemonLauncher(process.pid, undefined, daemonInstanceId);
|
|
171
|
+
process.on('exit', () => {
|
|
172
|
+
clearDaemonPidIfOwner(process.pid, daemonInstanceId);
|
|
173
|
+
clearDaemonLauncher(daemonInstanceId);
|
|
144
174
|
});
|
|
145
175
|
// Crash detection: if we're recovering from a failed upgrade, rollback and exit
|
|
146
176
|
const didRollback = await handleStartupCrashCheck();
|
|
@@ -156,24 +186,24 @@ program
|
|
|
156
186
|
const serverUrl = opts.api ?? process.env.SHENNIAN_DESKTOP_SERVER_URL ?? config.serverUrl ?? SERVERS.cn.url;
|
|
157
187
|
const wsBase = httpToWs(serverUrl);
|
|
158
188
|
const wsUrl = `${wsBase}/relay/machine`;
|
|
159
|
-
const
|
|
189
|
+
const currentCliVersion = getCurrentVersion();
|
|
160
190
|
const detectedAgents = detectAgents();
|
|
161
191
|
const agentList = detectedAgents.map((a) => a.type);
|
|
162
192
|
const cachedAgentInfos = getCachedAgentInfos(detectedAgents);
|
|
163
193
|
if (config.machineId) {
|
|
164
194
|
initCliLogReporter(serverUrl, config.machineId);
|
|
165
195
|
}
|
|
166
|
-
console.log(`[${new Date().toISOString()}] Connecting to ${wsUrl}... (v${
|
|
196
|
+
console.log(`[${new Date().toISOString()}] Connecting to ${wsUrl}... (v${currentCliVersion}) agents: ${agentList.join(',')}`);
|
|
167
197
|
reportLog({
|
|
168
198
|
level: 'info',
|
|
169
199
|
wsEvent: 'daemon.start',
|
|
170
|
-
metadata: { version:
|
|
200
|
+
metadata: { version: currentCliVersion, agents: agentList },
|
|
171
201
|
});
|
|
172
202
|
let nativeFusion = null;
|
|
173
203
|
const client = new CliRelayClient({
|
|
174
204
|
serverUrl: wsUrl,
|
|
175
205
|
machineToken: config.machineToken,
|
|
176
|
-
cliVersion,
|
|
206
|
+
cliVersion: currentCliVersion,
|
|
177
207
|
agentList,
|
|
178
208
|
onConnected: () => {
|
|
179
209
|
console.log(`[${new Date().toISOString()}] ✓ Connected`);
|
|
@@ -224,7 +254,7 @@ program
|
|
|
224
254
|
}
|
|
225
255
|
})
|
|
226
256
|
.catch(() => { });
|
|
227
|
-
void scheduleAutoUpgrade(client, config.autoUpgrade ?? 'patch',
|
|
257
|
+
void scheduleAutoUpgrade(client, config.autoUpgrade ?? 'patch', currentCliVersion);
|
|
228
258
|
nativeFusion?.handleConnected();
|
|
229
259
|
},
|
|
230
260
|
onDisconnected: (info) => {
|
|
@@ -255,13 +285,13 @@ program
|
|
|
255
285
|
},
|
|
256
286
|
});
|
|
257
287
|
nativeFusion =
|
|
258
|
-
process.env.
|
|
259
|
-
?
|
|
260
|
-
:
|
|
261
|
-
const sessionManager = new SessionManager(client, nativeFusion,
|
|
288
|
+
process.env.SHENNIAN_NATIVE_FUSION_ENABLED === '1'
|
|
289
|
+
? new NativeSessionFusionService(client)
|
|
290
|
+
: null;
|
|
291
|
+
const sessionManager = new SessionManager(client, nativeFusion, currentCliVersion);
|
|
262
292
|
fs.mkdirSync(SHENNIAN_DIR, { recursive: true });
|
|
263
|
-
|
|
264
|
-
writeDaemonLauncher(process.pid);
|
|
293
|
+
writeDaemonPid(process.pid, daemonInstanceId, { version: currentCliVersion });
|
|
294
|
+
writeDaemonLauncher(process.pid, undefined, daemonInstanceId);
|
|
265
295
|
client.connect();
|
|
266
296
|
process.stdin.resume();
|
|
267
297
|
const shutdown = () => {
|
|
@@ -271,13 +301,8 @@ program
|
|
|
271
301
|
sessionManager.cleanup();
|
|
272
302
|
nativeFusion?.stop();
|
|
273
303
|
client.disconnect();
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
/* noop */
|
|
279
|
-
}
|
|
280
|
-
clearDaemonLauncher();
|
|
304
|
+
clearDaemonPidIfOwner(process.pid, daemonInstanceId);
|
|
305
|
+
clearDaemonLauncher(daemonInstanceId);
|
|
281
306
|
process.exit(0);
|
|
282
307
|
};
|
|
283
308
|
process.on('SIGINT', shutdown);
|
|
@@ -772,8 +772,12 @@ ${message || worker.summary || '(无可见摘要)'}
|
|
|
772
772
|
const modelId = config?.modelId || manager?.modelId || '';
|
|
773
773
|
const attachmentInputs = externalAttachmentsForAgent(event.attachments);
|
|
774
774
|
const attachmentSummary = externalAttachmentSummary(event.attachments, event.text);
|
|
775
|
+
const mentionSummary = event.isMentioned ? '提及:是' : '';
|
|
775
776
|
const visibleReplyTarget = event.replyTarget ? `回复目标:${event.replyTarget}` : '';
|
|
776
|
-
const
|
|
777
|
+
const visibleConversation = event.channelType === 'wechat-rpa' && event.conversationName
|
|
778
|
+
? `来源群:${event.conversationName}`
|
|
779
|
+
: '';
|
|
780
|
+
const visibleBody = [visibleConversation, event.text, attachmentSummary, mentionSummary, visibleReplyTarget].filter(Boolean).join('\n');
|
|
777
781
|
const visibleMessage = visibleBody
|
|
778
782
|
? `外部消息 / ${event.sender.name || event.sender.id}\n${visibleBody}`
|
|
779
783
|
: `外部消息 / ${event.sender.name || event.sender.id}`;
|
|
@@ -791,7 +795,7 @@ ${message || worker.summary || '(无可见摘要)'}
|
|
|
791
795
|
modelId,
|
|
792
796
|
text: visibleMessage,
|
|
793
797
|
attachments: attachmentInputs,
|
|
794
|
-
externalChannel,
|
|
798
|
+
externalChannel: externalChannelForChatEnqueue(externalChannel),
|
|
795
799
|
replyTarget: event.replyTarget,
|
|
796
800
|
});
|
|
797
801
|
}
|
|
@@ -896,7 +900,7 @@ function parseWeChatRpaGroups(value) {
|
|
|
896
900
|
.filter((item) => item.name);
|
|
897
901
|
}
|
|
898
902
|
function parseWeChatRpaSource(value) {
|
|
899
|
-
return value === 'macos-flow' || value === 'macos-probe' || value === 'windows-visual-flow' || value === 'fixture-jsonl' ? value : undefined;
|
|
903
|
+
return value === 'macos-flow' || value === 'macos-probe' || value === 'windows-visual-flow' || value === 'wechat-rpa-lab' || value === 'fixture-jsonl' ? value : undefined;
|
|
900
904
|
}
|
|
901
905
|
function externalAttachmentsForAgent(attachments) {
|
|
902
906
|
const result = attachments
|
|
@@ -947,6 +951,34 @@ function externalAttachmentUnavailableStatus(attachment) {
|
|
|
947
951
|
return 'edge-local-unavailable';
|
|
948
952
|
return attachment.availability || 'metadata-only';
|
|
949
953
|
}
|
|
954
|
+
function externalChannelForChatEnqueue(channel) {
|
|
955
|
+
if (!channel)
|
|
956
|
+
return null;
|
|
957
|
+
if (channel.type !== 'wechat-rpa')
|
|
958
|
+
return channel;
|
|
959
|
+
return {
|
|
960
|
+
configured: channel.configured,
|
|
961
|
+
connected: channel.connected,
|
|
962
|
+
type: channel.type,
|
|
963
|
+
channelId: channel.channelId,
|
|
964
|
+
name: channel.name,
|
|
965
|
+
canReply: channel.canReply,
|
|
966
|
+
systemPrompt: channel.systemPrompt,
|
|
967
|
+
wechatRpaSource: channel.wechatRpaSource,
|
|
968
|
+
wechatRpaGroups: channel.wechatRpaGroups,
|
|
969
|
+
pollIntervalMs: channel.pollIntervalMs,
|
|
970
|
+
recentLimit: channel.recentLimit,
|
|
971
|
+
idleSeconds: channel.idleSeconds,
|
|
972
|
+
forceForeground: channel.forceForeground,
|
|
973
|
+
noRestore: channel.noRestore,
|
|
974
|
+
downloadAttachments: channel.downloadAttachments,
|
|
975
|
+
selfNickname: channel.selfNickname,
|
|
976
|
+
wechatRpaRuntimeState: channel.wechatRpaRuntimeState,
|
|
977
|
+
wechatRpaLastMessageAt: channel.wechatRpaLastMessageAt,
|
|
978
|
+
wechatRpaPendingReplyCount: channel.wechatRpaPendingReplyCount,
|
|
979
|
+
wechatRpaLastError: channel.wechatRpaLastError,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
950
982
|
function canUseOriginalLocalAttachment(attachment) {
|
|
951
983
|
return !attachment.providerError
|
|
952
984
|
&& (!attachment.availability || attachment.availability === 'edge-local');
|
|
@@ -113,6 +113,8 @@ export function parseOpenCodeSessionFile(filePath, startOffset) {
|
|
|
113
113
|
const stat = fs.statSync(filePath);
|
|
114
114
|
if (startOffset >= stat.size)
|
|
115
115
|
return { nextOffset: stat.size, events: [] };
|
|
116
|
+
if (startOffset > 0)
|
|
117
|
+
return { nextOffset: stat.size, events: [] };
|
|
116
118
|
const snapshot = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
117
119
|
return {
|
|
118
120
|
nextOffset: stat.size,
|
|
@@ -70,6 +70,17 @@ function stripCodexUserMessageWrapper(text) {
|
|
|
70
70
|
function stripCodexImagePlaceholders(text) {
|
|
71
71
|
return normalizeText(text.replace(/<image>\s*<\/image>/gi, ''));
|
|
72
72
|
}
|
|
73
|
+
function isCodexInjectedContextText(text) {
|
|
74
|
+
const normalized = normalizeText(text);
|
|
75
|
+
if (!normalized)
|
|
76
|
+
return false;
|
|
77
|
+
const hasProjectInstructions = normalized.startsWith('# AGENTS.md instructions for ') &&
|
|
78
|
+
normalized.includes('<INSTRUCTIONS>') &&
|
|
79
|
+
normalized.includes('</INSTRUCTIONS>');
|
|
80
|
+
const hasEnvironmentContext = normalized.includes('<environment_context>') &&
|
|
81
|
+
normalized.includes('</environment_context>');
|
|
82
|
+
return hasProjectInstructions && hasEnvironmentContext;
|
|
83
|
+
}
|
|
73
84
|
function parseCodexUserMessage(payload) {
|
|
74
85
|
const textFromElements = parseCodexTextElements(payload.text_elements);
|
|
75
86
|
const textFromInput = Array.isArray(payload.input)
|
|
@@ -90,6 +101,8 @@ function parseCodexUserMessage(payload) {
|
|
|
90
101
|
return null;
|
|
91
102
|
if (isSystemControlPayload(text) && attachments.length === 0)
|
|
92
103
|
return null;
|
|
104
|
+
if (isCodexInjectedContextText(text) && attachments.length === 0)
|
|
105
|
+
return null;
|
|
93
106
|
if (attachments.length > 0) {
|
|
94
107
|
return {
|
|
95
108
|
payload: buildUserMessagePayload(text, attachments),
|
|
@@ -185,6 +198,8 @@ function parseCodexResponseMessage(payload) {
|
|
|
185
198
|
return null;
|
|
186
199
|
if (isSystemControlPayload(text) && attachments.length === 0)
|
|
187
200
|
return null;
|
|
201
|
+
if (isCodexInjectedContextText(text) && attachments.length === 0)
|
|
202
|
+
return null;
|
|
188
203
|
return {
|
|
189
204
|
role,
|
|
190
205
|
payload: attachments.length > 0 ? buildUserMessagePayload(text, attachments) : text,
|