lark-remote 0.1.11 → 0.1.13

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 (71) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/bridge/inbound-media.d.ts +0 -3
  3. package/dist/bridge/inbound-media.js +0 -1
  4. package/dist/bridge/index.d.ts +7 -0
  5. package/dist/bridge/index.js +79 -70
  6. package/dist/card/card-shared.d.ts +1 -4
  7. package/dist/card/card-shared.js +2 -4
  8. package/dist/common/truncate.d.ts +0 -6
  9. package/dist/common/truncate.js +1 -1
  10. package/dist/common/ttl-cache.d.ts +24 -0
  11. package/dist/common/ttl-cache.js +53 -0
  12. package/dist/config/codex-config.d.ts +22 -0
  13. package/dist/config/codex-config.js +120 -25
  14. package/dist/config/index.d.ts +0 -3
  15. package/dist/config/index.js +0 -5
  16. package/dist/config/kimi-config.d.ts +1 -1
  17. package/dist/config/kimi-config.js +1 -1
  18. package/dist/config/opencode-config.d.ts +1 -1
  19. package/dist/config/opencode-config.js +3 -8
  20. package/dist/index.js +6 -0
  21. package/dist/router/index.d.ts +7 -0
  22. package/dist/router/index.js +229 -284
  23. package/dist/runner/claude/session.d.ts +0 -3
  24. package/dist/runner/claude/session.js +0 -5
  25. package/dist/runner/codex/app-server/runner.d.ts +1 -16
  26. package/dist/runner/codex/app-server/runner.js +2 -2
  27. package/dist/runner/common/acp/protocol-helpers.d.ts +31 -0
  28. package/dist/runner/common/acp/protocol-helpers.js +48 -0
  29. package/dist/runner/common/connection-based-runner.d.ts +0 -2
  30. package/dist/runner/common/connection-based-runner.js +1 -1
  31. package/dist/runner/dsh/index.d.ts +0 -2
  32. package/dist/runner/dsh/index.js +0 -2
  33. package/dist/runner/dsh/runner.d.ts +0 -2
  34. package/dist/runner/dsh/runner.js +1 -1
  35. package/dist/runner/index.d.ts +1 -1
  36. package/dist/runner/kimi/acp/runner.d.ts +18 -11
  37. package/dist/runner/kimi/acp/runner.js +95 -87
  38. package/dist/runner/kimi/acp/translator.d.ts +0 -1
  39. package/dist/runner/kimi/acp/translator.js +5 -13
  40. package/dist/runner/opencode/acp/error-monitor.d.ts +61 -0
  41. package/dist/runner/opencode/acp/error-monitor.js +150 -0
  42. package/dist/runner/opencode/acp/runner.d.ts +17 -0
  43. package/dist/runner/opencode/acp/runner.js +28 -42
  44. package/dist/runner/opencode/acp/translator.d.ts +0 -1
  45. package/dist/runner/opencode/acp/translator.js +3 -13
  46. package/dist/runner/pi/rpc/index.d.ts +0 -2
  47. package/dist/runner/pi/rpc/index.js +0 -2
  48. package/dist/runner/resolve-agent-choices.js +6 -2
  49. package/dist/runner/sync-agent-choices.js +1 -1
  50. package/dist/runner/types.d.ts +23 -0
  51. package/dist/session/claude/session-index.js +3 -1
  52. package/dist/session/claude/sessions.js +3 -7
  53. package/dist/session/codex/rollout-reader.d.ts +12 -2
  54. package/dist/session/codex/rollout-reader.js +259 -173
  55. package/dist/session/codex/sessions.d.ts +2 -1
  56. package/dist/session/codex/sessions.js +4 -1
  57. package/dist/session/common/pagination.d.ts +0 -2
  58. package/dist/session/common/pagination.js +1 -1
  59. package/dist/session/common/recency.d.ts +7 -0
  60. package/dist/session/common/recency.js +10 -0
  61. package/dist/session/common/usage-accumulator.d.ts +14 -0
  62. package/dist/session/common/usage-accumulator.js +15 -0
  63. package/dist/session/dsh/sessions.js +9 -6
  64. package/dist/session/kimi/sessions.d.ts +18 -4
  65. package/dist/session/kimi/sessions.js +49 -30
  66. package/dist/session/opencode/sessions.d.ts +2 -14
  67. package/dist/session/opencode/sessions.js +9 -7
  68. package/dist/session/pi/sessions.js +10 -22
  69. package/dist/update/index.d.ts +2 -2
  70. package/dist/update/index.js +1 -1
  71. package/package.json +1 -2
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { readJsonlLines, findJsonlLine } from '../common/jsonl.js';
19
19
  import { STALE_MS } from '../common/constants.js';
