lark-remote 0.5.5 → 0.5.7

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 (58) hide show
  1. package/CHANGELOG.md +48 -7
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/approval-coordinator.d.ts +13 -0
  5. package/dist/bridge/approval-coordinator.js +34 -1
  6. package/dist/bridge/index.d.ts +48 -1
  7. package/dist/bridge/index.js +86 -14
  8. package/dist/card/approval-render.js +23 -1
  9. package/dist/card/card-budget.d.ts +37 -0
  10. package/dist/card/card-budget.js +128 -5
  11. package/dist/card/run-renderer.d.ts +24 -0
  12. package/dist/card/run-renderer.js +256 -60
  13. package/dist/card/run-state.d.ts +26 -0
  14. package/dist/card/run-state.js +45 -0
  15. package/dist/card/time.d.ts +14 -0
  16. package/dist/card/time.js +30 -0
  17. package/dist/card/tool-render.d.ts +15 -2
  18. package/dist/card/tool-render.js +40 -66
  19. package/dist/common/text-width.d.ts +24 -0
  20. package/dist/common/text-width.js +93 -0
  21. package/dist/common/tool-summary.d.ts +11 -0
  22. package/dist/common/tool-summary.js +153 -0
  23. package/dist/config/index.d.ts +1 -1
  24. package/dist/config/index.js +1 -1
  25. package/dist/connector/index.js +1 -1
  26. package/dist/index.js +63 -8
  27. package/dist/platform/command.js +1 -1
  28. package/dist/platform/shell.d.ts +2 -2
  29. package/dist/platform/spawn.d.ts +1 -1
  30. package/dist/platform/spawn.js +2 -2
  31. package/dist/platform/types.d.ts +1 -1
  32. package/dist/router/card-helpers.d.ts +18 -16
  33. package/dist/router/card-helpers.js +65 -4
  34. package/dist/router/index.d.ts +9 -0
  35. package/dist/router/index.js +57 -7
  36. package/dist/runner/claude/session.d.ts +1 -1
  37. package/dist/runner/claude/session.js +3 -3
  38. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  39. package/dist/runner/common/jsonrpc/transport.js +3 -3
  40. package/dist/runner/common/spawning-runner.d.ts +1 -1
  41. package/dist/runner/common/spawning-runner.js +2 -2
  42. package/dist/runner/dsh/auth.js +2 -1
  43. package/dist/runner/dsh/client.d.ts +124 -6
  44. package/dist/runner/dsh/client.js +398 -12
  45. package/dist/runner/dsh/exit-cancel.d.ts +32 -0
  46. package/dist/runner/dsh/exit-cancel.js +72 -0
  47. package/dist/runner/dsh/inflight.d.ts +62 -0
  48. package/dist/runner/dsh/inflight.js +140 -0
  49. package/dist/runner/dsh/runner.d.ts +117 -7
  50. package/dist/runner/dsh/runner.js +607 -21
  51. package/dist/runner/dsh/translator.d.ts +1 -1
  52. package/dist/runner/dsh/translator.js +103 -1
  53. package/dist/runner/dsh/types.d.ts +91 -1
  54. package/dist/runner/types.d.ts +86 -2
  55. package/dist/session/claude/sessions.js +2 -2
  56. package/dist/session/dsh/sessions.js +100 -15
  57. package/dist/update/install.js +1 -1
  58. package/package.json +1 -1
@@ -10,16 +10,16 @@
10
10
  * 3. Live session data is a logical stream over `/api/remote.mux`:
11
11
  * `{type:'open',streamId,endpoint:'session/follow',payload}` →
12
12
  * `{type:'item',streamId,value}` / `{type:'end',streamId}`.
13
- * The Gateway-owned `$events` stream carries answerable
14
- * `approval/request` waterfalls; answers go back through
15
- * `$events/result`.
13
+ * The Gateway-owned `$events` stream carries answerable waterfalls:
14
+ * `approval/request` (tool permissions) and `user-questions/request`
15
+ * (`ask_user_question`); answers go back through `$events/result`.
16
16
  *
17
17
  * The client is stateless apart from the base URL and auth cookie: each
18
18
  * `run()` opens its own remote mux subscription and closes it on terminal.
19
19
  * `sessionEvents()` adds per-session filtering, reconnect + follow-snapshot
20
20
  * replay, and seq-based dedup.
21
21
  */
