lark-remote 0.1.6 → 0.1.7

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 (188) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.en.md +9 -1
  3. package/README.md +14 -2
  4. package/dist/bridge/approval-coordinator.d.ts +57 -34
  5. package/dist/bridge/approval-coordinator.js +191 -40
  6. package/dist/bridge/inbound-media.d.ts +62 -0
  7. package/dist/bridge/inbound-media.js +282 -0
  8. package/dist/bridge/index.d.ts +78 -7
  9. package/dist/bridge/index.js +202 -79
  10. package/dist/bridge/queue-manager.d.ts +2 -1
  11. package/dist/bridge/queue-manager.js +1 -0
  12. package/dist/card/approval-render.js +152 -36
  13. package/dist/card/bash-card-session.d.ts +2 -24
  14. package/dist/card/bash-card-session.js +6 -73
  15. package/dist/card/bash-renderer.js +2 -3
  16. package/dist/card/card-budget.js +29 -75
  17. package/dist/card/card-session.d.ts +48 -0
  18. package/dist/card/card-session.js +86 -0
  19. package/dist/card/card-shared.d.ts +1 -12
  20. package/dist/card/card-shared.js +1 -26
  21. package/dist/card/run-card-session.d.ts +2 -55
  22. package/dist/card/run-card-session.js +6 -104
  23. package/dist/card/run-renderer.js +99 -232
  24. package/dist/card/run-state.d.ts +13 -0
  25. package/dist/card/run-state.js +25 -16
  26. package/dist/card/text-truncate.d.ts +13 -12
  27. package/dist/card/text-truncate.js +13 -63
  28. package/dist/common/fs.d.ts +8 -0
  29. package/dist/common/fs.js +17 -0
  30. package/dist/common/guards.d.ts +0 -20
  31. package/dist/common/guards.js +0 -43
  32. package/dist/common/sleep.d.ts +2 -0
  33. package/dist/common/sleep.js +5 -0
  34. package/dist/common/truncate.d.ts +39 -0
  35. package/dist/common/truncate.js +92 -0
  36. package/dist/config/codex-config.d.ts +1 -1
  37. package/dist/config/codex-config.js +1 -1
  38. package/dist/config/index.d.ts +88 -23
  39. package/dist/config/index.js +105 -34
  40. package/dist/connector/index.d.ts +69 -2
  41. package/dist/connector/index.js +107 -1
  42. package/dist/index.js +123 -105
  43. package/dist/instance-lock.js +4 -12
  44. package/dist/order/alias-resolve.d.ts +13 -0
  45. package/dist/order/alias-resolve.js +22 -0
  46. package/dist/order/alias-store.d.ts +38 -0
  47. package/dist/order/alias-store.js +106 -0
  48. package/dist/persistence/atomic-write.d.ts +8 -2
  49. package/dist/persistence/atomic-write.js +23 -7
  50. package/dist/restart.js +2 -1
  51. package/dist/router/card-helpers.d.ts +63 -0
  52. package/dist/router/card-helpers.js +146 -0
  53. package/dist/router/config/claude.js +25 -1
  54. package/dist/router/config/codex.js +29 -50
  55. package/dist/router/config/common/model-patch.d.ts +22 -0
  56. package/dist/router/config/common/model-patch.js +26 -0
  57. package/dist/router/config/common/render.js +5 -4
  58. package/dist/router/config/index.d.ts +12 -0
  59. package/dist/router/config/index.js +25 -1
  60. package/dist/router/config/kimi.d.ts +4 -1
  61. package/dist/router/config/kimi.js +27 -0
  62. package/dist/router/config/opencode.js +18 -5
  63. package/dist/router/config/pi.js +4 -5
  64. package/dist/router/config/types.d.ts +11 -2
  65. package/dist/router/index.d.ts +56 -21
  66. package/dist/router/index.js +373 -548
  67. package/dist/router/utils.d.ts +2 -0
  68. package/dist/router/utils.js +4 -0
  69. package/dist/runner/agent-choices-common.d.ts +16 -0
  70. package/dist/runner/agent-choices-common.js +31 -0
  71. package/dist/runner/claude/runner.d.ts +47 -10
  72. package/dist/runner/claude/runner.js +117 -46
  73. package/dist/runner/claude/session.d.ts +166 -0
  74. package/dist/runner/claude/session.js +749 -0
  75. package/dist/runner/codex/app-server/protocol-types.d.ts +31 -39
  76. package/dist/runner/codex/app-server/protocol-types.js +1 -12
  77. package/dist/runner/codex/app-server/runner.d.ts +42 -59
  78. package/dist/runner/codex/app-server/runner.js +160 -337
  79. package/dist/runner/codex/app-server/translator.d.ts +1 -21
  80. package/dist/runner/codex/app-server/translator.js +0 -18
  81. package/dist/runner/codex/index.d.ts +0 -2
  82. package/dist/runner/codex/index.js +0 -2
  83. package/dist/runner/common/acp/protocol-helpers.d.ts +14 -0
  84. package/dist/runner/common/acp/protocol-helpers.js +21 -0
  85. package/dist/runner/common/acp/protocol-types.d.ts +238 -0
  86. package/dist/runner/common/acp/protocol-types.js +75 -0
  87. package/dist/runner/common/connection-based-runner.d.ts +111 -0
  88. package/dist/runner/common/connection-based-runner.js +261 -0
  89. package/dist/runner/{codex/app-server → common/jsonrpc}/client.d.ts +14 -7
  90. package/dist/runner/{codex/app-server → common/jsonrpc}/client.js +27 -18
  91. package/dist/runner/common/jsonrpc/connection-manager.d.ts +125 -0
  92. package/dist/runner/{codex/app-server → common/jsonrpc}/connection-manager.js +63 -28
  93. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.d.ts +5 -4
  94. package/dist/runner/{codex/app-server → common/jsonrpc}/transport.js +18 -19
  95. package/dist/runner/common/jsonrpc/types.d.ts +32 -0
  96. package/dist/runner/common/jsonrpc/types.js +9 -0
  97. package/dist/runner/common/process-stopper.d.ts +2 -1
  98. package/dist/runner/common/process-stopper.js +1 -0
  99. package/dist/runner/common/runner-utils.d.ts +6 -13
  100. package/dist/runner/common/runner-utils.js +6 -47
  101. package/dist/runner/common/spawning-runner.d.ts +24 -70
  102. package/dist/runner/common/spawning-runner.js +98 -152
  103. package/dist/runner/index.d.ts +4 -4
  104. package/dist/runner/index.js +4 -4
  105. package/dist/runner/kimi/acp/runner.d.ts +106 -0
  106. package/dist/runner/kimi/acp/runner.js +448 -0
  107. package/dist/runner/kimi/acp/translator.d.ts +124 -0
  108. package/dist/runner/kimi/acp/translator.js +336 -0
  109. package/dist/runner/kimi/index.d.ts +1 -1
  110. package/dist/runner/kimi/index.js +1 -1
  111. package/dist/runner/opencode/acp/index.d.ts +1 -0
  112. package/dist/runner/opencode/acp/index.js +2 -0
  113. package/dist/runner/opencode/acp/runner.d.ts +121 -0
  114. package/dist/runner/opencode/acp/runner.js +429 -0
  115. package/dist/runner/opencode/acp/translator.d.ts +119 -0
  116. package/dist/runner/opencode/acp/translator.js +345 -0
  117. package/dist/runner/opencode/index.d.ts +1 -1
  118. package/dist/runner/opencode/index.js +1 -1
  119. package/dist/runner/pi/index.d.ts +1 -1
  120. package/dist/runner/pi/index.js +1 -1
  121. package/dist/runner/pi/rpc/client.d.ts +52 -0
  122. package/dist/runner/pi/rpc/client.js +114 -0
  123. package/dist/runner/pi/rpc/index.d.ts +4 -0
  124. package/dist/runner/pi/rpc/index.js +4 -0
  125. package/dist/runner/pi/rpc/protocol-types.d.ts +127 -0
  126. package/dist/runner/pi/rpc/protocol-types.js +21 -0
  127. package/dist/runner/pi/rpc/runner.d.ts +80 -0
  128. package/dist/runner/pi/rpc/runner.js +199 -0
  129. package/dist/runner/pi/rpc/translator.d.ts +55 -0
  130. package/dist/runner/pi/rpc/translator.js +258 -0
  131. package/dist/runner/registry.d.ts +7 -8
  132. package/dist/runner/registry.js +3 -4
  133. package/dist/runner/resolve-agent-choices.js +18 -59
  134. package/dist/runner/sync-agent-choices.js +16 -67
  135. package/dist/runner/types.d.ts +43 -13
  136. package/dist/session/claude/session-index.js +2 -3
  137. package/dist/session/claude/session-reader.js +3 -6
  138. package/dist/session/claude/sessions.js +6 -13
  139. package/dist/session/codex/rollout-reader.d.ts +2 -2
  140. package/dist/session/codex/rollout-reader.js +14 -17
  141. package/dist/session/codex/sessions.d.ts +1 -1
  142. package/dist/session/codex/sessions.js +2 -2
  143. package/dist/session/common/content-blocks.d.ts +7 -0
  144. package/dist/session/common/content-blocks.js +13 -9
  145. package/dist/session/common/jsonl.d.ts +8 -25
  146. package/dist/session/common/jsonl.js +126 -261
  147. package/dist/session/common/pagination.d.ts +28 -0
  148. package/dist/session/common/pagination.js +33 -0
  149. package/dist/session/kimi/sessions.d.ts +34 -1
  150. package/dist/session/kimi/sessions.js +208 -98
  151. package/dist/session/opencode/sessions.d.ts +17 -3
  152. package/dist/session/opencode/sessions.js +29 -29
  153. package/dist/session/pi/sessions.d.ts +5 -1
  154. package/dist/session/pi/sessions.js +12 -16
  155. package/dist/session/session-store.d.ts +11 -1
  156. package/dist/session/session-store.js +75 -178
  157. package/dist/update/index.d.ts +2 -2
  158. package/dist/update/index.js +2 -2
  159. package/dist/update/install.d.ts +20 -2
  160. package/dist/update/install.js +51 -7
  161. package/package.json +1 -1
  162. package/dist/runner/codex/app-server/connection-manager.d.ts +0 -56
  163. package/dist/runner/codex/argv.d.ts +0 -33
  164. package/dist/runner/codex/argv.js +0 -40
  165. package/dist/runner/codex/factory.d.ts +0 -29
  166. package/dist/runner/codex/factory.js +0 -39
  167. package/dist/runner/codex/jsonl.d.ts +0 -37
  168. package/dist/runner/codex/jsonl.js +0 -221
  169. package/dist/runner/codex/runner.d.ts +0 -66
  170. package/dist/runner/codex/runner.js +0 -129
  171. package/dist/runner/common/exec-translator.d.ts +0 -73
  172. package/dist/runner/common/exec-translator.js +0 -105
  173. package/dist/runner/kimi/jsonl.d.ts +0 -28
  174. package/dist/runner/kimi/jsonl.js +0 -143
  175. package/dist/runner/kimi/runner.d.ts +0 -49
  176. package/dist/runner/kimi/runner.js +0 -105
  177. package/dist/runner/opencode/argv.d.ts +0 -32
  178. package/dist/runner/opencode/argv.js +0 -35
  179. package/dist/runner/opencode/jsonl.d.ts +0 -68
  180. package/dist/runner/opencode/jsonl.js +0 -295
  181. package/dist/runner/opencode/runner.d.ts +0 -61
  182. package/dist/runner/opencode/runner.js +0 -93
  183. package/dist/runner/pi/jsonl.d.ts +0 -128
  184. package/dist/runner/pi/jsonl.js +0 -249
  185. package/dist/runner/pi/runner.d.ts +0 -36
  186. package/dist/runner/pi/runner.js +0 -76
  187. package/dist/session/common/catch-up.d.ts +0 -12
  188. package/dist/session/common/catch-up.js +0 -31
