lark-remote 0.5.19 → 0.5.21

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 (136) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +72 -46
  10. package/dist/bridge/index.d.ts +106 -153
  11. package/dist/bridge/index.js +400 -1000
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +141 -130
  18. package/dist/card/card-budget.js +123 -98
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +114 -159
  24. package/dist/card/run-state.d.ts +1 -15
  25. package/dist/card/run-state.js +358 -292
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +21 -17
  28. package/dist/card/usage-format.d.ts +22 -7
  29. package/dist/card/usage-format.js +119 -100
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/common/text-width.js +36 -24
  33. package/dist/common/tool-summary.js +132 -90
  34. package/dist/config/codex-config.js +130 -106
  35. package/dist/config/index.d.ts +16 -2
  36. package/dist/config/index.js +23 -8
  37. package/dist/connector/index.js +6 -6
  38. package/dist/error-classification.js +3 -2
  39. package/dist/inbound/placeholder.d.ts +1 -1
  40. package/dist/inbound/placeholder.js +1 -1
  41. package/dist/index.js +387 -281
  42. package/dist/logger/index.js +6 -6
  43. package/dist/message-handlers.d.ts +0 -5
  44. package/dist/message-handlers.js +254 -237
  45. package/dist/model-catalog.d.ts +2 -2
  46. package/dist/model-catalog.js +3 -8
  47. package/dist/platform/identity.js +1 -1
  48. package/dist/platform/path.d.ts +1 -1
  49. package/dist/platform/path.js +1 -1
  50. package/dist/platform/sleep-blocker.js +1 -1
  51. package/dist/platform/terminator-win32.js +2 -1
  52. package/dist/router/commands/ls.d.ts +3 -0
  53. package/dist/router/commands/ls.js +256 -175
  54. package/dist/router/commands/order.js +184 -159
  55. package/dist/router/commands/resume.js +190 -152
  56. package/dist/router/commands/ws.js +219 -174
  57. package/dist/router/config/dsh.d.ts +4 -8
  58. package/dist/router/config/dsh.js +3 -10
  59. package/dist/router/config/kimi.js +1 -1
  60. package/dist/router/config/types.d.ts +1 -1
  61. package/dist/router/config-diff.js +1 -1
  62. package/dist/router/index.d.ts +56 -11
  63. package/dist/router/index.js +401 -391
  64. package/dist/runner/bash/runner.js +2 -4
  65. package/dist/runner/claude/session.d.ts +1 -1
  66. package/dist/runner/claude/session.js +6 -4
  67. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  68. package/dist/runner/codex/app-server/runner.js +30 -25
  69. package/dist/runner/codex/app-server/translator.d.ts +2 -5
  70. package/dist/runner/codex/app-server/translator.js +291 -238
  71. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  72. package/dist/runner/common/acp/acp-approval.js +1 -1
  73. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  74. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  75. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  76. package/dist/runner/common/acp/base-translator.js +50 -26
  77. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  78. package/dist/runner/common/acp/protocol-types.js +2 -2
  79. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  80. package/dist/runner/common/connection-based-runner.js +32 -2
  81. package/dist/runner/common/jsonl-stream.js +154 -187
  82. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  83. package/dist/runner/common/jsonrpc/client.js +43 -12
  84. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  85. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  86. package/dist/runner/common/spawning-runner.d.ts +6 -1
  87. package/dist/runner/common/spawning-runner.js +9 -4
  88. package/dist/runner/common/turn-started.d.ts +8 -0
  89. package/dist/runner/common/turn-started.js +11 -0
  90. package/dist/runner/dsh/approval.js +84 -51
  91. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  92. package/dist/runner/dsh/catalog-probe.js +10 -0
  93. package/dist/runner/dsh/client.d.ts +7 -11
  94. package/dist/runner/dsh/client.js +356 -289
  95. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  96. package/dist/runner/dsh/exit-cancel.js +2 -2
  97. package/dist/runner/dsh/inflight.d.ts +1 -1
  98. package/dist/runner/dsh/inflight.js +1 -1
  99. package/dist/runner/dsh/mux-stream.js +28 -8
  100. package/dist/runner/dsh/runner.d.ts +44 -4
  101. package/dist/runner/dsh/runner.js +296 -199
  102. package/dist/runner/dsh/translator.d.ts +1 -31
  103. package/dist/runner/dsh/translator.js +180 -178
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  106. package/dist/runner/kimi/acp/runner.js +82 -46
  107. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  108. package/dist/runner/kimi/acp/translator.js +3 -4
  109. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  110. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  111. package/dist/runner/opencode/acp/runner.js +4 -4
  112. package/dist/runner/pi/rpc/client.js +4 -13
  113. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  114. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  115. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  116. package/dist/runner/pi/rpc/runner.js +27 -42
  117. package/dist/runner/pi/rpc/translator.js +61 -60
  118. package/dist/runner/types.d.ts +2 -2
  119. package/dist/runner/types.js +1 -1
  120. package/dist/session/claude/sessions.d.ts +45 -0
  121. package/dist/session/claude/sessions.js +160 -122
  122. package/dist/session/codex/rollout-reader.js +181 -182
  123. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  124. package/dist/session/common/throw-session-list-error.js +16 -0
  125. package/dist/session/dsh/sessions.js +291 -235
  126. package/dist/session/kimi/sessions.d.ts +40 -8
  127. package/dist/session/kimi/sessions.js +373 -301
  128. package/dist/session/opencode/sessions.d.ts +142 -1
  129. package/dist/session/opencode/sessions.js +378 -321
  130. package/dist/session/pi/sessions.js +4 -5
  131. package/dist/session/session-store.d.ts +9 -1
  132. package/dist/session/session-store.js +99 -99
  133. package/dist/update/version-check.js +2 -1
  134. package/dist/workspace/index.d.ts +1 -1
  135. package/dist/workspace/index.js +1 -1
  136. package/package.json +1 -1
