lark-remote 0.5.19 → 0.5.20

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 (119) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +1 -1
  10. package/dist/bridge/index.d.ts +97 -153
  11. package/dist/bridge/index.js +182 -809
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +109 -97
  18. package/dist/card/card-budget.js +1 -1
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +34 -93
  24. package/dist/card/run-state.d.ts +1 -1
  25. package/dist/card/run-state.js +28 -90
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +1 -1
  28. package/dist/card/usage-format.d.ts +12 -0
  29. package/dist/card/usage-format.js +8 -3
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/config/codex-config.js +130 -106
  33. package/dist/config/index.d.ts +16 -2
  34. package/dist/config/index.js +23 -8
  35. package/dist/connector/index.js +6 -6
  36. package/dist/error-classification.js +3 -2
  37. package/dist/inbound/placeholder.d.ts +1 -1
  38. package/dist/inbound/placeholder.js +1 -1
  39. package/dist/index.js +203 -133
  40. package/dist/logger/index.js +6 -6
  41. package/dist/message-handlers.d.ts +0 -5
  42. package/dist/message-handlers.js +1 -1
  43. package/dist/model-catalog.d.ts +2 -2
  44. package/dist/model-catalog.js +3 -8
  45. package/dist/platform/identity.js +1 -1
  46. package/dist/platform/path.d.ts +1 -1
  47. package/dist/platform/path.js +1 -1
  48. package/dist/platform/sleep-blocker.js +1 -1
  49. package/dist/platform/terminator-win32.js +2 -1
  50. package/dist/router/commands/resume.js +2 -20
  51. package/dist/router/config/dsh.d.ts +4 -8
  52. package/dist/router/config/dsh.js +3 -10
  53. package/dist/router/config/kimi.js +1 -1
  54. package/dist/router/config/types.d.ts +1 -1
  55. package/dist/router/config-diff.js +1 -1
  56. package/dist/router/index.d.ts +56 -11
  57. package/dist/router/index.js +219 -200
  58. package/dist/runner/claude/session.d.ts +1 -1
  59. package/dist/runner/claude/session.js +6 -4
  60. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  61. package/dist/runner/codex/app-server/runner.js +30 -25
  62. package/dist/runner/codex/app-server/translator.d.ts +0 -5
  63. package/dist/runner/codex/app-server/translator.js +4 -17
  64. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  65. package/dist/runner/common/acp/acp-approval.js +1 -1
  66. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  67. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  68. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  69. package/dist/runner/common/acp/base-translator.js +31 -9
  70. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  71. package/dist/runner/common/acp/protocol-types.js +2 -2
  72. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  73. package/dist/runner/common/connection-based-runner.js +32 -2
  74. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  75. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  76. package/dist/runner/common/spawning-runner.d.ts +1 -1
  77. package/dist/runner/common/spawning-runner.js +1 -1
  78. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  79. package/dist/runner/dsh/catalog-probe.js +10 -0
  80. package/dist/runner/dsh/client.d.ts +1 -3
  81. package/dist/runner/dsh/client.js +2 -4
  82. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  83. package/dist/runner/dsh/exit-cancel.js +2 -2
  84. package/dist/runner/dsh/inflight.d.ts +1 -1
  85. package/dist/runner/dsh/inflight.js +1 -1
  86. package/dist/runner/dsh/runner.d.ts +40 -1
  87. package/dist/runner/dsh/runner.js +272 -199
  88. package/dist/runner/dsh/translator.d.ts +1 -1
  89. package/dist/runner/dsh/translator.js +1 -1
  90. package/dist/runner/index.d.ts +1 -1
  91. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  92. package/dist/runner/kimi/acp/runner.js +1 -2
  93. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  94. package/dist/runner/kimi/acp/translator.js +3 -4
  95. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  96. package/dist/runner/opencode/acp/runner.js +4 -4
  97. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  98. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  99. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  100. package/dist/runner/pi/rpc/runner.js +27 -42
  101. package/dist/runner/types.d.ts +2 -2
  102. package/dist/runner/types.js +1 -1
  103. package/dist/session/claude/sessions.d.ts +45 -0
  104. package/dist/session/claude/sessions.js +97 -71
  105. package/dist/session/codex/rollout-reader.js +4 -4
  106. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  107. package/dist/session/common/throw-session-list-error.js +16 -0
  108. package/dist/session/dsh/sessions.js +11 -5
  109. package/dist/session/kimi/sessions.d.ts +40 -1
  110. package/dist/session/kimi/sessions.js +244 -192
  111. package/dist/session/opencode/sessions.d.ts +153 -1
  112. package/dist/session/opencode/sessions.js +354 -321
  113. package/dist/session/pi/sessions.js +4 -5
  114. package/dist/session/session-store.d.ts +1 -1
  115. package/dist/session/session-store.js +1 -1
  116. package/dist/update/version-check.js +2 -1
  117. package/dist/workspace/index.d.ts +1 -1
  118. package/dist/workspace/index.js +1 -1
  119. package/package.json +1 -1
@@ -133,8 +133,8 @@ export class OpencodeAcpRunner extends BaseAcpRunner {
133
133
  }
134
134
  // Apply the configured model (`provider/model`) via set_config_option.
135
135
  // Without this the session runs opencode's own default model (e.g.
136
- // opencode/big-pickle), which may be unusable — 2026-08-17 live smoke:
137
- // default zen model left session/prompt hanging with no response.
136
+ // opencode/big-pickle), which may be unusable: the default zen model can
137
+ // leave session/prompt hanging with no response.
138
138
  // Non-fatal on failure (unknown model → server InvalidModelError).
