lark-remote 0.5.10 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/binder.js +7 -1
  3. package/dist/bridge/index.js +12 -3
  4. package/dist/card/approval-render.d.ts +14 -0
  5. package/dist/card/approval-render.js +155 -29
  6. package/dist/card/bash-card-session.js +8 -0
  7. package/dist/card/bash-renderer.js +11 -23
  8. package/dist/card/card-budget.js +24 -4
  9. package/dist/card/run-renderer.js +31 -9
  10. package/dist/common/text-width.js +5 -0
  11. package/dist/common/tool-summary.js +4 -2
  12. package/dist/config/codex-config.js +5 -3
  13. package/dist/config/index.js +13 -1
  14. package/dist/index.js +42 -24
  15. package/dist/instance-lock.d.ts +9 -1
  16. package/dist/instance-lock.js +42 -7
  17. package/dist/platform/identity.js +4 -3
  18. package/dist/platform/path.js +3 -1
  19. package/dist/platform/terminator-win32.d.ts +13 -1
  20. package/dist/platform/terminator-win32.js +4 -14
  21. package/dist/router/index.js +52 -10
  22. package/dist/runner/codex/app-server/runner.js +12 -0
  23. package/dist/runner/common/acp/base-translator.d.ts +12 -0
  24. package/dist/runner/common/acp/base-translator.js +22 -0
  25. package/dist/runner/common/jsonl-stream.js +12 -1
  26. package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
  27. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  28. package/dist/runner/common/jsonrpc/transport.js +7 -1
  29. package/dist/runner/dsh/approval.js +16 -3
  30. package/dist/runner/dsh/client.d.ts +31 -1
  31. package/dist/runner/dsh/client.js +126 -6
  32. package/dist/runner/dsh/exit-cancel.js +62 -23
  33. package/dist/runner/dsh/runner.d.ts +7 -0
  34. package/dist/runner/dsh/runner.js +42 -5
  35. package/dist/runner/kimi/acp/runner.js +42 -5
  36. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  37. package/dist/runner/kimi/acp/translator.js +10 -0
  38. package/dist/runner/pi/rpc/runner.js +33 -5
  39. package/dist/runner/resolve-agent-choices.js +5 -1
  40. package/dist/runner/sync-agent-choices.js +4 -1
  41. package/dist/session/claude/session-index.js +9 -2
  42. package/dist/session/codex/rollout-reader.js +51 -26
  43. package/dist/session/common/usage-accumulator.d.ts +10 -1
  44. package/dist/session/common/usage-accumulator.js +10 -2
  45. package/dist/session/dsh/sessions.js +29 -9
  46. package/dist/session/kimi/sessions.js +9 -5
  47. package/dist/session/opencode/sessions.js +13 -6
  48. package/dist/session/session-store.js +4 -3
  49. package/package.json +1 -1
