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 +16 -0
- package/dist/bridge/approval-coordinator.d.ts +13 -0
- package/dist/bridge/approval-coordinator.js +18 -0
- package/dist/bridge/index.js +70 -13
- package/dist/bridge/run-usage.d.ts +5 -0
- package/dist/bridge/run-usage.js +6 -1
- package/dist/card/run-renderer.js +22 -8
- package/dist/router/index.js +4 -7
- package/dist/runner/common/acp/base-acp-runner.d.ts +16 -0
- package/dist/runner/common/acp/base-acp-runner.js +37 -3
- package/dist/runner/dsh/client.js +5 -0
- package/dist/runner/dsh/runner.js +22 -6
- package/dist/runner/opencode/acp/runner.d.ts +11 -0
- package/dist/runner/opencode/acp/runner.js +13 -2
- package/dist/session/kimi/sessions.js +37 -19
- package/dist/session/opencode/sessions.js +115 -36
- package/package.json +1 -1
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
|
*/
|
package/dist/bridge/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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:
|
|
1676
|
+
title: presentation.title,
|
|
1677
|
+
...(presentation.result ? { usageResult: presentation.result } : {}),
|
|
1631
1678
|
actions: [resumeUseButton(sessionId, agentKind)],
|
|
1632
|
-
headerTemplate:
|
|
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
|
}
|
package/dist/bridge/run-usage.js
CHANGED
|
@@ -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
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
// 必须活到终态渲染(2026-08-15 事故回归)。
|
|
1140
|
+
// 中断来源三元:审批超时 / 用户取消审批 / 用户主动终止。
|
|
1141
|
+
// 显式原因优先:approvalExpired 是粘性标记(cancel 语义下 agent 可能继续跑),
|
|
1142
|
+
// 用户随后主动 /stop 时若仍按过期渲染,就把「我自己停的」误报成自动取消。
|
|
1144
1143
|
const expiredApproval = state.interruptedReason === 'approval_timeout' ||
|
|
1145
|
-
state.
|
|
1146
|
-
|
|
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
|
-
|
|
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} · 出错`;
|
package/dist/router/index.js
CHANGED
|
@@ -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
|
-
//
|
|
2500
|
+
// 只检查内存中的 activeRun 状态:存在且未收尾才算进行中。
|
|
2501
|
+
// 终态集合取 run-state 的单源判据(新增终态时不会漏掉这一处)。
|
|
2501
2502
|
const activeRunning = activeRun !== undefined &&
|
|
2502
|
-
activeRun.terminal
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
366
|
+
const record = {
|
|
359
367
|
input: inputOther,
|
|
360
368
|
output,
|
|
361
369
|
cacheRead: inputCacheRead,
|
|
362
370
|
cacheCreation: inputCacheCreation,
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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:
|
|
490
|
-
outputTokens:
|
|
507
|
+
inputTokens: flow.input,
|
|
508
|
+
outputTokens: flow.output,
|
|
491
509
|
contextLength: mainContextLength,
|
|
492
|
-
cacheReadTokens:
|
|
493
|
-
cacheCreationTokens:
|
|
494
|
-
totalTokens:
|
|
495
|
-
// Cumulative (session-wide):
|
|
496
|
-
//
|
|
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
|
-
//
|
|
269
|
-
//
|
|
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:
|
|
282
|
-
outputTokens:
|
|
283
|
-
contextLength:
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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",
|