@skillpie/dsh-remote 0.0.0-stage → 1.1.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/lib/remote.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * dsh-remote — outbound control link (host role).
3
+ *
4
+ * Dials `relayUrl + HOST_UPGRADE_PATH` over one WebSocket, registers the
5
+ * 8-digit code and identity, and feeds every control frame to the shared
6
+ * HostEngine. Reconnects with jittered backoff; mints/re-persists the token
7
+ * whenever the relay hands one back (first registration, or after the relay
8
+ * lost its registry).
9
+ */
10
+ import WebSocket from 'ws'
11
+ import { HOST_UPGRADE_PATH, PROTOCOL_VERSION } from './protocol.js'
12
+ import { adaptSocket } from './ws-adapter.js'
13
+
14
+ const BACKOFF_INITIAL_MS = 1_000
15
+ const BACKOFF_MAX_MS = 30_000
16
+ const HEARTBEAT_MS = 15_000
17
+ const IDLE_TIMEOUT_MS = 45_000
18
+
19
+ export class RelayLink {
20
+ /**
21
+ * @param identity { hostId, code, token, name, dsh } — read live via getters
22
+ * @param onToken (token) — persist a relay-minted token
23
+ * @param peer { attach(channel), detach(channel) } — the GUI bridge or
24
+ * command engine; attached on a healthy link
25
+ */
26
+ constructor({ relayUrl, identity, onToken, peer, log = () => {} }) {
27
+ this.relayUrl = relayUrl
28
+ this.identity = identity
29
+ this.onToken = onToken
30
+ this.peer = peer
31
+ this.log = log
32
+ this.raw = undefined
33
+ this.backoffMs = BACKOFF_INITIAL_MS
34
+ this.retryTimer = undefined
35
+ this.hbTimer = undefined
36
+ this.idleTimer = undefined
37
+ this.stopped = true
38
+ this.connectedSince = undefined
39
+ this.lastError = undefined
40
+ }
41
+
42
+ get connected() {
43
+ return this.raw !== undefined && this.connectedSince !== undefined
44
+ }
45
+
46
+ start() {
47
+ if (!this.stopped) return
48
+ this.stopped = false
49
+ this.connect()
50
+ }
51
+
52
+ stop() {
53
+ this.stopped = true
54
+ this.clearTimers()
55
+ if (this.retryTimer) { clearTimeout(this.retryTimer); this.retryTimer = undefined }
56
+ this.detachPeer()
57
+ if (this.raw) {
58
+ try { this.raw.close(1000) } catch { /* already gone */ }
59
+ this.raw = undefined
60
+ }
61
+ this.connectedSince = undefined
62
+ }
63
+
64
+ clearTimers() {
65
+ if (this.hbTimer) { clearInterval(this.hbTimer); this.hbTimer = undefined }
66
+ if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = undefined }
67
+ }
68
+
69
+ detachPeer() {
70
+ if (this.peer && this.adapter) this.peer.detach(this.adapter)
71
+ this.adapter = undefined
72
+ }
73
+
74
+ connect() {
75
+ if (this.stopped) return
76
+ const url = new URL(this.relayUrl)
77
+ url.protocol = url.protocol === 'http:' ? 'ws:' : url.protocol === 'https:' ? 'wss:' : url.protocol
78
+ url.pathname = HOST_UPGRADE_PATH
79
+ let raw
80
+ try {
81
+ raw = new WebSocket(url, { handshakeTimeout: 10_000 })
82
+ } catch (error) {
83
+ this.lastError = error?.message ?? String(error)
84
+ this.scheduleRetry()
85
+ return
86
+ }
87
+ this.raw = raw
88
+ raw.on('open', () => {
89
+ raw.send(JSON.stringify({
90
+ t: 'hello',
91
+ v: PROTOCOL_VERSION,
92
+ hostId: this.identity.hostId,
93
+ code: this.identity.code,
94
+ token: this.identity.token ?? '',
95
+ name: this.identity.name,
96
+ dsh: this.identity.dsh,
97
+ }))
98
+ })
99
+ raw.on('message', (data) => this.onFrame(String(data)))
100
+ raw.on('close', () => {
101
+ const wasConnected = this.connected
102
+ this.clearTimers()
103
+ this.detachPeer()
104
+ this.raw = undefined
105
+ this.connectedSince = undefined
106
+ if (this.stopped) return
107
+ if (wasConnected) this.log('relay', '中转连接断开,准备重连')
108
+ this.scheduleRetry()
109
+ })
110
+ raw.on('error', (error) => {
111
+ this.lastError = error?.message ?? String(error)
112
+ })
113
+ }
114
+
115
+ scheduleRetry() {
116
+ if (this.stopped || this.retryTimer) return
117
+ const jitter = Math.floor(Math.random() * this.backoffMs * 0.5)
118
+ const delay = this.backoffMs + jitter
119
+ this.backoffMs = Math.min(this.backoffMs * 2, BACKOFF_MAX_MS)
120
+ this.retryTimer = setTimeout(() => {
121
+ this.retryTimer = undefined
122
+ this.connect()
123
+ }, delay)
124
+ this.log('relay', `将在 ${Math.round(delay / 100) / 10}s 后重连中转`)
125
+ }
126
+
127
+ onFrame(text) {
128
+ let frame
129
+ try { frame = JSON.parse(text) } catch { return }
130
+ if (!frame || typeof frame !== 'object') return
131
+ if (frame.t === 'hello') {
132
+ if (frame.ok !== true) {
133
+ this.lastError = frame.error ?? 'relay rejected registration'
134
+ this.log('relay', `中转拒绝注册: ${this.lastError}`)
135
+ // Registration refused (e.g. code taken): back off like a network failure.
136
+ try { this.raw?.close() } catch { /* noop */ }
137
+ return
138
+ }
139
+ this.backoffMs = BACKOFF_INITIAL_MS
140
+ this.connectedSince = Date.now()
141
+ this.lastError = undefined
142
+ if (typeof frame.token === 'string' && frame.token !== this.identity.token) {
143
+ this.identity.token = frame.token
144
+ try { this.onToken?.(frame.token) } catch (error) {
145
+ this.log('relay', `token 持久化失败: ${error?.message ?? error}`)
146
+ }
147
+ }
148
+ this.armIdle()
149
+ this.adapter = adaptSocket(this.raw)
150
+ this.peer.attach(this.adapter)
151
+ this.hbTimer = setInterval(() => {
152
+ if (this.raw?.readyState === 1) this.raw.send(JSON.stringify({ t: 'hb' }))
153
+ }, HEARTBEAT_MS)
154
+ this.log('relay', `已连接中转,远程码 ${this.identity.code} 生效`)
155
+ return
156
+ }
157
+ if (frame.t === 'hb') {
158
+ this.armIdle()
159
+ return
160
+ }
161
+ // res/push frames belong to the engine's channel adapter; its own
162
+ // onMessage handler consumed them already (adapter shares this socket).
163
+ }
164
+
165
+ armIdle() {
166
+ if (this.idleTimer) clearTimeout(this.idleTimer)
167
+ this.idleTimer = setTimeout(() => {
168
+ this.log('relay', '中转心跳超时,主动断开')
169
+ try { this.raw?.close() } catch { /* noop */ }
170
+ }, IDLE_TIMEOUT_MS)
171
+ }
172
+
173
+ status() {
174
+ return {
175
+ relayUrl: this.relayUrl,
176
+ connected: this.connected,
177
+ connectedSince: this.connectedSince,
178
+ lastError: this.lastError,
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * dsh-remote — adapter from a raw `ws` socket to the small surface the
3
+ * plugin uses:
4
+ * send(obj) JSON text frame
5
+ * sendRaw(buffer) binary frame (GUI splice payloads)
6
+ * onMessage(cb) cb(text | Buffer) — text for JSON frames, Buffer for binary
7
+ * onClose(cb), close()
8
+ */
9
+ export function adaptSocket(raw) {
10
+ const end = {
11
+ open: raw.readyState === 1,
12
+ send(obj) {
13
+ if (raw.readyState !== 1) return
14
+ raw.send(JSON.stringify(obj))
15
+ },
16
+ sendRaw(buffer) {
17
+ if (raw.readyState !== 1) return
18
+ raw.send(buffer, { binary: true })
19
+ },
20
+ sendText(text) {
21
+ if (raw.readyState !== 1) return
22
+ raw.send(text)
23
+ },
24
+ onMessage(handler) {
25
+ const listener = (data, isBinary) => {
26
+ try {
27
+ handler(isBinary ? data : String(data))
28
+ } catch {
29
+ // A listener bug must never crash the host process.
30
+ }
31
+ }
32
+ raw.on('message', listener)
33
+ return () => raw.off('message', listener)
34
+ },
35
+ onClose(handler) {
36
+ const listener = () => {
37
+ try { handler() } catch { /* contained */ }
38
+ }
39
+ raw.on('close', listener)
40
+ return () => raw.off('close', listener)
41
+ },
42
+ close() {
43
+ try { raw.close(1000) } catch { raw.terminate() }
44
+ },
45
+ raw,
46
+ }
47
+ raw.on('close', () => { end.open = false })
48
+ return end
49
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Remote control",
4
+ "description": "Lets a browser reach this host with an 8-digit code through a relay origin, view sessions, send prompts and interrupt runs."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "远程控制",
4
+ "description": "通过中转站以 8 位远程码在浏览器里访问本机:查看会话、发送指令、中断运行。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@skillpie/dsh-remote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.1.0",
4
+ "description": "Remote control for DeepSeek Harness: reach any host by its 8-digit code through a relay origin such as dsh.skillpie.cn.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh",
9
+ "deepseek-harness",
10
+ "dsh-plugin",
11
+ "remote-control",
12
+ "relay",
13
+ "remote-assistance"
14
+ ],
15
+ "main": "./lib/index.js",
16
+ "exports": {
17
+ ".": "./lib/index.js",
18
+ "./client": "./lib/client.js",
19
+ "./locale/*.json": "./locale/*.json",
20
+ "./icon": "./icon.svg"
21
+ },
22
+ "files": [
23
+ "lib",
24
+ "locale/*.json",
25
+ "cordis.patch.yml",
26
+ "icon.svg",
27
+ "README.md"
28
+ ],
29
+ "engines": {
30
+ "node": ">=22.13",
31
+ "dsh": ">=0.2.1-rc.3"
32
+ },
33
+ "dependencies": {
34
+ "ws": "^8.22.2"
35
+ },
36
+ "dsh": {
37
+ "bundle": {
38
+ "patch": "./cordis.patch.yml"
39
+ },
40
+ "client": {
41
+ "platform": "web",
42
+ "inject": [
43
+ "@deepseek-ai/dsh-client-locale",
44
+ "@deepseek-ai/dsh-client-ui-layout",
45
+ "@deepseek-ai/dsh-client-ui-sidebar"
46
+ ]
47
+ }
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ }
52
+ }