dsh-device-fleet 0.1.0-dev.2 → 0.1.0-dev.20

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.
@@ -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: the hidden DSH session used to execute remote tool calls.'),
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, config) {
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: config.dataDir, name: config.deviceName })
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
- requestApproval: config.autoApproveAsk
172
- ? async () => true
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: config.relay ?? state.enrollment?.relay ?? null,
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
- const handlers = createFleetHandlers({ node, client, pairing })
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 && config.promptSection !== false) {
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