lark-remote 0.5.20 → 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 (55) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/bridge/inbound-media.js +71 -45
  3. package/dist/bridge/index.d.ts +9 -0
  4. package/dist/bridge/index.js +230 -203
  5. package/dist/card/approval-render.js +32 -33
  6. package/dist/card/card-budget.js +123 -98
  7. package/dist/card/run-renderer.js +82 -68
  8. package/dist/card/run-state.d.ts +0 -14
  9. package/dist/card/run-state.js +335 -207
  10. package/dist/card/tool-render.js +20 -16
  11. package/dist/card/usage-format.d.ts +12 -9
  12. package/dist/card/usage-format.js +111 -97
  13. package/dist/common/text-width.js +36 -24
  14. package/dist/common/tool-summary.js +132 -90
  15. package/dist/index.js +226 -190
  16. package/dist/message-handlers.js +253 -236
  17. package/dist/router/commands/ls.d.ts +3 -0
  18. package/dist/router/commands/ls.js +256 -175
  19. package/dist/router/commands/order.js +184 -159
  20. package/dist/router/commands/resume.js +189 -133
  21. package/dist/router/commands/ws.js +219 -174
  22. package/dist/router/index.js +192 -201
  23. package/dist/runner/bash/runner.js +2 -4
  24. package/dist/runner/codex/app-server/translator.d.ts +2 -0
  25. package/dist/runner/codex/app-server/translator.js +287 -221
  26. package/dist/runner/common/acp/base-translator.js +19 -17
  27. package/dist/runner/common/jsonl-stream.js +154 -187
  28. package/dist/runner/common/jsonrpc/client.d.ts +29 -0
  29. package/dist/runner/common/jsonrpc/client.js +43 -12
  30. package/dist/runner/common/spawning-runner.d.ts +5 -0
  31. package/dist/runner/common/spawning-runner.js +8 -3
  32. package/dist/runner/common/turn-started.d.ts +8 -0
  33. package/dist/runner/common/turn-started.js +11 -0
  34. package/dist/runner/dsh/approval.js +84 -51
  35. package/dist/runner/dsh/client.d.ts +7 -9
  36. package/dist/runner/dsh/client.js +356 -287
  37. package/dist/runner/dsh/mux-stream.js +28 -8
  38. package/dist/runner/dsh/runner.d.ts +4 -3
  39. package/dist/runner/dsh/runner.js +88 -64
  40. package/dist/runner/dsh/translator.d.ts +1 -31
  41. package/dist/runner/dsh/translator.js +180 -178
  42. package/dist/runner/kimi/acp/runner.js +81 -44
  43. package/dist/runner/opencode/acp/error-monitor.js +5 -5
  44. package/dist/runner/pi/rpc/client.js +4 -13
  45. package/dist/runner/pi/rpc/translator.js +61 -60
  46. package/dist/session/claude/sessions.js +113 -101
  47. package/dist/session/codex/rollout-reader.js +179 -180
  48. package/dist/session/dsh/sessions.js +281 -231
  49. package/dist/session/kimi/sessions.d.ts +0 -7
  50. package/dist/session/kimi/sessions.js +129 -109
  51. package/dist/session/opencode/sessions.d.ts +0 -11
  52. package/dist/session/opencode/sessions.js +161 -137
  53. package/dist/session/session-store.d.ts +8 -0
  54. package/dist/session/session-store.js +98 -98
  55. 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,103 +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
- getLogger().warn(`[dsh] 401 while authenticating ${method}, re-minting cookie once`);
243
- continue;
244
- }
245
- throw err;
246
- }
247
- const headers = { 'content-type': 'application/json' };
248
- if (cookie)
249
- headers.cookie = cookie;
250
- let res;
251
- try {
252
- res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
253
- method: 'POST',
254
- headers,
255
- body: JSON.stringify({
256
- type: 'client-request',
257
- rpcId,
258
- method,
259
- payload: { args: dshArgs(method, payload) },
260
- }),
261
- ...(signal ? { signal } : {}),
262
- });
263
- }
264
- catch (err) {
265
- if (opts.signal?.aborted || signal?.aborted) {
266
- throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
267
- }
268
- throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
269
- }
270
- if (opts.signal?.aborted) {
271
- throw new DshError('aborted', `DSH unary ${method} aborted`);
272
- }
273
- if (res.status === 401 && attempt === 0) {
274
- this.cookiePromise = undefined;
275
- getLogger().warn(`[dsh] 401 for ${method}, re-minting cookie once`);
276
- continue;
277
- }
278
- if (res.status === 401) {
279
- throw new DshError('http', `DSH HTTP 401 for ${method}: 认证失败;请确认 ${this.credentialsFile} 可读,` +
280
- '或在 config.yaml 的 agents.dsh.token 填入当前 dsh web 启动 token。' +
281
- '若凭据文件可读且格式正确,可能是 DSH 版本改变了 cookie 认证格式', { status: 401 });
282
- }
283
- if (!res.ok) {
284
- throw new DshError('http', `DSH HTTP ${res.status} for ${method}`, {
285
- status: res.status,
286
- });
287
- }
288
- let body;
289
- try {
290
- body = (await res.json());
291
- }
292
- catch (err) {
293
- throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
294
- }
295
- if (body.result?.ok !== true) {
296
- const err = body.result?.error;
297
- throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
298
- }
299
- return body.result.value;
448
+ const result = await this.attemptUnary(method, payload, opts, ctx, attempt);
449
+ if (result.kind === 'done')
450
+ return result.value;
300
451
  }
