lark-remote 0.5.10 → 0.5.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +51 -2
  2. package/README.en.md +1 -14
  3. package/README.md +1 -14
  4. package/dist/binder.js +7 -1
  5. package/dist/bridge/approval-coordinator.d.ts +1 -0
  6. package/dist/bridge/approval-coordinator.js +12 -24
  7. package/dist/bridge/index.d.ts +7 -0
  8. package/dist/bridge/index.js +89 -230
  9. package/dist/bridge/run-approval.d.ts +15 -0
  10. package/dist/bridge/run-approval.js +41 -0
  11. package/dist/bridge/run-usage.d.ts +46 -0
  12. package/dist/bridge/run-usage.js +96 -0
  13. package/dist/bridge/session-writeback.d.ts +18 -0
  14. package/dist/bridge/session-writeback.js +36 -0
  15. package/dist/card/approval-render.d.ts +14 -0
  16. package/dist/card/approval-render.js +155 -29
  17. package/dist/card/bash-card-session.js +10 -5
  18. package/dist/card/bash-renderer.js +11 -23
  19. package/dist/card/card-budget.js +24 -4
  20. package/dist/card/card-session.d.ts +2 -0
  21. package/dist/card/card-session.js +20 -22
  22. package/dist/card/card-shared.d.ts +4 -4
  23. package/dist/card/card-shared.js +3 -9
  24. package/dist/card/collapsible.js +3 -2
  25. package/dist/card/final-answer-overflow.d.ts +7 -0
  26. package/dist/card/final-answer-overflow.js +46 -0
  27. package/dist/card/run-card-session.js +2 -7
  28. package/dist/card/run-renderer.d.ts +14 -0
  29. package/dist/card/run-renderer.js +150 -10
  30. package/dist/card/run-state.d.ts +12 -1
  31. package/dist/card/run-state.js +73 -34
  32. package/dist/common/text-width.js +5 -0
  33. package/dist/common/tool-summary.js +4 -2
  34. package/dist/config/codex-config.js +5 -3
  35. package/dist/config/index.d.ts +4 -2
  36. package/dist/config/index.js +19 -5
  37. package/dist/inbound/turn-assembler.js +6 -0
  38. package/dist/index.js +15 -391
  39. package/dist/instance-lock.d.ts +9 -1
  40. package/dist/instance-lock.js +42 -7
  41. package/dist/message-handlers.d.ts +26 -0
  42. package/dist/message-handlers.js +275 -0
  43. package/dist/platform/identity.js +4 -3
  44. package/dist/platform/path.js +3 -1
  45. package/dist/platform/terminator-win32.d.ts +13 -1
  46. package/dist/platform/terminator-win32.js +4 -14
  47. package/dist/router/card-action-payload.d.ts +1 -1
  48. package/dist/router/card-action-payload.js +1 -1
  49. package/dist/router/commands/ls.d.ts +9 -0
  50. package/dist/router/commands/ls.js +243 -0
  51. package/dist/router/commands/order.d.ts +12 -0
  52. package/dist/router/commands/order.js +178 -0
  53. package/dist/router/commands/resume.d.ts +47 -0
  54. package/dist/router/commands/resume.js +246 -0
  55. package/dist/router/commands/ws.d.ts +15 -0
  56. package/dist/router/commands/ws.js +189 -0
  57. package/dist/router/config/claude.js +3 -3
  58. package/dist/router/config/codex.js +2 -6
  59. package/dist/router/config/common/turn-idle.d.ts +2 -0
  60. package/dist/router/config/common/turn-idle.js +9 -0
  61. package/dist/router/config/kimi.js +2 -6
  62. package/dist/router/config/opencode.js +2 -6
  63. package/dist/router/index.d.ts +14 -22
  64. package/dist/router/index.js +93 -1133
  65. package/dist/router/paging.d.ts +13 -0
  66. package/dist/router/paging.js +20 -0
  67. package/dist/router/path-validation.d.ts +27 -0
  68. package/dist/router/path-validation.js +45 -0
  69. package/dist/runner/claude/runner.d.ts +3 -4
  70. package/dist/runner/claude/runner.js +4 -5
  71. package/dist/runner/claude/session.js +1 -1
  72. package/dist/runner/codex/app-server/runner.js +12 -0
  73. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  74. package/dist/runner/codex/app-server/translator.js +65 -9
  75. package/dist/runner/common/acp/base-translator.d.ts +15 -8
  76. package/dist/runner/common/acp/base-translator.js +38 -7
  77. package/dist/runner/common/jsonl-stream.js +12 -1
  78. package/dist/runner/common/jsonrpc/connection-manager.js +11 -1
  79. package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
  80. package/dist/runner/common/jsonrpc/transport.js +7 -1
  81. package/dist/runner/common/spawning-runner.js +10 -4
  82. package/dist/runner/common/turn-started.d.ts +2 -0
  83. package/dist/runner/common/turn-started.js +10 -0
  84. package/dist/runner/dsh/approval.js +16 -3
  85. package/dist/runner/dsh/auth.js +2 -3
  86. package/dist/runner/dsh/client.d.ts +32 -7
  87. package/dist/runner/dsh/client.js +115 -149
  88. package/dist/runner/dsh/daemon.d.ts +0 -2
  89. package/dist/runner/dsh/daemon.js +1 -6
  90. package/dist/runner/dsh/errors.d.ts +5 -0
  91. package/dist/runner/dsh/errors.js +11 -0
  92. package/dist/runner/dsh/exit-cancel.d.ts +0 -2
  93. package/dist/runner/dsh/exit-cancel.js +63 -24
  94. package/dist/runner/dsh/home.d.ts +2 -0
  95. package/dist/runner/dsh/home.js +8 -0
  96. package/dist/runner/dsh/inflight.d.ts +0 -2
  97. package/dist/runner/dsh/inflight.js +1 -1
  98. package/dist/runner/dsh/mux-stream.d.ts +26 -0
  99. package/dist/runner/dsh/mux-stream.js +157 -0
  100. package/dist/runner/dsh/runner.d.ts +7 -0
  101. package/dist/runner/dsh/runner.js +42 -5
  102. package/dist/runner/dsh/translator.d.ts +0 -10
  103. package/dist/runner/dsh/translator.js +1 -1
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.js +42 -5
  106. package/dist/runner/kimi/acp/translator.d.ts +8 -0
  107. package/dist/runner/kimi/acp/translator.js +11 -12
  108. package/dist/runner/opencode/acp/translator.js +1 -12
  109. package/dist/runner/pi/rpc/runner.js +33 -5
  110. package/dist/runner/pi/rpc/translator.d.ts +2 -8
  111. package/dist/runner/pi/rpc/translator.js +2 -7
  112. package/dist/runner/registry.d.ts +0 -19
  113. package/dist/runner/registry.js +0 -32
  114. package/dist/runner/resolve-agent-choices.js +5 -1
  115. package/dist/runner/sync-agent-choices.js +4 -1
  116. package/dist/runner/types.d.ts +17 -1
  117. package/dist/session/claude/session-index.js +13 -4
  118. package/dist/session/codex/rollout-reader.js +51 -26
  119. package/dist/session/common/usage-accumulator.d.ts +10 -1
  120. package/dist/session/common/usage-accumulator.js +10 -2
  121. package/dist/session/dsh/sessions.js +29 -9
  122. package/dist/session/kimi/sessions.js +11 -7
  123. package/dist/session/opencode/sessions.js +15 -7
  124. package/dist/session/pi/sessions.js +3 -2
  125. package/dist/session/session-store.js +4 -3
  126. package/package.json +1 -1
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import type { DshApprovalStreamItem, DshCommandResult, DshModelCatalogValue, DshModelSelection, DshOpenTurnState, DshPermissionCatalogValue, DshJobView, DshPresetListValue, DshProjectionItem, DshProjectionSnapshot, DshQuestion, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage } from './types.js';
23
23
  import type { GoalView } from '../types.js';
