dsh-plugin-mobile-gateway 0.7.2 → 0.7.4
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/PROTOCOL.md +38 -14
- package/README.md +43 -5
- package/cordis.patch.yml +8 -0
- package/docs/dsh-0.1.5-rc.2-compatibility-audit.md +255 -0
- package/docs/dsh-rc2-mobile-integration.md +151 -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/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/typert-remote-gateway-feature-checklist.md +19 -21
- package/lib/client.js +30 -21
- package/lib/dsh-host-adapter.mjs +54 -81
- package/lib/gateway-state.mjs +57 -0
- package/lib/index.mjs +223 -72
- package/lib/session-follower.mjs +134 -0
- package/package.json +3 -3
package/lib/client.js
CHANGED
|
@@ -248,18 +248,17 @@ window.__ModuleLoader__.load({
|
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
const
|
|
252
|
-
const enabled = !(status && status.gatewayEnabled)
|
|
251
|
+
const changeGatewayMode = async (mode) => {
|
|
253
252
|
setBusy(true)
|
|
254
253
|
setError(null)
|
|
255
254
|
try {
|
|
256
255
|
const data = await request('/mgw/gateway', {
|
|
257
256
|
method: 'POST',
|
|
258
257
|
headers: { 'Content-Type': 'application/json' },
|
|
259
|
-
body: JSON.stringify({
|
|
258
|
+
body: JSON.stringify({ mode }),
|
|
260
259
|
})
|
|
261
260
|
setStatus((current) => ({ ...(current || {}), ...data }))
|
|
262
|
-
if (
|
|
261
|
+
if (mode === 'disabled') setQr(null)
|
|
263
262
|
} catch (cause) {
|
|
264
263
|
setError(cause.message)
|
|
265
264
|
} finally {
|
|
@@ -324,25 +323,35 @@ window.__ModuleLoader__.load({
|
|
|
324
323
|
React.createElement('section', { style: styles.card },
|
|
325
324
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
326
325
|
React.createElement('div', null,
|
|
327
|
-
React.createElement('strong', null, '
|
|
326
|
+
React.createElement('strong', null, '网关运行模式'),
|
|
328
327
|
React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.gatewayEnabled
|
|
329
|
-
? status.
|
|
330
|
-
?
|
|
331
|
-
:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
'
|
|
338
|
-
'aria-
|
|
339
|
-
|
|
328
|
+
? status.gatewayMode === 'persistent'
|
|
329
|
+
? '常驻开启,重启后保持;无需设备在线'
|
|
330
|
+
: status.waitExpiresAt
|
|
331
|
+
? `等待可信设备连接,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭`
|
|
332
|
+
: '设备已连接过,本次运行保持开启;重启后重新计时'
|
|
333
|
+
: '关闭时不会接受移动设备连接,重启后保持关闭')),
|
|
334
|
+
React.createElement('span', { style: { position: 'relative', display: 'inline-flex', flexShrink: 0 } },
|
|
335
|
+
React.createElement('select', {
|
|
336
|
+
style: { ...styles.input, width: 'auto', marginBottom: 0, padding: '8px 40px 8px 12px', appearance: 'none', WebkitAppearance: 'none' },
|
|
337
|
+
'aria-label': '网关运行模式',
|
|
338
|
+
value: status ? status.gatewayMode || (status.gatewayEnabled ? 'temporary' : 'disabled') : 'disabled',
|
|
340
339
|
disabled: busy || !status,
|
|
341
|
-
onChange:
|
|
342
|
-
|
|
343
|
-
}),
|
|
344
|
-
React.createElement('
|
|
345
|
-
React.createElement('
|
|
340
|
+
onChange: (event) => changeGatewayMode(event.target.value),
|
|
341
|
+
},
|
|
342
|
+
React.createElement('option', { value: 'disabled' }, '关闭'),
|
|
343
|
+
React.createElement('option', { value: 'temporary' }, '临时开启'),
|
|
344
|
+
React.createElement('option', { value: 'persistent' }, '常驻开启')),
|
|
345
|
+
React.createElement('span', {
|
|
346
|
+
'aria-hidden': true,
|
|
347
|
+
style: {
|
|
348
|
+
position: 'absolute', right: 15, top: '50%', width: 7, height: 7,
|
|
349
|
+
borderRight: `1.5px solid ${colors.text}`, borderBottom: `1.5px solid ${colors.text}`,
|
|
350
|
+
transform: 'translateY(-65%) rotate(45deg)', pointerEvents: 'none',
|
|
351
|
+
},
|
|
352
|
+
}))),
|
|
353
|
+
status && status.gatewayId ? React.createElement('div', { style: { ...styles.muted, marginTop: 12, overflowWrap: 'anywhere' } },
|
|
354
|
+
`${status.gatewayName} · ${status.gatewayId}`) : null),
|
|
346
355
|
React.createElement('section', { style: styles.card },
|
|
347
356
|
React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
|
|
348
357
|
React.createElement('div', null,
|
package/lib/dsh-host-adapter.mjs
CHANGED
|
@@ -13,88 +13,52 @@ function requireArray(value, endpoint) {
|
|
|
13
13
|
return value
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
export const DSH_VERSION = '0.1.5-rc.2'
|
|
17
|
+
export const SESSION_FORMAT_VERSION = 3
|
|
18
|
+
|
|
19
|
+
export function readHistoryRecords(records) {
|
|
20
|
+
return requireArray(records, 'session history').map(record => {
|
|
21
|
+
const value = requireRecord(record, 'session history record')
|
|
22
|
+
if (value.type !== 'event') throw new Error('DSH 0.1.5-rc.2 history requires event records')
|
|
23
|
+
const event = requireRecord(value.event, 'session history event')
|
|
24
|
+
if (!Number.isSafeInteger(event.seq) || event.seq < 0) throw new Error('invalid history sequence')
|
|
25
|
+
return event
|
|
26
|
+
})
|
|
19
27
|
}
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
? requireArray(data.args, event.type)
|
|
29
|
-
: requireArray(data.texts, event.type)
|
|
30
|
-
const gaps = requireArray(data.dt, event.type)
|
|
31
|
-
if (gaps.length !== Math.max(0, values.length - 1)) {
|
|
32
|
-
throw new Error(`${event.type} returned invalid timing data`)
|
|
33
|
-
}
|
|
34
|
-
const events = []
|
|
35
|
-
let time = event.time
|
|
36
|
-
for (let index = 0; index < values.length; index += 1) {
|
|
37
|
-
if (index > 0) time += gaps[index - 1]
|
|
38
|
-
let chunk
|
|
39
|
-
if (event.type === 'chunkrow/text-chunks') {
|
|
40
|
-
chunk = { type: 'text-delta', index: data.index, text: values[index] }
|
|
41
|
-
} else if (event.type === 'chunkrow/reasoning-chunks') {
|
|
42
|
-
chunk = { type: 'reasoning-delta', index: data.index, text: values[index] }
|
|
43
|
-
} else {
|
|
44
|
-
chunk = {
|
|
45
|
-
type: 'tool-call-delta',
|
|
46
|
-
index: data.index,
|
|
47
|
-
id: data.id,
|
|
48
|
-
...(Object.hasOwn(data, 'name') ? { name: data.name } : {}),
|
|
49
|
-
argumentsDelta: values[index],
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
events.push({
|
|
53
|
-
type: 'assistant/chunk',
|
|
54
|
-
seq: event.seq + index,
|
|
55
|
-
time,
|
|
56
|
-
data: { turn: data.turn, step: data.step, chunk },
|
|
29
|
+
export function readSessionSnapshot(frame, sessionId) {
|
|
30
|
+
requireRecord(frame, 'session/follow')
|
|
31
|
+
if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor) || frame.cursor < -1
|
|
32
|
+
|| frame.header?.id !== sessionId) throw new Error('session/follow returned an invalid snapshot')
|
|
33
|
+
if (frame.header.version !== SESSION_FORMAT_VERSION) {
|
|
34
|
+
throw Object.assign(new Error('mobile-gateway requires DSH 0.1.5-rc.2 Session format 3'), {
|
|
35
|
+
code: 'unsupported-session-format',
|
|
57
36
|
})
|
|
58
37
|
}
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const value = requireRecord(record, 'session history record')
|
|
66
|
-
const event = requireRecord(value.event, 'session history event')
|
|
67
|
-
if (value.type === 'event') {
|
|
68
|
-
events.push(event)
|
|
69
|
-
continue
|
|
70
|
-
}
|
|
71
|
-
if (value.type !== 'chunks' || ![
|
|
72
|
-
'chunkrow/text-chunks',
|
|
73
|
-
'chunkrow/reasoning-chunks',
|
|
74
|
-
'chunkrow/tool-call-chunks',
|
|
75
|
-
].includes(event.type)) {
|
|
76
|
-
throw new Error('session history returned an unsupported record')
|
|
77
|
-
}
|
|
78
|
-
if (chunkRowLength(event) === 0) throw new Error(`${event.type} returned an empty run`)
|
|
79
|
-
events.push(...expandChunkRow(event))
|
|
38
|
+
return {
|
|
39
|
+
events: readHistoryRecords(frame.records).map(event => ({ event })),
|
|
40
|
+
hasMore: frame.hasMore === true,
|
|
41
|
+
projections: requireRecord(frame.projections, 'session/follow projections'),
|
|
42
|
+
historyFormatVersion: frame.header.version,
|
|
43
|
+
cursor: frame.cursor,
|
|
80
44
|
}
|
|
81
|
-
return events
|
|
82
45
|
}
|
|
83
46
|
|
|
84
47
|
async function firstStreamFrame(gateway, namespace, method, args, signal) {
|
|
85
|
-
const ownedAbort =
|
|
86
|
-
const streamSignal = signal
|
|
87
|
-
|
|
88
|
-
const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
|
|
89
|
-
if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
|
|
90
|
-
const iterator = iteratorFactory.call(iterable)
|
|
48
|
+
const ownedAbort = new AbortController()
|
|
49
|
+
const streamSignal = signal ? AbortSignal.any([signal, ownedAbort.signal]) : ownedAbort.signal
|
|
50
|
+
let iterator
|
|
91
51
|
try {
|
|
52
|
+
const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
|
|
53
|
+
const iteratorFactory = iterable?.[Symbol.asyncIterator] ?? iterable?.[Symbol.iterator]
|
|
54
|
+
if (typeof iteratorFactory !== 'function') throw new Error(`${namespace}/${method} returned an invalid stream`)
|
|
55
|
+
iterator = iteratorFactory.call(iterable)
|
|
92
56
|
const first = await iterator.next()
|
|
93
57
|
if (first.done) throw new Error(`${namespace}/${method} ended before its opening frame`)
|
|
94
58
|
return first.value
|
|
95
59
|
} finally {
|
|
96
|
-
ownedAbort
|
|
97
|
-
if (typeof iterator
|
|
60
|
+
ownedAbort.abort()
|
|
61
|
+
if (typeof iterator?.return === 'function') await iterator.return()
|
|
98
62
|
}
|
|
99
63
|
}
|
|
100
64
|
|
|
@@ -102,7 +66,7 @@ function requestArgs(request) {
|
|
|
102
66
|
return { request }
|
|
103
67
|
}
|
|
104
68
|
|
|
105
|
-
// The
|
|
69
|
+
// The 0.1.5-rc.2 SessionController names its reserved list argument
|
|
106
70
|
// `_request`. Typert descriptors preserve that source parameter name exactly,
|
|
107
71
|
// so this endpoint cannot share the normal `{ request }` wrapper.
|
|
108
72
|
function sessionListArgs(request) {
|
|
@@ -134,10 +98,7 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
134
98
|
requestArgs(request),
|
|
135
99
|
signal,
|
|
136
100
|
), 'session/follow')
|
|
137
|
-
|
|
138
|
-
throw new Error('session/follow returned an invalid opening snapshot')
|
|
139
|
-
}
|
|
140
|
-
return frame
|
|
101
|
+
return readSessionSnapshot(frame, request.address.sessionId)
|
|
141
102
|
}
|
|
142
103
|
|
|
143
104
|
const history = async (payload, signal) => {
|
|
@@ -146,8 +107,8 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
146
107
|
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
147
108
|
}
|
|
148
109
|
const snapshot = await sessionSnapshot(request, signal)
|
|
149
|
-
let
|
|
150
|
-
let hasMore = snapshot.hasMore
|
|
110
|
+
let events = snapshot.events
|
|
111
|
+
let hasMore = snapshot.hasMore
|
|
151
112
|
if (payload.beforeSeq !== undefined) {
|
|
152
113
|
const page = requireRecord(await invoke('session', 'page', requestArgs({
|
|
153
114
|
address: request.address,
|
|
@@ -155,13 +116,13 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
155
116
|
beforeSeq: payload.beforeSeq,
|
|
156
117
|
...(payload.maxMessages === undefined ? {} : { maxMessages: payload.maxMessages }),
|
|
157
118
|
}), signal), 'session/page')
|
|
158
|
-
|
|
119
|
+
events = readHistoryRecords(page.records).map(event => ({ event }))
|
|
159
120
|
hasMore = page.hasMore === true
|
|
160
121
|
}
|
|
161
122
|
return {
|
|
162
|
-
|
|
123
|
+
...snapshot,
|
|
124
|
+
events,
|
|
163
125
|
hasMore,
|
|
164
|
-
projections: requireRecord(snapshot.projections, 'session/follow projections'),
|
|
165
126
|
}
|
|
166
127
|
}
|
|
167
128
|
|
|
@@ -172,10 +133,13 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
172
133
|
|
|
173
134
|
const commands = {
|
|
174
135
|
list: (sessionId, signal) => invoke('commands', 'list', { agentId: sessionId }, signal),
|
|
175
|
-
|
|
136
|
+
// DSH 0.1.5-rc.2 accepts `submittedAttachments`. The parsed
|
|
137
|
+
// wire images already carry the { type: 'image', ... } shape it expects, so
|
|
138
|
+
// only the field name changes.
|
|
139
|
+
execute: (sessionId, line, attachments, signal) => invoke(
|
|
176
140
|
'commands',
|
|
177
141
|
'execute',
|
|
178
|
-
{ agentId: sessionId, line,
|
|
142
|
+
{ agentId: sessionId, line, submittedAttachments: attachments },
|
|
179
143
|
signal,
|
|
180
144
|
),
|
|
181
145
|
}
|
|
@@ -196,6 +160,8 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
196
160
|
])
|
|
197
161
|
return {
|
|
198
162
|
version: 'remote-gateway',
|
|
163
|
+
dshVersion: DSH_VERSION,
|
|
164
|
+
historyFormatVersion: SESSION_FORMAT_VERSION,
|
|
199
165
|
cwd: os.homedir(),
|
|
200
166
|
attachedSessions: Array.isArray(sessions?.items) ? sessions.items.length : 0,
|
|
201
167
|
canOpenPath: canOpenPath === true,
|
|
@@ -301,6 +267,13 @@ export function createDshHostAdapter(typertGateway) {
|
|
|
301
267
|
describe: (_payload = {}, signal) => describeHost(signal),
|
|
302
268
|
},
|
|
303
269
|
describeHost,
|
|
270
|
+
openSessionStream(sessionId, signal) {
|
|
271
|
+
return typertGateway.stream({
|
|
272
|
+
namespace: 'session', method: 'follow',
|
|
273
|
+
args: requestArgs({ address: { kind: 'session', sessionId }, assistantStream: true }),
|
|
274
|
+
signal,
|
|
275
|
+
})
|
|
276
|
+
},
|
|
304
277
|
openControlStream(signal) {
|
|
305
278
|
return typertGateway.stream({ namespace: 'session', method: 'control', args: {}, signal })
|
|
306
279
|
},
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
export const GATEWAY_MODES = ['disabled', 'temporary', 'persistent']
|
|
6
|
+
|
|
7
|
+
// A dedicated file keeps installation identity independent of device revocation.
|
|
8
|
+
// A malformed file must fail startup rather than silently replace that identity.
|
|
9
|
+
export function createGatewayState(file) {
|
|
10
|
+
const validate = (value) => {
|
|
11
|
+
if (!value || value.version !== 1 ||
|
|
12
|
+
typeof value.gatewayId !== 'string' ||
|
|
13
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value.gatewayId) ||
|
|
14
|
+
(value.mode !== null && !GATEWAY_MODES.includes(value.mode))) {
|
|
15
|
+
throw new Error('invalid gateway identity or mode')
|
|
16
|
+
}
|
|
17
|
+
return value
|
|
18
|
+
}
|
|
19
|
+
const read = () => validate(JSON.parse(fs.readFileSync(file, 'utf8')))
|
|
20
|
+
const write = (value, initial = false) => {
|
|
21
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
22
|
+
const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
23
|
+
try {
|
|
24
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
25
|
+
if (initial) fs.linkSync(tmp, file) // Never overwrite a concurrently created identity.
|
|
26
|
+
else fs.renameSync(tmp, file)
|
|
27
|
+
} finally {
|
|
28
|
+
fs.rmSync(tmp, { force: true })
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
let state
|
|
32
|
+
try {
|
|
33
|
+
try {
|
|
34
|
+
state = read()
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code !== 'ENOENT') throw error
|
|
37
|
+
state = { version: 1, gatewayId: crypto.randomUUID(), mode: null }
|
|
38
|
+
try { write(state, true) } catch (cause) {
|
|
39
|
+
if (cause.code !== 'EEXIST') throw cause
|
|
40
|
+
state = read()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
fs.chmodSync(file, 0o600)
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(`failed to load gateway state ${file}: ${error.message}`, { cause: error })
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
get gatewayId() { return state.gatewayId },
|
|
49
|
+
get mode() { return state.mode },
|
|
50
|
+
setMode(mode) {
|
|
51
|
+
if (!GATEWAY_MODES.includes(mode)) throw new TypeError('invalid gateway mode')
|
|
52
|
+
const next = { ...state, mode }
|
|
53
|
+
write(next)
|
|
54
|
+
state = next
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|