lark-remote 0.1.13 → 0.2.1

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 (132) hide show
  1. package/CHANGELOG.md +36 -3
  2. package/README.en.md +7 -11
  3. package/README.md +7 -11
  4. package/dist/bridge/final-usage.d.ts +24 -0
  5. package/dist/bridge/final-usage.js +55 -0
  6. package/dist/bridge/index.d.ts +0 -7
  7. package/dist/bridge/index.js +22 -64
  8. package/dist/bridge/queue-manager.js +4 -3
  9. package/dist/card/approval-render.js +39 -94
  10. package/dist/card/bash-renderer.js +12 -21
  11. package/dist/card/card-shared.d.ts +13 -1
  12. package/dist/card/card-shared.js +21 -30
  13. package/dist/card/run-renderer.d.ts +0 -22
  14. package/dist/card/run-renderer.js +206 -153
  15. package/dist/card/run-state.d.ts +29 -1
  16. package/dist/card/run-state.js +172 -84
  17. package/dist/card/text-truncate.d.ts +0 -9
  18. package/dist/card/text-truncate.js +0 -9
  19. package/dist/card/tool-render.d.ts +4 -1
  20. package/dist/card/tool-render.js +76 -32
  21. package/dist/cli.js +2 -2
  22. package/dist/common/fs.d.ts +14 -0
  23. package/dist/common/fs.js +57 -3
  24. package/dist/config/codex-config.js +2 -0
  25. package/dist/config/index.d.ts +0 -5
  26. package/dist/config/index.js +0 -11
  27. package/dist/config/kimi-config.js +1 -0
  28. package/dist/config/opencode-config.js +13 -2
  29. package/dist/config/qr.d.ts +36 -0
  30. package/dist/config/qr.js +124 -0
  31. package/dist/config/wizard.js +69 -27
  32. package/dist/connector/index.d.ts +3 -2
  33. package/dist/connector/index.js +5 -4
  34. package/dist/index.js +15 -42
  35. package/dist/instance-lock.js +7 -0
  36. package/dist/persistence/atomic-write.d.ts +2 -0
  37. package/dist/persistence/atomic-write.js +20 -5
  38. package/dist/persistence/load-json-file.d.ts +1 -2
  39. package/dist/persistence/load-json-file.js +2 -3
  40. package/dist/platform/agent-stopper.d.ts +22 -0
  41. package/dist/platform/agent-stopper.js +19 -0
  42. package/dist/platform/command.d.ts +11 -0
  43. package/dist/platform/command.js +107 -0
  44. package/dist/platform/identity.d.ts +33 -0
  45. package/dist/platform/identity.js +187 -0
  46. package/dist/platform/path.d.ts +24 -0
  47. package/dist/platform/path.js +110 -0
  48. package/dist/platform/select.d.ts +9 -0
  49. package/dist/platform/select.js +12 -0
  50. package/dist/platform/shell.d.ts +51 -0
  51. package/dist/platform/shell.js +75 -0
  52. package/dist/platform/spawn.d.ts +41 -0
  53. package/dist/platform/spawn.js +77 -0
  54. package/dist/platform/terminator-posix.d.ts +38 -0
  55. package/dist/platform/terminator-posix.js +103 -0
  56. package/dist/platform/terminator-win32.d.ts +21 -0
  57. package/dist/platform/terminator-win32.js +110 -0
  58. package/dist/platform/terminator.d.ts +44 -0
  59. package/dist/platform/terminator.js +24 -0
  60. package/dist/platform/types.d.ts +23 -0
  61. package/dist/platform/types.js +5 -0
  62. package/dist/restart.js +4 -2
  63. package/dist/router/card-helpers.d.ts +33 -6
  64. package/dist/router/card-helpers.js +67 -11
  65. package/dist/router/config/common/render.d.ts +1 -1
  66. package/dist/router/config/common/render.js +1 -1
  67. package/dist/router/config-diff.d.ts +4 -0
  68. package/dist/router/config-diff.js +70 -0
  69. package/dist/router/index.d.ts +66 -6
  70. package/dist/router/index.js +451 -316
  71. package/dist/router/utils.d.ts +6 -0
  72. package/dist/router/utils.js +12 -0
  73. package/dist/runner/bash/runner.d.ts +9 -2
  74. package/dist/runner/bash/runner.js +45 -20
  75. package/dist/runner/claude/session.d.ts +4 -1
  76. package/dist/runner/claude/session.js +18 -4
  77. package/dist/runner/codex/app-server/protocol-types.d.ts +35 -1
  78. package/dist/runner/codex/app-server/translator.d.ts +18 -0
  79. package/dist/runner/codex/app-server/translator.js +146 -0
  80. package/dist/runner/common/acp/base-acp-runner.d.ts +116 -0
  81. package/dist/runner/common/acp/base-acp-runner.js +248 -0
  82. package/dist/runner/common/acp/base-translator.d.ts +141 -0
  83. package/dist/runner/common/acp/base-translator.js +271 -0
  84. package/dist/runner/common/acp/protocol-helpers.d.ts +39 -0
  85. package/dist/runner/common/acp/protocol-helpers.js +34 -0
  86. package/dist/runner/common/jsonrpc/transport.d.ts +2 -0
  87. package/dist/runner/common/jsonrpc/transport.js +25 -5
  88. package/dist/runner/common/process-stopper.d.ts +4 -26
  89. package/dist/runner/common/process-stopper.js +4 -76
  90. package/dist/runner/common/runner-utils.d.ts +0 -5
  91. package/dist/runner/common/runner-utils.js +0 -7
  92. package/dist/runner/common/spawning-runner.d.ts +42 -132
  93. package/dist/runner/common/spawning-runner.js +52 -271
  94. package/dist/runner/common/usage.d.ts +2 -0
  95. package/dist/runner/common/usage.js +2 -0
  96. package/dist/runner/index.d.ts +1 -1
  97. package/dist/runner/kimi/acp/runner.d.ts +45 -42
  98. package/dist/runner/kimi/acp/runner.js +140 -293
  99. package/dist/runner/kimi/acp/translator.d.ts +18 -100
  100. package/dist/runner/kimi/acp/translator.js +56 -242
  101. package/dist/runner/opencode/acp/runner.d.ts +22 -46
  102. package/dist/runner/opencode/acp/runner.js +73 -256
  103. package/dist/runner/opencode/acp/translator.d.ts +28 -103
  104. package/dist/runner/opencode/acp/translator.js +51 -236
  105. package/dist/runner/pi/rpc/translator.d.ts +3 -0
  106. package/dist/runner/pi/rpc/translator.js +47 -9
  107. package/dist/runner/probe.d.ts +8 -11
  108. package/dist/runner/probe.js +17 -21
  109. package/dist/runner/registry.d.ts +4 -4
  110. package/dist/runner/registry.js +4 -4
  111. package/dist/runner/types.d.ts +38 -1
  112. package/dist/session/claude/sessions.js +35 -58
  113. package/dist/session/codex/rollout-reader.d.ts +10 -0
  114. package/dist/session/codex/rollout-reader.js +123 -41
  115. package/dist/session/common/constants.d.ts +7 -0
  116. package/dist/session/common/constants.js +9 -0
  117. package/dist/session/common/two-pass.d.ts +22 -0
  118. package/dist/session/common/two-pass.js +70 -0
  119. package/dist/session/dsh/sessions.js +7 -6
  120. package/dist/session/kimi/sessions.d.ts +6 -0
  121. package/dist/session/kimi/sessions.js +50 -73
  122. package/dist/session/opencode/sessions.js +26 -8
  123. package/dist/session/pi/sessions.js +54 -73
  124. package/dist/update/index.d.ts +1 -2
  125. package/dist/update/index.js +1 -2
  126. package/dist/update/install.d.ts +2 -1
  127. package/dist/update/install.js +58 -9
  128. package/dist/update/version-check.d.ts +5 -0
  129. package/dist/update/version-check.js +9 -0
  130. package/package.json +9 -6
  131. package/dist/update/startup-hint.d.ts +0 -5
  132. package/dist/update/startup-hint.js +0 -11
