dsh-remote-control-plugin 0.3.4 → 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
@@ -4,12 +4,12 @@ Published as `dsh-remote-control-plugin`. Self-contained: no dependencies, no bu
4
4
 
5
5
  ## Install
6
6
 
7
- Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.4`, keep the official npm
7
+ Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.5`, keep the official npm
8
8
  registry selected, then **Install** and **Enable now**. Restart DSH.
9
9
 
10
10
  From a chat (Standard or Creator preset — PTC disables `tool-plugin-manager`):
11
11
 
12
- > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.4` with plugin_manager.
12
+ > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.5` with plugin_manager.
13
13
 
14
14
  ## Connect two machines
15
15
 
@@ -27,6 +27,7 @@ Same-LAN machines need no relay. No reachable machine? Run
27
27
  - Approvals live in `$DSH_HOME/remote-control-peers.json`; delete it to revoke.
28
28
  - Relay and control API share one port (default `57330`).
29
29
  - Peer traffic is signed but not encrypted; use a private network or TLS in front.
30
- - 0.3.4 fixes: ordered transcript chunks, hosted relay persistence, stale
31
- relay peer pruning, relay fallback when a direct LAN route is blocked, and a
32
- client module id that matches the package name.
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
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-control-plugin",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
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
6
  "exports": {