lark-remote 0.5.19 → 0.5.21

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 (136) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/bridge/approval-actions.d.ts +1 -1
  3. package/dist/bridge/approval-actions.js +1 -1
  4. package/dist/bridge/bash-run.d.ts +71 -0
  5. package/dist/bridge/bash-run.js +217 -0
  6. package/dist/bridge/compact.d.ts +155 -0
  7. package/dist/bridge/compact.js +450 -0
  8. package/dist/bridge/final-usage.js +4 -4
  9. package/dist/bridge/inbound-media.js +72 -46
  10. package/dist/bridge/index.d.ts +106 -153
  11. package/dist/bridge/index.js +400 -1000
  12. package/dist/bridge/job-watch.d.ts +68 -0
  13. package/dist/bridge/job-watch.js +146 -0
  14. package/dist/bridge/queue-manager.d.ts +3 -3
  15. package/dist/bridge/queue-manager.js +10 -29
  16. package/dist/card/approval-render.d.ts +2 -2
  17. package/dist/card/approval-render.js +141 -130
  18. package/dist/card/card-budget.js +123 -98
  19. package/dist/card/card-helpers.d.ts +3 -3
  20. package/dist/card/card-helpers.js +6 -6
  21. package/dist/card/final-answer-file.js +1 -1
  22. package/dist/card/run-renderer.d.ts +3 -3
  23. package/dist/card/run-renderer.js +114 -159
  24. package/dist/card/run-state.d.ts +1 -15
  25. package/dist/card/run-state.js +358 -292
  26. package/dist/card/tool-render.d.ts +1 -1
  27. package/dist/card/tool-render.js +21 -17
  28. package/dist/card/usage-format.d.ts +22 -7
  29. package/dist/card/usage-format.js +119 -100
  30. package/dist/cli.js +15 -3
  31. package/dist/clone.js +2 -2
  32. package/dist/common/text-width.js +36 -24
  33. package/dist/common/tool-summary.js +132 -90
  34. package/dist/config/codex-config.js +130 -106
  35. package/dist/config/index.d.ts +16 -2
  36. package/dist/config/index.js +23 -8
  37. package/dist/connector/index.js +6 -6
  38. package/dist/error-classification.js +3 -2
  39. package/dist/inbound/placeholder.d.ts +1 -1
  40. package/dist/inbound/placeholder.js +1 -1
  41. package/dist/index.js +387 -281
  42. package/dist/logger/index.js +6 -6
  43. package/dist/message-handlers.d.ts +0 -5
  44. package/dist/message-handlers.js +254 -237
  45. package/dist/model-catalog.d.ts +2 -2
  46. package/dist/model-catalog.js +3 -8
  47. package/dist/platform/identity.js +1 -1
  48. package/dist/platform/path.d.ts +1 -1
  49. package/dist/platform/path.js +1 -1
  50. package/dist/platform/sleep-blocker.js +1 -1
  51. package/dist/platform/terminator-win32.js +2 -1
  52. package/dist/router/commands/ls.d.ts +3 -0
  53. package/dist/router/commands/ls.js +256 -175
  54. package/dist/router/commands/order.js +184 -159
  55. package/dist/router/commands/resume.js +190 -152
  56. package/dist/router/commands/ws.js +219 -174
  57. package/dist/router/config/dsh.d.ts +4 -8
  58. package/dist/router/config/dsh.js +3 -10
  59. package/dist/router/config/kimi.js +1 -1
  60. package/dist/router/config/types.d.ts +1 -1
  61. package/dist/router/config-diff.js +1 -1
  62. package/dist/router/index.d.ts +56 -11
  63. package/dist/router/index.js +401 -391
  64. package/dist/runner/bash/runner.js +2 -4
  65. package/dist/runner/claude/session.d.ts +1 -1
  66. package/dist/runner/claude/session.js +6 -4
  67. package/dist/runner/codex/app-server/runner.d.ts +9 -2
  68. package/dist/runner/codex/app-server/runner.js +30 -25
  69. package/dist/runner/codex/app-server/translator.d.ts +2 -5
  70. package/dist/runner/codex/app-server/translator.js +291 -238
  71. package/dist/runner/common/acp/acp-approval.d.ts +1 -1
  72. package/dist/runner/common/acp/acp-approval.js +1 -1
  73. package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
  74. package/dist/runner/common/acp/base-acp-runner.js +26 -65
  75. package/dist/runner/common/acp/base-translator.d.ts +12 -2
  76. package/dist/runner/common/acp/base-translator.js +50 -26
  77. package/dist/runner/common/acp/protocol-types.d.ts +1 -1
  78. package/dist/runner/common/acp/protocol-types.js +2 -2
  79. package/dist/runner/common/connection-based-runner.d.ts +32 -1
  80. package/dist/runner/common/connection-based-runner.js +32 -2
  81. package/dist/runner/common/jsonl-stream.js +154 -187
  82. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  83. package/dist/runner/common/jsonrpc/client.js +43 -12
  84. package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
  85. package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
  86. package/dist/runner/common/spawning-runner.d.ts +6 -1
  87. package/dist/runner/common/spawning-runner.js +9 -4
  88. package/dist/runner/common/turn-started.d.ts +8 -0
  89. package/dist/runner/common/turn-started.js +11 -0
  90. package/dist/runner/dsh/approval.js +84 -51
  91. package/dist/runner/dsh/catalog-probe.d.ts +17 -0
  92. package/dist/runner/dsh/catalog-probe.js +10 -0
  93. package/dist/runner/dsh/client.d.ts +7 -11
  94. package/dist/runner/dsh/client.js +356 -289
  95. package/dist/runner/dsh/exit-cancel.d.ts +2 -2
  96. package/dist/runner/dsh/exit-cancel.js +2 -2
  97. package/dist/runner/dsh/inflight.d.ts +1 -1
  98. package/dist/runner/dsh/inflight.js +1 -1
  99. package/dist/runner/dsh/mux-stream.js +28 -8
  100. package/dist/runner/dsh/runner.d.ts +44 -4
  101. package/dist/runner/dsh/runner.js +296 -199
  102. package/dist/runner/dsh/translator.d.ts +1 -31
  103. package/dist/runner/dsh/translator.js +180 -178
  104. package/dist/runner/index.d.ts +1 -1
  105. package/dist/runner/kimi/acp/runner.d.ts +1 -2
  106. package/dist/runner/kimi/acp/runner.js +82 -46
  107. package/dist/runner/kimi/acp/translator.d.ts +1 -1
  108. package/dist/runner/kimi/acp/translator.js +3 -4
  109. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  110. package/dist/runner/opencode/acp/runner.d.ts +2 -2
  111. package/dist/runner/opencode/acp/runner.js +4 -4
  112. package/dist/runner/pi/rpc/client.js +4 -13
  113. package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
  114. package/dist/runner/pi/rpc/protocol-types.js +1 -1
  115. package/dist/runner/pi/rpc/runner.d.ts +3 -0
  116. package/dist/runner/pi/rpc/runner.js +27 -42
  117. package/dist/runner/pi/rpc/translator.js +61 -60
  118. package/dist/runner/types.d.ts +2 -2
  119. package/dist/runner/types.js +1 -1
  120. package/dist/session/claude/sessions.d.ts +45 -0
  121. package/dist/session/claude/sessions.js +160 -122
  122. package/dist/session/codex/rollout-reader.js +181 -182
  123. package/dist/session/common/throw-session-list-error.d.ts +10 -0
  124. package/dist/session/common/throw-session-list-error.js +16 -0
  125. package/dist/session/dsh/sessions.js +291 -235
  126. package/dist/session/kimi/sessions.d.ts +40 -8
  127. package/dist/session/kimi/sessions.js +373 -301
  128. package/dist/session/opencode/sessions.d.ts +142 -1
  129. package/dist/session/opencode/sessions.js +378 -321
  130. package/dist/session/pi/sessions.js +4 -5
  131. package/dist/session/session-store.d.ts +9 -1
  132. package/dist/session/session-store.js +99 -99
  133. package/dist/update/version-check.js +2 -1
  134. package/dist/workspace/index.d.ts +1 -1
  135. package/dist/workspace/index.js +1 -1
  136. package/package.json +1 -1