24
+ export { DshError } from './errors.js';
24
25
  interface WebSocketMessageEvent {
25
26
  data: unknown;
26
27
  }
@@ -40,11 +41,6 @@ export interface WebSocketLike {
40
41
  close(): void;
41
42
  }
42
43
  export type WebSocketFactory = (url: string, options?: WebSocketOptions) => WebSocketLike;
43
- export declare class DshError extends Error {
44
- readonly code: string;
45
- readonly details: Record<string, unknown>;
46
- constructor(code: string, message: string, details?: Record<string, unknown>);
47
- }
48
44
  export interface DshClientOptions {
49
45
  /** Configured DSH home; selects `.credentials.yaml` when no explicit file is given. */
50
46
  dshHome?: string;
@@ -256,7 +252,20 @@ export declare class DshClient {
256
252
  * Fallback probe: the newest durable event's type tells whether a turn is open.
257
253
  *
258
254
  * 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
259
- * 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
255
+ * 结算)、`turn/end` 都不算,判不出来就报告 `unknown`。
256
+ *
257
+ * 落盘顺序已从 DSH 源码验证(packages/core/agent-loop:agent.ts 的 turn()/
258
+ * step() + inbox.ts 的 mutate()):prompt 提交只落 `agent/inbox/spliced`,
259
+ * `turn/start` 由 turn() 在认领前 append,`user/message` 在 step() 认领时才
260
+ * append——顺序是 spliced → turn/start → user/message。两个边界:
261
+ *
262
+ * - 尾部 `agent/inbox/spliced` → closed 是**对的**:turn 尚未打开,cancel
263
+ * 空闲 session 无害,随后 withdraw 探测会撤走排队 prompt;
264
+ * - 尾部 `user/message` → **unknown**:step() 里 user/message append 后要等
265
+ * 一整个 LLM 往返才有下一条 durable 事件(assistant 流帧是 process-local
266
+ * 的),这个秒级窗口内 turn 已打开;且压缩 checkpoint 也以 turn:null 落
267
+ * user/message——两种解释并存时只能报 unknown(stop 按 unconfirmed 处理,
268
+ * 不假报已停止)。此前报 closed 会让一个 LLM 往返宽的窗口假报干净停止。
260
269
  */
261
270
  private probeOpenTurnByEvents;
262
271
  /**
@@ -290,6 +299,23 @@ export declare class DshClient {
290
299
  * keeps the card "running" until this run's turn actually finishes.
291
300
  */
292
301
  sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number, promptRequestId?: string): AsyncGenerator<DshStreamItem>;
