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.
- package/CHANGELOG.md +77 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +22 -8
- package/README.md +20 -8
- package/cordis.patch.yml +10 -0
- package/docs/README.md +8 -4
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +33 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +31 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +52 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +58 -0
- package/docs/zh/faq.md +72 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +30 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +48 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +270 -8
- package/lib/doctor.js +165 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1554 -182
- package/lib/tower.js +957 -102
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- 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
|
-
|
|
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,
|
|
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: {
|
|
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: {
|
|
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
|
|
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 {
|