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
@@ -6,17 +6,25 @@
6
6
  * alignment → synthetic system/init (DSH has no init event) → session/prompt
7
7
  * (mode:'queue') → consume `session/follow` + `$events` until turn/end.
8
8
  *
9
- * stop() → session/cancel + abort the mux subscription. killOrphan /
10
- * registerExitHandlers are no-ops (no local process). DSH approvals are
9
+ * stop() → session/cancel + abort the mux subscription. killOrphan is a no-op
10
+ * (no local process); registerExitHandlers cancels the in-flight turn
11
+ * synchronously on process exit, because the turn lives in the shared host and
12
+ * would otherwise keep running with no subscriber. DSH approvals are
11
13
  * two-channel: durable `approval/asked` / `approval/decided` drive the Feishu
12
14
  * card, while the answerable `$events` waterfall supplies eventId/clientId.
13
- * If `$events` is unavailable, the runner falls back to the Web UI prompt.
15
+ * `ask_user_question` prompts arrive only as a `user-questions/request`
16
+ * waterfall (no durable event); they are rendered as a `kind:'question'`
17
+ * approval on the same card. If `$events` is unavailable, the runner falls
18
+ * back to the Web UI prompt.
14
19
  */
15
20
  import { DshClient, DshError, newPromptRequestId } from './client.js';
16
21
  import { DshTranslator } from './translator.js';
17
22
  import { DshApprovalTracker } from './approval.js';
18
23
  import { mergeAsync } from './merge.js';
19
24
  import { getLogger } from '../../logger/index.js';
25
+ import { registerExitCleanup, unregisterExitCleanup } from '../common/spawning-runner.js';
26
+ import { cancelSessionSync } from './exit-cancel.js';
27
+ import { DshInflightStore } from './inflight.js';
20
28
  /**
21
29
  * Legacy provider for configs written before `agents.dsh.provider` existed.
22
30
  * New configs pin the provider explicitly; the card reads the live catalog.
@@ -24,8 +32,132 @@ import { getLogger } from '../../logger/index.js';
24
32
  const DSH_MODEL_PROVIDER = 'deepseek-official';
25
33
  /** DSH 内建手动压缩命令(dsh-command-compact,无参数、仅空闲可执行)。 */
26
34
  const DSH_COMPACT_COMMAND = '/compact';
27
- /** Budget for the fire-and-forget session/cancel on stop (never block /stop). */
35
+ /**
36
+ * 飞书提问卡的有效窗口。
37
+ *
38
+ * DSH 的 legacy `ask_user_question` 会无限等待(`timed:false`),Web UI 那侧
39
+ * 也一直在等。飞书卡超时后只把这一路的应答权交回(outcome `next`),不取消
40
+ * turn,所以窗口比审批默认的 5 分钟给得宽:用户通常要先读完上一条长回答。
41
+ * 超时后问题仍可在 DSH Web UI 回答;之后由 idle 看门狗兜底(默认 15 分钟
42
+ * 静默,见 `config.idle.watchdogMinutes`,等待提问期间暂停计时)。
43
+ */
44
+ const DSH_QUESTION_TIMEOUT_MS = 30 * 60 * 1000;
45
+ /**
46
+ * 卡片上实际生效的多选语义:无选项的 multiSelect 没有可勾内容,共享渲染层把它
47
+ * 当自由文本题,coordinator 的 answerCustom 又拒绝 multiSelect——所以按单选处理,
48
+ * 视图与答案编码必须用同一个判定,否则会编码出错答案。
49
+ */
50
+ function isCardMultiSelect(question) {
51
+ return question.multiSelect === true && (question.options?.length ?? 0) > 0;
52
+ }
53
+ /**
54
+ * 把 coordinator 回传的「问题文本 → 答案」映射成 DSH 的 `{id, selected,
55
+ * custom}` 批次。
56
+ *
57
+ * 契约见 `UserQuestion`:答案按问题文本回传;本函数按 id 回编。单选且文本
58
+ * 不等于任何选项 label 时,按 DSH Web UI 的语义记为自由答案(`selected: []`
59
+ * + `custom`)。
60
+ */
61
+ function buildQuestionAnswers(questions, response) {
62
+ const answers = response
63
+ ?.answers;
64
+ if (!answers)
65
+ throw new Error('DSH 提问回答缺少 answers 载荷');
66
+ return questions.map((question) => {
67
+ const value = answers[question.question];
68
+ if (value === undefined)
69
+ throw new Error(`DSH 提问缺少答案: ${question.question}`);
70
+ const values = (Array.isArray(value) ? value : [value]).filter((item) => typeof item === 'string' && item.length > 0);
71
+ if (values.length === 0)
72
+ throw new Error(`DSH 提问答案为空: ${question.question}`);
73
+ if (isCardMultiSelect(question))
74
+ return { id: question.id, selected: values };
75
+ const selected = values[0];
76
+ const isOption = (question.options ?? []).some((option) => option.label === selected);
77
+ return isOption
78
+ ? { id: question.id, selected: [selected] }
79
+ : { id: question.id, selected: [], custom: selected };
80
+ });
81
+ }
82
+ /** Budget for one session/cancel attempt on stop. */
28
83
  const CANCEL_TIMEOUT_MS = 5_000;
