lark-remote 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.en.md +2 -6
  3. package/README.md +3 -7
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/inbound-media.d.ts +0 -3
  7. package/dist/bridge/inbound-media.js +0 -1
  8. package/dist/bridge/index.d.ts +7 -7
  9. package/dist/bridge/index.js +100 -133
  10. package/dist/bridge/queue-manager.js +4 -3
  11. package/dist/card/approval-render.js +39 -94
  12. package/dist/card/bash-renderer.js +12 -21
  13. package/dist/card/card-shared.d.ts +14 -5
  14. package/dist/card/card-shared.js +23 -34
  15. package/dist/card/run-renderer.d.ts +0 -22
  16. package/dist/card/run-renderer.js +206 -153
  17. package/dist/card/run-state.d.ts +29 -1
  18. package/dist/card/run-state.js +172 -84
  19. package/dist/card/text-truncate.d.ts +0 -9
  20. package/dist/card/text-truncate.js +0 -9
  21. package/dist/card/tool-render.d.ts +4 -1
  22. package/dist/card/tool-render.js +76 -32
  23. package/dist/cli.js +2 -2
  24. package/dist/common/fs.d.ts +14 -0
  25. package/dist/common/fs.js +57 -3
  26. package/dist/common/truncate.d.ts +0 -6
  27. package/dist/common/truncate.js +1 -1
  28. package/dist/common/ttl-cache.d.ts +24 -0
  29. package/dist/common/ttl-cache.js +53 -0
  30. package/dist/config/codex-config.js +14 -11
  31. package/dist/config/index.d.ts +0 -8
  32. package/dist/config/index.js +0 -16
  33. package/dist/config/kimi-config.d.ts +1 -1
  34. package/dist/config/kimi-config.js +2 -1
  35. package/dist/config/opencode-config.d.ts +1 -1
  36. package/dist/config/opencode-config.js +16 -10
  37. package/dist/config/qr.d.ts +36 -0
  38. package/dist/config/qr.js +124 -0
  39. package/dist/config/wizard.js +69 -27
  40. package/dist/connector/index.d.ts +3 -2
  41. package/dist/connector/index.js +5 -3
  42. package/dist/index.js +14 -41
  43. package/dist/instance-lock.js +7 -0
  44. package/dist/persistence/atomic-write.d.ts +2 -0
  45. package/dist/persistence/atomic-write.js +20 -5
  46. package/dist/persistence/load-json-file.d.ts +1 -2
  47. package/dist/persistence/load-json-file.js +2 -3
  48. package/dist/platform/agent-stopper.d.ts +22 -0
  49. package/dist/platform/agent-stopper.js +19 -0
  50. package/dist/platform/command.d.ts +11 -0
  51. package/dist/platform/command.js +107 -0
  52. package/dist/platform/identity.d.ts +33 -0
  53. package/dist/platform/identity.js +187 -0
  54. package/dist/platform/path.d.ts +24 -0
  55. package/dist/platform/path.js +110 -0
  56. package/dist/platform/select.d.ts +9 -0
  57. package/dist/platform/select.js +12 -0
  58. package/dist/platform/shell.d.ts +51 -0
  59. package/dist/platform/shell.js +75 -0
  60. package/dist/platform/spawn.d.ts +41 -0
  61. package/dist/platform/spawn.js +77 -0
  62. package/dist/platform/terminator-posix.d.ts +38 -0
  63. package/dist/platform/terminator-posix.js +103 -0
  64. package/dist/platform/terminator-win32.d.ts +21 -0
  65. package/dist/platform/terminator-win32.js +110 -0
  66. package/dist/platform/terminator.d.ts +44 -0
  67. package/dist/platform/terminator.js +24 -0
  68. package/dist/platform/types.d.ts +23 -0
  69. package/dist/platform/types.js +5 -0
  70. package/dist/restart.js +4 -2
  71. package/dist/router/card-helpers.d.ts +33 -6
  72. package/dist/router/card-helpers.js +67 -11
  73. package/dist/router/config/common/render.d.ts +1 -1
  74. package/dist/router/config/common/render.js +1 -1
  75. package/dist/router/config-diff.d.ts +4 -0
  76. package/dist/router/config-diff.js +70 -0
  77. package/dist/router/index.d.ts +73 -6
  78. package/dist/router/index.js +663 -590
  79. package/dist/router/utils.d.ts +6 -0
  80. package/dist/router/utils.js +12 -0
  81. package/dist/runner/bash/runner.d.ts +9 -2
  82. package/dist/runner/bash/runner.js +45 -20
  83. package/dist/runner/claude/session.d.ts +4 -4
  84. package/dist/runner/claude/session.js +18 -9
  85. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  86. package/dist/runner/codex/app-server/runner.d.ts +1 -16
  87. package/dist/runner/codex/app-server/runner.js +2 -2
  88. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  89. package/dist/runner/codex/app-server/translator.js +146 -0
  90. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  91. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  92. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  93. package/dist/runner/common/acp/base-translator.js +271 -0
  94. package/dist/runner/common/acp/protocol-helpers.d.ts +70 -0
  95. package/dist/runner/common/acp/protocol-helpers.js +82 -0
  96. package/dist/runner/common/connection-based-runner.d.ts +0 -2
  97. package/dist/runner/common/connection-based-runner.js +1 -1
  98. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  99. package/dist/runner/common/jsonrpc/transport.js +25 -5
  100. package/dist/runner/common/process-stopper.d.ts +4 -26
  101. package/dist/runner/common/process-stopper.js +4 -76
  102. package/dist/runner/common/runner-utils.d.ts +0 -5
  103. package/dist/runner/common/runner-utils.js +0 -7
  104. package/dist/runner/common/spawning-runner.d.ts +42 -132
  105. package/dist/runner/common/spawning-runner.js +52 -271
  106. package/dist/runner/common/usage.d.ts +2 -0
  107. package/dist/runner/common/usage.js +2 -0
  108. package/dist/runner/dsh/index.d.ts +0 -2
  109. package/dist/runner/dsh/index.js +0 -2
  110. package/dist/runner/dsh/runner.d.ts +0 -2
  111. package/dist/runner/dsh/runner.js +1 -1
  112. package/dist/runner/index.d.ts +1 -1
  113. package/dist/runner/kimi/acp/runner.d.ts +63 -53
  114. package/dist/runner/kimi/acp/runner.js +189 -334
  115. package/dist/runner/kimi/acp/translator.d.ts +18 -101
  116. package/dist/runner/kimi/acp/translator.js +61 -255
  117. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  118. package/dist/runner/opencode/acp/runner.js +74 -297
  119. package/dist/runner/opencode/acp/translator.d.ts +28 -104
  120. package/dist/runner/opencode/acp/translator.js +54 -249
  121. package/dist/runner/pi/rpc/index.d.ts +0 -2
  122. package/dist/runner/pi/rpc/index.js +0 -2
  123. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  124. package/dist/runner/pi/rpc/translator.js +47 -9
  125. package/dist/runner/probe.d.ts +8 -11
  126. package/dist/runner/probe.js +17 -21
  127. package/dist/runner/registry.d.ts +4 -4
  128. package/dist/runner/registry.js +4 -4
  129. package/dist/runner/resolve-agent-choices.js +6 -2
  130. package/dist/runner/sync-agent-choices.js +1 -1
  131. package/dist/runner/types.d.ts +38 -1
  132. package/dist/session/claude/session-index.js +3 -1
  133. package/dist/session/claude/sessions.js +37 -64
  134. package/dist/session/codex/rollout-reader.d.ts +10 -14
  135. package/dist/session/codex/rollout-reader.js +126 -48
  136. package/dist/session/common/constants.d.ts +7 -0
  137. package/dist/session/common/constants.js +9 -0
  138. package/dist/session/common/pagination.d.ts +0 -2
  139. package/dist/session/common/pagination.js +1 -1
  140. package/dist/session/common/recency.d.ts +7 -0
  141. package/dist/session/common/recency.js +10 -0
  142. package/dist/session/common/two-pass.d.ts +22 -0
  143. package/dist/session/common/two-pass.js +70 -0
  144. package/dist/session/common/usage-accumulator.d.ts +14 -0
  145. package/dist/session/common/usage-accumulator.js +15 -0
  146. package/dist/session/dsh/sessions.js +15 -11
  147. package/dist/session/kimi/sessions.d.ts +24 -4
  148. package/dist/session/kimi/sessions.js +99 -103
  149. package/dist/session/opencode/sessions.d.ts +2 -14
  150. package/dist/session/opencode/sessions.js +35 -15
  151. package/dist/session/pi/sessions.js +64 -95
  152. package/dist/update/index.d.ts +2 -3
  153. package/dist/update/index.js +1 -2
  154. package/dist/update/install.d.ts +2 -1
  155. package/dist/update/install.js +58 -9
  156. package/dist/update/version-check.d.ts +5 -0
  157. package/dist/update/version-check.js +9 -0
  158. package/package.json +9 -7
  159. package/dist/update/startup-hint.d.ts +0 -5
  160. package/dist/update/startup-hint.js +0 -11
