dsh-device-fleet 0.1.0-dev.3 → 0.1.0-dev.30

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.
@@ -0,0 +1,192 @@
1
+ // Runtime configuration (ADR-0025): the profile patch stays the base layer,
2
+ // while `$DSH_HOME/device-fleet/config-overrides.json` holds UI edits. Applying
3
+ // a patch merges it, persists it and runs the registered live-apply handlers so
4
+ // no plugin/DSH restart is required.
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
6
+ import { dirname } from 'node:path'
7
+
8
+ const READONLY_FIELDS = ['dataDir']
9
+ const CAPABILITY_MODES = ['auto', 'ask', 'deny']
10
+
11
+ function isPlainObject(value) {
12
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
13
+ }
14
+
15
+ export function deepMerge(base, patch) {
16
+ if (!isPlainObject(patch)) return patch
17
+ const result = isPlainObject(base) ? { ...base } : {}
18
+ for (const [key, value] of Object.entries(patch)) {
19
+ if (value === undefined) continue
20
+ if (Array.isArray(value)) result[key] = value.map((item) => (isPlainObject(item) ? deepMerge({}, item) : item))
21
+ else if (isPlainObject(value)) result[key] = deepMerge(result[key], value)
22
+ else result[key] = value
23
+ }
24
+ return result
25
+ }
26
+
27
+ function readJson(file) {
28
+ if (!file || !existsSync(file)) return null
29
+ try {
30
+ return JSON.parse(readFileSync(file, 'utf8'))
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ function requirePositiveInteger(value, name, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
37
+ if (!Number.isInteger(value) || value < min || value > max) {
38
+ throw Object.assign(new Error(`${name} must be an integer between ${min} and ${max}`), { code: 'bad-request' })
39
+ }
40
+ return value
41
+ }
42
+
43
+ function stringList(value, name) {
44
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
45
+ throw Object.assign(new Error(`${name} must be an array of strings`), { code: 'bad-request' })
46
+ }
47
+ return value.map((item) => item.trim()).filter((item) => item !== '')
48
+ }
49
+
50
+ function optionalString(value, name) {
51
+ if (value === undefined || value === null) return ''
52
+ if (typeof value !== 'string') throw Object.assign(new Error(`${name} must be a string`), { code: 'bad-request' })
53
+ return value.trim()
54
+ }
55
+
56
+ /**
57
+ * Validate and clean a UI patch. Only known fields survive; unknown keys are
58
+ * dropped instead of leaking arbitrary objects into the effective config.
59
+ */
60
+ export function sanitizeConfigPatch(patch) {
61
+ if (!isPlainObject(patch)) throw Object.assign(new Error('config patch must be an object'), { code: 'bad-request' })
62
+ const clean = {}
63
+ if (patch.deviceName !== undefined) {
64
+ const name = optionalString(patch.deviceName, 'deviceName')
65
+ if (name !== '') clean.deviceName = name.slice(0, 80)
66
+ }
67
+ if (patch.promptSection !== undefined) clean.promptSection = patch.promptSection === true
68
+ if (patch.autoApproveAsk !== undefined) clean.autoApproveAsk = patch.autoApproveAsk === true
69
+ if (patch.discovery !== undefined) clean.discovery = patch.discovery === true
70
+ if (patch.dataDir !== undefined) {
71
+ throw Object.assign(new Error('dataDir cannot be changed at runtime'), { code: 'readonly' })
72
+ }
73
+ if (patch.direct !== undefined) {
74
+ if (!isPlainObject(patch.direct)) throw Object.assign(new Error('direct must be an object'), { code: 'bad-request' })
75
+ clean.direct = {
76
+ host: optionalString(patch.direct.host, 'direct.host') || '0.0.0.0',
77
+ port: requirePositiveInteger(Number(patch.direct.port ?? 0), 'direct.port', { min: 0, max: 65535 }),
78
+ }
79
+ }
80
+ if (patch.relay !== undefined) {
81
+ if (patch.relay === null) clean.relay = null
82
+ else if (isPlainObject(patch.relay)) {
83
+ clean.relay = {
84
+ host: optionalString(patch.relay.host, 'relay.host'),
85
+ port: requirePositiveInteger(Number(patch.relay.port ?? 0), 'relay.port', { min: 0, max: 65535 }),
86
+ dataPort: requirePositiveInteger(Number(patch.relay.dataPort ?? 0), 'relay.dataPort', { min: 0, max: 65535 }),
87
+ reconnect: patch.relay.reconnect !== false,
88
+ }
89
+ if (clean.relay.host === '' || clean.relay.port === 0) clean.relay = null
90
+ } else {
91
+ throw Object.assign(new Error('relay must be an object or null'), { code: 'bad-request' })
92
+ }
93
+ }
94
+ if (patch.execution !== undefined) {
95
+ if (!isPlainObject(patch.execution)) throw Object.assign(new Error('execution must be an object'), { code: 'bad-request' })
96
+ const execution = {}
97
+ if (patch.execution.cwd !== undefined) execution.cwd = optionalString(patch.execution.cwd, 'execution.cwd')
98
+ if (patch.execution.agentPreset !== undefined) execution.agentPreset = optionalString(patch.execution.agentPreset, 'execution.agentPreset')
99
+ if (patch.execution.shellTool !== undefined) execution.shellTool = optionalString(patch.execution.shellTool, 'execution.shellTool')
100
+ if (patch.execution.subagentProvider !== undefined) execution.subagentProvider = optionalString(patch.execution.subagentProvider, 'execution.subagentProvider')
101
+ if (patch.execution.approvalTimeoutMs !== undefined) {
102
+ execution.approvalTimeoutMs = requirePositiveInteger(Number(patch.execution.approvalTimeoutMs), 'execution.approvalTimeoutMs', { min: 1000 })
103
+ }
104
+ if (patch.execution.delegateTimeoutMs !== undefined) {
105
+ execution.delegateTimeoutMs = requirePositiveInteger(Number(patch.execution.delegateTimeoutMs), 'execution.delegateTimeoutMs', { min: 1000 })
106
+ }
107
+ clean.execution = execution
108
+ }
109
+ if (patch.policy !== undefined) {
110
+ if (!isPlainObject(patch.policy)) throw Object.assign(new Error('policy must be an object'), { code: 'bad-request' })
111
+ const policy = {}
112
+ if (patch.policy.allowedRoots !== undefined) policy.allowedRoots = stringList(patch.policy.allowedRoots, 'policy.allowedRoots')
113
+ if (patch.policy.allowedTools !== undefined) policy.allowedTools = stringList(patch.policy.allowedTools, 'policy.allowedTools')
114
+ if (patch.policy.approvalTtlMs !== undefined) {
115
+ policy.approvalTtlMs = requirePositiveInteger(Number(patch.policy.approvalTtlMs), 'policy.approvalTtlMs', { min: 1000 })
116
+ }
117
+ if (patch.policy.capabilities !== undefined) {
118
+ if (!isPlainObject(patch.policy.capabilities)) throw Object.assign(new Error('policy.capabilities must be an object'), { code: 'bad-request' })
119
+ const capabilities = {}
120
+ for (const [key, value] of Object.entries(patch.policy.capabilities)) {
121
+ if (!CAPABILITY_MODES.includes(value)) {
122
+ throw Object.assign(new Error(`policy.capabilities.${key} must be auto|ask|deny`), { code: 'bad-request' })
123
+ }
124
+ capabilities[key] = value
125
+ }
126
+ policy.capabilities = capabilities
127
+ }
128
+ clean.policy = policy
129
+ }
130
+ if (patch.approvals !== undefined) {
131
+ if (!isPlainObject(patch.approvals)) throw Object.assign(new Error('approvals must be an object'), { code: 'bad-request' })
132
+ const approvals = {}
133
+ if (patch.approvals.ttlMs !== undefined) approvals.ttlMs = requirePositiveInteger(Number(patch.approvals.ttlMs), 'approvals.ttlMs', { min: 1000 })
134
+ if (patch.approvals.maxPending !== undefined) approvals.maxPending = requirePositiveInteger(Number(patch.approvals.maxPending), 'approvals.maxPending', { min: 1 })
135
+ if (patch.approvals.simulation !== undefined) approvals.simulation = patch.approvals.simulation === true
136
+ clean.approvals = approvals
137
+ }
138
+ return clean
139
+ }
140
+
141
+ export class RuntimeConfig {
142
+ /**
143
+ * @param {object} options
144
+ * @param {object} options.base profile (Cordis) config
145
+ * @param {string} [options.overridesFile] JSON file edited from the UI
146
+ */
147
+ constructor({ base = {}, overridesFile = null } = {}) {
148
+ this.base = base
149
+ this.overridesFile = overridesFile
150
+ this.overrides = readJson(overridesFile) ?? {}
151
+ this.current = deepMerge(this.base, this.overrides)
152
+ this.applyHandlers = []
153
+ this.updating = Promise.resolve()
154
+ }
155
+
156
+ onChange(handler) {
157
+ this.applyHandlers.push(handler)
158
+ }
159
+
160
+ persist() {
161
+ if (!this.overridesFile) return
162
+ try {
163
+ mkdirSync(dirname(this.overridesFile), { recursive: true })
164
+ const temporary = `${this.overridesFile}.tmp`
165
+ writeFileSync(temporary, JSON.stringify(this.overrides, null, 2), 'utf8')
166
+ renameSync(temporary, this.overridesFile)
167
+ } catch {
168
+ /* overrides are best effort; the in-memory config is still applied */
169
+ }
170
+ }
171
+
172
+ /** Merge, persist and live-apply one patch. Returns the effective config. */
173
+ update(patch) {
174
+ const clean = sanitizeConfigPatch(patch)
175
+ return this.updating.then(async () => {
176
+ const previous = this.current
177
+ this.overrides = deepMerge(this.overrides, clean)
178
+ this.current = deepMerge(this.base, this.overrides)
179
+ this.persist()
180
+ for (const handler of this.applyHandlers) await handler(this.current, previous)
181
+ return this.current
182
+ })
183
+ }
184
+
185
+ snapshot() {
186
+ return {
187
+ config: this.current,
188
+ overrides: this.overrides,
189
+ readonly: READONLY_FIELDS,
190
+ }
191
+ }
192
+ }
package/src/host/store.js CHANGED
@@ -50,6 +50,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
50
50
 
51
51
  const roster = readJson(path.join(dir, 'roster.json'), [])
52
52
  const config = readJson(path.join(dir, 'config.json'), {})
53
+ const peerHints = readJson(path.join(dir, 'peer-hints.json'), [])
53
54
  let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
54
55
  let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
55
56
 
@@ -75,7 +76,32 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
75
76
  }
