lark-remote 0.5.13 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/agent-kinds.d.ts +27 -0
  3. package/dist/agent-kinds.js +38 -0
  4. package/dist/bridge/approval-actions.d.ts +95 -0
  5. package/dist/bridge/approval-actions.js +104 -0
  6. package/dist/bridge/approval-coordinator.d.ts +13 -0
  7. package/dist/bridge/approval-coordinator.js +18 -0
  8. package/dist/bridge/final-usage.d.ts +8 -17
  9. package/dist/bridge/final-usage.js +1 -18
  10. package/dist/bridge/index.d.ts +46 -39
  11. package/dist/bridge/index.js +269 -240
  12. package/dist/bridge/queue-manager.d.ts +25 -1
  13. package/dist/bridge/queue-manager.js +179 -160
  14. package/dist/bridge/run-approval.d.ts +2 -1
  15. package/dist/bridge/run-usage.d.ts +21 -30
  16. package/dist/bridge/run-usage.js +47 -51
  17. package/dist/bridge/session-writeback.d.ts +3 -2
  18. package/dist/card/approval-render.d.ts +3 -2
  19. package/dist/card/approval-render.js +3 -2
  20. package/dist/{router → card}/card-helpers.d.ts +7 -7
  21. package/dist/{router → card}/card-helpers.js +8 -8
  22. package/dist/card/card-session.d.ts +4 -2
  23. package/dist/card/card-session.js +8 -6
  24. package/dist/card/card-shared.d.ts +1 -1
  25. package/dist/card/card-shared.js +1 -1
  26. package/dist/card/final-answer-overflow.d.ts +0 -6
  27. package/dist/card/final-answer-overflow.js +1 -1
  28. package/dist/card/run-card-session.d.ts +2 -2
  29. package/dist/card/run-card-session.js +2 -2
  30. package/dist/card/run-renderer.js +29 -30
  31. package/dist/card/run-state.d.ts +28 -50
  32. package/dist/card/run-state.js +23 -0
  33. package/dist/card/usage-format.d.ts +30 -0
  34. package/dist/{router → card}/usage-format.js +3 -10
  35. package/dist/clone.js +6 -1
  36. package/dist/config/index.d.ts +16 -20
  37. package/dist/config/index.js +0 -16
  38. package/dist/config/pi-config.js +6 -4
  39. package/dist/connector/index.d.ts +2 -4
  40. package/dist/connector/index.js +7 -11
  41. package/dist/{runner/dsh → dsh}/auth.js +1 -1
  42. package/dist/dsh/protocol.d.ts +9 -0
  43. package/dist/dsh/protocol.js +61 -0
  44. package/dist/index.js +10 -5
  45. package/dist/model-catalog.d.ts +14 -0
  46. package/dist/model-catalog.js +25 -0
  47. package/dist/platform/path.d.ts +9 -0
  48. package/dist/platform/path.js +18 -0
  49. package/dist/router/active-run-usage.d.ts +4 -0
  50. package/dist/router/active-run-usage.js +12 -0
  51. package/dist/router/commands/ls.js +1 -1
  52. package/dist/router/commands/order.js +1 -1
  53. package/dist/router/commands/resume.d.ts +5 -4
  54. package/dist/router/commands/resume.js +4 -4
  55. package/dist/router/commands/ws.js +1 -1
  56. package/dist/router/config/claude.js +2 -1
  57. package/dist/router/config/common/render.d.ts +5 -1
  58. package/dist/router/config/common/render.js +6 -13
  59. package/dist/router/config/dsh.d.ts +1 -1
  60. package/dist/router/config/index.d.ts +1 -1
  61. package/dist/router/config/types.d.ts +11 -3
  62. package/dist/router/index.d.ts +41 -11
  63. package/dist/router/index.js +169 -233
  64. package/dist/runner/agent-choices-common.d.ts +1 -1
  65. package/dist/runner/claude/runner.d.ts +20 -2
  66. package/dist/runner/claude/runner.js +2 -15
  67. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  68. package/dist/runner/codex/app-server/translator.d.ts +11 -0
  69. package/dist/runner/codex/app-server/translator.js +56 -149
  70. package/dist/runner/common/acp/base-acp-runner.d.ts +19 -1
  71. package/dist/runner/common/acp/base-acp-runner.js +37 -3
  72. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  73. package/dist/runner/common/jsonrpc/transport.js +17 -7
  74. package/dist/runner/dsh/client.d.ts +1 -4
  75. package/dist/runner/dsh/client.js +7 -55
  76. package/dist/runner/dsh/daemon.js +9 -2
  77. package/dist/runner/dsh/exit-cancel.js +1 -1
  78. package/dist/runner/dsh/mux-stream.js +21 -2
  79. package/dist/runner/dsh/runner.d.ts +21 -1
  80. package/dist/runner/dsh/runner.js +97 -78
  81. package/dist/runner/dsh/translator.d.ts +1 -1
  82. package/dist/runner/index.d.ts +1 -1
  83. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  84. package/dist/runner/kimi/acp/runner.js +83 -60
  85. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  86. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  87. package/dist/runner/opencode/acp/runner.d.ts +14 -1
  88. package/dist/runner/opencode/acp/runner.js +13 -2
  89. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  90. package/dist/runner/probe.d.ts +1 -1
  91. package/dist/runner/probe.js +10 -9
  92. package/dist/runner/registry.d.ts +2 -1
  93. package/dist/runner/types.d.ts +20 -154
  94. package/dist/runner/types.js +4 -24
  95. package/dist/session/claude/session-index.d.ts +11 -4
  96. package/dist/session/claude/session-index.js +30 -8
  97. package/dist/session/claude/session-reader.d.ts +1 -1
  98. package/dist/session/claude/sessions.d.ts +2 -2
  99. package/dist/session/claude/sessions.js +41 -26
  100. package/dist/session/codex/rollout-reader.d.ts +1 -1
  101. package/dist/session/codex/rollout-reader.js +14 -13
  102. package/dist/session/codex/sessions.d.ts +1 -1
  103. package/dist/session/codex/sessions.js +4 -2
  104. package/dist/session/common/jsonl.js +7 -1
  105. package/dist/session/common/pagination.d.ts +2 -0
  106. package/dist/session/common/pagination.js +1 -1
  107. package/dist/session/common/recency.d.ts +6 -0
  108. package/dist/session/common/recency.js +7 -5
  109. package/dist/session/common/two-pass.d.ts +1 -1
  110. package/dist/session/common/usage-accumulator.d.ts +1 -1
  111. package/dist/session/dsh/sessions.d.ts +1 -1
  112. package/dist/session/dsh/sessions.js +37 -12
  113. package/dist/session/kimi/sessions.d.ts +1 -1
  114. package/dist/session/kimi/sessions.js +72 -27
  115. package/dist/session/opencode/sessions.d.ts +1 -1
  116. package/dist/session/opencode/sessions.js +164 -81
  117. package/dist/session/pi/sessions.d.ts +1 -1
  118. package/dist/session/pi/sessions.js +20 -7
  119. package/dist/session/registry.d.ts +2 -1
  120. package/dist/session/session-store.d.ts +1 -2
  121. package/dist/session/session-store.js +14 -7
  122. package/dist/session-contract.d.ts +178 -0
  123. package/dist/session-contract.js +9 -0
  124. package/package.json +1 -1
  125. package/dist/router/usage-format.d.ts +0 -60
  126. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  127. /package/dist/{runner/dsh → dsh}/home.d.ts +0 -0
  128. /package/dist/{runner/dsh → dsh}/home.js +0 -0
  129. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  130. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -74,15 +74,22 @@ export declare class SessionIndex {
74
74
  private readonly byCwd;
75
75
  private lastRefreshAt;
76
76
  private built;
77
+ private scanError?;
77
78
  constructor(projectsDir: string, opts?: SessionIndexOptions);
78
79
  /** Whether a full scan has ever completed successfully (readdir succeeded). */
79
80
  get isBuilt(): boolean;
81
+ /**
82
+ * 最近一次全量扫描的非 ENOENT 失败(undefined = 上次扫描正常)。
83
+ * list 路径用它把「读不到」与「真的没有」区分开。
84
+ */
85
+ get lastScanError(): unknown;
80
86
  build(): void;
81
87
  refresh(): void;
82
- listByCwd(cwd: string, opts?: {
83
- limit?: number;
84
- offset?: number;
85
- }): SessionIndexEntry[];
88
+ /**
89
+ * 该 cwd 的全部会话(newest first)。分页不在这里:`listSessions` 的
90
+ * offset/limit 契约由 reader 用共享 `paginate()` 完成,索引只负责有序全集。
91
+ */
92
+ listByCwd(cwd: string): SessionIndexEntry[];
86
93
  /**
87
94
  * Find a session entry by sessionId and cwd.
88
95
  * Re-stats the file to verify fingerprint before returning (拍板 3):
@@ -83,6 +83,7 @@ export function parseSessionJsonl(filePath) {
83
83
  });
84
84
  // If file couldn't be opened, return empty result
85
85
  if (!ok) {
86
+ getLogger().debug(`[session-index] unreadable session file ${filePath}, treating as empty`);
86
87
  return { cwdSet: new Set(), summary: '(无摘要)' };
87
88
  }
88
89
  return { cwdSet, summary };
@@ -98,7 +99,12 @@ export function buildFingerprint(filePath) {
98
99
  try {
99
100
  st = fs.statSync(filePath, { bigint: true });
100
101
  }
101
- catch {
102
+ catch (err) {
103
+ // ENOENT = 文件在扫描间隙被删除(正常竞态)。其余(EACCES/EBUSY…)留痕,
104
+ // 否则该会话会静默从 /resume 列表里消失。
105
+ if (err.code !== 'ENOENT') {
106
+ getLogger().warn(`[session-index] cannot stat ${filePath}: ${err?.message ?? String(err)}`);
107
+ }
102
108
  return '';
103
109
  }
104
110
  const parts = [String(st.size), String(st.mtimeNs)];
@@ -153,6 +159,7 @@ export class SessionIndex {
153
159
  byCwd = new Map();
154
160
  lastRefreshAt = 0;
155
161
  built = false;
162
+ scanError;
156
163
  constructor(projectsDir, opts = {}) {
157
164
  this.projectsDir = projectsDir;
158
165
  this.refreshIntervalMs = opts.refreshIntervalMs ?? 5000;
@@ -162,6 +169,13 @@ export class SessionIndex {
162
169
  get isBuilt() {
163
170
  return this.built;
164
171
  }
172
+ /**
173
+ * 最近一次全量扫描的非 ENOENT 失败(undefined = 上次扫描正常)。
174
+ * list 路径用它把「读不到」与「真的没有」区分开。
175
+ */
176
+ get lastScanError() {
177
+ return this.scanError;
178
+ }
165
179
  // ── Build (initial full scan) ───────────────────────────
166
180
  build() {
167
181
  this.fullScan();
@@ -177,7 +191,11 @@ export class SessionIndex {
177
191
  this.lastRefreshAt = now;
178
192
  }
179
193
  // ── Query: listByCwd ────────────────────────────────────
180
- listByCwd(cwd, opts = {}) {
194
+ /**
195
+ * 该 cwd 的全部会话(newest first)。分页不在这里:`listSessions` 的
196
+ * offset/limit 契约由 reader 用共享 `paginate()` 完成,索引只负责有序全集。
197
+ */
198
+ listByCwd(cwd) {
181
199
  const paths = this.byCwd.get(pathKey(cwd, { platform: this.platform }));
182
200
  if (!paths || paths.size === 0)
183
201
  return [];
@@ -194,10 +212,7 @@ export class SessionIndex {
194
212
  }
195
213
  const sorted = [...byId.values()];
196
214
  sortByRecencyDesc(sorted, (s) => s.mtimeMs, (s) => s.sessionId);
197
- const offset = opts.offset ?? 0;
198
- const limit = opts.limit;
199
- const sliced = limit === undefined ? sorted.slice(offset) : sorted.slice(offset, offset + limit);
200
- return sliced;
215
+ return sorted;
201
216
  }
202
217
  // ── Query: findBySessionIdAndCwd ────────────────────────
203
218
  /**
@@ -261,11 +276,18 @@ export class SessionIndex {
261
276
  try {
262
277
  entries = fs.readdirSync(this.projectsDir);
263
278
  }
264
- catch {
279
+ catch (err) {
265
280
  // readdir failure → preserve old index, degrade to fallback
266
- getLogger().warn('[session-index] readdir failed, preserving old index');
281
+ if (err.code === 'ENOENT') {
282
+ // projectsDir 不存在 = 全新环境(真空态),不是故障。
283
+ this.scanError = undefined;
284
+ return;
285
+ }
286
+ this.scanError = err;
287
+ getLogger().warn(`[session-index] readdir ${this.projectsDir} failed, preserving old index: ${err?.message ?? String(err)}`);
267
288
  return;
268
289
  }
290
+ this.scanError = undefined;
269
291
  // readdir succeeded → the snapshot is authoritative for what we could read.
270
292
  this.built = true;
271
293
  const currentPaths = new Set();
@@ -1,4 +1,4 @@
1
- import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../session-contract.js';
2
2
  /**
3
3
  * Adapter that exposes Claude Code's session-reading functions through the
4
4
  * unified `AgentSessionReader` interface. This lets `SessionReaderRegistry`
@@ -1,4 +1,4 @@
1
- import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../session-contract.js';
2
2
  /**
3
3
  * List Claude Code sessions for a given cwd, newest first. Each entry has
4
4
  * the sessionId (= jsonl filename stem), a one-line summary extracted from
@@ -15,7 +15,7 @@ export declare function getNewestSession(cwd: string, opts?: {
15
15
  }): AgentSession | undefined;
16
16
  /**
17
17
  * Check if a Claude session (by sessionId + cwd) is still active.
18
- * Public wrapper around the internal `isSessionActive(filePath, mtimeMs, now)`
18
+ * Public wrapper around the internal `hasRecentFileActivity(filePath, mtimeMs, now)`
19
19
  * for use by `ClaudeSessionReader` and the multi-agent adapter layer.
20
20
  *
21
21
  * Resolves the jsonl path via `projectDirForCwd`, stats it for mtime, then
@@ -6,6 +6,7 @@ import { isStale } from '../common/constants.js';
6
6
  import { samePath, encodeProjectDirName } from '../../platform/path.js';
7
7
  import { extractContentBlocks } from '../common/content-blocks.js';
8
8
  import { scanJsonlOnce, tailOffsetAfter, scanTailEvents } from '../common/two-pass.js';
9
+ import { getLogger } from '../../logger/index.js';
9
10
  import { UsageAccumulator, contextWindowOccupancy, cumulativeUsageFields, } from '../common/usage-accumulator.js';
10
11
  import { SessionIndex, extractText, isTaskNotificationText, CLAUDE_MAPPING, } from './session-index.js';
11
12
  /**
@@ -61,7 +62,10 @@ function findSessionFileInProjects(sessionId, projectsDir, cwd) {
61
62
  try {
62
63
  entries = fs.readdirSync(projectsDir);
63
64
  }
64
- catch {
65
+ catch (err) {
66
+ if (err.code !== 'ENOENT') {
67
+ getLogger().debug(`[claude-session-reader] cannot read projects dir ${projectsDir}: ${err?.message ?? String(err)}`);
68
+ }
65
69
  return undefined;
66
70
  }
67
71
  const name = `${sessionId}.jsonl`;
@@ -123,6 +127,13 @@ export function listClaudeSessions(cwd, opts = {}) {
123
127
  summary: e.summary,
124
128
  mtime: e.mtimeMs,
125
129
  }));
130
+ // 空列表 + 上次扫描是非 ENOENT 失败 → 是「读不到」而非「真的没有」。
131
+ // 与 opencode/kimi/pi 同口径:router 显示「列表失败」,别假装空态。
132
+ if (sessions.length === 0 && index.lastScanError !== undefined) {
133
+ const err = index.lastScanError;
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ throw new Error(`claude session list 读取失败: ${msg}`, { cause: err });
136
+ }
126
137
  return limit === undefined ? sessions : sessions.slice(0, limit);
127
138
  }
128
139
  /** Get the newest session for a given cwd. Returns undefined if none exist. */
@@ -145,7 +156,15 @@ export function getNewestSession(cwd, opts = {}) {
145
156
  *
146
157
  * Override `now` for tests.
147
158
  */
148
- function isSessionActive(filePath, mtimeMs, now = Date.now()) {
159
+ /** 末行是这些类型之一 = 会话已收尾(模块级:逐文件判定不必每次重建 Set)。 */
160
+ const CLAUDE_TERMINAL_TYPES = new Set(['result', 'last-prompt', 'mode', 'permission-mode']);
161
+ /**
162
+ * 文件侧的活跃判定:mtime 新鲜 + 末行没有终态标记。
163
+ *
164
+ * 刻意不叫 `isSessionActive`——那是 reader 契约上的方法名,语义各家自己定;
165
+ * 本函数只看这一个文件,两者同名不同义曾让人把「最近有写入」当成「turn 在跑」。
166
+ */
167
+ function hasRecentFileActivity(filePath, mtimeMs, now = Date.now()) {
149
168
  // Hard signal: file untouched for over an hour → can't be from a live process
150
169
  if (isStale(mtimeMs, now))
151
170
  return false;
@@ -158,8 +177,7 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
158
177
  // `last-prompt` / `mode` (no `result` event) to mark turn end, so a
159
178
  // 24-minute-old session with a last-prompt tail is already done
160
179
  // (regression 2026-06-21: previously falsely reported as active).
161
- const TERMINAL_TYPES = new Set(['result', 'last-prompt', 'mode', 'permission-mode']);
162
- if (typeof obj.type === 'string' && TERMINAL_TYPES.has(obj.type))
180
+ if (typeof obj.type === 'string' && CLAUDE_TERMINAL_TYPES.has(obj.type))
163
181
  return false;
164
182
  }
165
183
  catch {
@@ -167,9 +185,18 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
167
185
  }
168
186
  return true;
169
187
  }
188
+ /**
189
+ * stat 失败:ENOENT = 文件在两次查询之间消失(正常竞态);其余 errno 必须留痕,
190
+ * 否则「文件不可读」会被当成「会话不活跃」。
191
+ */
192
+ function logStatFailure(filePath, err) {
193
+ if (err.code === 'ENOENT')
194
+ return;
195
+ getLogger().debug(`[claude-session-reader] stat ${filePath} failed: ${err?.message ?? String(err)}`);
196
+ }
170
197
  /**
171
198
  * Check if a Claude session (by sessionId + cwd) is still active.
172
- * Public wrapper around the internal `isSessionActive(filePath, mtimeMs, now)`
199
+ * Public wrapper around the internal `hasRecentFileActivity(filePath, mtimeMs, now)`
173
200
  * for use by `ClaudeSessionReader` and the multi-agent adapter layer.
174
201
  *
175
202
  * Resolves the jsonl path via `projectDirForCwd`, stats it for mtime, then
@@ -187,9 +214,10 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
187
214
  if (entry) {
188
215
  try {
189
216
  const st = fs.statSync(entry.path);
190
- return isSessionActive(entry.path, st.mtimeMs);
217
+ return hasRecentFileActivity(entry.path, st.mtimeMs);
191
218
  }
192
- catch {
219
+ catch (err) {
220
+ logStatFailure(entry.path, err);
193
221
  return false;
194
222
  }
195
223
  }
@@ -210,9 +238,10 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
210
238
  return false;
211
239
  try {
212
240
  const st = fs.statSync(filePath);
213
- return isSessionActive(filePath, st.mtimeMs);
241
+ return hasRecentFileActivity(filePath, st.mtimeMs);
214
242
  }
215
- catch {
243
+ catch (err) {
244
+ logStatFailure(filePath, err);
216
245
  return false;
217
246
  }
218
247
  }
@@ -411,23 +440,9 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
411
440
  return { events: [] };
412
441
  }
413
442
  }
414
- // First pass STREAMS the file once via
415
- // `scanJsonlLines` (no `string[]` materialized), parsing each line once to
416
- // collect scalars (usage, tailOffset, aiTitle, recap, lastUserMessage)
417
- // while retaining NO parsed objects and NO whole-file line array. The
418
- // tailOffset is the byte offset where the tail begins (start of the line
419
- // after the last user message). A second pass re-reads ONLY the tail from
420
- // that offset via `readJsonlLinesFromOffset` and re-parses it for events —
421
- // O(tail) memory for both raw line strings and parsed objects, instead of
422
- // O(whole file).
423
- //
424
- // Parse ratio: ≈1.0–1.5× line count when a user message exists (streaming
425
- // full-file scan + tail re-parse of the usually-short post-user lines).
426
- // When the session has NO user message, tailOffset stays -1 and the tail
427
- // IS the whole file → scan(N) + tail(N) = 2.0× — the known asymptotic
428
- // upper bound of the body's two-phase design (still better than pre-'s
429
- // ~3×). Total parse count adds the index-miss fallback guard's fixed
430
- // early-stop overhead (~2 parses, independent of line count).
443
+ // Two-pass 扫描(pass 1 流式收标量 + pass 2 只重读 tail)的口径与解析比
444
+ // 见 `src/session/common/two-pass.ts` 头注释;claude 侧只多一项:索引 miss
445
+ // 时的 fallback 守卫另有固定 ~2 次早停解析,与行数无关。
431
446
  const scan = scalarScan(filePath);
432
447
  // 有 usage 即返回:非累计字段已是末轮 scope,命中判定用 session 累计兜底
433
448
  // (末轮可能 input/output 为 0 而仍有 cacheRead,累计口径不会被末轮遮挡)。
@@ -23,7 +23,7 @@
23
23
  * Note: This handles the **rollout file format** (historical logging), NOT the
24
24
  * `codex exec --json` stdout format(已随 exec 模式移除)。
25
25
  */
26
- import type { AgentSessionContentEvent, SessionContent, SessionSummary, AgentSessionUsage } from '../../runner/index.js';
26
+ import type { AgentSessionContentEvent, AgentSessionUsage, SessionContent, SessionSummary } from '../../session-contract.js';
27
27
  interface CodexRolloutEntry {
28
28
  threadId: string;
29
29
  cwd: string;
@@ -32,6 +32,7 @@ import { paginate, capEvents } from '../common/pagination.js';
32
32
  import { sortByRecencyDesc } from '../common/recency.js';
33
33
  import { getLogger } from '../../logger/index.js';
34
34
  import { isRecord, stringValue } from '../../common/guards.js';
35
+ import { TtlCache } from '../../common/ttl-cache.js';
35
36
  import { resolveCodexHome } from '../../config/codex-config.js';
36
37
  /** Field-wise difference (total - previous) for cumulative->incremental. */
37
38
  function subtractRawUsage(a, b) {
@@ -623,7 +624,12 @@ function safeIsDir(p) {
623
624
  }
624
625
  }
625
626
  const INDEX_TTL_MS = 5_000; // 5 seconds — covers a single /resume flow
626
- const sessionIndexCache = new Map();
627
+ /**
628
+ * codexHome → 会话索引。共享 `TtlCache`(TTL 由写入时间判定 + LRU 上限),
629
+ * 与 kimi/pi/opencode 三个 reader 同一套语义。生产侧 `CodexSessionReader`
630
+ * 构造时就把 codexHome 解析定死,实际只会用到 1 个键;上限是防漏的界。
631
+ */
632
+ const sessionIndexCache = new TtlCache(INDEX_TTL_MS, 4);
627
633
  /**
628
634
  * Build or return cached session index for a given resolved codexHome.
629
635
  * The index maps sessionId → {filePath, cwd, mtimeMs}.
@@ -635,19 +641,14 @@ const sessionIndexCache = new Map();
635
641
  * AFTER the cache was built — but still within the
636
642
  * 5s TTL — is found without waiting for expiry.
637
643
  *
638
- * Cache shape note: `sessionIndexCache` is module-level and keyed by the
639
- * resolved codexHome string. In production `CodexSessionReader` resolves
640
- * codexHome once in its constructor and reuses it for every call, so a single
641
- * bridge instance produces exactly one cache entry — the Map does NOT grow
642
- * unbounded. The TTL refreshes the value on each rebuild; entries are never
643
- * evicted, but there is effectively just one.
644
+ * Cache shape note: the cache is module-level and keyed by the resolved
645
+ * codexHome string; `TtlCache` caps entries (LRU) so a caller that injects
646
+ * many distinct codexHomes cannot grow memory without bound.
644
647
  */
645
648
  function getSessionIndex(resolvedCodexHome, forceRefresh = false) {
646
- const cacheKey = resolvedCodexHome;
647
- const cached = sessionIndexCache.get(cacheKey);
648
- if (!forceRefresh && cached && Date.now() - cached.builtAt < INDEX_TTL_MS) {
649
- return cached.index;
650
- }
649
+ const cached = forceRefresh ? undefined : sessionIndexCache.get(resolvedCodexHome);
650
+ if (cached)
651
+ return cached;
651
652
  const sessionsDir = path.join(resolvedCodexHome, 'sessions');
652
653
  const files = walkRolloutFiles(sessionsDir);
653
654
  const index = new Map();
@@ -704,7 +705,7 @@ function getSessionIndex(resolvedCodexHome, forceRefresh = false) {
704
705
  continue;
705
706
  }
706
707
  }
707
- sessionIndexCache.set(cacheKey, { index, builtAt: Date.now() });
708
+ sessionIndexCache.set(resolvedCodexHome, index);
708
709
  return index;
709
710
  }
710
711
  /** Clear the session index cache. Exposed for tests. */
@@ -1,4 +1,4 @@
1
- import type { AgentSession, SessionContent, SessionSummary, AgentSessionReader } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionReader, SessionContent, SessionSummary } from '../../session-contract.js';
2
2
  interface CodexSessionReaderOptions {
3
3
  codexHome?: string;
4
4
  }
@@ -1,4 +1,6 @@
1
1
  import { resolveCodexHome } from '../../config/codex-config.js';
2
+ import { DEFAULT_LIST_LIMIT } from '../common/pagination.js';
3
+ import { newestOf } from '../common/recency.js';
2
4
  import { listCodexRollouts, readCodexSessionContent, readCodexSessionSummary, isCodexSessionActive, } from './rollout-reader.js';
3
5
  export class CodexSessionReader {
4
6
  codexHome;
@@ -9,7 +11,7 @@ export class CodexSessionReader {
9
11
  const result = listCodexRollouts({
10
12
  codexHome: this.codexHome,
11
13
  cwd,
12
- limit: opts?.limit ?? 20,
14
+ limit: opts?.limit ?? DEFAULT_LIST_LIMIT,
13
15
  offset: opts?.offset,
14
16
  });
15
17
  return {
@@ -25,7 +27,7 @@ export class CodexSessionReader {
25
27
  };
26
28
  }
27
29
  getNewestSession(cwd) {
28
- return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
30
+ return newestOf(this, cwd);
29
31
  }
30
32
  readSessionContent(sessionId, cwd, opts) {
31
33
  return readCodexSessionContent(sessionId, {
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs';
2
+ import { getLogger } from '../../logger/index.js';
2
3
  /**
3
4
  * Stream a JSONL file line-by-line using chunked reads.
4
5
  *
@@ -55,7 +56,12 @@ function forEachJsonlLineCore(filePath, opts) {
55
56
  try {
56
57
  fd = fs.openSync(filePath, 'r');
57
58
  }
58
- catch {
59
+ catch (err) {
60
+ // ENOENT 是正常空态(陈旧 cwd / 会话已被清理);其余(EACCES/EBUSY…)
61
+ // 必须留痕:否则「文件打不开」在 /resume 上与「没有会话」不可区分。
62
+ if (err.code !== 'ENOENT') {
63
+ getLogger().warn(`[jsonl] cannot open ${filePath}: ${err?.message ?? String(err)}`);
64
+ }
59
65
  return false;
60
66
  }
61
67
  const CHUNK_SIZE = 65536;
@@ -7,6 +7,8 @@
7
7
  * drifted on the default limit). Centralized here so the contract is defined
8
8
  * once: default page limit is 20, matching the majority of readers.
9
9
  */
10
+ /** Default page size for listSessions. */
11
+ export declare const DEFAULT_LIST_LIMIT = 20;
10
12
  /**
11
13
  * Paginate a (already-ordered) full item list by offset + limit.
12
14
  * Clamps negative offset to 0; caps limit to the remaining items.
@@ -8,7 +8,7 @@
8
8
  * once: default page limit is 20, matching the majority of readers.
9
9
  */
10
10
  /** Default page size for listSessions. */
11
- const DEFAULT_LIST_LIMIT = 20;
11
+ export const DEFAULT_LIST_LIMIT = 20;
12
12
  /**
13
13
  * Paginate a (already-ordered) full item list by offset + limit.
14
14
  * Clamps negative offset to 0; caps limit to the remaining items.
@@ -3,5 +3,11 @@
3
3
  * with a deterministic id tie-break so index rebuilds / walk-order changes
4
4
  * never reorder a page (codex red line: never break out of a partial order).
5
5
  */
6
+ import type { AgentSessionReader } from '../../session-contract.js';
6
7
  /** 「时间降序 + id 字典序 tie-break」原地排序(原先 6 处内联拷贝)。 */
7
8
  export declare function sortByRecencyDesc<T>(items: T[], time: (item: T) => number, id: (item: T) => string): void;
9
+ /**
10
+ * 列表首项 = 最新会话(`getNewestSession` 的唯一实现,此前 5 个 reader 逐字各抄一遍)。
11
+ * 「newest first」是 `AgentSessionReader.listSessions` 的契约,这里只取第一页第一条。
12
+ */
13
+ export declare function newestOf(reader: Pick<AgentSessionReader, 'listSessions'>, cwd: string): import("../../session-contract.js").AgentSession;
@@ -1,10 +1,12 @@
1
- /**
2
- * Session-list ordering invariant shared by every reader: newest first,
3
- * with a deterministic id tie-break so index rebuilds / walk-order changes
4
- * never reorder a page (codex red line: never break out of a partial order).
5
- */
6
1
  /** 「时间降序 + id 字典序 tie-break」原地排序(原先 6 处内联拷贝)。 */
7
2
  export function sortByRecencyDesc(items, time, id) {
8
3
  items.sort((a, b) => time(b) - time(a) || id(a).localeCompare(id(b)));
9
4
  }
5
+ /**
6
+ * 列表首项 = 最新会话(`getNewestSession` 的唯一实现,此前 5 个 reader 逐字各抄一遍)。
7
+ * 「newest first」是 `AgentSessionReader.listSessions` 的契约,这里只取第一页第一条。
8
+ */
9
+ export function newestOf(reader, cwd) {
10
+ return reader.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
11
+ }
10
12
  //# sourceMappingURL=recency.js.map
@@ -1,4 +1,4 @@
1
- import type { AgentSessionContentEvent } from '../../runner/types.js';
1
+ import type { AgentSessionContentEvent } from '../../session-contract.js';
2
2
  /**
3
3
  * Byte offset where the tail begins: the start of the line AFTER `line`.
4
4
  * `offset` is the byte offset of `line` itself; +1 accounts for the trailing
@@ -77,5 +77,5 @@ export declare function cumulativeUsageFields(t: {
77
77
  output: number;
78
78
  cacheRead: number;
79
79
  cacheCreation: number;
80
- }): Pick<import('../../runner/types.js').AgentSessionUsage, 'cumulativeTotalTokens' | 'cumulativeInputTokens' | 'cumulativeOutputTokens' | 'cumulativeCacheReadTokens' | 'cumulativeCacheCreationTokens'>;
80
+ }): Pick<import('../../session-contract.js').AgentSessionUsage, 'cumulativeTotalTokens' | 'cumulativeInputTokens' | 'cumulativeOutputTokens' | 'cumulativeCacheReadTokens' | 'cumulativeCacheCreationTokens'>;
81
81
  export {};
@@ -13,7 +13,7 @@
13
13
  * session-wide usage accumulation (assistant/message.usage);本 run 的 flow
14
14
  * 字段 = 末个 turn(turn/start–turn/end)内所有采样之和,与 codex 同口径。
15
15
  */
16
- import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
16
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../session-contract.js';
17
17
  interface DshSessionReaderOptions {
18
18
  host?: string;
19
19
  /** Configured DSH home; selects `.credentials.yaml` when credentialsFile is unset. */
@@ -15,13 +15,13 @@
15
15
  */
16
16
  import { spawnProcessSync } from '../../platform/spawn.js';
17
17
  import { cumulativeUsageFields } from '../common/usage-accumulator.js';
18
- import { sortByRecencyDesc } from '../common/recency.js';
18
+ import { newestOf, sortByRecencyDesc } from '../common/recency.js';
19
19
  import { getLogger } from '../../logger/index.js';
20
20
  import { DEFAULT_DSH_HOST } from '../../config/index.js';
21
21
  import { samePath } from '../../platform/path.js';
22
22
  import { isWin32 } from '../../platform/select.js';
23
- import { dshArgs, extractDshToken } from '../../runner/dsh/client.js';
24
- import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecretSync, resolveDshCredentialsFile, } from '../../runner/dsh/auth.js';
23
+ import { dshArgs, extractDshToken } from '../../dsh/protocol.js';
24
+ import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecretSync, resolveDshCredentialsFile, } from '../../dsh/auth.js';
25
25
  import { capEvents, paginate } from '../common/pagination.js';
26
26
  import { truncateUtf8, TOOL_RESULT_MAX_BYTES } from '../../common/truncate.js';
27
27
  import { summarizeToolInput } from '../../common/tool-summary.js';
@@ -50,13 +50,23 @@ function defaultSyncRequest(baseUrl, method, payload, cookie) {
50
50
  ...(cookie ? ['-H', `Cookie: ${cookie}`] : []),
51
51
  '--data',
52
52
  body,
53
+ // 末尾追加 HTTP 状态码:401/403 要与「偶发失败」区分(cookie 失效才值得重试)。
54
+ '--write-out',
55
+ '\n%{http_code}',
53
56
  ], { encoding: 'utf-8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 });
54
57
  if (res.error || res.status !== 0) {
55
58
  throw new Error(`DSH ${method} failed: ${(res.error?.message ?? res.stderr) || 'unknown'}`);
56
59
  }
60
+ const raw = String(res.stdout);
61
+ const statusMatch = /\n(\d{3})$/.exec(raw);
62
+ const httpStatus = statusMatch ? Number(statusMatch[1]) : undefined;
63
+ const bodyText = statusMatch ? raw.slice(0, statusMatch.index) : raw;
64
+ if (httpStatus === 401 || httpStatus === 403) {
65
+ throw new Error(`DSH ${method} unauthorized (HTTP ${httpStatus})`);
66
+ }
57
67
  let parsed;
58
68
  try {
59
- parsed = JSON.parse(String(res.stdout));
69
+ parsed = JSON.parse(bodyText);
60
70
  }
61
71
  catch (err) {
62
72
  throw new Error(`DSH ${method} returned non-JSON: ${err.message}`, {
@@ -82,6 +92,11 @@ function defaultSyncConnect(baseUrl, token) {
82
92
  throw new Error('DSH auth failed: index handshake did not return a cookie');
83
93
  return cookie;
84
94
  }
95
+ /** Cookie 失效的判定:HTTP 401/403 或服务端回报 unauthorized/forbidden。 */
96
+ function isAuthFailure(err) {
97
+ const msg = err instanceof Error ? err.message : String(err);
98
+ return /\b401\b|\b403\b|unauthori[sz]ed|forbidden|invalid[ -]?cookie/i.test(msg);
99
+ }
85
100
  export class DshSessionReader {
86
101
  hostProvider;
87
102
  dshHomeProvider;
@@ -121,11 +136,23 @@ export class DshSessionReader {
121
136
  try {
122
137
  return this.syncRequest(baseUrl, method, payload, this.resolveCookie());
123
138
  }
124
- catch {
125
- // DSH may have restarted while lark-remote cached a token-derived cookie.
126
- // Re-read the credentials file and retry once with a fresh cookie.
139
+ catch (first) {
140
+ // 只有 cookie 失效值得清缓存重试。404/500/timeout 重试会让 /resume 延迟
141
+ // 翻倍,且第二次错误会盖掉真正的根因。
142
+ if (!isAuthFailure(first))
143
+ throw first;
144
+ getLogger().warn(`[dsh-session-reader] ${method} auth failed, refreshing cookie and retrying: ${first?.message ?? String(first)}`);
127
145
  this.cookieCache.clear();
128
- return this.syncRequest(baseUrl, method, payload, this.resolveCookie());
146
+ try {
147
+ return this.syncRequest(baseUrl, method, payload, this.resolveCookie());
148
+ }
149
+ catch (second) {
150
+ // 重试仍失败:首错(cookie 失效)才是根因。`cause` 必须是本次捕获的 err
151
+ // (lint preserve-caught-error),所以根因写进 message 一并留痕。
152
+ const firstMsg = first?.message ?? String(first);
153
+ const secondMsg = second?.message ?? String(second);
154
+ throw new Error(`DSH ${method} failed after cookie refresh: ${secondMsg} (root cause: ${firstMsg})`, { cause: second });
155
+ }
129
156
  }
130
157
  }
131
158
  /** Mint from the durable secret, or bootstrap and cache a token cookie. */
@@ -186,7 +213,7 @@ export class DshSessionReader {
186
213
  }
187
214
  }
188
215
  getNewestSession(cwd) {
189
- return this.listSessions(cwd, { limit: 1 }).sessions[0] ?? null;
216
+ return newestOf(this, cwd);
190
217
  }
191
218
  readSessionContent(sessionId, cwd, opts) {
192
219
  try {
@@ -354,9 +381,7 @@ export class DshSessionReader {
354
381
  const projection = this.unary('session/projections', { sessionId });
355
382
  if (typeof projection?.asOfSeq === 'number')
356
383
  return projection.asOfSeq;
357
- const row = known ??
358
- this.unary('session/list', {}).items?.find((item) => String(item.sessionId) === sessionId);
359
- return typeof row?.projections?.asOfSeq === 'number' ? row.projections.asOfSeq : -1;
384
+ return typeof known.projections?.asOfSeq === 'number' ? known.projections.asOfSeq : -1;
360
385
  }
361
386
  }
362
387
  /** Preset moved from the summary top level to projections in DSH 0.2.x. */
@@ -1,4 +1,4 @@
1
- import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../session-contract.js';
2
2
  export interface KimiSessionState {
3
3
  version?: number;
4
4
  createdAt: string | number;