@@ -5,11 +5,26 @@ import { readJsonlLines as readJsonlLinesShared, readLastNJsonlLines } from '../
5
5
  import { UsageAccumulator } from '../common/usage-accumulator.js';
6
6
  import { getLogger } from '../../logger/index.js';
7
7
  import { STALE_MS } from '../common/constants.js';
8
- import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../card/text-truncate.js';
8
+ import { capEvents, paginate } from '../common/pagination.js';
9
+ import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
10
+ import { truncateToolInput } from '../common/content-blocks.js';
9
11
  /** Default Kimi config directory */
10
12
  function defaultKimiDir() {
11
13
  return path.join(os.homedir(), '.kimi-code');
12
14
  }
15
+ /** union 含 catch-all 成员,普通 `===` 判窄不生效,用显式守卫收窄(替代 as unknown as)。 */
16
+ function isAppendLoopEvent(entry) {
17
+ return entry.type === 'context.append_loop_event';
18
+ }
19
+ function isUsageRecord(entry) {
20
+ return entry.type === 'usage.record';
21
+ }
22
+ function isTurnPrompt(entry) {
23
+ return entry.type === 'turn.prompt';
24
+ }
25
+ function isCompactionComplete(entry) {
26
+ return entry.type === 'full_compaction.complete' || entry.type === 'context.apply_compaction';
27
+ }
13
28
  /**
14
29
  * Max lines scanned backwards when looking for the last loop event.
15
30
  * Bounds the tail scan so huge wire files are not fully parsed.
@@ -28,11 +43,9 @@ function lastLoopEventIsStepEnd(lines) {
28
43
  for (let i = lines.length - 1, scanned = 0; i >= 0 && scanned < LOOP_EVENT_SCAN_LIMIT; i--, scanned++) {
29
44
  try {
30
45
  const entry = JSON.parse(lines[i]);
31
- if (entry.type !== 'context.append_loop_event') {
46
+ if (!isAppendLoopEvent(entry))
32
47
  continue;
33
- }
34
- const loopEvent = entry;
35
- return loopEvent.event.type === 'step.end';
48
+ return entry.event.type === 'step.end';
36
49
  }
37
50
  catch {
38
51
  // Skip malformed lines and keep scanning backwards
@@ -174,10 +187,9 @@ export class KimiSessionReader {
174
187
  for (const line of lines) {
175
188
  try {
176
189
  const entry = JSON.parse(line);
177
- if (entry.type === 'turn.prompt') {
178
- const prompt = entry;
179
- if (prompt.input && prompt.input.length > 0) {
180
- lastPrompt = prompt.input[0].text;
190
+ if (isTurnPrompt(entry)) {
191
+ if (entry.input && entry.input.length > 0) {
192
+ lastPrompt = entry.input[0].text;
181
193
  }
182
194
  }
183
195
  }
@@ -253,20 +265,16 @@ export class KimiSessionReader {
253
265
  }
254
266
  sessions.push({
255
267
  sessionId: entry.sessionId,
256
- summary: summary.substring(0, 200), // Truncate long titles
268
+ summary,
257
269
  mtime: mtimeMs,
258
270
  });
259
271
  }
260
272
  // Sort by mtime descending; same-mtime ties use a deterministic
261
273
  // secondary key so ordering is stable across scans.
262
274
  sessions.sort((a, b) => b.mtime - a.mtime || a.sessionId.localeCompare(b.sessionId));
263
- const total = sessions.length;
264
- const offset = Math.max(0, opts?.offset ?? 0);
265
- // Default limit 20, aligned with the other agent session readers
266
- // (claude/codex/opencode/pi); no opts -> return up to 20 sessions.
267
- const limit = opts?.limit ?? 20;
275
+ const { items, total } = paginate(sessions, opts ?? {});
268
276
  return {
269
- sessions: sessions.slice(offset, offset + limit),
277
+ sessions: items,
270
278
  total,
271
279
  };
272
280
  }
@@ -285,64 +293,8 @@ export class KimiSessionReader {
285
293
  * Read session content (events + usage)
286
294
  */
