dsh-tower 1.2.0 → 2.0.0

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
package/index.js CHANGED
@@ -6,23 +6,39 @@
6
6
  // forwarded to the model as the next user message. With
7
7
  // config.allowAgentControl the model may also enter or leave the mode through
8
8
  // `tower_control` when the user intent or task state clearly calls for it.
9
+ //
10
+ // `/tower reload` asks DSH HMR to reconcile this profile and reload the Tower
11
+ // plugin from disk when the HMR service is present (dev/linked installs).
12
+ import fs from 'node:fs'
9
13
  import { randomUUID } from 'node:crypto'
10
14
  import { registerTowerTools } from './lib/tools.js'
11
15
  import { loadModes, saveModes, storePath } from './lib/modeStore.js'
16
+ import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './lib/config.js'
17
+ import { TowerToolError } from './lib/errors.js'
18
+ import { resolveRepo } from './lib/git.js'
19
+ import {
20
+ acquireWorkspaceLock,
21
+ inspectWorkspaceLock,
22
+ releaseWorkspaceLock,
23
+ touchWorkspaceLock,
24
+ workspaceLockToken,
25
+ } from './lib/workspaceLock.js'
26
+ import { createHmrBridge } from './lib/hmr.js'
12
27
 
13
28
  export const name = 'dsh-tower'
14
29
  export const inject = ['tools', 'systemPrompt']
30
+ export const Config = ConfigSchema
15
31
 
