dsh-device-fleet 0.1.0-dev.3 → 0.1.0-dev.30

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,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: the hidden DSH session used to execute remote tool calls.'),
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, config) {
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: config.dataDir, name: config.deviceName })
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,217 @@ 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.
232
+ const focusedRooms = new Set()
233
+
234
+ // Client approval panel data plane (dev.9). Optional service: host-only
235
+ // deployments simply do not expose the panel routes.
236
+ const webServer = service('webServer')
237
+ const workspaceDir = join(state.dataDir, 'workspace')
238
+ const getOptions = async () => {
239
+ const addresses = new Set(['0.0.0.0', '127.0.0.1', runtime.current.direct?.host ?? ''])
240
+ for (const entries of Object.values(networkInterfaces())) {
241
+ for (const entry of entries ?? []) {
242
+ if (entry.family === 'IPv4' && !entry.internal) addresses.add(entry.address)
243
+ }
244
+ }
245
+ addresses.delete('')
246
+ let toolNames = []
247
+ try {
248
+ toolNames = (ctx.tools.definitions?.() ?? []).map((definition) => definition.name)
249
+ } catch {
250
+ toolNames = []
251
+ }
252
+ const shellCandidates = ['pwsh', 'bash', 'cmd', 'sh']
253
+ let shells = shellCandidates.filter((name) => toolNames.includes(name))
254
+ if (shells.length === 0) shells = [process.platform === 'win32' ? 'pwsh' : 'bash']
255
+ let presets = []
256
+ try {
257
+ presets = (await agentPresets?.list?.()) ?? []
258
+ } catch {
259
+ presets = []
260
+ }
261
+ return {
262
+ platform: process.platform,
263
+ addresses: [...addresses],
264
+ shells,
265
+ subagentProviders: (() => {
266
+ try {
267
+ return subagents?.list?.() ?? ['spawn']
268
+ } catch {
269
+ return ['spawn']
270
+ }
271
+ })(),
272
+ tools: [...toolNames].sort(),
273
+ presets: presets.map((preset) => ({
274
+ id: preset.id ?? preset.name,
275
+ label: preset.name ?? preset.title ?? preset.id,
276
+ })),
277
+ cwd: {
278
+ workspace: workspaceDir,
279
+ dshHome: process.env.DSH_HOME ?? '',
280
+ defaultDirectory: workingDirectory?.defaultDirectory ?? '',
281
+ current: runtime.current.execution?.cwd ?? '',
282
+ },
283
+ }
284
+ }
285
+ let pairingRef = null
286
+ if (webServer) {
287
+ ctx.effect(() =>
288
+ registerAvatarRoutes({
289
+ webServer,
290
+ dir: fileURLToPath(new URL('../../assets/avatars/', import.meta.url)),
291
+ libraryDir: join(state.dataDir, 'avatars'),
292
+ logger,
293
+ }),
294
+ )
295
+ ctx.effect(() => registerApprovalRoutes({ webServer, inbox, logger }))
296
+ ctx.effect(() =>
297
+ registerConfigRoutes({
298
+ webServer,
299
+ runtime,
300
+ getOptions,
301
+ preparePatch: async (patch) => {
302
+ const cwd = patch?.execution?.cwd
303
+ if (typeof cwd === 'string' && cwd !== '') {
304
+ try {
305
+ mkdirSync(cwd, { recursive: true })
306
+ } catch (error) {
307
+ logger.warn?.(`[device-fleet] cannot create execution cwd: ${String(error?.message ?? error)}`)
308
+ }
309
+ }
310
+ return patch
311
+ },
312
+ logger,
313
+ }),
314
+ )
315
+ const uiActions = {
316
+ async sendRoomMessage({ roomId, to, text } = {}) {
317
+ if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
318
+ const memberIds = rooms.status(roomId).members.map((member) => member.memberId)
319
+ const hostMemberId = `host:${roomId}`
320
+ const defaultRecipients = memberIds.filter((id) => id !== hostMemberId)
321
+ const recipients = Array.isArray(to) && to.length > 0 ? to : defaultRecipients.length > 0 ? defaultRecipients : [hostMemberId]
322
+ const result = await rooms.send({ roomId, from: { kind: 'user' }, to: recipients, text })
323
+ return { roomId, ...result }
324
+ },
325
+ async roomTask({ roomId, action, ...rest } = {}) {
326
+ if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
327
+ if (action === 'add') {
328
+ return rooms.addTask({ roomId, title: rest.title, owner: rest.owner, dependsOn: rest.dependsOn ?? [], description: rest.description ?? '' })
329
+ }
330
+ if (action === 'update') {
331
+ return rooms.updateTask({ roomId, id: rest.id, status: rest.status, note: rest.note })
332
+ }
333
+ throw Object.assign(new Error('action must be add or update'), { code: 'bad-request' })
334
+ },
335
+ closeRoom: ({ roomId, reason } = {}) => rooms.close({ roomId, reason: reason ?? 'closed by user' }),
336
+ setRoomFocus: ({ roomId, focused } = {}) => {
337
+ if (typeof roomId !== 'string' || roomId === '') throw Object.assign(new Error('roomId is required'), { code: 'bad-request' })
338
+ if (focused === true) focusedRooms.add(roomId)
339
+ else focusedRooms.delete(roomId)
340
+ return { roomId, focused: focused === true }
341
+ },
342
+ removeMember: ({ roomId, memberId, reason } = {}) => rooms.removeMember({ roomId, memberId, reason: reason ?? 'removed by user' }),
343
+ async upgradeRoom({ sessionId } = {}) {
344
+ if (typeof sessionId !== 'string' || sessionId === '') throw Object.assign(new Error('sessionId is required'), { code: 'bad-request' })
345
+ if (!sessionController?.resolveAgent) throw Object.assign(new Error('session agent is unavailable'), { code: 'unsupported' })
346
+ const resolved = await sessionController.resolveAgent(sessionId)
347
+ if (!resolved?.agent) throw Object.assign(new Error('session agent is unavailable'), { code: 'unsupported' })
348
+ return rooms.upgradeHost({ agent: resolved.agent })
349
+ },
350
+ createInvite: async () => {
351
+ if (!pairingRef?.invite) throw Object.assign(new Error('pairing is unavailable'), { code: 'unsupported' })
352
+ return pairingRef.invite()
353
+ },
354
+ connectAddress: async ({ host, port } = {}) => {
355
+ if (typeof host !== 'string' || !Number.isInteger(port) || port <= 0) {
356
+ throw Object.assign(new Error('host and port are required'), { code: 'bad-request' })
357
+ }
358
+ const peer = await node.connectAddress(host, port)
359
+ try {
360
+ savePeerHint(state, { host, port })
361
+ } catch {
362
+ /* best effort */
363
+ }
364
+ return { deviceId: peer.deviceId, name: peer.name ?? 'device' }
365
+ },
366
+ }
367
+ ctx.effect(() =>
368
+ registerUiRoutes({
369
+ webServer,
370
+ inbox,
371
+ rooms,
372
+ logger,
373
+ actions: uiActions,
374
+ getOverview: () => ({
375
+ ...(node.status?.() ?? {}),
376
+ rooms: rooms.list(),
377
+ approvals: inbox.list(),
378
+ approvalHistory: inbox.history?.() ?? [],
379
+ }),
380
+ }),
381
+ )
382
+ }
156
383
  if (!sessionController) logger.warn?.('[device-fleet] sessionController unavailable: direct calls to stateful tools will fail')
