dsh-mesh-host 0.0.4 → 0.0.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.
Files changed (2) hide show
  1. package/lib/index.js +787 -730
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1,730 +1,787 @@
1
- /**
2
- * dsh-mesh-host — DSH host-side bridge for dsh-mesh.
3
- *
4
- * Runs *inside* the DSH host process (as a cordis/DSH plugin) and exposes the
5
- * in-process `agents` + `tools` services over a loopback-only HTTP endpoint.
6
- * The standalone dsh-mesh MCP server proxies to this bridge, which is how a
7
- * remote peer can:
8
- * - create a fresh Session (`/host/sessions/create` → sessionController.create)
9
- * - prompt a Session without a web cookie (`/host/prompt`)
10
- * - abort one Session's active turn (`/host/sessions/cancel` → sessionController.cancel)
11
- * - wake a live session (`/host/wake` → agent inbox + followup)
12
- * - list host sessions and their status (`/host/agents`, `/host/sessions`)
13
- * - list / call tools registered in *this* DSH instance (`/host/tools`,
14
- * `/host/tools/call`)
15
- *
16
- * Session creation goes through the profile's `sessionController` service, the
17
- * same path the web UI uses (agent preset composition, cwd validation, default
18
- * model). Profiles that do not mount it answer 501 E_HOST_NO_CONTROLLER.
19
- *
20
- * Design constraints (deliberate):
21
- * - plain ESM, **no imports from @deepseek-ai/*** so it loads from any
22
- * profile without a workspace link; message objects are built locally
23
- * (same shape as `createUserMessage`: `{ id, role:'user', content, source }`).
24
- * - binds 127.0.0.1 only; every request needs a Bearer token from
25
- * `<mesh home>/host-token` (auto-created 0600 on first load).
26
- * - the HTTP server is `unref()`ed so a short-lived headless run can exit.
27
- */
28
-
29
- import { createServer } from 'node:http'
30
- import { randomUUID, timingSafeEqual } from 'node:crypto'
31
- import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs'
32
- import { homedir, hostname, platform, release, arch } from 'node:os'
33
- import { join, basename } from 'node:path'
34
-
35
- /** Plugin identity used by the loader. */
36
- export const name = 'dsh-mesh-host'
37
-
38
- /** Services this plugin needs before `apply` runs. */
39
- export const inject = ['tools', 'agents']
40
-
41
- export const PLUGIN_VERSION = '0.5.0'
42
- const MAX_BODY_BYTES = 256 * 1024
43
- const DEFAULT_TIMEOUT_MS = 120_000
44
-
45
- function meshHome() {
46
- return process.env.DSH_MESH_HOME || join(homedir(), '.dsh-mesh')
47
- }
48
-
49
- /**
50
- * Which DSH instance is this? `.dsh0` → 0, `.dsh1` → 1, anything else → 0.
51
- * Lets one plugin package serve several instances (9100/host-token, 9101/host-token-dsh1)
52
- * without every launcher having to set DSH_MESH_HOST_PORT / DSH_MESH_HOST_TOKEN_FILE.
53
- * Explicit env vars still win.
54
- */
55
- function instanceIndex() {
56
- const match = /^\.dsh(\d+)$/u.exec(basename(process.env.DSH_HOME || ''))
57
- return match ? Number(match[1]) : 0
58
- }
59
-
60
- function hostTokenPath() {
61
- if (process.env.DSH_MESH_HOST_TOKEN_FILE) return process.env.DSH_MESH_HOST_TOKEN_FILE
62
- const index = instanceIndex()
63
- return join(meshHome(), index === 0 ? 'host-token' : `host-token-dsh${index}`)
64
- }
65
-
66
- /** Read the shared loopback token, creating it (0600) when missing. */
67
- function ensureToken() {
68
- const path = hostTokenPath()
69
- try {
70
- const existing = readFileSync(path, 'utf8').trim()
71
- if (existing) return existing
72
- } catch {
73
- /* missing → create below */
74
- }
75
- mkdirSync(meshHome(), { recursive: true })
76
- const token = randomUUID().replaceAll('-', '') + randomUUID().replaceAll('-', '')
77
- writeFileSync(path, `${token}\n`, { mode: 0o600 })
78
- return token
79
- }
80
-
81
- function safeEqual(a, b) {
82
- const left = Buffer.from(String(a))
83
- const right = Buffer.from(String(b))
84
- if (left.length !== right.length) return false
85
- return timingSafeEqual(left, right)
86
- }
87
-
88
- /** Recursively freeze a message the same way `createUserMessage` does. */
89
- function deepFreeze(value) {
90
- if (value && typeof value === 'object' && !Object.isFrozen(value)) {
91
- Object.freeze(value)
92
- for (const child of Object.values(value)) deepFreeze(child)
93
- }
94
- return value
95
- }
96
-
97
- /** Local stand-in for `createUserMessage` (avoids importing @deepseek-ai/*). */
98
- function createUserMessage({ content, source }) {
99
- return deepFreeze(structuredClone({ role: 'user', content, source, id: randomUUID() }))
100
- }
101
-
102
- function json(res, status, body) {
103
- const payload = JSON.stringify(body)
104
- res.writeHead(status, {
105
- 'content-type': 'application/json; charset=utf-8',
106
- 'content-length': Buffer.byteLength(payload),
107
- 'cache-control': 'no-store',
108
- })
109
- res.end(payload)
110
- }
111
-
112
- function readBody(req) {
113
- return new Promise((resolve, reject) => {
114
- const chunks = []
115
- let total = 0
116
- req.on('data', chunk => {
117
- total += chunk.length
118
- if (total > MAX_BODY_BYTES) {
119
- reject(Object.assign(new Error('body too large'), { code: 'E_TOO_LARGE' }))
120
- req.destroy()
121
- return
122
- }
123
- chunks.push(chunk)
124
- })
125
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
126
- req.on('error', reject)
127
- })
128
- }
129
-
130
- const LOG_MAX_BYTES = 512 * 1024
131
- const LOG_NAME_RE = /^[A-Za-z0-9._-]{1,80}\.log$/
132
-
133
- /** Strip secrets DSH writes into its own logs (web token URLs / API keys / Bearer). */
134
- function redactSecrets(text) {
135
- return String(text)
136
- .replace(/([?&]token=)[A-Za-z0-9._~-]{8,}/gi, '$1***')
137
- .replace(/(Bearer\s+)[A-Za-z0-9._~-]{8,}/gi, '$1***')
138
- .replace(/((?:api[_-]?key|deepseek_api_key|token)\s*[=:]\s*)[A-Za-z0-9._~-]{8,}/gi, '$1***')
139
- .replace(/(sk-)[A-Za-z0-9]{8,}/g, '$1***')
140
- }
141
-
142
- /**
143
- * Resolve one allow-listed log file: `<mesh home>/<name>.log` or
144
- * `<mesh home>/logs/<name>.log`. Plain file names only — no path separators —
145
- * so a remote peer can never read an arbitrary file through this route.
146
- */
147
- function resolveLogPath(name) {
148
- const safe = String(name || '').trim()
149
- if (!LOG_NAME_RE.test(safe)) {
150
- throw Object.assign(new Error('name must be a plain *.log file name (no path separators)'), { code: 'E_INVALID_ARG' })
151
- }
152
- for (const candidate of [join(meshHome(), safe), join(meshHome(), 'logs', safe)]) {
153
- try {
154
- if (statSync(candidate).isFile()) return candidate
155
- } catch {
156
- /* keep looking */
157
- }
158
- }
159
- throw Object.assign(new Error(`log not found: ${safe}`), { code: 'E_NOT_FOUND' })
160
- }
161
-
162
- /** Tail a file without loading all of it (logs can be many MBs). */
163
- function tailFile(path, lines) {
164
- const fd = openSync(path, 'r')
165
- try {
166
- const size = fstatSync(fd).size
167
- const start = Math.max(0, size - LOG_MAX_BYTES)
168
- const length = size - start
169
- const buffer = Buffer.alloc(length)
170
- if (length > 0) readSync(fd, buffer, 0, length, start)
171
- const all = buffer.toString('utf8').split(/\r?\n/)
172
- const body = all.slice(Math.max(0, all.length - lines))
173
- return { sizeBytes: size, truncated: start > 0, lines: body.map((line) => redactSecrets(line)) }
174
- } finally {
175
- closeSync(fd)
176
- }
177
- }
178
-
179
- function normalizeToolList(schemas) {
180
- return (schemas || []).map(schema => ({
181
- name: schema?.name ?? '',
182
- description: schema?.description ?? '',
183
- parameters: schema?.parameters ?? null,
184
- }))
185
- }
186
-
187
- function serializeResult(result) {
188
- if (!result || typeof result !== 'object') {
189
- return { ok: true, value: result ?? null, content: [] }
190
- }
191
- if (result.isError === true) {
192
- const error = result.error ?? {}
193
- return {
194
- ok: false,
195
- code: error.code || 'E_HOST_TOOL',
196
- message: error.message || String(error) || 'host tool failed',
197
- content: result.content ?? [],
198
- }
199
- }
200
- return { ok: true, value: result.value ?? null, content: result.content ?? [] }
201
- }
202
-
203
- /**
204
- * Install the loopback bridge.
205
- * @param ctx - DSH host context with `tools` and `agents` injected.
206
- */
207
- export function apply(ctx) {
208
- const token = ensureToken()
209
- const port = Number(process.env.DSH_MESH_HOST_PORT || 9100 + instanceIndex())
210
- const bind = process.env.DSH_MESH_HOST_BIND || '127.0.0.1'
211
-
212
- const authorized = req => {
213
- const header = req.headers.authorization || ''
214
- const match = /^Bearer\s+(.+)$/iu.exec(header)
215
- return Boolean(match) && safeEqual(match[1].trim(), token)
216
- }
217
-
218
- const listAgents = () => {
219
- let agents = []
220
- try {
221
- agents = ctx.agents?.list?.() ?? []
222
- } catch {
223
- agents = []
224
- }
225
- return agents.map(agent => ({
226
- id: String(agent?.id ?? ''),
227
- status: String(agent?.status ?? 'unknown'),
228
- title: typeof agent?.session?.title === 'string' ? agent.session.title : undefined,
229
- seq: typeof agent?.session?.seq === 'number' ? agent.session.seq : undefined,
230
- // Workspace/cwd is what makes delegation routing possible: a task that
231
- // belongs to a repo should land in a session already opened there.
232
- // DSH keeps the session cwd in the durable header (`session.header.cwd`).
233
- cwd: typeof agent?.session?.header?.cwd === 'string'
234
- ? agent.session.header.cwd
235
- : (typeof agent?.session?.cwd === 'string' ? agent.session.cwd : undefined),
236
- }))
237
- }
238
-
239
- const rawAgents = () => {
240
- try {
241
- return ctx.agents?.list?.() ?? []
242
- } catch {
243
- return []
244
- }
245
- }
246
-
247
- // Optional services: newer DSH composes sessionQuery (live + persisted
248
- // history); older forks only expose sessionPersistence or neither. Read them
249
- // through ctx.get so the bridge still loads on every profile.
250
- const optionalService = (name) => {
251
- try {
252
- return typeof ctx.get === 'function' ? ctx.get(name) : undefined
253
- } catch {
254
- return undefined
255
- }
256
- }
257
-
258
- // Session creation/prompting is the web UI's own service. Profiles older than
259
- // the session-controller split do not mount it; callers get an explicit 501
260
- // instead of a silent no-op.
261
- const sessionController = () => optionalService('sessionController')
262
-
263
- const requireSessionController = () => {
264
- const controller = sessionController()
265
- if (!controller || typeof controller.create !== 'function' || typeof controller.prompt !== 'function') {
266
- throw Object.assign(new Error('this DSH profile mounts no sessionController service (session create/prompt unavailable)'), { code: 'E_HOST_NO_CONTROLLER' })
267
- }
268
- return controller
269
- }
270
-
271
- // Older forks can expose a session service whose list() never settles (or
272
- // blocks on a backend that is not mounted). Never let /host/sessions hang:
273
- // fall back to the live agents after a short budget.
274
- const withTimeout = (promise, ms, fallback) => {
275
- let timer
276
- return Promise.race([
277
- Promise.resolve(promise).then((value) => value, () => fallback),
278
- new Promise((resolve) => {
279
- timer = setTimeout(() => resolve(fallback), ms)
280
- })
281
- ]).finally(() => clearTimeout(timer))
282
- }
283
-
284
- // Once an old fork's session service times out, remember it for this plugin
285
- // lifetime so later /host/sessions calls answer from live agents immediately.
286
- let sessionListingBroken = false
287
-
288
- const listSessions = async ({ limit = 50, includeEnded = true, workspace = '' } = {}) => {
289
- const agents = listAgents()
290
- const liveById = new Map(agents.map((agent) => [agent.id, agent]))
291
- const titles = new Map()
292
- let source = 'agents'
293
- let records = []
294
- const query = optionalService('sessionQuery')
295
- const agentRecords = () => agents.map((agent) => ({
296
- header: { id: agent.id, createdAt: 0, cwd: agent.cwd },
297
- live: true,
298
- persisted: false
299
- }))
300
- if (sessionListingBroken) {
301
- source = 'agentsFallback'
302
- records = agentRecords()
303
- } else if (query && typeof query.listSessions === 'function') {
304
- source = 'sessionQuery'
305
- const listed = await withTimeout(query.listSessions(), 2000, null)
306
- if (!listed) {
307
- sessionListingBroken = true
308
- source = 'agentsFallback'
309
- records = agentRecords()
310
- } else {
311
- records = (listed || []).map((record) => ({
312
- header: record.header,
313
- live: record.live === true,
314
- persisted: record.persisted === true
315
- }))
316
- const ids = records.map((record) => record.header?.id).filter(Boolean)
317
- if (typeof query.readTitleSnapshots === 'function' && ids.length > 0) {
318
- try {
319
- const observed = await withTimeout(query.readTitleSnapshots(ids), 8000, null)
320
- for (const item of observed || []) {
321
- if (item.status !== 'fulfilled') continue
322
- const title = item.value?.title?.title || item.value?.title?.text || ''
323
- if (title) titles.set(item.sessionId, { title, updatedAt: item.value?.title?.updatedAt || 0 })
324
- }
325
- } catch {
326
- /* title is best effort; listing still works without it */
327
- }
328
- }
329
- }
330
- } else {
331
- const persistence = optionalService('sessionPersistence')
332
- if (persistence && typeof persistence.list === 'function') {
333
- source = 'sessionPersistence'
334
- const snapshots = await withTimeout(persistence.list(), 2000, null)
335
- if (!snapshots) {
336
- sessionListingBroken = true
337
- source = 'agentsFallback'
338
- records = agentRecords()
339
- } else {
340
- records = (snapshots || []).map((snapshot) => ({
341
- header: snapshot.header,
342
- live: liveById.has(snapshot.header?.id),
343
- persisted: true
344
- }))
345
- }
346
- } else {
347
- records = agentRecords()
348
- }
349
- }
350
-
351
- const norm = (value) => String(value || '').toLowerCase().replace(/\\/g, '/')
352
- const sessions = records
353
- .map((record) => {
354
- const id = String(record.header?.id || '')
355
- const agent = liveById.get(id)
356
- const running = agent?.status === 'running'
357
- const agentAvailable = agent !== undefined
358
- const titleInfo = titles.get(id)
359
- return {
360
- sessionId: id,
361
- title: agent?.title || titleInfo?.title || '',
362
- cwd: record.header?.cwd || agent?.cwd || '',
363
- state: running ? 'working' : agentAvailable ? 'idle' : 'ended',
364
- running,
365
- agentAvailable,
366
- live: record.live === true || agentAvailable,
367
- persisted: record.persisted === true,
368
- createdAt: record.header?.createdAt || 0,
369
- updatedAt: titleInfo?.updatedAt || record.header?.createdAt || 0
370
- }
371
- })
372
- .filter((session) => includeEnded || session.state !== 'ended')
373
- .filter((session) => !workspace || norm(session.cwd).includes(norm(workspace)))
374
- .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
375
- .slice(0, limit)
376
- return { source, includeEnded, count: sessions.length, sessions }
377
- }
378
-
379
- /**
380
- * Older DSH keeps tools in the root scope (`schemas()` sees them); newer DSH
381
- * registers tools per agent scope, so the root view is empty while
382
- * `schemas(agent)` returns that agent's tool set (probed on DSH next: 36 tools
383
- * vs 0 at root). Merge the per-agent views in that case.
384
- */
385
- const listSchemas = () => {
386
- const root = normalizeToolList(ctx.tools.schemas())
387
- if (root.length > 0) return root
388
- const merged = new Map()
389
- for (const agent of rawAgents()) {
390
- try {
391
- for (const tool of normalizeToolList(ctx.tools.schemas(agent))) merged.set(tool.name, tool)
392
- } catch {
393
- /* scope not usable */
394
- }
395
- }
396
- return [...merged.values()]
397
- }
398
-
399
- const findTool = (name, agent) => {
400
- try {
401
- const scoped = agent ? ctx.tools.get(name, agent) : undefined
402
- if (typeof scoped !== 'undefined') return scoped
403
- } catch {
404
- /* fall through to the unscoped registry */
405
- }
406
- return ctx.tools.get(name)
407
- }
408
-
409
- const server = createServer(async (req, res) => {
410
- try {
411
- if (req.method === 'GET' && req.url === '/host/ping') {
412
- return json(res, 200, { ok: true, plugin: PLUGIN_VERSION })
413
- }
414
- if (!authorized(req)) {
415
- return json(res, 401, { ok: false, code: 'E_AUTH', message: 'missing or invalid host token' })
416
- }
417
- const url = new URL(req.url || '/', 'http://127.0.0.1')
418
-
419
- if (req.method === 'GET' && url.pathname === '/host/self') {
420
- return json(res, 200, {
421
- ok: true,
422
- plugin: PLUGIN_VERSION,
423
- node: { hostname: hostname(), platform: platform(), release: release(), arch: arch(), pid: process.pid },
424
- tools: listSchemas().map(tool => tool.name),
425
- agents: listAgents(),
426
- now: new Date().toISOString(),
427
- })
428
- }
429
-
430
- if (req.method === 'GET' && url.pathname === '/host/logs') {
431
- const name = url.searchParams.get('name') || ''
432
- const lines = Math.min(Math.max(Number(url.searchParams.get('lines') || 100), 1), 500)
433
- let path
434
- try {
435
- path = resolveLogPath(name)
436
- } catch (error) {
437
- return json(res, 200, { ok: false, code: error.code || 'E_LOG', message: error.message })
438
- }
439
- try {
440
- const tail = tailFile(path, lines)
441
- return json(res, 200, { ok: true, name: basename(path), path, lines, ...tail, plugin: PLUGIN_VERSION })
442
- } catch (error) {
443
- return json(res, 200, { ok: false, code: 'E_LOG_READ', message: error?.message || String(error) })
444
- }
445
- }
446
-
447
- if (req.method === 'GET' && url.pathname === '/host/tools') {
448
- return json(res, 200, { ok: true, tools: listSchemas() })
449
- }
450
-
451
- if (req.method === 'GET' && url.pathname === '/host/agents') {
452
- return json(res, 200, { ok: true, agents: listAgents() })
453
- }
454
-
455
- if (req.method === 'GET' && url.pathname === '/host/sessions') {
456
- const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 50), 1), 200)
457
- const includeEnded = url.searchParams.get('includeEnded') !== 'false'
458
- const workspace = url.searchParams.get('workspace') || ''
459
- const listed = await listSessions({ limit, includeEnded, workspace })
460
- return json(res, 200, { ok: true, ...listed })
461
- }
462
-
463
- if (req.method === 'POST' && url.pathname === '/host/sessions/create') {
464
- let payload
465
- try {
466
- payload = JSON.parse((await readBody(req)) || '{}')
467
- } catch {
468
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
469
- }
470
- const optionalText = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
471
- const request = {
472
- ...(optionalText(payload.cwd) === undefined ? {} : { cwd: optionalText(payload.cwd) }),
473
- ...(optionalText(payload.sessionId) === undefined ? {} : { sessionId: optionalText(payload.sessionId) }),
474
- ...(optionalText(payload.agentPreset) === undefined ? {} : { agentPreset: optionalText(payload.agentPreset) }),
475
- ...(optionalText(payload.workspaceId) === undefined ? {} : { workspaceId: optionalText(payload.workspaceId) })
476
- }
477
- let controller
478
- try {
479
- controller = requireSessionController()
480
- } catch (error) {
481
- return json(res, 501, { ok: false, code: error.code || 'E_HOST_NO_CONTROLLER', message: error.message })
482
- }
483
- try {
484
- const created = await controller.create(request)
485
- return json(res, 200, {
486
- ok: true,
487
- sessionId: String(created?.sessionId || ''),
488
- ...(created?.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }),
489
- plugin: PLUGIN_VERSION
490
- })
491
- } catch (error) {
492
- return json(res, 200, {
493
- ok: false,
494
- code: error?.code || 'E_HOST_SESSION_CREATE',
495
- message: error?.message || String(error)
496
- })
497
- }
498
- }
499
-
500
- if (req.method === 'POST' && url.pathname === '/host/prompt') {
501
- let payload
502
- try {
503
- payload = JSON.parse((await readBody(req)) || '{}')
504
- } catch {
505
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
506
- }
507
- const sessionId = String(payload.sessionId || '').trim()
508
- const text = typeof payload.text === 'string' ? payload.text : ''
509
- const mode = payload.mode === 'steer' ? 'steer' : 'queue'
510
- if (!sessionId || text.trim() === '') {
511
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId and non-empty text are required' })
512
- }
513
- const requestId = typeof payload.requestId === 'string' && payload.requestId.trim()
514
- ? payload.requestId.trim()
515
- : randomUUID()
516
- let controller
517
- try {
518
- controller = requireSessionController()
519
- } catch (error) {
520
- return json(res, 501, { ok: false, code: error.code || 'E_HOST_NO_CONTROLLER', message: error.message })
521
- }
522
- try {
523
- // sessionController.prompt(request, signal): DSH dereferences the signal
524
- // (throwIfAborted) before dispatch, so it must be a real AbortSignal.
525
- const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || DEFAULT_TIMEOUT_MS, 1000), 600_000)
526
- const accepted = await controller.prompt({
527
- requestId,
528
- sessionId,
529
- mode,
530
- content: [{ type: 'text', text }],
531
- ...(typeof payload.clientTimeZone === 'string' && payload.clientTimeZone.trim()
532
- ? { clientTimeZone: payload.clientTimeZone.trim() }
533
- : {})
534
- }, AbortSignal.timeout(timeoutMs))
535
- return json(res, 200, { ok: true, sessionId, requestId, mode, accepted: accepted?.accepted === true, plugin: PLUGIN_VERSION })
536
- } catch (error) {
537
- return json(res, 200, {
538
- ok: false,
539
- code: error?.code || 'E_HOST_PROMPT',
540
- message: error?.message || String(error)
541
- })
542
- }
543
- }
544
-
545
- if (req.method === 'POST' && url.pathname === '/host/sessions/cancel') {
546
- let payload
547
- try {
548
- payload = JSON.parse((await readBody(req)) || '{}')
549
- } catch {
550
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
551
- }
552
- const sessionId = String(payload.sessionId || '').trim()
553
- if (!sessionId) {
554
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId is required' })
555
- }
556
- // The web UI's own stop button: sessionController.cancel aborts the active
557
- // turn and keeps pending inbox work. Profiles that mount no such controller
558
- // answer 501, so an old bridge (404 "unknown endpoint") stays distinguishable.
559
- const controller = sessionController()
560
- if (!controller || typeof controller.cancel !== 'function') {
561
- return json(res, 501, {
562
- ok: false,
563
- code: 'E_HOST_NO_CONTROLLER',
564
- message: 'this DSH profile mounts no sessionController service with cancel (session cancel unavailable)'
565
- })
566
- }
567
- let agent
568
- try {
569
- agent = ctx.agents.get(sessionId)
570
- } catch {
571
- agent = undefined
572
- }
573
- // Agent.cancel() is a no-op on an idle agent: report whether a turn was really aborted.
574
- const wasRunning = agent?.status === 'running'
575
- try {
576
- const value = await controller.cancel({ sessionId })
577
- return json(res, 200, {
578
- ok: true,
579
- sessionId,
580
- cancelled: true,
581
- wasRunning,
582
- accepted: value?.accepted !== false,
583
- plugin: PLUGIN_VERSION
584
- })
585
- } catch (error) {
586
- const message = error?.message || String(error)
587
- const code = error?.code || ''
588
- const missing = code === 'session/not-found' || /not found \(not attached\)/u.test(message)
589
- if (missing) {
590
- return json(res, 404, { ok: false, code: 'E_HOST_CANCEL', message })
591
- }
592
- return json(res, 200, { ok: false, code: code || 'E_HOST_CANCEL', message })
593
- }
594
- }
595
-
596
- if (req.method === 'POST' && url.pathname === '/host/sessions/archive') {
597
- let payload
598
- try {
599
- payload = JSON.parse((await readBody(req)) || '{}')
600
- } catch {
601
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
602
- }
603
- const sessionId = String(payload.sessionId || '').trim()
604
- if (!sessionId) {
605
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId is required' })
606
- }
607
- // Archiving is DSH's own "remove from the list" action: the conversation
608
- // stays on disk and stays restorable. Deletion has no public service, so
609
- // this is the cleanup path for sessions created by delegations.
610
- const registry = optionalService('workspaceRegistry')
611
- if (!registry || typeof registry.archiveSession !== 'function') {
612
- return json(res, 501, { ok: false, code: 'E_HOST_NO_WORKSPACE', message: 'this DSH profile mounts no workspaceRegistry service (session archive unavailable)' })
613
- }
614
- try {
615
- await registry.archiveSession(sessionId, payload.stopActivity === true ? { stopActivity: true } : {})
616
- return json(res, 200, { ok: true, sessionId, archived: true, plugin: PLUGIN_VERSION })
617
- } catch (error) {
618
- return json(res, 200, {
619
- ok: false,
620
- code: error?.code || 'E_HOST_ARCHIVE',
621
- message: error?.message || String(error)
622
- })
623
- }
624
- }
625
-
626
- if (req.method === 'POST' && url.pathname === '/host/tools/call') {
627
- let payload
628
- try {
629
- payload = JSON.parse((await readBody(req)) || '{}')
630
- } catch {
631
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
632
- }
633
- const toolName = String(payload.tool || payload.name || '').trim()
634
- if (!toolName) {
635
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'tool is required' })
636
- }
637
- if (typeof findTool(toolName) === 'undefined' && rawAgents().every(agent => typeof findTool(toolName, agent) === 'undefined')) {
638
- return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: `no such tool in this host: ${toolName}` })
639
- }
640
- const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || DEFAULT_TIMEOUT_MS, 1000), 600_000)
641
- // Newer DSH registers tools per agent scope: when the caller did not pick a
642
- // session, execute in the first available agent's scope (usually the same
643
- // preset as every other session of this instance).
644
- const agent = payload.sessionId ? ctx.agents.get(String(payload.sessionId)) : rawAgents()[0]
645
- if (payload.sessionId && !agent) {
646
- return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: `no such session: ${payload.sessionId}` })
647
- }
648
- let result
649
- try {
650
- result = await ctx.tools.execute({
651
- callId: randomUUID(),
652
- name: toolName,
653
- arguments: payload.arguments ?? {},
654
- agent,
655
- signal: AbortSignal.timeout(timeoutMs),
656
- })
657
- } catch (error) {
658
- return json(res, 200, {
659
- ok: false,
660
- code: error?.code || 'E_HOST_TOOL',
661
- message: error?.message || String(error),
662
- })
663
- }
664
- return json(res, 200, serializeResult(result))
665
- }
666
-
667
- if (req.method === 'POST' && url.pathname === '/host/wake') {
668
- let payload
669
- try {
670
- payload = JSON.parse((await readBody(req)) || '{}')
671
- } catch {
672
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
673
- }
674
- const sessionId = String(payload.sessionId || '').trim()
675
- const text = typeof payload.text === 'string' ? payload.text.trim() : ''
676
- const target = payload.target === 'next-step' ? 'next-step' : 'next-turn'
677
- if (!sessionId || !text) {
678
- return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId and text are required' })
679
- }
680
- const agent = ctx.agents.get(sessionId)
681
- if (!agent) {
682
- return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: `no such session: ${sessionId}` })
683
- }
684
- const message = createUserMessage({
685
- content: [{ type: 'text', text }],
686
- source: { kind: 'user' },
687
- })
688
- const wokeUp = agent.status === 'idle'
689
- try {
690
- if (typeof agent.send === 'function') agent.send(message, target, true)
691
- else agent.followup(message)
692
- } catch (error) {
693
- return json(res, 200, {
694
- ok: false,
695
- code: error?.code || 'E_HOST_WAKE',
696
- message: error?.message || String(error),
697
- })
698
- }
699
- return json(res, 200, {
700
- ok: true,
701
- sessionId,
702
- target,
703
- messageId: message.id,
704
- statusBefore: agent.status,
705
- wokeUp,
706
- delivered: true,
707
- })
708
- }
709
-
710
- return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: 'unknown endpoint' })
711
- } catch (error) {
712
- return json(res, 500, { ok: false, code: 'E_INTERNAL', message: error?.message || String(error) })
713
- }
714
- })
715
-
716
- server.on('error', error => {
717
- console.error(`[dsh-mesh-host] listen ${bind}:${port} failed: ${error?.code || error?.message || error}`)
718
- })
719
- server.unref()
720
- server.listen(port, bind, () => {
721
- console.error(`[dsh-mesh-host] listening on http://${bind}:${port} (token: ${hostTokenPath()})`)
722
- })
723
-
724
- // Close the loopback listener when the plugin is disposed.
725
- try {
726
- ctx.effect?.(() => () => server.close())
727
- } catch {
728
- /* older hosts: leaving the server unref()ed is enough */
729
- }
730
- }
1
+ /**
2
+ * dsh-mesh-host — DSH host-side bridge for dsh-mesh.
3
+ *
4
+ * Runs *inside* the DSH host process (as a cordis/DSH plugin) and exposes the
5
+ * in-process `agents` + `tools` services over a loopback-only HTTP endpoint.
6
+ * The standalone dsh-mesh MCP server proxies to this bridge, which is how a
7
+ * remote peer can:
8
+ * - create a fresh Session (`/host/sessions/create` → sessionController.create)
9
+ * - prompt a Session without a web cookie (`/host/prompt`)
10
+ * - abort one Session's active turn (`/host/sessions/cancel` → sessionController.cancel)
11
+ * - wake a live session (`/host/wake` → agent inbox + followup)
12
+ * - list host sessions and their status (`/host/agents`, `/host/sessions`)
13
+ * - list / call tools registered in *this* DSH instance (`/host/tools`,
14
+ * `/host/tools/call`)
15
+ *
16
+ * Session creation goes through the profile's `sessionController` service, the
17
+ * same path the web UI uses (agent preset composition, cwd validation, default
18
+ * model). Profiles that do not mount it answer 501 E_HOST_NO_CONTROLLER.
19
+ *
20
+ * Design constraints (deliberate):
21
+ * - plain ESM, **no imports from @deepseek-ai/*** so it loads from any
22
+ * profile without a workspace link; message objects are built locally
23
+ * (same shape as `createUserMessage`: `{ id, role:'user', content, source }`).
24
+ * - binds 127.0.0.1 only; every request needs a Bearer token from
25
+ * `<mesh home>/host-token` (auto-created 0600 on first load).
26
+ * - the HTTP server is `unref()`ed so a short-lived headless run can exit.
27
+ */
28
+
29
+ import { createServer } from 'node:http'
30
+ import { randomUUID, timingSafeEqual } from 'node:crypto'
31
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs'
32
+ import { homedir, hostname, platform, release, arch } from 'node:os'
33
+ import { join, basename } from 'node:path'
34
+
35
+ /** Plugin identity used by the loader. */
36
+ export const name = 'dsh-mesh-host'
37
+
38
+ /** Services this plugin needs before `apply` runs. */
39
+ export const inject = ['tools', 'agents']
40
+
41
+ export const PLUGIN_VERSION = '0.6.0'
42
+ const MAX_BODY_BYTES = 256 * 1024
43
+ const DEFAULT_TIMEOUT_MS = 120_000
44
+
45
+ function meshHome() {
46
+ return process.env.DSH_MESH_HOME || join(homedir(), '.dsh-mesh')
47
+ }
48
+
49
+ /**
50
+ * Which DSH instance is this? `.dsh0` → 0, `.dsh1` → 1, anything else → 0.
51
+ * Lets one plugin package serve several instances (9100/host-token, 9101/host-token-dsh1)
52
+ * without every launcher having to set DSH_MESH_HOST_PORT / DSH_MESH_HOST_TOKEN_FILE.
53
+ * Explicit env vars still win.
54
+ */
55
+ function instanceIndex() {
56
+ const match = /^\.dsh(\d+)$/u.exec(basename(process.env.DSH_HOME || ''))
57
+ return match ? Number(match[1]) : 0
58
+ }
59
+
60
+ function hostTokenPath() {
61
+ if (process.env.DSH_MESH_HOST_TOKEN_FILE) return process.env.DSH_MESH_HOST_TOKEN_FILE
62
+ const index = instanceIndex()
63
+ return join(meshHome(), index === 0 ? 'host-token' : `host-token-dsh${index}`)
64
+ }
65
+
66
+ /** Read the shared loopback token, creating it (0600) when missing. */
67
+ function ensureToken() {
68
+ const path = hostTokenPath()
69
+ try {
70
+ const existing = readFileSync(path, 'utf8').trim()
71
+ if (existing) return existing
72
+ } catch {
73
+ /* missing → create below */
74
+ }
75
+ mkdirSync(meshHome(), { recursive: true })
76
+ const token = randomUUID().replaceAll('-', '') + randomUUID().replaceAll('-', '')
77
+ writeFileSync(path, `${token}\n`, { mode: 0o600 })
78
+ return token
79
+ }
80
+
81
+ function safeEqual(a, b) {
82
+ const left = Buffer.from(String(a))
83
+ const right = Buffer.from(String(b))
84
+ if (left.length !== right.length) return false
85
+ return timingSafeEqual(left, right)
86
+ }
87
+
88
+ /** Recursively freeze a message the same way `createUserMessage` does. */
89
+ function deepFreeze(value) {
90
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
91
+ Object.freeze(value)
92
+ for (const child of Object.values(value)) deepFreeze(child)
93
+ }
94
+ return value
95
+ }
96
+
97
+ /** Local stand-in for `createUserMessage` (avoids importing @deepseek-ai/*). */
98
+ function createUserMessage({ content, source }) {
99
+ return deepFreeze(structuredClone({ role: 'user', content, source, id: randomUUID() }))
100
+ }
101
+
102
+ function json(res, status, body) {
103
+ const payload = JSON.stringify(body)
104
+ res.writeHead(status, {
105
+ 'content-type': 'application/json; charset=utf-8',
106
+ 'content-length': Buffer.byteLength(payload),
107
+ 'cache-control': 'no-store',
108
+ })
109
+ res.end(payload)
110
+ }
111
+
112
+ function readBody(req) {
113
+ return new Promise((resolve, reject) => {
114
+ const chunks = []
115
+ let total = 0
116
+ req.on('data', chunk => {
117
+ total += chunk.length
118
+ if (total > MAX_BODY_BYTES) {
119
+ reject(Object.assign(new Error('body too large'), { code: 'E_TOO_LARGE' }))
120
+ req.destroy()
121
+ return
122
+ }
123
+ chunks.push(chunk)
124
+ })
125
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
126
+ req.on('error', reject)
127
+ })
128
+ }
129
+
130
+ const LOG_MAX_BYTES = 512 * 1024
131
+ const LOG_NAME_RE = /^[A-Za-z0-9._-]{1,80}\.log$/
132
+
133
+ /** Strip secrets DSH writes into its own logs (web token URLs / API keys / Bearer). */
134
+ function redactSecrets(text) {
135
+ return String(text)
136
+ .replace(/([?&]token=)[A-Za-z0-9._~-]{8,}/gi, '$1***')
137
+ .replace(/(Bearer\s+)[A-Za-z0-9._~-]{8,}/gi, '$1***')
138
+ .replace(/((?:api[_-]?key|deepseek_api_key|token)\s*[=:]\s*)[A-Za-z0-9._~-]{8,}/gi, '$1***')
139
+ .replace(/(sk-)[A-Za-z0-9]{8,}/g, '$1***')
140
+ }
141
+
142
+ /**
143
+ * Resolve one allow-listed log file: `<mesh home>/<name>.log` or
144
+ * `<mesh home>/logs/<name>.log`. Plain file names only — no path separators —
145
+ * so a remote peer can never read an arbitrary file through this route.
146
+ */
147
+ function resolveLogPath(name) {
148
+ const safe = String(name || '').trim()
149
+ if (!LOG_NAME_RE.test(safe)) {
150
+ throw Object.assign(new Error('name must be a plain *.log file name (no path separators)'), { code: 'E_INVALID_ARG' })
151
+ }
152
+ for (const candidate of [join(meshHome(), safe), join(meshHome(), 'logs', safe)]) {
153
+ try {
154
+ if (statSync(candidate).isFile()) return candidate
155
+ } catch {
156
+ /* keep looking */
157
+ }
158
+ }
159
+ throw Object.assign(new Error(`log not found: ${safe}`), { code: 'E_NOT_FOUND' })
160
+ }
161
+
162
+ /** Tail a file without loading all of it (logs can be many MBs). */
163
+ function tailFile(path, lines) {
164
+ const fd = openSync(path, 'r')
165
+ try {
166
+ const size = fstatSync(fd).size
167
+ const start = Math.max(0, size - LOG_MAX_BYTES)
168
+ const length = size - start
169
+ const buffer = Buffer.alloc(length)
170
+ if (length > 0) readSync(fd, buffer, 0, length, start)
171
+ const all = buffer.toString('utf8').split(/\r?\n/)
172
+ const body = all.slice(Math.max(0, all.length - lines))
173
+ return { sizeBytes: size, truncated: start > 0, lines: body.map((line) => redactSecrets(line)) }
174
+ } finally {
175
+ closeSync(fd)
176
+ }
177
+ }
178
+
179
+ function normalizeToolList(schemas) {
180
+ return (schemas || []).map(schema => ({
181
+ name: schema?.name ?? '',
182
+ description: schema?.description ?? '',
183
+ parameters: schema?.parameters ?? null,
184
+ }))
185
+ }
186
+
187
+ function serializeResult(result) {
188
+ if (!result || typeof result !== 'object') {
189
+ return { ok: true, value: result ?? null, content: [] }
190
+ }
191
+ if (result.isError === true) {
192
+ const error = result.error ?? {}
193
+ return {
194
+ ok: false,
195
+ code: error.code || 'E_HOST_TOOL',
196
+ message: error.message || String(error) || 'host tool failed',
197
+ content: result.content ?? [],
198
+ }
199
+ }
200
+ return { ok: true, value: result.value ?? null, content: result.content ?? [] }
201
+ }
202
+
203
+ /**
204
+ * Install the loopback bridge.
205
+ * @param ctx - DSH host context with `tools` and `agents` injected.
206
+ */
207
+ export function apply(ctx) {
208
+ const token = ensureToken()
209
+ const port = Number(process.env.DSH_MESH_HOST_PORT || 9100 + instanceIndex())
210
+ const bind = process.env.DSH_MESH_HOST_BIND || '127.0.0.1'
211
+
212
+ const authorized = req => {
213
+ const header = req.headers.authorization || ''
214
+ const match = /^Bearer\s+(.+)$/iu.exec(header)
215
+ return Boolean(match) && safeEqual(match[1].trim(), token)
216
+ }
217
+
218
+ const listAgents = () => {
219
+ let agents = []
220
+ try {
221
+ agents = ctx.agents?.list?.() ?? []
222
+ } catch {
223
+ agents = []
224
+ }
225
+ return agents.map(agent => ({
226
+ id: String(agent?.id ?? ''),
227
+ status: String(agent?.status ?? 'unknown'),
228
+ title: typeof agent?.session?.title === 'string' ? agent.session.title : undefined,
229
+ seq: typeof agent?.session?.seq === 'number' ? agent.session.seq : undefined,
230
+ // Workspace/cwd is what makes delegation routing possible: a task that
231
+ // belongs to a repo should land in a session already opened there.
232
+ // DSH keeps the session cwd in the durable header (`session.header.cwd`).
233
+ cwd: typeof agent?.session?.header?.cwd === 'string'
234
+ ? agent.session.header.cwd
235
+ : (typeof agent?.session?.cwd === 'string' ? agent.session.cwd : undefined),
236
+ }))
237
+ }
238
+
239
+ const rawAgents = () => {
240
+ try {
241
+ return ctx.agents?.list?.() ?? []
242
+ } catch {
243
+ return []
244
+ }
245
+ }
246
+
247
+ // Optional services: newer DSH composes sessionQuery (live + persisted
248
+ // history); older forks only expose sessionPersistence or neither. Read them
249
+ // through ctx.get so the bridge still loads on every profile.
250
+ const optionalService = (name) => {
251
+ try {
252
+ return typeof ctx.get === 'function' ? ctx.get(name) : undefined
253
+ } catch {
254
+ return undefined
255
+ }
256
+ }
257
+
258
+ // Session creation/prompting is the web UI's own service. Profiles older than
259
+ // the session-controller split do not mount it; callers get an explicit 501
260
+ // instead of a silent no-op.
261
+ const sessionController = () => optionalService('sessionController')
262
+
263
+ const requireSessionController = () => {
264
+ const controller = sessionController()
265
+ if (!controller || typeof controller.create !== 'function' || typeof controller.prompt !== 'function') {
266
+ throw Object.assign(new Error('this DSH profile mounts no sessionController service (session create/prompt unavailable)'), { code: 'E_HOST_NO_CONTROLLER' })
267
+ }
268
+ return controller
269
+ }
270
+
271
+ // Older forks can expose a session service whose list() never settles (or
272
+ // blocks on a backend that is not mounted). Never let /host/sessions hang:
273
+ // fall back to the live agents after a short budget.
274
+ const withTimeout = (promise, ms, fallback) => {
275
+ let timer
276
+ return Promise.race([
277
+ Promise.resolve(promise).then((value) => value, () => fallback),
278
+ new Promise((resolve) => {
279
+ timer = setTimeout(() => resolve(fallback), ms)
280
+ })
281
+ ]).finally(() => clearTimeout(timer))
282
+ }
283
+
284
+ // Once an old fork's session service times out, remember it for this plugin
285
+ // lifetime so later /host/sessions calls answer from live agents immediately.
286
+ let sessionListingBroken = false
287
+
288
+ const listSessions = async ({ limit = 50, includeEnded = true, workspace = '' } = {}) => {
289
+ const agents = listAgents()
290
+ const liveById = new Map(agents.map((agent) => [agent.id, agent]))
291
+ const titles = new Map()
292
+ let source = 'agents'
293
+ let records = []
294
+ const query = optionalService('sessionQuery')
295
+ const agentRecords = () => agents.map((agent) => ({
296
+ header: { id: agent.id, createdAt: 0, cwd: agent.cwd },
297
+ live: true,
298
+ persisted: false
299
+ }))
300
+ if (sessionListingBroken) {
301
+ source = 'agentsFallback'
302
+ records = agentRecords()
303
+ } else if (query && typeof query.listSessions === 'function') {
304
+ source = 'sessionQuery'
305
+ const listed = await withTimeout(query.listSessions(), 2000, null)
306
+ if (!listed) {
307
+ sessionListingBroken = true
308
+ source = 'agentsFallback'
309
+ records = agentRecords()
310
+ } else {
311
+ records = (listed || []).map((record) => ({
312
+ header: record.header,
313
+ live: record.live === true,
314
+ persisted: record.persisted === true
315
+ }))
316
+ const ids = records.map((record) => record.header?.id).filter(Boolean)
317
+ if (typeof query.readTitleSnapshots === 'function' && ids.length > 0) {
318
+ try {
319
+ const observed = await withTimeout(query.readTitleSnapshots(ids), 8000, null)
320
+ for (const item of observed || []) {
321
+ if (item.status !== 'fulfilled') continue
322
+ const title = item.value?.title?.title || item.value?.title?.text || ''
323
+ if (title) titles.set(item.sessionId, { title, updatedAt: item.value?.title?.updatedAt || 0 })
324
+ }
325
+ } catch {
326
+ /* title is best effort; listing still works without it */
327
+ }
328
+ }
329
+ }
330
+ } else {
331
+ const persistence = optionalService('sessionPersistence')
332
+ if (persistence && typeof persistence.list === 'function') {
333
+ source = 'sessionPersistence'
334
+ const snapshots = await withTimeout(persistence.list(), 2000, null)
335
+ if (!snapshots) {
336
+ sessionListingBroken = true
337
+ source = 'agentsFallback'
338
+ records = agentRecords()
339
+ } else {
340
+ records = (snapshots || []).map((snapshot) => ({
341
+ header: snapshot.header,
342
+ live: liveById.has(snapshot.header?.id),
343
+ persisted: true
344
+ }))
345
+ }
346
+ } else {
347
+ records = agentRecords()
348
+ }
349
+ }
350
+
351
+ const norm = (value) => String(value || '').toLowerCase().replace(/\\/g, '/')
352
+ const sessions = records
353
+ .map((record) => {
354
+ const id = String(record.header?.id || '')
355
+ const agent = liveById.get(id)
356
+ const running = agent?.status === 'running'
357
+ const agentAvailable = agent !== undefined
358
+ const titleInfo = titles.get(id)
359
+ return {
360
+ sessionId: id,
361
+ title: agent?.title || titleInfo?.title || '',
362
+ cwd: record.header?.cwd || agent?.cwd || '',
363
+ state: running ? 'working' : agentAvailable ? 'idle' : 'ended',
364
+ running,
365
+ agentAvailable,
366
+ live: record.live === true || agentAvailable,
367
+ persisted: record.persisted === true,
368
+ createdAt: record.header?.createdAt || 0,
369
+ updatedAt: titleInfo?.updatedAt || record.header?.createdAt || 0
370
+ }
371
+ })
372
+ .filter((session) => includeEnded || session.state !== 'ended')
373
+ .filter((session) => !workspace || norm(session.cwd).includes(norm(workspace)))
374
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
375
+ .slice(0, limit)
376
+ return { source, includeEnded, count: sessions.length, sessions }
377
+ }
378
+
379
+ /**
380
+ * Older DSH keeps tools in the root scope (`schemas()` sees them); newer DSH
381
+ * registers tools per agent scope, so the root view is empty while
382
+ * `schemas(agent)` returns that agent's tool set (probed on DSH next: 36 tools
383
+ * vs 0 at root). Merge the per-agent views in that case.
384
+ */
385
+ const listSchemas = () => {
386
+ const root = normalizeToolList(ctx.tools.schemas())
387
+ if (root.length > 0) return root
388
+ const merged = new Map()
389
+ for (const agent of rawAgents()) {
390
+ try {
391
+ for (const tool of normalizeToolList(ctx.tools.schemas(agent))) merged.set(tool.name, tool)
392
+ } catch {
393
+ /* scope not usable */
394
+ }
395
+ }
396
+ return [...merged.values()]
397
+ }
398
+
399
+ const findTool = (name, agent) => {
400
+ try {
401
+ const scoped = agent ? ctx.tools.get(name, agent) : undefined
402
+ if (typeof scoped !== 'undefined') return scoped
403
+ } catch {
404
+ /* fall through to the unscoped registry */
405
+ }
406
+ return ctx.tools.get(name)
407
+ }
408
+
409
+ const server = createServer(async (req, res) => {
410
+ try {
411
+ if (req.method === 'GET' && req.url === '/host/ping') {
412
+ return json(res, 200, { ok: true, plugin: PLUGIN_VERSION })
413
+ }
414
+ if (!authorized(req)) {
415
+ return json(res, 401, { ok: false, code: 'E_AUTH', message: 'missing or invalid host token' })
416
+ }
417
+ const url = new URL(req.url || '/', 'http://127.0.0.1')
418
+
419
+ if (req.method === 'GET' && url.pathname === '/host/self') {
420
+ return json(res, 200, {
421
+ ok: true,
422
+ plugin: PLUGIN_VERSION,
423
+ node: { hostname: hostname(), platform: platform(), release: release(), arch: arch(), pid: process.pid },
424
+ tools: listSchemas().map(tool => tool.name),
425
+ agents: listAgents(),
426
+ now: new Date().toISOString(),
427
+ })
428
+ }
429
+
430
+ if (req.method === 'GET' && url.pathname === '/host/logs') {
431
+ const name = url.searchParams.get('name') || ''
432
+ const lines = Math.min(Math.max(Number(url.searchParams.get('lines') || 100), 1), 500)
433
+ let path
434
+ try {
435
+ path = resolveLogPath(name)
436
+ } catch (error) {
437
+ return json(res, 200, { ok: false, code: error.code || 'E_LOG', message: error.message })
438
+ }
439
+ try {
440
+ const tail = tailFile(path, lines)
441
+ return json(res, 200, { ok: true, name: basename(path), path, lines, ...tail, plugin: PLUGIN_VERSION })
442
+ } catch (error) {
443
+ return json(res, 200, { ok: false, code: 'E_LOG_READ', message: error?.message || String(error) })
444
+ }
445
+ }
446
+
447
+ if (req.method === 'GET' && url.pathname === '/host/tools') {
448
+ return json(res, 200, { ok: true, tools: listSchemas() })
449
+ }
450
+
451
+ if (req.method === 'GET' && url.pathname === '/host/agents') {
452
+ return json(res, 200, { ok: true, agents: listAgents() })
453
+ }
454
+
455
+ if (req.method === 'GET' && url.pathname === '/host/sessions') {
456
+ const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 50), 1), 200)
457
+ const includeEnded = url.searchParams.get('includeEnded') !== 'false'
458
+ const workspace = url.searchParams.get('workspace') || ''
459
+ const listed = await listSessions({ limit, includeEnded, workspace })
460
+ return json(res, 200, { ok: true, ...listed })
461
+ }
462
+
463
+ if (req.method === 'POST' && url.pathname === '/host/sessions/create') {
464
+ let payload
465
+ try {
466
+ payload = JSON.parse((await readBody(req)) || '{}')
467
+ } catch {
468
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
469
+ }
470
+ const optionalText = (value) => (typeof value === 'string' && value.trim() ? value.trim() : undefined)
471
+ const title = optionalText(payload.title)
472
+ const request = {
473
+ ...(optionalText(payload.cwd) === undefined ? {} : { cwd: optionalText(payload.cwd) }),
474
+ ...(optionalText(payload.sessionId) === undefined ? {} : { sessionId: optionalText(payload.sessionId) }),
475
+ ...(optionalText(payload.agentPreset) === undefined ? {} : { agentPreset: optionalText(payload.agentPreset) }),
476
+ ...(optionalText(payload.workspaceId) === undefined ? {} : { workspaceId: optionalText(payload.workspaceId) })
477
+ }
478
+ let controller
479
+ try {
480
+ controller = requireSessionController()
481
+ } catch (error) {
482
+ return json(res, 501, { ok: false, code: error.code || 'E_HOST_NO_CONTROLLER', message: error.message })
483
+ }
484
+ try {
485
+ const created = await controller.create(request)
486
+ const sessionId = String(created?.sessionId || '')
487
+ let acceptedTitle
488
+ let titleWarning
489
+ if (title && sessionId) {
490
+ if (typeof controller.rename !== 'function') {
491
+ titleWarning = 'sessionController.rename is unavailable in this DSH version'
492
+ } else {
493
+ try {
494
+ const renamed = await controller.rename({ sessionId, title })
495
+ acceptedTitle = typeof renamed?.title === 'string' ? renamed.title : title
496
+ } catch (error) {
497
+ titleWarning = error?.message || String(error)
498
+ }
499
+ }
500
+ }
501
+ return json(res, 200, {
502
+ ok: true,
503
+ sessionId,
504
+ ...(acceptedTitle === undefined ? {} : { title: acceptedTitle }),
505
+ ...(titleWarning === undefined ? {} : { titleWarning }),
506
+ ...(created?.agentPreset === undefined ? {} : { agentPreset: created.agentPreset }),
507
+ plugin: PLUGIN_VERSION
508
+ })
509
+ } catch (error) {
510
+ return json(res, 200, {
511
+ ok: false,
512
+ code: error?.code || 'E_HOST_SESSION_CREATE',
513
+ message: error?.message || String(error)
514
+ })
515
+ }
516
+ }
517
+
518
+ if (req.method === 'POST' && url.pathname === '/host/sessions/rename') {
519
+ let payload
520
+ try {
521
+ payload = JSON.parse((await readBody(req)) || '{}')
522
+ } catch {
523
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
524
+ }
525
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : ''
526
+ const title = typeof payload.title === 'string' ? payload.title.trim() : ''
527
+ if (!sessionId || !title) {
528
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId and title are required' })
529
+ }
530
+ let controller
531
+ try {
532
+ controller = requireSessionController()
533
+ } catch (error) {
534
+ return json(res, 501, { ok: false, code: error.code || 'E_HOST_NO_CONTROLLER', message: error.message })
535
+ }
536
+ if (typeof controller.rename !== 'function') {
537
+ return json(res, 501, { ok: false, code: 'E_HOST_NO_RENAME', message: 'sessionController.rename is unavailable in this DSH version' })
538
+ }
539
+ try {
540
+ const renamed = await controller.rename({ sessionId, title })
541
+ return json(res, 200, {
542
+ ok: true,
543
+ sessionId,
544
+ title: typeof renamed?.title === 'string' ? renamed.title : title,
545
+ ...(renamed?.seq === undefined ? {} : { seq: renamed.seq }),
546
+ plugin: PLUGIN_VERSION
547
+ })
548
+ } catch (error) {
549
+ return json(res, 200, {
550
+ ok: false,
551
+ code: error?.code || 'E_HOST_SESSION_RENAME',
552
+ message: error?.message || String(error)
553
+ })
554
+ }
555
+ }
556
+
557
+ if (req.method === 'POST' && url.pathname === '/host/prompt') {
558
+ let payload
559
+ try {
560
+ payload = JSON.parse((await readBody(req)) || '{}')
561
+ } catch {
562
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
563
+ }
564
+ const sessionId = String(payload.sessionId || '').trim()
565
+ const text = typeof payload.text === 'string' ? payload.text : ''
566
+ const mode = payload.mode === 'steer' ? 'steer' : 'queue'
567
+ if (!sessionId || text.trim() === '') {
568
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId and non-empty text are required' })
569
+ }
570
+ const requestId = typeof payload.requestId === 'string' && payload.requestId.trim()
571
+ ? payload.requestId.trim()
572
+ : randomUUID()
573
+ let controller
574
+ try {
575
+ controller = requireSessionController()
576
+ } catch (error) {
577
+ return json(res, 501, { ok: false, code: error.code || 'E_HOST_NO_CONTROLLER', message: error.message })
578
+ }
579
+ try {
580
+ // sessionController.prompt(request, signal): DSH dereferences the signal
581
+ // (throwIfAborted) before dispatch, so it must be a real AbortSignal.
582
+ const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || DEFAULT_TIMEOUT_MS, 1000), 600_000)
583
+ const accepted = await controller.prompt({
584
+ requestId,
585
+ sessionId,
586
+ mode,
587
+ content: [{ type: 'text', text }],
588
+ ...(typeof payload.clientTimeZone === 'string' && payload.clientTimeZone.trim()
589
+ ? { clientTimeZone: payload.clientTimeZone.trim() }
590
+ : {})
591
+ }, AbortSignal.timeout(timeoutMs))
592
+ return json(res, 200, { ok: true, sessionId, requestId, mode, accepted: accepted?.accepted === true, plugin: PLUGIN_VERSION })
593
+ } catch (error) {
594
+ return json(res, 200, {
595
+ ok: false,
596
+ code: error?.code || 'E_HOST_PROMPT',
597
+ message: error?.message || String(error)
598
+ })
599
+ }
600
+ }
601
+
602
+ if (req.method === 'POST' && url.pathname === '/host/sessions/cancel') {
603
+ let payload
604
+ try {
605
+ payload = JSON.parse((await readBody(req)) || '{}')
606
+ } catch {
607
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
608
+ }
609
+ const sessionId = String(payload.sessionId || '').trim()
610
+ if (!sessionId) {
611
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId is required' })
612
+ }
613
+ // The web UI's own stop button: sessionController.cancel aborts the active
614
+ // turn and keeps pending inbox work. Profiles that mount no such controller
615
+ // answer 501, so an old bridge (404 "unknown endpoint") stays distinguishable.
616
+ const controller = sessionController()
617
+ if (!controller || typeof controller.cancel !== 'function') {
618
+ return json(res, 501, {
619
+ ok: false,
620
+ code: 'E_HOST_NO_CONTROLLER',
621
+ message: 'this DSH profile mounts no sessionController service with cancel (session cancel unavailable)'
622
+ })
623
+ }
624
+ let agent
625
+ try {
626
+ agent = ctx.agents.get(sessionId)
627
+ } catch {
628
+ agent = undefined
629
+ }
630
+ // Agent.cancel() is a no-op on an idle agent: report whether a turn was really aborted.
631
+ const wasRunning = agent?.status === 'running'
632
+ try {
633
+ const value = await controller.cancel({ sessionId })
634
+ return json(res, 200, {
635
+ ok: true,
636
+ sessionId,
637
+ cancelled: true,
638
+ wasRunning,
639
+ accepted: value?.accepted !== false,
640
+ plugin: PLUGIN_VERSION
641
+ })
642
+ } catch (error) {
643
+ const message = error?.message || String(error)
644
+ const code = error?.code || ''
645
+ const missing = code === 'session/not-found' || /not found \(not attached\)/u.test(message)
646
+ if (missing) {
647
+ return json(res, 404, { ok: false, code: 'E_HOST_CANCEL', message })
648
+ }
649
+ return json(res, 200, { ok: false, code: code || 'E_HOST_CANCEL', message })
650
+ }
651
+ }
652
+
653
+ if (req.method === 'POST' && url.pathname === '/host/sessions/archive') {
654
+ let payload
655
+ try {
656
+ payload = JSON.parse((await readBody(req)) || '{}')
657
+ } catch {
658
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
659
+ }
660
+ const sessionId = String(payload.sessionId || '').trim()
661
+ if (!sessionId) {
662
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId is required' })
663
+ }
664
+ // Archiving is DSH's own "remove from the list" action: the conversation
665
+ // stays on disk and stays restorable. Deletion has no public service, so
666
+ // this is the cleanup path for sessions created by delegations.
667
+ const registry = optionalService('workspaceRegistry')
668
+ if (!registry || typeof registry.archiveSession !== 'function') {
669
+ return json(res, 501, { ok: false, code: 'E_HOST_NO_WORKSPACE', message: 'this DSH profile mounts no workspaceRegistry service (session archive unavailable)' })
670
+ }
671
+ try {
672
+ await registry.archiveSession(sessionId, payload.stopActivity === true ? { stopActivity: true } : {})
673
+ return json(res, 200, { ok: true, sessionId, archived: true, plugin: PLUGIN_VERSION })
674
+ } catch (error) {
675
+ return json(res, 200, {
676
+ ok: false,
677
+ code: error?.code || 'E_HOST_ARCHIVE',
678
+ message: error?.message || String(error)
679
+ })
680
+ }
681
+ }
682
+
683
+ if (req.method === 'POST' && url.pathname === '/host/tools/call') {
684
+ let payload
685
+ try {
686
+ payload = JSON.parse((await readBody(req)) || '{}')
687
+ } catch {
688
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
689
+ }
690
+ const toolName = String(payload.tool || payload.name || '').trim()
691
+ if (!toolName) {
692
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'tool is required' })
693
+ }
694
+ if (typeof findTool(toolName) === 'undefined' && rawAgents().every(agent => typeof findTool(toolName, agent) === 'undefined')) {
695
+ return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: `no such tool in this host: ${toolName}` })
696
+ }
697
+ const timeoutMs = Math.min(Math.max(Number(payload.timeoutMs) || DEFAULT_TIMEOUT_MS, 1000), 600_000)
698
+ // Newer DSH registers tools per agent scope: when the caller did not pick a
699
+ // session, execute in the first available agent's scope (usually the same
700
+ // preset as every other session of this instance).
701
+ const agent = payload.sessionId ? ctx.agents.get(String(payload.sessionId)) : rawAgents()[0]
702
+ if (payload.sessionId && !agent) {
703
+ return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: `no such session: ${payload.sessionId}` })
704
+ }
705
+ let result
706
+ try {
707
+ result = await ctx.tools.execute({
708
+ callId: randomUUID(),
709
+ name: toolName,
710
+ arguments: payload.arguments ?? {},
711
+ agent,
712
+ signal: AbortSignal.timeout(timeoutMs),
713
+ })
714
+ } catch (error) {
715
+ return json(res, 200, {
716
+ ok: false,
717
+ code: error?.code || 'E_HOST_TOOL',
718
+ message: error?.message || String(error),
719
+ })
720
+ }
721
+ return json(res, 200, serializeResult(result))
722
+ }
723
+
724
+ if (req.method === 'POST' && url.pathname === '/host/wake') {
725
+ let payload
726
+ try {
727
+ payload = JSON.parse((await readBody(req)) || '{}')
728
+ } catch {
729
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'body must be JSON' })
730
+ }
731
+ const sessionId = String(payload.sessionId || '').trim()
732
+ const text = typeof payload.text === 'string' ? payload.text.trim() : ''
733
+ const target = payload.target === 'next-step' ? 'next-step' : 'next-turn'
734
+ if (!sessionId || !text) {
735
+ return json(res, 400, { ok: false, code: 'E_INVALID_ARG', message: 'sessionId and text are required' })
736
+ }
737
+ const agent = ctx.agents.get(sessionId)
738
+ if (!agent) {
739
+ return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: `no such session: ${sessionId}` })
740
+ }
741
+ const message = createUserMessage({
742
+ content: [{ type: 'text', text }],
743
+ source: { kind: 'user' },
744
+ })
745
+ const wokeUp = agent.status === 'idle'
746
+ try {
747
+ if (typeof agent.send === 'function') agent.send(message, target, true)
748
+ else agent.followup(message)
749
+ } catch (error) {
750
+ return json(res, 200, {
751
+ ok: false,
752
+ code: error?.code || 'E_HOST_WAKE',
753
+ message: error?.message || String(error),
754
+ })
755
+ }
756
+ return json(res, 200, {
757
+ ok: true,
758
+ sessionId,
759
+ target,
760
+ messageId: message.id,
761
+ statusBefore: agent.status,
762
+ wokeUp,
763
+ delivered: true,
764
+ })
765
+ }
766
+
767
+ return json(res, 404, { ok: false, code: 'E_NOT_FOUND', message: 'unknown endpoint' })
768
+ } catch (error) {
769
+ return json(res, 500, { ok: false, code: 'E_INTERNAL', message: error?.message || String(error) })
770
+ }
771
+ })
772
+
773
+ server.on('error', error => {
774
+ console.error(`[dsh-mesh-host] listen ${bind}:${port} failed: ${error?.code || error?.message || error}`)
775
+ })
776
+ server.unref()
777
+ server.listen(port, bind, () => {
778
+ console.error(`[dsh-mesh-host] listening on http://${bind}:${port} (token: ${hostTokenPath()})`)
779
+ })
780
+
781
+ // Close the loopback listener when the plugin is disposed.
782
+ try {
783
+ ctx.effect?.(() => () => server.close())
784
+ } catch {
785
+ /* older hosts: leaving the server unref()ed is enough */
786
+ }
787
+ }