16
- const MODE = loadModes()
17
-
18
- const DENY_TOOLS = new Set([
19
- 'create_goal',
20
- 'todo_write',
21
- 'team_task_create',
22
- 'team_task_update',
23
- 'team_task_reopen',
24
- 'spawn_teammate',
32
+ let MODE = new Map()
33
+ let denyTools = new Set(DEFAULT_DENY_TOOLS)
34
+ const READ_ONLY_TOOL_NAMES = new Set([
35
+ 'tower_status',
36
+ 'tower_roster',
37
+ 'tower_diff',
38
+ 'tower_preflight',
39
+ 'tower_doctor',
25
40
  ])
41
+ let pluginVersion = null
26
42
 
27
43
  const BASE_REMINDER = [
28
44
  'Tower mode is active for this session. You are the control tower: plan missions, spawn workers and reviewers, and integrate only through the Tower tools.',
@@ -34,32 +50,46 @@ const BASE_REMINDER = [
34
50
  const AGENT_CONTROL_NOTE =
35
51
  'If tower_control is available you may also enter or leave Tower mode yourself when the user intent or task state clearly calls for it, and you must state a concrete reason.'
36
52
 
53
+ function readPluginVersion() {
54
+ if (pluginVersion !== null) return pluginVersion
55
+ try {
56
+ const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'))
57
+ pluginVersion = typeof pkg.version === 'string' ? pkg.version : null
58
+ } catch {
59
+ pluginVersion = null
60
+ }
61
+ return pluginVersion
62
+ }
63
+
37
64
  function agentKey(agent) {
38
65
  if (!agent) return null
39
66
  const key = agent.id ?? agent.session
40
67
  return key === undefined || key === null ? null : String(key)
41
68
  }
42
69
 
43
- function resolveConfig(config) {
44
- const input = config ?? {}
45
- const unknown = Object.keys(input).filter((key) => !['section', 'allowAgentControl'].includes(key))
46
- if (unknown.length > 0) {
47
- throw new Error(
48
- `Tower config has unknown key(s) ${unknown.join(', ')} — config is { section?, allowAgentControl? }`,
49
- )
50
- }
51
- const section = typeof input.section === 'string' && input.section.trim().length > 0
52
- ? input.section
70
+ function configSection(config, allowAgentControl) {
71
+ const configured = config.mode.promptSection && config.mode.promptSection.trim().length > 0
72
+ ? config.mode.promptSection
53
73
  : BASE_REMINDER
54
- return { section, allowAgentControl: input.allowAgentControl === true }
74
+ const appended = config.mode.sectionAppend && config.mode.sectionAppend.trim().length > 0
75
+ ? ` ${config.mode.sectionAppend.trim()}`
76
+ : ''
77
+ const control = allowAgentControl ? ` ${AGENT_CONTROL_NOTE}` : ''
78
+ return `${configured}${appended}${control}`
79
+ }
80
+
81
+ function persistModes() {
82
+ const config = runtimeConfig()
83
+ saveModes(MODE, config.mode.storePath || undefined, config.mode.maxEntries)
55
84
  }
56
85
 
57
86
  /** Parse `/tower` arguments: bare and free text both mean "ensure on". */
58
87
  function parseTowerInput(rawInput) {
59
88
  const raw = String(rawInput ?? '').trim()
89
+ if (/^reload$/i.test(raw)) return { action: 'reload' }
60
90
  const match = /^(on|off)(?:\s+([\s\S]*))?$/i.exec(raw)
61
- if (match) return { active: match[1].toLowerCase() === 'on', message: (match[2] ?? '').trim() }
62
- return { active: true, message: raw }
91
+ if (match) return { action: 'mode', active: match[1].toLowerCase() === 'on', message: (match[2] ?? '').trim() }
92
+ return { action: 'mode', active: true, message: raw }
63
93
  }
64
94
 
65
95
  /**
@@ -91,12 +121,48 @@ function pauseActiveGoal(ctx, agent) {
91
121
  }
92
122
  }
93
123
 
124
+ function planModeState(ctx, agent) {
125
+ const service = ctx.get?.('planMode')
126
+ if (!service || !agent) return { available: false, active: false, pending: false, service: null }
127
+ try {
128
+ const state = service.get(agent) ?? {}
129
+ return { available: true, active: state.active === true, pending: state.pending === true, service }
130
+ } catch {
131
+ return { available: false, active: false, pending: false, service: null }
132
+ }
133
+ }
134
+
135
+ /** Tower and plan mode are mutually exclusive; clearing is the default policy. */
136
+ function clearPlanModeForTower(ctx, agent) {
137
+ const state = planModeState(ctx, agent)
138
+ if (!state.available || (!state.active && !state.pending)) {
139
+ return { attempted: state.available, cleared: false, refused: false, result: null }
140
+ }
141
+ if (runtimeConfig().mode.planModePolicy === 'refuse') {
142
+ return { attempted: true, cleared: false, refused: true, result: null }
143
+ }
144
+ try {
145
+ const result = state.service.set(agent, false)
146
+ return { attempted: true, cleared: true, refused: false, result }
147
+ } catch (error) {
148
+ return { attempted: true, cleared: false, refused: false, error: String(error?.message ?? error) }
149
+ }
150
+ }
151
+
94
152
  function enterMode(ctx, key, agent, by, reason) {
95
153
  const previous = MODE.get(key)
96
154
  const wasOn = Boolean(previous && previous.on)
155
+ const plan = clearPlanModeForTower(ctx, agent)
156
+ if (plan.refused) {
157
+ throw new TowerToolError(
158
+ 'invalid-transition',
159
+ 'plan mode is active and mode.planModePolicy is "refuse"; exit plan mode before entering Tower',
160
+ )
161
+ }
162
+ const autoPauseGoal = runtimeConfig().mode.autoPauseGoal !== false
97
163
  let pausedGoal = previous?.pausedGoal ?? null
98
164
  let goalNote = null
99
- if (!wasOn) {
165
+ if (!wasOn && autoPauseGoal) {
100
166
  const paused = pauseActiveGoal(ctx, agent)
101
167
  if (paused.attempted && paused.ok) pausedGoal = paused.goal
102
168
  else if (paused.attempted && !paused.ok) goalNote = `pause-failed: ${paused.reason}`
@@ -108,18 +174,24 @@ function enterMode(ctx, key, agent, by, reason) {
108
174
  MODE.set(key, {
109
175
  on: true,
110
176
  pausedGoal,
177
+ lock: previous?.lock ?? null,
178
+ baseBranch: previous?.baseBranch ?? null,
179
+ planModeCleared: plan.cleared,
111
180
  lastChange: { by, action: 'on', reason: reason ?? null, at: now },
112
181
  at: now,
113
182
  })
114
- saveModes(MODE)
115
- return { wasOn, pausedGoal, goalNote }
183
+ persistModes()
184
+ return { wasOn, pausedGoal, goalNote, planModeCleared: plan.cleared, planModeResult: plan.result ?? null }
116
185
  }
117
186
 
118
- function exitMode(key, by, reason) {
187
+ async function exitMode(key, by, reason) {
119
188
  const previous = MODE.get(key)
120
189
  if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
121
190
  MODE.delete(key)
122
- saveModes(MODE)
191
+ persistModes()
192
+ if (previous.lock) {
193
+ await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
194
+ }
123
195
  return { wasOn: true, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
124
196
  }
125
197
 
@@ -162,14 +234,98 @@ function describeChange(active, outcome, forwarded, message, allowAgentControl)
162
234
  }
163
235
 
164
236
  export function apply(ctx, rawConfig) {
165
- const config = resolveConfig(rawConfig)
166
- registerTowerTools(ctx)
237
+ const config = configureRuntime(rawConfig)
238
+ const allowAgentControl = config.mode.allowAgentControl === true
239
+ const reminder = configSection(config, allowAgentControl)
240
+ MODE = loadModes(config.mode.storePath || undefined)
241
+ denyTools = new Set(config.mode.denyTools)
242
+ const hotReloadEnabled = config.dev?.hotReload !== false
243
+ const hmrBridge = hotReloadEnabled
244
+ ? createHmrBridge(ctx, {
245
+ requiredIds: config.dev?.reloadRequiredIds,
246
+ delayMs: config.dev?.reloadDelayMs,
247
+ logger: ctx.logger ?? console,
248
+ })
249
+ : null
250
+
251
+ const workspace = {
252
+ /** Lazily acquire the advisory workspace lock on the first repo-scoped tool. */
253
+ async ensure(repoRoot, exec, toolName) {
254
+ if (runtimeConfig().mode.workspaceLock !== true) return
255
+ if (runtimeConfig().mode.workspaceLockScope === 'mutations' && READ_ONLY_TOOL_NAMES.has(toolName)) return
256
+ const key = agentKey(exec?.agent)
257
+ if (!key) return
258
+ const entry = MODE.get(key)
259
+ if (!entry) return
260
+ const repo = await resolveRepo(repoRoot)
261
+ const existing = entry.lock
262
+ if (existing && existing.commonDir !== repo.commonDir) {
263
+ throw new TowerToolError(
264
+ 'workspace-busy',
265
+ `this Tower session already owns ${existing.commonDir}; /tower off before switching repositories`,
266
+ )
267
+ }
268
+ const result = await acquireWorkspaceLock(repo.commonDir, {
269
+ token: existing?.token ?? workspaceLockToken(),
270
+ sessionId: key,
271
+ agentId: key,
272
+ staleMs: Number(runtimeConfig().mode.workspaceStaleMs) || 300000,
273
+ })
274
+ if (!result.acquired) {
275
+ const holder = result.holder
276
+ ? `session ${String(result.holder.sessionId).slice(0, 12)} (since ${
277
+ Number.isFinite(Number(result.holder.at))
278
+ ? new Date(Number(result.holder.at)).toISOString()
279
+ : 'unknown'
280
+ })`
281
+ : 'an unreadable lock'
282
+ throw new TowerToolError(
283
+ 'workspace-busy',
284
+ `workspace ${repo.root} is locked by ${holder}; another Tower session must /tower off or set mode.workspaceLock:false`,
285
+ )
286
+ }
287
+ entry.lock = { token: result.record.token, commonDir: repo.commonDir }
288
+ persistModes()
289
+ },
290
+ async status(entry, key) {
291
+ if (!entry?.lock) return { enabled: runtimeConfig().mode.workspaceLock === true, held: false }
292
+ const info = await inspectWorkspaceLock(entry.lock.commonDir, {
293
+ sessionId: key ?? null,
294
+ staleMs: Number(runtimeConfig().mode.workspaceStaleMs) || 300000,
295
+ }).catch(() => null)
296
+ return {
297
+ enabled: runtimeConfig().mode.workspaceLock === true,
298
+ held: info?.held === true,
299
+ commonDir: entry.lock.commonDir,
300
+ self: info?.self === true,
301
+ stale: info?.stale === true || info === null,
302
+ holder: info?.holder ?? null,
303
+ }
304
+ },
305
+ basePreference(exec) {
306
+ const key = agentKey(exec?.agent)
307
+ return key ? MODE.get(key)?.baseBranch ?? null : null
308
+ },
309
+ }
310
+
311
+ registerTowerTools(ctx, { workspace, hmrBridge })
312
+
313
+ // On dispose (including an HMR reload) release advisory workspace locks this
314
+ // process still owns; a reloaded plugin restores the entries from modes.json.
315
+ ctx.effect(() => () => {
316
+ if (hmrBridge) hmrBridge.dispose?.()
317
+ for (const entry of MODE.values()) {
318
+ if (entry?.lock) {
319
+ releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
320
+ }
321
+ }
322
+ })
167
323
 
168
324
  ctx.effect(() =>
169
325
  ctx.tools.guard((execution) => {
170
326
  const key = agentKey(execution.agent)
171
327
  if (!key || !MODE.has(key)) return undefined
172
- if (DENY_TOOLS.has(execution.name)) {
328
+ if (denyTools.has(execution.name)) {
173
329
  return `tower mode: ${execution.name} is disabled while Tower mode is active; use the Tower tools or /tower off`
174
330
  }
175
331
  if (execution.name === 'update_goal') {
@@ -182,25 +338,66 @@ export function apply(ctx, rawConfig) {
182
338
  }),
183
339
  )
184
340
 
341
+ // Plan mode and Tower mode are mutually exclusive. Clearing at entry covers
342
+ // the normal path; this pre-step listener also clears a plan activated later
343
+ // in the same session, before the next step can run plan-policy tooling.
344
+ ctx.effect(() =>
345
+ typeof ctx.on === 'function'
346
+ ? ctx.on('agent/pre-step', (payload, next) => {
347
+ const key = agentKey(payload?.agent)
348
+ if (key && MODE.has(key)) {
349
+ // Once Tower is active, Tower wins regardless of entry policy: clear
350
+ // any plan state so the two policies cannot coexist.
351
+ const state = planModeState(ctx, payload.agent)
352
+ if (state.available && (state.active || state.pending)) state.service.set(payload.agent, false)
353
+ }
354
+ return next()
355
+ })
356
+ : () => {},
357
+ )
358
+
185
359
  ctx.systemPrompt.section({
186
360
  name: 'tower:mode',
187
361
  order: 15000,
188
- text: (context) =>
189
- context && MODE.has(agentKey(context.agent))
190
- ? config.allowAgentControl
191
- ? `${config.section} ${AGENT_CONTROL_NOTE}`
192
- : config.section
193
- : '',
362
+ text: (context) => (context && MODE.has(agentKey(context.agent)) ? reminder : ''),
363
+ })
364
+
365
+ // Workspace-lock heartbeat; only sessions holding a lock do I/O.
366
+ ctx.effect(() => {
367
+ const timer = setInterval(() => {
368
+ for (const entry of MODE.values()) {
369
+ if (!entry?.lock) continue
370
+ touchWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
371
+ }
372
+ }, Math.max(1000, Number(config.mode.heartbeatMs) || 10000))
373
+ if (typeof timer.unref === 'function') timer.unref()
374
+ return () => clearInterval(timer)
194
375
  })
195
376
 
196
377
  ctx.effect(() =>
197
378
  ctx.tools.register({
198
379
  name: 'tower_mode',
199
380
  description:
200
- 'Report whether Tower mode is active for the calling agent, who changed it last, whether the model may toggle it, and any goal paused on entry.',
381
+ 'Report whether Tower mode is active for the calling agent, who changed it last, whether the model may toggle it, any goal paused on entry, the workspace lock, HMR availability and the plugin version.',
201
382
  parameters: { type: 'object', properties: {}, additionalProperties: false },
202
383
  output: {
203
- schema: { type: 'object' },
384
+ schema: {
385
+ type: 'object',
386
+ properties: {
387
+ mode: { type: 'string', enum: ['on', 'off', 'unknown'] },
388
+ agentId: {},
389
+ allowAgentControl: { type: 'boolean' },
390
+ lastChange: {},
391
+ pausedGoal: {},
392
+ deniedWhileActive: { type: 'array', items: { type: 'string' } },
393
+ persistedAt: { type: 'string' },
394
+ pluginVersion: {},
395
+ hotReload: {},
396
+ workspaceLock: {},
397
+ },
398
+ required: ['mode', 'allowAgentControl', 'deniedWhileActive'],
399
+ additionalProperties: true,
400
+ },
204
401
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
205
402
  },
206
403
  async execute(_args, exec) {
@@ -209,17 +406,27 @@ export function apply(ctx, rawConfig) {
209
406
  return {
210
407
  mode: entry && entry.on === true ? 'on' : key ? 'off' : 'unknown',
211
408
  agentId: key,
212
- allowAgentControl: config.allowAgentControl,
409
+ allowAgentControl,
213
410
  lastChange: entry?.lastChange ?? null,
214
411
  pausedGoal: entry?.pausedGoal ?? null,
215
- deniedWhileActive: [...DENY_TOOLS],
216
- persistedAt: storePath(),
412
+ deniedWhileActive: [...denyTools],
413
+ persistedAt: storePath(config.mode.storePath || undefined),
414
+ pluginVersion: readPluginVersion(),
415
+ hotReload: hmrBridge ? hmrBridge.status() : { available: false, disabled: true },
416
+ planMode: (() => {
417
+ const state = planModeState(ctx, exec.agent)
418
+ return { available: state.available, active: state.active, pending: state.pending }
419
+ })(),
420
+ baseBranch: entry?.baseBranch ?? null,
421
+ workspaceLock: entry
422
+ ? await workspace.status(entry, key)
423
+ : { enabled: config.mode.workspaceLock === true, held: false },
217
424
  }
218
425
  },
219
426
  }),
220
427
  )
221
428
 
222
- if (config.allowAgentControl) {
429
+ if (allowAgentControl) {
223
430
  ctx.effect(() =>
224
431
  ctx.tools.register({
225
432
  name: 'tower_control',
@@ -235,7 +442,22 @@ export function apply(ctx, rawConfig) {
235
442
  additionalProperties: false,
236
443
  },
237
444
  output: {
238
- schema: { type: 'object' },
445
+ schema: {
446
+ type: 'object',
447
+ properties: {
448
+ ok: { type: 'boolean' },
449
+ action: { type: 'string', enum: ['enter', 'exit'] },
450
+ mode: { type: 'string', enum: ['on', 'off'] },
451
+ changed: { type: 'boolean' },
452
+ changedBy: { type: 'string', enum: ['agent', 'user'] },
453
+ reason: { type: 'string' },
454
+ pausedGoal: {},
455
+ goalNote: {},
456
+ error: { type: 'string' },
457
+ },
458
+ required: ['ok'],
459
+ additionalProperties: true,
460
+ },
239
461
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
240
462
  },
241
463
  async execute(args, exec) {
@@ -248,7 +470,7 @@ export function apply(ctx, rawConfig) {
248
470
  const outcome =
249
471
  args.action === 'enter'
250
472
  ? enterMode(ctx, key, exec.agent, 'agent', reason)
251
- : exitMode(key, 'agent', reason)
473
+ : await exitMode(key, 'agent', reason)
252
474
  return {
253
475
  ok: true,
254
476
  action: args.action,
@@ -267,15 +489,37 @@ export function apply(ctx, rawConfig) {
267
489
  ctx.inject(['commands'], (commandCtx) => {
268
490
  commandCtx.commands.register({
269
491
  name: 'tower',
270
- description: 'Enter or leave Tower mode, optionally carrying a user instruction',
271
- input: { hint: '[on|off] [message]', attachments: true },
492
+ description: 'Enter or leave Tower mode, optionally carrying a user instruction; /tower reload reloads via HMR; /tower base <branch> sets the next init base',
493
+ input: { hint: '[on|off|reload|base <branch>] [message]', attachments: true },
272
494
  handler: async ({ agent, rawInput, attachments }) => {
273
495
  const key = agentKey(agent)
274
496
  if (!key) return { kind: 'error', text: 'Tower mode requires a live agent.' }
275
- const { active, message } = parseTowerInput(rawInput)
497
+ const baseMatch = String(rawInput ?? '').trim().match(/^base\s+(\S+)\s*$/i)
498
+ if (baseMatch) {
499
+ const entry = MODE.get(key)
500
+ if (!entry) return { kind: 'error', text: 'Run /tower on first, then /tower base <branch>.' }
501
+ entry.baseBranch = baseMatch[1]
502
+ persistModes()
503
+ return { kind: 'success', text: `Tower base preference set to "${entry.baseBranch}"; tower_init without an explicit base will use it.` }
504
+ }
505
+ const parsed = parseTowerInput(rawInput)
506
+ if (parsed.action === 'reload') {
507
+ if (!hotReloadEnabled) {
508
+ return { kind: 'error', text: 'Tower reload is disabled by dev.hotReload:false; restart DSH instead.' }
509
+ }
510
+ if (!hmrBridge || !hmrBridge.available) {
511
+ return { kind: 'error', text: 'Tower reload is unavailable: this DSH profile has no HMR service. Restart DSH instead.' }
512
+ }
513
+ const outcome = hmrBridge.schedule()
514
+ return {
515
+ kind: 'success',
516
+ text: `Tower reload scheduled in ${outcome.delayMs}ms (required entries: ${outcome.requiredIds.join(', ')}). If the new code does not appear, restart DSH.`,
517
+ }
518
+ }
519
+ const { active, message } = parsed
276
520
  const outcome = active
277
521
  ? enterMode(ctx, key, agent, 'user', message || null)
278
- : exitMode(key, 'user', message || null)
522
+ : await exitMode(key, 'user', message || null)
279
523
 
280
524
  let forwarded = false
281
525
  try {
@@ -288,7 +532,7 @@ export function apply(ctx, rawConfig) {
288
532
  }
289
533
  return {
290
534
  kind: 'success',
291
- text: describeChange(active, outcome, forwarded, message, config.allowAgentControl),
535
+ text: describeChange(active, outcome, forwarded, message, allowAgentControl),
292
536
  }
293
537
  },
294
538
  })