lark-remote 0.2.2 → 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.
@@ -378,11 +378,10 @@ export class FeishuConnector {
378
378
  }
379
379
  }
380
380
  /**
381
- * Upload a file to Feishu and send it to the specified chat.
382
- * Uses the im/v1/files upload API.
381
+ * sendFile/sendImage 共用:校验文件存在与大小上限(30MB,对齐飞书
382
+ * im/v1 上传 API)。noun 是消息里的小写名词('file'/'image')。
383
383
  */
384
- async sendFile(chatId, filePath) {
385
- // Check file existence first with proper error handling
384
+ checkUploadable(filePath, noun) {
386
385
  let stat;
387
386
  try {
388
387
  stat = fs.statSync(filePath);
@@ -390,33 +389,32 @@ export class FeishuConnector {
390
389
  catch (err) {
391
390
  const code = err.code;
392
391
  if (code === 'ENOENT') {
393
- 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 });
394
393
  }
395
- throw new Error(`Cannot access file: ${err.message}`, { cause: err });
394
+ throw new Error(`Cannot access ${noun}: ${err.message}`, { cause: err });
396
395
  }
397
- // Check file size limit (30MB, aligned with Feishu im/v1/files API)
398
396
  if (stat.size > MAX_FILE_UPLOAD_SIZE) {
399
- 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`);
400
399
  }
401
- const fileName = displayName(filePath);
402
- // P2-18: capture the read stream so it can be destroyed on any failure
403
- // path. Without this, a token/upload/send failure leaks the fd (axios
404
- // 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) {
405
409
  let fileStream = null;
406
410
  try {
407
- // P2-18: tenant_access_token cache. Avoid re-fetching on every file send
408
- // (token is valid ~2h); validate data.code before trusting the token.
411
+ // P2-18: tenant_access_token cache(~2h),避免每次发送都取 token。
409
412
  const accessToken = await this.getTenantAccessToken();
410
- // Upload file
411
- const form = new FormData();
412
413
  fileStream = fs.createReadStream(filePath);
413
- form.append('file', fileStream);
414
- form.append('file_name', fileName);
415
- form.append('file_type', 'stream');
416
- // P2-18: timeout on the upload (large file, slow link) — 120s.
417
- // httpsAgent: noKeepAlive — prevents Bun from reusing a server-RST'd
418
- // keep-alive socket (ECONNRESET after ~30s; see noKeepAliveAgent docs).
419
- 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, {
420
418
  headers: {
421
419
  ...form.getHeaders(),
422
420
  Authorization: `Bearer ${accessToken}`,
@@ -425,15 +423,14 @@ export class FeishuConnector {
425
423
  httpsAgent: noKeepAliveAgent,
426
424
  });
427
425
  if (uploadResp.data.code !== 0) {
428
- throw new Error(`File upload failed: ${uploadResp.data.msg}`);
426
+ throw new Error(`Upload failed: ${uploadResp.data.msg}`);
429
427
  }
430
- const fileKey = uploadResp.data.data.file_key;
431
- // Send file message. P2-18: timeout 30s.
432
- // httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
428
+ const mediaKey = uploadResp.data.data[keyField];
429
+ // P2-18: timeout 30s;noKeepAlive 同上。
433
430
  const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
434
431
  receive_id: chatId,
435
- msg_type: 'file',
436
- content: JSON.stringify({ file_key: fileKey }),
432
+ msg_type: msgType,
433
+ content: JSON.stringify({ [keyField]: mediaKey }),
437
434
  }, {
438
435
  headers: {
439
436
  'Content-Type': 'application/json; charset=utf-8',
@@ -444,7 +441,7 @@ export class FeishuConnector {
444
441
  httpsAgent: noKeepAliveAgent,
445
442
  });
446
443
  if (sendResp.data.code !== 0) {
447
- throw new Error(`File message failed: ${sendResp.data.msg}`);
444
+ throw new Error(`Media message failed: ${sendResp.data.msg}`);
448
445
  }
449
446
  return sendResp.data.data.message_id;
450
447
  }
@@ -458,12 +455,39 @@ export class FeishuConnector {
458
455
  /* already destroyed */
459
456
  }
460
457
  }
461
- // Log the error but throw a formatted error to prevent unhandled rejection
462
458
  const errorInfo = this.formatError(err);
463
- getLogger().error('[feishu] sendFile failed:', errorInfo);
464
- throw new Error(`sendFile failed: ${errorInfo}`, { cause: err });
459
+ getLogger().error(`[feishu] ${label} failed:`, errorInfo);
460
+ throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
465
461
  }
466
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
+ }
467
491
  /**
468
492
  * Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
469
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
  }
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;
@@ -347,6 +347,8 @@ export class CommandRouter {
347
347
  devMode;
348
348
  /** Cache file for version checks (<configDir>/update-cache.json). */
349
349
  updateCachePath;
350
+ /** 复制分身状态机(/clone 入口 + 活跃期消息拦截)。 */
351
+ cloneSession;
350
352
  /** Injected update functions (for testability; defaults to real implementation). */
351
353
  updateFns;
352
354
  /** /config 卡片编辑暂存区(public:测试直接读取断言,替代 as unknown as)。 */
@@ -400,6 +402,7 @@ export class CommandRouter {
400
402
  this.sessionReaderRegistry = opts.sessionReaderRegistry;
401
403
  this.devMode = opts.devMode ?? false;
402
404
  this.updateCachePath = opts.updateCachePath;
405
+ this.cloneSession = opts.cloneSession;
403
406
  this.updateFns = {
404
407
  checkLatestVersion: opts.updateFns?.checkLatestVersion ?? defaultCheckLatestVersion,
405
408
  isNewer: opts.updateFns?.isNewer ?? defaultIsNewer,
@@ -421,6 +424,13 @@ export class CommandRouter {
421
424
  const startsWithBang = trimmed.startsWith('!');
422
425
  const startsWithSlash = trimmed.startsWith('/');
423
426
  getLogger().info(`[router] handle message="${message.slice(0, 50)}..." trimmed="${trimmed.slice(0, 50)}..." startsWithSlash=${startsWithSlash} startsWithBang=${startsWithBang}`);
427
+ // 复制分身流程活跃期:一切消息(含命令)交给 clone 状态机处理,
428
+ // 不转发 coding agent、不进命令分发(index.ts 已在入队前拦截,这里
429
+ // 兜住 order.exec 等其他 router.handle 入口)。
430
+ if (this.cloneSession?.isActive()) {
431
+ await this.cloneSession.handleMessage(trimmed, ctx);
432
+ return null;
433
+ }
424
434
  if (startsWithSlash) {
425
435
  const result = await this.executeCommand(trimmed, ctx);
426
436
  if (result) {
@@ -2001,6 +2011,8 @@ export class CommandRouter {
2001
2011
  case 'update':
2002
2012
  case 'u':
2003
2013
  return await this.cmdUpdate(args, ctx);
2014
+ case 'clone':
2015
+ return await this.cmdClone(args, ctx);
2004
2016
  default:
2005
2017
  return { text: `未知命令 /${cmd},输入 /help 查看可用命令` };
2006
2018
  }
@@ -2021,6 +2033,11 @@ export class CommandRouter {
2021
2033
  { cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
2022
2034
  { cmd: 'restart', label: '/restart', desc: '重启 bridge(新进程,config 不变)' },
2023
2035
  { cmd: 'update', label: '/update /u', desc: '检查并升级到最新版本' },
2036
+ {
2037
+ cmd: 'clone',
2038
+ label: '/clone',
2039
+ desc: '复制分身:克隆配置并扫码创建新应用',
2040
+ },
2024
2041
  { cmd: 'status', label: '/status /s', desc: '显示当前状态' },
2025
2042
  ].sort((a, b) => a.label.length - b.label.length || a.cmd.localeCompare(b.cmd));
2026
2043
  // 文本组(需带参数,不宜用按钮;以纯文本行展示)
@@ -2271,6 +2288,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2271
2288
  // 4. Report success (no auto-restart — user decides when to /restart)
2272
2289
  return { text: `✅ 升级成功 (${current} → ${latest}),发送 /restart 重启后生效` };
2273
2290
  }
2291
+ /**
2292
+ * /clone [name] — 复制分身:进入 clone 状态机(发二维码、等扫码、自动绑定)。
2293
+ * 全程由 CloneSession 自己经 connector 发消息(含二维码图片),故返回 null。
2294
+ */
2295
+ async cmdClone(args, ctx) {
2296
+ if (!this.cloneSession) {
2297
+ return { text: '当前环境未启用创建分身功能' };
2298
+ }
2299
+ await this.cloneSession.start(args[0], ctx);
2300
+ return null;
2301
+ }
2274
2302
  cmdNew(ctx) {
2275
2303
  // 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
2276
2304
  // 设置工作目录",但用户的 workspace 还在。2026-06-21 复盘:用户
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -58,7 +58,7 @@
58
58
  "test:opencode-full": "vitest run --project agent-opencode --project infra-runner --project infra-session",
59
59
  "test:pi-full": "vitest run --project agent-pi --project infra-runner --project infra-session",
60
60
  "test:kimi-full": "vitest run --project agent-kimi --project infra-runner --project infra-session",
61
- "test:live": "vitest run --project live",
61
+ "test:live": "node node_modules/vitest/vitest.mjs run --project live",
62
62
  "prepublishOnly": "npm run typecheck && vitest run && npm run build",
63
63
  "prepack": "node -e \"const fs=require('fs');if(!fs.existsSync('dist/cli.js')){console.error('dist/ missing — run bun run build first');process.exit(1)}\""
64
64
  },