lark-remote 0.5.13 → 0.5.14

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 (126) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-kinds.d.ts +27 -0
  3. package/dist/agent-kinds.js +38 -0
  4. package/dist/bridge/approval-actions.d.ts +95 -0
  5. package/dist/bridge/approval-actions.js +104 -0
  6. package/dist/bridge/final-usage.d.ts +8 -17
  7. package/dist/bridge/final-usage.js +1 -18
  8. package/dist/bridge/index.d.ts +46 -39
  9. package/dist/bridge/index.js +201 -229
  10. package/dist/bridge/queue-manager.d.ts +25 -1
  11. package/dist/bridge/queue-manager.js +179 -160
  12. package/dist/bridge/run-approval.d.ts +2 -1
  13. package/dist/bridge/run-usage.d.ts +16 -30
  14. package/dist/bridge/run-usage.js +41 -50
  15. package/dist/bridge/session-writeback.d.ts +3 -2
  16. package/dist/card/approval-render.d.ts +3 -2
  17. package/dist/card/approval-render.js +3 -2
  18. package/dist/{router → card}/card-helpers.d.ts +7 -7
  19. package/dist/{router → card}/card-helpers.js +8 -8
  20. package/dist/card/card-session.d.ts +4 -2
  21. package/dist/card/card-session.js +8 -6
  22. package/dist/card/card-shared.d.ts +1 -1
  23. package/dist/card/card-shared.js +1 -1
  24. package/dist/card/final-answer-overflow.d.ts +0 -6
  25. package/dist/card/final-answer-overflow.js +1 -1
  26. package/dist/card/run-card-session.d.ts +2 -2
  27. package/dist/card/run-card-session.js +2 -2
  28. package/dist/card/run-renderer.js +7 -22
  29. package/dist/card/run-state.d.ts +28 -50
  30. package/dist/card/run-state.js +23 -0
  31. package/dist/card/usage-format.d.ts +30 -0
  32. package/dist/{router → card}/usage-format.js +3 -10
  33. package/dist/clone.js +6 -1
  34. package/dist/config/index.d.ts +16 -20
  35. package/dist/config/index.js +0 -16
  36. package/dist/config/pi-config.js +6 -4
  37. package/dist/connector/index.d.ts +2 -4
  38. package/dist/connector/index.js +7 -11
  39. package/dist/{runner/dsh → dsh}/auth.js +1 -1
  40. package/dist/dsh/protocol.d.ts +9 -0
  41. package/dist/dsh/protocol.js +61 -0
  42. package/dist/index.js +10 -5
  43. package/dist/model-catalog.d.ts +14 -0
  44. package/dist/model-catalog.js +25 -0
  45. package/dist/platform/path.d.ts +9 -0
  46. package/dist/platform/path.js +18 -0
  47. package/dist/router/active-run-usage.d.ts +4 -0
  48. package/dist/router/active-run-usage.js +12 -0
  49. package/dist/router/commands/ls.js +1 -1
  50. package/dist/router/commands/order.js +1 -1
  51. package/dist/router/commands/resume.d.ts +5 -4
  52. package/dist/router/commands/resume.js +4 -4
  53. package/dist/router/commands/ws.js +1 -1
  54. package/dist/router/config/claude.js +2 -1
  55. package/dist/router/config/common/render.d.ts +5 -1
  56. package/dist/router/config/common/render.js +6 -13
  57. package/dist/router/config/dsh.d.ts +1 -1
  58. package/dist/router/config/index.d.ts +1 -1
  59. package/dist/router/config/types.d.ts +11 -3
  60. package/dist/router/index.d.ts +41 -11
  61. package/dist/router/index.js +165 -226
  62. package/dist/runner/agent-choices-common.d.ts +1 -1
  63. package/dist/runner/claude/runner.d.ts +20 -2
  64. package/dist/runner/claude/runner.js +2 -15
  65. package/dist/runner/codex/app-server/runner.d.ts +3 -1
  66. package/dist/runner/codex/app-server/translator.d.ts +11 -0
  67. package/dist/runner/codex/app-server/translator.js +56 -149
  68. package/dist/runner/common/acp/base-acp-runner.d.ts +3 -1
  69. package/dist/runner/common/connection-based-runner.d.ts +3 -1
  70. package/dist/runner/common/jsonrpc/transport.js +17 -7
  71. package/dist/runner/dsh/client.d.ts +1 -4
  72. package/dist/runner/dsh/client.js +2 -55
  73. package/dist/runner/dsh/daemon.js +9 -2
  74. package/dist/runner/dsh/exit-cancel.js +1 -1
  75. package/dist/runner/dsh/mux-stream.js +21 -2
  76. package/dist/runner/dsh/runner.d.ts +21 -1
  77. package/dist/runner/dsh/runner.js +76 -73
  78. package/dist/runner/dsh/translator.d.ts +1 -1
  79. package/dist/runner/index.d.ts +1 -1
  80. package/dist/runner/kimi/acp/runner.d.ts +22 -1
  81. package/dist/runner/kimi/acp/runner.js +83 -60
  82. package/dist/runner/opencode/acp/error-monitor.d.ts +11 -0
  83. package/dist/runner/opencode/acp/error-monitor.js +31 -2
  84. package/dist/runner/opencode/acp/runner.d.ts +3 -1
  85. package/dist/runner/pi/rpc/runner.d.ts +2 -1
  86. package/dist/runner/probe.d.ts +1 -1
  87. package/dist/runner/probe.js +10 -9
  88. package/dist/runner/registry.d.ts +2 -1
  89. package/dist/runner/types.d.ts +20 -154
  90. package/dist/runner/types.js +4 -24
  91. package/dist/session/claude/session-index.d.ts +11 -4
  92. package/dist/session/claude/session-index.js +30 -8
  93. package/dist/session/claude/session-reader.d.ts +1 -1
  94. package/dist/session/claude/sessions.d.ts +2 -2
  95. package/dist/session/claude/sessions.js +41 -26
  96. package/dist/session/codex/rollout-reader.d.ts +1 -1
  97. package/dist/session/codex/rollout-reader.js +14 -13
  98. package/dist/session/codex/sessions.d.ts +1 -1
  99. package/dist/session/codex/sessions.js +4 -2
  100. package/dist/session/common/jsonl.js +7 -1
  101. package/dist/session/common/pagination.d.ts +2 -0
  102. package/dist/session/common/pagination.js +1 -1
  103. package/dist/session/common/recency.d.ts +6 -0
  104. package/dist/session/common/recency.js +7 -5
  105. package/dist/session/common/two-pass.d.ts +1 -1
  106. package/dist/session/common/usage-accumulator.d.ts +1 -1
  107. package/dist/session/dsh/sessions.d.ts +1 -1
  108. package/dist/session/dsh/sessions.js +37 -12
  109. package/dist/session/kimi/sessions.d.ts +1 -1
  110. package/dist/session/kimi/sessions.js +35 -8
  111. package/dist/session/opencode/sessions.d.ts +1 -1
  112. package/dist/session/opencode/sessions.js +51 -47
  113. package/dist/session/pi/sessions.d.ts +1 -1
  114. package/dist/session/pi/sessions.js +20 -7
  115. package/dist/session/registry.d.ts +2 -1
  116. package/dist/session/session-store.d.ts +1 -2
  117. package/dist/session/session-store.js +14 -7
  118. package/dist/session-contract.d.ts +178 -0
  119. package/dist/session-contract.js +9 -0
  120. package/package.json +1 -1
  121. package/dist/router/usage-format.d.ts +0 -60
  122. /package/dist/{runner/dsh → dsh}/auth.d.ts +0 -0
  123. /package/dist/{runner/dsh → dsh}/home.d.ts +0 -0
  124. /package/dist/{runner/dsh → dsh}/home.js +0 -0
  125. /package/dist/{runner/dsh → dsh}/types.d.ts +0 -0
  126. /package/dist/{runner/dsh → dsh}/types.js +0 -0
