lark-remote 0.5.18 → 0.5.19
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 +13 -0
- package/dist/bridge/index.d.ts +59 -2
- package/dist/bridge/index.js +206 -17
- package/dist/card/bash-card-session.js +2 -0
- package/dist/card/card-session.d.ts +17 -0
- package/dist/card/card-session.js +35 -0
- package/dist/card/run-card-session.js +2 -0
- package/dist/card/run-renderer.js +43 -9
- package/dist/card/run-state.d.ts +34 -7
- package/dist/card/run-state.js +89 -0
- package/dist/message-handlers.js +3 -1
- package/dist/router/index.js +3 -2
- package/dist/runner/dsh/client.d.ts +12 -5
- package/dist/runner/dsh/client.js +21 -11
- package/dist/runner/dsh/runner.d.ts +8 -1
- package/dist/runner/dsh/runner.js +16 -0
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/types.d.ts +23 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.19] - 2026-11-03
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- **run 结束后卡片不再停在「运行中」**:卡片上的最后一帧只是交给飞书通道的节流队列,`controller.update()` 返回时 `message.patch` 还没发出;流若在下一次节流到期前关闭,这一帧可能永远不出门——run 早就结束,卡上仍画着运行态、底部「⏹ 停止」按钮一直挂着,点下去也不会生效。现在终态之后补一次整卡 patch 把它钉死(幂等替换,一次 run 只多一次请求,运行卡与后台任务卡共用同一份实现),并在日志里区分「帧没发出去」与「发出去了飞书没刷新」
|
|
13
|
+
- **卡片上的「⏹ 停止」按钮不再因为按钮值过期而空转**:停止动作携带的是本次 run 的 id,一旦这个值缺失或过期,点击就匹配不到任何 run。现在 id 精确匹配仍是权威路径,匹配不上时只在「被点的这张卡就是当前活跃的 run 卡」的前提下按卡片身份兜底;历史卡片和 `/active` 这类汇总卡片依旧不接受兜底,防止借一张旧卡停掉新 run。匹配失败时会把请求的 run id、卡片标识与当前活跃 run 记入日志,便于复盘
|
|
14
|
+
- **终态卡的「后台任务」小节跟着任务真实状态更新**:这一小节原本是结果那一刻的名单快照,之后运行卡停止接收事件、卡片定稿成静态内容,任务后来结束没有任何更新来源——卡上一直写「运行中」并留着必然失败的停止按钮,用户只能靠点一下看它报错才知道任务没了。现在收尾后按 run 订阅整份任务名单,只有名单变化才更新同一张卡片;已结束的任务显式标出并撤掉停止按钮,计数按 运行中 / 状态未知 / 已结束 分列;订阅中断而任务尚未结算时标「状态未知」(监控中断不等于任务结束);更新失败只退订,绝不因此补发新卡刷屏
|
|
15
|
+
|
|
16
|
+
### 维护
|
|
17
|
+
|
|
18
|
+
- **Windows 的参数直传分支补上用例,CI 两个 job 都跑覆盖率**:shell 后端此前只测了拼接命令行那条路径,argv 原样透传(不拼 shell 字符串)、找不到 Git Bash 时报专用错误而不是泛化执行原命令、以及强制隐藏控制台窗口都没有守卫。顺带让 CI 的 Linux 与 Windows 两个 job 都以覆盖率方式运行测试。无行为变化
|
|
19
|
+
- **解释故障为什么存在的注释改为自描述**:若干注释用「某次实机反馈 + 隔了多久还能点」来论证改动的必要性,这类叙述对读者既不是可复现步骤也不是阈值,改成直接描述故障形态与后果;通道契约、根因和退订条件原样保留。无行为变化
|
|
20
|
+
|
|
8
21
|
## [0.5.18] - 2026-11-02
|
|
9
22
|
|
|
10
23
|
### 修复
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -172,6 +172,20 @@ export declare class Bridge {
|
|
|
172
172
|
*/
|
|
173
173
|
private runCardSnapshots;
|
|
174
174
|
private static readonly RUN_CARD_SNAPSHOT_MAX;
|
|
175
|
+
/**
|
|
176
|
+
* 终态卡后台任务订阅(runId → 中止句柄)。
|
|
177
|
+
*
|
|
178
|
+
* 与卡片快照同生命周期:快照被淘汰、任务全结束、patch 失败时退订。上限天然
|
|
179
|
+
* 受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡最多一条流)。
|
|
180
|
+
*/
|
|
181
|
+
private jobWatches;
|
|
182
|
+
/**
|
|
183
|
+
* 同一张卡的 patch 串行链(runId → 链尾)。
|
|
184
|
+
*
|
|
185
|
+
* 「停止按钮」和「订阅帧」都会改同一张终态卡:不串行化时两条 PATCH 并发,
|
|
186
|
+
* 后发先至的那条会把卡片写回旧内容(任务又变回「运行中」)。
|
|
187
|
+
*/
|
|
188
|
+
private jobCardPatches;
|
|
175
189
|
/**
|
|
176
190
|
* Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
|
|
177
191
|
* queue and may run in parallel with claude runs / other bash runs in the same
|
|
@@ -344,6 +358,8 @@ export declare class Bridge {
|
|
|
344
358
|
userId: string;
|
|
345
359
|
chatId: string;
|
|
346
360
|
runId?: string;
|
|
361
|
+
/** Card callback's server-provided message id. */
|
|
362
|
+
cardMessageId?: string;
|
|
347
363
|
/** Restrict the match to a single cwd (queue.immediate). When
|
|
348
364
|
* omitted, all workspaces are candidates (used by /stop, /t, and card stop button which don't know which cwd to target). */
|
|
349
365
|
cwd?: string;
|
|
@@ -360,8 +376,20 @@ export declare class Bridge {
|
|
|
360
376
|
userId: string;
|
|
361
377
|
chatId: string;
|
|
362
378
|
runId?: string;
|
|
379
|
+
/**
|
|
380
|
+
* Card callback's server-provided message id. Used only as a narrow fallback
|
|
381
|
+
* when the behavior-value runId is missing or stale: the clicked card must
|
|
382
|
+
* be the one active run's card. A dashboard or historical card never matches.
|
|
383
|
+
*/
|
|
384
|
+
cardMessageId?: string;
|
|
363
385
|
cwd?: string;
|
|
364
386
|
}): Promise<InterruptOutcome>;
|
|
387
|
+
/**
|
|
388
|
+
* Resolve a scoped card click by card identity. Empty input or multiple
|
|
389
|
+
* matches fail closed; production message ids are unique, but refusing an
|
|
390
|
+
* ambiguous stop is safer than killing several workspaces.
|
|
391
|
+
*/
|
|
392
|
+
private activeRunsForCard;
|
|
365
393
|
/**
|
|
366
394
|
* 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
|
|
367
395
|
* 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
|
|
@@ -370,8 +398,15 @@ export declare class Bridge {
|
|
|
370
398
|
* 出现「停第一条就 return」的漏停。
|
|
371
399
|
*/
|
|
372
400
|
private interruptActiveRun;
|
|
373
|
-
/**
|
|
374
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Send a result message to the user (§7). Returns true on success, false on failure.
|
|
403
|
+
*
|
|
404
|
+
* `opts.onCardSent` 回传卡片的 messageId:终态卡收尾后还要靠它接收后台任务
|
|
405
|
+
* 更新(流式卡从 `cardSession.cardMessageId` 取,兜底发送只能从这里拿)。
|
|
406
|
+
*/
|
|
407
|
+
sendResult(result: CommandResult, ctx: CommandContext, opts?: {
|
|
408
|
+
onCardSent?: (messageId: string) => void;
|
|
409
|
+
}): Promise<boolean>;
|
|
375
410
|
/**
|
|
376
411
|
* Deliver a final answer that cannot fit in the run card as a Markdown file
|
|
377
412
|
* attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
|
|
@@ -413,6 +448,8 @@ export declare class Bridge {
|
|
|
413
448
|
* 与 activeRuns 生命周期在同一个地方收口。
|
|
414
449
|
*/
|
|
415
450
|
private rememberRunCardSnapshot;
|
|
451
|
+
/** 退订一张终态卡的后台任务名单(幂等)。 */
|
|
452
|
+
private stopJobWatch;
|
|
416
453
|
/**
|
|
417
454
|
* 停止终态卡上的一个后台任务。
|
|
418
455
|
*
|
|
@@ -422,6 +459,26 @@ export declare class Bridge {
|
|
|
422
459
|
* 会留在卡片上而任务早已结束。
|
|
423
460
|
*/
|
|
424
461
|
killBackgroundJob(runId: string, jobId: string, ctx: CommandContext): Promise<boolean>;
|
|
462
|
+
/**
|
|
463
|
+
* 让终态卡的后台任务小节跟着宿主继续更新。
|
|
464
|
+
*
|
|
465
|
+
* run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
|
|
466
|
+
* 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
|
|
467
|
+
* 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
|
|
468
|
+
* 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
|
|
469
|
+
*/
|
|
470
|
+
private startBackgroundJobWatch;
|
|
471
|
+
/** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
|
|
472
|
+
private consumeJobRoster;
|
|
473
|
+
/** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
|
|
474
|
+
private markJobsUnknown;
|
|
475
|
+
/**
|
|
476
|
+
* 一张终态卡的 PATCH 串行链。
|
|
477
|
+
*
|
|
478
|
+
* 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
|
|
479
|
+
* 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
|
|
480
|
+
*/
|
|
481
|
+
private enqueueJobCardPatch;
|
|
425
482
|
/** Send a file to the user via Feishu. */
|
|
426
483
|
sendFile(filePath: string, ctx: CommandContext): Promise<void>;
|
|
427
484
|
/**
|
package/dist/bridge/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { writeFinalAnswerFile, finalAnswerFileFailureCard } from '../card/final-
|
|
|
9
9
|
import { bestEffortUnlink } from '../common/fs.js';
|
|
10
10
|
import { renderBashCard } from '../card/bash-renderer.js';
|
|
11
11
|
import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
|
|
12
|
-
import { isRunActive, isRunTerminal, removeBackgroundJob, } from '../card/run-state.js';
|
|
12
|
+
import { isLiveBackgroundJob, isRunActive, isRunTerminal, markBackgroundJobsUnknown, removeBackgroundJob, syncBackgroundJobs, } from '../card/run-state.js';
|
|
13
13
|
import { buildSessionHistoryCard } from '../card/card-helpers.js';
|
|
14
14
|
import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
|
|
15
15
|
import { enforceCardBudget } from '../card/card-budget.js';
|
|
@@ -194,6 +194,20 @@ export class Bridge {
|
|
|
194
194
|
*/
|
|
195
195
|
runCardSnapshots = new Map();
|
|
196
196
|
static RUN_CARD_SNAPSHOT_MAX = 8;
|
|
197
|
+
/**
|
|
198
|
+
* 终态卡后台任务订阅(runId → 中止句柄)。
|
|
199
|
+
*
|
|
200
|
+
* 与卡片快照同生命周期:快照被淘汰、任务全结束、patch 失败时退订。上限天然
|
|
201
|
+
* 受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡最多一条流)。
|
|
202
|
+
*/
|
|
203
|
+
jobWatches = new Map();
|
|
204
|
+
/**
|
|
205
|
+
* 同一张卡的 patch 串行链(runId → 链尾)。
|
|
206
|
+
*
|
|
207
|
+
* 「停止按钮」和「订阅帧」都会改同一张终态卡:不串行化时两条 PATCH 并发,
|
|
208
|
+
* 后发先至的那条会把卡片写回旧内容(任务又变回「运行中」)。
|
|
209
|
+
*/
|
|
210
|
+
jobCardPatches = new Map();
|
|
197
211
|
/**
|
|
198
212
|
* Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
|
|
199
213
|
* queue and may run in parallel with claude runs / other bash runs in the same
|
|
@@ -664,15 +678,31 @@ export class Bridge {
|
|
|
664
678
|
// 并行跑;旧实现停在「第一条命中」就 return —— 两个 workspace 并行时
|
|
665
679
|
// `/stop` 只停一个却回「已停止」,另一个无声继续。
|
|
666
680
|
// 限定 cwd(queue.immediate)或带 runId(停止卡按钮)时仍只有一条命中。
|
|
667
|
-
const
|
|
681
|
+
const ownedActiveRuns = [];
|
|
668
682
|
for (const [cwd, active] of this.activeRuns) {
|
|
669
683
|
if (input.cwd !== undefined && cwd !== input.cwd)
|
|
670
684
|
continue;
|
|
671
685
|
if (active.userId !== input.userId || active.chatId !== input.chatId)
|
|
672
686
|
continue;
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
687
|
+
ownedActiveRuns.push([cwd, active]);
|
|
688
|
+
}
|
|
689
|
+
// Card stop prefers the authoritative runId. If the click is scoped to a
|
|
690
|
+
// run card but that value is stale/absent, accept only the active run whose
|
|
691
|
+
// server-provided card messageId is exactly the clicked card. This repairs
|
|
692
|
+
// callbacks that lose behavior-value without ever stopping a historical or
|
|
693
|
+
// dashboard card against a newer run.
|
|
694
|
+
let activeTargets;
|
|
695
|
+
if (input.runId) {
|
|
696
|
+
activeTargets = ownedActiveRuns.filter(([, active]) => active.runId === input.runId);
|
|
697
|
+
if (activeTargets.length === 0) {
|
|
698
|
+
activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
else if (input.cardMessageId) {
|
|
702
|
+
activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
activeTargets = ownedActiveRuns;
|
|
676
706
|
}
|
|
677
707
|
let stoppedCount = 0;
|
|
678
708
|
let agentKind;
|
|
@@ -695,15 +725,25 @@ export class Bridge {
|
|
|
695
725
|
// `!` 命令也要停——旧实现命中 claude run 就 return,这些 bash 会被漏掉。
|
|
696
726
|
// 带范围的中断(停止卡 runId、queue.immediate 的 cwd)保持旧语义:只有
|
|
697
727
|
// 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
|
|
698
|
-
const
|
|
728
|
+
const cardScoped = input.runId !== undefined || input.cardMessageId !== undefined;
|
|
729
|
+
const includeBashRuns = (input.runId === undefined && input.cardMessageId === undefined && input.cwd === undefined) ||
|
|
730
|
+
activeTargets.length === 0;
|
|
699
731
|
const bashTargets = includeBashRuns
|
|
700
732
|
? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
|
|
701
733
|
if (input.cwd !== undefined && b.cwd !== input.cwd)
|
|
702
734
|
return false;
|
|
703
735
|
if (b.userId !== input.userId || b.chatId !== input.chatId)
|
|
704
736
|
return false;
|
|
705
|
-
if (input.runId
|
|
706
|
-
|
|
737
|
+
if (input.runId) {
|
|
738
|
+
if (runId === input.runId)
|
|
739
|
+
return true;
|
|
740
|
+
// Scoped card clicks may fall back only through their own card id.
|
|
741
|
+
if (cardScoped)
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
if (input.cardMessageId) {
|
|
745
|
+
return b.cardSession.cardMessageId === input.cardMessageId;
|
|
746
|
+
}
|
|
707
747
|
return true;
|
|
708
748
|
})
|
|
709
749
|
: [];
|
|
@@ -719,7 +759,10 @@ export class Bridge {
|
|
|
719
759
|
stoppedCount++;
|
|
720
760
|
}
|
|
721
761
|
if (stoppedCount === 0) {
|
|
722
|
-
getLogger().
|
|
762
|
+
getLogger().warn(`[lark-remote] interrupt miss userId=${input.userId} ` +
|
|
763
|
+
`requestedRunId=${input.runId ?? '(none)'} ` +
|
|
764
|
+
`cardMessageId=${input.cardMessageId ?? '(none)'} ` +
|
|
765
|
+
`active=${ownedActiveRuns.map(([, active]) => active.runId).join(',') || '(none)'}`);
|
|
723
766
|
return { stopped: false };
|
|
724
767
|
}
|
|
725
768
|
return {
|
|
@@ -729,6 +772,26 @@ export class Bridge {
|
|
|
729
772
|
...(stop ? { stop } : {}),
|
|
730
773
|
};
|
|
731
774
|
}
|
|
775
|
+
/**
|
|
776
|
+
* Resolve a scoped card click by card identity. Empty input or multiple
|
|
777
|
+
* matches fail closed; production message ids are unique, but refusing an
|
|
778
|
+
* ambiguous stop is safer than killing several workspaces.
|
|
779
|
+
*/
|
|
780
|
+
activeRunsForCard(ownedActiveRuns, cardMessageId) {
|
|
781
|
+
if (!cardMessageId)
|
|
782
|
+
return [];
|
|
783
|
+
const matches = ownedActiveRuns.filter(([, active]) => active.session.cardMessageId === cardMessageId);
|
|
784
|
+
if (matches.length === 0)
|
|
785
|
+
return [];
|
|
786
|
+
if (matches.length > 1) {
|
|
787
|
+
getLogger().warn(`[lark-remote] interrupt ambiguous cardMessageId=${cardMessageId} ` +
|
|
788
|
+
`active=${matches.map(([, active]) => active.runId).join(',')}`);
|
|
789
|
+
return [];
|
|
790
|
+
}
|
|
791
|
+
getLogger().warn(`[lark-remote] interrupt using active card fallback ` +
|
|
792
|
+
`runId=${matches[0][1].runId} cardMessageId=${cardMessageId}`);
|
|
793
|
+
return matches;
|
|
794
|
+
}
|
|
732
795
|
/**
|
|
733
796
|
* 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
|
|
734
797
|
* 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
|
|
@@ -803,8 +866,13 @@ export class Bridge {
|
|
|
803
866
|
getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
|
|
804
867
|
return stop;
|
|
805
868
|
}
|
|
806
|
-
/**
|
|
807
|
-
|
|
869
|
+
/**
|
|
870
|
+
* Send a result message to the user (§7). Returns true on success, false on failure.
|
|
871
|
+
*
|
|
872
|
+
* `opts.onCardSent` 回传卡片的 messageId:终态卡收尾后还要靠它接收后台任务
|
|
873
|
+
* 更新(流式卡从 `cardSession.cardMessageId` 取,兜底发送只能从这里拿)。
|
|
874
|
+
*/
|
|
875
|
+
async sendResult(result, ctx, opts) {
|
|
808
876
|
try {
|
|
809
877
|
if (result.card) {
|
|
810
878
|
// 静态卡片体积保护:确保卡片不超过飞书大小限制
|
|
@@ -813,7 +881,8 @@ export class Bridge {
|
|
|
813
881
|
getLogger().info(`[lark-remote] card size exceeded limit, applied budget enforcement: reason=${reason} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter}`);
|
|
814
882
|
}
|
|
815
883
|
getLogger().debug(`[lark-remote] sendResult kind=card chatId=${ctx.chatId}`);
|
|
816
|
-
await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
|
|
884
|
+
const messageId = await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
|
|
885
|
+
opts?.onCardSent?.(messageId);
|
|
817
886
|
return true;
|
|
818
887
|
}
|
|
819
888
|
else if (result.markdown) {
|
|
@@ -925,14 +994,30 @@ export class Bridge {
|
|
|
925
994
|
* 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
|
|
926
995
|
* 与 activeRuns 生命周期在同一个地方收口。
|
|
927
996
|
*/
|
|
928
|
-
rememberRunCardSnapshot(runId, state, options, cwd, agentKind) {
|
|
929
|
-
|
|
997
|
+
rememberRunCardSnapshot(runId, state, options, cwd, agentKind, messageId) {
|
|
998
|
+
const snapshot = {
|
|
999
|
+
state,
|
|
1000
|
+
options,
|
|
1001
|
+
cwd,
|
|
1002
|
+
agentKind,
|
|
1003
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
1004
|
+
};
|
|
1005
|
+
this.runCardSnapshots.set(runId, snapshot);
|
|
930
1006
|
while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
|
|
931
1007
|
const oldest = this.runCardSnapshots.keys().next().value;
|
|
932
1008
|
if (oldest === undefined)
|
|
933
1009
|
break;
|
|
934
1010
|
this.runCardSnapshots.delete(oldest);
|
|
1011
|
+
// 卡片已经不在窗口里:继续订阅只是往一个没人看的 messageId 打 PATCH。
|
|
1012
|
+
this.stopJobWatch(oldest);
|
|
935
1013
|
}
|
|
1014
|
+
return snapshot;
|
|
1015
|
+
}
|
|
1016
|
+
/** 退订一张终态卡的后台任务名单(幂等)。 */
|
|
1017
|
+
stopJobWatch(runId) {
|
|
1018
|
+
this.jobWatches.get(runId)?.abort();
|
|
1019
|
+
this.jobWatches.delete(runId);
|
|
1020
|
+
this.jobCardPatches.delete(runId);
|
|
936
1021
|
}
|
|
937
1022
|
/**
|
|
938
1023
|
* 停止终态卡上的一个后台任务。
|
|
@@ -954,9 +1039,105 @@ export class Bridge {
|
|
|
954
1039
|
if (!killed)
|
|
955
1040
|
return false;
|
|
956
1041
|
snapshot.state = removeBackgroundJob(snapshot.state, jobId);
|
|
957
|
-
|
|
1042
|
+
// 与订阅帧共用一条串行链:两条 PATCH 并发时,后发先至的那条会把卡片写回
|
|
1043
|
+
// 旧内容(刚停掉的任务又显示成「运行中」)。
|
|
1044
|
+
await this.enqueueJobCardPatch(runId, async () => {
|
|
1045
|
+
await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
|
|
1046
|
+
return true;
|
|
1047
|
+
});
|
|
1048
|
+
// 卡片上已经没有在跑的任务:不必再等宿主推下一帧,直接退订。
|
|
1049
|
+
if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
|
|
1050
|
+
this.stopJobWatch(runId);
|
|
1051
|
+
}
|
|
958
1052
|
return true;
|
|
959
1053
|
}
|
|
1054
|
+
/**
|
|
1055
|
+
* 让终态卡的后台任务小节跟着宿主继续更新。
|
|
1056
|
+
*
|
|
1057
|
+
* run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
|
|
1058
|
+
* 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
|
|
1059
|
+
* 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
|
|
1060
|
+
* 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
|
|
1061
|
+
*/
|
|
1062
|
+
startBackgroundJobWatch(runId, snapshot, runner) {
|
|
1063
|
+
const live = (snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob);
|
|
1064
|
+
if (!live)
|
|
1065
|
+
return;
|
|
1066
|
+
const sessionId = snapshot.state.sessionId;
|
|
1067
|
+
const messageId = snapshot.messageId;
|
|
1068
|
+
const watchJobs = runner.watchJobs;
|
|
1069
|
+
if (!sessionId || !messageId || typeof watchJobs !== 'function') {
|
|
1070
|
+
// 没有卡 id 就没有 patch 的落点:保留收尾快照,不另发一张卡。
|
|
1071
|
+
getLogger().info(`[lark-remote] background jobs stay on the turn snapshot runId=${runId} ` +
|
|
1072
|
+
`(sessionId=${sessionId ?? '-'} messageId=${messageId ?? '-'} watchable=${typeof watchJobs === 'function'})`);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const controller = new AbortController();
|
|
1076
|
+
this.jobWatches.set(runId, controller);
|
|
1077
|
+
void this.consumeJobRoster(runId, snapshot, controller, (signal) => watchJobs.call(runner, sessionId, signal));
|
|
1078
|
+
}
|
|
1079
|
+
/** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
|
|
1080
|
+
async consumeJobRoster(runId, snapshot, controller, watch) {
|
|
1081
|
+
const messageId = snapshot.messageId;
|
|
1082
|
+
if (!messageId)
|
|
1083
|
+
return;
|
|
1084
|
+
let allEnded = false;
|
|
1085
|
+
try {
|
|
1086
|
+
for await (const roster of watch(controller.signal)) {
|
|
1087
|
+
const next = syncBackgroundJobs(snapshot.state, roster);
|
|
1088
|
+
// 名单没变化就不打 PATCH:宿主可能重复推同一份。
|
|
1089
|
+
if (next === snapshot.state)
|
|
1090
|
+
continue;
|
|
1091
|
+
snapshot.state = next;
|
|
1092
|
+
const ok = await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
|
|
1093
|
+
if (!ok) {
|
|
1094
|
+
getLogger().warn(`[lark-remote] background job card update failed, stop watching runId=${runId}`);
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
|
|
1098
|
+
allEnded = true;
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
if (!controller.signal.aborted) {
|
|
1105
|
+
getLogger().warn(`[lark-remote] background job watch failed runId=${runId}: ${errorMessage(err)}`);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
finally {
|
|
1109
|
+
const externallyAborted = controller.signal.aborted;
|
|
1110
|
+
controller.abort();
|
|
1111
|
+
// 流断了而任务没结算:监控中断 ≠ 任务结束,标成未知而不是继续写「运行中」。
|
|
1112
|
+
if (!allEnded && !externallyAborted)
|
|
1113
|
+
await this.markJobsUnknown(runId, snapshot);
|
|
1114
|
+
this.jobWatches.delete(runId);
|
|
1115
|
+
this.jobCardPatches.delete(runId);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
/** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
|
|
1119
|
+
async markJobsUnknown(runId, snapshot) {
|
|
1120
|
+
const next = markBackgroundJobsUnknown(snapshot.state);
|
|
1121
|
+
if (next === snapshot.state)
|
|
1122
|
+
return;
|
|
1123
|
+
snapshot.state = next;
|
|
1124
|
+
const messageId = snapshot.messageId;
|
|
1125
|
+
if (!messageId)
|
|
1126
|
+
return;
|
|
1127
|
+
await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
|
|
1128
|
+
}
|
|
1129
|
+
/**
|
|
1130
|
+
* 一张终态卡的 PATCH 串行链。
|
|
1131
|
+
*
|
|
1132
|
+
* 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
|
|
1133
|
+
* 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
|
|
1134
|
+
*/
|
|
1135
|
+
enqueueJobCardPatch(runId, task) {
|
|
1136
|
+
const previous = this.jobCardPatches.get(runId) ?? Promise.resolve();
|
|
1137
|
+
const next = previous.then(task, task);
|
|
1138
|
+
this.jobCardPatches.set(runId, next.then(() => undefined, () => undefined));
|
|
1139
|
+
return next;
|
|
1140
|
+
}
|
|
960
1141
|
/** Send a file to the user via Feishu. */
|
|
961
1142
|
async sendFile(filePath, ctx) {
|
|
962
1143
|
try {
|
|
@@ -1597,10 +1778,15 @@ export class Bridge {
|
|
|
1597
1778
|
this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
|
|
1598
1779
|
};
|
|
1599
1780
|
const finalRender = renderRunCardDetailed(cardSession.currentState, finalRenderOptions);
|
|
1600
|
-
this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
|
|
1781
|
+
const snapshot = this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind, cardSession.cardMessageId);
|
|
1601
1782
|
let runCardSent = false;
|
|
1602
1783
|
if (settleResult === 'unsent') {
|
|
1603
|
-
const sent = await this.sendResult({ card: finalRender.card }, ctx
|
|
1784
|
+
const sent = await this.sendResult({ card: finalRender.card }, ctx, {
|
|
1785
|
+
// 兜底发送的卡也要能接收后台任务更新 —— 流式卡没建起来时这是唯一来源。
|
|
1786
|
+
onCardSent: (messageId) => {
|
|
1787
|
+
snapshot.messageId = messageId;
|
|
1788
|
+
},
|
|
1789
|
+
});
|
|
1604
1790
|
runCardSent = sent;
|
|
1605
1791
|
}
|
|
1606
1792
|
else {
|
|
@@ -1618,6 +1804,9 @@ export class Bridge {
|
|
|
1618
1804
|
}
|
|
1619
1805
|
// Add a terminal-state emoji reaction to the user's original message
|
|
1620
1806
|
this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
|
|
1807
|
+
// 后台任务跨 turn:卡片收尾只是"这一轮的投影结束了",任务本身可能还在跑。
|
|
1808
|
+
// 订阅放在最后:卡片已落地,patch 才有落点。
|
|
1809
|
+
this.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
|
|
1621
1810
|
}
|
|
1622
1811
|
finally {
|
|
1623
1812
|
// cleanup must survive mid-finalize errors. The only unguarded
|
|
@@ -86,6 +86,8 @@ export class BashCardSession extends CardSession {
|
|
|
86
86
|
...(meta.output !== undefined ? { output: capBashOutput(meta.output) } : {}),
|
|
87
87
|
};
|
|
88
88
|
await this.updateCard();
|
|
89
|
+
// 同 run 卡:终态帧必须真的落地,否则停止后卡片仍显示停止按钮。
|
|
90
|
+
await this.confirmTerminalFrame(`runId=${this.state.runId} terminal=${terminal}`);
|
|
89
91
|
this.release();
|
|
90
92
|
}
|
|
91
93
|
/**
|
|
@@ -99,6 +99,9 @@ export declare abstract class CardSession<S, RO = unknown> {
|
|
|
99
99
|
* in-flight flush 完成后该事件无 follow-up render,停留为 stale 中间帧。
|
|
100
100
|
* finally 检查此标志后重新 scheduleFlush,保证 in-flight 期间累积的事件最终被渲染。 */
|
|
101
101
|
private pendingReschedule;
|
|
102
|
+
/** 终态帧是否已由 confirmTerminalFrame 确认落到飞书。settle 的兜底 patch
|
|
103
|
+
* 据此去重:流异常结束时若终态帧已经确认过,再 patch 一次只是幂等重发。 */
|
|
104
|
+
private terminalFrameConfirmed;
|
|
102
105
|
/** 当前 in-flight flush 的 promise(若 flush 正在 await updateCard)。
|
|
103
106
|
* finish() 在终态 patch 前 await 它,保证 in-flight 的 pre-terminal patch 先落地、
|
|
104
107
|
* terminal patch 后落地——否则两条 controller.update 并发无 FIFO,
|
|
@@ -121,5 +124,19 @@ export declare abstract class CardSession<S, RO = unknown> {
|
|
|
121
124
|
protected updateCard(): Promise<void>;
|
|
122
125
|
private waitForControllerReady;
|
|
123
126
|
protected prepareTerminalUpdate(): Promise<void>;
|
|
127
|
+
/**
|
|
128
|
+
* 把终态帧真正钉到飞书上。
|
|
129
|
+
*
|
|
130
|
+
* `controller.update()` 只把帧交给 @larksuite/channel 的 throttle/queue,
|
|
131
|
+
* resolve 时 `message.patch` 还没发出(见 `connector/index.ts` 的
|
|
132
|
+
* patchWithRetry 注释)。流若在下一次 throttle 到期前关闭,这一帧就可能
|
|
133
|
+
* 永远不出门——卡片停在运行态、底部「⏹ 停止」按钮还在:停止其实已经
|
|
134
|
+
* 生效,按钮仍可点,点了是空转。
|
|
135
|
+
*
|
|
136
|
+
* 终态后再走一次整卡 patch(幂等替换;一次 run 只多一次请求),成功打
|
|
137
|
+
* info 便于排障时区分「帧没发出去」与「发出去了飞书没刷」;失败只 warn,
|
|
138
|
+
* 不参与收尾决策——流本身已结束,不能让一次兜底 patch 把 finish 拖成异常。
|
|
139
|
+
*/
|
|
140
|
+
protected confirmTerminalFrame(label: string): Promise<void>;
|
|
124
141
|
protected release(): void;
|
|
125
142
|
}
|
|
@@ -128,6 +128,9 @@ export class CardSession {
|
|
|
128
128
|
* in-flight flush 完成后该事件无 follow-up render,停留为 stale 中间帧。
|
|
129
129
|
* finally 检查此标志后重新 scheduleFlush,保证 in-flight 期间累积的事件最终被渲染。 */
|
|
130
130
|
pendingReschedule = false;
|
|
131
|
+
/** 终态帧是否已由 confirmTerminalFrame 确认落到飞书。settle 的兜底 patch
|
|
132
|
+
* 据此去重:流异常结束时若终态帧已经确认过,再 patch 一次只是幂等重发。 */
|
|
133
|
+
terminalFrameConfirmed = false;
|
|
131
134
|
/** 当前 in-flight flush 的 promise(若 flush 正在 await updateCard)。
|
|
132
135
|
* finish() 在终态 patch 前 await 它,保证 in-flight 的 pre-terminal patch 先落地、
|
|
133
136
|
* terminal patch 后落地——否则两条 controller.update 并发无 FIFO,
|
|
@@ -185,6 +188,11 @@ export class CardSession {
|
|
|
185
188
|
getLogger().warn(`${this.logPrefix} stream did not complete cleanly:`, outcome.error);
|
|
186
189
|
if (!this.messageId)
|
|
187
190
|
return 'unsent';
|
|
191
|
+
// 终态帧已在 finish 里确认落地:兜底 patch 是幂等重发,跳过。
|
|
192
|
+
// 仍返回 'streamed' 而非 'unsent'——卡片确实已带终态内容,调用方不能
|
|
193
|
+
// 因此再发一张 fallback 卡(bridge 只对 'unsent' 兜底)。
|
|
194
|
+
if (this.terminalFrameConfirmed)
|
|
195
|
+
return 'streamed';
|
|
188
196
|
try {
|
|
189
197
|
await this.connector.updateCard(this.messageId, this.renderCurrentCard());
|
|
190
198
|
return 'updated';
|
|
@@ -247,6 +255,33 @@ export class CardSession {
|
|
|
247
255
|
if (this.flushP)
|
|
248
256
|
await this.flushP;
|
|
249
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* 把终态帧真正钉到飞书上。
|
|
260
|
+
*
|
|
261
|
+
* `controller.update()` 只把帧交给 @larksuite/channel 的 throttle/queue,
|
|
262
|
+
* resolve 时 `message.patch` 还没发出(见 `connector/index.ts` 的
|
|
263
|
+
* patchWithRetry 注释)。流若在下一次 throttle 到期前关闭,这一帧就可能
|
|
264
|
+
* 永远不出门——卡片停在运行态、底部「⏹ 停止」按钮还在:停止其实已经
|
|
265
|
+
* 生效,按钮仍可点,点了是空转。
|
|
266
|
+
*
|
|
267
|
+
* 终态后再走一次整卡 patch(幂等替换;一次 run 只多一次请求),成功打
|
|
268
|
+
* info 便于排障时区分「帧没发出去」与「发出去了飞书没刷」;失败只 warn,
|
|
269
|
+
* 不参与收尾决策——流本身已结束,不能让一次兜底 patch 把 finish 拖成异常。
|
|
270
|
+
*/
|
|
271
|
+
async confirmTerminalFrame(label) {
|
|
272
|
+
const messageId = this.messageId;
|
|
273
|
+
// 没拿到 messageId(流未建立/已降级)时无从 patch,交回 settle() 的兜底。
|
|
274
|
+
if (!messageId)
|
|
275
|
+
return;
|
|
276
|
+
try {
|
|
277
|
+
await this.connector.updateCard(messageId, this.renderCurrentCard());
|
|
278
|
+
this.terminalFrameConfirmed = true;
|
|
279
|
+
getLogger().info(`${this.logPrefix} terminal frame confirmed ${label} messageId=${messageId}`);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
getLogger().warn(`${this.logPrefix} terminal frame confirm failed ${label}:`, error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
250
285
|
release() {
|
|
251
286
|
if (this.released)
|
|
252
287
|
return;
|
|
@@ -50,6 +50,8 @@ export class RunCardSession extends CardSession {
|
|
|
50
50
|
await this.prepareTerminalUpdate();
|
|
51
51
|
this.state = finishRun(this.state, terminal, meta);
|
|
52
52
|
await this.updateCard();
|
|
53
|
+
// 终态帧必须真的落地(throttle 通道 resolve 时 patch 还没发)。
|
|
54
|
+
await this.confirmTerminalFrame(`runId=${this.state.runId} terminal=${this.state.terminal}`);
|
|
53
55
|
this.release();
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isRunActive, isRunTerminal, } from './run-state.js';
|
|
1
|
+
import { isLiveBackgroundJob, isRunActive, isRunTerminal, } from './run-state.js';
|
|
2
2
|
import { terminalToColor, terminalToLabel, newSessionButton, stopButton, compactButton, cardButton, agentDisplayName, } from './card-shared.js';
|
|
3
3
|
import { collapsibleMarkdownPanel, markdownDiv } from './collapsible.js';
|
|
4
4
|
import { toolBodyMd, toolHeaderText } from './tool-render.js';
|
|
@@ -1202,11 +1202,26 @@ const BACKGROUND_JOB_INLINE_MAX = 36;
|
|
|
1202
1202
|
const BACKGROUND_JOB_FOLD_DISPLAY_MAX = 180;
|
|
1203
1203
|
const BACKGROUND_JOB_FOLD_LINES = 6;
|
|
1204
1204
|
/**
|
|
1205
|
-
*
|
|
1205
|
+
* 任务状态标签:宿主的两态 + 桥侧观测出的「已结束 / 状态未知」。
|
|
1206
|
+
*
|
|
1207
|
+
* 「已结束」不是可有可无的修饰:卡片上曾经只有「运行中/停止中」,任务跑完之
|
|
1208
|
+
* 后卡片仍然这么写,用户只能靠点一次停止按钮看它报错来判断任务是否还在。
|
|
1206
1209
|
*/
|
|
1207
1210
|
function backgroundJobStatusLabel(status) {
|
|
1211
|
+
if (status === 'ended')
|
|
1212
|
+
return '已结束';
|
|
1213
|
+
if (status === 'unknown')
|
|
1214
|
+
return '状态未知';
|
|
1208
1215
|
return status === 'stopping' ? '停止中' : '运行中';
|
|
1209
1216
|
}
|
|
1217
|
+
/** 行尾状态(live 任务返回空串:运行中本来就是默认状态)。 */
|
|
1218
|
+
function backgroundJobStatusTail(status) {
|
|
1219
|
+
if (status === 'ended')
|
|
1220
|
+
return '✅ 已结束';
|
|
1221
|
+
if (status === 'unknown')
|
|
1222
|
+
return '⚠️ 状态未知';
|
|
1223
|
+
return '';
|
|
1224
|
+
}
|
|
1210
1225
|
/**
|
|
1211
1226
|
* 生成能包住任意反引号串的代码围栏。命令里的 shell 反引号不能替换成 `'`
|
|
1212
1227
|
* (那是改内容),只能选比内容里最长反引号串更长的围栏。
|
|
@@ -1244,24 +1259,40 @@ function buildBackgroundJobsSection(state) {
|
|
|
1244
1259
|
const jobs = state.backgroundJobs ?? [];
|
|
1245
1260
|
if (jobs.length === 0)
|
|
1246
1261
|
return [];
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1262
|
+
// 仍在跑的排前面:用户最先要知道的是"哪些还在占资源"。
|
|
1263
|
+
const running = jobs.filter((job) => isLiveBackgroundJob(job));
|
|
1264
|
+
const unknown = jobs.filter((job) => job.status === 'unknown');
|
|
1265
|
+
const ended = jobs.filter((job) => !isLiveBackgroundJob(job) && job.status !== 'unknown');
|
|
1266
|
+
const ordered = [...running, ...unknown, ...ended];
|
|
1267
|
+
// 计数逐维列出:只说「N 个任务」会让"都还在跑"与"都结束了"读起来一样。
|
|
1268
|
+
const counts = [];
|
|
1269
|
+
if (running.length > 0)
|
|
1270
|
+
counts.push(`${running.length} 个仍在运行`);
|
|
1271
|
+
if (unknown.length > 0)
|
|
1272
|
+
counts.push(`${unknown.length} 个状态未知`);
|
|
1273
|
+
if (ended.length > 0)
|
|
1274
|
+
counts.push(`${ended.length} 个已结束`);
|
|
1275
|
+
const headline = ended.length === jobs.length ? '已全部结束' : counts.join(' · ');
|
|
1276
|
+
const elements = [markdownDiv(`🧵 **后台任务** ${headline}`)];
|
|
1277
|
+
if (running.length + unknown.length > 0) {
|
|
1278
|
+
elements.push(markdownDiv('_本轮结束后继续,可逐个停止_', 'notation'));
|
|
1279
|
+
}
|
|
1280
|
+
ordered.slice(0, MAX_BACKGROUND_JOB_LINES).forEach((job, index) => {
|
|
1252
1281
|
const label = job.label.trim();
|
|
1282
|
+
const tail = backgroundJobStatusTail(job.status);
|
|
1253
1283
|
const lineCount = label === '' ? 0 : label.split(/\r?\n/).length;
|
|
1254
1284
|
const inline = label.length > 0 &&
|
|
1255
1285
|
lineCount === 1 &&
|
|
1256
1286
|
!label.includes('`') &&
|
|
1257
1287
|
displayWidth(label) <= BACKGROUND_JOB_INLINE_MAX;
|
|
1258
1288
|
if (inline) {
|
|
1259
|
-
elements.push(markdownDiv(`· \`${label}
|
|
1289
|
+
elements.push(markdownDiv(`· \`${label}\`${tail ? ` · ${tail}` : ''}`));
|
|
1260
1290
|
}
|
|
1261
1291
|
else {
|
|
1262
1292
|
// 多行/超宽/含反引号:代码块保留完整命令;只有超长才折叠。
|
|
1293
|
+
// 命令原文一个字都不改,状态出现在围栏之后。
|
|
1263
1294
|
const fence = codeFenceFor(label);
|
|
1264
|
-
const content = `${fence}\n${label}\n${fence}`;
|
|
1295
|
+
const content = `${fence}\n${label}\n${fence}${tail ? `\n\n${tail}` : ''}`;
|
|
1265
1296
|
const fold = lineCount > BACKGROUND_JOB_FOLD_LINES ||
|
|
1266
1297
|
displayWidth(label) > BACKGROUND_JOB_FOLD_DISPLAY_MAX;
|
|
1267
1298
|
elements.push(fold
|
|
@@ -1274,6 +1305,9 @@ function buildBackgroundJobsSection(state) {
|
|
|
1274
1305
|
})
|
|
1275
1306
|
: markdownDiv(content));
|
|
1276
1307
|
}
|
|
1308
|
+
// 已结束的任务不再给停止入口——那个按钮点下去只会回「可能已自行结束」。
|
|
1309
|
+
if (job.status === 'ended')
|
|
1310
|
+
return;
|
|
1277
1311
|
elements.push({
|
|
1278
1312
|
tag: 'column_set',
|
|
1279
1313
|
columns: [
|
package/dist/card/run-state.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentEvent, ApprovalView, ToolHint, GoalView } from '../runner/index.js';
|
|
1
|
+
import type { AgentEvent, BackgroundJobRoster, ApprovalView, ToolHint, GoalView } from '../runner/index.js';
|
|
2
2
|
import type { UsageSnapshot } from '../session-contract.js';
|
|
3
3
|
export type RunTerminal = 'running' | 'finalizing' | 'done' | 'error' | 'interrupted' | 'idle_timeout'
|
|
4
4
|
/**
|
|
@@ -172,13 +172,11 @@ export interface RunState extends UsageSnapshot {
|
|
|
172
172
|
/**
|
|
173
173
|
* run 结束时仍在后台运行的任务。跨 turn 生命周期:卡片必须说清楚"这一轮
|
|
174
174
|
* 结束了,但还有东西在跑",并给出逐个停止的入口。
|
|
175
|
+
*
|
|
176
|
+
* 收尾之后这份名单仍会被 `syncBackgroundJobs` 更新(宿主会继续推送任务
|
|
177
|
+
* 生命周期),所以它是"最后一次观测到的名单",不是"收尾那一刻的名单"。
|
|
175
178
|
*/
|
|
176
|
-
backgroundJobs?:
|
|
177
|
-
id: string;
|
|
178
|
-
label: string;
|
|
179
|
-
status?: string;
|
|
180
|
-
progress?: string;
|
|
181
|
-
}>;
|
|
179
|
+
backgroundJobs?: BackgroundJobRoster[];
|
|
182
180
|
/**
|
|
183
181
|
* 会话级长期目标(DSH goal)。跨 turn 生命周期:goal 仍 active 时,本轮结束
|
|
184
182
|
* 只是"一轮结束",宿主会自动续跑,卡片必须显示目标是什么、跑到第几轮。
|
|
@@ -208,5 +206,34 @@ export declare function reduceRunState(state: RunState, event: AgentEvent): RunS
|
|
|
208
206
|
* 写成「整体重建」,字段一多就会漏带。
|
|
209
207
|
*/
|
|
210
208
|
export declare function removeBackgroundJob(state: RunState, jobId: string): RunState;
|
|
209
|
+
/**
|
|
210
|
+
* 该任务是否仍在跑(`ended` / `unknown` 都不算)。
|
|
211
|
+
*
|
|
212
|
+
* 卡片据此决定要不要渲染「⏹ 停止」:给已经结束的任务留一个点了必然失败的
|
|
213
|
+
* 按钮,等于逼用户拿「停止」当查询用。
|
|
214
|
+
*/
|
|
215
|
+
export declare function isLiveBackgroundJob(job: BackgroundJobRoster): boolean;
|
|
216
|
+
/**
|
|
217
|
+
* 收尾后把宿主推来的整份任务名单同步进终态卡状态。
|
|
218
|
+
*
|
|
219
|
+
* 与 reducer 的分工:reducer 处理的是"收尾那一刻的名单",只在 run 还在消费
|
|
220
|
+
* 事件时有效;终态之后 `reduceRunState` 直接早退(终态幂等),名单只能由
|
|
221
|
+
* 这里更新。
|
|
222
|
+
*
|
|
223
|
+
* 三条规则:
|
|
224
|
+
* - 名单里消失 = 宿主已结算(`job/list` 是整份替换,摘掉即结束)→ `ended`;
|
|
225
|
+
* - 名单里还在、宿主却给了终态词 → `ended`(有些宿主会把已结束的行留在名单里);
|
|
226
|
+
* - `ended` 是终态,不回退成 running —— 同一个 job id 不会重新开始跑。
|
|
227
|
+
*
|
|
228
|
+
* 无变化时返回**同一个 state 引用**:bridge 靠它跳过无谓的卡片 patch。
|
|
229
|
+
*/
|
|
230
|
+
export declare function syncBackgroundJobs(state: RunState, roster: ReadonlyArray<BackgroundJobRoster>): RunState;
|
|
231
|
+
/**
|
|
232
|
+
* 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」。
|
|
233
|
+
*
|
|
234
|
+
* 监控中断不等于任务结束——不标出来,卡片又会退化成"最后一次观测当现状",
|
|
235
|
+
* 正是这次要修的那个缺陷。未知态仍保留停止按钮(用户还能尝试停掉)。
|
|
236
|
+
*/
|
|
237
|
+
export declare function markBackgroundJobsUnknown(state: RunState): RunState;
|
|
211
238
|
export declare function finishRun(state: RunState, terminal: RunFinalTerminal, meta?: FinishMeta): RunState;
|
|
212
239
|
export {};
|
package/dist/card/run-state.js
CHANGED
|
@@ -460,6 +460,95 @@ export function removeBackgroundJob(state, jobId) {
|
|
|
460
460
|
backgroundJobs: (state.backgroundJobs ?? []).filter((job) => job.id !== jobId),
|
|
461
461
|
};
|
|
462
462
|
}
|
|
463
|
+
/** 宿主仍在执行的任务状态词。状态缺失也算在跑——它还在名单上。 */
|
|
464
|
+
const RUNNING_JOB_STATUSES = new Set(['running', 'stopping']);
|
|
465
|
+
/**
|
|
466
|
+
* 该任务是否仍在跑(`ended` / `unknown` 都不算)。
|
|
467
|
+
*
|
|
468
|
+
* 卡片据此决定要不要渲染「⏹ 停止」:给已经结束的任务留一个点了必然失败的
|
|
469
|
+
* 按钮,等于逼用户拿「停止」当查询用。
|
|
470
|
+
*/
|
|
471
|
+
export function isLiveBackgroundJob(job) {
|
|
472
|
+
return job.status === undefined || RUNNING_JOB_STATUSES.has(job.status);
|
|
473
|
+
}
|
|
474
|
+
/** 宿主状态 → 桥侧状态:仍在跑的保留原词,其余已知终态归一成 `ended`。 */
|
|
475
|
+
function bridgeJobStatus(status) {
|
|
476
|
+
return RUNNING_JOB_STATUSES.has(status) ? status : 'ended';
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* 收尾后把宿主推来的整份任务名单同步进终态卡状态。
|
|
480
|
+
*
|
|
481
|
+
* 与 reducer 的分工:reducer 处理的是"收尾那一刻的名单",只在 run 还在消费
|
|
482
|
+
* 事件时有效;终态之后 `reduceRunState` 直接早退(终态幂等),名单只能由
|
|
483
|
+
* 这里更新。
|
|
484
|
+
*
|
|
485
|
+
* 三条规则:
|
|
486
|
+
* - 名单里消失 = 宿主已结算(`job/list` 是整份替换,摘掉即结束)→ `ended`;
|
|
487
|
+
* - 名单里还在、宿主却给了终态词 → `ended`(有些宿主会把已结束的行留在名单里);
|
|
488
|
+
* - `ended` 是终态,不回退成 running —— 同一个 job id 不会重新开始跑。
|
|
489
|
+
*
|
|
490
|
+
* 无变化时返回**同一个 state 引用**:bridge 靠它跳过无谓的卡片 patch。
|
|
491
|
+
*/
|
|
492
|
+
export function syncBackgroundJobs(state, roster) {
|
|
493
|
+
const tracked = state.backgroundJobs ?? [];
|
|
494
|
+
const byId = new Map(roster.map((job) => [job.id, job]));
|
|
495
|
+
const next = tracked.map((job) => {
|
|
496
|
+
const row = byId.get(job.id);
|
|
497
|
+
if (!row)
|
|
498
|
+
return job.status === 'ended' ? job : { ...job, status: 'ended' };
|
|
499
|
+
if (job.status === 'ended')
|
|
500
|
+
return job;
|
|
501
|
+
const status = row.status === undefined ? job.status : bridgeJobStatus(row.status);
|
|
502
|
+
const label = row.label.trim() === '' ? job.label : row.label;
|
|
503
|
+
const progress = row.progress ?? job.progress;
|
|
504
|
+
if (status === job.status && label === job.label && progress === job.progress)
|
|
505
|
+
return job;
|
|
506
|
+
return {
|
|
507
|
+
...job,
|
|
508
|
+
label,
|
|
509
|
+
...(status === undefined ? {} : { status }),
|
|
510
|
+
...(progress === undefined ? {} : { progress }),
|
|
511
|
+
};
|
|
512
|
+
});
|
|
513
|
+
// 新出现的任务只收仍在跑的:与收尾采集同一口径(列出已结束的任务只会被
|
|
514
|
+
// 读成"还在跑")。
|
|
515
|
+
const seen = new Set(tracked.map((job) => job.id));
|
|
516
|
+
for (const row of roster) {
|
|
517
|
+
if (seen.has(row.id))
|
|
518
|
+
continue;
|
|
519
|
+
if (row.status !== undefined && !RUNNING_JOB_STATUSES.has(row.status))
|
|
520
|
+
continue;
|
|
521
|
+
next.push({
|
|
522
|
+
id: row.id,
|
|
523
|
+
label: row.label,
|
|
524
|
+
...(row.status === undefined ? {} : { status: row.status }),
|
|
525
|
+
...(row.progress === undefined ? {} : { progress: row.progress }),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
if (next.length === tracked.length && next.every((job, index) => job === tracked[index])) {
|
|
529
|
+
return state;
|
|
530
|
+
}
|
|
531
|
+
return { ...state, backgroundJobs: next };
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」。
|
|
535
|
+
*
|
|
536
|
+
* 监控中断不等于任务结束——不标出来,卡片又会退化成"最后一次观测当现状",
|
|
537
|
+
* 正是这次要修的那个缺陷。未知态仍保留停止按钮(用户还能尝试停掉)。
|
|
538
|
+
*/
|
|
539
|
+
export function markBackgroundJobsUnknown(state) {
|
|
540
|
+
const jobs = state.backgroundJobs;
|
|
541
|
+
if (!jobs || jobs.length === 0)
|
|
542
|
+
return state;
|
|
543
|
+
let changed = false;
|
|
544
|
+
const next = jobs.map((job) => {
|
|
545
|
+
if (!isLiveBackgroundJob(job))
|
|
546
|
+
return job;
|
|
547
|
+
changed = true;
|
|
548
|
+
return { ...job, status: 'unknown' };
|
|
549
|
+
});
|
|
550
|
+
return changed ? { ...state, backgroundJobs: next } : state;
|
|
551
|
+
}
|
|
463
552
|
/**
|
|
464
553
|
* 运行期状态投影 → 状态行。
|
|
465
554
|
*
|
package/dist/message-handlers.js
CHANGED
|
@@ -207,7 +207,9 @@ export function setupMessageHandlers(deps) {
|
|
|
207
207
|
const actionValue = action.action.value;
|
|
208
208
|
if (!actionValue?.cmd)
|
|
209
209
|
return;
|
|
210
|
-
logger.info(
|
|
210
|
+
logger.info(actionValue.cmd === 'stop'
|
|
211
|
+
? `card action: stop runId=${actionValue.runId ?? '(none)'} messageId=${action.messageId}`
|
|
212
|
+
: `card action: ${actionValue.cmd}`);
|
|
211
213
|
const userId = action.operator.openId;
|
|
212
214
|
const chatId = action.chatId;
|
|
213
215
|
const messageId = action.messageId;
|
package/dist/router/index.js
CHANGED
|
@@ -1319,14 +1319,15 @@ export class CommandRouter {
|
|
|
1319
1319
|
* Handle stop card action: interrupt current run.
|
|
1320
1320
|
*/
|
|
1321
1321
|
async handleCardStop(value, ctx) {
|
|
1322
|
-
if (!
|
|
1322
|
+
if (!ctx.userId || !ctx.chatId || (!value.runId && !ctx.messageId)) {
|
|
1323
1323
|
await this.bridge.sendResult({ text: '⚠️ 无效的停止请求,缺少必要信息' }, ctx);
|
|
1324
1324
|
return;
|
|
1325
1325
|
}
|
|
1326
1326
|
const interrupt = await this.bridge.interruptCurrentRunDetailed({
|
|
1327
1327
|
userId: ctx.userId,
|
|
1328
1328
|
chatId: ctx.chatId,
|
|
1329
|
-
runId: value.runId,
|
|
1329
|
+
...(value.runId ? { runId: value.runId } : {}),
|
|
1330
|
+
cardMessageId: ctx.messageId,
|
|
1330
1331
|
});
|
|
1331
1332
|
const stopped = interrupt.stopped;
|
|
1332
1333
|
// Regression 2026-06-22: card stop button silently no-op'd when the
|
|
@@ -325,13 +325,20 @@ export declare class DshClient {
|
|
|
325
325
|
*/
|
|
326
326
|
controlFrames(signal: AbortSignal): AsyncGenerator<DshProjectionItem>;
|
|
327
327
|
/**
|
|
328
|
-
*
|
|
328
|
+
* Subscribe to one session's background-job roster as a live stream.
|
|
329
329
|
*
|
|
330
|
-
* `job/list`
|
|
331
|
-
*
|
|
332
|
-
*
|
|
330
|
+
* `job/list` 是整份替换流(whole-set replacement):首帧就是当前真相,之后
|
|
331
|
+
* 任务生命周期一变就再推一整份。**run 结束之后**必须用这个长订阅——后台任务
|
|
332
|
+
* 跨 turn 存活,任务结束这件事只会在这条流上出现;收尾时取首帧即断开
|
|
333
|
+
* ({@link jobRows})只够画那一瞬间的卡片。
|
|
334
|
+
*/
|
|
335
|
+
watchJobs(sessionId: string, signal: AbortSignal): AsyncGenerator<DshJobView[]>;
|
|
336
|
+
/**
|
|
337
|
+
* One session's current background-job roster (snapshot).
|
|
333
338
|
*
|
|
334
|
-
*
|
|
339
|
+
* "此刻有哪些任务在跑"只需要第一帧:文档明确首帧就是当前真相,取到即断开,
|
|
340
|
+
* 不长期持有连接。失败返回 undefined(与"没有任务"区分):拿不到就别在卡片
|
|
341
|
+
* 上写"0 个后台任务"。
|
|
335
342
|
*/
|
|
336
343
|
jobRows(sessionId: string, opts?: {
|
|
337
344
|
signal?: AbortSignal;
|
|
@@ -927,25 +927,35 @@ export class DshClient {
|
|
|
927
927
|
}
|
|
928
928
|
}
|
|
929
929
|
/**
|
|
930
|
-
*
|
|
930
|
+
* Subscribe to one session's background-job roster as a live stream.
|
|
931
931
|
*
|
|
932
|
-
* `job/list`
|
|
933
|
-
*
|
|
934
|
-
*
|
|
932
|
+
* `job/list` 是整份替换流(whole-set replacement):首帧就是当前真相,之后
|
|
933
|
+
* 任务生命周期一变就再推一整份。**run 结束之后**必须用这个长订阅——后台任务
|
|
934
|
+
* 跨 turn 存活,任务结束这件事只会在这条流上出现;收尾时取首帧即断开
|
|
935
|
+
* ({@link jobRows})只够画那一瞬间的卡片。
|
|
936
|
+
*/
|
|
937
|
+
async *watchJobs(sessionId, signal) {
|
|
938
|
+
for await (const frame of this.muxStream('job/list', { args: dshArgs('job/list', { sessionId }) }, signal)) {
|
|
939
|
+
const rows = frame;
|
|
940
|
+
if (rows?.type !== 'rows')
|
|
941
|
+
continue;
|
|
942
|
+
yield Array.isArray(rows.jobs) ? rows.jobs : [];
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* One session's current background-job roster (snapshot).
|
|
935
947
|
*
|
|
936
|
-
*
|
|
948
|
+
* "此刻有哪些任务在跑"只需要第一帧:文档明确首帧就是当前真相,取到即断开,
|
|
949
|
+
* 不长期持有连接。失败返回 undefined(与"没有任务"区分):拿不到就别在卡片
|
|
950
|
+
* 上写"0 个后台任务"。
|
|
937
951
|
*/
|
|
938
952
|
async jobRows(sessionId, opts = {}) {
|
|
939
953
|
const timer = new AbortController();
|
|
940
954
|
const timeout = setTimeout(() => timer.abort(), opts.timeoutMs ?? 2_000);
|
|
941
955
|
const signal = opts.signal ? AbortSignal.any([opts.signal, timer.signal]) : timer.signal;
|
|
942
956
|
try {
|
|
943
|
-
for await (const
|
|
944
|
-
|
|
945
|
-
if (rows?.type === 'rows') {
|
|
946
|
-
return Array.isArray(rows.jobs) ? rows.jobs : [];
|
|
947
|
-
}
|
|
948
|
-
}
|
|
957
|
+
for await (const rows of this.watchJobs(sessionId, signal))
|
|
958
|
+
return rows;
|
|
949
959
|
return undefined;
|
|
950
960
|
}
|
|
951
961
|
catch (err) {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* back to the Web UI prompt.
|
|
19
19
|
*/
|
|
20
20
|
import type { AgentSessionReader } from '../../session-contract.js';
|
|
21
|
-
import type { AgentEvent, AgentStatusInfo, SpawnOptions, StopOutcome } from '../types.js';
|
|
21
|
+
import type { AgentEvent, AgentStatusInfo, BackgroundJobRoster, SpawnOptions, StopOutcome } from '../types.js';
|
|
22
22
|
import type { AgentRunner } from '../types.js';
|
|
23
23
|
import { DshClient, type WebSocketFactory } from './client.js';
|
|
24
24
|
import type { DshDaemon } from './daemon.js';
|
|
@@ -169,6 +169,13 @@ export declare class DshRunner implements AgentRunner {
|
|
|
169
169
|
* 任务列表不等于有任务在跑。
|
|
170
170
|
*/
|
|
171
171
|
private collectBackgroundJobs;
|
|
172
|
+
/**
|
|
173
|
+
* 订阅本 session 的后台任务名单(跨 turn 生命周期)。
|
|
174
|
+
*
|
|
175
|
+
* 卡片收尾后仍要靠它把「任务结束了」推给用户:run 的事件流在 result 就关了,
|
|
176
|
+
* 而后台任务可能再跑半小时。名单是整份替换,消费方自己做 diff。
|
|
177
|
+
*/
|
|
178
|
+
watchJobs(sessionId: string, signal: AbortSignal): AsyncGenerator<ReadonlyArray<BackgroundJobRoster>>;
|
|
172
179
|
/** Kill one background job of this runner's session (card button path). */
|
|
173
180
|
killJob(sessionId: string, jobId: string): Promise<boolean>;
|
|
174
181
|
/** Apply the configured permission preset once per session, before prompt. */
|
|
@@ -796,6 +796,22 @@ export class DshRunner {
|
|
|
796
796
|
},
|
|
797
797
|
];
|
|
798
798
|
}
|
|
799
|
+
/**
|
|
800
|
+
* 订阅本 session 的后台任务名单(跨 turn 生命周期)。
|
|
801
|
+
*
|
|
802
|
+
* 卡片收尾后仍要靠它把「任务结束了」推给用户:run 的事件流在 result 就关了,
|
|
803
|
+
* 而后台任务可能再跑半小时。名单是整份替换,消费方自己做 diff。
|
|
804
|
+
*/
|
|
805
|
+
async *watchJobs(sessionId, signal) {
|
|
806
|
+
for await (const rows of this.client.watchJobs(sessionId, signal)) {
|
|
807
|
+
yield rows.map((job) => ({
|
|
808
|
+
id: job.id,
|
|
809
|
+
label: job.label ?? job.id,
|
|
810
|
+
...(job.status === undefined ? {} : { status: job.status }),
|
|
811
|
+
...(job.progress === undefined ? {} : { progress: job.progress }),
|
|
812
|
+
}));
|
|
813
|
+
}
|
|
814
|
+
}
|
|
799
815
|
/** Kill one background job of this runner's session (card button path). */
|
|
800
816
|
async killJob(sessionId, jobId) {
|
|
801
817
|
return await this.client.jobKill(sessionId, jobId);
|
package/dist/runner/index.d.ts
CHANGED
|
@@ -15,6 +15,6 @@ export { KimiAcpRunner } from './kimi/index.js';
|
|
|
15
15
|
export { DshRunner } from './dsh/index.js';
|
|
16
16
|
export { BashProcessRunner, type BashRunner } from './bash/index.js';
|
|
17
17
|
export { AgentRegistry } from './registry.js';
|
|
18
|
-
export type { ResultEvent, PlanEvent, FileChangeEvent, AgentEvent, Runner, RunnerLifetime, AgentRunner, TurnStartedEvent, TurnDiffEvent, FinalAnswerEvent, NoticeEvent, SessionInfoEvent, ToolHint, ApprovalView, ApprovalRequestedEvent, ApprovalResolvedEvent, GoalView, GoalStateEvent, } from './types.js';
|
|
18
|
+
export type { ResultEvent, PlanEvent, FileChangeEvent, AgentEvent, Runner, RunnerLifetime, AgentRunner, TurnStartedEvent, TurnDiffEvent, FinalAnswerEvent, NoticeEvent, SessionInfoEvent, ToolHint, ApprovalView, ApprovalRequestedEvent, ApprovalResolvedEvent, BackgroundJobRoster, BackgroundJobsEvent, GoalView, GoalStateEvent, } from './types.js';
|
|
19
19
|
export { resolveAgentChoices } from './resolve-agent-choices.js';
|
|
20
20
|
export { syncAgentChoices } from './sync-agent-choices.js';
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -416,6 +416,20 @@ export interface GoalStateEvent {
|
|
|
416
416
|
known: boolean;
|
|
417
417
|
timestamp?: string;
|
|
418
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* 后台任务名单里的一行(宿主侧权威数据)。
|
|
421
|
+
*
|
|
422
|
+
* `status` 保留宿主原词(running / stopping / completed / failed …)——各
|
|
423
|
+
* agent 的终态词表不同,桥侧只按「是不是 running/stopping」二分,不在这里
|
|
424
|
+
* 维护一份会漂移的枚举。桥侧自己产出的 `ended` / `unknown` 见
|
|
425
|
+
* `BackgroundJobStatus`(card/run-state)。
|
|
426
|
+
*/
|
|
427
|
+
export interface BackgroundJobRoster {
|
|
428
|
+
id: string;
|
|
429
|
+
label: string;
|
|
430
|
+
status?: string;
|
|
431
|
+
progress?: string;
|
|
432
|
+
}
|
|
419
433
|
/**
|
|
420
434
|
* 运行结束后仍在后台运行的任务(终态卡展示 + 逐个停止入口)。
|
|
421
435
|
*
|
|
@@ -425,12 +439,7 @@ export interface GoalStateEvent {
|
|
|
425
439
|
*/
|
|
426
440
|
export interface BackgroundJobsEvent {
|
|
427
441
|
type: 'background_jobs';
|
|
428
|
-
jobs:
|
|
429
|
-
id: string;
|
|
430
|
-
label: string;
|
|
431
|
-
status?: string;
|
|
432
|
-
progress?: string;
|
|
433
|
-
}>;
|
|
442
|
+
jobs: BackgroundJobRoster[];
|
|
434
443
|
timestamp?: string;
|
|
435
444
|
}
|
|
436
445
|
/** File change event — for agents that report file modifications (e.g. Codex). */
|
|
@@ -611,6 +620,14 @@ export interface AgentRunner extends Runner {
|
|
|
611
620
|
* agent 实现;调用方按能力存在与否门控。
|
|
612
621
|
*/
|
|
613
622
|
killJob?(sessionId: string, jobId: string): Promise<boolean>;
|
|
623
|
+
/**
|
|
624
|
+
* 订阅一个 session 的后台任务名单(整份替换),直到 `signal` abort 或宿主
|
|
625
|
+
* 关闭流。只有支持后台任务的 agent 实现;调用方按能力存在与否门控。
|
|
626
|
+
*
|
|
627
|
+
* 必须在 run 结束之后仍然可用:后台任务的生命周期跨 turn,收尾那一刻的快照
|
|
628
|
+
* 只能画一张会过期的卡。
|
|
629
|
+
*/
|
|
630
|
+
watchJobs?(sessionId: string, signal: AbortSignal): AsyncIterable<ReadonlyArray<BackgroundJobRoster>>;
|
|
614
631
|
/**
|
|
615
632
|
* 热更审批模式/权限预设(config.save 后推给在跑的 runner,不必重启会话)。
|
|
616
633
|
* 各家 settings 词表不同(codex: approvalPolicy/sandbox;kimi: permissionMode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lark-remote",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.19",
|
|
4
4
|
"description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|