dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10

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.
Files changed (60) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/client.js +167 -0
  5. package/cordis.patch.yml +26 -0
  6. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  7. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  8. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  9. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  10. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  11. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  12. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  13. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  14. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  15. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  16. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  17. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  18. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  19. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  20. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  21. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  22. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  23. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  24. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  25. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  26. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  27. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  28. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  29. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  30. package/docs/notes/approval-inbox-phase.md +31 -0
  31. package/docs/notes/design-decisions-summary.md +49 -0
  32. package/docs/notes/dev-release.md +67 -0
  33. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  34. package/docs/notes/network-v1-v2-implementation.md +71 -0
  35. package/docs/notes/pairing-session-2026-11-09.md +30 -0
  36. package/docs/notes/remote-tests-2026-11-09.md +33 -0
  37. package/docs/notes/room-logic-phase.md +31 -0
  38. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  39. package/package.json +51 -3
  40. package/src/host/approval-inbox.js +135 -0
  41. package/src/host/approval-web.js +95 -0
  42. package/src/host/delegation.js +127 -0
  43. package/src/host/direct-call.js +209 -0
  44. package/src/host/execution-slot.js +96 -0
  45. package/src/host/index.js +10 -0
  46. package/src/host/room-service.js +324 -0
  47. package/src/host/store.js +156 -0
  48. package/src/host/tool-policy.js +151 -0
  49. package/src/host/tools.js +590 -0
  50. package/src/index.js +3 -0
  51. package/src/net/direct.js +69 -0
  52. package/src/net/discovery.js +105 -0
  53. package/src/net/fleet-node.js +509 -0
  54. package/src/net/identity.js +167 -0
  55. package/src/net/index.js +9 -0
  56. package/src/net/protocol.js +73 -0
  57. package/src/net/relay-client.js +258 -0
  58. package/src/net/relay-server.js +366 -0
  59. package/src/net/secure-channel.js +538 -0
  60. package/src/plugin/index.js +380 -0
