lark-remote 0.5.14 → 0.5.16

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 CHANGED
@@ -5,6 +5,38 @@ 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.16] - 2026-11-01
9
+
10
+ ### 修复
11
+
12
+ - **Claude / Pi 的「本 run」用量在多步回合下不再低报**:卡片「本 run」与 `/resume`、自动恢复卡、完成通知卡的 Input / Output 由会话文件兜底读取,此前这两个 agent 只取**最后一条 assistant 消息**,而一条消息等于一次模型请求——带工具往返的回合里显示的是最后一次请求的量,整轮消耗被系统性低估。现在汇总末个真人回合内的所有 assistant 消息;`Context` 仍是末次请求的窗口水位、`累计` 仍是会话总量,口径不变。没有真人输入的旧数据或截断文件退回末条采样,不会把整份会话当成一轮
13
+ - **DSH 回合进行中的读数不再显示上一轮**:进程重启后的自动恢复与 `/resume`、没有活动用量时的中途读取、完成通知卡,此前优先取**上一个已结算回合**的数字(取值顺序与它自己的注释相反),当前回合已消耗的量被显示成上一轮。现在在途回合优先
14
+ - **DSH 的 `Context` 不再虚高**:窗口水位此前把整回合的输出一路相加,而前几步的输出早已计入后续请求的输入,属重复计数;`Context` 始终以会话文件为准,于是终态卡的上下文占用行随回合变长而虚高、百分比一起失真。现在只取末次请求的输出
15
+ - **Kimi / opencode 新回合刚开始时显示 0,不再显示上一轮残留**:回合已发出、模型还没产生任何用量采样的时刻,两个 reader 此前退回上一轮的最后一条记录,卡片把「刚开始的空回合」画成「刚结束那一轮」的消耗。现在按「末个回合」契约显示 0(`Context` 保留上一轮水位,因为窗口占用确实延续);整份记录里没有回合标记的旧数据沿用原有语义
16
+ - **`/compact` 收尾不再做无用功**:opencode 与 DSH 的会话读取给不出压缩次数,此前的重试把「这个 agent 没有该字段」当成「还没落盘」,每次压缩固定多跑两次完整导出 / 读取且永远等不到。现在 reader 显式声明能否产出压缩计数,声明不能的不再重试
17
+
18
+ ### 维护
19
+
20
+ - **用量来源与 result 事件语义拆成两个字段**:原先一个字段同时承担「Input/Output 来自 live 还是会话文件」和「result 事件是否按 live 语义处理(启动前的 result 丢弃、会话指针写回)」,而 Kimi / opencode 的 live 通知只带上下文占用、没有输入输出拆分,却被迫声明成 live 来源。现在前者单独声明为会话文件,result 事件语义走新的声明位。无行为变化
21
+ - **跨 agent 的用量口径断言集中到一处**:本 run / `Context` / 累计三条不变量原本每个 reader 各写一套期望值,容易把错误语义固化成测试基线;现在 Claude / Pi / DSH / Kimi / opencode 的回归用例共用同一组断言。无行为变化
22
+ - **新增 token 用量口径文档**(中英文):三个 scope 的硬不变量、live 与会话文件两条生产路径、同一低报问题反复复发的原因、六个 reader 各自的回合边界与 `Context` 公式,以及尚未落地的共享聚合器与子 agent 用量过滤缺口
23
+
24
+ ## [0.5.15] - 2026-10-31
25
+
26
+ ### 修复
27
+
28
+ - **`/stop` 落在「刚发消息、卡片还没建好」的窗口里不再空跑整轮**:此前卡片已经定格成「已中断」,但 agent 进程还没起来、没有可杀的进程,`run()` 入口又会把停止标记重置回去,于是整轮照常跑完并继续改动工作区。现在卡片一旦进入终态就直接收口,不再启动 agent
29
+ - **中断归因不再串台**:显式 `/stop` 不再被上一次遗留的粘性「审批超时」状态盖掉;在审批卡上点「取消」现在会如实归因成用户取消并由卡片透传,终态卡不再显示「审批超时」(该文案此前实际不可达)
30
+ - **目标状态读不到时不再谎报「已完成」**:终态复核探测不到目标状态时显式标记为「未知」;投影里缺少目标域按「明确没有目标」处理,不会把普通会话误标成「目标未确认」
31
+ - **opencode / kimi 卡片「本 run」token 低报或虚高**:ACP 的用量通知只带上下文占用与花费、没有输入输出拆分,这两个 agent 的卡片只能从会话文件兜底读取。opencode 取的是「最后一条 assistant 消息」而一条消息等于一次模型请求,实测一条 25 次请求的 run 卡片显示 Input 543 / Output 1,250,真实消耗是 20,040 / 10,358(相邻两张卡的「累计」差值反而对得上);kimi 更直接把整条会话的总量当本 run。现在两个 reader 都汇总**末个 turn** 的采样:opencode 以它自带的 turn 结束标记为边界(文件停在回合中间时就是当前回合),kimi 以 `turn.prompt` 为边界;`Context` 仍是末次请求的窗口水位,`累计` 仍是会话总量,口径不变;没有 turn 标记的旧数据沿用原语义
32
+ - **opencode 冷启动失败不再引导用户重启服务**:daemon 刚重启时模型目录还在加载,`/api/model` 会连续几分钟返 503,旧退避(0.5/1.5/3 秒)整段落在这个窗口里、四次重试无一命中,失败文案又叫用户 `opencode service restart`——重启只会清掉已热状态、重新进入同一个窗口。现在退避改为 0.5/2/8/20/45 秒(连每次请求自身约 10 秒超时,覆盖约 2 分钟窗口),重试耗尽后丢弃该工作目录的缓存连接、下一轮重新拉起 agent server,不再复用一个没激活的子进程;文案改成「冷启动/模型目录加载期间的临时状态,等 1–2 分钟重发」
33
+ - **`/compact` 的卡片流启动失败时补发静态终态卡**,不再只剩一条「Compact 已触发」加一个表情;兜底的完成通知卡标题跟随真实终态
34
+ - **收尾报文更如实**:区分「没收到结果」与「结果没落到卡片上」,不再用同一句话盖掉两种原因
35
+
36
+ ### 变更
37
+
38
+ - run 终态卡文案更如实:目标被阻断 / 已暂停分别显示「目标被阻断」「目标已暂停」,不再统一写「已完成」;目标读取失败时标题写「完成(目标未确认)」、摘要首行同步;`goal_continuing` 的原消息表情由 Done 改为 OnIt;run 是否活跃改用统一的终态判据收口
39
+
8
40
  ## [0.5.14] - 2026-10-28
