dsh-device-fleet 0.1.0-dev.6 → 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 事件一起做)。
@@ -38,4 +38,16 @@ dsh plugin --profile desktop add dsh-device-fleet@dev
38
38
 
39
39
  - 邀请里新增 `endpoints`(创建者的局域网/热点 IPv4 + direct 端口),加入方重启后自动向这些地址回连,绕开组播发现失败;
40
40
  - 新增 `fleet_status`(deviceId/直连端口/发现/连接/roster/hints)与 `fleet_connect(host, port)`(手动直连,成功后会从握手学到对端 deviceId);
41
- - `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
41
+ - `fleet_list` 仍是本地视图;配对成功后双方 roster 会自动固定对端公钥。
42
+ ## 0.1.0-dev.7(稳定化)
43
+
44
+ - 直连端口可固定(profile `direct.port`),插件重载不再换端口;
45
+ - 连接断开后自动重试已知地址(不再只在启动时试一次);
46
+ - `fleet_connect` 手动地址会持久化到 `peer-hints.json`,重启后继续回连;
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 应答。
@@ -0,0 +1,33 @@
1
+ # 跨设备调用实测(2026-11-09,dev.6)
2
+
3
+ 环境:A `desktop` / `50fdaf90-…`(端口固定 60580);B `laptop` / `c2d42572-…`(dev.6,同一热点)。
4
+ 测试文件:A `E:\Dsh_test\hello-from-A.txt`;B `E:\Dsh_test\hello-from-B.txt`。
5
+
6
+ ## 结果
7
+
8
+ | # | 调用 | 结果 | 耗时 |
9
+ |---|---|---|---|
10
+ | 1 | `fleet_read(B, E:/Dsh_test/hello-from-B.txt)` | ✅ 返回 `hello from device B` | 48ms |
11
+ | 2 | `fleet_call(B, tool="read", {file_path:…hello-from-B.txt})` | ✅ 返回同一内容(原始工具直调) | 4ms |
12
+ | 3 | `fleet_fetch(B, hello-from-B.txt)` | ✅ 返回文本 | 1ms |
13
+ | 4 | `fleet_run(B, "echo hello-from-B")` | ⚠️ `approval_denied`(B 策略 run=ask,审批 UI 未实现,fail-closed 正确) | 即时 |
14
+ | 5 | `fleet_delegate(B, "读取 hello-from-B.txt 并原样返回")` | ✅ B 的 agent(spawn 子会话 `1a768f89…`)读文件并回传最终文本 | 2.7s |
15
+ | 6 | `fleet_delegate(B, "用 fleet_read 读 A 的 hello-from-A.txt 并原样返回")` | ✅ B 的 agent 反向调用 A 的 `read`,回传 `hello from device A (fleet test)`(子会话 `515f8c1b…`) | 2.5s |
16
+
17
+ ## 结论
18
+
19
+ - v1 直连 + 端到端加密 + 配对/roster 固定公钥:**通过**;
20
+ - 直调(typed 快捷工具 / `fleet_call` 原始工具 / fetch):**通过**;
21
+ - Tool Policy 与 fail-closed 审批:**按设计生效**;
22
+ - 跨设备委派(B 的 agent 在本机执行并回传;B 的 agent 反向调 A 工具):**通过**;
23
+ - 遗留:审批收件箱 UI(`run`/`delegate` 的 `ask` 档目前只能拒绝或配置 auto);Room/Room Board/设备页 UI 未开始;委派返回文本里 `[reasoning]` 是结果里的 reasoning block 展示,可后续过滤。
24
+
25
+ ## 复现命令(A 侧)
26
+
27
+ ```
28
+ fleet_list()
29
+ fleet_read(device="c2d42572-…", path="E:/Dsh_test/hello-from-B.txt")
30
+ fleet_call(device="c2d42572-…", tool="read", parameters={file_path:"E:/Dsh_test/hello-from-B.txt"})
31
+ fleet_delegate(device="c2d42572-…", task="读取 E:/Dsh_test/hello-from-B.txt 并原样返回")
32
+ fleet_delegate(device="c2d42572-…", task="用 fleet_read 读设备 50fdaf90-… 的 E:/Dsh_test/hello-from-A.txt 并原样返回")
33
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-device-fleet",
3
- "version": "0.1.0-dev.6",
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",
@@ -0,0 +1,135 @@
1
+ // Local approval inbox (ADR-0020): a target device asks its own user before a
2
+ // remote call/delegation with the `ask` policy can run. v1 has no client UI yet;
3
+ // the inbox is exposed through `fleet_approvals` / `fleet_approve` /
4
+ // `fleet_deny` and can be exercised with `fleet_simulate_request` while the
5
+ // client panel is still missing.
6
+ import { EventEmitter } from 'node:events'
7
+ import { randomUUID } from 'node:crypto'
8
+
9
+ export const APPROVAL_SCOPES = ['once', 'ttl']
10
+
11
+ const SUMMARY_ARG_LENGTH = 160
12
+
13
+ function summarizeArgs(args) {
14
+ try {
15
+ const text = JSON.stringify(args ?? {})
16
+ return text.length > SUMMARY_ARG_LENGTH ? `${text.slice(0, SUMMARY_ARG_LENGTH)}…` : text
17
+ } catch {
18
+ return '[unserializable arguments]'
19
+ }
20
+ }
21
+
22
+ export class ApprovalInbox extends EventEmitter {
23
+ constructor({ ttlMs = 60_000, maxPending = 50, now = Date.now } = {}) {
24
+ super()
25
+ this.ttlMs = ttlMs
26
+ this.maxPending = maxPending
27
+ this.now = now
28
+ this.closed = false
29
+ /** @type {Map<string, any>} */
30
+ this.pending = new Map()
31
+ }
32
+
33
+ /**
34
+ * Ask the local user. Resolves `{ approved, scope?, reason? }` when approved,
35
+ * denied, expired or closed.
36
+ */
37
+ request(request) {
38
+ if (this.closed) return Promise.resolve({ approved: false, reason: 'inbox-closed' })
39
+ if (this.pending.size >= this.maxPending) {
40
+ return Promise.resolve({ approved: false, reason: 'inbox-full' })
41
+ }
42
+ const id = randomUUID()
43
+ const createdAt = this.now()
44
+ const entry = {
45
+ id,
46
+ fromDeviceId: request.fromDeviceId ?? 'unknown',
47
+ capability: request.capability ?? 'tool',
48
+ tool: request.tool ?? null,
49
+ reason: request.reason ?? '',
50
+ argsSummary: summarizeArgs(request.args),
51
+ createdAt,
52
+ expiresAt: createdAt + this.ttlMs,
53
+ status: 'pending',
54
+ }
55
+ entry.promise = new Promise((resolve) => {
56
+ entry.resolve = resolve
57
+ })
58
+ entry.timer = setTimeout(() => this.expire(id), this.ttlMs)
59
+ entry.timer.unref?.()
60
+ this.pending.set(id, entry)
61
+ this.emit('request', this.summary(entry))
62
+ return entry.promise
63
+ }
64
+
65
+ summary(entry) {
66
+ return {
67
+ id: entry.id,
68
+ fromDeviceId: entry.fromDeviceId,
69
+ capability: entry.capability,
70
+ tool: entry.tool,
71
+ reason: entry.reason,
72
+ argsSummary: entry.argsSummary,
73
+ createdAt: entry.createdAt,
74
+ expiresAt: entry.expiresAt,
75
+ expiresInMs: Math.max(0, entry.expiresAt - this.now()),
76
+ status: entry.status,
77
+ }
78
+ }
79
+
80
+ list() {
81
+ return [...this.pending.values()]
82
+ .filter((entry) => entry.status === 'pending')
83
+ .sort((a, b) => a.createdAt - b.createdAt)
84
+ .map((entry) => this.summary(entry))
85
+ }
86
+
87
+ approve(id, { scope = 'once' } = {}) {
88
+ const entry = this.settle(id, 'approved')
89
+ if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
90
+ const chosen = APPROVAL_SCOPES.includes(scope) ? scope : 'once'
91
+ entry.resolve({ approved: true, scope: chosen })
92
+ this.emit('approved', { ...this.summary(entry), scope: chosen })
93
+ return { ...this.summary(entry), scope: chosen }
94
+ }
95
+
96
+ deny(id, reason = 'denied by local user') {
97
+ const entry = this.settle(id, 'denied')
98
+ if (!entry) throw Object.assign(new Error(`unknown approval request: ${String(id)}`), { code: 'unknown-request' })
99
+ entry.resolve({ approved: false, reason })
100
+ this.emit('denied', { ...this.summary(entry), reason })
101
+ return { ...this.summary(entry), reason }
102
+ }
103
+
104
+ expire(id) {
105
+ const entry = this.pending.get(id)
106
+ if (!entry || entry.status !== 'pending') return
107
+ entry.status = 'expired'
108
+ this.pending.delete(id)
109
+ clearTimeout(entry.timer)
110
+ entry.resolve({ approved: false, reason: 'approval-timeout' })
111
+ this.emit('expired', this.summary(entry))
112
+ }
113
+
114
+ settle(id, status) {
115
+ const entry = this.pending.get(id)
116
+ if (!entry || entry.status !== 'pending') return undefined
117
+ entry.status = status
118
+ this.pending.delete(id)
119
+ clearTimeout(entry.timer)
120
+ return entry
121
+ }
122
+
123
+ /** Close the inbox: every pending request fails closed. */
124
+ close() {
125
+ if (this.closed) return
126
+ this.closed = true
127
+ for (const entry of this.pending.values()) {
128
+ clearTimeout(entry.timer)
129
+ entry.status = 'closed'
130
+ entry.resolve({ approved: false, reason: 'inbox-closed' })
131
+ }
132
+ this.pending.clear()
133
+ this.emit('closed')
134
+ }
135
+ }
@@ -72,12 +72,15 @@ export class DelegationHost {
72
72
  throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability: 'delegate' } })
