dsh-remote-control-plugin 0.3.3 → 0.3.5

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/INSTALL.md CHANGED
@@ -1,40 +1,33 @@
1
1
  # Install on another desktop
2
2
 
3
- Published as `dsh-remote-control-plugin`. The package is self-contained — no dependencies, no
4
- build step, no postinstall script.
3
+ Published as `dsh-remote-control-plugin`. Self-contained: no dependencies, no build step, no postinstall.
5
4
 
6
- ## 1. Install it
5
+ ## Install
7
6
 
8
- In the Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.3`, keep the
9
- official npm registry selected, then **Install** and **Enable now**. Restart DSH.
7
+ Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.5`, keep the official npm
8
+ registry selected, then **Install** and **Enable now**. Restart DSH.
10
9
 
11
- A **Devices** icon appears in the sidebar.
10
+ From a chat (Standard or Creator preset — PTC disables `tool-plugin-manager`):
12
11
 
13
- From a chat, the same install works where the plugin tool is enabled (Standard or Creator
14
- preset — PTC disables `tool-plugin-manager`):
12
+ > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.5` with plugin_manager.
15
13
 
16
- > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.3` with plugin_manager, then tell me when it is applied.
14
+ ## Connect two machines
17
15
 
18
- ## 2. Connect two machines
16
+ 1. On the machine with a reachable address: **Devices → Relay → Create relay here**, **Copy**.
17
+ 2. On the other: **Devices → Relay**, paste the invite, **Connect**.
18
+ 3. Either: **Request**, then **Allow** on the target.
19
19
 
20
- Pick the machine with a reachable address (same LAN, VPN, or a forwarded port):
21
-
22
- 1. On that machine: **Devices → Relay → Create relay here**, then **Copy** the invite.
23
- 2. On the other machine: **Devices → Relay**, paste the invite into the URL field, **Connect**.
24
- 3. Either machine: **Request** next to the peer, then **Allow** on the target.
25
-
26
- Two machines on the same LAN need no relay at all — LAN discovery lists them directly.
27
-
28
- No reachable machine? Run a relay anywhere both can reach (`PORT=8787 node relay/server.js`) and
29
- paste `http://<host>:8787 <workspace key>` into both panels.
20
+ Same-LAN machines need no relay. No reachable machine? Run
21
+ `PORT=8787 node relay/server.js` anywhere both can reach and paste
22
+ `http://<host>:8787 <workspace key>` into both panels.
30
23
 
31
24
  ## Notes
32
25
 