22
- import type { DshApprovalStreamItem, DshModelCatalogValue, DshModelSelection, DshPermissionCatalogValue, DshPresetListValue, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage, DshCommandResult } from './types.js';
22
+ import type { DshApprovalStreamItem, DshCommandResult, DshModelCatalogValue, DshModelSelection, DshOpenTurnState, DshPermissionCatalogValue, DshJobView, DshPresetListValue, DshProjectionItem, DshProjectionSnapshot, DshQuestion, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage } from './types.js';
23
23
  interface WebSocketMessageEvent {
24
24
  data: unknown;
25
25
  }
@@ -163,6 +163,8 @@ export declare class DshClient {
163
163
  signal?: AbortSignal;
164
164
  timeoutMs?: number;
165
165
  }): Promise<void>;
166
+ /** Hand one waterfall delivery back with `next` (another answerer owns it). */
167
+ private delegateWaterfall;
166
168
  /**
167
169
  * Return the highest event `seq` currently persisted for the session.
168
170
  *
@@ -180,6 +182,68 @@ export declare class DshClient {
180
182
  signal?: AbortSignal;
181
183
  timeoutMs?: number;
182
184
  }): Promise<DshSessionPageValue>;
185
+ /**
186
+ * Read the session's exact projection values.
187
+ *
188
+ * Returns `null` when the host has no projections for the session (e.g. it was
189
+ * deleted). A failed unary still throws — callers that only need a
190
+ * best-effort probe catch it themselves.
191
+ */
192
+ projectionSnapshot(sessionId: string, opts?: {
193
+ signal?: AbortSignal;
194
+ timeoutMs?: number;
195
+ }): Promise<DshProjectionSnapshot | null>;
196
+ /**
197
+ * Find this run's prompt if it is still sitting in the host's inbox.
198
+ *
199
+ * DSH 的 prompt 受理后先进入 inbox,被 turn 取走(claim)就不再出现;停止
200
+ * 时用它区分“还没开始 → 可以从队列里撤掉”和“已经在跑 → 只能 cancel”。
201
+ * `source.rpcId` 是 `session/prompt` 的 requestId,归属不会认错。
202
+ */
203
+ findPendingPrompt(sessionId: string, requestId: string, opts?: {
204
+ signal?: AbortSignal;
205
+ timeoutMs?: number;
206
+ }): Promise<string | undefined>;
207
+ /**
208
+ * Remove one not-yet-claimed inbox occurrence (`session/updateQueue`).
209
+ *
210
+ * @returns true when the host removed it; false when it was claimed between
211
+ * the projection read and this call (`session/queue-item-not-found`).
212
+ */
213
+ removeQueuedPrompt(sessionId: string, itemId: string, opts?: {
214
+ signal?: AbortSignal;
215
+ timeoutMs?: number;
216
+ }): Promise<boolean>;
217
+ /**
218
+ * Whether the session still has a running turn.
219
+ *
220
+ * 主信号是 `session/list` 的 `running` 字段:服务端权威(会话列表页"运行中"
221
+ * 用的就是它),一次 unary,投票不受桥接进程内存影响。名单里没有该会话或
222
+ * 服务端不返回 `running` 时,回退到事件探测({@link probeOpenTurnByEvents}),
223
+ * 两者都判不出来就返回 false(fail open,仍由 DSH 自己拒绝)。
224
+ */
225
+ hasOpenTurn(sessionId: string, opts?: {
226
+ signal?: AbortSignal;
227
+ timeoutMs?: number;
228
+ }): Promise<boolean>;
229
+ /**
230
+ * Tri-state version of {@link hasOpenTurn}.
231
+ *
232
+ * Stop verification must distinguish "the host says idle" from "the probe
233
+ * could not tell" — reporting `closed` on a failed probe would let a still
234
+ * running turn be reported as stopped. `unknown` is the honest answer there.
235
+ */
236
+ openTurnState(sessionId: string, opts?: {
237
+ signal?: AbortSignal;
238
+ timeoutMs?: number;
239
+ }): Promise<DshOpenTurnState>;
240
+ /**
241
+ * Fallback probe: the newest durable event's type tells whether a turn is open.
242
+ *
243
+ * 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
244
+ * 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
245
+ */
246
+ private probeOpenTurnByEvents;
183
247
  /**
184
248
  * One logical stream over `/api/remote.mux`. The WebSocket must carry the
185
249
  * same auth cookie as unary calls. Ends on the server's `end` frame, socket
@@ -201,12 +265,66 @@ export declare class DshClient {
201
265
  * Per-session subscription: yields session events, assistant stream chunks
202
266
  * and approval notices for `sessionId`, auto-reconnecting on stream
203
267
  * drop/error and replaying the follow snapshot (dedup by seq). Terminates on
204
- * turn/end or `signal` abort.
268
+ * the run's own turn/end or `signal` abort.
269
+ *
270
+ * `promptRequestId` binds the stream to this run's prompt. `mode:'queue'`
271
+ * prompts are accepted while the agent is mid-turn and start a turn only
272
+ * later, so the first `turn/end` after `initialSeq` can belong to that OTHER
273
+ * turn. Everything recorded before the prompt's own durable `user/message`
274
+ * (whose source echoes the request id as `rpcId`) is foreign: dropping it
275
+ * keeps the card "running" until this run's turn actually finishes.
205
276
  */
