dsh-mesh-node 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +8 -2
- package/package.json +43 -4
- package/src/cli.js +1246 -0
- package/src/core/acl.js +81 -0
- package/src/core/audit.js +75 -0
- package/src/core/autostart.js +119 -0
- package/src/core/cluster.js +341 -0
- package/src/core/config.js +622 -0
- package/src/core/desktop-proxy.js +85 -0
- package/src/core/ds-web.js +394 -0
- package/src/core/host-agent.js +263 -0
- package/src/core/jobs.js +259 -0
- package/src/core/pairing.js +141 -0
- package/src/core/peer-client.js +273 -0
- package/src/core/peer-doctor.js +294 -0
- package/src/core/peer-http.js +61 -0
- package/src/core/repair.js +93 -0
- package/src/core/restart-policy.js +21 -0
- package/src/core/result.js +29 -0
- package/src/core/sakura-api.js +103 -0
- package/src/core/session-profile.js +146 -0
- package/src/core/tls.js +160 -0
- package/src/core/tunnels.js +212 -0
- package/src/core/util.js +68 -0
- package/src/e2e-autostart.js +58 -0
- package/src/e2e-channel.js +384 -0
- package/src/e2e-cluster.js +417 -0
- package/src/e2e-host.js +411 -0
- package/src/e2e-onboard.js +232 -0
- package/src/e2e-pair.js +317 -0
- package/src/e2e-plugin.js +395 -0
- package/src/e2e-repair.js +297 -0
- package/src/index.js +20 -0
- package/src/peer-bridge.js +124 -0
- package/src/selftest.js +872 -0
- package/src/server.js +317 -0
- package/src/tools/alerts.js +80 -0
- package/src/tools/channel.js +253 -0
- package/src/tools/cluster.js +235 -0
- package/src/tools/config-patch.js +182 -0
- package/src/tools/control-apps.js +469 -0
- package/src/tools/control-path.js +160 -0
- package/src/tools/control.js +232 -0
- package/src/tools/core.js +79 -0
- package/src/tools/delegate.js +653 -0
- package/src/tools/fs.js +436 -0
- package/src/tools/git.js +266 -0
- package/src/tools/host.js +305 -0
- package/src/tools/index.js +122 -0
- package/src/tools/repair-fs.js +130 -0
- package/src/tools/repair.js +317 -0
- package/src/tools/session-history.js +60 -0
- package/src/tools/shell.js +66 -0
- package/src/tools/tasks.js +117 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import WebSocket from 'ws'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Client for the DSH web host's local Remote API (route A of the channel design).
|
|
6
|
+
*
|
|
7
|
+
* Two facts from the harness source that shape this file:
|
|
8
|
+
* - The only way in is the one-time launch URL printed by `dsh web`
|
|
9
|
+
* (`http://127.0.0.1:<port>/?token=<43 chars>`): GET it with `redirect: 'manual'`
|
|
10
|
+
* and you get `303 + Set-Cookie`. There is no static token on disk.
|
|
11
|
+
* (apps/web/tests/smoke-real.e2e.ts:50-66, apps/cli/tests/web-auth.e2e.ts:173-183)
|
|
12
|
+
* - Every unary call is `POST /api/<method>` with
|
|
13
|
+
* `{type:'client-request', rpcId, method, payload:{args}}` and answers
|
|
14
|
+
* `{result:{ok:true,value}}` — HTTP can be 200 while `result.ok` is false, so
|
|
15
|
+
* never trust the status code alone. (smoke-real.e2e.ts:91-109)
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export class DshError extends Error {
|
|
19
|
+
constructor(code, message, extra = {}) {
|
|
20
|
+
super(`${code}: ${message}`)
|
|
21
|
+
this.name = 'DshError'
|
|
22
|
+
this.code = code
|
|
23
|
+
this.extra = extra
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Strip a trailing slash and any path so we always talk to the origin. */
|
|
28
|
+
function originOf(url) {
|
|
29
|
+
return new URL(url).origin
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class DshWebClient {
|
|
33
|
+
constructor({ baseUrl, cookie, log = console } = {}) {
|
|
34
|
+
this.baseUrl = baseUrl ? originOf(baseUrl) : null
|
|
35
|
+
this.cookie = cookie || null
|
|
36
|
+
this.log = log
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get authenticated() {
|
|
40
|
+
return Boolean(this.cookie)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Exchange a launch URL for the session cookie. The launch token is single-use;
|
|
45
|
+
* the resulting cookie survives `dsh web` restarts (it is signed by the key that
|
|
46
|
+
* lives in the DSH home), so it is worth persisting.
|
|
47
|
+
*/
|
|
48
|
+
async login(launchUrl, { timeoutMs = 15000 } = {}) {
|
|
49
|
+
if (!launchUrl) throw new DshError('E_INVALID_ARG', 'launchUrl is required')
|
|
50
|
+
const started = Date.now()
|
|
51
|
+
const response = await fetch(launchUrl, { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) })
|
|
52
|
+
const setCookie = response.headers.get('set-cookie')
|
|
53
|
+
if (response.status !== 303 || !setCookie) {
|
|
54
|
+
throw new DshError('E_DSH_AUTH', `launch URL rejected (HTTP ${response.status}, set-cookie=${setCookie ? 'yes' : 'no'})`, {
|
|
55
|
+
status: response.status
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
this.cookie = setCookie.split(';', 1)[0]
|
|
59
|
+
this.baseUrl = originOf(launchUrl)
|
|
60
|
+
this.log?.debug?.(`[dsh-mesh] channel login ok in ${Date.now() - started}ms`)
|
|
61
|
+
return { baseUrl: this.baseUrl, cookie: this.cookie }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async rpc(endpoint, args = {}, { timeoutMs = 60000, rpcId } = {}) {
|
|
65
|
+
if (!this.baseUrl) throw new DshError('E_DSH_NO_TARGET', 'no baseUrl: log in first')
|
|
66
|
+
if (!this.cookie) throw new DshError('E_DSH_NO_COOKIE', 'no session cookie: log in first')
|
|
67
|
+
const body = {
|
|
68
|
+
type: 'client-request',
|
|
69
|
+
rpcId: rpcId || `mesh-${endpoint.replace(/\//g, '-')}-${randomUUID()}`,
|
|
70
|
+
method: endpoint,
|
|
71
|
+
payload: { args }
|
|
72
|
+
}
|
|
73
|
+
let response
|
|
74
|
+
try {
|
|
75
|
+
response = await fetch(`${this.baseUrl}/api/${endpoint}`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json', cookie: this.cookie },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
80
|
+
})
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.name === 'TimeoutError') throw new DshError('E_TIMEOUT', `${endpoint} timed out after ${timeoutMs}ms`)
|
|
83
|
+
throw new DshError('E_DSH_UNREACHABLE', `${endpoint} failed: ${error?.message || error}`)
|
|
84
|
+
}
|
|
85
|
+
const text = await response.text()
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new DshError('E_DSH_HTTP', `${endpoint} HTTP ${response.status}: ${text.slice(0, 400)}`, {
|
|
88
|
+
status: response.status
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
let parsed
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(text)
|
|
94
|
+
} catch {
|
|
95
|
+
throw new DshError('E_DSH_BAD_BODY', `${endpoint} returned non-JSON: ${text.slice(0, 200)}`)
|
|
96
|
+
}
|
|
97
|
+
const result = parsed?.result
|
|
98
|
+
if (!result || result.ok !== true) {
|
|
99
|
+
const error = result?.error || {}
|
|
100
|
+
throw new DshError(error.code || 'E_DSH_RPC', `${endpoint}: ${error.message || 'unknown RPC failure'}`, {
|
|
101
|
+
endpoint
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
return result.value
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `{ items: [{ sessionId, ... }] }` — see smoke-real.e2e.ts:759-771. */
|
|
108
|
+
async listSessions({ timeoutMs = 30000 } = {}) {
|
|
109
|
+
return this.rpc('session/list', { _request: {} }, { timeoutMs })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `{ sessionId }` — smoke-real.e2e.ts:456. */
|
|
113
|
+
async createSession({ request = {}, timeoutMs = 30000 } = {}) {
|
|
114
|
+
const value = await this.rpc('session/create', { request }, { timeoutMs })
|
|
115
|
+
const sessionId = value?.sessionId || value?.request?.sessionId
|
|
116
|
+
if (!sessionId) throw new DshError('E_DSH_BAD_BODY', 'session/create returned no sessionId')
|
|
117
|
+
return sessionId
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `{ accepted: true }` — smoke-real.e2e.ts:457-462. */
|
|
121
|
+
async prompt({ sessionId, text, mode = 'queue', timeoutMs = 60000 } = {}) {
|
|
122
|
+
if (!sessionId) throw new DshError('E_INVALID_ARG', 'sessionId is required')
|
|
123
|
+
if (typeof text !== 'string' || text.trim() === '') throw new DshError('E_INVALID_ARG', 'text is required')
|
|
124
|
+
const requestId = randomUUID()
|
|
125
|
+
const value = await this.rpc(
|
|
126
|
+
'session/prompt',
|
|
127
|
+
{ request: { requestId, sessionId, mode, content: [{ type: 'text', text }] } },
|
|
128
|
+
{ timeoutMs }
|
|
129
|
+
)
|
|
130
|
+
return { requestId, accepted: value?.accepted !== false, value }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read the latest history window over one short-lived follow stream.
|
|
135
|
+
*
|
|
136
|
+
* `session/page` requires a `throughSeq` cut that only a follow opening frame
|
|
137
|
+
* provides (`packages/api/session-controller/src/types.ts:471-476`), and the
|
|
138
|
+
* `throughSeq:-1` sentinel paginates to an empty window
|
|
139
|
+
* (`packages/api/session-controller/src/history.ts:392-426`). So: open, keep the
|
|
140
|
+
* snapshot, close.
|
|
141
|
+
*/
|
|
142
|
+
async page({ sessionId, maxMessages = 40, timeoutMs = 30000 } = {}) {
|
|
143
|
+
const follow = await this.openFollow({ sessionId, maxMessages, timeoutMs })
|
|
144
|
+
try {
|
|
145
|
+
return {
|
|
146
|
+
records: follow.snapshot.records,
|
|
147
|
+
hasMore: follow.snapshot.hasMore,
|
|
148
|
+
cursor: follow.snapshot.cursor
|
|
149
|
+
}
|
|
150
|
+
} finally {
|
|
151
|
+
follow.close()
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Open `session/follow` on the host's WebSocket mux
|
|
157
|
+
* (`/api/remote.mux`, `packages/api/gateway/src/stream-protocol.ts:7`) and resolve
|
|
158
|
+
* once the opening snapshot arrives. Live durable events after that are delivered
|
|
159
|
+
* to `follow.onEvent`.
|
|
160
|
+
*/
|
|
161
|
+
openFollow({ sessionId, maxMessages = 40, timeoutMs = 15000 } = {}) {
|
|
162
|
+
if (!this.baseUrl) throw new DshError('E_DSH_NO_TARGET', 'no baseUrl: log in first')
|
|
163
|
+
if (!this.cookie) throw new DshError('E_DSH_NO_COOKIE', 'no session cookie: log in first')
|
|
164
|
+
if (!sessionId) throw new DshError('E_INVALID_ARG', 'sessionId is required')
|
|
165
|
+
const streamId = `mesh-follow-${randomUUID()}`
|
|
166
|
+
const url = `${this.baseUrl.replace(/^http/u, 'ws')}/api/remote.mux`
|
|
167
|
+
const socket = new WebSocket(url, { headers: { cookie: this.cookie } })
|
|
168
|
+
const follow = {
|
|
169
|
+
socket,
|
|
170
|
+
streamId,
|
|
171
|
+
snapshot: null,
|
|
172
|
+
/** @type {((event: any) => void) | null} */
|
|
173
|
+
onEvent: null,
|
|
174
|
+
/** @type {((error: Error) => void) | null} */
|
|
175
|
+
onError: null,
|
|
176
|
+
closed: false,
|
|
177
|
+
close() {
|
|
178
|
+
if (follow.closed) return
|
|
179
|
+
follow.closed = true
|
|
180
|
+
try {
|
|
181
|
+
socket.close()
|
|
182
|
+
} catch {
|
|
183
|
+
/* already gone */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
let settled = false
|
|
189
|
+
const finish = (error) => {
|
|
190
|
+
if (settled) return
|
|
191
|
+
settled = true
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
if (error) {
|
|
194
|
+
follow.close()
|
|
195
|
+
reject(error)
|
|
196
|
+
} else {
|
|
197
|
+
resolve(follow)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const timer = setTimeout(
|
|
201
|
+
() => finish(new DshError('E_TIMEOUT', `session/follow did not open within ${timeoutMs}ms`)),
|
|
202
|
+
timeoutMs
|
|
203
|
+
)
|
|
204
|
+
socket.on('open', () => {
|
|
205
|
+
socket.send(
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
type: 'open',
|
|
208
|
+
streamId,
|
|
209
|
+
endpoint: 'session/follow',
|
|
210
|
+
payload: {
|
|
211
|
+
args: { request: { address: { kind: 'session', sessionId }, maxMessages } }
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
socket.on('message', (data) => {
|
|
217
|
+
let frame
|
|
218
|
+
try {
|
|
219
|
+
frame = JSON.parse(typeof data === 'string' ? data : data.toString('utf8'))
|
|
220
|
+
} catch {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (!frame || frame.streamId !== streamId) return
|
|
224
|
+
if (frame.type === 'error') {
|
|
225
|
+
const error = new DshError(
|
|
226
|
+
frame.error?.code || 'E_DSH_STREAM',
|
|
227
|
+
`session/follow failed: ${frame.error?.message || 'unknown error'}`
|
|
228
|
+
)
|
|
229
|
+
if (!settled) finish(error)
|
|
230
|
+
else follow.onError?.(error)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (frame.type === 'end') {
|
|
234
|
+
follow.close()
|
|
235
|
+
if (!settled) finish(new DshError('E_DSH_STREAM', 'session/follow ended before its opening snapshot'))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
if (frame.type !== 'item') return
|
|
239
|
+
const value = frame.value
|
|
240
|
+
if (!value) return
|
|
241
|
+
if (value.type === 'snapshot') {
|
|
242
|
+
follow.snapshot = {
|
|
243
|
+
cursor: value.cursor,
|
|
244
|
+
records: Array.isArray(value.records) ? value.records : [],
|
|
245
|
+
hasMore: value.hasMore === true
|
|
246
|
+
}
|
|
247
|
+
finish()
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
if (value.type === 'event' && value.event) follow.onEvent?.(value.event)
|
|
251
|
+
})
|
|
252
|
+
socket.on('error', (error) => {
|
|
253
|
+
const wrapped = new DshError('E_DSH_UNREACHABLE', `session/follow carrier failed: ${error?.message || error}`)
|
|
254
|
+
if (!settled) finish(wrapped)
|
|
255
|
+
else follow.onError?.(wrapped)
|
|
256
|
+
})
|
|
257
|
+
socket.on('close', () => {
|
|
258
|
+
follow.closed = true
|
|
259
|
+
if (!settled) finish(new DshError('E_DSH_STREAM', 'session/follow carrier closed before its opening snapshot'))
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async cancel({ sessionId, timeoutMs = 30000 } = {}) {
|
|
265
|
+
if (!sessionId) throw new DshError('E_INVALID_ARG', 'sessionId is required')
|
|
266
|
+
return this.rpc('session/cancel', { request: { sessionId } }, { timeoutMs })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Send a prompt and wait for the turn that carries it to end.
|
|
271
|
+
*
|
|
272
|
+
* The follow stream is opened *before* the prompt, so no event can be missed; the
|
|
273
|
+
* snapshot gives us the baseline turn counter. A `queue` prompt can land after a
|
|
274
|
+
* turn that was already running, so we only accept a `turn/end` that follows a
|
|
275
|
+
* `turn/start` we saw live (or, for `steer`, the very next `turn/end`).
|
|
276
|
+
*/
|
|
277
|
+
async sendAndWait({ sessionId, text, mode = 'queue', waitMs = 180000 } = {}) {
|
|
278
|
+
const started = Date.now()
|
|
279
|
+
const follow = await this.openFollow({ sessionId, maxMessages: 40 })
|
|
280
|
+
const baselineTurn = lastTurnOf(follow.snapshot.records)
|
|
281
|
+
const fresh = []
|
|
282
|
+
let sawTurnStart = false
|
|
283
|
+
const outcome = new Promise((resolve, reject) => {
|
|
284
|
+
const timer = setTimeout(() => resolve({ timedOut: true }), waitMs)
|
|
285
|
+
follow.onEvent = (event) => {
|
|
286
|
+
fresh.push({ type: 'event', event })
|
|
287
|
+
if (event?.type === 'turn/start') sawTurnStart = true
|
|
288
|
+
if (event?.type !== 'turn/end') return
|
|
289
|
+
const turn = event?.data?.turn
|
|
290
|
+
const accepted =
|
|
291
|
+
mode === 'steer' || sawTurnStart || (Number.isFinite(turn) && turn > baselineTurn)
|
|
292
|
+
if (!accepted) return
|
|
293
|
+
clearTimeout(timer)
|
|
294
|
+
resolve({ timedOut: false, turnEnd: normalizeTurnEnd(event) })
|
|
295
|
+
}
|
|
296
|
+
follow.onError = (error) => {
|
|
297
|
+
clearTimeout(timer)
|
|
298
|
+
reject(error)
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
try {
|
|
302
|
+
const { requestId } = await this.prompt({ sessionId, text, mode })
|
|
303
|
+
const result = await outcome
|
|
304
|
+
if (result.timedOut) {
|
|
305
|
+
throw new DshError('E_TIMEOUT', `turn did not finish within ${waitMs}ms`, {
|
|
306
|
+
sessionId,
|
|
307
|
+
requestId,
|
|
308
|
+
mode,
|
|
309
|
+
reply: assistantText(fresh).slice(-4000)
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
sessionId,
|
|
314
|
+
requestId,
|
|
315
|
+
reply: assistantText(fresh).slice(-4000),
|
|
316
|
+
turnEnd: result.turnEnd,
|
|
317
|
+
events: fresh.length,
|
|
318
|
+
ms: Date.now() - started
|
|
319
|
+
}
|
|
320
|
+
} finally {
|
|
321
|
+
follow.close()
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Event records are `{ type:'event', event:{ seq, type, data } }`. */
|
|
327
|
+
function seqOf(record) {
|
|
328
|
+
const seq = record?.event?.seq ?? record?.seq
|
|
329
|
+
return Number.isFinite(seq) ? seq : null
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function lastSeqOf(records) {
|
|
333
|
+
let last = -1
|
|
334
|
+
for (const record of records || []) {
|
|
335
|
+
const seq = seqOf(record)
|
|
336
|
+
if (seq !== null && seq > last) last = seq
|
|
337
|
+
}
|
|
338
|
+
return last
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Highest turn counter present in a history window (0 when there is no turn yet). */
|
|
342
|
+
function lastTurnOf(records) {
|
|
343
|
+
let last = 0
|
|
344
|
+
let seen = 0
|
|
345
|
+
for (const record of records || []) {
|
|
346
|
+
const event = record?.event
|
|
347
|
+
if (event?.type !== 'turn/start') continue
|
|
348
|
+
seen += 1
|
|
349
|
+
const turn = event?.data?.turn ?? event?.data?.turnId
|
|
350
|
+
if (Number.isFinite(turn)) last = Math.max(last, turn)
|
|
351
|
+
else last = Math.max(last, seen)
|
|
352
|
+
}
|
|
353
|
+
return last
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Concatenate every assistant text block in order. */
|
|
357
|
+
export function assistantText(records) {
|
|
358
|
+
const parts = []
|
|
359
|
+
for (const record of records || []) {
|
|
360
|
+
const event = record?.event
|
|
361
|
+
if (event?.type !== 'assistant/message') continue
|
|
362
|
+
const content = event?.data?.message?.content
|
|
363
|
+
if (!Array.isArray(content)) continue
|
|
364
|
+
for (const block of content) {
|
|
365
|
+
if (block?.type === 'text' && typeof block.text === 'string') parts.push(block.text)
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return parts.join('\n').trim()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function normalizeTurnEnd(event) {
|
|
372
|
+
const reason = event?.data?.reason || {}
|
|
373
|
+
return { kind: reason.kind || 'unknown', message: reason.message, seq: event?.seq }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Compact a history record for MCP output (never dump the whole event stream). */
|
|
377
|
+
export function summarizeRecord(record) {
|
|
378
|
+
const event = record?.event
|
|
379
|
+
if (!event) return { type: record?.type || 'unknown' }
|
|
380
|
+
const summary = { seq: event.seq, type: event.type }
|
|
381
|
+
if (event.type === 'user/message') {
|
|
382
|
+
const content = event?.data?.message?.content
|
|
383
|
+
summary.text = Array.isArray(content)
|
|
384
|
+
? content.filter((b) => b?.type === 'text').map((b) => b.text).join('\n').slice(0, 500)
|
|
385
|
+
: undefined
|
|
386
|
+
} else if (event.type === 'assistant/message') {
|
|
387
|
+
summary.text = assistantText([record]).slice(0, 4000)
|
|
388
|
+
} else if (event.type === 'turn/end') {
|
|
389
|
+
summary.reason = normalizeTurnEnd(event)
|
|
390
|
+
} else if (event.type === 'turn/start') {
|
|
391
|
+
summary.turn = event?.data?.turn ?? event?.data?.turnId
|
|
392
|
+
}
|
|
393
|
+
return summary
|
|
394
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for the `dsh-mesh-host` bridge plugin (route B).
|
|
3
|
+
*
|
|
4
|
+
* The plugin runs *inside* a DSH instance and listens on loopback only; this
|
|
5
|
+
* module is the mesh server's side of that link. Authentication is a shared
|
|
6
|
+
* random token file (`<home>/host-token`, 0600) written by the plugin on first
|
|
7
|
+
* load and readable by the mesh CLI.
|
|
8
|
+
*
|
|
9
|
+
* Every call is short-lived and has its own timeout: the bridge is a local
|
|
10
|
+
* daemon, so "slow" means "that DSH instance is busy", not "the network is bad".
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export class HostError extends Error {
|
|
17
|
+
constructor(code, message, extra = {}) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = 'HostError'
|
|
20
|
+
this.code = code
|
|
21
|
+
Object.assign(this, extra)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Configured host bridges (one per local DSH instance). */
|
|
26
|
+
export function hostInstances(config) {
|
|
27
|
+
const list = Array.isArray(config?.hosts) ? config.hosts : []
|
|
28
|
+
return list.filter((entry) => entry && typeof entry.id === 'string' && entry.id.length > 0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve one host instance by id; with no id, the first enabled instance wins.
|
|
33
|
+
* Unknown ids fail loud so a caller cannot silently hit the wrong DSH.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveHostInstance(config, id) {
|
|
36
|
+
const list = hostInstances(config)
|
|
37
|
+
if (list.length === 0) {
|
|
38
|
+
throw new HostError('E_HOST_NO_INSTANCE', 'no host bridge configured (config.hosts is empty)')
|
|
39
|
+
}
|
|
40
|
+
if (id === undefined || id === null || id === '') {
|
|
41
|
+
return list.find((entry) => entry.enabled !== false) || list[0]
|
|
42
|
+
}
|
|
43
|
+
const found = list.find((entry) => entry.id === id)
|
|
44
|
+
if (!found) {
|
|
45
|
+
throw new HostError(
|
|
46
|
+
'E_HOST_UNKNOWN',
|
|
47
|
+
`unknown host instance "${id}" — configured: ${list.map((entry) => entry.id).join(', ')}`,
|
|
48
|
+
{ instances: list.map((entry) => entry.id) }
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
return found
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve the shared loopback token path for one host instance. */
|
|
55
|
+
export function hostTokenPath(config, home, id) {
|
|
56
|
+
const entry = resolveHostInstance(config, id)
|
|
57
|
+
if (entry.tokenFile) return entry.tokenFile
|
|
58
|
+
// dsh0 keeps the historical name so existing installs keep working.
|
|
59
|
+
return entry.id === 'dsh0' ? join(home, 'host-token') : join(home, `host-token-${entry.id}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Read the shared token; throws E_HOST_NO_TOKEN with a fix hint when absent. */
|
|
63
|
+
export function readHostToken(config, home, id) {
|
|
64
|
+
const path = hostTokenPath(config, home, id)
|
|
65
|
+
try {
|
|
66
|
+
const token = readFileSync(path, 'utf8').trim()
|
|
67
|
+
if (token) return token
|
|
68
|
+
} catch {
|
|
69
|
+
/* fall through to the descriptive error */
|
|
70
|
+
}
|
|
71
|
+
throw new HostError(
|
|
72
|
+
'E_HOST_NO_TOKEN',
|
|
73
|
+
`host bridge token not found at ${path} — start a DSH instance with the dsh-mesh-host plugin installed (it creates the token on first load)`
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requestJson({ url, token, method = 'GET', path, body, timeoutMs = 120000 }) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const target = new URL(path, url)
|
|
80
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body), 'utf8')
|
|
81
|
+
const headers = { authorization: `Bearer ${token}`, accept: 'application/json' }
|
|
82
|
+
if (payload) {
|
|
83
|
+
headers['content-type'] = 'application/json'
|
|
84
|
+
headers['content-length'] = String(payload.length)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let settled = false
|
|
88
|
+
const finish = (fn, value) => {
|
|
89
|
+
if (settled) return
|
|
90
|
+
settled = true
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
fn(value)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const req = (target.protocol === 'https:' ? import('node:https') : import('node:http'))
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
req?.then?.(() => {})
|
|
98
|
+
try {
|
|
99
|
+
request?.destroy?.(new Error('timeout'))
|
|
100
|
+
} catch {
|
|
101
|
+
/* ignore */
|
|
102
|
+
}
|
|
103
|
+
finish(reject, new HostError('E_HOST_TIMEOUT', `host bridge did not answer within ${timeoutMs} ms`))
|
|
104
|
+
}, timeoutMs)
|
|
105
|
+
|
|
106
|
+
let request
|
|
107
|
+
Promise.resolve(req).then(({ default: mod }) => {
|
|
108
|
+
request = mod.request(
|
|
109
|
+
target,
|
|
110
|
+
{ method, headers },
|
|
111
|
+
res => {
|
|
112
|
+
const chunks = []
|
|
113
|
+
res.on('data', chunk => chunks.push(chunk))
|
|
114
|
+
res.on('end', () => {
|
|
115
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
116
|
+
let parsed
|
|
117
|
+
try {
|
|
118
|
+
parsed = text ? JSON.parse(text) : {}
|
|
119
|
+
} catch {
|
|
120
|
+
return finish(
|
|
121
|
+
reject,
|
|
122
|
+
new HostError('E_HOST_BAD_RESPONSE', `host bridge returned non-JSON (${res.statusCode})`)
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
finish(resolve, { status: res.statusCode, body: parsed })
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
request.on('error', error => {
|
|
130
|
+
finish(
|
|
131
|
+
reject,
|
|
132
|
+
new HostError(
|
|
133
|
+
'E_HOST_OFFLINE',
|
|
134
|
+
`host bridge unreachable at ${url} (${error?.code || error?.message || error}) — is a DSH instance running with the dsh-mesh-host plugin?`
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
if (payload) request.write(payload)
|
|
139
|
+
request.end()
|
|
140
|
+
})
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Build a host-bridge client bound to one config/home pair and one instance.
|
|
146
|
+
* @param {{config: object, home: string, log?: object, instance?: string}} options
|
|
147
|
+
*/
|
|
148
|
+
export function createHostClient({ config, home, log = console, instance }) {
|
|
149
|
+
const entry = resolveHostInstance(config, instance)
|
|
150
|
+
const base = {
|
|
151
|
+
id: entry.id,
|
|
152
|
+
label: entry.label || entry.id,
|
|
153
|
+
url: String(entry.url || 'http://127.0.0.1:9100'),
|
|
154
|
+
timeoutMs: Number(entry.timeoutMs) || 120000
|
|
155
|
+
}
|
|
156
|
+
const tokenOf = () => readHostToken(config, home, entry.id)
|
|
157
|
+
|
|
158
|
+
async function call(path, { method = 'GET', body, timeoutMs } = {}) {
|
|
159
|
+
const { status, body: payload } = await requestJson({
|
|
160
|
+
url: base.url,
|
|
161
|
+
token: tokenOf(),
|
|
162
|
+
method,
|
|
163
|
+
path,
|
|
164
|
+
body,
|
|
165
|
+
timeoutMs: timeoutMs ?? base.timeoutMs
|
|
166
|
+
})
|
|
167
|
+
if (status === 401) throw new HostError('E_HOST_AUTH', 'host bridge rejected the shared token')
|
|
168
|
+
if (payload && payload.ok === false) {
|
|
169
|
+
throw new HostError(payload.code || 'E_HOST_ERROR', payload.message || 'host bridge call failed')
|
|
170
|
+
}
|
|
171
|
+
if (status >= 400) {
|
|
172
|
+
throw new HostError('E_HOST_ERROR', `host bridge returned HTTP ${status}`)
|
|
173
|
+
}
|
|
174
|
+
return payload
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
id: base.id,
|
|
179
|
+
label: base.label,
|
|
180
|
+
url: base.url,
|
|
181
|
+
/** True when the bridge answers /host/ping. */
|
|
182
|
+
async ping() {
|
|
183
|
+
try {
|
|
184
|
+
const { body } = await requestJson({
|
|
185
|
+
url: base.url,
|
|
186
|
+
token: tokenOf(),
|
|
187
|
+
method: 'GET',
|
|
188
|
+
path: '/host/ping',
|
|
189
|
+
timeoutMs: 4000
|
|
190
|
+
})
|
|
191
|
+
return body?.ok === true
|
|
192
|
+
} catch (error) {
|
|
193
|
+
log?.debug?.(`[dsh-mesh] host ping failed: ${error?.code || error?.message}`)
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async self() {
|
|
198
|
+
return call('/host/self')
|
|
199
|
+
},
|
|
200
|
+
async tools() {
|
|
201
|
+
return call('/host/tools')
|
|
202
|
+
},
|
|
203
|
+
async agents() {
|
|
204
|
+
return call('/host/agents')
|
|
205
|
+
},
|
|
206
|
+
async sessions({ limit = 50, includeEnded = true, workspace = '' } = {}) {
|
|
207
|
+
const params = new URLSearchParams({ limit: String(limit), includeEnded: String(includeEnded) })
|
|
208
|
+
if (workspace) params.set('workspace', workspace)
|
|
209
|
+
return call(`/host/sessions?${params.toString()}`)
|
|
210
|
+
},
|
|
211
|
+
async callTool({ tool, args, sessionId, timeoutMs }) {
|
|
212
|
+
return call('/host/tools/call', {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: { tool, arguments: args ?? {}, sessionId, timeoutMs },
|
|
215
|
+
timeoutMs: timeoutMs ?? base.timeoutMs
|
|
216
|
+
})
|
|
217
|
+
},
|
|
218
|
+
async wake({ sessionId, text, target }) {
|
|
219
|
+
return call('/host/wake', {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
body: { sessionId, text, target },
|
|
222
|
+
timeoutMs: 30000
|
|
223
|
+
})
|
|
224
|
+
},
|
|
225
|
+
/**
|
|
226
|
+
* Create a fresh Session in that DSH instance (the web UI's own
|
|
227
|
+
* sessionController path). Requires dsh-mesh-host >= 0.2.0; an older
|
|
228
|
+
* plugin answers 404 unknown endpoint.
|
|
229
|
+
*/
|
|
230
|
+
async createSession({ cwd, sessionId, agentPreset, workspaceId } = {}) {
|
|
231
|
+
return call('/host/sessions/create', {
|
|
232
|
+
method: 'POST',
|
|
233
|
+
body: { cwd, sessionId, agentPreset, workspaceId },
|
|
234
|
+
timeoutMs: 60000
|
|
235
|
+
})
|
|
236
|
+
},
|
|
237
|
+
/** Deliver one user prompt into a Session without a web cookie. */
|
|
238
|
+
async prompt({ sessionId, text, mode, requestId, clientTimeZone } = {}) {
|
|
239
|
+
return call('/host/prompt', {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
body: { sessionId, text, mode, requestId, clientTimeZone },
|
|
242
|
+
timeoutMs: 60000
|
|
243
|
+
})
|
|
244
|
+
},
|
|
245
|
+
/**
|
|
246
|
+
* Tail one allow-listed log file on the peer machine (mesh home root or
|
|
247
|
+
* `<mesh home>/logs`, `*.log` only; secrets are redacted by the bridge).
|
|
248
|
+
* Requires dsh-mesh-host >= 0.4.0; older plugins answer 404 unknown endpoint.
|
|
249
|
+
*/
|
|
250
|
+
async logs({ name, lines = 100 } = {}) {
|
|
251
|
+
const params = new URLSearchParams({ name: String(name || ''), lines: String(lines) })
|
|
252
|
+
return call(`/host/logs?${params.toString()}`, { timeoutMs: 15000 })
|
|
253
|
+
},
|
|
254
|
+
/** Archive a Session (DSH's restorable "remove from list"; no hard delete exists). */
|
|
255
|
+
async archiveSession({ sessionId, stopActivity } = {}) {
|
|
256
|
+
return call('/host/sessions/archive', {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
body: { sessionId, stopActivity },
|
|
259
|
+
timeoutMs: 60000
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|