lark-remote 0.5.4 → 0.5.5

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 (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridge/approval-coordinator.d.ts +18 -1
  5. package/dist/bridge/approval-coordinator.js +57 -10
  6. package/dist/bridge/index.js +90 -17
  7. package/dist/card/approval-render.js +18 -0
  8. package/dist/card/run-state.js +10 -5
  9. package/dist/config/index.d.ts +16 -0
  10. package/dist/config/index.js +46 -6
  11. package/dist/config/opencode-config.d.ts +3 -3
  12. package/dist/config/opencode-config.js +38 -22
  13. package/dist/inbound/authorize.d.ts +34 -0
  14. package/dist/inbound/authorize.js +31 -0
  15. package/dist/index.js +116 -63
  16. package/dist/instance-lock.d.ts +9 -0
  17. package/dist/instance-lock.js +113 -42
  18. package/dist/router/config/dsh.d.ts +51 -21
  19. package/dist/router/config/dsh.js +255 -66
  20. package/dist/router/config/opencode.js +16 -6
  21. package/dist/router/config/types.d.ts +6 -3
  22. package/dist/router/index.d.ts +1 -0
  23. package/dist/router/index.js +13 -5
  24. package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
  25. package/dist/runner/common/acp/base-acp-runner.js +83 -8
  26. package/dist/runner/common/acp/protocol-types.d.ts +10 -2
  27. package/dist/runner/common/acp/protocol-types.js +3 -1
  28. package/dist/runner/common/connection-based-runner.d.ts +15 -0
  29. package/dist/runner/common/connection-based-runner.js +40 -5
  30. package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
  31. package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
  32. package/dist/runner/dsh/approval.d.ts +53 -0
  33. package/dist/runner/dsh/approval.js +161 -0
  34. package/dist/runner/dsh/auth.d.ts +36 -0
  35. package/dist/runner/dsh/auth.js +118 -0
  36. package/dist/runner/dsh/client.d.ts +161 -42
  37. package/dist/runner/dsh/client.js +551 -244
  38. package/dist/runner/dsh/daemon.d.ts +85 -0
  39. package/dist/runner/dsh/daemon.js +272 -0
  40. package/dist/runner/dsh/merge.d.ts +9 -0
  41. package/dist/runner/dsh/merge.js +51 -0
  42. package/dist/runner/dsh/runner.d.ts +94 -11
  43. package/dist/runner/dsh/runner.js +553 -67
  44. package/dist/runner/dsh/translator.d.ts +43 -3
  45. package/dist/runner/dsh/translator.js +115 -38
  46. package/dist/runner/dsh/types.d.ts +189 -27
  47. package/dist/runner/dsh/types.js +11 -5
  48. package/dist/runner/kimi/acp/runner.js +15 -3
  49. package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
  50. package/dist/runner/opencode/acp/error-monitor.js +2 -2
  51. package/dist/runner/opencode/acp/runner.d.ts +9 -0
  52. package/dist/runner/opencode/acp/runner.js +40 -1
  53. package/dist/runner/opencode/acp/translator.d.ts +4 -2
  54. package/dist/runner/opencode/acp/translator.js +12 -3
  55. package/dist/runner/pi/rpc/translator.d.ts +2 -0
  56. package/dist/runner/pi/rpc/translator.js +27 -1
  57. package/dist/runner/types.d.ts +34 -0
  58. package/dist/runner/types.js +24 -0
  59. package/dist/session/dsh/sessions.d.ts +45 -6
  60. package/dist/session/dsh/sessions.js +171 -27
  61. package/dist/session/opencode/sessions.d.ts +18 -7
  62. package/dist/session/opencode/sessions.js +294 -15
  63. package/dist/session/pi/sessions.js +6 -2
  64. package/package.json +3 -1
@@ -2,45 +2,98 @@
2
2
  * DshRunner — workspace-lifetime runner bridging to the DSH Web Host
3
3
  * (DeepSeek Harness) over plain HTTP. No local subprocess is spawned.
4
4
  *
5
- * Flow per run: session.create(cwd) (when no sessionId) → synthetic system/init
6
- * (DSH has no init event) → open per-session mux subscription → session.prompt
7
- * (mode:'queue') → consume subscription until turn/end → result event.
5
+ * Flow per run: session/create(cwd) (when no sessionId) → model/permission
6
+ * alignment → synthetic system/init (DSH has no init event) → session/prompt
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). approval/requested is
11
- * surfaced as a WARN log + an assistant text prompt pointing to the DSH Web UI
12
- * (never silently parked — the turn stays live until the user resolves it in
13
- * the UI or the turn is cancelled/times out).
9
+ * stop() → session/cancel + abort the mux subscription. killOrphan /
10
+ * registerExitHandlers are no-ops (no local process). DSH approvals are
11
+ * two-channel: durable `approval/asked` / `approval/decided` drive the Feishu
12
+ * card, while the answerable `$events` waterfall supplies eventId/clientId.
13
+ * If `$events` is unavailable, the runner falls back to the Web UI prompt.
14
14
  */
