lark-remote 0.2.1 → 0.3.0

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.
@@ -114,7 +114,6 @@ export declare class FeishuConnector {
114
114
  }): Promise<InboundMediaPayload>;
115
115
  connect(): Promise<void>;
116
116
  disconnect(): Promise<void>;
117
- reconnect(): Promise<void>;
118
117
  /**
119
118
  * Send a message with automatic rate-limit retry (§9.5).
120
119
  * Encounters rate_limited / feishuCode 99991400|99991401(SDK 归类为
@@ -141,11 +140,30 @@ export declare class FeishuConnector {
141
140
  * The SDK resolves the reaction id internally; missing reaction is a no-op.
142
141
  */
143
142
  removeReactionByEmoji(messageId: string, emoji: string): Promise<void>;
143
+ /**
144
+ * sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
145
+ * im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
146
+ */
147
+ private checkUploadable;
148
+ /**
149
+ * sendFile/sendImage 共用骨架:token → 上传(流在任何失败路径销毁,P2-18)
150
+ * → 发送媒体消息。上传端点 / form 字段 / 响应 key / msg_type 的差异由调用方
151
+ * 以 uploadUrl + buildForm + keyField + msgType 表达;timeout、noKeepAliveAgent
152
+ * (Bun keep-alive 修复)与错误包装在此单源。label 是对外错误前缀与日志
153
+ * 标签('sendFile' | 'sendImage')。
154
+ */
155
+ private uploadAndSendMedia;
144
156
  /**
145
157
  * Upload a file to Feishu and send it to the specified chat.
146
158
  * Uses the im/v1/files upload API.
147
159
  */
148
160
  sendFile(chatId: string, filePath: string): Promise<string>;
161
+ /**
162
+ * Upload a local image file to Feishu and send it as an image message
163
+ * (im/v1/images + msg_type=image). Used by the clone wizard to deliver the
164
+ * scan-to-create QR code inline instead of as a downloadable file.
165
+ */
166
+ sendImage(chatId: string, filePath: string): Promise<string>;
149
167
  /**
150
168
  * Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
151
169
  * fetching it on every file send wastes a round-trip and an unguarded fetch
@@ -288,12 +288,6 @@ export class FeishuConnector {
288
288
  this.isConnected = false;
289
289
  }
290
290
  }
291
- async reconnect() {
292
- // disconnect() swallows its own errors internally, so no try/catch needed
293
- // here — it always resolves.
294
- await this.disconnect();
295
- await this.connect();
296
- }
297
291
  /**
298
292
  * Send a message with automatic rate-limit retry (§9.5).
299
293
  * Encounters rate_limited / feishuCode 99991400|99991401(SDK 归类为
@@ -384,11 +378,10 @@ export class FeishuConnector {
384
378
  }
385
379
  }
386
380
  /**
387
- * Upload a file to Feishu and send it to the specified chat.
388
- * Uses the im/v1/files upload API.
381
+ * sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
382
+ * im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
389
383
  */