@@ -22,6 +22,7 @@ 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 { throwSessionListError } from '../common/throw-session-list-error.js';
25
26
  import { safeRealpath, samePath } from '../../platform/path.js';
26
27
  import { paginate, capEvents } from '../common/pagination.js';
27
28
  import { newestOf, sortByRecencyDesc } from '../common/recency.js';
@@ -165,6 +166,349 @@ function withCumulativeUsage(usage, cum) {
165
166
  cumulativeCacheCreationTokens: cum.cacheWrite,
166
167
  };
167
168
  }
169
+ function emptyFlow() {
170
+ return { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
171
+ }
172
+ /** 末条 user 消息下标(catch-up tail 起点);无 user 消息 = -1。 */
173
+ function lastV1UserIndex(messages) {
174
+ let lastUserIndex = -1;
175
+ for (let i = 0; i < messages.length; i++) {
176
+ if (messages[i].info.role === 'user') {
177
+ lastUserIndex = i;
178
+ }
179
+ }
180
+ return lastUserIndex;
181
+ }
182
+ /** cumulative:全会话逐字段求和,含零 total 的退化 step(字段 0 不改变和)。 */
183
+ function addV1Cumulative(tokens, cum) {
184
+ cum.input += tokens.input ?? 0;
185
+ cum.output += tokens.output ?? 0;
186
+ cum.total += tokens.total ?? 0;
187
+ cum.cacheRead += tokens.cache?.read ?? 0;
188
+ cum.cacheCreation += tokens.cache?.write ?? 0;
189
+ }
190
+ /** 单次非零采样:累加本 run flow 并返回 latest 快照。 */
191
+ function addV1TurnSample(tokens, turn) {
192
+ // ccusage-aligned: cache.write -> cacheCreationTokens.
193
+ // contextLength = input + output + cacheRead + cacheCreation —
194
+ // the unified context-window occupancy contract across all readers
195
+ // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
196
+ // postTokens 水位同语义).
197
+ const input = tokens.input ?? 0;
198
+ const output = tokens.output ?? 0;
199
+ const cacheRead = tokens.cache?.read ?? 0;
200
+ const cacheWrite = tokens.cache?.write ?? 0;
201
+ turn.input += input;
202
+ turn.output += output;
203
+ turn.cacheRead += cacheRead;
204
+ turn.cacheCreation += cacheWrite;
205
+ turn.total += tokens.total;
206
+ return {
207
+ inputTokens: input,
208
+ outputTokens: output,
209
+ contextLength: input + output + cacheRead + cacheWrite,
210
+ cacheReadTokens: tokens.cache?.read,
211
+ cacheCreationTokens: tokens.cache?.write,
212
+ totalTokens: tokens.total,
213
+ };
214
+ }
215
+ /**
216
+ * 单条 step-finish part:cum 对全会话逐字段求和(含零 total 退化 step),
217
+ * inTail 且非零 total 时把本 run flow 累加到 turn 并返回新的 latest 采样。
218
+ */
219
+ function accumulateV1StepPart(part, inTail, turn, cum) {
220
+ if (part.type !== 'step-finish' || !part.tokens)
221
+ return undefined;
222
+ addV1Cumulative(part.tokens, cum);
223
+ if (!inTail)
224
+ return undefined;
225
+ // Skip zero-total steps(模型在 reasoning 中被截断)——它们没有消耗,
226
+ // 也不该把本次请求当成一条真实采样。
227
+ if ((part.tokens.total ?? 0) === 0)
228
+ return undefined;
229
+ return addV1TurnSample(part.tokens, turn);
230
+ }
231
+ /** 一条 user 消息里的首个 text part(tail displayTitle 首选)。 */
232
+ function firstV1TextPart(parts) {
233
+ for (const part of parts) {
234
+ if (part.type === 'text' && part.text)
235
+ return part.text;
236
+ }
237
+ return undefined;
238
+ }
239
+ export function scanV1Messages(messages) {
240
+ // Find the last user message position (for catch-up tail).
241
+ // messages are already parsed objects — scan directly.
242
+ const lastUserIndex = lastV1UserIndex(messages);
243
+ // Process messages from last user message onwards (catch-up tail)
244
+ const startIndex = lastUserIndex >= 0 ? lastUserIndex : 0;
245
+ const turn = emptyFlow();
246
+ const cum = emptyFlow();
247
+ let latest;
248
+ let tailDisplayTitle;
249
+ for (let i = 0; i < messages.length; i++) {
250
+ const inTail = i >= startIndex;
251
+ const msg = messages[i];
252
+ // Track usage from step-finish parts. v1 没有 idle 标记,turn 边界就是
253
+ // 末条 user 消息;同一 turn 可能有多个 step-finish(每次模型请求一条),
254
+ // flow 要全加起来,只留末条会把多步 run 低报。
255
+ for (const part of msg.parts) {
256
+ const stepLatest = accumulateV1StepPart(part, inTail, turn, cum);
257
+ if (stepLatest)
258
+ latest = stepLatest;
259
+ }
260
+ // First user text after last user message is displayTitle
261
+ if (inTail && !tailDisplayTitle && msg.info.role === 'user') {
262
+ tailDisplayTitle = firstV1TextPart(msg.parts);
263
+ }
264
+ }
265
+ return { lastUserIndex, tailDisplayTitle, latest, turn, cum };
266
+ }
267
+ /**
268
+ * v1 catch-up tail 装配(纯函数):把 [startIndex, 末尾) 的消息映射为展示
269
+ * 事件(text / thinking / tool_use / tool_result)。tool 在同一 part 上先发
270
+ * use(有 callID+input)再发 result(有 callID+output)。
271
+ */
272
+ function assembleV1TailEvents(messages, startIndex) {
273
+ const events = [];
274
+ for (let i = startIndex; i < messages.length; i++) {
275
+ const msg = messages[i];
276
+ const timestamp = msg.info.time?.created
277
+ ? new Date(msg.info.time.created).toISOString()
278
+ : undefined;
279
+ for (const part of msg.parts) {
280
+ if (part.type === 'text') {
281
+ events.push({
282
+ type: 'text',
283
+ content: part.text ?? '',
284
+ timestamp,
285
+ });
286
+ }
287
+ else if (part.type === 'reasoning') {
288
+ events.push({
289
+ type: 'thinking',
290
+ content: part.text ?? '',
291
+ timestamp,
292
+ });
293
+ }
294
+ else if (part.type === 'tool') {
295
+ // Tool use
296
+ if (part.callID && part.state?.input) {
297
+ events.push({
298
+ type: 'tool_use',
299
+ content: JSON.stringify({
300
+ id: part.callID,
301
+ name: part.tool,
302
+ input: part.state.input,
303
+ }),
304
+ timestamp,
305
+ });
306
+ }
307
+ // Tool result (completed or error)
308
+ if (part.callID && part.state?.output) {
309
+ const isError = part.state.status === 'error';
310
+ const rawOut = isError
311
+ ? (part.state.error ?? part.state.output)
312
+ : (part.state.output ?? '');
313
+ // pre-fold pathological tool_results (e.g. a 500KB file
314
+ // listing) via the shared truncateUtf8 primitive, reusing the
315
+ // same …(已截断) suffix as the live renderer / card budget.
316
+ events.push({
317
+ type: 'tool_result',
318
+ content: truncateUtf8(rawOut, TOOL_RESULT_MAX_BYTES),
319
+ timestamp,
320
+ });
321
+ }
322
+ }
323
+ }
324
+ }
325
+ return events;
326
+ }
327
+ /** 全会话首条 user 文本(displayTitle 兜底;纯函数)。 */
328
+ function firstV1UserText(messages) {
329
+ for (const msg of messages) {
330
+ if (msg.info.role === 'user') {
331
+ for (const part of msg.parts) {
332
+ if (part.type === 'text' && part.text) {
333
+ return part.text;
334
+ }
335
+ }
336
+ }
337
+ }
338
+ return undefined;
339
+ }
340
+ /**
341
+ * usage 装配(v1/v2 共用,同 readSessionContent 内的既有共享粒度):
342
+ * flow 覆盖末条采样值(contextLength 保留末条——水位不求和),再落会话累计。
343
+ */
344
+ function finalizeTurnUsage(latest, turn, cum) {
345
+ if (latest) {
346
+ latest.inputTokens = turn.input;
347
+ latest.outputTokens = turn.output;
348
+ latest.cacheReadTokens = turn.cacheRead;
349
+ latest.cacheCreationTokens = turn.cacheCreation;
350
+ latest.totalTokens = turn.total;
351
+ }
352
+ return withCumulativeUsage(latest, {
353
+ total: cum.total,
354
+ input: cum.input,
355
+ output: cum.output,
356
+ cacheRead: cum.cacheRead,
357
+ cacheWrite: cum.cacheCreation,
358
+ });
359
+ }
360
+ /**
361
+ * v2 扫描累积器(纯函数):usageSample 已滤掉零 total 的退化消息;turn 边界
362
+ * 由 lastTurnStartIndex 判定(idle / user / synthetic / system,见其注释)。
363
+ *
364
+ * 产生:OpencodeV2UsageScan。依赖:入参 messages。不改任何外部状态。
365
+ */
366
+ export function scanV2Usage(messages) {
367
+ const turn = emptyFlow();
368
+ const cum = emptyFlow();
369
+ let latest;
370
+ // 本 turn(= 本 run)的 flow:末个 turn 内每次模型请求之和。contextLength
371
+ // 仍取末次请求的窗口水位(水位不可求和)。只取末条会把多步 run 低报
372
+ // (实测一条 25 请求的 run:input 20,040 显示成 543、output 10,358 显示成 1,250)。
373
+ const turnStart = lastTurnStartIndex(messages);
374
+ for (let i = 0; i < messages.length; i++) {
375
+ const sample = usageSample(messages[i]);
376
+ if (!sample)
377
+ continue;
378
+ cum.input += sample.input;
379
+ cum.output += sample.output;
380
+ cum.total += sample.total;
381
+ cum.cacheRead += sample.cacheRead;
382
+ cum.cacheCreation += sample.cacheCreation;
383
+ if (i > turnStart) {
384
+ turn.input += sample.input;
385
+ turn.output += sample.output;
386
+ turn.cacheRead += sample.cacheRead;
387
+ turn.cacheCreation += sample.cacheCreation;
388
+ turn.total += sample.total;
389
+ }
390
+ latest = {
391
+ inputTokens: sample.input,
392
+ outputTokens: sample.output,
393
+ // §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
394
+ contextLength: sample.input + sample.output + sample.cacheRead + sample.cacheCreation,
395
+ cacheReadTokens: sample.cacheRead,
396
+ cacheCreationTokens: sample.cacheCreation,
397
+ totalTokens: sample.total,
398
+ };
399
+ }
400
+ return { latest, turn, cum };
401
+ }
402
+ function v2UserHandler(msg, timestamp, state) {
403
+ if (typeof msg.text === 'string' && msg.text.length > 0) {
404
+ if (!state.displayTitle)
405
+ state.displayTitle = msg.text;
406
+ state.events.push({ type: 'text', content: msg.text, timestamp });
407
+ }
408
+ }
409
+ function pushV2ToolPart(part, timestamp, state) {
410
+ const toolId = part.id;
411
+ const toolName = part.name ?? '';
412
+ const input = parseToolInput(part.state?.input);
413
+ if (toolId) {
414
+ state.events.push({
415
+ type: 'tool_use',
416
+ content: JSON.stringify({ id: toolId, name: toolName, input }),
417
+ timestamp,
418
+ });
419
+ }
420
+ const output = v2ToolOutput(part.state);
421
+ if (toolId && output !== undefined) {
422
+ state.events.push({
423
+ type: 'tool_result',
424
+ content: truncateUtf8(output, TOOL_RESULT_MAX_BYTES),
425
+ timestamp,
426
+ });
427
+ }
428
+ }
429
+ function v2AssistantHandler(msg, timestamp, state) {
430
+ for (const part of Array.isArray(msg.content) ? msg.content : []) {
431
+ if (part?.type === 'text' && typeof part.text === 'string') {
432
+ state.events.push({ type: 'text', content: part.text, timestamp });
433
+ continue;
434
+ }
435
+ if (part?.type === 'reasoning' && typeof part.text === 'string') {
436
+ state.events.push({ type: 'thinking', content: part.text, timestamp });
437
+ continue;
438
+ }
439
+ if (part?.type === 'tool')
440
+ pushV2ToolPart(part, timestamp, state);
441
+ }
442
+ }
443
+ /** User-invoked shell messages are a first-class message type in v2. */
444
+ function v2ShellHandler(msg, timestamp, state) {
445
+ if (typeof msg.command !== 'string')
446
+ return;
447
+ state.events.push({
448
+ type: 'tool_use',
449
+ content: JSON.stringify({
450
+ id: msg.callID ?? msg.id ?? 'shell',
451
+ name: 'bash',
452
+ input: { command: msg.command },
453
+ }),
454
+ timestamp,
455
+ });
456
+ if (typeof msg.output === 'string') {
457
+ state.events.push({
458
+ type: 'tool_result',
459
+ content: truncateUtf8(msg.output, TOOL_RESULT_MAX_BYTES),
460
+ timestamp,
461
+ });
462
+ }
463
+ }
464
+ /** 只保留 compact summary;system/synthetic/切换类控制消息是噪声。 */
465
+ function v2CompactionHandler(msg, timestamp, state) {
466
+ if (typeof msg.summary === 'string' && msg.summary) {
467
+ state.events.push({ type: 'text', content: msg.summary, timestamp });
468
+ }
469
+ }
470
+ /**
471
+ * 消息类型 → 命名 handler。用 Map 而非对象字面量 / 长串 if:
472
+ * `constructor` / `toString` 这类外来 type 会命中 Object.prototype。
473
+ */
474
+ const V2_TAIL_HANDLERS = new Map([
475
+ ['user', v2UserHandler],
476
+ ['assistant', v2AssistantHandler],
477
+ ['shell', v2ShellHandler],
478
+ ['compaction', v2CompactionHandler],
479
+ ]);
480
+ /** 全会话首条 user 文本(tail 无 user 文本时的 displayTitle 兜底)。 */
481
+ function firstV2UserText(messages) {
482
+ for (const msg of messages) {
483
+ if (msg?.type === 'user' && typeof msg.text === 'string' && msg.text.length > 0) {
484
+ return msg.text;
485
+ }
486
+ }
487
+ return undefined;
488
+ }
489
+ function assembleV2TailEvents(messages) {
490
+ const state = { events: [] };
491
+ let lastUserIndex = -1;
492
+ for (let i = 0; i < messages.length; i++) {
493
+ if (messages[i]?.type === 'user')
494
+ lastUserIndex = i;
495
+ }
496
+ const startIndex = lastUserIndex >= 0 ? lastUserIndex : 0;
497
+ for (let i = startIndex; i < messages.length; i++) {
498
+ const msg = messages[i];
499
+ if (!msg || typeof msg.type !== 'string')
500
+ continue;
501
+ const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : undefined;
502
+ const handler = V2_TAIL_HANDLERS.get(msg.type);
503
+ if (handler)
504
+ handler(msg, timestamp, state);
505
+ }
506
+ // If the last user message had no text (e.g. attachment-only), fall back to
507
+ // the first human text anywhere in the session for the card title.
508
+ if (!state.displayTitle)
509
+ state.displayTitle = firstV2UserText(messages);
510
+ return { events: state.events, displayTitle: state.displayTitle };
511
+ }
168
512
  export class OpencodeSessionReader {
169
513
  binary;
170
514
  cacheTtlMs;
@@ -214,6 +558,9 @@ export class OpencodeSessionReader {
214
558
  }
215
559
  /**
216
560
  * Read session content: extract catch-up tail (last user message + everything after).
561
+ *
562
+ * 编排:定位会话文件(captureExport + parse)→ directory 精确匹配(与
563
+ * cwd-scoped list 同口径)→ 按 export 版本分发到 v1/v2 装配。
217
564
  */
218
565
  readSessionContent(sessionId, cwd, opts) {
219
566
  const realCwd = this.realpath(cwd);
@@ -251,342 +598,54 @@ export class OpencodeSessionReader {
251
598
  if (isV2Export(data)) {
252
599
  return this.readV2SessionContent(data, opts);
253
600
  }
254
- const events = [];
255
- let usage;
256
- /** 本 turn 的 flow 累计:末条 user 消息之后的全部 step-finish。 */
257
- let turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
258
- let aiTitle;
259
- let displayTitle;
260
- // Find the last user message position (for catch-up tail).
261
- // messages are already parsed objects — scan directly.
262
- let lastUserIndex = -1;
263
- for (let i = 0; i < data.messages.length; i++) {
264
- if (data.messages[i].info.role === 'user') {
265
- lastUserIndex = i;
266
- }
267
- }
268
- // Process messages from last user message onwards (catch-up tail)
269
- const startIndex = lastUserIndex >= 0 ? lastUserIndex : 0;
270
- for (let i = startIndex; i < data.messages.length; i++) {
271
- const msg = data.messages[i];
272
- const timestamp = msg.info.time?.created
273
- ? new Date(msg.info.time.created).toISOString()
274
- : undefined;
275
- for (const part of msg.parts) {
276
- if (part.type === 'text') {
277
- // First user text after last user message is displayTitle
278
- if (msg.info.role === 'user' && part.text && !displayTitle) {
279
- displayTitle = part.text;
280
- }
281
- events.push({
282
- type: 'text',
283
- content: part.text ?? '',
284
- timestamp,
285
- });
286
- }
287
- else if (part.type === 'reasoning') {
288
- events.push({
289
- type: 'thinking',
290
- content: part.text ?? '',
291
- timestamp,
292
- });
293
- }
294
- else if (part.type === 'tool') {
295
- // Tool use
296
- if (part.callID && part.state?.input) {
297
- events.push({
298
- type: 'tool_use',
299
- content: JSON.stringify({
300
- id: part.callID,
301
- name: part.tool,
302
- input: part.state.input,
303
- }),
304
- timestamp,
305
- });
306
- }
307
- // Tool result (completed or error)
308
- if (part.callID && part.state?.output) {
309
- const isError = part.state.status === 'error';
310
- const rawOut = isError
311
- ? (part.state.error ?? part.state.output)
312
- : (part.state.output ?? '');
313
- // pre-fold pathological tool_results (e.g. a 500KB file
314
- // listing) via the shared truncateUtf8 primitive, reusing the
315
- // same …(已截断) suffix as the live renderer / card budget.
316
- events.push({
317
- type: 'tool_result',
318
- content: truncateUtf8(rawOut, TOOL_RESULT_MAX_BYTES),
319
- timestamp,
320
- });
321
- }
322
- }
323
- }
324
- // Track usage from step-finish parts. v1 没有 idle 标记,turn 边界就是
325
- // 上面那段"最后一条 user 消息";同一 turn 可能有多个 step-finish
326
- // (每次模型请求一条),flow 要全加起来,只留末条会把多步 run 低报。
327
- for (const part of msg.parts) {
328
- if (part.type === 'step-finish' && part.tokens) {
329
- // Skip zero-total steps(模型在 reasoning 中被截断)——它们没有消耗,
330
- // 也不该把本次请求当成一条真实采样。
331
- // ccusage-aligned: cache.write -> cacheCreationTokens.
332
- // contextLength = input + output + cacheRead + cacheCreation —
333
- // the unified context-window occupancy contract across all readers
334
- // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
335
- // postTokens 水位同语义).
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
- };
348
- usage = {
349
- inputTokens: input,
350
- outputTokens: output,
351
- contextLength: input + output + cacheRead + cacheWrite,
352
- cacheReadTokens: part.tokens.cache?.read,
353
- cacheCreationTokens: part.tokens.cache?.write,
354
- totalTokens: part.tokens.total,
355
- };
356
- }
357
- }
358
- }
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
- }
368
- // Cumulative (session-wide): sum input/output/total across ALL step-finish
369
- // parts in the whole session. The catch-up loop above only scans from
370
- // the last user message (= last run) for per-turn usage; cumulative
371
- // needs a full scan so multi-run sessions report the true total.
372
- let cumInput = 0;
373
- let cumOutput = 0;
374
- let cumTotal = 0;
375
- let cumCacheRead = 0;
376
- let cumCacheWrite = 0;
377
- for (const msg of data.messages) {
378
- for (const part of msg.parts) {
379
- if (part.type === 'step-finish' && part.tokens) {
380
- cumInput += part.tokens.input ?? 0;
381
- cumOutput += part.tokens.output ?? 0;
382
- cumTotal += part.tokens.total ?? 0;
383
- cumCacheRead += part.tokens.cache?.read ?? 0;
384
- cumCacheWrite += part.tokens.cache?.write ?? 0;
385
- }
386
- }
387
- }
388
- usage = withCumulativeUsage(usage, {
389
- total: cumTotal,
390
- input: cumInput,
391
- output: cumOutput,
392
- cacheRead: cumCacheRead,
393
- cacheWrite: cumCacheWrite,
394
- });
395
- // Use info.title as aiTitle
396
- if (data.info.title) {
397
- aiTitle = data.info.title;
398
- }
399
- // If no displayTitle yet, use first user text as fallback
400
- if (!displayTitle) {
401
- for (const msg of data.messages) {
402
- if (msg.info.role === 'user') {
403
- for (const part of msg.parts) {
404
- if (part.type === 'text' && part.text) {
405
- displayTitle = part.text;
406
- break;
407
- }
408
- }
409
- }
410
- if (displayTitle)
411
- break;
412
- }
413
- }
414
- // Apply maxEvents cap: keep the LAST N events (most recent), matching
415
- // CodexSessionReader's slice(-maxEvents). maxEvents limits the catch-up
416
- // tail so auto-resume cards don't load the entire session.
417
- const cappedEvents = capEvents(events, opts?.maxEvents);
418
- return {
419
- events: cappedEvents,
420
- usage,
421
- // opencode run doesn't have background tasks
422
- aiTitle,
423
- displayTitle,
424
- };
601
+ return this.readV1SessionContent(data, opts);
425
602
  }
426
603
  catch (err) {
427
604
  getLogger().warn(`[opencode-session-reader] readSessionContent build failed: ${err}`);
428
605
  return { events: [] };
429
606
  }
430
607
  }
