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
|
@@ -1,23 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DshClient — HTTP client for the DSH Web Host
|
|
2
|
+
* DshClient — HTTP/WebSocket client for the DSH Web Host.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* 1.
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* DSH 0.2.x guardrails:
|
|
5
|
+
* 1. `/api/*` and `/api/remote.mux` require a signed `dsh-auth-*` cookie.
|
|
6
|
+
* We mint the v1 cookie from DSH's durable browser-session secret; the
|
|
7
|
+
* launch token remains a fallback via `GET /?token=<launch token>`.
|
|
8
|
+
* 2. Unary calls are POST `/api/<namespace>/<method>` envelopes whose
|
|
9
|
+
* `payload` is `{args:<request>}`.
|
|
10
|
+
* 3. Live session data is a logical stream over `/api/remote.mux`:
|
|
11
|
+
* `{type:'open',streamId,endpoint:'session/follow',payload}` →
|
|
12
|
+
* `{type:'item',streamId,value}` / `{type:'end',streamId}`.
|
|
13
|
+
* The Gateway-owned `$events` stream carries answerable
|
|
14
|
+
* `approval/request` waterfalls; answers go back through
|
|
15
|
+
* `$events/result`.
|
|
8
16
|
*
|
|
9
|
-
* The client is stateless apart from the base URL
|
|
10
|
-
* mux subscription and closes it on terminal.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* history, dedup by event seq).
|
|
17
|
+
* The client is stateless apart from the base URL and auth cookie: each
|
|
18
|
+
* `run()` opens its own remote mux subscription and closes it on terminal.
|
|
19
|
+
* `sessionEvents()` adds per-session filtering, reconnect + follow-snapshot
|
|
20
|
+
* replay, and seq-based dedup.
|
|
14
21
|
*/
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
import WebSocket from 'ws';
|
|
15
24
|
import { getLogger } from '../../logger/index.js';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecret, resolveDshCredentialsFile, } from './auth.js';
|
|
26
|
+
function defaultWebSocketFactory(url, options) {
|
|
27
|
+
const socket = new WebSocket(url, {
|
|
28
|
+
...(options?.headers ? { headers: options.headers } : {}),
|
|
29
|
+
});
|
|
30
|
+
const raw = socket;
|
|
31
|
+
raw.on('unexpected-response', (_request, response) => {
|
|
32
|
+
socket.onunexpectedresponse?.({ statusCode: response.statusCode });
|
|
33
|
+
});
|
|
34
|
+
return socket;
|
|
21
35
|
}
|
|
22
36
|
export class DshError extends Error {
|
|
23
37
|
code;
|
|
@@ -29,172 +43,340 @@ export class DshError extends Error {
|
|
|
29
43
|
this.details = details;
|
|
30
44
|
}
|
|
31
45
|
}
|
|
46
|
+
function isDshAuthError(error) {
|
|
47
|
+
return (error instanceof DshError &&
|
|
48
|
+
error.code === 'http' &&
|
|
49
|
+
(error.details.status === 401 || error.details.status === 403));
|
|
50
|
+
}
|
|
32
51
|
/** Backoff between mux reconnects to avoid a hot loop on a dropping server. */
|
|
33
52
|
const RECONNECT_BACKOFF_MS = 200;
|
|
53
|
+
/** Follow snapshot budget. `session/follow` also stays live after the snapshot. */
|
|
54
|
+
const FOLLOW_MAX_MESSAGES = 200;
|
|
55
|
+
/** Default unary timeout: long prompts are unbounded on the server side. */
|
|
56
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
57
|
+
/** Extract the `token` query parameter from a pasted `dsh web` URL. */
|
|
58
|
+
export function extractDshToken(raw) {
|
|
59
|
+
const value = (raw ?? '').trim();
|
|
60
|
+
if (!value)
|
|
61
|
+
return '';
|
|
62
|
+
if (!value.includes('token=') && !value.includes('://'))
|
|
63
|
+
return value;
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(value.startsWith('?') ? `http://dsh.invalid/${value}` : value);
|
|
66
|
+
return url.searchParams.get('token') ?? value;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Remote methods take named parameters. Most session methods call their single
|
|
74
|
+
* argument `request`; `session/list` reserves `_request`; catalog methods take
|
|
75
|
+
* no arguments. `commands/*` address the receiving agent by its wire field
|
|
76
|
+
* `agentId`, so their payload is already in wire shape (pass-through).
|
|
77
|
+
* The gateway rejects any other `args` shape.
|
|
78
|
+
*/
|
|
79
|
+
const DSH_ARGS_KEY = {
|
|
80
|
+
'session/list': '_request',
|
|
81
|
+
'session/create': 'request',
|
|
82
|
+
'session/prompt': 'request',
|
|
83
|
+
'session/cancel': 'request',
|
|
84
|
+
'session/selectModel': 'request',
|
|
85
|
+
'session/projections': 'request',
|
|
86
|
+
'session/page': 'request',
|
|
87
|
+
'session/follow': 'request',
|
|
88
|
+
'session/modelCatalog': null,
|
|
89
|
+
'agentPresets/list': null,
|
|
90
|
+
'permissionPresets/catalog': null,
|
|
91
|
+
};
|
|
92
|
+
export function dshArgs(method, payload) {
|
|
93
|
+
const key = DSH_ARGS_KEY[method];
|
|
94
|
+
if (key === null)
|
|
95
|
+
return {};
|
|
96
|
+
if (key === undefined)
|
|
97
|
+
return payload;
|
|
98
|
+
return { [key]: payload };
|
|
99
|
+
}
|
|
34
100
|
export class DshClient {
|
|
35
101
|
baseUrl;
|
|
102
|
+
token;
|
|
103
|
+
credentialsFile;
|
|
104
|
+
readCredentials;
|
|
105
|
+
now;
|
|
106
|
+
cookieLifetimeMs;
|
|
36
107
|
fetchImpl;
|
|
37
108
|
webSocketImpl;
|
|
38
|
-
|
|
109
|
+
requestTimeoutMs;
|
|
110
|
+
cookiePromise;
|
|
111
|
+
constructor(baseUrl, options = {}) {
|
|
39
112
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
40
|
-
this.
|
|
41
|
-
this.
|
|
113
|
+
this.token = extractDshToken(options.token);
|
|
114
|
+
this.credentialsFile =
|
|
115
|
+
options.credentialsFile?.trim() || resolveDshCredentialsFile(process.env, options.dshHome);
|
|
116
|
+
this.readCredentials = options.readCredentials ?? readDshBrowserSecret;
|
|
117
|
+
this.now = options.now ?? Date.now;
|
|
118
|
+
this.cookieLifetimeMs = options.cookieLifetimeMs ?? DSH_COOKIE_LIFETIME_MS;
|
|
119
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
120
|
+
this.webSocketImpl = options.webSocketImpl ?? defaultWebSocketFactory;
|
|
121
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
42
122
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Resolve a DSH browser cookie. The durable credentials file is read on every
|
|
125
|
+
* call so a rotated signing secret is picked up without restarting
|
|
126
|
+
* lark-remote. Token bootstrap remains a fallback for hosts without that
|
|
127
|
+
* file; unlike `/connect`, the index handshake mints a v1 cookie that DSH
|
|
128
|
+
* accepts for API and WebSocket requests across its own restarts.
|
|
129
|
+
*/
|
|
130
|
+
async authCookie(force = false, signal) {
|
|
131
|
+
const fromCredentials = await this.cookieFromCredentials();
|
|
132
|
+
if (fromCredentials)
|
|
133
|
+
return fromCredentials;
|
|
134
|
+
if (!this.token)
|
|
135
|
+
return undefined;
|
|
136
|
+
if (force)
|
|
137
|
+
this.cookiePromise = undefined;
|
|
138
|
+
if (!this.cookiePromise) {
|
|
139
|
+
this.cookiePromise = (async () => {
|
|
140
|
+
const url = `${this.baseUrl}/?token=${encodeURIComponent(this.token)}`;
|
|
141
|
+
let res;
|
|
142
|
+
try {
|
|
143
|
+
res = await this.fetchImpl(url, {
|
|
144
|
+
method: 'GET',
|
|
145
|
+
redirect: 'manual',
|
|
146
|
+
...(signal ? { signal } : {}),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
if (signal?.aborted)
|
|
151
|
+
throw err;
|
|
152
|
+
throw new DshError('transport', `DSH auth transport error: ${err.message}`);
|
|
153
|
+
}
|
|
154
|
+
if (res.status !== 303 && !res.ok) {
|
|
155
|
+
throw new DshError('http', `DSH auth HTTP ${res.status}: 凭据文件不可用,且 dsh web 启动 token 无效`, { status: res.status });
|
|
156
|
+
}
|
|
157
|
+
const setCookies = typeof res.headers.getSetCookie === 'function'
|
|
158
|
+
? res.headers.getSetCookie()
|
|
159
|
+
: [res.headers.get('set-cookie')].filter((v) => !!v);
|
|
160
|
+
const cookie = setCookies
|
|
161
|
+
.map((value) => value.split(';')[0]?.trim())
|
|
162
|
+
.filter((value) => !!value)
|
|
163
|
+
.join('; ');
|
|
164
|
+
if (!cookie) {
|
|
165
|
+
throw new DshError('auth', 'DSH index handshake did not return a session cookie');
|
|
166
|
+
}
|
|
167
|
+
return cookie;
|
|
168
|
+
})();
|
|
169
|
+
// A failed handshake must not poison later retries (e.g. after the user
|
|
170
|
+
// updates agents.dsh.token in config.yaml and the runner is rebuilt).
|
|
171
|
+
this.cookiePromise.catch(() => {
|
|
172
|
+
this.cookiePromise = undefined;
|
|
52
173
|
});
|
|
53
174
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
throw new DshError('http', `DSH HTTP ${res.status} for ${method}`);
|
|
59
|
-
}
|
|
60
|
-
let body;
|
|
175
|
+
return this.cookiePromise;
|
|
176
|
+
}
|
|
177
|
+
/** Mint a v1 browser cookie from DSH's durable signing secret. */
|
|
178
|
+
async cookieFromCredentials() {
|
|
61
179
|
try {
|
|
62
|
-
|
|
180
|
+
const secret = await this.readCredentials(this.credentialsFile);
|
|
181
|
+
return mintDshBrowserCookie(this.baseUrl, secret, this.now(), this.cookieLifetimeMs);
|
|
63
182
|
}
|
|
64
183
|
catch (err) {
|
|
65
|
-
|
|
184
|
+
if (isDshCredentialsNotFound(err))
|
|
185
|
+
return undefined;
|
|
186
|
+
if (this.token) {
|
|
187
|
+
getLogger().warn(`[dsh] cannot use DSH credentials file ${this.credentialsFile}: ${err.message}; ` +
|
|
188
|
+
'falling back to the launch token');
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
throw new DshError('auth', `DSH auth credentials file ${this.credentialsFile}: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Perform a unary POST and return the ok value; throws DshError on failure.
|
|
196
|
+
*
|
|
197
|
+
* `signal` aborts the request when the run is stopped; a timeout (default
|
|
198
|
+
* `requestTimeoutMs`) guards against a server that accepted the connection
|
|
199
|
+
* but never answers. Both are composed into the request's AbortSignal.
|
|
200
|
+
*/
|
|
201
|
+
async unary(method, payload, opts = {}) {
|
|
202
|
+
const _timeoutMs = opts.timeoutMs ?? this.requestTimeoutMs;
|
|
203
|
+
// 0/负数 = 不设超时(兼容「长请求不取消」的配置),其余走 AbortSignal.timeout。
|
|
204
|
+
const timeoutSignal = _timeoutMs > 0 ? AbortSignal.timeout(_timeoutMs) : undefined;
|
|
205
|
+
const signal = opts.signal && timeoutSignal
|
|
206
|
+
? AbortSignal.any([opts.signal, timeoutSignal])
|
|
207
|
+
: (opts.signal ?? timeoutSignal);
|
|
208
|
+
const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
209
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
210
|
+
if (opts.signal?.aborted) {
|
|
211
|
+
throw new DshError('aborted', `DSH unary ${method} aborted before send`);
|
|
212
|
+
}
|
|
213
|
+
let cookie;
|
|
214
|
+
try {
|
|
215
|
+
cookie = await this.authCookie(attempt > 0, signal);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (opts.signal?.aborted || signal?.aborted) {
|
|
219
|
+
throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
|
|
220
|
+
}
|
|
221
|
+
// A token bootstrap can race a DSH restart; retry once with a fresh
|
|
222
|
+
// credentials read before surfacing the deterministic auth failure.
|
|
223
|
+
if (attempt === 0 &&
|
|
224
|
+
err instanceof DshError &&
|
|
225
|
+
err.code === 'http' &&
|
|
226
|
+
err.details.status === 401) {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
const headers = { 'content-type': 'application/json' };
|
|
232
|
+
if (cookie)
|
|
233
|
+
headers.cookie = cookie;
|
|
234
|
+
let res;
|
|
235
|
+
try {
|
|
236
|
+
res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers,
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
type: 'client-request',
|
|
241
|
+
rpcId,
|
|
242
|
+
method,
|
|
243
|
+
payload: { args: dshArgs(method, payload) },
|
|
244
|
+
}),
|
|
245
|
+
...(signal ? { signal } : {}),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
if (opts.signal?.aborted || signal?.aborted) {
|
|
250
|
+
throw this.abortError(method, err, opts.signal, timeoutSignal, _timeoutMs);
|
|
251
|
+
}
|
|
252
|
+
throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
|
|
253
|
+
}
|
|
254
|
+
if (opts.signal?.aborted) {
|
|
255
|
+
throw new DshError('aborted', `DSH unary ${method} aborted`);
|
|
256
|
+
}
|
|
257
|
+
if (res.status === 401 && attempt === 0) {
|
|
258
|
+
this.cookiePromise = undefined;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (res.status === 401) {
|
|
262
|
+
throw new DshError('http', `DSH HTTP 401 for ${method}: 认证失败;请确认 ${this.credentialsFile} 可读,` +
|
|
263
|
+
'或在 config.yaml 的 agents.dsh.token 填入当前 dsh web 启动 token。' +
|
|
264
|
+
'若凭据文件可读且格式正确,可能是 DSH 版本改变了 cookie 认证格式', { status: 401 });
|
|
265
|
+
}
|
|
266
|
+
if (!res.ok) {
|
|
267
|
+
throw new DshError('http', `DSH HTTP ${res.status} for ${method}`, {
|
|
268
|
+
status: res.status,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
let body;
|
|
272
|
+
try {
|
|
273
|
+
body = (await res.json());
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
|
|
277
|
+
}
|
|
278
|
+
if (body.result?.ok !== true) {
|
|
279
|
+
const err = body.result?.error;
|
|
280
|
+
throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
|
|
281
|
+
}
|
|
282
|
+
return body.result.value;
|
|
66
283
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
284
|
+
throw new DshError('auth', `DSH ${method} authentication failed after refresh`);
|
|
285
|
+
}
|
|
286
|
+
/** Distinguish "run stopped" (abort) from "server did not answer" (timeout). */
|
|
287
|
+
abortError(method, err, runSignal, timeoutSignal, timeoutMs) {
|
|
288
|
+
if (runSignal?.aborted) {
|
|
289
|
+
return new DshError('aborted', `DSH unary ${method} aborted`, { cause: err });
|
|
290
|
+
}
|
|
291
|
+
if (timeoutSignal?.aborted) {
|
|
292
|
+
return new DshError('timeout', `DSH unary ${method} timed out after ${timeoutMs}ms`, {
|
|
293
|
+
cause: err,
|
|
294
|
+
});
|
|
70
295
|
}
|
|
71
|
-
return
|
|
296
|
+
return new DshError('aborted', `DSH unary ${method} aborted: ${err.message}`);
|
|
72
297
|
}
|
|
73
298
|
/** Create a session. Pass `agentPreset` to pin the session's preset (fixed at
|
|
74
299
|
* creation). */
|
|
75
|
-
async createSession(payload) {
|
|
76
|
-
return (await this.unary('session
|
|
300
|
+
async createSession(payload, opts) {
|
|
301
|
+
return (await this.unary('session/create', payload, opts));
|
|
77
302
|
}
|
|
78
303
|
/** Align the session's model/reasoningEffort (also persists as global default
|
|
79
304
|
* server-side). Failures are surfaced as DshError for the caller to downgrade. */
|
|
80
|
-
async selectModel(payload) {
|
|
81
|
-
const value = (await this.unary('session
|
|
305
|
+
async selectModel(payload, opts) {
|
|
306
|
+
const value = (await this.unary('session/selectModel', payload, opts));
|
|
82
307
|
return value.selected;
|
|
83
308
|
}
|
|
84
|
-
/** Read the model catalog (session
|
|
85
|
-
async listModels() {
|
|
86
|
-
return (await this.unary('
|
|
87
|
-
}
|
|
88
|
-
/** Read the preset catalog (agentPreset.list). */
|
|
89
|
-
async listPresets() {
|
|
90
|
-
return (await this.unary('agentPreset.list', {}));
|
|
309
|
+
/** Read the model catalog (session/modelCatalog). */
|
|
310
|
+
async listModels(opts) {
|
|
311
|
+
return (await this.unary('session/modelCatalog', {}, opts));
|
|
91
312
|
}
|
|
92
313
|
/**
|
|
93
|
-
*
|
|
314
|
+
* Execute one slash command against a session (commands/execute).
|
|
315
|
+
*
|
|
316
|
+
* This is the only way to run a DSH command: `session/prompt` turns the text
|
|
317
|
+
* into an ordinary user message and never routes it through the command
|
|
318
|
+
* registry, so `/compact` sent as a prompt would just be model input.
|
|
94
319
|
*
|
|
95
|
-
* The
|
|
96
|
-
*
|
|
97
|
-
*
|
|
320
|
+
* The RPC settles when the handler settles, not when it is accepted: DSH's
|
|
321
|
+
* `/compact` waits for the compaction to finish. Callers therefore must pass
|
|
322
|
+
* an explicit `timeoutMs` (0 = no unary budget) and rely on their own
|
|
323
|
+
* cancellation/watchdog instead of the 60s default.
|
|
98
324
|
*/
|
|
99
|
-
async
|
|
100
|
-
const value = (await this.unary('
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
325
|
+
async executeCommand(sessionId, line, opts) {
|
|
326
|
+
const value = (await this.unary('commands/execute', { agentId: sessionId, line, submittedAttachments: [] }, opts));
|
|
327
|
+
if (!value || typeof value !== 'object')
|
|
328
|
+
return undefined;
|
|
329
|
+
const result = value.result && typeof value.result === 'object' ? value.result : value;
|
|
330
|
+
return {
|
|
331
|
+
...(typeof value.commandId === 'string' ? { commandId: value.commandId } : {}),
|
|
332
|
+
...(result.kind === 'success' || result.kind === 'error' ? { kind: result.kind } : {}),
|
|
333
|
+
...(typeof result.text === 'string' ? { text: result.text } : {}),
|
|
334
|
+
...(typeof result.sourceEventSeq === 'number'
|
|
335
|
+
? { sourceEventSeq: result.sourceEventSeq }
|
|
336
|
+
: {}),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
/** Read the preset catalog (agentPresets/list). */
|
|
340
|
+
async listPresets() {
|
|
341
|
+
return (await this.unary('agentPresets/list', {}));
|
|
342
|
+
}
|
|
343
|
+
/** Read the permission preset catalog (permissionPresets/catalog). */
|
|
344
|
+
async listPermissionPresets() {
|
|
345
|
+
return (await this.unary('permissionPresets/catalog', {}));
|
|
346
|
+
}
|
|
347
|
+
/** Answer one forwarded approval waterfall ($events/result). */
|
|
348
|
+
async answerRemoteEvent(clientId, eventId, outcome, opts) {
|
|
349
|
+
await this.unary('$events/result', { clientId, eventId, outcome }, opts);
|
|
111
350
|
}
|
|
112
351
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
352
|
+
* Return the highest event `seq` currently persisted for the session.
|
|
353
|
+
*
|
|
354
|
+
* `session/projections` is the unary watermark (the old `session.history`
|
|
355
|
+
* high-water scan is gone in 0.2.x). The caller snapshots this before
|
|
356
|
+
* `session/prompt` so a follow snapshot can skip already-completed turns.
|
|
115
357
|
*/
|
|
116
|
-
async
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
throw new DshError('transport', `DSH mux open error: ${err.message}`);
|
|
129
|
-
}
|
|
130
|
-
if (!res.ok || !res.body) {
|
|
131
|
-
throw new DshError('http', `DSH mux HTTP ${res.status}`, { status: res.status });
|
|
132
|
-
}
|
|
133
|
-
const reader = res.body.getReader();
|
|
134
|
-
const decoder = new TextDecoder();
|
|
135
|
-
let buffer = '';
|
|
136
|
-
try {
|
|
137
|
-
for (;;) {
|
|
138
|
-
if (signal.aborted)
|
|
139
|
-
break;
|
|
140
|
-
let result;
|
|
141
|
-
try {
|
|
142
|
-
result = await reader.read();
|
|
143
|
-
}
|
|
144
|
-
catch (err) {
|
|
145
|
-
if (signal.aborted)
|
|
146
|
-
break;
|
|
147
|
-
throw new DshError('transport', `DSH mux read error: ${err.message}`);
|
|
148
|
-
}
|
|
149
|
-
if (result.done)
|
|
150
|
-
break;
|
|
151
|
-
buffer += decoder.decode(result.value, { stream: true });
|
|
152
|
-
let idx;
|
|
153
|
-
while ((idx = buffer.indexOf('\n\n')) !== -1) {
|
|
154
|
-
const chunk = buffer.slice(0, idx);
|
|
155
|
-
buffer = buffer.slice(idx + 2);
|
|
156
|
-
const dataLine = chunk.split('\n').find((l) => l.startsWith('data:'));
|
|
157
|
-
if (!dataLine)
|
|
158
|
-
continue;
|
|
159
|
-
const json = dataLine.slice(5).trim();
|
|
160
|
-
if (!json)
|
|
161
|
-
continue;
|
|
162
|
-
try {
|
|
163
|
-
const frame = JSON.parse(json);
|
|
164
|
-
if (frame?.type === 'server-request')
|
|
165
|
-
yield frame;
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
getLogger().warn('[dsh] dropped malformed mux frame');
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
finally {
|
|
174
|
-
reader.releaseLock();
|
|
175
|
-
try {
|
|
176
|
-
await res.body.cancel();
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
/* ignore cancel error */
|
|
180
|
-
}
|
|
181
|
-
}
|
|
358
|
+
async latestEventSeq(sessionId, opts) {
|
|
359
|
+
const value = (await this.unary('session/projections', { sessionId }, opts));
|
|
360
|
+
return typeof value?.asOfSeq === 'number' ? value.asOfSeq : -1;
|
|
361
|
+
}
|
|
362
|
+
/** Read one cold history page. `throughSeq` normally comes from
|
|
363
|
+
* `latestEventSeq()`; `session/page` needs an inclusive cursor. */
|
|
364
|
+
async page(sessionId, throughSeq, maxMessages, opts) {
|
|
365
|
+
return (await this.unary('session/page', {
|
|
366
|
+
address: { kind: 'session', sessionId },
|
|
367
|
+
throughSeq,
|
|
368
|
+
maxMessages,
|
|
369
|
+
}, opts));
|
|
182
370
|
}
|
|
183
371
|
/**
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* `
|
|
187
|
-
* close/abort, letting `sessionEvents()` reconnect through the same
|
|
188
|
-
* history-replay loop.
|
|
372
|
+
* One logical stream over `/api/remote.mux`. The WebSocket must carry the
|
|
373
|
+
* same auth cookie as unary calls. Ends on the server's `end` frame, socket
|
|
374
|
+
* close, error, or `signal` abort.
|
|
189
375
|
*/
|
|
190
|
-
async *
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
catch (err) {
|
|
196
|
-
throw new DshError('transport', `DSH mux WebSocket open error: ${err.message}`);
|
|
197
|
-
}
|
|
376
|
+
async *muxStream(endpoint, payload, signal) {
|
|
377
|
+
const cookie = await this.authCookie();
|
|
378
|
+
const socket = this.webSocketImpl(`${this.baseUrl.replace(/^http/, 'ws')}/api/remote.mux`, cookie ? { headers: { cookie } } : undefined);
|
|
379
|
+
const streamId = `lr-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
198
380
|
const queue = [];
|
|
199
381
|
let waiter = null;
|
|
200
382
|
let ended = false;
|
|
@@ -211,24 +393,68 @@ export class DshClient {
|
|
|
211
393
|
else
|
|
212
394
|
ended = true;
|
|
213
395
|
};
|
|
214
|
-
socket.
|
|
396
|
+
socket.onopen = () => {
|
|
215
397
|
if (ended)
|
|
216
398
|
return;
|
|
217
|
-
|
|
399
|
+
try {
|
|
400
|
+
socket.send(JSON.stringify({
|
|
401
|
+
type: 'open',
|
|
402
|
+
streamId,
|
|
403
|
+
endpoint,
|
|
404
|
+
payload,
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
catch (err) {
|
|
408
|
+
failure = new DshError('transport', `DSH remote mux send error: ${err.message}`);
|
|
409
|
+
ended = true;
|
|
410
|
+
settle(undefined);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
socket.onmessage = (event) => {
|
|
414
|
+
if (ended)
|
|
218
415
|
return;
|
|
416
|
+
const text = typeof event.data === 'string'
|
|
417
|
+
? event.data
|
|
418
|
+
: Buffer.isBuffer(event.data)
|
|
419
|
+
? event.data.toString('utf8')
|
|
420
|
+
: String(event.data);
|
|
421
|
+
let message;
|
|
219
422
|
try {
|
|
220
|
-
|
|
221
|
-
if (frame?.type === 'server-request')
|
|
222
|
-
settle(frame);
|
|
423
|
+
message = JSON.parse(text);
|
|
223
424
|
}
|
|
224
425
|
catch {
|
|
225
|
-
getLogger().warn('[dsh] dropped malformed
|
|
426
|
+
getLogger().warn('[dsh] dropped malformed remote mux frame');
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (message.streamId !== streamId)
|
|
430
|
+
return;
|
|
431
|
+
if (message.type === 'item' && message.value) {
|
|
432
|
+
settle(message.value);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (message.type === 'end') {
|
|
436
|
+
ended = true;
|
|
437
|
+
settle(undefined);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (message.type === 'error') {
|
|
441
|
+
failure = new DshError(message.error?.code ?? 'stream', message.error?.message ?? 'DSH remote stream failed', message.error?.details ?? {});
|
|
442
|
+
ended = true;
|
|
443
|
+
settle(undefined);
|
|
226
444
|
}
|
|
227
445
|
};
|
|
228
446
|
socket.onerror = () => {
|
|
229
447
|
if (ended)
|
|
230
448
|
return;
|
|
231
|
-
failure = new DshError('transport', 'DSH mux WebSocket error');
|
|
449
|
+
failure = new DshError('transport', 'DSH remote mux WebSocket error');
|
|
450
|
+
ended = true;
|
|
451
|
+
settle(undefined);
|
|
452
|
+
};
|
|
453
|
+
socket.onunexpectedresponse = (event) => {
|
|
454
|
+
if (ended)
|
|
455
|
+
return;
|
|
456
|
+
const status = event.statusCode;
|
|
457
|
+
failure = new DshError('http', `DSH remote mux WebSocket HTTP ${status === undefined ? 'error' : status}`, status === undefined ? {} : { status });
|
|
232
458
|
ended = true;
|
|
233
459
|
settle(undefined);
|
|
234
460
|
};
|
|
@@ -283,126 +509,203 @@ export class DshClient {
|
|
|
283
509
|
}
|
|
284
510
|
}
|
|
285
511
|
}
|
|
512
|
+
/** One logical `session/follow` stream. */
|
|
513
|
+
async *followStream(sessionId, signal) {
|
|
514
|
+
const payload = {
|
|
515
|
+
args: dshArgs('session/follow', {
|
|
516
|
+
address: { kind: 'session', sessionId },
|
|
517
|
+
assistantStream: true,
|
|
518
|
+
maxMessages: FOLLOW_MAX_MESSAGES,
|
|
519
|
+
}),
|
|
520
|
+
};
|
|
521
|
+
for await (const frame of this.muxStream('session/follow', payload, signal)) {
|
|
522
|
+
yield frame;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
286
525
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
526
|
+
* Consume the Gateway-owned `$events` stream. The stream is owner-only and
|
|
527
|
+
* answer-capable; each waterfall for another session is delegated with
|
|
528
|
+
* `next` so a different lark-remote workspace cannot park it.
|
|
290
529
|
*/
|
|
291
|
-
async *
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
530
|
+
async *approvalEvents(sessionId, signal) {
|
|
531
|
+
let authRefreshAttempted = false;
|
|
532
|
+
let reconnectAttempts = 0;
|
|
533
|
+
while (!signal.aborted) {
|
|
534
|
+
let streamClientId;
|
|
535
|
+
let sawReady = false;
|
|
536
|
+
try {
|
|
537
|
+
for await (const raw of this.muxStream('$events', { args: {} }, signal)) {
|
|
538
|
+
if (signal.aborted)
|
|
539
|
+
return;
|
|
540
|
+
const frame = raw;
|
|
541
|
+
if (frame.type === 'ready') {
|
|
542
|
+
authRefreshAttempted = false;
|
|
543
|
+
sawReady = true;
|
|
544
|
+
streamClientId = frame.clientId;
|
|
545
|
+
yield { kind: 'ready', clientId: streamClientId };
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (frame.type === 'waterfall') {
|
|
549
|
+
if (!streamClientId) {
|
|
550
|
+
yield {
|
|
551
|
+
kind: 'unavailable',
|
|
552
|
+
message: 'DSH approval stream sent a waterfall before ready',
|
|
553
|
+
};
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (frame.event !== 'approval/request' || frame.agentId !== sessionId) {
|
|
557
|
+
// Every owner answerer receives the waterfall. A non-owner of
|
|
558
|
+
// the session must explicitly delegate, otherwise DSH waits.
|
|
559
|
+
void this.answerRemoteEvent(streamClientId, frame.eventId, { kind: 'next' }, { signal }).catch((err) => {
|
|
560
|
+
if (!signal.aborted) {
|
|
561
|
+
getLogger().warn(`[dsh] approval waterfall delegation failed eventId=${frame.eventId}: ${err.message}`);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const data = frame.request;
|
|
567
|
+
const toolName = typeof data.toolName === 'string' ? data.toolName : '';
|
|
568
|
+
const callId = typeof data.callId === 'string' ? data.callId : undefined;
|
|
569
|
+
const reason = typeof data.reason === 'string' ? data.reason : undefined;
|
|
570
|
+
yield {
|
|
571
|
+
kind: 'request',
|
|
572
|
+
eventId: frame.eventId,
|
|
573
|
+
clientId: streamClientId,
|
|
574
|
+
toolName,
|
|
575
|
+
...(callId ? { callId } : {}),
|
|
576
|
+
...(reason ? { reason } : {}),
|
|
577
|
+
};
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (frame.type === 'cancel') {
|
|
581
|
+
yield { kind: 'cancelled', eventId: frame.eventId };
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
298
586
|
if (signal.aborted)
|
|
299
587
|
return;
|
|
300
|
-
|
|
301
|
-
|
|
588
|
+
if (err instanceof DshError &&
|
|
589
|
+
err.code === 'http' &&
|
|
590
|
+
err.details.status === 401 &&
|
|
591
|
+
!authRefreshAttempted) {
|
|
592
|
+
authRefreshAttempted = true;
|
|
593
|
+
this.cookiePromise = undefined;
|
|
594
|
+
getLogger().warn(`[dsh] approval event stream authentication expired; refreshing credentials and reconnecting once`);
|
|
302
595
|
continue;
|
|
303
|
-
|
|
304
|
-
|
|
596
|
+
}
|
|
597
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
598
|
+
const forbidden = (err instanceof DshError &&
|
|
599
|
+
(err.code === 'gateway/interactions-forbidden' ||
|
|
600
|
+
(err.code === 'http' && err.details.status === 403))) ||
|
|
601
|
+
message.includes('interactions-forbidden');
|
|
602
|
+
if (!forbidden && reconnectAttempts < 3) {
|
|
603
|
+
reconnectAttempts += 1;
|
|
604
|
+
getLogger().warn(`[dsh] approval event stream dropped (attempt ${reconnectAttempts}/3); reconnecting`);
|
|
605
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
305
606
|
continue;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
yield
|
|
607
|
+
}
|
|
608
|
+
getLogger().warn(`[dsh] approval event stream unavailable: ${message}`);
|
|
609
|
+
yield { kind: 'unavailable', message };
|
|
610
|
+
return;
|
|
309
611
|
}
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
612
|
+
if (!signal.aborted) {
|
|
613
|
+
if (sawReady && reconnectAttempts < 3) {
|
|
614
|
+
reconnectAttempts += 1;
|
|
615
|
+
getLogger().warn(`[dsh] approval event stream ended (attempt ${reconnectAttempts}/3); reconnecting`);
|
|
616
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
// A clean end before ready means the Host does not expose `$events`;
|
|
620
|
+
// fall back to the Web UI prompt instead of retrying forever.
|
|
621
|
+
yield { kind: 'unavailable', message: 'DSH approval event stream ended' };
|
|
313
622
|
return;
|
|
314
|
-
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Translate one durable event into a stream item (approvals are surfaced
|
|
627
|
+
* separately; everything else is forwarded to the runner). */
|
|
628
|
+
eventItem(sessionId, event) {
|
|
629
|
+
if (event.type === 'approval/asked') {
|
|
630
|
+
const data = event.data;
|
|
631
|
+
if (!data.id)
|
|
632
|
+
return undefined;
|
|
633
|
+
return {
|
|
634
|
+
kind: 'approval',
|
|
635
|
+
sessionId,
|
|
636
|
+
approvalId: data.id,
|
|
637
|
+
toolName: data.toolName ?? '',
|
|
638
|
+
...(data.callId ? { callId: data.callId } : {}),
|
|
639
|
+
...(data.reason ? { reason: data.reason } : {}),
|
|
640
|
+
};
|
|
315
641
|
}
|
|
642
|
+
return { kind: 'event', sessionId, event };
|
|
316
643
|
}
|
|
317
644
|
/**
|
|
318
|
-
* Per-session subscription: yields session events
|
|
319
|
-
* `sessionId`, auto-reconnecting on stream
|
|
320
|
-
*
|
|
321
|
-
* or `signal` abort.
|
|
645
|
+
* Per-session subscription: yields session events, assistant stream chunks
|
|
646
|
+
* and approval notices for `sessionId`, auto-reconnecting on stream
|
|
647
|
+
* drop/error and replaying the follow snapshot (dedup by seq). Terminates on
|
|
648
|
+
* turn/end or `signal` abort.
|
|
322
649
|
*/
|
|
323
650
|
async *sessionEvents(sessionId, signal, initialSeq = -1) {
|
|
324
651
|
const state = { lastSeq: initialSeq };
|
|
325
|
-
|
|
326
|
-
// history once so events produced between prompt-accept and the mux opening
|
|
327
|
-
// (e.g. a fast turn that already ended) are not lost. Dedup by seq keeps the
|
|
328
|
-
// replay and the live stream convergent.
|
|
329
|
-
for await (const ev of this.replayHistory(sessionId, signal, state, 'initial history baseline')) {
|
|
330
|
-
if (signal.aborted)
|
|
331
|
-
return;
|
|
332
|
-
yield { kind: 'event', sessionId, event: ev };
|
|
333
|
-
if (ev.type === 'turn/end')
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
let muxMode = 'fetch';
|
|
652
|
+
let authRefreshAttempted = false;
|
|
337
653
|
while (!signal.aborted) {
|
|
338
|
-
let dropped = false;
|
|
339
654
|
try {
|
|
340
|
-
|
|
341
|
-
for await (const frame of frames) {
|
|
655
|
+
for await (const frame of this.followStream(sessionId, signal)) {
|
|
342
656
|
if (signal.aborted)
|
|
343
657
|
return;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
reason: p.reason,
|
|
357
|
-
};
|
|
658
|
+
authRefreshAttempted = false;
|
|
659
|
+
if (frame.type === 'snapshot') {
|
|
660
|
+
for (const record of frame.records ?? []) {
|
|
661
|
+
const ev = record.event;
|
|
662
|
+
if (!ev || ev.seq <= state.lastSeq)
|
|
663
|
+
continue;
|
|
664
|
+
state.lastSeq = ev.seq;
|
|
665
|
+
const item = this.eventItem(sessionId, ev);
|
|
666
|
+
if (item)
|
|
667
|
+
yield item;
|
|
668
|
+
if (ev.type === 'turn/end')
|
|
669
|
+
return;
|
|
358
670
|
}
|
|
359
671
|
continue;
|
|
360
672
|
}
|
|
361
|
-
if (frame.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
673
|
+
if (frame.type === 'event') {
|
|
674
|
+
const ev = frame.event;
|
|
675
|
+
if (ev.seq <= state.lastSeq)
|
|
676
|
+
continue;
|
|
677
|
+
state.lastSeq = ev.seq;
|
|
678
|
+
const item = this.eventItem(sessionId, ev);
|
|
679
|
+
if (item)
|
|
680
|
+
yield item;
|
|
681
|
+
if (ev.type === 'turn/end')
|
|
682
|
+
return;
|
|
365
683
|
continue;
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
state.lastSeq = seq;
|
|
371
|
-
yield { kind: 'event', sessionId, event: payload.event };
|
|
372
|
-
if (payload.event.type === 'turn/end')
|
|
373
|
-
return;
|
|
684
|
+
}
|
|
685
|
+
if (frame.type === 'assistant-stream') {
|
|
686
|
+
yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
|
|
687
|
+
}
|
|
374
688
|
}
|
|
375
|
-
dropped = true; // stream closed normally → treat as drop, replay history
|
|
376
689
|
}
|
|
377
690
|
catch (err) {
|
|
378
691
|
if (signal.aborted)
|
|
379
692
|
return;
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
693
|
+
// A DSH restart invalidates the process-local v2 operator cookie. A
|
|
694
|
+
// fresh credentials read mints a new v1 cookie, so retry once; a
|
|
695
|
+
// second auth failure is deterministic and belongs on the run card.
|
|
696
|
+
if (isDshAuthError(err)) {
|
|
697
|
+
if (!authRefreshAttempted) {
|
|
698
|
+
authRefreshAttempted = true;
|
|
699
|
+
this.cookiePromise = undefined;
|
|
700
|
+
getLogger().warn(`[dsh] remote mux authentication expired for session ${sessionId}; refreshing credentials and reconnecting once`);
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
throw err;
|
|
388
704
|
}
|
|
389
|
-
getLogger().warn(`[dsh] mux disconnected for session ${sessionId}: ${err.message};
|
|
390
|
-
dropped = true;
|
|
705
|
+
getLogger().warn(`[dsh] remote mux disconnected for session ${sessionId}: ${err.message}; reconnecting`);
|
|
391
706
|
}
|
|
392
707
|
if (signal.aborted)
|
|
393
708
|
return;
|
|
394
|
-
if (!dropped)
|
|
395
|
-
break;
|
|
396
|
-
// Reconnect baseline: replay history events after lastSeq (dedup keeps
|
|
397
|
-
// the live stream and the replay convergent).
|
|
398
|
-
for await (const ev of this.replayHistory(sessionId, signal, state, 'history replay')) {
|
|
399
|
-
if (signal.aborted)
|
|
400
|
-
return;
|
|
401
|
-
yield { kind: 'event', sessionId, event: ev };
|
|
402
|
-
if (ev.type === 'turn/end')
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
// Bound reconnect frequency.
|
|
406
709
|
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
|
|
407
710
|
}
|
|
408
711
|
}
|
|
@@ -411,4 +714,8 @@ export class DshClient {
|
|
|
411
714
|
return data.usage ?? undefined;
|
|
412
715
|
}
|
|
413
716
|
}
|
|
717
|
+
/** Stable client-side prompt identity persisted on the accepted user message. */
|
|
718
|
+
export function newPromptRequestId() {
|
|
719
|
+
return `lark-remote-${randomUUID()}`;
|
|
720
|
+
}
|
|
414
721
|
//# sourceMappingURL=client.js.map
|