390
- async sendFile(chatId, filePath) {
391
- // Check file existence first with proper error handling
384
+ checkUploadable(filePath, noun) {
392
385
  let stat;
393
386
  try {
394
387
  stat = fs.statSync(filePath);
@@ -396,33 +389,32 @@ export class FeishuConnector {
396
389
  catch (err) {
397
390
  const code = err.code;
398
391
  if (code === 'ENOENT') {
399
- throw new Error(`Cannot access file: file not found (${filePath})`, { cause: err });
392
+ throw new Error(`Cannot access ${noun}: file not found (${filePath})`, { cause: err });
400
393
  }
401
- throw new Error(`Cannot access file: ${err.message}`, { cause: err });
394
+ throw new Error(`Cannot access ${noun}: ${err.message}`, { cause: err });
402
395
  }
403
- // Check file size limit (30MB, aligned with Feishu im/v1/files API)
404
396
  if (stat.size > MAX_FILE_UPLOAD_SIZE) {
405
- throw new Error(`File too large (${(stat.size / 1_000_000).toFixed(1)}MB), exceeds ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB limit`);
397
+ const nounCap = noun.charAt(0).toUpperCase() + noun.slice(1);
398
+ throw new Error(`${nounCap} too large (${(stat.size / 1_000_000).toFixed(1)}MB), exceeds ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB limit`);
406
399
  }
407
- const fileName = displayName(filePath);
408
- // P2-18: capture the read stream so it can be destroyed on any failure
409
- // path. Without this, a token/upload/send failure leaks the fd (axios
410
- // only closes the stream when it fully consumes it on success).
400
+ }
401
+ /**
402
+ * sendFile/sendImage 共用骨架:token → 上传(流在任何失败路径销毁,P2-18)
403
+ * → 发送媒体消息。上传端点 / form 字段 / 响应 key / msg_type 的差异由调用方
404
+ * 以 uploadUrl + buildForm + keyField + msgType 表达;timeout、noKeepAliveAgent
405
+ * (Bun keep-alive 修复)与错误包装在此单源。label 是对外错误前缀与日志
406
+ * 标签('sendFile' | 'sendImage')。
407
+ */
408
+ async uploadAndSendMedia(chatId, filePath, label, uploadUrl, buildForm, keyField, msgType) {
411
409
  let fileStream = null;
412
410
  try {
413
- // P2-18: tenant_access_token cache. Avoid re-fetching on every file send
414
- // (token is valid ~2h); validate data.code before trusting the token.
411
+ // P2-18: tenant_access_token cache(~2h),避免每次发送都取 token。
415
412
  const accessToken = await this.getTenantAccessToken();
416
- // Upload file
417
- const form = new FormData();
418
413
  fileStream = fs.createReadStream(filePath);
419
- form.append('file', fileStream);
420
- form.append('file_name', fileName);
421
- form.append('file_type', 'stream');
422
- // P2-18: timeout on the upload (large file, slow link) — 120s.
423
- // httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
424
- // keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
425
- const uploadResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
414
+ const form = buildForm(fileStream);
415
+ // P2-18: timeout 120s(大文件慢链路);httpsAgent: noKeepAlive 防止
416
+ // Bun 复用已被服务端 RST 的 keep-alive socket(ECONNRESET after ~30s)。
417
+ const uploadResp = await axios.post(uploadUrl, form, {
426
418
  headers: {
427
419
  ...form.getHeaders(),
428
420
  Authorization: `Bearer ${accessToken}`,
@@ -431,15 +423,14 @@ export class FeishuConnector {
431
423
  httpsAgent: noKeepAliveAgent,
432
424
  });
433
425
  if (uploadResp.data.code !== 0) {
434
- throw new Error(`File upload failed: ${uploadResp.data.msg}`);
426
+ throw new Error(`Upload failed: ${uploadResp.data.msg}`);
435
427
  }
436
- const fileKey = uploadResp.data.data.file_key;
437
- // Send file message. P2-18: timeout 30s.
438
- // httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
428
+ const mediaKey = uploadResp.data.data[keyField];
429
+ // P2-18: timeout 30s;noKeepAlive 同上。
439
430
  const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
440
431
  receive_id: chatId,
441
- msg_type: 'file',
442
- content: JSON.stringify({ file_key: fileKey }),
432
+ msg_type: msgType,
433
+ content: JSON.stringify({ [keyField]: mediaKey }),
443
434
  }, {
444
435
  headers: {
445
436
  'Content-Type': 'application/json; charset=utf-8',
@@ -450,7 +441,7 @@ export class FeishuConnector {
450
441
  httpsAgent: noKeepAliveAgent,
451
442
  });
452
443
  if (sendResp.data.code !== 0) {
453
- throw new Error(`File message failed: ${sendResp.data.msg}`);
444
+ throw new Error(`Media message failed: ${sendResp.data.msg}`);
454
445
  }
455
446
  return sendResp.data.data.message_id;
456
447
  }
@@ -464,12 +455,39 @@ export class FeishuConnector {
464
455
  /* already destroyed */
465
456
  }
466
457
  }
467
- // Log the error but throw a formatted error to prevent unhandled rejection
468
458
  const errorInfo = this.formatError(err);
469
- getLogger().error('[feishu] sendFile failed:', errorInfo);
470
- throw new Error(`sendFile failed: ${errorInfo}`, { cause: err });
459
+ getLogger().error(`[feishu] ${label} failed:`, errorInfo);
460
+ throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
471
461
  }
472
462
  }
463
+ /**
464
+ * Upload a file to Feishu and send it to the specified chat.
465
+ * Uses the im/v1/files upload API.
466
+ */
467
+ async sendFile(chatId, filePath) {
468
+ this.checkUploadable(filePath, 'file');
469
+ return this.uploadAndSendMedia(chatId, filePath, 'sendFile', 'https://open.feishu.cn/open-apis/im/v1/files', (stream) => {
470
+ const form = new FormData();
471
+ form.append('file', stream);
472
+ form.append('file_name', displayName(filePath));
473
+ form.append('file_type', 'stream');
474
+ return form;
475
+ }, 'file_key', 'file');
476
+ }
477
+ /**
478
+ * Upload a local image file to Feishu and send it as an image message
479
+ * (im/v1/images + msg_type=image). Used by the clone wizard to deliver the
480
+ * scan-to-create QR code inline instead of as a downloadable file.
481
+ */
482
+ async sendImage(chatId, filePath) {
483
+ this.checkUploadable(filePath, 'image');
484
+ return this.uploadAndSendMedia(chatId, filePath, 'sendImage', 'https://open.feishu.cn/open-apis/im/v1/images', (stream) => {
485
+ const form = new FormData();
486
+ form.append('image_type', 'message');
487
+ form.append('image', stream);
488
+ return form;
489
+ }, 'image_key', 'image');
490
+ }
473
491
  /**
474
492
  * Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
475
493
  * fetching it on every file send wastes a round-trip and an unguarded fetch
package/dist/index.js CHANGED
@@ -15,7 +15,8 @@ import { buildCardActionFullValue } from './router/card-action-payload.js';
15
15
  import { Bridge } from './bridge/index.js';
16
16
  import { initLogger, getLogger } from './logger/index.js';
17
17
  import { StartupContactStore, sendStartupHello } from './startup-contact.js';
18
- import { OwnerBinder, formatPinGuidance } from './binder.js';
18
+ import { OwnerBinder, formatBindGuidance } from './binder.js';
19
+ import { CloneSession } from './clone.js';
19
20
  import { InstanceAlreadyRunningError, InstanceLock } from './instance-lock.js';
20
21
  import { spawnReplacementBridge, waitForPreviousInstance } from './restart.js';
21
22
  import { checkLatestVersion, isNewer, runInstallLatest, formatUpdateHint } from './update/index.js';
@@ -26,6 +27,14 @@ import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/
26
27
  import path from 'node:path';
27
28
  import fs from 'node:fs';
28
29
  import { silentlyUnlink } from './common/fs.js';
30
+ /**
31
+ * /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
32
+ * 共用同一判定,防止将来加别名时两处漂移。
33
+ */
34
+ function isStopCommand(text) {
35
+ const t = text.trim().toLowerCase();
36
+ return t === '/stop' || t === '/t';
37
+ }
29
38
  /** Send auto-resume card for a restored session */
30
39
  async function sendAutoResumeCard(connector, contact, cwd, session, state, compactSupported = false) {
31
40
  if (!contact.userId)
@@ -280,7 +289,7 @@ function initializeRunner(config, configDir, cliArgs) {
280
289
  return { agentRegistry, sessionReaderRegistry };
281
290
  }
282
291
  /** Setup message and card action handlers. */
283
- function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config) {
292
+ function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession) {
284
293
  // 入站媒体(图片/文件):先认证后下载(P1 review 修复)。
285
294
  // connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
286
295
  // 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
@@ -324,13 +333,10 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
324
333
  })().catch((err) => logger.error('[media] inbound media download/save failed:', err));
325
334
  });