608
+ /**
609
+ * v1 export(`{info, parts}` 信封 + step-finish 用量 part)的 catch-up tail
610
+ * 装配:扫描累积器(scanV1Messages)出 tail 位置与本 run/累计 usage,事件
611
+ * 装配(assembleV1TailEvents)出展示流,这里只做组装。
612
+ */
613
+ readV1SessionContent(data, opts) {
614
+ const scan = scanV1Messages(data.messages);
615
+ const startIndex = scan.lastUserIndex >= 0 ? scan.lastUserIndex : 0;
616
+ return {
617
+ // Apply maxEvents cap: keep the LAST N events (most recent), matching
618
+ // CodexSessionReader's slice(-maxEvents). maxEvents limits the catch-up
619
+ // tail so auto-resume cards don't load the entire session.
620
+ events: capEvents(assembleV1TailEvents(data.messages, startIndex), opts?.maxEvents),
621
+ usage: finalizeTurnUsage(scan.latest, scan.turn, scan.cum),
622
+ // opencode run doesn't have background tasks
623
+ aiTitle: data.info.title || undefined,
624
+ // tail 无 user 文本时回退全会话首条 user 文本
625
+ displayTitle: scan.tailDisplayTitle ?? firstV1UserText(data.messages),
626
+ };
627
+ }
431
628
  /**
432
629
  * v2 export: messages are flat (`type` + `text`/`content`) and assistant
433
630
  * usage lives on the message instead of a `step-finish` part. Keep this
434
631
  * mapping isolated from the v1 parser so v1 behaviour stays unchanged.
632
+ *
633
+ * turnStart 可能落在末条采样之后(新 turn 尚无采样)→ turn 全 0;latest
634
+ * 仍是上一轮末条采样,contextLength 保留水位。turnStart 小于最后一个采样
635
+ * 下标时,flow 覆盖末条值,只有 contextLength 保留末条(finalizeTurnUsage)。
435
636
  */
