dsh-mesh-node 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.
Files changed (53) 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 +340 -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/session-profile.js +146 -0
  22. package/src/core/tls.js +160 -0
  23. package/src/core/util.js +68 -0
  24. package/src/e2e-autostart.js +58 -0
  25. package/src/e2e-channel.js +384 -0
  26. package/src/e2e-cluster.js +417 -0
  27. package/src/e2e-host.js +411 -0
  28. package/src/e2e-onboard.js +232 -0
  29. package/src/e2e-pair.js +317 -0
  30. package/src/e2e-plugin.js +395 -0
  31. package/src/e2e-repair.js +297 -0
  32. package/src/index.js +20 -0
  33. package/src/peer-bridge.js +124 -0
  34. package/src/selftest.js +872 -0
  35. package/src/server.js +313 -0
  36. package/src/tools/alerts.js +80 -0
  37. package/src/tools/channel.js +253 -0
  38. package/src/tools/cluster.js +235 -0
  39. package/src/tools/config-patch.js +182 -0
  40. package/src/tools/control-apps.js +469 -0
  41. package/src/tools/control-path.js +160 -0
  42. package/src/tools/control.js +232 -0
  43. package/src/tools/core.js +79 -0
  44. package/src/tools/delegate.js +653 -0
  45. package/src/tools/fs.js +436 -0
  46. package/src/tools/git.js +266 -0
  47. package/src/tools/host.js +305 -0
  48. package/src/tools/index.js +122 -0
  49. package/src/tools/repair-fs.js +130 -0
  50. package/src/tools/repair.js +317 -0
  51. package/src/tools/session-history.js +60 -0
  52. package/src/tools/shell.js +66 -0
  53. package/src/tools/tasks.js +117 -0
