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 +6 -5
- package/client.js +83 -0
- package/host/fabric.js +13 -0
- package/index.js +109 -1
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
31
|
-
relay peer pruning, relay fallback when a direct LAN route is blocked,
|
|
32
|
-
|
|
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.
|
|
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": {
|