206
- sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number): AsyncGenerator<DshStreamItem>;
277
+ sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number, promptRequestId?: string): AsyncGenerator<DshStreamItem>;
278
+ /**
279
+ * Host-wide live projection stream (`session/control`).
280
+ *
281
+ * 一份 baseline(当前所有活跃 session 的完整投影值)+ 之后每个投影的变化帧。
282
+ * 调用方按 sessionId 过滤:这是 DSH 上唯一"服务端变一次推一次"的通道,
283
+ * 不需要轮询 `session/projections`。
284
+ *
285
+ * 失败必须降级为"没有状态条",不能影响 run:控制流是增强通道,断了运行
286
+ * 照常(异常在此吞掉并记日志,不向 merge 抛)。
287
+ */
288
+ controlFrames(signal: AbortSignal): AsyncGenerator<DshProjectionItem>;
289
+ /**
290
+ * One session's current background-job roster.
291
+ *
292
+ * `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
293
+ * 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
294
+ * 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
295
+ *
296
+ * 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
297
+ */
298
+ jobRows(sessionId: string, opts?: {
299
+ signal?: AbortSignal;
300
+ timeoutMs?: number;
301
+ }): Promise<DshJobView[] | undefined>;
302
+ /** Kill one background job on the user's behalf (`job/kill`). */
303
+ jobKill(sessionId: string, jobId: string, opts?: {
304
+ signal?: AbortSignal;
305
+ }): Promise<boolean>;
207
306
  /** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
208
307
  static usageOf(data: Record<string, unknown>): DshTokenUsage | undefined;
209
308
  }
210
309
  /** Stable client-side prompt identity persisted on the accepted user message. */
211
310
  export declare function newPromptRequestId(): string;
311
+ /**
312
+ * Read the questions of one `user-questions/request` waterfall.
313
+ *
314
+ * Returns undefined when the batch cannot be answered through the Feishu card:
315
+ * malformed fields, or duplicate question texts — the approval coordinator
316
+ * keys answers by question text, so duplicates would answer the wrong one.
317
+ * The caller delegates such a batch back to the DSH Web UI.
318
+ */
319
+ export declare function dshQuestionsOf(request: Record<string, unknown>): DshQuestion[] | undefined;
320
+ /**
321
+ * True for a timed (`askTimed`) request — `wait.timed` in the projected payload.
322
+ *
323
+ * Timed asks settle at their own foreground deadline and then close the
324
+ * waterfall, so an answer sent outside that window can never land. Such a
325
+ * request belongs to the client that claims the wait (`attachWait`) and renders
326
+ * its countdown, i.e. the DSH Web UI; the card must not offer answers that expire
327
+ * silently on the server side.
328
+ */
329
+ export declare function isTimedQuestionRequest(request: Record<string, unknown>): boolean;
212
330
  export {};
@@ -10,9 +10,9 @@
10
10
  * 3. Live session data is a logical stream over `/api/remote.mux`:
11
11
  * `{type:'open',streamId,endpoint:'session/follow',payload}` →
12
12
  * `{type:'item',streamId,value}` / `{type:'end',streamId}`.
13
- * The Gateway-owned `$events` stream carries answerable
14
- * `approval/request` waterfalls; answers go back through
15
- * `$events/result`.
13
+ * The Gateway-owned `$events` stream carries answerable waterfalls:
14
+ * `approval/request` (tool permissions) and `user-questions/request`
15
+ * (`ask_user_question`); answers go back through `$events/result`.
16
16
  *
17
17
  * The client is stateless apart from the base URL and auth cookie: each
18
18
  * `run()` opens its own remote mux subscription and closes it on terminal.