@@ -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,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 { scanJsonlOnce, tailOffsetAfter, scanTailEvents } from '../common/two-pass.js';
8
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
@@ -111,7 +114,7 @@ export function listClaudeSessions(cwd, opts = {}) {
111
114
  const limit = opts.limit;
112
115
  const projectsDir = opts.projectsDir ?? defaultProjectsDir();
113
116
  const index = ensureSessionIndex(projectsDir);
114
- // 首次调用延迟全量建索引;之后 5s 按需节流增量刷新(session-index-manual 拍板 3)。
117
+ // 首次调用延迟全量建索引;之后 5s 按需节流增量刷新(拍板 3)。
115
118
  index.refresh();
116
119
  // 统一 parser 的完整 cwd 集合 → A→B→C 任意中间 cwd 都可列出(核心 bug 修复)。
117
120
  // readdir 失败时索引保留旧快照(首次失败则为空),与旧扫描的 readdir 兜底等价。
@@ -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 ---
@@ -358,47 +353,30 @@ function scalarScan(filePath) {
358
353
  };
359
354
  }
360
355
  /**
361
- * P2-2 + P2-5 second-pass: extract content-block events from the tail
362
- * (lines after the last user message), re-parsing only those lines.
363
- * `tailLines` is already the tail-only slice (read via
364
- * `readJsonlLinesFromOffset`), so both raw line-string memory and parsed-
365
- * 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.
366
360
  *
367
- * `maxEvents` (optional): when set, only the LAST `maxEvents` events are kept
368
- * (review P2-7 — unified "last N events" contract across all five readers,
369
- * matching codex/opencode/pi's `slice(-maxEvents)`). The whole tail is parsed
370
- * first (usage/title extraction above already scanned the file; this pass only
371
- * re-parses the short tail), then `slice(-maxEvents)` keeps the most recent
372
- * events. `maxEvents <= 0` returns `[]` (guards the `slice(-0) === slice(0)`
373
- * full-array trap).
361
+ * `maxEvents` capping (review P2-7 — unified "last N events" contract across
362
+ * all readers) is applied by scanTailEvents.
374
363
  */
