dsh-plugin-weapp-gateway 0.8.23 → 0.8.34

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 (71) hide show
  1. package/LICENSE +21 -21
  2. package/PROTOCOL.md +1545 -1486
  3. package/README.md +503 -464
  4. package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -31
  5. package/bin/setup-ip.mjs +474 -474
  6. package/cordis.patch.yml +50 -50
  7. package/docs/agent-team-app-integration.md +945 -0
  8. package/docs/blog-mobile-gateway.md +542 -542
  9. package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -255
  10. package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -122
  11. package/docs/dsh-0.1.7-rc.1-compatibility-audit.md +52 -52
  12. package/docs/dsh-0.2.0-rc.2-plugin-management-mobile-integration.md +109 -109
  13. package/docs/dsh-rc2-mobile-integration.md +155 -155
  14. package/docs/file-upload-app-integration.md +250 -0
  15. package/docs/multi-gateway-app-integration.md +141 -125
  16. package/docs/multi-gateway-phase1-acceptance.md +179 -179
  17. package/docs/multi-gateway-todo.md +137 -137
  18. package/docs/remote-gateway-refactor-plan.md +52 -52
  19. package/docs/runtime-architecture.architecture.json +264 -264
  20. package/docs/runtime-architecture.html +15001 -15001
  21. package/docs/runtime-architecture.visual-check.html +32 -32
  22. package/docs/runtime-architecture.visual-check.json +548 -548
  23. package/docs/session-agent-preset-app-integration.md +93 -93
  24. package/docs/turn-outline-app-integration.md +196 -0
  25. package/docs/typert-remote-gateway-feature-checklist.md +294 -294
  26. package/docs/voice-input-implementation.md +219 -219
  27. package/docs/workspace-changes-mobile-integration.md +32 -32
  28. package/helper/dsh_mobile_gateway_helper.py +227 -227
  29. package/lib/account-bridge.mjs +57 -57
  30. package/lib/client.js +1409 -1361
  31. package/lib/cloudflare-tunnel.mjs +314 -314
  32. package/lib/cloudflared-binary.mjs +135 -135
  33. package/lib/context-bridge.mjs +3338 -1715
  34. package/lib/cost-meter-bridge.mjs +794 -794
  35. package/lib/cost-meter-dashboard-bridge.mjs +1655 -1449
  36. package/lib/devices.js +273 -245
  37. package/lib/dsh-host-adapter.mjs +508 -426
  38. package/lib/file-upload.mjs +205 -0
  39. package/lib/gateway-probe.mjs +6 -6
  40. package/lib/gateway-state.mjs +57 -57
  41. package/lib/gateway-tools.mjs +36 -36
  42. package/lib/history-cache.mjs +66 -0
  43. package/lib/index.mjs +4390 -3803
  44. package/lib/plugin-management.mjs +251 -233
  45. package/lib/public-endpoint-check.mjs +61 -61
  46. package/lib/relay-connector.mjs +213 -181
  47. package/lib/relay-state.mjs +83 -76
  48. package/lib/session-follower.mjs +140 -136
  49. package/lib/session-resume-bridge.mjs +86 -0
  50. package/lib/session-target.mjs +29 -0
  51. package/lib/speech-input.mjs +192 -192
  52. package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -123
  53. package/lib/vendor/cost-meter/coding-plans.mjs +1668 -1668
  54. package/lib/vendor/cost-meter/credentials-shim.mjs +8 -8
  55. package/lib/vendor/cost-meter/custom-balance.mjs +281 -281
  56. package/lib/vendor/cost-meter/factory-quota.mjs +69 -69
  57. package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -1006
  58. package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -664
  59. package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -13
  60. package/lib/vendor/cost-meter/net.mjs +183 -183
  61. package/lib/vendor/cost-meter/opendesign.mjs +204 -204
  62. package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -95
  63. package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -44
  64. package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -38
  65. package/lib/vendor/cost-meter/workbuddy.mjs +356 -356
  66. package/lib/web-restart.mjs +47 -47
  67. package/lib/wire-json.mjs +7 -7
  68. package/lib/workspace-changes-bridge.mjs +32 -32
  69. package/package.json +54 -54
  70. package/relay/agent-connector.js +588 -569
  71. package/relay/vendor/tweetnacl.js +2391 -2391
