@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.
@@ -0,0 +1,136 @@
1
+ import crypto from 'node:crypto'
2
+ import { readSessionSnapshot } from './dsh-host-adapter.mjs'
3
+
4
+ function integer(value, minimum = 0) {
5
+ return Number.isSafeInteger(value) && value >= minimum
6
+ }
7
+
8
+ function requireAttempt(attempt) {
9
+ if (!attempt || typeof attempt.attemptId !== 'string' || !attempt.attemptId
10
+ || !integer(attempt.turn) || !integer(attempt.step)
11
+ || !integer(attempt.startedAfterSeq, -1)) throw new Error('invalid assistant attempt')
12
+ return attempt
13
+ }
14
+
15
+ // Validate the two independent clocks. No transient frame owns a Session seq.
16
+ export function createFollowDecoder(sessionId) {
17
+ let cursor
18
+ let revision
19
+ let active
20
+ return frame => {
21
+ if (cursor === undefined) {
22
+ const history = readSessionSnapshot(frame, sessionId)
23
+ const baseline = frame.assistantStream
24
+ if (!baseline || !integer(baseline.revision)) throw new Error('missing assistant stream baseline')
25
+ if (baseline.activeAttempt !== undefined) {
26
+ const attempt = requireAttempt(baseline.activeAttempt)
27
+ if (!integer(attempt.nextIndex) || !Array.isArray(attempt.stream)
28
+ || attempt.startedAfterSeq > history.cursor) throw new Error('invalid assistant baseline')
29
+ active = { ...attempt }
30
+ }
31
+ cursor = history.cursor
32
+ revision = baseline.revision
33
+ return { type: 'snapshot', history, assistantStream: baseline }
34
+ }
35
+ if (frame?.type === 'event') {
36
+ if (!frame.event || frame.event.seq !== cursor + 1) throw new Error('session event sequence gap')
37
+ cursor = frame.event.seq
38
+ return frame
39
+ }
40
+ if (frame?.type !== 'assistant-stream') throw new Error('unexpected session follow frame')
41
+ const next = frame.frame
42
+ if (!next || next.revision !== revision + 1) throw new Error('assistant stream revision gap')
43
+ if (next.type === 'start') {
44
+ requireAttempt(next)
45
+ if (active || next.startedAfterSeq > cursor) throw new Error('unexpected assistant start')
46
+ active = { ...next, nextIndex: 0 }
47
+ } else {
48
+ if (!active || next.attemptId !== active.attemptId || next.index !== active.nextIndex) {
49
+ throw new Error('assistant stream attempt/index gap')
50
+ }
51
+ if (next.type === 'chunk') {
52
+ if (!integer(next.time) || !next.chunk || typeof next.chunk.type !== 'string') {
53
+ throw new Error('invalid assistant chunk')
54
+ }
55
+ active.nextIndex += 1
56
+ } else if (next.type === 'end') {
57
+ const outcome = next.outcome
58
+ if (!outcome || (outcome.kind !== 'abandoned' && (outcome.kind !== 'committed'
59
+ || !['assistant/message', 'assistant/attempt'].includes(outcome.eventType)
60
+ || !integer(outcome.seq) || outcome.seq > cursor || outcome.seq <= active.startedAfterSeq))) {
61
+ throw new Error('invalid assistant settlement')
62
+ }
63
+ } else throw new Error('unexpected assistant frame')
64
+ }
65
+ revision = next.revision
66
+ const normalized = { ...next, turn: active.turn, step: active.step }
67
+ if (next.type === 'end') active = undefined
68
+ return { type: 'assistant-stream', frame: normalized }
69
+ }
70
+ }
71
+
72
+ function delay(ms, signal) {
73
+ return new Promise(resolve => {
74
+ if (signal.aborted) return resolve()
75
+ const finish = () => {
76
+ clearTimeout(timer)
77
+ signal.removeEventListener('abort', finish)
78
+ resolve()
79
+ }
80
+ const timer = setTimeout(finish, ms)
81
+ signal.addEventListener('abort', finish, { once: true })
82
+ })
83
+ }
84
+
85
+ // One follow per opted-in connection. Each opening owns a new stream identity
86
+ // and atomic snapshot, including a reconnecting Agent's active attempt.
87
+ export function createSessionFollower(api, { onFrame, onError, retryMs = 1000 }) {
88
+ let current
89
+ const stop = () => {
90
+ const previous = current
91
+ current = undefined
92
+ previous?.abort.abort()
93
+ }
94
+ const start = (sessionId, subscriptionId) => {
95
+ stop()
96
+ const state = { abort: new AbortController() }
97
+ current = state
98
+ const task = (async () => {
99
+ let failures = 0
100
+ while (current === state && !state.abort.signal.aborted) {
101
+ const attemptAbort = new AbortController()
102
+ const signal = AbortSignal.any([state.abort.signal, attemptAbort.signal])
103
+ const streamId = crypto.randomUUID()
104
+ const context = { sessionId, subscriptionId, streamId }
105
+ const decode = createFollowDecoder(sessionId)
106
+ let iterator
107
+ let permanent = false
108
+ try {
109
+ const stream = await api.openSessionStream(sessionId, signal)
110
+ iterator = stream[Symbol.asyncIterator]()
111
+ while (current === state && !signal.aborted) {
112
+ const item = await iterator.next()
113
+ if (current !== state || signal.aborted) break
114
+ if (item.done) throw new Error('session follow ended')
115
+ const decoded = decode(item.value)
116
+ onFrame(decoded, context)
117
+ }
118
+ } catch (error) {
119
+ if (current !== state || signal.aborted) break
120
+ permanent = ['unsupported-session-format', 'session/not-found', 'gateway/bad-request'].includes(error.code)
121
+ || (error.code === 'SESSION_QUERY_PERSISTENCE_FAILED'
122
+ && /refuses this format .*Session/.test(error.message || ''))
123
+ onError(error, { ...context, retrying: !permanent })
124
+ } finally {
125
+ // Abort before return: the producer may be waiting for another event.
126
+ attemptAbort.abort()
127
+ try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
128
+ }
129
+ if (permanent) break
130
+ await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
131
+ }
132
+ })()
133
+ return task
134
+ }
135
+ return { start, stop }
136
+ }
@@ -0,0 +1,7 @@
1
+ // Host 的摘要截断可能留下单个 UTF-16 代理项。JSON.stringify 会将其转义,
2
+ // 但 Swift/Foundation 仍拒绝整帧;在传输边界统一输出有效 Unicode。
3
+ // 不修改原对象、历史正文、有效 emoji 或字面量反斜杠转义文本。
4
+ export function stringifyWireFrame(frame) {
5
+ return JSON.stringify(frame, (_key, value) =>
6
+ typeof value === 'string' ? value.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') : value)
7
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@sparkelf/dsh-plugin-mobile-gateway",
3
+ "version": "0.8.0",
4
+ "description": "Mobile gateway for DSH 0.1.7 (Session format 4), carrying the fork's format adaptation: v3 rows are lifted on read so a Session written by an earlier release still opens. Per-session Agent preset selection, live preset updates, and the upstream LAN/pairing protocol are unchanged.",
5
+ "main": "lib/index.mjs",
6
+ "files": [
7
+ "bin",
8
+ "helper",
9
+ "lib",
10
+ "docs",
11
+ "cordis.patch.yml",
12
+ "PROTOCOL.md"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/SparkElf/dsh-plugin-mobile-gateway.git"
17
+ },
18
+ "homepage": "https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/Clarklevis1995/dsh-plugin-mobile-gateway/issues"
21
+ },
22
+ "dependencies": {
23
+ "@deepseek-ai/schemastery": "^3.18.1",
24
+ "ws": "^8.18.0",
25
+ "qrcode": "^1.5.4"
26
+ },
27
+ "bin": "bin/setup-ip.mjs",
28
+ "scripts": {
29
+ "test": "node test/wire-json.test.mjs && node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/session-preset.test.mjs && node test/session-follower.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
30
+ },
31
+ "exports": {
32
+ ".": "./lib/index.mjs",
33
+ "./client": "./lib/client.js",
34
+ "./package.json": "./package.json"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web",
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-runtime",
44
+ "@deepseek-ai/dsh-client-ui-layout",
45
+ "@deepseek-ai/dsh-client-ui-sidebar"
46
+ ]
47
+ }
48
+ },
49
+ "license": "MIT"
50
+ }