157
384
  const slot = sessionController
158
385
  ? new ExecutionSlot({ sessionController, tools: ctx.tools, config: config.execution })
159
386
  : null
160
387
 
388
+ const delegation =
389
+ slot && subagents
390
+ ? new DelegationHost({
391
+ subagents,
392
+ slot,
393
+ policy,
394
+ deviceId: state.identity.deviceId,
395
+ defaultCwd: config.execution?.cwd,
396
+ defaultAgentPreset: config.execution?.agentPreset,
397
+ provider: config.execution?.subagentProvider ?? 'spawn',
398
+ timeoutMs: config.execution?.delegateTimeoutMs ?? 180_000,
399
+ requestApproval,
400
+ })
401
+ : null
402
+
161
403
  const host = slot
162
404
  ? new DirectCallHost({
163
405
  slot,
@@ -170,15 +412,112 @@ export function apply(ctx, config) {
170
412
  capabilities: CAPABILITIES,
171
413
  shellTool: config.execution?.shellTool ?? DEFAULT_SHELL_TOOL,
172
414
  approvalTimeoutMs: config.execution?.approvalTimeoutMs ?? 60_000,
173
- requestApproval: config.autoApproveAsk
174
- ? async () => true
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
- },
415
+ delegation,
416
+ requestApproval,
179
417
  })
180
418
  : null
181
419
 