@@ -1,426 +1,508 @@
1
- import crypto from 'node:crypto'
2
- import os from 'node:os'
3
-
4
- function requireRecord(value, endpoint) {
5
- if (value === null || typeof value !== 'object' || Array.isArray(value)) {
6
- throw new Error(`${endpoint} returned an invalid object`)
7
- }
8
- return value
9
- }
10
-
11
- function requireArray(value, endpoint) {
12
- if (!Array.isArray(value)) throw new Error(`${endpoint} returned an invalid array`)
13
- return value
14
- }
15
-
16
- // Fork pin: this deployment runs DSH 0.1.7-rc.2. Upstream 0.10.0 tracks
17
- // 0.2.0-rc.2 for the plugin-management namespaces; those calls simply fail
18
- // on an older host and the panel degrades, so the constant stays truthful.
19
- export const DSH_VERSION = '0.1.7-rc.2'
20
- export const SESSION_FORMAT_VERSION = 4
21
-
22
- export function readHistoryRecords(records) {
23
- return requireArray(records, 'session history').map(record => {
24
- const value = requireRecord(record, 'session history record')
25
- if (value.type !== 'event') throw new Error('DSH Session V4 history requires event records')
26
- const event = requireRecord(value.event, 'session history event')
27
- if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
28
- return event
29
- })
30
- }
31
-
32
- export function readSessionSnapshot(frame, sessionId) {
33
- requireRecord(frame, 'session/follow')
34
- if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
35
- || frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
36
- if (frame.header.version !== SESSION_FORMAT_VERSION) {
37
- throw Object.assign(new Error(`weapp-gateway requires Session format ${SESSION_FORMAT_VERSION}; received ${frame.header.version}`), {
38
- code: 'unsupported-session-format',
39
- })
40
- }
41
- return {
42
- events: readHistoryRecords(frame.records).map(event => ({ event })),
43
- hasMore: frame.hasMore === true,
44
- projections: requireRecord(frame.projections, 'session/follow projections'),
45
- historyFormatVersion: frame.header.version,
46
- cursor: frame.cursor,
47
- }
48
- }
49
-
50
- async function firstStreamFrame(gateway, namespace, method, args, signal, peer) {
51
- const ownedAbort = new AbortController()
52
- const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
53
- let iterator
54
- try {
55
- const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal, peer })
56
- const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
57
- if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
58
- iterator = iteratorFactory.call(iterable)
59
- const first = await iterator.next()
60
- if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
61
- return first.value
62
- } finally {
63
- ownedAbort.abort()
64
- if (typeof iterator?.return === 'function') await iterator.return()
65
- }
66
- }
67
-
68
- function requestArgs(request) {
69
- return { request }
70
- }
71
-
72
- // The SessionController names its reserved list argument
73
- // `_request`. Typert descriptors preserve that source parameter name exactly,
74
- // so this endpoint cannot share the normal `{ request }` wrapper.
75
- function sessionListArgs(request) {
76
- return { _request: request }
77
- }
78
-
79
- /**
80
- * The only module allowed to know DSH Remote endpoint names and argument
81
- * descriptors. Its public methods intentionally match weapp-gateway domain
82
- * operations, not the upstream transport envelope.
83
- */
84
- export function createDshHostAdapter(typertGateway) {
85
- if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
86
- throw new Error('weapp-gateway requires the DSH Remote Gateway host service')
87
- }
88
-
89
- // DSH 0.2.1-alpha made the Peer explicit for in-process Remote calls:
90
- // InvokeRemoteRequest.peer is required, the Gateway reads peer.signal (both
91
- // assertPeerLive and methodSignal) and peer.principal for the remote-access
92
- // decision, and omitting it fails every call with
93
- // "Cannot read properties of undefined (reading 'signal')". Hosts up to
94
- // 0.2.0-rc.2 ignore the field, so always naming it works on both. The plugin
95
- // speaks as the Host itself — an owner Peer living as long as this adapter,
96
- // so disposing the plugin ends every stream it opened.
97
- const peerLifetime = new AbortController()
98
- const hostPeer = {
99
- id: `weapp-gateway:${crypto.randomUUID()}`,
100
- principal: Object.freeze({ kind: 'owner' }),
101
- interactions: 'none',
102
- signal: peerLifetime.signal,
103
- dispose: async () => {},
104
- }
105
-
106
- const invoke = (namespace, method, args, signal) => typertGateway.invoke({
107
- namespace,
108
- method,
109
- args,
110
- ...(signal === undefined ? {} : { signal }),
111
- peer: hostPeer,
112
- })
113
-
114
- const sessionSnapshot = async (request, signal) => {
115
- const frame = requireRecord(await firstStreamFrame(
116
- typertGateway,
117
- 'session',
118
- 'follow',
119
- requestArgs(request),
120
- signal,
121
- hostPeer,
122
- ), 'session/follow')
123
- return readSessionSnapshot(frame, request.address.sessionId)
124
- }
125
-
126
- const history = async (payload, signal) => {
127
- const request = {
128
- address: { kind: 'session', sessionId: payload.sessionId },
129
- // Older-page requests need only the opening cursor/projections, not another large latest page.
130
- ...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
131
- : payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
132
- }
133
- const snapshot = await sessionSnapshot(request, signal)
134
- let events = snapshot.events
135
- let hasMore = snapshot.hasMore
136
- if (payload.beforeSeq !== undefined) {
137
- const page = requireRecord(await invoke('session', 'page', requestArgs({
138
- address: request.address,
139
- throughSeq: Number.isSafeInteger(payload.throughSeq) ? payload.throughSeq : snapshot.cursor,
140
- beforeSeq: payload.beforeSeq,
141
- ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
142
- }), signal), 'session/page')
143
- events = readHistoryRecords(page.records).map(event => ({ event }))
144
- hasMore = page.hasMore === true
145
- }
146
- return {
147
- ...snapshot,
148
- events,
149
- hasMore,
150
- }
151
- }
152
-
153
- const modelCatalog = async (signal) => requireRecord(
154
- await invoke('session', 'modelCatalog', {}, signal),
155
- 'session/modelCatalog',
156
- )
157
-
158
- // Control and conversation sockets share this adapter. Admit a prompt only
159
- // after an earlier preset switch finishes, and re-check later switches.
160
- const sessionOperations = new Map()
161
- const admittedPrompts = new Set()
162
- const serializeSession = (sessionId, operation) => {
163
- const task = (sessionOperations.get(sessionId) ?? Promise.resolve()).then(operation)
164
- const guard = task.catch(() => {})
165
- sessionOperations.set(sessionId, guard)
166
- return task.finally(() => {
167
- if (sessionOperations.get(sessionId) === guard) sessionOperations.delete(sessionId)
168
- })
169
- }
170
- const sessionPreset = async (sessionId, signal) => {
171
- const snapshot = await sessionSnapshot({ address: { kind: 'session', sessionId }, maxMessages: 1 }, signal)
172
- const values = snapshot.projections.values
173
- const metadata = values?.sessionListMetadata
174
- if (typeof values?.agentPreset !== 'string' || !metadata || typeof metadata.blank !== 'boolean'
175
- || !(metadata.lastPromptAt === null || Number.isFinite(metadata.lastPromptAt))) {
176
- throw Object.assign(new Error('session preset state is unavailable'), { code: 'agent-preset/unavailable' })
177
- }
178
- const durableLock = !metadata.blank || metadata.lastPromptAt !== null
179
- if (durableLock) admittedPrompts.delete(sessionId)
180
- return { sessionId, agentPreset: values.agentPreset, locked: durableLock || admittedPrompts.has(sessionId) }
181
- }
182
-
183
- const commands = {
184
- list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
185
- // DSH accepts `submittedAttachments`. The parsed
186
- // wire images already carry the { type: 'image', ... } shape it expects, so
187
- // only the field name changes.
188
- execute: (sessionId, line, attachments, signal) => invoke(
189
- 'commands',
190
- 'execute',
191
- { agentId: sessionId, line, submittedAttachments: attachments },
192
- signal,
193
- ),
194
- }
195
-
196
- const goalRefValue = (value, endpoint) => {
197
- const goal = requireRecord(value, endpoint)
198
- if (typeof goal.id !== 'string' || !Number.isSafeInteger(goal.revision)) {
199
- throw new Error(`${endpoint} returned an invalid goal`)
200
- }
201
- return { ref: { id: goal.id, revision: goal.revision } }
202
- }
203
-
204
- const describeHost = async (signal) => {
205
- const [sessions, catalog, canOpenPath] = await Promise.all([
206
- invoke('session', 'list', sessionListArgs({}), signal),
207
- modelCatalog(signal),
208
- invoke('session', 'canOpenWorkspacePath', {}, signal).catch(() => false),
209
- ])
210
- return {
211
- version: 'remote-gateway',
212
- dshVersion: DSH_VERSION,
213
- historyFormatVersion: SESSION_FORMAT_VERSION,
214
- cwd: os.homedir(),
215
- attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
216
- canOpenPath: canOpenPath === true,
217
- defaultProvider: catalog.default?.provider,
218
- defaultModel: catalog.default?.model,
219
- }
220
- }
221
-
222
- return {
223
- speech: {
224
- catalog: (signal) => invoke('speech', 'catalog', {}, signal),
225
- follow: (signal) => typertGateway.stream({ namespace: 'speech', method: 'follow', args: {}, signal, peer: hostPeer }),
226
- configure: (patch, signal) => invoke('speech', 'configure', { patch }, signal),
227
- prepare: (providerId, options, signal) => invoke('speech', 'prepare', {
228
- providerId, ...(options === undefined ? {} : { options }),
229
- }, signal),
230
- cancelPreparation: (providerId, signal) => invoke('speech', 'cancelPreparation', { providerId }, signal),
231
- transcribe: (request, signal) => invoke('speech', 'transcribe', { request }, signal),
232
- },
233
- sessions: {
234
- list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
235
- search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
236
- create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
237
- prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
238
- // Accepted input may not have reached turn/start yet. Keep that gap
239
- // locked; the persistent event or a subsequent read retires the marker.
240
- const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
241
- admittedPrompts.add(payload.sessionId)
242
- try {
243
- return await invoke('session', 'prompt', requestArgs({ requestId: crypto.randomUUID(), ...payload }), signal)
244
- } catch (error) {
245
- if (!alreadyAdmitted) admittedPrompts.delete(payload.sessionId)
246
- throw error
247
- }
248
- }),
249
- attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
250
- fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
251
- cancel: (payload, signal) => invoke('session', 'cancel', requestArgs(payload), signal),
252
- updateQueue: (payload, signal) => invoke('session', 'updateQueue', requestArgs(payload), signal),
253
- rename: (payload, signal) => invoke('session', 'rename', requestArgs(payload), signal),
254
- selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
255
- history,
256
- async models(payload, signal) {
257
- const [catalog, snapshot] = await Promise.all([
258
- modelCatalog(signal),
259
- sessionSnapshot({ address: { kind: 'session', sessionId: payload.sessionId }, maxMessages: 1 }, signal),
260
- ])
261
- const current = snapshot.projections?.values?.modelSelection?.next ?? catalog.default
262
- return {
263
- current,
264
- routable: typeof current?.provider === 'string'
265
- && requireArray(catalog.routableProviders, 'session/modelCatalog routableProviders').includes(current.provider),
266
- groups: requireArray(catalog.groups, 'session/modelCatalog groups'),
267
- failures: requireArray(catalog.failures, 'session/modelCatalog failures'),
268
- }
269
- },
270
- },
271
- workspace: {
272
- async list(_payload = {}, signal) {
273
- const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal, hostPeer), 'workspace/follow')
274
- if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
275
- return requireRecord(frame.value, 'workspace/follow baseline')
276
- },
277
- create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
278
- archiveSession: (payload, signal) => invoke('workspace', 'archiveSession', requestArgs(payload), signal),
279
- },
280
- pluginConfiguration: {
281
- credentialDescribe: (ref) => invoke('credentials', 'describe', { refs: [ref] }),
282
- credentialSet: (ref, value) => invoke('credentials', 'set', { ref, value }),
283
- translationLimits: () => invoke('cotTranslation', 'limits', {}),
284
- },
285
- settings: {
286
- describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
287
- mutate: (payload, signal) => invoke('settings', 'mutate', {
288
- ns: payload.ns,
289
- ops: payload.ops,
290
- expectedRevision: payload.expectedRevision,
291
- }, signal),
292
- update: (payload, signal) => invoke('settings', 'update', {
293
- ns: payload.ns,
294
- patch: payload.patch,
295
- ...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
296
- }, signal),
297
- },
298
- pluginMarket: {
299
- listIndexes: () => invoke('pluginMarket', 'listIndexes', {}),
300
- setIndexes: (urls) => invoke('pluginMarket', 'setIndexes', { urls }),
301
- search: (url, query) => invoke('pluginMarket', 'search', { url, query }),
302
- },
303
- plugins: {
304
- inventory: (_payload = {}, signal) => invoke('pluginInventory', 'list', {}, signal),
305
- listBundles: (signal) => invoke('pluginManager', 'listBundles', {}, signal),
306
- listPlugins: (signal) => invoke('pluginManager', 'listPlugins', {}, signal),
307
- registries: (signal) => invoke('pluginManager', 'registries', {}, signal),
308
- inspect: (spec, options, signal) => invoke('pluginManager', 'inspect', {
309
- spec, ...(options === undefined ? {} : { options }),
310
- }, signal),
311
- installBundle: (spec, options) => invoke('pluginManager', 'installBundle', { spec, options }),
312
- waitForInstall: (requestId) => invoke('pluginManager', 'waitForInstall', { requestId }),
313
- cancelInstall: (requestId) => invoke('pluginManager', 'cancelInstall', { requestId }),
314
- setBundleEnabled: (name, enabled) => invoke('pluginManager', 'setBundleEnabled', { name, enabled }),
315
- setPluginEnabled: (id, enabled) => invoke('pluginManager', 'setPluginEnabled', { id, enabled }),
316
- removeBundle: (name) => invoke('pluginManager', 'removeBundle', { name }),
317
- listVersionExemptions: () => invoke('pluginManager', 'listVersionExemptions', {}),
318
- setVersionExemption: (packageVersion, runtimeVersion, enabled, acceptRisk) => invoke(
319
- 'pluginManager', 'setVersionExemption', {
320
- packageVersion, runtimeVersion, enabled,
321
- ...(acceptRisk === undefined ? {} : { acceptRisk }),
322
- },
323
- ),
324
- },
325
- permissionPresets: {
326
- catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
327
- },
328
- schedule: {
329
- async catalog(_payload = {}, signal) {
330
- return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
331
- },
332
- async list(payload, signal) {
333
- return { items: requireArray(await invoke('schedule', 'list', requestArgs(payload), signal), 'schedule/list') }
334
- },
335
- history: (payload, signal) => invoke('schedule', 'history', requestArgs(payload), signal),
336
- update: (payload, signal) => invoke('schedule', 'update', requestArgs(payload), signal),
337
- delete: (payload, signal) => invoke('schedule', 'delete', requestArgs(payload), signal),
338
- },
339
- skills: {
340
- list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
341
- },
342
- agentPresets: {
343
- list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
344
- session: (payload, signal) => serializeSession(payload.sessionId, () => sessionPreset(payload.sessionId, signal)),
345
- select: (payload, signal) => serializeSession(payload.sessionId, async () => {
346
- const state = await sessionPreset(payload.sessionId, signal)
347
- if (state.locked) {
348
- throw Object.assign(new Error('session has already started; its agent preset is fixed'), { code: 'agent-preset/locked' })
349
- }
350
- // Host validates the live catalog, mounts the composition, checks its
351
- // own turn boundary and persists agent-preset/selected on this ID.
352
- const selected = await invoke('agentPresets', 'select', {
353
- agentId: payload.sessionId, agentPreset: payload.agentPreset,
354
- }, signal)
355
- if (typeof selected !== 'string' || !selected) throw new Error('agentPresets/select returned an invalid preset')
356
- return { sessionId: payload.sessionId, agentPreset: selected }
357
- }),
358
- },
359
- llm: {
360
- async models(_payload = {}, signal) {
361
- const catalog = await modelCatalog(signal)
362
- return { groups: catalog.groups, failures: catalog.failures }
363
- },
364
- async providers(_payload = {}, signal) {
365
- const providers = await invoke('llm', 'listConfigurableProviders', {}, signal)
366
- return { providers: requireArray(providers, 'llm/listConfigurableProviders') }
367
- },
368
- },
369
- goals: {
370
- async edit(payload) {
371
- return goalRefValue(await invoke('goals', 'edit', {
372
- agentId: payload.sessionId,
373
- ref: payload.ref,
374
- request: {
375
- ...(payload.objective === undefined ? {} : { objective: payload.objective }),
376
- ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }),
377
- },
378
- }), 'goals/edit')
379
- },
380
- async pause(payload) {
381
- return goalRefValue(
382
- await invoke('goals', 'pause', { agentId: payload.sessionId, ref: payload.ref }),
383
- 'goals/pause',
384
- )
385
- },
386
- async resume(payload) {
387
- return goalRefValue(
388
- await invoke('goals', 'resume', { agentId: payload.sessionId, ref: payload.ref }),
389
- 'goals/resume',
390
- )
391
- },
392
- clear: async payload => {
393
- await invoke('goals', 'clear', { agentId: payload.sessionId, ref: payload.ref })
394
- return { cleared: true }
395
- },
396
- },
397
- commands,
398
- host: {
399
- describe: (_payload = {}, signal) => describeHost(signal),
400
- },
401
- describeHost,
402
- observeSessionEvent(sessionId, event) {
403
- if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
404
- },
405
- openSessionStream(sessionId, signal) {
406
- return typertGateway.stream({
407
- namespace: 'session', method: 'follow',
408
- // Bound the opening window at the Host; older records remain available via session.page.
409
- args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
410
- signal,
411
- peer: hostPeer,
412
- })
413
- },
414
- openControlStream(signal) {
415
- return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal, peer: hostPeer })
416
- },
417
- openWorkspaceStream(signal) {
418
- return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal, peer: hostPeer })
419
- },
420
- // Ends every stream this adapter opened: the host Peer's signal is joined
421
- // into each one by the 0.2.1 Gateway.
422
- dispose() {
423
- peerLifetime.abort(new Error('weapp-gateway host adapter disposed'))
424
- },
425
- }
426
- }
1
+ import crypto from 'node:crypto'
2
+ import os from 'node:os'
3
+ import { Readable } from 'node:stream'
4
+
5
+ function requireRecord(value, endpoint) {
6
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
7
+ throw new Error(`${endpoint} returned an invalid object`)
8
+ }
9
+ return value
10
+ }
11
+
12
+ function requireArray(value, endpoint) {
13
+ if (!Array.isArray(value)) throw new Error(`${endpoint} returned an invalid array`)
14
+ return value
15
+ }
16
+
17
+ // The current session address, upload and Peer routes are provided by this
18
+ // Host baseline. Keep the hello value truthful so clients can gate features.
19
+ export const DSH_VERSION = '0.2.1-rc.2'
20
+ export const SESSION_FORMAT_VERSION = 4
21
+
22
+ export function readHistoryRecords(records) {
23
+ return requireArray(records, 'session history').map(record => {
24
+ const value = requireRecord(record, 'session history record')
25
+ if (value.type !== 'event') throw new Error('DSH Session V4 history requires event records')
26
+ const event = requireRecord(value.event, 'session history event')
27
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
28
+ return event
29
+ })
30
+ }
31
+
32
+ export function readSessionSnapshot(frame, sessionId) {
33
+ requireRecord(frame, 'session/follow')
34
+ if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
35
+ || frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
36
+ if (frame.header.version !== SESSION_FORMAT_VERSION) {
37
+ throw Object.assign(new Error(`weapp-gateway requires Session format ${SESSION_FORMAT_VERSION}; received ${frame.header.version}`), {
38
+ code: 'unsupported-session-format',
39
+ })
40
+ }
41
+ return {
42
+ events: readHistoryRecords(frame.records).map(event => ({ event })),
43
+ hasMore: frame.hasMore === true,
44
+ projections: requireRecord(frame.projections, 'session/follow projections'),
45
+ historyFormatVersion: frame.header.version,
46
+ cursor: frame.cursor,
47
+ }
48
+ }
49
+
50
+ async function firstStreamFrame(gateway, namespace, method, args, signal, peer) {
51
+ const ownedAbort = new AbortController()
52
+ const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
53
+ let iterator
54
+ try {
55
+ const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal, peer })
56
+ const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
57
+ if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
58
+ iterator = iteratorFactory.call(iterable)
59
+ const first = await iterator.next()
60
+ if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
61
+ return first.value
62
+ } finally {
63
+ ownedAbort.abort()
64
+ if (typeof iterator?.return === 'function') await iterator.return()
65
+ }
66
+ }
67
+
68
+ function requestArgs(request) {
69
+ return { request }
70
+ }
71
+
72
+ // The SessionController names its reserved list argument
73
+ // `_request`. Typert descriptors preserve that source parameter name exactly,
74
+ // so this endpoint cannot share the normal `{ request }` wrapper.
75
+ function sessionListArgs(request) {
76
+ return { _request: request }
77
+ }
78
+
79
+ /**
80
+ * The only module allowed to know DSH Remote endpoint names and argument
81
+ * descriptors. Its public methods intentionally match weapp-gateway domain
82
+ * operations, not the upstream transport envelope.
83
+ */
84
+ export function createDshHostAdapter(typertGateway, connection) {
85
+ if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
86
+ throw new Error('weapp-gateway requires the DSH Remote Gateway host service')
87
+ }
88
+
89
+ // DSH 0.2.1-alpha made the Peer explicit for in-process Remote calls:
90
+ // InvokeRemoteRequest.peer is required, the Gateway reads peer.signal (both
91
+ // assertPeerLive and methodSignal) and peer.principal for the remote-access
92
+ // decision, and omitting it fails every call with
93
+ // "Cannot read properties of undefined (reading 'signal')". Hosts up to
94
+ // 0.2.0-rc.2 ignore the field, so always naming it works on both. The plugin
95
+ // speaks as the Host itself — an owner Peer living as long as this adapter,
96
+ // so disposing the plugin ends every stream it opened.
97
+ const peerLifetime = new AbortController()
98
+ const hostPeer = typeof connection?.createPeer === 'function'
99
+ ? connection.createPeer({ principal: { kind: 'owner' }, interactions: 'none' })
100
+ : {
101
+ id: `weapp-gateway:${crypto.randomUUID()}`,
102
+ principal: Object.freeze({ kind: 'owner' }),
103
+ interactions: 'none',
104
+ signal: peerLifetime.signal,
105
+ dispose: async () => { peerLifetime.abort(new Error('weapp-gateway host adapter disposed')) },
106
+ }
107
+ if (!(hostPeer?.signal instanceof AbortSignal)) throw new Error('DSH Connection.createPeer returned an invalid Peer')
108
+
109
+ // Reuse DSH's authenticated in-process route for binary file admission.
110
+ // Older hosts without this route remain usable; the capability is omitted.
111
+ const uploadHandler = connection?.createSharedFetchHandler?.('/api')
112
+ const uploadUrl = new URL('http://localhost/api/session/uploadFileBinary')
113
+ const uploadsAvailable = () => uploadHandler?.requestBodyMode?.({ method: 'POST', url: uploadUrl }) === 'streaming'
114
+
115
+ const invoke = (namespace, method, args, signal) => typertGateway.invoke({
116
+ namespace,
117
+ method,
118
+ args,
119
+ ...(signal === undefined ? {} : { signal }),
120
+ peer: hostPeer,
121
+ })
122
+
123
+ // 子智能体(subagent)会话必须带持久父地址寻址:host 对裸 sessionId 的
124
+ // 子会话读历史会直接以 session/agent-busy 拒绝("subagent Sessions require
125
+ // their durable parent address")。父会话 id 由调用方(小程序协作面板)从
126
+ // subagentCatalog 投影里带入。
127
+ const historyAddress = (payload) => {
128
+ const parentSessionId = typeof payload.parentSessionId === 'string' ? payload.parentSessionId.trim() : ''
129
+ if (!parentSessionId) return { kind: 'session', sessionId: payload.sessionId }
130
+ const requestedMode = payload.subagentMode ?? payload.mode
131
+ const mode = ['one-shot', 'continuable', 'unknown'].includes(requestedMode) ? requestedMode : 'unknown'
132
+ return { kind: 'subagent', parentSessionId, childSessionId: payload.sessionId, mode }
133
+ }
134
+ const addressSessionId = (address) => (address.kind === 'session' ? address.sessionId : address.childSessionId)
135
+
136
+ const sessionSnapshot = async (request, signal) => {
137
+ const frame = requireRecord(await firstStreamFrame(
138
+ typertGateway,
139
+ 'session',
140
+ 'follow',
141
+ requestArgs(request),
142
+ signal,
143
+ hostPeer,
144
+ ), 'session/follow')
145
+ return readSessionSnapshot(frame, addressSessionId(request.address))
146
+ }
147
+
148
+ const history = async (payload, signal) => {
149
+ const request = {
150
+ address: historyAddress(payload),
151
+ // Older-page requests need only the opening cursor/projections, not another large latest page.
152
+ ...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
153
+ : payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
154
+ }
155
+ const snapshot = await sessionSnapshot(request, signal)
156
+ let events = snapshot.events
157
+ let hasMore = snapshot.hasMore
158
+ if (payload.beforeSeq !== undefined) {
159
+ const page = requireRecord(await invoke('session', 'page', requestArgs({
160
+ address: request.address,
161
+ throughSeq: Number.isSafeInteger(payload.throughSeq) ? payload.throughSeq : snapshot.cursor,
162
+ beforeSeq: payload.beforeSeq,
163
+ ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
164
+ }), signal), 'session/page')
165
+ events = readHistoryRecords(page.records).map(event => ({ event }))
166
+ hasMore = page.hasMore === true
167
+ }
168
+ return {
169
+ ...snapshot,
170
+ events,
171
+ hasMore,
172
+ }
173
+ }
174
+
175
+ const modelCatalog = async (signal) => requireRecord(
176
+ await invoke('session', 'modelCatalog', {}, signal),
177
+ 'session/modelCatalog',
178
+ )
179
+
180
+ // Control and conversation sockets share this adapter. Admit a prompt only
181
+ // after an earlier preset switch finishes, and re-check later switches.
182
+ const sessionOperations = new Map()
183
+ const admittedPrompts = new Set()
184
+ const serializeSession = (sessionId, operation) => {
185
+ const task = (sessionOperations.get(sessionId) ?? Promise.resolve()).then(operation)
186
+ const guard = task.catch(() => {})
187
+ sessionOperations.set(sessionId, guard)
188
+ return task.finally(() => {
189
+ if (sessionOperations.get(sessionId) === guard) sessionOperations.delete(sessionId)
190
+ })
191
+ }
192
+ const sessionPreset = async (sessionId, signal) => {
193
+ const snapshot = await sessionSnapshot({ address: { kind: 'session', sessionId }, maxMessages: 1 }, signal)
194
+ const values = snapshot.projections.values
195
+ const metadata = values?.sessionListMetadata
196
+ if (typeof values?.agentPreset !== 'string' || !metadata || typeof metadata.blank !== 'boolean'
197
+ || !(metadata.lastPromptAt === null || Number.isFinite(metadata.lastPromptAt))) {
198
+ throw Object.assign(new Error('session preset state is unavailable'), { code: 'agent-preset/unavailable' })
199
+ }
200
+ const durableLock = !metadata.blank || metadata.lastPromptAt !== null
201
+ if (durableLock) admittedPrompts.delete(sessionId)
202
+ return { sessionId, agentPreset: values.agentPreset, locked: durableLock || admittedPrompts.has(sessionId) }
203
+ }
204
+
205
+ const commands = {
206
+ list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
207
+ // DSH accepts `submittedAttachments`. The parsed
208
+ // wire images already carry the { type: 'image', ... } shape it expects, so
209
+ // only the field name changes.
210
+ execute: (sessionId, line, attachments, signal) => invoke(
211
+ 'commands',
212
+ 'execute',
213
+ { agentId: sessionId, line, submittedAttachments: attachments },
214
+ signal,
215
+ ),
216
+ }
217
+
218
+ const goalRefValue = (value, endpoint) => {
219
+ const goal = requireRecord(value, endpoint)
220
+ if (typeof goal.id !== 'string' || !Number.isSafeInteger(goal.revision)) {
221
+ throw new Error(`${endpoint} returned an invalid goal`)
222
+ }
223
+ return { ref: { id: goal.id, revision: goal.revision } }
224
+ }
225
+
226
+ const describeHost = async (signal) => {
227
+ const [sessions, catalog, canOpenPath] = await Promise.all([
228
+ invoke('session', 'list', sessionListArgs({}), signal),
229
+ modelCatalog(signal),
230
+ invoke('session', 'canOpenWorkspacePath', {}, signal).catch(() => false),
231
+ ])
232
+ return {
233
+ // 真实 DSH 版本。客户端(host-info.normalizeHostInfo)优先读 dshVersion,
234
+ // 但 settings / session-settings 直接读 host.version 作为账号 API 的版本门控,
235
+ // 所以这里必须给真版本,不能放占位标记。
236
+ version: DSH_VERSION,
237
+ dshVersion: DSH_VERSION,
238
+ historyFormatVersion: SESSION_FORMAT_VERSION,
239
+ cwd: os.homedir(),
240
+ attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
241
+ canOpenPath: canOpenPath === true,
242
+ defaultProvider: catalog.default?.provider,
243
+ defaultModel: catalog.default?.model,
244
+ }
245
+ }
246
+
247
+ return {
248
+ files: {
249
+ available: uploadsAvailable,
250
+ async upload({ sessionId, name, data }, signal) {
251
+ if (!uploadsAvailable()) throw Object.assign(new Error('The official file upload service is unavailable'), { code: 'file-upload-unavailable' })
252
+ const url = new URL(uploadUrl)
253
+ url.searchParams.set('sessionId', sessionId)
254
+ url.searchParams.set('name', name)
255
+ const uploadSignal = signal ? AbortSignal.any([signal, hostPeer.signal]) : hostPeer.signal
256
+ uploadSignal.throwIfAborted()
257
+ const source = Readable.from(data, { objectMode: false })
258
+ try {
259
+ const response = await uploadHandler.fetch(new Request(url, {
260
+ method: 'POST', headers: { 'content-type': 'application/octet-stream' },
261
+ body: Readable.toWeb(source), duplex: 'half', signal: uploadSignal,
262
+ }), hostPeer)
263
+ if (!response.ok) throw Object.assign(new Error(`Official file upload returned HTTP ${response.status}`), { code: 'file-upload-unavailable' })
264
+ const result = await response.json()
265
+ if (result.ok !== true) throw Object.assign(new Error(result.error?.message || 'Official file upload failed'),
266
+ { code: result.error?.code || 'file-upload-failed' })
267
+ return requireRecord(result.value, 'file upload')
268
+ } catch (error) {
269
+ uploadSignal.throwIfAborted()
270
+ throw error
271
+ } finally { source.destroy() }
272
+ },
273
+ },
274
+ speech: {
275
+ catalog: (signal) => invoke('speech', 'catalog', {}, signal),
276
+ follow: (signal) => typertGateway.stream({ namespace: 'speech', method: 'follow', args: {}, signal, peer: hostPeer }),
277
+ configure: (patch, signal) => invoke('speech', 'configure', { patch }, signal),
278
+ prepare: (providerId, options, signal) => invoke('speech', 'prepare', {
279
+ providerId, ...(options === undefined ? {} : { options }),
280
+ }, signal),
281
+ cancelPreparation: (providerId, signal) => invoke('speech', 'cancelPreparation', { providerId }, signal),
282
+ transcribe: (request, signal) => invoke('speech', 'transcribe', { request }, signal),
283
+ },
284
+ sessions: {
285
+ list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
286
+ projections: (payload, signal) => invoke('session', 'projections', requestArgs(payload), signal),
287
+ search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
288
+ create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
289
+ prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
290
+ if (payload.parentSessionId !== undefined) {
291
+ if (payload.subagentMode !== 'continuable') {
292
+ throw Object.assign(new Error('only continuable subagents accept messages'), { code: 'subagent/not-resumable' })
293
+ }
294
+ return invoke('subagents', 'prompt', requestArgs({
295
+ requestId: payload.requestId ?? crypto.randomUUID(),
296
+ parentSessionId: payload.parentSessionId,
297
+ childSessionId: payload.sessionId,
298
+ mode: 'continuable',
299
+ delivery: payload.mode,
300
+ content: payload.content,
301
+ ...(payload.clientTimeZone === undefined ? {} : { clientTimeZone: payload.clientTimeZone }),
302
+ }), signal)
303
+ }
304
+ // Accepted input may not have reached turn/start yet. Keep that gap
305
+ // locked; the persistent event or a subsequent read retires the marker.
306
+ const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
307
+ admittedPrompts.add(payload.sessionId)
308
+ try {
309
+ return await invoke('session', 'prompt', requestArgs({ requestId: crypto.randomUUID(), ...payload }), signal)
310
+ } catch (error) {
311
+ if (!alreadyAdmitted) admittedPrompts.delete(payload.sessionId)
312
+ throw error
313
+ }
314
+ }),
315
+ attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
316
+ fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
317
+ cancel: (payload, signal) => payload.parentSessionId === undefined
318
+ ? invoke('session', 'cancel', requestArgs(payload), signal)
319
+ : payload.subagentMode !== 'continuable'
320
+ ? Promise.reject(Object.assign(new Error('only continuable subagents accept interrupts'), { code: 'subagent/not-resumable' }))
321
+ : invoke('subagents', 'interruptByParent', {
322
+ parentSessionId: payload.parentSessionId,
323
+ childSessionId: payload.sessionId,
324
+ mode: 'continuable',
325
+ }, signal),
326
+ updateQueue: (payload, signal) => invoke('session', 'updateQueue', requestArgs(payload), signal),
327
+ rename: (payload, signal) => invoke('session', 'rename', requestArgs(payload), signal),
328
+ selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
329
+ history,
330
+ async models(payload, signal) {
331
+ const [catalog, snapshot] = await Promise.all([
332
+ modelCatalog(signal),
333
+ sessionSnapshot({ address: { kind: 'session', sessionId: payload.sessionId }, maxMessages: 1 }, signal),
334
+ ])
335
+ const current = snapshot.projections?.values?.modelSelection?.next ?? catalog.default
336
+ return {
337
+ current,
338
+ routable: typeof current?.provider === 'string'
339
+ && requireArray(catalog.routableProviders, 'session/modelCatalog routableProviders').includes(current.provider),
340
+ groups: requireArray(catalog.groups, 'session/modelCatalog groups'),
341
+ failures: requireArray(catalog.failures, 'session/modelCatalog failures'),
342
+ }
343
+ },
344
+ },
345
+ workspace: {
346
+ async list(_payload = {}, signal) {
347
+ const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal, hostPeer), 'workspace/follow')
348
+ if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
349
+ return requireRecord(frame.value, 'workspace/follow baseline')
350
+ },
351
+ create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
352
+ archiveSession: (payload, signal) => invoke('workspace', 'archiveSession', requestArgs(payload), signal),
353
+ },
354
+ pluginConfiguration: {
355
+ credentialDescribe: (ref) => invoke('credentials', 'describe', { refs: [ref] }),
356
+ credentialSet: (ref, value) => invoke('credentials', 'set', { ref, value }),
357
+ translationLimits: () => invoke('cotTranslation', 'limits', {}),
358
+ },
359
+ settings: {
360
+ describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
361
+ mutate: (payload, signal) => invoke('settings', 'mutate', {
362
+ ns: payload.ns,
363
+ ops: payload.ops,
364
+ expectedRevision: payload.expectedRevision,
365
+ }, signal),
366
+ update: (payload, signal) => invoke('settings', 'update', {
367
+ ns: payload.ns,
368
+ patch: payload.patch,
369
+ ...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
370
+ }, signal),
371
+ },
372
+ pluginMarket: {
373
+ listIndexes: () => invoke('pluginMarket', 'listIndexes', {}),
374
+ setIndexes: (urls) => invoke('pluginMarket', 'setIndexes', { urls }),
375
+ search: (url, query) => invoke('pluginMarket', 'search', { url, query }),
376
+ },
377
+ plugins: {
378
+ inventory: (_payload = {}, signal) => invoke('pluginInventory', 'list', {}, signal),
379
+ listBundles: (signal) => invoke('pluginManager', 'listBundles', {}, signal),
380
+ listPlugins: (signal) => invoke('pluginManager', 'listPlugins', {}, signal),
381
+ registries: (signal) => invoke('pluginManager', 'registries', {}, signal),
382
+ inspect: (spec, options, signal) => invoke('pluginManager', 'inspect', {
383
+ spec, ...(options === undefined ? {} : { options }),
384
+ }, signal),
385
+ installBundle: (spec, options) => invoke('pluginManager', 'installBundle', { spec, options }),
386
+ waitForInstall: (requestId) => invoke('pluginManager', 'waitForInstall', { requestId }),
387
+ cancelInstall: (requestId) => invoke('pluginManager', 'cancelInstall', { requestId }),
388
+ setBundleEnabled: (name, enabled) => invoke('pluginManager', 'setBundleEnabled', { name, enabled }),
389
+ setPluginEnabled: (id, enabled) => invoke('pluginManager', 'setPluginEnabled', { id, enabled }),
390
+ removeBundle: (name) => invoke('pluginManager', 'removeBundle', { name }),
391
+ listVersionExemptions: () => invoke('pluginManager', 'listVersionExemptions', {}),
392
+ setVersionExemption: (packageVersion, runtimeVersion, enabled, acceptRisk) => invoke(
393
+ 'pluginManager', 'setVersionExemption', {
394
+ packageVersion, runtimeVersion, enabled,
395
+ ...(acceptRisk === undefined ? {} : { acceptRisk }),
396
+ },
397
+ ),
398
+ },
399
+ permissionPresets: {
400
+ catalog: (_payload = {}, signal) => invoke('permissionPresets', 'catalog', {}, signal),
401
+ },
402
+ schedule: {
403
+ async catalog(_payload = {}, signal) {
404
+ return { items: requireArray(await invoke('schedule', 'catalog', {}, signal), 'schedule/catalog') }
405
+ },
406
+ async list(payload, signal) {
407
+ return { items: requireArray(await invoke('schedule', 'list', requestArgs(payload), signal), 'schedule/list') }
408
+ },
409
+ history: (payload, signal) => invoke('schedule', 'history', requestArgs(payload), signal),
410
+ update: (payload, signal) => invoke('schedule', 'update', requestArgs(payload), signal),
411
+ delete: (payload, signal) => invoke('schedule', 'delete', requestArgs(payload), signal),
412
+ },
413
+ skills: {
414
+ list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
415
+ },
416
+ agentPresets: {
417
+ list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
418
+ session: (payload, signal) => serializeSession(payload.sessionId, () => sessionPreset(payload.sessionId, signal)),
419
+ select: (payload, signal) => serializeSession(payload.sessionId, async () => {
420
+ const state = await sessionPreset(payload.sessionId, signal)
421
+ if (state.locked) {
422
+ throw Object.assign(new Error('session has already started; its agent preset is fixed'), { code: 'agent-preset/locked' })
423
+ }
424
+ // Host validates the live catalog, mounts the composition, checks its
425
+ // own turn boundary and persists agent-preset/selected on this ID.
426
+ const selected = await invoke('agentPresets', 'select', {
427
+ agentId: payload.sessionId, agentPreset: payload.agentPreset,
428
+ }, signal)
429
+ if (typeof selected !== 'string' || !selected) throw new Error('agentPresets/select returned an invalid preset')
430
+ return { sessionId: payload.sessionId, agentPreset: selected }
431
+ }),
432
+ },
433
+ llm: {
434
+ async models(_payload = {}, signal) {
435
+ const catalog = await modelCatalog(signal)
436
+ return { groups: catalog.groups, failures: catalog.failures }
437
+ },
438
+ async providers(_payload = {}, signal) {
439
+ const providers = await invoke('llm', 'listConfigurableProviders', {}, signal)
440
+ return { providers: requireArray(providers, 'llm/listConfigurableProviders') }
441
+ },
442
+ },
443
+ goals: {
444
+ async edit(payload) {
445
+ return goalRefValue(await invoke('goals', 'edit', {
446
+ agentId: payload.sessionId,
447
+ ref: payload.ref,
448
+ request: {
449
+ ...(payload.objective === undefined ? {} : { objective: payload.objective }),
450
+ ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }),
451
+ },
452
+ }), 'goals/edit')
453
+ },
454
+ async pause(payload) {
455
+ return goalRefValue(
456
+ await invoke('goals', 'pause', { agentId: payload.sessionId, ref: payload.ref }),
457
+ 'goals/pause',
458
+ )
459
+ },
460
+ async resume(payload) {
461
+ return goalRefValue(
462
+ await invoke('goals', 'resume', { agentId: payload.sessionId, ref: payload.ref }),
463
+ 'goals/resume',
464
+ )
465
+ },
466
+ clear: async payload => {
467
+ await invoke('goals', 'clear', { agentId: payload.sessionId, ref: payload.ref })
468
+ return { cleared: true }
469
+ },
470
+ },
471
+ commands,
472
+ host: {
473
+ describe: (_payload = {}, signal) => describeHost(signal),
474
+ },
475
+ describeHost,
476
+ observeSessionEvent(sessionId, event) {
477
+ if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
478
+ },
479
+ openSessionStream(target, signal) {
480
+ const sessionId = typeof target === 'string' ? target : target?.sessionId
481
+ const parentSessionId = typeof target === 'object' && target ? target.parentSessionId : undefined
482
+ const mode = typeof target === 'object' && target ? (target.subagentMode ?? target.mode) : undefined
483
+ const address = parentSessionId
484
+ ? { kind: 'subagent', parentSessionId, childSessionId: sessionId,
485
+ mode: ['continuable', 'one-shot', 'unknown'].includes(mode) ? mode : 'unknown' }
486
+ : { kind: 'session', sessionId }
487
+ return typertGateway.stream({
488
+ namespace: 'session', method: 'follow',
489
+ // Bound the opening window at the Host; older records remain available via session.page.
490
+ args: requestArgs({ address, maxMessages: 12, assistantStream: true }),
491
+ signal,
492
+ peer: hostPeer,
493
+ })
494
+ },
495
+ openControlStream(signal) {
496
+ return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal, peer: hostPeer })
497
+ },
498
+ openWorkspaceStream(signal) {
499
+ return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal, peer: hostPeer })
500
+ },
501
+ // Ends every stream this adapter opened: the host Peer's signal is joined
502
+ // into each one by the 0.2.1 Gateway.
503
+ dispose() {
504
+ if (typeof hostPeer.dispose === 'function') return hostPeer.dispose()
505
+ peerLifetime.abort(new Error('weapp-gateway host adapter disposed'))
506
+ },
507
+ }
508
+ }