@sparkelf/dsh-plugin-mobile-gateway 0.8.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.
package/lib/devices.js ADDED
@@ -0,0 +1,209 @@
1
+ // Persistent paired-device registry for mobile-gateway authentication.
2
+ //
3
+ // Long-lived device tokens are generated with 256 bits of entropy and are
4
+ // never written to disk. Only their SHA-256 digests are persisted. Pairing
5
+ // codes are one-time, short-lived, and memory-only, so a restart invalidates
6
+ // every outstanding QR code without affecting already paired devices.
7
+ 'use strict'
8
+
9
+ const fs = require('node:fs')
10
+ const crypto = require('node:crypto')
11
+ const path = require('node:path')
12
+
13
+ const STORE_VERSION = 3
14
+ const DEFAULT_PAIRING_TTL_MS = 5 * 60 * 1000
15
+
16
+ function digest(secret) {
17
+ return crypto.createHash('sha256').update(secret, 'utf8').digest('hex')
18
+ }
19
+
20
+ function safeEqualHex(left, right) {
21
+ if (typeof left !== 'string' || typeof right !== 'string') return false
22
+ const a = Buffer.from(left, 'hex')
23
+ const b = Buffer.from(right, 'hex')
24
+ return a.length === 32 && b.length === 32 && crypto.timingSafeEqual(a, b)
25
+ }
26
+
27
+ function publicDevice(device, connections) {
28
+ return {
29
+ id: device.id,
30
+ name: device.name,
31
+ createdAt: device.createdAt,
32
+ lastSeenAt: device.lastSeenAt || null,
33
+ online: connections > 0,
34
+ connections,
35
+ }
36
+ }
37
+
38
+ function normalizeClientDeviceId(value) {
39
+ if (typeof value !== 'string') return undefined
40
+ const normalized = value.trim()
41
+ return /^[A-Za-z0-9._:-]{8,128}$/.test(normalized) ? normalized : undefined
42
+ }
43
+
44
+ function createRegistry(file, options = {}) {
45
+ const pairingTtlMs = Number.isSafeInteger(options.pairingTtlMs) && options.pairingTtlMs > 0
46
+ ? options.pairingTtlMs
47
+ : DEFAULT_PAIRING_TTL_MS
48
+ let devices = []
49
+ const pairings = new Map()
50
+ const online = new Map()
51
+
52
+ const save = () => {
53
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
54
+ const tmp = `${file}.${process.pid}.tmp`
55
+ fs.writeFileSync(tmp, JSON.stringify({ version: STORE_VERSION, devices }, null, 2), { mode: 0o600 })
56
+ fs.chmodSync(tmp, 0o600)
57
+ fs.renameSync(tmp, file)
58
+ }
59
+
60
+ const load = () => {
61
+ try {
62
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
63
+ if (!Array.isArray(parsed.devices)) return
64
+ let migrated = false
65
+ devices = parsed.devices.flatMap((row) => {
66
+ if (!row || typeof row.id !== 'string' || typeof row.name !== 'string') return []
67
+ let tokenHash = typeof row.tokenHash === 'string' ? row.tokenHash : undefined
68
+ // Migrate the v1 development format without invalidating an existing
69
+ // device. The plaintext is removed on the next atomic save.
70
+ if (!tokenHash && typeof row.token === 'string' && row.token !== '') {
71
+ tokenHash = digest(row.token)
72
+ migrated = true
73
+ }
74
+ if (!tokenHash || !/^[a-f0-9]{64}$/.test(tokenHash)) return []
75
+ return [{
76
+ id: row.id,
77
+ name: row.name.slice(0, 80),
78
+ clientDeviceId: normalizeClientDeviceId(row.clientDeviceId),
79
+ tokenHash,
80
+ createdAt: Number.isFinite(row.createdAt) ? row.createdAt : Date.now(),
81
+ lastSeenAt: Number.isFinite(row.lastSeenAt) ? row.lastSeenAt : null,
82
+ revokedAt: Number.isFinite(row.revokedAt) ? row.revokedAt : (row.revoked ? Date.now() : null),
83
+ }]
84
+ })
85
+ if (migrated || parsed.version !== STORE_VERSION) save()
86
+ else fs.chmodSync(file, 0o600)
87
+ } catch (error) {
88
+ if (error && error.code !== 'ENOENT') throw new Error(`failed to load device registry: ${error.message}`, { cause: error })
89
+ }
90
+ }
91
+
92
+ const prunePairings = (now = Date.now()) => {
93
+ for (const [codeHash, pairing] of pairings) {
94
+ if (pairing.expiresAt <= now) pairings.delete(codeHash)
95
+ }
96
+ }
97
+
98
+ load()
99
+
100
+ return {
101
+ list() {
102
+ return devices
103
+ .filter((device) => !device.revokedAt)
104
+ .map((device) => publicDevice(device, online.get(device.id) || 0))
105
+ },
106
+
107
+ count() {
108
+ return devices.filter((device) => !device.revokedAt).length
109
+ },
110
+
111
+ createPairing(name) {
112
+ prunePairings()
113
+ const code = crypto.randomBytes(32).toString('base64url')
114
+ const pairing = {
115
+ id: crypto.randomUUID(),
116
+ name: typeof name === 'string' && name.trim() !== '' ? name.trim().slice(0, 80) : '未命名设备',
117
+ expiresAt: Date.now() + pairingTtlMs,
118
+ }
119
+ pairings.set(digest(code), pairing)
120
+ return { ...pairing, code }
121
+ },
122
+
123
+ hasPairing(code) {
124
+ prunePairings()
125
+ return typeof code === 'string' && pairings.has(digest(code))
126
+ },
127
+
128
+ claimPairing(code, clientDeviceId) {
129
+ prunePairings()
130
+ if (typeof code !== 'string' || code === '') return undefined
131
+ const codeHash = digest(code)
132
+ const pairing = pairings.get(codeHash)
133
+ if (!pairing) return undefined
134
+ // Delete before doing any further work: claiming is single-use even if a
135
+ // later socket upgrade fails.
136
+ pairings.delete(codeHash)
137
+ const token = crypto.randomBytes(32).toString('base64url')
138
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
139
+ let device = normalizedClientDeviceId
140
+ ? devices.find((candidate) => !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
141
+ : undefined
142
+ if (device) {
143
+ // A valid one-time pairing code authorizes credential rotation. Keep
144
+ // the stable server-side device record while replacing its token, so
145
+ // re-pairing one iOS installation never creates duplicate rows.
146
+ device.name = pairing.name
147
+ device.tokenHash = digest(token)
148
+ device.clientDeviceId = normalizedClientDeviceId
149
+ } else {
150
+ device = {
151
+ id: pairing.id,
152
+ name: pairing.name,
153
+ clientDeviceId: normalizedClientDeviceId,
154
+ tokenHash: digest(token),
155
+ createdAt: Date.now(),
156
+ lastSeenAt: null,
157
+ revokedAt: null,
158
+ }
159
+ devices.push(device)
160
+ }
161
+ save()
162
+ return { device: publicDevice(device, 0), token }
163
+ },
164
+
165
+ authenticate(token, clientDeviceId) {
166
+ if (typeof token !== 'string' || token === '') return undefined
167
+ const tokenHash = digest(token)
168
+ const device = devices.find((candidate) => !candidate.revokedAt && safeEqualHex(candidate.tokenHash, tokenHash))
169
+ const normalizedClientDeviceId = normalizeClientDeviceId(clientDeviceId)
170
+ if (device && normalizedClientDeviceId && !device.clientDeviceId) {
171
+ // Migration path for devices paired before installation IDs existed:
172
+ // a valid long-lived token proves ownership of this record, so it is
173
+ // safe to bind the client's stable ID without creating a new device.
174
+ const conflict = devices.some((candidate) => candidate !== device && !candidate.revokedAt && candidate.clientDeviceId === normalizedClientDeviceId)
175
+ if (!conflict) {
176
+ device.clientDeviceId = normalizedClientDeviceId
177
+ save()
178
+ }
179
+ }
180
+ return device ? publicDevice(device, online.get(device.id) || 0) : undefined
181
+ },
182
+
183
+ connected(deviceId) {
184
+ const device = devices.find((candidate) => candidate.id === deviceId && !candidate.revokedAt)
185
+ if (!device) return false
186
+ online.set(deviceId, (online.get(deviceId) || 0) + 1)
187
+ device.lastSeenAt = Date.now()
188
+ save()
189
+ return true
190
+ },
191
+
192
+ disconnected(deviceId) {
193
+ const count = online.get(deviceId) || 0
194
+ if (count <= 1) online.delete(deviceId)
195
+ else online.set(deviceId, count - 1)
196
+ },
197
+
198
+ revoke(deviceId) {
199
+ const index = devices.findIndex((candidate) => candidate.id === deviceId && !candidate.revokedAt)
200
+ if (index < 0) return false
201
+ devices.splice(index, 1)
202
+ online.delete(deviceId)
203
+ save()
204
+ return true
205
+ },
206
+ }
207
+ }
208
+
209
+ module.exports = { createRegistry, digest }
@@ -0,0 +1,466 @@
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
+ export const DSH_VERSION = '0.1.7-rc.2'
17
+ /**
18
+ * Oldest Session format this adapter reads.
19
+ *
20
+ * A DSH deployment writes the current format, but a Session persisted earlier keeps its own
21
+ * version: v3 rows carry the retired `source.kind: "plugin"` wrapper and omit the tool role on
22
+ * `tool/result`. Both are lifted on read (see {@link liftMessageSource}), so one adapter serves
23
+ * every generation the harness still stores.
24
+ */
25
+ export const SESSION_FORMAT_VERSION = 3
26
+ /** Current Session format. Rows above this version are refused rather than misread. */
27
+ export const SESSION_FORMAT_VERSION_MAX = 4
28
+ /** Producer kinds that replace the retired plugin wrapper, keyed by the plugin it named. */
29
+ const LIFTED_PRODUCER_KINDS = Object.freeze({
30
+ '@deepseek-ai/dsh-system-prompt': 'runtime-context',
31
+ 'dsh-session-title-llm': 'dsh-session-title-llm',
32
+ })
33
+
34
+ /**
35
+ * Lift one message's retired v3 source into the current producer-owned shape.
36
+ *
37
+ * v4 refuses `source.kind: "plugin"` outright, so a v3 row that still carries it fails decoding
38
+ * with "format v4 message requires a producer-owned source kind". The mapping mirrors the harness's
39
+ * own v3→v4 migration: a system-prompt plugin that produced a system role becomes `system-prompt`,
40
+ * anything else it produced becomes `runtime-context`, and a plugin whose name is already the
41
+ * producer kind keeps it. The `plugin` key is dropped either way.
42
+ *
43
+ * @param message - message whose source may still be the v3 plugin wrapper.
44
+ * @returns the same message, or a copy whose source is producer-owned.
45
+ */
46
+ export function liftMessageSource(message) {
47
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) return message
48
+ const source = message.source
49
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) return message
50
+ if (source.kind !== 'plugin') return message
51
+ const plugin = source.plugin
52
+ if (typeof plugin !== 'string' || plugin.length === 0) return message
53
+ const lifted = plugin === '@deepseek-ai/dsh-system-prompt' && message.role === 'system'
54
+ ? 'system-prompt'
55
+ : LIFTED_PRODUCER_KINDS[plugin] ?? `plugin:${plugin}`
56
+ const { plugin: _dropped, ...rest } = source
57
+ return { ...message, source: { ...rest, kind: lifted } }
58
+ }
59
+
60
+ /** Fields that belong to the released wrapper block rather than the enclosing message. */
61
+ const TOOL_WRAPPER_FIELDS = new Set(['type', 'toolCallId', 'content', 'isError'])
62
+ /** Fields of the v4 tool message; every other key is preserved under a `plugin:` prefix. */
63
+ const TOOL_MESSAGE_FIELDS = new Set(['id', 'role', 'source', 'content'])
64
+
65
+ /**
66
+ * Promote one released-v3 wrapper tool result to the first-class v4 tool message.
67
+ *
68
+ * v3 carries a tool result as a `user` message whose single content block is a `tool-result`
69
+ * wrapper; v4 makes the message itself the tool role and hoists the wrapper's `toolCallId`,
70
+ * `content`, and `isError` onto it. Unknown keys survive under a `plugin:` prefix so a
71
+ * third-party extension is not silently dropped. A row already in the v4 shape is returned by
72
+ * identity.
73
+ *
74
+ * @param message - message carried by a `tool/result` event.
75
+ * @returns the first-class tool message, or the input when it is already one.
76
+ */
77
+ function liftToolResultMessage(message) {
78
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) return message
79
+ if (message.role !== 'user') return message
80
+ const source = message.source
81
+ const sourceCallId = source !== null && typeof source === 'object' && !Array.isArray(source) ? source.callId : undefined
82
+ const content = message.content
83
+ const block = Array.isArray(content) && content.length === 1 ? content[0] : undefined
84
+ if (block === null || typeof block !== 'object' || Array.isArray(block) || block.type !== 'tool-result') return message
85
+ const callId = block.toolCallId
86
+ if (typeof callId !== 'string' || typeof sourceCallId !== 'string' || sourceCallId !== callId) return message
87
+ const hoisted = {
88
+ role: 'tool',
89
+ source,
90
+ toolCallId: callId,
91
+ content: Array.isArray(block.content) ? block.content : [],
92
+ // v4 requires a first-class message with a string id; the wrapper already carries one.
93
+ ...(typeof message.id === 'string' && message.id.length > 0 ? { id: message.id } : {}),
94
+ }
95
+ if (typeof block.isError === 'boolean') hoisted.isError = block.isError
96
+ for (const [key, value] of Object.entries(message)) {
97
+ if (!TOOL_MESSAGE_FIELDS.has(key)) hoisted['plugin:message:' + key] = value
98
+ }
99
+ for (const [key, value] of Object.entries(block)) {
100
+ if (!TOOL_WRAPPER_FIELDS.has(key)) hoisted['plugin:result:' + key] = value
101
+ }
102
+ return hoisted
103
+ }
104
+
105
+ /** Message-bearing slots of one Session event, mirroring the harness's declared traversal. */
106
+ function messageSlots(data, type) {
107
+ if (type === 'user/message') return [data]
108
+ if (type === 'developer/message' || type === 'system/message' || type === 'assistant/message' || type === 'tool/result') {
109
+ return data.message === undefined ? [] : [data.message]
110
+ }
111
+ if (type === 'agent/inbox/spliced') return Array.isArray(data.inserted) ? data.inserted : []
112
+ if (type === 'session/title-llm-request') return Array.isArray(data.messages) ? data.messages : []
113
+ return []
114
+ }
115
+
116
+ /**
117
+ * Rewrite every message an event carries into the current source shape.
118
+ *
119
+ * `tool/result` additionally gains the tool role the v4 decoder requires; a v3 row left it to the
120
+ * enclosing event type.
121
+ *
122
+ * @param event - decoded Session event.
123
+ * @returns the event, or a copy whose messages carry producer-owned sources.
124
+ */
125
+ export function liftEventMessages(event) {
126
+ const data = event?.data
127
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return event
128
+ const slots = messageSlots(data, event.type)
129
+ if (slots.length === 0) return event
130
+ const lifted = slots.map(message => {
131
+ const withSource = liftMessageSource(message)
132
+ // v3 wraps a tool result as a user-role message around one nested `tool-result` block; v4
133
+ // promotes it to a first-class tool message. Only the wrapper is converted, so a row already
134
+ // in the v4 shape passes through untouched.
135
+ if (event.type === 'tool/result') return liftToolResultMessage(withSource)
136
+ return withSource
137
+ })
138
+ if (lifted.every((message, index) => message === slots[index])) return event
139
+ if (event.type === 'user/message') return { ...event, data: lifted[0] }
140
+ if (event.type === 'agent/inbox/spliced') return { ...event, data: { ...data, inserted: lifted } }
141
+ if (event.type === 'session/title-llm-request') return { ...event, data: { ...data, messages: lifted } }
142
+ return { ...event, data: { ...data, message: lifted[0] } }
143
+ }
144
+
145
+ export function readHistoryRecords(records) {
146
+ return requireArray(records, 'session history').map(record => {
147
+ const value = requireRecord(record, 'session history record')
148
+ if (value.type !== 'event') throw new Error('DSH 0.1.5-rc.2 history requires event records')
149
+ const event = requireRecord(value.event, 'session history event')
150
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
151
+ return liftEventMessages(event)
152
+ })
153
+ }
154
+
155
+ export function readSessionSnapshot(frame, sessionId) {
156
+ requireRecord(frame, 'session/follow')
157
+ if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
158
+ || frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
159
+ // Accept every format this adapter can read rather than one exact number: the harness stores
160
+ // Sessions written by earlier versions, and each row is lifted to the current shape on read.
161
+ const version = frame.header.version
162
+ if (!Number.isSafeInteger(version) || version < SESSION_FORMAT_VERSION || version > SESSION_FORMAT_VERSION_MAX) {
163
+ throw Object.assign(new Error(`mobile-gateway reads Session format ${SESSION_FORMAT_VERSION}-${SESSION_FORMAT_VERSION_MAX}, got ${version}`), {
164
+ code: 'unsupported-session-format',
165
+ })
166
+ }
167
+ return {
168
+ events: readHistoryRecords(frame.records).map(event => ({ event })),
169
+ hasMore: frame.hasMore === true,
170
+ projections: requireRecord(frame.projections, 'session/follow projections'),
171
+ historyFormatVersion: frame.header.version,
172
+ cursor: frame.cursor,
173
+ }
174
+ }
175
+
176
+ async function firstStreamFrame(gateway, namespace, method, args, signal) {
177
+ const ownedAbort = new AbortController()
178
+ const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
179
+ let iterator
180
+ try {
181
+ const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
182
+ const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
183
+ if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
184
+ iterator = iteratorFactory.call(iterable)
185
+ const first = await iterator.next()
186
+ if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
187
+ return first.value
188
+ } finally {
189
+ ownedAbort.abort()
190
+ if (typeof iterator?.return === 'function') await iterator.return()
191
+ }
192
+ }
193
+
194
+ function requestArgs(request) {
195
+ return { request }
196
+ }
197
+
198
+ // The 0.1.5-rc.2 SessionController names its reserved list argument
199
+ // `_request`. Typert descriptors preserve that source parameter name exactly,
200
+ // so this endpoint cannot share the normal `{ request }` wrapper.
201
+ function sessionListArgs(request) {
202
+ return { _request: request }
203
+ }
204
+
205
+ /**
206
+ * The only module allowed to know DSH Remote endpoint names and argument
207
+ * descriptors. Its public methods intentionally match mobile-gateway domain
208
+ * operations, not the upstream transport envelope.
209
+ */
210
+ export function createDshHostAdapter(typertGateway) {
211
+ if (!typertGateway || typeof typertGateway.invoke !== 'function' || typeof typertGateway.stream !== 'function') {
212
+ throw new Error('mobile-gateway requires the DSH Remote Gateway host service')
213
+ }
214
+
215
+ const invoke = (namespace, method, args, signal) => typertGateway.invoke({
216
+ namespace,
217
+ method,
218
+ args,
219
+ ...(signal === undefined ? {} : { signal }),
220
+ })
221
+
222
+ const sessionSnapshot = async (request, signal) => {
223
+ const frame = requireRecord(await firstStreamFrame(
224
+ typertGateway,
225
+ 'session',
226
+ 'follow',
227
+ requestArgs(request),
228
+ signal,
229
+ ), 'session/follow')
230
+ return readSessionSnapshot(frame, request.address.sessionId)
231
+ }
232
+
233
+ const history = async (payload, signal) => {
234
+ const request = {
235
+ address: { kind: 'session', sessionId: payload.sessionId },
236
+ // Older-page requests need only the opening cursor/projections, not another large latest page.
237
+ ...(payload.beforeSeq !== undefined ? { maxMessages: 1 }
238
+ : payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
239
+ }
240
+ const snapshot = await sessionSnapshot(request, signal)
241
+ let events = snapshot.events
242
+ let hasMore = snapshot.hasMore
243
+ if (payload.beforeSeq !== undefined) {
244
+ const page = requireRecord(await invoke('session', 'page', requestArgs({
245
+ address: request.address,
246
+ throughSeq: snapshot.cursor,
247
+ beforeSeq: payload.beforeSeq,
248
+ ...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
249
+ }), signal), 'session/page')
250
+ events = readHistoryRecords(page.records).map(event => ({ event }))
251
+ hasMore = page.hasMore === true
252
+ }
253
+ return {
254
+ ...snapshot,
255
+ events,
256
+ hasMore,
257
+ }
258
+ }
259
+
260
+ const modelCatalog = async (signal) => requireRecord(
261
+ await invoke('session', 'modelCatalog', {}, signal),
262
+ 'session/modelCatalog',
263
+ )
264
+
265
+ // Control and conversation sockets share this adapter. Admit a prompt only
266
+ // after an earlier preset switch finishes, and re-check later switches.
267
+ const sessionOperations = new Map()
268
+ const admittedPrompts = new Set()
269
+ const serializeSession = (sessionId, operation) => {
270
+ const task = (sessionOperations.get(sessionId) ?? Promise.resolve()).then(operation)
271
+ const guard = task.catch(() => {})
272
+ sessionOperations.set(sessionId, guard)
273
+ return task.finally(() => {
274
+ if (sessionOperations.get(sessionId) === guard) sessionOperations.delete(sessionId)
275
+ })
276
+ }
277
+ const sessionPreset = async (sessionId, signal) => {
278
+ const snapshot = await sessionSnapshot({ address: { kind: 'session', sessionId }, maxMessages: 1 }, signal)
279
+ const values = snapshot.projections.values
280
+ const metadata = values?.sessionListMetadata
281
+ if (typeof values?.agentPreset !== 'string' || !metadata || typeof metadata.blank !== 'boolean'
282
+ || !(metadata.lastPromptAt === null || Number.isFinite(metadata.lastPromptAt))) {
283
+ throw Object.assign(new Error('session preset state is unavailable'), { code: 'agent-preset/unavailable' })
284
+ }
285
+ const durableLock = !metadata.blank || metadata.lastPromptAt !== null
286
+ if (durableLock) admittedPrompts.delete(sessionId)
287
+ return { sessionId, agentPreset: values.agentPreset, locked: durableLock || admittedPrompts.has(sessionId) }
288
+ }
289
+
290
+ const commands = {
291
+ list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
292
+ // DSH 0.1.5-rc.2 accepts `submittedAttachments`. The parsed
293
+ // wire images already carry the { type: 'image', ... } shape it expects, so
294
+ // only the field name changes.
295
+ execute: (sessionId, line, attachments, signal) => invoke(
296
+ 'commands',
297
+ 'execute',
298
+ { agentId: sessionId, line, submittedAttachments: attachments },
299
+ signal,
300
+ ),
301
+ }
302
+
303
+ const goalRefValue = (value, endpoint) => {
304
+ const goal = requireRecord(value, endpoint)
305
+ if (typeof goal.id !== 'string' || !Number.isSafeInteger(goal.revision)) {
306
+ throw new Error(`${endpoint} returned an invalid goal`)
307
+ }
308
+ return { ref: { id: goal.id, revision: goal.revision } }
309
+ }
310
+
311
+ const describeHost = async (signal) => {
312
+ const [sessions, catalog, canOpenPath] = await Promise.all([
313
+ invoke('session', 'list', sessionListArgs({}), signal),
314
+ modelCatalog(signal),
315
+ invoke('session', 'canOpenWorkspacePath', {}, signal).catch(() => false),
316
+ ])
317
+ return {
318
+ version: 'remote-gateway',
319
+ dshVersion: DSH_VERSION,
320
+ historyFormatVersion: SESSION_FORMAT_VERSION,
321
+ cwd: os.homedir(),
322
+ attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
323
+ canOpenPath: canOpenPath === true,
324
+ defaultProvider: catalog.default?.provider,
325
+ defaultModel: catalog.default?.model,
326
+ }
327
+ }
328
+
329
+ return {
330
+ sessions: {
331
+ list: (payload = {}, signal) => invoke('session', 'list', sessionListArgs(payload), signal),
332
+ search: (payload, signal) => invoke('session', 'search', requestArgs(payload), signal),
333
+ create: (payload, signal) => invoke('session', 'create', requestArgs(payload), signal),
334
+ prompt: (payload, signal) => serializeSession(payload.sessionId, async () => {
335
+ // Accepted input may not have reached turn/start yet. Keep that gap
336
+ // locked; the persistent event or a subsequent read retires the marker.
337
+ const alreadyAdmitted = admittedPrompts.has(payload.sessionId)
338
+ admittedPrompts.add(payload.sessionId)
339
+ try {
340
+ return await invoke('session', 'prompt', requestArgs({ requestId: crypto.randomUUID(), ...payload }), signal)
341
+ } catch (error) {
342
+ if (!alreadyAdmitted) admittedPrompts.delete(payload.sessionId)
343
+ throw error
344
+ }
345
+ }),
346
+ attachment: (payload, signal) => invoke('session', 'attachment', requestArgs(payload), signal),
347
+ fork: (payload, signal) => invoke('session', 'fork', requestArgs(payload), signal),
348
+ cancel: (payload, signal) => invoke('session', 'cancel', requestArgs(payload), signal),
349
+ updateQueue: (payload, signal) => invoke('session', 'updateQueue', requestArgs(payload), signal),
350
+ rename: (payload, signal) => invoke('session', 'rename', requestArgs(payload), signal),
351
+ selectModel: (payload, signal) => invoke('session', 'selectModel', requestArgs(payload), signal),
352
+ history,
353
+ async models(payload, signal) {
354
+ const [catalog, snapshot] = await Promise.all([
355
+ modelCatalog(signal),
356
+ sessionSnapshot({ address: { kind: 'session', sessionId: payload.sessionId }, maxMessages: 1 }, signal),
357
+ ])
358
+ const current = snapshot.projections?.values?.modelSelection?.next ?? catalog.default
359
+ return {
360
+ current,
361
+ routable: typeof current?.provider === 'string'
362
+ && requireArray(catalog.routableProviders, 'session/modelCatalog routableProviders').includes(current.provider),
363
+ groups: requireArray(catalog.groups, 'session/modelCatalog groups'),
364
+ failures: requireArray(catalog.failures, 'session/modelCatalog failures'),
365
+ }
366
+ },
367
+ },
368
+ workspace: {
369
+ async list(_payload = {}, signal) {
370
+ const frame = requireRecord(await firstStreamFrame(typertGateway, 'workspace', 'follow', {}, signal), 'workspace/follow')
371
+ if (frame.type !== 'baseline') throw new Error('workspace/follow returned an invalid opening baseline')
372
+ return requireRecord(frame.value, 'workspace/follow baseline')
373
+ },
374
+ create: (payload, signal) => invoke('workspace', 'create', requestArgs(payload), signal),
375
+ archiveSession: (payload, signal) => invoke('workspace', 'archiveSession', requestArgs(payload), signal),
376
+ },
377
+ settings: {
378
+ describe: (_payload = {}, signal) => invoke('settings', 'describe', {}, signal),
379
+ update: (payload, signal) => invoke('settings', 'update', {
380
+ ns: payload.ns,
381
+ patch: payload.patch,
382
+ ...(payload.expectedRevision === undefined ? {} : { expectedRevision: payload.expectedRevision }),
383
+ }, signal),
384
+ },
385
+ skills: {
386
+ list: (payload, signal) => invoke('skills', 'list', requestArgs(payload), signal),
387
+ },
388
+ agentPresets: {
389
+ list: (_payload = {}, signal) => invoke('agentPresets', 'list', {}, signal),
390
+ session: (payload, signal) => serializeSession(payload.sessionId, () => sessionPreset(payload.sessionId, signal)),
391
+ select: (payload, signal) => serializeSession(payload.sessionId, async () => {
392
+ const state = await sessionPreset(payload.sessionId, signal)
393
+ if (state.locked) {
394
+ throw Object.assign(new Error('session has already started; its agent preset is fixed'), { code: 'agent-preset/locked' })
395
+ }
396
+ // Host validates the live catalog, mounts the composition, checks its
397
+ // own turn boundary and persists agent-preset/selected on this ID.
398
+ const selected = await invoke('agentPresets', 'select', {
399
+ agentId: payload.sessionId, agentPreset: payload.agentPreset,
400
+ }, signal)
401
+ if (typeof selected !== 'string' || !selected) throw new Error('agentPresets/select returned an invalid preset')
402
+ return { sessionId: payload.sessionId, agentPreset: selected }
403
+ }),
404
+ },
405
+ llm: {
406
+ async models(_payload = {}, signal) {
407
+ const catalog = await modelCatalog(signal)
408
+ return { groups: catalog.groups, failures: catalog.failures }
409
+ },
410
+ async providers(_payload = {}, signal) {
411
+ const providers = await invoke('llm', 'listConfigurableProviders', {}, signal)
412
+ return { providers: requireArray(providers, 'llm/listConfigurableProviders') }
413
+ },
414
+ },
415
+ goals: {
416
+ async edit(payload) {
417
+ return goalRefValue(await invoke('goals', 'edit', {
418
+ agentId: payload.sessionId,
419
+ ref: payload.ref,
420
+ request: {
421
+ ...(payload.objective === undefined ? {} : { objective: payload.objective }),
422
+ ...(payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: payload.maxGoalRounds }),
423
+ },
424
+ }), 'goals/edit')
425
+ },
426
+ async pause(payload) {
427
+ return goalRefValue(
428
+ await invoke('goals', 'pause', { agentId: payload.sessionId, ref: payload.ref }),
429
+ 'goals/pause',
430
+ )
431
+ },
432
+ async resume(payload) {
433
+ return goalRefValue(
434
+ await invoke('goals', 'resume', { agentId: payload.sessionId, ref: payload.ref }),
435
+ 'goals/resume',
436
+ )
437
+ },
438
+ clear: async payload => {
439
+ await invoke('goals', 'clear', { agentId: payload.sessionId, ref: payload.ref })
440
+ return { cleared: true }
441
+ },
442
+ },
443
+ commands,
444
+ host: {
445
+ describe: (_payload = {}, signal) => describeHost(signal),
446
+ },
447
+ describeHost,
448
+ observeSessionEvent(sessionId, event) {
449
+ if (event.type === 'turn/start') admittedPrompts.delete(sessionId)
450
+ },
451
+ openSessionStream(sessionId, signal) {
452
+ return typertGateway.stream({
453
+ namespace: 'session', method: 'follow',
454
+ // Bound the opening window at the Host; older records remain available via session.page.
455
+ args: requestArgs({ address: { kind: 'session', sessionId }, maxMessages: 12, assistantStream: true }),
456
+ signal,
457
+ })
458
+ },
459
+ openControlStream(signal) {
460
+ return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
461
+ },
462
+ openWorkspaceStream(signal) {
463
+ return typertGateway.stream({ namespace: 'workspace', method: 'follow', args: {}, signal })
464
+ },
465
+ }
466
+ }