@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/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
+ }