lark-remote 0.5.13 → 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.
Files changed (130) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/agent-kinds.d.ts +27 -0
  3. package/dist/agent-kinds.js +38 -0
  4. package/dist/bridge/approval-actions.d.ts +95 -0
  5. package/dist/bridge/approval-actions.js +104 -0
  6. package/dist/bridge/approval-coordinator.d.ts +13 -0
  7. package/dist/bridge/approval-coordinator.js +18 -0
  8. package/dist/bridge/final-usage.d.ts +8 -17
  9. package/dist/bridge/final-usage.js +1 -18
  10. package/dist/bridge/index.d.ts +46 -39
  11. package/dist/bridge/index.js +269 -240
  12. package/dist/bridge/queue-manager.d.ts +25 -1
  13. package/dist/bridge/queue-manager.js +179 -160
  14. package/dist/bridge/run-approval.d.ts +2 -1
  15. package/dist/bridge/run-usage.d.ts +21 -30
  16. package/dist/bridge/run-usage.js +47 -51
  17. package/dist/bridge/session-writeback.d.ts +3 -2
  18. package/dist/card/approval-render.d.ts +3 -2
  19. package/dist/card/approval-render.js +3 -2
  20. package/dist/{router → card}/card-helpers.d.ts +7 -7
  21. package/dist/{router → card}/card-helpers.js +8 -8
  22. package/dist/card/card-session.d.ts +4 -2
  23. package/dist/card/card-session.js +8 -6
  24. package/dist/card/card-shared.d.ts +1 -1
  25. package/dist/card/card-shared.js +1 -1
  26. package/dist/card/final-answer-overflow.d.ts +0 -6
  27. package/dist/card/final-answer-overflow.js +1 -1
  28. package/dist/card/run-card-session.d.ts +2 -2
  29. package/dist/card/run-card-session.js +2 -2
  30. package/dist/card/run-renderer.js +29 -30
  31. package/dist/card/run-state.d.ts +28 -50
  32. package/dist/card/run-state.js +23 -0
  33. package/dist/card/usage-format.d.ts +30 -0
  34. package/dist/{router → card}/usage-format.js +3 -10
  35. package/dist/clone.js +6 -1
  36. package/dist/config/index.d.ts +16 -20
  37. package/dist/config/index.js +0 -16
  38. package/dist/config/pi-config.js +6 -4
  39. package/dist/connector/index.d.ts +2 -4
  40. package/dist/connector/index.js +7 -11
  41. package/dist/{runner/dsh → dsh}/auth.js +1 -1
  42. package/dist/dsh/protocol.d.ts +9 -0
  43. package/dist/dsh/protocol.js +61 -0
  44. package/dist/index.js +10 -5
  45. package/dist/model-catalog.d.ts +14 -0
  46. package/dist/model-catalog.js +25 -0
  47. package/dist/platform/path.d.ts +9 -0
  48. package/dist/platform/path.js +18 -0
  49. package/dist/router/active-run-usage.d.ts +4 -0
  50. package/dist/router/active-run-usage.js +12 -0
  51. package/dist/router/commands/ls.js +1 -1
  52. package/dist/router/commands/order.js +1 -1
  53. package/dist/router/commands/resume.d.ts +5 -4
  54. package/dist/router/commands/resume.js +4 -4
  55. package/dist/router/commands/ws.js +1 -1
  56. package/dist/router/config/claude.js +2 -1
  57. package/dist/router/config/common/render.d.ts +5 -1
  58. package/dist/router/config/common/render.js +6 -13
  59. package/dist/router/config/dsh.d.ts +1 -1
  60. package/dist/router/config/index.d.ts +1 -1
  61. package/dist/router/config/types.d.ts +11 -3
  62. package/dist/router/index.d.ts +41 -11
  63. package/dist/router/index.js +169 -233
  64. package/dist/runner/agent-choices-common.d.ts +1 -1
  65. package/dist/runner/claude/runner.d.ts +20 -2
  66. package/dist/runner/claude/runner.js +2 -15
  67. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  68. package/dist/runner/codex/app-server/translator.d.ts +11 -0
  69. package/dist/runner/codex/app-server/translator.js +56 -149
  70. package/dist/runner/common/acp/base-acp-runner.d.ts +19 -1
  71. package/dist/runner/common/acp/base-acp-runner.js +37 -3
  72. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  73. package/dist/runner/common/jsonrpc/transport.js +17 -7
  74. package/dist/runner/dsh/client.d.ts +1 -4
  75. package/dist/runner/dsh/client.js +7 -55
  76. package/dist/runner/dsh/daemon.js +9 -2
  77. package/dist/runner/dsh/exit-cancel.js +1 -1
  78. package/dist/runner/dsh/mux-stream.js +21 -2
  79. package/dist/runner/dsh/runner.d.ts +21 -1
  80. package/dist/runner/dsh/runner.js +97 -78
  81. package/dist/runner/dsh/translator.d.ts +1 -1
  82. package/dist/runner/index.d.ts +1 -1
  83. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  84. package/dist/runner/kimi/acp/runner.js +83 -60
  85. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  86. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  87. package/dist/runner/opencode/acp/runner.d.ts +14 -1
  88. package/dist/runner/opencode/acp/runner.js +13 -2
  89. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  90. package/dist/runner/probe.d.ts +1 -1
  91. package/dist/runner/probe.js +10 -9
  92. package/dist/runner/registry.d.ts +2 -1
  93. package/dist/runner/types.d.ts +20 -154
  94. package/dist/runner/types.js +4 -24
  95. package/dist/session/claude/session-index.d.ts +11 -4
  96. package/dist/session/claude/session-index.js +30 -8
  97. package/dist/session/claude/session-reader.d.ts +1 -1
  98. package/dist/session/claude/sessions.d.ts +2 -2
  99. package/dist/session/claude/sessions.js +41 -26
  100. package/dist/session/codex/rollout-reader.d.ts +1 -1
  101. package/dist/session/codex/rollout-reader.js +14 -13
  102. package/dist/session/codex/sessions.d.ts +1 -1
  103. package/dist/session/codex/sessions.js +4 -2
  104. package/dist/session/common/jsonl.js +7 -1
  105. package/dist/session/common/pagination.d.ts +2 -0
  106. package/dist/session/common/pagination.js +1 -1
  107. package/dist/session/common/recency.d.ts +6 -0
  108. package/dist/session/common/recency.js +7 -5
  109. package/dist/session/common/two-pass.d.ts +1 -1
  110. package/dist/session/common/usage-accumulator.d.ts +1 -1
  111. package/dist/session/dsh/sessions.d.ts +1 -1
  112. package/dist/session/dsh/sessions.js +37 -12
  113. package/dist/session/kimi/sessions.d.ts +1 -1
  114. package/dist/session/kimi/sessions.js +72 -27
  115. package/dist/session/opencode/sessions.d.ts +1 -1
  116. package/dist/session/opencode/sessions.js +164 -81
  117. package/dist/session/pi/sessions.d.ts +1 -1
  118. package/dist/session/pi/sessions.js +20 -7
  119. package/dist/session/registry.d.ts +2 -1
  120. package/dist/session/session-store.d.ts +1 -2
  121. package/dist/session/session-store.js +14 -7
  122. package/dist/session-contract.d.ts +178 -0
  123. package/dist/session-contract.js +9 -0
  124. package/package.json +1 -1
  125. package/dist/router/usage-format.d.ts +0 -60
  126. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  127. /package/dist/{runner/dsh → dsh}/home.d.ts +0 -0
  128. /package/dist/{runner/dsh → dsh}/home.js +0 -0
  129. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  130. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -5,9 +5,9 @@ import { readJsonlLines as readJsonlLinesShared, readLastNJsonlLines } from '../