73
73
  }
74
74
  if (decision.decision === 'ask') {
75
- const approved =
75
+ const outcome =
76
76
  typeof this.requestApproval === 'function'
77
77
  ? await this.requestApproval({ fromDeviceId, capability: 'delegate', tool: 'delegate', args: { task }, reason: decision.reason })
78
- : false
79
- if (approved !== true) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
80
- this.policy.grant(decision.key, { scope: 'once' })
78
+ : { approved: false, reason: 'no approval channel' }
79
+ const approved = outcome === true || outcome?.approved === true
80
+ if (!approved) {
81
+ throw Object.assign(new Error(outcome?.reason ?? 'local approval was not granted'), { code: 'approval_denied' })
82
+ }
83
+ this.policy.grant(decision.key, { scope: outcome?.scope === 'ttl' ? 'ttl' : 'once' })
81
84
  }
82
85
 
83
86
  const agent = await this.slot.ensureAgent()
@@ -97,9 +97,11 @@ export class DirectCallHost {
97
97
  throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
98
98
  }
99
99
  if (decision.decision === 'ask') {
100
- const approved = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
101
- if (!approved) throw Object.assign(new Error('local approval was not granted'), { code: 'approval_denied' })
102
- this.policy.grant(decision.key, { scope: 'once' })
100
+ const outcome = await this.askApproval({ fromDeviceId, capability, tool, args, reason: decision.reason })
101
+ if (outcome.approved !== true) {
102
+ throw Object.assign(new Error(outcome.reason ?? 'local approval was not granted'), { code: 'approval_denied' })
103
+ }
104
+ this.policy.grant(decision.key, { scope: outcome.scope === 'ttl' ? 'ttl' : 'once' })
103
105
  }
