dsh-mesh-host 0.0.0-stage → 0.0.1

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