dsh-mesh-node 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -2
  3. package/package.json +43 -4
  4. package/src/cli.js +1246 -0
  5. package/src/core/acl.js +81 -0
  6. package/src/core/audit.js +75 -0
  7. package/src/core/autostart.js +119 -0
  8. package/src/core/cluster.js +341 -0
  9. package/src/core/config.js +622 -0
  10. package/src/core/desktop-proxy.js +85 -0
  11. package/src/core/ds-web.js +394 -0
  12. package/src/core/host-agent.js +263 -0
  13. package/src/core/jobs.js +259 -0
  14. package/src/core/pairing.js +141 -0
  15. package/src/core/peer-client.js +273 -0
  16. package/src/core/peer-doctor.js +294 -0
  17. package/src/core/peer-http.js +61 -0
  18. package/src/core/repair.js +93 -0
  19. package/src/core/restart-policy.js +21 -0
  20. package/src/core/result.js +29 -0
  21. package/src/core/sakura-api.js +103 -0
  22. package/src/core/session-profile.js +146 -0
  23. package/src/core/tls.js +160 -0
  24. package/src/core/tunnels.js +212 -0
  25. package/src/core/util.js +68 -0
  26. package/src/e2e-autostart.js +58 -0
  27. package/src/e2e-channel.js +384 -0
  28. package/src/e2e-cluster.js +417 -0
  29. package/src/e2e-host.js +411 -0
  30. package/src/e2e-onboard.js +232 -0
  31. package/src/e2e-pair.js +317 -0
  32. package/src/e2e-plugin.js +395 -0
  33. package/src/e2e-repair.js +297 -0
  34. package/src/index.js +20 -0
  35. package/src/peer-bridge.js +124 -0
  36. package/src/selftest.js +872 -0
  37. package/src/server.js +317 -0
  38. package/src/tools/alerts.js +80 -0
  39. package/src/tools/channel.js +253 -0
  40. package/src/tools/cluster.js +235 -0
  41. package/src/tools/config-patch.js +182 -0
  42. package/src/tools/control-apps.js +469 -0
  43. package/src/tools/control-path.js +160 -0
  44. package/src/tools/control.js +232 -0
  45. package/src/tools/core.js +79 -0
  46. package/src/tools/delegate.js +653 -0
  47. package/src/tools/fs.js +436 -0
  48. package/src/tools/git.js +266 -0
  49. package/src/tools/host.js +305 -0
  50. package/src/tools/index.js +122 -0
  51. package/src/tools/repair-fs.js +130 -0
  52. package/src/tools/repair.js +317 -0
  53. package/src/tools/session-history.js +60 -0
  54. package/src/tools/shell.js +66 -0
  55. package/src/tools/tasks.js +117 -0
