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
@@ -269,6 +269,10 @@ export class BaseAcpTranslator {
269
269
  }
270
270
  /**
271
271
  * tool_call_update → user/tool_result. status:'failed' → is_error:true.
272
+ *
273
+ * 终态(completed/failed)产出 tool_result;非终态的 tool_result 由
274
+ * {@link emitToolResultForNonTerminalUpdate} 裁定(reducer 见到 tool_result
275
+ * 即置 status:'ok'——无内容的非终态 tool_result 会把运行中的工具提前翻成 ✅)。
272
276
  */
273
277
  handleToolCallUpdate(event, _sessionId) {
274
278
  const isError = event.status === 'failed';
@@ -293,6 +297,13 @@ export class BaseAcpTranslator {
293
297
  else if (event.rawInput !== undefined && event.rawInput !== null) {
294
298
  this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
295
299
  }
300
+ // 非终态是否发 tool_result 由子类裁定(emitToolResultForNonTerminalUpdate):
301
+ // opencode 的 in-progress 带输出快照,seam 契约钉死「patch + tool_result」
302
+ // 都要发(面板在工具运行时显示部分输出);kimi 的 in-progress 只带流式
303
+ // args,空 tool_result 会让 reducer 把运行中的工具提前置 ✅ + 空输出。
304
+ if (!this.emitToolResultForNonTerminalUpdate(content)) {
305
+ return useUpdate ? [useUpdate] : [];
306
+ }
296
307
  }
297
308
  const events = [];
298
309
  if (useUpdate)
@@ -390,6 +401,17 @@ export class BaseAcpTranslator {
390
401
  extractToolResultContent(event) {
391
402
  return event.rawOutput ?? '';
392
403
  }
404
+ /**
405
+ * 非终态 tool_call_update 是否附带 tool_result。默认 true(opencode seam
406
+ * 契约:in-progress 的输出快照随 tool_result 进面板)。kimi 覆写为
407
+ * 「有内容才发」——它的 in-progress 只带流式 args,空 tool_result 会让
408
+ * reducer 把运行中的工具提前置 ✅ + 空输出(终态丢失时面板停在错误的
409
+ * 「已完成」)。
410
+ */
411
+ emitToolResultForNonTerminalUpdate(content) {
412
+ void content;
413
+ return true;
414
+ }
393
415
  /**
394
416
  * usage_update → context occupancy bookkeeping. The wire shape is
395
417
  * {used, size} (+ opencode: cost:{amount}) — context occupancy, not
@@ -1,4 +1,5 @@
1
1
  import { getLogger } from '../../logger/index.js';
2
+ import { StringDecoder } from 'node:string_decoder';
2
3
  /**
3
4
  * Parse JSONL (newline-delimited JSON) from a readable stream.
4
5
  *
@@ -30,6 +31,8 @@ export function createJSONLStream(stdout, options) {
30
31
  // and push complete lines; the residual tail stays as array fragments until
31
32
  // the next newline or stream end. Net allocation: O(n) instead of O(n²).
32
33
  const partialFragments = [];
34
+ // 跨 chunk 多字节字符的边界安全解码器(见 stdout 'data' 处理)。
35
+ const decoder = new StringDecoder('utf8');
33
36
  // accumulated byte length of partialFragments. Backpressure (above)
34
37
  // only counts COMPLETE lines in lineQueue; a never-newline stream (malformed
35
38
  // output, binary garbage) accumulates fragments without ever completing a
@@ -60,6 +63,11 @@ export function createJSONLStream(stdout, options) {
60
63
  // Flush any trailing partial line and mark the stream finished. Shared by the
61
64
  // 'end'/'close'/'error' handlers so flush logic lives in one place (nit).
62
65
  function flushAndFinish() {
66
+ // decoder.end() 吐出被 chunk 边界切开的最后一个多字节字符的残余字节
67
+ //(无残尾时返回空串):不加这句,末尾被切开的字符会凭空丢失。
68
+ const tail = decoder.end();
69
+ if (tail)
70
+ partialFragments.push(tail);
63
71
  if (partialFragments.length > 0) {
64
72
  const trimmed = partialFragments.join('').trim();
65
73
  if (trimmed)
@@ -109,7 +117,10 @@ export function createJSONLStream(stdout, options) {
109
117
  }
110
118
  }
111
119
  stdout.on('data', (chunk) => {
112
- const text = chunk.toString('utf-8');
120
+ // StringDecoder 按字节边界解码:多字节字符(中文/emoji)被 chunk 边界
121
+ // 切开时,裸 toString 会产出不可逆的 U+FFFD(同族修复见
122
+ // jsonrpc/transport.ts,claude 的中文输出走本流)。
123
+ const text = decoder.write(chunk);
113
124
  // split on newlines. The first segment continues the pending partial
114
125
  // line (joined with accumulated fragments — one join per chunk, not per
115
126
  // byte); subsequent segments are complete lines. The last segment is the
@@ -84,7 +84,17 @@ export class ConnectionManager {
84
84
  if (existing) {
85
85
  // If a create is in flight, wait for it
86
86
  if (existing.createPromise) {
87
- return existing.createPromise;
87
+ const client = await existing.createPromise;
88
+ // 快速路径复核(与下方建槽方的竞态复核对称):await 期间槽位被
89
+ // release()/disposeAll() 删掉时,建槽方会 dispose 该连接并重试——
90
+ // 直接返回会把「已被建槽方 dispose 的连接」交给本调用方(表现为
91
+ // setupTurn 抛 ConnectionLostError 走 respawn-retry 自愈)。槽位
92
+ // 不再持有该 client 时与本轮创建同路:重试。
93
+ const current = this.slots.get(cwd);
94
+ if (current && current.client === client) {
95
+ return client;
96
+ }
97
+ continue;
88
98
  }
89
99
  // Reuse when the (session-aware) reuse predicate holds — e.g. the client
90
100
  // is ready and its transport is alive (default), or it is bound to the
@@ -54,7 +54,8 @@ export declare class JsonlRpcTransport {
54
54
  get closed(): boolean;
55
55
  /**
56
56
  * Spawn the child process and start reading its stdout.
57
- * Returns when the process closes (or immediately if spawn fails).
57
+ * Resolves once the process has spawned and listeners are wired — it does
58
+ * NOT wait for the process to close(关闭经 events.onClose 上报)。
58
59
  */
59
60
  start(events: TransportEvents): Promise<void>;
60
61
  /**
@@ -62,7 +62,8 @@ export class JsonlRpcTransport {
62
62
  }
63
63
  /**
64
64
  * Spawn the child process and start reading its stdout.
65
- * Returns when the process closes (or immediately if spawn fails).
65
+ * Resolves once the process has spawned and listeners are wired — it does
66
+ * NOT wait for the process to close(关闭经 events.onClose 上报)。
66
67
  */
67
68
  async start(events) {
68
69
  this.events = events;
@@ -121,6 +122,11 @@ export class JsonlRpcTransport {
121
122
  const decoder = new StringDecoder('utf8');
122
123
  let remainder = '';
123
124
  proc.stdout?.on('data', (chunk) => {
125
+ // handleClose(超长行/EPIPE/进程 error)判死通道后不再分发:子进程被
126
+ // terminator 停止前还会吐几帧,把这些帧交给 onMessage 等于向已判死的
127
+ // run 注入事件。
128
+ if (this._closed)
129
+ return;
124
130
  remainder += decoder.write(chunk);
125
131
  const lines = remainder.split('\n');
126
132
  // Lines except the last (incomplete) one
@@ -8,6 +8,7 @@
8
8
  * `callId`, with an order-independent pairing so either transport may arrive
9
9
  * first.
10
10
  */
11
+ import { getLogger } from '../../logger/index.js';
11
12
  export class DshApprovalTracker {
12
13
  audits = new Map();
13
14
  waterfallByCallId = new Map();
@@ -22,8 +23,17 @@ export class DshApprovalTracker {
22
23
  this.audits.set(audit.approvalId, audit);
23
24
  if (audit.callId) {
24
25
  const waterfall = this.waterfallByCallId.get(audit.callId);
25
- if (waterfall)
26
- return this.bind(audit, waterfall);
26
+ if (waterfall) {
27
+ const boundTo = this.approvalIdByEventId.get(waterfall.eventId);
28
+ if (!boundTo || boundTo === audit.approvalId) {
29
+ return this.bind(audit, waterfall);
30
+ }
31
+ // 该 waterfall 已被兜底配对绑给另一张卡(「无 callId 的 audit 先到 +
32
+ // waterfall 先到」的乱序组合,见 onWaterfall 的 toolName 启发式):
33
+ // 不再二次绑定——两张卡指向同一可答 waterfall 时,先答的生效、
34
+ // 后答的服务端报错;单卡可答严格好于双卡可答。
35
+ getLogger().debug(`[dsh] waterfall already bound to ${boundTo}; skip rebind for ${audit.approvalId}`);
36
+ }
27
37
  }
28
38
  return undefined;
29
39
  }
@@ -86,8 +96,11 @@ export class DshApprovalTracker {
86
96
  this.waterfallByCallId.delete(binding.waterfall.callId);
87
97
  }
88
98
  }
89
- this.resolveWaiters(approvalId, undefined);
90
99
  }
100
+ // 无论有无 binding 都要唤醒 waiter:approval/decided 落在「audit 已到、
101
+ // waterfall 未到」的窗口时,waitForBinding 的点击会白等满超时才收到
102
+ // 「审批通道尚未就绪」——而该审批实际上已被外部决策结束。
103
+ this.resolveWaiters(approvalId, undefined);
91
104
  return binding;
92
105
  }
93
106
  /** Mark a waterfall cancelled by the Host (another client answered it). */
@@ -256,7 +256,20 @@ export declare class DshClient {
256
256
  * Fallback probe: the newest durable event's type tells whether a turn is open.
257
257
  *
258
258
  * 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
259
- * 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
259
+ * 结算)、`turn/end` 都不算,判不出来就报告 `unknown`。
260
+ *
261
+ * 落盘顺序已从 DSH 源码验证(packages/core/agent-loop:agent.ts 的 turn()/
262
+ * step() + inbox.ts 的 mutate()):prompt 提交只落 `agent/inbox/spliced`,
263
+ * `turn/start` 由 turn() 在认领前 append,`user/message` 在 step() 认领时才
264
+ * append——顺序是 spliced → turn/start → user/message。两个边界:
265
+ *
266
+ * - 尾部 `agent/inbox/spliced` → closed 是**对的**:turn 尚未打开,cancel
267
+ * 空闲 session 无害,随后 withdraw 探测会撤走排队 prompt;
268
+ * - 尾部 `user/message` → **unknown**:step() 里 user/message append 后要等
269
+ * 一整个 LLM 往返才有下一条 durable 事件(assistant 流帧是 process-local
270
+ * 的),这个秒级窗口内 turn 已打开;且压缩 checkpoint 也以 turn:null 落
271
+ * user/message——两种解释并存时只能报 unknown(stop 按 unconfirmed 处理,
272
+ * 不假报已停止)。此前报 closed 会让一个 LLM 往返宽的窗口假报干净停止。
260
273
  */
261
274
  private probeOpenTurnByEvents;
262
275
  /**
@@ -290,6 +303,23 @@ export declare class DshClient {
290
303
  * keeps the card "running" until this run's turn actually finishes.
291
304
  */
292
305
  sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number, promptRequestId?: string): AsyncGenerator<DshStreamItem>;
306
+ /**
307
+ * 快照缺口回填(经 DSH 源码验证后实现)。
308
+ *
309
+ * follow 开场快照按**消息**截取:host 的 paginate 只数 user/assistant
310
+ * message(工具事件随所在消息组搭车进快照,不占预算),`maxMessages` 内
311
+ * 装不下的更早前缀被截掉并以 `hasMore: true` 标记。断连期间积压超过
312
+ * FOLLOW_MAX_MESSAGES 条消息时,`lastSeq` 与快照起点之间的缺口若不补,
313
+ * 这些事件对本次 follow 永久缺失(卡片丢中段工具行;seq 去重只防重放
314
+ * 不补缺)。`session/page` 的 `throughSeq` 游标正是服务端为此设计的回填
315
+ * 通道——从缺口尾部向前翻页直到盖住 lastSeq、翻到日志头或达到页数上限。
316
+ *
317
+ * best-effort:翻页失败保留已取回的部分(缺口剩余与旧行为一致地丢失,
318
+ * 但只发生在超出 BACKFILL_MAX_PAGES×PAGE_MESSAGES 条消息的极端断连)。
319
+ * 返回 seq 升序的完整回放序列(回填 + 快照记录;与 lastSeq 重叠的条目
320
+ * 由回放循环的 seq 去重兜住)。
321
+ */
322
+ private replaySnapshotGap;
293
323
  /**
294
324
  * Host-wide live projection stream (`session/control`).
295
325
  *
@@ -52,8 +52,19 @@ function isDshAuthError(error) {
52
52
  }
53
53
  /** Backoff between mux reconnects to avoid a hot loop on a dropping server. */
54
54
  const RECONNECT_BACKOFF_MS = 200;
55
- /** Follow snapshot budget. `session/follow` also stays live after the snapshot. */
55
+ /**
56
+ * Follow snapshot budget. `session/follow` also stays live after the snapshot.
57
+ *
58
+ * 语义(经 DSH 源码验证,packages/api/session-controller/src/history.ts
59
+ * 的 paginate):按**消息**计数——只有 user/assistant message 占预算,工具
60
+ * 事件随所在消息组搭进快照。断连期间积压超过该预算时快照首条会越过
61
+ * lastSeq,缺口经 {@link replaySnapshotGap} 用 session/page 回填。
62
+ */
56
63
  const FOLLOW_MAX_MESSAGES = 200;
64
+ /** 快照缺口回填的单页消息数(session/page 的 maxMessages)。 */
65
+ const FOLLOW_GAP_BACKFILL_PAGE_MESSAGES = 200;
66
+ /** 快照缺口回填的页数上限(×PAGE_MESSAGES = 单次重连最多回补 4000 条消息)。 */
67
+ const FOLLOW_GAP_BACKFILL_MAX_PAGES = 20;
57
68
  /**
58
69
  * 只在 turn 内部落盘的会话事件类型:看到它们说明 turn 还没结束。
59
70
  * 名单刻意保守——`user/message`(压缩 checkpoint 会以 turn:null 落盘)、
@@ -116,7 +127,15 @@ export function extractDshToken(raw) {
116
127
  return value;
117
128
  try {
118
129
  const url = new URL(value.startsWith('?') ? `http://dsh.invalid/${value}` : value);
119
- return url.searchParams.get('token') ?? value;
130
+ const token = url.searchParams.get('token');
131
+ if (token)
132
+ return token;
133
+ // 带 scheme 却没有 token 参数的输入是误贴的 host URL(如
134
+ // `http://127.0.0.1:3080`):整串不是合法 token,参与握手只会得到
135
+ // 指向性缺失的 401。返回空串让上游走「token 缺失」的诊断路径。
136
+ if (value.includes('://'))
137
+ return '';
138
+ return value;
120
139
  }
121
140
  catch {
122
141
  return value;
@@ -540,7 +559,20 @@ export class DshClient {
540
559
  * Fallback probe: the newest durable event's type tells whether a turn is open.
541
560
  *
542
561
  * 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
543
- * 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
562
+ * 结算)、`turn/end` 都不算,判不出来就报告 `unknown`。
563
+ *
564
+ * 落盘顺序已从 DSH 源码验证(packages/core/agent-loop:agent.ts 的 turn()/
565
+ * step() + inbox.ts 的 mutate()):prompt 提交只落 `agent/inbox/spliced`,
566
+ * `turn/start` 由 turn() 在认领前 append,`user/message` 在 step() 认领时才
567
+ * append——顺序是 spliced → turn/start → user/message。两个边界:
568
+ *
569
+ * - 尾部 `agent/inbox/spliced` → closed 是**对的**:turn 尚未打开,cancel
570
+ * 空闲 session 无害,随后 withdraw 探测会撤走排队 prompt;
571
+ * - 尾部 `user/message` → **unknown**:step() 里 user/message append 后要等
572
+ * 一整个 LLM 往返才有下一条 durable 事件(assistant 流帧是 process-local
573
+ * 的),这个秒级窗口内 turn 已打开;且压缩 checkpoint 也以 turn:null 落
574
+ * user/message——两种解释并存时只能报 unknown(stop 按 unconfirmed 处理,
575
+ * 不假报已停止)。此前报 closed 会让一个 LLM 往返宽的窗口假报干净停止。
544
576
  */
545
577
  async probeOpenTurnByEvents(sessionId, opts) {
546
578
  try {
@@ -555,6 +587,8 @@ export class DshClient {
555
587
  const last = value.records.at(-1)?.event;
556
588
  if (last === undefined)
557
589
  return 'closed';
590
+ if (last.type === 'user/message')
591
+ return 'unknown';
558
592
  return last.type === 'turn/start' || IN_TURN_EVENT_TYPES.has(last.type) ? 'open' : 'closed';
559
593
  }
560
594
  catch (err) {
@@ -575,6 +609,7 @@ export class DshClient {
575
609
  let waiter = null;
576
610
  let ended = false;
577
611
  let failure;
612
+ let opened = false;
578
613
  const settle = (frame) => {
579
614
  if (waiter) {
580
615
  const resolve = waiter;
@@ -587,7 +622,27 @@ export class DshClient {
587
622
  else
588
623
  ended = true;
589
624
  };
625
+ // 建连/首帧超时:服务器接受 TCP 但不完成 WS 握手时 onopen/onerror/onclose
626
+ // 都不触发,generator 会永远停在 await next()——unary 挂起有 60s 预算与
627
+ // 回归测试(test_anchor_dsh_stop_with_hung_unary_releases_run),mux 的
628
+ // 同型问题此前只能靠 idle 看门狗兜底。超时按传输错误走既有重连路径。
629
+ const connectTimer = setTimeout(() => {
630
+ if (opened || ended)
631
+ return;
632
+ failure = new DshError('transport', 'DSH remote mux WebSocket connect timeout');
633
+ ended = true;
634
+ settle(undefined);
635
+ try {
636
+ socket.close();
637
+ }
638
+ catch {
639
+ /* ignore close error */
640
+ }
641
+ }, this.requestTimeoutMs);
642
+ connectTimer.unref?.();
590
643
  socket.onopen = () => {
644
+ opened = true;
645
+ clearTimeout(connectTimer);
591
646
  if (ended)
592
647
  return;
593
648
  try {
@@ -694,6 +749,7 @@ export class DshClient {
694
749
  }
695
750
  }
696
751
  finally {
752
+ clearTimeout(connectTimer);
697
753
  signal.removeEventListener('abort', onAbort);
698
754
  try {
699
755
  socket.close();
@@ -734,6 +790,12 @@ export class DshClient {
734
790
  const frame = raw;
735
791
  if (frame.type === 'ready') {
736
792
  authRefreshAttempted = false;
793
+ // 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 +
794
+ // 网络抖动累计掉线超过 3 次后,旧实现会让 $events 永久 unavailable
795
+ // ——已渲染的提问卡持有过期 clientId(点答案必失败)、Host 的 cancel
796
+ // 帧也收不到,违反「同一 waterfall 在 $events 重连后会被重投」的
797
+ // 不变量。预算语义是「单次断连窗口内的连续失败上限」,不是终身配额。
798
+ reconnectAttempts = 0;
737
799
  sawReady = true;
738
800
  streamClientId = frame.clientId;
739
801
  yield { kind: 'ready', clientId: streamClientId };
@@ -904,9 +966,10 @@ export class DshClient {
904
966
  return;
905
967
  authRefreshAttempted = false;
906
968
  if (frame.type === 'snapshot') {
907
- for (const record of frame.records ?? []) {
908
- const ev = record.event;
909
- if (!ev || ev.seq <= state.lastSeq)
969
+ // 快照可能只覆盖尾部窗口(见 replaySnapshotGap):先补缺口再回放,
970
+ // 回放循环与原逻辑逐条相同(去重 / admit / turn/end 早退)。
971
+ for (const ev of await this.replaySnapshotGap(sessionId, signal, state.lastSeq, frame)) {
972
+ if (ev.seq <= state.lastSeq)
910
973
  continue;
911
974
  state.lastSeq = ev.seq;
912
975
  if (!admit(ev))
@@ -963,6 +1026,63 @@ export class DshClient {
963
1026
  await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
964
1027
  }
965
1028
  }
1029
+ /**
1030
+ * 快照缺口回填(经 DSH 源码验证后实现)。
1031
+ *
1032
+ * follow 开场快照按**消息**截取:host 的 paginate 只数 user/assistant
1033
+ * message(工具事件随所在消息组搭车进快照,不占预算),`maxMessages` 内
1034
+ * 装不下的更早前缀被截掉并以 `hasMore: true` 标记。断连期间积压超过
1035
+ * FOLLOW_MAX_MESSAGES 条消息时,`lastSeq` 与快照起点之间的缺口若不补,
1036
+ * 这些事件对本次 follow 永久缺失(卡片丢中段工具行;seq 去重只防重放
1037
+ * 不补缺)。`session/page` 的 `throughSeq` 游标正是服务端为此设计的回填
1038
+ * 通道——从缺口尾部向前翻页直到盖住 lastSeq、翻到日志头或达到页数上限。
1039
+ *
1040
+ * best-effort:翻页失败保留已取回的部分(缺口剩余与旧行为一致地丢失,
1041
+ * 但只发生在超出 BACKFILL_MAX_PAGES×PAGE_MESSAGES 条消息的极端断连)。
1042
+ * 返回 seq 升序的完整回放序列(回填 + 快照记录;与 lastSeq 重叠的条目
1043
+ * 由回放循环的 seq 去重兜住)。
1044
+ */
1045
+ async replaySnapshotGap(sessionId, signal, lastSeq, frame) {
1046
+ const records = (frame.records ?? [])
1047
+ .map((record) => record.event)
1048
+ .filter((ev) => !!ev);
1049
+ if (frame.hasMore !== true || signal.aborted)
1050
+ return records;
1051
+ const firstSeq = records[0]?.seq;
1052
+ const gapEnd = firstSeq !== undefined ? firstSeq - 1 : frame.cursor;
1053
+ if (gapEnd <= lastSeq)
1054
+ return records;
1055
+ const pages = [];
1056
+ try {
1057
+ let throughSeq = gapEnd;
1058
+ for (let page = 0; page < FOLLOW_GAP_BACKFILL_MAX_PAGES; page++) {
1059
+ const value = await this.page(sessionId, throughSeq, FOLLOW_GAP_BACKFILL_PAGE_MESSAGES, {
1060
+ signal,
1061
+ });
1062
+ const events = (value.records ?? [])
1063
+ .map((record) => record.event)
1064
+ .filter((ev) => !!ev);
1065
+ if (events.length > 0)
1066
+ pages.push(events);
1067
+ const oldest = events[0]?.seq;
1068
+ // hasMore=false(日志头)或本页已盖住 lastSeq:缺口补齐,停。
1069
+ if (value.hasMore !== true || oldest === undefined || oldest <= lastSeq + 1)
1070
+ break;
1071
+ throughSeq = oldest - 1;
1072
+ }
1073
+ }
1074
+ catch (err) {
1075
+ getLogger().warn(`[dsh] follow snapshot gap backfill incomplete sessionId=${sessionId}: ${err.message}`);
1076
+ }
1077
+ // pages 是「从新到旧」收集的;倒序拼接得到 seq 升序回放。
1078
+ const backfill = [];
1079
+ for (let index = pages.length - 1; index >= 0; index--)
1080
+ backfill.push(...pages[index]);
1081
+ if (backfill.length > 0) {
1082
+ getLogger().info(`[dsh] backfilled ${backfill.length} events before follow snapshot sessionId=${sessionId} gapEnd=${gapEnd} lastSeq=${lastSeq}`);
1083
+ }
1084
+ return [...backfill, ...records];
1085
+ }
966
1086
  /**
967
1087
  * Host-wide live projection stream (`session/control`).
968
1088
  *
@@ -12,6 +12,9 @@
12
12
  * curl transport the session reader already uses: bounded by `timeoutMs` and
13
13
  * never throws.
14
14
  */
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
15
18
  import { getLogger } from '../../logger/index.js';
16
19
  import { isWin32 } from '../../platform/select.js';
17
20
  import { spawnProcessSync } from '../../platform/spawn.js';
@@ -44,29 +47,65 @@ export function cancelSessionSync(opts) {
44
47
  method: 'session/cancel',
45
48
  payload: { args: { request: { sessionId: opts.sessionId } } },
46
49
  });
47
- const res = spawnProcessSync('curl', [
48
- '-s',
49
- '-o',
50
- isWin32() ? 'NUL' : '/dev/null',
51
- '-w',
52
- '%{http_code}',
53
- '-X',
54
- 'POST',
55
- `${opts.baseUrl.replace(/\/$/, '')}/api/session/cancel`,
56
- '-H',
57
- 'content-type: application/json',
58
- '-H',
59
- `Cookie: ${cookie}`,
60
- '--max-time',
61
- String(Math.max(1, Math.ceil(timeoutMs / 1000))),
62
- '--data',
63
- body,
64
- ], { encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 1024 * 1024 });
65
- const status = String(res.stdout ?? '').trim();
66
- const ok = status === '200';
67
- if (!ok) {
68
- logger.warn(`[dsh] exit-cancel failed for ${opts.sessionId}: ${res.error?.message ?? `http ${status || 'n/a'}`}`);
50
+ // Cookie 不走 argv(`-H "Cookie: …"` 会在 curl 运行期间对本机任何进程的
51
+ // ps 可见——1 小时有效的 v1 凭据泄露面):写 0600 临时文件用 `-H @file`
52
+ // 传入,用完即删。临时目录必须用 mkdtemp(0700、随机名):Linux 的 /tmp
53
+ // 所有用户可写,固定名(如 `<pid>.hdr`)可被本地用户预建软链,而
54
+ // writeFileSync 会跟随软链把 cookie 写进攻击者可读的文件;后续 chmod
55
+ // 也救不回已被打开的 fd。文件写失败时回退 argv——退出路径的 cancel
56
+ // 不能因此放弃。
57
+ let headerDir;
58
+ let cookieHeaderArgs;
59
+ try {
60
+ headerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lark-remote-exit-cancel-'));
61
+ const headerFile = path.join(headerDir, 'cookie.hdr');
62
+ fs.writeFileSync(headerFile, `Cookie: ${cookie}\n`, { mode: 0o600, flag: 'wx' });
63
+ cookieHeaderArgs = ['-H', `@${headerFile}`];
64
+ }
65
+ catch (err) {
66
+ logger.warn(`[dsh] exit-cancel header file unavailable: ${err.message}`);
67
+ removeDir(headerDir);
68
+ headerDir = undefined;
69
+ cookieHeaderArgs = ['-H', `Cookie: ${cookie}`];
70
+ }
71
+ try {
72
+ const res = spawnProcessSync('curl', [
73
+ '-s',
74
+ '-o',
75
+ isWin32() ? 'NUL' : '/dev/null',
76
+ '-w',
77
+ '%{http_code}',
78
+ '-X',
79
+ 'POST',
80
+ `${opts.baseUrl.replace(/\/$/, '')}/api/session/cancel`,
81
+ '-H',
82
+ 'content-type: application/json',
83
+ ...cookieHeaderArgs,
84
+ '--max-time',
85
+ String(Math.max(1, Math.ceil(timeoutMs / 1000))),
86
+ '--data',
87
+ body,
88
+ ], { encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 1024 * 1024 });
89
+ const status = String(res.stdout ?? '').trim();
90
+ const ok = status === '200';
91
+ if (!ok) {
92
+ logger.warn(`[dsh] exit-cancel failed for ${opts.sessionId}: ${res.error?.message ?? `http ${status || 'n/a'}`}`);
93
+ }
94
+ return ok;
95
+ }
96
+ finally {
97
+ removeDir(headerDir);
98
+ }
99
+ }
100
+ /** 删除 cookie 临时目录(best-effort);目录未创建时是空操作。 */
101
+ function removeDir(dir) {
102
+ if (!dir)
103
+ return;
104
+ try {
105
+ fs.rmSync(dir, { recursive: true, force: true });
106
+ }
107
+ catch {
108
+ /* best-effort cleanup */
69
109
  }
70
- return ok;
71
110
  }
72
111
  //# sourceMappingURL=exit-cancel.js.map
@@ -230,6 +230,13 @@ export declare class DshRunner implements AgentRunner {
230
230
  * that is the only client-side protection against a late orphan turn.
231
231
  */
232
232
  private findPendingPromptWithin;
233
+ /**
234
+ * cancel 确认 closed 后的收尾:cancel 是 keepInbox,prompt 可能仍在 inbox。
235
+ * 在则撤回(防被下一个 turn 隐形认领),确认不在(已被本 turn 消费)或
236
+ * 撤回成功才清 inflight 标记;探测/撤回失败保留标记,交给下次启动的
237
+ * reconcile(findPendingPrompt 落空即自然清除,无副作用)。
238
+ */
239
+ private withdrawPendingPromptAfterCancel;
233
240
  private removePendingPrompt;
234
241
  /** One cancel attempt, then one retry (the request is idempotent when idle). */
235
242
  private cancelWithRetry;
@@ -1216,8 +1216,14 @@ export class DshRunner {
1216
1216
  const state = await this.waitForClosed(sessionId, deadline);
1217
1217
  if (state === 'closed') {
1218
1218
  releaseGate();
1219
- if (promptRequestId)
1220
- this.inflight?.clear(sessionId, promptRequestId);
1219
+ // cancel 是 keepInbox:prompt 可能仍在 inbox 里,等下一个无人订阅的
1220
+ // turn 认领(探测超时/迟到 admit 退到 cancel 分支时,
1221
+ // 旧实现直接清 inflight 标记——被停的 prompt 会被后续 turn 隐形执行
1222
+ //(耗 token、改工作区,卡片不可见),启动对账安全网也被解除)。
1223
+ // closed 后补一次撤回:在 inbox 就撤走,确认不在才清标记。
1224
+ if (promptRequestId) {
1225
+ await this.withdrawPendingPromptAfterCancel(sessionId, promptRequestId, deadline);
1226
+ }
1221
1227
  getLogger().info(`[dsh] cancelled session ${sessionId}`);
1222
1228
  return { status: 'cancelled', sessionId };
1223
1229
  }
@@ -1233,8 +1239,9 @@ export class DshRunner {
1233
1239
  const retryState = await this.waitForClosed(sessionId, deadline);
1234
1240
  releaseGate();
1235
1241
  if (retryState === 'closed') {
1236
- if (promptRequestId)
1237
- this.inflight?.clear(sessionId, promptRequestId);
1242
+ if (promptRequestId) {
1243
+ await this.withdrawPendingPromptAfterCancel(sessionId, promptRequestId, deadline);
1244
+ }
1238
1245
  return { status: 'cancelled', sessionId };
1239
1246
  }
1240
1247
  getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: turn still open`);
@@ -1268,12 +1275,42 @@ export class DshRunner {
1268
1275
  return undefined;
1269
1276
  }
1270
1277
  catch (err) {
1278
+ // 单次探测错误只消耗一次尝试(不提前放弃):探测失败就 return 会把
1279
+ // 仍在 inbox 的 prompt 交给 cancel 分支处理——cancel 是 keepInbox,
1280
+ // prompt 会留到下一个无人订阅的 turn 被隐形执行。
1271
1281
  getLogger().warn(`[dsh] stop inbox probe failed session=${sessionId}: ${err.message}`);
1272
- return undefined;
1282
+ continue;
1273
1283
  }
1274
1284
  }
1275
1285
  return undefined;
1276
1286
  }
1287
+ /**
1288
+ * cancel 确认 closed 后的收尾:cancel 是 keepInbox,prompt 可能仍在 inbox。
1289
+ * 在则撤回(防被下一个 turn 隐形认领),确认不在(已被本 turn 消费)或
1290
+ * 撤回成功才清 inflight 标记;探测/撤回失败保留标记,交给下次启动的
1291
+ * reconcile(findPendingPrompt 落空即自然清除,无副作用)。
1292
+ */
1293
+ async withdrawPendingPromptAfterCancel(sessionId, promptRequestId, deadline) {
1294
+ try {
1295
+ const itemId = await this.client.findPendingPrompt(sessionId, promptRequestId, {
1296
+ timeoutMs: this.stopProbeTimeout(deadline),
1297
+ });
1298
+ if (itemId) {
1299
+ const removed = await this.client.removeQueuedPrompt(sessionId, itemId, {
1300
+ timeoutMs: this.stopProbeTimeout(deadline),
1301
+ });
1302
+ if (removed) {
1303
+ getLogger().info(`[dsh] stop withdrew inbox prompt left behind by cancel session=${sessionId}`);
1304
+ this.inflight?.clear(sessionId, promptRequestId);
1305
+ }
1306
+ return;
1307
+ }
1308
+ this.inflight?.clear(sessionId, promptRequestId);
1309
+ }
1310
+ catch (err) {
1311
+ getLogger().warn(`[dsh] post-cancel withdraw probe failed session=${sessionId}: ${err.message}`);
1312
+ }
1313
+ }
1277
1314
  async removePendingPrompt(sessionId, itemId, deadline) {
1278
1315
  try {
1279
1316
  return await this.client.removeQueuedPrompt(sessionId, itemId, {