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.
@@ -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
  }
@@ -56,6 +56,8 @@ export interface KimiAcpRunnerOptions {
56
56
  permissionMode?: 'manual' | 'auto' | 'yolo';
57
57
  }
58
58
  export declare class KimiAcpRunner extends BaseAcpRunner<KimiAcpTranslator> {
59
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
60
+ getUsageAuthority(): 'jsonl';
59
61
  private permissionMode;
60
62
  private thinkingEffort?;
61
63
  private readonly compactIdleTimeoutMs;
@@ -134,6 +134,10 @@ function buildQuestionResponse(action, pending, response) {
134
134
  // Runner
135
135
  // =============================================================================
136
136
  export class KimiAcpRunner extends BaseAcpRunner {
137
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
138
+ getUsageAuthority() {
139
+ return 'jsonl';
140
+ }
137
141
  permissionMode;
138
142
  thinkingEffort;
139
143
  compactIdleTimeoutMs;
@@ -53,6 +53,8 @@ export interface OpencodeAcpRunnerOptions {
53
53
  errorMonitorPollIntervalMs?: number;
54
54
  }
55
55
  export declare class OpencodeAcpRunner extends BaseAcpRunner<OpencodeAcpTranslator> {
56
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
57
+ getUsageAuthority(): 'jsonl';
56
58
  /** Configured session mode applied via session/set_mode. */
57
59
  private configuredMode;
58
60
  /** Current session mode id, parsed from session/new|resume configOptions. */
@@ -91,9 +93,20 @@ export declare class OpencodeAcpRunner extends BaseAcpRunner<OpencodeAcpTranslat
91
93
  * starting or still activating location plugins when the first session is
92
94
  * created; both surface as a JSON-RPC internal error. Retry a few times
93
95
  * before failing the turn.
96
+ *
97
+ * 单次 attempt 自身还要等 opencode 侧超时(~10s),所以真实窗口 ≈ Σdelay +
98
+ * 每次的等待。2026-10-30 现场:daemon 刚重启,`/api/model` 因模型目录拉取
99
+ * 超时连续 503 约 7 分钟,旧退避 0.5/1.5/3s(合计 ~45s)全落在这段里,4 次
100
+ * 重试无一成功。现在铺到 ~75s + 每次超时 ≈ 2 分钟,覆盖一次冷启动窗口,
101
+ * 又不至于让真故障的用户干等太久。
94
102
  */
95
103
  protected sessionSetupRetryDelaysMs(): number[];
96
104
  protected isRetryableSessionSetupError(err: unknown): boolean;
105
+ /**
106
+ * 用户可见文案。这里刻意**不**建议 `opencode service restart`:卡住的是
107
+ * opencode 的模型目录/位置服务冷启动,重启只会清掉刚热好的状态、重新回到
108
+ * 同一个窗口(2026-10-30 现场:照提示重启两次仍然失败,等几分钟自己好了)。
109
+ */
97
110
  protected enrichSessionSetupError(err: unknown): Error;
98
111
  getStatusInfo(): AgentStatusInfo;
99
112
  /**
@@ -30,6 +30,10 @@ import { OpencodeLogErrorMonitor, resolveOpencodeLogPath } from './error-monitor
30
30
  // Runner
31
31
  // =============================================================================
32
32
  export class OpencodeAcpRunner extends BaseAcpRunner {
33
+ /** ACP usage_update 只有 context,没有 input/output:flow 走 jsonl。 */
34
+ getUsageAuthority() {
35
+ return 'jsonl';
36
+ }
33
37
  /** Configured session mode applied via session/set_mode. */
34
38
  configuredMode;
35
39
  /** Current session mode id, parsed from session/new|resume configOptions. */
@@ -173,20 +177,31 @@ export class OpencodeAcpRunner extends BaseAcpRunner {
173
177
  * starting or still activating location plugins when the first session is
174
178
  * created; both surface as a JSON-RPC internal error. Retry a few times
175
179
  * before failing the turn.
180
+ *
181
+ * 单次 attempt 自身还要等 opencode 侧超时(~10s),所以真实窗口 ≈ Σdelay +
182
+ * 每次的等待。2026-10-30 现场:daemon 刚重启,`/api/model` 因模型目录拉取
183
+ * 超时连续 503 约 7 分钟,旧退避 0.5/1.5/3s(合计 ~45s)全落在这段里,4 次
184
+ * 重试无一成功。现在铺到 ~75s + 每次超时 ≈ 2 分钟,覆盖一次冷启动窗口,
185
+ * 又不至于让真故障的用户干等太久。
176
186
  */
177
187
  sessionSetupRetryDelaysMs() {
178
- return [500, 1500, 3000];
188
+ return [500, 2000, 8000, 20000, 45000];
179
189
  }
180
190
  isRetryableSessionSetupError(err) {
181
191
  const message = err instanceof Error ? err.message : String(err);
182
192
  return /ServiceUnavailableError|Location plugins did not finish activating|Internal service failure|ECONNREFUSED|fetch failed/i.test(message);
183
193
  }
194
+ /**
195
+ * 用户可见文案。这里刻意**不**建议 `opencode service restart`:卡住的是
196
+ * opencode 的模型目录/位置服务冷启动,重启只会清掉刚热好的状态、重新回到
197
+ * 同一个窗口(2026-10-30 现场:照提示重启两次仍然失败,等几分钟自己好了)。
198
+ */
184
199
  enrichSessionSetupError(err) {
185
200
  if (!this.isRetryableSessionSetupError(err)) {
186
201
  return super.enrichSessionSetupError(err);
187
202
  }
188
203
  const message = err instanceof Error ? err.message : String(err);
189
- return new Error(`opencode 2.0 后台服务不可用(${message})。请在终端执行 \`opencode service restart\` 后重试。`, { cause: err });
204
+ return new Error(`opencode 后台服务尚未就绪(${message})。这是 opencode 冷启动 / 模型目录加载期间的临时状态:等 1–2 分钟重发即可,重启服务只会重新进入同一窗口。`, { cause: err });
190
205
  }
191
206
  getStatusInfo() {
192
207
  return {
@@ -581,8 +581,15 @@ export interface AgentRunner extends Runner {
581
581
  readonly sessionReader: AgentSessionReader;
582
582
  /** Return current status info for /status display. */
583
583
  getStatusInfo(): AgentStatusInfo;
584
- /** Whether this runner provides live usage data (vs. file-based). */
584
+ /** flow 字段的权威来源:live 有 input/output 用 live,否则用 jsonl。 */
585
585
  getUsageAuthority?(): 'live' | 'jsonl';
586
+ /**
587
+ * 该 runner 的 result 事件是否具备 live 语义(pre-init result 处理与
588
+ * session 写回)。与用量来源分开:ACP live 只有 context,用量来源是
589
+ * jsonl,但 result 事件仍需按 live 语义处理。
590
+ * 未实现 = 按 getUsageAuthority() === 'live' 兼容旧行为。
591
+ */
592
+ usesLiveResultEvents?(): boolean;
586
593
  /**
587
594
  * 回传审批决策(codex thread/permissions、ACP elicitation、DSH approval
588
595
  * resolve)。未实现的 agent 由协调器按「无人应答」超时收尾。
@@ -246,6 +246,32 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
246
246
  }
247
247
  }
248
248
  }
249
+ /**
250
+ * 是否是“真人发起新 turn”的 user 行。
251
+ *
252
+ * 只用于 turn 边界,不用于展示标题。排除:tool_result、compact summary、
253
+ * isMeta(skill/命令注入)、task notification、sidechain(子 agent prompt)。
254
+ * 不要求有可展示文本——attachment-only 的输入同样是一次真人 turn。
255
+ */
256
+ function isRealUserPrompt(obj) {
257
+ if (obj.type !== 'user' || obj.isCompactSummary === true || obj.isMeta === true) {
258
+ return false;
259
+ }
260
+ if (obj.isSidechain === true)
261
+ return false;
262
+ const msg = obj.message;
263
+ if (msg?.role !== 'user' || msg.content === undefined)
264
+ return false;
265
+ const content = msg.content;
266
+ if (Array.isArray(content) &&
267
+ content.some((block) => block?.type === 'tool_result')) {
268
+ return false;
269
+ }
270
+ const text = extractText(content);
271
+ if (text && isTaskNotificationText(text))
272
+ return false;
273
+ return true;
274
+ }
249
275
  /**
250
276
  * first-pass scan: stream the file once via `scanJsonlLines`
251
277
  * (no `string[]` materialized), parse each line exactly once, and collect
@@ -268,6 +294,8 @@ function scalarScan(filePath) {
268
294
  const usageByMessageId = new Map();
269
295
  let lastPostTokens = 0;
270
296
  let tailOffset = -1;
297
+ /** 真人 prompt 序号;末个 turn = 当前值。sidechain/注入/工具结果不递增。 */
298
+ let turnSeq = 0;
271
299
  let lastAiTitle;
272
300
  let lastRecap;
273
301
  let lastUserMessage;
@@ -285,6 +313,7 @@ function scalarScan(filePath) {
285
313
  output: Math.max(prev?.output ?? 0, usage.output_tokens ?? 0),
286
314
  cacheRead: Math.max(prev?.cacheRead ?? 0, usage.cache_read_input_tokens ?? 0),
287
315
  cacheCreation: Math.max(prev?.cacheCreation ?? 0, usage.cache_creation_input_tokens ?? 0),
316
+ turn: prev?.turn ?? turnSeq,
288
317
  });
289
318
  }
290
319
  }
@@ -337,16 +366,39 @@ function scalarScan(filePath) {
337
366
  }
338
367
  }
339
368
  }
369
+ // --- turn boundary for per-run usage ---
370
+ // 真人 prompt 开启新 turn;注入/工具结果/sidechain 不递增。usage 记录在
371
+ // 解析到 assistant 行时已经带上当前 turnSeq,末尾只汇总末个 turn。
372
+ if (isRealUserPrompt(obj))
373
+ turnSeq++;
340
374
  });
341
375
  // Feed one best record per message id, in first-appearance order (Map
342
376
  // preserves insertion order), so acc.last is the LAST message's full usage
343
- // and totals sum each API response exactly once.
377
+ // and totals sum each API response exactly once. Per-run flow is only the
378
+ // records belonging to the last real-user turn — a tool-looping turn has
379
+ // several assistant messages (one per model request), so `last` alone
380
+ // under-reports it.
381
+ const turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
344
382
  for (const record of usageByMessageId.values()) {
345
383
  acc.add(record);
384
+ if (turnSeq > 0 && record.turn === turnSeq) {
385
+ turn.input += record.input;
386
+ turn.output += record.output;
387
+ turn.cacheRead += record.cacheRead;
388
+ turn.cacheCreation += record.cacheCreation;
389
+ }
346
390
  }
347
391
  // Build usage result (same logic as old aggregateSessionUsage)
348
392
  const t = acc.totals;
349
393
  const l = acc.last;
394
+ // 没有真人 user prompt(旧数据/截断文件)→ 无法划分 turn,回退末条采样,
395
+ // 与 dsh/codex「无 turn 标记退回末条」一致;不能把整份文件当成一轮。
396
+ if (turnSeq === 0 && l) {
397
+ turn.input = l.input;
398
+ turn.output = l.output;
399
+ turn.cacheRead = l.cacheRead;
400
+ turn.cacheCreation = l.cacheCreation;
401
+ }
350
402
  // contextLength = current context-window occupancy. Prefer compact_boundary
351
403
  // postTokens (the precise post-compact size); otherwise fall back to the
352
404
  // last turn's input + cacheRead + cacheCreation (unified
@@ -362,14 +414,14 @@ function scalarScan(filePath) {
362
414
  // 对齐 codex reader 语义(§9.21:`last_token_usage`=单 turn 增量、
363
415
  // `total_token_usage`=session 累计)——否则 jsonl 兜底路径会把 session 累计
364
416
  // 当成"本 run"显示,与卡片"本 run ≤ 累计"不变量冲突(累计反而更小)。
365
- const lastTotal = l ? l.input + l.output + l.cacheRead + l.cacheCreation : 0;
417
+ const lastTotal = turn.input + turn.output + turn.cacheRead + turn.cacheCreation;
366
418
  const usage = {
367
- inputTokens: l ? l.input : 0,
368
- outputTokens: l ? l.output : 0,
419
+ inputTokens: turn.input,
420
+ outputTokens: turn.output,
369
421
  contextLength,
370
422
  compactCount: acc.compactCount,
371
- cacheReadTokens: l && l.cacheRead > 0 ? l.cacheRead : undefined,
372
- cacheCreationTokens: l && l.cacheCreation > 0 ? l.cacheCreation : undefined,
423
+ cacheReadTokens: turn.cacheRead > 0 ? turn.cacheRead : undefined,
424
+ cacheCreationTokens: turn.cacheCreation > 0 ? turn.cacheCreation : undefined,
373
425
  totalTokens: lastTotal,
374
426
  ...cumulativeUsageFields(t),
375
427
  };
@@ -41,6 +41,8 @@ interface DshSessionReaderOptions {
41
41
  syncConnect?: (baseUrl: string, token: string) => string;
42
42
  }
43
43
  export declare class DshSessionReader implements AgentSessionReader {
44
+ /** DSH session/page 没有 compactCount;compact 收尾不应因此重试读取。 */
45
+ readonly supportsCompactCount = false;
44
46
  private readonly hostProvider;
45
47
  private readonly dshHomeProvider;
46
48
  private readonly tokenProvider;
@@ -98,6 +98,8 @@ function isAuthFailure(err) {
98
98
  return /\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid[ -]?cookie/i.test(msg);
99
99
  }
100
100
  export class DshSessionReader {
101
+ /** DSH session/page 没有 compactCount;compact 收尾不应因此重试读取。 */
102
+ supportsCompactCount = false;
101
103
  hostProvider;
102
104
  dshHomeProvider;
103
105
  tokenProvider;
@@ -315,8 +317,8 @@ export class DshSessionReader {
315
317
  // 在途 turn(turn/start 无 turn/end,run 进行中读历史)的采样优先于
316
318
  // 上一轮:旧实现只认已结算的 lastTurnUsage,mid-run 读取会把当前 turn
317
319
  // 已消耗的量显示成上一个完整 turn 的数字。
318
- const flow = lastTurnUsage ??
319
- turnUsage ?? {
320
+ const flow = turnUsage ??
321
+ lastTurnUsage ?? {
320
322
  input,
321
323
  output: lastUsage.outputTokens ?? 0,
322
324
  cacheRead,
@@ -325,8 +327,9 @@ export class DshSessionReader {
325
327
  sessionUsage = {
326
328
  inputTokens: flow.input,
327
329
  outputTokens: flow.output,
328
- // §9.21 口径:含末轮 output(下一 prompt 全量携带,与 postTokens 水位同语义)。
329
- contextLength: input + flow.output + cacheRead + cacheWrite,
330
+ // §9.21 口径:context 是末次采样的窗口水位(input + 末次 output +
331
+ // cache),不把整 turn 的 output 求和(前几步 output 已进入后续 input)。
332
+ contextLength: input + (lastUsage.outputTokens ?? 0) + cacheRead + cacheWrite,
330
333
  cacheReadTokens: flow.cacheRead,
331
334
  cacheCreationTokens: flow.cacheWrite,
332
335
  totalTokens: flow.input + flow.output + flow.cacheRead + flow.cacheWrite,
@@ -347,7 +347,16 @@ 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
+ // 末个 turn 尚无 usage.record 时 flow = 0(当前契约是「末个 turn」,不是
357
+ // 「末个非空 turn」)——不能退回上一轮的记录。
358
+ let turnAcc = null;
359
+ const accumulateUsage = (u, scope) => {
351
360
  hasUsageRecord = true;
352
361
  // Malformed records (missing usage object or missing fields) must
353
362
  // not poison the accumulators with NaN: count missing fields as 0.
@@ -355,18 +364,20 @@ export class KimiSessionReader {
355
364
  const output = u?.output ?? 0;
356
365
  const inputCacheRead = u?.inputCacheRead ?? 0;
357
366
  const inputCacheCreation = u?.inputCacheCreation ?? 0;
358
- acc.add({
367
+ const record = {
359
368
  input: inputOther,
360
369
  output,
361
370
  cacheRead: inputCacheRead,
362
371
  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
- }
372
+ };
373
+ acc.add(record);
374
+ if (scope !== 'main')
375
+ return;
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
+ // turnAcc.count = 0 → flow = 0。整份 wire 都没有 turn.prompt(旧数据/
503
+ // 截断)时无从分段,沿用「整份文件 = 一个 turn」的旧口径(含子 agent wire)。
504
+ const flow = turnAcc ? turnAcc.totals : 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,
@@ -31,6 +31,8 @@ interface OpencodeSessionReaderOptions {
31
31
  export declare class OpencodeSessionReader implements AgentSessionReader {
32
32
  private readonly binary;
33
33
  private readonly cacheTtlMs;
34
+ /** opencode export 没有 compactCount;compact 收尾不应因此重试 export。 */
35
+ readonly supportsCompactCount = false;
34
36
  /** Optional raw-export capture override (testing / alt transport). */
35
37
  private readonly captureExportOverride?;
36
38
  /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
@@ -81,6 +81,61 @@ 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
+ // 末条采样之后又出现 user:这是新 turn 的起点,但还没有采样(空 turn)。
123
+ // 返回该 user 下标,让 flow 汇总范围为空 → 本 run = 0,而不是上一轮。
124
+ for (let i = lastSample + 1; i < messages.length; i++) {
125
+ if (messages[i]?.type === 'user')
126
+ return i;
127
+ }
128
+ for (let i = lastSample - 1; i >= 0; i--) {
129
+ if (messages[i]?.type === 'idle')
130
+ return i;
131
+ }
132
+ for (let i = lastSample - 1; i >= 0; i--) {
133
+ const type = messages[i]?.type;
134
+ if (type === 'user' || type === 'synthetic' || type === 'system')
135
+ return i;
136
+ }
137
+ return -1;
138
+ }
84
139
  /**
85
140
  * 把会话级累计 token 落到 usage 上(v1 export 与 v2 export 两条读取路径共用)。
86
141
  *
@@ -113,6 +168,8 @@ function withCumulativeUsage(usage, cum) {
113
168
  export class OpencodeSessionReader {
114
169
  binary;
115
170
  cacheTtlMs;
171
+ /** opencode export 没有 compactCount;compact 收尾不应因此重试 export。 */
172
+ supportsCompactCount = false;
116
173
  /** Optional raw-export capture override (testing / alt transport). */
117
174
  captureExportOverride;
118
175
  /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
@@ -196,6 +253,8 @@ export class OpencodeSessionReader {
196
253
  }
197
254
  const events = [];
198
255
  let usage;
256
+ /** 本 turn 的 flow 累计:末条 user 消息之后的全部 step-finish。 */
257
+ let turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
199
258
  let aiTitle;
200
259
  let displayTitle;
201
260
  // Find the last user message position (for catch-up tail).
@@ -262,28 +321,34 @@ export class OpencodeSessionReader {
262
321
  }
263
322
  }
264
323
  }
265
- // Track usage from step-finish parts
324
+ // Track usage from step-finish parts. v1 没有 idle 标记,turn 边界就是
325
+ // 上面那段"最后一条 user 消息";同一 turn 可能有多个 step-finish
326
+ // (每次模型请求一条),flow 要全加起来,只留末条会把多步 run 低报。
266
327
  for (const part of msg.parts) {
267
328
  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.
329
+ // Skip zero-total steps(模型在 reasoning 中被截断)——它们没有消耗,
330
+ // 也不该把本次请求当成一条真实采样。
274
331
  // ccusage-aligned: cache.write -> cacheCreationTokens.
275
332
  // contextLength = input + output + cacheRead + cacheCreation —
276
333
  // the unified context-window occupancy contract across all readers
277
334
  // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
278
335
  // postTokens 水位同语义).
279
336
  if ((part.tokens.total ?? 0) !== 0) {
337
+ const input = part.tokens.input ?? 0;
338
+ const output = part.tokens.output ?? 0;
339
+ const cacheRead = part.tokens.cache?.read ?? 0;
340
+ const cacheWrite = part.tokens.cache?.write ?? 0;
341
+ turn = {
342
+ input: turn.input + input,
343
+ output: turn.output + output,
344
+ cacheRead: turn.cacheRead + cacheRead,
345
+ cacheCreation: turn.cacheCreation + cacheWrite,
346
+ total: turn.total + part.tokens.total,
347
+ };
280
348
  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),
349
+ inputTokens: input,
350
+ outputTokens: output,
351
+ contextLength: input + output + cacheRead + cacheWrite,
287
352
  cacheReadTokens: part.tokens.cache?.read,
288
353
  cacheCreationTokens: part.tokens.cache?.write,
289
354
  totalTokens: part.tokens.total,
@@ -292,6 +357,14 @@ export class OpencodeSessionReader {
292
357
  }
293
358
  }
294
359
  }
360
+ // flow 覆盖末条采样值,contextLength 保留末条(水位不求和)。
361
+ if (usage) {
362
+ usage.inputTokens = turn.input;
363
+ usage.outputTokens = turn.output;
364
+ usage.cacheReadTokens = turn.cacheRead;
365
+ usage.cacheCreationTokens = turn.cacheCreation;
366
+ usage.totalTokens = turn.total;
367
+ }
295
368
  // Cumulative (session-wide): sum input/output/total across ALL step-finish
296
369
  // parts in the whole session. The catch-up loop above only scans from
297
370
  // the last user message (= last run) for per-turn usage; cumulative
@@ -461,31 +534,46 @@ export class OpencodeSessionReader {
461
534
  let cumTotal = 0;
462
535
  let cumCacheRead = 0;
463
536
  let cumCacheWrite = 0;
464
- for (const msg of messages) {
465
- if (msg?.type !== 'assistant' || !msg.tokens)
537
+ // 本 turn(= 本 run)的 flow:末个 turn 内每次模型请求之和。contextLength
538
+ // 仍取末次请求的窗口水位(水位不可求和)。只取末条会把多步 run 低报
539
+ // (实测一条 25 请求的 run:input 20,040 显示成 543、output 10,358 显示成 1,250)。
540
+ const turnStart = lastTurnStartIndex(messages);
541
+ const turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
542
+ for (let i = 0; i < messages.length; i++) {
543
+ const sample = usageSample(messages[i]);
544
+ if (!sample)
466
545
  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
- };
546
+ cumInput += sample.input;
547
+ cumOutput += sample.output;
548
+ cumTotal += sample.total;
549
+ cumCacheRead += sample.cacheRead;
550
+ cumCacheWrite += sample.cacheCreation;
551
+ if (i > turnStart) {
552
+ turn.input += sample.input;
553
+ turn.output += sample.output;
554
+ turn.cacheRead += sample.cacheRead;
555
+ turn.cacheCreation += sample.cacheCreation;
556
+ turn.total += sample.total;
488
557
  }
558
+ latest = {
559
+ inputTokens: sample.input,
560
+ outputTokens: sample.output,
561
+ // §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
562
+ contextLength: sample.input + sample.output + sample.cacheRead + sample.cacheCreation,
563
+ cacheReadTokens: sample.cacheRead,
564
+ cacheCreationTokens: sample.cacheCreation,
565
+ totalTokens: sample.total,
566
+ };
567
+ }
568
+ // turnStart 可能落在末条采样之后(新 turn 尚无采样)→ turn 全 0;latest
569
+ // 仍是上一轮末条采样,contextLength 保留水位。turnStart 小于最后一个采样
570
+ // 下标时,flow 覆盖末条值,只有 contextLength 保留末条。
571
+ if (latest) {
572
+ latest.inputTokens = turn.input;
573
+ latest.outputTokens = turn.output;
574
+ latest.cacheReadTokens = turn.cacheRead;
575
+ latest.cacheCreationTokens = turn.cacheCreation;
576
+ latest.totalTokens = turn.total;
489
577
  }
490
578
  latest = withCumulativeUsage(latest, {
491
579
  total: cumTotal,