139
139
  if (this.model && this.currentModelValue !== this.model) {
140
140
  try {
@@ -179,7 +179,7 @@ export class OpencodeAcpRunner extends BaseAcpRunner {
179
179
  * before failing the turn.
180
180
  *
181
181
  * 单次 attempt 自身还要等 opencode 侧超时(~10s),所以真实窗口 ≈ Σdelay +
182
- * 每次的等待。2026-10-30 现场:daemon 刚重启,`/api/model` 因模型目录拉取
182
+ * 每次的等待。daemon 刚重启时 `/api/model` 会因模型目录拉取
183
183
  * 超时连续 503 约 7 分钟,旧退避 0.5/1.5/3s(合计 ~45s)全落在这段里,4 次
184
184
  * 重试无一成功。现在铺到 ~75s + 每次超时 ≈ 2 分钟,覆盖一次冷启动窗口,
185
185
  * 又不至于让真故障的用户干等太久。
@@ -194,7 +194,7 @@ export class OpencodeAcpRunner extends BaseAcpRunner {
194
194
  /**
195
195
  * 用户可见文案。这里刻意**不**建议 `opencode service restart`:卡住的是
196
196
  * opencode 的模型目录/位置服务冷启动,重启只会清掉刚热好的状态、重新回到
197
- * 同一个窗口(2026-10-30 现场:照提示重启两次仍然失败,等几分钟自己好了)。
197
+ * 同一个窗口(照提示重启两次仍然失败,等几分钟自己好了)。
198
198
  */
199
199
  enrichSessionSetupError(err) {
200
200
  if (!this.isRetryableSessionSetupError(err)) {
@@ -7,7 +7,7 @@
7
7
  * and events (session lifecycle / message / compaction) as bare
8
8
  * JSON objects (no envelope).
9
9
  *
10
- * Turn semantics (verified live against pi 0.84.1, 2026-08-18):
10
+ * Turn semantics (verified against pi 0.84.1):
11
11
  * - `prompt` returns an ack (`success:true`) immediately; the turn then streams
12
12
  * events and completes with an `agent_settled` event.
13
13
  * - `compact` runs synchronously and returns its response at completion; it also
@@ -7,7 +7,7 @@
7
7
  * and events (session lifecycle / message / compaction) as bare
8
8
  * JSON objects (no envelope).
9
9
  *
10
- * Turn semantics (verified live against pi 0.84.1, 2026-08-18):
10
+ * Turn semantics (verified against pi 0.84.1):
11
11
  * - `prompt` returns an ack (`success:true`) immediately; the turn then streams
12
12
  * events and completes with an `agent_settled` event.
13
13
  * - `compact` runs synchronously and returns its response at completion; it also
@@ -59,6 +59,7 @@ export declare class PiRpcRunner extends ConnectionBasedRunner<PiRpcClient, PiRp
59
59
  protected clearTurnState(): void;
60
60
  protected releaseConnection(cwd: string): Promise<void>;
61
61
  protected notifyIdle(cwd: string): void;
62
+ protected notifyConnectionActivity(cwd: string): void;
62
63
  protected disposeConnections(): Promise<void>;
63
64
  getStatusInfo(): AgentStatusInfo;
64
65
  /**
@@ -72,4 +73,6 @@ export declare class PiRpcRunner extends ConnectionBasedRunner<PiRpcClient, PiRp
72
73
  */
73
74
  runCompact(_message: string, opts: SpawnOptions): AsyncGenerator<AgentEvent>;
74
75
  private handleEvent;
76
+ /** Wire the acquired client's hooks into the turn dispatcher (anchor-guarded). */
77
+ private installTurnHooks;
75
78
  }
@@ -20,6 +20,7 @@
20
20
  import { ConnectionBasedRunner } from '../../common/connection-based-runner.js';
21
21
  import { ConnectionManager } from '../../common/jsonrpc/connection-manager.js';
22
22
  import { getLogger } from '../../../logger/index.js';
23
+ import { PI_DEFAULTS } from '../../../config/index.js';
23
24
  import { PiRpcClient } from './client.js';
24
25
  import { PiRpcTranslator } from './translator.js';
25
26
  export class PiRpcRunner extends ConnectionBasedRunner {
@@ -36,10 +37,12 @@ export class PiRpcRunner extends ConnectionBasedRunner {
36
37
  sessionReader: opts.sessionReader,
37
38
  turnIdleTimeoutMs: opts.turnIdleTimeoutMs,
38
39
  });
39
- this.defaultModel = opts.model ?? 'glm-5.2';
40
- this.provider = opts.provider ?? 'Volcano';
41
- this.thinking = opts.thinking ?? 'medium';
42
- const tools = (opts.tools ?? ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']).join(',');
40
+ this.defaultModel = opts.model ?? PI_DEFAULTS.model;
41
+ this.provider = opts.provider ?? PI_DEFAULTS.provider;
42
+ this.thinking = opts.thinking ?? PI_DEFAULTS.thinking;
43
+ // PI_DEFAULTS.tools 是逗号串(config schema 的 tools 同形态),与原数组兜底
44
+ // join(',') 结果逐字相等;opts.tools 自身的数组语义(含空数组 → 空串)不变。
45
+ const tools = opts.tools?.join(',') ?? PI_DEFAULTS.tools;
43
46
  this.manager = new ConnectionManager({
44
47
  binary: opts.binary ?? 'pi',
45
48
  buildArgs: (req) => buildPiArgs(this.provider, this.defaultModel, this.thinking, tools, req.sessionId),
@@ -93,6 +96,9 @@ export class PiRpcRunner extends ConnectionBasedRunner {
93
96
  notifyIdle(cwd) {
94
97
  this.manager.notifyIdle(cwd);
95
98
  }
99
+ notifyConnectionActivity(cwd) {
100
+ this.manager.notifyActivity(cwd);
101
+ }
96
102
  async disposeConnections() {
97
103
  await this.manager.disposeAll();
98
104
  }
@@ -110,28 +116,13 @@ export class PiRpcRunner extends ConnectionBasedRunner {
110
116
  * `agent_settled` event produces the result.
111
117
  */
112
118
  async setupTurn(message, opts) {
113
- const client = await this.manager.acquire(opts.cwd, { sessionId: opts.sessionId });
114
- this.manager.notifyActivity(opts.cwd);
115
- this.currentClient = client;
116
- // 轮次代际锚点(与 BaseAcpRunner 同口径):pi 的 shouldReuse 要求
117
- // sessionId 匹配,/new、/resume 切换后的首条消息会让 acquire **同步
118
- // dispose** 旧连接;terminator 等到进程退出后,旧 client 上残留的上一轮
119
- // onClose 闭包会把假错误推进新 turn 的共享队列(用户消息未发出即丢失)。
120
- // 旧轮迟到的 agent_settled 同理(新 turn 空转立即"成功")。所有 hook
121
- // 落地前必须过 isCurrentTurn。
122
- const generation = this.turnGeneration;
123
- client.setHooks({
124
- onEvent: (evt) => {
125
- if (!this.isCurrentTurn(generation, client))
126
- return;
127
- this.handleEvent(evt);
128
- },
129
- onClose: () => {
130
- if (!this.isCurrentTurn(generation, client))
131
- return;
132
- this.failTurn('Pi RPC connection closed');
133
- },
134
- });
119
+ const anchor = await this.acquireForTurn(opts.cwd, () => this.manager.acquire(opts.cwd, { sessionId: opts.sessionId }));
120
+ // pi 的 shouldReuse 要求 sessionId 匹配:/new、/resume 切换后的首条消息会让
121
+ // acquire **同步 dispose** 旧连接。旧 client 残留的上一轮 onClose / 迟到的
122
+ // agent_settled 必须被 anchor 守卫挡掉,否则假错误会推进新 turn 的共享队列
123
+ // (语义见 acquireForTurn / TurnAnchor)。
124
+ this.installTurnHooks(anchor);
125
+ const { client } = anchor;
135
126
  let sessionId = opts.sessionId;
136
127
  if (!sessionId) {
137
128
  const state = await client.request({ type: 'get_state' });
@@ -169,24 +160,11 @@ export class PiRpcRunner extends ConnectionBasedRunner {
169
160
  if (!opts.sessionId) {
170
161
  throw new Error('compact requires a sessionId');
171
162
  }
172
- const client = await this.manager.acquire(opts.cwd, { sessionId: opts.sessionId });
173
- this.manager.notifyActivity(opts.cwd);
174
- this.currentClient = client;
175
163
  // 同 setupTurn:hook 带轮次代际锚点,旧连接的迟到回调不得进入本轮。
176
- const generation = this.turnGeneration;
177
- client.setHooks({
178
- onEvent: (evt) => {
179
- if (!this.isCurrentTurn(generation, client))
180
- return;
181
- this.handleEvent(evt);
182
- },
183
- onClose: () => {
184
- if (!this.isCurrentTurn(generation, client))
185
- return;
186
- this.failTurn('Pi RPC connection closed');
187
- },
188
- });
164
+ const anchor = await this.acquireForTurn(opts.cwd, () => this.manager.acquire(opts.cwd, { sessionId: opts.sessionId }));
165
+ this.installTurnHooks(anchor);
189
166
  this.activeSessionId = opts.sessionId;
167
+ const { client } = anchor;
190
168
  const translator = new PiRpcTranslator();
191
169
  translator.setSessionId(opts.sessionId);
192
170
  translator.setOperationKind('compact');
@@ -210,6 +188,13 @@ export class PiRpcRunner extends ConnectionBasedRunner {
210
188
  handleEvent(evt) {
211
189
  this.pushEvents(this.currentTranslator?.handleEvent(evt) ?? []);
212
190
  }
191
+ /** Wire the acquired client's hooks into the turn dispatcher (anchor-guarded). */
192
+ installTurnHooks(anchor) {
193
+ anchor.client.setHooks({
194
+ onEvent: this.forCurrentTurn(anchor, 'event', (evt) => this.handleEvent(evt)),
195
+ onClose: this.forCurrentTurn(anchor, 'connection close', () => this.failTurn('Pi RPC connection closed')),
196
+ });
197
+ }
213
198
  }
214
199
  /** Build pi `--mode rpc` spawn args. `sessionId` is bound at spawn (pi has no
215
200
  * RPC resume-by-id command); omitting it spawns a brand-new session. */
@@ -302,7 +302,7 @@ export interface ResultEvent {
302
302
  * 省略)` 标记——渲染层再怎么「回答优先」也找不回被丢掉的头部。runner 在
303
303
  * 收 turn 时拿到的是**完整** assistant 文本(一条消息,不是逐 delta 拼接),
304
304
  * 把它挂在 result 上,`promoteFinalAnswer` 用它建立 finalAnswer,回答因此
305
- * 不再被进度窗口截断(2026-11-01 卡片输出块被截断缺陷)。
305
+ * 不再被进度窗口截断。
306
306
  */
307
307
  finalAnswerText?: string;
308
308
  usage?: {
@@ -582,7 +582,7 @@ export { AGENT_DISPLAY_NAMES } from '../agent-kinds.js';
582
582
  * 键集就是 `AgentKind`:新增 agent 时缺一项 = TypeScript 报错,必须显式回答
583
583
  * 「这个 agent 能不能压缩」。历史教训:能力只由 runner 上有没有 `runCompact`
584
584
  * 这个鸭子方法隐式决定,dsh 接入时漏了实现,run 卡片直接不渲染按钮——没有
585
- * 报错、没有置灰、没有日志,用户只能看到一个缺失的按钮(2026-10-17)。
585
+ * 报错、没有置灰、没有日志,用户只能看到一个缺失的按钮。
586
586
  *
587
587
  * 声明与实现的唯一性由 `src/runner/runner-structure.test.ts` 的矩阵用例保证:
588
588
  * 每个 kind 的声明值必须等于对应 runner 类是否实现 `runCompact`。bridge 侧
@@ -11,7 +11,7 @@ export { AGENT_DISPLAY_NAMES } from '../agent-kinds.js';
11
11
  * 键集就是 `AgentKind`:新增 agent 时缺一项 = TypeScript 报错,必须显式回答
12
12
  * 「这个 agent 能不能压缩」。历史教训:能力只由 runner 上有没有 `runCompact`
13
13
  * 这个鸭子方法隐式决定,dsh 接入时漏了实现,run 卡片直接不渲染按钮——没有
14
- * 报错、没有置灰、没有日志,用户只能看到一个缺失的按钮(2026-10-17)。
14
+ * 报错、没有置灰、没有日志,用户只能看到一个缺失的按钮。
15
15
  *
16
16
  * 声明与实现的唯一性由 `src/runner/runner-structure.test.ts` 的矩阵用例保证:
17
17
  * 每个 kind 的声明值必须等于对应 runner 类是否实现 `runCompact`。bridge 侧
@@ -25,6 +25,50 @@ export declare function getNewestSession(cwd: string, opts?: {
25
25
  export declare function isClaudeSessionActive(sessionId: string, cwd: string, opts?: {
26
26
  projectsDir?: string;
27
27
  }): boolean;
28
+ /** claude wire 一条 assistant 行携带的原始 usage 字段(可选,缺按 0 计)。 */
29
+ interface ClaudeRawUsage {
30
+ input_tokens?: number;
31
+ output_tokens?: number;
32
+ cache_read_input_tokens?: number;
33
+ cache_creation_input_tokens?: number;
34
+ }
35
+ /** per-message-id 合并后的 usage 采样(四个分量 + 采集时的真人 turn 序号)。 */
36
+ export interface ClaudePerMessageUsage {
37
+ input: number;
38
+ output: number;
39
+ cacheRead: number;
40
+ cacheCreation: number;
41
+ /** 该 message 属于第几个真人 turn(0 = 还没有真人 prompt)。 */
42
+ turn: number;
43
+ }
44
+ /**
45
+ * 两条采样逐字段取 max(纯函数;UsageAccumulator 的 per-field max 语义)。
46
+ *
47
+ * RED LINE(token 口径单源的一部分):一条 streaming assistant 消息跨多行
48
+ * jsonl、共享同一 message id;部分后端(第三方网关,如 DeepSeek)在早期占位
49
+ * 行(空 thinking 块)写全零 usage,真实用量只在带 stop_reason 的末行。
50
+ * first-occurrence-wins 会把零记录钉死、丢掉真实用量(regression 2026-08-19:
51
+ * auto-resume 卡 per-run 统计全 0)。逐字段 max 与行序无关地重建完整用量;
52
+ * 官方 API 每行重复相同 usage,max 是 no-op。
53
+ *
54
+ * 产生:合并后的一条采样。依赖:入参。不改任何外部状态。
55
+ */
56
+ export declare function maxUsageRecord(prev: ClaudePerMessageUsage | undefined, usage: ClaudeRawUsage, turnSeq: number): ClaudePerMessageUsage;
57
+ /**
58
+ * usage 装配(纯函数):每个 message id 一条最优记录、按首次出现顺序喂
59
+ * UsageAccumulator(Map 保序),acc.last = 末条 message 的完整用量,totals
60
+ * 让每条 API 响应恰好多计一次。per-run flow 只取末个真人 turn 的记录——
61
+ * 工具往返的 turn 有多条 assistant 消息(一次模型请求一条),只取 `last`
62
+ * 会低报。
63
+ *
64
+ * 产生:AgentSessionUsage。依赖:入参 records + 标量。不改任何外部状态
65
+ * (UsageAccumulator 在函数内新建)。
66
+ */
67
+ export declare function buildClaudeUsage(records: Iterable<ClaudePerMessageUsage>, opts: {
68
+ turnSeq: number;
69
+ lastPostTokens: number;
70
+ compactCount: number;
71
+ }): AgentSessionUsage;
28
72
  export declare function readSessionContent(sessionId: string, cwd: string, opts?: {
29
73
  projectsDir?: string;
30
74
  maxEvents?: number;
@@ -35,3 +79,4 @@ export declare function readSessionContent(sessionId: string, cwd: string, opts?
35
79
  recap?: string;
36
80
  displayTitle?: string;
37
81
  };
82
+ export {};
@@ -19,7 +19,7 @@ import { SessionIndex, extractText, isTaskNotificationText, CLAUDE_MAPPING, } fr
19
19
  * Note: this encoding is LOSSY (`my_data/foo` and `my-data/foo` both encode to
20
20
  * `my-data-foo`). It is only safe to use the result to **locate** files; never
21
21
  * to **decode** a directory name back to a cwd. The cwd must always be read
22
- * from the JSONL content (regression 2026-06-21 /resume & /cd 路径错乱).
22
+ * from the JSONL content (regression: /resume & /cd 路径错乱).
23
23
  */
24
24
  function projectDirForCwd(cwd, projectsDir) {
25
25
  return path.join(projectsDir, encodeProjectDirName(cwd));
@@ -32,7 +32,7 @@ function projectDirForCwd(cwd, projectsDir) {
32
32
  * MULTIPLE cwd values (pre- and post-relocate) — accept when ANY `cwd` field
33
33
  * matches. Early-stops on the first match (findJsonlLine), keeping the
34
34
  * single-pass parse-count anchors intact on the fallback path.
35
- * Regression 2026-08-04 EnterWorktree relocate.
35
+ * Needed for EnterWorktree relocate.
36
36
  */
37
37
  function fileContainsCwd(filePath, cwd) {
38
38
  return (findJsonlLine(filePath, (l) => {
@@ -50,7 +50,7 @@ function defaultProjectsDir() {
50
50
  return path.join(os.homedir(), '.claude', 'projects');
51
51
  }
52
52
  /**
53
- * EnterWorktree relocate support (2026-08-04): Claude Code MOVES the
53
+ * EnterWorktree relocate support: Claude Code MOVES the
54
54
  * transcript file to the new cwd's project dir mid-session, so the file may
55
55
  * not exist under the requested cwd's encoded dir. Fall back to locating
56
56
  * `<sessionId>.jsonl` across all project dirs. Deterministic: entries are
@@ -148,7 +148,7 @@ export function getNewestSession(cwd, opts = {}) {
148
148
  * JSONL output continuously. A file whose mtime is older than
149
149
  * `STALE_MS` cannot be from a running process. This catches the
150
150
  * common Claude CLI 2.x case where the tail is `last-prompt` / `mode`
151
- * (no `type:"result"`) and the process exited long ago (regression 2026-06-21).
151
+ * (no `type:"result"`) and the process exited long ago.
152
152
  *
153
153
  * 2. **last-line terminal marker**: if the last JSON line is `result`
154
154
  * (older CLI) or any explicit terminal event, the session is done
@@ -176,7 +176,7 @@ function hasRecentFileActivity(filePath, mtimeMs, now = Date.now()) {
176
176
  // Explicit terminal markers — regardless of mtime. Claude CLI 2.x uses
177
177
  // `last-prompt` / `mode` (no `result` event) to mark turn end, so a
178
178
  // 24-minute-old session with a last-prompt tail is already done
179
- // (regression 2026-06-21: previously falsely reported as active).
179
+ // (previously falsely reported as active).
180
180
  if (typeof obj.type === 'string' && CLAUDE_TERMINAL_TYPES.has(obj.type))
181
181
  return false;
182
182
  }
@@ -272,6 +272,87 @@ function isRealUserPrompt(obj) {
272
272
  return false;
273
273
  return true;
274
274
  }
275
+ /**
276
+ * 两条采样逐字段取 max(纯函数;UsageAccumulator 的 per-field max 语义)。
277
+ *
278
+ * RED LINE(token 口径单源的一部分):一条 streaming assistant 消息跨多行
279
+ * jsonl、共享同一 message id;部分后端(第三方网关,如 DeepSeek)在早期占位
280
+ * 行(空 thinking 块)写全零 usage,真实用量只在带 stop_reason 的末行。
281
+ * first-occurrence-wins 会把零记录钉死、丢掉真实用量(regression 2026-08-19:
282
+ * auto-resume 卡 per-run 统计全 0)。逐字段 max 与行序无关地重建完整用量;
283
+ * 官方 API 每行重复相同 usage,max 是 no-op。
284
+ *
285
+ * 产生:合并后的一条采样。依赖:入参。不改任何外部状态。
286
+ */
287
+ export function maxUsageRecord(prev, usage, turnSeq) {
288
+ return {
289
+ input: Math.max(prev?.input ?? 0, usage.input_tokens ?? 0),
290
+ output: Math.max(prev?.output ?? 0, usage.output_tokens ?? 0),
291
+ cacheRead: Math.max(prev?.cacheRead ?? 0, usage.cache_read_input_tokens ?? 0),
292
+ cacheCreation: Math.max(prev?.cacheCreation ?? 0, usage.cache_creation_input_tokens ?? 0),
293
+ turn: prev?.turn ?? turnSeq,
294
+ };
295
+ }
296
+ /**
297
+ * usage 装配(纯函数):每个 message id 一条最优记录、按首次出现顺序喂
298
+ * UsageAccumulator(Map 保序),acc.last = 末条 message 的完整用量,totals
299
+ * 让每条 API 响应恰好多计一次。per-run flow 只取末个真人 turn 的记录——
300
+ * 工具往返的 turn 有多条 assistant 消息(一次模型请求一条),只取 `last`
301
+ * 会低报。
302
+ *
303
+ * 产生:AgentSessionUsage。依赖:入参 records + 标量。不改任何外部状态
304
+ * (UsageAccumulator 在函数内新建)。
305
+ */
306
+ export function buildClaudeUsage(records, opts) {
307
+ const acc = new UsageAccumulator();
308
+ const turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
309
+ for (const record of records) {
310
+ acc.add(record);
311
+ if (opts.turnSeq > 0 && record.turn === opts.turnSeq) {
312
+ turn.input += record.input;
313
+ turn.output += record.output;
314
+ turn.cacheRead += record.cacheRead;
315
+ turn.cacheCreation += record.cacheCreation;
316
+ }
317
+ }
318
+ // Build usage result (same logic as old aggregateSessionUsage)
319
+ const t = acc.totals;
320
+ const l = acc.last;
321
+ // 没有真人 user prompt(旧数据/截断文件)→ 无法划分 turn,回退末条采样,
322
+ // 与 dsh/codex「无 turn 标记退回末条」一致;不能把整份文件当成一轮。
323
+ if (opts.turnSeq === 0 && l) {
324
+ turn.input = l.input;
325
+ turn.output = l.output;
326
+ turn.cacheRead = l.cacheRead;
327
+ turn.cacheCreation = l.cacheCreation;
328
+ }
329
+ // contextLength = current context-window occupancy. Prefer compact_boundary
330
+ // postTokens (the precise post-compact size); otherwise fall back to the
331
+ // last turn's input + cacheRead + cacheCreation (unified
332
+ // contract — excludes output/reasoning, which are generated, not part of
333
+ // the input window). Take the max so a pre-compact turn larger than the
334
+ // post-compact size is not under-reported.
335
+ const lastWindow = l ? contextWindowOccupancy(l) : 0;
336
+ let contextLength = opts.lastPostTokens;
337
+ if (contextLength === 0 || lastWindow > contextLength) {
338
+ contextLength = lastWindow;
339
+ }
340
+ // 非累计字段 = 末轮(本 run)scope,累计字段 = session 总和(所有 run)。
341
+ // 对齐 codex reader 语义(§9.21:`last_token_usage`=单 turn 增量、
342
+ // `total_token_usage`=session 累计)——否则 jsonl 兜底路径会把 session 累计
343
+ // 当成"本 run"显示,与卡片"本 run ≤ 累计"不变量冲突(累计反而更小)。
344
+ const lastTotal = turn.input + turn.output + turn.cacheRead + turn.cacheCreation;
345
+ return {
346
+ inputTokens: turn.input,
347
+ outputTokens: turn.output,
348
+ contextLength,
349
+ compactCount: opts.compactCount,
350
+ cacheReadTokens: turn.cacheRead > 0 ? turn.cacheRead : undefined,
351
+ cacheCreationTokens: turn.cacheCreation > 0 ? turn.cacheCreation : undefined,
352
+ totalTokens: lastTotal,
353
+ ...cumulativeUsageFields(t),
354
+ };
355
+ }
275
356
  /**
276
357
  * first-pass scan: stream the file once via `scanJsonlLines`
277
358
  * (no `string[]` materialized), parse each line exactly once, and collect
@@ -279,20 +360,25 @@ function isRealUserPrompt(obj) {
279
360
  * recap, lastUserMessage). Does NOT retain parsed objects — the events tail
280
361
  * is re-parsed separately by `extractEventsFromTail` so memory for both raw
281
362
  * line strings and parsed objects is O(tail), not O(whole file).
363
+ *
364
+ * 主体是分发表:每类行只收集自己的标量(assistant → usage 并表、
365
+ * compact_boundary → 计数 + postTokens、user → tail 定位/标题、ai-title);
366
+ * usage 的逐字段 max 合并(maxUsageRecord)与装配(buildClaudeUsage)已
367
+ * 独立成纯函数。
282
368
  */
283
369
  function scalarScan(filePath) {
284
- const acc = new UsageAccumulator();
285
370
  // Per-message-id best usage record (per-field max). A streaming assistant
286
371
  // message spans multiple jsonl lines sharing the same message id; some
287
372
  // backends (third-party gateways, e.g. DeepSeek) write all-zero usage on
288
373
  // the early placeholder lines (empty thinking block) and the real usage
289
374
  // only on the final line carrying stop_reason. First-occurrence-wins dedup
290
- // would pin the zero record and discard the real one (regression
291
- // 2026-08-19: auto-resume card per-run stats all 0). Per-field max
375
+ // would pin the zero record and discard the real one (auto-resume
376
+ // card per-run stats all 0). Per-field max
292
377
  // reconstructs the full usage regardless of line order — on the official
293
378
  // API every line repeats identical usage, so max is a no-op there.
294
379
  const usageByMessageId = new Map();
295
380
  let lastPostTokens = 0;
381
+ let compactCount = 0;
296
382
  let tailOffset = -1;
297
383
  /** 真人 prompt 序号;末个 turn = 当前值。sidechain/注入/工具结果不递增。 */
298
384
  let turnSeq = 0;
@@ -307,19 +393,12 @@ function scalarScan(filePath) {
307
393
  if (usage) {
308
394
  const msgId = msg?.id;
309
395
  if (msgId) {
310
- const prev = usageByMessageId.get(msgId);
311
- usageByMessageId.set(msgId, {
312
- input: Math.max(prev?.input ?? 0, usage.input_tokens ?? 0),
313
- output: Math.max(prev?.output ?? 0, usage.output_tokens ?? 0),
314
- cacheRead: Math.max(prev?.cacheRead ?? 0, usage.cache_read_input_tokens ?? 0),
315
- cacheCreation: Math.max(prev?.cacheCreation ?? 0, usage.cache_creation_input_tokens ?? 0),
316
- turn: prev?.turn ?? turnSeq,
317
- });
396
+ usageByMessageId.set(msgId, maxUsageRecord(usageByMessageId.get(msgId), usage, turnSeq));
318
397
  }
319
398
  }
320
399
  }
321
400
  if (obj.type === 'system' && obj.subtype === 'compact_boundary') {
322
- acc.bumpCompact();
401
+ compactCount++;
323
402
  const cm = obj.compactMetadata;
324
403
  if (cm?.postTokens) {
325
404
  lastPostTokens = cm.postTokens;
@@ -372,62 +451,9 @@ function scalarScan(filePath) {
372
451
  if (isRealUserPrompt(obj))
373
452
  turnSeq++;
374
453
  });
375
- // Feed one best record per message id, in first-appearance order (Map
376
- // preserves insertion order), so acc.last is the LAST message's full usage
377
- // and totals sum each API response exactly once. Per-run flow is only the
378
- // records belonging to the last real-user turn — a tool-looping turn has
379
- // several assistant messages (one per model request), so `last` alone
380
- // under-reports it.
381
- const turn = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
382
- for (const record of usageByMessageId.values()) {
383
- acc.add(record);
384
- if (turnSeq > 0 && record.turn === turnSeq) {
385
- turn.input += record.input;
386
- turn.output += record.output;
387
- turn.cacheRead += record.cacheRead;
388
- turn.cacheCreation += record.cacheCreation;
389
- }
390
- }
391
- // Build usage result (same logic as old aggregateSessionUsage)
392
- const t = acc.totals;
393
- const l = acc.last;
394
- // 没有真人 user prompt(旧数据/截断文件)→ 无法划分 turn,回退末条采样,
395
- // 与 dsh/codex「无 turn 标记退回末条」一致;不能把整份文件当成一轮。
396
- if (turnSeq === 0 && l) {
397
- turn.input = l.input;
398
- turn.output = l.output;
399
- turn.cacheRead = l.cacheRead;
400
- turn.cacheCreation = l.cacheCreation;
401
- }
402
- // contextLength = current context-window occupancy. Prefer compact_boundary
403
- // postTokens (the precise post-compact size); otherwise fall back to the
404
- // last turn's input + cacheRead + cacheCreation (unified
405
- // contract — excludes output/reasoning, which are generated, not part of
406
- // the input window). Take the max so a pre-compact turn larger than the
407
- // post-compact size is not under-reported.
408
- const lastWindow = l ? contextWindowOccupancy(l) : 0;
409
- let contextLength = lastPostTokens;
410
- if (contextLength === 0 || lastWindow > contextLength) {
411
- contextLength = lastWindow;
412
- }
413
- // 非累计字段 = 末轮(本 run)scope,累计字段 = session 总和(所有 run)。
414
- // 对齐 codex reader 语义(§9.21:`last_token_usage`=单 turn 增量、
415
- // `total_token_usage`=session 累计)——否则 jsonl 兜底路径会把 session 累计
416
- // 当成"本 run"显示,与卡片"本 run ≤ 累计"不变量冲突(累计反而更小)。
417
- const lastTotal = turn.input + turn.output + turn.cacheRead + turn.cacheCreation;
418
- const usage = {
419
- inputTokens: turn.input,
420
- outputTokens: turn.output,
421
- contextLength,
422
- compactCount: acc.compactCount,
423
- cacheReadTokens: turn.cacheRead > 0 ? turn.cacheRead : undefined,
424
- cacheCreationTokens: turn.cacheCreation > 0 ? turn.cacheCreation : undefined,
425
- totalTokens: lastTotal,
426
- ...cumulativeUsageFields(t),
427
- };
428
454
  return {
429
455
  tailOffset,
430
- usage,
456
+ usage: buildClaudeUsage(usageByMessageId.values(), { turnSeq, lastPostTokens, compactCount }),
431
457
  aiTitle: lastAiTitle,
432
458
  recap: lastRecap,
433
459
  lastUserMessage,
@@ -132,7 +132,7 @@ function scanCodexRollout(filePath, includeEvents) {
132
132
  // (see `extractRealUserText`). Injected scaffolding (project rules,
133
133
  // <environment_context>, permissions) is written as `role:"user"`
134
134
  // response_items but gets NEITHER event, so it stays excluded from
135
- // displayTitle/recap/summary. Regression 2026-07-13: the first `role:user`
135
+ // displayTitle/recap/summary. Without this, the first `role:user`
136
136
  // message is the injected project rules, mistakenly shown as "最近输入".
137
137
  const realUserMessages = [];
138
138
  // Which channel produced the last pushed message — used only to collapse a
@@ -298,7 +298,7 @@ function scanCodexRollout(filePath, includeEvents) {
298
298
  }
299
299
  // 兼容性漂移探针:文件里有 `role:"user"` 注入,却识别不出任何真人输入 ——
300
300
  // 说明 codex 又换了 event_msg 词表,displayTitle 会静默变空(0.153 事故形态)。
301
- // 只告警不兜底:误用注入文本当标题正是 2026-07-13 修掉的缺陷。
301
+ // 只告警不兜底:误用注入文本当标题是已修掉的缺陷。
302
302
  if (realUserMessages.length === 0 && userResponseItemCount > 0) {
303
303
  getLogger().warn(`[codex-rollout-reader] no human user message recognised in ${filePath} ` +
304
304
  `(${userResponseItemCount} role:"user" response_items seen) — codex rollout ` +
@@ -720,7 +720,7 @@ export function clearSessionIndexCache() {
720
720
  * event. Codex emitted it ONLY for text the human actually typed; injected
721
721
  * scaffolding (project rules, `<environment_context>`, permissions) is
722
722
  * written as `role:"user"` response_items but gets NO such event, so it stays
723
- * out of displayTitle/recap/summary. (Regression 2026-07-13: the first
723
+ * out of displayTitle/recap/summary. (Without this, the first
724
724
  * `role:user` message is the injected project rules, once shown as 最近输入.)
725
725
  * - Current (CLI ≥0.153.4, transitional from 0.147.0): the `user_message` /
726
726
  * `agent_message` events were removed; the same authoritative text now rides
@@ -729,7 +729,7 @@ export function clearSessionIndexCache() {
729
729
  * input, `skill` parts are injected skill files (e.g. a `$skill-name`
730
730
  * invocation) that must not leak into the title.
731
731
  *
732
- * Real-data note (2026-09): no rollout file observed carries BOTH spellings;
732
+ * Real-data note: no rollout file observed carries BOTH spellings;
733
733
  * 0.147–0.149 are transitional (either spelling per file), 0.153.4+/0.154.0 are
734
734
  * item_completed-only. `source` reports which spelling matched so the scanner
735
735
  * can collapse a hypothetical double-write of the same turn.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * session list 故障路径的统一出口:error 日志 + 抛契约文案 + cause 保留。
3
+ *
4
+ * 契约红线:错误文案固定为 `<agent> session list 读取失败: <msg>`(逐字节),
5
+ * router 靠它展示「列表失败」——静默 [] 会让用户误以为没有 session,
6
+ * 与真空不可区分。
7
+ *
8
+ * 各家 ENOENT → 优雅空态的分支是正常路径,留在调用点,不经过这里。
9
+ */
10
+ export declare function throwSessionListError(agent: string, err: unknown): never;
@@ -0,0 +1,16 @@
1
+ import { getLogger } from '../../logger/index.js';
2
+ /**
3
+ * session list 故障路径的统一出口:error 日志 + 抛契约文案 + cause 保留。
4
+ *
5
+ * 契约红线:错误文案固定为 `<agent> session list 读取失败: <msg>`(逐字节),
6
+ * router 靠它展示「列表失败」——静默 [] 会让用户误以为没有 session,
7
+ * 与真空不可区分。
8
+ *
9
+ * 各家 ENOENT → 优雅空态的分支是正常路径,留在调用点,不经过这里。
10
+ */
11
+ export function throwSessionListError(agent, err) {
12
+ getLogger().error(`[${agent}-session-reader] session list failed: ${err}`);
13
+ const msg = err instanceof Error ? err.message : String(err);
14
+ throw new Error(`${agent} session list 读取失败: ${msg}`, { cause: err });
15
+ }
16
+ //# sourceMappingURL=throw-session-list-error.js.map
@@ -211,8 +211,12 @@ export class DshSessionReader {
211
211
  return { sessions: page, total };
212
212
  }
213
213
  catch (error) {
214
- getLogger().error(`[dsh-session-reader] listSessions error: ${error.message}`);
215
- return { sessions: [], total: 0 };
214
+ // 远端 RPC 没有真空态:daemon 未启动 / cookie 失效 / 超时全是故障态。
215
+ // error 日志 + 上抛,router 显示「列表失败」——静默 [] 会让用户误以为
216
+ // 没有 session,与真空不可区分(与 kimi/opencode 同口径)。
217
+ getLogger().error(`[dsh-session-reader] listSessions failed: ${error}`);
218
+ const msg = error instanceof Error ? error.message : String(error);
219
+ throw new Error(`dsh session list 读取失败: ${msg}`, { cause: error });
216
220
  }
217
221
  }
218
222
  getNewestSession(cwd) {
@@ -358,7 +362,9 @@ export class DshSessionReader {
358
362
  };
359
363
  }
360
364
  catch (error) {
361
- getLogger().error(`[dsh-session-reader] readSessionContent error: ${error.message}`);
365
+ // 降级为空卡(与 claude/pi 同口径),但完整错误对象入日志保留 stack,
366
+ // 否则 wire 截断 / 权限问题只剩一句 message 无法定位。
367
+ getLogger().error(`[dsh-session-reader] readSessionContent failed: ${error}`);
362
368
  return { events: [] };
363
369
  }
364
370
  }
@@ -367,7 +373,7 @@ export class DshSessionReader {
367
373
  return this.resolveSession(sessionId, cwd)?.running === true;
368
374
  }
369
375
  catch (error) {
370
- getLogger().error(`[dsh-session-reader] isSessionActive error: ${error.message}`);
376
+ getLogger().error(`[dsh-session-reader] isSessionActive failed: ${error}`);
371
377
  return false;
372
378
  }
373
379
  }
@@ -535,7 +541,7 @@ function mapEvent(ev) {
535
541
  const timestamp = safeEventTimestamp(ev.time);
536
542
  // DSH 的块类型是 text / reasoning / tool-call(不是 Anthropic 的
537
543
  // thinking)。只认 text 会让 resume 卡"只有工具调用、没有思考与输出"
538
- // (2026-10-19 反馈:一个 31 turn 的会话里 reasoning 块 371 个、text 12 个,
544
+ // (一个 31 turn 的会话里 reasoning 块 371 个、text 12 个,
539
545
  // 全被丢掉)。tool-call 块不在这里取——独立的 `tool/call` 事件带完整参数。
540
546
  for (const block of content) {
541
547
  const text = typeof block.text === 'string' ? block.text.trim() : '';