package/src/server.js ADDED
@@ -0,0 +1,313 @@
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
+
13
+ const NODE_STARTED_AT = new Date().toISOString()
14
+
15
+ const INSTRUCTIONS = `This endpoint is a dsh-mesh node: another developer machine exposed over the Model Context Protocol.
16
+ Call mesh_self first to learn what the peer can do and which tools your token may use.
17
+ All paths are paths on the PEER machine, not on yours.
18
+ Long operations: use task_submit (with an idem key) then poll task_status, instead of risking a call timeout.
19
+ shell_run is opt-in and normally refused.`
20
+
21
+ /** Carries the authenticated token id down into the per-request MCP server factory. */
22
+ const requestScope = new AsyncLocalStorage()
23
+
24
+ export function buildMcpServer({ ctx, log = console }) {
25
+ const config = ctx.getConfig()
26
+ const server = new McpServer(
27
+ { name: `dsh-mesh/${config.node?.name || 'local'}`, version: MESH_VERSION },
28
+ { instructions: INSTRUCTIONS }
29
+ )
30
+ registerTools(server, ctx)
31
+ return server
32
+ }
33
+
34
+ function sendJson(res, status, body, extraHeaders = {}) {
35
+ const text = JSON.stringify(body)
36
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...extraHeaders })
37
+ res.end(text)
38
+ }
39
+
40
+ /** Read a small JSON body (pairing requests are tiny; anything huge is a mistake). */
41
+ function readJsonBody(req, limitBytes = 8192) {
42
+ return new Promise((resolvePromise, rejectPromise) => {
43
+ let size = 0
44
+ const chunks = []
45
+ req.on('data', (chunk) => {
46
+ size += chunk.length
47
+ if (size > limitBytes) {
48
+ rejectPromise(new Error('body too large'))
49
+ req.destroy()
50
+ return
51
+ }
52
+ chunks.push(chunk)
53
+ })
54
+ req.on('end', () => {
55
+ const text = Buffer.concat(chunks).toString('utf8').replace(/^\uFEFF/, '').trim()
56
+ if (text === '') return resolvePromise({})
57
+ try {
58
+ resolvePromise(JSON.parse(text))
59
+ } catch {
60
+ rejectPromise(new Error('body is not valid JSON'))
61
+ }
62
+ })
63
+ req.on('error', rejectPromise)
64
+ })
65
+ }
66
+
67
+ /** Peer ids double as token ids; keep them filename/log friendly. */
68
+ function sanitizePeerId(raw) {
69
+ const id = String(raw || '')
70
+ .trim()
71
+ .replace(/[^A-Za-z0-9._-]/g, '-')
72
+ .replace(/-+/g, '-')
73
+ .slice(0, 48)
74
+ return id === '' ? null : id
75
+ }
76
+
77
+ /**
78
+ * Start the mesh HTTP endpoint.
79
+ * @param {object} opts
80
+ * @param {object} [opts.config] Fixed config (tests). Omit to load and hot-reload from disk.
81
+ * @param {object} opts.secrets Inbound tokens.
82
+ * @param {string} [opts.home] Mesh home directory (defaults to ~/.dsh-mesh).
83
+ */
84
+ export async function startMeshServer({ config, secrets, log = console, port, bind, tls, home } = {}) {
85
+ const meshHomeDir = home || meshHome()
86
+ const getConfig = config ? () => config : createConfigLoader({ home: meshHomeDir, log })
87
+ // Secrets hot-reload too: rotating a token takes effect without a restart.
88
+ const getSecrets = secrets ? () => secrets : createSecretsLoader({ home: meshHomeDir, log })
89
+ const audit = makeAudit({ home: meshHomeDir, log })
90
+
91
+ const initial = getConfig()
92
+ const jobs = new JobRunner({
93
+ home: meshHomeDir,
94
+ audit,
95
+ log,
96
+ limits: initial.limits || {},
97
+ exec: (tool, args, opts) => dispatch(ctx, tool, args, opts)
98
+ })
99
+
100
+ // Shared context. `peerId` is filled per request from the verified bearer token.
101
+ const ctx = {
102
+ home: meshHomeDir,
103
+ secrets,
104
+ getSecrets,
105
+ log,
106
+ audit,
107
+ jobs,
108
+ getConfig,
109
+ isAllowed,
110
+ toolNames: () => toolNames(ctx),
111
+ dispatch: (name, args, opts) => dispatch(ctx, name, args, opts)
112
+ }
113
+
114
+ const nodeCfg = initial.node || {}
115
+ const listenPort = port ?? nodeCfg.port ?? 8124
116
+ const listenBind = bind ?? nodeCfg.bind ?? '127.0.0.1'
117
+ const mcpPath = nodeCfg.path || '/mcp'
118
+ const tlsCfg = tls ?? nodeCfg.tls ?? { enabled: false }
119
+
120
+ let calls = 0
121
+ const mcpHandler = createMcpHandler(
122
+ () => {
123
+ const scope = requestScope.getStore()
124
+ const peerAcl = scope?.peerAcl || null
125
+ return buildMcpServer({
126
+ ctx: {
127
+ ...ctx,
128
+ peerId: scope?.tokenId,
129
+ peerAcl,
130
+ getConfig: () => applyPeerAcl(getConfig(), peerAcl)
131
+ },
132
+ log
133
+ })
134
+ },
135
+ { onerror: (error) => log?.error?.(`[dsh-mesh] handler error: ${error?.message || error}`) }
136
+ )
137
+ const nodeHandler = toNodeHandler(mcpHandler)
138
+
139
+ const requestListener = async (req, res) => {
140
+ let pathname = '/'
141
+ try {
142
+ pathname = new URL(req.url, 'http://mesh.local').pathname
143
+ } catch {
144
+ /* malformed URL falls through to 404 */
145
+ }
146
+
147
+ if (pathname === '/health') {
148
+ const liveConfig = getConfig()
149
+ const pairingState = readPairingState(meshHomeDir)
150
+ sendJson(res, 200, {
151
+ ok: true,
152
+ service: 'dsh-mesh',
153
+ version: MESH_VERSION,
154
+ node: liveConfig.node?.name || 'local',
155
+ /** Local DSH instances this node can bridge to (cluster addressing: <node>:<instance>). */
156
+ hosts: (liveConfig.hosts || []).map((entry) => ({
157
+ id: entry.id,
158
+ url: entry.url,
159
+ enabled: entry.enabled !== false
160
+ })),
161
+ uptimeSec: Math.round((Date.now() - Date.parse(NODE_STARTED_AT)) / 1000),
162
+ pid: process.pid,
163
+ startedAt: NODE_STARTED_AT,
164
+ tools: toolNames(ctx).length,
165
+ jobs: jobs.stats(),
166
+ tls: tlsCfg?.enabled === true,
167
+ pairing: {
168
+ enabled: liveConfig.pairing?.enabled === true,
169
+ codeActive: Boolean(pairingState && !pairingState.used && !pairingState.expired),
170
+ expiresAt: pairingState?.expiresAt ?? null
171
+ },
172
+ now: new Date().toISOString()
173
+ })
174
+ return
175
+ }
176
+
177
+ // Unauthenticated by design: a peer has no token yet, it redeems a one-time code instead.
178
+ if (pathname === '/pair') {
179
+ if (req.method !== 'POST') {
180
+ sendJson(res, 405, { code: 'E_INVALID_ARG', message: 'use POST /pair' }, { allow: 'POST' })
181
+ return
182
+ }
183
+ const liveConfig = getConfig()
184
+ if (liveConfig.pairing?.enabled !== true) {
185
+ sendJson(res, 403, { code: 'E_DENIED', message: 'pairing is disabled on this node' })
186
+ return
187
+ }
188
+ let body
189
+ try {
190
+ body = await readJsonBody(req)
191
+ } catch (error) {
192
+ sendJson(res, 400, { code: 'E_INVALID_ARG', message: error?.message || 'bad body' })
193
+ return
194
+ }
195
+ const claim = claimPairingCode({
196
+ home: meshHomeDir,
197
+ code: body?.code,
198
+ maxAttempts: Number(liveConfig.pairing?.maxAttempts) || 5
199
+ })
200
+ if (!claim.ok) {
201
+ // Small constant delay so the answer time does not reveal much; never say why.
202
+ await new Promise((r) => setTimeout(r, 200))
203
+ await audit({ peer: 'pair', tool: 'pair', ok: false, code: claim.reason, args: ['code'] })
204
+ sendJson(
205
+ res,
206
+ 401,
207
+ {
208
+ code: claim.reason === 'E_MAX_ATTEMPTS' ? 'E_MAX_ATTEMPTS' : 'E_AUTH',
209
+ message: 'pairing code rejected',
210
+ retryable: claim.reason !== 'E_MAX_ATTEMPTS' && claim.reason !== 'E_CODE_USED'
211
+ },
212
+ { 'www-authenticate': 'Bearer realm="dsh-mesh"' }
213
+ )
214
+ return
215
+ }
216
+ const baseId = sanitizePeerId(body?.peerName) || `peer-${Date.now().toString(36)}`
217
+ let created
218
+ try {
219
+ created = addInboundToken({
220
+ home: meshHomeDir,
221
+ id: baseId,
222
+ note: `paired ${new Date().toISOString()}`,
223
+ acl: body?.acl
224
+ })
225
+ } catch {
226
+ created = addInboundToken({
227
+ home: meshHomeDir,
228
+ id: `${baseId}-${Math.random().toString(36).slice(2, 6)}`,
229
+ note: `paired ${new Date().toISOString()}`,
230
+ acl: body?.acl
231
+ })
232
+ }
233
+ await audit({ peer: created.id, tool: 'pair', ok: true, args: ['code', 'peerName'] })
234
+ const scheme = tlsCfg?.enabled === true ? 'https' : 'http'
235
+ const address = httpServer.address()
236
+ const actualPort = typeof address === 'object' && address ? address.port : listenPort
237
+ sendJson(res, 200, {
238
+ ok: true,
239
+ token: created.token,
240
+ tokenId: created.id,
241
+ node: liveConfig.node?.name || 'local',
242
+ version: MESH_VERSION,
243
+ url: `${scheme}://${listenBind}:${actualPort}${mcpPath}`,
244
+ certFingerprint: tlsCfg?.enabled === true ? describeCert(meshHomeDir)?.fingerprint256 ?? null : null,
245
+ tools: toolNames(ctx).length
246
+ })
247
+ return
248
+ }
249
+
250
+ if (pathname !== mcpPath) {
251
+ sendJson(res, 404, { code: 'E_NOT_FOUND', message: `no route for ${pathname}`, mcpEndpoint: mcpPath })
252
+ return
253
+ }
254
+
255
+ const secretsNow = getSecrets()
256
+ const tokenId = verifyBearer(req, secretsNow)
257
+ if (!tokenId) {
258
+ sendJson(
259
+ res,
260
+ 401,
261
+ { code: 'E_AUTH', message: 'missing or invalid bearer token' },
262
+ { 'www-authenticate': 'Bearer realm="dsh-mesh"' }
263
+ )
264
+ return
265
+ }
266
+ const peerAcl = peerAclForToken(secretsNow, tokenId)
267
+
268
+ calls += 1
269
+ const started = Date.now()
270
+ res.on('close', () => {
271
+ log?.debug?.(`[dsh-mesh] ${req.method} ${pathname} token=${tokenId} ${res.statusCode} ${Date.now() - started}ms`)
272
+ })
273
+
274
+ try {
275
+ await requestScope.run({ tokenId, peerAcl }, () => nodeHandler(req, res))
276
+ } catch (error) {
277
+ log?.error?.(`[dsh-mesh] transport failure: ${error?.message || error}`)
278
+ if (!res.headersSent) sendJson(res, 500, { code: 'E_INTERNAL', message: 'transport failure' })
279
+ }
280
+ }
281
+
282
+ const httpServer =
283
+ tlsCfg?.enabled === true
284
+ ? createHttpsServer(loadTlsMaterial({ home: meshHomeDir, cert: tlsCfg.cert, key: tlsCfg.key }), requestListener)
285
+ : createHttpServer(requestListener)
286
+
287
+ await new Promise((resolvePromise, rejectPromise) => {
288
+ httpServer.once('error', rejectPromise)
289
+ httpServer.listen(listenPort, listenBind, () => {
290
+ httpServer.off('error', rejectPromise)
291
+ resolvePromise()
292
+ })
293
+ })
294
+
295
+ const address = httpServer.address()
296
+ const actualPort = typeof address === 'object' && address ? address.port : listenPort
297
+ const scheme = tlsCfg?.enabled === true ? 'https' : 'http'
298
+ const url = `${scheme}://${listenBind}:${actualPort}${mcpPath}`
299
+
300
+ return {
301
+ port: actualPort,
302
+ url,
303
+ httpServer,
304
+ ctx,
305
+ auditPath: auditPath(meshHomeDir),
306
+ get requestCount() {
307
+ return calls
308
+ },
309
+ close() {
310
+ return new Promise((resolvePromise) => httpServer.close(() => resolvePromise()))
311
+ }
312
+ }
313
+ }
@@ -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
+ }