lark-remote 0.2.2 → 0.4.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.en.md +8 -5
  3. package/README.md +10 -6
  4. package/dist/binder.d.ts +9 -16
  5. package/dist/binder.js +14 -40
  6. package/dist/bridge/final-usage.js +2 -2
  7. package/dist/bridge/inbound-media.js +7 -5
  8. package/dist/bridge/index.js +75 -75
  9. package/dist/clone.d.ts +139 -0
  10. package/dist/clone.js +415 -0
  11. package/dist/common/fs.js +1 -1
  12. package/dist/config/codex-config.js +79 -24
  13. package/dist/config/index.js +1 -1
  14. package/dist/config/kimi-config.js +16 -5
  15. package/dist/connector/index.d.ts +25 -0
  16. package/dist/connector/index.js +133 -50
  17. package/dist/error-classification.d.ts +7 -0
  18. package/dist/error-classification.js +37 -1
  19. package/dist/index.js +48 -15
  20. package/dist/platform/command.js +1 -1
  21. package/dist/platform/sleep-blocker.d.ts +17 -0
  22. package/dist/platform/sleep-blocker.js +73 -0
  23. package/dist/platform/terminator-win32.js +1 -1
  24. package/dist/restart.d.ts +20 -3
  25. package/dist/restart.js +42 -23
  26. package/dist/router/config/kimi.js +4 -3
  27. package/dist/router/index.d.ts +10 -0
  28. package/dist/router/index.js +44 -6
  29. package/dist/runner/common/acp/base-acp-runner.js +7 -2
  30. package/dist/runner/common/acp/base-translator.d.ts +19 -0
  31. package/dist/runner/common/acp/base-translator.js +82 -0
  32. package/dist/runner/common/acp/protocol-types.d.ts +16 -2
  33. package/dist/runner/common/acp/protocol-types.js +3 -0
  34. package/dist/runner/kimi/acp/translator.js +60 -4
  35. package/dist/session/session-store.d.ts +1 -1
  36. package/dist/session/session-store.js +1 -1
  37. package/dist/workspace/index.d.ts +1 -1
  38. package/dist/workspace/index.js +1 -1
  39. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -15,9 +15,12 @@ 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';
22
+ import { currentPlatform } from './platform/select.js';
23
+ import { startSleepBlocker } from './platform/sleep-blocker.js';
21
24
  import { checkLatestVersion, isNewer, runInstallLatest, formatUpdateHint } from './update/index.js';
22
25
  import { classifyRejection } from './error-classification.js';
23
26
  import { WorkspaceStore } from './workspace/index.js';
@@ -26,6 +29,14 @@ import { newSessionButton, resumeCompactButton, agentDisplayName } from './card/
26
29
  import path from 'node:path';
27
30
  import fs from 'node:fs';
28
31
  import { silentlyUnlink } from './common/fs.js';
32
+ /**
33
+ * /stop 命令别名单源:普通消息的停止分支与 clone 活跃期拦截的排除条件
34
+ * 共用同一判定,防止将来加别名时两处漂移。
35
+ */
36
+ function isStopCommand(text) {
37
+ const t = text.trim().toLowerCase();
38
+ return t === '/stop' || t === '/t';
39
+ }
29
40
  /** Send auto-resume card for a restored session */
30
41
  async function sendAutoResumeCard(connector, contact, cwd, session, state, compactSupported = false) {
31
42
  if (!contact.userId)
@@ -280,7 +291,7 @@ function initializeRunner(config, configDir, cliArgs) {
280
291
  return { agentRegistry, sessionReaderRegistry };
281
292
  }
282
293
  /** Setup message and card action handlers. */
283
- function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config) {
294
+ function setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession) {
284
295
  // 入站媒体(图片/文件):先认证后下载(P1 review 修复)。
285
296
  // connector 只上报"媒体到达",这里先过 owner + enabled 闸门,通过后才
286
297
  // 下载——未认证/关闭配置时不会发生任何网络下载或内存/磁盘占用。
@@ -324,13 +335,10 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
324
335
  })().catch((err) => logger.error('[media] inbound media download/save failed:', err));
