dsh-device-fleet 0.1.0-dev.3 → 0.1.0-dev.31
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 +7 -3
- package/assets/avatars/host.png +0 -0
- package/assets/avatars/user.png +0 -0
- package/assets/avatars/worker.png +0 -0
- package/client.js +1682 -0
- package/cordis.patch.yml +3 -3
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/approval-panel-ui.md +38 -0
- package/docs/notes/dev-release.md +134 -5
- package/docs/notes/fleet-console-ui.md +44 -0
- package/docs/notes/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/ui-surface-audit.md +93 -0
- package/package.json +11 -1
- package/src/host/approval-inbox.js +163 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/avatar-web.js +179 -0
- package/src/host/config-web.js +84 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +27 -8
- package/src/host/execution-slot.js +27 -5
- package/src/host/index.js +8 -0
- package/src/host/room-service.js +435 -0
- package/src/host/runtime-config.js +192 -0
- package/src/host/store.js +27 -1
- package/src/host/tool-policy.js +13 -10
- package/src/host/tools.js +341 -1
- package/src/host/ui-web.js +139 -0
- package/src/net/fleet-node.js +30 -0
- package/src/plugin/index.js +421 -16
package/src/host/direct-call.js
CHANGED
|
@@ -27,6 +27,7 @@ export class DirectCallHost {
|
|
|
27
27
|
* @param {string} [options.dshVersion]
|
|
28
28
|
* @param {string[]} [options.capabilities]
|
|
29
29
|
* @param {string} [options.shellTool]
|
|
30
|
+
* @param {object} [options.delegation] DelegationHost for `fleet.delegate`
|
|
30
31
|
*/
|
|
31
32
|
constructor({
|
|
32
33
|
slot,
|
|
@@ -40,6 +41,7 @@ export class DirectCallHost {
|
|
|
40
41
|
capabilities = [],
|
|
41
42
|
shellTool = DEFAULT_SHELL_TOOL,
|
|
42
43
|
approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
|
|
44
|
+
delegation = null,
|
|
43
45
|
}) {
|
|
44
46
|
this.slot = slot
|
|
45
47
|
this.policy = policy
|
|
@@ -52,6 +54,7 @@ export class DirectCallHost {
|
|
|
52
54
|
this.capabilities = capabilities
|
|
53
55
|
this.shellTool = shellTool
|
|
54
56
|
this.approvalTimeoutMs = approvalTimeoutMs
|
|
57
|
+
this.delegation = delegation
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
info() {
|
|
@@ -75,6 +78,11 @@ export class DirectCallHost {
|
|
|
75
78
|
return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
|
|
76
79
|
case 'fleet.call':
|
|
77
80
|
return this.handleCall({ fromDeviceId: deviceId, request: params ?? {} })
|
|
81
|
+
case 'fleet.delegate':
|
|
82
|
+
if (!this.delegation) {
|
|
83
|
+
throw Object.assign(new Error('delegation is unavailable on this device'), { code: 'unsupported' })
|
|
84
|
+
}
|
|
85
|
+
return this.delegation.delegate({ fromDeviceId: deviceId, request: params ?? {} })
|
|
78
86
|
default:
|
|
79
87
|
throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
|
|
80
88
|
}
|
|
@@ -89,9 +97,11 @@ export class DirectCallHost {
|
|
|
89
97
|
throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
|
|
90
98
|
}
|
|
91
99
|
if (decision.decision === 'ask') {
|
|
92
|
-
const
|
|
93
|
-
if (
|
|
94
|
-
|
|
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' })
|
|
95
105
|
}
|
|
96
106
|
const startedAt = Date.now()
|
|
97
107
|
const result = await this.execute({ capability, tool, args })
|
|
@@ -108,12 +118,16 @@ export class DirectCallHost {
|
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
async askApproval({ fromDeviceId, capability, tool, args, reason }) {
|
|
111
|
-
if (typeof this.requestApproval !== 'function') return false
|
|
121
|
+
if (typeof this.requestApproval !== 'function') return { approved: false, reason: 'no approval channel' }
|
|
112
122
|
try {
|
|
113
|
-
const
|
|
114
|
-
return approved
|
|
115
|
-
|
|
116
|
-
|
|
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) }
|
|
117
131
|
}
|
|
118
132
|
}
|
|
119
133
|
|
|
@@ -178,6 +192,11 @@ export class DirectCallClient {
|
|
|
178
192
|
return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
|
|
179
193
|
}
|
|
180
194
|
|
|
195
|
+
/** Ask the target device's agent to do a one-shot task and return its final text. */
|
|
196
|
+
async delegate(deviceId, request, options = {}) {
|
|
197
|
+
return this.node.request(deviceId, 'fleet.delegate', request, options)
|
|
198
|
+
}
|
|
199
|
+
|
|
181
200
|
/** Format a remote result for a model-facing text tool. */
|
|
182
201
|
render(result) {
|
|
183
202
|
if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
|
|
@@ -18,6 +18,14 @@ export class ExecutionSlot {
|
|
|
18
18
|
this.initializing = null
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** Replace the slot's creation config; the next call lazily recreates the agent. */
|
|
22
|
+
setConfig(next = {}) {
|
|
23
|
+
this.config = { ...this.config, ...(next ?? {}) }
|
|
24
|
+
this.agent = null
|
|
25
|
+
this.sessionId = null
|
|
26
|
+
this.initializing = null
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
async ensureAgent() {
|
|
22
30
|
if (this.agent) return this.agent
|
|
23
31
|
if (this.initializing) return this.initializing
|
|
@@ -55,11 +63,25 @@ export class ExecutionSlot {
|
|
|
55
63
|
*/
|
|
56
64
|
async executeTool({ name, args = {}, signal }) {
|
|
57
65
|
const agent = await this.ensureAgent()
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
// execToolBatch requires a real AbortSignal: without one the pipeline
|
|
67
|
+
// throws before dispatch, which surfaces as "Tool batch execution failed".
|
|
68
|
+
const effectiveSignal = signal ?? new AbortController().signal
|
|
69
|
+
let batch
|
|
70
|
+
try {
|
|
71
|
+
batch = await this.tools.execute({
|
|
72
|
+
calls: [{ id: randomUUID(), name, arguments: JSON.stringify(args ?? {}) }],
|
|
73
|
+
agent,
|
|
74
|
+
signal: effectiveSignal,
|
|
75
|
+
})
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const cause = error?.cause
|
|
78
|
+
const detail = cause?.message ?? error?.message ?? String(error)
|
|
79
|
+
throw Object.assign(new Error(`tool ${name} failed: ${String(detail)}`), {
|
|
80
|
+
code: 'tool-failed',
|
|
81
|
+
cause,
|
|
82
|
+
data: { causeCode: cause?.code ?? null, causeMessage: cause?.message ?? null },
|
|
83
|
+
})
|
|
84
|
+
}
|
|
63
85
|
const first = batch?.results?.[0]?.result
|
|
64
86
|
if (!first) throw Object.assign(new Error(`tool ${name} returned no result`), { code: 'tool-no-result' })
|
|
65
87
|
const text = (first.content ?? [])
|
package/src/host/index.js
CHANGED
|
@@ -3,4 +3,12 @@ export * from './store.js'
|
|
|
3
3
|
export * from './tool-policy.js'
|
|
4
4
|
export * from './execution-slot.js'
|
|
5
5
|
export * from './direct-call.js'
|
|
6
|
+
export * from './delegation.js'
|
|
7
|
+
export * from './approval-inbox.js'
|
|
8
|
+
export * from './approval-web.js'
|
|
9
|
+
export * from './ui-web.js'
|
|
10
|
+
export * from './config-web.js'
|
|
11
|
+
export * from './avatar-web.js'
|
|
12
|
+
export * from './runtime-config.js'
|
|
13
|
+
export * from './room-service.js'
|
|
6
14
|
export * from './tools.js'
|
|
@@ -0,0 +1,435 @@
|
|
|
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
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { dirname } from 'node:path'
|
|
9
|
+
|
|
10
|
+
const MAX_MESSAGE_CHARS = 20_000
|
|
11
|
+
const STATE_VERSION = 1
|
|
12
|
+
|
|
13
|
+
const DEFAULT_POLICY = Object.freeze({
|
|
14
|
+
invite: 'members',
|
|
15
|
+
join: 'auto',
|
|
16
|
+
maxMembers: 8,
|
|
17
|
+
boardWrite: 'members',
|
|
18
|
+
remove: 'members',
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
function hostMemberId(sessionId) {
|
|
22
|
+
return `host:${sessionId}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function localMemberId(sessionId) {
|
|
26
|
+
return `local:${sessionId}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class RoomService {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} options
|
|
32
|
+
* @param {object} options.sessionController ctx.sessionController
|
|
33
|
+
* @param {(input: object) => object} [options.createUserMessage] dsh-llm helper
|
|
34
|
+
* @param {(now?: number) => number} [options.now]
|
|
35
|
+
* @param {string} [options.stateFile] JSON file used to rehydrate rooms after a reload
|
|
36
|
+
*/
|
|
37
|
+
constructor({ sessionController, createUserMessage = null, now = Date.now, stateFile = null } = {}) {
|
|
38
|
+
this.sessionController = sessionController
|
|
39
|
+
this.createUserMessage = createUserMessage
|
|
40
|
+
this.now = now
|
|
41
|
+
this.stateFile = stateFile
|
|
42
|
+
/** @type {Map<string, any>} */
|
|
43
|
+
this.rooms = new Map()
|
|
44
|
+
if (stateFile) this.load()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Rehydrate active rooms from the persisted snapshot (agents resolve lazily). */
|
|
48
|
+
load() {
|
|
49
|
+
if (!this.stateFile || !existsSync(this.stateFile)) return
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.parse(readFileSync(this.stateFile, 'utf8'))
|
|
52
|
+
if (!data || data.version !== STATE_VERSION || !Array.isArray(data.rooms)) return
|
|
53
|
+
for (const stored of data.rooms) {
|
|
54
|
+
if (!stored || typeof stored.roomId !== 'string' || !Array.isArray(stored.members)) continue
|
|
55
|
+
const members = new Map()
|
|
56
|
+
for (const member of stored.members) {
|
|
57
|
+
if (!member || typeof member.memberId !== 'string') continue
|
|
58
|
+
members.set(member.memberId, { ...member, agent: undefined })
|
|
59
|
+
}
|
|
60
|
+
if (!members.has(hostMemberId(stored.roomId))) continue
|
|
61
|
+
this.rooms.set(stored.roomId, {
|
|
62
|
+
roomId: stored.roomId,
|
|
63
|
+
createdAt: Number.isFinite(stored.createdAt) ? stored.createdAt : this.now(),
|
|
64
|
+
hostSessionId: typeof stored.hostSessionId === 'string' ? stored.hostSessionId : stored.roomId,
|
|
65
|
+
policy: { ...DEFAULT_POLICY, ...(stored.policy ?? {}) },
|
|
66
|
+
members,
|
|
67
|
+
messages: Array.isArray(stored.messages) ? stored.messages : [],
|
|
68
|
+
tasks: new Map((Array.isArray(stored.tasks) ? stored.tasks : []).map((task) => [task.id, task])),
|
|
69
|
+
closed: false,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* a corrupt snapshot is ignored; the host session log stays authoritative */
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
persist() {
|
|
78
|
+
if (!this.stateFile) return
|
|
79
|
+
try {
|
|
80
|
+
const snapshot = {
|
|
81
|
+
version: STATE_VERSION,
|
|
82
|
+
savedAt: this.now(),
|
|
83
|
+
rooms: [...this.rooms.values()].map((room) => ({
|
|
84
|
+
roomId: room.roomId,
|
|
85
|
+
createdAt: room.createdAt,
|
|
86
|
+
hostSessionId: room.hostSessionId,
|
|
87
|
+
policy: room.policy,
|
|
88
|
+
members: [...room.members.values()].map(({ agent, ...rest }) => rest),
|
|
89
|
+
messages: room.messages,
|
|
90
|
+
tasks: [...room.tasks.values()],
|
|
91
|
+
})),
|
|
92
|
+
}
|
|
93
|
+
mkdirSync(dirname(this.stateFile), { recursive: true })
|
|
94
|
+
const temporary = `${this.stateFile}.tmp`
|
|
95
|
+
writeFileSync(temporary, JSON.stringify(snapshot, null, 2), 'utf8')
|
|
96
|
+
renameSync(temporary, this.stateFile)
|
|
97
|
+
} catch {
|
|
98
|
+
/* persistence is best effort; the session log remains authoritative */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve a live agent for a persisted member on demand. */
|
|
103
|
+
async ensureAgent(member) {
|
|
104
|
+
if (member?.agent) return true
|
|
105
|
+
if (!member?.sessionId || !this.sessionController?.resolveAgent) return false
|
|
106
|
+
try {
|
|
107
|
+
const resolved = await this.sessionController.resolveAgent(member.sessionId)
|
|
108
|
+
if (!resolved?.agent) return false
|
|
109
|
+
member.agent = resolved.agent
|
|
110
|
+
return true
|
|
111
|
+
} catch {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async upgradeHost({ agent, policy = {} } = {}) {
|
|
117
|
+
if (!agent?.session?.id) throw Object.assign(new Error('room upgrade needs the calling agent session'), { code: 'bad-request' })
|
|
118
|
+
const sessionId = agent.session.id
|
|
119
|
+
const existing = this.rooms.get(sessionId)
|
|
120
|
+
if (existing) return this.summary(existing)
|
|
121
|
+
const memberId = hostMemberId(sessionId)
|
|
122
|
+
const room = {
|
|
123
|
+
roomId: sessionId,
|
|
124
|
+
createdAt: this.now(),
|
|
125
|
+
hostSessionId: sessionId,
|
|
126
|
+
policy: { ...DEFAULT_POLICY, ...policy },
|
|
127
|
+
members: new Map(),
|
|
128
|
+
messages: [],
|
|
129
|
+
tasks: new Map(),
|
|
130
|
+
closed: false,
|
|
131
|
+
}
|
|
132
|
+
room.members.set(memberId, {
|
|
133
|
+
memberId,
|
|
134
|
+
role: 'host',
|
|
135
|
+
kind: 'local',
|
|
136
|
+
sessionId,
|
|
137
|
+
name: agent.session.header?.title ?? 'host',
|
|
138
|
+
agent,
|
|
139
|
+
})
|
|
140
|
+
this.rooms.set(sessionId, room)
|
|
141
|
+
await this.append(room, 'room/created', { roomId: room.roomId, policy: room.policy, hostMemberId: memberId })
|
|
142
|
+
return this.summary(room)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async addLocalParticipant({ roomId, name = 'worker', agentPreset, cwd } = {}) {
|
|
146
|
+
const room = this.requireRoom(roomId)
|
|
147
|
+
if (room.members.size >= room.policy.maxMembers) {
|
|
148
|
+
throw Object.assign(new Error(`room member limit reached (${room.policy.maxMembers})`), { code: 'room-full' })
|
|
149
|
+
}
|
|
150
|
+
if (!this.sessionController?.create) {
|
|
151
|
+
throw Object.assign(new Error('participant sessions are unavailable'), { code: 'unsupported' })
|
|
152
|
+
}
|
|
153
|
+
const created = await this.sessionController.create({
|
|
154
|
+
...(cwd ? { cwd } : {}),
|
|
155
|
+
...(agentPreset ? { agentPreset } : {}),
|
|
156
|
+
})
|
|
157
|
+
const sessionId = created?.sessionId
|
|
158
|
+
if (!sessionId) throw Object.assign(new Error('could not create the participant session'), { code: 'room-participant-failed' })
|
|
159
|
+
const resolved = await this.sessionController.resolveAgent(sessionId)
|
|
160
|
+
if (!resolved?.agent) throw Object.assign(new Error('participant agent could not be resolved'), { code: 'room-participant-failed' })
|
|
161
|
+
const memberId = localMemberId(sessionId)
|
|
162
|
+
const member = { memberId, role: 'participant', kind: 'local', sessionId, name, agent: resolved.agent }
|
|
163
|
+
room.members.set(memberId, member)
|
|
164
|
+
await this.append(room, 'room/member', {
|
|
165
|
+
roomId: room.roomId,
|
|
166
|
+
action: 'added',
|
|
167
|
+
memberId,
|
|
168
|
+
role: 'participant',
|
|
169
|
+
name,
|
|
170
|
+
sessionId,
|
|
171
|
+
})
|
|
172
|
+
return this.memberSummary(member)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async removeMember({ roomId, memberId, reason = 'removed' } = {}) {
|
|
176
|
+
const room = this.requireRoom(roomId)
|
|
177
|
+
const member = room.members.get(memberId)
|
|
178
|
+
if (!member) throw Object.assign(new Error(`unknown member: ${String(memberId)}`), { code: 'unknown-member' })
|
|
179
|
+
if (member.role === 'host') throw Object.assign(new Error('the host cannot be removed; close the room instead'), { code: 'forbidden' })
|
|
180
|
+
room.members.delete(memberId)
|
|
181
|
+
await this.append(room, 'room/member', { roomId: room.roomId, action: 'removed', memberId, reason })
|
|
182
|
+
return { roomId: room.roomId, memberId }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Send one addressed message. The Host appends it to the canonical log and
|
|
187
|
+
* wakes only the addressed local members (never the sender).
|
|
188
|
+
*/
|
|
189
|
+
async send({ roomId, from, to, text, skipWakeFor = [] } = {}) {
|
|
190
|
+
const room = this.requireRoom(roomId)
|
|
191
|
+
const body = typeof text === 'string' ? text.trim() : ''
|
|
192
|
+
if (body === '') throw Object.assign(new Error('room message needs text'), { code: 'bad-request' })
|
|
193
|
+
if (body.length > MAX_MESSAGE_CHARS) throw Object.assign(new Error('room message is too long'), { code: 'bad-request' })
|
|
194
|
+
const recipients = Array.isArray(to) ? to.filter((id) => typeof id === 'string') : []
|
|
195
|
+
if (recipients.length === 0) throw Object.assign(new Error('room message needs at least one recipient'), { code: 'bad-request' })
|
|
196
|
+
for (const memberId of recipients) {
|
|
197
|
+
if (!room.members.has(memberId)) throw Object.assign(new Error(`unknown member: ${memberId}`), { code: 'unknown-member' })
|
|
198
|
+
}
|
|
199
|
+
const sender = this.resolveSender(room, from)
|
|
200
|
+
const message = {
|
|
201
|
+
id: randomUUID(),
|
|
202
|
+
roomId: room.roomId,
|
|
203
|
+
from: sender,
|
|
204
|
+
to: recipients,
|
|
205
|
+
text: body,
|
|
206
|
+
createdAt: this.now(),
|
|
207
|
+
}
|
|
208
|
+
room.messages.push(message)
|
|
209
|
+
await this.append(room, 'room/message', {
|
|
210
|
+
roomId: room.roomId,
|
|
211
|
+
messageId: message.id,
|
|
212
|
+
from: sender,
|
|
213
|
+
to: recipients,
|
|
214
|
+
text: body,
|
|
215
|
+
})
|
|
216
|
+
const woken = []
|
|
217
|
+
for (const memberId of recipients) {
|
|
218
|
+
if (sender.memberId === memberId) continue
|
|
219
|
+
if (skipWakeFor.includes(memberId)) continue
|
|
220
|
+
const member = room.members.get(memberId)
|
|
221
|
+
if (await this.wake(room, member, message)) woken.push(memberId)
|
|
222
|
+
}
|
|
223
|
+
return { messageId: message.id, to: recipients, woken }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
resolveSender(room, from) {
|
|
227
|
+
if (from && typeof from === 'object') {
|
|
228
|
+
if (typeof from.memberId === 'string') {
|
|
229
|
+
const member = room.members.get(from.memberId)
|
|
230
|
+
if (!member) throw Object.assign(new Error(`unknown sender member: ${from.memberId}`), { code: 'unknown-member' })
|
|
231
|
+
return { kind: 'agent', memberId: member.memberId, name: member.name ?? 'agent' }
|
|
232
|
+
}
|
|
233
|
+
if (typeof from.sessionId === 'string') {
|
|
234
|
+
for (const member of room.members.values()) {
|
|
235
|
+
if (member.sessionId === from.sessionId) {
|
|
236
|
+
return { kind: 'agent', memberId: member.memberId, name: member.name ?? 'agent' }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
throw Object.assign(new Error(`sender session is not a room member: ${from.sessionId}`), { code: 'unknown-member' })
|
|
240
|
+
}
|
|
241
|
+
if (from.kind === 'user') return { kind: 'user', name: 'user' }
|
|
242
|
+
}
|
|
243
|
+
return { kind: 'user', name: 'user' }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Wake one local participant by injecting the addressed message. */
|
|
247
|
+
async wake(room, member, message) {
|
|
248
|
+
if (!member || typeof this.createUserMessage !== 'function') return false
|
|
249
|
+
if (!(await this.ensureAgent(member))) return false
|
|
250
|
+
if (typeof member.agent?.followup !== 'function') return false
|
|
251
|
+
// Participant replies are rendered by the Room view; do not inject them into
|
|
252
|
+
// the Host's normal Chat stream (that caused the host agent to relay them).
|
|
253
|
+
if (member.role === 'host' && message.from.kind !== 'user') return false
|
|
254
|
+
const senderLabel = message.from.kind === 'user' ? 'user' : (message.from.name ?? 'agent')
|
|
255
|
+
// A user sender has no member id; the reply target is always the Host member.
|
|
256
|
+
const replyTarget = message.from.memberId ?? hostMemberId(room.hostSessionId)
|
|
257
|
+
// The host is the user's own session: deliver a plain attributed line into
|
|
258
|
+
// the normal Chat stream instead of an agent-to-agent reply instruction.
|
|
259
|
+
const text =
|
|
260
|
+
member.role === 'host'
|
|
261
|
+
? `[Room] ${senderLabel}: ${message.text}`
|
|
262
|
+
: [
|
|
263
|
+
`[Fleet Room ${room.roomId}] ${senderLabel} → you:`,
|
|
264
|
+
message.text,
|
|
265
|
+
'',
|
|
266
|
+
`Reply with room_send (roomId="${room.roomId}", to=["${replyTarget}"]) when you have an answer.`,
|
|
267
|
+
].join('\n')
|
|
268
|
+
const userMessage = this.createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
269
|
+
try {
|
|
270
|
+
member.agent.followup(userMessage)
|
|
271
|
+
return true
|
|
272
|
+
} catch {
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async append(room, type, data) {
|
|
278
|
+
const host = room.members.get(hostMemberId(room.hostSessionId))
|
|
279
|
+
const session = host?.agent?.session
|
|
280
|
+
if (session?.append) {
|
|
281
|
+
try {
|
|
282
|
+
await session.append(type, data)
|
|
283
|
+
} catch {
|
|
284
|
+
/* the snapshot below still records the room state */
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
this.persist()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --------------------------------------------------------------- room tasks
|
|
291
|
+
|
|
292
|
+
async addTask({ roomId, title, owner, dependsOn = [], description = '' } = {}) {
|
|
293
|
+
const room = this.requireRoom(roomId)
|
|
294
|
+
if (typeof title !== 'string' || title.trim() === '') throw Object.assign(new Error('task needs a title'), { code: 'bad-request' })
|
|
295
|
+
const ownerMember = room.members.get(owner)
|
|
296
|
+
if (!ownerMember) throw Object.assign(new Error(`unknown task owner: ${String(owner)}`), { code: 'unknown-member' })
|
|
297
|
+
const id = randomUUID()
|
|
298
|
+
const task = {
|
|
299
|
+
id,
|
|
300
|
+
roomId: room.roomId,
|
|
301
|
+
title: title.trim(),
|
|
302
|
+
description,
|
|
303
|
+
owner,
|
|
304
|
+
status: 'pending',
|
|
305
|
+
dependsOn: Array.isArray(dependsOn) ? dependsOn : [],
|
|
306
|
+
readyNotified: false,
|
|
307
|
+
createdAt: this.now(),
|
|
308
|
+
updatedAt: this.now(),
|
|
309
|
+
}
|
|
310
|
+
room.tasks.set(id, task)
|
|
311
|
+
await this.append(room, 'room/task', { roomId: room.roomId, action: 'added', task })
|
|
312
|
+
return task
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async updateTask({ roomId, id, status, note } = {}) {
|
|
316
|
+
const room = this.requireRoom(roomId)
|
|
317
|
+
const task = room.tasks.get(id)
|
|
318
|
+
if (!task) throw Object.assign(new Error(`unknown task: ${String(id)}`), { code: 'unknown-task' })
|
|
319
|
+
const allowed = ['pending', 'running', 'done', 'blocked']
|
|
320
|
+
if (status !== undefined && !allowed.includes(status)) {
|
|
321
|
+
throw Object.assign(new Error(`invalid task status: ${String(status)}`), { code: 'bad-request' })
|
|
322
|
+
}
|
|
323
|
+
if (status !== undefined) task.status = status
|
|
324
|
+
if (typeof note === 'string' && note !== '') task.note = note
|
|
325
|
+
task.updatedAt = this.now()
|
|
326
|
+
await this.append(room, 'room/task', { roomId: room.roomId, action: 'updated', task })
|
|
327
|
+
let handedOff = []
|
|
328
|
+
if (status === 'done') handedOff = await this.handOffReady(room)
|
|
329
|
+
return { task, handedOff }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Wake owners of pending tasks whose dependencies all completed. */
|
|
333
|
+
async handOffReady(room) {
|
|
334
|
+
const ready = []
|
|
335
|
+
for (const task of room.tasks.values()) {
|
|
336
|
+
if (task.status !== 'pending' || task.readyNotified) continue
|
|
337
|
+
if (task.dependsOn.length === 0) continue
|
|
338
|
+
const depsDone = task.dependsOn.every((dep) => room.tasks.get(dep)?.status === 'done')
|
|
339
|
+
if (!depsDone) continue
|
|
340
|
+
task.readyNotified = true
|
|
341
|
+
task.updatedAt = this.now()
|
|
342
|
+
const owner = room.members.get(task.owner)
|
|
343
|
+
const message = {
|
|
344
|
+
id: randomUUID(),
|
|
345
|
+
roomId: room.roomId,
|
|
346
|
+
from: { kind: 'user', name: 'room' },
|
|
347
|
+
to: [task.owner],
|
|
348
|
+
text: `任务 "${task.title}" 的前置任务已完成,现在可以开始。`,
|
|
349
|
+
createdAt: this.now(),
|
|
350
|
+
}
|
|
351
|
+
room.messages.push(message)
|
|
352
|
+
await this.append(room, 'room/message', { roomId: room.roomId, messageId: message.id, from: message.from, to: message.to, text: message.text })
|
|
353
|
+
const woken = owner ? await this.wake(room, owner, message) : false
|
|
354
|
+
ready.push({ taskId: task.id, owner: task.owner, woken })
|
|
355
|
+
}
|
|
356
|
+
return ready
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
listTasks(roomId) {
|
|
360
|
+
const room = this.requireRoom(roomId)
|
|
361
|
+
return [...room.tasks.values()].sort((a, b) => a.createdAt - b.createdAt)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ------------------------------------------------------------------- reads
|
|
365
|
+
|
|
366
|
+
messages({ roomId, limit = 50 } = {}) {
|
|
367
|
+
const room = this.requireRoom(roomId)
|
|
368
|
+
const items = room.messages.slice(-Math.max(1, Math.min(limit, 200)))
|
|
369
|
+
return items.map((message) => ({
|
|
370
|
+
...message,
|
|
371
|
+
fromLabel: message.from.kind === 'user' ? 'user' : `${message.from.name ?? 'agent'} (${message.from.memberId ?? '?'})`,
|
|
372
|
+
}))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
summary(room) {
|
|
376
|
+
return {
|
|
377
|
+
roomId: room.roomId,
|
|
378
|
+
createdAt: room.createdAt,
|
|
379
|
+
closed: room.closed,
|
|
380
|
+
policy: { ...room.policy },
|
|
381
|
+
members: [...room.members.values()].map((member) => this.memberSummary(member)),
|
|
382
|
+
messageCount: room.messages.length,
|
|
383
|
+
tasks: this.listTasks(room.roomId).length,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
memberSummary(member) {
|
|
388
|
+
return {
|
|
389
|
+
memberId: member.memberId,
|
|
390
|
+
role: member.role,
|
|
391
|
+
kind: member.kind,
|
|
392
|
+
sessionId: member.sessionId,
|
|
393
|
+
name: member.name,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
status(roomId) {
|
|
398
|
+
return this.summary(this.requireRoom(roomId))
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** All active rooms (UI list). */
|
|
402
|
+
list() {
|
|
403
|
+
return [...this.rooms.values()].map((room) => this.summary(room))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** One room plus its transcript and board (UI detail). */
|
|
407
|
+
detail(roomId, { limit = 100 } = {}) {
|
|
408
|
+
const room = this.requireRoom(roomId)
|
|
409
|
+
return {
|
|
410
|
+
...this.summary(room),
|
|
411
|
+
messages: this.messages({ roomId, limit }),
|
|
412
|
+
tasks: this.listTasks(roomId),
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** All open rooms (for cleanup and diagnostics). */
|
|
417
|
+
openRooms() {
|
|
418
|
+
return [...this.rooms.keys()]
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async close({ roomId, reason = 'closed by host' } = {}) {
|
|
422
|
+
const room = this.requireRoom(roomId)
|
|
423
|
+
room.closed = true
|
|
424
|
+
await this.append(room, 'room/closed', { roomId: room.roomId, reason, closedAt: this.now() })
|
|
425
|
+
this.rooms.delete(room.roomId)
|
|
426
|
+
this.persist()
|
|
427
|
+
return { roomId: room.roomId, closed: true, reason }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
requireRoom(roomId) {
|
|
431
|
+
const room = this.rooms.get(roomId)
|
|
432
|
+
if (!room) throw Object.assign(new Error(`unknown or closed room: ${String(roomId)}`), { code: 'unknown-room' })
|
|
433
|
+
return room
|
|
434
|
+
}
|
|
435
|
+
}
|