104
106
  const startedAt = Date.now()
105
107
  const result = await this.execute({ capability, tool, args })
@@ -116,12 +118,16 @@ export class DirectCallHost {
116
118
  }
117
119
 
118
120
  async askApproval({ fromDeviceId, capability, tool, args, reason }) {
119
- if (typeof this.requestApproval !== 'function') return false
121
+ if (typeof this.requestApproval !== 'function') return { approved: false, reason: 'no approval channel' }
120
122
  try {
121
- const approved = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
122
- return approved === true
123
- } catch {
124
- return false
123
+ const outcome = await this.requestApproval({ fromDeviceId, capability, tool, args, reason })
124
+ if (outcome === true) return { approved: true, scope: 'once' }
125
+ if (outcome && typeof outcome === 'object') {
126
+ return { approved: outcome.approved === true, scope: outcome.scope ?? 'once', reason: outcome.reason }
127
+ }
128
+ return { approved: false, reason: 'approval not granted' }
129
+ } catch (error) {
130
+ return { approved: false, reason: String(error?.message ?? error) }
125
131
  }
126
132
  }
127
133
 
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
@@ -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, onPeerHint = 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) }
@@ -69,6 +115,123 @@ export function createFleetHandlers({ node, client = new DirectCallClient({ node
69
115
  }
70
116
  },
71
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
+
72
235
  async fleet_list() {
73
236
  const lines = []
74
237
  lines.push(`this device: ${node.identity.deviceId} (${node.name})`)
@@ -180,6 +343,155 @@ export function createFleetToolDefinitions({ defineTool, handlers }) {
180
343
  output,
181
344
  execute: (args) => handlers.fleet_connect(args),
182
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
+ }),
183
495
  defineTool({
184
496
  name: 'fleet_invite',
185
497
  description:
@@ -14,6 +14,8 @@ import { FleetNode } from '../net/fleet-node.js'
14
14
  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
+ import { ApprovalInbox } from '../host/approval-inbox.js'
18
+ import { RoomService } from '../host/room-service.js'
17
19
  import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
18
20
  import { ToolPolicy } from '../host/tool-policy.js'
19
21
  import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
@@ -76,6 +78,13 @@ export const Config = Schema.object({
76
78
  .description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
77
79
  promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
78
80
  autoApproveAsk: Schema.boolean().default(false).description('Testing only: auto-approve every local approval request.'),
81
+ approvals: Schema.object({
82
+ ttlMs: Schema.number().default(60000),
83
+ maxPending: Schema.number().default(50),
84
+ simulation: Schema.boolean().default(false),
85
+ })
86
+ .default({ ttlMs: 60000, maxPending: 50, simulation: false })
87
+ .description('Local approval inbox (ADR-0020). `simulation` exposes fleet_simulate_request for local testing.'),
79
88
  })
80
89
 
81
90
  function packageVersion() {
@@ -157,6 +166,37 @@ export function apply(ctx, config) {
157
166
  const sessionController = service('sessionController')
158
167
  const fs = service('fs')
159
168
  const subagents = service('subagents')
169
+ const inbox = new ApprovalInbox({
170
+ ttlMs: config.approvals?.ttlMs ?? 60_000,
171
+ maxPending: config.approvals?.maxPending ?? 50,
172
+ })
173
+ inbox.on('request', (request) => logger.info?.(`[device-fleet] approval requested by ${request.fromDeviceId}: ${request.capability}/${String(request.tool ?? '')}`))
174
+ const approvalsApi = {
175
+ list: () => inbox.list(),
176
+ approve: (id, options) => inbox.approve(id, options),
177
+ deny: (id, reason) => inbox.deny(id, reason),
178
+ simulate: config.approvals?.simulation
179
+ ? async ({ fromDeviceId, capability, tool, reason }) => {
180
+ const promise = inbox.request({
181
+ fromDeviceId: fromDeviceId ?? 'simulated-device',
182
+ capability: capability ?? 'run',
183
+ tool: tool ?? null,
184
+ reason: reason ?? 'simulated approval request',
185
+ args: {},
186
+ })
187
+ const summary = inbox.list().at(-1)
188
+ // Keep the promise from becoming an unhandled rejection when the
189
+ // request expires without an answer; the demo only inspects the inbox.
190
+ promise.catch(() => {})
191
+ return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
192
+ }
193
+ : null,
194
+ }
195
+ const requestApproval = config.autoApproveAsk
196
+ ? async () => ({ approved: true, scope: 'once' })
197
+ : async (request) => inbox.request(request)
198
+
199
+ const rooms = new RoomService({ sessionController, createUserMessage })
160
200
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
161
201
  const slot = sessionController
162
202
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
@@ -173,12 +213,7 @@ export function apply(ctx, config) {
173
213
  defaultAgentPreset: config.execution?.agentPreset,
174
214
  provider: config.execution?.subagentProvider ?? 'spawn',
175
215
  timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
176
- requestApproval: config.autoApproveAsk
177
- ? async () => true
178
- : async (request) => {
179
- logger.info?.(`[device-fleet] delegation approval required from ${request.fromDeviceId} (no approval UI yet)`)
180
- return false
181
- },
216
+ requestApproval,
182
217
  })
183
218
  : null
184
219
 
@@ -195,12 +230,7 @@ export function apply(ctx, config) {
195
230
  shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
196
231
  approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
197
232
  delegation,
198
- requestApproval: config.autoApproveAsk
199
- ? async () => true
200
- : async (request) => {
201
- logger.info?.(`[device-fleet] approval required from ${request.fromDeviceId} for ${request.capability}/${String(request.tool)} (no approval UI yet)`)
202
- return false
203
- },
233
+ requestApproval,
204
234
  })
205
235
  : null
206
236
 
@@ -279,6 +309,8 @@ export function apply(ctx, config) {
279
309
  logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
280
310
  }
281
311
  },
312
+ approvals: approvalsApi,
313
+ rooms,
282
314
  })
283
315
  const definitions = createFleetToolDefinitions({ defineTool, handlers })
284
316
  const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
@@ -328,6 +360,10 @@ export function apply(ctx, config) {
328
360
  })
329
361
 
330
362
  ctx.effect(() => () => {
363
+ inbox.close()
364
+ for (const roomId of rooms.openRooms()) {
365
+ void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
366
+ }
331
367
  void node.close()
332
368
  })
333
369