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/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +159 -2
- package/README.zh.md +160 -0
- package/cordis.patch.yml +23 -0
- package/icon.svg +7 -0
- package/index.js +296 -0
- package/lib/git.js +144 -0
- package/lib/modeStore.js +68 -0
- package/lib/store.js +123 -0
- package/lib/tools.js +801 -0
- package/lib/tower.js +1507 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
- package/worker.js +41 -0
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
|
+
}
|
package/lib/modeStore.js
ADDED
|
@@ -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
|
+
}
|