325
336
  });
326
337
  connector.setMessageHandler((msg) => {
327
- // 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时要求 PIN 认领
338
+ // 绑定/授权闸门:仅 owner 放行;非 owner 静默丢弃;未绑定时首条消息(任意内容)认领
328
339
  const decision = binder.classify(msg.userId, msg.content, msg.chatId);
329
340
  if (decision.kind === 'rejected')
330
341
  return;
331
- // 未绑定且 PIN 错误:完全静默丢弃(不回复、不提醒)
332
- if (decision.kind === 'pin_wrong')
333
- return;
334
342
  if (decision.kind === 'bind_success') {
335
343
  // First-run onboarding: set default cwd + send welcome + Help card.
336
344
  // setCwd is synchronous (outside the async closure) so that the user's
@@ -385,12 +393,23 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
385
393
  bridge.flushMediaNotifications(msg.userId, msg.chatId);
386
394
  // Add Typing reaction to indicate "still alive"
387
395
  void connector.addReaction(msg.messageId, 'Typing');
396
+ // 复制分身流程活跃期:一切消息先经 clone 状态机(stop 命令除外,保留其
397
+ // 绕队列停止能力),不转发 coding agent、不进命令分发、不展开别名。
398
+ if (cloneSession.isActive() && !isStopCommand(msg.content)) {
399
+ void cloneSession
400
+ .handleMessage(msg.content, {
401
+ userId: msg.userId,
402
+ chatId: msg.chatId,
403
+ messageId: msg.messageId,
404
+ })
405
+ .catch((err) => logger.error('[clone] message handling failed:', err));
406
+ return;
407
+ }
388
408
  // 别名展开:命令分发前对消息做一次 $name 展开(不递归)。
389
409
  // `!` / `/` 开头的消息不会进入展开路径($PATH、$HOME 等 shell 变量不受影响),
390
410
  // 未知 $xxx 原样透传;展开结果若以 `/` 开头会自然落入命令路径。
391
411
  const content = router.expandAliasMessage(msg.content);
392
- const trimmedLower = content.trim().toLowerCase();
393
- if (trimmedLower === '/stop' || trimmedLower === '/t') {
412
+ if (isStopCommand(content)) {
394
413
  void (async () => {
395
414
  const stopped = await bridge.interruptCurrentRun({
396
415
  userId: msg.userId,
@@ -605,9 +624,17 @@ async function main() {
605
624
  }
606
625
  const { config, configDir, logger, instanceLock } = await initializeCliAndConfig();
607
626
  // Restart handoff: if spawned as a /restart replacement, wait for the old
608
- // bridge to die (and release the instance lock) before acquiring it.
627
+ // lark-remote to die (and release the instance lock) before acquiring it.
609
628
  await waitForPreviousInstance();
610
629
  setupInstanceLockAndHandlers(instanceLock, logger, configDir);
630
+ // 阻止系统休眠(macOS caffeinate / Windows SetThreadExecutionState):lark-remote
631
+ // 的场景是人不在电脑前远程使用,系统休眠即失联。恒开启、无配置开关;helper 进程
632
+ // 绑定本进程生命周期(caffeinate -w / powershell 轮询父 pid),崩溃也能自清理;
633
+ // 'exit' 钩子里的 stop() 仅覆盖优雅退出路径。
634
+ const sleepBlocker = startSleepBlocker({ platform: currentPlatform, pid: process.pid });
635
+ if (sleepBlocker) {
636
+ process.on('exit', () => sleepBlocker.stop());
637
+ }
611
638
  logger.info('config loaded');
612
639
  logger.info(`configDir = ${configDir}`);
613
640
  logger.info(`feishu.appId = ${config.feishu.appId}`);
@@ -619,10 +646,10 @@ async function main() {
619
646
  const connector = new FeishuConnector(config);
620
647
  const startupContactStore = new StartupContactStore(path.join(configDir, 'startup-contact.json'));
621
648
  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)');
649
+ if (!binder.isBound()) {
650
+ // 未绑定:控制台(stderr)引导任意消息绑定。守护模式下被 watchdog 重定向到日志
651
+ console.error(formatBindGuidance());
652
+ logger.info('[binder] awaiting first binding (any first private message binds)');
626
653
  }
627
654
  else {
628
655
  logger.info(`[binder] bound to owner openId=${binder.boundOpenId()}`);
@@ -637,6 +664,11 @@ async function main() {
637
664
  agentRegistry,
638
665
  sessionReaderRegistry,
639
666
  });
667
+ const cloneSession = new CloneSession({
668
+ connector,
669
+ configPath: path.join(configDir, 'config.yaml'),
670
+ configDir,
671
+ });
640
672
  const router = new CommandRouter({
641
673
  sessionStore,
642
674
  bridge,
@@ -648,8 +680,9 @@ async function main() {
648
680
  sessionReaderRegistry,
649
681
  devMode: cliArgs.dev,
650
682
  updateCachePath: path.join(configDir, 'update-cache.json'),
683
+ cloneSession,
651
684
  });
652
- setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config);
685
+ setupMessageHandlers(connector, router, bridge, sessionStore, workspaceStore, binder, logger, config, cloneSession);
653
686
  try {
654
687
  await connector.connect();
655
688
  }
@@ -718,7 +751,7 @@ async function main() {
718
751
  }
719
752
  }
720
753
  }
721
- logger.info('bridge is running, press Ctrl+C to exit');
754
+ logger.info('lark-remote is running, press Ctrl+C to exit');
722
755
  }
723
756
  main().catch((err) => {
724
757
  getLogger().error('fatal error:', err);
@@ -46,7 +46,7 @@ function isBashQuery(name) {
46
46
  return lower === 'bash' || lower === 'bash.exe';
47
47
  }
48
48
  /**
49
- * 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——bridge 运行期间
49
+ * 缓存 TTL:probe 层(runner/probe.ts)5 分钟,本层更短——lark-remote 运行期间
50
50
  * 用户新装/卸载 agent(典型场景:按 /config 不可用提示去安装)后,最迟
51
51
  * TTL 过期即可被重新探测到,不必重启。null(未找到)结果同样过期重查。
52
52
  */
@@ -0,0 +1,17 @@
1
+ import { spawnProcess } from './spawn.js';
2
+ export interface SleepBlocker {
3
+ /** 防御性停止 helper(正常路径 helper 随 lark-remote 死亡自行退出)。 */
4
+ stop(): void;
5
+ }
6
+ export interface SleepBlockerOptions {
7
+ platform: NodeJS.Platform;
8
+ pid: number;
9
+ /** 测试注入点:默认走 platform/spawn.ts 的 cross-spawn 收口。 */
10
+ spawnFn?: typeof spawnProcess;
11
+ }
12
+ /**
13
+ * PowerShell 托管脚本:声明 P/Invoke → 设置持续唤醒 → 轮询父 pid 直至父进程消失。
14
+ * 单字符串经 `-Command` 传入(不经 shell,内嵌双引号安全);父 pid 是 number,插值安全。
15
+ */
16
+ export declare function win32SleepBlockerScript(parentPid: number): string;
17
+ export declare function startSleepBlocker(opts: SleepBlockerOptions): SleepBlocker | null;
@@ -0,0 +1,73 @@
1
+ import { spawnProcess } from './spawn.js';
2
+ import { getLogger } from '../logger/index.js';
3
+ /** ES_CONTINUOUS (0x80000000) | ES_SYSTEM_REQUIRED (0x00000001)。 */
4
+ const ES_FLAGS = '0x80000001';
5
+ /**
6
+ * PowerShell 托管脚本:声明 P/Invoke → 设置持续唤醒 → 轮询父 pid 直至父进程消失。
7
+ * 单字符串经 `-Command` 传入(不经 shell,内嵌双引号安全);父 pid 是 number,插值安全。
8
+ */
9
+ export function win32SleepBlockerScript(parentPid) {
10
+ return [
11
+ `Add-Type -TypeDefinition 'using System.Runtime.InteropServices; public static class SleepBlocker { [DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags); }'`,
12
+ `[SleepBlocker]::SetThreadExecutionState(${ES_FLAGS}) | Out-Null`,
13
+ `while (Get-Process -Id ${parentPid} -ErrorAction SilentlyContinue) { Start-Sleep -Seconds 30 }`,
14
+ ].join('; ');
15
+ }
16
+ export function startSleepBlocker(opts) {
17
+ const { platform, pid } = opts;
18
+ const spawnFn = opts.spawnFn ?? spawnProcess;
19
+ const logger = getLogger();
20
+ let command;
21
+ let args;
22
+ if (platform === 'darwin') {
23
+ command = 'caffeinate';
24
+ args = ['-i', '-w', String(pid)];
25
+ }
26
+ else if (platform === 'win32') {
27
+ command = 'powershell.exe';
28
+ args = ['-NoProfile', '-NonInteractive', '-Command', win32SleepBlockerScript(pid)];
29
+ }
30
+ else {
31
+ return null;
32
+ }
33
+ let proc;
34
+ try {
35
+ proc = spawnFn(command, args, { stdio: 'ignore' });
36
+ }
37
+ catch (err) {
38
+ logger.warn(`[sleep-blocker] failed to spawn ${command}:`, err);
39
+ return null;
40
+ }
41
+ // spawn 二进制缺失早检 + error 事件兜底(同 runner 层纪律)
42
+ if (proc.pid === undefined) {
43
+ logger.warn(`[sleep-blocker] ${command} unavailable (spawn failed), sleep prevention off`);
44
+ return null;
45
+ }
46
+ proc.on('error', (err) => {
47
+ logger.warn(`[sleep-blocker] ${command} error:`, err);
48
+ });
49
+ // helper 意外死亡 = 防休眠静默失效(win32 上 Add-Type 动态编译被企业策略/
50
+ // 杀软拦掉、Get-Process 权限失败;darwin 上 caffeinate 早退)。桥此时还活着,
51
+ // 是唯一能留痕的时机——否则日志里只剩启动那句 "active",误以为防休眠在生效。
52
+ let stopping = false;
53
+ proc.on('exit', (code, signal) => {
54
+ if (stopping)
55
+ return;
56
+ logger.warn(`[sleep-blocker] ${command} exited unexpectedly (code=${code}, signal=${signal}), sleep prevention off`);
57
+ });
58
+ // helper 不得阻止 lark-remote 自身退出;父进程死后 helper 走各自的自清理机制退出
59
+ proc.unref();
60
+ logger.info(`[sleep-blocker] active via ${command} pid=${proc.pid}`);
61
+ return {
62
+ stop() {
63
+ stopping = true;
64
+ try {
65
+ proc.kill();
66
+ }
67
+ catch {
68
+ // helper 已死或不可杀——正常路径下它会自行退出,忽略
69
+ }
70
+ },
71
+ };
72
+ }
73
+ //# sourceMappingURL=sleep-blocker.js.map
@@ -100,7 +100,7 @@ export function createWin32Terminator(deps) {
100
100
  // M0 真机验证项(§9.2 矩阵):本方法在 process.on('exit') 里调用,而
101
101
  // killTree 依赖 libuv uv_spawn 在 exit handler 内同步发起 taskkill——
102
102
  // 大概率可用(uv_spawn 本身同步),但与 posix 路径(同步 process.kill)
103
- // 可靠性不等价;失败表现为 win32 上 bridge 退出后 agent 变孤儿。
103
+ // 可靠性不等价;失败表现为 win32 上 lark-remote 退出后 agent 变孤儿。
104
104
  if (!isAlive(proc))
105
105
  return;
106
106
  killTree(proc.pid);
package/dist/restart.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Self-restart without an external watchdog/cron: the only process that can
3
- * reliably start the successor is the dying bridge itself. The old process
3
+ * reliably start the successor is the dying lark-remote itself. The old process
4
4
  * spawns a detached replacement (same executable + argv, hence same
5
5
  * --config-dir), then exits cleanly. The child learns the old pid via env
6
6
  * and waits for it to die before acquiring the instance lock, so the lock
@@ -9,7 +9,24 @@
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
11
  /**
12
- * Spawn a detached replacement bridge with the same executable and argv
12
+ * Spawn a detached lark-remote 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;
28
+ /**
29
+ * Spawn a detached replacement lark-remote with the same executable and argv
13
30
  * (including --config-dir) and return its pid. The caller is expected to
14
31
  * exit right after. stdio is redirected to restart-child.log so early
15
32
  * startup failures (before the file logger initializes) are not lost.
@@ -17,7 +34,7 @@ export declare const RESTART_WAIT_PID_ENV = "LARK_REMOTE_RESTART_WAIT_PID";
17
34
  export declare function spawnReplacementBridge(logsDir: string): number;
18
35
  /**
19
36
  * On startup, if this process was spawned as a restart replacement, wait for
20
- * the previous bridge process to exit (and release the instance lock) before
37
+ * the previous lark-remote process to exit (and release the instance lock) before
21
38
  * continuing. No-op for normal starts. On timeout, proceed anyway — lock
22
39
  * acquisition remains the authority.
23
40
  */
package/dist/restart.js CHANGED
@@ -5,7 +5,7 @@ import { getLogger } from './logger/index.js';
5
5
  import { sleep } from './common/sleep.js';
6
6
  /**
7
7
  * Self-restart without an external watchdog/cron: the only process that can
8
- * reliably start the successor is the dying bridge itself. The old process
8
+ * reliably start the successor is the dying lark-remote itself. The old process
9
9
  * spawns a detached replacement (same executable + argv, hence same
10
10
  * --config-dir), then exits cleanly. The child learns the old pid via env
11
11
  * and waits for it to die before acquiring the instance lock, so the lock
@@ -16,39 +16,58 @@ 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
18
  /**
19
- * Spawn a detached replacement bridge with the same executable and argv
19
+ * Spawn a detached lark-remote 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
+ }
53
+ /**
54
+ * Spawn a detached replacement lark-remote with the same executable and argv
20
55
  * (including --config-dir) and return its pid. The caller is expected to
21
56
  * exit right after. stdio is redirected to restart-child.log so early
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) {
44
- throw new Error('spawn replacement bridge failed: no pid');
63
+ if (pid === null) {
64
+ throw new Error('spawn replacement lark-remote 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
51
- * the previous bridge process to exit (and release the instance lock) before
70
+ * the previous lark-remote process to exit (and release the instance lock) before
52
71
  * continuing. No-op for normal starts. On timeout, proceed anyway — lock
53
72
  * acquisition remains the authority.
54
73
  */
@@ -7,9 +7,10 @@
7
7
  import { loadKimiConfig, KIMI_THINKING_EFFORTS, FALLBACK_EFFORTS, } from '../../config/kimi-config.js';
8
8
  import { DEFAULT_TURN_IDLE_TIMEOUT_MINUTES } from '../../config/index.js';
9
9
  const PERMISSION_MODE_OPTIONS = [
10
- { text: 'manual(逐项审批)', value: 'manual' },
11
- { text: 'auto(引擎裁决)', value: 'auto' },
12
- { text: 'yolo(全部放行)', value: 'yolo' },
10
+ // kimi 0.40.0(2026-09-02)起官方语义:manual=始终询问 / yolo=必要时询问 / auto=完全自动
11
+ { text: 'manual(始终询问)', value: 'manual' },
12
+ { text: 'auto(完全自动)', value: 'auto' },
13
+ { text: 'yolo(必要时询问)', value: 'yolo' },
13
14
  ];
14
15
  export class KimiConfigBuilder {
15
16
  buildFields(displayConfig) {
@@ -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
  }
@@ -2018,9 +2030,14 @@ export class CommandRouter {
2018
2030
  { cmd: 'ws', label: '/ws', desc: 'workspace 管理(save|use|remove)' },
2019
2031
  { cmd: 'config', label: '/config /c', desc: '查看和修改配置(卡片交互)' },
2020
2032
  { cmd: 'active', label: '/active', desc: '查看所有正在进行中的 session' },
2021
- { cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
2022
- { cmd: 'restart', label: '/restart', desc: '重启 bridge(新进程,config 不变)' },
2033
+ { cmd: 'exit', label: '/exit /e', desc: '退出 lark-remote' },
2034
+ { cmd: 'restart', label: '/restart', desc: '重启 lark-remote(新进程,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
  // 文本组(需带参数,不宜用按钮;以纯文本行展示)
@@ -2123,6 +2140,16 @@ export class CommandRouter {
2123
2140
  content: '**Agent**\n通过 `/config` → 默认 Agent 切换:Claude / Codex / Opencode / Pi / Kimi',
2124
2141
  },
2125
2142
  });
2143
+ // 系统休眠说明
2144
+ bodyElements.push({ tag: 'hr' });
2145
+ bodyElements.push({
2146
+ tag: 'div',
2147
+ text: {
2148
+ tag: 'lark_md',
2149
+ content: '**系统休眠**\nlark-remote 运行期间会阻止系统自动休眠,以免远程连接不上,' +
2150
+ '不影响显示器睡眠',
2151
+ },
2152
+ });
2126
2153
  return {
2127
2154
  card: {
2128
2155
  schema: '2.0',
@@ -2211,7 +2238,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2211
2238
  async cmdExit() {
2212
2239
  // Signal handle() to invoke exitHandler after the reply is sent.
2213
2240
  this.pendingExit = true;
2214
- return { text: 'bridge 正在退出...' };
2241
+ return { text: 'lark-remote 正在退出...' };
2215
2242
  }
2216
2243
  cmdRestart() {
2217
2244
  if (!this.restartSpawner) {
@@ -2223,7 +2250,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2223
2250
  // 链路——spawn 失败则旧进程保持存活,不会两头落空。
2224
2251
  const pid = this.restartSpawner();
2225
2252
  this.pendingExit = true;
2226
- return { text: `♻️ bridge 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
2253
+ return { text: `♻️ lark-remote 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
2227
2254
  }
2228
2255
  catch (err) {
2229
2256
  getLogger().error('[router] restart spawn failed:', err);
@@ -2271,6 +2298,17 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2271
2298
  // 4. Report success (no auto-restart — user decides when to /restart)
2272
2299
  return { text: `✅ 升级成功 (${current} → ${latest}),发送 /restart 重启后生效` };
2273
2300
  }
2301
+ /**
2302
+ * /clone [name] — 复制分身:进入 clone 状态机(发二维码、等扫码、自动绑定)。
2303
+ * 全程由 CloneSession 自己经 connector 发消息(含二维码图片),故返回 null。
2304
+ */
2305
+ async cmdClone(args, ctx) {
2306
+ if (!this.cloneSession) {
2307
+ return { text: '当前环境未启用创建分身功能' };
2308
+ }
2309
+ await this.cloneSession.start(args[0], ctx);
2310
+ return null;
2311
+ }
2274
2312
  cmdNew(ctx) {
2275
2313
  // 只清 sessionId 保留 cwd — 否则 /new 之后 /resume 会提示"请先 /cd
2276
2314
  // 设置工作目录",但用户的 workspace 还在。2026-06-21 复盘:用户
@@ -3385,8 +3423,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3385
3423
  return { card };
3386
3424
  }
3387
3425
  cmdActive(_args, _ctx, offset = 0) {
3388
- // List all active runs from bridge memory (not from file system scan)
3389
- // New semantics (2026-07-20): only shows runs started by THIS bridge process
3426
+ // List all active runs from lark-remote memory (not from file system scan)
3427
+ // New semantics (2026-07-20): only shows runs started by THIS lark-remote process
3390
3428
  const activeRuns = this.bridge.getActiveRuns();
3391
3429
  const activeBashRuns = this.bridge.getActiveBashRuns();
3392
3430
  if (activeRuns.length === 0 && activeBashRuns.length === 0) {
@@ -1,4 +1,4 @@
1
- import { respondAcpApproval, sendAcpSetMode } from './protocol-helpers.js';
1
+ import { respondAcpApproval, sendAcpSetMode, truncateWithEllipsis, } from './protocol-helpers.js';
2
2
  import { getLogger } from '../../../logger/index.js';
3
3
  import { ConnectionBasedRunner } from '../connection-based-runner.js';
4
4
  export class BaseAcpRunner extends ConnectionBasedRunner {
@@ -87,7 +87,12 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
87
87
  options,
88
88
  ...(proto ? { proto } : {}),
89
89
  });
90
- getLogger().info(`[${this.logTag}] approval requested requestId=${ev.requestId} kind=${ev.kind}`);
90
+ // 命令预览进日志:审批卡在飞书侧,日志是本地唯一的事后追溯通道
91
+ // (kimi 的 request_permission 不带 rawInput,曾只剩 kind=command 可查)。
92
+ const commandPreview = ev.view.command
93
+ ? ` command=${truncateWithEllipsis(ev.view.command, 120)}`
94
+ : '';
95
+ getLogger().info(`[${this.logTag}] approval requested requestId=${ev.requestId} kind=${ev.kind}${commandPreview}`);
91
96
  }
92
97
  }
93
98
  }
@@ -59,6 +59,16 @@ export declare abstract class BaseAcpTranslator {
59
59
  protected liveCostUsd?: number;
60
60
  /** Current turn id, set by produceTurnStarted. */
61
61
  protected currentTurnId: string;
62
+ /**
63
+ * Latest streaming-args text per tool call (keyed by wire toolCallId).
64
+ * kimi ACP sends the permission request BEFORE tool.call.started (verified
65
+ * live against kimi 0.42.0, 2026-09-13): the request_permission toolCall
66
+ * carries only title + a truncated summary in content, NO rawInput — the
67
+ * full args only ever reach the client as accumulated text in the
68
+ * tool_call/tool_call_update streaming deltas. Tracking them here lets the
69
+ * approval handler correlate by toolCallId and recover the real command.
70
+ */
71
+ private toolCallArgsByCallId;
62
72
  /**
63
73
  * Handle a notification from the ACP server and return translated events.
64
74
  * Wire envelope: {sessionId, update: {sessionUpdate: '<kind>', ...}} — the
@@ -115,6 +125,15 @@ export declare abstract class BaseAcpTranslator {
115
125
  * tool_call_update → user/tool_result. status:'failed' → is_error:true.
116
126
  */
117
127
  protected handleToolCallUpdate(event: ToolCallUpdateEvent, _sessionId: string): AcpTranslatorEvent[];
128
+ /**
129
+ * Latest tracked streaming-args text for a tool call (undefined when never
130
+ * seen or already terminated). Approval handlers correlate
131
+ * request_permission's toolCall.toolCallId against this to recover the real
132
+ * command (see toolCallArgsByCallId).
133
+ */
134
+ protected getTrackedToolCallArgs(toolCallId: string): string | undefined;
135
+ /** Record latest args text, bounded so a long session cannot grow it forever. */
136
+ private trackToolCallArgs;
118
137
  /**
119
138
  * tool_result content extraction. Default: rawOutput passthrough (kimi
120
139
  * semantics). opencode overrides — its rawOutput is an OBJECT