lark-remote 0.1.10 → 0.1.12

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.
@@ -25,6 +25,7 @@ import { RpcErrorCode, } from '../../common/acp/protocol-types.js';
25
25
  import { findOptionIdByKind } from '../../common/acp/protocol-helpers.js';
26
26
  import { getLogger } from '../../../logger/index.js';
27
27
  import { ConnectionBasedRunner } from '../../common/connection-based-runner.js';
28
+ import { OpencodeLogErrorMonitor, resolveOpencodeLogPath } from './error-monitor.js';
28
29
  /**
29
30
  * Build the ACP protocol response for a permission approval decision.
30
31
  *
@@ -79,6 +80,9 @@ export class OpencodeAcpRunner extends ConnectionBasedRunner {
79
80
  currentModeId;
80
81
  /** Current model value (`provider/model`), parsed from configOptions. */
81
82
  currentModelValue;
83
+ /** Tails opencode's own log to surface LLM stream errors the ACP protocol
84
+ * never forwards (quota/limit errors back off for ~1h → turn would hang). */
85
+ errorMonitor;
82
86
  /** Pending approval requests: requestId → kind + view + options. */
83
87
  pendingApprovals = new Map();
84
88
  /** Tracker for the in-flight prompt request — needed for cancellation. */
@@ -91,6 +95,10 @@ export class OpencodeAcpRunner extends ConnectionBasedRunner {
91
95
  super({ kind: opts.kind, sessionReader: opts.sessionReader }, opts.turnIdleTimeoutMs);
92
96
  this.model = opts.model;
93
97
  this.configuredMode = opts.mode ?? 'build';
98
+ this.errorMonitor = new OpencodeLogErrorMonitor({
99
+ logPath: opts.errorMonitorLogPath ?? resolveOpencodeLogPath(),
100
+ pollIntervalMs: opts.errorMonitorPollIntervalMs,
101
+ });
94
102
  const managerOpts = {
95
103
  binary: opts.binary ?? 'opencode',
96
104
  args: opts.acpArgs ?? ['acp'],
@@ -138,11 +146,27 @@ export class OpencodeAcpRunner extends ConnectionBasedRunner {
138
146
  }
139
147
  }
140
148
  clearTurnState() {
149
+ this.errorMonitor.stop();
141
150
  this.currentTranslator = null;
142
151
  this.promptSettled = false;
143
152
  this.promptSent = false;
144
153
  this.activeSessionId = null;
145
154
  }
155
+ /**
156
+ * Start watching opencode's own log for LLM stream errors of the active
157
+ * session. opencode ACP does not surface retry/error state to the client
158
+ * (no error/status notification type, session/list has no status field), so
159
+ * the log is the only channel that carries the real provider error. Without
160
+ * this, a quota error (e.g. opencode-go weekly limit) leaves session/prompt
161
+ * pending for the full retry-after backoff (~1h) and the Feishu card hangs
162
+ * until the idle watchdog.
163
+ */
164
+ startErrorMonitor(sessionId) {
165
+ this.errorMonitor.start(sessionId, (message) => {
166
+ getLogger().warn(`[${this.logTag}] opencode stream error detected (log), failing turn: ${message}`);
167
+ this.failTurn(`opencode LLM stream error: ${message}`);
168
+ });
169
+ }
146
170
  async releaseConnection(cwd) {
147
171
  await this.connectionManager.release(cwd);
148
172
  }
@@ -175,6 +199,7 @@ export class OpencodeAcpRunner extends ConnectionBasedRunner {
175
199
  throw new Error('compact requires a sessionId');
176
200
  }
177
201
  this.activeSessionId = sessionId;
202
+ this.startErrorMonitor(sessionId);
178
203
  // Cold-connection fallback: session/resume loads the session into
179
204
  // memory. On a reused connection the session is already loaded —
180
205
  // resume is idempotent and harmless.
@@ -310,6 +335,7 @@ export class OpencodeAcpRunner extends ConnectionBasedRunner {
310
335
  sessionId = newResult.sessionId;
311
336
  }
312
337
  this.activeSessionId = sessionId;
338
+ this.startErrorMonitor(sessionId);
313
339
  // §P5: apply the configured mode (build/plan). If the session already
314
340
  // runs it (from session/new|resume configOptions), skip the wire call;
315
341
  // otherwise send session/set_mode and refresh the local view (opencode
@@ -416,6 +416,21 @@ export interface SessionContent {
416
416
  recap?: string;
417
417
  displayTitle?: string;
418
418
  }
419
+ /**
420
+ * Lightweight session summary used by `/resume` list prefetch.
421
+ *
422
+ * Carries only the display fields (title / recap / usage) — **no `events`
423
+ * array**. Readers that can extract these without constructing the full event
424
+ * list (e.g. codex, which would otherwise JSON.parse every response_item and
425
+ * build an `events[]` that the prefetch immediately discards) implement the
426
+ * optional `readSessionSummary` on `AgentSessionReader`.
427
+ */
428
+ export interface SessionSummary {
429
+ aiTitle?: string;
430
+ recap?: string;
431
+ displayTitle?: string;
432
+ usage?: AgentSessionUsage;
433
+ }
419
434
  /**
420
435
  * Reads an agent's session history. Each agent implements its own (Claude reads
421
436
  * `~/.claude/projects/`, Codex calls `thread/list`, OpenCode calls `GET /session`).
@@ -448,6 +463,14 @@ export interface AgentSessionReader {
448
463
  readSessionContent(sessionId: string, cwd: string, opts?: {
449
464
  maxEvents?: number;
450
465
  }): SessionContent;
466
+ /**
467
+ * Optional lightweight summary for list-prefetch.
468
+ *
469
+ * Returns only the display fields (aiTitle/recap/displayTitle/usage) without
470
+ * constructing the full `events` array. When absent, callers fall back to
471
+ * `readSessionContent` and take the needed fields.
472
+ */
473
+ readSessionSummary?(sessionId: string, cwd: string): SessionSummary;
451
474
  /**
452
475
  * Whether a session is still active (process may still be running).
453
476
  *
@@ -13,7 +13,7 @@
13
13
  * Note: This handles the **rollout file format** (historical logging), NOT the
14
14
  * `codex exec --json` stdout format(已随 exec 模式移除)。
15
15
  */
16
- import type { AgentSessionContentEvent, SessionContent, AgentSessionUsage } from '../../runner/index.js';
16
+ import type { AgentSessionContentEvent, SessionContent, SessionSummary, AgentSessionUsage } from '../../runner/index.js';
17
17
  interface CodexRolloutEntry {
18
18
  threadId: string;
19
19
  cwd: string;
@@ -38,6 +38,19 @@ interface ListCodexRolloutsOptions {
38
38
  offset?: number;
39
39
  }
40
40
  export declare function readCodexRollout(filePath: string): CodexRolloutEntry | null;
41
+ /**
42
+ * Lightweight single-file scan that extracts only the display-relevant fields
43
+ * (last real user message + usage) — **without** constructing the `events`
44
+ * array. Backs `readCodexSessionSummary` so the `/resume` list prefetch does
45
+ * not pay the cost of JSON.parse-ing every response_item and building events
46
+ * that are immediately discarded.
47
+ *
48
+ * Returns null if the file doesn't exist, is corrupted, or has no session_meta.
49
+ */
50
+ export declare function readCodexRolloutSummary(filePath: string): {
51
+ lastRealUserMessage: string;
52
+ usage?: AgentSessionUsage;
53
+ } | null;
41
54
  /**
42
55
  * List Codex sessions from rollout files.
43
56
  *
@@ -71,6 +84,17 @@ export declare function readCodexSessionContent(sessionId: string, opts?: {
71
84
  maxEvents?: number;
72
85
  cwd?: string;
73
86
  }): SessionContent;
87
+ /**
88
+ * Read the summary (title + usage, no events) of a specific session by id.
89
+ *
90
+ * Mirrors `readCodexSessionContent`'s index lookup + cwd guard, but uses the
91
+ * lightweight `readCodexRolloutSummary` instead of the full parse — so the
92
+ * `/resume` list prefetch never constructs the events array it would discard.
93
+ */
94
+ export declare function readCodexSessionSummary(sessionId: string, opts?: {
95
+ codexHome?: string;
96
+ cwd?: string;
97
+ }): SessionSummary;
74
98
  /**
75
99
  * Check if a session is still active (recently updated).
76
100
  *
@@ -35,130 +35,8 @@ export function readCodexRollout(filePath) {
35
35
  return null;
36
36
  }
37
37
  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] ?? '';
38
+ const scan = scanCodexRollout(filePath, true);
39
+ const { sessionMeta } = scan;
162
40
  if (!sessionMeta) {
163
41
  return null;
164
42
  }
@@ -173,48 +51,9 @@ export function readCodexRollout(filePath) {
173
51
  ? new Date(String(sessionMeta.timestamp)).getTime()
174
52
  : stats.birthtimeMs;
175
53
  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
- }
54
+ const firstUserMessage = scan.realUserMessages[0] ?? '';
55
+ const lastRealUserMessage = scan.realUserMessages[scan.realUserMessages.length - 1] ?? '';
56
+ const usage = buildUsageFromScan(scan);
218
57
  return {
219
58
  threadId: sessionId,
220
59
  cwd,
@@ -222,7 +61,7 @@ export function readCodexRollout(filePath) {
222
61
  lastRealUserMessage,
223
62
  createdAtMs,
224
63
  updatedAtMs,
225
- events,
64
+ events: scan.events,
226
65
  usage,
227
66
  };
228
67
  }
@@ -231,6 +70,222 @@ export function readCodexRollout(filePath) {
231
70
  return null;
232
71
  }
233
72
  }
73
+ /**
74
+ * Lightweight single-file scan that extracts only the display-relevant fields
75
+ * (last real user message + usage) — **without** constructing the `events`
76
+ * array. Backs `readCodexSessionSummary` so the `/resume` list prefetch does
77
+ * not pay the cost of JSON.parse-ing every response_item and building events
78
+ * that are immediately discarded.
79
+ *
80
+ * Returns null if the file doesn't exist, is corrupted, or has no session_meta.
81
+ */
82
+ export function readCodexRolloutSummary(filePath) {
83
+ if (!fs.existsSync(filePath)) {
84
+ return null;
85
+ }
86
+ try {
87
+ const scan = scanCodexRollout(filePath, false);
88
+ if (!scan.sessionMeta)
89
+ return null;
90
+ return {
91
+ lastRealUserMessage: scan.realUserMessages[scan.realUserMessages.length - 1] ?? '',
92
+ usage: buildUsageFromScan(scan),
93
+ };
94
+ }
95
+ catch (err) {
96
+ getLogger().warn(`[codex-rollout-reader] failed to read summary ${filePath}: ${err}`);
97
+ return null;
98
+ }
99
+ }
100
+ /**
101
+ * Scan a rollout file once, tracking user messages, usage and compaction state.
102
+ * When `includeEvents` is true, response_item content is also extracted into
103
+ * `events`; when false, response_item lines are skipped entirely (the summary
104
+ * fast path).
105
+ */
106
+ function scanCodexRollout(filePath, includeEvents) {
107
+ const lines = readJsonlLines(filePath);
108
+ const events = [];
109
+ let sessionMeta = null;
110
+ // Real user input is identified by a paired `event_msg` whose
111
+ // payload.type === "user_message" - codex emits this ONLY for text the
112
+ // human actually typed. Injected scaffolding (project rules,
113
+ // <environment_context>, permissions) is also written as `role:"user"`
114
+ // response_items but has NO user_message event, so it must be excluded
115
+ // from displayTitle/recap/summary. Regression 2026-07-13: the first
116
+ // `role:user` message is the injected project rules, mistakenly shown as
117
+ // "最近输入".
118
+ const realUserMessages = [];
119
+ // token_count tracking: codex emits cumulative `total_token_usage` and an
120
+ // incremental `last_token_usage`. We want the LAST turn's usage (matches
121
+ // pi/opencode /resume "last turn" display semantics). When
122
+ // last_token_usage is absent we derive it as total - previous_total.
123
+ let lastRawUsage;
124
+ // Context window limit from the same token_count event as lastRawUsage
125
+ // (codex reports info.model_context_window per turn; absent on old data).
126
+ let lastContextLimit;
127
+ // Final total_token_usage (cumulative across the whole session) for the
128
+ // Run card's "累计" display. Codex emits this alongside last_token_usage.
129
+ let lastTotalUsage;
130
+ let previousTotals;
131
+ // Compaction 统计:codex 的 compact turn 在会话文件里写顶层 `compacted`
132
+ // 事件(含摘要),压缩前后的上下文水位由相邻 token_count 表达。压缩收尾的
133
+ // token_count 增量是 input/cached/output 全 0、只有 total_tokens 有值
134
+ // (窗口被摘要+replacement history 占据),现有 zero-filter 会跳过它,
135
+ // 因此这里必须单独捕获,不能复用 lastRawUsage。
136
+ // 单位口径(review P3-9):前后两侧统一用 total_tokens —— 压缩前取最近一次
137
+ // 非零 token_count 的 total_tokens(input+output,即该 turn 在窗口内的全部
138
+ // token),压缩后取收尾事件的 total_tokens(压缩后窗口)。不得一边 input
139
+ // 一边 total,否则「压缩前 X → 压缩后 Y」两边单位不一致。
140
+ let compactCount = 0;
141
+ let compactPreContextLength;
142
+ let compactPostContextLength;
143
+ for (const line of lines) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed)
146
+ continue;
147
+ try {
148
+ const parsed = JSON.parse(trimmed);
149
+ if (!parsed || typeof parsed.type !== 'string')
150
+ continue;
151
+ if (parsed.type === 'session_meta' && parsed.payload) {
152
+ sessionMeta = parsed.payload;
153
+ }
154
+ else if (parsed.type === 'event_msg' && parsed.payload) {
155
+ // user_message events carry the authoritative human-typed text.
156
+ const payload = parsed.payload;
157
+ if (payload.type === 'user_message') {
158
+ const text = stringValue(payload.message);
159
+ if (text) {
160
+ realUserMessages.push(text.slice(0, 200));
161
+ }
162
+ }
163
+ else if (payload.type === 'token_count') {
164
+ const info = payload.info;
165
+ const last = info?.last_token_usage;
166
+ const total = info?.total_token_usage;
167
+ let raw;
168
+ if (last) {
169
+ raw = last;
170
+ }
171
+ else if (total && previousTotals) {
172
+ raw = subtractRawUsage(total, previousTotals);
173
+ }
174
+ else {
175
+ raw = total;
176
+ }
177
+ if (total) {
178
+ previousTotals = total;
179
+ lastTotalUsage = total;
180
+ }
181
+ if (raw && (raw.input_tokens || raw.cached_input_tokens || raw.output_tokens)) {
182
+ lastRawUsage = raw;
183
+ const ctxWindow = info?.model_context_window;
184
+ lastContextLimit = typeof ctxWindow === 'number' ? ctxWindow : undefined;
185
+ }
186
+ // 压缩后的水位:压缩收尾 token_count 全 0(raw 不满足上面 zero-filter),
187
+ // 但 total_tokens 表达真实窗口;之后若出现普通 turn(input>0),
188
+ // 水位回归该 turn,post-compact 值失效。
189
+ if (compactCount > 0 && raw) {
190
+ if (raw.total_tokens &&
191
+ !raw.input_tokens &&
192
+ !raw.cached_input_tokens &&
193
+ !raw.output_tokens) {
194
+ compactPostContextLength = raw.total_tokens;
195
+ }
196
+ else if (raw.input_tokens > 0) {
197
+ compactPostContextLength = undefined;
198
+ }
199
+ }
200
+ }
201
+ }
202
+ else if (parsed.type === 'compacted') {
203
+ // 顶层 compacted 事件:压缩次数 +1,压缩前水位取最近一次非零 token_count。
204
+ compactCount++;
205
+ if (lastRawUsage?.total_tokens !== undefined) {
206
+ compactPreContextLength = lastRawUsage.total_tokens;
207
+ }
208
+ }
209
+ else if (includeEvents && parsed.type === 'response_item' && parsed.payload) {
210
+ const payload = parsed.payload;
211
+ if (payload.type === 'message' && Array.isArray(payload.content)) {
212
+ // Extract text content for display
213
+ const messages = extractMessageContent(payload);
214
+ for (const msg of messages) {
215
+ events.push({
216
+ type: msg.role === 'user' ? 'user' : 'assistant',
217
+ content: msg.text,
218
+ timestamp: parsed.timestamp,
219
+ });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ catch {
225
+ // Skip malformed lines
226
+ continue;
227
+ }
228
+ }
229
+ return {
230
+ sessionMeta,
231
+ events,
232
+ realUserMessages,
233
+ lastRawUsage,
234
+ lastContextLimit,
235
+ lastTotalUsage,
236
+ previousTotals,
237
+ compactCount,
238
+ compactPreContextLength,
239
+ compactPostContextLength,
240
+ };
241
+ }
242
+ /**
243
+ * Build ccusage-aligned usage from a scan's last-turn raw tokens.
244
+ * input = raw - cached (codex reports cached input separately), cache
245
+ * creation is never reported by codex, reasoning is a subset of output
246
+ * (display-only, never added to total).
247
+ * contextLength = input_tokens (= (input_tokens-cached) + cached + 0),
248
+ * i.e. input + cacheRead + cacheCreation — the unified context-window
249
+ * occupancy contract across all five readers (review P2-8, excludes
250
+ * output/reasoning).
251
+ */
252
+ function buildUsageFromScan(scan) {
253
+ const { lastRawUsage, lastContextLimit, lastTotalUsage, compactCount, compactPreContextLength, compactPostContextLength, } = scan;
254
+ if (!lastRawUsage)
255
+ return undefined;
256
+ const r = lastRawUsage;
257
+ const cached = Math.min(r.cached_input_tokens, r.input_tokens);
258
+ const usage = {
259
+ inputTokens: r.input_tokens - cached,
260
+ outputTokens: r.output_tokens,
261
+ contextLength: r.input_tokens,
262
+ contextLimit: lastContextLimit,
263
+ cacheReadTokens: cached,
264
+ cacheCreationTokens: 0,
265
+ totalTokens: r.total_tokens,
266
+ };
267
+ // Cumulative from the final total_token_usage (non-cached input +
268
+ // output), summed across all turns in the session.
269
+ if (lastTotalUsage) {
270
+ const cumCached = Math.min(lastTotalUsage.cached_input_tokens, lastTotalUsage.input_tokens);
271
+ usage.cumulativeInputTokens = lastTotalUsage.input_tokens - cumCached;
272
+ usage.cumulativeOutputTokens = lastTotalUsage.output_tokens;
273
+ usage.cumulativeTotalTokens = lastTotalUsage.total_tokens;
274
+ usage.cumulativeCacheReadTokens = cumCached;
275
+ usage.cumulativeCacheCreationTokens = 0; // Codex 不报告 cache creation
276
+ }
277
+ // 压缩统计:会话内出现过 compacted 事件就计数(供 /resume 与 Compact 卡
278
+ // 展示);仅当会话以压缩收尾时(post-compact 水位存在),contextLength
279
+ // 覆盖为压缩后水位并暴露压缩前水位——后续还有普通 turn 时水位回归该 turn。
280
+ if (compactCount > 0) {
281
+ usage.compactCount = compactCount;
282
+ if (compactPostContextLength !== undefined) {
283
+ usage.contextLength = compactPostContextLength;
284
+ usage.compactPreContextLength = compactPreContextLength;
285
+ }
286
+ }
287
+ return usage;
288
+ }
234
289
  /**
235
290
  * List Codex sessions from rollout files.
236
291
  *
@@ -327,6 +382,41 @@ export function readCodexSessionContent(sessionId, opts = {}) {
327
382
  usage: rollout.usage,
328
383
  };
329
384
  }
385
+ /**
386
+ * Read the summary (title + usage, no events) of a specific session by id.
387
+ *
388
+ * Mirrors `readCodexSessionContent`'s index lookup + cwd guard, but uses the
389
+ * lightweight `readCodexRolloutSummary` instead of the full parse — so the
390
+ * `/resume` list prefetch never constructs the events array it would discard.
391
+ */
392
+ export function readCodexSessionSummary(sessionId, opts = {}) {
393
+ const codexHome = resolveCodexHome(opts.codexHome);
394
+ let index = getSessionIndex(codexHome);
395
+ let entry = index.get(sessionId);
396
+ if (!entry) {
397
+ index = getSessionIndex(codexHome, true);
398
+ entry = index.get(sessionId);
399
+ }
400
+ if (!entry) {
401
+ return {};
402
+ }
403
+ // Cwd guard: same semantics as readCodexSessionContent — when a cwd is
404
+ // provided, the session's working directory must match.
405
+ if (opts.cwd && entry.cwd !== opts.cwd) {
406
+ return {};
407
+ }
408
+ const summary = readCodexRolloutSummary(entry.filePath);
409
+ if (!summary) {
410
+ return {};
411
+ }
412
+ return {
413
+ // displayTitle = LAST real user message (matches the "最近输入" label).
414
+ displayTitle: summary.lastRealUserMessage || undefined,
415
+ // codex has no compact-summary concept, so recap is never faked.
416
+ recap: undefined,
417
+ usage: summary.usage,
418
+ };
419
+ }
330
420
  /**
331
421
  * Check if a session is still active (recently updated).
332
422
  *
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",