lark-remote 0.1.12 → 0.2.0

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 (160) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.en.md +2 -6
  3. package/README.md +3 -7
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/inbound-media.d.ts +0 -3
  7. package/dist/bridge/inbound-media.js +0 -1
  8. package/dist/bridge/index.d.ts +7 -7
  9. package/dist/bridge/index.js +100 -133
  10. package/dist/bridge/queue-manager.js +4 -3
  11. package/dist/card/approval-render.js +39 -94
  12. package/dist/card/bash-renderer.js +12 -21
  13. package/dist/card/card-shared.d.ts +14 -5
  14. package/dist/card/card-shared.js +23 -34
  15. package/dist/card/run-renderer.d.ts +0 -22
  16. package/dist/card/run-renderer.js +206 -153
  17. package/dist/card/run-state.d.ts +29 -1
  18. package/dist/card/run-state.js +172 -84
  19. package/dist/card/text-truncate.d.ts +0 -9
  20. package/dist/card/text-truncate.js +0 -9
  21. package/dist/card/tool-render.d.ts +4 -1
  22. package/dist/card/tool-render.js +76 -32
  23. package/dist/cli.js +2 -2
  24. package/dist/common/fs.d.ts +14 -0
  25. package/dist/common/fs.js +57 -3
  26. package/dist/common/truncate.d.ts +0 -6
  27. package/dist/common/truncate.js +1 -1
  28. package/dist/common/ttl-cache.d.ts +24 -0
  29. package/dist/common/ttl-cache.js +53 -0
  30. package/dist/config/codex-config.js +14 -11
  31. package/dist/config/index.d.ts +0 -8
  32. package/dist/config/index.js +0 -16
  33. package/dist/config/kimi-config.d.ts +1 -1
  34. package/dist/config/kimi-config.js +2 -1
  35. package/dist/config/opencode-config.d.ts +1 -1
  36. package/dist/config/opencode-config.js +16 -10
  37. package/dist/config/qr.d.ts +36 -0
  38. package/dist/config/qr.js +124 -0
  39. package/dist/config/wizard.js +69 -27
  40. package/dist/connector/index.d.ts +3 -2
  41. package/dist/connector/index.js +5 -3
  42. package/dist/index.js +14 -41
  43. package/dist/instance-lock.js +7 -0
  44. package/dist/persistence/atomic-write.d.ts +2 -0
  45. package/dist/persistence/atomic-write.js +20 -5
  46. package/dist/persistence/load-json-file.d.ts +1 -2
  47. package/dist/persistence/load-json-file.js +2 -3
  48. package/dist/platform/agent-stopper.d.ts +22 -0
  49. package/dist/platform/agent-stopper.js +19 -0
  50. package/dist/platform/command.d.ts +11 -0
  51. package/dist/platform/command.js +107 -0
  52. package/dist/platform/identity.d.ts +33 -0
  53. package/dist/platform/identity.js +187 -0
  54. package/dist/platform/path.d.ts +24 -0
  55. package/dist/platform/path.js +110 -0
  56. package/dist/platform/select.d.ts +9 -0
  57. package/dist/platform/select.js +12 -0
  58. package/dist/platform/shell.d.ts +51 -0
  59. package/dist/platform/shell.js +75 -0
  60. package/dist/platform/spawn.d.ts +41 -0
  61. package/dist/platform/spawn.js +77 -0
  62. package/dist/platform/terminator-posix.d.ts +38 -0
  63. package/dist/platform/terminator-posix.js +103 -0
  64. package/dist/platform/terminator-win32.d.ts +21 -0
  65. package/dist/platform/terminator-win32.js +110 -0
  66. package/dist/platform/terminator.d.ts +44 -0
  67. package/dist/platform/terminator.js +24 -0
  68. package/dist/platform/types.d.ts +23 -0
  69. package/dist/platform/types.js +5 -0
  70. package/dist/restart.js +4 -2
  71. package/dist/router/card-helpers.d.ts +33 -6
  72. package/dist/router/card-helpers.js +67 -11
  73. package/dist/router/config/common/render.d.ts +1 -1
  74. package/dist/router/config/common/render.js +1 -1
  75. package/dist/router/config-diff.d.ts +4 -0
  76. package/dist/router/config-diff.js +70 -0
  77. package/dist/router/index.d.ts +73 -6
  78. package/dist/router/index.js +663 -590
  79. package/dist/router/utils.d.ts +6 -0
  80. package/dist/router/utils.js +12 -0
  81. package/dist/runner/bash/runner.d.ts +9 -2
  82. package/dist/runner/bash/runner.js +45 -20
  83. package/dist/runner/claude/session.d.ts +4 -4
  84. package/dist/runner/claude/session.js +18 -9
  85. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  86. package/dist/runner/codex/app-server/runner.d.ts +1 -16
  87. package/dist/runner/codex/app-server/runner.js +2 -2
  88. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  89. package/dist/runner/codex/app-server/translator.js +146 -0
  90. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  91. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  92. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  93. package/dist/runner/common/acp/base-translator.js +271 -0
  94. package/dist/runner/common/acp/protocol-helpers.d.ts +70 -0
  95. package/dist/runner/common/acp/protocol-helpers.js +82 -0
  96. package/dist/runner/common/connection-based-runner.d.ts +0 -2
  97. package/dist/runner/common/connection-based-runner.js +1 -1
  98. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  99. package/dist/runner/common/jsonrpc/transport.js +25 -5
  100. package/dist/runner/common/process-stopper.d.ts +4 -26
  101. package/dist/runner/common/process-stopper.js +4 -76
  102. package/dist/runner/common/runner-utils.d.ts +0 -5
  103. package/dist/runner/common/runner-utils.js +0 -7
  104. package/dist/runner/common/spawning-runner.d.ts +42 -132
  105. package/dist/runner/common/spawning-runner.js +52 -271
  106. package/dist/runner/common/usage.d.ts +2 -0
  107. package/dist/runner/common/usage.js +2 -0
  108. package/dist/runner/dsh/index.d.ts +0 -2
  109. package/dist/runner/dsh/index.js +0 -2
  110. package/dist/runner/dsh/runner.d.ts +0 -2
  111. package/dist/runner/dsh/runner.js +1 -1
  112. package/dist/runner/index.d.ts +1 -1
  113. package/dist/runner/kimi/acp/runner.d.ts +63 -53
  114. package/dist/runner/kimi/acp/runner.js +189 -334
  115. package/dist/runner/kimi/acp/translator.d.ts +18 -101
  116. package/dist/runner/kimi/acp/translator.js +61 -255
  117. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  118. package/dist/runner/opencode/acp/runner.js +74 -297
  119. package/dist/runner/opencode/acp/translator.d.ts +28 -104
  120. package/dist/runner/opencode/acp/translator.js +54 -249
  121. package/dist/runner/pi/rpc/index.d.ts +0 -2
  122. package/dist/runner/pi/rpc/index.js +0 -2
  123. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  124. package/dist/runner/pi/rpc/translator.js +47 -9
  125. package/dist/runner/probe.d.ts +8 -11
  126. package/dist/runner/probe.js +17 -21
  127. package/dist/runner/registry.d.ts +4 -4
  128. package/dist/runner/registry.js +4 -4
  129. package/dist/runner/resolve-agent-choices.js +6 -2
  130. package/dist/runner/sync-agent-choices.js +1 -1
  131. package/dist/runner/types.d.ts +38 -1
  132. package/dist/session/claude/session-index.js +3 -1
  133. package/dist/session/claude/sessions.js +37 -64
  134. package/dist/session/codex/rollout-reader.d.ts +10 -14
  135. package/dist/session/codex/rollout-reader.js +126 -48
  136. package/dist/session/common/constants.d.ts +7 -0
  137. package/dist/session/common/constants.js +9 -0
  138. package/dist/session/common/pagination.d.ts +0 -2
  139. package/dist/session/common/pagination.js +1 -1
  140. package/dist/session/common/recency.d.ts +7 -0
  141. package/dist/session/common/recency.js +10 -0
  142. package/dist/session/common/two-pass.d.ts +22 -0
  143. package/dist/session/common/two-pass.js +70 -0
  144. package/dist/session/common/usage-accumulator.d.ts +14 -0
  145. package/dist/session/common/usage-accumulator.js +15 -0
  146. package/dist/session/dsh/sessions.js +15 -11
  147. package/dist/session/kimi/sessions.d.ts +24 -4
  148. package/dist/session/kimi/sessions.js +99 -103
  149. package/dist/session/opencode/sessions.d.ts +2 -14
  150. package/dist/session/opencode/sessions.js +35 -15
  151. package/dist/session/pi/sessions.js +64 -95
  152. package/dist/update/index.d.ts +2 -3
  153. package/dist/update/index.js +1 -2
  154. package/dist/update/install.d.ts +2 -1
  155. package/dist/update/install.js +58 -9
  156. package/dist/update/version-check.d.ts +5 -0
  157. package/dist/update/version-check.js +9 -0
  158. package/package.json +9 -7
  159. package/dist/update/startup-hint.d.ts +0 -5
  160. package/dist/update/startup-hint.js +0 -11
