lark-remote 0.5.20 → 0.5.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/bridge/inbound-media.js +71 -45
- package/dist/bridge/index.d.ts +9 -0
- package/dist/bridge/index.js +230 -203
- package/dist/card/approval-render.js +32 -33
- package/dist/card/card-budget.js +123 -98
- package/dist/card/run-renderer.js +82 -68
- package/dist/card/run-state.d.ts +0 -14
- package/dist/card/run-state.js +335 -207
- package/dist/card/tool-render.js +20 -16
- package/dist/card/usage-format.d.ts +12 -9
- package/dist/card/usage-format.js +111 -97
- package/dist/common/text-width.js +36 -24
- package/dist/common/tool-summary.js +132 -90
- package/dist/index.js +226 -190
- package/dist/message-handlers.js +253 -236
- package/dist/router/commands/ls.d.ts +3 -0
- package/dist/router/commands/ls.js +256 -175
- package/dist/router/commands/order.js +184 -159
- package/dist/router/commands/resume.js +189 -133
- package/dist/router/commands/ws.js +219 -174
- package/dist/router/index.js +192 -201
- package/dist/runner/bash/runner.js +2 -4
- package/dist/runner/codex/app-server/translator.d.ts +2 -0
- package/dist/runner/codex/app-server/translator.js +287 -221
- package/dist/runner/common/acp/base-translator.js +19 -17
- package/dist/runner/common/jsonl-stream.js +154 -187
- package/dist/runner/common/jsonrpc/client.d.ts +29 -0
- package/dist/runner/common/jsonrpc/client.js +43 -12
- package/dist/runner/common/spawning-runner.d.ts +5 -0
- package/dist/runner/common/spawning-runner.js +8 -3
- package/dist/runner/common/turn-started.d.ts +8 -0
- package/dist/runner/common/turn-started.js +11 -0
- package/dist/runner/dsh/approval.js +84 -51
- package/dist/runner/dsh/client.d.ts +7 -9
- package/dist/runner/dsh/client.js +356 -287
- package/dist/runner/dsh/mux-stream.js +28 -8
- package/dist/runner/dsh/runner.d.ts +4 -3
- package/dist/runner/dsh/runner.js +88 -64
- package/dist/runner/dsh/translator.d.ts +1 -31
- package/dist/runner/dsh/translator.js +180 -178
- package/dist/runner/kimi/acp/runner.js +81 -44
- package/dist/runner/opencode/acp/error-monitor.js +5 -5
- package/dist/runner/pi/rpc/client.js +4 -13
- package/dist/runner/pi/rpc/translator.js +61 -60
- package/dist/session/claude/sessions.js +113 -101
- package/dist/session/codex/rollout-reader.js +179 -180
- package/dist/session/dsh/sessions.js +281 -231
- package/dist/session/kimi/sessions.d.ts +0 -7
- package/dist/session/kimi/sessions.js +129 -109
- package/dist/session/opencode/sessions.d.ts +0 -11
- package/dist/session/opencode/sessions.js +161 -137
- package/dist/session/session-store.d.ts +8 -0
- package/dist/session/session-store.js +98 -98
- package/package.json +1 -1
|
@@ -44,6 +44,49 @@ function isDshAuthError(error) {
|
|
|
44
44
|
error.code === 'http' &&
|
|
45
45
|
(error.details.status === 401 || error.details.status === 403));
|
|
46
46
|
}
|
|
47
|
+
/** 组合 abort/timeout signal 并生成本次 unary 的 rpcId(循环外一次算好)。 */
|
|
48
|
+
function prepareUnary(opts, requestTimeoutMs) {
|
|
49
|
+
const timeoutMs = opts.timeoutMs ?? requestTimeoutMs;
|
|
50
|
+
// 0/负数 = 不设超时(兼容「长请求不取消」的配置),其余走 AbortSignal.timeout。
|
|
51
|
+
const timeoutSignal = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
52
|
+
const signal = opts.signal && timeoutSignal
|
|
53
|
+
? AbortSignal.any([opts.signal, timeoutSignal])
|
|
54
|
+
: (opts.signal ?? timeoutSignal);
|
|
55
|
+
const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
56
|
+
return { signal, timeoutSignal, timeoutMs, rpcId };
|
|
57
|
+
}
|
|
58
|
+
/** Distinguish "run stopped" (abort) from "server did not answer" (timeout). */
|
|
59
|
+
function abortErrorOf(method, err, runSignal, timeoutSignal, timeoutMs) {
|
|
60
|
+
if (runSignal?.aborted) {
|
|
61
|
+
return new DshError('aborted', `DSH unary ${method} aborted`, { cause: err });
|
|
62
|
+
}
|
|
63
|
+
if (timeoutSignal?.aborted) {
|
|
64
|
+
return new DshError('timeout', `DSH unary ${method} timed out after ${timeoutMs}ms`, {
|
|
65
|
+
cause: err,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return new DshError('aborted', `DSH unary ${method} aborted: ${err.message}`);
|
|
69
|
+
}
|
|
70
|
+
/** 校验 ok 响应并取 `result.value`;非 ok / 非 JSON / result 失败都抛 DshError。 */
|
|
71
|
+
async function readUnaryBody(method, res) {
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
throw new DshError('http', `DSH HTTP ${res.status} for ${method}`, {
|
|
74
|
+
status: res.status,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
let body;
|
|
78
|
+
try {
|
|
79
|
+
body = (await res.json());
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
if (body.result?.ok !== true) {
|
|
85
|
+
const err = body.result?.error;
|
|
86
|
+
throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
|
|
87
|
+
}
|
|
88
|
+
return body.result.value;
|
|
89
|
+
}
|
|
47
90
|
/** Backoff between mux reconnects to avoid a hot loop on a dropping server. */
|
|
48
91
|
const RECONNECT_BACKOFF_MS = 200;
|
|
49
92
|
/**
|
|
@@ -112,6 +155,192 @@ function inboxItemsOf(values) {
|
|
|
112
155
|
}
|
|
113
156
|
return out;
|
|
114
157
|
}
|
|
158
|
+
/** Hand one waterfall delivery back with `next` (another answerer owns it). */
|
|
159
|
+
function delegateWaterfall(client, clientId, eventId, signal) {
|
|
160
|
+
void client
|
|
161
|
+
.answerRemoteEvent(clientId, eventId, { kind: 'next' }, { signal })
|
|
162
|
+
.catch((err) => {
|
|
163
|
+
if (!signal.aborted) {
|
|
164
|
+
getLogger().warn(`[dsh] waterfall delegation failed eventId=${eventId}: ${err.message}`);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/** 一个 waterfall 帧 → 要 yield 的 items(含 question/request 委托)。 */
|
|
169
|
+
function waterfallToItems(client, clientId, frame, sessionId, signal) {
|
|
170
|
+
if (frame.event === 'user-questions/request' && frame.agentId === sessionId) {
|
|
171
|
+
if (isTimedQuestionRequest(frame.request)) {
|
|
172
|
+
// The timed wait owns its own deadline (and the Web UI renders the
|
|
173
|
+
// countdown); a card answer outside that window is lost.
|
|
174
|
+
getLogger().info(`[dsh] timed user-questions/request delegated to the Web UI eventId=${frame.eventId}`);
|
|
175
|
+
delegateWaterfall(client, clientId, frame.eventId, signal);
|
|
176
|
+
return { items: [], stop: false };
|
|
177
|
+
}
|
|
178
|
+
const questions = dshQuestionsOf(frame.request);
|
|
179
|
+
if (questions) {
|
|
180
|
+
return {
|
|
181
|
+
items: [{ kind: 'question', eventId: frame.eventId, clientId, questions }],
|
|
182
|
+
stop: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// The Feishu card cannot answer this batch safely (malformed, or duplicate
|
|
186
|
+
// texts — the coordinator keys answers by text).
|
|
187
|
+
getLogger().warn(`[dsh] user-questions/request not relayable; delegating eventId=${frame.eventId}`);
|
|
188
|
+
delegateWaterfall(client, clientId, frame.eventId, signal);
|
|
189
|
+
return { items: [], stop: false };
|
|
190
|
+
}
|
|
191
|
+
if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
|
|
192
|
+
// Every owner answerer receives the waterfall. A non-owner of the session
|
|
193
|
+
// must explicitly delegate, otherwise DSH waits.
|
|
194
|
+
delegateWaterfall(client, clientId, frame.eventId, signal);
|
|
195
|
+
return { items: [], stop: false };
|
|
196
|
+
}
|
|
197
|
+
const data = frame.request;
|
|
198
|
+
const toolName = typeof data.toolName === 'string' ? data.toolName : '';
|
|
199
|
+
const callId = typeof data.callId === 'string' ? data.callId : undefined;
|
|
200
|
+
const reason = typeof data.reason === 'string' ? data.reason : undefined;
|
|
201
|
+
return {
|
|
202
|
+
items: [
|
|
203
|
+
{
|
|
204
|
+
kind: 'request',
|
|
205
|
+
eventId: frame.eventId,
|
|
206
|
+
clientId,
|
|
207
|
+
toolName,
|
|
208
|
+
...(callId ? { callId } : {}),
|
|
209
|
+
...(reason ? { reason } : {}),
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
stop: false,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** 一个 `$events` 帧 → items(并按需更新 state)。 */
|
|
216
|
+
function processApprovalFrame(state, frame, sessionId, signal, client) {
|
|
217
|
+
if (frame.type === 'ready') {
|
|
218
|
+
state.authRefreshAttempted = false;
|
|
219
|
+
// 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 + 网络抖动
|
|
220
|
+
// 累计掉线超过 3 次后旧实现会让 $events 永久 unavailable。
|
|
221
|
+
state.reconnectAttempts = 0;
|
|
222
|
+
state.sawReady = true;
|
|
223
|
+
state.streamClientId = frame.clientId;
|
|
224
|
+
return { items: [{ kind: 'ready', clientId: frame.clientId }], stop: false };
|
|
225
|
+
}
|
|
226
|
+
if (frame.type === 'waterfall') {
|
|
227
|
+
if (!state.streamClientId) {
|
|
228
|
+
return {
|
|
229
|
+
items: [
|
|
230
|
+
{ kind: 'unavailable', message: 'DSH approval stream sent a waterfall before ready' },
|
|
231
|
+
],
|
|
232
|
+
stop: true,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return waterfallToItems(client, state.streamClientId, frame, sessionId, signal);
|
|
236
|
+
}
|
|
237
|
+
if (frame.type === 'cancel') {
|
|
238
|
+
return { items: [{ kind: 'cancelled', eventId: frame.eventId }], stop: false };
|
|
239
|
+
}
|
|
240
|
+
return { items: [], stop: false };
|
|
241
|
+
}
|
|
242
|
+
/** 把 `$events` 流异常分类成 4 种动作(不改变重试/超时语义)。 */
|
|
243
|
+
function classifyApprovalStreamError(err, state, signal) {
|
|
244
|
+
if (signal.aborted)
|
|
245
|
+
return { kind: 'return' };
|
|
246
|
+
if (err instanceof DshError &&
|
|
247
|
+
err.code === 'http' &&
|
|
248
|
+
err.details.status === 401 &&
|
|
249
|
+
!state.authRefreshAttempted) {
|
|
250
|
+
return { kind: 'refresh-auth' };
|
|
251
|
+
}
|
|
252
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
253
|
+
const forbidden = (err instanceof DshError &&
|
|
254
|
+
(err.code === 'gateway/interactions-forbidden' ||
|
|
255
|
+
(err.code === 'http' && err.details.status === 403))) ||
|
|
256
|
+
message.includes('interactions-forbidden');
|
|
257
|
+
if (!forbidden && state.reconnectAttempts < 3) {
|
|
258
|
+
return { kind: 'reconnect', attempt: state.reconnectAttempts + 1 };
|
|
259
|
+
}
|
|
260
|
+
return { kind: 'unavailable', message };
|
|
261
|
+
}
|
|
262
|
+
/** 一次流正常结束(无异常)后的收尾决策。 */
|
|
263
|
+
function approvalStreamTail(state, signal) {
|
|
264
|
+
if (signal.aborted)
|
|
265
|
+
return 'aborted';
|
|
266
|
+
if (state.sawReady && state.reconnectAttempts < 3) {
|
|
267
|
+
state.reconnectAttempts += 1;
|
|
268
|
+
return 'reconnect';
|
|
269
|
+
}
|
|
270
|
+
return 'ended';
|
|
271
|
+
}
|
|
272
|
+
function reconnectBackoff() {
|
|
273
|
+
return new Promise((resolve) => setTimeout(resolve, 200));
|
|
274
|
+
}
|
|
275
|
+
/** Mark this run as the owner once its own prompt message is recorded. */
|
|
276
|
+
function ownsSessionEvent(state, ev) {
|
|
277
|
+
if (state.owns)
|
|
278
|
+
return true;
|
|
279
|
+
if (ev.type !== 'user/message')
|
|
280
|
+
return false;
|
|
281
|
+
const source = ev.data?.source;
|
|
282
|
+
if (source?.kind === 'user' && source.rpcId === state.promptRequestId) {
|
|
283
|
+
state.owns = true;
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
function admitSessionEvent(state, ev) {
|
|
289
|
+
if (ownsSessionEvent(state, ev))
|
|
290
|
+
return true;
|
|
291
|
+
if (!state.foreignTailReported) {
|
|
292
|
+
state.foreignTailReported = true;
|
|
293
|
+
getLogger().info(`[dsh] prompt ${state.promptRequestId ?? '(none)'} is queued behind a running turn for session ${state.sessionId}; holding the card open until its own turn starts`);
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
/** Translate one durable event into a stream item (approvals are surfaced
|
|
298
|
+
* separately; everything else is forwarded to the runner). */
|
|
299
|
+
function sessionEventItem(sessionId, event) {
|
|
300
|
+
if (event.type === 'approval/asked') {
|
|
301
|
+
const data = event.data;
|
|
302
|
+
if (!data.id)
|
|
303
|
+
return undefined;
|
|
304
|
+
return {
|
|
305
|
+
kind: 'approval',
|
|
306
|
+
sessionId,
|
|
307
|
+
approvalId: data.id,
|
|
308
|
+
toolName: data.toolName ?? '',
|
|
309
|
+
...(data.callId ? { callId: data.callId } : {}),
|
|
310
|
+
...(data.reason ? { reason: data.reason } : {}),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
return { kind: 'event', sessionId, event };
|
|
314
|
+
}
|
|
315
|
+
/** 单条事件的 seq 去重 / admit / item / turn-end 早退;undefined 表示跳过。 */
|
|
316
|
+
function advanceSessionEvent(state, ev) {
|
|
317
|
+
if (ev.seq <= state.lastSeq)
|
|
318
|
+
return undefined;
|
|
319
|
+
state.lastSeq = ev.seq;
|
|
320
|
+
if (!admitSessionEvent(state, ev))
|
|
321
|
+
return undefined;
|
|
322
|
+
return { item: sessionEventItem(state.sessionId, ev), done: ev.type === 'turn/end' };
|
|
323
|
+
}
|
|
324
|
+
/** 一帧 follow → 待回放的 durable 事件(assistant-stream 不带 durable 事件)。 */
|
|
325
|
+
async function sessionFrameEvents(client, state, frame, signal) {
|
|
326
|
+
if (frame.type === 'snapshot') {
|
|
327
|
+
return client.replaySnapshotGap(state.sessionId, signal, state.lastSeq, frame);
|
|
328
|
+
}
|
|
329
|
+
if (frame.type === 'event')
|
|
330
|
+
return [frame.event];
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
/** 把 follow 流异常分类成 4 种动作(不改变重试/超时语义)。 */
|
|
334
|
+
function classifySessionStreamError(err, state, signal) {
|
|
335
|
+
if (signal.aborted)
|
|
336
|
+
return { kind: 'return' };
|
|
337
|
+
if (isDshAuthError(err)) {
|
|
338
|
+
if (!state.authRefreshAttempted)
|
|
339
|
+
return { kind: 'refresh-auth' };
|
|
340
|
+
return { kind: 'rethrow' };
|
|
341
|
+
}
|
|
342
|
+
return { kind: 'reconnect' };
|
|
343
|
+
}
|
|
115
344
|
export class DshClient {
|
|
116
345
|
baseUrl;
|
|
117
346
|
token;
|
|
@@ -214,103 +443,83 @@ export class DshClient {
|
|
|
214
443
|
* but never answers. Both are composed into the request's AbortSignal.
|
|
215
444
|
*/
|
|
216
445
|
async unary(method, payload, opts = {}) {
|
|
217
|
-
const
|
|
218
|
-
// 0/负数 = 不设超时(兼容「长请求不取消」的配置),其余走 AbortSignal.timeout。
|
|
219
|
-
const timeoutSignal = _timeoutMs > 0 ? AbortSignal.timeout(_timeoutMs) : undefined;
|
|
220
|
-
const signal = opts.signal && timeoutSignal
|
|
221
|
-
? AbortSignal.any([opts.signal, timeoutSignal])
|
|
222
|
-
: (opts.signal ?? timeoutSignal);
|
|
223
|
-
const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
446
|
+
const ctx = prepareUnary(opts, this.requestTimeoutMs);
|
|
224
447
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
let cookie;
|
|
229
|
-
try {
|
|
230
|
-
cookie = await this.authCookie(attempt > 0, signal);
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
if (opts.signal?.aborted || signal?.aborted) {
|
|
234
|
-
throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
|
|
235
|
-
}
|
|
236
|
-
// A token bootstrap can race a DSH restart; retry once with a fresh
|
|
237
|
-
// credentials read before surfacing the deterministic auth failure.
|
|
238
|
-
if (attempt === 0 &&
|
|
239
|
-
err instanceof DshError &&
|
|
240
|
-
err.code === 'http' &&
|
|
241
|
-
err.details.status === 401) {
|
|
242
|
-
getLogger().warn(`[dsh] 401 while authenticating ${method}, re-minting cookie once`);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
throw err;
|
|
246
|
-
}
|
|
247
|
-
const headers = { 'content-type': 'application/json' };
|
|
248
|
-
if (cookie)
|
|
249
|
-
headers.cookie = cookie;
|
|
250
|
-
let res;
|
|
251
|
-
try {
|
|
252
|
-
res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
|
|
253
|
-
method: 'POST',
|
|
254
|
-
headers,
|
|
255
|
-
body: JSON.stringify({
|
|
256
|
-
type: 'client-request',
|
|
257
|
-
rpcId,
|
|
258
|
-
method,
|
|
259
|
-
payload: { args: dshArgs(method, payload) },
|
|
260
|
-
}),
|
|
261
|
-
...(signal ? { signal } : {}),
|
|
262
|
-
});
|
|
263
|
-
}
|
|
264
|
-
catch (err) {
|
|
265
|
-
if (opts.signal?.aborted || signal?.aborted) {
|
|
266
|
-
throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
|
|
267
|
-
}
|
|
268
|
-
throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
|
|
269
|
-
}
|
|
270
|
-
if (opts.signal?.aborted) {
|
|
271
|
-
throw new DshError('aborted', `DSH unary ${method} aborted`);
|
|
272
|
-
}
|
|
273
|
-
if (res.status === 401 && attempt === 0) {
|
|
274
|
-
this.cookiePromise = undefined;
|
|
275
|
-
getLogger().warn(`[dsh] 401 for ${method}, re-minting cookie once`);
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
if (res.status === 401) {
|
|
279
|
-
throw new DshError('http', `DSH HTTP 401 for ${method}: 认证失败;请确认 ${this.credentialsFile} 可读,` +
|
|
280
|
-
'或在 config.yaml 的 agents.dsh.token 填入当前 dsh web 启动 token。' +
|
|
281
|
-
'若凭据文件可读且格式正确,可能是 DSH 版本改变了 cookie 认证格式', { status: 401 });
|
|
282
|
-
}
|
|
283
|
-
if (!res.ok) {
|
|
284
|
-
throw new DshError('http', `DSH HTTP ${res.status} for ${method}`, {
|
|
285
|
-
status: res.status,
|
|
286
|
-
});
|
|
287
|
-
}
|
|
288
|
-
let body;
|
|
289
|
-
try {
|
|
290
|
-
body = (await res.json());
|
|
291
|
-
}
|
|
292
|
-
catch (err) {
|
|
293
|
-
throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
|
|
294
|
-
}
|
|
295
|
-
if (body.result?.ok !== true) {
|
|
296
|
-
const err = body.result?.error;
|
|
297
|
-
throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
|
|
298
|
-
}
|
|
299
|
-
return body.result.value;
|
|
448
|
+
const result = await this.attemptUnary(method, payload, opts, ctx, attempt);
|
|
449
|
+
if (result.kind === 'done')
|
|
450
|
+
return result.value;
|
|
300
451
|
}
|
|
301
452
|
throw new DshError('auth', `DSH ${method} authentication failed after refresh`);
|
|
302
453
|
}
|
|
303
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
454
|
+
/** One unary POST attempt: obtain cookie, then post; both stages may ask to
|
|
455
|
+
* retry once (401 → re-mint cookie); all other failures throw DshError. */
|
|
456
|
+
async attemptUnary(method, payload, opts, ctx, attempt) {
|
|
457
|
+
const cookieResult = await this.obtainUnaryCookie(method, opts, ctx, attempt);
|
|
458
|
+
if (cookieResult.kind === 'retry')
|
|
459
|
+
return { kind: 'retry' };
|
|
460
|
+
return this.postUnary(method, payload, opts, ctx, cookieResult.cookie, attempt);
|
|
461
|
+
}
|
|
462
|
+
async obtainUnaryCookie(method, opts, ctx, attempt) {
|
|
463
|
+
if (opts.signal?.aborted) {
|
|
464
|
+
throw new DshError('aborted', `DSH unary ${method} aborted before send`);
|
|
307
465
|
}
|
|
308
|
-
|
|
309
|
-
return
|
|
310
|
-
|
|
466
|
+
try {
|
|
467
|
+
return { kind: 'cookie', cookie: await this.authCookie(attempt > 0, ctx.signal) };
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
if (opts.signal?.aborted || ctx.signal?.aborted) {
|
|
471
|
+
throw abortErrorOf(method, err, opts.signal, ctx.timeoutSignal, ctx.timeoutMs);
|
|
472
|
+
}
|
|
473
|
+
// A token bootstrap can race a DSH restart; retry once with a fresh
|
|
474
|
+
// credentials read before surfacing the deterministic auth failure.
|
|
475
|
+
if (attempt === 0 &&
|
|
476
|
+
err instanceof DshError &&
|
|
477
|
+
err.code === 'http' &&
|
|
478
|
+
err.details.status === 401) {
|
|
479
|
+
getLogger().warn(`[dsh] 401 while authenticating ${method}, re-minting cookie once`);
|
|
480
|
+
return { kind: 'retry' };
|
|
481
|
+
}
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
async postUnary(method, payload, opts, ctx, cookie, attempt) {
|
|
486
|
+
const headers = { 'content-type': 'application/json' };
|
|
487
|
+
if (cookie)
|
|
488
|
+
headers.cookie = cookie;
|
|
489
|
+
let res;
|
|
490
|
+
try {
|
|
491
|
+
res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
|
|
492
|
+
method: 'POST',
|
|
493
|
+
headers,
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
type: 'client-request',
|
|
496
|
+
rpcId: ctx.rpcId,
|
|
497
|
+
method,
|
|
498
|
+
payload: { args: dshArgs(method, payload) },
|
|
499
|
+
}),
|
|
500
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
311
501
|
});
|
|
312
502
|
}
|
|
313
|
-
|
|
503
|
+
catch (err) {
|
|
504
|
+
if (opts.signal?.aborted || ctx.signal?.aborted) {
|
|
505
|
+
throw abortErrorOf(method, err, opts.signal, ctx.timeoutSignal, ctx.timeoutMs);
|
|
506
|
+
}
|
|
507
|
+
throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
|
|
508
|
+
}
|
|
509
|
+
if (opts.signal?.aborted) {
|
|
510
|
+
throw new DshError('aborted', `DSH unary ${method} aborted`);
|
|
511
|
+
}
|
|
512
|
+
if (res.status === 401 && attempt === 0) {
|
|
513
|
+
this.cookiePromise = undefined;
|
|
514
|
+
getLogger().warn(`[dsh] 401 for ${method}, re-minting cookie once`);
|
|
515
|
+
return { kind: 'retry' };
|
|
516
|
+
}
|
|
517
|
+
if (res.status === 401) {
|
|
518
|
+
throw new DshError('http', `DSH HTTP 401 for ${method}: 认证失败;请确认 ${this.credentialsFile} 可读,` +
|
|
519
|
+
'或在 config.yaml 的 agents.dsh.token 填入当前 dsh web 启动 token。' +
|
|
520
|
+
'若凭据文件可读且格式正确,可能是 DSH 版本改变了 cookie 认证格式', { status: 401 });
|
|
521
|
+
}
|
|
522
|
+
return { kind: 'done', value: await readUnaryBody(method, res) };
|
|
314
523
|
}
|
|
315
524
|
/** Create a session. Pass `agentPreset` to pin the session's preset (fixed at
|
|
316
525
|
* creation). */
|
|
@@ -365,14 +574,6 @@ export class DshClient {
|
|
|
365
574
|
async answerRemoteEvent(clientId, eventId, outcome, opts) {
|
|
366
575
|
await this.unary('$events/result', { clientId, eventId, outcome }, opts);
|
|
367
576
|
}
|
|
368
|
-
/** Hand one waterfall delivery back with `next` (another answerer owns it). */
|
|
369
|
-
delegateWaterfall(clientId, eventId, signal) {
|
|
370
|
-
void this.answerRemoteEvent(clientId, eventId, { kind: 'next' }, { signal }).catch((err) => {
|
|
371
|
-
if (!signal.aborted) {
|
|
372
|
-
getLogger().warn(`[dsh] waterfall delegation failed eventId=${eventId}: ${err.message}`);
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
577
|
/**
|
|
377
578
|
* Return the highest event `seq` currently persisted for the session.
|
|
378
579
|
*
|
|
@@ -578,147 +779,60 @@ export class DshClient {
|
|
|
578
779
|
* `next` so a different lark-remote workspace cannot park it.
|
|
579
780
|
*/
|
|
580
781
|
async *approvalEvents(sessionId, signal) {
|
|
581
|
-
|
|
582
|
-
|
|
782
|
+
const state = {
|
|
783
|
+
authRefreshAttempted: false,
|
|
784
|
+
reconnectAttempts: 0,
|
|
785
|
+
sawReady: false,
|
|
786
|
+
};
|
|
583
787
|
while (!signal.aborted) {
|
|
584
|
-
|
|
585
|
-
|
|
788
|
+
// 每次重连重置本次连接的 ready 身份;auth 刷新与重连预算跨连接保留。
|
|
789
|
+
state.sawReady = false;
|
|
790
|
+
state.streamClientId = undefined;
|
|
586
791
|
try {
|
|
587
792
|
for await (const raw of this.muxStream('$events', { args: {} }, signal)) {
|
|
588
793
|
if (signal.aborted)
|
|
589
794
|
return;
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
// ——已渲染的提问卡持有过期 clientId(点答案必失败)、Host 的 cancel
|
|
596
|
-
// 帧也收不到,违反「同一 waterfall 在 $events 重连后会被重投」的
|
|
597
|
-
// 不变量。预算语义是「单次断连窗口内的连续失败上限」,不是终身配额。
|
|
598
|
-
reconnectAttempts = 0;
|
|
599
|
-
sawReady = true;
|
|
600
|
-
streamClientId = frame.clientId;
|
|
601
|
-
yield { kind: 'ready', clientId: streamClientId };
|
|
602
|
-
continue;
|
|
603
|
-
}
|
|
604
|
-
if (frame.type === 'waterfall') {
|
|
605
|
-
if (!streamClientId) {
|
|
606
|
-
yield {
|
|
607
|
-
kind: 'unavailable',
|
|
608
|
-
message: 'DSH approval stream sent a waterfall before ready',
|
|
609
|
-
};
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
if (frame.event === 'user-questions/request' && frame.agentId === sessionId) {
|
|
613
|
-
if (isTimedQuestionRequest(frame.request)) {
|
|
614
|
-
// The timed wait owns its own deadline (and the Web UI renders
|
|
615
|
-
// the countdown); a card answer outside that window is lost.
|
|
616
|
-
getLogger().info(`[dsh] timed user-questions/request delegated to the Web UI eventId=${frame.eventId}`);
|
|
617
|
-
this.delegateWaterfall(streamClientId, frame.eventId, signal);
|
|
618
|
-
continue;
|
|
619
|
-
}
|
|
620
|
-
const questions = dshQuestionsOf(frame.request);
|
|
621
|
-
if (questions) {
|
|
622
|
-
yield {
|
|
623
|
-
kind: 'question',
|
|
624
|
-
eventId: frame.eventId,
|
|
625
|
-
clientId: streamClientId,
|
|
626
|
-
questions,
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
else {
|
|
630
|
-
// The Feishu card cannot answer this batch safely (malformed,
|
|
631
|
-
// or duplicate texts — the coordinator keys answers by text).
|
|
632
|
-
// Hand the one-shot answer back so the DSH Web UI stays in charge.
|
|
633
|
-
getLogger().warn(`[dsh] user-questions/request not relayable; delegating eventId=${frame.eventId}`);
|
|
634
|
-
this.delegateWaterfall(streamClientId, frame.eventId, signal);
|
|
635
|
-
}
|
|
636
|
-
continue;
|
|
637
|
-
}
|
|
638
|
-
if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
|
|
639
|
-
// Every owner answerer receives the waterfall. A non-owner of
|
|
640
|
-
// the session must explicitly delegate, otherwise DSH waits.
|
|
641
|
-
this.delegateWaterfall(streamClientId, frame.eventId, signal);
|
|
642
|
-
continue;
|
|
643
|
-
}
|
|
644
|
-
const data = frame.request;
|
|
645
|
-
const toolName = typeof data.toolName === 'string' ? data.toolName : '';
|
|
646
|
-
const callId = typeof data.callId === 'string' ? data.callId : undefined;
|
|
647
|
-
const reason = typeof data.reason === 'string' ? data.reason : undefined;
|
|
648
|
-
yield {
|
|
649
|
-
kind: 'request',
|
|
650
|
-
eventId: frame.eventId,
|
|
651
|
-
clientId: streamClientId,
|
|
652
|
-
toolName,
|
|
653
|
-
...(callId ? { callId } : {}),
|
|
654
|
-
...(reason ? { reason } : {}),
|
|
655
|
-
};
|
|
656
|
-
continue;
|
|
657
|
-
}
|
|
658
|
-
if (frame.type === 'cancel') {
|
|
659
|
-
yield { kind: 'cancelled', eventId: frame.eventId };
|
|
660
|
-
}
|
|
795
|
+
const result = processApprovalFrame(state, raw, sessionId, signal, this);
|
|
796
|
+
for (const item of result.items)
|
|
797
|
+
yield item;
|
|
798
|
+
if (result.stop)
|
|
799
|
+
return;
|
|
661
800
|
}
|
|
662
801
|
}
|
|
663
802
|
catch (err) {
|
|
664
|
-
|
|
803
|
+
const action = classifyApprovalStreamError(err, state, signal);
|
|
804
|
+
if (action.kind === 'return')
|
|
665
805
|
return;
|
|
666
|
-
if (
|
|
667
|
-
|
|
668
|
-
err.details.status === 401 &&
|
|
669
|
-
!authRefreshAttempted) {
|
|
670
|
-
authRefreshAttempted = true;
|
|
806
|
+
if (action.kind === 'refresh-auth') {
|
|
807
|
+
state.authRefreshAttempted = true;
|
|
671
808
|
this.cookiePromise = undefined;
|
|
672
809
|
getLogger().warn(`[dsh] approval event stream authentication expired; refreshing credentials and reconnecting once`);
|
|
673
810
|
continue;
|
|
674
811
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
(
|
|
678
|
-
|
|
679
|
-
message.includes('interactions-forbidden');
|
|
680
|
-
if (!forbidden && reconnectAttempts < 3) {
|
|
681
|
-
reconnectAttempts += 1;
|
|
682
|
-
getLogger().warn(`[dsh] approval event stream dropped (attempt ${reconnectAttempts}/3); reconnecting`);
|
|
683
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
812
|
+
if (action.kind === 'reconnect') {
|
|
813
|
+
state.reconnectAttempts = action.attempt;
|
|
814
|
+
getLogger().warn(`[dsh] approval event stream dropped (attempt ${action.attempt}/3); reconnecting`);
|
|
815
|
+
await reconnectBackoff();
|
|
684
816
|
continue;
|
|
685
817
|
}
|
|
686
|
-
getLogger().warn(`[dsh] approval event stream unavailable: ${message}`);
|
|
687
|
-
yield { kind: 'unavailable', message };
|
|
818
|
+
getLogger().warn(`[dsh] approval event stream unavailable: ${action.message}`);
|
|
819
|
+
yield { kind: 'unavailable', message: action.message };
|
|
688
820
|
return;
|
|
689
821
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
reconnectAttempts += 1;
|
|
693
|
-
getLogger().warn(`[dsh] approval event stream ended (attempt ${reconnectAttempts}/3); reconnecting`);
|
|
694
|
-
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
697
|
-
// A clean end before ready means the Host does not expose `$events`;
|
|
698
|
-
// fall back to the Web UI prompt instead of retrying forever.
|
|
699
|
-
yield { kind: 'unavailable', message: 'DSH approval event stream ended' };
|
|
822
|
+
const tail = approvalStreamTail(state, signal);
|
|
823
|
+
if (tail === 'aborted')
|
|
700
824
|
return;
|
|
825
|
+
if (tail === 'reconnect') {
|
|
826
|
+
getLogger().warn(`[dsh] approval event stream ended (attempt ${state.reconnectAttempts}/3); reconnecting`);
|
|
827
|
+
await reconnectBackoff();
|
|
828
|
+
continue;
|
|
701
829
|
}
|
|
830
|
+
// A clean end before ready means the Host does not expose `$events`;
|
|
831
|
+
// fall back to the Web UI prompt instead of retrying forever.
|
|
832
|
+
yield { kind: 'unavailable', message: 'DSH approval event stream ended' };
|
|
833
|
+
return;
|
|
702
834
|
}
|
|
703
835
|
}
|
|
704
|
-
/** Translate one durable event into a stream item (approvals are surfaced
|
|
705
|
-
* separately; everything else is forwarded to the runner). */
|
|
706
|
-
eventItem(sessionId, event) {
|
|
707
|
-
if (event.type === 'approval/asked') {
|
|
708
|
-
const data = event.data;
|
|
709
|
-
if (!data.id)
|
|
710
|
-
return undefined;
|
|
711
|
-
return {
|
|
712
|
-
kind: 'approval',
|
|
713
|
-
sessionId,
|
|
714
|
-
approvalId: data.id,
|
|
715
|
-
toolName: data.toolName ?? '',
|
|
716
|
-
...(data.callId ? { callId: data.callId } : {}),
|
|
717
|
-
...(data.reason ? { reason: data.reason } : {}),
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
return { kind: 'event', sessionId, event };
|
|
721
|
-
}
|
|
722
836
|
/**
|
|
723
837
|
* Per-session subscription: yields session events, assistant stream chunks
|
|
724
838
|
* and approval notices for `sessionId`, auto-reconnecting on stream
|
|
@@ -733,92 +847,47 @@ export class DshClient {
|
|
|
733
847
|
* keeps the card "running" until this run's turn actually finishes.
|
|
734
848
|
*/
|
|
735
849
|
async *sessionEvents(sessionId, signal, initialSeq = -1, promptRequestId) {
|
|
736
|
-
const state = {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
return false;
|
|
744
|
-
const source = ev.data
|
|
745
|
-
?.source;
|
|
746
|
-
if (source?.kind === 'user' && source.rpcId === promptRequestId) {
|
|
747
|
-
state.owns = true;
|
|
748
|
-
return true;
|
|
749
|
-
}
|
|
750
|
-
return false;
|
|
850
|
+
const state = {
|
|
851
|
+
lastSeq: initialSeq,
|
|
852
|
+
owns: promptRequestId === undefined,
|
|
853
|
+
foreignTailReported: false,
|
|
854
|
+
sessionId,
|
|
855
|
+
promptRequestId,
|
|
856
|
+
authRefreshAttempted: false,
|
|
751
857
|
};
|
|
752
|
-
const admit = (ev) => {
|
|
753
|
-
if (ownsEvent(ev))
|
|
754
|
-
return true;
|
|
755
|
-
if (!foreignTailReported) {
|
|
756
|
-
foreignTailReported = true;
|
|
757
|
-
getLogger().info(`[dsh] prompt ${promptRequestId ?? '(none)'} is queued behind a running turn for session ${sessionId}; holding the card open until its own turn starts`);
|
|
758
|
-
}
|
|
759
|
-
return false;
|
|
760
|
-
};
|
|
761
|
-
let authRefreshAttempted = false;
|
|
762
858
|
while (!signal.aborted) {
|
|
763
859
|
try {
|
|
764
860
|
for await (const frame of this.followStream(sessionId, signal)) {
|
|
765
861
|
if (signal.aborted)
|
|
766
862
|
return;
|
|
767
|
-
authRefreshAttempted = false;
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
for (const ev of await this.replaySnapshotGap(sessionId, signal, state.lastSeq, frame)) {
|
|
772
|
-
if (ev.seq <= state.lastSeq)
|
|
773
|
-
continue;
|
|
774
|
-
state.lastSeq = ev.seq;
|
|
775
|
-
if (!admit(ev))
|
|
776
|
-
continue;
|
|
777
|
-
const item = this.eventItem(sessionId, ev);
|
|
778
|
-
if (item)
|
|
779
|
-
yield item;
|
|
780
|
-
if (ev.type === 'turn/end')
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
continue;
|
|
784
|
-
}
|
|
785
|
-
if (frame.type === 'event') {
|
|
786
|
-
const ev = frame.event;
|
|
787
|
-
if (ev.seq <= state.lastSeq)
|
|
788
|
-
continue;
|
|
789
|
-
state.lastSeq = ev.seq;
|
|
790
|
-
if (!admit(ev))
|
|
863
|
+
state.authRefreshAttempted = false;
|
|
864
|
+
for (const ev of await sessionFrameEvents(this, state, frame, signal)) {
|
|
865
|
+
const step = advanceSessionEvent(state, ev);
|
|
866
|
+
if (!step)
|
|
791
867
|
continue;
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
if (ev.type === 'turn/end')
|
|
868
|
+
if (step.item)
|
|
869
|
+
yield step.item;
|
|
870
|
+
if (step.done)
|
|
796
871
|
return;
|
|
797
|
-
continue;
|
|
798
872
|
}
|
|
799
|
-
if (frame.type === 'assistant-stream') {
|
|
873
|
+
if (frame.type === 'assistant-stream' && state.owns) {
|
|
800
874
|
// Mid-turn deltas of the turn this prompt is queued behind.
|
|
801
|
-
if (!state.owns)
|
|
802
|
-
continue;
|
|
803
875
|
yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
|
|
804
876
|
}
|
|
805
877
|
}
|
|
806
878
|
}
|
|
807
879
|
catch (err) {
|
|
808
|
-
|
|
880
|
+
const action = classifySessionStreamError(err, state, signal);
|
|
881
|
+
if (action.kind === 'return')
|
|
809
882
|
return;
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
authRefreshAttempted = true;
|
|
816
|
-
this.cookiePromise = undefined;
|
|
817
|
-
getLogger().warn(`[dsh] remote mux authentication expired for session ${sessionId}; refreshing credentials and reconnecting once`);
|
|
818
|
-
continue;
|
|
819
|
-
}
|
|
820
|
-
throw err;
|
|
883
|
+
if (action.kind === 'refresh-auth') {
|
|
884
|
+
state.authRefreshAttempted = true;
|
|
885
|
+
this.cookiePromise = undefined;
|
|
886
|
+
getLogger().warn(`[dsh] remote mux authentication expired for session ${sessionId}; refreshing credentials and reconnecting once`);
|
|
887
|
+
continue;
|
|
821
888
|
}
|
|
889
|
+
if (action.kind === 'rethrow')
|
|
890
|
+
throw err;
|
|
822
891
|
getLogger().warn(`[dsh] remote mux disconnected for session ${sessionId}: ${err.message}; reconnecting`);
|
|
823
892
|
}
|
|
824
893
|
if (signal.aborted)
|