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.
- package/CHANGELOG.md +17 -0
- package/README.en.md +6 -6
- package/README.md +8 -7
- package/dist/binder.d.ts +1 -1
- package/dist/binder.js +1 -1
- package/dist/bridge/final-usage.js +2 -2
- package/dist/bridge/inbound-media.js +7 -5
- package/dist/bridge/index.js +75 -75
- package/dist/clone.d.ts +1 -1
- package/dist/common/fs.js +1 -1
- package/dist/config/index.js +1 -1
- package/dist/connector/index.d.ts +6 -0
- package/dist/connector/index.js +76 -17
- package/dist/error-classification.d.ts +7 -0
- package/dist/error-classification.js +37 -1
- package/dist/index.js +12 -2
- package/dist/platform/command.js +1 -1
- package/dist/platform/sleep-blocker.d.ts +17 -0
- package/dist/platform/sleep-blocker.js +73 -0
- package/dist/platform/terminator-win32.js +1 -1
- package/dist/restart.d.ts +4 -4
- package/dist/restart.js +5 -5
- package/dist/router/config/kimi.js +4 -3
- package/dist/router/index.js +16 -6
- package/dist/runner/common/acp/base-acp-runner.js +7 -2
- package/dist/runner/common/acp/base-translator.d.ts +19 -0
- package/dist/runner/common/acp/base-translator.js +82 -0
- package/dist/runner/common/acp/protocol-types.d.ts +16 -2
- package/dist/runner/common/acp/protocol-types.js +3 -0
- package/dist/runner/kimi/acp/translator.js +60 -4
- package/dist/session/session-store.d.ts +1 -1
- package/dist/session/session-store.js +1 -1
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/index.js +1 -1
- package/package.json +1 -1
package/dist/connector/index.js
CHANGED
|
@@ -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
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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('
|
|
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);
|
package/dist/platform/command.js
CHANGED
|
@@ -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 分钟,本层更短——
|
|
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 上
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
11
|
-
{ text: '
|
|
12
|
-
{ text: '
|
|
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) {
|
package/dist/router/index.js
CHANGED
|
@@ -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: '退出
|
|
2034
|
-
{ cmd: 'restart', label: '/restart', desc: '重启
|
|
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: '
|
|
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: `♻️
|
|
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
|
|
3417
|
-
// New semantics (2026-07-20): only shows runs started by THIS
|
|
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
|
-
|
|
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
|