@@ -52,6 +52,59 @@ function isDshAuthError(error) {
52
52
  const RECONNECT_BACKOFF_MS = 200;
53
53
  /** Follow snapshot budget. `session/follow` also stays live after the snapshot. */
54
54
  const FOLLOW_MAX_MESSAGES = 200;
55
+ /**
56
+ * 只在 turn 内部落盘的会话事件类型:看到它们说明 turn 还没结束。
57
+ * 名单刻意保守——`user/message`(压缩 checkpoint 会以 turn:null 落盘)、
58
+ * `job/*`(后台任务可在 turn 外结算)、`turn/end`、`command/*` 等都不算,
59
+ * 判不出来时宁可放行,让 DSH 自己拒绝。
60
+ */
61
+ const IN_TURN_EVENT_TYPES = new Set([
62
+ 'step/start',
63
+ 'step/end',
64
+ 'assistant/chunk',
65
+ 'assistant/message',
66
+ 'assistant/attempt',
67
+ 'tool/call',
68
+ 'tool/result',
69
+ 'request/header',
70
+ ]);
71
+ /**
72
+ * Parse the `inbox` projection into the pending items this repo consumes.
73
+ *
74
+ * The projection's items are wire data (typed `{}` by the server schema); keep
75
+ * this defensive: one malformed row must not hide the others.
76
+ */
77
+ function inboxItemsOf(values) {
78
+ const inbox = values.inbox;
79
+ if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox))
80
+ return [];
81
+ const out = [];
82
+ for (const target of ['next-turn', 'next-step']) {
83
+ const list = inbox[target];
84
+ if (!Array.isArray(list))
85
+ continue;
86
+ for (const raw of list) {
87
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
88
+ continue;
89
+ const item = raw;
90
+ if (typeof item.id !== 'string' || item.id.length === 0)
91
+ continue;
92
+ const source = item.source;
93
+ const parsedSource = source && typeof source === 'object' && !Array.isArray(source)
94
+ ? {
95
+ ...(typeof source.kind === 'string'
96
+ ? { kind: source.kind }
97
+ : {}),
98
+ ...(typeof source.rpcId === 'string'
99
+ ? { rpcId: source.rpcId }
100
+ : {}),
101
+ }
102
+ : undefined;
103
+ out.push({ id: item.id, ...(parsedSource ? { source: parsedSource } : {}) });
104
+ }
105
+ }
106
+ return out;
107
+ }
55
108
  /** Default unary timeout: long prompts are unbounded on the server side. */
56
109
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
57
110
  /** Extract the `token` query parameter from a pasted `dsh web` URL. */
