dsh-plugin-dshm 0.0.0-stage → 0.1.0

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 (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +21 -0
  3. package/README.md +228 -2
  4. package/README.zh-CN.md +190 -0
  5. package/cordis.patch.yml +7 -0
  6. package/lib/auth/signature.d.ts +4 -0
  7. package/lib/auth/signature.js +24 -0
  8. package/lib/cli/index.d.ts +4 -0
  9. package/lib/cli/index.js +41 -0
  10. package/lib/client.js +419 -0
  11. package/lib/config.d.ts +10 -0
  12. package/lib/config.js +27 -0
  13. package/lib/discovery/advertiser.d.ts +45 -0
  14. package/lib/discovery/advertiser.js +141 -0
  15. package/lib/discovery/interfaces.d.ts +5 -0
  16. package/lib/discovery/interfaces.js +24 -0
  17. package/lib/errors.d.ts +5 -0
  18. package/lib/errors.js +10 -0
  19. package/lib/host/local.d.ts +5 -0
  20. package/lib/host/local.js +122 -0
  21. package/lib/host/tailscale.d.ts +43 -0
  22. package/lib/host/tailscale.js +137 -0
  23. package/lib/host/types.d.ts +40 -0
  24. package/lib/host/types.js +1 -0
  25. package/lib/index.d.ts +30 -0
  26. package/lib/index.js +98 -0
  27. package/lib/methods/index.d.ts +9 -0
  28. package/lib/methods/index.js +206 -0
  29. package/lib/pairing/state.d.ts +26 -0
  30. package/lib/pairing/state.js +89 -0
  31. package/lib/pairing/window.d.ts +18 -0
  32. package/lib/pairing/window.js +57 -0
  33. package/lib/projection/client.d.mts +2 -0
  34. package/lib/projection/client.mjs +11 -0
  35. package/lib/projection/files.d.ts +33 -0
  36. package/lib/projection/files.js +278 -0
  37. package/lib/projection/fold-recording.d.mts +50 -0
  38. package/lib/projection/fold-recording.mjs +440 -0
  39. package/lib/projection/host.d.ts +6 -0
  40. package/lib/projection/host.js +86 -0
  41. package/lib/projection/ids.d.mts +1 -0
  42. package/lib/projection/ids.mjs +1 -0
  43. package/lib/projection/index.d.ts +2 -0
  44. package/lib/projection/index.js +2 -0
  45. package/lib/projection/interactions.d.ts +10 -0
  46. package/lib/projection/interactions.js +73 -0
  47. package/lib/projection/native.d.ts +114 -0
  48. package/lib/projection/native.js +903 -0
  49. package/lib/protocol/THIRD_PARTY_LICENSES.txt +135 -0
  50. package/lib/protocol/dmp.d.ts +402 -0
  51. package/lib/protocol/dmp.js +1 -0
  52. package/lib/protocol/schema/dmp-v1/common.schema.json +88 -0
  53. package/lib/protocol/schema/dmp-v1/events.schema.json +107 -0
  54. package/lib/protocol/schema/dmp-v1/frames.schema.json +280 -0
  55. package/lib/protocol/schema/dmp-v1/methods.schema.json +584 -0
  56. package/lib/protocol/schema/dmp-v1/models.schema.json +674 -0
  57. package/lib/protocol/scripts/contract.mjs +8106 -0
  58. package/lib/push/controller.d.ts +64 -0
  59. package/lib/push/controller.js +290 -0
  60. package/lib/push/observer.d.ts +51 -0
  61. package/lib/push/observer.js +266 -0
  62. package/lib/push/payload.d.ts +15 -0
  63. package/lib/push/payload.js +18 -0
  64. package/lib/push/store.d.ts +17 -0
  65. package/lib/push/store.js +75 -0
  66. package/lib/server/index.d.ts +30 -0
  67. package/lib/server/index.js +500 -0
  68. package/lib/server/rate-limit.d.ts +7 -0
  69. package/lib/server/rate-limit.js +19 -0
  70. package/lib/server/uploads.d.ts +21 -0
  71. package/lib/server/uploads.js +74 -0
  72. package/lib/types.d.ts +92 -0
  73. package/lib/types.js +1 -0
  74. package/lib/ui/card.d.mts +3 -0
  75. package/lib/ui/card.mjs +59 -0
  76. package/lib/ui/copy.d.mts +146 -0
  77. package/lib/ui/copy.mjs +106 -0
  78. package/lib/ui/remote-setup.d.mts +81 -0
  79. package/lib/ui/remote-setup.mjs +75 -0
  80. package/package.json +67 -4
@@ -0,0 +1,903 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { createProjectionFold } from './fold-recording.mjs';
7
+ import { FileProjection } from './files.js';
8
+ import { interactionIdentity, expectedInteractionIds } from './interactions.js';
9
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
+ const array = (value) => Array.isArray(value) ? value : [];
11
+ const string = (value, fallback = '') => typeof value === 'string' ? value : fallback;
12
+ const number = (value, fallback = 0) => typeof value === 'number' && Number.isFinite(value) ? value : fallback;
13
+ const iso = (value) => new Date(number(value)).toISOString();
14
+ const clone = (value) => structuredClone(value);
15
+ const fault = (code, message = code) => Object.assign(new Error(message), { code });
16
+ const empty = async function* () { };
17
+ async function observe(work, signal) {
18
+ if (!signal)
19
+ return work;
20
+ signal.throwIfAborted();
21
+ let abort;
22
+ const cancelled = new Promise((_, reject) => { abort = () => reject(fault('cancelled')); signal.addEventListener('abort', abort, { once: true }); });
23
+ try {
24
+ return await Promise.race([work, cancelled]);
25
+ }
26
+ finally {
27
+ signal.removeEventListener('abort', abort);
28
+ }
29
+ }
30
+ const signalFor = (lifetime, request) => AbortSignal.any([lifetime, ...(request ? [request] : []), AbortSignal.timeout(30000)]);
31
+ const order = (left, right) => number(object(left.order).seq) - number(object(right.order).seq) || number(object(left.order).index) - number(object(right.order).index);
32
+ function mapped(error) {
33
+ const code = string(object(error).code);
34
+ const message = string(object(error).message);
35
+ // The registry's observation methods throw plain errors for removed or
36
+ // foreign jobs. Do not expose registry diagnostics or report them as a
37
+ // server fault when a foreground job has already left the live roster.
38
+ if (/^unknown job /.test(message) || /^job .+ belongs to another session$/.test(message))
39
+ return fault('not-found');
40
+ if (code === 'gateway/bad-request' || code.startsWith('gateway/input') || code.startsWith('gateway/arguments'))
41
+ return fault('invalid-params');
42
+ if (code.includes('not-found'))
43
+ return fault('not-found');
44
+ if (code.includes('busy'))
45
+ return fault('busy');
46
+ if (code === 'session/steer-unavailable' || code.includes('conflict'))
47
+ return fault('conflict');
48
+ if (code === 'session/model-unavailable' || code === 'session/attachment-invalid')
49
+ return fault('invalid-params');
50
+ if (code.includes('cancelled') || object(error).name === 'AbortError')
51
+ return fault('cancelled');
52
+ if (object(error).name === 'TimeoutError')
53
+ return fault('timeout');
54
+ if (code.includes('peer-disposed'))
55
+ return fault('dsh-unavailable');
56
+ if (['not-found', 'conflict', 'invalid-params', 'too-large', 'unsupported'].includes(code))
57
+ return error;
58
+ return fault('internal', 'DSH operation failed');
59
+ }
60
+ export const inject = ['connection', 'typertGateway', 'sessionController', 'permissionPresets'];
61
+ export function onDispose(context, handler) { context.on('dispose', handler); }
62
+ export function createProjectionHost(context) {
63
+ const ctx = context;
64
+ let version = 'unknown';
65
+ for (const base of [process.argv[1], process.execPath].filter((value) => Boolean(value))) {
66
+ try {
67
+ const loader = createRequire(resolve(base));
68
+ const entry = loader.resolve('@deepseek-ai/dsh-app-boot');
69
+ let directory = dirname(entry);
70
+ for (let depth = 0; depth < 4; depth++, directory = dirname(directory)) {
71
+ try {
72
+ const manifest = object(JSON.parse(readFileSync(resolve(directory, 'package.json'), 'utf8')));
73
+ if (manifest.name === '@deepseek-ai/dsh-app-boot') {
74
+ version = string(manifest.version, 'unknown');
75
+ break;
76
+ }
77
+ }
78
+ catch { /* Only package metadata candidates; never inspect user state. */ }
79
+ }
80
+ if (version !== 'unknown')
81
+ break;
82
+ }
83
+ catch { /* The bundle may not run under a DSH CLI carrier. */ }
84
+ }
85
+ const status = version === '0.2.1-rc.3' ? 'ok' : 'incompatible';
86
+ const diagnostics = { createdPeers: 0, disposedPeers: 0, activePeers: 0 };
87
+ const peers = new Set();
88
+ const attachments = new Map();
89
+ return {
90
+ version, status,
91
+ async connect(deviceId) {
92
+ if (status !== 'ok')
93
+ throw fault('dsh-incompatible');
94
+ const native = ctx.connection.createPeer({ principal: { kind: 'owner' }, interactions: 'answer' });
95
+ diagnostics.createdPeers++;
96
+ diagnostics.activePeers++;
97
+ const peer = new LivePeer(ctx, native, deviceId, attachments, () => { peers.delete(peer); diagnostics.disposedPeers++; diagnostics.activePeers--; });
98
+ peers.add(peer);
99
+ try {
100
+ await peer.start();
101
+ return peer;
102
+ }
103
+ catch (error) {
104
+ await peer.dispose();
105
+ throw mapped(error);
106
+ }
107
+ },
108
+ async dispose() { await Promise.all([...peers].map(peer => peer.dispose())); attachments.clear(); },
109
+ diagnostics: () => ({ ...diagnostics }),
110
+ };
111
+ }
112
+ export class LivePeer {
113
+ ctx;
114
+ peer;
115
+ deviceId;
116
+ attachments;
117
+ onDisposed;
118
+ lifetime = new AbortController();
119
+ streams = new Set();
120
+ workspaces = new Map();
121
+ nativeSummaries = new Map();
122
+ sessions = new Map();
123
+ deliveries = new Map();
124
+ listListeners = new Set();
125
+ clientId = '';
126
+ disposed = false;
127
+ archived = new Set();
128
+ pinned = new Set();
129
+ files;
130
+ constructor(ctx, peer, deviceId, attachments, onDisposed) {
131
+ this.ctx = ctx;
132
+ this.peer = peer;
133
+ this.deviceId = deviceId;
134
+ this.attachments = attachments;
135
+ this.onDisposed = onDisposed;
136
+ this.files = new FileProjection({
137
+ home: resolve(process.env.DSH_HOME ?? resolve(homedir(), '.dsh')),
138
+ session: async (id, signal) => { const state = await this.ensure(id, signal); return { cwd: string(state.metadata.cwd), records: state.records }; },
139
+ call: (namespace, method, args, signal) => this.rawCall(namespace, method, args, signal),
140
+ fetch: request => this.ctx.connection.createSharedFetchHandler('/api').fetch(request, this.peer),
141
+ changeState: (id, seq, value) => { const state = this.sessions.get(id); if (state)
142
+ this.accept(state, { source: 'change-state', frame: { seq, ...value }, atMs: 0 }); },
143
+ });
144
+ }
145
+ rawCall(namespace, method, args, signal) {
146
+ return this.ctx.typertGateway.invoke({ namespace, method, args, peer: this.peer, signal: signalFor(this.lifetime.signal, signal) });
147
+ }
148
+ async call(namespace, method, args, signal) {
149
+ try {
150
+ return object(await this.rawCall(namespace, method, args, signal));
151
+ }
152
+ catch (error) {
153
+ throw mapped(error);
154
+ }
155
+ }
156
+ startStream(endpoint, args, consume, readyType, cancel = this.lifetime, onFailure) {
157
+ let resolveReady, rejectReady;
158
+ const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
159
+ const work = (async () => {
160
+ try {
161
+ const stream = await this.ctx.typertGateway.wireStream.open(endpoint, { args }, empty(), this.peer, cancel.signal);
162
+ for await (const raw of stream) {
163
+ const frame = object(raw);
164
+ await consume(frame);
165
+ if (frame.type === readyType)
166
+ resolveReady();
167
+ }
168
+ if (!cancel.signal.aborted) {
169
+ onFailure?.();
170
+ rejectReady(fault('dsh-unavailable'));
171
+ }
172
+ }
173
+ catch (error) {
174
+ if (!cancel.signal.aborted)
175
+ onFailure?.();
176
+ rejectReady(cancel.signal.aborted ? fault('cancelled') : mapped(error));
177
+ }
178
+ })();
179
+ this.streams.add(work);
180
+ void work.finally(() => this.streams.delete(work));
181
+ return Promise.race([ready, new Promise((_, reject) => {
182
+ const timer = setTimeout(() => reject(fault('timeout')), 15000);
183
+ void ready.then(() => clearTimeout(timer), () => clearTimeout(timer));
184
+ })]);
185
+ }
186
+ async start() {
187
+ await Promise.all([
188
+ this.startStream('workspace/follow', {}, frame => this.workspaceFrame(frame), 'baseline'),
189
+ this.startStream('session/control', {}, frame => this.controlFrame(frame), 'baseline'),
190
+ this.startStream('$events', {}, frame => this.eventFrame(frame), 'ready'),
191
+ ]);
192
+ await this.refresh();
193
+ }
194
+ workspaceFrame(frame) {
195
+ if (frame.type === 'baseline' || frame.type === 'replace') {
196
+ const value = object(frame.value), before = new Set(this.workspaces.keys());
197
+ this.workspaces.clear();
198
+ for (const raw of array(value.items)) {
199
+ const workspace = object(raw);
200
+ this.workspaces.set(string(workspace.workspaceId), workspace);
201
+ before.delete(string(workspace.workspaceId));
202
+ this.listEmit('workspace.upsert', { workspace: this.workspace(workspace) });
203
+ }
204
+ for (const workspaceId of before)
205
+ this.listEmit('workspace.remove', { workspaceId });
206
+ this.archived = new Set(array(value.archivedSessionIds).map(value => string(value)));
207
+ this.pinned = new Set(array(value.pinnedSessionIds).map(value => string(value)));
208
+ }
209
+ else if (frame.type === 'upsert') {
210
+ const workspace = object(frame.workspace);
211
+ this.workspaces.set(string(workspace.workspaceId), workspace);
212
+ this.listEmit('workspace.upsert', { workspace: this.workspace(workspace) });
213
+ }
214
+ else if (frame.type === 'remove') {
215
+ this.workspaces.delete(string(frame.workspaceId));
216
+ this.listEmit('workspace.remove', { workspaceId: frame.workspaceId });
217
+ }
218
+ else if (frame.type === 'archived')
219
+ this.archived = new Set(array(frame.archivedSessionIds).map(value => string(value)));
220
+ else if (frame.type === 'pinned')
221
+ this.pinned = new Set(array(frame.pinnedSessionIds).map(value => string(value)));
222
+ if (['archived', 'pinned'].includes(string(frame.type)))
223
+ for (const summary of this.nativeSummaries.values())
224
+ this.listEmit('session.upsert', { session: this.summary(summary) });
225
+ }
226
+ controlFrame(frame) {
227
+ if (frame.type === 'baseline') {
228
+ for (const [sessionId, block] of Object.entries(object(object(frame.value).projections))) {
229
+ const state = this.sessions.get(sessionId);
230
+ if (state && object(block).values)
231
+ this.queueFrom(state, object(object(block).values).inbox);
232
+ }
233
+ }
234
+ else if (frame.type === 'projection') {
235
+ const state = this.sessions.get(string(frame.sessionId));
236
+ if (state && frame.key === 'inbox')
237
+ this.queueFrom(state, frame.value);
238
+ if (state && frame.key === 'permissions')
239
+ this.accept(state, { source: 'permission', frame: { current: object(frame.value).currentValue }, atMs: 0 });
240
+ if (frame.key === 'title') {
241
+ const summary = this.nativeSummaries.get(string(frame.sessionId));
242
+ if (summary) {
243
+ const projections = object(summary.projections);
244
+ const values = object(projections.values);
245
+ values.title = frame.value;
246
+ summary.projections = { ...projections, values };
247
+ this.listEmit('session.upsert', { session: this.summary(summary) });
248
+ }
249
+ }
250
+ }
251
+ }
252
+ async eventFrame(frame) {
253
+ if (frame.type === 'ready') {
254
+ this.clientId = string(frame.clientId);
255
+ return;
256
+ }
257
+ if (frame.type === 'waterfall' && ['approval/request', 'user-questions/request'].includes(string(frame.event))) {
258
+ const sessionId = string(frame.agentId);
259
+ const state = await this.ensure(sessionId);
260
+ const request = object(frame.request);
261
+ const nativeId = string(frame.eventId);
262
+ state.identifying.add(nativeId);
263
+ let identity;
264
+ try {
265
+ identity = await this.waitForProjection(state, () => interactionIdentity(sessionId, string(frame.event), request, state.records));
266
+ }
267
+ catch (error) {
268
+ if (state.cancel.signal.aborted)
269
+ return;
270
+ throw error;
271
+ }
272
+ finally {
273
+ state.identifying.delete(nativeId);
274
+ }
275
+ if (!identity) {
276
+ this.expireMissingInteractions(state);
277
+ this.accept(state, { source: 'interaction-warning', frame: { kind: frame.event === 'approval/request' ? 'approval' : 'question', status: this.summary(this.nativeSummaries.get(sessionId) ?? { sessionId }).status }, atMs: 0 });
278
+ return;
279
+ }
280
+ const { id, callId } = identity;
281
+ const previous = this.deliveries.get(id);
282
+ const delivery = previous ?? { id, nativeId: '', sessionId, value: frame, answered: false };
283
+ delivery.nativeId = nativeId;
284
+ delivery.value = frame;
285
+ this.deliveries.set(id, delivery);
286
+ state.expiredInteractions.delete(id);
287
+ // Redelivery refreshes routing, not the interaction's identity or lifetime.
288
+ if (!previous)
289
+ this.accept(state, { source: 'interaction', frame: { ...frame, eventId: id, request: { ...request, ...(callId ? { callId } : {}) } }, atMs: 0 });
290
+ else
291
+ for (const changed of state.changed)
292
+ changed();
293
+ }
294
+ else if (frame.type === 'cancel') {
295
+ const delivery = [...this.deliveries.values()].find(value => value.nativeId === frame.eventId);
296
+ if (delivery) {
297
+ if (!delivery.answered) {
298
+ const state = this.sessions.get(delivery.sessionId);
299
+ if (state)
300
+ this.accept(state, { source: 'interaction', frame: { type: 'cancel', eventId: delivery.id }, atMs: 0 });
301
+ }
302
+ this.deliveries.delete(delivery.id);
303
+ }
304
+ }
305
+ else if (frame.type === 'emit' && string(frame.event).startsWith('api-session/')) {
306
+ const args = array(frame.args), id = string(args[0]);
307
+ if (frame.event === 'api-session/removed') {
308
+ this.nativeSummaries.delete(id);
309
+ this.sessions.get(id)?.cancel.abort();
310
+ this.sessions.delete(id);
311
+ this.listEmit('session.remove', { sessionId: id });
312
+ return;
313
+ }
314
+ // A cold-safe list read also refreshes titles, activity, and running state.
315
+ await this.refresh();
316
+ }
317
+ }
318
+ listEmit(kind, data) { for (const emit of this.listListeners)
319
+ emit({ kind, data }); }
320
+ workspace(value) { return { workspaceId: value.workspaceId, title: string(value.title), path: string(value.path), updatedAt: iso(value.updatedAt) }; }
321
+ summary(value) {
322
+ const id = string(value.sessionId), values = object(object(value.projections).values);
323
+ const cached = this.sessions.get(id)?.fold.state();
324
+ const pending = [...this.deliveries.values()].filter(delivery => delivery.sessionId === id && !delivery.answered).length;
325
+ const workspace = [...this.workspaces.values()].find(workspace => array(workspace.sessionIds).includes(id) || workspace.path === value.cwd);
326
+ const model = object(values.modelSelection), selected = object(model.next ?? model.lastUsed);
327
+ return {
328
+ sessionId: id, title: typeof values.title === 'string' ? values.title : null,
329
+ status: pending ? 'needs-input' : value.running ? 'running' : object(cached?.session.summary).status === 'error' ? 'error' : 'idle',
330
+ pendingInteractions: pending, updatedAt: iso(value.updatedAt), archived: this.archived.has(id), pinned: this.pinned.has(id),
331
+ ...(workspace ? { workspaceId: workspace.workspaceId } : {}), ...(value.parentSessionId ? { parentSessionId: value.parentSessionId } : {}),
332
+ ...(selected.provider && selected.model ? { model: { provider: selected.provider, model: selected.model, ...(selected.reasoningEffort ? { effort: selected.reasoningEffort } : {}) } } : {}),
333
+ };
334
+ }
335
+ async refresh(signal) {
336
+ const result = await this.call('session', 'list', { _request: {} }, signal);
337
+ const seen = new Set();
338
+ for (const raw of array(result.items)) {
339
+ const value = object(raw), id = string(value.sessionId);
340
+ seen.add(id);
341
+ this.nativeSummaries.set(id, value);
342
+ this.listEmit('session.upsert', { session: this.summary(value) });
343
+ }
344
+ for (const id of this.nativeSummaries.keys())
345
+ if (!seen.has(id)) {
346
+ this.nativeSummaries.delete(id);
347
+ this.listEmit('session.remove', { sessionId: id });
348
+ }
349
+ }
350
+ queueFrom(state, value) {
351
+ if (value && typeof value === 'object')
352
+ this.accept(state, { source: 'queue', frame: object(value), atMs: 0 });
353
+ }
354
+ accept(state, record, emit = true) {
355
+ record.nativeSeq = state.nativeSeq;
356
+ state.records.push(clone(record));
357
+ for (const entry of state.fold.accept(record)) {
358
+ if (entry.from !== 'server' || entry.frame.t !== 'ev')
359
+ continue;
360
+ const event = { kind: string(entry.frame.kind), data: object(entry.frame.data) };
361
+ if (emit)
362
+ for (const listener of state.listeners)
363
+ listener(event);
364
+ if (event.kind === 'session.update')
365
+ this.listEmit('session.upsert', { session: object(event.data.session).summary });
366
+ }
367
+ for (const changed of state.changed)
368
+ changed();
369
+ }
370
+ async waitForProjection(state, probe, signal, deadline = Date.now() + 2000) {
371
+ const cancellation = AbortSignal.any([this.lifetime.signal, state.cancel.signal, ...(signal ? [signal] : [])]);
372
+ cancellation.throwIfAborted();
373
+ const initial = probe();
374
+ if (initial !== undefined)
375
+ return initial;
376
+ return new Promise((resolve, reject) => {
377
+ const finish = (error, value) => {
378
+ clearTimeout(timer);
379
+ state.changed.delete(check);
380
+ cancellation.removeEventListener('abort', abort);
381
+ if (error)
382
+ reject(error);
383
+ else
384
+ resolve(value);
385
+ };
386
+ const check = () => { try {
387
+ const value = probe();
388
+ if (value !== undefined)
389
+ finish(undefined, value);
390
+ }
391
+ catch (error) {
392
+ finish(error);
393
+ } };
394
+ const abort = () => finish(fault('cancelled'));
395
+ const timer = setTimeout(() => finish(), Math.max(0, deadline - Date.now()));
396
+ state.changed.add(check);
397
+ cancellation.addEventListener('abort', abort, { once: true });
398
+ check();
399
+ });
400
+ }
401
+ expireMissingInteractions(state) {
402
+ const ids = expectedInteractionIds(state.id, state.records).filter(id => !this.deliveries.has(id) && !state.expiredInteractions.has(id));
403
+ for (const id of ids)
404
+ state.expiredInteractions.add(id);
405
+ if (ids.length)
406
+ this.accept(state, { source: 'interaction-expire', frame: { ids, status: this.summary(this.nativeSummaries.get(state.id) ?? { sessionId: state.id }).status }, atMs: 0 });
407
+ }
408
+ async interactionsReady(state, signal, deadline = Date.now() + 2000) {
409
+ const ready = await this.waitForProjection(state, () => state.followReady && !state.identifying.size &&
410
+ expectedInteractionIds(state.id, state.records).every(id => this.deliveries.has(id) || state.expiredInteractions.has(id)) ? true : undefined, signal, deadline);
411
+ // A durable audit can outlive its native waterfall after a crash. Keep the
412
+ // session readable, but never present an unanswerable row as live.
413
+ if (!ready)
414
+ this.expireMissingInteractions(state);
415
+ }
416
+ async loadChanges(state, seq) {
417
+ const url = new URL('http://localhost/api/changes.summary');
418
+ url.search = new URLSearchParams({ sessionId: state.id, seq: String(seq) }).toString();
419
+ try {
420
+ const response = await this.ctx.connection.createSharedFetchHandler('/api').fetch(new Request(url, { signal: signalFor(this.lifetime.signal, state.cancel.signal) }), this.peer);
421
+ if (response.ok) {
422
+ const value = object(await response.json());
423
+ return { seq, available: true, fileCount: number(value.total, array(value.files).length), added: number(value.added), deleted: number(value.deleted) };
424
+ }
425
+ }
426
+ catch { /* Keep unavailable until the process-local snapshot is confirmed. */ }
427
+ return { seq, available: false };
428
+ }
429
+ async acceptNative(state, frame, emit) {
430
+ const event = object(frame.event);
431
+ if (event.type !== 'workspace/changes') {
432
+ this.accept(state, { source: 'session', frame, atMs: 0 }, emit);
433
+ return;
434
+ }
435
+ // Resolve the process-local snapshot before advancing the native watermark.
436
+ // A concurrent watch/resume must never observe a provisional changes item
437
+ // at the same sequence as its later hydrated replacement.
438
+ const change = await this.loadChanges(state, number(event.seq));
439
+ this.accept(state, { source: 'session', frame, atMs: 0 }, false);
440
+ this.accept(state, { source: 'change-state', frame: change, atMs: 0 }, emit);
441
+ }
442
+ async ensure(id, signal) {
443
+ const existing = this.sessions.get(id);
444
+ if (existing) {
445
+ await observe(existing.ready, signal);
446
+ return existing;
447
+ }
448
+ if (!this.nativeSummaries.has(id))
449
+ await this.refresh();
450
+ // Redelivery and watch can both await the same cold list refresh. Only one
451
+ // follow/fold may own this session after that asynchronous boundary.
452
+ const concurrent = this.sessions.get(id);
453
+ if (concurrent) {
454
+ await observe(concurrent.ready, signal);
455
+ return concurrent;
456
+ }
457
+ const native = this.nativeSummaries.get(id);
458
+ if (!native)
459
+ throw fault('not-found');
460
+ const metadata = { live: true, scenario: 'live', sessionId: id, title: null, recordedAt: iso(native.updatedAt), summary: this.summary(native), cwd: native.cwd };
461
+ const state = { id, metadata, fold: createProjectionFold(metadata), records: [], nativeSeq: -1, listeners: new Set(), ready: Promise.resolve(), followReady: false, cancel: new AbortController(), changed: new Set(), identifying: new Set(), expiredInteractions: new Set() };
462
+ this.lifetime.signal.addEventListener('abort', () => state.cancel.abort(), { once: true });
463
+ this.sessions.set(id, state);
464
+ state.ready = this.startStream('session/follow', { request: { address: { kind: 'session', sessionId: id }, assistantStream: true, maxMessages: 200 } }, async (frame) => {
465
+ if (frame.type === 'snapshot') {
466
+ const cursor = number(frame.cursor, -1);
467
+ let records = array(frame.records).map(object);
468
+ let hasMore = Boolean(frame.hasMore), pages = 0;
469
+ while (hasMore) {
470
+ state.cancel.signal.throwIfAborted();
471
+ if (++pages > 500)
472
+ throw fault('too-large');
473
+ const first = number(object(records[0]?.event).seq, cursor + 1);
474
+ const page = await this.call('session', 'page', { request: { address: { kind: 'session', sessionId: id }, throughSeq: cursor, beforeSeq: first, maxMessages: 200 } }, state.cancel.signal);
475
+ const older = array(page.records).map(object);
476
+ if (!older.length && page.hasMore)
477
+ throw fault('dsh-incompatible');
478
+ records = [...older, ...records];
479
+ hasMore = Boolean(page.hasMore);
480
+ }
481
+ state.fold = createProjectionFold(metadata);
482
+ state.records = [];
483
+ state.nativeSeq = -1;
484
+ for (const raw of records) {
485
+ const event = object(raw.event), seq = number(event.seq, -1);
486
+ if (seq <= state.nativeSeq)
487
+ continue;
488
+ state.nativeSeq = seq;
489
+ await this.acceptNative(state, raw, false);
490
+ }
491
+ state.nativeSeq = cursor;
492
+ const projectionValues = object(object(frame.projections).values);
493
+ this.queueFrom(state, projectionValues.inbox);
494
+ if (object(projectionValues.permissions).currentValue)
495
+ this.accept(state, { source: 'permission', frame: { current: object(projectionValues.permissions).currentValue }, atMs: 0 }, false);
496
+ // The follow snapshot may carry an in-progress assistant stream baseline.
497
+ for (const item of array(object(frame.assistantStream).frames))
498
+ this.accept(state, { source: 'session', frame: { type: 'assistant-stream', frame: object(item) }, atMs: 0 }, false);
499
+ }
500
+ else if (frame.type === 'event') {
501
+ const seq = number(object(frame.event).seq, -1);
502
+ if (seq <= state.nativeSeq)
503
+ return;
504
+ if (state.nativeSeq >= 0 && seq !== state.nativeSeq + 1) {
505
+ throw fault('dsh-unavailable');
506
+ }
507
+ state.nativeSeq = seq;
508
+ await this.acceptNative(state, frame, true);
509
+ }
510
+ else if (frame.type === 'assistant-stream')
511
+ this.accept(state, { source: 'session', frame, atMs: 0 });
512
+ }, 'snapshot', state.cancel, () => {
513
+ if (this.sessions.get(id) !== state)
514
+ return;
515
+ this.sessions.delete(id);
516
+ for (const emit of state.listeners)
517
+ emit({ kind: 'session.reset', data: { reason: 'rewritten' } });
518
+ state.cancel.abort();
519
+ });
520
+ state.ready = state.ready.then(() => { state.followReady = true; for (const changed of state.changed)
521
+ changed(); });
522
+ try {
523
+ await observe(state.ready, signal);
524
+ return state;
525
+ }
526
+ catch (error) {
527
+ if (!signal?.aborted) {
528
+ this.sessions.delete(id);
529
+ state.cancel.abort();
530
+ }
531
+ throw error;
532
+ }
533
+ }
534
+ window(state, params) {
535
+ const since = params.sinceSeq;
536
+ const resumed = typeof since === 'number' && since >= -1 && state.throughSeq >= since && state.throughSeq - since <= 2000;
537
+ let items = state.inlineItems.sort(order);
538
+ let hasMoreBefore = false;
539
+ if (resumed)
540
+ items = items.filter(item => number(item.seq) > since);
541
+ else {
542
+ const window = object(params.window), minItems = number(window.minItems, 30), turns = number(window.turns, 3);
543
+ const latestTurn = Math.max(0, ...items.map(item => number(item.turn)));
544
+ const turnStart = items.findIndex(item => number(item.turn) >= Math.max(1, latestTurn - turns + 1));
545
+ const start = Math.min(Math.max(0, items.length - minItems), turnStart < 0 ? items.length : turnStart);
546
+ hasMoreBefore = start > 0;
547
+ items = items.slice(start);
548
+ }
549
+ const removedItemIds = resumed ? state.removed.filter(([, seq]) => seq > since).map(([id]) => id) : [];
550
+ const result = { session: state.session, items, cursor: { throughSeq: state.throughSeq }, hasMoreBefore, resumed, removedItemIds, interactions: state.interactions, queue: state.queue };
551
+ if (resumed && Buffer.byteLength(JSON.stringify(result)) > 950000)
552
+ return this.window(state, {});
553
+ while (Buffer.byteLength(JSON.stringify(result)) > 950000 && items.length > 1) {
554
+ const firstSeq = number(object(items[0]?.order).seq);
555
+ items = items.filter(item => number(object(item.order).seq) !== firstSeq);
556
+ result.items = items;
557
+ result.hasMoreBefore = true;
558
+ }
559
+ if (Buffer.byteLength(JSON.stringify(result)) > 950000)
560
+ throw fault('too-large');
561
+ return result;
562
+ }
563
+ async watch(method, params, emit, signal) {
564
+ signal.throwIfAborted();
565
+ if (method === 'jobs.watch' || method === 'job.watch')
566
+ return this.watchJobs(method, params, emit, signal);
567
+ if (method === 'sessions.watch') {
568
+ await this.refresh(signal);
569
+ const filtered = (event) => {
570
+ if (event.kind === 'session.upsert' && object(event.data.session).archived && !params.includeArchived)
571
+ emit({ kind: 'session.remove', data: { sessionId: object(event.data.session).sessionId } });
572
+ else
573
+ emit(event);
574
+ };
575
+ this.listListeners.add(filtered);
576
+ return { result: this.list(Boolean(params.includeArchived)), dispose: () => { this.listListeners.delete(filtered); } };
577
+ }
578
+ const state = await this.ensure(string(params.sessionId), signal);
579
+ await this.interactionsReady(state, signal);
580
+ const result = this.window(state.fold.state(), params);
581
+ state.listeners.add(emit);
582
+ return { result, dispose: () => { state.listeners.delete(emit); } };
583
+ }
584
+ list(includeArchived = false) { return { workspaces: [...this.workspaces.values()].map(value => this.workspace(value)), sessions: [...this.nativeSummaries.values()].map(value => this.summary(value)).filter(value => includeArchived || !value.archived) }; }
585
+ async create(workspaceId, sessionId, signal) {
586
+ if (!this.workspaces.has(workspaceId))
587
+ throw fault('not-found');
588
+ await this.refresh(signal);
589
+ if (this.nativeSummaries.has(sessionId))
590
+ return { sessionId };
591
+ const result = await this.call('session', 'create', { request: { workspaceId, sessionId } }, signal);
592
+ await this.refresh(signal);
593
+ return { sessionId: string(result.sessionId) };
594
+ }
595
+ model(selected) { return { provider: selected.provider, model: selected.model, ...(selected.reasoningEffort ? { effort: selected.reasoningEffort } : {}) }; }
596
+ async permissions(signal) {
597
+ const catalog = await this.call('permissionPresets', 'catalog', {}, signal);
598
+ return { options: array(catalog.options).map(object).filter(option => option.value !== 'custom').map(option => ({
599
+ preset: option.value, label: string(option.name, string(option.value)),
600
+ ...(option.description ? { description: option.description } : {}),
601
+ // The native display catalog omits this policy. Resolve the advertised
602
+ // preset through its canonical read-only service, never infer from names.
603
+ approval: this.ctx.permissionPresets.resolve(string(option.value)).approval,
604
+ })), defaultPreset: catalog.defaultPreset };
605
+ }
606
+ job(state, native) {
607
+ const registration = state.records.find(record => record.source === 'session' && object(record.frame.event).type === 'job/registered' && object(object(record.frame.event).data).jobId === native.id);
608
+ const callId = object(object(object(registration?.frame.event).data).toolCall).callId;
609
+ return { jobId: native.id, title: string(native.label), status: native.status === 'stopping' ? 'running' : native.status,
610
+ output: { earliest: number(object(native.output).earliest), total: number(object(native.output).total) },
611
+ ...(native.owner ? { sessionId: native.owner } : {}), ...(callId ? { toolCallId: callId } : {}),
612
+ ...(typeof native.startedAt === 'number' ? { startedAt: iso(native.startedAt) } : {}),
613
+ ...(typeof native.finishedAt === 'number' ? { endedAt: iso(native.finishedAt) } : {}),
614
+ };
615
+ }
616
+ async reconcileJob(state, job, signal) {
617
+ if (!job.toolCallId)
618
+ return;
619
+ const settled = () => state.fold.state().items.find(item => item.kind === 'tool_call' && item.callId === job.toolCallId && item.output);
620
+ let item = settled();
621
+ if (!item) {
622
+ // Native foreground observation may close before the tool result is
623
+ // committed. The durable timeline output is authoritative (DMP §8.5),
624
+ // so do not announce terminal status before that reconciliation lands.
625
+ await new Promise((resolveReady, reject) => {
626
+ const abort = () => { cleanup(); reject(fault('cancelled')); };
627
+ const listener = () => { if (settled()) {
628
+ cleanup();
629
+ resolveReady();
630
+ } };
631
+ const timer = setTimeout(() => { cleanup(); reject(fault('timeout')); }, 30000);
632
+ const cleanup = () => { clearTimeout(timer); state.listeners.delete(listener); signal.removeEventListener('abort', abort); };
633
+ state.listeners.add(listener);
634
+ signal.addEventListener('abort', abort, { once: true });
635
+ listener();
636
+ if (signal.aborted)
637
+ abort();
638
+ });
639
+ item = settled();
640
+ }
641
+ if (item)
642
+ for (const listener of state.listeners)
643
+ listener({ kind: 'timeline.upsert', data: { item: state.fold.state().inlineItems.find(value => value.itemId === item.itemId), throughSeq: state.fold.state().throughSeq } });
644
+ }
645
+ async watchJobs(method, params, emit, signal) {
646
+ const state = await this.ensure(string(params.sessionId), signal);
647
+ const cancel = new AbortController(), abort = () => cancel.abort();
648
+ this.lifetime.signal.addEventListener('abort', abort, { once: true });
649
+ signal.addEventListener('abort', abort, { once: true });
650
+ const dispose = () => { cancel.abort(); this.lifetime.signal.removeEventListener('abort', abort); signal.removeEventListener('abort', abort); };
651
+ let result = {}, anchored = false, cursor = 0;
652
+ try {
653
+ if (method === 'jobs.watch') {
654
+ await this.startStream('job/list', { request: { sessionId: params.sessionId } }, frame => {
655
+ if (frame.type !== 'rows')
656
+ return;
657
+ const jobs = array(frame.jobs).map(object).filter(job => job.kind === 'bash').map(job => this.job(state, job));
658
+ if (!anchored) {
659
+ result = { jobs };
660
+ anchored = true;
661
+ }
662
+ else
663
+ emit({ kind: 'jobs.replace', data: { jobs } });
664
+ }, 'rows', cancel);
665
+ }
666
+ else {
667
+ await this.startStream('job/follow', { request: { sessionId: params.sessionId, jobId: params.jobId, ...(params.fromByte !== undefined ? { from: params.fromByte } : {}) } }, async (frame) => {
668
+ if (frame.type === 'opened') {
669
+ const native = object(frame.job);
670
+ if (native.kind !== 'bash')
671
+ throw fault('unsupported');
672
+ cursor = number(frame.from);
673
+ if (cursor > number(object(native.output).total))
674
+ throw fault('invalid-params');
675
+ result = { job: this.job(state, native), from: cursor };
676
+ anchored = true;
677
+ }
678
+ else if (frame.type === 'output') {
679
+ const chunks = array(frame.chunks).map(object).flatMap(chunk => {
680
+ const bytes = Buffer.from(string(chunk.text)), at = number(chunk.at);
681
+ let start = Math.max(0, cursor - at);
682
+ if (start >= bytes.length)
683
+ return [];
684
+ const requested = start;
685
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80)
686
+ start++;
687
+ if (start >= bytes.length)
688
+ return [];
689
+ return [{ at: at + start, text: bytes.subarray(start).toString('utf8'), channel: chunk.channel === 'stderr' ? 'stderr' : 'stdout', ...(chunk.gapBefore || start > requested ? { gapBefore: true } : {}) }];
690
+ });
691
+ cursor = Math.max(cursor, number(frame.next));
692
+ if (frame.lossy && chunks[0])
693
+ Object.assign(chunks[0], { lossy: true });
694
+ emit({ kind: 'job.output', data: { chunks, next: cursor } });
695
+ }
696
+ else if (frame.type === 'status') {
697
+ const job = this.job(state, object(frame.job));
698
+ await this.reconcileJob(state, job, cancel.signal);
699
+ if (!cancel.signal.aborted)
700
+ emit({ kind: 'job.status', data: { job } });
701
+ }
702
+ }, 'opened', cancel);
703
+ }
704
+ signal.removeEventListener('abort', abort);
705
+ return { result, dispose };
706
+ }
707
+ catch (error) {
708
+ dispose();
709
+ throw error;
710
+ }
711
+ }
712
+ async request(method, params, signal) {
713
+ signal.throwIfAborted();
714
+ if (method === 'workspaces.list')
715
+ return { workspaces: [...this.workspaces.values()].map(value => this.workspace(value)) };
716
+ if (method === 'sessions.list') {
717
+ await this.refresh(signal);
718
+ return this.list(Boolean(params.includeArchived));
719
+ }
720
+ if (method === 'sessions.create')
721
+ return this.create(string(params.workspaceId), randomUUID(), signal);
722
+ if (method === 'attachments.begin') {
723
+ await this.ensure(string(params.sessionId), signal);
724
+ return {};
725
+ }
726
+ if (method === 'session.send') {
727
+ this.expireAttachments();
728
+ const attachments = array(params.attachments).map(id => {
729
+ const attachment = this.attachments.get(string(id));
730
+ if (!attachment)
731
+ throw fault('expired');
732
+ if (attachment.deviceId !== this.deviceId || attachment.sessionId !== params.sessionId)
733
+ throw fault('invalid-params', 'Attachment does not belong to this session and device');
734
+ if (attachment.sendId && attachment.sendId !== params.sendId)
735
+ throw fault('conflict', 'Attachment already belongs to an accepted send');
736
+ return attachment;
737
+ });
738
+ const result = await this.call('session', 'prompt', { request: { sessionId: params.sessionId, requestId: params.sendId, mode: params.mode ?? 'queue', content: [{ type: 'text', text: params.text }, ...attachments.map(attachment => attachment.content)] } }, signal);
739
+ for (const attachment of attachments) {
740
+ attachment.sendId = string(params.sendId);
741
+ attachment.expires = Date.now() + 15 * 60 * 1000;
742
+ }
743
+ return result;
744
+ }
745
+ if (method === 'session.queue.update')
746
+ return this.call('session', 'updateQueue', { request: { sessionId: params.sessionId, itemId: params.queueItemId, action: { kind: params.action, ...(params.action === 'edit' ? { content: [{ type: 'text', text: params.text }] } : {}) } } }, signal);
747
+ if (method === 'models.catalog') {
748
+ const native = await this.call('session', 'modelCatalog', {}, signal);
749
+ return { providers: [...array(native.groups).map(raw => { const group = object(raw); return { provider: group.id, label: string(group.name, string(group.id)), models: array(group.models).map(rawModel => { const model = object(rawModel); return { model: model.id, label: string(model.name, string(model.id)), efforts: array(object(model.reasoning).efforts).map(effort => string(object(effort).id)) }; }) }; }), ...array(native.failures).map(raw => { const failure = object(raw); return { provider: failure.id, label: string(failure.name, string(failure.id)), models: [], error: 'Provider model catalog unavailable' }; })], ...(object(native.default).model ? { default: this.model(object(native.default)) } : {}) };
750
+ }
751
+ if (method === 'session.setModel') {
752
+ const result = await this.call('session', 'selectModel', { request: { sessionId: params.sessionId, provider: params.provider, model: params.model, ...(params.effort ? { reasoningEffort: params.effort } : {}) } }, signal);
753
+ return { selected: this.model(object(result.selected)), defaultUpdated: false };
754
+ }
755
+ if (method === 'permissions.catalog')
756
+ return this.permissions(signal);
757
+ if (method === 'session.setPermission') {
758
+ const catalog = await this.permissions(signal), preset = string(params.preset);
759
+ if (!array(catalog.options).some(option => object(option).preset === preset))
760
+ throw fault('invalid-params');
761
+ await this.call('commands', 'execute', { agentId: params.sessionId, line: `/permission ${preset}`, submittedAttachments: [] }, signal);
762
+ const readback = await this.call('session', 'projections', { request: { sessionId: params.sessionId } }, signal);
763
+ const current = object(object(readback.values).permissions).currentValue;
764
+ if (current !== preset)
765
+ throw fault('conflict', 'Permission change was not confirmed');
766
+ return { current };
767
+ }
768
+ if (['files.list', 'files.read', 'files.stat', 'changes.summary', 'changes.diff', 'deliverables.list'].includes(method))
769
+ return this.files.request(method, params, signal);
770
+ if (method === 'session.interrupt')
771
+ return this.call('session', 'cancel', { request: { sessionId: params.sessionId } }, signal);
772
+ if (method === 'interactions.list') {
773
+ const deadline = Date.now() + 2000;
774
+ await Promise.all([...this.sessions.values()].map(state => this.interactionsReady(state, signal, deadline)));
775
+ return { interactions: [...this.sessions.values()].flatMap(state => state.fold.state().interactions).filter(interaction => this.deliveries.has(string(interaction.interactionId))) };
776
+ }
777
+ if (method === 'interaction.answer')
778
+ return this.answer(params, signal);
779
+ if (method === 'session.history') {
780
+ const state = await this.ensure(string(params.sessionId), signal), through = number(params.throughSeq, -1);
781
+ const fold = createProjectionFold(state.metadata);
782
+ for (const record of state.records)
783
+ if (number(record.nativeSeq, -1) <= through)
784
+ fold.accept(record);
785
+ const all = fold.state().inlineItems.filter(item => number(object(item.order).seq) < number(params.beforeSeq)).sort(order);
786
+ const minimum = number(object(all[Math.max(0, all.length - number(params.limit, 50))]?.order).seq, -1);
787
+ let items = all.filter(item => number(object(item.order).seq) >= minimum);
788
+ while (Buffer.byteLength(JSON.stringify(items)) > 950000 && items.length) {
789
+ const seq = number(object(items[0]?.order).seq);
790
+ items = items.filter(item => number(object(item.order).seq) !== seq);
791
+ }
792
+ if (all.length && !items.length)
793
+ throw fault('too-large');
794
+ return { items, hasMoreBefore: items.length < all.length };
795
+ }
796
+ throw fault('unsupported');
797
+ }
798
+ expireAttachments() {
799
+ for (const [id, attachment] of this.attachments)
800
+ if (attachment.expires <= Date.now())
801
+ this.attachments.delete(id);
802
+ }
803
+ async upload(sessionId, name, mime, bytes, signal) {
804
+ await this.ensure(sessionId, signal);
805
+ this.expireAttachments();
806
+ const image = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(mime);
807
+ if (bytes.byteLength > (image ? 20 : 32) * 1024 * 1024)
808
+ throw fault('too-large');
809
+ if (this.attachments.size >= 256 || [...this.attachments.values()].reduce((size, attachment) => size + attachment.size, bytes.byteLength) > 128 * 1024 * 1024)
810
+ throw fault('busy');
811
+ const attachmentId = randomUUID();
812
+ const attachment = { deviceId: this.deviceId, sessionId, content: {}, size: bytes.byteLength, expires: Date.now() + 15 * 60 * 1000 };
813
+ // Reserve the shared host budget before awaiting native storage, so uploads
814
+ // from different authenticated connections cannot oversubscribe it.
815
+ this.attachments.set(attachmentId, attachment);
816
+ try {
817
+ if (image)
818
+ attachment.content = { type: 'image', mediaType: mime, data: Buffer.from(bytes).toString('base64'), name };
819
+ else {
820
+ const url = new URL('http://localhost/api/session/uploadFileBinary');
821
+ url.searchParams.set('sessionId', sessionId);
822
+ url.searchParams.set('name', name);
823
+ const response = await this.ctx.connection.createSharedFetchHandler('/api').fetch(new Request(url, { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: Buffer.from(bytes), signal: signalFor(this.lifetime.signal, signal) }), this.peer);
824
+ const body = object(await response.json());
825
+ if (!response.ok || body.ok !== true)
826
+ throw mapped(body.error);
827
+ const receiptId = string(object(body.value).receiptId);
828
+ if (!receiptId)
829
+ throw fault('dsh-incompatible');
830
+ attachment.content = { type: 'file', receiptId };
831
+ }
832
+ return { attachmentId };
833
+ }
834
+ catch (error) {
835
+ this.attachments.delete(attachmentId);
836
+ throw error;
837
+ }
838
+ }
839
+ async answer(params, signal) {
840
+ const id = string(params.interactionId), delivery = this.deliveries.get(id);
841
+ if (!delivery || delivery.answered)
842
+ throw fault('conflict');
843
+ const answer = object(params.answer), nativeRequest = object(delivery.value.request);
844
+ const approval = delivery.value.event === 'approval/request';
845
+ if ((approval ? 'approval' : 'question') !== answer.kind)
846
+ throw fault('invalid-params');
847
+ const answers = array(answer.answers).map(object);
848
+ if (!approval) {
849
+ const questions = array(nativeRequest.questions).map(object);
850
+ if (answers.length !== questions.length || new Set(answers.map(a => a.questionId)).size !== questions.length)
851
+ throw fault('invalid-params');
852
+ for (const question of questions) {
853
+ const selected = answers.find(a => a.questionId === question.id);
854
+ if (!selected || array(selected.selected).some(label => !array(question.options).some(option => object(option).label === label)) || !question.multiSelect && array(selected.selected).length > 1)
855
+ throw fault('invalid-params');
856
+ }
857
+ }
858
+ const value = approval ? answer.decision === 'deny' ? 'rejected' : 'allowed-once' : { answers: answers.map(a => ({ id: a.questionId, selected: a.selected, ...(a.custom ? { custom: a.custom } : {}) })) };
859
+ // Mark before dispatch: the native cancellation broadcast may race the response.
860
+ delivery.answered = true;
861
+ try {
862
+ const response = await this.ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://localhost/api/$events/result', {
863
+ method: 'POST', headers: { 'content-type': 'application/json' }, signal,
864
+ body: JSON.stringify({ type: 'client-request', rpcId: randomUUID(), method: '$events/result', payload: { args: { clientId: this.clientId, eventId: delivery.nativeId, outcome: { kind: 'result', value } } } }),
865
+ }), this.peer);
866
+ const result = object(object(await response.json()).result);
867
+ if (!response.ok || result.ok !== true)
868
+ throw fault('conflict');
869
+ const state = this.sessions.get(delivery.sessionId);
870
+ if (state)
871
+ this.accept(state, { source: 'answer', frame: { eventId: id, value }, atMs: 0 });
872
+ this.deliveries.delete(id);
873
+ return { settled: true };
874
+ }
875
+ catch (error) {
876
+ delivery.answered = false;
877
+ throw mapped(error);
878
+ }
879
+ }
880
+ async content(method, params, signal) {
881
+ if (method === 'files.download' || method === 'deliverables.download')
882
+ return this.files.content(method, params, signal);
883
+ const state = await this.ensure(string(params.sessionId), signal);
884
+ const item = state.fold.state().items.find(value => method === 'tool.output' ? value.kind === 'tool_call' && value.callId === params.callId : value.itemId === params.itemId && ['user_message', 'assistant_message', 'reasoning'].includes(string(value.kind)));
885
+ if (!item)
886
+ throw fault('not-found');
887
+ return Buffer.from(string(method === 'tool.output' ? object(item.output).text : item.text));
888
+ }
889
+ async dispose() {
890
+ if (this.disposed)
891
+ return;
892
+ this.disposed = true;
893
+ this.lifetime.abort();
894
+ for (const state of this.sessions.values())
895
+ state.cancel.abort();
896
+ await this.peer.dispose();
897
+ await Promise.allSettled(this.streams);
898
+ this.sessions.clear();
899
+ this.deliveries.clear();
900
+ this.listListeners.clear();
901
+ this.onDisposed();
902
+ }
903
+ }