@@ -18,6 +18,7 @@
18
18
  * with no child process at all.
19
19
  */
20
20
  import { resolveExecutable } from '../platform/command.js';
21
+ import { TtlCache } from '../common/ttl-cache.js';
21
22
  /** Map from AgentKind to its CLI binary name. */
22
23
  const BINARY_MAP = {
23
24
  claude: 'claude',
@@ -30,8 +31,11 @@ const BINARY_MAP = {
30
31
  dsh: '',
31
32
  };
32
33
  const CACHE_TTL_MS = 5 * 60_000;
33
- /** Module-level cache, survives across calls within the process. */
34
- const cache = new Map();
34
+ /**
35
+ * Module-level cache, survives across calls within the process. 键集就是
36
+ * AgentKind(有界),因此不设条目上限;共享 `TtlCache` 与 session readers 同语义。
37
+ */
38
+ const cache = new TtlCache(CACHE_TTL_MS);
35
39
  /**
36
40
  * Probe a single agent's availability (pure PATH lookup; dsh has no CLI
37
41
  * binary and is always unavailable here).
@@ -49,10 +53,10 @@ async function probeOne(kind) {
49
53
  */
50
54
  export async function probeAgentAvailability(kind) {
51
55
  const cached = cache.get(kind);
52
- if (cached && Date.now() - cached.ts < CACHE_TTL_MS)
53
- return cached.ok;
56
+ if (cached !== undefined)
57
+ return cached;
54
58
  const ok = await probeOne(kind);
55
- cache.set(kind, { ts: Date.now(), ok });
59
+ cache.set(kind, ok);
56
60
  return ok;
57
61
  }
58
62
  /**
@@ -76,10 +80,7 @@ export async function probeAllAgents() {
76
80
  * Used by synchronous code paths (e.g. buildConfigCard) that cannot await.
77
81
  */
78
82
  export function getCachedAvailability(kind) {
79
- const cached = cache.get(kind);
80
- if (!cached || Date.now() - cached.ts >= CACHE_TTL_MS)
81
- return undefined;
82
- return cached.ok;
83
+ return cache.get(kind);
83
84
  }
84
85
  /** Clear all cache entries. Exported for test use only (cache auto-expires via TTL). */
85
86
  export function _clearCacheForTest() {
@@ -1,4 +1,5 @@
1
- import type { AgentKind, Runner } from './index.js';
1
+ import type { AgentKind } from '../agent-kinds.js';
2
+ import type { Runner } from './index.js';
2
3
  /**
3
4
  * Registry of agent factories, keyed by `AgentKind`. `index.ts` registers one
4
5
  * factory per available agent at startup; `Bridge.getRunner(cwd)` looks
@@ -3,7 +3,10 @@
3
3
  *
4
4
  * 本文件包含所有 Agent 相关的类型定义,从 runner/index.ts 提取。
5
5
  * 所有下游模块应从本文件 import 类型,而非直接依赖实现。
6
+ * (例外:`AgentKind` 住在 `src/agent-kinds.ts`,见文件底部说明。)
6
7
  */
8
+ import type { AgentKind } from '../agent-kinds.js';
9
+ import type { AgentSessionReader } from '../session-contract.js';
7
10
  export interface SystemInitEvent {
8
11
  type: 'system';
9
12
  subtype: 'init';
@@ -552,18 +555,7 @@ export interface Runner {
552
555
  */
553
556
  unregisterExitHandlers?(): void;
554
557
  }
555
- /**
556
- * Agent 名单单源(运行时数组 + 类型同源,二者不可能漂移)。
557
- *
558
- * 历史上这份名单被手抄成多份(router 的 VALID_AGENTS、bridge 的 resume.compact
559
- * 校验、各 agent 的 config builder…),漂移过:dsh 加入后 bridge 的
560
- * resume.compact 仍只有 5 个,带 dsh 的 resume 卡片会静默退回 defaultAgent。
561
- * 新增 agent 只改这里。
562
- */
563
- export declare const AGENT_KINDS: readonly ["claude", "codex", "opencode", "pi", "kimi", "dsh"];
564
- export type AgentKind = (typeof AGENT_KINDS)[number];
565
- /** 用户可见 agent 显示名单源:启动注册与无 registry 的回退路径共用。 */
566
- export declare const AGENT_DISPLAY_NAMES: Record<AgentKind, string>;
558
+ export { AGENT_DISPLAY_NAMES } from '../agent-kinds.js';
567
559
  /**
568
560
  * Compact 能力声明(运行时门控的能力清单,编译期强制完整)。
569
561
  *
@@ -579,147 +571,6 @@ export declare const AGENT_DISPLAY_NAMES: Record<AgentKind, string>;
579
571
  export declare const COMPACT_CAPABILITY: Readonly<Record<AgentKind, boolean>>;
580
572
  /** 该 agent 是否声明支持 compact(渲染门控与执行门控共用的唯一判据)。 */
581
573
  export declare function supportsCompact(kind: AgentKind): boolean;
582
- /** Unified session descriptor returned by every AgentSessionReader. */
583
- export interface AgentSession {
584
- sessionId: string;
585
- summary: string;
586
- mtime: number;
587
- /** DSH:session 创建时固定的 preset;其他 agent 无此概念(undefined)。 */
588
- agentPreset?: string;
589
- /**
590
- * 服务端投影已有的会话标题(DSH `projections.values.title`)。
591
- *
592
- * 与 `summary` 的区别:`summary` 是「首条用户输入」的兜底口径(claude /
593
- * codex / pi / kimi 无标题字段),`title` 是 agent 自己起的标题。列表卡
594
- * 用它决定标签文案(标题 / 会话摘要),缺省时回退 `summary`。
595
- */
596
- title?: string;
597
- /**
598
- * 最后一轮用户输入(服务端投影,单行预览)。
599
- *
600
- * 只给「列表响应里已顺带返回」的 reader 用(DSH 的 `turnOutline` 末条),
601
- * 目的是让列表卡不必为每行做一次全量内容读取。
602
- */
603
- lastUserMessage?: string;
604
- /** 最后一轮助手输出(服务端投影已截断,单行预览)。 */
605
- lastAssistantMessage?: string;
606
- }
607
- /** A single renderable event from session history. */
608
- export interface AgentSessionContentEvent {
609
- type: string;
610
- /** 正文(展开后的面板内容)。 */
611
- content: string;
612
- /**
613
- * 面板标题(可选,覆盖 reader 的默认标签)。
614
- *
615
- * 折叠面板默认只显示标题:工具调用这种"标题即全部信息"的事件必须把
616
- * `bash — npm test` 这类摘要放进标题,否则折叠态只剩 `🔧 工具调用`。
617
- */
618
- title?: string;
619
- timestamp?: string;
620
- }
621
- export interface AgentSessionUsage {
622
- inputTokens: number;
623
- outputTokens: number;
624
- contextLength: number;
625
- /** Agent 上报的当前模型 context window 上限(codex token_count.info.model_context_window)。
626
- * 仅 codex 提供;缺省时卡片只显示绝对量、不显示百分比。 */
627
- contextLimit?: number;
628
- /** Claude auto-compact 次数(数 compact_boundary 事件);codex/opencode 无此概念,留 undefined。 */
629
- compactCount?: number;
630
- /** 压缩前上下文水位(codex compact 后从会话 jsonl 读取,仅会话以压缩收尾时有值)。 */
631
- compactPreContextLength?: number;
632
- /** 从缓存读取的 token 数(节省的费用)。 */
633
- cacheReadTokens?: number;
634
- /** 新建缓存的 token 数。 */
635
- cacheCreationTokens?: number;
636
- /** Agent 声明的总量;展示用 max(total, 分项和)。 */
637
- totalTokens?: number;
638
- /** 会话累计 total token(所有 run 之和,含本次)。用于 Run 卡片"累计"展示。 */
639
- cumulativeTotalTokens?: number;
640
- /** 会话累计 input token(所有 run 之和,含本次)。用于 Run 卡片"累计"展示。 */
641
- cumulativeInputTokens?: number;
642
- /** 会话累计 output token(所有 run 之和,含本次)。 */
643
- cumulativeOutputTokens?: number;
644
- /** 会话累计 cache read token(所有 run 之和)。用于 Run 卡片展示累计缓存命中。 */
645
- cumulativeCacheReadTokens?: number;
646
- /** 会话累计 cache creation token(所有 run 之和)。 */
647
- cumulativeCacheCreationTokens?: number;
648
- }
649
- /** Unified session content payload returned by every AgentSessionReader. */
650
- export interface SessionContent {
651
- events: AgentSessionContentEvent[];
652
- usage?: AgentSessionUsage;
653
- aiTitle?: string;
654
- recap?: string;
655
- displayTitle?: string;
656
- }
657
- /**
658
- * Lightweight session summary used by `/resume` list prefetch.
659
- *
660
- * Carries only the display fields (title / recap / usage) — **no `events`
661
- * array**. Readers that can extract these without constructing the full event
662
- * list (e.g. codex, which would otherwise JSON.parse every response_item and
663
- * build an `events[]` that the prefetch immediately discards) implement the
664
- * optional `readSessionSummary` on `AgentSessionReader`.
665
- */
666
- export interface SessionSummary {
667
- aiTitle?: string;
668
- recap?: string;
669
- displayTitle?: string;
670
- usage?: AgentSessionUsage;
671
- }
672
- /**
673
- * Reads an agent's session history. Each agent implements its own (Claude reads
674
- * `~/.claude/projects/`, Codex calls `thread/list`, OpenCode calls `GET /session`).
675
- *
676
- * Q-C decision: `projectsDir` and other agent-specific path config live on the
677
- * concrete reader's constructor, NOT on these method opts. The router/bridge
678
- * depend only on this interface and never pass path config.
679
- */
680
- export interface AgentSessionReader {
681
- /**
682
- * List sessions for a cwd, newest first by mtime.
683
- *
684
- * `sessions` is the page `[offset, offset+limit)` of the full set ordered
685
- * by mtime desc, with same-mtime ties broken by a deterministic secondary
686
- * key (sessionId/file path) so ordering is stable across calls. When
687
- * `limit` is omitted, codex/pi/kimi return their default-sized page
688
- * (default 20); claude/opencode return the full remaining set from
689
- * `offset`. `total` is ALWAYS the size of the full cwd-matched set before
690
- * pagination — never the truncated page length. A negative `offset` is
691
- * clamped to 0 (first page).
692
- */
693
- listSessions(cwd: string, opts?: {
694
- limit?: number;
695
- offset?: number;
696
- }): {
697
- sessions: AgentSession[];
698
- total: number;
699
- };
700
- getNewestSession(cwd: string): AgentSession | null;
701
- readSessionContent(sessionId: string, cwd: string, opts?: {
702
- maxEvents?: number;
703
- }): SessionContent;
704
- /**
705
- * Optional lightweight summary for list-prefetch.
706
- *
707
- * Returns only the display fields (aiTitle/recap/displayTitle/usage) without
708
- * constructing the full `events` array. When absent, callers fall back to
709
- * `readSessionContent` and take the needed fields.
710
- */
711
- readSessionSummary?(sessionId: string, cwd: string): SessionSummary;
712
- /**
713
- * Whether a session is still active (process may still be running).
714
- *
715
- * Dormant API: no production caller reads this today — the router decides
716
- * "is an agent running" via the in-memory `bridge.isBusyFor(cwd)` (see
717
- * `cmdPs`), not by probing session files. Retained on the reader contract
718
- * because all 5 readers implement it and it is the natural hook if a
719
- * file-based liveness check is ever needed; not wired into the bridge.
720
- */
721
- isSessionActive(sessionId: string, cwd: string): boolean;
722
- }
723
574
  /**
724
575
  * AgentRunner extends Runner with agent identity and a session reader.
725
576
  * Bridge/Router depend on this interface so adding a new agent is
@@ -732,6 +583,22 @@ export interface AgentRunner extends Runner {
732
583
  getStatusInfo(): AgentStatusInfo;
733
584
  /** Whether this runner provides live usage data (vs. file-based). */
734
585
  getUsageAuthority?(): 'live' | 'jsonl';
586
+ /**
587
+ * 回传审批决策(codex thread/permissions、ACP elicitation、DSH approval
588
+ * resolve)。未实现的 agent 由协调器按「无人应答」超时收尾。
589
+ */
590
+ respondApproval?(requestId: number | string, response: unknown): Promise<void>;
591
+ /**
592
+ * 停止 agent 侧的一个后台任务(DSH 的 background job)。只有支持后台任务的
593
+ * agent 实现;调用方按能力存在与否门控。
594
+ */
595
+ killJob?(sessionId: string, jobId: string): Promise<boolean>;
596
+ /**
597
+ * 热更审批模式/权限预设(config.save 后推给在跑的 runner,不必重启会话)。
598
+ * 各家 settings 词表不同(codex: approvalPolicy/sandbox;kimi: permissionMode;
599
+ * opencode: mode;dsh: permissionPreset),故为开放载荷。
600
+ */
601
+ updateApprovalMode?(settings: Record<string, unknown>): Promise<void>;
735
602
  }
736
603
  /** Agent self-describing status info for /status display. */
737
604
  export interface AgentStatusInfo {
@@ -746,4 +613,3 @@ export interface AgentStatusInfo {
746
613
  /** Agent-specific extra fields (e.g. codex.sandbox). */
747
614
  extras?: Record<string, string>;
748
615
  }
749
- export {};
@@ -1,30 +1,10 @@
1
- /**
2
- * Runner 模块类型定义
3
- *
4
- * 本文件包含所有 Agent 相关的类型定义,从 runner/index.ts 提取。
5
- * 所有下游模块应从本文件 import 类型,而非直接依赖实现。
6
- */
7
1
  // =============================================================================
8
2
  // Multi-Agent Adapter Types
9
3
  // =============================================================================
10
- /**
11
- * Agent 名单单源(运行时数组 + 类型同源,二者不可能漂移)。
12
- *
13
- * 历史上这份名单被手抄成多份(router 的 VALID_AGENTS、bridge 的 resume.compact
14
- * 校验、各 agent 的 config builder…),漂移过:dsh 加入后 bridge 的
15
- * resume.compact 仍只有 5 个,带 dsh 的 resume 卡片会静默退回 defaultAgent。
16
- * 新增 agent 只改这里。
17
- */
18
- export const AGENT_KINDS = ['claude', 'codex', 'opencode', 'pi', 'kimi', 'dsh'];
19
- /** 用户可见 agent 显示名单源:启动注册与无 registry 的回退路径共用。 */
20
- export const AGENT_DISPLAY_NAMES = {
21
- claude: 'Claude',
22
- codex: 'Codex',
23
- opencode: 'Opencode',
24
- pi: 'Pi',
25
- kimi: 'Kimi',
26
- dsh: 'DSH',
27
- };
4
+ // Agent 名单与显示名住在 `src/agent-kinds.ts`(中立层,config/session/card 都
5
+ // 要用);本模块只 import `AgentKind` 类型来签名 runner 契约。保留旧路径的
6
+ // re-export 兼容调用方,但值仍只由 agent-kinds.ts 定义。
7
+ export { AGENT_DISPLAY_NAMES } from '../agent-kinds.js';
28
8
  /**
29
9
  * Compact 能力声明(运行时门控的能力清单,编译期强制完整)。
30
10
  *
@@ -74,15 +74,22 @@ export declare class SessionIndex {
74
74
  private readonly byCwd;
75
75
  private lastRefreshAt;
76
76
  private built;
77
+ private scanError?;
77
78
  constructor(projectsDir: string, opts?: SessionIndexOptions);
78
79
  /** Whether a full scan has ever completed successfully (readdir succeeded). */
79
80
  get isBuilt(): boolean;
81
+ /**
82
+ * 最近一次全量扫描的非 ENOENT 失败(undefined = 上次扫描正常)。
83
+ * list 路径用它把「读不到」与「真的没有」区分开。
84
+ */
85
+ get lastScanError(): unknown;
80
86
  build(): void;
81
87
  refresh(): void;
82
- listByCwd(cwd: string, opts?: {
83
- limit?: number;
84
- offset?: number;
85
- }): SessionIndexEntry[];
88
+ /**
89
+ * 该 cwd 的全部会话(newest first)。分页不在这里:`listSessions` 的
90
+ * offset/limit 契约由 reader 用共享 `paginate()` 完成,索引只负责有序全集。
91
+ */
92
+ listByCwd(cwd: string): SessionIndexEntry[];
86
93
  /**
87
94
  * Find a session entry by sessionId and cwd.
88
95
  * Re-stats the file to verify fingerprint before returning (拍板 3):
@@ -83,6 +83,7 @@ export function parseSessionJsonl(filePath) {
83
83
  });
84
84
  // If file couldn't be opened, return empty result
85
85
  if (!ok) {
86
+ getLogger().debug(`[session-index] unreadable session file ${filePath}, treating as empty`);
86
87
  return { cwdSet: new Set(), summary: '(无摘要)' };
87
88
  }
88
89
  return { cwdSet, summary };
@@ -98,7 +99,12 @@ export function buildFingerprint(filePath) {
98
99
  try {
99
100
  st = fs.statSync(filePath, { bigint: true });
100
101
  }
101
- catch {
102
+ catch (err) {
103
+ // ENOENT = 文件在扫描间隙被删除(正常竞态)。其余(EACCES/EBUSY…)留痕,
104
+ // 否则该会话会静默从 /resume 列表里消失。
105
+ if (err.code !== 'ENOENT') {
106
+ getLogger().warn(`[session-index] cannot stat ${filePath}: ${err?.message ?? String(err)}`);
107
+ }
102
108
  return '';
103
109
  }
104
110
  const parts = [String(st.size), String(st.mtimeNs)];
@@ -153,6 +159,7 @@ export class SessionIndex {
153
159
  byCwd = new Map();
154
160
  lastRefreshAt = 0;
155
161
  built = false;
162
+ scanError;
156
163
  constructor(projectsDir, opts = {}) {
157
164
  this.projectsDir = projectsDir;
158
165
  this.refreshIntervalMs = opts.refreshIntervalMs ?? 5000;
@@ -162,6 +169,13 @@ export class SessionIndex {
162
169
  get isBuilt() {
163
170
  return this.built;
164
171
  }
172
+ /**
173
+ * 最近一次全量扫描的非 ENOENT 失败(undefined = 上次扫描正常)。
174
+ * list 路径用它把「读不到」与「真的没有」区分开。
175
+ */
176
+ get lastScanError() {
177
+ return this.scanError;
178
+ }
165
179
  // ── Build (initial full scan) ───────────────────────────
166
180
  build() {
167
181
  this.fullScan();
@@ -177,7 +191,11 @@ export class SessionIndex {
177
191
  this.lastRefreshAt = now;
178
192
  }
179
193
  // ── Query: listByCwd ────────────────────────────────────
180
- listByCwd(cwd, opts = {}) {
194
+ /**
195
+ * 该 cwd 的全部会话(newest first)。分页不在这里:`listSessions` 的
196
+ * offset/limit 契约由 reader 用共享 `paginate()` 完成,索引只负责有序全集。
197
+ */
198
+ listByCwd(cwd) {
181
199
  const paths = this.byCwd.get(pathKey(cwd, { platform: this.platform }));
182
200
  if (!paths || paths.size === 0)
183
201
  return [];
@@ -194,10 +212,7 @@ export class SessionIndex {
194
212
  }
195
213
  const sorted = [...byId.values()];
196
214
  sortByRecencyDesc(sorted, (s) => s.mtimeMs, (s) => s.sessionId);
197
- const offset = opts.offset ?? 0;
198
- const limit = opts.limit;
199
- const sliced = limit === undefined ? sorted.slice(offset) : sorted.slice(offset, offset + limit);
200
- return sliced;
215
+ return sorted;
201
216
  }
202
217
  // ── Query: findBySessionIdAndCwd ────────────────────────
203
218
  /**
@@ -261,11 +276,18 @@ export class SessionIndex {
261
276
  try {
262
277
  entries = fs.readdirSync(this.projectsDir);
263
278
  }
264
- catch {
279
+ catch (err) {
265
280
  // readdir failure → preserve old index, degrade to fallback
266
- getLogger().warn('[session-index] readdir failed, preserving old index');
281
+ if (err.code === 'ENOENT') {
282
+ // projectsDir 不存在 = 全新环境(真空态),不是故障。
283
+ this.scanError = undefined;
284
+ return;
285
+ }
286
+ this.scanError = err;
287
+ getLogger().warn(`[session-index] readdir ${this.projectsDir} failed, preserving old index: ${err?.message ?? String(err)}`);
267
288
  return;
268
289
  }
290
+ this.scanError = undefined;
269
291
  // readdir succeeded → the snapshot is authoritative for what we could read.
270
292
  this.built = true;
271
293
  const currentPaths = new Set();
@@ -1,4 +1,4 @@
1
- import type { AgentSession, AgentSessionReader, SessionContent } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionReader, SessionContent } from '../../session-contract.js';
2
2
  /**
3
3
  * Adapter that exposes Claude Code's session-reading functions through the
4
4
  * unified `AgentSessionReader` interface. This lets `SessionReaderRegistry`
@@ -1,4 +1,4 @@
1
- import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionContentEvent, AgentSessionUsage } from '../../session-contract.js';
2
2
  /**
3
3
  * List Claude Code sessions for a given cwd, newest first. Each entry has
4
4
  * the sessionId (= jsonl filename stem), a one-line summary extracted from
@@ -15,7 +15,7 @@ export declare function getNewestSession(cwd: string, opts?: {
15
15
  }): AgentSession | undefined;
16
16
  /**
17
17
  * Check if a Claude session (by sessionId + cwd) is still active.
18
- * Public wrapper around the internal `isSessionActive(filePath, mtimeMs, now)`
18
+ * Public wrapper around the internal `hasRecentFileActivity(filePath, mtimeMs, now)`
19
19
  * for use by `ClaudeSessionReader` and the multi-agent adapter layer.
20
20
  *
21
21
  * Resolves the jsonl path via `projectDirForCwd`, stats it for mtime, then
@@ -6,6 +6,7 @@ import { isStale } from '../common/constants.js';
6
6
  import { samePath, encodeProjectDirName } from '../../platform/path.js';
7
7
  import { extractContentBlocks } from '../common/content-blocks.js';
8
8
  import { scanJsonlOnce, tailOffsetAfter, scanTailEvents } from '../common/two-pass.js';
9
+ import { getLogger } from '../../logger/index.js';
9
10
  import { UsageAccumulator, contextWindowOccupancy, cumulativeUsageFields, } from '../common/usage-accumulator.js';
10
11
  import { SessionIndex, extractText, isTaskNotificationText, CLAUDE_MAPPING, } from './session-index.js';
11
12
  /**
@@ -61,7 +62,10 @@ function findSessionFileInProjects(sessionId, projectsDir, cwd) {
61
62
  try {
62
63
  entries = fs.readdirSync(projectsDir);
63
64
  }
64
- catch {
65
+ catch (err) {
66
+ if (err.code !== 'ENOENT') {
67
+ getLogger().debug(`[claude-session-reader] cannot read projects dir ${projectsDir}: ${err?.message ?? String(err)}`);
68
+ }
65
69
  return undefined;
66
70
  }
67
71
  const name = `${sessionId}.jsonl`;
@@ -123,6 +127,13 @@ export function listClaudeSessions(cwd, opts = {}) {
123
127
  summary: e.summary,
124
128
  mtime: e.mtimeMs,
125
129
  }));
130
+ // 空列表 + 上次扫描是非 ENOENT 失败 → 是「读不到」而非「真的没有」。
131
+ // 与 opencode/kimi/pi 同口径:router 显示「列表失败」,别假装空态。
132
+ if (sessions.length === 0 && index.lastScanError !== undefined) {
133
+ const err = index.lastScanError;
134
+ const msg = err instanceof Error ? err.message : String(err);
135
+ throw new Error(`claude session list 读取失败: ${msg}`, { cause: err });
136
+ }
126
137
  return limit === undefined ? sessions : sessions.slice(0, limit);
127
138
  }
128
139
  /** Get the newest session for a given cwd. Returns undefined if none exist. */
@@ -145,7 +156,15 @@ export function getNewestSession(cwd, opts = {}) {
145
156
  *
146
157
  * Override `now` for tests.
147
158
  */
148
- function isSessionActive(filePath, mtimeMs, now = Date.now()) {
159
+ /** 末行是这些类型之一 = 会话已收尾(模块级:逐文件判定不必每次重建 Set)。 */
160
+ const CLAUDE_TERMINAL_TYPES = new Set(['result', 'last-prompt', 'mode', 'permission-mode']);
161
+ /**
162
+ * 文件侧的活跃判定:mtime 新鲜 + 末行没有终态标记。
163
+ *
164
+ * 刻意不叫 `isSessionActive`——那是 reader 契约上的方法名,语义各家自己定;
165
+ * 本函数只看这一个文件,两者同名不同义曾让人把「最近有写入」当成「turn 在跑」。
166
+ */
167
+ function hasRecentFileActivity(filePath, mtimeMs, now = Date.now()) {
149
168
  // Hard signal: file untouched for over an hour → can't be from a live process
150
169
  if (isStale(mtimeMs, now))
151
170
  return false;
@@ -158,8 +177,7 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
158
177
  // `last-prompt` / `mode` (no `result` event) to mark turn end, so a
159
178
  // 24-minute-old session with a last-prompt tail is already done
160
179
  // (regression 2026-06-21: previously falsely reported as active).
161
- const TERMINAL_TYPES = new Set(['result', 'last-prompt', 'mode', 'permission-mode']);
162
- if (typeof obj.type === 'string' && TERMINAL_TYPES.has(obj.type))
180
+ if (typeof obj.type === 'string' && CLAUDE_TERMINAL_TYPES.has(obj.type))
163
181
  return false;
164
182
  }
165
183
  catch {
@@ -167,9 +185,18 @@ function isSessionActive(filePath, mtimeMs, now = Date.now()) {
167
185
  }
168
186
  return true;
169
187
  }
188
+ /**
189
+ * stat 失败:ENOENT = 文件在两次查询之间消失(正常竞态);其余 errno 必须留痕,
190
+ * 否则「文件不可读」会被当成「会话不活跃」。
191
+ */
192
+ function logStatFailure(filePath, err) {
193
+ if (err.code === 'ENOENT')
194
+ return;
195
+ getLogger().debug(`[claude-session-reader] stat ${filePath} failed: ${err?.message ?? String(err)}`);
196
+ }
170
197
  /**
171
198
  * Check if a Claude session (by sessionId + cwd) is still active.
172
- * Public wrapper around the internal `isSessionActive(filePath, mtimeMs, now)`
199
+ * Public wrapper around the internal `hasRecentFileActivity(filePath, mtimeMs, now)`
173
200
  * for use by `ClaudeSessionReader` and the multi-agent adapter layer.
174
201
  *
175
202
  * Resolves the jsonl path via `projectDirForCwd`, stats it for mtime, then
@@ -187,9 +214,10 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
187
214
  if (entry) {
188
215
  try {
189
216
  const st = fs.statSync(entry.path);
190
- return isSessionActive(entry.path, st.mtimeMs);
217
+ return hasRecentFileActivity(entry.path, st.mtimeMs);
191
218
  }
192
- catch {
219
+ catch (err) {
220
+ logStatFailure(entry.path, err);
193
221
  return false;
194
222
  }
195
223
  }
@@ -210,9 +238,10 @@ export function isClaudeSessionActive(sessionId, cwd, opts = {}) {
210
238
  return false;
211
239
  try {
212
240
  const st = fs.statSync(filePath);
213
- return isSessionActive(filePath, st.mtimeMs);
241
+ return hasRecentFileActivity(filePath, st.mtimeMs);
214
242
  }
215
- catch {
243
+ catch (err) {
244
+ logStatFailure(filePath, err);
216
245
  return false;
217
246
  }
218
247
  }
@@ -411,23 +440,9 @@ export function readSessionContent(sessionId, cwd, opts = {}) {
411
440
  return { events: [] };
412
441
  }
413
442
  }
414
- // First pass STREAMS the file once via
415
- // `scanJsonlLines` (no `string[]` materialized), parsing each line once to
416
- // collect scalars (usage, tailOffset, aiTitle, recap, lastUserMessage)
417
- // while retaining NO parsed objects and NO whole-file line array. The
418
- // tailOffset is the byte offset where the tail begins (start of the line
419
- // after the last user message). A second pass re-reads ONLY the tail from
420
- // that offset via `readJsonlLinesFromOffset` and re-parses it for events —
421
- // O(tail) memory for both raw line strings and parsed objects, instead of
422
- // O(whole file).
423
- //
424
- // Parse ratio: ≈1.0–1.5× line count when a user message exists (streaming
425
- // full-file scan + tail re-parse of the usually-short post-user lines).
426
- // When the session has NO user message, tailOffset stays -1 and the tail
427
- // IS the whole file → scan(N) + tail(N) = 2.0× — the known asymptotic
428
- // upper bound of the body's two-phase design (still better than pre-'s
429
- // ~3×). Total parse count adds the index-miss fallback guard's fixed
430
- // early-stop overhead (~2 parses, independent of line count).
443
+ // Two-pass 扫描(pass 1 流式收标量 + pass 2 只重读 tail)的口径与解析比
444
+ // 见 `src/session/common/two-pass.ts` 头注释;claude 侧只多一项:索引 miss
445
+ // 时的 fallback 守卫另有固定 ~2 次早停解析,与行数无关。
431
446
  const scan = scalarScan(filePath);
432
447
  // 有 usage 即返回:非累计字段已是末轮 scope,命中判定用 session 累计兜底
433
448
  // (末轮可能 input/output 为 0 而仍有 cacheRead,累计口径不会被末轮遮挡)。
@@ -23,7 +23,7 @@
23
23
  * Note: This handles the **rollout file format** (historical logging), NOT the
24
24
  * `codex exec --json` stdout format(已随 exec 模式移除)。
25
25
  */
26
- import type { AgentSessionContentEvent, SessionContent, SessionSummary, AgentSessionUsage } from '../../runner/index.js';
26
+ import type { AgentSessionContentEvent, AgentSessionUsage, SessionContent, SessionSummary } from '../../session-contract.js';
27
27
  interface CodexRolloutEntry {
28
28
  threadId: string;
29
29
  cwd: string;
@@ -32,6 +32,7 @@ import { paginate, capEvents } from '../common/pagination.js';
32
32
  import { sortByRecencyDesc } from '../common/recency.js';
33
33
  import { getLogger } from '../../logger/index.js';
34
34
  import { isRecord, stringValue } from '../../common/guards.js';
35
+ import { TtlCache } from '../../common/ttl-cache.js';
35
36
  import { resolveCodexHome } from '../../config/codex-config.js';
36
37
  /** Field-wise difference (total - previous) for cumulative->incremental. */
37
38
  function subtractRawUsage(a, b) {
@@ -623,7 +624,12 @@ function safeIsDir(p) {
623
624
  }
624
625
  }
625
626
  const INDEX_TTL_MS = 5_000; // 5 seconds — covers a single /resume flow
626
- const sessionIndexCache = new Map();
627
+ /**
628
+ * codexHome → 会话索引。共享 `TtlCache`(TTL 由写入时间判定 + LRU 上限),
629
+ * 与 kimi/pi/opencode 三个 reader 同一套语义。生产侧 `CodexSessionReader`
630
+ * 构造时就把 codexHome 解析定死,实际只会用到 1 个键;上限是防漏的界。
631
+ */
632
+ const sessionIndexCache = new TtlCache(INDEX_TTL_MS, 4);
627
633
  /**
628
634
  * Build or return cached session index for a given resolved codexHome.
629
635
  * The index maps sessionId → {filePath, cwd, mtimeMs}.
@@ -635,19 +641,14 @@ const sessionIndexCache = new Map();
635
641
  * AFTER the cache was built — but still within the
636
642
  * 5s TTL — is found without waiting for expiry.
637
643
  *
638
- * Cache shape note: `sessionIndexCache` is module-level and keyed by the
639
- * resolved codexHome string. In production `CodexSessionReader` resolves
640
- * codexHome once in its constructor and reuses it for every call, so a single
641
- * bridge instance produces exactly one cache entry — the Map does NOT grow
642
- * unbounded. The TTL refreshes the value on each rebuild; entries are never
643
- * evicted, but there is effectively just one.
644
+ * Cache shape note: the cache is module-level and keyed by the resolved
645
+ * codexHome string; `TtlCache` caps entries (LRU) so a caller that injects
646
+ * many distinct codexHomes cannot grow memory without bound.
644
647
  */
645
648
  function getSessionIndex(resolvedCodexHome, forceRefresh = false) {
646
- const cacheKey = resolvedCodexHome;
647
- const cached = sessionIndexCache.get(cacheKey);
648
- if (!forceRefresh && cached && Date.now() - cached.builtAt < INDEX_TTL_MS) {
649
- return cached.index;
650
- }
649
+ const cached = forceRefresh ? undefined : sessionIndexCache.get(resolvedCodexHome);
650
+ if (cached)
651
+ return cached;
651
652
  const sessionsDir = path.join(resolvedCodexHome, 'sessions');
652
653
  const files = walkRolloutFiles(sessionsDir);
653
654
  const index = new Map();
@@ -704,7 +705,7 @@ function getSessionIndex(resolvedCodexHome, forceRefresh = false) {
704
705
  continue;
705
706
  }
706
707
  }
707
- sessionIndexCache.set(cacheKey, { index, builtAt: Date.now() });
708
+ sessionIndexCache.set(resolvedCodexHome, index);
708
709
  return index;
709
710
  }
710
711
  /** Clear the session index cache. Exposed for tests. */
@@ -1,4 +1,4 @@
1
- import type { AgentSession, SessionContent, SessionSummary, AgentSessionReader } from '../../runner/index.js';
1
+ import type { AgentSession, AgentSessionReader, SessionContent, SessionSummary } from '../../session-contract.js';
2
2
  interface CodexSessionReaderOptions {
3
3
  codexHome?: string;
4
4
  }