@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/LICENSE +21 -0
- package/PROTOCOL.md +1180 -0
- package/README.md +281 -0
- package/bin/setup-ip.mjs +474 -0
- package/cordis.patch.yml +44 -0
- package/docs/assets/mobile-device-management.png +0 -0
- package/docs/assets/public-access-ui.png +0 -0
- package/docs/assets/whale-girl-ios-app-promo-16x9.png +0 -0
- package/docs/blog-mobile-gateway.md +542 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-0.1.6-alpha.1-compatibility-audit.md +122 -0
- package/docs/dsh-rc2-mobile-integration.md +155 -0
- package/docs/multi-gateway-app-integration.md +124 -0
- package/docs/multi-gateway-phase1-acceptance.md +179 -0
- package/docs/multi-gateway-todo.md +137 -0
- package/docs/remote-gateway-refactor-plan.md +52 -0
- package/docs/runtime-architecture.architecture.json +264 -0
- package/docs/runtime-architecture.html +15001 -0
- package/docs/runtime-architecture.visual-check.html +32 -0
- package/docs/runtime-architecture.visual-check.json +548 -0
- package/docs/session-agent-preset-app-integration.md +93 -0
- package/docs/typert-remote-gateway-feature-checklist.md +294 -0
- package/helper/dsh_mobile_gateway_helper.py +227 -0
- package/lib/client.js +520 -0
- package/lib/devices.js +209 -0
- package/lib/dsh-host-adapter.mjs +466 -0
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +3197 -0
- package/lib/session-follower.mjs +136 -0
- package/lib/wire-json.mjs +7 -0
- package/package.json +50 -0
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
|
+
}
|