@@ -44,6 +44,49 @@ function isDshAuthError(error) {
44
44
  error.code === 'http' &&
45
45
  (error.details.status === 401 || error.details.status === 403));
46
46
  }
47
+ /** 组合 abort/timeout signal 并生成本次 unary 的 rpcId(循环外一次算好)。 */
48
+ function prepareUnary(opts, requestTimeoutMs) {
49
+ const timeoutMs = opts.timeoutMs ?? requestTimeoutMs;
50
+ // 0/负数 = 不设超时(兼容「长请求不取消」的配置),其余走 AbortSignal.timeout。
51
+ const timeoutSignal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
52
+ const signal = opts.signal && timeoutSignal
53
+ ? AbortSignal.any([opts.signal, timeoutSignal])
54
+ : (opts.signal ?? timeoutSignal);
55
+ const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
56
+ return { signal, timeoutSignal, timeoutMs, rpcId };
57
+ }
58
+ /** Distinguish "run stopped" (abort) from "server did not answer" (timeout). */
59
+ function abortErrorOf(method, err, runSignal, timeoutSignal, timeoutMs) {
60
+ if (runSignal?.aborted) {
61
+ return new DshError('aborted', `DSH unary ${method} aborted`, { cause: err });
62
+ }
63
+ if (timeoutSignal?.aborted) {
64
+ return new DshError('timeout', `DSH unary ${method} timed out after ${timeoutMs}ms`, {
65
+ cause: err,
66
+ });
67
+ }
68
+ return new DshError('aborted', `DSH unary ${method} aborted: ${err.message}`);
69
+ }
70
+ /** 校验 ok 响应并取 `result.value`;非 ok / 非 JSON / result 失败都抛 DshError。 */
71
+ async function readUnaryBody(method, res) {
72
+ if (!res.ok) {
73
+ throw new DshError('http', `DSH HTTP ${res.status} for ${method}`, {
74
+ status: res.status,
75
+ });
76
+ }
77
+ let body;
78
+ try {
79
+ body = (await res.json());
80
+ }
81
+ catch (err) {
82
+ throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
83
+ }
84
+ if (body.result?.ok !== true) {
85
+ const err = body.result?.error;
86
+ throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
87
+ }
88
+ return body.result.value;
89
+ }
47
90
  /** Backoff between mux reconnects to avoid a hot loop on a dropping server. */