@@ -2,10 +2,13 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { readJsonlLines as readJsonlLinesShared, readLastNJsonlLines } from '../common/jsonl.js';
5
- import { UsageAccumulator } from '../common/usage-accumulator.js';
5
+ import { UsageAccumulator, cumulativeUsageFields } from '../common/usage-accumulator.js';
6
6
  import { getLogger } from '../../logger/index.js';
7
- import { STALE_MS } from '../common/constants.js';
7
+ import { isStale } from '../common/constants.js';
8
+ import { samePath } from '../../platform/path.js';
8
9
  import { capEvents, paginate } from '../common/pagination.js';
10
+ import { sortByRecencyDesc } from '../common/recency.js';
11
+ import { TtlCache } from '../../common/ttl-cache.js';
9
12
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
10
13
  import { truncateToolInput } from '../common/content-blocks.js';
11
14
  /** Default Kimi config directory */
@@ -25,6 +28,16 @@ function isTurnPrompt(entry) {
25
28
  function isCompactionComplete(entry) {
26
29
  return entry.type === 'full_compaction.complete' || entry.type === 'context.apply_compaction';
27
30
  }
31
+ /** full_compaction.begin:压缩开始(手动 /compact 或自动触发都写)。 */
32
+ function isCompactionBegin(entry) {
33
+ return entry.type === 'full_compaction.begin';
34
+ }
35
+ /** 压缩终态:完成(complete/apply)或失败/取消(cancel)共用 cancel 记录。 */
36
+ function isCompactionTerminal(entry) {
37
+ return (entry.type === 'full_compaction.complete' ||
38
+ entry.type === 'context.apply_compaction' ||
39
+ entry.type === 'full_compaction.cancel');
40
+ }
28
41
  /**
29
42
  * Max lines scanned backwards when looking for the last loop event.
30
43
  * Bounds the tail scan so huge wire files are not fully parsed.
@@ -55,10 +68,7 @@ function lastLoopEventIsStepEnd(lines) {
55
68
  return false;
56
69
  }
57
70
  /** Cache for JSONL file contents to avoid repeated full reads */
58
- const jsonlCache = new Map();
59
- const CACHE_TTL_MS = 5000; // 5 second cache TTL
60
- /** Upper bound on cached files; evict the least-recently-used entry past this. */
61
- const CACHE_MAX_ENTRIES = 32;
71
+ const jsonlCache = new TtlCache(5_000, 32);
62
72
  /**
63
73
  * Read lines from a JSONL file with caching.
64
74
  * Delegates to the shared readJsonlLines for the actual file read,
@@ -68,25 +78,14 @@ function readJsonlLines(filePath) {
68
78
  try {
69
79
  const stat = fs.statSync(filePath);
70
80
  const mtime = stat.mtimeMs;
81
+ // P1-17: TTL 由 TtlCache 按缓存写入时间判定(不是文件 mtime),命中即 LRU 刷新;
82
+ // mtime 变化视为失效(文件被改写后不得返回旧行)。
71
83
  const cached = jsonlCache.get(filePath);
72
- // P1-17: TTL must be measured from the cache-write time (cachedAt), not
73
- // the FILE mtime — a file modified an hour ago would otherwise never hit
74
- // the cache, making every call re-read and re-write the same entry.
75
- if (cached && mtime === cached.mtime && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
76
- // LRU refresh on hit: re-insert so recently-used entries survive eviction.
77
- jsonlCache.delete(filePath);
78
- jsonlCache.set(filePath, cached);
84
+ if (cached && mtime === cached.mtime) {
79
85
  return cached.lines;
80
86
  }
81
87
  const lines = readJsonlLinesShared(filePath);
82
- jsonlCache.set(filePath, { mtime, cachedAt: Date.now(), lines });
83
- // P1-17: bound the cache. Map iteration is insertion order; after the LRU
84
- // refresh above the least-recently-used entry is the first key.
85
- if (jsonlCache.size > CACHE_MAX_ENTRIES) {
86
- const oldest = jsonlCache.keys().next().value;
87
- if (oldest !== undefined)
88
- jsonlCache.delete(oldest);
89
- }
88
+ jsonlCache.set(filePath, { mtime, lines });
90
89
  return lines;
91
90
  }
92
91
  catch {
@@ -126,7 +125,7 @@ export function checkCwdGuard(state, realCwd) {
126
125
  const sessionWorkDir = extractWorkDir(state);
127
126
  if (sessionWorkDir === undefined)
128
127
  return 'unverifiable';
129
- if (sessionWorkDir === realCwd)
128
+ if (samePath(sessionWorkDir, realCwd))
130
129
  return 'verified';
131
130
  return 'failed';
132
131
  }
@@ -211,35 +210,36 @@ export class KimiSessionReader {
211
210
  const realCwd = fs.realpathSync(cwd);
212
211
  const sessions = [];
213
212
  for (const entry of this.scanSessionIndex()) {
214
- // Index workDir is v1-only; v2 sessions may have empty/missing workDir.
215
- // When index workDir is absent, fall back to state.json's cwd field.
216
- let entryWorkDir = entry.workDir;
217
- if (!entryWorkDir) {
218
- const statePath = path.join(entry.sessionDir, 'state.json');
213
+ // W2.7:state.json 一次读取共用(cwd 兜底 + summary),I/O 减半。
214
+ // 两次 catch 语义保留:解析失败 = 无法判 cwd → 跳过(continue);
215
+ // 解析成功但字段缺失 = summary 落 wire.jsonl 提取。
216
+ const statePath = path.join(entry.sessionDir, 'state.json');
217
+ let state = null;
218
+ let stateParsed = false;
219
+ if (fs.existsSync(statePath)) {
219
220
  try {
220
- if (fs.existsSync(statePath)) {
221
- const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
222
- entryWorkDir = extractWorkDir(state);
223
- }
221
+ state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
222
+ stateParsed = true;
224
223
  }
225
224
  catch {
226
- // Unreadable state — cannot determine cwd, skip
225
+ // Corrupt state.json — cannot determine cwd → skip below
227
226
  }
228
227
  }
228
+ // Index workDir is v1-only; v2 sessions may have empty/missing workDir.
229
+ // When index workDir is absent, fall back to state.json's cwd field.
230
+ let entryWorkDir = entry.workDir;
231
+ if (!entryWorkDir) {
232
+ if (stateParsed && state)
233
+ entryWorkDir = extractWorkDir(state);
234
+ // Unreadable state — cannot determine cwd, entryWorkDir stays undefined
235
+ }
229
236
  if (entryWorkDir !== realCwd) {
230
237
  continue;
231
238
  }
232
- const statePath = path.join(entry.sessionDir, 'state.json');
233
239
  let summary = 'New Session';
234
- if (fs.existsSync(statePath)) {
235
- try {
236
- const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
237
- // v1: state.title / state.lastPrompt; v2: these fields may be absent
238
- summary = state.title || state.lastPrompt || '';
239
- }
240
- catch {
241
- // Corrupt state.json — fall through to wire.jsonl extraction
242
- }
240
+ if (stateParsed && state) {
241
+ // v1: state.title / state.lastPrompt; v2: these fields may be absent
242
+ summary = state.title || state.lastPrompt || '';
243
243
  }
244
244
  // v2 state.json omits title/lastPrompt: extract from wire.jsonl's last
245
245
  // turn.prompt instead. Only scan a small tail to find it — the summary
@@ -271,7 +271,7 @@ export class KimiSessionReader {
271
271
  }
272
272
  // Sort by mtime descending; same-mtime ties use a deterministic
273
273
  // secondary key so ordering is stable across scans.
274
- sessions.sort((a, b) => b.mtime - a.mtime || a.sessionId.localeCompare(b.sessionId));
274
+ sortByRecencyDesc(sessions, (s) => s.mtime, (s) => s.sessionId);
275
275
  const { items, total } = paginate(sessions, opts ?? {});
276
276
  return {
277
277
  sessions: items,
@@ -469,11 +469,7 @@ export class KimiSessionReader {
469
469
  totalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
470
470
  // Cumulative (session-wide): kimi sums all usage.records; mirrors
471
471
  // inputTokens/outputTokens which are already session totals.
472
- cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
473
- cumulativeInputTokens: t.input,
474
- cumulativeOutputTokens: t.output,
475
- cumulativeCacheReadTokens: t.cacheRead > 0 ? t.cacheRead : 0,
476
- cumulativeCacheCreationTokens: t.cacheCreation > 0 ? t.cacheCreation : 0,
472
+ ...cumulativeUsageFields(t),
477
473
  // §6.3: compaction stats from wire.jsonl
478
474
  compactCount,
479
475
  ...(compactPreContextLength !== undefined ? { compactPreContextLength } : {}),
@@ -490,11 +486,12 @@ export class KimiSessionReader {
490
486
  }
491
487
  }
492
488
  /**
493
- * Read the compaction records (context.apply_compaction /
494
- * full_compaction.complete) from a session's wire.jsonl.
489
+ * Read the compaction records (full_compaction.begin / complete / cancel /
490
+ * context.apply_compaction) from a session's wire.jsonl.
495
491
  *
496
492
  * Same cwd guard as readSessionContent. Used by KimiAcpRunner.runCompact
497
- * (R2) to wait for the background compaction to land before ending the run.
493
+ * to wait for the background compaction terminal before ending the run, and
494
+ * by readCompactionState to detect an in-flight compaction.
498
495
  */
499
496
  readCompactionRecords(sessionId, cwd) {
500
497
  const wirePath = this.resolveSessionWirePath(sessionId, cwd);
@@ -506,9 +503,10 @@ export class KimiSessionReader {
506
503
  for (const line of readJsonlLines(wirePath)) {
507
504
  try {
508
505
  const entry = JSON.parse(line);
509
- if (isCompactionComplete(entry)) {
506
+ if (isCompactionBegin(entry) || isCompactionTerminal(entry)) {
510
507
  records.push({
511
508
  type: entry.type,
509
+ ...(entry.source !== undefined ? { source: entry.source } : {}),
512
510
  ...(entry.compactedCount !== undefined
513
511
  ? { compactedCount: entry.compactedCount }
514
512
  : {}),
@@ -530,11 +528,34 @@ export class KimiSessionReader {
530
528
  return records;
531
529
  }
532
530
  /**
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.
531
+ * 由 begin/terminal 记录重建压缩状态机:最后一次 begin 无更新的 terminal
532
+ * 记录 = 在途(kimi-compact-wait-redesign §5.1)。崩溃墓碑由 kimi resume
533
+ * 时补写 full_compaction.cancel,天然闭合。
536
534
  */
537
- resolveSessionWirePath(sessionId, cwd) {
535
+ readCompactionState(sessionId, cwd) {
536
+ const records = [...this.readCompactionRecords(sessionId, cwd)].sort((a, b) => a.time - b.time);
537
+ let pendingBeginAt;
538
+ for (const record of records) {
539
+ if (isCompactionBegin(record)) {
540
+ pendingBeginAt = record.time;
541
+ }
542
+ else {
543
+ // 终态(complete/apply/cancel)闭合当前在途压缩。
544
+ pendingBeginAt = undefined;
545
+ }
546
+ }
547
+ return {
548
+ inFlight: pendingBeginAt !== undefined,
549
+ ...(pendingBeginAt !== undefined ? { inFlightSince: pendingBeginAt } : {}),
550
+ records,
551
+ };
552
+ }
553
+ /**
554
+ * W2.5 单源:resolveSessionWirePath 与 isSessionActive 共用的 cwd 守卫
555
+ * (realpathSync → state.json workDir/cwd 三态 → index workDir 兜底 →
556
+ * fail-closed)。返回 sessionDir;会话未知或守卫不过返回 null。
557
+ */
558
+ verifySessionCwd(sessionId, cwd) {
538
559
  // Find session directory from index (also retrieve index entry for
539
560
  // fallback cwd guard when state.json is missing or unparseable)
540
561
  const indexEntry = this.findSessionIndexEntry(sessionId);
@@ -542,7 +563,6 @@ export class KimiSessionReader {
542
563
  if (!sessionDir) {
543
564
  return null;
544
565
  }
545
- const statePath = path.join(sessionDir, 'state.json');
546
566
  let realCwd;
547
567
  try {
548
568
  realCwd = fs.realpathSync(cwd);
@@ -552,6 +572,7 @@ export class KimiSessionReader {
552
572
  return null;
553
573
  }
554
574
  let cwdGuardPassed = false;
575
+ const statePath = path.join(sessionDir, 'state.json');
555
576
  try {
556
577
  if (fs.existsSync(statePath)) {
557
578
  const state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
@@ -559,13 +580,12 @@ export class KimiSessionReader {
559
580
  if (guardResult === 'failed') {
560
581
  return null;
561
582
  }
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
583
+ if (guardResult === 'verified') {
584
+ cwdGuardPassed = true;
565
585
  }
566
586
  else {
567
- // verified — state.json cwd matches
568
- cwdGuardPassed = true;
587
+ getLogger().warn(`[kimi-session-reader] state.json has no workDir/cwd field for session ${sessionId}, falling back to index workDir`);
588
+ // Fall through to index-based guard below
569
589
  }
570
590
  }
571
591
  }
@@ -591,7 +611,18 @@ export class KimiSessionReader {
591
611
  return null;
592
612
  }
593
613
  }
594
- const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
614
+ return { sessionDir };
615
+ }
616
+ /**
617
+ * Resolve the main wire.jsonl path for a session after passing the cwd
618
+ * guard (state.json + session index fallback). Returns null when the
619
+ * session is unknown, fails the guard, or has no wire file.
620
+ */
621
+ resolveSessionWirePath(sessionId, cwd) {
622
+ const verified = this.verifySessionCwd(sessionId, cwd);
623
+ if (!verified)
624
+ return null;
625
+ const wirePath = path.join(verified.sessionDir, 'agents', 'main', 'wire.jsonl');
595
626
  if (!fs.existsSync(wirePath)) {
596
627
  return null;
597
628
  }
@@ -601,46 +632,11 @@ export class KimiSessionReader {
601
632
  * Check if a session is currently active
602
633
  */
603
634
  isSessionActive(sessionId, cwd) {
604
- const indexEntry = this.findSessionIndexEntry(sessionId);
605
- const sessionDir = indexEntry?.sessionDir;
606
- if (!sessionDir) {
635
+ // W2.5:cwd 守卫与 resolveSessionWirePath 共用 verifySessionCwd(单源)。
636
+ const verified = this.verifySessionCwd(sessionId, cwd);
637
+ if (!verified)
607
638
  return false;
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
- }
643
- const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
639
+ const wirePath = path.join(verified.sessionDir, 'agents', 'main', 'wire.jsonl');
644
640
  if (!fs.existsSync(wirePath)) {
645
641
  return false;
646
642
  }
@@ -648,7 +644,7 @@ export class KimiSessionReader {
648
644
  const stat = fs.statSync(wirePath);
649
645
  const now = Date.now();
650
646
  // Check mtime freshness
651
- if (now - stat.mtimeMs > STALE_MS) {
647
+ if (isStale(stat.mtimeMs, now)) {
652
648
  return false;
653
649
  }
654
650
  // Read the tail to check for active indicators. kimi appends
@@ -24,25 +24,13 @@ 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
- }
35
27
  export declare class OpencodeSessionReader implements AgentSessionReader {
36
28
  private readonly binary;
37
29
  private readonly cacheTtlMs;
38
30
  /** Optional raw-export capture override (testing / alt transport). */
39
31
  private readonly captureExportOverride?;
40
- /** Cache keyed by cwd - each cwd has its own cache entry */
41
- /** cwd → 列表缓存(public:测试直接断言缓存形状,替代 as unknown as)。 */
42
- listCache: Map<string, {
43
- ts: number;
44
- data: OpencodeSessionListEntry[];
45
- }>;
32
+ /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
33
+ private listCache;
46
34
  constructor(opts?: OpencodeSessionReaderOptions);
47
35
  /**
48
36
  * List sessions in a given cwd, using directory field for matching.
@@ -13,26 +13,29 @@
13
13
  * - isSessionActive: use `updated` timestamp with 1 hour stale window.
14
14
  * - Simple memory cache with TTL.
15
15
  */
16
- import { execFileSync } from 'node:child_process';
16
+ import { spawnProcessSync } from '../../platform/spawn.js';
17
17
  import fs from 'node:fs';
18
18
  import { silentlyUnlink } from '../../common/fs.js';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
22
22
  import { getLogger } from '../../logger/index.js';
23
- import { STALE_MS } from '../common/constants.js';
23
+ import { isStale } from '../common/constants.js';
24
+ import { samePath } from '../../platform/path.js';
24
25
  import { paginate, capEvents } from '../common/pagination.js';
26
+ import { sortByRecencyDesc } from '../common/recency.js';
27
+ import { TtlCache } from '../../common/ttl-cache.js';
25
28
  export class OpencodeSessionReader {
26
29
  binary;
27
30
  cacheTtlMs;
28
31
  /** Optional raw-export capture override (testing / alt transport). */
29
32
  captureExportOverride;
30
- /** Cache keyed by cwd - each cwd has its own cache entry */
31
- /** cwd → 列表缓存(public:测试直接断言缓存形状,替代 as unknown as)。 */
32
- listCache = new Map();
33
+ /** cwd → 列表缓存(TTL 由 cacheTtlMs 决定)。 */
34
+ listCache;
33
35
  constructor(opts = {}) {
34
36
  this.binary = 'opencode';
35
37
  this.cacheTtlMs = opts.cacheTtlMs ?? 10_000;
38
+ this.listCache = new TtlCache(this.cacheTtlMs);
36
39
  this.captureExportOverride = opts.captureExport;
37
40
  }
38
41
  /**
@@ -44,10 +47,10 @@ export class OpencodeSessionReader {
44
47
  const realCwd = this.realpath(cwd);
45
48
  const entries = this.fetchSessionList(realCwd);
46
49
  // Filter by directory === realpath(cwd)
47
- const filtered = entries.filter((e) => e.directory === realCwd);
50
+ const filtered = entries.filter((e) => samePath(e.directory, realCwd));
48
51
  // Sort by updated descending; same-updated ties use id as a deterministic
49
52
  // secondary key so CLI/list-cache rebuilds never reorder the page.
50
- filtered.sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id));
53
+ sortByRecencyDesc(filtered, (s) => s.updated, (s) => s.id);
51
54
  // Compute total over the full sorted set, then paginate.
52
55
  const { items, total } = paginate(filtered, opts ?? {});
53
56
  return {
@@ -279,11 +282,11 @@ export class OpencodeSessionReader {
279
282
  const realCwd = this.realpath(cwd);
280
283
  try {
281
284
  const entries = this.fetchSessionList(realCwd);
282
- const entry = entries.find((e) => e.id === sessionId && e.directory === realCwd);
285
+ const entry = entries.find((e) => e.id === sessionId && samePath(e.directory, realCwd));
283
286
  if (!entry)
284
287
  return false;
285
288
  // Check if updated within STALE_MS
286
- return Date.now() - entry.updated < STALE_MS;
289
+ return !isStale(entry.updated);
287
290
  }
288
291
  catch {
289
292
  return false;
@@ -302,12 +305,16 @@ export class OpencodeSessionReader {
302
305
  fetchSessionList(cwd) {
303
306
  const realCwd = this.realpath(cwd);
304
307
  const cached = this.listCache.get(realCwd);
305
- if (cached && Date.now() - cached.ts < this.cacheTtlMs) {
306
- return cached.data;
308
+ if (cached) {
309
+ return cached;
307
310
  }
308
311
  try {
312
+ // spawnProcessSync(cross-spawn)而非 execFileSync:opencode 是 npm 安装
313
+ // 的 .cmd 垫片,win32 上 execFileSync 不做 PATHEXT 解析会直接 ENOENT,
314
+ // 会话列表永远为空;cross-spawn 走与 runner spawn 相同的垫片执行路径
315
+ // (posix 为 child_process.spawnSync 直通,行为不变)。
309
316
  // Pass cwd to execFileSync so opencode CLI returns sessions for the correct directory
310
- const output = execFileSync(this.binary, ['session', 'list', '--format', 'json'], {
317
+ const res = spawnProcessSync(this.binary, ['session', 'list', '--format', 'json'], {
311
318
  encoding: 'utf-8',
312
319
  timeout: 10000,
313
320
  // P1-15: default maxBuffer is 1MiB — a large session list (>1MiB JSON)
@@ -315,14 +322,26 @@ export class OpencodeSessionReader {
315
322
  // user sees "no sessions" even though the CLI returned healthy data.
316
323
  maxBuffer: 64 * 1024 * 1024,
317
324
  cwd: realCwd, // Key fix: pass user cwd to CLI
318
- }).trim();
325
+ windowsHide: true, // opencode 是 npm .cmd 垫片:不隐藏会闪 cmd.exe 控制台
326
+ });
327
+ if (res.error) {
328
+ const enoent = res.error.code === 'ENOENT';
329
+ if (enoent)
330
+ return [];
331
+ throw res.error;
332
+ }
333
+ if (res.status !== 0) {
334
+ const stderr = String(res.stderr ?? '').trim();
335
+ throw new Error(`exit ${res.status}${stderr ? `: ${stderr}` : ''}`);
336
+ }
337
+ const output = String(res.stdout ?? '').trim();
319
338
  // CLI returns empty string when no sessions exist - treat as empty array
320
339
  if (!output) {
321
340
  return [];
322
341
  }
323
342
  const data = JSON.parse(output);
324
343
  // Update cache keyed by cwd
325
- this.listCache.set(realCwd, { ts: Date.now(), data });
344
+ this.listCache.set(realCwd, data);
326
345
  return data;
327
346
  }
328
347
  catch (err) {
@@ -363,7 +382,7 @@ export class OpencodeSessionReader {
363
382
  let fd;
364
383
  try {
365
384
  fd = fs.openSync(tmp, 'w');
366
- execFileSync(this.binary, ['export', sessionId], {
385
+ spawnProcessSync(this.binary, ['export', sessionId], {
367
386
  encoding: 'utf-8',
368
387
  timeout: 30000,
369
388
  stdio: ['ignore', fd, 'pipe'],
@@ -371,6 +390,7 @@ export class OpencodeSessionReader {
371
390
  // stderr) would throw ENOBUFS and lose the export. Keep the default
372
391
  // stdout file-fd transport unaffected by raising the pipe cap.
373
392
  maxBuffer: 64 * 1024 * 1024,
393
+ windowsHide: true,
374
394
  });
375
395
  return fs.readFileSync(tmp, 'utf-8');
376
396
  }