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.
- package/lib/index.js +787 -730
- 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.
|
|
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
|
|
472
|
-
|
|
473
|
-
...(optionalText(payload.
|
|
474
|
-
...(optionalText(payload.
|
|
475
|
-
...(optionalText(payload.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
return json(res,
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
return json(res,
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
return json(res, 200, { ok:
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
return json(res,
|
|
619
|
-
ok: false,
|
|
620
|
-
code:
|
|
621
|
-
message:
|
|
622
|
-
})
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
})
|
|
657
|
-
} catch
|
|
658
|
-
return json(res,
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
})
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
+
}
|