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.
- package/CHANGELOG.md +28 -0
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/bridge/approval-coordinator.d.ts +18 -1
- package/dist/bridge/approval-coordinator.js +57 -10
- package/dist/bridge/index.js +90 -17
- package/dist/card/approval-render.js +18 -0
- package/dist/card/run-state.js +10 -5
- package/dist/config/index.d.ts +16 -0
- package/dist/config/index.js +46 -6
- package/dist/config/opencode-config.d.ts +3 -3
- package/dist/config/opencode-config.js +38 -22
- package/dist/inbound/authorize.d.ts +34 -0
- package/dist/inbound/authorize.js +31 -0
- package/dist/index.js +116 -63
- package/dist/instance-lock.d.ts +9 -0
- package/dist/instance-lock.js +113 -42
- package/dist/router/config/dsh.d.ts +51 -21
- package/dist/router/config/dsh.js +255 -66
- package/dist/router/config/opencode.js +16 -6
- package/dist/router/config/types.d.ts +6 -3
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.js +13 -5
- package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
- package/dist/runner/common/acp/base-acp-runner.js +83 -8
- package/dist/runner/common/acp/protocol-types.d.ts +10 -2
- package/dist/runner/common/acp/protocol-types.js +3 -1
- package/dist/runner/common/connection-based-runner.d.ts +15 -0
- package/dist/runner/common/connection-based-runner.js +40 -5
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
- package/dist/runner/dsh/approval.d.ts +53 -0
- package/dist/runner/dsh/approval.js +161 -0
- package/dist/runner/dsh/auth.d.ts +36 -0
- package/dist/runner/dsh/auth.js +118 -0
- package/dist/runner/dsh/client.d.ts +161 -42
- package/dist/runner/dsh/client.js +551 -244
- package/dist/runner/dsh/daemon.d.ts +85 -0
- package/dist/runner/dsh/daemon.js +272 -0
- package/dist/runner/dsh/merge.d.ts +9 -0
- package/dist/runner/dsh/merge.js +51 -0
- package/dist/runner/dsh/runner.d.ts +94 -11
- package/dist/runner/dsh/runner.js +553 -67
- package/dist/runner/dsh/translator.d.ts +43 -3
- package/dist/runner/dsh/translator.js +115 -38
- package/dist/runner/dsh/types.d.ts +189 -27
- package/dist/runner/dsh/types.js +11 -5
- package/dist/runner/kimi/acp/runner.js +15 -3
- package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
- package/dist/runner/opencode/acp/error-monitor.js +2 -2
- package/dist/runner/opencode/acp/runner.d.ts +9 -0
- package/dist/runner/opencode/acp/runner.js +40 -1
- package/dist/runner/opencode/acp/translator.d.ts +4 -2
- package/dist/runner/opencode/acp/translator.js +12 -3
- package/dist/runner/pi/rpc/translator.d.ts +2 -0
- package/dist/runner/pi/rpc/translator.js +27 -1
- package/dist/runner/types.d.ts +34 -0
- package/dist/runner/types.js +24 -0
- package/dist/session/dsh/sessions.d.ts +45 -6
- package/dist/session/dsh/sessions.js +171 -27
- package/dist/session/opencode/sessions.d.ts +18 -7
- package/dist/session/opencode/sessions.js +294 -15
- package/dist/session/pi/sessions.js +6 -2
- 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
|
|
6
|
-
* (DSH has no init event) →
|
|
7
|
-
* (mode:'queue') → consume
|
|
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
|
|
10
|
-
* registerExitHandlers are no-ops (no local process).
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
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
|
-
/**
|
|
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
|
|
34
|
-
* configuredModel 在实例生命周期内不变(/config
|
|
35
|
-
*
|
|
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',
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
215
|
+
// provider/模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
|
|
103
216
|
// (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
|
|
104
|
-
//
|
|
105
|
-
|
|
217
|
+
// 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
|
|
218
|
+
// provider 会让用户以为切换成功,实际模型与账单都错。
|
|
219
|
+
if ((this.configuredModel || this.configuredProvider) &&
|
|
220
|
+
!this.alignedSessions.has(sessionId)) {
|
|
106
221
|
try {
|
|
107
|
-
await this.
|
|
108
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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() {
|