@@ -9,9 +9,13 @@ export function resolveAgentChoices(config) {
9
9
  if (agent === 'claude') {
10
10
  return config;
11
11
  }
12
- const agentChoices = choices[agent];
13
12
  const fields = choiceFieldsFor(agent);
14
- if (!agentChoices || !fields) {
13
+ // dsh 的 choices 恒为空(host 是连接配置而非 per-run choice),无需恢复
14
+ if (!fields || fields.length === 0) {
15
+ return config;
16
+ }
17
+ const agentChoices = choices[agent];
18
+ if (!agentChoices) {
15
19
  return config;
16
20
  }
17
21
  // Clone to avoid mutation
@@ -2,7 +2,7 @@ import { choiceFieldsFor } from './agent-choices-common.js';
2
2
  export function syncAgentChoices(config, agent) {
3
3
  // claude 的配置在顶层 config.claude,不在 agents 下,无需同步
4
4
  const fields = choiceFieldsFor(agent);
5
- if (!fields)
5
+ if (!fields || fields.length === 0)
6
6
  return config;
7
7
  const agents = config.agents;
8
8
  const agentCfg = agents?.[agent];
@@ -215,6 +215,10 @@ interface ToolUseContent {
215
215
  id: string;
216
216
  name: string;
217
217
  input: unknown;
218
+ /**
219
+ * 工具原始 title(ACP kind 映射后与 name 不同时保存;渲染层做面板副标题)。
220
+ */
221
+ summary?: string;
218
222
  }
219
223
  interface ToolResultContent {
220
224
  type: 'tool_result';
@@ -259,6 +263,8 @@ export interface ResultEvent {
259
263
  /** 当前模型 context window 上限(codex app-server tokenUsage.modelContextWindow
260
264
  * 透传)。仅 app-server 提供;缺省时卡片只显示绝对量、不显示百分比。 */
261
265
  context_limit?: number;
266
+ /** 推理 token(pi 提供)。缺省不展示。 */
267
+ reasoning_tokens?: number;
262
268
  };
263
269
  total_cost_usd?: number;
264
270
  errorMessage?: string;
@@ -275,6 +281,21 @@ export interface PlanEvent {
275
281
  plan: string;
276
282
  timestamp?: string;
277
283
  }
284
+ /** 运行期通知(warning、模型重路由、压缩提示等)。translator 产出,公共层无分支渲染。 */
285
+ export interface NoticeEvent {
286
+ type: 'notice';
287
+ level: 'info' | 'warn' | 'error';
288
+ code?: string;
289
+ text: string;
290
+ timestamp?: string;
291
+ }
292
+ /** 会话元信息(ACP session_info_update / current_mode_update)。字段缺省表示本次未变。 */
293
+ export interface SessionInfoEvent {
294
+ type: 'session_info';
295
+ title?: string;
296
+ mode?: string;
297
+ timestamp?: string;
298
+ }
278
299
  /** File change event — for agents that report file modifications (e.g. Codex). */
279
300
  export interface FileChangeEvent {
280
301
  type: 'file_change';
@@ -319,14 +340,30 @@ export interface TurnDiffEvent {
319
340
  * tool status ok, completedAt stamped. Never reorders blocks.
320
341
  */
321
342
  complete?: boolean;
343
+ /**
344
+ * Kimi ACP snapshot semantics: replace the existing block's anchor
345
+ * timestamp with THIS diff's timestamp on every update (text/reasoning).
346
+ * Kimi's `'text'`/`'thinking'` are whole-turn accumulators that keep
347
+ * receiving late deltas while tools run, so a first-sight timestamp makes
348
+ * the header (and its position after move-to-bottom) look stale. Other
349
+ * snapshot streams (codex app-server) keep the first-sight timestamp by
350
+ * contract — this flag stays false/undefined for them.
351
+ */
352
+ refreshTimestamp?: boolean;
322
353
  /** Authoritative tool status at completion (commandExecution item). */
323
354
  toolStatus?: 'ok' | 'error';
355
+ /** 工具身份(commandExecution/mcpToolCall 等 item 首次发出时携带)。 */
356
+ toolName?: string;
357
+ toolInput?: Record<string, unknown>;
358
+ toolHint?: ToolHint;
324
359
  threadId: string;
325
360
  turnId: string;
326
361
  timestamp?: string;
327
362
  }
328
363
  export type RunnerLifetime = 'turn' | 'workspace';
329
- export type AgentEvent = SystemInitEvent | SystemCompactEvent | ApprovalRequestedEvent | ApprovalResolvedEvent | ApprovalViewUpdatedEvent | ApprovalExpiredEvent | AssistantEvent | UserEvent | ResultEvent | PlanEvent | FileChangeEvent | TurnStartedEvent | TurnDiffEvent;
364
+ /** 工具渲染 hint:runner 自愿声明,渲染层只消费不推断。 */
365
+ export type ToolHint = 'shell' | 'file' | 'search' | 'web' | 'patch';
366
+ export type AgentEvent = SystemInitEvent | SystemCompactEvent | ApprovalRequestedEvent | ApprovalResolvedEvent | ApprovalViewUpdatedEvent | ApprovalExpiredEvent | AssistantEvent | UserEvent | ResultEvent | PlanEvent | FileChangeEvent | TurnStartedEvent | TurnDiffEvent | NoticeEvent | SessionInfoEvent;
330
367
  export interface SpawnOptions {
331
368
  cwd: string;
332
369
  sessionId?: string;
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { scanJsonlLines } from '../common/jsonl.js';
4
+ import { sortByRecencyDesc } from '../common/recency.js';
4
5
  import { extractContentBlocks } from '../common/content-blocks.js';
5
6
  import { getLogger } from '../../logger/index.js';
6
7
  // ─── Claude content block mapping (shared with sessions.ts) ─
@@ -175,7 +176,8 @@ export class SessionIndex {
175
176
  byId.set(entry.sessionId, entry);
176
177
  }
177
178
  }
178
- const sorted = [...byId.values()].sort((a, b) => b.mtimeMs - a.mtimeMs || a.sessionId.localeCompare(b.sessionId));
179
+ const sorted = [...byId.values()];
180
+ sortByRecencyDesc(sorted, (s) => s.mtimeMs, (s) => s.sessionId);
179
181
  const offset = opts.offset ?? 0;
180
182
  const limit = opts.limit;
181
183
  const sliced = limit === undefined ? sorted.slice(offset) : sorted.slice(offset, offset + limit);
@@ -1,16 +1,19 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
- import { findJsonlLine, readLastJsonlLine, scanJsonlLines, readJsonlLinesFromOffset, } from '../common/jsonl.js';
5
- import { STALE_MS } from '../common/constants.js';
6
- import { capEvents } from '../common/pagination.js';
4
+ import { findJsonlLine, readLastJsonlLine } from '../common/jsonl.js';
5
+ import { isStale } from '../common/constants.js';
6
+ import { samePath, encodeProjectDirName } from '../../platform/path.js';
7
7
  import { extractContentBlocks } from '../common/content-blocks.js';
8
- import { UsageAccumulator } from '../common/usage-accumulator.js';
8
+ import { scanJsonlOnce, tailOffsetAfter, scanTailEvents } from '../common/two-pass.js';
9
+ import { UsageAccumulator, contextWindowOccupancy, cumulativeUsageFields, } from '../common/usage-accumulator.js';
9
10
  import { SessionIndex, extractText, isTaskNotificationText, CLAUDE_MAPPING, } from './session-index.js';
10
11
  /**
11
12
  * Directory Claude Code uses to store session transcripts for a given cwd.
12
13
  * Claude encodes the cwd by replacing `/` with `-` (e.g.
13
14
  * `/Users/x/proj` -> `-Users-x-proj`). Underscores are also replaced with `-`.
15
+ * win32 上 `\` 同样是分隔符、`:` 是非法文件名字符,一并归一为 `-`
16
+ * (`C:\Users\x\proj` -> `C--Users-x-proj`);见 encodeProjectDirName。
14
17
  *
15
18
  * Note: this encoding is LOSSY (`disk_d/foo` and `disk-d/foo` both encode to
16
19
  * `disk-d-foo`). It is only safe to use the result to **locate** files; never
@@ -18,8 +21,7 @@ import { SessionIndex, extractText, isTaskNotificationText, CLAUDE_MAPPING, } fr
18
21
  * from the JSONL content (regression 2026-06-21 /resume & /cd 路径错乱).
19
22
  */
20
23
  function projectDirForCwd(cwd, projectsDir) {
21
- const encoded = cwd.replace(/\//g, '-').replace(/_/g, '-');
22
- return path.join(projectsDir, encoded);
24
+ return path.join(projectsDir, encodeProjectDirName(cwd));
23
25
  }
24
26
  /**
25
27
  * cwd guard for the index-miss fallback (readSessionContent /
@@ -35,7 +37,8 @@ function fileContainsCwd(filePath, cwd) {
35
37
  return (findJsonlLine(filePath, (l) => {
36
38
  try {
37
39
  const obj = JSON.parse(l);
38
- return typeof obj.cwd === 'string' && obj.cwd === cwd;
40
+ // PathKit.samePath:darwin/win32 大小写不敏感 + 尾分隔符不敏感,linux 严格
41
+ return typeof obj.cwd === 'string' && samePath(obj.cwd, cwd);
39
42
  }
40
43
  catch {
41
44
  return false; // skip malformed line
@@ -144,7 +147,7 @@ export function getNewestSession(cwd, opts = {}) {
144
147
  */
145
148
  function isSessionActive(filePath, mtimeMs, now = Date.now()) {
146
149
  // Hard signal: file untouched for over an hour → can't be from a live process
147
- if (now - mtimeMs > STALE_MS)
150
+ if (isStale(mtimeMs, now))
148
151
  return false;
149
152
  const lastLine = readLastJsonlLine(filePath);
150
153
  if (!lastLine)
@@ -239,14 +242,7 @@ function scalarScan(filePath) {
239
242
  let lastAiTitle;
240
243
  let lastRecap;
241
244
  let lastUserMessage;
242
- scanJsonlLines(filePath, (line, offset) => {
243
- let obj;
244
- try {
245
- obj = JSON.parse(line);
246
- }
247
- catch {
248
- return;
249
- }
245
+ scanJsonlOnce(filePath, (obj, line, offset) => {
250
246
  // --- usage aggregation (was aggregateSessionUsage) ---
251
247
  if (obj.type === 'assistant') {
252
248
  const msg = obj.message;
@@ -273,12 +269,11 @@ function scalarScan(filePath) {
273
269
  }
274
270
  // --- last user message offset ---
275
271
  // Record the byte offset where the tail should start: the beginning of
276
- // the NEXT line after this user message. We compute it as this line's
277
- // start offset + its byte length + 1 (the trailing '\n').
272
+ // the NEXT line after this user message (see tailOffsetAfter).
278
273
  if (obj.type === 'user') {
279
274
  const msg = obj.message;
280
275
  if (msg?.role === 'user') {
281
- tailOffset = offset + Buffer.byteLength(line, 'utf-8') + 1;
276
+ tailOffset = tailOffsetAfter(line, offset);
282
277
  }
283
278
  }
284
279
  // --- ai-title extraction ---
@@ -329,7 +324,7 @@ function scalarScan(filePath) {
329
324
  // contract — excludes output/reasoning, which are generated, not part of
330
325
  // the input window). Take the max so a pre-compact turn larger than the
331
326
  // post-compact size is not under-reported.
332
- const lastWindow = l ? l.input + l.cacheRead + l.cacheCreation : 0;
327
+ const lastWindow = l ? contextWindowOccupancy(l) : 0;
333
328
  let contextLength = lastPostTokens;
334
329
  if (contextLength === 0 || lastWindow > contextLength) {
335
330
  contextLength = lastWindow;
@@ -347,11 +342,7 @@ function scalarScan(filePath) {
347
342
  cacheReadTokens: l && l.cacheRead > 0 ? l.cacheRead : undefined,
348
343
  cacheCreationTokens: l && l.cacheCreation > 0 ? l.cacheCreation : undefined,
349
344
  totalTokens: lastTotal,
350
- cumulativeTotalTokens: t.input + t.output + t.cacheRead + t.cacheCreation,
351
- cumulativeInputTokens: t.input,
352
- cumulativeOutputTokens: t.output,
353
- cumulativeCacheReadTokens: t.cacheRead,
354
- cumulativeCacheCreationTokens: t.cacheCreation,
345
+ ...cumulativeUsageFields(t),
355
346
  };
356
347
  return {
357
348
  tailOffset,
@@ -362,47 +353,30 @@ function scalarScan(filePath) {
362
353
  };
363
354
  }
364
355
  /**
365
- * P2-2 + P2-5 second-pass: extract content-block events from the tail
366
- * (lines after the last user message), re-parsing only those lines.
367
- * `tailLines` is already the tail-only slice (read via
368
- * `readJsonlLinesFromOffset`), so both raw line-string memory and parsed-
369
- * object memory are O(tail), not O(whole file) — P3-1 + P2-5.
356
+ * P2-2 + P2-5 second-pass tail mapper: expand one parsed tail line into
357
+ * content-block events. Re-parses only tail lines (scanTailEvents does the
358
+ * offset re-read + line filtering), so both raw line-string memory and
359
+ * parsed-object memory are O(tail), not O(whole file) — P3-1 + P2-5.
370
360
  *
371
- * `maxEvents` (optional): when set, only the LAST `maxEvents` events are kept
372
- * (review P2-7 — unified "last N events" contract across all five readers,
373
- * matching codex/opencode/pi's `slice(-maxEvents)`). The whole tail is parsed
374
- * first (usage/title extraction above already scanned the file; this pass only
375
- * re-parses the short tail), then `slice(-maxEvents)` keeps the most recent
376
- * events. `maxEvents <= 0` returns `[]` (guards the `slice(-0) === slice(0)`
377
- * full-array trap).
361
+ * `maxEvents` capping (review P2-7 — unified "last N events" contract across
362
+ * all readers) is applied by scanTailEvents.
378
363
  */
379
- function extractEventsFromTail(tailLines, maxEvents) {
364
+ function mapClaudeTailLine(obj) {
365
+ const message = obj.message;
366
+ // Expand all content blocks from this message into separate events.
367
+ const blocks = extractContentBlocks(message?.content, CLAUDE_MAPPING);
368
+ const role = message?.role ?? obj.type ?? 'unknown';
369
+ const timestamp = obj.timestamp;
380
370
  const events = [];
381
- for (const line of tailLines) {
382
- if (!line.trim())
383
- continue;
384
- let obj;
385
- try {
386
- obj = JSON.parse(line);
387
- }
388
- catch {
389
- continue;
390
- }
391
- const message = obj.message;
392
- // Expand all content blocks from this message into separate events.
393
- const blocks = extractContentBlocks(message?.content, CLAUDE_MAPPING);
394
- const role = message?.role ?? obj.type ?? 'unknown';
395
- const timestamp = obj.timestamp;
396
- for (const block of blocks) {
397
- events.push({ type: block.type, content: block.content, timestamp });
398
- }
399
- // If message has no content blocks (e.g., metadata-only lines like last-prompt),
400
- // still emit an event with the raw type so users see what happened
401
- if (blocks.length === 0 && message?.role) {
402
- events.push({ type: role, content: `(${role} event)`, timestamp });
403
- }
371
+ for (const block of blocks) {
372
+ events.push({ type: block.type, content: block.content, timestamp });
373
+ }
374
+ // If message has no content blocks (e.g., metadata-only lines like last-prompt),
375
+ // still emit an event with the raw type so users see what happened
376
+ if (blocks.length === 0 && message?.role) {
377
+ events.push({ type: role, content: `(${role} event)`, timestamp });
404
378
  }
405
- return capEvents(events, maxEvents);
379
+ return events;
406
380
  }
407
381
  export function readSessionContent(sessionId, cwd, opts = {}) {
408
382
  const { maxEvents } = opts;
@@ -462,8 +436,7 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
462
436
  // only the tail. If no user message was found (tailOffset === -1), fall
463
437
  // back to re-reading the whole file so the card still has something to
464
438
  // show.
465
- const tailLines = readJsonlLinesFromOffset(filePath, scan.tailOffset >= 0 ? scan.tailOffset : 0);
466
- const events = extractEventsFromTail(tailLines, maxEvents);
439
+ const events = scanTailEvents(filePath, scan.tailOffset, mapClaudeTailLine, maxEvents);
467
440
  // Use aiTitle if available, otherwise fallback to last user message.
468
441
  // Truncation is deferred to the consumer (router card building).
469
442
  const displayTitle = scan.aiTitle ?? scan.lastUserMessage ?? undefined;
@@ -10,6 +10,16 @@
10
10
  * {"timestamp":"...","type":"response_item","payload":{"type":"message","role":"user|developer","content":[...]}}
11
11
  * ```
12
12
  *
13
+ * Event vocabulary drift (codex CLI ≥0.153.4, transitional from 0.147.0): the
14
+ * top-level `user_message` / `agent_message` events were removed; the same
15
+ * payloads now ride on `event_msg` → `item_completed` envelopes whose
16
+ * `item.type` is `UserMessage` / `AgentMessage` / `CommandExecution` / … .
17
+ * Both spellings stay readable — `extractRealUserText` adapts per file.
18
+ *
19
+ * Deprecation: if ~6 months pass (≈2027-03) with the legacy `user_message` /
20
+ * `agent_message` events still absent from every codex CLI and the old-codex
21
+ * session tail effectively gone, drop the legacy branch + its fixtures.
22
+ *
13
23
  * Note: This handles the **rollout file format** (historical logging), NOT the
14
24
  * `codex exec --json` stdout format(已随 exec 模式移除)。
15
25
  */
@@ -21,7 +31,6 @@ interface CodexRolloutEntry {
21
31
  firstUserMessage: string;
22
32
  /** Last real (human-typed) user message, for the "最近输入" card label. */
23
33
  lastRealUserMessage: string;
24
- createdAtMs: number;
25
34
  updatedAtMs: number;
26
35
  events: AgentSessionContentEvent[];
27
36
  /** Last-turn usage extracted from token_count events (ccusage-aligned). */
@@ -38,19 +47,6 @@ interface ListCodexRolloutsOptions {
38
47
  offset?: number;
39
48
  }
40
49
  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;
54
50
  /**
55
51
  * List Codex sessions from rollout files.
56
52
  *
@@ -10,6 +10,16 @@
10
10
  * {"timestamp":"...","type":"response_item","payload":{"type":"message","role":"user|developer","content":[...]}}
11
11
  * ```
12
12
  *
13
+ * Event vocabulary drift (codex CLI ≥0.153.4, transitional from 0.147.0): the
14
+ * top-level `user_message` / `agent_message` events were removed; the same
15
+ * payloads now ride on `event_msg` → `item_completed` envelopes whose
16
+ * `item.type` is `UserMessage` / `AgentMessage` / `CommandExecution` / … .
17
+ * Both spellings stay readable — `extractRealUserText` adapts per file.
18
+ *
19
+ * Deprecation: if ~6 months pass (≈2027-03) with the legacy `user_message` /
20
+ * `agent_message` events still absent from every codex CLI and the old-codex
21
+ * session tail effectively gone, drop the legacy branch + its fixtures.
22
+ *
13
23
  * Note: This handles the **rollout file format** (historical logging), NOT the
14
24
  * `codex exec --json` stdout format(已随 exec 模式移除)。
15
25
  */
@@ -17,7 +27,9 @@ import fs from 'node:fs';
17
27
  import path from 'node:path';
18
28
  import { readJsonlLines, findJsonlLine } from '../common/jsonl.js';
19
29
  import { STALE_MS } from '../common/constants.js';
30
+ import { samePath } from '../../platform/path.js';
20
31
  import { paginate, capEvents } from '../common/pagination.js';
32
+ import { sortByRecencyDesc } from '../common/recency.js';
21
33
  import { getLogger } from '../../logger/index.js';
22
34
  import { isRecord, stringValue } from '../../common/guards.js';
23
35
  import { resolveCodexHome } from '../../config/codex-config.js';
@@ -45,11 +57,7 @@ export function readCodexRollout(filePath) {
45
57
  if (!sessionId) {
46
58
  return null;
47
59
  }
48
- // Use file mtime as updatedAtMs, or parse timestamp from session_meta
49
60
  const stats = fs.statSync(filePath);
50
- const createdAtMs = sessionMeta.timestamp
51
- ? new Date(String(sessionMeta.timestamp)).getTime()
52
- : stats.birthtimeMs;
53
61
  const updatedAtMs = stats.mtimeMs;
54
62
  const firstUserMessage = scan.realUserMessages[0] ?? '';
55
63
  const lastRealUserMessage = scan.realUserMessages[scan.realUserMessages.length - 1] ?? '';
@@ -59,7 +67,6 @@ export function readCodexRollout(filePath) {
59
67
  cwd,
60
68
  firstUserMessage: firstUserMessage || '(no user message)',
61
69
  lastRealUserMessage,
62
- createdAtMs,
63
70
  updatedAtMs,
64
71
  events: scan.events,
65
72
  usage,
@@ -79,7 +86,7 @@ export function readCodexRollout(filePath) {
79
86
  *
80
87
  * Returns null if the file doesn't exist, is corrupted, or has no session_meta.
81
88
  */
82
- export function readCodexRolloutSummary(filePath) {
89
+ function readCodexRolloutSummary(filePath) {
83
90
  if (!fs.existsSync(filePath)) {
84
91
  return null;
85
92
  }
@@ -107,15 +114,23 @@ function scanCodexRollout(filePath, includeEvents) {
107
114
  const lines = readJsonlLines(filePath);
108
115
  const events = [];
109
116
  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
- // "最近输入".
117
+ // Real user input comes from the authoritative "human typed this" event —
118
+ // `user_message` on old codex, `item_completed`/UserMessage on new codex
119
+ // (see `extractRealUserText`). Injected scaffolding (project rules,
120
+ // <environment_context>, permissions) is written as `role:"user"`
121
+ // response_items but gets NEITHER event, so it stays excluded from
122
+ // displayTitle/recap/summary. Regression 2026-07-13: the first `role:user`
123
+ // message is the injected project rules, mistakenly shown as "最近输入".
118
124
  const realUserMessages = [];
125
+ // Which channel produced the last pushed message — used only to collapse a
126
+ // hypothetical double-write (same turn logged by both the legacy and the
127
+ // item channel), never to drop real consecutive input.
128
+ let lastUserMessageSource = null;
129
+ // Drift probe: count of `role:"user"` response_items (injected scaffolding
130
+ // included). If a file has those but yields zero real user messages, the
131
+ // event vocabulary changed again and displayTitle silently went empty —
132
+ // exactly the 0.153 compatibility incident shape.
133
+ let userResponseItemCount = 0;
119
134
  // token_count tracking: codex emits cumulative `total_token_usage` and an
120
135
  // incremental `last_token_usage`. We want the LAST turn's usage (matches
121
136
  // pi/opencode /resume "last turn" display semantics). When
@@ -152,13 +167,21 @@ function scanCodexRollout(filePath, includeEvents) {
152
167
  sessionMeta = parsed.payload;
153
168
  }
154
169
  else if (parsed.type === 'event_msg' && parsed.payload) {
155
- // user_message events carry the authoritative human-typed text.
156
170
  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));
171
+ const human = extractRealUserText(payload);
172
+ if (human) {
173
+ const text = human.text.slice(0, 200);
174
+ const prev = realUserMessages[realUserMessages.length - 1];
175
+ // 过渡期防御:同一 turn 若被旧版 user_message 与新版 item_completed
176
+ // 各写一次,会得到相邻重复。仅在「通道切换且文本与上一条完全相同」
177
+ // 时跳过——同通道连续相同输入(真人连发两条一样的话)不受影响。
178
+ const duplicateAcrossChannels = lastUserMessageSource !== null &&
179
+ lastUserMessageSource !== human.source &&
180
+ prev === text;
181
+ if (!duplicateAcrossChannels) {
182
+ realUserMessages.push(text);
161
183
  }
184
+ lastUserMessageSource = human.source;
162
185
  }
163
186
  else if (payload.type === 'token_count') {
164
187
  const info = payload.info;
@@ -209,6 +232,8 @@ function scanCodexRollout(filePath, includeEvents) {
209
232
  else if (includeEvents && parsed.type === 'response_item' && parsed.payload) {
210
233
  const payload = parsed.payload;
211
234
  if (payload.type === 'message' && Array.isArray(payload.content)) {
235
+ if (payload.role === 'user')
236
+ userResponseItemCount++;
212
237
  // Extract text content for display
213
238
  const messages = extractMessageContent(payload);
214
239
  for (const msg of messages) {
@@ -226,6 +251,14 @@ function scanCodexRollout(filePath, includeEvents) {
226
251
  continue;
227
252
  }
228
253
  }
254
+ // 兼容性漂移探针:文件里有 `role:"user"` 注入,却识别不出任何真人输入 ——
255
+ // 说明 codex 又换了 event_msg 词表,displayTitle 会静默变空(0.153 事故形态)。
256
+ // 只告警不兜底:误用注入文本当标题正是 2026-07-13 修掉的缺陷。
257
+ if (realUserMessages.length === 0 && userResponseItemCount > 0) {
258
+ getLogger().warn(`[codex-rollout-reader] no human user message recognised in ${filePath} ` +
259
+ `(${userResponseItemCount} role:"user" response_items seen) — codex rollout ` +
260
+ `event vocabulary may have changed; session title will be empty`);
261
+ }
229
262
  return {
230
263
  sessionMeta,
231
264
  events,
@@ -309,14 +342,14 @@ export function listCodexRollouts(opts = {}) {
309
342
  // session (main file missing) never pollutes the list or total.
310
343
  if (entry.isSubagent)
311
344
  continue;
312
- if (filterCwd && entry.cwd !== filterCwd)
345
+ if (filterCwd && !samePath(entry.cwd, filterCwd))
313
346
  continue;
314
347
  matched.push(entry);
315
348
  }
316
349
  // Establish the global order by mtime desc, then slice the page.
317
350
  // Same-mtime ties use filePath as a deterministic secondary key so index
318
351
  // rebuilds / walk order changes never reorder the page.
319
- matched.sort((a, b) => b.mtimeMs - a.mtimeMs || a.filePath.localeCompare(b.filePath));
352
+ sortByRecencyDesc(matched, (e) => e.mtimeMs, (e) => e.filePath);
320
353
  const { items: page, total } = paginate(matched, { limit: opts.limit, offset: opts.offset });
321
354
  // Full-parse only the page being returned (summary/usage).
322
355
  const entries = [];
@@ -327,6 +360,24 @@ export function listCodexRollouts(opts = {}) {
327
360
  }
328
361
  return { entries, total };
329
362
  }
363
+ /**
364
+ * W2.6 单源:三处(readCodexSessionContent / readCodexSessionSummary /
365
+ * isCodexSessionActive)共用的「索引查找 + miss 强制刷新重查」。
366
+ * cwd 守卫与 subagent 过滤语义各异,留在调用方。
367
+ */
368
+ function resolveRolloutEntry(sessionId, codexHome) {
369
+ // P2-4: Use session index for direct file lookup
370
+ let index = getSessionIndex(codexHome);
371
+ let entry = index.get(sessionId);
372
+ // P3-2: On miss, the index may be stale (a rollout file created after the
373
+ // cache was built but within the 5s TTL). Force a refresh and recheck so a
374
+ // brand-new session is found without waiting for TTL expiry.
375
+ if (!entry) {
376
+ index = getSessionIndex(codexHome, true);
377
+ entry = index.get(sessionId);
378
+ }
379
+ return entry;
380
+ }
330
381
  /**
331
382
  * Read the full content of a specific session by its threadId.
332
383
  *
@@ -342,16 +393,7 @@ export function listCodexRollouts(opts = {}) {
342
393
  */
343
394
  export function readCodexSessionContent(sessionId, opts = {}) {
344
395
  const codexHome = resolveCodexHome(opts.codexHome);
345
- // P2-4: Use session index for direct file lookup
346
- let index = getSessionIndex(codexHome);
347
- let entry = index.get(sessionId);
348
- // P3-2: On miss, the index may be stale (a rollout file created after the
349
- // cache was built but within the 5s TTL). Force a refresh and recheck so a
350
- // brand-new session is found without waiting for TTL expiry.
351
- if (!entry) {
352
- index = getSessionIndex(codexHome, true);
353
- entry = index.get(sessionId);
354
- }
396
+ const entry = resolveRolloutEntry(sessionId, codexHome);
355
397
  if (!entry) {
356
398
  return { events: [] };
357
399
  }
@@ -361,7 +403,7 @@ export function readCodexSessionContent(sessionId, opts = {}) {
361
403
  // Codex has no relocation (no EnterWorktree equivalent), so a simple equality
362
404
  // check suffices — unlike claude which needs jsonlContainsCwd to handle
363
405
  // relocated sessions with multiple cwd values.
364
- if (opts.cwd && entry.cwd !== opts.cwd) {
406
+ if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
365
407
  return { events: [] };
366
408
  }
367
409
  const rollout = readCodexRollout(entry.filePath);
@@ -391,18 +433,13 @@ export function readCodexSessionContent(sessionId, opts = {}) {
391
433
  */
392
434
  export function readCodexSessionSummary(sessionId, opts = {}) {
393
435
  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
- }
436
+ const entry = resolveRolloutEntry(sessionId, codexHome);
400
437
  if (!entry) {
401
438
  return {};
402
439
  }
403
440
  // Cwd guard: same semantics as readCodexSessionContent — when a cwd is
404
441
  // provided, the session's working directory must match.
405
- if (opts.cwd && entry.cwd !== opts.cwd) {
442
+ if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
406
443
  return {};
407
444
  }
408
445
  const summary = readCodexRolloutSummary(entry.filePath);
@@ -431,14 +468,7 @@ export function isCodexSessionActive(sessionId, opts = {}) {
431
468
  // all use STALE_MS). Previously codex used a divergent 10-minute default.
432
469
  const threshold = opts.activeThresholdMs ?? STALE_MS;
433
470
  // P2-4: Use session index for direct file lookup + mtime check
434
- let index = getSessionIndex(codexHome);
435
- let entry = index.get(sessionId);
436
- // P3-2: On miss, refresh the index in case the session file was created
437
- // after the cache was built (within the 5s TTL). See readCodexSessionContent.
438
- if (!entry) {
439
- index = getSessionIndex(codexHome, true);
440
- entry = index.get(sessionId);
441
- }
471
+ const entry = resolveRolloutEntry(sessionId, codexHome);
442
472
  if (!entry) {
443
473
  return false;
444
474
  }
@@ -450,7 +480,7 @@ export function isCodexSessionActive(sessionId, opts = {}) {
450
480
  }
451
481
  // Cwd guard: when a cwd is provided, the session's working directory must
452
482
  // match (align with claude/pi/opencode which validate cwd).
453
- if (opts.cwd && entry.cwd !== opts.cwd) {
483
+ if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
454
484
  return false;
455
485
  }
456
486
  // Re-stat to get fresh mtime (index may be up to INDEX_TTL_MS stale)
@@ -598,6 +628,54 @@ function getSessionIndex(resolvedCodexHome, forceRefresh = false) {
598
628
  export function clearSessionIndexCache() {
599
629
  sessionIndexCache.clear();
600
630
  }
631
+ /**
632
+ * Extract the human-typed text from a rollout `event_msg` payload, adapting to
633
+ * codex's two rollout event vocabularies.
634
+ *
635
+ * - Legacy (CLI ≤0.150.1): a dedicated `{"type":"user_message","message":…}`
636
+ * event. Codex emitted it ONLY for text the human actually typed; injected
637
+ * scaffolding (project rules, `<environment_context>`, permissions) is
638
+ * written as `role:"user"` response_items but gets NO such event, so it stays
639
+ * out of displayTitle/recap/summary. (Regression 2026-07-13: the first
640
+ * `role:user` message is the injected project rules, once shown as 最近输入.)
641
+ * - Current (CLI ≥0.153.4, transitional from 0.147.0): the `user_message` /
642
+ * `agent_message` events were removed; the same authoritative text now rides
643
+ * on `{"type":"item_completed","item":{"type":"UserMessage","content":[…]}}`.
644
+ * A UserMessage may bundle several content parts — `text` parts are the human
645
+ * input, `skill` parts are injected skill files (e.g. a `$skill-name`
646
+ * invocation) that must not leak into the title.
647
+ *
648
+ * Real-data note (2026-09): no rollout file observed carries BOTH spellings;
649
+ * 0.147–0.149 are transitional (either spelling per file), 0.153.4+/0.154.0 are
650
+ * item_completed-only. `source` reports which spelling matched so the scanner
651
+ * can collapse a hypothetical double-write of the same turn.
652
+ *
653
+ * TODO(2027-03-11): drop the legacy branch (and its fixtures) once ~6 months
654
+ * pass with the event still absent from every CLI and old-codex tails gone —
655
+ * mirrors the deprecation note in the module header.
656
+ */
657
+ function extractRealUserText(payload) {
658
+ if (payload.type === 'user_message') {
659
+ const text = stringValue(payload.message);
660
+ return text ? { text, source: 'legacy' } : null;
661
+ }
662
+ if (payload.type === 'item_completed') {
663
+ const item = payload.item;
664
+ if (!isRecord(item) || item.type !== 'UserMessage' || !Array.isArray(item.content)) {
665
+ return null;
666
+ }
667
+ const texts = [];
668
+ for (const part of item.content) {
669
+ if (!isRecord(part) || part.type !== 'text')
670
+ continue;
671
+ const text = stringValue(part.text);
672
+ if (text)
673
+ texts.push(text);
674
+ }
675
+ return texts.length > 0 ? { text: texts.join('\n'), source: 'item' } : null;
676
+ }
677
+ return null;
678
+ }
601
679
  function extractMessageContent(payload) {
602
680
  const result = [];
603
681
  const content = payload.content;