48
91
  const RECONNECT_BACKOFF_MS = 200;
49
92
  /**
@@ -112,6 +155,192 @@ function inboxItemsOf(values) {
112
155
  }
113
156
  return out;
114
157
  }
158
+ /** Hand one waterfall delivery back with `next` (another answerer owns it). */
159
+ function delegateWaterfall(client, clientId, eventId, signal) {
160
+ void client
161
+ .answerRemoteEvent(clientId, eventId, { kind: 'next' }, { signal })
162
+ .catch((err) => {
163
+ if (!signal.aborted) {
164
+ getLogger().warn(`[dsh] waterfall delegation failed eventId=${eventId}: ${err.message}`);
165
+ }
166
+ });
167
+ }
168
+ /** 一个 waterfall 帧 → 要 yield 的 items(含 question/request 委托)。 */
169
+ function waterfallToItems(client, clientId, frame, sessionId, signal) {
170
+ if (frame.event === 'user-questions/request' && frame.agentId === sessionId) {
171
+ if (isTimedQuestionRequest(frame.request)) {
172
+ // The timed wait owns its own deadline (and the Web UI renders the
173
+ // countdown); a card answer outside that window is lost.
174
+ getLogger().info(`[dsh] timed user-questions/request delegated to the Web UI eventId=${frame.eventId}`);
175
+ delegateWaterfall(client, clientId, frame.eventId, signal);
176
+ return { items: [], stop: false };
177
+ }
178
+ const questions = dshQuestionsOf(frame.request);
179
+ if (questions) {
180
+ return {
181
+ items: [{ kind: 'question', eventId: frame.eventId, clientId, questions }],
182
+ stop: false,
183
+ };
184
+ }
185
+ // The Feishu card cannot answer this batch safely (malformed, or duplicate
186
+ // texts — the coordinator keys answers by text).
187
+ getLogger().warn(`[dsh] user-questions/request not relayable; delegating eventId=${frame.eventId}`);
188
+ delegateWaterfall(client, clientId, frame.eventId, signal);
189
+ return { items: [], stop: false };
190
+ }
191
+ if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
192
+ // Every owner answerer receives the waterfall. A non-owner of the session
193
+ // must explicitly delegate, otherwise DSH waits.
194
+ delegateWaterfall(client, clientId, frame.eventId, signal);
195
+ return { items: [], stop: false };
196
+ }
197
+ const data = frame.request;
198
+ const toolName = typeof data.toolName === 'string' ? data.toolName : '';
199
+ const callId = typeof data.callId === 'string' ? data.callId : undefined;
200
+ const reason = typeof data.reason === 'string' ? data.reason : undefined;
201
+ return {
202
+ items: [
203
+ {
204
+ kind: 'request',
205
+ eventId: frame.eventId,
206
+ clientId,
207
+ toolName,
208
+ ...(callId ? { callId } : {}),
209
+ ...(reason ? { reason } : {}),
210
+ },
211
+ ],
212
+ stop: false,
213
+ };
214
+ }
215
+ /** 一个 `$events` 帧 → items(并按需更新 state)。 */
216
+ function processApprovalFrame(state, frame, sessionId, signal, client) {
217
+ if (frame.type === 'ready') {
218
+ state.authRefreshAttempted = false;
219
+ // 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 + 网络抖动
220
+ // 累计掉线超过 3 次后旧实现会让 $events 永久 unavailable。
221
+ state.reconnectAttempts = 0;
222
+ state.sawReady = true;
223
+ state.streamClientId = frame.clientId;
224
+ return { items: [{ kind: 'ready', clientId: frame.clientId }], stop: false };
225
+ }
226
+ if (frame.type === 'waterfall') {
227
+ if (!state.streamClientId) {
228
+ return {
229
+ items: [
230
+ { kind: 'unavailable', message: 'DSH approval stream sent a waterfall before ready' },
231
+ ],
232
+ stop: true,
233
+ };
234
+ }
235
+ return waterfallToItems(client, state.streamClientId, frame, sessionId, signal);
236
+ }
237
+ if (frame.type === 'cancel') {
238
+ return { items: [{ kind: 'cancelled', eventId: frame.eventId }], stop: false };
239
+ }
240
+ return { items: [], stop: false };
241
+ }
242
+ /** 把 `$events` 流异常分类成 4 种动作(不改变重试/超时语义)。 */
243
+ function classifyApprovalStreamError(err, state, signal) {
244
+ if (signal.aborted)
245
+ return { kind: 'return' };
246
+ if (err instanceof DshError &&
247
+ err.code === 'http' &&
248
+ err.details.status === 401 &&
249
+ !state.authRefreshAttempted) {
250
+ return { kind: 'refresh-auth' };
251
+ }
252
+ const message = err instanceof Error ? err.message : String(err);
253
+ const forbidden = (err instanceof DshError &&
254
+ (err.code === 'gateway/interactions-forbidden' ||
255
+ (err.code === 'http' && err.details.status === 403))) ||
256
+ message.includes('interactions-forbidden');
257
+ if (!forbidden && state.reconnectAttempts < 3) {
258
+ return { kind: 'reconnect', attempt: state.reconnectAttempts + 1 };
259
+ }
260
+ return { kind: 'unavailable', message };
261
+ }
262
+ /** 一次流正常结束(无异常)后的收尾决策。 */
263
+ function approvalStreamTail(state, signal) {
264
+ if (signal.aborted)
265
+ return 'aborted';
266
+ if (state.sawReady && state.reconnectAttempts < 3) {
267
+ state.reconnectAttempts += 1;
268
+ return 'reconnect';
269
+ }
270
+ return 'ended';
271
+ }
272
+ function reconnectBackoff() {
273
+ return new Promise((resolve) => setTimeout(resolve, 200));
274
+ }
275
+ /** Mark this run as the owner once its own prompt message is recorded. */
276
+ function ownsSessionEvent(state, ev) {
277
+ if (state.owns)
278
+ return true;
279
+ if (ev.type !== 'user/message')
280
+ return false;
281
+ const source = ev.data?.source;
282
+ if (source?.kind === 'user' && source.rpcId === state.promptRequestId) {
283
+ state.owns = true;
284
+ return true;
285
+ }
286
+ return false;
287
+ }
288
+ function admitSessionEvent(state, ev) {
289
+ if (ownsSessionEvent(state, ev))
290
+ return true;
291
+ if (!state.foreignTailReported) {
292
+ state.foreignTailReported = true;
293
+ getLogger().info(`[dsh] prompt ${state.promptRequestId ?? '(none)'} is queued behind a running turn for session ${state.sessionId}; holding the card open until its own turn starts`);
294
+ }
295
+ return false;
296
+ }
297
+ /** Translate one durable event into a stream item (approvals are surfaced
298
+ * separately; everything else is forwarded to the runner). */
299
+ function sessionEventItem(sessionId, event) {
300
+ if (event.type === 'approval/asked') {
301
+ const data = event.data;
302
+ if (!data.id)
303
+ return undefined;
304
+ return {
305
+ kind: 'approval',
306
+ sessionId,
307
+ approvalId: data.id,
308
+ toolName: data.toolName ?? '',
309
+ ...(data.callId ? { callId: data.callId } : {}),
310
+ ...(data.reason ? { reason: data.reason } : {}),
311
+ };
312
+ }
313
+ return { kind: 'event', sessionId, event };
314
+ }
315
+ /** 单条事件的 seq 去重 / admit / item / turn-end 早退;undefined 表示跳过。 */
316
+ function advanceSessionEvent(state, ev) {
317
+ if (ev.seq <= state.lastSeq)
318
+ return undefined;
319
+ state.lastSeq = ev.seq;
320
+ if (!admitSessionEvent(state, ev))
321
+ return undefined;
322
+ return { item: sessionEventItem(state.sessionId, ev), done: ev.type === 'turn/end' };
323
+ }
324
+ /** 一帧 follow → 待回放的 durable 事件(assistant-stream 不带 durable 事件)。 */
325
+ async function sessionFrameEvents(client, state, frame, signal) {
326
+ if (frame.type === 'snapshot') {
327
+ return client.replaySnapshotGap(state.sessionId, signal, state.lastSeq, frame);
328
+ }
329
+ if (frame.type === 'event')
330
+ return [frame.event];
331
+ return [];
332
+ }
333
+ /** 把 follow 流异常分类成 4 种动作(不改变重试/超时语义)。 */
334
+ function classifySessionStreamError(err, state, signal) {
335
+ if (signal.aborted)
336
+ return { kind: 'return' };
337
+ if (isDshAuthError(err)) {
338
+ if (!state.authRefreshAttempted)
339
+ return { kind: 'refresh-auth' };
340
+ return { kind: 'rethrow' };
341
+ }
342
+ return { kind: 'reconnect' };
343
+ }
115
344
  export class DshClient {
116
345
  baseUrl;
117
346
  token;
@@ -214,101 +443,83 @@ export class DshClient {
214
443
  * but never answers. Both are composed into the request's AbortSignal.
215
444
  */
216
445
  async unary(method, payload, opts = {}) {
217
- const _timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
218
- // 0/负数 = 不设超时(兼容「长请求不取消」的配置),其余走 AbortSignal.timeout。
219
- const timeoutSignal = _timeoutMs > 0 ? AbortSignal.timeout(_timeoutMs) : undefined;
220
- const signal = opts.signal && timeoutSignal
221
- ? AbortSignal.any([opts.signal, timeoutSignal])
222
- : (opts.signal ?? timeoutSignal);
223
- const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
446
+ const ctx = prepareUnary(opts, this.requestTimeoutMs);
224
447
  for (let attempt = 0; attempt < 2; attempt++) {
225
- if (opts.signal?.aborted) {
226
- throw new DshError('aborted', `DSH unary ${method} aborted before send`);
227
- }
228
- let cookie;
229
- try {
230
- cookie = await this.authCookie(attempt > 0, signal);
231
- }
232
- catch (err) {
233
- if (opts.signal?.aborted || signal?.aborted) {
234
- throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
235
- }
236
- // A token bootstrap can race a DSH restart; retry once with a fresh
237
- // credentials read before surfacing the deterministic auth failure.
238
- if (attempt === 0 &&
239
- err instanceof DshError &&
240
- err.code === 'http' &&
241
- err.details.status === 401) {
242
- continue;
243
- }
244
- throw err;
245
- }
246
- const headers = { 'content-type': 'application/json' };
247
- if (cookie)
248
- headers.cookie = cookie;
249
- let res;
250
- try {
251
- res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
252
- method: 'POST',
253
- headers,
254
- body: JSON.stringify({
255
- type: 'client-request',
256
- rpcId,
257
- method,
258
- payload: { args: dshArgs(method, payload) },
259
- }),
260
- ...(signal ? { signal } : {}),
261
- });
262
- }
263
- catch (err) {
264
- if (opts.signal?.aborted || signal?.aborted) {
265
- throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
266
- }
267
- throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
268
- }
269
- if (opts.signal?.aborted) {
270
- throw new DshError('aborted', `DSH unary ${method} aborted`);
271
- }
272
- if (res.status === 401 && attempt === 0) {
273
- this.cookiePromise = undefined;
274
- continue;
275
- }
276
- if (res.status === 401) {
277
- throw new DshError('http', `DSH HTTP 401 for ${method}: 认证失败;请确认 ${this.credentialsFile} 可读,` +
278
- '或在 config.yaml 的 agents.dsh.token 填入当前 dsh web 启动 token。' +
279
- '若凭据文件可读且格式正确,可能是 DSH 版本改变了 cookie 认证格式', { status: 401 });
280
- }
281
- if (!res.ok) {
282
- throw new DshError('http', `DSH HTTP ${res.status} for ${method}`, {
283
- status: res.status,
284
- });
285
- }
286
- let body;
287
- try {
288
- body = (await res.json());
289
- }
290
- catch (err) {
291
- throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
292
- }
293
- if (body.result?.ok !== true) {
294
- const err = body.result?.error;
295
- throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
296
- }
297
- return body.result.value;
448
+ const result = await this.attemptUnary(method, payload, opts, ctx, attempt);
449
+ if (result.kind === 'done')
450
+ return result.value;
298
451
  }
299
452
  throw new DshError('auth', `DSH ${method} authentication failed after refresh`);
300
453
  }
