dsh-tower 0.0.0-stage → 1.1.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/index.js ADDED
@@ -0,0 +1,296 @@
1
+ // dsh-tower — Host entry.
2
+ //
3
+ // Tower mode is a session-level stance. The human owns it through the
4
+ // `/tower` command: bare `/tower`, `/tower on [message]`, `/tower off
5
+ // [message]`, and `/tower <message>` all work, and any trailing text is
6
+ // forwarded to the model as the next user message. With
7
+ // config.allowAgentControl the model may also enter or leave the mode through
8
+ // `tower_control` when the user intent or task state clearly calls for it.
9
+ import { randomUUID } from 'node:crypto'
10
+ import { registerTowerTools } from './lib/tools.js'
11
+ import { loadModes, saveModes, storePath } from './lib/modeStore.js'
12
+
13
+ export const name = 'dsh-tower'
14
+ export const inject = ['tools', 'systemPrompt']
15
+
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',
25
+ ])
26
+
27
+ const BASE_REMINDER = [
28
+ '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.',
29
+ 'Mission tracking lives in tower_plan / tower_mission / tower_status, not todo_write or the Team task board.',
30
+ 'Do not create or resume goals, do not spawn teammates, and never merge without a green tower_preflight.',
31
+ 'Only the human can normally change this mode with /tower on|off.',
32
+ ].join(' ')
33
+
34
+ const AGENT_CONTROL_NOTE =
35
+ '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
+
37
+ function agentKey(agent) {
38
+ if (!agent) return null
39
+ const key = agent.id ?? agent.session
40
+ return key === undefined || key === null ? null : String(key)
41
+ }
42
+
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
53
+ : BASE_REMINDER
54
+ return { section, allowAgentControl: input.allowAgentControl === true }
55
+ }
56
+
57
+ /** Parse `/tower` arguments: bare and free text both mean "ensure on". */
58
+ function parseTowerInput(rawInput) {
59
+ const raw = String(rawInput ?? '').trim()
60
+ 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 }
63
+ }
64
+
65
+ /**
66
+ * Pause an active goal through the goals service when Tower mode starts.
67
+ * DSH exposes `ctx.goals.get/pause`, so no projection guesswork is needed.
68
+ */
69
+ function pauseActiveGoal(ctx, agent) {
70
+ const goals = ctx.get('goals')
71
+ if (!goals || typeof goals.get !== 'function' || typeof goals.pause !== 'function') {
72
+ return { attempted: false, reason: 'no goals service' }
73
+ }
74
+ try {
75
+ const view = goals.get(agent)
76
+ if (!view) return { attempted: false, reason: 'no goal' }
77
+ if (view.phase !== 'active') return { attempted: false, reason: `goal is ${view.phase}` }
78
+ const paused = goals.pause(agent, { id: view.id, revision: view.revision })
79
+ return {
80
+ attempted: true,
81
+ ok: true,
82
+ goal: {
83
+ id: String(paused.id),
84
+ revision: paused.revision,
85
+ objective: paused.objective,
86
+ phase: paused.phase,
87
+ },
88
+ }
89
+ } catch (error) {
90
+ return { attempted: true, ok: false, reason: String(error?.message ?? error) }
91
+ }
92
+ }
93
+
94
+ function enterMode(ctx, key, agent, by, reason) {
95
+ const previous = MODE.get(key)
96
+ const wasOn = Boolean(previous && previous.on)
97
+ let pausedGoal = previous?.pausedGoal ?? null
98
+ let goalNote = null
99
+ if (!wasOn) {
100
+ const paused = pauseActiveGoal(ctx, agent)
101
+ if (paused.attempted && paused.ok) pausedGoal = paused.goal
102
+ else if (paused.attempted && !paused.ok) goalNote = `pause-failed: ${paused.reason}`
103
+ else if (paused.reason && paused.reason !== 'no goal' && paused.reason !== 'no goals service') {
104
+ goalNote = paused.reason
105
+ }
106
+ }
107
+ const now = Date.now()
108
+ MODE.set(key, {
109
+ on: true,
110
+ pausedGoal,
111
+ lastChange: { by, action: 'on', reason: reason ?? null, at: now },
112
+ at: now,
113
+ })
114
+ saveModes(MODE)
115
+ return { wasOn, pausedGoal, goalNote }
116
+ }
117
+
118
+ function exitMode(key, by, reason) {
119
+ const previous = MODE.get(key)
120
+ if (!previous) return { wasOn: false, pausedGoal: null, goalNote: null }
121
+ MODE.delete(key)
122
+ saveModes(MODE)
123
+ return { wasOn: true, pausedGoal: previous.pausedGoal ?? null, goalNote: null, by, reason: reason ?? null }
124
+ }
125
+
126
+ function forwardInstruction(agent, message, attachments) {
127
+ const content = [...(Array.isArray(attachments) ? attachments : [])]
128
+ if (message) content.push({ type: 'text', text: message })
129
+ if (content.length === 0) return false
130
+ if (!agent || typeof agent.steer !== 'function') {
131
+ throw new Error('the calling agent cannot accept a steering message')
132
+ }
133
+ // `Agent.steer` takes a COMPLETE UserMessage (`id` + `role` + `content` +
134
+ // `source`), not a `{content, source}` init. Passing the init shape used to
135
+ // insert a role-less message into the inbox; the next model request then
136
+ // failed with 422 "messages[N]: missing field `role`" and the session could
137
+ // no longer produce valid requests. Mirror `createUserMessage()` here, since
138
+ // a local bundle cannot import `@deepseek-ai/dsh-llm` from the installation.
139
+ agent.steer({
140
+ id: randomUUID(),
141
+ role: 'user',
142
+ content,
143
+ source: { kind: 'user' },
144
+ })
145
+ return true
146
+ }
147
+
148
+ function describeChange(active, outcome, forwarded, message, allowAgentControl) {
149
+ const lines = []
150
+ if (active) {
151
+ lines.push(outcome.wasOn ? 'Tower mode remains on.' : 'Tower mode ON. Use the Tower tools; goal, todo, and Team delegation tools are disabled.')
152
+ } else {
153
+ lines.push(outcome.wasOn ? 'Tower mode OFF. Normal collaboration tools are available again.' : 'Tower mode is already off.')
154
+ }
155
+ if (outcome.pausedGoal) {
156
+ lines.push(`A goal was paused on entry ("${outcome.pausedGoal.objective ?? outcome.pausedGoal.id}"); resume it yourself when ready.`)
157
+ }
158
+ if (outcome.goalNote) lines.push(`Goal handling: ${outcome.goalNote}`)
159
+ if (forwarded) lines.push(message ? `Carried your instruction: ${message}` : 'Carried your attachments.')
160
+ if (!allowAgentControl) lines.push('Only the human can change Tower mode.')
161
+ return lines.join(' ')
162
+ }
163
+
164
+ export function apply(ctx, rawConfig) {
165
+ const config = resolveConfig(rawConfig)
166
+ registerTowerTools(ctx)
167
+
168
+ ctx.effect(() =>
169
+ ctx.tools.guard((execution) => {
170
+ const key = agentKey(execution.agent)
171
+ if (!key || !MODE.has(key)) return undefined
172
+ if (DENY_TOOLS.has(execution.name)) {
173
+ return `tower mode: ${execution.name} is disabled while Tower mode is active; use the Tower tools or /tower off`
174
+ }
175
+ if (execution.name === 'update_goal') {
176
+ const args = execution.arguments
177
+ if (args && typeof args === 'object' && args.action === 'resume') {
178
+ return 'tower mode: goals cannot be resumed while Tower mode is active; use /tower off first'
179
+ }
180
+ }
181
+ return undefined
182
+ }),
183
+ )
184
+
185
+ ctx.systemPrompt.section({
186
+ name: 'tower:mode',
187
+ 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
+ : '',
194
+ })
195
+
196
+ ctx.effect(() =>
197
+ ctx.tools.register({
198
+ name: 'tower_mode',
199
+ 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.',
201
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
202
+ output: {
203
+ schema: { type: 'object' },
204
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
205
+ },
206
+ async execute(_args, exec) {
207
+ const key = agentKey(exec.agent)
208
+ const entry = key ? MODE.get(key) : undefined
209
+ return {
210
+ mode: entry && entry.on === true ? 'on' : key ? 'off' : 'unknown',
211
+ agentId: key,
212
+ allowAgentControl: config.allowAgentControl,
213
+ lastChange: entry?.lastChange ?? null,
214
+ pausedGoal: entry?.pausedGoal ?? null,
215
+ deniedWhileActive: [...DENY_TOOLS],
216
+ persistedAt: storePath(),
217
+ }
218
+ },
219
+ }),
220
+ )
221
+
222
+ if (config.allowAgentControl) {
223
+ ctx.effect(() =>
224
+ ctx.tools.register({
225
+ name: 'tower_control',
226
+ description:
227
+ 'Enter or leave Tower mode for the calling agent. Allowed only when the deployment enables allowAgentControl; use it when the user intent or task state clearly calls for Tower orchestration or a return to normal collaboration, and always state a concrete reason.',
228
+ parameters: {
229
+ type: 'object',
230
+ properties: {
231
+ action: { type: 'string', enum: ['enter', 'exit'], description: 'enter = Tower mode on; exit = Tower mode off.' },
232
+ reason: { type: 'string', description: 'Why this mode change is warranted (user intent or task state).' },
233
+ },
234
+ required: ['action', 'reason'],
235
+ additionalProperties: false,
236
+ },
237
+ output: {
238
+ schema: { type: 'object' },
239
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
240
+ },
241
+ async execute(args, exec) {
242
+ const key = agentKey(exec.agent)
243
+ if (!key) return { ok: false, error: 'tower_control requires a live calling agent' }
244
+ if (typeof args.reason !== 'string' || args.reason.trim().length === 0) {
245
+ throw new Error('tower_control requires a non-empty reason')
246
+ }
247
+ const reason = args.reason.trim()
248
+ const outcome =
249
+ args.action === 'enter'
250
+ ? enterMode(ctx, key, exec.agent, 'agent', reason)
251
+ : exitMode(key, 'agent', reason)
252
+ return {
253
+ ok: true,
254
+ action: args.action,
255
+ mode: MODE.has(key) ? 'on' : 'off',
256
+ changed: args.action === 'enter' ? !outcome.wasOn : outcome.wasOn,
257
+ changedBy: 'agent',
258
+ reason,
259
+ pausedGoal: outcome.pausedGoal ?? null,
260
+ goalNote: outcome.goalNote ?? null,
261
+ }
262
+ },
263
+ }),
264
+ )
265
+ }
266
+
267
+ ctx.inject(['commands'], (commandCtx) => {
268
+ commandCtx.commands.register({
269
+ name: 'tower',
270
+ description: 'Enter or leave Tower mode, optionally carrying a user instruction',
271
+ input: { hint: '[on|off] [message]', attachments: true },
272
+ handler: async ({ agent, rawInput, attachments }) => {
273
+ const key = agentKey(agent)
274
+ if (!key) return { kind: 'error', text: 'Tower mode requires a live agent.' }
275
+ const { active, message } = parseTowerInput(rawInput)
276
+ const outcome = active
277
+ ? enterMode(ctx, key, agent, 'user', message || null)
278
+ : exitMode(key, 'user', message || null)
279
+
280
+ let forwarded = false
281
+ try {
282
+ forwarded = forwardInstruction(agent, message, attachments)
283
+ } catch (error) {
284
+ return {
285
+ kind: 'error',
286
+ text: `Tower mode changed, but the carried message could not be delivered: ${String(error?.message ?? error)}`,
287
+ }
288
+ }
289
+ return {
290
+ kind: 'success',
291
+ text: describeChange(active, outcome, forwarded, message, config.allowAgentControl),
292
+ }
293
+ },
294
+ })
295
+ })
296
+ }
package/lib/git.js ADDED
@@ -0,0 +1,144 @@
1
+ // Minimal git port: array arguments only, bounded output, no shell.
2
+ import { execFile } from 'node:child_process'
3
+ import path from 'node:path'
4
+
5
+ const GIT_TIMEOUT_MS = 15000
6
+ const MAX_BUFFER = 16 * 1024 * 1024
7
+
8
+ export class GitError extends Error {
9
+ constructor(args, detail, code) {
10
+ super(`git ${args.join(' ')} failed: ${detail}`)
11
+ this.name = 'GitError'
12
+ this.code = code
13
+ }
14
+ }
15
+
16
+ export function git(cwd, args, options = {}) {
17
+ return new Promise((resolve, reject) => {
18
+ execFile(
19
+ 'git',
20
+ args,
21
+ {
22
+ cwd,
23
+ timeout: options.timeoutMs ?? GIT_TIMEOUT_MS,
24
+ maxBuffer: options.maxBuffer ?? MAX_BUFFER,
25
+ windowsHide: true,
26
+ env: process.env,
27
+ },
28
+ (error, stdout, stderr) => {
29
+ if (error) {
30
+ reject(
31
+ new GitError(
32
+ args,
33
+ String(stderr || error.message || '').trim(),
34
+ typeof error.code === 'number' ? error.code : undefined,
35
+ ),
36
+ )
37
+ return
38
+ }
39
+ resolve(String(stdout).replace(/\r\n/g, '\n').trimEnd())
40
+ },
41
+ )
42
+ })
43
+ }
44
+
45
+ export async function tryGit(cwd, args, options) {
46
+ try {
47
+ return await git(cwd, args, options)
48
+ } catch {
49
+ return null
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Resolve an absolute caller path to the repository root, its common git dir
55
+ * (shared by all worktrees), and the current branch of that checkout.
56
+ */
57
+ export async function resolveRepo(repoRoot) {
58
+ if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
59
+ throw new Error('repoRoot is required')
60
+ }
61
+ if (!path.isAbsolute(repoRoot)) {
62
+ throw new Error(`repoRoot must be an absolute path: ${repoRoot}`)
63
+ }
64
+ const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
65
+ const root = path.resolve(top)
66
+ const commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
67
+ const commonDir = path.isAbsolute(commonRaw)
68
+ ? path.resolve(commonRaw)
69
+ : path.resolve(root, commonRaw)
70
+ const branch = await tryGit(root, ['symbolic-ref', '--short', '-q', 'HEAD'])
71
+ return {
72
+ root,
73
+ commonDir,
74
+ branch: branch && branch.trim() ? branch.trim() : null,
75
+ }
76
+ }
77
+
78
+ export async function hasAnyCommit(root) {
79
+ const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
80
+ return Boolean(out && out.trim())
81
+ }
82
+
83
+ export async function localBranchExists(root, branch) {
84
+ const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
85
+ return out !== null
86
+ }
87
+
88
+ export async function revParse(cwd, ref) {
89
+ const out = await git(cwd, ['rev-parse', ref])
90
+ return out.trim()
91
+ }
92
+
93
+ export async function branchExists(cwd, branch) {
94
+ return (await localBranchExists(cwd, branch)) === true
95
+ }
96
+
97
+ export async function currentBranch(cwd) {
98
+ const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
99
+ return out && out.trim() ? out.trim() : null
100
+ }
101
+
102
+ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
103
+ await git(cwd, ['worktree', 'add', '-b', branch, worktreePath, commit])
104
+ return worktreePath
105
+ }
106
+
107
+ export async function listWorktrees(cwd) {
108
+ const out = await git(cwd, ['worktree', 'list', '--porcelain'])
109
+ const entries = []
110
+ let current = null
111
+ for (const line of out.split('\n')) {
112
+ if (line.startsWith('worktree ')) {
113
+ if (current) entries.push(current)
114
+ current = { path: line.slice('worktree '.length) }
115
+ } else if (current && line.startsWith('branch ')) {
116
+ current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
117
+ } else if (current && line.startsWith('HEAD ')) {
118
+ current.head = line.slice('HEAD '.length)
119
+ }
120
+ }
121
+ if (current) entries.push(current)
122
+ return entries
123
+ }
124
+
125
+ export async function isClean(cwd) {
126
+ const out = await git(cwd, ['status', '--porcelain'])
127
+ return out.trim().length === 0
128
+ }
129
+
130
+ export async function currentCommonDir(cwd) {
131
+ const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
132
+ if (raw === null) return null
133
+ return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
134
+ }
135
+
136
+ export async function countCommits(cwd, fromRef) {
137
+ const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
138
+ return Number(out.trim() || 0)
139
+ }
140
+
141
+ export async function isAncestor(cwd, ancestor, descendant) {
142
+ const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
143
+ return out !== null
144
+ }
@@ -0,0 +1,68 @@
1
+ // Tower mode persistence.
2
+ //
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.
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+
11
+ const MAX_ENTRIES = 200
12
+
13
+ function storeDir() {
14
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
15
+ return path.join(home, 'tower-mode')
16
+ }
17
+
18
+ function storeFile() {
19
+ return path.join(storeDir(), 'modes.json')
20
+ }
21
+
22
+ /** Read persisted modes into a Map keyed by agent/session id. */
23
+ export function loadModes() {
24
+ const modes = new Map()
25
+ try {
26
+ const raw = fs.readFileSync(storeFile(), 'utf8')
27
+ const parsed = JSON.parse(raw)
28
+ const entries = parsed && typeof parsed === 'object' && parsed.modes && typeof parsed.modes === 'object'
29
+ ? parsed.modes
30
+ : {}
31
+ for (const [key, value] of Object.entries(entries)) {
32
+ if (value && value.on === true) {
33
+ modes.set(key, {
34
+ on: true,
35
+ pausedGoal: value.pausedGoal && typeof value.pausedGoal === 'object' ? value.pausedGoal : null,
36
+ lastChange: value.lastChange && typeof value.lastChange === 'object' ? value.lastChange : null,
37
+ at: Number(value.at) || 0,
38
+ })
39
+ }
40
+ }
41
+ } catch {
42
+ // a missing or unreadable store simply starts with no persisted mode
43
+ }
44
+ return modes
45
+ }
46
+
47
+ /** Persist the current modes atomically; failures never break the command. */
48
+ export function saveModes(modes) {
49
+ try {
50
+ const dir = storeDir()
51
+ fs.mkdirSync(dir, { recursive: true })
52
+ const entries = [...modes.entries()]
53
+ .sort((a, b) => (Number(b[1]?.at) || 0) - (Number(a[1]?.at) || 0))
54
+ .slice(0, MAX_ENTRIES)
55
+ const payload = { version: 1, modes: Object.fromEntries(entries) }
56
+ const file = storeFile()
57
+ const tmp = `${file}.tmp-${process.pid}`
58
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
59
+ fs.renameSync(tmp, file)
60
+ return true
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ export function storePath() {
67
+ return storeFile()
68
+ }
package/lib/store.js ADDED
@@ -0,0 +1,123 @@
1
+ // Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
2
+ import fsp from 'node:fs/promises'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+
6
+ export function pathsFor(commonDir) {
7
+ const dir = path.join(commonDir, 'dsh-tower')
8
+ return {
9
+ dir,
10
+ state: path.join(dir, 'state.json'),
11
+ lock: path.join(dir, 'state.lock'),
12
+ activity: path.join(dir, 'activity.jsonl'),
13
+ }
14
+ }
15
+
16
+ export async function exists(file) {
17
+ try {
18
+ await fsp.access(file)
19
+ return true
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ export async function ensureDir(dir) {
26
+ await fsp.mkdir(dir, { recursive: true })
27
+ }
28
+
29
+ export async function readJson(file) {
30
+ const text = await fsp.readFile(file, 'utf8')
31
+ try {
32
+ return JSON.parse(text)
33
+ } catch (error) {
34
+ throw new Error(`corrupt tower state at ${file}: ${error.message}`)
35
+ }
36
+ }
37
+
38
+ export async function writeJsonAtomic(file, value) {
39
+ const dir = path.dirname(file)
40
+ const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
41
+ const handle = await fsp.open(tmp, 'w')
42
+ try {
43
+ await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
44
+ await handle.sync()
45
+ } finally {
46
+ await handle.close()
47
+ }
48
+ await fsp.rename(tmp, file)
49
+ }
50
+
51
+ export async function appendJsonl(file, value) {
52
+ await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
53
+ }
54
+
55
+ export function shortHash(text) {
56
+ return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
57
+ }
58
+
59
+ export async function readActivityTail(file, limit = 12) {
60
+ const text = await fsp.readFile(file, 'utf8').catch(() => '')
61
+ const lines = text.split('\n').filter(Boolean)
62
+ const tail = lines.slice(Math.max(0, lines.length - limit))
63
+ const out = []
64
+ for (const line of tail) {
65
+ try {
66
+ out.push(JSON.parse(line))
67
+ } catch {
68
+ // keep going: a truncated activity line must not hide the rest
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ function sleep(ms) {
75
+ return new Promise((resolve) => setTimeout(resolve, ms))
76
+ }
77
+
78
+ /**
79
+ * Hold the repo-wide tower lock for one mutation. Stale locks are reported,
80
+ * never silently stolen (matches the fail-closed protocol).
81
+ */
82
+ export async function withLock(paths, fn, options = {}) {
83
+ const timeoutMs = options.timeoutMs ?? 5000
84
+ const pollMs = options.pollMs ?? 25
85
+ const token = crypto.randomUUID()
86
+ const record = JSON.stringify({ token, pid: process.pid, at: Date.now() })
87
+ const deadline = Date.now() + timeoutMs
88
+
89
+ for (;;) {
90
+ try {
91
+ await fsp.writeFile(paths.lock, record, { flag: 'wx' })
92
+ break
93
+ } catch (error) {
94
+ if (error.code !== 'EEXIST') throw error
95
+ if (Date.now() > deadline) {
96
+ let owner = null
97
+ try {
98
+ owner = await fsp.readFile(paths.lock, 'utf8')
99
+ } catch {
100
+ owner = null
101
+ }
102
+ throw new Error(
103
+ `tower state is locked by another writer${owner ? ` (${owner})` : ''}; retry after it finishes`,
104
+ )
105
+ }
106
+ await sleep(pollMs)
107
+ }
108
+ }
109
+
110
+ try {
111
+ return await fn(paths)
112
+ } finally {
113
+ try {
114
+ const current = await fsp.readFile(paths.lock, 'utf8').catch(() => null)
115
+ if (current) {
116
+ const parsed = JSON.parse(current)
117
+ if (parsed && parsed.token === token) await fsp.unlink(paths.lock)
118
+ }
119
+ } catch {
120
+ // a lock left behind by a crash is reported by the next writer, not fixed here
121
+ }
122
+ }
123
+ }