5
5
  import { UsageAccumulator, cumulativeUsageFields } from '../common/usage-accumulator.js';
6
6
  import { getLogger } from '../../logger/index.js';
7
7
  import { isStale } from '../common/constants.js';
8
- import { samePath } from '../../platform/path.js';
8
+ import { safeRealpath, samePath } from '../../platform/path.js';
9
9
  import { capEvents, paginate } from '../common/pagination.js';
10
- import { sortByRecencyDesc } from '../common/recency.js';
10
+ import { newestOf, sortByRecencyDesc } from '../common/recency.js';
11
11
  import { TtlCache } from '../../common/ttl-cache.js';
12
12
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
13
13
  import { truncateToolInput } from '../common/content-blocks.js';
@@ -88,7 +88,12 @@ function readJsonlLines(filePath) {
88
88
  jsonlCache.set(filePath, { mtime, lines });
89
89
  return lines;
90
90
  }
91
- catch {
91
+ catch (err) {
92
+ // ENOENT = 会话文件在扫描间隙被清理(正常);其余 errno 留痕,否则该会话
93
+ // 静默从 /resume 消失。
94
+ if (err.code !== 'ENOENT') {
95
+ getLogger().warn(`[kimi-session-reader] cannot read ${filePath}: ${err?.message ?? String(err)}`);
96
+ }
92
97
  return [];
93
98
  }
94
99
  }
