dsh-device-fleet 0.1.0-dev.7 → 0.1.0-dev.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,8 @@ DeepSeek Harness 的设备联邦插件:把登录同一账号、且都安装本
7
7
  - ✅ **网络层 v1**:设备身份、邀请、端到端加密安全通道、直连(手动地址)、UDP 组播发现
8
8
  - ✅ **网络层 v2**:自建 Relay(fleetKey 鉴权的信令 + TCP 拼接数据面,不做 NAT 打洞)
9
9
  - ✅ **Host 插件 + 直调 MVP(代码完成、单测通过,待真实 DSH 安装联调)**:`fleet_list` / `fleet_tools` / `fleet_call` + `fleet_read` / `fleet_fetch` / `fleet_run`、Tool Policy、Execution Slot、审批 fail-closed
10
- - ⏳ Room / Room Board、委派、审批收件箱 UI、设备页/配对/房间 UI(下一步)
10
+ - ✅ **审批收件箱逻辑层(dev.7)**:`fleet_approvals` / `fleet_approve` / `fleet_deny`,ask 档排队、超时 fail-closed、once/ttl 授权、`fleet_simulate_request` 本机演练;Client 审批面板待做
11
+ - ⏳ Room / Room Board、委派升级 Room、审批面板/设备页 UI(下一步)
11
12
 
12
13
  ## 安装(开发版)
13
14
 
@@ -0,0 +1,31 @@
1
+ # 审批收件箱(逻辑层,dev.7)
2
+
3
+ ## 实现
4
+
5
+ - `src/host/approval-inbox.js`:`ApprovalInbox`
6
+ - `request()`:排队、60s(可配)超时 fail-closed、事件 `request/approved/denied/expired/closed`;
7
+ - `list()`:待审列表(来源设备、能力、工具、参数摘要、剩余时间);
8
+ - `approve(id, {scope})`:`once` / `ttl`(TTL 由 Tool Policy 的 `approvalTtlMs` 控制);
9
+ - `deny(id, reason)`;`close()` 时全部失败关闭。
10
+ - `DirectCallHost` / `DelegationHost` 的 `ask` 档改为等待收件箱结果(不再一律拒绝),批准后按 scope 写入 Tool Policy 授权。
11
+ - 新工具:`fleet_approvals` / `fleet_approve` / `fleet_deny`;`fleet_simulate_request`(仅 `approvals.simulation: true`)用于本机无第二台设备时演练。
12
+ - 配置:`approvals: {ttlMs, maxPending, simulation}`。
13
+
14
+ ## 已验证
15
+
16
+ 单元测试(`node test/host.test.mjs`,10/10):
17
+ - 收件箱排队/批准/拒绝/超时/关闭/上限;
18
+ - DirectCallHost 在 `run=ask` 时阻塞等待收件箱批准后才执行;
19
+ - 工具 handler 的列表/批准/拒绝/模拟。
20
+
21
+ 当前 DSH 环境内模拟(dev.7,无需第二台设备):
22
+ 1. `fleet_simulate_request` 创建一条 `run/pwsh` 请求,`fleet_approvals` 显示 `58328ms left`;
23
+ 2. `fleet_approve(id, scope="ttl")` → `approved … (ttl)`,再次 `fleet_approvals` 为 `(no pending approvals)`;
24
+ 3. 再模拟一条 `delegate` 请求,`fleet_deny` → `denied …`;
25
+ 4. 对不存在的 id 调 `fleet_approve` → `Error: [unknown-request]`。
26
+
27
+ ## 未完成
28
+
29
+ - **Client UI 审批面板/通知**(需 Client bundle):当前只能通过工具或后续 UI 应答;Host 侧已发出 `request/approved/denied/expired` 事件,面板可直接消费;
30
+ - `room` 级授权 scope(需 Room 概念落地后再加);
31
+ - 审批结果持久化审计日志(后续与 Room 事件一起做)。
@@ -39,9 +39,15 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
39
39
  - 邀请里新增 `endpoints`(创建者的局域网/热点 IPv4 + direct 端口),加入方重启后自动向这些地址回连,绕开组播发现失败;