15
- import { DshClient } from './client.js';
15
+ import { DshClient, DshError, newPromptRequestId } from './client.js';
16
16
  import { DshTranslator } from './translator.js';
17
+ import { DshApprovalTracker } from './approval.js';
18
+ import { mergeAsync } from './merge.js';
17
19
  import { getLogger } from '../../logger/index.js';
18
- /** DSH selectModel provider(deepseek-official 是 rc.7 模型目录的 provider)。 */
20
+ /**
21
+ * Legacy provider for configs written before `agents.dsh.provider` existed.
22
+ * New configs pin the provider explicitly; the card reads the live catalog.
23
+ */
19
24
  const DSH_MODEL_PROVIDER = 'deepseek-official';
25
+ /** DSH 内建手动压缩命令(dsh-command-compact,无参数、仅空闲可执行)。 */
26
+ const DSH_COMPACT_COMMAND = '/compact';
27
+ /** Budget for the fire-and-forget session/cancel on stop (never block /stop). */
28
+ const CANCEL_TIMEOUT_MS = 5_000;
29
+ /** Parse tool-call arguments without letting malformed JSON crash the run. */
30
+ function parseToolArguments(raw) {
31
+ if (!raw)
32
+ return {};
33
+ try {
34
+ const value = JSON.parse(raw);
35
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
36
+ ? value
37
+ : {};
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
20
43
  export class DshRunner {
21
44
  kind = 'dsh';
45
+ /**
46
+ * Session identity lives in the DSH Web Host (server-side), not in a local
47
+ * process, so a finished run is still addressable — the same property the
48
+ * bridge uses before recording a run as compactable. Without it the Compact
49
+ * button renders (capability is declared) but every click answers
50
+ * 「该任务已结束或不属于当前会话」.
51
+ */
52
+ lifetime = 'workspace';
22
53
  sessionReader;
23
54
  client;
24
55
  /** 用户配置的模型 ID(run 前 selectModel 对齐用);undefined = 服务端默认。 */
25
56
  configuredModel;
57
+ /** 用户配置的 provider route;undefined = 兼容旧配置,按 deepseek-official 处理。 */
58
+ configuredProvider;
26
59
  agentPreset;
60
+ configuredPermissionPreset;
27
61
  reasoningEffort;
28
62
  activeAbort = null;
29
63
  activeSessionId = null;
30
64
  running = false;
65
+ daemon;
31
66
  /**
32
67
  * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
33
- * (session.selectModel 会写服务端全局默认,每次 run 都写是写放大)。
34
- * configuredModel 在实例生命周期内不变(/config 改模型会 clearRunners
35
- * 重建实例 + 换新 sessionId),故「每 session 对齐一次」足以覆盖配置变化。
68
+ * (session/selectModel 会写服务端全局默认,每次 run 都写是写放大)。
69
+ * configuredModel / configuredProvider 在实例生命周期内不变(/config 改动
70
+ * 会 clearRunners 重建实例),故「每 session 对齐一次」足以覆盖配置变化。
36
71
  */
37
72
  alignedSessions = new Set();
73
+ /** 已对齐权限预设的 session;同一 session 只执行一次 `/permission`。 */
74
+ permissionAlignedSessions = new Set();
75
+ /** `$events` approval waterfall state for the current run. */
76
+ approvalTracker = new DshApprovalTracker();
77
+ /** callId → tool call, used to render the command in the approval card. */
78
+ toolCalls = new Map();
79
+ /** False after `$events` is unavailable; approval/asked then falls back to text. */
80
+ approvalChannelAvailable = true;
38
81
  constructor(opts) {
39
82
  this.sessionReader = opts.sessionReader;
83
+ this.daemon = opts.daemon;
40
84
  this.configuredModel = opts.model;
85
+ this.configuredProvider = opts.provider;
41
86
  this.agentPreset = opts.agentPreset;
87
+ this.configuredPermissionPreset = opts.permissionPreset;
42
88
  this.reasoningEffort = opts.reasoningEffort;
43
- this.client = new DshClient(opts.host ?? 'http://127.0.0.1:3080', opts.fetchImpl, opts.webSocketImpl);
89
+ this.client = new DshClient(opts.host ?? 'http://127.0.0.1:3080', {
90
+ dshHome: opts.dshHome,
91
+ token: opts.token,
92
+ credentialsFile: opts.credentialsFile,
93
+ fetchImpl: opts.fetchImpl,
94
+ webSocketImpl: opts.webSocketImpl,
95
+ requestTimeoutMs: opts.requestTimeoutMs,
96
+ });
44
97
  }
45
98
  get isRunning() {
46
99
  return this.running;
@@ -49,14 +102,56 @@ export class DshRunner {
49
102
  const extras = { host: this.client.baseUrl };
50
103
  if (this.agentPreset)
51
104
  extras.preset = this.agentPreset;
105
+ if (this.configuredPermissionPreset) {
106
+ extras.permissionPreset = this.configuredPermissionPreset;
107
+ }
52
108
  if (this.reasoningEffort)
53
109
  extras.reasoning = this.reasoningEffort;
54
110
  return {
55
111
  kind: this.kind,
56
112
  model: this.configuredModel ?? 'DSH',
113
+ provider: this.configuredProvider ?? DSH_MODEL_PROVIDER,
57
114
  extras,
58
115
  };
59
116
  }
117
+ /**
118
+ * Resolve the configured provider/model pair.
119
+ *
120
+ * A model-only config keeps the legacy `deepseek-official` route, so
121
+ * existing configs behave exactly as before. A provider-only config asks
122
+ * the live catalog for that provider's default (or first) model.
123
+ */
124
+ async resolveModelSelection(signal) {
125
+ const provider = this.configuredProvider ?? DSH_MODEL_PROVIDER;
126
+ if (this.configuredModel) {
127
+ return {
128
+ provider,
129
+ model: this.configuredModel,
130
+ ...(this.reasoningEffort ? { reasoningEffort: this.reasoningEffort } : {}),
131
+ };
132
+ }
133
+ const catalog = await this.client.listModels({ signal });
134
+ const group = catalog.groups.find((entry) => entry.id === provider);
135
+ if (!group || group.models.length === 0) {
136
+ throw new Error(`Provider "${provider}" 在 DSH 模型目录中不可用(没有可用模型)`);
137
+ }
138
+ const defaultModel = catalog.default?.provider === provider ? catalog.default.model : undefined;
139
+ const model = defaultModel && group.models.some((entry) => entry.id === defaultModel)
140
+ ? defaultModel
141
+ : group.models[0].id;
142
+ const entry = group.models.find((candidate) => candidate.id === model);
143
+ const efforts = entry?.reasoning?.efforts.map((effort) => effort.id) ?? [];
144
+ let reasoningEffort;
145
+ if (this.reasoningEffort) {
146
+ if (efforts.length === 0 || efforts.includes(this.reasoningEffort)) {
147
+ reasoningEffort = this.reasoningEffort;
148
+ }
149
+ else {
150
+ getLogger().warn(`[dsh] reasoningEffort "${this.reasoningEffort}" 不被 ${provider}/${model} 支持,改用模型默认档位`);
151
+ }
152
+ }
153
+ return { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) };
154
+ }
60
155
  async *run(message, opts) {
61
156
  if (this.running) {
62
157
  throw new Error('DshRunner is already running');
@@ -67,13 +162,36 @@ export class DshRunner {
67
162
  let sessionId = opts.sessionId;
68
163
  let terminalEmitted = false;
69
164
  try {
70
- // session.create 必须包进同一 try/finally,否则创建失败(服务端不可达/报错)
165
+ // DSH is a shared, machine-wide process. Reuse it when it is already
166
+ // listening; otherwise start it detached so lark-remote exiting does not
167
+ // stop it for every other instance.
168
+ if (this.daemon)
169
+ await this.daemon.ensure();
170
+ // session/create 必须包进同一 try/finally,否则创建失败(服务端不可达/报错)
71
171
  // 会在进入旧 try 之前抛异常,finally 不执行 → running 恒 true、activeAbort 残留。
72
172
  if (!sessionId) {
73
- const created = await this.client.createSession({
173
+ const createPromise = this.client.createSession({
74
174
  cwd: opts.cwd,
75
175
  ...(this.agentPreset ? { agentPreset: this.agentPreset } : {}),
76
176
  });
177
+ // 不把 run 的 abort signal 直接交给 create:abort 掉请求会丢掉刚创建
178
+ // 的 sessionId,服务端若已完成创建就留下一个无法 cancel 的孤儿 session。
179
+ // 改为 abort 时立即放行 run(队列不被挂起),create 后台落定后再补 cancel。
180
+ const created = await this.awaitOrAbort(createPromise, abort.signal);
181
+ if (!created) {
182
+ void createPromise.then((c) => this.cancelSessionQuietly(c.sessionId), () => {
183
+ /* create failed after stop — nothing to cancel */
184
+ });
185
+ getLogger().info('[dsh] run stopped during session/create; will cancel if it settles');
186
+ terminalEmitted = true;
187
+ yield {
188
+ type: 'result',
189
+ subtype: 'interrupted',
190
+ session_id: '',
191
+ errorMessage: 'run stopped',
192
+ };
193
+ return;
194
+ }
77
195
  sessionId = created.sessionId;
78
196
  }
79
197
  this.activeSessionId = sessionId;
@@ -83,12 +201,7 @@ export class DshRunner {
83
201
  // 必须 yield interrupted result(不能裸 return):调用方依赖 result 收尾
84
202
  // run 卡,否则 for-await 自然结束后无终态、卡片停留「运行中」。
85
203
  if (abort.signal.aborted) {
86
- try {
87
- await this.client.unary('session.cancel', { sessionId });
88
- }
89
- catch {
90
- /* 取消失败仅告警,session 已 abort 无需继续 */
91
- }
204
+ await this.cancelSessionQuietly(sessionId);
92
205
  getLogger().info(`[dsh] run aborted during startup, cancelled session ${sessionId}`);
93
206
  terminalEmitted = true;
94
207
  yield {
@@ -99,23 +212,51 @@ export class DshRunner {
99
212
  };
100
213
  return;
101
214
  }
102
- // 模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
215
+ // provider/模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
103
216
  // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
104
- // 跳过。失败仅告警不阻断 run(模型校验由 DSH 负责)。
105
- if (this.configuredModel && !this.alignedSessions.has(sessionId)) {
217
+ // 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
218
+ // provider 会让用户以为切换成功,实际模型与账单都错。
219
+ if ((this.configuredModel || this.configuredProvider) &&
220
+ !this.alignedSessions.has(sessionId)) {
106
221
  try {
107
- await this.client.selectModel({
108
- sessionId,
109
- provider: DSH_MODEL_PROVIDER,
110
- model: this.configuredModel,
111
- ...(this.reasoningEffort ? { reasoningEffort: this.reasoningEffort } : {}),
112
- });
222
+ const selection = await this.resolveModelSelection(abort.signal);
223
+ await this.client.selectModel({ sessionId, ...selection }, { signal: abort.signal });
113
224
  this.alignedSessions.add(sessionId);
114
225
  }
115
226
  catch (err) {
227
+ if (abort.signal.aborted) {
228
+ getLogger().info(`[dsh] run stopped during selectModel session=${sessionId}`);
229
+ terminalEmitted = true;
230
+ yield {
231
+ type: 'result',
232
+ subtype: 'interrupted',
233
+ session_id: sessionId,
234
+ errorMessage: 'run stopped',
235
+ };
236
+ return;
237
+ }
238
+ if (this.configuredProvider) {
239
+ throw new Error(`Provider "${this.configuredProvider}" 的模型对齐失败:${err.message}。` +
240
+ '请在 DSH Web UI → 设置 → Models 检查该 Provider 的凭据与模型目录', { cause: err });
241
+ }
116
242
  getLogger().warn(`[dsh] selectModel failed for session ${sessionId} (run continues): ${err.message}`);
117
243
  }
118
244
  }
245
+ // 权限预设对齐:与模型对齐同语义,同一 session 只执行一次
246
+ // `/permission <preset>`。失败必须 fail closed——用户明确选了安全策略,
247
+ // 不能在错误的权限下继续跑 prompt。
248
+ await this.alignPermissionPreset(sessionId, abort.signal);
249
+ // Abort 可能在上述 unary 完成与检查之间到达:不进入 prompt/turn。
250
+ if (abort.signal.aborted) {
251
+ terminalEmitted = true;
252
+ yield {
253
+ type: 'result',
254
+ subtype: 'interrupted',
255
+ session_id: sessionId,
256
+ errorMessage: 'run stopped',
257
+ };
258
+ return;
259
+ }
119
260
  yield {
120
261
  type: 'system',
121
262
  subtype: 'init',
@@ -125,40 +266,35 @@ export class DshRunner {
125
266
  };
126
267
  try {
127
268
  // Snapshot the pre-prompt high-water mark before mutating the session.
128
- // DSH session.history returns the whole session, so starting from seq 0
269
+ // DSH session/page returns the whole session, so starting from seq 0
129
270
  // would replay the first completed turn on every follow-up prompt.
130
- const prePromptSeq = await this.client.latestEventSeq(sessionId);
131
- await this.client.unary('session.prompt', {
271
+ const prePromptSeq = await this.client.latestEventSeq(sessionId, {
272
+ signal: abort.signal,
273
+ });
274
+ await this.client.unary('session/prompt', {
275
+ requestId: newPromptRequestId(),
132
276
  sessionId,
133
277
  mode: 'queue',
134
278
  content: [{ type: 'text', text: message }],
135
- });
136
- const translator = new DshTranslator();
279
+ }, { signal: abort.signal });
280
+ if (abort.signal.aborted) {
281
+ // stop() 在 prompt 已受理后到达:cancel 由 stop() 发出,这里直接收尾,
282
+ // 不再打开 mux(否则会看到 cancel 产生的 turn/end 事件)。
283
+ terminalEmitted = true;
284
+ yield {
285
+ type: 'result',
286
+ subtype: 'interrupted',
287
+ session_id: sessionId,
288
+ errorMessage: 'run stopped',
289
+ };
290
+ return;
291
+ }
137
292
  try {
138
- for await (const item of this.client.sessionEvents(sessionId, abort.signal, prePromptSeq)) {
139
- if (abort.signal.aborted)
140
- break;
141
- if (item.kind === 'approval') {
142
- getLogger().warn(`[dsh] approval requested approvalId=${item.approvalId} tool=${item.toolName} — resolve in DSH Web UI`);
143
- yield {
144
- type: 'assistant',
145
- message: {
146
- content: [
147
- {
148
- type: 'text',
149
- text: `⚠️ 需要授权(${item.toolName}):请在 DSH Web UI 处理 ${this.client.baseUrl}`,
150
- },
151
- ],
152
- },
153
- };
154
- continue;
155
- }
156
- for (const ev of translator.eventToAgentEvents(item.event, sessionId)) {
157
- yield ev;
158
- if (ev.type === 'result') {
159
- terminalEmitted = true;
160
- return;
161
- }
293
+ for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, abort)) {
294
+ yield ev;
295
+ if (ev.type === 'result') {
296
+ terminalEmitted = true;
297
+ return;
162
298
  }
163
299
  }
164
300
  }
@@ -191,6 +327,7 @@ export class DshRunner {
191
327
  // createSession 等启动期失败也产出 error result(而非抛异常),
192
328
  // 由本 catch 统一转 error result;state 清理在 finally。
193
329
  if (!abort.signal.aborted) {
330
+ getLogger().error(`[dsh] run startup failed session=${sessionId ?? '(none)'}: ${err.message}`);
194
331
  yield {
195
332
  type: 'result',
196
333
  subtype: 'error',
@@ -214,19 +351,368 @@ export class DshRunner {
214
351
  };
215
352
  }
216
353
  }
354
+ /**
355
+ * Consume one turn from both transports. `session/follow` owns transcript
356
+ * events; `$events` owns the answerable approval waterfall. The merged
357
+ * stream keeps either transport from starving the other.
358
+ */
359
+ async *runTurn(sessionId, cwd, prePromptSeq, abort) {
360
+ const streamAbort = new AbortController();
361
+ const signal = AbortSignal.any([abort.signal, streamAbort.signal]);
362
+ const translator = new DshTranslator();
363
+ const emittedApprovals = new Set();
364
+ this.approvalTracker.clear();
365
+ this.toolCalls.clear();
366
+ this.approvalChannelAvailable = true;
367
+ try {
368
+ const sources = [
369
+ this.client.sessionEvents(sessionId, signal, prePromptSeq),
370
+ this.client.approvalEvents(sessionId, signal),
371
+ ];
372
+ for await (const item of mergeAsync(sources)) {
373
+ if (abort.signal.aborted)
374
+ return;
375
+ if (item.kind === 'ready') {
376
+ this.approvalChannelAvailable = true;
377
+ continue;
378
+ }
379
+ if (item.kind === 'unavailable') {
380
+ this.approvalChannelAvailable = false;
381
+ getLogger().warn(`[dsh] approval channel unavailable: ${item.message}`);
382
+ continue;
383
+ }
384
+ if (item.kind === 'request') {
385
+ this.approvalTracker.onWaterfall({
386
+ eventId: item.eventId,
387
+ clientId: item.clientId,
388
+ ...(item.callId ? { callId: item.callId } : {}),
389
+ toolName: item.toolName,
390
+ ...(item.reason ? { reason: item.reason } : {}),
391
+ });
392
+ continue;
393
+ }
394
+ if (item.kind === 'cancelled') {
395
+ const binding = this.approvalTracker.onCancelled(item.eventId);
396
+ if (binding)
397
+ yield this.approvalResolved(binding.audit.approvalId);
398
+ continue;
399
+ }
400
+ if (item.kind === 'approval') {
401
+ const audit = {
402
+ approvalId: item.approvalId,
403
+ toolName: item.toolName,
404
+ ...(item.callId ? { callId: item.callId } : {}),
405
+ ...(item.reason ? { reason: item.reason } : {}),
406
+ };
407
+ const binding = this.approvalTracker.onAuditAsked(audit);
408
+ if (!this.approvalChannelAvailable) {
409
+ getLogger().warn(`[dsh] approval requested approvalId=${item.approvalId} tool=${item.toolName}; live channel unavailable — falling back to Web UI`);
410
+ yield this.approvalFallbackText(item.toolName);
411
+ continue;
412
+ }
413
+ if (emittedApprovals.has(item.approvalId))
414
+ continue;
415
+ emittedApprovals.add(item.approvalId);
416
+ yield this.buildApprovalRequested(sessionId, audit, binding, cwd);
417
+ continue;
418
+ }
419
+ if (item.kind === 'assistant-stream') {
420
+ for (const ev of translator.streamFrameToAgentEvents(item.frame, sessionId)) {
421
+ yield ev;
422
+ }
423
+ continue;
424
+ }
425
+ const event = item.event;
426
+ if (event.type === 'tool/call') {
427
+ const data = event.data;
428
+ if (data.callId && data.name) {
429
+ this.toolCalls.set(data.callId, {
430
+ name: data.name,
431
+ arguments: data.arguments ?? '',
432
+ ...(data.turn === undefined ? {} : { turn: data.turn }),
433
+ });
434
+ if (this.toolCalls.size > 100) {
435
+ const oldest = this.toolCalls.keys().next().value;
436
+ if (oldest !== undefined)
437
+ this.toolCalls.delete(oldest);
438
+ }
439
+ }
440
+ continue;
441
+ }
442
+ if (event.type === 'approval/decided') {
443
+ const data = event.data;
444
+ if (!data.id)
445
+ continue;
446
+ this.approvalTracker.onDecided(data.id);
447
+ yield this.approvalResolved(data.id);
448
+ continue;
449
+ }
450
+ for (const ev of translator.eventToAgentEvents(event, sessionId)) {
451
+ yield ev;
452
+ if (ev.type === 'result')
453
+ return;
454
+ }
455
+ }
456
+ }
457
+ finally {
458
+ streamAbort.abort();
459
+ this.approvalTracker.clear();
460
+ this.toolCalls.clear();
461
+ }
462
+ }
463
+ /** Apply the configured permission preset once per session, before prompt. */
464
+ async alignPermissionPreset(sessionId, signal) {
465
+ const preset = this.configuredPermissionPreset;
466
+ if (!preset || this.permissionAlignedSessions.has(sessionId))
467
+ return;
468
+ const result = await this.client.executeCommand(sessionId, `/permission ${preset}`, { signal });
469
+ if (!result) {
470
+ throw new DshError('command-unavailable', `DSH 未提供 /permission 命令,无法应用权限预设 ${preset}`);
471
+ }
472
+ if (result.kind !== 'success') {
473
+ throw new DshError('command-failed', `DSH 应用权限预设 ${preset} 失败: ${result.text ?? 'unknown error'}`);
474
+ }
475
+ this.permissionAlignedSessions.add(sessionId);
476
+ getLogger().info(`[dsh] permission preset aligned session=${sessionId} preset=${preset}`);
477
+ }
478
+ /** Hot-update the configured preset; the next run aligns it. */
479
+ async updateApprovalMode(settings) {
480
+ this.configuredPermissionPreset = settings.permissionPreset;
481
+ this.permissionAlignedSessions.clear();
482
+ getLogger().info(`[dsh] permission preset updated to ${settings.permissionPreset ?? '(follow default)'}; applies to the next run`);
483
+ }
484
+ /**
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.
487
+ */
488
+ async respondApproval(requestId, response) {
489
+ const approvalId = String(requestId);
490
+ const binding = this.approvalTracker.getBinding(approvalId) ??
491
+ (await this.approvalTracker.waitForBinding(approvalId, 2_000, this.activeAbort?.signal));
492
+ if (!binding) {
493
+ throw new Error('DSH 审批通道尚未就绪,请稍后重试或到 DSH Web UI 处理');
494
+ }
495
+ const action = response?.action;
496
+ if (action === 'accept' || action === 'decline') {
497
+ await this.client.answerRemoteEvent(binding.waterfall.clientId, binding.waterfall.eventId, { kind: 'result', value: action === 'accept' ? 'allowed-once' : 'rejected' }, { signal: this.activeAbort?.signal });
498
+ }
499
+ else if (action === 'cancel') {
500
+ const sessionId = this.activeSessionId;
501
+ if (!sessionId)
502
+ throw new Error('DSH session 已结束,无法取消审批');
503
+ await this.client.unary('session/cancel', { sessionId }, { timeoutMs: CANCEL_TIMEOUT_MS });
504
+ }
505
+ else {
506
+ throw new Error(`DSH 不支持审批决策: ${String(action)}`);
507
+ }
508
+ this.approvalTracker.onDecided(approvalId);
509
+ getLogger().info(`[dsh] approval responded approvalId=${approvalId} action=${String(action)} eventId=${binding.waterfall.eventId}`);
510
+ }
511
+ buildApprovalRequested(sessionId, audit, binding, cwd) {
512
+ const call = audit.callId ? this.toolCalls.get(audit.callId) : undefined;
513
+ const args = parseToolArguments(call?.arguments);
514
+ const command = typeof args.command === 'string' ? args.command : undefined;
515
+ const commandCwd = typeof args.workdir === 'string' ? args.workdir : cwd;
516
+ const reason = binding?.waterfall.reason ?? audit.reason;
517
+ const view = {
518
+ requestId: audit.approvalId,
519
+ kind: command ? 'command' : 'tool',
520
+ ...(reason ? { reason } : {}),
521
+ ...(command
522
+ ? { command, commandCwd }
523
+ : {
524
+ toolName: audit.toolName,
525
+ ...(call?.arguments ? { toolInput: call.arguments } : {}),
526
+ }),
527
+ availableDecisions: ['accept', 'decline'],
528
+ note: '仅放行这一次调用;不会改变会话权限预设。若飞书提交失败,请到 DSH Web UI 处理。',
529
+ };
530
+ return {
531
+ type: 'approval_requested',
532
+ requestId: audit.approvalId,
533
+ kind: view.kind,
534
+ threadId: sessionId,
535
+ turnId: call?.turn === undefined ? '' : String(call.turn),
536
+ itemId: audit.callId ?? audit.approvalId,
537
+ view,
538
+ timestamp: new Date().toISOString(),
539
+ };
540
+ }
541
+ approvalResolved(requestId) {
542
+ return {
543
+ type: 'approval_resolved',
544
+ requestId,
545
+ outcome: 'resolved',
546
+ timestamp: new Date().toISOString(),
547
+ };
548
+ }
549
+ approvalFallbackText(toolName) {
550
+ return {
551
+ type: 'assistant',
552
+ message: {
553
+ content: [
554
+ {
555
+ type: 'text',
556
+ text: `⚠️ 需要授权(${toolName}):请在 DSH Web UI 处理 ${this.client.baseUrl}`,
557
+ },
558
+ ],
559
+ },
560
+ };
561
+ }
562
+ /**
563
+ * Compact the session through DSH's `/compact` command.
564
+ *
565
+ * Commands are a separate RPC from turns (`commands/execute`, idle-only), so
566
+ * this never prompts the model and never waits for a `turn/end`: the RPC
567
+ * itself settles when the compaction finishes, which is why the unary budget
568
+ * is disabled here (stop + the idle watchdog bound it instead). DSH reports
569
+ * expected refusals (busy / nothing to compact / summary failure) as
570
+ * `kind:'error'` values rather than RPC errors — those must become an error
571
+ * result, not a success card.
572
+ */
573
+ async *runCompact(_message, opts) {
574
+ if (this.running) {
575
+ throw new Error('DshRunner is already running');
576
+ }
577
+ const sessionId = opts.sessionId;
578
+ if (!sessionId) {
579
+ throw new Error('compact requires a sessionId');
580
+ }
581
+ const abort = new AbortController();
582
+ this.activeAbort = abort;
583
+ this.running = true;
584
+ this.activeSessionId = sessionId;
585
+ let terminalEmitted = false;
586
+ try {
587
+ if (this.daemon)
588
+ await this.daemon.ensure();
589
+ yield {
590
+ type: 'system',
591
+ subtype: 'init',
592
+ session_id: sessionId,
593
+ cwd: opts.cwd,
594
+ model: this.configuredModel ?? 'DSH',
595
+ };
596
+ const outcome = await this.client.executeCommand(sessionId, DSH_COMPACT_COMMAND, {
597
+ signal: abort.signal,
598
+ // 压缩是一次长 unary(服务端等压缩结束才回),60s 默认预算会误杀;
599
+ // 由 /stop 的 abort 与卡片空闲看门狗兜底。
600
+ timeoutMs: 0,
601
+ });
602
+ if (abort.signal.aborted) {
603
+ terminalEmitted = true;
604
+ yield {
605
+ type: 'result',
606
+ subtype: 'interrupted',
607
+ session_id: sessionId,
608
+ errorMessage: 'compact stopped',
609
+ };
610
+ return;
611
+ }
612
+ if (!outcome) {
613
+ terminalEmitted = true;
614
+ yield {
615
+ type: 'result',
616
+ subtype: 'error',
617
+ session_id: sessionId,
618
+ errorMessage: 'DSH 未执行压缩:该 session 没有 /compact 命令',
619
+ };
620
+ return;
621
+ }
622
+ const text = typeof outcome.text === 'string' ? outcome.text.trim() : '';
623
+ if (text) {
624
+ yield {
625
+ type: 'assistant',
626
+ message: { content: [{ type: 'text', text }] },
627
+ timestamp: new Date().toISOString(),
628
+ };
629
+ }
630
+ const failed = outcome.kind === 'error';
631
+ terminalEmitted = true;
632
+ yield {
633
+ type: 'result',
634
+ subtype: failed ? 'error' : 'success',
635
+ session_id: sessionId,
636
+ ...(failed ? { errorMessage: text || 'DSH 压缩失败' } : {}),
637
+ };
638
+ }
639
+ catch (err) {
640
+ terminalEmitted = true;
641
+ const aborted = abort.signal.aborted;
642
+ getLogger().warn(`[dsh] compact failed session=${sessionId}: ${err.message}` +
643
+ (aborted ? ' (stopped)' : ''));
644
+ yield {
645
+ type: 'result',
646
+ subtype: aborted ? 'interrupted' : 'error',
647
+ session_id: sessionId,
648
+ errorMessage: aborted ? 'compact stopped' : `DSH compact failed: ${err.message}`,
649
+ };
650
+ }
651
+ finally {
652
+ this.running = false;
653
+ this.activeSessionId = null;
654
+ this.activeAbort = null;
655
+ }
656
+ if (!terminalEmitted) {
657
+ yield {
658
+ type: 'result',
659
+ subtype: 'error',
660
+ session_id: sessionId,
661
+ errorMessage: 'compact ended without a terminal event',
662
+ };
663
+ }
664
+ }
665
+ /**
666
+ * Resolve with the promise's value, or `undefined` as soon as `signal` aborts.
667
+ * The underlying promise keeps running (its own timeout still applies) — the
668
+ * caller decides what to do with a late settlement.
669
+ */
670
+ async awaitOrAbort(promise, signal) {
671
+ if (signal.aborted)
672
+ return undefined;
673
+ return new Promise((resolve, reject) => {
674
+ let done = false;
675
+ const onAbort = () => {
676
+ if (done)
677
+ return;
678
+ done = true;
679
+ resolve(undefined);
680
+ };
681
+ signal.addEventListener('abort', onAbort, { once: true });
682
+ promise.then((value) => {
683
+ if (done)
684
+ return;
685
+ done = true;
686
+ signal.removeEventListener('abort', onAbort);
687
+ resolve(value);
688
+ }, (err) => {
689
+ if (done)
690
+ return;
691
+ done = true;
692
+ signal.removeEventListener('abort', onAbort);
693
+ reject(err);
694
+ });
695
+ });
696
+ }
697
+ /** Send session/cancel with a short bound; never throws (cleanup path). */
698
+ async cancelSessionQuietly(sessionId) {
699
+ try {
700
+ await this.client.unary('session/cancel', { sessionId }, { timeoutMs: CANCEL_TIMEOUT_MS });
701
+ }
702
+ catch (err) {
703
+ getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
704
+ }
705
+ }
217
706
  async stop(_opts) {
218
707
  const sessionId = this.activeSessionId;
219
708
  const abort = this.activeAbort;
220
709
  if (abort && !abort.signal.aborted)
221
710
  abort.abort();
222
711
  if (sessionId) {
223
- try {
224
- await this.client.unary('session.cancel', { sessionId });
225
- getLogger().info(`[dsh] cancelled session ${sessionId}`);
226
- }
227
- catch (err) {
228
- getLogger().warn(`[dsh] session.cancel failed: ${err.message}`);
229
- }
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}`);
230
716
  }
231
717
  }
232
718
  killOrphan() {