lark-remote 0.3.0 → 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.
@@ -3,6 +3,7 @@ import { getLogger } from '../logger/index.js';
3
3
  import { MAX_FILE_UPLOAD_SIZE } from './file-limits.js';
4
4
  import { DEFAULT_INBOUND_MEDIA_MAX_SIZE_MB } from '../config/index.js';
5
5
  import { sleep } from '../common/sleep.js';
6
+ import { isTransientTransportError } from '../error-classification.js';
6
7
  import axios from 'axios';
7
8
  import fs from 'node:fs';
8
9
  import { silentlyUnlink } from '../common/fs.js';
@@ -88,6 +89,72 @@ function shouldRetrySendError(err) {
88
89
  return true;
89
90
  return false;
90
91
  }
92
+ /**
93
+ * 卡片 patch 的重试上限。含义:1 次原始尝试 + 最多 PATCH_MAX_RETRIES 次重试。
94
+ */
95
+ export const PATCH_MAX_RETRIES = 3;
96
+ /** 重试退避基数(ms);第 n 次重试前等待 n × 基数,给对端恢复的时间。 */
97
+ export const PATCH_RETRY_BASE_DELAY_MS = 150;
98
+ /** 记日志用的错误摘要(不打印 axios 巨型循环对象)。 */
99
+ function formatErrorForLog(err) {
100
+ if (err instanceof Error) {
101
+ // For axios errors, extract useful info without circular refs
102
+ const axiosErr = err;
103
+ if (axiosErr.response?.status) {
104
+ return `${err.message} (HTTP ${axiosErr.response.status})`;
105
+ }
106
+ if (axiosErr.code) {
107
+ return `${err.message} (code: ${axiosErr.code})`;
108
+ }
109
+ return err.message;
110
+ }
111
+ return String(err);
112
+ }
113
+ /**
114
+ * 卡片 patch 是否值得重试:传输层瞬态失败 + 5xx。
115
+ * 4xx(业务拒绝/请求构造错误)不重试 —— 重试只会得到同样的结果。
116
+ */
117
+ function isRetryablePatchError(err) {
118
+ if (isTransientTransportError(err))
119
+ return true;
120
+ if (err == null || typeof err !== 'object')
121
+ return false;
122
+ const status = err.response?.status;
123
+ return typeof status === 'number' && status >= 500 && status < 600;
124
+ }
125
+ /**
126
+ * 带重试的 `im.v1.message.patch`。
127
+ *
128
+ * 为什么必须在这一层重试:流式卡片的 patch 由 @larksuite/channel 的
129
+ * CardStreamController 经 throttle 延迟触发 —— `controller.update()` 调完
130
+ * `throttle.note()` 就 resolve,不等真正的 patch。失败时是一条脱离 await 链的
131
+ * detached rejection,只能冒泡到 `process.unhandledRejection`,调用方的
132
+ * try/catch 根本挡不住。2026-09-15 00:09 的事故正是如此:卡片 patch 时
133
+ * socket 被关(AxiosError `ERR_SOCKET_CLOSED`),整进程退出,正在跑的 run 陪葬。
134
+ *
135
+ * 这里在唯一的底层 patch 出口上兜住:瞬态错误重试最多 PATCH_MAX_RETRIES 次,
136
+ * 全部失败才把最后一个错误抛出去(此时上层 classifyRejection 也会判为
137
+ * recoverable,不会击穿进程)。patch 是整卡替换,重试天然幂等。
138
+ */
139
+ async function patchWithRetry(patchFn, request, options) {
140
+ let lastError;
141
+ for (let attempt = 0; attempt <= PATCH_MAX_RETRIES; attempt += 1) {
142
+ try {
143
+ return await patchFn(request, options);
144
+ }
145
+ catch (err) {
146
+ lastError = err;
147
+ if (attempt === PATCH_MAX_RETRIES || !isRetryablePatchError(err))
148
+ throw err;
149
+ const delayMs = PATCH_RETRY_BASE_DELAY_MS * (attempt + 1);
150
+ getLogger().warn(`[feishu] message.patch transient failure ` +
151
+ `(attempt ${attempt + 1}/${PATCH_MAX_RETRIES + 1}), retrying in ${delayMs}ms: ` +
152
+ `${formatErrorForLog(err)}`);
153
+ await sleep(delayMs);
154
+ }
155
+ }
156
+ throw lastError;
157
+ }
91
158
  /**
92
159
  * Safely extract the im.v1.message.patch service from a LarkChannel.
93
160
  * Returns undefined when the channel mock omits rawClient (unit tests).
@@ -126,16 +193,19 @@ export class FeishuConnector {
126
193
  },
127
194
  },
128
195
  });
129
- // 观测探针(2026-08-11 run 卡定格事故):飞书业务码错误以 HTTP 200 + {code!=0}
130
- // 返回时,lark SDK 正常 resolve、@larksuite/channel 的 patchCard 丢弃返回值,
131
- // 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里只观测不改行为——
132
- // 不 throw、不重试,返回值原样透传。
196
+ // 卡片 patch 的统一出口(观测 + 重试):
197
+ // 1) 观测(2026-08-11 run 卡定格事故):飞书业务码错误以 HTTP 200 + {code!=0}
198
+ // 返回时,lark SDK 正常 resolve、@larksuite/channel 的 patchCard 丢弃返回值,
199
+ // 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里把业务码记 warn,
200
+ // 返回值原样透传。
201
+ // 2) 重试(2026-09-15 socket-close 事故):瞬态传输失败就地重试,最多
202
+ // PATCH_MAX_RETRIES 次,不把失败直接落到 unhandledRejection 打死进程。
133
203
  // Guard: unit-test mocks may omit rawClient; skip probe installation in that case.
134
204
  const messageService = tryGetPatchService(this.channel);
135
205
  if (messageService?.patch) {
136
206
  const origPatch = messageService.patch.bind(messageService);
137
207
  messageService.patch = (async (request, options) => {
138
- const res = await origPatch(request, options);
208
+ const res = await patchWithRetry(origPatch, request, options);
139
209
  if (typeof res?.code === 'number' && res.code !== 0) {
140
210
  const content = request?.data?.content;
141
211
  const bytes = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : undefined;
@@ -344,18 +414,7 @@ export class FeishuConnector {
344
414
  }
345
415
  /** Format error for logging without causing circular serialization */
