lark-remote 0.5.14 → 0.5.15

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,22 @@ 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.15] - 2026-10-31
9
+
10
+ ### 修复
11
+
12
+ - **`/stop` 落在「刚发消息、卡片还没建好」的窗口里不再空跑整轮**:此前卡片已经定格成「已中断」,但 agent 进程还没起来、没有可杀的进程,`run()` 入口又会把停止标记重置回去,于是整轮照常跑完并继续改动工作区。现在卡片一旦进入终态就直接收口,不再启动 agent
13
+ - **中断归因不再串台**:显式 `/stop` 不再被上一次遗留的粘性「审批超时」状态盖掉;在审批卡上点「取消」现在会如实归因成用户取消并由卡片透传,终态卡不再显示「审批超时」(该文案此前实际不可达)
14
+ - **目标状态读不到时不再谎报「已完成」**:终态复核探测不到目标状态时显式标记为「未知」;投影里缺少目标域按「明确没有目标」处理,不会把普通会话误标成「目标未确认」
15
+ - **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 标记的旧数据沿用原语义
16
+ - **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 分钟重发」
17
+ - **`/compact` 的卡片流启动失败时补发静态终态卡**,不再只剩一条「Compact 已触发」加一个表情;兜底的完成通知卡标题跟随真实终态
18
+ - **收尾报文更如实**:区分「没收到结果」与「结果没落到卡片上」,不再用同一句话盖掉两种原因
19
+
20
+ ### 变更
21
+
22
+ - run 终态卡文案更如实:目标被阻断 / 已暂停分别显示「目标被阻断」「目标已暂停」,不再统一写「已完成」;目标读取失败时标题写「完成(目标未确认)」、摘要首行同步;`goal_continuing` 的原消息表情由 Done 改为 OnIt;run 是否活跃改用统一的终态判据收口
23
+
8
24
  ## [0.5.14] - 2026-10-28
9
25
 
10
26
  ### 修复
