lark-remote 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/bridge/approval-coordinator.d.ts +18 -1
  5. package/dist/bridge/approval-coordinator.js +57 -10
  6. package/dist/bridge/index.js +90 -17
  7. package/dist/card/approval-render.js +18 -0
  8. package/dist/card/run-state.js +10 -5
  9. package/dist/config/index.d.ts +16 -0
  10. package/dist/config/index.js +46 -6
  11. package/dist/config/opencode-config.d.ts +3 -3
  12. package/dist/config/opencode-config.js +38 -22
  13. package/dist/inbound/authorize.d.ts +34 -0
  14. package/dist/inbound/authorize.js +31 -0
  15. package/dist/index.js +116 -63
  16. package/dist/instance-lock.d.ts +9 -0
  17. package/dist/instance-lock.js +113 -42
  18. package/dist/router/config/dsh.d.ts +51 -21
  19. package/dist/router/config/dsh.js +255 -66
  20. package/dist/router/config/opencode.js +16 -6
  21. package/dist/router/config/types.d.ts +6 -3
  22. package/dist/router/index.d.ts +1 -0
  23. package/dist/router/index.js +13 -5
  24. package/dist/runner/codex/app-server/translator.js +21 -7
  25. package/dist/runner/common/acp/base-acp-runner.d.ts +12 -0
  26. package/dist/runner/common/acp/base-acp-runner.js +83 -8
  27. package/dist/runner/common/acp/protocol-types.d.ts +10 -2
  28. package/dist/runner/common/acp/protocol-types.js +3 -1
  29. package/dist/runner/common/connection-based-runner.d.ts +15 -0
  30. package/dist/runner/common/connection-based-runner.js +40 -5
  31. package/dist/runner/common/jsonrpc/connection-manager.d.ts +15 -0
  32. package/dist/runner/common/jsonrpc/connection-manager.js +83 -50
  33. package/dist/runner/dsh/approval.d.ts +53 -0
  34. package/dist/runner/dsh/approval.js +161 -0
  35. package/dist/runner/dsh/auth.d.ts +36 -0
  36. package/dist/runner/dsh/auth.js +118 -0
  37. package/dist/runner/dsh/client.d.ts +161 -42
  38. package/dist/runner/dsh/client.js +551 -244
  39. package/dist/runner/dsh/daemon.d.ts +85 -0
  40. package/dist/runner/dsh/daemon.js +272 -0
  41. package/dist/runner/dsh/merge.d.ts +9 -0
  42. package/dist/runner/dsh/merge.js +51 -0
  43. package/dist/runner/dsh/runner.d.ts +94 -11
  44. package/dist/runner/dsh/runner.js +553 -67
  45. package/dist/runner/dsh/translator.d.ts +43 -3
  46. package/dist/runner/dsh/translator.js +115 -38
  47. package/dist/runner/dsh/types.d.ts +189 -27
  48. package/dist/runner/dsh/types.js +11 -5
  49. package/dist/runner/kimi/acp/runner.js +15 -3
  50. package/dist/runner/opencode/acp/error-monitor.d.ts +2 -2
  51. package/dist/runner/opencode/acp/error-monitor.js +2 -2
  52. package/dist/runner/opencode/acp/runner.d.ts +9 -0
  53. package/dist/runner/opencode/acp/runner.js +40 -1
  54. package/dist/runner/opencode/acp/translator.d.ts +4 -2
  55. package/dist/runner/opencode/acp/translator.js +12 -3
  56. package/dist/runner/pi/rpc/translator.d.ts +2 -0
  57. package/dist/runner/pi/rpc/translator.js +27 -1
  58. package/dist/runner/types.d.ts +34 -0
  59. package/dist/runner/types.js +24 -0
  60. package/dist/session/codex/rollout-reader.js +44 -7
  61. package/dist/session/dsh/sessions.d.ts +45 -6
  62. package/dist/session/dsh/sessions.js +171 -27
  63. package/dist/session/opencode/sessions.d.ts +18 -7
  64. package/dist/session/opencode/sessions.js +294 -15
  65. package/dist/session/pi/sessions.js +6 -2
  66. package/package.json +3 -1
