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/plugin/index.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// the six `fleet_*` tools, and the two adoption mechanisms that are cheap to
|
|
5
5
|
// land now (a prompt section and a first-use manual + orchestration nudge).
|
|
6
6
|
// Room, Room Board, delegation and the UI land in later steps.
|
|
7
|
-
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { mkdirSync, readFileSync } from 'node:fs'
|
|
8
8
|
import { networkInterfaces } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
9
10
|
import { fileURLToPath } from 'node:url'
|
|
10
11
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
11
12
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
@@ -13,10 +14,18 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
13
14
|
import { FleetNode } from '../net/fleet-node.js'
|
|
14
15
|
import { accountFingerprint } from '../net/identity.js'
|
|
15
16
|
import { DirectCallClient, DirectCallHost } from '../host/direct-call.js'
|
|
17
|
+
import { DelegationHost } from '../host/delegation.js'
|
|
18
|
+
import { ApprovalInbox } from '../host/approval-inbox.js'
|
|
19
|
+
import { RoomService } from '../host/room-service.js'
|
|
20
|
+
import { registerApprovalRoutes } from '../host/approval-web.js'
|
|
21
|
+
import { registerUiRoutes } from '../host/ui-web.js'
|
|
22
|
+
import { registerConfigRoutes } from '../host/config-web.js'
|
|
23
|
+
import { registerAvatarRoutes } from '../host/avatar-web.js'
|
|
24
|
+
import { RuntimeConfig, deepMerge } from '../host/runtime-config.js'
|
|
16
25
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
17
26
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
18
27
|
import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
|
|
19
|
-
import { accountMatches, createInvite, loadState, saveRoster, writePendingEnrollment } from '../host/store.js'
|
|
28
|
+
import { accountMatches, createInvite, loadState, savePeerHint, saveRoster, writePendingEnrollment } from '../host/store.js'
|
|
20
29
|
|
|
21
30
|
export const name = 'device-fleet'
|
|
22
31
|
|
|
@@ -60,9 +69,11 @@ export const Config = Schema.object({
|
|
|
60
69
|
agentPreset: Schema.string(),
|
|
61
70
|
shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
|
|
62
71
|
approvalTimeoutMs: Schema.number().default(60000),
|
|
72
|
+
delegateTimeoutMs: Schema.number().default(180000),
|
|
73
|
+
subagentProvider: Schema.string().default('spawn'),
|
|
63
74
|
})
|
|
64
|
-
.default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000 })
|
|
65
|
-
.description('Execution Slot
|
|
75
|
+
.default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000, delegateTimeoutMs: 180000, subagentProvider: 'spawn' })
|
|
76
|
+
.description('Execution Slot and delegation settings for direct calls and one-shot delegation.'),
|
|
66
77
|
policy: Schema.object({
|
|
67
78
|
allowedRoots: Schema.array(Schema.string()).default([]),
|
|
68
79
|
capabilities: Schema.any().default({}),
|
|
@@ -73,6 +84,13 @@ export const Config = Schema.object({
|
|
|
73
84
|
.description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
|
|
74
85
|
promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
|
|
75
86
|
autoApproveAsk: Schema.boolean().default(false).description('Testing only: auto-approve every local approval request.'),
|
|
87
|
+
approvals: Schema.object({
|
|
88
|
+
ttlMs: Schema.number().default(60000),
|
|
89
|
+
maxPending: Schema.number().default(50),
|
|
90
|
+
simulation: Schema.boolean().default(false),
|
|
91
|
+
})
|
|
92
|
+
.default({ ttlMs: 60000, maxPending: 50, simulation: false })
|
|
93
|
+
.description('Local approval inbox (ADR-0020). `simulation` exposes fleet_simulate_request for local testing.'),
|
|
76
94
|
})
|
|
77
95
|
|
|
78
96
|
function packageVersion() {
|
|
@@ -113,7 +131,7 @@ function withManual(definitions) {
|
|
|
113
131
|
}))
|
|
114
132
|
}
|
|
115
133
|
|
|
116
|
-
export function apply(ctx,
|
|
134
|
+
export function apply(ctx, profileConfig) {
|
|
117
135
|
const pluginVersion = packageVersion()
|
|
118
136
|
const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
|
|
119
137
|
// Optional services: Cordis throws on direct property access for services the
|
|
@@ -129,12 +147,30 @@ export function apply(ctx, config) {
|
|
|
129
147
|
|
|
130
148
|
let state
|
|
131
149
|
try {
|
|
132
|
-
state = loadState({ dshHome: process.env.DSH_HOME, dataDir:
|
|
150
|
+
state = loadState({ dshHome: process.env.DSH_HOME, dataDir: profileConfig?.dataDir, name: profileConfig?.deviceName })
|
|
133
151
|
} catch (error) {
|
|
134
152
|
logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
|
|
135
153
|
return
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
// Runtime config: profile patch is the base, UI edits are persisted in the
|
|
157
|
+
// state dir and applied live (no plugin/DSH restart).
|
|
158
|
+
const runtime = new RuntimeConfig({
|
|
159
|
+
base: profileConfig ?? {},
|
|
160
|
+
overridesFile: join(state.dataDir, 'config-overrides.json'),
|
|
161
|
+
})
|
|
162
|
+
// Migration: an old UI save wrote the legacy `tool: deny` + empty allowlist;
|
|
163
|
+
// raw tools now follow the DSH pipeline by default.
|
|
164
|
+
if (
|
|
165
|
+
runtime.overrides?.policy?.capabilities?.tool === 'deny' &&
|
|
166
|
+
(runtime.overrides?.policy?.allowedTools?.length ?? 0) === 0
|
|
167
|
+
) {
|
|
168
|
+
runtime.overrides = deepMerge(runtime.overrides, { policy: { capabilities: { tool: 'auto' } } })
|
|
169
|
+
runtime.current = deepMerge(runtime.base, runtime.overrides)
|
|
170
|
+
runtime.persist()
|
|
171
|
+
}
|
|
172
|
+
const config = runtime.current
|
|
173
|
+
|
|
138
174
|
const node = new FleetNode({
|
|
139
175
|
identity: state.identity,
|
|
140
176
|
fleet: state.fleet,
|
|
@@ -144,7 +180,7 @@ export function apply(ctx, config) {
|
|
|
144
180
|
discovery: config.discovery ? true : false,
|
|
145
181
|
relay: config.relay ?? state.enrollment?.relay ?? null,
|
|
146
182
|
roster: state.roster,
|
|
147
|
-
peerHints: state.enrollment?.endpoints ?? [],
|
|
183
|
+
peerHints: [...(state.enrollment?.endpoints ?? []), ...(state.peerHints ?? [])],
|
|
148
184
|
pluginVersion,
|
|
149
185
|
dshVersion,
|
|
150
186
|
capabilities: CAPABILITIES,
|
|
@@ -153,11 +189,219 @@ export function apply(ctx, config) {
|
|
|
153
189
|
const policy = new ToolPolicy(config.policy)
|
|
154
190
|
const sessionController = service('sessionController')
|
|
155
191
|
const fs = service('fs')
|
|
192
|
+
const subagents = service('subagents')
|
|
193
|
+
const agentPresets = service('agentPresets')
|
|
194
|
+
const workingDirectory = service('workingDirectory')
|
|
195
|
+
const inbox = new ApprovalInbox({
|
|
196
|
+
ttlMs: config.approvals?.ttlMs ?? 60_000,
|
|
197
|
+
maxPending: config.approvals?.maxPending ?? 50,
|
|
198
|
+
})
|
|
199
|
+
inbox.on('request', (request) => logger.info?.(`[device-fleet] approval requested by ${request.fromDeviceId}: ${request.capability}/${String(request.tool ?? '')}`))
|
|
200
|
+
const approvalsApi = {
|
|
201
|
+
list: () => inbox.list(),
|
|
202
|
+
approve: (id, options) => inbox.approve(id, options),
|
|
203
|
+
deny: (id, reason) => inbox.deny(id, reason),
|
|
204
|
+
simulate: async ({ fromDeviceId, capability, tool, reason }) => {
|
|
205
|
+
if (runtime.current.approvals?.simulation !== true) {
|
|
206
|
+
throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
|
|
207
|
+
}
|
|
208
|
+
const promise = inbox.request({
|
|
209
|
+
fromDeviceId: fromDeviceId ?? 'simulated-device',
|
|
210
|
+
capability: capability ?? 'run',
|
|
211
|
+
tool: tool ?? null,
|
|
212
|
+
reason: reason ?? 'simulated approval request',
|
|
213
|
+
args: {},
|
|
214
|
+
})
|
|
215
|
+
const summary = inbox.list().at(-1)
|
|
216
|
+
// Keep the promise from becoming an unhandled rejection when the
|
|
217
|
+
// request expires without an answer; the demo only inspects the inbox.
|
|
218
|
+
promise.catch(() => {})
|
|
219
|
+
return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
const requestApproval = async (request) =>
|
|
223
|
+
runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
|
|
224
|
+
|
|
225
|
+
const rooms = new RoomService({
|
|
226
|
+
sessionController,
|
|
227
|
+
createUserMessage,
|
|
228
|
+
stateFile: join(state.dataDir, 'rooms.json'),
|
|
229
|
+
})
|
|
230
|
+
// User input is routed to a Room only while its Conversation View is open;
|
|
231
|
+
// normal Chat with the host agent must not wake participants. Focus expires so
|
|
232
|
+
// a hidden/unmounted view cannot leave a stale route.
|
|
233
|
+
const focusedRooms = new Map()
|
|
234
|
+
const ROOM_FOCUS_TTL_MS = 20_000
|
|
235
|
+
|
|
236
|
+
// Client approval panel data plane (dev.9). Optional service: host-only
|
|
237
|
+
// deployments simply do not expose the panel routes.
|
|
238
|
+
const webServer = service('webServer')
|
|
239
|
+
const workspaceDir = join(state.dataDir, 'workspace')
|
|
240
|
+
const getOptions = async () => {
|
|
241
|
+
const addresses = new Set(['0.0.0.0', '127.0.0.1', runtime.current.direct?.host ?? ''])
|
|
242
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
243
|
+
for (const entry of entries ?? []) {
|
|
244
|
+
if (entry.family === 'IPv4' && !entry.internal) addresses.add(entry.address)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
addresses.delete('')
|
|
248
|
+
let toolNames = []
|
|
249
|
+
try {
|
|
250
|
+
toolNames = (ctx.tools.definitions?.() ?? []).map((definition) => definition.name)
|
|
251
|
+
} catch {
|
|
252
|
+
toolNames = []
|
|
253
|
+
}
|
|
254
|
+
const shellCandidates = ['pwsh', 'bash', 'cmd', 'sh']
|
|
255
|
+
let shells = shellCandidates.filter((name) => toolNames.includes(name))
|
|
256
|
+
if (shells.length === 0) shells = [process.platform === 'win32' ? 'pwsh' : 'bash']
|
|
257
|
+
let presets = []
|
|
258
|
+
try {
|
|
259
|
+
presets = (await agentPresets?.list?.()) ?? []
|
|
260
|
+
} catch {
|
|
261
|
+
presets = []
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
platform: process.platform,
|
|
265
|
+
addresses: [...addresses],
|
|
266
|
+
shells,
|
|
267
|
+
subagentProviders: (() => {
|
|
268
|
+
try {
|
|
269
|
+
return subagents?.list?.() ?? ['spawn']
|
|
270
|
+
} catch {
|
|
271
|
+
return ['spawn']
|
|
272
|
+
}
|
|
273
|
+
})(),
|
|
274
|
+
tools: [...toolNames].sort(),
|
|
275
|
+
presets: presets.map((preset) => ({
|
|
276
|
+
id: preset.id ?? preset.name,
|
|
277
|
+
label: preset.name ?? preset.title ?? preset.id,
|
|
278
|
+
})),
|
|
279
|
+
cwd: {
|
|
280
|
+
workspace: workspaceDir,
|
|
281
|
+
dshHome: process.env.DSH_HOME ?? '',
|
|
282
|
+
defaultDirectory: workingDirectory?.defaultDirectory ?? '',
|
|
283
|
+
current: runtime.current.execution?.cwd ?? '',
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let pairingRef = null
|
|
288
|
+
if (webServer) {
|
|
289
|
+
ctx.effect(() =>
|
|
290
|
+
registerAvatarRoutes({
|
|
291
|
+
webServer,
|
|
292
|
+
dir: fileURLToPath(new URL('../../assets/avatars/', import.meta.url)),
|
|
293
|
+
libraryDir: join(state.dataDir, 'avatars'),
|
|
294
|
+
logger,
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
|
|
298
|
+
ctx.effect(() =>
|
|
299
|
+
registerConfigRoutes({
|
|
300
|
+
webServer,
|
|
301
|
+
runtime,
|
|
302
|
+
getOptions,
|
|
303
|
+
preparePatch: async (patch) => {
|
|
304
|
+
const cwd = patch?.execution?.cwd
|
|
305
|
+
if (typeof cwd === 'string' && cwd !== '') {
|
|
306
|
+
try {
|
|
307
|
+
mkdirSync(cwd, { recursive: true })
|
|
308
|
+
} catch (error) {
|
|
309
|
+
logger.warn?.(`[device-fleet] cannot create execution cwd: ${String(error?.message ?? error)}`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return patch
|
|
313
|
+
},
|
|
314
|
+
logger,
|
|
315
|
+
}),
|
|
316
|
+
)
|
|
317
|
+
const uiActions = {
|
|
318
|
+
async sendRoomMessage({ roomId, to, text } = {}) {
|
|
319
|
+
if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
|
|
320
|
+
const memberIds = rooms.status(roomId).members.map((member) => member.memberId)
|
|
321
|
+
const hostMemberId = `host:${roomId}`
|
|
322
|
+
const defaultRecipients = memberIds.filter((id) => id !== hostMemberId)
|
|
323
|
+
const recipients = Array.isArray(to) && to.length > 0 ? to : defaultRecipients.length > 0 ? defaultRecipients : [hostMemberId]
|
|
324
|
+
const result = await rooms.send({ roomId, from: { kind: 'user' }, to: recipients, text })
|
|
325
|
+
return { roomId, ...result }
|
|
326
|
+
},
|
|
327
|
+
async roomTask({ roomId, action, ...rest } = {}) {
|
|
328
|
+
if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
|
|
329
|
+
if (action === 'add') {
|
|
330
|
+
return rooms.addTask({ roomId, title: rest.title, owner: rest.owner, dependsOn: rest.dependsOn ?? [], description: rest.description ?? '' })
|
|
331
|
+
}
|
|
332
|
+
if (action === 'update') {
|
|
333
|
+
return rooms.updateTask({ roomId, id: rest.id, status: rest.status, note: rest.note })
|
|
334
|
+
}
|
|
335
|
+
throw Object.assign(new Error('action must be add or update'), { code: 'bad-request' })
|
|
336
|
+
},
|
|
337
|
+
closeRoom: ({ roomId, reason } = {}) => rooms.close({ roomId, reason: reason ?? 'closed by user' }),
|
|
338
|
+
setRoomFocus: ({ roomId, focused } = {}) => {
|
|
339
|
+
if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
|
|
340
|
+
if (focused === true) focusedRooms.set(roomId, Date.now())
|
|
341
|
+
else focusedRooms.delete(roomId)
|
|
342
|
+
return { roomId, focused: focused === true }
|
|
343
|
+
},
|
|
344
|
+
removeMember: ({ roomId, memberId, reason } = {}) => rooms.removeMember({ roomId, memberId, reason: reason ?? 'removed by user' }),
|
|
345
|
+
async upgradeRoom({ sessionId } = {}) {
|
|
346
|
+
if (typeof sessionId !== 'string' || sessionId === '') throw Object.assign(new Error('sessionId is required'), { code: 'bad-request' })
|
|
347
|
+
if (!sessionController?.resolveAgent) throw Object.assign(new Error('session agent is unavailable'), { code: 'unsupported' })
|
|
348
|
+
const resolved = await sessionController.resolveAgent(sessionId)
|
|
349
|
+
if (!resolved?.agent) throw Object.assign(new Error('session agent is unavailable'), { code: 'unsupported' })
|
|
350
|
+
return rooms.upgradeHost({ agent: resolved.agent })
|
|
351
|
+
},
|
|
352
|
+
createInvite: async () => {
|
|
353
|
+
if (!pairingRef?.invite) throw Object.assign(new Error('pairing is unavailable'), { code: 'unsupported' })
|
|
354
|
+
return pairingRef.invite()
|
|
355
|
+
},
|
|
356
|
+
connectAddress: async ({ host, port } = {}) => {
|
|
357
|
+
if (typeof host !== 'string' || !Number.isInteger(port) || port <= 0) {
|
|
358
|
+
throw Object.assign(new Error('host and port are required'), { code: 'bad-request' })
|
|
359
|
+
}
|
|
360
|
+
const peer = await node.connectAddress(host, port)
|
|
361
|
+
try {
|
|
362
|
+
savePeerHint(state, { host, port })
|
|
363
|
+
} catch {
|
|
364
|
+
/* best effort */
|
|
365
|
+
}
|
|
366
|
+
return { deviceId: peer.deviceId, name: peer.name ?? 'device' }
|
|
367
|
+
},
|
|
368
|
+
}
|
|
369
|
+
ctx.effect(() =>
|
|
370
|
+
registerUiRoutes({
|
|
371
|
+
webServer,
|
|
372
|
+
inbox,
|
|
373
|
+
rooms,
|
|
374
|
+
logger,
|
|
375
|
+
actions: uiActions,
|
|
376
|
+
getOverview: () => ({
|
|
377
|
+
...(node.status?.() ?? {}),
|
|
378
|
+
rooms: rooms.list(),
|
|
379
|
+
approvals: inbox.list(),
|
|
380
|
+
approvalHistory: inbox.history?.() ?? [],
|
|
381
|
+
}),
|
|
382
|
+
}),
|
|
383
|
+
)
|
|
384
|
+
}
|
|
156
385
|
if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
|
|
157
386
|
const slot = sessionController
|
|
158
387
|
? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
|
|
159
388
|
: null
|
|
160
389
|
|
|
390
|
+
const delegation =
|
|
391
|
+
slot && subagents
|
|
392
|
+
? new DelegationHost({
|
|
393
|
+
subagents,
|
|
394
|
+
slot,
|
|
395
|
+
policy,
|
|
396
|
+
deviceId: state.identity.deviceId,
|
|
397
|
+
defaultCwd: config.execution?.cwd,
|
|
398
|
+
defaultAgentPreset: config.execution?.agentPreset,
|
|
399
|
+
provider: config.execution?.subagentProvider ?? 'spawn',
|
|
400
|
+
timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
|
|
401
|
+
requestApproval,
|
|
402
|
+
})
|
|
403
|
+
: null
|
|
404
|
+
|
|
161
405
|
const host = slot
|
|
162
406
|
? new DirectCallHost({
|
|
163
407
|
slot,
|
|
@@ -170,15 +414,116 @@ export function apply(ctx, config) {
|
|
|
170
414
|
capabilities: CAPABILITIES,
|
|
171
415
|
shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
|
|
172
416
|
approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
: async (request) => {
|
|
176
|
-
logger.info?.(`[device-fleet] approval required from ${request.fromDeviceId} for ${request.capability}/${String(request.tool)} (no approval UI yet)`)
|
|
177
|
-
return false
|
|
178
|
-
},
|
|
417
|
+
delegation,
|
|
418
|
+
requestApproval,
|
|
179
419
|
})
|
|
180
420
|
: null
|
|
181
421
|
|
|
422
|
+
// Mirror the normal DSH approval behavior for tools executed in the hidden
|
|
423
|
+
// Execution Slot: the tool pipeline asks here when its own pre-execute policy
|
|
424
|
+
// requires approval, and this device's approval inbox answers.
|
|
425
|
+
ctx.effect(() => {
|
|
426
|
+
if (!slot) return () => {}
|
|
427
|
+
const handler = async (request, next) => {
|
|
428
|
+
try {
|
|
429
|
+
if (request?.signal?.aborted) return 'cancelled'
|
|
430
|
+
if (!slot.sessionId || request?.agent?.session?.id !== slot.sessionId) return next()
|
|
431
|
+
const toolName = typeof request?.toolName === 'string' ? request.toolName : 'tool'
|
|
432
|
+
const outcome = await inbox.request({
|
|
433
|
+
fromDeviceId: 'local-tool-pipeline',
|
|
434
|
+
capability: 'tool',
|
|
435
|
+
tool: toolName,
|
|
436
|
+
reason: request?.displayReason ?? request?.reason ?? `tool "${toolName}" requires approval`,
|
|
437
|
+
args: {},
|
|
438
|
+
})
|
|
439
|
+
if (outcome.approved === true) return 'allowed-once'
|
|
440
|
+
if (outcome.reason === 'inbox-closed') return 'cancelled'
|
|
441
|
+
return 'rejected'
|
|
442
|
+
} catch {
|
|
443
|
+
return next()
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return ctx.on('approval/request', handler)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// A Room reuses the normal DSH composer: every user message in a Room Host
|
|
450
|
+
// session is logged as a Room message. `@name` addresses specific members;
|
|
451
|
+
// without mentions it goes to every participant. Host itself already has the
|
|
452
|
+
// message as normal Chat input, so it is never re-woken.
|
|
453
|
+
ctx.effect(() =>
|
|
454
|
+
ctx.on?.('session/event', (session, event) => {
|
|
455
|
+
try {
|
|
456
|
+
if (!session?.id || event?.type !== 'user/message') return
|
|
457
|
+
const room = rooms.rooms.get(session.id)
|
|
458
|
+
if (!room) return
|
|
459
|
+
if (!focusedRooms.has(session.id)) return
|
|
460
|
+
if (Date.now() - focusedRooms.get(session.id) > ROOM_FOCUS_TTL_MS) {
|
|
461
|
+
focusedRooms.delete(session.id)
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
const message = event.data
|
|
465
|
+
if (message?.source?.kind && message.source.kind !== 'user') return
|
|
466
|
+
const text = (Array.isArray(message?.content) ? message.content : [])
|
|
467
|
+
.filter((block) => block?.type === 'text')
|
|
468
|
+
.map((block) => block.text)
|
|
469
|
+
.join('\n')
|
|
470
|
+
.trim()
|
|
471
|
+
if (text === '' || text.startsWith('[Room]') || text.startsWith('[Fleet Room')) return
|
|
472
|
+
const members = rooms.status(session.id).members
|
|
473
|
+
const hostMemberId = `host:${session.id}`
|
|
474
|
+
const participants = members.filter((member) => member.role !== 'host')
|
|
475
|
+
const mentioned = members.filter((member) => member.name && text.includes('@' + member.name))
|
|
476
|
+
const recipients = mentioned.length > 0
|
|
477
|
+
? mentioned.map((member) => member.memberId)
|
|
478
|
+
: participants.map((member) => member.memberId)
|
|
479
|
+
if (recipients.length === 0) return
|
|
480
|
+
let body = text
|
|
481
|
+
for (const member of mentioned) body = body.split('@' + member.name).join('')
|
|
482
|
+
body = body.trim() || '(点名)'
|
|
483
|
+
void rooms.send({
|
|
484
|
+
roomId: session.id,
|
|
485
|
+
from: { kind: 'user' },
|
|
486
|
+
to: recipients,
|
|
487
|
+
text: body,
|
|
488
|
+
skipWakeFor: [hostMemberId],
|
|
489
|
+
}).catch((error) => {
|
|
490
|
+
logger.warn?.(`[device-fleet] cannot route room message: ${String(error?.message ?? error)}`)
|
|
491
|
+
})
|
|
492
|
+
} catch (error) {
|
|
493
|
+
logger.warn?.(`[device-fleet] room message routing failed: ${String(error?.message ?? error)}`)
|
|
494
|
+
}
|
|
495
|
+
}),
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
// The host agent's own Chat replies are mirrored into the Room transcript as
|
|
499
|
+
// host speech (read-only for participants: recorded, never re-woken).
|
|
500
|
+
ctx.effect(() =>
|
|
501
|
+
ctx.on?.('session/event', (session, event) => {
|
|
502
|
+
try {
|
|
503
|
+
if (!session?.id || event?.type !== 'assistant/message') return
|
|
504
|
+
const room = rooms.rooms.get(session.id)
|
|
505
|
+
if (!room) return
|
|
506
|
+
const message = event.data
|
|
507
|
+
const text = (Array.isArray(message?.content) ? message.content : [])
|
|
508
|
+
.filter((block) => block?.type === 'text')
|
|
509
|
+
.map((block) => block.text)
|
|
510
|
+
.join('\n')
|
|
511
|
+
.trim()
|
|
512
|
+
if (text === '') return
|
|
513
|
+
const hostMemberId = `host:${session.id}`
|
|
514
|
+
void rooms.send({
|
|
515
|
+
roomId: session.id,
|
|
516
|
+
from: { sessionId: session.id },
|
|
517
|
+
to: [hostMemberId],
|
|
518
|
+
text,
|
|
519
|
+
skipWakeFor: [hostMemberId],
|
|
520
|
+
}).catch(() => {})
|
|
521
|
+
} catch {
|
|
522
|
+
/* ignore */
|
|
523
|
+
}
|
|
524
|
+
}),
|
|
525
|
+
)
|
|
526
|
+
|
|
182
527
|
node.onRequest(async (request) => {
|
|
183
528
|
if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
|
|
184
529
|
return host.handleRequest(request)
|
|
@@ -235,7 +580,7 @@ export function apply(ctx, config) {
|
|
|
235
580
|
: (state.enrollment?.accountFingerprint ?? null)
|
|
236
581
|
return createInvite(state, {
|
|
237
582
|
accountFingerprint: fingerprint,
|
|
238
|
-
relay:
|
|
583
|
+
relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
|
|
239
584
|
endpoints: localEndpoints(),
|
|
240
585
|
})
|
|
241
586
|
},
|
|
@@ -243,20 +588,35 @@ export function apply(ctx, config) {
|
|
|
243
588
|
writePendingEnrollment(state, code)
|
|
244
589
|
},
|
|
245
590
|
}
|
|
246
|
-
|
|
591
|
+
pairingRef = pairing
|
|
592
|
+
const handlers = createFleetHandlers({
|
|
593
|
+
node,
|
|
594
|
+
client,
|
|
595
|
+
pairing,
|
|
596
|
+
onPeerHint: async (hint) => {
|
|
597
|
+
try {
|
|
598
|
+
savePeerHint(state, hint)
|
|
599
|
+
} catch (error) {
|
|
600
|
+
logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
approvals: approvalsApi,
|
|
604
|
+
rooms,
|
|
605
|
+
})
|
|
247
606
|
const definitions = createFleetToolDefinitions({ defineTool, handlers })
|
|
248
607
|
const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
|
|
249
608
|
for (const definition of wrapped) ctx.tools.register(definition)
|
|
250
609
|
|
|
251
610
|
// Adoption layer 2: one sentence, only when the Fleet has members.
|
|
252
611
|
const systemPrompt = service('systemPrompt')
|
|
253
|
-
if (systemPrompt
|
|
612
|
+
if (systemPrompt) {
|
|
254
613
|
const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
|
|
255
614
|
ctx.effect(() =>
|
|
256
615
|
systemPrompt.section({
|
|
257
616
|
name: 'device-fleet:members',
|
|
258
617
|
order,
|
|
259
618
|
text: () => {
|
|
619
|
+
if (runtime.current.promptSection === false) return ''
|
|
260
620
|
const members = node.rosterEntries().length
|
|
261
621
|
if (members === 0) return ''
|
|
262
622
|
return `This DSH instance is part of a device Fleet with ${members} other device(s). Use fleet_list/fleet_read/fleet_fetch/fleet_run for work that belongs on another device; direct calls fail fast when that device is offline.`
|
|
@@ -265,6 +625,48 @@ export function apply(ctx, config) {
|
|
|
265
625
|
)
|
|
266
626
|
}
|
|
267
627
|
|
|
628
|
+
// Live config application: every UI save runs these handlers in-process.
|
|
629
|
+
runtime.onChange(async (next, previous) => {
|
|
630
|
+
if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
|
|
631
|
+
policy = new ToolPolicy(next.policy)
|
|
632
|
+
if (host) host.policy = policy
|
|
633
|
+
if (delegation) delegation.policy = policy
|
|
634
|
+
}
|
|
635
|
+
if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
|
|
636
|
+
slot?.setConfig(next.execution)
|
|
637
|
+
if (host) {
|
|
638
|
+
host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
|
|
639
|
+
host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
|
|
640
|
+
}
|
|
641
|
+
if (delegation) {
|
|
642
|
+
delegation.defaultCwd = next.execution?.cwd
|
|
643
|
+
delegation.defaultAgentPreset = next.execution?.agentPreset
|
|
644
|
+
delegation.provider = next.execution?.subagentProvider ?? 'spawn'
|
|
645
|
+
delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
|
|
649
|
+
if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
|
|
650
|
+
if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
|
|
651
|
+
}
|
|
652
|
+
if (next.deviceName !== previous?.deviceName) {
|
|
653
|
+
node.name = next.deviceName
|
|
654
|
+
if (host) host.name = next.deviceName
|
|
655
|
+
}
|
|
656
|
+
const networkChanged =
|
|
657
|
+
JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
|
|
658
|
+
next.discovery !== previous?.discovery ||
|
|
659
|
+
JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
|
|
660
|
+
if (networkChanged) {
|
|
661
|
+
await node.reconfigure({
|
|
662
|
+
name: next.deviceName,
|
|
663
|
+
direct: next.direct,
|
|
664
|
+
discovery: next.discovery ? true : false,
|
|
665
|
+
relay: next.relay ?? null,
|
|
666
|
+
})
|
|
667
|
+
}
|
|
668
|
+
})
|
|
669
|
+
|
|
268
670
|
// Adoption layer 4: one-line nudge when the model starts orchestrating.
|
|
269
671
|
ctx.on?.('tools/post-execute', async (exec, _result, next) => {
|
|
270
672
|
const decision = await next()
|
|
@@ -292,6 +694,9 @@ export function apply(ctx, config) {
|
|
|
292
694
|
})
|
|
293
695
|
|
|
294
696
|
ctx.effect(() => () => {
|
|
697
|
+
inbox.close()
|
|
698
|
+
// Rooms are intentionally left open (and persisted) so a plugin reload can
|
|
699
|
+
// rehydrate them instead of destroying a running collaboration.
|
|
295
700
|
void node.close()
|
|
296
701
|
})
|
|
297
702
|
|