lark-remote 0.5.9 → 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 (53) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/binder.js +7 -1
  3. package/dist/bridge/index.d.ts +9 -0
  4. package/dist/bridge/index.js +28 -3
  5. package/dist/bridge/queue-manager.d.ts +31 -0
  6. package/dist/bridge/queue-manager.js +78 -0
  7. package/dist/card/approval-render.d.ts +14 -0
  8. package/dist/card/approval-render.js +155 -29
  9. package/dist/card/bash-card-session.js +8 -0
  10. package/dist/card/bash-renderer.js +11 -23
  11. package/dist/card/card-budget.js +24 -4
  12. package/dist/card/run-renderer.js +31 -9
  13. package/dist/common/text-width.js +5 -0
  14. package/dist/common/tool-summary.js +4 -2
  15. package/dist/config/codex-config.js +5 -3
  16. package/dist/config/index.js +13 -1
  17. package/dist/index.js +42 -24
  18. package/dist/instance-lock.d.ts +9 -1
  19. package/dist/instance-lock.js +42 -7
  20. package/dist/platform/identity.js +4 -3
  21. package/dist/platform/path.js +3 -1
  22. package/dist/platform/terminator-win32.d.ts +13 -1
  23. package/dist/platform/terminator-win32.js +4 -14
  24. package/dist/router/index.js +179 -122
  25. package/dist/runner/codex/app-server/runner.js +12 -0
  26. package/dist/runner/common/acp/base-translator.d.ts +12 -0
  27. package/dist/runner/common/acp/base-translator.js +22 -0
  28. package/dist/runner/common/jsonl-stream.js +12 -1
  29. package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
  30. package/dist/runner/common/jsonrpc/connection-manager.js +34 -9
  31. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  32. package/dist/runner/common/jsonrpc/transport.js +7 -1
  33. package/dist/runner/dsh/approval.js +16 -3
  34. package/dist/runner/dsh/client.d.ts +31 -1
  35. package/dist/runner/dsh/client.js +126 -6
  36. package/dist/runner/dsh/exit-cancel.js +62 -23
  37. package/dist/runner/dsh/runner.d.ts +7 -0
  38. package/dist/runner/dsh/runner.js +42 -5
  39. package/dist/runner/kimi/acp/runner.js +42 -5
  40. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  41. package/dist/runner/kimi/acp/translator.js +10 -0
  42. package/dist/runner/pi/rpc/runner.js +33 -5
  43. package/dist/runner/resolve-agent-choices.js +5 -1
  44. package/dist/runner/sync-agent-choices.js +4 -1
  45. package/dist/session/claude/session-index.js +9 -2
  46. package/dist/session/codex/rollout-reader.js +51 -26
  47. package/dist/session/common/usage-accumulator.d.ts +10 -1
  48. package/dist/session/common/usage-accumulator.js +10 -2
  49. package/dist/session/dsh/sessions.js +29 -9
  50. package/dist/session/kimi/sessions.js +9 -5
  51. package/dist/session/opencode/sessions.js +13 -6
  52. package/dist/session/session-store.js +4 -3
  53. package/package.json +1 -1
@@ -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, {
@@ -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. */