287
295
  readSessionContent(sessionId, cwd, opts) {
288
- // Find session directory from index (also retrieve index entry for
289
- // fallback cwd guard when state.json is missing or unparseable)
290
- const indexEntry = this.findSessionIndexEntry(sessionId);
291
- const sessionDir = indexEntry?.sessionDir ?? null;
292
- if (!sessionDir) {
293
- return { events: [] };
294
- }
295
- const statePath = path.join(sessionDir, 'state.json');
296
- let realCwd;
297
- try {
298
- realCwd = fs.realpathSync(cwd);
299
- }
300
- catch {
301
- getLogger().warn(`[kimi-session-reader] fs.realpathSync failed for cwd=${cwd} (session ${sessionId}), returning empty`);
302
- return { events: [] };
303
- }
304
- let cwdGuardPassed = false;
305
- try {
306
- if (fs.existsSync(statePath)) {
307
- const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
308
- const guardResult = checkCwdGuard(state, realCwd);
309
- if (guardResult === 'failed') {
310
- return { events: [] };
311
- }
312
- if (guardResult === 'unverifiable') {
313
- getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, falling back to index workDir`);
314
- // Fall through to index-based guard below
315
- }
316
- else {
317
- // verified — state.json cwd matches
318
- cwdGuardPassed = true;
319
- }
320
- }
321
- }
322
- catch {
323
- // state.json unparseable — fall through to index-based guard
324
- getLogger().warn(`[kimi-session-reader] state.json parse failed for session ${sessionId}, falling back to index workDir`);
325
- }
326
- // Fallback: when state.json is missing, unparseable, or has no workDir/cwd,
327
- // use the workDir from the session index entry as a secondary guard.
328
- // This prevents /resume <id> from accessing a session belonging to a
329
- // different workspace when state.json is unavailable.
330
- if (!cwdGuardPassed) {
331
- const indexWorkDir = indexEntry?.workDir;
332
- if (indexWorkDir && indexWorkDir !== realCwd) {
333
- return { events: [] };
334
- }
335
- // v2 sessions have empty index workDir (v1-only field) and may lack
336
- // state.json cwd. When no cwd source can verify the session belongs to
337
- // the requested workspace, fail-closed to prevent cross-workspace access
338
- // (aligned with claude's fail-closed cwd guard).
339
- if (!indexWorkDir) {
340
- getLogger().warn(`[kimi-session-reader] no cwd source (state.json + index) for session ${sessionId}, rejecting (fail-closed)`);
341
- return { events: [] };
342
- }
343
- }
344
- const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
345
- if (!fs.existsSync(wirePath)) {
296
+ const wirePath = this.resolveSessionWirePath(sessionId, cwd);
297
+ if (wirePath === null) {
346
298
  return { events: [] };
347
299
  }
348
300
  try {
@@ -355,6 +307,9 @@ export class KimiSessionReader {
355
307
  // (subagent context is an independent window). Tracked separately
356
308
  // from the accumulator's `last` (which could be a subagent record).
357
309
  let mainContextLength = 0;
310
+ // §6.3: Compaction stats — count compact events, track last pre-compact context.
311
+ let compactCount = 0;
312
+ let compactPreContextLength;
358
313
  // Distinguish "no usage.record in any wire file" (no data → usage:
359
314
  // undefined, downstream omits the token block) from "records exist but
360
315
  // are all zero / malformed" (real data → aggregate with ?? 0 and return
@@ -393,9 +348,22 @@ export class KimiSessionReader {
393
348
  try {
394
349
  const entry = JSON.parse(line);
395
350
  // Extract usage from usage.record.
396
- if (entry.type === 'usage.record') {
397
- const usageEntry = entry;
398
- accumulateUsage(usageEntry.usage, true);
351
+ if (isUsageRecord(entry)) {
352
+ accumulateUsage(entry.usage, true);
353
+ continue;
354
+ }
355
+ // §6.3: Count compaction events and track pre-compact context length.
356
+ // 展示计数只计 context.apply_compaction:一次手动 /compact 会同时写
357
+ // full_compaction.complete + context.apply_compaction 两条(2026-08-16
358
+ // 实弹 wire.jsonl),双计会把一次压缩虚报成 2 次。full_compaction.complete
359
+ // 仅作 R2 runCompact 轮询的完成信号(readCompactionRecords 仍返回两种)。
360
+ if (isCompactionComplete(entry) && entry.type === 'context.apply_compaction') {
361
+ compactCount++;
362
+ // Capture context length before this compaction as the pre-compact
363
+ // watermark (the most recent one is most relevant for display).
364
+ if (entry.tokensBefore !== undefined) {
365
+ compactPreContextLength = entry.tokensBefore;
366
+ }
399
367
  continue;
400
368
  }
401
369
  // P2-7: displayTitle + display events now scan ALL lines (not a
@@ -407,17 +375,15 @@ export class KimiSessionReader {
407
375
  // last turn.prompt across the whole session (the most recent user
408
376
  // prompt), which is the correct catch-up title.
409
377
  // Extract display title from turn.prompt
410
- if (entry.type === 'turn.prompt') {
411
- const prompt = entry;
412
- if (prompt.input && prompt.input.length > 0) {
413
- displayTitle = prompt.input[0].text.substring(0, 100);
378
+ if (isTurnPrompt(entry)) {
379
+ if (entry.input && entry.input.length > 0) {
380
+ displayTitle = entry.input[0].text;
414
381
  }
415
382
  continue;
416
383
  }
417
384
  // Convert loop events to content events
418
- if (entry.type === 'context.append_loop_event') {
419
- const loopEvent = entry;
420
- const evt = loopEvent.event;
385
+ if (isAppendLoopEvent(entry)) {
386
+ const evt = entry.event;
421
387
  if (evt.type === 'content.part' && evt.part) {
422
388
  if (evt.part.type === 'think') {
423
389
  // Skip thinking blocks for display
@@ -431,13 +397,10 @@ export class KimiSessionReader {
431
397
  }
432
398
  }
433
399
  else if (evt.type === 'tool.call' && evt.name) {
434
- // 对齐 claude content-blocks 的 200 字符截断:大 args(整段
435
- // 文件内容写入、长 prompt)原文进事件会把 resume 卡顶到
436
- // 28KB 预算之外(2026-08-08 extreme_fallback 故障的推手之一)。
437
- let inputStr = JSON.stringify(evt.args ?? {});
438
- if (inputStr.length > 200) {
439
- inputStr = inputStr.replace(/\n/g, ' ').slice(0, 197) + '...';
440
- }
400
+ // 复用 content-blocks 的 200 字符截断:大 args(整段文件内容
401
+ // 写入、长 prompt)原文进事件会把 resume 卡顶到 28KB 预算之外
402
+ // (2026-08-08 extreme_fallback 故障的推手之一)。
403
+ const inputStr = truncateToolInput(JSON.stringify(evt.args ?? {}));
441
404
  events.push({
442
405
  type: 'tool_use',
443
406
  content: `${evt.name}(${inputStr})`,
@@ -466,7 +429,9 @@ export class KimiSessionReader {
466
429
  // contextLength stays main-only (subagent context is an independent
467
430
  // window). Display events/title also stay main-only. Any unreadable
468
431
  // directory or missing/corrupt wire file is skipped silently.
469
- const agentsDir = path.join(sessionDir, 'agents');
432
+ // wirePath = <sessionDir>/agents/main/wire.jsonl → agents dir is two
433
+ // levels up (siblings of main hold subagent wires).
434
+ const agentsDir = path.dirname(path.dirname(wirePath));
470
435
  try {
471
436
  for (const dirent of fs.readdirSync(agentsDir, { withFileTypes: true })) {
472
437
  if (!dirent.isDirectory() || dirent.name === 'main') {
@@ -479,9 +444,8 @@ export class KimiSessionReader {
479
444
  for (const subLine of readJsonlLines(subWirePath)) {
480
445
  try {
481
446
  const subEntry = JSON.parse(subLine);
482
- if (subEntry.type === 'usage.record') {
483
- const usageEntry = subEntry;
484
- accumulateUsage(usageEntry.usage, false);
447
+ if (isUsageRecord(subEntry)) {
448
+ accumulateUsage(subEntry.usage, false);
485
449
  }
486
450
  }
487
451
  catch {
@@ -510,9 +474,12 @@ export class KimiSessionReader {
510
474
  cumulativeOutputTokens: t.output,
511
475
  cumulativeCacheReadTokens: t.cacheRead > 0 ? t.cacheRead : 0,
512
476
  cumulativeCacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : 0,
477
+ // §6.3: compaction stats from wire.jsonl
478
+ compactCount,
479
+ ...(compactPreContextLength !== undefined ? { compactPreContextLength } : {}),
513
480
  };
514
481
  return {
515
- events: maxEvents <= 0 ? [] : events.slice(-maxEvents),
482
+ events: capEvents(events, maxEvents),
516
483
  usage: hasUsageRecord ? totalUsage : undefined,
517
484
  displayTitle,
518
485
  };
@@ -522,14 +489,157 @@ export class KimiSessionReader {
522
489
  return { events: [] };
523
490
  }
524
491
  }
492
+ /**
493
+ * Read the compaction records (context.apply_compaction /
494
+ * full_compaction.complete) from a session's wire.jsonl.
495
+ *
496
+ * Same cwd guard as readSessionContent. Used by KimiAcpRunner.runCompact
497
+ * (R2) to wait for the background compaction to land before ending the run.
498
+ */
499
+ readCompactionRecords(sessionId, cwd) {
500
+ const wirePath = this.resolveSessionWirePath(sessionId, cwd);
501
+ if (wirePath === null) {
502
+ return [];
503
+ }
504
+ const records = [];
505
+ try {
506
+ for (const line of readJsonlLines(wirePath)) {
507
+ try {
508
+ const entry = JSON.parse(line);
509
+ if (isCompactionComplete(entry)) {
510
+ records.push({
511
+ type: entry.type,
512
+ ...(entry.compactedCount !== undefined
513
+ ? { compactedCount: entry.compactedCount }
514
+ : {}),
515
+ ...(entry.tokensBefore !== undefined ? { tokensBefore: entry.tokensBefore } : {}),
516
+ ...(entry.tokensAfter !== undefined ? { tokensAfter: entry.tokensAfter } : {}),
517
+ time: entry.time,
518
+ });
519
+ }
520
+ }
521
+ catch {
522
+ continue;
523
+ }
524
+ }
525
+ }
526
+ catch (error) {
527
+ getLogger().error(`[kimi-session-reader] readCompactionRecords error: ${error}`);
528
+ return [];
529
+ }
530
+ return records;
531
+ }
532
+ /**
533
+ * Resolve the main wire.jsonl path for a session after passing the cwd
534
+ * guard (state.json + session index fallback). Returns null when the
535
+ * session is unknown, fails the guard, or has no wire file.
536
+ */
537
+ resolveSessionWirePath(sessionId, cwd) {
538
+ // Find session directory from index (also retrieve index entry for
539
+ // fallback cwd guard when state.json is missing or unparseable)
540
+ const indexEntry = this.findSessionIndexEntry(sessionId);
541
+ const sessionDir = indexEntry?.sessionDir ?? null;
542
+ if (!sessionDir) {
543
+ return null;
544
+ }
545
+ const statePath = path.join(sessionDir, 'state.json');
546
+ let realCwd;
547
+ try {
548
+ realCwd = fs.realpathSync(cwd);
549
+ }
550
+ catch {
551
+ getLogger().warn(`[kimi-session-reader] fs.realpathSync failed for cwd=${cwd} (session ${sessionId}), returning empty`);
552
+ return null;
553
+ }
554
+ let cwdGuardPassed = false;
555
+ try {
556
+ if (fs.existsSync(statePath)) {
557
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
558
+ const guardResult = checkCwdGuard(state, realCwd);
559
+ if (guardResult === 'failed') {
560
+ return null;
561
+ }
562
+ if (guardResult === 'unverifiable') {
563
+ getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, falling back to index workDir`);
564
+ // Fall through to index-based guard below
565
+ }
566
+ else {
567
+ // verified — state.json cwd matches
568
+ cwdGuardPassed = true;
569
+ }
570
+ }
571
+ }
572
+ catch {
573
+ // state.json unparseable — fall through to index-based guard
574
+ getLogger().warn(`[kimi-session-reader] state.json parse failed for session ${sessionId}, falling back to index workDir`);
575
+ }
576
+ // Fallback: when state.json is missing, unparseable, or has no workDir/cwd,
577
+ // use the workDir from the session index entry as a secondary guard.
578
+ // This prevents /resume <id> from accessing a session belonging to a
579
+ // different workspace when state.json is unavailable.
580
+ if (!cwdGuardPassed) {
581
+ const indexWorkDir = indexEntry?.workDir;
582
+ if (indexWorkDir && indexWorkDir !== realCwd) {
583
+ return null;
584
+ }
585
+ // v2 sessions have empty index workDir (v1-only field) and may lack
586
+ // state.json cwd. When no cwd source can verify the session belongs to
587
+ // the requested workspace, fail-closed to prevent cross-workspace access
588
+ // (aligned with claude's fail-closed cwd guard).
589
+ if (!indexWorkDir) {
590
+ getLogger().warn(`[kimi-session-reader] no cwd source (state.json + index) for session ${sessionId}, rejecting (fail-closed)`);
591
+ return null;
592
+ }
593
+ }
594
+ const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
595
+ if (!fs.existsSync(wirePath)) {
596
+ return null;
597
+ }
598
+ return wirePath;
599
+ }
525
600
  /**
526
601
  * Check if a session is currently active
527
602
  */