20
20
  import { paginate, capEvents } from '../common/pagination.js';
21
+ import { sortByRecencyDesc } from '../common/recency.js';
21
22
  import { getLogger } from '../../logger/index.js';
22
23
  import { isRecord, stringValue } from '../../common/guards.js';
23
24
  import { resolveCodexHome } from '../../config/codex-config.js';
@@ -35,130 +36,8 @@ export function readCodexRollout(filePath) {
35
36
  return null;
36
37
  }
37
38
  try {
38
- const lines = readJsonlLines(filePath);
39
- const events = [];
40
- let sessionMeta = null;
41
- // Real user input is identified by a paired `event_msg` whose
42
- // payload.type === "user_message" - codex emits this ONLY for text the
43
- // human actually typed. Injected scaffolding (project rules,
44
- // <environment_context>, permissions) is also written as `role:"user"`
45
- // response_items but has NO user_message event, so it must be excluded
46
- // from displayTitle/recap/summary. Regression 2026-07-13: the first
47
- // `role:user` message is the injected project rules, mistakenly shown as
48
- // "最近输入".
49
- const realUserMessages = [];
50
- // token_count tracking: codex emits cumulative `total_token_usage` and an
51
- // incremental `last_token_usage`. We want the LAST turn's usage (matches
52
- // pi/opencode /resume "last turn" display semantics). When
53
- // last_token_usage is absent we derive it as total - previous_total.
54
- let lastRawUsage;
55
- // Context window limit from the same token_count event as lastRawUsage
56
- // (codex reports info.model_context_window per turn; absent on old data).
57
- let lastContextLimit;
58
- // Final total_token_usage (cumulative across the whole session) for the
59
- // Run card's "累计" display. Codex emits this alongside last_token_usage.
60
- let lastTotalUsage;
61
- let previousTotals;
62
- // Compaction 统计:codex 的 compact turn 在会话文件里写顶层 `compacted`
63
- // 事件(含摘要),压缩前后的上下文水位由相邻 token_count 表达。压缩收尾的
64
- // token_count 增量是 input/cached/output 全 0、只有 total_tokens 有值
65
- // (窗口被摘要+replacement history 占据),现有 zero-filter 会跳过它,
66
- // 因此这里必须单独捕获,不能复用 lastRawUsage。
67
- // 单位口径(review P3-9):前后两侧统一用 total_tokens —— 压缩前取最近一次
68
- // 非零 token_count 的 total_tokens(input+output,即该 turn 在窗口内的全部
69
- // token),压缩后取收尾事件的 total_tokens(压缩后窗口)。不得一边 input
70
- // 一边 total,否则「压缩前 X → 压缩后 Y」两边单位不一致。
71
- let compactCount = 0;
72
- let compactPreContextLength;
73
- let compactPostContextLength;
74
- for (const line of lines) {
75
- const trimmed = line.trim();
76
- if (!trimmed)
77
- continue;
78
- try {
79
- const parsed = JSON.parse(trimmed);
80
- if (!parsed || typeof parsed.type !== 'string')
81
- continue;
82
- if (parsed.type === 'session_meta' && parsed.payload) {
83
- sessionMeta = parsed.payload;
84
- }
85
- else if (parsed.type === 'event_msg' && parsed.payload) {
86
- // user_message events carry the authoritative human-typed text.
87
- const payload = parsed.payload;
88
- if (payload.type === 'user_message') {
89
- const text = stringValue(payload.message);
90
- if (text) {
91
- realUserMessages.push(text.slice(0, 200));
92
- }
93
- }
94
- else if (payload.type === 'token_count') {
95
- const info = payload.info;
96
- const last = info?.last_token_usage;
97
- const total = info?.total_token_usage;
98
- let raw;
99
- if (last) {
100
- raw = last;
101
- }
102
- else if (total && previousTotals) {
103
- raw = subtractRawUsage(total, previousTotals);
104
- }
105
- else {
106
- raw = total;
107
- }
108
- if (total) {
109
- previousTotals = total;
110
- lastTotalUsage = total;
111
- }
112
- if (raw && (raw.input_tokens || raw.cached_input_tokens || raw.output_tokens)) {
113
- lastRawUsage = raw;
114
- const ctxWindow = info?.model_context_window;
115
- lastContextLimit = typeof ctxWindow === 'number' ? ctxWindow : undefined;
116
- }
117
- // 压缩后的水位:压缩收尾 token_count 全 0(raw 不满足上面 zero-filter),
118
- // 但 total_tokens 表达真实窗口;之后若出现普通 turn(input>0),
119
- // 水位回归该 turn,post-compact 值失效。
120
- if (compactCount > 0 && raw) {
121
- if (raw.total_tokens &&
122
- !raw.input_tokens &&
123
- !raw.cached_input_tokens &&
124
- !raw.output_tokens) {
125
- compactPostContextLength = raw.total_tokens;
126
- }
127
- else if (raw.input_tokens > 0) {
128
- compactPostContextLength = undefined;
129
- }
130
- }
131
- }
132
- }
133
- else if (parsed.type === 'compacted') {
134
- // 顶层 compacted 事件:压缩次数 +1,压缩前水位取最近一次非零 token_count。
135
- compactCount++;
136
- if (lastRawUsage?.total_tokens !== undefined) {
137
- compactPreContextLength = lastRawUsage.total_tokens;
138
- }
139
- }
140
- else if (parsed.type === 'response_item' && parsed.payload) {
141
- const payload = parsed.payload;
142
- if (payload.type === 'message' && Array.isArray(payload.content)) {
143
- // Extract text content for display
144
- const messages = extractMessageContent(payload);
145
- for (const msg of messages) {
146
- events.push({
147
- type: msg.role === 'user' ? 'user' : 'assistant',
148
- content: msg.text,
149
- timestamp: parsed.timestamp,
150
- });
151
- }
152
- }
153
- }
154
- }
155
- catch {
156
- // Skip malformed lines
157
- continue;
158
- }
159
- }
160
- const firstUserMessage = realUserMessages[0] ?? '';
161
- const lastRealUserMessage = realUserMessages[realUserMessages.length - 1] ?? '';
39
+ const scan = scanCodexRollout(filePath, true);
40
+ const { sessionMeta } = scan;
162
41
  if (!sessionMeta) {
163
42
  return null;
164
43
  }
@@ -167,62 +46,18 @@ export function readCodexRollout(filePath) {
167
46
  if (!sessionId) {
168
47
  return null;
169
48
  }
170
- // Use file mtime as updatedAtMs, or parse timestamp from session_meta
171
49
  const stats = fs.statSync(filePath);
172
- const createdAtMs = sessionMeta.timestamp
173
- ? new Date(String(sessionMeta.timestamp)).getTime()
174
- : stats.birthtimeMs;
175
50
  const updatedAtMs = stats.mtimeMs;
176
- // Build ccusage-aligned usage from the last turn's raw tokens.
177
- // input = raw - cached (codex reports cached input separately), cache
178
- // creation is never reported by codex, reasoning is a subset of output
179
- // (display-only, never added to total).
180
- // contextLength = input_tokens (= (input_tokens-cached) + cached + 0),
181
- // i.e. input + cacheRead + cacheCreation — the unified context-window
182
- // occupancy contract across all five readers (review P2-8, excludes
183
- // output/reasoning).
184
- let usage;
185
- if (lastRawUsage) {
186
- const r = lastRawUsage;
187
- const cached = Math.min(r.cached_input_tokens, r.input_tokens);
188
- usage = {
189
- inputTokens: r.input_tokens - cached,
190
- outputTokens: r.output_tokens,
191
- contextLength: r.input_tokens,
192
- contextLimit: lastContextLimit,
193
- cacheReadTokens: cached,
194
- cacheCreationTokens: 0,
195
- totalTokens: r.total_tokens,
196
- };
197
- // Cumulative from the final total_token_usage (non-cached input +
198
- // output), summed across all turns in the session.
199
- if (lastTotalUsage) {
200
- const cumCached = Math.min(lastTotalUsage.cached_input_tokens, lastTotalUsage.input_tokens);
201
- usage.cumulativeInputTokens = lastTotalUsage.input_tokens - cumCached;
202
- usage.cumulativeOutputTokens = lastTotalUsage.output_tokens;
203
- usage.cumulativeTotalTokens = lastTotalUsage.total_tokens;
204
- usage.cumulativeCacheReadTokens = cumCached;
205
- usage.cumulativeCacheCreationTokens = 0; // Codex 不报告 cache creation
206
- }
207
- // 压缩统计:会话内出现过 compacted 事件就计数(供 /resume 与 Compact 卡
208
- // 展示);仅当会话以压缩收尾时(post-compact 水位存在),contextLength
209
- // 覆盖为压缩后水位并暴露压缩前水位——后续还有普通 turn 时水位回归该 turn。
210
- if (compactCount > 0) {
211
- usage.compactCount = compactCount;
212
- if (compactPostContextLength !== undefined) {
213
- usage.contextLength = compactPostContextLength;
214
- usage.compactPreContextLength = compactPreContextLength;
215
- }
216
- }
217
- }
51
+ const firstUserMessage = scan.realUserMessages[0] ?? '';
52
+ const lastRealUserMessage = scan.realUserMessages[scan.realUserMessages.length - 1] ?? '';
53
+ const usage = buildUsageFromScan(scan);
218
54
  return {
219
55
  threadId: sessionId,
220
56
  cwd,
221
57
  firstUserMessage: firstUserMessage || '(no user message)',
222
58
  lastRealUserMessage,
223
- createdAtMs,
224
59
  updatedAtMs,
225
- events,
60
+ events: scan.events,
226
61
  usage,
227
62
  };
228
63
  }
@@ -231,6 +66,222 @@ export function readCodexRollout(filePath) {
231
66
  return null;
232
67
  }
233
68
  }
