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/lib/client.js CHANGED
@@ -248,18 +248,17 @@ window.__ModuleLoader__.load({
248
248
  }
249
249
  }
250
250
 
251
- const toggleGateway = async () => {
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({ enabled }),
258
+ body: JSON.stringify({ mode }),
260
259
  })
261
260
  setStatus((current) => ({ ...(current || {}), ...data }))
262
- if (!enabled) setQr(null)
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.connectedClients > 0
330
- ? `${status.connectedClients} 个连接,网关保持开启`
331
- : `等待可信设备连接${status.waitExpiresAt ? `,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭` : ''}`
332
- : '关闭时不会接受移动设备连接')),
333
- React.createElement('label', { style: { ...styles.switch, opacity: busy || !status ? .55 : 1 }, title: status && status.gatewayEnabled ? '关闭移动网关' : '开启移动网关' },
334
- React.createElement('input', {
335
- type: 'checkbox',
336
- role: 'switch',
337
- 'aria-label': '允许移动设备连接',
338
- 'aria-checked': !!(status && status.gatewayEnabled),
339
- checked: !!(status && status.gatewayEnabled),
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: toggleGateway,
342
- style: styles.switchInput,
343
- }),
344
- React.createElement('span', { style: { ...styles.switchTrack, background: status && status.gatewayEnabled ? colors.success : colors.muted } }),
345
- React.createElement('span', { style: { ...styles.switchKnob, left: status && status.gatewayEnabled ? 21 : 3 } })))),
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,
@@ -13,88 +13,52 @@ function requireArray(value, endpoint) {
13
13
  return value
14
14
  }
15
15
 
16
- function chunkRowLength(event) {
17
- if (event.type === 'chunkrow/tool-call-chunks') return requireArray(event.data?.args, event.type).length
18
- return requireArray(event.data?.texts, event.type).length
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
- // DSH 0.1.2 compresses consecutive assistant deltas inside history pages.
22
- // dsh-mobile-v1 predates that storage shape, so restore the exact logical
23
- // SessionEvent sequence at this boundary instead of teaching every client a
24
- // Host-specific encoding detail.
25
- function expandChunkRow(event) {
26
- const data = requireRecord(event.data, event.type)
27
- const values = event.type === 'chunkrow/tool-call-chunks'
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 events
60
- }
61
-
62
- export function expandHistoryRecords(records) {
63
- const events = []
64
- for (const record of requireArray(records, 'session history')) {
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 = signal === undefined ? new AbortController() : null
86
- const streamSignal = signal ?? ownedAbort.signal
87
- const iterable = await gateway.stream({ namespace, method, args, signal: streamSignal })
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?.abort()
97
- if (typeof iterator.return === 'function') await iterator.return()
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 RC.1 SessionController intentionally names its reserved list argument
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
- if (frame.type !== 'snapshot' || !Number.isSafeInteger(frame.cursor)) {
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 records = snapshot.records
150
- let hasMore = snapshot.hasMore === true
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
- records = page.records
119
+ events = readHistoryRecords(page.records).map(event => ({ event }))
159
120
  hasMore = page.hasMore === true
160
121
  }
161
122
  return {
162
- events: expandHistoryRecords(records).map(event => ({ event })),
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
- execute: (sessionId, line, images, signal) => invoke(
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, images },
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
+ }