@@ -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 满足。
@@ -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;
@@ -1322,6 +1359,8 @@ export class Bridge {
1322
1359
  // §9.12 空闲看门狗:判定口径见 startIdleWatchdog;武装点在 cardSession.start() 之后。
1323
1360
  let watchdog = null;
1324
1361
  let sawResult = false;
1362
+ /** 收到过任何 result(含被守卫丢弃/未进卡片的):收尾报文据此措辞。 */
1363
+ let sawAnyResult = false;
1325
1364
  // A runner can fail before emitting its synthetic system/init (e.g. DSH
1326
1365
  // session/create -> 401). The pre-init guard below drops that result to
1327
1366
  // protect Claude resume replay, but if the stream ends without ever seeing
@@ -1345,9 +1384,14 @@ export class Bridge {
1345
1384
  // Card stream failed - continue with static fallback below, but ensure
1346
1385
  // we don't silently fail if the fallback also fails (covered in catch below).
1347
1386
  }
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`);
1387
+ // /stop(或队列「立即执行」的中断)可能落在建卡窗口内:卡片已定格为
1388
+ // 终态,但 runner 还没开始跑,runner.stop() 也没有进程可杀——spawn 型
1389
+ // 与连接型 runner 的 stop() 在 run() 之前都是空操作,且 run() 入口会
1390
+ // 重置停止标记。必须在这里收口,否则用户看到「已中断」,agent 仍会把
1391
+ // 这一轮跑完并改写工作区。finalizeRun 仍会照常收尾(settle / reaction)。
1392
+ if (isRunTerminal(cardSession.currentState.terminal)) {
1393
+ getLogger().info(`[lark-remote] card terminal before run start, skip runner runId=${runId} terminal=${cardSession.currentState.terminal}`);
1394
+ return;
1351
1395
  }
1352
1396
  // 单 interval 看门狗。事件到来时只刷新时间戳(无 timer 操作);
1353
1397
  // interval 周期检查静默时长,超时才 finish('idle_timeout') + stop()。
@@ -1418,6 +1462,7 @@ export class Bridge {
1418
1462
  pendingPreInitTerminal ??= outcome.preInitTerminal;
1419
1463
  if (outcome.sawResult)
1420
1464
  sawResult = true;
1465
+ sawAnyResult = true;
1421
1466
  // 陈旧 result 不进卡片:run-state reducer 本来也会因 sessionId 未定而
1422
1467
  // 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
1423
1468
  if (outcome.dropFromCard) {
@@ -1464,6 +1509,7 @@ export class Bridge {
1464
1509
  cardSession,
1465
1510
  usage,
1466
1511
  sawResult,
1512
+ sawAnyResult,
1467
1513
  pendingPreInitTerminal,
1468
1514
  agentKind,
1469
1515
  runId,
@@ -1561,7 +1607,7 @@ export class Bridge {
1561
1607
  // but NOT when streaming succeeded (card already shows completion).
1562
1608
  const sessionId = cardSession.currentState.sessionId;
1563
1609
  if (sessionId && runCardSent) {
1564
- await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind);
1610
+ await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind, cardSession.currentState.terminal);
1565
1611
  }
1566
1612
  if (finalRender.finalAnswerStatus === 'overflow' && finalRender.finalAnswerText) {
1567
1613
  await this.sendFinalAnswerOverflow(finalRender.finalAnswerText, ctx);
@@ -1609,7 +1655,8 @@ export class Bridge {
1609
1655
  * It's sent as a new message (not updating the run card) so users can easily
1610
1656
  * find and return to this session without scrolling up.
1611
1657
  */
1612
- async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent) {
1658
+ async sendCompletionNotificationCard(sessionId, cwd, ctx, agentKind = this.config.defaultAgent, terminal = 'done') {
1659
+ const presentation = completionNotificationPresentation(terminal);
1613
1660
  try {
1614
1661
  // cap the events read for the notification card — without
1615
1662
  // maxEvents claude returns every event after the last user message and
@@ -1626,10 +1673,10 @@ export class Bridge {
1626
1673
  }, {
1627
1674
  agentKind,
1628
1675
  headerText: `📂 \`${cwd}\`\n会话: **${sessionId}**`,
1629
- title: '✅ 会话已完成',
1630
- usageResult: 'completed',
1676
+ title: presentation.title,
1677
+ ...(presentation.result ? { usageResult: presentation.result } : {}),
1631
1678
  actions: [resumeUseButton(sessionId, agentKind)],
1632
- headerTemplate: 'green',
1679
+ headerTemplate: presentation.template,
1633
1680
  });
1634
1681
  // send through sendResult so enforceCardBudget guards the card
1635
1682
  // (previously sent via connector directly, bypassing the budget — long
@@ -1861,14 +1908,15 @@ export class Bridge {
1861
1908
  }
1862
1909
  await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
1863
1910
  const compactRunId = randomUUID();
1911
+ // 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
1912
+ // 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
1913
+ const renderOptions = { agentKind };
1864
1914
  const cardSession = new RunCardSession({
1865
1915
  connector: this.connector,
1866
1916
  chatId: ctx.chatId,
1867
1917
  replyTo: ctx.messageId,
1868
1918
  runId: compactRunId,
1869
- renderOptions: {
1870
- agentKind,
1871
- },
1919
+ renderOptions,
1872
1920
  });
1873
1921
  // 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
1874
1922
  // /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
@@ -1955,6 +2003,15 @@ export class Bridge {
1955
2003
  }
