dsh-device-fleet 0.1.0-dev.1 → 0.1.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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/store.js CHANGED
@@ -50,6 +50,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
50
50
 
51
51
  const roster = readJson(path.join(dir, 'roster.json'), [])
52
52
  const config = readJson(path.join(dir, 'config.json'), {})
53
+ const peerHints = readJson(path.join(dir, 'peer-hints.json'), [])
53
54
  let enrollment = readJson(path.join(dir, 'enrollment.json'), null)
54
55
  let pendingEnrollment = readJson(path.join(dir, 'enrollment.pending.json'), null)
55
56
 
@@ -64,6 +65,7 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
64
65
  joinedAt: Date.now(),
65
66
  accountFingerprint: decoded.accountFingerprint ?? null,
66
67
  relay: decoded.relay ?? null,
68
+ endpoints: decoded.endpoints ?? [],
67
69
  }
68
70
  writeJsonAtomic(path.join(dir, 'fleet.json'), fleet)
69
71
  writeJsonAtomic(path.join(dir, 'enrollment.json'), enrollment)
@@ -74,7 +76,32 @@ export function loadState({ dshHome, dataDir, name = 'device' } = {}) {
74
76
  }
75
77
  }
76
78
 
77
- return { dataDir: dir, identity, fleet, roster: Array.isArray(roster) ? roster : [], config, enrollment, pendingEnrollment, createdFleet }
79
+ return {
80
+ dataDir: dir,
81
+ identity,
82
+ fleet,
83
+ roster: Array.isArray(roster) ? roster : [],
84
+ config,
85
+ enrollment,
86
+ pendingEnrollment,
87
+ peerHints: Array.isArray(peerHints) ? peerHints : [],
88
+ createdFleet,
89
+ }
90
+ }
91
+
92
+ /** Persist one manually supplied peer address (from fleet_connect) for reconnects. */
93
+ export function savePeerHint(state, hint) {
94
+ if (!hint || typeof hint.host !== 'string' || !Number.isInteger(hint.port) || hint.port <= 0) return
95
+ const entry = {
96
+ host: hint.host,
97
+ port: hint.port,
98
+ ...(typeof hint.deviceId === 'string' ? { deviceId: hint.deviceId } : {}),
99
+ }
100
+ const hints = Array.isArray(state.peerHints) ? state.peerHints : []
101
+ const exists = hints.some((item) => item.host === entry.host && item.port === entry.port && item.deviceId === entry.deviceId)
102
+ if (!exists) hints.push(entry)
103
+ state.peerHints = hints
104
+ writeJsonAtomic(path.join(state.dataDir, 'peer-hints.json'), hints)
78
105
  }
79
106
 
80
107
  /** Queue an invite for the next plugin load (the running node keeps its fleet). */
@@ -102,8 +129,8 @@ export function saveConfig(state, config) {
102
129
  }
103
130
 
104
131
  /** Create an invite for this device's fleet. */
105
- export function createInvite(state, { accountFingerprint = null, relay = null } = {}) {
106
- return encodeInvite({ fleet: state.fleet, accountFingerprint, relay })
132
+ export function createInvite(state, { accountFingerprint = null, relay = null, endpoints = [] } = {}) {
133
+ return encodeInvite({ fleet: state.fleet, accountFingerprint, relay, endpoints })
107
134
  }
108
135
 
109
136
  /** Join a fleet from an invite code (overwrites fleet/enrollment, keeps identity). */
@@ -114,6 +141,7 @@ export function enrollFromInvite(state, code) {
114
141
  joinedAt: Date.now(),
115
142
  accountFingerprint: decoded.accountFingerprint ?? null,
116
143
  relay: decoded.relay ?? null,
144
+ endpoints: decoded.endpoints ?? [],
117
145
  }
118
146
  saveFleet(state)
119
147
  writeJsonAtomic(path.join(state.dataDir, 'enrollment.json'), state.enrollment)
@@ -3,7 +3,7 @@
3
3
  // the raw-tool allowlist, and short-lived grants created by approvals.
4
4
  import path from 'node:path'
5
5
 
6
- export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool']
6
+ export const CAPABILITIES = ['read', 'list', 'grep', 'fetch', 'run', 'tool', 'delegate']
7
7
 
8
8
  export const DEFAULT_POLICY = Object.freeze({
9
9
  allowedRoots: [],
@@ -14,6 +14,7 @@ export const DEFAULT_POLICY = Object.freeze({
14
14
  fetch: 'auto',
15
15
  run: 'ask',
16
16
  tool: 'deny',
17
+ delegate: 'ask',
17
18
  },
18
19
  allowedTools: [],
19
20
  approvalTtlMs: 15 * 60 * 1000,