302
+ /**
303
+ * 快照缺口回填(经 DSH 源码验证后实现)。
304
+ *
305
+ * follow 开场快照按**消息**截取:host 的 paginate 只数 user/assistant
306
+ * message(工具事件随所在消息组搭车进快照,不占预算),`maxMessages` 内
307
+ * 装不下的更早前缀被截掉并以 `hasMore: true` 标记。断连期间积压超过
308
+ * FOLLOW_MAX_MESSAGES 条消息时,`lastSeq` 与快照起点之间的缺口若不补,
309
+ * 这些事件对本次 follow 永久缺失(卡片丢中段工具行;seq 去重只防重放
310
+ * 不补缺)。`session/page` 的 `throughSeq` 游标正是服务端为此设计的回填
311
+ * 通道——从缺口尾部向前翻页直到盖住 lastSeq、翻到日志头或达到页数上限。
312
+ *
313
+ * best-effort:翻页失败保留已取回的部分(缺口剩余与旧行为一致地丢失,
314
+ * 但只发生在超出 BACKFILL_MAX_PAGES×PAGE_MESSAGES 条消息的极端断连)。
315
+ * 返回 seq 升序的完整回放序列(回填 + 快照记录;与 lastSeq 重叠的条目
316
+ * 由回放循环的 seq 去重兜住)。
317
+ */
318
+ private replaySnapshotGap;
293
319
  /**
294
320
  * Host-wide live projection stream (`session/control`).
295
321
  *
@@ -342,4 +368,3 @@ export declare function dshQuestionsOf(request: Record<string, unknown>): DshQue
342
368
  * silently on the server side.
343
369
  */
344
370
  export declare function isTimedQuestionRequest(request: Record<string, unknown>): boolean;
345
- export {};
@@ -25,6 +25,9 @@ import { getLogger } from '../../logger/index.js';
25
25
  import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecret, resolveDshCredentialsFile, } from './auth.js';
26
26
  import { DEFAULT_REQUEST_TIMEOUT_MS } from '../common/defaults.js';
27
27
  import { goalFromProjection } from './goal-view.js';
