lark-remote 0.1.11 → 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.
- package/CHANGELOG.md +11 -0
- package/dist/config/codex-config.d.ts +22 -0
- package/dist/config/codex-config.js +108 -14
- package/dist/index.js +6 -0
- package/dist/router/index.js +12 -5
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/opencode/acp/error-monitor.d.ts +61 -0
- package/dist/runner/opencode/acp/error-monitor.js +150 -0
- package/dist/runner/opencode/acp/runner.d.ts +17 -0
- package/dist/runner/opencode/acp/runner.js +26 -0
- package/dist/runner/types.d.ts +23 -0
- package/dist/session/codex/rollout-reader.d.ts +25 -1
- package/dist/session/codex/rollout-reader.js +257 -167
- package/dist/session/codex/sessions.d.ts +2 -1
- package/dist/session/codex/sessions.js +4 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.1.12] - 2026-08-29
|
|
9
|
+
|
|
10
|
+
### 修复
|
|
11
|
+
|
|
12
|
+
- opencode ACP stream error 实时上报:tail opencode 日志即时捕获错误行,配额类错误秒级报错,不再挂起卡片等待超时
|
|
13
|
+
|
|
14
|
+
### 变更
|
|
15
|
+
|
|
16
|
+
- codex 会话历史读取轻量化:长会话生成轻量摘要,模型目录异步加载,减少启动阻塞
|
|
17
|
+
- CI 覆盖率门禁纳入 `src/index.ts`,覆盖率统计更完整
|
|
18
|
+
|
|
8
19
|
## [0.1.11] - 2026-08-24
|
|
9
20
|
|
|
10
21
|
### 新增
|
|
@@ -75,14 +75,36 @@ export declare function parseCodexModelsOutput(stdout: string): BundledModelInfo
|
|
|
75
75
|
* 缓存:键含 binary/home/mode/models.json mtime:size + config.toml mtime:size
|
|
76
76
|
* (P3-4/P3-15/P3-5);成功结果 TTL 1h,失败/空结果短 TTL 负缓存;stat 异常时
|
|
77
77
|
* 指纹为空(P1-1/P3-10)。
|
|
78
|
+
*
|
|
79
|
+
* 同步路径(卡片构建用):缓存命中直接返回;若有一个后台异步加载
|
|
80
|
+
* (`loadCodexCatalogModelsAsync` / 启动 warm)在途,则不阻塞事件循环、不重复
|
|
81
|
+
* spawn,直接返回 [] 走既有 fallback(FALLBACK_MODELS / currentModel)——该
|
|
82
|
+
* [] 状态与命令失败/超时的已有语义一致,由调用方既有 fallback 兜底。
|
|
78
83
|
*/
|
|
79
84
|
export declare function getCodexCatalogModels(codexHome?: string): BundledModelInfo[];
|
|
85
|
+
/**
|
|
86
|
+
* 异步加载模型目录(execFile,不阻塞事件循环),写入与同步路径共享的
|
|
87
|
+
* `catalogCache`。按缓存 key 复用 in-flight Promise:并发调用(多个 warm /
|
|
88
|
+
* 显式调用)只 spawn 一次 `codex debug models`。
|
|
89
|
+
*
|
|
90
|
+
* 与同步路径的职责划分:启动时用 `warmCodexCatalogCache` 后台预热,使首次打开
|
|
91
|
+
* Codex 配置卡(同步读)时缓存已温、无需 8s execFileSync 阻塞;若用户在 warm
|
|
92
|
+
* 完成前打开卡片,同步路径会检测到 in-flight 并直接返回 [] fallback。
|
|
93
|
+
*/
|
|
94
|
+
export declare function loadCodexCatalogModelsAsync(codexHome?: string): Promise<BundledModelInfo[]>;
|
|
95
|
+
/**
|
|
96
|
+
* 启动/后台预热:fire-and-forget 调用异步加载,消除首次打开 Codex 配置卡时的
|
|
97
|
+
* 8s 同步阻塞。非致命——失败/未安装 codex 时静默落负缓存。
|
|
98
|
+
*/
|
|
99
|
+
export declare function warmCodexCatalogCache(codexHome?: string): void;
|
|
80
100
|
/**
|
|
81
101
|
* Test utility: clear the catalog cache. Tests that exercise the bundled
|
|
82
102
|
* command path (`--bundled`) directly use `invalidateCodexBundledTestCache`
|
|
83
103
|
* from `tests/lib/codex-bundled-test-helpers.ts`.
|
|
84
104
|
*/
|
|
85
105
|
export declare function invalidateCodexBundledCache(): void;
|
|
106
|
+
/** Test utility: clear in-flight async catalog loads (see async loader tests). */
|
|
107
|
+
export declare function _clearCodexCatalogInFlightForTest(): void;
|
|
86
108
|
/**
|
|
87
109
|
* Get supported reasoning effort options for a model.
|
|
88
110
|
* @param codexHome 可选 codex 配置目录覆盖(与 loadCodexConfig 的 codexHome 对齐,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - catalog 模式(config.toml 声明 model_catalog_json):模型列表/档位来自活动目录
|
|
9
9
|
* - 非 catalog 模式:openai 用 bundled 全量;anthropic 仅在用户显式配置时存在
|
|
10
10
|
*/
|
|
11
|
-
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import { execFileSync, execFile } from 'node:child_process';
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import os from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
@@ -46,6 +46,8 @@ const BUNDLED_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
|
46
46
|
/** 失败/空结果负缓存 TTL:避免每次卡片构建同步阻塞最长 8s(P3-5) */
|
|
47
47
|
const NEGATIVE_CACHE_TTL_MS = 30 * 1000;
|
|
48
48
|
let catalogCache = null;
|
|
49
|
+
/** In-flight async catalog loads, keyed by cache key — dedup concurrent warm ups. */
|
|
50
|
+
const catalogInFlight = new Map();
|
|
49
51
|
/**
|
|
50
52
|
* Read config.toml `model_catalog_json` path (tilde-expanded).
|
|
51
53
|
* 返回语义:
|
|
@@ -153,8 +155,89 @@ export function parseCodexModelsOutput(stdout) {
|
|
|
153
155
|
* 缓存:键含 binary/home/mode/models.json mtime:size + config.toml mtime:size
|
|
154
156
|
* (P3-4/P3-15/P3-5);成功结果 TTL 1h,失败/空结果短 TTL 负缓存;stat 异常时
|
|
155
157
|
* 指纹为空(P1-1/P3-10)。
|
|
158
|
+
*
|
|
159
|
+
* 同步路径(卡片构建用):缓存命中直接返回;若有一个后台异步加载
|
|
160
|
+
* (`loadCodexCatalogModelsAsync` / 启动 warm)在途,则不阻塞事件循环、不重复
|
|
161
|
+
* spawn,直接返回 [] 走既有 fallback(FALLBACK_MODELS / currentModel)——该
|
|
162
|
+
* [] 状态与命令失败/超时的已有语义一致,由调用方既有 fallback 兜底。
|
|
156
163
|
*/
|
|
157
164
|
export function getCodexCatalogModels(codexHome) {
|
|
165
|
+
const { key, bundled } = computeCodexCatalogKey(codexHome);
|
|
166
|
+
const cached = readCachedCatalog(key);
|
|
167
|
+
if (cached !== undefined)
|
|
168
|
+
return cached;
|
|
169
|
+
// in-flight 异步加载进行中:不 execFileSync(会阻塞 8s),返回 [] 交给
|
|
170
|
+
// 调用方既有 fallback;in-flight 完成后下次读取命中缓存。
|
|
171
|
+
if (catalogInFlight.has(key)) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
try {
|
|
176
|
+
const stdout = execFileSync('codex', catalogArgs(bundled), {
|
|
177
|
+
encoding: 'utf-8',
|
|
178
|
+
timeout: 8_000,
|
|
179
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
180
|
+
env: process.env,
|
|
181
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
182
|
+
});
|
|
183
|
+
const models = parseCodexModelsOutput(stdout);
|
|
184
|
+
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
185
|
+
return models;
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
getLogger().warn(`[codex-config] models unavailable for binary "codex": ${err.message}`);
|
|
189
|
+
catalogCache = { key, models: [], ts: now, failed: true };
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* 异步加载模型目录(execFile,不阻塞事件循环),写入与同步路径共享的
|
|
195
|
+
* `catalogCache`。按缓存 key 复用 in-flight Promise:并发调用(多个 warm /
|
|
196
|
+
* 显式调用)只 spawn 一次 `codex debug models`。
|
|
197
|
+
*
|
|
198
|
+
* 与同步路径的职责划分:启动时用 `warmCodexCatalogCache` 后台预热,使首次打开
|
|
199
|
+
* Codex 配置卡(同步读)时缓存已温、无需 8s execFileSync 阻塞;若用户在 warm
|
|
200
|
+
* 完成前打开卡片,同步路径会检测到 in-flight 并直接返回 [] fallback。
|
|
201
|
+
*/
|
|
202
|
+
export async function loadCodexCatalogModelsAsync(codexHome) {
|
|
203
|
+
const { key, bundled } = computeCodexCatalogKey(codexHome);
|
|
204
|
+
const cached = readCachedCatalog(key);
|
|
205
|
+
if (cached !== undefined)
|
|
206
|
+
return cached;
|
|
207
|
+
const inFlight = catalogInFlight.get(key);
|
|
208
|
+
if (inFlight)
|
|
209
|
+
return inFlight;
|
|
210
|
+
const p = (async () => {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
try {
|
|
213
|
+
const stdout = await execFileAsync('codex', catalogArgs(bundled));
|
|
214
|
+
const models = parseCodexModelsOutput(stdout);
|
|
215
|
+
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
216
|
+
return models;
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
getLogger().warn(`[codex-config] models unavailable for binary "codex": ${err.message}`);
|
|
220
|
+
catalogCache = { key, models: [], ts: now, failed: true };
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
catalogInFlight.set(key, p);
|
|
225
|
+
try {
|
|
226
|
+
return await p;
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
catalogInFlight.delete(key);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* 启动/后台预热:fire-and-forget 调用异步加载,消除首次打开 Codex 配置卡时的
|
|
234
|
+
* 8s 同步阻塞。非致命——失败/未安装 codex 时静默落负缓存。
|
|
235
|
+
*/
|
|
236
|
+
export function warmCodexCatalogCache(codexHome) {
|
|
237
|
+
void loadCodexCatalogModelsAsync(codexHome);
|
|
238
|
+
}
|
|
239
|
+
/** 计算目录缓存键(binary/home/mode + 指纹),同步与异步路径共享。 */
|
|
240
|
+
function computeCodexCatalogKey(codexHome) {
|
|
158
241
|
const binary = 'codex';
|
|
159
242
|
const home = resolveCodexHome(codexHome);
|
|
160
243
|
const catalogPath = readModelCatalogJsonPath(home);
|
|
@@ -179,6 +262,10 @@ export function getCodexCatalogModels(codexHome) {
|
|
|
179
262
|
}
|
|
180
263
|
}
|
|
181
264
|
const key = `${binary}\u0000${home}\u0000${bundled ? 'bundled' : 'active'}\u0000${catalogFingerprint}\u0000${configFingerprint}`;
|
|
265
|
+
return { key, bundled };
|
|
266
|
+
}
|
|
267
|
+
/** 读取有效缓存(含负缓存 TTL);命中返回模型,否则 undefined。 */
|
|
268
|
+
function readCachedCatalog(key) {
|
|
182
269
|
const now = Date.now();
|
|
183
270
|
if (catalogCache && catalogCache.key === key) {
|
|
184
271
|
const ttl = catalogCache.failed ? NEGATIVE_CACHE_TTL_MS : BUNDLED_CACHE_TTL_MS;
|
|
@@ -186,24 +273,27 @@ export function getCodexCatalogModels(codexHome) {
|
|
|
186
273
|
return catalogCache.models;
|
|
187
274
|
}
|
|
188
275
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
/** `codex debug models` 参数(--bundled 与否)。 */
|
|
279
|
+
function catalogArgs(bundled) {
|
|
280
|
+
return bundled ? ['debug', 'models', '--bundled'] : ['debug', 'models'];
|
|
281
|
+
}
|
|
282
|
+
/** Promisify execFile with the same 8s timeout / 4MB cap as the sync path. */
|
|
283
|
+
function execFileAsync(file, args) {
|
|
284
|
+
return new Promise((resolve, reject) => {
|
|
285
|
+
execFile(file, args, {
|
|
192
286
|
encoding: 'utf-8',
|
|
193
287
|
timeout: 8_000,
|
|
194
288
|
maxBuffer: 4 * 1024 * 1024,
|
|
195
289
|
env: process.env,
|
|
196
|
-
|
|
290
|
+
}, (err, stdout) => {
|
|
291
|
+
if (err)
|
|
292
|
+
reject(err);
|
|
293
|
+
else
|
|
294
|
+
resolve(stdout);
|
|
197
295
|
});
|
|
198
|
-
|
|
199
|
-
catalogCache = { key, models, ts: now, failed: models.length === 0 };
|
|
200
|
-
return models;
|
|
201
|
-
}
|
|
202
|
-
catch (err) {
|
|
203
|
-
getLogger().warn(`[codex-config] models unavailable for binary "${binary}": ${err.message}`);
|
|
204
|
-
catalogCache = { key, models: [], ts: now, failed: true };
|
|
205
|
-
return [];
|
|
206
|
-
}
|
|
296
|
+
});
|
|
207
297
|
}
|
|
208
298
|
/**
|
|
209
299
|
* Test utility: clear the catalog cache. Tests that exercise the bundled
|
|
@@ -213,6 +303,10 @@ export function getCodexCatalogModels(codexHome) {
|
|
|
213
303
|
export function invalidateCodexBundledCache() {
|
|
214
304
|
catalogCache = null;
|
|
215
305
|
}
|
|
306
|
+
/** Test utility: clear in-flight async catalog loads (see async loader tests). */
|
|
307
|
+
export function _clearCodexCatalogInFlightForTest() {
|
|
308
|
+
catalogInFlight.clear();
|
|
309
|
+
}
|
|
216
310
|
/**
|
|
217
311
|
* Get supported reasoning effort options for a model.
|
|
218
312
|
* @param codexHome 可选 codex 配置目录覆盖(与 loadCodexConfig 的 codexHome 对齐,
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { FeishuConnector } from './connector/index.js';
|
|
|
6
6
|
import { ClaudeRunner, CodexAppServerRunner, OpencodeAcpRunner, PiRpcRunner, KimiAcpRunner, DshRunner, } from './runner/index.js';
|
|
7
7
|
import { AgentRegistry } from './runner/registry.js';
|
|
8
8
|
import { probeAllAgents } from './runner/probe.js';
|
|
9
|
+
import { warmCodexCatalogCache } from './config/codex-config.js';
|
|
9
10
|
import { SessionReaderRegistry, SessionStore } from './session/index.js';
|
|
10
11
|
import { ClaudeSessionReader, CodexSessionReader, OpencodeSessionReader, PiSessionReader, KimiSessionReader, DshSessionReader, } from './session/index.js';
|
|
11
12
|
import { CommandRouter, isImmediateAction } from './router/index.js';
|
|
@@ -289,6 +290,11 @@ function initializeRunner(config, configDir, cliArgs) {
|
|
|
289
290
|
.catch(() => {
|
|
290
291
|
// Probe failure is non-fatal; /config card will retry on open.
|
|
291
292
|
});
|
|
293
|
+
// Warm the codex model catalog in the background so the first Codex /config
|
|
294
|
+
// card open reads a warm cache instead of blocking the event loop for up to
|
|
295
|
+
// 8s (execFileSync cold path). Fire-and-forget; failure lands in negative
|
|
296
|
+
// cache and the sync card path falls back gracefully.
|
|
297
|
+
warmCodexCatalogCache();
|
|
292
298
|
return { agentRegistry, sessionReaderRegistry };
|
|
293
299
|
}
|
|
294
300
|
/** Setup message and card action handlers. */
|
package/dist/router/index.js
CHANGED
|
@@ -3108,12 +3108,19 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3108
3108
|
const sessionMeta = new Map();
|
|
3109
3109
|
const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
|
|
3110
3110
|
for (const s of allSessions.slice(0, prefetchCount)) {
|
|
3111
|
-
//
|
|
3112
|
-
|
|
3111
|
+
// 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
|
|
3112
|
+
// readSessionSummary,避免对前 N 条各做一次全量 JSONL 解析来构造
|
|
3113
|
+
// 立即被丢弃的 events 数组。其他 agent 无此方法时回退全量读取再取字段。
|
|
3114
|
+
const meta = reader.readSessionSummary
|
|
3115
|
+
? reader.readSessionSummary(s.sessionId, cwd)
|
|
3116
|
+
: (() => {
|
|
3117
|
+
const c = reader.readSessionContent(s.sessionId, cwd);
|
|
3118
|
+
return { aiTitle: c.aiTitle, recap: c.recap, displayTitle: c.displayTitle };
|
|
3119
|
+
})();
|
|
3113
3120
|
sessionMeta.set(s.sessionId, {
|
|
3114
|
-
aiTitle:
|
|
3115
|
-
recap:
|
|
3116
|
-
displayTitle:
|
|
3121
|
+
aiTitle: meta.aiTitle,
|
|
3122
|
+
recap: meta.recap,
|
|
3123
|
+
displayTitle: meta.displayTitle,
|
|
3117
3124
|
});
|
|
3118
3125
|
}
|
|
3119
3126
|
const now = Date.now();
|
package/dist/runner/index.d.ts
CHANGED
|
@@ -15,6 +15,6 @@ export { KimiAcpRunner } from './kimi/index.js';
|
|
|
15
15
|
export { DshRunner } from './dsh/index.js';
|
|
16
16
|
export { BashProcessRunner, type BashRunner } from './bash/index.js';
|
|
17
17
|
export { AgentRegistry } from './registry.js';
|
|
18
|
-
export type { ResultEvent, PlanEvent, FileChangeEvent, AgentEvent, Runner, RunnerLifetime, AgentKind, AgentSession, AgentSessionContentEvent, AgentSessionUsage, SessionContent, AgentSessionReader, AgentRunner, TurnStartedEvent, TurnDiffEvent, ApprovalView, ApprovalRequestedEvent, ApprovalResolvedEvent, } from './types.js';
|
|
18
|
+
export type { ResultEvent, PlanEvent, FileChangeEvent, AgentEvent, Runner, RunnerLifetime, AgentKind, AgentSession, AgentSessionContentEvent, AgentSessionUsage, SessionContent, SessionSummary, AgentSessionReader, AgentRunner, TurnStartedEvent, TurnDiffEvent, ApprovalView, ApprovalRequestedEvent, ApprovalResolvedEvent, } from './types.js';
|
|
19
19
|
export { resolveAgentChoices } from './resolve-agent-choices.js';
|
|
20
20
|
export { syncAgentChoices } from './sync-agent-choices.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpencodeLogErrorMonitor: watches opencode's own log file for LLM stream
|
|
3
|
+
* errors of the active ACP session and reports them immediately.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: opencode ACP (v1.18.x) does NOT forward retry/error state
|
|
6
|
+
* to the client over the ACP protocol. Its session/update notifications are
|
|
7
|
+
* limited to content deltas (agent_message_chunk / agent_thought_chunk /
|
|
8
|
+
* tool_call / tool_call_update), usage and available-commands updates — there
|
|
9
|
+
* is no error/retry/status event type, and session/list carries no status.
|
|
10
|
+
* When a retryable provider error hits (e.g. opencode-go "Weekly usage limit
|
|
11
|
+
* reached"), the session enters a retry backoff (often the full reset time,
|
|
12
|
+
* ~1h) and session/prompt stays pending with zero events, so the client would
|
|
13
|
+
* hang until its own idle watchdog.
|
|
14
|
+
*
|
|
15
|
+
* The only channel that carries the real error is opencode's own log file
|
|
16
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`), where every LLM stream
|
|
17
|
+
* failure is written as `message="stream error" ... session.id=<id>
|
|
18
|
+
* small=false ... error.error="<reason>"`.
|
|
19
|
+
*
|
|
20
|
+
* This monitor tails the log (delta from the turn-start offset), scoped to
|
|
21
|
+
* the active sessionId + `small=false` (main turn; the `small=true` title
|
|
22
|
+
* stream must not kill the turn). Only limit/quota-class errors trigger the
|
|
23
|
+
* callback — they are the ones opencode backs off for a long time; transient
|
|
24
|
+
* 5xx/network errors retry in seconds and must be left to opencode's own
|
|
25
|
+
* retry loop (the idle watchdog remains the fallback for silent hangs).
|
|
26
|
+
*/
|
|
27
|
+
export interface OpencodeLogErrorMonitorOptions {
|
|
28
|
+
/** Path to opencode's own log file (default: resolveOpencodeLogPath()). */
|
|
29
|
+
logPath: string;
|
|
30
|
+
/** Tail poll interval in milliseconds. Defaults to 2000. */
|
|
31
|
+
pollIntervalMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Resolve opencode's log file path from the XDG data dir
|
|
35
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`, default
|
|
36
|
+
* `~/.local/share/opencode/log/opencode.log`). Matches opencode's own
|
|
37
|
+
* data-dir layout (open source v1.18.x: `opencode/src/cli/global.ts`).
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveOpencodeLogPath(): string;
|
|
40
|
+
export declare class OpencodeLogErrorMonitor {
|
|
41
|
+
private readonly logPath;
|
|
42
|
+
private readonly pollIntervalMs;
|
|
43
|
+
private offset;
|
|
44
|
+
private pendingTail;
|
|
45
|
+
private timer;
|
|
46
|
+
private sessionId;
|
|
47
|
+
private onError;
|
|
48
|
+
constructor(opts: OpencodeLogErrorMonitorOptions);
|
|
49
|
+
/**
|
|
50
|
+
* Start monitoring the given session. Replaces any previous monitor state;
|
|
51
|
+
* the log offset is snapshotted now, so pre-existing entries never fire.
|
|
52
|
+
*/
|
|
53
|
+
start(sessionId: string, onError: (message: string) => void): void;
|
|
54
|
+
/** Stop monitoring and clear all state. Safe to call multiple times. */
|
|
55
|
+
stop(): void;
|
|
56
|
+
private currentSize;
|
|
57
|
+
private poll;
|
|
58
|
+
/** Match a main-turn stream error line for the monitored session with a
|
|
59
|
+
* limit/quota reason; returns the extracted error text or null. */
|
|
60
|
+
private matchLimitError;
|
|
61
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpencodeLogErrorMonitor: watches opencode's own log file for LLM stream
|
|
3
|
+
* errors of the active ACP session and reports them immediately.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists: opencode ACP (v1.18.x) does NOT forward retry/error state
|
|
6
|
+
* to the client over the ACP protocol. Its session/update notifications are
|
|
7
|
+
* limited to content deltas (agent_message_chunk / agent_thought_chunk /
|
|
8
|
+
* tool_call / tool_call_update), usage and available-commands updates — there
|
|
9
|
+
* is no error/retry/status event type, and session/list carries no status.
|
|
10
|
+
* When a retryable provider error hits (e.g. opencode-go "Weekly usage limit
|
|
11
|
+
* reached"), the session enters a retry backoff (often the full reset time,
|
|
12
|
+
* ~1h) and session/prompt stays pending with zero events, so the client would
|
|
13
|
+
* hang until its own idle watchdog.
|
|
14
|
+
*
|
|
15
|
+
* The only channel that carries the real error is opencode's own log file
|
|
16
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`), where every LLM stream
|
|
17
|
+
* failure is written as `message="stream error" ... session.id=<id>
|
|
18
|
+
* small=false ... error.error="<reason>"`.
|
|
19
|
+
*
|
|
20
|
+
* This monitor tails the log (delta from the turn-start offset), scoped to
|
|
21
|
+
* the active sessionId + `small=false` (main turn; the `small=true` title
|
|
22
|
+
* stream must not kill the turn). Only limit/quota-class errors trigger the
|
|
23
|
+
* callback — they are the ones opencode backs off for a long time; transient
|
|
24
|
+
* 5xx/network errors retry in seconds and must be left to opencode's own
|
|
25
|
+
* retry loop (the idle watchdog remains the fallback for silent hangs).
|
|
26
|
+
*/
|
|
27
|
+
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
/** Error text we fail fast on: quota/limit conditions opencode backs off on. */
|
|
31
|
+
const LIMIT_ERROR_RE = /usage limit|quota|rate limit|rate_limit|too many requests|insufficient balance|free usage/i;
|
|
32
|
+
const STREAM_ERROR_RE = /message="stream error"/;
|
|
33
|
+
const MAIN_TURN_RE = /small=false/;
|
|
34
|
+
const ERROR_MESSAGE_RE = /error\.error="((?:[^"\\]|\\.)*)"/;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve opencode's log file path from the XDG data dir
|
|
37
|
+
* (`$XDG_DATA_HOME/opencode/log/opencode.log`, default
|
|
38
|
+
* `~/.local/share/opencode/log/opencode.log`). Matches opencode's own
|
|
39
|
+
* data-dir layout (open source v1.18.x: `opencode/src/cli/global.ts`).
|
|
40
|
+
*/
|
|
41
|
+
export function resolveOpencodeLogPath() {
|
|
42
|
+
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
43
|
+
return join(dataHome, 'opencode', 'log', 'opencode.log');
|
|
44
|
+
}
|
|
45
|
+
export class OpencodeLogErrorMonitor {
|
|
46
|
+
logPath;
|
|
47
|
+
pollIntervalMs;
|
|
48
|
+
offset = 0;
|
|
49
|
+
pendingTail = '';
|
|
50
|
+
timer = null;
|
|
51
|
+
sessionId = null;
|
|
52
|
+
onError = null;
|
|
53
|
+
constructor(opts) {
|
|
54
|
+
this.logPath = opts.logPath;
|
|
55
|
+
this.pollIntervalMs = opts.pollIntervalMs ?? 2000;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Start monitoring the given session. Replaces any previous monitor state;
|
|
59
|
+
* the log offset is snapshotted now, so pre-existing entries never fire.
|
|
60
|
+
*/
|
|
61
|
+
start(sessionId, onError) {
|
|
62
|
+
this.stop();
|
|
63
|
+
this.sessionId = sessionId;
|
|
64
|
+
this.onError = onError;
|
|
65
|
+
this.offset = this.currentSize();
|
|
66
|
+
this.pendingTail = '';
|
|
67
|
+
this.timer = setInterval(() => this.poll(), this.pollIntervalMs);
|
|
68
|
+
this.timer.unref?.();
|
|
69
|
+
}
|
|
70
|
+
/** Stop monitoring and clear all state. Safe to call multiple times. */
|
|
71
|
+
stop() {
|
|
72
|
+
if (this.timer !== null) {
|
|
73
|
+
clearInterval(this.timer);
|
|
74
|
+
this.timer = null;
|
|
75
|
+
}
|
|
76
|
+
this.sessionId = null;
|
|
77
|
+
this.onError = null;
|
|
78
|
+
this.pendingTail = '';
|
|
79
|
+
}
|
|
80
|
+
currentSize() {
|
|
81
|
+
try {
|
|
82
|
+
return statSync(this.logPath).size;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
poll() {
|
|
89
|
+
if (this.sessionId === null || this.onError === null)
|
|
90
|
+
return;
|
|
91
|
+
const size = this.currentSize();
|
|
92
|
+
if (size < this.offset) {
|
|
93
|
+
// Log rotated/truncated: restart from the top of the current file.
|
|
94
|
+
this.offset = 0;
|
|
95
|
+
this.pendingTail = '';
|
|
96
|
+
}
|
|
97
|
+
if (size > this.offset) {
|
|
98
|
+
let chunk;
|
|
99
|
+
try {
|
|
100
|
+
const fd = openSync(this.logPath, 'r');
|
|
101
|
+
try {
|
|
102
|
+
const buf = Buffer.alloc(size - this.offset);
|
|
103
|
+
readSync(fd, buf, 0, buf.length, this.offset);
|
|
104
|
+
chunk = buf.toString('utf8');
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.offset = size;
|
|
114
|
+
this.pendingTail += chunk;
|
|
115
|
+
}
|
|
116
|
+
// Scan complete lines (and any trailing line without newline).
|
|
117
|
+
for (const line of this.pendingTail.split('\n')) {
|
|
118
|
+
const message = this.matchLimitError(line);
|
|
119
|
+
if (message === null)
|
|
120
|
+
continue;
|
|
121
|
+
const onError = this.onError;
|
|
122
|
+
this.stop();
|
|
123
|
+
onError(message);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Keep only the last partial line (everything before the last '\n' was
|
|
127
|
+
// fully scanned above and did not match).
|
|
128
|
+
const lastNewline = this.pendingTail.lastIndexOf('\n');
|
|
129
|
+
if (lastNewline >= 0) {
|
|
130
|
+
this.pendingTail = this.pendingTail.slice(lastNewline + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Match a main-turn stream error line for the monitored session with a
|
|
134
|
+
* limit/quota reason; returns the extracted error text or null. */
|
|
135
|
+
matchLimitError(line) {
|
|
136
|
+
if (this.sessionId === null)
|
|
137
|
+
return null;
|
|
138
|
+
if (!line.includes(this.sessionId))
|
|
139
|
+
return null;
|
|
140
|
+
if (!STREAM_ERROR_RE.test(line))
|
|
141
|
+
return null;
|
|
142
|
+
if (!MAIN_TURN_RE.test(line))
|
|
143
|
+
return null;
|
|
144
|
+
const message = ERROR_MESSAGE_RE.exec(line)?.[1];
|
|
145
|
+
if (!message || !LIMIT_ERROR_RE.test(message))
|
|
146
|
+
return null;
|
|
147
|
+
return message;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=error-monitor.js.map
|
|
@@ -43,6 +43,10 @@ export interface OpencodeAcpRunnerOptions {
|
|
|
43
43
|
model?: string;
|
|
44
44
|
/** Configured session mode: 'build' (default) or 'plan' (opencode agent names). */
|
|
45
45
|
mode?: 'build' | 'plan';
|
|
46
|
+
/** Path to opencode's own log file (default: resolveOpencodeLogPath()). */
|
|
47
|
+
errorMonitorLogPath?: string;
|
|
48
|
+
/** Tail poll interval for the opencode log error monitor. Defaults to 2000ms. */
|
|
49
|
+
errorMonitorPollIntervalMs?: number;
|
|
46
50
|
}
|
|
47
51
|
export declare class OpencodeAcpRunner extends ConnectionBasedRunner<JsonRpcClient, OpencodeAcpTranslatorEvent> {
|
|
48
52
|
private connectionManager;
|
|
@@ -55,6 +59,9 @@ export declare class OpencodeAcpRunner extends ConnectionBasedRunner<JsonRpcClie
|
|
|
55
59
|
private currentModeId?;
|
|
56
60
|
/** Current model value (`provider/model`), parsed from configOptions. */
|
|
57
61
|
private currentModelValue?;
|
|
62
|
+
/** Tails opencode's own log to surface LLM stream errors the ACP protocol
|
|
63
|
+
* never forwards (quota/limit errors back off for ~1h → turn would hang). */
|
|
64
|
+
private readonly errorMonitor;
|
|
58
65
|
/** Pending approval requests: requestId → kind + view + options. */
|
|
59
66
|
private pendingApprovals;
|
|
60
67
|
/** Tracker for the in-flight prompt request — needed for cancellation. */
|
|
@@ -71,6 +78,16 @@ export declare class OpencodeAcpRunner extends ConnectionBasedRunner<JsonRpcClie
|
|
|
71
78
|
protected shouldDeferStop(): boolean;
|
|
72
79
|
protected cancelCurrentTurn(): Promise<void>;
|
|
73
80
|
protected clearTurnState(): void;
|
|
81
|
+
/**
|
|
82
|
+
* Start watching opencode's own log for LLM stream errors of the active
|
|
83
|
+
* session. opencode ACP does not surface retry/error state to the client
|
|
84
|
+
* (no error/status notification type, session/list has no status field), so
|
|
85
|
+
* the log is the only channel that carries the real provider error. Without
|
|
86
|
+
* this, a quota error (e.g. opencode-go weekly limit) leaves session/prompt
|
|
87
|
+
* pending for the full retry-after backoff (~1h) and the Feishu card hangs
|
|
88
|
+
* until the idle watchdog.
|
|
89
|
+
*/
|
|
90
|
+
private startErrorMonitor;
|
|
74
91
|
protected releaseConnection(cwd: string): Promise<void>;
|
|
75
92
|
protected notifyIdle(cwd: string): void;
|
|
76
93
|
protected disposeConnections(): Promise<void>;
|
|
@@ -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
|
package/dist/runner/types.d.ts
CHANGED
|
@@ -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
|
|
39
|
-
const
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
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",
|