@@ -144,6 +144,11 @@ export class KimiAcpRunner extends BaseAcpRunner {
144
144
  */
145
145
  compactBaselineRecords = [];
146
146
  /** 存活 terminal(kimi 把 Bash 执行下放客户端):terminalId → 本地句柄。 */
147
+ // 已知限制(待验证 kimi 0.4x 契约):句柄只在
148
+ // terminal/release 与连接关闭(cleanupTerminals)时删除。若服务端只 kill
149
+ // 不 release(中断 turn 的场景),已退出进程的句柄(含 ≤4MB output 缓冲)
150
+ // 会滞留到连接释放。kimi 契约上工具结束会发 release(mock 测试同驱动);
151
+ // 若真机确认存在只 kill 不 release 的路径,再补 exit 后延迟回收。
147
152
  terminals = new Map();
148
153
  /** terminalId 自增序号(term-1, term-2, …)。 */
149
154
  terminalSeq = 0;
@@ -422,6 +427,11 @@ export class KimiAcpRunner extends BaseAcpRunner {
422
427
  while (true) {
423
428
  if (this.forceFinish || this.stopRequested)
424
429
  return 'stopped';
430
+ // turn 已死亡(failTurn,如连接关闭)时不再轮询:forceFinish 与
431
+ // stopRequested 均为 false 的死亡路径会让循环空转到 10 分钟沉默超时,
432
+ // 结果还被 isCurrent() 丢弃——纯浪费的每秒文件读。
433
+ if (!this._isRunning)
434
+ return 'unknown';
425
435
  const records = reader.call(this.sessionReader, sessionId, opts.cwd);
426
436
  if (records.length !== lastTotalCount) {
427
437
  lastTotalCount = records.length;
@@ -506,8 +516,15 @@ export class KimiAcpRunner extends BaseAcpRunner {
506
516
  // (error = "rejected"), otherwise the server hangs waiting for a
507
517
  // response. For question elicitation (§5.4), auto-respond cancelled.
508
518
  const rpParams = params;
509
- if (method === ServerRequestMethod.REQUEST_PERMISSION &&
510
- (rpParams.isQuestion || !rpParams.toolCall)) {
519
+ // elicitation/create 的表单属性全不可解析时(translator 返回 [])同样回
520
+ // cancelled 而不是 METHOD_NOT_FOUND:§5.4 的不变量是「识别到非工具审批
521
+ // 形态 → 自动回应 cancelled,防止服务端挂起」;错误响应虽也能解悬挂,
522
+ // 但 kimi 侧可能把 JSON-RPC error 当工具级失败让整个 turn 报错,而不是
523
+ // 跳过提问继续。
524
+ const isQuestionElicitation = method === ServerRequestMethod.REQUEST_PERMISSION &&
525
+ (rpParams.isQuestion || !rpParams.toolCall);
526
+ const isUnparseableElicitationForm = method === ServerRequestMethod.ELICITATION_CREATE;
527
+ if (isQuestionElicitation || isUnparseableElicitationForm) {
511
528
  this.currentClient?.respond(id, { outcome: { outcome: 'cancelled' } });
512
529
  getLogger().info(`[${this.logTag}] auto-responded cancelled to question elicitation requestId=${id}`);
513
530
  }
@@ -633,8 +650,27 @@ export class KimiAcpRunner extends BaseAcpRunner {
633
650
  proc.once('exit', (code, signal) => {
634
651
  handle.exitCode = code;
635
652
  handle.signal = signal;
636
- resolveExit?.();
637
- emitResult();
653
+ // 'exit' 可先于 stdout 缓冲派发:立刻 emit 会按旧快照推送 tool_result,
654
+ // 卡片面板丢尾部输出(terminal/output 反向 RPC 读到的是补齐后的全量,
655
+ // 只影响卡片)。等流 end 或 200ms 宽限——不等 'close':它要等所有共享
656
+ // 管道的子孙进程退出,`nohup &` 类后台任务会把它拖到永远。
657
+ //
658
+ // wait_for_exit 必须在 tool_result 入队之后才 resolve:服务端拿到响应
659
+ // 后会立刻 release + end_turn,先 resolve 会让回合结果早于延迟推送的
660
+ // tool_result 到达,Bash 输出被丢弃(卡片面板空输出)。
661
+ const flushThenEmit = () => {
662
+ clearTimeout(emitTimer);
663
+ emitResult();
664
+ resolveExit?.();
665
+ };
666
+ const emitTimer = setTimeout(flushThenEmit, 200);
667
+ emitTimer.unref?.();
668
+ if (proc.stdout && !proc.stdout.destroyed) {
669
+ proc.stdout.once('end', flushThenEmit);
670
+ }
671
+ else {
672
+ flushThenEmit();
673
+ }
638
674
  });
639
675
  proc.once('error', (err) => {
640
676
  getLogger().warn(`[${this.logTag}] terminal process error: ${err.message}`);
@@ -642,8 +678,9 @@ export class KimiAcpRunner extends BaseAcpRunner {
642
678
  handle.exitCode = 1;
643
679
  handle.signal = null;
644
680
  }
645
- resolveExit?.();
681
+ // 同 exit:tool_result 先入队,再放行 wait_for_exit。
646
682
  emitResult();
683
+ resolveExit?.();
647
684
  });
648
685
  const terminalId = `term-${++this.terminalSeq}`;
649
686
  this.terminals.set(terminalId, handle);
@@ -29,6 +29,14 @@ import { type RequestPermissionParams, type AgentMessageChunkEvent, type AgentTh
29
29
  import { BaseAcpTranslator, type AcpTranslatorEvent } from '../../common/acp/base-translator.js';
30
30
  export declare class KimiAcpTranslator extends BaseAcpTranslator {
31
31
  protected readonly logTag = "kimi-acp-translator";
32
+ /**
33
+ * 非终态 tool_call_update 不附带空 tool_result:kimi 非 Bash 工具的
34
+ * in-progress 只带流式 args(rawOutput 缺省 → content ''),空 tool_result
35
+ * 会让 reducer 把运行中的工具提前置 ✅ + 空输出(终态到达后自愈,但终态
36
+ * 丢失时面板停在错误的「已完成」)。opencode 走基类默认 true(其
37
+ * in-progress 带输出快照,seam 契约钉死要发)。
38
+ */
39
+ protected emitToolResultForNonTerminalUpdate(content: unknown): boolean;
32
40
  /** Accumulated text per tool-call or message (keyed by a logical item id). */
33
41
  private textByItem;
34
42
  /** Accumulated thinking per item. */
@@ -42,6 +42,16 @@ const THINKING_ITEM_ID = 'thinking';
42
42
  // =============================================================================
43
43
  export class KimiAcpTranslator extends BaseAcpTranslator {
44
44
  logTag = 'kimi-acp-translator';
45
+ /**
46
+ * 非终态 tool_call_update 不附带空 tool_result:kimi 非 Bash 工具的
47
+ * in-progress 只带流式 args(rawOutput 缺省 → content ''),空 tool_result
48
+ * 会让 reducer 把运行中的工具提前置 ✅ + 空输出(终态到达后自愈,但终态
49
+ * 丢失时面板停在错误的「已完成」)。opencode 走基类默认 true(其
50
+ * in-progress 带输出快照,seam 契约钉死要发)。
51
+ */
52
+ emitToolResultForNonTerminalUpdate(content) {
53
+ return !(content === undefined || content === null || content === '');
54
+ }
45
55
  /** Accumulated text per tool-call or message (keyed by a logical item id). */
46
56
  textByItem = new Map();
47
57
  /** Accumulated thinking per item. */
@@ -113,9 +113,24 @@ export class PiRpcRunner extends ConnectionBasedRunner {
113
113
  const client = await this.manager.acquire(opts.cwd, { sessionId: opts.sessionId });
114
114
  this.manager.notifyActivity(opts.cwd);
115
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;
116
123
  client.setHooks({
117
- onEvent: (evt) => this.handleEvent(evt),
118
- onClose: () => this.failTurn('Pi RPC connection closed'),
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
+ },
119
134
  });
120
135
  let sessionId = opts.sessionId;
121
136
  if (!sessionId) {
@@ -157,9 +172,19 @@ export class PiRpcRunner extends ConnectionBasedRunner {
157
172
  const client = await this.manager.acquire(opts.cwd, { sessionId: opts.sessionId });
158
173
  this.manager.notifyActivity(opts.cwd);
159
174
  this.currentClient = client;
175
+ // 同 setupTurn:hook 带轮次代际锚点,旧连接的迟到回调不得进入本轮。
176
+ const generation = this.turnGeneration;
160
177
  client.setHooks({
161
- onEvent: (evt) => this.handleEvent(evt),
162
- onClose: () => this.failTurn('Pi RPC connection closed'),
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
+ },
163
188
  });
164
189
  this.activeSessionId = opts.sessionId;
165
190
  const translator = new PiRpcTranslator();
@@ -170,7 +195,10 @@ export class PiRpcRunner extends ConnectionBasedRunner {
170
195
  this.currentTurnId = turnId;
171
196
  this.pushEvents([translator.produceTurnStarted(opts.sessionId, turnId)]);
172
197
  this.promptSettled = false;
173
- const response = await client.request({ type: 'compact' });
198
+ // compact 是完整的 LLM 请求(同步跑完才回响应,协议文档自述),大上下文
199
+ // 压缩轻松超过默认 60s 请求超时——超时只会出假错误卡,压缩仍在后台完成。
200
+ // 活性交给 turn idle 看门狗与 /stop(与 ACP 侧 turn 级请求同口径)。
201
+ const response = await client.request({ type: 'compact' }, Number.POSITIVE_INFINITY);
174
202
  this.promptSettled = true;
175
203
  // Guard: a stop may have already ended this turn (deferred-stop during
176
204
  // compaction) — don't push a stale result into a (possibly reused) queue.
@@ -25,7 +25,11 @@ export function resolveAgentChoices(config) {
25
25
  const source = agentChoices;
26
26
  for (const { configKey, choicesKey } of fields) {
27
27
  const value = source[choicesKey];
28
- if (!target[configKey] && value) {
28
+ // 只填「配置里根本没写」的字段(undefined):显式清空(config.yaml 写了
29
+ // `model: ""`)是用户意志,旧真值判断 `!target[configKey]` 会把空串当
30
+ // 「未配置」,用残留 choices 把清空值填回去——重启后永远回不到默认。
31
+ // value 为空串同样不填(choices 里记录的「跟随默认」)。
32
+ if (target[configKey] === undefined && value) {
29
33
  target[configKey] = value;
30
34
  }
31
35
  }
@@ -14,7 +14,10 @@ export function syncAgentChoices(config, agent) {
14
14
  const source = agentCfg;
15
15
  for (const { configKey, choicesKey } of fields) {
16
16
  const value = source[configKey];
17
- if (value)
17
+ // 显式空串也要同步(用户清空 = 回到默认):旧真值判断不同步空串,
18
+ // agentChoices 里残留上一次的选择——/config 卡片显示的与实际生效的
19
+ // 配置从此分叉,重启后 resolve 还会把残留填回 config。
20
+ if (value !== undefined)
18
21
  agentChoices[choicesKey] = value;
19
22
  }
20
23
  return updated;
@@ -62,8 +62,15 @@ export function parseSessionJsonl(filePath) {
62
62
  if (typeof obj.cwd === 'string' && obj.cwd) {
63
63
  cwdSet.add(obj.cwd);
64
64
  }
65
- // Find first real user message for summary (same logic as summarizeSession)
66
- if (!foundSummary && obj.type === 'user' && !!obj.message) {
65
+ // Find first real user message for summary (same logic as summarizeSession).
66
+ // 过滤谓词与 scalarScan 的 lastUserMessage 同源:isMeta(/clear 等命令回显
67
+ // 与项目规则注入)和 isCompactSummary(压缩摘要)不是用户说的话,当列表
68
+ // 标题会误导(codex 修过同型缺陷:注入文本被当成「最近输入」)。
69
+ if (!foundSummary &&
70
+ obj.type === 'user' &&
71
+ !obj.isCompactSummary &&
72
+ !obj.isMeta &&
73
+ !!obj.message) {
67
74
  const msg = obj.message;
68
75
  if (msg.content) {
69
76
  const text = extractText(msg.content);
@@ -455,6 +455,14 @@ export function readCodexSessionContent(sessionId, opts = {}) {
455
455
  if (!entry) {
456
456
  return { events: [] };
457
457
  }
458
+ // subagent rollout 不得当父会话历史返回:子线程 rollout 的
459
+ // session_meta.session_id 是父 id(§9.27),索引冲突解决在主文件缺失时会让
460
+ // 子文件占据该 id 的槽位,threadId 检查因等于父 id 而天然通过——list/active
461
+ // 都已过滤(isSubagent),按 id 读取必须同口径,否则「列表没有、active 报
462
+ // false、读内容却成功」三处语义互相矛盾。
463
+ if (entry.isSubagent) {
464
+ return { events: [] };
465
+ }
458
466
  // Cwd guard: when a cwd is provided, the session's working directory must
459
467
  // match. Without this, /resume <id> finds sessions by global ID lookup and
460
468
  // allows resuming a session from cwd B while the user is in cwd A.
@@ -495,6 +503,10 @@ export function readCodexSessionSummary(sessionId, opts = {}) {
495
503
  if (!entry) {
496
504
  return {};
497
505
  }
506
+ // subagent rollout 同样不得当父会话摘要(与 readCodexSessionContent 同口径)。
507
+ if (entry.isSubagent) {
508
+ return {};
509
+ }
498
510
  // Cwd guard: same semantics as readCodexSessionContent — when a cwd is
499
511
  // provided, the session's working directory must match.
500
512
  if (opts.cwd && !samePath(entry.cwd, opts.cwd)) {
@@ -560,42 +572,55 @@ function walkRolloutFiles(sessionsDir) {
560
572
  if (!fs.existsSync(sessionsDir))
561
573
  return [];
562
574
  const result = [];
563
- try {
564
- const years = fs.readdirSync(sessionsDir);
565
- for (const year of years) {
566
- if (!/^\d{4}$/.test(year))
575
+ // per-entry try/catch:目录在 readdir 与 stat 之间被删(并发归档/清理)时,
576
+ // 旧实现整个 walk 提前终止并返回部分结果——索引漏掉后续文件,会话从
577
+ // /resume 列表与 total 消失直到 5s TTL 重建。单条目失败只跳过自己。
578
+ const years = safeReaddir(sessionsDir);
579
+ for (const year of years) {
580
+ if (!/^\d{4}$/.test(year))
581
+ continue;
582
+ const yearPath = path.join(sessionsDir, year);
583
+ if (!safeIsDir(yearPath))
584
+ continue;
585
+ for (const month of safeReaddir(yearPath)) {
586
+ if (!/^\d{2}$/.test(month))
567
587
  continue;
568
- const yearPath = path.join(sessionsDir, year);
569
- if (!fs.statSync(yearPath).isDirectory())
588
+ const monthPath = path.join(yearPath, month);
589
+ if (!safeIsDir(monthPath))
570
590
  continue;
571
- const months = fs.readdirSync(yearPath);
572
- for (const month of months) {
573
- if (!/^\d{2}$/.test(month))
591
+ for (const day of safeReaddir(monthPath)) {
592
+ if (!/^\d{2}$/.test(day))
574
593
  continue;
575
- const monthPath = path.join(yearPath, month);
576
- if (!fs.statSync(monthPath).isDirectory())
594
+ const dayPath = path.join(monthPath, day);
595
+ if (!safeIsDir(dayPath))
577
596
  continue;
578
- const days = fs.readdirSync(monthPath);
579
- for (const day of days) {
580
- if (!/^\d{2}$/.test(day))
581
- continue;
582
- const dayPath = path.join(monthPath, day);
583
- if (!fs.statSync(dayPath).isDirectory())
584
- continue;
585
- const files = fs
586
- .readdirSync(dayPath)
587
- .filter((f) => f.startsWith('rollout-') && f.endsWith('.jsonl'));
588
- for (const file of files) {
589
- result.push(path.join(dayPath, file));
590
- }
597
+ const files = safeReaddir(dayPath).filter((f) => f.startsWith('rollout-') && f.endsWith('.jsonl'));
598
+ for (const file of files) {
599
+ result.push(path.join(dayPath, file));
591
600
  }
592
601
  }
593
602
  }
594
603
  }
604
+ return result;
605
+ }
606
+ /** readdir 的竞态安全版:目录消失/不可读返回空并记 debug(外层不再中断)。 */
607
+ function safeReaddir(dir) {
608
+ try {
609
+ return fs.readdirSync(dir);
610
+ }
595
611
  catch (err) {
596
- getLogger().warn(`[codex-rollout-reader] failed to walk sessions dir: ${err}`);
612
+ getLogger().debug(`[codex-rollout-reader] readdir failed (raced delete?): ${err}`);
613
+ return [];
614
+ }
615
+ }
616
+ /** statSync 的竞态安全目录判定。 */
617
+ function safeIsDir(p) {
618
+ try {
619
+ return fs.statSync(p).isDirectory();
620
+ }
621
+ catch {
622
+ return false;
597
623
  }
598
- return result;
599
624
  }
600
625
  const INDEX_TTL_MS = 5_000; // 5 seconds — covers a single /resume flow
601
626
  const sessionIndexCache = new Map();
@@ -55,9 +55,18 @@ export declare class UsageAccumulator {
55
55
  */
56
56
  bumpCompact(): void;
57
57
  }
58
- /** 统一契约:上下文窗口占用 = 末轮 input + cacheRead + cacheCreation(不含 output/reasoning)。 */
58
+ /**
59
+ * 统一契约:上下文窗口占用 = 末轮 input + output + cacheRead + cacheCreation。
60
+ *
61
+ * 口径以 design.md §9.13/§9.21 为准(文档在 6 处钉死该公式):上下文占用衡量
62
+ * 「本轮结束后窗口里有多少 token」,末轮 output 已进入历史、下一个 prompt 会
63
+ * 全量携带;与 max() 另一侧的 compact postTokens(含全部内容的压缩后水位)
64
+ * 语义对齐。此前实现漏加 output(长收尾 turn 可低估数千~3 万 token),且
65
+ * compact 会话与非 compact 会话两口径不可比。
66
+ */
59
67
  export declare function contextWindowOccupancy(l: {
60
68
  input: number;
69
+ output: number;
61
70
  cacheRead: number;
62
71
  cacheCreation: number;
63
72
  }): number;
@@ -69,9 +69,17 @@ export class UsageAccumulator {
69
69
  this._compactCount++;
70
70
  }
71
71
  }
72
- /** 统一契约:上下文窗口占用 = 末轮 input + cacheRead + cacheCreation(不含 output/reasoning)。 */
72
+ /**
73
+ * 统一契约:上下文窗口占用 = 末轮 input + output + cacheRead + cacheCreation。
74
+ *
75
+ * 口径以 design.md §9.13/§9.21 为准(文档在 6 处钉死该公式):上下文占用衡量
76
+ * 「本轮结束后窗口里有多少 token」,末轮 output 已进入历史、下一个 prompt 会
77
+ * 全量携带;与 max() 另一侧的 compact postTokens(含全部内容的压缩后水位)
78
+ * 语义对齐。此前实现漏加 output(长收尾 turn 可低估数千~3 万 token),且
79
+ * compact 会话与非 compact 会话两口径不可比。
80
+ */
73
81
  export function contextWindowOccupancy(l) {
74
- return l.input + l.cacheRead + l.cacheCreation;
82
+ return l.input + l.output + l.cacheRead + l.cacheCreation;
75
83
  }
76
84
  /** 统一契约:session 全量累计字段组装(4 个 reader 共用:claude/dsh/kimi/pi;
77
85
  * codex 走 last_token_usage 口径、opencode 保留自己的条件赋值,原为逐字段复制的 4 连拷贝)。 */
@@ -285,7 +285,11 @@ export class DshSessionReader {
285
285
  const input = lastUsage.inputTokens ?? 0;
286
286
  const cacheRead = lastUsage.cacheReadTokens ?? 0;
287
287
  const cacheWrite = lastUsage.cacheWriteTokens ?? 0;
288
- const flow = lastTurnUsage ?? {
288
+ // 在途 turn(turn/start 无 turn/end,run 进行中读历史)的采样优先于
289
+ // 上一轮:旧实现只认已结算的 lastTurnUsage,mid-run 读取会把当前 turn
290
+ // 已消耗的量显示成上一个完整 turn 的数字。
291
+ const flow = lastTurnUsage ??
292
+ turnUsage ?? {
289
293
  input,
290
294
  output: lastUsage.outputTokens ?? 0,
291
295
  cacheRead,
@@ -294,7 +298,8 @@ export class DshSessionReader {
294
298
  sessionUsage = {
295
299
  inputTokens: flow.input,
296
300
  outputTokens: flow.output,
297
- contextLength: input + cacheRead + cacheWrite,
301
+ // §9.21 口径:含末轮 output(下一 prompt 全量携带,与 postTokens 水位同语义)。
302
+ contextLength: input + flow.output + cacheRead + cacheWrite,
298
303
  cacheReadTokens: flow.cacheRead,
299
304
  cacheCreationTokens: flow.cacheWrite,
300
305
  totalTokens: flow.input + flow.output + flow.cacheRead + flow.cacheWrite,
@@ -432,11 +437,27 @@ const TEXT_MAX_BYTES = 2_000;
432
437
  /** 工具原始参数上限(标题已有摘要,正文只用于看完整命令)。 */
433
438
  const TOOL_INPUT_MAX_BYTES = 600;
434
439
  /** Map one DSH SessionEvent to display content events (+ title/usage side effects). */
440
+ /**
441
+ * 单条畸形事件只跳过自己,不炸整张卡:DSH 服务端格式漂移时(time 非法、
442
+ * content 非数组),旧实现会让异常冒到外层 catch 把整个 resume 卡变成空卡
443
+ * (fail-whole)。时间戳缺失由卡片层兜底;content 非数组按无块处理。
444
+ */
445
+ function safeEventTimestamp(time) {
446
+ if (typeof time !== 'string' && typeof time !== 'number')
447
+ return undefined;
448
+ const d = new Date(time);
449
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
450
+ }
451
+ function asBlocks(value) {
452
+ if (!Array.isArray(value))
453
+ return [];
454
+ return value.filter((b) => !!b && typeof b === 'object');
455
+ }
435
456
  function mapEvent(ev) {
436
457
  const out = { events: [] };
437
458
  switch (ev.type) {
438
459
  case 'user/message': {
439
- const content = ev.data.content ?? [];
460
+ const content = asBlocks(ev.data.content);
440
461
  const text = content
441
462
  .filter((c) => c.type === 'text')
442
463
  .map((c) => c.text ?? '')
@@ -456,14 +477,13 @@ function mapEvent(ev) {
456
477
  type: isNotice ? 'notice' : 'user',
457
478
  ...(isNotice ? { title: '🔔 通知' } : {}),
458
479
  content: isNotice ? truncateUtf8(text.trim(), NOTICE_MAX_BYTES) : text,
459
- timestamp: new Date(ev.time).toISOString(),
480
+ timestamp: safeEventTimestamp(ev.time),
460
481
  });
461
482
  break;
462
483
  }
463
484
  case 'assistant/message': {
464
- const content = ev.data.message
465
- ?.content ?? [];
466
- const timestamp = new Date(ev.time).toISOString();
485
+ const content = asBlocks(ev.data.message?.content);
486
+ const timestamp = safeEventTimestamp(ev.time);
467
487
  // DSH 的块类型是 text / reasoning / tool-call(不是 Anthropic 的
468
488
  // thinking)。只认 text 会让 resume 卡"只有工具调用、没有思考与输出"
469
489
  // (2026-10-19 反馈:一个 31 turn 的会话里 reasoning 块 371 个、text 12 个,
@@ -503,7 +523,7 @@ function mapEvent(ev) {
503
523
  type: 'tool_use',
504
524
  title: summary ? `🔧 ${name} — ${summary}` : `🔧 ${name}`,
505
525
  content: truncateUtf8(args, TOOL_INPUT_MAX_BYTES),
506
- timestamp: new Date(ev.time).toISOString(),
526
+ timestamp: safeEventTimestamp(ev.time),
507
527
  };
508
528
  break;
509
529
  }
@@ -515,7 +535,7 @@ function mapEvent(ev) {
515
535
  type: 'tool_result',
516
536
  title: isError ? '🔴 结果(失败)' : '🟢 结果',
517
537
  content: truncateUtf8(raw, TOOL_RESULT_MAX_BYTES),
518
- timestamp: new Date(ev.time).toISOString(),
538
+ timestamp: safeEventTimestamp(ev.time),
519
539
  });
520
540
  break;
521
541
  }
@@ -227,7 +227,10 @@ export class KimiSessionReader {
227
227
  entryWorkDir = extractWorkDir(state);
228
228
  // Unreadable state — cannot determine cwd, entryWorkDir stays undefined
229
229
  }
230
- if (entryWorkDir !== realCwd) {
230
+ // cwd 判定与守卫(checkCwdGuard 的 samePath)同口径:严格字符串比较会
231
+ // 把大小写(darwin)/尾分隔符形态不同的会话从列表里滤掉,而 /resume <id>
232
+ // 的守卫却判等通过——「列表没有、按 id 却能读」。
233
+ if (entryWorkDir === undefined || !samePath(entryWorkDir, realCwd)) {
231
234
  continue;
232
235
  }
233
236
  let summary = 'New Session';
@@ -332,9 +335,10 @@ export class KimiSessionReader {
332
335
  cacheCreation: inputCacheCreation,
333
336
  });
334
337
  if (updateContextLength) {
335
- // unified contract: input + cacheRead + cacheCreation
336
- // (excludes output — generated, not part of the input window).
337
- mainContextLength = inputOther + inputCacheRead + inputCacheCreation;
338
+ // unified contract (§9.21): input + output + cacheRead + cacheCreation.
339
+ // 末轮 output 已进入历史、下一 prompt 全量携带——与 compact postTokens
340
+ // 水位同语义(此前漏加 output 会系统性低估长收尾 turn)。
341
+ mainContextLength = inputOther + output + inputCacheRead + inputCacheCreation;
338
342
  }
339
343
  };
340
344
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
@@ -593,7 +597,7 @@ export class KimiSessionReader {
593
597
  // different cwd when state.json is unavailable.
594
598
  if (!cwdGuardPassed) {
595
599
  const indexWorkDir = indexEntry?.workDir;
596
- if (indexWorkDir && indexWorkDir !== realCwd) {
600
+ if (indexWorkDir && !samePath(indexWorkDir, realCwd)) {
597
601
  return null;
598
602
  }
599
603
  // v2 sessions have empty index workDir (v1-only field) and may lack
@@ -243,14 +243,16 @@ export class OpencodeSessionReader {
243
243
  // all render 0). Skip zero-total steps so per-turn usage reflects
244
244
  // the last step that actually consumed tokens.
245
245
  // ccusage-aligned: cache.write -> cacheCreationTokens.
246
- // contextLength = input + cacheRead + cacheCreation (excludes
247
- // output/reasoning) — the unified context-window occupancy
248
- // contract across all five readers.
246
+ // contextLength = input + output + cacheRead + cacheCreation —
247
+ // the unified context-window occupancy contract across all readers
248
+ // (§9.21:末轮 output 已进入历史、下一 prompt 全量携带,与 compact
249
+ // postTokens 水位同语义).
249
250
  if ((part.tokens.total ?? 0) !== 0) {
250
251
  usage = {
251
252
  inputTokens: part.tokens.input,
252
253
  outputTokens: part.tokens.output,
253
- contextLength: part.tokens.input +
254
+ contextLength: (part.tokens.input ?? 0) +
255
+ (part.tokens.output ?? 0) +
254
256
  (part.tokens.cache?.read ?? 0) +
255
257
  (part.tokens.cache?.write ?? 0),
256
258
  cacheReadTokens: part.tokens.cache?.read,
@@ -460,7 +462,8 @@ export class OpencodeSessionReader {
460
462
  latest = {
461
463
  inputTokens: tokens.input,
462
464
  outputTokens: tokens.output,
463
- contextLength: tokens.input + cacheRead + cacheWrite,
465
+ // §9.21 口径:含末轮 output(与 postTokens 水位同语义)。
466
+ contextLength: tokens.input + tokens.output + cacheRead + cacheWrite,
464
467
  cacheReadTokens: cacheRead,
465
468
  cacheCreationTokens: cacheWrite,
466
469
  totalTokens: total,
@@ -628,7 +631,11 @@ export class OpencodeSessionReader {
628
631
  * stdout when it is redirected to a file fd, so we read the file instead.
629
632
  */
630
633
  captureExportViaFileFd(sessionId) {
631
- const tmp = path.join(os.tmpdir(), `opencode-export-${sessionId}-${process.pid}-${Date.now()}.json`);
634
+ // sessionId 经 path.basename 净化:它来自用户 /resume <id> 输入或畸形 CLI
635
+ // 数据,含 / 或 .. 时 path.join 会归一化出 tmpdir 之外的路径(写入内容为
636
+ // CLI stdout,可能截断/覆盖目标文件)。
637
+ const safeId = path.basename(sessionId);
638
+ const tmp = path.join(os.tmpdir(), `opencode-export-${safeId}-${process.pid}-${Date.now()}.json`);
632
639
  let fd;
633
640
  try {
634
641
  const exportOpts = {
@@ -230,9 +230,10 @@ export class SessionStore {
230
230
  for (const [userId, data] of Object.entries(parsed)) {
231
231
  if (typeof data === 'object' && data !== null) {
232
232
  const entry = data;
233
- // All four session fields are required; a missing one indicates a
234
- // corrupt record — skip it entirely. cwd is allowed to be absent
235
- // (legitimately-empty cwd state, see round7 tests).
233
+ // 四个 session 字段缺失 = 损坏记录,整条跳过。cwd 允许缺失:
234
+ // 全新用户(未 /cd)先切 agent 时 previous/arrival 停车位已落盘,
235
+ // cwd 尚为空——不能因 cwd 缺失把停车位整条丢掉
236
+ // (anchor_r7_parked_previous_survives_rebuild_without_cwd)。
236
237
  if (!entry.sessions ||
237
238
  !entry.previousSessions ||
238
239
  !entry.arrivalSessions ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",