@@ -0,0 +1,209 @@
1
+ // Direct-call host/client over the fleet RPC layer (ADR-0003, ADR-0004, ADR-0014).
2
+ //
3
+ // Host side runs on the resource-owner device: it applies Tool Policy, may ask
4
+ // for local approval, then executes through the Execution Slot (or a direct fs
5
+ // read for list/fetch). Client side runs on the caller device and backs the
6
+ // `fleet_*` model tools.
7
+ import { DEFAULT_SHELL_TOOL } from './execution-slot.js'
8
+
9
+ const DEFAULT_POLICY_TIMEOUT_MS = 60_000
10
+
11
+ function toolText(result) {
12
+ if (!result) return ''
13
+ if (result.isError) return `Error: ${result.text || 'tool failed'}`
14
+ return result.text ?? ''
15
+ }
16
+
17
+ export class DirectCallHost {
18
+ /**
19
+ * @param {object} options
20
+ * @param {object} options.slot ExecutionSlot
21
+ * @param {import('./tool-policy.js').ToolPolicy} options.policy
22
+ * @param {{resolve: Function, listDir: Function, stat?: Function, readText?: Function}} [options.fs]
23
+ * @param {(request: object) => Promise<boolean>} [options.requestApproval]
24
+ * @param {string} options.deviceId
25
+ * @param {string} [options.name]
26
+ * @param {string} [options.pluginVersion]
27
+ * @param {string} [options.dshVersion]
28
+ * @param {string[]} [options.capabilities]
29
+ * @param {string} [options.shellTool]
30
+ * @param {object} [options.delegation] DelegationHost for `fleet.delegate`
31
+ */
32
+ constructor({
33
+ slot,
34
+ policy,
35
+ fs,
36
+ requestApproval,
37
+ deviceId,
38
+ name = 'device',
39
+ pluginVersion = '0.0.0',
40
+ dshVersion = 'unknown',
41
+ capabilities = [],
42
+ shellTool = DEFAULT_SHELL_TOOL,
43
+ approvalTimeoutMs = DEFAULT_POLICY_TIMEOUT_MS,
44
+ delegation = null,
45
+ }) {
46
+ this.slot = slot
47
+ this.policy = policy
48
+ this.fs = fs
49
+ this.requestApproval = requestApproval
50
+ this.deviceId = deviceId
51
+ this.name = name
52
+ this.pluginVersion = pluginVersion
53
+ this.dshVersion = dshVersion
54
+ this.capabilities = capabilities
55
+ this.shellTool = shellTool
56
+ this.approvalTimeoutMs = approvalTimeoutMs
57
+ this.delegation = delegation
58
+ }
59
+
60
+ info() {
61
+ return {
62
+ deviceId: this.deviceId,
63
+ name: this.name,
64
+ pluginVersion: this.pluginVersion,
65
+ dshVersion: this.dshVersion,
66
+ capabilities: this.capabilities,
67
+ policy: this.policy.summarize(),
68
+ }
69
+ }
70
+
71
+ async handleRequest({ deviceId, method, params }) {
72
+ switch (method) {
73
+ case 'fleet.ping':
74
+ return { pong: true, deviceId: this.deviceId }
75
+ case 'fleet.info':
76
+ return this.info()
77
+ case 'fleet.tools':
78
+ return { deviceId: this.deviceId, tools: await this.slot.listTools(), policy: this.policy.summarize() }
79
+ case 'fleet.call':
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 ?? {} })
86
+ default:
87
+ throw Object.assign(new Error(`unknown fleet method: ${String(method)}`), { code: 'unknown-method' })
88
+ }
89
+ }
90
+
91
+ async handleCall({ fromDeviceId, request }) {
92
+ const capability = request.capability ?? 'tool'
93
+ const tool = request.tool ?? null
94
+ const args = request.args ?? {}
95
+ const decision = this.policy.decide({ capability, tool, args })
96
+ if (decision.decision === 'deny') {
97
+ throw Object.assign(new Error(decision.reason), { code: 'denied', data: { capability, tool } })
98
+ }
99
+ if (decision.decision === 'ask') {
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' })
105
+ }
106
+ const startedAt = Date.now()
107
+ const result = await this.execute({ capability, tool, args })
108
+ return {
109
+ ...result,
110
+ meta: {
111
+ ...(result.meta ?? {}),
112
+ capability,
113
+ tool,
114
+ deviceId: this.deviceId,
115
+ durationMs: Date.now() - startedAt,
116
+ },
117
+ }
118
+ }
119
+
120
+ async askApproval({ fromDeviceId, capability, tool, args, reason }) {
121
+ if (typeof this.requestApproval !== 'function') return { approved: false, reason: 'no approval channel' }
122
+ try {
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) }
131
+ }
132
+ }
133
+
134
+ async execute({ capability, tool, args }) {
135
+ switch (capability) {
136
+ case 'read':
137
+ case 'grep':
138
+ case 'run':
139
+ case 'tool': {
140
+ const name = capability === 'run' ? this.shellTool : tool
141
+ if (typeof name !== 'string' || name === '') {
142
+ throw Object.assign(new Error(`capability ${capability} needs a tool name`), { code: 'bad-request' })
143
+ }
144
+ return this.slot.executeTool({ name, args })
145
+ }
146
+ case 'list':
147
+ return this.readDirectory(args)
148
+ case 'fetch':
149
+ return this.fetchFile(args)
150
+ default:
151
+ throw Object.assign(new Error(`unsupported capability: ${String(capability)}`), { code: 'unsupported' })
152
+ }
153
+ }
154
+
155
+ async readDirectory(args) {
156
+ if (!this.fs) throw Object.assign(new Error('filesystem is unavailable'), { code: 'unsupported' })
157
+ const path = String(args?.path ?? args?.dir ?? '')
158
+ if (!path) throw Object.assign(new Error('list needs path'), { code: 'bad-request' })
159
+ const target = await this.fs.resolve(path)
160
+ const entries = await this.fs.listDir(target)
161
+ const lines = entries.map((entry) => `${entry.type}\t${entry.name}${entry.size === undefined ? '' : `\t${entry.size}`}`)
162
+ return { isError: false, text: lines.join('\n') || '(empty directory)', content: [], meta: { path } }
163
+ }
164
+
165
+ async fetchFile(args) {
166
+ if (!this.fs) throw Object.assign(new Error('filesystem is unavailable'), { code: 'unsupported' })
167
+ const path = String(args?.path ?? '')
168
+ if (!path) throw Object.assign(new Error('fetch needs path'), { code: 'bad-request' })
169
+ const target = await this.fs.resolve(path)
170
+ if (typeof this.fs.readText !== 'function') {
171
+ throw Object.assign(new Error('fetch is unavailable on this device'), { code: 'unsupported' })
172
+ }
173
+ const text = await this.fs.readText(target)
174
+ return { isError: false, text, content: [], meta: { path, bytes: Buffer.byteLength(text) } }
175
+ }
176
+ }
177
+
178
+ export class DirectCallClient {
179
+ constructor({ node }) {
180
+ this.node = node
181
+ }
182
+
183
+ async info(deviceId) {
184
+ return this.node.request(deviceId, 'fleet.info', {})
185
+ }
186
+
187
+ async listTools(deviceId) {
188
+ return this.node.request(deviceId, 'fleet.tools', {})
189
+ }
190
+
191
+ async call(deviceId, { capability, tool = null, args = {} }, options = {}) {
192
+ return this.node.request(deviceId, 'fleet.call', { capability, tool, args }, options)
193
+ }
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
+
200
+ /** Format a remote result for a model-facing text tool. */
201
+ render(result) {
202
+ if (result?.isError) return `Error: ${result.text || 'remote tool failed'}`
203
+ const meta = result?.meta ?? {}
204
+ const footer = meta.durationMs !== undefined ? `\n[${String(meta.capability ?? 'call')} on ${String(meta.deviceId ?? 'peer')} · ${String(meta.durationMs)}ms]` : ''
205
+ return `${result?.text ?? ''}${footer}`.trim()
206
+ }
207
+ }
208
+
209
+ export { toolText }
@@ -0,0 +1,96 @@
1
+ // Execution Slot: a hidden DSH session/agent that gives remote direct calls a
2
+ // legal tool-execution context (cwd, sandbox policy, approval routing) without
3
+ // running any model turn. See ADR-0003 and ADR-0019.
4
+ import { randomUUID } from 'node:crypto'
5
+
6
+ export const DEFAULT_SHELL_TOOL = process.platform === 'win32' ? 'pwsh' : 'bash'
7
+
8
+ export class ExecutionSlot {
9
+ /**
10
+ * @param {{sessionController: object, tools: object, config?: {cwd?: string, agentPreset?: string}}} options
11
+ */
12
+ constructor({ sessionController, tools, config = {} }) {
13
+ this.sessionController = sessionController
14
+ this.tools = tools
15
+ this.config = config
16
+ this.agent = null
17
+ this.sessionId = null
18
+ this.initializing = null
19
+ }
20
+
21
+ async ensureAgent() {
22
+ if (this.agent) return this.agent
23
+ if (this.initializing) return this.initializing
24
+ this.initializing = (async () => {
25
+ const created = await this.sessionController.create({
26
+ cwd: this.config.cwd,
27
+ agentPreset: this.config.agentPreset,
28
+ })
29
+ const sessionId = created?.sessionId
30
+ if (!sessionId) throw Object.assign(new Error('execution slot could not create a session'), { code: 'slot-unavailable' })
31
+ const resolved = await this.sessionController.resolveAgent(sessionId)
32
+ if (!resolved?.agent) {
33
+ const reason = resolved?.error?.code ?? 'session/agent-busy'
34
+ throw Object.assign(new Error(`execution slot agent unavailable: ${String(reason)}`), { code: 'slot-unavailable' })
35
+ }
36
+ this.sessionId = sessionId
37
+ this.agent = resolved.agent
38
+ return this.agent
39
+ })().finally(() => {
40
+ this.initializing = null
41
+ })
42
+ return this.initializing
43
+ }
44
+
45
+ /** Model-facing tool schemas visible to the execution slot agent. */
46
+ async listTools() {
47
+ const agent = await this.ensureAgent()
48
+ const schemas = this.tools.schemas?.(agent) ?? this.tools.schemas?.() ?? []
49
+ return schemas
50
+ }
51
+
52
+ /**
53
+ * Execute one tool inside the slot context and normalize the result.
54
+ * @param {{name: string, args: object, signal?: AbortSignal}} call
55
+ */
56
+ async executeTool({ name, args = {}, signal }) {
57
+ const agent = await this.ensureAgent()
58
+ // execToolBatch requires a real AbortSignal: without one the pipeline
59
+ // throws before dispatch, which surfaces as "Tool batch execution failed".
60
+ const effectiveSignal = signal ?? new AbortController().signal
61
+ let batch
62
+ try {
63
+ batch = await this.tools.execute({
64
+ calls: [{ id: randomUUID(), name, arguments: JSON.stringify(args ?? {}) }],
65
+ agent,
66
+ signal: effectiveSignal,
67
+ })
68
+ } catch (error) {
69
+ const cause = error?.cause
70
+ const detail = cause?.message ?? error?.message ?? String(error)
71
+ throw Object.assign(new Error(`tool ${name} failed: ${String(detail)}`), {
72
+ code: 'tool-failed',
73
+ cause,
74
+ data: { causeCode: cause?.code ?? null, causeMessage: cause?.message ?? null },
75
+ })
76
+ }
77
+ const first = batch?.results?.[0]?.result
78
+ if (!first) throw Object.assign(new Error(`tool ${name} returned no result`), { code: 'tool-no-result' })
79
+ const text = (first.content ?? [])
80
+ .map((block) => (block?.type === 'text' ? block.text : `[${String(block?.type ?? 'block')}]`))
81
+ .join('\n')
82
+ return {
83
+ isError: first.isError === true,
84
+ text,
85
+ content: first.content ?? [],
86
+ error: first.error ?? null,
87
+ meta: first.meta ?? null,
88
+ }
89
+ }
90
+
91
+ /** Tool names currently visible to the slot agent. */
92
+ async toolNames() {
93
+ const tools = await this.listTools()
94
+ return tools.map((tool) => tool.name).filter((name) => typeof name === 'string')
95
+ }
96
+ }
@@ -0,0 +1,10 @@
1
+ // Public host-side exports for the fleet plugin.
2
+ export * from './store.js'
3
+ export * from './tool-policy.js'
4
+ export * from './execution-slot.js'
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 './room-service.js'
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
+ }