dsh-device-fleet 0.1.0-dev.7 → 0.1.0-dev.9
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 +3 -1
- package/client.js +167 -0
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/dev-release.md +16 -2
- package/docs/notes/room-logic-phase.md +31 -0
- package/package.json +10 -1
- package/src/host/approval-web.js +95 -0
- package/src/host/index.js +3 -0
- package/src/host/room-service.js +324 -0
- package/src/host/tools.js +225 -0
- package/src/plugin/index.js +15 -0
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@ 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
|
-
-
|
|
10
|
+
- ✅ **审批收件箱逻辑层(dev.7)**:`fleet_approvals` / `fleet_approve` / `fleet_deny`,ask 档排队、超时 fail-closed、once/ttl 授权、`fleet_simulate_request` 本机演练;Client 审批面板待做
|
|
11
|
+
- ✅ **Room 逻辑层(dev.8,单机模拟)**:`room_create` / `room_add_local` / `room_send` / `room_messages` / `room_status` / `room_task_*` / `room_close`;本地真实 agent 参与者、寻址唤醒、Room Board 串行交接;单机仿真验证通过
|
|
12
|
+
- ⏳ Client 审批面板 / 房间视图、远程参与者传输、插件重载 rehydrate(下一步)
|
|
11
13
|
|
|
12
14
|
## 安装(开发版)
|
|
13
15
|
|
package/client.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Fleet approval panel (Client half).
|
|
2
|
+
// A floating button under shell.overlay polls the Host route registered by
|
|
3
|
+
// src/host/approval-web.js. Decisions go through ApprovalInbox on the Host.
|
|
4
|
+
window.__ModuleLoader__.load({
|
|
5
|
+
id: 'dsh-device-fleet',
|
|
6
|
+
factory(require) {
|
|
7
|
+
const React = require('react');
|
|
8
|
+
const h = React.createElement;
|
|
9
|
+
const { useEffect, useState } = React;
|
|
10
|
+
|
|
11
|
+
const POLL_MS = 2500;
|
|
12
|
+
const panelStyle = {
|
|
13
|
+
position: 'fixed',
|
|
14
|
+
right: 16,
|
|
15
|
+
bottom: 72,
|
|
16
|
+
width: 360,
|
|
17
|
+
maxHeight: '65vh',
|
|
18
|
+
overflow: 'auto',
|
|
19
|
+
zIndex: 900,
|
|
20
|
+
background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
|
|
21
|
+
color: 'var(--dsw-alias-text, #e8e8e8)',
|
|
22
|
+
border: '1px solid var(--dsw-alias-border, #3a3a3a)',
|
|
23
|
+
borderRadius: 10,
|
|
24
|
+
boxShadow: '0 12px 32px rgba(0,0,0,.35)',
|
|
25
|
+
padding: 12,
|
|
26
|
+
font: '13px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
27
|
+
};
|
|
28
|
+
const buttonStyle = {
|
|
29
|
+
position: 'fixed',
|
|
30
|
+
right: 16,
|
|
31
|
+
bottom: 16,
|
|
32
|
+
zIndex: 900,
|
|
33
|
+
display: 'flex',
|
|
34
|
+
alignItems: 'center',
|
|
35
|
+
gap: 8,
|
|
36
|
+
padding: '8px 12px',
|
|
37
|
+
borderRadius: 999,
|
|
38
|
+
border: '1px solid var(--dsw-alias-border, #3a3a3a)',
|
|
39
|
+
background: 'var(--dsw-alias-surface-raised, #1f1f1f)',
|
|
40
|
+
color: 'var(--dsw-alias-text, #e8e8e8)',
|
|
41
|
+
cursor: 'pointer',
|
|
42
|
+
boxShadow: '0 6px 18px rgba(0,0,0,.3)',
|
|
43
|
+
font: '13px system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function ActionButton({ children, onClick, tone = 'normal', disabled = false }) {
|
|
47
|
+
const colors = {
|
|
48
|
+
normal: { bg: 'var(--dsw-alias-surface, transparent)', border: 'var(--dsw-alias-border, #3a3a3a)' },
|
|
49
|
+
allow: { bg: 'var(--dsw-alias-accent, #2f6fed)', border: 'var(--dsw-alias-accent, #2f6fed)' },
|
|
50
|
+
deny: { bg: 'transparent', border: 'var(--dsw-alias-border, #3a3a3a)' },
|
|
51
|
+
};
|
|
52
|
+
const theme = colors[tone] ?? colors.normal;
|
|
53
|
+
return h(
|
|
54
|
+
'button',
|
|
55
|
+
{
|
|
56
|
+
type: 'button',
|
|
57
|
+
disabled,
|
|
58
|
+
onClick,
|
|
59
|
+
style: {
|
|
60
|
+
padding: '3px 9px',
|
|
61
|
+
borderRadius: 6,
|
|
62
|
+
border: '1px solid ' + theme.border,
|
|
63
|
+
background: theme.bg,
|
|
64
|
+
color: 'var(--dsw-alias-text, #e8e8e8)',
|
|
65
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
66
|
+
opacity: disabled ? 0.55 : 1,
|
|
67
|
+
fontSize: 12,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
children,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ApprovalPanel() {
|
|
75
|
+
const [items, setItems] = useState([]);
|
|
76
|
+
const [open, setOpen] = useState(false);
|
|
77
|
+
const [busy, setBusy] = useState({});
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
let disposed = false;
|
|
81
|
+
let timer;
|
|
82
|
+
const poll = async () => {
|
|
83
|
+
try {
|
|
84
|
+
const response = await fetch('/device-fleet/approvals', { headers: { accept: 'application/json' } });
|
|
85
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
86
|
+
const data = await response.json();
|
|
87
|
+
if (!disposed) setItems(Array.isArray(data.approvals) ? data.approvals : []);
|
|
88
|
+
} catch {
|
|
89
|
+
if (!disposed) setItems([]);
|
|
90
|
+
}
|
|
91
|
+
if (!disposed) timer = setTimeout(poll, POLL_MS);
|
|
92
|
+
};
|
|
93
|
+
poll();
|
|
94
|
+
return () => {
|
|
95
|
+
disposed = true;
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
};
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const decide = async (id, action, scope) => {
|
|
101
|
+
setBusy((current) => ({ ...current, [id]: true }));
|
|
102
|
+
try {
|
|
103
|
+
const response = await fetch('/device-fleet/approvals/decide', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ id, action, ...(scope ? { scope } : {}) }),
|
|
107
|
+
});
|
|
108
|
+
if (response.ok) setItems((current) => current.filter((item) => item.id !== id));
|
|
109
|
+
} finally {
|
|
110
|
+
setBusy((current) => ({ ...current, [id]: false }));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const label = items.length > 0 ? 'Fleet 审批 (' + items.length + ')' : 'Fleet 审批';
|
|
115
|
+
return h(
|
|
116
|
+
'div',
|
|
117
|
+
null,
|
|
118
|
+
open
|
|
119
|
+
? h(
|
|
120
|
+
'div',
|
|
121
|
+
{ style: panelStyle, role: 'dialog', 'aria-label': 'Fleet 待审批请求' },
|
|
122
|
+
h(
|
|
123
|
+
'div',
|
|
124
|
+
{ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 } },
|
|
125
|
+
h('strong', null, '待审批请求'),
|
|
126
|
+
h('button', { type: 'button', onClick: () => setOpen(false), style: { ...buttonStyle, position: 'static', boxShadow: 'none', padding: '3px 9px', fontSize: 12 } }, '收起'),
|
|
127
|
+
),
|
|
128
|
+
items.length === 0
|
|
129
|
+
? h('div', { style: { opacity: 0.7 } }, '暂无待审批请求')
|
|
130
|
+
: items.map((item) =>
|
|
131
|
+
h(
|
|
132
|
+
'div',
|
|
133
|
+
{ key: item.id, style: { borderTop: '1px solid var(--dsw-alias-border, #333)', padding: '8px 0' } },
|
|
134
|
+
h('div', { style: { fontWeight: 600 } }, (item.capability || 'tool') + (item.tool ? ' / ' + item.tool : '')),
|
|
135
|
+
h('div', { style: { opacity: 0.75, fontSize: 12 } }, '来自 ' + item.fromDeviceId + ' · ' + Math.max(0, Math.round(item.expiresInMs / 1000)) + 's 后超时'),
|
|
136
|
+
item.reason ? h('div', { style: { opacity: 0.75, fontSize: 12, marginTop: 2 } }, item.reason) : null,
|
|
137
|
+
h('div', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 11, margin: '4px 0', opacity: 0.8, wordBreak: 'break-all' } }, item.argsSummary),
|
|
138
|
+
h(
|
|
139
|
+
'div',
|
|
140
|
+
{ style: { display: 'flex', gap: 6, marginTop: 4 } },
|
|
141
|
+
h(ActionButton, { tone: 'allow', onClick: () => decide(item.id, 'approve') }, busy[item.id] ? '处理中…' : '允许一次'),
|
|
142
|
+
h(ActionButton, { tone: 'allow', onClick: () => decide(item.id, 'approve', 'ttl') }, '允许 15 分钟'),
|
|
143
|
+
h(ActionButton, { tone: 'deny', onClick: () => decide(item.id, 'deny') }, '拒绝'),
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
: null,
|
|
149
|
+
h(
|
|
150
|
+
'button',
|
|
151
|
+
{ type: 'button', style: { ...buttonStyle, borderColor: items.length > 0 ? 'var(--dsw-alias-accent, #2f6fed)' : undefined }, onClick: () => setOpen((value) => !value) },
|
|
152
|
+
h('span', { 'aria-hidden': true }, '🛰️'),
|
|
153
|
+
h('span', null, label),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
inject: ['slots'],
|
|
160
|
+
apply(ctx) {
|
|
161
|
+
ctx.slots.inject('shell.overlay', () =>
|
|
162
|
+
ctx.slots.register({ name: 'shell.overlay', id: 'fleet-approvals', order: 10 }, ApprovalPanel),
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
});
|
|
@@ -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,23 @@ 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.
|
|
42
|
+
## 0.1.0-dev.8(稳定化)
|
|
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.8(审批收件箱,逻辑层)
|
|
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 应答。
|
|
54
|
+
## 0.1.0-dev.8(Room 逻辑层,本机模拟)
|
|
55
|
+
|
|
56
|
+
- `RoomService`:当前会话升级为 Room Host;本地模拟参与者(真实 DSH session/agent);
|
|
57
|
+
- 寻址消息:`room_send` 只唤醒被点名成员,Host 会话写权威 `room/message` 事件;
|
|
58
|
+
- 可见性:`room_messages` 提供完整 transcript;未点名成员不运行;
|
|
59
|
+
- Room Board:`room_task_add/update/list`,`dependsOn` 串行交接(前置完成时唤醒下一个 owner),无依赖任务并行不受影响;
|
|
60
|
+
- 工具:`room_create / room_add_local / room_remove_member / room_send / room_messages / room_status / room_task_* / room_close`;
|
|
61
|
+
- 远程参与者传输与 Client UI 未做(下一步)。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Room 逻辑层实测(2026-11-09,dev.8,单机模拟参与者)
|
|
2
|
+
|
|
3
|
+
在当前这台 DSH 内,用两个**真实本地 session/agent** 充当 Room 参与者,验证寻址唤醒与看板串行交接,不依赖第二台设备。
|
|
4
|
+
|
|
5
|
+
## 流程与结果
|
|
6
|
+
|
|
7
|
+
1. `room_create` → roomId = 当前会话 `session-269260c9-…`,host 成员 `host:session-269260c9-…`。
|
|
8
|
+
2. `room_add_local(name="worker-a", cwd=E:/Dsh_test)` → 真实 session `session-d063026e-…`。
|
|
9
|
+
3. `room_send(to=[worker-a], text="读取 hello-from-A.txt 并用 room_send 回复 host")` → `woke worker-a`;
|
|
10
|
+
- worker-a 的 agent 真实跑了一轮模型 + 本机 `read`,并在 3.4s 内调用 `room_send` 回传 `hello from device A (fleet test)`;
|
|
11
|
+
- transcript 两条消息,完整可见。
|
|
12
|
+
4. `room_add_local(name="worker-b")`;看板:
|
|
13
|
+
- taskA `读取 hello-from-A.txt` owner=worker-a;
|
|
14
|
+
- taskB `汇总读取结果` owner=worker-b,`dependsOn=[taskA]`;
|
|
15
|
+
- taskC `并行:独立检查` owner=worker-a,无依赖。
|
|
16
|
+
5. `room_task_update(taskA, done)` → 返回 `handed off to worker-b`;worker-b 被点名唤醒,30s 内完成汇总并 `room_send` 给 host(内容含前序结果、交叉校验、并行任务仍 pending 的说明),同时把自己 taskB 置为 running。
|
|
17
|
+
6. `room_task_list`:taskA done、taskB running、taskC pending(并行任务未被串行交接影响)。
|
|
18
|
+
7. `room_close`。
|
|
19
|
+
|
|
20
|
+
## 结论
|
|
21
|
+
|
|
22
|
+
- Room 生命周期、成员管理、寻址消息、未点名不唤醒、host 权威日志:单机逻辑通过;
|
|
23
|
+
- Room Board `dependsOn` 串行交接 / 并行任务不阻塞:真实 agent 验证通过;
|
|
24
|
+
- 参与者使用的都是真实 DSH agent(各自 session),不是 mock。
|
|
25
|
+
|
|
26
|
+
## 限制(下一步)
|
|
27
|
+
|
|
28
|
+
- 远程参与者传输(把 local participant 换成远端设备成员)尚未接入;
|
|
29
|
+
- Client 面板/房间视图未做(用户无法在 GUI 直接看 room 时间线/看板);
|
|
30
|
+
- 房间状态在插件内存 + host session 事件里,插件重载后需要 rehydrate(未实现);
|
|
31
|
+
- 房间策略仅存储,尚未强制(invite/join/maxMembers 中 maxMembers 已生效)。
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-device-fleet",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.9",
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/plugin/index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
9
10
|
"./net": "./src/net/index.js",
|
|
10
11
|
"./host": "./src/host/index.js",
|
|
11
12
|
"./package.json": "./package.json"
|
|
@@ -16,6 +17,13 @@
|
|
|
16
17
|
"dsh": {
|
|
17
18
|
"bundle": {
|
|
18
19
|
"patch": "./cordis.patch.yml"
|
|
20
|
+
},
|
|
21
|
+
"client": {
|
|
22
|
+
"platform": "web",
|
|
23
|
+
"immediately": true,
|
|
24
|
+
"inject": [
|
|
25
|
+
"@deepseek-ai/dsh-client-runtime"
|
|
26
|
+
]
|
|
19
27
|
}
|
|
20
28
|
},
|
|
21
29
|
"peerDependencies": {
|
|
@@ -31,6 +39,7 @@
|
|
|
31
39
|
},
|
|
32
40
|
"files": [
|
|
33
41
|
"src",
|
|
42
|
+
"client.js",
|
|
34
43
|
"cordis.patch.yml",
|
|
35
44
|
"README.md",
|
|
36
45
|
"CONTEXT.md",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Local approval HTTP surface for the approval panel (dev.9). The page is
|
|
2
|
+
// served by the DSH web carrier, so browser fetch carries the session cookie.
|
|
3
|
+
// All decisions go through ApprovalInbox; nothing here bypasses Tool Policy.
|
|
4
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
5
|
+
|
|
6
|
+
function json(res, status, value) {
|
|
7
|
+
const text = JSON.stringify(value)
|
|
8
|
+
res.statusCode = status
|
|
9
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
10
|
+
res.setHeader('cache-control', 'no-store')
|
|
11
|
+
res.end(text)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function readJsonBody(req) {
|
|
15
|
+
const chunks = []
|
|
16
|
+
let size = 0
|
|
17
|
+
for await (const chunk of req) {
|
|
18
|
+
size += chunk.length
|
|
19
|
+
if (size > MAX_BODY_BYTES) throw Object.assign(new Error('request body is too large'), { code: 'body-too-large' })
|
|
20
|
+
chunks.push(chunk)
|
|
21
|
+
}
|
|
22
|
+
if (chunks.length === 0) return {}
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
throw Object.assign(new Error('request body must be JSON'), { code: 'bad-json' })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** HTTP-free decision helper shared by the routes and unit tests. */
|
|
31
|
+
export function createApprovalApi({ inbox }) {
|
|
32
|
+
return {
|
|
33
|
+
list() {
|
|
34
|
+
return { approvals: inbox.list() }
|
|
35
|
+
},
|
|
36
|
+
decide(body = {}) {
|
|
37
|
+
const { id, action } = body
|
|
38
|
+
if (typeof id !== 'string' || id === '') {
|
|
39
|
+
throw Object.assign(new Error('id is required'), { code: 'bad-request' })
|
|
40
|
+
}
|
|
41
|
+
if (action === 'approve') {
|
|
42
|
+
const scope = body.scope === 'ttl' ? 'ttl' : 'once'
|
|
43
|
+
return { ok: true, result: inbox.approve(id, { scope }) }
|
|
44
|
+
}
|
|
45
|
+
if (action === 'deny') {
|
|
46
|
+
return { ok: true, result: inbox.deny(id, typeof body.reason === 'string' ? body.reason : 'denied by local user') }
|
|
47
|
+
}
|
|
48
|
+
throw Object.assign(new Error('action must be approve or deny'), { code: 'bad-request' })
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register the approval routes on the DSH web carrier.
|
|
55
|
+
* @returns {() => void} disposer
|
|
56
|
+
*/
|
|
57
|
+
export function registerApprovalRoutes({ webServer, inbox, logger = null }) {
|
|
58
|
+
if (!webServer?.register || !inbox) return () => {}
|
|
59
|
+
const api = createApprovalApi({ inbox })
|
|
60
|
+
const disposers = []
|
|
61
|
+
disposers.push(
|
|
62
|
+
webServer.register({
|
|
63
|
+
kind: 'exact',
|
|
64
|
+
path: '/device-fleet/approvals',
|
|
65
|
+
handler: async (req, res) => {
|
|
66
|
+
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' })
|
|
67
|
+
try {
|
|
68
|
+
json(res, 200, api.list())
|
|
69
|
+
} catch (error) {
|
|
70
|
+
logger?.warn?.(`[device-fleet] approval list failed: ${String(error?.message ?? error)}`)
|
|
71
|
+
json(res, 500, { error: 'internal error' })
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
disposers.push(
|
|
77
|
+
webServer.register({
|
|
78
|
+
kind: 'exact',
|
|
79
|
+
path: '/device-fleet/approvals/decide',
|
|
80
|
+
handler: async (req, res) => {
|
|
81
|
+
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' })
|
|
82
|
+
try {
|
|
83
|
+
const body = await readJsonBody(req)
|
|
84
|
+
json(res, 200, api.decide(body))
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const status = ['bad-request', 'bad-json', 'body-too-large', 'unknown-request'].includes(error?.code) ? 400 : 500
|
|
87
|
+
json(res, status, { error: String(error?.message ?? error), code: error?.code ?? 'error' })
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
)
|
|
92
|
+
return () => {
|
|
93
|
+
for (const dispose of disposers) dispose()
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/host/index.js
CHANGED
|
@@ -4,4 +4,7 @@ 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 './approval-web.js'
|
|
9
|
+
export * from './room-service.js'
|
|
7
10
|
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:
|
package/src/plugin/index.js
CHANGED
|
@@ -15,6 +15,8 @@ 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'
|
|
19
|
+
import { registerApprovalRoutes } from '../host/approval-web.js'
|
|
18
20
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
19
21
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
20
22
|
import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
|
|
@@ -194,6 +196,15 @@ export function apply(ctx, config) {
|
|
|
194
196
|
const requestApproval = config.autoApproveAsk
|
|
195
197
|
? async () => ({ approved: true, scope: 'once' })
|
|
196
198
|
: async (request) => inbox.request(request)
|
|
199
|
+
|
|
200
|
+
const rooms = new RoomService({ sessionController, createUserMessage })
|
|
201
|
+
|
|
202
|
+
// Client approval panel data plane (dev.9). Optional service: host-only
|
|
203
|
+
// deployments simply do not expose the panel routes.
|
|
204
|
+
const webServer = service('webServer')
|
|
205
|
+
if (webServer) {
|
|
206
|
+
ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
|
|
207
|
+
}
|
|
197
208
|
if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
|
|
198
209
|
const slot = sessionController
|
|
199
210
|
? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
|
|
@@ -307,6 +318,7 @@ export function apply(ctx, config) {
|
|
|
307
318
|
}
|
|
308
319
|
},
|
|
309
320
|
approvals: approvalsApi,
|
|
321
|
+
rooms,
|
|
310
322
|
})
|
|
311
323
|
const definitions = createFleetToolDefinitions({ defineTool, handlers })
|
|
312
324
|
const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
|
|
@@ -357,6 +369,9 @@ export function apply(ctx, config) {
|
|
|
357
369
|
|
|
358
370
|
ctx.effect(() => () => {
|
|
359
371
|
inbox.close()
|
|
372
|
+
for (const roomId of rooms.openRooms()) {
|
|
373
|
+
void rooms.close({ roomId, reason: 'plugin unloaded' }).catch(() => {})
|
|
374
|
+
}
|
|
360
375
|
void node.close()
|
|
361
376
|
})
|
|
362
377
|
|