lark-remote 0.4.1 → 0.4.3
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 +39 -0
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/approval-coordinator.d.ts +2 -0
- package/dist/bridge/approval-coordinator.js +16 -7
- package/dist/bridge/index.d.ts +22 -0
- package/dist/bridge/index.js +138 -56
- package/dist/card/approval-render.js +9 -8
- package/dist/card/card-budget.js +39 -26
- package/dist/card/run-renderer.js +9 -2
- package/dist/card/text-truncate.d.ts +9 -10
- package/dist/card/text-truncate.js +48 -43
- package/dist/cli.js +17 -0
- package/dist/config/codex-config.d.ts +3 -0
- package/dist/config/codex-config.js +4 -1
- package/dist/config/dir.js +6 -6
- package/dist/config/index.d.ts +7 -5
- package/dist/config/index.js +52 -13
- package/dist/config/kimi-config.d.ts +4 -1
- package/dist/config/kimi-config.js +5 -2
- package/dist/config/pi-config.d.ts +2 -7
- package/dist/config/pi-config.js +6 -6
- package/dist/config/wizard.js +8 -4
- package/dist/connector/index.d.ts +19 -0
- package/dist/connector/index.js +134 -23
- package/dist/inbound/placeholder.d.ts +3 -2
- package/dist/inbound/placeholder.js +17 -7
- package/dist/index.js +28 -6
- package/dist/instance-lock.d.ts +31 -15
- package/dist/instance-lock.js +64 -62
- package/dist/platform/agent-stopper.d.ts +29 -7
- package/dist/platform/agent-stopper.js +40 -8
- package/dist/platform/identity.d.ts +59 -3
- package/dist/platform/identity.js +105 -30
- package/dist/platform/path.d.ts +0 -7
- package/dist/platform/path.js +1 -51
- package/dist/platform/shell.d.ts +14 -0
- package/dist/platform/shell.js +35 -2
- package/dist/platform/sleep-blocker.d.ts +6 -0
- package/dist/platform/sleep-blocker.js +31 -5
- package/dist/platform/spawn.d.ts +1 -1
- package/dist/platform/spawn.js +1 -1
- package/dist/platform/terminator-posix.d.ts +1 -29
- package/dist/platform/terminator-posix.js +32 -30
- package/dist/platform/terminator-win32.js +4 -2
- package/dist/platform/terminator.d.ts +2 -2
- package/dist/platform/terminator.js +17 -2
- package/dist/platform/types.d.ts +3 -0
- package/dist/router/card-action-feedback.d.ts +17 -0
- package/dist/router/card-action-feedback.js +31 -0
- package/dist/router/card-helpers.d.ts +38 -2
- package/dist/router/card-helpers.js +79 -2
- package/dist/router/config/claude.js +8 -5
- package/dist/router/index.d.ts +41 -6
- package/dist/router/index.js +254 -84
- package/dist/runner/claude/runner.js +1 -1
- package/dist/runner/claude/session.d.ts +2 -6
- package/dist/runner/claude/session.js +7 -8
- package/dist/runner/codex/app-server/protocol-types.d.ts +4 -0
- package/dist/runner/codex/app-server/protocol-types.js +4 -0
- package/dist/runner/codex/app-server/runner.js +3 -0
- package/dist/runner/common/acp/base-acp-runner.js +7 -0
- package/dist/runner/common/connection-based-runner.d.ts +18 -0
- package/dist/runner/common/connection-based-runner.js +31 -0
- package/dist/runner/common/jsonrpc/client.js +6 -1
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +20 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +27 -1
- package/dist/runner/common/jsonrpc/transport.d.ts +29 -1
- package/dist/runner/common/jsonrpc/transport.js +79 -10
- package/dist/runner/common/spawning-runner.d.ts +49 -19
- package/dist/runner/common/spawning-runner.js +94 -54
- package/dist/runner/dsh/client.d.ts +1 -3
- package/dist/runner/dsh/client.js +0 -4
- package/dist/runner/dsh/types.d.ts +0 -36
- package/dist/runner/kimi/acp/runner.d.ts +18 -1
- package/dist/runner/kimi/acp/runner.js +100 -42
- package/dist/runner/kimi/acp/translator.d.ts +0 -1
- package/dist/runner/opencode/acp/translator.d.ts +0 -1
- package/dist/runner/pi/rpc/protocol-types.d.ts +4 -0
- package/dist/runner/pi/rpc/runner.js +3 -0
- package/dist/runner/resolve-agent-choices.js +0 -5
- package/package.json +2 -2
- package/dist/runner/common/process-stopper.d.ts +0 -7
- package/dist/runner/common/process-stopper.js +0 -8
- package/dist/runner/dsh/fake-dsh-server.d.ts +0 -60
- package/dist/runner/dsh/fake-dsh-server.js +0 -156
package/dist/connector/index.js
CHANGED
|
@@ -47,7 +47,7 @@ const noKeepAliveAgent = new https.Agent({ keepAlive: false });
|
|
|
47
47
|
*/
|
|
48
48
|
export const DEDUP_TTL_MS = 300;
|
|
49
49
|
/**
|
|
50
|
-
* SDK converter 注册表支持的 msg_type(@larksuite/channel 0.
|
|
50
|
+
* SDK converter 注册表支持的 msg_type(@larksuite/channel 0.7.1 实测)。
|
|
51
51
|
* 带资源但类型不在其中 = 未来新增类型:照常下载 + warn(default-deny)。
|
|
52
52
|
*/
|
|
53
53
|
const KNOWN_RESOURCE_CONTENT_TYPES = new Set([
|
|
@@ -85,9 +85,22 @@ const RESOURCE_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000;
|
|
|
85
85
|
const MERGE_FORWARD_HINT = {
|
|
86
86
|
merge_forward: '(合并转发里的附件暂不支持保存,可逐条转发或直接发送文件)',
|
|
87
87
|
};
|
|
88
|
-
|
|
88
|
+
/** withTimeout 的超时专用错误:catch 侧据此知道清理已交给补删路径。 */
|
|
89
|
+
class ResourceTimeoutError extends Error {
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* 给没有超时的 SDK 调用套一层超时。**超时只是不再等它,底层操作照跑**
|
|
93
|
+
* (`downloadResourceToFile` 没有 abort 入参),所以 onLateSettle 在操作自己
|
|
94
|
+
* 结束时补做清理:此刻 unlink 在 win32 会被句柄占用挡到放弃、在 posix 只删掉
|
|
95
|
+
* 目录项把空间留给未关闭的 fd,两种平台都可能留下孤儿文件。
|
|
96
|
+
*/
|
|
97
|
+
function withTimeout(promise, ms, label, onLateSettle) {
|
|
89
98
|
return new Promise((resolve, reject) => {
|
|
90
|
-
const timer = setTimeout(() =>
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
reject(new ResourceTimeoutError(`${label} timed out after ${ms}ms`));
|
|
101
|
+
if (onLateSettle)
|
|
102
|
+
promise.then(onLateSettle, onLateSettle);
|
|
103
|
+
}, ms);
|
|
91
104
|
promise.then((value) => {
|
|
92
105
|
clearTimeout(timer);
|
|
93
106
|
resolve(value);
|
|
@@ -100,7 +113,7 @@ function withTimeout(promise, ms, label) {
|
|
|
100
113
|
/**
|
|
101
114
|
* 判断 send 失败是否值得重试一次(§9.5 限流重试口径,§P1-3 修正)。
|
|
102
115
|
*
|
|
103
|
-
* @larksuite/channel@0.
|
|
116
|
+
* @larksuite/channel@0.7.1 的 classifyError 把飞书业务码 99991400/99991401
|
|
104
117
|
* (频率控制)归类为 `code='permission_denied'`(SDK 源码实证),并保留原始
|
|
105
118
|
* axios 错误在 `cause` 链上(`cause.response.data.code`)。因此仅判
|
|
106
119
|
* `code === 'rate_limited'` 会让这条重试路径对设计目标完全死亡。判定覆盖:
|
|
@@ -160,6 +173,30 @@ function isRetryablePatchError(err) {
|
|
|
160
173
|
const status = err.response?.status;
|
|
161
174
|
return typeof status === 'number' && status >= 500 && status < 600;
|
|
162
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* 飞书的「tenant_access_token 无效」业务码(HTTP 200 + body code)。
|
|
178
|
+
* appSecret 在后台被重置后,此前签发的 token 立刻落到这个码。
|
|
179
|
+
*/
|
|
180
|
+
const FEISHU_TOKEN_INVALID_CODE = 99991663;
|
|
181
|
+
/** 带飞书业务码的失败:日志与错误文案只留 message,判定要另存 code。 */
|
|
182
|
+
class FeishuBusinessError extends Error {
|
|
183
|
+
code;
|
|
184
|
+
constructor(message, code) {
|
|
185
|
+
super(message);
|
|
186
|
+
this.name = 'FeishuBusinessError';
|
|
187
|
+
this.code = code;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* 这次失败是否等于「缓存里的 token 已失效」:body code 99991663 或 HTTP 401。
|
|
192
|
+
* 判定发生在内层 try 抓到的原始错误上,外层 `${label} failed: …` 包装在其之后。
|
|
193
|
+
*/
|
|
194
|
+
function isAccessTokenRejected(err) {
|
|
195
|
+
if (err instanceof FeishuBusinessError)
|
|
196
|
+
return err.code === FEISHU_TOKEN_INVALID_CODE;
|
|
197
|
+
const resp = err?.response;
|
|
198
|
+
return resp?.status === 401;
|
|
199
|
+
}
|
|
163
200
|
/**
|
|
164
201
|
* 带重试的 `im.v1.message.patch`。
|
|
165
202
|
*
|
|
@@ -173,6 +210,11 @@ function isRetryablePatchError(err) {
|
|
|
173
210
|
* 这里在唯一的底层 patch 出口上兜住:瞬态错误重试最多 PATCH_MAX_RETRIES 次,
|
|
174
211
|
* 全部失败才把最后一个错误抛出去(此时上层 classifyRejection 也会判为
|
|
175
212
|
* recoverable,不会击穿进程)。patch 是整卡替换,重试天然幂等。
|
|
213
|
+
*
|
|
214
|
+
* 重试期间不会有更新的帧插到前面:@larksuite/channel 的 CardStreamController 用
|
|
215
|
+
* `Throttle.inFlight`(在途 fire 未结束则新 fire 延后)+ 每流 FIFO `UpdateQueue`
|
|
216
|
+
* 两层串行,本函数就在 `fire()` 的 await 链上,所以后续帧一定排在本次重试之后。
|
|
217
|
+
* 终态帧另走 `flushNow()` + `queue.drain()`。不需要在这里补 sequence 机制。
|
|
176
218
|
*/
|
|
177
219
|
async function patchWithRetry(patchFn, request, options) {
|
|
178
220
|
let lastError;
|
|
@@ -234,10 +276,13 @@ export class FeishuConnector {
|
|
|
234
276
|
// 卡片 patch 的统一出口(观测 + 重试):
|
|
235
277
|
// 1) 观测(2026-08-11 run 卡定格事故):飞书业务码错误以 HTTP 200 + {code!=0}
|
|
236
278
|
// 返回时,lark SDK 正常 resolve、@larksuite/channel 的 patchCard 丢弃返回值,
|
|
237
|
-
// 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里把业务码记 warn
|
|
238
|
-
//
|
|
279
|
+
// 导致终态卡 patch 被业务层拒绝时全链路无日志无兜底。这里把业务码记 warn。
|
|
280
|
+
// **不在这里抛**:流式卡片的 patch 由 throttle 触发、脱离 await 链
|
|
281
|
+
// (`Throttle.fireSoon` 丢掉 doFire 的 promise),抛出即成 detached rejection。
|
|
282
|
+
// 恢复链路只覆盖有 await 的调用方:见 `updateCard()` 读业务码后抛。
|
|
239
283
|
// 2) 重试(2026-09-15 socket-close 事故):瞬态传输失败就地重试,最多
|
|
240
284
|
// PATCH_MAX_RETRIES 次,不把失败直接落到 unhandledRejection 打死进程。
|
|
285
|
+
// 业务码是确定性失败,不进重试循环。
|
|
241
286
|
// Guard: unit-test mocks may omit rawClient; skip probe installation in that case.
|
|
242
287
|
const messageService = tryGetPatchService(this.channel);
|
|
243
288
|
if (messageService?.patch) {
|
|
@@ -358,7 +403,7 @@ export class FeishuConnector {
|
|
|
358
403
|
for (const res of msg.resources) {
|
|
359
404
|
const tmpPath = path.join(os.tmpdir(), `lark-remote-inbound-${randomUUID()}`);
|
|
360
405
|
try {
|
|
361
|
-
const { contentType, bytesWritten } = await withTimeout(this.channel.downloadResourceToFile(msg.messageId, res.fileKey, res.type, tmpPath), timeoutMs, `downloadResource fileKey=${res.fileKey}
|
|
406
|
+
const { contentType, bytesWritten } = await withTimeout(this.channel.downloadResourceToFile(msg.messageId, res.fileKey, res.type, tmpPath), timeoutMs, `downloadResource fileKey=${res.fileKey}`, () => silentlyUnlink(tmpPath));
|
|
362
407
|
if (bytesWritten > maxBytes) {
|
|
363
408
|
silentlyUnlink(tmpPath);
|
|
364
409
|
const limitMb = maxBytes / (1024 * 1024);
|
|
@@ -382,7 +427,9 @@ export class FeishuConnector {
|
|
|
382
427
|
});
|
|
383
428
|
}
|
|
384
429
|
catch (err) {
|
|
385
|
-
|
|
430
|
+
// 超时不在这里删文件:上面已把清理挂到底层传输的 settle 上。
|
|
431
|
+
if (!(err instanceof ResourceTimeoutError))
|
|
432
|
+
silentlyUnlink(tmpPath);
|
|
386
433
|
getLogger().warn(`[feishu] downloadResource failed fileKey=${res.fileKey} type=${res.type}:`, err.message);
|
|
387
434
|
failures.push({
|
|
388
435
|
kind: res.kind,
|
|
@@ -458,24 +505,49 @@ export class FeishuConnector {
|
|
|
458
505
|
return result.messageId;
|
|
459
506
|
}
|
|
460
507
|
catch (err) {
|
|
461
|
-
//
|
|
508
|
+
// 抛出去前先落一条格式化日志(调用方只看到包装后的 message)。
|
|
509
|
+
// 抛普通 Error 而不是原 err:axios 错误对象跨层序列化会丢信息。
|
|
462
510
|
const errorInfo = this.formatError(err);
|
|
463
511
|
getLogger().error('[feishu] streamCard failed:', errorInfo);
|
|
464
|
-
// Throw a plain error to avoid axios error serialization issues
|
|
465
512
|
throw new Error(`streamCard failed: ${errorInfo}`, { cause: err });
|
|
466
513
|
}
|
|
467
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* 原地替换已发消息的卡片内容。
|
|
517
|
+
*
|
|
518
|
+
* 走 `rawClient.im.v1.message.patch` 而不是 `channel.updateCard()`,唯一目的就是
|
|
519
|
+
* 拿到返回值:飞书的业务码拒绝以 HTTP 200 + `{code!=0}` 返回,SDK 的 `patchCard`
|
|
520
|
+
* 把返回值丢了,调用方于是拿到假成功(卡片定格在被打回的那一帧,用户却收到
|
|
521
|
+
* 「已保存」)。抛出去让 `bridge.updateCardInPlace()` 的 catch 走 sendResult 兜底。
|
|
522
|
+
* `rawClient` 缺失(测试 mock)时回退 SDK 入口,行为退化为「看不见业务码」。
|
|
523
|
+
*/
|
|
468
524
|
async updateCard(messageId, card) {
|
|
525
|
+
const messageService = tryGetPatchService(this.channel);
|
|
526
|
+
let rejection;
|
|
469
527
|
try {
|
|
470
|
-
|
|
528
|
+
if (messageService?.patch) {
|
|
529
|
+
const res = await messageService.patch({
|
|
530
|
+
path: { message_id: messageId },
|
|
531
|
+
data: { content: JSON.stringify(card) },
|
|
532
|
+
});
|
|
533
|
+
if (typeof res?.code === 'number' && res.code !== 0) {
|
|
534
|
+
rejection = { code: res.code, msg: res.msg };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else {
|
|
538
|
+
await this.channel.updateCard(messageId, card);
|
|
539
|
+
}
|
|
471
540
|
}
|
|
472
541
|
catch (err) {
|
|
473
|
-
// Log the error but don't
|
|
542
|
+
// Log the error but don't swallow it - a plain error avoids axios
|
|
543
|
+
// serialization issues in the logger.
|
|
474
544
|
const errorInfo = this.formatError(err);
|
|
475
545
|
getLogger().error('[feishu] updateCard failed:', errorInfo);
|
|
476
|
-
// Throw a plain error to avoid axios error serialization issues
|
|
477
546
|
throw new Error(`updateCard failed: ${errorInfo}`, { cause: err });
|
|
478
547
|
}
|
|
548
|
+
if (rejection) {
|
|
549
|
+
throw new Error(`updateCard failed: Feishu rejected the card (code=${rejection.code} msg=${rejection.msg ?? ''})`, { cause: rejection });
|
|
550
|
+
}
|
|
479
551
|
}
|
|
480
552
|
/** Format error for logging without causing circular serialization */
|
|
481
553
|
formatError(err) {
|
|
@@ -530,13 +602,38 @@ export class FeishuConnector {
|
|
|
530
602
|
* 标签('sendFile' | 'sendImage')。
|
|
531
603
|
*/
|
|
532
604
|
async uploadAndSendMedia(chatId, filePath, label, uploadUrl, buildForm, keyField, msgType) {
|
|
605
|
+
try {
|
|
606
|
+
// 被判 token 无效时清缓存重取,整段上传重做**一次**(appSecret 在飞书后台
|
|
607
|
+
// 被重置后,缓存里的旧 token 立刻失效;不清缓存就要一直失败到自然过期
|
|
608
|
+
// ~2h,期间发文件全报错)。只重试一次:持续被拒说明问题不在 token 缓存。
|
|
609
|
+
for (let attempt = 0;; attempt++) {
|
|
610
|
+
const accessToken = await this.getTenantAccessToken();
|
|
611
|
+
try {
|
|
612
|
+
return await this.uploadWithToken(accessToken, chatId, filePath, uploadUrl, buildForm, keyField, msgType);
|
|
613
|
+
}
|
|
614
|
+
catch (err) {
|
|
615
|
+
if (attempt === 0 && isAccessTokenRejected(err)) {
|
|
616
|
+
this.invalidateTenantToken();
|
|
617
|
+
getLogger().warn(`[feishu] ${label}: tenant token rejected, refetching once`);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
throw err;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
const errorInfo = this.formatError(err);
|
|
626
|
+
getLogger().error(`[feishu] ${label} failed:`, errorInfo);
|
|
627
|
+
throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
/** 单次「上传 + 发媒体消息」;文件流在此拥有,任何失败路径都销毁(P2-18)。 */
|
|
631
|
+
async uploadWithToken(accessToken, chatId, filePath, uploadUrl, buildForm, keyField, msgType) {
|
|
533
632
|
let fileStream = null;
|
|
534
633
|
try {
|
|
535
|
-
// P2-18: tenant_access_token cache(~2h),避免每次发送都取 token。
|
|
536
|
-
const accessToken = await this.getTenantAccessToken();
|
|
537
634
|
fileStream = fs.createReadStream(filePath);
|
|
538
635
|
const form = buildForm(fileStream);
|
|
539
|
-
// P2-18: timeout 120s(大文件慢链路);httpsAgent:
|
|
636
|
+
// P2-18: timeout 120s(大文件慢链路);httpsAgent: noKeepAliveAgent 防止
|
|
540
637
|
// Bun 复用已被服务端 RST 的 keep-alive socket(ECONNRESET after ~30s)。
|
|
541
638
|
const uploadResp = await axios.post(uploadUrl, form, {
|
|
542
639
|
headers: {
|
|
@@ -547,7 +644,7 @@ export class FeishuConnector {
|
|
|
547
644
|
httpsAgent: noKeepAliveAgent,
|
|
548
645
|
});
|
|
549
646
|
if (uploadResp.data.code !== 0) {
|
|
550
|
-
throw new
|
|
647
|
+
throw new FeishuBusinessError(`Upload failed: ${uploadResp.data.msg}`, uploadResp.data.code);
|
|
551
648
|
}
|
|
552
649
|
const mediaKey = uploadResp.data.data[keyField];
|
|
553
650
|
// P2-18: timeout 30s;noKeepAlive 同上。
|
|
@@ -565,7 +662,7 @@ export class FeishuConnector {
|
|
|
565
662
|
httpsAgent: noKeepAliveAgent,
|
|
566
663
|
});
|
|
567
664
|
if (sendResp.data.code !== 0) {
|
|
568
|
-
throw new
|
|
665
|
+
throw new FeishuBusinessError(`Media message failed: ${sendResp.data.msg}`, sendResp.data.code);
|
|
569
666
|
}
|
|
570
667
|
return sendResp.data.data.message_id;
|
|
571
668
|
}
|
|
@@ -579,9 +676,7 @@ export class FeishuConnector {
|
|
|
579
676
|
/* already destroyed */
|
|
580
677
|
}
|
|
581
678
|
}
|
|
582
|
-
|
|
583
|
-
getLogger().error(`[feishu] ${label} failed:`, errorInfo);
|
|
584
|
-
throw new Error(`${label} failed: ${errorInfo}`, { cause: err });
|
|
679
|
+
throw err;
|
|
585
680
|
}
|
|
586
681
|
}
|
|
587
682
|
/**
|
|
@@ -617,14 +712,30 @@ export class FeishuConnector {
|
|
|
617
712
|
* fetching it on every file send wastes a round-trip and an unguarded fetch
|
|
618
713
|
* could return data.code != 0 with an undefined token. Cache with expiry and
|
|
619
714
|
* validate the response code.
|
|
715
|
+
*
|
|
716
|
+
* 缓存的两条失效路径:到期(下面 expire)与被服务端判 token 无效
|
|
717
|
+
* (invalidateTenantToken,见 §B13)。
|
|
620
718
|
*/
|
|
621
719
|
cachedToken = null;
|
|
622
720
|
cachedTokenExpireAt = 0;
|
|
721
|
+
/** 在途的取 token 请求:并发发送合流成一次请求(飞书该端点有限流)。 */
|
|
722
|
+
tokenFetch = null;
|
|
623
723
|
async getTenantAccessToken() {
|
|
624
724
|
const now = Date.now();
|
|
625
725
|
if (this.cachedToken && now < this.cachedTokenExpireAt) {
|
|
626
726
|
return this.cachedToken;
|
|
627
727
|
}
|
|
728
|
+
this.tokenFetch ??= this.fetchTenantAccessToken().finally(() => {
|
|
729
|
+
this.tokenFetch = null;
|
|
730
|
+
});
|
|
731
|
+
return this.tokenFetch;
|
|
732
|
+
}
|
|
733
|
+
/** 清掉缓存的 token:鉴权被拒时旧值已经没有价值,留着只会继续失败。 */
|
|
734
|
+
invalidateTenantToken() {
|
|
735
|
+
this.cachedToken = null;
|
|
736
|
+
this.cachedTokenExpireAt = 0;
|
|
737
|
+
}
|
|
738
|
+
async fetchTenantAccessToken() {
|
|
628
739
|
// P2-18: timeout 30s on the token request.
|
|
629
740
|
// httpsAgent: noKeepAlive — same Bun keep-alive fix as upload above.
|
|
630
741
|
const tokenResp = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
@@ -636,13 +747,13 @@ export class FeishuConnector {
|
|
|
636
747
|
httpsAgent: noKeepAliveAgent,
|
|
637
748
|
});
|
|
638
749
|
if (tokenResp.data.code !== 0) {
|
|
639
|
-
throw new
|
|
750
|
+
throw new FeishuBusinessError(`Failed to get tenant_access_token: ${tokenResp.data.msg ?? 'unknown error'}`, tokenResp.data.code);
|
|
640
751
|
}
|
|
641
752
|
const token = tokenResp.data.tenant_access_token;
|
|
642
753
|
// Expire is in seconds; refresh 5min early as a safety margin.
|
|
643
754
|
const expire = (tokenResp.data.expire ?? 7200);
|
|
644
755
|
this.cachedToken = token;
|
|
645
|
-
this.cachedTokenExpireAt = now + (expire - 300) * 1000;
|
|
756
|
+
this.cachedTokenExpireAt = Date.now() + (expire - 300) * 1000;
|
|
646
757
|
return token;
|
|
647
758
|
}
|
|
648
759
|
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* `src/router/index.ts` 的裸前缀判定当成 bash 命令执行了整段消息。
|
|
7
7
|
*
|
|
8
8
|
* 规则来源:`docs/zh/architecture/inbound-message-matrix.md` §1 的 22 种 message_type
|
|
9
|
-
*
|
|
9
|
+
* 实测渲染串(`@larksuite/channel@0.7.1`)。判定顺序**先清洗后判语义**:任何前缀
|
|
10
|
+
* 判定都必须发生在剥离之后。
|
|
10
11
|
*
|
|
11
12
|
* 取舍(有意为之):剥离对所有消息生效,所以用户在**纯文本**消息里手打
|
|
12
13
|
* `` / `<file key="…"/>` 这类字面量也会被当成占位符剥掉。代价是极少数
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
*/
|
|
16
17
|
export type PlaceholderKind = 'image' | 'file' | 'video' | 'audio' | 'sticker' | 'share' | 'location' | 'folder' | 'vote' | 'todo' | 'meeting' | 'calendar' | 'hongbao' | 'forwarded' | 'unsupported' | 'unknown';
|
|
17
18
|
export interface StripResult {
|
|
18
|
-
/** 剥离占位符后的文本(trim 后;forwarded
|
|
19
|
+
/** 剥离占位符后的文本(trim 后;forwarded 与未知成对标签只剥标签、保留内部文本)。 */
|
|
19
20
|
clean: string;
|
|
20
21
|
/** 命中的占位符种类(去重、保序),用于 rejected 回执与排障。 */
|
|
21
22
|
kinds: PlaceholderKind[];
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* `src/router/index.ts` 的裸前缀判定当成 bash 命令执行了整段消息。
|
|
7
7
|
*
|
|
8
8
|
* 规则来源:`docs/zh/architecture/inbound-message-matrix.md` §1 的 22 种 message_type
|
|
9
|
-
*
|
|
9
|
+
* 实测渲染串(`@larksuite/channel@0.7.1`)。判定顺序**先清洗后判语义**:任何前缀
|
|
10
|
+
* 判定都必须发生在剥离之后。
|
|
10
11
|
*
|
|
11
12
|
* 取舍(有意为之):剥离对所有消息生效,所以用户在**纯文本**消息里手打
|
|
12
13
|
* `` / `<file key="…"/>` 这类字面量也会被当成占位符剥掉。代价是极少数
|
|
@@ -54,8 +55,13 @@ const BLOCK_RULES = [
|
|
|
54
55
|
];
|
|
55
56
|
/** 图片渲染串:`` / `![]()`,富文本内嵌图与纯 image 消息同形。 */
|
|
56
57
|
const IMAGE_MD_RE = /!\[(?:image)?\]\([^)]*\)/g;
|
|
57
|
-
/**
|
|
58
|
-
|
|
58
|
+
/**
|
|
59
|
+
* 合并转发:只剥标签、保留内部文本(子消息文本有语义)。
|
|
60
|
+
* `@larksuite/channel@0.4.1+` 在子消息抓取重试耗尽后渲染
|
|
61
|
+
* `<forwarded_messages status="fetch_failed"/>`(带属性、自闭合)——属性段必须
|
|
62
|
+
* 一起吃掉,否则落到「未知标签」分支,被判为 unsupported 并污染排障日志。
|
|
63
|
+
*/
|
|
64
|
+
const FORWARDED_TAG_RE = /<\/?forwarded_messages(?:\s[^<>]*)?\/?>/g;
|
|
59
65
|
/** 提及标签:保留内部显示名,不作为占位符上报。 */
|
|
60
66
|
const AT_TAG_RE = /<at(?:\s[^<>]*)?>([\s\S]*?)<\/at>/g;
|
|
61
67
|
/** converter 的降级态(缺 key / 未识别类型 —— 都是纯噪声)。 */
|
|
@@ -63,9 +69,13 @@ const BRACKET_FALLBACK_RE = /\[(?:unsupported message|rich text message|interact
|
|
|
63
69
|
/**
|
|
64
70
|
* 未知标签兜底:只在「有属性」或「成对闭合」时才剥离。
|
|
65
71
|
* 要求有属性可显著降低误伤——用户正文里的 `<3`、`a < b`、`<div>` 不会被吃掉。
|
|
72
|
+
*
|
|
73
|
+
* 成对标签**只剥标签、保留内文本**(与 `AT_TAG_RE`、`FORWARDED_TAG_RE` 同口径):
|
|
74
|
+
* 内文本是用户打出来的正文(`<b>重点</b>`、`<info>disk full</info>`),整块吃掉
|
|
75
|
+
* 会让 coding agent 拿到残缺输入,而用户完全无从察觉。
|
|
66
76
|
*/
|
|
67
77
|
const UNKNOWN_SELF_CLOSING_RE = /<([a-z][a-z0-9_]*)\s[^<>]*\/>/g;
|
|
68
|
-
const UNKNOWN_PAIRED_RE = /<([a-z][a-z0-9_]*)(?:\s[^<>]*)?>[\s\S]
|
|
78
|
+
const UNKNOWN_PAIRED_RE = /<([a-z][a-z0-9_]*)(?:\s[^<>]*)?>([\s\S]*?)<\/\1>/g;
|
|
69
79
|
/** 剥离后遗留的连续空行折叠为单空行(属剥离产物,不算对用户文本加工)。 */
|
|
70
80
|
function collapseBlankLines(text) {
|
|
71
81
|
return text.replace(/\n{3,}/g, '\n\n');
|
|
@@ -122,17 +132,17 @@ export function stripPlaceholders(raw) {
|
|
|
122
132
|
return '';
|
|
123
133
|
});
|
|
124
134
|
// 8. 未知标签兜底(未来 SDK 新增类型):剥离 + 记录 tag 名。
|
|
125
|
-
working = working.replace(UNKNOWN_SELF_CLOSING_RE, (
|
|
135
|
+
working = working.replace(UNKNOWN_SELF_CLOSING_RE, (_match, tag) => {
|
|
126
136
|
hit('unknown');
|
|
127
137
|
if (!unknownTags.includes(tag))
|
|
128
138
|
unknownTags.push(tag);
|
|
129
139
|
return '';
|
|
130
140
|
});
|
|
131
|
-
working = working.replace(UNKNOWN_PAIRED_RE, (
|
|
141
|
+
working = working.replace(UNKNOWN_PAIRED_RE, (_match, tag, inner) => {
|
|
132
142
|
hit('unknown');
|
|
133
143
|
if (!unknownTags.includes(tag))
|
|
134
144
|
unknownTags.push(tag);
|
|
135
|
-
return
|
|
145
|
+
return inner;
|
|
136
146
|
});
|
|
137
147
|
// 9. 放回协议块。
|
|
138
148
|
if (protectedBlocks.length > 0) {
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { ClaudeSessionReader, CodexSessionReader, OpencodeSessionReader, PiSessi
|
|
|
12
12
|
import { CommandRouter, isImmediateAction, DIRECT_RETURN_CMDS, } from './router/index.js';
|
|
13
13
|
import { dispatchOrderExecForQueue } from './router/order-exec-dispatch.js';
|
|
14
14
|
import { buildCardActionFullValue } from './router/card-action-payload.js';
|
|
15
|
+
import { actionFeedbackText } from './router/card-action-feedback.js';
|
|
15
16
|
import { Bridge } from './bridge/index.js';
|
|
16
17
|
import { initLogger, getLogger } from './logger/index.js';
|
|
17
18
|
import { StartupContactStore, sendStartupHello } from './startup-contact.js';
|
|
@@ -94,9 +95,11 @@ async function initializeCliAndConfig() {
|
|
|
94
95
|
return { config, configDir, logger, instanceLock };
|
|
95
96
|
}
|
|
96
97
|
/** Acquire instance lock and setup global exception handlers. */
|
|
97
|
-
function setupInstanceLockAndHandlers(instanceLock, logger, configDir) {
|
|
98
|
+
async function setupInstanceLockAndHandlers(instanceLock, logger, configDir) {
|
|
98
99
|
try {
|
|
99
|
-
|
|
100
|
+
// acquire 是异步的(身份校验走 platform/identity,win32 侧是异步 CIM 查询):
|
|
101
|
+
// 必须 await,否则会在校验完成前就放行第二条实例。
|
|
102
|
+
await instanceLock.acquire();
|
|
100
103
|
}
|
|
101
104
|
catch (err) {
|
|
102
105
|
if (err instanceof InstanceAlreadyRunningError) {
|
|
@@ -245,6 +248,7 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
245
248
|
? acpConf.turnIdleTimeoutMinutes * 60_000
|
|
246
249
|
: undefined,
|
|
247
250
|
model: kimiConf?.model ?? 'kimi-code/k3',
|
|
251
|
+
thinkingEffort: kimiConf?.thinkingEffort,
|
|
248
252
|
permissionMode: kimiConf?.permissionMode ?? 'manual',
|
|
249
253
|
});
|
|
250
254
|
});
|
|
@@ -307,7 +311,13 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
307
311
|
// 入队时刻(T0)快照 workspace + agent/session 绑定,语义与旧路径一致
|
|
308
312
|
// (排队期间 /cd、/config 不再导致语义漂移)。
|
|
309
313
|
const messageId = turn.messageIds[turn.messageIds.length - 1] ?? '';
|
|
310
|
-
|
|
314
|
+
// turnMessageIds:终态收尾要覆盖本轮每一条消息(逐条挂的 Typing 得逐条撤)
|
|
315
|
+
const ctx = {
|
|
316
|
+
userId: turn.userId,
|
|
317
|
+
chatId: turn.chatId,
|
|
318
|
+
messageId,
|
|
319
|
+
turnMessageIds: turn.messageIds,
|
|
320
|
+
};
|
|
311
321
|
let workspace = sessionStore.getCwd(turn.userId) ?? '';
|
|
312
322
|
if (!workspace && workspaceStore) {
|
|
313
323
|
const workspaces = workspaceStore.list();
|
|
@@ -587,14 +597,26 @@ function setupMessageHandlers(connector, router, bridge, sessionStore, workspace
|
|
|
587
597
|
}).catch((err) => logger.error('[control] order.exec dispatch failed:', err));
|
|
588
598
|
return;
|
|
589
599
|
}
|
|
600
|
+
// B7:非直返命令到这里已经没有回调响应可回(enqueue* 是 fire-and-forget),
|
|
601
|
+
// handler 返回的 toast 必须显式转成持久文本,否则失败毫无反馈。
|
|
602
|
+
const forwardActionFeedback = (res) => {
|
|
603
|
+
const text = actionFeedbackText(res);
|
|
604
|
+
if (!text)
|
|
605
|
+
return;
|
|
606
|
+
void bridge
|
|
607
|
+
.sendResult({ text }, { userId, chatId, messageId })
|
|
608
|
+
.catch((err) => logger.error('[control] card action feedback failed:', err));
|
|
609
|
+
};
|
|
590
610
|
if (isImmediate) {
|
|
591
611
|
bridge.enqueueImmediate(workspace, async () => {
|
|
592
|
-
await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
612
|
+
const res = await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
613
|
+
forwardActionFeedback(res);
|
|
593
614
|
});
|
|
594
615
|
}
|
|
595
616
|
else {
|
|
596
617
|
bridge.enqueue(workspace, async () => {
|
|
597
|
-
await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
618
|
+
const res = await router.handleCardAction(fullValue, { userId, chatId, messageId });
|
|
619
|
+
forwardActionFeedback(res);
|
|
598
620
|
}, {
|
|
599
621
|
taskMeta: {
|
|
600
622
|
userId,
|
|
@@ -651,7 +673,7 @@ async function main() {
|
|
|
651
673
|
// Restart handoff: if spawned as a /restart replacement, wait for the old
|
|
652
674
|
// lark-remote to die (and release the instance lock) before acquiring it.
|
|
653
675
|
await waitForPreviousInstance();
|
|
654
|
-
setupInstanceLockAndHandlers(instanceLock, logger, configDir);
|
|
676
|
+
await setupInstanceLockAndHandlers(instanceLock, logger, configDir);
|
|
655
677
|
// 阻止系统休眠(macOS caffeinate / Windows SetThreadExecutionState):lark-remote
|
|
656
678
|
// 的场景是人不在电脑前远程使用,系统休眠即失联。恒开启、无配置开关;helper 进程
|
|
657
679
|
// 绑定本进程生命周期(caffeinate -w / powershell 轮询父 pid),崩溃也能自清理;
|
package/dist/instance-lock.d.ts
CHANGED
|
@@ -1,30 +1,46 @@
|
|
|
1
|
+
import { type IdentityVerdict } from './platform/identity.js';
|
|
1
2
|
export declare class InstanceAlreadyRunningError extends Error {
|
|
2
3
|
readonly pid: number;
|
|
3
4
|
readonly lockPath: string;
|
|
4
5
|
constructor(pid: number, lockPath: string);
|
|
5
6
|
}
|
|
7
|
+
/** 锁持有者身份判定(测试注入点);默认 = platform/identity 的真实查询。 */
|
|
8
|
+
export type LockIdentityVerifier = (pid: number, expectedBinary: string) => Promise<IdentityVerdict>;
|
|
9
|
+
export interface InstanceLockOptions {
|
|
10
|
+
/**
|
|
11
|
+
* 注入身份判定(测试用)。缺省走真实查询;win32 上一次查询要拉起
|
|
12
|
+
* PowerShell(冷启动约 1s),但只在「锁文件已存在」(陈旧锁/取锁竞态)
|
|
13
|
+
* 这条路径上发生,不在正常启动路径上。
|
|
14
|
+
*/
|
|
15
|
+
verifyIdentity?: LockIdentityVerifier;
|
|
16
|
+
}
|
|
6
17
|
export declare class InstanceLock {
|
|
7
18
|
private readonly lockPath;
|
|
19
|
+
private readonly verifyIdentity;
|
|
20
|
+
constructor(lockPath: string, opts?: InstanceLockOptions);
|
|
8
21
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Default implementation checks the process name via `ps` when available.
|
|
22
|
+
* 取锁。**异步**是接口的一部分:身份校验走 platform/identity(win32 侧是
|
|
23
|
+
* 异步 CIM 查询),调用方必须 await——漏 await 会在校验完成前就放行第二条
|
|
24
|
+
* 实例(这正是锁要防的事)。
|
|
13
25
|
*/
|
|
14
|
-
|
|
15
|
-
constructor(lockPath: string, opts?: {
|
|
16
|
-
isProcessRunning?: (pid: number, expectedComm: string | undefined) => boolean;
|
|
17
|
-
});
|
|
18
|
-
acquire(): void;
|
|
26
|
+
acquire(): Promise<void>;
|
|
19
27
|
release(): void;
|
|
20
28
|
registerExitHandlers(): void;
|
|
21
29
|
/**
|
|
22
|
-
* Lock file stores `pid\
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the stale pid.
|
|
30
|
+
* Lock file stores `pid\nbinaryName`, the owning process's executable name
|
|
31
|
+
* captured at acquire time. `binaryName(process.execPath)` 是唯一在
|
|
32
|
+
* posix/win32 都稳定可比的口径:win32 没有 ps,而 execPath(`C:\...\node.exe`)
|
|
33
|
+
* 与 CIM CommandLine 首 token 归一后必然一致(bun/node 垫片场景亦然)。
|
|
27
34
|
*/
|
|
28
35
|
private lockContents;
|
|
29
|
-
private
|
|
36
|
+
private readPidAndName;
|
|
37
|
+
/**
|
|
38
|
+
* 锁是否仍由「我们的」实例持有(fail-open 三态):
|
|
39
|
+
* - 进程不存在 → false(陈旧)
|
|
40
|
+
* - 无记录名(老/损坏文件)→ false(无法验证,与迁移前同口径按陈旧处理)
|
|
41
|
+
* - 身份 match → true(锁定实例还在跑,拒绝取锁)
|
|
42
|
+
* - 身份 mismatch → false(pid 已复用给无关进程,陈旧可接管)
|
|
43
|
+
* - 身份 unknown → true(查不到身份,宁可拒绝也不 clobber)
|
|
44
|
+
*/
|
|
45
|
+
private isHeldByLiveInstance;
|
|
30
46
|
}
|