@@ -201,7 +206,13 @@ export class KimiSessionReader {
201
206
  */
202
207
  listSessions(cwd, opts) {
203
208
  try {
204
- const realCwd = fs.realpathSync(cwd);
209
+ // 归一失败(陈旧/无权限 cwd)回退原样输入,让逐条 samePath 比较去决定
210
+ // 空态——一次 realpathSync 抛错不得把整张列表清掉(与 opencode 同口径)。
211
+ const realCwd = safeRealpath(cwd, (err) => {
212
+ if (err.code === 'ENOENT')
213
+ return;
214
+ getLogger().debug(`[kimi-session-reader] realpath ${cwd} failed, using the path as given: ${err?.message ?? String(err)}`);
215
+ });
205
216
  const sessions = [];
206
217
  for (const entry of this.scanSessionIndex()) {
207
218
  // state.json 一次读取共用(cwd 兜底 + summary),I/O 减半。
@@ -258,7 +269,16 @@ export class KimiSessionReader {
258
269
  mtimeMs = fs.statSync(wirePath).mtimeMs;
259
270
  }
260
271
  catch {
261
- mtimeMs = fs.statSync(entry.sessionDir).mtimeMs;
272
+ try {
273
+ mtimeMs = fs.statSync(entry.sessionDir).mtimeMs;
274
+ }
275
+ catch (err) {
276
+ if (err.code !== 'ENOENT') {
277
+ getLogger().warn(`[kimi-session-reader] cannot stat ${entry.sessionDir}: ${err?.message ?? String(err)}`);
278
+ }
279
+ // 会话目录已不存在(或被清理):跳过该条,别让它拖垮整张列表。
280
+ continue;
281
+ }
262
282
  }
263
283
  sessions.push({
264
284
  sessionId: entry.sessionId,
@@ -276,15 +296,22 @@ export class KimiSessionReader {
276
296
  };
277
297
  }
278
298
  catch (error) {
279
- getLogger().error(`[kimi-session-reader] listSessions error: ${error}`);
280
- return { sessions: [], total: 0 };
299
+ // ENOENT(cwd 已删除):陈旧 cwd 保持优雅空态。
300
+ // 其余失败:error 日志 + 上抛,router 显示「列表失败」——静默 [] 会让
301
+ // 用户误以为没有 session,与真空不可区分(与 opencode 同口径)。
302
+ if (error.code === 'ENOENT') {
303
+ return { sessions: [], total: 0 };
304
+ }
305
+ getLogger().error(`[kimi-session-reader] listSessions failed: ${error}`);
306
+ const msg = error instanceof Error ? error.message : String(error);
307
+ throw new Error(`kimi session list 读取失败: ${msg}`, { cause: error });
281
308
  }
282
309
  }
283
310
  /**
284
311
  * Get the newest session for a given working directory
285
312
  */
286
313
  getNewestSession(cwd) {
287
- return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
314
+ return newestOf(this, cwd);
288
315
  }
289
316
  /**
290
317
  * Read session content (events + usage)
@@ -320,7 +347,15 @@ export class KimiSessionReader {
320
347
  // is an independent window). Aggregation covers the whole wire.jsonl
321
348
  // and is independent of the maxEvents display slice
322
349
  // (usage = the session's overall token cost).
323
- 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) => {
324
359
  hasUsageRecord = true;
325
360
  // Malformed records (missing usage object or missing fields) must
326
361
  // not poison the accumulators with NaN: count missing fields as 0.
@@ -328,18 +363,21 @@ export class KimiSessionReader {
328
363
  const output = u?.output ?? 0;
329
364
  const inputCacheRead = u?.inputCacheRead ?? 0;
330
365
  const inputCacheCreation = u?.inputCacheCreation ?? 0;
331
- acc.add({
366
+ const record = {
332
367
  input: inputOther,
333
368
  output,
334
369
  cacheRead: inputCacheRead,
335
370
  cacheCreation: inputCacheCreation,
336
- });
337
- if (updateContextLength) {
338
- // unified contract (§9.21): input + output + cacheRead + cacheCreation.
339
- // 末轮 output 已进入历史、下一 prompt 全量携带——与 compact postTokens
340
- // 水位同语义(此前漏加 output 会系统性低估长收尾 turn)。
341
- mainContextLength = inputOther + output + inputCacheRead + inputCacheCreation;
342
- }
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;
343
381
  };
344
382
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
345
383
  const line = lines[lineIndex];
@@ -347,7 +385,7 @@ export class KimiSessionReader {
347
385
  const entry = JSON.parse(line);
348
386
  // Extract usage from usage.record.
349
387
  if (isUsageRecord(entry)) {
350
- accumulateUsage(entry.usage, true);
388
+ accumulateUsage(entry.usage, 'main');
351
389
  continue;
352
390
  }
353
391
  // §6.3: Count compaction events and track pre-compact context length.
@@ -374,6 +412,8 @@ export class KimiSessionReader {
374
412
  // prompt), which is the correct catch-up title.
375
413
  // Extract display title from turn.prompt
376
414
  if (isTurnPrompt(entry)) {
415
+ // turn.prompt 是 turn 的起点:本 turn 的 flow 从这里重新累计。
416
+ turnAcc = new UsageAccumulator();
377
417
  if (entry.input && entry.input.length > 0) {
378
418
  displayTitle = entry.input[0].text;
379
419
  }
@@ -443,7 +483,7 @@ export class KimiSessionReader {
443
483
  try {
444
484
  const subEntry = JSON.parse(subLine);
445
485
  if (isUsageRecord(subEntry)) {
446
- accumulateUsage(subEntry.usage, false);
486
+ accumulateUsage(subEntry.usage, 'subagent');
447
487
  }
448
488
  }
449
489
  catch {
@@ -455,18 +495,23 @@ export class KimiSessionReader {
455
495
  catch {
456
496
  // agents dir unreadable — fall back to main-only usage
457
497
  }
458
- // 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.
459
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;
460
505
  // Kimi semantics: preserve 0 values (not undefined) for cache fields to match original
461
506
  const totalUsage = {
462
- inputTokens: t.input,
463
- outputTokens: t.output,
507
+ inputTokens: flow.input,
508
+ outputTokens: flow.output,
464
509
  contextLength: mainContextLength,
465
- cacheReadTokens: t.cacheRead > 0 ? t.cacheRead : 0,
466
- cacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : 0,
467
- totalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
468
- // Cumulative (session-wide): kimi sums all usage.records; mirrors
469
- // 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.
470
515
  ...cumulativeUsageFields(t),
471
516
  // §6.3: compaction stats from wire.jsonl
472
517
  compactCount,
@@ -15,7 +15,7 @@
15
15
  * - isSessionActive: use `updated` timestamp with 1 hour stale window.
16
16
  * - Simple memory cache with TTL.
17
17
  */
18
- import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
18
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../session-contract.js';
19
19
  interface OpencodeSessionReaderOptions {
20
20
  /** Path to the opencode binary. Defaults to `opencode`. */
21
21
  binary?: string;
@@ -22,9 +22,9 @@ import os from 'node:os';
22
22
  import path from 'node:path';
23
23
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
24
24
  import { isStale } from '../common/constants.js';
25
- import { samePath } from '../../platform/path.js';
25
+ import { safeRealpath, samePath } from '../../platform/path.js';
26
26
  import { paginate, capEvents } from '../common/pagination.js';
27
- import { sortByRecencyDesc } from '../common/recency.js';
27
+ import { newestOf, sortByRecencyDesc } from '../common/recency.js';
28
28
  import { TtlCache } from '../../common/ttl-cache.js';
29
29
  import { getLogger } from '../../logger/index.js';
30
30
  /** v2 CLI defaults `session list` to 100 entries; keep the reader's historical
@@ -81,6 +81,84 @@ 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
+ }
133
+ /**
134
+ * 把会话级累计 token 落到 usage 上(v1 export 与 v2 export 两条读取路径共用)。
135
+ *
136
+ * 已有 usage 就地补累计字段;没有 usage 但累计非零(例如只有 cache 计数)时,
137
+ * 构造一条只带累计的,避免"会话有消耗却显示无 usage"。
138
+ */
139
+ function withCumulativeUsage(usage, cum) {
140
+ if (usage) {
141
+ usage.cumulativeTotalTokens = cum.total;
142
+ usage.cumulativeInputTokens = cum.input;
143
+ usage.cumulativeOutputTokens = cum.output;
144
+ usage.cumulativeCacheReadTokens = cum.cacheRead;
145
+ usage.cumulativeCacheCreationTokens = cum.cacheWrite;
146
+ return usage;
147
+ }
148
+ if (!(cum.input || cum.output || cum.total || cum.cacheRead || cum.cacheWrite)) {
149
+ return undefined;
150
+ }
151
+ return {
152
+ inputTokens: 0,
153
+ outputTokens: 0,
154
+ contextLength: 0,
155
+ cumulativeTotalTokens: cum.total,
156
+ cumulativeInputTokens: cum.input,
157
+ cumulativeOutputTokens: cum.output,
158
+ cumulativeCacheReadTokens: cum.cacheRead,
159
+ cumulativeCacheCreationTokens: cum.cacheWrite,
160
+ };
161
+ }
84
162
  export class OpencodeSessionReader {
85
163
  binary;
86
164
  cacheTtlMs;
@@ -124,7 +202,7 @@ export class OpencodeSessionReader {
124
202
  * Get the newest session in a given cwd.
125
203
  */
126
204
  getNewestSession(cwd) {
127
- return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
205
+ return newestOf(this, cwd);
128
206
  }
129
207
  /**
130
208
  * Read session content: extract catch-up tail (last user message + everything after).
@@ -167,6 +245,8 @@ export class OpencodeSessionReader {
167
245
  }
168
246
  const events = [];
169
247
  let usage;
248
+ /** 本 turn 的 flow 累计:末条 user 消息之后的全部 step-finish。 */
249
+ let turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
170
250
  let aiTitle;
171
251
  let displayTitle;
172
252
  // Find the last user message position (for catch-up tail).
@@ -233,28 +313,34 @@ export class OpencodeSessionReader {
233
313
  }
234
314
  }
235
315
  }
236
- // Track usage from step-finish parts
316
+ // Track usage from step-finish parts. v1 没有 idle 标记,turn 边界就是
317
+ // 上面那段"最后一条 user 消息";同一 turn 可能有多个 step-finish
318
+ // (每次模型请求一条),flow 要全加起来,只留末条会把多步 run 低报。
237
319
  for (const part of msg.parts) {
238
320
  if (part.type === 'step-finish' && part.tokens) {
239
- // Use the last NON-EMPTY step-finish tokens. A long autonomous run
240
- // can end on a degenerate step (model cut off mid-reasoning) whose
241
- // step-finish reports all-zero tokens; trusting it would wipe out
242
- // the run's real per-turn usage (contextLength/input/output/total
243
- // all render 0). Skip zero-total steps so per-turn usage reflects
244
- // the last step that actually consumed tokens.
321
+ // Skip zero-total steps(模型在 reasoning 中被截断)——它们没有消耗,
322
+ // 也不该把本次请求当成一条真实采样。
245
323
  // ccusage-aligned: cache.write -> cacheCreationTokens.
246
324
  // contextLength = input + output + cacheRead + cacheCreation —
247
325
  // the unified context-window occupancy contract across all readers
248
326
  // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
249
327
  // postTokens 水位同语义).
250
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
+ };
251
340
  usage = {
252
- inputTokens: part.tokens.input,
253
- outputTokens: part.tokens.output,
254
- contextLength: (part.tokens.input ?? 0) +
255
- (part.tokens.output ?? 0) +
256
- (part.tokens.cache?.read ?? 0) +
257
- (part.tokens.cache?.write ?? 0),
341
+ inputTokens: input,
342
+ outputTokens: output,
343
+ contextLength: input + output + cacheRead + cacheWrite,
258
344
  cacheReadTokens: part.tokens.cache?.read,
259
345
  cacheCreationTokens: part.tokens.cache?.write,
260
346
  totalTokens: part.tokens.total,
@@ -263,6 +349,14 @@ export class OpencodeSessionReader {
263
349
  }
264
350
  }
265
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
+ }
266
360
  // Cumulative (session-wide): sum input/output/total across ALL step-finish
267
361
  // parts in the whole session. The catch-up loop above only scans from
268
362
  // the last user message (= last run) for per-turn usage; cumulative
@@ -283,25 +377,13 @@ export class OpencodeSessionReader {
283
377
  }
284
378
  }
285
379
  }
286
- if (usage) {
287
- usage.cumulativeTotalTokens = cumTotal;
288
- usage.cumulativeInputTokens = cumInput;
289
- usage.cumulativeOutputTokens = cumOutput;
290
- usage.cumulativeCacheReadTokens = cumCacheRead;
291
- usage.cumulativeCacheCreationTokens = cumCacheWrite;
292
- }
293
- else if (cumInput || cumOutput || cumTotal || cumCacheRead || cumCacheWrite) {
294
- usage = {
295
- inputTokens: 0,
296
- outputTokens: 0,
297
- contextLength: 0,
298
- cumulativeTotalTokens: cumTotal,
299
- cumulativeInputTokens: cumInput,
300
- cumulativeOutputTokens: cumOutput,
301
- cumulativeCacheReadTokens: cumCacheRead,
302
- cumulativeCacheCreationTokens: cumCacheWrite,
303
- };
304
- }
380
+ usage = withCumulativeUsage(usage, {
381
+ total: cumTotal,
382
+ input: cumInput,
383
+ output: cumOutput,
384
+ cacheRead: cumCacheRead,
385
+ cacheWrite: cumCacheWrite,
386
+ });
305
387
  // Use info.title as aiTitle
306
388
  if (data.info.title) {
307
389
  aiTitle = data.info.title;
@@ -444,51 +526,53 @@ export class OpencodeSessionReader {
444
526
  let cumTotal = 0;
445
527
  let cumCacheRead = 0;
446
528
  let cumCacheWrite = 0;
447
- for (const msg of messages) {
448
- 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)
449
537
  continue;
450
- const tokens = msg.tokens;
451
- const cacheRead = tokens.cache?.read ?? 0;
452
- const cacheWrite = tokens.cache?.write ?? 0;
453
- const total = tokens.input + tokens.output + tokens.reasoning + cacheRead + cacheWrite;
454
- cumInput += tokens.input;
455
- cumOutput += tokens.output;
456
- cumTotal += total;
457
- cumCacheRead += cacheRead;
458
- cumCacheWrite += cacheWrite;
459
- // A long autonomous run can end on a degenerate assistant message with
460
- // zero tokens; skip it so the card reflects the last real step.
461
- if (total !== 0) {
462
- latest = {
463
- inputTokens: tokens.input,
464
- outputTokens: tokens.output,
465
- // §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
466
- contextLength: tokens.input + tokens.output + cacheRead + cacheWrite,
467
- cacheReadTokens: cacheRead,
468
- cacheCreationTokens: cacheWrite,
469
- totalTokens: total,
470
- };
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;
471
549
  }
472
- }
473
- if (latest) {
474
- latest.cumulativeTotalTokens = cumTotal;
475
- latest.cumulativeInputTokens = cumInput;
476
- latest.cumulativeOutputTokens = cumOutput;
477
- latest.cumulativeCacheReadTokens = cumCacheRead;
478
- latest.cumulativeCacheCreationTokens = cumCacheWrite;
479
- }
480
- else if (cumInput || cumOutput || cumTotal || cumCacheRead || cumCacheWrite) {
481
550
  latest = {
482
- inputTokens: 0,
483
- outputTokens: 0,
484
- contextLength: 0,
485
- cumulativeTotalTokens: cumTotal,
486
- cumulativeInputTokens: cumInput,
487
- cumulativeOutputTokens: cumOutput,
488
- cumulativeCacheReadTokens: cumCacheRead,
489
- cumulativeCacheCreationTokens: cumCacheWrite,
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,
490
558
  };
491
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;
568
+ }
569
+ latest = withCumulativeUsage(latest, {
570
+ total: cumTotal,
571
+ input: cumInput,
572
+ output: cumOutput,
573
+ cacheRead: cumCacheRead,
574
+ cacheWrite: cumCacheWrite,
575
+ });
492
576
  return {
493
577
  events: capEvents(events, opts?.maxEvents),
494
578
  usage: latest,
@@ -518,12 +602,11 @@ export class OpencodeSessionReader {
518
602
  // --- Private helpers ---
519
603
  /** realpath 归一化(public:测试直接调用,替代 as unknown as)。 */
520
604
  realpath(cwd) {
521
- try {
522
- return fs.realpathSync(cwd);
523
- }
524
- catch {
525
- return cwd;
526
- }
605
+ return safeRealpath(cwd, (err) => {
606
+ if (err.code === 'ENOENT')
607
+ return;
608
+ getLogger().debug(`[opencode-session-reader] realpath ${cwd} failed, using the path as given: ${err?.message ?? String(err)}`);
609
+ });
527
610
  }
528
611
  fetchSessionList(cwd) {
529
612
  const realCwd = this.realpath(cwd);
@@ -1,5 +1,5 @@
1
1
  import { type ContentBlockMapping } from '../common/content-blocks.js';
2
- import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
2
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../session-contract.js';
3
3
  /** Pi-specific field-name mapping for content block extraction. */
4
4
  export declare const PI_MAPPING: ContentBlockMapping;
5
5
  /**
@@ -8,7 +8,7 @@ import { UsageAccumulator, contextWindowOccupancy, cumulativeUsageFields, } from
8
8
  import { isStale } from '../common/constants.js';
9
9
  import { encodeProjectDirName, samePath } from '../../platform/path.js';
10
10
  import { paginate } from '../common/pagination.js';
11
- import { sortByRecencyDesc } from '../common/recency.js';
11
+ import { newestOf, sortByRecencyDesc } from '../common/recency.js';
12
12
  import { TtlCache } from '../../common/ttl-cache.js';
13
13
  import { getLogger } from '../../logger/index.js';
14
14
  function defaultPiDir() {
@@ -135,8 +135,11 @@ function findSessionFile(sessionId, cwd, sessionsDir) {
135
135
  }
136
136
  }
137
137
  }
138
- catch {
139
- // dir doesn't exist
138
+ catch (err) {
139
+ // dir 不存在(陈旧 cwd / 该 cwd 从没有会话)是正常空态;其余留痕。
140
+ if (err.code !== 'ENOENT') {
141
+ getLogger().debug(`[pi-session-reader] cannot read ${dir}: ${err?.message ?? String(err)}`);
142
+ }
140
143
  }
141
144
  return null;
142
145
  }
@@ -282,7 +285,7 @@ export class PiSessionReader {
282
285
  };
283
286
  }
284
287
  getNewestSession(cwd) {
285
- return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
288
+ return newestOf(this, cwd);
286
289
  }
287
290
  readSessionContent(sessionId, cwd, opts) {
288
291
  let filePath = null;
@@ -319,7 +322,10 @@ export class PiSessionReader {
319
322
  const st = fs.statSync(filePath);
320
323
  return !isStale(st.mtimeMs);
321
324
  }
322
- catch {
325
+ catch (err) {
326
+ if (err.code !== 'ENOENT') {
327
+ getLogger().debug(`[pi-session-reader] stat ${filePath} failed: ${err?.message ?? String(err)}`);
328
+ }
323
329
  return false;
324
330
  }
325
331
  }
@@ -335,8 +341,15 @@ export class PiSessionReader {
335
341
  try {
336
342
  files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
337
343
  }
338
- catch {
339
- return [];
344
+ catch (err) {
345
+ // ENOENT:该 cwd 还没有会话目录(正常空态)。其余失败上抛,由 router
346
+ // 显示「列表失败」——静默 [] 与真空不可区分(与 opencode 同口径)。
347
+ if (err.code === 'ENOENT') {
348
+ return [];
349
+ }
350
+ getLogger().error(`[pi-session-reader] listSessions failed: ${err}`);
351
+ const msg = err instanceof Error ? err.message : String(err);
352
+ throw new Error(`pi session list 读取失败: ${msg}`, { cause: err });
340
353
  }
341
354
  const sessions = [];
342
355
  for (const f of files) {
@@ -1,4 +1,5 @@
1
- import type { AgentKind, AgentSessionReader } from '../runner/index.js';
1
+ import type { AgentKind } from '../agent-kinds.js';
2
+ import type { AgentSessionReader } from '../session-contract.js';
2
3
  /**
3
4
  * Registry of `AgentSessionReader` instances, keyed by `AgentKind`. Mirrors
4
5
  * `AgentRegistry` but for session-history reads. `index.ts` registers one
@@ -1,4 +1,4 @@
1
- import type { AgentKind } from '../runner/index.js';
1
+ import type { AgentKind } from '../agent-kinds.js';
2
2
  interface SessionEntry {
3
3
  /** Session IDs per agent kind (Map for in-memory, plain object for persistence). */
4
4
  sessions: Map<AgentKind, string>;
@@ -58,7 +58,6 @@ export declare class SessionStore {
58
58
  */
59
59
  private mutateEntry;
60
60
  get(userId: string): SessionEntry | undefined;
61
- set(userId: string, entry: SessionEntry): void;
62
61
  delete(userId: string): void;
63
62
  /** Clear sessionId for a specific agent, keeping cwd and other agent sessions */
64
63
  clearSessionId(userId: string, agent?: AgentKind, opts?: {
@@ -85,10 +85,6 @@ export class SessionStore {
85
85
  get(userId) {
86
86
  return this.sessions.get(userId);
87
87
  }
88
- set(userId, entry) {
89
- this.sessions.set(userId, entry);
90
- this.autoPersist();
91
- }
92
88
  delete(userId) {
93
89
  this.sessions.delete(userId);
94
90
  this.autoPersist();
@@ -170,7 +166,7 @@ export class SessionStore {
170
166
  /** Get sessionId for a specific agent, or undefined if no session or sessionId is empty */
171
167
  getSessionId(userId, agent = 'claude') {
172
168
  const entry = this.sessions.get(userId);
173
- if (!entry || !entry.sessions)
169
+ if (!entry)
174
170
  return undefined;
175
171
  const sessionId = entry.sessions.get(agent);
176
172
  if (!sessionId)
@@ -284,8 +280,19 @@ export class SessionStore {
284
280
  }
285
281
  }
286
282
  }
287
- catch {
288
- getLogger().warn('[session] persisted session store corrupted, ignoring');
283
+ catch (err) {
284
+ getLogger().warn(`[session] persisted session store corrupted (${this.persistPath ?? '(memory)'}), ignoring:`, err);
285
+ // 改名保留现场:原文件继续原地躺着会让下次启动重复告警,也丢掉排障证据。
286
+ if (this.persistPath) {
287
+ const brokenPath = `${this.persistPath}.corrupt-${Date.now()}`;
288
+ try {
289
+ fs.renameSync(this.persistPath, brokenPath);
290
+ getLogger().warn(`[session] moved corrupt store to ${brokenPath}`);
291
+ }
292
+ catch (renameErr) {
293
+ getLogger().warn('[session] failed to preserve corrupt store:', renameErr);
294
+ }
295
+ }
289
296
  }
290
297
  }
291
298
  autoPersist() {