1956
2004
  finally {
1957
2005
  watchdog?.stop();
2006
+ // 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
2007
+ // 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
2008
+ // 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
2009
+ // 和一条 reaction,用户既不知道失败也不知道原因。
2010
+ const settled = await cardSession.settle();
2011
+ if (settled === 'unsent') {
2012
+ const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
2013
+ await this.sendResult({ card: finalRender.card }, ctx);
2014
+ }
1958
2015
  // 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
1959
2016
  if (this.activeRuns.get(cwd) === activeRun) {
1960
2017
  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');
@@ -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 };
@@ -482,10 +482,17 @@ export class DshRunner {
482
482
  timeoutMs: GOAL_PROBE_TIMEOUT_MS,
483
483
  });
484
484
  const lastGoal = seedGoal.known ? seedGoal.goal : undefined;
485
- // 只有确实存在 goal 才产事件:没有 goal 的会话保持事件序列不变(少一次
486
- // 渲染,也不让"读不到"变成一张多出来的卡片事件)。
487
- if (seedGoal.known && seedGoal.goal)
488
- yield goalStateEvent(seedGoal.goal, true);
485
+ if (seedGoal.known) {
486
+ // 只有确实存在 goal 才产事件:没有 goal 的会话保持事件序列不变(少一次
487
+ // 渲染,也不让"读不到"变成一张多出来的卡片事件)。
488
+ if (seedGoal.goal)
489
+ yield goalStateEvent(seedGoal.goal, true);
490
+ }
491
+ else {
492
+ // 读不到 ≠ 没有目标。必须显式把 unknown 交给卡片,否则收尾只能按
493
+ // 「没有 goal」写成「已完成」——正是 GoalStateEvent 契约禁止的推断。
494
+ yield goalStateEvent(null, false);
495
+ }
489
496
  try {
490
497
  const submitted = await this.submitPrompt(sessionId, opts.cwd, message, abort.signal);
491
498
  promptRequestId = submitted.promptRequestId;
@@ -497,7 +504,7 @@ export class DshRunner {
497
504
  return;
498
505
  }
499
506
  try {
500
- for await (const ev of this.runTurn(sessionId, opts.cwd, submitted.prePromptSeq, promptRequestId, abort, { last: lastGoal })) {
507
+ for await (const ev of this.runTurn(sessionId, opts.cwd, submitted.prePromptSeq, promptRequestId, abort, { last: lastGoal, known: seedGoal.known })) {
501
508
  yield ev;
502
509
  if (ev.type === 'result') {
503
510
  terminalEmitted = true;
@@ -734,7 +741,16 @@ export class DshRunner {
734
741
  signal,
735
742
  timeoutMs: GOAL_PROBE_TIMEOUT_MS,
736
743
  });
737
- if (finalGoal.known && !sameGoal(finalGoal.goal, goal.last)) {
744
+ if (!finalGoal.known) {
745
+ // 收尾读不到:seed 已报过 unknown 就不重复发,否则把状态降级
746
+ // 交给卡片(卡片据此不再宣布完成)。
747
+ if (goal.known) {
748
+ goal.known = false;
749
+ yield goalStateEvent(null, false);
750
+ }
751
+ }
752
+ else if (!goal.known || !sameGoal(finalGoal.goal, goal.last)) {
753
+ goal.known = true;
738
754
  goal.last = finalGoal.goal;
739
755
  yield goalStateEvent(finalGoal.goal, true);
740
756
  }
@@ -91,9 +91,20 @@ export declare class OpencodeAcpRunner extends BaseAcpRunner<OpencodeAcpTranslat
91
91
  * starting or still activating location plugins when the first session is
92
92
  * created; both surface as a JSON-RPC internal error. Retry a few times
93
93
  * before failing the turn.
94
+ *
95
+ * 单次 attempt 自身还要等 opencode 侧超时(~10s),所以真实窗口 ≈ Σdelay +
96
+ * 每次的等待。2026-10-30 现场:daemon 刚重启,`/api/model` 因模型目录拉取
97
+ * 超时连续 503 约 7 分钟,旧退避 0.5/1.5/3s(合计 ~45s)全落在这段里,4 次
98
+ * 重试无一成功。现在铺到 ~75s + 每次超时 ≈ 2 分钟,覆盖一次冷启动窗口,
99
+ * 又不至于让真故障的用户干等太久。
94
100
  */
95
101
  protected sessionSetupRetryDelaysMs(): number[];
96
102
  protected isRetryableSessionSetupError(err: unknown): boolean;
103
+ /**
104
+ * 用户可见文案。这里刻意**不**建议 `opencode service restart`:卡住的是
105
+ * opencode 的模型目录/位置服务冷启动,重启只会清掉刚热好的状态、重新回到
106
+ * 同一个窗口(2026-10-30 现场:照提示重启两次仍然失败,等几分钟自己好了)。
107
+ */
97
108
  protected enrichSessionSetupError(err: unknown): Error;
98
109
  getStatusInfo(): AgentStatusInfo;
99
110
  /**
@@ -173,20 +173,31 @@ export class OpencodeAcpRunner extends BaseAcpRunner {
173
173
  * starting or still activating location plugins when the first session is
174
174
  * created; both surface as a JSON-RPC internal error. Retry a few times
175
175
  * before failing the turn.
176
+ *
177
+ * 单次 attempt 自身还要等 opencode 侧超时(~10s),所以真实窗口 ≈ Σdelay +
178
+ * 每次的等待。2026-10-30 现场:daemon 刚重启,`/api/model` 因模型目录拉取
179
+ * 超时连续 503 约 7 分钟,旧退避 0.5/1.5/3s(合计 ~45s)全落在这段里,4 次
180
+ * 重试无一成功。现在铺到 ~75s + 每次超时 ≈ 2 分钟,覆盖一次冷启动窗口,
181
+ * 又不至于让真故障的用户干等太久。
176
182
  */
177
183
  sessionSetupRetryDelaysMs() {
178
- return [500, 1500, 3000];
184
+ return [500, 2000, 8000, 20000, 45000];
179
185
  }
180
186
  isRetryableSessionSetupError(err) {
181
187
  const message = err instanceof Error ? err.message : String(err);
182
188
  return /ServiceUnavailableError|Location plugins did not finish activating|Internal service failure|ECONNREFUSED|fetch failed/i.test(message);
183
189
  }
190
+ /**
191
+ * 用户可见文案。这里刻意**不**建议 `opencode service restart`:卡住的是
192
+ * opencode 的模型目录/位置服务冷启动,重启只会清掉刚热好的状态、重新回到
193
+ * 同一个窗口(2026-10-30 现场:照提示重启两次仍然失败,等几分钟自己好了)。
194
+ */
184
195
  enrichSessionSetupError(err) {
185
196
  if (!this.isRetryableSessionSetupError(err)) {
186
197
  return super.enrichSessionSetupError(err);
187
198
  }
188
199
  const message = err instanceof Error ? err.message : String(err);
189
- return new Error(`opencode 2.0 后台服务不可用(${message})。请在终端执行 \`opencode service restart\` 后重试。`, { cause: err });
200
+ return new Error(`opencode 后台服务尚未就绪(${message})。这是 opencode 冷启动 / 模型目录加载期间的临时状态:等 1–2 分钟重发即可,重启服务只会重新进入同一窗口。`, { cause: err });
190
201
  }
191
202
  getStatusInfo() {
192
203
  return {
@@ -347,7 +347,15 @@ export class KimiSessionReader {
347
347
  // is an independent window). Aggregation covers the whole wire.jsonl
348
348
  // and is independent of the maxEvents display slice
349
349
  // (usage = the session's overall token cost).
350
- const accumulateUsage = (u, updateContextLength) => {
350
+ //
351
+ // 本 run 的 flow 字段是**末个 turn**(最后一个 turn.prompt 之后)的记录之和:
352
+ // kimi 每次模型调用写一条 usage.record,带工具往返的 turn 会有多条。只取
353
+ // 末条会低报,取会话总量(旧实现)又会把历史所有 run 的消耗算进这一轮。
354
+ // 子 agent 的 wire 没有 turn 标记,只能进会话累计(无法归属到某一轮);
355
+ // 整份 wire 都没有 turn.prompt(旧数据)时沿用「整份文件 = 一个 turn」。
356
+ let turnAcc = null;
357
+ let lastMainUsage;
358
+ const accumulateUsage = (u, scope) => {
351
359
  hasUsageRecord = true;
352
360
  // Malformed records (missing usage object or missing fields) must
353
361
  // not poison the accumulators with NaN: count missing fields as 0.
@@ -355,18 +363,21 @@ export class KimiSessionReader {
355
363
  const output = u?.output ?? 0;
356
364
  const inputCacheRead = u?.inputCacheRead ?? 0;
357
365
  const inputCacheCreation = u?.inputCacheCreation ?? 0;
358
- acc.add({
366
+ const record = {
359
367
  input: inputOther,
360
368
  output,
361
369
  cacheRead: inputCacheRead,
362
370
  cacheCreation: inputCacheCreation,
363
- });
364
- if (updateContextLength) {
365
- // unified contract (§9.21): input + output + cacheRead + cacheCreation.
366
- // 末轮 output 已进入历史、下一 prompt 全量携带——与 compact postTokens
367
- // 水位同语义(此前漏加 output 会系统性低估长收尾 turn)。
368
- mainContextLength = inputOther + output + inputCacheRead + inputCacheCreation;
369
- }
371
+ };
372
+ acc.add(record);
373
+ if (scope !== 'main')
374
+ return;
375
+ lastMainUsage = record;
376
+ turnAcc?.add(record);
377
+ // unified contract (§9.21): input + output + cacheRead + cacheCreation.
378
+ // 末轮 output 已进入历史、下一 prompt 全量携带——与 compact postTokens
379
+ // 水位同语义(此前漏加 output 会系统性低估长收尾 turn)。
380
+ mainContextLength = inputOther + output + inputCacheRead + inputCacheCreation;
370
381
  };
371
382
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
372
383
  const line = lines[lineIndex];
@@ -374,7 +385,7 @@ export class KimiSessionReader {
374
385
  const entry = JSON.parse(line);
375
386
  // Extract usage from usage.record.
376
387
  if (isUsageRecord(entry)) {
377
- accumulateUsage(entry.usage, true);
388
+ accumulateUsage(entry.usage, 'main');
378
389
  continue;
379
390
  }
380
391
  // §6.3: Count compaction events and track pre-compact context length.
@@ -401,6 +412,8 @@ export class KimiSessionReader {
401
412
  // prompt), which is the correct catch-up title.
402
413
  // Extract display title from turn.prompt
403
414
  if (isTurnPrompt(entry)) {
415
+ // turn.prompt 是 turn 的起点:本 turn 的 flow 从这里重新累计。
416
+ turnAcc = new UsageAccumulator();
404
417
  if (entry.input && entry.input.length > 0) {
405
418
  displayTitle = entry.input[0].text;
406
419
  }
@@ -470,7 +483,7 @@ export class KimiSessionReader {
470
483
  try {
471
484
  const subEntry = JSON.parse(subLine);
472
485
  if (isUsageRecord(subEntry)) {
473
- accumulateUsage(subEntry.usage, false);
486
+ accumulateUsage(subEntry.usage, 'subagent');
474
487
  }
475
488
  }
476
489
  catch {
@@ -482,18 +495,23 @@ export class KimiSessionReader {
482
495
  catch {
483
496
  // agents dir unreadable — fall back to main-only usage
484
497
  }
485
- // Build the final AgentSessionUsage from accumulator totals + main contextLength.
498
+ // Build the final AgentSessionUsage from the last turn's flow + session
499
+ // cumulative + main contextLength.
486
500
  const t = acc.totals;
501
+ // 有 turn.prompt 时本 run = 末个 turn 的 flow(末 turn 还没有记录时退回
502
+ // 末条主 wire 记录);整份 wire 都没有 turn.prompt(旧数据/截断)时无从
503
+ // 分段,沿用「整份文件 = 一个 turn」的旧口径(含子 agent wire)。
504
+ const flow = turnAcc ? (turnAcc.count > 0 ? turnAcc.totals : (lastMainUsage ?? t)) : t;
487
505
  // Kimi semantics: preserve 0 values (not undefined) for cache fields to match original
488
506
  const totalUsage = {
489
- inputTokens: t.input,
490
- outputTokens: t.output,
507
+ inputTokens: flow.input,
508
+ outputTokens: flow.output,
491
509
  contextLength: mainContextLength,
492
- cacheReadTokens: t.cacheRead > 0 ? t.cacheRead : 0,
493
- cacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : 0,
494
- totalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
495
- // Cumulative (session-wide): kimi sums all usage.records; mirrors
496
- // inputTokens/outputTokens which are already session totals.
510
+ cacheReadTokens: flow.cacheRead > 0 ? flow.cacheRead : 0,
511
+ cacheCreationTokens: flow.cacheCreation > 0 ? flow.cacheCreation : 0,
512
+ totalTokens: flow.input + flow.output + flow.cacheRead + flow.cacheCreation,
513
+ // Cumulative (session-wide): sums every usage.record across all turns
514
+ // (subagent wires included) — the "累计" line, never the per-run value.
497
515
  ...cumulativeUsageFields(t),
498
516
  // §6.3: compaction stats from wire.jsonl
499
517
  compactCount,
@@ -81,6 +81,55 @@ function v2ToolOutput(state) {
81
81
  return state.result;
82
82
  return undefined;
83
83
  }
84
+ /**
85
+ * v2 assistant 消息 → 用量采样。total 为 0 的退化消息(模型在 reasoning 中被
86
+ * 截断)不算采样:信任它会抹掉整轮真实用量。
87
+ */
88
+ function usageSample(msg) {
89
+ if (msg?.type !== 'assistant' || !msg.tokens)
90
+ return undefined;
91
+ const input = msg.tokens.input ?? 0;
92
+ const output = msg.tokens.output ?? 0;
93
+ const cacheRead = msg.tokens.cache?.read ?? 0;
94
+ const cacheCreation = msg.tokens.cache?.write ?? 0;
95
+ const total = input + output + (msg.tokens.reasoning ?? 0) + cacheRead + cacheCreation;
96
+ if (total === 0)
97
+ return undefined;
98
+ return { input, output, cacheRead, cacheCreation, total };
99
+ }
100
+ /**
101
+ * 末个 turn 的起点(其后才是"本 run"的采样)。
102
+ *
103
+ * `idle` 是 opencode 自带的 turn 结束标记(真实 export 里每个 idle 前一条是
104
+ * assistant、后一条是 user/synthetic/system)。取「最后一个其后仍有采样的 idle」:
105
+ * - 文件以 idle 收尾(/resume 常态)时,末个 idle 之后没有采样,边界自动落到
106
+ * 再上一个 idle,即最后那个完整 turn;
107
+ * - 文件停在 turn 中间(run 收尾读盘)时,末个 idle 就是当前 turn 的起点。
108
+ *
109
+ * 老数据没有 idle:退回最后一个 turn 起跑标记(user/synthetic/system),与
110
+ * codex/dsh「无 turn 标记则退回末条采样」的既有兜底一致。
111
+ */
112
+ function lastTurnStartIndex(messages) {
113
+ let lastSample = -1;
114
+ for (let i = messages.length - 1; i >= 0; i--) {
115
+ if (usageSample(messages[i]) !== undefined) {
116
+ lastSample = i;
117
+ break;
118
+ }
119
+ }
120
+ if (lastSample < 0)
121
+ return -1;
122
+ for (let i = lastSample - 1; i >= 0; i--) {
123
+ if (messages[i]?.type === 'idle')
124
+ return i;
125
+ }
126
+ for (let i = lastSample - 1; i >= 0; i--) {
127
+ const type = messages[i]?.type;
128
+ if (type === 'user' || type === 'synthetic' || type === 'system')
129
+ return i;
130
+ }
131
+ return -1;
132
+ }
84
133
  /**
85
134
  * 把会话级累计 token 落到 usage 上(v1 export 与 v2 export 两条读取路径共用)。
86
135
  *
@@ -196,6 +245,8 @@ export class OpencodeSessionReader {
196
245
  }
197
246
  const events = [];
198
247
  let usage;
248
+ /** 本 turn 的 flow 累计:末条 user 消息之后的全部 step-finish。 */
249
+ let turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
199
250
  let aiTitle;
200
251
  let displayTitle;
201
252
  // Find the last user message position (for catch-up tail).
@@ -262,28 +313,34 @@ export class OpencodeSessionReader {
262
313
  }
263
314
  }
264
315
  }
265
- // Track usage from step-finish parts
316
+ // Track usage from step-finish parts. v1 没有 idle 标记,turn 边界就是
317
+ // 上面那段"最后一条 user 消息";同一 turn 可能有多个 step-finish
318
+ // (每次模型请求一条),flow 要全加起来,只留末条会把多步 run 低报。
266
319
  for (const part of msg.parts) {
267
320
  if (part.type === 'step-finish' && part.tokens) {
268
- // Use the last NON-EMPTY step-finish tokens. A long autonomous run
269
- // can end on a degenerate step (model cut off mid-reasoning) whose
270
- // step-finish reports all-zero tokens; trusting it would wipe out
271
- // the run's real per-turn usage (contextLength/input/output/total
272
- // all render 0). Skip zero-total steps so per-turn usage reflects
273
- // the last step that actually consumed tokens.
321
+ // Skip zero-total steps(模型在 reasoning 中被截断)——它们没有消耗,
322
+ // 也不该把本次请求当成一条真实采样。
274
323
  // ccusage-aligned: cache.write -> cacheCreationTokens.
275
324
  // contextLength = input + output + cacheRead + cacheCreation —
276
325
  // the unified context-window occupancy contract across all readers
277
326
  // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
278
327
  // postTokens 水位同语义).
279
328
  if ((part.tokens.total ?? 0) !== 0) {
329
+ const input = part.tokens.input ?? 0;
330
+ const output = part.tokens.output ?? 0;
331
+ const cacheRead = part.tokens.cache?.read ?? 0;
332
+ const cacheWrite = part.tokens.cache?.write ?? 0;
333
+ turn = {
334
+ input: turn.input + input,
335
+ output: turn.output + output,
336
+ cacheRead: turn.cacheRead + cacheRead,
337
+ cacheCreation: turn.cacheCreation + cacheWrite,
338
+ total: turn.total + part.tokens.total,
339
+ };
280
340
  usage = {
281
- inputTokens: part.tokens.input,
282
- outputTokens: part.tokens.output,
283
- contextLength: (part.tokens.input ?? 0) +
284
- (part.tokens.output ?? 0) +
285
- (part.tokens.cache?.read ?? 0) +
286
- (part.tokens.cache?.write ?? 0),
341
+ inputTokens: input,
342
+ outputTokens: output,
343
+ contextLength: input + output + cacheRead + cacheWrite,
287
344
  cacheReadTokens: part.tokens.cache?.read,
288
345
  cacheCreationTokens: part.tokens.cache?.write,
289
346
  totalTokens: part.tokens.total,
@@ -292,6 +349,14 @@ export class OpencodeSessionReader {
292
349
  }
293
350
  }
294
351
  }
352
+ // flow 覆盖末条采样值,contextLength 保留末条(水位不求和)。
353
+ if (usage) {
354
+ usage.inputTokens = turn.input;
355
+ usage.outputTokens = turn.output;
356
+ usage.cacheReadTokens = turn.cacheRead;
357
+ usage.cacheCreationTokens = turn.cacheCreation;
358
+ usage.totalTokens = turn.total;
359
+ }
295
360
  // Cumulative (session-wide): sum input/output/total across ALL step-finish
296
361
  // parts in the whole session. The catch-up loop above only scans from
297
362
  // the last user message (= last run) for per-turn usage; cumulative
@@ -461,31 +526,45 @@ export class OpencodeSessionReader {
461
526
  let cumTotal = 0;
462
527
  let cumCacheRead = 0;
463
528
  let cumCacheWrite = 0;
464
- for (const msg of messages) {
465
- if (msg?.type !== 'assistant' || !msg.tokens)
529
+ // 本 turn(= 本 run)的 flow:末个 turn 内每次模型请求之和。contextLength
530
+ // 仍取末次请求的窗口水位(水位不可求和)。只取末条会把多步 run 低报
531
+ // (实测一条 25 请求的 run:input 20,040 显示成 543、output 10,358 显示成 1,250)。
532
+ const turnStart = lastTurnStartIndex(messages);
533
+ const turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
534
+ for (let i = 0; i < messages.length; i++) {
535
+ const sample = usageSample(messages[i]);
536
+ if (!sample)
466
537
  continue;
467
- const tokens = msg.tokens;
468
- const cacheRead = tokens.cache?.read ?? 0;
469
- const cacheWrite = tokens.cache?.write ?? 0;
470
- const total = tokens.input + tokens.output + tokens.reasoning + cacheRead + cacheWrite;
471
- cumInput += tokens.input;
472
- cumOutput += tokens.output;
473
- cumTotal += total;
474
- cumCacheRead += cacheRead;
475
- cumCacheWrite += cacheWrite;
476
- // A long autonomous run can end on a degenerate assistant message with
477
- // zero tokens; skip it so the card reflects the last real step.
478
- if (total !== 0) {
479
- latest = {
480
- inputTokens: tokens.input,
481
- outputTokens: tokens.output,
482
- // §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
483
- contextLength: tokens.input + tokens.output + cacheRead + cacheWrite,
484
- cacheReadTokens: cacheRead,
485
- cacheCreationTokens: cacheWrite,
486
- totalTokens: total,
487
- };
538
+ cumInput += sample.input;
539
+ cumOutput += sample.output;
540
+ cumTotal += sample.total;
541
+ cumCacheRead += sample.cacheRead;
542
+ cumCacheWrite += sample.cacheCreation;
543
+ if (i > turnStart) {
544
+ turn.input += sample.input;
545
+ turn.output += sample.output;
546
+ turn.cacheRead += sample.cacheRead;
547
+ turn.cacheCreation += sample.cacheCreation;
548
+ turn.total += sample.total;
488
549
  }
550
+ latest = {
551
+ inputTokens: sample.input,
552
+ outputTokens: sample.output,
553
+ // §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
554
+ contextLength: sample.input + sample.output + sample.cacheRead + sample.cacheCreation,
555
+ cacheReadTokens: sample.cacheRead,
556
+ cacheCreationTokens: sample.cacheCreation,
557
+ totalTokens: sample.total,
558
+ };
559
+ }
560
+ // turnStart 严格小于最后一个采样下标,所以 latest 存在时 turn 至少含一条
561
+ // 采样:flow 覆盖末条值,只有 contextLength 保留末条。
562
+ if (latest) {
563
+ latest.inputTokens = turn.input;
564
+ latest.outputTokens = turn.output;
565
+ latest.cacheReadTokens = turn.cacheRead;
566
+ latest.cacheCreationTokens = turn.cacheCreation;
567
+ latest.totalTokens = turn.total;
489
568
  }
490
569
  latest = withCumulativeUsage(latest, {
491
570
  total: cumTotal,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.14",
3
+ "version": "0.5.15",
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",