28
+ import { DshError } from './errors.js';
29
+ import { openMuxStream } from './mux-stream.js';
30
+ export { DshError } from './errors.js';
28
31
  function defaultWebSocketFactory(url, options) {
29
32
  const socket = new WebSocket(url, {
30
33
  ...(options?.headers ? { headers: options.headers } : {}),
@@ -35,16 +38,6 @@ function defaultWebSocketFactory(url, options) {
35
38
  });
36
39
  return socket;
37
40
  }
38
- export class DshError extends Error {
39
- code;
40
- details;
41
- constructor(code, message, details = {}) {
42
- super(message);
43
- this.name = 'DshError';
44
- this.code = code;
45
- this.details = details;
46
- }
47
- }
48
41
  function isDshAuthError(error) {
49
42
  return (error instanceof DshError &&
50
43
  error.code === 'http' &&
@@ -52,8 +45,19 @@ function isDshAuthError(error) {
52
45
  }
53
46
  /** Backoff between mux reconnects to avoid a hot loop on a dropping server. */
54
47
  const RECONNECT_BACKOFF_MS = 200;
55
- /** Follow snapshot budget. `session/follow` also stays live after the snapshot. */
48
+ /**
49
+ * Follow snapshot budget. `session/follow` also stays live after the snapshot.
50
+ *
51
+ * 语义(经 DSH 源码验证,packages/api/session-controller/src/history.ts
52
+ * 的 paginate):按**消息**计数——只有 user/assistant message 占预算,工具
53
+ * 事件随所在消息组搭进快照。断连期间积压超过该预算时快照首条会越过
54
+ * lastSeq,缺口经 {@link replaySnapshotGap} 用 session/page 回填。
55
+ */
56
56
  const FOLLOW_MAX_MESSAGES = 200;
57
+ /** 快照缺口回填的单页消息数(session/page 的 maxMessages)。 */
58
+ const FOLLOW_GAP_BACKFILL_PAGE_MESSAGES = 200;
59
+ /** 快照缺口回填的页数上限(×PAGE_MESSAGES = 单次重连最多回补 4000 条消息)。 */
60
+ const FOLLOW_GAP_BACKFILL_MAX_PAGES = 20;
57
61
  /**
58
62
  * 只在 turn 内部落盘的会话事件类型:看到它们说明 turn 还没结束。
59
63
  * 名单刻意保守——`user/message`(压缩 checkpoint 会以 turn:null 落盘)、
@@ -116,7 +120,15 @@ export function extractDshToken(raw) {
116
120
  return value;
117
121
  try {
118
122
  const url = new URL(value.startsWith('?') ? `http://dsh.invalid/${value}` : value);
119
- return url.searchParams.get('token') ?? value;
123
+ const token = url.searchParams.get('token');
124
+ if (token)
125
+ return token;
126
+ // 带 scheme 却没有 token 参数的输入是误贴的 host URL(如
127
+ // `http://127.0.0.1:3080`):整串不是合法 token,参与握手只会得到
128
+ // 指向性缺失的 401。返回空串让上游走「token 缺失」的诊断路径。
129
+ if (value.includes('://'))
130
+ return '';
131
+ return value;
120
132
  }
121
133
  catch {
122
134
  return value;
@@ -540,7 +552,20 @@ export class DshClient {
540
552
  * Fallback probe: the newest durable event's type tells whether a turn is open.
541
553
  *
542
554
  * 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
543
- * 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
555
+ * 结算)、`turn/end` 都不算,判不出来就报告 `unknown`。
556
+ *
557
+ * 落盘顺序已从 DSH 源码验证(packages/core/agent-loop:agent.ts 的 turn()/
558
+ * step() + inbox.ts 的 mutate()):prompt 提交只落 `agent/inbox/spliced`,
559
+ * `turn/start` 由 turn() 在认领前 append,`user/message` 在 step() 认领时才
560
+ * append——顺序是 spliced → turn/start → user/message。两个边界:
561
+ *
562
+ * - 尾部 `agent/inbox/spliced` → closed 是**对的**:turn 尚未打开,cancel
563
+ * 空闲 session 无害,随后 withdraw 探测会撤走排队 prompt;
564
+ * - 尾部 `user/message` → **unknown**:step() 里 user/message append 后要等
565
+ * 一整个 LLM 往返才有下一条 durable 事件(assistant 流帧是 process-local
566
+ * 的),这个秒级窗口内 turn 已打开;且压缩 checkpoint 也以 turn:null 落
567
+ * user/message——两种解释并存时只能报 unknown(stop 按 unconfirmed 处理,
568
+ * 不假报已停止)。此前报 closed 会让一个 LLM 往返宽的窗口假报干净停止。
544
569
  */
545
570
  async probeOpenTurnByEvents(sessionId, opts) {
546
571
  try {
@@ -555,6 +580,8 @@ export class DshClient {
555
580
  const last = value.records.at(-1)?.event;
556
581
  if (last === undefined)
557
582
  return 'closed';
583
+ if (last.type === 'user/message')
584
+ return 'unknown';
558
585
  return last.type === 'turn/start' || IN_TURN_EVENT_TYPES.has(last.type) ? 'open' : 'closed';
559
586
  }
560
587
  catch (err) {
@@ -568,140 +595,15 @@ export class DshClient {
568
595
  * close, error, or `signal` abort.
569
596
  */
570
597
  async *muxStream(endpoint, payload, signal) {
571
- const cookie = await this.authCookie();
572
- const socket = this.webSocketImpl(`${this.baseUrl.replace(/^http/, 'ws')}/api/remote.mux`, cookie ? { headers: { cookie } } : undefined);
573
- const streamId = `lr-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
574
- const queue = [];
575
- let waiter = null;
576
- let ended = false;
577
- let failure;
578
- const settle = (frame) => {
579
- if (waiter) {
580
- const resolve = waiter;
581
- waiter = null;
582
- resolve(frame);
583
- return;
584
- }
585
- if (frame)
586
- queue.push(frame);
587
- else
588
- ended = true;
589
- };
590
- socket.onopen = () => {
591
- if (ended)
592
- return;
593
- try {
594
- socket.send(JSON.stringify({
595
- type: 'open',
596
- streamId,
597
- endpoint,
598
- payload,
599
- }));
600
- }
601
- catch (err) {
602
- failure = new DshError('transport', `DSH remote mux send error: ${err.message}`);
603
- ended = true;
604
- settle(undefined);
605
- }
606
- };
607
- socket.onmessage = (event) => {
608
- if (ended)
609
- return;
610
- const text = typeof event.data === 'string'
611
- ? event.data
612
- : Buffer.isBuffer(event.data)
613
- ? event.data.toString('utf8')
614
- : String(event.data);
615
- let message;
616
- try {
617
- message = JSON.parse(text);
618
- }
619
- catch {
620
- getLogger().warn('[dsh] dropped malformed remote mux frame');
621
- return;
622
- }
623
- if (message.streamId !== streamId)
624
- return;
625
- if (message.type === 'item' && message.value) {
626
- settle(message.value);
627
- return;
628
- }
629
- if (message.type === 'end') {
630
- ended = true;
631
- settle(undefined);
632
- return;
633
- }
634
- if (message.type === 'error') {
635
- failure = new DshError(message.error?.code ?? 'stream', message.error?.message ?? 'DSH remote stream failed', message.error?.details ?? {});
636
- ended = true;
637
- settle(undefined);
638
- }
639
- };
640
- socket.onerror = () => {
641
- if (ended)
642
- return;
643
- failure = new DshError('transport', 'DSH remote mux WebSocket error');
644
- ended = true;
645
- settle(undefined);
646
- };
647
- socket.onunexpectedresponse = (event) => {
648
- if (ended)
649
- return;
650
- const status = event.statusCode;
651
- failure = new DshError('http', `DSH remote mux WebSocket HTTP ${status === undefined ? 'error' : status}`, status === undefined ? {} : { status });
652
- ended = true;
653
- settle(undefined);
654
- };
655
- socket.onclose = () => {
656
- if (ended)
657
- return;
658
- ended = true;
659
- settle(undefined);
660
- };
661
- const onAbort = () => {
662
- if (ended)
663
- return;
664
- ended = true;
665
- settle(undefined);
666
- try {
667
- socket.close();
668
- }
669
- catch {
670
- /* ignore close error */
671
- }
672
- };
673
- signal.addEventListener('abort', onAbort, { once: true });
674
- const next = () => new Promise((resolve) => {
675
- if (queue.length > 0) {
676
- resolve(queue.shift());
677
- return;
678
- }
679
- if (ended) {
680
- resolve(undefined);
681
- return;
682
- }
683
- waiter = resolve;
598
+ yield* openMuxStream({
599
+ baseUrl: this.baseUrl,
600
+ endpoint,
601
+ payload,
602
+ signal,
603
+ requestTimeoutMs: this.requestTimeoutMs,
604
+ authCookie: () => this.authCookie(),
605
+ webSocketFactory: this.webSocketImpl,
684
606
  });
685
- try {
686
- while (!signal.aborted) {
687
- const frame = await next();
688
- if (frame === undefined) {
689
- if (failure && !signal.aborted)
690
- throw failure;
691
- return;
692
- }
693
- yield frame;
694
- }
695
- }
696
- finally {
697
- signal.removeEventListener('abort', onAbort);
698
- try {
699
- socket.close();
700
- }
701
- catch {
702
- /* ignore close error */
703
- }
704
- }
705
607
  }
706
608
  /** One logical `session/follow` stream. */
707
609
  async *followStream(sessionId, signal) {
@@ -734,6 +636,12 @@ export class DshClient {
734
636
  const frame = raw;
735
637
  if (frame.type === 'ready') {
736
638
  authRefreshAttempted = false;
639
+ // 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 +
640
+ // 网络抖动累计掉线超过 3 次后,旧实现会让 $events 永久 unavailable
641
+ // ——已渲染的提问卡持有过期 clientId(点答案必失败)、Host 的 cancel
642
+ // 帧也收不到,违反「同一 waterfall 在 $events 重连后会被重投」的
643
+ // 不变量。预算语义是「单次断连窗口内的连续失败上限」,不是终身配额。
644
+ reconnectAttempts = 0;
737
645
  sawReady = true;
738
646
  streamClientId = frame.clientId;
739
647
  yield { kind: 'ready', clientId: streamClientId };
@@ -904,9 +812,10 @@ export class DshClient {
904
812
  return;
905
813
  authRefreshAttempted = false;
906
814
  if (frame.type === 'snapshot') {
907
- for (const record of frame.records ?? []) {
908
- const ev = record.event;
909
- if (!ev || ev.seq <= state.lastSeq)
815
+ // 快照可能只覆盖尾部窗口(见 replaySnapshotGap):先补缺口再回放,
816
+ // 回放循环与原逻辑逐条相同(去重 / admit / turn/end 早退)。
817
+ for (const ev of await this.replaySnapshotGap(sessionId, signal, state.lastSeq, frame)) {
818
+ if (ev.seq <= state.lastSeq)
910
819
  continue;
911
820
  state.lastSeq = ev.seq;
912
821
  if (!admit(ev))
@@ -963,6 +872,63 @@ export class DshClient {
963
872
  await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
964
873
  }
965
874
  }
875
+ /**
876
+ * 快照缺口回填(经 DSH 源码验证后实现)。
877
+ *
878
+ * follow 开场快照按**消息**截取:host 的 paginate 只数 user/assistant
879
+ * message(工具事件随所在消息组搭车进快照,不占预算),`maxMessages` 内
880
+ * 装不下的更早前缀被截掉并以 `hasMore: true` 标记。断连期间积压超过
881
+ * FOLLOW_MAX_MESSAGES 条消息时,`lastSeq` 与快照起点之间的缺口若不补,
882
+ * 这些事件对本次 follow 永久缺失(卡片丢中段工具行;seq 去重只防重放
883
+ * 不补缺)。`session/page` 的 `throughSeq` 游标正是服务端为此设计的回填
884
+ * 通道——从缺口尾部向前翻页直到盖住 lastSeq、翻到日志头或达到页数上限。
885
+ *
886
+ * best-effort:翻页失败保留已取回的部分(缺口剩余与旧行为一致地丢失,
887
+ * 但只发生在超出 BACKFILL_MAX_PAGES×PAGE_MESSAGES 条消息的极端断连)。
888
+ * 返回 seq 升序的完整回放序列(回填 + 快照记录;与 lastSeq 重叠的条目
889
+ * 由回放循环的 seq 去重兜住)。
890
+ */
891
+ async replaySnapshotGap(sessionId, signal, lastSeq, frame) {
892
+ const records = (frame.records ?? [])
893
+ .map((record) => record.event)
894
+ .filter((ev) => !!ev);
895
+ if (frame.hasMore !== true || signal.aborted)
896
+ return records;
897
+ const firstSeq = records[0]?.seq;
898
+ const gapEnd = firstSeq !== undefined ? firstSeq - 1 : frame.cursor;
899
+ if (gapEnd <= lastSeq)
900
+ return records;
901
+ const pages = [];
902
+ try {
903
+ let throughSeq = gapEnd;
904
+ for (let page = 0; page < FOLLOW_GAP_BACKFILL_MAX_PAGES; page++) {
905
+ const value = await this.page(sessionId, throughSeq, FOLLOW_GAP_BACKFILL_PAGE_MESSAGES, {
906
+ signal,
907
+ });
908
+ const events = (value.records ?? [])
909
+ .map((record) => record.event)
910
+ .filter((ev) => !!ev);
911
+ if (events.length > 0)
912
+ pages.push(events);
913
+ const oldest = events[0]?.seq;
914
+ // hasMore=false(日志头)或本页已盖住 lastSeq:缺口补齐,停。
915
+ if (value.hasMore !== true || oldest === undefined || oldest <= lastSeq + 1)
916
+ break;
917
+ throughSeq = oldest - 1;
918
+ }
919
+ }
920
+ catch (err) {
921
+ getLogger().warn(`[dsh] follow snapshot gap backfill incomplete sessionId=${sessionId}: ${err.message}`);
922
+ }
923
+ // pages 是「从新到旧」收集的;倒序拼接得到 seq 升序回放。
924
+ const backfill = [];
925
+ for (let index = pages.length - 1; index >= 0; index--)
926
+ backfill.push(...pages[index]);
927
+ if (backfill.length > 0) {
928
+ getLogger().info(`[dsh] backfilled ${backfill.length} events before follow snapshot sessionId=${sessionId} gapEnd=${gapEnd} lastSeq=${lastSeq}`);
929
+ }
930
+ return [...backfill, ...records];
931
+ }
966
932
  /**
967
933
  * Host-wide live projection stream (`session/control`).
968
934
  *
@@ -28,8 +28,6 @@ interface SpawnedDaemon {
28
28
  failure(): Error | undefined;
29
29
  exited(): boolean;
30
30
  }
31
- /** Resolve the same Harness home DSH resolves: configured, env, then ~/.dsh. */
32
- export declare function resolveDshHome(configured?: string, env?: NodeJS.ProcessEnv): string;
33
31
  /** True only for the loopback HTTP address this process may start and probe. */
34
32
  export declare function isLoopbackHttpUrl(value: string): boolean;
35
33
  /**
@@ -12,10 +12,10 @@
12
12
  */
13
13
  import fs from 'node:fs';
14
14
  import fsp from 'node:fs/promises';
15
- import os from 'node:os';
16
15
  import path from 'node:path';
17
16
  import { DEFAULT_DSH_HOST } from '../../config/index.js';
18
17
  import { mergeProcessEnv, spawnProcess } from '../../platform/spawn.js';
18
+ import { resolveDshHome } from './home.js';
19
19
  const DEFAULT_START_TIMEOUT_MS = 30_000;
20
20
  const READY_POLL_MS = 100;
21
21
  /** User-facing daemon failure; the bridge renders `message` in the chat card. */
@@ -30,11 +30,6 @@ export class DshDaemonError extends Error {
30
30
  function stripTrailingSlash(value) {
31
31
  return value.replace(/\/+$/, '');
32
32
  }
33
- /** Resolve the same Harness home DSH resolves: configured, env, then ~/.dsh. */
34
- export function resolveDshHome(configured, env = process.env) {
35
- const value = configured?.trim() || env.DSH_HOME?.trim();
36
- return path.resolve(value && value.length > 0 ? value : path.join(os.homedir(), '.dsh'));
37
- }
38
33
  /** True only for the loopback HTTP address this process may start and probe. */
39
34
  export function isLoopbackHttpUrl(value) {
40
35
  try {
@@ -0,0 +1,5 @@
1
+ export declare class DshError extends Error {
2
+ readonly code: string;
3
+ readonly details: Record<string, unknown>;
4
+ constructor(code: string, message: string, details?: Record<string, unknown>);
5
+ }
@@ -0,0 +1,11 @@
1
+ export class DshError extends Error {
2
+ code;
3
+ details;
4
+ constructor(code, message, details = {}) {
5
+ super(message);
6
+ this.name = 'DshError';
7
+ this.code = code;
8
+ this.details = details;
9
+ }
10
+ }
11
+ //# sourceMappingURL=errors.js.map
@@ -12,8 +12,6 @@
12
12
  * curl transport the session reader already uses: bounded by `timeoutMs` and
13
13
  * never throws.
14
14
  */
15
- /** Upper bound on the exit-path cancel:退出不能被一个卡住的 host 拖住。 */
16
- export declare const EXIT_CANCEL_TIMEOUT_MS = 2000;
17
15
  export interface SyncCancelOptions {
18
16
  baseUrl: string;
19
17
  sessionId: string;
@@ -12,12 +12,15 @@
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';
18
21
  import { DSH_COOKIE_LIFETIME_MS, mintDshBrowserCookie, readDshBrowserSecretSync, resolveDshCredentialsFile, } from './auth.js';
19
22
  /** Upper bound on the exit-path cancel:退出不能被一个卡住的 host 拖住。 */
20
- export const EXIT_CANCEL_TIMEOUT_MS = 2_000;
23
+ const EXIT_CANCEL_TIMEOUT_MS = 2_000;
21
24
  /**
22
25
  * Fire one `session/cancel` synchronously. Never throws: a failed cancel must
23
26
  * not block the exit path or mask the real exit reason.
@@ -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
@@ -0,0 +1,2 @@
1
+ /** Resolve the same DSH home DSH itself uses: configured, env, then ~/.dsh. */
2
+ export declare function resolveDshHome(configured?: string, env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,8 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /** Resolve the same DSH home DSH itself uses: configured, env, then ~/.dsh. */
4
+ export function resolveDshHome(configured, env = process.env) {
5
+ const value = configured?.trim() || env.DSH_HOME?.trim();
6
+ return path.resolve(value && value.length > 0 ? value : path.join(os.homedir(), '.dsh'));
7
+ }
8
+ //# sourceMappingURL=home.js.map
@@ -58,5 +58,3 @@ export interface DshInflightReconcileResult {
58
58
  * entries are left in the file so a later startup can retry.
59
59
  */
60
60
  export declare function reconcileInflightDshRuns(opts: DshInflightReconcileOptions): Promise<DshInflightReconcileResult>;
61
- /** One short owner-facing notice; session ids are truncated, not paths. */
62
- export declare function formatInflightWarning(entries: DshInflightEntry[]): string;
@@ -126,7 +126,7 @@ export async function reconcileInflightDshRuns(opts) {
126
126
  return { removed, open: open.length, skipped: false };
127
127
  }
128
128
  /** One short owner-facing notice; session ids are truncated, not paths. */
129
- export function formatInflightWarning(entries) {
129
+ function formatInflightWarning(entries) {
130
130
  const lines = entries.slice(0, 3).map((entry) => {
131
131
  const id = entry.sessionId.length > 8 ? entry.sessionId.slice(0, 8) : entry.sessionId;
132
132
  return `- session ${id}(${entry.cwd ?? '未知目录'})`;
@@ -0,0 +1,26 @@
1
+ interface MuxSocket {
2
+ onopen: ((event: unknown) => void) | null;
3
+ onmessage: ((event: {
4
+ data: unknown;
5
+ }) => void) | null;
6
+ onerror: ((event: unknown) => void) | null;
7
+ onclose: ((event: unknown) => void) | null;
8
+ onunexpectedresponse?: ((event: {
9
+ statusCode?: number;
10
+ }) => void) | null;
11
+ send(data: string): void;
12
+ close(): void;
13
+ }
14
+ export interface OpenMuxStreamOptions {
15
+ baseUrl: string;
16
+ endpoint: string;
17
+ payload: unknown;
18
+ signal: AbortSignal;
19
+ requestTimeoutMs: number;
20
+ authCookie(): Promise<string | undefined>;
21
+ webSocketFactory(url: string, options?: {
22
+ headers?: Record<string, string>;
23
+ }): MuxSocket;
24
+ }
25
+ export declare function openMuxStream(opts: OpenMuxStreamOptions): AsyncGenerator<unknown>;
26
+ export {};