69
+ /**
70
+ * Lightweight single-file scan that extracts only the display-relevant fields
71
+ * (last real user message + usage) — **without** constructing the `events`
72
+ * array. Backs `readCodexSessionSummary` so the `/resume` list prefetch does
73
+ * not pay the cost of JSON.parse-ing every response_item and building events
74
+ * that are immediately discarded.
75
+ *
76
+ * Returns null if the file doesn't exist, is corrupted, or has no session_meta.
77
+ */
78
+ function readCodexRolloutSummary(filePath) {
79
+ if (!fs.existsSync(filePath)) {
80
+ return null;
81
+ }
82
+ try {
83
+ const scan = scanCodexRollout(filePath, false);
84
+ if (!scan.sessionMeta)
85
+ return null;
86
+ return {
87
+ lastRealUserMessage: scan.realUserMessages[scan.realUserMessages.length - 1] ?? '',
88
+ usage: buildUsageFromScan(scan),
89
+ };
90
+ }
91
+ catch (err) {
92
+ getLogger().warn(`[codex-rollout-reader] failed to read summary ${filePath}: ${err}`);
93
+ return null;
94
+ }
95
+ }
96
+ /**
97
+ * Scan a rollout file once, tracking user messages, usage and compaction state.
98
+ * When `includeEvents` is true, response_item content is also extracted into
99
+ * `events`; when false, response_item lines are skipped entirely (the summary
100
+ * fast path).
101
+ */
102
+ function scanCodexRollout(filePath, includeEvents) {
103
+ const lines = readJsonlLines(filePath);
104
+ const events = [];
105
+ let sessionMeta = null;
106
+ // Real user input is identified by a paired `event_msg` whose
107
+ // payload.type === "user_message" - codex emits this ONLY for text the
108
+ // human actually typed. Injected scaffolding (project rules,
109
+ // <environment_context>, permissions) is also written as `role:"user"`
110
+ // response_items but has NO user_message event, so it must be excluded
111
+ // from displayTitle/recap/summary. Regression 2026-07-13: the first
112
+ // `role:user` message is the injected project rules, mistakenly shown as
113
+ // "最近输入".
114
+ const realUserMessages = [];
115
+ // token_count tracking: codex emits cumulative `total_token_usage` and an
116
+ // incremental `last_token_usage`. We want the LAST turn's usage (matches
117
+ // pi/opencode /resume "last turn" display semantics). When
118
+ // last_token_usage is absent we derive it as total - previous_total.
119
+ let lastRawUsage;
120
+ // Context window limit from the same token_count event as lastRawUsage
121
+ // (codex reports info.model_context_window per turn; absent on old data).
122
+ let lastContextLimit;
123
+ // Final total_token_usage (cumulative across the whole session) for the
124
+ // Run card's "累计" display. Codex emits this alongside last_token_usage.
125
+ let lastTotalUsage;
126
+ let previousTotals;
127
+ // Compaction 统计:codex 的 compact turn 在会话文件里写顶层 `compacted`
128
+ // 事件(含摘要),压缩前后的上下文水位由相邻 token_count 表达。压缩收尾的
129
+ // token_count 增量是 input/cached/output 全 0、只有 total_tokens 有值
130
+ // (窗口被摘要+replacement history 占据),现有 zero-filter 会跳过它,
131
+ // 因此这里必须单独捕获,不能复用 lastRawUsage。
132
+ // 单位口径(review P3-9):前后两侧统一用 total_tokens —— 压缩前取最近一次
133
+ // 非零 token_count 的 total_tokens(input+output,即该 turn 在窗口内的全部
134
+ // token),压缩后取收尾事件的 total_tokens(压缩后窗口)。不得一边 input
135
+ // 一边 total,否则「压缩前 X → 压缩后 Y」两边单位不一致。
136
+ let compactCount = 0;
137
+ let compactPreContextLength;
138
+ let compactPostContextLength;
139
+ for (const line of lines) {
140
+ const trimmed = line.trim();
141
+ if (!trimmed)
142
+ continue;
143
+ try {
144
+ const parsed = JSON.parse(trimmed);
145
+ if (!parsed || typeof parsed.type !== 'string')
146
+ continue;
147
+ if (parsed.type === 'session_meta' && parsed.payload) {
148
+ sessionMeta = parsed.payload;
149
+ }
150
+ else if (parsed.type === 'event_msg' && parsed.payload) {
151
+ // user_message events carry the authoritative human-typed text.
152
+ const payload = parsed.payload;
153
+ if (payload.type === 'user_message') {
154
+ const text = stringValue(payload.message);
155
+ if (text) {
156
+ realUserMessages.push(text.slice(0, 200));
157
+ }
158
+ }
159
+ else if (payload.type === 'token_count') {
160
+ const info = payload.info;
161
+ const last = info?.last_token_usage;
162
+ const total = info?.total_token_usage;
163
+ let raw;
164
+ if (last) {
165
+ raw = last;
166
+ }
167
+ else if (total && previousTotals) {
168
+ raw = subtractRawUsage(total, previousTotals);
169
+ }
170
+ else {
171
+ raw = total;
172
+ }
173
+ if (total) {
174
+ previousTotals = total;
175
+ lastTotalUsage = total;
176
+ }
177
+ if (raw && (raw.input_tokens || raw.cached_input_tokens || raw.output_tokens)) {
178
+ lastRawUsage = raw;
179
+ const ctxWindow = info?.model_context_window;
180
+ lastContextLimit = typeof ctxWindow === 'number' ? ctxWindow : undefined;
181
+ }
182
+ // 压缩后的水位:压缩收尾 token_count 全 0(raw 不满足上面 zero-filter),
183
+ // 但 total_tokens 表达真实窗口;之后若出现普通 turn(input>0),
184
+ // 水位回归该 turn,post-compact 值失效。
185
+ if (compactCount > 0 && raw) {
186
+ if (raw.total_tokens &&
187
+ !raw.input_tokens &&
188
+ !raw.cached_input_tokens &&
189
+ !raw.output_tokens) {
190
+ compactPostContextLength = raw.total_tokens;
191
+ }
192
+ else if (raw.input_tokens > 0) {
193
+ compactPostContextLength = undefined;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ else if (parsed.type === 'compacted') {
199
+ // 顶层 compacted 事件:压缩次数 +1,压缩前水位取最近一次非零 token_count。
200
+ compactCount++;
201
+ if (lastRawUsage?.total_tokens !== undefined) {
202
+ compactPreContextLength = lastRawUsage.total_tokens;
203
+ }
204
+ }
205
+ else if (includeEvents && parsed.type === 'response_item' && parsed.payload) {
206
+ const payload = parsed.payload;
207
+ if (payload.type === 'message' && Array.isArray(payload.content)) {
208
+ // Extract text content for display
209
+ const messages = extractMessageContent(payload);
210
+ for (const msg of messages) {
211
+ events.push({
212
+ type: msg.role === 'user' ? 'user' : 'assistant',
213
+ content: msg.text,
214
+ timestamp: parsed.timestamp,
215
+ });
216
+ }
217
+ }
218
+ }
219
+ }
220
+ catch {
221
+ // Skip malformed lines
222
+ continue;
223
+ }
224
+ }
225
+ return {
226
+ sessionMeta,
227
+ events,
228
+ realUserMessages,
229
+ lastRawUsage,
230
+ lastContextLimit,
231
+ lastTotalUsage,
232
+ previousTotals,
233
+ compactCount,
234
+ compactPreContextLength,
235
+ compactPostContextLength,
236
+ };
237
+ }
238
+ /**
239
+ * Build ccusage-aligned usage from a scan's last-turn raw tokens.
240
+ * input = raw - cached (codex reports cached input separately), cache
241
+ * creation is never reported by codex, reasoning is a subset of output
242
+ * (display-only, never added to total).
243
+ * contextLength = input_tokens (= (input_tokens-cached) + cached + 0),
244
+ * i.e. input + cacheRead + cacheCreation — the unified context-window
245
+ * occupancy contract across all five readers (review P2-8, excludes
246
+ * output/reasoning).
247
+ */
248
+ function buildUsageFromScan(scan) {
249
+ const { lastRawUsage, lastContextLimit, lastTotalUsage, compactCount, compactPreContextLength, compactPostContextLength, } = scan;
250
+ if (!lastRawUsage)
251
+ return undefined;
252
+ const r = lastRawUsage;
253
+ const cached = Math.min(r.cached_input_tokens, r.input_tokens);
254
+ const usage = {
255
+ inputTokens: r.input_tokens - cached,
256
+ outputTokens: r.output_tokens,
257
+ contextLength: r.input_tokens,
258
+ contextLimit: lastContextLimit,
259
+ cacheReadTokens: cached,
260
+ cacheCreationTokens: 0,
261
+ totalTokens: r.total_tokens,
262
+ };
263
+ // Cumulative from the final total_token_usage (non-cached input +
264
+ // output), summed across all turns in the session.
265
+ if (lastTotalUsage) {
266
+ const cumCached = Math.min(lastTotalUsage.cached_input_tokens, lastTotalUsage.input_tokens);
267
+ usage.cumulativeInputTokens = lastTotalUsage.input_tokens - cumCached;
268
+ usage.cumulativeOutputTokens = lastTotalUsage.output_tokens;
269
+ usage.cumulativeTotalTokens = lastTotalUsage.total_tokens;
270
+ usage.cumulativeCacheReadTokens = cumCached;
271
+ usage.cumulativeCacheCreationTokens = 0; // Codex 不报告 cache creation
272
+ }
273
+ // 压缩统计:会话内出现过 compacted 事件就计数(供 /resume 与 Compact 卡
274
+ // 展示);仅当会话以压缩收尾时(post-compact 水位存在),contextLength
275
+ // 覆盖为压缩后水位并暴露压缩前水位——后续还有普通 turn 时水位回归该 turn。
276
+ if (compactCount > 0) {
277
+ usage.compactCount = compactCount;
278
+ if (compactPostContextLength !== undefined) {
279
+ usage.contextLength = compactPostContextLength;
280
+ usage.compactPreContextLength = compactPreContextLength;
281
+ }
282
+ }
283
+ return usage;
284
+ }
234
285
  /**
235
286
  * List Codex sessions from rollout files.
236
287
  *
@@ -261,7 +312,7 @@ export function listCodexRollouts(opts = {}) {
261
312
  // Establish the global order by mtime desc, then slice the page.
262
313
  // Same-mtime ties use filePath as a deterministic secondary key so index
263
314
  // rebuilds / walk order changes never reorder the page.
264
- matched.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
315
+ sortByRecencyDesc(matched, (e) => e.mtimeMs, (e) => e.filePath);
265
316
  const { items: page, total } = paginate(matched, { limit: opts.limit, offset: opts.offset });
266
317
  // Full-parse only the page being returned (summary/usage).
267
318
  const entries = [];
@@ -327,6 +378,41 @@ export function readCodexSessionContent(sessionId, opts = {}) {
327
378
  usage: rollout.usage,
328
379
  };
329
380
  }
381
+ /**
382
+ * Read the summary (title + usage, no events) of a specific session by id.
383
+ *
384
+ * Mirrors `readCodexSessionContent`'s index lookup + cwd guard, but uses the
385
+ * lightweight `readCodexRolloutSummary` instead of the full parse — so the
386
+ * `/resume` list prefetch never constructs the events array it would discard.
387
+ */
388
+ export function readCodexSessionSummary(sessionId, opts = {}) {
389
+ const codexHome = resolveCodexHome(opts.codexHome);
390
+ let index = getSessionIndex(codexHome);
391
+ let entry = index.get(sessionId);
392
+ if (!entry) {
393
+ index = getSessionIndex(codexHome, true);
394
+ entry = index.get(sessionId);
395
+ }
396
+ if (!entry) {
397
+ return {};
398
+ }
399
+ // Cwd guard: same semantics as readCodexSessionContent — when a cwd is
400
+ // provided, the session's working directory must match.
401
+ if (opts.cwd && entry.cwd !== opts.cwd) {
402
+ return {};
403
+ }
404
+ const summary = readCodexRolloutSummary(entry.filePath);
405
+ if (!summary) {
406
+ return {};
407
+ }
408
+ return {
409
+ // displayTitle = LAST real user message (matches the "最近输入" label).
410
+ displayTitle: summary.lastRealUserMessage || undefined,
411
+ // codex has no compact-summary concept, so recap is never faked.
412
+ recap: undefined,
413
+ usage: summary.usage,
414
+ };
415
+ }
330
416
  /**
331
417
  * Check if a session is still active (recently updated).
332
418
  *
@@ -1,4 +1,4 @@
1
- import type { AgentSession, SessionContent, AgentSessionReader } from '../../runner/index.js';
1
+ import type { AgentSession, SessionContent, SessionSummary, AgentSessionReader } from '../../runner/index.js';
2
2
  interface CodexSessionReaderOptions {
3
3
  codexHome?: string;
4
4
  }
@@ -16,6 +16,7 @@ export declare class CodexSessionReader implements AgentSessionReader {
16
16
  readSessionContent(sessionId: string, cwd: string, opts?: {
17
17
  maxEvents?: number;
18
18
  }): SessionContent;
19
+ readSessionSummary(sessionId: string, cwd: string): SessionSummary;
19
20
  isSessionActive(sessionId: string, cwd: string): boolean;
20
21
  }
21
22
  export {};
@@ -1,5 +1,5 @@
1
1
  import { resolveCodexHome } from '../../config/codex-config.js';
2
- import { listCodexRollouts, readCodexSessionContent, isCodexSessionActive, } from './rollout-reader.js';
2
+ import { listCodexRollouts, readCodexSessionContent, readCodexSessionSummary, isCodexSessionActive, } from './rollout-reader.js';
3
3
  export class CodexSessionReader {
4
4
  codexHome;
5
5
  constructor(opts = {}) {
@@ -34,6 +34,9 @@ export class CodexSessionReader {
34
34
  cwd,
35
35
  });
36
36
  }
37
+ readSessionSummary(sessionId, cwd) {
38
+ return readCodexSessionSummary(sessionId, { codexHome: this.codexHome, cwd });
39
+ }
37
40
  isSessionActive(sessionId, cwd) {
38
41
  return isCodexSessionActive(sessionId, { codexHome: this.codexHome, cwd });
39
42
  }
@@ -7,8 +7,6 @@
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;
12
10
  /**
13
11
  * Paginate a (already-ordered) full item list by offset + limit.
14
12
  * 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
- export const DEFAULT_LIST_LIMIT = 20;
11
+ 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.
@@ -0,0 +1,7 @@
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
+ /** 「时间降序 + id 字典序 tie-break」原地排序(原先 6 处内联拷贝)。 */
7
+ export declare function sortByRecencyDesc<T>(items: T[], time: (item: T) => number, id: (item: T) => string): void;
@@ -0,0 +1,10 @@
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
+ /** 「时间降序 + id 字典序 tie-break」原地排序(原先 6 处内联拷贝)。 */
7
+ export function sortByRecencyDesc(items, time, id) {
8
+ items.sort((a, b) => time(b) - time(a) || id(a).localeCompare(id(b)));
9
+ }
10
+ //# sourceMappingURL=recency.js.map
@@ -55,4 +55,18 @@ export declare class UsageAccumulator {
55
55
  */
56
56
  bumpCompact(): void;
57
57
  }
58
+ /** P2-8 统一契约:上下文窗口占用 = 末轮 input + cacheRead + cacheCreation(不含 output/reasoning)。 */
59
+ export declare function contextWindowOccupancy(l: {
60
+ input: number;
61
+ cacheRead: number;
62
+ cacheCreation: number;
63
+ }): number;
64
+ /** P2-8 统一契约:session 全量累计字段组装(4 个 reader 共用:claude/dsh/kimi/pi;
65
+ * codex 走 last_token_usage 口径、opencode 保留自己的条件赋值,原为逐字段复制的 4 连拷贝)。 */
66
+ export declare function cumulativeUsageFields(t: {
67
+ input: number;
68
+ output: number;
69
+ cacheRead: number;
70
+ cacheCreation: number;
71
+ }): Pick<import('../../runner/types.js').AgentSessionUsage, 'cumulativeTotalTokens' | 'cumulativeInputTokens' | 'cumulativeOutputTokens' | 'cumulativeCacheReadTokens' | 'cumulativeCacheCreationTokens'>;
58
72
  export {};
@@ -69,4 +69,19 @@ export class UsageAccumulator {
69
69
  this._compactCount++;
70
70
  }
71
71
  }
72
+ /** P2-8 统一契约:上下文窗口占用 = 末轮 input + cacheRead + cacheCreation(不含 output/reasoning)。 */
73
+ export function contextWindowOccupancy(l) {
74
+ return l.input + l.cacheRead + l.cacheCreation;
75
+ }
76
+ /** P2-8 统一契约:session 全量累计字段组装(4 个 reader 共用:claude/dsh/kimi/pi;
77
+ * codex 走 last_token_usage 口径、opencode 保留自己的条件赋值,原为逐字段复制的 4 连拷贝)。 */
78
+ export function cumulativeUsageFields(t) {
79
+ return {
80
+ cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
81
+ cumulativeInputTokens: t.input,
82
+ cumulativeOutputTokens: t.output,
83
+ cumulativeCacheReadTokens: t.cacheRead,
84
+ cumulativeCacheCreationTokens: t.cacheCreation,
85
+ };
86
+ }
72
87
  //# sourceMappingURL=usage-accumulator.js.map
@@ -13,6 +13,8 @@
13
13
  * session-wide usage accumulation (assistant/message.usage).
14
14
  */
15
15
  import { spawnSync } from 'node:child_process';
16
+ import { cumulativeUsageFields } from '../common/usage-accumulator.js';
17
+ import { sortByRecencyDesc } from '../common/recency.js';
16
18
  import { getLogger } from '../../logger/index.js';
17
19
  import { DEFAULT_DSH_HOST } from '../../config/index.js';
18
20
  import { capEvents, paginate } from '../common/pagination.js';
@@ -81,13 +83,13 @@ export class DshSessionReader {
81
83
  const items = value.items ?? [];
82
84
  const filtered = items
83
85
  .filter((i) => i.cwd === cwd)
84
- .sort((a, b) => b.updatedAt - a.updatedAt || String(a.sessionId).localeCompare(String(b.sessionId)))
85
86
  .map((i) => ({
86
87
  sessionId: String(i.sessionId),
87
88
  summary: '',
88
89
  mtime: i.updatedAt,
89
90
  ...(i.agentPreset ? { agentPreset: i.agentPreset } : {}),
90
91
  }));
92
+ sortByRecencyDesc(filtered, (i) => i.mtime, (i) => i.sessionId);
91
93
  const { items: page, total } = paginate(filtered, opts ?? {});
92
94
  return { sessions: page, total };
93
95
  }
@@ -147,11 +149,12 @@ export class DshSessionReader {
147
149
  cacheReadTokens: cacheRead,
148
150
  cacheCreationTokens: cacheWrite,
149
151
  totalTokens: input + output + cacheRead + cacheWrite,
150
- cumulativeTotalTokens: cumInput + cumOutput + cumCacheRead + cumCacheWrite,
151
- cumulativeInputTokens: cumInput,
152
- cumulativeOutputTokens: cumOutput,
153
- cumulativeCacheReadTokens: cumCacheRead,
154
- cumulativeCacheCreationTokens: cumCacheWrite,
152
+ ...cumulativeUsageFields({
153
+ input: cumInput,
154
+ output: cumOutput,
155
+ cacheRead: cumCacheRead,
156
+ cacheCreation: cumCacheWrite,
157
+ }),
155
158
  };
156
159
  }
157
160
  return {