301
452
  throw new DshError('auth', `DSH ${method} authentication failed after refresh`);
302
453
  }
303
- /** Distinguish "run stopped" (abort) from "server did not answer" (timeout). */
304
- abortError(method, err, runSignal, timeoutSignal, timeoutMs) {
305
- if (runSignal?.aborted) {
306
- 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`);
307
465
  }
308
- if (timeoutSignal?.aborted) {
309
- return new DshError('timeout', `DSH unary ${method} timed out after ${timeoutMs}ms`, {
310
- cause: err,
466
+ try {
467
+ return { kind: 'cookie', cookie: await this.authCookie(attempt > 0, ctx.signal) };
468
+ }
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 } : {}),
311
501
  });
312
502
  }
313
- 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) };
314
523
  }
315
524
  /** Create a session. Pass `agentPreset` to pin the session's preset (fixed at
316
525
  * creation). */
@@ -365,14 +574,6 @@ export class DshClient {
365
574
  async answerRemoteEvent(clientId, eventId, outcome, opts) {
366
575
  await this.unary('$events/result', { clientId, eventId, outcome }, opts);
367
576
  }
368
- /** Hand one waterfall delivery back with `next` (another answerer owns it). */
369
- delegateWaterfall(clientId, eventId, signal) {
370
- void this.answerRemoteEvent(clientId, eventId, { kind: 'next' }, { signal }).catch((err) => {
371
- if (!signal.aborted) {
372
- getLogger().warn(`[dsh] waterfall delegation failed eventId=${eventId}: ${err.message}`);
373
- }
374
- });
375
- }
376
577
  /**
377
578
  * Return the highest event `seq` currently persisted for the session.
378
579
  *
@@ -578,147 +779,60 @@ export class DshClient {
578
779
  * `next` so a different lark-remote workspace cannot park it.
579
780
  */
580
781
  async *approvalEvents(sessionId, signal) {
581
- let authRefreshAttempted = false;
582
- let reconnectAttempts = 0;
782
+ const state = {
783
+ authRefreshAttempted: false,
784
+ reconnectAttempts: 0,
785
+ sawReady: false,
786
+ };
583
787
  while (!signal.aborted) {
584
- let streamClientId;
585
- let sawReady = false;
788
+ // 每次重连重置本次连接的 ready 身份;auth 刷新与重连预算跨连接保留。
789
+ state.sawReady = false;
790
+ state.streamClientId = undefined;
586
791
  try {
587
792
  for await (const raw of this.muxStream('$events', { args: {} }, signal)) {
588
793
  if (signal.aborted)
589
794
  return;
590
- const frame = raw;
591
- if (frame.type === 'ready') {
592
- authRefreshAttempted = false;
593
- // 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 +
594
- // 网络抖动累计掉线超过 3 次后,旧实现会让 $events 永久 unavailable
595
- // ——已渲染的提问卡持有过期 clientId(点答案必失败)、Host 的 cancel
596
- // 帧也收不到,违反「同一 waterfall 在 $events 重连后会被重投」的
597
- // 不变量。预算语义是「单次断连窗口内的连续失败上限」,不是终身配额。
598
- reconnectAttempts = 0;
599
- sawReady = true;
600
- streamClientId = frame.clientId;
601
- yield { kind: 'ready', clientId: streamClientId };
602
- continue;
603
- }
604
- if (frame.type === 'waterfall') {
605
- if (!streamClientId) {
606
- yield {
607
- kind: 'unavailable',
608
- message: 'DSH approval stream sent a waterfall before ready',
609
- };
610
- return;
611
- }
612
- if (frame.event === 'user-questions/request' && frame.agentId === sessionId) {
613
- if (isTimedQuestionRequest(frame.request)) {
614
- // The timed wait owns its own deadline (and the Web UI renders
615
- // the countdown); a card answer outside that window is lost.
616
- getLogger().info(`[dsh] timed user-questions/request delegated to the Web UI eventId=${frame.eventId}`);
617
- this.delegateWaterfall(streamClientId, frame.eventId, signal);
618
- continue;
619
- }
620
- const questions = dshQuestionsOf(frame.request);
621
- if (questions) {
622
- yield {
623
- kind: 'question',
624
- eventId: frame.eventId,
625
- clientId: streamClientId,
626
- questions,
627
- };
628
- }
629
- else {
630
- // The Feishu card cannot answer this batch safely (malformed,
631
- // or duplicate texts — the coordinator keys answers by text).
632
- // Hand the one-shot answer back so the DSH Web UI stays in charge.
633
- getLogger().warn(`[dsh] user-questions/request not relayable; delegating eventId=${frame.eventId}`);
634
- this.delegateWaterfall(streamClientId, frame.eventId, signal);
635
- }
636
- continue;
637
- }
638
- if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
639
- // Every owner answerer receives the waterfall. A non-owner of
640
- // the session must explicitly delegate, otherwise DSH waits.
641
- this.delegateWaterfall(streamClientId, frame.eventId, signal);
642
- continue;
643
- }
644
- const data = frame.request;
645
- const toolName = typeof data.toolName === 'string' ? data.toolName : '';
646
- const callId = typeof data.callId === 'string' ? data.callId : undefined;
647
- const reason = typeof data.reason === 'string' ? data.reason : undefined;
648
- yield {
649
- kind: 'request',
650
- eventId: frame.eventId,
651
- clientId: streamClientId,
652
- toolName,
653
- ...(callId ? { callId } : {}),
654
- ...(reason ? { reason } : {}),
655
- };
656
- continue;
657
- }
658
- if (frame.type === 'cancel') {
659
- yield { kind: 'cancelled', eventId: frame.eventId };
660
- }
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;
661
800
  }
662
801
  }
663
802
  catch (err) {
664
- if (signal.aborted)
803
+ const action = classifyApprovalStreamError(err, state, signal);
804
+ if (action.kind === 'return')
665
805
  return;
666
- if (err instanceof DshError &&
667
- err.code === 'http' &&
668
- err.details.status === 401 &&
669
- !authRefreshAttempted) {
670
- authRefreshAttempted = true;
806
+ if (action.kind === 'refresh-auth') {
807
+ state.authRefreshAttempted = true;
671
808
  this.cookiePromise = undefined;
672
809
  getLogger().warn(`[dsh] approval event stream authentication expired; refreshing credentials and reconnecting once`);
673
810
  continue;
674
811
  }
675
- const message = err instanceof Error ? err.message : String(err);
676
- const forbidden = (err instanceof DshError &&
677
- (err.code === 'gateway/interactions-forbidden' ||
678
- (err.code === 'http' && err.details.status === 403))) ||
679
- message.includes('interactions-forbidden');
680
- if (!forbidden && reconnectAttempts < 3) {
681
- reconnectAttempts += 1;
682
- getLogger().warn(`[dsh] approval event stream dropped (attempt ${reconnectAttempts}/3); reconnecting`);
683
- 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();
684
816
  continue;
685
817
  }
686
- getLogger().warn(`[dsh] approval event stream unavailable: ${message}`);
687
- yield { kind: 'unavailable', message };
818
+ getLogger().warn(`[dsh] approval event stream unavailable: ${action.message}`);
819
+ yield { kind: 'unavailable', message: action.message };
688
820
  return;
689
821
  }
690
- if (!signal.aborted) {
691
- if (sawReady && reconnectAttempts < 3) {
692
- reconnectAttempts += 1;
693
- getLogger().warn(`[dsh] approval event stream ended (attempt ${reconnectAttempts}/3); reconnecting`);
694
- await new Promise((resolve) => setTimeout(resolve, 200));
695
- continue;
696
- }
697
- // A clean end before ready means the Host does not expose `$events`;
698
- // fall back to the Web UI prompt instead of retrying forever.
699
- yield { kind: 'unavailable', message: 'DSH approval event stream ended' };
822
+ const tail = approvalStreamTail(state, signal);
823
+ if (tail === 'aborted')
700
824
  return;
825
+ if (tail === 'reconnect') {
826
+ getLogger().warn(`[dsh] approval event stream ended (attempt ${state.reconnectAttempts}/3); reconnecting`);
827
+ await reconnectBackoff();
828
+ continue;
701
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;
702
834
  }
703
835
  }
704
- /** Translate one durable event into a stream item (approvals are surfaced
705
- * separately; everything else is forwarded to the runner). */
706
- eventItem(sessionId, event) {
707
- if (event.type === 'approval/asked') {
708
- const data = event.data;
709
- if (!data.id)
710
- return undefined;
711
- return {
712
- kind: 'approval',
713
- sessionId,
714
- approvalId: data.id,
715
- toolName: data.toolName ?? '',
716
- ...(data.callId ? { callId: data.callId } : {}),
717
- ...(data.reason ? { reason: data.reason } : {}),
718
- };
719
- }
720
- return { kind: 'event', sessionId, event };
721
- }
722
836
  /**
723
837
  * Per-session subscription: yields session events, assistant stream chunks
724
838
  * and approval notices for `sessionId`, auto-reconnecting on stream
@@ -733,92 +847,47 @@ export class DshClient {
733
847
  * keeps the card "running" until this run's turn actually finishes.
734
848
  */
735
849
  async *sessionEvents(sessionId, signal, initialSeq = -1, promptRequestId) {
736
- const state = { lastSeq: initialSeq, owns: promptRequestId === undefined };
737
- let foreignTailReported = false;
738
- /** Mark this run as the owner once its own prompt message is recorded. */
739
- const ownsEvent = (ev) => {
740
- if (state.owns)
741
- return true;
742
- if (ev.type !== 'user/message')
743
- return false;
744
- const source = ev.data
745
- ?.source;
746
- if (source?.kind === 'user' && source.rpcId === promptRequestId) {
747
- state.owns = true;
748
- return true;
749
- }
750
- return false;
850
+ const state = {
851
+ lastSeq: initialSeq,
852
+ owns: promptRequestId === undefined,
853
+ foreignTailReported: false,
854
+ sessionId,
855
+ promptRequestId,
856
+ authRefreshAttempted: false,
751
857
  };
752
- const admit = (ev) => {
753
- if (ownsEvent(ev))
754
- return true;
755
- if (!foreignTailReported) {
756
- foreignTailReported = true;
757
- getLogger().info(`[dsh] prompt ${promptRequestId ?? '(none)'} is queued behind a running turn for session ${sessionId}; holding the card open until its own turn starts`);
758
- }
759
- return false;
760
- };
761
- let authRefreshAttempted = false;
762
858
  while (!signal.aborted) {
763
859
  try {
764
860
  for await (const frame of this.followStream(sessionId, signal)) {
765
861
  if (signal.aborted)
766
862
  return;
767
- authRefreshAttempted = false;
768
- if (frame.type === 'snapshot') {
769
- // 快照可能只覆盖尾部窗口(见 replaySnapshotGap):先补缺口再回放,
770
- // 回放循环与原逻辑逐条相同(去重 / admit / turn/end 早退)。
771
- for (const ev of await this.replaySnapshotGap(sessionId, signal, state.lastSeq, frame)) {
772
- if (ev.seq <= state.lastSeq)
773
- continue;
774
- state.lastSeq = ev.seq;
775
- if (!admit(ev))
776
- continue;
777
- const item = this.eventItem(sessionId, ev);
778
- if (item)
779
- yield item;
780
- if (ev.type === 'turn/end')
781
- return;
782
- }
783
- continue;
784
- }
785
- if (frame.type === 'event') {
786
- const ev = frame.event;
787
- if (ev.seq <= state.lastSeq)
788
- continue;
789
- state.lastSeq = ev.seq;
790
- if (!admit(ev))
863
+ state.authRefreshAttempted = false;
864
+ for (const ev of await sessionFrameEvents(this, state, frame, signal)) {
865
+ const step = advanceSessionEvent(state, ev);
866
+ if (!step)
791
867
  continue;
792
- const item = this.eventItem(sessionId, ev);
793
- if (item)
794
- yield item;
795
- if (ev.type === 'turn/end')
868
+ if (step.item)
869
+ yield step.item;
870
+ if (step.done)
796
871
  return;
797
- continue;
798
872
  }
799
- if (frame.type === 'assistant-stream') {
873
+ if (frame.type === 'assistant-stream' && state.owns) {
800
874
  // Mid-turn deltas of the turn this prompt is queued behind.
801
- if (!state.owns)
802
- continue;
803
875
  yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
804
876
  }
805
877
  }
806
878
  }
807
879
  catch (err) {
808
- if (signal.aborted)
880
+ const action = classifySessionStreamError(err, state, signal);
881
+ if (action.kind === 'return')
809
882
  return;
810
- // A DSH restart invalidates the process-local v2 operator cookie. A
811
- // fresh credentials read mints a new v1 cookie, so retry once; a
812
- // second auth failure is deterministic and belongs on the run card.
813
- if (isDshAuthError(err)) {
814
- if (!authRefreshAttempted) {
815
- authRefreshAttempted = true;
816
- this.cookiePromise = undefined;
817
- getLogger().warn(`[dsh] remote mux authentication expired for session ${sessionId}; refreshing credentials and reconnecting once`);
818
- continue;
819
- }
820
- 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;
821
888
  }
889
+ if (action.kind === 'rethrow')
890
+ throw err;
822
891
  getLogger().warn(`[dsh] remote mux disconnected for session ${sessionId}: ${err.message}; reconnecting`);
823
892
  }
824
893
  if (signal.aborted)