dsh-tower 1.3.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 (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +22 -8
  4. package/README.md +20 -8
  5. package/cordis.patch.yml +10 -0
  6. package/docs/README.md +8 -4
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +33 -2
  9. package/docs/guide/hot-reload.md +189 -0
  10. package/docs/guide/install-upgrade-migration.md +11 -0
  11. package/docs/guide/quickstart.md +33 -1
  12. package/docs/operations/runbook.md +23 -0
  13. package/docs/operations/security.md +23 -0
  14. package/docs/reference/blockers.md +10 -2
  15. package/docs/reference/config.md +65 -2
  16. package/docs/reference/doctor.md +31 -0
  17. package/docs/reference/errors.md +50 -0
  18. package/docs/reference/events.md +45 -0
  19. package/docs/reference/operations.md +52 -0
  20. package/docs/reference/testing.md +61 -0
  21. package/docs/reference/tools.md +65 -23
  22. package/docs/zh/README.md +58 -0
  23. package/docs/zh/faq.md +72 -0
  24. package/docs/zh/guide/hot-reload.md +178 -0
  25. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  26. package/docs/zh/guide/quickstart.md +96 -0
  27. package/docs/zh/operations/security.md +42 -0
  28. package/docs/zh/reference/blockers.md +62 -0
  29. package/docs/zh/reference/config.md +113 -0
  30. package/docs/zh/reference/doctor.md +30 -0
  31. package/docs/zh/reference/errors.md +52 -0
  32. package/docs/zh/reference/events.md +46 -0
  33. package/docs/zh/reference/operations.md +48 -0
  34. package/docs/zh/reference/testing.md +60 -0
  35. package/docs/zh/reference/tools.md +75 -0
  36. package/index.js +261 -13
  37. package/lib/batch.js +578 -0
  38. package/lib/config.js +270 -8
  39. package/lib/doctor.js +165 -0
  40. package/lib/errors.js +111 -0
  41. package/lib/hmr.js +196 -0
  42. package/lib/leases.js +317 -0
  43. package/lib/legacy.js +413 -0
  44. package/lib/modeStore.js +28 -1
  45. package/lib/store.js +24 -1
  46. package/lib/tests.js +628 -0
  47. package/lib/tools.js +1554 -182
  48. package/lib/tower.js +957 -102
  49. package/lib/workspaceLock.js +117 -0
  50. package/lib/worktreeOwnership.js +166 -0
  51. package/package.json +4 -1
  52. package/reviewer.js +1 -0
  53. package/scripts/check-docs.mjs +135 -0
  54. package/scripts/enable-dev-hmr.mjs +347 -0
  55. package/skills/tower-review.md +4 -2
  56. package/skills/tower-worker-protocol.md +3 -1
  57. package/worker.js +4 -2
package/index.js CHANGED
@@ -6,10 +6,24 @@
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'
12
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'
13
27
 
14
28
  export const name = 'dsh-tower'
15
29
  export const inject = ['tools', 'systemPrompt']
@@ -17,6 +31,14 @@ export const Config = ConfigSchema
17
31
 
18
32
  let MODE = new Map()
19
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',
40
+ ])
41
+ let pluginVersion = null
20
42
 
