@skillpie/dsh-remote 0.0.0-stage → 1.0.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/README.md +77 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +14 -0
- package/lib/bridge.js +377 -0
- package/lib/client.js +493 -0
- package/lib/console.js +434 -0
- package/lib/engine.js +367 -0
- package/lib/index.js +475 -0
- package/lib/pipe.js +83 -0
- package/lib/protocol.js +122 -0
- package/lib/relay-core.js +823 -0
- package/lib/remote.js +181 -0
- package/lib/ws-adapter.js +49 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +50 -4
package/lib/engine.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote — host-side visitor command engine.
|
|
3
|
+
*
|
|
4
|
+
* One engine per host process. It speaks the control-channel frames over one
|
|
5
|
+
* OR MORE transports at a time (relay WebSocket and the local direct pipe
|
|
6
|
+
* are both common), and drives the in-process sessionController exactly
|
|
7
|
+
* where the web GUI does: list, state, create, prompt (queued), cancel, plus
|
|
8
|
+
* per-session durable event and state streams shared across observing
|
|
9
|
+
* visitors.
|
|
10
|
+
*
|
|
11
|
+
* Routing rule: a reply or addressed push always rides the transport the
|
|
12
|
+
* visitor came in on; session-wide fan-out rides every transport with an
|
|
13
|
+
* observer. The engine never reaches the model or the filesystem directly;
|
|
14
|
+
* every capability is a sessionController call, so a visitor can do remotely
|
|
15
|
+
* what the owner could do in the GUI — and nothing else.
|
|
16
|
+
*/
|
|
17
|
+
import { generateId, VISITOR_COMMANDS, INTERNAL_COMMANDS, PROTOCOL_VERSION } from './protocol.js'
|
|
18
|
+
|
|
19
|
+
const HEARTBEAT_MS = 15_000
|
|
20
|
+
const MAX_TEXT = 32 * 1024
|
|
21
|
+
const LOG_CAP = 200
|
|
22
|
+
const HISTORY_MAX_MESSAGES = 40
|
|
23
|
+
/** sessionController's remote signatures require a signal; cap one-shot reads. */
|
|
24
|
+
const READ_TIMEOUT_MS = 20_000
|
|
25
|
+
|
|
26
|
+
/** A fresh bounded signal for one-shot sessionController reads. */
|
|
27
|
+
const readSignal = () => AbortSignal.timeout(READ_TIMEOUT_MS)
|
|
28
|
+
|
|
29
|
+
export class HostEngine {
|
|
30
|
+
/**
|
|
31
|
+
* @param services lazy service access: { sessionController(): object | undefined }
|
|
32
|
+
* @param host static host facts: { name, dsh, code } (plain values)
|
|
33
|
+
*/
|
|
34
|
+
constructor({ services, host, log = () => {} }) {
|
|
35
|
+
this.services = services
|
|
36
|
+
this.host = host
|
|
37
|
+
this.log = log
|
|
38
|
+
/** channel records: { channel, heartbeat } */
|
|
39
|
+
this.channels = new Map()
|
|
40
|
+
/** visitorId → { connectedAt, sessions:Set, channel } */
|
|
41
|
+
this.visitors = new Map()
|
|
42
|
+
/** sessionId → { refs, controller, cleanups[] } */
|
|
43
|
+
this.streams = new Map()
|
|
44
|
+
this.allowControl = true
|
|
45
|
+
this.startedAt = Date.now()
|
|
46
|
+
this.broadcastTimer = undefined
|
|
47
|
+
this.closed = false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Wire the engine to a transport. The transport speaks hello itself. */
|
|
51
|
+
attach(channel) {
|
|
52
|
+
const record = { channel, heartbeat: undefined }
|
|
53
|
+
this.channels.set(channel, record)
|
|
54
|
+
record.offMessage = channel.onMessage((text) => this.onFrame(text, channel))
|
|
55
|
+
record.offClose = channel.onClose(() => this.detach(channel))
|
|
56
|
+
record.heartbeat = setInterval(() => {
|
|
57
|
+
if (this.channels.has(channel)) channel.send({ t: 'hb' })
|
|
58
|
+
}, HEARTBEAT_MS)
|
|
59
|
+
this.log('engine', 'channel attached')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove one transport; its visitors go with it. */
|
|
63
|
+
detach(channel) {
|
|
64
|
+
const record = this.channels.get(channel)
|
|
65
|
+
if (!record) return
|
|
66
|
+
this.channels.delete(channel)
|
|
67
|
+
record.offMessage?.()
|
|
68
|
+
record.offClose?.()
|
|
69
|
+
if (record.heartbeat) clearInterval(record.heartbeat)
|
|
70
|
+
for (const [visitorId, visitor] of [...this.visitors]) {
|
|
71
|
+
if (visitor.channel === channel) {
|
|
72
|
+
for (const sessionId of visitor.sessions) this.unobserve(visitorId, sessionId)
|
|
73
|
+
this.visitors.delete(visitorId)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Full teardown for plugin unload. */
|
|
79
|
+
close() {
|
|
80
|
+
if (this.closed) return
|
|
81
|
+
this.closed = true
|
|
82
|
+
for (const channel of [...this.channels.keys()]) this.detach(channel)
|
|
83
|
+
if (this.broadcastTimer) clearTimeout(this.broadcastTimer)
|
|
84
|
+
this.broadcastTimer = undefined
|
|
85
|
+
for (const stream of this.streams.values()) stream.controller.abort()
|
|
86
|
+
this.streams.clear()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
onFrame(text, channel) {
|
|
90
|
+
if (this.closed) return
|
|
91
|
+
const frame = (() => {
|
|
92
|
+
try { return JSON.parse(text) } catch { return null }
|
|
93
|
+
})()
|
|
94
|
+
if (!frame || typeof frame !== 'object') return
|
|
95
|
+
if (frame.t === 'hb') return
|
|
96
|
+
if (frame.t !== 'cmd') return
|
|
97
|
+
const { id, from, cmd } = frame
|
|
98
|
+
if (typeof id !== 'string' || typeof from !== 'string' || typeof cmd !== 'string') return
|
|
99
|
+
const known = VISITOR_COMMANDS.has(cmd) || INTERNAL_COMMANDS.has(cmd)
|
|
100
|
+
if (!known) return this.reply(id, false, undefined, `unknown command: ${cmd}`, channel)
|
|
101
|
+
Promise.resolve()
|
|
102
|
+
.then(() => this.dispatch(cmd, frame.p, from, channel))
|
|
103
|
+
.then((data) => this.reply(id, true, data, undefined, channel))
|
|
104
|
+
.catch((error) => {
|
|
105
|
+
this.log('cmd-error', `${cmd}: ${error?.message ?? String(error)}`)
|
|
106
|
+
this.reply(id, false, undefined, error?.message ?? String(error), channel)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
reply(id, ok, data, error, channel) {
|
|
111
|
+
channel.send({ t: 'res', id, ok, ...(ok ? { data } : { error }) })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
controller() {
|
|
115
|
+
return this.services.sessionController?.()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
requireControl() {
|
|
119
|
+
if (!this.allowControl) throw new Error('远程控制已关闭(只读模式)')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async dispatch(cmd, p = {}, from, channel) {
|
|
123
|
+
const controller = this.controller()
|
|
124
|
+
if (!controller) throw new Error('sessionController 服务不可用')
|
|
125
|
+
switch (cmd) {
|
|
126
|
+
case 'visitor.hello': {
|
|
127
|
+
const previous = this.visitors.get(from)
|
|
128
|
+
if (previous) {
|
|
129
|
+
for (const sessionId of previous.sessions) this.unobserve(from, sessionId)
|
|
130
|
+
}
|
|
131
|
+
this.visitors.set(from, { id: from, connectedAt: Date.now(), sessions: new Set(), channel })
|
|
132
|
+
this.log('visitor', `访客接入 ${shortId(from)}`)
|
|
133
|
+
return {
|
|
134
|
+
host: {
|
|
135
|
+
name: this.host.name,
|
|
136
|
+
dsh: this.host.dsh,
|
|
137
|
+
code: this.host.code,
|
|
138
|
+
allowControl: this.allowControl,
|
|
139
|
+
},
|
|
140
|
+
sessions: await this.listSessions(),
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
case 'visitor.gone': {
|
|
144
|
+
const record = this.visitors.get(from)
|
|
145
|
+
if (record) {
|
|
146
|
+
for (const sessionId of record.sessions) this.unobserve(from, sessionId)
|
|
147
|
+
this.visitors.delete(from)
|
|
148
|
+
}
|
|
149
|
+
this.log('visitor', `访客断开 ${shortId(from)}`)
|
|
150
|
+
return { ok: true }
|
|
151
|
+
}
|
|
152
|
+
case 'host.info': {
|
|
153
|
+
return { name: this.host.name, dsh: this.host.dsh, allowControl: this.allowControl }
|
|
154
|
+
}
|
|
155
|
+
case 'sessions.list': {
|
|
156
|
+
return { sessions: await this.listSessions() }
|
|
157
|
+
}
|
|
158
|
+
case 'sessions.state': {
|
|
159
|
+
const state = await controller.state({ sessionId: p.sessionId }, readSignal())
|
|
160
|
+
return { state: projectState(state) }
|
|
161
|
+
}
|
|
162
|
+
case 'sessions.create': {
|
|
163
|
+
this.requireControl()
|
|
164
|
+
const created = await controller.create({})
|
|
165
|
+
this.log('cmd', `访客新建会话 ${shortId(created.sessionId)}`)
|
|
166
|
+
this.scheduleSessionsBroadcast()
|
|
167
|
+
return { sessionId: created.sessionId, agentPreset: created.agentPreset }
|
|
168
|
+
}
|
|
169
|
+
case 'sessions.send': {
|
|
170
|
+
this.requireControl()
|
|
171
|
+
const text = typeof p.text === 'string' ? p.text.trim() : ''
|
|
172
|
+
if (!text) throw new Error('消息内容为空')
|
|
173
|
+
if (text.length > MAX_TEXT) throw new Error('消息过长')
|
|
174
|
+
const sessionId = p.sessionId
|
|
175
|
+
await controller.prompt({
|
|
176
|
+
requestId: generateId(),
|
|
177
|
+
sessionId,
|
|
178
|
+
mode: 'queue',
|
|
179
|
+
content: [{ type: 'text', text }],
|
|
180
|
+
clientTimeZone: 'Asia/Shanghai',
|
|
181
|
+
}, readSignal())
|
|
182
|
+
this.log('cmd', `访客向会话 ${shortId(sessionId)} 发送指令 (${text.length} 字)`)
|
|
183
|
+
return { accepted: true }
|
|
184
|
+
}
|
|
185
|
+
case 'sessions.cancel': {
|
|
186
|
+
this.requireControl()
|
|
187
|
+
await controller.cancel({ sessionId: p.sessionId })
|
|
188
|
+
this.log('cmd', `访客中断会话 ${shortId(p.sessionId)}`)
|
|
189
|
+
return { accepted: true }
|
|
190
|
+
}
|
|
191
|
+
case 'sessions.observe': {
|
|
192
|
+
const sessionId = p.sessionId
|
|
193
|
+
const visitor = this.visitors.get(from)
|
|
194
|
+
if (!visitor) throw new Error('访客会话不存在(请重连)')
|
|
195
|
+
for (const previous of visitor.sessions) {
|
|
196
|
+
if (previous !== sessionId) this.unobserve(from, previous)
|
|
197
|
+
}
|
|
198
|
+
visitor.sessions.add(sessionId)
|
|
199
|
+
const stream = this.streamFor(sessionId)
|
|
200
|
+
const [state, history] = await Promise.all([
|
|
201
|
+
controller.state({ sessionId }, readSignal()),
|
|
202
|
+
controller.events({
|
|
203
|
+
address: { kind: 'session', sessionId },
|
|
204
|
+
maxMessages: HISTORY_MAX_MESSAGES,
|
|
205
|
+
turnWindow: { minMessages: 10, minTurns: 2 },
|
|
206
|
+
}, readSignal()),
|
|
207
|
+
])
|
|
208
|
+
this.pushTo(from, {
|
|
209
|
+
t: 'session-history',
|
|
210
|
+
sessionId,
|
|
211
|
+
records: history.records,
|
|
212
|
+
hasMore: history.hasMore,
|
|
213
|
+
state: projectState(state),
|
|
214
|
+
})
|
|
215
|
+
this.log('cmd', `访客查看会话 ${shortId(sessionId)}`)
|
|
216
|
+
return { cursor: state.cursor, state: projectState(state) }
|
|
217
|
+
}
|
|
218
|
+
case 'sessions.unobserve': {
|
|
219
|
+
this.unobserve(from, p.sessionId)
|
|
220
|
+
return { ok: true }
|
|
221
|
+
}
|
|
222
|
+
default:
|
|
223
|
+
throw new Error(`unknown command: ${cmd}`)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async listSessions() {
|
|
228
|
+
const controller = this.controller()
|
|
229
|
+
if (!controller) return []
|
|
230
|
+
const { items } = await controller.list({}, readSignal())
|
|
231
|
+
return items
|
|
232
|
+
.filter((item) => !item.blank && !item.origin)
|
|
233
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
234
|
+
.slice(0, 50)
|
|
235
|
+
.map((item) => projectSummary(item))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
streamFor(sessionId) {
|
|
239
|
+
let stream = this.streams.get(sessionId)
|
|
240
|
+
if (stream) {
|
|
241
|
+
stream.refs += 1
|
|
242
|
+
return stream
|
|
243
|
+
}
|
|
244
|
+
const controller = this.controller()
|
|
245
|
+
if (!controller) throw new Error('sessionController 服务不可用')
|
|
246
|
+
const ctl = new AbortController()
|
|
247
|
+
const cleanups = []
|
|
248
|
+
stream = { refs: 1, controller: ctl, cleanups }
|
|
249
|
+
this.streams.set(sessionId, stream)
|
|
250
|
+
// Durable event feed: 'ready' then one entry per committed event.
|
|
251
|
+
void this.pump(
|
|
252
|
+
ctl,
|
|
253
|
+
controller.observeEvent({ address: { kind: 'session', sessionId } }, ctl.signal),
|
|
254
|
+
(event) => this.pushSession(sessionId, { t: 'session-event', sessionId, event }),
|
|
255
|
+
)
|
|
256
|
+
// Passive state feed: running flag, queue, pending approvals.
|
|
257
|
+
void this.pump(
|
|
258
|
+
ctl,
|
|
259
|
+
controller.observeState({ sessionId }, ctl.signal),
|
|
260
|
+
(state) => this.pushSession(sessionId, { t: 'session-state', sessionId, state: projectState(state) }),
|
|
261
|
+
)
|
|
262
|
+
cleanups.push(() => this.streams.delete(sessionId))
|
|
263
|
+
return stream
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async pump(ctl, iterator, onFrame) {
|
|
267
|
+
try {
|
|
268
|
+
for await (const frame of iterator) {
|
|
269
|
+
if (ctl.signal.aborted) return
|
|
270
|
+
if (frame && frame.type !== 'ready') onFrame(frame)
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
if (!ctl.signal.aborted) this.log('stream', `stream error: ${error?.message ?? String(error)}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
unobserve(visitorId, sessionId) {
|
|
278
|
+
const visitor = this.visitors.get(visitorId)
|
|
279
|
+
visitor?.sessions.delete(sessionId)
|
|
280
|
+
const stream = this.streams.get(sessionId)
|
|
281
|
+
if (!stream) return
|
|
282
|
+
stream.refs -= 1
|
|
283
|
+
if (stream.refs <= 0) {
|
|
284
|
+
stream.controller.abort()
|
|
285
|
+
for (const cleanup of stream.cleanups) cleanup()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
pushSession(sessionId, frame) {
|
|
290
|
+
for (const visitor of this.visitors.values()) {
|
|
291
|
+
if (visitor.sessions.has(sessionId)) visitor.channel.send({ t: 'push', to: visitor.id, f: frame })
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
pushTo(visitorId, frame) {
|
|
296
|
+
const visitor = this.visitors.get(visitorId)
|
|
297
|
+
visitor?.channel.send({ t: 'push', to: visitorId, f: frame })
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Fan a refreshed session list to every visitor, throttled. */
|
|
301
|
+
scheduleSessionsBroadcast() {
|
|
302
|
+
if (this.broadcastTimer) return
|
|
303
|
+
this.broadcastTimer = setTimeout(async () => {
|
|
304
|
+
this.broadcastTimer = undefined
|
|
305
|
+
if (this.visitors.size === 0) return
|
|
306
|
+
try {
|
|
307
|
+
const sessions = await this.listSessions()
|
|
308
|
+
for (const visitor of this.visitors.values()) {
|
|
309
|
+
visitor.channel.send({ t: 'push', to: visitor.id, f: { t: 'sessions-updated', sessions } })
|
|
310
|
+
}
|
|
311
|
+
} catch {
|
|
312
|
+
// listing raced a disposal; the next trigger retries
|
|
313
|
+
}
|
|
314
|
+
}, 400)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Wire session lifecycle events, best-effort; degrades to action-triggered refresh. */
|
|
318
|
+
observeSessionEvents(on) {
|
|
319
|
+
const names = ['api-session/added', 'api-session/removed', 'api-session/status', 'api-session/activity']
|
|
320
|
+
const offs = []
|
|
321
|
+
for (const name of names) {
|
|
322
|
+
try {
|
|
323
|
+
offs.push(on(name, () => this.scheduleSessionsBroadcast()))
|
|
324
|
+
} catch {
|
|
325
|
+
// event bus not offered in this assembly
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return () => { for (const off of offs) off() }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
status() {
|
|
332
|
+
return {
|
|
333
|
+
protocol: PROTOCOL_VERSION,
|
|
334
|
+
startedAt: this.startedAt,
|
|
335
|
+
visitors: [...this.visitors.entries()].map(([id, visitor]) => ({
|
|
336
|
+
id,
|
|
337
|
+
connectedAt: visitor.connectedAt,
|
|
338
|
+
sessions: [...visitor.sessions],
|
|
339
|
+
})),
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function projectSummary(item) {
|
|
345
|
+
return {
|
|
346
|
+
id: item.sessionId,
|
|
347
|
+
title: typeof item.projections?.values?.title === 'string' ? item.projections.values.title : null,
|
|
348
|
+
updatedAt: item.updatedAt,
|
|
349
|
+
running: item.running === true,
|
|
350
|
+
agentAvailable: item.agentAvailable === true,
|
|
351
|
+
cwd: typeof item.cwd === 'string' ? item.cwd : undefined,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function projectState(state) {
|
|
356
|
+
return {
|
|
357
|
+
cursor: state?.cursor ?? 0,
|
|
358
|
+
active: state?.active === true,
|
|
359
|
+
running: state?.running === true,
|
|
360
|
+
queued: Array.isArray(state?.queue) ? state.queue.length : 0,
|
|
361
|
+
pendingInput: Array.isArray(state?.pendingInput) ? state.pendingInput : [],
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function shortId(id) {
|
|
366
|
+
return typeof id === 'string' && id.length > 10 ? `${id.slice(0, 8)}…` : String(id)
|
|
367
|
+
}
|