dsh-remote-control-plugin 0.3.4 → 0.3.6

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.6`, 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.6` 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.6 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,111 @@ 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 out = { sessionId }
96
+ // Cordis throws when a service is read as a property without `inject`,
97
+ // so every optional service is reached through ctx.get inside a guard.
98
+ let liveSession
99
+ let agent
100
+ try { liveSession = ctx.get?.('sessions')?.get?.(sessionId) } catch { /* not injected */ }
101
+ try { agent = ctx.get?.('agents')?.get?.(sessionId) } catch { /* not injected */ }
102
+ out.live = Boolean(liveSession)
103
+ out.running = Boolean(agent)
104
+
105
+ try {
106
+ const catalog = await ctx.sessionController.modelCatalog()
107
+ out.models = (catalog?.groups ?? []).map((group) => ({
108
+ provider: group.id,
109
+ name: group.name,
110
+ models: (group.models ?? []).map((model) => ({ id: model.id, name: model.name })),
111
+ }))
112
+ } catch { /* catalog unavailable */ }
113
+
114
+ try {
115
+ const projections = await ctx.sessionController.projections({ sessionId })
116
+ const current = projections?.values?.modelSelection
117
+ if (current) out.model = { provider: current.provider, model: current.model, reasoningEffort: current.reasoningEffort }
118
+ } catch { /* no projection */ }
119
+
120
+ try {
121
+ const presets = ctx.get?.('permissionPresets')
122
+ if (presets) {
123
+ out.permission = {
124
+ current: liveSession ? presets.current(liveSession) : null,
125
+ catalog: presets.catalog(),
126
+ }
127
+ }
128
+ } catch { /* permission presets unavailable */ }
129
+
130
+ try {
131
+ const plan = ctx.get?.('planMode')
132
+ if (plan && agent) out.plan = plan.get(agent)
133
+ } catch { /* plan mode unavailable */ }
134
+
135
+ return out
136
+ },
137
+ /** Apply model / permission / plan to a peer session, resuming it if cold. */
138
+ async applyConfig(sessionId, patch) {
139
+ if (!patch || typeof patch !== 'object') return { ok: false, error: 'bad-request' }
140
+ const applied = {}
141
+ const failures = []
142
+
143
+ if (patch.model && typeof patch.model.provider === 'string' && typeof patch.model.model === 'string') {
144
+ try {
145
+ const selection = { provider: patch.model.provider, model: patch.model.model }
146
+ if (typeof patch.model.reasoningEffort === 'string') selection.reasoningEffort = patch.model.reasoningEffort
147
+ const result = await ctx.sessionController.selectModel({ sessionId, ...selection })
148
+ applied.model = result?.selected ?? selection
149
+ } catch (error) {
150
+ failures.push(`model: ${error?.message || error}`)
151
+ }
152
+ }
153
+
154
+ const needsLive = typeof patch.permission === 'string' || typeof patch.plan === 'boolean'
155
+ if (needsLive) {
156
+ let agent
157
+ let session
158
+ try { agent = ctx.get?.('agents')?.get?.(sessionId) } catch { /* not injected */ }
159
+ if (!agent) {
160
+ try {
161
+ const resolved = await ctx.sessionController.resolveAgent(sessionId)
162
+ agent = resolved?.agent
163
+ } catch (error) {
164
+ failures.push(`resume: ${error?.message || error}`)
165
+ }
166
+ }
167
+ try { session = ctx.get?.('sessions')?.get?.(sessionId) } catch { /* not injected */ }
168
+
169
+ if (typeof patch.permission === 'string') {
170
+ try {
171
+ const presets = ctx.get?.('permissionPresets')
172
+ if (!presets || !session) throw new Error('permission presets unavailable')
173
+ presets.set(session, patch.permission)
174
+ applied.permission = patch.permission
175
+ } catch (error) {
176
+ failures.push(`permission: ${error?.message || error}`)
177
+ }
178
+ }
179
+
180
+ if (typeof patch.plan === 'boolean') {
181
+ try {
182
+ const plan = ctx.get?.('planMode')
183
+ if (!plan || !agent) throw new Error('plan mode unavailable')
184
+ applied.plan = plan.set(agent, patch.plan)
185
+ } catch (error) {
186
+ failures.push(`plan: ${error?.message || error}`)
187
+ }
188
+ }
189
+ }
190
+
191
+ return failures.length > 0
192
+ ? { ok: false, applied, failures }
193
+ : { ok: true, applied }
194
+ },
90
195
  }
91
196
 
92
197
  fabric = createFabric({
@@ -349,10 +454,20 @@ export function apply(ctx, rawConfig) {
349
454
  return json(res, 200, await fabric.connect(String(body?.deviceId || '')))
350
455
  }
351
456
 
352
- const match = action.match(/^\/peer\/([^/]+)\/(sessions|stream|prompt|cancel)$/)
457
+ const match = action.match(/^\/peer\/([^/]+)\/(sessions|stream|prompt|cancel|session-config)$/)
353
458
  if (match) {
354
459
  const deviceId = decodeURIComponent(match[1])
355
460
  const operation = match[2]
461
+ if (operation === 'session-config' && req.method === 'GET') {
462
+ const sessionId = String(url.searchParams.get('sessionId') || '')
463
+ const reply = await fabric.callPeer(deviceId, 'GET', `/peer/session/config?sessionId=${encodeURIComponent(sessionId)}`)
464
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
465
+ }
466
+ if (operation === 'session-config' && req.method === 'POST') {
467
+ const body = await readJson(req)
468
+ const reply = await fabric.callPeer(deviceId, 'POST', '/peer/session/config', body)
469
+ return json(res, reply.status ?? (reply.ok ? 200 : 502), reply.json ?? { error: reply.error })
470
+ }
356
471
  if (operation === 'sessions' && req.method === 'GET') {
357
472
  const reply = await fabric.callPeer(deviceId, 'GET', '/peer/sessions')
358
473
  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.6",
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": {