@@ -85,6 +138,9 @@ const DSH_ARGS_KEY = {
85
138
  'session/projections': 'request',
86
139
  'session/page': 'request',
87
140
  'session/follow': 'request',
141
+ 'session/updateQueue': 'request',
142
+ 'job/list': 'request',
143
+ 'job/kill': 'request',
88
144
  'session/modelCatalog': null,
89
145
  'agentPresets/list': null,
90
146
  'permissionPresets/catalog': null,
@@ -348,6 +404,14 @@ export class DshClient {
348
404
  async answerRemoteEvent(clientId, eventId, outcome, opts) {
349
405
  await this.unary('$events/result', { clientId, eventId, outcome }, opts);
350
406
  }
407
+ /** Hand one waterfall delivery back with `next` (another answerer owns it). */
408
+ delegateWaterfall(clientId, eventId, signal) {
409
+ void this.answerRemoteEvent(clientId, eventId, { kind: 'next' }, { signal }).catch((err) => {
410
+ if (!signal.aborted) {
411
+ getLogger().warn(`[dsh] waterfall delegation failed eventId=${eventId}: ${err.message}`);
412
+ }
413
+ });
414
+ }
351
415
  /**
352
416
  * Return the highest event `seq` currently persisted for the session.
353
417
  *
@@ -356,7 +420,7 @@ export class DshClient {
356
420
  * `session/prompt` so a follow snapshot can skip already-completed turns.
357
421
  */
358
422
  async latestEventSeq(sessionId, opts) {
359
- const value = (await this.unary('session/projections', { sessionId }, opts));
423
+ const value = await this.projectionSnapshot(sessionId, opts);
360
424
  return typeof value?.asOfSeq === 'number' ? value.asOfSeq : -1;
361
425
  }
362
426
  /** Read one cold history page. `throughSeq` normally comes from
@@ -368,6 +432,110 @@ export class DshClient {
368
432
  maxMessages,
369
433
  }, opts));
370
434
  }
435
+ /**
436
+ * Read the session's exact projection values.
437
+ *
438
+ * Returns `null` when the host has no projections for the session (e.g. it was
439
+ * deleted). A failed unary still throws — callers that only need a
440
+ * best-effort probe catch it themselves.
441
+ */
442
+ async projectionSnapshot(sessionId, opts) {
443
+ const value = (await this.unary('session/projections', { sessionId }, opts));
444
+ if (!value || typeof value !== 'object')
445
+ return null;
446
+ return {
447
+ ...(typeof value.asOfSeq === 'number' ? { asOfSeq: value.asOfSeq } : {}),
448
+ values: value.values && typeof value.values === 'object' && !Array.isArray(value.values)
449
+ ? value.values
450
+ : {},
451
+ };
452
+ }
453
+ /**
454
+ * Find this run's prompt if it is still sitting in the host's inbox.
455
+ *
456
+ * DSH 的 prompt 受理后先进入 inbox,被 turn 取走(claim)就不再出现;停止
457
+ * 时用它区分“还没开始 → 可以从队列里撤掉”和“已经在跑 → 只能 cancel”。
458
+ * `source.rpcId` 是 `session/prompt` 的 requestId,归属不会认错。
459
+ */
460
+ async findPendingPrompt(sessionId, requestId, opts) {
461
+ const snapshot = await this.projectionSnapshot(sessionId, opts);
462
+ if (!snapshot)
463
+ return undefined;
464
+ return inboxItemsOf(snapshot.values).find((item) => item.source?.kind === 'user' && item.source.rpcId === requestId)?.id;
465
+ }
466
+ /**
467
+ * Remove one not-yet-claimed inbox occurrence (`session/updateQueue`).
468
+ *
469
+ * @returns true when the host removed it; false when it was claimed between
470
+ * the projection read and this call (`session/queue-item-not-found`).
471
+ */
472
+ async removeQueuedPrompt(sessionId, itemId, opts) {
473
+ try {
474
+ await this.unary('session/updateQueue', { sessionId, itemId, action: { kind: 'remove' } }, opts);
475
+ return true;
476
+ }
477
+ catch (err) {
478
+ if (err instanceof DshError && err.code === 'session/queue-item-not-found')
479
+ return false;
480
+ throw err;
481
+ }
482
+ }
483
+ /**
484
+ * Whether the session still has a running turn.
485
+ *
486
+ * 主信号是 `session/list` 的 `running` 字段:服务端权威(会话列表页"运行中"
487
+ * 用的就是它),一次 unary,投票不受桥接进程内存影响。名单里没有该会话或
488
+ * 服务端不返回 `running` 时,回退到事件探测({@link probeOpenTurnByEvents}),
489
+ * 两者都判不出来就返回 false(fail open,仍由 DSH 自己拒绝)。
490
+ */
491
+ async hasOpenTurn(sessionId, opts) {
492
+ return (await this.openTurnState(sessionId, opts)) === 'open';
493
+ }
494
+ /**
495
+ * Tri-state version of {@link hasOpenTurn}.
496
+ *
497
+ * Stop verification must distinguish "the host says idle" from "the probe
498
+ * could not tell" — reporting `closed` on a failed probe would let a still
499
+ * running turn be reported as stopped. `unknown` is the honest answer there.
500
+ */
501
+ async openTurnState(sessionId, opts) {
502
+ try {
503
+ const list = (await this.unary('session/list', {}, opts));
504
+ const row = list?.items?.find((item) => String(item.sessionId) === sessionId);
505
+ if (row && typeof row.running === 'boolean')
506
+ return row.running ? 'open' : 'closed';
507
+ }
508
+ catch (err) {
509
+ getLogger().warn(`[dsh] open-turn probe: session/list failed for ${sessionId}: ${err.message}`);
510
+ }
511
+ return this.probeOpenTurnByEvents(sessionId, opts);
512
+ }
513
+ /**
514
+ * Fallback probe: the newest durable event's type tells whether a turn is open.
515
+ *
516
+ * 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
517
+ * 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
518
+ */
519
+ async probeOpenTurnByEvents(sessionId, opts) {
520
+ try {
521
+ // A missing projection snapshot is not "idle": the host simply cannot
522
+ // tell us. Only a known watermark with no events (or a closed tail) is
523
+ // evidence that no turn is open.
524
+ const snapshot = await this.projectionSnapshot(sessionId, opts);
525
+ if (!snapshot || typeof snapshot.asOfSeq !== 'number' || snapshot.asOfSeq < 0) {
526
+ return 'unknown';
527
+ }
528
+ const value = await this.page(sessionId, snapshot.asOfSeq, 1, opts);
529
+ const last = value.records.at(-1)?.event;
530
+ if (last === undefined)
531
+ return 'closed';
532
+ return last.type === 'turn/start' || IN_TURN_EVENT_TYPES.has(last.type) ? 'open' : 'closed';
533
+ }
534
+ catch (err) {
535
+ getLogger().warn(`[dsh] open-turn probe failed for session ${sessionId}: ${err.message}`);
536
+ return 'unknown';
537
+ }
538
+ }
371
539
  /**
372
540
  * One logical stream over `/api/remote.mux`. The WebSocket must carry the
373
541
  * same auth cookie as unary calls. Ends on the server's `end` frame, socket
@@ -553,14 +721,36 @@ export class DshClient {
553
721
  };
554
722
  return;
555
723
  }
724
+ if (frame.event === 'user-questions/request' && frame.agentId === sessionId) {
725
+ if (isTimedQuestionRequest(frame.request)) {
726
+ // The timed wait owns its own deadline (and the Web UI renders
727
+ // the countdown); a card answer outside that window is lost.
728
+ getLogger().info(`[dsh] timed user-questions/request delegated to the Web UI eventId=${frame.eventId}`);
729
+ this.delegateWaterfall(streamClientId, frame.eventId, signal);
730
+ continue;
731
+ }
732
+ const questions = dshQuestionsOf(frame.request);
733
+ if (questions) {
734
+ yield {
735
+ kind: 'question',
736
+ eventId: frame.eventId,
737
+ clientId: streamClientId,
738
+ questions,
739
+ };
740
+ }
741
+ else {
742
+ // The Feishu card cannot answer this batch safely (malformed,
743
+ // or duplicate texts — the coordinator keys answers by text).
744
+ // Hand the one-shot answer back so the DSH Web UI stays in charge.
745
+ getLogger().warn(`[dsh] user-questions/request not relayable; delegating eventId=${frame.eventId}`);
746
+ this.delegateWaterfall(streamClientId, frame.eventId, signal);
747
+ }
748
+ continue;
749
+ }
556
750
  if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
557
751
  // Every owner answerer receives the waterfall. A non-owner of
558
752
  // the session must explicitly delegate, otherwise DSH waits.
559
- void this.answerRemoteEvent(streamClientId, frame.eventId, { kind: 'next' }, { signal }).catch((err) => {
560
- if (!signal.aborted) {
561
- getLogger().warn(`[dsh] approval waterfall delegation failed eventId=${frame.eventId}: ${err.message}`);
562
- }
563
- });
753
+ this.delegateWaterfall(streamClientId, frame.eventId, signal);
564
754
  continue;
565
755
  }
566
756
  const data = frame.request;
@@ -645,10 +835,41 @@ export class DshClient {
645
835
  * Per-session subscription: yields session events, assistant stream chunks
646
836
  * and approval notices for `sessionId`, auto-reconnecting on stream
647
837
  * drop/error and replaying the follow snapshot (dedup by seq). Terminates on
648
- * turn/end or `signal` abort.
838
+ * the run's own turn/end or `signal` abort.
839
+ *
840
+ * `promptRequestId` binds the stream to this run's prompt. `mode:'queue'`
841
+ * prompts are accepted while the agent is mid-turn and start a turn only
842
+ * later, so the first `turn/end` after `initialSeq` can belong to that OTHER
843
+ * turn. Everything recorded before the prompt's own durable `user/message`
844
+ * (whose source echoes the request id as `rpcId`) is foreign: dropping it
845
+ * keeps the card "running" until this run's turn actually finishes.
649
846
  */
650
- async *sessionEvents(sessionId, signal, initialSeq = -1) {
651
- const state = { lastSeq: initialSeq };
847
+ async *sessionEvents(sessionId, signal, initialSeq = -1, promptRequestId) {
848
+ const state = { lastSeq: initialSeq, owns: promptRequestId === undefined };
849
+ let foreignTailReported = false;
850
+ /** Mark this run as the owner once its own prompt message is recorded. */
851
+ const ownsEvent = (ev) => {
852
+ if (state.owns)
853
+ return true;
854
+ if (ev.type !== 'user/message')
855
+ return false;
856
+ const source = ev.data
857
+ ?.source;
858
+ if (source?.kind === 'user' && source.rpcId === promptRequestId) {
859
+ state.owns = true;
860
+ return true;
861
+ }
862
+ return false;
863
+ };
864
+ const admit = (ev) => {
865
+ if (ownsEvent(ev))
866
+ return true;
867
+ if (!foreignTailReported) {
868
+ foreignTailReported = true;
869
+ getLogger().info(`[dsh] prompt ${promptRequestId ?? '(none)'} is queued behind a running turn for session ${sessionId}; holding the card open until its own turn starts`);
870
+ }
871
+ return false;
872
+ };
652
873
  let authRefreshAttempted = false;
653
874
  while (!signal.aborted) {
654
875
  try {
@@ -662,6 +883,8 @@ export class DshClient {
662
883
  if (!ev || ev.seq <= state.lastSeq)
663
884
  continue;
664
885
  state.lastSeq = ev.seq;
886
+ if (!admit(ev))
887
+ continue;
665
888
  const item = this.eventItem(sessionId, ev);
666
889
  if (item)
667
890
  yield item;
@@ -675,6 +898,8 @@ export class DshClient {
675
898
  if (ev.seq <= state.lastSeq)
676
899
  continue;
677
900
  state.lastSeq = ev.seq;
901
+ if (!admit(ev))
902
+ continue;
678
903
  const item = this.eventItem(sessionId, ev);
679
904
  if (item)
680
905
  yield item;
@@ -683,6 +908,9 @@ export class DshClient {
683
908
  continue;
684
909
  }
685
910
  if (frame.type === 'assistant-stream') {
911
+ // Mid-turn deltas of the turn this prompt is queued behind.
912
+ if (!state.owns)
913
+ continue;
686
914
  yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
687
915
  }
688
916
  }
@@ -709,6 +937,93 @@ export class DshClient {
709
937
  await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
710
938
  }
711
939
  }
940
+ /**
941
+ * Host-wide live projection stream (`session/control`).
942
+ *
943
+ * 一份 baseline(当前所有活跃 session 的完整投影值)+ 之后每个投影的变化帧。
944
+ * 调用方按 sessionId 过滤:这是 DSH 上唯一"服务端变一次推一次"的通道,
945
+ * 不需要轮询 `session/projections`。
946
+ *
947
+ * 失败必须降级为"没有状态条",不能影响 run:控制流是增强通道,断了运行
948
+ * 照常(异常在此吞掉并记日志,不向 merge 抛)。
949
+ */
950
+ async *controlFrames(signal) {
951
+ try {
952
+ // mux 的 payload 与 unary 同信封:`{args:{...}}`。control 无参数,故空 args;
953
+ // 少了这层包装网关直接拒绝(live 实测 0 帧,测试用假服务端不会发现)。
954
+ for await (const raw of this.muxStream('session/control', { args: {} }, signal)) {
955
+ const frame = raw;
956
+ if (!frame || typeof frame !== 'object')
957
+ continue;
958
+ if (frame.type === 'baseline') {
959
+ const projections = frame.value?.projections ??
960
+ {};
961
+ for (const [sessionId, snapshot] of Object.entries(projections)) {
962
+ const values = snapshot?.values ?? {};
963
+ for (const [key, value] of Object.entries(values)) {
964
+ yield { kind: 'projection', sessionId, key, value };
965
+ }
966
+ }
967
+ continue;
968
+ }
969
+ if (frame.type === 'projection' && frame.sessionId && frame.key) {
970
+ yield {
971
+ kind: 'projection',
972
+ sessionId: frame.sessionId,
973
+ key: frame.key,
974
+ value: frame.value,
975
+ };
976
+ }
977
+ }
978
+ }
979
+ catch (err) {
980
+ if (signal.aborted)
981
+ return;
982
+ getLogger().warn(`[dsh] session/control stream unavailable (${err.message}); run continues without live status`);
983
+ }
984
+ }
985
+ /**
986
+ * One session's current background-job roster.
987
+ *
988
+ * `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
989
+ * 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
990
+ * 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
991
+ *
992
+ * 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
993
+ */
994
+ async jobRows(sessionId, opts = {}) {
995
+ const timer = new AbortController();
996
+ const timeout = setTimeout(() => timer.abort(), opts.timeoutMs ?? 2_000);
997
+ const signal = opts.signal ? AbortSignal.any([opts.signal, timer.signal]) : timer.signal;
998
+ try {
999
+ for await (const frame of this.muxStream('job/list', { args: dshArgs('job/list', { sessionId }) }, signal)) {
1000
+ const rows = frame;
1001
+ if (rows?.type === 'rows') {
1002
+ return Array.isArray(rows.jobs) ? rows.jobs : [];
1003
+ }
1004
+ }
1005
+ return undefined;
1006
+ }
1007
+ catch (err) {
1008
+ getLogger().debug(`[dsh] job/list unavailable: ${err.message}`);
1009
+ return undefined;
1010
+ }
1011
+ finally {
1012
+ clearTimeout(timeout);
1013
+ timer.abort();
1014
+ }
1015
+ }
1016
+ /** Kill one background job on the user's behalf (`job/kill`). */
1017
+ async jobKill(sessionId, jobId, opts = {}) {
1018
+ try {
1019
+ await this.unary('job/kill', { sessionId, jobId }, opts);
1020
+ return true;
1021
+ }
1022
+ catch (err) {
1023
+ getLogger().warn(`[dsh] job/kill failed job=${jobId}: ${err.message}`);
1024
+ return false;
1025
+ }
1026
+ }
712
1027
  /** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
713
1028
  static usageOf(data) {
714
1029
  return data.usage ?? undefined;
@@ -718,4 +1033,75 @@ export class DshClient {
718
1033
  export function newPromptRequestId() {
719
1034
  return `lark-remote-${randomUUID()}`;
720
1035
  }
1036
+ /**
1037
+ * Read the questions of one `user-questions/request` waterfall.
1038
+ *
1039
+ * Returns undefined when the batch cannot be answered through the Feishu card:
1040
+ * malformed fields, or duplicate question texts — the approval coordinator
1041
+ * keys answers by question text, so duplicates would answer the wrong one.
1042
+ * The caller delegates such a batch back to the DSH Web UI.
1043
+ */
1044
+ export function dshQuestionsOf(request) {
1045
+ const raw = request.questions;
1046
+ if (!Array.isArray(raw) || raw.length === 0)
1047
+ return undefined;
1048
+ const questions = [];
1049
+ const seen = new Set();
1050
+ for (const item of raw) {
1051
+ if (item === null || typeof item !== 'object' || Array.isArray(item))
1052
+ return undefined;
1053
+ const record = item;
1054
+ const id = typeof record.id === 'string' ? record.id : '';
1055
+ const text = typeof record.question === 'string' ? record.question : '';
1056
+ if (!id || !text)
1057
+ return undefined;
1058
+ if (seen.has(text))
1059
+ return undefined;
1060
+ seen.add(text);
1061
+ const options = [];
1062
+ if (record.options !== undefined) {
1063
+ if (!Array.isArray(record.options))
1064
+ return undefined;
1065
+ for (const rawOption of record.options) {
1066
+ if (rawOption === null || typeof rawOption !== 'object' || Array.isArray(rawOption)) {
1067
+ return undefined;
1068
+ }
1069
+ const option = rawOption;
1070
+ const label = typeof option.label === 'string' ? option.label : '';
1071
+ if (!label)
1072
+ return undefined;
1073
+ options.push({
1074
+ label,
1075
+ ...(typeof option.description === 'string' && option.description
1076
+ ? { description: option.description }
1077
+ : {}),
1078
+ });
1079
+ }
1080
+ }
1081
+ questions.push({
1082
+ id,
1083
+ question: text,
1084
+ ...(typeof record.detail === 'string' && record.detail ? { detail: record.detail } : {}),
1085
+ ...(typeof record.header === 'string' && record.header ? { header: record.header } : {}),
1086
+ ...(options.length > 0 ? { options } : {}),
1087
+ ...(record.multiSelect === true ? { multiSelect: true } : {}),
1088
+ });
1089
+ }
1090
+ return questions;
1091
+ }
1092
+ /**
1093
+ * True for a timed (`askTimed`) request — `wait.timed` in the projected payload.
1094
+ *
1095
+ * Timed asks settle at their own foreground deadline and then close the
1096
+ * waterfall, so an answer sent outside that window can never land. Such a
1097
+ * request belongs to the client that claims the wait (`attachWait`) and renders
1098
+ * its countdown, i.e. the DSH Web UI; the card must not offer answers that expire
1099
+ * silently on the server side.
1100
+ */
1101
+ export function isTimedQuestionRequest(request) {
1102
+ const wait = request.wait;
1103
+ if (wait === null || typeof wait !== 'object' || Array.isArray(wait))
1104
+ return false;
1105
+ return wait.timed === true;
1106
+ }
721
1107
  //# sourceMappingURL=client.js.map