lark-remote 0.5.20 → 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 (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/bridge/inbound-media.js +71 -45
  3. package/dist/bridge/index.d.ts +9 -0
  4. package/dist/bridge/index.js +230 -203
  5. package/dist/card/approval-render.js +32 -33
  6. package/dist/card/card-budget.js +123 -98
  7. package/dist/card/run-renderer.js +82 -68
  8. package/dist/card/run-state.d.ts +0 -14
  9. package/dist/card/run-state.js +335 -207
  10. package/dist/card/tool-render.js +20 -16
  11. package/dist/card/usage-format.d.ts +12 -9
  12. package/dist/card/usage-format.js +111 -97
  13. package/dist/common/text-width.js +36 -24
  14. package/dist/common/tool-summary.js +132 -90
  15. package/dist/index.js +226 -190
  16. package/dist/message-handlers.js +253 -236
  17. package/dist/router/commands/ls.d.ts +3 -0
  18. package/dist/router/commands/ls.js +256 -175
  19. package/dist/router/commands/order.js +184 -159
  20. package/dist/router/commands/resume.js +189 -133
  21. package/dist/router/commands/ws.js +219 -174
  22. package/dist/router/index.js +192 -201
  23. package/dist/runner/bash/runner.js +2 -4
  24. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  25. package/dist/runner/codex/app-server/translator.js +287 -221
  26. package/dist/runner/common/acp/base-translator.js +19 -17
  27. package/dist/runner/common/jsonl-stream.js +154 -187
  28. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  29. package/dist/runner/common/jsonrpc/client.js +43 -12
  30. package/dist/runner/common/spawning-runner.d.ts +5 -0
  31. package/dist/runner/common/spawning-runner.js +8 -3
  32. package/dist/runner/common/turn-started.d.ts +8 -0
  33. package/dist/runner/common/turn-started.js +11 -0
  34. package/dist/runner/dsh/approval.js +84 -51
  35. package/dist/runner/dsh/client.d.ts +7 -9
  36. package/dist/runner/dsh/client.js +356 -287
  37. package/dist/runner/dsh/mux-stream.js +28 -8
  38. package/dist/runner/dsh/runner.d.ts +4 -3
  39. package/dist/runner/dsh/runner.js +88 -64
  40. package/dist/runner/dsh/translator.d.ts +1 -31
  41. package/dist/runner/dsh/translator.js +180 -178
  42. package/dist/runner/kimi/acp/runner.js +81 -44
  43. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  44. package/dist/runner/pi/rpc/client.js +4 -13
  45. package/dist/runner/pi/rpc/translator.js +61 -60
  46. package/dist/session/claude/sessions.js +113 -101
  47. package/dist/session/codex/rollout-reader.js +179 -180
  48. package/dist/session/dsh/sessions.js +281 -231
  49. package/dist/session/kimi/sessions.d.ts +0 -7
  50. package/dist/session/kimi/sessions.js +129 -109
  51. package/dist/session/opencode/sessions.d.ts +0 -11
  52. package/dist/session/opencode/sessions.js +161 -137
  53. package/dist/session/session-store.d.ts +8 -0
  54. package/dist/session/session-store.js +98 -98
  55. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { readJsonlLines as readJsonlLinesShared, readLastNJsonlLines } from '../
5
5
  import { cumulativeUsageFields } from '../common/usage-accumulator.js';
6
6
  import { throwSessionListError } from '../common/throw-session-list-error.js';
7
7
  import { getLogger } from '../../logger/index.js';
8
+ import { logNonEnoentDebug } from '../session-store.js';
8
9
  import { isStale } from '../common/constants.js';
9
10
  import { safeRealpath, samePath } from '../../platform/path.js';
10
11
  import { capEvents, paginate } from '../common/pagination.js';
@@ -396,39 +397,6 @@ export class KimiSessionReader {
396
397
  findSessionIndexEntry(sessionId) {
397
398
  return this.scanSessionIndex().find((e) => e.sessionId === sessionId);
398
399
  }
399
- /**
400
- * Extract the last turn.prompt text from a wire.jsonl file.
401
- * Used as a fallback summary source when state.json has no title/lastPrompt
402
- * (v2 sessions). Scans the tail of the file for efficiency.
403
- * Returns undefined if no turn.prompt is found.
404
- */
405
- extractLastPromptFromWire(wirePath) {
406
- try {
407
- if (!fs.existsSync(wirePath))
408
- return undefined;
409
- // Scan backwards from the tail — turn.prompt is typically near the end
410
- // of a completed turn (before content.part / step.end / usage.record).
411
- const lines = readLastNJsonlLines(wirePath, 50);
412
- let lastPrompt;
413
- for (const line of lines) {
414
- try {
415
- const entry = JSON.parse(line);
416
- if (isTurnPrompt(entry)) {
417
- if (entry.input && entry.input.length > 0) {
418
- lastPrompt = entry.input[0].text;
419
- }
420
- }
421
- }
422
- catch {
423
- continue;
424
- }
425
- }
426
- return lastPrompt?.substring(0, 200);
427
- }
428
- catch {
429
- return undefined;
430
- }
431
- }
432
400
  /**
433
401
  * List all sessions for a given working directory
434
402
  */
@@ -436,87 +404,16 @@ export class KimiSessionReader {
436
404
  try {
437
405
  // 归一失败(陈旧/无权限 cwd)回退原样输入,让逐条 samePath 比较去决定
438
406
  // 空态——一次 realpathSync 抛错不得把整张列表清掉(与 opencode 同口径)。
439
- const realCwd = safeRealpath(cwd, (err) => {
440
- if (err.code === 'ENOENT')
441
- return;
442
- getLogger().debug(`[kimi-session-reader] realpath ${cwd} failed, using the path as given: ${err?.message ?? String(err)}`);
443
- });
407
+ const realCwd = safeRealpath(cwd, logKimiRealpathFailure.bind(null, cwd));
444
408
  const sessions = [];
445
409
  for (const entry of this.scanSessionIndex()) {
446
- // state.json 一次读取共用(cwd 兜底 + summary),I/O 减半。
447
- // 两次 catch 语义保留:解析失败 = 无法判 cwd → 跳过(continue);
448
- // 解析成功但字段缺失 = summary 落 wire.jsonl 提取。
449
- const statePath = path.join(entry.sessionDir, 'state.json');
450
- let state = null;
451
- let stateParsed = false;
452
- if (fs.existsSync(statePath)) {
453
- try {
454
- state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
455
- stateParsed = true;
456
- }
457
- catch (err) {
458
- getLogger().debug(`[kimi-session-reader] corrupt state.json skipped session=${entry.sessionId}: ${err?.message ?? String(err)}`);
459
- }
460
- }
461
- // Index workDir is v1-only; v2 sessions may have empty/missing workDir.
462
- // When index workDir is absent, fall back to state.json's cwd field.
463
- let entryWorkDir = entry.workDir;
464
- if (!entryWorkDir) {
465
- if (stateParsed && state)
466
- entryWorkDir = extractWorkDir(state);
467
- // Unreadable state — cannot determine cwd, entryWorkDir stays undefined
468
- }
469
- // cwd 判定与守卫(checkCwdGuard 的 samePath)同口径:严格字符串比较会
470
- // 把大小写(darwin)/尾分隔符形态不同的会话从列表里滤掉,而 /resume <id>
471
- // 的守卫却判等通过——「列表没有、按 id 却能读」。
472
- if (entryWorkDir === undefined || !samePath(entryWorkDir, realCwd)) {
473
- continue;
474
- }
475
- let summary = 'New Session';
476
- if (stateParsed && state) {
477
- // v1: state.title / state.lastPrompt; v2: these fields may be absent
478
- summary = state.title || state.lastPrompt || '';
479
- }
480
- // v2 state.json omits title/lastPrompt: extract from wire.jsonl's last
481
- // turn.prompt instead. Only scan a small tail to find it — the summary
482
- // only needs the most recent user prompt.
483
- if (!summary) {
484
- const wirePath = path.join(entry.sessionDir, 'agents', 'main', 'wire.jsonl');
485
- summary = this.extractLastPromptFromWire(wirePath) || 'New Session';
486
- }
487
- // Get mtime. the session DIRECTORY mtime only changes when
488
- // entries are created/deleted/renamed inside it — kimi appending to
489
- // agents/main/wire.jsonl during a run does NOT touch it, so dir-mtime
490
- // sorting lags real activity by minutes. Use the wire.jsonl mtime
491
- // (the file kimi keeps writing while the session is active) and fall
492
- // back to the dir mtime when the wire file is missing (e.g. a session
493
- // that never produced output).
494
- const wirePath = path.join(entry.sessionDir, 'agents', 'main', 'wire.jsonl');
495
- let mtimeMs;
496
- try {
497
- mtimeMs = fs.statSync(wirePath).mtimeMs;
498
- }
499
- catch {
500
- try {
501
- mtimeMs = fs.statSync(entry.sessionDir).mtimeMs;
502
- }
503
- catch (err) {
504
- if (err.code !== 'ENOENT') {
505
- getLogger().warn(`[kimi-session-reader] cannot stat ${entry.sessionDir}: ${err?.message ?? String(err)}`);
506
- }
507
- // 会话目录已不存在(或被清理):跳过该条,别让它拖垮整张列表。
508
- continue;
509
- }
510
- }
511
- sessions.push({
512
- sessionId: entry.sessionId,
513
- summary,
514
- mtime: mtimeMs,
515
- });
410
+ const row = collectKimiSession(entry, realCwd);
411
+ if (row)
412
+ sessions.push(row);
516
413
  }
517
414
  // Sort by mtime descending; same-mtime ties use a deterministic
518
415
  // secondary key so ordering is stable across scans.
519
- sortByRecencyDesc(sessions, (s) => s.mtime, (s) => s.sessionId);
416
+ sortByRecencyDesc(sessions, kimiRowMtime, kimiRowId);
520
417
  const { items, total } = paginate(sessions, opts ?? {});
521
418
  return {
522
419
  sessions: items,
@@ -761,4 +658,127 @@ export class KimiSessionReader {
761
658
  }
762
659
  }
763
660
  }
661
+ // --- listSessions 装配原语(模块级命名函数 + 显式传参) ---
662
+ // 纯结构拆分:listSessions 只做 realpath / 遍历 / 排序分页 / 错误降级,
663
+ // 单条会话的 state 读取、cwd 守卫、summary 三级回退、mtime 回退全部下沉到
664
+ // 这些模块级函数。文案、跳过条件、排序键、分页口径逐项保持。
665
+ /** realpath 归一化失败(非 ENOENT)留痕;ENOENT 静默沿用原 cwd。 */
666
+ function logKimiRealpathFailure(cwd, err) {
667
+ logNonEnoentDebug(err, (m) => `[kimi-session-reader] realpath ${cwd} failed, using the path as given: ${m}`);
668
+ }
669
+ /** state.json 一次读取共用(cwd 兜底 + summary),I/O 减半。 */
670
+ function readKimiState(sessionDir, sessionId) {
671
+ const statePath = path.join(sessionDir, 'state.json');
672
+ if (!fs.existsSync(statePath))
673
+ return { state: null, stateParsed: false };
674
+ try {
675
+ return {
676
+ state: JSON.parse(fs.readFileSync(statePath, 'utf-8')),
677
+ stateParsed: true,
678
+ };
679
+ }
680
+ catch (err) {
681
+ // 解析失败 = 无法判 cwd → 不 continue,留给 index workDir 兜底。
682
+ getLogger().debug(`[kimi-session-reader] corrupt state.json skipped session=${sessionId}: ${err?.message ?? String(err)}`);
683
+ return { state: null, stateParsed: false };
684
+ }
685
+ }
686
+ /** index workDir 优先,缺失时回退 state.json 的 workDir/cwd。 */
687
+ function resolveKimiEntryWorkDir(entry, read) {
688
+ let entryWorkDir = entry.workDir;
689
+ if (!entryWorkDir && read.stateParsed && read.state) {
690
+ entryWorkDir = extractWorkDir(read.state);
691
+ }
692
+ return entryWorkDir;
693
+ }
694
+ /**
695
+ * 从 wire.jsonl 末段提取最后一条 turn.prompt。
696
+ * v2 state.json 省略 title/lastPrompt 时作为 summary 兜底;只扫尾部 50 行。
697
+ */
698
+ function extractLastPromptFromWire(wirePath) {
699
+ try {
700
+ if (!fs.existsSync(wirePath))
701
+ return undefined;
702
+ // Scan backwards from the tail — turn.prompt is typically near the end
703
+ // of a completed turn (before content.part / step.end / usage.record).
704
+ const lines = readLastNJsonlLines(wirePath, 50);
705
+ let lastPrompt;
706
+ for (const line of lines) {
707
+ try {
708
+ const entry = JSON.parse(line);
709
+ if (isTurnPrompt(entry)) {
710
+ if (entry.input && entry.input.length > 0) {
711
+ lastPrompt = entry.input[0].text;
712
+ }
713
+ }
714
+ }
715
+ catch {
716
+ continue;
717
+ }
718
+ }
719
+ return lastPrompt?.substring(0, 200);
720
+ }
721
+ catch {
722
+ return undefined;
723
+ }
724
+ }
725
+ /** summary:state.title / lastPrompt → wire turn.prompt → 'New Session'。 */
726
+ function kimiSessionSummary(sessionDir, read) {
727
+ let summary = 'New Session';
728
+ if (read.stateParsed && read.state) {
729
+ // v1: state.title / state.lastPrompt; v2: these fields may be absent
730
+ summary = read.state.title || read.state.lastPrompt || '';
731
+ }
732
+ if (!summary) {
733
+ const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
734
+ summary = extractLastPromptFromWire(wirePath) || 'New Session';
735
+ }
736
+ return summary;
737
+ }
738
+ /**
739
+ * 排序用 mtime:wire.jsonl 优先(run 期间持续写入),缺失回退会话目录
740
+ * mtime;目录也不可读(非 ENOENT 留痕)返回 null,由调用方跳过该条。
741
+ */
742
+ function kimiSessionMtime(sessionDir) {
743
+ const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
744
+ try {
745
+ return fs.statSync(wirePath).mtimeMs;
746
+ }
747
+ catch {
748
+ try {
749
+ return fs.statSync(sessionDir).mtimeMs;
750
+ }
751
+ catch (err) {
752
+ if (err.code !== 'ENOENT') {
753
+ getLogger().warn(`[kimi-session-reader] cannot stat ${sessionDir}: ${err?.message ?? String(err)}`);
754
+ }
755
+ // 会话目录已不存在(或被清理):跳过该条,别让它拖垮整张列表。
756
+ return null;
757
+ }
758
+ }
759
+ }
760
+ /** 单条 index entry → 列表行;cwd 不匹配 / 目录不可读时返回 null(跳过)。 */
761
+ function collectKimiSession(entry, realCwd) {
762
+ const read = readKimiState(entry.sessionDir, entry.sessionId);
763
+ const entryWorkDir = resolveKimiEntryWorkDir(entry, read);
764
+ if (entryWorkDir === undefined || !samePath(entryWorkDir, realCwd)) {
765
+ return null;
766
+ }
767
+ const summary = kimiSessionSummary(entry.sessionDir, read);
768
+ const mtimeMs = kimiSessionMtime(entry.sessionDir);
769
+ if (mtimeMs === null)
770
+ return null;
771
+ return {
772
+ sessionId: entry.sessionId,
773
+ summary,
774
+ mtime: mtimeMs,
775
+ };
776
+ }
777
+ /** 排序键(模块级:不把回调决策点算回 listSessions)。 */
778
+ function kimiRowMtime(s) {
779
+ return s.mtime;
780
+ }
781
+ function kimiRowId(s) {
782
+ return s.sessionId;
783
+ }
764
784
  //# sourceMappingURL=sessions.js.map
@@ -139,17 +139,6 @@ export interface OpencodeV1Scan {
139
139
  /** 全会话 step-finish 求和(cumulative;零 total 的退化 step 字段照加)。 */
140
140
  cum: OpencodeUsageFlow;
141
141
  }
142
- /**
143
- * v1 扫描累积器(纯函数):单遍扫过全部 export 消息,产出 catch-up tail 的
144
- * 位置(末条 user 消息)、本 run flow(tail 内非零 step-finish 求和)、末条
145
- * 非零采样(contextLength 水位)与全会话累计。
146
- *
147
- * 产生:OpencodeV1Scan。依赖:入参 messages。不改任何外部状态。
148
- *
149
- * 口径(勿改):cumulative 对**所有** step-finish 逐字段求和、不设零 total
150
- * 门槛;turn/latest 只认 tail 内非零 total 的 step(模型在 reasoning 中被
151
- * 截断的退化 step 不算采样)。
152
- */
153
142
  export declare function scanV1Messages(messages: OpencodeExportMessage[]): OpencodeV1Scan;
154
143
  /** v2 usage 扫描累积器结果(纯数据)。 */
155
144
  export interface OpencodeV2UsageScan {
@@ -169,26 +169,77 @@ function withCumulativeUsage(usage, cum) {
169
169
  function emptyFlow() {
170
170
  return { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, total: 0 };
171
171
  }
172
- /**
173
- * v1 扫描累积器(纯函数):单遍扫过全部 export 消息,产出 catch-up tail 的
174
- * 位置(末条 user 消息)、本 run flow(tail 内非零 step-finish 求和)、末条
175
- * 非零采样(contextLength 水位)与全会话累计。
176
- *
177
- * 产生:OpencodeV1Scan。依赖:入参 messages。不改任何外部状态。
178
- *
179
- * 口径(勿改):cumulative 对**所有** step-finish 逐字段求和、不设零 total
180
- * 门槛;turn/latest 只认 tail 内非零 total 的 step(模型在 reasoning 中被
181
- * 截断的退化 step 不算采样)。
182
- */
183
- export function scanV1Messages(messages) {
184
- // Find the last user message position (for catch-up tail).
185
- // messages are already parsed objects — scan directly.
172
+ /** 末条 user 消息下标(catch-up tail 起点);无 user 消息 = -1。 */
173
+ function lastV1UserIndex(messages) {
186
174
  let lastUserIndex = -1;
187
175
  for (let i = 0; i < messages.length; i++) {
188
176
  if (messages[i].info.role === 'user') {
189
177
  lastUserIndex = i;
190
178
  }
191
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);
192
243
  // Process messages from last user message onwards (catch-up tail)
193
244
  const startIndex = lastUserIndex >= 0 ? lastUserIndex : 0;
194
245
  const turn = emptyFlow();
@@ -202,52 +253,13 @@ export function scanV1Messages(messages) {
202
253
  // 末条 user 消息;同一 turn 可能有多个 step-finish(每次模型请求一条),
203
254
  // flow 要全加起来,只留末条会把多步 run 低报。
204
255
  for (const part of msg.parts) {
205
- if (part.type === 'step-finish' && part.tokens) {
206
- // cumulative:全会话求和,含零 total 的退化 step(字段为 0 时求和不
207
- // 变,与拆分前行为一致)。
208
- cum.input += part.tokens.input ?? 0;
209
- cum.output += part.tokens.output ?? 0;
210
- cum.total += part.tokens.total ?? 0;
211
- cum.cacheRead += part.tokens.cache?.read ?? 0;
212
- cum.cacheCreation += part.tokens.cache?.write ?? 0;
213
- if (!inTail)
214
- continue;
215
- // Skip zero-total steps(模型在 reasoning 中被截断)——它们没有消耗,
216
- // 也不该把本次请求当成一条真实采样。
217
- // ccusage-aligned: cache.write -> cacheCreationTokens.
218
- // contextLength = input + output + cacheRead + cacheCreation —
219
- // the unified context-window occupancy contract across all readers
220
- // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
221
- // postTokens 水位同语义).
222
- if ((part.tokens.total ?? 0) === 0)
223
- continue;
224
- const input = part.tokens.input ?? 0;
225
- const output = part.tokens.output ?? 0;
226
- const cacheRead = part.tokens.cache?.read ?? 0;
227
- const cacheWrite = part.tokens.cache?.write ?? 0;
228
- turn.input += input;
229
- turn.output += output;
230
- turn.cacheRead += cacheRead;
231
- turn.cacheCreation += cacheWrite;
232
- turn.total += part.tokens.total;
233
- latest = {
234
- inputTokens: input,
235
- outputTokens: output,
236
- contextLength: input + output + cacheRead + cacheWrite,
237
- cacheReadTokens: part.tokens.cache?.read,
238
- cacheCreationTokens: part.tokens.cache?.write,
239
- totalTokens: part.tokens.total,
240
- };
241
- }
256
+ const stepLatest = accumulateV1StepPart(part, inTail, turn, cum);
257
+ if (stepLatest)
258
+ latest = stepLatest;
242
259
  }
243
260
  // First user text after last user message is displayTitle
244
261
  if (inTail && !tailDisplayTitle && msg.info.role === 'user') {
245
- for (const part of msg.parts) {
246
- if (part.type === 'text' && part.text) {
247
- tailDisplayTitle = part.text;
248
- break;
249
- }
250
- }
262
+ tailDisplayTitle = firstV1TextPart(msg.parts);
251
263
  }
252
264
  }
253
265
  return { lastUserIndex, tailDisplayTitle, latest, turn, cum };
@@ -387,14 +399,95 @@ export function scanV2Usage(messages) {
387
399
  }
388
400
  return { latest, turn, cum };
389
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
+ }
390
470
  /**
391
- * v2 catch-up tail 装配(纯函数):末条 user 消息起的消息映射为展示事件
392
- * (user / assistant / shell / compaction);displayTitle 取 tail 内首条
393
- * user 文本,tail 无 user 文本(如附件-only)时回退全会话首条。
471
+ * 消息类型 → 命名 handler。用 Map 而非对象字面量 / 长串 if:
472
+ * `constructor` / `toString` 这类外来 type 会命中 Object.prototype。
394
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
+ }
395
489
  function assembleV2TailEvents(messages) {
396
- const events = [];
397
- let displayTitle;
490
+ const state = { events: [] };
398
491
  let lastUserIndex = -1;
399
492
  for (let i = 0; i < messages.length; i++) {
400
493
  if (messages[i]?.type === 'user')
@@ -406,84 +499,15 @@ function assembleV2TailEvents(messages) {
406
499
  if (!msg || typeof msg.type !== 'string')
407
500
  continue;
408
501
  const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : undefined;
409
- if (msg.type === 'user') {
410
- if (typeof msg.text === 'string' && msg.text.length > 0) {
411
- if (!displayTitle)
412
- displayTitle = msg.text;
413
- events.push({ type: 'text', content: msg.text, timestamp });
414
- }
415
- continue;
416
- }
417
- if (msg.type === 'assistant') {
418
- for (const part of Array.isArray(msg.content) ? msg.content : []) {
419
- if (part?.type === 'text' && typeof part.text === 'string') {
420
- events.push({ type: 'text', content: part.text, timestamp });
421
- continue;
422
- }
423
- if (part?.type === 'reasoning' && typeof part.text === 'string') {
424
- events.push({ type: 'thinking', content: part.text, timestamp });
425
- continue;
426
- }
427
- if (part?.type === 'tool') {
428
- const toolId = part.id;
429
- const toolName = part.name ?? '';
430
- const input = parseToolInput(part.state?.input);
431
- if (toolId) {
432
- events.push({
433
- type: 'tool_use',
434
- content: JSON.stringify({ id: toolId, name: toolName, input }),
435
- timestamp,
436
- });
437
- }
438
- const output = v2ToolOutput(part.state);
439
- if (toolId && output !== undefined) {
440
- events.push({
441
- type: 'tool_result',
442
- content: truncateUtf8(output, TOOL_RESULT_MAX_BYTES),
443
- timestamp,
444
- });
445
- }
446
- }
447
- }
448
- continue;
449
- }
450
- // User-invoked shell messages are a first-class message type in v2.
451
- if (msg.type === 'shell' && typeof msg.command === 'string') {
452
- events.push({
453
- type: 'tool_use',
454
- content: JSON.stringify({
455
- id: msg.callID ?? msg.id ?? 'shell',
456
- name: 'bash',
457
- input: { command: msg.command },
458
- }),
459
- timestamp,
460
- });
461
- if (typeof msg.output === 'string') {
462
- events.push({
463
- type: 'tool_result',
464
- content: truncateUtf8(msg.output, TOOL_RESULT_MAX_BYTES),
465
- timestamp,
466
- });
467
- }
468
- continue;
469
- }
470
- // Keep the compact summary visible in the catch-up tail; other control
471
- // messages (system/synthetic/agent-switched/model-switched) are noise.
472
- if (msg.type === 'compaction' && typeof msg.summary === 'string' && msg.summary) {
473
- events.push({ type: 'text', content: msg.summary, timestamp });
474
- }
502
+ const handler = V2_TAIL_HANDLERS.get(msg.type);
503
+ if (handler)
504
+ handler(msg, timestamp, state);
475
505
  }
476
506
  // If the last user message had no text (e.g. attachment-only), fall back to
477
507
  // the first human text anywhere in the session for the card title.
478
- if (!displayTitle) {
479
- for (const msg of messages) {
480
- if (msg?.type === 'user' && typeof msg.text === 'string' && msg.text.length > 0) {
481
- displayTitle = msg.text;
482
- break;
483
- }
484
- }
485
- }
486
- return { events, displayTitle };
508
+ if (!state.displayTitle)
509
+ state.displayTitle = firstV2UserText(messages);
510
+ return { events: state.events, displayTitle: state.displayTitle };
487
511
  }
488
512
  export class OpencodeSessionReader {
489
513
  binary;
@@ -1,4 +1,10 @@
1
1
  import type { AgentKind } from '../agent-kinds.js';
2
+ /**
3
+ * session reader 共用的失败留痕(claude stat / kimi realpath):ENOENT 是两次
4
+ * 查询之间的正常竞态,静默;其余 errno 必须 debug 留痕,否则「文件不可读」
5
+ * 会被误判成「会话不活跃」。文案由调用方按场景给出,逐字保持。
6
+ */
7
+ export declare function logNonEnoentDebug(err: unknown, message: (errText: string) => string): void;
2
8
  interface SessionEntry {
3
9
  /** Session IDs per agent kind (Map for in-memory, plain object for persistence). */
4
10
  sessions: Map<AgentKind, string>;
@@ -73,6 +79,8 @@ export declare class SessionStore {
73
79
  getArrivalSessionId(userId: string, agent: AgentKind): string | undefined;
74
80
  /** Set the arrival baseline for a specific agent ('' explicitly clears the arrival baseline) */
75
81
  setArrivalSessionId(userId: string, agent: AgentKind, sessionId: string): void;
82
+ /** previous/arrival 两类 setter 共用的单一实现(合并完全重复块)。 */
83
+ private writeSessionId;
76
84
  /** Set cwd for a user, clearing all sessionIds (§9.1) */
77
85
  setCwd(userId: string, cwd: string): void;
78
86
  /** Get cwd for a user, or undefined if no session */