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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. 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
+ }