9
41
 
10
42
  ### 修复
@@ -37,6 +37,15 @@ export declare class ApprovalCoordinator {
37
37
  private approvals;
38
38
  /** 已提交过的 nonce(按 requestId):同一按钮连点/飞书重投递只生效一次。 */
39
39
  private submittedNonces;
40
+ /**
41
+ * 用户是否在审批卡上主动点了「取消」。
42
+ *
43
+ * run 的终态归因需要它:cancel 在真实协议里是「停止等待」,agent 可能继续
44
+ * 跑,turn 结束时只剩一个泛化的 interrupted。桥据此把终态写成
45
+ * approval_cancelled,与 /stop(user_stop)、审批超时(approval_timeout)
46
+ * 区分开。仅在 responder 成功后置位——提交失败仍可重试。
47
+ */
48
+ private userCancelled;
40
49
  private readonly approvalTimeoutMs;
41
50
  private readonly responder;
42
51
  private readonly interruptTurn;
@@ -139,6 +148,10 @@ export declare class ApprovalCoordinator {
139
148
  * approvals must not be resurrected or rewritten.
140
149
  */
141
150
  updateView(requestId: number | string, view: ApprovalView): void;
151
+ /**
152
+ * 用户是否在审批卡上主动点了「取消」(终态归因用;提交失败不置位)。
153
+ */
154
+ get cancelledByUser(): boolean;
142
155
  /**
143
156
  * Get the number of pending approvals.
144
157
  */
@@ -16,6 +16,15 @@ export class ApprovalCoordinator {
16
16
  approvals = new Map();
17
17
  /** 已提交过的 nonce(按 requestId):同一按钮连点/飞书重投递只生效一次。 */
18
18
  submittedNonces = new Map();
19
+ /**
20
+ * 用户是否在审批卡上主动点了「取消」。
21
+ *
22
+ * run 的终态归因需要它:cancel 在真实协议里是「停止等待」,agent 可能继续
23
+ * 跑,turn 结束时只剩一个泛化的 interrupted。桥据此把终态写成
24
+ * approval_cancelled,与 /stop(user_stop)、审批超时(approval_timeout)
25
+ * 区分开。仅在 responder 成功后置位——提交失败仍可重试。
26
+ */
27
+ userCancelled = false;
19
28
  approvalTimeoutMs;
20
29
  responder;
21
30
  interruptTurn;
@@ -131,6 +140,9 @@ export class ApprovalCoordinator {
131
140
  try {
132
141
  await this.responder(ctx.requestId, effectiveAction);
133
142
  tracked.state = 'resolved';
143
+ // 终态归因:用户主动取消才置位(accept/decline 都是"继续本轮")。
144
+ if (effectiveAction.action === 'cancel')
145
+ this.userCancelled = true;
134
146
  // nonce 在 responder 成功后才消费:失败可原按钮重试,
135
147
  // 不会被自己的 nonce 去重挡掉(失败后已回滚 pending 并重新计时)。
136
148
  this.consumeNonce(ctx.requestId, ctx.nonce);
@@ -301,6 +313,12 @@ export class ApprovalCoordinator {
301
313
  return;
302
314
  tracked.view = view;
303
315
  }
316
+ /**
317
+ * 用户是否在审批卡上主动点了「取消」(终态归因用;提交失败不置位)。
318
+ */
319
+ get cancelledByUser() {
320
+ return this.userCancelled;
321
+ }
304
322
  /**
305
323
  * Get the number of pending approvals.
306
324
  */
@@ -50,10 +50,36 @@ const TERMINAL_REACTION_EMOJI = {
50
50
  error: 'ERROR',
51
51
  idle_timeout: 'Alarm',
52
52
  interrupted: 'SHHH',
53
+ // 本轮结束但目标继续:任务还没完,不能打 ✓(Done)。
54
+ goal_continuing: 'OnIt',
53
55
  };
54
56
  function terminalReactionEmoji(terminal) {
55
57
  return TERMINAL_REACTION_EMOJI[terminal] ?? 'Done';
56
58
  }
59
+ /**
60
+ * 完成通知卡的标题 / 配色 / 结果行。
61
+ *
62
+ * 这张卡只在流式卡失败(settle==='unsent')时补发,但终态必须如实反映:
63
+ * 写死「✅ 会话已完成」会把出错/中断的 run 报成成功。
64
+ */
65
+ function completionNotificationPresentation(terminal) {
66
+ switch (terminal) {
67
+ case 'error':
68
+ return { title: '⚠️ 会话出错', template: 'red' };
69
+ case 'interrupted':
70
+ return { title: '⏹ 会话已中断', template: 'grey' };
71
+ case 'idle_timeout':
72
+ return { title: '⏱ 会话已超时', template: 'orange' };
73
+ case 'goal_continuing':
74
+ return { title: '⏳ 本轮结束,目标继续', template: 'orange' };
75
+ case 'finalizing':
76
+ return { title: '⏳ 会话仍在收尾', template: 'orange' };
77
+ case 'running':
78
+ return { title: '🔴 会话运行中', template: 'blue' };
79
+ case 'done':
80
+ return { title: '✅ 会话已完成', template: 'green', result: 'completed' };
81
+ }
82
+ }
57
83
  /**
58
84
  * 单点化:`'runCompact' in runner` 鸭子探测
59
85
  * codex app-server + kimi/opencode ACP runner 满足。
@@ -1194,7 +1220,7 @@ export class Bridge {
1194
1220
  * @param dropFromCard 该 result 是陈旧重放,不得进卡片(调用方 continue 跳过 push)
1195
1221
  */
1196
1222
  observeResultEvent(event, opts) {
1197
- const { sawInit, usageAuthority, usage, writeback, runId } = opts;
1223
+ const { sawInit, liveResultEvents, usage, writeback, runId } = opts;
1198
1224
  const preInitResult = !sawInit;
1199
1225
  const preInitTerminal = preInitResult && (event.subtype === 'error' || event.subtype === 'interrupted')
1200
1226
  ? {
@@ -1202,7 +1228,7 @@ export class Bridge {
1202
1228
  errorMessage: event.errorMessage,
1203
1229
  }
1204
1230
  : undefined;
1205
- if (preInitResult && usageAuthority !== 'live') {
1231
+ if (preInitResult && !liveResultEvents) {
1206
1232
  getLogger().info(`[lark-remote] pre-init result ignored (resume replay) runId=${runId}`);
1207
1233
  return { sawResult: false, dropFromCard: true, preInitTerminal };
1208
1234
  }
@@ -1210,11 +1236,11 @@ export class Bridge {
1210
1236
  getLogger().info(preInitResult
1211
1237
  ? `[lark-remote] pre-init result from live runner (app-server setup failure) runId=${runId}`
1212
1238
  : `[lark-remote] result event received runId=${runId}`);
1213
- // app-server 模式 setup 失败路径(thread/start 成功后 turn/start 失败)不会有
1214
- // turn_started 事件,store 不会写回新线程 id;runner 已在 result 里兜底上报。
1215
- // 仅当 runner 声明 live usage(app-server)且会话指针未动时写回,避免下条消息
1216
- // 再开一个孤儿线程。其余 agent 不在此列(各自有 system.init / turn_started 写回)。
1217
- if (usageAuthority === 'live')
1239
+ // 连接型 runner 的 setup 失败路径(thread/start 成功后 turn/start 失败)
1240
+ // 不会有 turn_started 事件,store 不会写回新线程 id;runner 已在 result 里
1241
+ // 兜底上报。仅当 runner 声明 live result 语义且会话指针未动时写回,避免
1242
+ // 下条消息再开一个孤儿线程。其余 agent 不在此列(各自有 init/turn_started 写回)。
1243
+ if (liveResultEvents)
1218
1244
  writeBackResultSession(writeback, event.session_id);
1219
1245
  return { sawResult: !preInitResult, dropFromCard: false, preInitTerminal };
1220
1246
  }
@@ -1263,7 +1289,7 @@ export class Bridge {
1263
1289
  * 4) 已终态且未收到 result(如 spawn 失败后 interrupted)→ 无 usage 可补,跳过
1264
1290
  */
1265
1291
  async finishRunAfterStream(opts) {
1266
- const { cardSession, usage, sawResult, pendingPreInitTerminal, agentKind, runId } = opts;
1292
+ const { cardSession, usage, sawResult, sawAnyResult, pendingPreInitTerminal, agentKind, runId, } = opts;
1267
1293
  const finalState = cardSession.currentState;
1268
1294
  if (finalState.terminal === 'finalizing') {
1269
1295
  // 三态终态映射(2026-08-14):interrupted(审批超时/取消、/stop)是独立
@@ -1280,9 +1306,14 @@ export class Bridge {
1280
1306
  : goalContinues
1281
1307
  ? 'goal_continuing'
1282
1308
  : 'done';
1309
+ // 中断归因:用户主动取消审批(cancel)优先于"未归类"。用户 /stop 的
1310
+ // 路径先置了终态(interrupted + user_stop),不会走到这里。
1311
+ const approvalCancelled = terminal === 'interrupted' &&
1312
+ this.approvalCoordinators.get(runId)?.cancelledByUser === true;
1283
1313
  await cardSession.finish(terminal, {
1284
1314
  resultSubtype: subtype,
1285
1315
  errorMsg: finalState.errorMsg,
1316
+ ...(approvalCancelled ? { interruptedReason: 'approval_cancelled' } : {}),
1286
1317
  ...usage.meta(),
1287
1318
  });
1288
1319
  return;
@@ -1300,7 +1331,13 @@ export class Bridge {
1300
1331
  else {
1301
1332
  await cardSession.finish('error', {
1302
1333
  ...usage.meta(),
1303
- errorMsg: agentDisplayName(agentKind) + ' 输出流已结束,但未收到 result 事件',
1334
+ // sawAnyResult=true 说明 result 收到了、只是没能落到卡片(reducer 的
1335
+ // sessionId 守卫或 pre-init 守卫挡下)。报文不能再说"未收到 result
1336
+ // 事件",否则排障会去查输出流而不是查守卫。
1337
+ errorMsg: agentDisplayName(agentKind) +
1338
+ (sawAnyResult
1339
+ ? ' 输出流已结束,但本轮结果未能落到卡片上'
1340
+ : ' 输出流已结束,但未收到 result 事件'),
1304
1341
  });
1305
1342
  }
1306
1343
  return;
@@ -1316,12 +1353,17 @@ export class Bridge {
1316
1353
  * Handles idle watchdog, event processing, session sync, and state transitions.
1317
1354
  */
1318
1355
  async runAgentStreamToEnd(runner, cardSession, activeRun, sessionId, sessionEpochAtStart, ctx, cwd, runId, message, agentKind = this.config.defaultAgent) {
1319
- // Runner 声明的 usage 权威来源:codex app-server = 'live'
1320
- // (turn/started 的 tokenUsage.last 是本 turn 增量)。其余 runner 未声明。
1356
+ // Runner 声明的 usage 权威来源:codex app-server / pi RPC = 'live'
1357
+ // (live 带 input/output);opencode/kimi ACP live 只有 context,声明
1358
+ // 'jsonl'。result 事件的 live 语义(pre-init/写回)单独声明,不能和
1359
+ // 用量来源共用一个字段。
1321
1360
  const usageAuthority = runner.getUsageAuthority?.();
1361
+ const liveResultEvents = runner.usesLiveResultEvents?.() ?? usageAuthority === 'live';
1322
1362
  // §9.12 空闲看门狗:判定口径见 startIdleWatchdog;武装点在 cardSession.start() 之后。
1323
1363
  let watchdog = null;
1324
1364
  let sawResult = false;
1365
+ /** 收到过任何 result(含被守卫丢弃/未进卡片的):收尾报文据此措辞。 */
1366
+ let sawAnyResult = false;
1325
1367
  // A runner can fail before emitting its synthetic system/init (e.g. DSH
1326
1368
  // session/create -> 401). The pre-init guard below drops that result to
1327
1369
  // protect Claude resume replay, but if the stream ends without ever seeing
@@ -1345,9 +1387,14 @@ export class Bridge {
1345
1387
  // Card stream failed - continue with static fallback below, but ensure
1346
1388
  // we don't silently fail if the fallback also fails (covered in catch below).
1347
1389
  }
1348
- if (cardSession.currentState.terminal !== 'running') {
1349
- // Card session did not enter running state - still try to send static fallback
1350
- getLogger().info(`[lark-remote] cardSession not running, using static fallback`);
1390
+ // /stop(或队列「立即执行」的中断)可能落在建卡窗口内:卡片已定格为
1391
+ // 终态,但 runner 还没开始跑,runner.stop() 也没有进程可杀——spawn 型
1392
+ // 与连接型 runner 的 stop() 在 run() 之前都是空操作,且 run() 入口会
1393
+ // 重置停止标记。必须在这里收口,否则用户看到「已中断」,agent 仍会把
1394
+ // 这一轮跑完并改写工作区。finalizeRun 仍会照常收尾(settle / reaction)。
1395
+ if (isRunTerminal(cardSession.currentState.terminal)) {
1396
+ getLogger().info(`[lark-remote] card terminal before run start, skip runner runId=${runId} terminal=${cardSession.currentState.terminal}`);
1397
+ return;
1351
1398
  }
1352
1399
  // 单 interval 看门狗。事件到来时只刷新时间戳(无 timer 操作);
1353
1400
  // interval 周期检查静默时长,超时才 finish('idle_timeout') + stop()。
@@ -1409,7 +1456,7 @@ export class Bridge {
1409
1456
  if (event.type === 'result') {
1410
1457
  const outcome = this.observeResultEvent(event, {
1411
1458
  sawInit,
1412
- usageAuthority,
1459
+ liveResultEvents,
1413
1460
  usage,
1414
1461
  writeback,
1415
1462
  runId,
@@ -1418,6 +1465,7 @@ export class Bridge {
1418
1465
  pendingPreInitTerminal ??= outcome.preInitTerminal;
1419
1466
  if (outcome.sawResult)
1420
1467
  sawResult = true;
1468
+ sawAnyResult = true;
1421
1469
  // 陈旧 result 不进卡片:run-state reducer 本来也会因 sessionId 未定而
1422
1470
  // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1423
1471
  if (outcome.dropFromCard) {
@@ -1464,6 +1512,7 @@ export class Bridge {
1464
1512
  cardSession,
1465
1513
  usage,
1466
1514
  sawResult,
1515
+ sawAnyResult,
1467
1516
  pendingPreInitTerminal,
1468
1517
  agentKind,
1469
1518
  runId,
@@ -1561,7 +1610,7 @@ export class Bridge {
1561
1610
  // but NOT when streaming succeeded (card already shows completion).
1562
1611
  const sessionId = cardSession.currentState.sessionId;
1563
1612
  if (sessionId && runCardSent) {
1564
- await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind);
1613
+ await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind, cardSession.currentState.terminal);
1565
1614
  }
1566
1615
  if (finalRender.finalAnswerStatus === 'overflow' && finalRender.finalAnswerText) {
1567
1616
  await this.sendFinalAnswerOverflow(finalRender.finalAnswerText, ctx);
@@ -1609,7 +1658,8 @@ export class Bridge {
1609
1658
  * It's sent as a new message (not updating the run card) so users can easily
1610
1659
  * find and return to this session without scrolling up.
1611
1660
  */
1612
- async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent) {
1661
+ async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent, terminal = 'done') {
1662
+ const presentation = completionNotificationPresentation(terminal);
1613
1663
  try {
1614
1664
  // cap the events read for the notification card — without
1615
1665
  // maxEvents claude returns every event after the last user message and
@@ -1626,10 +1676,10 @@ export class Bridge {
1626
1676
  }, {
1627
1677
  agentKind,
1628
1678
  headerText: `📂 \`${cwd}\`\n会话: **${sessionId}**`,
1629
- title: '✅ 会话已完成',
1630
- usageResult: 'completed',
1679
+ title: presentation.title,
1680
+ ...(presentation.result ? { usageResult: presentation.result } : {}),
1631
1681
  actions: [resumeUseButton(sessionId, agentKind)],
1632
- headerTemplate: 'green',
1682
+ headerTemplate: presentation.template,
1633
1683
  });
1634
1684
  // send through sendResult so enforceCardBudget guards the card
1635
1685
  // (previously sent via connector directly, bypassing the budget — long
@@ -1861,14 +1911,15 @@ export class Bridge {
1861
1911
  }
1862
1912
  await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
1863
1913
  const compactRunId = randomUUID();
1914
+ // 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
1915
+ // 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
1916
+ const renderOptions = { agentKind };
1864
1917
  const cardSession = new RunCardSession({
1865
1918
  connector: this.connector,
1866
1919
  chatId: ctx.chatId,
1867
1920
  replyTo: ctx.messageId,
1868
1921
  runId: compactRunId,
1869
- renderOptions: {
1870
- agentKind,
1871
- },
1922
+ renderOptions,
1872
1923
  });
1873
1924
  // 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
1874
1925
  // /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
@@ -1927,8 +1978,11 @@ export class Bridge {
1927
1978
  // 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
1928
1979
  // token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
1929
1980
  // (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
1981
+ // 不支持 compactCount 的 reader(opencode/dsh)不重试——否则每次 compact
1982
+ // 都会固定多跑两次完整 export/读取,且永远等不到该字段。
1983
+ const compactCountSupported = this.sessionReaderRegistry.tryGet(agentKind)?.supportsCompactCount !== false;
1930
1984
  let finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
1931
- for (let attempt = 0; !finalUsage?.compactCount && attempt < 2; attempt++) {
1985
+ for (let attempt = 0; compactCountSupported && !finalUsage?.compactCount && attempt < 2; attempt++) {
1932
1986
  await sleep(150);
1933
1987
  finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
1934
1988
  }
@@ -1955,6 +2009,15 @@ export class Bridge {
1955
2009
  }
1956
2010
  finally {
1957
2011
  watchdog?.stop();
2012
+ // 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
2013
+ // 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
2014
+ // 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
2015
+ // 和一条 reaction,用户既不知道失败也不知道原因。
2016
+ const settled = await cardSession.settle();
2017
+ if (settled === 'unsent') {
2018
+ const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
2019
+ await this.sendResult({ card: finalRender.card }, ctx);
2020
+ }
1958
2021
  // 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
1959
2022
  if (this.activeRuns.get(cwd) === activeRun) {
1960
2023
  this.activeRuns.delete(cwd);
@@ -28,5 +28,10 @@ export declare class RunUsageCollector {
28
28
  observeResult(event: ResultEvent): void;
29
29
  finalize(finalUsage: FinalUsageSnapshot | undefined, authority: UsageAuthority | undefined): void;
30
30
  meta(): UsageSnapshot;
31
+ /**
32
+ * 收尾探针。input/output 必须在这里出现:卡片"本 run"的这两个数来自
33
+ * `resolveFinalUsage`(ACP 的 live 层只有 context 占用、没有 input/output),
34
+ * 不带它们时日志无法区分"本 run 合计"与"末次请求"。
35
+ */
31
36
  logSummary(runId: string): string;
32
37
  }
@@ -79,9 +79,14 @@ export class RunUsageCollector {
79
79
  meta() {
80
80
  return this.resolved;
81
81
  }
82
+ /**
83
+ * 收尾探针。input/output 必须在这里出现:卡片"本 run"的这两个数来自
84
+ * `resolveFinalUsage`(ACP 的 live 层只有 context 占用、没有 input/output),
85
+ * 不带它们时日志无法区分"本 run 合计"与"末次请求"。
86
+ */
82
87
  logSummary(runId) {
83
88
  const meta = this.meta();
84
- return `[lark-remote] final usage runId=${runId} contextLength=${meta.contextLength} contextLimit=${meta.contextLimit ?? 'undefined'} compactCount=${meta.compactCount ?? 'undefined'} cacheRead=${meta.cacheReadTokens ?? 'undefined'} cacheCreate=${meta.cacheCreationTokens ?? 'undefined'}`;
89
+ return `[lark-remote] final usage runId=${runId} contextLength=${meta.contextLength} contextLimit=${meta.contextLimit ?? 'undefined'} compactCount=${meta.compactCount ?? 'undefined'} input=${meta.inputTokens ?? 'undefined'} output=${meta.outputTokens ?? 'undefined'} cacheRead=${meta.cacheReadTokens ?? 'undefined'} cacheCreate=${meta.cacheCreationTokens ?? 'undefined'} total=${meta.totalTokens ?? 'undefined'}`;
85
90
  }
86
91
  }
87
92
  //# sourceMappingURL=run-usage.js.map
@@ -1137,13 +1137,12 @@ function buildSummaryContent(state, options = {}) {
1137
1137
  }
1138
1138
  }
1139
1139
  else if (state.terminal === 'interrupted') {
1140
- // 审批超时(approval_expired 已标记)是「无人响应被自动取消」,不是用户
1141
- // 主动终止;如实展示原因,避免用户误判为 Agent 出错或自己操作过。
1142
- // 顶层 approvalExpired 优先:approval_resolved 会移除审批条目,但标记
1143
- // 必须活到终态渲染(2026-08-15 事故回归)。
1140
+ // 中断来源三元:审批超时 / 用户取消审批 / 用户主动终止。
1141
+ // 显式原因优先:approvalExpired 是粘性标记(cancel 语义下 agent 可能继续跑),
1142
+ // 用户随后主动 /stop 时若仍按过期渲染,就把「我自己停的」误报成自动取消。
1144
1143
  const expiredApproval = state.interruptedReason === 'approval_timeout' ||
1145
- state.approvalExpired === true ||
1146
- (state.approvals ?? []).some((a) => a.expired);
1144
+ (state.interruptedReason === undefined &&
1145
+ (state.approvalExpired === true || (state.approvals ?? []).some((a) => a.expired)));
1147
1146
  elements.push(markdownDiv(expiredApproval
1148
1147
  ? '⏰ **审批超时未响应,已自动取消**'
1149
1148
  : state.interruptedReason === 'approval_cancelled'
@@ -1165,6 +1164,9 @@ function buildSummaryContent(state, options = {}) {
1165
1164
  : state.operationKind === 'compaction'
1166
1165
  ? '\n\n🗜 Compact 完成'
1167
1166
  : '\n\n(未返回内容)';
1167
+ // goal 本 run 读不到(known=false)时不能写「已完成」:这是 GoalStateEvent
1168
+ // 契约里明令禁止的推断(读不到 ≠ 没有目标)。run 本身成功,只是目标维度未知。
1169
+ const goalUnknown = state.terminal === 'done' && state.goalKnown === false;
1168
1170
  // RunState 的用量字段就是 formatUsageStats 的入参形态(同为
1169
1171
  // `UsageSnapshot`)——原先这里逐字段抄了 17 行,加一个字段要同时改
1170
1172
  // RunState、FinishMeta 和这张清单(§9.21)。
@@ -1174,7 +1176,9 @@ function buildSummaryContent(state, options = {}) {
1174
1176
  // 本轮结束但目标继续:不能报"已完成",也不能说"出错"。
1175
1177
  ...(state.terminal === 'goal_continuing'
1176
1178
  ? { completedLabel: '⏳ 本轮结束 · 目标仍在继续' }
1177
- : {}),
1179
+ : goalUnknown
1180
+ ? { completedLabel: '✅ 本轮完成 · 目标状态未确认' }
1181
+ : {}),
1178
1182
  });
1179
1183
  elements.push(markdownDiv(usageStatsStr + empty));
1180
1184
  // 后台任务:跨 turn 生命周期,必须显式告知并可逐个停止。
@@ -1302,7 +1306,17 @@ function headerTitle(state, options = {}) {
1302
1306
  if (state.terminal === 'goal_continuing')
1303
1307
  return `⏳ ${agent} · 本轮结束(目标继续)`;
1304
1308
  if (state.terminal === 'done') {
1305
- return state.goal?.phase === 'complete' ? `✅ ${agent} · 目标已完成` : `✅ ${agent} · 已完成`;
1309
+ // goal 维度优先于「完成」:blocked/paused 不是任务做完了,complete 才是;
1310
+ // 读不到(goalKnown=false)时也不能替宿主宣布完成。
1311
+ if (state.goal?.phase === 'complete')
1312
+ return `✅ ${agent} · 目标已完成`;
1313
+ if (state.goal?.phase === 'blocked')
1314
+ return `⛔ ${agent} · 目标被阻断`;
1315
+ if (state.goal?.phase === 'paused')
1316
+ return `⏸ ${agent} · 目标已暂停`;
1317
+ if (state.goalKnown === false)
1318
+ return `✅ ${agent} · 完成(目标未确认)`;
1319
+ return `✅ ${agent} · 已完成`;
1306
1320
  }
1307
1321
  if (state.terminal === 'error')
1308
1322
  return `⚠️ ${agent} · 出错`;
@@ -12,6 +12,7 @@ import { AGENT_KINDS, resolveAgentKind } from '../agent-kinds.js';
12
12
  import { getLogger } from '../logger/index.js';
13
13
  import { stripPlaceholders } from '../inbound/placeholder.js';
14
14
  import { clampInt } from '../common/clamp.js';
15
+ import { isRunTerminal } from '../card/run-state.js';
15
16
  import { activeRunUsage } from './active-run-usage.js';
16
17
  import { markdownDiv, buildSessionHistoryCard, paginationBar, parseJumpOffset, SEARCH_INPUT_NAME, PAGE_JUMP_INVALID_HINT, } from '../card/card-helpers.js';
17
18
  import { MAX_FILE_UPLOAD_SIZE } from '../connector/file-limits.js';
@@ -2496,14 +2497,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
2496
2497
  }
2497
2498
  const { events, usage, aiTitle, recap, displayTitle } = reader.readSessionContent(sessionId, cwd, { maxEvents: opts?.maxEvents });
2498
2499
  const activeRun = this.bridge.getActiveRunFor(cwd);
2499
- // 只检查内存中的 activeRun 状态
2500
- // 只有当 activeRun 存在且不是终态时,才认为任务仍在进行中
2500
+ // 只检查内存中的 activeRun 状态:存在且未收尾才算进行中。
2501
+ // 终态集合取 run-state 的单源判据(新增终态时不会漏掉这一处)。
2501
2502
  const activeRunning = activeRun !== undefined &&
2502
- activeRun.terminal !== 'done' &&
2503
- activeRun.terminal !== 'error' &&
2504
- activeRun.terminal !== 'interrupted' &&
2505
- activeRun.terminal !== 'idle_timeout' &&
2506
- activeRun.terminal !== 'goal_continuing' &&
2503
+ !isRunTerminal(activeRun.terminal) &&
2507
2504
  activeRun.sessionId === sessionId;
2508
2505
  const activeUsage = activeRunUsage(activeRun);
2509
2506
  return {
@@ -73,6 +73,22 @@ export declare abstract class BaseAcpRunner<TTranslator extends BaseAcpTranslato
73
73
  */
74
74
  protected abstract onCompactPromptFired(promptPromise: Promise<SessionPromptResult>, translator: TTranslator, sessionId: string, opts: SpawnOptions, baselineTotalCount: number): void;
75
75
  private withSessionSetupRetry;
76
+ /**
77
+ * 建立会话(session/new|resume),重试耗尽后丢弃该 cwd 缓存的连接。
78
+ *
79
+ * 「服务端未就绪」类错误(isRetryableSessionSetupError)说明这个 agent server
80
+ * 自身半死:opencode v2 的 location 在每轮拉起的 `opencode acp` 子进程里 boot,
81
+ * boot 期间后台服务不可用会让它长期停在未激活,后续轮次复用同一连接只会重复
82
+ * 失败——用户重启 opencode service 也治不好,因为连接缓存在本进程里
83
+ * (2026-10-30 现场:4 次重试全失败,重启服务与 lark-remote 都无效)。丢掉连接
84
+ * 后下一轮重新 spawn,拿到的是新进程的新状态。
85
+ *
86
+ * 只对声明为可重试的错误生效:kimi 等沿用默认 `isRetryableSessionSetupError`
87
+ * = false,行为不变(session id 不存在之类的确定性失败不踢连接)。
88
+ */
89
+ private setupSessionWithRetry;
90
+ /** Dispose the cached connection for `cwd`; never mask the setup error. */
91
+ private discardConnection;
76
92
  protected currentSessionId(): string | null;
77
93
  protected cancelCurrentTurn(): Promise<void>;
78
94
  protected clearTurnState(): void;
@@ -58,6 +58,40 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
58
58
  }
59
59
  }
60
60
  }
61
+ /**
62
+ * 建立会话(session/new|resume),重试耗尽后丢弃该 cwd 缓存的连接。
63
+ *
64
+ * 「服务端未就绪」类错误(isRetryableSessionSetupError)说明这个 agent server
65
+ * 自身半死:opencode v2 的 location 在每轮拉起的 `opencode acp` 子进程里 boot,
66
+ * boot 期间后台服务不可用会让它长期停在未激活,后续轮次复用同一连接只会重复
67
+ * 失败——用户重启 opencode service 也治不好,因为连接缓存在本进程里
68
+ * (2026-10-30 现场:4 次重试全失败,重启服务与 lark-remote 都无效)。丢掉连接
69
+ * 后下一轮重新 spawn,拿到的是新进程的新状态。
70
+ *
71
+ * 只对声明为可重试的错误生效:kimi 等沿用默认 `isRetryableSessionSetupError`
72
+ * = false,行为不变(session id 不存在之类的确定性失败不踢连接)。
73
+ */
74
+ async setupSessionWithRetry(cwd, fn) {
75
+ try {
76
+ return await this.withSessionSetupRetry(fn);
77
+ }
78
+ catch (err) {
79
+ if (this.isRetryableSessionSetupError(err)) {
80
+ await this.discardConnection(cwd);
81
+ }
82
+ throw err;
83
+ }
84
+ }
85
+ /** Dispose the cached connection for `cwd`; never mask the setup error. */
86
+ async discardConnection(cwd) {
87
+ try {
88
+ await this.connectionManager.release(cwd);
89
+ getLogger().warn(`[${this.logTag}] dropped cached connection after session setup failure cwd=${cwd}`);
90
+ }
91
+ catch (err) {
92
+ getLogger().warn(`[${this.logTag}] failed to drop cached connection cwd=${cwd}: ${err.message}`);
93
+ }
94
+ }
61
95
  currentSessionId() {
62
96
  return this.activeSessionId;
63
97
  }
@@ -203,13 +237,13 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
203
237
  sessionId: opts.sessionId,
204
238
  cwd: opts.cwd,
205
239
  };
206
- const resumeResult = await this.withSessionSetupRetry(() => client.request('session/resume', resumeParams));
240
+ const resumeResult = await this.setupSessionWithRetry(opts.cwd, () => client.request('session/resume', resumeParams));
207
241
  this.onSessionEstablished(resumeResult);
208
242
  sessionId = opts.sessionId;
209
243
  }
210
244
  else {
211
245
  const newParams = { cwd: opts.cwd, mcpServers: [] };
212
- const newResult = await this.withSessionSetupRetry(() => client.request('session/new', newParams));
246
+ const newResult = await this.setupSessionWithRetry(opts.cwd, () => client.request('session/new', newParams));
213
247
  this.onSessionEstablished(newResult);
214
248
  sessionId = newResult.sessionId;
215
249
  }
@@ -286,7 +320,7 @@ export class BaseAcpRunner extends ConnectionBasedRunner {
286
320
  // memory. On a reused connection the session is already loaded —
287
321
  // resume is idempotent and harmless.
288
322
  const resumeParams = { sessionId, cwd: opts.cwd };
289
- const resumeResult = await this.withSessionSetupRetry(() => client.request('session/resume', resumeParams));
323
+ const resumeResult = await this.setupSessionWithRetry(opts.cwd, () => client.request('session/resume', resumeParams));
290
324
  this.onSessionEstablished(resumeResult);
291
325
  const translator = this.createTranslator();
292
326
  translator.setOperationKind('compact');
@@ -122,7 +122,9 @@ export declare abstract class ConnectionBasedRunner<TClient, TEvent = AgentEvent
122
122
  * One-way by design: an evicted instance never returns to the cache.
123
123
  */
124
124
  markOrphaned(): void;
125
- getUsageAuthority(): 'live';
125
+ getUsageAuthority(): 'live' | 'jsonl';
126
+ /** 连接型 runner 的 result 事件都是 runner 自己产出的 live 语义。 */
127
+ usesLiveResultEvents(): boolean;
126
128
  killOrphan(): void;
127
129
  registerExitHandlers(): void;
128
130
  unregisterExitHandlers(): void;
@@ -221,6 +221,10 @@ export class ConnectionBasedRunner {
221
221
  getUsageAuthority() {
222
222
  return 'live';
223
223
  }
224
+ /** 连接型 runner 的 result 事件都是 runner 自己产出的 live 语义。 */
225
+ usesLiveResultEvents() {
226
+ return true;
227
+ }
224
228
  killOrphan() {
225
229
  // No-op: the connection is managed by ConnectionManager.
226
230
  }
@@ -430,6 +430,11 @@ export class DshClient {
430
430
  // 拿不到投影 = 宿主没这个会话的投影,不等于"没有 goal"。
431
431
  if (!snapshot)
432
432
  return { known: false, goal: null };
433
+ // 投影快照存在但没有 goal 域 = 宿主明确没有目标(未用过 goal 的会话就是这样)。
434
+ // 不能把缺失当成"读不懂":goalFromProjection(undefined) 也返回 undefined,
435
+ // 那会把每次普通会话都误报成「目标状态未确认」。
436
+ if (!('goal' in snapshot.values))
437
+ return { known: true, goal: null };
433
438
  const parsed = goalFromProjection(snapshot.values.goal);
434
439
  if (parsed === undefined)
435
440
  return { known: false, goal: null };