375
- 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;
376
370
  const events = [];
377
- for (const line of tailLines) {
378
- if (!line.trim())
379
- continue;
380
- let obj;
381
- try {
382
- obj = JSON.parse(line);
383
- }
384
- catch {
385
- continue;
386
- }
387
- const message = obj.message;
388
- // Expand all content blocks from this message into separate events.
389
- const blocks = extractContentBlocks(message?.content, CLAUDE_MAPPING);
390
- const role = message?.role ?? obj.type ?? 'unknown';
391
- const timestamp = obj.timestamp;
392
- for (const block of blocks) {
393
- events.push({ type: block.type, content: block.content, timestamp });
394
- }
395
- // If message has no content blocks (e.g., metadata-only lines like last-prompt),
396
- // still emit an event with the raw type so users see what happened
397
- if (blocks.length === 0 && message?.role) {
398
- events.push({ type: role, content: `(${role} event)`, timestamp });
399
- }
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 });
400
378
  }
401
- return capEvents(events, maxEvents);
379
+ return events;
402
380
  }
403
381
  export function readSessionContent(sessionId, cwd, opts = {}) {
404
382
  const { maxEvents } = opts;
@@ -458,8 +436,7 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
458
436
  // only the tail. If no user message was found (tailOffset === -1), fall
459
437
  // back to re-reading the whole file so the card still has something to
460
438
  // show.
461
- const tailLines = readJsonlLinesFromOffset(filePath, scan.tailOffset >= 0 ? scan.tailOffset : 0);
462
- const events = extractEventsFromTail(tailLines, maxEvents);
439
+ const events = scanTailEvents(filePath, scan.tailOffset, mapClaudeTailLine, maxEvents);
463
440
  // Use aiTitle if available, otherwise fallback to last user message.
464
441
  // Truncation is deferred to the consumer (router card building).
465
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
  */
@@ -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,6 +27,7 @@ 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';
21
32
  import { sortByRecencyDesc } from '../common/recency.js';
22
33
  import { getLogger } from '../../logger/index.js';
@@ -103,15 +114,23 @@ function scanCodexRollout(filePath, includeEvents) {
103
114
  const lines = readJsonlLines(filePath);
104
115
  const events = [];
105
116
  let sessionMeta = null;
106
- // Real user input is identified by a paired `event_msg` whose
107
- // payload.type === "user_message" - codex emits this ONLY for text the
108
- // human actually typed. Injected scaffolding (project rules,
109
- // <environment_context>, permissions) is also written as `role:"user"`
110
- // response_items but has NO user_message event, so it must be excluded
111
- // from displayTitle/recap/summary. Regression 2026-07-13: the first
112
- // `role:user` message is the injected project rules, mistakenly shown as
113
- // "最近输入".
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 "最近输入".
114
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;
115
134
  // token_count tracking: codex emits cumulative `total_token_usage` and an
116
135
  // incremental `last_token_usage`. We want the LAST turn's usage (matches
117
136
  // pi/opencode /resume "last turn" display semantics). When
@@ -148,13 +167,21 @@ function scanCodexRollout(filePath, includeEvents) {
148
167
  sessionMeta = parsed.payload;
149
168
  }
150
169
  else if (parsed.type === 'event_msg' && parsed.payload) {
151
- // user_message events carry the authoritative human-typed text.
152
170
  const payload = parsed.payload;
153
- if (payload.type === 'user_message') {
154
- const text = stringValue(payload.message);
155
- if (text) {
156
- realUserMessages.push(text.slice(0, 200));
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);
157
183
  }
184
+ lastUserMessageSource = human.source;
158
185
  }
159
186
  else if (payload.type === 'token_count') {
160
187
  const info = payload.info;
@@ -205,6 +232,8 @@ function scanCodexRollout(filePath, includeEvents) {
205
232
  else if (includeEvents && parsed.type === 'response_item' && parsed.payload) {
206
233
  const payload = parsed.payload;
207
234
  if (payload.type === 'message' && Array.isArray(payload.content)) {
235
+ if (payload.role === 'user')
236
+ userResponseItemCount++;
208
237
  // Extract text content for display
209
238
  const messages = extractMessageContent(payload);
210
239
  for (const msg of messages) {
@@ -222,6 +251,14 @@ function scanCodexRollout(filePath, includeEvents) {
222
251
  continue;
223
252
  }
224
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
+ }
225
262
  return {
226
263
  sessionMeta,
227
264
  events,
@@ -305,7 +342,7 @@ export function listCodexRollouts(opts = {}) {
305
342
  // session (main file missing) never pollutes the list or total.
306
343
  if (entry.isSubagent)
307
344
  continue;
308
- if (filterCwd && entry.cwd !== filterCwd)
345
+ if (filterCwd && !samePath(entry.cwd, filterCwd))
309
346
  continue;
310
347
  matched.push(entry);
311
348
  }
@@ -323,6 +360,24 @@ export function listCodexRollouts(opts = {}) {
323
360
  }
324
361
  return { entries, total };
325
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
+ }
326
381
  /**
327
382
  * Read the full content of a specific session by its threadId.
328
383
  *
@@ -338,16 +393,7 @@ export function listCodexRollouts(opts = {}) {
338
393
  */
339
394
  export function readCodexSessionContent(sessionId, opts = {}) {
340
395
  const codexHome = resolveCodexHome(opts.codexHome);
341
- // P2-4: Use session index for direct file lookup
342
- let index = getSessionIndex(codexHome);
343
- let entry = index.get(sessionId);
344
- // P3-2: On miss, the index may be stale (a rollout file created after the
345
- // cache was built but within the 5s TTL). Force a refresh and recheck so a
346
- // brand-new session is found without waiting for TTL expiry.
347
- if (!entry) {
348
- index = getSessionIndex(codexHome, true);
349
- entry = index.get(sessionId);
350
- }
396
+ const entry = resolveRolloutEntry(sessionId, codexHome);
351
397
  if (!entry) {
352
398
  return { events: [] };
353
399
  }
@@ -357,7 +403,7 @@ export function readCodexSessionContent(sessionId, opts = {}) {
357
403
  // Codex has no relocation (no EnterWorktree equivalent), so a simple equality
358
404
  // check suffices — unlike claude which needs jsonlContainsCwd to handle
359
405
  // relocated sessions with multiple cwd values.
360
- if (opts.cwd && entry.cwd !== opts.cwd) {
406
+ if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
361
407
  return { events: [] };
362
408
  }
363
409
  const rollout = readCodexRollout(entry.filePath);
@@ -387,18 +433,13 @@ export function readCodexSessionContent(sessionId, opts = {}) {
387
433
  */
388
434
  export function readCodexSessionSummary(sessionId, opts = {}) {
389
435
  const codexHome = resolveCodexHome(opts.codexHome);
390
- let index = getSessionIndex(codexHome);
391
- let entry = index.get(sessionId);
392
- if (!entry) {
393
- index = getSessionIndex(codexHome, true);
394
- entry = index.get(sessionId);
395
- }
436
+ const entry = resolveRolloutEntry(sessionId, codexHome);
396
437
  if (!entry) {
397
438
  return {};
398
439
  }
399
440
  // Cwd guard: same semantics as readCodexSessionContent — when a cwd is
400
441
  // provided, the session's working directory must match.
401
- if (opts.cwd && entry.cwd !== opts.cwd) {
442
+ if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
402
443
  return {};
403
444
  }
404
445
  const summary = readCodexRolloutSummary(entry.filePath);
@@ -427,14 +468,7 @@ export function isCodexSessionActive(sessionId, opts = {}) {
427
468
  // all use STALE_MS). Previously codex used a divergent 10-minute default.
428
469
  const threshold = opts.activeThresholdMs ?? STALE_MS;
429
470
  // P2-4: Use session index for direct file lookup + mtime check
430
- let index = getSessionIndex(codexHome);
431
- let entry = index.get(sessionId);
432
- // P3-2: On miss, refresh the index in case the session file was created
433
- // after the cache was built (within the 5s TTL). See readCodexSessionContent.
434
- if (!entry) {
435
- index = getSessionIndex(codexHome, true);
436
- entry = index.get(sessionId);
437
- }
471
+ const entry = resolveRolloutEntry(sessionId, codexHome);
438
472
  if (!entry) {
439
473
  return false;
440
474
  }
@@ -446,7 +480,7 @@ export function isCodexSessionActive(sessionId, opts = {}) {
446
480
  }
447
481
  // Cwd guard: when a cwd is provided, the session's working directory must
448
482
  // match (align with claude/pi/opencode which validate cwd).
449
- if (opts.cwd && entry.cwd !== opts.cwd) {
483
+ if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
450
484
  return false;
451
485
  }
452
486
  // Re-stat to get fresh mtime (index may be up to INDEX_TTL_MS stale)
@@ -594,6 +628,54 @@ function getSessionIndex(resolvedCodexHome, forceRefresh = false) {
594
628
  export function clearSessionIndexCache() {
595
629
  sessionIndexCache.clear();
596
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
+ }
597
679
  function extractMessageContent(payload) {
598
680
  const result = [];
599
681
  const content = payload.content;
@@ -9,3 +9,10 @@
9
9
  * mtime is older than this threshold cannot be from a running process.
10
10
  */
11
11
  export declare const STALE_MS: number;
12
+ /**
13
+ * A session file is stale (cannot be from a running process) when its
14
+ * timestamp is older than STALE_MS. All readers share this single
15
+ * comparison — previously it was re-derived per reader as
16
+ * `Date.now() - mtimeMs > STALE_MS` (or its `< STALE_MS` negation).
17
+ */
18
+ export declare function isStale(timestampMs: number, now?: number): boolean;
@@ -9,4 +9,13 @@
9
9
  * mtime is older than this threshold cannot be from a running process.
10
10
  */
11
11
  export const STALE_MS = 60 * 60 * 1000;
12
+ /**
13
+ * A session file is stale (cannot be from a running process) when its
14
+ * timestamp is older than STALE_MS. All readers share this single
15
+ * comparison — previously it was re-derived per reader as
16
+ * `Date.now() - mtimeMs > STALE_MS` (or its `< STALE_MS` negation).
17
+ */
18
+ export function isStale(timestampMs, now = Date.now()) {
19
+ return now - timestampMs > STALE_MS;
20
+ }
12
21
  //# sourceMappingURL=constants.js.map
@@ -0,0 +1,22 @@
1
+ import type { AgentSessionContentEvent } from '../../runner/types.js';
2
+ /**
3
+ * Byte offset where the tail begins: the start of the line AFTER `line`.
4
+ * `offset` is the byte offset of `line` itself; +1 accounts for the trailing
5
+ * '\n'.
6
+ */
7
+ export declare function tailOffsetAfter(line: string, offset: number): number;
8
+ /**
9
+ * Pass 1: stream the file once, parse each line exactly once (broken lines
10
+ * skipped), and feed the parsed object + raw line + byte offset to `handle`.
11
+ * `handle` may mutate closure state to collect scalars and track the tail
12
+ * offset (via tailOffsetAfter on user lines).
13
+ */
14
+ export declare function scanJsonlOnce(filePath: string, handle: (obj: Record<string, unknown>, line: string, offset: number) => void): void;
15
+ /**
16
+ * Pass 2: re-read only the tail (lines after `tailOffset`; the whole file
17
+ * when tailOffset is -1) and map each parsed line to content events via
18
+ * `mapLine` (return an array — possibly empty — of events; returning null
19
+ * skips the line entirely). `maxEvents` keeps the LAST N events
20
+ * (`maxEvents <= 0` → [], guarding the `slice(-0)` full-array trap).
21
+ */
22
+ export declare function scanTailEvents(filePath: string, tailOffset: number, mapLine: (obj: Record<string, unknown>) => AgentSessionContentEvent[] | null, maxEvents?: number): AgentSessionContentEvent[];
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Two-pass scan skeleton shared by the claude / pi session readers (P2-2 +
3
+ * P2-5/P2-6 lineage): pass 1 STREAMS the whole file once via `scanJsonlLines`
4
+ * (no `string[]` materialized), parsing each line exactly once to collect
5
+ * scalars (usage, tail offset, title) while retaining NO parsed objects; pass
6
+ * 2 re-reads ONLY the tail from the recorded offset and re-parses it for
7
+ * content events — O(tail) memory for both raw line strings and parsed
8
+ * objects, instead of O(whole file).
9
+ *
10
+ * Parse ratio: ≈1.0–1.5× line count when a user message exists (streaming
11
+ * full-file scan + tail re-parse of the usually-short post-user lines). When
12
+ * the session has NO user message, tailOffset stays -1 and the tail IS the
13
+ * whole file → scan(N) + tail(N) = 2.0× — the known asymptotic upper bound of
14
+ * the two-phase design.
15
+ */
16
+ import { scanJsonlLines, readJsonlLinesFromOffset } from './jsonl.js';
17
+ import { capEvents } from './pagination.js';
18
+ /**
19
+ * Byte offset where the tail begins: the start of the line AFTER `line`.
20
+ * `offset` is the byte offset of `line` itself; +1 accounts for the trailing
21
+ * '\n'.
22
+ */
23
+ export function tailOffsetAfter(line, offset) {
24
+ return offset + Buffer.byteLength(line, 'utf-8') + 1;
25
+ }
26
+ /**
27
+ * Pass 1: stream the file once, parse each line exactly once (broken lines
28
+ * skipped), and feed the parsed object + raw line + byte offset to `handle`.
29
+ * `handle` may mutate closure state to collect scalars and track the tail
30
+ * offset (via tailOffsetAfter on user lines).
31
+ */
32
+ export function scanJsonlOnce(filePath, handle) {
33
+ scanJsonlLines(filePath, (line, offset) => {
34
+ let obj;
35
+ try {
36
+ obj = JSON.parse(line);
37
+ }
38
+ catch {
39
+ return;
40
+ }
41
+ handle(obj, line, offset);
42
+ });
43
+ }
44
+ /**
45
+ * Pass 2: re-read only the tail (lines after `tailOffset`; the whole file
46
+ * when tailOffset is -1) and map each parsed line to content events via
47
+ * `mapLine` (return an array — possibly empty — of events; returning null
48
+ * skips the line entirely). `maxEvents` keeps the LAST N events
49
+ * (`maxEvents <= 0` → [], guarding the `slice(-0)` full-array trap).
50
+ */
51
+ export function scanTailEvents(filePath, tailOffset, mapLine, maxEvents) {
52
+ const tailLines = readJsonlLinesFromOffset(filePath, tailOffset >= 0 ? tailOffset : 0);
53
+ const events = [];
54
+ for (const line of tailLines) {
55
+ if (!line.trim())
56
+ continue;
57
+ let obj;
58
+ try {
59
+ obj = JSON.parse(line);
60
+ }
61
+ catch {
62
+ continue;
63
+ }
64
+ const produced = mapLine(obj);
65
+ if (produced)
66
+ events.push(...produced);
67
+ }
68
+ return capEvents(events, maxEvents);
69
+ }
70
+ //# sourceMappingURL=two-pass.js.map