420
+ // Mirror the normal DSH approval behavior for tools executed in the hidden
421
+ // Execution Slot: the tool pipeline asks here when its own pre-execute policy
422
+ // requires approval, and this device's approval inbox answers.
423
+ ctx.effect(() => {
424
+ if (!slot) return () => {}
425
+ const handler = async (request, next) => {
426
+ try {
427
+ if (request?.signal?.aborted) return 'cancelled'
428
+ if (!slot.sessionId || request?.agent?.session?.id !== slot.sessionId) return next()
429
+ const toolName = typeof request?.toolName === 'string' ? request.toolName : 'tool'
430
+ const outcome = await inbox.request({
431
+ fromDeviceId: 'local-tool-pipeline',
432
+ capability: 'tool',
433
+ tool: toolName,
434
+ reason: request?.displayReason ?? request?.reason ?? `tool "${toolName}" requires approval`,
435
+ args: {},
436
+ })
437
+ if (outcome.approved === true) return 'allowed-once'
438
+ if (outcome.reason === 'inbox-closed') return 'cancelled'
439
+ return 'rejected'
440
+ } catch {
441
+ return next()
442
+ }
443
+ }
444
+ return ctx.on('approval/request', handler)
445
+ })
446
+
447
+ // A Room reuses the normal DSH composer: every user message in a Room Host
448
+ // session is logged as a Room message. `@name` addresses specific members;
449
+ // without mentions it goes to every participant. Host itself already has the
450
+ // message as normal Chat input, so it is never re-woken.
451
+ ctx.effect(() =>
452
+ ctx.on?.('session/event', (session, event) => {
453
+ try {
454
+ if (!session?.id || event?.type !== 'user/message') return
455
+ const room = rooms.rooms.get(session.id)
456
+ if (!room) return
457
+ if (!focusedRooms.has(session.id)) return
458
+ const message = event.data
459
+ if (message?.source?.kind && message.source.kind !== 'user') return
460
+ const text = (Array.isArray(message?.content) ? message.content : [])
461
+ .filter((block) => block?.type === 'text')
462
+ .map((block) => block.text)
463
+ .join('\n')
464
+ .trim()
465
+ if (text === '' || text.startsWith('[Room]') || text.startsWith('[Fleet Room')) return
466
+ const members = rooms.status(session.id).members
467
+ const hostMemberId = `host:${session.id}`
468
+ const participants = members.filter((member) => member.role !== 'host')
469
+ const mentioned = members.filter((member) => member.name && text.includes('@' + member.name))
470
+ const recipients = mentioned.length > 0
471
+ ? mentioned.map((member) => member.memberId)
472
+ : participants.map((member) => member.memberId)
473
+ if (recipients.length === 0) return
474
+ let body = text
475
+ for (const member of mentioned) body = body.split('@' + member.name).join('')
476
+ body = body.trim() || '(点名)'
477
+ void rooms.send({
478
+ roomId: session.id,
479
+ from: { kind: 'user' },
480
+ to: recipients,
481
+ text: body,
482
+ skipWakeFor: [hostMemberId],
483
+ }).catch((error) => {
484
+ logger.warn?.(`[device-fleet] cannot route room message: ${String(error?.message ?? error)}`)
485
+ })
486
+ } catch (error) {
487
+ logger.warn?.(`[device-fleet] room message routing failed: ${String(error?.message ?? error)}`)
488
+ }
489
+ }),
490
+ )
491
+
492
+ // The host agent's own Chat replies are mirrored into the Room transcript as
493
+ // host speech (read-only for participants: recorded, never re-woken).
494
+ ctx.effect(() =>
495
+ ctx.on?.('session/event', (session, event) => {
496
+ try {
497
+ if (!session?.id || event?.type !== 'assistant/message') return
498
+ const room = rooms.rooms.get(session.id)
499
+ if (!room) return
500
+ const message = event.data
501
+ const text = (Array.isArray(message?.content) ? message.content : [])
502
+ .filter((block) => block?.type === 'text')
503
+ .map((block) => block.text)
504
+ .join('\n')
505
+ .trim()
506
+ if (text === '') return
507
+ const hostMemberId = `host:${session.id}`
508
+ void rooms.send({
509
+ roomId: session.id,
510
+ from: { sessionId: session.id },
511
+ to: [hostMemberId],
512
+ text,
513
+ skipWakeFor: [hostMemberId],
514
+ }).catch(() => {})
515
+ } catch {
516
+ /* ignore */
517
+ }
518
+ }),
519
+ )
520
+
182
521
  node.onRequest(async (request) => {
183
522
  if (!host) throw Object.assign(new Error('this device cannot serve direct calls (no execution slot)'), { code: 'unsupported' })
184
523
  return host.handleRequest(request)
@@ -235,7 +574,7 @@ export function apply(ctx, config) {
235
574
  : (state.enrollment?.accountFingerprint ?? null)
236
575
  return createInvite(state, {
237
576
  accountFingerprint: fingerprint,
238
- relay: config.relay ?? state.enrollment?.relay ?? null,
577
+ relay: runtime.current.relay ?? state.enrollment?.relay ?? null,
239
578
  endpoints: localEndpoints(),
240
579
  })
241
580
  },
@@ -243,20 +582,35 @@ export function apply(ctx, config) {
243
582
  writePendingEnrollment(state, code)
244
583
  },
245
584
  }
246
- const handlers = createFleetHandlers({ node, client, pairing })
585
+ pairingRef = pairing
586
+ const handlers = createFleetHandlers({
587
+ node,
588
+ client,
589
+ pairing,
590
+ onPeerHint: async (hint) => {
591
+ try {
592
+ savePeerHint(state, hint)
593
+ } catch (error) {
594
+ logger.warn?.(`[device-fleet] cannot persist peer hint: ${String(error?.message ?? error)}`)
595
+ }
596
+ },
597
+ approvals: approvalsApi,
598
+ rooms,
599
+ })
247
600
  const definitions = createFleetToolDefinitions({ defineTool, handlers })
248
601
  const wrapped = withManual(definitions.map((definition) => ({ ...definition, execute: safe(definition.execute) })))
249
602
  for (const definition of wrapped) ctx.tools.register(definition)
250
603
 
251
604
  // Adoption layer 2: one sentence, only when the Fleet has members.
252
605
  const systemPrompt = service('systemPrompt')
253
- if (systemPrompt && config.promptSection !== false) {
606
+ if (systemPrompt) {
254
607
  const order = systemPrompt.getSectionOrder?.('TOOL_SUBAGENT') ?? PROMPT_SECTION_ORDER
255
608
  ctx.effect(() =>
256
609
  systemPrompt.section({
257
610
  name: 'device-fleet:members',
258
611
  order,
259
612
  text: () => {
613
+ if (runtime.current.promptSection === false) return ''
260
614
  const members = node.rosterEntries().length
261
615
  if (members === 0) return ''
262
616
  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 +619,48 @@ export function apply(ctx, config) {
265
619
  )
266
620
  }
267
621
 
622
+ // Live config application: every UI save runs these handlers in-process.
623
+ runtime.onChange(async (next, previous) => {
624
+ if (JSON.stringify(next.policy) !== JSON.stringify(previous?.policy)) {
625
+ policy = new ToolPolicy(next.policy)
626
+ if (host) host.policy = policy
627
+ if (delegation) delegation.policy = policy
628
+ }
629
+ if (JSON.stringify(next.execution) !== JSON.stringify(previous?.execution)) {
630
+ slot?.setConfig(next.execution)
631
+ if (host) {
632
+ host.shellTool = next.execution?.shellTool ?? DEFAULT_SHELL_TOOL
633
+ host.approvalTimeoutMs = next.execution?.approvalTimeoutMs ?? 60_000
634
+ }
635
+ if (delegation) {
636
+ delegation.defaultCwd = next.execution?.cwd
637
+ delegation.defaultAgentPreset = next.execution?.agentPreset
638
+ delegation.provider = next.execution?.subagentProvider ?? 'spawn'
639
+ delegation.timeoutMs = next.execution?.delegateTimeoutMs ?? 180_000
640
+ }
641
+ }
642
+ if (JSON.stringify(next.approvals) !== JSON.stringify(previous?.approvals)) {
643
+ if (Number.isInteger(next.approvals?.ttlMs) && next.approvals.ttlMs >= 1000) inbox.ttlMs = next.approvals.ttlMs
644
+ if (Number.isInteger(next.approvals?.maxPending) && next.approvals.maxPending >= 1) inbox.maxPending = next.approvals.maxPending
645
+ }
646
+ if (next.deviceName !== previous?.deviceName) {
647
+ node.name = next.deviceName
648
+ if (host) host.name = next.deviceName
649
+ }
650
+ const networkChanged =
651
+ JSON.stringify(next.direct) !== JSON.stringify(previous?.direct) ||
652
+ next.discovery !== previous?.discovery ||
653
+ JSON.stringify(next.relay) !== JSON.stringify(previous?.relay)
654
+ if (networkChanged) {
655
+ await node.reconfigure({
656
+ name: next.deviceName,
657
+ direct: next.direct,
658
+ discovery: next.discovery ? true : false,
659
+ relay: next.relay ?? null,
660
+ })
661
+ }
662
+ })
663
+
268
664
  // Adoption layer 4: one-line nudge when the model starts orchestrating.
269
665
  ctx.on?.('tools/post-execute', async (exec, _result, next) => {
270
666
  const decision = await next()
@@ -292,6 +688,9 @@ export function apply(ctx, config) {
292
688
  })
293
689
 
294
690
  ctx.effect(() => () => {
691
+ inbox.close()
692
+ // Rooms are intentionally left open (and persisted) so a plugin reload can
693
+ // rehydrate them instead of destroying a running collaboration.
295
694
  void node.close()
296
695
  })
297
696