21
43
  const BASE_REMINDER = [
22
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.',
@@ -28,6 +50,17 @@ const BASE_REMINDER = [
28
50
  const AGENT_CONTROL_NOTE =
29
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.'
30
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
+
31
64
  function agentKey(agent) {
32
65
  if (!agent) return null
33
66
  const key = agent.id ?? agent.session
@@ -53,9 +86,10 @@ function persistModes() {
53
86
  /** Parse `/tower` arguments: bare and free text both mean "ensure on". */
54
87
  function parseTowerInput(rawInput) {
55
88
  const raw = String(rawInput ?? '').trim()
89
+ if (/^reload$/i.test(raw)) return { action: 'reload' }
56
90
  const match = /^(on|off)(?:\s+([\s\S]*))?$/i.exec(raw)
57
- if (match) return { active: match[1].toLowerCase() === 'on', message: (match[2] ?? '').trim() }
58
- 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 }
59
93
  }
60
94
 
61
95
  /**
@@ -87,9 +121,44 @@ function pauseActiveGoal(ctx, agent) {
87
121
  }
88
122
  }
89
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
+
90
152
  function enterMode(ctx, key, agent, by, reason) {
91
153
  const previous = MODE.get(key)
92
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
+ }
93
162
  const autoPauseGoal = runtimeConfig().mode.autoPauseGoal !== false
94
163
  let pausedGoal = previous?.pausedGoal ?? null
95
164
  let goalNote = null
@@ -105,18 +174,24 @@ function enterMode(ctx, key, agent, by, reason) {
105
174
  MODE.set(key, {
106
175
  on: true,
107
176
  pausedGoal,
177
+ lock: previous?.lock ?? null,
178
+ baseBranch: previous?.baseBranch ?? null,
179
+ planModeCleared: plan.cleared,
108
180
  lastChange: { by, action: 'on', reason: reason ?? null, at: now },
109
181
  at: now,
110
182
  })
111
183
  persistModes()
112
- return { wasOn, pausedGoal, goalNote }
184
+ return { wasOn, pausedGoal, goalNote, planModeCleared: plan.cleared, planModeResult: plan.result ?? null }
113
185
  }
114
186
 
115
- function exitMode(key, by, reason) {
187
+ async function exitMode(key, by, reason) {
116
188
  const previous = MODE.get(key)
117
189
  if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
118
190
  MODE.delete(key)
119
191
  persistModes()
192
+ if (previous.lock) {
193
+ await releaseWorkspaceLock(previous.lock.commonDir, { token: previous.lock.token }).catch(() => {})
194
+ }
120
195
  return { wasOn: true, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
121
196
  }
122
197
 
@@ -164,7 +239,87 @@ export function apply(ctx, rawConfig) {
164
239
  const reminder = configSection(config, allowAgentControl)
165
240
  MODE = loadModes(config.mode.storePath || undefined)
166
241
  denyTools = new Set(config.mode.denyTools)
167
- registerTowerTools(ctx)
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
+ })
168
323
 
169
324
  ctx.effect(() =>
170
325
  ctx.tools.guard((execution) => {
@@ -183,20 +338,66 @@ export function apply(ctx, rawConfig) {
183
338
  }),
184
339
  )
185
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
+
186
359
  ctx.systemPrompt.section({
187
360
  name: 'tower:mode',
188
361
  order: 15000,
189
362
  text: (context) => (context && MODE.has(agentKey(context.agent)) ? reminder : ''),
190
363
  })
191
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)
375
+ })
376
+
192
377
  ctx.effect(() =>
193
378
  ctx.tools.register({
194
379
  name: 'tower_mode',
195
380
  description:
196
- '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.',
197
382
  parameters: { type: 'object', properties: {}, additionalProperties: false },
198
383
  output: {
199
- 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
+ },
200
401
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
201
402
  },
202
403
  async execute(_args, exec) {
@@ -210,6 +411,16 @@ export function apply(ctx, rawConfig) {
210
411
  pausedGoal: entry?.pausedGoal ?? null,
211
412
  deniedWhileActive: [...denyTools],
212
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 },
213
424
  }
214
425
  },
215
426
  }),
@@ -231,7 +442,22 @@ export function apply(ctx, rawConfig) {
231
442
  additionalProperties: false,
232
443
  },
233
444
  output: {
234
- 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
+ },
235
461
  render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
236
462
  },
237
463
  async execute(args, exec) {
@@ -244,7 +470,7 @@ export function apply(ctx, rawConfig) {
244
470
  const outcome =
245
471
  args.action === 'enter'
246
472
  ? enterMode(ctx, key, exec.agent, 'agent', reason)
247
- : exitMode(key, 'agent', reason)
473
+ : await exitMode(key, 'agent', reason)
248
474
  return {
249
475
  ok: true,
250
476
  action: args.action,
@@ -263,15 +489,37 @@ export function apply(ctx, rawConfig) {
263
489
  ctx.inject(['commands'], (commandCtx) => {
264
490
  commandCtx.commands.register({
265
491
  name: 'tower',
266
- description: 'Enter or leave Tower mode, optionally carrying a user instruction',
267
- 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 },
268
494
  handler: async ({ agent, rawInput, attachments }) => {
269
495
  const key = agentKey(agent)
270
496
  if (!key) return { kind: 'error', text: 'Tower mode requires a live agent.' }
271
- 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
272
520
  const outcome = active
273
521
  ? enterMode(ctx, key, agent, 'user', message || null)
274
- : exitMode(key, 'user', message || null)
522
+ : await exitMode(key, 'user', message || null)
275
523
 
276
524
  let forwarded = false
277
525
  try {