dsh-plugin-mobile-gateway 0.7.3 → 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.
@@ -0,0 +1,134 @@
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
+ onError(error, { ...context, retrying: !permanent })
122
+ } finally {
123
+ // Abort before return: the producer may be waiting for another event.
124
+ attemptAbort.abort()
125
+ try { await iterator?.return?.() } catch { /* The opening failure was already reported. */ }
126
+ }
127
+ if (permanent) break
128
+ await delay(Math.min(retryMs * 2 ** Math.min(failures++, 5), 30_000), state.abort.signal)
129
+ }
130
+ })()
131
+ return task
132
+ }
133
+ return { start, stop }
134
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-mobile-gateway",
3
- "version": "0.7.3",
4
- "description": "Secure mobile gateway for DeepSeek Harness with split realtime/control channels, editable queues, stop/resume, archive/rename sync, tasks, goals, commands, approvals, and file transfers",
3
+ "version": "0.7.4",
4
+ "description": "Updated for dsh 0.1.5-rc.1/2 with dedicated realtime streams, reconnect recovery, history format and cursor validation, and fixes for command attachments and session state sync",
5
5
  "main": "lib/index.mjs",
6
6
  "files": [
7
7
  "bin",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "bin": "bin/setup-ip.mjs",
28
28
  "scripts": {
29
- "test": "node test/setup-ip.test.mjs && node test/host-adapter.test.mjs && node test/gateway.test.mjs && node test/auth.test.mjs && node test/lan.test.mjs && node test/multi-gateway.test.mjs"
29
+ "test": "node test/setup-ip.test.mjs && node test/host-adapter.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
30
  },
31
31
  "exports": {
32
32
  ".": "./lib/index.mjs",