40
40
  - 新增 `fleet_status`(deviceId/直连端口/发现/连接/roster/hints)与 `fleet_connect(host, port)`(手动直连,成功后会从握手学到对端 deviceId);
41
41
  - `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
42
- ## 0.1.0-dev.6(稳定化)
42
+ ## 0.1.0-dev.7(稳定化)
43
43
 
44
44
  - 直连端口可固定(profile `direct.port`),插件重载不再换端口;
45
45
  - 连接断开后自动重试已知地址(不再只在启动时试一次);
46
46
  - `fleet_connect` 手动地址会持久化到 `peer-hints.json`,重启后继续回连;
47
- - 新增 `fleet_delegate`(dev.5):目标设备用 `SubagentProvider` + `delivery:'caller'` 跑 agent 并返回最终结果。
47
+ - 新增 `fleet_delegate`(dev.5):目标设备用 `SubagentProvider` + `delivery:'caller'` 跑 agent 并返回最终结果。
48
+ ## 0.1.0-dev.7(审批收件箱,逻辑层)
49
+
50
+ - 新增 `ApprovalInbox`:ask 档请求排队、超时 fail-closed、approve(once/ttl)/deny、关闭时全部失败关闭;
51
+ - 直调与委派的 ask 档改为等待本机收件箱结果(不再一律拒绝);
52
+ - 新工具:`fleet_approvals` / `fleet_approve` / `fleet_deny`;`fleet_simulate_request`(仅 `approvals.simulation: true`)用于本机无第二台设备时演练流程;
53
+ - 审批面板(Client UI)与通知尚未实现,当前通过工具/后续 UI 应答。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.7",
3
+ "version": "0.1.0-dev.8",
4
4
  "description": "DeepSeek Harness device fleet: enroll devices, direct-call remote tools, shared Rooms with lazy-woken agents, and delegated subagents across devices.",
5
5
  "type": "module",
6
6
  "main": "src/plugin/index.js",
package/src/host/index.js CHANGED
@@ -4,4 +4,6 @@ export * from './tool-policy.js'
4
4
  export * from './execution-slot.js'
5
5
  export * from './direct-call.js'
6
6
  export * from './delegation.js'
7
+ export * from './approval-inbox.js'
8
+ export * from './room-service.js'
7
9
  export * from './tools.js'
@@ -0,0 +1,324 @@
1
+ // Room logic layer (ADR-0002/0005/0006/0011/0021), with local participants for
2
+ // simulation inside one DSH. The Host session log carries the authoritative
3
+ // room events; participant agents are real local DSH sessions woken with an
4
+ // addressed message, so addressing/wake/visibility and board handoff can be
5
+ // exercised before the remote transport and the client UI exist.
6
+ import { randomUUID } from 'node:crypto'
7
+
8
+ const MAX_MESSAGE_CHARS = 20_000
9
+
10
+ const DEFAULT_POLICY = Object.freeze({
11
+ invite: 'members',
12
+ join: 'auto',
13
+ maxMembers: 8,
14
+ boardWrite: 'members',
15
+ remove: 'members',
16
+ })
17
+
18
+ function hostMemberId(sessionId) {
19
+ return `host:${sessionId}`
20
+ }
21
+
22
+ function localMemberId(sessionId) {
23
+ return `local:${sessionId}`
24
+ }
25
+
26
+ export class RoomService {
27
+ /**
28
+ * @param {object} options
29
+ * @param {object} options.sessionController ctx.sessionController
30
+ * @param {(input: object) => object} [options.createUserMessage] dsh-llm helper
31
+ * @param {(now?: number) => number} [options.now]
32
+ */
33
+ constructor({ sessionController, createUserMessage = null, now = Date.now } = {}) {
34
+ this.sessionController = sessionController
35
+ this.createUserMessage = createUserMessage
36
+ this.now = now
37
+ /** @type {Map<string, any>} */
38
+ this.rooms = new Map()
39
+ }
40
+
41
+ async upgradeHost({ agent, policy = {} } = {}) {
42
+ if (!agent?.session?.id) throw Object.assign(new Error('room upgrade needs the calling agent session'), { code: 'bad-request' })
43
+ const sessionId = agent.session.id
44
+ const existing = this.rooms.get(sessionId)
45
+ if (existing) return this.summary(existing)
46
+ const memberId = hostMemberId(sessionId)
47
+ const room = {
48
+ roomId: sessionId,
49
+ createdAt: this.now(),
50
+ hostSessionId: sessionId,
51
+ policy: { ...DEFAULT_POLICY, ...policy },
52
+ members: new Map(),
53
+ messages: [],
54
+ tasks: new Map(),
55
+ closed: false,
56
+ }
57
+ room.members.set(memberId, {
58
+ memberId,
59
+ role: 'host',
60
+ kind: 'local',
61
+ sessionId,
62
+ name: agent.session.header?.title ?? 'host',
63
+ agent,
64
+ })
65
+ this.rooms.set(sessionId, room)
66
+ await this.append(room, 'room/created', { roomId: room.roomId, policy: room.policy, hostMemberId: memberId })
67
+ return this.summary(room)
68
+ }
69
+
70
+ async addLocalParticipant({ roomId, name = 'worker', agentPreset, cwd } = {}) {
71
+ const room = this.requireRoom(roomId)
72
+ if (room.members.size >= room.policy.maxMembers) {
73
+ throw Object.assign(new Error(`room member limit reached (${room.policy.maxMembers})`), { code: 'room-full' })
74
+ }
75
+ if (!this.sessionController?.create) {
76
+ throw Object.assign(new Error('participant sessions are unavailable'), { code: 'unsupported' })
77
+ }
78
+ const created = await this.sessionController.create({
79
+ ...(cwd ? { cwd } : {}),
80
+ ...(agentPreset ? { agentPreset } : {}),
81
+ })
82
+ const sessionId = created?.sessionId
83
+ if (!sessionId) throw Object.assign(new Error('could not create the participant session'), { code: 'room-participant-failed' })
84
+ const resolved = await this.sessionController.resolveAgent(sessionId)
85
+ if (!resolved?.agent) throw Object.assign(new Error('participant agent could not be resolved'), { code: 'room-participant-failed' })
86
+ const memberId = localMemberId(sessionId)
87
+ const member = { memberId, role: 'participant', kind: 'local', sessionId, name, agent: resolved.agent }
88
+ room.members.set(memberId, member)
89
+ await this.append(room, 'room/member', {
90
+ roomId: room.roomId,
91
+ action: 'added',
92
+ memberId,
93
+ role: 'participant',
94
+ name,
95
+ sessionId,
96
+ })
97
+ return this.memberSummary(member)
98
+ }
99
+
100
+ async removeMember({ roomId, memberId, reason = 'removed' } = {}) {
101
+ const room = this.requireRoom(roomId)
102
+ const member = room.members.get(memberId)
103
+ if (!member) throw Object.assign(new Error(`unknown member: ${String(memberId)}`), { code: 'unknown-member' })
104
+ if (member.role === 'host') throw Object.assign(new Error('the host cannot be removed; close the room instead'), { code: 'forbidden' })
105
+ room.members.delete(memberId)
106
+ await this.append(room, 'room/member', { roomId: room.roomId, action: 'removed', memberId, reason })
107
+ return { roomId: room.roomId, memberId }
108
+ }
109
+
110
+ /**
111
+ * Send one addressed message. The Host appends it to the canonical log and
112
+ * wakes only the addressed local members (never the sender).
113
+ */
114
+ async send({ roomId, from, to, text } = {}) {
115
+ const room = this.requireRoom(roomId)
116
+ const body = typeof text === 'string' ? text.trim() : ''
117
+ if (body === '') throw Object.assign(new Error('room message needs text'), { code: 'bad-request' })
118
+ if (body.length > MAX_MESSAGE_CHARS) throw Object.assign(new Error('room message is too long'), { code: 'bad-request' })
119
+ const recipients = Array.isArray(to) ? to.filter((id) => typeof id === 'string') : []
120
+ if (recipients.length === 0) throw Object.assign(new Error('room message needs at least one recipient'), { code: 'bad-request' })
121
+ for (const memberId of recipients) {
122
+ if (!room.members.has(memberId)) throw Object.assign(new Error(`unknown member: ${memberId}`), { code: 'unknown-member' })
123
+ }
124
+ const sender = this.resolveSender(room, from)
125
+ const message = {
126
+ id: randomUUID(),
127
+ roomId: room.roomId,
128
+ from: sender,
129
+ to: recipients,
130
+ text: body,
131
+ createdAt: this.now(),
132
+ }
133
+ room.messages.push(message)
134
+ await this.append(room, 'room/message', {
135
+ roomId: room.roomId,
136
+ messageId: message.id,
137
+ from: sender,
138
+ to: recipients,
139
+ text: body,
140
+ })
141
+ const woken = []
142
+ for (const memberId of recipients) {
143
+ if (sender.memberId === memberId) continue
144
+ const member = room.members.get(memberId)
145
+ if (await this.wake(room, member, message)) woken.push(memberId)
146
+ }
147
+ return { messageId: message.id, to: recipients, woken }
148
+ }
149
+
150
+ resolveSender(room, from) {
151
+ if (from && typeof from === 'object') {
152
+ if (typeof from.memberId === 'string') {
153
+ const member = room.members.get(from.memberId)
154
+ if (!member) throw Object.assign(new Error(`unknown sender member: ${from.memberId}`), { code: 'unknown-member' })
155
+ return { kind: 'agent', memberId: member.memberId, name: member.name ?? 'agent' }
156
+ }
157
+ if (typeof from.sessionId === 'string') {
158
+ for (const member of room.members.values()) {
159
+ if (member.sessionId === from.sessionId) {
160
+ return { kind: 'agent', memberId: member.memberId, name: member.name ?? 'agent' }
161
+ }
162
+ }
163
+ throw Object.assign(new Error(`sender session is not a room member: ${from.sessionId}`), { code: 'unknown-member' })
164
+ }
165
+ if (from.kind === 'user') return { kind: 'user', name: 'user' }
166
+ }
167
+ return { kind: 'user', name: 'user' }
168
+ }
169
+
170
+ /** Wake one local participant by injecting the addressed message. */
171
+ async wake(room, member, message) {
172
+ if (!member?.agent || typeof member.agent.followup !== 'function' || typeof this.createUserMessage !== 'function') return false
173
+ const senderLabel = message.from.kind === 'user' ? 'user' : (message.from.name ?? 'agent')
174
+ const text = [
175
+ `[Fleet Room ${room.roomId}] ${senderLabel} → you:`,
176
+ message.text,
177
+ '',
178
+ `Reply with room_send (roomId="${room.roomId}", to=["${message.from.memberId ?? 'host'}"]) when you have an answer.`,
179
+ ].join('\n')
180
+ const userMessage = this.createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
181
+ try {
182
+ member.agent.followup(userMessage)
183
+ return true
184
+ } catch {
185
+ return false
186
+ }
187
+ }
188
+
189
+ async append(room, type, data) {
190
+ const host = room.members.get(hostMemberId(room.hostSessionId))
191
+ const session = host?.agent?.session
192
+ if (session?.append) await session.append(type, data)
193
+ }
194
+
195
+ // --------------------------------------------------------------- room tasks
196
+
197
+ async addTask({ roomId, title, owner, dependsOn = [], description = '' } = {}) {
198
+ const room = this.requireRoom(roomId)
199
+ if (typeof title !== 'string' || title.trim() === '') throw Object.assign(new Error('task needs a title'), { code: 'bad-request' })
200
+ const ownerMember = room.members.get(owner)
201
+ if (!ownerMember) throw Object.assign(new Error(`unknown task owner: ${String(owner)}`), { code: 'unknown-member' })
202
+ const id = randomUUID()
203
+ const task = {
204
+ id,
205
+ roomId: room.roomId,
206
+ title: title.trim(),
207
+ description,
208
+ owner,
209
+ status: 'pending',
210
+ dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
211
+ readyNotified: false,
212
+ createdAt: this.now(),
213
+ updatedAt: this.now(),
214
+ }
215
+ room.tasks.set(id, task)
216
+ await this.append(room, 'room/task', { roomId: room.roomId, action: 'added', task })
217
+ return task
218
+ }
219
+
220
+ async updateTask({ roomId, id, status, note } = {}) {
221
+ const room = this.requireRoom(roomId)
222
+ const task = room.tasks.get(id)
223
+ if (!task) throw Object.assign(new Error(`unknown task: ${String(id)}`), { code: 'unknown-task' })
224
+ const allowed = ['pending', 'running', 'done', 'blocked']
225
+ if (status !== undefined && !allowed.includes(status)) {
226
+ throw Object.assign(new Error(`invalid task status: ${String(status)}`), { code: 'bad-request' })
227
+ }
228
+ if (status !== undefined) task.status = status
229
+ if (typeof note === 'string' && note !== '') task.note = note
230
+ task.updatedAt = this.now()
231
+ await this.append(room, 'room/task', { roomId: room.roomId, action: 'updated', task })
232
+ let handedOff = []
233
+ if (status === 'done') handedOff = await this.handOffReady(room)
234
+ return { task, handedOff }
235
+ }
236
+
237
+ /** Wake owners of pending tasks whose dependencies all completed. */
238
+ async handOffReady(room) {
239
+ const ready = []
240
+ for (const task of room.tasks.values()) {
241
+ if (task.status !== 'pending' || task.readyNotified) continue
242
+ if (task.dependsOn.length === 0) continue
243
+ const depsDone = task.dependsOn.every((dep) => room.tasks.get(dep)?.status === 'done')
244
+ if (!depsDone) continue
245
+ task.readyNotified = true
246
+ task.updatedAt = this.now()
247
+ const owner = room.members.get(task.owner)
248
+ const message = {
249
+ id: randomUUID(),
250
+ roomId: room.roomId,
251
+ from: { kind: 'user', name: 'room' },
252
+ to: [task.owner],
253
+ text: `任务 "${task.title}" 的前置任务已完成,现在可以开始。`,
254
+ createdAt: this.now(),
255
+ }
256
+ room.messages.push(message)
257
+ await this.append(room, 'room/message', { roomId: room.roomId, messageId: message.id, from: message.from, to: message.to, text: message.text })
258
+ const woken = owner ? await this.wake(room, owner, message) : false
259
+ ready.push({ taskId: task.id, owner: task.owner, woken })
260
+ }
261
+ return ready
262
+ }
263
+
264
+ listTasks(roomId) {
265
+ const room = this.requireRoom(roomId)
266
+ return [...room.tasks.values()].sort((a, b) => a.createdAt - b.createdAt)
267
+ }
268
+
269
+ // ------------------------------------------------------------------- reads
270
+
271
+ messages({ roomId, limit = 50 } = {}) {
272
+ const room = this.requireRoom(roomId)
273
+ const items = room.messages.slice(-Math.max(1, Math.min(limit, 200)))
274
+ return items.map((message) => ({
275
+ ...message,
276
+ fromLabel: message.from.kind === 'user' ? 'user' : `${message.from.name ?? 'agent'} (${message.from.memberId ?? '?'})`,
277
+ }))
278
+ }
279
+
280
+ summary(room) {
281
+ return {
282
+ roomId: room.roomId,
283
+ createdAt: room.createdAt,
284
+ closed: room.closed,
285
+ policy: { ...room.policy },
286
+ members: [...room.members.values()].map((member) => this.memberSummary(member)),
287
+ messageCount: room.messages.length,
288
+ tasks: this.listTasks(room.roomId).length,
289
+ }
290
+ }
291
+
292
+ memberSummary(member) {
293
+ return {
294
+ memberId: member.memberId,
295
+ role: member.role,
296
+ kind: member.kind,
297
+ sessionId: member.sessionId,
298
+ name: member.name,
299
+ }
300
+ }
301
+
302
+ status(roomId) {
303
+ return this.summary(this.requireRoom(roomId))
304
+ }
305
+
306
+ /** All open rooms (for cleanup and diagnostics). */
307
+ openRooms() {
308
+ return [...this.rooms.keys()]
309
+ }
310
+
311
+ async close({ roomId, reason = 'closed by host' } = {}) {
312
+ const room = this.requireRoom(roomId)
313
+ room.closed = true
314
+ await this.append(room, 'room/closed', { roomId: room.roomId, reason, closedAt: this.now() })
315
+ this.rooms.delete(room.roomId)
316
+ return { roomId: room.roomId, closed: true, reason }
317
+ }
318
+
319
+ requireRoom(roomId) {
320
+ const room = this.rooms.get(roomId)
321
+ if (!room) throw Object.assign(new Error(`unknown or closed room: ${String(roomId)}`), { code: 'unknown-room' })
322
+ return room
323
+ }
324
+ }
package/src/host/tools.js CHANGED
@@ -28,6 +28,7 @@ export function createFleetHandlers({
28
28
  pairing = null,
29
29
  onPeerHint = null,
30
30
  approvals = null,
31
+ rooms = null,
31
32
  }) {
32
33
  return {
33
34
  async fleet_approvals() {
@@ -114,6 +115,123 @@ export function createFleetHandlers({
114
115
  }
115
116
  },
116
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
+
117
235
  async fleet_list() {
118
236
  const lines = []
119
237
  lines.push(`this device: ${node.identity.deviceId} (${node.name})`)
@@ -267,6 +385,113 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
267
385
  output,
268
386
  execute: (args) => handlers.fleet_simulate_request(args),
269
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
+ }),
270
495
  defineTool({
271
496
  name: 'fleet_invite',
272
497
  description:
@@ -15,6 +15,7 @@ import { accountFingerprint } from '../net/identity.js'
15
15
  import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
16
16
  import { DelegationHost } from '../host/delegation.js'
17
17
  import { ApprovalInbox } from '../host/approval-inbox.js'
18
+ import { RoomService } from '../host/room-service.js'
18
19
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
19
20
  import { ToolPolicy } from '../host/tool-policy.js'
20
21
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -194,6 +195,8 @@ export function apply(ctx, config) {
194
195
  const requestApproval = config.autoApproveAsk
195
196
  ? async () => ({ approved: true, scope: 'once' })
196
197
  : async (request) => inbox.request(request)
198
+
199
+ const rooms = new RoomService({ sessionController, createUserMessage })
197
200
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
198
201
  const slot = sessionController
199
202
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
@@ -307,6 +310,7 @@ export function apply(ctx, config) {
307
310
  }
308
311
  },
309
312
  approvals: approvalsApi,
313
+ rooms,
310
314
  })
311
315
  const definitions = createFleetToolDefinitions({ defineTool, handlers })
312
316
  const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
@@ -357,6 +361,9 @@ export function apply(ctx, config) {
357
361
 
358
362
  ctx.effect(() => () => {
359
363
  inbox.close()
364
+ for (const roomId of rooms.openRooms()) {
365
+ void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
366
+ }
360
367
  void node.close()
361
368
  })
362
369