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
@@ -0,0 +1,266 @@
1
+ import { existsSync, readdirSync, realpathSync } from 'node:fs'
2
+ import { basename, join, resolve } from 'node:path'
3
+ import { z } from 'zod'
4
+ import { isInsideRoots } from '../core/config.js'
5
+ import { clip, run } from '../core/util.js'
6
+ import { denied, invalidArg, notFound, ok } from '../core/result.js'
7
+
8
+ function guard(path, config) {
9
+ const abs = resolve(path)
10
+ if (!isInsideRoots(abs, config.acl?.fsRoots)) {
11
+ return { error: denied(`path is outside the allowed fsRoots: ${abs}`, { fsRoots: config.acl?.fsRoots }) }
12
+ }
13
+ if (!existsSync(abs)) return { error: notFound(`path not found: ${abs}`) }
14
+ return { abs }
15
+ }
16
+
17
+ async function repoRootOf(abs, { timeout, signal } = {}) {
18
+ const top = await run('git', ['-C', abs, 'rev-parse', '--show-toplevel'], { timeout, signal })
19
+ if (top.error || !top.stdout.trim()) return { error: top }
20
+ const root = top.stdout.trim()
21
+ try {
22
+ return { root: realpathSync(root) }
23
+ } catch {
24
+ return { root }
25
+ }
26
+ }
27
+
28
+ function countDirty(entries) {
29
+ const counts = { staged: 0, modified: 0, untracked: 0, unmerged: 0 }
30
+ for (const e of entries) {
31
+ const x = e[0]
32
+ const y = e[1]
33
+ if (x === '?' && y === '?') counts.untracked += 1
34
+ else if ('UAD'.includes(x) || 'UAD'.includes(y)) counts.unmerged += 1
35
+ else if (x === ' ' && y !== ' ') counts.modified += 1
36
+ else if (x !== ' ' && y === ' ') counts.staged += 1
37
+ else if (x !== ' ' && y !== ' ') {
38
+ counts.staged += 1
39
+ counts.modified += 1
40
+ }
41
+ }
42
+ return counts
43
+ }
44
+
45
+ async function repoSummary(abs, { timeout, signal, entries = 200 } = {}) {
46
+ const found = await repoRootOf(abs, { timeout, signal })
47
+ if (found.error) return null
48
+ const root = found.root
49
+ const [branch, head, upstream, status] = await Promise.all([
50
+ run('git', ['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD'], { timeout, signal }),
51
+ run('git', ['-C', root, 'rev-parse', 'HEAD'], { timeout, signal }),
52
+ run('git', ['-C', root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], { timeout, signal }),
53
+ run('git', ['-C', root, 'status', '--porcelain=v1', '-b', '--untracked-files=normal'], { timeout, signal })
54
+ ])
55
+ let ahead = null
56
+ let behind = null
57
+ if (!upstream.error) {
58
+ const counts = await run('git', ['-C', root, 'rev-list', '--left-right', '--count', '@{u}...HEAD'], {
59
+ timeout,
60
+ signal
61
+ })
62
+ if (!counts.error) {
63
+ const [b, a] = counts.stdout.trim().split(/\s+/).map((n) => Number.parseInt(n, 10))
64
+ behind = Number.isFinite(b) ? b : null
65
+ ahead = Number.isFinite(a) ? a : null
66
+ }
67
+ }
68
+ const lines = status.stdout.split(/\r?\n/).filter(Boolean)
69
+ const branchLine = lines.find((l) => l.startsWith('## ')) || ''
70
+ const statusEntries = lines.filter((l) => !l.startsWith('## '))
71
+ return {
72
+ root,
73
+ name: basename(root),
74
+ branch: branch.error ? null : branch.stdout.trim(),
75
+ head: head.error ? null : head.stdout.trim(),
76
+ upstream: upstream.error ? null : upstream.stdout.trim(),
77
+ ahead,
78
+ behind,
79
+ statusBranchLine: branchLine,
80
+ dirty: countDirty(statusEntries),
81
+ entries: statusEntries.slice(0, entries),
82
+ truncatedEntries: Math.max(0, statusEntries.length - entries)
83
+ }
84
+ }
85
+
86
+ /** Discover git repositories under a directory (bounded depth). */
87
+ function findRepos(root, { depth = 2, maxRepos = 60 } = {}) {
88
+ const found = []
89
+ const stack = [{ dir: root, level: 0 }]
90
+ while (stack.length > 0 && found.length < maxRepos) {
91
+ const { dir, level } = stack.pop()
92
+ let entries
93
+ try {
94
+ entries = readdirSync(dir, { withFileTypes: true })
95
+ } catch {
96
+ continue
97
+ }
98
+ const names = new Set(entries.map((e) => e.name))
99
+ if (names.has('.git')) {
100
+ found.push(dir)
101
+ continue
102
+ }
103
+ if (level >= depth) continue
104
+ for (const entry of entries) {
105
+ if (!entry.isDirectory()) continue
106
+ if (entry.name === 'node_modules' || entry.name === '.git' || entry.name.startsWith('.')) continue
107
+ stack.push({ dir: join(dir, entry.name), level: level + 1 })
108
+ }
109
+ }
110
+ return found
111
+ }
112
+
113
+ /** Read-only git tools: git_status / git_log / git_diff / repo_scan. */
114
+ export function buildGit(ctx) {
115
+ return [
116
+ {
117
+ name: 'git_status',
118
+ title: 'Git status of a repository on the peer machine',
119
+ description:
120
+ 'Run read-only git inspection on a repository on the peer machine: toplevel, current branch, HEAD sha, upstream, ahead/behind counts and porcelain status entries. Never mutates the repository.',
121
+ inputSchema: z.object({ path: z.string().describe('Path inside the repository.') }),
122
+ handler: async ({ path }, { signal } = {}) => {
123
+ const config = ctx.getConfig()
124
+ const g = guard(path, config)
125
+ if (g.error) return g.error
126
+ const timeout = config.limits?.gitTimeoutMs ?? 20000
127
+ const summary = await repoSummary(g.abs, { timeout, signal })
128
+ if (!summary) return invalidArg(`not a git repository: ${g.abs}`)
129
+ return ok(summary)
130
+ }
131
+ },
132
+ {
133
+ name: 'git_log',
134
+ title: 'Recent commits of a repository on the peer machine',
135
+ description:
136
+ 'Return the most recent commits of a repository on the peer machine (sha, author, ISO date, subject). Read-only.',
137
+ inputSchema: z.object({
138
+ path: z.string().describe('Path inside the repository.'),
139
+ limit: z.number().int().min(1).max(200).optional().describe('How many commits (default 20).'),
140
+ ref: z.string().optional().describe('Optional revision range, e.g. "origin/main..HEAD".'),
141
+ format: z.enum(['oneline', 'detail']).optional()
142
+ }),
143
+ handler: async ({ path, limit, ref, format }, { signal } = {}) => {
144
+ const config = ctx.getConfig()
145
+ const g = guard(path, config)
146
+ if (g.error) return g.error
147
+ const timeout = config.limits?.gitTimeoutMs ?? 20000
148
+ const found = await repoRootOf(g.abs, { timeout, signal })
149
+ if (found.error) return invalidArg(`not a git repository: ${g.abs}`)
150
+ const n = Math.min(limit ?? 20, 200)
151
+ const args = ['-C', found.root, 'log', `-n${n}`]
152
+ if (format === 'detail') args.push('--date=iso-strict', '--pretty=format:%H%x09%h%x09%an%x09%aI%x09%s')
153
+ else args.push('--date=short', '--pretty=format:%h%x09%ad%x09%s')
154
+ if (ref) args.push(ref, '--')
155
+ const out = await run('git', args, { timeout, signal })
156
+ if (out.error) return invalidArg(`git log failed: ${out.stderr.trim().slice(0, 500)}`)
157
+ const commits = out.stdout
158
+ .split(/\r?\n/)
159
+ .filter(Boolean)
160
+ .map((line) => {
161
+ const parts = line.split('\t')
162
+ return format === 'detail'
163
+ ? { sha: parts[0], short: parts[1], author: parts[2], date: parts[3], subject: parts.slice(4).join('\t') }
164
+ : { short: parts[0], date: parts[1], subject: parts.slice(2).join('\t') }
165
+ })
166
+ return ok({ root: found.root, ref: ref || null, count: commits.length, commits })
167
+ }
168
+ },
169
+ {
170
+ name: 'git_diff',
171
+ title: 'Diff of a repository on the peer machine',
172
+ description:
173
+ 'Return the current diff of a repository on the peer machine (working tree or staged), plus --stat summary. Read-only; optionally limit to one path and/or a revision.',
174
+ inputSchema: z.object({
175
+ path: z.string().describe('Path inside the repository.'),
176
+ staged: z.boolean().optional().describe('Diff the index instead of the working tree.'),
177
+ ref: z.string().optional().describe('Optional revision, e.g. HEAD or origin/main.'),
178
+ file: z.string().optional().describe('Limit the diff to one path.'),
179
+ maxBytes: z.number().int().min(1).optional()
180
+ }),
181
+ handler: async ({ path, staged, ref, file, maxBytes }, { signal } = {}) => {
182
+ const config = ctx.getConfig()
183
+ const g = guard(path, config)
184
+ if (g.error) return g.error
185
+ const timeout = config.limits?.gitTimeoutMs ?? 20000
186
+ const found = await repoRootOf(g.abs, { timeout, signal })
187
+ if (found.error) return invalidArg(`not a git repository: ${g.abs}`)
188
+ const base = ['-C', found.root, 'diff']
189
+ if (staged) base.push('--staged')
190
+ if (ref) base.push(ref)
191
+ const pathspec = file ? ['--', file] : []
192
+ const statOut = await run('git', [...base, '--stat', ...pathspec], { timeout, signal })
193
+ const diffOut = await run('git', [...base, ...pathspec], { timeout, signal })
194
+ if (diffOut.error && diffOut.code !== 1) {
195
+ return invalidArg(`git diff failed: ${diffOut.stderr.trim().slice(0, 500)}`)
196
+ }
197
+ const budget = maxBytes ?? config.limits?.maxDiffBytes ?? 131072
198
+ const capped = clip(diffOut.stdout, budget)
199
+ return ok({
200
+ root: found.root,
201
+ staged: Boolean(staged),
202
+ ref: ref || null,
203
+ file: file || null,
204
+ stat: statOut.stdout.trim(),
205
+ bytes: capped.bytes,
206
+ truncated: capped.truncated,
207
+ diff: capped.text
208
+ })
209
+ }
210
+ },
211
+ {
212
+ name: 'repo_scan',
213
+ title: 'Scan a directory tree for git repositories',
214
+ description:
215
+ 'Find git repositories under a directory on the peer machine (bounded depth) and report each one: branch, HEAD, upstream, ahead/behind and dirty counts. Use this to answer "what is uncommitted / unpushed over here" in one call.',
216
+ inputSchema: z.object({
217
+ roots: z.array(z.string()).min(1).describe('Directories to scan (must be under fsRoots).'),
218
+ depth: z.number().int().min(1).max(4).optional().describe('Directory depth to descend (default 2).'),
219
+ maxRepos: z.number().int().min(1).max(200).optional().describe('Cap on repositories (default 60).'),
220
+ dirtyOnly: z.boolean().optional().describe('Only return repositories with uncommitted changes or ahead>0.')
221
+ }),
222
+ handler: async ({ roots, depth, maxRepos, dirtyOnly }, { signal } = {}) => {
223
+ const config = ctx.getConfig()
224
+ const timeout = config.limits?.gitTimeoutMs ?? 20000
225
+ const cap = Math.min(maxRepos ?? 60, 200)
226
+ const targets = []
227
+ for (const root of roots) {
228
+ const abs = resolve(root)
229
+ if (!isInsideRoots(abs, config.acl?.fsRoots)) {
230
+ return denied(`root is outside the allowed fsRoots: ${abs}`, { fsRoots: config.acl?.fsRoots })
231
+ }
232
+ if (!existsSync(abs)) continue
233
+ targets.push(...findRepos(abs, { depth: depth ?? 2, maxRepos: cap - targets.length }))
234
+ }
235
+ const repos = []
236
+ let index = 0
237
+ const worker = async () => {
238
+ while (index < targets.length) {
239
+ const dir = targets[index]
240
+ index += 1
241
+ const summary = await repoSummary(dir, { timeout, signal, entries: 20 })
242
+ if (!summary) continue
243
+ const dirtyCount =
244
+ summary.dirty.staged + summary.dirty.modified + summary.dirty.untracked + summary.dirty.unmerged
245
+ if (dirtyOnly && dirtyCount === 0 && !(summary.ahead > 0)) continue
246
+ repos.push(summary)
247
+ }
248
+ }
249
+ await Promise.all(Array.from({ length: Math.min(4, targets.length) }, worker))
250
+ repos.sort((a, b) => a.root.localeCompare(b.root))
251
+ const dirty = repos.filter(
252
+ (r) => r.dirty.staged + r.dirty.modified + r.dirty.untracked + r.dirty.unmerged > 0 || r.ahead > 0
253
+ )
254
+ return ok({
255
+ roots: roots.map((r) => resolve(r)),
256
+ depth: depth ?? 2,
257
+ scanned: targets.length,
258
+ repos: repos.length,
259
+ dirtyRepos: dirty.length,
260
+ dirtyOnly: Boolean(dirtyOnly),
261
+ items: repos
262
+ })
263
+ }
264
+ }
265
+ ]
266
+ }
@@ -0,0 +1,305 @@
1
+ import { z } from 'zod'
2
+ import { createHostClient, hostInstances, hostTokenPath, HostError } from '../core/host-agent.js'
3
+ import { buildSessionProfile, classifySession, sessionBlock } from '../core/session-profile.js'
4
+ import { fail, ok } from '../core/result.js'
5
+
6
+ const instanceSchema = z
7
+ .string()
8
+ .optional()
9
+ .describe(
10
+ 'Local DSH instance id from host_instances (default: the first enabled instance, usually dsh0).'
11
+ )
12
+
13
+ function hostClient(ctx, instance) {
14
+ return createHostClient({ config: ctx.getConfig(), home: ctx.home, log: ctx.log, instance })
15
+ }
16
+
17
+ /**
18
+ * Refuse to inject into a session that owns scheduled tasks (delegation
19
+ * pollution guard, 2026-10-25). Returns null when the target is safe.
20
+ */
21
+ function guardScheduleSession(ctx, sessionId, force) {
22
+ if (!sessionId || force === true) return null
23
+ const block = sessionBlock(buildSessionProfile(ctx.getConfig()), sessionId)
24
+ return block ? fail('E_SESSION_PROTECTED', block.reason, block) : null
25
+ }
26
+
27
+ function hostError(error) {
28
+ if (error instanceof HostError) {
29
+ const retryable = error.code === 'E_HOST_OFFLINE' || error.code === 'E_HOST_TIMEOUT'
30
+ return fail(error.code, error.message, { retryable, host: true })
31
+ }
32
+ return fail('E_HOST_ERROR', error?.message || String(error), { host: true })
33
+ }
34
+
35
+ /**
36
+ * Route B of the channel design: the `dsh-mesh-host` plugin inside a DSH
37
+ * instance exposes that instance's live agents and registered tools on a
38
+ * loopback endpoint. These tools let a remote peer use them without the
39
+ * instance's web cookie (route A) and without a model round-trip.
40
+ *
41
+ * A machine can run several DSH instances (the two-DSH insurance convention),
42
+ * so every tool takes `instance`; `host_instances` lists them and pings each.
43
+ */
44
+ export function buildHost(ctx) {
45
+ return [
46
+ {
47
+ name: 'host_instances',
48
+ title: 'List local DSH instances behind host bridges',
49
+ description:
50
+ 'List every configured host bridge on this machine (id, label, url, enabled) and ping each one. Use an id as the `instance` argument of host_self / host_tools / host_call / channel_agents / channel_wake. A node normally exposes two: dsh0 (3080/.dsh0) and dsh1 (3081/.dsh1).',
51
+ inputSchema: z.object({}),
52
+ handler: async () => {
53
+ try {
54
+ const config = ctx.getConfig()
55
+ const list = hostInstances(config)
56
+ const instances = await Promise.all(
57
+ list.map(async (entry) => {
58
+ let alive = false
59
+ try {
60
+ alive = await hostClient(ctx, entry.id).ping()
61
+ } catch {
62
+ /* keep the row with alive:false */
63
+ }
64
+ let tokenFile = entry.tokenFile || ''
65
+ try {
66
+ tokenFile = hostTokenPath(config, ctx.home, entry.id)
67
+ } catch {
68
+ /* keep empty tokenFile */
69
+ }
70
+ return {
71
+ id: entry.id,
72
+ label: entry.label || entry.id,
73
+ url: entry.url,
74
+ enabled: entry.enabled !== false,
75
+ alive,
76
+ tokenFile
77
+ }
78
+ })
79
+ )
80
+ return ok({ count: instances.length, instances })
81
+ } catch (error) {
82
+ return hostError(error)
83
+ }
84
+ }
85
+ },
86
+ {
87
+ name: 'host_self',
88
+ title: 'Describe the DSH instance behind a host bridge',
89
+ description:
90
+ 'Report which DSH instance is reachable through a local host bridge (hostname, pid, plugin version), how many tools it has registered, and which agent sessions are live plus their status. Start here to discover what the peer machine exposes in-process.',
91
+ inputSchema: z.object({ instance: instanceSchema }),
92
+ handler: async ({ instance }) => {
93
+ try {
94
+ return ok(await hostClient(ctx, instance).self())
95
+ } catch (error) {
96
+ return hostError(error)
97
+ }
98
+ }
99
+ },
100
+ {
101
+ name: 'host_logs',
102
+ title: 'Tail a DSH/mesh log file on this machine',
103
+ description:
104
+ 'Read the last N lines of one allow-listed log file on this machine: `<mesh home>/<name>.log` or `<mesh home>/logs/<name>.log`, plain *.log names only. Secrets (web token URLs / API keys / Bearer) are redacted by the bridge before they leave the process. Use it to explain WHY an instance died or restarted — e.g. dsh0-console.log, dsh1-console.log, dsh0-autostart.log, watchdog.log, supervisor.log. Requires dsh-mesh-host >= 0.4.0.',
105
+ inputSchema: z.object({
106
+ instance: instanceSchema,
107
+ name: z
108
+ .string()
109
+ .describe('Plain log file name, e.g. dsh0-console.log / dsh1-autostart.log / watchdog.log (no path separators).'),
110
+ lines: z.number().int().min(1).max(500).optional().describe('How many trailing lines (default 100).')
111
+ }),
112
+ handler: async ({ instance, name, lines }) => {
113
+ try {
114
+ const data = await hostClient(ctx, instance).logs({ name, lines: lines ?? 100 })
115
+ return ok(data)
116
+ } catch (error) {
117
+ return hostError(error)
118
+ }
119
+ }
120
+ },
121
+ {
122
+ name: 'host_tools',
123
+ title: 'List tools registered inside a DSH instance',
124
+ description:
125
+ 'List every tool a DSH instance has registered (name, description, JSON parameter schema). This is the in-process capability surface, unlike mesh-native tools: it includes DSH built-ins such as file/edit/shell tools and any plugin tools.',
126
+ inputSchema: z.object({
127
+ search: z.string().optional().describe('Case-insensitive substring filter on tool name or description.'),
128
+ instance: instanceSchema
129
+ }),
130
+ handler: async ({ search, instance }) => {
131
+ try {
132
+ const { tools } = await hostClient(ctx, instance).tools()
133
+ const needle = (search || '').toLowerCase()
134
+ const list = (tools || []).filter(
135
+ tool =>
136
+ !needle ||
137
+ String(tool.name || '').toLowerCase().includes(needle) ||
138
+ String(tool.description || '').toLowerCase().includes(needle)
139
+ )
140
+ return ok({ count: list.length, tools: list })
141
+ } catch (error) {
142
+ return hostError(error)
143
+ }
144
+ }
145
+ },
146
+ {
147
+ name: 'host_call',
148
+ title: 'Call a tool registered inside a DSH instance',
149
+ description:
150
+ 'Execute one tool by name inside a DSH instance and return its result. Use host_tools first to discover names/schemas. This is powerful: some host tools write files or run commands, and the host instance applies its own permission policy. Denied by default — the node ACL must allow host_call.',
151
+ inputSchema: z.object({
152
+ tool: z.string().describe('Host tool name, as returned by host_tools.'),
153
+ arguments: z.record(z.string(), z.unknown()).optional().describe('Arguments object for that tool.'),
154
+ sessionId: z
155
+ .string()
156
+ .optional()
157
+ .describe('Run on behalf of this live session (needed by session-scoped tools).'),
158
+ timeoutMs: z.number().int().min(1000).max(600000).optional(),
159
+ force: z
160
+ .boolean()
161
+ .optional()
162
+ .describe('Bypass the scheduled-session guard (default false) for write-capable tools such as schedule_create.'),
163
+ instance: instanceSchema
164
+ }),
165
+ handler: async ({ tool, arguments: args, sessionId, timeoutMs, force, instance }) => {
166
+ try {
167
+ const WRITE_TOOL = /^(schedule_(create|update|delete)|host_session_prompt|channel_wake|session_)/i
168
+ if (sessionId && WRITE_TOOL.test(String(tool))) {
169
+ const blocked = guardScheduleSession(ctx, sessionId, force)
170
+ if (blocked) return blocked
171
+ }
172
+ return ok(await hostClient(ctx, instance).callTool({ tool, args: args ?? {}, sessionId, timeoutMs }))
173
+ } catch (error) {
174
+ return hostError(error)
175
+ }
176
+ }
177
+ },
178
+ {
179
+ name: 'channel_agents',
180
+ title: 'List live DSH sessions via a host bridge',
181
+ description:
182
+ 'List the agent sessions that are live inside a DSH instance (sessionId, status idle|running, title) through the host bridge. Works for headless/TUI instances, where the web-cookie route is unavailable.',
183
+ inputSchema: z.object({
184
+ sessionId: z.string().optional().describe('Return only this session when given.'),
185
+ instance: instanceSchema
186
+ }),
187
+ handler: async ({ sessionId, instance }) => {
188
+ try {
189
+ const { agents } = await hostClient(ctx, instance).agents()
190
+ const list = sessionId ? (agents || []).filter(agent => agent.id === sessionId) : agents || []
191
+ return ok({ count: list.length, agents: list })
192
+ } catch (error) {
193
+ return hostError(error)
194
+ }
195
+ }
196
+ },
197
+ {
198
+ name: 'host_sessions',
199
+ title: 'List persistent DSH sessions via a host bridge',
200
+ description:
201
+ 'List both live and stored sessions of one local DSH instance through the host bridge: sessionId, title when available, cwd (workspace), state (working|idle|ended), running, agentAvailable, live, persisted and timestamps. Unlike channel_sessions this needs no DSH web cookie/token. Read-only.',
202
+ inputSchema: z.object({
203
+ instance: instanceSchema,
204
+ limit: z.number().int().min(1).max(200).optional(),
205
+ includeEnded: z.boolean().optional().describe('Include stored sessions with no live agent (default true).'),
206
+ workspace: z.string().max(500).optional().describe('Only sessions whose cwd contains this path.')
207
+ }),
208
+ handler: async ({ instance, limit, includeEnded, workspace }) => {
209
+ try {
210
+ const data = await hostClient(ctx, instance).sessions({ limit, includeEnded, workspace })
211
+ const profile = buildSessionProfile(ctx.getConfig())
212
+ const sessions = (data.sessions || []).map((session) => ({ ...session, ...classifySession(profile, session) }))
213
+ return ok({ ...data, sessions, protectedCount: sessions.filter((session) => session.protected).length })
214
+ } catch (error) {
215
+ return hostError(error)
216
+ }
217
+ }
218
+ },
219
+ {
220
+ name: 'host_session_create',
221
+ title: 'Create a fresh DSH session on this machine',
222
+ description:
223
+ 'Create a new DSH conversation inside one local instance through the host bridge (same path as the web UI: agent preset composition, cwd validation, default model). Returns its sessionId; use host_session_prompt or channel_wake to send the first message. Requires dsh-mesh-host >= 0.2.0. Denied by default — the node ACL must allow host_session_create.',
224
+ inputSchema: z.object({
225
+ cwd: z.string().max(1000).optional().describe('Workspace/cwd for the new session (default: the instance default).'),
226
+ agentPreset: z.string().max(200).optional().describe('Agent preset id to compose (default: instance default).'),
227
+ sessionId: z.string().optional().describe('Adopt this exact session id instead of generating one (rarely needed).'),
228
+ workspaceId: z.string().optional().describe('Attach to a registered workspace id instead of a raw cwd.'),
229
+ instance: instanceSchema
230
+ }),
231
+ handler: async ({ cwd, agentPreset, sessionId, workspaceId, instance }) => {
232
+ try {
233
+ return ok(await hostClient(ctx, instance).createSession({ cwd, agentPreset, sessionId, workspaceId }))
234
+ } catch (error) {
235
+ return hostError(error)
236
+ }
237
+ }
238
+ },
239
+ {
240
+ name: 'host_session_prompt',
241
+ title: 'Prompt a DSH session without a web cookie',
242
+ description:
243
+ 'Deliver one user message into a DSH session through the in-process sessionController (no web login, no cookie). Use host_session_create first for a fresh conversation, or host_sessions / channel_agents to find an existing one. Returns accepted=true once the agent inbox took it. Denied by default — the node ACL must allow host_session_prompt.',
244
+ inputSchema: z.object({
245
+ sessionId: z.string().describe('Target DSH session id.'),
246
+ text: z.string().min(1).describe('Prompt text.'),
247
+ mode: z.enum(['queue', 'steer']).optional().describe('queue (default) waits for the current turn; steer lands between steps.'),
248
+ force: z.boolean().optional().describe('Inject even when the target session owns scheduled tasks (default false).'),
249
+ instance: instanceSchema
250
+ }),
251
+ handler: async ({ sessionId, text, mode, force, instance }) => {
252
+ try {
253
+ const blocked = guardScheduleSession(ctx, sessionId, force)
254
+ if (blocked) return blocked
255
+ return ok(await hostClient(ctx, instance).prompt({ sessionId, text, mode }))
256
+ } catch (error) {
257
+ return hostError(error)
258
+ }
259
+ }
260
+ },
261
+ {
262
+ name: 'host_session_archive',
263
+ title: 'Archive a DSH session (cleanup)',
264
+ description:
265
+ 'Drop a session from the DSH sidebar via the in-process workspaceRegistry (DSH archive is restorable; there is no public hard-delete). Use it to clean up sessions created by delegations or probes. An archived session stays on disk and can be restored from the UI\'s archived filter. Denied by default — the node ACL must allow host_session_archive.',
266
+ inputSchema: z.object({
267
+ sessionId: z.string().describe('Session id to archive (host_sessions / channel_agents / delegate status).'),
268
+ stopActivity: z.boolean().optional().describe('Stop the session\'s running work before archiving (default: refuse if it is busy).'),
269
+ instance: instanceSchema
270
+ }),
271
+ handler: async ({ sessionId, stopActivity, instance }) => {
272
+ try {
273
+ return ok(await hostClient(ctx, instance).archiveSession({ sessionId, stopActivity }))
274
+ } catch (error) {
275
+ return hostError(error)
276
+ }
277
+ }
278
+ },
279
+ {
280
+ name: 'channel_wake',
281
+ title: 'Inject a message into a live DSH session and wake it',
282
+ description:
283
+ 'Deliver a user message into a live DSH session through the in-process inbox and wake the agent if it is idle (target next-turn | next-step). Returns the messageId and whether the agent was woken. Unlike channel_send this does not require a web cookie and it does not wait for the reply; pair it with channel_page or the reply arrives in the session itself. Denied by default.',
284
+ inputSchema: z.object({
285
+ sessionId: z.string().describe('Target live session id, from channel_agents.'),
286
+ text: z.string().min(1).describe('Message text to deliver.'),
287
+ target: z
288
+ .enum(['next-turn', 'next-step'])
289
+ .optional()
290
+ .describe('Inbox boundary: next-turn waits for the current turn, next-step lands between steps.'),
291
+ force: z.boolean().optional().describe('Inject even when the target session owns scheduled tasks (default false).'),
292
+ instance: instanceSchema
293
+ }),
294
+ handler: async ({ sessionId, text, target, force, instance }) => {
295
+ try {
296
+ const blocked = guardScheduleSession(ctx, sessionId, force)
297
+ if (blocked) return blocked
298
+ return ok(await hostClient(ctx, instance).wake({ sessionId, text, target }))
299
+ } catch (error) {
300
+ return hostError(error)
301
+ }
302
+ }
303
+ }
304
+ ]
305
+ }