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.
- package/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/client.js +167 -0
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +67 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -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/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +51 -3
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +209 -0
- package/src/host/execution-slot.js +96 -0
- package/src/host/index.js +10 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +156 -0
- package/src/host/tool-policy.js +151 -0
- package/src/host/tools.js +590 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +509 -0
- package/src/net/identity.js +167 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- 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
|
+
}
|