dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.21
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/client.js +1176 -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 +93 -4
- 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 +10 -1
- package/src/host/approval-inbox.js +163 -0
- package/src/host/approval-web.js +95 -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 +7 -0
- package/src/host/room-service.js +431 -0
- package/src/host/runtime-config.js +192 -0
- package/src/host/store.js +31 -3
- package/src/host/tool-policy.js +13 -10
- package/src/host/tools.js +376 -1
- package/src/host/ui-web.js +133 -0
- package/src/net/fleet-node.js +118 -1
- package/src/net/identity.js +12 -1
- package/src/plugin/index.js +366 -15
package/src/plugin/index.js
CHANGED
|
@@ -4,7 +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
|
+
import { networkInterfaces } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
8
10
|
import { fileURLToPath } from 'node:url'
|
|
9
11
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
10
12
|
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
@@ -12,10 +14,17 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
12
14
|
import { FleetNode } from '../net/fleet-node.js'
|
|
13
15
|
import { accountFingerprint } from '../net/identity.js'
|
|
14
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 { RuntimeConfig, deepMerge } from '../host/runtime-config.js'
|
|
15
24
|
import { ExecutionSlot, DEFAULT_SHELL_TOOL } from '../host/execution-slot.js'
|
|
16
25
|
import { ToolPolicy } from '../host/tool-policy.js'
|
|
17
26
|
import { createFleetHandlers, createFleetToolDefinitions } from '../host/tools.js'
|
|
18
|
-
import { accountMatches, createInvite, loadState, saveRoster, writePendingEnrollment } from '../host/store.js'
|
|
27
|
+
import { accountMatches, createInvite, loadState, savePeerHint, saveRoster, writePendingEnrollment } from '../host/store.js'
|
|
19
28
|
|
|
20
29
|
export const name = 'device-fleet'
|
|
21
30
|
|
|
@@ -59,9 +68,11 @@ export const Config = Schema.object({
|
|
|
59
68
|
agentPreset: Schema.string(),
|
|
60
69
|
shellTool: Schema.string().default(DEFAULT_SHELL_TOOL),
|
|
61
70
|
approvalTimeoutMs: Schema.number().default(60000),
|
|
71
|
+
delegateTimeoutMs: Schema.number().default(180000),
|
|
72
|
+
subagentProvider: Schema.string().default('spawn'),
|
|
62
73
|
})
|
|
63
|
-
.default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000 })
|
|
64
|
-
.description('Execution Slot
|
|
74
|
+
.default({ shellTool: DEFAULT_SHELL_TOOL, approvalTimeoutMs: 60000, delegateTimeoutMs: 180000, subagentProvider: 'spawn' })
|
|
75
|
+
.description('Execution Slot and delegation settings for direct calls and one-shot delegation.'),
|
|
65
76
|
policy: Schema.object({
|
|
66
77
|
allowedRoots: Schema.array(Schema.string()).default([]),
|
|
67
78
|
capabilities: Schema.any().default({}),
|
|
@@ -72,6 +83,13 @@ export const Config = Schema.object({
|
|
|
72
83
|
.description('Direct-call Tool Policy owned by this device (ADR-0004/ADR-0019).'),
|
|
73
84
|
promptSection: Schema.boolean().default(true).description('Render the one-sentence Fleet nudge when the Fleet has members.'),
|
|
74
85
|
autoApproveAsk: Schema.boolean().default(false).description('Testing only: auto-approve every local approval request.'),
|
|
86
|
+
approvals: Schema.object({
|
|
87
|
+
ttlMs: Schema.number().default(60000),
|
|
88
|
+
maxPending: Schema.number().default(50),
|
|
89
|
+
simulation: Schema.boolean().default(false),
|
|
90
|
+
})
|
|
91
|
+
.default({ ttlMs: 60000, maxPending: 50, simulation: false })
|
|
92
|
+
.description('Local approval inbox (ADR-0020). `simulation` exposes fleet_simulate_request for local testing.'),
|
|
75
93
|
})
|
|
76
94
|
|
|
77
95
|
function packageVersion() {
|
|
@@ -112,7 +130,7 @@ function withManual(definitions) {
|
|
|
112
130
|
}))
|
|
113
131
|
}
|
|
114
132
|
|
|
115
|
-
export function apply(ctx,
|
|
133
|
+
export function apply(ctx, profileConfig) {
|
|
116
134
|
const pluginVersion = packageVersion()
|
|
117
135
|
const dshVersion = process.env.DSH_CLIENT_VERSION ?? 'unknown'
|
|
118
136
|
// Optional services: Cordis throws on direct property access for services the
|
|
@@ -128,12 +146,30 @@ export function apply(ctx, config) {
|
|
|
128
146
|
|
|
129
147
|
let state
|
|
130
148
|
try {
|
|
131
|
-
state = loadState({ dshHome: process.env.DSH_HOME, dataDir:
|
|
149
|
+
state = loadState({ dshHome: process.env.DSH_HOME, dataDir: profileConfig?.dataDir, name: profileConfig?.deviceName })
|
|
132
150
|
} catch (error) {
|
|
133
151
|
logger.error?.(`[device-fleet] cannot load state: ${String(error?.message ?? error)}`)
|
|
134
152
|
return
|
|
135
153
|
}
|
|
136
154
|
|
|
155
|
+
// Runtime config: profile patch is the base, UI edits are persisted in the
|
|
156
|
+
// state dir and applied live (no plugin/DSH restart).
|
|
157
|
+
const runtime = new RuntimeConfig({
|
|
158
|
+
base: profileConfig ?? {},
|
|
159
|
+
overridesFile: join(state.dataDir, 'config-overrides.json'),
|
|
160
|
+
})
|
|
161
|
+
// Migration: an old UI save wrote the legacy `tool: deny` + empty allowlist;
|
|
162
|
+
// raw tools now follow the DSH pipeline by default.
|
|
163
|
+
if (
|
|
164
|
+
runtime.overrides?.policy?.capabilities?.tool === 'deny' &&
|
|
165
|
+
(runtime.overrides?.policy?.allowedTools?.length ?? 0) === 0
|
|
166
|
+
) {
|
|
167
|
+
runtime.overrides = deepMerge(runtime.overrides, { policy: { capabilities: { tool: 'auto' } } })
|
|
168
|
+
runtime.current = deepMerge(runtime.base, runtime.overrides)
|
|
169
|
+
runtime.persist()
|
|
170
|
+
}
|
|
171
|
+
const config = runtime.current
|
|
172
|
+
|
|
137
173
|
const node = new FleetNode({
|
|
138
174
|
identity: state.identity,
|
|
139
175
|
fleet: state.fleet,
|
|
@@ -143,6 +179,7 @@ export function apply(ctx, config) {
|
|
|
143
179
|
discovery: config.discovery ? true : false,
|
|
144
180
|
relay: config.relay ?? state.enrollment?.relay ?? null,
|
|
145
181
|
roster: state.roster,
|
|
182
|
+
peerHints: [...(state.enrollment?.endpoints ?? []), ...(state.peerHints ?? [])],
|
|
146
183
|
pluginVersion,
|
|
147
184
|
dshVersion,
|
|
148
185
|
capabilities: CAPABILITIES,
|
|
@@ -151,11 +188,198 @@ export function apply(ctx, config) {
|
|
|
151
188
|
const policy = new ToolPolicy(config.policy)
|
|
152
189
|
const sessionController = service('sessionController')
|
|
153
190
|
const fs = service('fs')
|
|
191
|
+
const subagents = service('subagents')
|
|
192
|
+
const agentPresets = service('agentPresets')
|
|
193
|
+
const workingDirectory = service('workingDirectory')
|
|
194
|
+
const inbox = new ApprovalInbox({
|
|
195
|
+
ttlMs: config.approvals?.ttlMs ?? 60_000,
|
|
196
|
+
maxPending: config.approvals?.maxPending ?? 50,
|
|
197
|
+
})
|
|
198
|
+
inbox.on('request', (request) => logger.info?.(`[device-fleet] approval requested by ${request.fromDeviceId}: ${request.capability}/${String(request.tool ?? '')}`))
|
|
199
|
+
const approvalsApi = {
|
|
200
|
+
list: () => inbox.list(),
|
|
201
|
+
approve: (id, options) => inbox.approve(id, options),
|
|
202
|
+
deny: (id, reason) => inbox.deny(id, reason),
|
|
203
|
+
simulate: async ({ fromDeviceId, capability, tool, reason }) => {
|
|
204
|
+
if (runtime.current.approvals?.simulation !== true) {
|
|
205
|
+
throw Object.assign(new Error('approval simulation is disabled'), { code: 'unsupported' })
|
|
206
|
+
}
|
|
207
|
+
const promise = inbox.request({
|
|
208
|
+
fromDeviceId: fromDeviceId ?? 'simulated-device',
|
|
209
|
+
capability: capability ?? 'run',
|
|
210
|
+
tool: tool ?? null,
|
|
211
|
+
reason: reason ?? 'simulated approval request',
|
|
212
|
+
args: {},
|
|
213
|
+
})
|
|
214
|
+
const summary = inbox.list().at(-1)
|
|
215
|
+
// Keep the promise from becoming an unhandled rejection when the
|
|
216
|
+
// request expires without an answer; the demo only inspects the inbox.
|
|
217
|
+
promise.catch(() => {})
|
|
218
|
+
return `simulated approval request ${summary?.id ?? '?'} created; use fleet_approvals / fleet_approve / fleet_deny`
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
const requestApproval = async (request) =>
|
|
222
|
+
runtime.current.autoApproveAsk === true ? { approved: true, scope: 'once' } : inbox.request(request)
|
|
223
|
+
|
|
224
|
+
const rooms = new RoomService({
|
|
225
|
+
sessionController,
|
|
226
|
+
createUserMessage,
|
|
227
|
+
stateFile: join(state.dataDir, 'rooms.json'),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// Client approval panel data plane (dev.9). Optional service: host-only
|
|
231
|
+
// deployments simply do not expose the panel routes.
|
|
232
|
+
const webServer = service('webServer')
|
|
233
|
+
const workspaceDir = join(state.dataDir, 'workspace')
|
|
234
|
+
const getOptions = async () => {
|
|
235
|
+
const addresses = new Set(['0.0.0.0', '127.0.0.1', runtime.current.direct?.host ?? ''])
|
|
236
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
237
|
+
for (const entry of entries ?? []) {
|
|
238
|
+
if (entry.family === 'IPv4' && !entry.internal) addresses.add(entry.address)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
addresses.delete('')
|
|
242
|
+
let toolNames = []
|
|
243
|
+
try {
|
|
244
|
+
toolNames = (ctx.tools.definitions?.() ?? []).map((definition) => definition.name)
|
|
245
|
+
} catch {
|
|
246
|
+
toolNames = []
|
|
247
|
+
}
|
|
248
|
+
const shellCandidates = ['pwsh', 'bash', 'cmd', 'sh']
|
|
249
|
+
let shells = shellCandidates.filter((name) => toolNames.includes(name))
|
|
250
|
+
if (shells.length === 0) shells = [process.platform === 'win32' ? 'pwsh' : 'bash']
|
|
251
|
+
let presets = []
|
|
252
|
+
try {
|
|
253
|
+
presets = (await agentPresets?.list?.()) ?? []
|
|
254
|
+
} catch {
|
|
255
|
+
presets = []
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
platform: process.platform,
|
|
259
|
+
addresses: [...addresses],
|
|
260
|
+
shells,
|
|
261
|
+
subagentProviders: (() => {
|
|
262
|
+
try {
|
|
263
|
+
return subagents?.list?.() ?? ['spawn']
|
|
264
|
+
} catch {
|
|
265
|
+
return ['spawn']
|
|
266
|
+
}
|
|
267
|
+
})(),
|
|
268
|
+
tools: [...toolNames].sort(),
|
|
269
|
+
presets: presets.map((preset) => ({
|
|
270
|
+
id: preset.id ?? preset.name,
|
|
271
|
+
label: preset.name ?? preset.title ?? preset.id,
|
|
272
|
+
})),
|
|
273
|
+
cwd: {
|
|
274
|
+
workspace: workspaceDir,
|
|
275
|
+
dshHome: process.env.DSH_HOME ?? '',
|
|
276
|
+
defaultDirectory: workingDirectory?.defaultDirectory ?? '',
|
|
277
|
+
current: runtime.current.execution?.cwd ?? '',
|
|
278
|
+
},
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
let pairingRef = null
|
|
282
|
+
if (webServer) {
|
|
283
|
+
ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
|
|
284
|
+
ctx.effect(() =>
|
|
285
|
+
registerConfigRoutes({
|
|
286
|
+
webServer,
|
|
287
|
+
runtime,
|
|
288
|
+
getOptions,
|
|
289
|
+
preparePatch: async (patch) => {
|
|
290
|
+
const cwd = patch?.execution?.cwd
|
|
291
|
+
if (typeof cwd === 'string' && cwd !== '') {
|
|
292
|
+
try {
|
|
293
|
+
mkdirSync(cwd, { recursive: true })
|
|
294
|
+
} catch (error) {
|
|
295
|
+
logger.warn?.(`[device-fleet] cannot create execution cwd: ${String(error?.message ?? error)}`)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return patch
|
|
299
|
+
},
|
|
300
|
+
logger,
|
|
301
|
+
}),
|
|
302
|
+
)
|
|
303
|
+
const uiActions = {
|
|
304
|
+
async sendRoomMessage({ roomId, to, text } = {}) {
|
|
305
|
+
if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
|
|
306
|
+
const memberIds = rooms.status(roomId).members.map((member) => member.memberId)
|
|
307
|
+
const recipients = Array.isArray(to) && to.length > 0 ? to : memberIds.filter((id) => id.startsWith('host:'))
|
|
308
|
+
const result = await rooms.send({ roomId, from: { kind: 'user' }, to: recipients, text })
|
|
309
|
+
return { roomId, ...result }
|
|
310
|
+
},
|
|
311
|
+
async roomTask({ roomId, action, ...rest } = {}) {
|
|
312
|
+
if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
|
|
313
|
+
if (action === 'add') {
|
|
314
|
+
return rooms.addTask({ roomId, title: rest.title, owner: rest.owner, dependsOn: rest.dependsOn ?? [], description: rest.description ?? '' })
|
|
315
|
+
}
|
|
316
|
+
if (action === 'update') {
|
|
317
|
+
return rooms.updateTask({ roomId, id: rest.id, status: rest.status, note: rest.note })
|
|
318
|
+
}
|
|
319
|
+
throw Object.assign(new Error('action must be add or update'), { code: 'bad-request' })
|
|
320
|
+
},
|
|
321
|
+
closeRoom: ({ roomId, reason } = {}) => rooms.close({ roomId, reason: reason ?? 'closed by user' }),
|
|
322
|
+
removeMember: ({ roomId, memberId, reason } = {}) => rooms.removeMember({ roomId, memberId, reason: reason ?? 'removed by user' }),
|
|
323
|
+
async upgradeRoom({ sessionId } = {}) {
|
|
324
|
+
if (typeof sessionId !== 'string' || sessionId === '') throw Object.assign(new Error('sessionId is required'), { code: 'bad-request' })
|
|
325
|
+
if (!sessionController?.resolveAgent) throw Object.assign(new Error('session agent is unavailable'), { code: 'unsupported' })
|
|
326
|
+
const resolved = await sessionController.resolveAgent(sessionId)
|
|
327
|
+
if (!resolved?.agent) throw Object.assign(new Error('session agent is unavailable'), { code: 'unsupported' })
|
|
328
|
+
return rooms.upgradeHost({ agent: resolved.agent })
|
|
329
|
+
},
|
|
330
|
+
createInvite: async () => {
|
|
331
|
+
if (!pairingRef?.invite) throw Object.assign(new Error('pairing is unavailable'), { code: 'unsupported' })
|
|
332
|
+
return pairingRef.invite()
|
|
333
|
+
},
|
|
334
|
+
connectAddress: async ({ host, port } = {}) => {
|
|
335
|
+
if (typeof host !== 'string' || !Number.isInteger(port) || port <= 0) {
|
|
336
|
+
throw Object.assign(new Error('host and port are required'), { code: 'bad-request' })
|
|
337
|
+
}
|
|
338
|
+
const peer = await node.connectAddress(host, port)
|
|
339
|
+
try {
|
|
340
|
+
savePeerHint(state, { host, port })
|
|
341
|
+
} catch {
|
|
342
|
+
/* best effort */
|
|
343
|
+
}
|
|
344
|
+
return { deviceId: peer.deviceId, name: peer.name ?? 'device' }
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
ctx.effect(() =>
|
|
348
|
+
registerUiRoutes({
|
|
349
|
+
webServer,
|
|
350
|
+
inbox,
|
|
351
|
+
rooms,
|
|
352
|
+
logger,
|
|
353
|
+
actions: uiActions,
|
|
354
|
+
getOverview: () => ({
|
|
355
|
+
...(node.status?.() ?? {}),
|
|
356
|
+
rooms: rooms.list(),
|
|
357
|
+
approvals: inbox.list(),
|
|
358
|
+
approvalHistory: inbox.history?.() ?? [],
|
|
359
|
+
}),
|
|
360
|
+
}),
|
|
361
|
+
)
|
|
362
|
+
}
|
|
154
363
|
if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
|
|
155
364
|
const slot = sessionController
|
|
156
365
|
? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
|
|
157
366
|
: null
|
|
158
367
|
|
|
368
|
+
const delegation =
|
|
369
|
+
slot && subagents
|
|
370
|
+
? new DelegationHost({
|
|
371
|
+
subagents,
|
|
372
|
+
slot,
|
|
373
|
+
policy,
|
|
374
|
+
deviceId: state.identity.deviceId,
|
|
375
|
+
defaultCwd: config.execution?.cwd,
|
|
376
|
+
defaultAgentPreset: config.execution?.agentPreset,
|
|
377
|
+
provider: config.execution?.subagentProvider ?? 'spawn',
|
|
378
|
+
timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
|
|
379
|
+
requestApproval,
|
|
380
|
+
})
|
|
381
|
+
: null
|
|
382
|
+
|
|
159
383
|
const host = slot
|
|
160
384
|
? new DirectCallHost({
|
|
161
385
|
slot,
|
|
@@ -168,15 +392,69 @@ export function apply(ctx, config) {
|
|
|
168
392
|
capabilities: CAPABILITIES,
|
|
169
393
|
shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
|
|
170
394
|
approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
: async (request) => {
|
|
174
|
-
logger.info?.(`[device-fleet] approval required from ${request.fromDeviceId} for ${request.capability}/${String(request.tool)} (no approval UI yet)`)
|
|
175
|
-
return false
|
|
176
|
-
},
|
|
395
|
+
delegation,
|
|
396
|
+
requestApproval,
|
|
177
397
|
})
|
|
178
398
|
: null
|
|
179
399
|
|
|
400
|
+
// Mirror the normal DSH approval behavior for tools executed in the hidden
|
|
401
|
+
// Execution Slot: the tool pipeline asks here when its own pre-execute policy
|
|
402
|
+
// requires approval, and this device's approval inbox answers.
|
|
403
|
+
ctx.effect(() => {
|
|
404
|
+
if (!slot) return () => {}
|
|
405
|
+
const handler = async (request, next) => {
|
|
406
|
+
try {
|
|
407
|
+
if (request?.signal?.aborted) return 'cancelled'
|
|
408
|
+
if (!slot.sessionId || request?.agent?.session?.id !== slot.sessionId) return next()
|
|
409
|
+
const toolName = typeof request?.toolName === 'string' ? request.toolName : 'tool'
|
|
410
|
+
const outcome = await inbox.request({
|
|
411
|
+
fromDeviceId: 'local-tool-pipeline',
|
|
412
|
+
capability: 'tool',
|
|
413
|
+
tool: toolName,
|
|
414
|
+
reason: request?.displayReason ?? request?.reason ?? `tool "${toolName}" requires approval`,
|
|
415
|
+
args: {},
|
|
416
|
+
})
|
|
417
|
+
if (outcome.approved === true) return 'allowed-once'
|
|
418
|
+
if (outcome.reason === 'inbox-closed') return 'cancelled'
|
|
419
|
+
return 'rejected'
|
|
420
|
+
} catch {
|
|
421
|
+
return next()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return ctx.on('approval/request', handler)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// A Room is a normal Session whose user input is also delivered to its
|
|
428
|
+
// participants. Typing in the Host conversation therefore needs no separate
|
|
429
|
+
// Room composer.
|
|
430
|
+
ctx.effect(() =>
|
|
431
|
+
ctx.on?.('session/event', (session, event) => {
|
|
432
|
+
try {
|
|
433
|
+
if (!session?.id || event?.type !== 'user/message') return
|
|
434
|
+
const room = rooms.rooms.get(session.id)
|
|
435
|
+
if (!room) return
|
|
436
|
+
const message = event.data
|
|
437
|
+
if (message?.source?.kind && message.source.kind !== 'user') return
|
|
438
|
+
const text = (Array.isArray(message?.content) ? message.content : [])
|
|
439
|
+
.filter((block) => block?.type === 'text')
|
|
440
|
+
.map((block) => block.text)
|
|
441
|
+
.join('\n')
|
|
442
|
+
.trim()
|
|
443
|
+
if (text === '' || text.startsWith('[Room]') || text.startsWith('[Fleet Room')) return
|
|
444
|
+
const recipients = rooms
|
|
445
|
+
.status(session.id)
|
|
446
|
+
.members.filter((member) => member.role !== 'host')
|
|
447
|
+
.map((member) => member.memberId)
|
|
448
|
+
if (recipients.length === 0) return
|
|
449
|
+
void rooms.send({ roomId: session.id, from: { kind: 'user' }, to: recipients, text }).catch((error) => {
|
|
450
|
+
logger.warn?.(`[device-fleet] cannot broadcast room message: ${String(error?.message ?? error)}`)
|
|
451
|
+
})
|
|
452
|
+
} catch (error) {
|
|
453
|
+
logger.warn?.(`[device-fleet] room broadcast failed: ${String(error?.message ?? error)}`)
|
|
454
|
+
}
|
|
455
|
+
}),
|
|
456
|
+
)
|
|
457
|
+
|
|
180
458
|
node.onRequest(async (request) => {
|
|
181
459
|
if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
|
|
182
460
|
return host.handleRequest(request)
|
|
@@ -213,6 +491,18 @@ export function apply(ctx, config) {
|
|
|
213
491
|
}
|
|
214
492
|
|
|
215
493
|
const client = new DirectCallClient({ node })
|
|
494
|
+
const localEndpoints = () => {
|
|
495
|
+
const port = node.directAddress?.port
|
|
496
|
+
if (!Number.isInteger(port) || port <= 0) return []
|
|
497
|
+
const endpoints = []
|
|
498
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
499
|
+
for (const entry of entries ?? []) {
|
|
500
|
+
if (entry.family !== 'IPv4' || entry.internal) continue
|
|
501
|
+
endpoints.push({ deviceId: state.identity.deviceId, host: entry.address, port })
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return endpoints
|
|
505
|
+
}
|
|
216
506
|
const pairing = {
|
|
217
507
|
async invite() {
|
|
218
508
|
const fingerprint =
|
|
@@ -221,27 +511,43 @@ export function apply(ctx, config) {
|
|
|
221
511
|
: (state.enrollment?.accountFingerprint ?? null)
|
|
222
512
|
return createInvite(state, {
|
|
223
513
|
accountFingerprint: fingerprint,
|
|
224
|
-
relay:
|
|
514
|
+
relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
|
|
515
|
+
endpoints: localEndpoints(),
|
|
225
516
|
})
|
|
226
517
|
},
|
|
227
518
|
async join(code) {
|
|
228
519
|
writePendingEnrollment(state, code)
|
|
229
520
|
},
|
|
230
521
|
}
|
|
231
|
-
|
|
522
|
+
pairingRef = pairing
|
|
523
|
+
const handlers = createFleetHandlers({
|
|
524
|
+
node,
|
|
525
|
+
client,
|
|
526
|
+
pairing,
|
|
527
|
+
onPeerHint: async (hint) => {
|
|
528
|
+
try {
|
|
529
|
+
savePeerHint(state, hint)
|
|
530
|
+
} catch (error) {
|
|
531
|
+
logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
|
|
532
|
+
}
|
|
533
|
+
},
|
|
534
|
+
approvals: approvalsApi,
|
|
535
|
+
rooms,
|
|
536
|
+
})
|
|
232
537
|
const definitions = createFleetToolDefinitions({ defineTool, handlers })
|
|
233
538
|
const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
|
|
234
539
|
for (const definition of wrapped) ctx.tools.register(definition)
|
|
235
540
|
|
|
236
541
|
// Adoption layer 2: one sentence, only when the Fleet has members.
|
|
237
542
|
const systemPrompt = service('systemPrompt')
|
|
238
|
-
if (systemPrompt
|
|
543
|
+
if (systemPrompt) {
|
|
239
544
|
const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
|
|
240
545
|
ctx.effect(() =>
|
|
241
546
|
systemPrompt.section({
|
|
242
547
|
name: 'device-fleet:members',
|
|
243
548
|
order,
|
|
244
549
|
text: () => {
|
|
550
|
+
if (runtime.current.promptSection === false) return ''
|
|
245
551
|
const members = node.rosterEntries().length
|
|
246
552
|
if (members === 0) return ''
|
|
247
553
|
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.`
|
|
@@ -250,6 +556,48 @@ export function apply(ctx, config) {
|
|
|
250
556
|
)
|
|
251
557
|
}
|
|
252
558
|
|
|
559
|
+
// Live config application: every UI save runs these handlers in-process.
|
|
560
|
+
runtime.onChange(async (next, previous) => {
|
|
561
|
+
if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
|
|
562
|
+
policy = new ToolPolicy(next.policy)
|
|
563
|
+
if (host) host.policy = policy
|
|
564
|
+
if (delegation) delegation.policy = policy
|
|
565
|
+
}
|
|
566
|
+
if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
|
|
567
|
+
slot?.setConfig(next.execution)
|
|
568
|
+
if (host) {
|
|
569
|
+
host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
|
|
570
|
+
host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
|
|
571
|
+
}
|
|
572
|
+
if (delegation) {
|
|
573
|
+
delegation.defaultCwd = next.execution?.cwd
|
|
574
|
+
delegation.defaultAgentPreset = next.execution?.agentPreset
|
|
575
|
+
delegation.provider = next.execution?.subagentProvider ?? 'spawn'
|
|
576
|
+
delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
|
|
580
|
+
if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
|
|
581
|
+
if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
|
|
582
|
+
}
|
|
583
|
+
if (next.deviceName !== previous?.deviceName) {
|
|
584
|
+
node.name = next.deviceName
|
|
585
|
+
if (host) host.name = next.deviceName
|
|
586
|
+
}
|
|
587
|
+
const networkChanged =
|
|
588
|
+
JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
|
|
589
|
+
next.discovery !== previous?.discovery ||
|
|
590
|
+
JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
|
|
591
|
+
if (networkChanged) {
|
|
592
|
+
await node.reconfigure({
|
|
593
|
+
name: next.deviceName,
|
|
594
|
+
direct: next.direct,
|
|
595
|
+
discovery: next.discovery ? true : false,
|
|
596
|
+
relay: next.relay ?? null,
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
|
|
253
601
|
// Adoption layer 4: one-line nudge when the model starts orchestrating.
|
|
254
602
|
ctx.on?.('tools/post-execute', async (exec, _result, next) => {
|
|
255
603
|
const decision = await next()
|
|
@@ -277,6 +625,9 @@ export function apply(ctx, config) {
|
|
|
277
625
|
})
|
|
278
626
|
|
|
279
627
|
ctx.effect(() => () => {
|
|
628
|
+
inbox.close()
|
|
629
|
+
// Rooms are intentionally left open (and persisted) so a plugin reload can
|
|
630
|
+
// rehydrate them instead of destroying a running collaboration.
|
|
280
631
|
void node.close()
|
|
281
632
|
})
|
|
282
633
|
|