33
- - The account label is cosmetic; trust comes from the one-time **Allow** on the target device.
34
- - Approvals persist in `$DSH_HOME/remote-control-peers.json`; deleting it revokes every peer.
35
- - The relay and the control API share one port (default `57330`), so one forwarded port is enough.
36
- - Peer traffic is signed but not encrypted. Use a private network or TLS in front of the port.
37
- - 0.3.3 fixes four relay bugs: a hosted relay now survives a restart, peers no
38
- longer linger as `503 relay-offline` after a relay goes away, a blocked direct
39
- LAN route falls back to the outbound relay, and transcript chunks are sent in
40
- order so a large snapshot always assembles into a complete frame.
26
+ - Trust comes from the one-time **Allow**; the account label is cosmetic.
27
+ - Approvals live in `$DSH_HOME/remote-control-peers.json`; delete it to revoke.
28
+ - Relay and control API share one port (default `57330`).
29
+ - Peer traffic is signed but not encrypted; use a private network or TLS in front.
30
+ - 0.3.5 fixes: ordered transcript chunks, hosted relay persistence, stale
31
+ relay peer pruning, relay fallback when a direct LAN route is blocked, a client
32
+ module id that matches the package name, and a Devices config row for the
33
+ remote session's model, permission preset, and plan mode.
package/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  window.__ModuleLoader__.load({
2
- id: '@local/dsh-remote-control',
2
+ id: 'dsh-remote-control-plugin',
3
3
  factory(require) {
4
4
  const React = require('react')
5
5
  const h = React.createElement
@@ -72,6 +72,10 @@ window.__ModuleLoader__.load({
72
72
  border: '1px solid var(--dsw-alias-border-l1)',
73
73
  },
74
74
  error: { fontSize: '12px', color: 'var(--dsw-alias-state-error-primary)', padding: '8px 18px' },
75
+ configBar: {
76
+ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap',
77
+ padding: '8px 14px', borderTop: '1px solid var(--dsw-alias-border-l1)',
78
+ },
75
79
  }
76
80
 
77
81
  async function api(path, body) {
@@ -125,6 +129,8 @@ window.__ModuleLoader__.load({
125
129
  const [error, setError] = useState(null)
126
130
  const [streamState, setStreamState] = useState('connecting')
127
131
  const [attempt, setAttempt] = useState(0)
132
+ const [config, setConfig] = useState(null)
133
+ const [configBusy, setConfigBusy] = useState(false)
128
134
  const seen = useRef(new Set())
129
135
  const logRef = useRef(null)
130
136
  const pinned = useRef(true)
@@ -212,6 +218,82 @@ window.__ModuleLoader__.load({
212
218
  }
213
219
  }, [peerId, sessionId])
214
220
 
221
+ const loadConfig = useCallback(async () => {
222
+ if (!peerId || !sessionId) return
223
+ try {
224
+ const value = await api(
225
+ `/remote-control/api/peer/${encodeURIComponent(peerId)}/session-config?sessionId=${encodeURIComponent(sessionId)}`,
226
+ )
227
+ setConfig(value)
228
+ } catch {
229
+ setConfig(null)
230
+ }
231
+ }, [peerId, sessionId])
232
+
233
+ useEffect(() => { loadConfig() }, [loadConfig])
234
+
235
+ const applyConfig = useCallback(async (patch) => {
236
+ setConfigBusy(true)
237
+ setError(null)
238
+ try {
239
+ const value = await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/session-config`, { sessionId, ...patch })
240
+ if (value && value.ok === false) throw new Error((value.failures || []).join('; ') || 'config rejected')
241
+ await loadConfig()
242
+ } catch (failure) {
243
+ setError(failure.message)
244
+ }
245
+ setConfigBusy(false)
246
+ }, [peerId, sessionId, loadConfig])
247
+
248
+ const models = config?.models ?? []
249
+ const permissions = config?.permission?.catalog?.options ?? []
250
+ const modelValue = config?.model ? `${config.model.provider}:${config.model.model}` : ''
251
+ const permissionValue = config?.permission?.current ?? config?.permission?.catalog?.defaultPreset ?? ''
252
+ const planActive = config?.plan?.active === true
253
+ const configBar = (models.length > 0 || permissions.length > 0)
254
+ ? h('div', { style: S.configBar },
255
+ models.length > 0
256
+ ? h('select', {
257
+ style: S.field,
258
+ value: modelValue,
259
+ disabled: configBusy,
260
+ onChange: (event) => {
261
+ const [provider, model] = event.target.value.split(':')
262
+ if (provider && model) applyConfig({ model: { provider, model } })
263
+ },
264
+ },
265
+ modelValue ? null : h('option', { value: '' }, 'Model'),
266
+ models.map((group) => h('optgroup', { key: group.provider, label: group.name },
267
+ group.models.map((model) => h('option', {
268
+ key: `${group.provider}:${model.id}`,
269
+ value: `${group.provider}:${model.id}`,
270
+ }, model.name)),
271
+ )),
272
+ )
273
+ : null,
274
+ permissions.length > 0
275
+ ? h('select', {
276
+ style: S.field,
277
+ value: permissionValue,
278
+ disabled: configBusy,
279
+ onChange: (event) => applyConfig({ permission: event.target.value }),
280
+ }, permissions.map((option) => h('option', {
281
+ key: option.value,
282
+ value: option.value,
283
+ title: option.description ?? '',
284
+ }, option.name)))
285
+ : null,
286
+ config?.plan
287
+ ? h('button', {
288
+ type: 'button',
289
+ disabled: configBusy,
290
+ style: { ...S.button, ...(planActive ? S.primary : {}) },
291
+ onClick: () => applyConfig({ plan: !planActive }),
292
+ }, planActive ? 'Plan on' : 'Plan off')
293
+ : null,
294
+ )
295
+ : null
296
+
215
297
  return h('div', { style: { ...S.column, flex: 1, minHeight: 0 } },
216
298
  h('div', {
217
299
  style: { padding: '10px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1)', fontSize: '13px', fontWeight: 600 },
@@ -247,6 +329,7 @@ window.__ModuleLoader__.load({
247
329
  : null,
248
330
  live ? h('div', { style: { ...S.bubble, ...S.assistantBubble, opacity: 0.85 } }, live) : null,
249
331
  ),
332
+ configBar,
250
333
  h('div', { style: S.composer },
251
334
  h('textarea', {
252
335
  style: S.input,
package/host/fabric.js CHANGED
@@ -286,6 +286,19 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
286
286
  const verdict = verifyPeer(req, url)
287
287
  if (!verdict.ok) return json(res, 403, { error: verdict.error })
288
288
 
289
+ if (pathname === '/peer/session/config' && req.method === 'GET') {
290
+ const sessionId = String(url.searchParams.get('sessionId') || '')
291
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
292
+ if (typeof sessions.config !== 'function') return json(res, 503, { error: 'config-unavailable' })
293
+ return json(res, 200, await sessions.config(sessionId))
294
+ }
295
+ if (pathname === '/peer/session/config' && req.method === 'POST') {
296
+ const body = await readJson(req)
297
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
298
+ if (!sessionId) return json(res, 400, { error: 'missing-session' })
299
+ if (typeof sessions.applyConfig !== 'function') return json(res, 503, { error: 'config-unavailable' })
300
+ return json(res, 200, await sessions.applyConfig(sessionId, body))
301
+ }
289
302
  if (pathname === '/peer/sessions' && req.method === 'GET') {
290
303
  const value = await sessions.list()
291
304
  return json(res, 200, { items: value.map((item) => ({ ...item, title: item.title })) })
package/index.js CHANGED
@@ -87,6 +87,104 @@ export function apply(ctx, rawConfig) {
87
87
  cancel(sessionId) {
88
88
  return ctx.sessionController.cancel({ sessionId })
89
89
  },
90
+ /**
91
+ * Read a session's live configuration. Never resumes a cold session: a
92
+ * remote read must not have side effects on the target.
93
+ */
94
+ async config(sessionId) {
95
+ const values = {}
96
+ const liveSession = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
97
+ const agent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
98
+ const out = { sessionId, live: Boolean(liveSession), running: Boolean(agent) }
99
+
100
+ try {
101
+ const catalog = await ctx.sessionController.modelCatalog()
102
+ out.models = (catalog?.groups ?? []).map((group) => ({
103
+ provider: group.id,
104
+ name: group.name,
105
+ models: (group.models ?? []).map((model) => ({ id: model.id, name: model.name })),
106
+ }))
107
+ } catch { /* catalog unavailable */ }
108
+
109
+ try {
110
+ const projections = await ctx.sessionController.projections({ sessionId })
111
+ const current = projections?.values?.modelSelection
112
+ if (current) out.model = { provider: current.provider, model: current.model, reasoningEffort: current.reasoningEffort }
113
+ } catch { /* no projection */ }
114
+
115
+ try {
116
+ const presets = ctx.get?.('permissionPresets')
117
+ if (presets) {
118
+ out.permission = {
119
+ current: liveSession ? presets.current(liveSession) : null,
120
+ catalog: presets.catalog(),
121
+ }
122
+ }
123
+ } catch { /* permission presets unavailable */ }
124
+
125
+ try {
126
+ const plan = ctx.get?.('planMode')
127
+ if (plan && agent) out.plan = plan.get(agent)
128
+ } catch { /* plan mode unavailable */ }
129
+
130
+ return out
131
+ },
132
+ /** Apply model / permission / plan to a peer session, resuming it if cold. */
133
+ async applyConfig(sessionId, patch) {
134
+ if (!patch || typeof patch !== 'object') return { ok: false, error: 'bad-request' }
135
+ const applied = {}
136
+ const failures = []
137
+
138
+ if (patch.model && typeof patch.model.provider === 'string' && typeof patch.model.model === 'string') {
139
+ try {
140
+ const selection = { provider: patch.model.provider, model: patch.model.model }
141
+ if (typeof patch.model.reasoningEffort === 'string') selection.reasoningEffort = patch.model.reasoningEffort
142
+ const result = await ctx.sessionController.selectModel({ sessionId, ...selection })
143
+ applied.model = result?.selected ?? selection
144
+ } catch (error) {
145
+ failures.push(`model: ${error?.message || error}`)
146
+ }
147
+ }
148
+
149
+ const needsLive = typeof patch.permission === 'string' || typeof patch.plan === 'boolean'
150
+ if (needsLive) {
151
+ let agent = typeof ctx.agents?.get === 'function' ? ctx.agents.get(sessionId) : undefined
152
+ if (!agent) {
153
+ try {
154
+ const resolved = await ctx.sessionController.resolveAgent(sessionId)
155
+ agent = resolved?.agent
156
+ } catch (error) {
157
+ failures.push(`resume: ${error?.message || error}`)
158
+ }
159
+ }
160
+ const session = typeof ctx.sessions?.get === 'function' ? ctx.sessions.get(sessionId) : undefined
161
+
162
+ if (typeof patch.permission === 'string') {
163
+ try {
164
+ const presets = ctx.get?.('permissionPresets')
165
+ if (!presets || !session) throw new Error('permission presets unavailable')
166
+ presets.set(session, patch.permission)
167
+ applied.permission = patch.permission
168
+ } catch (error) {
169
+ failures.push(`permission: ${error?.message || error}`)
170
+ }
171
+ }
172
+
173
+ if (typeof patch.plan === 'boolean') {
174
+ try {
175
+ const plan = ctx.get?.('planMode')
176
+ if (!plan || !agent) throw new Error('plan mode unavailable')
177
+ applied.plan = plan.set(agent, patch.plan)
178
+ } catch (error) {
179
+ failures.push(`plan: ${error?.message || error}`)
180
+ }
181
+ }
182
+ }
183
+
184
+ return failures.length > 0
185
+ ? { ok: false, applied, failures }
186
+ : { ok: true, applied }
187
+ },
90
188
  }
91
189
 
92
190
  fabric = createFabric({
@@ -349,10 +447,20 @@ export function apply(ctx, rawConfig) {
349
447
  return json(res, 200, await fabric.connect(String(body?.deviceId || '')))
350
448
  }
351
449
 
352
- const match = action.match(/^\/peer\/([^/]+)\/(sessions|stream|prompt|cancel)$/)
450
+ const match = action.match(/^\/peer\/([^/]+)\/(sessions|stream|prompt|cancel|session-config)$/)
353
451
  if (match) {
354
452
  const deviceId = decodeURIComponent(match[1])
355
453
  const operation = match[2]
454
+ if (operation === 'session-config' && req.method === 'GET') {
455
+ const sessionId = String(url.searchParams.get('sessionId') || '')
456
+ const reply = await fabric.callPeer(deviceId, 'GET', `/peer/session/config?sessionId=${encodeURIComponent(sessionId)}`)
457
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
458
+ }
459
+ if (operation === 'session-config' && req.method === 'POST') {
460
+ const body = await readJson(req)
461
+ const reply = await fabric.callPeer(deviceId, 'POST', '/peer/session/config', body)
462
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
463
+ }
356
464
  if (operation === 'sessions' && req.method === 'GET') {
357
465
  const reply = await fabric.callPeer(deviceId, 'GET', '/peer/sessions')
358
466
  return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
package/package.json CHANGED
@@ -1,48 +1,40 @@
1
- {
2
- "name": "dsh-remote-control-plugin",
3
- "version": "0.3.3",
4
- "description": "Remote control for DSH: a Devices panel that discovers other DSH desktops, pairs with them once, and drives their sessions across LAN or a relay.",
5
- "license": "MIT",
6
- "type": "module",
7
- "keywords": [
8
- "dsh",
9
- "deepseek-harness",
10
- "cordis",
11
- "cordis-plugin",
12
- "remote-control",
13
- "devices"
14
- ],
15
- "exports": {
16
- ".": "./index.js",
17
- "./client": "./client.js",
18
- "./locale/*.json": "./locale/*.json",
19
- "./icon": "./icon.svg"
20
- },
21
- "files": [
22
- "index.js",
23
- "client.js",
24
- "host/*.js",
25
- "cordis.patch.yml",
26
- "locale/*.json",
27
- "icon.svg",
28
- "README.md",
29
- "INSTALL.md",
30
- "LICENSE",
31
- "web/index.html",
32
- "web/app.js",
33
- "web/app.css",
34
- "web/icon.svg"
35
- ],
36
- "engines": {
37
- "node": "\u003e=20"
38
- },
39
- "dsh": {
40
- "bundle": {
41
- "patch": "./cordis.patch.yml"
42
- },
43
- "client": {
44
- "platform": "web",
45
- "immediately": true
46
- }
47
- }
48
- }
1
+ {
2
+ "name": "dsh-remote-control-plugin",
3
+ "version": "0.3.5",
4
+ "type": "module",
5
+ "description": "Remote control for DSH: a Devices panel that discovers other DSH desktops signed into the same account, pairs with them once, and drives their sessions.",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./client": "./client.js",
9
+ "./locale/*.json": "./locale/*.json",
10
+ "./icon": "./icon.svg"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "client.js",
15
+ "host/*.js",
16
+ "cordis.patch.yml",
17
+ "locale/*.json",
18
+ "icon.svg",
19
+ "README.md",
20
+ "INSTALL.md",
21
+ "LICENSE",
22
+ "web/index.html",
23
+ "web/app.js",
24
+ "web/app.css",
25
+ "web/icon.svg"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "immediately": true
34
+ }
35
+ },
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=20"
39
+ }
40
+ }