@@ -1,23 +1,37 @@
1
1
  /**
2
- * DshClient — HTTP client for the DSH Web Host proxy.
2
+ * DshClient — HTTP/WebSocket client for the DSH Web Host.
3
3
  *
4
- * Two transports:
5
- * 1. unary POST /api/<method> (client-request → server-response).
6
- * 2. SSE GET /api/events.mux (all-session aggregated stream). Frames are
7
- * ServerRequest envelopes; `method` equals the MuxFrame's `type`.
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: each `run()` opens its own
10
- * mux subscription and closes it on terminal. `sessionEvents()` wraps the raw
11
- * mux with per-session filtering plus reconnect + history-replay dedup
12
- * (DSH v1 has no `since` resume — reconnection = reopen stream + refetch
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
- function defaultWebSocketFactory(url) {
17
- const Ctor = globalThis.WebSocket;
18
- if (!Ctor)
19
- throw new Error('DSH WebSocket is unavailable');
20
- return new Ctor(url);
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
- constructor(baseUrl, fetchImpl = fetch, webSocketImpl = defaultWebSocketFactory) {
109
+ requestTimeoutMs;
110
+ cookiePromise;
111
+ constructor(baseUrl, options = {}) {
39
112
  this.baseUrl = baseUrl.replace(/\/$/, '');
40
- this.fetchImpl = fetchImpl;
41
- this.webSocketImpl = webSocketImpl;
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
- /** Perform a unary POST and return the ok value; throws DshError on failure. */
44
- async unary(method, payload) {
45
- const rpcId = `rpc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
46
- let res;
47
- try {
48
- res = await this.fetchImpl(`${this.baseUrl}/api/${method}`, {
49
- method: 'POST',
50
- headers: { 'content-type': 'application/json' },
51
- body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
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
- catch (err) {
55
- throw new DshError('transport', `DSH unary ${method} transport error: ${err.message}`);
56
- }
57
- if (!res.ok) {
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
- body = (await res.json());
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
- throw new DshError('protocol', `DSH ${method} returned non-JSON: ${err.message}`);
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
- if (body.result?.ok !== true) {
68
- const err = body.result?.error;
69
- throw new DshError(err?.code ?? 'error', err?.message ?? `DSH ${method} failed`, err?.details ?? {});
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 body.result.value;
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.create', payload));
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.selectModel', payload));
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-independent, llm.models). */
85
- async listModels() {
86
- return (await this.unary('llm.models', {}));
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
- * Return the highest event `seq` currently persisted in session.history.
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 caller snapshots this before `session.prompt`. `session.history` is a
96
- * full-session replay rather than a delta, so this high-water mark is what
97
- * lets a subsequent replay skip every already-completed turn.
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 latestEventSeq(sessionId) {
100
- const value = (await this.unary('session.history', {
101
- sessionId,
102
- maxMessages: 1,
103
- }));
104
- let max = -1;
105
- for (const entry of value.events ?? []) {
106
- const seq = entry.event?.seq;
107
- if (typeof seq === 'number' && seq > max)
108
- max = seq;
109
- }
110
- return max;
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
- * Raw SSE mux stream: yields ServerRequest envelopes until the stream ends,
114
- * errors, or `signal` aborts. Does NOT reconnect (the caller owns retry).
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 *mux(signal) {
117
- let res;
118
- try {
119
- res = await this.fetchImpl(`${this.baseUrl}/api/events.mux`, {
120
- method: 'GET',
121
- headers: { accept: 'text/event-stream' },
122
- signal,
123
- });
124
- }
125
- catch (err) {
126
- if (signal.aborted)
127
- return;
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
- * WebSocket mux stream. The real DSH `/api/events.mux` endpoint is a
185
- * WebSocket upgrade, not HTTP SSE: each text message is already a
186
- * `DshServerRequest` JSON envelope. This yields those envelopes and ends on
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 *muxWebSocket(signal) {
191
- let socket;
192
- try {
193
- socket = this.webSocketImpl(`${this.baseUrl.replace(/^http/, 'ws')}/api/events.mux`);
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.onmessage = (event) => {
396
+ socket.onopen = () => {
215
397
  if (ended)
216
398
  return;
217
- if (typeof event.data !== 'string')
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
- const frame = JSON.parse(event.data);
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 WebSocket mux frame');
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
- * Replay session.history events that are newer than `state.lastSeq`, updating
288
- * `state.lastSeq` as it goes. Yields each non-duplicate SessionEvent. Used for
289
- * both the initial baseline (first subscription) and reconnect gap-fill.
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 *replayHistory(sessionId, signal, state, logTag) {
292
- try {
293
- const value = (await this.unary('session.history', {
294
- sessionId,
295
- maxMessages: 100,
296
- }));
297
- for (const entry of value.events ?? []) {
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
- const ev = entry.event;
301
- if (!ev)
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
- const seq = ev.seq;
304
- if (typeof seq === 'number' && seq <= state.lastSeq)
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
- if (typeof seq === 'number')
307
- state.lastSeq = seq;
308
- yield ev;
607
+ }
608
+ getLogger().warn(`[dsh] approval event stream unavailable: ${message}`);
609
+ yield { kind: 'unavailable', message };
610
+ return;
309
611
  }
310
- }
311
- catch (err) {
312
- if (signal.aborted)
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
- getLogger().warn(`[dsh] ${logTag} failed for session ${sessionId}: ${err.message}`);
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 (and approval notices) for
319
- * `sessionId`, auto-reconnecting on stream drop/error and replaying
320
- * `session.history` to fill the gap (dedup by seq). Terminates on turn/end
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
- // Initial baseline: before opening the first live mux subscription, replay
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
- const frames = muxMode === 'websocket' ? this.muxWebSocket(signal) : this.mux(signal);
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
- if (frame.method === 'stream/error') {
345
- dropped = true;
346
- break;
347
- }
348
- if (frame.method === 'approval/requested') {
349
- const p = frame.payload;
350
- if (p?.sessionId === sessionId && p.approvalId) {
351
- yield {
352
- kind: 'approval',
353
- sessionId,
354
- approvalId: p.approvalId,
355
- toolName: p.toolName ?? '',
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.method !== 'session/event')
362
- continue;
363
- const payload = frame.payload;
364
- if (!payload || payload.sessionId !== sessionId || !payload.event)
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
- const seq = payload.event.seq;
367
- if (typeof seq === 'number' && seq <= state.lastSeq)
368
- continue; // dedup
369
- if (typeof seq === 'number')
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
- if (muxMode === 'fetch' &&
381
- err instanceof DshError &&
382
- err.code === 'http' &&
383
- err.details.status === 426) {
384
- getLogger().info(`[dsh] mux endpoint requires WebSocket upgrade; switching transports for session ${sessionId}`);
385
- muxMode = 'websocket';
386
- dropped = true;
387
- continue;
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}; replaying history`);
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