301
- /** Distinguish "run stopped" (abort) from "server did not answer" (timeout). */
302
- abortError(method, err, runSignal, timeoutSignal, timeoutMs) {
303
- if (runSignal?.aborted) {
304
- return new DshError('aborted', `DSH unary ${method} aborted`, { cause: err });
454
+ /** One unary POST attempt: obtain cookie, then post; both stages may ask to
455
+ * retry once (401 → re-mint cookie); all other failures throw DshError. */
456
+ async attemptUnary(method, payload, opts, ctx, attempt) {
457
+ const cookieResult = await this.obtainUnaryCookie(method, opts, ctx, attempt);
458
+ if (cookieResult.kind === 'retry')
459
+ return { kind: 'retry' };
460
+ return this.postUnary(method, payload, opts, ctx, cookieResult.cookie, attempt);
461
+ }
462
+ async obtainUnaryCookie(method, opts, ctx, attempt) {
463
+ if (opts.signal?.aborted) {
464
+ throw new DshError('aborted', `DSH unary ${method} aborted before send`);
465
+ }
466
+ try {
467
+ return { kind: 'cookie', cookie: await this.authCookie(attempt > 0, ctx.signal) };
305
468
  }
306
- if (timeoutSignal?.aborted) {
307
- return new DshError('timeout', `DSH unary ${method} timed out after ${timeoutMs}ms`, {
308
- cause: err,
469
+ catch (err) {
470
+ if (opts.signal?.aborted || ctx.signal?.aborted) {
471
+ throw abortErrorOf(method, err, opts.signal, ctx.timeoutSignal, ctx.timeoutMs);
472
+ }
473
+ // A token bootstrap can race a DSH restart; retry once with a fresh
474
+ // credentials read before surfacing the deterministic auth failure.
475
+ if (attempt === 0 &&
476
+ err instanceof DshError &&
477
+ err.code === 'http' &&
478
+ err.details.status === 401) {
479
+ getLogger().warn(`[dsh] 401 while authenticating ${method}, re-minting cookie once`);
480
+ return { kind: 'retry' };
481
+ }
482
+ throw err;
483
+ }
484
+ }
485
+ async postUnary(method, payload, opts, ctx, cookie, attempt) {
486
+ const headers = { 'content-type': 'application/json' };
487
+ if (cookie)
488
+ headers.cookie = cookie;
489
+ let res;
490
+ try {
491
+ res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
492
+ method: 'POST',
493
+ headers,
494
+ body: JSON.stringify({
495
+ type: 'client-request',
496
+ rpcId: ctx.rpcId,
497
+ method,
498
+ payload: { args: dshArgs(method, payload) },
499
+ }),
500
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
309
501
  });
310
502
  }
311
- return new DshError('aborted', `DSH unary ${method} aborted: ${err.message}`);
503
+ catch (err) {
504
+ if (opts.signal?.aborted || ctx.signal?.aborted) {
505
+ throw abortErrorOf(method, err, opts.signal, ctx.timeoutSignal, ctx.timeoutMs);
506
+ }
507
+ throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
508
+ }
509
+ if (opts.signal?.aborted) {
510
+ throw new DshError('aborted', `DSH unary ${method} aborted`);
511
+ }
512
+ if (res.status === 401 && attempt === 0) {
513
+ this.cookiePromise = undefined;
514
+ getLogger().warn(`[dsh] 401 for ${method}, re-minting cookie once`);
515
+ return { kind: 'retry' };
516
+ }
517
+ if (res.status === 401) {
518
+ throw new DshError('http', `DSH HTTP 401 for ${method}: 认证失败;请确认 ${this.credentialsFile} 可读,` +
519
+ '或在 config.yaml 的 agents.dsh.token 填入当前 dsh web 启动 token。' +
520
+ '若凭据文件可读且格式正确,可能是 DSH 版本改变了 cookie 认证格式', { status: 401 });
521
+ }
522
+ return { kind: 'done', value: await readUnaryBody(method, res) };
312
523
  }
313
524
  /** Create a session. Pass `agentPreset` to pin the session's preset (fixed at
314
525
  * creation). */
@@ -363,14 +574,6 @@ export class DshClient {
363
574
  async answerRemoteEvent(clientId, eventId, outcome, opts) {
364
575
  await this.unary('$events/result', { clientId, eventId, outcome }, opts);
365
576
  }
366
- /** Hand one waterfall delivery back with `next` (another answerer owns it). */
367
- delegateWaterfall(clientId, eventId, signal) {
368
- void this.answerRemoteEvent(clientId, eventId, { kind: 'next' }, { signal }).catch((err) => {
369
- if (!signal.aborted) {
370
- getLogger().warn(`[dsh] waterfall delegation failed eventId=${eventId}: ${err.message}`);
371
- }
372
- });
373
- }
374
577
  /**
375
578
  * Return the highest event `seq` currently persisted for the session.
376
579
  *
@@ -576,147 +779,60 @@ export class DshClient {
576
779
  * `next` so a different lark-remote workspace cannot park it.
577
780
  */
578
781
  async *approvalEvents(sessionId, signal) {
579
- let authRefreshAttempted = false;
580
- let reconnectAttempts = 0;
782
+ const state = {
783
+ authRefreshAttempted: false,
784
+ reconnectAttempts: 0,
785
+ sawReady: false,
786
+ };
581
787
  while (!signal.aborted) {
582
- let streamClientId;
583
- let sawReady = false;
788
+ // 每次重连重置本次连接的 ready 身份;auth 刷新与重连预算跨连接保留。
789
+ state.sawReady = false;
790
+ state.streamClientId = undefined;
584
791
  try {
585
792
  for await (const raw of this.muxStream('$events', { args: {} }, signal)) {
586
793
  if (signal.aborted)
587
794
  return;
588
- const frame = raw;
589
- if (frame.type === 'ready') {
590
- authRefreshAttempted = false;
591
- // 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 +
592
- // 网络抖动累计掉线超过 3 次后,旧实现会让 $events 永久 unavailable
593
- // ——已渲染的提问卡持有过期 clientId(点答案必失败)、Host 的 cancel
594
- // 帧也收不到,违反「同一 waterfall 在 $events 重连后会被重投」的
595
- // 不变量。预算语义是「单次断连窗口内的连续失败上限」,不是终身配额。
596
- reconnectAttempts = 0;
597
- sawReady = true;
598
- streamClientId = frame.clientId;
599
- yield { kind: 'ready', clientId: streamClientId };
600
- continue;
601
- }
602
- if (frame.type === 'waterfall') {
603
- if (!streamClientId) {
604
- yield {
605
- kind: 'unavailable',
606
- message: 'DSH approval stream sent a waterfall before ready',
607
- };
608
- return;
609
- }
610
- if (frame.event === 'user-questions/request' && frame.agentId === sessionId) {
611
- if (isTimedQuestionRequest(frame.request)) {
612
- // The timed wait owns its own deadline (and the Web UI renders
613
- // the countdown); a card answer outside that window is lost.
614
- getLogger().info(`[dsh] timed user-questions/request delegated to the Web UI eventId=${frame.eventId}`);
615
- this.delegateWaterfall(streamClientId, frame.eventId, signal);
616
- continue;
617
- }
618
- const questions = dshQuestionsOf(frame.request);
619
- if (questions) {
620
- yield {
621
- kind: 'question',
622
- eventId: frame.eventId,
623
- clientId: streamClientId,
624
- questions,
625
- };
626
- }
627
- else {
628
- // The Feishu card cannot answer this batch safely (malformed,
629
- // or duplicate texts — the coordinator keys answers by text).
630
- // Hand the one-shot answer back so the DSH Web UI stays in charge.
631
- getLogger().warn(`[dsh] user-questions/request not relayable; delegating eventId=${frame.eventId}`);
632
- this.delegateWaterfall(streamClientId, frame.eventId, signal);
633
- }
634
- continue;
635
- }
636
- if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
637
- // Every owner answerer receives the waterfall. A non-owner of
638
- // the session must explicitly delegate, otherwise DSH waits.
639
- this.delegateWaterfall(streamClientId, frame.eventId, signal);
640
- continue;
641
- }
642
- const data = frame.request;
643
- const toolName = typeof data.toolName === 'string' ? data.toolName : '';
644
- const callId = typeof data.callId === 'string' ? data.callId : undefined;
645
- const reason = typeof data.reason === 'string' ? data.reason : undefined;
646
- yield {
647
- kind: 'request',
648
- eventId: frame.eventId,
649
- clientId: streamClientId,
650
- toolName,
651
- ...(callId ? { callId } : {}),
652
- ...(reason ? { reason } : {}),
653
- };
654
- continue;
655
- }
656
- if (frame.type === 'cancel') {
657
- yield { kind: 'cancelled', eventId: frame.eventId };
658
- }
795
+ const result = processApprovalFrame(state, raw, sessionId, signal, this);
796
+ for (const item of result.items)
797
+ yield item;
798
+ if (result.stop)
799
+ return;
659
800
  }
660
801
  }
661
802
  catch (err) {
662
- if (signal.aborted)
803
+ const action = classifyApprovalStreamError(err, state, signal);
804
+ if (action.kind === 'return')
663
805
  return;
664
- if (err instanceof DshError &&
665
- err.code === 'http' &&
666
- err.details.status === 401 &&
667
- !authRefreshAttempted) {
668
- authRefreshAttempted = true;
806
+ if (action.kind === 'refresh-auth') {
807
+ state.authRefreshAttempted = true;
669
808
  this.cookiePromise = undefined;
670
809
  getLogger().warn(`[dsh] approval event stream authentication expired; refreshing credentials and reconnecting once`);
671
810
  continue;
672
811
  }
673
- const message = err instanceof Error ? err.message : String(err);
674
- const forbidden = (err instanceof DshError &&
675
- (err.code === 'gateway/interactions-forbidden' ||
676
- (err.code === 'http' && err.details.status === 403))) ||
677
- message.includes('interactions-forbidden');
678
- if (!forbidden && reconnectAttempts < 3) {
679
- reconnectAttempts += 1;
680
- getLogger().warn(`[dsh] approval event stream dropped (attempt ${reconnectAttempts}/3); reconnecting`);
681
- await new Promise((resolve) => setTimeout(resolve, 200));
812
+ if (action.kind === 'reconnect') {
813
+ state.reconnectAttempts = action.attempt;
814
+ getLogger().warn(`[dsh] approval event stream dropped (attempt ${action.attempt}/3); reconnecting`);
815
+ await reconnectBackoff();
682
816
  continue;
683
817
  }
684
- getLogger().warn(`[dsh] approval event stream unavailable: ${message}`);
685
- yield { kind: 'unavailable', message };
818
+ getLogger().warn(`[dsh] approval event stream unavailable: ${action.message}`);
819
+ yield { kind: 'unavailable', message: action.message };
686
820
  return;
687
821
  }
688
- if (!signal.aborted) {
689
- if (sawReady && reconnectAttempts < 3) {
690
- reconnectAttempts += 1;
691
- getLogger().warn(`[dsh] approval event stream ended (attempt ${reconnectAttempts}/3); reconnecting`);
692
- await new Promise((resolve) => setTimeout(resolve, 200));
693
- continue;
694
- }
695
- // A clean end before ready means the Host does not expose `$events`;
696
- // fall back to the Web UI prompt instead of retrying forever.
697
- yield { kind: 'unavailable', message: 'DSH approval event stream ended' };
822
+ const tail = approvalStreamTail(state, signal);
823
+ if (tail === 'aborted')
698
824
  return;
825
+ if (tail === 'reconnect') {
826
+ getLogger().warn(`[dsh] approval event stream ended (attempt ${state.reconnectAttempts}/3); reconnecting`);
827
+ await reconnectBackoff();
828
+ continue;
699
829
  }
830
+ // A clean end before ready means the Host does not expose `$events`;
831
+ // fall back to the Web UI prompt instead of retrying forever.
832
+ yield { kind: 'unavailable', message: 'DSH approval event stream ended' };
833
+ return;
700
834
  }
701
835
  }
702
- /** Translate one durable event into a stream item (approvals are surfaced
703
- * separately; everything else is forwarded to the runner). */
704
- eventItem(sessionId, event) {
705
- if (event.type === 'approval/asked') {
706
- const data = event.data;
707
- if (!data.id)
708
- return undefined;
709
- return {
710
- kind: 'approval',
711
- sessionId,
712
- approvalId: data.id,
713
- toolName: data.toolName ?? '',
714
- ...(data.callId ? { callId: data.callId } : {}),
715
- ...(data.reason ? { reason: data.reason } : {}),
716
- };
717
- }
718
- return { kind: 'event', sessionId, event };
719
- }
720
836
  /**
721
837
  * Per-session subscription: yields session events, assistant stream chunks
722
838
  * and approval notices for `sessionId`, auto-reconnecting on stream
@@ -731,92 +847,47 @@ export class DshClient {
731
847
  * keeps the card "running" until this run's turn actually finishes.
732
848
  */
733
849
  async *sessionEvents(sessionId, signal, initialSeq = -1, promptRequestId) {
734
- const state = { lastSeq: initialSeq, owns: promptRequestId === undefined };
735
- let foreignTailReported = false;
736
- /** Mark this run as the owner once its own prompt message is recorded. */
737
- const ownsEvent = (ev) => {
738
- if (state.owns)
739
- return true;
740
- if (ev.type !== 'user/message')
741
- return false;
742
- const source = ev.data
743
- ?.source;
744
- if (source?.kind === 'user' && source.rpcId === promptRequestId) {
745
- state.owns = true;
746
- return true;
747
- }
748
- return false;
749
- };
750
- const admit = (ev) => {
751
- if (ownsEvent(ev))
752
- return true;
753
- if (!foreignTailReported) {
754
- foreignTailReported = true;
755
- getLogger().info(`[dsh] prompt ${promptRequestId ?? '(none)'} is queued behind a running turn for session ${sessionId}; holding the card open until its own turn starts`);
756
- }
757
- return false;
850
+ const state = {
851
+ lastSeq: initialSeq,
852
+ owns: promptRequestId === undefined,
853
+ foreignTailReported: false,
854
+ sessionId,
855
+ promptRequestId,
856
+ authRefreshAttempted: false,
758
857
  };
759
- let authRefreshAttempted = false;
760
858
  while (!signal.aborted) {
761
859
  try {
762
860
  for await (const frame of this.followStream(sessionId, signal)) {
763
861
  if (signal.aborted)
764
862
  return;
765
- authRefreshAttempted = false;
766
- if (frame.type === 'snapshot') {
767
- // 快照可能只覆盖尾部窗口(见 replaySnapshotGap):先补缺口再回放,
768
- // 回放循环与原逻辑逐条相同(去重 / admit / turn/end 早退)。
769
- for (const ev of await this.replaySnapshotGap(sessionId, signal, state.lastSeq, frame)) {
770
- if (ev.seq <= state.lastSeq)
771
- continue;
772
- state.lastSeq = ev.seq;
773
- if (!admit(ev))
774
- continue;
775
- const item = this.eventItem(sessionId, ev);
776
- if (item)
777
- yield item;
778
- if (ev.type === 'turn/end')
779
- return;
780
- }
781
- continue;
782
- }
783
- if (frame.type === 'event') {
784
- const ev = frame.event;
785
- if (ev.seq <= state.lastSeq)
863
+ state.authRefreshAttempted = false;
864
+ for (const ev of await sessionFrameEvents(this, state, frame, signal)) {
865
+ const step = advanceSessionEvent(state, ev);
866
+ if (!step)
786
867
  continue;
787
- state.lastSeq = ev.seq;
788
- if (!admit(ev))
789
- continue;
790
- const item = this.eventItem(sessionId, ev);
791
- if (item)
792
- yield item;
793
- if (ev.type === 'turn/end')
868
+ if (step.item)
869
+ yield step.item;
870
+ if (step.done)
794
871
  return;
795
- continue;
796
872
  }
797
- if (frame.type === 'assistant-stream') {
873
+ if (frame.type === 'assistant-stream' && state.owns) {
798
874
  // Mid-turn deltas of the turn this prompt is queued behind.
799
- if (!state.owns)
800
- continue;
801
875
  yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
802
876
  }
803
877
  }
804
878
  }
805
879
  catch (err) {
806
- if (signal.aborted)
880
+ const action = classifySessionStreamError(err, state, signal);
881
+ if (action.kind === 'return')
807
882
  return;
808
- // A DSH restart invalidates the process-local v2 operator cookie. A
809
- // fresh credentials read mints a new v1 cookie, so retry once; a
810
- // second auth failure is deterministic and belongs on the run card.
811
- if (isDshAuthError(err)) {
812
- if (!authRefreshAttempted) {
813
- authRefreshAttempted = true;
814
- this.cookiePromise = undefined;
815
- getLogger().warn(`[dsh] remote mux authentication expired for session ${sessionId}; refreshing credentials and reconnecting once`);
816
- continue;
817
- }
818
- throw err;
883
+ if (action.kind === 'refresh-auth') {
884
+ state.authRefreshAttempted = true;
885
+ this.cookiePromise = undefined;
886
+ getLogger().warn(`[dsh] remote mux authentication expired for session ${sessionId}; refreshing credentials and reconnecting once`);
887
+ continue;
819
888
  }
889
+ if (action.kind === 'rethrow')
890
+ throw err;
820
891
  getLogger().warn(`[dsh] remote mux disconnected for session ${sessionId}: ${err.message}; reconnecting`);
821
892
  }
822
893
  if (signal.aborted)
@@ -978,10 +1049,6 @@ export class DshClient {
978
1049
  return false;
979
1050
  }
980
1051
  }
981
- /** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
982
- static usageOf(data) {
983
- return data.usage ?? undefined;
984
- }
985
1052
  }
986
1053
  /** Stable client-side prompt identity persisted on the accepted user message. */
987
1054
  export function newPromptRequestId() {