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.
@@ -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 writeDaemonLauncher(pid: number, launcher?: DaemonLauncher): void;
43
- export declare function clearDaemonLauncher(): void;
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
- // ─── PID helpers ────────────────────────────────────────────────────────────
87
- function readPid() {
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
- const raw = fs.readFileSync(PID_FILE, 'utf-8').trim();
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 writeDaemonLauncher(pid, launcher = getCurrentProcessDaemonLauncher()) {
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
- const pid = readPid();
179
- const running = pid !== null && isRunning(pid);
180
- const stale = pid !== null && !running;
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 pid = readPid();
430
- if (pid !== null && isRunning(pid)) {
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 pid = readPid();
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 { Command } from 'commander';
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 ? `lastCloudOcr=${String(channel.wechatRpaLastCloudOcrAt)}` : '',
78
- channel.wechatRpaLastCloudOcrPurpose ? `lastCloudOcrPurpose=${String(channel.wechatRpaLastCloudOcrPurpose)}` : '',
79
- channel.wechatRpaLastCloudOcrRequestId ? `lastCloudOcrRequestId=${String(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; repeat for multiple groups', collect, [])
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
- .option('--cloud-ocr-url <url>', 'Shennian server OCR fallback URL')
366
- .option('--cloud-ocr-token <token>', 'Shennian user/channel token for OCR fallback')
367
- .option('--cloud-ocr-mode <off|fallback|always>', 'Cloud OCR mode')
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 pidFile = resolveShennianPath('daemon.pid');
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 = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
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
- console.log(`[${new Date().toISOString()}] managed start taking over from existing daemon (PID ${oldPid})`);
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
- fs.writeFileSync(pidFile, String(process.pid));
135
- writeDaemonLauncher(process.pid);
136
- process.on('exit', () => {
137
- try {
138
- fs.unlinkSync(pidFile);
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
- catch {
141
- /* noop */
163
+ else {
164
+ console.log(`[${new Date().toISOString()}] daemon already running (PID ${orphanPids[0]}), skipping duplicate start`);
165
+ process.exit(0);
142
166
  }
143
- clearDaemonLauncher();
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 cliVersion = getCurrentVersion();
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${cliVersion}) agents: ${agentList.join(',')}`);
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: cliVersion, agents: agentList },
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', cliVersion);
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.SHENNIAN_NATIVE_FUSION_DISABLED === '1'
259
- ? null
260
- : new NativeSessionFusionService(client);
261
- const sessionManager = new SessionManager(client, nativeFusion, cliVersion);
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
- fs.writeFileSync(PID_FILE, String(process.pid));
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
- try {
275
- fs.unlinkSync(PID_FILE);
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 visibleBody = [event.text, attachmentSummary, visibleReplyTarget].filter(Boolean).join('\n');
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,