326
335
  connector.setMessageHandler((msg) => {
327
- // 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时要求 PIN 认领
336
+ // 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息(任意内容)认领
328
337
  const decision = binder.classify(msg.userId, msg.content, msg.chatId);
329
338
  if (decision.kind === 'rejected')
330
339
  return;
331
- // 未绑定且 PIN 错误:完全静默丢弃(不回复、不提醒)
332
- if (decision.kind === 'pin_wrong')
333
- return;
334
340
  if (decision.kind === 'bind_success') {
335
341
  // First-run onboarding: set default cwd + send welcome + Help card.
336
342
  // setCwd is synchronous (outside the async closure) so that the user's
@@ -385,12 +391,23 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
385
391
  bridge.flushMediaNotifications(msg.userId, msg.chatId);
386
392
  // Add Typing reaction to indicate "still alive"
387
393
  void connector.addReaction(msg.messageId, 'Typing');
394
+ // 复制分身流程活跃期:一切消息先经 clone 状态机(stop 命令除外,保留其
395
+ // 绕队列停止能力),不转发 coding agent、不进命令分发、不展开别名。
396
+ if (cloneSession.isActive() && !isStopCommand(msg.content)) {
397
+ void cloneSession
398
+ .handleMessage(msg.content, {
399
+ userId: msg.userId,
400
+ chatId: msg.chatId,
401
+ messageId: msg.messageId,
402
+ })
403
+ .catch((err) => logger.error('[clone] message handling failed:', err));
404
+ return;
405
+ }
388
406
  // 别名展开:命令分发前对消息做一次 $name 展开(不递归)。
389
407
  // `!` / `/` 开头的消息不会进入展开路径($PATH、$HOME 等 shell 变量不受影响),
390
408
  // 未知 $xxx 原样透传;展开结果若以 `/` 开头会自然落入命令路径。
391
409
  const content = router.expandAliasMessage(msg.content);
392
- const trimmedLower = content.trim().toLowerCase();
393
- if (trimmedLower === '/stop' || trimmedLower === '/t') {
410
+ if (isStopCommand(content)) {
394
411
  void (async () => {
395
412
  const stopped = await bridge.interruptCurrentRun({
396
413
  userId: msg.userId,
@@ -619,10 +636,10 @@ async function main() {
619
636
  const connector = new FeishuConnector(config);
620
637
  const startupContactStore = new StartupContactStore(path.join(configDir, 'startup-contact.json'));
621
638
  const binder = new OwnerBinder(startupContactStore);
622
- if (binder.pendingPin) {
623
- // 未绑定:控制台(stderr)展示 PIN。守护模式下被 watchdog 重定向到 daemon 日志
624
- console.error(formatPinGuidance(binder.pendingPin));
625
- logger.info('[binder] awaiting first binding (PIN printed to stderr)');
639
+ if (!binder.isBound()) {
640
+ // 未绑定:控制台(stderr)引导任意消息绑定。守护模式下被 watchdog 重定向到日志
641
+ console.error(formatBindGuidance());
642
+ logger.info('[binder] awaiting first binding (any first private message binds)');
626
643
  }
627
644
  else {
628
645
  logger.info(`[binder] bound to owner openId=${binder.boundOpenId()}`);
@@ -637,6 +654,11 @@ async function main() {
637
654
  agentRegistry,
638
655
  sessionReaderRegistry,
639
656
  });
657
+ const cloneSession = new CloneSession({
658
+ connector,
659
+ configPath: path.join(configDir, 'config.yaml'),
660
+ configDir,
661
+ });
640
662
  const router = new CommandRouter({
641
663
  sessionStore,
642
664
  bridge,
@@ -648,8 +670,9 @@ async function main() {
648
670
  sessionReaderRegistry,
649
671
  devMode: cliArgs.dev,
650
672
  updateCachePath: path.join(configDir, 'update-cache.json'),
673
+ cloneSession,
651
674
  });
652
- setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config);
675
+ setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession);
653
676
  try {
654
677
  await connector.connect();
655
678
  }
@@ -1,5 +1,7 @@
1
1
  import type { LaunchSpec } from './types.js';
2
2
  export type { LaunchSpec } from './types.js';
3
+ /** 判断一个已命中的 bash.exe 是否只是 WSL 启动器(而非 Git Bash)。 */
4
+ export declare function isWslBashLauncher(file: string): boolean;
3
5
  export interface ResolveOptions {
4
6
  platform?: NodeJS.Platform;
5
7
  /** PATH 环境变量注入(测试用);默认 process.env.PATH */
@@ -18,6 +18,33 @@ import fs from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { currentPlatform, isWin32 } from './select.js';
20
20
  const DEFAULT_PATHEXT = ['.exe', '.bat', '.cmd'];
21
+ /**
22
+ * win32 的「假 bash」:`%SystemRoot%\system32\bash.exe`(SysWOW64 同理)不是
23
+ * Git Bash,而是 WSL 启动器(wsl.exe 的 bash 别名)。PATH 里 system32 通常
24
+ * 排在 Git\cmd 之前,按目录顺序命中它会把整条 `!` 命令丢进 WSL 发行版执行:
25
+ * 工作目录变成 /mnt/d/...、node 变成 WSL 里装的那个版本。实测本机 WSL node
26
+ * 为 v12,加载 dist/index.js 的 ES2022 语法(?? / ?.)直接
27
+ * `SyntaxError: Unexpected token '?'`,`!lark-remote -v` 因此失败。
28
+ *
29
+ * 因此解析 `bash` 时必须跳过它,继续在 PATH 中找真正的 Git Bash。
30
+ *
31
+ * 判定只看两层目录名(`<...>\system32\bash.exe`):Git Bash 装在
32
+ * `<Git>\bin` / `<Git>\usr\bin`,不会叫 system32;反过来把真正的 Git Bash
33
+ * 装在名为 system32 的目录里也可以忽略。
34
+ */
35
+ const WSL_BASH_PARENT_DIRS = ['system32', 'syswow64'];
36
+ /** 判断一个已命中的 bash.exe 是否只是 WSL 启动器(而非 Git Bash)。 */
37
+ export function isWslBashLauncher(file) {
38
+ const normalized = file.replaceAll('/', '\\').toLowerCase();
39
+ if (path.win32.basename(normalized) !== 'bash.exe')
40
+ return false;
41
+ return WSL_BASH_PARENT_DIRS.includes(path.win32.basename(path.win32.dirname(normalized)));
42
+ }
43
+ /** 查询目标是 bash(`bash` 或显式 `bash.exe`)时才走上面的特例。 */
44
+ function isBashQuery(name) {
45
+ const lower = name.toLowerCase();
46
+ return lower === 'bash' || lower === 'bash.exe';
47
+ }
21
48
  /**
22
49
  * 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——bridge 运行期间
23
50
  * 用户新装/卸载 agent(典型场景:按 /config 不可用提示去安装)后,最迟
@@ -56,17 +83,42 @@ function existsWin32(file) {
56
83
  return posixForm;
57
84
  return null;
58
85
  }
86
+ /**
87
+ * Git for Windows 只把 `Git\cmd` 放进 PATH(里面没有 bash.exe),真正的
88
+ * bash 在 `Git\bin\bash.exe` / `Git\usr\bin\bash.exe`。PATH 里只暴露 cmd
89
+ * 目录时,从 cmd 反推安装根再找 bash。
90
+ */
91
+ function resolveBashFromGitCmdDir(pathEnv) {
92
+ for (const dir of listPathDirs(pathEnv, 'win32')) {
93
+ if (path.win32.basename(dir).toLowerCase() !== 'cmd')
94
+ continue;
95
+ const root = path.win32.dirname(dir);
96
+ for (const rel of ['bin\\bash.exe', 'usr\\bin\\bash.exe']) {
97
+ const found = existsWin32(path.win32.join(root, rel));
98
+ if (found)
99
+ return { kind: 'direct', file: found };
100
+ }
101
+ }
102
+ return null;
103
+ }
59
104
  function resolveWin32(name, pathEnv, pathExt) {
60
105
  const hasExtension = /\.[A-Za-z0-9]+$/.test(name);
106
+ const bashQuery = isBashQuery(name);
61
107
  for (const dir of listPathDirs(pathEnv, 'win32')) {
62
108
  const candidates = hasExtension ? [name] : pathExt.map((ext) => name + ext);
63
109
  for (const candidate of candidates) {
64
110
  const found = existsWin32(path.win32.join(dir, candidate));
111
+ if (!found)
112
+ continue;
113
+ // WSL 启动器不是 Git Bash:跳过,继续找后面的 PATH 目录
114
+ if (bashQuery && isWslBashLauncher(found))
115
+ continue;
65
116
  // .cmd/.bat 垫片命中即报告垫片路径本身;执行语义由 cross-spawn 负责
66
- if (found)
67
- return { kind: 'direct', file: found };
117
+ return { kind: 'direct', file: found };
68
118
  }
69
119
  }
120
+ if (bashQuery)
121
+ return resolveBashFromGitCmdDir(pathEnv);
70
122
  return null;
71
123
  }
72
124
  function resolvePosix(name, pathEnv) {
package/dist/restart.d.ts CHANGED
@@ -8,6 +8,23 @@
8
8
  */
9
9
  /** Env var telling a replacement child which parent pid to wait for before acquiring the instance lock. */
10
10
  export declare const RESTART_WAIT_PID_ENV = "LARK_REMOTE_RESTART_WAIT_PID";
11
+ /**
12
+ * Spawn a detached bridge process (shared by /restart replacement and /clone
13
+ * new-instance) whose early output — before the child's own file logger
14
+ * initializes — lands on logFilePath. Returns the child pid, or null when
15
+ * spawn failed synchronously / no pid; filesystem errors (unwritable log
16
+ * dir) propagate. Caller picks throw-vs-degrade semantics.
17
+ *
18
+ * Two disciplines live here so every caller inherits them for free:
19
+ * - windowsHide: win32 detached children would flash a console window (§3.7);
20
+ * - the 'error' handler is attached BEFORE the pid check — a failed spawn
21
+ * emits 'error' asynchronously (ENOENT/EACCES), and the synchronous pid
22
+ * check throws/returns first, so attaching after would leave the 'error'
23
+ * event unhandled → uncaughtException kills the parent.
24
+ */
25
+ export declare function spawnDetachedBridge(args: string[], logFilePath: string, opts?: {
26
+ env?: NodeJS.ProcessEnv;
27
+ }): number | null;
11
28
  /**
12
29
  * Spawn a detached replacement bridge with the same executable and argv
13
30
  * (including --config-dir) and return its pid. The caller is expected to
package/dist/restart.js CHANGED
@@ -15,6 +15,41 @@ import { sleep } from './common/sleep.js';
15
15
  export const RESTART_WAIT_PID_ENV = 'LARK_REMOTE_RESTART_WAIT_PID';
16
16
  const WAIT_TIMEOUT_MS = 20_000;
17
17
  const POLL_MS = 100;
18
+ /**
19
+ * Spawn a detached bridge process (shared by /restart replacement and /clone
20
+ * new-instance) whose early output — before the child's own file logger
21
+ * initializes — lands on logFilePath. Returns the child pid, or null when
22
+ * spawn failed synchronously / no pid; filesystem errors (unwritable log
23
+ * dir) propagate. Caller picks throw-vs-degrade semantics.
24
+ *
25
+ * Two disciplines live here so every caller inherits them for free:
26
+ * - windowsHide: win32 detached children would flash a console window (§3.7);
27
+ * - the 'error' handler is attached BEFORE the pid check — a failed spawn
28
+ * emits 'error' asynchronously (ENOENT/EACCES), and the synchronous pid
29
+ * check throws/returns first, so attaching after would leave the 'error'
30
+ * event unhandled → uncaughtException kills the parent.
31
+ */
32
+ export function spawnDetachedBridge(args, logFilePath, opts) {
33
+ fs.mkdirSync(path.dirname(logFilePath), { recursive: true });
34
+ const out = fs.openSync(logFilePath, 'a');
35
+ try {
36
+ const child = spawnProcess(process.execPath, args, {
37
+ cwd: process.cwd(),
38
+ env: opts?.env ?? process.env,
39
+ detached: true,
40
+ windowsHide: true,
41
+ stdio: ['ignore', out, out],
42
+ });
43
+ child.on('error', () => { });
44
+ if (child.pid === undefined)
45
+ return null;
46
+ child.unref();
47
+ return child.pid;
48
+ }
49
+ finally {
50
+ fs.closeSync(out);
51
+ }
52
+ }
18
53
  /**
19
54
  * Spawn a detached replacement bridge with the same executable and argv
20
55
  * (including --config-dir) and return its pid. The caller is expected to
@@ -22,29 +57,13 @@ const POLL_MS = 100;
22
57
  * startup failures (before the file logger initializes) are not lost.
23
58
  */
24
59
  export function spawnReplacementBridge(logsDir) {
25
- fs.mkdirSync(logsDir, { recursive: true });
26
- const out = fs.openSync(path.join(logsDir, 'restart-child.log'), 'a');
27
- const child = spawnProcess(process.execPath, process.argv.slice(1), {
28
- cwd: process.cwd(),
60
+ const pid = spawnDetachedBridge(process.argv.slice(1), path.join(logsDir, 'restart-child.log'), {
29
61
  env: { ...process.env, [RESTART_WAIT_PID_ENV]: String(process.pid) },
30
- detached: true,
31
- // win32:detached 继任者默认会闪控制台窗口(§3.7)
32
- windowsHide: true,
33
- stdio: ['ignore', out, out],
34
62
  });
35
- // Late spawn errors (e.g. binary removed mid-run) must not crash the
36
- // exiting parent via an unhandled 'error' event. Attach BEFORE the pid
37
- // check — a failed spawn emits 'error' asynchronously (ENOENT/EACCES),
38
- // and the pid check below throws synchronously first, so attaching after
39
- // the throw would leave the 'error' event unhandled → uncaughtException
40
- // → the old bridge exits despite having promised to stay alive.
41
- child.on('error', () => { });
42
- fs.closeSync(out);
43
- if (child.pid === undefined) {
63
+ if (pid === null) {
44
64
  throw new Error('spawn replacement bridge failed: no pid');
45
65
  }
46
- child.unref();
47
- return child.pid;
66
+ return pid;
48
67
  }
49
68
  /**
50
69
  * On startup, if this process was spawned as a restart replacement, wait for
@@ -2,6 +2,7 @@ import type { SessionStore, SessionReaderRegistry } from '../session/index.js';
2
2
  import type { AppConfig } from '../config/index.js';
3
3
  import type { Bridge } from '../bridge/index.js';
4
4
  import type { AgentBinding } from '../bridge/queue-manager.js';
5
+ import type { CloneSession } from '../clone.js';
5
6
  import { type VersionCheckResult, type InstallResult } from '../update/index.js';
6
7
  interface CommandContext {
7
8
  userId: string;
@@ -125,6 +126,8 @@ export declare class CommandRouter {
125
126
  private devMode;
126
127
  /** Cache file for version checks (<configDir>/update-cache.json). */
127
128
  private updateCachePath?;
129
+ /** 复制分身状态机(/clone 入口 + 活跃期消息拦截)。 */
130
+ private cloneSession?;
128
131
  /** Injected update functions (for testability; defaults to real implementation). */
129
132
  private updateFns;
130
133
  /** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
@@ -177,6 +180,8 @@ export declare class CommandRouter {
177
180
  devMode?: boolean;
178
181
  /** Version check cache path (<configDir>/update-cache.json). */
179
182
  updateCachePath?: string;
183
+ /** 复制分身状态机(/clone;index.ts 构造注入,未注入时命令回报不可用)。 */
184
+ cloneSession?: CloneSession;
180
185
  /** Override update functions for testability. */
181
186
  updateFns?: {
182
187
  checkLatestVersion?: (opts?: {
@@ -455,6 +460,11 @@ export declare class CommandRouter {
455
460
  * Flow: check version → if newer, install → report success (no auto-restart).
456
461
  */
457
462
  private cmdUpdate;
463
+ /**
464
+ * /clone [name] — 复制分身:进入 clone 状态机(发二维码、等扫码、自动绑定)。
465
+ * 全程由 CloneSession 自己经 connector 发消息(含二维码图片),故返回 null。
466
+ */
467
+ private cmdClone;
458
468
  private cmdNew;
459
469
  private cmdCd;
460
470
  private readSessionDisplayState;
@@ -548,7 +558,6 @@ export declare class CommandRouter {
548
558
  * Paginates with ACTIVE_PAGE_SIZE (default 20) items per page.
549
559
  */
550
560
  private buildActiveCardFromMemory;
551
- private cmdReconnect;
552
561
  private cmdConfig;
553
562
  /**
554
563
  * DSH /config 卡片构建前异步预取模型/预设目录(§4.2 动态清单)。