346
416
  formatError(err) {
347
- if (err instanceof Error) {
348
- // For axios errors, extract useful info without circular refs
349
- const axiosErr = err;
350
- if (axiosErr.response?.status) {
351
- return `${err.message} (HTTP ${axiosErr.response.status})`;
352
- }
353
- if (axiosErr.code) {
354
- return `${err.message} (code: ${axiosErr.code})`;
355
- }
356
- return err.message;
357
- }
358
- return String(err);
417
+ return formatErrorForLog(err);
359
418
  }
360
419
  async addReaction(messageId, emoji) {
361
420
  try {
@@ -11,5 +11,12 @@
11
11
  * (recoverable,记日志不 exit)还是「进程状态损坏」(fatal,exit)。
12
12
  */
13
13
  type RejectionVerdict = 'recoverable' | 'fatal';
14
+ /**
15
+ * Whether `reason` is a transient transport failure (connection/socket level).
16
+ *
17
+ * Exported because the card-patch retry path needs the same judgement:
18
+ * one definition, so "retry" and "don't kill the process" never drift apart.
19
+ */
20
+ export declare function isTransientTransportError(reason: unknown): boolean;
14
21
  export declare function classifyRejection(reason: unknown): RejectionVerdict;
15
22
  export {};
@@ -1,3 +1,36 @@
1
+ /**
2
+ * 传输层瞬态错误码(Node errno + axios 包装码)。
3
+ *
4
+ * 这些错误代表"这次请求没送出去/连接断了",重试或忽略都是安全的;
5
+ * 它们不携带任何"程序状态损坏"的信息。典型场景:本地 mitmdump 代理重启
6
+ * (ECONNREFUSED)、Wi-Fi 抖动(ENOTFOUND/EAI_AGAIN)、对端关连接
7
+ * (ECONNRESET/EPIPE/ERR_SOCKET_CLOSED)。
8
+ */
9
+ const TRANSIENT_TRANSPORT_CODES = new Set([
10
+ 'ETIMEDOUT',
11
+ 'ECONNRESET',
12
+ 'ECONNREFUSED',
13
+ 'ECONNABORTED',
14
+ 'EPIPE',
15
+ 'EHOSTUNREACH',
16
+ 'ENETUNREACH',
17
+ 'ENOTFOUND',
18
+ 'EAI_AGAIN',
19
+ 'ERR_SOCKET_CLOSED',
20
+ 'ERR_NETWORK',
21
+ ]);
22
+ /**
23
+ * Whether `reason` is a transient transport failure (connection/socket level).
24
+ *
25
+ * Exported because the card-patch retry path needs the same judgement:
26
+ * one definition, so "retry" and "don't kill the process" never drift apart.
27
+ */
28
+ export function isTransientTransportError(reason) {
29
+ if (reason == null || typeof reason !== 'object')
30
+ return false;
31
+ const code = reason.code;
32
+ return typeof code === 'string' && TRANSIENT_TRANSPORT_CODES.has(code);
33
+ }
1
34
  export function classifyRejection(reason) {
2
35
  // Guard against null/undefined/primitive reasons: the `as` cast below is
3
36
  // TS-only (erased at runtime), so `null.response` would throw a TypeError
@@ -10,7 +43,10 @@ export function classifyRejection(reason) {
10
43
  const status = err.response?.status;
11
44
  if (status !== undefined && status >= 500 && status < 600)
12
45
  return 'recoverable';
13
- if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET')
46
+ // 传输层瞬态失败:连接/socket 级别,与进程状态无关。
47
+ // 2026-09-15 00:09 的崩溃就是这条漏了 —— AxiosError `ERR_SOCKET_CLOSED`
48
+ // (卡片 patch 时 socket 被关)落到 fatal → process.exit(1),正在跑的 agent run 陪葬。
49
+ if (isTransientTransportError(reason))
14
50
  return 'recoverable';
15
51
  // 飞书 API 业务错误(如 230027 外部会话无权限)—— 经 SDK throttle detach 路径逃逸的
16
52
  // patch rejection。流式卡片是尽力而为的展示层,飞书侧业务拒绝不应击穿进程。
package/dist/index.js CHANGED
@@ -19,6 +19,8 @@ import { OwnerBinder, formatBindGuidance } from './binder.js';
19
19
  import { CloneSession } from './clone.js';
20
20
  import { InstanceAlreadyRunningError, InstanceLock } from './instance-lock.js';
21
21
  import { spawnReplacementBridge, waitForPreviousInstance } from './restart.js';
22
+ import { currentPlatform } from './platform/select.js';
23
+ import { startSleepBlocker } from './platform/sleep-blocker.js';
22
24
  import { checkLatestVersion, isNewer, runInstallLatest, formatUpdateHint } from './update/index.js';
23
25
  import { classifyRejection } from './error-classification.js';
24
26
  import { WorkspaceStore } from './workspace/index.js';
@@ -622,9 +624,17 @@ async function main() {
622
624
  }
623
625
  const { config, configDir, logger, instanceLock } = await initializeCliAndConfig();
624
626
  // Restart handoff: if spawned as a /restart replacement, wait for the old
625
- // bridge to die (and release the instance lock) before acquiring it.
627
+ // lark-remote to die (and release the instance lock) before acquiring it.
626
628
  await waitForPreviousInstance();
627
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
+ }
628
638
  logger.info('config loaded');
629
639
  logger.info(`configDir = ${configDir}`);
630
640
  logger.info(`feishu.appId = ${config.feishu.appId}`);
@@ -741,7 +751,7 @@ async function main() {
741
751
  }
742
752
  }
743
753
  }
744
- logger.info('bridge is running, press Ctrl+C to exit');
754
+ logger.info('lark-remote is running, press Ctrl+C to exit');
745
755
  }