package/src/server.js ADDED
@@ -0,0 +1,317 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import { createServer as createHttpServer } from 'node:http'
3
+ import { createServer as createHttpsServer } from 'node:https'
4
+ import { McpServer, createMcpHandler } from '@modelcontextprotocol/server'
5
+ import { toNodeHandler } from '@modelcontextprotocol/node'
6
+ import { MESH_VERSION, verifyBearer, createConfigLoader, createSecretsLoader, meshHome, addInboundToken, peerAclForToken } from './core/config.js'
7
+ import { makeAudit, auditPath } from './core/audit.js'
8
+ import { JobRunner } from './core/jobs.js'
9
+ import { loadTlsMaterial, describeCert } from './core/tls.js'
10
+ import { claimPairingCode, readPairingState } from './core/pairing.js'
11
+ import { dispatch, isAllowed, applyPeerAcl, registerTools, toolNames } from './tools/index.js'
12
+ import { createTunnelCache } from './core/tunnels.js'
13
+
14
+ const NODE_STARTED_AT = new Date().toISOString()
15
+
16
+ const INSTRUCTIONS = `This endpoint is a dsh-mesh node: another developer machine exposed over the Model Context Protocol.
17
+ Call mesh_self first to learn what the peer can do and which tools your token may use.
18
+ All paths are paths on the PEER machine, not on yours.
19
+ Long operations: use task_submit (with an idem key) then poll task_status, instead of risking a call timeout.
20
+ shell_run is opt-in and normally refused.`
21
+
22
+ /** Carries the authenticated token id down into the per-request MCP server factory. */
23
+ const requestScope = new AsyncLocalStorage()
24
+
25
+ export function buildMcpServer({ ctx, log = console }) {
26
+ const config = ctx.getConfig()
27
+ const server = new McpServer(
28
+ { name: `dsh-mesh/${config.node?.name || 'local'}`, version: MESH_VERSION },
29
+ { instructions: INSTRUCTIONS }
30
+ )
31
+ registerTools(server, ctx)
32
+ return server
33
+ }
34
+
35
+ function sendJson(res, status, body, extraHeaders = {}) {
36
+ const text = JSON.stringify(body)
37
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
38
+ res.end(text)
39
+ }
40
+
41
+ /** Read a small JSON body (pairing requests are tiny; anything huge is a mistake). */
42
+ function readJsonBody(req, limitBytes = 8192) {
43
+ return new Promise((resolvePromise, rejectPromise) => {
44
+ let size = 0
45
+ const chunks = []
46
+ req.on('data', (chunk) => {
47
+ size += chunk.length
48
+ if (size > limitBytes) {
49
+ rejectPromise(new Error('body too large'))
50
+ req.destroy()
51
+ return
52
+ }
53
+ chunks.push(chunk)
54
+ })
55
+ req.on('end', () => {
56
+ const text = Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, '').trim()
57
+ if (text === '') return resolvePromise({})
58
+ try {
59
+ resolvePromise(JSON.parse(text))
60
+ } catch {
61
+ rejectPromise(new Error('body is not valid JSON'))
62
+ }
63
+ })
64
+ req.on('error', rejectPromise)
65
+ })
66
+ }
67
+
68
+ /** Peer ids double as token ids; keep them filename/log friendly. */
69
+ function sanitizePeerId(raw) {
70
+ const id = String(raw || '')
71
+ .trim()
72
+ .replace(/[^A-Za-z0-9._-]/g, '-')
73
+ .replace(/-+/g, '-')
74
+ .slice(0, 48)
75
+ return id === '' ? null : id
76
+ }
77
+
78
+ /**
79
+ * Start the mesh HTTP endpoint.
80
+ * @param {object} opts
81
+ * @param {object} [opts.config] Fixed config (tests). Omit to load and hot-reload from disk.
82
+ * @param {object} opts.secrets Inbound tokens.
83
+ * @param {string} [opts.home] Mesh home directory (defaults to ~/.dsh-mesh).
84
+ */
85
+ export async function startMeshServer({ config, secrets, log = console, port, bind, tls, home } = {}) {
86
+ const meshHomeDir = home || meshHome()
87
+ const getConfig = config ? () => config : createConfigLoader({ home: meshHomeDir, log })
88
+ // Secrets hot-reload too: rotating a token takes effect without a restart.
89
+ const getSecrets = secrets ? () => secrets : createSecretsLoader({ home: meshHomeDir, log })
90
+ const audit = makeAudit({ home: meshHomeDir, log })
91
+
92
+ const initial = getConfig()
93
+ // 2026-11-09:隧道状态缓存(自建 frps + Sakura)。/health 携带,60s 后台刷新,不阻塞请求。
94
+ const tunnelCache = createTunnelCache({ repo: initial.repo || process.cwd(), home: meshHomeDir, refreshMs: Math.max(3000, Number(process.env.TUNNEL_CACHE_MS || 10_000)), log }).start()
95
+ const jobs = new JobRunner({
96
+ home: meshHomeDir,
97
+ audit,
98
+ log,
99
+ limits: initial.limits || {},
100
+ exec: (tool, args, opts) => dispatch(ctx, tool, args, opts)
101
+ })
102
+
103
+ // Shared context. `peerId` is filled per request from the verified bearer token.
104
+ const ctx = {
105
+ home: meshHomeDir,
106
+ secrets,
107
+ getSecrets,
108
+ log,
109
+ audit,
110
+ jobs,
111
+ getConfig,
112
+ isAllowed,
113
+ toolNames: () => toolNames(ctx),
114
+ dispatch: (name, args, opts) => dispatch(ctx, name, args, opts)
115
+ }
116
+
117
+ const nodeCfg = initial.node || {}
118
+ const listenPort = port ?? nodeCfg.port ?? 8124
119
+ const listenBind = bind ?? nodeCfg.bind ?? '127.0.0.1'
120
+ const mcpPath = nodeCfg.path || '/mcp'
121
+ const tlsCfg = tls ?? nodeCfg.tls ?? { enabled: false }
122
+
123
+ let calls = 0
124
+ const mcpHandler = createMcpHandler(
125
+ () => {
126
+ const scope = requestScope.getStore()
127
+ const peerAcl = scope?.peerAcl || null
128
+ return buildMcpServer({
129
+ ctx: {
130
+ ...ctx,
131
+ peerId: scope?.tokenId,
132
+ peerAcl,
133
+ getConfig: () => applyPeerAcl(getConfig(), peerAcl)
134
+ },
135
+ log
136
+ })
137
+ },
138
+ { onerror: (error) => log?.error?.(`[dsh-mesh] handler error: ${error?.message || error}`) }
139
+ )
140
+ const nodeHandler = toNodeHandler(mcpHandler)
141
+
142
+ const requestListener = async (req, res) => {
143
+ let pathname = '/'
144
+ try {
145
+ pathname = new URL(req.url, 'http://mesh.local').pathname
146
+ } catch {
147
+ /* malformed URL falls through to 404 */
148
+ }
149
+
150
+ if (pathname === '/health') {
151
+ const liveConfig = getConfig()
152
+ const pairingState = readPairingState(meshHomeDir)
153
+ sendJson(res, 200, {
154
+ ok: true,
155
+ service: 'dsh-mesh',
156
+ version: MESH_VERSION,
157
+ node: liveConfig.node?.name || 'local',
158
+ /** Local DSH instances this node can bridge to (cluster addressing: <node>:<instance>). */
159
+ hosts: (liveConfig.hosts || []).map((entry) => ({
160
+ id: entry.id,
161
+ url: entry.url,
162
+ enabled: entry.enabled !== false
163
+ })),
164
+ uptimeSec: Math.round((Date.now() - Date.parse(NODE_STARTED_AT)) / 1000),
165
+ pid: process.pid,
166
+ startedAt: NODE_STARTED_AT,
167
+ tools: toolNames(ctx).length,
168
+ jobs: jobs.stats(),
169
+ tls: tlsCfg?.enabled === true,
170
+ tunnels: tunnelCache.get(),
171
+ pairing: {
172
+ enabled: liveConfig.pairing?.enabled === true,
173
+ codeActive: Boolean(pairingState && !pairingState.used && !pairingState.expired),
174
+ expiresAt: pairingState?.expiresAt ?? null
175
+ },
176
+ now: new Date().toISOString()
177
+ })
178
+ return
179
+ }
180
+
181
+ // Unauthenticated by design: a peer has no token yet, it redeems a one-time code instead.
182
+ if (pathname === '/pair') {
183
+ if (req.method !== 'POST') {
184
+ sendJson(res, 405, { code: 'E_INVALID_ARG', message: 'use POST /pair' }, { allow: 'POST' })
185
+ return
186
+ }
187
+ const liveConfig = getConfig()
188
+ if (liveConfig.pairing?.enabled !== true) {
189
+ sendJson(res, 403, { code: 'E_DENIED', message: 'pairing is disabled on this node' })
190
+ return
191
+ }
192
+ let body
193
+ try {
194
+ body = await readJsonBody(req)
195
+ } catch (error) {
196
+ sendJson(res, 400, { code: 'E_INVALID_ARG', message: error?.message || 'bad body' })
197
+ return
198
+ }
199
+ const claim = claimPairingCode({
200
+ home: meshHomeDir,
201
+ code: body?.code,
202
+ maxAttempts: Number(liveConfig.pairing?.maxAttempts) || 5
203
+ })
204
+ if (!claim.ok) {
205
+ // Small constant delay so the answer time does not reveal much; never say why.
206
+ await new Promise((r) => setTimeout(r, 200))
207
+ await audit({ peer: 'pair', tool: 'pair', ok: false, code: claim.reason, args: ['code'] })
208
+ sendJson(
209
+ res,
210
+ 401,
211
+ {
212
+ code: claim.reason === 'E_MAX_ATTEMPTS' ? 'E_MAX_ATTEMPTS' : 'E_AUTH',
213
+ message: 'pairing code rejected',
214
+ retryable: claim.reason !== 'E_MAX_ATTEMPTS' && claim.reason !== 'E_CODE_USED'
215
+ },
216
+ { 'www-authenticate': 'Bearer realm="dsh-mesh"' }
217
+ )
218
+ return
219
+ }
220
+ const baseId = sanitizePeerId(body?.peerName) || `peer-${Date.now().toString(36)}`
221
+ let created
222
+ try {
223
+ created = addInboundToken({
224
+ home: meshHomeDir,
225
+ id: baseId,
226
+ note: `paired ${new Date().toISOString()}`,
227
+ acl: body?.acl
228
+ })
229
+ } catch {
230
+ created = addInboundToken({
231
+ home: meshHomeDir,
232
+ id: `${baseId}-${Math.random().toString(36).slice(2, 6)}`,
233
+ note: `paired ${new Date().toISOString()}`,
234
+ acl: body?.acl
235
+ })
236
+ }
237
+ await audit({ peer: created.id, tool: 'pair', ok: true, args: ['code', 'peerName'] })
238
+ const scheme = tlsCfg?.enabled === true ? 'https' : 'http'
239
+ const address = httpServer.address()
240
+ const actualPort = typeof address === 'object' && address ? address.port : listenPort
241
+ sendJson(res, 200, {
242
+ ok: true,
243
+ token: created.token,
244
+ tokenId: created.id,
245
+ node: liveConfig.node?.name || 'local',
246
+ version: MESH_VERSION,
247
+ url: `${scheme}://${listenBind}:${actualPort}${mcpPath}`,
248
+ certFingerprint: tlsCfg?.enabled === true ? describeCert(meshHomeDir)?.fingerprint256 ?? null : null,
249
+ tools: toolNames(ctx).length
250
+ })
251
+ return
252
+ }
253
+
254
+ if (pathname !== mcpPath) {
255
+ sendJson(res, 404, { code: 'E_NOT_FOUND', message: `no route for ${pathname}`, mcpEndpoint: mcpPath })
256
+ return
257
+ }
258
+
259
+ const secretsNow = getSecrets()
260
+ const tokenId = verifyBearer(req, secretsNow)
261
+ if (!tokenId) {
262
+ sendJson(
263
+ res,
264
+ 401,
265
+ { code: 'E_AUTH', message: 'missing or invalid bearer token' },
266
+ { 'www-authenticate': 'Bearer realm="dsh-mesh"' }
267
+ )
268
+ return
269
+ }
270
+ const peerAcl = peerAclForToken(secretsNow, tokenId)
271
+
272
+ calls += 1
273
+ const started = Date.now()
274
+ res.on('close', () => {
275
+ log?.debug?.(`[dsh-mesh] ${req.method} ${pathname} token=${tokenId} ${res.statusCode} ${Date.now() - started}ms`)
276
+ })
277
+
278
+ try {
279
+ await requestScope.run({ tokenId, peerAcl }, () => nodeHandler(req, res))
280
+ } catch (error) {
281
+ log?.error?.(`[dsh-mesh] transport failure: ${error?.message || error}`)
282
+ if (!res.headersSent) sendJson(res, 500, { code: 'E_INTERNAL', message: 'transport failure' })
283
+ }
284
+ }
285
+
286
+ const httpServer =
287
+ tlsCfg?.enabled === true
288
+ ? createHttpsServer(loadTlsMaterial({ home: meshHomeDir, cert: tlsCfg.cert, key: tlsCfg.key }), requestListener)
289
+ : createHttpServer(requestListener)
290
+
291
+ await new Promise((resolvePromise, rejectPromise) => {
292
+ httpServer.once('error', rejectPromise)
293
+ httpServer.listen(listenPort, listenBind, () => {
294
+ httpServer.off('error', rejectPromise)
295
+ resolvePromise()
296
+ })
297
+ })
298
+
299
+ const address = httpServer.address()
300
+ const actualPort = typeof address === 'object' && address ? address.port : listenPort
301
+ const scheme = tlsCfg?.enabled === true ? 'https' : 'http'
302
+ const url = `${scheme}://${listenBind}:${actualPort}${mcpPath}`
303
+
304
+ return {
305
+ port: actualPort,
306
+ url,
307
+ httpServer,
308
+ ctx,
309
+ auditPath: auditPath(meshHomeDir),
310
+ get requestCount() {
311
+ return calls
312
+ },
313
+ close() {
314
+ return new Promise((resolvePromise) => httpServer.close(() => resolvePromise()))
315
+ }
316
+ }
317
+ }
@@ -0,0 +1,80 @@
1
+ // @generated 2026-10-27 P2:mesh_alert(查看/清除本机「当前告警」指针)
2
+ // [why] 告警写进 <mesh home>/ALERT.json 后,只能本机跑 `mesh-health --clear-alert` 清;
3
+ // 加一个 mesh 工具就能远程清(例如家里/peer-x 上残留的旧告警),历史 alerts.jsonl 默认保留。
4
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { z } from 'zod'
7
+ import { fail, ok } from '../core/result.js'
8
+
9
+ function alertPaths(home) {
10
+ return { state: join(home, 'ALERT.json'), history: join(home, 'alerts.jsonl') }
11
+ }
12
+
13
+ function readLatest(state) {
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(state, 'utf8'))
16
+ return parsed && parsed.at ? parsed : null
17
+ } catch {
18
+ return null
19
+ }
20
+ }
21
+
22
+ export function buildAlerts(ctx) {
23
+ return [
24
+ {
25
+ name: 'mesh_alert',
26
+ title: 'Inspect or clear this node active alert',
27
+ description:
28
+ 'Read the latest alert (ALERT.json) plus the last 10 entries of alerts.jsonl, or clear the "active alert" pointer so mesh-health stops showing it (history is kept unless keepHistory:false). status is read-only; clear requires confirm:true. Use it after verifying a recovered incident.',
29
+ inputSchema: z.object({
30
+ op: z.enum(['status', 'clear']).describe('status = read; clear = reset the active-alert pointer'),
31
+ confirm: z.boolean().optional().describe('clear requires true'),
32
+ keepHistory: z.boolean().optional().describe('clear: keep alerts.jsonl history (default true; false truncates it)')
33
+ }),
34
+ handler: async ({ op, confirm, keepHistory }) => {
35
+ const { state, history } = alertPaths(ctx.home)
36
+ if (op === 'status') {
37
+ let recent = []
38
+ try {
39
+ recent = readFileSync(history, 'utf8')
40
+ .trim()
41
+ .split('\n')
42
+ .filter(Boolean)
43
+ .slice(-10)
44
+ .map((line) => {
45
+ try {
46
+ return JSON.parse(line)
47
+ } catch {
48
+ return { raw: line.slice(0, 200) }
49
+ }
50
+ })
51
+ } catch {
52
+ /* no history yet */
53
+ }
54
+ return ok({ latest: readLatest(state), recentCount: recent.length, recent, paths: { state, history } })
55
+ }
56
+ if (op === 'clear') {
57
+ if (confirm !== true) return fail('E_INVALID_ARG', 'clear requires confirm:true')
58
+ const previous = readLatest(state)
59
+ try {
60
+ mkdirSync(ctx.home, { recursive: true })
61
+ writeFileSync(state, '{}\n', 'utf8')
62
+ } catch (error) {
63
+ return fail('E_WRITE', `cannot clear active alert: ${error?.message || error}`)
64
+ }
65
+ let historyTruncated = false
66
+ if (keepHistory === false) {
67
+ try {
68
+ writeFileSync(history, '', 'utf8')
69
+ historyTruncated = true
70
+ } catch {
71
+ /* keep history on failure */
72
+ }
73
+ }
74
+ return ok({ cleared: Boolean(previous), previous, keptHistory: !historyTruncated, historyTruncated })
75
+ }
76
+ return fail('E_INVALID_ARG', 'op must be status|clear')
77
+ }
78
+ }
79
+ ]
80
+ }
@@ -0,0 +1,253 @@
1
+ import { z } from 'zod'
2
+ import { DshWebClient, DshError, assistantText, summarizeRecord } from '../core/ds-web.js'
3
+ import { saveSecrets } from '../core/config.js'
4
+ import { fail, invalidArg, notFound, ok } from '../core/result.js'
5
+
6
+ /**
7
+ * M3 channel (route A): talk to a *local* DSH web instance's Remote API so a peer AI
8
+ * can put a message into a running session and read the reply back.
9
+ *
10
+ * Route B (a DSH plugin calling `inbox.append`) needs an installed plugin and is not
11
+ * implemented yet; when it lands these tools keep the same surface.
12
+ */
13
+
14
+ function meshHome(ctx) {
15
+ return ctx.home
16
+ }
17
+
18
+ function targets(ctx) {
19
+ const config = ctx.getConfig()
20
+ const configured = (config.dsh?.targets || []).filter((t) => t && t.id && t.baseUrl)
21
+ return configured.map((t) => ({ id: t.id, baseUrl: t.baseUrl, note: t.note }))
22
+ }
23
+
24
+ function secretsOf(ctx) {
25
+ try {
26
+ return ctx.getSecrets() || {}
27
+ } catch {
28
+ return {}
29
+ }
30
+ }
31
+
32
+ /** Persist the cookie so a restart of the mesh node does not need a new launch URL. */
33
+ function rememberCookie(ctx, { targetId, baseUrl, cookie }) {
34
+ const secrets = secretsOf(ctx)
35
+ secrets.dshWeb = { ...(secrets.dshWeb || {}) }
36
+ secrets.dshWeb[targetId] = { baseUrl, cookie, savedAt: new Date().toISOString() }
37
+ saveSecrets(secrets, meshHome(ctx))
38
+ }
39
+
40
+ function targetInfo(ctx) {
41
+ const secrets = secretsOf(ctx)
42
+ const stored = secrets.dshWeb || {}
43
+ return targets(ctx).map((t) => {
44
+ const saved = stored[t.id]
45
+ return {
46
+ id: t.id,
47
+ baseUrl: t.baseUrl,
48
+ note: t.note,
49
+ hasCookie: Boolean(saved?.cookie),
50
+ savedAt: saved?.savedAt
51
+ }
52
+ })
53
+ }
54
+
55
+ /** Build a client for one configured target, reusing the persisted cookie. */
56
+ function clientFor(ctx, targetId) {
57
+ const secrets = secretsOf(ctx)
58
+ const saved = (secrets.dshWeb || {})[targetId]
59
+ const configured = targets(ctx).find((t) => t.id === targetId)
60
+ if (!configured && !saved) {
61
+ return {
62
+ error: notFound(`unknown channel target: ${targetId}`, {
63
+ targets: targetInfo(ctx).map((t) => t.id),
64
+ hint: 'add dsh.targets[] to config.json or call channel_login with a launch URL'
65
+ })
66
+ }
67
+ }
68
+ const baseUrl = configured?.baseUrl || saved?.baseUrl
69
+ const client = new DshWebClient({ baseUrl, cookie: saved?.cookie, log: ctx.log })
70
+ return { client, baseUrl, targetId }
71
+ }
72
+
73
+ function describeError(error) {
74
+ if (error instanceof DshError) {
75
+ return fail(error.code, error.message.replace(/^[A-Z_]+: /, ''), {
76
+ retryable: error.code === 'E_DSH_UNREACHABLE' || error.code === 'E_TIMEOUT',
77
+ ...error.extra
78
+ })
79
+ }
80
+ return fail('E_DSH_UNEXPECTED', String(error?.message || error))
81
+ }
82
+
83
+ function autoTargetId(baseUrl) {
84
+ try {
85
+ const url = new URL(baseUrl)
86
+ const port = url.port || (url.protocol === 'https:' ? '443' : '80')
87
+ return `dsh-${port}`
88
+ } catch {
89
+ return 'dsh-local'
90
+ }
91
+ }
92
+
93
+ export function buildChannel(ctx) {
94
+ const limits = ctx.getConfig().limits || {}
95
+ const defaultWaitMs = limits.channelWaitMs ?? 180000
96
+ const defaultPollMs = limits.channelPollMs ?? 800
97
+ const defaultMessages = limits.channelMaxMessages ?? 60
98
+
99
+ return [
100
+ {
101
+ name: 'channel_targets',
102
+ title: 'List DSH channel targets',
103
+ description:
104
+ 'List the local DSH instances this mesh node can talk to (config.dsh.targets plus targets saved by channel_login), and whether a session cookie is already stored. Read-only.',
105
+ inputSchema: z.object({}),
106
+ handler: async () => ok({ targets: targetInfo(ctx), hint: 'channel_login <launchUrl> to (re)authenticate' })
107
+ },
108
+ {
109
+ name: 'channel_login',
110
+ title: 'Log in to a local DSH instance',
111
+ description:
112
+ 'Exchange a `dsh web` launch URL (the one printed on startup, containing ?token=) for a session cookie, then persist it in secrets.json. The launch token is single-use; the cookie survives DSH restarts. Returns the target id and how many sessions exist.',
113
+ inputSchema: z.object({
114
+ launchUrl: z.string().describe('Full launch URL, e.g. http://127.0.0.1:3080/?token=...'),
115
+ target: z.string().optional().describe('Target id to store under (defaults to dsh-<port>)')
116
+ }),
117
+ handler: async (args) => {
118
+ try {
119
+ const client = new DshWebClient({ log: ctx.log })
120
+ const { baseUrl } = await client.login(args.launchUrl)
121
+ const targetId = args.target || autoTargetId(baseUrl)
122
+ const list = await client.listSessions()
123
+ rememberCookie(ctx, { targetId, baseUrl, cookie: client.cookie })
124
+ return ok({
125
+ target: targetId,
126
+ baseUrl,
127
+ sessions: (list?.items || []).length,
128
+ stored: '~/.dsh-mesh/secrets.json (0600, never in git)',
129
+ next: `channel_send({ target: "${targetId}", text: "..." })`
130
+ })
131
+ } catch (error) {
132
+ return describeError(error)
133
+ }
134
+ }
135
+ },
136
+ {
137
+ name: 'channel_sessions',
138
+ title: 'List sessions on a DSH target',
139
+ description:
140
+ 'List sessions of one local DSH instance: sessionId, title, state (working|idle|ended), running, agentAvailable, cwd (workspace) and updatedAt. Requires a channel_login cookie for that target; host_sessions is the cookie-free alternative. Use this before channel_send to find the session you want to talk to, or omit sessionId there to open a new one.',
141
+ inputSchema: z.object({
142
+ target: z.string().describe('Target id from channel_targets'),
143
+ limit: z.number().int().min(1).max(200).optional()
144
+ }),
145
+ handler: async (args) => {
146
+ const built = clientFor(ctx, args.target)
147
+ if (built.error) return built.error
148
+ try {
149
+ const value = await built.client.listSessions()
150
+ const items = (value?.items || []).slice(0, args.limit ?? 50).map((item) => ({
151
+ sessionId: item.sessionId || item.id,
152
+ title: item.title || item.projections?.values?.title || '',
153
+ state: item.running ? 'working' : item.agentAvailable ? 'idle' : 'ended',
154
+ status: item.status,
155
+ running: item.running === true,
156
+ agentAvailable: item.agentAvailable === true,
157
+ cwd: item.cwd || '',
158
+ updatedAt: item.updatedAt || item.modifiedAt || item.createdAt
159
+ }))
160
+ return ok({ target: args.target, baseUrl: built.baseUrl, count: items.length, sessions: items })
161
+ } catch (error) {
162
+ return describeError(error)
163
+ }
164
+ }
165
+ },
166
+ {
167
+ name: 'channel_page',
168
+ title: 'Read recent events of a DSH session',
169
+ description:
170
+ 'Read the last N events of one session (user messages, assistant messages, turn boundaries) as a compact list. Useful to catch up on a conversation without waiting for a turn.',
171
+ inputSchema: z.object({
172
+ target: z.string().describe('Target id from channel_targets'),
173
+ sessionId: z.string(),
174
+ maxMessages: z.number().int().min(1).max(200).optional()
175
+ }),
176
+ handler: async (args) => {
177
+ const built = clientFor(ctx, args.target)
178
+ if (built.error) return built.error
179
+ try {
180
+ const page = await built.client.page({
181
+ sessionId: args.sessionId,
182
+ maxMessages: args.maxMessages ?? defaultMessages
183
+ })
184
+ return ok({
185
+ target: args.target,
186
+ sessionId: args.sessionId,
187
+ hasMore: page.hasMore,
188
+ events: page.records.map(summarizeRecord),
189
+ assistantText: assistantText(page.records).slice(-4000)
190
+ })
191
+ } catch (error) {
192
+ return describeError(error)
193
+ }
194
+ }
195
+ },
196
+ {
197
+ name: 'channel_send',
198
+ title: 'Send a message to a DSH session and wait for the reply',
199
+ description:
200
+ 'Put a text message into a local DSH session (creating a fresh session when sessionId is omitted) and wait until that turn ends, then return the assistant reply. This is how a peer AI wakes the other side up instead of only reading files. Opt-in tool: enable it in acl.allow.',
201
+ inputSchema: z.object({
202
+ target: z.string().describe('Target id from channel_targets'),
203
+ text: z.string().min(1).describe('Message to send, as if typed by the user'),
204
+ sessionId: z.string().optional().describe('Existing session; omit to open a new one'),
205
+ mode: z.enum(['queue', 'steer']).optional().describe('queue = after current turn, steer = interrupt'),
206
+ waitMs: z.number().int().min(1000).max(1800000).optional()
207
+ }),
208
+ handler: async (args, { signal } = {}) => {
209
+ const built = clientFor(ctx, args.target)
210
+ if (built.error) return built.error
211
+ try {
212
+ const sessionId = args.sessionId || (await built.client.createSession())
213
+ if (signal?.aborted) throw new DshError('E_ABORTED', 'caller aborted before prompt')
214
+ const result = await built.client.sendAndWait({
215
+ sessionId,
216
+ text: args.text,
217
+ mode: args.mode || 'queue',
218
+ waitMs: args.waitMs ?? defaultWaitMs,
219
+ pollMs: defaultPollMs
220
+ })
221
+ return ok({
222
+ target: args.target,
223
+ created: !args.sessionId,
224
+ sessionId: result.sessionId,
225
+ requestId: result.requestId,
226
+ turnEnd: result.turnEnd,
227
+ reply: result.reply,
228
+ events: result.events,
229
+ ms: result.ms
230
+ })
231
+ } catch (error) {
232
+ return describeError(error)
233
+ }
234
+ }
235
+ },
236
+ {
237
+ name: 'channel_cancel',
238
+ title: 'Cancel the running turn of a DSH session',
239
+ description: 'Ask one session to abort its current turn. Opt-in tool: enable it in acl.allow.',
240
+ inputSchema: z.object({ target: z.string(), sessionId: z.string() }),
241
+ handler: async (args) => {
242
+ const built = clientFor(ctx, args.target)
243
+ if (built.error) return built.error
244
+ try {
245
+ await built.client.cancel({ sessionId: args.sessionId })
246
+ return ok({ target: args.target, sessionId: args.sessionId, cancelled: true })
247
+ } catch (error) {
248
+ return describeError(error)
249
+ }
250
+ }
251
+ }
252
+ ]
253
+ }