436
637
  readV2SessionContent(data, opts) {
437
638
  const messages = Array.isArray(data.messages) ? data.messages : [];
438
- const events = [];
439
639
  const aiTitle = typeof data.info.title === 'string' && data.info.title.length > 0
440
640
  ? data.info.title
441
641
  : undefined;
442
- let displayTitle;
443
- let lastUserIndex = -1;
444
- for (let i = 0; i < messages.length; i++) {
445
- if (messages[i]?.type === 'user')
446
- lastUserIndex = i;
447
- }
448
- const startIndex = lastUserIndex >= 0 ? lastUserIndex : 0;
449
- for (let i = startIndex; i < messages.length; i++) {
450
- const msg = messages[i];
451
- if (!msg || typeof msg.type !== 'string')
452
- continue;
453
- const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : undefined;
454
- if (msg.type === 'user') {
455
- if (typeof msg.text === 'string' && msg.text.length > 0) {
456
- if (!displayTitle)
457
- displayTitle = msg.text;
458
- events.push({ type: 'text', content: msg.text, timestamp });
459
- }
460
- continue;
461
- }
462
- if (msg.type === 'assistant') {
463
- for (const part of Array.isArray(msg.content) ? msg.content : []) {
464
- if (part?.type === 'text' && typeof part.text === 'string') {
465
- events.push({ type: 'text', content: part.text, timestamp });
466
- continue;
467
- }
468
- if (part?.type === 'reasoning' && typeof part.text === 'string') {
469
- events.push({ type: 'thinking', content: part.text, timestamp });
470
- continue;
471
- }
472
- if (part?.type === 'tool') {
473
- const toolId = part.id;
474
- const toolName = part.name ?? '';
475
- const input = parseToolInput(part.state?.input);
476
- if (toolId) {
477
- events.push({
478
- type: 'tool_use',
479
- content: JSON.stringify({ id: toolId, name: toolName, input }),
480
- timestamp,
481
- });
482
- }
483
- const output = v2ToolOutput(part.state);
484
- if (toolId && output !== undefined) {
485
- events.push({
486
- type: 'tool_result',
487
- content: truncateUtf8(output, TOOL_RESULT_MAX_BYTES),
488
- timestamp,
489
- });
490
- }
491
- }
492
- }
493
- continue;
494
- }
495
- // User-invoked shell messages are a first-class message type in v2.
496
- if (msg.type === 'shell' && typeof msg.command === 'string') {
497
- events.push({
498
- type: 'tool_use',
499
- content: JSON.stringify({
500
- id: msg.callID ?? msg.id ?? 'shell',
501
- name: 'bash',
502
- input: { command: msg.command },
503
- }),
504
- timestamp,
505
- });
506
- if (typeof msg.output === 'string') {
507
- events.push({
508
- type: 'tool_result',
509
- content: truncateUtf8(msg.output, TOOL_RESULT_MAX_BYTES),
510
- timestamp,
511
- });
512
- }
513
- continue;
514
- }
515
- // Keep the compact summary visible in the catch-up tail; other control
516
- // messages (system/synthetic/agent-switched/model-switched) are noise.
517
- if (msg.type === 'compaction' && typeof msg.summary === 'string' && msg.summary) {
518
- events.push({ type: 'text', content: msg.summary, timestamp });
519
- }
520
- }
521
- // If the last user message had no text (e.g. attachment-only), fall back to
522
- // the first human text anywhere in the session for the card title.
523
- if (!displayTitle) {
524
- for (const msg of messages) {
525
- if (msg?.type === 'user' && typeof msg.text === 'string' && msg.text.length > 0) {
526
- displayTitle = msg.text;
527
- break;
528
- }
529
- }
530
- }
531
- let latest;
532
- let cumInput = 0;
533
- let cumOutput = 0;
534
- let cumTotal = 0;
535
- let cumCacheRead = 0;
536
- let cumCacheWrite = 0;
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)
545
- continue;
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;
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;
577
- }
578
- latest = withCumulativeUsage(latest, {
579
- total: cumTotal,
580
- input: cumInput,
581
- output: cumOutput,
582
- cacheRead: cumCacheRead,
583
- cacheWrite: cumCacheWrite,
584
- });
642
+ const tail = assembleV2TailEvents(messages);
643
+ const usage = scanV2Usage(messages);
585
644
  return {
586
- events: capEvents(events, opts?.maxEvents),
587
- usage: latest,
645
+ events: capEvents(tail.events, opts?.maxEvents),
646
+ usage: finalizeTurnUsage(usage.latest, usage.turn, usage.cum),
588
647
  aiTitle,
589
- displayTitle,
648
+ displayTitle: tail.displayTitle,
590
649
  };
591
650
  }
592
651
  /**
@@ -699,9 +758,7 @@ export class OpencodeSessionReader {
699
758
  if (err.code === 'ENOENT') {
700
759
  return [];
701
760
  }
702
- getLogger().error(`[opencode-session-reader] session list failed: ${err}`);
703
- const msg = err instanceof Error ? err.message : String(err);
704
- throw new Error(`opencode session list 读取失败: ${msg}`, { cause: err });
761
+ throwSessionListError('opencode', err);
705
762
  }
706
763
  }
707
764
  /**