76
77
  }
77
78
 
78
- return { dataDir: dir, identity, fleet, roster: Array.isArray(roster) ? roster : [], config, enrollment, pendingEnrollment, createdFleet }
79
+ return {
80
+ dataDir: dir,
81
+ identity,
82
+ fleet,
83
+ roster: Array.isArray(roster) ? roster : [],
84
+ config,
85
+ enrollment,
86
+ pendingEnrollment,
87
+ peerHints: Array.isArray(peerHints) ? peerHints : [],
88
+ createdFleet,
89
+ }
90
+ }
91
+
92
+ /** Persist one manually supplied peer address (from fleet_connect) for reconnects. */
93
+ export function savePeerHint(state, hint) {
94
+ if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
95
+ const entry = {
96
+ host: hint.host,
97
+ port: hint.port,
98
+ ...(typeof hint.deviceId === 'string' ? { deviceId: hint.deviceId } : {}),
99
+ }
100
+ const hints = Array.isArray(state.peerHints) ? state.peerHints : []
101
+ const exists = hints.some((item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId)
102
+ if (!exists) hints.push(entry)
103
+ state.peerHints = hints
104
+ writeJsonAtomic(path.join(state.dataDir, 'peer-hints.json'), hints)
79
105
  }
80
106
 
81
107
  /** Queue an invite for the next plugin load (the running node keeps its fleet). */
@@ -3,7 +3,7 @@
3
3
  // the raw-tool allowlist, and short-lived grants created by approvals.
4
4
  import path from 'node:path'
5
5
 
6
- export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool']
6
+ export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool', 'delegate']
7
7
 
8
8
  export const DEFAULT_POLICY = Object.freeze({
9
9
  allowedRoots: [],
@@ -13,7 +13,8 @@ export const DEFAULT_POLICY = Object.freeze({
13
13
  grep: 'auto',
14
14
  fetch: 'auto',
15
15
  run: 'ask',
16
- tool: 'deny',
16
+ tool: 'auto',
17
+ delegate: 'ask',
17
18
  },
18
19
  allowedTools: [],
19
20
  approvalTtlMs: 15 * 60 * 1000,
@@ -76,15 +77,17 @@ export class ToolPolicy {
76
77
  return { decision: 'allow', reason: 'run is allowed by policy', key: grantKey, capability: capabilityName }
77
78
  }
78
79
  if (capabilityName === 'tool') {
79
- if (!this.config.allowedTools.includes(String(tool))) {
80
- return {
81
- decision: 'deny',
82
- reason: `tool ${String(tool)} is not in the allowed tools list`,
83
- key: grantKey,
84
- capability: capabilityName,
85
- }
80
+ // Raw tools follow the normal DSH tool pipeline on this device: the
81
+ // target Session's tool pre-execute policy and approval seam decide
82
+ // whether a call runs, asks, or is rejected. `deny` disables raw tools
83
+ // entirely; `ask` adds the plugin-level approval in front of DSH.
84
+ if (mode === 'deny') {
85
+ return { decision: 'deny', reason: 'raw tool calls are disabled by policy', key: grantKey, capability: capabilityName }
86
+ }
87
+ if (mode === 'ask') {
88
+ return { decision: 'ask', reason: 'raw tool call requires local approval', key: grantKey, capability: capabilityName }
86
89
  }
87
- return { decision: 'allow', reason: 'tool is allowlisted', key: grantKey, capability: capabilityName }
90
+ return { decision: 'allow', reason: 'raw tool call follows the DSH tool pipeline', key: grantKey, capability: capabilityName }
88
91
  }
89
92
  if (mode === 'allow') return { decision: 'allow', reason: 'capability is allowed by policy', key: grantKey, capability: capabilityName }
90
93
  if (mode === 'auto') return { decision: 'allow', reason: 'capability is auto-approved', key: grantKey, capability: capabilityName }
package/src/host/tools.js CHANGED
@@ -22,8 +22,54 @@ function fail(error) {
22
22
  }
23
23
 
24
24
  /** Pure handlers; wrapped into ToolDefinitions by `createFleetToolDefinitions`. */
25
- export function createFleetHandlers({ node, client = new DirectCallClient({ node }), pairing = null }) {
25
+ export function createFleetHandlers({
26
+ node,
27
+ client = new DirectCallClient({ node }),
28
+ pairing = null,
29
+ onPeerHint = null,
30
+ approvals = null,
31
+ rooms = null,
32
+ }) {
26
33
  return {
34
+ async fleet_approvals() {
35
+ if (!approvals?.list) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
36
+ const items = approvals.list()
37
+ if (items.length === 0) return { text: '(no pending approvals)' }
38
+ const lines = items.map(
39
+ (item) =>
40
+ `- ${item.id} · ${item.fromDeviceId} · ${item.capability}${item.tool ? `/${String(item.tool)}` : ''} · ${item.expiresInMs}ms left · ${item.argsSummary}`,
41
+ )
42
+ return { text: lines.join('\n') }
43
+ },
44
+
45
+ async fleet_approve({ id, scope }) {
46
+ if (!approvals?.approve) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
47
+ try {
48
+ const result = approvals.approve(id, { scope: scope ?? 'once' })
49
+ return { text: `approved ${result.id} (${result.scope})` }
50
+ } catch (error) {
51
+ return fail(error)
52
+ }
53
+ },
54
+
55
+ async fleet_deny({ id, reason }) {
56
+ if (!approvals?.deny) return { text: 'Error: [unsupported] the approval inbox is unavailable' }
57
+ try {
58
+ const result = approvals.deny(id, reason ?? 'denied by local user')
59
+ return { text: `denied ${result.id}` }
60
+ } catch (error) {
61
+ return fail(error)
62
+ }
63
+ },
64
+
65
+ async fleet_simulate_request({ fromDeviceId, capability, tool, reason }) {
66
+ if (!approvals?.simulate) return { text: 'Error: [unsupported] approval simulation is disabled' }
67
+ try {
68
+ return { text: await approvals.simulate({ fromDeviceId, capability, tool, reason }) }
69
+ } catch (error) {
70
+ return fail(error)
71
+ }
72
+ },
27
73
  async fleet_status() {
28
74
  if (typeof node.status !== 'function') return { text: 'Error: [unsupported] status is unavailable' }
29
75
  return { text: JSON.stringify(node.status(), null, 2) }
@@ -34,6 +80,7 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
34
80
  try {
35
81
  node.addPeerHint?.({ host, port })
36
82
  const peer = await node.connectAddress(host, port)
83
+ await onPeerHint?.({ host, port })
37
84
  return { text: `connected to ${peer.deviceId} (${peer.name ?? 'device'}) at ${host}:${port}` }
38
85
  } catch (error) {
39
86
  return fail(error)
@@ -68,6 +115,123 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
68
115
  }
69
116
  },
70
117
 
118
+ async room_create(args, exec) {
119
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
120
+ try {
121
+ const room = await rooms.upgradeHost({ agent: exec?.agent, policy: args?.policy ?? {} })
122
+ return {
123
+ text: [
124
+ `room created: ${room.roomId}`,
125
+ `host: ${room.members.find((member) => member.role === 'host')?.memberId ?? '?'}`,
126
+ 'Add participants with room_add_local (simulation) or the remote invite flow later, then room_send.',
127
+ ].join('\n'),
128
+ }
129
+ } catch (error) {
130
+ return fail(error)
131
+ }
132
+ },
133
+
134
+ async room_add_local({ roomId, name, agentPreset, cwd }) {
135
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
136
+ try {
137
+ const member = await rooms.addLocalParticipant({ roomId, name, agentPreset, cwd })
138
+ return { text: `participant added: ${member.memberId} (${member.name}) session=${member.sessionId}` }
139
+ } catch (error) {
140
+ return fail(error)
141
+ }
142
+ },
143
+
144
+ async room_remove_member({ roomId, memberId, reason }) {
145
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
146
+ try {
147
+ const result = await rooms.removeMember({ roomId, memberId, reason })
148
+ return { text: `member removed: ${result.memberId}` }
149
+ } catch (error) {
150
+ return fail(error)
151
+ }
152
+ },
153
+
154
+ async room_send({ roomId, to, text }, exec) {
155
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
156
+ try {
157
+ const from = { sessionId: exec?.agent?.session?.id }
158
+ const result = await rooms.send({ roomId, from, to, text })
159
+ const woken = result.woken.length > 0 ? `, woke ${result.woken.join(', ')}` : ''
160
+ return { text: `message ${result.messageId} sent to ${result.to.join(', ')}${woken}` }
161
+ } catch (error) {
162
+ return fail(error)
163
+ }
164
+ },
165
+
166
+ async room_messages({ roomId, limit }) {
167
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
168
+ try {
169
+ const items = rooms.messages({ roomId, limit })
170
+ if (items.length === 0) return { text: '(no messages)' }
171
+ const lines = items.map(
172
+ (message) => `- [${new Date(message.createdAt).toISOString()}] ${message.fromLabel} → ${message.to.join(', ')}: ${message.text}`,
173
+ )
174
+ return { text: lines.join('\n') }
175
+ } catch (error) {
176
+ return fail(error)
177
+ }
178
+ },
179
+
180
+ async room_status({ roomId }) {
181
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
182
+ try {
183
+ return { text: JSON.stringify(rooms.status(roomId), null, 2) }
184
+ } catch (error) {
185
+ return fail(error)
186
+ }
187
+ },
188
+
189
+ async room_task_add({ roomId, title, owner, dependsOn, description }) {
190
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
191
+ try {
192
+ const task = await rooms.addTask({ roomId, title, owner, dependsOn: dependsOn ?? [], description: description ?? '' })
193
+ return { text: `task ${task.id} added for ${task.owner}: ${task.title}` }
194
+ } catch (error) {
195
+ return fail(error)
196
+ }
197
+ },
198
+
199
+ async room_task_update({ roomId, id, status, note }) {
200
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
201
+ try {
202
+ const result = await rooms.updateTask({ roomId, id, status, note })
203
+ const handed = result.handedOff.length > 0 ? `; handed off to ${result.handedOff.map((item) => item.owner).join(', ')}` : ''
204
+ return { text: `task ${result.task.id} updated (${result.task.status})${handed}` }
205
+ } catch (error) {
206
+ return fail(error)
207
+ }
208
+ },
209
+
210
+ async room_task_list({ roomId }) {
211
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
212
+ try {
213
+ const tasks = rooms.listTasks(roomId)
214
+ if (tasks.length === 0) return { text: '(no tasks)' }
215
+ return {
216
+ text: tasks
217
+ .map((task) => `- [${task.status}] ${task.title} (${task.id}) owner=${task.owner}${task.dependsOn.length ? ` deps=${task.dependsOn.join(',')}` : ''}`)
218
+ .join('\n'),
219
+ }
220
+ } catch (error) {
221
+ return fail(error)
222
+ }
223
+ },
224
+
225
+ async room_close({ roomId, reason }) {
226
+ if (!rooms) return { text: 'Error: [unsupported] rooms are unavailable' }
227
+ try {
228
+ await rooms.close({ roomId, reason: reason ?? 'closed by host' })
229
+ return { text: `room closed: ${roomId}` }
230
+ } catch (error) {
231
+ return fail(error)
232
+ }
233
+ },
234
+
71
235
  async fleet_list() {
72
236
  const lines = []
73
237
  lines.push(`this device: ${node.identity.deviceId} (${node.name})`)
@@ -109,6 +273,20 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
109
273
  }
110
274
  },
111
275
 
276
+ async fleet_delegate({ device, task, label, cwd }) {
277
+ try {
278
+ const result = await client.delegate(
279
+ device,
280
+ { task, ...(label ? { label } : {}), ...(cwd ? { cwd } : {}) },
281
+ { timeoutMs: 200_000 },
282
+ )
283
+ const meta = result?.childId ? `\n[delegated on ${device} · ${String(result.durationMs ?? 0)}ms · child ${String(result.childId)}]` : ''
284
+ return { text: `${String(result?.text ?? '')}${meta}`.trim() }
285
+ } catch (error) {
286
+ return fail(error)
287
+ }
288
+ },
289
+
112
290
  async fleet_read({ device, path, offset, limit }) {
113
291
  try {
114
292
  const args = { file_path: path }
@@ -165,6 +343,155 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
165
343
  output,
166
344
  execute: (args) => handlers.fleet_connect(args),
167
345
  }),
346
+ defineTool({
347
+ name: 'fleet_approvals',
348
+ description:
349
+ 'List pending approval requests waiting on this device (remote direct calls or delegations whose Tool Policy is `ask`). Approve or deny them with `fleet_approve` / `fleet_deny`. Approvals expire after the configured timeout and fail closed.',
350
+ parameters: {},
351
+ output,
352
+ execute: () => handlers.fleet_approvals(),
353
+ }),
354
+ defineTool({
355
+ name: 'fleet_approve',
356
+ description:
357
+ 'Approve one pending approval request on this device. `scope: "once"` allows one call; `scope: "ttl"` allows that capability for the configured TTL (default 15 minutes). Permanent grants belong in the device settings, not here.',
358
+ parameters: {
359
+ id: { type: 'string', required: true, description: 'Approval request id from `fleet_approvals`.' },
360
+ scope: { type: 'string', enum: ['once', 'ttl'], description: 'Approval scope; defaults to once.' },
361
+ },
362
+ output,
363
+ execute: (args) => handlers.fleet_approve(args),
364
+ }),
365
+ defineTool({
366
+ name: 'fleet_deny',
367
+ description: 'Deny one pending approval request on this device.',
368
+ parameters: {
369
+ id: { type: 'string', required: true, description: 'Approval request id from `fleet_approvals`.' },
370
+ reason: { type: 'string', description: 'Optional reason returned to the caller.' },
371
+ },
372
+ output,
373
+ execute: (args) => handlers.fleet_deny(args),
374
+ }),
375
+ defineTool({
376
+ name: 'fleet_simulate_request',
377
+ description:
378
+ 'Development helper (only when approval simulation is enabled): create a fake pending approval request so the inbox flow can be exercised without a second device. It does not execute anything.',
379
+ parameters: {
380
+ fromDeviceId: { type: 'string', description: 'Pretend caller device id.' },
381
+ capability: { type: 'string', description: 'Capability name, e.g. run.' },
382
+ tool: { type: 'string', description: 'Optional tool name.' },
383
+ reason: { type: 'string', description: 'Optional reason shown in the request.' },
384
+ },
385
+ output,
386
+ execute: (args) => handlers.fleet_simulate_request(args),
387
+ }),
388
+ defineTool({
389
+ name: 'room_create',
390
+ description:
391
+ 'Upgrade the current session into a Room Host (local logic layer) so multiple agents can collaborate with shared messages and a shared task board. Add participants with `room_add_local` (simulation) or the future remote invite flow. Only do this when the user explicitly wants multi-agent collaboration, not for a one-off remote tool call.',
392
+ parameters: {
393
+ policy: { type: 'object', additionalProperties: true, description: 'Optional room policy overrides (invite/join/maxMembers/boardWrite/remove).' },
394
+ },
395
+ output,
396
+ execute: (args, exec) => handlers.room_create(args, exec),
397
+ }),
398
+ defineTool({
399
+ name: 'room_add_local',
400
+ description:
401
+ 'Simulation helper: add a real local DSH agent as a Room participant (its own session). Use it to exercise addressing/wake/board logic in one DSH before the remote participant transport exists.',
402
+ parameters: {
403
+ roomId: { type: 'string', required: true, description: 'Room id returned by room_create.' },
404
+ name: { type: 'string', description: 'Participant display name.' },
405
+ agentPreset: { type: 'string', description: 'Optional agent preset id for the participant session.' },
406
+ cwd: { type: 'string', description: 'Optional working directory for the participant session.' },
407
+ },
408
+ output,
409
+ execute: (args) => handlers.room_add_local(args),
410
+ }),
411
+ defineTool({
412
+ name: 'room_remove_member',
413
+ description: 'Remove a participant from a Room (the host cannot be removed; close the room instead).',
414
+ parameters: {
415
+ roomId: { type: 'string', required: true, description: 'Room id.' },
416
+ memberId: { type: 'string', required: true, description: 'Member id from room_status.' },
417
+ reason: { type: 'string', description: 'Optional reason recorded in the room log.' },
418
+ },
419
+ output,
420
+ execute: (args) => handlers.room_remove_member(args),
421
+ }),
422
+ defineTool({
423
+ name: 'room_send',
424
+ description:
425
+ 'Send one addressed Room message as the calling agent. The host appends it to the canonical room log and wakes only the addressed participants; unaddressed members do not run. To say different things to different members, send separate messages.',
426
+ parameters: {
427
+ roomId: { type: 'string', required: true, description: 'Room id.' },
428
+ to: { type: 'array', items: { type: 'string' }, required: true, description: 'Recipient member ids.' },
429
+ text: { type: 'string', required: true, description: 'Message body.' },
430
+ },
431
+ output,
432
+ execute: (args, exec) => handlers.room_send(args, exec),
433
+ }),
434
+ defineTool({
435
+ name: 'room_messages',
436
+ description: 'Read the Room transcript (the user-visible complete conversation, including agent-to-agent messages).',
437
+ parameters: {
438
+ roomId: { type: 'string', required: true, description: 'Room id.' },
439
+ limit: { type: 'number', description: 'Maximum recent messages to return (default 50).' },
440
+ },
441
+ output,
442
+ execute: (args) => handlers.room_messages(args),
443
+ }),
444
+ defineTool({
445
+ name: 'room_status',
446
+ description: 'Show one Room\'s members, policy, message count and task count.',
447
+ parameters: { roomId: { type: 'string', required: true, description: 'Room id.' } },
448
+ output,
449
+ execute: (args) => handlers.room_status(args),
450
+ }),
451
+ defineTool({
452
+ name: 'room_task_add',
453
+ description:
454
+ 'Add one shared Room Board task. Use dependsOn to express serial handoff: when all dependencies are done, the owner is woken with a "ready" message. Parallel tasks have no dependencies and do not block each other.',
455
+ parameters: {
456
+ roomId: { type: 'string', required: true, description: 'Room id.' },
457
+ title: { type: 'string', required: true, description: 'Task title.' },
458
+ owner: { type: 'string', required: true, description: 'Owner member id.' },
459
+ dependsOn: { type: 'array', items: { type: 'string' }, description: 'Task ids that must complete first.' },
460
+ description: { type: 'string', description: 'Optional task detail.' },
461
+ },
462
+ output,
463
+ execute: (args) => handlers.room_task_add(args),
464
+ }),
465
+ defineTool({
466
+ name: 'room_task_update',
467
+ description:
468
+ 'Update one Room Board task. Marking it done triggers serial handoff: owners of pending dependent tasks are woken. Keep status/steps current when starting and finishing your block.',
469
+ parameters: {
470
+ roomId: { type: 'string', required: true, description: 'Room id.' },
471
+ id: { type: 'string', required: true, description: 'Task id.' },
472
+ status: { type: 'string', enum: ['pending', 'running', 'done', 'blocked'], description: 'New status.' },
473
+ note: { type: 'string', description: 'Optional progress note.' },
474
+ },
475
+ output,
476
+ execute: (args) => handlers.room_task_update(args),
477
+ }),
478
+ defineTool({
479
+ name: 'room_task_list',
480
+ description: 'List the shared Room Board tasks with status, owner and dependencies.',
481
+ parameters: { roomId: { type: 'string', required: true, description: 'Room id.' } },
482
+ output,
483
+ execute: (args) => handlers.room_task_list(args),
484
+ }),
485
+ defineTool({
486
+ name: 'room_close',
487
+ description: 'Close a Room (archives its canonical log); participants are no longer woken afterwards.',
488
+ parameters: {
489
+ roomId: { type: 'string', required: true, description: 'Room id.' },
490
+ reason: { type: 'string', description: 'Optional close reason.' },
491
+ },
492
+ output,
493
+ execute: (args) => handlers.room_close(args),
494
+ }),
168
495
  defineTool({
169
496
  name: 'fleet_invite',
170
497
  description:
@@ -211,6 +538,19 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
211
538
  output,
212
539
  execute: (args) => handlers.fleet_call(args),
213
540
  }),
541
+ defineTool({
542
+ name: 'fleet_delegate',
543
+ description:
544
+ 'Delegate a one-shot task to another device\'s agent and wait for its final answer. Use it when the work needs that device\'s model and tools ("let B build/check this"), not just one tool call. The delegated agent runs on the target device; it does not join a Room and its history is not shared. The target device decides whether delegation is allowed or needs local approval.',
545
+ parameters: {
546
+ device: commonDevice,
547
+ task: { type: 'string', required: true, description: 'Self-contained task instructions for the target agent.' },
548
+ label: { type: 'string', description: 'Short label shown in the target device\'s subagent list.' },
549
+ cwd: { type: 'string', description: 'Optional working directory on the target device.' },
550
+ },
551
+ output,
552
+ execute: (args) => handlers.fleet_delegate(args),
553
+ }),
214
554
  defineTool({
215
555
  name: 'fleet_read',
216
556
  description: