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
@@ -192,6 +192,88 @@ function interruptedResult(sessionId) {
192
192
  errorMessage: 'run stopped',
193
193
  };
194
194
  }
195
+ /** goal/change:权威变更,不等投影直接按事件更新(create/edit/pause/...)。 */
196
+ function goalChangeEvents(_runner, event, ctx) {
197
+ const parsed = goalFromChange(event.data);
198
+ if (parsed !== undefined && !sameGoal(parsed, ctx.goal.last)) {
199
+ ctx.goal.last = parsed;
200
+ return [goalStateEvent(parsed, true)];
201
+ }
202
+ return [];
203
+ }
204
+ /** approval/decided:审批回流,清审批卡。 */
205
+ function approvalDecidedEvents(runner, event) {
206
+ const data = event.data;
207
+ if (!data.id)
208
+ return [];
209
+ runner.approvalTracker.onDecided(data.id);
210
+ return [runner.approvalResolved(data.id)];
211
+ }
212
+ /** 终态分支查表:命中即返回(不再落到 translator)。 */
213
+ const SESSION_EVENT_TERMINAL_HANDLERS = new Map([
214
+ ['goal/change', goalChangeEvents],
215
+ ['approval/decided', approvalDecidedEvents],
216
+ ]);
217
+ /** tool/call:先记账(审批卡命令展示用),随后仍落到 translator 出工具行。 */
218
+ function recordToolCall(toolCalls, event) {
219
+ const data = event.data;
220
+ if (data.callId && data.name) {
221
+ toolCalls.set(data.callId, {
222
+ name: data.name,
223
+ arguments: data.arguments ?? '',
224
+ ...(data.turn === undefined ? {} : { turn: data.turn }),
225
+ });
226
+ if (toolCalls.size > 100) {
227
+ const oldest = toolCalls.keys().next().value;
228
+ if (oldest !== undefined)
229
+ toolCalls.delete(oldest);
230
+ }
231
+ }
232
+ }
233
+ /** result 收尾:后台任务名单 + goal 收尾复核;返回要插到 result 前的事件。 */
234
+ async function resultFinalizeEvents(runner, ctx) {
235
+ const events = [];
236
+ for (const jobsEvent of await runner.collectBackgroundJobs(ctx.sessionId, ctx.abort.signal)) {
237
+ events.push(jobsEvent);
238
+ }
239
+ // 收尾复核:这一轮结束时 goal 是否仍 active,决定卡片能不能收尾。
240
+ // 必须现读宿主,不能沿用过程中的最后一次观测——goal 可能在本轮
241
+ // 最后一个 step 才被 complete,也可能刚被 driver 置为 blocked。
242
+ const finalGoal = await runner.client.goalState(ctx.sessionId, {
243
+ signal: ctx.signal,
244
+ timeoutMs: GOAL_PROBE_TIMEOUT_MS,
245
+ });
246
+ if (!finalGoal.known) {
247
+ // 收尾读不到:seed 已报过 unknown 就不重复发,否则把状态降级
248
+ // 交给卡片(卡片据此不再宣布完成)。
249
+ if (ctx.goal.known) {
250
+ ctx.goal.known = false;
251
+ events.push(goalStateEvent(null, false));
252
+ }
253
+ }
254
+ else if (!ctx.goal.known || !sameGoal(finalGoal.goal, ctx.goal.last)) {
255
+ ctx.goal.known = true;
256
+ ctx.goal.last = finalGoal.goal;
257
+ events.push(goalStateEvent(finalGoal.goal, true));
258
+ }
259
+ return events;
260
+ }
261
+ /** translator 循环:result 事件插入收尾事件并提前返回。 */
262
+ async function collectAgentEvents(runner, event, ctx) {
263
+ const events = [];
264
+ for (const ev of ctx.translator.eventToAgentEvents(event, ctx.sessionId)) {
265
+ if (ev.type === 'result') {
266
+ if (!ctx.abort.signal.aborted) {
267
+ for (const finalized of await resultFinalizeEvents(runner, ctx))
268
+ events.push(finalized);
269
+ }
270
+ events.push(attachContextLimit(ev, ctx.live.contextLimit));
271
+ return events;
272
+ }
273
+ events.push(ev);
274
+ }
275
+ return events;
276
+ }
195
277
  export class DshRunner {
196
278
  kind = 'dsh';
197
279
  /**
@@ -432,41 +514,6 @@ export class DshRunner {
432
514
  yield interruptedResult(sessionId);
433
515
  return;
434
516
  }
435
- // provider/模型/推理强度对齐:会话首次 run 时 selectModel 一次
436
- // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
437
- // 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
438
- // provider 会让用户以为切换成功,实际模型与账单都错。
439
- if ((this.configuredModel || this.configuredProvider) &&
440
- !this.alignedSessions.has(sessionId)) {
441
- try {
442
- const selection = await this.resolveModelSelection(abort.signal);
443
- await this.client.selectModel({ sessionId, ...selection }, { signal: abort.signal });
444
- this.alignedSessions.add(sessionId);
445
- }
446
- catch (err) {
447
- if (abort.signal.aborted) {
448
- getLogger().info(`[dsh] run stopped during selectModel session=${sessionId}`);
449
- terminalEmitted = true;
450
- yield interruptedResult(sessionId);
451
- return;
452
- }
453
- if (this.configuredProvider) {
454
- throw new Error(`Provider "${this.configuredProvider}" 的模型对齐失败:${err.message}。` +
455
- '请在 DSH Web UI → 设置 → Models 检查该 Provider 的凭据与模型目录', { cause: err });
456
- }
457
- getLogger().warn(`[dsh] selectModel failed for session ${sessionId} (run continues): ${err.message}`);
458
- }
459
- }
460
- // 权限预设对齐:与模型对齐同语义,同一 session 只执行一次
461
- // `/permission <preset>`。失败必须 fail closed——用户明确选了安全策略,
462
- // 不能在错误的权限下继续跑 prompt。
463
- await this.alignPermissionPreset(sessionId, abort.signal);
464
- // Abort 可能在上述 unary 完成与检查之间到达:不进入 prompt/turn。
465
- if (abort.signal.aborted) {
466
- terminalEmitted = true;
467
- yield interruptedResult(sessionId);
468
- return;
469
- }
470
517
  yield {
471
518
  type: 'system',
472
519
  subtype: 'init',
@@ -474,24 +521,18 @@ export class DshRunner {
474
521
  cwd: opts.cwd,
475
522
  model: this.configuredModel ?? 'DSH',
476
523
  };
477
- // Goal seed:卡片从第一帧就知道这个会话有没有长期目标。放在 init 之后、
478
- // prompt 之前——用户发消息时若是 queued,卡片会长时间停在「运行中」,
479
- // 这期间"目标是什么、第几轮"是唯一能解释"为什么还不停"的信息。
480
- const seedGoal = await this.client.goalState(sessionId, {
481
- signal: abort.signal,
482
- timeoutMs: GOAL_PROBE_TIMEOUT_MS,
483
- });
484
- const lastGoal = seedGoal.known ? seedGoal.goal : undefined;
485
- if (seedGoal.known) {
486
- // 只有确实存在 goal 才产事件:没有 goal 的会话保持事件序列不变(少一次
487
- // 渲染,也不让"读不到"变成一张多出来的卡片事件)。
488
- if (seedGoal.goal)
489
- yield goalStateEvent(seedGoal.goal, true);
524
+ // 准备阶段(模型对齐 + 权限预设对齐 + goal seed + abort 检查)抽到
525
+ // prepareTurn:对齐失败 fail closed(显式 provider 时 throw,由外层 catch
526
+ // 转 error result);被 stop 时返回 interrupted 形态,这里统一 yield
527
+ // interrupted result 收尾。
528
+ const prepared = await this.prepareTurn(sessionId, abort);
529
+ if (!prepared.ok) {
530
+ terminalEmitted = true;
531
+ yield interruptedResult(sessionId);
532
+ return;
490
533
  }
491
- else {
492
- // 读不到 ≠ 没有目标。必须显式把 unknown 交给卡片,否则收尾只能按
493
- // 「没有 goal」写成「已完成」——正是 GoalStateEvent 契约禁止的推断。
494
- yield goalStateEvent(null, false);
534
+ for (const goalEvent of prepared.goalEvents) {
535
+ yield goalEvent;
495
536
  }
496
537
  try {
497
538
  const submitted = await this.submitPrompt(sessionId, opts.cwd, message, abort.signal);
@@ -504,7 +545,7 @@ export class DshRunner {
504
545
  return;
505
546
  }
506
547
  try {
507
- for await (const ev of this.runTurn(sessionId, opts.cwd, submitted.prePromptSeq, promptRequestId, abort, { last: lastGoal, known: seedGoal.known })) {
548
+ for await (const ev of this.runTurn(sessionId, opts.cwd, submitted.prePromptSeq, promptRequestId, abort, { last: prepared.lastGoal, known: prepared.goalKnown })) {
508
549
  yield ev;
509
550
  if (ev.type === 'result') {
510
551
  terminalEmitted = true;
@@ -573,10 +614,80 @@ export class DshRunner {
573
614
  };
574
615
  }
575
616
  }
617
+ /**
618
+ * 准备一个 turn(run() 的准备阶段):模型对齐 + 权限预设对齐 + goal seed 探测。
619
+ *
620
+ * 红线(逐条保留):
621
+ * - 对齐失败 fail closed:显式配置 provider 时 selectModel 失败必须 throw
622
+ * (静默回退到其他 provider 会让用户以为切换成功,实际模型与账单都错);
623
+ * 权限预设失败由 alignPermissionPreset throw(用户明确选了安全策略,不能在
624
+ * 错误的权限下继续跑 prompt)。
625
+ * - 准备期间被 stop(abort)→ 返回 { ok:false },由调用方 yield interrupted
626
+ * result 收尾。
627
+ * - goal seed 只探测不推断:读不到按 unknown 上报,不冒充「没有 goal」。
628
+ */
629
+ async prepareTurn(sessionId, abort) {
630
+ // provider/模型/推理强度对齐:会话首次 run 时 selectModel 一次
631
+ // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
632
+ // 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
633
+ // provider 会让用户以为切换成功,实际模型与账单都错。
634
+ if ((this.configuredModel || this.configuredProvider) && !this.alignedSessions.has(sessionId)) {
635
+ try {
636
+ const selection = await this.resolveModelSelection(abort.signal);
637
+ await this.client.selectModel({ sessionId, ...selection }, { signal: abort.signal });
638
+ this.alignedSessions.add(sessionId);
639
+ }
640
+ catch (err) {
641
+ if (abort.signal.aborted) {
642
+ getLogger().info(`[dsh] run stopped during selectModel session=${sessionId}`);
643
+ return { ok: false };
644
+ }
645
+ if (this.configuredProvider) {
646
+ throw new Error(`Provider "${this.configuredProvider}" 的模型对齐失败:${err.message}。` +
647
+ '请在 DSH Web UI → 设置 → Models 检查该 Provider 的凭据与模型目录', { cause: err });
648
+ }
649
+ getLogger().warn(`[dsh] selectModel failed for session ${sessionId} (run continues): ${err.message}`);
650
+ }
651
+ }
652
+ // 权限预设对齐:与模型对齐同语义,同一 session 只执行一次
653
+ // `/permission <preset>`。失败必须 fail closed——用户明确选了安全策略,
654
+ // 不能在错误的权限下继续跑 prompt。
655
+ await this.alignPermissionPreset(sessionId, abort.signal);
656
+ // Abort 可能在上述 unary 完成与检查之间到达:不进入 prompt/turn。
657
+ if (abort.signal.aborted) {
658
+ return { ok: false };
659
+ }
660
+ // Goal seed:卡片从第一帧就知道这个会话有没有长期目标。放在 init 之后、
661
+ // prompt 之前——用户发消息时若是 queued,卡片会长时间停在「运行中」,
662
+ // 这期间"目标是什么、第几轮"是唯一能解释"为什么还不停"的信息。
663
+ const seedGoal = await this.client.goalState(sessionId, {
664
+ signal: abort.signal,
665
+ timeoutMs: GOAL_PROBE_TIMEOUT_MS,
666
+ });
667
+ const lastGoal = seedGoal.known ? seedGoal.goal : undefined;
668
+ const goalEvents = [];
669
+ if (seedGoal.known) {
670
+ // 只有确实存在 goal 才产事件:没有 goal 的会话保持事件序列不变(少一次
671
+ // 渲染,也不让"读不到"变成一张多出来的卡片事件)。
672
+ if (seedGoal.goal)
673
+ goalEvents.push(goalStateEvent(seedGoal.goal, true));
674
+ }
675
+ else {
676
+ // 读不到 ≠ 没有目标。必须显式把 unknown 交给卡片,否则收尾只能按
677
+ // 「没有 goal」写成「已完成」——正是 GoalStateEvent 契约禁止的推断。
678
+ goalEvents.push(goalStateEvent(null, false));
679
+ }
680
+ return { ok: true, lastGoal, goalKnown: seedGoal.known, goalEvents };
681
+ }
576
682
  /**
577
683
  * Consume one turn from both transports. `session/follow` owns transcript
578
684
  * events; `$events` owns the answerable approval waterfall. The merged
579
685
  * stream keeps either transport from starving the other.
686
+ *
687
+ * 事件源分派:projection(goal + 状态条)→ handleProjectionItem;
688
+ * $events waterfall(审批/提问)→ handleApprovalWaterfallItem;
689
+ * assistant-stream 直转发(translator 持有本 turn 的 usage 累计状态);
690
+ * session/follow 事件 → handleSessionEvent(含 result 收尾的 jobs + goal 复核)。
580
691
  */
581
692
  async *runTurn(sessionId, cwd, prePromptSeq, promptRequestId, abort, goal) {
582
693
  const streamAbort = new AbortController();
@@ -600,165 +711,41 @@ export class DshRunner {
600
711
  for await (const item of mergeAsync(sources)) {
601
712
  if (abort.signal.aborted)
602
713
  return;
714
+ // 事件源 1:Host-wide 投影(goal + 状态条)。
603
715
  if (item.kind === 'projection') {
604
- // 全 Host 流:只认本 session。未知 key 直接忽略(投影是能力发现式
605
- // 扩展的,不能因为多了个 key 就报错)。
606
- if (item.sessionId !== sessionId)
607
- continue;
608
- // goal 是会话级长期目标,单独成事件:卡片要在 agent 创建目标的当下
609
- // 就显示它(否则用户只会在一小时后看到「完成」才发现有个 40 轮任务)。
610
- if (item.key === 'goal') {
611
- const parsed = goalFromProjection(item.value);
612
- if (parsed !== undefined && !sameGoal(parsed, goal.last)) {
613
- goal.last = parsed;
614
- yield goalStateEvent(parsed, true);
615
- }
616
- continue;
617
- }
618
- // 只在消费字段真的变了时才产出事件:投影帧本身按 key 去重推送,
619
- // 这里再挡掉"值没变"的重复帧。不需要时间节流——卡片更新本来就有
620
- // 100ms 合批窗口,与正文 delta 共用同一条节流路径。
621
- if (!applyProjection(live, item.key, item.value))
622
- continue;
623
- yield statusEventOf(live);
624
- continue;
625
- }
626
- if (item.kind === 'ready') {
627
- this.approvalChannelAvailable = true;
628
- continue;
629
- }
630
- if (item.kind === 'unavailable') {
631
- this.approvalChannelAvailable = false;
632
- getLogger().warn(`[dsh] approval channel unavailable: ${item.message}`);
633
- continue;
634
- }
635
- if (item.kind === 'request') {
636
- this.approvalTracker.onWaterfall({
637
- eventId: item.eventId,
638
- clientId: item.clientId,
639
- ...(item.callId ? { callId: item.callId } : {}),
640
- toolName: item.toolName,
641
- ...(item.reason ? { reason: item.reason } : {}),
642
- });
643
- continue;
644
- }
645
- if (item.kind === 'question') {
646
- // 同一 waterfall 在重连后会带新的 clientId 重投:覆盖写回,卡片的
647
- // requestId(eventId)不变,coordinator 按重投语义刷新视图。
648
- this.pendingQuestions.set(item.eventId, {
649
- clientId: item.clientId,
650
- questions: item.questions,
651
- });
652
- getLogger().info(`[dsh] user question surfaced eventId=${item.eventId} questions=${item.questions.length}`);
653
- yield this.buildQuestionRequested(sessionId, item.eventId, item.questions);
654
- continue;
655
- }
656
- if (item.kind === 'cancelled') {
657
- // 提问与审批共用 cancel 帧:提问在别处(DSH Web UI)被回答或超时后
658
- // 也要清掉飞书卡片,否则上面还留着可点但已无 responder 的问题区。
659
- if (this.pendingQuestions.delete(item.eventId)) {
660
- getLogger().info(`[dsh] user question resolved elsewhere eventId=${item.eventId}`);
661
- yield this.approvalResolved(item.eventId);
662
- continue;
716
+ for (const ev of this.handleProjectionItem(item, sessionId, goal, live)) {
717
+ yield ev;
663
718
  }
664
- const binding = this.approvalTracker.onCancelled(item.eventId);
665
- if (binding)
666
- yield this.approvalResolved(binding.audit.approvalId);
667
719
  continue;
668
720
  }
669
- if (item.kind === 'approval') {
670
- const audit = {
671
- approvalId: item.approvalId,
672
- toolName: item.toolName,
673
- ...(item.callId ? { callId: item.callId } : {}),
674
- ...(item.reason ? { reason: item.reason } : {}),
675
- };
676
- const binding = this.approvalTracker.onAuditAsked(audit);
677
- if (!this.approvalChannelAvailable) {
678
- getLogger().warn(`[dsh] approval requested approvalId=${item.approvalId} tool=${item.toolName}; live channel unavailable — falling back to Web UI`);
679
- yield this.approvalFallbackText(item.toolName);
680
- continue;
721
+ // 事件源 2:$events 审批/提问 waterfall。
722
+ if (item.kind === 'ready' ||
723
+ item.kind === 'unavailable' ||
724
+ item.kind === 'request' ||
725
+ item.kind === 'question' ||
726
+ item.kind === 'cancelled' ||
727
+ item.kind === 'approval') {
728
+ for (const ev of this.handleApprovalWaterfallItem(item, sessionId, cwd, emittedApprovals)) {
729
+ yield ev;
681
730
  }
682
- if (emittedApprovals.has(item.approvalId))
683
- continue;
684
- emittedApprovals.add(item.approvalId);
685
- yield this.buildApprovalRequested(sessionId, audit, binding, cwd);
686
731
  continue;
687
732
  }
733
+ // 事件源 3a:assistant 流式帧(translator 持有本 turn 的 usage 累计状态,
734
+ // 必须与 3b 共用同一实例)。
688
735
  if (item.kind === 'assistant-stream') {
689
736
  for (const ev of translator.streamFrameToAgentEvents(item.frame, sessionId)) {
690
737
  yield ev;
691
738
  }
692
739
  continue;
693
740
  }
694
- const event = item.event;
695
- if (event.type === 'goal/change') {
696
- // 权威变更(create/edit/pause/resume/complete/block/clear):不等投影
697
- // 推送,直接按事件更新——agent 刚 create 的那一刻卡片就该显示目标。
698
- const parsed = goalFromChange(event.data);
699
- if (parsed !== undefined && !sameGoal(parsed, goal.last)) {
700
- goal.last = parsed;
701
- yield goalStateEvent(parsed, true);
702
- }
703
- continue;
704
- }
705
- if (event.type === 'tool/call') {
706
- const data = event.data;
707
- if (data.callId && data.name) {
708
- this.toolCalls.set(data.callId, {
709
- name: data.name,
710
- arguments: data.arguments ?? '',
711
- ...(data.turn === undefined ? {} : { turn: data.turn }),
712
- });
713
- if (this.toolCalls.size > 100) {
714
- const oldest = this.toolCalls.keys().next().value;
715
- if (oldest !== undefined)
716
- this.toolCalls.delete(oldest);
717
- }
718
- }
719
- // 记账后继续往下走:tool/call 同时是卡片工具行的来源(translator 翻
720
- // 译成 assistant.tool_use)。早期版本在这里 `continue`,把调用轨迹整个
721
- // 吞掉——卡片因此只有正文、没有任何工具行。
722
- }
723
- if (event.type === 'approval/decided') {
724
- const data = event.data;
725
- if (!data.id)
726
- continue;
727
- this.approvalTracker.onDecided(data.id);
728
- yield this.approvalResolved(data.id);
729
- continue;
730
- }
731
- for (const ev of translator.eventToAgentEvents(event, sessionId)) {
732
- if (ev.type === 'result') {
733
- if (!abort.signal.aborted) {
734
- for (const jobsEvent of await this.collectBackgroundJobs(sessionId, abort.signal)) {
735
- yield jobsEvent;
736
- }
737
- // 收尾复核:这一轮结束时 goal 是否仍 active,决定卡片能不能收尾。
738
- // 必须现读宿主,不能沿用过程中的最后一次观测——goal 可能在本轮
739
- // 最后一个 step 才被 complete,也可能刚被 driver 置为 blocked。
740
- const finalGoal = await this.client.goalState(sessionId, {
741
- signal,
742
- timeoutMs: GOAL_PROBE_TIMEOUT_MS,
743
- });
744
- if (!finalGoal.known) {
745
- // 收尾读不到:seed 已报过 unknown 就不重复发,否则把状态降级
746
- // 交给卡片(卡片据此不再宣布完成)。
747
- if (goal.known) {
748
- goal.known = false;
749
- yield goalStateEvent(null, false);
750
- }
751
- }
752
- else if (!goal.known || !sameGoal(finalGoal.goal, goal.last)) {
753
- goal.known = true;
754
- goal.last = finalGoal.goal;
755
- yield goalStateEvent(finalGoal.goal, true);
756
- }
757
- }
758
- yield attachContextLimit(ev, live.contextLimit);
759
- return;
760
- }
741
+ // 事件源 3b:session/follow 事件(正文/工具行/result 收尾,含 jobs +
742
+ // goal 复核)。result 之后的收尾事件由 handler 按序返回,见到 result
743
+ // 即结束本 turn(与原 yield-then-return 逐字等价)。
744
+ const eventEvents = await this.handleSessionEvent(item.event, sessionId, translator, goal, live, abort, signal);
745
+ for (const ev of eventEvents) {
761
746
  yield ev;
747
+ if (ev.type === 'result')
748
+ return;
762
749
  }
763
750
  }
764
751
  }
@@ -769,6 +756,116 @@ export class DshRunner {
769
756
  this.toolCalls.clear();
770
757
  }
771
758
  }
759
+ /**
760
+ * 事件源 handler:Host-wide 投影(goal + 状态条)。
761
+ * 返回要回放的事件(可能为空——未变化/非本 session 的帧不产事件)。
762
+ */
763
+ handleProjectionItem(item, sessionId, goal, live) {
764
+ // 全 Host 流:只认本 session。未知 key 直接忽略(投影是能力发现式
765
+ // 扩展的,不能因为多了个 key 就报错)。
766
+ if (item.sessionId !== sessionId)
767
+ return [];
768
+ // goal 是会话级长期目标,单独成事件:卡片要在 agent 创建目标的当下
769
+ // 就显示它(否则用户只会在一小时后看到「完成」才发现有个 40 轮任务)。
770
+ if (item.key === 'goal') {
771
+ const parsed = goalFromProjection(item.value);
772
+ if (parsed !== undefined && !sameGoal(parsed, goal.last)) {
773
+ goal.last = parsed;
774
+ return [goalStateEvent(parsed, true)];
775
+ }
776
+ return [];
777
+ }
778
+ // 只在消费字段真的变了时才产出事件:投影帧本身按 key 去重推送,
779
+ // 这里再挡掉"值没变"的重复帧。不需要时间节流——卡片更新本来就有
780
+ // 100ms 合批窗口,与正文 delta 共用同一条节流路径。
781
+ if (!applyProjection(live, item.key, item.value))
782
+ return [];
783
+ return [statusEventOf(live)];
784
+ }
785
+ /**
786
+ * 事件源 handler:$events 审批/提问 waterfall(channel 状态、审批 waterfall
787
+ * 登记、提问卡、cancel 回流、审批卡)。
788
+ * 返回要回放的事件;channel 状态类帧(ready/unavailable/request)只改内部
789
+ * 状态、不产事件。
790
+ */
791
+ handleApprovalWaterfallItem(item, sessionId, cwd, emittedApprovals) {
792
+ switch (item.kind) {
793
+ case 'ready':
794
+ this.approvalChannelAvailable = true;
795
+ return [];
796
+ case 'unavailable':
797
+ this.approvalChannelAvailable = false;
798
+ getLogger().warn(`[dsh] approval channel unavailable: ${item.message}`);
799
+ return [];
800
+ case 'request':
801
+ this.approvalTracker.onWaterfall({
802
+ eventId: item.eventId,
803
+ clientId: item.clientId,
804
+ ...(item.callId ? { callId: item.callId } : {}),
805
+ toolName: item.toolName,
806
+ ...(item.reason ? { reason: item.reason } : {}),
807
+ });
808
+ return [];
809
+ case 'question': {
810
+ // 同一 waterfall 在重连后会带新的 clientId 重投:覆盖写回,卡片的
811
+ // requestId(eventId)不变,coordinator 按重投语义刷新视图。
812
+ this.pendingQuestions.set(item.eventId, {
813
+ clientId: item.clientId,
814
+ questions: item.questions,
815
+ });
816
+ getLogger().info(`[dsh] user question surfaced eventId=${item.eventId} questions=${item.questions.length}`);
817
+ return [this.buildQuestionRequested(sessionId, item.eventId, item.questions)];
818
+ }
819
+ case 'cancelled': {
820
+ // 提问与审批共用 cancel 帧:提问在别处(DSH Web UI)被回答或超时后
821
+ // 也要清掉飞书卡片,否则上面还留着可点但已无 responder 的问题区。
822
+ if (this.pendingQuestions.delete(item.eventId)) {
823
+ getLogger().info(`[dsh] user question resolved elsewhere eventId=${item.eventId}`);
824
+ return [this.approvalResolved(item.eventId)];
825
+ }
826
+ const binding = this.approvalTracker.onCancelled(item.eventId);
827
+ return binding ? [this.approvalResolved(binding.audit.approvalId)] : [];
828
+ }
829
+ case 'approval': {
830
+ const audit = {
831
+ approvalId: item.approvalId,
832
+ toolName: item.toolName,
833
+ ...(item.callId ? { callId: item.callId } : {}),
834
+ ...(item.reason ? { reason: item.reason } : {}),
835
+ };
836
+ const binding = this.approvalTracker.onAuditAsked(audit);
837
+ if (!this.approvalChannelAvailable) {
838
+ getLogger().warn(`[dsh] approval requested approvalId=${item.approvalId} tool=${item.toolName}; live channel unavailable — falling back to Web UI`);
839
+ return [this.approvalFallbackText(item.toolName)];
840
+ }
841
+ if (emittedApprovals.has(item.approvalId))
842
+ return [];
843
+ emittedApprovals.add(item.approvalId);
844
+ return [this.buildApprovalRequested(sessionId, audit, binding, cwd)];
845
+ }
846
+ }
847
+ }
848
+ /**
849
+ * 事件源 handler:`session/follow` 会话事件。
850
+ * - goal/change:权威变更,不等投影直接更新;
851
+ * - tool/call:先记账(审批卡命令展示用)再落入 translator(卡片工具行来源);
852
+ * - approval/decided:审批回流,清审批卡;
853
+ * - 其余事件经 translator 翻译;result 事件先做收尾(后台任务名单 + goal
854
+ * 复核)再随 result 一起返回。
855
+ */
856
+ async handleSessionEvent(event, sessionId, translator, goal, live, abort, signal) {
857
+ const ctx = { sessionId, translator, goal, live, abort, signal };
858
+ const terminal = SESSION_EVENT_TERMINAL_HANDLERS.get(event.type);
859
+ if (terminal)
860
+ return terminal(this, event, ctx);
861
+ if (event.type === 'tool/call') {
862
+ // 记账后继续往下走:tool/call 同时是卡片工具行的来源(translator 翻
863
+ // 译成 assistant.tool_use)。早期版本在这里 `continue`,把调用轨迹整个
864
+ // 吞掉——卡片因此只有正文、没有任何工具行。
865
+ recordToolCall(this.toolCalls, event);
866
+ }
867
+ return collectAgentEvents(this, event, ctx);
868
+ }
772
869
  /**
773
870
  * 收尾时仍在跑的后台任务。
774
871
  *
@@ -1409,7 +1506,7 @@ export class DshRunner {
1409
1506
  *
1410
1507
  * turn 跑在常驻共享的 `dsh web` host 里,不在本进程的子进程树上;所以
1411
1508
  * "杀自己的子进程"这套退出清理对它无效,桥接一退出,turn 继续无人接收地跑
1412
- * (2026-10-19 实测:一个孤儿 turn 在桥接死后又跑了 2h21m / 345 个 step)。
1509
+ * (孤儿 turn 在桥接死后可能再跑数小时)。
1413
1510
  * 这里复用进程级退出分发器,用同步 curl 发一次 `session/cancel`。
1414
1511
  */
1415
1512
  registerExitHandlers() {
@@ -26,39 +26,9 @@
26
26
  import type { AgentEvent, ResultEvent } from '../types.js';
27
27
  import type { DshAssistantStreamFrame, DshSessionEvent, DshTokenUsage } from '../../dsh/types.js';
28
28
  export declare class DshTranslator {
29
- /** 本 turn 累计用量:DSH 每次模型调用(step)一条 usage,工具往返 = 多条。 */
30
- private turnUsage;
31
- /** 末次采样的 usage:上下文水位,或只有 live chunk 时的兜底值。 */
32
- private lastUsage?;
33
- /** 是否收到过 durable assistant/message.usage(唯一累加来源)。 */
34
- private hasMessageUsage;
35
- /** 是否只收到过 live usage chunk(无 durable message usage 时的兜底)。 */
36
- private hasStreamUsage;
37
- /** 当前 attempt 的 id(start 帧携带,chunk 帧也带;断开重连重放时不变)。 */
38
- private attemptId;
39
- /**
40
- * 本 turn 末条 durable assistant 消息的正文(**完整**、未截断)。
41
- *
42
- * live chunk 是逐 delta 拼接、会被状态层的 MAX_TEXT_CHARS 窗口截断;
43
- * durable `assistant/message` 一次就到齐全文。收 turn 时把它挂在 result 上,
44
- * 卡片才能渲染完整回答(2026-11-01 输出块被截断缺陷)。
45
- */
46
- private lastMessageText?;
29
+ private readonly state;
47
30
  /** Translate one live assistant-stream chunk into AgentEvents. */
48
31
  streamFrameToAgentEvents(frame: DshAssistantStreamFrame, _sessionId: string): AgentEvent[];
49
- /**
50
- * Record durable per-step usage — the accumulation source. assistant/message
51
- * carries exactly one usage per model call and is seq-deduped by the client,
52
- * so summing it gives the turn total without double counting.
53
- */
54
- private recordMessageUsage;
55
- /**
56
- * Record a live usage chunk. Never summed: the same step also arrives as a
57
- * durable assistant/message.usage, so summing both would double count. It only
58
- * refreshes the watermark and covers a turn whose messages never persisted.
59
- */
60
- private recordStreamUsage;
61
- private chunkToAgentEvents;
62
32
  /** Translate one session event into AgentEvents (may be empty). */
63
33
  eventToAgentEvents(ev: DshSessionEvent, sessionId: string): AgentEvent[];
64
34
  }