746
756
  main().catch((err) => {
747
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,7 @@
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 bridge process (shared by /restart replacement and /clone
12
+ * Spawn a detached lark-remote process (shared by /restart replacement and /clone
13
13
  * new-instance) whose early output — before the child's own file logger
14
14
  * initializes — lands on logFilePath. Returns the child pid, or null when
15
15
  * spawn failed synchronously / no pid; filesystem errors (unwritable log
@@ -26,7 +26,7 @@ export declare function spawnDetachedBridge(args: string[], logFilePath: string,
26
26
  env?: NodeJS.ProcessEnv;
27
27
  }): number | null;
28
28
  /**
29
- * Spawn a detached replacement bridge with the same executable and argv
29
+ * Spawn a detached replacement lark-remote with the same executable and argv
30
30
  * (including --config-dir) and return its pid. The caller is expected to
31
31
  * exit right after. stdio is redirected to restart-child.log so early
32
32
  * startup failures (before the file logger initializes) are not lost.
@@ -34,7 +34,7 @@ export declare function spawnDetachedBridge(args: string[], logFilePath: string,
34
34
  export declare function spawnReplacementBridge(logsDir: string): number;
35
35
  /**
36
36
  * On startup, if this process was spawned as a restart replacement, wait for
37
- * 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
38
38
  * continuing. No-op for normal starts. On timeout, proceed anyway — lock
39
39
  * acquisition remains the authority.
40
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,7 +16,7 @@ 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 bridge process (shared by /restart replacement and /clone
19
+ * Spawn a detached lark-remote process (shared by /restart replacement and /clone
20
20
  * new-instance) whose early output — before the child's own file logger
21
21
  * initializes — lands on logFilePath. Returns the child pid, or null when
22
22
  * spawn failed synchronously / no pid; filesystem errors (unwritable log
@@ -51,7 +51,7 @@ export function spawnDetachedBridge(args, logFilePath, opts) {
51
51
  }
52
52
  }
53
53
  /**
54
- * Spawn a detached replacement bridge with the same executable and argv
54
+ * Spawn a detached replacement lark-remote with the same executable and argv
55
55
  * (including --config-dir) and return its pid. The caller is expected to
56
56
  * exit right after. stdio is redirected to restart-child.log so early
57
57
  * startup failures (before the file logger initializes) are not lost.
@@ -61,13 +61,13 @@ export function spawnReplacementBridge(logsDir) {
61
61
  env: { ...process.env, [RESTART_WAIT_PID_ENV]: String(process.pid) },
62
62
  });
63
63
  if (pid === null) {
64
- throw new Error('spawn replacement bridge failed: no pid');
64
+ throw new Error('spawn replacement lark-remote failed: no pid');
65
65
  }
66
66
  return pid;
67
67
  }
68
68
  /**
69
69
  * On startup, if this process was spawned as a restart replacement, wait for
70
- * 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
71
71
  * continuing. No-op for normal starts. On timeout, proceed anyway — lock
72
72
  * acquisition remains the authority.
73
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) {
@@ -2030,8 +2030,8 @@ export class CommandRouter {
2030
2030
  { cmd: 'ws', label: '/ws', desc: 'workspace 管理(save|use|remove)' },
2031
2031
  { cmd: 'config', label: '/config /c', desc: '查看和修改配置(卡片交互)' },
2032
2032
  { cmd: 'active', label: '/active', desc: '查看所有正在进行中的 session' },
2033
- { cmd: 'exit', label: '/exit /e', desc: '退出 bridge' },
2034
- { 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 不变)' },
2035
2035
  { cmd: 'update', label: '/update /u', desc: '检查并升级到最新版本' },
2036
2036
  {
2037
2037
  cmd: 'clone',
@@ -2140,6 +2140,16 @@ export class CommandRouter {
2140
2140
  content: '**Agent**\n通过 `/config` → 默认 Agent 切换:Claude / Codex / Opencode / Pi / Kimi',
2141
2141
  },
2142
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
+ });
2143
2153
  return {
2144
2154
  card: {
2145
2155
  schema: '2.0',
@@ -2228,7 +2238,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2228
2238
  async cmdExit() {
2229
2239
  // Signal handle() to invoke exitHandler after the reply is sent.
2230
2240
  this.pendingExit = true;
2231
- return { text: 'bridge 正在退出...' };
2241
+ return { text: 'lark-remote 正在退出...' };
2232
2242
  }
2233
2243
  cmdRestart() {
2234
2244
  if (!this.restartSpawner) {
@@ -2240,7 +2250,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2240
2250
  // 链路——spawn 失败则旧进程保持存活,不会两头落空。
2241
2251
  const pid = this.restartSpawner();
2242
2252
  this.pendingExit = true;
2243
- return { text: `♻️ bridge 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
2253
+ return { text: `♻️ lark-remote 重启中(新进程 pid ${pid}),启动通知稍后送达…` };
2244
2254
  }
2245
2255
  catch (err) {
2246
2256
  getLogger().error('[router] restart spawn failed:', err);
@@ -3413,8 +3423,8 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
3413
3423
  return { card };
3414
3424
  }
3415
3425
  cmdActive(_args, _ctx, offset = 0) {
3416
- // List all active runs from bridge memory (not from file system scan)
3417
- // 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
3418
3428
  const activeRuns = this.bridge.getActiveRuns();
3419
3429
  const activeBashRuns = this.bridge.getActiveBashRuns();
3420
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
@@ -11,6 +11,36 @@ const KIND_TO_TOOL = {
11
11
  search: 'Grep',
12
12
  fetch: 'WebFetch',
13
13
  };
14
+ /**
15
+ * Join the text payloads of a tool_call(/_update) content array
16
+ * (`[{type:'content', content:{type:'text', text}}]` entries). Returns
17
+ * undefined when no text entry exists (diff/terminal-only content).
18
+ */
19
+ function extractToolCallContentText(content) {
20
+ if (!Array.isArray(content))
21
+ return undefined;
22
+ let text = '';
23
+ let found = false;
24
+ for (const entry of content) {
25
+ const e = entry;
26
+ if (e?.type === 'content' && e.content?.type === 'text' && typeof e.content.text === 'string') {
27
+ text += e.content.text;
28
+ found = true;
29
+ }
30
+ }
31
+ return found ? text : undefined;
32
+ }
33
+ /** Best-effort stringify for tracked rawInput objects (never throws). */
34
+ function stringifyUnknown(value) {
35
+ if (typeof value === 'string')
36
+ return value;
37
+ try {
38
+ return JSON.stringify(value) ?? String(value);
39
+ }
40
+ catch {
41
+ return String(value);
42
+ }
43
+ }
14
44
  // =============================================================================
15
45
  // Translator base class
16
46
  // =============================================================================
@@ -25,6 +55,16 @@ export class BaseAcpTranslator {
25
55
  liveCostUsd;
26
56
  /** Current turn id, set by produceTurnStarted. */
27
57
  currentTurnId = '';
58
+ /**
59
+ * Latest streaming-args text per tool call (keyed by wire toolCallId).
60
+ * kimi ACP sends the permission request BEFORE tool.call.started (verified
61
+ * live against kimi 0.42.0, 2026-09-13): the request_permission toolCall
62
+ * carries only title + a truncated summary in content, NO rawInput — the
63
+ * full args only ever reach the client as accumulated text in the
64
+ * tool_call/tool_call_update streaming deltas. Tracking them here lets the
65
+ * approval handler correlate by toolCallId and recover the real command.
66
+ */
67
+ toolCallArgsByCallId = new Map();
28
68
  /**
29
69
  * Handle a notification from the ACP server and return translated events.
30
70
  * Wire envelope: {sessionId, update: {sessionUpdate: '<kind>', ...}} — the
@@ -191,6 +231,13 @@ export class BaseAcpTranslator {
191
231
  // Keep raw string as-is
192
232
  }
193
233
  }
234
+ const argsText = extractToolCallContentText(event.content);
235
+ if (argsText !== undefined) {
236
+ this.trackToolCallArgs(event.toolCallId, argsText);
237
+ }
238
+ else if (event.rawInput !== undefined && event.rawInput !== null) {
239
+ this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
240
+ }
194
241
  const mapped = event.kind ? KIND_TO_TOOL[event.kind] : undefined;
195
242
  const name = mapped ?? event.title;
196
243
  const summary = mapped && event.title && event.title !== name ? event.title : undefined;
@@ -218,6 +265,22 @@ export class BaseAcpTranslator {
218
265
  handleToolCallUpdate(event, _sessionId) {
219
266
  const isError = event.status === 'failed';
220
267
  const content = this.extractToolResultContent(event);
268
+ // Terminal updates end the args-streaming phase — drop the tracked entry.
269
+ // In-progress delta text is cumulative (events-map.ts
270
+ // toolCallDeltaToSessionUpdate: content.text = accumulator.args), so the
271
+ // latest text replaces the previous one rather than appending.
272
+ if (event.status === 'completed' || event.status === 'failed') {
273
+ this.toolCallArgsByCallId.delete(event.toolCallId);
274
+ }
275
+ else {
276
+ const argsText = extractToolCallContentText(event.content);
277
+ if (argsText !== undefined) {
278
+ this.trackToolCallArgs(event.toolCallId, argsText);
279
+ }
280
+ else if (event.rawInput !== undefined && event.rawInput !== null) {
281
+ this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
282
+ }
283
+ }
221
284
  return [
222
285
  {
223
286
  type: 'user',
@@ -235,6 +298,25 @@ export class BaseAcpTranslator {
235
298
  },
236
299
  ];
237
300
  }
301
+ /**
302
+ * Latest tracked streaming-args text for a tool call (undefined when never
303
+ * seen or already terminated). Approval handlers correlate
304
+ * request_permission's toolCall.toolCallId against this to recover the real
305
+ * command (see toolCallArgsByCallId).
306
+ */
307
+ getTrackedToolCallArgs(toolCallId) {
308
+ return this.toolCallArgsByCallId.get(toolCallId);
309
+ }
310
+ /** Record latest args text, bounded so a long session cannot grow it forever. */
311
+ trackToolCallArgs(toolCallId, argsText) {
312
+ this.toolCallArgsByCallId.delete(toolCallId);
313
+ this.toolCallArgsByCallId.set(toolCallId, argsText);
314
+ if (this.toolCallArgsByCallId.size > 100) {
315
+ const oldest = this.toolCallArgsByCallId.keys().next().value;
316
+ if (oldest !== undefined)
317
+ this.toolCallArgsByCallId.delete(oldest);
318
+ }
319
+ }
238
320
  /**
239
321
  * tool_result content extraction. Default: rawOutput passthrough (kimi
240
322
  * semantics). opencode overrides — its rawOutput is an OBJECT