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.
- package/CHANGELOG.md +96 -0
- package/CONTRIBUTING.md +36 -0
- package/README.en.md +60 -23
- package/README.md +54 -21
- package/SECURITY.md +20 -0
- package/cordis.patch.yml +32 -0
- package/docs/README.md +17 -0
- package/docs/architecture/overview.md +70 -0
- package/docs/examples/first-mission.md +58 -0
- package/docs/faq.md +74 -0
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +59 -0
- package/docs/guide/quickstart.md +97 -0
- package/docs/operations/runbook.md +80 -0
- package/docs/operations/security.md +62 -0
- package/docs/reference/blockers.md +58 -0
- package/docs/reference/config.md +114 -0
- 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 +75 -0
- 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 +293 -49
- package/lib/batch.js +578 -0
- package/lib/config.js +381 -0
- 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 +47 -16
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1594 -160
- package/lib/tower.js +1025 -101
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +21 -4
- package/reviewer.js +40 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-recovery.md +40 -0
- package/skills/tower-review.md +16 -0
- package/skills/tower-worker-protocol.md +15 -0
- package/skills.js +63 -0
- 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
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
'
|
|
22
|
-
'
|
|
23
|
-
'
|
|
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
|
|
44
|
-
const
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
166
|
-
|
|
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 (
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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: {
|
|
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
|
|
409
|
+
allowAgentControl,
|
|
213
410
|
lastChange: entry?.lastChange ?? null,
|
|
214
411
|
pausedGoal: entry?.pausedGoal ?? null,
|
|
215
|
-
deniedWhileActive: [...
|
|
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 (
|
|
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: {
|
|
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
|
|
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,
|
|
535
|
+
text: describeChange(active, outcome, forwarded, message, allowAgentControl),
|
|
292
536
|
}
|
|
293
537
|
},
|
|
294
538
|
})
|