84
+ /** Total stop budget: withdraw + cancel + verify must not hold the lane forever. */
85
+ const STOP_BUDGET_MS = 5_000;
86
+ /** A prompt request that was still in flight can land after stop's abort. */
87
+ const STOP_ADMIT_ATTEMPTS = 5;
88
+ const STOP_ADMIT_DELAY_MS = 250;
89
+ /** Cancel is applied synchronously; give the turn a moment to unwind. */
90
+ const STOP_VERIFY_ATTEMPTS = 4;
91
+ const STOP_VERIFY_DELAY_MS = 350;
92
+ /** Per-read budget for inbox/status probes on the stop path. */
93
+ const STOP_PROBE_TIMEOUT_MS = 2_000;
94
+ function delay(ms) {
95
+ return new Promise((resolve) => setTimeout(resolve, ms));
96
+ }
97
+ /**
98
+ * 把一帧 `session/control` 投影写进状态条累计值。
99
+ * @returns 本仓库消费的字段是否真的变了(未变不必刷新卡片)。
100
+ */
101
+ function applyProjection(live, key, value) {
102
+ if (!value || typeof value !== 'object')
103
+ return false;
104
+ if (key === 'contextPressure') {
105
+ const pressure = value;
106
+ // 官方 UI 的口径:优先 projectedTokens(下一次请求的预估),退回实际占用。
107
+ const used = pressure.projectedTokens ?? pressure.pressureTokens;
108
+ let changed = false;
109
+ if (typeof used === 'number' && used !== live.contextUsed) {
110
+ live.contextUsed = used;
111
+ changed = true;
112
+ }
113
+ if (typeof pressure.contextWindow === 'number' &&
114
+ pressure.contextWindow > 0 &&
115
+ pressure.contextWindow !== live.contextLimit) {
116
+ live.contextLimit = pressure.contextWindow;
117
+ changed = true;
118
+ }
119
+ return changed;
120
+ }
121
+ if (key === 'sessionStats') {
122
+ const stats = value;
123
+ let changed = false;
124
+ if (typeof stats.steps === 'number' && stats.steps !== live.steps) {
125
+ live.steps = stats.steps;
126
+ changed = true;
127
+ }
128
+ if (typeof stats.toolMs === 'number' && stats.toolMs !== live.toolMs) {
129
+ live.toolMs = stats.toolMs;
130
+ changed = true;
131
+ }
132
+ return changed;
133
+ }
134
+ return false;
135
+ }
136
+ /** Build the `run_status` event for the card's status strip. */
137
+ function statusEventOf(live) {
138
+ return {
139
+ type: 'run_status',
140
+ ...(live.contextUsed === undefined ? {} : { contextUsed: live.contextUsed }),
141
+ ...(live.contextLimit === undefined ? {} : { contextLimit: live.contextLimit }),
142
+ ...(live.steps === undefined ? {} : { steps: live.steps }),
143
+ ...(live.toolMs === undefined ? {} : { toolMs: live.toolMs }),
144
+ timestamp: new Date().toISOString(),
145
+ };
146
+ }
147
+ /**
148
+ * Attach the context window to the result usage.
149
+ *
150
+ * DSH 的会话读取端只产出占用、不产出窗口上限,终态卡因此只能显示绝对量;
151
+ * 控制流拿到的 `contextWindow` 是唯一的百分比来源。已有上限(未来 DSH 自带)
152
+ * 时不覆盖。
153
+ */
154
+ function attachContextLimit(ev, limit) {
155
+ if (ev.type !== 'result' || limit === undefined || !ev.usage)
156
+ return ev;
157
+ if (ev.usage.context_limit !== undefined)
158
+ return ev;
159
+ return { ...ev, usage: { ...ev.usage, context_limit: limit } };
160
+ }
29
161
  /** Parse tool-call arguments without letting malformed JSON crash the run. */
