dsh-tower 1.1.2 → 1.3.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.
package/lib/modeStore.js CHANGED
@@ -1,29 +1,32 @@
1
1
  // Tower mode persistence.
2
2
  //
3
3
  // The session-projection route needs zod schemas, which this bundle cannot
4
- // resolve from the DSH installation; a small atomic JSON store under
5
- // DSH_HOME/tower-mode keeps the mode across restarts without adding a
6
- // dependency or touching profile state.
4
+ // resolve from the DSH installation on every deployment; a small atomic JSON
5
+ // store under DSH_HOME/tower-mode keeps the mode across restarts.
7
6
  import fs from 'node:fs'
8
7
  import os from 'node:os'
9
8
  import path from 'node:path'
10
9
 
11
- const MAX_ENTRIES = 200
10
+ const DEFAULT_MAX_ENTRIES = 200
12
11
 
13
- function storeDir() {
12
+ function defaultStoreDir() {
14
13
  const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
15
14
  return path.join(home, 'tower-mode')
16
15
  }
17
16
 
18
- function storeFile() {
19
- return path.join(storeDir(), 'modes.json')
17
+ function storeDir(baseDir) {
18
+ return baseDir && String(baseDir).trim() ? path.resolve(String(baseDir)) : defaultStoreDir()
19
+ }
20
+
21
+ function storeFile(baseDir) {
22
+ return path.join(storeDir(baseDir), 'modes.json')
20
23
  }
21
24
 
22
25
  /** Read persisted modes into a Map keyed by agent/session id. */
23
- export function loadModes() {
26
+ export function loadModes(baseDir) {
24
27
  const modes = new Map()
25
28
  try {
26
- const raw = fs.readFileSync(storeFile(), 'utf8')
29
+ const raw = fs.readFileSync(storeFile(baseDir), 'utf8')
27
30
  const parsed = JSON.parse(raw)
28
31
  const entries = parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object'
29
32
  ? parsed.modes
@@ -45,15 +48,16 @@ export function loadModes() {
45
48
  }
46
49
 
47
50
  /** Persist the current modes atomically; failures never break the command. */
48
- export function saveModes(modes) {
51
+ export function saveModes(modes, baseDir, maxEntries = DEFAULT_MAX_ENTRIES) {
49
52
  try {
50
- const dir = storeDir()
53
+ const dir = storeDir(baseDir)
51
54
  fs.mkdirSync(dir, { recursive: true })
55
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0 ? maxEntries : DEFAULT_MAX_ENTRIES
52
56
  const entries = [...modes.entries()]
53
57
  .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
54
- .slice(0, MAX_ENTRIES)
58
+ .slice(0, limit)
55
59
  const payload = { version: 1, modes: Object.fromEntries(entries) }
56
- const file = storeFile()
60
+ const file = storeFile(baseDir)
57
61
  const tmp = `${file}.tmp-${process.pid}`
58
62
  fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
59
63
  fs.renameSync(tmp, file)
@@ -63,6 +67,6 @@ export function saveModes(modes) {
63
67
  }
64
68
  }
65
69
 
66
- export function storePath() {
67
- return storeFile()
70
+ export function storePath(baseDir) {
71
+ return storeFile(baseDir)
68
72
  }
package/lib/tools.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Tower tools: init, plan, mission, status, spawn, roster, adopt.
2
2
  import { Tower } from './tower.js'
3
+ import { runtimeConfig } from './config.js'
3
4
 
4
5
  const OUTPUT = {
5
6
  schema: { type: 'object' },
@@ -251,7 +252,7 @@ export function registerTowerTools(ctx) {
251
252
  define(ctx, {
252
253
  name: 'tower_spawn',
253
254
  description:
254
- 'Spawn a worker or reviewer for a mission. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing (mission tasks, scope, deps, context, rules), and starts a background tower-worker subagent in that worktree. Records the agent in the roster and marks the mission active. Returns immediately unless wait=true.',
255
+ 'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
255
256
  parameters: {
256
257
  type: 'object',
257
258
  properties: {
@@ -272,9 +273,17 @@ export function registerTowerTools(ctx) {
272
273
  const tower = await Tower.open(args.repoRoot)
273
274
  const role = args.role ?? 'worker'
274
275
  if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
276
+ const config = runtimeConfig()
277
+ const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
278
+ const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
275
279
  const context = await tower.missionContext(args.missionId)
276
- if (role === 'reviewer' && !context.mission.worktree) {
277
- fail('reviewer needs an existing mission worktree; spawn a worker first')
280
+ if (role === 'reviewer') {
281
+ if (!context.mission.worktree) {
282
+ fail('reviewer needs an existing mission worktree; spawn a worker first')
283
+ }
284
+ if (context.mission.status !== 'completed') {
285
+ fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
286
+ }
278
287
  }
279
288
  const provisioned = await tower.provision({
280
289
  missionId: args.missionId,
@@ -311,25 +320,35 @@ export function registerTowerTools(ctx) {
311
320
  const subagents = ctx.get('subagents')
312
321
  if (!subagents) fail('subagents service is unavailable; cannot spawn')
313
322
  const providers = subagents.list()
314
- const provider = providers.includes('spawn') ? 'spawn' : providers[0]
323
+ const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
315
324
  if (!provider) fail('no subagent provider is registered')
325
+ const presets = ctx.get('agentPresets')
326
+ if (presets && typeof presets.list === 'function') {
327
+ const available = await presets.list()
328
+ if (!available.some((entry) => String(entry.id) === preset)) {
329
+ fail(`agent preset "${preset}" is not registered; check the Tower bundle's preset rows and restart DSH`)
330
+ }
331
+ }
332
+
333
+ const agentOptions = {}
334
+ for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
335
+ if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
336
+ }
316
337
 
317
338
  const activation = await subagents.startActivation({
318
339
  provider,
319
340
  label: name,
320
341
  request: {
321
342
  cwd: worktree.path,
322
- agentPreset: 'tower-worker',
343
+ agentPreset: preset,
323
344
  prompt: [{ type: 'text', text: briefing }],
324
345
  parent: exec.agent,
325
- maxDepth: 1,
346
+ maxDepth: config.spawn.maxDepth ?? 1,
347
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
326
348
  },
327
349
  signal: exec.signal,
328
350
  delivery: 'parent',
329
351
  })
330
- if (activation.result && typeof activation.result.catch === 'function') {
331
- activation.result.catch(() => {})
332
- }
333
352
 
334
353
  const agentId = String(activation.childId)
335
354
  const entry = await tower.recordSpawn({
@@ -342,16 +361,22 @@ export function registerTowerTools(ctx) {
342
361
  branch: worktree.branch,
343
362
  })
344
363
 
364
+ const recordOutcome = (state, detail) =>
365
+ tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
366
+
345
367
  const report = {
346
368
  name,
347
369
  agentId,
348
370
  role,
349
371
  missionId: mission.id,
372
+ preset,
373
+ provider,
374
+ maxDepth: config.spawn.maxDepth ?? 1,
375
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
350
376
  worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
351
377
  created: provisioned.created,
352
378
  briefingChars: briefing.length,
353
379
  rosterEntry: entry,
354
- provider,
355
380
  }
356
381
 
357
382
  if (args.wait === true) {
@@ -363,16 +388,32 @@ export function registerTowerTools(ctx) {
363
388
  let result
364
389
  try {
365
390
  result = await Promise.race([activation.result, timeout])
391
+ } catch (error) {
392
+ report.stopReason = 'failed'
393
+ report.diagnostic = String(error?.message ?? error)
394
+ await recordOutcome('failed', report.diagnostic)
395
+ throw error
366
396
  } finally {
367
397
  clearTimeout(timer)
368
398
  }
369
399
  if (result && result.__timeout) {
370
400
  report.timedOut = true
401
+ recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
371
402
  } else {
372
403
  report.stopReason = result?.stopReason ?? null
373
404
  report.output = textOf(result?.output)
374
405
  report.diagnostic = result?.diagnostic ?? null
406
+ const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
407
+ if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
408
+ await recordOutcome(state, report.diagnostic ?? null)
375
409
  }
410
+ } else {
411
+ activation.result
412
+ .then((result) => {
413
+ const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
414
+ return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
415
+ })
416
+ .catch((error) => recordOutcome('failed', String(error?.message ?? error)))
376
417
  }
377
418
  return report
378
419
  },
@@ -423,6 +464,27 @@ export function registerTowerTools(ctx) {
423
464
  },
424
465
  })
425
466
 
467
+ define(ctx, {
468
+ name: 'tower_diff',
469
+ description:
470
+ 'Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository.',
471
+ parameters: {
472
+ type: 'object',
473
+ properties: {
474
+ repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
475
+ missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
476
+ maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
477
+ },
478
+ required: ['repoRoot', 'missionId'],
479
+ additionalProperties: false,
480
+ },
481
+ output: OUTPUT,
482
+ async execute(args) {
483
+ const tower = await Tower.open(args.repoRoot)
484
+ return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
485
+ },
486
+ })
487
+
426
488
  define(ctx, {
427
489
  name: 'tower_adopt',
428
490
  description:
@@ -732,12 +794,17 @@ export function registerTowerTools(ctx) {
732
794
  define(ctx, {
733
795
  name: 'tower_merge',
734
796
  description:
735
- 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. A failed gate returns stable blocker codes instead of merging; a Git conflict aborts the merge and marks the mission blocked.',
797
+ 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict".',
736
798
  parameters: {
737
799
  type: 'object',
738
800
  properties: {
739
801
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
740
802
  missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
803
+ timeoutMs: {
804
+ type: 'integer',
805
+ minimum: 1000,
806
+ description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
807
+ },
741
808
  },
742
809
  required: ['repoRoot', 'missionId'],
743
810
  additionalProperties: false,
@@ -746,14 +813,14 @@ export function registerTowerTools(ctx) {
746
813
  async execute(args) {
747
814
  const tower = await Tower.open(args.repoRoot)
748
815
  const { activity } = await observeActivity(ctx, tower)
749
- return tower.merge({ missionId: args.missionId, activity })
816
+ return tower.merge({ missionId: args.missionId, activity, timeoutMs: args.timeoutMs })
750
817
  },
751
818
  })
752
819
 
753
820
  define(ctx, {
754
821
  name: 'tower_rebase',
755
822
  description:
756
- 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked.',
823
+ 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge.',
757
824
  parameters: {
758
825
  type: 'object',
759
826
  properties: {