528
- isSessionActive(sessionId, _cwd) {
529
- const sessionDir = this.findSessionDirFromIndex(sessionId);
603
+ isSessionActive(sessionId, cwd) {
604
+ const indexEntry = this.findSessionIndexEntry(sessionId);
605
+ const sessionDir = indexEntry?.sessionDir;
530
606
  if (!sessionDir) {
531
607
  return false;
532
608
  }
609
+ // Cwd guard: the session's working directory must match the requested
610
+ // cwd (align with claude/pi/opencode). Mirrors readSessionContent's
611
+ // fail-closed cwd check (state.json workDir/cwd, then index workDir).
612
+ let realCwd;
613
+ try {
614
+ realCwd = fs.realpathSync(cwd);
615
+ }
616
+ catch {
617
+ return false;
618
+ }
619
+ let cwdGuardPassed = false;
620
+ const statePath = path.join(sessionDir, 'state.json');
621
+ try {
622
+ if (fs.existsSync(statePath)) {
623
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
624
+ const guardResult = checkCwdGuard(state, realCwd);
625
+ if (guardResult === 'failed')
626
+ return false;
627
+ if (guardResult === 'verified')
628
+ cwdGuardPassed = true;
629
+ }
630
+ }
631
+ catch {
632
+ // state.json unparseable — fall through to index-based guard
633
+ }
634
+ if (!cwdGuardPassed) {
635
+ const indexWorkDir = indexEntry?.workDir;
636
+ if (indexWorkDir && indexWorkDir !== realCwd)
637
+ return false;
638
+ // v2 sessions have empty index workDir (v1-only field) and may lack
639
+ // state.json cwd — fail-closed to prevent cross-workspace access.
640
+ if (!indexWorkDir)
641
+ return false;
642
+ }
533
643
  const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
534
644
  if (!fs.existsSync(wirePath)) {
535
645
  return false;
@@ -24,13 +24,25 @@ interface OpencodeSessionReaderOptions {
24
24
  */
25
25
  captureExport?: (sessionId: string) => string;
26
26
  }
27
+ interface OpencodeSessionListEntry {
28
+ id: string;
29
+ title: string;
30
+ updated: number;
31
+ created: number;
32
+ projectId: string;
33
+ directory: string;
34
+ }
27
35
  export declare class OpencodeSessionReader implements AgentSessionReader {
28
36
  private readonly binary;
29
37
  private readonly cacheTtlMs;
30
38
  /** Optional raw-export capture override (testing / alt transport). */
31
39
  private readonly captureExportOverride?;
32
40
  /** Cache keyed by cwd - each cwd has its own cache entry */
33
- private listCache;
41
+ /** cwd → 列表缓存(public:测试直接断言缓存形状,替代 as unknown as)。 */
42
+ listCache: Map<string, {
43
+ ts: number;
44
+ data: OpencodeSessionListEntry[];
45
+ }>;
34
46
  constructor(opts?: OpencodeSessionReaderOptions);
35
47
  /**
36
48
  * List sessions in a given cwd, using directory field for matching.
@@ -59,14 +71,16 @@ export declare class OpencodeSessionReader implements AgentSessionReader {
59
71
  * Passes cwd to fetchSessionList so it returns sessions for the correct directory.
60
72
  */
61
73
  isSessionActive(sessionId: string, cwd: string): boolean;
62
- private realpath;
74
+ /** realpath 归一化(public:测试直接调用,替代 as unknown as)。 */
75
+ realpath(cwd: string): string;
63
76
  private fetchSessionList;
64
77
  /**
65
78
  * Capture raw `opencode export <sessionId>` output (JSON string). Injectable
66
79
  * via the constructor; the default routes stdout to a temp file fd to bypass
67
80
  * opencode's ~128KB pipe truncation on large sessions (L1).
68
81
  */
69
- private captureExport;
82
+ /** 导出会话内容(public:测试直接调用,替代 as unknown as)。 */
83
+ captureExport(sessionId: string): string;
70
84
  /**
71
85
  * L1 default transport: run `opencode export` with stdout pointed at a
72
86
  * regular-file fd (NOT a pipe). opencode truncates piped stdout at ~128KB for
@@ -15,17 +15,20 @@
15
15
  */
16
16
  import { execFileSync } from 'node:child_process';
17
17
  import fs from 'node:fs';
18
+ import { silentlyUnlink } from '../../common/fs.js';
18
19
  import os from 'node:os';
19
20
  import path from 'node:path';
20
- import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../card/text-truncate.js';
21
+ import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
21
22
  import { getLogger } from '../../logger/index.js';
22
23
  import { STALE_MS } from '../common/constants.js';
24
+ import { paginate, capEvents } from '../common/pagination.js';
23
25
  export class OpencodeSessionReader {
24
26
  binary;
25
27
  cacheTtlMs;
26
28
  /** Optional raw-export capture override (testing / alt transport). */
27
29
  captureExportOverride;
28
30
  /** Cache keyed by cwd - each cwd has its own cache entry */
31
+ /** cwd → 列表缓存(public:测试直接断言缓存形状,替代 as unknown as)。 */
29
32
  listCache = new Map();
30
33
  constructor(opts = {}) {
31
34
  this.binary = 'opencode';
@@ -46,12 +49,9 @@ export class OpencodeSessionReader {
46
49
  // secondary key so CLI/list-cache rebuilds never reorder the page.
47
50
  filtered.sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id));
48
51
  // Compute total over the full sorted set, then paginate.
49
- const total = filtered.length;
50
- const offset = Math.max(0, opts?.offset ?? 0);
51
- const limit = opts?.limit;
52
- const page = limit === undefined ? filtered.slice(offset) : filtered.slice(offset, offset + limit);
52
+ const { items, total } = paginate(filtered, opts ?? {});
53
53
  return {
54
- sessions: page.map((e) => ({
54
+ sessions: items.map((e) => ({
55
55
  sessionId: e.id,
56
56
  // CLI title 字段形状不保证恒为 string(null/缺字段时 router 的
57
57
  // s.summary.trim() 会 TypeError)——非 string 给空串(Review R3 P3-3)。
@@ -105,8 +105,7 @@ export class OpencodeSessionReader {
105
105
  let aiTitle;
106
106
  let displayTitle;
107
107
  // Find the last user message position (for catch-up tail).
108
- // messages are already parsed objects — scan directly instead of
109
- // stringify→findLastUserIndex→parse round-trip (review P2-36).
108
+ // messages are already parsed objects — scan directly.
110
109
  let lastUserIndex = -1;
111
110
  for (let i = 0; i < data.messages.length; i++) {
112
111
  if (data.messages[i].info.role === 'user') {
@@ -172,21 +171,28 @@ export class OpencodeSessionReader {
172
171
  // Track usage from step-finish parts
173
172
  for (const part of msg.parts) {
174
173
  if (part.type === 'step-finish' && part.tokens) {
175
- // Use the last step-finish tokens (usually the final one).
174
+ // Use the last NON-EMPTY step-finish tokens. A long autonomous run
175
+ // can end on a degenerate step (model cut off mid-reasoning) whose
176
+ // step-finish reports all-zero tokens; trusting it would wipe out
177
+ // the run's real per-turn usage (contextLength/input/output/total
178
+ // all render 0). Skip zero-total steps so per-turn usage reflects
179
+ // the last step that actually consumed tokens.
176
180
  // ccusage-aligned: cache.write -> cacheCreationTokens.
177
181
  // contextLength = input + cacheRead + cacheCreation (excludes
178
182
  // output/reasoning) — the unified context-window occupancy
179
183
  // contract across all five readers (review P2-8).
180
- usage = {
181
- inputTokens: part.tokens.input,
182
- outputTokens: part.tokens.output,
183
- contextLength: part.tokens.input +
184
- (part.tokens.cache?.read ?? 0) +
185
- (part.tokens.cache?.write ?? 0),
186
- cacheReadTokens: part.tokens.cache?.read,
187
- cacheCreationTokens: part.tokens.cache?.write,
188
- totalTokens: part.tokens.total,
189
- };
184
+ if ((part.tokens.total ?? 0) !== 0) {
185
+ usage = {
186
+ inputTokens: part.tokens.input,
187
+ outputTokens: part.tokens.output,
188
+ contextLength: part.tokens.input +
189
+ (part.tokens.cache?.read ?? 0) +
190
+ (part.tokens.cache?.write ?? 0),
191
+ cacheReadTokens: part.tokens.cache?.read,
192
+ cacheCreationTokens: part.tokens.cache?.write,
193
+ totalTokens: part.tokens.total,
194
+ };
195
+ }
190
196
  }
191
197
  }
192
198
  }
@@ -251,12 +257,7 @@ export class OpencodeSessionReader {
251
257
  // Apply maxEvents cap: keep the LAST N events (most recent), matching
252
258
  // CodexSessionReader's slice(-maxEvents). maxEvents limits the catch-up
253
259
  // tail so auto-resume cards don't load the entire session.
254
- // P2-7: maxEvents <= 0 returns [] (guards `slice(-0)` full-array trap).
255
- const cappedEvents = opts?.maxEvents !== undefined
256
- ? opts.maxEvents <= 0
257
- ? []
258
- : events.slice(-opts.maxEvents)
259
- : events;
260
+ const cappedEvents = capEvents(events, opts?.maxEvents);
260
261
  return {
261
262
  events: cappedEvents,
262
263
  usage,
@@ -289,6 +290,7 @@ export class OpencodeSessionReader {
289
290
  }
290
291
  }
291
292
  // --- Private helpers ---
293
+ /** realpath 归一化(public:测试直接调用,替代 as unknown as)。 */
292
294
  realpath(cwd) {
293
295
  try {
294
296
  return fs.realpathSync(cwd);
@@ -343,6 +345,7 @@ export class OpencodeSessionReader {
343
345
  * via the constructor; the default routes stdout to a temp file fd to bypass
344
346
  * opencode's ~128KB pipe truncation on large sessions (L1).
345
347
  */
348
+ /** 导出会话内容(public:测试直接调用,替代 as unknown as)。 */
346
349
  captureExport(sessionId) {
347
350
  if (this.captureExportOverride)
348
351
  return this.captureExportOverride(sessionId);
@@ -378,10 +381,7 @@ export class OpencodeSessionReader {
378
381
  }
379
382
  catch { }
380
383
  }
381
- try {
382
- fs.unlinkSync(tmp);
383
- }
384
- catch { }
384
+ silentlyUnlink(tmp);
385
385
  }
386
386
  }
387
387
  }
@@ -1,4 +1,7 @@
1
+ import { type ContentBlockMapping } from '../common/content-blocks.js';
1
2
  import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
3
+ /** Pi-specific field-name mapping for content block extraction. */
4
+ export declare const PI_MAPPING: ContentBlockMapping;
2
5
  /**
3
6
  * PiSessionReader: reads pi agent sessions from `~/.pi/agent/`.
4
7
  *
@@ -7,7 +10,8 @@ import type { AgentSession, AgentSessionReader, SessionContent } from '../../run
7
10
  */
8
11
  export declare class PiSessionReader implements AgentSessionReader {
9
12
  private readonly piDir;
10
- private readonly sessionsDir;
13
+ /** 会话目录(public:测试可重定向到 fixture 目录,替代 as any)。 */
14
+ sessionsDir: string;
11
15
  constructor(opts?: {
12
16
  piDir?: string;
13
17
  });