30
162
  function parseToolArguments(raw) {
31
163
  if (!raw)
@@ -62,6 +194,19 @@ export class DshRunner {
62
194
  activeAbort = null;
63
195
  activeSessionId = null;
64
196
  running = false;
197
+ /** This run's prompt identity (`source.rpcId`); null outside a submitted prompt. */
198
+ activePromptRequestId = null;
199
+ /** Whether the current prompt request was accepted by the host. */
200
+ promptAdmitted = false;
201
+ /**
202
+ * Releases the next run's prompt only after this stop's withdraw/cancel has
203
+ * been applied. Aborting the local mux lets the queue advance immediately;
204
+ * without the gate the successor's prompt can reach the host before the
205
+ * cancel, and a session-scoped cancel would then hit the *new* turn.
206
+ */
207
+ pendingStopGate = null;
208
+ pendingStopSessionId = null;
209
+ inflight;
65
210
  daemon;
66
211
  /**
67
212
  * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
@@ -74,10 +219,19 @@ export class DshRunner {
74
219
  permissionAlignedSessions = new Set();
75
220
  /** `$events` approval waterfall state for the current run. */
76
221
  approvalTracker = new DshApprovalTracker();
222
+ /**
223
+ * eventId → 待回答的 DSH `ask_user_question` waterfall。
224
+ *
225
+ * 提问没有 durable 审计事件,eventId 本身就是卡片 requestId;这里保存应答
226
+ * 所需的 clientId 与题面(DSH 回编按题目 id)。每轮 run 清空。
227
+ */
228
+ pendingQuestions = new Map();
77
229
  /** callId → tool call, used to render the command in the approval card. */
78
230
  toolCalls = new Map();
79
231
  /** False after `$events` is unavailable; approval/asked then falls back to text. */
80
232
  approvalChannelAvailable = true;
233
+ /** 退出路径的取消实现(默认同步 curl,见 `cancelSessionSync`)。 */
234
+ cancelOnExit;
81
235
  constructor(opts) {
82
236
  this.sessionReader = opts.sessionReader;
83
237
  this.daemon = opts.daemon;
@@ -86,6 +240,7 @@ export class DshRunner {
86
240
  this.agentPreset = opts.agentPreset;
87
241
  this.configuredPermissionPreset = opts.permissionPreset;
88
242
  this.reasoningEffort = opts.reasoningEffort;
243
+ this.inflight = opts.inflightPath ? new DshInflightStore(opts.inflightPath) : null;
89
244
  this.client = new DshClient(opts.host ?? 'http://127.0.0.1:3080', {
90
245
  dshHome: opts.dshHome,
91
246
  token: opts.token,
@@ -94,6 +249,16 @@ export class DshRunner {
94
249
  webSocketImpl: opts.webSocketImpl,
95
250
  requestTimeoutMs: opts.requestTimeoutMs,
96
251
  });
252
+ this.cancelOnExit =
253
+ opts.cancelOnExit ??
254
+ (({ baseUrl, sessionId }) => {
255
+ cancelSessionSync({
256
+ baseUrl,
257
+ sessionId,
258
+ dshHome: opts.dshHome,
259
+ credentialsFile: opts.credentialsFile,
260
+ });
261
+ });
97
262
  }
98
263
  get isRunning() {
99
264
  return this.running;
@@ -159,8 +324,13 @@ export class DshRunner {
159
324
  const abort = new AbortController();
160
325
  this.activeAbort = abort;
161
326
  this.running = true;
327
+ // A stop releases the local lane before its cancel has been applied; the
328
+ // next prompt must not race that cancel (see `pendingStopGate`). State is
329
+ // already published above so an immediate stop can abort this run too.
330
+ await this.awaitStopGate(opts.sessionId);
162
331
  let sessionId = opts.sessionId;
163
332
  let terminalEmitted = false;
333
+ let promptRequestId = null;
164
334
  try {
165
335
  // DSH is a shared, machine-wide process. Reuse it when it is already
166
336
  // listening; otherwise start it detached so lark-remote exiting does not
@@ -212,7 +382,7 @@ export class DshRunner {
212
382
  };
213
383
  return;
214
384
  }
215
- // provider/模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
385
+ // provider/模型/推理强度对齐:会话首次 run 时 selectModel 一次
216
386
  // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
217
387
  // 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
218
388
  // provider 会让用户以为切换成功,实际模型与账单都错。
@@ -271,12 +441,28 @@ export class DshRunner {
271
441
  const prePromptSeq = await this.client.latestEventSeq(sessionId, {
272
442
  signal: abort.signal,
273
443
  });
444
+ // Keep the prompt identity: DSH echoes it into the durable user/message
445
+ // source.rpcId, which is how runTurn tells this run's turn from a turn
446
+ // it is merely queued behind.
447
+ promptRequestId = newPromptRequestId();
448
+ // Publish the identity before the request leaves: a stop that arrives
449
+ // while `session/prompt` is in flight must still know which inbox item
450
+ // (if any) to withdraw once the host admits it.
451
+ this.activePromptRequestId = promptRequestId;
452
+ this.promptAdmitted = false;
453
+ this.inflight?.record({
454
+ sessionId,
455
+ promptRequestId,
456
+ baseUrl: this.client.baseUrl,
457
+ cwd: opts.cwd,
458
+ });
274
459
  await this.client.unary('session/prompt', {
275
- requestId: newPromptRequestId(),
460
+ requestId: promptRequestId,
276
461
  sessionId,
277
462
  mode: 'queue',
278
463
  content: [{ type: 'text', text: message }],
279
464
  }, { signal: abort.signal });
465
+ this.promptAdmitted = true;
280
466
  if (abort.signal.aborted) {
281
467
  // stop() 在 prompt 已受理后到达:cancel 由 stop() 发出,这里直接收尾,
282
468
  // 不再打开 mux(否则会看到 cancel 产生的 turn/end 事件)。
@@ -290,7 +476,7 @@ export class DshRunner {
290
476
  return;
291
477
  }
292
478
  try {
293
- for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, abort)) {
479
+ for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, promptRequestId, abort)) {
294
480
  yield ev;
295
481
  if (ev.type === 'result') {
296
482
  terminalEmitted = true;
@@ -341,6 +527,14 @@ export class DshRunner {
341
527
  this.running = false;
342
528
  this.activeSessionId = null;
343
529
  this.activeAbort = null;
530
+ this.activePromptRequestId = null;
531
+ this.promptAdmitted = false;
532
+ // On abort the stop path decides whether the marker is settled (verified
533
+ // stop) or must survive for startup reconciliation (cancel failed). A
534
+ // naturally finished run owns its terminal result, so drop it here.
535
+ if (!abort.signal.aborted && sessionId && promptRequestId) {
536
+ this.inflight?.clear(sessionId, promptRequestId);
537
+ }
344
538
  }
345
539
  if (!terminalEmitted) {
346
540
  yield {
@@ -356,22 +550,41 @@ export class DshRunner {
356
550
  * events; `$events` owns the answerable approval waterfall. The merged
357
551
  * stream keeps either transport from starving the other.
358
552
  */
359
- async *runTurn(sessionId, cwd, prePromptSeq, abort) {
553
+ async *runTurn(sessionId, cwd, prePromptSeq, promptRequestId, abort) {
360
554
  const streamAbort = new AbortController();
361
555
  const signal = AbortSignal.any([abort.signal, streamAbort.signal]);
362
556
  const translator = new DshTranslator();
363
557
  const emittedApprovals = new Set();
364
558
  this.approvalTracker.clear();
559
+ this.pendingQuestions.clear();
365
560
  this.toolCalls.clear();
366
561
  this.approvalChannelAvailable = true;
562
+ // 状态条累计值(contextPressure / sessionStats 投影)。`contextWindow` 同时
563
+ // 用于给 result.usage.context_limit 兜底:DSH 的会话读取端不产出上限,
564
+ // 没有它终态卡只能显示绝对量、算不出百分比。
565
+ const live = {};
367
566
  try {
368
567
  const sources = [
369
- this.client.sessionEvents(sessionId, signal, prePromptSeq),
568
+ this.client.sessionEvents(sessionId, signal, prePromptSeq, promptRequestId),
370
569
  this.client.approvalEvents(sessionId, signal),
570
+ this.client.controlFrames(signal),
371
571
  ];
372
572
  for await (const item of mergeAsync(sources)) {
373
573
  if (abort.signal.aborted)
374
574
  return;
575
+ if (item.kind === 'projection') {
576
+ // 全 Host 流:只认本 session。未知 key 直接忽略(投影是能力发现式
577
+ // 扩展的,不能因为多了个 key 就报错)。
578
+ if (item.sessionId !== sessionId)
579
+ continue;
580
+ // 只在消费字段真的变了时才产出事件:投影帧本身按 key 去重推送,
581
+ // 这里再挡掉"值没变"的重复帧。不需要时间节流——卡片更新本来就有
582
+ // 100ms 合批窗口,与正文 delta 共用同一条节流路径。
583
+ if (!applyProjection(live, item.key, item.value))
584
+ continue;
585
+ yield statusEventOf(live);
586
+ continue;
587
+ }
375
588
  if (item.kind === 'ready') {
376
589
  this.approvalChannelAvailable = true;
377
590
  continue;
@@ -391,7 +604,25 @@ export class DshRunner {
391
604
  });
392
605
  continue;
393
606
  }
607
+ if (item.kind === 'question') {
608
+ // 同一 waterfall 在重连后会带新的 clientId 重投:覆盖写回,卡片的
609
+ // requestId(eventId)不变,coordinator 按重投语义刷新视图。
610
+ this.pendingQuestions.set(item.eventId, {
611
+ clientId: item.clientId,
612
+ questions: item.questions,
613
+ });
614
+ getLogger().info(`[dsh] user question surfaced eventId=${item.eventId} questions=${item.questions.length}`);
615
+ yield this.buildQuestionRequested(sessionId, item.eventId, item.questions);
616
+ continue;
617
+ }
394
618
  if (item.kind === 'cancelled') {
619
+ // 提问与审批共用 cancel 帧:提问在别处(DSH Web UI)被回答或超时后
620
+ // 也要清掉飞书卡片,否则上面还留着可点但已无 responder 的问题区。
621
+ if (this.pendingQuestions.delete(item.eventId)) {
622
+ getLogger().info(`[dsh] user question resolved elsewhere eventId=${item.eventId}`);
623
+ yield this.approvalResolved(item.eventId);
624
+ continue;
625
+ }
395
626
  const binding = this.approvalTracker.onCancelled(item.eventId);
396
627
  if (binding)
397
628
  yield this.approvalResolved(binding.audit.approvalId);
@@ -437,7 +668,9 @@ export class DshRunner {
437
668
  this.toolCalls.delete(oldest);
438
669
  }
439
670
  }
440
- continue;
671
+ // 记账后继续往下走:tool/call 同时是卡片工具行的来源(translator 翻
672
+ // 译成 assistant.tool_use)。早期版本在这里 `continue`,把调用轨迹整个
673
+ // 吞掉——卡片因此只有正文、没有任何工具行。
441
674
  }
442
675
  if (event.type === 'approval/decided') {
443
676
  const data = event.data;
@@ -448,18 +681,57 @@ export class DshRunner {
448
681
  continue;
449
682
  }
450
683
  for (const ev of translator.eventToAgentEvents(event, sessionId)) {
451
- yield ev;
452
- if (ev.type === 'result')
684
+ if (ev.type === 'result') {
685
+ if (!abort.signal.aborted) {
686
+ for (const jobsEvent of await this.collectBackgroundJobs(sessionId, abort.signal)) {
687
+ yield jobsEvent;
688
+ }
689
+ }
690
+ yield attachContextLimit(ev, live.contextLimit);
453
691
  return;
692
+ }
693
+ yield ev;
454
694
  }
455
695
  }
456
696
  }
457
697
  finally {
458
698
  streamAbort.abort();
459
699
  this.approvalTracker.clear();
700
+ this.pendingQuestions.clear();
460
701
  this.toolCalls.clear();
461
702
  }
462
703
  }
704
+ /**
705
+ * 收尾时仍在跑的后台任务。
706
+ *
707
+ * 只保留非终态(running/stopping):已完成的任务由 agent 自己的 output 呈
708
+ * 现,卡片列出来只会误导"还没跑完"。`job/list` 拿不到时返回空数组——没有
709
+ * 任务列表不等于有任务在跑。
710
+ */
711
+ async collectBackgroundJobs(sessionId, signal) {
712
+ const rows = await this.client.jobRows(sessionId, { signal });
713
+ if (!rows)
714
+ return [];
715
+ const live = rows.filter((job) => job.status === 'running' || job.status === 'stopping');
716
+ if (live.length === 0)
717
+ return [];
718
+ return [
719
+ {
720
+ type: 'background_jobs',
721
+ jobs: live.map((job) => ({
722
+ id: job.id,
723
+ label: job.label ?? job.id,
724
+ ...(job.status === undefined ? {} : { status: job.status }),
725
+ ...(job.progress === undefined ? {} : { progress: job.progress }),
726
+ })),
727
+ timestamp: new Date().toISOString(),
728
+ },
729
+ ];
730
+ }
731
+ /** Kill one background job of this runner's session (card button path). */
732
+ async killJob(sessionId, jobId) {
733
+ return await this.client.jobKill(sessionId, jobId);
734
+ }
463
735
  /** Apply the configured permission preset once per session, before prompt. */
464
736
  async alignPermissionPreset(sessionId, signal) {
465
737
  const preset = this.configuredPermissionPreset;
@@ -482,11 +754,22 @@ export class DshRunner {
482
754
  getLogger().info(`[dsh] permission preset updated to ${settings.permissionPreset ?? '(follow default)'}; applies to the next run`);
483
755
  }
484
756
  /**
485
- * Answer one DSH approval. `accept` / `decline` are the only user decisions
486
- * exposed by the DSH Web UI; `cancel` stops the turn through session/cancel.
757
+ * Answer one DSH approval or `ask_user_question` prompt.
758
+ *
759
+ * Approvals: `accept` / `decline` are the only user decisions exposed by the
760
+ * DSH Web UI; `cancel` stops the turn through session/cancel.
761
+ *
762
+ * Questions: `answer` / `decline`(跳过回答)resolve the `user-questions/request`
763
+ * waterfall with an answer batch; `cancel`(飞书卡超时)只把这一路交回
764
+ * `next`,让 DSH Web UI 仍可回答,不取消 turn。
487
765
  */
488
766
  async respondApproval(requestId, response) {
489
767
  const approvalId = String(requestId);
768
+ const question = this.pendingQuestions.get(approvalId);
769
+ if (question) {
770
+ await this.respondQuestion(approvalId, question, response);
771
+ return;
772
+ }
490
773
  const binding = this.approvalTracker.getBinding(approvalId) ??
491
774
  (await this.approvalTracker.waitForBinding(approvalId, 2_000, this.activeAbort?.signal));
492
775
  if (!binding) {
@@ -508,6 +791,41 @@ export class DshRunner {
508
791
  this.approvalTracker.onDecided(approvalId);
509
792
  getLogger().info(`[dsh] approval responded approvalId=${approvalId} action=${String(action)} eventId=${binding.waterfall.eventId}`);
510
793
  }
794
+ /** Resolve one pending `ask_user_question` waterfall from a card action. */
795
+ async respondQuestion(eventId, pending, response) {
796
+ const action = response?.action;
797
+ let outcome;
798
+ if (action === 'answer') {
799
+ outcome = {
800
+ kind: 'result',
801
+ value: { answers: buildQuestionAnswers(pending.questions, response) },
802
+ };
803
+ }
804
+ else if (action === 'decline') {
805
+ // 「跳过回答」:与 DSH Web UI 的 skip 同语义,逐题回空 selected,
806
+ // 工具把这次提问当作已跳过,agent 拿到结果后继续。
807
+ outcome = {
808
+ kind: 'result',
809
+ value: {
810
+ answers: pending.questions.map((question) => ({ id: question.id, selected: [] })),
811
+ },
812
+ };
813
+ }
814
+ else if (action === 'cancel') {
815
+ // 飞书卡超时/放弃:只交回这一路的应答权(outcome next)。DSH Web UI
816
+ // 仍是 owner Client 且持有该 waterfall,可以继续回答;没有其他 Client
817
+ // 时才 settle 为 next(工具报 no user-questions answerer)。
818
+ outcome = { kind: 'next' };
819
+ }
820
+ else {
821
+ throw new Error(`DSH 提问不支持审批决策: ${String(action)}`);
822
+ }
823
+ await this.client.answerRemoteEvent(pending.clientId, eventId, outcome, {
824
+ signal: this.activeAbort?.signal,
825
+ });
826
+ this.pendingQuestions.delete(eventId);
827
+ getLogger().info(`[dsh] user question responded eventId=${eventId} action=${String(action)}`);
828
+ }
511
829
  buildApprovalRequested(sessionId, audit, binding, cwd) {
512
830
  const call = audit.callId ? this.toolCalls.get(audit.callId) : undefined;
513
831
  const args = parseToolArguments(call?.arguments);
@@ -538,6 +856,51 @@ export class DshRunner {
538
856
  timestamp: new Date().toISOString(),
539
857
  };
540
858
  }
859
+ /**
860
+ * Render one `user-questions/request` waterfall as a `kind:'question'`
861
+ * approval area. The card renderer is agent-agnostic: it draws options,
862
+ * the Other input, multi-select submit and skip from this view alone.
863
+ */
864
+ buildQuestionRequested(sessionId, eventId, questions) {
865
+ const view = {
866
+ requestId: eventId,
867
+ kind: 'question',
868
+ note: '仅影响本次回答;若飞书提交失败,请到 DSH Web UI 处理。',
869
+ // 提问不是审批:超时只是把这一路交回 Web UI,卡上要如实说清还能去哪答。
870
+ expiredHint: '仍可在 DSH Web UI 继续回答;本卡不再接收回答。',
871
+ questions: questions.map((question) => {
872
+ const options = (question.options ?? []).map((option) => ({
873
+ label: option.label,
874
+ ...(option.description ? { description: option.description } : {}),
875
+ }));
876
+ return {
877
+ id: question.id,
878
+ question: question.question,
879
+ // plan-review 提问把计划全文放在 detail;丢了就成了「看不见计划就批准」。
880
+ ...(question.detail ? { detail: question.detail } : {}),
881
+ ...(question.header ? { header: question.header } : {}),
882
+ // 共享渲染层把「无选项」当自由文本题,而 coordinator 的 answerCustom
883
+ // 拒绝 multiSelect——无选项的多选在卡片上等于无解,按单选渲染。
884
+ ...(isCardMultiSelect(question) ? { multiSelect: true } : {}),
885
+ options,
886
+ };
887
+ }),
888
+ availableDecisions: [],
889
+ };
890
+ return {
891
+ type: 'approval_requested',
892
+ requestId: eventId,
893
+ kind: 'question',
894
+ threadId: sessionId,
895
+ turnId: '',
896
+ itemId: eventId,
897
+ view,
898
+ // legacy ask_user_question 没有服务端期限;给飞书卡一个宽窗口,
899
+ // 超时只交回应答权、不取消 turn(见 respondQuestion)。
900
+ timeoutMs: DSH_QUESTION_TIMEOUT_MS,
901
+ timestamp: new Date().toISOString(),
902
+ };
903
+ }
541
904
  approvalResolved(requestId) {
542
905
  return {
543
906
  type: 'approval_resolved',
@@ -583,6 +946,9 @@ export class DshRunner {
583
946
  this.running = true;
584
947
  this.activeSessionId = sessionId;
585
948
  let terminalEmitted = false;
949
+ // Same barrier as run(): a stop of this session must reach the host before
950
+ // the next compact is submitted.
951
+ await this.awaitStopGate(sessionId);
586
952
  try {
587
953
  if (this.daemon)
588
954
  await this.daemon.ensure();
@@ -593,6 +959,19 @@ export class DshRunner {
593
959
  cwd: opts.cwd,
594
960
  model: this.configuredModel ?? 'DSH',
595
961
  };
962
+ // 桥接重启后内存里没有在途 run,但服务端可能还在跑上一轮:先用只读
963
+ // 探测兜底,别把注定被拒的 /compact 发出去(DSH 只回一句把「有压缩
964
+ // 在跑」和「agent 不空闲」合并在一起的文案,用户看不出真实原因)。
965
+ if (await this.client.hasOpenTurn(sessionId, { signal: abort.signal })) {
966
+ terminalEmitted = true;
967
+ yield {
968
+ type: 'result',
969
+ subtype: 'error',
970
+ session_id: sessionId,
971
+ errorMessage: '当前会话还有一轮在跑(DSH 服务端未空闲),等它结束后再压缩',
972
+ };
973
+ return;
974
+ }
596
975
  const outcome = await this.client.executeCommand(sessionId, DSH_COMPACT_COMMAND, {
597
976
  signal: abort.signal,
598
977
  // 压缩是一次长 unary(服务端等压缩结束才回),60s 默认预算会误杀;
@@ -703,26 +1082,233 @@ export class DshRunner {
703
1082
  getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
704
1083
  }
705
1084
  }
1085
+ /**
1086
+ * Stop this run's work in the shared host.
1087
+ *
1088
+ * Two distinct things can be running, and DSH needs a different call for each:
1089
+ *
1090
+ * 1. A prompt that was accepted but not claimed yet still sits in the host
1091
+ * inbox. `session/cancel` would NOT drop it (`keepInbox`), so the turn would
1092
+ * start later with no subscriber — an orphan. Withdraw it by `rpcId` via
1093
+ * `session/updateQueue` instead, and leave the (foreign) active turn alone.
1094
+ * 2. An already-claimed turn (or a compact) can only be stopped with
1095
+ * `session/cancel`. Since that request is session-scoped and only an
1096
+ * acknowledgement, verify the session went idle before reporting success.
1097
+ *
1098
+ * The gate is held until the host-side action has landed. For a cancel that
1099
+ * includes the verification (and possible retry), because a retry cancel is
1100
+ * session-scoped and would otherwise hit the successor's new turn.
1101
+ */
706
1102
  async stop(_opts) {
707
1103
  const sessionId = this.activeSessionId;
1104
+ const promptRequestId = this.activePromptRequestId;
1105
+ const promptAdmitted = this.promptAdmitted;
708
1106
  const abort = this.activeAbort;
1107
+ // Free the local mux/queue first; the host-side stop continues below.
709
1108
  if (abort && !abort.signal.aborted)
710
1109
  abort.abort();
711
- if (sessionId) {
712
- // Do NOT reuse the run's (already aborted) signal: cancel must be sent,
713
- // but bounded — a hung server must not block /stop or the queue.
714
- await this.cancelSessionQuietly(sessionId);
715
- getLogger().info(`[dsh] cancelled session ${sessionId}`);
1110
+ if (!sessionId)
1111
+ return { status: 'noop' };
1112
+ let releaseGate = () => { };
1113
+ const gate = new Promise((resolve) => {
1114
+ releaseGate = resolve;
1115
+ });
1116
+ this.pendingStopGate = gate;
1117
+ this.pendingStopSessionId = sessionId;
1118
+ let settled = false;
1119
+ const release = () => {
1120
+ if (settled)
1121
+ return;
1122
+ settled = true;
1123
+ releaseGate();
1124
+ };
1125
+ try {
1126
+ return await this.performStop(sessionId, promptRequestId, promptAdmitted, release);
1127
+ }
1128
+ finally {
1129
+ // Safety net for early returns/throws; performStop normally releases first.
1130
+ release();
1131
+ if (this.pendingStopGate === gate) {
1132
+ this.pendingStopGate = null;
1133
+ this.pendingStopSessionId = null;
1134
+ }
1135
+ }
1136
+ }
1137
+ /** Wait for a concurrent stop to reach the host before submitting a new prompt. */
1138
+ async awaitStopGate(sessionId) {
1139
+ const gate = this.pendingStopGate;
1140
+ if (!gate)
1141
+ return;
1142
+ if (sessionId === undefined ||
1143
+ this.pendingStopSessionId === null ||
1144
+ sessionId !== this.pendingStopSessionId) {
1145
+ return;
1146
+ }
1147
+ await gate;
1148
+ }
1149
+ async performStop(sessionId, promptRequestId, promptAdmitted, releaseGate) {
1150
+ const deadline = Date.now() + STOP_BUDGET_MS;
1151
+ try {
1152
+ if (promptRequestId) {
1153
+ const itemId = await this.findPendingPromptWithin(sessionId, promptRequestId, promptAdmitted, deadline);
1154
+ if (itemId && (await this.removePendingPrompt(sessionId, itemId, deadline))) {
1155
+ releaseGate();
1156
+ this.inflight?.clear(sessionId, promptRequestId);
1157
+ getLogger().info(`[dsh] stop withdrew queued prompt session=${sessionId}`);
1158
+ return { status: 'withdrawn', sessionId };
1159
+ }
1160
+ }
1161
+ // Nothing (or nothing claimable) in the inbox: the turn/compact is live.
1162
+ if (!(await this.cancelWithRetry(sessionId, deadline))) {
1163
+ releaseGate();
1164
+ getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: cancel not accepted`);
1165
+ return { status: 'unconfirmed', sessionId, detail: 'session/cancel was not accepted' };
1166
+ }
1167
+ const state = await this.waitForClosed(sessionId, deadline);
1168
+ if (state === 'closed') {
1169
+ releaseGate();
1170
+ if (promptRequestId)
1171
+ this.inflight?.clear(sessionId, promptRequestId);
1172
+ getLogger().info(`[dsh] cancelled session ${sessionId}`);
1173
+ return { status: 'cancelled', sessionId };
1174
+ }
1175
+ if (state === 'unknown') {
1176
+ releaseGate();
1177
+ return { status: 'unconfirmed', sessionId, detail: 'turn state unavailable after cancel' };
1178
+ }
1179
+ // The host still reports an open turn (slow unwind or lost cancel): one
1180
+ // more attempt, then report honestly instead of pretending it stopped.
1181
+ // Keep the gate closed through the retry: it is session-scoped, so the
1182
+ // successor must not submit its prompt until this attempt has landed.
1183
+ await this.cancelWithRetry(sessionId, deadline);
1184
+ const retryState = await this.waitForClosed(sessionId, deadline);
1185
+ releaseGate();
1186
+ if (retryState === 'closed') {
1187
+ if (promptRequestId)
1188
+ this.inflight?.clear(sessionId, promptRequestId);
1189
+ return { status: 'cancelled', sessionId };
1190
+ }
1191
+ getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: turn still open`);
1192
+ return { status: 'unconfirmed', sessionId, detail: 'turn still open after cancel' };
1193
+ }
1194
+ catch (err) {
1195
+ releaseGate();
1196
+ getLogger().warn(`[dsh] stop failed session=${sessionId}: ${err.message}`);
1197
+ return { status: 'unconfirmed', sessionId, detail: err.message };
1198
+ }
1199
+ }
1200
+ /**
1201
+ * Locate this run's unclaimed prompt.
1202
+ *
1203
+ * An already-admitted prompt is visible immediately. A request still in
1204
+ * flight can be admitted after stop aborted it, so poll the inbox briefly —
1205
+ * that is the only client-side protection against a late orphan turn.
1206
+ */
1207
+ async findPendingPromptWithin(sessionId, requestId, admitted, deadline) {
1208
+ const attempts = admitted ? 1 : STOP_ADMIT_ATTEMPTS;
1209
+ for (let attempt = 0; attempt < attempts; attempt++) {
1210
+ if (attempt > 0 && !(await this.sleepWithin(deadline, STOP_ADMIT_DELAY_MS)))
1211
+ return undefined;
1212
+ try {
1213
+ const itemId = await this.client.findPendingPrompt(sessionId, requestId, {
1214
+ timeoutMs: this.stopProbeTimeout(deadline),
1215
+ });
1216
+ if (itemId)
1217
+ return itemId;
1218
+ if (admitted)
1219
+ return undefined;
1220
+ }
1221
+ catch (err) {
1222
+ getLogger().warn(`[dsh] stop inbox probe failed session=${sessionId}: ${err.message}`);
1223
+ return undefined;
1224
+ }
1225
+ }
1226
+ return undefined;
1227
+ }
1228
+ async removePendingPrompt(sessionId, itemId, deadline) {
1229
+ try {
1230
+ return await this.client.removeQueuedPrompt(sessionId, itemId, {
1231
+ timeoutMs: this.stopProbeTimeout(deadline),
1232
+ });
716
1233
  }
1234
+ catch (err) {
1235
+ getLogger().warn(`[dsh] stop withdraw failed session=${sessionId}: ${err.message}`);
1236
+ return false;
1237
+ }
1238
+ }
1239
+ /** One cancel attempt, then one retry (the request is idempotent when idle). */
1240
+ async cancelWithRetry(sessionId, deadline) {
1241
+ if (await this.cancelOnce(sessionId, deadline))
1242
+ return true;
1243
+ if (!(await this.sleepWithin(deadline, 150)))
1244
+ return false;
1245
+ return this.cancelOnce(sessionId, deadline);
1246
+ }
1247
+ async cancelOnce(sessionId, deadline) {
1248
+ const timeoutMs = Math.max(1, Math.min(CANCEL_TIMEOUT_MS, deadline - Date.now()));
1249
+ try {
1250
+ await this.client.unary('session/cancel', { sessionId }, { timeoutMs });
1251
+ return true;
1252
+ }
1253
+ catch (err) {
1254
+ getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
1255
+ return false;
1256
+ }
1257
+ }
1258
+ /** Poll the host until the session reports idle, or the budget runs out. */
1259
+ async waitForClosed(sessionId, deadline) {
1260
+ let state = 'unknown';
1261
+ for (let attempt = 0; attempt < STOP_VERIFY_ATTEMPTS; attempt++) {
1262
+ if (attempt > 0 && !(await this.sleepWithin(deadline, STOP_VERIFY_DELAY_MS)))
1263
+ break;
1264
+ state = await this.client.openTurnState(sessionId, {
1265
+ timeoutMs: this.stopProbeTimeout(deadline),
1266
+ });
1267
+ if (state === 'closed')
1268
+ return 'closed';
1269
+ }
1270
+ return state;
1271
+ }
1272
+ /** Sleep, but never past the stop deadline. Returns false when out of budget. */
1273
+ async sleepWithin(deadline, ms) {
1274
+ const remaining = deadline - Date.now();
1275
+ if (remaining <= 0)
1276
+ return false;
1277
+ await delay(Math.min(ms, remaining));
1278
+ return Date.now() < deadline;
1279
+ }
1280
+ stopProbeTimeout(deadline) {
1281
+ return Math.max(1, Math.min(STOP_PROBE_TIMEOUT_MS, deadline - Date.now()));
717
1282
  }
718
1283
  killOrphan() {
719
1284
  // No local subprocess for DSH — nothing to reap.
720
1285
  }
1286
+ /**
1287
+ * 退出前取消在途 turn。
1288
+ *
1289
+ * turn 跑在常驻共享的 `dsh web` host 里,不在本进程的子进程树上;所以
1290
+ * "杀自己的子进程"这套退出清理对它无效,桥接一退出,turn 继续无人接收地跑
1291
+ * (2026-10-19 实测:一个孤儿 turn 在桥接死后又跑了 2h21m / 345 个 step)。
1292
+ * 这里复用进程级退出分发器,用同步 curl 发一次 `session/cancel`。
1293
+ */
721
1294
  registerExitHandlers() {
722
- // No local subprocess for DSH — nothing to register.
1295
+ registerExitCleanup(this);
723
1296
  }
724
1297
  unregisterExitHandlers() {
725
- // No-op (see registerExitHandlers).
1298
+ unregisterExitCleanup(this);
1299
+ }
1300
+ /** Exit dispatcher hook: cancel the in-flight turn, bounded and never throwing. */
1301
+ cleanupOnExit() {
1302
+ if (!this.running || !this.activeSessionId)
1303
+ return;
1304
+ const sessionId = this.activeSessionId;
1305
+ try {
1306
+ this.cancelOnExit({ baseUrl: this.client.baseUrl, sessionId });
1307
+ getLogger().info(`[dsh] exit-cancel sent session=${sessionId}`);
1308
+ }
1309
+ catch (err) {
1310
+ getLogger().warn(`[dsh] exit-cancel failed session=${sessionId}: ${err.message}`);
1311
+ }
726
1312
  }
727
1313
  }
728
1314
  //# sourceMappingURL=runner.js.map